Skip to content

fix(nextly): publish all languages runs hooks and applies every language's pending change - #1883

Open
mobeenabdullah wants to merge 17 commits into
mainfrom
fix/publish-all-runs-hooks-and-promotes-every-draft
Open

mobeenabdullah wants to merge 17 commits into
mainfrom
fix/publish-all-runs-hooks-and-promotes-every-draft

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

Stacked on #1859: the base branch is fix/a-stale-live-verdict-does-not-refuse-a-valid-publish. Once #1859 merges, this rebases onto main.

What changes for editors

  • "Publish all languages" and "Unpublish all languages" run hooks. They were a separate method that flipped status columns and nothing else: no beforeUpdate or afterUpdate, no field rules, no validation. They now send a status patch through updateEntry under the wildcard locale, the path a single-language publish and a scheduled release already use.
  • Every language's pending change goes live. Before, the button left every pending change unapplied while the statuses said published, and the wildcard path that releases use refused with 409 whenever another language held one. Both now apply every configured language's pending change.
  • A shared-field edit is not lost to another language's save. A pending change stores the whole document as it was when saved, so a German translation saved after an English editor changed a shared field still carries the old value. A shared value is taken from a pending change only where it differs from the live row, oldest save first, so the later save wins only when two languages changed the same shared field. Components follow the same rule matched on instance id, and each language writes only the component translations its own change holds.
  • The admin shows why publishing all languages failed instead of one fixed message.

Rulings implemented: decision:bulk-publish-and-the-pending-draft (B) and decision:all-languages-publish-uses-one-write-path (one write path).

How

  • pending-change-merge.ts holds the rule as pure functions (order, content equality, shared values, component translations), unit-tested.
  • In updateEntry, the wildcard's held-draft 409 is replaced by promoteEveryLanguageInTx. Every language's live document is read before anything is written. Each language is then built, judged by resolvePromotedDocument (fail closed, no caller payload), validated with contexts resolved before the transaction, and written. One refusal rolls back the whole write with every pending change kept. A change held for a language the app no longer configures is left in place.
  • publishAllLocales and unpublishAllLocales keep their pre-checks (a collection with no lifecycle still answers "nothing to do"; unpublish still refuses a companion with no _status column) and delegate. The route passes the request, so hooks see it. setLifecycleAllLocales (752 lines) is deleted.
  • Two gaps in the wildcard path surfaced through the existing suites and are fixed there, which also serves scheduled releases: the revalidation intent carries every language's slug, and an in-process status event is not repeated untagged when the default language's companion records the same transition, the rule the durable events already follow.

Behaviour changes worth knowing

  • wildcard-locale-contract's collection test that asserted the 409 now asserts promotion, per the ruling. Its removed-language case now also asserts the held work is kept.
  • Unpublishing every language applies each language's pending change before withdrawing, as a single-language unpublish already does.
  • On a localized collection, a subscriber to document.published hears a default-language publish once, tagged with the language, rather than once untagged and once tagged.

Not in this PR

  • Singles: SinglePublishAllService still runs no hooks and the Singles wildcard still refuses. Filed as task:singles-publish-all-on-one-write-path.
  • findAllWorkingDrafts has no ORDER BY, so the Singles publish-all shared-value winner depends on the database: finding:find-all-working-drafts-has-no-order. The collections path orders explicitly.

Verification

Each new test has a control that fails for the reason it names:

Test Control Fails with
a later translation-only change keeps an earlier shared edit merge rule replaced by "take the pending value" expected 'live note' to be 'EN edited note'
the later save wins a real conflict changes applied newest first expected 'DE note' to be 'EN note'
one denied change refuses the whole write and keeps every draft resolver skipped the guarded publish succeeds
every language's pending change is applied service as it is on #1859 409 unpublished changes in de
a block English added survives a German translation saved after it component translations taken whole expected true to be false: an empty German row stored
hooks run once for the whole document publish-all as it was expected [] to deeply equal [ 'before:POST', 'after' ]
unpublish all applies each pending change publish-all as it was expected [ 'EN v1', 'DE v1' ] to deeply equal [ 'EN v2', 'DE v2' ]
every language's slug tag is busted; one in-process event per publish wildcard path before its fix missing nextly:locposts:slug:bonjour; [ undefined, 'en', 'de' ]

Unit: pending-change-merge 15 of 15, with a naive last-save merge and a structure-only component merge each failing their case.

Integration, SQLite: the new suite 8 of 8; the wildcard contract, promoted-draft-loss, per-language drafts and localized release suites 32 of 32; every suite that calls publish all (i18n publish and unpublish, cache revalidation, first-published marker, publish RBAC, status transition events, webhook outbox, write-path events matrix, plugin document events) passing after the two wildcard fixes.

A local run of the whole integration config was taken while the machine ran at a load of about 150 on 14 cores; it reported 9 failing files, 6 of them unrelated to this change. The same 9 files re-run in isolation at normal load pass: 9 files, 78 tests. CI runs the whole config on SQLite, PostgreSQL and MySQL.

pnpm --filter nextly check-types exit 0 after the rebase onto #1859's head. eslint (--max-warnings 0), pnpm check:comments and pnpm fallow:audit exit 0; they ran before that rebase, which changed only comments in #1859's lines.

…ould allow

The resolver refuses a publish that deletes a field whose live rule
denies it, even when the promoted siblings would allow it. The edited
variant was already pinned. The deleted one is decided by the live
row's verdict alone, so it gets its own test, and both share one rule
helper.
… to be

The resolver's comments and the tests that pin it described what earlier
versions did and what was measured before a fix. They now state the rule
each piece of code holds, and why, in the present tense. The module header
also drops the comparison to another product.
A pending change stores the whole document as it was when saved, so a
shared value in it is either an edit or a stale copy of what was live.
The rule takes a shared value only where it differs from live, oldest
save first, and each language's translations from its own change,
matched on instance id inside components.
…g change

The wildcard locale refused with 409 whenever another language held a
pending change. It now applies every configured language's change, oldest
save first: a shared value is taken only where a change differs from the
live row, and each language's translations come from its own change. Each
language is judged and validated on the document it will read, and one
refusal rolls back the whole write with every pending change kept. A change
held for a language the app no longer configures is left in place.
…ons it holds

Promoting every language saved each language's components with that
language's values. A block another language added in the same write came
back with no translation for it, and saving that blank stored an empty
translation. The write now carries translations only for instances the
language's pending change holds; an added instance keeps what its author
gave it. Tested with the translation saved before and after the new block.
…hooks included

publishAllLocales and unpublishAllLocales keep their pre-checks and move
every language with a status patch under the wildcard locale, so hooks,
field rules, validation and each language's pending change come from
updateEntry. The request reaches the hooks from the route.

Two gaps in the wildcard path surfaced and are fixed there, which also
serves scheduled releases: the revalidation intent carries every
language's slug, and an in-process status event is not repeated untagged
when the write locale's companion records the same transition, the rule
the durable events already follow.
setLifecycleAllLocales stated the lifecycle gate, the row lock, the
companion sweep, the version capture, the events and the cache flush a
second time beside updateEntry. Both entry points now move every
language through updateEntry, so it goes, along with the direction
fields only it read.
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

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.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 34 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: 70af3b22-a76a-49b6-ae2b-b73d46b95658

📥 Commits

Reviewing files that changed from the base of the PR and between ce962c7 and 4274860.

⛔ Files ignored due to path filters (1)
  • .changeset/publish-all-languages-publishes-every-pending-change.md is excluded by !.changeset/**
📒 Files selected for processing (12)
  • packages/admin/src/hooks/queries/usePublishAllLocales.ts
  • packages/nextly/src/dispatcher/handlers/collection-dispatcher.ts
  • packages/nextly/src/domains/collections/__tests__/publish-every-language.integration.test.ts
  • packages/nextly/src/domains/collections/__tests__/wildcard-locale-contract.integration.test.ts
  • packages/nextly/src/domains/collections/services/__tests__/pending-change-merge.test.ts
  • packages/nextly/src/domains/collections/services/all-locales-lifecycle.ts
  • packages/nextly/src/domains/collections/services/collection-mutation-service.ts
  • packages/nextly/src/domains/collections/services/pending-change-merge.ts
  • packages/nextly/src/services/collections-handler.ts
  • packages/nextly/src/services/collections/collection-entry-service.ts
  • scripts/check-comment-convention.test.mjs
  • scripts/comment-convention-allowlist.json

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 13, 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-15T00:47:34.912035Z 4274860 New commits
ℹ️ 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.

@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: 4713e55c65

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

if (promoteEveryLanguage && promoteRestoreCtx) {
// Every configured language's pending change is applied here, and
// the write below moves only the lifecycle.
await this.promoteEveryLanguageInTx(tx, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Emit update events for every promoted locale

When a non-default locale already has published status and holds a pending edit—the normal state in which a working draft exists—this call applies and deletes that locale's draft, but the subsequent entry.updated event is emitted only for the normalized default locale, while recordSweptLocaleStatusEvents emits nothing because the locale's status did not change. Webhook and indexing consumers therefore never learn that the non-default locale's live content changed; preserve the promoted locales returned by this helper and record a locale-tagged update for each one independently of lifecycle transitions.

Useful? React with 👍 / 👎.

if (promoteEveryLanguage && promoteRestoreCtx) {
// Every configured language's pending change is applied here, and
// the write below moves only the lifecycle.
await this.promoteEveryLanguageInTx(tx, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Capture a durable version for each promoted locale

When multiple localized working drafts are promoted here, the helper writes and deletes all of them, but the ordinary capture later in updateEntry runs only once and uses localizedUpdate.writeLocale (the normalized default locale). Consequently a German, French, etc. pending snapshot is consumed without ever becoming a numbered version for that locale, so locale-filtered history cannot display or restore the content that was just released. Capture the post-promotion state of every affected locale before deleting its working draft.

Useful? React with 👍 / 👎.

Comment on lines +4403 to +4404
message: direction.successMessage,
data: { id: params.entryId, status: direction.nextStatus },

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 Report the lifecycle state that hooks actually persisted

If a beforeOperation, beforeUpdate, or beforeChange hook replaces the requested status with the opposite valid lifecycle status, updateEntry accepts that value and sweeps every locale to it, but this wrapper unconditionally reports the original direction. For example, a publish-all whose hook changes status to draft can successfully unpublish the document while returning “All languages published” and { status: "published" }; either reject a post-hook status that differs from the requested direction or return the committed status.

Useful? React with 👍 / 👎.

Comment on lines +266 to +268
for (const [key, child] of Object.entries(instance)) {
const nested = shape.nested(instance, key);
assign(

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 Do not materialize newly added component translations

When a pending draft predates a newly added localized field inside an existing component instance, withTranslationsFrom keeps the current fallback value because the draft source lacks that key, and this loop then copies every non-nested child into the locale write without checking whether the source actually held it. Publishing the draft therefore stores another locale's fallback as an authored translation, so later edits to the fallback language stop propagating. Omit translatable keys that are absent from the source instance when constructing the promoted component write.

Useful? React with 👍 / 👎.

Comment on lines +83 to +84
a.updatedAt.getTime() - b.updatedAt.getTime() ||
a.locale.localeCompare(b.locale)

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 Preserve save order when draft timestamps collide

On SQLite, nextly_versions.updated_at is stored with whole-second resolution, so edits to the same shared field in two locales within one second reach this comparator with identical timestamps; MySQL can likewise collide within its millisecond precision. Falling back to the locale code then makes alphabetical order—not the actual later save—decide which shared value is published, silently violating the advertised last-save-wins behavior. Persist and sort by a cross-locale monotonic ordering value rather than using the locale as the tie-breaker.

Useful? React with 👍 / 👎.

@mobeenabdullah
mobeenabdullah changed the base branch from fix/a-stale-live-verdict-does-not-refuse-a-valid-publish to main September 15, 2026 00:30
@github-actions

github-actions Bot commented Sep 15, 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 · ⚠️ 677 clone groups · ⚠️ 1034 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 (677 groups · 28406 lines · 3.9%)
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:927-969
field-groups/services/field-group-table-provisioning.ts:186-236
singles/services/reconcile-single-companion.ts:110-160
51 149

… and 672 more groups.

Across 427 files.

Complexity (1034 functions above threshold)
File Function Severity Cyclomatic Cognitive CRAP Lines
singles/services/single-mutation-service.ts:966 <arrow> critical 246 ! 308 ! 13317.5 ! 1650
collections/services/collection-mutation-service.ts:6186 <arrow> critical 161 ! 145 ! 5759.9 ! 1234
src/init/reload-config.ts:1417 applyReload critical 143 ! 211 ! 4560 ! 1470
shared/lib/entry-validation.ts:245 validateFieldValue critical 109 ! 157 ! 2675.3 ! 432
dynamic-collections/services/dynamic-collection-schema-service.ts:1050 generateAlterTableMigration critical 104 ! 221 ! 2440.3 ! 782

5137 files, 80171 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.

@pkg-pr-new

pkg-pr-new Bot commented Sep 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

@nextlyhq/adapter-drizzle

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

@nextlyhq/adapter-mysql

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

@nextlyhq/adapter-postgres

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

@nextlyhq/adapter-sqlite

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

@nextlyhq/admin

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

@nextlyhq/admin-css

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

@nextlyhq/blocks-engine

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

@nextlyhq/blocks-react

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

@nextlyhq/builder

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

create-nextly-app

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

@nextlyhq/eslint-plugin

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

nextly

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

@nextlyhq/plugin-form-builder

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

@nextlyhq/plugin-mcp

npm i https://pkg.pr.new/@nextlyhq/plugin-mcp@4274860

@nextlyhq/plugin-page-builder

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

@nextlyhq/plugin-sdk

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

@nextlyhq/plugin-seo

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

@nextlyhq/storage-s3

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

@nextlyhq/storage-uploadthing

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

@nextlyhq/storage-vercel-blob

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

@nextlyhq/ui

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

commit: 4274860

…service holds

Deleting setLifecycleAllLocales removed two recorded offences from
collection-mutation-service.ts, leaving 17 where the allowlist recorded 19. The
allowlist may only shrink, so the entry is lowered to 17 and the two digests
whose comments no longer exist are removed; the pinned total follows to 505.
The entry count is unchanged, since the file still holds offences.

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

ℹ️ 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 on lines +5030 to +5034
await this.applyLanguageChangeInTx(
tx,
ctx,
change,
liveByLocale.get(change.locale) ?? {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate every locale against the final shared state

When two pending drafts exist and the later one changes a shared field, this loop validates the earlier locale before that shared value is written and never revisits it. For example, a custom validator can allow note: "bad" for the German document but reject it when data.title is English; German's later draft passes, writes the shared note, and leaves the final English document invalid even though publish-all succeeds. Fold the final shared state first, then validate every locale against that state before applying any draft.

AGENTS.md reference: AGENTS.md:L303-L311

Useful? React with 👍 / 👎.

if (promoteEveryLanguage && promoteRestoreCtx) {
// Every configured language's pending change is applied here, and
// the write below moves only the lifecycle.
await this.promoteEveryLanguageInTx(tx, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Refresh the hook document after localized promotion

When a default-locale pending draft contains localized fields, this helper writes those values directly to the companion row, but the status-only caller ignores the result and leaves localizedUpdate containing only _status. The post-commit responseSource is then built from the main row—which deliberately lacks localized fields—and only overlays that status-only split, so the newly enabled afterUpdate and afterChange hooks receive stale or absent translated fields even though they were just published; hook-driven indexing or synchronization can therefore publish an incomplete document. Rebuild the resolved default-locale document after promotion before invoking the hooks.

Useful? React with 👍 / 👎.

Comment on lines +221 to +227
const structureChanged = !sameContent(
withoutTranslations(pending, shape),
withoutTranslations(live, shape)
);
return structureChanged
? pending
: withTranslationsFrom({ current, pending, shape });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve disjoint shared component edits

When two locale drafts edit different non-localized children of the same component instance, any shared difference makes this branch return the later draft's entire component snapshot. For example, an older English draft can change variant while a later German draft changes url; the German snapshot still contains the live variant, so applying it second silently reverts the English edit even though German never touched that child. Merge only the shared component paths that differ from that draft's live baseline onto current, reserving whole-instance replacement for actual structural changes.

Useful? React with 👍 / 👎.

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

Labels

scope: admin @nextlyhq/admin scope: core nextly type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants