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).
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.
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.
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 schemaover a single database — is exercised as a regression test atpackages/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 (notable_removed/table_addedfires after expand), pinning the differ's attach/re-attach/detach transitions to this document's worked example.
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 affectedContact_v2rows in the same statement. Both tables change together, atomically, and both sync out. - An old peer writing
Contact_v1directly (through its own v1 lens) — onlyContact_v1changes locally. When the change syncs to any upgraded peer, the inbound application (viaDatabase.ingestExternalRowChangeswithmaintainMaterializedViewson) fires the derivation there, and the derived rows sync onward — including back to old peers, which storeContact_v2as an opaque, unmapped basis table. Upgraded peers thus act as derivation proxies for the whole network. - An old peer that later upgrades —
Contact_v2already 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.
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_v1once the local lens points atContact_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 thenotifyLensDeploymenthook toSyncManager.recordLensDeployment, which OR-folds each logical schema's directly-mapped contribution (so a table stays directly-mapped until the last mapper drops it), stampsmappedSince/unmappedSinceon the directly-mapped boundary, persists one KV record per basis table (survives restart), and emitsonBasisTableLifecycleon each transition.getBasisTableLifecycle()reads the records back — or, after the host opts in withregisterBasisLifecycleTvf(db, syncManager), query them straight from SQL via the zero-argumentquereus_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_v1last 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 ofdirectly-mapped, any inbound change to it from a remote site is presumed a direct write from a peer that still maps it, and bumpslastDirectlyMappedWriteAt = 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.
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_v1see correctly derived data, and its inbound writes toContact_v1can be applied at upgraded peers by DML replay through the table name (insert or replace/deletevia the engine rather than the bulk ingest seam — see mv-ingestion.md § DML replay vs. the ingestion seam), which rides write-through to land inContact_v2. Retirement stops being urgent: the compatibility table can persist indefinitely at the cost of its storage.
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
retentionHorizonMsinSyncConfig. Retirement inherits it: drop the legacy table no sooner thanretentionHorizonMsafter the last directly-mapped write, and a peer offline longer than the horizon was already outside the delivery guarantee for ordinary reasons. The sweep isSyncManager.evictExpiredBasisTables(now?)— host-driven (called from the same periodic maintenance path aspruneTombstones/pruneQuarantine; the library adds no timer). It reclaims a basis table's local storage only when it isdetached(out of the basis the app still declares — an in-basisunreferencedtable is a signal, never auto-dropped, since a re-map would resurrect it) and quiet past its effective horizon, wherequietSince = max(unmappedSince ?? detachedAt, lastDirectlyMappedWriteAt). The reclaim itself goes through adropLocalTable(schema, table, indexNames)callback wired to the store'sreclaimDetachedTable(the index-name list is captured into the lifecycle record before detach, since the table schema is gone afterward); it firesonBasisTableEvictedand clears the record, retrying idempotently on a drop failure. A relay-only coordinator with nodropLocalTablemakes 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 tagquereus.sync.evict = never | immediate | <ms>on the basis table overrides it (captured into the lifecycle record at lens-deploy time).neverkeeps storage forever;immediatereclaims on the first sweep after detach (zero horizon, still requiresdetached); a number is a custom horizon.
- The policy knob.
-
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 (
getTableSchemareturns nothing) — during Phase 1 ofapplyChanges, before any change is resolved or any CRDT metadata is written, so a retired table never pollutes the change log (no survivor-HLC entry thecollectChangesSinceinvariant 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, thencreate_table/drop_table/rename_tablereplay 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. Acreate_tablemakes its table known even though the basis read still returns nothing, a trailingdrop_tablemakes a present table unknown, and adrop-then-createbatch 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 perSyncConfig.unknownTableDisposition, with always-on telemetry regardless of disposition — theonUnknownTableevent, the cumulativegetUnknownTableStats()counter, andApplyResult.unknownTable— because the failure mode is otherwise silent write loss the straggler never learns about:quarantine(the default) durably holds each divertedChangeverbatim under aqt: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.ignoredrops 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, theignoredcounter still bumps), not silent.store-and-forward— implemented. Durably holds each divertedChangeexactly asquarantinedoes (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:getChangesSincefolds the forwardable held changes into itsChangeSet[]return (no new transport surface — the client and coordinator are unchanged), each re-offered with its originalhlc+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> sinceHLCbefore 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 acrosssync-store-and-forward-hold(the durable forwardable hold + telemetry) andsync-store-and-forward-relay(the outboundgetChangesSinceintegration); seedocs/sync.md§ Store-and-forward relay.- Revival / drain. — implemented. A retired table can come back — re-created app-side, a
create_tablefor it arrives in an inbound batch, or a local lens redeploy re-maps it back into the basis. When it does, its held changes (bothquarantineand forwardablestore-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-drivenSyncManager.drainHeldChanges(schema?, table?)— a sibling ofpruneTombstones/pruneQuarantine/repairChangeLog/evictExpiredBasisTables, called from the same maintenance path (or right after the host re-creates a table). Scope mirrorsQuarantineStore.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 fireonRemoteChange(so MV maintenance /Database.watch/ UI react to the revival) and each drained table firesonHeldChangesDrained({ 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-getTableSchemapeer — 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 bySyncConfig.drainOnReappearfor 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 inboundcreate_table— or arename_tablemoving a table onto the held name — that revives a held table drains it from withinapplyChanges(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 trailingdrop_table, or a rename the receiver declines — leaves the drain a no-op, while adrop-then-createbatch leaves it present and does drain); (2) a local lens redeploy whoserecordLensDeploymentre-maps a table fromdetachedback into the basis (thedetached → presenttransition) drains it after the lifecycle records are durable and theironBasisTableLifecycleevents have fired — and, because thenotifyLensDeploymenthook runs inside the firingapply schemastatement (which holds the engine exec mutex the drain'singestExternalRowChangesre-acquires), this drain is deferred to fire-and-forget when the engine reports it is mid-statement (Database._isExecuting()), running the instantapply schemacommits and releases the mutex rather than being awaited inline — unlike path (1), which runs fromapplyChangeswith no exec mutex held and is awaited inline (seedocs/sync.md§ Revival / drain); (3) a localcreate tableissued by the app itself — the quoomb-web worker subscribesdb.onSchemaChangefor{type:'create', objectType:'table', remote:false}events (which fire post-commit, after the creating transaction has committed) and callsdrainHeldChanges(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 sameSyncConfig.drainOnReappearflag and the same advisorydrainReappearedTableshelper, so a drain throw can never abort the apply or the deploy (built insync-held-change-drain-on-reappear, the reactive create_table path insync-drain-reappear-inbound-ddl, and the lens-redeploy path insync-drain-reappear-lens-redeploy; path (3) insync-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 defensiveTable not found for external writethrow (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/applySnapshotStreambootstrap 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 deltaapplyChangespath.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 inclose(), and deliberately survivesdisconnectSync()(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-onlysync-coordinatorruns the same pass on its own hourly loop, over every database currently open in itsStoreManager(seedocs/sync.md§ Who drives the sweep). Two of the five sweeps are inert there — with nogetTableSchemaoracle and nodropLocalTablecallback,drainHeldChanges/evictExpiredBasisTablesreturn 0 — butpruneTombstones/pruneQuarantine/repairChangeLogdo real work, which is why the coordinator needs a loop at all.
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) arepassthroughin the invertibility registry — fully writable, nothing to author. - Registry-invertible conversions (
±karithmetic, 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
casemapping 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 redsno-inverse— never silently dropped). A developer who wants writability during the window authors the inverse explicitly with thewith inverseclause: 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.
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: trueat registration) — a deliberate authoring assertion. The class is implemented: the function schema carries areplicableflag, builtins are auto-stamped, and the create-time MV gate rejects any non-replicable function in the body when the backing host declaresrequiresReplicableDerivations(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 withdb.registerCollation(name, cmp, { replicable: true }). The samerequiresReplicableDerivationshost 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 topragma 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.
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.
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.
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.
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 — thereplicablefunction flag (builtins auto-stamped, UDFs opt in), theBackingHost.requiresReplicableDerivationscapability 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 — areplicablecollation flag (builtins auto-stamped, custom collations opt in viaregisterCollation'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 policies —
unknown-table disposition + telemetry(implemented — structural out-of-basis detection during Phase 1 ofapplyChangeswithignore/quarantinedispositions (defaultquarantine, horizon-bounded GC viapruneQuarantine), theonUnknownTableevent +getUnknownTableStats()counter, andApplyResult.unknownTable; thestore-and-forwardrelay disposition is now also implemented — a durable forwardable hold plus outboundgetChangesSincerelay that re-offers held changes with their originalhlc+siteIdfor loop-free convergence, with arelayedactivity counter; and the revival / drain path is implemented — the host-drivendrainHeldChangesreplays 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, firingonHeldChangesDrained, a no-op on a no-oracle peer),and mapped-since bookkeeping over(static half implemented —notifyLensDeploymentSyncManager.recordLensDeploymentmaintains a durable per-basis-table lifecycle record (directly-mapped / derivation-source-only / unreferenced / detached) withmappedSince/unmappedSincetimestamps, surfaced bygetBasisTableLifecycle()+ theonBasisTableLifecycleevent; see § 2 Converge), andretention-horizon-driven retirement(implemented — the dynamiclastDirectlyMappedWriteAtsignal bumped by the change applicator, the per-tablequereus.sync.evictoverride + globalSyncConfig.basisEvictionpolicy, and the host-drivenSyncManager.evictExpiredBasisTablessweep that reclaims a detached table's storage via thedropLocalTable→ storereclaimDetachedTableseam and firesonBasisTableEvicted; see § 4 Contract). (Per-table change-logging opt-in for maintenance writes is implemented — thequereus.sync.replicatereserved tag; the store backing host queues aDataChangeEventper 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.)