@quereus/store 4.3.0 → 4.3.2

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 (44) hide show
  1. package/README.md +486 -436
  2. package/dist/src/common/bytes.d.ts +4 -1
  3. package/dist/src/common/bytes.d.ts.map +1 -1
  4. package/dist/src/common/bytes.js +23 -2
  5. package/dist/src/common/bytes.js.map +1 -1
  6. package/dist/src/common/cached-kv-store.d.ts.map +1 -1
  7. package/dist/src/common/cached-kv-store.js +6 -13
  8. package/dist/src/common/cached-kv-store.js.map +1 -1
  9. package/dist/src/common/encoding.d.ts +69 -26
  10. package/dist/src/common/encoding.d.ts.map +1 -1
  11. package/dist/src/common/encoding.js +292 -230
  12. package/dist/src/common/encoding.js.map +1 -1
  13. package/dist/src/common/index.d.ts +1 -1
  14. package/dist/src/common/index.d.ts.map +1 -1
  15. package/dist/src/common/index.js +1 -1
  16. package/dist/src/common/index.js.map +1 -1
  17. package/dist/src/common/key-builder.d.ts +5 -1
  18. package/dist/src/common/key-builder.d.ts.map +1 -1
  19. package/dist/src/common/key-builder.js +30 -2
  20. package/dist/src/common/key-builder.js.map +1 -1
  21. package/dist/src/common/kv-store.d.ts +1 -1
  22. package/dist/src/common/memory-store.d.ts.map +1 -1
  23. package/dist/src/common/memory-store.js +21 -6
  24. package/dist/src/common/memory-store.js.map +1 -1
  25. package/dist/src/common/serialization.d.ts.map +1 -1
  26. package/dist/src/common/serialization.js +6 -3
  27. package/dist/src/common/serialization.js.map +1 -1
  28. package/dist/src/common/store-module.d.ts +293 -26
  29. package/dist/src/common/store-module.d.ts.map +1 -1
  30. package/dist/src/common/store-module.js +1543 -612
  31. package/dist/src/common/store-module.js.map +1 -1
  32. package/dist/src/common/store-table.d.ts +515 -36
  33. package/dist/src/common/store-table.d.ts.map +1 -1
  34. package/dist/src/common/store-table.js +873 -100
  35. package/dist/src/common/store-table.js.map +1 -1
  36. package/dist/src/common/transaction.d.ts +8 -5
  37. package/dist/src/common/transaction.d.ts.map +1 -1
  38. package/dist/src/common/transaction.js +32 -5
  39. package/dist/src/common/transaction.js.map +1 -1
  40. package/dist/src/testing/kv-conformance.d.ts +47 -0
  41. package/dist/src/testing/kv-conformance.d.ts.map +1 -0
  42. package/dist/src/testing/kv-conformance.js +418 -0
  43. package/dist/src/testing/kv-conformance.js.map +1 -0
  44. package/package.json +17 -5
@@ -12,13 +12,107 @@
12
12
  * - {prefix}.__stats__ - Unified stats store (row counts for all tables)
13
13
  * - Catalog store: __catalog__ - DDL metadata keyed by {schema}.{table}
14
14
  */
15
- import { AccessPlanBuilder, QuereusError, StatusCode, buildColumnIndexMap, columnDefToSchema, compilePredicate, inferType, tryFoldLiteral, validateAndParse, buildAdvertisementsFromTags, resolveNamedConstraintClass, validateCollationForType, buildUniqueConstraintSchema, buildForeignKeyConstraintSchema, buildCheckConstraintSchema, validateForeignKeyOverExistingRows, extractColumnLevelCheckConstraints, extractColumnLevelForeignKeys, appendIndexToTableSchema, resolveKeyNormalizer, serializeRowKey, isMaintainedTable } from '@quereus/quereus';
15
+ import { AccessPlanBuilder, QuereusError, StatusCode, buildColumnIndexMap, columnDefToSchema, compilePredicate, inferType, tryFoldLiteral, validateAndParse, buildAdvertisementsFromTags, resolveNamedConstraintClass, validateCollationForType, buildUniqueConstraintSchema, buildForeignKeyConstraintSchema, buildCheckConstraintSchema, validateForeignKeyOverExistingRows, extractColumnLevelCheckConstraints, extractColumnLevelForeignKeys, appendIndexToTableSchema, logicalTypeCanHoldText, serializeRowKey, isMaintainedTable, renameColumnInIndexPredicates, renameTableInIndexPredicates, renameColumnInCheckConstraints, renameTableInCheckConstraints } from '@quereus/quereus';
16
16
  import { TransactionCoordinator } from './transaction.js';
17
+ import { StoreConnection } from './store-connection.js';
17
18
  import { StoreBackingHost } from './backing-host.js';
18
- import { StoreTable, resolvePkKeyCollations } from './store-table.js';
19
- import { buildCatalogKey, buildCatalogScanBounds, buildDataStoreName, buildIndexKey, buildIndexStoreName, buildFullScanBounds, buildStatsKey, buildViewCatalogKey, buildMaterializedViewCatalogKey, parseMaterializedViewCatalogKey, buildMetaCatalogKey, CLEAN_SHUTDOWN_META_NAME, STALE_MVS_META_NAME, classifyCatalogKey, } from './key-builder.js';
19
+ import { StoreTable, resolvePkKeyCollations, 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';
20
22
  import { deserializeRow } from './serialization.js';
21
23
  import { generateTableDDL, generateIndexDDL, 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
+ const LOWER_BOUND_OPS = ['>', '>='];
80
+ const UPPER_BOUND_OPS = ['<', '<='];
81
+ const RANGE_OPS = [...LOWER_BOUND_OPS, ...UPPER_BOUND_OPS];
82
+ /**
83
+ * Build `handledFilters` by claiming ONLY the constraints `rule-select-access-path`
84
+ * actually consumes: per seek column the FIRST '=' (equality seek), or the FIRST lower
85
+ * ('>'/'>=') plus the FIRST upper ('<'/'<=') bound, selected by `colConstraints.find(...)`
86
+ * in `request.filters` order.
87
+ *
88
+ * That rule collapses `handledFilters` into a per-COLUMN set, so a redundant same-column,
89
+ * same-role constraint marked handled is neither turned into a seek bound nor kept by
90
+ * `ruleGrowRetrieve` as a residual — its predicate would be LOST (`where v > 10 and v > 30`
91
+ * would wrongly return the `v > 10` rows; `where v = 20 and v = 30` would wrongly return
92
+ * the `v = 20` row). The engine now reattaches such orphans defensively, but a module that
93
+ * over-claims still pays a redundant predicate evaluation per fetched row.
94
+ *
95
+ * Claiming must therefore be POSITIONAL and match the rule's first-match pick: claiming the
96
+ * tighter-but-later duplicate instead would be actively wrong (the rule would still seek on
97
+ * the earlier one). Any later duplicate stays unhandled so it survives in the residual Filter.
98
+ */
99
+ function claimFirstPerRole(filters, roles) {
100
+ const claimed = new Set();
101
+ for (const { colIdx, ops } of roles) {
102
+ const i = filters.findIndex(f => f.columnIndex === colIdx && ops.includes(f.op));
103
+ if (i >= 0)
104
+ claimed.add(i);
105
+ }
106
+ return filters.map((_f, i) => claimed.has(i));
107
+ }
108
+ /** The lower-bound + upper-bound roles a single-column range seek on `colIdx` fills. */
109
+ function rangeRoles(colIdx) {
110
+ return [{ colIdx, ops: LOWER_BOUND_OPS }, { colIdx, ops: UPPER_BOUND_OPS }];
111
+ }
112
+ /** The one equality role each seek column of an equality/prefix seek fills. */
113
+ function equalityRoles(colIdxs) {
114
+ return colIdxs.map(colIdx => ({ colIdx, ops: EQ_OPS }));
115
+ }
22
116
  /**
23
117
  * Generic store module that works with any KVStoreProvider.
24
118
  *
@@ -124,6 +218,13 @@ export class StoreModule {
124
218
  persistent: true,
125
219
  secondaryIndexes: true,
126
220
  rangeScans: true,
221
+ // Worst-case summary across this module's DDL. The row-rewriting ALTER arms
222
+ // and renameTable call ddlCommitPendingOps(), which commits the WHOLE module
223
+ // transaction (schema change + every buffered write) at DDL time — a later
224
+ // rollback undoes nothing. Non-committing DDL (create index, add constraint,
225
+ // schema-only arms) is merely non-transactional, but the declared value is
226
+ // the worst case. See docs/store.md § "DDL that implicitly commits".
227
+ ddlTransactionality: 'auto-commit',
127
228
  };
128
229
  }
129
230
  /**
@@ -383,6 +484,13 @@ export class StoreModule {
383
484
  const vtabArgs = {};
384
485
  if (options?.collation !== undefined)
385
486
  vtabArgs.collation = options.collation;
487
+ // `options` is the raw DDL arg record (snake_case keys), so read `max_batch_bytes`,
488
+ // not the parsed `maxBatchBytes` field — the latter is never populated here, which
489
+ // silently dropped a configured budget across reopen. `?? maxBatchBytes` still honors
490
+ // a caller that hands in an already-parsed config.
491
+ const rawMaxBatchBytes = options?.max_batch_bytes ?? options?.maxBatchBytes;
492
+ if (rawMaxBatchBytes !== undefined)
493
+ vtabArgs.max_batch_bytes = rawMaxBatchBytes;
386
494
  // Resolve the table schema:
387
495
  // 1. Use importedTableSchema if provided (from catalog import or runtime)
388
496
  // 2. Look up from schemaManager (most common case during runtime queries)
@@ -449,7 +557,9 @@ export class StoreModule {
449
557
  // no-sibling case and strictly safer for the sibling case.
450
558
  const table = this.tables.get(tableKey);
451
559
  const currentSchema = table?.getSchema() ?? db.schemaManager.getTable(schemaName, tableName);
452
- const indexNames = (currentSchema?.indexes ?? []).map(i => i.name);
560
+ // Materialized index list, so the hidden `_uc_*` stores backing plain UNIQUEs are
561
+ // torn down too — the engine-facing `.indexes` omits them and would leak them.
562
+ const indexNames = this.materializedIndexNames(table, currentSchema);
453
563
  await this.tearDownTableStorage(schemaName, tableName, indexNames);
454
564
  // Emit schema change event for table drop
455
565
  this.eventEmitter?.emitSchemaChange({
@@ -546,9 +656,17 @@ export class StoreModule {
546
656
  }
547
657
  /**
548
658
  * Creates an index on a store-backed table.
659
+ *
660
+ * `rows` — an {@link EffectiveRowSource} supplied by a wrapper module (the isolation
661
+ * layer) — replaces this module's own rows as the set the UNIQUE duplicate check judges.
662
+ * When present the check runs UP FRONT, before `getIndexStore` opens the index-store
663
+ * directory, so a rejection leaves no directory behind; and `buildIndexEntries` then
664
+ * populates the physical index from this module's own (committed) rows with its in-pass
665
+ * dup check disabled. Those two row sets legitimately differ — the wrapper's transaction
666
+ * may have deleted a committed duplicate — and an index entry with no live row behind it
667
+ * is harmless (see the entry-resolution note in `buildIndexEntries`).
549
668
  */
550
- async createIndex(db, schemaName, tableName, indexSchema) {
551
- const tableKey = `${schemaName}.${tableName}`.toLowerCase();
669
+ async createIndex(db, schemaName, tableName, indexSchema, rows) {
552
670
  const table = this.getOrReconnectTable(db, schemaName, tableName);
553
671
  if (!table) {
554
672
  throw new QuereusError(`Store table '${tableName}' not found in schema '${schemaName}'`, StatusCode.NOTFOUND);
@@ -562,13 +680,52 @@ export class StoreModule {
562
680
  // which opens/creates the directory that `buildIndexEntries` then writes into.
563
681
  const indexStoreName = buildIndexStoreName(schemaName, tableName, indexSchema.name);
564
682
  this.assertStoreNameFree(db, schemaName, indexStoreName, `index store of new index '${indexSchema.name}' on table '${schemaName}.${tableName}'`);
565
- // Create the index store
566
- const indexStore = await this.provider.getIndexStore(schemaName, tableName, indexSchema.name);
567
- // Build index entries for existing rows
568
- const dataStore = await this.getStore(tableKey, table.getConfig());
569
683
  const tableSchema = table.getSchema();
570
684
  const keyCollation = (table.getConfig().collation || 'NOCASE').toUpperCase();
571
- await this.buildIndexEntries(dataStore, indexStore, tableSchema, indexSchema, keyCollation);
685
+ // Wrapper-supplied rows are the judged set. Validate BEFORE `getIndexStore` so a
686
+ // rejection leaves no index-store directory behind, and disable the in-pass dup
687
+ // check below (which would judge this module's committed rows instead).
688
+ if (rows && indexSchema.unique) {
689
+ await this.validateUniqueIndexOverRows(rows(), tableSchema, indexSchema, db.getKeyNormalizerResolver());
690
+ }
691
+ // Create the index store
692
+ const indexStore = await this.provider.getIndexStore(schemaName, tableName, indexSchema.name);
693
+ // Build index entries for every row the table can currently SEE — committed
694
+ // rows merged with this transaction's pending writes (`iterateEffectiveEntries`).
695
+ // The committed-only stream would leave a row inserted earlier in an open
696
+ // transaction unindexed, and `StoreTable.findUniqueConflictViaIndex` trusts the
697
+ // index to hold every live row: a `CREATE UNIQUE INDEX` would miss a pending
698
+ // duplicate, and a later insert colliding with that pending row would be
699
+ // silently ACCEPTED. Entries written here for rows that a subsequent ROLLBACK
700
+ // discards are harmless: both readers resolve each entry to its live row and
701
+ // drop it when the row is gone or no longer matches (see `scanIndex`).
702
+ //
703
+ // The store is FRESHLY created above, so any build failure — a UNIQUE violation,
704
+ // an IO error, or a mid-stream flush failure now that the build is chunked — must
705
+ // tear the whole store down. Nothing else reclaims it: SchemaManager.createIndex
706
+ // wraps the error but does no teardown, so without this a rejected build leaks a
707
+ // partial/empty index-store directory. Mirrors dropIndex's teardown.
708
+ try {
709
+ await this.buildIndexEntries(table.iterateEffectiveEntries(buildFullScanBounds()), indexStore, tableSchema, indexSchema, keyCollation, db.getKeyNormalizerResolver(), rows !== undefined, table.getConfig().maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES);
710
+ }
711
+ catch (buildError) {
712
+ // Best-effort teardown: guard it against its own throw so a teardown failure
713
+ // never masks the original build error the caller must see.
714
+ try {
715
+ await table.releaseIndexStore(indexSchema.name);
716
+ if (this.provider.deleteIndexStore) {
717
+ await this.provider.deleteIndexStore(schemaName, tableName, indexSchema.name);
718
+ }
719
+ else {
720
+ await this.provider.closeIndexStore(schemaName, tableName, indexSchema.name);
721
+ }
722
+ }
723
+ catch (teardownError) {
724
+ console.warn(`[StoreModule] failed to tear down index store '${indexSchema.name}' on `
725
+ + `table '${schemaName}.${tableName}' after a failed CREATE INDEX: ${String(teardownError)}`);
726
+ }
727
+ throw buildError;
728
+ }
572
729
  // Refresh the connected table's cached schema so subsequent DML
573
730
  // maintains the new index (the engine's schema registry is updated
574
731
  // separately by SchemaManager.createIndex, but the StoreTable instance
@@ -656,36 +813,52 @@ export class StoreModule {
656
813
  /**
657
814
  * Build index entries for all existing rows in a table.
658
815
  *
816
+ * `dataEntries` is the row stream to index. Callers choose its visibility:
817
+ * `createIndex` passes the table's EFFECTIVE stream (committed + pending, so
818
+ * an open transaction's rows are indexed too), while `rebuildSecondaryIndexes`
819
+ * passes the raw committed stream — it runs immediately after an ALTER has
820
+ * re-encoded the data store in place, and any pending ops still address the
821
+ * pre-ALTER key bytes.
822
+ *
659
823
  * For UNIQUE indexes, performs an in-pass duplicate check (honoring partial
660
824
  * predicates and SQL NULL semantics: multiple NULLs are allowed) and throws
661
825
  * CONSTRAINT before any entries are written. Mirrors the memory module's
662
826
  * populateNewIndex so `CREATE UNIQUE INDEX` over duplicated data fails
663
- * atomically.
827
+ * atomically. `skipDuplicateCheck` suppresses it: `createIndex` sets it when a
828
+ * wrapper module supplied the rows to judge, having already validated them (see
829
+ * {@link validateUniqueIndexOverRows}). Judging `dataEntries` too would reject a
830
+ * committed duplicate the wrapper's transaction has already deleted.
831
+ *
832
+ * `normalizers` MUST be the owning connection's `db.getKeyNormalizerResolver()` —
833
+ * the same resolver `StoreTable.encodeOptions` carries. A rebuild that resolved
834
+ * collations any other way would re-encode the PK suffix under different bytes than
835
+ * the table writes at maintenance time, silently corrupting the index.
664
836
  */
665
- async buildIndexEntries(dataStore, indexStore, tableSchema, indexSchema, keyCollation) {
837
+ async buildIndexEntries(dataEntries, indexStore, tableSchema, indexSchema, keyCollation, normalizers, skipDuplicateCheck = false, maxBatchBytes = DEFAULT_MAX_BATCH_BYTES) {
666
838
  // Index COLUMN values use the table-level key collation K; the PK SUFFIX uses
667
839
  // each PK column's own key collation, so the suffix bytes match the data-store
668
840
  // keys (and `StoreTable.updateSecondaryIndexes`' maintenance writes) exactly.
669
- const encodeOptions = { collation: keyCollation };
841
+ const encodeOptions = { collation: keyCollation, normalizers };
670
842
  const pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
671
843
  const pkCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, keyCollation);
672
844
  const indexDirections = indexSchema.columns.map(col => !!col.desc);
673
845
  const predicate = indexSchema.predicate
674
- ? compilePredicate(indexSchema.predicate, tableSchema.columns)
846
+ ? compilePredicate(indexSchema.predicate, tableSchema.columns, tableSchema.name)
675
847
  : undefined;
676
- const seen = indexSchema.unique ? new Set() : undefined;
677
- // Per-column normalizers for the in-pass UNIQUE dup check, drawing each
678
- // column's collation from the index column (if it carries one) else the
679
- // underlying table column so the dedup signature honors a per-column
680
- // NOCASE/RTRIM collation, matching write-time enforcement.
848
+ // NOTE: `seen` holds one signature per DISTINCT indexed key for the WHOLE build and
849
+ // is NOT bounded by the maxBatchBytes chunking below (that bounds only the write
850
+ // batch). A very large UNIQUE index still spikes memory on this set. Bounding it
851
+ // needs a sort- or store-probe-based dedup (a separate design), not a batch knob.
852
+ const seen = (indexSchema.unique && !skipDuplicateCheck) ? new Set() : undefined;
681
853
  const indexColIndices = indexSchema.columns.map(col => col.index);
682
854
  const indexNormalizers = seen
683
- ? indexSchema.columns.map(col => resolveKeyNormalizer(col.collation ?? tableSchema.columns[col.index].collation))
855
+ ? indexDedupeNormalizers(tableSchema, indexSchema, normalizers)
684
856
  : undefined;
685
- // Scan all data rows
686
- const bounds = buildFullScanBounds();
687
- const batch = indexStore.batch();
688
- for await (const entry of dataStore.iterate(bounds)) {
857
+ let batch = indexStore.batch();
858
+ // Serialized key bytes accumulated in the CURRENT batch. Flushed and reset once it
859
+ // crosses maxBatchBytes so a table larger than memory never buffers its whole index.
860
+ let batchBytes = 0;
861
+ for await (const entry of dataEntries) {
689
862
  const row = deserializeRow(entry.value);
690
863
  // Partial index: skip rows whose predicate is not unambiguously TRUE.
691
864
  if (predicate && predicate.evaluate(row) !== true)
@@ -710,8 +883,29 @@ export class StoreModule {
710
883
  }
711
884
  // Build and store index key
712
885
  const indexKey = buildIndexKey(indexValues, pkValues, encodeOptions, indexDirections, pkDirections, pkCollations);
713
- batch.put(indexKey, new Uint8Array(0)); // Index value is empty
886
+ // Index value = the row's encoded DATA key, so an index scan resolves each
887
+ // entry back to its base row via a direct data-store read (see
888
+ // `StoreTable.scanIndex`). Encoded under the same PK directions + per-column
889
+ // PK collations as `buildDataKey` / `updateSecondaryIndexes`, so the value
890
+ // byte-matches the data store's key for this row.
891
+ const dataKey = buildDataKey(pkValues, encodeOptions, pkDirections, pkCollations);
892
+ batch.put(indexKey, dataKey);
893
+ // Bound heap: once the accumulated serialized key bytes cross the budget, flush
894
+ // the batch and start a fresh one. Both callers ITERATE the data store and WRITE
895
+ // the index store — different stores — so a mid-stream flush never mutates the
896
+ // stream being read, and this is safe on every provider. A mid-stream flush
897
+ // failure in `createIndex` is torn down by its try/catch (the whole fresh index
898
+ // store is deleted); in `rebuildSecondaryIndexes` it leaves a partial-but-
899
+ // recoverable index (re-run the rebuild — clear + rebuild is idempotent).
900
+ batchBytes += indexKey.length + dataKey.length;
901
+ if (batchBytes >= maxBatchBytes) {
902
+ await batch.write();
903
+ batch = indexStore.batch();
904
+ batchBytes = 0;
905
+ }
714
906
  }
907
+ // Final flush of the residual. May be empty (an empty table, or a build whose last
908
+ // row exactly hit the budget and flushed) — providers accept an empty write.
715
909
  await batch.write();
716
910
  }
717
911
  /**
@@ -722,549 +916,923 @@ export class StoreModule {
722
916
  * COLLATE` on a PK member (a PK column's key collation changes). Shared by both
723
917
  * arms so the clear-then-rebuild stays identical. `schema` must already be the
724
918
  * post-ALTER schema (its `primaryKeyDefinition` + column collations drive the new
725
- * PK-suffix encoding via {@link buildIndexEntries}).
919
+ * PK-suffix encoding via {@link buildIndexEntries}). `normalizers` is the owning
920
+ * connection's `db.getKeyNormalizerResolver()` — see {@link buildIndexEntries}.
921
+ *
922
+ * NOTE: reads the data store committed-only and writes the index stores outside the
923
+ * coordinator. Sound only because both callers (the two re-key arms) call
924
+ * {@link ddlCommitPendingOps} first, so "committed" is "everything live". A caller
925
+ * that skips that flush would rebuild an index missing its transaction's pending rows.
726
926
  */
727
- async rebuildSecondaryIndexes(schemaName, tableName, tableKey, table, schema) {
927
+ async rebuildSecondaryIndexes(schemaName, tableName, table, schema, normalizers) {
728
928
  const keyCollation = (table.getConfig().collation || 'NOCASE').toUpperCase();
729
- const dataStore = await this.getStore(tableKey, table.getConfig());
929
+ const dataStore = await this.getStore(schemaName, tableName, table.getConfig());
930
+ const maxBatchBytes = table.getConfig().maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
730
931
  for (const indexSchema of schema.indexes ?? []) {
731
932
  const indexStore = await this.getIndexStore(schemaName, tableName, indexSchema.name);
933
+ // NOTE: this clear pass buffers EVERY existing index key into one batch. It holds
934
+ // only KEYS (no values), so its peak is far below the value-bearing build pass and
935
+ // is left unbounded for now. Chunking it is harder than the build: it deletes from
936
+ // the SAME store it iterates, so a mid-iteration flush risks iterate-while-mutate
937
+ // semantics that differ per provider (LevelDB snapshots its iterator; IndexedDB may
938
+ // not). If the index key set ever dominates memory, chunk this with a snapshot-safe
939
+ // re-seek, not a naive mid-iteration flush.
732
940
  const clearBatch = indexStore.batch();
733
941
  for await (const entry of indexStore.iterate(buildFullScanBounds())) {
734
942
  clearBatch.delete(entry.key);
735
943
  }
736
944
  await clearBatch.write();
737
- await this.buildIndexEntries(dataStore, indexStore, schema, indexSchema, keyCollation);
945
+ await this.buildIndexEntries(dataStore.iterate(buildFullScanBounds()), indexStore, schema, indexSchema, keyCollation, normalizers, false, maxBatchBytes);
738
946
  }
739
947
  }
740
948
  /**
741
- * Validates the existing rows in `dataStore` against a UNIQUE constraint,
742
- * throwing `CONSTRAINT` on the first duplicate before any schema mutation.
743
- * Used by `ADD CONSTRAINT UNIQUE` (validate against the current collation) and
744
- * by `SET COLLATE` (pass an `updatedSchema` whose altered column carries the
745
- * NEW collation, so the dedup is performed under it). Mirrors the duplicate
746
- * detection in {@link buildIndexEntries}: a `seen` Set keyed on a per-column
747
- * collation-aware signature of the constrained values, with SQL NULL semantics
748
- * (a row with any NULL constrained value never counts as a duplicate) and the
749
- * partial `predicate` honored.
949
+ * Validates `rows` against a UNIQUE constraint, throwing `CONSTRAINT` on the first
950
+ * duplicate before any schema mutation. Used by `ADD CONSTRAINT UNIQUE` (validate
951
+ * against the current collation) and by `SET COLLATE` (pass an `updatedSchema` whose
952
+ * altered column carries the NEW collation, so the dedup is performed under it).
953
+ * Mirrors the duplicate detection in {@link buildIndexEntries}: a `seen` Set keyed on
954
+ * a per-column collation-aware signature of the constrained values, with SQL NULL
955
+ * semantics (a row with any NULL constrained value never counts as a duplicate) and
956
+ * the partial `predicate` honored.
957
+ *
958
+ * `rows` MUST be the rows the DDL-issuing connection can SEE. Ordinarily that is this
959
+ * table's EFFECTIVE stream (committed rows merged with the open transaction's own
960
+ * pending puts/deletes — `StoreTable.iterateEffectiveEntries`, adapted by
961
+ * {@link rowsFromEntries}); when a wrapper module holds the pending rows outside this
962
+ * module it hands them down as an `EffectiveRowSource` instead. Either way, a
963
+ * committed-only stream would let a duplicate inserted earlier in the same transaction
964
+ * slip past validation and land in the table once the transaction commits.
750
965
  *
751
966
  * No index store is written — store UNIQUE enforcement is a full-scan over
752
967
  * `uniqueConstraints` at write time. The signature is built by
753
- * {@link serializeRowKey} with one normalizer per constrained column drawn from
754
- * `tableSchema.columns[idx].collation`, so a per-column NOCASE/RTRIM collation
755
- * is honored (matching write-time `compareSqlValues` enforcement). Residual: a
756
- * custom comparator-only collation has no string normalizer and falls back to
757
- * BINARY for the dedup (see docs/schema.md store-collation note).
758
- */
759
- async validateUniqueOverExistingRows(dataStore, tableSchema, uc) {
760
- const predicate = uc.predicate
761
- ? compilePredicate(uc.predicate, tableSchema.columns)
762
- : undefined;
763
- const normalizers = uc.columns.map(idx => resolveKeyNormalizer(tableSchema.columns[idx].collation));
764
- const seen = new Set();
765
- for await (const entry of dataStore.iterate(buildFullScanBounds())) {
766
- const row = deserializeRow(entry.value);
767
- // Partial constraint: only rows the predicate unambiguously accepts count.
768
- if (predicate && predicate.evaluate(row) !== true)
769
- continue;
770
- // serializeRowKey returns null when any constrained column is NULL
771
- // SQL UNIQUE allows multiple NULLs, so those rows never collide.
772
- const keySig = serializeRowKey(row, uc.columns, normalizers);
773
- if (keySig === null)
774
- continue;
775
- if (seen.has(keySig)) {
776
- const colNames = uc.columns.map(i => tableSchema.columns[i]?.name ?? String(i)).join(', ');
777
- throw new QuereusError(`UNIQUE constraint failed: ${tableSchema.name} (${colNames})`, StatusCode.CONSTRAINT);
778
- }
779
- seen.add(keySig);
968
+ * {@link serializeRowKey} with one normalizer per constrained column, resolved from
969
+ * `tableSchema.columns[idx].collation` through the connection's
970
+ * `db.getKeyNormalizerResolver()`, so a per-column collation registered with
971
+ * `db.registerCollation` is honored (matching write-time `compareSqlValues`
972
+ * enforcement). A comparator-only collation raises: rows that cannot be bucketed
973
+ * cannot be deduped. Columns whose declared type can never hold text take the
974
+ * identity normalizer and so never raise.
975
+ */
976
+ async validateUniqueOverExistingRows(rows, tableSchema, uc, keyNormalizers) {
977
+ await assertNoDuplicateRows(rows, tableSchema, uc.columns, uc.columns.map(idx => {
978
+ const column = tableSchema.columns[idx];
979
+ return keyNormalizers(logicalTypeCanHoldText(column.logicalType) ? column.collation : undefined);
980
+ }), uc.predicate ? compilePredicate(uc.predicate, tableSchema.columns, tableSchema.name) : undefined);
981
+ }
982
+ /**
983
+ * Index-shaped twin of {@link validateUniqueOverExistingRows}, used by
984
+ * {@link createIndex} when a wrapper module supplies the rows to judge. Dedupes on the
985
+ * index's own columns under the index column's COLLATE (falling back to the table
986
+ * column's), so it enforces exactly what `buildIndexEntries`' suppressed in-pass check
987
+ * would have over the wrapper's rows instead of this module's committed ones.
988
+ */
989
+ async validateUniqueIndexOverRows(rows, tableSchema, indexSchema, keyNormalizers) {
990
+ await assertNoDuplicateRows(rows, tableSchema, indexSchema.columns.map(col => col.index), indexDedupeNormalizers(tableSchema, indexSchema, keyNormalizers), indexSchema.predicate ? compilePredicate(indexSchema.predicate, tableSchema.columns, tableSchema.name) : undefined);
991
+ }
992
+ /**
993
+ * Flush the module's buffered writes before a DDL operation that physically
994
+ * rewrites or relocates storage.
995
+ *
996
+ * Such a DDL cannot honor the coordinator's buffer: pending ops are
997
+ * `(keyBytes, valueBytes, store)` triples computed at DML time under the
998
+ * PRE-DDL schema, and every physical rewrite (`StoreTable.rekeyRows` /
999
+ * `migrateRows`, the provider's directory move) reads and writes the COMMITTED
1000
+ * store directly. Replaying stale-schema ops over the rewritten store on the
1001
+ * eventual commit corrupts, loses, or misfiles those rows. So an `ALTER TABLE`
1002
+ * that touches storage is effectively DDL-committing on a store-backed table:
1003
+ * flush NOW, before the first physical read, so the rewrite sees every live row
1004
+ * and there is nothing left to replay afterwards.
1005
+ *
1006
+ * Consequences, both deliberate:
1007
+ * - The coordinator is module-wide, so this commits the WHOLE module
1008
+ * transaction — every table's pending ops, not just the altered/renamed
1009
+ * table's — in one all-or-nothing batch. An ALTER cannot half-commit some
1010
+ * sibling tables.
1011
+ * - Validation running AFTER this point (e.g. `rekeyRows`' duplicate-key pass,
1012
+ * which must see pending rows because a pending insert can itself be the
1013
+ * duplicate) throws with the enclosing transaction already flushed. The store
1014
+ * stays unmutated — only the transaction is gone. Validation that does NOT
1015
+ * need pending rows belongs before the call, so the transaction survives it.
1016
+ *
1017
+ * Subsequent `commit()` calls on the same coordinator are no-ops
1018
+ * (`inTransaction` is cleared), which keeps the enclosing transaction safe.
1019
+ * The savepoint stack is cleared too, so a later `ROLLBACK TO` / `RELEASE` that
1020
+ * the engine still broadcasts warns and degrades rather than throwing — see
1021
+ * `TransactionCoordinator.rollbackToSavepoint`, which mirrors the memory
1022
+ * module's identical posture.
1023
+ */
1024
+ async ddlCommitPendingOps() {
1025
+ if (this.moduleCoordinator?.isInTransaction()) {
1026
+ await this.moduleCoordinator.commit();
780
1027
  }
781
1028
  }
782
1029
  /**
783
- * Alters an existing store table's structure (ADD/DROP/RENAME COLUMN).
784
- * Performs eager row migration for ADD and DROP, schema-only update for RENAME.
785
- * Returns the updated TableSchema for the engine to register.
1030
+ * Alters an existing store table's structure. Resolves the table, captures the
1031
+ * pre-alter schema, and dispatches to the per-change-type `alter*` helper below;
1032
+ * the helper does the arm's work (row migration, physical re-key, constraint
1033
+ * validation, DDL persist) and returns the updated TableSchema for the engine to
1034
+ * register. The shared preamble (schema subscription, reconnect, not-found throw,
1035
+ * `defaultNotNull`) lives here so every arm sees the same resolved state.
786
1036
  */
787
- async alterTable(db, schemaName, tableName, change) {
1037
+ async alterTable(db, schemaName, tableName, change, rows) {
788
1038
  this.ensureSchemaSubscription(db);
789
- const tableKey = `${schemaName}.${tableName}`.toLowerCase();
790
1039
  const table = this.getOrReconnectTable(db, schemaName, tableName);
791
1040
  if (!table) {
792
1041
  throw new QuereusError(`Store table '${tableName}' not found in schema '${schemaName}'. Cannot alter.`, StatusCode.ERROR);
793
1042
  }
1043
+ // The engine-facing schema carries no `_uc_*` (the store keeps the materialized
1044
+ // enforcement copy internal), so the arms build `updatedSchema` off a clean schema
1045
+ // exactly as before this feature; `table.updateSchema` recomputes the enforcement
1046
+ // copy, and {@link reconcileImplicitUniqueIndexStores} (below) moves the physical
1047
+ // stores for any constraint-set change.
794
1048
  const oldSchema = table.getSchema();
795
1049
  const defaultNotNull = db.options.getStringOption('default_column_nullability') === 'not_null';
1050
+ let updated;
796
1051
  switch (change.type) {
797
- case 'addColumn': {
798
- // Honor the session `default_collation` for an ADD COLUMN that omits an
799
- // explicit COLLATE, matching the CREATE path so an ADD-COLUMN-ed text column
800
- // gets the same collation a CREATE-d one would. The persisted DDL re-emits an
801
- // explicit COLLATE for any non-BINARY collation, so reopen stays stable.
802
- const newColSchema = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'));
803
- // Extract default value from column def constraints. Use the shared
804
- // `tryFoldLiteral` helper so signed numerics like `-123.0`
805
- // (a UnaryExpr in the AST) are recognized — matching the
806
- // memory-mode path and the engine-level ALTER validation.
807
- let defaultValue = null;
808
- const defaultConstraint = change.columnDef.constraints?.find(c => c.type === 'default');
809
- if (defaultConstraint?.expr) {
810
- const folded = tryFoldLiteral(defaultConstraint.expr);
811
- if (folded !== undefined) {
812
- defaultValue = folded;
813
- }
814
- }
815
- // A non-foldable DEFAULT (e.g. `new.<col>`) backfills each existing row from
816
- // its own value via the engine-supplied evaluator (mirrors the memory path).
817
- const backfillEvaluator = change.backfillEvaluator;
818
- // Refuse NOT NULL without a usable DEFAULT on a non-empty table
819
- // (SQLite-compatible). A per-row evaluator IS usable its NOT NULL is enforced
820
- // per row during migration — so it is exempt from this no-default rejection.
821
- if (newColSchema.notNull && defaultValue === null && !backfillEvaluator) {
822
- if (await table.hasAnyRows()) {
823
- throw new QuereusError(`Cannot add NOT NULL column '${newColSchema.name}' to non-empty table `
824
- + `'${schemaName}.${tableName}' without a DEFAULT value`, StatusCode.CONSTRAINT);
825
- }
826
- }
827
- // Build updated schema: append new column
828
- const updatedColumns = Object.freeze([...oldSchema.columns, newColSchema]);
829
- const updatedSchema = {
830
- ...oldSchema,
831
- columns: updatedColumns,
832
- columnIndexMap: buildColumnIndexMap(updatedColumns),
833
- };
834
- // Extract any column-level CHECK / FK to persist (see the persist block below).
835
- // Hoisted above the row migration so a malformed constraint (e.g. a multi-column
836
- // FK on a single ADD COLUMN, which `extractColumnLevelForeignKeys` rejects) throws
837
- // BEFORE any rows are migrated or the in-memory schema is swapped — validate-before-
838
- // mutate, matching the engine's ordering in `runAddColumn`.
839
- const newCheckConstraints = extractColumnLevelCheckConstraints(change.columnDef);
840
- const newForeignKeys = extractColumnLevelForeignKeys(change.columnDef, schemaName);
841
- // Migrate rows: append the new column's value — a single literal default, or a
842
- // per-row value derived from the existing row when a backfill evaluator is set.
843
- const remap = buildColumnRemap(oldSchema.columns.map(c => c.name), updatedColumns.map(c => c.name));
844
- await table.migrateRows(remap, defaultValue, backfillEvaluator
845
- ? { evaluator: backfillEvaluator, notNull: newColSchema.notNull, columnName: newColSchema.name }
846
- : undefined);
847
- // Update table schema (column-only) and persist DDL.
848
- //
849
- // The engine's `runAddColumn` re-merges the column-level FK/CHECK extracted
850
- // from `columnDef.constraints` into the LIVE in-memory schema AFTER this hook
851
- // returns, so the schema handed back to it must stay column-only — returning a
852
- // constrained schema would double the constraint in the live SchemaManager (and,
853
- // on the next persist, in the DDL). But that engine-side merge is in-memory only:
854
- // it never reaches the catalog, so persistence must carry the column-level
855
- // CHECK/FK itself or they vanish on `rehydrateCatalog`. Build a separate
856
- // `persistedSchema` for `saveTableDDL` when (and only when) the column declares
857
- // such a constraint; the common path persists `updatedSchema` unchanged. This is
858
- // unconditional on the default kind — a per-row (evaluator) DEFAULT extracts the
859
- // same AST constraints as a literal one.
860
- table.updateSchema(updatedSchema);
861
- let persistedSchema = updatedSchema;
862
- if (newCheckConstraints.length > 0 || newForeignKeys.length > 0) {
863
- // The new column is appended last; resolve each FK's child column to its index
864
- // (matching how the engine resolves `resolvedForeignKeys` via columnIndexMap).
865
- const newColIdx = updatedColumns.length - 1;
866
- const resolvedForeignKeys = newForeignKeys.map(fk => ({ ...fk, columns: Object.freeze([newColIdx]) }));
867
- persistedSchema = {
868
- ...updatedSchema,
869
- checkConstraints: Object.freeze([...updatedSchema.checkConstraints, ...newCheckConstraints]),
870
- foreignKeys: Object.freeze([...(updatedSchema.foreignKeys ?? []), ...resolvedForeignKeys]),
871
- };
872
- }
873
- await this.saveTableDDL(persistedSchema);
874
- this.eventEmitter?.emitSchemaChange({
875
- type: 'alter',
876
- objectType: 'table',
877
- schemaName,
878
- objectName: tableName,
879
- });
880
- return updatedSchema;
881
- }
882
- case 'dropColumn': {
883
- const colNameLower = change.columnName.toLowerCase();
884
- const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === colNameLower);
885
- if (colIndex === -1) {
886
- throw new QuereusError(`Column '${change.columnName}' not found.`, StatusCode.ERROR);
887
- }
888
- // Build updated schema: remove column and reindex PK/indexes
889
- // Filter by original index BEFORE remapping to avoid incorrectly
890
- // removing columns that remap to the dropped column's position.
891
- const updatedColumns = oldSchema.columns.filter((_, idx) => idx !== colIndex);
892
- const updatedPkDef = oldSchema.primaryKeyDefinition
893
- .filter(def => def.index !== colIndex)
894
- .map(def => ({
895
- ...def,
896
- index: def.index > colIndex ? def.index - 1 : def.index,
897
- }));
898
- const updatedIndexes = (oldSchema.indexes || [])
899
- .map(idx => ({
900
- ...idx,
901
- columns: idx.columns
902
- .filter(ic => ic.index !== colIndex)
903
- .map(ic => ({ ...ic, index: ic.index > colIndex ? ic.index - 1 : ic.index })),
904
- }))
905
- .filter(idx => idx.columns.length > 0);
906
- // Prune any UNIQUE constraint over the dropped column, mirroring the index
907
- // filtering above. Store-backed UNIQUE is enforced by a full scan over
908
- // `uniqueConstraints`, so a stranded constraint whose column index dangles past
909
- // the column array would break the next insert's validation (and the persisted
910
- // DDL). A UNIQUE that includes the dropped column is removed outright; remaining
911
- // constraints have their column indices shifted to track the removed slot. This
912
- // also covers the engine's ADD COLUMN + inline-UNIQUE revert, which drops the
913
- // just-added (uniquely-constrained) column.
914
- const updatedUniqueConstraints = (oldSchema.uniqueConstraints ?? [])
915
- .filter(uc => !uc.columns.includes(colIndex))
916
- .map(uc => ({ ...uc, columns: Object.freeze(uc.columns.map(i => i > colIndex ? i - 1 : i)) }));
917
- const updatedSchema = {
918
- ...oldSchema,
919
- columns: Object.freeze(updatedColumns),
920
- columnIndexMap: buildColumnIndexMap(updatedColumns),
921
- primaryKeyDefinition: Object.freeze(updatedPkDef),
922
- indexes: Object.freeze(updatedIndexes),
923
- uniqueConstraints: updatedUniqueConstraints.length > 0
924
- ? Object.freeze(updatedUniqueConstraints)
925
- : undefined,
926
- };
927
- // Migrate rows: remove the dropped column slot
928
- const remap = buildColumnRemap(oldSchema.columns.map(c => c.name), updatedColumns.map(c => c.name));
929
- await table.migrateRows(remap, null);
930
- // Update table schema and persist DDL
931
- table.updateSchema(updatedSchema);
932
- await this.saveTableDDL(updatedSchema);
933
- this.eventEmitter?.emitSchemaChange({
934
- type: 'alter',
935
- objectType: 'table',
936
- schemaName,
937
- objectName: tableName,
938
- });
939
- return updatedSchema;
1052
+ case 'addColumn':
1053
+ updated = await this.alterAddColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull);
1054
+ break;
1055
+ case 'dropColumn':
1056
+ updated = await this.alterDropColumn(schemaName, tableName, table, oldSchema, change);
1057
+ break;
1058
+ case 'renameColumn':
1059
+ updated = await this.alterRenameColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull);
1060
+ break;
1061
+ case 'alterPrimaryKey':
1062
+ updated = await this.alterPrimaryKeyChange(db, schemaName, tableName, table, oldSchema, change);
1063
+ break;
1064
+ case 'addConstraint':
1065
+ updated = await this.alterAddConstraint(db, schemaName, tableName, table, oldSchema, change, rows);
1066
+ break;
1067
+ case 'dropConstraint':
1068
+ updated = await this.alterDropConstraint(schemaName, tableName, table, oldSchema, change);
1069
+ break;
1070
+ case 'renameConstraint':
1071
+ updated = await this.alterRenameConstraint(schemaName, tableName, table, oldSchema, change);
1072
+ break;
1073
+ case 'alterColumn':
1074
+ updated = await this.alterColumnChange(db, schemaName, tableName, table, oldSchema, change, rows);
1075
+ break;
1076
+ default: {
1077
+ const _exhaustive = change;
1078
+ void _exhaustive;
1079
+ throw new QuereusError(`Unhandled ALTER TABLE change type '${change.type}'`, StatusCode.INTERNAL);
940
1080
  }
941
- case 'renameColumn': {
942
- if (!change.newColumnDefAst) {
943
- throw new QuereusError('RENAME COLUMN requires a new column definition AST', StatusCode.INTERNAL);
944
- }
945
- const oldNameLower = change.oldName.toLowerCase();
946
- const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === oldNameLower);
947
- if (colIndex === -1) {
948
- throw new QuereusError(`Column '${change.oldName}' not found.`, StatusCode.ERROR);
949
- }
950
- const newColSchema = columnDefToSchema(change.newColumnDefAst, defaultNotNull);
951
- const updatedColumns = oldSchema.columns.map((c, i) => i === colIndex ? newColSchema : c);
952
- const updatedIndexes = (oldSchema.indexes || []).map(idx => ({
953
- ...idx,
954
- columns: idx.columns.map(ic => ic.index === colIndex ? { ...ic, name: change.newName } : ic),
955
- }));
956
- const updatedSchema = {
957
- ...oldSchema,
958
- columns: Object.freeze(updatedColumns),
959
- columnIndexMap: buildColumnIndexMap(updatedColumns),
960
- indexes: Object.freeze(updatedIndexes),
961
- };
962
- // Rename is schema-only no row migration needed
963
- table.updateSchema(updatedSchema);
964
- await this.saveTableDDL(updatedSchema);
965
- this.eventEmitter?.emitSchemaChange({
966
- type: 'alter',
967
- objectType: 'table',
968
- schemaName,
969
- objectName: tableName,
970
- });
971
- return updatedSchema;
1081
+ }
1082
+ // Move the physical `_uc_*` index stores to match the post-ALTER constraint set:
1083
+ // the SCHEMA entries were re-materialized by the arm's `table.updateSchema`, but a
1084
+ // newly-added constraint's store must be BUILT and a dropped/renamed one's TORN
1085
+ // DOWN. `oldSchema` carries the pre-ALTER `uniqueConstraints`; `table.getSchema()`
1086
+ // the post-ALTER set. A no-op when the implicit-index name set is unchanged (the
1087
+ // common case, incl. PK/collation/type ALTERs whose physical re-encode is already
1088
+ // handled by `rebuildSecondaryIndexes`).
1089
+ await this.reconcileImplicitUniqueIndexStores(db, schemaName, tableName, table, oldSchema);
1090
+ return updated;
1091
+ }
1092
+ /**
1093
+ * Build / tear down the physical index stores backing implicit UNIQUE indexes
1094
+ * (`_uc_*`) after an ALTER, by diffing the implicit-index NAMES of the old vs new
1095
+ * constraint sets:
1096
+ * - a name newly PRESENT (ADD CONSTRAINT UNIQUE, or the target half of a
1097
+ * RENAME CONSTRAINT / rename of an unnamed UC's column) has its physical store
1098
+ * populated from this module's effective rows;
1099
+ * - a name newly ABSENT (DROP CONSTRAINT UNIQUE, or the source half of a rename)
1100
+ * has its store torn down, so a later re-ADD does not reopen stale entries.
1101
+ *
1102
+ * The SCHEMA entry itself is materialized/removed by `withImplicitUniqueIndexes`
1103
+ * on the arm's `table.updateSchema`; only the physical store needs this. Runs after
1104
+ * every ALTER but is a no-op whenever the implicit-index name set is unchanged
1105
+ * (incl. PK / collation / data-type ALTERs, whose same-name physical re-encode is
1106
+ * already done by {@link rebuildSecondaryIndexes}).
1107
+ *
1108
+ * `oldSchema` carries the pre-ALTER `uniqueConstraints` (its `.indexes` may be
1109
+ * de-materialized — the diff reads `uniqueConstraints`, not `.indexes`).
1110
+ *
1111
+ * NOTE: the build populates from THIS module's own effective rows (committed + this
1112
+ * transaction's pending), never a wrapper's — mirroring `createIndex`. Under the
1113
+ * isolation layer the wrapper's rows are what ADD CONSTRAINT's validation judged;
1114
+ * the physical index legitimately fills from this module's committed rows and an
1115
+ * entry with no live row behind it is harmless (both readers resolve to the live
1116
+ * row). Duplicates were already rejected by `validateUniqueOverExistingRows` (ADD)
1117
+ * or cannot exist (RENAME leaves data unchanged), so the in-pass check is skipped.
1118
+ */
1119
+ async reconcileImplicitUniqueIndexStores(db, schemaName, tableName, table, oldSchema) {
1120
+ // Diff the constraint sets by implicit-index NAME (derived from `uniqueConstraints`,
1121
+ // so the engine-facing schemas work directly); resolve the index SHAPE to build from
1122
+ // the materialized enforcement schema.
1123
+ const newSchema = table.getSchema();
1124
+ const newMaterialized = table.getMaterializedSchema();
1125
+ const oldNames = implicitUniqueIndexNameMap(oldSchema);
1126
+ const newNames = implicitUniqueIndexNameMap(newSchema);
1127
+ // Tear down each store whose implicit index no longer exists.
1128
+ for (const [lower, name] of oldNames) {
1129
+ if (!newNames.has(lower)) {
1130
+ await this.tearDownImplicitUniqueIndexStore(schemaName, tableName, table, name);
972
1131
  }
973
- case 'alterPrimaryKey': {
974
- const newPkColumns = change.newPkColumns;
975
- const updatedSchema = {
976
- ...oldSchema,
977
- primaryKeyDefinition: Object.freeze(newPkColumns.map(pk => ({ index: pk.index, desc: pk.desc }))),
978
- };
979
- // Re-key the data store. Throws CONSTRAINT on duplicates without
980
- // mutating the store, giving us all-or-nothing semantics for the
981
- // validation phase.
982
- await table.rekeyRows(newPkColumns);
983
- // Secondary index keys embed the PK suffix clear + rebuild every
984
- // index against the now-rekeyed data store.
985
- await this.rebuildSecondaryIndexes(schemaName, tableName, tableKey, table, updatedSchema);
986
- table.updateSchema(updatedSchema);
987
- await this.saveTableDDL(updatedSchema);
988
- this.eventEmitter?.emitSchemaChange({
989
- type: 'alter',
990
- objectType: 'table',
991
- schemaName,
992
- objectName: tableName,
993
- });
994
- return updatedSchema;
1132
+ }
1133
+ // Build each newly-materialized implicit index's store from effective rows.
1134
+ for (const [lower, name] of newNames) {
1135
+ if (oldNames.has(lower))
1136
+ continue;
1137
+ const indexSchema = (newMaterialized.indexes ?? []).find(ix => ix.name === name);
1138
+ if (!indexSchema)
1139
+ continue; // defensive name is derived from the same UC set
1140
+ // NOTE: no `assertStoreNameFree` here (unlike explicit `createIndex`): the name
1141
+ // is engine-derived and deterministic, and the lazy first-write build path in
1142
+ // `updateSecondaryIndexes` does not assert eitherkeep the two consistent.
1143
+ // NOTE: no teardown-on-failure wrapper (unlike `createIndex`): an IO error mid-build
1144
+ // leaves a partial `_uc_*` store while the constraint schema is already committed, so
1145
+ // enforcement could under-report until the store is rebuilt. Tolerated because the
1146
+ // build has no in-pass dup check (skipDuplicateCheck below) so the only failure is a
1147
+ // genuine IO error — the same non-atomicity `rebuildSecondaryIndexes` documents; a
1148
+ // re-run / reopen rebuilds. Add a try/catch teardown if this ever bites.
1149
+ const keyCollation = (table.getConfig().collation || 'NOCASE').toUpperCase();
1150
+ const indexStore = await this.provider.getIndexStore(schemaName, tableName, name);
1151
+ 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)
1152
+ table.getConfig().maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES);
1153
+ }
1154
+ }
1155
+ /**
1156
+ * The physical index-store names a table owns for DROP / RENAME TABLE to relocate
1157
+ * or reclaim: the MATERIALIZED index set — every explicit `CREATE INDEX` PLUS the
1158
+ * hidden `_uc_*` realizing each plain UNIQUE. Reading only `getSchema().indexes`
1159
+ * (the engine-facing schema, which carries no `_uc_*`) would strand a `_uc_*` store
1160
+ * on disk after DROP TABLE, and — worse — after RENAME TABLE leave the renamed
1161
+ * table seeking a freshly-created EMPTY `_uc_*` store, silently accepting a
1162
+ * duplicate of a pre-rename row. Prefers the cached StoreTable's own materialized
1163
+ * schema; falls back to materializing the schema-manager copy when the instance is
1164
+ * already gone.
1165
+ */
1166
+ materializedIndexNames(table, fallback) {
1167
+ const schema = table
1168
+ ? table.getMaterializedSchema()
1169
+ : (fallback ? withImplicitUniqueIndexes(fallback) : undefined);
1170
+ return (schema?.indexes ?? []).map(i => i.name);
1171
+ }
1172
+ /** Close + delete the physical store of an implicit UNIQUE index. Mirrors `dropIndex`. */
1173
+ async tearDownImplicitUniqueIndexStore(schemaName, tableName, table, indexName) {
1174
+ await table.releaseIndexStore(indexName);
1175
+ if (this.provider.deleteIndexStore) {
1176
+ await this.provider.deleteIndexStore(schemaName, tableName, indexName);
1177
+ }
1178
+ else {
1179
+ await this.provider.closeIndexStore(schemaName, tableName, indexName);
1180
+ }
1181
+ }
1182
+ /**
1183
+ * ADD COLUMN arm of {@link alterTable}: append the new column, eagerly migrate
1184
+ * each row (literal or per-row backfill), and persist. Behavior-preserving
1185
+ * extraction of the former `switch` arm.
1186
+ */
1187
+ async alterAddColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull) {
1188
+ // Honor the session `default_collation` for an ADD COLUMN that omits an
1189
+ // explicit COLLATE, matching the CREATE path so an ADD-COLUMN-ed text column
1190
+ // gets the same collation a CREATE-d one would. The persisted DDL re-emits an
1191
+ // explicit COLLATE for any non-BINARY collation, so reopen stays stable.
1192
+ const newColSchema = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'), (n) => db.isCollationRegistered(n));
1193
+ // Extract default value from column def constraints. Use the shared
1194
+ // `tryFoldLiteral` helper so signed numerics like `-123.0`
1195
+ // (a UnaryExpr in the AST) are recognized — matching the
1196
+ // memory-mode path and the engine-level ALTER validation.
1197
+ let defaultValue = null;
1198
+ const defaultConstraint = change.columnDef.constraints?.find(c => c.type === 'default');
1199
+ if (defaultConstraint?.expr) {
1200
+ const folded = tryFoldLiteral(defaultConstraint.expr);
1201
+ if (folded !== undefined) {
1202
+ defaultValue = folded;
995
1203
  }
996
- case 'addConstraint': {
997
- const constraint = change.constraint;
998
- let updatedSchema;
999
- if (constraint.type === 'unique') {
1000
- // Store enforces inline UNIQUE by full-scan over `uniqueConstraints`
1001
- // (no separate index store), so there is nothing physical to build
1002
- // but we must validate the existing rows before persisting.
1003
- const uc = buildUniqueConstraintSchema(constraint, oldSchema.columnIndexMap);
1004
- const dataStore = await this.getStore(tableKey, table.getConfig());
1005
- await this.validateUniqueOverExistingRows(dataStore, oldSchema, uc);
1006
- updatedSchema = {
1007
- ...oldSchema,
1008
- uniqueConstraints: Object.freeze([...(oldSchema.uniqueConstraints ?? []), uc]),
1009
- };
1010
- }
1011
- else if (constraint.type === 'foreignKey') {
1012
- const fk = buildForeignKeyConstraintSchema(constraint, oldSchema.columnIndexMap, oldSchema.name, oldSchema.schemaName);
1013
- updatedSchema = {
1014
- ...oldSchema,
1015
- foreignKeys: Object.freeze([...(oldSchema.foreignKeys ?? []), fk]),
1016
- };
1017
- // Pragma-gated existing-row validation; throws before persistence on an orphan.
1018
- await validateForeignKeyOverExistingRows(db, updatedSchema, fk);
1019
- }
1020
- else if (constraint.type === 'check') {
1021
- // Schema-only: a CHECK has no physical structure and (matching the
1022
- // engine's prior in-emitter behavior) no existing-row scan. Routing it
1023
- // here — rather than catalog-only — keeps the persisted DDL and the
1024
- // connected-table schema in lock-step so DROP/RENAME CONSTRAINT resolve it.
1025
- const check = buildCheckConstraintSchema(constraint, oldSchema.checkConstraints.length);
1026
- updatedSchema = {
1027
- ...oldSchema,
1028
- checkConstraints: Object.freeze([...oldSchema.checkConstraints, check]),
1029
- };
1030
- }
1031
- else {
1032
- throw new QuereusError(`Store table ADD CONSTRAINT does not support constraint type '${constraint.type}'`, StatusCode.UNSUPPORTED);
1033
- }
1034
- table.updateSchema(updatedSchema);
1035
- await this.saveTableDDL(updatedSchema);
1036
- this.eventEmitter?.emitSchemaChange({
1037
- type: 'alter',
1038
- objectType: 'table',
1039
- schemaName,
1040
- objectName: tableName,
1041
- });
1042
- return updatedSchema;
1204
+ }
1205
+ // A non-foldable DEFAULT (e.g. `new.<col>`) backfills each existing row from
1206
+ // its own value via the engine-supplied evaluator (mirrors the memory path).
1207
+ const backfillEvaluator = change.backfillEvaluator;
1208
+ // Refuse NOT NULL without a usable DEFAULT on a non-empty table
1209
+ // (SQLite-compatible). A per-row evaluator IS usable its NOT NULL is enforced
1210
+ // per row during migration so it is exempt from this no-default rejection.
1211
+ if (newColSchema.notNull && defaultValue === null && !backfillEvaluator) {
1212
+ if (await table.hasAnyRows()) {
1213
+ throw new QuereusError(`Cannot add NOT NULL column '${newColSchema.name}' to non-empty table `
1214
+ + `'${schemaName}.${tableName}' without a DEFAULT value`, StatusCode.CONSTRAINT);
1043
1215
  }
1044
- case 'dropConstraint': {
1045
- // Schema-only catalog rewrite: store-backed UNIQUE enforcement is a
1046
- // full-scan over `uniqueConstraints` (no separate index store for an
1047
- // inline UNIQUE), so dropping the constraint stops enforcement with no
1048
- // physical teardown. A UNIQUE derived from a CREATE UNIQUE INDEX is
1049
- // rejected upstream (drop the index instead), so we never strand a store.
1050
- const constraintClass = resolveNamedConstraintClass(oldSchema, change.constraintName);
1051
- const lower = change.constraintName.toLowerCase();
1052
- let updatedSchema;
1053
- if (constraintClass === 'check') {
1054
- updatedSchema = {
1055
- ...oldSchema,
1056
- checkConstraints: Object.freeze(oldSchema.checkConstraints.filter(c => c.name?.toLowerCase() !== lower)),
1057
- };
1058
- }
1059
- else if (constraintClass === 'foreignKey') {
1060
- const remaining = (oldSchema.foreignKeys ?? []).filter(c => c.name?.toLowerCase() !== lower);
1061
- updatedSchema = { ...oldSchema, foreignKeys: remaining.length > 0 ? Object.freeze(remaining) : undefined };
1062
- }
1063
- else {
1064
- const remaining = (oldSchema.uniqueConstraints ?? []).filter(c => c.name?.toLowerCase() !== lower);
1065
- updatedSchema = { ...oldSchema, uniqueConstraints: remaining.length > 0 ? Object.freeze(remaining) : undefined };
1066
- }
1067
- table.updateSchema(updatedSchema);
1068
- await this.saveTableDDL(updatedSchema);
1069
- this.eventEmitter?.emitSchemaChange({
1070
- type: 'alter',
1071
- objectType: 'table',
1072
- schemaName,
1073
- objectName: tableName,
1074
- });
1075
- return updatedSchema;
1216
+ }
1217
+ // Build updated schema: append new column
1218
+ const updatedColumns = Object.freeze([...oldSchema.columns, newColSchema]);
1219
+ const updatedSchema = {
1220
+ ...oldSchema,
1221
+ columns: updatedColumns,
1222
+ columnIndexMap: buildColumnIndexMap(updatedColumns),
1223
+ };
1224
+ // Extract any column-level CHECK / FK to persist (see the persist block below).
1225
+ // Hoisted above the row migration so a malformed constraint (e.g. a multi-column
1226
+ // FK on a single ADD COLUMN, which `extractColumnLevelForeignKeys` rejects) throws
1227
+ // BEFORE any rows are migrated or the in-memory schema is swapped — validate-before-
1228
+ // mutate, matching the engine's ordering in `runAddColumn`.
1229
+ const newCheckConstraints = extractColumnLevelCheckConstraints(change.columnDef);
1230
+ const newForeignKeys = extractColumnLevelForeignKeys(change.columnDef, schemaName);
1231
+ // Physical rewrite ahead: flush the module's buffered writes so `migrateRows`
1232
+ // (a committed-store scan + batch) sees this transaction's rows and re-encodes
1233
+ // them under the new column layout. See {@link ddlCommitPendingOps}. Placed
1234
+ // after every throw-only check above — the NOT NULL rejection reads effectively
1235
+ // (`hasAnyRows`) and `extractColumnLevel*` reads no rows — so a rejected ALTER
1236
+ // leaves the enclosing transaction intact.
1237
+ await this.ddlCommitPendingOps();
1238
+ // Migrate rows: append the new column's value — a single literal default, or a
1239
+ // per-row value derived from the existing row when a backfill evaluator is set.
1240
+ const remap = buildColumnRemap(oldSchema.columns.map(c => c.name), updatedColumns.map(c => c.name));
1241
+ await table.migrateRows(remap, defaultValue, backfillEvaluator
1242
+ ? { evaluator: backfillEvaluator, notNull: newColSchema.notNull, columnName: newColSchema.name }
1243
+ : undefined);
1244
+ // Update table schema (column-only) and persist DDL.
1245
+ //
1246
+ // The engine's `runAddColumn` re-merges the column-level FK/CHECK extracted
1247
+ // from `columnDef.constraints` into the LIVE in-memory schema AFTER this hook
1248
+ // returns, so the schema handed back to it must stay column-only — returning a
1249
+ // constrained schema would double the constraint in the live SchemaManager (and,
1250
+ // on the next persist, in the DDL). But that engine-side merge is in-memory only:
1251
+ // it never reaches the catalog, so persistence must carry the column-level
1252
+ // CHECK/FK itself or they vanish on `rehydrateCatalog`. Build a separate
1253
+ // `persistedSchema` for `saveTableDDL` when (and only when) the column declares
1254
+ // such a constraint; the common path persists `updatedSchema` unchanged. This is
1255
+ // unconditional on the default kind — a per-row (evaluator) DEFAULT extracts the
1256
+ // same AST constraints as a literal one.
1257
+ table.updateSchema(updatedSchema);
1258
+ let persistedSchema = updatedSchema;
1259
+ if (newCheckConstraints.length > 0 || newForeignKeys.length > 0) {
1260
+ // The new column is appended last; resolve each FK's child column to its index
1261
+ // (matching how the engine resolves `resolvedForeignKeys` via columnIndexMap).
1262
+ const newColIdx = updatedColumns.length - 1;
1263
+ const resolvedForeignKeys = newForeignKeys.map(fk => ({ ...fk, columns: Object.freeze([newColIdx]) }));
1264
+ persistedSchema = {
1265
+ ...updatedSchema,
1266
+ checkConstraints: Object.freeze([...updatedSchema.checkConstraints, ...newCheckConstraints]),
1267
+ foreignKeys: Object.freeze([...(updatedSchema.foreignKeys ?? []), ...resolvedForeignKeys]),
1268
+ };
1269
+ }
1270
+ await this.saveTableDDL(persistedSchema);
1271
+ this.eventEmitter?.emitSchemaChange({
1272
+ type: 'alter',
1273
+ objectType: 'table',
1274
+ schemaName,
1275
+ objectName: tableName,
1276
+ });
1277
+ return updatedSchema;
1278
+ }
1279
+ /** DROP COLUMN arm of {@link alterTable}: drop the column slot, reindex PK / indexes /
1280
+ * UNIQUE, migrate rows, and persist. Behavior-preserving extraction. */
1281
+ async alterDropColumn(schemaName, tableName, table, oldSchema, change) {
1282
+ const colNameLower = change.columnName.toLowerCase();
1283
+ const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === colNameLower);
1284
+ if (colIndex === -1) {
1285
+ throw new QuereusError(`Column '${change.columnName}' not found.`, StatusCode.ERROR);
1286
+ }
1287
+ // Build updated schema: remove column and reindex PK/indexes
1288
+ // Filter by original index BEFORE remapping to avoid incorrectly
1289
+ // removing columns that remap to the dropped column's position.
1290
+ const updatedColumns = oldSchema.columns.filter((_, idx) => idx !== colIndex);
1291
+ const updatedPkDef = oldSchema.primaryKeyDefinition
1292
+ .filter(def => def.index !== colIndex)
1293
+ .map(def => ({
1294
+ ...def,
1295
+ index: def.index > colIndex ? def.index - 1 : def.index,
1296
+ }));
1297
+ const updatedIndexes = (oldSchema.indexes || [])
1298
+ .map(idx => ({
1299
+ ...idx,
1300
+ columns: idx.columns
1301
+ .filter(ic => ic.index !== colIndex)
1302
+ .map(ic => ({ ...ic, index: ic.index > colIndex ? ic.index - 1 : ic.index })),
1303
+ }))
1304
+ .filter(idx => idx.columns.length > 0);
1305
+ // Prune any UNIQUE constraint over the dropped column, mirroring the index
1306
+ // filtering above. Store-backed UNIQUE is enforced by a full scan over
1307
+ // `uniqueConstraints`, so a stranded constraint whose column index dangles past
1308
+ // the column array would break the next insert's validation (and the persisted
1309
+ // DDL). A UNIQUE that includes the dropped column is removed outright; remaining
1310
+ // constraints have their column indices shifted to track the removed slot. This
1311
+ // also covers the engine's ADD COLUMN + inline-UNIQUE revert, which drops the
1312
+ // just-added (uniquely-constrained) column.
1313
+ const updatedUniqueConstraints = (oldSchema.uniqueConstraints ?? [])
1314
+ .filter(uc => !uc.columns.includes(colIndex))
1315
+ .map(uc => ({ ...uc, columns: Object.freeze(uc.columns.map(i => i > colIndex ? i - 1 : i)) }));
1316
+ const updatedSchema = {
1317
+ ...oldSchema,
1318
+ columns: Object.freeze(updatedColumns),
1319
+ columnIndexMap: buildColumnIndexMap(updatedColumns),
1320
+ primaryKeyDefinition: Object.freeze(updatedPkDef),
1321
+ indexes: Object.freeze(updatedIndexes),
1322
+ uniqueConstraints: updatedUniqueConstraints.length > 0
1323
+ ? Object.freeze(updatedUniqueConstraints)
1324
+ : undefined,
1325
+ };
1326
+ // Physical rewrite ahead — flush buffered writes so `migrateRows` re-encodes
1327
+ // this transaction's rows too. See {@link ddlCommitPendingOps}.
1328
+ await this.ddlCommitPendingOps();
1329
+ // Migrate rows: remove the dropped column slot
1330
+ const remap = buildColumnRemap(oldSchema.columns.map(c => c.name), updatedColumns.map(c => c.name));
1331
+ await table.migrateRows(remap, null);
1332
+ // Update table schema and persist DDL
1333
+ table.updateSchema(updatedSchema);
1334
+ await this.saveTableDDL(updatedSchema);
1335
+ this.eventEmitter?.emitSchemaChange({
1336
+ type: 'alter',
1337
+ objectType: 'table',
1338
+ schemaName,
1339
+ objectName: tableName,
1340
+ });
1341
+ return updatedSchema;
1342
+ }
1343
+ /** RENAME COLUMN arm of {@link alterTable}: schema-only rewrite (columns, indexes, self-FK,
1344
+ * in-place predicate / CHECK AST rewrite) and persist. Behavior-preserving extraction. */
1345
+ async alterRenameColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull) {
1346
+ if (!change.newColumnDefAst) {
1347
+ throw new QuereusError('RENAME COLUMN requires a new column definition AST', StatusCode.INTERNAL);
1348
+ }
1349
+ const oldNameLower = change.oldName.toLowerCase();
1350
+ const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === oldNameLower);
1351
+ if (colIndex === -1) {
1352
+ throw new QuereusError(`Column '${change.oldName}' not found.`, StatusCode.ERROR);
1353
+ }
1354
+ const newColSchema = columnDefToSchema(change.newColumnDefAst, defaultNotNull, 'BINARY', (n) => db.isCollationRegistered(n));
1355
+ const updatedColumns = oldSchema.columns.map((c, i) => i === colIndex ? newColSchema : c);
1356
+ const updatedIndexes = (oldSchema.indexes || []).map(idx => ({
1357
+ ...idx,
1358
+ columns: idx.columns.map(ic => ic.index === colIndex ? { ...ic, name: change.newName } : ic),
1359
+ }));
1360
+ const updatedSchema = {
1361
+ ...oldSchema,
1362
+ columns: Object.freeze(updatedColumns),
1363
+ columnIndexMap: buildColumnIndexMap(updatedColumns),
1364
+ indexes: Object.freeze(updatedIndexes),
1365
+ // A self-referencing FK names the renamed column in `referencedColumnNames`
1366
+ // and is persisted by name; the engine rewrites it only in the post-hook
1367
+ // pass. Pure copy — no rollback needed, this schema is only adopted on the
1368
+ // success path.
1369
+ foreignKeys: renameColumnInSelfForeignKeys(oldSchema.foreignKeys, schemaName, tableName, change.oldName, change.newName),
1370
+ };
1371
+ // A partial index's WHERE clause and a CHECK constraint's expression both
1372
+ // still name the OLD column: the engine's `propagateColumnRename` pass runs
1373
+ // only after this hook returns. Persisting now would durably write a bundle
1374
+ // naming a column the table no longer has, and only the later propagation's
1375
+ // `table_modified` event would correct it — a crash in between leaves the
1376
+ // catalog un-rehydratable. So rewrite first, in place: each `Expression` is
1377
+ // shared by reference with the catalog's `TableSchema` and, for a unique
1378
+ // partial index, with the `derivedFromIndex` UNIQUE constraint, so one
1379
+ // rewrite covers all holders and makes the later propagation pass a no-op.
1380
+ //
1381
+ // The rewrites are the first statements in the `try`, and each walks its
1382
+ // collection one item at a time, so a throw anywhere — including partway
1383
+ // through a walk — must reverse them: restoring `oldSchema` cannot, since the
1384
+ // ASTs are shared. Reversing is a no-op wherever nothing names the new column,
1385
+ // and the engine has already rejected a rename onto an existing column name,
1386
+ // so the reverse pass cannot collide with an expression that legitimately
1387
+ // named it.
1388
+ const resolveColumnInSource = (s, t, col) => db.schemaManager.getSchema(s)?.getTable(t)?.columnIndexMap.has(col.toLowerCase()) ?? false;
1389
+ const rewriteColumn = (from, to) => {
1390
+ renameColumnInIndexPredicates(updatedIndexes, tableName, from, to, schemaName, resolveColumnInSource);
1391
+ renameColumnInCheckConstraints(oldSchema.checkConstraints, tableName, from, to, schemaName, resolveColumnInSource);
1392
+ };
1393
+ try {
1394
+ rewriteColumn(change.oldName, change.newName);
1395
+ // Rename is schema-only — no row migration needed
1396
+ table.updateSchema(updatedSchema);
1397
+ await this.saveTableDDL(updatedSchema);
1398
+ }
1399
+ catch (e) {
1400
+ rewriteColumn(change.newName, change.oldName);
1401
+ throw e;
1402
+ }
1403
+ this.eventEmitter?.emitSchemaChange({
1404
+ type: 'alter',
1405
+ objectType: 'table',
1406
+ schemaName,
1407
+ objectName: tableName,
1408
+ });
1409
+ return updatedSchema;
1410
+ }
1411
+ /** ALTER PRIMARY KEY arm of {@link alterTable}: physically re-key the data store, rebuild
1412
+ * secondary indexes, and persist. Behavior-preserving extraction. */
1413
+ async alterPrimaryKeyChange(db, schemaName, tableName, table, oldSchema, change) {
1414
+ const newPkColumns = change.newPkColumns;
1415
+ const updatedSchema = {
1416
+ ...oldSchema,
1417
+ primaryKeyDefinition: Object.freeze(newPkColumns.map(pk => ({ index: pk.index, desc: pk.desc }))),
1418
+ };
1419
+ // Physical re-key ahead — flush buffered writes so every live row is
1420
+ // re-keyed and no stale-schema op replays over the rewritten store.
1421
+ // `rekeyRows`' duplicate-key pass runs against the flushed store, so a
1422
+ // pending insert that collides under the new PK is caught. See
1423
+ // {@link ddlCommitPendingOps} for the transaction consequences.
1424
+ await this.ddlCommitPendingOps();
1425
+ // Re-key the data store. Throws CONSTRAINT on duplicates without
1426
+ // mutating the store, giving us all-or-nothing semantics for the
1427
+ // validation phase.
1428
+ await table.rekeyRows(newPkColumns);
1429
+ // Secondary index keys embed the PK suffix — clear + rebuild every
1430
+ // index against the now-rekeyed data store. Rebuild the MATERIALIZED index list
1431
+ // so each implicit `_uc_*` PK suffix is re-encoded too (`updatedSchema` is built
1432
+ // off the de-materialized `oldSchema`, so it carries no `_uc_*` on its own).
1433
+ await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver());
1434
+ table.updateSchema(updatedSchema);
1435
+ await this.saveTableDDL(updatedSchema);
1436
+ this.eventEmitter?.emitSchemaChange({
1437
+ type: 'alter',
1438
+ objectType: 'table',
1439
+ schemaName,
1440
+ objectName: tableName,
1441
+ });
1442
+ return updatedSchema;
1443
+ }
1444
+ /** ADD CONSTRAINT arm of {@link alterTable}: validate existing rows as the constraint kind
1445
+ * (UNIQUE / FOREIGN KEY / CHECK) requires, then persist. Behavior-preserving extraction. */
1446
+ async alterAddConstraint(db, schemaName, tableName, table, oldSchema, change, rows) {
1447
+ const constraint = change.constraint;
1448
+ let updatedSchema;
1449
+ if (constraint.type === 'unique') {
1450
+ // Validate the existing rows against the new UNIQUE before persisting. The
1451
+ // implicit `_uc_*` index that backs enforcement is materialized into the
1452
+ // StoreTable schema by `table.updateSchema` below, and its PHYSICAL store is
1453
+ // built by `reconcileImplicitUniqueIndexStores` after this arm returns — the
1454
+ // validation here runs first, so a CONSTRAINT rejection never leaves a store
1455
+ // behind.
1456
+ //
1457
+ // No `ddlCommitPendingOps()` here (unlike the row-rewriting arms): this
1458
+ // writes no rows, and the validation scan already reads effectively, so
1459
+ // the transaction survives a CONSTRAINT rejection.
1460
+ const uc = buildUniqueConstraintSchema(constraint, oldSchema.columnIndexMap);
1461
+ await this.validateUniqueOverExistingRows(rows ? rows() : rowsFromEntries(table.iterateEffectiveEntries(buildFullScanBounds())), oldSchema, uc, db.getKeyNormalizerResolver());
1462
+ updatedSchema = {
1463
+ ...oldSchema,
1464
+ uniqueConstraints: Object.freeze([...(oldSchema.uniqueConstraints ?? []), uc]),
1465
+ };
1466
+ }
1467
+ else if (constraint.type === 'foreignKey') {
1468
+ const fk = buildForeignKeyConstraintSchema(constraint, oldSchema.columnIndexMap, oldSchema.name, oldSchema.schemaName);
1469
+ updatedSchema = {
1470
+ ...oldSchema,
1471
+ foreignKeys: Object.freeze([...(oldSchema.foreignKeys ?? []), fk]),
1472
+ };
1473
+ // Pragma-gated existing-row validation; throws before persistence on an orphan.
1474
+ await validateForeignKeyOverExistingRows(db, updatedSchema, fk);
1475
+ }
1476
+ else if (constraint.type === 'check') {
1477
+ // Schema-only: a CHECK has no physical structure and (matching the
1478
+ // engine's prior in-emitter behavior) no existing-row scan. Routing it
1479
+ // here — rather than catalog-only — keeps the persisted DDL and the
1480
+ // connected-table schema in lock-step so DROP/RENAME CONSTRAINT resolve it.
1481
+ const check = buildCheckConstraintSchema(constraint, oldSchema.checkConstraints.length);
1482
+ updatedSchema = {
1483
+ ...oldSchema,
1484
+ checkConstraints: Object.freeze([...oldSchema.checkConstraints, check]),
1485
+ };
1486
+ }
1487
+ else {
1488
+ throw new QuereusError(`Store table ADD CONSTRAINT does not support constraint type '${constraint.type}'`, StatusCode.UNSUPPORTED);
1489
+ }
1490
+ table.updateSchema(updatedSchema);
1491
+ await this.saveTableDDL(updatedSchema);
1492
+ this.eventEmitter?.emitSchemaChange({
1493
+ type: 'alter',
1494
+ objectType: 'table',
1495
+ schemaName,
1496
+ objectName: tableName,
1497
+ });
1498
+ return updatedSchema;
1499
+ }
1500
+ /** DROP CONSTRAINT arm of {@link alterTable}: schema-only catalog rewrite dropping a named
1501
+ * constraint, then persist. Behavior-preserving extraction. */
1502
+ async alterDropConstraint(schemaName, tableName, table, oldSchema, change) {
1503
+ // Schema-only catalog rewrite. Dropping a UNIQUE removes it from
1504
+ // `uniqueConstraints`, so `table.updateSchema` below de-materializes its implicit
1505
+ // `_uc_*` index and `reconcileImplicitUniqueIndexStores` (after this arm returns)
1506
+ // tears down the now-orphaned physical store — without which a later re-ADD would
1507
+ // reopen stale entries. A UNIQUE derived from a CREATE UNIQUE INDEX is rejected
1508
+ // upstream (drop the index instead), and has no `_uc_*` anyway.
1509
+ const constraintClass = resolveNamedConstraintClass(oldSchema, change.constraintName);
1510
+ const lower = change.constraintName.toLowerCase();
1511
+ let updatedSchema;
1512
+ if (constraintClass === 'check') {
1513
+ updatedSchema = {
1514
+ ...oldSchema,
1515
+ checkConstraints: Object.freeze(oldSchema.checkConstraints.filter(c => c.name?.toLowerCase() !== lower)),
1516
+ };
1517
+ }
1518
+ else if (constraintClass === 'foreignKey') {
1519
+ const remaining = (oldSchema.foreignKeys ?? []).filter(c => c.name?.toLowerCase() !== lower);
1520
+ updatedSchema = { ...oldSchema, foreignKeys: remaining.length > 0 ? Object.freeze(remaining) : undefined };
1521
+ }
1522
+ else {
1523
+ const remaining = (oldSchema.uniqueConstraints ?? []).filter(c => c.name?.toLowerCase() !== lower);
1524
+ updatedSchema = { ...oldSchema, uniqueConstraints: remaining.length > 0 ? Object.freeze(remaining) : undefined };
1525
+ }
1526
+ table.updateSchema(updatedSchema);
1527
+ await this.saveTableDDL(updatedSchema);
1528
+ this.eventEmitter?.emitSchemaChange({
1529
+ type: 'alter',
1530
+ objectType: 'table',
1531
+ schemaName,
1532
+ objectName: tableName,
1533
+ });
1534
+ return updatedSchema;
1535
+ }
1536
+ /** RENAME CONSTRAINT arm of {@link alterTable}: schema-only rename of a named constraint,
1537
+ * then persist. A renamed named-UNIQUE changes its implicit `_uc_*` index name, so
1538
+ * `reconcileImplicitUniqueIndexStores` (run after this arm) MOVES the physical store —
1539
+ * tears down the old-named store and rebuilds the new-named one from effective rows. */
1540
+ async alterRenameConstraint(schemaName, tableName, table, oldSchema, change) {
1541
+ const constraintClass = resolveNamedConstraintClass(oldSchema, change.oldName);
1542
+ const oldLower = change.oldName.toLowerCase();
1543
+ let updatedSchema;
1544
+ if (constraintClass === 'check') {
1545
+ updatedSchema = {
1546
+ ...oldSchema,
1547
+ checkConstraints: Object.freeze(oldSchema.checkConstraints.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
1548
+ };
1549
+ }
1550
+ else if (constraintClass === 'foreignKey') {
1551
+ updatedSchema = {
1552
+ ...oldSchema,
1553
+ foreignKeys: Object.freeze(oldSchema.foreignKeys.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
1554
+ };
1555
+ }
1556
+ else {
1557
+ updatedSchema = {
1558
+ ...oldSchema,
1559
+ uniqueConstraints: Object.freeze(oldSchema.uniqueConstraints.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
1560
+ };
1561
+ }
1562
+ table.updateSchema(updatedSchema);
1563
+ await this.saveTableDDL(updatedSchema);
1564
+ this.eventEmitter?.emitSchemaChange({
1565
+ type: 'alter',
1566
+ objectType: 'table',
1567
+ schemaName,
1568
+ objectName: tableName,
1569
+ });
1570
+ return updatedSchema;
1571
+ }
1572
+ /** ALTER COLUMN arm of {@link alterTable}: change one attribute (NOT NULL / data type /
1573
+ * default / collation), running the physical re-key + existing-row re-validation the
1574
+ * collation / PK paths require, then persist. Behavior-preserving extraction. */
1575
+ async alterColumnChange(db, schemaName, tableName, table, oldSchema, change, rows) {
1576
+ const colNameLower = change.columnName.toLowerCase();
1577
+ const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === colNameLower);
1578
+ if (colIndex === -1) {
1579
+ throw new QuereusError(`Column '${change.columnName}' not found.`, StatusCode.ERROR);
1580
+ }
1581
+ const oldCol = oldSchema.columns[colIndex];
1582
+ // Pull exactly one attribute from the change. Each sub-helper returns the new
1583
+ // column schema plus whether the collation bytes changed (`collationChanged` gates
1584
+ // the existing-row UNIQUE re-validation and PK re-key below), or null when the column
1585
+ // is already in the desired state — preserving the pre-refactor early exits
1586
+ // (SET NOT NULL no-op, SET COLLATE already-explicit) that leave the table and any
1587
+ // open transaction untouched.
1588
+ let attr;
1589
+ if (change.setNotNull !== undefined) {
1590
+ attr = await this.alterColumnSetNotNull(table, oldSchema, oldCol, colIndex, change, rows);
1591
+ }
1592
+ else if (change.setDataType !== undefined) {
1593
+ attr = await this.alterColumnSetDataType(table, oldCol, colIndex, change);
1594
+ }
1595
+ else if (change.setDefault !== undefined) {
1596
+ attr = { newCol: { ...oldCol, defaultValue: change.setDefault }, collationChanged: false };
1597
+ }
1598
+ else if (change.setCollation !== undefined) {
1599
+ attr = this.alterColumnSetCollation(oldCol, change, (n) => db.isCollationRegistered(n));
1600
+ }
1601
+ else {
1602
+ throw new QuereusError('ALTER COLUMN requires an attribute to change', StatusCode.INTERNAL);
1603
+ }
1604
+ if (attr === null) {
1605
+ return oldSchema;
1606
+ }
1607
+ const { newCol, collationChanged, valuesRewritten } = attr;
1608
+ const updatedColumns = oldSchema.columns.map((c, i) => i === colIndex ? newCol : c);
1609
+ // Mirror the memory module (MemoryTableManager.alterColumn): a per-column
1610
+ // collation change propagates into every index column ordering by this
1611
+ // column, so a `derivedFromIndex` UNIQUE re-keys its enforcement under the
1612
+ // new collation. StoreTable.uniqueEnforcementCollations reads the index's
1613
+ // per-column collation, so without this the index entry would stay stale
1614
+ // and the derived UNIQUE would keep enforcing the OLD collation after the
1615
+ // ALTER. Metadata-only: the store's index KEY bytes use the table-level key
1616
+ // collation K (see buildIndexEntries / updateSecondaryIndexes), so no index
1617
+ // entry re-encode is required for a non-PK column. An index column with an
1618
+ // explicit COLLATE is re-collated too — matching memory, which clobbers it
1619
+ // the same way (no surface preserves a differing index COLLATE across an
1620
+ // ALTER COLUMN SET COLLATE on its column).
1621
+ const updatedIndexes = (collationChanged && oldSchema.indexes)
1622
+ ? oldSchema.indexes.map(idx => ({
1623
+ ...idx,
1624
+ columns: idx.columns.map(ic => ic.index === colIndex ? { ...ic, collation: newCol.collation } : ic),
1625
+ }))
1626
+ : oldSchema.indexes;
1627
+ const updatedSchema = {
1628
+ ...oldSchema,
1629
+ columns: Object.freeze(updatedColumns),
1630
+ columnIndexMap: buildColumnIndexMap(updatedColumns),
1631
+ indexes: updatedIndexes ? Object.freeze(updatedIndexes) : updatedIndexes,
1632
+ };
1633
+ // SET COLLATE existing-row re-validation (Option A, non-PK UNIQUE): a new
1634
+ // per-column collation can make rows that were distinct under the old
1635
+ // collation collide. Re-scan every UNIQUE constraint covering the altered
1636
+ // column under the NEW collation (`updatedSchema` carries it). The first
1637
+ // collision throws CONSTRAINT BEFORE any mutation/persist, so the table is
1638
+ // left unchanged and writable (matches the ADD CONSTRAINT rollback shape).
1639
+ // The PK is intentionally excluded — it never appears in `uniqueConstraints`;
1640
+ // its physical re-key/re-validation is the `isPkColumn` block below.
1641
+ if (collationChanged) {
1642
+ const coveringConstraints = (updatedSchema.uniqueConstraints ?? [])
1643
+ .filter(uc => uc.columns.includes(colIndex));
1644
+ for (const uc of coveringConstraints) {
1645
+ // Fresh generator per constraint — an async generator is single-shot. An
1646
+ // `EffectiveRowSource` is re-callable for exactly this reason.
1647
+ await this.validateUniqueOverExistingRows(rows ? rows() : rowsFromEntries(table.iterateEffectiveEntries(buildFullScanBounds())), updatedSchema, uc, db.getKeyNormalizerResolver());
1076
1648
  }
1077
- case 'renameConstraint': {
1078
- const constraintClass = resolveNamedConstraintClass(oldSchema, change.oldName);
1079
- const oldLower = change.oldName.toLowerCase();
1080
- let updatedSchema;
1081
- if (constraintClass === 'check') {
1082
- updatedSchema = {
1083
- ...oldSchema,
1084
- checkConstraints: Object.freeze(oldSchema.checkConstraints.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
1085
- };
1086
- }
1087
- else if (constraintClass === 'foreignKey') {
1088
- updatedSchema = {
1089
- ...oldSchema,
1090
- foreignKeys: Object.freeze(oldSchema.foreignKeys.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
1091
- };
1092
- }
1093
- else {
1094
- updatedSchema = {
1095
- ...oldSchema,
1096
- uniqueConstraints: Object.freeze(oldSchema.uniqueConstraints.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
1097
- };
1098
- }
1099
- table.updateSchema(updatedSchema);
1100
- await this.saveTableDDL(updatedSchema);
1101
- this.eventEmitter?.emitSchemaChange({
1102
- type: 'alter',
1103
- objectType: 'table',
1104
- schemaName,
1105
- objectName: tableName,
1106
- });
1107
- return updatedSchema;
1649
+ }
1650
+ // SET COLLATE on a PRIMARY KEY member (Option B physical re-key): re-encode
1651
+ // every data-store key under the column's new key collation, then rebuild every
1652
+ // secondary index (its keys embed the PK suffix). `rekeyRows` validates in a
1653
+ // first pass and throws CONSTRAINT on a collision under the new collation WITHOUT
1654
+ // mutating the store — so a coarser collation that collapses two distinct PKs
1655
+ // (e.g. 'a'/'A' under BINARY→NOCASE) is rejected all-or-nothing, mirroring
1656
+ // ALTER PRIMARY KEY. Runs AFTER the non-PK UNIQUE re-validation above so both
1657
+ // throw-only checks precede the first store mutation. `updatedSchema.columns`
1658
+ // carries the new collation, so the new key bytes follow it.
1659
+ if (collationChanged && oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
1660
+ // Physical re-key ahead — flush buffered writes (see
1661
+ // {@link ddlCommitPendingOps}). Deliberately AFTER the non-PK UNIQUE
1662
+ // re-validation above: that check reads effectively and throws without
1663
+ // needing the flush, so it must keep the transaction alive.
1664
+ //
1665
+ // NOTE: `rekeyRows` detects PK collisions among THIS module's rows only. When a
1666
+ // wrapper module supplied `rows`, its staged rows are not in this store, so a
1667
+ // pending row that collides with a committed one under the new collation is
1668
+ // caught by neither side the wrapper's overlay enforces the PK among its own
1669
+ // rows, this store among its own. Closing it needs a PK-dedupe pass over `rows`
1670
+ // under the new collation here, before the re-key.
1671
+ await this.ddlCommitPendingOps();
1672
+ await table.rekeyRows(oldSchema.primaryKeyDefinition, updatedSchema.columns);
1673
+ // Materialize so the implicit `_uc_*` PK suffix is re-encoded under the new
1674
+ // key collation too (`updatedSchema` carries none on its own).
1675
+ await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver());
1676
+ }
1677
+ // SET DATA TYPE physical conversion / SET NOT NULL DEFAULT backfill rewrote stored
1678
+ // column values in place (same PK, new value) via mapRowsAtIndex. Secondary index KEY
1679
+ // bytes encode the indexed column VALUES, so any index covering this column still points
1680
+ // at the OLD value bytes until rebuilt — an index-backed lookup for the new value finds
1681
+ // nothing (mirrors the memory module's `valuesRewritten` rebuild in MemoryTableManager.
1682
+ // alterColumn). The sub-helper already ran ddlCommitPendingOps before its rewrite, so the
1683
+ // committed data store rebuildSecondaryIndexes reads is "everything live". Mutually
1684
+ // exclusive with the collation re-key above (distinct attributes), so no double rebuild.
1685
+ // Materialize so an implicit `_uc_*` over the rewritten column is rebuilt against the
1686
+ // new value bytes too.
1687
+ if (valuesRewritten) {
1688
+ await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver());
1689
+ }
1690
+ table.updateSchema(updatedSchema);
1691
+ await this.saveTableDDL(updatedSchema);
1692
+ this.eventEmitter?.emitSchemaChange({
1693
+ type: 'alter',
1694
+ objectType: 'table',
1695
+ schemaName,
1696
+ objectName: tableName,
1697
+ });
1698
+ return updatedSchema;
1699
+ }
1700
+ /**
1701
+ * SET NOT NULL / DROP NOT NULL sub-branch of {@link alterColumnChange}. Returns the
1702
+ * new column schema, or null when the column is already in the desired nullability
1703
+ * (the pre-refactor `return oldSchema` no-op). The NULL-backfill probe and rewrite
1704
+ * order (throw-only probe before {@link ddlCommitPendingOps}) is unchanged.
1705
+ *
1706
+ * `rows` is the wrapper-supplied effective row source (the isolation overlay). When present,
1707
+ * the reject-vs-backfill decision scans it instead of `table.rowsWithNullAtIndex`: behind the
1708
+ * isolation layer the issuer's pending inserts live in the wrapper's overlay, not this store,
1709
+ * so the store's own count would miss them and wrongly accept the ALTER. The committed-store
1710
+ * `mapRowsAtIndex` backfill is unchanged — the overlay-resident pending rows are the isolation
1711
+ * layer's job (its overlay migration), this store only owns its committed rows.
1712
+ */
1713
+ async alterColumnSetNotNull(table, oldSchema, oldCol, colIndex, change, rows) {
1714
+ let newCol;
1715
+ let valuesRewritten = false;
1716
+ if (change.setNotNull === true && !oldCol.notNull) {
1717
+ // Backfill NULLs from a literal DEFAULT, or throw.
1718
+ let defaultLiteral;
1719
+ const expr = oldCol.defaultValue;
1720
+ if (expr && expr.type === 'literal') {
1721
+ defaultLiteral = expr.value ?? null;
1108
1722
  }
1109
- case 'alterColumn': {
1110
- const colNameLower = change.columnName.toLowerCase();
1111
- const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === colNameLower);
1112
- if (colIndex === -1) {
1113
- throw new QuereusError(`Column '${change.columnName}' not found.`, StatusCode.ERROR);
1114
- }
1115
- const oldCol = oldSchema.columns[colIndex];
1116
- let newCol = oldCol;
1117
- // A collation change needs existing-row UNIQUE re-validation below
1118
- // (non-PK, Option A); the other attribute changes do not.
1119
- let collationChanged = false;
1120
- // Pull exactly one of the three attributes from the change.
1121
- if (change.setNotNull !== undefined) {
1122
- if (change.setNotNull === true && !oldCol.notNull) {
1123
- // Backfill NULLs from a literal DEFAULT, or throw.
1124
- let defaultLiteral;
1125
- const expr = oldCol.defaultValue;
1126
- if (expr && expr.type === 'literal') {
1127
- defaultLiteral = expr.value ?? null;
1128
- }
1129
- const nullCount = await table.rowsWithNullAtIndex(colIndex);
1130
- if (nullCount > 0) {
1131
- if (defaultLiteral === undefined || defaultLiteral === null) {
1132
- throw new QuereusError(`column ${change.columnName} contains NULL values`, StatusCode.CONSTRAINT);
1133
- }
1134
- const fill = defaultLiteral;
1135
- await table.mapRowsAtIndex(colIndex, (v) => v === null ? fill : v);
1136
- }
1137
- newCol = { ...oldCol, notNull: true };
1138
- }
1139
- else if (change.setNotNull === false && oldCol.notNull) {
1140
- if (oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
1141
- throw new QuereusError(`Cannot DROP NOT NULL on PRIMARY KEY column '${change.columnName}'`, StatusCode.CONSTRAINT);
1142
- }
1143
- newCol = { ...oldCol, notNull: false };
1144
- }
1145
- else {
1146
- return oldSchema; // already in desired state
1147
- }
1148
- }
1149
- else if (change.setDataType !== undefined) {
1150
- const newLogicalType = inferType(change.setDataType);
1151
- if (newLogicalType.physicalType !== oldCol.logicalType.physicalType) {
1152
- // Physical conversion required — walk every row and attempt parse.
1153
- await table.mapRowsAtIndex(colIndex, (v) => {
1154
- if (v === null)
1155
- return v;
1156
- try {
1157
- return validateAndParse(v, newLogicalType, change.columnName);
1158
- }
1159
- catch {
1160
- throw new QuereusError(`Cannot convert value in '${change.columnName}' to ${change.setDataType}`, StatusCode.MISMATCH);
1161
- }
1162
- });
1723
+ // Decide reject-vs-backfill over the DDL transaction's EFFECTIVE rows. `rows()` (the
1724
+ // isolation overlay) sees the issuer's pending inserts; `rowsWithNullAtIndex` (run
1725
+ // directly, no wrapper) sees the store's own effective rows. Either way a NULL the
1726
+ // transaction can see is backfilled, or rejects the ALTER, like any other row.
1727
+ let anyNull;
1728
+ if (rows) {
1729
+ anyNull = false;
1730
+ for await (const row of rows()) {
1731
+ if (row[colIndex] === null) {
1732
+ anyNull = true;
1733
+ break;
1163
1734
  }
1164
- newCol = { ...oldCol, logicalType: newLogicalType };
1165
1735
  }
1166
- else if (change.setDefault !== undefined) {
1167
- newCol = { ...oldCol, defaultValue: change.setDefault };
1168
- }
1169
- else if (change.setCollation !== undefined) {
1170
- // Per-column collation update. PRIMARY KEY uniqueness/ordering is enforced
1171
- // PHYSICALLY in the key bytes under a PER-COLUMN key collation
1172
- // (`StoreTable.pkKeyCollations`), so a PK-column SET COLLATE is honored
1173
- // natively by physically re-keying the data store + rebuilding every
1174
- // secondary index under the new collation (the `isPkColumn` block below),
1175
- // mirroring the memory module's primary re-key. A re-key that would collide
1176
- // under the new collation throws CONSTRAINT before any mutation. For non-PK
1177
- // UNIQUE constraints we re-validate existing rows under the new collation
1178
- // (Option A). Query-layer ORDER BY / `=` / `table_info().collation` pick the
1179
- // new collation up from the column schema once this updated schema re-registers.
1180
- const normalized = validateCollationForType(change.setCollation, oldCol.logicalType, change.columnName);
1181
- const nameMatches = normalized === (oldCol.collation || 'BINARY');
1182
- if (nameMatches && oldCol.collationExplicit) {
1183
- return oldSchema; // already explicit in the desired collation — no scan, no re-key, no re-persist
1184
- }
1185
- // SET COLLATE is a user declaration with the same standing as a
1186
- // CREATE-time COLLATE clause, so mark the collation explicit (rank 2
1187
- // in the comparison lattice) regardless of the column's creation
1188
- // history — including SET COLLATE binary. When only the name matches
1189
- // but the column was not yet explicit (a defaulted collation, or one
1190
- // inherited from session default_collation), flip the flag as a
1191
- // METADATA-ONLY change: the collation bytes are unchanged, so keep
1192
- // collationChanged false to skip rekeyRows / validateUniqueOverExistingRows
1193
- // below while still re-registering the schema and re-persisting DDL.
1194
- // A different name takes the full physical re-key path AND sets the flag.
1195
- newCol = { ...oldCol, collation: normalized, collationExplicit: true };
1196
- collationChanged = !nameMatches;
1197
- }
1198
- else {
1199
- throw new QuereusError('ALTER COLUMN requires an attribute to change', StatusCode.INTERNAL);
1736
+ }
1737
+ else {
1738
+ anyNull = (await table.rowsWithNullAtIndex(colIndex)) > 0;
1739
+ }
1740
+ if (anyNull) {
1741
+ if (defaultLiteral === undefined || defaultLiteral === null) {
1742
+ // Throw-only, and before the DDL-commit below: the transaction survives.
1743
+ throw new QuereusError(`column ${change.columnName} contains NULL values`, StatusCode.CONSTRAINT);
1200
1744
  }
1201
- const updatedColumns = oldSchema.columns.map((c, i) => i === colIndex ? newCol : c);
1202
- // Mirror the memory module (MemoryTableManager.alterColumn): a per-column
1203
- // collation change propagates into every index column ordering by this
1204
- // column, so a `derivedFromIndex` UNIQUE re-keys its enforcement under the
1205
- // new collation. StoreTable.uniqueEnforcementCollations reads the index's
1206
- // per-column collation, so without this the index entry would stay stale
1207
- // and the derived UNIQUE would keep enforcing the OLD collation after the
1208
- // ALTER. Metadata-only: the store's index KEY bytes use the table-level key
1209
- // collation K (see buildIndexEntries / updateSecondaryIndexes), so no index
1210
- // entry re-encode is required for a non-PK column. An index column with an
1211
- // explicit COLLATE is re-collated too — matching memory, which clobbers it
1212
- // the same way (no surface preserves a differing index COLLATE across an
1213
- // ALTER COLUMN SET COLLATE on its column).
1214
- const updatedIndexes = (collationChanged && oldSchema.indexes)
1215
- ? oldSchema.indexes.map(idx => ({
1216
- ...idx,
1217
- columns: idx.columns.map(ic => ic.index === colIndex ? { ...ic, collation: newCol.collation } : ic),
1218
- }))
1219
- : oldSchema.indexes;
1220
- const updatedSchema = {
1221
- ...oldSchema,
1222
- columns: Object.freeze(updatedColumns),
1223
- columnIndexMap: buildColumnIndexMap(updatedColumns),
1224
- indexes: updatedIndexes ? Object.freeze(updatedIndexes) : updatedIndexes,
1225
- };
1226
- // SET COLLATE existing-row re-validation (Option A, non-PK UNIQUE): a new
1227
- // per-column collation can make rows that were distinct under the old
1228
- // collation collide. Re-scan every UNIQUE constraint covering the altered
1229
- // column under the NEW collation (`updatedSchema` carries it). The first
1230
- // collision throws CONSTRAINT BEFORE any mutation/persist, so the table is
1231
- // left unchanged and writable (matches the ADD CONSTRAINT rollback shape).
1232
- // The PK is intentionally excluded — it never appears in `uniqueConstraints`;
1233
- // its physical re-key/re-validation is the `isPkColumn` block below.
1234
- if (collationChanged) {
1235
- const coveringConstraints = (updatedSchema.uniqueConstraints ?? [])
1236
- .filter(uc => uc.columns.includes(colIndex));
1237
- if (coveringConstraints.length > 0) {
1238
- const dataStore = await this.getStore(tableKey, table.getConfig());
1239
- for (const uc of coveringConstraints) {
1240
- await this.validateUniqueOverExistingRows(dataStore, updatedSchema, uc);
1241
- }
1242
- }
1745
+ const fill = defaultLiteral;
1746
+ // Physical rewrite ahead flush buffered writes so `mapRowsAtIndex` backfills this
1747
+ // store's own NULL rows. Run directly, that also fills the transaction's rows (the
1748
+ // probe saw them). Behind isolation the issuer's overlay-resident NULL rows are
1749
+ // filled by the isolation layer's overlay migration, not here — this store never
1750
+ // holds them. See {@link ddlCommitPendingOps}.
1751
+ await this.ddlCommitPendingOps();
1752
+ await table.mapRowsAtIndex(colIndex, (v) => v === null ? fill : v);
1753
+ valuesRewritten = true;
1754
+ }
1755
+ newCol = { ...oldCol, notNull: true };
1756
+ }
1757
+ else if (change.setNotNull === false && oldCol.notNull) {
1758
+ if (oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
1759
+ throw new QuereusError(`Cannot DROP NOT NULL on PRIMARY KEY column '${change.columnName}'`, StatusCode.CONSTRAINT);
1760
+ }
1761
+ newCol = { ...oldCol, notNull: false };
1762
+ }
1763
+ else {
1764
+ return null; // already in desired state
1765
+ }
1766
+ return { newCol, collationChanged: false, valuesRewritten };
1767
+ }
1768
+ /**
1769
+ * SET DATA TYPE sub-branch of {@link alterColumnChange}. Returns the retyped column
1770
+ * schema. When the physical type changes, a throw-only convert pass over the live
1771
+ * rows precedes {@link ddlCommitPendingOps} and the rewrite order unchanged.
1772
+ */
1773
+ async alterColumnSetDataType(table, oldCol, colIndex, change) {
1774
+ const newLogicalType = inferType(change.setDataType);
1775
+ let valuesRewritten = false;
1776
+ if (newLogicalType.physicalType !== oldCol.logicalType.physicalType) {
1777
+ // Physical conversion required walk every row and attempt parse.
1778
+ const convert = (v) => {
1779
+ try {
1780
+ return validateAndParse(v, newLogicalType, change.columnName);
1243
1781
  }
1244
- // SET COLLATE on a PRIMARY KEY member (Option B physical re-key): re-encode
1245
- // every data-store key under the column's new key collation, then rebuild every
1246
- // secondary index (its keys embed the PK suffix). `rekeyRows` validates in a
1247
- // first pass and throws CONSTRAINT on a collision under the new collation WITHOUT
1248
- // mutating the store — so a coarser collation that collapses two distinct PKs
1249
- // (e.g. 'a'/'A' under BINARY→NOCASE) is rejected all-or-nothing, mirroring
1250
- // ALTER PRIMARY KEY. Runs AFTER the non-PK UNIQUE re-validation above so both
1251
- // throw-only checks precede the first store mutation. `updatedSchema.columns`
1252
- // carries the new collation, so the new key bytes follow it.
1253
- if (collationChanged && oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
1254
- await table.rekeyRows(oldSchema.primaryKeyDefinition, updatedSchema.columns);
1255
- await this.rebuildSecondaryIndexes(schemaName, tableName, tableKey, table, updatedSchema);
1782
+ catch {
1783
+ throw new QuereusError(`Cannot convert value in '${change.columnName}' to ${change.setDataType}`, StatusCode.MISMATCH);
1256
1784
  }
1257
- table.updateSchema(updatedSchema);
1258
- await this.saveTableDDL(updatedSchema);
1259
- this.eventEmitter?.emitSchemaChange({
1260
- type: 'alter',
1261
- objectType: 'table',
1262
- schemaName,
1263
- objectName: tableName,
1264
- });
1265
- return updatedSchema;
1785
+ };
1786
+ // Throw-only pass over the LIVE rows first, so an unconvertible value
1787
+ // this transaction inserted rejects the ALTER with the transaction still
1788
+ // intact. The rewrite below reads only committed rows.
1789
+ for await (const value of table.iterateEffectiveValuesAtIndex(colIndex)) {
1790
+ if (value !== null)
1791
+ convert(value);
1266
1792
  }
1793
+ // Physical rewrite ahead — flush buffered writes so `mapRowsAtIndex`
1794
+ // converts this transaction's rows too; unflushed, they would replay
1795
+ // under the OLD physical type. See {@link ddlCommitPendingOps}.
1796
+ await this.ddlCommitPendingOps();
1797
+ await table.mapRowsAtIndex(colIndex, (v) => v === null ? v : convert(v));
1798
+ valuesRewritten = true;
1267
1799
  }
1800
+ return { newCol: { ...oldCol, logicalType: newLogicalType }, collationChanged: false, valuesRewritten };
1801
+ }
1802
+ /**
1803
+ * SET COLLATE sub-branch of {@link alterColumnChange}. Returns the recollated column
1804
+ * schema with `collationChanged` set when the collation bytes actually change (a bare
1805
+ * metadata flip keeps it false), or null when the column is already explicit in the
1806
+ * desired collation (the pre-refactor `return oldSchema` no-op). Synchronous — no
1807
+ * row scan happens here; the caller runs the re-validation / re-key the flag gates.
1808
+ */
1809
+ alterColumnSetCollation(oldCol, change, isCollationRegistered) {
1810
+ // Per-column collation update. PRIMARY KEY uniqueness/ordering is enforced
1811
+ // PHYSICALLY in the key bytes under a PER-COLUMN key collation
1812
+ // (`StoreTable.pkKeyCollations`), so a PK-column SET COLLATE is honored
1813
+ // natively by physically re-keying the data store + rebuilding every
1814
+ // secondary index under the new collation (the `isPkColumn` block below),
1815
+ // mirroring the memory module's primary re-key. A re-key that would collide
1816
+ // under the new collation throws CONSTRAINT before any mutation. For non-PK
1817
+ // UNIQUE constraints we re-validate existing rows under the new collation
1818
+ // (Option A). Query-layer ORDER BY / `=` / `table_info().collation` pick the
1819
+ // new collation up from the column schema once this updated schema re-registers.
1820
+ const normalized = validateCollationForType(change.setCollation, oldCol.logicalType, change.columnName, isCollationRegistered);
1821
+ const nameMatches = normalized === (oldCol.collation || 'BINARY');
1822
+ if (nameMatches && oldCol.collationExplicit) {
1823
+ return null; // already explicit in the desired collation — no scan, no re-key, no re-persist
1824
+ }
1825
+ // SET COLLATE is a user declaration with the same standing as a
1826
+ // CREATE-time COLLATE clause, so mark the collation explicit (rank 2
1827
+ // in the comparison lattice) regardless of the column's creation
1828
+ // history — including SET COLLATE binary. When only the name matches
1829
+ // but the column was not yet explicit (a defaulted collation, or one
1830
+ // inherited from session default_collation), flip the flag as a
1831
+ // METADATA-ONLY change: the collation bytes are unchanged, so keep
1832
+ // collationChanged false to skip rekeyRows / validateUniqueOverExistingRows
1833
+ // below while still re-registering the schema and re-persisting DDL.
1834
+ // A different name takes the full physical re-key path AND sets the flag.
1835
+ return { newCol: { ...oldCol, collation: normalized, collationExplicit: true }, collationChanged: !nameMatches };
1268
1836
  }
1269
1837
  /**
1270
1838
  * Rename a store-backed table.
@@ -1290,7 +1858,11 @@ export class StoreModule {
1290
1858
  // Authoritative index list (exact store names): the provider relocates
1291
1859
  // exactly these index stores instead of prefix-scanning `{oldName}_idx_`,
1292
1860
  // which would also catch a sibling table named `{oldName}_idx_<x>`.
1293
- const indexNames = (currentSchema?.indexes ?? []).map(i => i.name);
1861
+ // MATERIALIZED, so the hidden `_uc_*` store realizing a plain UNIQUE moves with
1862
+ // the table — otherwise the renamed table seeks a fresh EMPTY `_uc_*` and silently
1863
+ // accepts a duplicate of a pre-rename row. `currentSchema` itself stays
1864
+ // non-materialized: the catalog DDL rewritten below must carry no `_uc_*`.
1865
+ const indexNames = this.materializedIndexNames(existing, currentSchema);
1294
1866
  // Reject when ANY physical name the rename introduces — the new data store
1295
1867
  // AND each relocated index store `{schema}.{newName}_idx_{x}` — already
1296
1868
  // names an existing store. E.g. rename some table to `q_idx_archive` while
@@ -1308,20 +1880,10 @@ export class StoreModule {
1308
1880
  for (const indexName of indexNames) {
1309
1881
  this.assertStoreNameFree(db, schemaName, buildIndexStoreName(schemaName, newName, indexName), `index store of index '${indexName}' on table '${schemaName}.${newName}' (rename target)`, occupied);
1310
1882
  }
1311
- // ALTER TABLE is effectively DDL-committing on a store-backed table:
1312
- // once we move the on-disk directory, prior buffered writes can no
1313
- // longer be rolled back through the coordinator. Flush any pending
1314
- // ops NOW, before the old store's handle is closed. Subsequent
1315
- // commit() calls on the same coordinator are no-ops (inTransaction
1316
- // is cleared), which keeps the enclosing transaction safe.
1317
- //
1318
- // The coordinator is module-wide, so this DDL-commits the WHOLE module
1319
- // transaction — every table's pending ops, not just the renamed table's —
1320
- // in one all-or-nothing batch. That is the correct, consistent posture
1321
- // for a store DDL-commit: an ALTER cannot half-commit some sibling tables.
1322
- if (this.moduleCoordinator?.isInTransaction()) {
1323
- await this.moduleCoordinator.commit();
1324
- }
1883
+ // Flush buffered writes before the old store's handle is closed: once the
1884
+ // on-disk directory moves, prior buffered ops address stores that no longer
1885
+ // exist under those names. See {@link ddlCommitPendingOps}.
1886
+ await this.ddlCommitPendingOps();
1325
1887
  // Hard-dispose the evicted handle: flush any lazy stats it was buffering AND
1326
1888
  // deregister its coordinator stats-callback pair (the renamed instance is
1327
1889
  // gone after this — the next connect()/getOrReconnectTable mints a fresh one
@@ -1339,15 +1901,32 @@ export class StoreModule {
1339
1901
  this.stores.delete(oldKey);
1340
1902
  // The coordinator is module-wide (flushed above); it is not per-table, so
1341
1903
  // it is not evicted here.
1342
- // Evict the disposed instance's registered engine connection. It is bound to
1343
- // the OLD qualified name and its owning StoreTable is now disposed, so it is
1344
- // definitively stale. Unlike dropwhere the engine's schema manager calls
1345
- // `removeConnectionsForTable` for us the generic rename path
1346
- // (`alter-table.ts` renameTableImpl) does NOT, so the store must evict it
1347
- // here or the connection leaks one per rename. Safe because the module
1348
- // DDL-commit above already flushed its pending ops (no uncommitted writes to
1349
- // lose).
1350
- db.removeConnectionsForTable(schemaName, oldName);
1904
+ // Evict the disposed instance's registered engine connections but ONLY the
1905
+ // ones this module created. Unlike drop where the engine's schema manager
1906
+ // calls `removeConnectionsForTable` for us — the generic rename path
1907
+ // (`alter-table.ts` renameTableImpl) does NOT, so the store must evict here or
1908
+ // a StoreConnection leaks one per rename. Safe for `StoreConnection`s (both the
1909
+ // StoreTable-owned DML connection and the StoreBackingHost-owned one): they hold
1910
+ // no state of their own, delegating to the module-wide coordinator that the
1911
+ // DDL-commit above already flushed, and their owning StoreTable is now disposed.
1912
+ //
1913
+ // A blanket name-keyed sweep is NOT safe: a wrapping module (the isolation layer)
1914
+ // registers its own connection under the same qualified name, and that connection
1915
+ // is the only thing that drives its staged overlay to storage at COMMIT. Evicting
1916
+ // it silently drops the transaction's writes.
1917
+ //
1918
+ // NOTE: a rename onto a never-before-used name leaves the wrapper's connection
1919
+ // registered under the stale old name (it is not retargeted here). Benign — the
1920
+ // commit flush resolves overlays db-wide by their re-keyed names — but a workload
1921
+ // that renames one table through many distinct names in a single process
1922
+ // accumulates one such connection per name. If that ever matters, retarget the
1923
+ // connection's `tableName` across the rename instead of leaving it stale.
1924
+ const oldQualified = `${schemaName}.${oldName}`.toLowerCase();
1925
+ for (const conn of db.getAllConnections()) {
1926
+ if (conn instanceof StoreConnection && conn.tableName.toLowerCase() === oldQualified) {
1927
+ db.removeConnection(conn.connectionId);
1928
+ }
1929
+ }
1351
1930
  // Move physical storage (data directory + index directories).
1352
1931
  if (this.provider.renameTableStores) {
1353
1932
  await this.provider.renameTableStores(schemaName, oldName, newName, indexNames);
@@ -1356,18 +1935,85 @@ export class StoreModule {
1356
1935
  // so a crash mid-rename leaves the table discoverable under at least one
1357
1936
  // name rather than neither.
1358
1937
  if (currentSchema) {
1359
- const renamedSchema = { ...currentSchema, name: newName };
1360
- await this.saveTableDDL(renamedSchema);
1938
+ const renamedSchema = {
1939
+ ...currentSchema,
1940
+ name: newName,
1941
+ // A self-referencing FK is persisted as `references <oldName>(...)`; after
1942
+ // `removeTableDDL` below, that names a table no longer in the catalog. Pure
1943
+ // copy — no rollback needed, `renamedSchema` is local to the write.
1944
+ foreignKeys: retargetSelfForeignKeys(currentSchema.foreignKeys, schemaName, oldName, newName),
1945
+ };
1946
+ // A partial index's WHERE clause and a CHECK constraint's expression can each
1947
+ // carry a table-qualified self-reference (`where t.b > 0`) still naming the OLD
1948
+ // table: `propagateTableRename` runs only after this hook returns, so — exactly
1949
+ // as in the `renameColumn` arm of `alterTable` — persisting now would durably
1950
+ // write a stale qualifier that only the later propagation event corrects.
1951
+ // Rewrite first, in place (each `Expression` is shared with the catalog
1952
+ // `TableSchema` and, for a unique partial index, with its derived UNIQUE
1953
+ // constraint), which also makes that later pass a no-op for these fields. A
1954
+ // throw anywhere in the `try` — including partway through a walk — reverses the
1955
+ // rewrites; reversing is a no-op wherever nothing names `newName`. The physical
1956
+ // stores have already moved by this point, so the reverse restores only the AST,
1957
+ // not the on-disk layout.
1958
+ //
1959
+ // NOTE: the reverse assumes no expression legitimately named `newName` before
1960
+ // the rename. The rename-target guard above makes that true for a real table,
1961
+ // and `compilePredicate` now rejects a foreign `table` qualifier at create time,
1962
+ // so a live partial-index predicate can only carry a self-qualifier (`where
1963
+ // <thisTable>.b > 0`) or a bare reference — never `where <newName>.b > 0` for a
1964
+ // different table. The mis-reversal path is therefore unreachable for a live
1965
+ // predicate.
1966
+ const rewriteTable = (from, to) => {
1967
+ renameTableInIndexPredicates(currentSchema.indexes, from, to, schemaName);
1968
+ renameTableInCheckConstraints(currentSchema.checkConstraints, from, to, schemaName);
1969
+ };
1970
+ try {
1971
+ rewriteTable(oldName, newName);
1972
+ await this.saveTableDDL(renamedSchema);
1973
+ }
1974
+ catch (e) {
1975
+ rewriteTable(newName, oldName);
1976
+ throw e;
1977
+ }
1361
1978
  }
1362
- await this.removeTableDDL(schemaName, oldName);
1363
- // Relocate the stats entry (unified __stats__ store, keyed by schema.table).
1979
+ // NOTE: the OLD name's catalog entry is deliberately NOT removed here. Deleting it
1980
+ // synchronously would drop `oldName` from the catalog BEFORE the engine's post-hook
1981
+ // `propagateTableRename` has rewritten — let alone persisted — the OTHER tables /
1982
+ // views / MVs that still name `oldName` (a cross-schema FK, a CHECK expression, a
1983
+ // dependent view/MV body). A crash in that gap would strand a durable catalog set
1984
+ // naming a vanished table, which reopens as a healthy-looking database whose
1985
+ // dependents cannot be written to. The removal is deferred to `finalizeRename`,
1986
+ // which the engine calls AFTER propagation has made the dependents durable.
1987
+ // Migrate the stats entry (unified __stats__ store, keyed by schema.table).
1988
+ // The entry is RE-KEYED, not physically moved with the directory: a unified
1989
+ // store keys every table's stats under `schema.table` in one store, so the
1990
+ // value must be copied from the old key to the new key and the old key
1991
+ // dropped. `dispose()` above already flushed any buffered delta to disk under
1992
+ // the old key, so the read here sees the current row-count estimate; deleting
1993
+ // without copying (the prior behavior) blinded the planner — a freshly-renamed
1994
+ // table reported getEstimatedRowCount() === 0 until stats were re-gathered.
1995
+ //
1996
+ // NOTE: this re-key reaches the old value only when getStatsStore(newName)
1997
+ // returns a store that CONTAINS the old key — true for the shipped providers,
1998
+ // which all share one unified __stats__ store, and for any provider whose
1999
+ // renameTableStores physically relocates a per-table stats store. A provider
2000
+ // that kept per-table stats stores and did NOT relocate them in
2001
+ // renameTableStores would orphan the old-keyed value out of reach here (the
2002
+ // prior delete-only code lost it just the same — no regression). No shipped
2003
+ // provider does this; revisit if one ever keeps per-table, non-relocated stats.
1364
2004
  try {
1365
2005
  const statsStore = await this.provider.getStatsStore(schemaName, newName);
1366
2006
  const oldStatsKey = buildStatsKey(schemaName, oldName);
2007
+ const statsValue = await statsStore.get(oldStatsKey);
2008
+ if (statsValue) {
2009
+ // A table with no stats yet (never flushed) returns undefined here —
2010
+ // skip the copy so no spurious zero-count entry lands under the new key.
2011
+ await statsStore.put(buildStatsKey(schemaName, newName), statsValue);
2012
+ }
1367
2013
  await statsStore.delete(oldStatsKey);
1368
2014
  }
1369
2015
  catch {
1370
- /* stats are advisory — a stale entry under the old key is harmless */
2016
+ /* stats are advisory — a stats hiccup must never block the rename */
1371
2017
  }
1372
2018
  this.eventEmitter?.emitSchemaChange({
1373
2019
  type: 'alter',
@@ -1376,6 +2022,32 @@ export class StoreModule {
1376
2022
  objectName: newName,
1377
2023
  });
1378
2024
  }
2025
+ /**
2026
+ * Second phase of a two-phase RENAME TABLE (see {@link renameTable}). The engine calls
2027
+ * this at the END of ALTER TABLE ... RENAME TO — AFTER its `propagateTableRename` has
2028
+ * rewritten every dependent object that named `oldName` and enqueued their corrective
2029
+ * catalog writes onto {@link persistQueue}. `renameTable` deliberately left `oldName`'s
2030
+ * catalog entry in place; here we drain those dependent writes to durability and only
2031
+ * THEN drop the old entry. During the window both entries coexist on disk, so every
2032
+ * dependent resolves against one of them and every intermediate catalog set rehydrates
2033
+ * into a working database.
2034
+ *
2035
+ * The old-entry delete rides `persistQueue` behind the dependents' already-enqueued
2036
+ * writes (FIFO), so it can only run after them, and the drain below awaits the whole
2037
+ * chain. Errors are swallowed+logged (the {@link enqueuePersist} contract): a failed
2038
+ * delete leaves the old entry present — a visible, droppable orphan, strictly safer
2039
+ * than a dependent stranded against a vanished table.
2040
+ *
2041
+ * NOTE: full cross-table atomicity — bundling this old-entry delete together with every
2042
+ * dependent rewrite into one `provider.beginAtomicBatch` commit — would eliminate even
2043
+ * the transient two-entry window (and the physical-move orphan `renameTableStores`
2044
+ * leaves), but only on atomic providers, and the dependent set is known only to the
2045
+ * engine post-propagate. That is the atomic-provider hardening path; out of scope here.
2046
+ */
2047
+ async finalizeRename(_db, schemaName, oldName, _newName) {
2048
+ this.enqueuePersist(() => this.removeTableDDL(schemaName, oldName));
2049
+ await this.whenCatalogPersisted();
2050
+ }
1379
2051
  /**
1380
2052
  * Modern access planning interface.
1381
2053
  *
@@ -1389,26 +2061,41 @@ export class StoreModule {
1389
2061
  * (via `StoreTable.buildPKRangeBounds`) encodes the LT/LE/GT/GE bounds under the
1390
2062
  * same per-column key collations the data keys use and iterates that
1391
2063
  * seek-start/early-termination window. The window is a SUPERSET, so the post-fetch
1392
- * row filter still reproduces the exact collation semantics and a comparator-only
1393
- * collation with no byte encoder safely falls back to a full scan. Mirrors the
1394
- * memory module's advertisement.
2064
+ * row filter still reproduces the exact collation semantics. (A collation that cannot
2065
+ * key at all never reaches a PK column: `StoreTable`'s constructor rejects it at DDL
2066
+ * time.) Mirrors the memory module's advertisement.
2067
+ *
2068
+ * The seek/ordering claims are additionally gated on the key collation being
2069
+ * ORDER-PRESERVING — byte order and comparator order must coincide, or the seek would
2070
+ * under-fetch and the ordering advertisement would elide a Sort it must not. That gate
2071
+ * needs the connection's collation registry, hence `db` is threaded down.
1395
2072
  */
1396
- getBestAccessPlan(_db, tableInfo, request) {
1397
- return { ...this.computeBestAccessPlan(tableInfo, request), honorsCollatedRangeBounds: true };
2073
+ getBestAccessPlan(db, tableInfo, request) {
2074
+ return { ...this.computeBestAccessPlan(db, tableInfo, request), honorsCollatedRangeBounds: true };
1398
2075
  }
1399
- computeBestAccessPlan(tableInfo, request) {
2076
+ computeBestAccessPlan(db, tableInfo, request) {
1400
2077
  const estimatedRows = request.estimatedRows ?? 1000;
1401
- // Check for primary key equality constraints
2078
+ // Table key collation K (default NOCASE). Any K reaching here has a key normalizer:
2079
+ // `StoreTable`'s constructor rejects one that cannot key, and a name we cannot resolve
2080
+ // to a table falls back to the built-in NOCASE. `||`, not `??`, so an empty-string
2081
+ // `config.collation` reads as the default here exactly as it does in
2082
+ // `StoreTable.encodeOptions` — the two must agree or `analyzePKAccess` would decline a
2083
+ // window `computeBestAccessPlan` already marked handled.
2084
+ const tableKeyCollation = (this.getTable(tableInfo.schemaName, tableInfo.name)?.getConfig().collation || 'NOCASE').toUpperCase();
2085
+ // Check for primary key equality constraints. Count DISTINCT pinned PK columns:
2086
+ // counting raw '=' filters would read `a = 1 and a = 2` on a composite PK (a, b)
2087
+ // as "both PK columns pinned", claim both filters handled, then — with no
2088
+ // complete PK equality set to seek — degrade to a sequential scan whose residual
2089
+ // has already been discarded, returning the whole table.
1402
2090
  const pkColumns = tableInfo.primaryKeyDefinition.map(pk => pk.index);
1403
- const pkFilters = request.filters.filter(f => f.columnIndex !== undefined &&
1404
- pkColumns.includes(f.columnIndex) &&
1405
- f.op === '=');
1406
- if (pkFilters.length === pkColumns.length && pkColumns.length > 0) {
2091
+ const pinnedPkColumns = new Set(request.filters
2092
+ .filter(f => f.columnIndex !== undefined && pkColumns.includes(f.columnIndex) && f.op === '=')
2093
+ .map(f => f.columnIndex));
2094
+ if (pinnedPkColumns.size === pkColumns.length && pkColumns.length > 0) {
1407
2095
  // Full PK match - point lookup (single row; no monotonic advertisement)
1408
- const handledFilters = request.filters.map(f => pkFilters.some(pf => pf.columnIndex === f.columnIndex && pf.op === f.op));
1409
2096
  return AccessPlanBuilder
1410
2097
  .eqMatch(1, 0.1)
1411
- .setHandledFilters(handledFilters)
2098
+ .setHandledFilters(claimFirstPerRole(request.filters, equalityRoles(pkColumns)))
1412
2099
  .setIsSet(true)
1413
2100
  .setIndexName('_primary_')
1414
2101
  .setExplanation('Store primary key lookup')
@@ -1419,50 +2106,61 @@ export class StoreModule {
1419
2106
  // range bounds for primaryKeyDefinition[0]; ranges on later PK columns
1420
2107
  // are silently dropped if marked handled. So only claim handled=true
1421
2108
  // when the range is on the first PK column.
1422
- const rangeOps = ['<', '<=', '>', '>='];
2109
+ //
2110
+ // The seek is also declined when the leading PK column's key bytes do not order the
2111
+ // way its comparator does (`pkOrderPreservingPrefix === 0`): `StoreTable.analyzePKAccess`
2112
+ // declines the byte window under exactly that condition, so claiming the range filters
2113
+ // handled here would drop the residual Filter and return the whole table.
2114
+ const pkOrderPreservingPrefix = pkOrderPreservingPrefixLength(db, tableInfo, resolvePkKeyCollations(tableInfo.primaryKeyDefinition, tableInfo.columns, tableKeyCollation), tableKeyCollation);
1423
2115
  const firstPkColumn = tableInfo.primaryKeyDefinition[0]?.index;
1424
- const rangeFilters = firstPkColumn !== undefined
1425
- ? request.filters.filter(f => f.columnIndex === firstPkColumn &&
1426
- rangeOps.includes(f.op))
1427
- : [];
1428
- if (rangeFilters.length > 0) {
2116
+ const hasLeadingPkRange = firstPkColumn !== undefined
2117
+ && pkOrderPreservingPrefix >= 1
2118
+ && request.filters.some(f => f.columnIndex === firstPkColumn && RANGE_OPS.includes(f.op));
2119
+ if (hasLeadingPkRange) {
1429
2120
  // Range scan on first PK column. Iteration is by PK key order (see
1430
2121
  // StoreTable.scanPKRange), so we can advertise monotonic emission on
1431
2122
  // the leading PK column. The scan seeks to the window start and
1432
2123
  // early-terminates (StoreTable.buildPKRangeBounds derives the encoded
1433
2124
  // bounds), and the leading-PK order guarantee holds throughout.
1434
- const handledFilters = request.filters.map(f => rangeFilters.some(rf => rf.columnIndex === f.columnIndex && rf.op === f.op));
1435
2125
  const rangeRows = Math.max(1, Math.floor(estimatedRows * 0.3));
1436
2126
  const plan = AccessPlanBuilder
1437
2127
  .rangeScan(rangeRows, 0.2)
1438
- .setHandledFilters(handledFilters)
2128
+ .setHandledFilters(claimFirstPerRole(request.filters, rangeRoles(firstPkColumn)))
1439
2129
  .setIndexName('_primary_')
1440
2130
  .setSeekColumns([firstPkColumn])
1441
2131
  .setExplanation('Store primary key range scan')
1442
2132
  .build();
1443
- return { ...plan, ...this.buildPkOrderingAdvertisement(tableInfo, request) };
1444
- }
1445
- // Check for secondary index usage
1446
- // Note: query() does not yet implement secondary index scans — it falls
1447
- // back to a full table scan + matchesFilters. We still advertise better
1448
- // cost estimates when a usable index exists (so the planner prefers this
1449
- // table access) but we must NOT mark filters as handled, otherwise the
1450
- // engine won't supply them to matchesFilters and rows pass unfiltered.
2133
+ return { ...plan, ...this.buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix) };
2134
+ }
2135
+ // Check for secondary index usage. `StoreTable.query` now implements the
2136
+ // secondary-index scan arm (leading-prefix EQ point / leading-column range),
2137
+ // so we advertise the index with `indexName` + `seekColumns` and mark the
2138
+ // covered filters handled subject to the collation-safety guard in
2139
+ // {@link tryIndexAccessPlan}. A cost-only plan (no seek) is kept as a fallback
2140
+ // when no index yields a collation-safe seek, preserving the prior "cheaper
2141
+ // cost, filters unhandled, residual retained" behavior.
1451
2142
  const indexes = tableInfo.indexes || [];
2143
+ let costOnlyFallback = null;
1452
2144
  for (const index of indexes) {
1453
- const indexColumns = index.columns.map(c => c.index);
1454
- const indexFilters = request.filters.filter(f => f.columnIndex !== undefined &&
1455
- indexColumns.includes(f.columnIndex) &&
1456
- f.op === '=');
1457
- if (indexFilters.length > 0) {
1458
- const matchedRows = Math.max(1, Math.floor(estimatedRows * 0.1));
1459
- return AccessPlanBuilder
1460
- .eqMatch(matchedRows, 0.3)
1461
- .setHandledFilters(new Array(request.filters.length).fill(false))
1462
- .setExplanation(`Store index scan on ${index.name}`)
1463
- .build();
1464
- }
2145
+ if (index.columns.length === 0)
2146
+ continue;
2147
+ const plan = this.tryIndexAccessPlan(db, tableKeyCollation, tableInfo, request, index, estimatedRows);
2148
+ if (!plan)
2149
+ continue;
2150
+ // A fully-handled seek (indexName + seekColumns set) wins immediately.
2151
+ if (plan.seekColumnIndexes && plan.seekColumnIndexes.length > 0)
2152
+ return plan;
2153
+ // Otherwise remember the first cost-only advertisement as a fallback.
2154
+ if (!costOnlyFallback)
2155
+ costOnlyFallback = plan;
1465
2156
  }
2157
+ // NOTE: a cost-only plan carries no PK-order advertisement even though the store still
2158
+ // iterates in PK key order for it (`StoreTable.query` full-scans). The range gate makes
2159
+ // this arm fire more often — an index range on a BINARY text column of a default-K table
2160
+ // now lands here — so `... where v > 'x' order by <pk>` picks up a Sort it did not need.
2161
+ // If that shows up as slow, merge `buildPkOrderingAdvertisement(...)` into this return.
2162
+ if (costOnlyFallback)
2163
+ return costOnlyFallback;
1466
2164
  // Fallback to full scan. The store iterates rows in PK key order
1467
2165
  // (see StoreTable.query / store.iterate over buildFullScanBounds), so
1468
2166
  // the scan is monotonic on the leading PK column. Advertise that so
@@ -1473,7 +2171,115 @@ export class StoreModule {
1473
2171
  .setHandledFilters(new Array(request.filters.length).fill(false))
1474
2172
  .setExplanation('Store full table scan')
1475
2173
  .build();
1476
- return { ...plan, ...this.buildPkOrderingAdvertisement(tableInfo, request) };
2174
+ return { ...plan, ...this.buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix) };
2175
+ }
2176
+ /**
2177
+ * Build the access plan for one secondary index against `request`, or null when
2178
+ * the index is not usable for this predicate.
2179
+ *
2180
+ * Usable = a contiguous leading-prefix EQ on the index columns (an index seek /
2181
+ * point), or a LT/LE/GT/GE range on the LEADING index column. These mirror the
2182
+ * two windows {@link StoreTable.analyzeIndexAccess} can build.
2183
+ *
2184
+ * **Collation-safety guard against under-fetch.** The store's index-column
2185
+ * window is encoded under the table key collation K, but `matchesFilters`
2186
+ * compares under the COLUMN's declared collation. Marking a filter handled drops
2187
+ * the residual Filter, so the K-window must be a guaranteed SUPERSET of the
2188
+ * qualifying rows. That holds only when K is coarser-or-equal to the column's
2189
+ * declared collation. To stay provably safe with minimal logic we mark the
2190
+ * covered filters handled — setting `indexName` + `seekColumns` — only when every
2191
+ * seek column is non-text, OR its declared collation equals K, OR (K = NOCASE
2192
+ * while the column is BINARY, i.e. K strictly coarser). Otherwise we return a
2193
+ * cost-only plan (cheaper cost, filters unhandled, residual retained — correct,
2194
+ * just not sped up). K itself always keys: `StoreTable`'s constructor rejects a
2195
+ * table whose key encoding would need a collation it cannot resolve to a normalizer.
2196
+ *
2197
+ * NOTE: the coarser-K relaxation is sound for EQUALITY only. A RANGE window equates
2198
+ * memcmp of K-normalized bytes with C's comparator order, which a merely coarser K does
2199
+ * not give — under K = NOCASE and C = BINARY, 'K' (U+212A) is `> 'z'` yet keys as 'k',
2200
+ * before 'z'. So the range arm demands `C === K` *and* K's `orderPreserving` assertion,
2201
+ * via the shared {@link keyOrderMatchesCollation}; `StoreTable.analyzeIndexAccess`
2202
+ * declines the same windows, so the two decisions cannot disagree. The cost is that a
2203
+ * default-K (NOCASE) table with an index on a plain BINARY text column loses its index
2204
+ * RANGE seek and falls back to the cost-only plan; EQ seeks are unchanged.
2205
+ */
2206
+ tryIndexAccessPlan(db, tableKeyCollation, tableInfo, request, index, estimatedRows) {
2207
+ // Exclude PARTIAL indexes from access planning: neither the engine nor this
2208
+ // module checks that the query's WHERE implies the index predicate, so seeking
2209
+ // a partial index for a query it doesn't cover would silently drop the rows the
2210
+ // index omits (an out-of-scope predicate returns nothing). Treat partial indexes
2211
+ // purely as uniqueness enforcers — the query full-scans + residual instead.
2212
+ // Mirrors MemoryTableModule.getAvailableIndexes (`if (idx.predicate) continue`).
2213
+ if (index.predicate)
2214
+ return null;
2215
+ const indexColIndexes = index.columns.map(c => c.index);
2216
+ // Contiguous leading-prefix EQ → point/prefix seek.
2217
+ const eqCols = [];
2218
+ for (const colIdx of indexColIndexes) {
2219
+ if (!request.filters.some(f => f.columnIndex === colIdx && f.op === '='))
2220
+ break;
2221
+ eqCols.push(colIdx);
2222
+ }
2223
+ const leadingCol = indexColIndexes[0];
2224
+ const hasLeadingRange = request.filters.some(f => f.columnIndex === leadingCol && RANGE_OPS.includes(f.op));
2225
+ let seekCols;
2226
+ let isRange;
2227
+ if (eqCols.length > 0) {
2228
+ seekCols = eqCols;
2229
+ isRange = false;
2230
+ }
2231
+ else if (hasLeadingRange) {
2232
+ seekCols = [leadingCol];
2233
+ isRange = true;
2234
+ }
2235
+ else {
2236
+ return null; // this index cannot serve this predicate
2237
+ }
2238
+ const rows = isRange
2239
+ ? Math.max(1, Math.floor(estimatedRows * 0.3))
2240
+ : Math.max(1, Math.floor(estimatedRows * 0.1));
2241
+ const costOnly = (why) => (isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3))
2242
+ .setHandledFilters(new Array(request.filters.length).fill(false))
2243
+ .setExplanation(`Store index scan on ${index.name} (${why})`)
2244
+ .build();
2245
+ // The INDEX column's effective comparison collation C — the index column's own
2246
+ // COLLATE, else the table column's declared collation. C, not the table column's
2247
+ // declared collation, is what matchesFilters compares an index-scan row under and
2248
+ // what the planner matched to drop the residual, so the K-window must be a superset
2249
+ // relative to C.
2250
+ const K = tableKeyCollation;
2251
+ const effectiveCollation = (colIdx) => {
2252
+ const indexCol = index.columns.find(c => c.index === colIdx);
2253
+ return (indexCol?.collation ?? tableInfo.columns[colIdx]?.collation ?? 'BINARY').toUpperCase();
2254
+ };
2255
+ // EQUALITY: safe to mark handled iff K is coarser-or-equal to C. Exempt only columns
2256
+ // that can NEVER hold text — their key bytes are type-native and collation-independent.
2257
+ // A bare `isTextual` test wrongly exempts an `ANY` column (no marker, but it stores
2258
+ // text as text), which would seek under K, drop the residual, and lose rows.
2259
+ const eqSafeToHandle = (colIdx) => {
2260
+ const col = tableInfo.columns[colIdx];
2261
+ if (!columnCanHoldText(col))
2262
+ return true;
2263
+ const C = effectiveCollation(colIdx);
2264
+ if (C === K)
2265
+ return true; // equal
2266
+ if (K === 'NOCASE' && C === 'BINARY')
2267
+ return true; // K strictly coarser
2268
+ return false;
2269
+ };
2270
+ // RANGE: coarser is not enough — byte order must BE comparator order. See the doc above.
2271
+ const rangeSafeToHandle = (colIdx) => keyOrderMatchesCollation(db, tableInfo.columns[colIdx], K, effectiveCollation(colIdx));
2272
+ if (!seekCols.every(isRange ? rangeSafeToHandle : eqSafeToHandle)) {
2273
+ return costOnly('cost-only; key collation may under-fetch');
2274
+ }
2275
+ // Claim positionally — see {@link claimFirstPerRole}.
2276
+ const handledFilters = claimFirstPerRole(request.filters, isRange ? rangeRoles(leadingCol) : equalityRoles(eqCols));
2277
+ return (isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3))
2278
+ .setHandledFilters(handledFilters)
2279
+ .setIndexName(index.name)
2280
+ .setSeekColumns(seekCols)
2281
+ .setExplanation(`Store index ${isRange ? 'range scan' : 'seek'} on ${index.name}`)
2282
+ .build();
1477
2283
  }
1478
2284
  /**
1479
2285
  * Compute the PK-ordering advertisement for a scan-style plan. Returns the
@@ -1498,10 +2304,17 @@ export class StoreModule {
1498
2304
  *
1499
2305
  * Returns an empty object when there is no PK (heap-only table) — without a
1500
2306
  * leading key column there is no natural emit order.
2307
+ *
2308
+ * Every claim here is about the PHYSICAL key-byte order the store iterates in, but the
2309
+ * consumers of `providesOrdering` / `monotonicOn` reason in the columns' COLLATION order.
2310
+ * `orderPreservingPrefix` (from {@link pkOrderPreservingPrefixLength}) is how many leading
2311
+ * PK members those two orders provably agree on: the advertisement is truncated to that
2312
+ * prefix, and voided entirely when even the leading member disagrees — otherwise the
2313
+ * absorb-Sort rule would elide a Sort and hand the caller byte-ordered rows.
1501
2314
  */
1502
- buildPkOrderingAdvertisement(tableInfo, request) {
2315
+ buildPkOrderingAdvertisement(tableInfo, request, orderPreservingPrefix) {
1503
2316
  const pk = tableInfo.primaryKeyDefinition;
1504
- if (pk.length === 0)
2317
+ if (pk.length === 0 || orderPreservingPrefix === 0)
1505
2318
  return {};
1506
2319
  const leading = pk[0];
1507
2320
  const monotonicOn = {
@@ -1509,7 +2322,7 @@ export class StoreModule {
1509
2322
  direction: leading.desc ? 'desc' : 'asc',
1510
2323
  strict: pk.length === 1,
1511
2324
  };
1512
- const pkOrdering = pk.map(col => ({
2325
+ const pkOrdering = pk.slice(0, orderPreservingPrefix).map(col => ({
1513
2326
  columnIndex: col.index,
1514
2327
  desc: !!col.desc,
1515
2328
  }));
@@ -1522,7 +2335,7 @@ export class StoreModule {
1522
2335
  // matched here — if the request specifies an explicit NULLS
1523
2336
  // FIRST/LAST, leave the Sort in place rather than assume the PK
1524
2337
  // scan's natural NULL placement matches.
1525
- if (required.length > pk.length)
2338
+ if (required.length > pkOrdering.length)
1526
2339
  return { monotonicOn, supportsAsofRight: true };
1527
2340
  for (let i = 0; i < required.length; i++) {
1528
2341
  if (required[i].columnIndex !== pkOrdering[i].columnIndex)
@@ -1548,10 +2361,10 @@ export class StoreModule {
1548
2361
  /**
1549
2362
  * Get or create a data store for a table.
1550
2363
  */
1551
- async getStore(tableKey, _config) {
2364
+ async getStore(schemaName, tableName, _config) {
2365
+ const tableKey = `${schemaName}.${tableName}`.toLowerCase();
1552
2366
  let store = this.stores.get(tableKey);
1553
2367
  if (!store) {
1554
- const [schemaName, tableName] = tableKey.split('.');
1555
2368
  store = await this.provider.getStore(schemaName, tableName);
1556
2369
  if (!store) {
1557
2370
  throw new Error(`Provider.getStore returned null/undefined for ${tableKey}`);
@@ -1631,6 +2444,10 @@ export class StoreModule {
1631
2444
  * the compare-write in `persistCatalogIfChanged` relies on.
1632
2445
  */
1633
2446
  buildCatalogEntry(tableSchema) {
2447
+ // `tableSchema` is always the engine-facing schema (no `_uc_*`; the store keeps the
2448
+ // materialized enforcement copy internal), so hidden implicit indexes reach here
2449
+ // only through the `uniqueConstraints`-driven `isHiddenImplicitIndex` /
2450
+ // `exposedImplicitIndexes` paths — never as a materialized `CREATE INDEX`.
1634
2451
  const parts = [generateTableDDL(tableSchema)];
1635
2452
  for (const idx of tableSchema.indexes ?? []) {
1636
2453
  if (isHiddenImplicitIndex(tableSchema, idx.name))
@@ -1644,14 +2461,26 @@ export class StoreModule {
1644
2461
  }
1645
2462
  return parts.join('\n');
1646
2463
  }
2464
+ /**
2465
+ * Encode a bundle of persisted schema text (DDL) for the catalog store.
2466
+ *
2467
+ * Guards the FULL text, not just the object's name: a lone surrogate anywhere in it — a
2468
+ * quoted column name, a `default '…'` string literal, a `check` constraint's string
2469
+ * constant — would otherwise fold to U+FFFD under `TextEncoder` and read back as
2470
+ * different schema text than what was created. This does not rely on the catalog-key
2471
+ * builders' identifier guard having already caught it; it catches it independently.
2472
+ */
2473
+ encodeCatalogDDL(ddl) {
2474
+ assertNoUnpairedSurrogate(ddl, 'persisted schema text');
2475
+ return new TextEncoder().encode(ddl);
2476
+ }
1647
2477
  /**
1648
2478
  * Save table DDL (bundled with its secondary index DDL) to the catalog store.
1649
2479
  */
1650
2480
  async saveTableDDL(tableSchema) {
1651
2481
  const ddl = this.buildCatalogEntry(tableSchema);
1652
2482
  const catalogKey = buildCatalogKey(tableSchema.schemaName, tableSchema.name);
1653
- const encoder = new TextEncoder();
1654
- const encodedDDL = encoder.encode(ddl);
2483
+ const encodedDDL = this.encodeCatalogDDL(ddl);
1655
2484
  const catalogStore = await this.provider.getCatalogStore();
1656
2485
  await catalogStore.put(catalogKey, encodedDDL);
1657
2486
  }
@@ -1934,7 +2763,7 @@ export class StoreModule {
1934
2763
  // data writes can land closes the power-loss window where a persisted data write
1935
2764
  // outlives a lost marker-delete and resurrects a consumed marker. (Backends
1936
2765
  // without a durability knob no-op the hint — losing it is conservative there, and
1937
- // memory has no crash.) See docs/materialized-views.md § Cross-module atomicity.
2766
+ // memory has no crash.) See docs/mv-backing-host.md § Cross-module atomicity.
1938
2767
  await catalogStore.delete(markerKey, { sync: true }); // single-use, regardless of parse outcome
1939
2768
  try {
1940
2769
  const parsed = JSON.parse(new TextDecoder().decode(raw));
@@ -1999,7 +2828,7 @@ export class StoreModule {
1999
2828
  const existing = await catalogStore.get(key);
2000
2829
  if (existing !== undefined && new TextDecoder().decode(existing) === newDDL)
2001
2830
  return;
2002
- await catalogStore.put(key, new TextEncoder().encode(newDDL));
2831
+ await catalogStore.put(key, this.encodeCatalogDDL(newDDL));
2003
2832
  }
2004
2833
  /**
2005
2834
  * Parse module configuration from vtab args.
@@ -2007,6 +2836,9 @@ export class StoreModule {
2007
2836
  parseConfig(args) {
2008
2837
  return {
2009
2838
  collation: args?.collation || 'NOCASE',
2839
+ // Index-build batch budget; malformed / non-positive clamps to the default
2840
+ // so a bad arg can never disable the bounded-memory flush (see resolveMaxBatchBytes).
2841
+ maxBatchBytes: resolveMaxBatchBytes(args?.max_batch_bytes),
2010
2842
  };
2011
2843
  }
2012
2844
  // --- Engine schema-change subscription (catalog-only tag persistence) ---
@@ -2076,7 +2908,7 @@ export class StoreModule {
2076
2908
  // rename-restore fires one too — so recompute on each event keeps the durable set
2077
2909
  // current. Enqueued on `persistQueue` AFTER the dispatch above, so the `sync` lands
2078
2910
  // after the event's own source-DDL write is queued (the durability ordering the
2079
- // adopt soundness argument relies on — see `docs/materialized-views.md`).
2911
+ // adopt soundness argument relies on — see `docs/mv-backing-host.md` § Cross-module atomicity).
2080
2912
  this.persistStaleMvSetIfChanged();
2081
2913
  };
2082
2914
  /** Dispatch a single engine schema-change event to its catalog-persistence arm. */
@@ -2287,7 +3119,7 @@ export class StoreModule {
2287
3119
  const existingDDL = new TextDecoder().decode(existing);
2288
3120
  if (existingDDL === newDDL)
2289
3121
  return; // identical — no redundant write
2290
- await catalogStore.put(key, new TextEncoder().encode(newDDL));
3122
+ await catalogStore.put(key, this.encodeCatalogDDL(newDDL));
2291
3123
  }
2292
3124
  /**
2293
3125
  * Resolve once all catalog writes queued by async schema-change listeners
@@ -2365,6 +3197,54 @@ export class StoreModule {
2365
3197
  return this.tables.get(tableKey);
2366
3198
  }
2367
3199
  }
3200
+ /** Adapts a raw KV entry stream into the row stream the uniqueness validators consume. */
3201
+ async function* rowsFromEntries(entries) {
3202
+ for await (const entry of entries) {
3203
+ yield deserializeRow(entry.value);
3204
+ }
3205
+ }
3206
+ /**
3207
+ * Per-column key normalizers for a UNIQUE INDEX dedupe signature, drawing each column's
3208
+ * collation from the index column (if it carries one) else the underlying table column —
3209
+ * so the signature honors a per-column collation registered on this connection, matching
3210
+ * write-time enforcement.
3211
+ *
3212
+ * A column whose declared type can never hold text takes the identity normalizer regardless
3213
+ * of its collation (`serializeRowKey` normalizes only string values), so a comparator-only
3214
+ * collation named on an integer column is not rejected here when the engine's own hash sites
3215
+ * would accept it.
3216
+ */
3217
+ function indexDedupeNormalizers(tableSchema, indexSchema, keyNormalizers) {
3218
+ return indexSchema.columns.map(col => {
3219
+ const column = tableSchema.columns[col.index];
3220
+ return keyNormalizers(logicalTypeCanHoldText(column.logicalType)
3221
+ ? (col.collation ?? column.collation)
3222
+ : undefined);
3223
+ });
3224
+ }
3225
+ /**
3226
+ * Throws CONSTRAINT on the first pair of `rows` that share a signature over `columnIndices`.
3227
+ *
3228
+ * SQL NULL semantics: `serializeRowKey` returns null when any constrained column is NULL, and
3229
+ * such rows never collide. A partial `predicate` restricts the judged set to rows it accepts
3230
+ * unambiguously. Shared by {@link StoreModule.validateUniqueOverExistingRows} (constraint
3231
+ * columns) and {@link StoreModule.validateUniqueIndexOverRows} (index columns).
3232
+ */
3233
+ async function assertNoDuplicateRows(rows, tableSchema, columnIndices, normalizers, predicate) {
3234
+ const seen = new Set();
3235
+ for await (const row of rows) {
3236
+ if (predicate && predicate.evaluate(row) !== true)
3237
+ continue;
3238
+ const keySig = serializeRowKey(row, columnIndices, normalizers);
3239
+ if (keySig === null)
3240
+ continue;
3241
+ if (seen.has(keySig)) {
3242
+ const colNames = columnIndices.map(i => tableSchema.columns[i]?.name ?? String(i)).join(', ');
3243
+ throw new QuereusError(`UNIQUE constraint failed: ${tableSchema.name} (${colNames})`, StatusCode.CONSTRAINT);
3244
+ }
3245
+ seen.add(keySig);
3246
+ }
3247
+ }
2368
3248
  /**
2369
3249
  * Reconcile each text PRIMARY KEY column's declared collation at CREATE time.
2370
3250
  *
@@ -2435,4 +3315,55 @@ function buildColumnRemap(oldColumnNames, newColumnNames) {
2435
3315
  return oldIdx !== undefined ? oldIdx : -1;
2436
3316
  });
2437
3317
  }
3318
+ /** Whether `fk` points back at the table that owns it. */
3319
+ function isSelfForeignKey(fk, schemaLower, tableLower) {
3320
+ return (fk.referencedSchema ?? schemaLower).toLowerCase() === schemaLower
3321
+ && fk.referencedTable.toLowerCase() === tableLower;
3322
+ }
3323
+ /**
3324
+ * Retarget a self-referencing foreign key's `referencedTable` across a rename of
3325
+ * the owning table. A pure copy — unlike the CHECK / partial-index rewrites this
3326
+ * touches a name field, not a shared AST, so the caller needs no rollback.
3327
+ * Returns the input array when nothing self-references.
3328
+ */
3329
+ function retargetSelfForeignKeys(fks, schemaName, oldName, newName) {
3330
+ if (!fks)
3331
+ return fks;
3332
+ const schemaLower = schemaName.toLowerCase();
3333
+ const oldLower = oldName.toLowerCase();
3334
+ let changed = false;
3335
+ const next = fks.map(fk => {
3336
+ if (!isSelfForeignKey(fk, schemaLower, oldLower))
3337
+ return fk;
3338
+ changed = true;
3339
+ return { ...fk, referencedTable: newName };
3340
+ });
3341
+ return changed ? Object.freeze(next) : fks;
3342
+ }
3343
+ /**
3344
+ * Rename a column inside a self-referencing foreign key's `referencedColumnNames`
3345
+ * (the names the FK resolves to parent-column indices at enforcement time). Pure
3346
+ * copy, as in {@link retargetSelfForeignKeys}. The child-side `columns` are
3347
+ * indices, unaffected by a rename.
3348
+ */
3349
+ function renameColumnInSelfForeignKeys(fks, schemaName, tableName, oldCol, newCol) {
3350
+ if (!fks)
3351
+ return fks;
3352
+ const schemaLower = schemaName.toLowerCase();
3353
+ const tableLower = tableName.toLowerCase();
3354
+ const oldColLower = oldCol.toLowerCase();
3355
+ let changed = false;
3356
+ const next = fks.map(fk => {
3357
+ if (!isSelfForeignKey(fk, schemaLower, tableLower))
3358
+ return fk;
3359
+ if (!fk.referencedColumnNames?.some(n => n.toLowerCase() === oldColLower))
3360
+ return fk;
3361
+ changed = true;
3362
+ return {
3363
+ ...fk,
3364
+ referencedColumnNames: Object.freeze(fk.referencedColumnNames.map(n => n.toLowerCase() === oldColLower ? newCol : n)),
3365
+ };
3366
+ });
3367
+ return changed ? Object.freeze(next) : fks;
3368
+ }
2438
3369
  //# sourceMappingURL=store-module.js.map