Skip to content

Builder CMS live writes + multi-source row-union content databases#1485

Draft
3mdistal wants to merge 37 commits into
mainfrom
slice/builder-live-writes
Draft

Builder CMS live writes + multi-source row-union content databases#1485
3mdistal wants to merge 37 commits into
mainfrom
slice/builder-live-writes

Conversation

@3mdistal

@3mdistal 3mdistal commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What this is

Builder CMS live writes plus multi-source row-union content databases, end to end. Originally scoped to live writes; it grew (with sign-off) to cover the full row-union feature. Now rebased on main (which extracted the database UI into database/DatabaseView.tsx and added the useT i18n layer).

Live writes (the original slice)

  • State-preserving publish, write-mode tiers (read-only / stage-only / publish-updates), publication transitions, and bulk push.
  • Decluttered review UI: merged "Builder changes" card, split pending vs pushed, applied-change collapse.
  • Sources IA reorg: root shows connected sources + "Add a source"; integrations live inside the add flow.
  • Live writes remain gated to the safe test model (agent-native-blog-article-test); other collections stay read-only.

Multi-source row-union (slices 6a–6c)

A content database can stack rows from several Builder collections in one table, each row owned by one collection and independently writable.

  • 6a — writable leaf for any source. The Sources panel + review/push/write-mode/refresh path are scoped to the viewed source (not just the primary), via a sourceId threaded through every write action. Includes two change-detection fixes (cross-source create leak; phantom no-op diffs from the internal "Source" tag being seeded as a Builder field).
  • 6b — new-row source picker + ownership-by-tag. "New" becomes a picker ("Add a row to…" → collection or Local) that pre-tags the row; change-detection reads the Source tag → owning source (id-based) so a tagged row becomes a create_draft for that collection. Also fixes a resync over-claim (a refresh used to re-link every row to whatever source refreshed).
  • 6c — column field-binding editor. A logical column can draw its value from a different field per source. New bind-content-database-source-field action (repoint + backfill, with unbind); column-settings UI ("Sources feeding this column" + "Bind a field from a source"). Guards: excludes managed/system/internal-Source fields, type-compatible only, one field per source per column.

Verification

  • Typecheck clean; full suite green (865 tests) including new DB-integration tests for the bind action (6 cases) and the resync over-claim self-heal, plus change-detection scoping unit tests.
  • Live-verified end to end before the main rebase: the visible blog+resources row union, the new-row picker creating a correctly-tagged create_draft, and a column binding backfilling per-source values (ZIP/Spreadsheet/PDF on resource rows, blank on blog rows).
  • Adversarially reviewed by six Codex passes across the slices; all High and clear-cut Medium findings fixed (id-based Source identity, deterministic primary ordering, bind-action hardening, per-source auto-sync/review/badge scoping).

Known follow-ups (non-blocking)

  • The re-homed row-union panel in DocumentDatabase.tsx and the shared review dialog use hardcoded English where the rest of the editor now uses useT/dbTexti18n the new row-union strings.
  • Row-union UI lives in the page editor (DocumentDatabase.tsx); porting it to the inline-block DatabaseView.tsx is a follow-up.
  • Moving an already-synced row between collections (vs. tagging a new one) isn't wired yet.
  • A pre-6b-fix over-claimed database needs one live resync per source to fully heal (new DBs are fine).

🤖 Generated with Claude Code

3mdistal and others added 11 commits June 24, 2026 13:15
Proves the full execute orchestration (prepare→execute→reconcile) performs a
safe Builder autosave end-to-end using the REAL write client — the one seam
previously only unit-tested with a mocked client. Runs `executeBuilder
SourceExecutionWithDeps` with real `executeBuilderCmsWrite` against the safe
model, asserts the execution succeeds + reconciles and the live/published
artifact is unchanged (autosaved value never goes live).

Gated behind BUILDER_LIVE_E2E=1 + BUILDER_PRIVATE_KEY + BUILDER_PUBLIC_KEY;
skips offline so normal CI stays green. Verified: typecheck, prettier, full
guard suite, skip-mode, and a real live run all pass; cleans up its throwaway
entry.

Authored via Codex (GPT-5.5), reviewed + live-verified by Claude.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a guarded autosave-only toggle next to the Builder source status badge so
an editor can turn on live writes without the agent/CLI. Reuses the existing
`builderSourceLiveWriteControlState` helper and the file's existing toggle
styling; calls the already-wired `onSetBuilderLiveWrites` callback.

Guard rails: shown only for the safe write model (a muted hint explains why it's
absent on other Builder models), disabled on `!canEdit || sourceActionPending`,
autosave-only (enforced by the callback + server gates). role="switch" +
aria-checked for a11y. UI-only; no server/action changes.

Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck,
prettier, 81-test component suite, full guard suite). Browser visual pass owed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a concise README section: autosave-only semantics (never changes the
live/published artifact), credentials via the existing Builder Connect flow
(no separate key entry; .env.local for local dev), the safe-model-only gate
(agent-native-blog-article-test), how to enable (toggle or
set-content-database-source-write-mode), and the execution guards. Scoped
honestly to metadata + existing-entry autosave — body diffs are a later slice.

Authored via Codex (GPT-5.5), reviewed by Claude.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per review, the source name + status badge + toggle on one row crowded the
header (name truncated to "agent…"). For the safe-model Builder source, drop
the redundant Read-only/Live-writes-on badge and move the control to a dedicated
"Enable live writes (autosave)" row with a bare switch; the name row gets its
space back. Non-safe Builder models keep the Read-only badge + hint; non-Builder
sources unchanged. a11y (role=switch/aria-checked) preserved.

Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier,
IconPencil import still used). UI-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the state-blind autosave/draft/publish intents with a typed write
effect that makes state-preservation structural:
- autosave / update_in_place send only changed data.* and NO `published` field
  (Builder PATCH merges data.* and preserves omitted publication state — proven
  against real Builder), so they cannot change publication state by construction.
- publish / unpublish are the ONLY effects that set `published`, gated behind an
  explicit per-item transition intent (publish) / confirmation (unpublish).
- create_draft writes new entries as draft.
update_in_place/publish/unpublish trigger webhooks (live-affecting); autosave
stays quiet. Dry-run staleness now compares `effect`. Legacy non-autosave push
modes reinterpret as update_in_place (no more blind publish/draft).

Also updates the source-panel layout test for the decluttered live-writes
control (drops the removed "Live writes on" badge copy).

Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier,
guards, full suite 782 pass). Live-read classification + transitions wired next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ilder writes

Adds the "re-check at write time" capability that didn't exist (the dry-run only
compared a local snapshot). Before any live-affecting write (update_in_place /
publish / unpublish), reads the target entry's current Builder state via a new
readBuilderCmsEntryLiveState and blocks before claim/write on:
- missing entry (deleted in Builder),
- stale: live lastUpdated != the row's lastSourceUpdatedAt baseline (someone
  edited it since the diff was approved),
- transition mismatch: publish on a non-draft, or unpublish on a non-published.
autosave/create_draft skip the preflight. Threads publicationTransition /
confirmUnpublish through the action schema into the plan.

Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier,
guards, full suite 789 pass). NOTE for live-test phase: must verify the
lastUpdated format matches across the sync baseline and the live read (epoch vs
ISO) so update_in_place doesn't falsely block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…works

Live verification caught a feature-breaking bug: Builder delivery returns
`lastUpdated` as a NUMBER and stringFromRecord only accepted strings, so the
synced `lastSourceUpdatedAt` baseline never captured the entry version — the
Task-2 preflight then flagged EVERY update_in_place/publish/unpublish as stale.

- Adapter: timestampStringFromRecord captures a numeric lastUpdated as its
  stringified epoch (string/ISO fallbacks kept).
- Execute: toEpochMs + liveTimestampsDiffer normalize both sides to epoch-ms
  (number / numeric-string / ISO) before comparing; unknown values fall back to
  strict string compare (never silently skip the guard).
- Gated live test extended with real readLiveEntry: update_in_place is NOT
  falsely stale-blocked + takes content live, a wrong baseline DOES block, and
  publish/unpublish transitions work — all against real Builder (5/5 live pass).

Authored via Codex (GPT-5.5), reviewed + live-verified by Claude (typecheck,
prettier, guards, offline suite, 5/5 live).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ublish)

Replaces the binary autosave toggle with an intentional write tier:
- read_only (default; new sources start here),
- stage_only → autosave revisions (human publishes in Builder),
- publish_updates → state-preserving live writes (update_in_place).
Publication transitions (publish a draft / unpublish a published) are an extra
per-source allowance (allowPublicationTransitions) that requires publish_updates
and stays per-item + confirmed. capabilities.liveWritesEnabled is now derived
from writeMode; legacy pushMode/flags still work for older sources.

