Stability: Experimental — see Stability Tiers.
How quereus-sync replicates the catalog — tables, columns, indexes — rather than row
data, and how an application ships its initial schema as a syncable seed. The data path and
storage layout live in sync.md; the wire protocol and transports live in
sync-protocol.md.
Schema (catalog) changes use the same CRDT approach as data, giving eventual convergence across all replicas with no perpetual migration log.
- Catalog as Data: schema elements (tables, columns, indexes) carry HLCs like row data
- Column-Level Granularity: each column definition has its own HLC, enabling parallel schema changes
- Most Destructive Wins: DROP takes precedence over modification
- DDL Before DML: sync batches apply schema changes before data changes
- No Perpetual Log: only current state is tracked, not a migration history
Schema metadata is stored alongside data metadata, same patterns:
| Key Pattern | Purpose | Value |
|---|---|---|
sv:{schema}.{table}:__table__ |
Table existence | {hlc, exists, ddl} |
sv:{schema}.{table}:{column} |
Column definition | {hlc, definition, deleted?} |
sv:{schema}.{table}:{index}:__index__ |
Index definition | {hlc, definition, deleted?} |
Schema conflicts follow a hierarchy where more destructive operations take precedence:
DROP TABLE > DROP COLUMN > ALTER COLUMN > ADD COLUMN
DROP TABLE > DROP INDEX > CREATE INDEX
Within the same level of destructiveness, Last-Write-Wins (LWW) applies based on HLC.
Examples:
A: DROP COLUMN foo @ HLC(1000, 1, A)
B: ALTER COLUMN foo... @ HLC(2000, 1, B)
⇒ DROP wins (more destructive), even though B has the higher HLC.
A: ALTER COLUMN foo SET DEFAULT 'x' @ HLC(1000, 1, A)
B: ALTER COLUMN foo SET DEFAULT 'y' @ HLC(2000, 1, B)
⇒ B wins (same level, higher HLC).
A: ADD COLUMN bar INTEGER @ HLC(1000, 1, A)
B: ADD COLUMN bar TEXT @ HLC(2000, 1, B)
⇒ B wins (same level, higher HLC); the column ends up TEXT.
Invariant: SYNC-001
Why the sort is needed at all: neither sm: key order (schema, kind, name — index
migrations ahead of table ones) nor arrival order is causal, so producers and consumers on
both paths sort (sortMigrationsByHLC).
"Within a batch" is the whole guarantee: a streamed snapshot is many batches (the
receiver flushes every 100 pending row changes), so DDL arriving after a flush cannot
help it. The streaming protocol therefore puts every schema-migration chunk ahead of
all table data — see sync-protocol.md § Streaming Snapshot API.
type SchemaChangeType =
| 'create_table'
| 'drop_table'
| 'add_column'
| 'drop_column'
| 'alter_column'
| 'create_index'
| 'drop_index'
| 'create_view'
| 'drop_view'
| 'create_trigger'
| 'drop_trigger';
interface SchemaChange {
type: SchemaChangeType;
schema: string;
table: string;
column?: string; // For column operations
objectName?: string; // For index/view/trigger
definition?: string; // DDL or column definition
hlc: HLC;
deleted?: boolean; // True for DROP operations
}On receiving a remote schema change: compare HLCs under "most destructive wins"; if the
remote wins, update local schema metadata and execute the DDL against the database with
the remote: true flag, whereupon the store emits schema change events for UI reactivity.
The precedence test (shouldApplySchemaChange) is:
// Most destructive wins
if (remote.deleted && !local.deleted) return true; // DROP beats non-DROP
if (!remote.deleted && local.deleted) return false; // non-DROP loses to DROP
return compareHLC(remote.hlc, local.hlc) > 0; // same level: LWWWhen it passes, applySchemaChange writes the new SchemaVersion (hlc, definition,
deleted) and, if the change carries a definition, executes the DDL via
applyDDL(definition, { remote: true }); otherwise it returns 'skipped'.
Replicated DDL is applied idempotently, by two independent gates.
The first, in change-applicator.ts, runs before the adapter is involved: an incoming
migration is looked up by (schema, object name, schema version), and skipped and counted
if one is already recorded there with an HLC ≥ the incoming one. That absorbs the ordinary
cases — the same batch delivered twice, or a migration the receiver originated itself. The
object identity here is the object's own name: for an index migration, the index name,
with no table component.
The second gate only sees migrations the first admitted: a migration whose version
slot is free, or whose HLC beats what is recorded there. Two peers offline at the same
time can each run the same create table orders, and the HLC winner is then delivered to
a peer that already has the table. So before executing anything, the store adapter
(store-adapter.ts § decideSchemaChange) checks whether the named object is already in
the migration's wanted state:
| Situation | Outcome |
|---|---|
| Object absent (create) / present (drop) | Execute the DDL |
| Object already in the wanted state, definition matches | Converge silently — nothing is executed, the change still counts as applied and its migration metadata commits |
| Object already exists with a different definition | Error naming the object and printing both definitions |
"Definition matches" is decided by regenerating the canonical DDL from the local
TableSchema / IndexSchema (generateTableDDL / generateIndexDDL with no db
argument — exactly how the origin produced the DDL it put on the wire) and comparing it
against the received DDL after a small normalization: trim, drop a trailing ;, collapse
whitespace runs, compare case-insensitively.
An alter_column migration (the coarse "table definition changed" migration every
ALTER TABLE records) is decided per alteration arm: the adapter parses the statement with
the engine's own parser (decideAlterTable) and compares the parsed action against the
local TableSchema. A parse failure is logged and the DDL executed as-is, so the
engine's own diagnostic is what surfaces. If the named table does not exist locally,
the migration converges with a warning (the local drop was the more destructive change) —
consistent with drop_index's absent-owner arm.
| Alteration | already-applied when |
otherwise |
|---|---|---|
add column |
column present with the same logical type | absent → execute; different type → conflict naming the column and both types |
drop column |
column absent | execute |
rename column |
old absent, new present (neither: converge with a warning) | execute |
add constraint |
a constraint of that name exists (named), or an unconditional UNIQUE over the same column set exists (unnamed — a partial UNIQUE, i.e. one carrying a predicate, is weaker and does not count) | execute |
drop constraint |
no constraint of that name | execute |
rename constraint |
old absent, new present (neither: converge with a warning) | execute |
alter column … set data type |
local logical type already equals the target | execute |
alter column … set/drop not null |
local nullability already equals the target (column absent: converge with a warning — drop wins) | execute |
alter column … set/drop default |
rendered local default equals the target expression | execute |
alter column … set collate |
local collation equals the target, case-insensitively | execute |
alter primary key |
local PK column names and directions already equal the target list | execute |
| tag / maintained arms | — | execute (no idempotency arm; see § What replicates) |
RENAME TO is not decided here at all: a current origin records it as its own
rename_table migration (see § What replicates below). An alter_column migration that
turns out to parse as a rename can only come from a peer on an older build, and is
executed as those builds always did.
A rename_table migration carries the OLD table name in fromTable (the migration's
table is the NEW name, which is also what it is keyed under, so later alterations of
the renamed table stay in one contiguous version stream). The receiver decides it from
the local catalog for both names:
| old present | new present | verdict |
|---|---|---|
| yes | no | execute |
| no | yes | already-applied — the rename already happened here |
| yes | yes | conflict naming both tables — renaming would collide with an existing table; same posture as a divergent create_table |
| no | no | converge with a warning — nothing to rename (the local drop was more destructive) |
A rename_table whose fromTable is missing (an omitting peer) is undecidable and
converges with a warning.
Only add column compares any part of a definition, and only its logical type. A
type mismatch is the dangerous divergence — two peers would interpret the same rows under
different shapes. Anything richer would compare an AST column definition against a
catalog ColumnSchema, and those do not round-trip (an unnamed inline CHECK is
auto-named _check_<col> in the catalog; session default_column_nullability decides
whether not null is even spelled) — a false conflict permanently blocks the peer, which
is strictly worse than converging on a constraint-level difference. So constraint-level
drift between two same-named, same-typed columns converges silently.
Silent convergence matters beyond noise reduction: a schema-change throw lands in
ApplyToStoreResult.errors, and any non-empty errors aborts the whole admission unit
before its CRDT metadata commits (see
sync.md § Transactional Integrity During Sync), so a
throwing duplicate create would block every other change in the batch from ever
committing metadata, the peer watermark would never advance, and the receiver would
re-apply and re-fail the same batch on every sync, permanently.
A divergent same-name definition is deliberately still an error, with no automatic convergence path: two peers with the same table name but different column layouts would interpret each other's rows under different shapes, so quietly keeping the local shape and committing the metadata would record "converged" for a divergence that is not. That batch keeps aborting and retrying until an operator resolves it. Resolving it automatically would require last-writer-wins over schema definitions (apply the higher-HLC shape as a migration, rewriting the local table) — substantially more machinery.
A different failure survives all of this: an alteration that tightens a rule
(set not null, add constraint unique, a narrowing set data type, alter primary key) is legal only against rows the receiver may not hold yet, and schema changes are
applied ahead of the same batch's data facts. The DDL therefore fails on the batch that
carries the very rows satisfying it, and succeeds on the next sync round — self-healing,
but one spurious error and one wasted round trip. Tracked as
bug-sync-tightening-ddl-applied-before-its-data.
Four object-lifecycle migrations reach a peer with a real DDL string and are re-executed
there: create_table, drop_table, add_index and drop_index. The store module
attaches the canonical text at each emit site — the two creates via generateTableDDL /
generateIndexDDL, the two drops via generateDropTableDDL / generateDropIndexDDL (all
in packages/quereus/src/schema/ddl-generator.ts). The memory virtual-table module
attaches the same four, though there is no end-to-end sync path for memory-backed tables
today.
Table alterations replicate too. Every ALTER TABLE statement's schema-change event
carries the statement's canonical, schema-qualified SQL in ddl: the engine renders
it once at plan-build time (buildAlterTableStmt in
packages/quereus/src/planner/building/alter-table.ts) and threads it to the module as
SchemaChangeInfo.ddl (or renameTable's ddl parameter), which the store module puts
on the event verbatim. The qualification rule matches generateTableDDL, so both wire
sources agree, and a module emits exactly one event per ALTER statement — the
inline-constraint installs behind add column x text unique, the revert calls of a
failed ADD COLUMN, and the materialized-view backing reshapes all pass no ddl and
announce nothing. An alteration therefore records a non-blank alter_column migration,
and the receiver re-executes the statement (idempotently — see the decision table below):
add / drop / rename column, add / drop / rename constraint, every alter column
sub-form, and alter primary key all reach every synced peer.
RENAME TO replicates, and data flows across it. A rename records its own
rename_table migration — filed under the NEW name, carrying the old one in fromTable
(sourced from the schema event's oldObjectName, which only the RENAME TO emit sites
set). Two things make the data path survive the rename:
- In-batch routing. The apply path's Phase-1 table-fate computation
(
computeBatchTableFatesinchange-applicator.ts) simulates what the batch's schema steps leave behind: it seeds each mentioned name with whether the receiver has it right now, then replays the migrations Phase 1a kept, in HLC order, applying the same verdictdecideSchemaChangewill reach. Arename_tabletherefore moves the name only when the receiver still has the old one — matchingdecideRenameTableexactly — so rows arriving under the new name in the very batch that carries the rename land, while rows for a rename the receiver DECLINES (its old table was dropped locally, orfromTableis missing) take the unknown-table disposition instead of reaching a table that does not exist. Chained renames, rename-then-drop, and rename-then-rename-back within one batch all resolve by the same replay. Arename_tablethat leaves the new name PRESENT also triggers the reactive held-change drain for that name, the same as acreate_table; a declined one does not, because nothing reappeared. - A table keeps whatever history is really its own. The companion read-free verdict —
"the bookkeeping stranded under this name describes some OTHER table, so these rows must
resolve without consulting local cell versions and tombstones" — follows table identity,
not the name. The simulation records, per name, which table's bookkeeping is filed there
(
historyName); a rename moves the table but not its records. So a name vacated by adrop_tableand refilled — by acreate_table, or by renaming a DIFFERENT table into it, the ordinary table-swap migration — resolves read-free, while a name vacated by a rename AWAY and then renamed BACK gets its own table returned to it and resolves normally: a stored tombstone still blocks a re-delivered row. - Same-transaction writes. The engine relabels a transaction's already-batched data
events to the new name before commit (
renameBatchedEvents), so a rename in the same transaction as writes files every fact under the new name.
Two caveats:
- CRDT metadata is stranded at the old name. Sync's per-row bookkeeping
(
cv:/tb:/cl:) is keyed by table name, so both origin and receiver abandon it at the old name and start empty at the new one — later conflict resolution and tombstone blocking lose their history for the renamed table (an old delete no longer blocks a replayed insert), a from-zero delta re-pull diverts pre-rename facts (still filed under the old name) to the unknown-table disposition, and a snapshot taken after the rename ships pre-rename rows under the retired name. Pre-existing for a purely local rename; replicating the rename makes it happen on every peer. Tracked asbug-sync-rename-and-pk-change-strand-crdt-metadata. - Storage format bump. Stored migration records gained a length-prefixed
fromTableslot before the DDL, soSYNC_METADATA_FORMAT_VERSIONis now 5 — a replica with v4 metadata refuses to start and must re-bootstrap from a peer snapshot.
The tag / maintained arms (SET TAGS, ADD TAGS, DROP TAGS,
SET/DROP MAINTAINED) still do not replicate. These are catalog-only and emit no
schema event, so no migration is ever recorded for them.
Backfilled values are not data facts. add column sku text default 'x' writes every
existing row inside module.alterTable (migrateRows), which emits no data events — so
nothing lands in the change log and each peer computes its own backfill when it
replays the statement. That converges only because non-deterministic defaults are
rejected at plan-build time and a per-row backfill is a function of the row it fills; the
backfilled values themselves never cross the wire.
Re-keying strands CRDT metadata. Sync's cv: / tb: / cl: keys are filed under
the row's primary-key identity, so alter primary key abandons the metadata of every
existing row and later conflict resolution silently starts from empty. Pre-existing for a
local primary-key change; replicating the statement makes it happen on every peer.
Tracked as bug-sync-rename-and-pk-change-strand-crdt-metadata.
The receiver still warns in applySchemaChange's blank-DDL early return, naming
migration type, schema and table — a blank migration can now only come from an
older-build peer or a third-party module, and the origin-side recordSchemaMigration
warning covers the same gap at the other end. A blank migration is still recorded (still
advancing the table's schema version, which the destructiveness comparison depends on)
and still skipped.
While the receiver executes replicated DDL, it marks the module events that DDL emits as
remote via a scoped marker (StoreEventEmitter.beginRemoteSchemaScope /
endRemoteSchemaScope): every schema event naming the migration's (schema, object)
between begin and end is marked remote, so the SyncManager's local-fact capture skips it
and nothing is broadcast back out. The scope covers zero, one, or several events and is
released in a finally, so a statement that emits nothing (a blank migration is skipped
before the scope even opens; a tag arm emits nothing) leaves no residue, and a failed
statement cannot leave a marker behind to swallow the next genuine local DDL. The
tradeoff is time-bounding rather than signature-matching: a host issuing local DDL on the
very table being replicated at that instant would be mis-marked remote (see the doc
comment on beginRemoteSchemaScope).
How to distribute app schema migrations as a static "seed" that syncs into the user's database using the existing sync infrastructure, treating the app provider as a read-only peer with a well-known site ID.
An app's initial schema (and optionally seed data) must reach each user's local database. Rather than imperative migrations or version checks, leverage the CRDT sync infrastructure:
- Build time: Generate a JSON bundle containing sync metadata for the app's schema
- Runtime: Sync from the bundled seed into the user's database using
applyChanges() - Updates: On app updates, only new schema changes are applied (delta sync)
This reuses existing sync code paths (no new migration infrastructure), handles user customizations naturally via CRDT semantics, delta-syncs efficiently on app updates, and works offline. Because the seed is applied through applyChanges(), it rides the wire path's group-atomic admission core (admitGroup, see Transactional Integrity During Sync) and inherits the same data-first/metadata-second/abort-with-no-metadata write-ordering guarantees with no seed-specific code.
Build time: DDL statements are run against an in-memory SyncManager and its metadata
serialized to schema-seed.json — a fixed well-known APP_PROVIDER_SITE_ID, the build
timestamp as HLC base, recording SchemaMigrations plus ColumnVersions for table columns.
Runtime: the read-only seed store's getChangesSince() (bounded by the lastSeedHLC
kept in user metadata, so only changes after it are returned) feeds the user's
SyncManager.applyChanges(), which executes the schema DDL and records CRDT metadata.
Use a deterministic, well-known site ID for the app provider:
/** All-zeros site ID for app provider schema seeds */
const APP_PROVIDER_SITE_ID = new Uint8Array(16); // 16 bytes of 0x00
/** Or use a fixed base64 */
const APP_PROVIDER_SITE_ID = siteIdFromBase64('AAAAAAAAAAAAAAAAAAAAAA');So the provider's site ID is consistent across builds, the user's local changes (random site IDs) never collide with seed schema, and seed-originated changes are easy to identify in debugging.
The change log in the seed enables efficient delta sync. On first launch lastSeedHLC is
undefined and every seed entry applies; on an app update it points at the previous seed's
latest HLC, and filtering change-log entries by hlc > lastSeedHLC processes only new
schema changes — O(k) in the number of new changes.
Standard CRDT semantics handle user customizations: app schema is the baseline, user edits layer on top, conflicts resolve deterministically.
- User adds a column: their column carries their site ID with a later HLC, preserved
- App adds the same column in an update: LWW resolves (later HLC wins, or the user's if concurrent)
- User drops a table: "most destructive wins" — the drop persists even if the app's seed has the table
Every primitive a schema seed needs already ships: from @quereus/sync,
SyncManager.applyChanges() / .getPeerSyncState() / .updatePeerSyncState(),
compareHLC(), hlcToJson() / hlcFromJson(), the SerializedHLC type,
siteIdToBase64() / siteIdFromBase64(), and toBase64Url() / fromBase64Url();
from @quereus/store, InMemoryKVStore.
Implement in your application:
SchemaSeedinterface: Define the JSON structure for your seed filesgenerateSchemaSeed(): Build-time script to create seeds from DDLsyncFromSchemaSeed(): Runtime function to apply seeds
These stay app-specific because the seed format may vary (JSON, MessagePack, …), generation integrates with your build system, and applications may add custom sync logic or validation.