Skip to content

feat(nextly): execute and verify the field group storage migration's steps - #423

Merged
mobeenabdullah merged 28 commits into
mainfrom
feat/field-groups-ddl
Jul 30, 2026
Merged

feat(nextly): execute and verify the field group storage migration's steps#423
mobeenabdullah merged 28 commits into
mainfrom
feat/field-groups-ddl

Conversation

@mobeenabdullah

@mobeenabdullah mobeenabdullah commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Slice B1-3c of task 011. Continues from #408 (reconciliation). Five commits, each independently verified; nothing calls the migration itself yet except the sync guard, which is live.

What this is

The migration could plan its renames and check that plan against the catalog. It could not yet run them. This makes each step executable and self-verifying, and closes the one gap that could destroy data before the migration is even wired up.

1. The recorded plan is what a resume executes

A resume used to rebuild its plan from registry rows and compare two hashes. That stops working the moment a rename updates its registry pointer: the rebuilt plan omits every row already renamed, so it has fewer entries, different step positions, and a different hash from the one the marker recorded — and the resume refuses the very plan it is resuming.

The plan is now persisted for both directions and read back. Identity moved with it: the stored plan is checked against a hash of itself, and "did the world move underneath this run" is answered by hashing the sorted slug set. Slugs, because table names are rewritten as the run progresses — a name-based comparison would refuse every resume past step one.

2. Each table rename carries its registry pointer

Renaming comp_hero to fg_hero while dynamic_components.table_name still says comp_hero leaves every row addressing a table that is gone, and the read path turns that into empty content rather than an error. The two now land in one transaction, and verify checks both.

Which name the pointer update targets is derived from the plan, not probed for: the registry renames last going up and first on a rollback, so the answer depends on position within that plan. Mid-run both names can be observable on a folding server, and reading it from the catalog would repeat a judgement reconciliation already owns.

Both halves are idempotent because MySQL commits DDL implicitly — a rename can land while the pointer update fails, so a resume applies only the half that is missing.

3. db:sync refuses while a migration is in flight

The highest-risk item here. Mid-run some tables carry pre-rename names and some post-rename, and --remove-orphaned deletes what it cannot account for. An unreadable marker refuses rather than reading as absent: absence means no migration ever ran, while an unreadable marker may still describe renamed objects.

4. Index survival is checked by name

From the existing introspectLiveSnapshot — no new adapter methods. By name rather than by count, because a count survives losing one index and gaining another, which is the exact shape of the SQLite index loss. An untracked index list stays undefined rather than collapsing to [], so a caller cannot mistake "nothing was lost" for "nothing could be compared".

Verification

pnpm build 17/17 · pnpm check-types 19/19 · pnpm lint exit 0 · full packages/nextly run 401, matching the baseline measured on main, with 0 branch-only failures.

Every behavioural change has a test proven load-bearing by breaking the implementation and confirming the intended test failed with the count unchanged — 11 breaks across the five commits.

Worth a reviewer's attention

  • A known gap, stated rather than hidden: the per-step index check compares before/after within one invocation. On a resumed step the rename has already committed, so there is no before-state and it reports not comparable rather than passing. A crash between a rename and its verification can therefore hide index loss; the backstop is B1-5's end-of-run structural verification, not a per-step guess.
  • MySQL non-transactional DDL is reasoned about and encoded, but not yet observed on a real server. B1-5's 3-dialect matrix is where that gets tested.
  • Two raw statements exist deliberately: the registry pointer update and its read. Both address a table by a name the ORM's schema registry cannot resolve — it looks tables up exactly by the name their Drizzle definition declares, and during a run the registry sits under whichever name the plan has reached. Values are bound; identifiers go through quoteIdent.

Summary by CodeRabbit

  • New Features

    • Added a safer migration exclusion scope so schema sync (including watch-mode re-sync) can proceed while a field-group migration is in-flight.
    • Improved field-group migration planning/resume with stronger recorded plan identity handling and companion-aware behavior.
    • Added storage observation helpers to support rename/index verification.
  • Bug Fixes

    • Improved rename reconciliation for companion moves and more reliable resume/rollback verification.
    • Postgres index introspection now properly respects schema scoping.
  • Tests

    • Expanded migration guard, storage observation, step/reconciliation, session/lock behavior, and schema reload skip coverage.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

Three things I would most like challenged:

  1. The resume contract change. Retiring the plan rebuild also retired what assertPlanUnchanged protected, so I re-based it on a hash of the sorted slug set. My claim is that slugs are the only identity stable across a run, because table names are rewritten as the pointer updates land. If there is a case where the slug set is unchanged but a resume is still unsafe, I want it.

  2. Deriving the registry name from plan position rather than probing the catalog. I rejected probing because mid-run both names can be observable on a folding server. That reasoning depends on the registry entry being present exactly once in every plan, which parseAppliedManifest enforces on the marker — but the step builder derives it from the entries it was handed, which is a different object.

  3. The index check on a resumed step. It reports not comparable rather than passing, which I believe is honest, but it does mean a crash between a rename and its verification hides index loss. I have deferred the real backstop to B1-5 structural verification. Push back if that is the wrong place for it.

@coderabbitai review
@greptileai review

@github-actions github-actions Bot added scope: core nextly type: docs Documentation only labels Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mobeenabdullah, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 867edfae-75e4-496d-9ca6-da3cf4c93f0e

📥 Commits

Reviewing files that changed from the base of the PR and between a4dad07 and 9255f6c.