UI: the source panel's control becomes a three-tier selector (safe model only),
revealing "Allow publish/unpublish per item" at publish_updates. Effect
derivation maps tier→default effect. Settings/action validate tier combinations
and safe-model-only.

Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier,
guards, offline suite 795 pass, 5/5 live cases against real Builder incl. the
new transition gate). Browser visual pass of the tier selector owed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ror)

Adds executeBuilderSourceBatchWithDeps + an execute-builder-source-batch action
to push many approved outbound change-sets in one pass over the proven per-item
pipeline:
- bounded concurrency (default 3, cap 8),
- continue-on-error with per-item {changeSetId, status, message} results,
- a batch summary (total/succeeded/blocked/failed),
- resumable: already-succeeded executions are skipped (per-item idempotency),
- transitions applied ONLY when explicitly mapped per change-set (bulk default
  stays update-in-place; never auto-publishes/unpublishes).
Extends prepare so an explicit publish/unpublish prepares the matching gate.
UI: a "Push all approved (N)" affordance (safe-model-only) that shows the
returned summary + non-succeeded per-item messages.

Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier,
all guards incl. no-unscoped-queries, offline suite 801 pass). Batch orchestration
is unit-tested over the already-live-verified per-item path; UI visual pass owed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he diff

The review/diff now makes publication intent explicit per row (the philosophy:
transitions are deliberate, item-level acts):
- per-row default effect label (Stage autosave / Update in place — keeps current
  published/draft state),
- per-row Publish / Unpublish controls, shown ONLY when the source allows
  transitions (publish_updates + allowPublicationTransitions); mutually
  exclusive; Unpublish is destructive-styled and requires explicit confirm,
- a footer intent summary ("N update in place · M publish · K unpublish"),
- selections flow into the batch `transitions` map (unselected rows push with no
  transition — update-in-place/autosave per tier; nothing auto-transitions).

Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier,
guards, offline suite 805 pass). UI visual pass owed (spacing/wrap in real
review payloads).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrites the section for the full feature: per-source write tiers (read_only /
stage_only / publish_updates), state-preserving update-in-place (never sends
`published`, so it can't change publication state; Builder merges fields +
preserves envelope), explicit per-row publish/unpublish transitions (gated +
unpublish-confirm), new-entries-as-draft, bulk "Push all approved" with summary,
the write-time stale guard, and webhook behavior. Keeps the body-diff caveat.

Authored via Codex (GPT-5.5), reviewed by Claude (prettier clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@netlify

This comment has been minimized.

@3mdistal 3mdistal marked this pull request as draft June 25, 2026 11:47
@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

Recap skipped for 6cb2d92: draft PR.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

3mdistal and others added 2 commits June 25, 2026 09:20
Closes the "new row does nothing" gap (Alice's testing). A local database item
not linked to a Builder entry (no source row) with a non-empty title now becomes
an outbound change-set; with a null target it resolves to the create_draft effect
(POST as draft — already live-proven). Title + property field values become the
create body. Skips titleless rows and rows that already have a stored outbound
change-set. loadSourceSnapshot loads the database's items + their property values
(owner-scoped — guard:no-unscoped-queries clean) and threads them in; the
detection extension is additive (a separate loop — existing title-diff path
untouched). New-row create is safe (no baseline comparison, so no normalization
risk).

Hand-implemented by Claude (Codex unavailable — hung repeatedly), verified:
typecheck, prettier, all guards, full suite 807 pass. Functional pass owed
(create appearing in the review diff → push → new Builder entry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…der rows

Broadens existing-row change detection from title-only to every mapped property
field: local value vs the synced sourceValues baseline, compared with a stable,
key-order-insensitive serialization (null/undefined/"" treated as empty; absent
local value = "not loaded", skipped — never reported as cleared). Title + changed
property fields merge into one change-set; the dedup-vs-stored check is
generalized to per-field exact match (preserves "surface a new edit after an
older staged record"). Multi-field bodies already flow through the adapter
(nestedBuilderPatch) and review UI.

Severity note: detection is advisory — the write path is independently safe
(state-preserving), so a diff miscompare over/under-reports in the review (caught
by human review), it cannot lose data or mis-publish. A false-diff guard test
(local==source ⇒ no change-set) covers the main risk.

Hand-implemented by Claude (Codex unavailable), verified: typecheck, prettier,
all guards, full suite 809 pass. Functional pass owed (edit a field → see it in
the review diff → push).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3mdistal and others added 23 commits June 25, 2026 12:27
Two bugs blocked pushing a new-row create change-set under the
publish_updates tier:

1. Gate-key divergence. prepare derived the idempotency-key push mode
   from the write tier (publish_updates -> "publish") while execute used
   the change-set's own pushMode (local creates hardcode "autosave").
   The keys disagreed only for creates, so execute never found the
   prepared gate and threw "Prepare the Builder execution gate before
   executing it." Both paths now resolve push mode through one shared
   helper, resolveBuilderCmsExecutionPushMode (tier wins).

2. Self-defeating create guard. builderSafetyChecks blocked create_draft
   whenever the target was a synthetic-fixture row — but an unmatched
   synthetic-fixture row (sourceRowId `builder-<documentId>`, no real
   entry) is exactly what makes the effect create_draft, so every create
   was blocked. The unmatched-row blocker now applies only to effects
   that write to an existing entry (autosave / update_in_place).

Verified live: a new workspace row pushed through the publish_updates
tier creates a real Builder draft entry and reconciles the local row to
the returned entry id. Updated the two guard tests that codified the old
block into positive create assertions, plus regression coverage for the
shared push-mode resolver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
End-user audit of the live-writes flow found internal/debug vocabulary,
redundant sections, and alarming risk treatment on benign changes. This
trims the review dialog and source panel to plain language.

Honest effect labels (server-threaded):
- Add `effect` to ContentDatabaseSourceReviewRowSummary, resolved via a new
  resolveBuilderCmsWriteEffect helper (does not require an approved gate).
  A create now reads "Create draft", not "Update in place".

Review dialog (shown to every user) — full declutter:
- Drop the "Where it will go" (raw model slug, push mode, read mode) and
  "Risk check" (internal chips) sections.
- Replace with one plain destination line ("Writes a new draft to Builder —
  won't publish") plus inline warnings shown ONLY when something is actually
  wrong (a real conflict, or an unpublish).
- Plain-language result/status ("Ready" / "Needs attention" / "Pushed")
  folded into the footer; effect-aware primary button ("Create draft").

Risk recalibration:
- "live writes enabled" was bumping EVERY outbound change to high risk.
  Removed — live writes is a capability, not per-change risk. A plain
  create/edit is now low; red is reserved for conflicts/unpublish.

Source panel:
- Humanize microcopy ("guarded Builder autosave path" → "Review local edits
  before they're written to Builder"; tier descriptions).
- Tidy the Code-mode developer card: drop duplicate chips and the repeated
  "live writes enabled", collapse the raw request URL + idempotency key
  behind a "Technical details" disclosure.

Verified live: the decluttered source panel renders the humanized copy,
green low-risk, trimmed chips, and the Technical-details disclosure.
Full suite green (814 passed); dialog helpers covered by new unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Code-mode "Local Builder changes" card kept rendering a finished
(applied) change with the full review ceremony — diff, risk chip,
"approved by", and the whole Execution-gate block. A done change doesn't
need any of that.

- An applied change now collapses to a single confirmation line:
  "✓ <title> · Created draft · pushed 2h ago". The effect label is read
  from the recorded execution payload, not re-resolved (the live resolver
  would mislabel a completed create as an update once the row is matched).
- Pending changes keep the diff visible but fold the reasons line,
  review event, and execution-gate metadata behind a single "Details"
  disclosure; the risk chip only shows when risk is above low.

Verified live in Code mode. Typecheck + DocumentDatabase suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion

The Sources panel put the pending-changes badge on the generic "Builder"
integration row and made the connected collection drill through
provider → space → model-picker to reach its own settings.

- Move the pending-changes badge onto the connected collection row (where
  the changes actually live); drop it from the generic Builder row.
- Clicking the connected collection now navigates straight to its detail
  leaf (write mode + local changes) via a synthesized model summary,
  instead of reopening the Builder space/model picker.

Verified live: badge sits on the Primary collection, and one click opens
its detail. Typecheck + DocumentDatabase suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Local Builder changes" roster mixed completed changes with pending
ones and re-rendered the full field diff that the "Review diff" button
right above already opens.

- Split the list: pending changes stay under the section; applied changes
  move to a muted "Recently pushed" group, so the section's "review local
  edits before they're written" copy actually describes what's under it.
- Replace each pending card's inline Current/Proposed diff with a one-line
  scope hint ("N field changes · open Review diff to see details"). The
  diff is shown in the review dialog, not duplicated here.

Verified live. Typecheck + DocumentDatabase suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The source panel had two cards for one idea: a "1 change ready to push"
summary + Review button, then a separate "Local Builder changes" list.
The split made the heading wrap (title fighting the button for the row)
and put the action above the changes it acts on.

Merge into a single "Builder changes" card that reads like a commit flow:
- title + one-line intent
- pending changes (the items)
- a "Review changes" action right below them (the panel only opens the
  review dialog; the dialog owns the push verb)
- a muted "Recently pushed" history, capped at 3

The card is now shown to all users (not just Code mode) so the queued
items are visible, not just a count; the per-row "Details" disclosure
(execution gate, idempotency key) stays gated to Code mode via a new
showDetails prop.

Verified live. Typecheck + DocumentDatabase suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tions live in "Add a source"

First slice of the multi-source work (row-union design brief). Pure UI/IA;
no backend behavior change.

- Root Sources view shows only connected sources + "Add another source"
  (a single "Add a source" button when the database has none). The
  Integrations and Agent-Native apps sections no longer live at the root.
- "Add a source" now hosts the integrations: Builder (live, browse +
  attach), Notion (coming soon), and Analytics under Agent-Native apps
  (coming soon). Builder stays offered there even when a Builder source is
  already connected, so additional collections can be added.

Adding an additional Builder source still uses today's attach behavior;
making each attached collection independently writable is a later slice
per the brief.

Verified live: root shows connected + add only; "Add a source" lists
Builder/Notion/Analytics; Builder navigates into the space browser.
Typecheck + DocumentDatabase suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…source backend)

Slice 2 of the row-union multi-source build. Purely additive: every
write/review/settings action now accepts an optional sourceId and resolves
that specific source; when omitted it falls back to the primary, so
single-source behavior is byte-for-byte unchanged.

- _database-source-utils: add getContentDatabaseSourceSnapshotById +
  getContentDatabaseSourceSnapshotForWrite, and getExistingSourceById +
  getExistingSourceForWrite (by-id-or-primary resolvers).
- sourceId added to the request schemas/types and threaded through:
  prepare-builder-source-review, prepare-builder-source-execution,
  execute-builder-source-execution (+ realExecutionDeps),
  execute-builder-source-batch (+ realBatchDeps/runOne),
  validate-builder-source-execution, stage-builder-revision,
  review-content-database-source-change-set,
  set-content-database-source-write-mode, refresh-content-database-source.
- stage-builder-revision now resolves its source via the for-write snapshot
  (was getExistingSource primary guard + a separate primary snapshot).

No client wiring yet — the UI passes sourceId in the later UI slice. Full
action suite green (178), typecheck + prettier clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + create-fix + UI)

Three Codex instances reviewed three slices in granular detail. Fixes:

Backend multi-source (review A):
- prepare-builder-source-review resolved the source/guard/approval via the
  PRIMARY (getExistingSource) even when args.sourceId targeted a non-primary
  source — so gates landed on the wrong source and the Builder guard checked
  the wrong type. Now resolves the target snapshot for the guard, approval,
  and review payload (built from the target, not response.source/primary).
- getExistingSource now orders by createdAt (oldest = primary), matching the
  snapshot resolver, so an omitted sourceId is deterministically the primary
  on multi-source databases (was unordered after the for-write refactor).

Idempotency key (review B):
- The execution-gate idempotency key now keys on the RAW resolved push mode
  (may be "none") instead of collapsing "none" → "autosave". A read-only gate
  no longer shares a key with a stage-only gate for the same change-set.
- Execute keys on the resolved mode, not pushModeConfirmation, removing a
  prepare/execute key-divergence path (the confirmation is still validated in
  the plan).

Review dialog / card (review C):
- Publish/Unpublish transition controls are hidden for create_draft rows, and
  builderReviewEffectiveRowEffect never relabels a create as publish/unpublish
  (the adapter always writes a draft when there's no entry id) — no more
  wrong-action label on a live-write control.
- The merged "Builder changes" card shows "Review changes" only when there are
  reviewable (pending/staged/approved outbound) changes, not for stale
  source-changed conflicts that open an empty dialog.
- The applied-change collapse reports the SUCCEEDED execution's effect/time,
  not whatever the latest (possibly later-blocked) gate row says.

Full suite green (814), typecheck + prettier clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e property (slice 5)

Row-union slice 5. Adds the "add an additional Builder collection as its own
writable source" path; each collection's entries become their own rows.

- attach-content-database-source: new `mode: "add"` path — when a source already
  exists and mode is "add", insert an additional Builder source (insertSecondarySource)
  and import its entries as their OWN database items/rows via the existing
  primary-import machinery (importBuilderCmsEntriesAsDatabaseItems + seed fields/rows
  + read metadata), instead of replacing the primary or read-only-federating.
- importBuilderCmsEntriesAsDatabaseItems: new `skipTitleDedup` flag so a second
  collection's same-titled rows aren't dropped by the cross-database title dedup
  (per-source re-import idempotency still handled by builderCmsEntryAlreadyRepresented).
- ensureDatabaseSourceProperty: auto-creates a "Source" select property (one option
  per source + "Local") and tags every item with its owning collection; rows with no
  source binding are "Local". Option ids preserved across re-runs.
- UI: onAttachBuilderSource passes mode "add" when a source already exists, "replace"
  for the first source. The existing model-picker → Attach flow triggers it.

Implemented; typecheck + full suite green (814). Live end-to-end verification
(attach a 2nd collection, see row-union + Source tags) is pending Chrome
reconnection after the machine restart — the entry import needs the running app's
Builder keys. Reuses the proven primary-import path; new DB logic is the Source
property + the add orchestration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ce (slice 5)

Each mode:"add" attach starts a fresh source with no prior rows, so attaching
the same collection again would insert a duplicate source and re-import
duplicate rows. databaseSourceExistsForTable now blocks a duplicate add with a
clear error. Typecheck + suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e, pagination)

Codex review D on the attach-writable slice found:
- High: the add-branch seeded the new source from ALL database items
  (sourceSetupPayload returns everything), binding the PRIMARY's rows to the
  new source and corrupting row identity / the Source tag. Now snapshots
  existing items before import and seeds the new source from ONLY the items it
  imported (before/after documentId diff).
- Medium: the auto "Source" select stored the source NAME as its value, but the
  UI matches select values by option ID (board grouping, pills, filters). Now
  stores the option id.
- Medium: response pagination only scoped the primary's rows to the page,
  treating row-union secondary rows as join-only. Now scopes every
  document-backed source's rows; federated join rows (empty documentId) stay
  intact.

Noted (not fixed): no (database_id, source_table) unique index — the
duplicate-attach guard is check-then-insert (single-user low risk; a unique
index is a follow-up migration).

Typecheck + suite green (814).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Sources panel's attached-model leaf and the whole review/push/write-mode
path were hardwired to the primary source. A row-union database has multiple
writable Builder collections, so opening any of them must land on its own
writable leaf and scope writes to that collection.

- Resolve the leaf's source by the model being viewed (sourceTable is unique
  per database — duplicate-attach is guarded), not by assuming primary.
- Recompute the leaf's change-set groups, sync state, and write-mode controls
  from that source; route refresh / review / push / write-mode / disconnect
  through the source's own id via the already-threaded backend sourceId.
- Track an active review source id so the review dialog + push target the
  opened collection; reset on disconnect.
- Per-source reviewable badge on the Sources list; mark every attached
  collection (not just primary) in the space model list.

sourceId omitted ⇒ primary ⇒ byte-for-byte single-source behavior. Purely a
client wiring change over the backend sourceId support from 1ab87e4.
Typecheck + suite green (814).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs surfaced once each source's own change-set card became visible via
the writable leaf:

1. Cross-source create leak: change detection loaded ALL database items as
   create candidates for every Builder source, so each collection claimed the
   other collections' rows as new-row create_drafts (e.g. a blog row showed as
   a create under zz-resources). Now a Builder source excludes documents owned
   by another source, and a truly unsourced "Local" row creates only against
   the primary — not fanned out to every attached collection. Single-source is
   unchanged (no other owners; primary allows unsourced creates).

2. Phantom field change on freshly-imported rows: seedMockSourceFields mapped
   EVERY local property to a writable Builder field, including the auto-created
   internal "Source" tagging property → its option-id value diffed against an
   absent baseline, so every row showed a no-op "1 field change" (and a push
   would have written the internal tag to Builder). The Source property is now
   excluded from source-field seeding.

Live-verified: page/figma-imports badges 0 (was 6), zz-resources card shows
only its own 3 rows. Typecheck + suite green (814).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- a Builder source never creates rows owned by another source
- only the primary adopts unsourced "Local" rows as creates

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backend (_database-source-utils.ts):
- The internal "Source" property exclusion was name-only and applied to all
  source types — a user field named "Source", or any non-Builder/local-table
  source, lost its mapping. Now scoped to Builder sources and matched on the
  internal tag's exact shape (a `select` named "Source"), so user fields and
  legacy/local sources are untouched.
- Primary detection ordered by createdAt alone (nondeterministic on ties) and
  diverged from the write path. All "primary = oldest" orderings now share an
  (createdAt, id) tie-break, so the create-adoption primary is definitionally
  the same source getExistingSource resolves.

Client (DocumentDatabase.tsx):
- Auto-sync refreshed the primary even when viewing a non-primary source's
  leaf; it now targets the viewed source (primary at the root), and the
  throttle no longer blocks syncing a freshly-opened source.
- The review push sent a stale builderReviewSourceId to the backend while the
  dialog rendered primary-derived state; push now targets the same resolved
  source the dialog shows (activeReviewSource), falling back to primary.
- The collapsed Sources settings-row badge summed only the primary's reviewable
  changes; it now sums across every source so a secondary's pending pushes show.

Deferred (documented in the brief as the first slice-6b task): a row tagged for
a non-primary collection via the Source property isn't yet adopted as a
create_draft by that collection — that needs the new-row source-picker work and
ownership-by-tag in change detection.

Typecheck + suite green (816).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolves the deferred Codex finding from slice 6a: a new, still-unlinked row
tagged for a non-primary collection via the visible "Source" select property is
now adopted as a create_draft by THAT collection — not silently routed to the
primary. loadSourceSnapshot maps each row's Source option-id to its owning
sourceId (option name === source name) and passes taggedSourceByDocumentId;
the create loop creates a tagged row only against its tagged source, and falls
back to the primary-only rule for untagged "Local" rows.

Foundation for the new-row source picker (next). Typecheck + tests green (28
in the change-detection suite).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resyncBuilderCmsSourceSnapshot re-seeded source-row identity from EVERY database
item, so refreshing any source claimed every row — fine when only the primary
ever auto-synced (single owner), but in a row-union it cross-links every row to
every refreshed collection and corrupts ownership. Now, with multiple sources, a
resync links only the rows that belong to this source: its remote-backed entries
when the read is live (which also self-heals any prior over-claim, since rows are
deleted then reseeded), or just the rows it already owned when offline. New /
"Local" / other-collection rows stay unlinked so the Source-tag create path can
adopt them into the right collection. Single-source is unchanged (all items).

Without this, a row created via the new-row source picker is claimed by the
primary on the next sync and never surfaces as a create_draft for its tagged
collection. Live-verified: each row now has exactly one source link, and a row
tagged for zz-resources surfaces a create_draft under zz-resources. Suite green
(817).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e 6b)

When a content database has 2+ sources, the "New" button becomes a small picker
("Add a row to…") listing each connected collection plus "Local (no collection)".
Picking a collection creates the row already tagged with that collection via the
auto-created "Source" select, so the backend routes its create_draft to the right
source (ownership-by-tag). Single-source databases keep the plain "New" button.

Live-verified: picking "zz Resources" creates a row tagged zz-resources, and that
row surfaces as a "Create draft" in the zz-resources Builder-changes card — not
the primary. Editing the Source pill afterward re-targets the create (the Source
property is a normal editable select). Typecheck + suite green (817).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lets a logical column draw its values from a different field in each source
(row-union). New action bind-content-database-source-field binds an existing,
type-compatible, unmapped source field to an existing column (or unbinds it):
it repoints the source-field mapping's propertyId and backfills that source's
per-row values into the column. Read and write already flow through propertyId,
so binding is the whole mechanism — a column can be fed by blog.body AND
zz.resourceType at once, each row showing its own source's value.

Guards: excludes integration-managed/system/derived fields and the internal
Source tag column; requires type compatibility (a text column is permissive);
at most one field per source per column.

UI: the column-settings menu gains a "Sources feeding this column" section
listing each bound source field (with unbind) and a "Bind a field from a
source" submenu of bindable, type-compatible fields grouped by source. Threaded
`sources` into the column header → PropertyManagementPopover.

Live-verified: bound zz-resources "Resource Type" into a "Type" column — the
three zz rows show ZIP / Spreadsheet / PDF while blog rows stay blank.
Typecheck + suite green (817).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6b (review C):
- Source-tag identity is now id-based, not name-based: each Source select
  option's id IS the sourceId ("Local" uses a fixed "local" sentinel that can't
  collide with a UUID). Resolving a row's tag to its owning source is pure id
  matching, so duplicate collection display names — or a collection literally
  named "Local" — can no longer misroute rows (High). ensureDatabaseSource
  property builds one option per source keyed by id and tags rows with the
  source id directly.
