@minnowdb/core 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +12 -9
  2. package/dist/engine/client.d.ts +10 -1
  3. package/dist/engine/client.d.ts.map +1 -1
  4. package/dist/engine/client.js +30 -5
  5. package/dist/engine/client.js.map +1 -1
  6. package/dist/engine/database.d.ts +55 -6
  7. package/dist/engine/database.d.ts.map +1 -1
  8. package/dist/engine/database.js +2689 -898
  9. package/dist/engine/database.js.map +1 -1
  10. package/dist/engine/live.d.ts +6 -2
  11. package/dist/engine/live.d.ts.map +1 -1
  12. package/dist/engine/live.js +35 -11
  13. package/dist/engine/live.js.map +1 -1
  14. package/dist/engine/query-cache.d.ts +13 -1
  15. package/dist/engine/query-cache.d.ts.map +1 -1
  16. package/dist/engine/query-cache.js +34 -5
  17. package/dist/engine/query-cache.js.map +1 -1
  18. package/dist/engine/query.d.ts +13 -1
  19. package/dist/engine/query.d.ts.map +1 -1
  20. package/dist/engine/query.js +73 -46
  21. package/dist/engine/query.js.map +1 -1
  22. package/dist/engine/result-wire.d.ts +68 -0
  23. package/dist/engine/result-wire.d.ts.map +1 -0
  24. package/dist/engine/result-wire.js +264 -0
  25. package/dist/engine/result-wire.js.map +1 -0
  26. package/dist/engine/sort-keys.d.ts +50 -12
  27. package/dist/engine/sort-keys.d.ts.map +1 -1
  28. package/dist/engine/sort-keys.js +371 -25
  29. package/dist/engine/sort-keys.js.map +1 -1
  30. package/dist/engine/vector.d.ts +11 -0
  31. package/dist/engine/vector.d.ts.map +1 -1
  32. package/dist/engine/vector.js +99 -203
  33. package/dist/engine/vector.js.map +1 -1
  34. package/dist/engine/worker-host.d.ts +17 -1
  35. package/dist/engine/worker-host.d.ts.map +1 -1
  36. package/dist/engine/worker-host.js +87 -13
  37. package/dist/engine/worker-host.js.map +1 -1
  38. package/dist/storage/index.d.ts +13 -0
  39. package/dist/storage/index.d.ts.map +1 -1
  40. package/dist/storage/index.js +13 -0
  41. package/dist/storage/index.js.map +1 -1
  42. package/dist/storage/indexeddb.d.ts +5 -1
  43. package/dist/storage/indexeddb.d.ts.map +1 -1
  44. package/dist/storage/indexeddb.js +484 -402
  45. package/dist/storage/indexeddb.js.map +1 -1
  46. package/dist/storage/memory.d.ts +24 -21
  47. package/dist/storage/memory.d.ts.map +1 -1
  48. package/dist/storage/memory.js +124 -1199
  49. package/dist/storage/memory.js.map +1 -1
  50. package/dist/storage/opfs/files.d.ts +64 -0
  51. package/dist/storage/opfs/files.d.ts.map +1 -0
  52. package/dist/storage/opfs/files.js +243 -0
  53. package/dist/storage/opfs/files.js.map +1 -0
  54. package/dist/storage/opfs/index.d.ts +3 -0
  55. package/dist/storage/opfs/index.d.ts.map +1 -0
  56. package/dist/storage/opfs/index.js +3 -0
  57. package/dist/storage/opfs/index.js.map +1 -0
  58. package/dist/storage/opfs/leader.d.ts +130 -0
  59. package/dist/storage/opfs/leader.d.ts.map +1 -0
  60. package/dist/storage/opfs/leader.js +1104 -0
  61. package/dist/storage/opfs/leader.js.map +1 -0
  62. package/dist/storage/opfs/rpc.d.ts +70 -0
  63. package/dist/storage/opfs/rpc.d.ts.map +1 -0
  64. package/dist/storage/opfs/rpc.js +58 -0
  65. package/dist/storage/opfs/rpc.js.map +1 -0
  66. package/dist/storage/opfs/store.d.ts +154 -0
  67. package/dist/storage/opfs/store.d.ts.map +1 -0
  68. package/dist/storage/opfs/store.js +958 -0
  69. package/dist/storage/opfs/store.js.map +1 -0
  70. package/dist/storage/toolkit/extents.d.ts +69 -0
  71. package/dist/storage/toolkit/extents.d.ts.map +1 -0
  72. package/dist/storage/toolkit/extents.js +175 -0
  73. package/dist/storage/toolkit/extents.js.map +1 -0
  74. package/dist/storage/toolkit/index.d.ts +30 -0
  75. package/dist/storage/toolkit/index.d.ts.map +1 -0
  76. package/dist/storage/toolkit/index.js +5 -0
  77. package/dist/storage/toolkit/index.js.map +1 -0
  78. package/dist/storage/toolkit/record-core.d.ts +252 -0
  79. package/dist/storage/toolkit/record-core.d.ts.map +1 -0
  80. package/dist/storage/toolkit/record-core.js +1670 -0
  81. package/dist/storage/toolkit/record-core.js.map +1 -0
  82. package/dist/storage/toolkit/sync-file.d.ts +29 -0
  83. package/dist/storage/toolkit/sync-file.d.ts.map +1 -0
  84. package/dist/storage/toolkit/sync-file.js +2 -0
  85. package/dist/storage/toolkit/sync-file.js.map +1 -0
  86. package/dist/storage/toolkit/wal.d.ts +22 -0
  87. package/dist/storage/toolkit/wal.d.ts.map +1 -0
  88. package/dist/storage/toolkit/wal.js +79 -0
  89. package/dist/storage/toolkit/wal.js.map +1 -0
  90. package/dist/storage/toolkit/wire.d.ts +25 -0
  91. package/dist/storage/toolkit/wire.d.ts.map +1 -0
  92. package/dist/storage/toolkit/wire.js +100 -0
  93. package/dist/storage/toolkit/wire.js.map +1 -0
  94. package/dist/storage/types.d.ts +343 -59
  95. package/dist/storage/types.d.ts.map +1 -1
  96. package/dist/storage/types.js +112 -11
  97. package/dist/storage/types.js.map +1 -1
  98. package/dist/testing/block-store-conformance.d.ts +51 -0
  99. package/dist/testing/block-store-conformance.d.ts.map +1 -0
  100. package/dist/testing/block-store-conformance.js +745 -0
  101. package/dist/testing/block-store-conformance.js.map +1 -0
  102. package/dist/testing/index.d.ts +3 -0
  103. package/dist/testing/index.d.ts.map +1 -1
  104. package/dist/testing/index.js +5 -0
  105. package/dist/testing/index.js.map +1 -1
  106. package/dist/testing/opfs-shim.d.ts +35 -0
  107. package/dist/testing/opfs-shim.d.ts.map +1 -0
  108. package/dist/testing/opfs-shim.js +295 -0
  109. package/dist/testing/opfs-shim.js.map +1 -0
  110. package/dist/transactions/index.d.ts +42 -1
  111. package/dist/transactions/index.d.ts.map +1 -1
  112. package/dist/transactions/index.js +219 -13
  113. package/dist/transactions/index.js.map +1 -1
  114. package/dist/worker-protocol/index.d.ts +2 -2
  115. package/dist/worker-protocol/index.d.ts.map +1 -1
  116. package/dist/worker-protocol/index.js +2 -1
  117. package/dist/worker-protocol/index.js.map +1 -1
  118. package/package.json +10 -2
  119. package/sql-feature-matrix.json +8 -0
@@ -9,7 +9,7 @@ import { cachedQueryTerms, FTS_TOKENIZER_VERSION, renderDocumentValue, tokenize
9
9
  import { simpleDataTypes, floorWholeNumberProduct, validateColumnDefault, validateEnumValues, CompactionJobConflictError, GarbageCollectionJobConflictError, decodeSnapshot, encodeSnapshot, SnapshotManifestMissingError, TableRecordConflictError, TransactionRecordConflictError, UniqueKeyConflictError, WriteConflictError, } from "../storage/index.js";
10
10
  import { Snapshot, TransactionManager, } from "../transactions/index.js";
11
11
  import { applyWindowFunctions, bindPlanParameters, bindStatementParameters, DUAL_TABLE, dualTableRows, blockHasSubqueries, combineUnionResults, compileCheckExpression, compileQuery, hasAggregate, createRecursiveCteState, compileStatement, createPreparedColumnarQuery, evaluateJoinedRowExpression, evaluateRowExpression, expressionColumnNames, inferBlockSchema, referencedColumns, childExpressions, expandFtsColumns, expandNaturalJoins, expandSourceColumnAliases, expandViewSources, forEachBlockExpression, planContainsFts, planHasNaturalJoins, planHasSourceColumnAliases, planReadsViews, planReadsBeyondSingleScan, projectResultColumns, subqueryResolutionSteps, topLevelFtsMatchConjuncts, transparentProjectionSource, windowOutputType, } from "./query.js";
12
- import { copyQueryResult, queryResultMemoKey, queryResultRetainedBytes, RESULT_MEMO_MAX_BYTES, } from "./query-cache.js";
12
+ import { copyQueryResult, planMemoKey, queryResultMemoKey, queryResultRetainedBytes, RESULT_MEMO_MAX_BYTES, } from "./query-cache.js";
13
13
  import { QueryMemoryBudgetError, QueryMemoryContext, } from "./memory.js";
14
14
  import { LiveQuerySet } from "./live.js";
15
15
  import { chooseJoinOrder, renderPlan } from "./optimizer.js";
@@ -25,20 +25,60 @@ const FTS_FOLD_DELTA_CHUNKS = 16;
25
25
  const DEFAULT_COMPACTION_TARGET_BLOCK_BYTES = 2 * 1024 * 1024;
26
26
  const DEFAULT_COMPACTION_MEMORY_BUDGET_BYTES = 32 * 1024 * 1024;
27
27
  const DEFAULT_COMPACTION_MINIMUM_LEVEL_ZERO_SEGMENTS = 2;
28
- const DEFAULT_COMPACTION_MAXIMUM_LEVEL_ZERO_SEGMENTS = 16;
28
+ const DEFAULT_COMPACTION_MAXIMUM_LEVEL_ZERO_SEGMENTS = 64;
29
29
  const DEFAULT_COMPACTION_MAXIMUM_LEVEL_ZERO_STORED_BYTES = 64 * 1024 * 1024;
30
+ /**
31
+ * Rows a keyed fold aims to keep in one level-one partition. A fold rewrites only the
32
+ * partitions its deltas touch, so this bounds how much one touched key costs to absorb; the
33
+ * table's partition count, and with it the per-query block count, grows as rows divided by it.
34
+ */
35
+ const DEFAULT_COMPACTION_PARTITION_ROWS = 16_384;
30
36
  const DEFAULT_LEVEL_TWO_MAX_WRITE_AMPLIFICATION = 16;
31
37
  const MAX_COMPACTION_TARGET_BLOCK_BYTES = 64 * 1024 * 1024;
32
38
  const MAX_BLOCK_ENVELOPE_BYTES = 1024;
33
39
  const INTERNAL_READ_LEASE_TTL_MS = 60_000;
40
+ /** Live proof windows kept resident; a sweep uses one, concurrent sets a few. */
41
+ const LIVE_PROOF_CONTEXT_LIMIT = 4;
34
42
  /** Distinct table-name sets whose catalog state stays resident; entries are tiny (records only). */
35
43
  const CATALOG_STATE_CACHE_LIMIT = 64;
36
44
  /** Blocks fetched per round trip when a streamed scan window needs more data. */
37
45
  const STREAMED_SCAN_LOOKAHEAD_BLOCKS = 8;
38
- /** Visible segments per table at which a streamed scan schedules a compaction step. */
46
+ /** Visible segments per table at which a scan or a commit schedules a compaction step. */
39
47
  const AUTO_COMPACT_SCAN_SEGMENTS = 48;
40
- /** Visible delete/update segments at which a streamed scan schedules a compaction step. */
41
- const AUTO_COMPACT_DELTA_SEGMENTS = 8;
48
+ /** Visible delete/update segments at which a scan or a commit schedules a compaction step. */
49
+ const AUTO_COMPACT_DELTA_SEGMENTS = 32;
50
+ /** Commits to one table between auto-compaction checks on the write path. */
51
+ const AUTO_COMPACT_COMMIT_CHECK_INTERVAL = 8;
52
+ /** Quiet time after a write burst before checking its final, sub-interval tail. */
53
+ const AUTO_COMPACT_IDLE_CHECK_MS = 25;
54
+ /** Commits between background collection passes; each prunes the manifests they wrote. */
55
+ const AUTO_COLLECT_COMMIT_INTERVAL = 64;
56
+ /**
57
+ * Manifest versions background collection leaves readable behind the current one, and how
58
+ * old one may be before it is collected regardless. A version is kept only while both hold: the
59
+ * count serves a reader that names a version it was just handed, the age keeps a burst of
60
+ * commits from pinning everything it superseded until the next burst — an idle tab reclaims
61
+ * within a minute.
62
+ */
63
+ const AUTO_COLLECT_RETAINED_VERSIONS = 64;
64
+ const AUTO_COLLECT_RETAINED_VERSION_MS = 60_000;
65
+ /** A commit this long after the last collection pass starts one, whatever the commit count. */
66
+ const AUTO_COLLECT_QUIET_MS = 60_000;
67
+ /** Candidates one background collection step examines before yielding to the event loop. */
68
+ const AUTO_COLLECT_STEP_ITEMS = 64;
69
+ /** Passes one background collection run makes before handing the rest to the next trigger. */
70
+ const AUTO_COLLECT_MAX_PASSES = 32;
71
+ /** Finished job records of each kind a background run leaves for inspection. */
72
+ const AUTO_COLLECT_RETAINED_JOB_RECORDS = 8;
73
+ /** Output blocks one background compaction step writes before yielding to the event loop. */
74
+ const AUTO_COMPACT_STEP_BLOCKS = 4;
75
+ /**
76
+ * Level-zero segments one background fold may absorb. A fold rewrites every partition its
77
+ * deltas touch, and a partition touched by several deltas is rewritten once, so absorbing
78
+ * everything pending in one pass costs one rewrite of those partitions where the default would
79
+ * cost several; the stored-bytes ceiling still bounds the pass.
80
+ */
81
+ const AUTO_COMPACT_MAX_LEVEL_ZERO_SEGMENTS = 256;
42
82
  /** Modeled retained bytes for one cached block description (header metadata, no payload). */
43
83
  const ZONE_DESCRIPTION_CACHE_BYTES = 160;
44
84
  /** Overlay logical order for a write scope's staged segments: after all committed data. */
@@ -70,8 +110,15 @@ const GZIP_WORTHWHILE_RATIO = 1.2;
70
110
  * bounds how long a wrong observation can persist if the data changes underneath it.
71
111
  */
72
112
  const GZIP_REPROBE_BLOCKS = 32;
73
- /** Blocks smaller than this are written with the configured codec; the choice cannot repay. */
74
- const GZIP_DECISION_MIN_BYTES = 64 * 1024;
113
+ /** Failed per-column probes retained in one database session. */
114
+ const GZIP_VERDICT_CACHE_LIMIT = 256;
115
+ /**
116
+ * Below this many logical bytes a block is written raw: the compression pass on the write and
117
+ * the decompression pass on every read would cost more than the bytes they save, and a point
118
+ * update's or delete's one-row block is the common case — it used to pay a CompressionStream
119
+ * round trip to shrink a few dozen bytes.
120
+ */
121
+ const GZIP_MINIMUM_INPUT_BYTES = 4 * 1024;
75
122
  /** Bounds concurrent compression work without serializing independent column blocks. */
76
123
  const WRITE_ENCODE_CONCURRENCY = 6;
77
124
  export { CompactionJobCancelledError, CompactionMemoryBudgetError, CompactionWriteAmplificationError, MissingKeyError, SqlCompileError, UniqueConstraintError, };
@@ -87,11 +134,6 @@ class TransactionRollback extends Error {
87
134
  this.name = "TransactionRollback";
88
135
  }
89
136
  }
90
- /**
91
- * Whether a statement may run inside a statement-level transaction. Reads and row writes stage
92
- * into the scope; schema changes do not, because the catalog commits outside it and a rollback
93
- * could not take them back.
94
- */
95
137
  function isTransactionalStatement(statement) {
96
138
  return (statement.kind === "insert" ||
97
139
  statement.kind === "update" ||
@@ -109,19 +151,24 @@ function boundInsertValue(value) {
109
151
  function quoteSqlIdentifier(identifier) {
110
152
  return `"${identifier.replaceAll('"', '""')}"`;
111
153
  }
154
+ /**
155
+ * Snapshots are optional members of `BlockStore` — a store can be a complete database backend
156
+ * without being able to copy itself out — so the database checks at the call and says plainly
157
+ * when the capability is absent rather than failing as a missing property.
158
+ */
112
159
  function exportingStore(store) {
113
- const candidate = store;
114
- if (typeof candidate.exportSnapshot !== "function") {
160
+ const exportSnapshot = store.exportSnapshot?.bind(store);
161
+ if (exportSnapshot === undefined) {
115
162
  throw new Error("This database's block store cannot export snapshots");
116
163
  }
117
- return candidate;
164
+ return { exportSnapshot };
118
165
  }
119
166
  function importingStore(store) {
120
- const candidate = store;
121
- if (typeof candidate.importSnapshot !== "function") {
167
+ const importSnapshot = store.importSnapshot?.bind(store);
168
+ if (importSnapshot === undefined) {
122
169
  throw new Error("This database's block store cannot load snapshots");
123
170
  }
124
- return candidate;
171
+ return { importSnapshot };
125
172
  }
126
173
  export class MinnowDatabase {
127
174
  store;
@@ -137,7 +184,7 @@ export class MinnowDatabase {
137
184
  /** The scope a statement-level BEGIN opened, held until COMMIT, ROLLBACK, or the idle sweep. */
138
185
  #openTransaction;
139
186
  #compression;
140
- /** Per-column record of whether gzip repaid itself, and how many blocks ago that was seen. */
187
+ /** Per-column count since gzip last failed to repay itself; successful probes need no entry. */
141
188
  #gzipVerdicts = new Map();
142
189
  #rowsPerBlock;
143
190
  #maxCommitRetries;
@@ -146,16 +193,51 @@ export class MinnowDatabase {
146
193
  #createId;
147
194
  #internalLeaseOwnerId = `minnow/${crypto.randomUUID()}`;
148
195
  #liveSets = new Set();
196
+ /** Live proof inputs per commit window, keyed `after:until`; see #liveProofContext. */
197
+ #liveProofContexts = new Map();
149
198
  #internalLeaseSequence = 0;
150
199
  #artifactCache;
151
200
  #ftsAutoIndexRows;
152
201
  #autoCompact;
202
+ #compactionPartitionRows;
203
+ #autoCollect;
204
+ /** Data commits since the last background collection pass. */
205
+ #commitsSinceCollection = 0;
206
+ /**
207
+ * The highest manifest version below which everything is known to be collected — pruned,
208
+ * with no block left behind; a collection plan starts its walk there. In memory only: a
209
+ * fresh instance walks the whole history once and learns it again.
210
+ */
211
+ #collectionWatermark = null;
212
+ #autoCollectionInFlight = false;
213
+ /** A trigger that arrived while a run was in flight; honoured when the run ends. */
214
+ #autoCollectionRequested = false;
215
+ #autoCollectionBackoffUntilCommit = 0;
216
+ /** When the last background collection pass started, by the database clock. */
217
+ #lastCollectionAt;
218
+ /** The idle pass scheduled after the last commit; reset by the next commit. */
219
+ #idleCollectionTimer;
220
+ /** The garbage-collection step in flight, so steps run one at a time: see #serializedCollectionStep. */
221
+ #collectionSteps = Promise.resolve();
153
222
  /** One background build attempt per (table, column) per session; misses just stay scans. */
154
223
  #ftsBuildsInFlight = new Set();
155
224
  /** Tables with a fire-and-forget compaction step already running. */
156
225
  #autoCompactionsInFlight = new Set();
226
+ /** Tables whose maintenance threshold was observed again while their fold was still running. */
227
+ #autoCompactionsRequested = new Set();
228
+ /** Changed tables awaiting the debounced check that closes a write burst. */
229
+ #idleCompactionTableIds = new Set();
230
+ #idleCompactionTimer;
231
+ /** Tables whose drop is retiring data; prevents a new background fold from starting. */
232
+ #droppingTables = new Set();
157
233
  /** Per table: the visible segment count a failed auto-compaction must see before retrying. */
158
234
  #autoCompactionBackoff = new Map();
235
+ /** Data commits per table since its last write-path auto-compaction check. */
236
+ #commitsSinceCompactionCheck = new Map();
237
+ /** The compaction step in flight per table, so steps on one table run one at a time. */
238
+ #compactionSteps = new Map();
239
+ /** The simple writes in flight, chained so they commit one after another: see #runWrite. */
240
+ #writeChain = Promise.resolve();
159
241
  /**
160
242
  * SQL text to optimized plan, LRU by insertion order. Compiled plans are never mutated after
161
243
  * optimization — subquery resolution and CTE expansion clone before rewriting and join
@@ -169,6 +251,8 @@ export class MinnowDatabase {
169
251
  #ftsCandidatesMemo = new WeakMap();
170
252
  #sharedLease;
171
253
  #sharedLeaseRenewal;
254
+ /** An in-flight re-pin of the shared lease; acquirers wait for it, never join it. */
255
+ #sharedLeaseMove;
172
256
  /**
173
257
  * Catalog states keyed by requested table-name set, valid only at #catalogStateEpoch.
174
258
  * The (version, epoch) probe is the sole validity signal: a matched epoch proves a cached
@@ -200,6 +284,8 @@ export class MinnowDatabase {
200
284
  this.#artifactCache = new ArtifactCache(options.bufferPoolBytes ?? 64 * 1024 * 1024);
201
285
  this.#ftsAutoIndexRows = options.ftsAutoIndexRows ?? 4096;
202
286
  this.#autoCompact = options.autoCompact ?? true;
287
+ this.#autoCollect = options.autoCollect ?? this.#autoCompact;
288
+ this.#compactionPartitionRows = positiveWholeNumber(options.compaction?.partitionRows ?? DEFAULT_COMPACTION_PARTITION_ROWS, "Compaction partition rows");
203
289
  if (!Number.isSafeInteger(this.#ftsAutoIndexRows) || this.#ftsAutoIndexRows < 0) {
204
290
  throw new RangeError("Full-text auto-index row threshold must be a non-negative whole number");
205
291
  }
@@ -300,7 +386,6 @@ export class MinnowDatabase {
300
386
  ...(input.managed === true ? { managed: true } : {}),
301
387
  ...(uniqueKeyColumn === undefined ? {} : { uniqueKeyColumnId: uniqueKeyColumn.id }),
302
388
  ...(uniqueKeyColumn === undefined ? {} : { uniqueKeyLookupReady: true }),
303
- ...(uniqueKeyColumn === undefined ? {} : { uniqueKeyStorage: "chunks-v2" }),
304
389
  createdAt: this.#now().toISOString(),
305
390
  });
306
391
  }
@@ -333,17 +418,42 @@ export class MinnowDatabase {
333
418
  * conflict: unlike the plain rebase-and-retry, a restart re-reads pre-images and re-runs
334
419
  * trigger bodies at the fresh state, so derivations can never publish stale values.
335
420
  */
336
- async #withTriggerRestarts(run) {
337
- for (let attempt = 0;; attempt += 1) {
338
- try {
339
- return await run();
340
- }
341
- catch (error) {
342
- if (!(error instanceof StaleTriggerDerivationsError))
343
- throw error;
344
- if (attempt >= this.#maxCommitRetries)
345
- throw error.conflict;
421
+ /**
422
+ * Runs one simple write insert, upsert, update, or delete — after every simple write this
423
+ * database already has in flight, restarting it when its trigger derivations went stale.
424
+ *
425
+ * Commits are optimistic: a writer reads the manifest version, stages, and publishes only if
426
+ * the version has not moved, rebasing and retrying otherwise up to `maxCommitRetries`.
427
+ * Writers issued concurrently from one database used to all read the same version and spend
428
+ * a retry per rival that landed first, so past `maxCommitRetries + 1` of them the rest failed
429
+ * for nothing — contention this database need not create, and the queue does not. Writers in
430
+ * other instances and other tabs still contend, and the retry loop is still what resolves
431
+ * them. Write scopes are not queued: a scope's callback may issue a plain write of its own,
432
+ * which must not wait on the scope that contains it.
433
+ */
434
+ async #runWrite(run) {
435
+ const restarting = async () => {
436
+ for (let attempt = 0;; attempt += 1) {
437
+ try {
438
+ return await run();
439
+ }
440
+ catch (error) {
441
+ if (!(error instanceof StaleTriggerDerivationsError))
442
+ throw error;
443
+ if (attempt >= this.#maxCommitRetries)
444
+ throw error.conflict;
445
+ }
346
446
  }
447
+ };
448
+ const previous = this.#writeChain;
449
+ const current = previous.then(restarting, restarting);
450
+ this.#writeChain = current;
451
+ try {
452
+ return await current;
453
+ }
454
+ finally {
455
+ if (this.#writeChain === current)
456
+ this.#writeChain = Promise.resolve();
347
457
  }
348
458
  }
349
459
  /**
@@ -449,39 +559,57 @@ export class MinnowDatabase {
449
559
  }
450
560
  }
451
561
  }
452
- // Retiring the blocks is a commit like any other, and background compaction publishes
453
- // underneath it: a block this table owned a moment ago can already have been rewritten. The
454
- // list is therefore taken from the transaction's own snapshot the manifest its commit will
455
- // be validated against and a scope that loses the race simply runs again.
456
- for (let attempt = 0;; attempt += 1) {
457
- const transaction = await this.#transactions.begin();
458
- try {
459
- const segments = await this.store.listSegments(table.id);
460
- const snapshot = transaction.snapshotVersion === null
461
- ? undefined
462
- : await this.store.getManifest(transaction.snapshotVersion);
463
- const live = new Set(snapshot?.blockIds ?? []);
464
- const blockIds = [
465
- ...new Set(segments.flatMap((segment) => Object.values(segment.columnBlockIds).flat())),
466
- ].filter((id) => live.has(id));
467
- transaction.markTableChanged(table.id);
468
- if (blockIds.length > 0)
469
- transaction.supersedeBlocks(blockIds);
470
- await transaction.commit();
471
- break;
472
- }
473
- catch (error) {
474
- await transaction.abort();
475
- if (!(error instanceof WriteConflictError) || attempt >= this.#maxCommitRetries)
476
- throw error;
562
+ this.#droppingTables.add(table.id);
563
+ try {
564
+ // Stop every fold already attached to the table before its catalog record disappears.
565
+ // Otherwise an unpublished job can no longer resume or be cancelled by table name, and
566
+ // its transaction and staged output become permanent roots.
567
+ await this.#cancelTableCompactions(table.id);
568
+ // Retiring the blocks is a commit like any other, and background compaction publishes
569
+ // underneath it: a block this table owned a moment ago can already have been rewritten. The
570
+ // list is therefore taken from the transaction's own snapshot — the manifest its commit will
571
+ // be validated against — and a scope that loses the race simply runs again.
572
+ for (let attempt = 0;; attempt += 1) {
573
+ const transaction = await this.#transactions.begin();
574
+ try {
575
+ const segments = await this.store.listSegments(table.id);
576
+ const snapshot = transaction.snapshotVersion === null
577
+ ? undefined
578
+ : await this.store.getManifest(transaction.snapshotVersion);
579
+ const live = new Set(snapshot?.blockIds ?? []);
580
+ const blockIds = [
581
+ ...new Set(segments.flatMap((segment) => Object.values(segment.columnBlockIds).flat())),
582
+ ].filter((id) => live.has(id));
583
+ transaction.markTableChanged(table.id);
584
+ if (blockIds.length > 0)
585
+ transaction.supersedeBlocks(blockIds);
586
+ await transaction.commit();
587
+ break;
588
+ }
589
+ catch (error) {
590
+ await transaction.abort();
591
+ if (!(error instanceof WriteConflictError) || attempt >= this.#maxCommitRetries)
592
+ throw error;
593
+ }
477
594
  }
595
+ // The catalog goes last: until it does, the table is merely empty of live blocks, and a
596
+ // crash in between leaves a table whose rows are gone rather than a segment pointing at a
597
+ // table that is not there.
598
+ // Catch a fold that was already between its scheduling check and job creation when the
599
+ // drop began. The dropping marker prevents another one from starting after this point.
600
+ await this.#cancelTableCompactions(table.id);
601
+ await this.store.removeTable(table.id, table.revision ?? 0);
602
+ for (const column of table.columns)
603
+ this.#gzipVerdicts.delete(column.id);
604
+ this.#autoCompactionBackoff.delete(table.id);
605
+ this.#commitsSinceCompactionCheck.delete(table.id);
606
+ this.#idleCompactionTableIds.delete(table.id);
607
+ this.#planCache.clear();
608
+ return true;
609
+ }
610
+ finally {
611
+ this.#droppingTables.delete(table.id);
478
612
  }
479
- // The catalog goes last: until it does, the table is merely empty of live blocks, and a
480
- // crash in between leaves a table whose rows are gone rather than a segment pointing at a
481
- // table that is not there.
482
- await this.store.removeTable(table.id, table.revision ?? 0);
483
- this.#planCache.clear();
484
- return true;
485
613
  }
486
614
  async insertBatch(tableName, input) {
487
615
  const table = await this.#findTable(tableName);
@@ -490,7 +618,7 @@ export class MinnowDatabase {
490
618
  const keys = autoIncrement === undefined || autoIncrement.missingIndexes.length === 0
491
619
  ? batchKeys(table, batch)
492
620
  : undefined;
493
- const result = await this.#withTriggerRestarts(() => this.#writeBatch(table, batch, "insert", keys, autoIncrement));
621
+ const result = await this.#runWrite(() => this.#writeBatch(table, batch, "insert", keys, autoIncrement));
494
622
  collectAutoIncrementGenerated(batch, generated, autoIncrement);
495
623
  return {
496
624
  tableName: result.tableName,
@@ -525,7 +653,7 @@ export class MinnowDatabase {
525
653
  await this.#assertForeignKeysPresent(table, (column) => batch.columns[column] ?? [], (sql, params) => this.query(sql, { params, memoize: false }));
526
654
  const deferred = autoIncrement !== undefined && autoIncrement.missingIndexes.length > 0;
527
655
  const keys = deferred ? undefined : batchKeys(table, batch);
528
- const result = await this.#withTriggerRestarts(() => this.#writeBatch(table, batch, "upsert", keys, autoIncrement));
656
+ const result = await this.#runWrite(() => this.#writeBatch(table, batch, "upsert", keys, autoIncrement));
529
657
  collectAutoIncrementGenerated(batch, generated, autoIncrement);
530
658
  return {
531
659
  ...result,
@@ -544,7 +672,7 @@ export class MinnowDatabase {
544
672
  }
545
673
  const keys = validateUpdateBatch(table, keyColumn, input);
546
674
  await this.#assertForeignKeysPresent(table, (column) => input.changes[column] ?? [], (sql, params) => this.query(sql, { params, memoize: false }));
547
- return this.#withTriggerRestarts(() => this.#writeUpdateBatch(table, keyColumn, input, keys));
675
+ return this.#runWrite(() => this.#writeUpdateBatch(table, keyColumn, input, keys));
548
676
  }
549
677
  async update(tableName, key, changes) {
550
678
  return this.updateBatch(tableName, {
@@ -555,7 +683,7 @@ export class MinnowDatabase {
555
683
  async deleteBatch(tableName, input) {
556
684
  const dependents = await this.#childForeignKeys(tableName);
557
685
  if (dependents.length === 0) {
558
- return this.#withTriggerRestarts(() => this.#deleteBatchOnce(tableName, input));
686
+ return this.#runWrite(() => this.#deleteBatchOnce(tableName, input));
559
687
  }
560
688
  // E141-04: the referential actions and the delete itself publish as one commit, so no tab
561
689
  // can observe a parent gone while its children still point at it.
@@ -606,14 +734,15 @@ export class MinnowDatabase {
606
734
  keys.set(token, value);
607
735
  });
608
736
  const logicalBytes = estimateValuesBytes(input.keys);
609
- const transaction = await this.#transactions.begin();
737
+ // Deferred: the record is written only if something stages in two steps (trigger rows),
738
+ // and otherwise rides the single-shot commit below — or never exists, for a no-op delete.
739
+ const transaction = await this.#transactions.beginDeferred();
610
740
  const segmentId = this.#createId();
611
741
  transaction.setUniqueKeyChanges({
612
742
  tableId: table.id,
613
743
  keyTokens: [...keys.keys()],
614
744
  requireAbsent: false,
615
745
  remove: true,
616
- ...(table.uniqueKeyStorage === undefined ? {} : { storageMode: table.uniqueKeyStorage }),
617
746
  });
618
747
  let deletedRowCount;
619
748
  let storedBytes = 0;
@@ -674,11 +803,7 @@ export class MinnowDatabase {
674
803
  const deletePreImages = (await this.#triggerPreImages(table, keyColumn, [...keys.values()], "delete")).filter((row) => row !== undefined);
675
804
  const deleteValueAt = (source, column, rowIndex) => (source === "old" ? (deletePreImages[rowIndex]?.[column] ?? null) : null);
676
805
  await this.#stageTriggerDerivedInserts(transaction, table, "delete", deletePreImages.length, deleteValueAt, "before");
677
- let stageStarted = performance.now();
678
- await transaction.stageBlocks(blockWrites);
679
- stageMs += performance.now() - stageStarted;
680
- stageStarted = performance.now();
681
- await transaction.stageSegment({
806
+ const segment = {
682
807
  id: segmentId,
683
808
  tableId: table.id,
684
809
  transactionId: transaction.id,
@@ -690,15 +815,24 @@ export class MinnowDatabase {
690
815
  keyColumnId: keyColumn.id,
691
816
  level: 0,
692
817
  createdAt: this.#now().toISOString(),
693
- });
694
- await this.#stageTriggerDerivedInserts(transaction, table, "delete", deletePreImages.length, deleteValueAt, "after");
818
+ };
819
+ // AFTER triggers stage derived rows between the segment and the commit, which keeps the
820
+ // two apart; without them the stage and the commit collapse into one storage write.
821
+ const stagesAfter = firesAfterTriggers(table, "delete");
822
+ const stageStarted = performance.now();
823
+ if (stagesAfter) {
824
+ await transaction.stageArtifacts(blockWrites, [segment]);
825
+ await this.#stageTriggerDerivedInserts(transaction, table, "delete", deletePreImages.length, deleteValueAt, "after");
826
+ }
695
827
  stageMs += performance.now() - stageStarted;
696
828
  for (let attempt = 0; attempt <= this.#maxCommitRetries; attempt += 1) {
697
829
  const commitStarted = performance.now();
698
830
  try {
699
- const manifest = await transaction.commit();
831
+ const manifest = stagesAfter
832
+ ? await transaction.commit()
833
+ : await transaction.stageArtifactsAndCommit(blockWrites, [segment]);
700
834
  commitMs += performance.now() - commitStarted;
701
- this.#notifyLiveCommit();
835
+ this.#afterCommit(manifest);
702
836
  return {
703
837
  tableName: table.name,
704
838
  segmentId,
@@ -745,7 +879,8 @@ export class MinnowDatabase {
745
879
  const started = performance.now();
746
880
  const logicalBytes = estimateValuesBytes(input.keys) +
747
881
  Object.values(input.changes).reduce((total, values) => total + estimateValuesBytes(values), 0);
748
- const transaction = await this.#transactions.begin();
882
+ // Deferred: the record rides the single-shot commit below unless trigger rows stage first.
883
+ const transaction = await this.#transactions.beginDeferred();
749
884
  const segmentId = this.#createId();
750
885
  const columnBlockIds = {};
751
886
  const changedColumns = Object.keys(input.changes).sort();
@@ -810,30 +945,36 @@ export class MinnowDatabase {
810
945
  }
811
946
  }
812
947
  await this.#stageTriggerDerivedInserts(transaction, table, "update", input.keys.length, updateValueAt, "before");
948
+ const segment = {
949
+ id: segmentId,
950
+ tableId: table.id,
951
+ transactionId: transaction.id,
952
+ rowCount: input.keys.length,
953
+ rowIdStart: 0n,
954
+ rowIdEndExclusive: 0n,
955
+ columnBlockIds,
956
+ kind: "update",
957
+ keyColumnId: keyColumn.id,
958
+ level: 0,
959
+ createdAt: this.#now().toISOString(),
960
+ };
961
+ // AFTER triggers stage derived rows between the segment and the commit, which keeps the
962
+ // two apart; without them the stage and the commit collapse into one storage write.
963
+ const stagesAfter = firesAfterTriggers(table, "update");
813
964
  const stageStarted = performance.now();
814
- await transaction.stageArtifacts(batchBlockWrites, [
815
- {
816
- id: segmentId,
817
- tableId: table.id,
818
- transactionId: transaction.id,
819
- rowCount: input.keys.length,
820
- rowIdStart: 0n,
821
- rowIdEndExclusive: 0n,
822
- columnBlockIds,
823
- kind: "update",
824
- keyColumnId: keyColumn.id,
825
- level: 0,
826
- createdAt: this.#now().toISOString(),
827
- },
828
- ]);
829
- await this.#stageTriggerDerivedInserts(transaction, table, "update", input.keys.length, updateValueAt, "after");
965
+ if (stagesAfter) {
966
+ await transaction.stageArtifacts(batchBlockWrites, [segment]);
967
+ await this.#stageTriggerDerivedInserts(transaction, table, "update", input.keys.length, updateValueAt, "after");
968
+ }
830
969
  stageMs += performance.now() - stageStarted;
831
970
  for (let attempt = 0; attempt <= this.#maxCommitRetries; attempt += 1) {
832
971
  const commitStarted = performance.now();
833
972
  try {
834
- const manifest = await transaction.commit();
973
+ const manifest = stagesAfter
974
+ ? await transaction.commit()
975
+ : await transaction.stageArtifactsAndCommit(batchBlockWrites, [segment]);
835
976
  commitMs += performance.now() - commitStarted;
836
- this.#notifyLiveCommit();
977
+ this.#afterCommit(manifest);
837
978
  return {
838
979
  tableName: table.name,
839
980
  segmentId,
@@ -927,7 +1068,6 @@ export class MinnowDatabase {
927
1068
  tableId: table.id,
928
1069
  keyTokens: [...resolvedKeys.keys()],
929
1070
  requireAbsent: kind === "insert",
930
- ...(table.uniqueKeyStorage === undefined ? {} : { storageMode: table.uniqueKeyStorage }),
931
1071
  });
932
1072
  }
933
1073
  counts =
@@ -1008,20 +1148,29 @@ export class MinnowDatabase {
1008
1148
  else if (upsertFirings !== undefined) {
1009
1149
  await this.#stageUpsertTriggerFirings(transaction, table, input, upsertFirings, "before");
1010
1150
  }
1011
- await transaction.stageArtifacts(batchBlockWrites, [segment]);
1012
- if (kind === "insert") {
1013
- await this.#stageTriggerDerivedInserts(transaction, table, "insert", rowCount, insertValueAt, "after");
1014
- }
1015
- else if (upsertFirings !== undefined) {
1016
- await this.#stageUpsertTriggerFirings(transaction, table, input, upsertFirings, "after");
1151
+ // AFTER triggers stage derived rows between the segment and the commit, which keeps the
1152
+ // two apart; without them the stage and the commit collapse into one storage write.
1153
+ const stagesAfter = kind === "insert"
1154
+ ? firesAfterTriggers(table, "insert")
1155
+ : upsertFirings !== undefined && firesAfterTriggers(table, "insert", "update");
1156
+ if (stagesAfter) {
1157
+ await transaction.stageArtifacts(batchBlockWrites, [segment]);
1158
+ if (kind === "insert") {
1159
+ await this.#stageTriggerDerivedInserts(transaction, table, "insert", rowCount, insertValueAt, "after");
1160
+ }
1161
+ else if (upsertFirings !== undefined) {
1162
+ await this.#stageUpsertTriggerFirings(transaction, table, input, upsertFirings, "after");
1163
+ }
1017
1164
  }
1018
1165
  stageMs += performance.now() - stageStarted;
1019
1166
  for (let attempt = 0; attempt <= this.#maxCommitRetries; attempt += 1) {
1020
1167
  const commitStarted = performance.now();
1021
1168
  try {
1022
- const manifest = await transaction.commit();
1169
+ const manifest = stagesAfter
1170
+ ? await transaction.commit()
1171
+ : await transaction.stageArtifactsAndCommit(batchBlockWrites, [segment]);
1023
1172
  commitMs += performance.now() - commitStarted;
1024
- this.#notifyLiveCommit();
1173
+ this.#afterCommit(manifest);
1025
1174
  return {
1026
1175
  tableName: table.name,
1027
1176
  segmentId,
@@ -1125,13 +1274,13 @@ export class MinnowDatabase {
1125
1274
  }
1126
1275
  return plan;
1127
1276
  }
1128
- async #prepareCompiledPlan(plan, options = {}) {
1277
+ async #prepareCompiledPlan(plan, options = {}, probe) {
1129
1278
  // The ORDER-BY-expression desugar's wrapper is projection-only: prepare the inner block
1130
1279
  // directly (no derived materialization) and project each result to the visible aliases,
1131
1280
  // so `.search()` costs the same whether or not the caller also selects the score.
1132
1281
  const wrapper = transparentProjectionSource(plan);
1133
1282
  if (wrapper !== undefined) {
1134
- const prepared = await this.#prepareCompiledPlan(wrapper.inner, options);
1283
+ const prepared = await this.#prepareCompiledPlan(wrapper.inner, options, probe);
1135
1284
  return {
1136
1285
  sql: prepared.sql,
1137
1286
  tables: prepared.tables,
@@ -1179,7 +1328,7 @@ export class MinnowDatabase {
1179
1328
  });
1180
1329
  }
1181
1330
  else {
1182
- await this.#withSharedCatalogSnapshot(collectRealTableNames(plan), prepareAtSnapshot);
1331
+ await this.#withSharedCatalogSnapshot(collectRealTableNames(plan), prepareAtSnapshot, probe);
1183
1332
  }
1184
1333
  return createPreparedColumnarQuery(chooseJoinOrder(resolvedPlan, columnarTables), columnarTables, memory, ftsStats === undefined ? {} : { ftsStats });
1185
1334
  }
@@ -1196,9 +1345,9 @@ export class MinnowDatabase {
1196
1345
  * write transactions. If the manifest is pruned between the read and the lease, the
1197
1346
  * state is re-read.
1198
1347
  */
1199
- async #withSharedCatalogSnapshot(names, action) {
1348
+ async #withSharedCatalogSnapshot(names, action, probe) {
1200
1349
  for (;;) {
1201
- const state = await this.#cachedCatalogState(names);
1350
+ const state = await this.#cachedCatalogState(names, probe);
1202
1351
  const realTables = new Map();
1203
1352
  names.forEach((name, index) => {
1204
1353
  const table = state.tables[index];
@@ -1239,11 +1388,13 @@ export class MinnowDatabase {
1239
1388
  * cached state was read, so reuse is exact, not heuristic. Stores without a probe are never
1240
1389
  * cached. Entries key on the requested table-name set; a changed epoch clears them all.
1241
1390
  */
1242
- async #cachedCatalogState(names) {
1243
- const probe = this.store.getCatalogProbe?.bind(this.store);
1244
- if (probe === undefined)
1391
+ async #cachedCatalogState(names, probe) {
1392
+ // A probe the caller read moments earlier in the same statement serves: a state read under
1393
+ // it is at least as fresh, and the cache is only consulted under its epoch.
1394
+ const read = this.store.getCatalogProbe?.bind(this.store);
1395
+ if (read === undefined)
1245
1396
  return this.#queryCatalogState(names);
1246
- const { catalogEpoch } = await probe();
1397
+ const { catalogEpoch } = probe ?? (await read());
1247
1398
  // Table names are only trimmed, never charset-restricted, so no join separator is
1248
1399
  // collision-free; JSON encoding is.
1249
1400
  const key = JSON.stringify(names);
@@ -1292,45 +1443,83 @@ export class MinnowDatabase {
1292
1443
  }
1293
1444
  /**
1294
1445
  * Reuses the shared internal reader lease when it targets the requested version and has
1295
- * not expired; otherwise opens a fresh lease at that exact version and retires the old
1296
- * one once its readers drain. Returns undefined when the version's manifest disappeared
1297
- * between the catalog read and the lease, so the caller can re-read.
1446
+ * not expired. Otherwise the pin has to move: with no reader left on the old version the
1447
+ * one lease record is re-pinned in place (one storage write, instead of a create now and a
1448
+ * remove once the old one drains); while readers remain, a fresh lease opens at the exact
1449
+ * version and the old one retires as they finish. Returns undefined when the version's
1450
+ * manifest disappeared between the catalog read and the lease, so the caller can re-read.
1298
1451
  */
1299
1452
  async #acquireSharedLease(version) {
1300
- const current = this.#sharedLease;
1301
- if (current?.version === version &&
1302
- current.lease.expiresAt.getTime() - this.#now().getTime() > 0) {
1303
- current.refCount += 1;
1304
- try {
1305
- await this.#renewInternalLeaseIfNeeded(current.lease);
1306
- return current;
1453
+ for (;;) {
1454
+ // A move in flight is closing the shared snapshot it re-pins; wait for it rather than
1455
+ // hand that snapshot out, then look again.
1456
+ if (this.#sharedLeaseMove !== undefined) {
1457
+ await this.#sharedLeaseMove;
1458
+ continue;
1307
1459
  }
1308
- catch (error) {
1309
- this.#releaseSharedLease(current);
1310
- throw error;
1460
+ const current = this.#sharedLease;
1461
+ if (current?.version === version &&
1462
+ current.lease.expiresAt.getTime() - this.#now().getTime() > 0) {
1463
+ current.refCount += 1;
1464
+ try {
1465
+ await this.#renewInternalLeaseIfNeeded(current.lease);
1466
+ return current;
1467
+ }
1468
+ catch (error) {
1469
+ this.#releaseSharedLease(current);
1470
+ throw error;
1471
+ }
1311
1472
  }
1312
- }
1313
- let lease;
1314
- try {
1315
- lease = await this.#transactions.openLeasedSnapshot({
1473
+ const options = {
1316
1474
  id: `${this.#internalLeaseOwnerId}/${String(this.#internalLeaseSequence++)}`,
1317
1475
  ownerId: this.#internalLeaseOwnerId,
1318
1476
  ttlMs: INTERNAL_READ_LEASE_TTL_MS,
1319
1477
  version,
1320
- });
1321
- }
1322
- catch (error) {
1323
- if (error instanceof SnapshotManifestMissingError)
1324
- return undefined;
1325
- throw error;
1326
- }
1327
- const entry = { lease, version, refCount: 1 };
1328
- const previous = this.#sharedLease;
1329
- this.#sharedLease = entry;
1330
- if (previous?.refCount === 0) {
1331
- void previous.lease.release().catch(() => undefined);
1478
+ };
1479
+ let lease;
1480
+ try {
1481
+ if (current?.refCount === 0) {
1482
+ const move = this.#transactions.moveLeasedSnapshot(current.lease, options);
1483
+ this.#sharedLeaseMove = move.then(() => undefined, () => undefined);
1484
+ try {
1485
+ lease = await move;
1486
+ }
1487
+ finally {
1488
+ this.#sharedLeaseMove = undefined;
1489
+ }
1490
+ }
1491
+ else {
1492
+ lease = await this.#transactions.openLeasedSnapshot(options);
1493
+ }
1494
+ }
1495
+ catch (error) {
1496
+ if (error instanceof SnapshotManifestMissingError)
1497
+ return undefined;
1498
+ throw error;
1499
+ }
1500
+ const entry = { lease, version, refCount: 1 };
1501
+ const previous = this.#sharedLease;
1502
+ this.#sharedLease = entry;
1503
+ if (previous?.refCount === 0) {
1504
+ // Already closed when it was the one just moved; a remove otherwise.
1505
+ void previous.lease.release().catch(() => undefined);
1506
+ }
1507
+ return entry;
1332
1508
  }
1333
- return entry;
1509
+ }
1510
+ /**
1511
+ * Drops the shared reader lease when nothing holds it and it has fallen behind the current
1512
+ * version. The lease outlives the query that took it so the next query at the same version
1513
+ * reuses it — but after a burst of writes and a fold, an idle database's last lease sits at a
1514
+ * pre-fold version and roots every block that version referenced, which is exactly what a
1515
+ * collection pass is trying to reclaim. The next query simply takes a fresh lease.
1516
+ */
1517
+ #releaseIdleSharedLease() {
1518
+ const current = this.#sharedLease;
1519
+ if (current?.refCount !== 0)
1520
+ return;
1521
+ this.#sharedLease = undefined;
1522
+ void current.lease.release().catch(() => undefined);
1334
1523
  }
1335
1524
  #releaseSharedLease(entry) {
1336
1525
  entry.refCount -= 1;
@@ -1439,7 +1628,9 @@ export class MinnowDatabase {
1439
1628
  }
1440
1629
  }
1441
1630
  const { result: inner, schema: innerSchema } = await this.#executeBlockWithSchemaCached(source.windowed.block, snapshot, visibility, memory, realTables, typedSchemas, cacheResults);
1442
- const windowed = applyWindowFunctions(inner, source.windowed.windows);
1631
+ const windowed = applyWindowFunctions(inner, source.windowed.windows, {
1632
+ copyRows: cacheResults,
1633
+ });
1443
1634
  const schema = [
1444
1635
  ...innerSchema,
1445
1636
  ...source.windowed.windows.map((window) => ({
@@ -1522,12 +1713,20 @@ export class MinnowDatabase {
1522
1713
  probe !== undefined;
1523
1714
  if (probe === undefined || !memoizable)
1524
1715
  return this.#queryCompiled(plan, options);
1525
- const key = `res ${queryResultMemoKey(sql, options.params ?? [])}`;
1716
+ return this.#memoizedQuery(plan, `res ${queryResultMemoKey(sql, options.params ?? [])}`, options, probe);
1717
+ }
1718
+ /**
1719
+ * The result memo: a pure cache over the freshness probe, keyed by the statement and the
1720
+ * catalog epoch it was answered at. The probe read before execution is handed down to the
1721
+ * execution itself — the view lookup and the catalog state would otherwise each probe again,
1722
+ * and on IndexedDB every probe is a read transaction, a floor under every small query.
1723
+ */
1724
+ async #memoizedQuery(plan, key, options, probe) {
1526
1725
  const before = await probe();
1527
1726
  const cached = this.#cacheGet(`${key}\u0001${String(before.catalogEpoch)}`);
1528
1727
  if (cached !== undefined)
1529
1728
  return copyQueryResult(cached);
1530
- const result = await this.#queryCompiled(plan, options);
1729
+ const result = await this.#queryCompiled(plan, options, before);
1531
1730
  const bytes = queryResultRetainedBytes(result);
1532
1731
  if (bytes <= RESULT_MEMO_MAX_BYTES) {
1533
1732
  // Cache only when the epoch did not move during execution: the result is then exactly
@@ -1545,14 +1744,14 @@ export class MinnowDatabase {
1545
1744
  * (E051-09), and a NATURAL join becomes the equality over the columns its sides share
1546
1745
  * (F401-01). Reads the catalog only for the statements that ask for one of them.
1547
1746
  */
1548
- async #applyCatalogRewrites(plan) {
1747
+ async #applyCatalogRewrites(plan, probe) {
1549
1748
  const aliased = planHasSourceColumnAliases(plan);
1550
1749
  const natural = planHasNaturalJoins(plan);
1551
1750
  // Whether a name is a view cannot be read off the statement, so this is the one thing every
1552
1751
  // read has to ask the catalog. It asks by epoch — an O(1) probe the store already serves for
1553
1752
  // result memoization — and only re-reads the view set when the catalog has actually moved.
1554
1753
  // A database with no views therefore pays one probe, not a catalog scan per query.
1555
- const { views } = await this.#catalogFacts();
1754
+ const { views } = await this.#catalogFacts(probe);
1556
1755
  let rewritten = plan;
1557
1756
  if (views.size > 0 && planReadsViews(plan, (name) => views.has(name))) {
1558
1757
  const bodies = new Map();
@@ -1587,8 +1786,8 @@ export class MinnowDatabase {
1587
1786
  * the steady state has to be one epoch probe and no allocation; the facts are rebuilt only
1588
1787
  * when a catalog mutation — anywhere, including another tab — moves the epoch.
1589
1788
  */
1590
- async #catalogFacts() {
1591
- const probe = await this.store.getCatalogProbe?.();
1789
+ async #catalogFacts(probe) {
1790
+ probe ??= await this.store.getCatalogProbe?.();
1592
1791
  const epoch = probe?.catalogEpoch;
1593
1792
  const cached = this.#catalogCache;
1594
1793
  if (cached !== undefined && epoch !== undefined && cached.epoch === epoch)
@@ -1620,13 +1819,16 @@ export class MinnowDatabase {
1620
1819
  * re-runs — routes through the same streaming-first execution, so builder/SQL parity holds
1621
1820
  * for the execution path as well as the plan.
1622
1821
  */
1623
- async #queryCompiled(plan, options = {}) {
1624
- plan = await this.#applyCatalogRewrites(plan);
1822
+ async #queryCompiled(plan, options = {}, probe) {
1823
+ // One freshness probe per query: read here unless the caller already has one, and handed
1824
+ // to the view lookup and the catalog state below, which would otherwise probe again each.
1825
+ probe ??= await this.store.getCatalogProbe?.();
1826
+ plan = await this.#applyCatalogRewrites(plan, probe);
1625
1827
  const spillPageRows = options.spillPageRows === undefined
1626
1828
  ? undefined
1627
1829
  : positiveWholeNumber(options.spillPageRows, "Query spill page rows");
1628
1830
  if (this.#canStreamPlanShape(plan, options)) {
1629
- const streamed = await this.#queryStreamed(plan, options, spillPageRows);
1831
+ const streamed = await this.#queryStreamed(plan, options, spillPageRows, probe);
1630
1832
  if (streamed !== undefined)
1631
1833
  return streamed;
1632
1834
  }
@@ -1636,12 +1838,12 @@ export class MinnowDatabase {
1636
1838
  // query its streaming eligibility.
1637
1839
  const wrapper = transparentProjectionSource(plan);
1638
1840
  if (wrapper !== undefined && this.#canStreamPlanShape(wrapper.inner, options)) {
1639
- const streamed = await this.#queryStreamed(wrapper.inner, options, spillPageRows);
1841
+ const streamed = await this.#queryStreamed(wrapper.inner, options, spillPageRows, probe);
1640
1842
  if (streamed !== undefined)
1641
1843
  return projectResultColumns(streamed, wrapper.aliases);
1642
1844
  }
1643
1845
  }
1644
- const prepared = await this.#prepareCompiledPlan(plan, options);
1846
+ const prepared = await this.#prepareCompiledPlan(plan, options, probe);
1645
1847
  // Read the peak before close(): closing releases the context and zeroes what it tracked.
1646
1848
  const report = (result) => {
1647
1849
  options.onStats?.({ peakMemoryBytes: prepared.memoryUsage.peakBytes });
@@ -1695,11 +1897,22 @@ export class MinnowDatabase {
1695
1897
  const renewed = await this.store.renewTempOwner(ownerId, lease.revision, new Date(expiresAtMs).toISOString());
1696
1898
  leases.set(ownerId, { revision: renewed.revision, expiresAtMs });
1697
1899
  };
1900
+ const batched = this.store.putTempRunPages?.bind(this.store);
1698
1901
  return {
1699
1902
  putPage: async (ownerId, runId, pageIndex, bytes) => {
1700
1903
  await ensureLease(ownerId);
1701
1904
  await this.store.putTempRunPage({ ownerId, runId, pageIndex, bytes });
1702
1905
  },
1906
+ ...(batched === undefined
1907
+ ? {}
1908
+ : {
1909
+ putPages: async (pages) => {
1910
+ for (const owner of new Set(pages.map((page) => page.ownerId))) {
1911
+ await ensureLease(owner);
1912
+ }
1913
+ await batched(pages);
1914
+ },
1915
+ }),
1703
1916
  getPage: async (ownerId, runId, pageIndex) => {
1704
1917
  await ensureLease(ownerId);
1705
1918
  return this.store.getTempRunPage(ownerId, runId, pageIndex);
@@ -1751,17 +1964,30 @@ export class MinnowDatabase {
1751
1964
  * refresh but never produce a stale result. Subscriptions retain a result digest, not rows.
1752
1965
  */
1753
1966
  liveQueries(options = {}) {
1967
+ const compileLiveQuery = (query) => {
1968
+ if (typeof query === "string")
1969
+ return this.#compileCached(query);
1970
+ if (query.kind === "typed-query")
1971
+ return query.plan;
1972
+ return bindPlanParameters(this.#compileCached(query.sql), query.params);
1973
+ };
1754
1974
  const set = new LiveQuerySet({
1755
1975
  currentVersion: () => this.store.getCurrentManifestVersion(),
1756
1976
  manifestPage: (afterVersion, limit) => this.store.listManifestPage(afterVersion, limit),
1757
1977
  dependencyTableIds: async (query) => {
1758
- const compiled = typeof query === "string" ? this.#compileCached(query) : query.plan;
1978
+ const compiled = compileLiveQuery(query);
1759
1979
  // A live query over a view depends on the tables behind it, not on the view's name.
1760
1980
  const plan = await this.#applyCatalogRewrites(compiled);
1761
1981
  const tables = await this.#findRealBlockTables(plan);
1762
1982
  return new Set([...tables.values()].map((record) => record.id));
1763
1983
  },
1764
- execute: async (query) => typeof query === "string" ? this.query(query) : this.#queryCompiled(query.plan),
1984
+ execute: async (query) => {
1985
+ if (typeof query === "string")
1986
+ return this.query(query);
1987
+ if (query.kind === "typed-query")
1988
+ return this.#queryCompiled(query.plan);
1989
+ return this.query(query.sql, { params: query.params });
1990
+ },
1765
1991
  changeCanAffect: (query, tableIds, after, until) => this.#liveChangeCanAffect(query, tableIds, after, until),
1766
1992
  }, {
1767
1993
  ...options,
@@ -1774,15 +2000,20 @@ export class MinnowDatabase {
1774
2000
  return set;
1775
2001
  }
1776
2002
  /**
1777
- * Data-layer live-query selectivity: proves, when it can, that the commits in
1778
- * (after, until] to the given tables cannot change the query's result. Pure compaction
1779
- * rewrites are data-neutral; a pure-insert commit whose every new block's zone statistics
1780
- * reject the plan's predicates for that table cannot add a matching row. Everything else —
1781
- * updates, deletes, upserts (which can remove rows from a result), missing statistics,
1782
- * full-text plans, tables without zone-analyzable predicates — answers true.
2003
+ * The data-layer proof behind a live sweep's zone skips: whether the commits in
2004
+ * (after, until] to these tables can change this query's result. False only on proof —
2005
+ * every segment the window introduced to the table is a compaction rewrite or an insert
2006
+ * whose zone maps reject the query's predicates, and every version that changed the table
2007
+ * left a segment to inspect.
2008
+ *
2009
+ * Its inputs come from `#liveProofContext`, shared by every subscription in the sweep.
1783
2010
  */
1784
2011
  async #liveChangeCanAffect(query, tableIds, after, until) {
1785
- const plan = typeof query === "string" ? this.#compileCached(query) : query.plan;
2012
+ const plan = typeof query === "string"
2013
+ ? this.#compileCached(query)
2014
+ : query.kind === "typed-query"
2015
+ ? query.plan
2016
+ : bindPlanParameters(this.#compileCached(query.sql), query.params);
1786
2017
  if (planContainsFts(plan))
1787
2018
  return true;
1788
2019
  // Base-scan zone proofs are unsound when the plan reads the table anywhere else — a
@@ -1790,44 +2021,13 @@ export class MinnowDatabase {
1790
2021
  // predicates reject (e.g. `value > (SELECT AVG(value) FROM t)`).
1791
2022
  if (planReadsBeyondSingleScan(plan))
1792
2023
  return true;
1793
- // Versions in (after, until] that recorded a change to each table. Proof requires every
1794
- // one of them to be accounted for by a surviving, inspected segment: garbage collection
1795
- // deletes reclaimed segments outright, so "no segment in the window" is absence of
1796
- // evidence, not evidence of neutrality.
1797
- const changedVersions = new Map();
1798
- {
1799
- let cursor = after;
1800
- pages: for (;;) {
1801
- const page = await this.store.listManifestPage(cursor, 64);
1802
- for (const manifest of page.records) {
1803
- if (manifest.version > until)
1804
- break pages;
1805
- for (const tableId of manifest.changedTableIds ?? []) {
1806
- const versions = changedVersions.get(tableId) ?? new Set();
1807
- versions.add(manifest.version);
1808
- changedVersions.set(tableId, versions);
1809
- }
1810
- if (manifest.version === until)
1811
- break pages;
1812
- }
1813
- if (page.nextCursor === null)
1814
- break;
1815
- cursor = page.nextCursor;
1816
- }
1817
- }
2024
+ const context = await this.#liveProofContext(after, until);
1818
2025
  for (const tableId of tableIds) {
1819
- const table = await this.store.getTable(tableId);
1820
- if (table === undefined)
2026
+ const entry = await this.#liveProofTable(context, tableId);
2027
+ if (entry === undefined)
1821
2028
  return true;
1822
- const predicates = zonePredicates(plan, table);
1823
- const segments = await this.store.listSegments(tableId);
1824
- const transactions = new Map((await this.#transactionRecordsForSegments(segments)).map((record) => [record.id, record]));
1825
- const coveredVersions = new Set();
1826
- for (const segment of segments) {
1827
- const committed = transactions.get(segment.transactionId)?.committedVersion ?? null;
1828
- if (committed === null || committed <= (after ?? -1) || committed > until)
1829
- continue;
1830
- coveredVersions.add(committed);
2029
+ const predicates = zonePredicates(plan, entry.table);
2030
+ for (const segment of entry.windowSegments) {
1831
2031
  const kind = segment.kind ?? "insert";
1832
2032
  // Compaction rewrites are visible-data-neutral by construction.
1833
2033
  if (kind === "base")
@@ -1884,13 +2084,85 @@ export class MinnowDatabase {
1884
2084
  }
1885
2085
  // A version that changed this table but left no surviving segment to inspect (its
1886
2086
  // segments were compacted away and reclaimed) cannot be proven neutral.
1887
- for (const version of changedVersions.get(tableId) ?? []) {
1888
- if (!coveredVersions.has(version))
2087
+ for (const version of context.changedVersions.get(tableId) ?? []) {
2088
+ if (!entry.coveredVersions.has(version))
1889
2089
  return true;
1890
2090
  }
1891
2091
  }
1892
2092
  return false;
1893
2093
  }
2094
+ /**
2095
+ * The inputs every live proof over one commit window shares: the versions in (after, until]
2096
+ * that recorded a change to each table, and — filled in per table as proofs ask — the table
2097
+ * record, its segments committed in the window, and the versions those segments account
2098
+ * for. A sweep proves each subscription separately, and twenty subscriptions on one table
2099
+ * used to list its segments and transactions twenty times, a readonly transaction each on
2100
+ * IndexedDB and a cross-tab round trip each on an OPFS follower. A few recent windows stay
2101
+ * resident so concurrent sets sweeping different windows do not evict each other.
2102
+ */
2103
+ async #liveProofContext(after, until) {
2104
+ const key = `${String(after)}:${String(until)}`;
2105
+ const cached = this.#liveProofContexts.get(key);
2106
+ if (cached !== undefined)
2107
+ return cached;
2108
+ const context = (async () => {
2109
+ // Proof requires every version to be accounted for by a surviving, inspected segment:
2110
+ // garbage collection deletes reclaimed segments outright, so "no segment in the window"
2111
+ // is absence of evidence, not evidence of neutrality.
2112
+ const changedVersions = new Map();
2113
+ let cursor = after;
2114
+ pages: for (;;) {
2115
+ const page = await this.store.listManifestPage(cursor, 64);
2116
+ for (const manifest of page.records) {
2117
+ if (manifest.version > until)
2118
+ break pages;
2119
+ for (const tableId of manifest.changedTableIds ?? []) {
2120
+ const versions = changedVersions.get(tableId) ?? new Set();
2121
+ versions.add(manifest.version);
2122
+ changedVersions.set(tableId, versions);
2123
+ }
2124
+ if (manifest.version === until)
2125
+ break pages;
2126
+ }
2127
+ if (page.nextCursor === null)
2128
+ break;
2129
+ cursor = page.nextCursor;
2130
+ }
2131
+ return { after, until, changedVersions, tables: new Map() };
2132
+ })();
2133
+ this.#liveProofContexts.set(key, context);
2134
+ if (this.#liveProofContexts.size > LIVE_PROOF_CONTEXT_LIMIT) {
2135
+ const oldest = this.#liveProofContexts.keys().next().value;
2136
+ if (oldest !== undefined)
2137
+ this.#liveProofContexts.delete(oldest);
2138
+ }
2139
+ return context;
2140
+ }
2141
+ #liveProofTable(context, tableId) {
2142
+ const cached = context.tables.get(tableId);
2143
+ if (cached !== undefined)
2144
+ return cached;
2145
+ const entry = (async () => {
2146
+ const table = await this.store.getTable(tableId);
2147
+ if (table === undefined)
2148
+ return undefined;
2149
+ const segments = await this.store.listSegments(tableId);
2150
+ const transactions = new Map((await this.#transactionRecordsForSegments(segments)).map((record) => [record.id, record]));
2151
+ const windowSegments = [];
2152
+ const coveredVersions = new Set();
2153
+ for (const segment of segments) {
2154
+ const committed = transactions.get(segment.transactionId)?.committedVersion ?? null;
2155
+ if (committed === null || committed <= (context.after ?? -1) || committed > context.until) {
2156
+ continue;
2157
+ }
2158
+ coveredVersions.add(committed);
2159
+ windowSegments.push(segment);
2160
+ }
2161
+ return { table, windowSegments, coveredVersions };
2162
+ })();
2163
+ context.tables.set(tableId, entry);
2164
+ return entry;
2165
+ }
1894
2166
  /**
1895
2167
  * True when every manifest published in (after, until] changed no row in any table: the
1896
2168
  * explicitly empty `changedTableIds` that compaction publishes through
@@ -1942,29 +2214,168 @@ export class MinnowDatabase {
1942
2214
  #maybeScheduleAutoCompaction(table, segments) {
1943
2215
  if (!this.#autoCompact)
1944
2216
  return;
1945
- const deltas = segments.filter((segment) => {
1946
- const kind = segment.kind ?? "insert";
1947
- return kind !== "insert" && kind !== "base";
1948
- }).length;
1949
- if (segments.length < AUTO_COMPACT_SCAN_SEGMENTS && deltas < AUTO_COMPACT_DELTA_SEGMENTS) {
2217
+ if (this.#droppingTables.has(table.id))
2218
+ return;
2219
+ if (!autoCompactionDue(segments))
1950
2220
  return;
1951
- }
1952
2221
  if (segments.length < (this.#autoCompactionBackoff.get(table.id) ?? 0))
1953
2222
  return;
1954
- if (this.#autoCompactionsInFlight.has(table.id))
2223
+ if (this.#autoCompactionsInFlight.has(table.id)) {
2224
+ // A final burst can cross the threshold while the prior fold is still planning or
2225
+ // running. Remember it: otherwise no later commit or scan may arrive to trigger the fold
2226
+ // that the final state still needs.
2227
+ this.#autoCompactionsRequested.add(table.id);
1955
2228
  return;
2229
+ }
1956
2230
  this.#autoCompactionsInFlight.add(table.id);
1957
- void this.compactTableStep(table.name, { maxBlocks: 4 })
1958
- .then(() => {
1959
- this.#autoCompactionBackoff.delete(table.id);
2231
+ void this.#runAutoCompaction(table)
2232
+ .then((folded) => {
2233
+ if (folded)
2234
+ this.#autoCompactionBackoff.delete(table.id);
2235
+ else {
2236
+ this.#autoCompactionBackoff.set(table.id, Math.min(AUTO_COMPACT_MAX_LEVEL_ZERO_SEGMENTS, Math.max(2, segments.length * 2)));
2237
+ }
1960
2238
  })
1961
2239
  .catch(() => {
1962
- this.#autoCompactionBackoff.set(table.id, Math.max(2, segments.length * 2));
2240
+ // Back off deterministic failures, but never beyond the maximum L0 prefix a fold can
2241
+ // consume. A transient conflict near the end of a burst must not strand hundreds of
2242
+ // segments waiting for a segment count the idle database can never reach.
2243
+ this.#autoCompactionBackoff.set(table.id, Math.min(AUTO_COMPACT_MAX_LEVEL_ZERO_SEGMENTS, Math.max(2, segments.length * 2)));
1963
2244
  })
1964
2245
  .finally(() => {
1965
2246
  this.#autoCompactionsInFlight.delete(table.id);
2247
+ if (this.#autoCompactionsRequested.delete(table.id)) {
2248
+ void yieldToEventLoop().then(() => this.#checkAutoCompaction(table.id));
2249
+ }
1966
2250
  });
1967
2251
  }
2252
+ /**
2253
+ * Plans a compaction job and drives it to publication in small steps, yielding to the event
2254
+ * loop between them so queries and writes interleave with the maintenance; then, while the
2255
+ * table is still due, plans the next. A job that only advanced when the next scan happened
2256
+ * to trigger it would sit half-written in an idle tab, its output staged and its sources
2257
+ * still read on every query, and the deltas that landed while it ran would wait for a scan
2258
+ * that may never come. Returns whether anything was folded: a table compaction cannot help
2259
+ * (an unsupported layout, keys living in published partitions) must not be re-planned on
2260
+ * every trigger, so the caller backs it off as it would a failure.
2261
+ */
2262
+ async #runAutoCompaction(table) {
2263
+ const options = {
2264
+ maxBlocks: AUTO_COMPACT_STEP_BLOCKS,
2265
+ maxLevel0Segments: AUTO_COMPACT_MAX_LEVEL_ZERO_SEGMENTS,
2266
+ };
2267
+ let folded = false;
2268
+ for (;;) {
2269
+ if (this.#droppingTables.has(table.id))
2270
+ return folded;
2271
+ let progress = await this.compactTableStep(table.name, options);
2272
+ while (progress.result === null) {
2273
+ if (progress.jobId === null)
2274
+ throw new Error("Compaction progress lost its job ID");
2275
+ await yieldToEventLoop();
2276
+ progress = await this.resumeCompactionJob(progress.jobId, options);
2277
+ }
2278
+ if (!progress.result.compacted)
2279
+ return folded;
2280
+ folded = true;
2281
+ // The fold's sources are garbage now; collect before planning the next fold.
2282
+ this.#maybeScheduleAutoCollection();
2283
+ await yieldToEventLoop();
2284
+ const current = await this.store.getTable(table.id);
2285
+ if (current === undefined ||
2286
+ !autoCompactionDue(await this.#currentVisibleSegments(current))) {
2287
+ return folded;
2288
+ }
2289
+ }
2290
+ }
2291
+ /** The table's visible segments at the current manifest. */
2292
+ async #currentVisibleSegments(table) {
2293
+ // This is an optimistic metadata read, not a user snapshot: taking a durable reader lease
2294
+ // here would add a readwrite transaction to whichever foreground write happened to trigger
2295
+ // maintenance. Verify the manifest did not move while its segment records were loaded; the
2296
+ // current manifest itself cannot be pruned, so a matching version is the same stability proof
2297
+ // without persistent state. Do not chase a busy writer forever: this probe is only a hint,
2298
+ // periodic checks keep arriving during the burst, and the quiet-tail check gets a stable view
2299
+ // once it ends.
2300
+ let segments = [];
2301
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2302
+ const manifest = await this.store.getCurrentManifest();
2303
+ segments = await this.#visibleSegmentRecords(table, new Snapshot(this.store, manifest?.version ?? null, manifest?.blockIds ?? []));
2304
+ if ((await this.store.getCurrentManifestVersion()) === (manifest?.version ?? null)) {
2305
+ return segments;
2306
+ }
2307
+ }
2308
+ return segments;
2309
+ }
2310
+ /**
2311
+ * What every data commit shares: live sets learn of it, and the tables it changed count
2312
+ * toward their next write-path auto-compaction check.
2313
+ */
2314
+ #afterCommit(manifest) {
2315
+ this.#notifyLiveCommit();
2316
+ this.#commitsSinceCollection += 1;
2317
+ const now = this.#now().getTime();
2318
+ if (this.#commitsSinceCollection >= AUTO_COLLECT_COMMIT_INTERVAL ||
2319
+ (this.#lastCollectionAt !== undefined &&
2320
+ now - this.#lastCollectionAt >= AUTO_COLLECT_QUIET_MS)) {
2321
+ this.#maybeScheduleAutoCollection();
2322
+ }
2323
+ this.#armIdleCollection();
2324
+ if (!this.#autoCompact)
2325
+ return;
2326
+ for (const tableId of manifest.changedTableIds ?? []) {
2327
+ this.#idleCompactionTableIds.add(tableId);
2328
+ const commits = (this.#commitsSinceCompactionCheck.get(tableId) ?? 0) + 1;
2329
+ if (commits < AUTO_COMPACT_COMMIT_CHECK_INTERVAL) {
2330
+ this.#commitsSinceCompactionCheck.set(tableId, commits);
2331
+ continue;
2332
+ }
2333
+ this.#commitsSinceCompactionCheck.delete(tableId);
2334
+ void this.#checkAutoCompaction(tableId);
2335
+ }
2336
+ this.#armIdleCompactionCheck();
2337
+ }
2338
+ /**
2339
+ * Debounces the final write-path check for a burst. Sampling every few commits keeps the hot
2340
+ * path cheap, but the last one through seven commits can be the ones that cross a fold
2341
+ * threshold. Without this check an idle table can remain due forever because no later write or
2342
+ * scan arrives to notice it.
2343
+ */
2344
+ #armIdleCompactionCheck() {
2345
+ if (this.#idleCompactionTableIds.size === 0)
2346
+ return;
2347
+ if (this.#idleCompactionTimer !== undefined)
2348
+ clearTimeout(this.#idleCompactionTimer);
2349
+ const timer = setTimeout(() => {
2350
+ this.#idleCompactionTimer = undefined;
2351
+ const tableIds = [...this.#idleCompactionTableIds];
2352
+ this.#idleCompactionTableIds.clear();
2353
+ for (const tableId of tableIds) {
2354
+ this.#commitsSinceCompactionCheck.delete(tableId);
2355
+ void this.#checkAutoCompaction(tableId);
2356
+ }
2357
+ }, AUTO_COMPACT_IDLE_CHECK_MS);
2358
+ timer.unref?.();
2359
+ this.#idleCompactionTimer = timer;
2360
+ }
2361
+ /**
2362
+ * The write-path auto-compaction check: the table's visible segments at the current manifest,
2363
+ * judged by the same thresholds a streamed scan applies. Without it a write-heavy phase with
2364
+ * no reads in between piles deltas up unfolded, and the next query pays for all of them at
2365
+ * once. Background maintenance never surfaces through a write; a failed check waits for the
2366
+ * next one.
2367
+ */
2368
+ async #checkAutoCompaction(tableId) {
2369
+ try {
2370
+ const table = await this.store.getTable(tableId);
2371
+ if (table === undefined)
2372
+ return;
2373
+ this.#maybeScheduleAutoCompaction(table, await this.#currentVisibleSegments(table));
2374
+ }
2375
+ catch {
2376
+ // Deliberately silent: the next commit or scan checks again.
2377
+ }
2378
+ }
1968
2379
  /**
1969
2380
  * Persists one AFTER trigger on its table record (compare-and-swap with retry, like
1970
2381
  * migration). Validation is CREATE-time so firing can trust the record: events bind only
@@ -2429,7 +2840,25 @@ export class MinnowDatabase {
2429
2840
  open();
2430
2841
  return this.#sessionQuery(transaction, sql, options);
2431
2842
  },
2432
- insertBatch: async (tableName, input) => {
2843
+ execute: async (sql, params) => {
2844
+ open();
2845
+ const compiled = compileStatement(sql);
2846
+ if (compiled.kind === "select") {
2847
+ return {
2848
+ kind: "rows",
2849
+ result: await this.#sessionQuery(transaction, compiled.sql, params === undefined ? {} : { params }),
2850
+ };
2851
+ }
2852
+ const statement = bindStatementParameters(compiled, params);
2853
+ if (!isTransactionalStatement(statement)) {
2854
+ throw new TypeError(`${statement.kind.toUpperCase().replace("-", " ")} is not allowed inside a write scope`);
2855
+ }
2856
+ // Guard the whole SQL statement as one stage. Some INSERT forms perform more than one
2857
+ // batch operation; if a later step fails after an earlier one staged work, the caller
2858
+ // must not be able to catch the error and commit only part of the statement.
2859
+ return writer.executeStatement(statement);
2860
+ },
2861
+ insertBatch: async (tableName, input) => {
2433
2862
  open();
2434
2863
  staged += 1;
2435
2864
  return guarded(() => this.#sessionInsert(transaction, tableName, input, "insert"));
@@ -2450,8 +2879,16 @@ export class MinnowDatabase {
2450
2879
  return guarded(() => this.#sessionDelete(transaction, tableName, input));
2451
2880
  },
2452
2881
  };
2882
+ const writer = {
2883
+ ...session,
2884
+ queryPlan: (plan) => this.#sessionQueryPlan(transaction, plan),
2885
+ executeStatement: (statement) => {
2886
+ open();
2887
+ return guarded(() => this.runStatement(statement, { writer }));
2888
+ },
2889
+ };
2453
2890
  try {
2454
- const result = await action(session, transaction);
2891
+ const result = await action(session, transaction, writer);
2455
2892
  closed = true;
2456
2893
  if (poisoned !== undefined) {
2457
2894
  // The outer catch aborts the transaction.
@@ -2464,7 +2901,7 @@ export class MinnowDatabase {
2464
2901
  for (let attempt = 0; attempt <= this.#maxCommitRetries; attempt += 1) {
2465
2902
  try {
2466
2903
  const manifest = await transaction.commit();
2467
- this.#notifyLiveCommit();
2904
+ this.#afterCommit(manifest);
2468
2905
  return { result, version: manifest.version };
2469
2906
  }
2470
2907
  catch (error) {
@@ -2600,7 +3037,7 @@ export class MinnowDatabase {
2600
3037
  }
2601
3038
  async #sessionInsert(transaction, tableName, input, kind, cascadeBudget = 1) {
2602
3039
  const table = await this.#findTable(tableName);
2603
- const { batch, autoIncrement, rowCount } = this.#fillDefaults(table, input);
3040
+ const { batch, generated, autoIncrement, rowCount } = this.#fillDefaults(table, input);
2604
3041
  await this.#assertForeignKeysPresent(table, (column) => batch.columns[column] ?? [], (sql, params) => this.#sessionQuery(transaction, sql, { params }), transaction);
2605
3042
  if (autoIncrement !== undefined && autoIncrement.missingIndexes.length > 0) {
2606
3043
  const values = await this.store.reserveAutoIncrement(table.id, autoIncrement.column.id, autoIncrement.missingIndexes.length, autoIncrement.atLeast);
@@ -2616,7 +3053,6 @@ export class MinnowDatabase {
2616
3053
  tableId: table.id,
2617
3054
  keyTokens: [...keys.keys()],
2618
3055
  requireAbsent: kind === "insert",
2619
- ...(table.uniqueKeyStorage === undefined ? {} : { storageMode: table.uniqueKeyStorage }),
2620
3056
  });
2621
3057
  }
2622
3058
  const rowIds = await this.store.reserveRowIds(table.id, rowCount);
@@ -2646,7 +3082,13 @@ export class MinnowDatabase {
2646
3082
  else if (sessionUpsertFirings !== undefined) {
2647
3083
  await this.#stageUpsertTriggerFirings(transaction, table, batch, sessionUpsertFirings, "after", cascadeBudget);
2648
3084
  }
2649
- return { tableName: table.name, segmentId, rowCount };
3085
+ collectAutoIncrementGenerated(batch, generated, autoIncrement);
3086
+ return {
3087
+ tableName: table.name,
3088
+ segmentId,
3089
+ rowCount,
3090
+ ...(generated.size === 0 ? {} : { generatedColumns: Object.fromEntries(generated) }),
3091
+ };
2650
3092
  }
2651
3093
  async #sessionUpdate(transaction, tableName, input, cascadeBudget = 1) {
2652
3094
  const table = await this.#findTable(tableName);
@@ -2765,7 +3207,6 @@ export class MinnowDatabase {
2765
3207
  keyTokens: [...keys.keys()],
2766
3208
  requireAbsent: false,
2767
3209
  remove: true,
2768
- ...(table.uniqueKeyStorage === undefined ? {} : { storageMode: table.uniqueKeyStorage }),
2769
3210
  });
2770
3211
  // Fire only per existing row (session-visible state included): missing keys must not
2771
3212
  // produce phantom all-null OLD images.
@@ -2815,7 +3256,14 @@ export class MinnowDatabase {
2815
3256
  }
2816
3257
  /** Executes a built ORM query through the same streaming-first pipeline as compiled SQL. */
2817
3258
  async run(query) {
2818
- return (await this.#queryCompiled(query.plan)).rows;
3259
+ const probe = this.store.getCatalogProbe?.bind(this.store);
3260
+ // The same memo a SQL query gets, keyed by the plan: a typed query is compiled once by the
3261
+ // builder and run many times, and it used to re-execute on every run.
3262
+ if (probe === undefined || query.plan.usesStatementDatetime === true) {
3263
+ return (await this.#queryCompiled(query.plan)).rows;
3264
+ }
3265
+ return (await this.#memoizedQuery(query.plan, `typed ${planMemoKey(query.plan)}`, {}, probe))
3266
+ .rows;
2819
3267
  }
2820
3268
  /**
2821
3269
  * Applies a schema definition to the catalog through metadata-only steps: creating missing
@@ -3209,10 +3657,11 @@ export class MinnowDatabase {
3209
3657
  // together or not at all. Schema changes are refused rather than silently auto-committed:
3210
3658
  // the catalog is not part of the scope, so a DDL statement inside one would land even if
3211
3659
  // the transaction rolled back.
3212
- if (!isTransactionalStatement(statement)) {
3213
- throw new TypeError(`${statement.kind.toUpperCase().replace("-", " ")} is not allowed inside a transaction`);
3660
+ const boundStatement = bindStatementParameters(statement, params);
3661
+ if (!isTransactionalStatement(boundStatement)) {
3662
+ throw new TypeError(`${boundStatement.kind.toUpperCase().replace("-", " ")} is not allowed inside a transaction`);
3214
3663
  }
3215
- return this.#duringTransaction(open, () => this.runStatement(bindStatementParameters(statement, params), { writer: open.session }));
3664
+ return this.#duringTransaction(open, () => open.session.executeStatement(boundStatement));
3216
3665
  }
3217
3666
  return this.runStatement(bindStatementParameters(statement, params));
3218
3667
  }
@@ -3367,11 +3816,8 @@ export class MinnowDatabase {
3367
3816
  const decided = new Promise((resolve) => {
3368
3817
  settle = resolve;
3369
3818
  });
3370
- const finished = this.#openWriteScope(async (session, transaction) => {
3371
- start({
3372
- ...session,
3373
- queryPlan: (plan) => this.#sessionQueryPlan(transaction, plan),
3374
- });
3819
+ const finished = this.#openWriteScope(async (_session, _transaction, writer) => {
3820
+ start(writer);
3375
3821
  if ((await decided) === "rollback")
3376
3822
  throw new TransactionRollback();
3377
3823
  return null;
@@ -3455,7 +3901,7 @@ export class MinnowDatabase {
3455
3901
  }
3456
3902
  const assigned = statement.onConflict.columns ?? [];
3457
3903
  const keyToken = (value) => value instanceof Date ? `d${value.toISOString()}` : `${typeof value} ${String(value)}`;
3458
- const { result: returnedRows, version } = await this.write(async (transaction) => {
3904
+ const apply = async (transaction) => {
3459
3905
  const existing = await this.#existingInsertKeys(table, keyColumn, statement, (sql, params) => transaction.query(sql, { params }));
3460
3906
  const freshRows = [];
3461
3907
  const conflictingRows = [];
@@ -3506,12 +3952,22 @@ export class MinnowDatabase {
3506
3952
  const row = rowsByKey.get(keyToken(key)) ?? {};
3507
3953
  return Object.fromEntries(returningColumns.map((name) => [name, row[name] ?? null]));
3508
3954
  });
3509
- });
3955
+ };
3956
+ let returnedRows;
3957
+ let version;
3958
+ if (options.writer === undefined) {
3959
+ const completed = await this.write(apply);
3960
+ returnedRows = completed.result;
3961
+ version = completed.version;
3962
+ }
3963
+ else {
3964
+ returnedRows = await apply(options.writer);
3965
+ }
3510
3966
  return {
3511
3967
  kind: "insert",
3512
3968
  table: statement.table,
3513
3969
  rowCount: statement.rows.length,
3514
- ...(version === null ? {} : { version }),
3970
+ ...(version === null || version === undefined ? {} : { version }),
3515
3971
  ...(returnedRows === undefined ? {} : { returnedRows }),
3516
3972
  };
3517
3973
  }
@@ -3528,7 +3984,7 @@ export class MinnowDatabase {
3528
3984
  return new Set(result.rows.map((row) => keyToken(row.key ?? null)));
3529
3985
  }
3530
3986
  /** ON CONFLICT DO NOTHING: drops insert rows whose unique key already exists at a snapshot. */
3531
- async #filterConflictingInsertRows(statement) {
3987
+ async #filterConflictingInsertRows(statement, writer) {
3532
3988
  const table = await this.#findTable(statement.table);
3533
3989
  const keyColumn = getUniqueKeyColumn(table);
3534
3990
  if (keyColumn === undefined || statement.onConflict?.column !== keyColumn.name) {
@@ -3554,17 +4010,13 @@ export class MinnowDatabase {
3554
4010
  having: [],
3555
4011
  orderBy: [],
3556
4012
  };
3557
- const prepared = await this.#prepareCompiledPlan(plan);
3558
- let existing;
3559
- try {
3560
- existing = new Set(prepared.execute().rows.map((row) => {
3561
- const value = row.key ?? null;
3562
- return value instanceof Date ? value.toISOString() : value;
3563
- }));
3564
- }
3565
- finally {
3566
- prepared.close();
3567
- }
4013
+ // The streaming-first pipeline: a keyed IN list narrows to the blocks that can hold the
4014
+ // keys, where the prepared path materialized the table's columns first.
4015
+ const existingRows = writer === undefined ? await this.#queryCompiled(plan) : await writer.queryPlan(plan);
4016
+ const existing = new Set(existingRows.rows.map((row) => {
4017
+ const value = row.key ?? null;
4018
+ return value instanceof Date ? value.toISOString() : value;
4019
+ }));
3568
4020
  return {
3569
4021
  ...statement,
3570
4022
  rows: statement.rows.filter((row) => {
@@ -3657,16 +4109,22 @@ export class MinnowDatabase {
3657
4109
  ...(version === null ? {} : { version }),
3658
4110
  };
3659
4111
  }
3660
- async #materializeInsertSelect(statement) {
4112
+ async #materializeInsertSelect(statement, writer) {
3661
4113
  if (statement.query === undefined)
3662
4114
  return statement;
3663
- const prepared = await this.#prepareCompiledPlan(statement.query);
3664
4115
  let result;
3665
- try {
3666
- result = prepared.execute();
4116
+ if (writer === undefined) {
4117
+ const prepared = await this.#prepareCompiledPlan(statement.query);
4118
+ try {
4119
+ result = prepared.execute();
4120
+ }
4121
+ finally {
4122
+ prepared.close();
4123
+ }
3667
4124
  }
3668
- finally {
3669
- prepared.close();
4125
+ else {
4126
+ const plan = await this.#applyCatalogRewrites(statement.query);
4127
+ result = await writer.queryPlan(plan);
3670
4128
  }
3671
4129
  const { query, ...rest } = statement;
3672
4130
  void query;
@@ -3789,10 +4247,10 @@ export class MinnowDatabase {
3789
4247
  options = { ...options, returning: statement.returning };
3790
4248
  }
3791
4249
  if (statement.kind === "insert" && statement.query !== undefined) {
3792
- statement = await this.#materializeInsertSelect(statement);
4250
+ statement = await this.#materializeInsertSelect(statement, options.writer);
3793
4251
  }
3794
4252
  if (statement.kind === "insert" && statement.onConflict?.action === "nothing") {
3795
- statement = await this.#filterConflictingInsertRows(statement);
4253
+ statement = await this.#filterConflictingInsertRows(statement, options.writer);
3796
4254
  }
3797
4255
  if (statement.kind === "insert" && statement.onConflict?.action === "update") {
3798
4256
  return this.#mergeConflictingInsertRows(statement, options);
@@ -3907,13 +4365,10 @@ export class MinnowDatabase {
3907
4365
  rows = (await options.writer.queryPlan(plan)).rows;
3908
4366
  }
3909
4367
  else {
3910
- const prepared = await this.#prepareCompiledPlan(plan);
3911
- try {
3912
- rows = prepared.execute().rows;
3913
- }
3914
- finally {
3915
- prepared.close();
3916
- }
4368
+ // The same streaming-first pipeline a SELECT takes: its zone pruning and ascending-range
4369
+ // narrowing find the rows to touch, where the prepared path materialized the table's
4370
+ // columns first — most of a bulk delete's cost, at 200k rows.
4371
+ rows = (await this.#queryCompiled(plan)).rows;
3917
4372
  }
3918
4373
  const keys = rows.map((row) => row[keyColumn.name]);
3919
4374
  if (keys.some((key) => key === null || key === undefined)) {
@@ -4011,13 +4466,13 @@ export class MinnowDatabase {
4011
4466
  * Returns undefined when the base table's visible shape is ineligible (keyed mutation replay),
4012
4467
  * so the caller falls back to the materialized path.
4013
4468
  */
4014
- async #queryStreamed(plan, options, spillPageRows) {
4469
+ async #queryStreamed(plan, options, spillPageRows, probe) {
4015
4470
  const tableNames = [plan.base.table, ...plan.joins.map((join) => join.table)];
4016
4471
  const uniqueTableNames = [...new Set(tableNames)];
4017
4472
  if (options.version === undefined) {
4018
4473
  // The common path shares the probe-gated catalog state and the shared reader lease
4019
4474
  // with every other statement at the current version.
4020
- return this.#withSharedCatalogSnapshot(uniqueTableNames, (snapshot, realTables, visibility) => this.#queryStreamedAtSnapshot(plan, options, spillPageRows, snapshot, [...realTables.values()], visibility));
4475
+ return this.#withSharedCatalogSnapshot(uniqueTableNames, (snapshot, realTables, visibility) => this.#queryStreamedAtSnapshot(plan, options, spillPageRows, snapshot, [...realTables.values()], visibility), probe);
4021
4476
  }
4022
4477
  // Explicit time travel keeps the per-call lease and version-anchored reads.
4023
4478
  const tables = await Promise.all(uniqueTableNames.map((name) => this.#findTable(name)));
@@ -4620,19 +5075,6 @@ export class MinnowDatabase {
4620
5075
  load,
4621
5076
  };
4622
5077
  }
4623
- /**
4624
- * Builds a streamed view of a keyed table whose visible history contains update and delete
4625
- * segments (no upserts — those interleave new rows into slot order and keep the materialized
4626
- * path). Mutation deltas are the small part of such a history, so they replay into resident
4627
- * state — a dead-row bitmap over the base rows plus per-slot column patches referencing the
4628
- * resident update vectors — while the base rows stream through the existing block-aligned
4629
- * inner window. The outer view compacts dead rows and overlays patches per window, producing
4630
- * exactly the materialized replay's rows in exactly its order.
4631
- *
4632
- * The replay tracks only mutation-touched key tokens, so its memory is bounded by the
4633
- * mutation size, not the table; the duplicate-key corruption guard consequently only fires
4634
- * for touched keys on this path.
4635
- */
4636
5078
  /**
4637
5079
  * Block header/metadata descriptions, cached and fetched in one round trip: no decompress and
4638
5080
  * no payload validation. Descriptions are immutable per block id, so a repeated query pays
@@ -4664,24 +5106,232 @@ export class MinnowDatabase {
4664
5106
  }
4665
5107
  return descriptions;
4666
5108
  }
5109
+ /**
5110
+ * Builds a streamed view of a keyed table whose visible history contains update and delete
5111
+ * segments (no upserts — those interleave new rows into slot order and keep the materialized
5112
+ * path). Mutation deltas are the small part of such a history, so they replay into resident
5113
+ * state — a dead-row bitmap over the base rows plus per-slot column patches referencing the
5114
+ * resident update vectors — while the base rows stream through the block-aligned inner
5115
+ * window. The outer view compacts dead rows and overlays patches per window, producing
5116
+ * exactly the materialized replay's rows in exactly its order.
5117
+ *
5118
+ * The replay is a pure function of the visible segment set, so it is built once per commit
5119
+ * and shared by every query until the next one (`#streamedOverlayState`). The outer loader
5120
+ * serves whole inner windows: one whose rows nothing touched is installed by reference, the
5121
+ * difference between a copy per window and none; one with a dead or patched row is compacted
5122
+ * once, in runs rather than cells.
5123
+ *
5124
+ * The replay tracks only mutation-touched keys, so its memory is bounded by the mutation
5125
+ * size, not the table; the duplicate-key corruption guard consequently only fires for
5126
+ * touched keys on this path.
5127
+ */
4667
5128
  async #createStreamedMutationTable(table, keyColumn, projectedColumns, baseSegments, snapshot, memory, zonePruned = false, storedBlocks) {
4668
5129
  const scanSegments = baseSegments.filter((segment) => {
4669
5130
  const kind = segment.kind ?? "insert";
4670
5131
  return kind === "insert" || kind === "base";
4671
5132
  });
4672
- // Phase A: materialize each mutation segment's key vector (and an update's changed
4673
- // projected columns) resident and reserved, bounded by the mutation history's size.
5133
+ const overlay = await this.#streamedOverlayState(table, keyColumn, baseSegments, scanSegments, snapshot, memory, zonePruned);
5134
+ const { baseRows, dead, deadCount, patches, patchedSlots } = overlay;
5135
+ const hasPatches = patchedSlots.length > 0;
5136
+ const outputRows = baseRows - deadCount;
5137
+ const inner = this.#createStreamedTable(table, projectedColumns, scanSegments, snapshot, baseRows, memory, storedBlocks);
5138
+ // Deltas that touch no row this scan reads — every one of them eliminated with its row
5139
+ // group, or aimed at keys this table no longer holds — leave the scan exactly as it was.
5140
+ if (deadCount === 0 && !hasPatches)
5141
+ return inner;
5142
+ const states = projectedColumns.map((column) => ({
5143
+ column,
5144
+ vector: createStreamedColumnVector(column.type, outputRows),
5145
+ reservations: [],
5146
+ }));
5147
+ // Forward-only cursor: cursorOutput live rows exist strictly before base row cursorBase.
5148
+ let cursorOutput = 0;
5149
+ let cursorBase = 0;
5150
+ const load = async (start, length) => {
5151
+ const end = Math.min(start + length, outputRows);
5152
+ // COUNT(*) and friends project nothing: the replay already knows how many rows survive,
5153
+ // so there is no window to build and no reason to walk the base rows to build it.
5154
+ if (states.length === 0)
5155
+ return end;
5156
+ const window = states[0]?.vector.window;
5157
+ if (window !== undefined && start >= window.start && start < window.start + window.length) {
5158
+ return window.start + window.length;
5159
+ }
5160
+ if (window !== undefined && start < window.start) {
5161
+ throw new Error(`Streamed scan moved backward: ${table.name}`);
5162
+ }
5163
+ // The cursor stops at the end of the window it last served, which can sit past a start
5164
+ // that falls before it. Rewinding costs one pass over the dead-row bitmap.
5165
+ if (cursorOutput > start) {
5166
+ cursorOutput = 0;
5167
+ cursorBase = 0;
5168
+ }
5169
+ while (cursorOutput < start && cursorBase < baseRows) {
5170
+ if (!bitmapHasValue(dead, cursorBase))
5171
+ cursorOutput += 1;
5172
+ cursorBase += 1;
5173
+ }
5174
+ // Skip the dead rows in front of the first live one, so a window never starts dead.
5175
+ while (cursorBase < baseRows && bitmapHasValue(dead, cursorBase))
5176
+ cursorBase += 1;
5177
+ if (cursorOutput !== start || cursorBase >= baseRows) {
5178
+ throw new Error(`Column row count mismatch: ${table.name}`);
5179
+ }
5180
+ const baseStart = cursorBase;
5181
+ // The inner loader serves whole blocks; the outer window covers the suffix of the inner
5182
+ // window from baseStart, however long, and the caller clamps to what it asked for.
5183
+ const innerEnd = await inner.load(baseStart, baseRows - baseStart);
5184
+ const baseEnd = typeof innerEnd === "number" ? Math.min(innerEnd, baseRows) : baseRows;
5185
+ if (baseEnd <= baseStart)
5186
+ throw new Error(`Column row count mismatch: ${table.name}`);
5187
+ const deadInWindow = bitmapCountRange(dead, baseStart, baseEnd);
5188
+ const patchedInWindow = hasPatches ? sortedCountRange(patchedSlots, baseStart, baseEnd) : 0;
5189
+ const liveRows = baseEnd - baseStart - deadInWindow;
5190
+ const untouched = deadInWindow === 0 && patchedInWindow === 0;
5191
+ const runs = untouched
5192
+ ? undefined
5193
+ : overlayWindowRuns(dead, patchedSlots, baseStart, baseEnd, patchedInWindow);
5194
+ const targets = [];
5195
+ try {
5196
+ for (const state of states) {
5197
+ const innerVector = inner.table.columns.get(state.column.name);
5198
+ const innerWindow = innerVector?.window;
5199
+ if (innerVector === undefined || innerWindow === undefined) {
5200
+ throw new Error(`Streamed column is missing: ${state.column.name}`);
5201
+ }
5202
+ const offset = baseStart - innerWindow.start;
5203
+ if (offset < 0 || offset + (baseEnd - baseStart) > innerWindow.length) {
5204
+ throw new Error(`Column row count mismatch: ${state.column.name}`);
5205
+ }
5206
+ const replacements = [];
5207
+ const fields = runs === undefined
5208
+ ? overlayWindowView(innerVector, offset, liveRows, memory, state.column, replacements)
5209
+ : overlayWindowCompacted(innerVector, innerWindow.start, runs, liveRows, hasPatches ? patches : undefined, state.column, memory, replacements);
5210
+ fields.window = { start, length: liveRows };
5211
+ targets.push({ state, fields, replacements });
5212
+ }
5213
+ // Every fallible byte is reserved above; the installs below cannot throw, so a budget
5214
+ // overflow leaves every state's previous window and reservations intact.
5215
+ for (const { state, fields, replacements } of targets) {
5216
+ const mutable = state.vector;
5217
+ mutable.validity = fields.validity;
5218
+ if (fields.values !== undefined)
5219
+ mutable.values = fields.values;
5220
+ if (fields.codes !== undefined) {
5221
+ mutable.codes = fields.codes;
5222
+ mutable.dictionary = fields.dictionary ?? [];
5223
+ }
5224
+ mutable.window = fields.window;
5225
+ for (const previous of state.reservations)
5226
+ previous.release();
5227
+ state.reservations = replacements;
5228
+ }
5229
+ }
5230
+ catch (error) {
5231
+ for (const entry of targets) {
5232
+ for (const replacement of entry.replacements)
5233
+ replacement.release();
5234
+ }
5235
+ throw error;
5236
+ }
5237
+ cursorOutput = start + liveRows;
5238
+ cursorBase = baseEnd;
5239
+ return start + liveRows;
5240
+ };
5241
+ return {
5242
+ table: {
5243
+ name: table.name,
5244
+ rowCount: outputRows,
5245
+ columns: new Map(states.map((state) => [state.column.name, state.vector])),
5246
+ },
5247
+ load,
5248
+ };
5249
+ }
5250
+ /**
5251
+ * The replayed mutation state for one visible segment set: which base rows are dead, and
5252
+ * which columns of which slots an update replaced. Cached under the segment ids in the
5253
+ * artifact LRU, because nothing about it changes between commits — before this, every query
5254
+ * over a table with so much as one deleted row rebuilt it, which made COUNT(*) on such a
5255
+ * table cost twenty times what it costs on a clean one. A cache hit is charged to the
5256
+ * query's memory as a tally, the same bytes a build reserves.
5257
+ */
5258
+ async #streamedOverlayState(table, keyColumn, baseSegments, scanSegments, snapshot, memory, zonePruned) {
5259
+ // A zone-pruned scan keeps a segment's id with a subset of its blocks, and the slots the
5260
+ // replay addresses are the key blocks' rows in order — so the key blocks, not the segment
5261
+ // ids alone, are what identify the state.
5262
+ const key = [
5263
+ "overlay",
5264
+ table.id,
5265
+ zonePruned ? "pruned" : "full",
5266
+ baseSegments
5267
+ .map((segment) => mutationSegmentKind(segment)
5268
+ ? segment.id
5269
+ : `${segment.id}:${(segment.columnBlockIds[keyColumn.id] ?? []).join("+")}`)
5270
+ .join(","),
5271
+ ].join(" ");
5272
+ const cached = this.#cacheGet(key);
5273
+ if (cached !== undefined) {
5274
+ memory.tally(cached.bytes, "Streamed mutation replay");
5275
+ return cached;
5276
+ }
5277
+ const state = await this.#buildStreamedOverlayState(table, keyColumn, baseSegments, scanSegments, snapshot, memory, zonePruned);
5278
+ this.#cachePut(key, state, state.bytes);
5279
+ return state;
5280
+ }
5281
+ async #buildStreamedOverlayState(table, keyColumn, baseSegments, scanSegments, snapshot, memory, zonePruned) {
5282
+ // Phase A: each mutation segment's key vector, and every column an update changed —
5283
+ // resident and reserved, bounded by the mutation history's size. All of an update's
5284
+ // columns, not only the ones this query projects: the state outlives the query. The
5285
+ // history's blocks come out of the buffer pool in one round trip, as block vectors: one
5286
+ // await per segment is what made a table with a few hundred deltas pay ten milliseconds
5287
+ // to rebuild this state after every commit.
5288
+ const deltaSegments = baseSegments.filter(mutationSegmentKind);
5289
+ const deltaBlockIds = new Set();
5290
+ for (const segment of deltaSegments) {
5291
+ for (const column of table.columns) {
5292
+ for (const blockId of segment.columnBlockIds[column.id] ?? [])
5293
+ deltaBlockIds.add(blockId);
5294
+ }
5295
+ }
5296
+ const decodedDeltaBlocks = new Map();
5297
+ if (deltaBlockIds.size > 0) {
5298
+ const ids = [...deltaBlockIds];
5299
+ const decoded = await this.#decodedBlocksThroughCache(ids, snapshot);
5300
+ ids.forEach((id, index) => {
5301
+ const block = decoded[index];
5302
+ if (block !== undefined)
5303
+ decodedDeltaBlocks.set(id, block);
5304
+ });
5305
+ }
5306
+ const deltaVector = async (column, segment) => {
5307
+ const blockIds = segment.columnBlockIds[column.id] ?? [];
5308
+ const blockId = blockIds[0];
5309
+ if (blockIds.length !== 1 || blockId === undefined) {
5310
+ // A delta written in more than one block — a bulk update past rowsPerBlock — concatenates.
5311
+ return this.#materializeAppendColumnVector(column, [segment], snapshot, segment.rowCount);
5312
+ }
5313
+ const decoded = decodedDeltaBlocks.get(blockId);
5314
+ if (decoded === undefined)
5315
+ throw new Error(`Visible block is missing: ${blockId}`);
5316
+ if (decoded.column.type !== column.type) {
5317
+ throw new Error(`Column type mismatch: ${column.name}`);
5318
+ }
5319
+ const vector = this.#blockColumnVector(blockId, decoded);
5320
+ if (vector.length !== segment.rowCount) {
5321
+ throw new Error(`Column row count mismatch: ${column.name}`);
5322
+ }
5323
+ return vector;
5324
+ };
4674
5325
  const mutationKeyVectors = new Map();
4675
5326
  const mutationChangedVectors = new Map();
4676
5327
  // Keys as the primitives the vectors already hold. The replay used to build one string
4677
5328
  // token per row on both sides, which made a single deleted row cost an allocation and a
4678
5329
  // hash of every key in the table.
4679
5330
  const touched = new Set();
4680
- for (const segment of baseSegments) {
5331
+ let retainedBytes = 0;
5332
+ for (const segment of deltaSegments) {
4681
5333
  const kind = segment.kind ?? "insert";
4682
- if (kind !== "update" && kind !== "delete")
4683
- continue;
4684
- const keyVector = await this.#materializeAppendColumnVector(keyColumn, [segment], snapshot, segment.rowCount);
5334
+ const keyVector = await deltaVector(keyColumn, segment);
4685
5335
  memory.reserve(columnVectorRetainedBytes(keyVector), "Streamed mutation replay");
4686
5336
  mutationKeyVectors.set(segment.id, keyVector);
4687
5337
  const readMutationKey = requiredColumnVectorKeyReader(keyVector);
@@ -4690,20 +5340,22 @@ export class MinnowDatabase {
4690
5340
  }
4691
5341
  if (kind === "update") {
4692
5342
  const changed = new Map();
4693
- for (const column of projectedColumns) {
5343
+ for (const column of table.columns) {
4694
5344
  if (column.id === keyColumn.id)
4695
5345
  continue;
4696
5346
  if ((segment.columnBlockIds[column.id]?.length ?? 0) === 0)
4697
5347
  continue;
4698
- const vector = await this.#materializeAppendColumnVector(column, [segment], snapshot, segment.rowCount);
4699
- memory.reserve(columnVectorRetainedBytes(vector), "Streamed mutation replay");
5348
+ const vector = await deltaVector(column, segment);
5349
+ const bytes = columnVectorRetainedBytes(vector);
5350
+ memory.reserve(bytes, "Streamed mutation replay");
5351
+ retainedBytes += bytes;
4700
5352
  changed.set(column.id, vector);
4701
5353
  }
4702
5354
  mutationChangedVectors.set(segment.id, changed);
4703
5355
  }
4704
5356
  }
4705
5357
  // Phase B: one bounded pass over the scan segments' key blocks — a single block resident
4706
- // at a time — recording, per scan segment, the touched tokens and their absolute slots.
5358
+ // at a time — recording, per scan segment, the touched keys and their absolute slots.
4707
5359
  const touchedByScanSegment = new Map();
4708
5360
  // A mutation history is small, and a unique key is usually written in order, so most key
4709
5361
  // blocks cannot hold any touched key at all. Their zone maps say so from the header alone,
@@ -4734,26 +5386,10 @@ export class MinnowDatabase {
4734
5386
  if (decoded.column.type !== keyColumn.type) {
4735
5387
  throw new Error(`Column type mismatch: ${keyColumn.name}`);
4736
5388
  }
4737
- const rows = decoded.column.rowCount;
4738
- const validity = new Uint8Array(Math.ceil(rows / 8));
4739
- const values = keyColumn.type === "boolean"
4740
- ? new Uint8Array(rows)
4741
- : keyColumn.type === "string"
4742
- ? undefined
4743
- : new Float64Array(rows);
4744
- const codes = keyColumn.type === "string" ? new Uint32Array(rows) : undefined;
4745
- codes?.fill(NULL_STRING_VECTOR_CODE);
4746
- const builder = new StringDictionaryBuilder();
4747
- appendPhysicalColumnToVector(decoded.column, 0, validity, values, codes, builder);
4748
- const blockVector = (codes !== undefined
4749
- ? {
4750
- kind: "string",
4751
- length: rows,
4752
- validity,
4753
- codes,
4754
- dictionary: builder.dictionary,
4755
- }
4756
- : { kind: keyColumn.type, length: rows, validity, values });
5389
+ // The block's vector form is what a scan of this table reads anyway, so it comes from
5390
+ // (and stays in) the buffer pool rather than being rebuilt for the replay.
5391
+ const blockVector = this.#blockColumnVector(blockId, decoded);
5392
+ const rows = blockVector.length;
4757
5393
  const readBlockKey = requiredColumnVectorKeyReader(blockVector);
4758
5394
  for (let row = 0; row < rows; row += 1) {
4759
5395
  const key = readBlockKey(row);
@@ -4825,195 +5461,22 @@ export class MinnowDatabase {
4825
5461
  slotPatches.set(columnId, { vector, row });
4826
5462
  }
4827
5463
  }
4828
- memory.tally(patches.size * 96 + slotByKey.size * 64, "Streamed mutation replay");
4829
- const hasPatches = patches.size > 0;
4830
- const outputRows = baseRows - deadCount;
4831
- const inner = this.#createStreamedTable(table, projectedColumns, scanSegments, snapshot, baseRows, memory, storedBlocks);
4832
- // Deltas that touch no row this scan reads — every one of them eliminated with its row
4833
- // group, or aimed at keys this table no longer holds — leave the scan exactly as it was.
4834
- // The plain streamed scan hands decoded blocks straight through, so skipping the overlay
4835
- // here is the difference between a copy per window and none.
4836
- if (deadCount === 0 && !hasPatches)
4837
- return inner;
4838
- const states = projectedColumns.map((column) => ({
4839
- column,
4840
- vector: createStreamedColumnVector(column.type, outputRows),
4841
- reservations: [],
4842
- }));
4843
- // Forward-only cursor: cursorOutput live rows exist strictly before base row cursorBase.
4844
- let cursorOutput = 0;
4845
- let cursorBase = 0;
4846
- const load = async (start, length) => {
4847
- const end = Math.min(start + length, outputRows);
4848
- // COUNT(*) and friends project nothing: the replay already knows how many rows survive,
4849
- // so there is no window to build and no reason to walk the base rows to build it.
4850
- if (states.length === 0)
4851
- return end;
4852
- const window = states[0]?.vector.window;
4853
- if (window !== undefined && start >= window.start && end <= window.start + window.length) {
4854
- return window.start + window.length;
4855
- }
4856
- if (window !== undefined && start < window.start) {
4857
- throw new Error(`Streamed scan moved backward: ${table.name}`);
4858
- }
4859
- // The cursor stops at the end of the window it last built, which can sit past a start
4860
- // that falls inside that window. Rewinding costs one pass over the dead-row bitmap and
4861
- // keeps the window aligned with what was asked for, rather than with where the cursor
4862
- // happened to stop.
4863
- if (cursorOutput > start) {
4864
- cursorOutput = 0;
4865
- cursorBase = 0;
4866
- }
4867
- while (cursorOutput < start && cursorBase < baseRows) {
4868
- if (!bitmapHasValue(dead, cursorBase))
4869
- cursorOutput += 1;
4870
- cursorBase += 1;
4871
- }
4872
- let scanBase = cursorBase;
4873
- let scanOutput = cursorOutput;
4874
- const liveBaseRows = [];
4875
- while (scanOutput < end && scanBase < baseRows) {
4876
- if (!bitmapHasValue(dead, scanBase)) {
4877
- liveBaseRows.push(scanBase);
4878
- scanOutput += 1;
4879
- }
4880
- scanBase += 1;
4881
- }
4882
- if (scanOutput < end)
4883
- throw new Error(`Column row count mismatch: ${table.name}`);
4884
- const windowRows = end - start;
4885
- const targets = [];
4886
- try {
4887
- for (const state of states) {
4888
- const innerVector = inner.table.columns.get(state.column.name);
4889
- if (innerVector === undefined) {
4890
- throw new Error(`Streamed column is missing: ${state.column.name}`);
4891
- }
4892
- const validityBytes = Math.ceil(windowRows / 8);
4893
- const typedBytes = validityBytes +
4894
- (state.column.type === "boolean"
4895
- ? windowRows
4896
- : state.column.type === "string"
4897
- ? windowRows * Uint32Array.BYTES_PER_ELEMENT
4898
- : windowRows * Float64Array.BYTES_PER_ELEMENT);
4899
- const replacements = [];
4900
- replacements.push(memory.reserve(typedBytes, `Streamed window ${state.column.name}`));
4901
- const validity = new Uint8Array(validityBytes);
4902
- const values = state.column.type === "boolean"
4903
- ? new Uint8Array(windowRows)
4904
- : state.column.type === "string"
4905
- ? undefined
4906
- : new Float64Array(windowRows);
4907
- const codes = state.column.type === "string" ? new Uint32Array(windowRows) : undefined;
4908
- codes?.fill(NULL_STRING_VECTOR_CODE);
4909
- const dictionary = [];
4910
- const target = (codes !== undefined
4911
- ? { kind: "string", length: windowRows, validity, codes, dictionary }
4912
- : { kind: state.column.type, length: windowRows, validity, values });
4913
- targets.push({
4914
- state,
4915
- innerVector,
4916
- validity,
4917
- ...(values === undefined ? {} : { values }),
4918
- ...(codes === undefined ? {} : { codes }),
4919
- dictionary,
4920
- dictionaryIndex: new Map(),
4921
- replacements,
4922
- target,
4923
- });
4924
- }
4925
- // The inner loader serves whole blocks, so the copy walks live rows one inner window
4926
- // at a time: patched slots read the resident mutation vectors, everything else reads
4927
- // the inner window at its own offset.
4928
- let index = 0;
4929
- while (index < liveBaseRows.length) {
4930
- const chunkStart = liveBaseRows[index] ?? 0;
4931
- const innerEnd = await inner.load(chunkStart, scanBase - chunkStart);
4932
- const usable = typeof innerEnd === "number" ? Math.min(innerEnd, scanBase) : scanBase;
4933
- if (usable <= chunkStart) {
4934
- throw new Error(`Column row count mismatch: ${table.name}`);
4935
- }
4936
- let chunkEndIndex = index;
4937
- while (chunkEndIndex < liveBaseRows.length &&
4938
- (liveBaseRows[chunkEndIndex] ?? 0) < usable) {
4939
- chunkEndIndex += 1;
4940
- }
4941
- for (const entry of targets) {
4942
- const innerWindow = entry.innerVector.window;
4943
- if (innerWindow === undefined) {
4944
- throw new Error(`Streamed column is missing: ${entry.state.column.name}`);
4945
- }
4946
- // Live rows are consecutive except where a delete cut them, so the copy walks runs:
4947
- // one typed-array slice each, with patched slots taken out individually. Copying
4948
- // cell by cell here is what made a table with one deleted row scan like a replay.
4949
- const columnId = entry.state.column.id;
4950
- const remap = entry.innerVector.kind === "string"
4951
- ? remapDictionary(entry.innerVector.dictionary, entry.dictionary, entry.dictionaryIndex)
4952
- : undefined;
4953
- let live = index;
4954
- while (live < chunkEndIndex) {
4955
- const baseRow = liveBaseRows[live] ?? 0;
4956
- const patch = hasPatches ? patches.get(baseRow)?.get(columnId) : undefined;
4957
- if (patch !== undefined) {
4958
- copyColumnVectorValue(patch.vector, patch.row, entry.target, live, entry.dictionaryIndex);
4959
- live += 1;
4960
- continue;
4961
- }
4962
- let runEnd = live + 1;
4963
- while (runEnd < chunkEndIndex &&
4964
- (liveBaseRows[runEnd] ?? 0) === baseRow + (runEnd - live) &&
4965
- (!hasPatches || patches.get(liveBaseRows[runEnd] ?? 0)?.get(columnId) === undefined)) {
4966
- runEnd += 1;
4967
- }
4968
- copyVectorSpan(entry.innerVector, baseRow - innerWindow.start, runEnd - live, entry.target, live, remap);
4969
- live = runEnd;
4970
- }
4971
- }
4972
- index = chunkEndIndex;
4973
- }
4974
- // Reserve every fallible byte first; the installs below cannot throw, so a budget
4975
- // overflow here leaves every state's previous window and reservations intact.
4976
- for (const entry of targets) {
4977
- let dictionaryBytes = 0;
4978
- for (const value of entry.dictionary)
4979
- dictionaryBytes += value.length;
4980
- if (dictionaryBytes > 0) {
4981
- entry.replacements.push(memory.reserve(dictionaryBytes, `Streamed window ${entry.state.column.name}`));
4982
- }
4983
- }
4984
- for (const entry of targets) {
4985
- const mutable = entry.state.vector;
4986
- mutable.validity = entry.validity;
4987
- if (entry.values !== undefined)
4988
- mutable.values = entry.values;
4989
- if (entry.codes !== undefined) {
4990
- mutable.codes = entry.codes;
4991
- mutable.dictionary = entry.dictionary;
4992
- }
4993
- mutable.window = { start, length: windowRows };
4994
- for (const previous of entry.state.reservations)
4995
- previous.release();
4996
- entry.state.reservations = entry.replacements;
4997
- }
4998
- }
4999
- catch (error) {
5000
- for (const entry of targets) {
5001
- for (const replacement of entry.replacements)
5002
- replacement.release();
5003
- }
5004
- throw error;
5005
- }
5006
- cursorOutput = end;
5007
- cursorBase = scanBase;
5008
- return end;
5009
- };
5464
+ let patchCells = 0;
5465
+ for (const slotPatches of patches.values())
5466
+ patchCells += slotPatches.size;
5467
+ memory.tally(patches.size * 96 + patchCells * 48, "Streamed mutation replay");
5468
+ const patchedSlots = Uint32Array.from(patches.keys()).sort();
5010
5469
  return {
5011
- table: {
5012
- name: table.name,
5013
- rowCount: outputRows,
5014
- columns: new Map(states.map((state) => [state.column.name, state.vector])),
5015
- },
5016
- load,
5470
+ baseRows,
5471
+ deadCount,
5472
+ dead,
5473
+ patches,
5474
+ patchedSlots,
5475
+ bytes: dead.byteLength +
5476
+ retainedBytes +
5477
+ patchedSlots.byteLength +
5478
+ patches.size * 96 +
5479
+ patchCells * 48,
5017
5480
  };
5018
5481
  }
5019
5482
  /** Prepares one block's inputs and executes it, returning the caller-owned result. */
@@ -5174,15 +5637,17 @@ export class MinnowDatabase {
5174
5637
  /** Plans or advances one restart-safe physical compaction job. */
5175
5638
  async compactTableStep(tableName, options = {}) {
5176
5639
  const table = await this.#findTable(tableName);
5177
- const active = (await this.store.listCompactionJobs(table.id)).find((job) => isActiveCompactionState(job.state));
5178
- let job = active;
5179
- if (job === undefined) {
5180
- const planned = await this.#planCompaction(table, options);
5181
- if ("compacted" in planned)
5182
- return compactionSkippedProgress(planned);
5183
- job = planned;
5184
- }
5185
- return this.#runCompactionJob(table, job, positiveWholeNumber(options.maxBlocks ?? options.maxBlocksPerStep ?? 1, "Compaction step block limit"));
5640
+ return this.#serializedCompactionStep(table.id, async () => {
5641
+ const active = (await this.store.listCompactionJobs(table.id)).find((job) => isActiveCompactionState(job.state));
5642
+ let job = active;
5643
+ if (job === undefined) {
5644
+ const planned = await this.#planCompaction(table, options);
5645
+ if ("compacted" in planned)
5646
+ return compactionSkippedProgress(planned);
5647
+ job = planned;
5648
+ }
5649
+ return this.#runCompactionJob(table, job, positiveWholeNumber(options.maxBlocks ?? options.maxBlocksPerStep ?? 1, "Compaction step block limit"));
5650
+ });
5186
5651
  }
5187
5652
  /** Continues a persisted compaction job after a cooperative yield or restart. */
5188
5653
  async resumeCompactionJob(jobId, options = {}) {
@@ -5192,7 +5657,26 @@ export class MinnowDatabase {
5192
5657
  const table = await this.store.getTable(job.tableId);
5193
5658
  if (table === undefined)
5194
5659
  throw new Error(`Compaction table not found: ${job.tableId}`);
5195
- return this.#runCompactionJob(table, job, positiveWholeNumber(options.maxBlocks ?? 1, "Compaction step block limit"));
5660
+ return this.#serializedCompactionStep(table.id, () => this.#runCompactionJob(table, job, positiveWholeNumber(options.maxBlocks ?? 1, "Compaction step block limit")));
5661
+ }
5662
+ /**
5663
+ * One compaction step at a time per table within this database: background compaction
5664
+ * drives a job in steps, and a caller stepping the same table explicitly must take turns with
5665
+ * it rather than advance the same job concurrently, which would write its output blocks
5666
+ * twice. Each step loads the job record fresh, so alternating drivers simply continue where
5667
+ * the other left off. Between instances and tabs the job's revision is the guard.
5668
+ */
5669
+ async #serializedCompactionStep(tableId, step) {
5670
+ const previous = this.#compactionSteps.get(tableId) ?? Promise.resolve();
5671
+ const run = previous.then(step, step);
5672
+ this.#compactionSteps.set(tableId, run);
5673
+ try {
5674
+ return await run;
5675
+ }
5676
+ finally {
5677
+ if (this.#compactionSteps.get(tableId) === run)
5678
+ this.#compactionSteps.delete(tableId);
5679
+ }
5196
5680
  }
5197
5681
  async listCompactionJobs(tableName) {
5198
5682
  if (tableName === undefined)
@@ -5219,6 +5703,13 @@ export class MinnowDatabase {
5219
5703
  }
5220
5704
  }
5221
5705
  }
5706
+ async #cancelTableCompactions(tableId) {
5707
+ for (const job of await this.store.listCompactionJobs(tableId)) {
5708
+ if (job.state === "planned" || job.state === "running" || job.state === "ready") {
5709
+ await this.cancelCompactionJob(job.id);
5710
+ }
5711
+ }
5712
+ }
5222
5713
  /** Runs restart-safe lease-aware reclamation to completion in bounded durable steps. */
5223
5714
  async collectGarbage(options = {}) {
5224
5715
  const maxItems = positiveWholeNumber(options.maxItemsPerStep ?? 64, "Garbage collection items per step");
@@ -5227,38 +5718,201 @@ export class MinnowDatabase {
5227
5718
  ...(options.maxPlanningItems === undefined
5228
5719
  ? {}
5229
5720
  : { maxPlanningItems: options.maxPlanningItems }),
5721
+ ...(options.retainRecentVersions === undefined
5722
+ ? {}
5723
+ : { retainRecentVersions: options.retainRecentVersions }),
5230
5724
  });
5231
5725
  while (progress.result === null) {
5232
5726
  progress = await this.resumeGarbageCollectionJob(progress.jobId, { maxItems });
5233
5727
  }
5728
+ await this.#pruneFinishedJobRecords();
5234
5729
  return progress.result;
5235
5730
  }
5236
5731
  /** Plans or advances one durable garbage-collection pass. */
5237
5732
  async collectGarbageStep(options = {}) {
5238
- const active = (await this.store.listGarbageCollectionJobs()).find((job) => job.state === "planned" || job.state === "running");
5239
- const job = active ??
5240
- (await this.#planGarbageCollection(positiveWholeNumber(options.maxPlanningItems ?? 1_024, "Garbage collection planning limit")));
5241
- return this.#runGarbageCollectionJob(job, positiveWholeNumber(options.maxItems ?? 1, "Garbage collection item limit"));
5733
+ return this.#collectGarbageStep(options);
5734
+ }
5735
+ /** `collectGarbageStep`, with the age bound background collection adds to its retention. */
5736
+ async #collectGarbageStep(options, retainedVersionMaxAgeMs = Number.POSITIVE_INFINITY) {
5737
+ return this.#serializedCollectionStep(async () => {
5738
+ const active = (await this.store.listGarbageCollectionJobs()).find((job) => job.state === "planned" || job.state === "running");
5739
+ const job = active ??
5740
+ (await this.#planGarbageCollection(positiveWholeNumber(options.maxPlanningItems ?? 1_024, "Garbage collection planning limit"), nonNegativeWholeNumber(options.retainRecentVersions ?? 0, "Garbage collection retained versions"), retainedVersionMaxAgeMs));
5741
+ return this.#runGarbageCollectionJob(job, positiveWholeNumber(options.maxItems ?? 1, "Garbage collection item limit"));
5742
+ });
5242
5743
  }
5243
5744
  /** Continues a persisted reclamation pass after a cooperative yield or restart. */
5244
5745
  async resumeGarbageCollectionJob(jobId, options = {}) {
5245
5746
  const job = await this.store.getGarbageCollectionJob(jobId);
5246
5747
  if (job === undefined)
5247
5748
  throw new Error(`Garbage collection job not found: ${jobId}`);
5248
- return this.#runGarbageCollectionJob(job, positiveWholeNumber(options.maxItems ?? 1, "Garbage collection item limit"));
5749
+ return this.#serializedCollectionStep(() => this.#runGarbageCollectionJob(job, positiveWholeNumber(options.maxItems ?? 1, "Garbage collection item limit")));
5249
5750
  }
5250
- async listGarbageCollectionJobs() {
5251
- return this.store.listGarbageCollectionJobs();
5252
- }
5253
- async #planGarbageCollection(maxPlanningItems) {
5254
- const current = await this.store.getCurrentManifest();
5751
+ /**
5752
+ * One garbage-collection step at a time within this database, for the same reason
5753
+ * compaction steps take turns (`#serializedCompactionStep`): background collection drives
5754
+ * a job in steps, and a caller stepping collection explicitly continues the same job rather
5755
+ * than racing it.
5756
+ */
5757
+ async #serializedCollectionStep(step) {
5758
+ const previous = this.#collectionSteps;
5759
+ const run = previous.then(step, step);
5760
+ this.#collectionSteps = run;
5761
+ try {
5762
+ return await run;
5763
+ }
5764
+ finally {
5765
+ if (this.#collectionSteps === run)
5766
+ this.#collectionSteps = Promise.resolve();
5767
+ }
5768
+ }
5769
+ /**
5770
+ * Background collection: plans one pass and drives it to completion in yielding steps.
5771
+ * Runs after a background fold, whose superseded blocks are what a pass reclaims, and every
5772
+ * AUTO_COLLECT_COMMIT_INTERVAL commits, since every commit writes a manifest that stays on
5773
+ * disk until pruned. Keeps the most recent versions readable. Never surfaces through a
5774
+ * write or a scan; a failed pass backs off for an interval of commits.
5775
+ */
5776
+ #maybeScheduleAutoCollection() {
5777
+ if (!this.#autoCollect)
5778
+ return;
5779
+ if (this.#autoCollectionInFlight) {
5780
+ // A fold finishing or a quiet minute passing while a run is under way is a reason for
5781
+ // one more run once this one ends — a dropped trigger after the last commit of a burst
5782
+ // would otherwise leave the burst's leftovers until the next one.
5783
+ this.#autoCollectionRequested = true;
5784
+ return;
5785
+ }
5786
+ if (this.#commitsSinceCollection < this.#autoCollectionBackoffUntilCommit)
5787
+ return;
5788
+ this.#autoCollectionInFlight = true;
5789
+ this.#autoCollectionRequested = false;
5790
+ this.#commitsSinceCollection = 0;
5791
+ this.#lastCollectionAt = this.#now().getTime();
5792
+ void this.#runAutoCollection()
5793
+ .then(() => {
5794
+ this.#autoCollectionBackoffUntilCommit = 0;
5795
+ })
5796
+ .catch(() => {
5797
+ this.#autoCollectionBackoffUntilCommit = AUTO_COLLECT_COMMIT_INTERVAL * 2;
5798
+ })
5799
+ .finally(() => {
5800
+ this.#autoCollectionInFlight = false;
5801
+ if (this.#autoCollectionRequested) {
5802
+ this.#autoCollectionRequested = false;
5803
+ void yieldToEventLoop().then(() => {
5804
+ this.#maybeScheduleAutoCollection();
5805
+ });
5806
+ }
5807
+ });
5808
+ }
5809
+ /**
5810
+ * A pass a quiet period after the last commit, for a tab that stops writing: the retained
5811
+ * window's age bound lets that pass reclaim what the last burst superseded, which no commit
5812
+ * would otherwise arrive to trigger. Re-armed by every commit; unreferenced, so it never
5813
+ * keeps a process alive.
5814
+ */
5815
+ #armIdleCollection() {
5816
+ if (!this.#autoCollect)
5817
+ return;
5818
+ if (this.#idleCollectionTimer !== undefined)
5819
+ clearTimeout(this.#idleCollectionTimer);
5820
+ const timer = setTimeout(() => {
5821
+ this.#idleCollectionTimer = undefined;
5822
+ this.#maybeScheduleAutoCollection();
5823
+ }, AUTO_COLLECT_QUIET_MS);
5824
+ timer.unref?.();
5825
+ this.#idleCollectionTimer = timer;
5826
+ }
5827
+ async #runAutoCollection() {
5828
+ // One pass plans a bounded number of candidates, so a backlog — a burst of commits that
5829
+ // outran the passes between them — takes several. Keep passing while a pass still finds
5830
+ // something, up to a ceiling that keeps a pathological store from pinning the loop.
5831
+ for (let pass = 0; pass < AUTO_COLLECT_MAX_PASSES; pass += 1) {
5832
+ this.#releaseIdleSharedLease();
5833
+ let progress = await this.#collectGarbageStep({
5834
+ maxItems: AUTO_COLLECT_STEP_ITEMS,
5835
+ retainRecentVersions: AUTO_COLLECT_RETAINED_VERSIONS,
5836
+ }, AUTO_COLLECT_RETAINED_VERSION_MS);
5837
+ while (progress.result === null) {
5838
+ await yieldToEventLoop();
5839
+ progress = await this.resumeGarbageCollectionJob(progress.jobId, {
5840
+ maxItems: AUTO_COLLECT_STEP_ITEMS,
5841
+ });
5842
+ }
5843
+ const result = progress.result;
5844
+ if (result.prunedManifestCount === 0 &&
5845
+ result.reclaimedBlockCount === 0 &&
5846
+ result.reclaimedSegmentCount === 0 &&
5847
+ result.reclaimedTransactionCount === 0) {
5848
+ break;
5849
+ }
5850
+ await yieldToEventLoop();
5851
+ }
5852
+ await this.#pruneFinishedJobRecords();
5853
+ }
5854
+ /** Drops finished maintenance records while preserving the state needed for safe L2 retries. */
5855
+ async #pruneFinishedJobRecords() {
5856
+ const newestFirst = (jobs) => jobs.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
5857
+ const terminalCompactions = newestFirst((await this.store.listCompactionJobs()).filter((job) => job.state === "published" || job.state === "cancelled" || job.state === "aborted"));
5858
+ const retainedCompactionIds = new Set(terminalCompactions.slice(0, AUTO_COLLECT_RETAINED_JOB_RECORDS).map((job) => job.id));
5859
+ // A retry persists the cumulative bytes written by earlier attempts. Keep only the newest
5860
+ // failure for each still-readable source snapshot; it carries the whole lifetime budget.
5861
+ // Once the source manifest is pruned, that exact retry can never be planned again.
5862
+ const retainedFailureBases = new Set();
5863
+ const manifestReadable = new Map();
5864
+ for (const job of terminalCompactions) {
5865
+ if (job.state !== "cancelled" && job.state !== "aborted")
5866
+ continue;
5867
+ const baseId = job.id.split("/retry/", 1)[0] ?? job.id;
5868
+ if (retainedFailureBases.has(baseId))
5869
+ continue;
5870
+ let readable = manifestReadable.get(job.sourceManifestVersion);
5871
+ if (readable === undefined) {
5872
+ const manifest = await this.store.getManifest(job.sourceManifestVersion);
5873
+ readable = manifest !== undefined && manifest.prunedAt === undefined;
5874
+ manifestReadable.set(job.sourceManifestVersion, readable);
5875
+ }
5876
+ if (!readable)
5877
+ continue;
5878
+ retainedFailureBases.add(baseId);
5879
+ retainedCompactionIds.add(job.id);
5880
+ }
5881
+ for (const job of terminalCompactions) {
5882
+ if (!retainedCompactionIds.has(job.id))
5883
+ await this.store.removeCompactionJob(job.id);
5884
+ }
5885
+ const collections = newestFirst((await this.store.listGarbageCollectionJobs()).filter((job) => job.state === "completed"));
5886
+ for (const job of collections.slice(AUTO_COLLECT_RETAINED_JOB_RECORDS)) {
5887
+ await this.store.removeGarbageCollectionJob(job.id);
5888
+ }
5889
+ }
5890
+ async listGarbageCollectionJobs() {
5891
+ return this.store.listGarbageCollectionJobs();
5892
+ }
5893
+ async #planGarbageCollection(maxPlanningItems, retainRecentVersions, retainedVersionMaxAgeMs = Number.POSITIVE_INFINITY) {
5894
+ // Crashed readers and query spill owners are metadata roots too. Sweep their expired
5895
+ // records as part of every explicit or background collection so callers never need a
5896
+ // separate maintenance loop to keep either family bounded.
5897
+ await this.#transactions.removeExpiredLeases(this.#now());
5898
+ await this.cleanupQuerySpill();
5899
+ const current = await this.store.getCurrentManifest();
5255
5900
  const currentBlockIds = new Set(current?.blockIds ?? []);
5901
+ // Manifest versions are consecutive, so the retained window is a version floor; a version
5902
+ // inside it is still collected once it is older than the window's age.
5903
+ const retainAbove = (current?.version ?? 0) - retainRecentVersions;
5904
+ const retainAfter = this.#now().getTime() - retainedVersionMaxAgeMs;
5905
+ const retained = (manifest) => manifest.version > retainAbove && Date.parse(manifest.createdAt) > retainAfter;
5256
5906
  const candidateManifestVersions = [];
5257
5907
  const candidateBlockIds = [];
5258
5908
  const candidateSegmentIds = [];
5909
+ const candidateTransactionIds = [];
5259
5910
  const candidateBlockIdSet = new Set();
5260
5911
  const candidateSegmentIdSet = new Set();
5261
- const remaining = () => maxPlanningItems - candidateBlockIds.length - candidateSegmentIds.length;
5912
+ const remaining = () => maxPlanningItems -
5913
+ candidateBlockIds.length -
5914
+ candidateSegmentIds.length -
5915
+ candidateTransactionIds.length;
5262
5916
  const addBlocks = (ids) => {
5263
5917
  for (const id of ids) {
5264
5918
  if (remaining() <= 0)
@@ -5279,33 +5933,81 @@ export class MinnowDatabase {
5279
5933
  candidateSegmentIds.push(id);
5280
5934
  }
5281
5935
  };
5282
- let manifestCursor = null;
5283
- do {
5936
+ // The walk starts past the prefix of history this database has already seen fully
5937
+ // collected — every manifest pruned and none of its blocks left — and extends that prefix
5938
+ // as it goes. A pruned manifest's blocks can only disappear, and a block it shares with a
5939
+ // later manifest is found through that manifest or the job that superseded it, so skipping
5940
+ // the dead prefix loses nothing; it is what keeps a pass proportional to the live history
5941
+ // rather than to everything the database ever committed.
5942
+ let manifestCursor = this.#collectionWatermark;
5943
+ let deadPrefixEnd = this.#collectionWatermark;
5944
+ let prefixContiguous = true;
5945
+ walk: do {
5284
5946
  const page = await this.store.listManifestPage(manifestCursor, 64);
5285
5947
  for (const manifest of page.records) {
5286
5948
  if (manifest.version === current?.version)
5949
+ break walk;
5950
+ if (retained(manifest)) {
5951
+ prefixContiguous = false;
5287
5952
  continue;
5953
+ }
5288
5954
  const existing = await this.#existingGarbageBlockCandidates(manifest.blockIds, currentBlockIds, remaining());
5289
- if (manifest.prunedAt === undefined || existing.length > 0) {
5290
- candidateManifestVersions.push(manifest.version);
5291
- addBlocks(existing);
5955
+ if (manifest.prunedAt === undefined) {
5956
+ // Only an unpruned manifest is a pruning candidate: one already pruned would spend a
5957
+ // step's capacity confirming it, and with enough of them in front, the unpruned ones
5958
+ // behind them were never reached at all. Their leftover blocks still count.
5959
+ if (candidateManifestVersions.length < 64)
5960
+ candidateManifestVersions.push(manifest.version);
5961
+ prefixContiguous = false;
5292
5962
  }
5293
- if (remaining() <= 0 || candidateManifestVersions.length === 64)
5294
- break;
5963
+ else if (prefixContiguous && existing.length === 0) {
5964
+ deadPrefixEnd = manifest.version;
5965
+ }
5966
+ else {
5967
+ prefixContiguous = false;
5968
+ }
5969
+ addBlocks(existing);
5970
+ if (remaining() <= 0)
5971
+ break walk;
5295
5972
  }
5296
- if (remaining() <= 0 || candidateManifestVersions.length === 64)
5297
- break;
5298
5973
  manifestCursor = page.nextCursor;
5299
5974
  } while (manifestCursor !== null);
5975
+ this.#collectionWatermark = deadPrefixEnd;
5300
5976
  if (remaining() > 0) {
5977
+ const segmentOwnerIds = new Set((await this.store.listSegments()).map((segment) => segment.transactionId));
5978
+ const manifestEligibility = new Map();
5979
+ const candidateManifestSet = new Set(candidateManifestVersions);
5301
5980
  let transactionCursor = null;
5302
5981
  do {
5303
5982
  const page = await this.store.listTransactionPage(transactionCursor, 64);
5304
5983
  for (const transaction of page.records) {
5305
- if (transaction.status !== "aborted")
5306
- continue;
5307
- addBlocks(await this.#existingGarbageBlockCandidates(transaction.pendingBlockIds, currentBlockIds, remaining()));
5308
- addSegments(await this.#existingGarbageSegmentCandidates(transaction.pendingSegmentIds, remaining()));
5984
+ if (transaction.status === "aborted") {
5985
+ const pendingBlocks = await this.#existingGarbageBlockCandidates(transaction.pendingBlockIds, currentBlockIds, remaining());
5986
+ addBlocks(pendingBlocks);
5987
+ const pendingSegments = await this.#existingGarbageSegmentCandidates(transaction.pendingSegmentIds, remaining());
5988
+ addSegments(pendingSegments);
5989
+ // The artifacts are deleted before transaction candidates within a job. A later
5990
+ // pass sees the empty journal and removes the aborted record itself.
5991
+ if (pendingBlocks.length === 0 && pendingSegments.length === 0 && remaining() > 0) {
5992
+ candidateTransactionIds.push(transaction.id);
5993
+ }
5994
+ }
5995
+ else if (transaction.status === "committed" &&
5996
+ transaction.committedVersion !== null &&
5997
+ !segmentOwnerIds.has(transaction.id) &&
5998
+ remaining() > 0) {
5999
+ let eligible = manifestEligibility.get(transaction.committedVersion);
6000
+ if (eligible === undefined) {
6001
+ const manifest = await this.store.getManifest(transaction.committedVersion);
6002
+ eligible =
6003
+ manifest === undefined ||
6004
+ manifest.prunedAt !== undefined ||
6005
+ candidateManifestSet.has(transaction.committedVersion);
6006
+ manifestEligibility.set(transaction.committedVersion, eligible);
6007
+ }
6008
+ if (eligible)
6009
+ candidateTransactionIds.push(transaction.id);
6010
+ }
5309
6011
  if (remaining() <= 0)
5310
6012
  break;
5311
6013
  }
@@ -5323,10 +6025,7 @@ export class MinnowDatabase {
5323
6025
  continue;
5324
6026
  }
5325
6027
  addBlocks(await this.#existingGarbageBlockCandidates([...job.sourceBlockIds, ...job.outputBlockIds], currentBlockIds, remaining()));
5326
- addSegments(await this.#existingGarbageSegmentCandidates([
5327
- ...job.sourceSegmentIds,
5328
- ...(job.outputSegmentId === null ? [] : [job.outputSegmentId]),
5329
- ], remaining()));
6028
+ addSegments(await this.#existingGarbageSegmentCandidates([...job.sourceSegmentIds, ...compactionOutputSegmentIds(job)], remaining()));
5330
6029
  if (remaining() <= 0)
5331
6030
  break;
5332
6031
  }
@@ -5341,22 +6040,35 @@ export class MinnowDatabase {
5341
6040
  candidateManifestVersions,
5342
6041
  candidateSegmentIds,
5343
6042
  candidateBlockIds,
6043
+ candidateTransactionIds,
5344
6044
  leaseCutoff: timestamp,
5345
6045
  createdAt: timestamp,
5346
6046
  });
5347
6047
  }
5348
6048
  async #existingGarbageBlockCandidates(ids, currentBlockIds, limit) {
5349
- const candidates = [];
6049
+ if (limit <= 0)
6050
+ return [];
6051
+ // A block the current manifest still carries is not garbage, whatever else references it,
6052
+ // and an unpruned manifest shares nearly all of its blocks with the current one. Deciding
6053
+ // that from the set first leaves the store lookups to the few blocks that might be gone —
6054
+ // reading every block of every manifest to find them made a planning pass cost the table
6055
+ // times the history.
6056
+ const possible = [];
5350
6057
  const seen = new Set();
5351
- for (let start = 0; start < ids.length && candidates.length < limit; start += 64) {
5352
- const page = ids.slice(start, start + 64);
6058
+ for (const id of ids) {
6059
+ if (currentBlockIds.has(id) || seen.has(id))
6060
+ continue;
6061
+ seen.add(id);
6062
+ possible.push(id);
6063
+ }
6064
+ const candidates = [];
6065
+ for (let start = 0; start < possible.length && candidates.length < limit; start += 64) {
6066
+ const page = possible.slice(start, start + 64);
5353
6067
  const blocks = await this.store.getBlocks(page);
5354
6068
  for (let index = 0; index < page.length && candidates.length < limit; index += 1) {
5355
6069
  const id = page[index] ?? "";
5356
- if (blocks[index] !== undefined && !currentBlockIds.has(id) && !seen.has(id)) {
5357
- seen.add(id);
6070
+ if (blocks[index] !== undefined)
5358
6071
  candidates.push(id);
5359
- }
5360
6072
  }
5361
6073
  }
5362
6074
  return candidates;
@@ -5377,8 +6089,10 @@ export class MinnowDatabase {
5377
6089
  async #runGarbageCollectionJob(initialJob, maxItems) {
5378
6090
  let job = initialJob;
5379
6091
  for (;;) {
5380
- if (job.state === "completed")
6092
+ if (job.state === "completed") {
6093
+ await this.store.removePrunedManifestRecords();
5381
6094
  return garbageCollectionProgress(job);
6095
+ }
5382
6096
  try {
5383
6097
  const step = await this.store.runGarbageCollectionStep({
5384
6098
  jobId: job.id,
@@ -5386,6 +6100,8 @@ export class MinnowDatabase {
5386
6100
  maxItems,
5387
6101
  updatedAt: this.#now().toISOString(),
5388
6102
  });
6103
+ if (step.job.state === "completed")
6104
+ await this.store.removePrunedManifestRecords();
5389
6105
  return garbageCollectionProgress(step.job);
5390
6106
  }
5391
6107
  catch (error) {
@@ -5440,31 +6156,52 @@ export class MinnowDatabase {
5440
6156
  const levelTwoMaxWriteAmplification = targetLevel === 2
5441
6157
  ? positiveFiniteNumber(options.maxWriteAmplification ?? DEFAULT_LEVEL_TWO_MAX_WRITE_AMPLIFICATION, "Compaction maximum write amplification")
5442
6158
  : undefined;
5443
- let anchor;
6159
+ const targetBlockBytes = positiveWholeNumber(options.targetBlockBytes ?? DEFAULT_COMPACTION_TARGET_BLOCK_BYTES, "Compaction target block bytes");
6160
+ if (targetBlockBytes > MAX_COMPACTION_TARGET_BLOCK_BYTES) {
6161
+ throw new RangeError(`Compaction target block bytes cannot exceed ${String(MAX_COMPACTION_TARGET_BLOCK_BYTES)}`);
6162
+ }
6163
+ const outputCompression = validateCompression(options.outputCompression ?? "gzip", "Compaction output compression");
6164
+ if (getCompressionMemoryBound(outputCompression, targetBlockBytes).maximumOutputBytes >
6165
+ MAX_COMPACTION_TARGET_BLOCK_BYTES) {
6166
+ throw new RangeError(`Compaction target block bytes exceed the ${outputCompression} worst-case format limit`);
6167
+ }
6168
+ const memoryBudgetBytes = positiveWholeNumber(options.memoryBudgetBytes ?? DEFAULT_COMPACTION_MEMORY_BUDGET_BYTES, "Compaction memory budget");
6169
+ const partitionRows = positiveWholeNumber(options.partitionRows ?? this.#compactionPartitionRows, "Compaction partition rows");
6170
+ let anchors = [];
5444
6171
  let level0Segments;
5445
6172
  let effectiveMinimumLevel0Segments;
5446
6173
  let outputPartitionOrdinal;
5447
6174
  let keyedLevelTwo = false;
5448
- if (targetLevel === 1) {
5449
- const firstLevel = visibleSegments[0]?.level ?? 0;
5450
- const hasAnchor = firstLevel === 1;
5451
- const level0Offset = hasAnchor ? 1 : 0;
5452
- const supportedLevelLayout = firstLevel <= 1 &&
5453
- visibleSegments.slice(level0Offset).every((segment) => (segment.level ?? 0) === 0);
5454
- if (!supportedLevelLayout) {
6175
+ let keyedLevelOne;
6176
+ let keylessLevelOne;
6177
+ if (targetLevel === 1 && table.uniqueKeyColumnId !== undefined) {
6178
+ // Keyed L1: a prefix of level-one partitions, then level-zero history. A fold rewrites
6179
+ // only the partitions the selected deltas touch (and the tail partition new rows join),
6180
+ // so which partitions it sources is decided after the level-zero selection below.
6181
+ const layout = keyedLevelOneLayout(visibleSegments);
6182
+ if (layout === null) {
6183
+ return compactTableSkipped(table.name, "unsupported-level-layout", visibleSegments, visibleBlockIds, version);
6184
+ }
6185
+ keyedLevelOne = layout;
6186
+ level0Segments = layout.level0Segments;
6187
+ effectiveMinimumLevel0Segments =
6188
+ layout.partitions.length > 0 ? minimumLevel0Segments : Math.max(2, minimumLevel0Segments);
6189
+ }
6190
+ else if (targetLevel === 1) {
6191
+ const layout = keylessLevelOneLayout(visibleSegments);
6192
+ if (layout === null) {
5455
6193
  return compactTableSkipped(table.name, "unsupported-level-layout", visibleSegments, visibleBlockIds, version);
5456
6194
  }
5457
- anchor = hasAnchor ? visibleSegments[0] : undefined;
5458
- level0Segments = visibleSegments.slice(level0Offset);
5459
- effectiveMinimumLevel0Segments = hasAnchor
5460
- ? minimumLevel0Segments
5461
- : Math.max(2, minimumLevel0Segments);
6195
+ keylessLevelOne = layout;
6196
+ level0Segments = layout.level0Segments;
6197
+ effectiveMinimumLevel0Segments =
6198
+ layout.partitions.length > 0 ? minimumLevel0Segments : Math.max(2, minimumLevel0Segments);
5462
6199
  }
5463
6200
  else if (table.uniqueKeyColumnId !== undefined ||
5464
6201
  visibleSegments.some((segment) => (segment.kind ?? "insert") !== "insert" || segment.rowIdSpans !== undefined)) {
5465
- // Keyed multi-range L2: merge (optional anchor + oldest level-zero prefix) into a new
5466
- // span-carrying partition. Published partitions are never rewritten; mutation kinds
5467
- // without a unique key cannot merge and keep the materialized skip.
6202
+ // Keyed multi-range L2: merge (the level-one partitions + oldest level-zero prefix) into
6203
+ // a new span-carrying partition. Published partitions are never rewritten; mutation
6204
+ // kinds without a unique key cannot merge and keep the materialized skip.
5468
6205
  if (table.uniqueKeyColumnId === undefined) {
5469
6206
  return compactTableSkipped(table.name, "contains-mutation-segments", visibleSegments, visibleBlockIds, version);
5470
6207
  }
@@ -5472,7 +6209,7 @@ export class MinnowDatabase {
5472
6209
  if (layout === null) {
5473
6210
  return compactTableSkipped(table.name, "unsupported-level-layout", visibleSegments, visibleBlockIds, version);
5474
6211
  }
5475
- anchor = layout.anchor;
6212
+ anchors = layout.anchors;
5476
6213
  level0Segments = layout.level0Segments;
5477
6214
  effectiveMinimumLevel0Segments = minimumLevel0Segments;
5478
6215
  outputPartitionOrdinal = layout.levelTwoSegments.length;
@@ -5490,38 +6227,70 @@ export class MinnowDatabase {
5490
6227
  if (level0Segments.length < effectiveMinimumLevel0Segments) {
5491
6228
  return compactTableSkipped(table.name, "below-segment-threshold", visibleSegments, visibleBlockIds, version);
5492
6229
  }
5493
- const selection = await this.#selectCompactionSources(anchor, level0Segments, effectiveMinimumLevel0Segments, maxLevel0Segments, maxLevel0StoredBytes, snapshot);
6230
+ if (version === null)
6231
+ throw new Error("Visible compaction segments require a manifest");
6232
+ const level0Selection = await this.#selectLevelZeroSources(level0Segments, effectiveMinimumLevel0Segments, maxLevel0Segments, maxLevel0StoredBytes, snapshot);
6233
+ let partitioning;
6234
+ let rechunkPartitioning;
6235
+ if (keyedLevelOne !== undefined) {
6236
+ const keyColumn = getUniqueKeyColumn(table);
6237
+ if (keyColumn === undefined) {
6238
+ throw new Error(`Mutation compaction requires a unique key: ${table.name}`);
6239
+ }
6240
+ const touched = await this.#touchedPartitionIds(keyColumn, keyedLevelOne.partitions, level0Selection.segments, memoryBudgetBytes, snapshot);
6241
+ // New rows join the last partition while it is small, or when it is being rewritten
6242
+ // anyway; otherwise they open a new partition behind it and it stays untouched.
6243
+ const last = keyedLevelOne.partitions[keyedLevelOne.partitions.length - 1];
6244
+ const bearsNewRows = level0Selection.segments.some((segment) => mergeSourceBearsRows(segment.kind ?? "insert"));
6245
+ const absorbsTail = last !== undefined &&
6246
+ bearsNewRows &&
6247
+ (touched.has(last.id) || last.rowCount < partitionRows);
6248
+ anchors = keyedLevelOne.partitions.filter((partition) => partition.rowCount > partitionRows ||
6249
+ touched.has(partition.id) ||
6250
+ (absorbsTail && partition.id === last.id));
6251
+ partitioning = {
6252
+ partitions: keyedLevelOne.partitions,
6253
+ partitionRows,
6254
+ absorbsTail,
6255
+ nextLevelZeroOrder: level0Selection.nextLogicalOrder ?? version + 1,
6256
+ };
6257
+ }
6258
+ else if (keylessLevelOne !== undefined) {
6259
+ const last = keylessLevelOne.partitions.at(-1);
6260
+ // A partial tail is extended. An oversized legacy anchor is included once so this fold
6261
+ // heals it into bounded partitions; a full tail stays immutable and new rows start after it.
6262
+ const absorbsTail = last !== undefined && (last.rowCount < partitionRows || last.rowCount > partitionRows);
6263
+ anchors = absorbsTail ? [last] : [];
6264
+ rechunkPartitioning = {
6265
+ partitionRows,
6266
+ nextLevelZeroOrder: level0Selection.nextLogicalOrder ?? version + 1,
6267
+ };
6268
+ }
6269
+ const anchorMeasurement = await this.#measureCompactionSources(anchors, level0Selection.blockIds, snapshot);
6270
+ const selection = {
6271
+ sourceSegments: [...anchors, ...level0Selection.segments],
6272
+ level0SourceStoredBytes: level0Selection.storedBytes,
6273
+ anchorSourceStoredBytes: anchorMeasurement.storedBytes,
6274
+ };
5494
6275
  const sourceSegments = selection.sourceSegments;
5495
6276
  const sourceBlockIds = uniqueSegmentBlockIds(sourceSegments);
5496
6277
  const hasContiguousSourceRowIds = hasContiguousRowIds(sourceSegments);
5497
6278
  const hasPositiveSourceRowIds = (sourceSegments[0]?.rowIdStart ?? 0n) > 0n;
5498
- // A keyed L2 promotion always merges: one uniform partition shape (a full-row base with
5499
- // row-ID spans) regardless of whether the selected prefix happens to be pure inserts.
5500
- const requiresMerge = keyedLevelTwo
6279
+ // A keyed fold always merges: one uniform partition shape (a full-row base with row-ID
6280
+ // spans, bounded by `partitionRows`) regardless of whether the selected prefix happens to
6281
+ // be pure inserts.
6282
+ const requiresMerge = keyedLevelTwo || keyedLevelOne !== undefined
5501
6283
  ? true
5502
6284
  : targetLevel === 1 &&
5503
- (sourceSegments.some((segment) => (segment.kind ?? "insert") !== "insert" || segment.rowIdSpans !== undefined) ||
5504
- (!hasContiguousSourceRowIds && table.uniqueKeyColumnId !== undefined));
6285
+ sourceSegments.some((segment) => (segment.kind ?? "insert") !== "insert" || segment.rowIdSpans !== undefined);
5505
6286
  if (!requiresMerge &&
5506
6287
  (!hasContiguousSourceRowIds || (targetLevel === 2 && !hasPositiveSourceRowIds))) {
5507
6288
  return compactTableSkipped(table.name, "non-contiguous-row-ids", sourceSegments, sourceBlockIds, version);
5508
6289
  }
5509
- if (version === null)
5510
- throw new Error("Visible compaction segments require a manifest");
5511
- const targetBlockBytes = positiveWholeNumber(options.targetBlockBytes ?? DEFAULT_COMPACTION_TARGET_BLOCK_BYTES, "Compaction target block bytes");
5512
- if (targetBlockBytes > MAX_COMPACTION_TARGET_BLOCK_BYTES) {
5513
- throw new RangeError(`Compaction target block bytes cannot exceed ${String(MAX_COMPACTION_TARGET_BLOCK_BYTES)}`);
5514
- }
5515
- const outputCompression = validateCompression(options.outputCompression ?? "gzip", "Compaction output compression");
5516
- if (getCompressionMemoryBound(outputCompression, targetBlockBytes).maximumOutputBytes >
5517
- MAX_COMPACTION_TARGET_BLOCK_BYTES) {
5518
- throw new RangeError(`Compaction target block bytes exceed the ${outputCompression} worst-case format limit`);
5519
- }
5520
- const memoryBudgetBytes = positiveWholeNumber(options.memoryBudgetBytes ?? DEFAULT_COMPACTION_MEMORY_BUDGET_BYTES, "Compaction memory budget");
5521
6290
  let mergePlan;
5522
6291
  if (requiresMerge) {
5523
6292
  try {
5524
- mergePlan = await this.#createMergeCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot);
6293
+ mergePlan = await this.#createMergeCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot, partitioning);
5525
6294
  }
5526
6295
  catch (error) {
5527
6296
  // A keyed L2 prefix whose mutations reference keys living in already-published
@@ -5534,7 +6303,7 @@ export class MinnowDatabase {
5534
6303
  }
5535
6304
  }
5536
6305
  const rewritePlan = mergePlan ??
5537
- (await this.#createRechunkCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot));
6306
+ (await this.#createRechunkCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot, rechunkPartitioning));
5538
6307
  const minimumMemoryBytes = compactionMinimumMemoryBytes(rewritePlan);
5539
6308
  if (minimumMemoryBytes > memoryBudgetBytes) {
5540
6309
  throw new CompactionMemoryBudgetError(memoryBudgetBytes, minimumMemoryBytes);
@@ -5560,7 +6329,7 @@ export class MinnowDatabase {
5560
6329
  const priorAttemptOutputStoredBytes = (await this.store.listCompactionJobs(table.id))
5561
6330
  .filter((candidate) => (candidate.state === "cancelled" || candidate.state === "aborted") &&
5562
6331
  (candidate.id === baseJobId || candidate.id.startsWith(`${baseJobId}/retry/`)))
5563
- .reduce((total, candidate) => total + candidate.outputStoredBytes, 0);
6332
+ .reduce((largest, candidate) => Math.max(largest, safeWholeNumberSum([candidate.priorAttemptOutputStoredBytes ?? 0, candidate.outputStoredBytes], "Compaction prior-attempt output stored bytes")), 0);
5564
6333
  const maximumOutputStoredBytes = Math.max(0, floorWholeNumberProduct(selection.level0SourceStoredBytes, maxWriteAmplification, "Compaction maximum output stored bytes") - priorAttemptOutputStoredBytes);
5565
6334
  const plannedOutputStoredBytesUpperBound = await this.#plannedPhysicalOutputStoredBytesUpperBound(rewritePlan, snapshot);
5566
6335
  levelTwoBudget = {
@@ -5646,11 +6415,40 @@ export class MinnowDatabase {
5646
6415
  throw error;
5647
6416
  }
5648
6417
  }
5649
- async #selectCompactionSources(anchor, level0Segments, minimumLevel0Segments, maxLevel0Segments, maxLevel0StoredBytes, snapshot) {
5650
- const transactions = new Map((await this.#transactionRecordsForSegments([
5651
- ...(anchor === undefined ? [] : [anchor]),
5652
- ...level0Segments,
5653
- ])).map((record) => [record.id, record]));
6418
+ /**
6419
+ * Sums the stored bytes of the given segments' blocks, refusing a block that appears twice
6420
+ * among them or in `seenBlockIds` — a source block may only be superseded once.
6421
+ */
6422
+ async #measureCompactionSources(segments, seenBlockIds, snapshot) {
6423
+ let total = 0;
6424
+ const blockIds = [];
6425
+ const measuredBlockIds = new Set();
6426
+ for (const segment of segments) {
6427
+ for (const blockId of Object.values(segment.columnBlockIds).flat()) {
6428
+ if (seenBlockIds.has(blockId) || measuredBlockIds.has(blockId)) {
6429
+ throw new Error(`Compaction source block is referenced more than once: ${blockId}`);
6430
+ }
6431
+ measuredBlockIds.add(blockId);
6432
+ blockIds.push(blockId);
6433
+ await this.#renewInternalLeaseIfNeeded(snapshot);
6434
+ const bytes = await this.store.getBlock(blockId);
6435
+ if (bytes === undefined)
6436
+ throw new Error(`Compaction source block is missing: ${blockId}`);
6437
+ total = safeWholeNumberSum([total, bytes.byteLength], "Compaction selected stored bytes");
6438
+ }
6439
+ }
6440
+ return { storedBytes: total, blockIds };
6441
+ }
6442
+ /**
6443
+ * The oldest level-zero prefix one job promotes: whole equal-order groups, at least the
6444
+ * minimum, and past it no more than the segment and stored-byte ceilings allow. Also reports
6445
+ * the order of the first segment left behind, which bounds the orders a fold may publish.
6446
+ */
6447
+ async #selectLevelZeroSources(level0Segments, minimumLevel0Segments, maxLevel0Segments, maxLevel0StoredBytes, snapshot) {
6448
+ const transactions = new Map((await this.#transactionRecordsForSegments(level0Segments)).map((record) => [
6449
+ record.id,
6450
+ record,
6451
+ ]));
5654
6452
  const logicalOrder = (segment) => {
5655
6453
  const owner = transactions.get(segment.transactionId);
5656
6454
  if (owner?.status !== "committed" || owner.committedVersion === null) {
@@ -5659,42 +6457,10 @@ export class MinnowDatabase {
5659
6457
  return segment.logicalOrder ?? owner.committedVersion;
5660
6458
  };
5661
6459
  const seenBlockIds = new Set();
5662
- const measureStoredBytes = async (segments) => {
5663
- let total = 0;
5664
- const blockIds = [];
5665
- const measuredBlockIds = new Set();
5666
- let duplicateBlockId = null;
5667
- for (const segment of segments) {
5668
- for (const blockId of Object.values(segment.columnBlockIds).flat()) {
5669
- if (seenBlockIds.has(blockId) || measuredBlockIds.has(blockId)) {
5670
- duplicateBlockId ??= blockId;
5671
- }
5672
- measuredBlockIds.add(blockId);
5673
- blockIds.push(blockId);
5674
- await this.#renewInternalLeaseIfNeeded(snapshot);
5675
- const bytes = await this.store.getBlock(blockId);
5676
- if (bytes === undefined)
5677
- throw new Error(`Compaction source block is missing: ${blockId}`);
5678
- total = safeWholeNumberSum([total, bytes.byteLength], "Compaction selected stored bytes");
5679
- }
5680
- }
5681
- return { storedBytes: total, blockIds, duplicateBlockId };
5682
- };
5683
- const acceptMeasurement = (measurement) => {
5684
- if (measurement.duplicateBlockId !== null) {
5685
- throw new Error(`Compaction source block is referenced more than once: ${measurement.duplicateBlockId}`);
5686
- }
5687
- measurement.blockIds.forEach((blockId) => seenBlockIds.add(blockId));
5688
- };
5689
- let anchorSourceStoredBytes = 0;
5690
- if (anchor !== undefined) {
5691
- const anchorMeasurement = await measureStoredBytes([anchor]);
5692
- acceptMeasurement(anchorMeasurement);
5693
- anchorSourceStoredBytes = anchorMeasurement.storedBytes;
5694
- }
5695
- const selectedLevel0 = [];
5696
- let level0SourceStoredBytes = 0;
5697
- for (let start = 0; start < level0Segments.length;) {
6460
+ const selected = [];
6461
+ let storedBytes = 0;
6462
+ let start = 0;
6463
+ while (start < level0Segments.length) {
5698
6464
  const first = level0Segments[start];
5699
6465
  if (first === undefined)
5700
6466
  throw new Error("Compaction L0 source selection is unavailable");
@@ -5707,30 +6473,109 @@ export class MinnowDatabase {
5707
6473
  end += 1;
5708
6474
  }
5709
6475
  const group = level0Segments.slice(start, end);
5710
- if (selectedLevel0.length >= minimumLevel0Segments &&
5711
- selectedLevel0.length + group.length > maxLevel0Segments) {
6476
+ if (selected.length >= minimumLevel0Segments &&
6477
+ selected.length + group.length > maxLevel0Segments) {
5712
6478
  break;
5713
6479
  }
5714
- const groupMeasurement = await measureStoredBytes(group);
5715
- if (selectedLevel0.length >= minimumLevel0Segments &&
5716
- groupMeasurement.storedBytes > maxLevel0StoredBytes - level0SourceStoredBytes) {
6480
+ const measurement = await this.#measureCompactionSources(group, seenBlockIds, snapshot);
6481
+ if (selected.length >= minimumLevel0Segments &&
6482
+ measurement.storedBytes > maxLevel0StoredBytes - storedBytes) {
5717
6483
  break;
5718
6484
  }
5719
- acceptMeasurement(groupMeasurement);
5720
- selectedLevel0.push(...group);
5721
- level0SourceStoredBytes = safeWholeNumberSum([level0SourceStoredBytes, groupMeasurement.storedBytes], "Compaction selected L0 stored bytes");
6485
+ measurement.blockIds.forEach((blockId) => seenBlockIds.add(blockId));
6486
+ selected.push(...group);
6487
+ storedBytes = safeWholeNumberSum([storedBytes, measurement.storedBytes], "Compaction selected L0 stored bytes");
5722
6488
  start = end;
5723
6489
  }
5724
- if (selectedLevel0.length < minimumLevel0Segments) {
6490
+ if (selected.length < minimumLevel0Segments) {
5725
6491
  throw new Error("Compaction source selection did not satisfy its minimum L0 segment count");
5726
6492
  }
6493
+ const next = level0Segments[start];
5727
6494
  return {
5728
- sourceSegments: anchor === undefined ? selectedLevel0 : [anchor, ...selectedLevel0],
5729
- level0SourceStoredBytes,
5730
- anchorSourceStoredBytes,
6495
+ segments: selected,
6496
+ storedBytes,
6497
+ blockIds: seenBlockIds,
6498
+ nextLogicalOrder: next === undefined ? null : logicalOrder(next),
5731
6499
  };
5732
6500
  }
5733
- async #createRechunkCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot) {
6501
+ /**
6502
+ * Which level-one partitions the selected deltas reach into: those holding a key that some
6503
+ * delete, update, or upsert among them names. Inserts and upserts of new keys touch nothing;
6504
+ * their rows join the tail. Key blocks whose zone map rules every referenced key out are
6505
+ * skipped from the header, so a sorted key costs one decoded block per partition at most and
6506
+ * usually none; the referenced-key set is the same size the merge planner's is.
6507
+ */
6508
+ async #touchedPartitionIds(keyColumn, partitions, level0Segments, memoryBudgetBytes, snapshot) {
6509
+ const touched = new Set();
6510
+ if (partitions.length === 0)
6511
+ return touched;
6512
+ const deltas = level0Segments.filter((segment) => mergeSourceReferencesKeys(segment.kind ?? "insert"));
6513
+ if (deltas.length === 0)
6514
+ return touched;
6515
+ const referencedBytes = safeWholeNumberProduct(deltas.reduce((total, segment) => total + segment.rowCount, 0), MERGE_PLANNER_KEY_BYTES, "Compaction referenced keys");
6516
+ if (referencedBytes > memoryBudgetBytes) {
6517
+ throw new CompactionMemoryBudgetError(memoryBudgetBytes, referencedBytes);
6518
+ }
6519
+ const referenced = new Set();
6520
+ for (const segment of deltas) {
6521
+ await this.#forEachSegmentKey(segment, keyColumn, snapshot, (value) => {
6522
+ referenced.add(overlayKeyOf(keyColumn.type, value));
6523
+ });
6524
+ }
6525
+ const predicate = touchedKeyPredicate(keyColumn, referenced);
6526
+ const descriptions = predicate === undefined
6527
+ ? new Map()
6528
+ : await this.#zoneDescriptions(partitions.flatMap((partition) => partition.columnBlockIds[keyColumn.id] ?? []), snapshot);
6529
+ for (const partition of partitions) {
6530
+ const blockIds = partition.columnBlockIds[keyColumn.id] ?? [];
6531
+ if (blockIds.length === 0) {
6532
+ throw new Error(`Partition has no key column blocks: ${partition.id}`);
6533
+ }
6534
+ for (const blockId of blockIds) {
6535
+ const description = descriptions.get(blockId);
6536
+ if (predicate !== undefined &&
6537
+ description !== undefined &&
6538
+ !zoneMapCanMatch(description, predicate)) {
6539
+ continue;
6540
+ }
6541
+ await this.#renewInternalLeaseIfNeeded(snapshot);
6542
+ const bytes = await this.store.getBlock(blockId);
6543
+ if (bytes === undefined)
6544
+ throw new Error(`Compaction source block is missing: ${blockId}`);
6545
+ const decoded = await decodeBlock(bytes);
6546
+ if (decoded.column.type !== keyColumn.type) {
6547
+ throw new Error(`Compaction source block differs from table schema: ${blockId}`);
6548
+ }
6549
+ if (decoded.column.values.some((value) => referenced.has(overlayKeyOf(keyColumn.type, value)))) {
6550
+ touched.add(partition.id);
6551
+ break;
6552
+ }
6553
+ }
6554
+ }
6555
+ return touched;
6556
+ }
6557
+ /** Decodes a segment's key column in row order, one block resident at a time. */
6558
+ async #forEachSegmentKey(segment, keyColumn, snapshot, action) {
6559
+ let rowIndex = 0;
6560
+ for (const blockId of segment.columnBlockIds[keyColumn.id] ?? []) {
6561
+ await this.#renewInternalLeaseIfNeeded(snapshot);
6562
+ const bytes = await this.store.getBlock(blockId);
6563
+ if (bytes === undefined)
6564
+ throw new Error(`Compaction source block is missing: ${blockId}`);
6565
+ const decoded = await decodeBlock(bytes);
6566
+ if (decoded.column.type !== keyColumn.type) {
6567
+ throw new Error(`Compaction source block differs from table schema: ${blockId}`);
6568
+ }
6569
+ for (const value of decoded.column.values) {
6570
+ action(value, rowIndex);
6571
+ rowIndex += 1;
6572
+ }
6573
+ }
6574
+ if (rowIndex !== segment.rowCount) {
6575
+ throw new Error(`Mutation segment key rows differ: ${segment.id}`);
6576
+ }
6577
+ }
6578
+ async #createRechunkCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot, partitioning) {
5734
6579
  const first = sourceSegments[0];
5735
6580
  const last = sourceSegments[sourceSegments.length - 1];
5736
6581
  if (first === undefined || last === undefined) {
@@ -5784,12 +6629,20 @@ export class MinnowDatabase {
5784
6629
  throw new Error("Compaction could not estimate an output block size");
5785
6630
  }
5786
6631
  const rowsPerOutput = Math.max(1, Math.min(0xffff_ffff, Math.floor(targetBlockBytes / maximumEncodedBytesPerRow)));
6632
+ const logicalOrder = await this.#firstLogicalOrder(sourceSegments);
6633
+ const partitions = partitioning === undefined
6634
+ ? undefined
6635
+ : planLinearOutputPartitions(totalRows, partitioning.partitionRows, logicalOrder, partitioning.nextLevelZeroOrder);
5787
6636
  const estimatedOutputs = [];
5788
- for (let rowStart = 0; rowStart < totalRows; rowStart += rowsPerOutput) {
5789
- estimatedOutputs.push({
5790
- rowStart,
5791
- rowCount: Math.min(rowsPerOutput, totalRows - rowStart),
5792
- });
6637
+ const outputRegions = partitions ?? [{ rowStart: 0, rowCount: totalRows }];
6638
+ for (const region of outputRegions) {
6639
+ const regionEnd = region.rowStart + region.rowCount;
6640
+ for (let rowStart = region.rowStart; rowStart < regionEnd; rowStart += rowsPerOutput) {
6641
+ estimatedOutputs.push({
6642
+ rowStart,
6643
+ rowCount: Math.min(rowsPerOutput, regionEnd - rowStart),
6644
+ });
6645
+ }
5793
6646
  }
5794
6647
  const outputs = await this.#refinePhysicalOutputWindows(rechunkPhysicalColumns(columns), estimatedOutputs, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot);
5795
6648
  return {
@@ -5799,12 +6652,19 @@ export class MinnowDatabase {
5799
6652
  totalRows,
5800
6653
  rowIdStart: first.rowIdStart,
5801
6654
  rowIdEndExclusive: last.rowIdEndExclusive,
5802
- logicalOrder: await this.#firstLogicalOrder(sourceSegments),
6655
+ logicalOrder,
5803
6656
  columns,
5804
6657
  outputs,
6658
+ ...(partitions === undefined ? {} : { partitions }),
5805
6659
  };
5806
6660
  }
5807
- async #createMergeCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot) {
6661
+ /**
6662
+ * Plans a merge of the sources into one canonical output. With `partitioning`, the output
6663
+ * is also cut into level-one partitions: each rewritten source partition keeps its rows (and
6664
+ * its logical order) in place, new rows form the tail, and every run is chunked to at most
6665
+ * `partitionRows`, using fractional orders between unchanged neighbours.
6666
+ */
6667
+ async #createMergeCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot, partitioning) {
5808
6668
  const keyColumn = getUniqueKeyColumn(table);
5809
6669
  if (keyColumn === undefined) {
5810
6670
  throw new Error(`Mutation compaction requires a unique key: ${table.name}`);
@@ -5886,6 +6746,9 @@ export class MinnowDatabase {
5886
6746
  const resolved = await this.#resolveMergeOutput(table, describedSegments, keyColumn, memoryBudgetBytes, snapshot);
5887
6747
  const { columns, rowIdSpans, totalRows } = resolved;
5888
6748
  const rowIdEnvelope = rowIdSpanEnvelope(rowIdSpans);
6749
+ const partitions = partitioning === undefined
6750
+ ? undefined
6751
+ : planOutputPartitions(partitioning, describedSegments, resolved.sourceOutputRowStarts, totalRows);
5889
6752
  let outputs = [];
5890
6753
  if (totalRows > 0) {
5891
6754
  let maximumEncodedBytesPerRow = 0;
@@ -5902,12 +6765,16 @@ export class MinnowDatabase {
5902
6765
  throw new Error("Compaction could not estimate an output block size");
5903
6766
  }
5904
6767
  const rowsPerOutput = Math.max(1, Math.min(0xffff_ffff, Math.floor(targetBlockBytes / maximumEncodedBytesPerRow)));
6768
+ // Windows never straddle a partition: each partition's blocks are its own.
5905
6769
  const estimatedOutputs = [];
5906
- for (let rowStart = 0; rowStart < totalRows; rowStart += rowsPerOutput) {
5907
- estimatedOutputs.push({
5908
- rowStart,
5909
- rowCount: Math.min(rowsPerOutput, totalRows - rowStart),
5910
- });
6770
+ for (const region of partitions ?? [{ rowStart: 0, rowCount: totalRows }]) {
6771
+ const regionEnd = region.rowStart + region.rowCount;
6772
+ for (let rowStart = region.rowStart; rowStart < regionEnd; rowStart += rowsPerOutput) {
6773
+ estimatedOutputs.push({
6774
+ rowStart,
6775
+ rowCount: Math.min(rowsPerOutput, regionEnd - rowStart),
6776
+ });
6777
+ }
5911
6778
  }
5912
6779
  outputs = await this.#refinePhysicalOutputWindows(mergePhysicalColumns(columns, describedSegments), estimatedOutputs, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot);
5913
6780
  }
@@ -5924,97 +6791,165 @@ export class MinnowDatabase {
5924
6791
  sourceSegments: describedSegments,
5925
6792
  columns,
5926
6793
  outputs,
6794
+ ...(partitions === undefined ? {} : { partitions }),
5927
6795
  };
5928
6796
  }
6797
+ /**
6798
+ * Replays the source segments' mutations into one canonical output order, in memory that
6799
+ * scales with the deltas rather than with the table.
6800
+ *
6801
+ * Every row of every row-bearing source (base, insert, upsert) gets a slot, numbered in
6802
+ * canonical source order, and the output is the live slots in slot order. A row can only be
6803
+ * referenced later through its key, and only delete, update, and upsert sources reference
6804
+ * keys, so the first pass collects those keys — the touched set — and the replay then tracks
6805
+ * slots for touched keys alone. An untouched row can never be deleted, patched, or replaced:
6806
+ * it passes through as part of a run, one output range per source block rather than one per
6807
+ * row. Memory is O(delta rows + touched rows) plus two bytes per slot.
6808
+ *
6809
+ * The semantics are those of a per-row replay:
6810
+ * - delete: the key's live slot dies; a later insert of the key takes a new slot.
6811
+ * - update: the named columns of the key's live slot come from the update row; the key must
6812
+ * be live.
6813
+ * - upsert: when the key is live, every column of that slot comes from the upsert row and the
6814
+ * upsert row's own slot dies, so the row keeps its position and row ID; otherwise the
6815
+ * upsert row is a new live row.
6816
+ * - insert/base: a new live row; a second live occurrence of a touched key is an error.
6817
+ */
5929
6818
  async #resolveMergeOutput(table, segments, keyColumn, memoryBudgetBytes, snapshot) {
5930
6819
  const plannerMemoryBytes = mergePlannerMemoryBound(table, segments, keyColumn.id);
5931
6820
  if (plannerMemoryBytes > memoryBudgetBytes) {
5932
6821
  throw new CompactionMemoryBudgetError(memoryBudgetBytes, plannerMemoryBytes);
5933
6822
  }
5934
6823
  const columnIndexById = new Map(table.columns.map((column, index) => [column.id, index]));
5935
- const rows = [];
5936
- const rowIndexByKey = new Map();
6824
+ // Pass 1: the keys any delta references, and each delta's keys in row order.
6825
+ const touched = new Set();
6826
+ const deltaKeys = new Map();
6827
+ for (const segment of segments) {
6828
+ if (!mergeSourceReferencesKeys(segment.kind))
6829
+ continue;
6830
+ const keys = [];
6831
+ await this.#forEachMergeSourceKey(segment, keyColumn, snapshot, (value) => {
6832
+ const key = overlayKeyOf(keyColumn.type, value);
6833
+ keys.push(key);
6834
+ touched.add(key);
6835
+ });
6836
+ deltaKeys.set(segment.segmentId, keys);
6837
+ }
6838
+ // Pass 2: replay into slot state.
6839
+ let slotCount = 0;
6840
+ for (const segment of segments) {
6841
+ if (mergeSourceBearsRows(segment.kind))
6842
+ slotCount += segment.rowCount;
6843
+ }
6844
+ const dead = new Uint8Array(slotCount);
6845
+ const patched = new Uint8Array(slotCount);
6846
+ const patches = new Map();
6847
+ const liveSlotByKey = new Map();
6848
+ let slotBase = 0;
5937
6849
  for (const segment of segments) {
5938
6850
  if (segment.kind === "delete") {
5939
- await this.#forEachMergeSourceKey(segment, keyColumn, snapshot, (value) => {
5940
- const token = keyToken(keyColumn.type, value);
5941
- const existingIndex = rowIndexByKey.get(token);
5942
- if (existingIndex !== undefined)
5943
- rows[existingIndex] = undefined;
5944
- rowIndexByKey.delete(token);
5945
- });
6851
+ for (const key of deltaKeys.get(segment.segmentId) ?? []) {
6852
+ const slot = liveSlotByKey.get(key);
6853
+ if (slot === undefined)
6854
+ continue;
6855
+ dead[slot] = 1;
6856
+ patched[slot] = 0;
6857
+ patches.delete(slot);
6858
+ liveSlotByKey.delete(key);
6859
+ }
5946
6860
  continue;
5947
6861
  }
5948
6862
  if (segment.kind === "update") {
5949
- const changedColumnIds = segment.columns
6863
+ const changedColumns = segment.columns
5950
6864
  .map((column) => column.columnId)
5951
- .filter((columnId) => columnId !== keyColumn.id);
5952
- await this.#forEachMergeSourceKey(segment, keyColumn, snapshot, (value, rowIndex) => {
5953
- const token = keyToken(keyColumn.type, value);
5954
- const existingIndex = rowIndexByKey.get(token);
5955
- const existing = existingIndex === undefined ? undefined : rows[existingIndex];
5956
- if (existingIndex === undefined || existing === undefined) {
6865
+ .filter((columnId) => columnId !== keyColumn.id)
6866
+ .map((columnId) => {
6867
+ const columnIndex = columnIndexById.get(columnId);
6868
+ if (columnIndex === undefined) {
6869
+ throw new Error(`Mutation compaction column is missing: ${columnId}`);
6870
+ }
6871
+ return { columnId, columnIndex };
6872
+ });
6873
+ const keys = deltaKeys.get(segment.segmentId) ?? [];
6874
+ for (let rowIndex = 0; rowIndex < keys.length; rowIndex += 1) {
6875
+ const key = keys[rowIndex];
6876
+ const slot = key === undefined ? undefined : liveSlotByKey.get(key);
6877
+ if (slot === undefined) {
5957
6878
  throw new Error(`Update segment references a missing key: ${segment.segmentId}`);
5958
6879
  }
5959
- for (const columnId of changedColumnIds) {
5960
- const columnIndex = columnIndexById.get(columnId);
5961
- if (columnIndex === undefined) {
5962
- throw new Error(`Mutation compaction column is missing: ${columnId}`);
5963
- }
5964
- existing.sources[columnIndex] = mergeSourceAt(segment, columnId, rowIndex);
6880
+ let patch = patches.get(slot);
6881
+ if (patch === undefined) {
6882
+ patch = new Array(table.columns.length).fill(undefined);
6883
+ patches.set(slot, patch);
6884
+ patched[slot] = 1;
5965
6885
  }
5966
- });
6886
+ for (const { columnId, columnIndex } of changedColumns) {
6887
+ patch[columnIndex] = mergeSourceAt(segment, columnId, rowIndex);
6888
+ }
6889
+ }
5967
6890
  continue;
5968
6891
  }
5969
- await this.#forEachMergeSourceKey(segment, keyColumn, snapshot, (value, rowIndex) => {
5970
- const token = keyToken(keyColumn.type, value);
5971
- const existingIndex = rowIndexByKey.get(token);
5972
- const sources = table.columns.map((column) => mergeSourceAt(segment, column.id, rowIndex));
5973
- if (segment.kind === "upsert" && existingIndex !== undefined) {
5974
- const existing = rows[existingIndex];
5975
- if (existing === undefined) {
5976
- throw new Error(`Upsert segment references an invalid row slot: ${segment.segmentId}`);
5977
- }
5978
- rows[existingIndex] = { rowId: existing.rowId, sources };
6892
+ // A row-bearing source: base, insert, or upsert.
6893
+ const base = slotBase;
6894
+ const visit = (key, rowIndex) => {
6895
+ if (!touched.has(key))
6896
+ return;
6897
+ const slot = base + rowIndex;
6898
+ const existing = liveSlotByKey.get(key);
6899
+ if (existing === undefined) {
6900
+ liveSlotByKey.set(key, slot);
5979
6901
  return;
5980
6902
  }
5981
- if (existingIndex !== undefined) {
6903
+ if (segment.kind !== "upsert") {
5982
6904
  throw new Error(`Insert segment contains a duplicate unique key: ${segment.segmentId}`);
5983
6905
  }
5984
- rowIndexByKey.set(token, rows.length);
5985
- rows.push({ rowId: rowIdAt(segment.rowIdSpans, rowIndex), sources });
5986
- });
6906
+ patches.set(existing, table.columns.map((column) => mergeSourceAt(segment, column.id, rowIndex)));
6907
+ patched[existing] = 1;
6908
+ dead[slot] = 1;
6909
+ };
6910
+ const keys = deltaKeys.get(segment.segmentId);
6911
+ if (keys !== undefined) {
6912
+ keys.forEach(visit);
6913
+ }
6914
+ else if (touched.size > 0) {
6915
+ // With nothing referencing keys there is nothing to track: every row passes through.
6916
+ await this.#forEachMergeSourceKey(segment, keyColumn, snapshot, (value, rowIndex) => {
6917
+ visit(overlayKeyOf(keyColumn.type, value), rowIndex);
6918
+ });
6919
+ }
6920
+ slotBase += segment.rowCount;
5987
6921
  }
5988
- const rowIdSpans = [];
5989
- const sourceRangesByColumn = table.columns.map(() => []);
5990
- let totalRows = 0;
5991
- for (const row of rows) {
5992
- if (row === undefined)
6922
+ touched.clear();
6923
+ liveSlotByKey.clear();
6924
+ deltaKeys.clear();
6925
+ // Pass 3: the live slots in slot order, as runs wherever nothing touched them.
6926
+ const output = new MergeOutputBuilder(table.columns);
6927
+ const sourceOutputRowStarts = new Map();
6928
+ slotBase = 0;
6929
+ for (const segment of segments) {
6930
+ if (!mergeSourceBearsRows(segment.kind))
5993
6931
  continue;
5994
- appendRowIdSpan(rowIdSpans, totalRows, row.rowId);
5995
- for (let columnIndex = 0; columnIndex < table.columns.length; columnIndex += 1) {
5996
- const column = table.columns[columnIndex];
5997
- const source = row.sources[columnIndex];
5998
- if (column === undefined || source === undefined) {
5999
- throw new Error("Mutation compaction row is missing a column source");
6000
- }
6001
- const sourceRanges = sourceRangesByColumn[columnIndex];
6002
- if (sourceRanges === undefined)
6003
- throw new Error("Mutation output column is missing");
6004
- appendMergeOutputRange(sourceRanges, totalRows, source);
6932
+ sourceOutputRowStarts.set(segment.segmentId, output.totalRows);
6933
+ let runStart = -1;
6934
+ for (let rowIndex = 0; rowIndex < segment.rowCount; rowIndex += 1) {
6935
+ const slot = slotBase + rowIndex;
6936
+ if (dead[slot] === 1 || patched[slot] === 1) {
6937
+ if (runStart >= 0) {
6938
+ output.appendRun(segment, runStart, rowIndex - runStart);
6939
+ runStart = -1;
6940
+ }
6941
+ if (patched[slot] === 1)
6942
+ output.appendPatchedRow(segment, rowIndex, patches.get(slot));
6943
+ continue;
6944
+ }
6945
+ if (runStart < 0)
6946
+ runStart = rowIndex;
6005
6947
  }
6006
- totalRows += 1;
6948
+ if (runStart >= 0)
6949
+ output.appendRun(segment, runStart, segment.rowCount - runStart);
6950
+ slotBase += segment.rowCount;
6007
6951
  }
6008
- return {
6009
- rowIdSpans,
6010
- columns: table.columns.map((column, columnIndex) => {
6011
- const sourceRanges = sourceRangesByColumn[columnIndex];
6012
- if (sourceRanges === undefined)
6013
- throw new Error("Mutation output column is missing");
6014
- return { columnId: column.id, type: column.type, sourceRanges };
6015
- }),
6016
- totalRows,
6017
- };
6952
+ return { ...output.finish(), sourceOutputRowStarts };
6018
6953
  }
6019
6954
  async #forEachMergeSourceKey(segment, column, snapshot, action) {
6020
6955
  const planned = segment.columns.find((candidate) => candidate.columnId === column.id);
@@ -6264,56 +7199,32 @@ export class MinnowDatabase {
6264
7199
  else {
6265
7200
  if (outputSegmentId === null)
6266
7201
  throw new Error("Compaction output segment ID is missing");
6267
- const desiredOutputSegment = {
6268
- id: outputSegmentId,
6269
- tableId: table.id,
6270
- transactionId: transaction.id,
6271
- rowCount: outputRowCount,
6272
- rowIdStart: rewritePlan.kind === "copy-v1" ? (first?.rowIdStart ?? 0n) : rewritePlan.rowIdStart,
6273
- rowIdEndExclusive: rewritePlan.kind === "copy-v1"
6274
- ? (last?.rowIdEndExclusive ?? 0n)
6275
- : rewritePlan.rowIdEndExclusive,
6276
- columnBlockIds: rewritePlan.kind === "copy-v1"
6277
- ? compactionOutputColumns(table, sourceSegments, job.id)
6278
- : physicalOutputColumns(job.id, rewritePlan),
6279
- kind: rewritePlan.kind === "merge-v1" ? "base" : "insert",
6280
- ...(table.uniqueKeyColumnId === undefined
6281
- ? {}
6282
- : { keyColumnId: table.uniqueKeyColumnId }),
6283
- level: job.targetLevel,
6284
- ...(job.outputPartitionOrdinal === undefined
6285
- ? {}
6286
- : { partitionOrdinal: job.outputPartitionOrdinal }),
6287
- logicalOrder: rewritePlan.kind === "copy-v1"
6288
- ? await this.#firstLogicalOrder(sourceSegments)
6289
- : rewritePlan.logicalOrder,
6290
- ...(rewritePlan.kind === "merge-v1"
6291
- ? { rowIdSpans: structuredClone(rewritePlan.rowIdSpans) }
6292
- : {}),
6293
- createdAt: this.#now().toISOString(),
6294
- };
6295
- const outputSegment = await this.store.getSegment(outputSegmentId);
6296
- if (outputSegment === undefined) {
6297
- await transaction.stageSegment(desiredOutputSegment);
6298
- }
6299
- else if (outputSegment.transactionId === transaction.id) {
6300
- if (!sameCompactionSegment(outputSegment, desiredOutputSegment)) {
6301
- throw new Error(`A resumed compaction segment differs: ${outputSegmentId}`);
6302
- }
6303
- await transaction.stageExistingSegment(outputSegmentId);
6304
- }
6305
- else {
6306
- const owner = await this.store.getTransaction(outputSegment.transactionId);
6307
- if ((owner !== undefined && owner.status !== "aborted") ||
6308
- !sameCompactionSegment(outputSegment, desiredOutputSegment)) {
6309
- throw new Error(`Compaction output segment cannot be adopted: ${outputSegmentId}`);
6310
- }
6311
- const visible = await this.#unprunedManifestContainsAll(expectedOutputIds);
6312
- if (visible) {
6313
- throw new Error(`Compaction output segment is already visible: ${outputSegmentId}`);
6314
- }
6315
- await this.store.removeSegment(outputSegmentId);
6316
- await transaction.stageSegment(desiredOutputSegment);
7202
+ const createdAt = this.#now().toISOString();
7203
+ const desiredOutputSegments = rewritePlan.kind === "copy-v1"
7204
+ ? [
7205
+ {
7206
+ id: outputSegmentId,
7207
+ tableId: table.id,
7208
+ transactionId: transaction.id,
7209
+ rowCount: outputRowCount,
7210
+ rowIdStart: first?.rowIdStart ?? 0n,
7211
+ rowIdEndExclusive: last?.rowIdEndExclusive ?? 0n,
7212
+ columnBlockIds: compactionOutputColumns(table, sourceSegments, job.id),
7213
+ kind: "insert",
7214
+ ...(table.uniqueKeyColumnId === undefined
7215
+ ? {}
7216
+ : { keyColumnId: table.uniqueKeyColumnId }),
7217
+ level: job.targetLevel,
7218
+ ...(job.outputPartitionOrdinal === undefined
7219
+ ? {}
7220
+ : { partitionOrdinal: job.outputPartitionOrdinal }),
7221
+ logicalOrder: await this.#firstLogicalOrder(sourceSegments),
7222
+ createdAt,
7223
+ },
7224
+ ]
7225
+ : compactionOutputSegments(table, job, rewritePlan, transaction.id, createdAt);
7226
+ for (const desiredOutputSegment of desiredOutputSegments) {
7227
+ await this.#stageCompactionOutputSegment(transaction, desiredOutputSegment, expectedOutputIds);
6317
7228
  }
6318
7229
  }
6319
7230
  if (job.state !== "ready") {
@@ -6327,19 +7238,30 @@ export class MinnowDatabase {
6327
7238
  transaction.supersedeBlocks(job.sourceBlockIds);
6328
7239
  transaction.markLogicallyUnchanged();
6329
7240
  let manifest;
6330
- try {
6331
- manifest = await transaction.commit();
6332
- }
6333
- catch (error) {
6334
- if (!(error instanceof WriteConflictError))
6335
- throw error;
7241
+ for (;;) {
7242
+ let publicationConflict;
7243
+ try {
7244
+ manifest = await transaction.commit();
7245
+ break;
7246
+ }
7247
+ catch (error) {
7248
+ if (!(error instanceof WriteConflictError))
7249
+ throw error;
7250
+ publicationConflict = error;
7251
+ }
7252
+ // Publication is logically neutral, so it may follow any number of concurrent data
7253
+ // commits while every source remains visible and in the same logical position. A single
7254
+ // retry is not sufficient: another tab (or this database's write queue) can win the
7255
+ // manifest CAS again between rebase and commit, leaving an otherwise complete job stuck
7256
+ // in `ready` after the last write. Keep rebasing until publication wins or a source
7257
+ // genuinely changes.
6336
7258
  const current = await this.store.getCurrentManifest();
6337
7259
  const currentIds = new Set(current?.blockIds ?? []);
6338
7260
  if (job.sourceBlockIds.some((id) => !currentIds.has(id))) {
6339
7261
  if (transaction.status === "active")
6340
7262
  await transaction.abort();
6341
7263
  job = await this.#abortCompactionJob(job, "Compaction sources changed before publication");
6342
- throw new Error(job.error, { cause: error });
7264
+ throw new Error(job.error, { cause: publicationConflict });
6343
7265
  }
6344
7266
  const rebased = await transaction.rebase();
6345
7267
  try {
@@ -6356,11 +7278,11 @@ export class MinnowDatabase {
6356
7278
  if (transaction.status === "active")
6357
7279
  await transaction.abort();
6358
7280
  job = await this.#abortCompactionJob(job, `Compaction source is no longer visible: ${missingSourceId}`);
6359
- throw new Error(job.error, { cause: error });
7281
+ throw new Error(job.error, { cause: publicationConflict });
6360
7282
  }
6361
7283
  transaction.supersedeBlocks(job.sourceBlockIds);
6362
7284
  transaction.markLogicallyUnchanged();
6363
- manifest = await transaction.commit();
7285
+ await yieldToEventLoop();
6364
7286
  }
6365
7287
  job = await this.#markCompactionPublished(job, manifest.version);
6366
7288
  return compactionProgress(table.name, job, this.#compactionResult(table, job, manifest.version));
@@ -6423,12 +7345,12 @@ export class MinnowDatabase {
6423
7345
  const existing = await this.store.getBlock(outputBlockId);
6424
7346
  let outputBytes;
6425
7347
  if (existing === undefined) {
6426
- outputBytes = await encodePhysicalBlock(built.physical, plan.outputCompression);
7348
+ outputBytes = await this.#encodePreferredBlock(column.columnId, plan.outputCompression, built.physical.bytes.byteLength < GZIP_MINIMUM_INPUT_BYTES, (compression) => encodePhysicalBlock(built.physical, compression));
6427
7349
  }
6428
7350
  else {
6429
7351
  const decoded = await decodePhysicalBlock(existing);
6430
7352
  if (decoded.description.type !== column.type ||
6431
- decoded.description.compression !== plan.outputCompression ||
7353
+ (plan.outputCompression === "raw" && decoded.description.compression !== "raw") ||
6432
7354
  decoded.description.rowCount !== output.rowCount ||
6433
7355
  !sameBytes(decoded.column.bytes, built.physical.bytes)) {
6434
7356
  throw new Error(`A resumed compaction block differs: ${outputBlockId}`);
@@ -6542,7 +7464,17 @@ export class MinnowDatabase {
6542
7464
  throw new Error(`Concurrent segment shares a compaction source block: ${segment.id}`);
6543
7465
  }
6544
7466
  }
6545
- if (job.outputPartitionOrdinal !== undefined) {
7467
+ if (job.outputPartitionOrdinal === undefined &&
7468
+ job.targetLevel === 1 &&
7469
+ plan.kind !== "copy-v1" &&
7470
+ plan.partitions !== undefined) {
7471
+ const table = await this.store.getTable(job.tableId);
7472
+ if (table === undefined)
7473
+ throw new Error(`Compaction table is missing: ${job.tableId}`);
7474
+ await this.#assertPartitionedLevelOneSnapshotOrder(job, plan, table, visibleSegments, transactions);
7475
+ return;
7476
+ }
7477
+ if (job.outputPartitionOrdinal !== undefined) {
6546
7478
  if (plan.kind === "rechunk-v1") {
6547
7479
  await this.#assertLevelTwoSnapshotOrder(job, plan, snapshot, transactions);
6548
7480
  return;
@@ -6559,8 +7491,11 @@ export class MinnowDatabase {
6559
7491
  const visibleById = new Map(visibleSegments.map((segment) => [segment.id, segment]));
6560
7492
  for (const planned of plan.sourceSegments) {
6561
7493
  const actual = visibleById.get(planned.segmentId);
6562
- const owner = actual === undefined ? undefined : transactions.get(actual.transactionId);
6563
- if (actual === undefined || !sameMergeSourceSegment(actual, owner, planned)) {
7494
+ if (actual === undefined) {
7495
+ throw new Error(`Compaction source is no longer visible: ${planned.segmentId}`);
7496
+ }
7497
+ const owner = transactions.get(actual.transactionId);
7498
+ if (!sameMergeSourceSegment(actual, owner, planned)) {
6564
7499
  throw new Error(`Compaction source segment differs from its plan: ${planned.segmentId}`);
6565
7500
  }
6566
7501
  }
@@ -6623,6 +7558,93 @@ export class MinnowDatabase {
6623
7558
  }
6624
7559
  }
6625
7560
  }
7561
+ /**
7562
+ * The partitioned level-one rebase rule, shared by keyed merges and keyless rechunks. The
7563
+ * sources must be exactly as planned. Every partition the plan left alone must still be visible
7564
+ * and unchanged — they are read back from the planning snapshot's manifest, which the job
7565
+ * roots until it ends, so the check needs no record of its own. Every other visible segment
7566
+ * must be level-zero history committed after the latest source and ordered after every
7567
+ * partition the job publishes, so the output slots into the same place relative to the deltas
7568
+ * it did not absorb.
7569
+ */
7570
+ async #assertPartitionedLevelOneSnapshotOrder(job, plan, table, visibleSegments, transactions) {
7571
+ const sourceIds = new Set(job.sourceSegmentIds);
7572
+ const visibleById = new Map(visibleSegments.map((segment) => [segment.id, segment]));
7573
+ const sourceManifest = await this.store.getManifest(job.sourceManifestVersion);
7574
+ if (sourceManifest === undefined || sourceManifest.prunedAt !== undefined) {
7575
+ throw new Error(`Compaction source manifest is unavailable: ${String(job.sourceManifestVersion)}`);
7576
+ }
7577
+ const plannedVisible = await this.#visibleSegmentRecords(table, new Snapshot(this.store, sourceManifest.version, sourceManifest.blockIds));
7578
+ const plannedById = new Map(plannedVisible.map((segment) => [segment.id, segment]));
7579
+ const plannedLayout = table.uniqueKeyColumnId === undefined
7580
+ ? keylessLevelOneLayout(plannedVisible)
7581
+ : keyedLevelOneLayout(plannedVisible);
7582
+ if (plannedLayout === null)
7583
+ throw new Error("Compaction planned layout is no longer valid");
7584
+ let latestSource = null;
7585
+ if (plan.kind === "merge-v1") {
7586
+ for (const planned of plan.sourceSegments) {
7587
+ const actual = visibleById.get(planned.segmentId);
7588
+ if (actual === undefined) {
7589
+ throw new Error(`Compaction source is no longer visible: ${planned.segmentId}`);
7590
+ }
7591
+ const owner = transactions.get(actual.transactionId);
7592
+ if (!sameMergeSourceSegment(actual, owner, planned)) {
7593
+ throw new Error(`Compaction source segment differs from its plan: ${planned.segmentId}`);
7594
+ }
7595
+ }
7596
+ latestSource = plan.sourceSegments[plan.sourceSegments.length - 1] ?? null;
7597
+ }
7598
+ else {
7599
+ for (const id of job.sourceSegmentIds) {
7600
+ const actual = visibleById.get(id);
7601
+ const planned = plannedById.get(id);
7602
+ if (actual === undefined)
7603
+ throw new Error(`Compaction source is no longer visible: ${id}`);
7604
+ if (actual.transactionId !== planned?.transactionId ||
7605
+ !sameCompactionSegment(actual, planned)) {
7606
+ throw new Error(`Compaction source segment differs from its plan: ${id}`);
7607
+ }
7608
+ const tuple = sourceOrderTuple(actual, transactions, "Compaction source");
7609
+ if (latestSource === null || compareMergeSourceOrder(latestSource, tuple) < 0) {
7610
+ latestSource = tuple;
7611
+ }
7612
+ }
7613
+ }
7614
+ if (latestSource === null)
7615
+ throw new Error("Compaction source order is unavailable");
7616
+ const maxOutputOrder = plan.partitions === undefined
7617
+ ? plan.logicalOrder
7618
+ : Math.max(plan.logicalOrder, ...plan.partitions.map((partition) => partition.logicalOrder));
7619
+ const retained = new Map(plannedLayout.partitions
7620
+ .filter((partition) => !sourceIds.has(partition.id))
7621
+ .map((partition) => [partition.id, partition]));
7622
+ for (const segment of visibleSegments) {
7623
+ if (sourceIds.has(segment.id))
7624
+ continue;
7625
+ if ((segment.level ?? 0) === 1) {
7626
+ const planned = retained.get(segment.id);
7627
+ if (planned?.transactionId !== segment.transactionId ||
7628
+ !sameCompactionSegment(segment, planned)) {
7629
+ throw new Error(`Concurrent segment is not a retained partition: ${segment.id}`);
7630
+ }
7631
+ continue;
7632
+ }
7633
+ if ((segment.level ?? 0) !== 0) {
7634
+ throw new Error(`Concurrent segment has an unsupported compaction level: ${segment.id}`);
7635
+ }
7636
+ const tuple = sourceOrderTuple(segment, transactions, "Concurrent compaction segment");
7637
+ if (tuple.logicalOrder <= maxOutputOrder ||
7638
+ compareMergeSourceOrder(latestSource, tuple) >= 0) {
7639
+ throw new Error(`Concurrent segment would reorder compaction output: ${segment.id}`);
7640
+ }
7641
+ }
7642
+ for (const id of retained.keys()) {
7643
+ if (!visibleById.has(id)) {
7644
+ throw new Error(`Retained compaction partition is no longer visible: ${id}`);
7645
+ }
7646
+ }
7647
+ }
6626
7648
  async #assertLevelTwoSnapshotOrder(job, plan, snapshot, transactions) {
6627
7649
  const table = await this.store.getTable(job.tableId);
6628
7650
  if (table === undefined)
@@ -6777,6 +7799,35 @@ export class MinnowDatabase {
6777
7799
  throw error;
6778
7800
  }
6779
7801
  }
7802
+ /**
7803
+ * Stages one output segment, reconciling with what a previous attempt left: the same
7804
+ * segment staged by this transaction is reused, one left by an aborted transaction is
7805
+ * adopted when it matches and was never published, anything else is an error.
7806
+ */
7807
+ async #stageCompactionOutputSegment(transaction, desired, expectedOutputIds) {
7808
+ const existing = await this.store.getSegment(desired.id);
7809
+ if (existing === undefined) {
7810
+ await transaction.stageSegment(desired);
7811
+ return;
7812
+ }
7813
+ if (existing.transactionId === transaction.id) {
7814
+ if (!sameCompactionSegment(existing, desired)) {
7815
+ throw new Error(`A resumed compaction segment differs: ${desired.id}`);
7816
+ }
7817
+ await transaction.stageExistingSegment(desired.id);
7818
+ return;
7819
+ }
7820
+ const owner = await this.store.getTransaction(existing.transactionId);
7821
+ if ((owner !== undefined && owner.status !== "aborted") ||
7822
+ !sameCompactionSegment(existing, desired)) {
7823
+ throw new Error(`Compaction output segment cannot be adopted: ${desired.id}`);
7824
+ }
7825
+ if (await this.#unprunedManifestContainsAll(expectedOutputIds)) {
7826
+ throw new Error(`Compaction output segment is already visible: ${desired.id}`);
7827
+ }
7828
+ await this.store.removeSegment(desired.id);
7829
+ await transaction.stageSegment(desired);
7830
+ }
6780
7831
  async #abortCompactionJob(job, error) {
6781
7832
  return this.store.updateCompactionJob(job.id, job.revision, {
6782
7833
  state: "aborted",
@@ -6803,6 +7854,7 @@ export class MinnowDatabase {
6803
7854
  sourceSegmentCount: job.sourceSegmentIds.length,
6804
7855
  sourceBlockCount: job.sourceBlockIds.length,
6805
7856
  outputSegmentId: job.outputSegmentId,
7857
+ outputSegmentIds: compactionOutputSegmentIds(job),
6806
7858
  outputBlockCount: job.outputBlockIds.length,
6807
7859
  rowCount,
6808
7860
  sourceStoredBytes: job.sourceStoredBytes,
@@ -8062,26 +9114,42 @@ export class MinnowDatabase {
8062
9114
  * bytes and never correctness.
8063
9115
  */
8064
9116
  async #encodeColumnBlock(columnId, input) {
8065
- if (this.#compression !== "gzip")
8066
- return encodeBlock(input, this.#compression);
9117
+ return this.#encodePreferredBlock(columnId, this.#compression, columnInputBytesBelow(input, GZIP_MINIMUM_INPUT_BYTES), (compression) => encodeBlock(input, compression));
9118
+ }
9119
+ /**
9120
+ * Applies the same adaptive gzip rule to ordinary writes and compaction output. `gzip` is a
9121
+ * preference, not a promise: tiny inputs and probes that save less than 20% stay raw. Only a
9122
+ * failed verdict is cached, so successful columns do not leave one map entry behind forever.
9123
+ */
9124
+ async #encodePreferredBlock(columnId, preferred, belowMinimum, encode) {
9125
+ if (preferred !== "gzip")
9126
+ return encode(preferred);
9127
+ if (belowMinimum)
9128
+ return encode("raw");
8067
9129
  const verdict = this.#gzipVerdicts.get(columnId);
8068
- if (verdict !== undefined && !verdict.worthwhile) {
8069
- if (verdict.blocksSince < GZIP_REPROBE_BLOCKS) {
8070
- verdict.blocksSince += 1;
8071
- return encodeBlock(input, "raw");
9130
+ if (verdict !== undefined) {
9131
+ if (verdict < GZIP_REPROBE_BLOCKS) {
9132
+ this.#gzipVerdicts.set(columnId, verdict + 1);
9133
+ return encode("raw");
8072
9134
  }
8073
9135
  this.#gzipVerdicts.delete(columnId);
8074
9136
  }
8075
- const bytes = await encodeBlock(input, "gzip");
9137
+ const bytes = await encode("gzip");
8076
9138
  const description = inspectBlock(bytes);
8077
- if (description.encodedLength >= GZIP_DECISION_MIN_BYTES) {
8078
- const worthwhile = description.encodedLength >= bytes.byteLength * GZIP_WORTHWHILE_RATIO;
8079
- this.#gzipVerdicts.set(columnId, { worthwhile, blocksSince: 0 });
9139
+ const worthwhile = description.encodedLength >= bytes.byteLength * GZIP_WORTHWHILE_RATIO;
9140
+ if (!worthwhile) {
9141
+ if (!this.#gzipVerdicts.has(columnId) &&
9142
+ this.#gzipVerdicts.size >= GZIP_VERDICT_CACHE_LIMIT) {
9143
+ const oldest = this.#gzipVerdicts.keys().next().value;
9144
+ if (oldest !== undefined)
9145
+ this.#gzipVerdicts.delete(oldest);
9146
+ }
9147
+ this.#gzipVerdicts.set(columnId, 0);
8080
9148
  // Nothing was gained, so hand back the uncompressed form rather than make every read of
8081
9149
  // this block pay to inflate it.
8082
- if (!worthwhile)
8083
- return encodeBlock(input, "raw");
9150
+ return encode("raw");
8084
9151
  }
9152
+ this.#gzipVerdicts.delete(columnId);
8085
9153
  return bytes;
8086
9154
  }
8087
9155
  async #findTable(name) {
@@ -8542,6 +9610,22 @@ function batchKeys(table, input) {
8542
9610
  }
8543
9611
  return keys;
8544
9612
  }
9613
+ /**
9614
+ * Whether a column block's logical payload is under `limit` bytes: strings by length (two
9615
+ * bytes a code unit, stopping as soon as the limit is reached), everything else eight bytes a
9616
+ * value. An estimate, for the write path's codec choice — not an encoded size.
9617
+ */
9618
+ function columnInputBytesBelow(input, limit) {
9619
+ if (input.type !== "string")
9620
+ return input.values.length * 8 < limit;
9621
+ let bytes = 0;
9622
+ for (const value of input.values) {
9623
+ bytes += 8 + (value === null ? 0 : value.length * 2);
9624
+ if (bytes >= limit)
9625
+ return false;
9626
+ }
9627
+ return true;
9628
+ }
8545
9629
  /** `keyToken` for values that may not encode: undefined instead of a thrown encoding error. */
8546
9630
  function tryKeyToken(type, value) {
8547
9631
  try {
@@ -8551,6 +9635,34 @@ function tryKeyToken(type, value) {
8551
9635
  return undefined;
8552
9636
  }
8553
9637
  }
9638
+ /**
9639
+ * Whether a table's visible segments warrant a background fold: enough segments for a scan to
9640
+ * pay per-segment overhead, or enough deltas that every query replays a history. Counted in
9641
+ * segments, not rows — a handful of deltas costs little however many rows they hold, and
9642
+ * folding rewrites the table's anchor, so it is reserved for when the count has built up.
9643
+ */
9644
+ /**
9645
+ * Whether a table's visible history warrants a background fold: enough level-zero segments to
9646
+ * fragment a scan, or enough deltas to cost one. Partitions compaction itself published
9647
+ * (level one and above) are the folded state, not fragmentation, and do not count — a large
9648
+ * keyed table is many partitions by design.
9649
+ */
9650
+ function autoCompactionDue(segments) {
9651
+ let levelZero = 0;
9652
+ let deltas = 0;
9653
+ for (const segment of segments) {
9654
+ if ((segment.level ?? 0) === 0)
9655
+ levelZero += 1;
9656
+ const kind = segment.kind ?? "insert";
9657
+ if (kind !== "insert" && kind !== "base")
9658
+ deltas += 1;
9659
+ }
9660
+ return levelZero >= AUTO_COMPACT_SCAN_SEGMENTS || deltas >= AUTO_COMPACT_DELTA_SEGMENTS;
9661
+ }
9662
+ /** A macrotask boundary, so background work lets queued queries and writes run between steps. */
9663
+ function yieldToEventLoop() {
9664
+ return new Promise((resolve) => setTimeout(resolve, 0));
9665
+ }
8554
9666
  function keyToken(type, value) {
8555
9667
  if (value === null)
8556
9668
  throw new TypeError("Unique key cannot be null");
@@ -8578,6 +9690,12 @@ function keyToken(type, value) {
8578
9690
  function formatValue(value) {
8579
9691
  return value instanceof Date ? value.toISOString() : String(value);
8580
9692
  }
9693
+ function nonNegativeWholeNumber(value, name) {
9694
+ if (!Number.isSafeInteger(value) || value < 0) {
9695
+ throw new RangeError(`${name} must be a non-negative whole number`);
9696
+ }
9697
+ return value;
9698
+ }
8581
9699
  function positiveWholeNumber(value, name) {
8582
9700
  if (!Number.isSafeInteger(value) || value <= 0) {
8583
9701
  throw new RangeError(`${name} must be a positive whole number`);
@@ -9087,6 +10205,229 @@ function estimatedColumnarBytes(segments, columns) {
9087
10205
  rowWidth += column.type === "string" ? 32 : 8;
9088
10206
  return rows * Math.max(rowWidth, 1);
9089
10207
  }
10208
+ /** Whether a visible segment is a delete or update delta rather than appended rows. */
10209
+ function mutationSegmentKind(segment) {
10210
+ const kind = segment.kind ?? "insert";
10211
+ return kind !== "insert" && kind !== "base";
10212
+ }
10213
+ const BYTE_POPCOUNT = new Uint8Array(256).map((_, byte) => {
10214
+ let count = 0;
10215
+ for (let value = byte; value !== 0; value &= value - 1)
10216
+ count += 1;
10217
+ return count;
10218
+ });
10219
+ /** Set bits in `bitmap` over bit indexes `[from, to)`. */
10220
+ function bitmapCountRange(bitmap, from, to) {
10221
+ let count = 0;
10222
+ let index = from;
10223
+ while (index < to && (index & 7) !== 0) {
10224
+ if (bitmapHasValue(bitmap, index))
10225
+ count += 1;
10226
+ index += 1;
10227
+ }
10228
+ while (index + 8 <= to) {
10229
+ count += BYTE_POPCOUNT[bitmap[index >>> 3] ?? 0] ?? 0;
10230
+ index += 8;
10231
+ }
10232
+ while (index < to) {
10233
+ if (bitmapHasValue(bitmap, index))
10234
+ count += 1;
10235
+ index += 1;
10236
+ }
10237
+ return count;
10238
+ }
10239
+ /** The first index in ascending `sorted` whose value is at least `value`. */
10240
+ function sortedLowerBound(sorted, value) {
10241
+ let low = 0;
10242
+ let high = sorted.length;
10243
+ while (low < high) {
10244
+ const middle = (low + high) >>> 1;
10245
+ if ((sorted[middle] ?? 0) < value)
10246
+ low = middle + 1;
10247
+ else
10248
+ high = middle;
10249
+ }
10250
+ return low;
10251
+ }
10252
+ /** Members of ascending `sorted` in `[from, to)`. */
10253
+ function sortedCountRange(sorted, from, to) {
10254
+ return sortedLowerBound(sorted, to) - sortedLowerBound(sorted, from);
10255
+ }
10256
+ function overlayWindowRuns(dead, patchedSlots, from, to, patchedInWindow) {
10257
+ const steps = [];
10258
+ let nextPatched = patchedInWindow > 0 ? sortedLowerBound(patchedSlots, from) : -1;
10259
+ let row = from;
10260
+ while (row < to) {
10261
+ // Dead rows, eight at a time where a whole byte is dead.
10262
+ if (bitmapHasValue(dead, row)) {
10263
+ row += 1;
10264
+ while (row < to && (row & 7) === 0 && dead[row >>> 3] === 0xff && row + 8 <= to)
10265
+ row += 8;
10266
+ while (row < to && bitmapHasValue(dead, row))
10267
+ row += 1;
10268
+ continue;
10269
+ }
10270
+ const patchedRow = nextPatched >= 0 ? (patchedSlots[nextPatched] ?? to) : to;
10271
+ if (row === patchedRow) {
10272
+ steps.push(row, 0);
10273
+ row += 1;
10274
+ nextPatched += 1;
10275
+ if (nextPatched >= patchedSlots.length)
10276
+ nextPatched = -1;
10277
+ continue;
10278
+ }
10279
+ // A live run: up to the next patched row, the window end, or the next dead row — live
10280
+ // rows are consecutive except where a delete cut them, and whole live bytes skip in one.
10281
+ const limit = Math.min(to, patchedRow);
10282
+ const runStart = row;
10283
+ row += 1;
10284
+ while (row < limit && (row & 7) === 0 && row + 8 <= limit && dead[row >>> 3] === 0)
10285
+ row += 8;
10286
+ while (row < limit && !bitmapHasValue(dead, row))
10287
+ row += 1;
10288
+ steps.push(runStart, row - runStart);
10289
+ }
10290
+ return steps;
10291
+ }
10292
+ /**
10293
+ * Copies `length` bits from `source` at bit `sourceStart` to `target` at bit `targetStart`,
10294
+ * whole bytes at a time once the target is byte-aligned: the target bytes it overwrites lie
10295
+ * entirely inside the copied range, so the target's other bits are left alone. This is what
10296
+ * makes a validity copy proportional to bytes rather than to cells.
10297
+ */
10298
+ function copyBitRun(source, sourceStart, target, targetStart, length) {
10299
+ let remaining = length;
10300
+ let from = sourceStart;
10301
+ let to = targetStart;
10302
+ while (remaining > 0 && (to & 7) !== 0) {
10303
+ if (bitmapHasValue(source, from))
10304
+ setBitmapValue(target, to);
10305
+ from += 1;
10306
+ to += 1;
10307
+ remaining -= 1;
10308
+ }
10309
+ const shift = from & 7;
10310
+ if (shift === 0) {
10311
+ const bytes = remaining >>> 3;
10312
+ if (bytes > 0) {
10313
+ target.set(source.subarray(from >>> 3, (from >>> 3) + bytes), to >>> 3);
10314
+ from += bytes * 8;
10315
+ to += bytes * 8;
10316
+ remaining -= bytes * 8;
10317
+ }
10318
+ }
10319
+ else {
10320
+ while (remaining >= 8) {
10321
+ const sourceByte = from >>> 3;
10322
+ target[to >>> 3] =
10323
+ (((source[sourceByte] ?? 0) >>> shift) | ((source[sourceByte + 1] ?? 0) << (8 - shift))) &
10324
+ 0xff;
10325
+ from += 8;
10326
+ to += 8;
10327
+ remaining -= 8;
10328
+ }
10329
+ }
10330
+ while (remaining > 0) {
10331
+ if (bitmapHasValue(source, from))
10332
+ setBitmapValue(target, to);
10333
+ from += 1;
10334
+ to += 1;
10335
+ remaining -= 1;
10336
+ }
10337
+ }
10338
+ /**
10339
+ * An outer window that is the inner window's rows from `offset` on, by reference: typed-array
10340
+ * views over the resident block, and its dictionary as-is. Validity is a view too when the
10341
+ * offset falls on a byte, and otherwise the one small copy a bit offset forces.
10342
+ */
10343
+ function overlayWindowView(inner, offset, rows, memory, column, reservations) {
10344
+ let validity;
10345
+ if ((offset & 7) === 0) {
10346
+ validity = inner.validity.subarray(offset >>> 3, (offset >>> 3) + Math.ceil(rows / 8));
10347
+ }
10348
+ else {
10349
+ validity = new Uint8Array(Math.ceil(rows / 8));
10350
+ reservations.push(memory.reserve(validity.byteLength, `Streamed window ${column.name}`));
10351
+ copyBitRun(inner.validity, offset, validity, 0, rows);
10352
+ }
10353
+ const fields = { validity, window: { start: 0, length: rows } };
10354
+ if (inner.kind === "string") {
10355
+ fields.codes = inner.codes.subarray(offset, offset + rows);
10356
+ fields.dictionary = inner.dictionary;
10357
+ }
10358
+ else {
10359
+ fields.values = inner.values.subarray(offset, offset + rows);
10360
+ }
10361
+ return fields;
10362
+ }
10363
+ /**
10364
+ * An outer window compacted from an inner window: live runs copied as slices, patched rows
10365
+ * read from their update vectors. A string window shares the inner dictionary unless a patch
10366
+ * has to add to it, in which case it copies the dictionary first.
10367
+ */
10368
+ function overlayWindowCompacted(inner, innerWindowStart, steps, rows, patches, column, memory, reservations) {
10369
+ const validityBytes = Math.ceil(rows / 8);
10370
+ const typedBytes = validityBytes +
10371
+ (inner.kind === "boolean"
10372
+ ? rows
10373
+ : inner.kind === "string"
10374
+ ? rows * Uint32Array.BYTES_PER_ELEMENT
10375
+ : rows * Float64Array.BYTES_PER_ELEMENT);
10376
+ reservations.push(memory.reserve(typedBytes, `Streamed window ${column.name}`));
10377
+ const validity = new Uint8Array(validityBytes);
10378
+ const values = inner.kind === "boolean"
10379
+ ? new Uint8Array(rows)
10380
+ : inner.kind === "string"
10381
+ ? undefined
10382
+ : new Float64Array(rows);
10383
+ const codes = inner.kind === "string" ? new Uint32Array(rows) : undefined;
10384
+ codes?.fill(NULL_STRING_VECTOR_CODE);
10385
+ let dictionary = inner.kind === "string" ? inner.dictionary : undefined;
10386
+ let dictionaryIndex;
10387
+ let dictionaryCopied = false;
10388
+ const target = (codes !== undefined
10389
+ ? { kind: "string", length: rows, validity, codes, dictionary: dictionary ?? [] }
10390
+ : { kind: inner.kind, length: rows, validity, values });
10391
+ let out = 0;
10392
+ for (let index = 0; index < steps.length; index += 2) {
10393
+ const start = steps[index] ?? 0;
10394
+ const length = steps[index + 1] ?? 0;
10395
+ const patch = length === 0 ? patches?.get(start)?.get(column.id) : undefined;
10396
+ if (patch === undefined) {
10397
+ const count = Math.max(1, length);
10398
+ copyVectorSpan(inner, start - innerWindowStart, count, target, out);
10399
+ out += count;
10400
+ continue;
10401
+ }
10402
+ if (target.kind === "string" && !dictionaryCopied) {
10403
+ // A patch value may be new to this window's dictionary, and the inner's belongs to the
10404
+ // buffer pool: copy before the first append, and index the copy for the lookups.
10405
+ dictionary = [...(dictionary ?? [])];
10406
+ target.dictionary = dictionary;
10407
+ dictionaryIndex = new Map(dictionary.map((value, code) => [value, code]));
10408
+ dictionaryCopied = true;
10409
+ }
10410
+ copyColumnVectorValue(patch.vector, patch.row, target, out, dictionaryIndex);
10411
+ out += 1;
10412
+ }
10413
+ if (out !== rows)
10414
+ throw new Error(`Column row count mismatch: ${column.name}`);
10415
+ if (dictionaryCopied && dictionary !== undefined) {
10416
+ let dictionaryBytes = 0;
10417
+ for (const value of dictionary)
10418
+ dictionaryBytes += 16 + value.length * 2;
10419
+ reservations.push(memory.reserve(dictionaryBytes, `Streamed window ${column.name}`));
10420
+ }
10421
+ const fields = { validity, window: { start: 0, length: rows } };
10422
+ if (codes !== undefined) {
10423
+ fields.codes = codes;
10424
+ fields.dictionary = dictionary ?? [];
10425
+ }
10426
+ else if (values !== undefined) {
10427
+ fields.values = values;
10428
+ }
10429
+ return fields;
10430
+ }
9090
10431
  /**
9091
10432
  * Reads a key column's values as primitives. Dictionary-coded strings resolve through the
9092
10433
  * dictionary the vector already holds, so a string key costs one array index and no encoding.
@@ -9140,9 +10481,10 @@ function touchedKeyPredicate(keyColumn, touched) {
9140
10481
  return { column: keyColumn, operator: "IN", value: members[0] ?? 0, members };
9141
10482
  }
9142
10483
  /**
9143
- * Copies a run of rows between vectors: values as one typed-array slice, validity bit by bit.
10484
+ * Copies a run of rows between vectors: values as one typed-array slice, validity as a bit run.
9144
10485
  * A string run needs `remap` unless both sides share a dictionary — the codes mean nothing on
9145
- * their own. This is what keeps a copy proportional to bytes rather than to cells.
10486
+ * their own. This is what keeps a copy proportional to bytes rather than to cells. The target
10487
+ * validity bits of the run must be clear beforehand, as a fresh window's are.
9146
10488
  */
9147
10489
  function copyVectorSpan(source, sourceStart, length, target, targetStart, remap) {
9148
10490
  if (source.kind === "string") {
@@ -9172,26 +10514,7 @@ function copyVectorSpan(source, sourceStart, length, target, targetStart, remap)
9172
10514
  }
9173
10515
  target.values.set(source.values.subarray(sourceStart, sourceStart + length), targetStart);
9174
10516
  }
9175
- for (let index = 0; index < length; index += 1) {
9176
- if (bitmapHasValue(source.validity, sourceStart + index)) {
9177
- setBitmapValue(target.validity, targetStart + index);
9178
- }
9179
- }
9180
- }
9181
- /** Source dictionary code -> target dictionary code, built once per source window. */
9182
- function remapDictionary(source, targetDictionary, targetIndex) {
9183
- const remap = new Uint32Array(source.length);
9184
- for (let code = 0; code < source.length; code += 1) {
9185
- const value = source[code] ?? "";
9186
- let mapped = targetIndex.get(value);
9187
- if (mapped === undefined) {
9188
- mapped = targetDictionary.length;
9189
- targetDictionary.push(value);
9190
- targetIndex.set(value, mapped);
9191
- }
9192
- remap[code] = mapped;
9193
- }
9194
- return remap;
10517
+ copyBitRun(source.validity, sourceStart, target.validity, targetStart, length);
9195
10518
  }
9196
10519
  /**
9197
10520
  * The live rows of a vector, in order. Runs between deletions copy as typed-array slices and
@@ -9568,13 +10891,356 @@ function sourceOrderTuple(segment, transactions, label) {
9568
10891
  segmentId: segment.id,
9569
10892
  };
9570
10893
  }
10894
+ /** Modeled bytes per referenced key the merge planner and the partition probe hold resident. */
10895
+ const MERGE_PLANNER_KEY_BYTES = 96;
10896
+ function planLinearOutputPartitions(totalRows, partitionRows, firstOrder, nextOrder) {
10897
+ const count = Math.max(1, Math.ceil(totalRows / partitionRows));
10898
+ const orders = fractionalLogicalOrders(firstOrder, nextOrder, count);
10899
+ const partitions = [];
10900
+ for (let index = 0, rowStart = 0; index < count; index += 1) {
10901
+ const rowCount = Math.min(partitionRows, totalRows - rowStart);
10902
+ const logicalOrder = orders[index];
10903
+ if (rowCount <= 0 || logicalOrder === undefined) {
10904
+ throw new Error("Rechunk partition layout is incomplete");
10905
+ }
10906
+ partitions.push({ rowStart, rowCount, logicalOrder });
10907
+ rowStart += rowCount;
10908
+ }
10909
+ return partitions;
10910
+ }
10911
+ /**
10912
+ * Cuts the canonical merged output into the partitions a keyed fold publishes.
10913
+ *
10914
+ * Each rewritten source partition's surviving rows form one region that keeps the partition's
10915
+ * logical order, and so its place among the partitions the fold leaves alone. The rows of the
10916
+ * level-zero sources — the tail — form a region behind every existing partition, or extend
10917
+ * the last partition's region when the fold absorbs them into it. A region is then chunked to
10918
+ * at most `partitionRows` rows per published partition. The first chunk keeps the source
10919
+ * partition's order and every further chunk takes an evenly spaced fractional order before
10920
+ * the unchanged successor;
10921
+ * a fresh tail starts at its earliest source's order. Fractional orders make room independent
10922
+ * of adjacent commit versions, so every output is bounded by `partitionRows`. The order is
10923
+ * stable: a published partition sorts strictly between its neighbours and below every
10924
+ * level-zero segment, so a later fold rewrites it alone without moving a row.
10925
+ */
10926
+ function planOutputPartitions(partitioning, sources, sourceOutputRowStarts, totalRows) {
10927
+ const { partitions, partitionRows, absorbsTail, nextLevelZeroOrder } = partitioning;
10928
+ const startOf = (segmentId) => {
10929
+ const start = sourceOutputRowStarts.get(segmentId);
10930
+ if (start === undefined)
10931
+ throw new Error(`Merge source has no output position: ${segmentId}`);
10932
+ return start;
10933
+ };
10934
+ const levelZeroRowSources = sources.filter((source) => source.level === 0 && mergeSourceBearsRows(source.kind));
10935
+ const tailStart = levelZeroRowSources.length === 0 ? totalRows : startOf(levelZeroRowSources[0]?.segmentId ?? "");
10936
+ const sourceIds = new Set(sources.map((source) => source.segmentId));
10937
+ const sourcedPartitionIndexes = partitions.flatMap((partition, index) => sourceIds.has(partition.id) ? [index] : []);
10938
+ const regions = [];
10939
+ for (const [position, index] of sourcedPartitionIndexes.entries()) {
10940
+ const partition = partitions[index];
10941
+ const order = partition?.logicalOrder;
10942
+ if (partition === undefined || order === undefined) {
10943
+ throw new Error("Partitioned merge source is not a level-one partition");
10944
+ }
10945
+ const nextSourced = sourcedPartitionIndexes[position + 1];
10946
+ const isLast = index === partitions.length - 1;
10947
+ const rowStart = startOf(partition.id);
10948
+ const rowEnd = isLast && absorbsTail
10949
+ ? totalRows
10950
+ : nextSourced === undefined
10951
+ ? tailStart
10952
+ : startOf(partitions[nextSourced]?.id ?? "");
10953
+ const successorOrder = partitions[index + 1]?.logicalOrder ?? nextLevelZeroOrder;
10954
+ regions.push({
10955
+ rowStart,
10956
+ rowCount: rowEnd - rowStart,
10957
+ anchorOrder: order,
10958
+ roomStart: order,
10959
+ roomEnd: successorOrder,
10960
+ preferredOrder: order,
10961
+ });
10962
+ }
10963
+ if (!(absorbsTail && partitions.length > 0)) {
10964
+ const lastOrder = partitions[partitions.length - 1]?.logicalOrder ?? -1;
10965
+ regions.push({
10966
+ rowStart: tailStart,
10967
+ rowCount: totalRows - tailStart,
10968
+ anchorOrder: null,
10969
+ roomStart: lastOrder,
10970
+ roomEnd: nextLevelZeroOrder,
10971
+ preferredOrder: Math.min(...sources.filter((source) => source.level === 0).map((source) => source.logicalOrder)),
10972
+ });
10973
+ }
10974
+ const output = [];
10975
+ for (const region of regions) {
10976
+ if (region.rowCount <= 0)
10977
+ continue;
10978
+ const chunks = Math.max(1, Math.ceil(region.rowCount / partitionRows));
10979
+ const firstOrder = region.anchorOrder ?? region.preferredOrder;
10980
+ if (!validLogicalOrder(firstOrder) ||
10981
+ firstOrder >= region.roomEnd ||
10982
+ (region.anchorOrder === null && firstOrder <= region.roomStart)) {
10983
+ throw new Error("Partitioned merge has no logical-order interval for its output");
10984
+ }
10985
+ const logicalOrders = fractionalLogicalOrders(firstOrder, region.roomEnd, chunks);
10986
+ const baseRows = Math.floor(region.rowCount / chunks);
10987
+ const extraRows = region.rowCount % chunks;
10988
+ let rowStart = region.rowStart;
10989
+ for (let chunk = 0; chunk < chunks; chunk += 1) {
10990
+ const rowCount = baseRows + (chunk < extraRows ? 1 : 0);
10991
+ const logicalOrder = logicalOrders[chunk];
10992
+ if (logicalOrder === undefined)
10993
+ throw new Error("Partition logical order is unavailable");
10994
+ output.push({ rowStart, rowCount, logicalOrder });
10995
+ rowStart += rowCount;
10996
+ }
10997
+ }
10998
+ let coveredRows = 0;
10999
+ for (const [index, partition] of output.entries()) {
11000
+ const previous = output[index - 1];
11001
+ if (partition.rowStart !== coveredRows ||
11002
+ partition.rowCount <= 0 ||
11003
+ partition.logicalOrder >= nextLevelZeroOrder ||
11004
+ (previous !== undefined && previous.logicalOrder >= partition.logicalOrder)) {
11005
+ throw new Error("Partitioned merge produced an invalid partition layout");
11006
+ }
11007
+ coveredRows += partition.rowCount;
11008
+ }
11009
+ if (coveredRows !== totalRows) {
11010
+ throw new Error("Partitioned merge partitions do not cover the merged output");
11011
+ }
11012
+ return output;
11013
+ }
11014
+ /** `count` increasing doubles in [first, upper), retaining `first` exactly. */
11015
+ function fractionalLogicalOrders(first, upper, count) {
11016
+ if (!validLogicalOrder(first) || !Number.isFinite(upper) || upper <= first || count < 1) {
11017
+ throw new Error("Partition logical-order interval is invalid");
11018
+ }
11019
+ const orders = [];
11020
+ for (let index = 0; index < count; index += 1) {
11021
+ const order = index === 0 ? first : first + ((upper - first) * index) / count;
11022
+ const previous = orders[index - 1];
11023
+ if (!validLogicalOrder(order) ||
11024
+ order >= upper ||
11025
+ (previous !== undefined && order <= previous)) {
11026
+ throw new Error("Partition logical-order precision is exhausted");
11027
+ }
11028
+ orders.push(order);
11029
+ }
11030
+ return orders;
11031
+ }
11032
+ /**
11033
+ * The planner's working memory, as `#resolveMergeOutput` allocates it: two bytes per slot, the
11034
+ * touched-key set and live-slot map over the delta keys, one patch array per patched row, one
11035
+ * decoded key block at a time, and the output ranges themselves — which number the source
11036
+ * blocks plus one per patched cell, not one per row. Deliberately generous per element; this
11037
+ * bound is what a caller's `memoryBudgetBytes` is judged against, so it must not be optimistic.
11038
+ */
9571
11039
  function mergePlannerMemoryBound(table, segments, keyColumnId) {
9572
- const candidateRows = safeWholeNumberSum(segments
9573
- .filter((segment) => segment.kind === "insert" || segment.kind === "upsert" || segment.kind === "base")
9574
- .map((segment) => segment.rowCount), "Mutation compaction candidate rows");
9575
- const keyEncodedBytes = safeWholeNumberSum(segments.flatMap((segment) => (segment.columns.find((column) => column.columnId === keyColumnId)?.sourceBlocks ?? []).map((block) => block.encodedBytes)), "Mutation compaction key bytes");
9576
- const rowMetadataBytes = safeWholeNumberProduct(candidateRows, safeWholeNumberSum([256, safeWholeNumberProduct(table.columns.length, 256, "Mutation compaction row cells")], "Mutation compaction row metadata"), "Mutation compaction row metadata");
9577
- return safeWholeNumberSum([rowMetadataBytes, safeWholeNumberProduct(keyEncodedBytes, 4, "Mutation compaction keys")], "Mutation compaction planner memory");
11040
+ const SLOT_BYTES = 2;
11041
+ const KEY_BYTES = MERGE_PLANNER_KEY_BYTES;
11042
+ const PATCH_ROW_BYTES = 64;
11043
+ const PATCH_CELL_BYTES = 48;
11044
+ const RANGE_BYTES = 80;
11045
+ const DECODED_KEY_BLOCK_FACTOR = 4;
11046
+ let slotRows = 0;
11047
+ let deltaKeys = 0;
11048
+ let patchRows = 0;
11049
+ let sourceBlocks = 0;
11050
+ let largestKeyBlockBytes = 0;
11051
+ for (const segment of segments) {
11052
+ if (mergeSourceBearsRows(segment.kind))
11053
+ slotRows += segment.rowCount;
11054
+ if (mergeSourceReferencesKeys(segment.kind))
11055
+ deltaKeys += segment.rowCount;
11056
+ if (segment.kind === "update" || segment.kind === "upsert")
11057
+ patchRows += segment.rowCount;
11058
+ for (const column of segment.columns) {
11059
+ sourceBlocks += column.sourceBlocks.length;
11060
+ if (column.columnId !== keyColumnId)
11061
+ continue;
11062
+ for (const block of column.sourceBlocks) {
11063
+ largestKeyBlockBytes = Math.max(largestKeyBlockBytes, block.encodedBytes);
11064
+ }
11065
+ }
11066
+ }
11067
+ const columns = table.columns.length;
11068
+ return safeWholeNumberSum([
11069
+ safeWholeNumberProduct(slotRows, SLOT_BYTES, "Mutation compaction slots"),
11070
+ safeWholeNumberProduct(deltaKeys, KEY_BYTES, "Mutation compaction keys"),
11071
+ safeWholeNumberProduct(patchRows, safeWholeNumberSum([
11072
+ PATCH_ROW_BYTES,
11073
+ safeWholeNumberProduct(columns, PATCH_CELL_BYTES, "Mutation patch cells"),
11074
+ ], "Mutation compaction patch row"), "Mutation compaction patches"),
11075
+ safeWholeNumberProduct(safeWholeNumberSum([sourceBlocks, safeWholeNumberProduct(patchRows, columns, "Mutation patched cells")], "Mutation compaction ranges"), RANGE_BYTES, "Mutation compaction range bytes"),
11076
+ safeWholeNumberProduct(largestKeyBlockBytes, DECODED_KEY_BLOCK_FACTOR, "Mutation compaction decoded key block"),
11077
+ ], "Mutation compaction planner memory");
11078
+ }
11079
+ /** Whether a source of this kind contributes rows to the merged output. */
11080
+ function mergeSourceBearsRows(kind) {
11081
+ return kind === "insert" || kind === "upsert" || kind === "base";
11082
+ }
11083
+ /** Whether a source of this kind names existing rows by key. */
11084
+ function mergeSourceReferencesKeys(kind) {
11085
+ return kind === "delete" || kind === "update" || kind === "upsert";
11086
+ }
11087
+ /**
11088
+ * A decoded key value as the primitive the overlay replay keys on (`OverlayKey`): equal keys
11089
+ * are equal primitives, and one table's key has one type, so nothing can collide.
11090
+ */
11091
+ function overlayKeyOf(type, value) {
11092
+ if (value === null)
11093
+ throw new TypeError("Unique key cannot be null");
11094
+ switch (type) {
11095
+ case "boolean":
11096
+ if (typeof value !== "boolean")
11097
+ throw new TypeError("Invalid boolean unique key");
11098
+ return value;
11099
+ case "number":
11100
+ if (typeof value !== "number" || !Number.isFinite(value)) {
11101
+ throw new TypeError("Invalid number unique key");
11102
+ }
11103
+ return value;
11104
+ case "string":
11105
+ if (typeof value !== "string")
11106
+ throw new TypeError("Invalid string unique key");
11107
+ return value;
11108
+ case "datetime":
11109
+ if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
11110
+ throw new TypeError("Invalid datetime unique key");
11111
+ }
11112
+ return value.getTime();
11113
+ }
11114
+ }
11115
+ /**
11116
+ * Accumulates the merged output as coalesced row-ID spans and per-column source ranges. A run
11117
+ * of untouched rows appends at most one range per source block it crosses, whatever its
11118
+ * length; a patched row appends one range per column. Adjacent ranges over the same block
11119
+ * merge in place, so the finished plan is proportional to blocks plus patched cells.
11120
+ */
11121
+ class MergeOutputBuilder {
11122
+ #columns;
11123
+ #rowIdSpans = [];
11124
+ #rangesByColumn;
11125
+ #blocksBySegment = new Map();
11126
+ #totalRows = 0;
11127
+ constructor(columns) {
11128
+ this.#columns = columns;
11129
+ this.#rangesByColumn = columns.map(() => []);
11130
+ }
11131
+ /** Output rows appended so far. */
11132
+ get totalRows() {
11133
+ return this.#totalRows;
11134
+ }
11135
+ /** Rows `[rowStart, rowStart + rowCount)` of a row-bearing source, unchanged. */
11136
+ appendRun(segment, rowStart, rowCount) {
11137
+ if (rowCount <= 0)
11138
+ return;
11139
+ this.#appendRowIds(segment, rowStart, rowCount);
11140
+ const blocksByColumn = this.#sourceBlocks(segment);
11141
+ for (let columnIndex = 0; columnIndex < this.#columns.length; columnIndex += 1) {
11142
+ const blocks = blocksByColumn[columnIndex];
11143
+ const ranges = this.#rangesByColumn[columnIndex];
11144
+ if (blocks === undefined || ranges === undefined) {
11145
+ throw new Error("Mutation output column is missing");
11146
+ }
11147
+ let outputRow = this.#totalRows;
11148
+ let remaining = rowCount;
11149
+ let rowIndex = rowStart;
11150
+ while (remaining > 0) {
11151
+ const block = rowRangeAt(blocks, rowIndex);
11152
+ if (block === undefined) {
11153
+ throw new Error(`Mutation source row is missing: ${segment.segmentId}`);
11154
+ }
11155
+ const count = Math.min(remaining, block.rowStart + block.rowCount - rowIndex);
11156
+ appendMergeOutputRange(ranges, outputRow, block.blockId, rowIndex - block.rowStart, count);
11157
+ outputRow += count;
11158
+ rowIndex += count;
11159
+ remaining -= count;
11160
+ }
11161
+ }
11162
+ this.#totalRows += rowCount;
11163
+ }
11164
+ /** One row of a row-bearing source whose columns may come from later mutations. */
11165
+ appendPatchedRow(segment, rowIndex, patch) {
11166
+ this.#appendRowIds(segment, rowIndex, 1);
11167
+ for (let columnIndex = 0; columnIndex < this.#columns.length; columnIndex += 1) {
11168
+ const column = this.#columns[columnIndex];
11169
+ const ranges = this.#rangesByColumn[columnIndex];
11170
+ if (column === undefined || ranges === undefined) {
11171
+ throw new Error("Mutation output column is missing");
11172
+ }
11173
+ const source = patch?.[columnIndex] ?? mergeSourceAt(segment, column.id, rowIndex);
11174
+ appendMergeOutputRange(ranges, this.#totalRows, source.blockId, source.sourceRowIndex, 1);
11175
+ }
11176
+ this.#totalRows += 1;
11177
+ }
11178
+ finish() {
11179
+ return {
11180
+ rowIdSpans: this.#rowIdSpans,
11181
+ columns: this.#columns.map((column, columnIndex) => {
11182
+ const sourceRanges = this.#rangesByColumn[columnIndex];
11183
+ if (sourceRanges === undefined)
11184
+ throw new Error("Mutation output column is missing");
11185
+ return { columnId: column.id, type: column.type, sourceRanges };
11186
+ }),
11187
+ totalRows: this.#totalRows,
11188
+ };
11189
+ }
11190
+ #sourceBlocks(segment) {
11191
+ let blocks = this.#blocksBySegment.get(segment.segmentId);
11192
+ if (blocks === undefined) {
11193
+ blocks = this.#columns.map((column) => {
11194
+ const source = segment.columns.find((candidate) => candidate.columnId === column.id);
11195
+ if (source === undefined) {
11196
+ throw new Error(`Mutation source row is missing: ${segment.segmentId}:${column.id}`);
11197
+ }
11198
+ return source.sourceBlocks;
11199
+ });
11200
+ this.#blocksBySegment.set(segment.segmentId, blocks);
11201
+ }
11202
+ return blocks;
11203
+ }
11204
+ #appendRowIds(segment, rowStart, rowCount) {
11205
+ let outputRow = this.#totalRows;
11206
+ let remaining = rowCount;
11207
+ let rowIndex = rowStart;
11208
+ while (remaining > 0) {
11209
+ const span = rowRangeAt(segment.rowIdSpans, rowIndex);
11210
+ if (span === undefined) {
11211
+ throw new Error(`Mutation source row ID is missing: ${String(rowIndex)}`);
11212
+ }
11213
+ const count = Math.min(remaining, span.rowStart + span.rowCount - rowIndex);
11214
+ appendRowIdSpan(this.#rowIdSpans, outputRow, span.rowIdStart + BigInt(rowIndex - span.rowStart), count);
11215
+ outputRow += count;
11216
+ rowIndex += count;
11217
+ remaining -= count;
11218
+ }
11219
+ }
11220
+ }
11221
+ /** Appends `rowCount` consecutive row IDs from `rowId`, extending the last span when contiguous. */
11222
+ function appendRowIdSpan(spans, rowStart, rowId, rowCount) {
11223
+ const previous = spans[spans.length - 1];
11224
+ if (previous !== undefined &&
11225
+ previous.rowStart + previous.rowCount === rowStart &&
11226
+ previous.rowIdStart + BigInt(previous.rowCount) === rowId) {
11227
+ previous.rowCount += rowCount;
11228
+ }
11229
+ else {
11230
+ spans.push({ rowStart, rowCount, rowIdStart: rowId });
11231
+ }
11232
+ }
11233
+ /** Appends `rowCount` output rows read from one source block, extending the last range when contiguous. */
11234
+ function appendMergeOutputRange(ranges, outputRowStart, sourceBlockId, sourceRowStart, rowCount) {
11235
+ const previous = ranges[ranges.length - 1];
11236
+ if (previous?.sourceBlockId === sourceBlockId &&
11237
+ previous.outputRowStart + previous.rowCount === outputRowStart &&
11238
+ previous.sourceRowStart + previous.rowCount === sourceRowStart) {
11239
+ previous.rowCount += rowCount;
11240
+ }
11241
+ else {
11242
+ ranges.push({ outputRowStart, sourceBlockId, sourceRowStart, rowCount });
11243
+ }
9578
11244
  }
9579
11245
  function mergeSourceAt(segment, columnId, rowIndex) {
9580
11246
  const column = segment.columns.find((candidate) => candidate.columnId === columnId);
@@ -9584,12 +11250,6 @@ function mergeSourceAt(segment, columnId, rowIndex) {
9584
11250
  }
9585
11251
  return { blockId: block.blockId, sourceRowIndex: rowIndex - block.rowStart };
9586
11252
  }
9587
- function rowIdAt(spans, rowIndex) {
9588
- const span = rowRangeAt(spans, rowIndex);
9589
- if (span === undefined)
9590
- throw new Error(`Mutation source row ID is missing: ${String(rowIndex)}`);
9591
- return span.rowIdStart + BigInt(rowIndex - span.rowStart);
9592
- }
9593
11253
  function rowRangeAt(ranges, rowIndex) {
9594
11254
  let low = 0;
9595
11255
  let high = ranges.length - 1;
@@ -9610,17 +11270,6 @@ function rowRangeAt(ranges, rowIndex) {
9610
11270
  }
9611
11271
  return undefined;
9612
11272
  }
9613
- function appendRowIdSpan(spans, rowStart, rowId) {
9614
- const previous = spans[spans.length - 1];
9615
- if (previous !== undefined &&
9616
- previous.rowStart + previous.rowCount === rowStart &&
9617
- previous.rowIdStart + BigInt(previous.rowCount) === rowId) {
9618
- spans[spans.length - 1] = { ...previous, rowCount: previous.rowCount + 1 };
9619
- }
9620
- else {
9621
- spans.push({ rowStart, rowCount: 1, rowIdStart: rowId });
9622
- }
9623
- }
9624
11273
  function rowIdSpanEnvelope(spans) {
9625
11274
  if (spans.length === 0)
9626
11275
  return { start: 0n, endExclusive: 0n };
@@ -9635,22 +11284,6 @@ function rowIdSpanEnvelope(spans) {
9635
11284
  }
9636
11285
  return { start, endExclusive };
9637
11286
  }
9638
- function appendMergeOutputRange(ranges, outputRowStart, source) {
9639
- const previous = ranges[ranges.length - 1];
9640
- if (previous?.sourceBlockId === source.blockId &&
9641
- previous.outputRowStart + previous.rowCount === outputRowStart &&
9642
- previous.sourceRowStart + previous.rowCount === source.sourceRowIndex) {
9643
- ranges[ranges.length - 1] = { ...previous, rowCount: previous.rowCount + 1 };
9644
- }
9645
- else {
9646
- ranges.push({
9647
- outputRowStart,
9648
- sourceBlockId: source.blockId,
9649
- sourceRowStart: source.sourceRowIndex,
9650
- rowCount: 1,
9651
- });
9652
- }
9653
- }
9654
11287
  function validatePhysicalTablePlan(table, plan) {
9655
11288
  if (table.columns.length !== plan.columns.length ||
9656
11289
  table.columns.some((column, index) => {
@@ -9779,12 +11412,108 @@ function physicalOutputBlockId(jobId, outputIndex, columnIndex) {
9779
11412
  function physicalOutputBlockIds(jobId, plan) {
9780
11413
  return plan.outputs.flatMap((_output, outputIndex) => plan.columns.map((_column, columnIndex) => physicalOutputBlockId(jobId, outputIndex, columnIndex)));
9781
11414
  }
9782
- function physicalOutputColumns(jobId, plan) {
11415
+ function physicalOutputColumns(jobId, plan, window = {
11416
+ rowStart: 0,
11417
+ rowCount: plan.totalRows,
11418
+ }) {
11419
+ const windowEnd = window.rowStart + window.rowCount;
9783
11420
  return Object.fromEntries(plan.columns.map((column, columnIndex) => [
9784
11421
  column.columnId,
9785
- plan.outputs.map((_output, outputIndex) => physicalOutputBlockId(jobId, outputIndex, columnIndex)),
11422
+ plan.outputs.flatMap((output, outputIndex) => output.rowStart >= window.rowStart && output.rowStart + output.rowCount <= windowEnd
11423
+ ? [physicalOutputBlockId(jobId, outputIndex, columnIndex)]
11424
+ : []),
9786
11425
  ]));
9787
11426
  }
11427
+ /** The segment ID partition `index` of a partitioned merge publishes under. */
11428
+ function partitionOutputSegmentId(outputSegmentId, index) {
11429
+ return index === 0 ? outputSegmentId : `${outputSegmentId}/${String(index)}`;
11430
+ }
11431
+ /** Every segment a job publishes: one per output partition, or the single output segment. */
11432
+ function compactionOutputSegmentIds(job) {
11433
+ if (job.outputSegmentId === null)
11434
+ return [];
11435
+ const plan = job.rewritePlan;
11436
+ if ((plan?.kind !== "merge-v1" && plan?.kind !== "rechunk-v1") || plan.partitions === undefined) {
11437
+ return [job.outputSegmentId];
11438
+ }
11439
+ const outputSegmentId = job.outputSegmentId;
11440
+ return plan.partitions.map((_partition, index) => partitionOutputSegmentId(outputSegmentId, index));
11441
+ }
11442
+ /**
11443
+ * The segments a physical compaction publishes, with the blocks of the windows each covers.
11444
+ * A partitioned rewrite publishes one level-one segment per planned partition. A merge carries
11445
+ * the slice of its row-ID spans; a rechunk carries the corresponding contiguous interval.
11446
+ */
11447
+ function compactionOutputSegments(table, job, plan, transactionId, createdAt) {
11448
+ const outputSegmentId = job.outputSegmentId;
11449
+ if (outputSegmentId === null)
11450
+ throw new Error("Compaction output segment ID is missing");
11451
+ const keyColumn = table.uniqueKeyColumnId === undefined ? {} : { keyColumnId: table.uniqueKeyColumnId };
11452
+ const partitionOrdinal = job.outputPartitionOrdinal === undefined
11453
+ ? {}
11454
+ : { partitionOrdinal: job.outputPartitionOrdinal };
11455
+ if (plan.partitions !== undefined) {
11456
+ return plan.partitions.map((partition, index) => {
11457
+ const rowIdSpans = plan.kind === "merge-v1"
11458
+ ? sliceRowIdSpans(plan.rowIdSpans, partition.rowStart, partition.rowCount)
11459
+ : undefined;
11460
+ const envelope = rowIdSpans === undefined
11461
+ ? {
11462
+ start: plan.rowIdStart + BigInt(partition.rowStart),
11463
+ endExclusive: plan.rowIdStart + BigInt(partition.rowStart + partition.rowCount),
11464
+ }
11465
+ : rowIdSpanEnvelope(rowIdSpans);
11466
+ return {
11467
+ id: partitionOutputSegmentId(outputSegmentId, index),
11468
+ tableId: table.id,
11469
+ transactionId,
11470
+ rowCount: partition.rowCount,
11471
+ rowIdStart: envelope.start,
11472
+ rowIdEndExclusive: envelope.endExclusive,
11473
+ columnBlockIds: physicalOutputColumns(job.id, plan, partition),
11474
+ kind: plan.kind === "merge-v1" ? "base" : "insert",
11475
+ ...keyColumn,
11476
+ level: job.targetLevel,
11477
+ ...partitionOrdinal,
11478
+ logicalOrder: partition.logicalOrder,
11479
+ ...(rowIdSpans === undefined ? {} : { rowIdSpans }),
11480
+ createdAt,
11481
+ };
11482
+ });
11483
+ }
11484
+ return [
11485
+ {
11486
+ id: outputSegmentId,
11487
+ tableId: table.id,
11488
+ transactionId,
11489
+ rowCount: plan.totalRows,
11490
+ rowIdStart: plan.rowIdStart,
11491
+ rowIdEndExclusive: plan.rowIdEndExclusive,
11492
+ columnBlockIds: physicalOutputColumns(job.id, plan),
11493
+ kind: plan.kind === "merge-v1" ? "base" : "insert",
11494
+ ...keyColumn,
11495
+ level: job.targetLevel,
11496
+ ...partitionOrdinal,
11497
+ logicalOrder: plan.logicalOrder,
11498
+ ...(plan.kind === "merge-v1" ? { rowIdSpans: structuredClone(plan.rowIdSpans) } : {}),
11499
+ createdAt,
11500
+ },
11501
+ ];
11502
+ }
11503
+ /** The spans of output rows `[rowStart, rowStart + rowCount)`, rebased to start at row zero. */
11504
+ function sliceRowIdSpans(spans, rowStart, rowCount) {
11505
+ const sliced = [];
11506
+ const rowEnd = rowStart + rowCount;
11507
+ for (const span of spans) {
11508
+ const spanEnd = span.rowStart + span.rowCount;
11509
+ if (spanEnd <= rowStart || span.rowStart >= rowEnd)
11510
+ continue;
11511
+ const start = Math.max(span.rowStart, rowStart);
11512
+ const end = Math.min(spanEnd, rowEnd);
11513
+ appendRowIdSpan(sliced, start - rowStart, span.rowIdStart + BigInt(start - span.rowStart), end - start);
11514
+ }
11515
+ return sliced;
11516
+ }
9788
11517
  function isActiveCompactionState(state) {
9789
11518
  return state === "planned" || state === "running" || state === "ready";
9790
11519
  }
@@ -9889,6 +11618,9 @@ function garbageCollectionProgress(job) {
9889
11618
  reclaimedBlockCount: job.reclaimedBlockCount,
9890
11619
  retainedBlockCount: job.retainedBlockCount,
9891
11620
  missingBlockCount: job.missingBlockCount,
11621
+ reclaimedTransactionCount: job.reclaimedTransactionCount,
11622
+ retainedTransactionCount: job.retainedTransactionCount,
11623
+ missingTransactionCount: job.missingTransactionCount,
9892
11624
  physicallyReclaimedBytes: job.reclaimedBlockBytes,
9893
11625
  }
9894
11626
  : null;
@@ -9898,9 +11630,14 @@ function garbageCollectionProgress(job) {
9898
11630
  examinedManifestCount: job.cursor.manifestIndex,
9899
11631
  examinedSegmentCount: job.cursor.segmentIndex,
9900
11632
  examinedBlockCount: job.cursor.blockIndex,
11633
+ examinedTransactionCount: job.cursor.transactionIndex,
9901
11634
  result,
9902
11635
  };
9903
11636
  }
11637
+ /** Whether any of the table's triggers for these events fire AFTER the write, staging rows. */
11638
+ function firesAfterTriggers(table, ...events) {
11639
+ return (table.triggers ?? []).some((trigger) => trigger.timing === "after" && events.includes(trigger.event));
11640
+ }
9904
11641
  /** Collects every real table name referenced by a block, its derived sources, or its subqueries. */
9905
11642
  function collectRealTableNames(plan) {
9906
11643
  const names = new Set();
@@ -10090,22 +11827,19 @@ function hasContiguousRowIds(segments) {
10090
11827
  return previous === undefined || previous.rowIdEndExclusive === segment.rowIdStart;
10091
11828
  });
10092
11829
  }
11830
+ function validLogicalOrder(value) {
11831
+ return value !== undefined && Number.isFinite(value) && value >= 0;
11832
+ }
10093
11833
  function appendLevelTwoLayout(segments) {
10094
- let index = 0;
11834
+ const levelOneSegments = levelOnePartitionPrefix(segments);
11835
+ if (levelOneSegments === null ||
11836
+ levelOneSegments.some((segment) => (segment.kind ?? "insert") !== "insert" || segment.rowIdSpans !== undefined)) {
11837
+ return null;
11838
+ }
11839
+ let index = levelOneSegments.length;
10095
11840
  const retainedPrefix = [];
10096
11841
  const levelTwoSegments = [];
10097
- const first = segments[0];
10098
- if (first !== undefined && (first.level ?? 0) === 1) {
10099
- if ((first.kind ?? "insert") !== "insert" ||
10100
- first.rowIdSpans !== undefined ||
10101
- first.partitionOrdinal !== undefined ||
10102
- (first.logicalOrder !== undefined &&
10103
- (!Number.isSafeInteger(first.logicalOrder) || first.logicalOrder < 0))) {
10104
- return null;
10105
- }
10106
- retainedPrefix.push(first);
10107
- index += 1;
10108
- }
11842
+ retainedPrefix.push(...levelOneSegments);
10109
11843
  for (;;) {
10110
11844
  const segment = segments[index];
10111
11845
  if (segment === undefined || (segment.level ?? 0) !== 2)
@@ -10113,8 +11847,7 @@ function appendLevelTwoLayout(segments) {
10113
11847
  if ((segment.kind ?? "insert") !== "insert" ||
10114
11848
  segment.rowIdSpans !== undefined ||
10115
11849
  segment.partitionOrdinal !== levelTwoSegments.length ||
10116
- !Number.isSafeInteger(segment.logicalOrder) ||
10117
- (segment.logicalOrder ?? -1) < 0 ||
11850
+ !validLogicalOrder(segment.logicalOrder) ||
10118
11851
  segment.rowIdEndExclusive - segment.rowIdStart !== BigInt(segment.rowCount)) {
10119
11852
  return null;
10120
11853
  }
@@ -10127,8 +11860,7 @@ function appendLevelTwoLayout(segments) {
10127
11860
  segment.partitionOrdinal !== undefined ||
10128
11861
  (segment.kind ?? "insert") !== "insert" ||
10129
11862
  segment.rowIdSpans !== undefined ||
10130
- (segment.logicalOrder !== undefined &&
10131
- (!Number.isSafeInteger(segment.logicalOrder) || segment.logicalOrder < 0)))) {
11863
+ (segment.logicalOrder !== undefined && !validLogicalOrder(segment.logicalOrder)))) {
10132
11864
  return null;
10133
11865
  }
10134
11866
  const rowIdIntervals = segments
@@ -10148,46 +11880,67 @@ function appendLevelTwoLayout(segments) {
10148
11880
  return { retainedPrefix, levelTwoSegments, level0Segments };
10149
11881
  }
10150
11882
  /**
10151
- * Validates a keyed table's visible history for multi-range L2 promotion: existing partitions
10152
- * (append-shaped inserts or merged bases carrying row-ID spans) with ordinals exactly 0..N-1,
10153
- * then an optional single level-one anchor, then level-zero segments of any mutation kind. Every
10154
- * row footprint a partition's spans or interval, the anchor's, and each level-zero
10155
- * insert/upsert interval must be pairwise disjoint; update and delete deltas carry no
10156
- * footprint. Returns null when the shape does not hold so the planner skips explicitly.
11883
+ * Validates a keyed table's visible history for a partitioned level-one fold: a prefix of
11884
+ * level-one partitions merged bases carrying row-ID spans, or append-shaped inserts — each
11885
+ * with an explicit logical order, strictly increasing along the prefix; then level-zero
11886
+ * segments of any mutation kind. Every row footprint must be pairwise disjoint. Returns null
11887
+ * when the shape does not hold so the planner skips explicitly.
10157
11888
  */
10158
- function keyedLevelTwoLayout(segments) {
10159
- let index = 0;
10160
- const levelTwoSegments = [];
10161
- for (;;) {
10162
- const segment = segments[index];
10163
- if (segment === undefined || (segment.level ?? 0) !== 2)
11889
+ function keyedLevelOneLayout(segments) {
11890
+ const partitions = levelOnePartitionPrefix(segments);
11891
+ if (partitions === null)
11892
+ return null;
11893
+ const level0Segments = segments.slice(partitions.length);
11894
+ if (level0Segments.some((segment) => (segment.level ?? 0) !== 0 || segment.partitionOrdinal !== undefined)) {
11895
+ return null;
11896
+ }
11897
+ if (!disjointRowIdFootprints(segments))
11898
+ return null;
11899
+ return { partitions, level0Segments };
11900
+ }
11901
+ /** The append-only counterpart: bounded L1 partitions followed by contiguous insert deltas. */
11902
+ function keylessLevelOneLayout(segments) {
11903
+ const partitions = levelOnePartitionPrefix(segments);
11904
+ if (partitions === null ||
11905
+ partitions.some((segment) => (segment.kind ?? "insert") !== "insert")) {
11906
+ return null;
11907
+ }
11908
+ const level0Segments = segments.slice(partitions.length);
11909
+ if (level0Segments.some((segment) => (segment.level ?? 0) !== 0 ||
11910
+ segment.partitionOrdinal !== undefined ||
11911
+ (segment.kind ?? "insert") !== "insert" ||
11912
+ segment.rowIdSpans !== undefined) ||
11913
+ !hasContiguousRowIds(segments)) {
11914
+ return null;
11915
+ }
11916
+ return { partitions, level0Segments };
11917
+ }
11918
+ /**
11919
+ * The leading level-one segments, when they form a valid partition prefix: insert or base
11920
+ * kinds, no L2 ordinal, and explicit strictly increasing logical orders. Null otherwise.
11921
+ */
11922
+ function levelOnePartitionPrefix(segments) {
11923
+ const partitions = [];
11924
+ for (const segment of segments) {
11925
+ if ((segment.level ?? 0) !== 1)
10164
11926
  break;
10165
11927
  const kind = segment.kind ?? "insert";
10166
- if (segment.partitionOrdinal !== levelTwoSegments.length ||
10167
- !Number.isSafeInteger(segment.logicalOrder) ||
10168
- (segment.logicalOrder ?? -1) < 0 ||
10169
- (kind !== "insert" && kind !== "base") ||
10170
- (kind === "insert" && segment.rowIdSpans !== undefined) ||
10171
- (kind === "base" && (segment.rowIdSpans?.length ?? 0) === 0)) {
10172
- return null;
10173
- }
10174
- levelTwoSegments.push(segment);
10175
- index += 1;
10176
- }
10177
- let anchor;
10178
- const maybeAnchor = segments[index];
10179
- if (maybeAnchor !== undefined && (maybeAnchor.level ?? 0) === 1) {
10180
- const kind = maybeAnchor.kind ?? "insert";
10181
- if ((kind !== "insert" && kind !== "base") || maybeAnchor.partitionOrdinal !== undefined) {
11928
+ const previousOrder = partitions[partitions.length - 1]?.logicalOrder ?? -1;
11929
+ if ((kind !== "insert" && kind !== "base") ||
11930
+ segment.partitionOrdinal !== undefined ||
11931
+ !validLogicalOrder(segment.logicalOrder) ||
11932
+ (segment.logicalOrder ?? -1) <= previousOrder) {
10182
11933
  return null;
10183
11934
  }
10184
- anchor = maybeAnchor;
10185
- index += 1;
10186
- }
10187
- const level0Segments = segments.slice(index);
10188
- if (level0Segments.some((segment) => (segment.level ?? 0) !== 0 || segment.partitionOrdinal !== undefined)) {
10189
- return null;
11935
+ partitions.push(segment);
10190
11936
  }
11937
+ return partitions;
11938
+ }
11939
+ /**
11940
+ * Whether the segments' row footprints — spans where present, otherwise the contiguous
11941
+ * interval — are positive and pairwise disjoint. Update and delete deltas carry no footprint.
11942
+ */
11943
+ function disjointRowIdFootprints(segments) {
10191
11944
  const intervals = [];
10192
11945
  for (const segment of segments) {
10193
11946
  if (segment.rowIdSpans !== undefined) {
@@ -10199,18 +11952,56 @@ function keyedLevelTwoLayout(segments) {
10199
11952
  if (segment.rowIdEndExclusive <= segment.rowIdStart)
10200
11953
  continue;
10201
11954
  if (segment.rowIdEndExclusive - segment.rowIdStart !== BigInt(segment.rowCount))
10202
- return null;
11955
+ return false;
10203
11956
  intervals.push({ start: segment.rowIdStart, end: segment.rowIdEndExclusive });
10204
11957
  }
10205
11958
  intervals.sort((left, right) => left.start < right.start ? -1 : left.start > right.start ? 1 : 0);
10206
11959
  for (const [intervalIndex, interval] of intervals.entries()) {
10207
11960
  if (interval.start <= 0n)
10208
- return null;
11961
+ return false;
10209
11962
  const previous = intervals[intervalIndex - 1];
10210
11963
  if (previous !== undefined && interval.start < previous.end)
11964
+ return false;
11965
+ }
11966
+ return true;
11967
+ }
11968
+ /**
11969
+ * Validates a keyed table's visible history for multi-range L2 promotion: existing partitions
11970
+ * (append-shaped inserts or merged bases carrying row-ID spans) with ordinals exactly 0..N-1,
11971
+ * then the level-one partitions, then level-zero segments of any mutation kind. Every row
11972
+ * footprint — a partition's spans or interval, the anchors', and each level-zero insert/upsert
11973
+ * interval — must be pairwise disjoint; update and delete deltas carry no footprint. Returns
11974
+ * null when the shape does not hold so the planner skips explicitly.
11975
+ */
11976
+ function keyedLevelTwoLayout(segments) {
11977
+ let index = 0;
11978
+ const levelTwoSegments = [];
11979
+ for (;;) {
11980
+ const segment = segments[index];
11981
+ if (segment === undefined || (segment.level ?? 0) !== 2)
11982
+ break;
11983
+ const kind = segment.kind ?? "insert";
11984
+ if (segment.partitionOrdinal !== levelTwoSegments.length ||
11985
+ !validLogicalOrder(segment.logicalOrder) ||
11986
+ (kind !== "insert" && kind !== "base") ||
11987
+ (kind === "insert" && segment.rowIdSpans !== undefined) ||
11988
+ (kind === "base" && (segment.rowIdSpans?.length ?? 0) === 0)) {
10211
11989
  return null;
11990
+ }
11991
+ levelTwoSegments.push(segment);
11992
+ index += 1;
11993
+ }
11994
+ const anchors = levelOnePartitionPrefix(segments.slice(index));
11995
+ if (anchors === null)
11996
+ return null;
11997
+ index += anchors.length;
11998
+ const level0Segments = segments.slice(index);
11999
+ if (level0Segments.some((segment) => (segment.level ?? 0) !== 0 || segment.partitionOrdinal !== undefined)) {
12000
+ return null;
10212
12001
  }
10213
- return { levelTwoSegments, anchor, level0Segments };
12002
+ if (!disjointRowIdFootprints(segments))
12003
+ return null;
12004
+ return { levelTwoSegments, anchors, level0Segments };
10214
12005
  }
10215
12006
  function compactionWriteAmplificationSkipped(input) {
10216
12007
  return {