Skip to content

feat(nodejs): add a build-time migration manifest and schema check - #128242

Merged
trunk-io[bot] merged 2 commits into
masterfrom
feat/devex-node-schema-check
Nov 4, 2026
Merged

trunk-io[bot] merged 2 commits into
masterfrom
feat/devex-node-schema-check

Conversation

@webjunkie

@webjunkie webjunkie commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Changes

  • Each Node image now carries nodejs/schema-requirements.json: the Django migrations its code needs, derived at build time from the tables nodejs/src names. No Python or Django in the build.
  • node nodejs/dist/schema-check/cli.js checks that list against django_migrations on the primary. A missing manifest fails, never passes.
  • The manifest also lists the sqlx databases Node reads (persons, cyclotron-node, behavioral-cohorts). The CLI checks them in shadow mode: it logs the result and never blocks.
  • Each entry has a kind, and an unknown kind fails the parse.
  • Nothing runs the CLI yet. The deploy hook that calls it ships disabled in PostHog/charts#19305.
  • Mechanical: the DATABASE_URL and behavioral cohorts URL builders move to common/config/database-url.ts.

How did you test this code?

Test rationale: fixtures catch scope regressions (label overrides, moved models, squashes, excluded databases), and a real-repo case keeps workflows_teamworkflowsconfig in scope.

  • Ran the CLI against local Postgres: a missing manifest exits 2, and a stale DB exits 1. The sqlx shadow lines report satisfied, missing and error, and the exit code stays the same.
  • The replaces parsing matches Python's ast for all 72 squash migrations in the repo.
  • CI built the Node image, and the generator step wrote the manifest.
  • Not run: a check through PgBouncer.

Release status

  • No feature flag controls this change

Docs update

  • safe-django-migrations.md gains a rule: migrate before Node or Rust code reads the column.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Opus 5.5 (orchestrator and workflow subagents)

Skills: /workflow-authoring, /writing-pr-descriptions, /writing-tests, /writing-code-comments

More context for AI agents
  • Session: Claude Code workflow with build, blind adversarial review and fix agents for this repo and the charts repo.
  • Decisions: rejected a deploy-pipeline wait, because it does not carry over to Kargo.
  • Decisions: rejected a startup check in the Node pod, because of startup-probe crash loops.
  • Decisions: rejected a single schema version number, because it passes wrongly after reverts and manual unapplies.
  • Decisions: statement_timeout goes through set_config in a transaction, because PgBouncer rejects it as a startup parameter.
  • Decisions: sqlx stays shadow-only until logs show grants and timing work. Then the CLI enforces it.
  • Follow-ups: table-level scope to cut waits, Rust services as consumers, and enforcing sqlx.
  • Read closely: the bind-mount step in Dockerfile.node, nodejs/src/schema-check/derive-requirements.ts, and postgres-migration-store.ts.

…heck

Node images deploy before the Django image that runs bin/migrate, so a Node
release that reads a new column can start while the column is still missing.
After #127579 that meant "column does not exist" errors until Django caught up.

- Dockerfile.node writes nodejs/schema-requirements.json at build time. The
  generator scans nodejs/src for Django table names, maps each table to its
  owning app from the migration files (CreateModel, db_table, AlterModelTable,
  so moved models count for both apps) and records each scoped app's
  max_migration.txt leaf. Squash leaves also record their replaces list.
  ClickHouse and apps routed in products/db_routing.yaml are skipped.
- Bind mounts feed the migration trees to the generator, so no Python, no
  Django and no extra layer in the Node build. The step lives in the
  Dockerfile so every build of Dockerfile.node gets the file.
- nodejs/dist/schema-check/cli.js reads the manifest and checks
  django_migrations on the primary (DATABASE_URL or the POSTHOG_DB_* parts,
  never the replica). Exit 0 applied, 1 missing, 2 cannot check.
  --wait-seconds polls. statement_timeout goes through set_config inside a
  transaction because PgBouncer rejects it as a startup parameter.
- The DATABASE_URL builder moves to common/config/database-url.ts so the CLI
  reuses it without loading the full service config.
- safe-django-migrations.md: Node/Rust readers of a new column need the
  migration live first; drops stay code-first.

The deploy hook that runs the CLI lives in the charts repo and ships disabled.
Hook timing and which apps enable it stay a cloud-platform call.
@webjunkie webjunkie self-assigned this Nov 3, 2026
@trunk-io

trunk-io Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

😎 Merged successfully - details.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

✅ Duplication (Python) — clean

New Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

✅ Duplication (TypeScript) — clean

New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