⛔ Files ignored due to path filters (1)
  • .changeset/field-group-migration-ddl.md is excluded by !.changeset/**
📒 Files selected for processing (31)
  • packages/adapter-drizzle/src/adapter.ts
  • packages/adapter-drizzle/src/types/transaction.ts
  • packages/adapter-mysql/src/index.ts
  • packages/adapter-postgres/src/index.ts
  • packages/adapter-sqlite/src/index.ts
  • packages/nextly/src/cli/commands/db-sync.ts
  • packages/nextly/src/cli/commands/dev-watcher.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/guard.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/helpers/locking-adapter.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/manifest.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/observer.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/reconcile.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/runner.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/session.integration.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/session.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/state.test-d.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/state.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/steps.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/sync-guard.test.ts
  • packages/nextly/src/domains/field-groups/migration/guard.ts
  • packages/nextly/src/domains/field-groups/migration/manifest.ts
  • packages/nextly/src/domains/field-groups/migration/observer.ts
  • packages/nextly/src/domains/field-groups/migration/reconcile.ts
  • packages/nextly/src/domains/field-groups/migration/session.ts
  • packages/nextly/src/domains/field-groups/migration/state.ts
  • packages/nextly/src/domains/field-groups/migration/steps.ts
  • packages/nextly/src/domains/field-groups/migration/sync-guard.ts
  • packages/nextly/src/domains/schema/pipeline/diff/__tests__/pg-index-schema-scope.integration.test.ts
  • packages/nextly/src/domains/schema/pipeline/diff/introspect-live.ts
  • packages/nextly/src/init/__tests__/reload-config.test.ts
  • packages/nextly/src/init/reload-config.ts
📝 Walkthrough

Walkthrough

Field-group migration markers now carry canonical applied plans and separate registry and manifest identities. New observable, idempotent migration steps, SQL-backed locking, sync exclusions, reload guards, and schema-scoped index introspection are covered by tests.

Changes

Field-group migration safety

Layer / File(s) Summary
Canonical migration identity and resume state
packages/nextly/src/domains/field-groups/migration/{manifest,state,guard}.ts, packages/nextly/src/domains/field-groups/migration/__tests__/*
Markers and resume verdicts require appliedManifest, use registryHash and manifestHash, and represent companion moves within table entries.
Observed idempotent migration steps
packages/nextly/src/domains/field-groups/migration/{observer,steps,reconcile}.ts, packages/nextly/src/domains/field-groups/migration/__tests__/{observer,steps,reconcile}.test.ts
Migration steps observe live storage and perform verified, transactional, resumable table, companion, pointer, and column renames.
Adapter statement execution
packages/adapter-*/src/*, packages/adapter-drizzle/src/*
Adapters add normalized raw SQL queries and transaction-scoped statement execution for migration and lock operations.
Schema-sync migration exclusion
packages/nextly/src/domains/field-groups/migration/{sync-guard,session}.ts, packages/nextly/src/cli/commands/{db-sync,dev-watcher}.ts, packages/nextly/src/domains/field-groups/migration/__tests__/{sync-guard,session}.test.ts
Sync and watch resynchronization hold a migration exclusion, check in-flight markers, support lock-creation policy, and release locks on completion or interruption.
Reload protection and Postgres index scoping
packages/nextly/src/init/reload-config.ts, packages/nextly/src/init/__tests__/reload-config.test.ts, packages/nextly/src/domains/schema/pipeline/diff/*
Config reload stops when a migration is active, and PostgreSQL index introspection filters to the public schema.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant dbSync
  participant withMigrationExcluded
  participant MigrationSession
  participant Database
  dbSync->>withMigrationExcluded: run synchronization workflow
  withMigrationExcluded->>MigrationSession: acquire exclusion and check migration marker
  MigrationSession->>Database: claim lock row
  withMigrationExcluded->>dbSync: execute sync while exclusion is held
  MigrationSession->>Database: release owned lock
Loading

Possibly related PRs

Suggested labels: scope: cli

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: making migration steps executable and self-verifying.
Description check ✅ Passed The description clearly explains the goal, major changes, verification, and reviewer concerns, though it doesn't follow the template headings exactly.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/field-groups-ddl

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.

@pkg-pr-new

pkg-pr-new Bot commented Jul 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

@nextlyhq/adapter-drizzle

npm i https://pkg.pr.new/@nextlyhq/adapter-drizzle@9255f6c

@nextlyhq/adapter-mysql

npm i https://pkg.pr.new/@nextlyhq/adapter-mysql@9255f6c

@nextlyhq/adapter-postgres

npm i https://pkg.pr.new/@nextlyhq/adapter-postgres@9255f6c

@nextlyhq/adapter-sqlite

npm i https://pkg.pr.new/@nextlyhq/adapter-sqlite@9255f6c

@nextlyhq/admin

npm i https://pkg.pr.new/@nextlyhq/admin@9255f6c

@nextlyhq/admin-css

npm i https://pkg.pr.new/@nextlyhq/admin-css@9255f6c

@nextlyhq/blocks-engine

npm i https://pkg.pr.new/@nextlyhq/blocks-engine@9255f6c

create-nextly-app

npm i https://pkg.pr.new/create-nextly-app@9255f6c

nextly

npm i https://pkg.pr.new/nextly@9255f6c

@nextlyhq/plugin-form-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-form-builder@9255f6c

@nextlyhq/plugin-page-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-page-builder@9255f6c

@nextlyhq/plugin-sdk

npm i https://pkg.pr.new/@nextlyhq/plugin-sdk@9255f6c

@nextlyhq/plugin-seo

npm i https://pkg.pr.new/@nextlyhq/plugin-seo@9255f6c

@nextlyhq/storage-s3

npm i https://pkg.pr.new/@nextlyhq/storage-s3@9255f6c

@nextlyhq/storage-uploadthing

npm i https://pkg.pr.new/@nextlyhq/storage-uploadthing@9255f6c

@nextlyhq/storage-vercel-blob

npm i https://pkg.pr.new/@nextlyhq/storage-vercel-blob@9255f6c

@nextlyhq/ui

npm i https://pkg.pr.new/@nextlyhq/ui@9255f6c

commit: 9255f6c

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Changed files ==='
git diff --name-only "$(git merge-base HEAD origin/main)" HEAD 2>/dev/null || git diff --name-only HEAD~1 HEAD

printf '%s\n' '=== Candidate migration files ==='
fd -t f . | rg -i '(field.*group|group.*storage|migration|manifest|snapshot)' | head -n 200

printf '%s\n' '=== Relevant symbol locations ==='
rg -n -C 3 \
  'assertPlanUnchanged|parseAppliedManifest|introspectLiveSnapshot|quoteIdent|slug.*hash|hash.*slug|registry.*pointer|table_name|not comparable|comparable|resume|stored plan|storedPlan' \
  . -g '!**/node_modules/**' -g '!**/dist/**' -g '!**/build/**' \
  | head -n 500

Length of output: 142029


🏁 Script executed:

#!/bin/bash
set -euo pipefail

base="$(git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD~1)"

printf '%s\n' '=== Module outlines ==='
for f in \
  packages/nextly/src/domains/field-groups/migration/{guard,manifest,observer,state,steps,sync-guard}.ts
do
  echo "--- $f"
  ast-grep outline "$f" --items all --view expanded || true
done

printf '%s\n' '=== Changed production diff ==='
git diff --unified=80 "$base" HEAD -- \
  packages/nextly/src/domains/field-groups/migration/guard.ts \
  packages/nextly/src/domains/field-groups/migration/manifest.ts \
  packages/nextly/src/domains/field-groups/migration/observer.ts \
  packages/nextly/src/domains/field-groups/migration/state.ts \
  packages/nextly/src/domains/field-groups/migration/steps.ts \
  packages/nextly/src/domains/field-groups/migration/sync-guard.ts \
  packages/nextly/src/cli/commands/db-sync.ts

printf '%s\n' '=== Focused test names and assertions ==='
rg -n -C 5 \
  'describe\(|it\(|test\(|assertPlanUnchanged|parseAppliedManifest|duplicate|slug|resume|index|comparable|registry|table_name' \
  packages/nextly/src/domains/field-groups/migration/__tests__/{manifest,observer,runner,steps,guard,sync-guard}.test.ts

Length of output: 214953


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Precise call sites ==='
rg -n -C 8 \
  '\b(buildMigrationSteps|assertPlanUnchanged|findLostIndexes|refuseLostIndexes|createStorageObserver|beginMigration|runMigrationSteps|hashSlugSet)\s*\(' \
  packages/nextly/src/domains/field-groups \
  -g '*.ts'

printf '%s\n' '=== Persisted-plan parser and resume assertion ==='
sed -n '430,620p' packages/nextly/src/domains/field-groups/migration/state.ts

printf '%s\n' '=== Migration orchestration outlines ==='
for f in \
  packages/nextly/src/domains/field-groups/migration/{reconcile,runner,session}.ts
do
  echo "--- $f"
  ast-grep outline "$f" --items all --view expanded
done

printf '%s\n' '=== Orchestration source ==='
for f in \
  packages/nextly/src/domains/field-groups/migration/{reconcile,runner,session}.ts
do
  echo "--- $f"
  cat -n "$f"
done

printf '%s\n' '=== Relevant test source around state/resume ==='
rg -n -C 8 \
  'same slug|tableName|slug.*hash|hash.*slug|assertPlanUnchanged|duplicate.*registry|registry.*twice|appliedManifest|persisted plan' \
  packages/nextly/src/domains/field-groups/migration/__tests__/{state,manifest,guard,steps}.test.ts

Length of output: 244151


@mobeenabdullah I found three issues in the requested areas:

  1. P1 — The index-survival check is currently dead code.
    findLostIndexes() and refuseLostIndexes() exist in observer.ts (Line 105 onward), but steps.ts never imports or calls them. The call-site search finds them only in observer.test.ts. Consequently, even a fresh table rename that loses an index will still pass renameStep.verify() (which checks only the table and registry pointer) and be recorded as complete.

    The stated resumed-step limitation is reasonable—“not comparable” is more honest than a false pass—but that backstop discussion only applies once the normal per-step check actually runs. Please capture source indexes before generateSQL(rename_table), inspect target indexes after it, and refuse when a comparable snapshot loses names.

  2. P1 — An unchanged slug set is not enough to prove a persisted plan still applies.
    assertPlanUnchanged() at state.ts Line 449 accepts any current registry state with the same slugs. A same-slug mutation of the storage mapping is still unsafe: for example, an interrupted plan records hero: comp_hero → fg_hero, then a concurrent registry repair/config change repoints hero to custom_hero. Resume continues executing the persisted comp_hero → fg_hero operation even though the current row no longer identifies that table. Depending on the catalog state, it can rename an orphan/other object before the pointer verification eventually fails.

    Slugs are the right stable identity, but the resume guard also needs to establish that each current row remains compatible with its persisted entry—normally that its table_name is one of the plan’s expected pre/post names for that slug and step position. This distinguishes expected in-flight pointer rewrites from an unrelated same-slug remap without rebuilding or rehashing the plan.

  3. P2 — The registry-name derivation relies on an invariant its public input type does not enforce.
    parseAppliedManifest() correctly requires exactly one registry entry, but registryNameAt() in steps.ts Line 88 only finds the first one. buildMigrationSteps() accepts arbitrary ManifestEntry[], so a caller can supply two registry entries and get a coherent-looking but incorrect registry name for later pointer updates. That is the exact mismatch you called out: the marker parser protects one object, while the builder consumes another.

    I agree that probing the catalog is the wrong fix on folding servers. Instead, enforce “exactly one registry rename” at the step-builder boundary as well (or use a validated/branded plan type so only parseAppliedManifest()/the manifest builder can produce step-builder input). Add a direct test for duplicate registry entries passed to buildMigrationSteps().

The persisted-plan approach itself is sound, and deferring the resume-only index comparability gap to B1-5 is reasonable. The first finding is broader, though: the index check needs wiring before B1-5 can serve as a backstop.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 81c20ca9dd

ℹ️ 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".

Comment thread packages/nextly/src/cli/commands/db-sync.ts Outdated
Comment thread packages/nextly/src/domains/field-groups/migration/manifest.ts Outdated
Comment thread packages/nextly/src/domains/field-groups/migration/steps.ts Outdated
Comment thread packages/nextly/src/domains/field-groups/migration/steps.ts Outdated
Comment thread packages/nextly/src/domains/field-groups/migration/steps.ts Outdated
Comment thread packages/nextly/src/domains/field-groups/migration/observer.ts Outdated
Comment thread packages/nextly/src/domains/field-groups/migration/steps.ts Outdated
Comment thread packages/nextly/src/domains/field-groups/migration/steps.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/nextly/src/domains/field-groups/migration/__tests__/state.test.ts (1)

657-661: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

refusalFrom's parameter type still names the removed planHash field.

The helper at Line 637 declares current: { manifestHash: string; planHash: string }, so this new call (which passes slugsHash/manifestHash) no longer matches the MigrationPlanIdentity contract assertPlanUnchanged accepts. It runs fine because the spread supplies the right keys at runtime, and *.test.ts is excluded from check-types, but the stale signature hides the shape change.

♻️ Align the helper with the new identity shape
-  function refusalFrom(current: {
-    manifestHash: string;
-    planHash: string;
-  }): NextlyError {
+  function refusalFrom(current: MigrationPlanIdentity): NextlyError {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nextly/src/domains/field-groups/migration/__tests__/state.test.ts`
around lines 657 - 661, Update the refusalFrom helper’s current parameter type
to use the new MigrationPlanIdentity shape, replacing the removed planHash field
with slugsHash alongside manifestHash. Ensure its argument is compatible with
the identity passed to assertPlanUnchanged while preserving the existing refusal
behavior and tests.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/nextly/src/cli/commands/db-sync.ts`:
- Around line 289-292: Remove the redundant `as unknown as DrizzleAdapter`
assertion from the `adapter` argument in the `assertNoMigrationInFlight` call,
passing the declared `adapter` value directly while preserving the existing
logger and migration-check behavior.

---

Nitpick comments:
In `@packages/nextly/src/domains/field-groups/migration/__tests__/state.test.ts`:
- Around line 657-661: Update the refusalFrom helper’s current parameter type to
use the new MigrationPlanIdentity shape, replacing the removed planHash field
with slugsHash alongside manifestHash. Ensure its argument is compatible with
the identity passed to assertPlanUnchanged while preserving the existing refusal
behavior and tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dc425e17-f595-4fde-8ed2-f3bb4f5ee210

📥 Commits

Reviewing files that changed from the base of the PR and between 83ed5c9 and 81c20ca.

⛔ Files ignored due to path filters (1)
  • .changeset/field-group-migration-ddl.md is excluded by !.changeset/**
📒 Files selected for processing (15)
  • packages/nextly/src/cli/commands/db-sync.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/guard.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/manifest.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/observer.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/runner.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/state.test-d.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/state.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/steps.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/sync-guard.test.ts
  • packages/nextly/src/domains/field-groups/migration/guard.ts
  • packages/nextly/src/domains/field-groups/migration/manifest.ts
  • packages/nextly/src/domains/field-groups/migration/observer.ts
  • packages/nextly/src/domains/field-groups/migration/state.ts
  • packages/nextly/src/domains/field-groups/migration/steps.ts
  • packages/nextly/src/domains/field-groups/migration/sync-guard.ts

Comment thread packages/nextly/src/cli/commands/db-sync.ts Outdated
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

Correcting a claim in this PR description before working the round.

The description and the changeset both say index survival is checked by name. It is not. findLostIndexes, refuseLostIndexes and indexNames have no production caller — verified by grep across src/. I built the helpers, tested them, and never wired them into the rename step's verify. Codex is right (steps.ts:202), and the PR body overstated what landed. I will fix the wiring and the changeset wording together.

Two more I have already verified as real rather than taking on trust:

  • steps.ts:190 — Postgres placeholders. adapter-postgres/src/index.ts:935 calls client.query(sql, params), and node-postgres requires $1/$2. The pointer update emits ?, so every Postgres rename step would fail with a syntax error. My test double parsed ? positionally and applied the params itself, so it passed — the third time in this program a double has been more permissive than the thing it stands in for.
  • manifest.ts:223 — same-slug row replacement. This is exactly the hole I asked to have found in the slug-set identity, and it is a real one: delete and recreate a group under the same slug with an author-chosen dbName and the hash is unchanged while the registry identity is not.

I am treating the remaining six as unverified until I check each against the code, and will reply per thread with either a fix proven by breaking its test, or a rebuttal with file:line evidence.

No merge request here — this PR is not ready.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

Round 1 status at fc4aa226. Six of nine findings resolved, all verified against the code first.

Fixed (five), each proven load-bearing by breaking it:

  • PostgreSQL placeholdersclient.query binds $1/$2; the pointer update emitted ?, so every PostgreSQL rename step would have failed before renaming anything. My test double parsed ? positionally, which is why it passed; it now requires what the driver requires, and reverting the fix fails six tests.
  • Pool re-entry — observations moved out of the step transaction, which would have deadlocked a pool sized to one.
  • Satisfied entries skipping the pointer repair — a genuine dead end: verification failed on every resume with no sequence of retries able to fix it. DDL is still skipped; the idempotent pointer update is not.
  • Index verification was unwired — the helpers had no production caller at all. Now compared in verify, and it refuses rather than returning false, because a lost index is not work still to do.
  • Introspected names compared exactly — now resolved through the same IdentifierCaseRules reconciliation uses.

Rebutted (one), with evidence: the as unknown as DrizzleAdapter cast in db-sync.ts is load-bearing — removing it gives TS2740: Type 'CLIDatabaseAdapter' is missing … executeQuery, transaction, getDrizzle, tableResolver, and 30 more. Local eslint on that file exits 0 and the CI lint check passed.

Three still open, and I want to be straight that two need design rather than a patch:

  1. manifest.ts:223 — same-slug row replacement. This is the hole I asked to have found in the slug-set identity, and it is real. Delete and recreate a group under the same slug with an author-chosen dbName and the hash is unchanged while the registry identity is not. A slug hash cannot distinguish this; it needs a stable per-row identity, which is a change to what the marker records.
  2. steps.ts:179 — companion rename ordering. The base step moves the pointer before the companion step renames the companion, so between them the registry derives a name that does not exist yet. Fixing it properly means either grouping base+companion+pointer into one step or moving the pointer last, and both change the step model.
  3. db-sync.ts:292 — watch-mode re-sync. The guard runs once at startup; createDebouncedSync re-syncs without it. Needs a common schema-mutation boundary rather than a second call site, or it will drift again.

I would rather land these as considered changes than as quick patches, so they are the next work on this PR, not this push.

@codex please review this PR
@coderabbitai review
@greptileai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

base="$(git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD~1)"
printf '%s\n' '=== HEAD ==='
git rev-parse --short HEAD
git status --short

printf '%s\n' '=== Relevant changed files ==='
git diff --name-only "$base" HEAD -- \
  packages/nextly/src/domains/field-groups/migration \
  packages/nextly/src/cli/commands/db-sync.ts

printf '%s\n' '=== Outlines ==='
for f in \
  packages/nextly/src/domains/field-groups/migration/{manifest,observer,steps,state,runner,sync-guard}.ts \
  packages/nextly/src/cli/commands/db-sync.ts
do
  if [ -f "$f" ]; then
    echo "--- $f"
    ast-grep outline "$f" --items all --view expanded || true
  fi
done

printf '%s\n' '=== Focused definitions and calls ==='
rg -n -C 7 \
  '\b(findLostIndexes|refuseLostIndexes|indexNames|registryNameAt|buildMigrationSteps|assertPlanUnchanged|hashSlugSet|createDebouncedSync|assertNoMigrationInFlight|inTransaction)\s*\(' \
  packages/nextly/src/domains/field-groups/migration \
  packages/nextly/src/cli/commands/db-sync.ts \
  -g '*.ts'

printf '%s\n' '=== Updated production diff ==='
git diff --unified=50 "$base" HEAD -- \
  packages/nextly/src/domains/field-groups/migration \
  packages/nextly/src/cli/commands/db-sync.ts

printf '%s\n' '=== Focused tests ==='
rg -n -C 5 \
  'placeholder|\\$1|pool|transaction|satisfied|pointer|index|case|companion|watch|debounced|same.slug|replacement|registry' \
  packages/nextly/src/domains/field-groups/migration/__tests__ \
  packages/nextly/src/cli \
  -g '*test*.ts' || true

Length of output: 202048


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== steps.ts ==='
cat -n packages/nextly/src/domains/field-groups/migration/steps.ts

printf '%s\n' '=== session.ts ==='
cat -n packages/nextly/src/domains/field-groups/migration/session.ts

printf '%s\n' '=== db-sync orchestration ==='
rg -n -C 18 \
  'function runDbSync|const runDbSync|assertNoMigrationInFlight|createDebouncedSync|syncCollections|syncSingles|syncComponents|watchConfig' \
  packages/nextly/src/cli/commands/db-sync.ts

printf '%s\n' '=== migration orchestration callers ==='
rg -n -C 12 \
  '\b(buildMigrationSteps|runMigrationSteps|withMigrationSession|assertPlanUnchanged)\s*\(' \
  packages/nextly/src/domains/field-groups \
  -g '*.ts'

printf '%s\n' '=== Exact focused test ranges ==='
sed -n '45,140p' packages/nextly/src/domains/field-groups/migration/__tests__/steps.test.ts
sed -n '185,410p' packages/nextly/src/domains/field-groups/migration/__tests__/steps.test.ts
sed -n '150,185p' packages/nextly/src/domains/field-groups/migration/__tests__/session.test.ts

printf '%s\n' '=== Placeholder implementation across adapters ==='
rg -n -C 8 \
  'placeholder|\\$[0-9]|client\.query|executeQuery' \
  packages/nextly/src/database \
  packages/adapter-postgres \
  -g '*.ts' || true

Length of output: 184144


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Storage observer implementation ==='
cat -n packages/nextly/src/domains/field-groups/migration/observer.ts

printf '%s\n' '=== Debounced watch sync implementation ==='
fd -t f '^dev-watcher\.ts$' packages/nextly/src/cli --exec sh -c 'echo "--- $1"; cat -n "$1"' sh {}

printf '%s\n' '=== Production step-builder call sites ==='
rg -n -C 16 \
  '\bbuildMigrationSteps\s*\(' \
  packages/nextly/src \
  -g '*.ts' -g '!**/__tests__/**'

printf '%s\n' '=== Registry parser multiplicity validation ==='
rg -n -C 14 \
  'parseAppliedManifest|exactly one|registry.*entry|entry.*registry' \
  packages/nextly/src/domains/field-groups/migration \
  -g '*.ts'

printf '%s\n' '=== Column-step pool/re-entry coverage ==='
rg -n -C 10 \
  'column.*pool|pool.*column|observer\.columns|inTransaction' \
  packages/nextly/src/domains/field-groups/migration/__tests__ \
  -g '*.ts'

Length of output: 61832


@mobeenabdullah I verified the five claimed fixes: PostgreSQL $1/$2 markers, base-table observation outside the rename transaction, idempotent pointer repair for satisfied steps, wired index-loss refusal, and case-aware catalog resolution are all present.

Two findings remain:

  1. P1 — The pool re-entry fix is incomplete for column steps.
    columnStep.run() calls observer.columns(session, table) inside session.inTransaction() at steps.ts Lines 283-315. The production observer implements columns() using introspectLiveSnapshot(adapter.getDrizzle(), ...) at observer.ts Lines 60-67, so it checks out a separate adapter connection rather than using the transaction context. With pool.max = 1, an outstanding column rename holds the only connection and waits indefinitely for this observation—exactly the failure avoided for table/index observations at steps.ts Lines 188-200.

    Read the column metadata before entering inTransaction(), then use that captured observation inside the transaction. Please add a column-step variant of the one-connection regression test; the existing session test only proves that the migration lock itself does not retain a connection.

  2. P2 — Registry multiplicity remains unenforced at the step-builder boundary.
    The marker parser correctly requires exactly one registry entry in state.ts Lines 518-550, but registryNameAt() still uses findIndex() at steps.ts Line 109, and buildMigrationSteps() accepts arbitrary readonly ManifestEntry[] at Lines 128-152. Therefore a direct caller can supply two registry entries, and pointer updates after the first one will derive their registry name from the wrong boundary.

    I agree that catalog probing is not the fix. Reject plans whose registry-entry count is not exactly one in registryNameAt()/buildMigrationSteps(), or make the builder consume a validated plan type. Add the direct duplicate-registry test here as well.

The three issues you listed as deliberately open—same-slug row replacement, companion ordering, and watch-mode re-sync—remain accurately characterized by the code. In particular, dev-watcher.ts Lines 69-103 has no migration-state guard, so the watch gap is still unresolved as stated.


✏️ Learnings added
Learnt from: mobeenabdullah
URL: https://github.com/nextlyhq/nextly/pull/423

Timestamp: 2026-07-30T16:05:02.631Z
Learning: In PR `#423`, field-group migration catalog observations must occur outside a migration step transaction when they use `createStorageObserver`, because `StorageObserver` introspection accesses the adapter independently and can deadlock a `pool.max = 1` database connection pool.

Learnt from: mobeenabdullah
URL: https://github.com/nextlyhq/nextly/pull/423

Timestamp: 2026-07-30T16:05:02.631Z
Learning: In the field-group migration, persisted marker plans are validated by `parseAppliedManifest()` to contain exactly one registry rename, but any step-builder API accepting raw `ManifestEntry[]` must enforce the same invariant before deriving registry names from plan position.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mobeenabdullah
mobeenabdullah force-pushed the feat/field-groups-ddl branch from fc4aa22 to b190ae6 Compare July 30, 2026 16:10
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

Round 1 complete at 5d2ef2f2. All nine findings addressed — eight fixed, one rebutted with evidence.

Since the last status: companion renames now move with their owner and the pointer in one transaction (no ordering fixes it alone — the pointer decides which companion name the registry derives, so it can only move when both objects have); the marker is re-checked on every watched re-sync rather than once at boot; and resume identity now covers each registry row's primary key, not just its slug.

Every fix has a test proven load-bearing by breaking it. build 17/17, check-types 19/19, lint exit 0, full run 401 with 0 branch-only failures, rebased onto 21bb5b3e.

@codex please review this PR
@coderabbitai review
@greptileai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

base="$(git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD~1)"

echo '=== HEAD and changed files ==='
git rev-parse --short HEAD
git diff --name-only "$base" HEAD -- \
  packages/nextly/src/domains/field-groups/migration \
  packages/nextly/src/cli/commands/db-sync.ts \
  packages/nextly/src/cli

echo '=== Module outlines ==='
for file in \
  packages/nextly/src/domains/field-groups/migration/steps.ts \
  packages/nextly/src/domains/field-groups/migration/state.ts \
  packages/nextly/src/domains/field-groups/migration/runner.ts \
  packages/nextly/src/domains/field-groups/migration/session.ts \
  packages/nextly/src/domains/field-groups/migration/sync-guard.ts \
  packages/nextly/src/cli/commands/db-sync.ts
do
  if [ -f "$file" ]; then
    echo "--- $file"
    ast-grep outline "$file" --items all --view expanded || true
  fi
done

echo '=== Relevant definitions, call sites, and tests ==='
rg -n -C 8 \
  '\b(buildMigrationSteps|registryNameAt|assertPlanUnchanged|hashSlugSet|hash.*Identity|primary.?key|row.*id|assertNoMigrationInFlight|createDebouncedSync|inTransaction|observer\.columns|companion|pointer)\s*\(' \
  packages/nextly/src/domains/field-groups/migration \
  packages/nextly/src/cli \
  -g '*.ts'

echo '=== Updated production diff ==='
git diff --unified=60 "$base" HEAD -- \
  packages/nextly/src/domains/field-groups/migration \
  packages/nextly/src/cli/commands/db-sync.ts \
  packages/nextly/src/cli/dev-watcher.ts

echo '=== Focused test references ==='
rg -n -C 5 \
  'companion|atomic|transaction|watch|debounced|primary.?key|row.*id|same.slug|replacement|duplicate.*registry|pool|max.?1|column' \
  packages/nextly/src/domains/field-groups/migration/__tests__ \
  packages/nextly/src/cli \
  -g '*test*.ts' || true

Length of output: 202305


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Current commit ==='
git rev-parse --short HEAD

echo '=== steps.ts: complete focused implementation ==='
sed -n '1,430p' packages/nextly/src/domains/field-groups/migration/steps.ts

echo '=== manifest and state identity implementation ==='
rg -n -C 12 \
  'export function hashRegistryIdentity|export function hashSlugSet|interface RegistryRow|type RegistryRow|assertPlanUnchanged|registryIdentity' \
  packages/nextly/src/domains/field-groups/migration/manifest.ts \
  packages/nextly/src/domains/field-groups/migration/state.ts

echo '=== watch-mode and db-sync paths ==='
rg -n -C 25 \
  'assertNoMigrationInFlight|createDebouncedSync|runDbSync|watchConfig|syncCollections|syncSingles|syncComponents|ensureLocalizedCompanions' \
  packages/nextly/src/cli/commands/db-sync.ts \
  packages/nextly/src/cli/dev-watcher.ts \
  packages/nextly/src/cli \
  -g '*.ts'

echo '=== Direct production builder call sites ==='
rg -n -C 15 '\bbuildMigrationSteps\s*\(' packages/nextly/src -g '*.ts' -g '!**/__tests__/**'

echo '=== Regression test bodies ==='
sed -n '390,475p' packages/nextly/src/domains/field-groups/migration/__tests__/steps.test.ts
rg -n -C 15 'watch|re-sync|in.flight|assertNoMigrationInFlight|migration marker' \
  packages/nextly/src/cli \
  packages/nextly/src/domains/field-groups/migration/__tests__ \
  -g '*test*.ts'
rg -n -C 15 'same slug|recreated|registry identity|hashRegistryIdentity|assertPlanUnchanged' \
  packages/nextly/src/domains/field-groups/migration/__tests__ \
  -g '*test*.ts'

Length of output: 162326


@​mobeenabdullah I verified the new watch-mode guard and the row-ID-based resume identity; both are present and address their stated failure modes. However, two previously reported issues remain in 5d2ef2f2:

  1. P1 — Column steps still risk pool re-entry deadlock.
    In packages/nextly/src/domains/field-groups/migration/steps.ts, columnStep().run() invokes observer.columns(session, table) inside session.inTransaction() (Lines 281–285). The production observer performs adapter-backed introspection rather than using the transaction context, so on pool.max = 1 the transaction holds the only connection while the observer waits for another.

    This is the same pattern already removed from table/index observations in renameStep().run(). Read the columns before opening inTransaction(), then use that captured snapshot in the transaction. Please add the corresponding one-connection regression test for a column rename.

  2. P2 — Step-builder input still does not enforce exactly one registry entry.
    registryNameAt() in packages/nextly/src/domains/field-groups/migration/steps.ts uses findIndex() (Line 95) and accepts the first registry entry. buildMigrationSteps() accepts arbitrary readonly ManifestEntry[] (Lines 113–153), so direct callers can supply duplicate registry entries and derive a plausible but incorrect registry table name after the first registry step.

    The persisted-marker parser’s validation does not protect this public builder boundary. Reject plans whose registry-entry count is not exactly one in registryNameAt()/buildMigrationSteps(), or narrow the builder to a validated plan type, and retain a direct duplicate-entry test.

The grouped owner/companion/pointer operation, the watched re-sync guard, and registry identity change otherwise look correctly wired.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

A resume rebuilt its plan from registry rows and compared the result against two
recorded hashes. That stops working as soon as each rename updates its registry
pointer: a rebuilt plan then omits every row already renamed, so it has fewer
entries, different step positions, and a different hash from the one the marker
recorded, and the resume refuses the plan it is resuming.

The plan is now persisted in the marker for both directions and read back rather
than rebuilt. It is always the canonical legacy-to-migrated form: an up run
applies it, a down run reverses it at execution time. A run in flight without
one is corruption rather than an optional field, and the resume verdict carries
it in both directions.

What the identity has to catch changes with it. The stored plan's integrity is
checked on read against a hash of itself, and the question a recorded step
position actually depends on — did the set of field groups move underneath the
interrupted run — is answered by hashing the sorted slug set. Slugs rather than
table names, because table names are rewritten as the run progresses, so a
name-based comparison would refuse every resume past the first step. The step-
list hash retires with the rebuild that needed it.

Three tests were passing for a reason other than their own: the marker they
built was refused by the new plan checks before reaching the guard each was
written to exercise. They now carry a valid plan, and the step-bound test fails
again when its guard is removed.
Renaming a table and updating the registry row that points at it are one
logical change. Apart, there is a window in which every row addresses a table
that no longer exists, and the read path turns that into empty content rather
than an error. Each rename step now issues both in one transaction, and its
postcondition checks both: a rename whose pointer update did not land fails
verification rather than being recorded as done.

Which name the pointer update targets is derived from the plan rather than
probed for. The registry renames last going up, so every update precedes it and
addresses the legacy name; a rollback reverses that order, so the registry moves
first and later steps address the name the rollback restored. Reading it from
the catalog instead would repeat a judgement reconciliation already owns, and
mid-run both names can be observable on a server that folds case.

Both halves are idempotent because a step has to survive being half applied.
MySQL commits DDL implicitly, so a rename can land while the pointer update
fails; the resume re-runs the step, finds the table already moved, and applies
only the half that is missing.

Companions carry no pointer of their own — their name derives from the owner's
table_name, so the owner's update already moves them — and no row addresses the
registry itself. Both are still verified by name, because deriving a name is not
evidence the object was renamed.

No SQL is written here: generateSQL already emits both renames for all three
dialects. The pointer update binds its values and quotes only the table name,
which cannot go through Drizzle because the schema registry resolves tables by
the exact name their definition declares and a migrated table is unregistered
as far as it is concerned.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d2ef2f26f

ℹ️ 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".

Comment thread packages/nextly/src/domains/field-groups/migration/steps.ts Outdated
Comment thread packages/nextly/src/domains/field-groups/migration/steps.ts Outdated
Comment thread packages/nextly/src/cli/commands/dev-watcher.ts Outdated
Comment thread packages/nextly/src/domains/field-groups/migration/observer.ts Outdated
Mid-run the database is in a shape neither the old config nor the new one
describes: some tables carry their pre-rename names and some their post-rename
names, and the registry rows pointing at them move one step at a time. db:sync
reconciles the config against exactly that, and --remove-orphaned deletes what
it cannot account for, so a sync during a partial migration can drop
half-renamed storage.

Nothing about that is recoverable by being careful inside the sync, so it
refuses and names the run an operator has to clear.

Placed after the core tables are ensured, because the marker lives in
nextly_meta and a database that has never been set up has nothing to read, and
before anything that inspects or changes schema.

A marker that is present but unreadable refuses rather than reading as absent.
Absence means no migration ever ran; an unreadable marker may still describe
objects that were renamed, and treating the two alike would let the sync
proceed over the storage this guard exists to protect.
…ction

The steps decide against an injected observer so they stay testable without a
database. This binds that seam to introspectLiveSnapshot rather than issuing new
catalog queries: it already answers the same question for all three dialects,
and a second implementation of what a table looks like would drift from it the
way independent naming rules have drifted here before.

Index survival is checked BY NAME. Renaming a table keeps its indexes on every
supported dialect, so a name missing afterwards means one was dropped rather
than moved. Comparing counts instead would survive losing one index and gaining
another, which is the exact shape of the loss this guards against.

An untracked index list stays undefined rather than collapsing to empty.
Snapshots written before index data was recorded leave the field unset, and
reading that as "no indexes" would report every index intact on a snapshot that
never held any. The two cases are distinguished in the result, so a caller
cannot mistake "nothing was lost" for "nothing could be compared".

The registry pointer read addresses a table by a name the ORM's schema registry
does not know, since it resolves tables exactly by the name their definition
declares and the registry sits under whichever name the plan has reached.
…eck indexes

Four defects found in review, all in the rename step.

The pointer update emitted `?` markers on every dialect. node-postgres binds
`$1`/`$2` and receives the statement unchanged, so every PostgreSQL rename step
would have failed with a syntax error before renaming anything. Markers are now
generated per dialect. The test double parsed `?` positionally and applied the
parameters itself, which is why this passed; it now rejects anything the real
driver would, and reverting the fix fails six tests.

Observations moved out of the step transaction. The observer reads through the
adapter, which takes its own connection, so asking it from inside the
transaction waits for a second checkout and deadlocks a pool sized to one.

A satisfied entry no longer skips its pointer repair. MySQL commits DDL
implicitly, so reconciliation can mark a rename satisfied while its pointer
update never landed; skipping both halves left the pointer stale and failed
verification on every resume, with no way forward. The DDL is still skipped, and
the update is idempotent.

Index survival is now actually checked. The comparison helpers existed, were
tested, and had no production caller — the step verified names and pointers
only, so a rename that dropped an index still advanced the marker. `verify` now
compares the names captured before the rename against those after, and refuses
rather than returning false: a lost index is not work still to do, and retrying
cannot bring it back.
… rules

The snapshot lookup compared spellings exactly. MySQL with
lower_case_table_names=1 answers a query for a mixed-case custom name while
information_schema reports the lowercased one, so the lookup discarded a
snapshot describing the very table it had asked about — and the caller then read
an existing table as missing, refusing the migration over storage that is there.

Resolved through the same identifier-case rules reconciliation already uses, so
one set of rules decides what counts as the same object everywhere rather than
two answers drifting apart.
A companion's name is derived from its owner's table_name, so the moment the
pointer moves the registry begins deriving the new companion name. Renaming the
companion in a separate, independently recorded step left a window in which that
derived name addressed an object that did not exist yet, and a crash or a
concurrent reader in that window saw localized storage missing.

Neither ordering fixes it alone. Renaming the companion first leaves the pointer
still deriving the old name for a table that has moved; renaming it after leaves
the derived new name pointing at nothing. The pointer can only move when both
objects have, so both renames and the pointer update now share one transaction.

The companion keeps its own entry and position, so the plan's shape and hash are
unchanged and step numbers still index it. Its own step finds the source already
gone and does nothing, which is the same idempotence every step relies on.
The guard ran once, when db:sync started. A migration can begin after that, so
every later save re-synced without it — and with --remove-orphaned, deleted what
it could not account for, over tables halfway through being renamed.

Placed beside the plugin-declaration gate in the watcher's sync, which already
re-runs on every reload for the same reason: a check that only runs at boot
protects the first sync and nothing after it.
A slug census cannot tell a field group that survived an interrupted run from
one deleted and recreated under the same name. Recreate `hero` with an
author-chosen dbName of `fg_hero` and the hash is unchanged, so a resume reads
that table as its own crash-window work, settles claiming it created it, and a
later rollback renames the author's table to `comp_hero`.

The identity now covers each registry row's own primary key alongside its slug.
The id is stable across the run — renames rewrite table_name, never this — and a
recreated row cannot reuse it, which is exactly the distinction a slug lacks.

Table names stay out of it for the original reason: they are rewritten as each
rename commits, so hashing them would stop matching partway through the run the
hash exists to protect.
@mobeenabdullah
mobeenabdullah force-pushed the feat/field-groups-ddl branch from 5d2ef2f to d02993e Compare July 30, 2026 16:30
The table path was moved out of its transaction and the column path was left
behind. The observer reads through the adapter, which checks out its own
connection, so introspecting from inside the transaction waits for a second
checkout and hangs a pool sized to one — every discriminator rename, on the two
dialects that pool.

Both paths are now asserted by one test, because fixing one and leaving its
sibling is what happened here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d02993edcc

ℹ️ 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".

Comment thread packages/nextly/src/domains/field-groups/migration/steps.ts Outdated
Comment thread packages/nextly/src/domains/field-groups/migration/steps.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c7d60175b

ℹ️ 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".

Comment thread packages/nextly/src/domains/field-groups/migration/state.ts
Comment thread packages/nextly/src/domains/field-groups/migration/observer.ts
Comment thread packages/nextly/src/domains/field-groups/migration/state.ts
pg_class.relname is unique per schema rather than per database, so an index
query filtering on relname alone matched every schema holding that name and
merged their indexes into one table's snapshot. The column query beside it was
already scoped to public.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e1a40e677

ℹ️ 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".

Comment thread packages/nextly/src/cli/commands/db-sync.ts
…nly a sync's

The signal handler was registered for every claim, so an interrupted migration
released its lock while its work was still in flight — a signal does not stop
the callback — letting a second process resume the same run against a database
the first was still writing to. That is the failure the lock's no-expiry design
exists to prevent, and the comment above it already said so.

Recovery is now opt-in and taken only by schema syncs, where two overlapping
syncs are the state that existed before this exclusion. An interrupted migration
stays held for an operator, as designed.
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@greptileai

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 17 minutes.

Every other test of this module talks to a stand-in, and a stand-in is what hid
the fact that the lock could not run at all. This covers seed, claim, exclusion
and release through the real adapter, with the lock table genuinely absent from
the schema registry.

Also refuses an unrecognised driver result in the mysql transaction reader
rather than reporting it as no rows, and stops an interrupted release from
surfacing as an unhandled rejection instead of letting the process stop.
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@greptileai

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 11 minutes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

`CREATE TABLE IF NOT EXISTS ${MIGRATION_LOCK_TABLE} (id ${idType} PRIMARY KEY, owner text)`,

P1 Badge Model the migration lock table through Drizzle

On the first auto-sync for a database without this lock table, ensureLockRow sends this hand-built CREATE TABLE string through executeQuery. That introduces raw SQL database access in product code despite the repository's Drizzle-only invariant; define the lock table through the dialect schemas or another Drizzle-backed production DDL abstraction instead.

AGENTS.md reference: AGENTS.md:L83-L85

ℹ️ 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".

Comment thread packages/nextly/src/cli/commands/db-sync.ts
…applies

next dev routes config edits through the reload path rather than the CLI
watcher, so the schema-applying path most users are on had no guard at all: it
builds field-group diffs and applies DDL, and its pre-cleanup issues UPDATE and
DELETE, none of which can be reasoned about against half-renamed storage.

The reload is abandoned rather than throwing, because a refused reload leaves
the previous config in place while an exception here reaches the dev server and
turns every later request into a 500. A refusal and a check that could not run
are logged differently: the first is routine, the second would otherwise
disable schema applies silently.
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@greptileai

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 59 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/nextly/src/domains/field-groups/migration/__tests__/session.test.ts (1)

134-137: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the takeOver helper’s purpose.

Add a short comment explaining that this mutates the lock owner to simulate another process taking ownership for release-safety tests.

Suggested change
+    // Simulate another process taking ownership so release checks run against a changed claim.
     takeOver: (owner: string | null) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nextly/src/domains/field-groups/migration/__tests__/session.test.ts`
around lines 134 - 137, Add a concise comment above the takeOver helper
explaining that it mutates the lock owner to simulate another process taking
ownership for release-safety tests.

Sources: Coding guidelines, Learnings

packages/nextly/src/domains/field-groups/migration/__tests__/state.test.ts (1)

705-716: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the plan guard fixtures to use registryHash.

assertPlanUnchanged compares recorded.registryHash vs current.registryHash, but this test still declares both PLAN and refusalFrom with manifestHash/planHash. As a result, the unchanged/resume test objects pass without a registry hash while the field-group drift test adds only registryHash onto the wrong shape. Use { manifestHash: "...", registryHash: "slugs-1" } consistently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nextly/src/domains/field-groups/migration/__tests__/state.test.ts`
around lines 705 - 716, Update the plan guard fixtures used by refusalFrom and
related tests to replace planHash with registryHash, using the shape {
manifestHash, registryHash } consistently for PLAN, current inputs, and
unchanged/resume objects. Ensure the field-group drift fixture sets registryHash
on that same shape so assertPlanUnchanged compares the intended values.
🧹 Nitpick comments (1)
packages/nextly/src/cli/commands/db-sync.ts (1)

281-293: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move this block to the withMigrationExcluded call it now describes.

The guard is no longer a statement at this position, so the "Step 3.55" block documents nothing where it sits; the placement and watch-mode rationale belong beside the wrapper at Line 313.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nextly/src/cli/commands/db-sync.ts` around lines 281 - 293, Move the
“Step 3.55” migration-exclusion comment from its current location to the
withMigrationExcluded call around the sync execution. Keep the explanation of
holding the exclusion across the sync and leaving watch mode outside adjacent to
that wrapper, removing the misplaced standalone block.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/nextly/src/domains/field-groups/migration/__tests__/session.test.ts`:
- Around line 387-405: The process.kill spy created in the “keeps a migration's
claim held when the process is interrupted” test must always be restored. Wrap
the withMigrationSession execution and assertions in a try/finally block,
placing kill.mockRestore() in finally so cleanup occurs even when setup, the
callback, or an assertion fails.

In `@packages/nextly/src/init/reload-config.ts`:
- Around line 968-976: Update the error-handling branch in runReload around
NextlyError.is(error) to use optional logger invocation for both warning and
error messages, matching the logger?. pattern used elsewhere in the function.
Preserve the existing messages and describeError(error) details while ensuring
an undefined logger cannot throw.
- Around line 953-961: Wrap the HMR reload schema-work sequence after
assertNoMigrationInFlight in withMigrationExcluded, passing the adapter/logger
context and mayCreateLock: false, and execute the diff, introspection,
PushSchemaPipeline DDL, and RealPreCleanupExecutor UPDATE/DELETE work inside its
callback. Preserve the existing migration check and ensure the exclusion covers
the entire reload operation through completion.

---

Outside diff comments:
In
`@packages/nextly/src/domains/field-groups/migration/__tests__/session.test.ts`:
- Around line 134-137: Add a concise comment above the takeOver helper
explaining that it mutates the lock owner to simulate another process taking
ownership for release-safety tests.

In `@packages/nextly/src/domains/field-groups/migration/__tests__/state.test.ts`:
- Around line 705-716: Update the plan guard fixtures used by refusalFrom and
related tests to replace planHash with registryHash, using the shape {
manifestHash, registryHash } consistently for PLAN, current inputs, and
unchanged/resume objects. Ensure the field-group drift fixture sets registryHash
on that same shape so assertPlanUnchanged compares the intended values.

---

Nitpick comments:
In `@packages/nextly/src/cli/commands/db-sync.ts`:
- Around line 281-293: Move the “Step 3.55” migration-exclusion comment from its
current location to the withMigrationExcluded call around the sync execution.
Keep the explanation of holding the exclusion across the sync and leaving watch
mode outside adjacent to that wrapper, removing the misplaced standalone block.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e5269d0-0993-41e3-9617-bdfdb209ee22

📥 Commits

Reviewing files that changed from the base of the PR and between ff987ec and 85b1963.

📒 Files selected for processing (12)
  • packages/adapter-mysql/src/index.ts
  • packages/nextly/src/cli/commands/db-sync.ts
  • packages/nextly/src/cli/commands/dev-watcher.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/session.integration.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/session.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/state.test.ts
  • packages/nextly/src/domains/field-groups/migration/__tests__/sync-guard.test.ts
  • packages/nextly/src/domains/field-groups/migration/session.ts
  • packages/nextly/src/domains/field-groups/migration/state.ts
  • packages/nextly/src/domains/field-groups/migration/sync-guard.ts
  • packages/nextly/src/init/__tests__/reload-config.test.ts
  • packages/nextly/src/init/reload-config.ts

Comment thread packages/nextly/src/domains/field-groups/migration/__tests__/session.test.ts Outdated
Comment thread packages/nextly/src/init/reload-config.ts Outdated
Comment thread packages/nextly/src/init/reload-config.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 85b19636e6

ℹ️ 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".

Comment thread packages/nextly/src/init/reload-config.ts Outdated
…ust check it

A migration starting between the check and the apply left the same window open
the check was added to close. Holding is safe on this path specifically because
reloads are already serialized in the process — one is kept in flight and the
next is queued — so taking the lock cannot make a concurrent edit lose its turn.

The reload body moved behind a wrapper rather than being re-indented, so the
diff stays reviewable. A refused exclusion is told apart from a failure inside
the reload, which still propagates as before, and the logger is optional at
every new call site like the rest of the function.

The lock double is now shared between the session and reload suites instead of
each keeping its own, and the process.kill spies restore in finally so a failed
assertion cannot leave the global mocked for later tests.
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@greptileai

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 43 minutes.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 9255f6cfd7

ℹ️ 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".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: core nextly scope: db-adapter @nextlyhq/adapter-* packages type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant