Skip to content

Phase/data model migrations#2

Merged
Ryanakml merged 3 commits intomainfrom
phase/data-model-migrations
Mar 6, 2026
Merged

Phase/data model migrations#2
Ryanakml merged 3 commits intomainfrom
phase/data-model-migrations

Conversation

@Ryanakml
Copy link
Copy Markdown
Owner

@Ryanakml Ryanakml commented Mar 6, 2026

Summary by CodeRabbit

  • New Features

    • Added initial database schema with core tables for users, conversations, messages, agent events, tool calls, tickets, and knowledge documents.
  • Chores

    • Enhanced development tooling with improved git hooks, migration validation, and environment checks.
    • Updated dependencies and npm scripts for better project management.
  • Tests

    • Improved environment validation test output with enhanced logging.
  • Style

    • Applied minor code formatting refinements across the codebase.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 6, 2026

📝 Walkthrough

Walkthrough

This pull request introduces database migration validation infrastructure, environment variable configuration management, enhanced development workflows with improved Node detection, initial PostgreSQL schema with core tables, and minor code formatting refinements across the application.

Changes

Cohort / File(s) Summary
Linting & ESLint Configuration
.commitlintrc.cjs, eslint.config.mjs
Added ESLint disable directive to suppress no-undef warnings; introduced new ESLint config block defining readonly globals (process, console) for API and worker source trees.
Git Hooks & Environment Setup
.husky/commit-msg, .husky/pre-commit
Enhanced hook scripts with robust Node detection and PATH initialization: prepends local node_modules/.bin, adds macOS-specific paths, loads NVM as fallback, and exits with error if Node is unavailable.
CI/CD Workflows
.github/workflows/migration-guard.yml
Added new GitHub Actions workflow that triggers on pull_request and push events to validate database migrations via bash script.
Code Formatting
apps/api/src/index.js, apps/dashboard/app/page.tsx
Whitespace and indentation refinements with no behavioral changes; reformatted multiline expressions and JSX quotes for consistency.
TypeScript Configuration
apps/worker/tsconfig.json
Changed rootDir from "./src" to "../..", affecting TypeScript compilation path resolution for the worker application.
Package Management
package.json
Updated commitlint and @commitlint/config-conventional to latest; added tsx dependency; added validate:migrations npm script; updated prepare script from "husky install" to "husky".
Configuration Validation Package
packages/config/src/index.js, packages/config/src/index.d.ts
Created new configuration module exporting environment variable constants (REQUIRED_ENV_VARS, OPTIONAL_ENV_VARS), type definitions, and validateEnv function that validates and trims environment variables.
Configuration Tests
packages/config/src/env.test.ts
Added colored, labeled console output for environment validation test results with success and completion messages.
Development Scripts
scripts/local-dev-up.sh, scripts/local-dev-down.sh
Enhanced scripts with colored logging utilities, pre-checks for docker-compose files, OpenTelemetry profile activation, and conditional database seeding with user-friendly feedback.
Migration Validation Script
scripts/validate-migrations.sh
Added new Bash script that validates database migrations: enforces forward-only migrations, validates filename format (YYYYMMDDHHMM_name.sql), checks timestamp uniqueness and strict ordering, with colored error/success output.
Database Schema & Indexes
supabase/migrations/202603061200_core_schema.sql, supabase/migrations/202603061230_d2_indexes_retention.sql
Introduced initial PostgreSQL schema with core tables (users, conversations, messages, agent_events, tool_calls, tickets, knowledge_documents, knowledge_chunks) including constraints and relationships; added conditional indexes for query optimization on common access patterns.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐰 Migrations hop through numbered paths so straight,
Schemas whisper of users, chats, and fate,
Husky guards the gate with Node's embrace,
While validation dances through config space! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Phase/data model migrations' accurately reflects the main changes, which include database schema migrations, environment validation configuration, and infrastructure setup updates.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch phase/data-model-migrations

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (5)
.husky/pre-commit (1)

3-24: Extract the Node bootstrap into a shared hook helper.

Lines 3-24 are duplicated verbatim in .husky/commit-msg. Keeping two copies of the same PATH/NVM bootstrap will drift the next time hook startup logic changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.husky/pre-commit around lines 3 - 24, The PATH/NVM bootstrap block
duplicated in .husky/pre-commit (the PATH/NVM bootstrap lines) should be
extracted into a single shared hook helper (e.g., create a helper file like
.husky/_node_bootstrap or .husky/helpers/node-bootstrap.sh), move the entire
bootstrap logic into that helper, mark it executable, and replace the original
block in both .husky/pre-commit and .husky/commit-msg with a short source/.`.`
call to the helper so both hooks reuse the same startup logic and avoid drift.
scripts/validate-migrations.sh (1)

66-68: Make the regex match the documented timestamp policy.

The message says YYYYMMDDHHMM_migration_name.sql, but the regex accepts 12-14 digits. Either enforce one width here or update the help text so contributors aren't following a different rule than the validator.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/validate-migrations.sh` around lines 66 - 68, The filename validation
currently allows 12–14 digits but the user-facing help says YYYYMMDDHHMM (12
digits); update the validator to enforce the documented 12-digit timestamp by
changing the regex used to validate $file_name (the existing pattern in the if
condition) to require exactly 12 digits (^[0-9]{12}_[a-z0-9_]+\.sql$), and leave
the error messages (log_error and the echo "Expected: ...") unchanged so they
match the enforced rule.
package.json (1)

23-26: Use explicit semver ranges instead of latest for better reproducibility.

While the committed package-lock.json does mitigate most of the risk by locking versions after the initial install, using explicit versions in package.json is still a best practice. It makes dependency updates intentional and reduces the chance of unexpected version changes if the lockfile is accidentally regenerated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 23 - 26, The package.json currently uses the
"latest" tag for dependencies ("@commitlint/config-conventional" and
"commitlint"), which is brittle; replace each "latest" with an explicit semver
range (for example a caret or tilde range or exact version like ^X.Y.Z) to lock
the intended major/minor behavior, update the package.json entries for
"@commitlint/config-conventional" and "commitlint" accordingly, run your package
manager (npm/yarn/pnpm) to regenerate package-lock.json or yarn.lock, verify
installs, and commit both package.json and the updated lockfile.
supabase/migrations/202603061200_core_schema.sql (2)

12-12: updated_at will stay at the insert timestamp unless every writer updates it manually.

default now() is applied when an insert omits the column; it does not automatically advance on later updates. If these columns are meant to be authoritative, add a shared BEFORE UPDATE trigger now rather than depending on every write path to remember updated_at = now(). (postgresql.org)

Also applies to: 23-23, 69-69, 82-82

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/202603061200_core_schema.sql` at line 12, The updated_at
columns are defined with "default now()" which only sets the value on INSERT, so
add a PostgreSQL BEFORE UPDATE trigger to set updated_at = now() automatically
on any row update; create a small immutable function (e.g., set_updated_at() or
fn_set_updated_at) that assigns NEW.updated_at = now() and attach a trigger
(e.g., trg_set_updated_at) to the affected tables to run BEFORE UPDATE; apply
this change for all occurrences of the updated_at column in this migration (the
lines referenced at 12, 23, 69, 82) so writers don't have to set updated_at
manually.

13-13: Remove the duplicate phone_number index from the follow-up migration.

Line 13 already creates the backing unique index for users_phone_number_unique. The later create index if not exists users_phone_number_unique on users (phone_number) in supabase/migrations/202603061230_d2_indexes_retention.sql, Line 3, is redundant, and IF NOT EXISTS only checks whether that name already exists, not whether the existing object matches the intended definition. (postgresql.org)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/202603061200_core_schema.sql` at line 13, Remove the
redundant index creation that duplicates the unique constraint backing index:
the migration later containing "create index if not exists
users_phone_number_unique on users (phone_number)" should be removed (or
replaced) because the unique constraint "constraint users_phone_number_unique
unique (phone_number)" already created the backing index; if you intended a
different index definition, rename the index and adjust the definition instead
of reusing the same name so you don't mask mismatched objects.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/src/index.js`:
- Line 30: adminRateLimitStore is never pruned so one-off IPs cause unbounded
memory growth; add expiry logic: when creating/updating an entry in
adminRateLimitStore store a timestamp (or schedule a setTimeout) and either (a)
attach a per-key timeout that deletes the Map entry after the rate-window
elapses (clear and reschedule the timeout on updates), or (b) add a lightweight
lastSeen timestamp and run a periodic sweep (setInterval) to delete entries
older than the window; apply the same fix to the other rate-limit Maps
referenced in the diff (the other rate-limit stores at lines 55–58) so entries
for expired windows are removed.
- Around line 7-29: The code reads raw process.env for optional configs
(allowInsecureHttp, trustProxy, adminIpAllowlist, adminAuthHeader,
adminRoleHeader, adminAllowedRoles, and numeric parsing via parseNumber for
adminRateLimitWindowMs/adminRateLimitMax) which bypasses validateEnv()’s
trimming/normalization; update these to read from the normalized env object
returned by validateEnv() (use the same env reference used for PORT), e.g.
replace process.env.* usages with env.* and keep existing helpers like
parseNumber and split/trim/filter logic but applied to env values so flags like
"true " behave consistently.
- Around line 34-40: isHttpsRequest currently reads
req.headers['x-forwarded-proto'] directly which allows header spoofing when
Express trust proxy is disabled; change it to rely on Express's protocol/trust
settings by returning req.protocol === 'https' (which respects app.get('trust
proxy')) or, if you must inspect headers directly, only read
req.headers['x-forwarded-proto'] when req.app && req.app.get('trust proxy') is
truthy; update the isHttpsRequest function to use req.protocol === 'https' with
a fallback to req.secure or the trusted header check guarded by
req.app.get('trust proxy').

In `@apps/worker/tsconfig.json`:
- Line 11: The tsconfig.json change setting "rootDir": "../.." causes worker
emits to be placed under dist/apps/worker/src/* so the built entrypoints
(apps/worker/src/index.ts -> dist/index.js expected by package.json
main/types/start) are missing; revert or remove the rootDir change (restore
default rootDir or set it to the worker's src) so compiled output places
index.ts at dist/index.js, and instead fix the dependency on `@wa-chat/config` by
adding a workspace/project reference or proper build step for `@wa-chat/config`
(use TypeScript project references or the monorepo workspace build) rather than
widening rootDir.

In `@scripts/local-dev-down.sh`:
- Around line 9-43: The script contains a stale earlier docker compose down
invocation that runs before the new guard/logging block; remove the
duplicate/stale invocation so the script only validates COMPOSE_FILE existence
and then runs docker compose -f "$COMPOSE_FILE" down -v once. Specifically, keep
the guard and logging functions (log_info, log_error, log_success), ensure
COMPOSE_FILE is set, check [[ -f "$COMPOSE_FILE" ]] and exit with an error via
log_error if missing, then call docker compose -f "$COMPOSE_FILE" down -v and
log_success; delete any earlier standalone docker compose -f "$COMPOSE_FILE"
down -v call so it doesn't run before the guard.

In `@scripts/local-dev-up.sh`:
- Around line 51-54: The seeding step runs immediately after docker compose up
-d and races with container startup; update the block that checks DATABASE_URL
and calls "$ROOT_DIR/scripts/seed-local-db.sh" to first wait for the database to
accept connections (e.g. after the docker compose up -d call) by implementing a
retry loop that probes the DB (use pg_isready or a psql -c '\q' attempt against
$DATABASE_URL or an equivalent TCP check), sleep between retries, and enforce a
reasonable timeout; only invoke "$ROOT_DIR/scripts/seed-local-db.sh" when the
probe succeeds, and fail with a clear error if the timeout is reached.

In `@scripts/validate-migrations.sh`:
- Around line 58-60: The guard in validate-migrations.sh that checks file_name
only rejects names that end with .down.sql or .rollback.sql but misses filenames
containing _down or _rollback earlier (e.g. 202603061230_down_users.sql); update
the condition around file_name to detect any occurrence of "down" or "rollback"
immediately before the .sql extension or preceded by a separator anywhere in the
name (use a single regex that matches
(.down\.sql$|.rollback\.sql$|[_-]down\.sql|[_-]rollback\.sql) or equivalent), so
the if block that calls log_error "Rollback/down migration detected: $file_name"
will catch those variants; modify the existing if that references file_name in
validate-migrations.sh accordingly.

In `@supabase/migrations/202603061200_core_schema.sql`:
- Line 28: Add a UNIQUE constraint on messages(id, conversation_id) and update
the foreign keys in agent_events and tool_calls to be composite (message_id,
conversation_id) referencing messages(id, conversation_id) to prevent
message/conversation mismatch; specifically, alter the messages table to add
UNIQUE (id, conversation_id) and replace the current single-column FKs in
agent_events and tool_calls with composite FK clauses referencing messages(id,
conversation_id), or alternatively remove conversation_id from agent_events and
tool_calls and rely solely on message_id -> messages(id) to derive
conversation_id.
- Around line 6-14: The migration creates new public tables (e.g., users)
without enabling Row Level Security; add an explicit ALTER TABLE <table_name>
ENABLE ROW LEVEL SECURITY for each created table (start with users) and then add
appropriate CREATE POLICY statements (e.g., policies to allow authenticated
users to access their own rows and any admin-safe reads/writes) for each table;
update the migration to include these ALTER TABLE ... ENABLE ROW LEVEL SECURITY
and corresponding CREATE POLICY ... FOR SELECT/INSERT/UPDATE/DELETE statements
for every table created by this file so RLS is active when the schema is exposed
to clients.

In `@supabase/migrations/202603061230_d2_indexes_retention.sql`:
- Around line 1-10: The migration wraps index creation in a transaction
(BEGIN/COMMIT), which prevents using concurrent builds and will block writes;
remove the transaction wrapper and change each statement to use CREATE INDEX
CONCURRENTLY for the production tables by replacing the existing statements that
create users_phone_number_unique, conversations_user_status_created_at_idx,
messages_conversation_created_at_idx, agent_events_conversation_created_at_idx,
tool_calls_conversation_status_idx, and tickets_status_created_at_idx with
CREATE INDEX CONCURRENTLY variants and ensure no BEGIN/COMMIT remains in the
file so PostgreSQL can execute them outside a transaction.

---

Nitpick comments:
In @.husky/pre-commit:
- Around line 3-24: The PATH/NVM bootstrap block duplicated in .husky/pre-commit
(the PATH/NVM bootstrap lines) should be extracted into a single shared hook
helper (e.g., create a helper file like .husky/_node_bootstrap or
.husky/helpers/node-bootstrap.sh), move the entire bootstrap logic into that
helper, mark it executable, and replace the original block in both
.husky/pre-commit and .husky/commit-msg with a short source/.`.` call to the
helper so both hooks reuse the same startup logic and avoid drift.

In `@package.json`:
- Around line 23-26: The package.json currently uses the "latest" tag for
dependencies ("@commitlint/config-conventional" and "commitlint"), which is
brittle; replace each "latest" with an explicit semver range (for example a
caret or tilde range or exact version like ^X.Y.Z) to lock the intended
major/minor behavior, update the package.json entries for
"@commitlint/config-conventional" and "commitlint" accordingly, run your package
manager (npm/yarn/pnpm) to regenerate package-lock.json or yarn.lock, verify
installs, and commit both package.json and the updated lockfile.

In `@scripts/validate-migrations.sh`:
- Around line 66-68: The filename validation currently allows 12–14 digits but
the user-facing help says YYYYMMDDHHMM (12 digits); update the validator to
enforce the documented 12-digit timestamp by changing the regex used to validate
$file_name (the existing pattern in the if condition) to require exactly 12
digits (^[0-9]{12}_[a-z0-9_]+\.sql$), and leave the error messages (log_error
and the echo "Expected: ...") unchanged so they match the enforced rule.

In `@supabase/migrations/202603061200_core_schema.sql`:
- Line 12: The updated_at columns are defined with "default now()" which only
sets the value on INSERT, so add a PostgreSQL BEFORE UPDATE trigger to set
updated_at = now() automatically on any row update; create a small immutable
function (e.g., set_updated_at() or fn_set_updated_at) that assigns
NEW.updated_at = now() and attach a trigger (e.g., trg_set_updated_at) to the
affected tables to run BEFORE UPDATE; apply this change for all occurrences of
the updated_at column in this migration (the lines referenced at 12, 23, 69, 82)
so writers don't have to set updated_at manually.
- Line 13: Remove the redundant index creation that duplicates the unique
constraint backing index: the migration later containing "create index if not
exists users_phone_number_unique on users (phone_number)" should be removed (or
replaced) because the unique constraint "constraint users_phone_number_unique
unique (phone_number)" already created the backing index; if you intended a
different index definition, rename the index and adjust the definition instead
of reusing the same name so you don't mask mismatched objects.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4cbc5c4b-7492-4c37-9fe5-b389acb707eb

📥 Commits

Reviewing files that changed from the base of the PR and between 1859971 and f114c9e.

⛔ Files ignored due to path filters (3)
  • package-lock.json is excluded by !**/package-lock.json
  • packages/config/src/index.d.ts.map is excluded by !**/*.map
  • packages/config/src/index.js.map is excluded by !**/*.map
📒 Files selected for processing (17)
  • .commitlintrc.cjs
  • .github/workflows/migration-guard.yml
  • .husky/commit-msg
  • .husky/pre-commit
  • apps/api/src/index.js
  • apps/dashboard/app/page.tsx
  • apps/worker/tsconfig.json
  • eslint.config.mjs
  • package.json
  • packages/config/src/env.test.ts
  • packages/config/src/index.d.ts
  • packages/config/src/index.js
  • scripts/local-dev-down.sh
  • scripts/local-dev-up.sh
  • scripts/validate-migrations.sh
  • supabase/migrations/202603061200_core_schema.sql
  • supabase/migrations/202603061230_d2_indexes_retention.sql

Comment on lines 7 to +29
const allowInsecureHttp = process.env.ALLOW_INSECURE_HTTP === 'true';
const trustProxy = process.env.TRUST_PROXY === 'true';
const adminIpAllowlist = process.env.ADMIN_IP_ALLOWLIST
? process.env.ADMIN_IP_ALLOWLIST.split(',')
.map((value) => value.trim())
.filter(Boolean)
: [];
? process.env.ADMIN_IP_ALLOWLIST.split(',')
.map((value) => value.trim())
.filter(Boolean)
: [];
const parseNumber = (value, fallback) => {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const adminRateLimitWindowMs = parseNumber(process.env.ADMIN_RATE_LIMIT_WINDOW_MS, 60_000);
const adminRateLimitMax = parseNumber(process.env.ADMIN_RATE_LIMIT_MAX, 30);
const adminAuthHeader = process.env.ADMIN_AUTH_HEADER?.trim() || 'x-wa-user';
const adminRoleHeader = process.env.ADMIN_ROLE_HEADER?.trim() || 'x-wa-role';
const adminAllowedRoles = process.env.ADMIN_ALLOWED_ROLES
? process.env.ADMIN_ALLOWED_ROLES.split(',')
.map((value) => value.trim())
.filter(Boolean)
: ['admin'];
? process.env.ADMIN_ALLOWED_ROLES.split(',')
.map((value) => value.trim())
.filter(Boolean)
: ['admin'];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the normalized env object for optional config too.

validateEnv() trims optional variables, but these lines still read raw process.env. That means flags like TRUST_PROXY="true " or ALLOW_INSECURE_HTTP="true " won't behave consistently with PORT on Line 85.

♻️ Proposed fix
-const allowInsecureHttp = process.env.ALLOW_INSECURE_HTTP === 'true';
-const trustProxy = process.env.TRUST_PROXY === 'true';
-const adminIpAllowlist = process.env.ADMIN_IP_ALLOWLIST
-    ? process.env.ADMIN_IP_ALLOWLIST.split(',')
+const allowInsecureHttp = env.ALLOW_INSECURE_HTTP === 'true';
+const trustProxy = env.TRUST_PROXY === 'true';
+const adminIpAllowlist = env.ADMIN_IP_ALLOWLIST
+    ? env.ADMIN_IP_ALLOWLIST.split(',')
         .map((value) => value.trim())
         .filter(Boolean)
     : [];
@@
-const adminRateLimitWindowMs = parseNumber(process.env.ADMIN_RATE_LIMIT_WINDOW_MS, 60_000);
-const adminRateLimitMax = parseNumber(process.env.ADMIN_RATE_LIMIT_MAX, 30);
-const adminAuthHeader = process.env.ADMIN_AUTH_HEADER?.trim() || 'x-wa-user';
-const adminRoleHeader = process.env.ADMIN_ROLE_HEADER?.trim() || 'x-wa-role';
-const adminAllowedRoles = process.env.ADMIN_ALLOWED_ROLES
-    ? process.env.ADMIN_ALLOWED_ROLES.split(',')
+const adminRateLimitWindowMs = parseNumber(env.ADMIN_RATE_LIMIT_WINDOW_MS, 60_000);
+const adminRateLimitMax = parseNumber(env.ADMIN_RATE_LIMIT_MAX, 30);
+const adminAuthHeader = env.ADMIN_AUTH_HEADER || 'x-wa-user';
+const adminRoleHeader = env.ADMIN_ROLE_HEADER || 'x-wa-role';
+const adminAllowedRoles = env.ADMIN_ALLOWED_ROLES
+    ? env.ADMIN_ALLOWED_ROLES.split(',')
         .map((value) => value.trim())
         .filter(Boolean)
     : ['admin'];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const allowInsecureHttp = process.env.ALLOW_INSECURE_HTTP === 'true';
const trustProxy = process.env.TRUST_PROXY === 'true';
const adminIpAllowlist = process.env.ADMIN_IP_ALLOWLIST
? process.env.ADMIN_IP_ALLOWLIST.split(',')
.map((value) => value.trim())
.filter(Boolean)
: [];
? process.env.ADMIN_IP_ALLOWLIST.split(',')
.map((value) => value.trim())
.filter(Boolean)
: [];
const parseNumber = (value, fallback) => {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const adminRateLimitWindowMs = parseNumber(process.env.ADMIN_RATE_LIMIT_WINDOW_MS, 60_000);
const adminRateLimitMax = parseNumber(process.env.ADMIN_RATE_LIMIT_MAX, 30);
const adminAuthHeader = process.env.ADMIN_AUTH_HEADER?.trim() || 'x-wa-user';
const adminRoleHeader = process.env.ADMIN_ROLE_HEADER?.trim() || 'x-wa-role';
const adminAllowedRoles = process.env.ADMIN_ALLOWED_ROLES
? process.env.ADMIN_ALLOWED_ROLES.split(',')
.map((value) => value.trim())
.filter(Boolean)
: ['admin'];
? process.env.ADMIN_ALLOWED_ROLES.split(',')
.map((value) => value.trim())
.filter(Boolean)
: ['admin'];
const allowInsecureHttp = env.ALLOW_INSECURE_HTTP === 'true';
const trustProxy = env.TRUST_PROXY === 'true';
const adminIpAllowlist = env.ADMIN_IP_ALLOWLIST
? env.ADMIN_IP_ALLOWLIST.split(',')
.map((value) => value.trim())
.filter(Boolean)
: [];
const parseNumber = (value, fallback) => {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const adminRateLimitWindowMs = parseNumber(env.ADMIN_RATE_LIMIT_WINDOW_MS, 60_000);
const adminRateLimitMax = parseNumber(env.ADMIN_RATE_LIMIT_MAX, 30);
const adminAuthHeader = env.ADMIN_AUTH_HEADER || 'x-wa-user';
const adminRoleHeader = env.ADMIN_ROLE_HEADER || 'x-wa-role';
const adminAllowedRoles = env.ADMIN_ALLOWED_ROLES
? env.ADMIN_ALLOWED_ROLES.split(',')
.map((value) => value.trim())
.filter(Boolean)
: ['admin'];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.js` around lines 7 - 29, The code reads raw process.env
for optional configs (allowInsecureHttp, trustProxy, adminIpAllowlist,
adminAuthHeader, adminRoleHeader, adminAllowedRoles, and numeric parsing via
parseNumber for adminRateLimitWindowMs/adminRateLimitMax) which bypasses
validateEnv()’s trimming/normalization; update these to read from the normalized
env object returned by validateEnv() (use the same env reference used for PORT),
e.g. replace process.env.* usages with env.* and keep existing helpers like
parseNumber and split/trim/filter logic but applied to env values so flags like
"true " behave consistently.

.map((value) => value.trim())
.filter(Boolean)
: ['admin'];
const adminRateLimitStore = new Map();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Expire old rate-limit entries or this map will grow without bound.

Every first-seen IP gets a Map entry, and expired windows are overwritten for that IP but never removed for one-off IPs. On a public route, that becomes steady memory growth over time.

Also applies to: 55-58

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.js` at line 30, adminRateLimitStore is never pruned so
one-off IPs cause unbounded memory growth; add expiry logic: when
creating/updating an entry in adminRateLimitStore store a timestamp (or schedule
a setTimeout) and either (a) attach a per-key timeout that deletes the Map entry
after the rate-window elapses (clear and reschedule the timeout on updates), or
(b) add a lightweight lastSeen timestamp and run a periodic sweep (setInterval)
to delete entries older than the window; apply the same fix to the other
rate-limit Maps referenced in the diff (the other rate-limit stores at lines
55–58) so entries for expired windows are removed.

Comment on lines 34 to +40
const isHttpsRequest = (req) => {
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string') {
const proto = forwardedProto.split(',')[0] ?? '';
return proto.trim() === 'https';
}
return req.secure;
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string') {
const proto = forwardedProto.split(',')[0] ?? '';
return proto.trim() === 'https';
}
return req.secure;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's see the full context of the file to understand trustProxy and HTTPS middleware setup
cat -n apps/api/src/index.js | head -60

Repository: Ryanakml/ChatFuse

Length of output: 2758


🏁 Script executed:

# Check for trustProxy definition in the file and surrounding code
rg -n "trustProxy|trust.proxy|trust\s+proxy" apps/api/src/index.js -B 2 -A 2

Repository: Ryanakml/ChatFuse

Length of output: 526


🏁 Script executed:

# Search the entire codebase for trustProxy references
rg -n "trustProxy|trust.proxy" --type js

Repository: Ryanakml/ChatFuse

Length of output: 212


🏁 Script executed:

# Check what the 'env' variable contains and how it's set
rg -n "validateEnv|const env" apps/api/src/index.js -B 1 -A 5

Repository: Ryanakml/ChatFuse

Length of output: 462


🌐 Web query:

Express.js trust proxysetting behavior: Doesx-forwarded-protoheader get processed by Express whentrust proxy is set to false or not set?

💡 Result:

No.

  • Default / unset trust proxy (which is false): Express does not use X-Forwarded-Proto when computing req.protocol (and therefore req.secure). req.protocol is derived from whether the connection itself is TLS (https) or not (http). (expressjs.com)
  • Only when trust proxy is enabled (i.e., does not evaluate to false): req.protocol will use the X-Forwarded-Proto header if present. (expressjs.com)

You can still read the raw header manually via req.get('X-Forwarded-Proto'), but Express won’t trust/use it for protocol detection unless trust proxy is enabled. (expressjs.com)

Citations:


🌐 Web query:

Express req.secure property: Is it set to true only when the actual connection is HTTPS, regardless of proxy headers?

💡 Result:

No.

In Express, req.secure is just req.protocol === 'https' (it’s not a separate “check the socket” flag). [1]

  • If trust proxy is not enabled (the default), req.protocol reflects the actual connection to your Express server (TLS vs non-TLS), so req.secure will only be true when the direct connection to Node/Express is HTTPS. [1]
  • If trust proxy is enabled (i.e., it does not evaluate to false), req.protocol will use the X-Forwarded-Proto header when present, so req.secure can become true even if the proxy-to-Express hop is plain HTTP (SSL terminated at the proxy). [1][2]

Because X-Forwarded-Proto can be spoofed unless your proxy strips/overwrites it, Express explicitly warns to ensure your trusted proxy removes/overwrites X-Forwarded-Proto (and related forwarded headers). [2]

Sources:
[1] Express 4.x API Reference — req.secure, req.protocol (expressjs.com)
[2] Express Guide — “Express behind proxies” / trust proxy implications (expressjs.com)

Citations:


Don't trust x-forwarded-proto unless proxy trust is enabled.

isHttpsRequest() manually reads x-forwarded-proto even when trust proxy is disabled. When trustProxy is false (the default), Express does not process this header for protocol detection—only the actual TLS connection matters. By checking the header directly anyway, an attacker can spoof x-forwarded-proto: https and bypass the HTTPS gate on line 43, even when ALLOW_INSECURE_HTTP is false.

🔒 Proposed fix
 const isHttpsRequest = (req) => {
     const forwardedProto = req.headers['x-forwarded-proto'];
-    if (typeof forwardedProto === 'string') {
+    if (trustProxy && typeof forwardedProto === 'string') {
         const proto = forwardedProto.split(',')[0] ?? '';
         return proto.trim() === 'https';
     }
     return req.secure;
 };

Also applies to: 42-48

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.js` around lines 34 - 40, isHttpsRequest currently reads
req.headers['x-forwarded-proto'] directly which allows header spoofing when
Express trust proxy is disabled; change it to rely on Express's protocol/trust
settings by returning req.protocol === 'https' (which respects app.get('trust
proxy')) or, if you must inspect headers directly, only read
req.headers['x-forwarded-proto'] when req.app && req.app.get('trust proxy') is
truthy; update the isHttpsRequest function to use req.protocol === 'https' with
a fallback to req.secure or the trusted header check guarded by
req.app.get('trust proxy').

"@wa-chat/config": ["../../packages/config/src/index.ts"]
},
"rootDir": "./src",
"rootDir": "../..",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cd /tmp && git ls-files apps/worker | head -20

Repository: Ryanakml/ChatFuse

Length of output: 129


🏁 Script executed:

cat -n apps/worker/tsconfig.json

Repository: Ryanakml/ChatFuse

Length of output: 899


🏁 Script executed:

cat -n apps/worker/package.json

Repository: Ryanakml/ChatFuse

Length of output: 587


🏁 Script executed:

python - <<'PY'
import json
from pathlib import Path

pkg_dir = Path("apps/worker")
tsconfig = json.loads(pkg_dir.joinpath("tsconfig.json").read_text())
package_json = json.loads(pkg_dir.joinpath("package.json").read_text())

root_dir = (pkg_dir / tsconfig["compilerOptions"]["rootDir"]).resolve()
out_dir = pkg_dir / tsconfig["compilerOptions"]["outDir"]
src_entry = pkg_dir.joinpath("src/index.ts").resolve()

rel = src_entry.relative_to(root_dir)
expected_js = out_dir / rel.with_suffix(".js")
expected_dts = out_dir / rel.with_suffix(".d.ts")

print("Computed emit root :", root_dir)
print("Source entry       :", src_entry)
print("Expected JS emit   :", expected_js)
print("Expected DTS emit  :", expected_dts)
print("package.json main  :", package_json.get("main"))
print("package.json types :", package_json.get("types"))
print("package.json start :", package_json.get("scripts", {}).get("start"))
PY

Repository: Ryanakml/ChatFuse

Length of output: 405


rootDir: "../.." breaks the worker's emitted entrypoints.

Line 11 moves the emit root to the repo root, so apps/worker/src/index.ts will be emitted under dist/apps/worker/src/* instead of dist/*. apps/worker/package.json still points main, types, and start at dist/index.js, so the built package and npm start will target files that won't exist. If this was added to accommodate @wa-chat/config, fix that dependency boundary with a workspace build/project reference rather than widening the worker's emit root.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/tsconfig.json` at line 11, The tsconfig.json change setting
"rootDir": "../.." causes worker emits to be placed under dist/apps/worker/src/*
so the built entrypoints (apps/worker/src/index.ts -> dist/index.js expected by
package.json main/types/start) are missing; revert or remove the rootDir change
(restore default rootDir or set it to the worker's src) so compiled output
places index.ts at dist/index.js, and instead fix the dependency on
`@wa-chat/config` by adding a workspace/project reference or proper build step for
`@wa-chat/config` (use TypeScript project references or the monorepo workspace
build) rather than widening rootDir.

Comment on lines +9 to +43
#!/usr/bin/env bash
set -euo pipefail

# ---- Terminal Colors ----
RED='\033[31m'
GREEN='\033[32m'
BLUE='\033[34m'
BOLD='\033[1m'
RESET='\033[0m'

log_info() {
echo -e "${BLUE}${BOLD}[INFO]${RESET} $1"
}

log_success() {
echo -e "${GREEN}${BOLD}[OK]${RESET} $1"
}

log_error() {
echo -e "${RED}${BOLD}[ERROR]${RESET} $1" >&2
}

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="$ROOT_DIR/docker-compose.local.yml"

log_info "Stopping local development services"

if [[ ! -f "$COMPOSE_FILE" ]]; then
log_error "docker-compose file not found: $COMPOSE_FILE"
exit 1
fi

docker compose -f "$COMPOSE_FILE" down -v

log_success "Local development stack stopped and volumes removed" No newline at end of file
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove the stale first script block; down -v runs before this new logic.

This new block was appended instead of replacing the existing script. Because the original docker compose -f "$COMPOSE_FILE" down -v at Line 7 still executes first, the new guard/logging here is skipped on failure and down -v runs a second time on the happy path.

Suggested fix
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-COMPOSE_FILE="$ROOT_DIR/docker-compose.local.yml"
-
-docker compose -f "$COMPOSE_FILE" down -v
-
 #!/usr/bin/env bash
 set -euo pipefail
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/local-dev-down.sh` around lines 9 - 43, The script contains a stale
earlier docker compose down invocation that runs before the new guard/logging
block; remove the duplicate/stale invocation so the script only validates
COMPOSE_FILE existence and then runs docker compose -f "$COMPOSE_FILE" down -v
once. Specifically, keep the guard and logging functions (log_info, log_error,
log_success), ensure COMPOSE_FILE is set, check [[ -f "$COMPOSE_FILE" ]] and
exit with an error via log_error if missing, then call docker compose -f
"$COMPOSE_FILE" down -v and log_success; delete any earlier standalone docker
compose -f "$COMPOSE_FILE" down -v call so it doesn't run before the guard.

Comment on lines 51 to +54
if [[ -n "${DATABASE_URL:-}" ]]; then
log_info "Seeding local database"
"$ROOT_DIR/scripts/seed-local-db.sh"
log_success "Database seed completed"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wait for the database to accept connections before seeding.

Line 53 runs the seed script immediately after docker compose ... up -d. That command returns before containers are healthy, so this becomes racey on slower startups and will intermittently fail even though the stack is still coming up.

Suggested fix
 if [[ -n "${DATABASE_URL:-}" ]]; then
   log_info "Seeding local database"
+  until psql "$DATABASE_URL" -c 'select 1' >/dev/null 2>&1; do
+    sleep 1
+  done
   "$ROOT_DIR/scripts/seed-local-db.sh"
   log_success "Database seed completed"
 else
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if [[ -n "${DATABASE_URL:-}" ]]; then
log_info "Seeding local database"
"$ROOT_DIR/scripts/seed-local-db.sh"
log_success "Database seed completed"
if [[ -n "${DATABASE_URL:-}" ]]; then
log_info "Seeding local database"
until psql "$DATABASE_URL" -c 'select 1' >/dev/null 2>&1; do
sleep 1
done
"$ROOT_DIR/scripts/seed-local-db.sh"
log_success "Database seed completed"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/local-dev-up.sh` around lines 51 - 54, The seeding step runs
immediately after docker compose up -d and races with container startup; update
the block that checks DATABASE_URL and calls
"$ROOT_DIR/scripts/seed-local-db.sh" to first wait for the database to accept
connections (e.g. after the docker compose up -d call) by implementing a retry
loop that probes the DB (use pg_isready or a psql -c '\q' attempt against
$DATABASE_URL or an equivalent TCP check), sleep between retries, and enforce a
reasonable timeout; only invoke "$ROOT_DIR/scripts/seed-local-db.sh" when the
probe succeeds, and fail with a clear error if the timeout is reached.

Comment on lines +58 to +60
# Enforce forward-only migrations
if [[ "$file_name" =~ \.down\.sql$ || "$file_name" =~ \.rollback\.sql$ || "$file_name" =~ (_|-)down\.sql$ ]]; then
log_error "Rollback/down migration detected: $file_name"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Rollback filenames can still slip through this guard.

This only rejects names that end with .rollback.sql / .down.sql. Files like 202603061230_rollback_users.sql or 202603061230_down_users.sql still pass the format check on Line 66 and defeat the forward-only rule.

🛡️ Proposed fix
-  if [[ "$file_name" =~ \.down\.sql$ || "$file_name" =~ \.rollback\.sql$ || "$file_name" =~ (_|-)down\.sql$ ]]; then
+  suffix="${file_name#*_}"
+  if [[ "$suffix" =~ (^|[_-])(down|rollback)([_-]|\.sql$) ]]; then
     log_error "Rollback/down migration detected: $file_name"
     invalid=1
     continue
   fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/validate-migrations.sh` around lines 58 - 60, The guard in
validate-migrations.sh that checks file_name only rejects names that end with
.down.sql or .rollback.sql but misses filenames containing _down or _rollback
earlier (e.g. 202603061230_down_users.sql); update the condition around
file_name to detect any occurrence of "down" or "rollback" immediately before
the .sql extension or preceded by a separator anywhere in the name (use a single
regex that matches (.down\.sql$|.rollback\.sql$|[_-]down\.sql|[_-]rollback\.sql)
or equivalent), so the if block that calls log_error "Rollback/down migration
detected: $file_name" will catch those variants; modify the existing if that
references file_name in validate-migrations.sh accordingly.

Comment on lines +6 to +14
create table users (
id uuid primary key default gen_random_uuid(),
phone_number text not null,
display_name text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint users_phone_number_unique unique (phone_number)
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the migration file
cat -n supabase/migrations/202603061200_core_schema.sql | head -100

Repository: Ryanakml/ChatFuse

Length of output: 4430


🌐 Web query:

Supabase row level security automatic enable raw SQL tables public schema

💡 Result:

  • Supabase does not automatically enable RLS for tables you create via raw SQL/migrations, even if they’re in the public schema. RLS being “on by default” only applies to tables created via the Dashboard Table Editor. [1]
  • For SQL-created tables, you must explicitly enable it per table:
    alter table public.your_table enable row level security;
    (Then add policies; once RLS is enabled, API access is blocked until policies allow it.) [1]

“Automatic RLS enable” for new SQL-created tables (DIY)

Supabase/Postgres doesn’t provide a built-in “always enable RLS on CREATE TABLE” switch, but you can implement it with a Postgres event trigger in a migration:

create or replace function public.enable_rls_on_new_public_tables()
returns event_trigger
language plpgsql
as $$
declare
  cmd record;
begin
  for cmd in
    select * from pg_event_trigger_ddl_commands()
  loop
    if cmd.object_type = 'table' and cmd.schema_name = 'public' then
      execute format('alter table %I.%I enable row level security', cmd.schema_name, cmd.object_name);
    end if;
  end loop;
end;
$$;

drop event trigger if exists trg_enable_rls_on_new_public_tables;

create event trigger trg_enable_rls_on_new_public_tables
on ddl_command_end
when tag in ('CREATE TABLE', 'CREATE TABLE AS')
execute function public.enable_rls_on_new_public_tables();

This pattern is commonly used specifically because tables created via SQL/migrations otherwise come up with RLS disabled by default. [2]

Sources: Supabase RLS docs (Table Editor default + ENABLE ROW LEVEL SECURITY) [1]; Supabase community discussion noting SQL-created tables default to RLS disabled / lack of protection [2].

[1] (supabase.com)
[2] (github.com)

Citations:


🌐 Web query:

PostgreSQL unqualified CREATE TABLE default schema public

💡 Result:

In PostgreSQL, an unqualified CREATE TABLE t (...) creates the table in the current schema, which is the first schema in your search_path (not necessarily public). (postgresql.fastware.com)

  • By default, search_path is typically "$user", public (and pg_catalog is searched implicitly), so:
    • if a schema named exactly like your login role exists and is first, the table may be created there;
    • otherwise it will commonly be created in public (if public is in search_path). (postgresql.org)

To ensure unqualified CREATE TABLE goes to public, do one of these:

  • Fully qualify: CREATE TABLE public.t (...);
  • Or set the path: SET search_path = public; (or per-role via ALTER ROLE ... SET search_path = public;). (postgresql.org)

Citations:


Enable RLS on these new public tables before shipping.

Supabase does not automatically enable RLS for tables created via raw SQL migrations—even in the public schema. RLS auto-enablement applies only to tables created through the Supabase Dashboard. Since these unqualified CREATE TABLE statements default to the public schema, each table requires an explicit ALTER TABLE ... ENABLE ROW LEVEL SECURITY statement followed by appropriate policies before the schema is exposed to authenticated clients.

Also applies to: 16-24, 26-38, 40-47, 49-59, 61-72, 74-84, 86-95

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/202603061200_core_schema.sql` around lines 6 - 14, The
migration creates new public tables (e.g., users) without enabling Row Level
Security; add an explicit ALTER TABLE <table_name> ENABLE ROW LEVEL SECURITY for
each created table (start with users) and then add appropriate CREATE POLICY
statements (e.g., policies to allow authenticated users to access their own rows
and any admin-safe reads/writes) for each table; update the migration to include
these ALTER TABLE ... ENABLE ROW LEVEL SECURITY and corresponding CREATE POLICY
... FOR SELECT/INSERT/UPDATE/DELETE statements for every table created by this
file so RLS is active when the schema is exposed to clients.


create table messages (
id uuid primary key default gen_random_uuid(),
conversation_id uuid not null references conversations(id) on delete cascade,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -E "migrations|\.sql" | head -20

Repository: Ryanakml/ChatFuse

Length of output: 226


🏁 Script executed:

cat -n supabase/migrations/202603061200_core_schema.sql

Repository: Ryanakml/ChatFuse

Length of output: 4430


Fix data integrity risk in agent_events and tool_calls foreign keys.

These tables reference conversation_id and message_id independently, allowing invalid states where message_id points to a message in a different conversation. PostgreSQL cannot enforce the pair constraint without a unique key on both columns.

Add a UNIQUE constraint to the messages table on (id, conversation_id), then create composite foreign keys:

  • agent_events (message_id, conversation_id) references messages (id, conversation_id)
  • tool_calls (message_id, conversation_id) references messages (id, conversation_id)

Alternatively, remove the duplicated conversation_id from child tables and derive it through the message_id relationship.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/202603061200_core_schema.sql` at line 28, Add a UNIQUE
constraint on messages(id, conversation_id) and update the foreign keys in
agent_events and tool_calls to be composite (message_id, conversation_id)
referencing messages(id, conversation_id) to prevent message/conversation
mismatch; specifically, alter the messages table to add UNIQUE (id,
conversation_id) and replace the current single-column FKs in agent_events and
tool_calls with composite FK clauses referencing messages(id, conversation_id),
or alternatively remove conversation_id from agent_events and tool_calls and
rely solely on message_id -> messages(id) to derive conversation_id.

Comment on lines +1 to +10
begin;

create index if not exists users_phone_number_unique on users (phone_number);
create index if not exists conversations_user_status_created_at_idx on conversations (user_id, status, created_at);
create index if not exists messages_conversation_created_at_idx on messages (conversation_id, created_at);
create index if not exists agent_events_conversation_created_at_idx on agent_events (conversation_id, created_at);
create index if not exists tool_calls_conversation_status_idx on tool_calls (conversation_id, status);
create index if not exists tickets_status_created_at_idx on tickets (status, created_at);

commit;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

PostgreSQL CREATE INDEX locking behavior locks on tables vs CREATE INDEX CONCURRENTLY

💡 Result:

CREATE INDEX (non-concurrent)

  • Lock on the table: takes a SHARE (ShareLock) on the target table. This blocks writers (INSERT/UPDATE/DELETE/MERGE take ROW EXCLUSIVE which conflicts with SHARE), but does not block plain reads (SELECT takes ACCESS SHARE, which does not conflict with SHARE). [1]
  • Practical effect: reads proceed; writes wait until the index build finishes. [2]

CREATE INDEX CONCURRENTLY

  • Lock on the table: takes SHARE UPDATE EXCLUSIVE (ShareUpdateExclusiveLock) on the target table. This does not conflict with ROW EXCLUSIVE, so concurrent INSERT/UPDATE/DELETE can proceed (but it does conflict with itself and with stronger DDL-ish locks). [1]
  • Practical effect: builds the index without taking locks that prevent concurrent inserts/updates/deletes (unlike standard CREATE INDEX). [2]
  • Cost/behavior differences: requires two table scans and must wait for transactions that could affect index validity; takes longer and cannot run inside a transaction block. [2]

Locks on the index object (not the table)

  • During index creation, PostgreSQL may take an ACCESS EXCLUSIVE lock on the index; with CONCURRENTLY it uses SHARE UPDATE EXCLUSIVE instead (on the index as a whole). [3]

Sources:
[1] PostgreSQL “Explicit Locking” (current) — lock modes and which commands acquire them (incl. CREATE INDEX vs CREATE INDEX CONCURRENTLY). (postgresql.org)
[2] PostgreSQL “CREATE INDEX” (v18) — standard build blocks writes; CONCURRENTLY avoids blocking writes; notes on scans/waits/transaction-block restriction. (postgresql.org)
[3] PostgreSQL “Index Locking Considerations” — index-level locks during creation/reindex and the CONCURRENTLY difference. (postgresql.org)

Citations:


🌐 Web query:

PostgreSQL CREATE INDEX CONCURRENTLY transaction block restriction

💡 Result:

CREATE INDEX CONCURRENTLY cannot be executed inside an explicit transaction block (BEGIN … COMMIT), and PostgreSQL will error with:

ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block [1][2]

Why: a concurrent index build is implemented as multiple internal transactions (catalog entry + multiple table scans + waits for other transactions/snapshots), so PostgreSQL forbids wrapping it in a user transaction block [1].

How to run it: execute it as a standalone statement in autocommit mode (i.e., not between BEGIN/COMMIT) [1][2]. If your migration tool wraps migrations in a transaction, you must use its “no-transaction / autocommit” mode for that statement [2].

Related operational note: if it fails, it may leave an INVALID index behind, which you typically DROP and retry [1].

Sources: PostgreSQL docs (CREATE INDEX, “Building Indexes Concurrently”) [1]; Cybertec write-up demonstrating the exact error and reasoning [2].


Remove transaction wrapper and use CREATE INDEX CONCURRENTLY for production tables.

The migration creates indexes inside BEGIN/COMMIT, forcing non-concurrent builds that take a SHARE lock on the affected tables. This blocks all writes (INSERT/UPDATE/DELETE) until the index build completes—a serious risk for active tables like conversations, messages, agent_events, tool_calls, and tickets.

PostgreSQL provides CREATE INDEX CONCURRENTLY, which uses a weaker lock (SHARE UPDATE EXCLUSIVE) that does not conflict with writes. However, CREATE INDEX CONCURRENTLY cannot run inside a transaction block and will error if wrapped in BEGIN/COMMIT.

Remove the transaction wrapper and execute the index creation statements outside any explicit transaction:

🛠️ Corrected migration
-begin;
-
 create index concurrently if not exists conversations_user_status_created_at_idx on conversations (user_id, status, created_at);
 create index concurrently if not exists messages_conversation_created_at_idx on messages (conversation_id, created_at);
 create index concurrently if not exists agent_events_conversation_created_at_idx on agent_events (conversation_id, created_at);
 create index concurrently if not exists tool_calls_conversation_status_idx on tool_calls (conversation_id, status);
 create index concurrently if not exists tickets_status_created_at_idx on tickets (status, created_at);
-
-commit;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/202603061230_d2_indexes_retention.sql` around lines 1 -
10, The migration wraps index creation in a transaction (BEGIN/COMMIT), which
prevents using concurrent builds and will block writes; remove the transaction
wrapper and change each statement to use CREATE INDEX CONCURRENTLY for the
production tables by replacing the existing statements that create
users_phone_number_unique, conversations_user_status_created_at_idx,
messages_conversation_created_at_idx, agent_events_conversation_created_at_idx,
tool_calls_conversation_status_idx, and tickets_status_created_at_idx with
CREATE INDEX CONCURRENTLY variants and ensure no BEGIN/COMMIT remains in the
file so PostgreSQL can execute them outside a transaction.

@Ryanakml Ryanakml merged commit ac16171 into main Mar 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant