@minnowdb/core 0.2.1 → 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 (270) hide show
  1. package/README.md +23 -32
  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 +2 -1
  21. package/dist/engine/artifact-cache.js +5 -1
  22. package/dist/engine/batch.d.ts +11 -3
  23. package/dist/engine/batch.js +17 -7
  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 +24 -5
  29. package/dist/engine/catalog.js +34 -7
  30. package/dist/engine/client.d.ts +29 -7
  31. package/dist/engine/client.js +216 -40
  32. package/dist/engine/database.d.ts +224 -39
  33. package/dist/engine/database.js +7475 -1539
  34. package/dist/engine/defaults.d.ts +4 -8
  35. package/dist/engine/defaults.js +46 -22
  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 +41 -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 -10
  43. package/dist/engine/index.js +12 -9
  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 +504 -126
  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 +262 -27
  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 +1 -2
  59. package/dist/engine/query-cache.js +7 -5
  60. package/dist/engine/query.d.ts +134 -29
  61. package/dist/engine/query.js +1668 -372
  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 +6 -3
  65. package/dist/engine/schema-wire.js +40 -34
  66. package/dist/engine/schema.d.ts +156 -80
  67. package/dist/engine/schema.js +570 -108
  68. package/dist/engine/sort-keys.d.ts +4 -4
  69. package/dist/engine/sort-keys.js +44 -29
  70. package/dist/engine/sql-domains.d.ts +31 -0
  71. package/dist/engine/sql-domains.js +585 -0
  72. package/dist/engine/sql-driver.d.ts +19 -0
  73. package/dist/engine/sql-driver.js +1 -0
  74. package/dist/engine/sql-json.d.ts +7 -9
  75. package/dist/engine/sql-json.js +75 -15
  76. package/dist/engine/sql-semantics.d.ts +8 -3
  77. package/dist/engine/sql-semantics.js +458 -30
  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 +12 -2
  81. package/dist/engine/vector.js +635 -119
  82. package/dist/engine/worker-host.d.ts +9 -2
  83. package/dist/engine/worker-host.js +620 -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 +3 -4
  91. package/dist/plan/index.js +3 -4
  92. package/dist/storage/index.d.ts +1 -1
  93. package/dist/storage/index.js +1 -1
  94. package/dist/storage/indexeddb.d.ts +99 -51
  95. package/dist/storage/indexeddb.js +13448 -2660
  96. package/dist/storage/memory.d.ts +64 -57
  97. package/dist/storage/memory.js +1042 -128
  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 +409 -75
  103. package/dist/storage/opfs/leader.js +4621 -650
  104. package/dist/storage/opfs/rpc.d.ts +15 -3
  105. package/dist/storage/opfs/rpc.js +224 -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 +33 -99
  109. package/dist/storage/opfs/store.js +453 -364
  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 -85
  115. package/dist/storage/snapshot.js +33 -265
  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 +4 -5
  119. package/dist/storage/toolkit/index.js +28 -4
  120. package/dist/storage/toolkit/record-core.d.ts +182 -55
  121. package/dist/storage/toolkit/record-core.js +6158 -1331
  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 +15 -2
  127. package/dist/storage/toolkit/wire.js +199 -10
  128. package/dist/storage/types.d.ts +1439 -204
  129. package/dist/storage/types.js +1745 -141
  130. package/dist/testing/block-store-conformance.d.ts +0 -1
  131. package/dist/testing/block-store-conformance.js +1009 -119
  132. package/dist/testing/index.d.ts +46 -26
  133. package/dist/testing/index.js +112 -57
  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 +97 -0
  137. package/dist/testing/simulator.js +591 -0
  138. package/dist/testing/sqllogictest.d.ts +89 -0
  139. package/dist/testing/sqllogictest.js +434 -0
  140. package/dist/transactions/index.d.ts +92 -16
  141. package/dist/transactions/index.js +1074 -236
  142. package/dist/worker-protocol/index.d.ts +3 -2
  143. package/dist/worker-protocol/index.js +2 -5
  144. package/package.json +53 -3
  145. package/postgres-feature-profile.json +223 -0
  146. package/sql-feature-matrix.json +172 -252
  147. package/dist/block-format/block.d.ts.map +0 -1
  148. package/dist/block-format/block.js.map +0 -1
  149. package/dist/block-format/checksum.d.ts.map +0 -1
  150. package/dist/block-format/checksum.js.map +0 -1
  151. package/dist/block-format/codecs.d.ts.map +0 -1
  152. package/dist/block-format/codecs.js.map +0 -1
  153. package/dist/block-format/column.d.ts.map +0 -1
  154. package/dist/block-format/column.js.map +0 -1
  155. package/dist/block-format/index.d.ts.map +0 -1
  156. package/dist/block-format/index.js.map +0 -1
  157. package/dist/block-format/physical.d.ts.map +0 -1
  158. package/dist/block-format/physical.js.map +0 -1
  159. package/dist/block-format/types.d.ts.map +0 -1
  160. package/dist/block-format/types.js.map +0 -1
  161. package/dist/engine/artifact-cache.d.ts.map +0 -1
  162. package/dist/engine/artifact-cache.js.map +0 -1
  163. package/dist/engine/batch.d.ts.map +0 -1
  164. package/dist/engine/batch.js.map +0 -1
  165. package/dist/engine/buffered-writer.d.ts.map +0 -1
  166. package/dist/engine/buffered-writer.js.map +0 -1
  167. package/dist/engine/catalog.d.ts.map +0 -1
  168. package/dist/engine/catalog.js.map +0 -1
  169. package/dist/engine/client.d.ts.map +0 -1
  170. package/dist/engine/client.js.map +0 -1
  171. package/dist/engine/coordinator.d.ts +0 -17
  172. package/dist/engine/coordinator.d.ts.map +0 -1
  173. package/dist/engine/coordinator.js +0 -60
  174. package/dist/engine/coordinator.js.map +0 -1
  175. package/dist/engine/database.d.ts.map +0 -1
  176. package/dist/engine/database.js.map +0 -1
  177. package/dist/engine/defaults.d.ts.map +0 -1
  178. package/dist/engine/defaults.js.map +0 -1
  179. package/dist/engine/errors.d.ts.map +0 -1
  180. package/dist/engine/errors.js.map +0 -1
  181. package/dist/engine/fts.d.ts.map +0 -1
  182. package/dist/engine/fts.js.map +0 -1
  183. package/dist/engine/group-index.d.ts.map +0 -1
  184. package/dist/engine/group-index.js.map +0 -1
  185. package/dist/engine/index.d.ts.map +0 -1
  186. package/dist/engine/index.js.map +0 -1
  187. package/dist/engine/join-index.d.ts.map +0 -1
  188. package/dist/engine/join-index.js.map +0 -1
  189. package/dist/engine/live.d.ts.map +0 -1
  190. package/dist/engine/live.js.map +0 -1
  191. package/dist/engine/memory.d.ts.map +0 -1
  192. package/dist/engine/memory.js.map +0 -1
  193. package/dist/engine/optimizer.d.ts.map +0 -1
  194. package/dist/engine/optimizer.js.map +0 -1
  195. package/dist/engine/query-cache.d.ts.map +0 -1
  196. package/dist/engine/query-cache.js.map +0 -1
  197. package/dist/engine/query.d.ts.map +0 -1
  198. package/dist/engine/query.js.map +0 -1
  199. package/dist/engine/result-wire.d.ts.map +0 -1
  200. package/dist/engine/result-wire.js.map +0 -1
  201. package/dist/engine/schema-wire.d.ts.map +0 -1
  202. package/dist/engine/schema-wire.js.map +0 -1
  203. package/dist/engine/schema.d.ts.map +0 -1
  204. package/dist/engine/schema.js.map +0 -1
  205. package/dist/engine/sort-keys.d.ts.map +0 -1
  206. package/dist/engine/sort-keys.js.map +0 -1
  207. package/dist/engine/sql-json.d.ts.map +0 -1
  208. package/dist/engine/sql-json.js.map +0 -1
  209. package/dist/engine/sql-semantics.d.ts.map +0 -1
  210. package/dist/engine/sql-semantics.js.map +0 -1
  211. package/dist/engine/vector.d.ts.map +0 -1
  212. package/dist/engine/vector.js.map +0 -1
  213. package/dist/engine/worker-host.d.ts.map +0 -1
  214. package/dist/engine/worker-host.js.map +0 -1
  215. package/dist/engine/worker.d.ts.map +0 -1
  216. package/dist/engine/worker.js.map +0 -1
  217. package/dist/index.d.ts.map +0 -1
  218. package/dist/index.js.map +0 -1
  219. package/dist/plan/index.d.ts.map +0 -1
  220. package/dist/plan/index.js.map +0 -1
  221. package/dist/storage/fixture-shape.d.ts +0 -42
  222. package/dist/storage/fixture-shape.d.ts.map +0 -1
  223. package/dist/storage/fixture-shape.js +0 -146
  224. package/dist/storage/fixture-shape.js.map +0 -1
  225. package/dist/storage/index.d.ts.map +0 -1
  226. package/dist/storage/index.js.map +0 -1
  227. package/dist/storage/indexeddb.d.ts.map +0 -1
  228. package/dist/storage/indexeddb.js.map +0 -1
  229. package/dist/storage/memory.d.ts.map +0 -1
  230. package/dist/storage/memory.js.map +0 -1
  231. package/dist/storage/opfs/files.d.ts.map +0 -1
  232. package/dist/storage/opfs/files.js.map +0 -1
  233. package/dist/storage/opfs/index.d.ts.map +0 -1
  234. package/dist/storage/opfs/index.js.map +0 -1
  235. package/dist/storage/opfs/leader.d.ts.map +0 -1
  236. package/dist/storage/opfs/leader.js.map +0 -1
  237. package/dist/storage/opfs/rpc.d.ts.map +0 -1
  238. package/dist/storage/opfs/rpc.js.map +0 -1
  239. package/dist/storage/opfs/store.d.ts.map +0 -1
  240. package/dist/storage/opfs/store.js.map +0 -1
  241. package/dist/storage/snapshot.d.ts.map +0 -1
  242. package/dist/storage/snapshot.js.map +0 -1
  243. package/dist/storage/toolkit/extents.d.ts.map +0 -1
  244. package/dist/storage/toolkit/extents.js.map +0 -1
  245. package/dist/storage/toolkit/index.d.ts.map +0 -1
  246. package/dist/storage/toolkit/index.js.map +0 -1
  247. package/dist/storage/toolkit/record-core.d.ts.map +0 -1
  248. package/dist/storage/toolkit/record-core.js.map +0 -1
  249. package/dist/storage/toolkit/sync-file.d.ts.map +0 -1
  250. package/dist/storage/toolkit/sync-file.js.map +0 -1
  251. package/dist/storage/toolkit/wal.d.ts.map +0 -1
  252. package/dist/storage/toolkit/wal.js.map +0 -1
  253. package/dist/storage/toolkit/wire.d.ts.map +0 -1
  254. package/dist/storage/toolkit/wire.js.map +0 -1
  255. package/dist/storage/types.d.ts.map +0 -1
  256. package/dist/storage/types.js.map +0 -1
  257. package/dist/testing/block-store-conformance.d.ts.map +0 -1
  258. package/dist/testing/block-store-conformance.js.map +0 -1
  259. package/dist/testing/index.d.ts.map +0 -1
  260. package/dist/testing/index.js.map +0 -1
  261. package/dist/testing/opfs-shim.d.ts.map +0 -1
  262. package/dist/testing/opfs-shim.js.map +0 -1
  263. package/dist/testing/seeds.d.ts +0 -11
  264. package/dist/testing/seeds.d.ts.map +0 -1
  265. package/dist/testing/seeds.js +0 -50
  266. package/dist/testing/seeds.js.map +0 -1
  267. package/dist/transactions/index.d.ts.map +0 -1
  268. package/dist/transactions/index.js.map +0 -1
  269. package/dist/worker-protocol/index.d.ts.map +0 -1
  270. 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
  /**
@@ -65,20 +73,25 @@ export class LeasedSnapshot extends Snapshot {
65
73
  */
66
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 = await loadSnapshot(this.#store, version);
73
- const expiresAt = new Date(this.now().getTime() + ttlMs).toISOString();
74
- 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
+ });
75
88
  this.#released = true;
76
- return new LeasedSnapshot(this.#store, version, target.listBlockIds(), record, this.now);
89
+ return new LeasedSnapshot(this.#store, version, record, this.now);
77
90
  }
78
91
  async release() {
79
92
  if (this.#released)
80
93
  return;
81
- await this.#store.removeLease(this.#record.id);
94
+ await this.#store.removeLease({ id: this.#record.id, ownerId: this.#record.ownerId });
82
95
  this.#released = true;
83
96
  }
84
97
  /** Closes this snapshot without touching the store — for a lease the store no longer holds. */
@@ -93,12 +106,27 @@ export class LeasedSnapshot extends Snapshot {
93
106
  export class DatabaseTransaction {
94
107
  store;
95
108
  now;
109
+ ttlMs;
96
110
  #record;
97
111
  #uniqueKeyChanges = [];
98
- #ftsChanges = [];
99
- #supersededBlockIds = new Set();
112
+ #ftsChanges = new Map();
113
+ #commitDeltaBytes = 0;
114
+ #commitDeltaEntries = 0;
115
+ #compactionJobId = null;
116
+ #compactionSourceBlockIds = new Set();
100
117
  #changedTableIds = new Set();
118
+ #levelZeroSegmentLimits = new Map();
101
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();
102
130
  /**
103
131
  * Whether the record exists in the store. A deferred transaction (`beginDeferred`) starts
104
132
  * without one: it is written on the first two-step staging call, or folded into the
@@ -106,11 +134,33 @@ export class DatabaseTransaction {
106
134
  * touches nothing.
107
135
  */
108
136
  #persisted;
109
- 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 = {}) {
110
146
  this.store = store;
111
147
  this.now = now;
148
+ this.ttlMs = ttlMs;
112
149
  this.#record = structuredClone(record);
113
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;
114
164
  }
115
165
  get id() {
116
166
  return this.#record.id;
@@ -122,13 +172,43 @@ export class DatabaseTransaction {
122
172
  return this.#record.snapshotVersion;
123
173
  }
124
174
  get pendingBlockIds() {
125
- return [...this.#record.pendingBlockIds];
175
+ return [...this.#record.pendingBlockIds, ...this.#deferredBlocks.map((block) => block.id)];
126
176
  }
127
177
  get pendingSegmentIds() {
128
- return [...this.#record.pendingSegmentIds];
178
+ return [
179
+ ...this.#record.pendingSegmentIds,
180
+ ...this.#deferredSegments.map((segment) => segment.id),
181
+ ];
129
182
  }
130
- get supersededBlockIds() {
131
- return [...this.#supersededBlockIds].sort();
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();
207
+ }
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);
132
212
  }
133
213
  /**
134
214
  * Counts every registration this transaction carries: staged blocks and segments, unique-key
@@ -138,21 +218,185 @@ export class DatabaseTransaction {
138
218
  * since there is no statement-level rollback inside a transaction.
139
219
  */
140
220
  get stagedWorkCount() {
141
- return (this.#record.pendingBlockIds.length +
142
- this.#record.pendingSegmentIds.length +
221
+ return (this.pendingBlockIds.length +
222
+ this.pendingSegmentIds.length +
143
223
  this.#uniqueKeyChanges.length +
144
- this.#ftsChanges.length +
145
- this.#supersededBlockIds.size);
224
+ this.#ftsChanges.size +
225
+ this.#compactionSourceBlockIds.size);
226
+ }
227
+ /** Captures all publish-relevant state; row-id/autoincrement reservations deliberately burn. */
228
+ checkpoint() {
229
+ this.#assertActive();
230
+ return {
231
+ transactionId: this.id,
232
+ pendingBlockIds: this.pendingBlockIds,
233
+ pendingSegmentIds: this.pendingSegmentIds,
234
+ uniqueKeyChanges: structuredClone(this.#uniqueKeyChanges),
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
+ })),
242
+ changedTableIds: [...this.#changedTableIds],
243
+ logicallyUnchanged: this.#logicallyUnchanged,
244
+ };
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
+ }
285
+ /** Rewinds staged artifacts and every in-memory commit delta to one earlier checkpoint. */
286
+ async rollbackTo(checkpoint) {
287
+ this.#assertActive();
288
+ if (checkpoint.transactionId !== this.id) {
289
+ throw new TypeError("A transaction checkpoint belongs to another transaction");
290
+ }
291
+ const blockPrefix = checkpoint.pendingBlockIds;
292
+ const segmentPrefix = checkpoint.pendingSegmentIds;
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))) {
299
+ throw new TypeError("A transaction checkpoint is no longer reachable");
300
+ }
301
+ const removedBlockIds = currentBlockIds.filter((id) => !retainedBlocks.has(id));
302
+ const removedSegmentIds = currentSegmentIds.filter((id) => !retainedSegments.has(id));
303
+ if (this.#persisted && (removedBlockIds.length > 0 || removedSegmentIds.length > 0)) {
304
+ await this.#renewOwnership();
305
+ this.#record = await this.store.rollbackTransactionArtifacts({
306
+ transactionId: this.id,
307
+ expectedRevision: this.#record.revision,
308
+ pendingBlockIds: blockPrefix,
309
+ pendingSegmentIds: segmentPrefix,
310
+ removeBlockIds: removedBlockIds,
311
+ removeSegmentIds: removedSegmentIds,
312
+ updatedAt: dateIsoString(this.now()),
313
+ });
314
+ }
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
+ }
345
+ this.#changedTableIds.clear();
346
+ for (const id of checkpoint.changedTableIds)
347
+ this.#changedTableIds.add(id);
348
+ this.#logicallyUnchanged = checkpoint.logicallyUnchanged;
146
349
  }
147
350
  async snapshot() {
148
351
  return loadSnapshot(this.store, this.#record.snapshotVersion);
149
352
  }
150
353
  async getBlock(id) {
151
354
  this.#assertActive();
355
+ const deferred = this.#deferredBlocks.find((block) => block.id === id);
356
+ if (deferred !== undefined)
357
+ return new Uint8Array(deferred.bytes);
152
358
  if (this.#record.pendingBlockIds.includes(id))
153
359
  return this.store.getBlock(id);
154
360
  return (await this.snapshot()).getBlock(id);
155
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
+ }
156
400
  async stageBlock(id, bytes) {
157
401
  return this.stageBlocks([{ id, bytes }]);
158
402
  }
@@ -160,12 +404,7 @@ export class DatabaseTransaction {
160
404
  this.#assertActive();
161
405
  if (blocks.length === 0)
162
406
  return;
163
- await this.#ensurePersisted();
164
- await this.store.addBlocks(blocks);
165
- this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
166
- pendingBlockIds: [...this.#record.pendingBlockIds, ...blocks.map((block) => block.id)],
167
- updatedAt: this.now().toISOString(),
168
- });
407
+ await this.stageArtifacts(blocks, []);
169
408
  }
170
409
  /**
171
410
  * Stages blocks and segments in one journal step. When the store implements the atomic
@@ -180,31 +419,38 @@ export class DatabaseTransaction {
180
419
  throw new Error(`Segment ${segment.id} belongs to another transaction`);
181
420
  }
182
421
  }
183
- // The journal stores pendingSegmentIds deduped and sorted, so staging position is the
184
- // only record of operation order within this transaction. Stamp it on the segments:
185
- // reads use it to fold same-commit segments in the order they were staged.
186
- 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;
187
426
  const ordered = segments.map((segment, index) => ({
188
427
  ...segment,
189
- commitOrdinal: segment.commitOrdinal ?? ordinalBase + index,
428
+ commitOrdinal: ordinalBase + index,
190
429
  }));
191
- const batched = this.store.stageTransactionArtifacts?.bind(this.store);
192
- if (batched === undefined) {
193
- 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));
194
442
  for (const segment of ordered)
195
- await this.stageSegment(segment);
443
+ this.#knownSegments.set(segment.id, structuredClone(segment));
444
+ for (const segment of ordered)
445
+ this.#changedTableIds.add(segment.tableId);
196
446
  return;
197
447
  }
198
- if (blocks.length === 0 && ordered.length === 0)
199
- return;
200
- await this.#ensurePersisted();
201
- this.#record = await batched({
202
- transactionId: this.id,
203
- expectedRevision: this.#record.revision,
204
- blocks,
205
- segments: ordered,
206
- updatedAt: this.now().toISOString(),
207
- });
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));
208
454
  for (const segment of ordered)
209
455
  this.#changedTableIds.add(segment.tableId);
210
456
  }
@@ -226,62 +472,88 @@ export class DatabaseTransaction {
226
472
  }
227
473
  const pendingBlockIds = new Set(this.#record.pendingBlockIds);
228
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));
229
478
  if (blocks.length + segments.length > 0 &&
230
- blocks.every((block) => pendingBlockIds.has(block.id)) &&
231
- segments.every((segment) => pendingSegmentIds.has(segment.id))) {
479
+ unstagedBlocks.length === 0 &&
480
+ unstagedSegments.length === 0) {
232
481
  return this.commit();
233
482
  }
483
+ this.#assertProspectiveArtifactJournal(unstagedBlocks, unstagedSegments);
234
484
  const single = this.store.writeTransaction?.bind(this.store);
235
- if (single === undefined) {
236
- await this.stageArtifacts(blocks, segments);
485
+ const batches = transactionArtifactBatches(unstagedBlocks, unstagedSegments);
486
+ if (single === undefined || batches.length !== 1) {
487
+ await this.stageArtifacts(unstagedBlocks, unstagedSegments);
237
488
  return this.commit();
238
489
  }
490
+ await this.#renewOwnership();
239
491
  const ordinalBase = this.#record.pendingSegmentIds.length;
240
- const ordered = segments.map((segment, index) => ({
492
+ const ordered = unstagedSegments.map((segment, index) => ({
241
493
  ...segment,
242
- commitOrdinal: segment.commitOrdinal ?? ordinalBase + index,
494
+ commitOrdinal: ordinalBase + index,
243
495
  }));
496
+ assertTransactionArtifactBatchLimits(unstagedBlocks, ordered);
497
+ this.#registerLevelZeroSegments(ordered);
244
498
  const changedTableIds = new Set(this.#changedTableIds);
245
499
  for (const segment of ordered)
246
500
  changedTableIds.add(segment.tableId);
247
501
  const { snapshotVersion: _pinned, ...fresh } = this.#record;
248
502
  void _pinned;
249
- 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);
250
515
  try {
251
516
  const manifest = await single({
252
517
  transaction: this.#persisted
253
518
  ? { id: this.id, expectedRevision: this.#record.revision }
254
519
  : { record: fresh },
255
- blocks,
520
+ blocks: unstagedBlocks,
256
521
  segments: ordered,
257
522
  expectedManifestVersion: this.#record.snapshotVersion,
258
523
  changedTableIds: this.#logicallyUnchanged ? [] : [...changedTableIds],
259
- ...(this.#supersededBlockIds.size === 0
524
+ ...(this.#compactionSourceBlockIds.size === 0
260
525
  ? {}
261
- : { removedBlockIds: [...this.#supersededBlockIds] }),
526
+ : {
527
+ compactionJobId: requiredCompactionJobId(compactionJobId),
528
+ removedBlockIds: [...this.#compactionSourceBlockIds],
529
+ }),
262
530
  ...(this.#uniqueKeyChanges.length === 0
263
531
  ? {}
264
532
  : { uniqueKeyChanges: this.#uniqueKeyChanges }),
265
- ...(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
+ }),
266
542
  committedAt,
267
543
  });
268
- for (const segment of ordered)
269
- this.#changedTableIds.add(segment.tableId);
270
544
  this.#record = {
271
545
  ...this.#record,
272
- pendingBlockIds: [
273
- ...new Set([...pendingBlockIds, ...blocks.map((block) => block.id)]),
274
- ].sort(),
275
- pendingSegmentIds: [
276
- ...new Set([...pendingSegmentIds, ...ordered.map((segment) => segment.id)]),
277
- ].sort(),
546
+ pendingBlockIds: [...pendingBlockIds, ...unstagedBlocks.map((block) => block.id)],
547
+ pendingSegmentIds: [...pendingSegmentIds, ...ordered.map((segment) => segment.id)],
278
548
  status: "committed",
279
549
  committedVersion: manifest.version,
280
550
  // One journal step and one commit, as the two-step shape would have left it.
281
- revision: this.#record.revision + 2,
551
+ revision: committedRevision,
282
552
  updatedAt: committedAt,
283
553
  };
284
554
  this.#persisted = true;
555
+ this.#stopHeartbeat();
556
+ await this.#releaseSnapshotLease().catch(() => undefined);
285
557
  return manifest;
286
558
  }
287
559
  catch (error) {
@@ -297,6 +569,7 @@ export class DatabaseTransaction {
297
569
  */
298
570
  async stageExistingBlocks(blockIds) {
299
571
  this.#assertActive();
572
+ await this.#persistDeferredArtifacts();
300
573
  const pending = new Set(this.#record.pendingBlockIds);
301
574
  const additions = [...new Set(blockIds)].filter((id) => !pending.has(id));
302
575
  if (additions.length === 0)
@@ -304,15 +577,17 @@ export class DatabaseTransaction {
304
577
  if (additions.some((id) => id.length === 0)) {
305
578
  throw new TypeError("Block ID cannot be empty");
306
579
  }
580
+ assertTransactionArtifactJournalLimits([...this.#record.pendingBlockIds, ...additions], this.#record.pendingSegmentIds);
307
581
  for (const id of additions) {
308
582
  if ((await this.store.getBlock(id)) === undefined) {
309
583
  throw new Error(`Cannot stage a missing existing block: ${id}`);
310
584
  }
311
585
  }
312
586
  await this.#ensurePersisted();
587
+ await this.#renewOwnership();
313
588
  this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
314
589
  pendingBlockIds: [...this.#record.pendingBlockIds, ...additions],
315
- updatedAt: this.now().toISOString(),
590
+ updatedAt: dateIsoString(this.now()),
316
591
  });
317
592
  }
318
593
  async stageSegment(record) {
@@ -320,20 +595,12 @@ export class DatabaseTransaction {
320
595
  if (record.transactionId !== this.id) {
321
596
  throw new Error(`Segment ${record.id} belongs to another transaction`);
322
597
  }
323
- if (record.commitOrdinal === undefined) {
324
- record = { ...record, commitOrdinal: this.#record.pendingSegmentIds.length };
325
- }
326
- this.#changedTableIds.add(record.tableId);
327
- await this.#ensurePersisted();
328
- await this.store.addSegment(record);
329
- this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
330
- pendingSegmentIds: [...this.#record.pendingSegmentIds, record.id],
331
- updatedAt: this.now().toISOString(),
332
- });
598
+ await this.stageArtifacts([], [record]);
333
599
  }
334
600
  /** Reconciles a persisted segment whose journal update was interrupted. */
335
601
  async stageExistingSegment(segmentId) {
336
602
  this.#assertActive();
603
+ await this.#persistDeferredArtifacts();
337
604
  if (this.#record.pendingSegmentIds.includes(segmentId))
338
605
  return;
339
606
  const record = await this.store.getSegment(segmentId);
@@ -342,13 +609,65 @@ export class DatabaseTransaction {
342
609
  if (record.transactionId !== this.id) {
343
610
  throw new Error(`Segment ${segmentId} belongs to another transaction`);
344
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]);
345
620
  this.#changedTableIds.add(record.tableId);
346
621
  await this.#ensurePersisted();
622
+ await this.#renewOwnership();
347
623
  this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
348
624
  pendingSegmentIds: [...this.#record.pendingSegmentIds, segmentId],
349
- updatedAt: this.now().toISOString(),
625
+ updatedAt: dateIsoString(this.now()),
350
626
  });
351
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
+ }
352
671
  /**
353
672
  * Records that this commit changes a table's logical content without staging a segment for
354
673
  * it — dropping the table, for instance, whose only publish is the retirement of its blocks.
@@ -373,14 +692,23 @@ export class DatabaseTransaction {
373
692
  /** Appends one operation's key changes; entries commit in operation order. */
374
693
  setUniqueKeyChanges(changes) {
375
694
  this.#assertActive();
376
- 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 = {
377
703
  tableId: changes.tableId,
378
704
  // Deduplicated, not sorted: membership is all a store reads from these, and sorting fifty
379
705
  // thousand tokens cost a bulk delete a fifth of its time.
380
706
  keyTokens: [...new Set(changes.keyTokens)],
381
707
  requireAbsent: changes.requireAbsent,
382
708
  ...(changes.remove === undefined ? {} : { remove: changes.remove }),
383
- });
709
+ };
710
+ this.#reserveCommitDelta([normalized], []);
711
+ this.#uniqueKeyChanges.push(normalized);
384
712
  }
385
713
  /**
386
714
  * Attaches one batch's full-text deltas; applied atomically with the publish. A second
@@ -390,82 +718,196 @@ export class DatabaseTransaction {
390
718
  */
391
719
  setFtsChanges(changes) {
392
720
  this.#assertActive();
393
- const existing = this.#ftsChanges.find((entry) => entry.tableId === changes.tableId);
394
- if (existing === undefined) {
395
- this.#ftsChanges.push(changes);
396
- 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);
397
732
  }
398
- const merged = new Map(existing.columns.map((column) => [column.columnId, column]));
399
733
  for (const column of changes.columns) {
400
- const present = merged.get(column.columnId);
734
+ const present = columns.get(column.columnId);
401
735
  if (present === undefined) {
402
- 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
+ });
403
747
  continue;
404
748
  }
405
- const byTerm = new Map(present.postings.map((posting) => [posting.term, posting]));
406
749
  for (const posting of column.postings) {
407
- const held = byTerm.get(posting.term);
750
+ const held = present.postings.get(posting.term);
408
751
  if (held === undefined) {
409
- byTerm.set(posting.term, posting);
410
- }
411
- else {
412
- byTerm.set(posting.term, {
752
+ present.postings.set(posting.term, {
413
753
  term: posting.term,
414
- rowIds: [...held.rowIds, ...posting.rowIds],
415
- tf: [...held.tf, ...posting.tf],
754
+ rowIds: [...posting.rowIds],
755
+ tf: [...posting.tf],
416
756
  });
417
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
+ }
418
764
  }
419
- merged.set(column.columnId, {
420
- columnId: column.columnId,
421
- postings: [...byTerm.values()].sort((left, right) => left.term < right.term ? -1 : left.term > right.term ? 1 : 0),
422
- totalTokens: present.totalTokens + column.totalTokens,
423
- });
765
+ present.totalTokens = tokenTotals.get(column.columnId) ?? present.totalTokens;
424
766
  }
425
- this.#ftsChanges[this.#ftsChanges.indexOf(existing)] = {
426
- tableId: changes.tableId,
427
- columns: [...merged.values()],
428
- };
429
767
  }
430
- 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) {
431
797
  this.#assertActive();
432
- 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) {
433
816
  if (id.length === 0)
434
- throw new TypeError("Superseded block ID cannot be empty");
817
+ throw new TypeError("Compaction source block ID cannot be empty");
435
818
  if (this.#record.pendingBlockIds.includes(id)) {
436
- 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);
437
844
  }
438
- this.#supersededBlockIds.add(id);
439
845
  }
440
846
  }
441
847
  async commit() {
442
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
+ }
443
868
  await this.#ensurePersisted();
869
+ await this.#renewOwnership();
444
870
  // The store derives the published manifest from its stored base plus this delta, so the
445
871
  // commit neither loads nor rebuilds the full block list.
446
- 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();
447
876
  try {
448
877
  const manifest = await this.store.commitTransaction({
449
878
  transactionId: this.id,
450
879
  expectedTransactionRevision: this.#record.revision,
451
880
  expectedManifestVersion: this.#record.snapshotVersion,
452
881
  changedTableIds: this.#logicallyUnchanged ? [] : [...this.#changedTableIds],
453
- ...(this.#supersededBlockIds.size === 0
882
+ ...(this.#compactionSourceBlockIds.size === 0
454
883
  ? {}
455
- : { removedBlockIds: [...this.#supersededBlockIds] }),
884
+ : {
885
+ compactionJobId: requiredCompactionJobId(compactionJobId),
886
+ removedBlockIds: [...this.#compactionSourceBlockIds],
887
+ }),
456
888
  ...(this.#uniqueKeyChanges.length === 0
457
889
  ? {}
458
890
  : { uniqueKeyChanges: this.#uniqueKeyChanges }),
459
- ...(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
+ }),
460
900
  committedAt,
461
901
  });
462
902
  this.#record = {
463
903
  ...this.#record,
464
904
  status: "committed",
465
905
  committedVersion: manifest.version,
466
- revision: this.#record.revision + 1,
906
+ revision: committedRevision,
467
907
  updatedAt: committedAt,
468
908
  };
909
+ this.#stopHeartbeat();
910
+ await this.#releaseSnapshotLease().catch(() => undefined);
469
911
  return manifest;
470
912
  }
471
913
  catch (error) {
@@ -475,22 +917,25 @@ export class DatabaseTransaction {
475
917
  async rebase() {
476
918
  this.#assertActive();
477
919
  for (;;) {
478
- const current = await this.store.getCurrentManifest();
920
+ const currentVersion = await this.store.getCurrentManifestVersion();
479
921
  if (!this.#persisted) {
480
- // 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);
481
925
  this.#record = {
482
926
  ...this.#record,
483
- snapshotVersion: current?.version ?? null,
484
- updatedAt: this.now().toISOString(),
927
+ snapshotVersion: currentVersion,
928
+ updatedAt: dateIsoString(this.now()),
485
929
  };
486
- return new Snapshot(this.store, current?.version ?? null, current?.blockIds ?? []);
930
+ return new Snapshot(this.store, currentVersion);
487
931
  }
488
932
  try {
933
+ await this.#renewOwnership();
489
934
  this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
490
- snapshotVersion: current?.version ?? null,
491
- updatedAt: this.now().toISOString(),
935
+ snapshotVersion: currentVersion,
936
+ updatedAt: dateIsoString(this.now()),
492
937
  });
493
- return new Snapshot(this.store, current?.version ?? null, current?.blockIds ?? []);
938
+ return new Snapshot(this.store, currentVersion);
494
939
  }
495
940
  catch (error) {
496
941
  if (error instanceof SnapshotManifestMissingError) {
@@ -504,13 +949,21 @@ export class DatabaseTransaction {
504
949
  this.#assertActive();
505
950
  if (!this.#persisted) {
506
951
  // Never written, so there is nothing to mark: the transaction simply ends here.
507
- 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);
508
958
  return;
509
959
  }
960
+ await this.#renewOwnership();
510
961
  this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
511
962
  status: "aborted",
512
- updatedAt: this.now().toISOString(),
963
+ updatedAt: dateIsoString(this.now()),
513
964
  });
965
+ this.#stopHeartbeat();
966
+ await this.#releaseSnapshotLease().catch(() => undefined);
514
967
  }
515
968
  #assertActive() {
516
969
  if (this.#record.status !== "active")
@@ -524,6 +977,11 @@ export class DatabaseTransaction {
524
977
  async #ensurePersisted() {
525
978
  if (this.#persisted)
526
979
  return;
980
+ this.#record = {
981
+ ...this.#record,
982
+ expiresAt: this.#nextExpiry(),
983
+ updatedAt: dateIsoString(this.now()),
984
+ };
527
985
  try {
528
986
  await this.store.createTransaction(this.#record);
529
987
  }
@@ -534,6 +992,19 @@ export class DatabaseTransaction {
534
992
  throw error;
535
993
  }
536
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;
537
1008
  }
538
1009
  /** A commit whose acknowledgement was lost still committed: answer from the persisted record. */
539
1010
  async #recoverCommitted(error) {
@@ -543,85 +1014,292 @@ export class DatabaseTransaction {
543
1014
  if (manifest !== undefined) {
544
1015
  this.#record = persisted;
545
1016
  this.#persisted = true;
1017
+ this.#stopHeartbeat();
1018
+ await this.#releaseSnapshotLease().catch(() => undefined);
546
1019
  return manifest;
547
1020
  }
548
1021
  }
549
1022
  throw error;
550
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]));
551
1213
  }
552
1214
  export class TransactionManager {
553
1215
  store;
554
1216
  #now;
555
1217
  #createId;
1218
+ #transactionTtlMs;
1219
+ #leaseCleanupCursor = null;
1220
+ #createWeakRef;
556
1221
  constructor(store, options = {}) {
557
1222
  this.store = store;
558
1223
  this.#now = options.now ?? (() => new Date());
559
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));
560
1228
  }
561
1229
  async begin() {
562
1230
  return (await this.beginWithReservation()).transaction;
563
1231
  }
564
- /**
565
- * Begins a transaction without a storage write: the record is pinned at the current manifest
566
- * version (one read) and written only when something is staged through the two-step methods
567
- * or never, when the whole write goes through `stageArtifactsAndCommit` on a store with
568
- * the single-shot `writeTransaction`, or the transaction aborts untouched. For writes whose
569
- * artifacts need no prior reservation; `beginWithReservation` is the shape for the rest.
570
- */
571
- async beginDeferred() {
572
- const currentVersion = await this.store.getCurrentManifestVersion();
573
- const timestamp = this.#now().toISOString();
574
- return new DatabaseTransaction(this.store, {
575
- id: this.#createId(),
576
- snapshotVersion: currentVersion,
577
- pendingBlockIds: [],
578
- pendingSegmentIds: [],
579
- status: "active",
580
- revision: 0,
581
- startedAt: timestamp,
582
- updatedAt: timestamp,
583
- committedVersion: null,
584
- }, 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);
585
1265
  }
586
1266
  /**
587
- * Begins a transaction at the current manifest version and optionally reserves row ids and
588
- * auto-increment values in the same step. Stores implementing the atomic `beginTransaction`
589
- * pay one storage round trip instead of the version-read/create/reserve sequence; the
590
- * 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.
591
1272
  */
592
- async beginWithReservation(reserveRowIds, reserveAutoIncrement) {
593
- const id = this.#createId();
594
- const batched = this.store.beginTransaction?.bind(this.store);
595
- if (batched !== undefined) {
596
- const timestamp = this.#now().toISOString();
597
- const begun = await batched({
598
- record: {
599
- id,
600
- pendingBlockIds: [],
601
- pendingSegmentIds: [],
602
- status: "active",
603
- revision: 0,
604
- startedAt: timestamp,
605
- updatedAt: timestamp,
606
- committedVersion: null,
607
- },
608
- ...(reserveRowIds === undefined ? {} : { reserveRowIds }),
609
- ...(reserveAutoIncrement === undefined ? {} : { reserveAutoIncrement }),
610
- });
611
- return {
612
- transaction: new DatabaseTransaction(this.store, begun.record, this.#now),
613
- ...(begun.rowIds === undefined ? {} : { rowIds: begun.rowIds }),
614
- ...(begun.autoIncrementValues === undefined
615
- ? {}
616
- : { autoIncrementValues: begun.autoIncrementValues }),
617
- };
618
- }
1273
+ async beginDeferred(options = {}) {
1274
+ const durableSnapshot = options.durableSnapshot ?? true;
619
1275
  for (;;) {
620
- const currentVersion = await this.store.getCurrentManifestVersion();
621
- const timestamp = this.#now().toISOString();
622
- 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, {
623
1298
  id,
624
- snapshotVersion: currentVersion,
1299
+ ownerId,
1300
+ expiresAt: this.#transactionExpiry(),
1301
+ snapshotVersion: probe.manifestVersion,
1302
+ schemaEpochGuard: probe.schemaEpoch,
625
1303
  pendingBlockIds: [],
626
1304
  pendingSegmentIds: [],
627
1305
  status: "active",
@@ -629,29 +1307,58 @@ export class TransactionManager {
629
1307
  startedAt: timestamp,
630
1308
  updatedAt: timestamp,
631
1309
  committedVersion: null,
632
- };
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 (;;) {
633
1329
  try {
634
- await this.store.createTransaction(record);
635
- const transaction = new DatabaseTransaction(this.store, record, this.#now);
636
- const autoIncrementValues = reserveAutoIncrement === undefined
637
- ? undefined
638
- : await this.store.reserveAutoIncrement(reserveAutoIncrement.tableId, reserveAutoIncrement.columnId, reserveAutoIncrement.count, reserveAutoIncrement.atLeast);
639
- const rowIds = reserveRowIds === undefined
640
- ? undefined
641
- : await this.store.reserveRowIds(reserveRowIds.tableId, reserveRowIds.count);
642
- return {
643
- transaction,
644
- ...(rowIds === undefined ? {} : { rowIds }),
645
- ...(autoIncrementValues === undefined ? {} : { autoIncrementValues }),
646
- };
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;
647
1347
  }
648
1348
  catch (error) {
649
- if (error instanceof SnapshotManifestMissingError) {
1349
+ // The store's atomic begin aborted in full, including any counter reservation.
1350
+ if (error instanceof SnapshotManifestMissingError)
650
1351
  continue;
651
- }
652
1352
  throw error;
653
1353
  }
654
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
+ };
655
1362
  }
656
1363
  async resume(transactionId) {
657
1364
  const record = await this.store.getTransaction(transactionId);
@@ -659,15 +1366,25 @@ export class TransactionManager {
659
1366
  throw new Error(`Transaction not found: ${transactionId}`);
660
1367
  if (record.status !== "active")
661
1368
  throw new TransactionClosedError(transactionId);
662
- 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);
663
1381
  }
664
1382
  async openSnapshot(version) {
665
1383
  if (version === null)
666
- return new Snapshot(this.store, null, []);
1384
+ return new Snapshot(this.store, null);
667
1385
  if (version !== undefined)
668
1386
  return loadSnapshot(this.store, version);
669
- const current = await this.store.getCurrentManifest();
670
- return new Snapshot(this.store, current?.version ?? null, current?.blockIds ?? []);
1387
+ return new Snapshot(this.store, await this.store.getCurrentManifestVersion());
671
1388
  }
672
1389
  async openLeasedSnapshot(options) {
673
1390
  validateTtl(options.ttlMs);
@@ -678,18 +1395,22 @@ export class TransactionManager {
678
1395
  }
679
1396
  const id = options.id ?? this.#createId();
680
1397
  for (;;) {
681
- const snapshot = await this.openSnapshot(options.version);
1398
+ const snapshot = new Snapshot(this.store, options.version === undefined
1399
+ ? await this.store.getCurrentManifestVersion()
1400
+ : options.version);
1401
+ const createdAt = dateIsoString(this.#now());
682
1402
  const record = {
683
1403
  id,
684
1404
  kind: options.kind ?? "reader",
685
1405
  manifestVersion: snapshot.version,
686
1406
  ownerId: options.ownerId,
687
- expiresAt: new Date(this.#now().getTime() + options.ttlMs).toISOString(),
1407
+ createdAt,
1408
+ expiresAt: expiryAfter(new Date(createdAt), options.ttlMs),
688
1409
  revision: 0,
689
1410
  };
690
1411
  try {
691
1412
  await this.store.createLease(record);
692
- return new LeasedSnapshot(this.store, snapshot.version, snapshot.listBlockIds(), record, this.#now);
1413
+ return new LeasedSnapshot(this.store, snapshot.version, record, this.#now);
693
1414
  }
694
1415
  catch (error) {
695
1416
  if (options.version === undefined && error instanceof SnapshotManifestMissingError) {
@@ -715,8 +1436,9 @@ export class TransactionManager {
715
1436
  return moved;
716
1437
  }
717
1438
  catch (error) {
718
- if (!(error instanceof LeaseConflictError))
1439
+ if (!(error instanceof LeaseConflictError) && !(error instanceof LeaseExpiredError)) {
719
1440
  throw error;
1441
+ }
720
1442
  // Not ours to move any more — swept after expiring, most likely. A fresh lease takes its
721
1443
  // place; whatever record remains is left to expire rather than deleted from under anyone.
722
1444
  const replacement = await this.openLeasedSnapshot(options);
@@ -728,15 +1450,16 @@ export class TransactionManager {
728
1450
  return replacement;
729
1451
  }
730
1452
  async removeExpiredLeases(at = this.#now()) {
731
- const timestamp = at.getTime();
1453
+ const timestamp = dateMilliseconds(at);
732
1454
  if (!Number.isFinite(timestamp))
733
1455
  throw new TypeError("Lease cutoff must be a valid date");
734
1456
  const removed = [];
735
- for (const lease of await this.store.listLeases()) {
736
- if (Date.parse(lease.expiresAt) > timestamp)
737
- 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) {
738
1461
  try {
739
- if (await this.store.removeLeaseIfExpired(lease.id, lease.revision, at.toISOString())) {
1462
+ if (await this.store.removeLeaseIfExpired(lease.id, lease.revision, cutoff)) {
740
1463
  removed.push(lease.id);
741
1464
  }
742
1465
  }
@@ -746,10 +1469,10 @@ export class TransactionManager {
746
1469
  throw error;
747
1470
  }
748
1471
  }
749
- return removed.sort();
1472
+ return removed;
750
1473
  }
751
1474
  async recover(options) {
752
- const staleBefore = options.staleBefore.getTime();
1475
+ const staleBefore = dateMilliseconds(options.staleBefore);
753
1476
  if (!Number.isFinite(staleBefore))
754
1477
  throw new TypeError("Recovery cutoff must be a valid date");
755
1478
  const abortedTransactionIds = [];
@@ -760,16 +1483,22 @@ export class TransactionManager {
760
1483
  do {
761
1484
  const page = await this.store.listTransactionPage(transactionCursor, 64);
762
1485
  for (const record of page.records) {
763
- if (record.status !== "active" || Date.parse(record.updatedAt) >= staleBefore)
1486
+ if (record.status !== "active" || Date.parse(record.expiresAt) > staleBefore)
764
1487
  continue;
765
1488
  try {
766
- await this.store.updateTransaction(record.id, record.revision, {
767
- status: "aborted",
768
- 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()),
769
1494
  });
1495
+ if (aborted === undefined) {
1496
+ skippedTransactionIds.push(record.id);
1497
+ continue;
1498
+ }
770
1499
  abortedTransactionIds.push(record.id);
771
- record.pendingBlockIds.forEach((id) => candidates.add(id));
772
- record.pendingSegmentIds.forEach((id) => segmentCandidates.add(id));
1500
+ aborted.pendingBlockIds.forEach((id) => candidates.add(id));
1501
+ aborted.pendingSegmentIds.forEach((id) => segmentCandidates.add(id));
773
1502
  }
774
1503
  catch (error) {
775
1504
  if (error instanceof TransactionRecordConflictError) {
@@ -790,7 +1519,7 @@ export class TransactionManager {
790
1519
  retainedSegmentIds.push(...[...segmentCandidates].sort());
791
1520
  }
792
1521
  else if (candidates.size > 0 || segmentCandidates.size > 0) {
793
- const timestamp = this.#now().toISOString();
1522
+ const timestamp = dateIsoString(this.#now());
794
1523
  const baseId = `recovery/${timestamp}/${this.#createId()}`;
795
1524
  let suffix = 0;
796
1525
  let job;
@@ -823,7 +1552,7 @@ export class TransactionManager {
823
1552
  jobId: job.id,
824
1553
  expectedRevision: job.revision,
825
1554
  maxItems: 128,
826
- updatedAt: this.#now().toISOString(),
1555
+ updatedAt: dateIsoString(this.#now()),
827
1556
  });
828
1557
  removedBlockIds.push(...step.reclaimedBlockIds);
829
1558
  retainedBlockIds.push(...step.retainedBlockIds);
@@ -852,17 +1581,126 @@ export class TransactionManager {
852
1581
  }
853
1582
  }
854
1583
  function validateTtl(ttlMs) {
855
- if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
856
- 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
+ });
857
1695
  }
1696
+ return batches;
858
1697
  }
859
1698
  async function loadSnapshot(store, version) {
860
1699
  if (version === null)
861
- return new Snapshot(store, null, []);
1700
+ return new Snapshot(store, null);
862
1701
  const manifest = await store.getManifest(version);
863
1702
  if (manifest === undefined || manifest.prunedAt !== undefined) {
864
1703
  throw new SnapshotManifestMissingError(version);
865
1704
  }
866
- return new Snapshot(store, manifest.version, manifest.blockIds);
1705
+ return new Snapshot(store, manifest.version);
867
1706
  }
868
- //# sourceMappingURL=index.js.map