- The new-row picker derives each collection's tag from its source id (always a
  valid option), so it can no longer silently create an untagged row when an
  option fails to resolve (Medium).

6c (review D):
- The bind action now clears the column's values for ALL of a source's rows
  before writing, so binding a sparse field no longer leaves stale/previous
  values on rows whose new value is empty (High).
- Rebinding a field that's already bound to another column is rejected (was
  silently repointed, orphaning the old column's values) (High).
- "One field per source per column" is enforced server-side, not just in the UI
  — a direct action call can no longer bind two fields from one source to one
  column (High).
- A multi-value (list) source field can no longer bind into a text column
  (silent lossy coercion); enforced server + client (Medium).

Offline-resync stale-over-claim (C2) and the overlap-delete (D4) are left as
documented edges: both are prevented for new databases by the row-union
one-source-per-row invariant and self-heal on a live resync.

Typecheck + suite green (817).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…6b/6c)

Boots a real in-memory libsql DB + migrations and drives the actual code paths
that previously had only live verification:

- bind-content-database-source-field.db.test.ts (6 cases): backfills only the
  bound source's rows; clears a stale column value when the new field is empty;
  rejects rebinding an already-bound field; rejects a second field from the same
  source on one column; rejects a multi-value field into a text column; supports
  two sources feeding one column + unbind.
