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