⚠️ Comment density — 3% of added code lines are comments (37 of 1170)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
nodejs/src/schema-check/derive-requirements.ts 12 228
nodejs/src/schema-check/postgres-migration-store.ts 7 74
nodejs/src/schema-check/check.ts 6 143
nodejs/src/schema-check/manifest.ts 6 136
nodejs/src/schema-check/cli.ts 3 202
nodejs/src/schema-check/derive-requirements.test.ts 2 114
nodejs/src/schema-check/check.test.ts 1 185

This check does not block merging. It updates on every push and clears when the share drops.

@webjunkie

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 5ec4d49700

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-03T13:58:24.887241Z 5ec4d49 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…mode

Node also reads tables in the persons, cyclotron-node and behavioral-cohorts
databases. sqlx-migrate deploys those migrations as its own app, so nothing
orders them against a Node rollout either. Recording them now settles the
manifest format before anything depends on it.

- Every requirement carries a kind ("django" or "sqlx"). The parser rejects an
  unknown kind, because skipping it would pass a check nothing ran.
- The generator reads CREATE TABLE statements in the three sqlx migration
  directories and adds the latest version of each database whose tables
  nodejs/src names. Dockerfile.node bind-mounts those directories.
- The CLI checks sqlx entries once after the Django wait, in shadow mode: it
  logs satisfied, missing, dirty (a success = false row), skipped (no URL in
  this app's env) or error, and never changes the exit code. Enforcing comes
  later, once the logs show the grants and timing work.
- The behavioral cohorts URL builder moves next to the main one so the CLI
  reuses it. Behavior is unchanged.
- The digest stays an identifier for logs. Consumers do not recompute it.
@webjunkie
webjunkie marked this pull request as ready for review November 4, 2026 07:49
@parameterai

parameterai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Risk: Low · 1 low

Adds a build-time manifest of the Django/sqlx migrations Node code depends on, plus a deploy-time CLI that checks them against django_migrations (Django enforced, sqlx shadow-only), and refactors the two database-URL builders into common/config/database-url.ts with unchanged behavior. No attacker-facing surface is introduced — the CLI reads only env vars and a build artifact, all SQL is parameterized, and every error path fails closed. The main risk is that the requirement derivation is fail-open: migrations that alter another app's table never enter the manifest, so the gate can pass while a needed column is missing.

Sentinel reviewed f5fe6b1 · Review settings

}

// A table belongs to every app whose migrations name it, so a model moved between apps puts both apps in scope.
export function collectTableOwners(apps: DjangoApp[]): Map<string, Set<string>> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Schema gate silently passes when a migration from another app adds a column Node reads

The deploy gate can report "all required migrations applied" while a migration Node needs is still unapplied, so the rollout proceeds into the exact column does not exist failure this PR exists to prevent. collectTableOwners (nodejs/src/schema-check/derive-requirements.ts:126-151) attributes a table only to apps whose migrations contain CreateModel, db_table, or table=; a migration that only alters another app's table records no ownership, so the altering app never gets a manifest requirement.

How:

  1. An app (e.g. ee) ships a migration adding a column to a table owned by the posthog app — ee/migrations/0006_event_definition_verification.py does exactly this shape (AddField on enterpriseeventdefinition, table posthog_eventdefinition, no db_table/CreateModel).
  2. Node code starts reading that column; posthog_eventdefinition appears in nodejs/src, which puts only the posthog app in the manifest (its leaf migration), never ee.
  3. Node deploys before the Django image runs bin/migrate; the ee migration is unapplied.
  4. The check queries only the posthog requirement, sees it applied, exits 0, and the rollout proceeds against the missing column.

The same heuristic also misses tables whose names Node builds dynamically (collectNodeIdentifiers at line ~166 matches literal identifiers only). No guard in derive-requirements.ts or manifest.ts captures cross-app alterations. Fix: also attribute a table to any app whose migrations reference its model (AddField/AlterField on model_name, and RunSQL bodies), or have the derivation take an explicit per-table ownership map instead of inferring it.


React with 👍 if useful or 👎 if not

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Does not apply. Django migration operations only touch models of their own app, so AddField(model_name="enterpriseeventdefinition") in ee changes ee_enterpriseeventdefinition (multi-table inheritance), not posthog_eventdefinition. A cross-app change would need RunSQL, and a scan of every default-database migration finds no ALTER TABLE on a table another app owns. Table names that Node builds at runtime are a known limit of the text match.

@trunk-io
trunk-io Bot merged commit 8e87f32 into master Nov 4, 2026
303 checks passed
@trunk-io
trunk-io Bot deleted the feat/devex-node-schema-check branch November 4, 2026 08:17
@deployment-status-posthog

deployment-status-posthog Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-11-04 08:36 UTC Run
prod-us ✅ Deployed 2026-11-04 08:52 UTC Run
prod-eu ✅ Deployed 2026-11-04 08:55 UTC Run

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.

2 participants