- resync-content-database-source.db.test.ts: simulates the pre-fix over-claim
  (source A claims every row incl. source B's) and asserts the live resync
  self-heals — A keeps only its own remote-backed rows, B is untouched. Mocks
  the Builder read client so it runs offline-deterministic.

Suite 824 passing (+7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ites

# Conflicts:
#	templates/content/actions/execute-builder-source-execution.ts
#	templates/content/app/components/editor/DocumentDatabase.layout.test.ts
#	templates/content/app/components/editor/DocumentDatabase.tsx
#	templates/content/app/components/editor/DocumentProperties.tsx
#	templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx
@3mdistal 3mdistal changed the title Builder CMS live writes: state-preserving publish, tiers, transitions, and bulk Builder CMS live writes + multi-source row-union content databases Jun 26, 2026
@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

…or + review dialog

The slice-6 row-union UI re-homed onto main shipped with hardcoded English while
the rest of the editor uses the useT/dbText i18n layer. Wire every flagged
visible string through i18n:

- New catalog keys under `database.*` for the source picker ("Add a row to…",
  "Local (no collection)"), the writable-leaf write-mode controls (Builder write
  mode, Stage only / Publish updates + descriptions, Read-only), the Builder-
  changes card, the SourcesListView, the column field-binding editor ("Sources
  feeding this column", "Bind a field from a source"), and the review dialog.
- BUILDER_WRITE_MODE_OPTIONS now carries i18n keys resolved via db().
- builderReviewResultStatus returns a labelKey resolved by the caller; the
  review dialog gains useT and routes all its strings through database.* keys
  (reusing main's existing keys where they already existed).
- Translations added for all 9 non-English locales (zh-CN, zh-TW, es, fr, de,
  ja, ko, pt-BR, hi, ar) so catalog coverage is complete.

guard:i18n-catalogs passes (no raw literals, full coverage, no script
contamination); typecheck clean; suite green (865).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant