@optimystic/db-core 1.0.0-beta.3 → 1.0.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 (171) hide show
  1. package/dist/src/cluster/structs.d.ts +30 -6
  2. package/dist/src/cluster/structs.d.ts.map +1 -1
  3. package/dist/src/cluster/structs.js +5 -3
  4. package/dist/src/cluster/structs.js.map +1 -1
  5. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts +2 -1
  6. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts.map +1 -1
  7. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js +10 -3
  8. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js.map +1 -1
  9. package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
  10. package/dist/src/cohort-topic/wire/codec.js +8 -2
  11. package/dist/src/cohort-topic/wire/codec.js.map +1 -1
  12. package/dist/src/collection/collection.d.ts +439 -34
  13. package/dist/src/collection/collection.d.ts.map +1 -1
  14. package/dist/src/collection/collection.js +843 -148
  15. package/dist/src/collection/collection.js.map +1 -1
  16. package/dist/src/collection/struct.d.ts +109 -4
  17. package/dist/src/collection/struct.d.ts.map +1 -1
  18. package/dist/src/collection/struct.js +88 -1
  19. package/dist/src/collection/struct.js.map +1 -1
  20. package/dist/src/collections/diary/diary.d.ts +6 -2
  21. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  22. package/dist/src/collections/diary/diary.js +5 -2
  23. package/dist/src/collections/diary/diary.js.map +1 -1
  24. package/dist/src/collections/tree/struct.d.ts +124 -16
  25. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  26. package/dist/src/collections/tree/struct.js +86 -11
  27. package/dist/src/collections/tree/struct.js.map +1 -1
  28. package/dist/src/collections/tree/tree.d.ts +13 -3
  29. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  30. package/dist/src/collections/tree/tree.js +106 -40
  31. package/dist/src/collections/tree/tree.js.map +1 -1
  32. package/dist/src/index.d.ts +1 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +1 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  37. package/dist/src/matchmaking/wire.js +8 -2
  38. package/dist/src/matchmaking/wire.js.map +1 -1
  39. package/dist/src/network/durability.d.ts +45 -0
  40. package/dist/src/network/durability.d.ts.map +1 -0
  41. package/dist/src/network/durability.js +114 -0
  42. package/dist/src/network/durability.js.map +1 -0
  43. package/dist/src/network/i-key-network.d.ts +14 -6
  44. package/dist/src/network/i-key-network.d.ts.map +1 -1
  45. package/dist/src/network/index.d.ts +3 -0
  46. package/dist/src/network/index.d.ts.map +1 -1
  47. package/dist/src/network/index.js +3 -0
  48. package/dist/src/network/index.js.map +1 -1
  49. package/dist/src/network/lineage.d.ts +39 -0
  50. package/dist/src/network/lineage.d.ts.map +1 -0
  51. package/dist/src/network/lineage.js +61 -0
  52. package/dist/src/network/lineage.js.map +1 -0
  53. package/dist/src/network/routing-key.d.ts +24 -0
  54. package/dist/src/network/routing-key.d.ts.map +1 -0
  55. package/dist/src/network/routing-key.js +9 -0
  56. package/dist/src/network/routing-key.js.map +1 -0
  57. package/dist/src/network/stale-failure.d.ts +5 -1
  58. package/dist/src/network/stale-failure.d.ts.map +1 -1
  59. package/dist/src/network/stale-failure.js +5 -1
  60. package/dist/src/network/stale-failure.js.map +1 -1
  61. package/dist/src/network/struct.d.ts +151 -8
  62. package/dist/src/network/struct.d.ts.map +1 -1
  63. package/dist/src/network/struct.js.map +1 -1
  64. package/dist/src/reactivity/wire.d.ts.map +1 -1
  65. package/dist/src/reactivity/wire.js +8 -2
  66. package/dist/src/reactivity/wire.js.map +1 -1
  67. package/dist/src/testing/test-transactor.d.ts +64 -7
  68. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  69. package/dist/src/testing/test-transactor.js +150 -23
  70. package/dist/src/testing/test-transactor.js.map +1 -1
  71. package/dist/src/transaction/coordinator.d.ts +141 -21
  72. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  73. package/dist/src/transaction/coordinator.js +408 -122
  74. package/dist/src/transaction/coordinator.js.map +1 -1
  75. package/dist/src/transaction/errors.d.ts +39 -10
  76. package/dist/src/transaction/errors.d.ts.map +1 -1
  77. package/dist/src/transaction/errors.js +34 -9
  78. package/dist/src/transaction/errors.js.map +1 -1
  79. package/dist/src/transaction/index.d.ts +1 -0
  80. package/dist/src/transaction/index.d.ts.map +1 -1
  81. package/dist/src/transaction/index.js.map +1 -1
  82. package/dist/src/transactor/block-floors.d.ts +78 -0
  83. package/dist/src/transactor/block-floors.d.ts.map +1 -0
  84. package/dist/src/transactor/block-floors.js +62 -0
  85. package/dist/src/transactor/block-floors.js.map +1 -0
  86. package/dist/src/transactor/change-notifier.d.ts +43 -0
  87. package/dist/src/transactor/change-notifier.d.ts.map +1 -1
  88. package/dist/src/transactor/change-notifier.js +3 -0
  89. package/dist/src/transactor/change-notifier.js.map +1 -1
  90. package/dist/src/transactor/index.d.ts +1 -0
  91. package/dist/src/transactor/index.d.ts.map +1 -1
  92. package/dist/src/transactor/index.js +1 -0
  93. package/dist/src/transactor/index.js.map +1 -1
  94. package/dist/src/transactor/network-transactor.d.ts +43 -6
  95. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  96. package/dist/src/transactor/network-transactor.js +279 -86
  97. package/dist/src/transactor/network-transactor.js.map +1 -1
  98. package/dist/src/transactor/transactor-source.d.ts +94 -4
  99. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  100. package/dist/src/transactor/transactor-source.js +120 -42
  101. package/dist/src/transactor/transactor-source.js.map +1 -1
  102. package/dist/src/transactor/transactor.d.ts +14 -1
  103. package/dist/src/transactor/transactor.d.ts.map +1 -1
  104. package/dist/src/transform/atomic.d.ts.map +1 -1
  105. package/dist/src/transform/atomic.js +12 -9
  106. package/dist/src/transform/atomic.js.map +1 -1
  107. package/dist/src/transform/base-pins.d.ts +50 -17
  108. package/dist/src/transform/base-pins.d.ts.map +1 -1
  109. package/dist/src/transform/base-pins.js +58 -11
  110. package/dist/src/transform/base-pins.js.map +1 -1
  111. package/dist/src/transform/cache-source.d.ts +75 -11
  112. package/dist/src/transform/cache-source.d.ts.map +1 -1
  113. package/dist/src/transform/cache-source.js +162 -36
  114. package/dist/src/transform/cache-source.js.map +1 -1
  115. package/dist/src/transform/digest.d.ts +8 -1
  116. package/dist/src/transform/digest.d.ts.map +1 -1
  117. package/dist/src/transform/digest.js +19 -9
  118. package/dist/src/transform/digest.js.map +1 -1
  119. package/dist/src/transform/helpers.d.ts +6 -2
  120. package/dist/src/transform/helpers.d.ts.map +1 -1
  121. package/dist/src/transform/helpers.js +8 -3
  122. package/dist/src/transform/helpers.js.map +1 -1
  123. package/dist/src/transform/tracker.d.ts +94 -14
  124. package/dist/src/transform/tracker.d.ts.map +1 -1
  125. package/dist/src/transform/tracker.js +222 -68
  126. package/dist/src/transform/tracker.js.map +1 -1
  127. package/dist/src/utility/structural-equals.d.ts +38 -0
  128. package/dist/src/utility/structural-equals.d.ts.map +1 -0
  129. package/dist/src/utility/structural-equals.js +77 -0
  130. package/dist/src/utility/structural-equals.js.map +1 -0
  131. package/package.json +2 -2
  132. package/src/cluster/structs.ts +28 -7
  133. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
  134. package/src/cohort-topic/wire/codec.ts +222 -216
  135. package/src/collection/collection.ts +960 -157
  136. package/src/collection/struct.ts +99 -4
  137. package/src/collections/diary/diary.ts +71 -68
  138. package/src/collections/tree/struct.ts +155 -23
  139. package/src/collections/tree/tree.ts +127 -40
  140. package/src/index.ts +1 -1
  141. package/src/matchmaking/wire.ts +8 -2
  142. package/src/network/durability.ts +123 -0
  143. package/src/network/i-key-network.ts +60 -52
  144. package/src/network/index.ts +10 -7
  145. package/src/network/lineage.ts +80 -0
  146. package/src/network/routing-key.ts +27 -0
  147. package/src/network/stale-failure.ts +5 -1
  148. package/src/network/struct.ts +156 -8
  149. package/src/reactivity/wire.ts +230 -224
  150. package/src/testing/test-transactor.ts +152 -24
  151. package/src/transaction/coordinator.ts +470 -131
  152. package/src/transaction/errors.ts +34 -9
  153. package/src/transaction/index.ts +1 -0
  154. package/src/transactor/block-floors.ts +105 -0
  155. package/src/transactor/change-notifier.ts +129 -80
  156. package/src/transactor/index.ts +1 -0
  157. package/src/transactor/network-transactor.ts +297 -89
  158. package/src/transactor/transactor-source.ts +131 -42
  159. package/src/transactor/transactor.ts +13 -1
  160. package/src/transform/atomic.ts +25 -22
  161. package/src/transform/base-pins.ts +76 -18
  162. package/src/transform/cache-source.ts +327 -194
  163. package/src/transform/digest.ts +21 -10
  164. package/src/transform/helpers.ts +8 -3
  165. package/src/transform/tracker.ts +423 -270
  166. package/src/utility/structural-equals.ts +72 -0
  167. package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
  168. package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
  169. package/dist/src/utility/block-id-to-bytes.js +0 -7
  170. package/dist/src/utility/block-id-to-bytes.js.map +0 -1
  171. package/src/utility/block-id-to-bytes.ts +0 -8
