@optimystic/db-core 1.0.0-beta.2 → 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 (180) hide show
  1. package/dist/src/cluster/structs.d.ts +62 -16
  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 +467 -35
  13. package/dist/src/collection/collection.d.ts.map +1 -1
  14. package/dist/src/collection/collection.js +879 -149
  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 +192 -5
  25. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  26. package/dist/src/collections/tree/struct.js +134 -0
  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 +109 -6
  31. package/dist/src/collections/tree/tree.js.map +1 -1
  32. package/dist/src/index.d.ts +2 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +2 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/logger-registry.d.ts +57 -0
  37. package/dist/src/logger-registry.d.ts.map +1 -0
  38. package/dist/src/logger-registry.js +168 -0
  39. package/dist/src/logger-registry.js.map +1 -0
  40. package/dist/src/logger.d.ts.map +1 -1
  41. package/dist/src/logger.js +3 -0
  42. package/dist/src/logger.js.map +1 -1
  43. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  44. package/dist/src/matchmaking/wire.js +8 -2
  45. package/dist/src/matchmaking/wire.js.map +1 -1
  46. package/dist/src/network/durability.d.ts +45 -0
  47. package/dist/src/network/durability.d.ts.map +1 -0
  48. package/dist/src/network/durability.js +114 -0
  49. package/dist/src/network/durability.js.map +1 -0
  50. package/dist/src/network/i-key-network.d.ts +14 -6
  51. package/dist/src/network/i-key-network.d.ts.map +1 -1
  52. package/dist/src/network/index.d.ts +3 -0
  53. package/dist/src/network/index.d.ts.map +1 -1
  54. package/dist/src/network/index.js +3 -0
  55. package/dist/src/network/index.js.map +1 -1
  56. package/dist/src/network/lineage.d.ts +39 -0
  57. package/dist/src/network/lineage.d.ts.map +1 -0
  58. package/dist/src/network/lineage.js +61 -0
  59. package/dist/src/network/lineage.js.map +1 -0
  60. package/dist/src/network/routing-key.d.ts +24 -0
  61. package/dist/src/network/routing-key.d.ts.map +1 -0
  62. package/dist/src/network/routing-key.js +9 -0
  63. package/dist/src/network/routing-key.js.map +1 -0
  64. package/dist/src/network/stale-failure.d.ts +5 -1
  65. package/dist/src/network/stale-failure.d.ts.map +1 -1
  66. package/dist/src/network/stale-failure.js +5 -1
  67. package/dist/src/network/stale-failure.js.map +1 -1
  68. package/dist/src/network/struct.d.ts +151 -8
  69. package/dist/src/network/struct.d.ts.map +1 -1
  70. package/dist/src/network/struct.js.map +1 -1
  71. package/dist/src/reactivity/wire.d.ts.map +1 -1
  72. package/dist/src/reactivity/wire.js +8 -2
  73. package/dist/src/reactivity/wire.js.map +1 -1
  74. package/dist/src/testing/test-transactor.d.ts +64 -7
  75. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  76. package/dist/src/testing/test-transactor.js +150 -23
  77. package/dist/src/testing/test-transactor.js.map +1 -1
  78. package/dist/src/transaction/coordinator.d.ts +141 -21
  79. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  80. package/dist/src/transaction/coordinator.js +408 -122
  81. package/dist/src/transaction/coordinator.js.map +1 -1
  82. package/dist/src/transaction/errors.d.ts +39 -10
  83. package/dist/src/transaction/errors.d.ts.map +1 -1
  84. package/dist/src/transaction/errors.js +34 -9
  85. package/dist/src/transaction/errors.js.map +1 -1
  86. package/dist/src/transaction/index.d.ts +1 -0
  87. package/dist/src/transaction/index.d.ts.map +1 -1
  88. package/dist/src/transaction/index.js.map +1 -1
  89. package/dist/src/transactor/block-floors.d.ts +78 -0
  90. package/dist/src/transactor/block-floors.d.ts.map +1 -0
  91. package/dist/src/transactor/block-floors.js +62 -0
  92. package/dist/src/transactor/block-floors.js.map +1 -0
  93. package/dist/src/transactor/change-notifier.d.ts +43 -0
  94. package/dist/src/transactor/change-notifier.d.ts.map +1 -1
  95. package/dist/src/transactor/change-notifier.js +3 -0
  96. package/dist/src/transactor/change-notifier.js.map +1 -1
  97. package/dist/src/transactor/index.d.ts +1 -0
  98. package/dist/src/transactor/index.d.ts.map +1 -1
  99. package/dist/src/transactor/index.js +1 -0
  100. package/dist/src/transactor/index.js.map +1 -1
  101. package/dist/src/transactor/network-transactor.d.ts +43 -6
  102. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  103. package/dist/src/transactor/network-transactor.js +279 -81
  104. package/dist/src/transactor/network-transactor.js.map +1 -1
  105. package/dist/src/transactor/transactor-source.d.ts +94 -4
  106. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  107. package/dist/src/transactor/transactor-source.js +120 -42
  108. package/dist/src/transactor/transactor-source.js.map +1 -1
  109. package/dist/src/transactor/transactor.d.ts +14 -1
  110. package/dist/src/transactor/transactor.d.ts.map +1 -1
  111. package/dist/src/transform/atomic.d.ts.map +1 -1
  112. package/dist/src/transform/atomic.js +12 -9
  113. package/dist/src/transform/atomic.js.map +1 -1
  114. package/dist/src/transform/base-pins.d.ts +50 -17
  115. package/dist/src/transform/base-pins.d.ts.map +1 -1
  116. package/dist/src/transform/base-pins.js +58 -11
  117. package/dist/src/transform/base-pins.js.map +1 -1
  118. package/dist/src/transform/cache-source.d.ts +75 -11
  119. package/dist/src/transform/cache-source.d.ts.map +1 -1
  120. package/dist/src/transform/cache-source.js +162 -36
  121. package/dist/src/transform/cache-source.js.map +1 -1
  122. package/dist/src/transform/digest.d.ts +8 -1
  123. package/dist/src/transform/digest.d.ts.map +1 -1
  124. package/dist/src/transform/digest.js +19 -9
  125. package/dist/src/transform/digest.js.map +1 -1
  126. package/dist/src/transform/helpers.d.ts +6 -2
  127. package/dist/src/transform/helpers.d.ts.map +1 -1
  128. package/dist/src/transform/helpers.js +8 -3
  129. package/dist/src/transform/helpers.js.map +1 -1
  130. package/dist/src/transform/tracker.d.ts +94 -14
  131. package/dist/src/transform/tracker.d.ts.map +1 -1
  132. package/dist/src/transform/tracker.js +222 -68
  133. package/dist/src/transform/tracker.js.map +1 -1
  134. package/dist/src/utility/structural-equals.d.ts +38 -0
  135. package/dist/src/utility/structural-equals.d.ts.map +1 -0
  136. package/dist/src/utility/structural-equals.js +77 -0
  137. package/dist/src/utility/structural-equals.js.map +1 -0
  138. package/package.json +2 -2
  139. package/src/cluster/structs.ts +298 -255
  140. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
  141. package/src/cohort-topic/wire/codec.ts +222 -216
  142. package/src/collection/collection.ts +996 -158
  143. package/src/collection/struct.ts +99 -4
  144. package/src/collections/diary/diary.ts +71 -68
  145. package/src/collections/tree/struct.ts +248 -26
  146. package/src/collections/tree/tree.ts +130 -9
  147. package/src/index.ts +2 -1
  148. package/src/logger-registry.ts +224 -0
  149. package/src/logger.ts +4 -0
  150. package/src/matchmaking/wire.ts +8 -2
  151. package/src/network/durability.ts +123 -0
  152. package/src/network/i-key-network.ts +60 -52
  153. package/src/network/index.ts +10 -7
  154. package/src/network/lineage.ts +80 -0
  155. package/src/network/routing-key.ts +27 -0
  156. package/src/network/stale-failure.ts +5 -1
  157. package/src/network/struct.ts +156 -8
  158. package/src/reactivity/wire.ts +230 -224
  159. package/src/testing/test-transactor.ts +152 -24
  160. package/src/transaction/coordinator.ts +470 -131
  161. package/src/transaction/errors.ts +34 -9
  162. package/src/transaction/index.ts +1 -0
  163. package/src/transactor/block-floors.ts +105 -0
  164. package/src/transactor/change-notifier.ts +129 -80
  165. package/src/transactor/index.ts +1 -0
  166. package/src/transactor/network-transactor.ts +297 -84
  167. package/src/transactor/transactor-source.ts +131 -42
  168. package/src/transactor/transactor.ts +13 -1
  169. package/src/transform/atomic.ts +25 -22
  170. package/src/transform/base-pins.ts +76 -18
  171. package/src/transform/cache-source.ts +327 -194
  172. package/src/transform/digest.ts +21 -10
  173. package/src/transform/helpers.ts +8 -3
  174. package/src/transform/tracker.ts +423 -270
  175. package/src/utility/structural-equals.ts +72 -0
  176. package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
  177. package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
  178. package/dist/src/utility/block-id-to-bytes.js +0 -7
  179. package/dist/src/utility/block-id-to-bytes.js.map +0 -1
  180. 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, 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
@@ -105,11 +185,43 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
105
185
  compare,
106
186
  nodeCapacity, // keep the write btree's fan-out in lock-step with the read btree
107
187
  );
108
- for (const [key, entry] of actions) {
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) {
204
+ // Enforce the entry's guard (if any) on EVERY handler run — initial staging
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;
215
+ }
110
216
  await actionTree.upsert(entry);
111
217
  } else {
112
- 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);
113
225
  }
114
226
  }
115
227
  // Mutations landed in `trx`, not the read btree, so its version counter never moved.
@@ -150,9 +262,18 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
150
262
  return new Tree<TKey, TEntry>(collection, held.btree, keyFromEntry, compare);
151
263
  }
152
264
 
153
- async replace(data: TreeReplaceAction<TKey, TEntry>): Promise<void> {
154
- await this.collection.act({ type: "replace", data });
155
- 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 }]);
156
277
  }
157
278
 
158
279
  /** Stage a mutation into the collection's tracker WITHOUT flushing it to the
@@ -167,8 +288,8 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
167
288
 
168
289
  /** Flush all staged (and any other pending) changes to the transactor.
169
290
  * Equivalent to the flush half of {@link replace}. */
170
- async sync(): Promise<void> {
171
- await this.collection.updateAndSync();
291
+ async sync(): Promise<WriteDurability | undefined> {
292
+ return await this.collection.updateAndSync();
172
293
  }
173
294
 
174
295
  /** Whether {@link sync} has anything to push — see
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./cohort-topic/index.js";
6
6
  export * from "./collection/index.js";
7
7
  export * from "./collections/index.js";
8
8
  export * from "./log/index.js";
9
+ export * from "./logger-registry.js";
9
10
  export * from "./matchmaking/index.js";
10
11
  export * from "./network/index.js";
11
12
  export * from "./reactivity/index.js";
@@ -17,8 +18,8 @@ export * from "./utility/groupby.js";
17
18
  export * from "./utility/hash-string.js";
18
19
  export * from "./utility/latches.js";
19
20
  export * from "./utility/nameof.js";
21
+ export * from "./utility/structural-equals.js";
20
22
  export * from "./utility/ensured.js";
21
23
  export * from "./utility/pending.js";
22
- export * from "./utility/block-id-to-bytes.js";
23
24
  export * from "./utility/lru-map.js";
24
25
  export * from "./utility/backoff.js";
@@ -0,0 +1,224 @@
1
+ /*
2
+ * One switch for every `optimystic:*` log channel, whichever copy of `debug` it lives on.
3
+ *
4
+ * Why this exists. `debug` turns namespaces on only from `process.env.DEBUG` (Node) or
5
+ * `localStorage.debug` (its browser build, which is the one Metro bundles). React Native has
6
+ * neither, so every channel is silently off there — and an empty capture reads exactly like "that
7
+ * code never ran". Calling `debug.enable(...)` from app code does not fix it reliably either: it
8
+ * reaches only the copy of `debug` the caller resolved, and each Optimystic package may resolve its
9
+ * own (this repo's install, `nmHoistingLimits: workspaces`, gives every package a separate copy).
10
+ *
11
+ * So each package's `src/logger.ts` registers the copy IT imported, and `enableOptimysticLogging`
12
+ * drives all of them. Coverage follows from every package registering, not from how the install
13
+ * happened to be laid out. `docs/debugging.md` § "Turning logging on" is the user-facing statement.
14
+ *
15
+ * This module imports nothing. It sits below every logger in the package, and
16
+ * `test/barrel-import-cycle.spec.ts` holds registry modules to zero runtime imports.
17
+ */
18
+
19
+ /** The slice of a `debug` module this registry drives. Structural, so db-core never type-imports a particular copy. */
20
+ export interface DebugModule {
21
+ enable(namespaces: string): void;
22
+ /** `debug` 4.x returns the namespaces that were active. */
23
+ disable(): string;
24
+ log: (...args: any[]) => any;
25
+ /**
26
+ * `debug`'s persistence hook, which `enable` calls with every new set: it writes
27
+ * `process.env.DEBUG` on Node and `localStorage.debug` in the browser build. Suppressed around
28
+ * every call this registry makes — see `withoutPersisting`. Optional so a test double need not
29
+ * supply one, and because `@types/debug` does not declare it.
30
+ */
31
+ save?: (namespaces: string) => void;
32
+ }
33
+
34
+ /** Where log lines go — the same shape as `debug`'s own `log` property. */
35
+ export type LogSink = (...args: unknown[]) => void;
36
+
37
+ export interface OptimysticLoggingOptions {
38
+ /**
39
+ * Where log lines go. Default: leave each copy's own sink (stderr on Node; `console.debug` in the
40
+ * browser/RN build). Pass one when the platform hides that sink (e.g. a device log filtered above
41
+ * debug level) or to collect a capture in memory.
42
+ */
43
+ log?: LogSink;
44
+ }
45
+
46
+ export interface OptimysticLoggingReport {
47
+ /** The Optimystic contribution, comma-joined — empty when the call enabled nothing. */
48
+ namespaces: string;
49
+ /** One entry per distinct `debug` copy: the packages that registered it. */
50
+ copies: string[][];
51
+ }
52
+
53
+ interface Entry {
54
+ module: DebugModule;
55
+ owners: string[];
56
+ /** What this copy had enabled, and its sink, before our first touch. Unset while untouched. */
57
+ original?: { namespaces: string; log: DebugModule['log'] };
58
+ }
59
+
60
+ interface Pending {
61
+ namespaces: string;
62
+ log?: LogSink;
63
+ }
64
+
65
+ /*
66
+ * NOTE: the state lives on `globalThis`, not in module scope, so a bundle that ends up with two
67
+ * copies of db-core still has ONE registry — a copy of a package that registered with the other
68
+ * db-core would otherwise be unreachable. The cost: two DIFFERENT db-core versions in one bundle
69
+ * share this object, so change its shape only additively.
70
+ */
71
+ const REGISTRY_KEY = Symbol.for('@optimystic/logger-registry');
72
+
73
+ interface RegistryState {
74
+ entries: Entry[];
75
+ /** The last enable call's settings, applied to copies that register afterwards. Unset when off. */
76
+ pending?: Pending;
77
+ }
78
+
79
+ function registry(): RegistryState {
80
+ const holder = globalThis as unknown as Record<symbol, RegistryState | undefined>;
81
+ return holder[REGISTRY_KEY] ??= { entries: [] };
82
+ }
83
+
84
+ /**
85
+ * Run `fn` with the copy's `save` hook stubbed out, so what we enable changes this process only.
86
+ *
87
+ * Without this, on Node every `enable` would write `process.env.DEBUG` — and a copy of `debug` that
88
+ * loads LATER reads that variable as its own starting set, so it would arrive with our namespaces
89
+ * baked into what `disableOptimysticLogging` later treats as its baseline (they could never be
90
+ * turned off on that copy). Capturing a baseline via `disable()` would also delete the user's
91
+ * `DEBUG`. In a browser, it would persist our namespaces into `localStorage.debug`, leaving logging
92
+ * on after a reload even without this call.
93
+ */
94
+ function withoutPersisting(module: DebugModule, fn: () => void): void {
95
+ const save = module.save;
96
+ if (save) module.save = () => { };
97
+ try {
98
+ fn();
99
+ } finally {
100
+ if (save) module.save = save;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Bring one copy in line with `pending`: its baseline (captured on first touch) plus ours, and
106
+ * `pending.log` or its original sink.
107
+ *
108
+ * NOTE: ours is appended, so a `-optimystic:…` skip already in the copy's baseline (say
109
+ * `DEBUG='*,-optimystic:*'`) still wins — `debug` checks skips first. That is the user's explicit
110
+ * exclusion, so it is left alone; if it ever confuses someone, have the confirmation line name the
111
+ * conflicting skip rather than overriding it.
112
+ *
113
+ * NOTE: the baseline is captured once per enable/disable cycle, so an app that calls
114
+ * `debug.enable(...)` on a shared copy while ours are on has that change overwritten by our next
115
+ * enable or disable. Fine while apps set their channels once at start-up; if one ever toggles them
116
+ * at run time, re-derive the baseline from the copy's live set minus our contribution instead.
117
+ */
118
+ function apply(entry: Entry, pending: Pending): void {
119
+ const module = entry.module;
120
+ if (!entry.original) {
121
+ let namespaces = '';
122
+ withoutPersisting(module, () => { namespaces = module.disable(); });
123
+ entry.original = { namespaces, log: module.log };
124
+ }
125
+ const combined = [entry.original.namespaces, pending.namespaces].filter(Boolean).join(',');
126
+ withoutPersisting(module, () => module.enable(combined));
127
+ module.log = pending.log ?? entry.original.log;
128
+ }
129
+
130
+ function restore(entry: Entry): void {
131
+ const original = entry.original;
132
+ if (!original) return;
133
+ withoutPersisting(entry.module, () => entry.module.enable(original.namespaces));
134
+ entry.module.log = original.log;
135
+ entry.original = undefined;
136
+ }
137
+
138
+ /** Comma-join, trimming each entry and dropping empty ones. A string may already hold a list. */
139
+ function normalizeNamespaces(namespaces: string | readonly string[]): string {
140
+ const parts = typeof namespaces === 'string' ? [namespaces] : namespaces;
141
+ return parts
142
+ .flatMap(part => part.split(','))
143
+ .map(part => part.trim())
144
+ .filter(Boolean)
145
+ .join(',');
146
+ }
147
+
148
+ function confirmationLine(report: OptimysticLoggingReport): string {
149
+ const what = report.namespaces ? `"${report.namespaces}"` : 'nothing (empty namespace list)';
150
+ const trailer = 'libp2p:* is separate, see docs/debugging.md';
151
+ if (report.copies.length === 0) {
152
+ return `optimystic logging on: ${what}; no debug copies registered yet, namespaces will apply as Optimystic packages load; ${trailer}`;
153
+ }
154
+ const count = report.copies.length;
155
+ const groups = report.copies.map(owners => owners.join(', ')).join(' | ');
156
+ return `optimystic logging on: ${what} across ${count} debug ${count === 1 ? 'copy' : 'copies'} [${groups}]; ${trailer}`;
157
+ }
158
+
159
+ /**
160
+ * Called once from each package's `src/logger.ts` at module load, with the `debug` module that file
161
+ * imported. Idempotent per (owner, module); several owners sharing one copy are one entry.
162
+ *
163
+ * With no prior `enableOptimysticLogging` call this touches nothing, so `DEBUG=` on Node behaves
164
+ * exactly as it always has. After one, the arriving copy gets the same namespaces and sink at once
165
+ * (its own baseline captured first), silently — the enable call already confirmed.
166
+ */
167
+ export function registerDebugModule(owner: string, module: DebugModule): void {
168
+ const state = registry();
169
+ const existing = state.entries.find(entry => entry.module === module);
170
+ if (existing) {
171
+ if (!existing.owners.includes(owner)) existing.owners.push(owner);
172
+ return;
173
+ }
174
+ const entry: Entry = { module, owners: [owner] };
175
+ state.entries.push(entry);
176
+ if (state.pending) apply(entry, state.pending);
177
+ }
178
+
179
+ /**
180
+ * Turn on the given namespaces on every registered `debug` copy, now and for copies that register
181
+ * later — the way to enable Optimystic logging on any runtime, React Native included, without
182
+ * environment variables.
183
+ *
184
+ * Adds to each copy's existing namespaces rather than replacing them, so an app's own channels
185
+ * sharing a copy stay on. A second call replaces the first call's namespaces and `log`, and does not
186
+ * accumulate. An empty list enables nothing of ours (and is not an error).
187
+ *
188
+ * Always writes one confirmation line — through `options.log` if given, else the first registered
189
+ * copy's sink, else `console.log` — because silence is exactly what cannot be told apart from "the
190
+ * code never ran". No line: this was not called, or the sink is swallowed. A line and no events: the
191
+ * code did not run, or the filter does not match. Events: it works.
192
+ */
193
+ export function enableOptimysticLogging(
194
+ namespaces: string | readonly string[],
195
+ options?: OptimysticLoggingOptions,
196
+ ): OptimysticLoggingReport {
197
+ const state = registry();
198
+ const pending: Pending = { namespaces: normalizeNamespaces(namespaces), log: options?.log };
199
+ state.pending = pending;
200
+ for (const entry of state.entries) apply(entry, pending);
201
+
202
+ const report: OptimysticLoggingReport = {
203
+ namespaces: pending.namespaces,
204
+ copies: state.entries.map(entry => [...entry.owners]),
205
+ };
206
+ const line = confirmationLine(report);
207
+ const sink = options?.log ?? state.entries[0]?.module.log;
208
+ if (sink) {
209
+ sink(line);
210
+ } else {
211
+ // No Optimystic package has loaded yet, so there is no debug sink to write through — and the
212
+ // confirmation line must never be skipped (see above).
213
+ // eslint-disable-next-line no-console
214
+ console.log(line);
215
+ }
216
+ return report;
217
+ }
218
+
219
+ /** Undo it: every copy goes back to what it had enabled before the first enable call, and its original sink. Safe to call when nothing is enabled. */
220
+ export function disableOptimysticLogging(): void {
221
+ const state = registry();
222
+ for (const entry of state.entries) restore(entry);
223
+ state.pending = undefined;
224
+ }
package/src/logger.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import debug from 'debug'
2
+ import { registerDebugModule } from './logger-registry.js'
2
3
 
3
4
  const BASE_NAMESPACE = 'optimystic:db-core'
4
5
 
6
+ // So `enableOptimysticLogging` reaches this package's copy of `debug`, which may be no one else's.
7
+ registerDebugModule('db-core', debug)
8
+
5
9
  export function createLogger(subNamespace: string): debug.Debugger {
6
10
  return debug(`${BASE_NAMESPACE}:${subNamespace}`)
7
11
  }
@@ -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
+ }