@optimystic/db-core 0.21.0 → 0.24.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 (185) hide show
  1. package/README.md +336 -336
  2. package/dist/src/btree/btree.d.ts +2 -1
  3. package/dist/src/btree/btree.d.ts.map +1 -1
  4. package/dist/src/btree/btree.js +1 -1
  5. package/dist/src/btree/btree.js.map +1 -1
  6. package/dist/src/chain/chain.d.ts +1 -1
  7. package/dist/src/chain/chain.d.ts.map +1 -1
  8. package/dist/src/chain/chain.js +1 -1
  9. package/dist/src/chain/chain.js.map +1 -1
  10. package/dist/src/cluster/structs.d.ts +39 -1
  11. package/dist/src/cluster/structs.d.ts.map +1 -1
  12. package/dist/src/cluster/structs.js +24 -0
  13. package/dist/src/cluster/structs.js.map +1 -1
  14. package/dist/src/collection/collection.d.ts +20 -1
  15. package/dist/src/collection/collection.d.ts.map +1 -1
  16. package/dist/src/collection/collection.js +31 -4
  17. package/dist/src/collection/collection.js.map +1 -1
  18. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  19. package/dist/src/collections/diary/diary.js +2 -1
  20. package/dist/src/collections/diary/diary.js.map +1 -1
  21. package/dist/src/collections/diary/struct.js +1 -1
  22. package/dist/src/collections/diary/struct.js.map +1 -1
  23. package/dist/src/collections/tree/collection-trunk.js +1 -1
  24. package/dist/src/collections/tree/collection-trunk.js.map +1 -1
  25. package/dist/src/collections/tree/struct.d.ts +1 -1
  26. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  27. package/dist/src/collections/tree/struct.js +2 -1
  28. package/dist/src/collections/tree/struct.js.map +1 -1
  29. package/dist/src/collections/tree/tree.d.ts +5 -0
  30. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  31. package/dist/src/collections/tree/tree.js +7 -0
  32. package/dist/src/collections/tree/tree.js.map +1 -1
  33. package/dist/src/log/log.d.ts +1 -1
  34. package/dist/src/log/log.d.ts.map +1 -1
  35. package/dist/src/log/log.js +2 -2
  36. package/dist/src/log/log.js.map +1 -1
  37. package/dist/src/network/i-peer-network.d.ts +16 -0
  38. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  39. package/dist/src/network/struct.d.ts +39 -2
  40. package/dist/src/network/struct.d.ts.map +1 -1
  41. package/dist/src/network/struct.js +18 -0
  42. package/dist/src/network/struct.js.map +1 -1
  43. package/dist/src/testing/test-transactor.d.ts +95 -8
  44. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  45. package/dist/src/testing/test-transactor.js +133 -12
  46. package/dist/src/testing/test-transactor.js.map +1 -1
  47. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  48. package/dist/src/transaction/coordinator.js +2 -1
  49. package/dist/src/transaction/coordinator.js.map +1 -1
  50. package/dist/src/transaction/transaction.d.ts +1 -1
  51. package/dist/src/transaction/transaction.js +1 -1
  52. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  53. package/dist/src/transactor/network-transactor.js +57 -18
  54. package/dist/src/transactor/network-transactor.js.map +1 -1
  55. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  56. package/dist/src/transactor/transactor-source.js +25 -2
  57. package/dist/src/transactor/transactor-source.js.map +1 -1
  58. package/dist/src/transform/cache-source.js +1 -1
  59. package/dist/src/transform/cache-source.js.map +1 -1
  60. package/dist/src/transform/helpers.d.ts +6 -1
  61. package/dist/src/transform/helpers.d.ts.map +1 -1
  62. package/dist/src/transform/helpers.js +7 -6
  63. package/dist/src/transform/helpers.js.map +1 -1
  64. package/dist/src/transform/tracker.d.ts.map +1 -1
  65. package/dist/src/transform/tracker.js +2 -1
  66. package/dist/src/transform/tracker.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/btree/btree.ts +2 -1
  69. package/src/chain/chain.ts +2 -1
  70. package/src/cluster/membership.ts +85 -85
  71. package/src/cluster/structs.ts +43 -4
  72. package/src/cohort-topic/addressing.ts +120 -120
  73. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  74. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  75. package/src/cohort-topic/antidos/index.ts +5 -5
  76. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  77. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  78. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  79. package/src/cohort-topic/antiflood/index.ts +2 -2
  80. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  81. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  82. package/src/cohort-topic/coldstart.ts +237 -237
  83. package/src/cohort-topic/dmax.ts +88 -88
  84. package/src/cohort-topic/gossip/bus.ts +254 -254
  85. package/src/cohort-topic/gossip/index.ts +3 -3
  86. package/src/cohort-topic/gossip/records.ts +45 -45
  87. package/src/cohort-topic/gossip/view.ts +91 -91
  88. package/src/cohort-topic/index.ts +20 -20
  89. package/src/cohort-topic/load/barometer.ts +134 -134
  90. package/src/cohort-topic/load/index.ts +1 -1
  91. package/src/cohort-topic/member-engine.ts +430 -430
  92. package/src/cohort-topic/membership/index.ts +3 -3
  93. package/src/cohort-topic/membership/publisher.ts +163 -163
  94. package/src/cohort-topic/membership/source.ts +41 -41
  95. package/src/cohort-topic/membership/verifier.ts +461 -461
  96. package/src/cohort-topic/ports.ts +157 -157
  97. package/src/cohort-topic/promotion.ts +405 -405
  98. package/src/cohort-topic/registration/bytes.ts +37 -37
  99. package/src/cohort-topic/registration/handoff.ts +154 -154
  100. package/src/cohort-topic/registration/index.ts +6 -6
  101. package/src/cohort-topic/registration/renewal.ts +495 -495
  102. package/src/cohort-topic/registration/sharding.ts +61 -61
  103. package/src/cohort-topic/registration/store.ts +81 -81
  104. package/src/cohort-topic/registration/types.ts +91 -91
  105. package/src/cohort-topic/ring-hash.ts +50 -50
  106. package/src/cohort-topic/service.ts +416 -416
  107. package/src/cohort-topic/sig/index.ts +2 -2
  108. package/src/cohort-topic/sig/payloads.ts +59 -59
  109. package/src/cohort-topic/sig/threshold.ts +64 -64
  110. package/src/cohort-topic/tiers.ts +74 -74
  111. package/src/cohort-topic/traffic.ts +233 -233
  112. package/src/cohort-topic/walk.ts +326 -326
  113. package/src/cohort-topic/willingness.ts +237 -237
  114. package/src/cohort-topic/wire/codec.ts +216 -216
  115. package/src/cohort-topic/wire/index.ts +18 -18
  116. package/src/cohort-topic/wire/payloads.ts +126 -126
  117. package/src/cohort-topic/wire/primitives.ts +188 -188
  118. package/src/cohort-topic/wire/types.ts +475 -475
  119. package/src/cohort-topic/wire/validate.ts +512 -512
  120. package/src/collection/collection-type-registry.ts +37 -37
  121. package/src/collection/collection.ts +32 -4
  122. package/src/collections/diary/diary.ts +68 -67
  123. package/src/collections/diary/struct.ts +1 -1
  124. package/src/collections/tree/collection-trunk.ts +1 -1
  125. package/src/collections/tree/readme.md +4 -0
  126. package/src/collections/tree/struct.ts +3 -1
  127. package/src/collections/tree/tree.ts +320 -312
  128. package/src/log/log.ts +2 -2
  129. package/src/matchmaking/capability-filter.ts +45 -45
  130. package/src/matchmaking/config.ts +98 -98
  131. package/src/matchmaking/index.ts +21 -21
  132. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  133. package/src/matchmaking/provider.ts +123 -123
  134. package/src/matchmaking/query-eval.ts +105 -105
  135. package/src/matchmaking/seeker-walk.ts +127 -127
  136. package/src/matchmaking/seeker.ts +86 -86
  137. package/src/matchmaking/topic-anchor.ts +90 -90
  138. package/src/matchmaking/voting-quorum.ts +394 -394
  139. package/src/matchmaking/wire.ts +603 -603
  140. package/src/network/i-peer-network.ts +17 -0
  141. package/src/network/stale-failure.ts +43 -43
  142. package/src/network/struct.ts +41 -2
  143. package/src/network/types.ts +37 -37
  144. package/src/reactivity/backfill.ts +220 -220
  145. package/src/reactivity/backpressure.ts +191 -191
  146. package/src/reactivity/checkpoint.ts +308 -308
  147. package/src/reactivity/config.ts +172 -172
  148. package/src/reactivity/dedupe.ts +132 -132
  149. package/src/reactivity/forwarder.ts +87 -87
  150. package/src/reactivity/index.ts +34 -34
  151. package/src/reactivity/notification.ts +123 -123
  152. package/src/reactivity/policy.ts +79 -79
  153. package/src/reactivity/push-state.ts +310 -310
  154. package/src/reactivity/recover.ts +153 -153
  155. package/src/reactivity/replay-buffer.ts +141 -141
  156. package/src/reactivity/resume.ts +549 -549
  157. package/src/reactivity/rotation.ts +415 -415
  158. package/src/reactivity/subscriber.ts +132 -132
  159. package/src/reactivity/subscription.ts +66 -66
  160. package/src/reactivity/topic-anchor.ts +71 -71
  161. package/src/reactivity/verify.ts +73 -73
  162. package/src/reactivity/wire-validate.ts +13 -13
  163. package/src/reactivity/wire.ts +224 -224
  164. package/src/testing/async-wait.ts +65 -65
  165. package/src/testing/index.ts +2 -2
  166. package/src/testing/test-transactor.ts +638 -489
  167. package/src/transaction/coordinator.ts +2 -1
  168. package/src/transaction/errors.ts +91 -91
  169. package/src/transaction/operations-hash.ts +196 -196
  170. package/src/transaction/read-dependency-collector.ts +78 -78
  171. package/src/transaction/transaction.ts +1 -1
  172. package/src/transactor/change-notifier.ts +80 -80
  173. package/src/transactor/index.ts +5 -5
  174. package/src/transactor/network-transactor.ts +58 -19
  175. package/src/transactor/transactor-source.ts +25 -2
  176. package/src/transform/atomic-proxy.ts +92 -92
  177. package/src/transform/cache-source.ts +1 -1
  178. package/src/transform/helpers.ts +159 -158
  179. package/src/transform/tracker.ts +2 -1
  180. package/src/utility/backoff.ts +95 -95
  181. package/src/utility/batch-coordinator.ts +191 -191
  182. package/dist/src/transaction/context.d.ts +0 -60
  183. package/dist/src/transaction/context.d.ts.map +0 -1
  184. package/dist/src/transaction/context.js +0 -91
  185. package/dist/src/transaction/context.js.map +0 -1
@@ -1,78 +1,78 @@
1
- import type { BlockId, ReadPurpose } from "../blocks/index.js";
2
- import type { ReadDependency } from "./transaction.js";
3
-
4
- /** One captured read: the highest revision observed for the block, plus its {@link ReadPurpose}. */
5
- type ReadEntry = { revision: number; purpose: ReadPurpose };
6
-
7
- /** Accumulates the read dependencies of one transaction. Keyed by block id. For each id it keeps:
8
- *
9
- * - the HIGHEST revision observed (never downgrades — a re-read from cache must not overwrite a
10
- * higher revision seen earlier), and
11
- * - the read's PURPOSE with value-wins semantics: once a block is recorded as a `value` read from
12
- * ANY path it stays `value`; `navigation` sticks only for a block that was never read as a value.
13
- * This is the exact analogue of the max-wins rule on revision, and it makes the classification
14
- * order-independent (so it is deterministic regardless of cache/timing — required for the
15
- * transaction id and client signature to match across a re-executing validator; Theorem 4).
16
- *
17
- * {@link getReadDependencies} returns only the CONFLICT set (value reads); purely-structural
18
- * `navigation` reads — interior B-tree branches walked through to reach a captured leaf — are
19
- * dropped. See {@link markValue} and docs/correctness.md Theorem 5.
20
- *
21
- * One instance is shared by a collection's {@link TransactorSource} (direct structural reads —
22
- * bootstrap, header) and its {@link CacheSource} (every cache hit/miss), so a block read from
23
- * either layer produces a dependency. Because both feed the same collector, a cache miss records
24
- * the id once from each layer at the same revision/purpose; the merge collapses those to one
25
- * entry. Cleared at each txn boundary. */
26
- export class ReadDependencyCollector {
27
- private reads = new Map<BlockId, ReadEntry>();
28
-
29
- /** Record a read of `blockId` at `revision`. `purpose` defaults to `value` (retained); pass
30
- * `navigation` for an interior structural read that a later {@link markValue} may or may not
31
- * upgrade. Revision is max-wins; purpose is value-wins (see class doc). */
32
- record(blockId: BlockId, revision: number, purpose: ReadPurpose = 'value'): void {
33
- const prev = this.reads.get(blockId);
34
- if (prev === undefined) {
35
- this.reads.set(blockId, { revision, purpose });
36
- return;
37
- }
38
- if (revision > prev.revision) {
39
- prev.revision = revision;
40
- }
41
- // value-wins: a value read anywhere pins the block as value; navigation never downgrades it.
42
- if (purpose === 'value') {
43
- prev.purpose = 'value';
44
- }
45
- }
46
-
47
- /** Upgrade an already-recorded read to `value` (keeping its revision), retaining it in the
48
- * conflict set. The B-tree point-lookup path uses this to mark the terminal leaf — the
49
- * load-bearing read whose content the result depends on — after its interior descent nodes
50
- * were recorded as `navigation`. No-op if the id was never recorded (e.g. a leaf served from
51
- * an uncommitted staged insert, which records no dependency at all). */
52
- markValue(blockId: BlockId): void {
53
- const prev = this.reads.get(blockId);
54
- if (prev) {
55
- prev.purpose = 'value';
56
- }
57
- }
58
-
59
- /** The optimistic-concurrency conflict (read) set: every `value` read, with `navigation`
60
- * reads excluded. Dropping a covered navigation read cannot admit a lost update — any
61
- * concurrent change to the queried result also bumps a retained value read (the target
62
- * leaf) — but it removes the false-positive stale rejections that structural block reads
63
- * otherwise caused (Theorem 5 Bound). Deterministic in query shape, so a coordinator and a
64
- * re-executing validator derive the identical set. */
65
- getReadDependencies(): ReadDependency[] {
66
- const result: ReadDependency[] = [];
67
- for (const [blockId, entry] of this.reads) {
68
- if (entry.purpose === 'value') {
69
- result.push({ blockId, revision: entry.revision });
70
- }
71
- }
72
- return result;
73
- }
74
-
75
- clear(): void {
76
- this.reads.clear();
77
- }
78
- }
1
+ import type { BlockId, ReadPurpose } from "../blocks/index.js";
2
+ import type { ReadDependency } from "./transaction.js";
3
+
4
+ /** One captured read: the highest revision observed for the block, plus its {@link ReadPurpose}. */
5
+ type ReadEntry = { revision: number; purpose: ReadPurpose };
6
+
7
+ /** Accumulates the read dependencies of one transaction. Keyed by block id. For each id it keeps:
8
+ *
9
+ * - the HIGHEST revision observed (never downgrades — a re-read from cache must not overwrite a
10
+ * higher revision seen earlier), and
11
+ * - the read's PURPOSE with value-wins semantics: once a block is recorded as a `value` read from
12
+ * ANY path it stays `value`; `navigation` sticks only for a block that was never read as a value.
13
+ * This is the exact analogue of the max-wins rule on revision, and it makes the classification
14
+ * order-independent (so it is deterministic regardless of cache/timing — required for the
15
+ * transaction id and client signature to match across a re-executing validator; Theorem 4).
16
+ *
17
+ * {@link getReadDependencies} returns only the CONFLICT set (value reads); purely-structural
18
+ * `navigation` reads — interior B-tree branches walked through to reach a captured leaf — are
19
+ * dropped. See {@link markValue} and docs/correctness.md Theorem 5.
20
+ *
21
+ * One instance is shared by a collection's {@link TransactorSource} (direct structural reads —
22
+ * bootstrap, header) and its {@link CacheSource} (every cache hit/miss), so a block read from
23
+ * either layer produces a dependency. Because both feed the same collector, a cache miss records
24
+ * the id once from each layer at the same revision/purpose; the merge collapses those to one
25
+ * entry. Cleared at each txn boundary. */
26
+ export class ReadDependencyCollector {
27
+ private reads = new Map<BlockId, ReadEntry>();
28
+
29
+ /** Record a read of `blockId` at `revision`. `purpose` defaults to `value` (retained); pass
30
+ * `navigation` for an interior structural read that a later {@link markValue} may or may not
31
+ * upgrade. Revision is max-wins; purpose is value-wins (see class doc). */
32
+ record(blockId: BlockId, revision: number, purpose: ReadPurpose = 'value'): void {
33
+ const prev = this.reads.get(blockId);
34
+ if (prev === undefined) {
35
+ this.reads.set(blockId, { revision, purpose });
36
+ return;
37
+ }
38
+ if (revision > prev.revision) {
39
+ prev.revision = revision;
40
+ }
41
+ // value-wins: a value read anywhere pins the block as value; navigation never downgrades it.
42
+ if (purpose === 'value') {
43
+ prev.purpose = 'value';
44
+ }
45
+ }
46
+
47
+ /** Upgrade an already-recorded read to `value` (keeping its revision), retaining it in the
48
+ * conflict set. The B-tree point-lookup path uses this to mark the terminal leaf — the
49
+ * load-bearing read whose content the result depends on — after its interior descent nodes
50
+ * were recorded as `navigation`. No-op if the id was never recorded (e.g. a leaf served from
51
+ * an uncommitted staged insert, which records no dependency at all). */
52
+ markValue(blockId: BlockId): void {
53
+ const prev = this.reads.get(blockId);
54
+ if (prev) {
55
+ prev.purpose = 'value';
56
+ }
57
+ }
58
+
59
+ /** The optimistic-concurrency conflict (read) set: every `value` read, with `navigation`
60
+ * reads excluded. Dropping a covered navigation read cannot admit a lost update — any
61
+ * concurrent change to the queried result also bumps a retained value read (the target
62
+ * leaf) — but it removes the false-positive stale rejections that structural block reads
63
+ * otherwise caused (Theorem 5 Bound). Deterministic in query shape, so a coordinator and a
64
+ * re-executing validator derive the identical set. */
65
+ getReadDependencies(): ReadDependency[] {
66
+ const result: ReadDependency[] = [];
67
+ for (const [blockId, entry] of this.reads) {
68
+ if (entry.purpose === 'value') {
69
+ result.push({ blockId, revision: entry.revision });
70
+ }
71
+ }
72
+ return result;
73
+ }
74
+
75
+ clear(): void {
76
+ this.reads.clear();
77
+ }
78
+ }
@@ -128,7 +128,7 @@ export const DEFAULT_TRANSACTION_TTL_MS = 30_000;
128
128
  * Upper bound on a transaction's aged {@link Transaction.priority}. Capping is essential: it
129
129
  * bounds the fairness effect (an aged transaction cannot accrue unlimited advantage) and bounds
130
130
  * the throughput cost (it can impose at most `MaxPriority` extra race-losses on rivals before it
131
- * commits). Two transactions capped out at `MaxPriority` fall back to the existing promise-count /
131
+ * commits). Two transactions capped out at `MaxPriority` fall back to the existing approval-count /
132
132
  * message-hash tiebreak, so behavior degrades gracefully to the pre-priority status quo.
133
133
  */
134
134
  export const MaxPriority = 8;
@@ -1,80 +1,80 @@
1
- import type { BlockId, CollectionId } from '../index.js';
2
- import type { ActionId } from '../collection/action.js';
3
-
4
- /** A commit landed on this node mutating one collection's blocks. */
5
- export type CollectionChangeEvent = {
6
- /** Header/collection id of the affected collection (block.header.collectionId). */
7
- readonly collectionId: CollectionId;
8
- /** Blocks within that collection mutated by this commit. */
9
- readonly blockIds: readonly BlockId[];
10
- readonly actionId: ActionId;
11
- readonly rev: number;
12
- /**
13
- * The collection's chain tail block id at the time of this commit (the `CommitRequest.tailId`).
14
- * Anchors the rotating reactivity topic `H(tailId ‖ "reactivity")`. Present on commit-driven
15
- * events; `undefined` on read-driven promotions (the `StorageRepo.get` path has no commit tail —
16
- * those never originate anyway, and are cert-gated out downstream).
17
- */
18
- readonly tailId?: BlockId;
19
- /**
20
- * `true` iff this change event is a durable **invalidation** — a compensating revision + appended
21
- * `InvalidationEntry` reversing a previously-committed action proven invalid by dispute
22
- * (`docs/right-is-right.md` §Durable Invalidation), rather than an ordinary commit. An invalidation
23
- * is a committed collection change like any other, so it flows through the same notification path;
24
- * this flag lets a subscriber distinguish it (drop derived results + resubmit) from a plain commit
25
- * (refresh). A hint only — correctness never depends on it: the subscriber always re-reads the
26
- * authoritative reverted state. Absent (falsy) on ordinary commits.
27
- */
28
- readonly invalidation?: boolean;
29
- /**
30
- * When {@link invalidation} is set, the `actionId` of the original committed action that was
31
- * reversed (the `InvalidationEntry.invalidatedActionId`). Lets an invalidation-aware client
32
- * dedup/coalesce multiple cascade notifications by the action they reverse, and resubmit exactly
33
- * the affected work. Absent on ordinary commits.
34
- */
35
- readonly invalidatedActionId?: ActionId;
36
- };
37
-
38
- export type CollectionChangeListener = (event: CollectionChangeEvent) => void;
39
-
40
- /**
41
- * The cluster-consensus commit certificate for a committed action — the **authoritative** proof
42
- * that the cohort agreed the commit. It is the cross-package currency the local change-notifier
43
- * bridge forwards into the cohort-topic substrate (`CohortTopicService.onLocalCommit`) so reactivity
44
- * can originate notifications **without re-signing**: a notification's signature is bit-for-bit this
45
- * `thresholdSig`. The bridge and every downstream consumer treat the bytes as opaque and pass them
46
- * through unchanged — only the cluster layer that produced consensus ever mints one.
47
- *
48
- * Peer ids are carried as their string form (the cluster keys signatures by peer-id string); db-core
49
- * stays cross-platform and never references a libp2p `PeerId` type here.
50
- */
51
- export type CommitCert = {
52
- /** Threshold signature bytes proving `signers` agreed the commit. Forwarded UNCHANGED; never re-signed. */
53
- readonly thresholdSig: Uint8Array;
54
- /** Peer-id strings whose signatures compose {@link thresholdSig} (a distinct set of size ≥ {@link minSigs}). */
55
- readonly signers: readonly string[];
56
- /** Threshold the signer set satisfies (the cluster super-majority / cohort `k − x`). */
57
- readonly minSigs: number;
58
- /**
59
- * The exact byte preimage each `signers[i]` signed to produce its 64-byte chunk of
60
- * {@link thresholdSig} — the cluster's per-member commit-vote payload `utf8(commitHash + ":approve")`,
61
- * identical across all approving signers. Reactivity sets a notification's `digest` to
62
- * base64url(signedPayload) so a subscriber's threshold-verify over `digest` reproduces the exact
63
- * signed image. Opaque to db-core; minted only by the cluster layer.
64
- */
65
- readonly signedPayload: Uint8Array;
66
- };
67
-
68
- export interface IBlockChangeNotifier {
69
- /**
70
- * Subscribe to commits that mutate the given collection. Returns an
71
- * idempotent unsubscribe. Listeners are invoked AFTER the commit's critical
72
- * section (locks released), synchronously in commit order; a throwing
73
- * listener must not break the commit or other listeners (log + continue).
74
- */
75
- onCollectionChange(collectionId: CollectionId, listener: CollectionChangeListener): () => void;
76
- }
77
-
78
- export function isBlockChangeNotifier(x: unknown): x is IBlockChangeNotifier {
79
- return !!x && typeof (x as IBlockChangeNotifier).onCollectionChange === 'function';
80
- }
1
+ import type { BlockId, CollectionId } from '../index.js';
2
+ import type { ActionId } from '../collection/action.js';
3
+
4
+ /** A commit landed on this node mutating one collection's blocks. */
5
+ export type CollectionChangeEvent = {
6
+ /** Header/collection id of the affected collection (block.header.collectionId). */
7
+ readonly collectionId: CollectionId;
8
+ /** Blocks within that collection mutated by this commit. */
9
+ readonly blockIds: readonly BlockId[];
10
+ readonly actionId: ActionId;
11
+ readonly rev: number;
12
+ /**
13
+ * The collection's chain tail block id at the time of this commit (the `CommitRequest.tailId`).
14
+ * Anchors the rotating reactivity topic `H(tailId ‖ "reactivity")`. Present on commit-driven
15
+ * events; `undefined` on read-driven promotions (the `StorageRepo.get` path has no commit tail —
16
+ * those never originate anyway, and are cert-gated out downstream).
17
+ */
18
+ readonly tailId?: BlockId;
19
+ /**
20
+ * `true` iff this change event is a durable **invalidation** — a compensating revision + appended
21
+ * `InvalidationEntry` reversing a previously-committed action proven invalid by dispute
22
+ * (`docs/right-is-right.md` §Durable Invalidation), rather than an ordinary commit. An invalidation
23
+ * is a committed collection change like any other, so it flows through the same notification path;
24
+ * this flag lets a subscriber distinguish it (drop derived results + resubmit) from a plain commit
25
+ * (refresh). A hint only — correctness never depends on it: the subscriber always re-reads the
26
+ * authoritative reverted state. Absent (falsy) on ordinary commits.
27
+ */
28
+ readonly invalidation?: boolean;
29
+ /**
30
+ * When {@link invalidation} is set, the `actionId` of the original committed action that was
31
+ * reversed (the `InvalidationEntry.invalidatedActionId`). Lets an invalidation-aware client
32
+ * dedup/coalesce multiple cascade notifications by the action they reverse, and resubmit exactly
33
+ * the affected work. Absent on ordinary commits.
34
+ */
35
+ readonly invalidatedActionId?: ActionId;
36
+ };
37
+
38
+ export type CollectionChangeListener = (event: CollectionChangeEvent) => void;
39
+
40
+ /**
41
+ * The cluster-consensus commit certificate for a committed action — the **authoritative** proof
42
+ * that the cohort agreed the commit. It is the cross-package currency the local change-notifier
43
+ * bridge forwards into the cohort-topic substrate (`CohortTopicService.onLocalCommit`) so reactivity
44
+ * can originate notifications **without re-signing**: a notification's signature is bit-for-bit this
45
+ * `thresholdSig`. The bridge and every downstream consumer treat the bytes as opaque and pass them
46
+ * through unchanged — only the cluster layer that produced consensus ever mints one.
47
+ *
48
+ * Peer ids are carried as their string form (the cluster keys signatures by peer-id string); db-core
49
+ * stays cross-platform and never references a libp2p `PeerId` type here.
50
+ */
51
+ export type CommitCert = {
52
+ /** Threshold signature bytes proving `signers` agreed the commit. Forwarded UNCHANGED; never re-signed. */
53
+ readonly thresholdSig: Uint8Array;
54
+ /** Peer-id strings whose signatures compose {@link thresholdSig} (a distinct set of size ≥ {@link minSigs}). */
55
+ readonly signers: readonly string[];
56
+ /** Threshold the signer set satisfies (the cluster super-majority / cohort `k − x`). */
57
+ readonly minSigs: number;
58
+ /**
59
+ * The exact byte preimage each `signers[i]` signed to produce its 64-byte chunk of
60
+ * {@link thresholdSig} — the cluster's per-member commit-vote payload `utf8(commitHash + ":approve")`,
61
+ * identical across all approving signers. Reactivity sets a notification's `digest` to
62
+ * base64url(signedPayload) so a subscriber's threshold-verify over `digest` reproduces the exact
63
+ * signed image. Opaque to db-core; minted only by the cluster layer.
64
+ */
65
+ readonly signedPayload: Uint8Array;
66
+ };
67
+
68
+ export interface IBlockChangeNotifier {
69
+ /**
70
+ * Subscribe to commits that mutate the given collection. Returns an
71
+ * idempotent unsubscribe. Listeners are invoked AFTER the commit's critical
72
+ * section (locks released), synchronously in commit order; a throwing
73
+ * listener must not break the commit or other listeners (log + continue).
74
+ */
75
+ onCollectionChange(collectionId: CollectionId, listener: CollectionChangeListener): () => void;
76
+ }
77
+
78
+ export function isBlockChangeNotifier(x: unknown): x is IBlockChangeNotifier {
79
+ return !!x && typeof (x as IBlockChangeNotifier).onCollectionChange === 'function';
80
+ }
@@ -1,5 +1,5 @@
1
- export * from "./change-notifier.js";
2
- export * from "./network-transactor.js";
3
- export * from "./transactor-source.js";
4
- export * from "./transactor.js";
5
-
1
+ export * from "./change-notifier.js";
2
+ export * from "./network-transactor.js";
3
+ export * from "./transactor-source.js";
4
+ export * from "./transactor.js";
5
+
@@ -1,10 +1,15 @@
1
1
  import { peerIdFromString } from "../network/types.js";
2
2
  import type { PeerId } from "../network/types.js";
3
3
  import { highestStaleAt, isConflictFailure } from "../network/stale-failure.js";
4
- import { BlockUnavailableError } from "../network/struct.js";
5
- import type { ActionTransforms, ActionBlocks, BlockActionStatus, ITransactor, PendSuccess, StaleFailure, IKeyNetwork, BlockId, GetBlockResults, PendResult, CommitResult, PendRequest, IRepo, BlockGets, Transforms, CommitRequest, ActionId, RepoCommitRequest, ClusterNomineesResult, CollectionId, IBlock, CoordinatorIntent } from "../index.js";
4
+ import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
5
+ import type { ActionTransforms, ActionBlocks, BlockActionStatus, ITransactor, PendSuccess, StaleFailure, IKeyNetwork, BlockId, GetBlockResults, PendResult, CommitResult, PendRequest, IRepo, BlockGets, Transforms, CommitRequest, ActionId, RepoCommitRequest, ClusterNomineesResult, CollectionId, IBlock, CoordinatorIntent, BlockUnavailableReason } from "../index.js";
6
6
  import type { IBlockChangeNotifier, CollectionChangeListener } from "./change-notifier.js";
7
- import { transformForBlockId, groupBy, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms, Log, Tracker, CacheSource, TransactorSource } from "../index.js";
7
+ import { transformForBlockId, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms } from "../transform/helpers.js";
8
+ import { Tracker } from "../transform/tracker.js";
9
+ import { CacheSource } from "../transform/cache-source.js";
10
+ import { TransactorSource } from "./transactor-source.js";
11
+ import { Log } from "../log/log.js";
12
+ import { groupBy } from "../utility/groupby.js";
8
13
  import { blockIdToBytes } from "../utility/block-id-to-bytes.js";
9
14
  import { isRecordEmpty } from "../utility/is-record-empty.js";
10
15
  import { type CoordinatorBatch, makeBatchesByPeer, incompleteBatches, everyBatch, allBatches, mergeBlocks, processBatches, createBatchesForPayload } from "../utility/batch-coordinator.js";
@@ -156,18 +161,29 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
156
161
  };
157
162
 
158
163
  // A batch is answered when its response carries an entry for EVERY requested
159
- // block id and none of those entries is flagged `unavailable`. An entry present
164
+ // block id and none of those entries carries a doubt marker. An entry present
160
165
  // with only `state` (no `block`) is an authoritative "absent", which counts as
161
- // answered — not a gap. An `unavailable` entry is the peer saying its own answer
162
- // is a guess, so it does NOT count as answered.
166
+ // answered — not a gap. An `unavailable` entry is the peer saying it could not
167
+ // find out whether the block EXISTS; an `unconfirmedAheadRev` entry is the peer
168
+ // saying it could not confirm the content it served is CURRENT (a cohort claim
169
+ // sits ahead of it, unsettled). Neither counts as answered, so both earn the
170
+ // second-chance retry against a different coordinator.
163
171
  const isAuthoritative = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
164
172
  if (!hasValidResponse(b)) return false;
165
173
  const resp = b.request!.response! as GetBlockResults;
166
- return b.payload.every(bid => resp[bid] !== undefined && resp[bid]!.unavailable === undefined);
174
+ return b.payload.every(bid => resp[bid] !== undefined
175
+ && resp[bid]!.unavailable === undefined
176
+ && resp[bid]!.unconfirmedAheadRev === undefined);
167
177
  };
168
178
 
169
179
  // Retry only genuine no-response / partial-response batches. An authoritative
170
180
  // absent answer is not retried.
181
+ // NOTE: a 'cohort-unreachable' entry earns this retry like any other flagged entry,
182
+ // and on a genuinely isolated node the retry re-picks the same node (the
183
+ // findCoordinator:all-excluded path) and repeats the same futile consult. Fine
184
+ // today — one extra bounded consult on an already failing read. If isolated-node
185
+ // read latency ever matters, skip the retry for that reason rather than widening
186
+ // isAuthoritative.
171
187
  const retryable = Array.from(allBatches(batches)).filter(b =>
172
188
  !isAuthoritative(b as any)
173
189
  ) as CoordinatorBatch<BlockId[], GetBlockResults>[];
@@ -211,22 +227,40 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
211
227
  // Cache the completed batches that had actual responses (not just coordinator not found)
212
228
  const completedBatches = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !isRecordEmpty(b.request!.response!)));
213
229
 
214
- // Three-way ranking per block id: a materialized block beats an authoritative
215
- // absent, which beats an `unavailable` guess one peer that positively knows the
216
- // block is absent outranks another that could not find out. Non-object junk ranks
217
- // below everything so any real entry replaces it.
230
+ // Ranking per block id: a block the answering repo could confirm is current beats
231
+ // one it marked possibly-behind (`unconfirmedAheadRev`), which beats an
232
+ // authoritative absent, which beats an absent marked possibly-behind (a doubted
233
+ // tombstone), which beats an `unavailable` guess. Content still beats absence at
234
+ // every confidence level, and one peer that positively knows outranks another
235
+ // that could not find out. Without the confirmed-over-unconfirmed split, a stale
236
+ // flagged block and the fresh confirmed block fetched by its own retry round tie
237
+ // — and only strictly-greater rank replaces, so first-arrival (the stale one)
238
+ // would win the very merge the retry exists to fix. Non-object junk ranks below
239
+ // everything so any real entry replaces it.
218
240
  // NOTE: `materializedRev` is not part of the ranking, so two peers answering the same
219
241
  // pinned get with block-carrying entries at DIFFERENT materialized revisions resolve
220
242
  // to whichever arrived first. Not a concern today — cohort peers share the block's
221
243
  // revision log, so they agree on the highest committed rev at or below a pin — and the
222
244
  // failure direction is safe (a lower recorded revision spuriously stale-rejects rather
223
245
  // than wrongly accepting). If peers are ever seen to disagree here, break the tie on
224
- // the HIGHEST materializedRev among rank-2 entries.
246
+ // the HIGHEST materializedRev among top-rank entries.
247
+ // `unavailable` answers rank among THEMSELVES by how much they establish, so the merged
248
+ // entry never presents a weaker doubt than some peer actually returned. This matters
249
+ // because the reason travels out verbatim on `BlockUnavailableError` and callers act on
250
+ // it: 'cohort-unreachable' is the one reason a caller may treat permissively (the
251
+ // answering node reached nobody, so its own view is all it has), and a partitioned
252
+ // coordinator answering first must not mask a well-connected one that positively
253
+ // established the block EXISTS ('claimed-elsewhere', or 'unmaterializable' — records
254
+ // held here). 'peers-unreachable' sits between: it establishes that some of the cohort
255
+ // was reachable, without settling existence.
256
+ const unavailableRank = (reason: BlockUnavailableReason): number =>
257
+ reason === 'cohort-unreachable' ? 0 : reason === 'peers-unreachable' ? 1 : 2;
225
258
  const rankOf = (r: unknown): number => {
226
259
  if (!r || typeof r !== 'object') return -1;
227
260
  const entry = r as GetBlockResults[BlockId];
228
- if (entry.block != null) return 2;
229
- return entry.unavailable === undefined ? 1 : 0;
261
+ if (entry.block != null) return entry.unconfirmedAheadRev === undefined ? 6 : 5;
262
+ if (entry.unavailable !== undefined) return unavailableRank(entry.unavailable);
263
+ return entry.unconfirmedAheadRev === undefined ? 4 : 3;
230
264
  };
231
265
 
232
266
  // Create a lookup map from successful responses only
@@ -275,12 +309,18 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
275
309
  // A block whose repo could not determine whether it exists carries no status either:
276
310
  // its empty `state` would read below as `aborted`, turning "I could not find out" into
277
311
  // a definite verdict on someone's action. Fail loudly instead, like every other read of
278
- // an unavailable block (see BlockUnavailableError).
312
+ // an unavailable block (see BlockUnavailableError). The same goes for a state the repo
313
+ // could not confirm is CURRENT (`unconfirmedAheadRev`, surviving the retry round above):
314
+ // an action committed at the claimed-ahead revision would read out of the stale state as
315
+ // a definite `aborted` — this read is unpinned, so the doubt always applies.
279
316
  for (const blockId of allBlockIds) {
280
317
  const entry = blockStates[blockId];
281
318
  if (entry?.unavailable !== undefined && entry.block == null) {
282
319
  throw new BlockUnavailableError(blockId, entry.unavailable);
283
320
  }
321
+ if (entry?.unconfirmedAheadRev !== undefined) {
322
+ throw new BlockPossiblyStaleError(blockId, entry.unconfirmedAheadRev);
323
+ }
284
324
  }
285
325
 
286
326
  // Determine status for each action ref
@@ -641,7 +681,6 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
641
681
  async commit(request: CommitRequest): Promise<CommitResult> {
642
682
  const t0 = Date.now();
643
683
  log('commit actionId=%s rev=%d blockIds=%d', request.actionId, request.rev, request.blockIds.length);
644
- const allBlockIds = [...new Set([...request.blockIds, request.tailId])];
645
684
 
646
685
  // Commit the header block if provided and not already in blockIds.
647
686
  // `request.tailId` is threaded into every per-block commit so the coordinator carries it into the
@@ -649,14 +688,14 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
649
688
  // CollectionChangeEvent (the reactivity topic anchor). Without this the per-block RepoCommitRequest
650
689
  // drops the collection tail and reactivity origination is gated off (undefined tail → non-member).
651
690
  if (request.headerId && !request.blockIds.includes(request.headerId)) {
652
- const headerResult = await this.commitBlock(request.headerId, allBlockIds, request.actionId, request.rev, request.tailId);
691
+ const headerResult = await this.commitBlock(request.headerId, request.actionId, request.rev, request.tailId);
653
692
  if (!headerResult.success) {
654
693
  return headerResult;
655
694
  }
656
695
  }
657
696
 
658
697
  // Commit the tail block
659
- const tailResult = await this.commitBlock(request.tailId, allBlockIds, request.actionId, request.rev, request.tailId);
698
+ const tailResult = await this.commitBlock(request.tailId, request.actionId, request.rev, request.tailId);
660
699
  if (!tailResult.success) {
661
700
  return tailResult;
662
701
  }
@@ -679,7 +718,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
679
718
  return { success: true };
680
719
  }
681
720
 
682
- private async commitBlock(blockId: BlockId, blockIds: BlockId[], actionId: ActionId, rev: number, tailId?: BlockId): Promise<CommitResult> {
721
+ private async commitBlock(blockId: BlockId, actionId: ActionId, rev: number, tailId?: BlockId): Promise<CommitResult> {
683
722
  const { batches: tailBatches, error: tailError } = await this.commitBlocks({ blockIds: [blockId], actionId, rev, tailId });
684
723
  if (tailError) {
685
724
  // commit is a pure attempt: stale → { success:false }, transient → throw. Cancellation
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from '@noble/hashes/utils.js'
2
2
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
3
  import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms } from "../index.js";
4
- import { BlockUnavailableError } from "../network/struct.js";
4
+ import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
5
5
  import type { ReadDependency } from "../transaction/transaction.js";
6
6
  import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
7
7
 
@@ -43,7 +43,7 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
43
43
  // `result[id]` is undefined. Destructuring that would throw a TypeError.
44
44
  const entry = result?.[id];
45
45
  if (entry) {
46
- const { block, state, materializedRev, unavailable } = entry;
46
+ const { block, state, materializedRev, unavailable, unconfirmedAheadRev } = entry;
47
47
  // An entry flagged `unavailable` with no block is the repo saying "I could not find
48
48
  // out whether this exists" — an answer that must not be read as absent. Throw rather
49
49
  // than return undefined, and record no read dependency (dependencies are recorded
@@ -51,6 +51,29 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
51
51
  if (!block && unavailable) {
52
52
  throw new BlockUnavailableError(id, unavailable);
53
53
  }
54
+ // A read whose surviving answer is marked possibly-behind (`unconfirmedAheadRev`
55
+ // outlived the transactor's retry round: every reachable coordinator served content it
56
+ // could not confirm current) must not pose as an answer for a view that should CONTAIN
57
+ // the claimed revision. Two such views, the same test the coordinator applies when it
58
+ // stamps: an UNPINNED "give me latest" read — the tail read is the one seam where a
59
+ // lagging collection can learn the truth (Collection.bootstrapContext), and silently
60
+ // serving doubted content there is exactly how a collection view freezes forever — and
61
+ // a read PINNED AT OR ABOVE the claim, whose snapshot is missing a revision the cohort
62
+ // says exists inside it. A read pinned strictly BELOW the claim keeps working: it
63
+ // legitimately asks for an older view, which is being served correctly.
64
+ // No read dependency is recorded: the throw means nothing was read.
65
+ // NOTE: accepted tradeoff — this converts a silent wrong answer into a loud failure. A
66
+ // node partitioned from every coordinator able to confirm currency used to read (stale)
67
+ // data indefinitely without any signal; it now raises BlockPossiblyStaleError on the
68
+ // reads that should contain the claim, until the partition heals or the claim is
69
+ // settled. Deliberate: the
70
+ // silent alternative is a collection view that forks and freezes with no report
71
+ // (ticket coordinator-serves-stale-data-as-if-confirmed). Revisit only if a
72
+ // degraded-read mode (serve-with-warning) becomes a product requirement.
73
+ if (unconfirmedAheadRev !== undefined
74
+ && (this.actionContext === undefined || this.actionContext.rev >= unconfirmedAheadRev)) {
75
+ throw new BlockPossiblyStaleError(id, unconfirmedAheadRev);
76
+ }
54
77
  // Record a read dependency only for a block that actually exists. A transactor may return a
55
78
  // populated entry with `block: undefined` for a genuinely-missing block (TestTransactor does;
56
79
  // the Network transactor always populates the key); recording there would add a phantom