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