@@ -1,8 +1,88 @@
1
1
  import { Collection, type CollectionInitOptions, type CollectionId, type CollectionSnapshot, type ReadViewOptions } from "../../collection/index.js";
2
- import type { ITransactor, BlockId, BlockStore, IBlock, ActionId } from "../../index.js";
2
+ import type { ITransactor, BlockId, BlockStore, IBlock, ActionId, WriteDurability } from "../../index.js";
3
3
  import { BTree, type Path, type KeyRange } from "../../btree/index.js";
4
4
  import { CollectionTrunk } from "./collection-trunk.js";
5
- import { TreeHeaderBlockType, TreeKeyTakenError, TreeRangeTakenError, type TreeReplaceAction } from "./struct.js";
5
+ import { structuralEquals } from "../../utility/structural-equals.js";
6
+ import {
7
+ TreeHeaderBlockType, TreeDeleteGuardKindError, TreeEntryChangedError, TreeKeyTakenError,
8
+ TreeRangeTakenError, type TreeEntryGuard, type TreeReplaceAction,
9
+ } from "./struct.js";
10
+
11
+ /**
12
+ * Enforce a guard carried by an UPSERT element against the CURRENT content of `actionTree` —
13
+ * the same Atomic store the upsert writes to, so it sees what earlier elements of this same
14
+ * replay already staged or deleted (an UPDATE's delete-old half runs before its guarded insert
15
+ * half). Throws the guard's refusal, which discards the whole action's staged writes.
16
+ *
17
+ * @returns `'skip'` when the element must be left unwritten (`keepExisting` found the key
18
+ * occupied — the INSERT OR IGNORE disposition), `'proceed'` otherwise.
19
+ */
20
+ async function enforceUpsertGuard<TKey, TEntry>(
21
+ actionTree: BTree<TKey, TEntry>,
22
+ id: CollectionId,
23
+ key: TKey,
24
+ guard: TreeEntryGuard<TKey, TEntry>,
25
+ keyFromEntry: (entry: TEntry) => TKey,
26
+ compare: (a: TKey, b: TKey) => number,
27
+ ): Promise<'proceed' | 'skip'> {
28
+ switch (guard.kind) {
29
+ case 'absentRange': {
30
+ // Secondary-UNIQUE: the claimed range (a unique index's framed value prefix) must hold
31
+ // no entry other than this action's own key. Two short descents (range start and end)
32
+ // plus an early-exit walk. The guard is plain data (it is serialized into the log with
33
+ // its action), and BTree.range reads the range's fields only — so a KeyRange that has
34
+ // round-tripped through JSON is scanned exactly like a live instance.
35
+ for await (const path of actionTree.range(guard.range)) {
36
+ const occupant = actionTree.at(path);
37
+ if (occupant === undefined) continue;
38
+ const occupantKey = keyFromEntry(occupant);
39
+ if (compare(occupantKey, key) === 0) continue; // self-exclusion
40
+ throw new TreeRangeTakenError(id, key, guard.range, occupantKey);
41
+ }
42
+ return 'proceed';
43
+ }
44
+ case 'unchanged': {
45
+ assertEntryUnchanged(id, key, guard.expected, actionTree.at(await actionTree.find(key)));
46
+ return 'proceed';
47
+ }
48
+ case 'absent':
49
+ case 'keepExisting': {
50
+ if (!(await actionTree.find(key)).on) return 'proceed';
51
+ if (guard.kind === 'absent') throw new TreeKeyTakenError(id, key);
52
+ // keepExisting: leave the present (rival's) entry in place and skip this element.
53
+ return 'skip';
54
+ }
55
+ }
56
+ }
57
+
58
+ /** The lost-update check shared by guarded upserts and guarded deletes: the committed entry must
59
+ * be present AND structurally equal to what the staged write read. Absence counts as changed —
60
+ * see {@link TreeUnchangedGuard} for why that is the contract on both dispositions. */
61
+ function assertEntryUnchanged<TKey, TEntry>(
62
+ id: CollectionId,
63
+ key: TKey,
64
+ expected: TEntry,
65
+ actual: TEntry | undefined,
66
+ ): void {
67
+ if (actual === undefined || !structuralEquals(actual, expected)) {
68
+ throw new TreeEntryChangedError(id, key, expected, actual);
69
+ }
70
+ }
71
+
72
+ /** Narrow a DELETE element's guard to the only kind a delete can act on. The element type already
73
+ * says this ({@link TreeDeleteElement}), but a replayed log entry is deserialized data that no
74
+ * type policed, so the handler re-checks it and throws {@link TreeDeleteGuardKindError} rather
75
+ * than silently ignoring a guard the writer believed was being enforced. */
76
+ function deleteGuardExpected<TKey, TEntry>(
77
+ id: CollectionId,
78
+ key: TKey,
79
+ guard: TreeEntryGuard<TKey, TEntry>,
80
+ ): TEntry {
81
+ if (guard.kind !== 'unchanged') {
82
+ throw new TreeDeleteGuardKindError(id, key, guard.kind);
83
+ }
84
+ return guard.expected;
85
+ }
6
86
 
7
87
  /**
8
88
  * Read-only surface of a tree: every navigation/lookup method a reader needs, with
@@ -106,44 +186,42 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
106
186
  nodeCapacity, // keep the write btree's fan-out in lock-step with the read btree
107
187
  );
108
188
  for (const [key, entry, guard] of actions) {
189
+ // NOTE: an element is a DELETE when its entry slot is falsy, not when it is
190
+ // strictly `undefined`. That is deliberate and load-bearing: JSON encodes the
191
+ // empty slot of `[key, undefined]` as `null`, so a replayed log entry arrives
192
+ // as `[key, null]` and a `!== undefined` test would upsert `null` over the row.
193
+ // JSON really is the live encoding, not just a hypothetical: the p2p repo
194
+ // protocol stringifies and parses every message (`protocol-client.ts` and
195
+ // `repo/service.ts` in `packages/db-p2p/src/`). The in-process test transactors
196
+ // use `structuredClone`, which preserves `undefined` — so no test that goes
197
+ // only through them can reach this shape; the round-trip spec below stages the
198
+ // JSON shape explicitly for that reason.
199
+ // The cost is that a falsy-but-real entry (`''`, `0`, `false`) reads as a
200
+ // delete. No entry type in this repo is falsy today (rows encode to a non-empty
201
+ // string or Uint8Array; index entries are arrays); if one ever is, this needs an
202
+ // explicit delete marker in the tuple rather than a tighter comparison.
109
203
  if (entry) {
110
204
  // Enforce the entry's guard (if any) on EVERY handler run — initial staging
111
- // and every conflict replay — so the uniqueness decision is re-made against
112
- // the newest adopted committed state, not just the stage-time snapshot.
113
- // A throw here discards the whole action's staged writes (Atomic wrapper).
114
- if (guard !== undefined) {
115
- if (guard.kind === 'absentRange') {
116
- // Secondary-UNIQUE: the claimed range (a unique index's framed
117
- // value prefix) must hold no entry other than this action's own
118
- // key. Two short descents (range start and end) plus an early-exit
119
- // walk, over the SAME store the upsert below writes to, so it sees entries earlier
120
- // actions of this replay already staged or deleted (an UPDATE's
121
- // delete-old half runs before its guarded insert half). The guard
122
- // is plain data (it is serialized into the log with its action), and
123
- // BTree.range reads the range's fields only — so a KeyRange that has
124
- // round-tripped through JSON is scanned exactly like a live instance.
125
- for await (const path of actionTree.range(guard.range)) {
126
- const occupant = actionTree.at(path);
127
- if (occupant === undefined) continue;
128
- const occupantKey = keyFromEntry(occupant);
129
- if (compare(occupantKey, key) === 0) continue; // self-exclusion
130
- throw new TreeRangeTakenError(id, key, guard.range, occupantKey);
131
- }
132
- } else {
133
- const found = await actionTree.find(key);
134
- if (found.on) {
135
- if (guard.kind === 'absent') {
136
- throw new TreeKeyTakenError(id, key);
137
- }
138
- // keepExisting: leave the present (rival's) entry in place and
139
- // skip this entry silently — the INSERT OR IGNORE disposition.
140
- continue;
141
- }
142
- }
205
+ // and every conflict replay — so the decision is re-made against the newest
206
+ // adopted committed state, not just the stage-time snapshot. A throw here
207
+ // discards the whole action's staged writes (Atomic wrapper).
208
+ // Truthiness, not `!== undefined`, for the same round-trip reason as the entry
209
+ // slot above: an explicitly-passed `undefined` guard (the shape every unguarded
210
+ // index write stages) comes back from JSON as `null`, and reading `.kind` off it
211
+ // would throw a TypeError mid-replay instead of meaning "no guard".
212
+ if (guard
213
+ && await enforceUpsertGuard(actionTree, id, key, guard, keyFromEntry, compare) === 'skip') {
214
+ continue;
143
215
  }
144
216
  await actionTree.upsert(entry);
145
217
  } else {
146
- await actionTree.deleteAt((await actionTree.find(key)));
218
+ // The path is found once and reused: the guard check reads it and `deleteAt`
219
+ // consumes it, with no mutation in between to invalidate it.
220
+ const path = await actionTree.find(key);
221
+ if (guard) {
222
+ assertEntryUnchanged(id, key, deleteGuardExpected(id, key, guard), actionTree.at(path));
223
+ }
224
+ await actionTree.deleteAt(path);
147
225
  }
148
226
  }
149
227
  // Mutations landed in `trx`, not the read btree, so its version counter never moved.
@@ -184,9 +262,18 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
184
262
  return new Tree<TKey, TEntry>(collection, held.btree, keyFromEntry, compare);
185
263
  }
186
264
 
187
- async replace(data: TreeReplaceAction<TKey, TEntry>): Promise<void> {
188
- await this.collection.act({ type: "replace", data });
189
- await this.collection.updateAndSync();
265
+ /** Stage a mutation and flush it. Forwards the collection's answer verbatim — see
266
+ * {@link Collection.sync} for what `undefined` means and why a torn commit's `torn` list must
267
+ * survive the trip. This layer never interprets the value.
268
+ *
269
+ * A call that throws leaves nothing staged (see {@link Collection.actAndSync}), so the mutation
270
+ * cannot ride along with a later `replace`. Whether it reached STORAGE is what the error says:
271
+ * a `TornActionError` with `final: true` means it did not and submitting it again stores it
272
+ * once; `final: false` means that could not be established. A `SyncRetryExhaustedError` is not
273
+ * a promise either way — the write path's last budgeted attempt, and a deadline, are not
274
+ * followed by the refresh that would have settled it (see `Collection.syncAttempts`). */
275
+ async replace(data: TreeReplaceAction<TKey, TEntry>): Promise<WriteDurability | undefined> {
276
+ return await this.collection.actAndSync([{ type: "replace", data }]);
190
277
  }
191
278
 
192
279
  /** Stage a mutation into the collection's tracker WITHOUT flushing it to the
@@ -201,8 +288,8 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
201
288
 
202
289
  /** Flush all staged (and any other pending) changes to the transactor.
203
290
  * Equivalent to the flush half of {@link replace}. */
204
- async sync(): Promise<void> {
205
- await this.collection.updateAndSync();
291
+ async sync(): Promise<WriteDurability | undefined> {
292
+ return await this.collection.updateAndSync();
206
293
  }
207
294
 
208
295
  /** Whether {@link sync} has anything to push — see
package/src/index.ts CHANGED
@@ -18,8 +18,8 @@ export * from "./utility/groupby.js";
18
18
  export * from "./utility/hash-string.js";
19
19
  export * from "./utility/latches.js";
20
20
  export * from "./utility/nameof.js";
21
+ export * from "./utility/structural-equals.js";
21
22
  export * from "./utility/ensured.js";
22
23
  export * from "./utility/pending.js";
23
- export * from "./utility/block-id-to-bytes.js";
24
24
  export * from "./utility/lru-map.js";
25
25
  export * from "./utility/backoff.js";
@@ -184,7 +184,10 @@ export interface AggregateBucketV1 {
184
184
  // --- matchmaking-local wire state (generic validation primitives live in cohort-topic/wire/primitives.js) ---
185
185
 
186
186
  const utf8Encoder = new TextEncoder();
187
- const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
187
+ // Built on first use, not at module load: Hermes (React Native) has no native `TextDecoder`, so a
188
+ // module-scope construction fails the import itself whenever it runs ahead of the host's polyfill.
189
+ let utf8DecoderInstance: TextDecoder | undefined;
190
+ const utf8Decoder = (): TextDecoder => utf8DecoderInstance ??= new TextDecoder("utf-8", { fatal: true });
188
191
 
189
192
  /** Ceiling for an opaque app payload (provider/seeker), guarding decode allocation. */
190
193
  export const DEFAULT_MAX_APP_PAYLOAD_BYTES = 64 * 1024;
@@ -295,9 +298,12 @@ function parseJsonBytes(bytes: Uint8Array, maxBytes: number, what: string): unkn
295
298
  if (bytes.length > maxBytes) {
296
299
  fail(`${what}: payload ${bytes.length} exceeds max ${maxBytes} bytes`);
297
300
  }
301
+ // Fetched outside the try, so a missing `TextDecoder` polyfill surfaces as itself rather than as
302
+ // "not valid UTF-8".
303
+ const decoder = utf8Decoder();
298
304
  let text: string;
299
305
  try {
300
- text = utf8Decoder.decode(bytes);
306
+ text = decoder.decode(bytes);
301
307
  } catch {
302
308
  fail(`${what}: payload is not valid UTF-8`);
303
309
  }
@@ -0,0 +1,123 @@
1
+ import type { BlockId } from "../index.js";
2
+ import type { DurabilityQuorum, WriteDurability } from "./struct.js";
3
+
4
+ /**
5
+ * The ONLY "this write is completely saved" test. `quorum === 'full'` alone is not enough: a torn
6
+ * action can have a fully-held tail and blocks that were abandoned and cancelled (`torn`), and a
7
+ * consumer that compared the class by hand would show such a write as saved. Every place that wants
8
+ * to present a change as saved calls this rather than reading the fields.
9
+ */
10
+ export function isFullyDurable(d: WriteDurability): boolean {
11
+ return d.quorum === 'full' && (d.torn === undefined || d.torn.length === 0);
12
+ }
13
+
14
+ /** Total order on quorum classes: unrouted < local < majority < full. */
15
+ export function durabilityRank(q: DurabilityQuorum): number {
16
+ switch (q) {
17
+ case 'unrouted': return 0;
18
+ case 'local': return 1;
19
+ case 'majority': return 2;
20
+ case 'full': return 3;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Merge per-cohort reports into one action-level report: the weakest becomes the scalar answer and
26
+ * the rest become `otherCohorts`, so a consumer that reads only the scalar fields is reading the
27
+ * binding constraint. `torn` is the union across the inputs — an abandoned block is abandoned
28
+ * whichever cohort it ran on. Reports that already carry `otherCohorts` are flattened first, so
29
+ * merging a merge is the same as merging its inputs. Identical reports collapse to one: a commit
30
+ * sends the tail and the sweep as separate batches, so ONE cohort routinely answers twice for one
31
+ * action, and without this a healthy single-cohort commit would list its own cohort under
32
+ * `otherCohorts`.
33
+ * NOTE: only structurally identical reports collapse. One cohort answering differently for two
34
+ * batches (a member confirmed one block and not the other) appears twice, once per answer, which
35
+ * is the truthful shape. If a consumer ever needs exactly one entry per cohort, fold same-cohort
36
+ * reports here by unioning `unconfirmed`; do not dedupe by `cohortPeerIds` alone.
37
+ *
38
+ * THROWS on an empty input: an action always ran on at least one cohort, and fabricating an answer
39
+ * for one that ran on none is exactly the plain-success ambiguity the durability field removes.
40
+ */
41
+ export function mergeDurability(reports: readonly WriteDurability[]): WriteDurability {
42
+ const flat = distinctReports(reports.flatMap(flattenDurability));
43
+ if (flat.length === 0) {
44
+ throw new Error('mergeDurability: no durability reports to merge — an action always ran on at least one cohort');
45
+ }
46
+ const ranked = [...flat].sort((a, b) => durabilityRank(a.quorum) - durabilityRank(b.quorum));
47
+ const weakest = ranked[0]!;
48
+ const others = ranked.slice(1);
49
+ // Over the ORIGINAL reports: flattening strips `torn` (it is action-level), so the union must
50
+ // be taken before that or a torn input merges to an untorn answer.
51
+ const torn = unionTorn(reports);
52
+ return {
53
+ ...stripMergedFields(weakest),
54
+ ...(others.length === 0 ? {} : { otherCohorts: others.map(stripMergedFields) }),
55
+ ...(torn.length === 0 ? {} : { torn })
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Clamp an action-level report below `full` when the action abandoned blocks. A cohort can hold
61
+ * every block it was asked to commit and the action still be incomplete — the blocks the sweep
62
+ * never reached are on nobody. Never raises the class; a report already below `full` keeps it.
63
+ * Idempotent, and a no-op for an empty `torn`.
64
+ */
65
+ export function withTornBlocks(d: WriteDurability, torn: readonly BlockId[]): WriteDurability {
66
+ if (torn.length === 0) return d;
67
+ const allTorn = Array.from(new Set([...(d.torn ?? []), ...torn]));
68
+ return {
69
+ ...d,
70
+ quorum: d.quorum === 'full' ? 'majority' : d.quorum,
71
+ torn: allTorn
72
+ };
73
+ }
74
+
75
+ /** The single-node answer: this node holds it and knows nothing about a cohort beyond itself. */
76
+ export function localDurability(selfPeerId?: string): WriteDurability {
77
+ return {
78
+ quorum: 'local',
79
+ confirmed: 1,
80
+ cohort: 1,
81
+ ...(selfPeerId === undefined ? {} : { unconfirmed: [], cohortPeerIds: [selfPeerId] })
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The no-cohort answer: this node holds it and could not establish where it belongs. Takes no
87
+ * reason on purpose: the producing site logs WHY the cohort did not resolve (`CohortResolution.reason`),
88
+ * and the result says only `unrouted`, so no consumer is tempted to branch on prose.
89
+ */
90
+ export function unroutedDurability(): WriteDurability {
91
+ return { quorum: 'unrouted', confirmed: 1, cohort: 0 };
92
+ }
93
+
94
+ /** A merged report's own scalar view plus every `otherCohorts` entry, each as a standalone report. */
95
+ function flattenDurability(d: WriteDurability): WriteDurability[] {
96
+ return [stripMergedFields(d), ...(d.otherCohorts ?? [])];
97
+ }
98
+
99
+ /** The per-cohort fields only — `otherCohorts` and `torn` are action-level and re-derived by the merge. */
100
+ function stripMergedFields(d: WriteDurability): WriteDurability {
101
+ const { otherCohorts: _otherCohorts, torn: _torn, ...cohortReport } = d;
102
+ return cohortReport;
103
+ }
104
+
105
+ /**
106
+ * First occurrence of each structurally identical per-cohort report, input order kept. Peer lists
107
+ * compare as SETS: `findCluster` orders a cohort by routing distance to the block's key, so one
108
+ * cohort answering for two blocks lists the same members in two orders.
109
+ */
110
+ function distinctReports(reports: readonly WriteDurability[]): WriteDurability[] {
111
+ const seen = new Set<string>();
112
+ const sorted = (ids: readonly string[] | undefined): string[] | null => ids === undefined ? null : [...ids].sort();
113
+ return reports.filter(r => {
114
+ const key = JSON.stringify([r.quorum, r.confirmed, r.cohort, sorted(r.unconfirmed), sorted(r.cohortPeerIds)]);
115
+ if (seen.has(key)) return false;
116
+ seen.add(key);
117
+ return true;
118
+ });
119
+ }
120
+
121
+ function unionTorn(reports: readonly WriteDurability[]): BlockId[] {
122
+ return Array.from(new Set(reports.flatMap(r => r.torn ?? [])));
123
+ }
@@ -1,52 +1,60 @@
1
- import type { PeerId } from "./types.js";
2
- import type { ClusterPeers } from "../cluster/structs.js";
3
-
4
- /**
5
- * What a caller intends to do with the coordinator it is asking for.
6
- *
7
- * The distinction matters only when a node is isolated and the only candidate left is
8
- * itself. A read served from this node's own replica is at worst STALE and the layers
9
- * below already say so (a self-only cohort answers conclusively; an unreachable cohort
10
- * comes back flagged unavailable). A write coordinated alone can instead diverge from the
11
- * rest of the network, so it is held to the stricter bar.
12
- */
13
- export type CoordinatorIntent = 'read' | 'write';
14
-
15
- export type FindCoordinatorOptions = {
16
- /** Peers that have already been tried (and failed) */
17
- excludedPeers?: PeerId[];
18
- /**
19
- * What the caller intends to do with the coordinator. A read may fall back to this
20
- * node's own replica when the network is unreachable; a write may not do so on the
21
- * strength of the same evidence. Defaults to `'write'` (the conservative behavior)
22
- * when unset, so callers that don't set it are unchanged.
23
- */
24
- intent?: CoordinatorIntent;
25
- };
26
-
27
-
28
- export type IKeyNetwork = {
29
- /**
30
- * Find a coordinator node responsible for a given key and establish connection
31
- * @param key The key to find coordinator for
32
- * @returns Promise resolving to ID of coordinator node
33
- */
34
- findCoordinator(key: Uint8Array, options?: Partial<FindCoordinatorOptions>): Promise<PeerId>;
35
-
36
- /**
37
- * Find the peers in the cluster responsible for a given key
38
- * @param key The key to find peers for
39
- * @returns Promise resolving to the peers in the cluster
40
- */
41
- findCluster(key: Uint8Array): Promise<ClusterPeers>;
42
-
43
- /**
44
- * Optionally cache a resolved coordinator for a key, so a follow-up operation
45
- * (e.g. commit after pend) reuses the same peer. Implementations that don't
46
- * cache coordinators simply omit this. `ttlMs` bounds how long the hint lives.
47
- * @param key The key the coordinator was resolved for
48
- * @param peerId The coordinating peer
49
- * @param ttlMs Optional time-to-live for the cached hint, in ms
50
- */
51
- recordCoordinator?(key: Uint8Array, peerId: PeerId, ttlMs?: number): void;
52
- }
1
+ import type { PeerId } from "./types.js";
2
+ import type { ClusterPeers } from "../cluster/structs.js";
3
+ import type { RoutingKey } from "./routing-key.js";
4
+
5
+ /**
6
+ * What a caller intends to do with the coordinator it is asking for.
7
+ *
8
+ * The distinction matters only when a node is isolated and the only candidate left is
9
+ * itself. A read served from this node's own replica is at worst STALE — and the layers
10
+ * below already say so (a self-only cohort answers conclusively; an unreachable cohort
11
+ * comes back flagged unavailable). A write coordinated alone can instead diverge from the
12
+ * rest of the network, so it is held to the stricter bar.
13
+ */
14
+ export type CoordinatorIntent = 'read' | 'write';
15
+
16
+ export type FindCoordinatorOptions = {
17
+ /** Peers that have already been tried (and failed) */
18
+ excludedPeers?: PeerId[];
19
+ /**
20
+ * What the caller intends to do with the coordinator. A read may fall back to this
21
+ * node's own replica when the network is unreachable; a write may not do so on the
22
+ * strength of the same evidence. Defaults to `'write'` (the conservative behavior)
23
+ * when unset, so callers that don't set it are unchanged.
24
+ */
25
+ intent?: CoordinatorIntent;
26
+ };
27
+
28
+
29
+ /**
30
+ * Maps a block to the peers responsible for it.
31
+ *
32
+ * Every key is a {@link RoutingKey} minted by `routingKeyForBlock`. Implementations hash it into a ring
33
+ * coordinate themselves, so a caller must never pre-hash: the writer and the servers agree on a
34
+ * block's cohort only because both hand over the same bytes and exactly one hash is applied to them.
35
+ */
36
+ export type IKeyNetwork = {
37
+ /**
38
+ * Find a coordinator node responsible for a given key and establish connection
39
+ * @param key The block's routing key
40
+ * @returns Promise resolving to ID of coordinator node
41
+ */
42
+ findCoordinator(key: RoutingKey, options?: Partial<FindCoordinatorOptions>): Promise<PeerId>;
43
+
44
+ /**
45
+ * Find the peers in the cluster responsible for a given key
46
+ * @param key The block's routing key
47
+ * @returns Promise resolving to the peers in the cluster
48
+ */
49
+ findCluster(key: RoutingKey): Promise<ClusterPeers>;
50
+
51
+ /**
52
+ * Optionally cache a resolved coordinator for a key, so a follow-up operation
53
+ * (e.g. commit after pend) reuses the same peer. Implementations that don't
54
+ * cache coordinators simply omit this. `ttlMs` bounds how long the hint lives.
55
+ * @param key The routing key the coordinator was resolved for — the same key a later `findCoordinator` looks it up by
56
+ * @param peerId The coordinating peer
57
+ * @param ttlMs Optional time-to-live for the cached hint, in ms
58
+ */
59
+ recordCoordinator?(key: RoutingKey, peerId: PeerId, ttlMs?: number): void;
60
+ }
@@ -1,7 +1,10 @@
1
- export * from "./i-key-network.js";
2
- export * from "./i-peer-network.js";
3
- export * from "./i-repo.js";
4
- export * from "./repo-protocol.js";
5
- export * from "./stale-failure.js";
6
- export * from "./struct.js";
7
- export * from "./types.js";
1
+ export * from "./i-key-network.js";
2
+ export * from "./i-peer-network.js";
3
+ export * from "./durability.js";
4
+ export * from "./i-repo.js";
5
+ export * from "./lineage.js";
6
+ export * from "./repo-protocol.js";
7
+ export * from "./routing-key.js";
8
+ export * from "./stale-failure.js";
9
+ export * from "./struct.js";
10
+ export * from "./types.js";
@@ -0,0 +1,80 @@
1
+ import type { ActionRev } from "../collection/action.js";
2
+ import type { BlockLineage, WriteDurability } from "./struct.js";
3
+
4
+ /** One cohort member's answer to {@link BlockGets.lineageOf} for one block. A member that could not
5
+ * be asked, or predates the question, is listed with `unknown` — it still counts as a member. */
6
+ export type MemberLineage = {
7
+ peerId: string;
8
+ lineage: BlockLineage;
9
+ /** The newest revision the member holds for the block, as it reported it. */
10
+ latest?: ActionRev;
11
+ };
12
+
13
+ /** A block's cohort-level lineage answer, with who holds the write when the answer is `contains`. */
14
+ export type CohortLineage = {
15
+ lineage: BlockLineage;
16
+ durability?: WriteDurability;
17
+ };
18
+
19
+ /**
20
+ * The single rule for turning a cohort's per-member lineage answers into one answer for the block.
21
+ *
22
+ * A member can only vouch for history it derived itself, so a member that took a later revision as
23
+ * a replica answers `unknown` even when that replica was built from the write. Such a member is
24
+ * counted WITH a member that did answer when the two hold the same latest revision under the same
25
+ * action: a revision's content is what the cohort agreed on at commit, so two members holding the
26
+ * same one hold the same lineage.
27
+ *
28
+ * - `contains` — a strict majority of the cohort holds content built from the write, the same bar
29
+ * a commit must meet to be acknowledged. Fewer than that is `unknown`, not a smaller `contains`:
30
+ * the write may yet spread from the members that hold it, or be overwritten by the ones that do
31
+ * not, and the writer must not be told either.
32
+ * - `excludes` — at least one member proves it, and EVERY member is accounted for as excluding it
33
+ * or not having reached it. One unaccounted member could be where the write survives.
34
+ * - `behind` — every member answered that it has not reached the revision.
35
+ * - `unknown` — anything else, including members that contradict each other (one proves the write
36
+ * is in its content, another that it is not): the block's content has forked, which is a fault
37
+ * this answer must not paper over in either direction.
38
+ *
39
+ * An empty cohort is `unknown`.
40
+ */
41
+ export function judgeCohortLineage(members: readonly MemberLineage[]): CohortLineage {
42
+ const provers = members.filter(m => m.lineage === 'contains');
43
+ const refuters = members.filter(m => m.lineage === 'excludes');
44
+ if (members.length === 0 || (provers.length > 0 && refuters.length > 0)) {
45
+ return { lineage: 'unknown' };
46
+ }
47
+ if (provers.length > 0) {
48
+ const holders = withSameLatest(members, provers);
49
+ if (holders.length * 2 <= members.length) {
50
+ return { lineage: 'unknown' };
51
+ }
52
+ const held = new Set(holders.map(m => m.peerId));
53
+ const unconfirmed = members.filter(m => !held.has(m.peerId)).map(m => m.peerId);
54
+ return {
55
+ lineage: 'contains',
56
+ durability: {
57
+ quorum: unconfirmed.length === 0 ? 'full' : 'majority',
58
+ confirmed: holders.length,
59
+ cohort: members.length,
60
+ unconfirmed,
61
+ cohortPeerIds: members.map(m => m.peerId)
62
+ }
63
+ };
64
+ }
65
+ if (refuters.length > 0) {
66
+ const excluding = new Set(withSameLatest(members, refuters).map(m => m.peerId));
67
+ return members.every(m => excluding.has(m.peerId) || m.lineage === 'behind')
68
+ ? { lineage: 'excludes' }
69
+ : { lineage: 'unknown' };
70
+ }
71
+ return { lineage: members.every(m => m.lineage === 'behind') ? 'behind' : 'unknown' };
72
+ }
73
+
74
+ /** `decided`, plus every `unknown` member holding the same latest revision as one of them. */
75
+ function withSameLatest(members: readonly MemberLineage[], decided: readonly MemberLineage[]): MemberLineage[] {
76
+ const sameRevision = (a: ActionRev | undefined, b: ActionRev | undefined): boolean =>
77
+ a !== undefined && b !== undefined && a.rev === b.rev && a.actionId === b.actionId;
78
+ return members.filter(m => decided.includes(m)
79
+ || (m.lineage === 'unknown' && decided.some(d => sameRevision(d.latest, m.latest))));
80
+ }
@@ -0,0 +1,27 @@
1
+ import type { BlockId } from "../blocks/structs.js";
2
+
3
+ declare const routingKeyBrand: unique symbol;
4
+
5
+ /**
6
+ * The bytes a block is routed on — what `IKeyNetwork.findCluster` and `IKeyNetwork.findCoordinator`
7
+ * are handed for that block.
8
+ *
9
+ * Branded so the only way to obtain one is {@link routingKeyForBlock}. Every party that asks "who is
10
+ * responsible for this block" (the writer's `NetworkTransactor`, a coordinator's responsibility check,
11
+ * a cohort member re-deriving its cluster, `RepoService`'s redirect check) must land on the same ring
12
+ * position, and they only do if they hand the key network the same bytes. When the writer pre-hashed
13
+ * the id and the servers did not, every network wider than one cohort sent writers to the wrong
14
+ * machines — and no fixture was wide enough to notice, because a cohort holding every peer is the same
15
+ * set from any coordinate.
16
+ */
17
+ export type RoutingKey = Uint8Array & { readonly [routingKeyBrand]: true };
18
+
19
+ const utf8 = new TextEncoder();
20
+
21
+ /**
22
+ * A block's routing key: the raw utf8 bytes of its id. Deliberately NOT hashed here — the key network
23
+ * hashes the key once into a ring coordinate, and that is the only hash between a block id and its cohort.
24
+ */
25
+ export function routingKeyForBlock(blockId: BlockId): RoutingKey {
26
+ return utf8.encode(blockId) as RoutingKey;
27
+ }
@@ -58,7 +58,11 @@ export function highestStaleAt(candidates: readonly StaleFailure['staleAt'][]):
58
58
  * Deliberately `===` only. At `latest.rev > rev` this returns false even for our own action: the
59
59
  * follow-on commit is refused as stale anyway (`StorageRepo.commit`'s `missedCommits` branch), so
60
60
  * approving would only defer the refusal by a round trip, and `latest` alone can no longer name
61
- * who holds `rev` — that needs the revision index (see `IRevisionActionReader`).
61
+ * who holds `rev` — that needs the revision index (see `IRevisionActionReader`). Nor would the
62
+ * index settle what the writer actually needs to know there, which is whether the content now held
63
+ * was BUILT FROM its revision; that is a separate question with its own answer
64
+ * (`IBlockStorage.lineageOf`, asked by the writer through `ITransactor.getLineage` once its re-send
65
+ * has been refused), not a widening of this one.
62
66
  *
63
67
  * Every revision-vs-action check calls this: the pend tier (`StorageRepo.pend`,
64
68
  * `ClusterMember.validatePendOperations`, `CoordinatorRepo.classifyStaleRejection`) and the commit