Skip to content

fix(nextly): decide what happens to a junction by its table, not by its field - #1814

Merged
mobeenabdullah merged 5 commits into
mainfrom
fix/junction-lifecycle-decided-by-table
Sep 12, 2026
Merged

mobeenabdullah merged 5 commits into
mainfrom
fix/junction-lifecycle-decided-by-table

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

What

The follow-up to #1796, which merged with three review findings open. All three were real, and they share one cause: the lifecycle was keyed on the FIELD, and what the database holds is a TABLE.

Ledger: task:junction-lifecycle-decided-by-table, research research:junction-table-lifecycle-prior-art.

Threads this closes, all on #1796's head 914574bcf:

  • 3991752095 (Codex, P2) — ownership was checked on the author-named junctionTable only, so a field naming a table exactly what another field's GENERATED name is passed validation, and the DDL then created one table for two fields.
  • 3991752107 (Codex, P2) — with a junction shared by two fields in a definition saved before sharing was refused, renaming one field renamed the shared table away from the surviving field: it pointed at a table that was gone, and the renamed field inherited every link.
  • 3991758326 (CodeRabbit, Major) — a regression feat(nextly): a many-to-many field's junction table follows the field through its life #1796's round 3 introduced. Its keep-the-table-by-name guard also kept a table whose name a field of ANOTHER relation now uses, and CREATE TABLE IF NOT EXISTS left the old tags_id column standing for a field that stores authors_id.

Two gaps older than #1796 fall out of the same repair: changing a field's junctionTable emitted nothing (the field then pointed at a table that does not exist), and pointing a field at another collection emitted nothing (same).

How

junctionLifecycle compares the junction TABLES the old and new definitions name (junctionTables, keyed by resolved name, each with its shape and the fields naming it):

  • a table no longer named → DROP TABLE IF EXISTS;
  • a newly named one → created;
  • a name in both but for another relation (sameJunctionColumns compares the link columns and their references) → dropped and created again. Drops run first, so the name is free before anything is created under it;
  • a field that keeps its relation while its table's name changes — renamed, or junctionTable edited — carries the table and its links (junctionCarries), through the same renameJunctionStatements as before;
  • the referential actions are deliberately NOT part of the comparison: a table rebuilt because onDelete changed would lose its links. That gap is finding:referential-action-edits-emit-no-ddl, unchanged here.

Refusals, by name, before any statement is written (refuseJunctionMoves, JUNCTION_TABLE_IN_USE): a junction may not move off a table another field still stores its links in, nor onto a table that already exists.

Ownership (validateJunctionOwnership, JUNCTION_TABLE_SHARED) now asks the name each field RESOLVES to, through the schema service's own junctionTableNameFor — the naming the DDL is written with — and both Builder save paths pass that resolver. Those two paths also shared a field prologue (lower-case the names, drop the reserved five, validate); adding the check to both crossed fallow's clone threshold, so userFieldsOf does it once over one RESERVED_FIELD_NAMES.

Verification

  • junction-table-lifecycle.test.ts (69 = 23 × 3 dialects), including: a junctionTable edit carries the table; a retarget replaces it; a reused name is dropped THEN created, with the create carrying authors_id; both refusals.
  • junction-ownership-is-exclusive.test.ts (6): the generated-name collision, the named collision, two own tables, generated names, a junctionTable on a many-to-one (inert), and both save paths refusing through the real resolver.
  • junction-table-lifecycle.integration.test.ts (7, real SQLite, fixture built from generateMigrationSQL): links survive a junctionTable edit; a reused name is rebuilt — PRAGMA table_info shows authors_id, no tags_id, and no rows.
  • Break-verified with eight wrong implementations, each killing exactly its named tests and nothing else: ownership asking the option only (3), the create path skipping the rule (1), the update path skipping it (1), no move guard (6), drops/creates by name only (3 + 1 integration), no kept-field carries (6 + 1 integration), a retarget carried as a rename (3), creates before drops (3 + 1 integration). Then again after the de-duplication: removing the ownership call from userFieldsOf fails both save-path tests.
  • Gates at b06c21dab (branched from main c1fdd9bb6, after refactor(nextly): decide access with one mechanism, not two #1659): nextly lint 0, check-types 0, unit 2013/2013 (dynamic-collections + schema + the bare-Error allowlist), integration 414 passed / 46 skipped (dynamic-collections + collections), docs-claims 0, pnpm exec fallow audit --changed-since origin/main --gate new-only pass, 0 introduced.
  • One changeset, all 25 packages, patch.

…ts field

Round 4 of review found three cases the field-keyed rules got wrong, and two
more share their cause: a table, not a field, is what the database holds.

junctionLifecycle now compares the tables the old and new definitions name. A
table no longer named is dropped; a newly named one is created; a name reused
for another relation is dropped and created again - CREATE TABLE IF NOT EXISTS
would keep the old tags_id column for a field storing authors_id, which the
round-3 keep-by-name guard caused - with drops first so the name is free. A
field kept by name whose table's name changed (its junctionTable edited) now
carries the table and its links, as a field rename does; a field retargeted to
another collection gets a new table instead of pointing at none. Referential
actions are not part of the comparison: rebuilding for them would lose links.

A junction may not move off a table another field still stores its links in
(a definition saved before shared junctions were refused), nor onto a table
that exists: JUNCTION_TABLE_IN_USE, by name. The ownership rule now asks the
name each field resolves to, through the schema service's own resolver, from
both Builder save paths - an author-named table spelling another field's
generated name collided unseen.
…nd one reserved-name list

Create and update each lower-cased the field names, dropped the same five
reserved fields from a list of their own (update had two), and validated; with
the junction-ownership check added to both, the two copies crossed the clone
threshold. userFieldsOf does the four steps once, in the same order, over one
RESERVED_FIELD_NAMES.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 28 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 61faafcb-ae8b-4a2b-a056-f33812f1ffd7

📥 Commits

Reviewing files that changed from the base of the PR and between d58455c and beb8e98.

⛔ Files ignored due to path filters (1)
  • .changeset/a-junction-is-decided-by-its-table.md is excluded by !.changeset/**
📒 Files selected for processing (7)
  • docs/dynamic-collections/index.mdx
  • packages/nextly/src/domains/dynamic-collections/__tests__/junction-ownership-is-exclusive.test.ts
  • packages/nextly/src/domains/dynamic-collections/__tests__/junction-table-lifecycle.integration.test.ts
  • packages/nextly/src/domains/dynamic-collections/__tests__/junction-table-lifecycle.test.ts
  • packages/nextly/src/domains/dynamic-collections/services/dynamic-collection-schema-service.ts
  • packages/nextly/src/domains/dynamic-collections/services/dynamic-collection-service.ts
  • packages/nextly/src/domains/dynamic-collections/services/dynamic-collection-validation-service.ts

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026 •

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-09-12T00:26:42.531494Z beb8e98 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.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@nextlyhq/adapter-drizzle

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

@nextlyhq/adapter-mysql

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

@nextlyhq/adapter-postgres

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

@nextlyhq/adapter-sqlite

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

@nextlyhq/admin

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

@nextlyhq/admin-css

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

@nextlyhq/blocks-engine

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

@nextlyhq/blocks-react

npm i https://pkg.pr.new/@nextlyhq/blocks-react@3796442

@nextlyhq/builder

npm i https://pkg.pr.new/@nextlyhq/builder@3796442

create-nextly-app

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

@nextlyhq/eslint-plugin

npm i https://pkg.pr.new/@nextlyhq/eslint-plugin@3796442

nextly

npm i https://pkg.pr.new/nextly@3796442

@nextlyhq/plugin-form-builder

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

@nextlyhq/plugin-page-builder

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

@nextlyhq/plugin-sdk

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

@nextlyhq/plugin-seo

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

@nextlyhq/storage-s3

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

@nextlyhq/storage-uploadthing

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

@nextlyhq/storage-vercel-blob

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

@nextlyhq/ui

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

commit: 3796442

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Whole-Repository Code Hygiene Summary

Full dead-code, duplication, and complexity report for the PR branch as it stands now. Playground is excluded. Quality gate enforcement on introduced issues is performed by the Changed files job.

🌿 Fallow

Warning

Review needed

⚠️ 73 code issues · ⚠️ 684 clone groups · ⚠️ 1033 health findings

See inline review comments for per-finding details.

Code issues (73)
Category Count
Unused files 2
Unused exports 5
Unused dependencies 19
Unused devDependencies 6
Unresolved imports 2
Unlisted dependencies 1
Circular dependencies 38
Duplication (684 groups · 28695 lines · 4%)
Locations Lines Tokens
schemas/_dialect-bundles/mysql.relations.ts:40-134
schemas/_dialect-bundles/postgres.relations.ts:40-134
schemas/_dialect-bundles/sqlite.relations.ts:40-134
95 593
cli/commands/db-sync-demote.ts:70-75
cli/commands/db-sync-promote.ts:38-43
cli/commands/dev-build.ts:100-105
cli/commands/dev-build.ts:179-184
cli/commands/dev-build.ts:299-304
cli/commands/dev-build.ts:411-416
cli/commands/dev-build.ts:552-557
cli/commands/dev-server.ts:575-580
cli/commands/dev-server.ts:840-845
cli/commands/dev-server.ts:1143-1148
cli/commands/migrate-field-groups.ts:110-115
6 70
entries/EntryList/EntryTableSkeleton.tsx:74-98
collection/components/CollectionTableSkeleton.tsx:94-118
field-group/components/FieldGroupTableSkeleton.tsx:90-114
plugins/components/PluginsTableSkeleton.tsx:86-110
singles/components/SinglesTableSkeleton.tsx:77-101
src/components/table-skeleton.tsx:100-124
25 89
collections/config/validate-config.ts:380-433
field-groups/config/validate-field-group.ts:185-238
singles/config/validate-single.ts:190-243
54 152
dispatcher/handlers/collection-dispatcher.ts:925-967
field-groups/services/field-group-table-provisioning.ts:186-236
singles/services/reconcile-single-companion.ts:110-160
51 149

… and 679 more groups.

Across 422 files.

Complexity (1033 functions above threshold)
File Function Severity Cyclomatic Cognitive CRAP Lines
singles/services/single-mutation-service.ts:937 <arrow> critical 245 ! 307 ! 13210.4 ! 1588
collections/services/collection-mutation-service.ts:6110 <arrow> critical 169 ! 160 ! 6338.2 ! 1261
src/init/reload-config.ts:1441 applyReload critical 137 ! 208 ! 4191.1 ! 1388
shared/lib/entry-validation.ts:223 validateFieldValue critical 109 ! 157 ! 2675.3 ! 432
blocks-engine/src/measure-bytes.ts:646 surveyDocument critical 102 ! 250 ! 137.1 ! 658

5029 files, 77434 functions analyzed (thresholds: cyclomatic > 20, cognitive > 15, CRAP >= 30)

Codebase health

Metric Value
Maintainability 91.7 / 100
Avg complexity 1.8

Tip

Run fallow fix --dry-run to preview auto-fixes.
Add /** @public */ above exports to preserve them.

@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: b06c21dabc

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

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

Lint / Typecheck / Test / Build (and so the blocking CI gate) fails here at step 19, Doc samples compile — main's own red since the first completed run after #1769, not this change. #1810 fixes it and is green on that job; merging it first clears this one. The oracle for this PR is Integration (postgres) and Integration (mysql), read by name.

…name in the database

Two more corners of the same rename, both from review.

A definition saved before shared junctions were refused can name one table
from two fields. Giving each of them a table of its own asks this save to
rename one table twice: the first statement succeeds, the second meets a table
that is no longer there, and the migration stops half-applied while the
registry records both new names. Refused by name, before any statement.

The generator can only see the tables this collection's own fields name, so a
junction left behind by an older migration is invisible to it and a rename
would meet that table at apply time - in production, where a migration can no
longer be refused. It publishes the moves it would make; the save boundary,
which can ask the database, refuses one whose destination already exists.
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: beb8e980bf

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

const newByName = new Map(newFields.map(f => [f.name, f]));
const carries = oldFields.filter(usesJunctionTable).flatMap(field => {
const next = newByName.get(field.name);
if (!next || !this.sameRelationTarget(field, next)) return [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require the destination field to remain junction-backed

When a many-to-many field is changed to a non-relationship type while its now-inert options retain the same target/cardinality and its junctionTable is changed or cleared, sameRelationTarget still passes because it does not inspect type. The new field is absent from after, but this branch nevertheless carries the old junction to the newly resolved name, so the migration leaves an orphan table and its links instead of dropping it. Include usesJunctionTable(next) in this guard before treating the edit as a carry.

AGENTS.md reference: AGENTS.md:L302-L305

Useful? React with 👍 / 👎.

@mobeenabdullah
mobeenabdullah merged commit 83a2e49 into main Sep 12, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: core nextly type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant