@quereus/store 4.3.2 → 4.4.1

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 (33) hide show
  1. package/README.md +5 -0
  2. package/dist/src/common/cached-kv-store.js +4 -0
  3. package/dist/src/common/cached-kv-store.js.map +1 -1
  4. package/dist/src/common/encoding.d.ts +32 -1
  5. package/dist/src/common/encoding.d.ts.map +1 -1
  6. package/dist/src/common/encoding.js +42 -10
  7. package/dist/src/common/encoding.js.map +1 -1
  8. package/dist/src/common/index.d.ts +3 -2
  9. package/dist/src/common/index.d.ts.map +1 -1
  10. package/dist/src/common/index.js +3 -1
  11. package/dist/src/common/index.js.map +1 -1
  12. package/dist/src/common/json-key.d.ts +96 -0
  13. package/dist/src/common/json-key.d.ts.map +1 -0
  14. package/dist/src/common/json-key.js +204 -0
  15. package/dist/src/common/json-key.js.map +1 -0
  16. package/dist/src/common/key-builder.d.ts +40 -5
  17. package/dist/src/common/key-builder.d.ts.map +1 -1
  18. package/dist/src/common/key-builder.js +59 -21
  19. package/dist/src/common/key-builder.js.map +1 -1
  20. package/dist/src/common/kv-store.d.ts +12 -2
  21. package/dist/src/common/kv-store.d.ts.map +1 -1
  22. package/dist/src/common/store-module.d.ts +64 -15
  23. package/dist/src/common/store-module.d.ts.map +1 -1
  24. package/dist/src/common/store-module.js +349 -125
  25. package/dist/src/common/store-module.js.map +1 -1
  26. package/dist/src/common/store-table.d.ts +125 -11
  27. package/dist/src/common/store-table.d.ts.map +1 -1
  28. package/dist/src/common/store-table.js +235 -34
  29. package/dist/src/common/store-table.js.map +1 -1
  30. package/dist/src/testing/kv-conformance.d.ts.map +1 -1
  31. package/dist/src/testing/kv-conformance.js +32 -0
  32. package/dist/src/testing/kv-conformance.js.map +1 -1
  33. package/package.json +5 -5
@@ -12,15 +12,15 @@
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, logicalTypeCanHoldText, serializeRowKey, isMaintainedTable, renameColumnInIndexPredicates, renameTableInIndexPredicates, renameColumnInCheckConstraints, renameTableInCheckConstraints } from '@quereus/quereus';
15
+ import { AccessPlanBuilder, QuereusError, StatusCode, buildColumnIndexMap, columnDefToSchema, compilePredicate, inferType, tryFoldLiteral, validateAndParse, buildAdvertisementsFromTags, resolveNamedConstraintClass, validateCollationForType, buildUniqueConstraintSchema, buildForeignKeyConstraintSchema, buildCheckConstraintSchema, validateForeignKeyOverExistingRows, appendIndexToTableSchema, logicalTypeCanHoldText, serializeKey, isMaintainedTable, renameColumnInIndexPredicates, renameTableInIndexPredicates, renameColumnInCheckConstraints, renameTableInCheckConstraints } from '@quereus/quereus';
16
16
  import { TransactionCoordinator } from './transaction.js';
17
17
  import { StoreConnection } from './store-connection.js';
18
18
  import { StoreBackingHost } from './backing-host.js';
19
- import { StoreTable, resolvePkKeyCollations, columnCanHoldText, keyOrderMatchesCollation, pkOrderPreservingPrefixLength, withImplicitUniqueIndexes, implicitUniqueIndexName } from './store-table.js';
19
+ import { StoreTable, resolvePkKeyCollations, resolvePkKeyTransforms, resolveIndexKeyTransforms, storeSemanticKeyTransform, columnCanHoldText, keyOrderMatchesCollation, pkOrderPreservingPrefixLength, withImplicitUniqueIndexes, implicitUniqueIndexName } from './store-table.js';
20
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
21
  import { assertNoUnpairedSurrogate } from './encoding.js';
22
22
  import { deserializeRow } from './serialization.js';
23
- import { generateTableDDL, generateIndexDDL, generateViewDDL, generateMaintainedTableDDL, generateIndexTagsDDL, isHiddenImplicitIndex, exposedImplicitIndexes } from '@quereus/quereus';
23
+ import { generateTableDDL, generateIndexDDL, generateDropTableDDL, generateDropIndexDDL, generateViewDDL, generateMaintainedTableDDL, generateIndexTagsDDL, isHiddenImplicitIndex, exposedImplicitIndexes } from '@quereus/quereus';
24
24
  /**
25
25
  * Default serialized-byte budget for a single index-build write batch. Bounds
26
26
  * heap directly: {@link StoreModule.buildIndexEntries} flushes and starts a fresh
@@ -113,6 +113,42 @@ function rangeRoles(colIdx) {
113
113
  function equalityRoles(colIdxs) {
114
114
  return colIdxs.map(colIdx => ({ colIdx, ops: EQ_OPS }));
115
115
  }
116
+ /**
117
+ * Guard the FULL persisted schema text, not just the object's own name: a lone
118
+ * surrogate anywhere in it — a quoted column name, a `default '…'` string literal, a
119
+ * `check` constraint's string constant — folds to U+FFFD under `TextEncoder` and would
120
+ * read back as different schema text than what was created. Independent of the
121
+ * catalog-key builders' identifier guard; it catches what they cannot see.
122
+ */
123
+ function assertPersistableDdlText(ddl) {
124
+ assertNoUnpairedSurrogate(ddl, 'persisted schema text');
125
+ }
126
+ /**
127
+ * The catalog entry a plain view would be persisted as. Building it is the check:
128
+ * {@link buildViewCatalogKey} rejects an unencodable name, and the caller then runs
129
+ * {@link assertPersistableDdlText} over `ddl`. Shared by the write path
130
+ * ({@link StoreModule.saveViewDDL}) and the pre-flight veto
131
+ * ({@link StoreModule.assertCatalogObjectPersistable}) so the two cannot drift.
132
+ */
133
+ function viewCatalogEntry(view) {
134
+ return {
135
+ key: buildViewCatalogKey(view.schemaName, view.name),
136
+ ddl: generateViewDDL(view),
137
+ };
138
+ }
139
+ /**
140
+ * The catalog entry a materialized view would be persisted as, or `undefined` when
141
+ * `table` carries no derivation (not an MV — nothing lands under the MV prefix).
142
+ * Counterpart of {@link viewCatalogEntry}; same write/veto sharing.
143
+ */
144
+ function maintainedViewCatalogEntry(table) {
145
+ if (!isMaintainedTable(table))
146
+ return undefined;
147
+ return {
148
+ key: buildMaterializedViewCatalogKey(table.schemaName, table.name),
149
+ ddl: generateMaintainedTableDDL(table),
150
+ };
151
+ }
116
152
  /**
117
153
  * Generic store module that works with any KVStoreProvider.
118
154
  *
@@ -372,6 +408,14 @@ export class StoreModule {
372
408
  * stores in the set therefore causes no false rejects and keeps the
373
409
  * reject-before-any-side-effect guarantee uniform.
374
410
  */
411
+ // NOTE: the builders below carry the unpaired-surrogate identifier guard, so this
412
+ // enumeration can THROW on an EXISTING schema object, not just on the incoming one —
413
+ // an unrelated CREATE/RENAME in the same schema would then fail with a confusing
414
+ // "cannot store the identifier ..." error. Unreachable today: `create` throws before
415
+ // the table is registered, and a catalog entry written before the guard existed was
416
+ // folded to a real U+FFFD character, which passes. If a lone-surrogate-named table ever
417
+ // does reach the schema manager by another route, skip such an object here (it occupies
418
+ // no store) rather than letting the enumeration throw.
375
419
  collectOccupiedStoreNames(db, schemaName) {
376
420
  const names = new Map();
377
421
  const add = (s) => {
@@ -561,12 +605,15 @@ export class StoreModule {
561
605
  // torn down too — the engine-facing `.indexes` omits them and would leak them.
562
606
  const indexNames = this.materializedIndexNames(table, currentSchema);
563
607
  await this.tearDownTableStorage(schemaName, tableName, indexNames);
564
- // Emit schema change event for table drop
608
+ // Emit schema change event for table drop. The `ddl` is what a sync peer
609
+ // re-executes to replicate the drop — a drop with no DDL crosses the wire as
610
+ // an empty statement and silently changes nothing on the receiver.
565
611
  this.eventEmitter?.emitSchemaChange({
566
612
  type: 'drop',
567
613
  objectType: 'table',
568
614
  schemaName,
569
615
  objectName: tableName,
616
+ ddl: generateDropTableDDL(schemaName, tableName),
570
617
  });
571
618
  }
572
619
  /**
@@ -745,12 +792,17 @@ export class StoreModule {
745
792
  // skips (identical) — see persistCatalogIfChanged.
746
793
  await this.saveTableDDL(updatedSchema);
747
794
  table.markDdlSaved();
748
- // Emit schema change event
795
+ // Emit schema change event. `updatedSchema` (not the pre-create
796
+ // `tableSchema`) is the owner the canonical CREATE INDEX renders against —
797
+ // it is only read for the table's qualified name and column names, but
798
+ // passing the post-create schema keeps the rendering consistent with what a
799
+ // receiving peer regenerates when it compares definitions.
749
800
  this.eventEmitter?.emitSchemaChange({
750
801
  type: 'create',
751
802
  objectType: 'index',
752
803
  schemaName,
753
804
  objectName: indexSchema.name,
805
+ ddl: generateIndexDDL(indexSchema, updatedSchema),
754
806
  });
755
807
  }
756
808
  /**
@@ -808,6 +860,7 @@ export class StoreModule {
808
860
  objectType: 'index',
809
861
  schemaName,
810
862
  objectName: indexName,
863
+ ddl: generateDropIndexDDL(schemaName, indexName),
811
864
  });
812
865
  }
813
866
  /**
@@ -841,6 +894,11 @@ export class StoreModule {
841
894
  const encodeOptions = { collation: keyCollation, normalizers };
842
895
  const pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
843
896
  const pkCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, keyCollation);
897
+ // Key-identity transforms for both halves, mirroring `StoreTable`'s maintenance
898
+ // writes (`updateSecondaryIndexes` / `encodeDataKey`) — a rebuild that skipped them
899
+ // would re-encode a TIMESPAN member under different bytes than DML writes.
900
+ const pkTransforms = resolvePkKeyTransforms(tableSchema.primaryKeyDefinition, tableSchema.columns);
901
+ const indexTransforms = resolveIndexKeyTransforms(indexSchema, tableSchema.columns);
844
902
  const indexDirections = indexSchema.columns.map(col => !!col.desc);
845
903
  const predicate = indexSchema.predicate
846
904
  ? compilePredicate(indexSchema.predicate, tableSchema.columns, tableSchema.name)
@@ -868,9 +926,9 @@ export class StoreModule {
868
926
  // Extract index column values
869
927
  const indexValues = indexSchema.columns.map(col => row[col.index]);
870
928
  if (seen) {
871
- // serializeRowKey returns null when any indexed column is NULL —
929
+ // The signature returns null when any indexed column is NULL —
872
930
  // SQL UNIQUE allows multiple NULLs, so those rows never collide.
873
- const keySig = serializeRowKey(row, indexColIndices, indexNormalizers);
931
+ const keySig = dedupeRowSignature(row, indexColIndices, indexNormalizers, indexTransforms);
874
932
  if (keySig !== null) {
875
933
  if (seen.has(keySig)) {
876
934
  const colNames = indexSchema.columns
@@ -882,13 +940,13 @@ export class StoreModule {
882
940
  }
883
941
  }
884
942
  // Build and store index key
885
- const indexKey = buildIndexKey(indexValues, pkValues, encodeOptions, indexDirections, pkDirections, pkCollations);
943
+ const indexKey = buildIndexKey(indexValues, pkValues, encodeOptions, indexDirections, pkDirections, pkCollations, indexTransforms, pkTransforms);
886
944
  // Index value = the row's encoded DATA key, so an index scan resolves each
887
945
  // entry back to its base row via a direct data-store read (see
888
946
  // `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);
947
+ // PK collations + key transforms as `buildDataKey` / `updateSecondaryIndexes`,
948
+ // so the value byte-matches the data store's key for this row.
949
+ const dataKey = buildDataKey(pkValues, encodeOptions, pkDirections, pkCollations, pkTransforms);
892
950
  batch.put(indexKey, dataKey);
893
951
  // Bound heap: once the accumulated serialized key bytes cross the budget, flush
894
952
  // the batch and start a fresh one. Both callers ITERATE the data store and WRITE
@@ -920,11 +978,20 @@ export class StoreModule {
920
978
  * connection's `db.getKeyNormalizerResolver()` — see {@link buildIndexEntries}.
921
979
  *
922
980
  * 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.
926
- */
927
- async rebuildSecondaryIndexes(schemaName, tableName, table, schema, normalizers) {
981
+ * coordinator. Sound only because every caller calls {@link ddlCommitPendingOps}
982
+ * first, so this module's "committed" is "everything live". A caller that skips that
983
+ * flush would rebuild an index missing its transaction's pending rows.
984
+ *
985
+ * `skipDuplicateCheck` suppresses {@link buildIndexEntries}' in-pass UNIQUE check.
986
+ * The value-rewriting / key-transform ALTER COLUMN arm sets it: its pre-mutation
987
+ * UNIQUE re-validation already judged the ISSUING connection's effective rows (a
988
+ * wrapper's overlay included), and this rebuild sees only THIS module's committed
989
+ * rows — a superset that may retain a row the wrapper's transaction has deleted, so
990
+ * judging it here would spuriously reject a duplicate pair the probe correctly
991
+ * accepted. Mirrors the memory module's deliberately non-enforcing base rebuild
992
+ * (the probe is the only guard).
993
+ */
994
+ async rebuildSecondaryIndexes(schemaName, tableName, table, schema, normalizers, skipDuplicateCheck = false) {
928
995
  const keyCollation = (table.getConfig().collation || 'NOCASE').toUpperCase();
929
996
  const dataStore = await this.getStore(schemaName, tableName, table.getConfig());
930
997
  const maxBatchBytes = table.getConfig().maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
@@ -942,7 +1009,7 @@ export class StoreModule {
942
1009
  clearBatch.delete(entry.key);
943
1010
  }
944
1011
  await clearBatch.write();
945
- await this.buildIndexEntries(dataStore.iterate(buildFullScanBounds()), indexStore, schema, indexSchema, keyCollation, normalizers, false, maxBatchBytes);
1012
+ await this.buildIndexEntries(dataStore.iterate(buildFullScanBounds()), indexStore, schema, indexSchema, keyCollation, normalizers, skipDuplicateCheck, maxBatchBytes);
946
1013
  }
947
1014
  }
948
1015
  /**
@@ -965,7 +1032,7 @@ export class StoreModule {
965
1032
  *
966
1033
  * No index store is written — store UNIQUE enforcement is a full-scan over
967
1034
  * `uniqueConstraints` at write time. The signature is built by
968
- * {@link serializeRowKey} with one normalizer per constrained column, resolved from
1035
+ * {@link dedupeRowSignature} with one normalizer per constrained column, resolved from
969
1036
  * `tableSchema.columns[idx].collation` through the connection's
970
1037
  * `db.getKeyNormalizerResolver()`, so a per-column collation registered with
971
1038
  * `db.registerCollation` is honored (matching write-time `compareSqlValues`
@@ -1183,8 +1250,16 @@ export class StoreModule {
1183
1250
  * ADD COLUMN arm of {@link alterTable}: append the new column, eagerly migrate
1184
1251
  * each row (literal or per-row backfill), and persist. Behavior-preserving
1185
1252
  * extraction of the former `switch` arm.
1253
+ *
1254
+ * The store always appends. A caller-chosen `insertAtIndex` (module-API only; SQL never
1255
+ * produces one) is rejected unless it names the append position, rather than silently
1256
+ * landing the column somewhere the caller did not ask for.
1186
1257
  */
1187
1258
  async alterAddColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull) {
1259
+ if (change.insertAtIndex !== undefined && change.insertAtIndex !== oldSchema.columns.length) {
1260
+ throw new QuereusError(`Store-backed table '${schemaName}.${tableName}' can only ADD COLUMN at the end `
1261
+ + `(position ${oldSchema.columns.length}), not at position ${change.insertAtIndex}`, StatusCode.UNSUPPORTED);
1262
+ }
1188
1263
  // Honor the session `default_collation` for an ADD COLUMN that omits an
1189
1264
  // explicit COLLATE, matching the CREATE path so an ADD-COLUMN-ed text column
1190
1265
  // gets the same collation a CREATE-d one would. The persisted DDL re-emits an
@@ -1221,19 +1296,11 @@ export class StoreModule {
1221
1296
  columns: updatedColumns,
1222
1297
  columnIndexMap: buildColumnIndexMap(updatedColumns),
1223
1298
  };
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
1299
  // Physical rewrite ahead: flush the module's buffered writes so `migrateRows`
1232
1300
  // (a committed-store scan + batch) sees this transaction's rows and re-encodes
1233
1301
  // them under the new column layout. See {@link ddlCommitPendingOps}. Placed
1234
1302
  // 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.
1303
+ // (`hasAnyRows`) so a rejected ALTER leaves the enclosing transaction intact.
1237
1304
  await this.ddlCommitPendingOps();
1238
1305
  // Migrate rows: append the new column's value — a single literal default, or a
1239
1306
  // per-row value derived from the existing row when a backfill evaluator is set.
@@ -1241,33 +1308,13 @@ export class StoreModule {
1241
1308
  await table.migrateRows(remap, defaultValue, backfillEvaluator
1242
1309
  ? { evaluator: backfillEvaluator, notNull: newColSchema.notNull, columnName: newColSchema.name }
1243
1310
  : 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.
1311
+ // Update table schema (column-only) and persist DDL. Any constraint declared inline
1312
+ // on the added column (UNIQUE / CHECK / FOREIGN KEY) arrives as its own follow-up
1313
+ // `addConstraint` call from the engine's `runAddColumn`, which is what persists it —
1314
+ // so this arm neither installs nor persists them, and the schema it hands back stays
1315
+ // column-only.
1257
1316
  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);
1317
+ await this.saveTableDDL(updatedSchema);
1271
1318
  this.eventEmitter?.emitSchemaChange({
1272
1319
  type: 'alter',
1273
1320
  objectType: 'table',
@@ -1313,6 +1360,18 @@ export class StoreModule {
1313
1360
  const updatedUniqueConstraints = (oldSchema.uniqueConstraints ?? [])
1314
1361
  .filter(uc => !uc.columns.includes(colIndex))
1315
1362
  .map(uc => ({ ...uc, columns: Object.freeze(uc.columns.map(i => i > colIndex ? i - 1 : i)) }));
1363
+ // Shift or prune this table's own foreign keys, mirroring the UNIQUE pruning above
1364
+ // and the memory module's `dropColumn`. `foreignKeys[].columns` are indices into
1365
+ // THIS table, so they renumber over the removed slot; a key that loses ANY of its
1366
+ // child columns is removed outright (a key missing a child column is a different
1367
+ // constraint against the parent's key, not a narrowed one). Left unshifted, a
1368
+ // surviving key's index dangles past the column array or silently slides onto an
1369
+ // unrelated column — and `saveTableDDL` below persists whichever it ends up being.
1370
+ // `referencedColumnNames` is resolved against the parent by name at enforcement
1371
+ // time, so the parent side needs nothing here.
1372
+ const updatedForeignKeys = (oldSchema.foreignKeys ?? [])
1373
+ .filter(fk => !fk.columns.includes(colIndex))
1374
+ .map(fk => ({ ...fk, columns: Object.freeze(fk.columns.map(i => i > colIndex ? i - 1 : i)) }));
1316
1375
  const updatedSchema = {
1317
1376
  ...oldSchema,
1318
1377
  columns: Object.freeze(updatedColumns),
@@ -1322,6 +1381,9 @@ export class StoreModule {
1322
1381
  uniqueConstraints: updatedUniqueConstraints.length > 0
1323
1382
  ? Object.freeze(updatedUniqueConstraints)
1324
1383
  : undefined,
1384
+ foreignKeys: updatedForeignKeys.length > 0
1385
+ ? Object.freeze(updatedForeignKeys)
1386
+ : undefined,
1325
1387
  };
1326
1388
  // Physical rewrite ahead — flush buffered writes so `migrateRows` re-encodes
1327
1389
  // this transaction's rows too. See {@link ddlCommitPendingOps}.
@@ -1581,16 +1643,17 @@ export class StoreModule {
1581
1643
  const oldCol = oldSchema.columns[colIndex];
1582
1644
  // Pull exactly one attribute from the change. Each sub-helper returns the new
1583
1645
  // 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 statepreserving 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.
1646
+ // the existing-row UNIQUE re-validation and PK re-key below) plus a DEFERRED
1647
+ // `valueConvert` closure when the change rewrites stored values — the sub-helper
1648
+ // itself mutates nothing. Or null when the column is already in the desired state
1649
+ // preserving the pre-refactor early exits (SET NOT NULL no-op, SET COLLATE
1650
+ // already-explicit) that leave the table and any open transaction untouched.
1588
1651
  let attr;
1589
1652
  if (change.setNotNull !== undefined) {
1590
1653
  attr = await this.alterColumnSetNotNull(table, oldSchema, oldCol, colIndex, change, rows);
1591
1654
  }
1592
1655
  else if (change.setDataType !== undefined) {
1593
- attr = await this.alterColumnSetDataType(table, oldCol, colIndex, change);
1656
+ attr = await this.alterColumnSetDataType(table, oldSchema, oldCol, colIndex, change);
1594
1657
  }
1595
1658
  else if (change.setDefault !== undefined) {
1596
1659
  attr = { newCol: { ...oldCol, defaultValue: change.setDefault }, collationChanged: false };
@@ -1604,7 +1667,12 @@ export class StoreModule {
1604
1667
  if (attr === null) {
1605
1668
  return oldSchema;
1606
1669
  }
1607
- const { newCol, collationChanged, valuesRewritten } = attr;
1670
+ const { newCol, collationChanged, valueConvert } = attr;
1671
+ // Named for the PENDING act, not a completed one: the deferred value rewrite (SET DATA
1672
+ // TYPE conversion / SET NOT NULL backfill) is applied below, after every throw-only
1673
+ // check. Both gates that read this run BEFORE it, while the store still holds the old
1674
+ // values.
1675
+ const rewritesValues = valueConvert !== undefined;
1608
1676
  const updatedColumns = oldSchema.columns.map((c, i) => i === colIndex ? newCol : c);
1609
1677
  // Mirror the memory module (MemoryTableManager.alterColumn): a per-column
1610
1678
  // collation change propagates into every index column ordering by this
@@ -1630,21 +1698,47 @@ export class StoreModule {
1630
1698
  columnIndexMap: buildColumnIndexMap(updatedColumns),
1631
1699
  indexes: updatedIndexes ? Object.freeze(updatedIndexes) : updatedIndexes,
1632
1700
  };
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).
1701
+ // SET DATA TYPE onto/off a type with a key transform (TIMESPAN's total-seconds
1702
+ // groupKey, JSON's structural encoder see `storeSemanticKeyTransform`)
1703
+ // changes the physical KEY BYTES a value encodes to, even when the stored
1704
+ // VALUE bytes don't change (text timespan keeps TEXT physical). Treated
1705
+ // exactly like a collation change below: existing-row UNIQUE re-validation
1706
+ // (equal-elapsed spellings now collide), a PK re-key when the column is a PK
1707
+ // member, and a secondary-index rebuild (index keys encode the column's
1708
+ // values). A same-type no-op ALTER never gets here (`inferType` returns the
1709
+ // shared type object, so the identity check short-circuits).
1710
+ const keyTransformChanged = oldCol.logicalType !== newCol.logicalType
1711
+ && (storeSemanticKeyTransform(oldCol.logicalType) !== undefined
1712
+ || storeSemanticKeyTransform(newCol.logicalType) !== undefined);
1713
+ // Existing-row UNIQUE re-validation (Option A, non-PK UNIQUE). Two ways an ALTER
1714
+ // COLUMN can make rows that were distinct collide, both judged here BEFORE any
1715
+ // mutation/persist so the first collision throws CONSTRAINT and leaves the table
1716
+ // unchanged and writable (matches the ADD CONSTRAINT rollback shape):
1717
+ // - SET COLLATE / a key-transform change: the comparators change ('a'/'A' collide
1718
+ // under NOCASE). Re-scan under the NEW collation (`updatedSchema` carries it).
1719
+ // - a value-REWRITING change (`valueConvert`: SET DATA TYPE, or a SET NOT NULL
1720
+ // null → DEFAULT backfill): the comparators are unchanged but the VALUES collapse
1721
+ // ('1'/'01' → 1; two NULLs → the same DEFAULT). The probe judges the CONVERTED
1722
+ // rows — the same closure the deferred rewrite below applies — while the store
1723
+ // still holds the old values. A standalone `create unique index` synthesizes a
1724
+ // `derivedFromIndex` entry in `uniqueConstraints`, so both declaration shapes are
1725
+ // reachable through this walk.
1639
1726
  // 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) {
1727
+ // its physical re-key/re-validation is the `pkRekeyNeeded` block below.
1728
+ //
1729
+ // NOTE: one full row scan per covering constraint, and this gate now fires on the
1730
+ // COMMON retype/backfill path, not only on the rare collation/transform ones. Fine
1731
+ // while tables covered by several UNIQUE constraints over one column are rare; if a
1732
+ // wide-constraint table makes ALTER COLUMN slow, judge all covering constraints in a
1733
+ // single pass (one `seen` Set per constraint, one stream).
1734
+ if (collationChanged || keyTransformChanged || rewritesValues) {
1642
1735
  const coveringConstraints = (updatedSchema.uniqueConstraints ?? [])
1643
1736
  .filter(uc => uc.columns.includes(colIndex));
1644
1737
  for (const uc of coveringConstraints) {
1645
1738
  // Fresh generator per constraint — an async generator is single-shot. An
1646
1739
  // `EffectiveRowSource` is re-callable for exactly this reason.
1647
- await this.validateUniqueOverExistingRows(rows ? rows() : rowsFromEntries(table.iterateEffectiveEntries(buildFullScanBounds())), updatedSchema, uc, db.getKeyNormalizerResolver());
1740
+ const effectiveRows = rows ? rows() : rowsFromEntries(table.iterateEffectiveEntries(buildFullScanBounds()));
1741
+ await this.validateUniqueOverExistingRows(valueConvert ? convertRowsAtIndex(effectiveRows, colIndex, valueConvert) : effectiveRows, updatedSchema, uc, db.getKeyNormalizerResolver());
1648
1742
  }
1649
1743
  }
1650
1744
  // SET COLLATE on a PRIMARY KEY member (Option B physical re-key): re-encode
@@ -1656,7 +1750,9 @@ export class StoreModule {
1656
1750
  // ALTER PRIMARY KEY. Runs AFTER the non-PK UNIQUE re-validation above so both
1657
1751
  // throw-only checks precede the first store mutation. `updatedSchema.columns`
1658
1752
  // carries the new collation, so the new key bytes follow it.
1659
- if (collationChanged && oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
1753
+ const pkRekeyNeeded = (collationChanged || keyTransformChanged)
1754
+ && oldSchema.primaryKeyDefinition.some(def => def.index === colIndex);
1755
+ if (pkRekeyNeeded) {
1660
1756
  // Physical re-key ahead — flush buffered writes (see
1661
1757
  // {@link ddlCommitPendingOps}). Deliberately AFTER the non-PK UNIQUE
1662
1758
  // re-validation above: that check reads effectively and throws without
@@ -1674,18 +1770,46 @@ export class StoreModule {
1674
1770
  // key collation too (`updatedSchema` carries none on its own).
1675
1771
  await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver());
1676
1772
  }
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());
1773
+ // Deferred value rewrite (SET DATA TYPE physical conversion / SET NOT NULL DEFAULT
1774
+ // backfill): every throw-only check above has passed, so the store mutation is now
1775
+ // safe a rejected ALTER never reaches this point, leaving values, DDL and the
1776
+ // transaction untouched. Flush buffered writes first so `mapRowsAtIndex` rewrites this
1777
+ // transaction's rows too; unflushed, they would replay under the OLD physical type
1778
+ // (see {@link ddlCommitPendingOps}; a re-flush after the PK re-key's own is a no-op).
1779
+ //
1780
+ // NOTE: `valueConvert` and `pkRekeyNeeded` cannot both be set today, and this ordering
1781
+ // depends on that. `valueConvert` comes only from SET DATA TYPE or SET NOT NULL;
1782
+ // SET NOT NULL changes neither collation nor logical type, so it never sets
1783
+ // `pkRekeyNeeded`, and SET DATA TYPE on a PK member is refused both upstream (every live
1784
+ // caller the engine's ALTER COLUMN emitter, see `runAlterColumn` in
1785
+ // runtime/emit/alter-table.ts, and the materialized-view reshape, which declares a
1786
+ // key-column retype inexpressible) AND locally, by `alterColumnSetDataType` itself. If a
1787
+ // PK-member retype is ever admitted (that local guard removed), this rewrite must move IN
1788
+ // FRONT of the `pkRekeyNeeded` block: otherwise `rekeyRows` and its index rebuild encode
1789
+ // the PRE-rewrite values and the rebuild below is skipped, leaving keys and indexes
1790
+ // disagreeing with the stored values.
1791
+ if (valueConvert) {
1792
+ await this.ddlCommitPendingOps();
1793
+ await table.mapRowsAtIndex(colIndex, valueConvert);
1794
+ }
1795
+ // The value rewrite above changed stored column values in place (same PK, new value) —
1796
+ // and a key-identity transform change re-encodes the column's index-key bytes even with
1797
+ // values untouched. Secondary index KEY bytes encode the indexed column VALUES, so any
1798
+ // index covering this column still points at the OLD bytes until rebuilt — an
1799
+ // index-backed lookup for the new value finds nothing (mirrors the memory module's
1800
+ // `valueConvert` rebuild in MemoryTableManager.alterColumn). `rebuildSecondaryIndexes`
1801
+ // reads committed-only, so flush buffered writes first — the value rewrite above
1802
+ // already did, and the transform-only path (text → timespan: no value rewrite) must
1803
+ // too; a re-flush with nothing pending is a no-op. Skipped when the PK re-key above
1804
+ // already rebuilt every index, so no double rebuild. Materialize so an implicit `_uc_*`
1805
+ // over the rewritten column is rebuilt against the new value bytes too. The rebuild is
1806
+ // NON-enforcing (`skipDuplicateCheck`): the UNIQUE re-validation above already judged
1807
+ // the issuer's effective rows, and this module's committed rows may retain a row a
1808
+ // wrapper's transaction has deleted whose converted value duplicates a survivor —
1809
+ // enforcing here would spuriously reject what the probe correctly accepted.
1810
+ if ((rewritesValues || keyTransformChanged) && !pkRekeyNeeded) {
1811
+ await this.ddlCommitPendingOps();
1812
+ await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver(), true);
1689
1813
  }
1690
1814
  table.updateSchema(updatedSchema);
1691
1815
  await this.saveTableDDL(updatedSchema);
@@ -1700,8 +1824,10 @@ export class StoreModule {
1700
1824
  /**
1701
1825
  * SET NOT NULL / DROP NOT NULL sub-branch of {@link alterColumnChange}. Returns the
1702
1826
  * 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.
1827
+ * (the pre-refactor `return oldSchema` no-op). Mutates nothing: the NULL-backfill
1828
+ * probe is throw-only, and the backfill itself is returned as a deferred
1829
+ * `valueConvert` (null → DEFAULT) the caller applies only after every throw-only
1830
+ * check — including the UNIQUE re-validation over the backfilled values — has passed.
1705
1831
  *
1706
1832
  * `rows` is the wrapper-supplied effective row source (the isolation overlay). When present,
1707
1833
  * the reject-vs-backfill decision scans it instead of `table.rowsWithNullAtIndex`: behind the
@@ -1712,7 +1838,7 @@ export class StoreModule {
1712
1838
  */
1713
1839
  async alterColumnSetNotNull(table, oldSchema, oldCol, colIndex, change, rows) {
1714
1840
  let newCol;
1715
- let valuesRewritten = false;
1841
+ let valueConvert;
1716
1842
  if (change.setNotNull === true && !oldCol.notNull) {
1717
1843
  // Backfill NULLs from a literal DEFAULT, or throw.
1718
1844
  let defaultLiteral;
@@ -1743,14 +1869,12 @@ export class StoreModule {
1743
1869
  throw new QuereusError(`column ${change.columnName} contains NULL values`, StatusCode.CONSTRAINT);
1744
1870
  }
1745
1871
  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;
1872
+ // Backfill DEFERRED: the caller flushes + `mapRowsAtIndex`es this closure only
1873
+ // after its throw-only checks pass. Run directly, that fills the transaction's
1874
+ // rows too (the probe saw them). Behind isolation the issuer's overlay-resident
1875
+ // NULL rows are filled by the isolation layer's overlay migration, not here —
1876
+ // this store never holds them.
1877
+ valueConvert = (v) => v === null ? fill : v;
1754
1878
  }
1755
1879
  newCol = { ...oldCol, notNull: true };
1756
1880
  }
@@ -1763,18 +1887,40 @@ export class StoreModule {
1763
1887
  else {
1764
1888
  return null; // already in desired state
1765
1889
  }
1766
- return { newCol, collationChanged: false, valuesRewritten };
1890
+ return { newCol, collationChanged: false, valueConvert };
1767
1891
  }
1768
1892
  /**
1769
1893
  * 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) {
1894
+ * schema. Mutates nothing: for every retype between DIFFERENT logical types, a
1895
+ * throw-only convert pass over the live rows proves every value convertible, and the
1896
+ * conversion itself is returned as a deferred `valueConvert` the caller applies only
1897
+ * after every throw-only check — including the UNIQUE re-validation over the converted
1898
+ * values — has passed. Gated on logical-type IDENTITY, not the physical storage class:
1899
+ * `inferType` flattens aliases to the shared type object (`varchar(50)` IS `TEXT_TYPE`),
1900
+ * so an alias retype is schema-only, while a same-class retype (text → date) still
1901
+ * rejects values the new type refuses and rewrites the rest to the new type's
1902
+ * canonical spelling ('2024-06-05T00:00:00Z' → '2024-06-05') — exactly as an INSERT
1903
+ * would have stored them.
1904
+ *
1905
+ * A retype of a PRIMARY KEY member is refused here rather than converted: the rewrite
1906
+ * below is `mapRowsAtIndex`, a payload-only rewrite that reuses `entry.key` verbatim, so
1907
+ * a PK column's physical key bytes would stay encoded under the OLD type while the value
1908
+ * moves to the new one — unfindable by any lookup under the new encoding, and the same
1909
+ * `keyTransformChanged` path in the caller would re-key from the pre-rewrite values before
1910
+ * this rewrite ever ran (see the NOTE above the `valueConvert` block in
1911
+ * {@link alterColumnChange}). Every live SQL caller already refuses this earlier (the
1912
+ * engine's `runAlterColumn` and the materialized-view reshape's inexpressibility check),
1913
+ * so this only guards a direct module call — mirrors the memory backend's carve-out
1914
+ * (`MemoryTableManager.alterColumn`).
1915
+ */
1916
+ async alterColumnSetDataType(table, oldSchema, oldCol, colIndex, change) {
1774
1917
  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.
1918
+ let valueConvert;
1919
+ if (newLogicalType !== oldCol.logicalType) {
1920
+ if (oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
1921
+ throw new QuereusError(`Cannot change the data type of primary key column '${change.columnName}' of table '${oldSchema.name}'.`, StatusCode.CONSTRAINT);
1922
+ }
1923
+ // Conversion required — walk every row and attempt parse.
1778
1924
  const convert = (v) => {
1779
1925
  try {
1780
1926
  return validateAndParse(v, newLogicalType, change.columnName);
@@ -1783,21 +1929,21 @@ export class StoreModule {
1783
1929
  throw new QuereusError(`Cannot convert value in '${change.columnName}' to ${change.setDataType}`, StatusCode.MISMATCH);
1784
1930
  }
1785
1931
  };
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.
1932
+ // Throw-only pass over the LIVE rows, so an unconvertible value this
1933
+ // transaction inserted rejects the ALTER with the transaction still intact.
1934
+ // The rewrite itself is DEFERRED: the caller flushes + `mapRowsAtIndex`es
1935
+ // this closure only after its remaining throw-only checks pass.
1936
+ // NOTE: pre-existing gap — this scan reads the store's own effective rows and
1937
+ // ignores a wrapper-supplied `rows` stream (unlike the memory module's arm). No
1938
+ // hole under isolation: the wrapper's `validateOverlayMigration` converts every
1939
+ // staged overlay value itself before the underlying mutates.
1789
1940
  for await (const value of table.iterateEffectiveValuesAtIndex(colIndex)) {
1790
1941
  if (value !== null)
1791
1942
  convert(value);
1792
1943
  }
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;
1944
+ valueConvert = (v) => v === null ? v : convert(v);
1799
1945
  }
1800
- return { newCol: { ...oldCol, logicalType: newLogicalType }, collationChanged: false, valuesRewritten };
1946
+ return { newCol: { ...oldCol, logicalType: newLogicalType }, collationChanged: false, valueConvert };
1801
1947
  }
1802
1948
  /**
1803
1949
  * SET COLLATE sub-branch of {@link alterColumnChange}. Returns the recollated column
@@ -1928,6 +2074,17 @@ export class StoreModule {
1928
2074
  }
1929
2075
  }
1930
2076
  // Move physical storage (data directory + index directories).
2077
+ //
2078
+ // NOTE: the relocation runs BEFORE the catalog rewrite below, and nothing undoes it.
2079
+ // Any failure after this point — an IO error, or the DDL-text guard in
2080
+ // `encodeCatalogDDL` firing on an unpaired surrogate in a column name or a `default`
2081
+ // literal — strands the rows under the NEW physical name while the catalog still
2082
+ // names the old table, so the table reads as empty with only the raised error as a
2083
+ // clue. Harmless for today's validation cases: the store-name guard in
2084
+ // `buildDataStoreName` (called above, before any side effect) refuses a bad target
2085
+ // name outright, and a table whose DDL text is unpersistable can never have held
2086
+ // rows in the first place. If a new post-relocation failure mode appears, the
2087
+ // relocation must be undone here or deferred until after the catalog write.
1931
2088
  if (this.provider.renameTableStores) {
1932
2089
  await this.provider.renameTableStores(schemaName, oldName, newName, indexNames);
1933
2090
  }
@@ -2463,15 +2620,11 @@ export class StoreModule {
2463
2620
  }
2464
2621
  /**
2465
2622
  * 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.
2623
+ * The guard is {@link assertPersistableDdlText} — see it for why the FULL text
2624
+ * is checked, not just the object's name.
2472
2625
  */
2473
2626
  encodeCatalogDDL(ddl) {
2474
- assertNoUnpairedSurrogate(ddl, 'persisted schema text');
2627
+ assertPersistableDdlText(ddl);
2475
2628
  return new TextEncoder().encode(ddl);
2476
2629
  }
2477
2630
  /**
@@ -2792,7 +2945,8 @@ export class StoreModule {
2792
2945
  * {@link persistObjectCatalogEntryIfChanged}.
2793
2946
  */
2794
2947
  async saveViewDDL(view) {
2795
- await this.persistObjectCatalogEntryIfChanged(buildViewCatalogKey(view.schemaName, view.name), generateViewDDL(view));
2948
+ const { key, ddl } = viewCatalogEntry(view);
2949
+ await this.persistObjectCatalogEntryIfChanged(key, ddl);
2796
2950
  }
2797
2951
  /** Remove a plain view's catalog entry (on DROP VIEW). */
2798
2952
  async removeViewDDL(schemaName, viewName) {
@@ -2806,13 +2960,50 @@ export class StoreModule {
2806
2960
  * {@link persistObjectCatalogEntryIfChanged}.
2807
2961
  */
2808
2962
  async saveMaterializedViewDDL(mv) {
2809
- await this.persistObjectCatalogEntryIfChanged(buildMaterializedViewCatalogKey(mv.schemaName, mv.name), generateMaintainedTableDDL(mv));
2963
+ const entry = maintainedViewCatalogEntry(mv);
2964
+ // Callers only reach here with a maintained table; the guard keeps the shared
2965
+ // helper's `undefined` arm honest rather than asserting non-null.
2966
+ if (!entry)
2967
+ return;
2968
+ await this.persistObjectCatalogEntryIfChanged(entry.key, entry.ddl);
2810
2969
  }
2811
2970
  /** Remove a materialized view's catalog entry (on DROP MATERIALIZED VIEW). */
2812
2971
  async removeMaterializedViewDDL(schemaName, mvName) {
2813
2972
  const catalogStore = await this.provider.getCatalogStore();
2814
2973
  await catalogStore.delete(buildMaterializedViewCatalogKey(schemaName, mvName));
2815
2974
  }
2975
+ /**
2976
+ * Pre-flight veto (see `VirtualTableModule.assertCatalogObjectPersistable`): refuse a
2977
+ * view / materialized view whose catalog entry this module could not durably write.
2978
+ *
2979
+ * It runs exactly the derivation the write path runs — {@link viewCatalogEntry} /
2980
+ * {@link maintainedViewCatalogEntry} build the key (rejecting an unencodable
2981
+ * identifier) and {@link assertPersistableDdlText} checks the generated DDL text —
2982
+ * so the veto and the write cannot disagree about what is persistable. It is the only
2983
+ * synchronous path a rejection can travel: the actual save is chained onto
2984
+ * `persistQueue` behind a `SchemaChangeNotifier` listener, and both layers swallow.
2985
+ *
2986
+ * Gated on `subscribedDb === db`: this module persists a view/MV only while subscribed
2987
+ * to that `Database`'s change notifier ({@link ensureSchemaSubscription}, driven by the
2988
+ * first `create`/`connect`/`alterTable`/`rehydrateCatalog`). A module registered but
2989
+ * never handed a `db` writes nothing, so vetoing there would reject a definition that
2990
+ * loses nothing. The gate becomes removable if persistence is ever made unconditional
2991
+ * (`bug-store-untouched-table-and-early-view-never-persisted`).
2992
+ */
2993
+ // NOTE: regenerates the object's full DDL text on every CREATE VIEW / CREATE MATERIALIZED
2994
+ // VIEW / view-or-MV SET TAGS, and the persist that follows regenerates it again. DDL is
2995
+ // rare and the text is small, so the duplicate render is not worth caching today; if a
2996
+ // schema-heavy workload (a large `apply schema` deploying many views) ever shows up hot
2997
+ // here, thread the already-built CatalogEntry from the veto through to the write.
2998
+ assertCatalogObjectPersistable(db, kind, object) {
2999
+ if (this.subscribedDb !== db)
3000
+ return;
3001
+ const entry = kind === 'view'
3002
+ ? viewCatalogEntry(object)
3003
+ : maintainedViewCatalogEntry(object);
3004
+ if (entry)
3005
+ assertPersistableDdlText(entry.ddl);
3006
+ }
2816
3007
  /**
2817
3008
  * Compare-write a view/MV catalog entry: write only when the entry is absent or its
2818
3009
  * DDL differs from `newDDL` (skip identical). Unlike the table path's
@@ -3203,6 +3394,21 @@ async function* rowsFromEntries(entries) {
3203
3394
  yield deserializeRow(entry.value);
3204
3395
  }
3205
3396
  }
3397
+ /**
3398
+ * Read-side wrap for a value-rewriting ALTER COLUMN's pre-mutation UNIQUE probe: each row's
3399
+ * value at `colIndex` run through `convert` (the sub-branch's deferred `valueConvert`, which
3400
+ * owns its own NULL handling), so the probe judges the POST-alter values while the store
3401
+ * still holds the old ones. Mutates nothing — the memory module's `convertRowAtIndex`
3402
+ * counterpart. A conversion failure propagates: the setDataType pre-pass already proved
3403
+ * every visible value convertible, so a throw here is unreachable, and surfacing it beats
3404
+ * probing a stale value that could mask a collision.
3405
+ */
3406
+ async function* convertRowsAtIndex(rows, colIndex, convert) {
3407
+ for await (const row of rows) {
3408
+ const newVal = convert(row[colIndex]);
3409
+ yield newVal === row[colIndex] ? row : row.map((v, i) => i === colIndex ? newVal : v);
3410
+ }
3411
+ }
3206
3412
  /**
3207
3413
  * Per-column key normalizers for a UNIQUE INDEX dedupe signature, drawing each column's
3208
3414
  * collation from the index column (if it carries one) else the underlying table column —
@@ -3210,7 +3416,7 @@ async function* rowsFromEntries(entries) {
3210
3416
  * write-time enforcement.
3211
3417
  *
3212
3418
  * 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
3419
+ * of its collation (the signature serializer normalizes only string values), so a comparator-only
3214
3420
  * collation named on an integer column is not rejected here when the engine's own hash sites
3215
3421
  * would accept it.
3216
3422
  */
@@ -3222,20 +3428,38 @@ function indexDedupeNormalizers(tableSchema, indexSchema, keyNormalizers) {
3222
3428
  : undefined);
3223
3429
  });
3224
3430
  }
3431
+ /**
3432
+ * Collation- and identity-aware dedupe signature of `row` over `colIndices`: each value
3433
+ * runs through its column's key transform (`storeSemanticKeyTransform` — TIMESPAN's
3434
+ * total-seconds `groupKey`, so 'PT1H' and 'PT60M' sign identically; JSON's structural
3435
+ * bytes, so reorder-equal objects sign identically) before `serializeKey` applies the
3436
+ * per-column normalizer. Returns null when any covered value
3437
+ * is NULL (SQL UNIQUE allows multiple NULLs). The build/validate-time twin of the
3438
+ * write-time typed compare in `StoreTable.uniqueColumnComparators`.
3439
+ */
3440
+ function dedupeRowSignature(row, colIndices, normalizers, transforms) {
3441
+ const values = colIndices.map((c, i) => {
3442
+ const v = row[c];
3443
+ const transform = transforms[i];
3444
+ return transform && v !== null ? transform(v) : v;
3445
+ });
3446
+ return serializeKey(values, normalizers);
3447
+ }
3225
3448
  /**
3226
3449
  * Throws CONSTRAINT on the first pair of `rows` that share a signature over `columnIndices`.
3227
3450
  *
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).
3451
+ * SQL NULL semantics: {@link dedupeRowSignature} returns null when any constrained column is
3452
+ * NULL, and such rows never collide. A partial `predicate` restricts the judged set to rows
3453
+ * it accepts unambiguously. Shared by {@link StoreModule.validateUniqueOverExistingRows}
3454
+ * (constraint columns) and {@link StoreModule.validateUniqueIndexOverRows} (index columns).
3232
3455
  */
3233
3456
  async function assertNoDuplicateRows(rows, tableSchema, columnIndices, normalizers, predicate) {
3457
+ const transforms = columnIndices.map(i => storeSemanticKeyTransform(tableSchema.columns[i]?.logicalType));
3234
3458
  const seen = new Set();
3235
3459
  for await (const row of rows) {
3236
3460
  if (predicate && predicate.evaluate(row) !== true)
3237
3461
  continue;
3238
- const keySig = serializeRowKey(row, columnIndices, normalizers);
3462
+ const keySig = dedupeRowSignature(row, columnIndices, normalizers, transforms);
3239
3463
  if (keySig === null)
3240
3464
  continue;
3241
3465
  if (seen.has(keySig)) {