@quereus/store 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +436 -317
  2. package/dist/src/common/backing-host.d.ts +198 -0
  3. package/dist/src/common/backing-host.d.ts.map +1 -0
  4. package/dist/src/common/backing-host.js +452 -0
  5. package/dist/src/common/backing-host.js.map +1 -0
  6. package/dist/src/common/bytes.d.ts +16 -0
  7. package/dist/src/common/bytes.d.ts.map +1 -0
  8. package/dist/src/common/bytes.js +33 -0
  9. package/dist/src/common/bytes.js.map +1 -0
  10. package/dist/src/common/cached-kv-store.d.ts +3 -3
  11. package/dist/src/common/cached-kv-store.d.ts.map +1 -1
  12. package/dist/src/common/cached-kv-store.js +6 -4
  13. package/dist/src/common/cached-kv-store.js.map +1 -1
  14. package/dist/src/common/encoding.d.ts +9 -1
  15. package/dist/src/common/encoding.d.ts.map +1 -1
  16. package/dist/src/common/encoding.js +12 -2
  17. package/dist/src/common/encoding.js.map +1 -1
  18. package/dist/src/common/index.d.ts +8 -6
  19. package/dist/src/common/index.d.ts.map +1 -1
  20. package/dist/src/common/index.js +7 -3
  21. package/dist/src/common/index.js.map +1 -1
  22. package/dist/src/common/key-builder.d.ts +125 -4
  23. package/dist/src/common/key-builder.d.ts.map +1 -1
  24. package/dist/src/common/key-builder.js +186 -9
  25. package/dist/src/common/key-builder.js.map +1 -1
  26. package/dist/src/common/kv-store.d.ts +75 -4
  27. package/dist/src/common/kv-store.d.ts.map +1 -1
  28. package/dist/src/common/memory-store.d.ts +3 -3
  29. package/dist/src/common/memory-store.d.ts.map +1 -1
  30. package/dist/src/common/memory-store.js +4 -2
  31. package/dist/src/common/memory-store.js.map +1 -1
  32. package/dist/src/common/store-connection.d.ts +11 -4
  33. package/dist/src/common/store-connection.d.ts.map +1 -1
  34. package/dist/src/common/store-connection.js +12 -6
  35. package/dist/src/common/store-connection.js.map +1 -1
  36. package/dist/src/common/store-module.d.ts +580 -20
  37. package/dist/src/common/store-module.d.ts.map +1 -1
  38. package/dist/src/common/store-module.js +1590 -135
  39. package/dist/src/common/store-module.js.map +1 -1
  40. package/dist/src/common/store-table.d.ts +347 -14
  41. package/dist/src/common/store-table.d.ts.map +1 -1
  42. package/dist/src/common/store-table.js +751 -98
  43. package/dist/src/common/store-table.js.map +1 -1
  44. package/dist/src/common/transaction.d.ts +135 -27
  45. package/dist/src/common/transaction.d.ts.map +1 -1
  46. package/dist/src/common/transaction.js +216 -55
  47. package/dist/src/common/transaction.js.map +1 -1
  48. package/package.json +5 -5
@@ -12,17 +12,22 @@
12
12
  * - {prefix}.__stats__ - Unified stats store (row counts for all tables)
13
13
  * - Catalog store: __catalog__ - DDL metadata keyed by {schema}.{table}
14
14
  */
15
- import type { Database, TableSchema, TableIndexSchema, VirtualTableModule, BaseModuleConfig, BestAccessPlanRequest, BestAccessPlanResult, ModuleCapabilities, SchemaChangeInfo } from '@quereus/quereus';
15
+ import type { Database, TableSchema, TableIndexSchema, VirtualTableModule, BaseModuleConfig, BestAccessPlanRequest, BestAccessPlanResult, ModuleCapabilities, SchemaChangeInfo, Schema, MappingAdvertisement, ViewSchema, MaintainedTableSchema, BackingHost, LensDeploymentSnapshot } from '@quereus/quereus';
16
16
  import type { KVStore, KVStoreProvider } from './kv-store.js';
17
17
  import type { StoreEventEmitter } from './events.js';
18
18
  import { TransactionCoordinator } from './transaction.js';
19
19
  import { StoreTable, type StoreTableConfig, type StoreTableModule } from './store-table.js';
20
20
  /**
21
21
  * Result of catalog rehydration.
22
+ *
23
+ * `views` / `materializedViews` are additive (existing consumers — e.g.
24
+ * `quoomb-web` — read only `.errors`). Errors from any phase land in `errors`.
22
25
  */
23
26
  export interface RehydrationResult {
24
27
  tables: string[];
25
28
  indexes: string[];
29
+ views: string[];
30
+ materializedViews: string[];
26
31
  errors: RehydrationError[];
27
32
  }
28
33
  /**
@@ -32,6 +37,14 @@ export interface RehydrationError {
32
37
  ddl: string;
33
38
  error: Error;
34
39
  }
40
+ /**
41
+ * Listener the host binds via {@link StoreModule.setLensDeploymentListener} to
42
+ * forward a logical `apply schema` lens deployment onward (typically to the sync
43
+ * layer's basis-table lifecycle bookkeeping). Kept as a plain callback so
44
+ * `@quereus/store` stays free of a `@quereus/sync` dependency; the worker — which
45
+ * depends on both — wires the two together.
46
+ */
47
+ export type LensDeploymentListener = (db: Database, logicalSchemaName: string, snapshot: LensDeploymentSnapshot) => void | Promise<void>;
35
48
  /**
36
49
  * Configuration options for StoreModule tables.
37
50
  */
@@ -57,16 +70,69 @@ export interface StoreModuleConfig extends BaseModuleConfig {
57
70
  export declare class StoreModule implements VirtualTableModule<StoreTable, StoreModuleConfig>, StoreTableModule {
58
71
  private provider;
59
72
  private stores;
60
- private coordinators;
73
+ /**
74
+ * The single transaction coordinator shared by every {@link StoreTable} this
75
+ * module owns — the unit of cross-table atomicity (see {@link getCoordinator}).
76
+ * Lives for the module's lifetime: a single table's drop must NOT evict it
77
+ * (sibling tables still use it); only {@link closeAll} clears it.
78
+ */
79
+ private moduleCoordinator?;
61
80
  private tables;
62
81
  private eventEmitter?;
82
+ /**
83
+ * Optional listener wired by the host (the worker) to forward a logical
84
+ * `apply schema` lens deployment to the sync layer's lifecycle bookkeeping.
85
+ * The store module is the only basis-backing host with both persistence and a
86
+ * `db` handle, so it is the forwarder; `@quereus/store` must not depend on
87
+ * `@quereus/sync`, so the listener is a plain callback the worker binds.
88
+ */
89
+ private lensDeploymentListener?;
90
+ /** Unsubscribe thunk for the engine `SchemaChangeNotifier` listener, set on first hook with a `db`. */
91
+ private schemaListenerUnsub?;
92
+ /** The `Database` whose notifier we subscribed to. One module instance serves one `Database`. */
93
+ private subscribedDb?;
94
+ /**
95
+ * Serialized chain of pending catalog writes triggered by engine schema-change
96
+ * events (catalog-only tag swaps). `notifyChange` invokes listeners synchronously
97
+ * and does not await them, so the actual read-compare-write runs here, in order,
98
+ * and is drained by `closeAll` (and `whenCatalogPersisted`) before the provider closes.
99
+ */
100
+ private persistQueue;
101
+ /**
102
+ * The JSON-serialized stale-MV set last enqueued onto {@link persistQueue} (the
103
+ * `\x00meta\x00stale_mvs` value), or `undefined` when no baseline is established
104
+ * for the current subscription. The schema-change listener compares each
105
+ * recomputed set against this so an unrelated `table_modified` (e.g. a tag swap
106
+ * that changes no MV's staleness) costs no extra fsync. Reset to `undefined`
107
+ * whenever a fresh `Database` is subscribed ({@link ensureSchemaSubscription}) so
108
+ * a reopened module re-establishes the baseline from the first relevant event (or
109
+ * the rehydrate tail).
110
+ */
111
+ private lastPersistedStaleMvs;
112
+ /**
113
+ * Whether the provider exposes an atomic cross-store commit domain
114
+ * ({@link KVStoreProvider.beginAtomicBatch}) — the capability the MV adopt fast
115
+ * path's gate-5 drop hinges on. Fixed for the provider's lifetime, so cached at
116
+ * construction (and used as the single gate for both writing AND reading the durable
117
+ * stale-MV set). Gating the **write** on it is load-bearing for soundness, not just an
118
+ * fsync saving: only an atomic-capable session — whose commits can never tear a source
119
+ * from its same-module backing — ever writes the durable set, so the set a later atomic
120
+ * session reads was necessarily written under that same no-tear guarantee. A persistent
121
+ * non-atomic provider therefore can never leave a torn-but-"not-stale" set for an atomic
122
+ * reopen to trust (it writes none); such a store falls back to the clean-shutdown marker.
123
+ */
124
+ private readonly atomicProvider;
63
125
  constructor(provider: KVStoreProvider, eventEmitter?: StoreEventEmitter);
64
126
  /**
65
127
  * Returns capability flags for this module.
66
128
  *
67
- * The base StoreModule does NOT provide transaction isolation.
68
- * Without isolation, queries see only committed data (no read-your-own-writes
69
- * within a transaction). To enable isolation, wrap with IsolationModule:
129
+ * The base StoreModule does NOT provide transaction isolation: there is no
130
+ * snapshot isolation and no cross-connection isolation (readers on other
131
+ * connections see only committed data). Within a transaction, reads through
132
+ * the table's shared coordinator DO see that transaction's own pending
133
+ * writes (read-your-own-writes — `StoreTable.query` merges the pending op
134
+ * view over the committed store). For full isolation, wrap with
135
+ * IsolationModule:
70
136
  *
71
137
  * ```typescript
72
138
  * import { IsolationModule, MemoryTableModule } from '@quereus/quereus';
@@ -81,14 +147,132 @@ export declare class StoreModule implements VirtualTableModule<StoreTable, Store
81
147
  * ```
82
148
  */
83
149
  getCapabilities(): ModuleCapabilities;
150
+ /**
151
+ * Generic-module mapping advertisements: assembled from the `quereus.lens.decomp.*`
152
+ * reserved tags on this basis schema's tables. Returns `[]` when the schema has no
153
+ * such tags, leaving the lens default mapper on its name-match path.
154
+ * See `docs/lens.md` § The Default Mapper.
155
+ */
156
+ getMappingAdvertisements(_db: Database, basisSchema: Schema): readonly MappingAdvertisement[];
157
+ /**
158
+ * Backing-host capability (engine `vtab/backing-host.ts`): resolve the
159
+ * privileged surface for a store table this module owns, or undefined when the
160
+ * table is unknown to it. The host binds the CURRENT (StoreTable, coordinator)
161
+ * pair — `destroy` evicts both maps, so a drop+recreate yields fresh instances
162
+ * and the returned host is pinned to one backing-table incarnation (the engine
163
+ * resolves hosts fresh per call, never caching them). Resolution goes through
164
+ * {@link getOrReconnectTable} so a rehydrated-but-untouched (or rename-evicted)
165
+ * backing still resolves; the ownership pre-check keeps the reconnect fallback
166
+ * from adopting a registered table owned by a different module (`vtabModule`
167
+ * must be this StoreModule, or a wrapper — IsolationModule — exposing it as
168
+ * `underlying`). Attaching the coordinator eagerly makes the shared
169
+ * StoreTable's read paths merge the host's pending writes (reads-own-writes
170
+ * for a `select` from the MV mid-transaction).
171
+ */
172
+ getBackingHost(db: Database, schemaName: string, tableName: string): BackingHost | undefined;
173
+ /**
174
+ * Resolve a {@link StoreTable} this module owns, reconnecting a
175
+ * rehydrated-but-untouched (or rename-evicted) table via
176
+ * {@link getOrReconnectTable}. The ownership pre-check keeps the reconnect
177
+ * fallback from adopting a registered table owned by a DIFFERENT module:
178
+ * `vtabModule` must be this StoreModule, or a wrapper (IsolationModule)
179
+ * exposing it as `underlying`. Returns undefined for an unknown/non-owned
180
+ * table. Shared by {@link getBackingHost} and
181
+ * {@link getTableForExternalWrite}; neither attaches a coordinator here —
182
+ * each layers its own pending-state policy on top.
183
+ */
184
+ private resolveOwnedTable;
185
+ /**
186
+ * Resolve the live {@link StoreTable} for an externally-applied write to a
187
+ * SOURCE table (committed put/delete + secondary-index + stats maintenance via
188
+ * {@link StoreTable.applyExternalRowChanges}). Returns undefined when the table
189
+ * is not this module's.
190
+ *
191
+ * Resolution goes through {@link resolveOwnedTable} (the shared ownership
192
+ * pre-check + reconnect fallback). Unlike `getBackingHost` it attaches no
193
+ * coordinator: external writes target committed storage, and the before-image
194
+ * read (`readEffectiveRowByKey`) merges any already-attached coordinator's
195
+ * pending state on its own (none when the table is freshly reconnected).
196
+ */
197
+ getTableForExternalWrite(db: Database, schemaName: string, tableName: string): StoreTable | undefined;
84
198
  /**
85
199
  * Get the event emitter for this module.
86
200
  */
87
201
  getEventEmitter(): StoreEventEmitter | undefined;
202
+ /**
203
+ * Bind (or clear, with `undefined`) the lens-deployment forwarder. The host
204
+ * (worker) calls this to route a logical `apply schema`'s deployment to the
205
+ * sync layer; see {@link notifyLensDeployment}.
206
+ */
207
+ setLensDeploymentListener(listener: LensDeploymentListener | undefined): void;
208
+ /**
209
+ * Engine `notifyLensDeployment` hook: a logical `apply schema X` fires this on
210
+ * every registered module once the lens catalog mutation + snapshot rotation
211
+ * complete (see `VirtualTableModule.notifyLensDeployment`). The store module —
212
+ * the basis-backing host — forwards the deployment to the bound listener so the
213
+ * sync layer can update its basis-table lifecycle bookkeeping.
214
+ *
215
+ * INVERSION OF THE ENGINE FIRING CONTRACT: the engine documents that a throwing
216
+ * notification aborts `apply schema X`. We deliberately wrap the listener in
217
+ * try/catch and SWALLOW (structured-log only) here — lifecycle bookkeeping is
218
+ * advisory and must never brick a schema apply. A bookkeeping bug is a logged
219
+ * warning, not a failed deploy.
220
+ */
221
+ notifyLensDeployment(db: Database, logicalSchemaName: string, snapshot: LensDeploymentSnapshot): Promise<void>;
88
222
  /**
89
223
  * Get the KVStoreProvider used by this module.
90
224
  */
91
225
  getProvider(): KVStoreProvider;
226
+ /**
227
+ * Build the set of physical store names this module's data/index stores
228
+ * currently occupy in `schemaName`, mapping each name to a human description
229
+ * of the logical object that owns it (for sited collision messages).
230
+ *
231
+ * Physical store names are built by string concatenation (`{schema}.{table}` /
232
+ * `{schema}.{table}_idx_{index}`) and the `_idx_` delimiter is itself a legal
233
+ * substring of any identifier, so two distinct logical objects (e.g. index
234
+ * `archive` on `t` and a sibling table `t_idx_archive`) can collapse to the
235
+ * same physical name. This set is the authoritative occupancy used by
236
+ * {@link assertStoreNameFree} to reject such a collision at CREATE time.
237
+ *
238
+ * The occupancy is the union of two sources, robust to lazy connection and the
239
+ * isolation wrapper (see the ticket's "Enumeration source" section):
240
+ * 1. `this.tables` — every store table this module touched this session.
241
+ * 2. the target schema's catalog tables whose `vtabModule === this` and which
242
+ * are not views — store-backed tables not yet lazily connected.
243
+ * Names embed the schema prefix, so cross-schema entries never collide and no
244
+ * per-schema filter on `this.tables` is needed. Memory-backed siblings and
245
+ * views own no store in this provider and are excluded (the `=== this` /
246
+ * `!isView` filter) to avoid false-positive rejects.
247
+ *
248
+ * No self-exclusion: at each guarded call the candidate object is not yet
249
+ * registered (create/createIndex run before the engine adds it) so it cannot
250
+ * self-collide; and for renameTable the renamed table's OWN stores stay in the
251
+ * set deliberately. Any overlap between a name the rename introduces and an
252
+ * own current store is a footprint-swap rename (`t` with index `x` → `t_idx_x`,
253
+ * or table `u_idx_x` with index `x` → `u`) that providers cannot relocate
254
+ * safely — relocation order determines whether a source is clobbered before it
255
+ * is moved — while no benign rename produces such an overlap. Keeping own
256
+ * stores in the set therefore causes no false rejects and keeps the
257
+ * reject-before-any-side-effect guarantee uniform.
258
+ */
259
+ private collectOccupiedStoreNames;
260
+ /**
261
+ * Throws `StatusCode.ERROR` when `candidate` (a physical store name produced by
262
+ * the key-builder for an object about to be created/renamed) already names an
263
+ * existing data or index store in `schemaName`. `candidateDesc` describes the
264
+ * incoming logical object; the message names the candidate physical store and
265
+ * both conflicting logical objects and is actionable (rename one of them).
266
+ *
267
+ * Must run BEFORE any storage side-effect (`getStore` / `getIndexStore` / the
268
+ * physical relocation): the provider opens/creates the store eagerly, so a
269
+ * guard that ran after would already have aliased the colliding object's store.
270
+ *
271
+ * Callers checking several candidates against the same occupancy (renameTable
272
+ * checks the new data store plus every relocated index store) pass a
273
+ * precomputed `occupied` map so the occupancy is collected once, not per call.
274
+ */
275
+ private assertStoreNameFree;
92
276
  /**
93
277
  * Creates a new store-backed table.
94
278
  * Called by CREATE TABLE.
@@ -106,11 +290,59 @@ export declare class StoreModule implements VirtualTableModule<StoreTable, Store
106
290
  /**
107
291
  * Destroys a store table and its storage.
108
292
  */
109
- destroy(_db: Database, _pAux: unknown, _moduleName: string, schemaName: string, tableName: string): Promise<void>;
293
+ destroy(db: Database, _pAux: unknown, _moduleName: string, schemaName: string, tableName: string): Promise<void>;
294
+ /**
295
+ * Reclaim the local storage of a DETACHED basis table by name — the store-side
296
+ * target of the sync layer's basis-eviction sweep
297
+ * (`SyncManager.evictExpiredBasisTables`, `docs/migration.md` § 4 Contract).
298
+ *
299
+ * Unlike {@link destroy}, the table is no longer in the engine schema (it was
300
+ * removed from the basis on detach; only its physical storage lingered), so
301
+ * there is no `db`/schema to consult and NO schema-change event is emitted — the
302
+ * engine already saw the detach. The caller (the sync recorder) supplies the
303
+ * captured secondary-index name list it retained from before detach, because the
304
+ * table schema (and its index list) is gone: passing the exact names avoids the
305
+ * provider prefix-scanning `{table}_idx_`, which can clobber a sibling table
306
+ * literally named `{table}_idx_<x>`.
307
+ *
308
+ * Idempotent: any cached handles are evicted and disconnected, then the data /
309
+ * index / stats stores and the catalog DDL are removed. Storage already gone (a
310
+ * prior real `drop table` ran `destroy`) is treated as success — the provider's
311
+ * `deleteTableStores` no-ops on absent stores and `removeTableDDL` no-ops on an
312
+ * absent key.
313
+ */
314
+ reclaimDetachedTable(schemaName: string, tableName: string, indexNames: readonly string[]): Promise<void>;
315
+ /**
316
+ * Tear down a table's in-memory handles and physical storage: evict the cached
317
+ * StoreTable / store / coordinator (synchronously, before any await, so a
318
+ * concurrent reconnect cannot observe a stale instance mid-teardown), disconnect
319
+ * the handle, delete the provider's data / index / stats stores (by the exact
320
+ * index names — never a `{table}_idx_` prefix scan, which could clobber a sibling
321
+ * table literally named `{table}_idx_<x>`), and remove the catalog DDL.
322
+ *
323
+ * Shared by {@link destroy} (live `drop table`) and {@link reclaimDetachedTable}
324
+ * (post-detach eviction). The caller owns the index-name source (live schema vs.
325
+ * the captured pre-detach list) and any schema-change event. Idempotent: the
326
+ * provider no-ops on absent stores and `removeTableDDL` no-ops on an absent key.
327
+ */
328
+ private tearDownTableStorage;
329
+ /**
330
+ * Returns the connected StoreTable for `schemaName.tableName`, lazily
331
+ * reconnecting from the engine's schema registry when absent.
332
+ *
333
+ * `renameTable` evicts the old key from `this.tables` and expects the next
334
+ * `connect()` to repopulate under the new name, but `apply schema` can run
335
+ * follow-up DDL (ALTER TABLE, CREATE/DROP INDEX) against the new name
336
+ * without an intervening connect. Mirror connect()'s schemaManager lookup
337
+ * so that DDL finds the moved table. Safe for the index paths because
338
+ * SchemaManager calls the module BEFORE mutating the registered table
339
+ * schema, so the reconnected cache matches what a connected instance holds.
340
+ */
341
+ private getOrReconnectTable;
110
342
  /**
111
343
  * Creates an index on a store-backed table.
112
344
  */
113
- createIndex(_db: Database, schemaName: string, tableName: string, indexSchema: TableIndexSchema): Promise<void>;
345
+ createIndex(db: Database, schemaName: string, tableName: string, indexSchema: TableIndexSchema): Promise<void>;
114
346
  /**
115
347
  * Drops an index on a store-backed table.
116
348
  *
@@ -119,7 +351,7 @@ export declare class StoreModule implements VirtualTableModule<StoreTable, Store
119
351
  * synthesized from it, tagged with `derivedFromIndex`), releases the
120
352
  * cached index-store handle, and tears down the underlying index store.
121
353
  */
122
- dropIndex(_db: Database, schemaName: string, tableName: string, indexName: string): Promise<void>;
354
+ dropIndex(db: Database, schemaName: string, tableName: string, indexName: string): Promise<void>;
123
355
  /**
124
356
  * Build index entries for all existing rows in a table.
125
357
  *
@@ -130,6 +362,37 @@ export declare class StoreModule implements VirtualTableModule<StoreTable, Store
130
362
  * atomically.
131
363
  */
132
364
  private buildIndexEntries;
365
+ /**
366
+ * Clear and rebuild every secondary index of `schema` against the (already
367
+ * re-encoded) data store. Secondary-index keys embed the PK suffix, so they must
368
+ * be rewritten whenever the data-store PK key bytes change — which happens both
369
+ * on `ALTER PRIMARY KEY` (the PK columns change) and on an `ALTER COLUMN … SET
370
+ * COLLATE` on a PK member (a PK column's key collation changes). Shared by both
371
+ * arms so the clear-then-rebuild stays identical. `schema` must already be the
372
+ * post-ALTER schema (its `primaryKeyDefinition` + column collations drive the new
373
+ * PK-suffix encoding via {@link buildIndexEntries}).
374
+ */
375
+ private rebuildSecondaryIndexes;
376
+ /**
377
+ * Validates the existing rows in `dataStore` against a UNIQUE constraint,
378
+ * throwing `CONSTRAINT` on the first duplicate before any schema mutation.
379
+ * Used by `ADD CONSTRAINT UNIQUE` (validate against the current collation) and
380
+ * by `SET COLLATE` (pass an `updatedSchema` whose altered column carries the
381
+ * NEW collation, so the dedup is performed under it). Mirrors the duplicate
382
+ * detection in {@link buildIndexEntries}: a `seen` Set keyed on a per-column
383
+ * collation-aware signature of the constrained values, with SQL NULL semantics
384
+ * (a row with any NULL constrained value never counts as a duplicate) and the
385
+ * partial `predicate` honored.
386
+ *
387
+ * No index store is written — store UNIQUE enforcement is a full-scan over
388
+ * `uniqueConstraints` at write time. The signature is built by
389
+ * {@link serializeRowKey} with one normalizer per constrained column drawn from
390
+ * `tableSchema.columns[idx].collation`, so a per-column NOCASE/RTRIM collation
391
+ * is honored (matching write-time `compareSqlValues` enforcement). Residual: a
392
+ * custom comparator-only collation has no string normalizer and falls back to
393
+ * BINARY for the dedup (see docs/schema.md store-collation note).
394
+ */
395
+ private validateUniqueOverExistingRows;
133
396
  /**
134
397
  * Alters an existing store table's structure (ADD/DROP/RENAME COLUMN).
135
398
  * Performs eager row migration for ADD and DROP, schema-only update for RENAME.
@@ -149,8 +412,23 @@ export declare class StoreModule implements VirtualTableModule<StoreTable, Store
149
412
  renameTable(db: Database, schemaName: string, oldName: string, newName: string): Promise<void>;
150
413
  /**
151
414
  * Modern access planning interface.
415
+ *
416
+ * Every plan is stamped with `honorsCollatedRangeBounds`: the store's post-fetch
417
+ * row filter (`StoreTable.matchesFilters` → `compareValues`) compares each pushed
418
+ * constraint — including LT/LE/GT/GE range bounds — under the column's declared
419
+ * collation, so the access path's collation-cover analysis may keep a
420
+ * collation-matched non-BINARY (NOCASE/RTRIM) PK range/BETWEEN seek instead of
421
+ * declining to a SeqScan + residual (see `classifyConstraintCover` in
422
+ * rule-select-access-path.ts). The seek really does narrow: `StoreTable.scanPKRange`
423
+ * (via `StoreTable.buildPKRangeBounds`) encodes the LT/LE/GT/GE bounds under the
424
+ * same per-column key collations the data keys use and iterates that
425
+ * seek-start/early-termination window. The window is a SUPERSET, so the post-fetch
426
+ * row filter still reproduces the exact collation semantics — and a comparator-only
427
+ * collation with no byte encoder safely falls back to a full scan. Mirrors the
428
+ * memory module's advertisement.
152
429
  */
153
430
  getBestAccessPlan(_db: Database, tableInfo: TableSchema, request: BestAccessPlanRequest): BestAccessPlanResult;
431
+ private computeBestAccessPlan;
154
432
  /**
155
433
  * Compute the PK-ordering advertisement for a scan-style plan. Returns the
156
434
  * `providesOrdering` / `monotonicOn` / `supportsAsofRight` fields for a plan
@@ -189,38 +467,320 @@ export declare class StoreModule implements VirtualTableModule<StoreTable, Store
189
467
  */
190
468
  getStatsStore(schemaName: string, tableName: string): Promise<KVStore>;
191
469
  /**
192
- * Get or create a transaction coordinator for a table.
470
+ * Get (lazily constructing) the SINGLE transaction coordinator shared by every
471
+ * table this module owns — the unit of cross-table atomicity. Every
472
+ * {@link StoreTable} attaches to the same instance, so a transaction touching
473
+ * several of the module's tables commits/rolls back as one all-or-nothing
474
+ * batch.
475
+ *
476
+ * Synchronously constructible: ops are addressed by explicit store handle, so
477
+ * the coordinator never needs to open a store to be created. This lets callers
478
+ * that must stay synchronous (e.g. a `BackingHost.connect()`) obtain a working
479
+ * coordinator before any table's store has been opened.
480
+ */
481
+ getCoordinator(): TransactionCoordinator;
482
+ /**
483
+ * Build the catalog entry for a table: its CREATE TABLE DDL, one
484
+ * `CREATE [UNIQUE] INDEX` statement per persistable secondary index, and one
485
+ * `alter index … set tags (…)` statement per exposed implicit index carrying
486
+ * user tags, newline-joined into a single multi-statement bundle keyed by
487
+ * `{schema}.{table}`.
488
+ *
489
+ * Bundling the indexes into the table's own entry means every existing
490
+ * re-persist path — `saveTableDDL` (each `alterTable` arm, `renameTable`) and
491
+ * the `table_modified` listener (`persistCatalogIfChanged`) — carries the
492
+ * indexes along for free, and `removeTableDDL` drops them with the table. The
493
+ * bundle is consumed on reopen by `rehydrateCatalog` → `importCatalog`, whose
494
+ * `parser.parseAll` splits it AST-by-AST (never on `\n`) and imports each
495
+ * statement in document order, so the table registers before its indexes.
496
+ *
497
+ * Both the table DDL and the index DDL are emitted without a `db` arg, keeping
498
+ * the persistence-safe fully-qualified form. Hidden implicit covering indexes
499
+ * (the auto-built BTree backing a declared UNIQUE constraint) are excluded —
500
+ * they are a backing detail that round-trips via the table's UNIQUE constraint,
501
+ * not as a standalone CREATE INDEX. For store tables `buildTableSchemaFromAST`
502
+ * synthesizes none of those, so in practice every index is included; the guard
503
+ * is defensive. A `CREATE UNIQUE INDEX`'s derived UNIQUE constraint is already
504
+ * excluded from the table DDL by the generator, so it round-trips solely via
505
+ * its own `CREATE UNIQUE INDEX` line — no doubling.
506
+ *
507
+ * An *exposed* implicit index (a non-derived UNIQUE constraint tagged
508
+ * `quereus.expose_implicit_index = true`, never materialized in store mode)
509
+ * must NOT get a synthetic `CREATE INDEX` line — re-import would materialize a
510
+ * real `IndexSchema` and change the store-mode shape. Its user tags
511
+ * (`UniqueConstraintSchema.exposedIndexTags`) instead ride a trailing
512
+ * `alter index … set tags` line, which `importDDL` re-applies silently after
513
+ * the CREATE TABLE in the same entry has registered the constraint. Empty tag
514
+ * records emit no line, and `exposedImplicitIndexes` returns `[]` for
515
+ * memory-mode tables (name materialized) and orders descriptors by the
516
+ * `uniqueConstraints` array, so the bundle stays byte-deterministic — which
517
+ * the compare-write in `persistCatalogIfChanged` relies on.
193
518
  */
194
- getCoordinator(tableKey: string, config: StoreTableConfig): Promise<TransactionCoordinator>;
519
+ private buildCatalogEntry;
195
520
  /**
196
- * Save table DDL to the catalog store.
521
+ * Save table DDL (bundled with its secondary index DDL) to the catalog store.
197
522
  */
198
523
  saveTableDDL(tableSchema: TableSchema): Promise<void>;
199
524
  /**
200
- * Load all DDL statements from the catalog store.
201
- * Used to restore persisted tables on startup.
525
+ * Load all DDL **values** from the catalog store (keys discarded).
526
+ * Used to restore persisted tables on startup and by tests asserting persisted DDL.
527
+ * Note: this returns table, view, AND materialized-view entries intermixed —
528
+ * {@link rehydrateCatalog} uses {@link loadCatalogEntries} (keys retained) to
529
+ * classify them. Meta entries (e.g. the clean-shutdown marker) are not DDL
530
+ * and are filtered out.
202
531
  */
203
532
  loadAllDDL(): Promise<string[]>;
204
533
  /**
205
- * Rehydrate persisted catalog into the in-memory schema manager.
534
+ * Load every catalog entry as `{ key, ddl }`. Unlike {@link loadAllDDL} (values
535
+ * only) this retains the key so {@link rehydrateCatalog} can classify each entry
536
+ * (table / view / materialized view) by its reserved key prefix.
537
+ */
538
+ private loadCatalogEntries;
539
+ /**
540
+ * Rehydrate the persisted catalog into the in-memory schema manager, in
541
+ * dependency order.
542
+ *
543
+ * Establishes the engine schema-change subscription up front (so a reopened DB
544
+ * persists subsequent DDL even when its first post-reopen statement is a view/MV,
545
+ * which never routes through a module hook — all the lazy subscription points are
546
+ * table hooks). Then loads every catalog entry once, classifies each by its key
547
+ * prefix into {tables, views, materialized views}, and imports in three phases:
548
+ *
549
+ * 1. **Tables** — `importCatalog` (connect to existing storage; refresh connected
550
+ * `StoreTable` schemas).
551
+ * 2. **Views** — `importCatalog` (engine silent-register; body validation deferred
552
+ * to query time, so order among views — and view-over-MV / view-over-view —
553
+ * does not matter, and no schema-change event fires → phase 2 writes nothing).
554
+ * 3. **Materialized views** — `importCatalog` per entry (engine re-materialize:
555
+ * rebuilds the memory backing from current source data, re-registers row-time
556
+ * maintenance, re-runs the eligibility gate — the same core the create emitter
557
+ * uses, but silent: no `materialized_view_added` fires, so phase 3 writes
558
+ * nothing back to the catalog). A store-hosted backing that phase 1 already
559
+ * rehydrated is **adopted without the refill** when the engine's adopt gates
560
+ * pass — see the clean-shutdown marker below.
561
+ *
562
+ * **Clean-shutdown marker.** Before anything loads, the reserved
563
+ * `\x00meta\x00clean_shutdown` catalog entry (written by {@link closeAll} after
564
+ * every batch flushed) is consumed: parsed into `{ trusted, staleAtClose }`, then
565
+ * **deleted immediately** — single-use, so a crash later in this session (or a
566
+ * second rehydrate without an intervening clean close) is detected at the next open
567
+ * and every adopt falls back to the always-correct drop+refill, self-healing any
568
+ * crash-window divergence (coordinated commit is not 2PC across stores). The marker
569
+ * payload is the JSON set of MVs that were **stale-at-close** (row-time maintenance
570
+ * detached, so the durable backing may be behind); phase 3 withholds trust per-entry
571
+ * for those — `trustBackings: trusted && !staleAtClose.has(name)` — so a
572
+ * stale-at-close MV refills (recomputing content and re-arming maintenance) while
573
+ * every live-at-close MV keeps the fast path. The one shared `adoptedBackings` set
574
+ * composes across fixpoint rounds (an upstream MV adopted in round 1 enables its
575
+ * dependent in round 2, while a refilled — or stale-at-close — upstream is never
576
+ * added to it, forcing dependents to refill).
577
+ *
578
+ * **MV-over-MV ordering** is handled by a fixpoint retry rather than a static topo
579
+ * sort: an MV's resolved `sourceTables` are computed at import time, not serialized
580
+ * in the DDL, so they are unavailable before import. Each round passes the names of
581
+ * every OTHER still-pending MV entry as `pendingDerivations`; the engine defers any
582
+ * entry whose body reads one (its source already pre-exists as a phase-1 plain
583
+ * table, so the body would otherwise plan against content the upstream's own import
584
+ * may be about to replace). The loop repeats while any MV makes progress — robust to
585
+ * arbitrary nesting depth. A genuinely unbuildable MV — a missing (e.g. memory)
586
+ * source, or an unresolvable cycle — makes no progress in a round and is recorded in
587
+ * `errors`.
206
588
  *
207
- * Loads all DDL from the catalog store and imports each entry
208
- * individually. Parse failures are collected rather than fatal,
209
- * so a single corrupt entry does not prevent other tables from
210
- * loading.
589
+ * Per-entry errors in any phase are collected (not fatal) so one bad object does not
590
+ * abort the rest.
211
591
  *
212
- * Call after `db.registerModule()` (and `db.setDefaultVtabName()`
213
- * if DDL may lack a USING clause).
592
+ * Call after `db.registerModule()` (and `db.setDefaultVtabName()` if DDL may lack a
593
+ * USING clause).
214
594
  */
215
595
  rehydrateCatalog(db: Database): Promise<RehydrationResult>;
596
+ /**
597
+ * Consume the clean-shutdown marker, deleting it in the same breath (single-use).
598
+ *
599
+ * Returns `{ trusted, staleAtClose }`:
600
+ * - `trusted` — the marker was present AND its payload parsed as a string array.
601
+ * Absence (a fresh store, a crash, or a prior rehydrate without an intervening
602
+ * {@link closeAll}) yields `false`, so every adopt this session falls back to
603
+ * refill.
604
+ * - `staleAtClose` — the qualified lowercased `schema.mv` names that were
605
+ * stale-at-close (written by {@link closeAll}); those MVs refill even under trust.
606
+ *
607
+ * Conservative parse: any unparseable / wrong-shape payload (including a legacy
608
+ * bare `'1'`, which parses to a number, not an array) degrades to
609
+ * `{ trusted: false, staleAtClose: ∅ }` — refill everything — rather than
610
+ * trust-everything. The marker is deleted regardless of parse outcome.
611
+ */
612
+ private consumeCleanShutdownMarker;
216
613
  /**
217
614
  * Remove DDL from the catalog store when a table is dropped.
218
615
  */
219
616
  removeTableDDL(schemaName: string, tableName: string): Promise<void>;
617
+ /**
618
+ * Persist a plain view's catalog entry (DDL via `generateViewDDL`), keyed by its
619
+ * reserved view prefix. Compare-write (skip identical) — see
620
+ * {@link persistObjectCatalogEntryIfChanged}.
621
+ */
622
+ saveViewDDL(view: ViewSchema): Promise<void>;
623
+ /** Remove a plain view's catalog entry (on DROP VIEW). */
624
+ removeViewDDL(schemaName: string, viewName: string): Promise<void>;
625
+ /**
626
+ * Persist a materialized view's catalog entry (DDL via `generateMaintainedTableDDL`
627
+ * over the maintained table — the unified record), keyed by its reserved MV
628
+ * prefix. Compare-write (skip identical) — see
629
+ * {@link persistObjectCatalogEntryIfChanged}.
630
+ */
631
+ saveMaterializedViewDDL(mv: MaintainedTableSchema): Promise<void>;
632
+ /** Remove a materialized view's catalog entry (on DROP MATERIALIZED VIEW). */
633
+ removeMaterializedViewDDL(schemaName: string, mvName: string): Promise<void>;
634
+ /**
635
+ * Compare-write a view/MV catalog entry: write only when the entry is absent or its
636
+ * DDL differs from `newDDL` (skip identical). Unlike the table path's
637
+ * {@link persistCatalogIfChanged} there is **no** absent→skip self-filter — a view/MV
638
+ * belongs to this db's catalog unconditionally (one module instance serves one
639
+ * Database). Skipping identical writes makes any event whose regenerated DDL is
640
+ * unchanged (e.g. a `materialized_view_refreshed`) a no-op, so a second consecutive
641
+ * reopen yields identical catalog bytes. (Rehydration itself fires no view/MV events
642
+ * at all — `importCatalog` is silent.)
643
+ */
644
+ private persistObjectCatalogEntryIfChanged;
220
645
  /**
221
646
  * Parse module configuration from vtab args.
222
647
  */
223
648
  private parseConfig;
649
+ /**
650
+ * Subscribe (once) to the engine's `SchemaChangeNotifier` so catalog-only
651
+ * mutations that bypass `module.alterTable` — notably `ALTER … SET TAGS` and the
652
+ * programmatic `setTableTags`/`setColumnTags`/`setConstraintTags` — still re-persist
653
+ * the table's catalog DDL. Called lazily from the first `create`/`connect`/
654
+ * `alterTable` hook that hands us a `db`.
655
+ *
656
+ * One `StoreModule` instance is assumed to serve one `Database`. A later hook
657
+ * carrying a *different* `db` keeps the existing subscription (multi-database
658
+ * sharing of a single module instance is out of scope) and logs.
659
+ */
660
+ private ensureSchemaSubscription;
661
+ /**
662
+ * Engine schema-change listener. Persists the catalog incrementally for the events
663
+ * that bypass `module.alterTable` / `module.destroy`:
664
+ *
665
+ * - `table_modified` — every catalog-only tag swap (and the redundant follow-up a
666
+ * structural ALTER fires). Keeps a connected `StoreTable`'s cached schema consistent
667
+ * (SET TAGS does not call `updateSchema`) then read-compare-writes the table bundle.
668
+ * - `view_added` / `view_modified` / `view_removed` — plain `CREATE`/`ALTER … SET TAGS`/
669
+ * `DROP VIEW` (the engine fires these from the runtime emitters).
670
+ * - `materialized_view_added` / `_modified` / `_refreshed` / `_removed` — MV lifecycle.
671
+ * Like `table_modified`, the `_added`/`_modified`/`_refreshed` arms also synchronously
672
+ * refresh the connected `StoreTable`'s cached schema so a tag change (e.g.
673
+ * `quereus.sync.replicate`) takes effect immediately without reopen.
674
+ *
675
+ * Unlike the table path there is **no** catalog-absent self-filter for view/MV
676
+ * add/remove: one `StoreModule` instance serves one `Database`, so that database's
677
+ * views/MVs belong in its catalog unconditionally. A MEMORY-hosted maintained table
678
+ * fires `table_added`/`table_removed`/`table_modified` like any table; those stay
679
+ * ignored (`table_added`/`table_removed` fall through; its `table_modified` is
680
+ * catalog-absent → skipped), so only the MV entry persists for it. A STORE-hosted
681
+ * maintained table additionally persists its own table bundle through the ordinary
682
+ * store-table machinery (which phase-1 rehydrate connects for the adopt fast path).
683
+ *
684
+ * Synchronous by contract (`notifyChange` does not await listeners); every async write
685
+ * rides `persistQueue`, drained by `closeAll`/`whenCatalogPersisted`.
686
+ *
687
+ * After dispatching the event's own catalog persistence, the listener recomputes the
688
+ * durable stale-MV set and compare-writes it (see {@link persistStaleMvSetIfChanged}).
689
+ * Because the engine's MV manager subscribes to the same notifier in the `Database`
690
+ * constructor — before this lazy subscription — its listener runs first, so the
691
+ * `derivation.stale` flags are already current when this recompute reads them.
692
+ */
693
+ private onEngineSchemaChange;
694
+ /** Dispatch a single engine schema-change event to its catalog-persistence arm. */
695
+ private dispatchSchemaChange;
696
+ /**
697
+ * The qualified lowercased `schema.mv` names of every maintained table currently
698
+ * marked `derivation.stale` — an MV whose row-time maintenance detached mid-session
699
+ * (a body-relevant `table_modified`/`table_removed` on a source, or a cascade
700
+ * backing-invalidation), so its durable backing may be behind. The single source of
701
+ * truth for both the clean-shutdown marker payload and the durable stale-MV set.
702
+ *
703
+ * No subscribed db ⇒ the empty set: every path that can mark an MV stale requires a
704
+ * session in which this module observed the db (a store source create/connect or
705
+ * `rehydrateCatalog`, both of which subscribe), so a session without `subscribedDb`
706
+ * never detached any persisted MV's maintenance. Memory-backed MVs that appear here
707
+ * are harmless — their catalog entries always refill (no phase-1 pre-existing
708
+ * backing), so withholding trust from them is a no-op.
709
+ */
710
+ private computeStaleMvSet;
711
+ /**
712
+ * Recompute the durable stale-MV set and, only when it differs from the last value
713
+ * enqueued this subscription ({@link lastPersistedStaleMvs}), enqueue a `sync: true`
714
+ * point-write of it onto {@link persistQueue}. The compare-skip keeps an unrelated
715
+ * `table_modified` (a tag swap that changes no MV's staleness) from costing an fsync.
716
+ *
717
+ * The recompute reads `derivation.stale` synchronously (the flags are current at
718
+ * listener-dispatch time); the field is updated synchronously at enqueue time, and
719
+ * `persistQueue` serializes the writes in order, so the field always names the last
720
+ * enqueued value. Riding `persistQueue` (rather than a bare `put`) also serializes
721
+ * this `sync` behind the triggering event's own source-DDL compare-write, so the
722
+ * stale-set becomes durable no-later-than the source DDL that caused the staleness —
723
+ * the ordering the adopt soundness argument depends on.
724
+ */
725
+ private persistStaleMvSetIfChanged;
726
+ /**
727
+ * Write the durable stale-MV set (`\x00meta\x00stale_mvs`) with `sync: true`. Unlike
728
+ * the clean-shutdown marker this entry is persistent current-truth — overwritten as
729
+ * staleness changes, never deleted — so a crash leaves the last synced value intact.
730
+ * The `sync` mirrors the marker-consume delete's durability discipline (and carries
731
+ * the same documented backend caveat: a backend without a durability knob no-ops it).
732
+ */
733
+ private writeDurableStaleMvSet;
734
+ /**
735
+ * Read + conservative-parse the durable stale-MV set, mirroring
736
+ * {@link consumeCleanShutdownMarker}'s parse discipline (but READ-only — never
737
+ * deleted). Returns:
738
+ * - `{ present: false }` — no entry on disk (a pre-atomic store, or an upgrade where
739
+ * the capability was gained but no stale-set has been written yet) → the caller
740
+ * falls back to the clean-shutdown marker path.
741
+ * - `{ present: true, stale }` — the parsed set of qualified `schema.mv` names.
742
+ * - `{ present: true, stale: null }` — a present but unparseable / wrong-shape payload
743
+ * → the caller refills everything (the always-safe posture).
744
+ */
745
+ private readDurableStaleMvSet;
746
+ /**
747
+ * Shared MV add/modify/refresh handling. Narrow defensively — a derivation-less
748
+ * payload would be an engine bug, so skip. Otherwise, mirror `table_modified`:
749
+ * synchronously refresh a connected `StoreTable`'s cached schema (so a tag change
750
+ * such as `quereus.sync.replicate` takes effect immediately without reopen) before
751
+ * enqueuing the catalog DDL persist.
752
+ */
753
+ private refreshConnectedMaterializedView;
754
+ /**
755
+ * Append a catalog-persistence task to the serialized `persistQueue`, so successive
756
+ * mutations (e.g. SET TAGS (a=1) then SET TAGS ()) apply in order and are drained by
757
+ * `closeAll`/`whenCatalogPersisted` before the provider closes. Errors are
758
+ * swallowed+logged to mirror `notifyChange`'s own try/catch contract — a listener
759
+ * rejection must never escape.
760
+ */
761
+ private enqueuePersist;
762
+ /**
763
+ * Read-compare-write the catalog DDL for a table that just fired `table_modified`.
764
+ *
765
+ * - **Absent** catalog entry → the table is not store-backed in this catalog (a
766
+ * memory table in the same `db`, or a store table never persisted) → skip. This
767
+ * self-filters foreign-module tables without relying on `vtabModule` identity
768
+ * (which points at the isolation wrapper when wrapped).
769
+ * - **Present** but identical DDL → skip (no redundant write). This is what makes a
770
+ * structural ALTER — whose own `alterTable` already wrote the final DDL, then fires
771
+ * `table_modified` with that same final schema — a no-op here (no double-write).
772
+ * - **Present** and different DDL → re-persist (the tag swap; or a beneficial
773
+ * propagated rewrite of a dependent store table).
774
+ */
775
+ private persistCatalogIfChanged;
776
+ /**
777
+ * Resolve once all catalog writes queued by async schema-change listeners
778
+ * (catalog-only tag swaps) have settled. A durability barrier: `closeAll` awaits
779
+ * it internally; callers/tests that need the persisted catalog current without a
780
+ * full close can await it directly. Never rejects — queued errors are logged in
781
+ * the chain.
782
+ */
783
+ whenCatalogPersisted(): Promise<void>;
224
784
  /**
225
785
  * Close all stores.
226
786
  */