@quereus/store 4.4.1 → 4.6.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 (104) hide show
  1. package/README.md +538 -491
  2. package/dist/src/common/encoding.d.ts +4 -3
  3. package/dist/src/common/encoding.d.ts.map +1 -1
  4. package/dist/src/common/encoding.js +17 -13
  5. package/dist/src/common/encoding.js.map +1 -1
  6. package/dist/src/common/events.d.ts +5 -1
  7. package/dist/src/common/events.d.ts.map +1 -1
  8. package/dist/src/common/events.js.map +1 -1
  9. package/dist/src/common/implicit-unique-index.d.ts +80 -0
  10. package/dist/src/common/implicit-unique-index.d.ts.map +1 -0
  11. package/dist/src/common/implicit-unique-index.js +148 -0
  12. package/dist/src/common/implicit-unique-index.js.map +1 -0
  13. package/dist/src/common/index.d.ts +7 -3
  14. package/dist/src/common/index.d.ts.map +1 -1
  15. package/dist/src/common/index.js +5 -2
  16. package/dist/src/common/index.js.map +1 -1
  17. package/dist/src/common/json-key.d.ts +2 -2
  18. package/dist/src/common/json-key.js +2 -2
  19. package/dist/src/common/key-builder.d.ts +40 -20
  20. package/dist/src/common/key-builder.d.ts.map +1 -1
  21. package/dist/src/common/key-builder.js +31 -23
  22. package/dist/src/common/key-builder.js.map +1 -1
  23. package/dist/src/common/kv-store.d.ts +10 -0
  24. package/dist/src/common/kv-store.d.ts.map +1 -1
  25. package/dist/src/common/pk-key-resolution.d.ts +275 -0
  26. package/dist/src/common/pk-key-resolution.d.ts.map +1 -0
  27. package/dist/src/common/pk-key-resolution.js +389 -0
  28. package/dist/src/common/pk-key-resolution.js.map +1 -0
  29. package/dist/src/common/store-module-access-plan.d.ts +32 -0
  30. package/dist/src/common/store-module-access-plan.d.ts.map +1 -0
  31. package/dist/src/common/store-module-access-plan.js +424 -0
  32. package/dist/src/common/store-module-access-plan.js.map +1 -0
  33. package/dist/src/common/store-module-alter-column.d.ts +23 -0
  34. package/dist/src/common/store-module-alter-column.d.ts.map +1 -0
  35. package/dist/src/common/store-module-alter-column.js +393 -0
  36. package/dist/src/common/store-module-alter-column.js.map +1 -0
  37. package/dist/src/common/store-module-alter.d.ts +55 -0
  38. package/dist/src/common/store-module-alter.d.ts.map +1 -0
  39. package/dist/src/common/store-module-alter.js +519 -0
  40. package/dist/src/common/store-module-alter.js.map +1 -0
  41. package/dist/src/common/store-module-base.d.ts +268 -0
  42. package/dist/src/common/store-module-base.d.ts.map +1 -0
  43. package/dist/src/common/store-module-base.js +362 -0
  44. package/dist/src/common/store-module-base.js.map +1 -0
  45. package/dist/src/common/store-module-catalog.d.ts +269 -0
  46. package/dist/src/common/store-module-catalog.d.ts.map +1 -0
  47. package/dist/src/common/store-module-catalog.js +462 -0
  48. package/dist/src/common/store-module-catalog.js.map +1 -0
  49. package/dist/src/common/store-module-index-build.d.ts +88 -0
  50. package/dist/src/common/store-module-index-build.d.ts.map +1 -0
  51. package/dist/src/common/store-module-index-build.js +280 -0
  52. package/dist/src/common/store-module-index-build.js.map +1 -0
  53. package/dist/src/common/store-module-index.d.ts +116 -0
  54. package/dist/src/common/store-module-index.d.ts.map +1 -0
  55. package/dist/src/common/store-module-index.js +373 -0
  56. package/dist/src/common/store-module-index.js.map +1 -0
  57. package/dist/src/common/store-module-rename.d.ts +49 -0
  58. package/dist/src/common/store-module-rename.d.ts.map +1 -0
  59. package/dist/src/common/store-module-rename.js +244 -0
  60. package/dist/src/common/store-module-rename.js.map +1 -0
  61. package/dist/src/common/store-module-schema-rewrite.d.ts +57 -0
  62. package/dist/src/common/store-module-schema-rewrite.d.ts.map +1 -0
  63. package/dist/src/common/store-module-schema-rewrite.js +140 -0
  64. package/dist/src/common/store-module-schema-rewrite.js.map +1 -0
  65. package/dist/src/common/store-module-schema-sync.d.ts +232 -0
  66. package/dist/src/common/store-module-schema-sync.d.ts.map +1 -0
  67. package/dist/src/common/store-module-schema-sync.js +529 -0
  68. package/dist/src/common/store-module-schema-sync.js.map +1 -0
  69. package/dist/src/common/store-module.d.ts +43 -909
  70. package/dist/src/common/store-module.d.ts.map +1 -1
  71. package/dist/src/common/store-module.js +184 -3194
  72. package/dist/src/common/store-module.js.map +1 -1
  73. package/dist/src/common/store-table-base.d.ts +523 -0
  74. package/dist/src/common/store-table-base.d.ts.map +1 -0
  75. package/dist/src/common/store-table-base.js +878 -0
  76. package/dist/src/common/store-table-base.js.map +1 -0
  77. package/dist/src/common/store-table-constraints.d.ts +237 -0
  78. package/dist/src/common/store-table-constraints.d.ts.map +1 -0
  79. package/dist/src/common/store-table-constraints.js +624 -0
  80. package/dist/src/common/store-table-constraints.js.map +1 -0
  81. package/dist/src/common/store-table-scan.d.ts +415 -0
  82. package/dist/src/common/store-table-scan.d.ts.map +1 -0
  83. package/dist/src/common/store-table-scan.js +821 -0
  84. package/dist/src/common/store-table-scan.js.map +1 -0
  85. package/dist/src/common/store-table.d.ts +71 -1044
  86. package/dist/src/common/store-table.d.ts.map +1 -1
  87. package/dist/src/common/store-table.js +130 -2131
  88. package/dist/src/common/store-table.js.map +1 -1
  89. package/dist/src/testing/index.d.ts +12 -0
  90. package/dist/src/testing/index.d.ts.map +1 -0
  91. package/dist/src/testing/index.js +12 -0
  92. package/dist/src/testing/index.js.map +1 -0
  93. package/dist/src/testing/kv-assert.d.ts +19 -0
  94. package/dist/src/testing/kv-assert.d.ts.map +1 -0
  95. package/dist/src/testing/kv-assert.js +25 -0
  96. package/dist/src/testing/kv-assert.js.map +1 -0
  97. package/dist/src/testing/kv-conformance.d.ts.map +1 -1
  98. package/dist/src/testing/kv-conformance.js +1 -15
  99. package/dist/src/testing/kv-conformance.js.map +1 -1
  100. package/dist/src/testing/kv-provider-conformance.d.ts +54 -0
  101. package/dist/src/testing/kv-provider-conformance.d.ts.map +1 -0
  102. package/dist/src/testing/kv-provider-conformance.js +183 -0
  103. package/dist/src/testing/kv-provider-conformance.js.map +1 -0
  104. package/package.json +9 -9
@@ -11,143 +11,68 @@
11
11
  * - Index stores: {schema}.{table}_idx_{name} - one per secondary index
12
12
  * - {prefix}.__stats__ - Unified stats store (row counts for all tables)
13
13
  * - Catalog store: __catalog__ - DDL metadata keyed by {schema}.{table}
14
+ *
15
+ * The class is layered across eight files, each adding one job to the one below:
16
+ * `store-module-base.ts` - module state, store handles, coordinator, name collisions
17
+ * `store-module-catalog.ts` - the catalog store: DDL entries, shutdown marker, stale-MV set
18
+ * `store-module-schema-sync.ts` - rehydration, lazy reconnect, engine schema-change subscription
19
+ * `store-module-index.ts` - CREATE / DROP INDEX and the implicit `_uc_*` store reconcile
20
+ * `store-module-alter-column.ts` - ALTER TABLE ... ALTER COLUMN
21
+ * `store-module-alter.ts` - ALTER TABLE dispatch and every other arm
22
+ * `store-module-rename.ts` - ALTER TABLE ... RENAME TO (two-phase)
23
+ * `store-module.ts` - this file: module lifecycle, capabilities, backing host
24
+ *
25
+ * Three groups read no module state and are free functions instead of layers:
26
+ * `store-module-access-plan.ts` - which access path this module advertises
27
+ * `store-module-index-build.ts` - index population + UNIQUE validation over a row stream
28
+ * `store-module-schema-rewrite.ts` - pure schema-to-schema rewrites around DDL
29
+ *
30
+ * A layer may call downward, never upward. Nothing enforces that explicitly — it holds
31
+ * because a base class cannot name a subclass member, so an upward call fails to compile.
14
32
  */
15
- import { AccessPlanBuilder, QuereusError, StatusCode, buildColumnIndexMap, columnDefToSchema, compilePredicate, inferType, tryFoldLiteral, validateAndParse, buildAdvertisementsFromTags, resolveNamedConstraintClass, validateCollationForType, buildUniqueConstraintSchema, buildForeignKeyConstraintSchema, buildCheckConstraintSchema, validateForeignKeyOverExistingRows, appendIndexToTableSchema, logicalTypeCanHoldText, serializeKey, isMaintainedTable, renameColumnInIndexPredicates, renameTableInIndexPredicates, renameColumnInCheckConstraints, renameTableInCheckConstraints } from '@quereus/quereus';
16
- import { TransactionCoordinator } from './transaction.js';
17
- import { StoreConnection } from './store-connection.js';
33
+ import { QuereusError, StatusCode, buildAdvertisementsFromTags, generateDropTableDDL, generateTableDDL, } from '@quereus/quereus';
18
34
  import { StoreBackingHost } from './backing-host.js';
19
- import { StoreTable, resolvePkKeyCollations, resolvePkKeyTransforms, resolveIndexKeyTransforms, storeSemanticKeyTransform, columnCanHoldText, keyOrderMatchesCollation, pkOrderPreservingPrefixLength, withImplicitUniqueIndexes, implicitUniqueIndexName } from './store-table.js';
20
- import { buildCatalogKey, buildCatalogScanBounds, buildDataKey, buildDataStoreName, buildIndexKey, buildIndexStoreName, buildFullScanBounds, buildStatsKey, buildViewCatalogKey, buildMaterializedViewCatalogKey, parseMaterializedViewCatalogKey, buildMetaCatalogKey, CLEAN_SHUTDOWN_META_NAME, STALE_MVS_META_NAME, classifyCatalogKey, } from './key-builder.js';
21
- import { assertNoUnpairedSurrogate } from './encoding.js';
22
- import { deserializeRow } from './serialization.js';
23
- import { generateTableDDL, generateIndexDDL, generateDropTableDDL, generateDropIndexDDL, generateViewDDL, generateMaintainedTableDDL, generateIndexTagsDDL, isHiddenImplicitIndex, exposedImplicitIndexes } from '@quereus/quereus';
24
- /**
25
- * Default serialized-byte budget for a single index-build write batch. Bounds
26
- * heap directly: {@link StoreModule.buildIndexEntries} flushes and starts a fresh
27
- * batch whenever the accumulated key bytes cross this, so a table larger than
28
- * memory never buffers its whole index in one batch. A byte budget (rather than a
29
- * fixed entry count) is used because index entries vary in size, so bytes bound
30
- * memory more tightly. 8 MiB trades a modest number of extra flushes against peak
31
- * memory; override per store with `using store (max_batch_bytes = …)`.
32
- */
33
- const DEFAULT_MAX_BATCH_BYTES = 8 * 1024 * 1024; // 8 MiB
34
- /**
35
- * Map of `lowercased implicit index name → actual name` for every NON-DERIVED
36
- * UNIQUE constraint of `schema` — the set of `_uc_*` stores that SHOULD exist for
37
- * that constraint set. Derived from `uniqueConstraints` (not `.indexes`), so it is
38
- * correct whether or not `schema` is materialized; the diff of two such maps drives
39
- * {@link StoreModule.reconcileImplicitUniqueIndexStores}.
40
- */
41
- function implicitUniqueIndexNameMap(schema) {
42
- const map = new Map();
43
- for (const uc of schema.uniqueConstraints ?? []) {
44
- if (uc.derivedFromIndex)
45
- continue;
46
- const name = implicitUniqueIndexName(schema, uc);
47
- map.set(name.toLowerCase(), name);
48
- }
49
- return map;
50
- }
51
- /**
52
- * Resolve the index-build batch byte budget from a `max_batch_bytes` module arg.
53
- * A missing, non-numeric, or non-positive value falls back to
54
- * {@link DEFAULT_MAX_BATCH_BYTES} — a zero or negative budget must NEVER disable
55
- * flushing, which would restore the unbounded single-batch behavior this guards
56
- * against.
57
- */
58
- function resolveMaxBatchBytes(raw) {
59
- let n;
60
- if (typeof raw === 'number')
61
- n = raw;
62
- else if (typeof raw === 'bigint')
63
- n = Number(raw);
64
- else if (typeof raw === 'string' && raw.trim() !== '')
65
- n = Number(raw);
66
- else
67
- return DEFAULT_MAX_BATCH_BYTES;
68
- if (!Number.isFinite(n) || n <= 0)
69
- return DEFAULT_MAX_BATCH_BYTES;
70
- return Math.floor(n);
71
- }
35
+ import { StoreTable } from './store-table.js';
36
+ import { CLEAN_SHUTDOWN_META_NAME, STALE_MVS_META_NAME, buildDataStoreName, buildMetaCatalogKey, } from './key-builder.js';
37
+ import { StoreModuleRename } from './store-module-rename.js';
38
+ import { computeBestAccessPlan } from './store-module-access-plan.js';
39
+ import { reconcilePkCollations } from './store-module-schema-rewrite.js';
72
40
  /**
73
- * Planner-side constraint operator groups, as `BestAccessPlanRequest.filters` spells
74
- * them. Kept as one source of truth for the access-plan code below: `getBestAccessPlan`
75
- * classifies each pushed filter with these, and `tryIndexAccessPlan` claims a filter as
76
- * handled only when it falls in the group the engine's access-path rule will consume.
77
- */
78
- const EQ_OPS = ['='];
79
- const LOWER_BOUND_OPS = ['>', '>='];
80
- const UPPER_BOUND_OPS = ['<', '<='];
81
- const RANGE_OPS = [...LOWER_BOUND_OPS, ...UPPER_BOUND_OPS];
82
- /**
83
- * Build `handledFilters` by claiming ONLY the constraints `rule-select-access-path`
84
- * actually consumes: per seek column the FIRST '=' (equality seek), or the FIRST lower
85
- * ('>'/'>=') plus the FIRST upper ('<'/'<=') bound, selected by `colConstraints.find(...)`
86
- * in `request.filters` order.
41
+ * `request` with the table's own size filled into `estimatedRows` when the planner
42
+ * supplied none — otherwise `request` unchanged.
87
43
  *
88
- * That rule collapses `handledFilters` into a per-COLUMN set, so a redundant same-column,
89
- * same-role constraint marked handled is neither turned into a seek bound nor kept by
90
- * `ruleGrowRetrieve` as a residual — its predicate would be LOST (`where v > 10 and v > 30`
91
- * would wrongly return the `v > 10` rows; `where v = 20 and v = 30` would wrongly return
92
- * the `v = 20` row). The engine now reattaches such orphans defensively, but a module that
93
- * over-claims still pays a redundant predicate evaluation per fetched row.
44
+ * The planner's hint comes only from `ANALYZE`-collected statistics, so a
45
+ * never-analyzed table arrives as `undefined` and every cost in
46
+ * `computeBestAccessPlan` would be computed against its fixed 1000-row placeholder,
47
+ * whatever the table actually holds. This module maintains a running row count as it
48
+ * writes ({@link StoreTable.getKnownRowCount}), so it can answer for itself.
94
49
  *
95
- * Claiming must therefore be POSITIONAL and match the rule's first-match pick: claiming the
96
- * tighter-but-later duplicate instead would be actively wrong (the rule would still seek on
97
- * the earlier one). Any later duplicate stays unhandled so it survives in the residual Filter.
98
- */
99
- function claimFirstPerRole(filters, roles) {
100
- const claimed = new Set();
101
- for (const { colIdx, ops } of roles) {
102
- const i = filters.findIndex(f => f.columnIndex === colIdx && ops.includes(f.op));
103
- if (i >= 0)
104
- claimed.add(i);
105
- }
106
- return filters.map((_f, i) => claimed.has(i));
107
- }
108
- /** The lower-bound + upper-bound roles a single-column range seek on `colIdx` fills. */
109
- function rangeRoles(colIdx) {
110
- return [{ colIdx, ops: LOWER_BOUND_OPS }, { colIdx, ops: UPPER_BOUND_OPS }];
111
- }
112
- /** The one equality role each seek column of an equality/prefix seek fills. */
113
- function equalityRoles(colIdxs) {
114
- return colIdxs.map(colIdx => ({ colIdx, ops: EQ_OPS }));
115
- }
116
- /**
117
- * Guard the FULL persisted schema text, not just the object's own name: a lone
118
- * surrogate anywhere in it — a quoted column name, a `default '…'` string literal, a
119
- * `check` constraint's string constant — folds to U+FFFD under `TextEncoder` and would
120
- * read back as different schema text than what was created. Independent of the
121
- * catalog-key builders' identifier guard; it catches what they cannot see.
122
- */
123
- function assertPersistableDdlText(ddl) {
124
- assertNoUnpairedSurrogate(ddl, 'persisted schema text');
125
- }
126
- /**
127
- * The catalog entry a plain view would be persisted as. Building it is the check:
128
- * {@link buildViewCatalogKey} rejects an unencodable name, and the caller then runs
129
- * {@link assertPersistableDdlText} over `ddl`. Shared by the write path
130
- * ({@link StoreModule.saveViewDDL}) and the pre-flight veto
131
- * ({@link StoreModule.assertCatalogObjectPersistable}) so the two cannot drift.
132
- */
133
- function viewCatalogEntry(view) {
134
- return {
135
- key: buildViewCatalogKey(view.schemaName, view.name),
136
- ddl: generateViewDDL(view),
137
- };
138
- }
139
- /**
140
- * The catalog entry a materialized view would be persisted as, or `undefined` when
141
- * `table` carries no derivation (not an MV — nothing lands under the MV prefix).
142
- * Counterpart of {@link viewCatalogEntry}; same write/veto sharing.
50
+ * A supplied hint WINS: it is the number the rest of the plan was costed with (join
51
+ * ordering, cache thresholds and sort costs all read the same catalog statistics), and
52
+ * a module quietly pricing against a different figure would make the access path
53
+ * disagree with the plan around it. A stale post-`ANALYZE` count therefore stays stale
54
+ * here — engine-wide snapshot semantics, not a separate rule.
55
+ *
56
+ * Floored at 1: `rows: 0` is not "this table is empty" in the access-plan protocol, it
57
+ * is a module PROVING its predicate unsatisfiable, and `rule-select-access-path` acts
58
+ * on it by replacing the whole table access with a static empty relation. A live count
59
+ * is a snapshot taken at PLAN time, so an empty table can still be read after rows are
60
+ * written into it by the very same statement — a view update materializing its missing
61
+ * non-preserved-side row does exactly that.
62
+ *
63
+ * NOTE: a prepared `Statement` holds its compiled plan and recompiles only on a schema
64
+ * change, so a long-lived prepared statement keeps the costs derived from the size the
65
+ * table had at first compile. Harmless while this only moves costs, not plan shape; if a
66
+ * prepared statement is ever seen picking a plan wrong for the table's current size, the
67
+ * fix belongs at the statement's recompile trigger, not here.
143
68
  */
144
- function maintainedViewCatalogEntry(table) {
145
- if (!isMaintainedTable(table))
146
- return undefined;
147
- return {
148
- key: buildMaterializedViewCatalogKey(table.schemaName, table.name),
149
- ddl: generateMaintainedTableDDL(table),
150
- };
69
+ function sizeRequestFromLiveCount(request, table) {
70
+ if (request.estimatedRows !== undefined)
71
+ return request;
72
+ const liveRows = table?.getKnownRowCount();
73
+ if (liveRows === undefined)
74
+ return request;
75
+ return { ...request, estimatedRows: Math.max(1, liveRows) };
151
76
  }
152
77
  /**
153
78
  * Generic store module that works with any KVStoreProvider.
@@ -162,66 +87,7 @@ function maintainedViewCatalogEntry(table) {
162
87
  * db.registerModule('store', module);
163
88
  * ```
164
89
  */
165
- export class StoreModule {
166
- provider;
167
- stores = new Map();
168
- /**
169
- * The single transaction coordinator shared by every {@link StoreTable} this
170
- * module owns — the unit of cross-table atomicity (see {@link getCoordinator}).
171
- * Lives for the module's lifetime: a single table's drop must NOT evict it
172
- * (sibling tables still use it); only {@link closeAll} clears it.
173
- */
174
- moduleCoordinator;
175
- tables = new Map();
176
- eventEmitter;
177
- /**
178
- * Optional listener wired by the host (the worker) to forward a logical
179
- * `apply schema` lens deployment to the sync layer's lifecycle bookkeeping.
180
- * The store module is the only basis-backing host with both persistence and a
181
- * `db` handle, so it is the forwarder; `@quereus/store` must not depend on
182
- * `@quereus/sync`, so the listener is a plain callback the worker binds.
183
- */
184
- lensDeploymentListener;
185
- /** Unsubscribe thunk for the engine `SchemaChangeNotifier` listener, set on first hook with a `db`. */
186
- schemaListenerUnsub;
187
- /** The `Database` whose notifier we subscribed to. One module instance serves one `Database`. */
188
- subscribedDb;
189
- /**
190
- * Serialized chain of pending catalog writes triggered by engine schema-change
191
- * events (catalog-only tag swaps). `notifyChange` invokes listeners synchronously
192
- * and does not await them, so the actual read-compare-write runs here, in order,
193
- * and is drained by `closeAll` (and `whenCatalogPersisted`) before the provider closes.
194
- */
195
- persistQueue = Promise.resolve();
196
- /**
197
- * The JSON-serialized stale-MV set last enqueued onto {@link persistQueue} (the
198
- * `\x00meta\x00stale_mvs` value), or `undefined` when no baseline is established
199
- * for the current subscription. The schema-change listener compares each
200
- * recomputed set against this so an unrelated `table_modified` (e.g. a tag swap
201
- * that changes no MV's staleness) costs no extra fsync. Reset to `undefined`
202
- * whenever a fresh `Database` is subscribed ({@link ensureSchemaSubscription}) so
203
- * a reopened module re-establishes the baseline from the first relevant event (or
204
- * the rehydrate tail).
205
- */
206
- lastPersistedStaleMvs;
207
- /**
208
- * Whether the provider exposes an atomic cross-store commit domain
209
- * ({@link KVStoreProvider.beginAtomicBatch}) — the capability the MV adopt fast
210
- * path's gate-5 drop hinges on. Fixed for the provider's lifetime, so cached at
211
- * construction (and used as the single gate for both writing AND reading the durable
212
- * stale-MV set). Gating the **write** on it is load-bearing for soundness, not just an
213
- * fsync saving: only an atomic-capable session — whose commits can never tear a source
214
- * from its same-module backing — ever writes the durable set, so the set a later atomic
215
- * session reads was necessarily written under that same no-tear guarantee. A persistent
216
- * non-atomic provider therefore can never leave a torn-but-"not-stale" set for an atomic
217
- * reopen to trust (it writes none); such a store falls back to the clean-shutdown marker.
218
- */
219
- atomicProvider;
220
- constructor(provider, eventEmitter) {
221
- this.provider = provider;
222
- this.eventEmitter = eventEmitter;
223
- this.atomicProvider = typeof provider.beginAtomicBatch === 'function';
224
- }
90
+ export class StoreModule extends StoreModuleRename {
225
91
  /**
226
92
  * Returns capability flags for this module.
227
93
  *
@@ -279,7 +145,7 @@ export class StoreModule {
279
145
  * pair — `destroy` evicts both maps, so a drop+recreate yields fresh instances
280
146
  * and the returned host is pinned to one backing-table incarnation (the engine
281
147
  * resolves hosts fresh per call, never caching them). Resolution goes through
282
- * {@link getOrReconnectTable} so a rehydrated-but-untouched (or rename-evicted)
148
+ * `StoreModuleSchemaSync.getOrReconnectTable` so a rehydrated-but-untouched (or rename-evicted)
283
149
  * backing still resolves; the ownership pre-check keeps the reconnect fallback
284
150
  * from adopting a registered table owned by a different module (`vtabModule`
285
151
  * must be this StoreModule, or a wrapper — IsolationModule — exposing it as
@@ -296,7 +162,7 @@ export class StoreModule {
296
162
  /**
297
163
  * Resolve a {@link StoreTable} this module owns, reconnecting a
298
164
  * rehydrated-but-untouched (or rename-evicted) table via
299
- * {@link getOrReconnectTable}. The ownership pre-check keeps the reconnect
165
+ * `StoreModuleSchemaSync.getOrReconnectTable`. The ownership pre-check keeps the reconnect
300
166
  * fallback from adopting a registered table owned by a DIFFERENT module:
301
167
  * `vtabModule` must be this StoreModule, or a wrapper (IsolationModule)
302
168
  * exposing it as `underlying`. Returns undefined for an unknown/non-owned
@@ -330,12 +196,6 @@ export class StoreModule {
330
196
  getTableForExternalWrite(db, schemaName, tableName) {
331
197
  return this.resolveOwnedTable(db, schemaName, tableName);
332
198
  }
333
- /**
334
- * Get the event emitter for this module.
335
- */
336
- getEventEmitter() {
337
- return this.eventEmitter;
338
- }
339
199
  /**
340
200
  * Bind (or clear, with `undefined`) the lens-deployment forwarder. The host
341
201
  * (worker) calls this to route a logical `apply schema`'s deployment to the
@@ -344,6 +204,23 @@ export class StoreModule {
344
204
  setLensDeploymentListener(listener) {
345
205
  this.lensDeploymentListener = listener;
346
206
  }
207
+ /**
208
+ * Engine `onRegister` hook: subscribe to the database's schema-change notifier the
209
+ * moment this module is registered, before any statement can run.
210
+ *
211
+ * Views and materialized views never route through a module table hook, so without
212
+ * this the module would only learn its `Database` from the first
213
+ * `create`/`connect`/`alterTable`/`rehydrateCatalog` — and a `create view` issued
214
+ * before that (the first statement of a brand-new database, say) would fire
215
+ * `view_added` at a module that is not listening yet and silently never persist.
216
+ *
217
+ * Idempotent with the lazy call sites; a second `Database` logs
218
+ * `ensureSchemaSubscription`'s "different Database" warning here rather than at first
219
+ * table use (a warning, not a throw — one module instance still serves one Database).
220
+ */
221
+ onRegister(db, _moduleName) {
222
+ this.ensureSchemaSubscription(db);
223
+ }
347
224
  /**
348
225
  * Engine `notifyLensDeployment` hook: a logical `apply schema X` fires this on
349
226
  * every registered module once the lens catalog mutation + snapshot rotation
@@ -369,101 +246,6 @@ export class StoreModule {
369
246
  + `lifecycle bookkeeping skipped: ${e instanceof Error ? e.message : String(e)}`);
370
247
  }
371
248
  }
372
- /**
373
- * Get the KVStoreProvider used by this module.
374
- */
375
- getProvider() {
376
- return this.provider;
377
- }
378
- /**
379
- * Build the set of physical store names this module's data/index stores
380
- * currently occupy in `schemaName`, mapping each name to a human description
381
- * of the logical object that owns it (for sited collision messages).
382
- *
383
- * Physical store names are built by string concatenation (`{schema}.{table}` /
384
- * `{schema}.{table}_idx_{index}`) and the `_idx_` delimiter is itself a legal
385
- * substring of any identifier, so two distinct logical objects (e.g. index
386
- * `archive` on `t` and a sibling table `t_idx_archive`) can collapse to the
387
- * same physical name. This set is the authoritative occupancy used by
388
- * {@link assertStoreNameFree} to reject such a collision at CREATE time.
389
- *
390
- * The occupancy is the union of two sources, robust to lazy connection and the
391
- * isolation wrapper (see the ticket's "Enumeration source" section):
392
- * 1. `this.tables` — every store table this module touched this session.
393
- * 2. the target schema's catalog tables whose `vtabModule === this` and which
394
- * are not views — store-backed tables not yet lazily connected.
395
- * Names embed the schema prefix, so cross-schema entries never collide and no
396
- * per-schema filter on `this.tables` is needed. Memory-backed siblings and
397
- * views own no store in this provider and are excluded (the `=== this` /
398
- * `!isView` filter) to avoid false-positive rejects.
399
- *
400
- * No self-exclusion: at each guarded call the candidate object is not yet
401
- * registered (create/createIndex run before the engine adds it) so it cannot
402
- * self-collide; and for renameTable the renamed table's OWN stores stay in the
403
- * set deliberately. Any overlap between a name the rename introduces and an
404
- * own current store is a footprint-swap rename (`t` with index `x` → `t_idx_x`,
405
- * or table `u_idx_x` with index `x` → `u`) that providers cannot relocate
406
- * safely — relocation order determines whether a source is clobbered before it
407
- * is moved — while no benign rename produces such an overlap. Keeping own
408
- * stores in the set therefore causes no false rejects and keeps the
409
- * reject-before-any-side-effect guarantee uniform.
410
- */
411
- // NOTE: the builders below carry the unpaired-surrogate identifier guard, so this
412
- // enumeration can THROW on an EXISTING schema object, not just on the incoming one —
413
- // an unrelated CREATE/RENAME in the same schema would then fail with a confusing
414
- // "cannot store the identifier ..." error. Unreachable today: `create` throws before
415
- // the table is registered, and a catalog entry written before the guard existed was
416
- // folded to a real U+FFFD character, which passes. If a lone-surrogate-named table ever
417
- // does reach the schema manager by another route, skip such an object here (it occupies
418
- // no store) rather than letting the enumeration throw.
419
- collectOccupiedStoreNames(db, schemaName) {
420
- const names = new Map();
421
- const add = (s) => {
422
- const dataName = buildDataStoreName(s.schemaName, s.name);
423
- if (!names.has(dataName)) {
424
- names.set(dataName, `data store of table '${s.schemaName}.${s.name}'`);
425
- }
426
- for (const idx of s.indexes ?? []) {
427
- const idxName = buildIndexStoreName(s.schemaName, s.name, idx.name);
428
- if (!names.has(idxName)) {
429
- names.set(idxName, `index store of index '${idx.name}' on table '${s.schemaName}.${s.name}'`);
430
- }
431
- }
432
- };
433
- for (const t of this.tables.values()) {
434
- add(t.getSchema());
435
- }
436
- for (const t of db.schemaManager.getSchemaOrFail(schemaName).getAllTables()) {
437
- if (t.vtabModule !== this || t.isView)
438
- continue;
439
- add(t);
440
- }
441
- return names;
442
- }
443
- /**
444
- * Throws `StatusCode.ERROR` when `candidate` (a physical store name produced by
445
- * the key-builder for an object about to be created/renamed) already names an
446
- * existing data or index store in `schemaName`. `candidateDesc` describes the
447
- * incoming logical object; the message names the candidate physical store and
448
- * both conflicting logical objects and is actionable (rename one of them).
449
- *
450
- * Must run BEFORE any storage side-effect (`getStore` / `getIndexStore` / the
451
- * physical relocation): the provider opens/creates the store eagerly, so a
452
- * guard that ran after would already have aliased the colliding object's store.
453
- *
454
- * Callers checking several candidates against the same occupancy (renameTable
455
- * checks the new data store plus every relocated index store) pass a
456
- * precomputed `occupied` map so the occupancy is collected once, not per call.
457
- */
458
- assertStoreNameFree(db, schemaName, candidate, candidateDesc, occupied) {
459
- const occupiedBy = (occupied ?? this.collectOccupiedStoreNames(db, schemaName)).get(candidate);
460
- if (occupiedBy !== undefined) {
461
- throw new QuereusError(`Physical store-name collision: the ${candidateDesc} would map to physical store `
462
- + `'${candidate}', which already backs the ${occupiedBy}. These two objects would `
463
- + `share one physical store and silently corrupt each other. Rename the table or the `
464
- + `colliding index.`, StatusCode.ERROR);
465
- }
466
- }
467
249
  /**
468
250
  * Creates a new store-backed table.
469
251
  * Called by CREATE TABLE.
@@ -674,2920 +456,128 @@ export class StoreModule {
674
456
  // Fallback: just close the data store
675
457
  await this.provider.closeStore(schemaName, tableName);
676
458
  }
677
- // Remove DDL from catalog
459
+ // Remove DDL from catalog. Drain the persist queue first: the `table_added`
460
+ // listener writes a freshly created table's entry asynchronously, so a
461
+ // create-then-drop in the same session could otherwise land that queued write
462
+ // AFTER this delete and resurrect the entry as a phantom table on reopen. Every
463
+ // other direct catalog write in the module is followed by a queued
464
+ // `table_modified` (or, for RENAME, a queued `removeTableDDL`) that re-establishes
465
+ // the truth; a drop has no such follow-up, so it drains explicitly.
466
+ //
467
+ // NOTE: the drain assumes no `persistQueue` task ever reaches teardown — a queued
468
+ // task calling `destroy`/`reclaimDetachedTable` would await the very chain it is a
469
+ // link in and hang. Holds today: every queued task body is a catalog read/write
470
+ // (`persistTableCatalogEntryIfChanged`, `persistCatalogIfChanged`, the view/MV
471
+ // saves and removes, `writeDurableStaleMvSet`, `removeTableDDL`), and the only
472
+ // caller of `reclaimDetachedTable` is the host-driven basis-eviction sweep. If a
473
+ // persist task ever needs to drop a table, give teardown a captured queue tail
474
+ // instead of the live one.
475
+ await this.whenCatalogPersisted();
678
476
  await this.removeTableDDL(schemaName, tableName);
679
477
  }
680
478
  /**
681
- * Returns the connected StoreTable for `schemaName.tableName`, lazily
682
- * reconnecting from the engine's schema registry when absent.
479
+ * Modern access planning interface.
683
480
  *
684
- * `renameTable` evicts the old key from `this.tables` and expects the next
685
- * `connect()` to repopulate under the new name, but `apply schema` can run
686
- * follow-up DDL (ALTER TABLE, CREATE/DROP INDEX) against the new name
687
- * without an intervening connect. Mirror connect()'s schemaManager lookup
688
- * so that DDL finds the moved table. Safe for the index paths because
689
- * SchemaManager calls the module BEFORE mutating the registered table
690
- * schema, so the reconnected cache matches what a connected instance holds.
691
- */
692
- getOrReconnectTable(db, schemaName, tableName) {
693
- const tableKey = `${schemaName}.${tableName}`.toLowerCase();
694
- let table = this.tables.get(tableKey);
695
- if (!table) {
696
- const registeredSchema = db.schemaManager.getTable(schemaName, tableName);
697
- if (registeredSchema) {
698
- table = new StoreTable(db, this, registeredSchema, this.parseConfig(registeredSchema.vtabArgs ?? {}), this.eventEmitter, true);
699
- this.tables.set(tableKey, table);
700
- }
701
- }
702
- return table;
703
- }
704
- /**
705
- * Creates an index on a store-backed table.
481
+ * Every plan is stamped with `honorsCollatedRangeBounds`: the store's post-fetch
482
+ * row filter (`StoreTable.matchesFilters` → `compareValues`) compares each pushed
483
+ * constraint — including LT/LE/GT/GE range bounds — under the column's declared
484
+ * collation, so the access path's collation-cover analysis may keep a
485
+ * collation-matched non-BINARY (NOCASE/RTRIM) PK range/BETWEEN seek instead of
486
+ * declining to a SeqScan + residual (see `classifyConstraintCover` in
487
+ * rule-select-access-path.ts). The seek really does narrow: `StoreTable.scanPKRange`
488
+ * (via `StoreTable.buildPKRangeBounds`) encodes the LT/LE/GT/GE bounds under the
489
+ * same per-column key collations the data keys use and iterates that
490
+ * seek-start/early-termination window. The window is a SUPERSET, so the post-fetch
491
+ * row filter still reproduces the exact collation semantics. (A collation that cannot
492
+ * key at all never reaches a PK column: `StoreTable`'s constructor rejects it at DDL
493
+ * time.) Mirrors the memory module's advertisement.
706
494
  *
707
- * `rows` — an {@link EffectiveRowSource} supplied by a wrapper module (the isolation
708
- * layer) — replaces this module's own rows as the set the UNIQUE duplicate check judges.
709
- * When present the check runs UP FRONT, before `getIndexStore` opens the index-store
710
- * directory, so a rejection leaves no directory behind; and `buildIndexEntries` then
711
- * populates the physical index from this module's own (committed) rows with its in-pass
712
- * dup check disabled. Those two row sets legitimately differ — the wrapper's transaction
713
- * may have deleted a committed duplicate — and an index entry with no live row behind it
714
- * is harmless (see the entry-resolution note in `buildIndexEntries`).
715
- */
716
- async createIndex(db, schemaName, tableName, indexSchema, rows) {
717
- const table = this.getOrReconnectTable(db, schemaName, tableName);
718
- if (!table) {
719
- throw new QuereusError(`Store table '${tableName}' not found in schema '${schemaName}'`, StatusCode.NOTFOUND);
720
- }
721
- // Reject when this new index's physical store name already names an existing
722
- // store: a sibling table's data store (`{schema}.t_idx_<x>` == sibling table
723
- // `t_idx_<x>`) or another table's index store (index-vs-index, e.g.
724
- // `a.b_idx_c` vs `a_idx_b.c` both → `{schema}.a_idx_b_idx_c`). The new index
725
- // is not yet registered in the schema or the table's cached schema at this
726
- // point, so the candidate cannot self-collide. Must precede `getIndexStore`,
727
- // which opens/creates the directory that `buildIndexEntries` then writes into.
728
- const indexStoreName = buildIndexStoreName(schemaName, tableName, indexSchema.name);
729
- this.assertStoreNameFree(db, schemaName, indexStoreName, `index store of new index '${indexSchema.name}' on table '${schemaName}.${tableName}'`);
730
- const tableSchema = table.getSchema();
731
- const keyCollation = (table.getConfig().collation || 'NOCASE').toUpperCase();
732
- // Wrapper-supplied rows are the judged set. Validate BEFORE `getIndexStore` so a
733
- // rejection leaves no index-store directory behind, and disable the in-pass dup
734
- // check below (which would judge this module's committed rows instead).
735
- if (rows && indexSchema.unique) {
736
- await this.validateUniqueIndexOverRows(rows(), tableSchema, indexSchema, db.getKeyNormalizerResolver());
737
- }
738
- // Create the index store
739
- const indexStore = await this.provider.getIndexStore(schemaName, tableName, indexSchema.name);
740
- // Build index entries for every row the table can currently SEE — committed
741
- // rows merged with this transaction's pending writes (`iterateEffectiveEntries`).
742
- // The committed-only stream would leave a row inserted earlier in an open
743
- // transaction unindexed, and `StoreTable.findUniqueConflictViaIndex` trusts the
744
- // index to hold every live row: a `CREATE UNIQUE INDEX` would miss a pending
745
- // duplicate, and a later insert colliding with that pending row would be
746
- // silently ACCEPTED. Entries written here for rows that a subsequent ROLLBACK
747
- // discards are harmless: both readers resolve each entry to its live row and
748
- // drop it when the row is gone or no longer matches (see `scanIndex`).
749
- //
750
- // The store is FRESHLY created above, so any build failure — a UNIQUE violation,
751
- // an IO error, or a mid-stream flush failure now that the build is chunked — must
752
- // tear the whole store down. Nothing else reclaims it: SchemaManager.createIndex
753
- // wraps the error but does no teardown, so without this a rejected build leaks a
754
- // partial/empty index-store directory. Mirrors dropIndex's teardown.
755
- try {
756
- await this.buildIndexEntries(table.iterateEffectiveEntries(buildFullScanBounds()), indexStore, tableSchema, indexSchema, keyCollation, db.getKeyNormalizerResolver(), rows !== undefined, table.getConfig().maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES);
757
- }
758
- catch (buildError) {
759
- // Best-effort teardown: guard it against its own throw so a teardown failure
760
- // never masks the original build error the caller must see.
761
- try {
762
- await table.releaseIndexStore(indexSchema.name);
763
- if (this.provider.deleteIndexStore) {
764
- await this.provider.deleteIndexStore(schemaName, tableName, indexSchema.name);
765
- }
766
- else {
767
- await this.provider.closeIndexStore(schemaName, tableName, indexSchema.name);
768
- }
769
- }
770
- catch (teardownError) {
771
- console.warn(`[StoreModule] failed to tear down index store '${indexSchema.name}' on `
772
- + `table '${schemaName}.${tableName}' after a failed CREATE INDEX: ${String(teardownError)}`);
773
- }
774
- throw buildError;
775
- }
776
- // Refresh the connected table's cached schema so subsequent DML
777
- // maintains the new index (the engine's schema registry is updated
778
- // separately by SchemaManager.createIndex, but the StoreTable instance
779
- // holds its own reference captured at connect time). The shared
780
- // appendIndexToTableSchema also synthesizes the UNIQUE → derived
781
- // uniqueConstraint entry so checkUniqueConstraints enforces it; the
782
- // `derivedFromIndex` tag lets StoreModule.dropIndex filter it back out
783
- // symmetrically (mirrors SchemaManager.dropIndex / MemoryTableManager.dropIndex).
784
- const updatedSchema = appendIndexToTableSchema(tableSchema, indexSchema);
785
- table.updateSchema(updatedSchema);
786
- // Authoritative catalog write: persist the table's bundle now (including the
787
- // new index), so the index survives close → reopen even when the table has
788
- // no rows yet and was never lazily persisted. `markDdlSaved` suppresses the
789
- // later lazy table-only write on first store access (StoreTable.ddlSaved), so
790
- // this is the only catalog write the createIndex produces. SchemaManager fires
791
- // a follow-up `table_modified` whose listener regenerates the SAME bundle and
792
- // skips (identical) — see persistCatalogIfChanged.
793
- await this.saveTableDDL(updatedSchema);
794
- table.markDdlSaved();
795
- // Emit schema change event. `updatedSchema` (not the pre-create
796
- // `tableSchema`) is the owner the canonical CREATE INDEX renders against —
797
- // it is only read for the table's qualified name and column names, but
798
- // passing the post-create schema keeps the rendering consistent with what a
799
- // receiving peer regenerates when it compares definitions.
800
- this.eventEmitter?.emitSchemaChange({
801
- type: 'create',
802
- objectType: 'index',
803
- schemaName,
804
- objectName: indexSchema.name,
805
- ddl: generateIndexDDL(indexSchema, updatedSchema),
806
- });
807
- }
808
- /**
809
- * Drops an index on a store-backed table.
495
+ * The seek/ordering claims are additionally gated on the key collation being
496
+ * ORDER-PRESERVING — byte order and comparator order must coincide, or the seek would
497
+ * under-fetch and the ordering advertisement would elide a Sort it must not. That gate
498
+ * needs the connection's collation registry, hence `db` is threaded down.
810
499
  *
811
- * Mirrors createIndex: refreshes the connected StoreTable's cached
812
- * tableSchema (removing the index entry and any UNIQUE constraint
813
- * synthesized from it, tagged with `derivedFromIndex`), releases the
814
- * cached index-store handle, and tears down the underlying index store.
500
+ * The request is sized from the table's own maintained row count when the planner
501
+ * supplied no estimate — see {@link sizeRequestFromLiveCount}.
815
502
  */
816
- async dropIndex(db, schemaName, tableName, indexName) {
817
- const table = this.getOrReconnectTable(db, schemaName, tableName);
818
- if (!table) {
819
- throw new QuereusError(`Store table '${tableName}' not found in schema '${schemaName}'`, StatusCode.NOTFOUND);
820
- }
821
- const tableSchema = table.getSchema();
822
- const lowerIndexName = indexName.toLowerCase();
823
- // Mirror SchemaManager.dropIndex: strip the index AND any UNIQUE
824
- // constraint synthesized from it (tagged with `derivedFromIndex` by
825
- // StoreModule.createIndex). Collapse uniqueConstraints to undefined
826
- // when empty.
827
- const updatedIndexes = Object.freeze((tableSchema.indexes ?? []).filter(idx => idx.name.toLowerCase() !== lowerIndexName));
828
- const remainingUniqueConstraints = (tableSchema.uniqueConstraints ?? []).filter(uc => uc.derivedFromIndex?.toLowerCase() !== lowerIndexName);
829
- const updatedSchema = {
830
- ...tableSchema,
831
- indexes: updatedIndexes,
832
- uniqueConstraints: remainingUniqueConstraints.length > 0
833
- ? Object.freeze(remainingUniqueConstraints)
834
- : undefined,
503
+ getBestAccessPlan(db, tableInfo, request) {
504
+ // NOTE: this module does not call `validateAccessPlan` / `validateAccessPlanRequest`,
505
+ // so a malformed request (e.g. a `runtimeSet` whose `maxCount` is not a positive
506
+ // integer) is not rejected at this boundary the way it is for the memory module. It
507
+ // degrades safely instead: `equalitySeekKeyCount` returns null for every malformed
508
+ // shape, so the filter is simply never claimed and survives as a residual. If an
509
+ // engine bug ever needs catching here rather than absorbing, call
510
+ // `validateAccessPlan(request, plan)` on the way out.
511
+ // Table key collation K (default NOCASE). Any K reaching here has a key normalizer:
512
+ // `StoreTable`'s constructor rejects one that cannot key, and a name we cannot resolve
513
+ // to a table falls back to the built-in NOCASE. `||`, not `??`, so an empty-string
514
+ // `config.collation` reads as the default here exactly as it does in
515
+ // `StoreTable.encodeOptions` — the two must agree or `analyzePKAccess` would decline a
516
+ // window `computeBestAccessPlan` already marked handled.
517
+ const table = this.getTable(tableInfo.schemaName, tableInfo.name);
518
+ const tableKeyCollation = (table?.getConfig().collation || 'NOCASE').toUpperCase();
519
+ return {
520
+ ...computeBestAccessPlan(db, tableInfo, sizeRequestFromLiveCount(request, table), tableKeyCollation),
521
+ honorsCollatedRangeBounds: true,
835
522
  };
836
- // Update the cached schema BEFORE tearing down the store so that a
837
- // failure of the physical drop doesn't leave the schema enforcing an
838
- // index whose backing store has already been mutated.
839
- table.updateSchema(updatedSchema);
840
- // Rewrite the catalog bundle without the dropped index, before the physical
841
- // teardown — so on reopen the index does not resurrect even if the store
842
- // delete below fails. `markDdlSaved` keeps the lazy first-access save from
843
- // re-writing the same bundle. SchemaManager's follow-up `table_modified`
844
- // regenerates an identical bundle and skips.
845
- await this.saveTableDDL(updatedSchema);
846
- table.markDdlSaved();
847
- // Drop the cached handle on the table side and tear down the
848
- // underlying KVStore. `deleteIndexStore` (if the provider implements
849
- // it) closes the handle before removing the directory; otherwise we
850
- // just close it.
851
- await table.releaseIndexStore(indexName);
852
- if (this.provider.deleteIndexStore) {
853
- await this.provider.deleteIndexStore(schemaName, tableName, indexName);
854
- }
855
- else {
856
- await this.provider.closeIndexStore(schemaName, tableName, indexName);
857
- }
858
- this.eventEmitter?.emitSchemaChange({
859
- type: 'drop',
860
- objectType: 'index',
861
- schemaName,
862
- objectName: indexName,
863
- ddl: generateDropIndexDDL(schemaName, indexName),
864
- });
865
523
  }
866
524
  /**
867
- * Build index entries for all existing rows in a table.
868
- *
869
- * `dataEntries` is the row stream to index. Callers choose its visibility:
870
- * `createIndex` passes the table's EFFECTIVE stream (committed + pending, so
871
- * an open transaction's rows are indexed too), while `rebuildSecondaryIndexes`
872
- * passes the raw committed stream — it runs immediately after an ALTER has
873
- * re-encoded the data store in place, and any pending ops still address the
874
- * pre-ALTER key bytes.
875
- *
876
- * For UNIQUE indexes, performs an in-pass duplicate check (honoring partial
877
- * predicates and SQL NULL semantics: multiple NULLs are allowed) and throws
878
- * CONSTRAINT before any entries are written. Mirrors the memory module's
879
- * populateNewIndex so `CREATE UNIQUE INDEX` over duplicated data fails
880
- * atomically. `skipDuplicateCheck` suppresses it: `createIndex` sets it when a
881
- * wrapper module supplied the rows to judge, having already validated them (see
882
- * {@link validateUniqueIndexOverRows}). Judging `dataEntries` too would reject a
883
- * committed duplicate the wrapper's transaction has already deleted.
884
- *
885
- * `normalizers` MUST be the owning connection's `db.getKeyNormalizerResolver()` —
886
- * the same resolver `StoreTable.encodeOptions` carries. A rebuild that resolved
887
- * collations any other way would re-encode the PK suffix under different bytes than
888
- * the table writes at maintenance time, silently corrupting the index.
889
- */
890
- async buildIndexEntries(dataEntries, indexStore, tableSchema, indexSchema, keyCollation, normalizers, skipDuplicateCheck = false, maxBatchBytes = DEFAULT_MAX_BATCH_BYTES) {
891
- // Index COLUMN values use the table-level key collation K; the PK SUFFIX uses
892
- // each PK column's own key collation, so the suffix bytes match the data-store
893
- // keys (and `StoreTable.updateSecondaryIndexes`' maintenance writes) exactly.
894
- const encodeOptions = { collation: keyCollation, normalizers };
895
- const pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
896
- const pkCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, keyCollation);
897
- // Key-identity transforms for both halves, mirroring `StoreTable`'s maintenance
898
- // writes (`updateSecondaryIndexes` / `encodeDataKey`) — a rebuild that skipped them
899
- // would re-encode a TIMESPAN member under different bytes than DML writes.
900
- const pkTransforms = resolvePkKeyTransforms(tableSchema.primaryKeyDefinition, tableSchema.columns);
901
- const indexTransforms = resolveIndexKeyTransforms(indexSchema, tableSchema.columns);
902
- const indexDirections = indexSchema.columns.map(col => !!col.desc);
903
- const predicate = indexSchema.predicate
904
- ? compilePredicate(indexSchema.predicate, tableSchema.columns, tableSchema.name)
905
- : undefined;
906
- // NOTE: `seen` holds one signature per DISTINCT indexed key for the WHOLE build and
907
- // is NOT bounded by the maxBatchBytes chunking below (that bounds only the write
908
- // batch). A very large UNIQUE index still spikes memory on this set. Bounding it
909
- // needs a sort- or store-probe-based dedup (a separate design), not a batch knob.
910
- const seen = (indexSchema.unique && !skipDuplicateCheck) ? new Set() : undefined;
911
- const indexColIndices = indexSchema.columns.map(col => col.index);
912
- const indexNormalizers = seen
913
- ? indexDedupeNormalizers(tableSchema, indexSchema, normalizers)
914
- : undefined;
915
- let batch = indexStore.batch();
916
- // Serialized key bytes accumulated in the CURRENT batch. Flushed and reset once it
917
- // crosses maxBatchBytes so a table larger than memory never buffers its whole index.
918
- let batchBytes = 0;
919
- for await (const entry of dataEntries) {
920
- const row = deserializeRow(entry.value);
921
- // Partial index: skip rows whose predicate is not unambiguously TRUE.
922
- if (predicate && predicate.evaluate(row) !== true)
923
- continue;
924
- // Extract PK values
925
- const pkValues = tableSchema.primaryKeyDefinition.map(pk => row[pk.index]);
926
- // Extract index column values
927
- const indexValues = indexSchema.columns.map(col => row[col.index]);
928
- if (seen) {
929
- // The signature returns null when any indexed column is NULL —
930
- // SQL UNIQUE allows multiple NULLs, so those rows never collide.
931
- const keySig = dedupeRowSignature(row, indexColIndices, indexNormalizers, indexTransforms);
932
- if (keySig !== null) {
933
- if (seen.has(keySig)) {
934
- const colNames = indexSchema.columns
935
- .map(c => tableSchema.columns[c.index]?.name ?? String(c.index))
936
- .join(', ');
937
- throw new QuereusError(`UNIQUE constraint failed: ${tableSchema.name} (${colNames})`, StatusCode.CONSTRAINT);
938
- }
939
- seen.add(keySig);
940
- }
941
- }
942
- // Build and store index key
943
- const indexKey = buildIndexKey(indexValues, pkValues, encodeOptions, indexDirections, pkDirections, pkCollations, indexTransforms, pkTransforms);
944
- // Index value = the row's encoded DATA key, so an index scan resolves each
945
- // entry back to its base row via a direct data-store read (see
946
- // `StoreTable.scanIndex`). Encoded under the same PK directions + per-column
947
- // PK collations + key transforms as `buildDataKey` / `updateSecondaryIndexes`,
948
- // so the value byte-matches the data store's key for this row.
949
- const dataKey = buildDataKey(pkValues, encodeOptions, pkDirections, pkCollations, pkTransforms);
950
- batch.put(indexKey, dataKey);
951
- // Bound heap: once the accumulated serialized key bytes cross the budget, flush
952
- // the batch and start a fresh one. Both callers ITERATE the data store and WRITE
953
- // the index store — different stores — so a mid-stream flush never mutates the
954
- // stream being read, and this is safe on every provider. A mid-stream flush
955
- // failure in `createIndex` is torn down by its try/catch (the whole fresh index
956
- // store is deleted); in `rebuildSecondaryIndexes` it leaves a partial-but-
957
- // recoverable index (re-run the rebuild — clear + rebuild is idempotent).
958
- batchBytes += indexKey.length + dataKey.length;
959
- if (batchBytes >= maxBatchBytes) {
960
- await batch.write();
961
- batch = indexStore.batch();
962
- batchBytes = 0;
963
- }
964
- }
965
- // Final flush of the residual. May be empty (an empty table, or a build whose last
966
- // row exactly hit the budget and flushed) — providers accept an empty write.
967
- await batch.write();
968
- }
969
- /**
970
- * Clear and rebuild every secondary index of `schema` against the (already
971
- * re-encoded) data store. Secondary-index keys embed the PK suffix, so they must
972
- * be rewritten whenever the data-store PK key bytes change — which happens both
973
- * on `ALTER PRIMARY KEY` (the PK columns change) and on an `ALTER COLUMN … SET
974
- * COLLATE` on a PK member (a PK column's key collation changes). Shared by both
975
- * arms so the clear-then-rebuild stays identical. `schema` must already be the
976
- * post-ALTER schema (its `primaryKeyDefinition` + column collations drive the new
977
- * PK-suffix encoding via {@link buildIndexEntries}). `normalizers` is the owning
978
- * connection's `db.getKeyNormalizerResolver()` — see {@link buildIndexEntries}.
979
- *
980
- * NOTE: reads the data store committed-only and writes the index stores outside the
981
- * coordinator. Sound only because every caller calls {@link ddlCommitPendingOps}
982
- * first, so this module's "committed" is "everything live". A caller that skips that
983
- * flush would rebuild an index missing its transaction's pending rows.
984
- *
985
- * `skipDuplicateCheck` suppresses {@link buildIndexEntries}' in-pass UNIQUE check.
986
- * The value-rewriting / key-transform ALTER COLUMN arm sets it: its pre-mutation
987
- * UNIQUE re-validation already judged the ISSUING connection's effective rows (a
988
- * wrapper's overlay included), and this rebuild sees only THIS module's committed
989
- * rows — a superset that may retain a row the wrapper's transaction has deleted, so
990
- * judging it here would spuriously reject a duplicate pair the probe correctly
991
- * accepted. Mirrors the memory module's deliberately non-enforcing base rebuild
992
- * (the probe is the only guard).
993
- */
994
- async rebuildSecondaryIndexes(schemaName, tableName, table, schema, normalizers, skipDuplicateCheck = false) {
995
- const keyCollation = (table.getConfig().collation || 'NOCASE').toUpperCase();
996
- const dataStore = await this.getStore(schemaName, tableName, table.getConfig());
997
- const maxBatchBytes = table.getConfig().maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
998
- for (const indexSchema of schema.indexes ?? []) {
999
- const indexStore = await this.getIndexStore(schemaName, tableName, indexSchema.name);
1000
- // NOTE: this clear pass buffers EVERY existing index key into one batch. It holds
1001
- // only KEYS (no values), so its peak is far below the value-bearing build pass and
1002
- // is left unbounded for now. Chunking it is harder than the build: it deletes from
1003
- // the SAME store it iterates, so a mid-iteration flush risks iterate-while-mutate
1004
- // semantics that differ per provider (LevelDB snapshots its iterator; IndexedDB may
1005
- // not). If the index key set ever dominates memory, chunk this with a snapshot-safe
1006
- // re-seek, not a naive mid-iteration flush.
1007
- const clearBatch = indexStore.batch();
1008
- for await (const entry of indexStore.iterate(buildFullScanBounds())) {
1009
- clearBatch.delete(entry.key);
1010
- }
1011
- await clearBatch.write();
1012
- await this.buildIndexEntries(dataStore.iterate(buildFullScanBounds()), indexStore, schema, indexSchema, keyCollation, normalizers, skipDuplicateCheck, maxBatchBytes);
1013
- }
1014
- }
1015
- /**
1016
- * Validates `rows` against a UNIQUE constraint, throwing `CONSTRAINT` on the first
1017
- * duplicate before any schema mutation. Used by `ADD CONSTRAINT UNIQUE` (validate
1018
- * against the current collation) and by `SET COLLATE` (pass an `updatedSchema` whose
1019
- * altered column carries the NEW collation, so the dedup is performed under it).
1020
- * Mirrors the duplicate detection in {@link buildIndexEntries}: a `seen` Set keyed on
1021
- * a per-column collation-aware signature of the constrained values, with SQL NULL
1022
- * semantics (a row with any NULL constrained value never counts as a duplicate) and
1023
- * the partial `predicate` honored.
1024
- *
1025
- * `rows` MUST be the rows the DDL-issuing connection can SEE. Ordinarily that is this
1026
- * table's EFFECTIVE stream (committed rows merged with the open transaction's own
1027
- * pending puts/deletes — `StoreTable.iterateEffectiveEntries`, adapted by
1028
- * {@link rowsFromEntries}); when a wrapper module holds the pending rows outside this
1029
- * module it hands them down as an `EffectiveRowSource` instead. Either way, a
1030
- * committed-only stream would let a duplicate inserted earlier in the same transaction
1031
- * slip past validation and land in the table once the transaction commits.
1032
- *
1033
- * No index store is written — store UNIQUE enforcement is a full-scan over
1034
- * `uniqueConstraints` at write time. The signature is built by
1035
- * {@link dedupeRowSignature} with one normalizer per constrained column, resolved from
1036
- * `tableSchema.columns[idx].collation` through the connection's
1037
- * `db.getKeyNormalizerResolver()`, so a per-column collation registered with
1038
- * `db.registerCollation` is honored (matching write-time `compareSqlValues`
1039
- * enforcement). A comparator-only collation raises: rows that cannot be bucketed
1040
- * cannot be deduped. Columns whose declared type can never hold text take the
1041
- * identity normalizer and so never raise.
1042
- */
1043
- async validateUniqueOverExistingRows(rows, tableSchema, uc, keyNormalizers) {
1044
- await assertNoDuplicateRows(rows, tableSchema, uc.columns, uc.columns.map(idx => {
1045
- const column = tableSchema.columns[idx];
1046
- return keyNormalizers(logicalTypeCanHoldText(column.logicalType) ? column.collation : undefined);
1047
- }), uc.predicate ? compilePredicate(uc.predicate, tableSchema.columns, tableSchema.name) : undefined);
1048
- }
1049
- /**
1050
- * Index-shaped twin of {@link validateUniqueOverExistingRows}, used by
1051
- * {@link createIndex} when a wrapper module supplies the rows to judge. Dedupes on the
1052
- * index's own columns under the index column's COLLATE (falling back to the table
1053
- * column's), so it enforces exactly what `buildIndexEntries`' suppressed in-pass check
1054
- * would have — over the wrapper's rows instead of this module's committed ones.
1055
- */
1056
- async validateUniqueIndexOverRows(rows, tableSchema, indexSchema, keyNormalizers) {
1057
- await assertNoDuplicateRows(rows, tableSchema, indexSchema.columns.map(col => col.index), indexDedupeNormalizers(tableSchema, indexSchema, keyNormalizers), indexSchema.predicate ? compilePredicate(indexSchema.predicate, tableSchema.columns, tableSchema.name) : undefined);
1058
- }
1059
- /**
1060
- * Flush the module's buffered writes before a DDL operation that physically
1061
- * rewrites or relocates storage.
1062
- *
1063
- * Such a DDL cannot honor the coordinator's buffer: pending ops are
1064
- * `(keyBytes, valueBytes, store)` triples computed at DML time under the
1065
- * PRE-DDL schema, and every physical rewrite (`StoreTable.rekeyRows` /
1066
- * `migrateRows`, the provider's directory move) reads and writes the COMMITTED
1067
- * store directly. Replaying stale-schema ops over the rewritten store on the
1068
- * eventual commit corrupts, loses, or misfiles those rows. So an `ALTER TABLE`
1069
- * that touches storage is effectively DDL-committing on a store-backed table:
1070
- * flush NOW, before the first physical read, so the rewrite sees every live row
1071
- * and there is nothing left to replay afterwards.
1072
- *
1073
- * Consequences, both deliberate:
1074
- * - The coordinator is module-wide, so this commits the WHOLE module
1075
- * transaction — every table's pending ops, not just the altered/renamed
1076
- * table's — in one all-or-nothing batch. An ALTER cannot half-commit some
1077
- * sibling tables.
1078
- * - Validation running AFTER this point (e.g. `rekeyRows`' duplicate-key pass,
1079
- * which must see pending rows because a pending insert can itself be the
1080
- * duplicate) throws with the enclosing transaction already flushed. The store
1081
- * stays unmutated — only the transaction is gone. Validation that does NOT
1082
- * need pending rows belongs before the call, so the transaction survives it.
1083
- *
1084
- * Subsequent `commit()` calls on the same coordinator are no-ops
1085
- * (`inTransaction` is cleared), which keeps the enclosing transaction safe.
1086
- * The savepoint stack is cleared too, so a later `ROLLBACK TO` / `RELEASE` that
1087
- * the engine still broadcasts warns and degrades rather than throwing — see
1088
- * `TransactionCoordinator.rollbackToSavepoint`, which mirrors the memory
1089
- * module's identical posture.
1090
- */
1091
- async ddlCommitPendingOps() {
1092
- if (this.moduleCoordinator?.isInTransaction()) {
1093
- await this.moduleCoordinator.commit();
1094
- }
1095
- }
1096
- /**
1097
- * Alters an existing store table's structure. Resolves the table, captures the
1098
- * pre-alter schema, and dispatches to the per-change-type `alter*` helper below;
1099
- * the helper does the arm's work (row migration, physical re-key, constraint
1100
- * validation, DDL persist) and returns the updated TableSchema for the engine to
1101
- * register. The shared preamble (schema subscription, reconnect, not-found throw,
1102
- * `defaultNotNull`) lives here so every arm sees the same resolved state.
1103
- */
1104
- async alterTable(db, schemaName, tableName, change, rows) {
1105
- this.ensureSchemaSubscription(db);
1106
- const table = this.getOrReconnectTable(db, schemaName, tableName);
1107
- if (!table) {
1108
- throw new QuereusError(`Store table '${tableName}' not found in schema '${schemaName}'. Cannot alter.`, StatusCode.ERROR);
1109
- }
1110
- // The engine-facing schema carries no `_uc_*` (the store keeps the materialized
1111
- // enforcement copy internal), so the arms build `updatedSchema` off a clean schema
1112
- // exactly as before this feature; `table.updateSchema` recomputes the enforcement
1113
- // copy, and {@link reconcileImplicitUniqueIndexStores} (below) moves the physical
1114
- // stores for any constraint-set change.
1115
- const oldSchema = table.getSchema();
1116
- const defaultNotNull = db.options.getStringOption('default_column_nullability') === 'not_null';
1117
- let updated;
1118
- switch (change.type) {
1119
- case 'addColumn':
1120
- updated = await this.alterAddColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull);
1121
- break;
1122
- case 'dropColumn':
1123
- updated = await this.alterDropColumn(schemaName, tableName, table, oldSchema, change);
1124
- break;
1125
- case 'renameColumn':
1126
- updated = await this.alterRenameColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull);
1127
- break;
1128
- case 'alterPrimaryKey':
1129
- updated = await this.alterPrimaryKeyChange(db, schemaName, tableName, table, oldSchema, change);
1130
- break;
1131
- case 'addConstraint':
1132
- updated = await this.alterAddConstraint(db, schemaName, tableName, table, oldSchema, change, rows);
1133
- break;
1134
- case 'dropConstraint':
1135
- updated = await this.alterDropConstraint(schemaName, tableName, table, oldSchema, change);
1136
- break;
1137
- case 'renameConstraint':
1138
- updated = await this.alterRenameConstraint(schemaName, tableName, table, oldSchema, change);
1139
- break;
1140
- case 'alterColumn':
1141
- updated = await this.alterColumnChange(db, schemaName, tableName, table, oldSchema, change, rows);
1142
- break;
1143
- default: {
1144
- const _exhaustive = change;
1145
- void _exhaustive;
1146
- throw new QuereusError(`Unhandled ALTER TABLE change type '${change.type}'`, StatusCode.INTERNAL);
1147
- }
1148
- }
1149
- // Move the physical `_uc_*` index stores to match the post-ALTER constraint set:
1150
- // the SCHEMA entries were re-materialized by the arm's `table.updateSchema`, but a
1151
- // newly-added constraint's store must be BUILT and a dropped/renamed one's TORN
1152
- // DOWN. `oldSchema` carries the pre-ALTER `uniqueConstraints`; `table.getSchema()`
1153
- // the post-ALTER set. A no-op when the implicit-index name set is unchanged (the
1154
- // common case, incl. PK/collation/type ALTERs whose physical re-encode is already
1155
- // handled by `rebuildSecondaryIndexes`).
1156
- await this.reconcileImplicitUniqueIndexStores(db, schemaName, tableName, table, oldSchema);
1157
- return updated;
1158
- }
1159
- /**
1160
- * Build / tear down the physical index stores backing implicit UNIQUE indexes
1161
- * (`_uc_*`) after an ALTER, by diffing the implicit-index NAMES of the old vs new
1162
- * constraint sets:
1163
- * - a name newly PRESENT (ADD CONSTRAINT UNIQUE, or the target half of a
1164
- * RENAME CONSTRAINT / rename of an unnamed UC's column) has its physical store
1165
- * populated from this module's effective rows;
1166
- * - a name newly ABSENT (DROP CONSTRAINT UNIQUE, or the source half of a rename)
1167
- * has its store torn down, so a later re-ADD does not reopen stale entries.
1168
- *
1169
- * The SCHEMA entry itself is materialized/removed by `withImplicitUniqueIndexes`
1170
- * on the arm's `table.updateSchema`; only the physical store needs this. Runs after
1171
- * every ALTER but is a no-op whenever the implicit-index name set is unchanged
1172
- * (incl. PK / collation / data-type ALTERs, whose same-name physical re-encode is
1173
- * already done by {@link rebuildSecondaryIndexes}).
1174
- *
1175
- * `oldSchema` carries the pre-ALTER `uniqueConstraints` (its `.indexes` may be
1176
- * de-materialized — the diff reads `uniqueConstraints`, not `.indexes`).
1177
- *
1178
- * NOTE: the build populates from THIS module's own effective rows (committed + this
1179
- * transaction's pending), never a wrapper's — mirroring `createIndex`. Under the
1180
- * isolation layer the wrapper's rows are what ADD CONSTRAINT's validation judged;
1181
- * the physical index legitimately fills from this module's committed rows and an
1182
- * entry with no live row behind it is harmless (both readers resolve to the live
1183
- * row). Duplicates were already rejected by `validateUniqueOverExistingRows` (ADD)
1184
- * or cannot exist (RENAME leaves data unchanged), so the in-pass check is skipped.
1185
- */
1186
- async reconcileImplicitUniqueIndexStores(db, schemaName, tableName, table, oldSchema) {
1187
- // Diff the constraint sets by implicit-index NAME (derived from `uniqueConstraints`,
1188
- // so the engine-facing schemas work directly); resolve the index SHAPE to build from
1189
- // the materialized enforcement schema.
1190
- const newSchema = table.getSchema();
1191
- const newMaterialized = table.getMaterializedSchema();
1192
- const oldNames = implicitUniqueIndexNameMap(oldSchema);
1193
- const newNames = implicitUniqueIndexNameMap(newSchema);
1194
- // Tear down each store whose implicit index no longer exists.
1195
- for (const [lower, name] of oldNames) {
1196
- if (!newNames.has(lower)) {
1197
- await this.tearDownImplicitUniqueIndexStore(schemaName, tableName, table, name);
1198
- }
1199
- }
1200
- // Build each newly-materialized implicit index's store from effective rows.
1201
- for (const [lower, name] of newNames) {
1202
- if (oldNames.has(lower))
1203
- continue;
1204
- const indexSchema = (newMaterialized.indexes ?? []).find(ix => ix.name === name);
1205
- if (!indexSchema)
1206
- continue; // defensive — name is derived from the same UC set
1207
- // NOTE: no `assertStoreNameFree` here (unlike explicit `createIndex`): the name
1208
- // is engine-derived and deterministic, and the lazy first-write build path in
1209
- // `updateSecondaryIndexes` does not assert either — keep the two consistent.
1210
- // NOTE: no teardown-on-failure wrapper (unlike `createIndex`): an IO error mid-build
1211
- // leaves a partial `_uc_*` store while the constraint schema is already committed, so
1212
- // enforcement could under-report until the store is rebuilt. Tolerated because the
1213
- // build has no in-pass dup check (skipDuplicateCheck below) so the only failure is a
1214
- // genuine IO error — the same non-atomicity `rebuildSecondaryIndexes` documents; a
1215
- // re-run / reopen rebuilds. Add a try/catch teardown if this ever bites.
1216
- const keyCollation = (table.getConfig().collation || 'NOCASE').toUpperCase();
1217
- const indexStore = await this.provider.getIndexStore(schemaName, tableName, name);
1218
- await this.buildIndexEntries(table.iterateEffectiveEntries(buildFullScanBounds()), indexStore, newSchema, indexSchema, keyCollation, db.getKeyNormalizerResolver(), true, // dup rejection already owned by the arm (ADD validates; RENAME is data-preserving)
1219
- table.getConfig().maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES);
1220
- }
1221
- }
1222
- /**
1223
- * The physical index-store names a table owns for DROP / RENAME TABLE to relocate
1224
- * or reclaim: the MATERIALIZED index set — every explicit `CREATE INDEX` PLUS the
1225
- * hidden `_uc_*` realizing each plain UNIQUE. Reading only `getSchema().indexes`
1226
- * (the engine-facing schema, which carries no `_uc_*`) would strand a `_uc_*` store
1227
- * on disk after DROP TABLE, and — worse — after RENAME TABLE leave the renamed
1228
- * table seeking a freshly-created EMPTY `_uc_*` store, silently accepting a
1229
- * duplicate of a pre-rename row. Prefers the cached StoreTable's own materialized
1230
- * schema; falls back to materializing the schema-manager copy when the instance is
1231
- * already gone.
1232
- */
1233
- materializedIndexNames(table, fallback) {
1234
- const schema = table
1235
- ? table.getMaterializedSchema()
1236
- : (fallback ? withImplicitUniqueIndexes(fallback) : undefined);
1237
- return (schema?.indexes ?? []).map(i => i.name);
1238
- }
1239
- /** Close + delete the physical store of an implicit UNIQUE index. Mirrors `dropIndex`. */
1240
- async tearDownImplicitUniqueIndexStore(schemaName, tableName, table, indexName) {
1241
- await table.releaseIndexStore(indexName);
1242
- if (this.provider.deleteIndexStore) {
1243
- await this.provider.deleteIndexStore(schemaName, tableName, indexName);
1244
- }
1245
- else {
1246
- await this.provider.closeIndexStore(schemaName, tableName, indexName);
1247
- }
1248
- }
1249
- /**
1250
- * ADD COLUMN arm of {@link alterTable}: append the new column, eagerly migrate
1251
- * each row (literal or per-row backfill), and persist. Behavior-preserving
1252
- * extraction of the former `switch` arm.
1253
- *
1254
- * The store always appends. A caller-chosen `insertAtIndex` (module-API only; SQL never
1255
- * produces one) is rejected unless it names the append position, rather than silently
1256
- * landing the column somewhere the caller did not ask for.
525
+ * Close all stores.
1257
526
  */
1258
- async alterAddColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull) {
1259
- if (change.insertAtIndex !== undefined && change.insertAtIndex !== oldSchema.columns.length) {
1260
- throw new QuereusError(`Store-backed table '${schemaName}.${tableName}' can only ADD COLUMN at the end `
1261
- + `(position ${oldSchema.columns.length}), not at position ${change.insertAtIndex}`, StatusCode.UNSUPPORTED);
1262
- }
1263
- // Honor the session `default_collation` for an ADD COLUMN that omits an
1264
- // explicit COLLATE, matching the CREATE path so an ADD-COLUMN-ed text column
1265
- // gets the same collation a CREATE-d one would. The persisted DDL re-emits an
1266
- // explicit COLLATE for any non-BINARY collation, so reopen stays stable.
1267
- const newColSchema = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'), (n) => db.isCollationRegistered(n));
1268
- // Extract default value from column def constraints. Use the shared
1269
- // `tryFoldLiteral` helper so signed numerics like `-123.0`
1270
- // (a UnaryExpr in the AST) are recognized — matching the
1271
- // memory-mode path and the engine-level ALTER validation.
1272
- let defaultValue = null;
1273
- const defaultConstraint = change.columnDef.constraints?.find(c => c.type === 'default');
1274
- if (defaultConstraint?.expr) {
1275
- const folded = tryFoldLiteral(defaultConstraint.expr);
1276
- if (folded !== undefined) {
1277
- defaultValue = folded;
1278
- }
527
+ async closeAll() {
528
+ // Capture the stale-at-close MV set BEFORE the unsubscribe block clears
529
+ // `subscribedDb`. Nothing between here and the marker write can change these
530
+ // flags (closeAll only drains the persist queue and disconnects tables).
531
+ // `stale` is in-memory-only runtime state — an MV whose row-time maintenance was
532
+ // detached mid-session (any `table_modified` on a source: an ALTER, even a
533
+ // `create index`) so subsequent source writes never reached its backing. Carrying
534
+ // the names lets the next open exclude exactly those from the adopt fast path.
535
+ //
536
+ // No subscribed db ⇒ the empty set. `onRegister` subscribes at
537
+ // `Database.registerModule`, so that case now means a module never registered on
538
+ // any `Database` (constructed and closed directly), which by construction observed
539
+ // no schema at all and so detached no persisted MV's maintenance. A module that IS
540
+ // registered but otherwise idle takes the normal path and enumerates its db's
541
+ // maintained tables — an empty list when it has none. Memory-backed MVs that appear in the set are
542
+ // harmless: their catalog entries always refill (no phase-1 pre-existing backing),
543
+ // so withholding trust from them is a no-op.
544
+ const staleAtClose = this.computeStaleMvSet();
545
+ // Stop listening first so no new persist work is enqueued mid-close, then drain
546
+ // the queued catalog writes (tag swaps) before the provider closes.
547
+ if (this.schemaListenerUnsub) {
548
+ this.schemaListenerUnsub();
549
+ this.schemaListenerUnsub = undefined;
550
+ this.subscribedDb = undefined;
1279
551
  }
1280
- // A non-foldable DEFAULT (e.g. `new.<col>`) backfills each existing row from
1281
- // its own value via the engine-supplied evaluator (mirrors the memory path).
1282
- const backfillEvaluator = change.backfillEvaluator;
1283
- // Refuse NOT NULL without a usable DEFAULT on a non-empty table
1284
- // (SQLite-compatible). A per-row evaluator IS usable — its NOT NULL is enforced
1285
- // per row during migration — so it is exempt from this no-default rejection.
1286
- if (newColSchema.notNull && defaultValue === null && !backfillEvaluator) {
1287
- if (await table.hasAnyRows()) {
1288
- throw new QuereusError(`Cannot add NOT NULL column '${newColSchema.name}' to non-empty table `
1289
- + `'${schemaName}.${tableName}' without a DEFAULT value`, StatusCode.CONSTRAINT);
1290
- }
552
+ await this.persistQueue;
553
+ for (const table of this.tables.values()) {
554
+ await table.disconnect();
1291
555
  }
1292
- // Build updated schema: append new column
1293
- const updatedColumns = Object.freeze([...oldSchema.columns, newColSchema]);
1294
- const updatedSchema = {
1295
- ...oldSchema,
1296
- columns: updatedColumns,
1297
- columnIndexMap: buildColumnIndexMap(updatedColumns),
1298
- };
1299
- // Physical rewrite ahead: flush the module's buffered writes so `migrateRows`
1300
- // (a committed-store scan + batch) sees this transaction's rows and re-encodes
1301
- // them under the new column layout. See {@link ddlCommitPendingOps}. Placed
1302
- // after every throw-only check above — the NOT NULL rejection reads effectively
1303
- // (`hasAnyRows`) — so a rejected ALTER leaves the enclosing transaction intact.
1304
- await this.ddlCommitPendingOps();
1305
- // Migrate rows: append the new column's value — a single literal default, or a
1306
- // per-row value derived from the existing row when a backfill evaluator is set.
1307
- const remap = buildColumnRemap(oldSchema.columns.map(c => c.name), updatedColumns.map(c => c.name));
1308
- await table.migrateRows(remap, defaultValue, backfillEvaluator
1309
- ? { evaluator: backfillEvaluator, notNull: newColSchema.notNull, columnName: newColSchema.name }
1310
- : undefined);
1311
- // Update table schema (column-only) and persist DDL. Any constraint declared inline
1312
- // on the added column (UNIQUE / CHECK / FOREIGN KEY) arrives as its own follow-up
1313
- // `addConstraint` call from the engine's `runAddColumn`, which is what persists it —
1314
- // so this arm neither installs nor persists them, and the schema it hands back stays
1315
- // column-only.
1316
- table.updateSchema(updatedSchema);
1317
- await this.saveTableDDL(updatedSchema);
1318
- this.eventEmitter?.emitSchemaChange({
1319
- type: 'alter',
1320
- objectType: 'table',
1321
- schemaName,
1322
- objectName: tableName,
1323
- });
1324
- return updatedSchema;
1325
- }
1326
- /** DROP COLUMN arm of {@link alterTable}: drop the column slot, reindex PK / indexes /
1327
- * UNIQUE, migrate rows, and persist. Behavior-preserving extraction. */
1328
- async alterDropColumn(schemaName, tableName, table, oldSchema, change) {
1329
- const colNameLower = change.columnName.toLowerCase();
1330
- const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === colNameLower);
1331
- if (colIndex === -1) {
1332
- throw new QuereusError(`Column '${change.columnName}' not found.`, StatusCode.ERROR);
556
+ this.tables.clear();
557
+ this.moduleCoordinator = undefined;
558
+ // Every batch has flushed (persist queue drained, tables disconnected):
559
+ // attest the clean shutdown so the next open may take the materialized-view
560
+ // adopt fast path. The marker VALUE is the JSON stale-at-close set (`[]` when
561
+ // nothing is stale) — `rehydrateCatalog` excludes those MVs from the fast path
562
+ // so they refill. Consumed (single-use) by `rehydrateCatalog`. Written LAST,
563
+ // immediately before the provider closes — anything that dies before this line
564
+ // leaves no marker and the next open refills everything.
565
+ const catalogStore = await this.provider.getCatalogStore();
566
+ await catalogStore.put(buildMetaCatalogKey(CLEAN_SHUTDOWN_META_NAME), new TextEncoder().encode(JSON.stringify(staleAtClose)));
567
+ // Also write the DURABLE stale-MV set (persistent current-truth, NOT single-use) —
568
+ // but ONLY for an atomic provider, the sole reader/truster of it (see
569
+ // `StoreModuleBase.atomicProvider`). Same array as the marker payload, under a separate
570
+ // reserved meta key that the next open READS (never deletes). In the atomic-commit
571
+ // domain this — not the crash-losable marker — is the adopt fast path's
572
+ // logical-staleness exclusion basis, so a clean close (a) corrects any rename-restore
573
+ // drift (the one staleness CLEAR transition that fires no event) and (b) guarantees
574
+ // the entry exists even in a session with no staleness events. `provider.closeAll()`
575
+ // below flushes it.
576
+ if (this.atomicProvider) {
577
+ await catalogStore.put(buildMetaCatalogKey(STALE_MVS_META_NAME), new TextEncoder().encode(JSON.stringify(staleAtClose)));
1333
578
  }
1334
- // Build updated schema: remove column and reindex PK/indexes
1335
- // Filter by original index BEFORE remapping to avoid incorrectly
1336
- // removing columns that remap to the dropped column's position.
1337
- const updatedColumns = oldSchema.columns.filter((_, idx) => idx !== colIndex);
1338
- const updatedPkDef = oldSchema.primaryKeyDefinition
1339
- .filter(def => def.index !== colIndex)
1340
- .map(def => ({
1341
- ...def,
1342
- index: def.index > colIndex ? def.index - 1 : def.index,
1343
- }));
1344
- const updatedIndexes = (oldSchema.indexes || [])
1345
- .map(idx => ({
1346
- ...idx,
1347
- columns: idx.columns
1348
- .filter(ic => ic.index !== colIndex)
1349
- .map(ic => ({ ...ic, index: ic.index > colIndex ? ic.index - 1 : ic.index })),
1350
- }))
1351
- .filter(idx => idx.columns.length > 0);
1352
- // Prune any UNIQUE constraint over the dropped column, mirroring the index
1353
- // filtering above. Store-backed UNIQUE is enforced by a full scan over
1354
- // `uniqueConstraints`, so a stranded constraint whose column index dangles past
1355
- // the column array would break the next insert's validation (and the persisted
1356
- // DDL). A UNIQUE that includes the dropped column is removed outright; remaining
1357
- // constraints have their column indices shifted to track the removed slot. This
1358
- // also covers the engine's ADD COLUMN + inline-UNIQUE revert, which drops the
1359
- // just-added (uniquely-constrained) column.
1360
- const updatedUniqueConstraints = (oldSchema.uniqueConstraints ?? [])
1361
- .filter(uc => !uc.columns.includes(colIndex))
1362
- .map(uc => ({ ...uc, columns: Object.freeze(uc.columns.map(i => i > colIndex ? i - 1 : i)) }));
1363
- // Shift or prune this table's own foreign keys, mirroring the UNIQUE pruning above
1364
- // and the memory module's `dropColumn`. `foreignKeys[].columns` are indices into
1365
- // THIS table, so they renumber over the removed slot; a key that loses ANY of its
1366
- // child columns is removed outright (a key missing a child column is a different
1367
- // constraint against the parent's key, not a narrowed one). Left unshifted, a
1368
- // surviving key's index dangles past the column array or silently slides onto an
1369
- // unrelated column — and `saveTableDDL` below persists whichever it ends up being.
1370
- // `referencedColumnNames` is resolved against the parent by name at enforcement
1371
- // time, so the parent side needs nothing here.
1372
- const updatedForeignKeys = (oldSchema.foreignKeys ?? [])
1373
- .filter(fk => !fk.columns.includes(colIndex))
1374
- .map(fk => ({ ...fk, columns: Object.freeze(fk.columns.map(i => i > colIndex ? i - 1 : i)) }));
1375
- const updatedSchema = {
1376
- ...oldSchema,
1377
- columns: Object.freeze(updatedColumns),
1378
- columnIndexMap: buildColumnIndexMap(updatedColumns),
1379
- primaryKeyDefinition: Object.freeze(updatedPkDef),
1380
- indexes: Object.freeze(updatedIndexes),
1381
- uniqueConstraints: updatedUniqueConstraints.length > 0
1382
- ? Object.freeze(updatedUniqueConstraints)
1383
- : undefined,
1384
- foreignKeys: updatedForeignKeys.length > 0
1385
- ? Object.freeze(updatedForeignKeys)
1386
- : undefined,
1387
- };
1388
- // Physical rewrite ahead — flush buffered writes so `migrateRows` re-encodes
1389
- // this transaction's rows too. See {@link ddlCommitPendingOps}.
1390
- await this.ddlCommitPendingOps();
1391
- // Migrate rows: remove the dropped column slot
1392
- const remap = buildColumnRemap(oldSchema.columns.map(c => c.name), updatedColumns.map(c => c.name));
1393
- await table.migrateRows(remap, null);
1394
- // Update table schema and persist DDL
1395
- table.updateSchema(updatedSchema);
1396
- await this.saveTableDDL(updatedSchema);
1397
- this.eventEmitter?.emitSchemaChange({
1398
- type: 'alter',
1399
- objectType: 'table',
1400
- schemaName,
1401
- objectName: tableName,
1402
- });
1403
- return updatedSchema;
579
+ await this.provider.closeAll();
580
+ this.stores.clear();
1404
581
  }
1405
- /** RENAME COLUMN arm of {@link alterTable}: schema-only rewrite (columns, indexes, self-FK,
1406
- * in-place predicate / CHECK AST rewrite) and persist. Behavior-preserving extraction. */
1407
- async alterRenameColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull) {
1408
- if (!change.newColumnDefAst) {
1409
- throw new QuereusError('RENAME COLUMN requires a new column definition AST', StatusCode.INTERNAL);
1410
- }
1411
- const oldNameLower = change.oldName.toLowerCase();
1412
- const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === oldNameLower);
1413
- if (colIndex === -1) {
1414
- throw new QuereusError(`Column '${change.oldName}' not found.`, StatusCode.ERROR);
1415
- }
1416
- const newColSchema = columnDefToSchema(change.newColumnDefAst, defaultNotNull, 'BINARY', (n) => db.isCollationRegistered(n));
1417
- const updatedColumns = oldSchema.columns.map((c, i) => i === colIndex ? newColSchema : c);
1418
- const updatedIndexes = (oldSchema.indexes || []).map(idx => ({
1419
- ...idx,
1420
- columns: idx.columns.map(ic => ic.index === colIndex ? { ...ic, name: change.newName } : ic),
1421
- }));
1422
- const updatedSchema = {
1423
- ...oldSchema,
1424
- columns: Object.freeze(updatedColumns),
1425
- columnIndexMap: buildColumnIndexMap(updatedColumns),
1426
- indexes: Object.freeze(updatedIndexes),
1427
- // A self-referencing FK names the renamed column in `referencedColumnNames`
1428
- // and is persisted by name; the engine rewrites it only in the post-hook
1429
- // pass. Pure copy — no rollback needed, this schema is only adopted on the
1430
- // success path.
1431
- foreignKeys: renameColumnInSelfForeignKeys(oldSchema.foreignKeys, schemaName, tableName, change.oldName, change.newName),
1432
- };
1433
- // A partial index's WHERE clause and a CHECK constraint's expression both
1434
- // still name the OLD column: the engine's `propagateColumnRename` pass runs
1435
- // only after this hook returns. Persisting now would durably write a bundle
1436
- // naming a column the table no longer has, and only the later propagation's
1437
- // `table_modified` event would correct it — a crash in between leaves the
1438
- // catalog un-rehydratable. So rewrite first, in place: each `Expression` is
1439
- // shared by reference with the catalog's `TableSchema` and, for a unique
1440
- // partial index, with the `derivedFromIndex` UNIQUE constraint, so one
1441
- // rewrite covers all holders and makes the later propagation pass a no-op.
1442
- //
1443
- // The rewrites are the first statements in the `try`, and each walks its
1444
- // collection one item at a time, so a throw anywhere — including partway
1445
- // through a walk — must reverse them: restoring `oldSchema` cannot, since the
1446
- // ASTs are shared. Reversing is a no-op wherever nothing names the new column,
1447
- // and the engine has already rejected a rename onto an existing column name,
1448
- // so the reverse pass cannot collide with an expression that legitimately
1449
- // named it.
1450
- const resolveColumnInSource = (s, t, col) => db.schemaManager.getSchema(s)?.getTable(t)?.columnIndexMap.has(col.toLowerCase()) ?? false;
1451
- const rewriteColumn = (from, to) => {
1452
- renameColumnInIndexPredicates(updatedIndexes, tableName, from, to, schemaName, resolveColumnInSource);
1453
- renameColumnInCheckConstraints(oldSchema.checkConstraints, tableName, from, to, schemaName, resolveColumnInSource);
1454
- };
1455
- try {
1456
- rewriteColumn(change.oldName, change.newName);
1457
- // Rename is schema-only — no row migration needed
1458
- table.updateSchema(updatedSchema);
1459
- await this.saveTableDDL(updatedSchema);
1460
- }
1461
- catch (e) {
1462
- rewriteColumn(change.newName, change.oldName);
1463
- throw e;
1464
- }
1465
- this.eventEmitter?.emitSchemaChange({
1466
- type: 'alter',
1467
- objectType: 'table',
1468
- schemaName,
1469
- objectName: tableName,
1470
- });
1471
- return updatedSchema;
1472
- }
1473
- /** ALTER PRIMARY KEY arm of {@link alterTable}: physically re-key the data store, rebuild
1474
- * secondary indexes, and persist. Behavior-preserving extraction. */
1475
- async alterPrimaryKeyChange(db, schemaName, tableName, table, oldSchema, change) {
1476
- const newPkColumns = change.newPkColumns;
1477
- const updatedSchema = {
1478
- ...oldSchema,
1479
- primaryKeyDefinition: Object.freeze(newPkColumns.map(pk => ({ index: pk.index, desc: pk.desc }))),
1480
- };
1481
- // Physical re-key ahead — flush buffered writes so every live row is
1482
- // re-keyed and no stale-schema op replays over the rewritten store.
1483
- // `rekeyRows`' duplicate-key pass runs against the flushed store, so a
1484
- // pending insert that collides under the new PK is caught. See
1485
- // {@link ddlCommitPendingOps} for the transaction consequences.
1486
- await this.ddlCommitPendingOps();
1487
- // Re-key the data store. Throws CONSTRAINT on duplicates without
1488
- // mutating the store, giving us all-or-nothing semantics for the
1489
- // validation phase.
1490
- await table.rekeyRows(newPkColumns);
1491
- // Secondary index keys embed the PK suffix — clear + rebuild every
1492
- // index against the now-rekeyed data store. Rebuild the MATERIALIZED index list
1493
- // so each implicit `_uc_*` PK suffix is re-encoded too (`updatedSchema` is built
1494
- // off the de-materialized `oldSchema`, so it carries no `_uc_*` on its own).
1495
- await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver());
1496
- table.updateSchema(updatedSchema);
1497
- await this.saveTableDDL(updatedSchema);
1498
- this.eventEmitter?.emitSchemaChange({
1499
- type: 'alter',
1500
- objectType: 'table',
1501
- schemaName,
1502
- objectName: tableName,
1503
- });
1504
- return updatedSchema;
1505
- }
1506
- /** ADD CONSTRAINT arm of {@link alterTable}: validate existing rows as the constraint kind
1507
- * (UNIQUE / FOREIGN KEY / CHECK) requires, then persist. Behavior-preserving extraction. */
1508
- async alterAddConstraint(db, schemaName, tableName, table, oldSchema, change, rows) {
1509
- const constraint = change.constraint;
1510
- let updatedSchema;
1511
- if (constraint.type === 'unique') {
1512
- // Validate the existing rows against the new UNIQUE before persisting. The
1513
- // implicit `_uc_*` index that backs enforcement is materialized into the
1514
- // StoreTable schema by `table.updateSchema` below, and its PHYSICAL store is
1515
- // built by `reconcileImplicitUniqueIndexStores` after this arm returns — the
1516
- // validation here runs first, so a CONSTRAINT rejection never leaves a store
1517
- // behind.
1518
- //
1519
- // No `ddlCommitPendingOps()` here (unlike the row-rewriting arms): this
1520
- // writes no rows, and the validation scan already reads effectively, so
1521
- // the transaction survives a CONSTRAINT rejection.
1522
- const uc = buildUniqueConstraintSchema(constraint, oldSchema.columnIndexMap);
1523
- await this.validateUniqueOverExistingRows(rows ? rows() : rowsFromEntries(table.iterateEffectiveEntries(buildFullScanBounds())), oldSchema, uc, db.getKeyNormalizerResolver());
1524
- updatedSchema = {
1525
- ...oldSchema,
1526
- uniqueConstraints: Object.freeze([...(oldSchema.uniqueConstraints ?? []), uc]),
1527
- };
1528
- }
1529
- else if (constraint.type === 'foreignKey') {
1530
- const fk = buildForeignKeyConstraintSchema(constraint, oldSchema.columnIndexMap, oldSchema.name, oldSchema.schemaName);
1531
- updatedSchema = {
1532
- ...oldSchema,
1533
- foreignKeys: Object.freeze([...(oldSchema.foreignKeys ?? []), fk]),
1534
- };
1535
- // Pragma-gated existing-row validation; throws before persistence on an orphan.
1536
- await validateForeignKeyOverExistingRows(db, updatedSchema, fk);
1537
- }
1538
- else if (constraint.type === 'check') {
1539
- // Schema-only: a CHECK has no physical structure and (matching the
1540
- // engine's prior in-emitter behavior) no existing-row scan. Routing it
1541
- // here — rather than catalog-only — keeps the persisted DDL and the
1542
- // connected-table schema in lock-step so DROP/RENAME CONSTRAINT resolve it.
1543
- const check = buildCheckConstraintSchema(constraint, oldSchema.checkConstraints.length);
1544
- updatedSchema = {
1545
- ...oldSchema,
1546
- checkConstraints: Object.freeze([...oldSchema.checkConstraints, check]),
1547
- };
1548
- }
1549
- else {
1550
- throw new QuereusError(`Store table ADD CONSTRAINT does not support constraint type '${constraint.type}'`, StatusCode.UNSUPPORTED);
1551
- }
1552
- table.updateSchema(updatedSchema);
1553
- await this.saveTableDDL(updatedSchema);
1554
- this.eventEmitter?.emitSchemaChange({
1555
- type: 'alter',
1556
- objectType: 'table',
1557
- schemaName,
1558
- objectName: tableName,
1559
- });
1560
- return updatedSchema;
1561
- }
1562
- /** DROP CONSTRAINT arm of {@link alterTable}: schema-only catalog rewrite dropping a named
1563
- * constraint, then persist. Behavior-preserving extraction. */
1564
- async alterDropConstraint(schemaName, tableName, table, oldSchema, change) {
1565
- // Schema-only catalog rewrite. Dropping a UNIQUE removes it from
1566
- // `uniqueConstraints`, so `table.updateSchema` below de-materializes its implicit
1567
- // `_uc_*` index and `reconcileImplicitUniqueIndexStores` (after this arm returns)
1568
- // tears down the now-orphaned physical store — without which a later re-ADD would
1569
- // reopen stale entries. A UNIQUE derived from a CREATE UNIQUE INDEX is rejected
1570
- // upstream (drop the index instead), and has no `_uc_*` anyway.
1571
- const constraintClass = resolveNamedConstraintClass(oldSchema, change.constraintName);
1572
- const lower = change.constraintName.toLowerCase();
1573
- let updatedSchema;
1574
- if (constraintClass === 'check') {
1575
- updatedSchema = {
1576
- ...oldSchema,
1577
- checkConstraints: Object.freeze(oldSchema.checkConstraints.filter(c => c.name?.toLowerCase() !== lower)),
1578
- };
1579
- }
1580
- else if (constraintClass === 'foreignKey') {
1581
- const remaining = (oldSchema.foreignKeys ?? []).filter(c => c.name?.toLowerCase() !== lower);
1582
- updatedSchema = { ...oldSchema, foreignKeys: remaining.length > 0 ? Object.freeze(remaining) : undefined };
1583
- }
1584
- else {
1585
- const remaining = (oldSchema.uniqueConstraints ?? []).filter(c => c.name?.toLowerCase() !== lower);
1586
- updatedSchema = { ...oldSchema, uniqueConstraints: remaining.length > 0 ? Object.freeze(remaining) : undefined };
1587
- }
1588
- table.updateSchema(updatedSchema);
1589
- await this.saveTableDDL(updatedSchema);
1590
- this.eventEmitter?.emitSchemaChange({
1591
- type: 'alter',
1592
- objectType: 'table',
1593
- schemaName,
1594
- objectName: tableName,
1595
- });
1596
- return updatedSchema;
1597
- }
1598
- /** RENAME CONSTRAINT arm of {@link alterTable}: schema-only rename of a named constraint,
1599
- * then persist. A renamed named-UNIQUE changes its implicit `_uc_*` index name, so
1600
- * `reconcileImplicitUniqueIndexStores` (run after this arm) MOVES the physical store —
1601
- * tears down the old-named store and rebuilds the new-named one from effective rows. */
1602
- async alterRenameConstraint(schemaName, tableName, table, oldSchema, change) {
1603
- const constraintClass = resolveNamedConstraintClass(oldSchema, change.oldName);
1604
- const oldLower = change.oldName.toLowerCase();
1605
- let updatedSchema;
1606
- if (constraintClass === 'check') {
1607
- updatedSchema = {
1608
- ...oldSchema,
1609
- checkConstraints: Object.freeze(oldSchema.checkConstraints.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
1610
- };
1611
- }
1612
- else if (constraintClass === 'foreignKey') {
1613
- updatedSchema = {
1614
- ...oldSchema,
1615
- foreignKeys: Object.freeze(oldSchema.foreignKeys.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
1616
- };
1617
- }
1618
- else {
1619
- updatedSchema = {
1620
- ...oldSchema,
1621
- uniqueConstraints: Object.freeze(oldSchema.uniqueConstraints.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
1622
- };
1623
- }
1624
- table.updateSchema(updatedSchema);
1625
- await this.saveTableDDL(updatedSchema);
1626
- this.eventEmitter?.emitSchemaChange({
1627
- type: 'alter',
1628
- objectType: 'table',
1629
- schemaName,
1630
- objectName: tableName,
1631
- });
1632
- return updatedSchema;
1633
- }
1634
- /** ALTER COLUMN arm of {@link alterTable}: change one attribute (NOT NULL / data type /
1635
- * default / collation), running the physical re-key + existing-row re-validation the
1636
- * collation / PK paths require, then persist. Behavior-preserving extraction. */
1637
- async alterColumnChange(db, schemaName, tableName, table, oldSchema, change, rows) {
1638
- const colNameLower = change.columnName.toLowerCase();
1639
- const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === colNameLower);
1640
- if (colIndex === -1) {
1641
- throw new QuereusError(`Column '${change.columnName}' not found.`, StatusCode.ERROR);
1642
- }
1643
- const oldCol = oldSchema.columns[colIndex];
1644
- // Pull exactly one attribute from the change. Each sub-helper returns the new
1645
- // column schema plus whether the collation bytes changed (`collationChanged` gates
1646
- // the existing-row UNIQUE re-validation and PK re-key below) plus a DEFERRED
1647
- // `valueConvert` closure when the change rewrites stored values — the sub-helper
1648
- // itself mutates nothing. Or null when the column is already in the desired state —
1649
- // preserving the pre-refactor early exits (SET NOT NULL no-op, SET COLLATE
1650
- // already-explicit) that leave the table and any open transaction untouched.
1651
- let attr;
1652
- if (change.setNotNull !== undefined) {
1653
- attr = await this.alterColumnSetNotNull(table, oldSchema, oldCol, colIndex, change, rows);
1654
- }
1655
- else if (change.setDataType !== undefined) {
1656
- attr = await this.alterColumnSetDataType(table, oldSchema, oldCol, colIndex, change);
1657
- }
1658
- else if (change.setDefault !== undefined) {
1659
- attr = { newCol: { ...oldCol, defaultValue: change.setDefault }, collationChanged: false };
1660
- }
1661
- else if (change.setCollation !== undefined) {
1662
- attr = this.alterColumnSetCollation(oldCol, change, (n) => db.isCollationRegistered(n));
1663
- }
1664
- else {
1665
- throw new QuereusError('ALTER COLUMN requires an attribute to change', StatusCode.INTERNAL);
1666
- }
1667
- if (attr === null) {
1668
- return oldSchema;
1669
- }
1670
- const { newCol, collationChanged, valueConvert } = attr;
1671
- // Named for the PENDING act, not a completed one: the deferred value rewrite (SET DATA
1672
- // TYPE conversion / SET NOT NULL backfill) is applied below, after every throw-only
1673
- // check. Both gates that read this run BEFORE it, while the store still holds the old
1674
- // values.
1675
- const rewritesValues = valueConvert !== undefined;
1676
- const updatedColumns = oldSchema.columns.map((c, i) => i === colIndex ? newCol : c);
1677
- // Mirror the memory module (MemoryTableManager.alterColumn): a per-column
1678
- // collation change propagates into every index column ordering by this
1679
- // column, so a `derivedFromIndex` UNIQUE re-keys its enforcement under the
1680
- // new collation. StoreTable.uniqueEnforcementCollations reads the index's
1681
- // per-column collation, so without this the index entry would stay stale
1682
- // and the derived UNIQUE would keep enforcing the OLD collation after the
1683
- // ALTER. Metadata-only: the store's index KEY bytes use the table-level key
1684
- // collation K (see buildIndexEntries / updateSecondaryIndexes), so no index
1685
- // entry re-encode is required for a non-PK column. An index column with an
1686
- // explicit COLLATE is re-collated too — matching memory, which clobbers it
1687
- // the same way (no surface preserves a differing index COLLATE across an
1688
- // ALTER COLUMN SET COLLATE on its column).
1689
- const updatedIndexes = (collationChanged && oldSchema.indexes)
1690
- ? oldSchema.indexes.map(idx => ({
1691
- ...idx,
1692
- columns: idx.columns.map(ic => ic.index === colIndex ? { ...ic, collation: newCol.collation } : ic),
1693
- }))
1694
- : oldSchema.indexes;
1695
- const updatedSchema = {
1696
- ...oldSchema,
1697
- columns: Object.freeze(updatedColumns),
1698
- columnIndexMap: buildColumnIndexMap(updatedColumns),
1699
- indexes: updatedIndexes ? Object.freeze(updatedIndexes) : updatedIndexes,
1700
- };
1701
- // SET DATA TYPE onto/off a type with a key transform (TIMESPAN's total-seconds
1702
- // groupKey, JSON's structural encoder — see `storeSemanticKeyTransform`)
1703
- // changes the physical KEY BYTES a value encodes to, even when the stored
1704
- // VALUE bytes don't change (text → timespan keeps TEXT physical). Treated
1705
- // exactly like a collation change below: existing-row UNIQUE re-validation
1706
- // (equal-elapsed spellings now collide), a PK re-key when the column is a PK
1707
- // member, and a secondary-index rebuild (index keys encode the column's
1708
- // values). A same-type no-op ALTER never gets here (`inferType` returns the
1709
- // shared type object, so the identity check short-circuits).
1710
- const keyTransformChanged = oldCol.logicalType !== newCol.logicalType
1711
- && (storeSemanticKeyTransform(oldCol.logicalType) !== undefined
1712
- || storeSemanticKeyTransform(newCol.logicalType) !== undefined);
1713
- // Existing-row UNIQUE re-validation (Option A, non-PK UNIQUE). Two ways an ALTER
1714
- // COLUMN can make rows that were distinct collide, both judged here BEFORE any
1715
- // mutation/persist so the first collision throws CONSTRAINT and leaves the table
1716
- // unchanged and writable (matches the ADD CONSTRAINT rollback shape):
1717
- // - SET COLLATE / a key-transform change: the comparators change ('a'/'A' collide
1718
- // under NOCASE). Re-scan under the NEW collation (`updatedSchema` carries it).
1719
- // - a value-REWRITING change (`valueConvert`: SET DATA TYPE, or a SET NOT NULL
1720
- // null → DEFAULT backfill): the comparators are unchanged but the VALUES collapse
1721
- // ('1'/'01' → 1; two NULLs → the same DEFAULT). The probe judges the CONVERTED
1722
- // rows — the same closure the deferred rewrite below applies — while the store
1723
- // still holds the old values. A standalone `create unique index` synthesizes a
1724
- // `derivedFromIndex` entry in `uniqueConstraints`, so both declaration shapes are
1725
- // reachable through this walk.
1726
- // The PK is intentionally excluded — it never appears in `uniqueConstraints`;
1727
- // its physical re-key/re-validation is the `pkRekeyNeeded` block below.
1728
- //
1729
- // NOTE: one full row scan per covering constraint, and this gate now fires on the
1730
- // COMMON retype/backfill path, not only on the rare collation/transform ones. Fine
1731
- // while tables covered by several UNIQUE constraints over one column are rare; if a
1732
- // wide-constraint table makes ALTER COLUMN slow, judge all covering constraints in a
1733
- // single pass (one `seen` Set per constraint, one stream).
1734
- if (collationChanged || keyTransformChanged || rewritesValues) {
1735
- const coveringConstraints = (updatedSchema.uniqueConstraints ?? [])
1736
- .filter(uc => uc.columns.includes(colIndex));
1737
- for (const uc of coveringConstraints) {
1738
- // Fresh generator per constraint — an async generator is single-shot. An
1739
- // `EffectiveRowSource` is re-callable for exactly this reason.
1740
- const effectiveRows = rows ? rows() : rowsFromEntries(table.iterateEffectiveEntries(buildFullScanBounds()));
1741
- await this.validateUniqueOverExistingRows(valueConvert ? convertRowsAtIndex(effectiveRows, colIndex, valueConvert) : effectiveRows, updatedSchema, uc, db.getKeyNormalizerResolver());
1742
- }
1743
- }
1744
- // SET COLLATE on a PRIMARY KEY member (Option B physical re-key): re-encode
1745
- // every data-store key under the column's new key collation, then rebuild every
1746
- // secondary index (its keys embed the PK suffix). `rekeyRows` validates in a
1747
- // first pass and throws CONSTRAINT on a collision under the new collation WITHOUT
1748
- // mutating the store — so a coarser collation that collapses two distinct PKs
1749
- // (e.g. 'a'/'A' under BINARY→NOCASE) is rejected all-or-nothing, mirroring
1750
- // ALTER PRIMARY KEY. Runs AFTER the non-PK UNIQUE re-validation above so both
1751
- // throw-only checks precede the first store mutation. `updatedSchema.columns`
1752
- // carries the new collation, so the new key bytes follow it.
1753
- const pkRekeyNeeded = (collationChanged || keyTransformChanged)
1754
- && oldSchema.primaryKeyDefinition.some(def => def.index === colIndex);
1755
- if (pkRekeyNeeded) {
1756
- // Physical re-key ahead — flush buffered writes (see
1757
- // {@link ddlCommitPendingOps}). Deliberately AFTER the non-PK UNIQUE
1758
- // re-validation above: that check reads effectively and throws without
1759
- // needing the flush, so it must keep the transaction alive.
1760
- //
1761
- // NOTE: `rekeyRows` detects PK collisions among THIS module's rows only. When a
1762
- // wrapper module supplied `rows`, its staged rows are not in this store, so a
1763
- // pending row that collides with a committed one under the new collation is
1764
- // caught by neither side — the wrapper's overlay enforces the PK among its own
1765
- // rows, this store among its own. Closing it needs a PK-dedupe pass over `rows`
1766
- // under the new collation here, before the re-key.
1767
- await this.ddlCommitPendingOps();
1768
- await table.rekeyRows(oldSchema.primaryKeyDefinition, updatedSchema.columns);
1769
- // Materialize so the implicit `_uc_*` PK suffix is re-encoded under the new
1770
- // key collation too (`updatedSchema` carries none on its own).
1771
- await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver());
1772
- }
1773
- // Deferred value rewrite (SET DATA TYPE physical conversion / SET NOT NULL DEFAULT
1774
- // backfill): every throw-only check above has passed, so the store mutation is now
1775
- // safe — a rejected ALTER never reaches this point, leaving values, DDL and the
1776
- // transaction untouched. Flush buffered writes first so `mapRowsAtIndex` rewrites this
1777
- // transaction's rows too; unflushed, they would replay under the OLD physical type
1778
- // (see {@link ddlCommitPendingOps}; a re-flush after the PK re-key's own is a no-op).
1779
- //
1780
- // NOTE: `valueConvert` and `pkRekeyNeeded` cannot both be set today, and this ordering
1781
- // depends on that. `valueConvert` comes only from SET DATA TYPE or SET NOT NULL;
1782
- // SET NOT NULL changes neither collation nor logical type, so it never sets
1783
- // `pkRekeyNeeded`, and SET DATA TYPE on a PK member is refused both upstream (every live
1784
- // caller — the engine's ALTER COLUMN emitter, see `runAlterColumn` in
1785
- // runtime/emit/alter-table.ts, and the materialized-view reshape, which declares a
1786
- // key-column retype inexpressible) AND locally, by `alterColumnSetDataType` itself. If a
1787
- // PK-member retype is ever admitted (that local guard removed), this rewrite must move IN
1788
- // FRONT of the `pkRekeyNeeded` block: otherwise `rekeyRows` and its index rebuild encode
1789
- // the PRE-rewrite values and the rebuild below is skipped, leaving keys and indexes
1790
- // disagreeing with the stored values.
1791
- if (valueConvert) {
1792
- await this.ddlCommitPendingOps();
1793
- await table.mapRowsAtIndex(colIndex, valueConvert);
1794
- }
1795
- // The value rewrite above changed stored column values in place (same PK, new value) —
1796
- // and a key-identity transform change re-encodes the column's index-key bytes even with
1797
- // values untouched. Secondary index KEY bytes encode the indexed column VALUES, so any
1798
- // index covering this column still points at the OLD bytes until rebuilt — an
1799
- // index-backed lookup for the new value finds nothing (mirrors the memory module's
1800
- // `valueConvert` rebuild in MemoryTableManager.alterColumn). `rebuildSecondaryIndexes`
1801
- // reads committed-only, so flush buffered writes first — the value rewrite above
1802
- // already did, and the transform-only path (text → timespan: no value rewrite) must
1803
- // too; a re-flush with nothing pending is a no-op. Skipped when the PK re-key above
1804
- // already rebuilt every index, so no double rebuild. Materialize so an implicit `_uc_*`
1805
- // over the rewritten column is rebuilt against the new value bytes too. The rebuild is
1806
- // NON-enforcing (`skipDuplicateCheck`): the UNIQUE re-validation above already judged
1807
- // the issuer's effective rows, and this module's committed rows may retain a row a
1808
- // wrapper's transaction has deleted whose converted value duplicates a survivor —
1809
- // enforcing here would spuriously reject what the probe correctly accepted.
1810
- if ((rewritesValues || keyTransformChanged) && !pkRekeyNeeded) {
1811
- await this.ddlCommitPendingOps();
1812
- await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver(), true);
1813
- }
1814
- table.updateSchema(updatedSchema);
1815
- await this.saveTableDDL(updatedSchema);
1816
- this.eventEmitter?.emitSchemaChange({
1817
- type: 'alter',
1818
- objectType: 'table',
1819
- schemaName,
1820
- objectName: tableName,
1821
- });
1822
- return updatedSchema;
1823
- }
1824
- /**
1825
- * SET NOT NULL / DROP NOT NULL sub-branch of {@link alterColumnChange}. Returns the
1826
- * new column schema, or null when the column is already in the desired nullability
1827
- * (the pre-refactor `return oldSchema` no-op). Mutates nothing: the NULL-backfill
1828
- * probe is throw-only, and the backfill itself is returned as a deferred
1829
- * `valueConvert` (null → DEFAULT) the caller applies only after every throw-only
1830
- * check — including the UNIQUE re-validation over the backfilled values — has passed.
1831
- *
1832
- * `rows` is the wrapper-supplied effective row source (the isolation overlay). When present,
1833
- * the reject-vs-backfill decision scans it instead of `table.rowsWithNullAtIndex`: behind the
1834
- * isolation layer the issuer's pending inserts live in the wrapper's overlay, not this store,
1835
- * so the store's own count would miss them and wrongly accept the ALTER. The committed-store
1836
- * `mapRowsAtIndex` backfill is unchanged — the overlay-resident pending rows are the isolation
1837
- * layer's job (its overlay migration), this store only owns its committed rows.
1838
- */
1839
- async alterColumnSetNotNull(table, oldSchema, oldCol, colIndex, change, rows) {
1840
- let newCol;
1841
- let valueConvert;
1842
- if (change.setNotNull === true && !oldCol.notNull) {
1843
- // Backfill NULLs from a literal DEFAULT, or throw.
1844
- let defaultLiteral;
1845
- const expr = oldCol.defaultValue;
1846
- if (expr && expr.type === 'literal') {
1847
- defaultLiteral = expr.value ?? null;
1848
- }
1849
- // Decide reject-vs-backfill over the DDL transaction's EFFECTIVE rows. `rows()` (the
1850
- // isolation overlay) sees the issuer's pending inserts; `rowsWithNullAtIndex` (run
1851
- // directly, no wrapper) sees the store's own effective rows. Either way a NULL the
1852
- // transaction can see is backfilled, or rejects the ALTER, like any other row.
1853
- let anyNull;
1854
- if (rows) {
1855
- anyNull = false;
1856
- for await (const row of rows()) {
1857
- if (row[colIndex] === null) {
1858
- anyNull = true;
1859
- break;
1860
- }
1861
- }
1862
- }
1863
- else {
1864
- anyNull = (await table.rowsWithNullAtIndex(colIndex)) > 0;
1865
- }
1866
- if (anyNull) {
1867
- if (defaultLiteral === undefined || defaultLiteral === null) {
1868
- // Throw-only, and before the DDL-commit below: the transaction survives.
1869
- throw new QuereusError(`column ${change.columnName} contains NULL values`, StatusCode.CONSTRAINT);
1870
- }
1871
- const fill = defaultLiteral;
1872
- // Backfill DEFERRED: the caller flushes + `mapRowsAtIndex`es this closure only
1873
- // after its throw-only checks pass. Run directly, that fills the transaction's
1874
- // rows too (the probe saw them). Behind isolation the issuer's overlay-resident
1875
- // NULL rows are filled by the isolation layer's overlay migration, not here —
1876
- // this store never holds them.
1877
- valueConvert = (v) => v === null ? fill : v;
1878
- }
1879
- newCol = { ...oldCol, notNull: true };
1880
- }
1881
- else if (change.setNotNull === false && oldCol.notNull) {
1882
- if (oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
1883
- throw new QuereusError(`Cannot DROP NOT NULL on PRIMARY KEY column '${change.columnName}'`, StatusCode.CONSTRAINT);
1884
- }
1885
- newCol = { ...oldCol, notNull: false };
1886
- }
1887
- else {
1888
- return null; // already in desired state
1889
- }
1890
- return { newCol, collationChanged: false, valueConvert };
1891
- }
1892
- /**
1893
- * SET DATA TYPE sub-branch of {@link alterColumnChange}. Returns the retyped column
1894
- * schema. Mutates nothing: for every retype between DIFFERENT logical types, a
1895
- * throw-only convert pass over the live rows proves every value convertible, and the
1896
- * conversion itself is returned as a deferred `valueConvert` the caller applies only
1897
- * after every throw-only check — including the UNIQUE re-validation over the converted
1898
- * values — has passed. Gated on logical-type IDENTITY, not the physical storage class:
1899
- * `inferType` flattens aliases to the shared type object (`varchar(50)` IS `TEXT_TYPE`),
1900
- * so an alias retype is schema-only, while a same-class retype (text → date) still
1901
- * rejects values the new type refuses and rewrites the rest to the new type's
1902
- * canonical spelling ('2024-06-05T00:00:00Z' → '2024-06-05') — exactly as an INSERT
1903
- * would have stored them.
1904
- *
1905
- * A retype of a PRIMARY KEY member is refused here rather than converted: the rewrite
1906
- * below is `mapRowsAtIndex`, a payload-only rewrite that reuses `entry.key` verbatim, so
1907
- * a PK column's physical key bytes would stay encoded under the OLD type while the value
1908
- * moves to the new one — unfindable by any lookup under the new encoding, and the same
1909
- * `keyTransformChanged` path in the caller would re-key from the pre-rewrite values before
1910
- * this rewrite ever ran (see the NOTE above the `valueConvert` block in
1911
- * {@link alterColumnChange}). Every live SQL caller already refuses this earlier (the
1912
- * engine's `runAlterColumn` and the materialized-view reshape's inexpressibility check),
1913
- * so this only guards a direct module call — mirrors the memory backend's carve-out
1914
- * (`MemoryTableManager.alterColumn`).
1915
- */
1916
- async alterColumnSetDataType(table, oldSchema, oldCol, colIndex, change) {
1917
- const newLogicalType = inferType(change.setDataType);
1918
- let valueConvert;
1919
- if (newLogicalType !== oldCol.logicalType) {
1920
- if (oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
1921
- throw new QuereusError(`Cannot change the data type of primary key column '${change.columnName}' of table '${oldSchema.name}'.`, StatusCode.CONSTRAINT);
1922
- }
1923
- // Conversion required — walk every row and attempt parse.
1924
- const convert = (v) => {
1925
- try {
1926
- return validateAndParse(v, newLogicalType, change.columnName);
1927
- }
1928
- catch {
1929
- throw new QuereusError(`Cannot convert value in '${change.columnName}' to ${change.setDataType}`, StatusCode.MISMATCH);
1930
- }
1931
- };
1932
- // Throw-only pass over the LIVE rows, so an unconvertible value this
1933
- // transaction inserted rejects the ALTER with the transaction still intact.
1934
- // The rewrite itself is DEFERRED: the caller flushes + `mapRowsAtIndex`es
1935
- // this closure only after its remaining throw-only checks pass.
1936
- // NOTE: pre-existing gap — this scan reads the store's own effective rows and
1937
- // ignores a wrapper-supplied `rows` stream (unlike the memory module's arm). No
1938
- // hole under isolation: the wrapper's `validateOverlayMigration` converts every
1939
- // staged overlay value itself before the underlying mutates.
1940
- for await (const value of table.iterateEffectiveValuesAtIndex(colIndex)) {
1941
- if (value !== null)
1942
- convert(value);
1943
- }
1944
- valueConvert = (v) => v === null ? v : convert(v);
1945
- }
1946
- return { newCol: { ...oldCol, logicalType: newLogicalType }, collationChanged: false, valueConvert };
1947
- }
1948
- /**
1949
- * SET COLLATE sub-branch of {@link alterColumnChange}. Returns the recollated column
1950
- * schema with `collationChanged` set when the collation bytes actually change (a bare
1951
- * metadata flip keeps it false), or null when the column is already explicit in the
1952
- * desired collation (the pre-refactor `return oldSchema` no-op). Synchronous — no
1953
- * row scan happens here; the caller runs the re-validation / re-key the flag gates.
1954
- */
1955
- alterColumnSetCollation(oldCol, change, isCollationRegistered) {
1956
- // Per-column collation update. PRIMARY KEY uniqueness/ordering is enforced
1957
- // PHYSICALLY in the key bytes under a PER-COLUMN key collation
1958
- // (`StoreTable.pkKeyCollations`), so a PK-column SET COLLATE is honored
1959
- // natively by physically re-keying the data store + rebuilding every
1960
- // secondary index under the new collation (the `isPkColumn` block below),
1961
- // mirroring the memory module's primary re-key. A re-key that would collide
1962
- // under the new collation throws CONSTRAINT before any mutation. For non-PK
1963
- // UNIQUE constraints we re-validate existing rows under the new collation
1964
- // (Option A). Query-layer ORDER BY / `=` / `table_info().collation` pick the
1965
- // new collation up from the column schema once this updated schema re-registers.
1966
- const normalized = validateCollationForType(change.setCollation, oldCol.logicalType, change.columnName, isCollationRegistered);
1967
- const nameMatches = normalized === (oldCol.collation || 'BINARY');
1968
- if (nameMatches && oldCol.collationExplicit) {
1969
- return null; // already explicit in the desired collation — no scan, no re-key, no re-persist
1970
- }
1971
- // SET COLLATE is a user declaration with the same standing as a
1972
- // CREATE-time COLLATE clause, so mark the collation explicit (rank 2
1973
- // in the comparison lattice) regardless of the column's creation
1974
- // history — including SET COLLATE binary. When only the name matches
1975
- // but the column was not yet explicit (a defaulted collation, or one
1976
- // inherited from session default_collation), flip the flag as a
1977
- // METADATA-ONLY change: the collation bytes are unchanged, so keep
1978
- // collationChanged false to skip rekeyRows / validateUniqueOverExistingRows
1979
- // below while still re-registering the schema and re-persisting DDL.
1980
- // A different name takes the full physical re-key path AND sets the flag.
1981
- return { newCol: { ...oldCol, collation: normalized, collationExplicit: true }, collationChanged: !nameMatches };
1982
- }
1983
- /**
1984
- * Rename a store-backed table.
1985
- *
1986
- * Drops every in-memory reference to the old name (so the coordinator, open
1987
- * handles, and cached StoreTable instance don't linger with stale paths),
1988
- * delegates physical storage relocation to the provider, then rewrites the
1989
- * persistent catalog DDL under the new key. After this returns, the next
1990
- * access to `newName` will reconnect via `connect()` and open fresh stores
1991
- * against the moved directories.
1992
- */
1993
- async renameTable(db, schemaName, oldName, newName) {
1994
- const oldKey = `${schemaName}.${oldName}`.toLowerCase();
1995
- const newKey = `${schemaName}.${newName}`.toLowerCase();
1996
- if (this.tables.has(newKey)) {
1997
- throw new QuereusError(`Store table '${newName}' already exists in schema '${schemaName}'`, StatusCode.ERROR);
1998
- }
1999
- // Capture the current schema BEFORE the guard (and before we drop in-memory
2000
- // references): the guard needs the index list to compute every relocated
2001
- // store name, and the new catalog DDL must reflect the real column set.
2002
- const existing = this.tables.get(oldKey);
2003
- const currentSchema = existing?.getSchema() ?? db.schemaManager.getTable(schemaName, oldName);
2004
- // Authoritative index list (exact store names): the provider relocates
2005
- // exactly these index stores instead of prefix-scanning `{oldName}_idx_`,
2006
- // which would also catch a sibling table named `{oldName}_idx_<x>`.
2007
- // MATERIALIZED, so the hidden `_uc_*` store realizing a plain UNIQUE moves with
2008
- // the table — otherwise the renamed table seeks a fresh EMPTY `_uc_*` and silently
2009
- // accepts a duplicate of a pre-rename row. `currentSchema` itself stays
2010
- // non-materialized: the catalog DDL rewritten below must carry no `_uc_*`.
2011
- const indexNames = this.materializedIndexNames(existing, currentSchema);
2012
- // Reject when ANY physical name the rename introduces — the new data store
2013
- // AND each relocated index store `{schema}.{newName}_idx_{x}` — already
2014
- // names an existing store. E.g. rename some table to `q_idx_archive` while
2015
- // table `q` has index `archive` (both → `{schema}.q_idx_archive`); or rename
2016
- // `t`→`u` while `t` has index `x` and a sibling table is literally named
2017
- // `u_idx_x`, which would relocate `t`'s index onto the sibling's data store.
2018
- // The renamed table's own current stores stay in the occupied set (see
2019
- // collectOccupiedStoreNames): an introduced name can only equal an own store
2020
- // in a footprint-swap rename providers cannot relocate safely. All checks
2021
- // run before the FIRST side effect (the coordinator commit, disconnect, and
2022
- // cache evictions below, then the physical relocation) so a colliding
2023
- // rename is a clean no-op.
2024
- const occupied = this.collectOccupiedStoreNames(db, schemaName);
2025
- this.assertStoreNameFree(db, schemaName, buildDataStoreName(schemaName, newName), `data store of table '${schemaName}.${newName}' (rename target)`, occupied);
2026
- for (const indexName of indexNames) {
2027
- this.assertStoreNameFree(db, schemaName, buildIndexStoreName(schemaName, newName, indexName), `index store of index '${indexName}' on table '${schemaName}.${newName}' (rename target)`, occupied);
2028
- }
2029
- // Flush buffered writes before the old store's handle is closed: once the
2030
- // on-disk directory moves, prior buffered ops address stores that no longer
2031
- // exist under those names. See {@link ddlCommitPendingOps}.
2032
- await this.ddlCommitPendingOps();
2033
- // Hard-dispose the evicted handle: flush any lazy stats it was buffering AND
2034
- // deregister its coordinator stats-callback pair (the renamed instance is
2035
- // gone after this — the next connect()/getOrReconnectTable mints a fresh one
2036
- // that re-registers against the shared coordinator). Dispose failures must
2037
- // not block the physical rename.
2038
- if (existing) {
2039
- try {
2040
- await existing.dispose();
2041
- }
2042
- catch {
2043
- /* ignore — physical rename must proceed */
2044
- }
2045
- }
2046
- this.tables.delete(oldKey);
2047
- this.stores.delete(oldKey);
2048
- // The coordinator is module-wide (flushed above); it is not per-table, so
2049
- // it is not evicted here.
2050
- // Evict the disposed instance's registered engine connections — but ONLY the
2051
- // ones this module created. Unlike drop — where the engine's schema manager
2052
- // calls `removeConnectionsForTable` for us — the generic rename path
2053
- // (`alter-table.ts` renameTableImpl) does NOT, so the store must evict here or
2054
- // a StoreConnection leaks one per rename. Safe for `StoreConnection`s (both the
2055
- // StoreTable-owned DML connection and the StoreBackingHost-owned one): they hold
2056
- // no state of their own, delegating to the module-wide coordinator that the
2057
- // DDL-commit above already flushed, and their owning StoreTable is now disposed.
2058
- //
2059
- // A blanket name-keyed sweep is NOT safe: a wrapping module (the isolation layer)
2060
- // registers its own connection under the same qualified name, and that connection
2061
- // is the only thing that drives its staged overlay to storage at COMMIT. Evicting
2062
- // it silently drops the transaction's writes.
2063
- //
2064
- // NOTE: a rename onto a never-before-used name leaves the wrapper's connection
2065
- // registered under the stale old name (it is not retargeted here). Benign — the
2066
- // commit flush resolves overlays db-wide by their re-keyed names — but a workload
2067
- // that renames one table through many distinct names in a single process
2068
- // accumulates one such connection per name. If that ever matters, retarget the
2069
- // connection's `tableName` across the rename instead of leaving it stale.
2070
- const oldQualified = `${schemaName}.${oldName}`.toLowerCase();
2071
- for (const conn of db.getAllConnections()) {
2072
- if (conn instanceof StoreConnection && conn.tableName.toLowerCase() === oldQualified) {
2073
- db.removeConnection(conn.connectionId);
2074
- }
2075
- }
2076
- // Move physical storage (data directory + index directories).
2077
- //
2078
- // NOTE: the relocation runs BEFORE the catalog rewrite below, and nothing undoes it.
2079
- // Any failure after this point — an IO error, or the DDL-text guard in
2080
- // `encodeCatalogDDL` firing on an unpaired surrogate in a column name or a `default`
2081
- // literal — strands the rows under the NEW physical name while the catalog still
2082
- // names the old table, so the table reads as empty with only the raised error as a
2083
- // clue. Harmless for today's validation cases: the store-name guard in
2084
- // `buildDataStoreName` (called above, before any side effect) refuses a bad target
2085
- // name outright, and a table whose DDL text is unpersistable can never have held
2086
- // rows in the first place. If a new post-relocation failure mode appears, the
2087
- // relocation must be undone here or deferred until after the catalog write.
2088
- if (this.provider.renameTableStores) {
2089
- await this.provider.renameTableStores(schemaName, oldName, newName, indexNames);
2090
- }
2091
- // Rewrite persistent catalog under the new name. Write the new DDL first
2092
- // so a crash mid-rename leaves the table discoverable under at least one
2093
- // name rather than neither.
2094
- if (currentSchema) {
2095
- const renamedSchema = {
2096
- ...currentSchema,
2097
- name: newName,
2098
- // A self-referencing FK is persisted as `references <oldName>(...)`; after
2099
- // `removeTableDDL` below, that names a table no longer in the catalog. Pure
2100
- // copy — no rollback needed, `renamedSchema` is local to the write.
2101
- foreignKeys: retargetSelfForeignKeys(currentSchema.foreignKeys, schemaName, oldName, newName),
2102
- };
2103
- // A partial index's WHERE clause and a CHECK constraint's expression can each
2104
- // carry a table-qualified self-reference (`where t.b > 0`) still naming the OLD
2105
- // table: `propagateTableRename` runs only after this hook returns, so — exactly
2106
- // as in the `renameColumn` arm of `alterTable` — persisting now would durably
2107
- // write a stale qualifier that only the later propagation event corrects.
2108
- // Rewrite first, in place (each `Expression` is shared with the catalog
2109
- // `TableSchema` and, for a unique partial index, with its derived UNIQUE
2110
- // constraint), which also makes that later pass a no-op for these fields. A
2111
- // throw anywhere in the `try` — including partway through a walk — reverses the
2112
- // rewrites; reversing is a no-op wherever nothing names `newName`. The physical
2113
- // stores have already moved by this point, so the reverse restores only the AST,
2114
- // not the on-disk layout.
2115
- //
2116
- // NOTE: the reverse assumes no expression legitimately named `newName` before
2117
- // the rename. The rename-target guard above makes that true for a real table,
2118
- // and `compilePredicate` now rejects a foreign `table` qualifier at create time,
2119
- // so a live partial-index predicate can only carry a self-qualifier (`where
2120
- // <thisTable>.b > 0`) or a bare reference — never `where <newName>.b > 0` for a
2121
- // different table. The mis-reversal path is therefore unreachable for a live
2122
- // predicate.
2123
- const rewriteTable = (from, to) => {
2124
- renameTableInIndexPredicates(currentSchema.indexes, from, to, schemaName);
2125
- renameTableInCheckConstraints(currentSchema.checkConstraints, from, to, schemaName);
2126
- };
2127
- try {
2128
- rewriteTable(oldName, newName);
2129
- await this.saveTableDDL(renamedSchema);
2130
- }
2131
- catch (e) {
2132
- rewriteTable(newName, oldName);
2133
- throw e;
2134
- }
2135
- }
2136
- // NOTE: the OLD name's catalog entry is deliberately NOT removed here. Deleting it
2137
- // synchronously would drop `oldName` from the catalog BEFORE the engine's post-hook
2138
- // `propagateTableRename` has rewritten — let alone persisted — the OTHER tables /
2139
- // views / MVs that still name `oldName` (a cross-schema FK, a CHECK expression, a
2140
- // dependent view/MV body). A crash in that gap would strand a durable catalog set
2141
- // naming a vanished table, which reopens as a healthy-looking database whose
2142
- // dependents cannot be written to. The removal is deferred to `finalizeRename`,
2143
- // which the engine calls AFTER propagation has made the dependents durable.
2144
- // Migrate the stats entry (unified __stats__ store, keyed by schema.table).
2145
- // The entry is RE-KEYED, not physically moved with the directory: a unified
2146
- // store keys every table's stats under `schema.table` in one store, so the
2147
- // value must be copied from the old key to the new key and the old key
2148
- // dropped. `dispose()` above already flushed any buffered delta to disk under
2149
- // the old key, so the read here sees the current row-count estimate; deleting
2150
- // without copying (the prior behavior) blinded the planner — a freshly-renamed
2151
- // table reported getEstimatedRowCount() === 0 until stats were re-gathered.
2152
- //
2153
- // NOTE: this re-key reaches the old value only when getStatsStore(newName)
2154
- // returns a store that CONTAINS the old key — true for the shipped providers,
2155
- // which all share one unified __stats__ store, and for any provider whose
2156
- // renameTableStores physically relocates a per-table stats store. A provider
2157
- // that kept per-table stats stores and did NOT relocate them in
2158
- // renameTableStores would orphan the old-keyed value out of reach here (the
2159
- // prior delete-only code lost it just the same — no regression). No shipped
2160
- // provider does this; revisit if one ever keeps per-table, non-relocated stats.
2161
- try {
2162
- const statsStore = await this.provider.getStatsStore(schemaName, newName);
2163
- const oldStatsKey = buildStatsKey(schemaName, oldName);
2164
- const statsValue = await statsStore.get(oldStatsKey);
2165
- if (statsValue) {
2166
- // A table with no stats yet (never flushed) returns undefined here —
2167
- // skip the copy so no spurious zero-count entry lands under the new key.
2168
- await statsStore.put(buildStatsKey(schemaName, newName), statsValue);
2169
- }
2170
- await statsStore.delete(oldStatsKey);
2171
- }
2172
- catch {
2173
- /* stats are advisory — a stats hiccup must never block the rename */
2174
- }
2175
- this.eventEmitter?.emitSchemaChange({
2176
- type: 'alter',
2177
- objectType: 'table',
2178
- schemaName,
2179
- objectName: newName,
2180
- });
2181
- }
2182
- /**
2183
- * Second phase of a two-phase RENAME TABLE (see {@link renameTable}). The engine calls
2184
- * this at the END of ALTER TABLE ... RENAME TO — AFTER its `propagateTableRename` has
2185
- * rewritten every dependent object that named `oldName` and enqueued their corrective
2186
- * catalog writes onto {@link persistQueue}. `renameTable` deliberately left `oldName`'s
2187
- * catalog entry in place; here we drain those dependent writes to durability and only
2188
- * THEN drop the old entry. During the window both entries coexist on disk, so every
2189
- * dependent resolves against one of them and every intermediate catalog set rehydrates
2190
- * into a working database.
2191
- *
2192
- * The old-entry delete rides `persistQueue` behind the dependents' already-enqueued
2193
- * writes (FIFO), so it can only run after them, and the drain below awaits the whole
2194
- * chain. Errors are swallowed+logged (the {@link enqueuePersist} contract): a failed
2195
- * delete leaves the old entry present — a visible, droppable orphan, strictly safer
2196
- * than a dependent stranded against a vanished table.
2197
- *
2198
- * NOTE: full cross-table atomicity — bundling this old-entry delete together with every
2199
- * dependent rewrite into one `provider.beginAtomicBatch` commit — would eliminate even
2200
- * the transient two-entry window (and the physical-move orphan `renameTableStores`
2201
- * leaves), but only on atomic providers, and the dependent set is known only to the
2202
- * engine post-propagate. That is the atomic-provider hardening path; out of scope here.
2203
- */
2204
- async finalizeRename(_db, schemaName, oldName, _newName) {
2205
- this.enqueuePersist(() => this.removeTableDDL(schemaName, oldName));
2206
- await this.whenCatalogPersisted();
2207
- }
2208
- /**
2209
- * Modern access planning interface.
2210
- *
2211
- * Every plan is stamped with `honorsCollatedRangeBounds`: the store's post-fetch
2212
- * row filter (`StoreTable.matchesFilters` → `compareValues`) compares each pushed
2213
- * constraint — including LT/LE/GT/GE range bounds — under the column's declared
2214
- * collation, so the access path's collation-cover analysis may keep a
2215
- * collation-matched non-BINARY (NOCASE/RTRIM) PK range/BETWEEN seek instead of
2216
- * declining to a SeqScan + residual (see `classifyConstraintCover` in
2217
- * rule-select-access-path.ts). The seek really does narrow: `StoreTable.scanPKRange`
2218
- * (via `StoreTable.buildPKRangeBounds`) encodes the LT/LE/GT/GE bounds under the
2219
- * same per-column key collations the data keys use and iterates that
2220
- * seek-start/early-termination window. The window is a SUPERSET, so the post-fetch
2221
- * row filter still reproduces the exact collation semantics. (A collation that cannot
2222
- * key at all never reaches a PK column: `StoreTable`'s constructor rejects it at DDL
2223
- * time.) Mirrors the memory module's advertisement.
2224
- *
2225
- * The seek/ordering claims are additionally gated on the key collation being
2226
- * ORDER-PRESERVING — byte order and comparator order must coincide, or the seek would
2227
- * under-fetch and the ordering advertisement would elide a Sort it must not. That gate
2228
- * needs the connection's collation registry, hence `db` is threaded down.
2229
- */
2230
- getBestAccessPlan(db, tableInfo, request) {
2231
- return { ...this.computeBestAccessPlan(db, tableInfo, request), honorsCollatedRangeBounds: true };
2232
- }
2233
- computeBestAccessPlan(db, tableInfo, request) {
2234
- const estimatedRows = request.estimatedRows ?? 1000;
2235
- // Table key collation K (default NOCASE). Any K reaching here has a key normalizer:
2236
- // `StoreTable`'s constructor rejects one that cannot key, and a name we cannot resolve
2237
- // to a table falls back to the built-in NOCASE. `||`, not `??`, so an empty-string
2238
- // `config.collation` reads as the default here exactly as it does in
2239
- // `StoreTable.encodeOptions` — the two must agree or `analyzePKAccess` would decline a
2240
- // window `computeBestAccessPlan` already marked handled.
2241
- const tableKeyCollation = (this.getTable(tableInfo.schemaName, tableInfo.name)?.getConfig().collation || 'NOCASE').toUpperCase();
2242
- // Check for primary key equality constraints. Count DISTINCT pinned PK columns:
2243
- // counting raw '=' filters would read `a = 1 and a = 2` on a composite PK (a, b)
2244
- // as "both PK columns pinned", claim both filters handled, then — with no
2245
- // complete PK equality set to seek — degrade to a sequential scan whose residual
2246
- // has already been discarded, returning the whole table.
2247
- const pkColumns = tableInfo.primaryKeyDefinition.map(pk => pk.index);
2248
- const pinnedPkColumns = new Set(request.filters
2249
- .filter(f => f.columnIndex !== undefined && pkColumns.includes(f.columnIndex) && f.op === '=')
2250
- .map(f => f.columnIndex));
2251
- if (pinnedPkColumns.size === pkColumns.length && pkColumns.length > 0) {
2252
- // Full PK match - point lookup (single row; no monotonic advertisement)
2253
- return AccessPlanBuilder
2254
- .eqMatch(1, 0.1)
2255
- .setHandledFilters(claimFirstPerRole(request.filters, equalityRoles(pkColumns)))
2256
- .setIsSet(true)
2257
- .setIndexName('_primary_')
2258
- .setExplanation('Store primary key lookup')
2259
- .build();
2260
- }
2261
- // Check for range constraints on the leading PK column.
2262
- // The legacy access-path rule (rule-select-access-path.ts) only forwards
2263
- // range bounds for primaryKeyDefinition[0]; ranges on later PK columns
2264
- // are silently dropped if marked handled. So only claim handled=true
2265
- // when the range is on the first PK column.
2266
- //
2267
- // The seek is also declined when the leading PK column's key bytes do not order the
2268
- // way its comparator does (`pkOrderPreservingPrefix === 0`): `StoreTable.analyzePKAccess`
2269
- // declines the byte window under exactly that condition, so claiming the range filters
2270
- // handled here would drop the residual Filter and return the whole table.
2271
- const pkOrderPreservingPrefix = pkOrderPreservingPrefixLength(db, tableInfo, resolvePkKeyCollations(tableInfo.primaryKeyDefinition, tableInfo.columns, tableKeyCollation), tableKeyCollation);
2272
- const firstPkColumn = tableInfo.primaryKeyDefinition[0]?.index;
2273
- const hasLeadingPkRange = firstPkColumn !== undefined
2274
- && pkOrderPreservingPrefix >= 1
2275
- && request.filters.some(f => f.columnIndex === firstPkColumn && RANGE_OPS.includes(f.op));
2276
- if (hasLeadingPkRange) {
2277
- // Range scan on first PK column. Iteration is by PK key order (see
2278
- // StoreTable.scanPKRange), so we can advertise monotonic emission on
2279
- // the leading PK column. The scan seeks to the window start and
2280
- // early-terminates (StoreTable.buildPKRangeBounds derives the encoded
2281
- // bounds), and the leading-PK order guarantee holds throughout.
2282
- const rangeRows = Math.max(1, Math.floor(estimatedRows * 0.3));
2283
- const plan = AccessPlanBuilder
2284
- .rangeScan(rangeRows, 0.2)
2285
- .setHandledFilters(claimFirstPerRole(request.filters, rangeRoles(firstPkColumn)))
2286
- .setIndexName('_primary_')
2287
- .setSeekColumns([firstPkColumn])
2288
- .setExplanation('Store primary key range scan')
2289
- .build();
2290
- return { ...plan, ...this.buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix) };
2291
- }
2292
- // Check for secondary index usage. `StoreTable.query` now implements the
2293
- // secondary-index scan arm (leading-prefix EQ point / leading-column range),
2294
- // so we advertise the index with `indexName` + `seekColumns` and mark the
2295
- // covered filters handled — subject to the collation-safety guard in
2296
- // {@link tryIndexAccessPlan}. A cost-only plan (no seek) is kept as a fallback
2297
- // when no index yields a collation-safe seek, preserving the prior "cheaper
2298
- // cost, filters unhandled, residual retained" behavior.
2299
- const indexes = tableInfo.indexes || [];
2300
- let costOnlyFallback = null;
2301
- for (const index of indexes) {
2302
- if (index.columns.length === 0)
2303
- continue;
2304
- const plan = this.tryIndexAccessPlan(db, tableKeyCollation, tableInfo, request, index, estimatedRows);
2305
- if (!plan)
2306
- continue;
2307
- // A fully-handled seek (indexName + seekColumns set) wins immediately.
2308
- if (plan.seekColumnIndexes && plan.seekColumnIndexes.length > 0)
2309
- return plan;
2310
- // Otherwise remember the first cost-only advertisement as a fallback.
2311
- if (!costOnlyFallback)
2312
- costOnlyFallback = plan;
2313
- }
2314
- // NOTE: a cost-only plan carries no PK-order advertisement even though the store still
2315
- // iterates in PK key order for it (`StoreTable.query` full-scans). The range gate makes
2316
- // this arm fire more often — an index range on a BINARY text column of a default-K table
2317
- // now lands here — so `... where v > 'x' order by <pk>` picks up a Sort it did not need.
2318
- // If that shows up as slow, merge `buildPkOrderingAdvertisement(...)` into this return.
2319
- if (costOnlyFallback)
2320
- return costOnlyFallback;
2321
- // Fallback to full scan. The store iterates rows in PK key order
2322
- // (see StoreTable.query / store.iterate over buildFullScanBounds), so
2323
- // the scan is monotonic on the leading PK column. Advertise that so
2324
- // downstream rules (merge-join, asof-scan) can fire on store-backed
2325
- // tables, matching memory-mode behavior.
2326
- const plan = AccessPlanBuilder
2327
- .fullScan(estimatedRows)
2328
- .setHandledFilters(new Array(request.filters.length).fill(false))
2329
- .setExplanation('Store full table scan')
2330
- .build();
2331
- return { ...plan, ...this.buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix) };
2332
- }
2333
- /**
2334
- * Build the access plan for one secondary index against `request`, or null when
2335
- * the index is not usable for this predicate.
2336
- *
2337
- * Usable = a contiguous leading-prefix EQ on the index columns (an index seek /
2338
- * point), or a LT/LE/GT/GE range on the LEADING index column. These mirror the
2339
- * two windows {@link StoreTable.analyzeIndexAccess} can build.
2340
- *
2341
- * **Collation-safety guard against under-fetch.** The store's index-column
2342
- * window is encoded under the table key collation K, but `matchesFilters`
2343
- * compares under the COLUMN's declared collation. Marking a filter handled drops
2344
- * the residual Filter, so the K-window must be a guaranteed SUPERSET of the
2345
- * qualifying rows. That holds only when K is coarser-or-equal to the column's
2346
- * declared collation. To stay provably safe with minimal logic we mark the
2347
- * covered filters handled — setting `indexName` + `seekColumns` — only when every
2348
- * seek column is non-text, OR its declared collation equals K, OR (K = NOCASE
2349
- * while the column is BINARY, i.e. K strictly coarser). Otherwise we return a
2350
- * cost-only plan (cheaper cost, filters unhandled, residual retained — correct,
2351
- * just not sped up). K itself always keys: `StoreTable`'s constructor rejects a
2352
- * table whose key encoding would need a collation it cannot resolve to a normalizer.
2353
- *
2354
- * NOTE: the coarser-K relaxation is sound for EQUALITY only. A RANGE window equates
2355
- * memcmp of K-normalized bytes with C's comparator order, which a merely coarser K does
2356
- * not give — under K = NOCASE and C = BINARY, 'K' (U+212A) is `> 'z'` yet keys as 'k',
2357
- * before 'z'. So the range arm demands `C === K` *and* K's `orderPreserving` assertion,
2358
- * via the shared {@link keyOrderMatchesCollation}; `StoreTable.analyzeIndexAccess`
2359
- * declines the same windows, so the two decisions cannot disagree. The cost is that a
2360
- * default-K (NOCASE) table with an index on a plain BINARY text column loses its index
2361
- * RANGE seek and falls back to the cost-only plan; EQ seeks are unchanged.
2362
- */
2363
- tryIndexAccessPlan(db, tableKeyCollation, tableInfo, request, index, estimatedRows) {
2364
- // Exclude PARTIAL indexes from access planning: neither the engine nor this
2365
- // module checks that the query's WHERE implies the index predicate, so seeking
2366
- // a partial index for a query it doesn't cover would silently drop the rows the
2367
- // index omits (an out-of-scope predicate returns nothing). Treat partial indexes
2368
- // purely as uniqueness enforcers — the query full-scans + residual instead.
2369
- // Mirrors MemoryTableModule.getAvailableIndexes (`if (idx.predicate) continue`).
2370
- if (index.predicate)
2371
- return null;
2372
- const indexColIndexes = index.columns.map(c => c.index);
2373
- // Contiguous leading-prefix EQ → point/prefix seek.
2374
- const eqCols = [];
2375
- for (const colIdx of indexColIndexes) {
2376
- if (!request.filters.some(f => f.columnIndex === colIdx && f.op === '='))
2377
- break;
2378
- eqCols.push(colIdx);
2379
- }
2380
- const leadingCol = indexColIndexes[0];
2381
- const hasLeadingRange = request.filters.some(f => f.columnIndex === leadingCol && RANGE_OPS.includes(f.op));
2382
- let seekCols;
2383
- let isRange;
2384
- if (eqCols.length > 0) {
2385
- seekCols = eqCols;
2386
- isRange = false;
2387
- }
2388
- else if (hasLeadingRange) {
2389
- seekCols = [leadingCol];
2390
- isRange = true;
2391
- }
2392
- else {
2393
- return null; // this index cannot serve this predicate
2394
- }
2395
- const rows = isRange
2396
- ? Math.max(1, Math.floor(estimatedRows * 0.3))
2397
- : Math.max(1, Math.floor(estimatedRows * 0.1));
2398
- const costOnly = (why) => (isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3))
2399
- .setHandledFilters(new Array(request.filters.length).fill(false))
2400
- .setExplanation(`Store index scan on ${index.name} (${why})`)
2401
- .build();
2402
- // The INDEX column's effective comparison collation C — the index column's own
2403
- // COLLATE, else the table column's declared collation. C, not the table column's
2404
- // declared collation, is what matchesFilters compares an index-scan row under and
2405
- // what the planner matched to drop the residual, so the K-window must be a superset
2406
- // relative to C.
2407
- const K = tableKeyCollation;
2408
- const effectiveCollation = (colIdx) => {
2409
- const indexCol = index.columns.find(c => c.index === colIdx);
2410
- return (indexCol?.collation ?? tableInfo.columns[colIdx]?.collation ?? 'BINARY').toUpperCase();
2411
- };
2412
- // EQUALITY: safe to mark handled iff K is coarser-or-equal to C. Exempt only columns
2413
- // that can NEVER hold text — their key bytes are type-native and collation-independent.
2414
- // A bare `isTextual` test wrongly exempts an `ANY` column (no marker, but it stores
2415
- // text as text), which would seek under K, drop the residual, and lose rows.
2416
- const eqSafeToHandle = (colIdx) => {
2417
- const col = tableInfo.columns[colIdx];
2418
- if (!columnCanHoldText(col))
2419
- return true;
2420
- const C = effectiveCollation(colIdx);
2421
- if (C === K)
2422
- return true; // equal
2423
- if (K === 'NOCASE' && C === 'BINARY')
2424
- return true; // K strictly coarser
2425
- return false;
2426
- };
2427
- // RANGE: coarser is not enough — byte order must BE comparator order. See the doc above.
2428
- const rangeSafeToHandle = (colIdx) => keyOrderMatchesCollation(db, tableInfo.columns[colIdx], K, effectiveCollation(colIdx));
2429
- if (!seekCols.every(isRange ? rangeSafeToHandle : eqSafeToHandle)) {
2430
- return costOnly('cost-only; key collation may under-fetch');
2431
- }
2432
- // Claim positionally — see {@link claimFirstPerRole}.
2433
- const handledFilters = claimFirstPerRole(request.filters, isRange ? rangeRoles(leadingCol) : equalityRoles(eqCols));
2434
- return (isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3))
2435
- .setHandledFilters(handledFilters)
2436
- .setIndexName(index.name)
2437
- .setSeekColumns(seekCols)
2438
- .setExplanation(`Store index ${isRange ? 'range scan' : 'seek'} on ${index.name}`)
2439
- .build();
2440
- }
2441
- /**
2442
- * Compute the PK-ordering advertisement for a scan-style plan. Returns the
2443
- * `providesOrdering` / `monotonicOn` / `supportsAsofRight` fields for a plan
2444
- * whose iteration is driven by the primary-key key order (full scan or PK
2445
- * range scan).
2446
- *
2447
- * `providesOrdering` is set only when it actually matches what the caller
2448
- * needs:
2449
- * - When the request carries `requiredOrdering`, claim it only if the
2450
- * requested keys form a prefix of the PK with matching directions.
2451
- * Claiming PK order against an `ORDER BY <other column>` would cause
2452
- * the absorb-Sort rule to drop the Sort and yield wrong-order rows.
2453
- * - When no `requiredOrdering` is present, advertise the full PK
2454
- * ordering so downstream rules (merge-join, sort elision after a
2455
- * filter) can opportunistically use it.
2456
- *
2457
- * `monotonicOn` reflects the access path itself and is independent of any
2458
- * `requiredOrdering`; it always advertises the leading PK column. Strict
2459
- * monotonicity is claimed iff the PK is single-column — composite PKs can
2460
- * repeat values on the leading column.
2461
- *
2462
- * Returns an empty object when there is no PK (heap-only table) — without a
2463
- * leading key column there is no natural emit order.
2464
- *
2465
- * Every claim here is about the PHYSICAL key-byte order the store iterates in, but the
2466
- * consumers of `providesOrdering` / `monotonicOn` reason in the columns' COLLATION order.
2467
- * `orderPreservingPrefix` (from {@link pkOrderPreservingPrefixLength}) is how many leading
2468
- * PK members those two orders provably agree on: the advertisement is truncated to that
2469
- * prefix, and voided entirely when even the leading member disagrees — otherwise the
2470
- * absorb-Sort rule would elide a Sort and hand the caller byte-ordered rows.
2471
- */
2472
- buildPkOrderingAdvertisement(tableInfo, request, orderPreservingPrefix) {
2473
- const pk = tableInfo.primaryKeyDefinition;
2474
- if (pk.length === 0 || orderPreservingPrefix === 0)
2475
- return {};
2476
- const leading = pk[0];
2477
- const monotonicOn = {
2478
- columnIndex: leading.index,
2479
- direction: leading.desc ? 'desc' : 'asc',
2480
- strict: pk.length === 1,
2481
- };
2482
- const pkOrdering = pk.slice(0, orderPreservingPrefix).map(col => ({
2483
- columnIndex: col.index,
2484
- desc: !!col.desc,
2485
- }));
2486
- // Pick the providesOrdering to advertise based on requiredOrdering.
2487
- const required = request.requiredOrdering;
2488
- let providesOrdering;
2489
- if (required && required.length > 0) {
2490
- // Only claim ordering when the requested keys form a prefix of the
2491
- // PK with matching directions. nullsFirst is intentionally not
2492
- // matched here — if the request specifies an explicit NULLS
2493
- // FIRST/LAST, leave the Sort in place rather than assume the PK
2494
- // scan's natural NULL placement matches.
2495
- if (required.length > pkOrdering.length)
2496
- return { monotonicOn, supportsAsofRight: true };
2497
- for (let i = 0; i < required.length; i++) {
2498
- if (required[i].columnIndex !== pkOrdering[i].columnIndex)
2499
- return { monotonicOn, supportsAsofRight: true };
2500
- if (required[i].desc !== pkOrdering[i].desc)
2501
- return { monotonicOn, supportsAsofRight: true };
2502
- if (required[i].nullsFirst !== undefined)
2503
- return { monotonicOn, supportsAsofRight: true };
2504
- }
2505
- providesOrdering = required;
2506
- }
2507
- else {
2508
- providesOrdering = pkOrdering;
2509
- }
2510
- return {
2511
- providesOrdering,
2512
- orderingIndexName: '_primary_',
2513
- monotonicOn,
2514
- supportsAsofRight: true,
2515
- };
2516
- }
2517
- // --- StoreTableModule interface implementation ---
2518
- /**
2519
- * Get or create a data store for a table.
2520
- */
2521
- async getStore(schemaName, tableName, _config) {
2522
- const tableKey = `${schemaName}.${tableName}`.toLowerCase();
2523
- let store = this.stores.get(tableKey);
2524
- if (!store) {
2525
- store = await this.provider.getStore(schemaName, tableName);
2526
- if (!store) {
2527
- throw new Error(`Provider.getStore returned null/undefined for ${tableKey}`);
2528
- }
2529
- this.stores.set(tableKey, store);
2530
- }
2531
- return store;
2532
- }
2533
- /**
2534
- * Get or create an index store for a table.
2535
- */
2536
- async getIndexStore(schemaName, tableName, indexName) {
2537
- return this.provider.getIndexStore(schemaName, tableName, indexName);
2538
- }
2539
- /**
2540
- * Get or create a stats store for a table.
2541
- */
2542
- async getStatsStore(schemaName, tableName) {
2543
- return this.provider.getStatsStore(schemaName, tableName);
2544
- }
2545
- /**
2546
- * Get (lazily constructing) the SINGLE transaction coordinator shared by every
2547
- * table this module owns — the unit of cross-table atomicity. Every
2548
- * {@link StoreTable} attaches to the same instance, so a transaction touching
2549
- * several of the module's tables commits/rolls back as one all-or-nothing
2550
- * batch.
2551
- *
2552
- * Synchronously constructible: ops are addressed by explicit store handle, so
2553
- * the coordinator never needs to open a store to be created. This lets callers
2554
- * that must stay synchronous (e.g. a `BackingHost.connect()`) obtain a working
2555
- * coordinator before any table's store has been opened.
2556
- */
2557
- getCoordinator() {
2558
- if (!this.moduleCoordinator) {
2559
- this.moduleCoordinator = new TransactionCoordinator(this.eventEmitter,
2560
- // Re-evaluated per commit (see TransactionCoordinator.atomicBatchFactory)
2561
- // so a provider that gains/loses the capability is always honored.
2562
- () => this.provider.beginAtomicBatch?.());
2563
- }
2564
- return this.moduleCoordinator;
2565
- }
2566
- /**
2567
- * Build the catalog entry for a table: its CREATE TABLE DDL, one
2568
- * `CREATE [UNIQUE] INDEX` statement per persistable secondary index, and one
2569
- * `alter index … set tags (…)` statement per exposed implicit index carrying
2570
- * user tags, newline-joined into a single multi-statement bundle keyed by
2571
- * `{schema}.{table}`.
2572
- *
2573
- * Bundling the indexes into the table's own entry means every existing
2574
- * re-persist path — `saveTableDDL` (each `alterTable` arm, `renameTable`) and
2575
- * the `table_modified` listener (`persistCatalogIfChanged`) — carries the
2576
- * indexes along for free, and `removeTableDDL` drops them with the table. The
2577
- * bundle is consumed on reopen by `rehydrateCatalog` → `importCatalog`, whose
2578
- * `parser.parseAll` splits it AST-by-AST (never on `\n`) and imports each
2579
- * statement in document order, so the table registers before its indexes.
2580
- *
2581
- * Both the table DDL and the index DDL are emitted without a `db` arg, keeping
2582
- * the persistence-safe fully-qualified form. Hidden implicit covering indexes
2583
- * (the auto-built BTree backing a declared UNIQUE constraint) are excluded —
2584
- * they are a backing detail that round-trips via the table's UNIQUE constraint,
2585
- * not as a standalone CREATE INDEX. For store tables `buildTableSchemaFromAST`
2586
- * synthesizes none of those, so in practice every index is included; the guard
2587
- * is defensive. A `CREATE UNIQUE INDEX`'s derived UNIQUE constraint is already
2588
- * excluded from the table DDL by the generator, so it round-trips solely via
2589
- * its own `CREATE UNIQUE INDEX` line — no doubling.
2590
- *
2591
- * An *exposed* implicit index (a non-derived UNIQUE constraint tagged
2592
- * `quereus.expose_implicit_index = true`, never materialized in store mode)
2593
- * must NOT get a synthetic `CREATE INDEX` line — re-import would materialize a
2594
- * real `IndexSchema` and change the store-mode shape. Its user tags
2595
- * (`UniqueConstraintSchema.exposedIndexTags`) instead ride a trailing
2596
- * `alter index … set tags` line, which `importDDL` re-applies silently after
2597
- * the CREATE TABLE in the same entry has registered the constraint. Empty tag
2598
- * records emit no line, and `exposedImplicitIndexes` returns `[]` for
2599
- * memory-mode tables (name materialized) and orders descriptors by the
2600
- * `uniqueConstraints` array, so the bundle stays byte-deterministic — which
2601
- * the compare-write in `persistCatalogIfChanged` relies on.
2602
- */
2603
- buildCatalogEntry(tableSchema) {
2604
- // `tableSchema` is always the engine-facing schema (no `_uc_*`; the store keeps the
2605
- // materialized enforcement copy internal), so hidden implicit indexes reach here
2606
- // only through the `uniqueConstraints`-driven `isHiddenImplicitIndex` /
2607
- // `exposedImplicitIndexes` paths — never as a materialized `CREATE INDEX`.
2608
- const parts = [generateTableDDL(tableSchema)];
2609
- for (const idx of tableSchema.indexes ?? []) {
2610
- if (isHiddenImplicitIndex(tableSchema, idx.name))
2611
- continue;
2612
- parts.push(generateIndexDDL(idx, tableSchema));
2613
- }
2614
- for (const desc of exposedImplicitIndexes(tableSchema)) {
2615
- if (desc.tags && Object.keys(desc.tags).length > 0) {
2616
- parts.push(generateIndexTagsDDL(tableSchema.schemaName, desc.name, desc.tags));
2617
- }
2618
- }
2619
- return parts.join('\n');
2620
- }
2621
- /**
2622
- * Encode a bundle of persisted schema text (DDL) for the catalog store.
2623
- * The guard is {@link assertPersistableDdlText} — see it for why the FULL text
2624
- * is checked, not just the object's name.
2625
- */
2626
- encodeCatalogDDL(ddl) {
2627
- assertPersistableDdlText(ddl);
2628
- return new TextEncoder().encode(ddl);
2629
- }
2630
- /**
2631
- * Save table DDL (bundled with its secondary index DDL) to the catalog store.
2632
- */
2633
- async saveTableDDL(tableSchema) {
2634
- const ddl = this.buildCatalogEntry(tableSchema);
2635
- const catalogKey = buildCatalogKey(tableSchema.schemaName, tableSchema.name);
2636
- const encodedDDL = this.encodeCatalogDDL(ddl);
2637
- const catalogStore = await this.provider.getCatalogStore();
2638
- await catalogStore.put(catalogKey, encodedDDL);
2639
- }
2640
- /**
2641
- * Load all DDL **values** from the catalog store (keys discarded).
2642
- * Used to restore persisted tables on startup and by tests asserting persisted DDL.
2643
- * Note: this returns table, view, AND materialized-view entries intermixed —
2644
- * {@link rehydrateCatalog} uses {@link loadCatalogEntries} (keys retained) to
2645
- * classify them. Meta entries (e.g. the clean-shutdown marker) are not DDL
2646
- * and are filtered out.
2647
- */
2648
- async loadAllDDL() {
2649
- const catalogStore = await this.provider.getCatalogStore();
2650
- const bounds = buildCatalogScanBounds();
2651
- const decoder = new TextDecoder();
2652
- const ddlStatements = [];
2653
- for await (const entry of catalogStore.iterate(bounds)) {
2654
- if (classifyCatalogKey(entry.key) === 'meta')
2655
- continue;
2656
- const ddl = decoder.decode(entry.value);
2657
- ddlStatements.push(ddl);
2658
- }
2659
- return ddlStatements;
2660
- }
2661
- /**
2662
- * Load every catalog entry as `{ key, ddl }`. Unlike {@link loadAllDDL} (values
2663
- * only) this retains the key so {@link rehydrateCatalog} can classify each entry
2664
- * (table / view / materialized view) by its reserved key prefix.
2665
- */
2666
- async loadCatalogEntries() {
2667
- const catalogStore = await this.provider.getCatalogStore();
2668
- const decoder = new TextDecoder();
2669
- const entries = [];
2670
- for await (const entry of catalogStore.iterate(buildCatalogScanBounds())) {
2671
- entries.push({ key: entry.key, ddl: decoder.decode(entry.value) });
2672
- }
2673
- return entries;
2674
- }
2675
- /**
2676
- * Rehydrate the persisted catalog into the in-memory schema manager, in
2677
- * dependency order.
2678
- *
2679
- * Establishes the engine schema-change subscription up front (so a reopened DB
2680
- * persists subsequent DDL even when its first post-reopen statement is a view/MV,
2681
- * which never routes through a module hook — all the lazy subscription points are
2682
- * table hooks). Then loads every catalog entry once, classifies each by its key
2683
- * prefix into {tables, views, materialized views}, and imports in three phases:
2684
- *
2685
- * 1. **Tables** — `importCatalog` (connect to existing storage; refresh connected
2686
- * `StoreTable` schemas).
2687
- * 2. **Views** — `importCatalog` (engine silent-register; body validation deferred
2688
- * to query time, so order among views — and view-over-MV / view-over-view —
2689
- * does not matter, and no schema-change event fires → phase 2 writes nothing).
2690
- * 3. **Materialized views** — `importCatalog` per entry (engine re-materialize:
2691
- * rebuilds the memory backing from current source data, re-registers row-time
2692
- * maintenance, re-runs the eligibility gate — the same core the create emitter
2693
- * uses, but silent: no `materialized_view_added` fires, so phase 3 writes
2694
- * nothing back to the catalog). A store-hosted backing that phase 1 already
2695
- * rehydrated is **adopted without the refill** when the engine's adopt gates
2696
- * pass — see the clean-shutdown marker below.
2697
- *
2698
- * **Clean-shutdown marker.** Before anything loads, the reserved
2699
- * `\x00meta\x00clean_shutdown` catalog entry (written by {@link closeAll} after
2700
- * every batch flushed) is consumed: parsed into `{ trusted, staleAtClose }`, then
2701
- * **deleted immediately** — single-use, so a crash later in this session (or a
2702
- * second rehydrate without an intervening clean close) is detected at the next open
2703
- * and every adopt falls back to the always-correct drop+refill, self-healing any
2704
- * crash-window divergence (coordinated commit is not 2PC across stores). The marker
2705
- * payload is the JSON set of MVs that were **stale-at-close** (row-time maintenance
2706
- * detached, so the durable backing may be behind); phase 3 withholds trust per-entry
2707
- * for those — `trustBackings: trusted && !staleAtClose.has(name)` — so a
2708
- * stale-at-close MV refills (recomputing content and re-arming maintenance) while
2709
- * every live-at-close MV keeps the fast path. The one shared `adoptedBackings` set
2710
- * composes across fixpoint rounds (an upstream MV adopted in round 1 enables its
2711
- * dependent in round 2, while a refilled — or stale-at-close — upstream is never
2712
- * added to it, forcing dependents to refill).
2713
- *
2714
- * **MV-over-MV ordering** is handled by a fixpoint retry rather than a static topo
2715
- * sort: an MV's resolved `sourceTables` are computed at import time, not serialized
2716
- * in the DDL, so they are unavailable before import. Each round passes the names of
2717
- * every OTHER still-pending MV entry as `pendingDerivations`; the engine defers any
2718
- * entry whose body reads one (its source already pre-exists as a phase-1 plain
2719
- * table, so the body would otherwise plan against content the upstream's own import
2720
- * may be about to replace). The loop repeats while any MV makes progress — robust to
2721
- * arbitrary nesting depth. A genuinely unbuildable MV — a missing (e.g. memory)
2722
- * source, or an unresolvable cycle — makes no progress in a round and is recorded in
2723
- * `errors`.
2724
- *
2725
- * Per-entry errors in any phase are collected (not fatal) so one bad object does not
2726
- * abort the rest.
2727
- *
2728
- * Call after `db.registerModule()` (and `db.setDefaultVtabName()` if DDL may lack a
2729
- * USING clause).
2730
- */
2731
- async rehydrateCatalog(db) {
2732
- // Subscribe up front: a reopened DB whose first post-reopen DDL is a view/MV
2733
- // would otherwise miss the event (the lazy `ensureSchemaSubscription` points are
2734
- // all table hooks). Done even for an empty catalog. (Documented gap: a brand-new
2735
- // DB — never rehydrated — whose very first DDL is a view still relies on a prior
2736
- // store-table create/connect to establish the subscription.)
2737
- this.ensureSchemaSubscription(db);
2738
- // Capability gate (method presence — matching the coordinator's own gate): an
2739
- // atomic-commit provider commits a source write and its same-module backing in
2740
- // one all-or-nothing batch, so a crash can no longer tear them apart (the
2741
- // crash-divergence window gate 5 historically guarded). In that domain, gate 4
2742
- // alone governs same-module backings and a non-stale backing adopts after a crash
2743
- // too — the LOGICAL-staleness window is closed instead by the durable stale-MV
2744
- // set, which (unlike the clean-shutdown marker) survives a crash. A non-atomic
2745
- // provider never writes the set (see {@link atomicProvider}), so skip the read.
2746
- const atomic = this.atomicProvider;
2747
- const durableStale = atomic ? await this.readDurableStaleMvSet() : { present: false };
2748
- // Consume the clean-shutdown marker FIRST (before the catalog scan): in the
2749
- // non-atomic domain its presence is the adopt trust basis for this rehydration
2750
- // only, and deleting it immediately makes it single-use. Its payload names the MVs
2751
- // that were stale-at-close. Always consumed for single-use hygiene even in the
2752
- // atomic branch (where its trust bit is ignored in favor of the durable set).
2753
- const { trusted, staleAtClose } = await this.consumeCleanShutdownMarker();
2754
- // Per-entry adopt trust basis, capability-aware:
2755
- // - Atomic domain WITH a durable stale-set on disk → gate 4 alone governs; the
2756
- // marker is NOT required, so a non-stale backing adopts even after a crash. A
2757
- // present-but-unparseable stale-set (`stale === null`) refills everything.
2758
- // - Otherwise (non-atomic provider, OR atomic but no stale-set yet — the upgrade
2759
- // path) → today's marker path, byte-for-byte.
2760
- const trustBacking = (name) => atomic && durableStale.present
2761
- ? durableStale.stale !== null && !durableStale.stale.has(name)
2762
- : trusted && !staleAtClose.has(name);
2763
- const entries = await this.loadCatalogEntries();
2764
- const result = { tables: [], indexes: [], views: [], materializedViews: [], errors: [] };
2765
- if (entries.length === 0)
2766
- return result;
2767
- const recordError = (ddl, e) => {
2768
- const error = e instanceof Error ? e : new Error(String(e));
2769
- console.warn(`[StoreModule] Failed to rehydrate DDL entry, skipping: ${error.message}\n DDL: ${ddl.substring(0, 120)}`);
2770
- result.errors.push({ ddl, error });
2771
- };
2772
- // Classify every loaded entry by key prefix. The full-range catalog scan returns
2773
- // table, view, and MV entries intermixed; each must reach the correct phase — a
2774
- // view/MV entry fed to the table-phase importCatalog would fail-loud or mis-handle.
2775
- const tableDDLs = [];
2776
- const viewDDLs = [];
2777
- // MV entries retain their qualified `schema.mv` name (derived from the catalog
2778
- // key) so phase 3 can withhold the adopt fast path per-entry for any MV that
2779
- // was stale-at-close.
2780
- const mvEntries = [];
2781
- for (const { key, ddl } of entries) {
2782
- switch (classifyCatalogKey(key)) {
2783
- case 'view': {
2784
- viewDDLs.push(ddl);
2785
- break;
2786
- }
2787
- case 'materializedView': {
2788
- mvEntries.push({ name: parseMaterializedViewCatalogKey(key), ddl });
2789
- break;
2790
- }
2791
- // Meta entries are store-internal, never DDL. (The marker itself was
2792
- // already consumed above; this guards any future meta key.)
2793
- case 'meta': {
2794
- break;
2795
- }
2796
- default: {
2797
- tableDDLs.push(ddl);
2798
- break;
2799
- }
2800
- }
2801
- }
2802
- // Phase 1 — tables. Per-entry import isolates a corrupt entry so the rest load.
2803
- for (const ddl of tableDDLs) {
2804
- try {
2805
- const imported = await db.schemaManager.importCatalog([ddl]);
2806
- result.tables.push(...imported.tables);
2807
- result.indexes.push(...imported.indexes);
2808
- }
2809
- catch (e) {
2810
- recordError(ddl, e);
2811
- }
2812
- }
2813
- // Phase 2 — views (silent register; deferred body validation → order-independent).
2814
- for (const ddl of viewDDLs) {
2815
- try {
2816
- const imported = await db.schemaManager.importCatalog([ddl]);
2817
- result.views.push(...imported.views);
2818
- }
2819
- catch (e) {
2820
- recordError(ddl, e);
2821
- }
2822
- }
2823
- // Phase 3 — materialized views, dependency-ordered via fixpoint retry (see
2824
- // docstring). One shared adopt ledger across all rounds: adopted upstream
2825
- // backings unlock their dependents' adoption in later rounds.
2826
- const adoptedBackings = new Set();
2827
- let pending = mvEntries;
2828
- while (pending.length > 0) {
2829
- const failed = [];
2830
- let progressed = false;
2831
- for (const entry of pending) {
2832
- try {
2833
- // Ordering gate (unified model): a dependent's source may already
2834
- // pre-exist as the upstream's phase-1 *plain* table, so its body
2835
- // PLANS before the upstream's own MV entry has imported. Pass the
2836
- // names of every OTHER still-pending MV entry; the engine defers
2837
- // (throws → retried next round) any entry whose body reads one.
2838
- const pendingDerivations = new Set(pending.filter(p => p !== entry).map(p => p.name));
2839
- // Trust this backing only when the per-entry capability-aware basis
2840
- // (`trustBacking`) holds: in the atomic domain a non-stale backing is
2841
- // trusted (even after a crash); otherwise a clean shutdown AND not
2842
- // stale-at-close. A withheld MV refills — recomputing content and
2843
- // re-arming maintenance (clearing `stale`) — and is never added to
2844
- // `adoptedBackings`, so the ledger gate forces its MV-over-MV dependents
2845
- // to refill too.
2846
- const imported = await db.schemaManager.importCatalog([entry.ddl], {
2847
- trustBackings: trustBacking(entry.name),
2848
- adoptedBackings,
2849
- pendingDerivations,
2850
- });
2851
- result.materializedViews.push(...imported.materializedViews);
2852
- progressed = true;
2853
- }
2854
- catch (e) {
2855
- failed.push({ entry, error: e });
2856
- }
2857
- }
2858
- if (!progressed) {
2859
- // No MV built this round → the remaining failures are genuine (missing
2860
- // source, ineligible body, unresolvable cycle). Record them and stop.
2861
- for (const f of failed)
2862
- recordError(f.entry.ddl, f.error);
2863
- break;
2864
- }
2865
- pending = failed.map(f => f.entry);
2866
- }
2867
- // Refresh each connected StoreTable from the now-current registry. During
2868
- // import, `importTable` connects a StoreTable holding the table-only schema,
2869
- // then `importIndex` appends the index (and its derived UNIQUE constraint) to
2870
- // the SchemaManager's registered schema but NOT to that live StoreTable
2871
- // instance — `importCatalog` deliberately skips module hooks to stay generic,
2872
- // so the store module reconciles here. Without this, DML on a rehydrated table
2873
- // would not maintain its indexes and the derived UNIQUE would not enforce.
2874
- for (const table of this.tables.values()) {
2875
- const current = table.getSchema();
2876
- const fresh = db.schemaManager.getTable(current.schemaName, current.name);
2877
- if (fresh)
2878
- table.updateSchema(fresh);
2879
- }
2880
- // Recompute the durable stale-MV set from the now-current flags and compare-write
2881
- // it: refilled MVs cleared `stale`, adopted MVs were never stale, so the on-disk
2882
- // entry (which a crash may have left naming an MV this rehydrate just refilled)
2883
- // now reflects post-rehydrate truth — preventing a wasteful re-refill at the next
2884
- // open. Phase 3's `importCatalog` is silent (no schema-change events fire), so the
2885
- // listener never ran this rehydration; this explicit tail write establishes the
2886
- // session baseline (`lastPersistedStaleMvs`). It rides `persistQueue`, drained by
2887
- // the next `closeAll`/`whenCatalogPersisted`. (An optimization, not soundness: a
2888
- // crash before it lands leaves a stale-set that only over-names → a sound refill.)
2889
- this.persistStaleMvSetIfChanged();
2890
- return result;
2891
- }
2892
- /**
2893
- * Consume the clean-shutdown marker, deleting it in the same breath (single-use).
2894
- *
2895
- * Returns `{ trusted, staleAtClose }`:
2896
- * - `trusted` — the marker was present AND its payload parsed as a string array.
2897
- * Absence (a fresh store, a crash, or a prior rehydrate without an intervening
2898
- * {@link closeAll}) yields `false`, so every adopt this session falls back to
2899
- * refill.
2900
- * - `staleAtClose` — the qualified lowercased `schema.mv` names that were
2901
- * stale-at-close (written by {@link closeAll}); those MVs refill even under trust.
2902
- *
2903
- * Conservative parse: any unparseable / wrong-shape payload (including a legacy
2904
- * bare `'1'`, which parses to a number, not an array) degrades to
2905
- * `{ trusted: false, staleAtClose: ∅ }` — refill everything — rather than
2906
- * trust-everything. The marker is deleted regardless of parse outcome.
2907
- */
2908
- async consumeCleanShutdownMarker() {
2909
- const catalogStore = await this.provider.getCatalogStore();
2910
- const markerKey = buildMetaCatalogKey(CLEAN_SHUTDOWN_META_NAME);
2911
- const raw = await catalogStore.get(markerKey);
2912
- if (raw === undefined)
2913
- return { trusted: false, staleAtClose: new Set() };
2914
- // Single-use AND durable-before-session-writes: forcing this delete to stable
2915
- // storage before any of the session's (independently flushed, different-store)
2916
- // data writes can land closes the power-loss window where a persisted data write
2917
- // outlives a lost marker-delete and resurrects a consumed marker. (Backends
2918
- // without a durability knob no-op the hint — losing it is conservative there, and
2919
- // memory has no crash.) See docs/mv-backing-host.md § Cross-module atomicity.
2920
- await catalogStore.delete(markerKey, { sync: true }); // single-use, regardless of parse outcome
2921
- try {
2922
- const parsed = JSON.parse(new TextDecoder().decode(raw));
2923
- if (!Array.isArray(parsed) || !parsed.every((s) => typeof s === 'string')) {
2924
- console.warn('[StoreModule] clean-shutdown marker payload is not a string array; refilling all backings.');
2925
- return { trusted: false, staleAtClose: new Set() };
2926
- }
2927
- return { trusted: true, staleAtClose: new Set(parsed) };
2928
- }
2929
- catch (e) {
2930
- console.warn(`[StoreModule] clean-shutdown marker payload did not parse as JSON; refilling all backings: ${e instanceof Error ? e.message : String(e)}`);
2931
- return { trusted: false, staleAtClose: new Set() };
2932
- }
2933
- }
2934
- /**
2935
- * Remove DDL from the catalog store when a table is dropped.
2936
- */
2937
- async removeTableDDL(schemaName, tableName) {
2938
- const catalogKey = buildCatalogKey(schemaName, tableName);
2939
- const catalogStore = await this.provider.getCatalogStore();
2940
- await catalogStore.delete(catalogKey);
2941
- }
2942
- /**
2943
- * Persist a plain view's catalog entry (DDL via `generateViewDDL`), keyed by its
2944
- * reserved view prefix. Compare-write (skip identical) — see
2945
- * {@link persistObjectCatalogEntryIfChanged}.
2946
- */
2947
- async saveViewDDL(view) {
2948
- const { key, ddl } = viewCatalogEntry(view);
2949
- await this.persistObjectCatalogEntryIfChanged(key, ddl);
2950
- }
2951
- /** Remove a plain view's catalog entry (on DROP VIEW). */
2952
- async removeViewDDL(schemaName, viewName) {
2953
- const catalogStore = await this.provider.getCatalogStore();
2954
- await catalogStore.delete(buildViewCatalogKey(schemaName, viewName));
2955
- }
2956
- /**
2957
- * Persist a materialized view's catalog entry (DDL via `generateMaintainedTableDDL`
2958
- * over the maintained table — the unified record), keyed by its reserved MV
2959
- * prefix. Compare-write (skip identical) — see
2960
- * {@link persistObjectCatalogEntryIfChanged}.
2961
- */
2962
- async saveMaterializedViewDDL(mv) {
2963
- const entry = maintainedViewCatalogEntry(mv);
2964
- // Callers only reach here with a maintained table; the guard keeps the shared
2965
- // helper's `undefined` arm honest rather than asserting non-null.
2966
- if (!entry)
2967
- return;
2968
- await this.persistObjectCatalogEntryIfChanged(entry.key, entry.ddl);
2969
- }
2970
- /** Remove a materialized view's catalog entry (on DROP MATERIALIZED VIEW). */
2971
- async removeMaterializedViewDDL(schemaName, mvName) {
2972
- const catalogStore = await this.provider.getCatalogStore();
2973
- await catalogStore.delete(buildMaterializedViewCatalogKey(schemaName, mvName));
2974
- }
2975
- /**
2976
- * Pre-flight veto (see `VirtualTableModule.assertCatalogObjectPersistable`): refuse a
2977
- * view / materialized view whose catalog entry this module could not durably write.
2978
- *
2979
- * It runs exactly the derivation the write path runs — {@link viewCatalogEntry} /
2980
- * {@link maintainedViewCatalogEntry} build the key (rejecting an unencodable
2981
- * identifier) and {@link assertPersistableDdlText} checks the generated DDL text —
2982
- * so the veto and the write cannot disagree about what is persistable. It is the only
2983
- * synchronous path a rejection can travel: the actual save is chained onto
2984
- * `persistQueue` behind a `SchemaChangeNotifier` listener, and both layers swallow.
2985
- *
2986
- * Gated on `subscribedDb === db`: this module persists a view/MV only while subscribed
2987
- * to that `Database`'s change notifier ({@link ensureSchemaSubscription}, driven by the
2988
- * first `create`/`connect`/`alterTable`/`rehydrateCatalog`). A module registered but
2989
- * never handed a `db` writes nothing, so vetoing there would reject a definition that
2990
- * loses nothing. The gate becomes removable if persistence is ever made unconditional
2991
- * (`bug-store-untouched-table-and-early-view-never-persisted`).
2992
- */
2993
- // NOTE: regenerates the object's full DDL text on every CREATE VIEW / CREATE MATERIALIZED
2994
- // VIEW / view-or-MV SET TAGS, and the persist that follows regenerates it again. DDL is
2995
- // rare and the text is small, so the duplicate render is not worth caching today; if a
2996
- // schema-heavy workload (a large `apply schema` deploying many views) ever shows up hot
2997
- // here, thread the already-built CatalogEntry from the veto through to the write.
2998
- assertCatalogObjectPersistable(db, kind, object) {
2999
- if (this.subscribedDb !== db)
3000
- return;
3001
- const entry = kind === 'view'
3002
- ? viewCatalogEntry(object)
3003
- : maintainedViewCatalogEntry(object);
3004
- if (entry)
3005
- assertPersistableDdlText(entry.ddl);
3006
- }
3007
- /**
3008
- * Compare-write a view/MV catalog entry: write only when the entry is absent or its
3009
- * DDL differs from `newDDL` (skip identical). Unlike the table path's
3010
- * {@link persistCatalogIfChanged} there is **no** absent→skip self-filter — a view/MV
3011
- * belongs to this db's catalog unconditionally (one module instance serves one
3012
- * Database). Skipping identical writes makes any event whose regenerated DDL is
3013
- * unchanged (e.g. a `materialized_view_refreshed`) a no-op, so a second consecutive
3014
- * reopen yields identical catalog bytes. (Rehydration itself fires no view/MV events
3015
- * at all — `importCatalog` is silent.)
3016
- */
3017
- async persistObjectCatalogEntryIfChanged(key, newDDL) {
3018
- const catalogStore = await this.provider.getCatalogStore();
3019
- const existing = await catalogStore.get(key);
3020
- if (existing !== undefined && new TextDecoder().decode(existing) === newDDL)
3021
- return;
3022
- await catalogStore.put(key, this.encodeCatalogDDL(newDDL));
3023
- }
3024
- /**
3025
- * Parse module configuration from vtab args.
3026
- */
3027
- parseConfig(args) {
3028
- return {
3029
- collation: args?.collation || 'NOCASE',
3030
- // Index-build batch budget; malformed / non-positive clamps to the default
3031
- // so a bad arg can never disable the bounded-memory flush (see resolveMaxBatchBytes).
3032
- maxBatchBytes: resolveMaxBatchBytes(args?.max_batch_bytes),
3033
- };
3034
- }
3035
- // --- Engine schema-change subscription (catalog-only tag persistence) ---
3036
- /**
3037
- * Subscribe (once) to the engine's `SchemaChangeNotifier` so catalog-only
3038
- * mutations that bypass `module.alterTable` — notably `ALTER … SET TAGS` and the
3039
- * programmatic `setTableTags`/`setColumnTags`/`setConstraintTags` — still re-persist
3040
- * the table's catalog DDL. Called lazily from the first `create`/`connect`/
3041
- * `alterTable` hook that hands us a `db`.
3042
- *
3043
- * One `StoreModule` instance is assumed to serve one `Database`. A later hook
3044
- * carrying a *different* `db` keeps the existing subscription (multi-database
3045
- * sharing of a single module instance is out of scope) and logs.
3046
- */
3047
- ensureSchemaSubscription(db) {
3048
- if (this.schemaListenerUnsub) {
3049
- if (this.subscribedDb && this.subscribedDb !== db) {
3050
- console.warn('[StoreModule] ensureSchemaSubscription called with a different Database; '
3051
- + 'keeping the existing subscription (one module instance is assumed to serve one Database).');
3052
- }
3053
- return;
3054
- }
3055
- this.subscribedDb = db;
3056
- // A fresh subscription has no on-disk stale-set baseline yet (this module never
3057
- // wrote one this session); reset so the first relevant event — or the rehydrate
3058
- // tail — re-establishes it rather than comparing against a prior subscription's.
3059
- this.lastPersistedStaleMvs = undefined;
3060
- this.schemaListenerUnsub = db.schemaManager.getChangeNotifier().addListener(this.onEngineSchemaChange);
3061
- }
3062
- /**
3063
- * Engine schema-change listener. Persists the catalog incrementally for the events
3064
- * that bypass `module.alterTable` / `module.destroy`:
3065
- *
3066
- * - `table_modified` — every catalog-only tag swap (and the redundant follow-up a
3067
- * structural ALTER fires). Keeps a connected `StoreTable`'s cached schema consistent
3068
- * (SET TAGS does not call `updateSchema`) then read-compare-writes the table bundle.
3069
- * - `view_added` / `view_modified` / `view_removed` — plain `CREATE`/`ALTER … SET TAGS`/
3070
- * `DROP VIEW` (the engine fires these from the runtime emitters).
3071
- * - `materialized_view_added` / `_modified` / `_refreshed` / `_removed` — MV lifecycle.
3072
- * Like `table_modified`, the `_added`/`_modified`/`_refreshed` arms also synchronously
3073
- * refresh the connected `StoreTable`'s cached schema so a tag change (e.g.
3074
- * `quereus.sync.replicate`) takes effect immediately without reopen.
3075
- *
3076
- * Unlike the table path there is **no** catalog-absent self-filter for view/MV
3077
- * add/remove: one `StoreModule` instance serves one `Database`, so that database's
3078
- * views/MVs belong in its catalog unconditionally. A MEMORY-hosted maintained table
3079
- * fires `table_added`/`table_removed`/`table_modified` like any table; those stay
3080
- * ignored (`table_added`/`table_removed` fall through; its `table_modified` is
3081
- * catalog-absent → skipped), so only the MV entry persists for it. A STORE-hosted
3082
- * maintained table additionally persists its own table bundle through the ordinary
3083
- * store-table machinery (which phase-1 rehydrate connects for the adopt fast path).
3084
- *
3085
- * Synchronous by contract (`notifyChange` does not await listeners); every async write
3086
- * rides `persistQueue`, drained by `closeAll`/`whenCatalogPersisted`.
3087
- *
3088
- * After dispatching the event's own catalog persistence, the listener recomputes the
3089
- * durable stale-MV set and compare-writes it (see {@link persistStaleMvSetIfChanged}).
3090
- * Because the engine's MV manager subscribes to the same notifier in the `Database`
3091
- * constructor — before this lazy subscription — its listener runs first, so the
3092
- * `derivation.stale` flags are already current when this recompute reads them.
3093
- */
3094
- onEngineSchemaChange = (event) => {
3095
- this.dispatchSchemaChange(event);
3096
- // Every staleness SET transition is bracketed by an event observed here (a source
3097
- // `table_modified`/`table_removed`, or the synthetic backing-invalidation
3098
- // `table_modified` for an MV-over-MV cascade), and every CLEAR but the no-event
3099
- // rename-restore fires one too — so recompute on each event keeps the durable set
3100
- // current. Enqueued on `persistQueue` AFTER the dispatch above, so the `sync` lands
3101
- // after the event's own source-DDL write is queued (the durability ordering the
3102
- // adopt soundness argument relies on — see `docs/mv-backing-host.md` § Cross-module atomicity).
3103
- this.persistStaleMvSetIfChanged();
3104
- };
3105
- /** Dispatch a single engine schema-change event to its catalog-persistence arm. */
3106
- dispatchSchemaChange(event) {
3107
- switch (event.type) {
3108
- case 'table_modified': {
3109
- // SET TAGS does not call `table.updateSchema`, so a connected instance's cached
3110
- // schema would otherwise go stale (and a later lazy `saveTableDDL` could re-write
3111
- // tag-less DDL). Persistence below always reads `newObject`, never this cache.
3112
- const tableKey = `${event.schemaName}.${event.objectName}`.toLowerCase();
3113
- const connected = this.tables.get(tableKey);
3114
- if (connected)
3115
- connected.updateSchema(event.newObject);
3116
- const key = buildCatalogKey(event.schemaName, event.objectName);
3117
- const newObject = event.newObject;
3118
- this.enqueuePersist(() => this.persistCatalogIfChanged(key, newObject));
3119
- return;
3120
- }
3121
- case 'view_added':
3122
- case 'view_modified': {
3123
- const view = event.newObject;
3124
- this.enqueuePersist(() => this.saveViewDDL(view));
3125
- return;
3126
- }
3127
- case 'view_removed': {
3128
- const { schemaName, objectName } = event;
3129
- this.enqueuePersist(() => this.removeViewDDL(schemaName, objectName));
3130
- return;
3131
- }
3132
- case 'materialized_view_added':
3133
- case 'materialized_view_modified':
3134
- // Unified model: the payload is the maintained table itself.
3135
- this.refreshConnectedMaterializedView(event.schemaName, event.objectName, event.newObject);
3136
- return;
3137
- case 'materialized_view_refreshed':
3138
- // DDL is usually unchanged by a REFRESH (body/tags identical) → compare-skip,
3139
- // but re-read tags in case they were updated alongside the refresh.
3140
- this.refreshConnectedMaterializedView(event.schemaName, event.objectName, event.object);
3141
- return;
3142
- case 'materialized_view_removed': {
3143
- const { schemaName, objectName } = event;
3144
- // DROP MAINTAINED detaches catalog-only: the engine has already swapped the
3145
- // catalog entry to a plain (derivation-less) schema before firing this event,
3146
- // but a connected `StoreTable` still caches the maintained schema. The store's
3147
- // `alterTable` reads that cache (`getSchema`), so a following structural ALTER
3148
- // would spread the stale `derivation` onto the rebuilt schema and re-register
3149
- // the table as a materialized view (rejecting the next ALTER). Refresh the cache
3150
- // to the now-plain catalog entry. When the entry is gone entirely (DROP TABLE /
3151
- // DROP MATERIALIZED VIEW), there is nothing to refresh — `destroy` retires it.
3152
- const plain = this.subscribedDb?.schemaManager.getTable(schemaName, objectName);
3153
- if (plain && !isMaintainedTable(plain)) {
3154
- const connected = this.tables.get(`${schemaName}.${objectName}`.toLowerCase());
3155
- if (connected)
3156
- connected.updateSchema(plain);
3157
- }
3158
- this.enqueuePersist(() => this.removeMaterializedViewDDL(schemaName, objectName));
3159
- return;
3160
- }
3161
- default:
3162
- return;
3163
- }
3164
- }
3165
- /**
3166
- * The qualified lowercased `schema.mv` names of every maintained table currently
3167
- * marked `derivation.stale` — an MV whose row-time maintenance detached mid-session
3168
- * (a body-relevant `table_modified`/`table_removed` on a source, or a cascade
3169
- * backing-invalidation), so its durable backing may be behind. The single source of
3170
- * truth for both the clean-shutdown marker payload and the durable stale-MV set.
3171
- *
3172
- * No subscribed db ⇒ the empty set: every path that can mark an MV stale requires a
3173
- * session in which this module observed the db (a store source create/connect or
3174
- * `rehydrateCatalog`, both of which subscribe), so a session without `subscribedDb`
3175
- * never detached any persisted MV's maintenance. Memory-backed MVs that appear here
3176
- * are harmless — their catalog entries always refill (no phase-1 pre-existing
3177
- * backing), so withholding trust from them is a no-op.
3178
- */
3179
- computeStaleMvSet() {
3180
- return this.subscribedDb
3181
- ? this.subscribedDb.schemaManager.getAllMaintainedTables()
3182
- .filter(mv => mv.derivation.stale)
3183
- .map(mv => `${mv.schemaName}.${mv.name}`.toLowerCase())
3184
- : [];
3185
- }
3186
- /**
3187
- * Recompute the durable stale-MV set and, only when it differs from the last value
3188
- * enqueued this subscription ({@link lastPersistedStaleMvs}), enqueue a `sync: true`
3189
- * point-write of it onto {@link persistQueue}. The compare-skip keeps an unrelated
3190
- * `table_modified` (a tag swap that changes no MV's staleness) from costing an fsync.
3191
- *
3192
- * The recompute reads `derivation.stale` synchronously (the flags are current at
3193
- * listener-dispatch time); the field is updated synchronously at enqueue time, and
3194
- * `persistQueue` serializes the writes in order, so the field always names the last
3195
- * enqueued value. Riding `persistQueue` (rather than a bare `put`) also serializes
3196
- * this `sync` behind the triggering event's own source-DDL compare-write, so the
3197
- * stale-set becomes durable no-later-than the source DDL that caused the staleness —
3198
- * the ordering the adopt soundness argument depends on.
3199
- */
3200
- persistStaleMvSetIfChanged() {
3201
- // Only an atomic-capable session writes the durable set — it is the sole reader and
3202
- // trust basis (see {@link atomicProvider}); a non-atomic session's trust basis is the
3203
- // clean-shutdown marker, so writing the set there is both useless and a soundness
3204
- // hazard (it could be trusted by a later atomic reopen). Skip before recomputing.
3205
- if (!this.atomicProvider)
3206
- return;
3207
- const json = JSON.stringify(this.computeStaleMvSet());
3208
- if (json === this.lastPersistedStaleMvs)
3209
- return;
3210
- this.lastPersistedStaleMvs = json;
3211
- this.enqueuePersist(() => this.writeDurableStaleMvSet(json));
3212
- }
3213
- /**
3214
- * Write the durable stale-MV set (`\x00meta\x00stale_mvs`) with `sync: true`. Unlike
3215
- * the clean-shutdown marker this entry is persistent current-truth — overwritten as
3216
- * staleness changes, never deleted — so a crash leaves the last synced value intact.
3217
- * The `sync` mirrors the marker-consume delete's durability discipline (and carries
3218
- * the same documented backend caveat: a backend without a durability knob no-ops it).
3219
- */
3220
- async writeDurableStaleMvSet(json) {
3221
- const catalogStore = await this.provider.getCatalogStore();
3222
- await catalogStore.put(buildMetaCatalogKey(STALE_MVS_META_NAME), new TextEncoder().encode(json), { sync: true });
3223
- }
3224
- /**
3225
- * Read + conservative-parse the durable stale-MV set, mirroring
3226
- * {@link consumeCleanShutdownMarker}'s parse discipline (but READ-only — never
3227
- * deleted). Returns:
3228
- * - `{ present: false }` — no entry on disk (a pre-atomic store, or an upgrade where
3229
- * the capability was gained but no stale-set has been written yet) → the caller
3230
- * falls back to the clean-shutdown marker path.
3231
- * - `{ present: true, stale }` — the parsed set of qualified `schema.mv` names.
3232
- * - `{ present: true, stale: null }` — a present but unparseable / wrong-shape payload
3233
- * → the caller refills everything (the always-safe posture).
3234
- */
3235
- async readDurableStaleMvSet() {
3236
- const catalogStore = await this.provider.getCatalogStore();
3237
- const raw = await catalogStore.get(buildMetaCatalogKey(STALE_MVS_META_NAME));
3238
- if (raw === undefined)
3239
- return { present: false };
3240
- try {
3241
- const parsed = JSON.parse(new TextDecoder().decode(raw));
3242
- if (!Array.isArray(parsed) || !parsed.every((s) => typeof s === 'string')) {
3243
- console.warn('[StoreModule] durable stale-MV set payload is not a string array; refilling all backings.');
3244
- return { present: true, stale: null };
3245
- }
3246
- return { present: true, stale: new Set(parsed) };
3247
- }
3248
- catch (e) {
3249
- console.warn(`[StoreModule] durable stale-MV set payload did not parse as JSON; refilling all backings: ${e instanceof Error ? e.message : String(e)}`);
3250
- return { present: true, stale: null };
3251
- }
3252
- }
3253
- /**
3254
- * Shared MV add/modify/refresh handling. Narrow defensively — a derivation-less
3255
- * payload would be an engine bug, so skip. Otherwise, mirror `table_modified`:
3256
- * synchronously refresh a connected `StoreTable`'s cached schema (so a tag change
3257
- * such as `quereus.sync.replicate` takes effect immediately without reopen) before
3258
- * enqueuing the catalog DDL persist.
3259
- */
3260
- refreshConnectedMaterializedView(schemaName, objectName, payload) {
3261
- if (!isMaintainedTable(payload))
3262
- return;
3263
- const key = `${schemaName}.${objectName}`.toLowerCase();
3264
- const connected = this.tables.get(key);
3265
- if (connected)
3266
- connected.updateSchema(payload);
3267
- this.enqueuePersist(() => this.saveMaterializedViewDDL(payload));
3268
- }
3269
- /**
3270
- * Append a catalog-persistence task to the serialized `persistQueue`, so successive
3271
- * mutations (e.g. SET TAGS (a=1) then SET TAGS ()) apply in order and are drained by
3272
- * `closeAll`/`whenCatalogPersisted` before the provider closes. Errors are
3273
- * swallowed+logged to mirror `notifyChange`'s own try/catch contract — a listener
3274
- * rejection must never escape.
3275
- */
3276
- enqueuePersist(work) {
3277
- this.persistQueue = this.persistQueue
3278
- .then(work)
3279
- .catch((err) => {
3280
- const message = err instanceof Error ? err.message : String(err);
3281
- console.warn(`[StoreModule] Failed to persist catalog DDL after schema change: ${message}`);
3282
- });
3283
- }
3284
- /**
3285
- * Read-compare-write the catalog DDL for a table that just fired `table_modified`.
3286
- *
3287
- * - **Absent** catalog entry → the table is not store-backed in this catalog (a
3288
- * memory table in the same `db`, or a store table never persisted) → skip. This
3289
- * self-filters foreign-module tables without relying on `vtabModule` identity
3290
- * (which points at the isolation wrapper when wrapped).
3291
- * - **Present** but identical DDL → skip (no redundant write). This is what makes a
3292
- * structural ALTER — whose own `alterTable` already wrote the final DDL, then fires
3293
- * `table_modified` with that same final schema — a no-op here (no double-write).
3294
- * - **Present** and different DDL → re-persist (the tag swap; or a beneficial
3295
- * propagated rewrite of a dependent store table).
3296
- */
3297
- async persistCatalogIfChanged(key, newObject) {
3298
- const catalogStore = await this.provider.getCatalogStore();
3299
- const existing = await catalogStore.get(key);
3300
- if (existing === undefined)
3301
- return; // not store-backed in this catalog — skip
3302
- // Regenerate the full bundle (table DDL + index DDL + exposed-implicit-index
3303
- // tag DDL): a SET TAGS on an index fires `table_modified` on the OWNING table
3304
- // with the updated index in `tableSchema.indexes` — or, for an exposed
3305
- // implicit index, with the updated `exposedIndexTags` on the originating
3306
- // UNIQUE constraint — so the changed DDL re-persists here with no
3307
- // index-specific plumbing. An identical bundle is what makes a structural
3308
- // ALTER (and the createIndex follow-up event) a no-op — no double-write.
3309
- const newDDL = this.buildCatalogEntry(newObject);
3310
- const existingDDL = new TextDecoder().decode(existing);
3311
- if (existingDDL === newDDL)
3312
- return; // identical — no redundant write
3313
- await catalogStore.put(key, this.encodeCatalogDDL(newDDL));
3314
- }
3315
- /**
3316
- * Resolve once all catalog writes queued by async schema-change listeners
3317
- * (catalog-only tag swaps) have settled. A durability barrier: `closeAll` awaits
3318
- * it internally; callers/tests that need the persisted catalog current without a
3319
- * full close can await it directly. Never rejects — queued errors are logged in
3320
- * the chain.
3321
- */
3322
- async whenCatalogPersisted() {
3323
- await this.persistQueue;
3324
- }
3325
- /**
3326
- * Close all stores.
3327
- */
3328
- async closeAll() {
3329
- // Capture the stale-at-close MV set BEFORE the unsubscribe block clears
3330
- // `subscribedDb`. Nothing between here and the marker write can change these
3331
- // flags (closeAll only drains the persist queue and disconnects tables).
3332
- // `stale` is in-memory-only runtime state — an MV whose row-time maintenance was
3333
- // detached mid-session (any `table_modified` on a source: an ALTER, even a
3334
- // `create index`) so subsequent source writes never reached its backing. Carrying
3335
- // the names lets the next open exclude exactly those from the adopt fast path.
3336
- //
3337
- // No subscribed db (this module was opened but never rehydrated and never had a
3338
- // store table created/connected) ⇒ the empty set: every path that can mark an MV
3339
- // stale requires a session in which this module observed the db — a store source
3340
- // create/connect (both call `ensureSchemaSubscription`) or `rehydrateCatalog`
3341
- // (subscribes up front) — so a session without `subscribedDb` never detached any
3342
- // persisted MV's maintenance. Memory-backed MVs that appear in the set are
3343
- // harmless: their catalog entries always refill (no phase-1 pre-existing backing),
3344
- // so withholding trust from them is a no-op.
3345
- const staleAtClose = this.computeStaleMvSet();
3346
- // Stop listening first so no new persist work is enqueued mid-close, then drain
3347
- // the queued catalog writes (tag swaps) before the provider closes.
3348
- if (this.schemaListenerUnsub) {
3349
- this.schemaListenerUnsub();
3350
- this.schemaListenerUnsub = undefined;
3351
- this.subscribedDb = undefined;
3352
- }
3353
- await this.persistQueue;
3354
- for (const table of this.tables.values()) {
3355
- await table.disconnect();
3356
- }
3357
- this.tables.clear();
3358
- this.moduleCoordinator = undefined;
3359
- // Every batch has flushed (persist queue drained, tables disconnected):
3360
- // attest the clean shutdown so the next open may take the materialized-view
3361
- // adopt fast path. The marker VALUE is the JSON stale-at-close set (`[]` when
3362
- // nothing is stale) — `rehydrateCatalog` excludes those MVs from the fast path
3363
- // so they refill. Consumed (single-use) by `rehydrateCatalog`. Written LAST,
3364
- // immediately before the provider closes — anything that dies before this line
3365
- // leaves no marker and the next open refills everything.
3366
- const catalogStore = await this.provider.getCatalogStore();
3367
- await catalogStore.put(buildMetaCatalogKey(CLEAN_SHUTDOWN_META_NAME), new TextEncoder().encode(JSON.stringify(staleAtClose)));
3368
- // Also write the DURABLE stale-MV set (persistent current-truth, NOT single-use) —
3369
- // but ONLY for an atomic provider, the sole reader/truster of it (see
3370
- // {@link atomicProvider}). Same array as the marker payload, under a separate
3371
- // reserved meta key that the next open READS (never deletes). In the atomic-commit
3372
- // domain this — not the crash-losable marker — is the adopt fast path's
3373
- // logical-staleness exclusion basis, so a clean close (a) corrects any rename-restore
3374
- // drift (the one staleness CLEAR transition that fires no event) and (b) guarantees
3375
- // the entry exists even in a session with no staleness events. `provider.closeAll()`
3376
- // below flushes it.
3377
- if (this.atomicProvider) {
3378
- await catalogStore.put(buildMetaCatalogKey(STALE_MVS_META_NAME), new TextEncoder().encode(JSON.stringify(staleAtClose)));
3379
- }
3380
- await this.provider.closeAll();
3381
- this.stores.clear();
3382
- }
3383
- /**
3384
- * Get a table by schema and name.
3385
- */
3386
- getTable(schemaName, tableName) {
3387
- const tableKey = `${schemaName}.${tableName}`.toLowerCase();
3388
- return this.tables.get(tableKey);
3389
- }
3390
- }
3391
- /** Adapts a raw KV entry stream into the row stream the uniqueness validators consume. */
3392
- async function* rowsFromEntries(entries) {
3393
- for await (const entry of entries) {
3394
- yield deserializeRow(entry.value);
3395
- }
3396
- }
3397
- /**
3398
- * Read-side wrap for a value-rewriting ALTER COLUMN's pre-mutation UNIQUE probe: each row's
3399
- * value at `colIndex` run through `convert` (the sub-branch's deferred `valueConvert`, which
3400
- * owns its own NULL handling), so the probe judges the POST-alter values while the store
3401
- * still holds the old ones. Mutates nothing — the memory module's `convertRowAtIndex`
3402
- * counterpart. A conversion failure propagates: the setDataType pre-pass already proved
3403
- * every visible value convertible, so a throw here is unreachable, and surfacing it beats
3404
- * probing a stale value that could mask a collision.
3405
- */
3406
- async function* convertRowsAtIndex(rows, colIndex, convert) {
3407
- for await (const row of rows) {
3408
- const newVal = convert(row[colIndex]);
3409
- yield newVal === row[colIndex] ? row : row.map((v, i) => i === colIndex ? newVal : v);
3410
- }
3411
- }
3412
- /**
3413
- * Per-column key normalizers for a UNIQUE INDEX dedupe signature, drawing each column's
3414
- * collation from the index column (if it carries one) else the underlying table column —
3415
- * so the signature honors a per-column collation registered on this connection, matching
3416
- * write-time enforcement.
3417
- *
3418
- * A column whose declared type can never hold text takes the identity normalizer regardless
3419
- * of its collation (the signature serializer normalizes only string values), so a comparator-only
3420
- * collation named on an integer column is not rejected here when the engine's own hash sites
3421
- * would accept it.
3422
- */
3423
- function indexDedupeNormalizers(tableSchema, indexSchema, keyNormalizers) {
3424
- return indexSchema.columns.map(col => {
3425
- const column = tableSchema.columns[col.index];
3426
- return keyNormalizers(logicalTypeCanHoldText(column.logicalType)
3427
- ? (col.collation ?? column.collation)
3428
- : undefined);
3429
- });
3430
- }
3431
- /**
3432
- * Collation- and identity-aware dedupe signature of `row` over `colIndices`: each value
3433
- * runs through its column's key transform (`storeSemanticKeyTransform` — TIMESPAN's
3434
- * total-seconds `groupKey`, so 'PT1H' and 'PT60M' sign identically; JSON's structural
3435
- * bytes, so reorder-equal objects sign identically) before `serializeKey` applies the
3436
- * per-column normalizer. Returns null when any covered value
3437
- * is NULL (SQL UNIQUE allows multiple NULLs). The build/validate-time twin of the
3438
- * write-time typed compare in `StoreTable.uniqueColumnComparators`.
3439
- */
3440
- function dedupeRowSignature(row, colIndices, normalizers, transforms) {
3441
- const values = colIndices.map((c, i) => {
3442
- const v = row[c];
3443
- const transform = transforms[i];
3444
- return transform && v !== null ? transform(v) : v;
3445
- });
3446
- return serializeKey(values, normalizers);
3447
- }
3448
- /**
3449
- * Throws CONSTRAINT on the first pair of `rows` that share a signature over `columnIndices`.
3450
- *
3451
- * SQL NULL semantics: {@link dedupeRowSignature} returns null when any constrained column is
3452
- * NULL, and such rows never collide. A partial `predicate` restricts the judged set to rows
3453
- * it accepts unambiguously. Shared by {@link StoreModule.validateUniqueOverExistingRows}
3454
- * (constraint columns) and {@link StoreModule.validateUniqueIndexOverRows} (index columns).
3455
- */
3456
- async function assertNoDuplicateRows(rows, tableSchema, columnIndices, normalizers, predicate) {
3457
- const transforms = columnIndices.map(i => storeSemanticKeyTransform(tableSchema.columns[i]?.logicalType));
3458
- const seen = new Set();
3459
- for await (const row of rows) {
3460
- if (predicate && predicate.evaluate(row) !== true)
3461
- continue;
3462
- const keySig = dedupeRowSignature(row, columnIndices, normalizers, transforms);
3463
- if (keySig === null)
3464
- continue;
3465
- if (seen.has(keySig)) {
3466
- const colNames = columnIndices.map(i => tableSchema.columns[i]?.name ?? String(i)).join(', ');
3467
- throw new QuereusError(`UNIQUE constraint failed: ${tableSchema.name} (${colNames})`, StatusCode.CONSTRAINT);
3468
- }
3469
- seen.add(keySig);
3470
- }
3471
- }
3472
- /**
3473
- * Reconcile each text PRIMARY KEY column's declared collation at CREATE time.
3474
- *
3475
- * The store now encodes PRIMARY KEY uniqueness/ordering PHYSICALLY with a
3476
- * PER-COLUMN key collation (`StoreTable.pkKeyCollations`, drawn from each PK
3477
- * column's declared `collation`), so ANY declared PK collation is honored
3478
- * natively — an explicit `collate binary` text PK is keyed under BINARY, a
3479
- * `collate nocase` under NOCASE, etc. The only thing this reconcile still does is
3480
- * supply the store's table-level DEFAULT (`keyCollation` = K = `config.collation`,
3481
- * default NOCASE) to a text PK column that declares NO explicit collation, so an
3482
- * implicit-default text PK keeps the store's historical NOCASE-keyed behavior
3483
- * (rather than the engine's BINARY column default). An EXPLICIT collation — even
3484
- * one diverging from K — is left exactly as declared and re-keyed natively.
3485
- *
3486
- * Collation governs key bytes only for text, so only PK members whose logical type
3487
- * is textual are touched (an `integer primary key` keeps its BINARY; temporal
3488
- * text-physical types carry no collation). Non-PK columns are never touched.
3489
- *
3490
- * This is the CREATE path only — the load path (`connect` / rehydrate) does not
3491
- * reconcile; the persisted DDL is the source of truth on reopen, and the column's
3492
- * (BINARY-elided) `COLLATE` clause round-trips the per-column key collation.
3493
- *
3494
- * Returns the schema unchanged when no implicit text PK column needs the default
3495
- * applied; otherwise a new schema with a rebuilt `columns` array + `columnIndexMap`.
3496
- */
3497
- function reconcilePkCollations(schema, keyCollation) {
3498
- const pkIndices = new Set(schema.primaryKeyDefinition.map(def => def.index));
3499
- if (pkIndices.size === 0)
3500
- return schema;
3501
- let changed = false;
3502
- const newColumns = schema.columns.map((col, idx) => {
3503
- if (!pkIndices.has(idx))
3504
- return col;
3505
- // Collation governs key bytes only for text; non-text PK columns (integer,
3506
- // real, blob, …) are encoded type-natively and keep their declared collation.
3507
- if (!col.logicalType.isTextual)
3508
- return col;
3509
- // An EXPLICIT collation is honored as-declared — the per-column key encoding
3510
- // keys the column under it (Option B physical re-key parity with memory).
3511
- if (col.collationExplicit)
3512
- return col;
3513
- const declared = (col.collation || 'BINARY').toUpperCase();
3514
- if (declared === keyCollation)
3515
- return col; // implicit default already == K
3516
- // Implicit default diverges from K (e.g. the engine's BINARY column default
3517
- // under the store's NOCASE K): apply the store's table-level default so an
3518
- // undecorated text PK keeps the store's historical NOCASE-keyed semantics.
3519
- changed = true;
3520
- return { ...col, collation: keyCollation };
3521
- });
3522
- if (!changed)
3523
- return schema;
3524
- const columns = Object.freeze(newColumns);
3525
- return { ...schema, columns, columnIndexMap: buildColumnIndexMap(columns) };
3526
- }
3527
- /**
3528
- * Build a column remap array: newColumnIndex -> oldColumnIndex | -1.
3529
- * Maps each column in the new layout to its position in the old layout.
3530
- * -1 means the column is new (fill with default).
3531
- */
3532
- function buildColumnRemap(oldColumnNames, newColumnNames) {
3533
- const oldIndexByName = new Map();
3534
- for (let i = 0; i < oldColumnNames.length; i++) {
3535
- oldIndexByName.set(oldColumnNames[i].toLowerCase(), i);
3536
- }
3537
- return newColumnNames.map(name => {
3538
- const oldIdx = oldIndexByName.get(name.toLowerCase());
3539
- return oldIdx !== undefined ? oldIdx : -1;
3540
- });
3541
- }
3542
- /** Whether `fk` points back at the table that owns it. */
3543
- function isSelfForeignKey(fk, schemaLower, tableLower) {
3544
- return (fk.referencedSchema ?? schemaLower).toLowerCase() === schemaLower
3545
- && fk.referencedTable.toLowerCase() === tableLower;
3546
- }
3547
- /**
3548
- * Retarget a self-referencing foreign key's `referencedTable` across a rename of
3549
- * the owning table. A pure copy — unlike the CHECK / partial-index rewrites this
3550
- * touches a name field, not a shared AST, so the caller needs no rollback.
3551
- * Returns the input array when nothing self-references.
3552
- */
3553
- function retargetSelfForeignKeys(fks, schemaName, oldName, newName) {
3554
- if (!fks)
3555
- return fks;
3556
- const schemaLower = schemaName.toLowerCase();
3557
- const oldLower = oldName.toLowerCase();
3558
- let changed = false;
3559
- const next = fks.map(fk => {
3560
- if (!isSelfForeignKey(fk, schemaLower, oldLower))
3561
- return fk;
3562
- changed = true;
3563
- return { ...fk, referencedTable: newName };
3564
- });
3565
- return changed ? Object.freeze(next) : fks;
3566
- }
3567
- /**
3568
- * Rename a column inside a self-referencing foreign key's `referencedColumnNames`
3569
- * (the names the FK resolves to parent-column indices at enforcement time). Pure
3570
- * copy, as in {@link retargetSelfForeignKeys}. The child-side `columns` are
3571
- * indices, unaffected by a rename.
3572
- */
3573
- function renameColumnInSelfForeignKeys(fks, schemaName, tableName, oldCol, newCol) {
3574
- if (!fks)
3575
- return fks;
3576
- const schemaLower = schemaName.toLowerCase();
3577
- const tableLower = tableName.toLowerCase();
3578
- const oldColLower = oldCol.toLowerCase();
3579
- let changed = false;
3580
- const next = fks.map(fk => {
3581
- if (!isSelfForeignKey(fk, schemaLower, tableLower))
3582
- return fk;
3583
- if (!fk.referencedColumnNames?.some(n => n.toLowerCase() === oldColLower))
3584
- return fk;
3585
- changed = true;
3586
- return {
3587
- ...fk,
3588
- referencedColumnNames: Object.freeze(fk.referencedColumnNames.map(n => n.toLowerCase() === oldColLower ? newCol : n)),
3589
- };
3590
- });
3591
- return changed ? Object.freeze(next) : fks;
3592
582
  }
3593
583
  //# sourceMappingURL=store-module.js.map