@quereus/store 4.5.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +34 -6
  2. package/dist/src/common/encoding.d.ts +4 -3
  3. package/dist/src/common/encoding.d.ts.map +1 -1
  4. package/dist/src/common/encoding.js +17 -13
  5. package/dist/src/common/encoding.js.map +1 -1
  6. package/dist/src/common/events.d.ts +31 -25
  7. package/dist/src/common/events.d.ts.map +1 -1
  8. package/dist/src/common/events.js +38 -39
  9. package/dist/src/common/events.js.map +1 -1
  10. package/dist/src/common/implicit-unique-index.d.ts +80 -0
  11. package/dist/src/common/implicit-unique-index.d.ts.map +1 -0
  12. package/dist/src/common/implicit-unique-index.js +148 -0
  13. package/dist/src/common/implicit-unique-index.js.map +1 -0
  14. package/dist/src/common/index.d.ts +7 -3
  15. package/dist/src/common/index.d.ts.map +1 -1
  16. package/dist/src/common/index.js +5 -2
  17. package/dist/src/common/index.js.map +1 -1
  18. package/dist/src/common/json-key.d.ts +30 -2
  19. package/dist/src/common/json-key.d.ts.map +1 -1
  20. package/dist/src/common/json-key.js +49 -2
  21. package/dist/src/common/json-key.js.map +1 -1
  22. package/dist/src/common/key-builder.d.ts +40 -20
  23. package/dist/src/common/key-builder.d.ts.map +1 -1
  24. package/dist/src/common/key-builder.js +31 -23
  25. package/dist/src/common/key-builder.js.map +1 -1
  26. package/dist/src/common/kv-store.d.ts +10 -0
  27. package/dist/src/common/kv-store.d.ts.map +1 -1
  28. package/dist/src/common/pk-key-resolution.d.ts +348 -0
  29. package/dist/src/common/pk-key-resolution.d.ts.map +1 -0
  30. package/dist/src/common/pk-key-resolution.js +481 -0
  31. package/dist/src/common/pk-key-resolution.js.map +1 -0
  32. package/dist/src/common/store-module-access-plan.d.ts +32 -0
  33. package/dist/src/common/store-module-access-plan.d.ts.map +1 -0
  34. package/dist/src/common/store-module-access-plan.js +428 -0
  35. package/dist/src/common/store-module-access-plan.js.map +1 -0
  36. package/dist/src/common/store-module-alter-column.d.ts +23 -0
  37. package/dist/src/common/store-module-alter-column.d.ts.map +1 -0
  38. package/dist/src/common/store-module-alter-column.js +389 -0
  39. package/dist/src/common/store-module-alter-column.js.map +1 -0
  40. package/dist/src/common/store-module-alter.d.ts +55 -0
  41. package/dist/src/common/store-module-alter.d.ts.map +1 -0
  42. package/dist/src/common/store-module-alter.js +504 -0
  43. package/dist/src/common/store-module-alter.js.map +1 -0
  44. package/dist/src/common/store-module-base.d.ts +268 -0
  45. package/dist/src/common/store-module-base.d.ts.map +1 -0
  46. package/dist/src/common/store-module-base.js +362 -0
  47. package/dist/src/common/store-module-base.js.map +1 -0
  48. package/dist/src/common/store-module-catalog.d.ts +269 -0
  49. package/dist/src/common/store-module-catalog.d.ts.map +1 -0
  50. package/dist/src/common/store-module-catalog.js +462 -0
  51. package/dist/src/common/store-module-catalog.js.map +1 -0
  52. package/dist/src/common/store-module-index-build.d.ts +88 -0
  53. package/dist/src/common/store-module-index-build.d.ts.map +1 -0
  54. package/dist/src/common/store-module-index-build.js +280 -0
  55. package/dist/src/common/store-module-index-build.js.map +1 -0
  56. package/dist/src/common/store-module-index.d.ts +116 -0
  57. package/dist/src/common/store-module-index.d.ts.map +1 -0
  58. package/dist/src/common/store-module-index.js +373 -0
  59. package/dist/src/common/store-module-index.js.map +1 -0
  60. package/dist/src/common/store-module-rename.d.ts +49 -0
  61. package/dist/src/common/store-module-rename.d.ts.map +1 -0
  62. package/dist/src/common/store-module-rename.js +268 -0
  63. package/dist/src/common/store-module-rename.js.map +1 -0
  64. package/dist/src/common/store-module-schema-rewrite.d.ts +57 -0
  65. package/dist/src/common/store-module-schema-rewrite.d.ts.map +1 -0
  66. package/dist/src/common/store-module-schema-rewrite.js +140 -0
  67. package/dist/src/common/store-module-schema-rewrite.js.map +1 -0
  68. package/dist/src/common/store-module-schema-sync.d.ts +232 -0
  69. package/dist/src/common/store-module-schema-sync.d.ts.map +1 -0
  70. package/dist/src/common/store-module-schema-sync.js +529 -0
  71. package/dist/src/common/store-module-schema-sync.js.map +1 -0
  72. package/dist/src/common/store-module.d.ts +43 -909
  73. package/dist/src/common/store-module.d.ts.map +1 -1
  74. package/dist/src/common/store-module.js +184 -3276
  75. package/dist/src/common/store-module.js.map +1 -1
  76. package/dist/src/common/store-table-base.d.ts +539 -0
  77. package/dist/src/common/store-table-base.d.ts.map +1 -0
  78. package/dist/src/common/store-table-base.js +900 -0
  79. package/dist/src/common/store-table-base.js.map +1 -0
  80. package/dist/src/common/store-table-constraints.d.ts +237 -0
  81. package/dist/src/common/store-table-constraints.d.ts.map +1 -0
  82. package/dist/src/common/store-table-constraints.js +624 -0
  83. package/dist/src/common/store-table-constraints.js.map +1 -0
  84. package/dist/src/common/store-table-scan.d.ts +452 -0
  85. package/dist/src/common/store-table-scan.d.ts.map +1 -0
  86. package/dist/src/common/store-table-scan.js +901 -0
  87. package/dist/src/common/store-table-scan.js.map +1 -0
  88. package/dist/src/common/store-table.d.ts +71 -1144
  89. package/dist/src/common/store-table.d.ts.map +1 -1
  90. package/dist/src/common/store-table.js +168 -2385
  91. package/dist/src/common/store-table.js.map +1 -1
  92. package/dist/src/testing/index.d.ts +12 -0
  93. package/dist/src/testing/index.d.ts.map +1 -0
  94. package/dist/src/testing/index.js +12 -0
  95. package/dist/src/testing/index.js.map +1 -0
  96. package/dist/src/testing/kv-assert.d.ts +19 -0
  97. package/dist/src/testing/kv-assert.d.ts.map +1 -0
  98. package/dist/src/testing/kv-assert.js +25 -0
  99. package/dist/src/testing/kv-assert.js.map +1 -0
  100. package/dist/src/testing/kv-conformance.d.ts.map +1 -1
  101. package/dist/src/testing/kv-conformance.js +1 -15
  102. package/dist/src/testing/kv-conformance.js.map +1 -1
  103. package/dist/src/testing/kv-provider-conformance.d.ts +54 -0
  104. package/dist/src/testing/kv-provider-conformance.d.ts.map +1 -0
  105. package/dist/src/testing/kv-provider-conformance.js +183 -0
  106. package/dist/src/testing/kv-provider-conformance.js.map +1 -0
  107. package/package.json +8 -8
@@ -0,0 +1,900 @@
1
+ /**
2
+ * State and storage plumbing shared by every layer of the KVStore-backed virtual
3
+ * table: schema/key-encoding state, store + coordinator handles, statistics,
4
+ * transaction lifecycle, and the effective-read primitives (committed store
5
+ * merged with the coordinator's pending ops) the read and write paths build on.
6
+ *
7
+ * Base of the store-table layer chain:
8
+ * StoreTableBase -> StoreTableScan -> StoreTableConstraints -> StoreTable
9
+ */
10
+ import { VirtualTable, QuereusError, StatusCode, hasSemanticOrdering, coerceRowToSchema, } from '@quereus/quereus';
11
+ import { bytesToHex, compareBytes } from './bytes.js';
12
+ import { StoreConnection } from './store-connection.js';
13
+ import { buildDataKey, buildFullScanBounds, buildPkPrefixBounds, buildStatsKey, } from './key-builder.js';
14
+ import { deserializeRow, serializeStats, deserializeStats, } from './serialization.js';
15
+ import { resolveIndexKeyCollations, resolvePkKeyCollations, resolvePkKeyTransforms, resolvePkSemanticEquality, storeSemanticKeyTransform } from './pk-key-resolution.js';
16
+ import { withImplicitUniqueIndexes } from './implicit-unique-index.js';
17
+ /** Number of mutations before persisting statistics. */
18
+ const STATS_FLUSH_INTERVAL = 100;
19
+ /** True when `key` falls within the (gte/gt/lte/lt) window of `bounds`. */
20
+ function keyWithinBounds(key, bounds) {
21
+ if (bounds.gte && compareBytes(key, bounds.gte) < 0)
22
+ return false;
23
+ if (bounds.gt && compareBytes(key, bounds.gt) <= 0)
24
+ return false;
25
+ if (bounds.lte && compareBytes(key, bounds.lte) > 0)
26
+ return false;
27
+ if (bounds.lt && compareBytes(key, bounds.lt) >= 0)
28
+ return false;
29
+ return true;
30
+ }
31
+ /**
32
+ * Storage-facing base of the generic KVStore-backed virtual table.
33
+ *
34
+ * Holds the table's schema-derived key encoding state, its data/index/stats
35
+ * store handles and transaction coordinator, its statistics accounting, and the
36
+ * effective-row reads every higher layer issues. Platform-specific behavior is
37
+ * delegated to the {@link StoreTableModule}.
38
+ *
39
+ * NOTE: `export` here is required, not intentional API surface — `StoreTable extends`
40
+ * this class transitively, so TypeScript cannot emit its declaration unless every base
41
+ * in the chain is nameable (TS4020). The package index deliberately does not re-export
42
+ * this class or the two layers above it; if an API-surface extractor is ever added, it
43
+ * should treat them as internal rather than prompt a re-export.
44
+ */
45
+ export class StoreTableBase extends VirtualTable {
46
+ storeModule;
47
+ config;
48
+ store = null;
49
+ storeInitPromise = null;
50
+ indexStores = new Map();
51
+ statsStore = null;
52
+ coordinator = null;
53
+ /**
54
+ * Disposer returned by the coordinator's `registerCallbacks`, captured on the
55
+ * first {@link attachCoordinator}. Run by {@link dispose} at hard eviction to
56
+ * deregister this instance's {stats apply/discard} pair from the module-wide
57
+ * coordinator; null until attached, and nulled again after dispose.
58
+ */
59
+ coordinatorDisposer = null;
60
+ connection = null;
61
+ eventEmitter;
62
+ encodeOptions;
63
+ pkDirections;
64
+ /**
65
+ * Per-PK-column KEY collation (see {@link resolvePkKeyCollations}). Drives the
66
+ * physical encoding of every data key and the PK suffix of every secondary-index
67
+ * key, so a text PK column declared BINARY/NOCASE/RTRIM is keyed under its own
68
+ * collation rather than one fixed table-level collation. Recomputed on every
69
+ * {@link updateSchema} (an ALTER COLUMN SET COLLATE on a PK member changes it).
70
+ */
71
+ pkKeyCollations;
72
+ /**
73
+ * Per-PK-column key-identity value transform (see {@link resolvePkKeyTransforms}).
74
+ * Threaded alongside {@link pkKeyCollations} into every data-key / PK-suffix /
75
+ * PK-prefix-bounds encode, so a semantic-ordering PK member (TIMESPAN) keys
76
+ * semantically-equal spellings to one physical key. Recomputed on every
77
+ * {@link updateSchema}.
78
+ */
79
+ pkKeyTransforms;
80
+ /**
81
+ * Per-PK-column typed EQUALITY comparator for the semantic-ordering members only —
82
+ * `undefined` for every other member, which `StoreTableConstraints.keysEqual` still compares with
83
+ * strict `!==` as before. Keeps self-PK exclusion and PK-identity questions in
84
+ * agreement with the physical key identity above ('PT1H' row IS the 'PT60M' row).
85
+ */
86
+ pkSemanticEquality;
87
+ /**
88
+ * `db.getCollationResolver()`, bound once at construction. Every collation-aware
89
+ * VALUE comparison this table makes — pushed-constraint re-check, UNIQUE conflict
90
+ * detection — resolves names through it, so a collation registered with
91
+ * `db.registerCollation` on *this* connection is honored rather than silently
92
+ * degraded to BINARY by the process-global built-in registry.
93
+ *
94
+ * Only the resolver closure is cached: it reads the live registry, so a collation
95
+ * registered after connect is still visible. Resolved *functions* are hoisted no
96
+ * further than the comparator or constraint check that resolved them.
97
+ *
98
+ * Its key-bytes counterpart, `db.getKeyNormalizerResolver()`, is bound into
99
+ * {@link encodeOptions} so the names in {@link pkKeyCollations} resolve to the same
100
+ * per-connection registry.
101
+ */
102
+ collationResolver;
103
+ ddlSaved = false;
104
+ // Statistics tracking
105
+ cachedStats = null;
106
+ pendingStatsDelta = 0;
107
+ mutationCount = 0;
108
+ statsFlushPending = false;
109
+ /**
110
+ * {@link tableSchema} with a hidden `_uc_*` secondary index materialized per
111
+ * non-derived UNIQUE constraint that no explicit index already covers (see
112
+ * {@link withImplicitUniqueIndexes}) — the schema that drives UNIQUE ENFORCEMENT
113
+ * (`StoreTableConstraints.findIndexForUniqueConstraint`) and physical index MAINTENANCE
114
+ * (`StoreTableConstraints.updateSecondaryIndexes`).
115
+ *
116
+ * Deliberately SEPARATE from the engine-facing {@link tableSchema}: the engine
117
+ * reads `tableInstance.tableSchema` at CREATE (`SchemaManager.finalizeCreatedTableSchema`)
118
+ * and the read-query planner reads the registered schema, and NEITHER may see
119
+ * `_uc_*` (mirrors the memory backend keeping its materialized schema
120
+ * manager-local; a plain UNIQUE gets no read-side plan from `_uc_*`, only
121
+ * enforcement). Recomputed alongside `tableSchema` in the constructor and
122
+ * {@link updateSchema}.
123
+ */
124
+ materializedSchema;
125
+ constructor(db, storeModule, tableSchema, config, eventEmitter, isConnected = false) {
126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
+ super(db, storeModule, tableSchema.schemaName, tableSchema.name);
128
+ this.storeModule = storeModule;
129
+ // The engine-facing schema stays NON-materialized (the engine registers it and the
130
+ // read-query planner reads it — neither may see `_uc_*`); the hidden `_uc_*`
131
+ // per-UNIQUE index that lets plain UNIQUE enforce via a point-seek lives only in
132
+ // {@link materializedSchema}.
133
+ this.tableSchema = tableSchema;
134
+ this.materializedSchema = withImplicitUniqueIndexes(tableSchema);
135
+ this.config = config;
136
+ this.eventEmitter = eventEmitter;
137
+ this.collationResolver = db.getCollationResolver();
138
+ this.encodeOptions = {
139
+ collation: config.collation || 'NOCASE',
140
+ normalizers: db.getKeyNormalizerResolver(),
141
+ };
142
+ this.pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
143
+ this.pkKeyCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
144
+ this.pkKeyTransforms = resolvePkKeyTransforms(tableSchema.primaryKeyDefinition, tableSchema.columns);
145
+ this.pkSemanticEquality = resolvePkSemanticEquality(tableSchema.primaryKeyDefinition, tableSchema.columns);
146
+ // Validate the MATERIALIZED schema, so a `_uc_*` whose key collations cannot key
147
+ // is rejected up front just like an explicit index would be.
148
+ this.validateKeyCollations(this.materializedSchema, this.pkKeyCollations);
149
+ this.validateSemanticKeyTransforms(this.materializedSchema);
150
+ this.ddlSaved = isConnected;
151
+ }
152
+ /**
153
+ * Reject, at DDL time, any collation this table's key encoding would need but cannot
154
+ * use — before a single row is written under bytes the connection cannot reproduce.
155
+ *
156
+ * A collation keys a persisted structure only if it carries a KEY NORMALIZER (the
157
+ * `(s) => string` whose output equality partitions strings exactly as its comparator
158
+ * does). `db.registerCollation(name, cmp)` with no `{ normalizer }` gives a collation
159
+ * that can order rows but not key them; an unregistered name gives nothing at all.
160
+ * Both raise here rather than at the first insert.
161
+ *
162
+ * Checked over exactly the collations the key encoding actually uses:
163
+ * - every defined `pkKeyCollations` entry (text-capable PK columns);
164
+ * - every defined index key collation of every index in the MATERIALIZED schema
165
+ * (`resolveIndexKeyCollations` — the index column's COLLATE, else the table
166
+ * column's declared collation, else BINARY; hidden `_uc_*` indexes included).
167
+ *
168
+ * Two deliberate consequences of validating the actual index key collations rather
169
+ * than blanket-requiring the table key collation K whenever any index column is
170
+ * text-capable (the pre-per-column-index-collation behavior):
171
+ * - an index column declaring a comparator-only collation is rejected here instead
172
+ * of silently keying under K;
173
+ * - a table whose K has no normalizer but whose key encoding never uses K (all
174
+ * index columns BINARY/type-native, PK needing no K) is no longer unopenable.
175
+ *
176
+ * Blast radius: this also fires on catalog rehydration. Reopening a persisted database
177
+ * from a connection that has not re-registered its custom collation now throws at
178
+ * CREATE-TABLE-from-catalog rather than silently reading rows under a key layout it
179
+ * cannot reproduce. See `docs/plugins.md`.
180
+ *
181
+ * Takes `pkKeyCollations` rather than reading the field so `updateSchema` can validate
182
+ * the incoming schema BEFORE it overwrites its own state with it.
183
+ */
184
+ validateKeyCollations(schema, pkKeyCollations) {
185
+ const names = new Set();
186
+ for (const collation of pkKeyCollations) {
187
+ if (collation !== undefined)
188
+ names.add(collation);
189
+ }
190
+ for (const index of schema.indexes ?? []) {
191
+ for (const collation of resolveIndexKeyCollations(index, schema.columns)) {
192
+ if (collation !== undefined)
193
+ names.add(collation);
194
+ }
195
+ }
196
+ this.assertCollationsCanKey(names);
197
+ }
198
+ /**
199
+ * Throw-only half of {@link validateKeyCollations}: raise unless every name in
200
+ * `names` resolves to a registered key normalizer.
201
+ */
202
+ assertCollationsCanKey(names) {
203
+ const dbInternal = this.db;
204
+ for (const name of names) {
205
+ if (dbInternal._getCollationNormalizer(name))
206
+ continue;
207
+ // Unregistered names raise `no such collation sequence: X` from the resolver;
208
+ // reaching past it means the collation exists but is comparator-only.
209
+ this.collationResolver(name);
210
+ throw new QuereusError(`collation ${name} cannot key a persisted structure: no key normalizer registered `
211
+ + `— pass { normalizer } to registerCollation`, StatusCode.ERROR);
212
+ }
213
+ }
214
+ /**
215
+ * Throw-only pre-check for `StoreModuleIndex.createIndex`: reject a NEW index whose
216
+ * key collations cannot key (comparator-only or unregistered), BEFORE the physical
217
+ * index store is created — so a rejection leaves no store directory behind even for
218
+ * an empty table (where the build itself would encode nothing and the later
219
+ * `updateSchema` validation would fire only after the store exists).
220
+ */
221
+ assertIndexKeyCollationsCanKey(indexSchema) {
222
+ const names = new Set();
223
+ for (const collation of resolveIndexKeyCollations(indexSchema, this.tableSchema.columns)) {
224
+ if (collation !== undefined)
225
+ names.add(collation);
226
+ }
227
+ this.assertCollationsCanKey(names);
228
+ }
229
+ /**
230
+ * Reject, at DDL time, a key member whose semantic-ordering logical type has no
231
+ * store key transform — before a single row is keyed under bytes whose order the
232
+ * type's `compare` (and so the isolation merge, the memory oracle, and Sort)
233
+ * disagrees with.
234
+ *
235
+ * A semantic-ordering type keys soundly only through an order- and
236
+ * identity-preserving transform ({@link storeSemanticKeyTransform}: the type's
237
+ * `groupKey`, or a store-local encoder like JSON's structural one). This gap used
238
+ * to be undetectable — JSON sat here transform-less for a release, keying in
239
+ * canonical-text order while every comparator ordered structurally, and the only
240
+ * symptom was a misaligned overlay merge deep inside a transaction. Unreachable
241
+ * today (TIMESPAN and JSON are both covered); it exists so the NEXT
242
+ * semantic-ordering type fails loudly at CREATE TABLE instead.
243
+ *
244
+ * Checked over the same materialized schema as {@link validateKeyCollations}:
245
+ * every PK member and every (explicit or hidden `_uc_*`) index column.
246
+ *
247
+ * CAVEAT: this can only check that a transform EXISTS — a `groupKey` that is
248
+ * identity-faithful but not order-preserving passes undetected, so a new type's
249
+ * transform still needs its byte order argued against its `compare` (see
250
+ * json-key.ts for the shape of that argument).
251
+ */
252
+ validateSemanticKeyTransforms(schema) {
253
+ const check = (colIndex, member) => {
254
+ const col = schema.columns[colIndex];
255
+ const type = col?.logicalType;
256
+ if (!hasSemanticOrdering(type) || storeSemanticKeyTransform(type) !== undefined)
257
+ return;
258
+ throw new QuereusError(`column ${col?.name ?? String(colIndex)} (${member}): semantic-ordering type `
259
+ + `${type.name} cannot key a persisted structure: its raw encoding would not `
260
+ + `reproduce the type's compare order — the type needs an order-preserving `
261
+ + `groupKey or a store key encoder (see storeSemanticKeyTransform)`, StatusCode.ERROR);
262
+ };
263
+ for (const def of schema.primaryKeyDefinition)
264
+ check(def.index, 'primary key');
265
+ for (const index of schema.indexes ?? []) {
266
+ for (const col of index.columns)
267
+ check(col.index, `index ${index.name}`);
268
+ }
269
+ }
270
+ /** Get the table configuration. */
271
+ getConfig() {
272
+ return this.config;
273
+ }
274
+ /** Get the table schema (engine-facing, WITHOUT the hidden `_uc_*` indexes). */
275
+ getSchema() {
276
+ return this.tableSchema;
277
+ }
278
+ /**
279
+ * Get the enforcement schema — {@link getSchema} plus the hidden `_uc_*`
280
+ * index materialized per non-derived UNIQUE. Used by the module to resolve the
281
+ * physical index store to build/tear down during ALTER; never registered with
282
+ * the engine.
283
+ */
284
+ getMaterializedSchema() {
285
+ return this.materializedSchema;
286
+ }
287
+ /**
288
+ * Update the table schema after an ALTER TABLE / CREATE INDEX operation.
289
+ *
290
+ * Validates the incoming schema's key collations before adopting any of it, so a
291
+ * rejection leaves this table on its previous, consistent schema.
292
+ */
293
+ updateSchema(newSchema) {
294
+ // `newSchema` is the engine-facing (non-materialized) schema; recompute the
295
+ // enforcement copy alongside it. Validate the MATERIALIZED copy so a `_uc_*`
296
+ // whose key collations cannot key is caught before adoption.
297
+ const materialized = withImplicitUniqueIndexes(newSchema);
298
+ const pkKeyCollations = resolvePkKeyCollations(newSchema.primaryKeyDefinition, newSchema.columns, this.encodeOptions.collation ?? 'NOCASE');
299
+ this.validateKeyCollations(materialized, pkKeyCollations);
300
+ this.validateSemanticKeyTransforms(materialized);
301
+ this.tableSchema = newSchema;
302
+ this.materializedSchema = materialized;
303
+ this.pkDirections = newSchema.primaryKeyDefinition.map(pk => !!pk.desc);
304
+ this.pkKeyCollations = pkKeyCollations;
305
+ this.pkKeyTransforms = resolvePkKeyTransforms(newSchema.primaryKeyDefinition, newSchema.columns);
306
+ this.pkSemanticEquality = resolvePkSemanticEquality(newSchema.primaryKeyDefinition, newSchema.columns);
307
+ }
308
+ /**
309
+ * Mark the table's DDL as already persisted to the catalog, so the lazy
310
+ * first-store-access save in {@link initializeStore} is skipped. Called by
311
+ * `StoreModule.createIndex` / `dropIndex` after they eagerly write the catalog
312
+ * bundle, so a subsequent INSERT does not redundantly re-persist identical DDL.
313
+ */
314
+ markDdlSaved() {
315
+ this.ddlSaved = true;
316
+ }
317
+ /** Close and forget a cached index-store handle, if any. */
318
+ async releaseIndexStore(indexName) {
319
+ const cached = this.indexStores.get(indexName);
320
+ if (!cached)
321
+ return;
322
+ this.indexStores.delete(indexName);
323
+ try {
324
+ await cached.close();
325
+ }
326
+ catch { /* close is best-effort */ }
327
+ }
328
+ /**
329
+ * Returns true if the table has at least one LIVE row — the open transaction's
330
+ * pending overlay over the committed store. Stops after the first hit.
331
+ *
332
+ * Effective (not committed-only) because its one caller, `ADD COLUMN`'s NOT
333
+ * NULL-without-DEFAULT rejection, must see rows the same transaction inserted:
334
+ * they are migrated too (the arm DDL-commits before `migrateRows`), and a
335
+ * committed-only probe would wave them through only to give them a NULL in a
336
+ * NOT NULL column. Conversely, a pending delete of the last row makes the table
337
+ * legitimately empty. Reading effectively keeps the check throw-only and
338
+ * BEFORE the DDL-commit, so a rejection leaves the transaction intact.
339
+ */
340
+ async hasAnyRows() {
341
+ for await (const _entry of this.iterateEffectiveEntries(buildFullScanBounds())) {
342
+ return true;
343
+ }
344
+ return false;
345
+ }
346
+ /**
347
+ * Yield the LIVE value of `colIndex` for every row — the open transaction's
348
+ * pending overlay over the committed store. For throw-only ALTER COLUMN probes
349
+ * that must run BEFORE the arm's DDL-commit (see `StoreModule.ddlCommitPendingOps`):
350
+ * they have to see rows this transaction wrote, because those rows are rewritten
351
+ * too, and a rejection here must leave the transaction intact.
352
+ */
353
+ async *iterateEffectiveValuesAtIndex(colIndex) {
354
+ for await (const entry of this.iterateEffectiveEntries(buildFullScanBounds())) {
355
+ yield deserializeRow(entry.value)[colIndex];
356
+ }
357
+ }
358
+ /**
359
+ * Count the LIVE rows whose column at `colIndex` holds NULL (see
360
+ * {@link iterateEffectiveValuesAtIndex} for why this reads effectively).
361
+ * Used by ALTER COLUMN SET NOT NULL to decide whether the tightening is safe.
362
+ */
363
+ async rowsWithNullAtIndex(colIndex) {
364
+ let count = 0;
365
+ for await (const value of this.iterateEffectiveValuesAtIndex(colIndex)) {
366
+ if (value === null)
367
+ count++;
368
+ }
369
+ return count;
370
+ }
371
+ /**
372
+ * Ensure the data store is open and DDL is persisted.
373
+ * Uses a promise-based singleton pattern to prevent race conditions
374
+ * when multiple concurrent queries access the same table.
375
+ */
376
+ ensureStore() {
377
+ if (this.store) {
378
+ return Promise.resolve(this.store);
379
+ }
380
+ if (this.storeInitPromise) {
381
+ return this.storeInitPromise;
382
+ }
383
+ this.storeInitPromise = this.initializeStore();
384
+ return this.storeInitPromise;
385
+ }
386
+ /**
387
+ * Internal method to actually initialize the store.
388
+ * Only called once per table instance.
389
+ */
390
+ async initializeStore() {
391
+ const tableKey = `${this.schemaName}.${this.tableName}`.toLowerCase();
392
+ try {
393
+ this.store = await this.storeModule.getStore(this.schemaName, this.tableName, this.config);
394
+ if (!this.store) {
395
+ throw new Error(`getStore returned null/undefined for ${tableKey}`);
396
+ }
397
+ // Catalog backstop on first storage access. The authoritative write happens
398
+ // earlier, off the engine's `table_added` event; this is a COMPARE-write, so for
399
+ // an already-persisted table it reads and skips rather than re-putting identical
400
+ // bytes.
401
+ //
402
+ // It is retained (rather than dropped as redundant) because it is the only place a
403
+ // table whose persisted DDL text is UNENCODABLE — a lone surrogate in a quoted
404
+ // column name, a DEFAULT string literal, a CHECK constant — can raise on the
405
+ // statement: the `table_added` write rides the persist queue, which logs and
406
+ // swallows. The entry is absent there (that write threw), so the compare-write
407
+ // attempts it and the encoding guard throws into the DML that opened the storage.
408
+ if (!this.ddlSaved && this.tableSchema) {
409
+ await this.storeModule.persistTableCatalogEntryIfChanged(this.tableSchema);
410
+ this.ddlSaved = true;
411
+ }
412
+ await this.primeStats();
413
+ return this.store;
414
+ }
415
+ catch (error) {
416
+ this.storeInitPromise = null;
417
+ throw error;
418
+ }
419
+ }
420
+ /**
421
+ * Get or create an index store for the given index name.
422
+ */
423
+ async ensureIndexStore(indexName) {
424
+ let indexStore = this.indexStores.get(indexName);
425
+ if (!indexStore) {
426
+ indexStore = await this.storeModule.getIndexStore(this.schemaName, this.tableName, indexName);
427
+ this.indexStores.set(indexName, indexStore);
428
+ }
429
+ return indexStore;
430
+ }
431
+ /**
432
+ * Read the persisted row count into {@link cachedStats} once per table instance,
433
+ * before any mutation can be tracked against it.
434
+ *
435
+ * `trackMutation` / `applyPendingStats` both seed a missing `cachedStats` with
436
+ * `rowCount: 0` and add their delta to it, so WITHOUT this priming the first write
437
+ * after a reopen silently restarted the count from zero: a table reopened with 500
438
+ * persisted rows reported 1 after a single insert, and the next
439
+ * {@link flushStats} made that durable. Nothing read the count for planning before,
440
+ * so the loss was invisible; it is not once {@link getKnownRowCount} prices access
441
+ * plans with it.
442
+ *
443
+ * Called from {@link initializeStore}, which every read and write path awaits
444
+ * before touching storage — so priming precedes the first `trackMutation` without
445
+ * any caller having to remember it. Costs one extra KV `get` per table per session.
446
+ *
447
+ * Statistics are advisory (same posture as `StoreModuleRename`'s stats re-key): a
448
+ * failure to open or read the stats store warns and leaves the count unknown rather
449
+ * than failing the storage open that carries the caller's actual query.
450
+ *
451
+ * NOTE: on that failure path the pre-fix behaviour returns for this table — the next
452
+ * `trackMutation` seeds `cachedStats` at 0 and a later {@link flushStats} makes the
453
+ * restarted count durable, silently discarding the persisted one. Tolerated because a
454
+ * stats-store read failure is already a storage-level fault and the count is advisory.
455
+ * If it ever needs to be safe, gate {@link flushStats} on a "primed or genuinely
456
+ * absent" flag rather than on `cachedStats` being non-null.
457
+ */
458
+ async primeStats() {
459
+ if (this.cachedStats)
460
+ return;
461
+ try {
462
+ const statsStore = await this.ensureStatsStore();
463
+ const statsData = await statsStore.get(buildStatsKey(this.schemaName, this.tableName));
464
+ if (statsData)
465
+ this.cachedStats = deserializeStats(statsData);
466
+ }
467
+ catch (e) {
468
+ console.warn(`[StoreModule] Failed to read persisted statistics for '${this.schemaName}.${this.tableName}': ${e instanceof Error ? e.message : String(e)}`);
469
+ }
470
+ }
471
+ /**
472
+ * Get or create the stats store.
473
+ */
474
+ async ensureStatsStore() {
475
+ if (!this.statsStore) {
476
+ this.statsStore = await this.storeModule.getStatsStore(this.schemaName, this.tableName);
477
+ }
478
+ return this.statsStore;
479
+ }
480
+ /**
481
+ * Resolve + cache the module's shared TransactionCoordinator and hook the
482
+ * stats lifecycle callbacks, WITHOUT creating or registering a connection.
483
+ * Synchronous (the coordinator is handle-addressed, never store-opening — see
484
+ * `StoreTableModule.getCoordinator`), so the backing host's resolution path can
485
+ * call it eagerly. Attaching matters for reads: `iterateEffective` /
486
+ * `readEffectiveRowByKey` consult `this.coordinator` for the pending merge, so a
487
+ * table whose only writer is the privileged backing host (which queues ops on the
488
+ * module coordinator, never through `update()`) must still hold the
489
+ * reference for its read paths to be reads-own-writes.
490
+ *
491
+ * The coordinator is module-wide, so its commit/rollback callback array holds
492
+ * one {stats apply/discard} pair per PARTICIPATING table; each table's
493
+ * `applyPendingStats` early-returns when its own `pendingStatsDelta` is 0, so a
494
+ * table that did no work contributes nothing. Registers only on first call per
495
+ * StoreTable instance (the `if (!this.coordinator)` guard); a fresh instance
496
+ * after drop+recreate re-registers against the shared coordinator. The OLD
497
+ * instance is not GC'd on its own — its callback closures capture `this` and
498
+ * stay pinned on the module-wide coordinator until {@link dispose} runs the
499
+ * captured disposer (called at the genuine eviction sites, see
500
+ * `StoreModule.tearDownTableStorage` / `renameTable`).
501
+ */
502
+ attachCoordinator() {
503
+ if (!this.coordinator) {
504
+ this.coordinator = this.storeModule.getCoordinator();
505
+ this.coordinatorDisposer = this.coordinator.registerCallbacks({
506
+ onCommit: () => this.applyPendingStats(),
507
+ onRollback: () => this.discardPendingStats(),
508
+ });
509
+ }
510
+ return this.coordinator;
511
+ }
512
+ /**
513
+ * Ensure the coordinator is available and connection is registered.
514
+ */
515
+ async ensureCoordinator() {
516
+ const coordinator = this.attachCoordinator();
517
+ if (!this.connection) {
518
+ this.connection = new StoreConnection(`${this.schemaName}.${this.tableName}`, coordinator);
519
+ await this.db.registerConnection(this.connection);
520
+ }
521
+ return coordinator;
522
+ }
523
+ /**
524
+ * Deliver one data-change event: buffered on the coordinator inside a transaction
525
+ * (fires on commit, discarded on rollback), emitted straight through otherwise.
526
+ * Every write path routes its event here so the two arms can never drift apart.
527
+ *
528
+ * Ordering within a transaction is the coordinator's queue order, which is the order
529
+ * these calls are made — the event contract's delete-before-insert promise for a
530
+ * relocating key change rides on that (docs/usage.md § Subscribing to Data Changes).
531
+ */
532
+ emitOrQueueDataChange(inTransaction, event) {
533
+ if (inTransaction && this.coordinator) {
534
+ this.coordinator.queueEvent(event);
535
+ }
536
+ else {
537
+ this.eventEmitter?.emitDataChange(event);
538
+ }
539
+ }
540
+ /** Apply pending stats on commit. */
541
+ applyPendingStats() {
542
+ if (this.pendingStatsDelta === 0)
543
+ return;
544
+ if (!this.cachedStats) {
545
+ this.cachedStats = { rowCount: 0, updatedAt: Date.now() };
546
+ }
547
+ this.cachedStats.rowCount = Math.max(0, this.cachedStats.rowCount + this.pendingStatsDelta);
548
+ this.cachedStats.updatedAt = Date.now();
549
+ this.mutationCount += Math.abs(this.pendingStatsDelta);
550
+ this.pendingStatsDelta = 0;
551
+ if (this.mutationCount >= STATS_FLUSH_INTERVAL && !this.statsFlushPending) {
552
+ this.statsFlushPending = true;
553
+ queueMicrotask(() => this.flushStats());
554
+ }
555
+ }
556
+ /** Discard pending stats on rollback. */
557
+ discardPendingStats() {
558
+ this.pendingStatsDelta = 0;
559
+ }
560
+ /** Flush statistics to the stats store. */
561
+ async flushStats() {
562
+ this.statsFlushPending = false;
563
+ this.mutationCount = 0;
564
+ if (!this.cachedStats) {
565
+ return;
566
+ }
567
+ const statsStore = await this.ensureStatsStore();
568
+ const statsKey = buildStatsKey(this.schemaName, this.tableName);
569
+ await statsStore.put(statsKey, serializeStats(this.cachedStats));
570
+ }
571
+ /** Create a new connection for transaction support. */
572
+ async createConnection() {
573
+ await this.ensureCoordinator();
574
+ return this.connection;
575
+ }
576
+ /** Get the current connection. */
577
+ getConnection() {
578
+ return this.connection ?? undefined;
579
+ }
580
+ /** Extract primary key values from a full row (positioned by column index). */
581
+ extractPK(row) {
582
+ const schema = this.tableSchema;
583
+ return schema.primaryKeyDefinition.map(pk => row[pk.index]);
584
+ }
585
+ /**
586
+ * Resolve the PK cells an UPDATE/DELETE's `oldKeyValues` addresses.
587
+ *
588
+ * `UpdateArgs.oldKeyValues` is COMPACT — one cell per `primaryKeyDefinition`
589
+ * entry, in that order — NOT a full row positioned by column index. Every
590
+ * producer builds it that way (`pkIndices.map(i => row[i])` in the DML
591
+ * executor and in the isolation flush/overlay), and the memory module's
592
+ * `buildPrimaryKeyFromValues` asserts exactly this arity.
593
+ *
594
+ * Running it through {@link extractPK} instead only coincides when every PK
595
+ * column sits at the index matching its position in the PK definition. For a
596
+ * table whose PK is not its leading columns (e.g. a generated stored PK
597
+ * declared after the columns it derives from) that read lands past the end of
598
+ * the compact array and yields `undefined`.
599
+ */
600
+ pkFromKeyValues(keyValues) {
601
+ const pkDef = this.tableSchema.primaryKeyDefinition;
602
+ if (keyValues.length !== pkDef.length) {
603
+ throw new QuereusError(`Key value count mismatch for '${this.tableName}'. Expected ${pkDef.length}, got ${keyValues.length}.`, StatusCode.INTERNAL);
604
+ }
605
+ return [...keyValues];
606
+ }
607
+ /**
608
+ * Coerce each cell in `row` to its declared column logical type.
609
+ * Mirrors the memory-table path (MemoryTableManager.performInsert/performUpdate)
610
+ * so INTEGER/REAL affinity is applied and JSON columns are parsed into native
611
+ * objects before PK extraction, serialization, and index-key construction.
612
+ */
613
+ coerceRow(row) {
614
+ return coerceRowToSchema(row, this.tableSchema.columns, `${this.schemaName}.${this.tableName}`);
615
+ }
616
+ /**
617
+ * Iterate the effective entry state of this table's data store within
618
+ * `bounds`: the committed `store.iterate` stream merged with the
619
+ * coordinator's pending ops for the data store (read-your-own-writes).
620
+ *
621
+ * Both inputs are sorted by encoded key bytes, so this is an
622
+ * order-preserving two-way merge: a pending put wins over a committed entry
623
+ * at the same key, a pending delete suppresses its committed entry, and
624
+ * pending puts outside `bounds` are excluded. With no active transaction
625
+ * (or an empty pending bucket) it degrades to the bare committed iterate.
626
+ *
627
+ * The pending side is the module coordinator's bucket for THIS table's data
628
+ * store handle (`store`) — every data op is queued under that exact handle, so
629
+ * a sibling table's pending ops (a different data-store handle) never bleed in.
630
+ */
631
+ async *iterateEffective(store, bounds, reverse = false) {
632
+ const pending = this.coordinator?.isInTransaction()
633
+ ? this.coordinator.getOrderedPendingOps(store)
634
+ : null;
635
+ const iterOptions = reverse ? { ...bounds, reverse } : bounds;
636
+ if (!pending || (pending.puts.length === 0 && pending.deletes.size === 0)) {
637
+ yield* store.iterate(iterOptions);
638
+ return;
639
+ }
640
+ const puts = pending.puts.filter(p => keyWithinBounds(p.key, bounds));
641
+ if (reverse)
642
+ puts.reverse();
643
+ // In iteration order, "before" means byte-less for forward and byte-greater
644
+ // for reverse; sign folds the direction into one comparison.
645
+ const sign = reverse ? -1 : 1;
646
+ let putIdx = 0;
647
+ for await (const entry of store.iterate(iterOptions)) {
648
+ // Emit pending puts that precede this committed entry in iteration order.
649
+ while (putIdx < puts.length && sign * compareBytes(puts[putIdx].key, entry.key) < 0) {
650
+ yield puts[putIdx++];
651
+ }
652
+ // Equal keys: the pending put shadows the committed entry.
653
+ if (putIdx < puts.length && compareBytes(puts[putIdx].key, entry.key) === 0) {
654
+ yield puts[putIdx++];
655
+ continue;
656
+ }
657
+ if (pending.deletes.has(bytesToHex(entry.key)))
658
+ continue;
659
+ yield entry;
660
+ }
661
+ // Pending puts beyond the last committed entry.
662
+ while (putIdx < puts.length) {
663
+ yield puts[putIdx++];
664
+ }
665
+ }
666
+ /**
667
+ * Read the live row at `pk` — this transaction's pending overlay (a pending
668
+ * delete ⇒ gone; a pending put ⇒ its value) shadowing the committed store.
669
+ * Backs `query()`'s point-lookup arm and validates covering-MV conflict
670
+ * candidates against the source of truth.
671
+ */
672
+ async readLiveRowByPk(pk) {
673
+ return this.readEffectiveRowByKey(this.encodeDataKey(pk));
674
+ }
675
+ // ── Backing-host surface ──────────────────────────────────────────────
676
+ // Narrow public surface `StoreBackingHost` (backing-host.ts) drives. Each
677
+ // method addresses the table's CURRENT schema/encoding state, so a host
678
+ // resolved fresh per engine call always keys and merges consistently with
679
+ // the table's own read/write paths.
680
+ /** Encode `pkValues` (in PK-definition order) exactly as the data store keys rows. */
681
+ encodeDataKey(pkValues) {
682
+ return buildDataKey(pkValues, this.encodeOptions, this.pkDirections, this.pkKeyCollations, this.pkKeyTransforms);
683
+ }
684
+ /**
685
+ * Byte bounds covering every data key whose leading PK columns equal
686
+ * `prefixValues` — encoded under the same per-column DESC directions and key
687
+ * collations as {@link encodeDataKey}, so seek + early-terminate addresses the
688
+ * exact slice. An empty prefix yields full-scan bounds.
689
+ */
690
+ encodePkPrefixBounds(prefixValues) {
691
+ return buildPkPrefixBounds(prefixValues, this.encodeOptions, this.pkDirections.slice(0, prefixValues.length), this.pkKeyCollations.slice(0, prefixValues.length), this.pkKeyTransforms.slice(0, prefixValues.length));
692
+ }
693
+ /**
694
+ * Effective (pending-over-committed) point read by encoded data key: a pending
695
+ * delete ⇒ null, a pending put ⇒ its value, else the committed store entry.
696
+ */
697
+ async readEffectiveRowByKey(key) {
698
+ const store = await this.ensureStore();
699
+ // Pending ops for THIS table's data store handle — see findUniqueConflict.
700
+ const pending = this.coordinator?.isInTransaction()
701
+ ? this.coordinator.getPendingOpsForStore(store)
702
+ : null;
703
+ if (pending) {
704
+ const hex = bytesToHex(key);
705
+ if (pending.deletes.has(hex))
706
+ return null;
707
+ const overlay = pending.puts.get(hex);
708
+ if (overlay)
709
+ return deserializeRow(overlay.value);
710
+ }
711
+ const value = await store.get(key);
712
+ return value ? deserializeRow(value) : null;
713
+ }
714
+ /**
715
+ * Effective ordered entry scan within `bounds` (see {@link iterateEffective}).
716
+ * Opens the data store first — which fires the lazy first-access `saveTableDDL`
717
+ * for a freshly created table, the catalog write a store-backed MV backing
718
+ * relies on to survive reopen.
719
+ */
720
+ async *iterateEffectiveEntries(bounds, reverse = false) {
721
+ const store = await this.ensureStore();
722
+ yield* this.iterateEffective(store, bounds, reverse);
723
+ }
724
+ /** Buffer a privileged row-count delta, applied at coordinator commit (host writes). */
725
+ trackPrivilegedMutation(delta) {
726
+ this.trackMutation(delta, true);
727
+ }
728
+ /**
729
+ * Reset statistics to an absolute committed row count and flush immediately.
730
+ * Used by the host's `replaceContents` (create-fill / refresh), where the new
731
+ * count is exact, replacing any drifted delta-tracked estimate.
732
+ */
733
+ async resetStats(rowCount) {
734
+ this.cachedStats = { rowCount, updatedAt: Date.now() };
735
+ this.pendingStatsDelta = 0;
736
+ this.mutationCount = 0;
737
+ await this.flushStats();
738
+ }
739
+ /**
740
+ * Open (and cache) the data store, firing the lazy first-access `saveTableDDL`.
741
+ * Public for the host's `replaceContents`, which writes the store directly.
742
+ */
743
+ openDataStore() {
744
+ return this.ensureStore();
745
+ }
746
+ // ── External row-write surface ────────────────────────────────────────
747
+ // Module-side entry point for externally-applied writes to a SOURCE table
748
+ // (the index-maintaining sibling of `StoreBackingHost`, which is for MV
749
+ // BACKING tables and deliberately keeps no indexes). Resolved per call via
750
+ // `StoreModule.getTableForExternalWrite`; addresses the table's CURRENT
751
+ // schema/encoding state so keys and index entries match its own DML paths.
752
+ /**
753
+ * Effective (pending-over-committed) point read by PK values — the public
754
+ * read an external writer issues before an upsert to learn the row's current
755
+ * image. Thin wrapper over the same private point-lookup that backs
756
+ * `StoreTableScan.query`'s point arm, so an external read merges pending-over-committed
757
+ * exactly like an engine read does.
758
+ */
759
+ readRowByPk(pk) {
760
+ return this.readLiveRowByPk(pk);
761
+ }
762
+ /**
763
+ * Begin a table-scoped transaction by ensuring the coordinator is active.
764
+ *
765
+ * Used by the isolation layer's flush path, which treats the underlying
766
+ * write as an independent mini-transaction. Idempotent: if the coordinator
767
+ * is already in a transaction (e.g. started by a registered connection),
768
+ * this is a no-op.
769
+ */
770
+ async begin() {
771
+ const coordinator = await this.ensureCoordinator();
772
+ coordinator.begin();
773
+ }
774
+ /**
775
+ * Commits a table-scoped transaction, flushing any buffered writes to the KV store.
776
+ * No-op if the coordinator is not currently in a transaction.
777
+ */
778
+ async commit() {
779
+ if (this.coordinator?.isInTransaction()) {
780
+ await this.coordinator.commit();
781
+ }
782
+ }
783
+ /**
784
+ * Rolls back a table-scoped transaction, discarding any buffered writes.
785
+ * No-op if the coordinator is not currently in a transaction.
786
+ */
787
+ async rollback() {
788
+ if (this.coordinator?.isInTransaction()) {
789
+ this.coordinator.rollback();
790
+ }
791
+ }
792
+ /** Disconnect from the store. */
793
+ async disconnect() {
794
+ // Called by Quereus after each scan completes.
795
+ // Do NOT clear the store - it's shared across concurrent queries.
796
+ // Only flush pending stats if there are mutations.
797
+ if (this.mutationCount > 0 && this.store) {
798
+ await this.flushStats();
799
+ }
800
+ // Store remains available for subsequent queries.
801
+ // Use destroy() to fully clean up the table.
802
+ }
803
+ /**
804
+ * Hard teardown: fully detach this instance from the module-wide coordinator.
805
+ *
806
+ * Distinct from the per-scan {@link disconnect} (which only flushes stats and
807
+ * deliberately keeps the table hooked mid-life). Called ONLY at the genuine
808
+ * eviction sites — `StoreModule.tearDownTableStorage` (drop / reclaim) and
809
+ * `renameTable` — where this instance is removed from the module's `tables` map
810
+ * and will never be used again. It best-effort flushes any buffered stats (the
811
+ * backing store is about to be deleted / relocated, so this is the last chance,
812
+ * same posture as the teardown-time `disconnect` it replaces), then runs the
813
+ * coordinator disposer so this instance's {stats apply/discard} callback pair —
814
+ * and the `this` its closures capture — is spliced off the shared coordinator's
815
+ * array rather than pinned for the module's lifetime.
816
+ *
817
+ * Idempotent: the disposer is run at most once and both it and the coordinator
818
+ * reference are nulled, so a double-dispose is a no-op and a re-`attachCoordinator`
819
+ * after dispose registers a fresh pair instead of double-registering.
820
+ */
821
+ async dispose() {
822
+ if (this.mutationCount > 0 && this.store) {
823
+ await this.flushStats();
824
+ }
825
+ this.coordinatorDisposer?.();
826
+ this.coordinatorDisposer = null;
827
+ this.coordinator = null;
828
+ }
829
+ /** Get the current estimated row count. */
830
+ async getEstimatedRowCount() {
831
+ if (!this.cachedStats) {
832
+ await this.primeStats();
833
+ }
834
+ // Still nothing on disk ⇒ this table has never been counted; report 0.
835
+ return this.cachedStats?.rowCount ?? 0;
836
+ }
837
+ /**
838
+ * The row count this instance can answer WITHOUT any I/O — `undefined` when no
839
+ * count has been read or maintained yet (a table nothing has touched this session,
840
+ * or one that has never been counted at all).
841
+ *
842
+ * Synchronous by design: `StoreModule.getBestAccessPlan` is a synchronous engine
843
+ * callback, so the only count it can price a plan with is one already in memory.
844
+ * {@link primeStats} is what makes that the common case rather than the rare one —
845
+ * it loads the persisted count when storage is first opened, which every read and
846
+ * write path does before it can reach the planner a second time.
847
+ *
848
+ * Includes the open transaction's buffered delta, so a statement that reads what
849
+ * its own transaction just wrote is costed against the size it will actually see.
850
+ */
851
+ getKnownRowCount() {
852
+ if (!this.cachedStats)
853
+ return undefined;
854
+ return Math.max(0, this.cachedStats.rowCount + this.pendingStatsDelta);
855
+ }
856
+ /**
857
+ * The `VirtualTable.getStatistics` contract: this table's SIZE, reported from the
858
+ * running count the write paths already maintain and persist, in O(1) — no scan.
859
+ *
860
+ * Column statistics are deliberately absent. The store keeps no value distribution
861
+ * (a distinct count or histogram would cost a full scan of the table or of an index),
862
+ * and `ANALYZE` reads an empty `columnStats` as "this module reported its size
863
+ * cheaply, collect the rest by scanning" (see `runtime/emit/analyze.ts`) — so
864
+ * implementing this method makes the count reachable without giving up the
865
+ * per-column statistics ANALYZE collects today.
866
+ *
867
+ * `rowCount` is delta-tracked rather than counted, so it can drift from the true
868
+ * count if a write path ever mis-accounts; `ANALYZE`'s scan is the reconciliation.
869
+ *
870
+ * `lastAnalyzed` is deliberately left unset: the only timestamp this table holds is
871
+ * `TableStats.updatedAt`, the moment the count last MOVED, which is a different fact
872
+ * — reporting it here would tell a staleness check the table had been analyzed.
873
+ */
874
+ async getStatistics() {
875
+ return {
876
+ rowCount: await this.getEstimatedRowCount(),
877
+ columnStats: new Map(),
878
+ };
879
+ }
880
+ /** Track a mutation and schedule lazy stats persistence. */
881
+ trackMutation(delta, inTransaction = false) {
882
+ if (inTransaction) {
883
+ // Buffer during transaction - stats will be applied at commit
884
+ this.pendingStatsDelta += delta;
885
+ return;
886
+ }
887
+ if (!this.cachedStats) {
888
+ this.cachedStats = { rowCount: 0, updatedAt: Date.now() };
889
+ }
890
+ this.cachedStats.rowCount = Math.max(0, this.cachedStats.rowCount + delta);
891
+ this.cachedStats.updatedAt = Date.now();
892
+ this.mutationCount++;
893
+ // Schedule lazy flush after threshold
894
+ if (this.mutationCount >= STATS_FLUSH_INTERVAL && !this.statsFlushPending) {
895
+ this.statsFlushPending = true;
896
+ queueMicrotask(() => this.flushStats());
897
+ }
898
+ }
899
+ }
900
+ //# sourceMappingURL=store-table-base.js.map