Skip to content

Stage a Builder publish or unpublish from the entry's status - #8168

Merged
3mdistal merged 50 commits into
mainfrom
alice/content-builder-status-editing
Oct 27, 2026
Merged

3mdistal merged 50 commits into
mainfrom
alice/content-builder-status-editing

Conversation

@3mdistal

@3mdistal 3mdistal commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

In a Builder row's Info sidebar, the read-only Builder status property becomes a picker when the source publishes updates and allows publication changes. Picking Published or Draft stages a publish or unpublish for that entry. Nothing reaches Builder until the source review pushes it; an unpublish still needs its confirmation.

What changes

  • Staging. set-builder-entry-status records the picked transition on the row and shows the status it will have. A pick needs edit access to the page as well as the collection, since a page can be shared more narrowly than its collection; the picker is offered only with both. Both are checked again once the pick holds the row, since a refresh can hold it a while. It survives refreshes until Builder reports that status. It clears when someone picks the entry's current Builder status (Unpublished changes included), turns publication changes off, or pushes that publish or unpublish. A push that sends the other one clears the pick it started with too, since its review chose over it. A pick made while a push is in flight survives it, and so does one a push that sends neither leaves out. Published can't be picked while Builder reports changes saved in its editor, even alongside Content's own unpublished save, because Content can't see them; a tooltip says to publish from Builder. The push checks Builder again and is blocked the same way if such changes appeared after the pick. A pick re-reads the source setting under the row lock, so turning publication changes off can't leave a hidden pick behind. The clear that turning them off starts re-reads the setting the same way, so if they're back on by then, a new pick stays. A push that only publishes or unpublishes is blocked once its pick is cleared or changed, even after it was approved.
  • Review. A staged change with no field or body edit becomes its own push (metadata_update). A staged change is reviewed against Builder's live values and body, so a publish also sends Content's saved but unpublished values. The review dialog preselects the staged transition. A toggle the reviewer turns off stays off, a different pick preselects the row again, and clearing the pick removes its preselection unless the reviewer changed that row by hand. A review or push that publishes or unpublishes an entry needs edit access to that entry's page, checked again just before the push sends it. One the reviewer can only view refuses the whole review or batch before anything is written or sent. Field and body pushes keep the collection check.
  • Write adapter. A publish or unpublish with no field or body change is no longer blocked as "nothing to send". A required reference the entry already holds no longer blocks a PATCH that leaves it out. That was the Required Builder field author is missing or invalid block on the live test entry. Publishes and unpublishes send the entry's name like every other write, so a title change in the same push renames the Builder entry too.
  • Empty Builder bodies. A Content body over a Builder body known to be empty is now proposed for the push. Before, it was treated like a body never read and left out. A body saved over an empty live body keeps that record through later refreshes. With a staged publish, a body emptied in Content is proposed over Builder's live body instead of being left out, also when Builder's read carried no hash for that body, unless Builder's body was recorded empty.
  • Refresh race. A refresh rebuilds rows from what it re-reads under the row lock. A save or status pick that lands while the refresh is reading Builder is no longer dropped.
  • Repeated pushes. Every transition-only push for an entry hashed alike. The second one reused the applied first one's id in the source listing, and a third would have failed prepare on a duplicate key. The listing and prepare now take the first unused revision id. Two reviews of the same change prepared at once share one approval instead of failing on that id.
  • Disconnect. Disconnecting the last Builder source removes the empty Builder status property, its values, and its references in shared views. Property delete and disconnect now share one view cleanup. It drops sorts, filters, grouping, date fields, widths, order, calculations, wrap, freeze, form questions, and list row layouts on the deleted property. A leading checkbox or status slot on it goes back to the page icon; with no property, that slot would show the collection's first checkbox or status instead. Before, a filter left on a deleted property still applied and hid every row. Personal views drop a removed property's sorts and filters when Content returns them (Drop a deleted property's sorts and filters from every view #8169), which covers the removed status property too.

Live check

Ran on agent-native-blog-article-test only, through the test-model guard. Across both runs, the guard allowed 6 Builder writes (PATCH), all to that one entry, and blocked nothing.

  1. Draft → publish with a note change: live and Content show the new note. The required author reference was kept.
  2. Stage-only save on the published entry: Builder kept the live note, and Content showed Unpublished changes.
  3. Picked Published, then pushed: the saved note went live, and the staged change and unpublished record cleared.
  4. Picked Draft, then pushed the transition-only unpublish: the entry is back to draft, its starting state.
  5. In the Info sidebar UI: Published was enabled and Unpublished changes disabled. Picking Published staged it, and picking Draft cleared it (about 140 ms). Neither pick sent anything to Builder.
  6. After the id fix, a staged publish listed as …-2, with every change-set id distinct.
  7. On ed08350, picked Published and changed the title, then pushed: Builder's entry name and title both took the new title, and the staged change cleared.
  8. Picked Draft and restored the title, then pushed the confirmed unpublish: the entry is back to draft under its original name.

Steps 1–8 ran before 9a18a90. Its changes and the ones after it (the Unpublished changes pick, the stricter autosave block, the setting recheck, the cleared-pick block, the page access checks, the moving preselection, the empty-body push, the clear's setting check, and the shared approval) are covered by tests only. Builder didn't report meta.hasAutosaves for this entry in any state, so the status read correctly right after each push and after a refresh, and the new autosave check never blocked a publish. The six prepares uploaded 30 encrypted payload files to Builder's file storage. Content uploads them unrecorded, so they don't appear in the space's asset library. The collection is back to stage-only with publication changes off.

Not covered

  • A source role change re-imports the source's rows from Builder, so it drops staged picks along with every other row state, as it already did for Content's unpublished saves. Afterwards the status shows what Builder reports.
  • After a prepare, the dialog shows the prepared review, so a pick changed after that shows up there only once the review is refreshed. The server still blocks a publish-only push whose pick changed.
  • Executing a blocked gate still returns a 500 (execute-builder-source-execution throws a bare Error after recording the block). That predates this branch and is unchanged here.

Checks

templates/content: set-builder-entry-status.db, builder-source-review-gates.db, execute-builder-source-execution, _builder-cms-write-adapter, _builder-cms-unpublished-changes, _builder-status-property.db, _database-source-utils, content-database-source-actions, stage-builder-source-bulk-update.db, cancel-prepared-builder-source-update.db, execute-builder-source-batch, resync-content-database-source.db, content-database-lifecycle.db, database-table-columns, and BuilderSourceReviewDialog.ui all pass. The new race, dialog, repeated-id, staged-pick, editor-autosave, entry-name, setting-recheck, cleared-pick, page-access, push page-access, moving-preselection, leading-slot, empty-body, emptied-body, delayed-clear, and concurrent-approval tests each fail without their fix. tsc is clean apart from the existing virtual:react-router/server-build error. pnpm guards passes apart from the existing dead-settings-keys baseline. Both i18n guards pass. On this branch merged with main (after #8083 and #8169 landed), strict-lint and compiler-strictness report nothing on its added lines.

content_product_impact:
  lane: contract_fulfillment
  features:
    - content.feature.trust-your-connected-sources
  capabilities:
    - content.source.adapters
    - content.source.sync-policy
  record_change: included
  proof:
    - pnpm exec vitest --run actions/set-builder-entry-status.db actions/builder-source-review-gates.db actions/execute-builder-source-execution actions/_builder-cms-write-adapter app/components/editor/database-sources/BuilderSourceReviewDialog.ui (templates/content)
  rationale: Publishing and unpublishing a Builder entry becomes a staged, reviewed push from the entry's own status, through the same guarded source review as every other Builder write.

🤖 Generated with Claude Code

A save that should leave the live page alone now picks its request from the
publication state Builder reports right before the write: a draft entry gets a
plain save that Builder's reads return, and a published entry gets an autosave.
Builder keeps autosaves out of its reads, so Content records the live value
each saved field replaced and keeps its saved value through refreshes and body
reads while Builder still reports that value.

The pre-write check is now field-level for saves: when Builder's lastUpdated
moved, a save blocks only if a field it writes changed, or the body changed or
cannot be compared. Publish and unpublish stay strict.
…y an unedited page when Builder's body is empty
…tes, and block live updates whose publication state changed
A read-only "Builder status" select property (Published, Draft,
Unpublished changes) now sits first in Builder collections, in the table,
the board, and the page info sidebar. Attach, refresh, body reads, and
saves keep it current, and rows leaving the source lose it. It's never
mapped to a Builder field. After a save, the row's stored publication
state follows the write.

The save dialog's row labels use the mockup's wording and are translated,
and the agent-facing Stage only description no longer says it never
writes to Builder.
…waps

A refresh now stores its rows and their statuses in one transaction, so a
save can no longer land between them and leave the status stale. The status
property is added when a Builder source is attached, even to an empty
model. A save whose reply leaves out the publication state keeps the stored
one; only an autosave falls back to the live check.

Copies of a row or page don't carry its Builder status, and a row keeps a
status only while some Builder source in the collection still has it, so
replacing the Builder source with a local one clears it. Setup discovery
reports the status as a system definition agents can't edit, and the
French save labels say "entrée publiée".
The save dialog's footer counted autosaves and draft saves as "draft
saves" and its button said "Push N updates", while the rows say "Save".
The footer now says "2 saves", the button says "Save 2 to Builder" when
every selected row is a save, and the Stage only description says
"Saves changes to Builder — never publishes." A save to a published entry
isn't a draft. The footer and button labels move into the catalog,
translated in all 10 locales.
Picking Published or Draft in a Builder row's status stages a publish or
unpublish that the source review preselects and sends. A staged change
survives refreshes until Builder reaches it, and clears on picking the
current status, turning publication changes off, or any executed publish or
unpublish. A refresh now builds on the rows it holds locked, so a pick or
save landing during its read isn't lost. Disconnecting the last Builder
source removes the status property and its view references.
A publish or unpublish with no field or body change hashes the same every
time, so the second one for an entry reused the applied first one's id in
the source listing, and a third failed prepare on a duplicate key. The
listing and prepare now pick the first revision id no stored change set
holds.
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

…y on publish

A push now settles only the staged publish or unpublish it sent, so one
picked while it was in flight survives. A publish is blocked when Builder's
live check reports editor autosaves Content didn't make, as the status picker
already is. Publishes and unpublishes send the entry's name like other writes,
so a title change in the same push renames the Builder entry.
builder-io-integration[bot]

This comment was marked as outdated.

…row lock, and clear a pick from Unpublished changes
@3mdistal

Copy link
Copy Markdown
Contributor Author

Recap of the review-summary items that weren't posted as threads:

  • Write-mode/staging race. Fixed in 9a18a90. The pick re-reads the source setting FOR SHARE under the row lock (thread on set-builder-entry-status.ts).
  • English-only action errors. Left as is. Content's actions report errors in English throughout (85 fail() calls, none localized). The picker's UI copy, including the "publish from Builder" tooltip, is translated in every locale.
  • Invalid staged markers dropped on refresh. Left as is. Only Content writes the marker (publish or unpublish), so any other value can't be sent and is already ignored by the shown status. The refresh logs it with console.error and drops it, and the status then shows what Builder reports, so it can be picked again.
  • Extra full-row read under the lock. Left as is. The read before the lock only finds the target row. The read under the lock is the one the pick builds on, so a refresh or save that lands in between isn't overwritten. The race test covers it.

builder-io-integration[bot]

This comment was marked as outdated.

…-status-editing

# Conflicts:
#	templates/content/actions/delete-document-property.ts
#	templates/content/app/components/editor/DocumentProperties.tsx
#	templates/content/parity/matrix.md
# Conflicts:
#	templates/content/actions/duplicate-page.ts
…s' into alice/content-builder-status-editing

# Conflicts:
#	templates/content/parity/matrix.md
#	templates/content/shared/database-table-columns.test.ts
Base automatically changed from alice/content-builder-real-drafts to main October 27, 2026 16:54
…-status-editing

# Conflicts:
#	templates/content/actions/_builder-cms-unpublished-changes.test.ts
#	templates/content/actions/_builder-cms-unpublished-changes.ts
#	templates/content/actions/_builder-cms-write-adapter.ts
#	templates/content/actions/_builder-status-property.ts
#	templates/content/actions/_database-source-utils.ts
#	templates/content/actions/disconnect-content-database-source.ts
#	templates/content/actions/execute-builder-source-execution.test.ts
#	templates/content/actions/execute-builder-source-execution.ts
#	templates/content/docs/product/capabilities/content.source.adapters.md
@builder-io-integration

Copy link
Copy Markdown
Contributor

⚠️ Review Agent ran into a problem and couldn't finish reviewing the latest commit.

We've been automatically notified and are looking into it. Push a new commit to re-trigger the review, or contact support@builder.io if this keeps happening.

Error ID: 522c090972f246678c2179b280a872d0

builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

@3mdistal

Copy link
Copy Markdown
Contributor Author

On the three low items in the latest review summary:

  • Transition-only override blocker text. Reworded in b40fd83. A push that only sends a publish or unpublish is still blocked when the entry doesn't stage that transition, since such a push exists only because of a pick. The message now reads "This push only sends a publish, and this entry has no publish staged.", which is right whether the pick was cleared or the review chose the other transition.
  • Entry name in publish/unpublish requests. No change. For a publish or unpublish, the live preflight blocks on any change to Builder's update time ("Builder entry changed since this diff was approved"). So a rename made in Builder after Content's reviewed read stops the push before it can send the older name. The name sent is the reviewed one, or Content's own title change.
  • Invalid staged intent on refresh. No change. An unreadable value can only come from a write outside the intent helpers. The refresh logs it, and the status falls back to what Builder reports, so the page shows its real state and the user can pick again. Keeping an unreadable value would leave it stuck.

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Builder reviewed your changes and found 1 potential issue 🟡

Review Details

Incremental review summary

This update tightens the status-pick action by checking page permissions inside the transaction and re-reading the source row under a row lock. It also adds a second page-edit check immediately before Builder dispatch, with a regression test for access revoked during live preflight; that prior comment is resolved. The staging/review/write architecture remains sensible, but refresh and disconnect are not serialized consistently. I classify this as high risk because the feature can mutate external publication state and relies on access checks and durable intent state.

New findings

  • 🟡 MEDIUM: A refresh can replace source rows after disconnect removes the source, leaving rows associated with a deleted source.
  • LOW: Status-pick and disconnect locking can deadlock because they acquire source and row locks in opposite order.
  • LOW: The status-property ensure path can re-create the property after the last Builder source is disconnected.
  • LOW: Disabling transitions commits before clearing intents; a clear failure can let old picks reappear after re-enabling.

The concurrent row-removal finding is fixed by the locked re-read and 409 path, and was resolved. Other previously reported issues were not reposted. 🧪 Browser testing: Skipped — dev server unavailable on 5f7a7fb4df5704440d00ba929782a50b25c1c189; setup retry failed building @agent-native/toolkit. Update the setup command in project settings before a later visual-verification attempt.

@3mdistal

Copy link
Copy Markdown
Contributor Author

Builder's reviews also raised these in their summaries, without a thread:

@3mdistal
3mdistal merged commit 9526f7a into main Oct 27, 2026
43 checks passed
@3mdistal
3mdistal deleted the alice/content-builder-status-editing branch October 27, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants