Stability: Stable — see Stability Tiers.
The single inventory of every capability-negotiation surface on the VirtualTableModule
contract: how each one is signaled, what the engine substitutes when a module omits it, and
which built-in modules implement it. Module authors and reviewers should treat it as the
reference for "what happens if my module doesn't do X"; the surrounding implementation guide
is module-authoring.md.
The contract signals capability three different ways, and the behavior when a module does not implement a surface ranges from a clean negotiated rejection to silent divergence.
| Signaling | Members | Engine consults it? |
|---|---|---|
| Method presence | supports / executePlan, getBestAccessPlan, getMappingAdvertisements, getBackingHost, createIndex / dropIndex, alterTable, renameTable, finalizeRename, beginSchemaBatch / endSchemaBatch, notifyLensDeployment, onRegister, assertCatalogObjectPersistable |
yes, per call site (varies) |
| Static field | concurrencyMode, expectedLatencyMs |
yes, before dispatch (the clean model) |
getCapabilities() flag |
delegatesNotNullBackfill, permitsGrandfatheredCheckViolators, ddlTransactionality (live); isolation, savepoints, persistent, secondaryIndexes, rangeScans (informational) |
only the first three |
The static-field model (concurrencyMode, see Concurrency Mode) is the clean exemplar: a defaulted, queryable value the engine reads before it dispatches, to choose its path. The recommended pattern generalizes toward it.
Each surface below is tagged by how its unsupported path behaves:
- Negotiated rejection — the engine consults presence (or catches a thrown
UNSUPPORTED) and turns the unsupported case into a clean, sited error before / at dispatch. - Engine-side fallback — absence has a defined behavior the engine substitutes.
- Silent divergence — the module no-ops a mandate it cannot meet, and the engine never learns. This is the bug class this inventory exists to surface.
- Data-dependent throw — the module throws
CONSTRAINT/MISMATCHper the arm's contract (correct — not a gap).
| Surface | Signaling | Unsupported-path | memory | store | isolation | leveldb / indexeddb |
|---|---|---|---|---|---|---|
create / connect / destroy |
required | n/a | ✓ | ✓ | wraps underlying | via store |
getBestAccessPlan |
presence | engine-side fallback (default full-scan; isolation returns a default plan when the underlying lacks it) | ✓ | ✓ | forwards | via store |
supports / executePlan |
presence (pair) | engine-side fallback (index path) — isolation deliberately suppresses it so the overlay sees every row | — | — | suppressed | — |
getMappingAdvertisements |
presence | engine-side fallback (name-match only) | ✓ tags | ✓ tags | forwards | via store |
getBackingHost |
presence | negotiated rejection (create materialized view … using <module> and catalog import both reject a capability-less module with a sited UNSUPPORTED; resolving a host on an already-created backing without the capability is a sited INTERNAL — engine bug) |
✓ | ✓ (StoreBackingHost — coordinator-pending) |
conditional forward (constructor-assigned only when the underlying implements it, so method presence mirrors the underlying — a wrapper around a capability-less module must not advertise) | via store |
createIndex / dropIndex |
presence | negotiated rejection (SchemaManager.createIndex — "does not support CREATE INDEX") |
✓ | ✓ | forwards (instance-level preferred) | via store |
alterTable (method present) |
presence | negotiated rejection (each data-affecting run* in runtime/emit/alter-table.ts throws a sited UNSUPPORTED if absent — except renameColumn, which degrades to an engine-side schema-only rename) |
✓ | ✓ | forwards (throws if underlying lacks) | via store |
renameTable |
presence | engine-side fallback (schema-only rename) | ✓ | ✓ physical move | forwards + rekeys maps | via store |
finalizeRename |
presence | engine-side fallback (no-op — renameTable did all the work) |
n/a | ✓ deletes old catalog entry after dependents persist (two-phase, see schema.md) | forwards | via store |
beginSchemaBatch / endSchemaBatch |
presence | engine-side fallback (per-DDL commits) | n/a | ✓ | forwards | via store |
notifyLensDeployment |
presence | engine-side fallback (no-op) | n/a | n/a | forwards | n/a |
onRegister |
presence | engine-side fallback (no-op — the module first sees its Database in a table hook) |
n/a | ✓ subscribes to the schema-change notifier at registerModule, so a view created before the first store table is still persisted (and still vetoed) |
forwards | via store |
assertCatalogObjectPersistable |
presence | engine-side fallback (no-op — the object is registered and its catalog write, if any, stays fire-and-forget) | n/a | ✓ refuses a view / MV / rename-rewritten table whose key or generated DDL it could not encode (tables self-filtered on ownership) | forwards | via store |
concurrencyMode |
static field | engine-side fallback ('serial') |
reentrant-reads |
serial (default) |
computed: weaker(underlying, overlay), capped at reentrant-reads |
via store |
expectedLatencyMs |
static field | engine-side fallback (0) |
0 | 0 | forwards underlying | via store |
readCommittedSnapshot |
static field | engine-side fallback (false — the read keeps the serialized path) |
true (immutable layers; single-pointer commit publish; pinned unregistered read connection) |
false (shared cached table per key drops the option; query merges pending ops over the committed store) |
mirrors underlying — a _readCommitted connect opens its OWN underlying handle (never the memoized writer handle) and the read bypasses the overlay, so the wrapper adds no tearing window; it still cannot promise more than the underlying delivers |
via store (false) |
getCapabilities().delegatesNotNullBackfill |
flag (live) | engine-side gate (ADD COLUMN skips validateNotNullBackfill) |
off | off | inherits underlying | off |
getCapabilities().permitsGrandfatheredCheckViolators |
flag (live) | engine-side gate (getTrustedCheckExtraction returns the empty extraction, so TableReferenceNode skips the CHECK lift and the lens prover refuses the table's CHECK-derived enum domains) |
off | off | inherits underlying | off |
getCapabilities().ddlTransactionality |
flag (live) | engine-side gate (ddl_transaction_policy = strict refuses module-dispatching DDL inside an explicit transaction unless the tier is transactional; default permissive never consults it — see DDL transactionality tiers) |
non-transactional |
auto-commit |
forwards underlying verbatim, never upgrades | via store |
getCapabilities().{isolation,savepoints,persistent,secondaryIndexes,rangeScans} |
flag (informational) | never consulted by engine — asserted only in tests; isolation augments isolation / savepoints but nothing reads them |
varies | varies | augments | varies |
Isolation wrapper asymmetry is intentional.
IsolationModuleforwards the isolation-transparent hooks (getBestAccessPlan,getMappingAdvertisements, the batch + lens lifecycle hooks,onRegister,assertCatalogObjectPersistable,renameTable,finalizeRename,alterTable) but suppressessupports(so the overlay always sees every row to merge), computes a conservativeconcurrencyMode(the weaker of the underlying and overlay modes, capped atreentrant-readsbecause its own write path is never fully-reentrant), and forwards the underlying'sexpectedLatencyMs. See the Transparent hook forwarding paragraph inpackages/quereus-isolation/README.mdfor the full rationale — do not restate it divergently here.
getCapabilities().ddlTransactionality declares how a module's DDL (schema-change)
statements behave with respect to the enclosing transaction. It is a single
worst-case summary across every DDL statement the module can execute — per-statement
detail belongs in the backend docs, not the flag. Three tiers, in severity order:
transactional— the reference semantics. A schema change is part of the enclosing transaction: the catalog entry and physical structures (index trees, migrated rows, moved storage) are buffered with the transaction, visible to later statements inside it, made durable atomically with the DML at commit, and discarded whole onrollback/rollback to savepoint. No built-in module reaches this tier today — it needs a transaction-scoped catalog, whichSchemaManagerdoes not have (tracked by the backlog ticketfeat-transactional-ddl-native-backends).non-transactional— the schema change escapes the transaction (it survivesrollback) but buffered DML still rolls back normally. This is the memory module, and the store's non-committing DDL statements (create index,add constraint, schema-only arms).auto-commit— executing certain DDL commits the module's buffered transaction at DDL time: the schema change and every buffered write become durable immediately, and a laterrollbackundoes nothing. This is the store module (its row-rewriting ALTER arms andrenameTableflush pending ops); because a module declares its worst case, the store declaresauto-commiteven though much of its DDL is only non-transactional.
Default when absent: non-transactional. A module must EXPLICITLY claim
transactional; defaulting to the clean semantics would let every existing module
silently over-promise.
The gate. The ddl_transaction_policy option / pragma governs enforcement:
-
permissive(default) — today's behavior, unchanged; the flag is not consulted. -
strict— a statement that dispatches to a module DDL surface (create table … using,drop table,create index,drop index, anyalter tablearm,rename table,create/drop/refresh materialized view) while an explicit transaction is open on a module whose tier is nottransactionalraises a sitedQuereusErrorbefore any dispatch or catalog mutation. The transaction stays open and usable. Autocommit-mode DDL (the normal case) is never gated — only an explicitBEGINtrips it.The materialized-view verbs consult the backing-host module — the
using <module>(…)host, else thememorydefault, not the session default module.refreshis gated even though it mostly rewrites rows: its reshape arm reconciles a shifted body shape with modulealterTableops, and both of its arms commit the contents swap (abegin; refresh; rollbackdoes not undo the refresh), so its effect escapes the enclosing transaction the same way the schema statements' do.
Engine-only schema objects (create view, create assertion, declare schema) are out
of the gate's scope: they touch no module, and the engine's own schema is transient by
design (see architecture.md § Transient Schema). Tag-only edits
(alter view/index/materialized view … set tags) are likewise ungated — they dispatch to
no module either. The one inconsistency: alter table … set tags is refused, because
the ALTER TABLE emitter gates all of its arms uniformly rather than per-arm.
apply schema runs its generated migration DDL through the engine's implicit
transaction, so the gate does not fire statement-by-statement inside a migration. It does
fire — on the first gated statement the migration emits — if the caller wrapped the
apply schema in an explicit begin.
The isolation wrapper forwards the underlying's declared tier verbatim and never upgrades it: the overlay stages DML outside the underlying module, so an underlying DDL-commit flushes only module-side ops — forwarding the pessimistic underlying value is the honest choice.
Invariant: SCH-004
alterTable presence is one bit covering ~12 SchemaChangeInfo arms (see Schema Changes), each with its own mandate. This mismatch is the divergence hazard: a module can be "ALTER-capable" (the method is present) yet silently fail one arm it cannot honor. The alterPrimaryKey row is the model the recommended pattern promotes to a universal rule: try native → on UNSUPPORTED apply a defined fallback.
| Arm | Mandate | memory | store |
|---|---|---|---|
addColumn |
append column; backfill; NOT-NULL gated by delegatesNotNullBackfill; honor insertAtIndex or throw UNSUPPORTED |
✓ (honors a position) | ✓ (append only — rejects a position) |
dropColumn |
remove slot + reindex | ✓ | ✓ |
renameColumn |
schema-only | ✓ | ✓ |
alterPrimaryKey |
re-key in place or throw UNSUPPORTED |
in-place re-key (open-transaction pending layers and their change events re-keyed too) | in-place re-key |
addConstraint |
materialize + validate (unique / fk) | ✓ | ✓ unique / fk; throws UNSUPPORTED for others |
dropConstraint / renameConstraint |
schema rewrite | ✓ | ✓ |
alterColumn.setNotNull |
backfill from default or throw CONSTRAINT |
✓ | ✓ |
alterColumn.setDataType |
physical convert or throw MISMATCH |
✓ | ✓ |
alterColumn.setDefault |
schema-only | ✓ | ✓ |
alterColumn.setCollation (non-PK UNIQUE) |
re-validate uniqueness under new collation | ✓ | ✓ |
alterColumn.setCollation (PK column) |
re-key / re-validate PK under new collation (module.ts setCollation contract) |
✓ re-keys | ✓ re-keys (physical re-key + secondary-index rebuild; CONSTRAINT on a collision under the new collation) |
The PK-column
setCollationcell is honored by a physical re-key on both modules. The store keys each PK column under its own declared collation (StoreTable.pkKeyCollations), so a PKSET COLLATEre-encodes every data-store key under the new collation (StoreTable.rekeyRows) and rebuilds each secondary index (whose keys embed the PK suffix). A re-key that would collide under the new collation throws a sitedCONSTRAINTin the validation pass without mutating the store (all-or-nothing, mirroringALTER PRIMARY KEY); a target equal to the column's current collation is a schema-only no-op. This is thestore-pk-collate-physical-rekeyfollow-up to the earlier negotiated-rejection stopgap — the store now reaches full memory parity, so neither the engine-side logical-enforce scan nor theUNSUPPORTEDreject is needed.
CREATE honors any declared PK collation too.
StoreModule.createkeys an explicit per-column PK collation natively (create table t (x text collate binary primary key)is keyed under BINARY), and only applies the store's table-level default K (config.collation || 'NOCASE') to an implicit-default text PK column (so an undecorated text PK keeps the store's historical NOCASE-keyed behavior rather than the engine's BINARY column default). The implicit-vs-explicit distinction is carried byColumnSchema.collationExplicit(set bycolumnDefToSchemaonly on aCOLLATEclause — purely additive; every other consumer ignores it). The per-column key collation round-trips through the column's (BINARY-elided)COLLATEclause, so the load path (connect/ rehydrate) needs no reconciliation. Non-text PK columns keep their declared collation — collation governs key bytes only for text. Seedocs/schema.md§ Per-column PK key collation for the store-side detail.
These are the rules new modules and new contract points should follow. They generalize the clean models already in the tree (concurrencyMode, the alterPrimaryKey protocol) and retire the failure mode the inventory above flags.
-
Presence-signaling is reserved for purely-additive optional hooks whose absence is already a clean engine-side fallback (
getMappingAdvertisements, the batch / lens lifecycle notifications). Absence there means a documented no-op — it can never diverge. -
Any contract point where the engine assumes a behavior must be declared and consulted before dispatch.
concurrencyModeis the template: a defaulted, queryable value the engine reads to choose its path. Generalize toward this, not toward more presence bits. -
getCapabilities()is the single home for binding capability gates. Three flags are live gates the engine consults before/at dispatch —delegatesNotNullBackfill,permitsGrandfatheredCheckViolators, andddlTransactionality(see DDL transactionality tiers) — and this is where new binding gates belong. The five informational flags —isolation,savepoints,persistent,secondaryIndexes,rangeScans— are advisory / non-binding: the engine does not consult them, so toggling them changes nothing about engine behavior (they are asserted only in tests, and isolation augmentsisolation/savepointsfor its own bookkeeping). Do not be misled into treating them as gates, and do not grow that advisory set. (Their removal / relocation is a separate code ticket; the distinction is documented here, not yet acted on.) -
Hard contract — no silent divergence, stated by SCH-004. It promotes the existing
alterPrimaryKeyprotocol to a universal rule. -
Fine-grained ALTER negotiation. Because
alterTablepresence is one coarse bit, per-arm support is negotiated at the relevantrun*call site. The negotiation does not have to be a static capability flag or an engine routing branch: when the accept/reject decision is intrinsically module-internal — depending on per-table physical state the engine neither owns nor tracks — the behavioralthrow UNSUPPORTEDcontract (rule 4) is the negotiation. The store's PK-columnsetCollationis the worked example: the accept/reject call hinges on the table's fixed physical key collation K (config.collation), so the store resolves it by applying a consistent change (target == K) schema-only and throwing a sitedUNSUPPORTEDon a divergent one —runAlterColumnpropagates the throw cleanly with no engine change. Thenative | logical-enforce | rejecttrichotomy the work explored collapses, for the store, toreject(the consistent case being plain schema-only, not a special mode); logical-enforce and physical re-key remain documented future enhancements. New arms adopt whichever shape fits — a queryable value when the engine must choose its path before dispatch (à laconcurrencyMode), or the behavioral contract when the decision is module-internal — incremental, not a giant up-front descriptor.