@minnowdb/core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/README.md +4 -2
  2. package/dist/block-format/block.d.ts +32 -13
  3. package/dist/block-format/block.js +179 -55
  4. package/dist/block-format/checksum.d.ts +2 -1
  5. package/dist/block-format/checksum.js +5 -2
  6. package/dist/block-format/codecs.d.ts +7 -2
  7. package/dist/block-format/codecs.js +53 -12
  8. package/dist/block-format/column.d.ts +3 -2
  9. package/dist/block-format/column.js +96 -30
  10. package/dist/block-format/index.d.ts +2 -2
  11. package/dist/block-format/index.js +2 -2
  12. package/dist/block-format/physical.d.ts +8 -2
  13. package/dist/block-format/physical.js +14 -6
  14. package/dist/block-format/types.d.ts +7 -3
  15. package/dist/block-format/types.js +0 -1
  16. package/dist/block-format/unicode.d.ts +11 -0
  17. package/dist/block-format/unicode.js +46 -0
  18. package/dist/date-value.d.ts +15 -0
  19. package/dist/date-value.js +64 -0
  20. package/dist/engine/artifact-cache.d.ts +0 -1
  21. package/dist/engine/artifact-cache.js +0 -1
  22. package/dist/engine/batch.d.ts +5 -2
  23. package/dist/engine/batch.js +0 -1
  24. package/dist/engine/buffered-writer.d.ts +2 -1
  25. package/dist/engine/buffered-writer.js +34 -9
  26. package/dist/engine/cache-limits.d.ts +25 -0
  27. package/dist/engine/cache-limits.js +25 -0
  28. package/dist/engine/catalog.d.ts +5 -6
  29. package/dist/engine/catalog.js +5 -5
  30. package/dist/engine/client.d.ts +27 -7
  31. package/dist/engine/client.js +197 -31
  32. package/dist/engine/database.d.ts +161 -38
  33. package/dist/engine/database.js +4320 -1523
  34. package/dist/engine/defaults.d.ts +0 -1
  35. package/dist/engine/defaults.js +2 -4
  36. package/dist/engine/errors.d.ts +19 -1
  37. package/dist/engine/errors.js +32 -2
  38. package/dist/engine/fts.d.ts +0 -6
  39. package/dist/engine/fts.js +39 -14
  40. package/dist/engine/group-index.d.ts +0 -1
  41. package/dist/engine/group-index.js +6 -11
  42. package/dist/engine/index.d.ts +16 -11
  43. package/dist/engine/index.js +12 -10
  44. package/dist/engine/join-index.d.ts +0 -1
  45. package/dist/engine/join-index.js +2 -2
  46. package/dist/engine/keyed-live.d.ts +57 -0
  47. package/dist/engine/keyed-live.js +226 -0
  48. package/dist/engine/live-api.d.ts +4 -0
  49. package/dist/engine/live-api.js +4 -0
  50. package/dist/engine/live.d.ts +43 -26
  51. package/dist/engine/live.js +499 -129
  52. package/dist/engine/memory.d.ts +2 -1
  53. package/dist/engine/memory.js +3 -2
  54. package/dist/engine/optimizer.d.ts +0 -1
  55. package/dist/engine/optimizer.js +4 -4
  56. package/dist/engine/query-api.d.ts +3 -0
  57. package/dist/engine/query-api.js +3 -0
  58. package/dist/engine/query-cache.d.ts +0 -1
  59. package/dist/engine/query-cache.js +4 -4
  60. package/dist/engine/query.d.ts +4 -7
  61. package/dist/engine/query.js +348 -155
  62. package/dist/engine/result-wire.d.ts +0 -1
  63. package/dist/engine/result-wire.js +2 -2
  64. package/dist/engine/schema-wire.d.ts +1 -2
  65. package/dist/engine/schema-wire.js +0 -1
  66. package/dist/engine/schema.d.ts +3 -8
  67. package/dist/engine/schema.js +41 -24
  68. package/dist/engine/sort-keys.d.ts +0 -1
  69. package/dist/engine/sort-keys.js +2 -2
  70. package/dist/engine/sql-domains.d.ts +6 -1
  71. package/dist/engine/sql-domains.js +209 -45
  72. package/dist/engine/sql-driver.d.ts +2 -2
  73. package/dist/engine/sql-driver.js +0 -1
  74. package/dist/engine/sql-json.d.ts +0 -1
  75. package/dist/engine/sql-json.js +53 -7
  76. package/dist/engine/sql-semantics.d.ts +8 -5
  77. package/dist/engine/sql-semantics.js +427 -65
  78. package/dist/engine/typed-live.d.ts +67 -0
  79. package/dist/engine/typed-live.js +349 -0
  80. package/dist/engine/vector.d.ts +7 -1
  81. package/dist/engine/vector.js +202 -58
  82. package/dist/engine/worker-host.d.ts +8 -2
  83. package/dist/engine/worker-host.js +616 -152
  84. package/dist/engine/worker.d.ts +0 -1
  85. package/dist/engine/worker.js +3 -3
  86. package/dist/engine/write-block-planner.d.ts +18 -0
  87. package/dist/engine/write-block-planner.js +112 -0
  88. package/dist/index.d.ts +0 -1
  89. package/dist/index.js +0 -1
  90. package/dist/plan/index.d.ts +0 -1
  91. package/dist/plan/index.js +0 -1
  92. package/dist/storage/index.d.ts +1 -1
  93. package/dist/storage/index.js +1 -1
  94. package/dist/storage/indexeddb.d.ts +74 -65
  95. package/dist/storage/indexeddb.js +12301 -2076
  96. package/dist/storage/memory.d.ts +56 -66
  97. package/dist/storage/memory.js +1004 -139
  98. package/dist/storage/opfs/files.d.ts +18 -7
  99. package/dist/storage/opfs/files.js +115 -28
  100. package/dist/storage/opfs/index.d.ts +1 -1
  101. package/dist/storage/opfs/index.js +1 -1
  102. package/dist/storage/opfs/leader.d.ts +403 -91
  103. package/dist/storage/opfs/leader.js +4306 -602
  104. package/dist/storage/opfs/rpc.d.ts +15 -3
  105. package/dist/storage/opfs/rpc.js +223 -2
  106. package/dist/storage/opfs/snapshot-ledger.d.ts +40 -0
  107. package/dist/storage/opfs/snapshot-ledger.js +281 -0
  108. package/dist/storage/opfs/store.d.ts +26 -121
  109. package/dist/storage/opfs/store.js +436 -388
  110. package/dist/storage/persistence.d.ts +37 -0
  111. package/dist/storage/persistence.js +78 -0
  112. package/dist/storage/snapshot-stream.d.ts +24 -0
  113. package/dist/storage/snapshot-stream.js +897 -0
  114. package/dist/storage/snapshot.d.ts +9 -99
  115. package/dist/storage/snapshot.js +33 -309
  116. package/dist/storage/toolkit/extents.d.ts +41 -7
  117. package/dist/storage/toolkit/extents.js +402 -39
  118. package/dist/storage/toolkit/index.d.ts +3 -4
  119. package/dist/storage/toolkit/index.js +27 -3
  120. package/dist/storage/toolkit/record-core.d.ts +174 -61
  121. package/dist/storage/toolkit/record-core.js +5401 -849
  122. package/dist/storage/toolkit/sync-file.d.ts +10 -1
  123. package/dist/storage/toolkit/sync-file.js +50 -2
  124. package/dist/storage/toolkit/wal.d.ts +23 -5
  125. package/dist/storage/toolkit/wal.js +89 -27
  126. package/dist/storage/toolkit/wire.d.ts +4 -6
  127. package/dist/storage/toolkit/wire.js +80 -25
  128. package/dist/storage/types.d.ts +1271 -218
  129. package/dist/storage/types.js +1471 -138
  130. package/dist/testing/block-store-conformance.d.ts +0 -1
  131. package/dist/testing/block-store-conformance.js +877 -138
  132. package/dist/testing/index.d.ts +42 -31
  133. package/dist/testing/index.js +100 -68
  134. package/dist/testing/opfs-shim.d.ts +13 -3
  135. package/dist/testing/opfs-shim.js +40 -6
  136. package/dist/testing/simulator.d.ts +0 -1
  137. package/dist/testing/simulator.js +43 -12
  138. package/dist/testing/sqllogictest.d.ts +0 -1
  139. package/dist/testing/sqllogictest.js +0 -1
  140. package/dist/transactions/index.d.ts +81 -20
  141. package/dist/transactions/index.js +1050 -268
  142. package/dist/worker-protocol/index.d.ts +3 -2
  143. package/dist/worker-protocol/index.js +2 -5
  144. package/package.json +49 -1
  145. package/dist/block-format/block.d.ts.map +0 -1
  146. package/dist/block-format/block.js.map +0 -1
  147. package/dist/block-format/checksum.d.ts.map +0 -1
  148. package/dist/block-format/checksum.js.map +0 -1
  149. package/dist/block-format/codecs.d.ts.map +0 -1
  150. package/dist/block-format/codecs.js.map +0 -1
  151. package/dist/block-format/column.d.ts.map +0 -1
  152. package/dist/block-format/column.js.map +0 -1
  153. package/dist/block-format/index.d.ts.map +0 -1
  154. package/dist/block-format/index.js.map +0 -1
  155. package/dist/block-format/physical.d.ts.map +0 -1
  156. package/dist/block-format/physical.js.map +0 -1
  157. package/dist/block-format/types.d.ts.map +0 -1
  158. package/dist/block-format/types.js.map +0 -1
  159. package/dist/engine/artifact-cache.d.ts.map +0 -1
  160. package/dist/engine/artifact-cache.js.map +0 -1
  161. package/dist/engine/batch.d.ts.map +0 -1
  162. package/dist/engine/batch.js.map +0 -1
  163. package/dist/engine/buffered-writer.d.ts.map +0 -1
  164. package/dist/engine/buffered-writer.js.map +0 -1
  165. package/dist/engine/catalog.d.ts.map +0 -1
  166. package/dist/engine/catalog.js.map +0 -1
  167. package/dist/engine/client.d.ts.map +0 -1
  168. package/dist/engine/client.js.map +0 -1
  169. package/dist/engine/coordinator.d.ts +0 -17
  170. package/dist/engine/coordinator.d.ts.map +0 -1
  171. package/dist/engine/coordinator.js +0 -60
  172. package/dist/engine/coordinator.js.map +0 -1
  173. package/dist/engine/database.d.ts.map +0 -1
  174. package/dist/engine/database.js.map +0 -1
  175. package/dist/engine/defaults.d.ts.map +0 -1
  176. package/dist/engine/defaults.js.map +0 -1
  177. package/dist/engine/errors.d.ts.map +0 -1
  178. package/dist/engine/errors.js.map +0 -1
  179. package/dist/engine/fts.d.ts.map +0 -1
  180. package/dist/engine/fts.js.map +0 -1
  181. package/dist/engine/group-index.d.ts.map +0 -1
  182. package/dist/engine/group-index.js.map +0 -1
  183. package/dist/engine/index.d.ts.map +0 -1
  184. package/dist/engine/index.js.map +0 -1
  185. package/dist/engine/join-index.d.ts.map +0 -1
  186. package/dist/engine/join-index.js.map +0 -1
  187. package/dist/engine/live.d.ts.map +0 -1
  188. package/dist/engine/live.js.map +0 -1
  189. package/dist/engine/memory.d.ts.map +0 -1
  190. package/dist/engine/memory.js.map +0 -1
  191. package/dist/engine/optimizer.d.ts.map +0 -1
  192. package/dist/engine/optimizer.js.map +0 -1
  193. package/dist/engine/query-cache.d.ts.map +0 -1
  194. package/dist/engine/query-cache.js.map +0 -1
  195. package/dist/engine/query.d.ts.map +0 -1
  196. package/dist/engine/query.js.map +0 -1
  197. package/dist/engine/result-wire.d.ts.map +0 -1
  198. package/dist/engine/result-wire.js.map +0 -1
  199. package/dist/engine/schema-wire.d.ts.map +0 -1
  200. package/dist/engine/schema-wire.js.map +0 -1
  201. package/dist/engine/schema.d.ts.map +0 -1
  202. package/dist/engine/schema.js.map +0 -1
  203. package/dist/engine/sort-keys.d.ts.map +0 -1
  204. package/dist/engine/sort-keys.js.map +0 -1
  205. package/dist/engine/sql-domains.d.ts.map +0 -1
  206. package/dist/engine/sql-domains.js.map +0 -1
  207. package/dist/engine/sql-driver.d.ts.map +0 -1
  208. package/dist/engine/sql-driver.js.map +0 -1
  209. package/dist/engine/sql-json.d.ts.map +0 -1
  210. package/dist/engine/sql-json.js.map +0 -1
  211. package/dist/engine/sql-semantics.d.ts.map +0 -1
  212. package/dist/engine/sql-semantics.js.map +0 -1
  213. package/dist/engine/vector.d.ts.map +0 -1
  214. package/dist/engine/vector.js.map +0 -1
  215. package/dist/engine/worker-host.d.ts.map +0 -1
  216. package/dist/engine/worker-host.js.map +0 -1
  217. package/dist/engine/worker.d.ts.map +0 -1
  218. package/dist/engine/worker.js.map +0 -1
  219. package/dist/index.d.ts.map +0 -1
  220. package/dist/index.js.map +0 -1
  221. package/dist/plan/index.d.ts.map +0 -1
  222. package/dist/plan/index.js.map +0 -1
  223. package/dist/storage/fixture-shape.d.ts +0 -42
  224. package/dist/storage/fixture-shape.d.ts.map +0 -1
  225. package/dist/storage/fixture-shape.js +0 -146
  226. package/dist/storage/fixture-shape.js.map +0 -1
  227. package/dist/storage/index.d.ts.map +0 -1
  228. package/dist/storage/index.js.map +0 -1
  229. package/dist/storage/indexeddb.d.ts.map +0 -1
  230. package/dist/storage/indexeddb.js.map +0 -1
  231. package/dist/storage/memory.d.ts.map +0 -1
  232. package/dist/storage/memory.js.map +0 -1
  233. package/dist/storage/opfs/files.d.ts.map +0 -1
  234. package/dist/storage/opfs/files.js.map +0 -1
  235. package/dist/storage/opfs/index.d.ts.map +0 -1
  236. package/dist/storage/opfs/index.js.map +0 -1
  237. package/dist/storage/opfs/leader.d.ts.map +0 -1
  238. package/dist/storage/opfs/leader.js.map +0 -1
  239. package/dist/storage/opfs/rpc.d.ts.map +0 -1
  240. package/dist/storage/opfs/rpc.js.map +0 -1
  241. package/dist/storage/opfs/store.d.ts.map +0 -1
  242. package/dist/storage/opfs/store.js.map +0 -1
  243. package/dist/storage/snapshot.d.ts.map +0 -1
  244. package/dist/storage/snapshot.js.map +0 -1
  245. package/dist/storage/toolkit/extents.d.ts.map +0 -1
  246. package/dist/storage/toolkit/extents.js.map +0 -1
  247. package/dist/storage/toolkit/index.d.ts.map +0 -1
  248. package/dist/storage/toolkit/index.js.map +0 -1
  249. package/dist/storage/toolkit/record-core.d.ts.map +0 -1
  250. package/dist/storage/toolkit/record-core.js.map +0 -1
  251. package/dist/storage/toolkit/sync-file.d.ts.map +0 -1
  252. package/dist/storage/toolkit/sync-file.js.map +0 -1
  253. package/dist/storage/toolkit/wal.d.ts.map +0 -1
  254. package/dist/storage/toolkit/wal.js.map +0 -1
  255. package/dist/storage/toolkit/wire.d.ts.map +0 -1
  256. package/dist/storage/toolkit/wire.js.map +0 -1
  257. package/dist/storage/types.d.ts.map +0 -1
  258. package/dist/storage/types.js.map +0 -1
  259. package/dist/testing/block-store-conformance.d.ts.map +0 -1
  260. package/dist/testing/block-store-conformance.js.map +0 -1
  261. package/dist/testing/index.d.ts.map +0 -1
  262. package/dist/testing/index.js.map +0 -1
  263. package/dist/testing/opfs-shim.d.ts.map +0 -1
  264. package/dist/testing/opfs-shim.js.map +0 -1
  265. package/dist/testing/seeds.d.ts +0 -11
  266. package/dist/testing/seeds.d.ts.map +0 -1
  267. package/dist/testing/seeds.js +0 -50
  268. package/dist/testing/seeds.js.map +0 -1
  269. package/dist/testing/simulator.d.ts.map +0 -1
  270. package/dist/testing/simulator.js.map +0 -1
  271. package/dist/testing/sqllogictest.d.ts.map +0 -1
  272. package/dist/testing/sqllogictest.js.map +0 -1
  273. package/dist/transactions/index.d.ts.map +0 -1
  274. package/dist/transactions/index.js.map +0 -1
  275. package/dist/worker-protocol/index.d.ts.map +0 -1
  276. package/dist/worker-protocol/index.js.map +0 -1
@@ -1,4 +1,7 @@
1
- import { GarbageCollectionJobConflictError, LeaseConflictError, SnapshotManifestMissingError, TransactionRecordConflictError, WriteConflictError, } from "../storage/index.js";
1
+ import { dateIsoString, dateMilliseconds } from "../date-value.js";
2
+ import { assertTransactionArtifactBatchLimits, assertTransactionArtifactJournalLimits, GarbageCollectionJobConflictError, LeaseConflictError, LeaseExpiredError, MAX_LEVEL_ZERO_SEGMENTS, MAX_MANIFEST_BLOCK_PRESENCE_IDS, MAX_LEASE_TTL_MS, MAX_TRANSACTION_STAGE_BLOCKS, MAX_TRANSACTION_STAGE_BYTES, MAX_TRANSACTION_STAGE_SEGMENTS, MAX_TRANSACTION_COMMIT_DELTA_BYTES, MAX_TRANSACTION_COMMIT_DELTA_ENTRIES, transactionCommitDeltaRetainedBytes, SnapshotManifestMissingError, TransactionRecordConflictError, WriteConflictError, } from "../storage/types.js";
3
+ export const DEFAULT_TRANSACTION_TTL_MS = 30_000;
4
+ export { MAX_LEASE_TTL_MS };
2
5
  export class TransactionClosedError extends Error {
3
6
  transactionId;
4
7
  name = "TransactionClosedError";
@@ -10,30 +13,30 @@ export class TransactionClosedError extends Error {
10
13
  export class Snapshot {
11
14
  store;
12
15
  version;
13
- #blockIds;
14
- constructor(store, version, blockIds) {
16
+ constructor(store, version) {
15
17
  this.store = store;
16
18
  this.version = version;
17
- this.#blockIds = new Set(blockIds);
18
19
  }
19
- listBlockIds() {
20
- return [...this.#blockIds].sort();
20
+ async hasBlock(id) {
21
+ return (await this.store.hasManifestBlocks(this.version, [id]))[0] === true;
21
22
  }
22
- hasBlock(id) {
23
- return this.#blockIds.has(id);
23
+ async hasBlocks(ids) {
24
+ const present = [];
25
+ for (let start = 0; start < ids.length; start += MAX_MANIFEST_BLOCK_PRESENCE_IDS) {
26
+ present.push(...(await this.store.hasManifestBlocks(this.version, ids.slice(start, start + MAX_MANIFEST_BLOCK_PRESENCE_IDS))));
27
+ }
28
+ return present;
24
29
  }
25
30
  async getBlock(id) {
26
- if (!this.#blockIds.has(id))
27
- return undefined;
28
- return this.store.getBlock(id);
31
+ return this.store.readManifestBlock(this.version, id);
29
32
  }
30
33
  }
31
34
  export class LeasedSnapshot extends Snapshot {
32
35
  now;
33
36
  #record;
34
37
  #released = false;
35
- constructor(store, version, blockIds, record, now) {
36
- super(store, version, blockIds);
38
+ constructor(store, version, record, now) {
39
+ super(store, version);
37
40
  this.now = now;
38
41
  this.#record = structuredClone(record);
39
42
  this.#store = store;
@@ -51,9 +54,14 @@ export class LeasedSnapshot extends Snapshot {
51
54
  }
52
55
  async renew(ttlMs) {
53
56
  this.#assertOpen();
54
- validateTtl(ttlMs);
55
- const expiresAt = new Date(this.now().getTime() + ttlMs).toISOString();
56
- this.#record = await this.#store.renewLease(this.#record.id, this.#record.revision, expiresAt);
57
+ const now = this.now();
58
+ const expiresAt = expiryAfter(now, ttlMs);
59
+ this.#record = await this.#store.renewLease({
60
+ id: this.#record.id,
61
+ expectedRevision: this.#record.revision,
62
+ expiresAtCutoff: dateIsoString(now),
63
+ expiresAt,
64
+ });
57
65
  return new Date(this.#record.expiresAt);
58
66
  }
59
67
  /**
@@ -63,24 +71,27 @@ export class LeasedSnapshot extends Snapshot {
63
71
  * (`SnapshotManifestMissingError`, `LeaseConflictError`) leave this snapshot open and pass
64
72
  * through.
65
73
  */
66
- async moveTo(version, ttlMs, knownBlockIds) {
74
+ async moveTo(version, ttlMs) {
67
75
  this.#assertOpen();
68
- validateTtl(ttlMs);
69
76
  const move = this.#store.moveLease?.bind(this.#store);
70
77
  if (move === undefined)
71
78
  return undefined;
72
- const target = knownBlockIds === undefined
73
- ? await loadSnapshot(this.#store, version)
74
- : new Snapshot(this.#store, version, knownBlockIds);
75
- const expiresAt = new Date(this.now().getTime() + ttlMs).toISOString();
76
- const record = await move(this.#record.id, this.#record.revision, version, expiresAt);
79
+ const now = this.now();
80
+ const expiresAt = expiryAfter(now, ttlMs);
81
+ const record = await move({
82
+ id: this.#record.id,
83
+ expectedRevision: this.#record.revision,
84
+ manifestVersion: version,
85
+ expiresAtCutoff: dateIsoString(now),
86
+ expiresAt,
87
+ });
77
88
  this.#released = true;
78
- return new LeasedSnapshot(this.#store, version, target.listBlockIds(), record, this.now);
89
+ return new LeasedSnapshot(this.#store, version, record, this.now);
79
90
  }
80
91
  async release() {
81
92
  if (this.#released)
82
93
  return;
83
- await this.#store.removeLease(this.#record.id);
94
+ await this.#store.removeLease({ id: this.#record.id, ownerId: this.#record.ownerId });
84
95
  this.#released = true;
85
96
  }
86
97
  /** Closes this snapshot without touching the store — for a lease the store no longer holds. */
@@ -95,12 +106,27 @@ export class LeasedSnapshot extends Snapshot {
95
106
  export class DatabaseTransaction {
96
107
  store;
97
108
  now;
109
+ ttlMs;
98
110
  #record;
99
111
  #uniqueKeyChanges = [];
100
- #ftsChanges = [];
101
- #supersededBlockIds = new Set();
112
+ #ftsChanges = new Map();
113
+ #commitDeltaBytes = 0;
114
+ #commitDeltaEntries = 0;
115
+ #compactionJobId = null;
116
+ #compactionSourceBlockIds = new Set();
102
117
  #changedTableIds = new Set();
118
+ #levelZeroSegmentLimits = new Map();
103
119
  #logicallyUnchanged = false;
120
+ /**
121
+ * One storage-sized artifact batch may stay process-local until commit. This lets the common
122
+ * one-statement transaction use the store's atomic writeTransaction operation instead of
123
+ * staging a durable journal and committing it in a second adapter transaction. A second batch
124
+ * flushes this one first, so retained bytes remain bounded by the storage batch limits.
125
+ */
126
+ #deferredBlocks = [];
127
+ #deferredSegments = [];
128
+ /** Segment metadata retained for local post-commit catalog-cache advancement. */
129
+ #knownSegments = new Map();
104
130
  /**
105
131
  * Whether the record exists in the store. A deferred transaction (`beginDeferred`) starts
106
132
  * without one: it is written on the first two-step staging call, or folded into the
@@ -108,11 +134,33 @@ export class DatabaseTransaction {
108
134
  * touches nothing.
109
135
  */
110
136
  #persisted;
111
- constructor(store, record, now, options = {}) {
137
+ /** Durable pin for a deferred transaction that has no transaction record yet. */
138
+ #snapshotLease;
139
+ /** Serializes revisioned operations against the one lease record. */
140
+ #snapshotLeaseOperation = Promise.resolve();
141
+ #snapshotRenewal;
142
+ #heartbeatTimer;
143
+ #renewal;
144
+ #initialCatalogProbe;
145
+ constructor(store, record, now, ttlMs, createWeakRef, options = {}) {
112
146
  this.store = store;
113
147
  this.now = now;
148
+ this.ttlMs = ttlMs;
114
149
  this.#record = structuredClone(record);
115
150
  this.#persisted = options.persisted ?? true;
151
+ this.#initialCatalogProbe = options.initialCatalogProbe;
152
+ this.#snapshotLease = options.snapshotLease;
153
+ const reference = createWeakRef(this);
154
+ const timer = setInterval(() => {
155
+ const transaction = reference.deref();
156
+ if (transaction === undefined) {
157
+ clearInterval(timer);
158
+ return;
159
+ }
160
+ void transaction.#renewOwnership(true).catch(() => undefined);
161
+ }, Math.max(1, Math.floor(ttlMs / 3)));
162
+ timer.unref?.();
163
+ this.#heartbeatTimer = timer;
116
164
  }
117
165
  get id() {
118
166
  return this.#record.id;
@@ -124,13 +172,43 @@ export class DatabaseTransaction {
124
172
  return this.#record.snapshotVersion;
125
173
  }
126
174
  get pendingBlockIds() {
127
- return [...this.#record.pendingBlockIds];
175
+ return [...this.#record.pendingBlockIds, ...this.#deferredBlocks.map((block) => block.id)];
128
176
  }
129
177
  get pendingSegmentIds() {
130
- return [...this.#record.pendingSegmentIds];
178
+ return [
179
+ ...this.#record.pendingSegmentIds,
180
+ ...this.#deferredSegments.map((segment) => segment.id),
181
+ ];
182
+ }
183
+ /** Process-local segments retained for a bounded single-shot commit. */
184
+ get deferredSegments() {
185
+ return structuredClone(this.#deferredSegments);
186
+ }
187
+ get pendingTableNextRowId() {
188
+ return this.#record.pendingTableNextRowId;
189
+ }
190
+ get initialCatalogProbe() {
191
+ return this.#initialCatalogProbe === undefined ? undefined : { ...this.#initialCatalogProbe };
192
+ }
193
+ /** Exact local commit contribution; undefined until this transaction commits. */
194
+ get committedCatalogContribution() {
195
+ if (this.#record.status !== "committed")
196
+ return undefined;
197
+ return {
198
+ transaction: structuredClone(this.#record),
199
+ segments: structuredClone([...this.#knownSegments.values()]),
200
+ ...(this.#initialCatalogProbe === undefined
201
+ ? {}
202
+ : { initialCatalogProbe: { ...this.#initialCatalogProbe } }),
203
+ };
204
+ }
205
+ get compactionSourceBlockIds() {
206
+ return [...this.#compactionSourceBlockIds].sort();
131
207
  }
132
- get supersededBlockIds() {
133
- return [...this.#supersededBlockIds].sort();
208
+ /** Extends durable ownership; scope owners call this while user code is legitimately waiting. */
209
+ async renew() {
210
+ this.#assertActive();
211
+ await this.#renewOwnership(true);
134
212
  }
135
213
  /**
136
214
  * Counts every registration this transaction carries: staged blocks and segments, unique-key
@@ -140,26 +218,70 @@ export class DatabaseTransaction {
140
218
  * since there is no statement-level rollback inside a transaction.
141
219
  */
142
220
  get stagedWorkCount() {
143
- return (this.#record.pendingBlockIds.length +
144
- this.#record.pendingSegmentIds.length +
221
+ return (this.pendingBlockIds.length +
222
+ this.pendingSegmentIds.length +
145
223
  this.#uniqueKeyChanges.length +
146
- this.#ftsChanges.length +
147
- this.#supersededBlockIds.size);
224
+ this.#ftsChanges.size +
225
+ this.#compactionSourceBlockIds.size);
148
226
  }
149
227
  /** Captures all publish-relevant state; row-id/autoincrement reservations deliberately burn. */
150
228
  checkpoint() {
151
229
  this.#assertActive();
152
230
  return {
153
231
  transactionId: this.id,
154
- pendingBlockIds: [...this.#record.pendingBlockIds],
155
- pendingSegmentIds: [...this.#record.pendingSegmentIds],
232
+ pendingBlockIds: this.pendingBlockIds,
233
+ pendingSegmentIds: this.pendingSegmentIds,
156
234
  uniqueKeyChanges: structuredClone(this.#uniqueKeyChanges),
157
- ftsChanges: structuredClone(this.#ftsChanges),
158
- supersededBlockIds: [...this.#supersededBlockIds],
235
+ ftsChanges: structuredClone(this.#materializedFtsChanges()),
236
+ compactionJobId: this.#compactionJobId,
237
+ compactionSourceBlockIds: [...this.#compactionSourceBlockIds],
238
+ levelZeroSegmentLimits: [...this.#levelZeroSegmentLimits].map(([tableId, limit]) => ({
239
+ tableId,
240
+ limit,
241
+ })),
159
242
  changedTableIds: [...this.#changedTableIds],
160
243
  logicallyUnchanged: this.#logicallyUnchanged,
161
244
  };
162
245
  }
246
+ /** Conservative retained-size estimate used to refuse savepoint clones before allocating. */
247
+ checkpointRetainedBytes() {
248
+ this.#assertActive();
249
+ let bytes = 256;
250
+ const addString = (value) => {
251
+ bytes = checkpointByteSum(bytes, 16 + value.length * 2);
252
+ };
253
+ addString(this.id);
254
+ for (const id of this.pendingBlockIds)
255
+ addString(id);
256
+ for (const id of this.pendingSegmentIds)
257
+ addString(id);
258
+ for (const change of this.#uniqueKeyChanges) {
259
+ addString(change.tableId);
260
+ for (const token of change.keyTokens)
261
+ addString(token);
262
+ bytes = checkpointByteSum(bytes, 32);
263
+ }
264
+ for (const change of this.#materializedFtsChanges()) {
265
+ addString(change.tableId);
266
+ for (const column of change.columns) {
267
+ addString(column.columnId);
268
+ bytes = checkpointByteSum(bytes, 48);
269
+ for (const posting of column.postings) {
270
+ addString(posting.term);
271
+ bytes = checkpointByteSum(bytes, posting.rowIds.length * 8 + posting.tf.length * 8 + 32);
272
+ }
273
+ }
274
+ }
275
+ if (this.#compactionJobId !== null)
276
+ addString(this.#compactionJobId);
277
+ for (const id of this.#compactionSourceBlockIds)
278
+ addString(id);
279
+ for (const [tableId] of this.#levelZeroSegmentLimits)
280
+ addString(tableId);
281
+ for (const id of this.#changedTableIds)
282
+ addString(id);
283
+ return bytes;
284
+ }
163
285
  /** Rewinds staged artifacts and every in-memory commit delta to one earlier checkpoint. */
164
286
  async rollbackTo(checkpoint) {
165
287
  this.#assertActive();
@@ -168,45 +290,113 @@ export class DatabaseTransaction {
168
290
  }
169
291
  const blockPrefix = checkpoint.pendingBlockIds;
170
292
  const segmentPrefix = checkpoint.pendingSegmentIds;
171
- if (blockPrefix.some((id, index) => this.#record.pendingBlockIds[index] !== id) ||
172
- segmentPrefix.some((id, index) => this.#record.pendingSegmentIds[index] !== id) ||
173
- blockPrefix.length > this.#record.pendingBlockIds.length ||
174
- segmentPrefix.length > this.#record.pendingSegmentIds.length) {
293
+ const retainedBlocks = new Set(blockPrefix);
294
+ const retainedSegments = new Set(segmentPrefix);
295
+ const currentBlockIds = this.pendingBlockIds;
296
+ const currentSegmentIds = this.pendingSegmentIds;
297
+ if (blockPrefix.some((id) => !currentBlockIds.includes(id)) ||
298
+ segmentPrefix.some((id) => !currentSegmentIds.includes(id))) {
175
299
  throw new TypeError("A transaction checkpoint is no longer reachable");
176
300
  }
177
- const removedBlockIds = this.#record.pendingBlockIds.slice(blockPrefix.length);
178
- const removedSegmentIds = this.#record.pendingSegmentIds.slice(segmentPrefix.length);
301
+ const removedBlockIds = currentBlockIds.filter((id) => !retainedBlocks.has(id));
302
+ const removedSegmentIds = currentSegmentIds.filter((id) => !retainedSegments.has(id));
179
303
  if (this.#persisted && (removedBlockIds.length > 0 || removedSegmentIds.length > 0)) {
180
- // The journal forgets first, so a crash can at worst leave unreachable immutable garbage;
181
- // it can never publish an artifact the savepoint rolled back. Normal completion removes
182
- // those artifacts immediately below.
183
- this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
304
+ await this.#renewOwnership();
305
+ this.#record = await this.store.rollbackTransactionArtifacts({
306
+ transactionId: this.id,
307
+ expectedRevision: this.#record.revision,
184
308
  pendingBlockIds: blockPrefix,
185
309
  pendingSegmentIds: segmentPrefix,
186
- updatedAt: this.now().toISOString(),
310
+ removeBlockIds: removedBlockIds,
311
+ removeSegmentIds: removedSegmentIds,
312
+ updatedAt: dateIsoString(this.now()),
187
313
  });
188
314
  }
189
- this.#uniqueKeyChanges.splice(0, this.#uniqueKeyChanges.length, ...structuredClone(checkpoint.uniqueKeyChanges));
190
- this.#ftsChanges.splice(0, this.#ftsChanges.length, ...structuredClone(checkpoint.ftsChanges));
191
- this.#supersededBlockIds.clear();
192
- for (const id of checkpoint.supersededBlockIds)
193
- this.#supersededBlockIds.add(id);
315
+ else if (!this.#persisted) {
316
+ for (let index = this.#deferredBlocks.length - 1; index >= 0; index -= 1) {
317
+ if (!retainedBlocks.has(this.#deferredBlocks[index]?.id ?? "")) {
318
+ this.#deferredBlocks.splice(index, 1);
319
+ }
320
+ }
321
+ for (let index = this.#deferredSegments.length - 1; index >= 0; index -= 1) {
322
+ if (!retainedSegments.has(this.#deferredSegments[index]?.id ?? "")) {
323
+ this.#deferredSegments.splice(index, 1);
324
+ }
325
+ }
326
+ }
327
+ for (const id of removedSegmentIds)
328
+ this.#knownSegments.delete(id);
329
+ this.#uniqueKeyChanges.splice(0);
330
+ this.#ftsChanges.clear();
331
+ this.#commitDeltaBytes = 0;
332
+ this.#commitDeltaEntries = 0;
333
+ for (const changes of checkpoint.uniqueKeyChanges)
334
+ this.setUniqueKeyChanges(changes);
335
+ for (const changes of checkpoint.ftsChanges)
336
+ this.setFtsChanges(changes);
337
+ this.#compactionJobId = checkpoint.compactionJobId;
338
+ this.#compactionSourceBlockIds.clear();
339
+ for (const id of checkpoint.compactionSourceBlockIds)
340
+ this.#compactionSourceBlockIds.add(id);
341
+ this.#levelZeroSegmentLimits.clear();
342
+ for (const { tableId, limit } of checkpoint.levelZeroSegmentLimits) {
343
+ this.#levelZeroSegmentLimits.set(tableId, limit);
344
+ }
194
345
  this.#changedTableIds.clear();
195
346
  for (const id of checkpoint.changedTableIds)
196
347
  this.#changedTableIds.add(id);
197
348
  this.#logicallyUnchanged = checkpoint.logicallyUnchanged;
198
- await Promise.all(removedSegmentIds.map((id) => this.store.removeSegment(id)));
199
- await Promise.all(removedBlockIds.map((id) => this.store.removeBlock(id)));
200
349
  }
201
350
  async snapshot() {
202
351
  return loadSnapshot(this.store, this.#record.snapshotVersion);
203
352
  }
204
353
  async getBlock(id) {
205
354
  this.#assertActive();
355
+ const deferred = this.#deferredBlocks.find((block) => block.id === id);
356
+ if (deferred !== undefined)
357
+ return new Uint8Array(deferred.bytes);
206
358
  if (this.#record.pendingBlockIds.includes(id))
207
359
  return this.store.getBlock(id);
208
360
  return (await this.snapshot()).getBlock(id);
209
361
  }
362
+ #assertProspectiveArtifactJournal(blocks, segments) {
363
+ const blockIds = new Set(this.pendingBlockIds);
364
+ const segmentIds = new Set(this.pendingSegmentIds);
365
+ for (const block of blocks) {
366
+ if (block.id.length === 0)
367
+ throw new TypeError("Block ID cannot be empty");
368
+ if (blockIds.has(block.id))
369
+ throw new Error(`Block already exists: ${block.id}`);
370
+ blockIds.add(block.id);
371
+ }
372
+ for (const segment of segments) {
373
+ if (segment.id.length === 0)
374
+ throw new TypeError("Segment ID cannot be empty");
375
+ if (segmentIds.has(segment.id))
376
+ throw new Error(`Segment already exists: ${segment.id}`);
377
+ segmentIds.add(segment.id);
378
+ }
379
+ assertTransactionArtifactJournalLimits([...blockIds], [...segmentIds]);
380
+ }
381
+ async #stageArtifactBatch(blocks, segments) {
382
+ if (blocks.length === 0 && segments.length === 0)
383
+ return;
384
+ assertTransactionArtifactBatchLimits(blocks, segments);
385
+ await this.#ensurePersisted();
386
+ await this.#renewOwnership();
387
+ try {
388
+ this.#record = await this.store.stageTransactionArtifacts({
389
+ transactionId: this.id,
390
+ expectedRevision: this.#record.revision,
391
+ blocks,
392
+ segments,
393
+ updatedAt: dateIsoString(this.now()),
394
+ });
395
+ }
396
+ catch (error) {
397
+ await this.#recoverStagedAcknowledgement(error, blocks, segments);
398
+ }
399
+ }
210
400
  async stageBlock(id, bytes) {
211
401
  return this.stageBlocks([{ id, bytes }]);
212
402
  }
@@ -214,12 +404,7 @@ export class DatabaseTransaction {
214
404
  this.#assertActive();
215
405
  if (blocks.length === 0)
216
406
  return;
217
- await this.#ensurePersisted();
218
- await this.store.addBlocks(blocks);
219
- this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
220
- pendingBlockIds: [...this.#record.pendingBlockIds, ...blocks.map((block) => block.id)],
221
- updatedAt: this.now().toISOString(),
222
- });
407
+ await this.stageArtifacts(blocks, []);
223
408
  }
224
409
  /**
225
410
  * Stages blocks and segments in one journal step. When the store implements the atomic
@@ -234,31 +419,38 @@ export class DatabaseTransaction {
234
419
  throw new Error(`Segment ${segment.id} belongs to another transaction`);
235
420
  }
236
421
  }
237
- // The journal stores pendingSegmentIds deduped and sorted, so staging position is the
238
- // only record of operation order within this transaction. Stamp it on the segments:
239
- // reads use it to fold same-commit segments in the order they were staged.
240
- const ordinalBase = this.#record.pendingSegmentIds.length;
422
+ // The journal is an append-only ordered sequence. Stamp the same position on each segment:
423
+ // reads and recovery use the redundant pair to reject reordering or ambiguous same-commit
424
+ // folds before publication.
425
+ const ordinalBase = this.pendingSegmentIds.length;
241
426
  const ordered = segments.map((segment, index) => ({
242
427
  ...segment,
243
- commitOrdinal: segment.commitOrdinal ?? ordinalBase + index,
428
+ commitOrdinal: ordinalBase + index,
244
429
  }));
245
- const batched = this.store.stageTransactionArtifacts?.bind(this.store);
246
- if (batched === undefined) {
247
- await this.stageBlocks(blocks);
430
+ this.#assertProspectiveArtifactJournal(blocks, ordered);
431
+ this.#registerLevelZeroSegments(ordered);
432
+ if (blocks.length === 0 && ordered.length === 0)
433
+ return;
434
+ const batches = transactionArtifactBatches(blocks, ordered);
435
+ if (!this.#persisted &&
436
+ this.#deferredBlocks.length === 0 &&
437
+ this.#deferredSegments.length === 0 &&
438
+ batches.length === 1) {
439
+ assertTransactionArtifactBatchLimits(blocks, ordered);
440
+ this.#deferredBlocks.push(...blocks.map((block) => ({ id: block.id, bytes: new Uint8Array(block.bytes) })));
441
+ this.#deferredSegments.push(...structuredClone(ordered));
442
+ for (const segment of ordered)
443
+ this.#knownSegments.set(segment.id, structuredClone(segment));
248
444
  for (const segment of ordered)
249
- await this.stageSegment(segment);
445
+ this.#changedTableIds.add(segment.tableId);
250
446
  return;
251
447
  }
252
- if (blocks.length === 0 && ordered.length === 0)
253
- return;
254
- await this.#ensurePersisted();
255
- this.#record = await batched({
256
- transactionId: this.id,
257
- expectedRevision: this.#record.revision,
258
- blocks,
259
- segments: ordered,
260
- updatedAt: this.now().toISOString(),
261
- });
448
+ await this.#persistDeferredArtifacts();
449
+ for (const batch of batches) {
450
+ await this.#stageArtifactBatch(batch.blocks, batch.segments);
451
+ }
452
+ for (const segment of ordered)
453
+ this.#knownSegments.set(segment.id, structuredClone(segment));
262
454
  for (const segment of ordered)
263
455
  this.#changedTableIds.add(segment.tableId);
264
456
  }
@@ -280,62 +472,88 @@ export class DatabaseTransaction {
280
472
  }
281
473
  const pendingBlockIds = new Set(this.#record.pendingBlockIds);
282
474
  const pendingSegmentIds = new Set(this.#record.pendingSegmentIds);
475
+ await this.#assertRetainedArtifactsUnchanged(blocks.filter((block) => pendingBlockIds.has(block.id)), segments.filter((segment) => pendingSegmentIds.has(segment.id)));
476
+ const unstagedBlocks = blocks.filter((block) => !pendingBlockIds.has(block.id));
477
+ const unstagedSegments = segments.filter((segment) => !pendingSegmentIds.has(segment.id));
283
478
  if (blocks.length + segments.length > 0 &&
284
- blocks.every((block) => pendingBlockIds.has(block.id)) &&
285
- segments.every((segment) => pendingSegmentIds.has(segment.id))) {
479
+ unstagedBlocks.length === 0 &&
480
+ unstagedSegments.length === 0) {
286
481
  return this.commit();
287
482
  }
483
+ this.#assertProspectiveArtifactJournal(unstagedBlocks, unstagedSegments);
288
484
  const single = this.store.writeTransaction?.bind(this.store);
289
- if (single === undefined) {
290
- await this.stageArtifacts(blocks, segments);
485
+ const batches = transactionArtifactBatches(unstagedBlocks, unstagedSegments);
486
+ if (single === undefined || batches.length !== 1) {
487
+ await this.stageArtifacts(unstagedBlocks, unstagedSegments);
291
488
  return this.commit();
292
489
  }
490
+ await this.#renewOwnership();
293
491
  const ordinalBase = this.#record.pendingSegmentIds.length;
294
- const ordered = segments.map((segment, index) => ({
492
+ const ordered = unstagedSegments.map((segment, index) => ({
295
493
  ...segment,
296
- commitOrdinal: segment.commitOrdinal ?? ordinalBase + index,
494
+ commitOrdinal: ordinalBase + index,
297
495
  }));
496
+ assertTransactionArtifactBatchLimits(unstagedBlocks, ordered);
497
+ this.#registerLevelZeroSegments(ordered);
298
498
  const changedTableIds = new Set(this.#changedTableIds);
299
499
  for (const segment of ordered)
300
500
  changedTableIds.add(segment.tableId);
301
501
  const { snapshotVersion: _pinned, ...fresh } = this.#record;
302
502
  void _pinned;
303
- const committedAt = this.now().toISOString();
503
+ const committedAt = dateIsoString(this.now());
504
+ const committedRevision = advanceRevision(this.#record.revision, 2);
505
+ const compactionJobId = this.#compactionJobId;
506
+ const ftsChanges = this.#materializedFtsChanges();
507
+ // Keep the exact staged metadata before issuing the atomic write. A store is allowed to
508
+ // commit and then lose the acknowledgement; #recoverCommitted turns that into success, so
509
+ // recording this only after the await would make the caller's local catalog advancement
510
+ // omit segments that are already durable.
511
+ for (const segment of ordered)
512
+ this.#knownSegments.set(segment.id, structuredClone(segment));
513
+ for (const segment of ordered)
514
+ this.#changedTableIds.add(segment.tableId);
304
515
  try {
305
516
  const manifest = await single({
306
517
  transaction: this.#persisted
307
518
  ? { id: this.id, expectedRevision: this.#record.revision }
308
519
  : { record: fresh },
309
- blocks,
520
+ blocks: unstagedBlocks,
310
521
  segments: ordered,
311
522
  expectedManifestVersion: this.#record.snapshotVersion,
312
523
  changedTableIds: this.#logicallyUnchanged ? [] : [...changedTableIds],
313
- ...(this.#supersededBlockIds.size === 0
524
+ ...(this.#compactionSourceBlockIds.size === 0
314
525
  ? {}
315
- : { removedBlockIds: [...this.#supersededBlockIds] }),
526
+ : {
527
+ compactionJobId: requiredCompactionJobId(compactionJobId),
528
+ removedBlockIds: [...this.#compactionSourceBlockIds],
529
+ }),
316
530
  ...(this.#uniqueKeyChanges.length === 0
317
531
  ? {}
318
532
  : { uniqueKeyChanges: this.#uniqueKeyChanges }),
319
- ...(this.#ftsChanges.length === 0 ? {} : { ftsChanges: this.#ftsChanges }),
533
+ ...(ftsChanges.length === 0 ? {} : { ftsChanges }),
534
+ ...(this.#levelZeroSegmentLimits.size === 0
535
+ ? {}
536
+ : {
537
+ levelZeroSegmentLimits: [...this.#levelZeroSegmentLimits].map(([tableId, limit]) => ({
538
+ tableId,
539
+ limit,
540
+ })),
541
+ }),
320
542
  committedAt,
321
543
  });
322
- for (const segment of ordered)
323
- this.#changedTableIds.add(segment.tableId);
324
544
  this.#record = {
325
545
  ...this.#record,
326
- pendingBlockIds: [
327
- ...new Set([...pendingBlockIds, ...blocks.map((block) => block.id)]),
328
- ].sort(),
329
- pendingSegmentIds: [
330
- ...new Set([...pendingSegmentIds, ...ordered.map((segment) => segment.id)]),
331
- ].sort(),
546
+ pendingBlockIds: [...pendingBlockIds, ...unstagedBlocks.map((block) => block.id)],
547
+ pendingSegmentIds: [...pendingSegmentIds, ...ordered.map((segment) => segment.id)],
332
548
  status: "committed",
333
549
  committedVersion: manifest.version,
334
550
  // One journal step and one commit, as the two-step shape would have left it.
335
- revision: this.#record.revision + 2,
551
+ revision: committedRevision,
336
552
  updatedAt: committedAt,
337
553
  };
338
554
  this.#persisted = true;
555
+ this.#stopHeartbeat();
556
+ await this.#releaseSnapshotLease().catch(() => undefined);
339
557
  return manifest;
340
558
  }
341
559
  catch (error) {
@@ -351,6 +569,7 @@ export class DatabaseTransaction {
351
569
  */
352
570
  async stageExistingBlocks(blockIds) {
353
571
  this.#assertActive();
572
+ await this.#persistDeferredArtifacts();
354
573
  const pending = new Set(this.#record.pendingBlockIds);
355
574
  const additions = [...new Set(blockIds)].filter((id) => !pending.has(id));
356
575
  if (additions.length === 0)
@@ -358,15 +577,17 @@ export class DatabaseTransaction {
358
577
  if (additions.some((id) => id.length === 0)) {
359
578
  throw new TypeError("Block ID cannot be empty");
360
579
  }
580
+ assertTransactionArtifactJournalLimits([...this.#record.pendingBlockIds, ...additions], this.#record.pendingSegmentIds);
361
581
  for (const id of additions) {
362
582
  if ((await this.store.getBlock(id)) === undefined) {
363
583
  throw new Error(`Cannot stage a missing existing block: ${id}`);
364
584
  }
365
585
  }
366
586
  await this.#ensurePersisted();
587
+ await this.#renewOwnership();
367
588
  this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
368
589
  pendingBlockIds: [...this.#record.pendingBlockIds, ...additions],
369
- updatedAt: this.now().toISOString(),
590
+ updatedAt: dateIsoString(this.now()),
370
591
  });
371
592
  }
372
593
  async stageSegment(record) {
@@ -374,20 +595,12 @@ export class DatabaseTransaction {
374
595
  if (record.transactionId !== this.id) {
375
596
  throw new Error(`Segment ${record.id} belongs to another transaction`);
376
597
  }
377
- if (record.commitOrdinal === undefined) {
378
- record = { ...record, commitOrdinal: this.#record.pendingSegmentIds.length };
379
- }
380
- this.#changedTableIds.add(record.tableId);
381
- await this.#ensurePersisted();
382
- await this.store.addSegment(record);
383
- this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
384
- pendingSegmentIds: [...this.#record.pendingSegmentIds, record.id],
385
- updatedAt: this.now().toISOString(),
386
- });
598
+ await this.stageArtifacts([], [record]);
387
599
  }
388
600
  /** Reconciles a persisted segment whose journal update was interrupted. */
389
601
  async stageExistingSegment(segmentId) {
390
602
  this.#assertActive();
603
+ await this.#persistDeferredArtifacts();
391
604
  if (this.#record.pendingSegmentIds.includes(segmentId))
392
605
  return;
393
606
  const record = await this.store.getSegment(segmentId);
@@ -396,13 +609,65 @@ export class DatabaseTransaction {
396
609
  if (record.transactionId !== this.id) {
397
610
  throw new Error(`Segment ${segmentId} belongs to another transaction`);
398
611
  }
612
+ if (record.commitOrdinal !== this.#record.pendingSegmentIds.length) {
613
+ throw new Error(`Existing segment ${segmentId} is not the next journal ordinal`);
614
+ }
615
+ assertTransactionArtifactJournalLimits(this.#record.pendingBlockIds, [
616
+ ...this.#record.pendingSegmentIds,
617
+ segmentId,
618
+ ]);
619
+ this.#registerLevelZeroSegments([record]);
399
620
  this.#changedTableIds.add(record.tableId);
400
621
  await this.#ensurePersisted();
622
+ await this.#renewOwnership();
401
623
  this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
402
624
  pendingSegmentIds: [...this.#record.pendingSegmentIds, segmentId],
403
- updatedAt: this.now().toISOString(),
625
+ updatedAt: dateIsoString(this.now()),
404
626
  });
405
627
  }
628
+ /** Atomically adopts an exact unpublished output left by an aborted attempt of this job. */
629
+ async adoptAbortedCompactionSegment(record, abortedOwner) {
630
+ this.#assertActive();
631
+ await this.#persistDeferredArtifacts();
632
+ if (record.transactionId !== this.id) {
633
+ throw new Error(`Segment ${record.id} belongs to another transaction`);
634
+ }
635
+ if (abortedOwner.status !== "aborted") {
636
+ throw new Error(`Segment ${record.id} owner is not aborted`);
637
+ }
638
+ const compactionJobId = requiredCompactionJobId(this.#compactionJobId);
639
+ this.#assertProspectiveArtifactJournal([], [record]);
640
+ this.#registerLevelZeroSegments([record]);
641
+ this.#changedTableIds.add(record.tableId);
642
+ await this.#ensurePersisted();
643
+ await this.#renewOwnership();
644
+ try {
645
+ this.#record = await this.store.adoptAbortedSegment({
646
+ segment: record,
647
+ expectedAbortedTransactionId: abortedOwner.id,
648
+ expectedAbortedTransactionRevision: abortedOwner.revision,
649
+ replacementTransactionId: this.id,
650
+ expectedReplacementTransactionRevision: this.#record.revision,
651
+ compactionJobId,
652
+ updatedAt: dateIsoString(this.now()),
653
+ });
654
+ }
655
+ catch (error) {
656
+ // A lost acknowledgement is safe to reconcile: the exact segment and journal move are one
657
+ // adapter transaction. Any partial or foreign state fails the strict equality check.
658
+ const [current, segment] = await Promise.all([
659
+ this.store.getTransaction(this.id),
660
+ this.store.getSegment(record.id),
661
+ ]);
662
+ if (current?.status !== "active" ||
663
+ !current.pendingSegmentIds.includes(record.id) ||
664
+ segment?.transactionId !== this.id ||
665
+ !sameSegmentArtifacts(segment, record)) {
666
+ throw error;
667
+ }
668
+ this.#record = current;
669
+ }
670
+ }
406
671
  /**
407
672
  * Records that this commit changes a table's logical content without staging a segment for
408
673
  * it — dropping the table, for instance, whose only publish is the retirement of its blocks.
@@ -427,14 +692,23 @@ export class DatabaseTransaction {
427
692
  /** Appends one operation's key changes; entries commit in operation order. */
428
693
  setUniqueKeyChanges(changes) {
429
694
  this.#assertActive();
430
- this.#uniqueKeyChanges.push({
695
+ if (changes.keyTokens.length > MAX_TRANSACTION_COMMIT_DELTA_ENTRIES) {
696
+ throw new RangeError(`Transaction commit deltas exceed ${String(MAX_TRANSACTION_COMMIT_DELTA_ENTRIES)} entries`);
697
+ }
698
+ // Walk the caller-owned array before allocating a Set or clone. The aggregate tracker below
699
+ // charges the deduplicated retained form, while this preflight prevents one hostile call from
700
+ // creating a database-sized temporary allocation first.
701
+ transactionCommitDeltaRetainedBytes([changes], []);
702
+ const normalized = {
431
703
  tableId: changes.tableId,
432
704
  // Deduplicated, not sorted: membership is all a store reads from these, and sorting fifty
433
705
  // thousand tokens cost a bulk delete a fifth of its time.
434
706
  keyTokens: [...new Set(changes.keyTokens)],
435
707
  requireAbsent: changes.requireAbsent,
436
708
  ...(changes.remove === undefined ? {} : { remove: changes.remove }),
437
- });
709
+ };
710
+ this.#reserveCommitDelta([normalized], []);
711
+ this.#uniqueKeyChanges.push(normalized);
438
712
  }
439
713
  /**
440
714
  * Attaches one batch's full-text deltas; applied atomically with the publish. A second
@@ -444,82 +718,196 @@ export class DatabaseTransaction {
444
718
  */
445
719
  setFtsChanges(changes) {
446
720
  this.#assertActive();
447
- const existing = this.#ftsChanges.find((entry) => entry.tableId === changes.tableId);
448
- if (existing === undefined) {
449
- this.#ftsChanges.push(changes);
450
- return;
721
+ const existingColumns = this.#ftsChanges.get(changes.tableId);
722
+ const tokenTotals = new Map();
723
+ for (const column of changes.columns) {
724
+ const prior = tokenTotals.get(column.columnId) ?? existingColumns?.get(column.columnId)?.totalTokens ?? 0;
725
+ tokenTotals.set(column.columnId, safeWholeNumberSum([prior, column.totalTokens], "Full-text transaction token count"));
726
+ }
727
+ this.#reserveCommitDelta([], [changes]);
728
+ let columns = this.#ftsChanges.get(changes.tableId);
729
+ if (columns === undefined) {
730
+ columns = new Map();
731
+ this.#ftsChanges.set(changes.tableId, columns);
451
732
  }
452
- const merged = new Map(existing.columns.map((column) => [column.columnId, column]));
453
733
  for (const column of changes.columns) {
454
- const present = merged.get(column.columnId);
734
+ const present = columns.get(column.columnId);
455
735
  if (present === undefined) {
456
- merged.set(column.columnId, column);
736
+ columns.set(column.columnId, {
737
+ postings: new Map(column.postings.map((posting) => [
738
+ posting.term,
739
+ {
740
+ term: posting.term,
741
+ rowIds: [...posting.rowIds],
742
+ tf: [...posting.tf],
743
+ },
744
+ ])),
745
+ totalTokens: column.totalTokens,
746
+ });
457
747
  continue;
458
748
  }
459
- const byTerm = new Map(present.postings.map((posting) => [posting.term, posting]));
460
749
  for (const posting of column.postings) {
461
- const held = byTerm.get(posting.term);
750
+ const held = present.postings.get(posting.term);
462
751
  if (held === undefined) {
463
- byTerm.set(posting.term, posting);
464
- }
465
- else {
466
- byTerm.set(posting.term, {
752
+ present.postings.set(posting.term, {
467
753
  term: posting.term,
468
- rowIds: [...held.rowIds, ...posting.rowIds],
469
- tf: [...held.tf, ...posting.tf],
754
+ rowIds: [...posting.rowIds],
755
+ tf: [...posting.tf],
470
756
  });
471
757
  }
758
+ else {
759
+ for (const rowId of posting.rowIds)
760
+ held.rowIds.push(rowId);
761
+ for (const tf of posting.tf)
762
+ held.tf.push(tf);
763
+ }
472
764
  }
473
- merged.set(column.columnId, {
474
- columnId: column.columnId,
475
- postings: [...byTerm.values()].sort((left, right) => left.term < right.term ? -1 : left.term > right.term ? 1 : 0),
476
- totalTokens: present.totalTokens + column.totalTokens,
477
- });
765
+ present.totalTokens = tokenTotals.get(column.columnId) ?? present.totalTokens;
478
766
  }
479
- this.#ftsChanges[this.#ftsChanges.indexOf(existing)] = {
480
- tableId: changes.tableId,
481
- columns: [...merged.values()],
482
- };
483
767
  }
484
- supersedeBlocks(blockIds) {
768
+ #materializedFtsChanges() {
769
+ return [...this.#ftsChanges].map(([tableId, columns]) => ({
770
+ tableId,
771
+ columns: [...columns].map(([columnId, change]) => ({
772
+ columnId,
773
+ postings: [...change.postings.values()].sort((left, right) => left.term < right.term ? -1 : left.term > right.term ? 1 : 0),
774
+ totalTokens: change.totalTokens,
775
+ })),
776
+ }));
777
+ }
778
+ #reserveCommitDelta(uniqueKeyChanges, ftsChanges) {
779
+ const added = transactionCommitDeltaRetainedBytes(uniqueKeyChanges, ftsChanges);
780
+ const bytes = this.#commitDeltaBytes + added.bytes;
781
+ const entries = this.#commitDeltaEntries + added.entries;
782
+ if (!Number.isSafeInteger(bytes) || bytes > MAX_TRANSACTION_COMMIT_DELTA_BYTES) {
783
+ throw new RangeError(`Transaction commit deltas exceed ${String(MAX_TRANSACTION_COMMIT_DELTA_BYTES)} retained bytes`);
784
+ }
785
+ if (!Number.isSafeInteger(entries) || entries > MAX_TRANSACTION_COMMIT_DELTA_ENTRIES) {
786
+ throw new RangeError(`Transaction commit deltas exceed ${String(MAX_TRANSACTION_COMMIT_DELTA_ENTRIES)} entries`);
787
+ }
788
+ this.#commitDeltaBytes = bytes;
789
+ this.#commitDeltaEntries = entries;
790
+ }
791
+ /**
792
+ * Binds a physical retirement to one persisted compaction job. Stores revalidate the job,
793
+ * source segments, current manifest, and aliases atomically with publication; this in-memory
794
+ * declaration is intent, never authority to hide arbitrary blocks.
795
+ */
796
+ setCompactionIntent(compactionJobId, sourceBlockIds) {
485
797
  this.#assertActive();
486
- for (const id of blockIds) {
798
+ if (compactionJobId.length === 0)
799
+ throw new TypeError("Compaction job ID cannot be empty");
800
+ if (sourceBlockIds.length === 0) {
801
+ throw new TypeError("A compaction retirement must name at least one source block");
802
+ }
803
+ const next = new Set(sourceBlockIds);
804
+ if (next.size !== sourceBlockIds.length) {
805
+ throw new TypeError("Compaction source block IDs must be unique");
806
+ }
807
+ if (this.#compactionJobId !== null) {
808
+ if (this.#compactionJobId !== compactionJobId ||
809
+ next.size !== this.#compactionSourceBlockIds.size ||
810
+ [...next].some((id) => !this.#compactionSourceBlockIds.has(id))) {
811
+ throw new Error("A transaction cannot change its compaction retirement intent");
812
+ }
813
+ return;
814
+ }
815
+ for (const id of next) {
487
816
  if (id.length === 0)
488
- throw new TypeError("Superseded block ID cannot be empty");
817
+ throw new TypeError("Compaction source block ID cannot be empty");
489
818
  if (this.#record.pendingBlockIds.includes(id)) {
490
- throw new Error(`A transaction cannot supersede its own pending block: ${id}`);
819
+ throw new Error(`A compaction cannot retire its own pending block: ${id}`);
820
+ }
821
+ }
822
+ this.#compactionJobId = compactionJobId;
823
+ for (const id of next)
824
+ this.#compactionSourceBlockIds.add(id);
825
+ }
826
+ /** Adds an adapter-enforced post-commit level-zero ceiling for one table. */
827
+ limitLevelZeroSegments(tableId, limit) {
828
+ this.#assertActive();
829
+ if (tableId.length === 0)
830
+ throw new TypeError("Table ID cannot be empty");
831
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
832
+ throw new RangeError("Level-zero segment limit must be a positive safe integer");
833
+ }
834
+ const existing = this.#levelZeroSegmentLimits.get(tableId);
835
+ this.#levelZeroSegmentLimits.set(tableId, existing === undefined ? limit : Math.min(existing, limit));
836
+ }
837
+ #registerLevelZeroSegments(segments) {
838
+ for (const segment of segments) {
839
+ if (segment.level !== 0)
840
+ continue;
841
+ const existing = this.#levelZeroSegmentLimits.get(segment.tableId);
842
+ if (existing === undefined) {
843
+ this.#levelZeroSegmentLimits.set(segment.tableId, MAX_LEVEL_ZERO_SEGMENTS);
491
844
  }
492
- this.#supersededBlockIds.add(id);
493
845
  }
494
846
  }
495
847
  async commit() {
496
848
  this.#assertActive();
849
+ if (this.#deferredBlocks.length > 0 || this.#deferredSegments.length > 0) {
850
+ if (this.store.writeTransaction === undefined) {
851
+ await this.#persistDeferredArtifacts();
852
+ }
853
+ else {
854
+ const blocks = this.#deferredBlocks.splice(0);
855
+ const segments = this.#deferredSegments.splice(0);
856
+ try {
857
+ return await this.stageArtifactsAndCommit(blocks, segments);
858
+ }
859
+ catch (error) {
860
+ // A single-shot refusal mutates nothing. Keep the exact immutable artifacts available
861
+ // for the caller's ordinary rebase-and-retry path.
862
+ this.#deferredBlocks.push(...blocks);
863
+ this.#deferredSegments.push(...segments);
864
+ throw error;
865
+ }
866
+ }
867
+ }
497
868
  await this.#ensurePersisted();
869
+ await this.#renewOwnership();
498
870
  // The store derives the published manifest from its stored base plus this delta, so the
499
871
  // commit neither loads nor rebuilds the full block list.
500
- const committedAt = this.now().toISOString();
872
+ const committedAt = dateIsoString(this.now());
873
+ const committedRevision = advanceRevision(this.#record.revision, 1);
874
+ const compactionJobId = this.#compactionJobId;
875
+ const ftsChanges = this.#materializedFtsChanges();
501
876
  try {
502
877
  const manifest = await this.store.commitTransaction({
503
878
  transactionId: this.id,
504
879
  expectedTransactionRevision: this.#record.revision,
505
880
  expectedManifestVersion: this.#record.snapshotVersion,
506
881
  changedTableIds: this.#logicallyUnchanged ? [] : [...this.#changedTableIds],
507
- ...(this.#supersededBlockIds.size === 0
882
+ ...(this.#compactionSourceBlockIds.size === 0
508
883
  ? {}
509
- : { removedBlockIds: [...this.#supersededBlockIds] }),
884
+ : {
885
+ compactionJobId: requiredCompactionJobId(compactionJobId),
886
+ removedBlockIds: [...this.#compactionSourceBlockIds],
887
+ }),
510
888
  ...(this.#uniqueKeyChanges.length === 0
511
889
  ? {}
512
890
  : { uniqueKeyChanges: this.#uniqueKeyChanges }),
513
- ...(this.#ftsChanges.length === 0 ? {} : { ftsChanges: this.#ftsChanges }),
891
+ ...(ftsChanges.length === 0 ? {} : { ftsChanges }),
892
+ ...(this.#levelZeroSegmentLimits.size === 0
893
+ ? {}
894
+ : {
895
+ levelZeroSegmentLimits: [...this.#levelZeroSegmentLimits].map(([tableId, limit]) => ({
896
+ tableId,
897
+ limit,
898
+ })),
899
+ }),
514
900
  committedAt,
515
901
  });
516
902
  this.#record = {
517
903
  ...this.#record,
518
904
  status: "committed",
519
905
  committedVersion: manifest.version,
520
- revision: this.#record.revision + 1,
906
+ revision: committedRevision,
521
907
  updatedAt: committedAt,
522
908
  };
909
+ this.#stopHeartbeat();
910
+ await this.#releaseSnapshotLease().catch(() => undefined);
523
911
  return manifest;
524
912
  }
525
913
  catch (error) {
@@ -529,22 +917,25 @@ export class DatabaseTransaction {
529
917
  async rebase() {
530
918
  this.#assertActive();
531
919
  for (;;) {
532
- const current = await this.store.getCurrentManifest();
920
+ const currentVersion = await this.store.getCurrentManifestVersion();
533
921
  if (!this.#persisted) {
534
- // Nothing is in the store yet, so the pin moves locally; it lands with the record.
922
+ // Nothing is in the store yet, so move the temporary durable pin before changing the
923
+ // local snapshot. The old version remains protected until the replacement is admitted.
924
+ await this.#moveSnapshotLease(currentVersion);
535
925
  this.#record = {
536
926
  ...this.#record,
537
- snapshotVersion: current?.version ?? null,
538
- updatedAt: this.now().toISOString(),
927
+ snapshotVersion: currentVersion,
928
+ updatedAt: dateIsoString(this.now()),
539
929
  };
540
- return new Snapshot(this.store, current?.version ?? null, current?.blockIds ?? []);
930
+ return new Snapshot(this.store, currentVersion);
541
931
  }
542
932
  try {
933
+ await this.#renewOwnership();
543
934
  this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
544
- snapshotVersion: current?.version ?? null,
545
- updatedAt: this.now().toISOString(),
935
+ snapshotVersion: currentVersion,
936
+ updatedAt: dateIsoString(this.now()),
546
937
  });
547
- return new Snapshot(this.store, current?.version ?? null, current?.blockIds ?? []);
938
+ return new Snapshot(this.store, currentVersion);
548
939
  }
549
940
  catch (error) {
550
941
  if (error instanceof SnapshotManifestMissingError) {
@@ -558,13 +949,21 @@ export class DatabaseTransaction {
558
949
  this.#assertActive();
559
950
  if (!this.#persisted) {
560
951
  // Never written, so there is nothing to mark: the transaction simply ends here.
561
- this.#record = { ...this.#record, status: "aborted", updatedAt: this.now().toISOString() };
952
+ this.#deferredBlocks.length = 0;
953
+ this.#deferredSegments.length = 0;
954
+ this.#knownSegments.clear();
955
+ this.#record = { ...this.#record, status: "aborted", updatedAt: dateIsoString(this.now()) };
956
+ this.#stopHeartbeat();
957
+ await this.#releaseSnapshotLease().catch(() => undefined);
562
958
  return;
563
959
  }
960
+ await this.#renewOwnership();
564
961
  this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
565
962
  status: "aborted",
566
- updatedAt: this.now().toISOString(),
963
+ updatedAt: dateIsoString(this.now()),
567
964
  });
965
+ this.#stopHeartbeat();
966
+ await this.#releaseSnapshotLease().catch(() => undefined);
568
967
  }
569
968
  #assertActive() {
570
969
  if (this.#record.status !== "active")
@@ -578,6 +977,11 @@ export class DatabaseTransaction {
578
977
  async #ensurePersisted() {
579
978
  if (this.#persisted)
580
979
  return;
980
+ this.#record = {
981
+ ...this.#record,
982
+ expiresAt: this.#nextExpiry(),
983
+ updatedAt: dateIsoString(this.now()),
984
+ };
581
985
  try {
582
986
  await this.store.createTransaction(this.#record);
583
987
  }
@@ -588,6 +992,19 @@ export class DatabaseTransaction {
588
992
  throw error;
589
993
  }
590
994
  this.#persisted = true;
995
+ // The transaction record now owns the same durable pin and renews on this heartbeat. Keep
996
+ // only one record rooted; a failed cleanup remains bounded by the lease expiry.
997
+ void this.#releaseSnapshotLease().catch(() => undefined);
998
+ }
999
+ /** Flushes the bounded process-local batch before a second durable staging operation. */
1000
+ async #persistDeferredArtifacts() {
1001
+ if (this.#deferredBlocks.length === 0 && this.#deferredSegments.length === 0)
1002
+ return;
1003
+ const blocks = [...this.#deferredBlocks];
1004
+ const segments = [...this.#deferredSegments];
1005
+ await this.#stageArtifactBatch(blocks, segments);
1006
+ this.#deferredBlocks.length = 0;
1007
+ this.#deferredSegments.length = 0;
591
1008
  }
592
1009
  /** A commit whose acknowledgement was lost still committed: answer from the persisted record. */
593
1010
  async #recoverCommitted(error) {
@@ -597,85 +1014,292 @@ export class DatabaseTransaction {
597
1014
  if (manifest !== undefined) {
598
1015
  this.#record = persisted;
599
1016
  this.#persisted = true;
1017
+ this.#stopHeartbeat();
1018
+ await this.#releaseSnapshotLease().catch(() => undefined);
600
1019
  return manifest;
601
1020
  }
602
1021
  }
603
1022
  throw error;
604
1023
  }
1024
+ async #renewOwnership(force = false) {
1025
+ await this.#renewSnapshotLease(force);
1026
+ if (!this.#persisted || this.#record.status !== "active")
1027
+ return;
1028
+ const now = dateMilliseconds(this.now());
1029
+ if (!force && Date.parse(this.#record.expiresAt) - now > this.ttlMs / 3)
1030
+ return;
1031
+ const expiresAt = expiryAfter(new Date(now), this.ttlMs);
1032
+ const renewal = this.#renewal ??
1033
+ this.store
1034
+ .renewTransaction({
1035
+ transactionId: this.id,
1036
+ ownerId: this.#record.ownerId,
1037
+ expiresAtCutoff: dateIsoString(new Date(now)),
1038
+ expiresAt,
1039
+ })
1040
+ .then((owned) => ({ owned, expiresAt }));
1041
+ this.#renewal = renewal;
1042
+ let result;
1043
+ try {
1044
+ result = await renewal;
1045
+ }
1046
+ finally {
1047
+ if (this.#renewal === renewal)
1048
+ this.#renewal = undefined;
1049
+ }
1050
+ if (result.owned) {
1051
+ this.#record = { ...this.#record, expiresAt: result.expiresAt };
1052
+ return;
1053
+ }
1054
+ const persisted = await this.store.getTransaction(this.id);
1055
+ if (persisted !== undefined)
1056
+ this.#record = persisted;
1057
+ this.#stopHeartbeat();
1058
+ await this.#releaseSnapshotLease().catch(() => undefined);
1059
+ throw new TransactionClosedError(this.id);
1060
+ }
1061
+ async #renewSnapshotLease(force) {
1062
+ const lease = this.#snapshotLease;
1063
+ if (lease === undefined || this.#record.status !== "active")
1064
+ return;
1065
+ if (!force &&
1066
+ dateMilliseconds(lease.expiresAt) - dateMilliseconds(this.now()) > this.ttlMs / 3) {
1067
+ return;
1068
+ }
1069
+ const renewal = this.#snapshotRenewal ??
1070
+ this.#serializeSnapshotLeaseOperation(async () => {
1071
+ const current = this.#snapshotLease;
1072
+ if (current === undefined || this.#record.status !== "active")
1073
+ return;
1074
+ if (!force &&
1075
+ dateMilliseconds(current.expiresAt) - dateMilliseconds(this.now()) > this.ttlMs / 3) {
1076
+ return;
1077
+ }
1078
+ await current.renew(this.ttlMs);
1079
+ });
1080
+ this.#snapshotRenewal = renewal;
1081
+ try {
1082
+ await renewal;
1083
+ }
1084
+ finally {
1085
+ if (this.#snapshotRenewal === renewal)
1086
+ this.#snapshotRenewal = undefined;
1087
+ }
1088
+ }
1089
+ async #moveSnapshotLease(version) {
1090
+ await this.#serializeSnapshotLeaseOperation(async () => {
1091
+ const current = this.#snapshotLease;
1092
+ if (current === undefined || current.version === version)
1093
+ return;
1094
+ const moved = await current.moveTo(version, this.ttlMs);
1095
+ if (moved !== undefined) {
1096
+ if (this.#snapshotLease === current) {
1097
+ this.#snapshotLease = moved;
1098
+ }
1099
+ else {
1100
+ // A terminal path detached the old lease while the adapter move was in flight. Do not
1101
+ // resurrect it in memory; retire the moved durable record as part of this operation.
1102
+ await moved.release().catch(() => undefined);
1103
+ }
1104
+ return;
1105
+ }
1106
+ // Custom stores may omit atomic moveLease. Admit a replacement before releasing the old
1107
+ // pin, so there is never a reclamation window between versions.
1108
+ const createdAt = dateIsoString(this.now());
1109
+ const record = {
1110
+ id: crypto.randomUUID(),
1111
+ kind: "reader",
1112
+ manifestVersion: version,
1113
+ ownerId: this.#record.ownerId,
1114
+ createdAt,
1115
+ expiresAt: expiryAfter(new Date(createdAt), this.ttlMs),
1116
+ revision: 0,
1117
+ };
1118
+ await this.store.createLease(record);
1119
+ const replacement = new LeasedSnapshot(this.store, version, record, this.now);
1120
+ if (this.#snapshotLease === current) {
1121
+ this.#snapshotLease = replacement;
1122
+ }
1123
+ else {
1124
+ await replacement.release().catch(() => undefined);
1125
+ }
1126
+ await current.release().catch(() => undefined);
1127
+ });
1128
+ }
1129
+ async #releaseSnapshotLease() {
1130
+ const lease = this.#snapshotLease;
1131
+ if (lease === undefined)
1132
+ return;
1133
+ // Detach before the adapter call. Once a transaction record is persisted it is the durable
1134
+ // owner; a slow lease removal must not let the next ownership check renew the retiring pin
1135
+ // concurrently and turn a healthy handoff into a LeaseConflictError.
1136
+ this.#snapshotLease = undefined;
1137
+ await this.#serializeSnapshotLeaseOperation(() => lease.release());
1138
+ }
1139
+ /**
1140
+ * Lease revisions are compare-and-swap tokens, so renew, move, and release must observe one
1141
+ * another's returned record before issuing the next adapter operation. Keep the tail fulfilled
1142
+ * after a refusal: the caller still receives its error, while a terminal cleanup can proceed.
1143
+ */
1144
+ #serializeSnapshotLeaseOperation(operation) {
1145
+ const run = this.#snapshotLeaseOperation.then(operation);
1146
+ this.#snapshotLeaseOperation = run.then(() => undefined, () => undefined);
1147
+ return run;
1148
+ }
1149
+ #nextExpiry() {
1150
+ return expiryAfter(this.now(), this.ttlMs);
1151
+ }
1152
+ #stopHeartbeat() {
1153
+ if (this.#heartbeatTimer !== undefined)
1154
+ clearInterval(this.#heartbeatTimer);
1155
+ this.#heartbeatTimer = undefined;
1156
+ }
1157
+ /**
1158
+ * An adapter can durably stage the artifacts and lose the acknowledgement afterwards. Refresh
1159
+ * the local revision only when the persisted journal is exactly the one this call would have
1160
+ * produced, so the caller's abort/rollback can clean it up without masking the original error.
1161
+ */
1162
+ async #recoverStagedAcknowledgement(error, blocks, segments) {
1163
+ try {
1164
+ const persisted = await this.store.getTransaction(this.id);
1165
+ const expectedBlocks = [...this.#record.pendingBlockIds, ...blocks.map((block) => block.id)];
1166
+ const expectedSegments = [
1167
+ ...this.#record.pendingSegmentIds,
1168
+ ...segments.map((segment) => segment.id),
1169
+ ];
1170
+ if (persisted?.status === "active" &&
1171
+ persisted.revision === advanceRevision(this.#record.revision, 1) &&
1172
+ sameStrings(persisted.pendingBlockIds, expectedBlocks) &&
1173
+ sameStrings(persisted.pendingSegmentIds, expectedSegments)) {
1174
+ const [storedBlocks, storedSegments] = await Promise.all([
1175
+ Promise.all(blocks.map((block) => this.store.getBlock(block.id))),
1176
+ Promise.all(segments.map((segment) => this.store.getSegment(segment.id))),
1177
+ ]);
1178
+ if (blocks.every((block, index) => sameBytes(block.bytes, storedBlocks[index])) &&
1179
+ segments.every((segment, index) => {
1180
+ const stored = storedSegments[index];
1181
+ return stored !== undefined && sameSegmentArtifacts(segment, stored);
1182
+ })) {
1183
+ this.#record = persisted;
1184
+ this.#persisted = true;
1185
+ }
1186
+ }
1187
+ }
1188
+ catch {
1189
+ // Preserve the operation error. A failed diagnostic read cannot prove a different outcome.
1190
+ }
1191
+ throw error;
1192
+ }
1193
+ async #assertRetainedArtifactsUnchanged(blocks, segments) {
1194
+ for (const block of blocks) {
1195
+ if (!sameBytes(block.bytes, await this.store.getBlock(block.id))) {
1196
+ throw new Error(`Retried block differs from the staged artifact: ${block.id}`);
1197
+ }
1198
+ }
1199
+ for (const segment of segments) {
1200
+ const stored = await this.store.getSegment(segment.id);
1201
+ if (stored === undefined ||
1202
+ !sameSegmentArtifacts(stored, { ...segment, commitOrdinal: stored.commitOrdinal })) {
1203
+ throw new Error(`Retried segment differs from the staged artifact: ${segment.id}`);
1204
+ }
1205
+ }
1206
+ }
1207
+ }
1208
+ function sameStrings(left, right) {
1209
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1210
+ }
1211
+ function sameBytes(left, right) {
1212
+ return (left.byteLength === right?.byteLength && left.every((byte, index) => byte === right[index]));
605
1213
  }
606
1214
  export class TransactionManager {
607
1215
  store;
608
1216
  #now;
609
1217
  #createId;
1218
+ #transactionTtlMs;
1219
+ #leaseCleanupCursor = null;
1220
+ #createWeakRef;
610
1221
  constructor(store, options = {}) {
611
1222
  this.store = store;
612
1223
  this.#now = options.now ?? (() => new Date());
613
1224
  this.#createId = options.createId ?? (() => crypto.randomUUID());
1225
+ this.#transactionTtlMs = options.transactionTtlMs ?? DEFAULT_TRANSACTION_TTL_MS;
1226
+ validateTtl(this.#transactionTtlMs);
1227
+ this.#createWeakRef = options.createWeakRef ?? ((transaction) => new WeakRef(transaction));
614
1228
  }
615
1229
  async begin() {
616
1230
  return (await this.beginWithReservation()).transaction;
617
1231
  }
618
- /**
619
- * Begins a transaction without a storage write: the record is pinned at the current manifest
620
- * version (one read) and written only when something is staged through the two-step methods
621
- * or never, when the whole write goes through `stageArtifactsAndCommit` on a store with
622
- * the single-shot `writeTransaction`, or the transaction aborts untouched. For writes whose
623
- * artifacts need no prior reservation; `beginWithReservation` is the shape for the rest.
624
- */
625
- async beginDeferred() {
626
- const currentVersion = await this.store.getCurrentManifestVersion();
627
- const timestamp = this.#now().toISOString();
628
- return new DatabaseTransaction(this.store, {
629
- id: this.#createId(),
630
- snapshotVersion: currentVersion,
631
- pendingBlockIds: [],
632
- pendingSegmentIds: [],
633
- status: "active",
634
- revision: 0,
635
- startedAt: timestamp,
636
- updatedAt: timestamp,
637
- committedVersion: null,
638
- }, this.#now, { persisted: false });
1232
+ /** Atomically reserves an invisible catalog table for a create-and-populate transaction. */
1233
+ async beginWithPendingTable(record, expectedCatalogEpoch) {
1234
+ const id = this.#createId();
1235
+ const ownerId = `${id}/owner`;
1236
+ const timestamp = dateIsoString(this.#now());
1237
+ let begun;
1238
+ for (;;) {
1239
+ try {
1240
+ begun = await this.store.beginTransaction({
1241
+ record: {
1242
+ id,
1243
+ ownerId,
1244
+ expiresAt: this.#transactionExpiry(),
1245
+ pendingBlockIds: [],
1246
+ pendingSegmentIds: [],
1247
+ status: "active",
1248
+ revision: 0,
1249
+ startedAt: timestamp,
1250
+ updatedAt: timestamp,
1251
+ committedVersion: null,
1252
+ },
1253
+ pendingTable: { record, nextRowId: 1n, expectedCatalogEpoch },
1254
+ });
1255
+ break;
1256
+ }
1257
+ catch (error) {
1258
+ // Atomic begin writes nothing when its current-manifest pin loses a pruning race.
1259
+ if (error instanceof SnapshotManifestMissingError)
1260
+ continue;
1261
+ throw error;
1262
+ }
1263
+ }
1264
+ return new DatabaseTransaction(this.store, begun.record, this.#now, this.#transactionTtlMs, this.#createWeakRef);
639
1265
  }
640
1266
  /**
641
- * Begins a transaction at the current manifest version and optionally reserves row ids and
642
- * auto-increment values in the same step. Stores implementing the atomic `beginTransaction`
643
- * pay one storage round trip instead of the version-read/create/reserve sequence; the
644
- * read-then-create fallback retries when the version it read is pruned before the record lands.
1267
+ * Begins without a transaction record. By default a renewable reader lease durably pins the
1268
+ * probed manifest until the first two-step stage hands ownership to a transaction record. The
1269
+ * record and lease are both omitted only for internal, uninterrupted single-shot writes that
1270
+ * opt out and publish through `writeTransaction`. For writes whose artifacts need no prior
1271
+ * reservation; `beginWithReservation` is the shape for the rest.
645
1272
  */
646
- async beginWithReservation(reserveRowIds, reserveAutoIncrement) {
647
- const id = this.#createId();
648
- const batched = this.store.beginTransaction?.bind(this.store);
649
- if (batched !== undefined) {
650
- const timestamp = this.#now().toISOString();
651
- const begun = await batched({
652
- record: {
653
- id,
654
- pendingBlockIds: [],
655
- pendingSegmentIds: [],
656
- status: "active",
657
- revision: 0,
658
- startedAt: timestamp,
659
- updatedAt: timestamp,
660
- committedVersion: null,
661
- },
662
- ...(reserveRowIds === undefined ? {} : { reserveRowIds }),
663
- ...(reserveAutoIncrement === undefined ? {} : { reserveAutoIncrement }),
664
- });
665
- return {
666
- transaction: new DatabaseTransaction(this.store, begun.record, this.#now),
667
- ...(begun.rowIds === undefined ? {} : { rowIds: begun.rowIds }),
668
- ...(begun.autoIncrementValues === undefined
669
- ? {}
670
- : { autoIncrementValues: begun.autoIncrementValues }),
671
- };
672
- }
1273
+ async beginDeferred(options = {}) {
1274
+ const durableSnapshot = options.durableSnapshot ?? true;
673
1275
  for (;;) {
674
- const currentVersion = await this.store.getCurrentManifestVersion();
675
- const timestamp = this.#now().toISOString();
676
- const record = {
1276
+ const probe = await this.store.getCatalogProbe();
1277
+ const timestamp = dateIsoString(this.#now());
1278
+ const id = this.#createId();
1279
+ const ownerId = `${id}/owner`;
1280
+ let snapshotLease;
1281
+ if (durableSnapshot) {
1282
+ try {
1283
+ snapshotLease = await this.openLeasedSnapshot({
1284
+ id: `${id}/snapshot`,
1285
+ ownerId,
1286
+ ttlMs: this.#transactionTtlMs,
1287
+ version: probe.manifestVersion,
1288
+ });
1289
+ }
1290
+ catch (error) {
1291
+ // The version was current at the probe but collection won before the durable pin.
1292
+ if (error instanceof SnapshotManifestMissingError)
1293
+ continue;
1294
+ throw error;
1295
+ }
1296
+ }
1297
+ return new DatabaseTransaction(this.store, {
677
1298
  id,
678
- snapshotVersion: currentVersion,
1299
+ ownerId,
1300
+ expiresAt: this.#transactionExpiry(),
1301
+ snapshotVersion: probe.manifestVersion,
1302
+ schemaEpochGuard: probe.schemaEpoch,
679
1303
  pendingBlockIds: [],
680
1304
  pendingSegmentIds: [],
681
1305
  status: "active",
@@ -683,29 +1307,58 @@ export class TransactionManager {
683
1307
  startedAt: timestamp,
684
1308
  updatedAt: timestamp,
685
1309
  committedVersion: null,
686
- };
1310
+ }, this.#now, this.#transactionTtlMs, this.#createWeakRef, {
1311
+ persisted: false,
1312
+ initialCatalogProbe: probe,
1313
+ ...(snapshotLease === undefined ? {} : { snapshotLease }),
1314
+ });
1315
+ }
1316
+ }
1317
+ /**
1318
+ * Begins a transaction at the current manifest version and optionally reserves row ids and
1319
+ * auto-increment values in the same step. Stores implementing the atomic `beginTransaction`
1320
+ * pay one storage round trip instead of the version-read/create/reserve sequence; the
1321
+ * read-then-create fallback retries when the version it read is pruned before the record lands.
1322
+ */
1323
+ async beginWithReservation(reserveRowIds, reserveAutoIncrement) {
1324
+ const id = this.#createId();
1325
+ const ownerId = `${id}/owner`;
1326
+ const timestamp = dateIsoString(this.#now());
1327
+ let begun;
1328
+ for (;;) {
687
1329
  try {
688
- await this.store.createTransaction(record);
689
- const transaction = new DatabaseTransaction(this.store, record, this.#now);
690
- const autoIncrementValues = reserveAutoIncrement === undefined
691
- ? undefined
692
- : await this.store.reserveAutoIncrement(reserveAutoIncrement.tableId, reserveAutoIncrement.columnId, reserveAutoIncrement.count, reserveAutoIncrement.atLeast);
693
- const rowIds = reserveRowIds === undefined
694
- ? undefined
695
- : await this.store.reserveRowIds(reserveRowIds.tableId, reserveRowIds.count);
696
- return {
697
- transaction,
698
- ...(rowIds === undefined ? {} : { rowIds }),
699
- ...(autoIncrementValues === undefined ? {} : { autoIncrementValues }),
700
- };
1330
+ begun = await this.store.beginTransaction({
1331
+ record: {
1332
+ id,
1333
+ ownerId,
1334
+ expiresAt: this.#transactionExpiry(),
1335
+ pendingBlockIds: [],
1336
+ pendingSegmentIds: [],
1337
+ status: "active",
1338
+ revision: 0,
1339
+ startedAt: timestamp,
1340
+ updatedAt: timestamp,
1341
+ committedVersion: null,
1342
+ },
1343
+ ...(reserveRowIds === undefined ? {} : { reserveRowIds }),
1344
+ ...(reserveAutoIncrement === undefined ? {} : { reserveAutoIncrement }),
1345
+ });
1346
+ break;
701
1347
  }
702
1348
  catch (error) {
703
- if (error instanceof SnapshotManifestMissingError) {
1349
+ // The store's atomic begin aborted in full, including any counter reservation.
1350
+ if (error instanceof SnapshotManifestMissingError)
704
1351
  continue;
705
- }
706
1352
  throw error;
707
1353
  }
708
1354
  }
1355
+ return {
1356
+ transaction: new DatabaseTransaction(this.store, begun.record, this.#now, this.#transactionTtlMs, this.#createWeakRef),
1357
+ ...(begun.rowIds === undefined ? {} : { rowIds: begun.rowIds }),
1358
+ ...(begun.autoIncrementValues === undefined
1359
+ ? {}
1360
+ : { autoIncrementValues: begun.autoIncrementValues }),
1361
+ };
709
1362
  }
710
1363
  async resume(transactionId) {
711
1364
  const record = await this.store.getTransaction(transactionId);
@@ -713,17 +1366,27 @@ export class TransactionManager {
713
1366
  throw new Error(`Transaction not found: ${transactionId}`);
714
1367
  if (record.status !== "active")
715
1368
  throw new TransactionClosedError(transactionId);
716
- return new DatabaseTransaction(this.store, record, this.#now);
1369
+ const transaction = new DatabaseTransaction(this.store, record, this.#now, this.#transactionTtlMs, this.#createWeakRef);
1370
+ for (const segmentId of record.pendingSegmentIds) {
1371
+ const segment = await this.store.getSegment(segmentId);
1372
+ if (segment?.level === 0) {
1373
+ transaction.limitLevelZeroSegments(segment.tableId, MAX_LEVEL_ZERO_SEGMENTS);
1374
+ }
1375
+ }
1376
+ await transaction.renew();
1377
+ return transaction;
1378
+ }
1379
+ #transactionExpiry() {
1380
+ return expiryAfter(this.#now(), this.#transactionTtlMs);
717
1381
  }
718
1382
  async openSnapshot(version) {
719
1383
  if (version === null)
720
- return new Snapshot(this.store, null, []);
1384
+ return new Snapshot(this.store, null);
721
1385
  if (version !== undefined)
722
1386
  return loadSnapshot(this.store, version);
723
- const current = await this.store.getCurrentManifest();
724
- return new Snapshot(this.store, current?.version ?? null, current?.blockIds ?? []);
1387
+ return new Snapshot(this.store, await this.store.getCurrentManifestVersion());
725
1388
  }
726
- async openLeasedSnapshot(options, knownBlockIds) {
1389
+ async openLeasedSnapshot(options) {
727
1390
  validateTtl(options.ttlMs);
728
1391
  if (options.ownerId.trim().length === 0)
729
1392
  throw new TypeError("Lease owner cannot be empty");
@@ -732,20 +1395,22 @@ export class TransactionManager {
732
1395
  }
733
1396
  const id = options.id ?? this.#createId();
734
1397
  for (;;) {
735
- const snapshot = knownBlockIds === undefined
736
- ? await this.openSnapshot(options.version)
737
- : new Snapshot(this.store, options.version ?? null, knownBlockIds);
1398
+ const snapshot = new Snapshot(this.store, options.version === undefined
1399
+ ? await this.store.getCurrentManifestVersion()
1400
+ : options.version);
1401
+ const createdAt = dateIsoString(this.#now());
738
1402
  const record = {
739
1403
  id,
740
1404
  kind: options.kind ?? "reader",
741
1405
  manifestVersion: snapshot.version,
742
1406
  ownerId: options.ownerId,
743
- expiresAt: new Date(this.#now().getTime() + options.ttlMs).toISOString(),
1407
+ createdAt,
1408
+ expiresAt: expiryAfter(new Date(createdAt), options.ttlMs),
744
1409
  revision: 0,
745
1410
  };
746
1411
  try {
747
1412
  await this.store.createLease(record);
748
- return new LeasedSnapshot(this.store, snapshot.version, snapshot.listBlockIds(), record, this.#now);
1413
+ return new LeasedSnapshot(this.store, snapshot.version, record, this.#now);
749
1414
  }
750
1415
  catch (error) {
751
1416
  if (options.version === undefined && error instanceof SnapshotManifestMissingError) {
@@ -763,36 +1428,38 @@ export class TransactionManager {
763
1428
  * given snapshot is closed on return either way; a missing target manifest throws
764
1429
  * `SnapshotManifestMissingError` and leaves it open.
765
1430
  */
766
- async moveLeasedSnapshot(snapshot, options, knownBlockIds) {
1431
+ async moveLeasedSnapshot(snapshot, options) {
767
1432
  validateTtl(options.ttlMs);
768
1433
  try {
769
- const moved = await snapshot.moveTo(options.version, options.ttlMs, knownBlockIds);
1434
+ const moved = await snapshot.moveTo(options.version, options.ttlMs);
770
1435
  if (moved !== undefined)
771
1436
  return moved;
772
1437
  }
773
1438
  catch (error) {
774
- if (!(error instanceof LeaseConflictError))
1439
+ if (!(error instanceof LeaseConflictError) && !(error instanceof LeaseExpiredError)) {
775
1440
  throw error;
1441
+ }
776
1442
  // Not ours to move any more — swept after expiring, most likely. A fresh lease takes its
777
1443
  // place; whatever record remains is left to expire rather than deleted from under anyone.
778
- const replacement = await this.openLeasedSnapshot(options, knownBlockIds);
1444
+ const replacement = await this.openLeasedSnapshot(options);
779
1445
  snapshot.discard();
780
1446
  return replacement;
781
1447
  }
782
- const replacement = await this.openLeasedSnapshot(options, knownBlockIds);
1448
+ const replacement = await this.openLeasedSnapshot(options);
783
1449
  await snapshot.release();
784
1450
  return replacement;
785
1451
  }
786
1452
  async removeExpiredLeases(at = this.#now()) {
787
- const timestamp = at.getTime();
1453
+ const timestamp = dateMilliseconds(at);
788
1454
  if (!Number.isFinite(timestamp))
789
1455
  throw new TypeError("Lease cutoff must be a valid date");
790
1456
  const removed = [];
791
- for (const lease of await this.store.listLeases()) {
792
- if (Date.parse(lease.expiresAt) > timestamp)
793
- continue;
1457
+ const cutoff = dateIsoString(at);
1458
+ const page = await this.store.listExpiredLeasePage(cutoff, this.#leaseCleanupCursor, 64);
1459
+ this.#leaseCleanupCursor = page.nextCursor;
1460
+ for (const lease of page.records) {
794
1461
  try {
795
- if (await this.store.removeLeaseIfExpired(lease.id, lease.revision, at.toISOString())) {
1462
+ if (await this.store.removeLeaseIfExpired(lease.id, lease.revision, cutoff)) {
796
1463
  removed.push(lease.id);
797
1464
  }
798
1465
  }
@@ -802,10 +1469,10 @@ export class TransactionManager {
802
1469
  throw error;
803
1470
  }
804
1471
  }
805
- return removed.sort();
1472
+ return removed;
806
1473
  }
807
1474
  async recover(options) {
808
- const staleBefore = options.staleBefore.getTime();
1475
+ const staleBefore = dateMilliseconds(options.staleBefore);
809
1476
  if (!Number.isFinite(staleBefore))
810
1477
  throw new TypeError("Recovery cutoff must be a valid date");
811
1478
  const abortedTransactionIds = [];
@@ -816,16 +1483,22 @@ export class TransactionManager {
816
1483
  do {
817
1484
  const page = await this.store.listTransactionPage(transactionCursor, 64);
818
1485
  for (const record of page.records) {
819
- if (record.status !== "active" || Date.parse(record.updatedAt) >= staleBefore)
1486
+ if (record.status !== "active" || Date.parse(record.expiresAt) > staleBefore)
820
1487
  continue;
821
1488
  try {
822
- await this.store.updateTransaction(record.id, record.revision, {
823
- status: "aborted",
824
- updatedAt: this.#now().toISOString(),
1489
+ const aborted = await this.store.abortTransactionIfExpired({
1490
+ transactionId: record.id,
1491
+ expectedOwnerId: record.ownerId,
1492
+ expiresAtCutoff: dateIsoString(options.staleBefore),
1493
+ updatedAt: dateIsoString(this.#now()),
825
1494
  });
1495
+ if (aborted === undefined) {
1496
+ skippedTransactionIds.push(record.id);
1497
+ continue;
1498
+ }
826
1499
  abortedTransactionIds.push(record.id);
827
- record.pendingBlockIds.forEach((id) => candidates.add(id));
828
- record.pendingSegmentIds.forEach((id) => segmentCandidates.add(id));
1500
+ aborted.pendingBlockIds.forEach((id) => candidates.add(id));
1501
+ aborted.pendingSegmentIds.forEach((id) => segmentCandidates.add(id));
829
1502
  }
830
1503
  catch (error) {
831
1504
  if (error instanceof TransactionRecordConflictError) {
@@ -846,7 +1519,7 @@ export class TransactionManager {
846
1519
  retainedSegmentIds.push(...[...segmentCandidates].sort());
847
1520
  }
848
1521
  else if (candidates.size > 0 || segmentCandidates.size > 0) {
849
- const timestamp = this.#now().toISOString();
1522
+ const timestamp = dateIsoString(this.#now());
850
1523
  const baseId = `recovery/${timestamp}/${this.#createId()}`;
851
1524
  let suffix = 0;
852
1525
  let job;
@@ -879,7 +1552,7 @@ export class TransactionManager {
879
1552
  jobId: job.id,
880
1553
  expectedRevision: job.revision,
881
1554
  maxItems: 128,
882
- updatedAt: this.#now().toISOString(),
1555
+ updatedAt: dateIsoString(this.#now()),
883
1556
  });
884
1557
  removedBlockIds.push(...step.reclaimedBlockIds);
885
1558
  retainedBlockIds.push(...step.retainedBlockIds);
@@ -908,17 +1581,126 @@ export class TransactionManager {
908
1581
  }
909
1582
  }
910
1583
  function validateTtl(ttlMs) {
911
- if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
912
- throw new RangeError("Lease lifetime must be a positive number of milliseconds");
1584
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > MAX_LEASE_TTL_MS) {
1585
+ throw new RangeError(`Lease lifetime must be a positive number no greater than ${String(MAX_LEASE_TTL_MS)} milliseconds`);
1586
+ }
1587
+ }
1588
+ function expiryAfter(now, ttlMs) {
1589
+ validateTtl(ttlMs);
1590
+ const expiresAt = dateMilliseconds(now) + ttlMs;
1591
+ if (!Number.isSafeInteger(expiresAt) || expiresAt > 8_640_000_000_000_000) {
1592
+ throw new RangeError("Lease expiry is outside the supported Date range");
1593
+ }
1594
+ return dateIsoString(new Date(expiresAt));
1595
+ }
1596
+ function advanceRevision(revision, amount) {
1597
+ if (!Number.isSafeInteger(revision) ||
1598
+ revision < 0 ||
1599
+ revision > Number.MAX_SAFE_INTEGER - amount) {
1600
+ throw new RangeError("Transaction revision is outside the safe integer range");
1601
+ }
1602
+ return revision + amount;
1603
+ }
1604
+ function checkpointByteSum(total, addition) {
1605
+ const next = total + addition;
1606
+ if (!Number.isSafeInteger(next)) {
1607
+ throw new RangeError("Transaction checkpoint size exceeds the safe integer range");
1608
+ }
1609
+ return next;
1610
+ }
1611
+ function safeWholeNumberSum(values, label) {
1612
+ const total = values.reduce((sum, value) => sum + value, 0);
1613
+ if (!Number.isSafeInteger(total) || total < 0) {
1614
+ throw new RangeError(`${label} exceeds the safe integer range`);
1615
+ }
1616
+ return total;
1617
+ }
1618
+ function requiredCompactionJobId(id) {
1619
+ if (id === null)
1620
+ throw new Error("Compaction source blocks require a compaction job");
1621
+ return id;
1622
+ }
1623
+ function sameSegmentArtifacts(left, right) {
1624
+ const leftColumns = Object.keys(left.columnBlockIds).sort();
1625
+ const rightColumns = Object.keys(right.columnBlockIds).sort();
1626
+ const leftSpans = left.rowIdSpans;
1627
+ const rightSpans = right.rowIdSpans;
1628
+ return (left.id === right.id &&
1629
+ left.tableId === right.tableId &&
1630
+ left.rowCount === right.rowCount &&
1631
+ left.rowIdStart === right.rowIdStart &&
1632
+ left.rowIdEndExclusive === right.rowIdEndExclusive &&
1633
+ left.kind === right.kind &&
1634
+ left.keyColumnId === right.keyColumnId &&
1635
+ left.level === right.level &&
1636
+ left.logicalOrder === right.logicalOrder &&
1637
+ left.commitOrdinal === right.commitOrdinal &&
1638
+ left.partitionOrdinal === right.partitionOrdinal &&
1639
+ left.createdAt === right.createdAt &&
1640
+ leftColumns.length === rightColumns.length &&
1641
+ leftColumns.every((columnId, index) => {
1642
+ if (columnId !== rightColumns[index])
1643
+ return false;
1644
+ const leftIds = left.columnBlockIds[columnId] ?? [];
1645
+ const rightIds = right.columnBlockIds[columnId] ?? [];
1646
+ return (leftIds.length === rightIds.length && leftIds.every((id, item) => id === rightIds[item]));
1647
+ }) &&
1648
+ leftSpans.length === rightSpans.length &&
1649
+ leftSpans.every((span, index) => {
1650
+ const other = rightSpans[index];
1651
+ return (span.rowStart === other?.rowStart &&
1652
+ span.rowCount === other.rowCount &&
1653
+ span.rowIdStart === other.rowIdStart);
1654
+ }));
1655
+ }
1656
+ function transactionBlockBatches(blocks) {
1657
+ const batches = [];
1658
+ let batch = [];
1659
+ let batchBytes = 0;
1660
+ for (const block of blocks) {
1661
+ assertTransactionArtifactBatchLimits([block], []);
1662
+ if (batch.length === MAX_TRANSACTION_STAGE_BLOCKS ||
1663
+ batchBytes + block.bytes.byteLength > MAX_TRANSACTION_STAGE_BYTES) {
1664
+ batches.push(batch);
1665
+ batch = [];
1666
+ batchBytes = 0;
1667
+ }
1668
+ batch.push(block);
1669
+ batchBytes += block.bytes.byteLength;
1670
+ }
1671
+ if (batch.length > 0)
1672
+ batches.push(batch);
1673
+ return batches;
1674
+ }
1675
+ function transactionArtifactBatches(blocks, segments) {
1676
+ let blockBytes = 0;
1677
+ for (const block of blocks) {
1678
+ assertTransactionArtifactBatchLimits([block], []);
1679
+ blockBytes += block.bytes.byteLength;
1680
+ }
1681
+ if (blocks.length <= MAX_TRANSACTION_STAGE_BLOCKS &&
1682
+ segments.length <= MAX_TRANSACTION_STAGE_SEGMENTS &&
1683
+ blockBytes <= MAX_TRANSACTION_STAGE_BYTES) {
1684
+ return [{ blocks, segments }];
1685
+ }
1686
+ const batches = transactionBlockBatches(blocks).map((batch) => ({
1687
+ blocks: batch,
1688
+ segments: [],
1689
+ }));
1690
+ for (let start = 0; start < segments.length; start += MAX_TRANSACTION_STAGE_SEGMENTS) {
1691
+ batches.push({
1692
+ blocks: [],
1693
+ segments: segments.slice(start, start + MAX_TRANSACTION_STAGE_SEGMENTS),
1694
+ });
913
1695
  }
1696
+ return batches;
914
1697
  }
915
1698
  async function loadSnapshot(store, version) {
916
1699
  if (version === null)
917
- return new Snapshot(store, null, []);
1700
+ return new Snapshot(store, null);
918
1701
  const manifest = await store.getManifest(version);
919
1702
  if (manifest === undefined || manifest.prunedAt !== undefined) {
920
1703
  throw new SnapshotManifestMissingError(version);
921
1704
  }
922
- return new Snapshot(store, manifest.version, manifest.blockIds);
1705
+ return new Snapshot(store, manifest.version);
923
1706
  }
924
- //# sourceMappingURL=index.js.map