Skip to content

Latest commit

 

History

History
171 lines (113 loc) · 39.7 KB

File metadata and controls

171 lines (113 loc) · 39.7 KB

Schema Migration in a Synced Database

Stability: Experimental — see Stability Tiers.

How an application evolves its schema when the database is replicated across peers that upgrade at different times. This document builds directly on Lenses and Layered Schemas and Materialized Views; read those first. The sync machinery referenced here is @quereus/sync (CRDT column-level LWW over HLC timestamps).

The problem

A synced application cannot coordinate upgrades. At any moment some peers run the new app version and some run the old, both reading and writing, both expecting their changes to reach everyone. A migration mechanism that requires "stop the world, transform, resume" — or a protocol version negotiation — does not fit. The mechanism must let both schema versions operate concurrently against shared data, converge, and let the old representation retire without a deadline.

The lens architecture supplies exactly the needed separation: each app version carries its own logical schema and lens, both app-local artifacts that never sync; what peers share is the basis — module-backed tables replicated by the sync layer. Migration is then purely a matter of how two different lenses map onto one shared, evolving basis.

The invariant: a frozen, shared basis

All intersecting basis tables between peers are identity- and configuration-stable: a basis table consistently holds the same meaning and the same layout, everywhere, for its whole life.

This is the load-bearing rule, and everything else in this document is a consequence of it:

  • Table presence is the unit of compatibility. There is no schema version number on the wire and none is needed. Two peers interoperate on exactly the basis tables they both hold; a peer simply ignores nothing and negotiates nothing — a table either exists with its one fixed meaning, or it does not exist. (What a peer does with inbound changes for a table outside its basis is a sync-layer policy — see Retirement.)
  • Physical layout is frozen at publication. Once a basis table is declared and made physical, its layout never changes unless the change is completely transparent — and transparency is the module's call, not the engine's. Quereus's logical/physical type separation makes many nominal changes (e.g. an integer-width widening) genuinely transparent for the memory and store modules, which hold JavaScript values; a module that cannot support the declared type faithfully should error at declaration, consistently, rather than approximate. Anything not attested transparent routes to the parallel-table pattern below.
  • Evolution is additive. A changed representation is a new basis table plus a derivation from the old — never an in-place mutation of the old. This is the distributed restatement of the deploy-time rule in lens.md § Deployment: logical evolution produces additive basis diffs, and logical removals detach mappings rather than dropping basis storage.

The pattern: expand → converge → flip → contract

A representation change runs through four phases. Only the first requires app code; the rest are observation and housekeeping.

Covered end-to-end by an engine-level capstone test. The expand → flip → contract walk below — driven entirely through declare schema + apply schema over a single database — is exercised as a regression test at packages/quereus/test/maintained-table-migration-capstone.spec.ts. It asserts data equivalence at every quiescent point and that the new table's incarnation and rows survive all three applies untouched (no table_removed/table_added fires after expand), pinning the differ's attach/re-attach/detach transitions to this document's worked example.

1. Expand — publish the new table as a derivation of the old

The upgraded app's deployment declares, in the basis schema, a new table for the new representation, maintained from the old one — a maintained table over the old basis table, with the conversion in the body and the backing placed in the synced store module. The canonical expression is the declared-shape table form (materialized-views.md § DDL statements): the new table's layout is authored (the frozen basis), and the body must derive exactly that shape:

-- Direct (imperative) form — the canonical table form:
create table Contact_v2 (
  handle text collate nocase primary key,
  email text
) using store()
  maintained as select handle collate nocase as handle, email
                from Contact_v1;
-- Declarative form. The differ recognizes the maintained clause on table items
-- and the `materialized view` item identically — both normalize to one declared
-- record, so either form applies as a non-destructive attach/re-attach:
declare schema Store {
  table Contact_v1 (handle text primary key, email text) using store();

  -- New representation: handle compared NOCASE. Value-preserving conversion.
  create materialized view Contact_v2
    using store()
    as select handle collate nocase as handle, email
       from Contact_v1;
}

-- Logical (app-local, v2 app only): the design just says what it wants.
declare logical schema App {
  table Contact (handle text collate nocase primary key, email text);
}

-- Lens (app-local, v2 app only): map the design over the new representation.
declare lens for App over Store {
  view Contact as select handle, email from Contact_v2;
}

The old (authoritative) table stays exactly as it was; old-version peers are untouched and unaware. The new table is derived: row-time maintenance keeps it consistent with the old table inside every writing transaction, on every peer that has deployed the definition.

What each peer sees during the window:

  • An upgraded peer writing through the new logical table — the lens resolves to the derived table; write-through rewrites the DML to target the source (Contact_v1); the source write fires row-time maintenance, which re-derives the affected Contact_v2 rows in the same statement. Both tables change together, atomically, and both sync out.
  • An old peer writing Contact_v1 directly (through its own v1 lens) — only Contact_v1 changes locally. When the change syncs to any upgraded peer, the inbound application (via Database.ingestExternalRowChanges with maintainMaterializedViews on) fires the derivation there, and the derived rows sync onward — including back to old peers, which store Contact_v2 as an opaque, unmapped basis table. Upgraded peers thus act as derivation proxies for the whole network.
  • An old peer that later upgradesContact_v2 already exists locally with synced data; the deploy attaches the definition to the existing rows rather than re-deriving from scratch (alter table Contact_v2 set maintained as … — verify-by-diff), reconciling any lag by keyed diff rather than refill: identical content writes nothing, divergence resolves derived-wins with only the genuine changes reported.

Convergence holds because the derivation is a pure, replicable function of the source rows (requirements below): every peer that runs it computes identical bytes, so concurrent derivation writes at different peers carry different HLC stamps but the same values, and column-level LWW settles on one of them harmlessly. Value-identical maintenance writes are suppressed (no entry in the change log), so the derivation does not echo between peers.

2. Converge — observe, don't guess

The developer's question — "has everyone upgraded?" — decomposes into a static signal and a dynamic one:

  • Static (per peer): implemented. Every basis table is in one of four states, computed on each lens deploy from existing metadata — directly-mapped (the deployed lens backs a logical column with it — from the lens deployment snapshot's relationBacking), derivation-source-only (referenced solely as a maintained table's source — Contact_v1 once the local lens points at Contact_v2), unreferenced (in the basis, neither mapped nor a source), or detached (no longer in the basis; storage may linger). The transition into derivation-source-only is the "this table is now legacy" signal. The sync layer maintains this durably: the basis-backing store module forwards the notifyLensDeployment hook to SyncManager.recordLensDeployment, which OR-folds each logical schema's directly-mapped contribution (so a table stays directly-mapped until the last mapper drops it), stamps mappedSince / unmappedSince on the directly-mapped boundary, persists one KV record per basis table (survives restart), and emits onBasisTableLifecycle on each transition. getBasisTableLifecycle() reads the records back — or, after the host opts in with registerBasisLifecycleTvf(db, syncManager), query them straight from SQL via the zero-argument quereus_basis_lifecycle() TVF (select "table", state, "unmappedSince" from quereus_basis_lifecycle() where state = 'derivation-source-only'). This bookkeeping is advisory — a throwing recorder is swallowed at the store forwarder so it can never abort a deploy.
  • Dynamic (network-wide): implemented. The sync layer's peer census — when did a change to Contact_v1 last originate at a peer whose deployment still maps it directly? The sync system, not the engine, owns this (the engine has no notion of peers). It is computed as a conservative observation, not an oracle: once the local classification has the table out of directly-mapped, any inbound change to it from a remote site is presumed a direct write from a peer that still maps it, and bumps lastDirectlyMappedWriteAt = max(current, change wall-time) on the lifecycle record (the change applicator, batched, zero-overhead pre-migration). While some peer still writes the legacy table directly, foreign writes keep arriving and keep resetting the clock; when they cease network-wide for a full horizon, retirement is safe. Over-counting (treating a maintenance write as a direct one) only delays reclamation; under-counting — the dangerous direction — cannot happen. The retention-horizon eviction policy below builds on this.

3. Flip — make the new table authoritative

When old-schema writers have (or are believed to have) drained, reverse the derivation: redefine Contact_v1 as maintained from Contact_v2 with the inverse body, and drop the Contact_v2 derivation. Nothing moves physically — both tables exist with the same rows; only the maintenance direction changes:

alter table Contact_v2 drop maintained;          -- detach: rows intact, now authoritative
alter table Contact_v1 set maintained as         -- reverse: derive the old from the new
  select handle collate binary as handle, email from Contact_v2;

(Detach first — attaching Contact_v1 from Contact_v2 while Contact_v2 still derives from Contact_v1 is rejected as a derivation cycle. The attach reconciles by diff, so when the two tables already agree — the steady state of the window — the flip writes zero rows.)

  • The flip is available exactly when the conversion is invertible over the data — trivially true for value-preserving changes (collation, transparent type changes), true for lossy changes only where an authored inverse supplies the representative mapping.
  • After the flip, a straggler old peer is still served: its reads of Contact_v1 see correctly derived data, and its inbound writes to Contact_v1 can be applied at upgraded peers by DML replay through the table name (insert or replace / delete via the engine rather than the bulk ingest seam — see mv-ingestion.md § DML replay vs. the ingestion seam), which rides write-through to land in Contact_v2. Retirement stops being urgent: the compatibility table can persist indefinitely at the cost of its storage.

4. Contract — retire the old table

Drop the Contact_v1 derivation (alter table Contact_v1 drop maintained, then drop table when retired) and remove it from the basis schema. The engine's part ends there (the same boundary as lens.md § GC of detached prior basis storage); reclaiming the physical storage is the storage module's / application's / sync layer's job, under a policy:

  • Retention horizon. Implemented. A CRDT deployment already carries a single time bound — "changes older than T are not guaranteed deliverable" — expressed as retentionHorizonMs in SyncConfig. Retirement inherits it: drop the legacy table no sooner than retentionHorizonMs after the last directly-mapped write, and a peer offline longer than the horizon was already outside the delivery guarantee for ordinary reasons. The sweep is SyncManager.evictExpiredBasisTables(now?)host-driven (called from the same periodic maintenance path as pruneTombstones / pruneQuarantine; the library adds no timer). It reclaims a basis table's local storage only when it is detached (out of the basis the app still declares — an in-basis unreferenced table is a signal, never auto-dropped, since a re-map would resurrect it) and quiet past its effective horizon, where quietSince = max(unmappedSince ?? detachedAt, lastDirectlyMappedWriteAt). The reclaim itself goes through a dropLocalTable(schema, table, indexNames) callback wired to the store's reclaimDetachedTable (the index-name list is captured into the lifecycle record before detach, since the table schema is gone afterward); it fires onBasisTableEvicted and clears the record, retrying idempotently on a drop failure. A relay-only coordinator with no dropLocalTable makes the sweep a no-op.

    • The policy knob. SyncConfig.basisEviction ({ mode: 'horizon' | 'never' | 'immediate', horizonMs? }, default { mode: 'horizon' }) is the global default; a per-table reserved tag quereus.sync.evict = never | immediate | <ms> on the basis table overrides it (captured into the lifecycle record at lens-deploy time). never keeps storage forever; immediate reclaims on the first sweep after detach (zero horizon, still requires detached); a number is a custom horizon.
  • Unknown-table disposition. Once retired, a straggler's inbound changes reference a table outside the receiver's basis. The receiver detects this structurally — no version check; the table simply isn't in the local basis (getTableSchema returns nothing) — during Phase 1 of applyChanges, before any change is resolved or any CRDT metadata is written, so a retired table never pollutes the change log (no survivor-HLC entry the collectChangesSince invariant would later trip on). Detection folds the batch's own in-flight DDL into the current basis by simulating it: every name the batch's kept migrations mention is seeded with whether the receiver has it right now, then create_table / drop_table / rename_table replay in timestamp order (the same total order the DDL replay itself uses), each applying the verdict the store adapter will reach against the catalog — so a rename the receiver will decline (its old table was dropped locally, or the migration omits the old name) leaves the new name as absent as it really will be, rather than routing rows at a table that does not exist. A create_table makes its table known even though the basis read still returns nothing, a trailing drop_table makes a present table unknown, and a drop-then-create batch leaves it known — its rows land in the new incarnation rather than being diverted. A name no kept migration mentions falls back to the basis read. Cell versions and tombstones are filed by table NAME and nothing re-files them on a drop or a rename, so the simulation also tracks which table's bookkeeping sits under each name: rows for a name whose surviving table is not the one that bookkeeping describes — a drop-then-re-create, or the table-swap shape that renames a different table into the vacated name — resolve read-free, so a tombstone from the departed table cannot silently discard them. A re-delivered batch (it changes no local schema, so the surviving metadata is the current incarnation's) and a name renamed away and back (its own table returns to it) both resolve normally and must still lose LWW to anything newer. Diverted changes are handled per SyncConfig.unknownTableDisposition, with always-on telemetry regardless of disposition — the onUnknownTable event, the cumulative getUnknownTableStats() counter, and ApplyResult.unknownTable — because the failure mode is otherwise silent write loss the straggler never learns about:

    • quarantine (the default) durably holds each diverted Change verbatim under a qt: key, HLC-keyed so a re-applied batch re-quarantines idempotently (exactly one entry per change), and folded into the same admission unit as the data/metadata commit so a crash after the clock watermark advances but before the hold is durable cannot strand a straggler's write. Held entries are operator-inspectable (QuarantineStore.list) and bounded: they GC at the same retention horizon tombstones use (pruneQuarantine, now - receivedAt > retentionHorizonMs), so a change held past the delivery guarantee is reclaimed. This is the safe out-of-box default — the minimal disposition that prevents write loss while bounding storage; cost is zero in the common (no-straggler) case.
    • ignore drops the diverted changes (writing nothing durable) — the deliberate opt-out for deployments that genuinely do not want to retain post-retirement straggler traffic. Write loss is then intentional and observable (telemetry still fires, the ignored counter still bumps), not silent.
    • store-and-forwardimplemented. Durably holds each diverted Change exactly as quarantine does (HLC-keyed, same admission unit, horizon-bounded GC) and marks it forwardable, so this peer relays it to peers that still have the table. The relay rides the existing outbound delta sync: getChangesSince folds the forwardable held changes into its ChangeSet[] return (no new transport surface — the client and coordinator are unchanged), each re-offered with its original hlc + siteId — the straggler's fact, never re-stamped to the relayer's clock. That original-HLC identity is the loop-breaker: a peer that already holds the change re-holds it idempotently (HLC-keyed, one entry), and the per-peer delta watermark stops re-sending after one exchange, so a forwarded change converges across relay hops with no per-table peer-membership oracle. Forwardable changes are filtered > sinceHLC before relay (identical to the change-log contract) so a forwarded-only round never regresses the consumer's watermark; the accepted corollary is that a change causally older than the holder's recency with a peer (HLC ≤ sinceHLC) is not re-propagated via this delta path — the same scalar-watermark limitation the base delta layer has (store-and-forward serves the transitional uneven-retirement window, and quarantine already prevents write loss outside it). Snapshot paths are carved out (a snapshot transfers the offering peer's own basis, and a forwarded change is for a table that peer does not have, so forwardable entries are delta-only). Built across sync-store-and-forward-hold (the durable forwardable hold + telemetry) and sync-store-and-forward-relay (the outbound getChangesSince integration); see docs/sync.md § Store-and-forward relay.
    • Revival / drain.implemented. A retired table can come back — re-created app-side, a create_table for it arrives in an inbound batch, or a local lens redeploy re-maps it back into the basis. When it does, its held changes (both quarantine and forwardable store-and-forward — a held change is a held change regardless of why it was held) are replayed into the now-present table rather than waiting on horizon GC, via the host-driven SyncManager.drainHeldChanges(schema?, table?) — a sibling of pruneTombstones / pruneQuarantine / repairChangeLog / evictExpiredBasisTables, called from the same maintenance path (or right after the host re-creates a table). Scope mirrors QuarantineStore.list: a (schema, table) drains one table, (schema) a schema, and the no-arg form sweeps every held entry whose table is back. Each held change is resolved against the reappeared table exactly like a fresh inbound change (LWW / tombstone-blocking / allowResurrection), then cleared from the hold on resolution whether or not it applied — a held change that lost LWW or was tombstone-blocked resolves identically on any later sweep, so holding it longer is pointless; only entries for still-absent tables stay held. A held column change for a column the re-created table no longer has is drift-dropped (resolved-and-cleared, never sent to the store), so one stale entry cannot poison the table's whole drain admission. Drain runs as a separate apply, after any re-creating batch has committed, so the fresh data lands first and the older held changes simply LWW-resolve against it — no intra-admission interleaving and no re-merge of the (already-merged) HLC watermark. Applied changes fire onRemoteChange (so MV maintenance / Database.watch / UI react to the revival) and each drained table fires onHeldChangesDrained ({ schema, table, drained, applied, skipped }); a forwarded entry that drains stops being relay-offered and rides the normal change log thereafter (it is a real local version now). The whole call is a no-op returning 0 on a relay-only / no-getTableSchema peer — without the oracle a coordinator cannot tell which held tables are present — and zero-cost when nothing is held. The library adds no timer for the periodic sweep, but the three reappearance paths each trigger an immediate scoped drain as a separate post-commit apply (gated by SyncConfig.drainOnReappear for the two library-internal paths, default on; the host path fires unconditionally via the public primitive; all are advisory and idempotent with the sweep) — refining the earlier rule to never interleaves drain into the admitting batch, rather than never drains inline: (1) an inbound create_table — or a rename_table moving a table onto the held name — that revives a held table drains it from within applyChanges (only an applied migration triggers it — an HLC-dominated duplicate that lost resolution does not — and the same simulation decides whether the batch left the table there at all: a batch that leaves the name absent — a trailing drop_table, or a rename the receiver declines — leaves the drain a no-op, while a drop-then-create batch leaves it present and does drain); (2) a local lens redeploy whose recordLensDeployment re-maps a table from detached back into the basis (the detached → present transition) drains it after the lifecycle records are durable and their onBasisTableLifecycle events have fired — and, because the notifyLensDeployment hook runs inside the firing apply schema statement (which holds the engine exec mutex the drain's ingestExternalRowChanges re-acquires), this drain is deferred to fire-and-forget when the engine reports it is mid-statement (Database._isExecuting()), running the instant apply schema commits and releases the mutex rather than being awaited inline — unlike path (1), which runs from applyChanges with no exec mutex held and is awaited inline (see docs/sync.md § Revival / drain); (3) a local create table issued by the app itself — the quoomb-web worker subscribes db.onSchemaChange for {type:'create', objectType:'table', remote:false} events (which fire post-commit, after the creating transaction has committed) and calls drainHeldChanges(schema, table) fire-and-forget, so a locally-driven re-create no longer waits up to one maintenance interval before held edits replay. Paths (1) and (2) reuse the same SyncConfig.drainOnReappear flag and the same advisory drainReappearedTables helper, so a drain throw can never abort the apply or the deploy (built in sync-held-change-drain-on-reappear, the reactive create_table path in sync-drain-reappear-inbound-ddl, and the lens-redeploy path in sync-drain-reappear-lens-redeploy; path (3) in sync-drain-reappear-local-ddl).

    Detection requires the basis oracle (getTableSchema); a relay-only coordinator constructed without one leaves detection inert and falls back to the store adapter's defensive Table not found for external write throw (which also still guards a genuine basis/store-ownership mismatch — a table the basis claims but the store has not provisioned). Snapshot paths are out of scope: applySnapshot / applySnapshotStream bootstrap a peer's whole basis (the offering peer's basis, not a straggler delta), so an unknown table there is a different scenario and still hits the adapter's defensive throw. This contract scopes to the delta applyChanges path.

    Where the maintenance path lives (implemented). The library is timer-free by design — it exposes the five host-driven sweeps (drainHeldChanges / pruneQuarantine / pruneTombstones / repairChangeLog / evictExpiredBasisTables) but schedules none. The quoomb-web worker is the concrete host that is that periodic path: it runs all five sweeps on one loop (5-minute default cadence, SYNC_MAINTENANCE_INTERVAL_MS), plus one immediate pass when the sync module initializes so a prior offline session's held changes drain on startup. The loop is owned by the sync module, not the connection — it starts on module init and stops in close(), and deliberately survives disconnectSync() (a table can reappear and held changes drain while offline; tombstone/quarantine GC is purely local). Passes are single-flight (a slow pass never overlaps the next tick) and each sweep is error-isolated (one throwing sweep is logged and the remaining four still run) — that pass shape lives in the library (runSyncMaintenancePass / createSyncMaintenanceTicker, packages/quereus-sync/src/sync/maintenance.ts) so hosts share it rather than each re-deriving it; the library still arms no timer. The relay-only sync-coordinator runs the same pass on its own hourly loop, over every database currently open in its StoreManager (see docs/sync.md § Who drives the sweep). Two of the five sweeps are inert there — with no getTableSchema oracle and no dropLocalTable callback, drainHeldChanges / evictExpiredBasisTables return 0 — but pruneTombstones / pruneQuarantine / repairChangeLog do real work, which is why the coordinator needs a loop at all.

Writability during the window

While the old table is authoritative, every write through the new logical schema must reach it through the derivation's inverse. The rule:

During the parallel phase, writability through the new schema is exactly the invertible fragment. Full writability arrives at the flip.

  • Value-preserving conversions (collate nocase, no-op casts) are passthrough in the invertibility registry — fully writable, nothing to author.
  • Registry-invertible conversions (±k arithmetic, declared lossless casts) — writable via the composed inverse.
  • Lossy conversions are the developer's prerogative — e.g. collapsing twenty legacy codes into three. The forward case mapping is ordinary SQL in the derivation body; with no inverse the column is simply read-only through the new schema until the flip (a write reds no-inverse — never silently dropped). A developer who wants writability during the window authors the inverse explicitly with the with inverse clause: the write stores a chosen representative. PutGet (what you write is what you read back) is still checked; GetPut (round-tripping the base is the identity) is intentionally surrendered for a non-injective mapping — a write normalizes — and surfaces as an acknowledgeable advisory (lens.getput-lossy), not an error.
  • A direct write to a derived column with neither kind of inverse is incoherent during the window even in principle: maintenance would re-derive and clobber it on the next source write. The engine's read-only stance is not a limitation here; it is the correct semantics.

Determinism requirements

A synced derivation must be a pure function of the source rows, bit-identical across peers, platforms, and app versions — strictly stronger than the engine's existing per-database determinism gate (which admits a UDF that is stable on one machine but platform-dependent). Consequences:

  • Built-in functions qualify automatically (Quereus implements its own collation and case-folding, so NOCASE semantics cannot drift between peers' JS engines). A UDF used in a synced derivation must be declared replicable (replicable: true at registration) — a deliberate authoring assertion. The class is implemented: the function schema carries a replicable flag, builtins are auto-stamped, and the create-time MV gate rejects any non-replicable function in the body when the backing host declares requiresReplicableDerivations (the future sync-store; memory/store declare nothing, so the class is inert by default). It is orthogonal to — and not waived by — pragma nondeterministic_schema. See mv-maintenance.md § Maintenance strategy for the create-time gate.
  • Custom collations are covered the same way. A collation whose sort/fold governs derived bytes (a comparison, ORDER BY, GROUP BY, DISTINCT, or the backing key) is a parallel divergence surface to a function: a locale-aware ordering can fold or sort differently across peers' platforms. Built-in collations (BINARY/NOCASE/RTRIM) auto-qualify (pure JS string operations, bit-identical across engines); a custom collation opts in with db.registerCollation(name, cmp, { replicable: true }). The same requiresReplicableDerivations host capability drives the create-time reject of a non-replicable custom collation in the body (or on a declared backing-key collation the body never names) — inert by default, also orthogonal to pragma nondeterministic_schema.
  • A derivation must not mint identity. Per-peer generation (uuid7() in a derivation body) is already rejected by the determinism gate; the subtler rule is that a new identity column in a migration target must be derived from source data (a hash of the source key) — or the column must wait until after the flip, when it can be an ordinary write-time default. Write-time surrogate defaults on ordinary tables are unaffected: they are evaluated once at the origin peer, captured as resolved values, and replicate as data.

Convergence hazards

Key coarsening. A conversion can weaken row identity — NOCASE makes 'Bob' and 'bob' one key. The hazards split by loudness:

  • At deploy (loud): the create-time fill rejects duplicate backing keys, so a peer upgrading over data that already collides fails atomically, before any catalog mutation. Correct: this is a data problem the developer must resolve (merge the source rows) before the migration can deploy.
  • In the window (silent → observable): an old peer inserts a colliding row; it arrives at upgraded peers through the ingest seam, which re-validates nothing, and the keyed derivation upsert last-writer-wins — two source rows merge into one derived row. And as long as both source rows live, each edit to either re-asserts its image into the shared derived key: the derived row oscillates deterministically (every peer agrees at every quiescent point) but does not settle until the source rows are merged. The merge is no longer silent: each realized merge fires the runtime collision telemetry below, so an operator can observe the window happening.

The structural fact is statically detectable — the derivation's key fails to functionally determine the source primary key — and the create path detects it: when the body has no provable unique key but the source key survives through value-preserving passthrough lineage (bare column / collate / no-op cast), the backing is keyed on the coarsened lineage key under the output collations and the create emits the key-coarsening warning ("colliding source rows will last-write-win until they are merged") on the structured logger's warn channel, with MaterializedViewSchema.coarsenedKey as the record-side stamp — see materialized-views.md § Coarsened backing keys for the full runtime contract (the in-window LWW merge, the delete-one-sibling anomaly the full-rebuild paths recover, and the loud REFRESH during a collision window). The implemented runtime collision telemetry is the operational complement to that static warning: every realized in-window merge fires a host-observable db.onMaintenanceCollision(...) event (carrying the K' key, the diverged column names, and the old/new rows) and increments the cumulative db.getMaterializedViewCollisionStats() counter — committed-merges-only, transaction-batched, zero-overhead for non-coarsened views, and observe-only — so a host can watch the convergence hazard happen in real time rather than infer it from the static warning. Detection, not prevention: the merge-on-coarsen behavior is often exactly what the migration intends.

Constraint divergence. More generally, the old schema may admit states the new schema rejects (the new uniqueness above is the common case). The stance: the old table's constraints govern while it is authoritative; the new schema's stricter constraints are fully enforced only against the new table once it is authoritative. A migration that needs the stricter invariant to hold during the window must clean the data first — there is no mechanism that can retroactively reject a concurrent old-peer write without breaking convergence.

Synced vs. local derived tables

Most materialized views are local — covering indexes and performance caches, derivable on demand, with no business in the change log (replicating a derived index to a peer that derives its own is pure waste). A migration target is the exception: its rows must exist independently of the source, because the source is scheduled to die.

"Synced" is deliberately not a core-engine concept. The differences are expressed at existing seams:

need where it lives
backing stored in the synced module using store(...) on the materialized view
maintenance writes recorded in the sync change log the backing host module's decision inside applyMaintenance, opted in per table via the reserved tag quereus.sync.replicate = true (default off — a privileged maintenance write emits no module data events otherwise). The store host queues one local DataChangeEvent per realized BackingRowChange, so the sync layer records column versions / HLC stamps / tombstones as for an ordinary write. Create-fill / refresh (replaceContents) likewise publishes genuine deltas against the committed contents — one event per real insert / update / delete, nothing for a byte-identical key — so cold/static derived rows reach old peers at deploy while a value-identical re-fill suppresses (no storm).
value-identical upsert suppression (echo prevention) universal maintenance behavior, not a sync feature
replicable-determinism validation (functions and collations) the backing-host capability declares the requirement; the engine validates at create

The engine never learns the word "synced": it learns that this host demands a stricter determinism class and that this table opted into change-logging. The sync-store module is simply a host that demands them.

The degenerate case: when no parallel table is needed

Not every logical change needs the pattern. A collation change on a non-key, non-unique column is purely a lens-boundary property: declare the new collation in the logical schema, lens straight onto the unchanged basis table, done — no new basis table, no window, no retirement. Even a collation change participating in a unique constraint may only need a local (unsynced) covering MV with the new ordering for enforcement, since the bytes never change. The full parallel-table pattern is forced only when the shared representation itself must change: a key's identity semantics, a value transform, a non-transparent type change, a split or merge. Reach for the cheapest mechanism that suffices.

Current gaps

The pattern above is the design target; these pieces are pending (tracked as tickets):

  • Authored inverses (with inverse (col = expr, …) on result columns) — parser/AST, write-path consumption, lens-prover integration (vu-inverses.md § Authored inverses).
  • Replicable determinism class for UDFs + host-declared requirements on the backing-host capability. Implemented — the replicable function flag (builtins auto-stamped, UDFs opt in), the BackingHost.requiresReplicableDerivations capability declaration, and the create-time MV gate that consumes it (see § Determinism requirements). The remaining piece is a host that actually demands it (the sync-store, below). Collation replicability is out of scope (functions-only). Custom collations are now covered too — a replicable collation flag (builtins auto-stamped, custom collations opt in via registerCollation's options object), gated by the SAME host capability over both the body's fold/order/key sites and the backing key's declared collations.
  • Sync-layer policiesunknown-table disposition + telemetry (implemented — structural out-of-basis detection during Phase 1 of applyChanges with ignore / quarantine dispositions (default quarantine, horizon-bounded GC via pruneQuarantine), the onUnknownTable event + getUnknownTableStats() counter, and ApplyResult.unknownTable; the store-and-forward relay disposition is now also implemented — a durable forwardable hold plus outbound getChangesSince relay that re-offers held changes with their original hlc + siteId for loop-free convergence, with a relayed activity counter; and the revival / drain path is implemented — the host-driven drainHeldChanges replays held changes (quarantine + forwardable) into a table that has reappeared in the basis, resolving each like a fresh inbound change and clearing it from the hold, firing onHeldChangesDrained, a no-op on a no-oracle peer), and mapped-since bookkeeping over notifyLensDeployment (static half implementedSyncManager.recordLensDeployment maintains a durable per-basis-table lifecycle record (directly-mapped / derivation-source-only / unreferenced / detached) with mappedSince / unmappedSince timestamps, surfaced by getBasisTableLifecycle() + the onBasisTableLifecycle event; see § 2 Converge), and retention-horizon-driven retirement (implemented — the dynamic lastDirectlyMappedWriteAt signal bumped by the change applicator, the per-table quereus.sync.evict override + global SyncConfig.basisEviction policy, and the host-driven SyncManager.evictExpiredBasisTables sweep that reclaims a detached table's storage via the dropLocalTable → store reclaimDetachedTable seam and fires onBasisTableEvicted; see § 4 Contract). (Per-table change-logging opt-in for maintenance writes is implemented — the quereus.sync.replicate reserved tag; the store backing host queues a DataChangeEvent per derivation write when set, on the row-time maintenance path and the create-fill / full-rebuild path (replaceContents), the latter as the minimal keyed diff against the committed contents so a value-identical re-fill suppresses.)