@optimystic/db-p2p 0.24.2 → 0.25.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 (249) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,116 +1,172 @@
1
- import type { IBlock, Transform, ActionId, ActionRev } from "@optimystic/db-core";
2
-
3
- /** Interface for block-level storage operations */
4
- export interface IBlockStorage {
5
- /** Gets the latest revision information for this block */
6
- getLatest(): Promise<ActionRev | undefined>;
7
-
8
- /**
9
- * Gets a materialized block at the given revision.
10
- * Returns undefined when the block has no materialized content yet — either
11
- * no metadata exists, or metadata exists (seeded by a pending transaction)
12
- * but no revision has been committed. Throws only when a specific `rev` was
13
- * requested but cannot be located.
14
- */
15
- getBlock(rev?: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined>;
16
-
17
- /** Gets an action by ID */
18
- getTransaction(actionId: ActionId): Promise<Transform | undefined>;
19
-
20
- /** Gets a pending action by ID */
21
- getPendingTransaction(actionId: ActionId): Promise<Transform | undefined>;
22
-
23
- /** Lists all pending action IDs */
24
- listPendingTransactions(): AsyncIterable<ActionId>;
25
-
26
- /** Saves a pending action */
27
- savePendingTransaction(actionId: ActionId, transform: Transform): Promise<void>;
28
-
29
- /** Deletes a pending action */
30
- deletePendingTransaction(actionId: ActionId): Promise<void>;
31
-
32
- /** Lists revisions in ascending or descending order between startRev and endRev (inclusive) */
33
- listRevisions(startRev: number, endRev: number): AsyncIterable<ActionRev>;
34
-
35
- /** Saves a materialized block */
36
- saveMaterializedBlock(actionId: ActionId, block: IBlock | undefined): Promise<void>;
37
-
38
- /**
39
- * Delete the materialized copy at `prior` if it is now redundant under the checkpoint
40
- * retention policy (not the tip, not its range floor, not a checkpoint rev). The forward
41
- * transform for `prior.rev` is retained, so the rev stays reconstructible by replay.
42
- * No-op if `prior.rev` must be retained or has no materialization (e.g. a tombstone rev).
43
- * Must be called under the per-block commit latch (serialized against concurrent commit).
44
- */
45
- pruneSupersededMaterialization(prior: ActionRev): Promise<void>;
46
-
47
- /** Saves a revision */
48
- saveRevision(rev: number, actionId: ActionId): Promise<void>;
49
-
50
- /**
51
- * Promotes a pending action to committed, MOVING the record from the pending namespace to the
52
- * committed one in a single atomic step.
53
- *
54
- * **Invariant P** a block never holds a pending record and a committed record for the same
55
- * action id at the same time. This method maintains it on the commit path; every OTHER writer of
56
- * a committed transform for a block ({@link saveReplica}, {@link saveDeletion}, and any forward
57
- * path added later) must maintain it too, by deleting that action's pending record when it
58
- * writes the committed one. A pending record left beside a committed one can never be promoted,
59
- * and is reported as a phantom conflicting action by `StorageRepo.pend` on every later write to
60
- * the block.
61
- */
62
- promotePendingTransaction(actionId: ActionId): Promise<void>;
63
-
64
- /** Sets the latest revision information */
65
- setLatest(latest: ActionRev): Promise<void>;
66
-
67
- /**
68
- * Persist a replica of a block received out-of-band (churn re-replication).
69
- *
70
- * Seeds metadata if absent, writes `rev → actionId`, the action transform, and the
71
- * materialized block, merges `[rev, rev+1]` into `ranges`, and advances `latest`
72
- * monotonically. When `source` is provided its `rev`/`actionId` are used; otherwise
73
- * it falls back to `rev = 1` and a deterministic `actionId` derived from the block
74
- * (so retries stay idempotent never random).
75
- *
76
- * Maintains **Invariant P** (see {@link promotePendingTransaction}): writing the committed
77
- * transform for `actionId` also deletes that action's pending record on this block, so a node
78
- * that pended the action but diverged before committing it does not keep an unpromotable record.
79
- * Only on the write path the monotonic no-op below deletes nothing.
80
- *
81
- * No-op (still durable) when an equal-or-newer revision is already present: `latest`
82
- * is never downgraded. Idempotent for a fixed `(rev, actionId)`. Returns the
83
- * effective latest `ActionRev`.
84
- */
85
- saveReplica(block: IBlock, source?: ActionRev): Promise<ActionRev>;
86
-
87
- /**
88
- * Writes a forward TOMBSTONE revision that reverses a block creation: persists `rev → actionId`,
89
- * a `{ delete: true }` transform, and NO materialized block, then merges `[rev, rev+1]` into
90
- * `ranges` and advances `latest` monotonically. The reverse-apply path treats the absent
91
- * materialization as a deletion, so a `getBlock()` after a tombstone reads back as *absent*
92
- * (`undefined`) while a historical `getBlock(creationRev)` still materializes the created content.
93
- *
94
- * Maintains **Invariant P** (see {@link promotePendingTransaction}) on the write path, exactly as
95
- * {@link saveReplica} does: the tombstone's `actionId` loses its pending record on this block.
96
- *
97
- * Idempotent for a fixed `(rev, actionId)`; never downgrades `latest` (a no-op still durable —
98
- * when an equal-or-newer revision is already present). Returns the effective latest `ActionRev`.
99
- */
100
- saveDeletion(source: ActionRev): Promise<ActionRev>;
101
-
102
- /**
103
- * Reconciles `metadata.latest` with the highest contiguous fully-promoted revision in
104
- * the revisions table. Intended for post-crash recovery of the Crash-D3 gap, where
105
- * `promotePendingTransaction` succeeded but `setLatest` did not: the revision and
106
- * committed-log entry are durable, but `meta.latest` still points at the prior rev
107
- * (or is undefined), and retry-commit is rejected because the pending record is gone.
108
- *
109
- * Stops at the first rev whose action is not yet in the committed log, preserving the
110
- * Crash-D2 invariant that retry-commit not recovery — owns advancement past a half-
111
- * promoted state.
112
- *
113
- * Idempotent and monotonic (latest only advances forward).
114
- */
115
- recover(): Promise<{ reconciled: boolean; latest?: ActionRev }>;
116
- }
1
+ import type { BlockId, IBlock, Transform, ActionId, ActionRev } from "@optimystic/db-core";
2
+ import type { BlockCommitProof } from "../cluster/commit-proof.js";
3
+ import type { BlockWriteLatch } from "./block-latch.js";
4
+
5
+ /**
6
+ * Thrown by {@link IBlockStorage.getBlock} when the block has metadata here but the target revision
7
+ * lies outside the locally held revision ranges — this node holds no local records that can serve
8
+ * it. Not a fault: it is the signal a caller that is allowed to heal (only `StorageRepo.get`, under
9
+ * the block's write latch) turns into a {@link IBlockStorage.restoreRevision}; every other caller
10
+ * treats it like any other unreadable-base condition.
11
+ *
12
+ * It lives beside the interface rather than beside `BlockStorage` because it is part of the
13
+ * `getBlock` contract: an alternate `IBlockStorage` implementation must be able to throw it, and
14
+ * `StorageRepo` must be able to catch it, without either depending on the concrete implementation.
15
+ */
16
+ export class RevisionNotCoveredError extends Error {
17
+ constructor(readonly blockId: BlockId, readonly rev: number) {
18
+ super(`Block ${blockId} revision ${rev} is not covered by local records`);
19
+ this.name = 'RevisionNotCoveredError';
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Interface for block-level storage operations.
25
+ *
26
+ * **One block, one write lock.** Every method that writes — metadata, revision records, action
27
+ * transforms, pending records, materializations, proofs — takes a {@link BlockWriteLatch} token as
28
+ * its LAST parameter. The token is proof the caller holds this block's write latch
29
+ * (`blockWriteLatchKey(blockId)`, see `block-latch.ts`), which is the only thing that keeps two
30
+ * writers' read-modify-writes of the metadata blob from silently undoing each other. Only
31
+ * `acquireBlockWriteLatch` / `withBlockWriteLatch` can mint a token, and an implementation must
32
+ * reject a token minted for a different block.
33
+ */
34
+ export interface IBlockStorage {
35
+ /** Gets the latest revision information for this block */
36
+ getLatest(): Promise<ActionRev | undefined>;
37
+
38
+ /**
39
+ * Gets a materialized block at the given revision, from LOCAL records only this never fetches
40
+ * from a peer. Returns `undefined` when this node has never seen the block (no metadata) or when
41
+ * it is pending-only (metadata seeded by a pending transaction, nothing committed) and no `rev`
42
+ * was named. Throws {@link RevisionNotCoveredError} when the target revision (`rev`, or `latest.rev`)
43
+ * lies outside `meta.ranges` the caller decides whether to heal that gap with
44
+ * {@link restoreRevision} under the block's write latch (`StorageRepo.get` does; the commit path
45
+ * deliberately does not). Throws a plain `Error` when the revision is covered but cannot be
46
+ * materialized from the records held (truncated history — genuine corruption).
47
+ */
48
+ getBlock(rev?: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined>;
49
+
50
+ /**
51
+ * Fill a gap in local revision history by fetching `rev` from a peer (the restore wire) and
52
+ * recording the vetted coverage. No-op when `rev` is already covered. Throws when the block has
53
+ * no metadata here (a never-seen block is not restored at this layer — see the note in
54
+ * `BlockStorage.getBlock`) or when no peer could supply an acceptable archive.
55
+ */
56
+ restoreRevision(rev: number, latch: BlockWriteLatch): Promise<void>;
57
+
58
+ /** Gets an action by ID */
59
+ getTransaction(actionId: ActionId): Promise<Transform | undefined>;
60
+
61
+ /** The commit proof stored for a revision, if one was retained (see IRawStorage.getBlockProof). */
62
+ getBlockProof(rev: number): Promise<BlockCommitProof | undefined>;
63
+
64
+ /** Persists the commit proof for a revision (see IRawStorage.saveBlockProof). */
65
+ saveBlockProof(rev: number, proof: BlockCommitProof, latch: BlockWriteLatch): Promise<void>;
66
+
67
+ /** Gets a pending action by ID */
68
+ getPendingTransaction(actionId: ActionId): Promise<Transform | undefined>;
69
+
70
+ /** Lists all pending action IDs */
71
+ listPendingTransactions(): AsyncIterable<ActionId>;
72
+
73
+ /** Saves a pending action (seeding this block's metadata when it has none). */
74
+ savePendingTransaction(actionId: ActionId, transform: Transform, latch: BlockWriteLatch): Promise<void>;
75
+
76
+ /** Deletes a pending action */
77
+ deletePendingTransaction(actionId: ActionId, latch: BlockWriteLatch): Promise<void>;
78
+
79
+ /** Lists revisions in ascending or descending order between startRev and endRev (inclusive) */
80
+ listRevisions(startRev: number, endRev: number): AsyncIterable<ActionRev>;
81
+
82
+ /** Saves a materialized block */
83
+ saveMaterializedBlock(actionId: ActionId, block: IBlock | undefined, latch: BlockWriteLatch): Promise<void>;
84
+
85
+ /**
86
+ * Delete the materialized copy at `prior` if it is now redundant under the checkpoint
87
+ * retention policy (not the tip, not its range floor, not a checkpoint rev). The forward
88
+ * transform for `prior.rev` is retained, so the rev stays reconstructible by replay.
89
+ * No-op if `prior.rev` must be retained or has no materialization (e.g. a tombstone rev).
90
+ */
91
+ pruneSupersededMaterialization(prior: ActionRev, latch: BlockWriteLatch): Promise<void>;
92
+
93
+ /** Saves a revision */
94
+ saveRevision(rev: number, actionId: ActionId, latch: BlockWriteLatch): Promise<void>;
95
+
96
+ /**
97
+ * Promotes a pending action to committed, MOVING the record from the pending namespace to the
98
+ * committed one in a single atomic step.
99
+ *
100
+ * **Invariant P** — a block never holds a pending record and a committed record for the same
101
+ * action id at the same time. This method maintains it on the commit path; every OTHER writer of
102
+ * a committed transform for a block ({@link saveReplica}, {@link saveDeletion}, and any forward
103
+ * path added later) must maintain it too, by deleting that action's pending record when it
104
+ * writes the committed one. A pending record left beside a committed one can never be promoted,
105
+ * and is reported as a phantom conflicting action by `StorageRepo.pend` on every later write to
106
+ * the block.
107
+ */
108
+ promotePendingTransaction(actionId: ActionId, latch: BlockWriteLatch): Promise<void>;
109
+
110
+ /** Sets the latest revision information */
111
+ setLatest(latest: ActionRev, latch: BlockWriteLatch): Promise<void>;
112
+
113
+ /**
114
+ * Persist a replica of a block received out-of-band (churn re-replication).
115
+ *
116
+ * Seeds metadata if absent, writes `rev → actionId`, the action transform, and the
117
+ * materialized block, merges `[rev, rev+1]` into `ranges`, and advances `latest`
118
+ * monotonically. When `source` is provided its `rev`/`actionId` are used; otherwise
119
+ * it falls back to `rev = 1` and a deterministic `actionId` derived from the block
120
+ * (so retries stay idempotent — never random).
121
+ *
122
+ * Maintains **Invariant P** (see {@link promotePendingTransaction}): writing the committed
123
+ * transform for `actionId` also deletes that action's pending record on this block, so a node
124
+ * that pended the action but diverged before committing it does not keep an unpromotable record.
125
+ * Only on the write path — the monotonic no-op below deletes nothing.
126
+ *
127
+ * No-op (still durable) when an equal-or-newer revision is already present: `latest`
128
+ * is never downgraded. Idempotent for a fixed `(rev, actionId)`. Returns the
129
+ * effective latest `ActionRev`.
130
+ *
131
+ * `proof` is persisted for `source.rev` and MUST already be verified by the caller against
132
+ * these exact bytes (`verifyBlockCommitProofContent` — the digest check is what binds a proof
133
+ * to the block content). An unverified proof passed here would be re-served onward as evidence
134
+ * this node never checked. The monotonic no-op persists nothing, proof included — a proof for an
135
+ * already-held revision is back-filled one layer up, by `StorageRepo.saveReplicatedBlock`, which
136
+ * first checks the declared digest against LOCAL content (these bytes may not be the held bytes).
137
+ *
138
+ * `source` and `proof` are positional-but-optional (pass `undefined` when absent) so the latch
139
+ * token can stay in the last position like every other writer.
140
+ */
141
+ saveReplica(block: IBlock, source: ActionRev | undefined, proof: BlockCommitProof | undefined, latch: BlockWriteLatch): Promise<ActionRev>;
142
+
143
+ /**
144
+ * Writes a forward TOMBSTONE revision that reverses a block creation: persists `rev → actionId`,
145
+ * a `{ delete: true }` transform, and NO materialized block, then merges `[rev, rev+1]` into
146
+ * `ranges` and advances `latest` monotonically. The reverse-apply path treats the absent
147
+ * materialization as a deletion, so a `getBlock()` after a tombstone reads back as *absent*
148
+ * (`undefined`) while a historical `getBlock(creationRev)` still materializes the created content.
149
+ *
150
+ * Maintains **Invariant P** (see {@link promotePendingTransaction}) on the write path, exactly as
151
+ * {@link saveReplica} does: the tombstone's `actionId` loses its pending record on this block.
152
+ *
153
+ * Idempotent for a fixed `(rev, actionId)`; never downgrades `latest` (a no-op — still durable —
154
+ * when an equal-or-newer revision is already present). Returns the effective latest `ActionRev`.
155
+ */
156
+ saveDeletion(source: ActionRev, latch: BlockWriteLatch): Promise<ActionRev>;
157
+
158
+ /**
159
+ * Reconciles `metadata.latest` with the highest contiguous fully-promoted revision in
160
+ * the revisions table. Intended for post-crash recovery of the Crash-D3 gap, where
161
+ * `promotePendingTransaction` succeeded but `setLatest` did not: the revision and
162
+ * committed-log entry are durable, but `meta.latest` still points at the prior rev
163
+ * (or is undefined), and retry-commit is rejected because the pending record is gone.
164
+ *
165
+ * Stops at the first rev whose action is not yet in the committed log, preserving the
166
+ * Crash-D2 invariant that retry-commit — not recovery — owns advancement past a half-
167
+ * promoted state.
168
+ *
169
+ * Idempotent and monotonic (latest only advances forward).
170
+ */
171
+ recover(latch: BlockWriteLatch): Promise<{ reconciled: boolean; latest?: ActionRev }>;
172
+ }
@@ -1,5 +1,7 @@
1
1
  import type { BlockId, ActionId, ActionRev, Transform, IBlock } from "@optimystic/db-core";
2
+ import type { BlockCommitProof } from "../cluster/commit-proof.js";
2
3
  import type { BlockMetadata } from "./struct.js";
4
+ import type { StoreIdentity } from "./store-identity.js";
3
5
 
4
6
  export interface IRawStorage {
5
7
  // Metadata operations
@@ -21,6 +23,17 @@ export interface IRawStorage {
21
23
  getTransaction(blockId: BlockId, actionId: ActionId): Promise<Transform | undefined>;
22
24
  saveTransaction(blockId: BlockId, actionId: ActionId, transform: Transform): Promise<void>;
23
25
 
26
+ /**
27
+ * The commit proof stored for a revision, if one was retained. Keyed by revision: the proof lives
28
+ * and dies with the REVISION record, not the materialization, so the checkpoint sweep
29
+ * (`pruneSupersededMaterialization`) never touches it — that is what lets it outlive the
30
+ * 60-second in-memory commit-cert TTL. Whatever deletes a revision record must delete its proof;
31
+ * no such site exists today (`RawStoreDriver` has no revision delete at all — invalidations write
32
+ * compensating FORWARD revisions), so this is a contract note, not a wired path.
33
+ */
34
+ getBlockProof(blockId: BlockId, rev: number): Promise<BlockCommitProof | undefined>;
35
+ saveBlockProof(blockId: BlockId, rev: number, proof: BlockCommitProof): Promise<void>;
36
+
24
37
  // Block materialization operations
25
38
  getMaterializedBlock(blockId: BlockId, actionId: ActionId): Promise<IBlock | undefined>;
26
39
  saveMaterializedBlock(blockId: BlockId, actionId: ActionId, block?: IBlock): Promise<void>;
@@ -28,6 +41,33 @@ export interface IRawStorage {
28
41
  // Promote a pending action to a committed action
29
42
  promotePendingTransaction(blockId: BlockId, actionId: ActionId): Promise<void>;
30
43
 
44
+ /**
45
+ * A stable, process-scoped string naming what this storage is ultimately backed by (a
46
+ * resolved directory, an open database handle). Wrappers pass it through from whatever they
47
+ * wrap, so a cache and the storage it fronts name the same store — e.g.
48
+ * `new CachedRawStorage(new FileRawStorage(dir)).getStoreIdentity()` is `'file:<resolved dir>'`.
49
+ *
50
+ * Contract — note it is ONE-DIRECTIONAL; read both halves before consuming it:
51
+ * - Two storages over DIFFERENT locations MUST NOT return equal strings. So **equality
52
+ * proves sameness**, and a consumer may merge on it. (The fs backend documents one exotic
53
+ * Windows exception at its own site.)
54
+ * - Two storages over the SAME location SHOULD return equal strings, but this is
55
+ * best-effort and several backends knowingly under-approximate: two handles opened over
56
+ * one database, or two spellings of one directory that cannot be collapsed synchronously,
57
+ * read as two identities. So **inequality proves nothing** — never treat it as evidence
58
+ * that two storages are distinct stores. Each backend's `NOTE:` states its own gaps.
59
+ * - Every string is scheme-prefixed so backends cannot collide: `file:`, `sqlite-handle:`,
60
+ * `idb-handle:`, `leveldb-handle:`. Compared for equality only — never parsed.
61
+ * - Stable for the storage object's whole life; fixed at construction.
62
+ * - OPTIONAL BY DESIGN. A backend that cannot honour the contract omits the method entirely
63
+ * and callers fall back to per-object behavior. Never install a stub that returns
64
+ * `undefined` — feature-detection (`typeof storage.getStoreIdentity === 'function'`) must
65
+ * see the backend's true capability, the same trap `KvRawStorage`'s class doc calls out
66
+ * for `listBlockIds`.
67
+ * - It identifies the STORE, not its contents.
68
+ */
69
+ getStoreIdentity?(): StoreIdentity;
70
+
31
71
  /**
32
72
  * Approximate bytes currently stored by this backend.
33
73
  *
@@ -1,34 +1,41 @@
1
1
  import type { BlockId, ActionId, ActionRev, Transform, IBlock } from "@optimystic/db-core";
2
+ import type { BlockCommitProof } from "../cluster/commit-proof.js";
2
3
  import type { BlockMetadata } from "./struct.js";
3
4
  import type { IRawStorage } from "./i-raw-storage.js";
4
5
  import type { RawStoreDriver } from "./raw-store-driver.js";
6
+ import type { StoreIdentity } from "./store-identity.js";
5
7
  import { encodeJson, decodeJson, encodeActionId, decodeActionId } from "./raw-store-codec.js";
6
8
 
7
9
  /**
8
10
  * Shared ordered-KV storage kernel. Implements the full {@link IRawStorage}
9
11
  * surface over a {@link RawStoreDriver}, owning all value (de)serialization and
10
- * call orchestration so each backend only has to expose its five logical stores
12
+ * call orchestration so each backend only has to expose its six logical stores
11
13
  * as bytes-valued maps over its native mechanism.
12
14
  *
13
15
  * The genuinely-shared logic lives ABOVE the storage primitive: the JSON/string
14
- * codec for the four value types, `listRevisions`' lo/hi/reverse bound
15
- * computation, `saveMaterializedBlock`'s put-or-delete branch, and the
16
- * passthroughs. Key layout / storage topology stays in the driver, because the
17
- * backends do NOT share one — LevelDB is a single ordered byte keyspace, SQLite
18
- * five relational tables, IndexedDB five object stores, the filesystem a
19
- * directory tree.
16
+ * codec for the value types, `listRevisions`' lo/hi/reverse bound computation,
17
+ * `saveMaterializedBlock`'s put-or-delete branch, and the passthroughs. Key
18
+ * layout / storage topology stays in the driver, because the backends do NOT
19
+ * share one — LevelDB is a single ordered byte keyspace, SQLite six relational
20
+ * tables, IndexedDB six object stores, the filesystem a directory tree.
20
21
  */
21
22
  export class KvRawStorage implements IRawStorage {
22
23
  /**
23
24
  * Optional passthroughs are wired in the constructor ONLY when the driver
24
25
  * provides them, so a `StorageMonitor` / owned-block seed that feature-detects
25
26
  * (`typeof storage.listBlockIds === 'function'`) sees the driver's true
26
- * capability instead of a stub that silently reports 0 / no seed.
27
+ * capability instead of a stub that silently reports 0 / no seed. The same rule
28
+ * governs `getStoreIdentity`: a stub returning `undefined` would make every store
29
+ * look identity-less to consumers that dedupe on it.
27
30
  */
28
31
  listBlockIds?: () => AsyncIterable<BlockId>;
29
32
  getApproximateBytesUsed?: () => Promise<number>;
33
+ getStoreIdentity?: () => StoreIdentity;
30
34
 
31
35
  constructor(private readonly driver: RawStoreDriver) {
36
+ if (driver.storeIdentity) {
37
+ this.getStoreIdentity = () => driver.storeIdentity!();
38
+ }
32
39
  if (driver.listBlockIds) {
33
40
  this.listBlockIds = () => driver.listBlockIds!();
34
41
  }
@@ -46,7 +53,7 @@ export class KvRawStorage implements IRawStorage {
46
53
 
47
54
  // NOTE: every value write funnels through the driver put/delete calls in the
48
55
  // methods below (saveMetadata / saveRevision / save*Transaction /
49
- // saveMaterializedBlock). This is the single choke point where an incremental
56
+ // saveBlockProof / saveMaterializedBlock). This is the single choke point where an incremental
50
57
  // byte counter would hook in, replacing the per-driver full-scan
51
58
  // getApproximateBytesUsed. The chosen capacity-estimate mechanism is instead a
52
59
  // short-TTL cache over the full scan in StorageMonitor (see storage-monitor.ts
@@ -111,6 +118,24 @@ export class KvRawStorage implements IRawStorage {
111
118
  await this.driver.putTransaction(blockId, actionId, encodeJson(transform));
112
119
  }
113
120
 
121
+ // --- Commit proofs (their OWN (blockId, rev)-keyed store — see RawStoreDriver.getProof) ---
122
+ //
123
+ // Proofs deliberately do NOT share the transactions keyspace. Action ids are
124
+ // chosen by whoever originates a write and are never re-derived or format-checked
125
+ // by the storing node, so no reserved action-id prefix can be relied on: a client
126
+ // pending an action literally named `~proof:5`, or a peer's restore archive naming
127
+ // one, used to land on the same key as revision 5's proof and silently overwrite it
128
+ // (or be overwritten by it). Separate stores make that collision unrepresentable.
129
+
130
+ async getBlockProof(blockId: BlockId, rev: number): Promise<BlockCommitProof | undefined> {
131
+ const bytes = await this.driver.getProof(blockId, rev);
132
+ return bytes === undefined ? undefined : decodeJson<BlockCommitProof>(bytes);
133
+ }
134
+
135
+ async saveBlockProof(blockId: BlockId, rev: number, proof: BlockCommitProof): Promise<void> {
136
+ await this.driver.putProof(blockId, rev, encodeJson(proof));
137
+ }
138
+
114
139
  // --- Materialized blocks ---
115
140
 
116
141
  async getMaterializedBlock(blockId: BlockId, actionId: ActionId): Promise<IBlock | undefined> {
@@ -2,7 +2,7 @@ import type { BlockId, ActionId } from "@optimystic/db-core";
2
2
  import type { RawStoreDriver } from "./raw-store-driver.js";
3
3
 
4
4
  /**
5
- * In-memory {@link RawStoreDriver}: the five logical block-storage stores as
5
+ * In-memory {@link RawStoreDriver}: the six logical block-storage stores as
6
6
  * `Map`s of `Uint8Array` values. `KvRawStorage` hands this driver bytes produced
7
7
  * by `JSON`-encode and reads them back via `JSON`-decode, so every get yields a
8
8
  * fresh object and every save stored an independent byte snapshot BY
@@ -16,6 +16,7 @@ export class MemoryStoreDriver implements RawStoreDriver {
16
16
  private readonly revisions = new Map<string, Uint8Array>(); // `${blockId}:${rev}` -> actionId bytes
17
17
  private readonly pending = new Map<string, Uint8Array>(); // `${blockId}:${actionId}` -> transform bytes
18
18
  private readonly transactions = new Map<string, Uint8Array>();
19
+ private readonly proofs = new Map<string, Uint8Array>(); // `${blockId}:${rev}` -> proof bytes
19
20
  private readonly materialized = new Map<string, Uint8Array>();
20
21
 
21
22
  private revisionKey(blockId: BlockId, rev: number): string {
@@ -104,6 +105,16 @@ export class MemoryStoreDriver implements RawStoreDriver {
104
105
  this.transactions.set(this.actionKey(blockId, actionId), value);
105
106
  }
106
107
 
108
+ // --- proofs (keyed like revisions, NOT like actions — see RawStoreDriver.getProof) ---
109
+
110
+ async getProof(blockId: BlockId, rev: number): Promise<Uint8Array | undefined> {
111
+ return this.proofs.get(this.revisionKey(blockId, rev));
112
+ }
113
+
114
+ async putProof(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
115
+ this.proofs.set(this.revisionKey(blockId, rev), value);
116
+ }
117
+
107
118
  // --- materialized ---
108
119
 
109
120
  async getMaterialized(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
@@ -132,6 +143,19 @@ export class MemoryStoreDriver implements RawStoreDriver {
132
143
 
133
144
  // --- optional passthroughs ---
134
145
 
146
+ // Deliberately NO storeIdentity(): two MemoryStoreDrivers hold two independent Maps and are
147
+ // genuinely two different stores, so there is no shared "location" for them to name.
148
+ //
149
+ // NOTE: one driver object shared by two `KvRawStorage` wrappers is NOT covered. `withReadCache`
150
+ // falls back to keying on the STORAGE object, not the driver, so those two wrappers are two
151
+ // keys and get two read caches over one Map — the divergence dedupe exists to remove. Harmless
152
+ // today: this driver is a test fixture and every shipping backend's driver reports an identity,
153
+ // so the identity-less path only ever sees genuinely distinct stores. If a host ever wires
154
+ // `rawStorageFactory: () => new KvRawStorage(oneSharedDriver)` for two consumers, give this
155
+ // driver `identityForHandle('memory', this)` (store-identity.ts) — that makes the two wrappers
156
+ // name one store — and re-premise `local-transactor-read-cache.spec.ts`, which currently
157
+ // asserts the non-dedupe.
158
+
135
159
  async *listBlockIds(): AsyncIterable<BlockId> {
136
160
  // Snapshot the keys before yielding so a concurrent putMetadata during the scan
137
161
  // doesn't invalidate a live map iterator. Fresh in-memory storage is empty, so at
@@ -147,7 +171,7 @@ export class MemoryStoreDriver implements RawStoreDriver {
147
171
  for (const [blockId, value] of this.metadata) {
148
172
  total += blockId.length + value.byteLength;
149
173
  }
150
- for (const store of [this.revisions, this.pending, this.transactions, this.materialized]) {
174
+ for (const store of [this.revisions, this.pending, this.transactions, this.proofs, this.materialized]) {
151
175
  for (const [key, value] of store) {
152
176
  total += key.length + value.byteLength;
153
177
  }
@@ -1,42 +1,42 @@
1
- import type { ActionId } from "@optimystic/db-core";
2
-
3
- /**
4
- * Value codec shared by every `KvRawStorage`-backed store. Values cross the
5
- * `RawStoreDriver` boundary as `Uint8Array`, so this module is the single place
6
- * that turns `BlockMetadata`/`Transform`/`IBlock` (via JSON) and the `ActionId`
7
- * string (via UTF-8) into bytes and back.
8
- *
9
- * Because a get always decodes fresh bytes into a NEW object and a save always
10
- * encodes an independent byte copy, the clone-on-store / clone-on-read invariant
11
- * that in-memory storage used to enforce by hand (`structuredClone`) is now
12
- * structural for kernel-backed stores — see docs/internals.md "Storage Returns
13
- * References".
14
- *
15
- * Round-trip fidelity matters for `BlockMetadata`: an open-ended `RevisionRange`
16
- * is encoded `[E]` (one element, upper bound `undefined`). `JSON.stringify([5])`
17
- * → `"[5]"` → `JSON.parse` → `[5]`, so the open-ended encoding survives byte-exact.
18
- * Do NOT normalize ranges here.
19
- */
20
-
21
- const encoder = new TextEncoder();
22
- const decoder = new TextDecoder();
23
-
24
- /** JSON-encode a value (`BlockMetadata` / `Transform` / `IBlock`) to UTF-8 bytes. */
25
- export function encodeJson<T>(value: T): Uint8Array {
26
- return encoder.encode(JSON.stringify(value));
27
- }
28
-
29
- /** Decode UTF-8 JSON bytes back into a value. Callers guard the miss (`undefined`) first. */
30
- export function decodeJson<T>(bytes: Uint8Array): T {
31
- return JSON.parse(decoder.decode(bytes)) as T;
32
- }
33
-
34
- /** Encode an `ActionId` string to UTF-8 bytes (stored as the revisions-store value). */
35
- export function encodeActionId(actionId: ActionId): Uint8Array {
36
- return encoder.encode(actionId);
37
- }
38
-
39
- /** Decode UTF-8 bytes back into an `ActionId` string. */
40
- export function decodeActionId(bytes: Uint8Array): ActionId {
41
- return decoder.decode(bytes) as ActionId;
42
- }
1
+ import type { ActionId } from "@optimystic/db-core";
2
+
3
+ /**
4
+ * Value codec shared by every `KvRawStorage`-backed store. Values cross the
5
+ * `RawStoreDriver` boundary as `Uint8Array`, so this module is the single place
6
+ * that turns `BlockMetadata`/`Transform`/`IBlock`/`BlockCommitProof` (via JSON)
7
+ * and the `ActionId` string (via UTF-8) into bytes and back.
8
+ *
9
+ * Because a get always decodes fresh bytes into a NEW object and a save always
10
+ * encodes an independent byte copy, the clone-on-store / clone-on-read invariant
11
+ * that in-memory storage used to enforce by hand (`structuredClone`) is now
12
+ * structural for kernel-backed stores — see docs/internals.md "Storage Returns
13
+ * References".
14
+ *
15
+ * Round-trip fidelity matters for `BlockMetadata`: an open-ended `RevisionRange`
16
+ * is encoded `[E]` (one element, upper bound `undefined`). `JSON.stringify([5])`
17
+ * → `"[5]"` → `JSON.parse` → `[5]`, so the open-ended encoding survives byte-exact.
18
+ * Do NOT normalize ranges here.
19
+ */
20
+
21
+ const encoder = new TextEncoder();
22
+ const decoder = new TextDecoder();
23
+
24
+ /** JSON-encode a value (`BlockMetadata` / `Transform` / `IBlock`) to UTF-8 bytes. */
25
+ export function encodeJson<T>(value: T): Uint8Array {
26
+ return encoder.encode(JSON.stringify(value));
27
+ }
28
+
29
+ /** Decode UTF-8 JSON bytes back into a value. Callers guard the miss (`undefined`) first. */
30
+ export function decodeJson<T>(bytes: Uint8Array): T {
31
+ return JSON.parse(decoder.decode(bytes)) as T;
32
+ }
33
+
34
+ /** Encode an `ActionId` string to UTF-8 bytes (stored as the revisions-store value). */
35
+ export function encodeActionId(actionId: ActionId): Uint8Array {
36
+ return encoder.encode(actionId);
37
+ }
38
+
39
+ /** Decode UTF-8 bytes back into an `ActionId` string. */
40
+ export function decodeActionId(bytes: Uint8Array): ActionId {
41
+ return decoder.decode(bytes) as ActionId;
42
+ }