@optimystic/db-core 0.22.0 → 0.24.1

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 (141) hide show
  1. package/README.md +336 -336
  2. package/dist/src/cluster/structs.d.ts +39 -1
  3. package/dist/src/cluster/structs.d.ts.map +1 -1
  4. package/dist/src/cluster/structs.js +24 -0
  5. package/dist/src/cluster/structs.js.map +1 -1
  6. package/dist/src/collection/collection.d.ts +17 -0
  7. package/dist/src/collection/collection.d.ts.map +1 -1
  8. package/dist/src/collection/collection.js +24 -2
  9. package/dist/src/collection/collection.js.map +1 -1
  10. package/dist/src/collections/tree/tree.d.ts +5 -0
  11. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  12. package/dist/src/collections/tree/tree.js +7 -0
  13. package/dist/src/collections/tree/tree.js.map +1 -1
  14. package/dist/src/network/i-peer-network.d.ts +16 -0
  15. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  16. package/dist/src/network/struct.d.ts +39 -2
  17. package/dist/src/network/struct.d.ts.map +1 -1
  18. package/dist/src/network/struct.js +18 -0
  19. package/dist/src/network/struct.js.map +1 -1
  20. package/dist/src/testing/test-transactor.d.ts +95 -8
  21. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  22. package/dist/src/testing/test-transactor.js +121 -8
  23. package/dist/src/testing/test-transactor.js.map +1 -1
  24. package/dist/src/transaction/transaction.d.ts +1 -1
  25. package/dist/src/transaction/transaction.js +1 -1
  26. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  27. package/dist/src/transactor/network-transactor.js +48 -13
  28. package/dist/src/transactor/network-transactor.js.map +1 -1
  29. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  30. package/dist/src/transactor/transactor-source.js +25 -2
  31. package/dist/src/transactor/transactor-source.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cluster/membership.ts +85 -85
  34. package/src/cluster/structs.ts +43 -4
  35. package/src/cohort-topic/addressing.ts +120 -120
  36. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  37. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  38. package/src/cohort-topic/antidos/index.ts +5 -5
  39. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  40. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  41. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  42. package/src/cohort-topic/antiflood/index.ts +2 -2
  43. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  44. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  45. package/src/cohort-topic/coldstart.ts +237 -237
  46. package/src/cohort-topic/dmax.ts +88 -88
  47. package/src/cohort-topic/gossip/bus.ts +254 -254
  48. package/src/cohort-topic/gossip/index.ts +3 -3
  49. package/src/cohort-topic/gossip/records.ts +45 -45
  50. package/src/cohort-topic/gossip/view.ts +91 -91
  51. package/src/cohort-topic/index.ts +20 -20
  52. package/src/cohort-topic/load/barometer.ts +134 -134
  53. package/src/cohort-topic/load/index.ts +1 -1
  54. package/src/cohort-topic/member-engine.ts +430 -430
  55. package/src/cohort-topic/membership/index.ts +3 -3
  56. package/src/cohort-topic/membership/publisher.ts +163 -163
  57. package/src/cohort-topic/membership/source.ts +41 -41
  58. package/src/cohort-topic/membership/verifier.ts +461 -461
  59. package/src/cohort-topic/ports.ts +157 -157
  60. package/src/cohort-topic/promotion.ts +405 -405
  61. package/src/cohort-topic/registration/bytes.ts +37 -37
  62. package/src/cohort-topic/registration/handoff.ts +154 -154
  63. package/src/cohort-topic/registration/index.ts +6 -6
  64. package/src/cohort-topic/registration/renewal.ts +495 -495
  65. package/src/cohort-topic/registration/sharding.ts +61 -61
  66. package/src/cohort-topic/registration/store.ts +81 -81
  67. package/src/cohort-topic/registration/types.ts +91 -91
  68. package/src/cohort-topic/ring-hash.ts +50 -50
  69. package/src/cohort-topic/service.ts +416 -416
  70. package/src/cohort-topic/sig/index.ts +2 -2
  71. package/src/cohort-topic/sig/payloads.ts +59 -59
  72. package/src/cohort-topic/sig/threshold.ts +64 -64
  73. package/src/cohort-topic/tiers.ts +74 -74
  74. package/src/cohort-topic/traffic.ts +233 -233
  75. package/src/cohort-topic/walk.ts +326 -326
  76. package/src/cohort-topic/willingness.ts +237 -237
  77. package/src/cohort-topic/wire/codec.ts +216 -216
  78. package/src/cohort-topic/wire/index.ts +18 -18
  79. package/src/cohort-topic/wire/payloads.ts +126 -126
  80. package/src/cohort-topic/wire/primitives.ts +188 -188
  81. package/src/cohort-topic/wire/types.ts +475 -475
  82. package/src/cohort-topic/wire/validate.ts +512 -512
  83. package/src/collection/collection-type-registry.ts +37 -37
  84. package/src/collection/collection.ts +25 -2
  85. package/src/collections/diary/diary.ts +68 -68
  86. package/src/collections/tree/readme.md +4 -0
  87. package/src/collections/tree/tree.ts +320 -312
  88. package/src/matchmaking/capability-filter.ts +45 -45
  89. package/src/matchmaking/config.ts +98 -98
  90. package/src/matchmaking/index.ts +21 -21
  91. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  92. package/src/matchmaking/provider.ts +123 -123
  93. package/src/matchmaking/query-eval.ts +105 -105
  94. package/src/matchmaking/seeker-walk.ts +127 -127
  95. package/src/matchmaking/seeker.ts +86 -86
  96. package/src/matchmaking/topic-anchor.ts +90 -90
  97. package/src/matchmaking/voting-quorum.ts +394 -394
  98. package/src/matchmaking/wire.ts +603 -603
  99. package/src/network/i-peer-network.ts +17 -0
  100. package/src/network/stale-failure.ts +43 -43
  101. package/src/network/struct.ts +41 -2
  102. package/src/network/types.ts +37 -37
  103. package/src/reactivity/backfill.ts +220 -220
  104. package/src/reactivity/backpressure.ts +191 -191
  105. package/src/reactivity/checkpoint.ts +308 -308
  106. package/src/reactivity/config.ts +172 -172
  107. package/src/reactivity/dedupe.ts +132 -132
  108. package/src/reactivity/forwarder.ts +87 -87
  109. package/src/reactivity/index.ts +34 -34
  110. package/src/reactivity/notification.ts +123 -123
  111. package/src/reactivity/policy.ts +79 -79
  112. package/src/reactivity/push-state.ts +310 -310
  113. package/src/reactivity/recover.ts +153 -153
  114. package/src/reactivity/replay-buffer.ts +141 -141
  115. package/src/reactivity/resume.ts +549 -549
  116. package/src/reactivity/rotation.ts +415 -415
  117. package/src/reactivity/subscriber.ts +132 -132
  118. package/src/reactivity/subscription.ts +66 -66
  119. package/src/reactivity/topic-anchor.ts +71 -71
  120. package/src/reactivity/verify.ts +73 -73
  121. package/src/reactivity/wire-validate.ts +13 -13
  122. package/src/reactivity/wire.ts +224 -224
  123. package/src/testing/async-wait.ts +65 -65
  124. package/src/testing/index.ts +2 -2
  125. package/src/testing/test-transactor.ts +638 -502
  126. package/src/transaction/errors.ts +91 -91
  127. package/src/transaction/operations-hash.ts +196 -196
  128. package/src/transaction/read-dependency-collector.ts +78 -78
  129. package/src/transaction/transaction.ts +1 -1
  130. package/src/transactor/change-notifier.ts +80 -80
  131. package/src/transactor/index.ts +5 -5
  132. package/src/transactor/network-transactor.ts +49 -14
  133. package/src/transactor/transactor-source.ts +25 -2
  134. package/src/transform/atomic-proxy.ts +92 -92
  135. package/src/transform/helpers.ts +159 -159
  136. package/src/utility/backoff.ts +95 -95
  137. package/src/utility/batch-coordinator.ts +191 -191
  138. package/dist/src/transaction/context.d.ts +0 -60
  139. package/dist/src/transaction/context.d.ts.map +0 -1
  140. package/dist/src/transaction/context.js +0 -91
  141. 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,8 +1,8 @@
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
7
  import { transformForBlockId, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms } from "../transform/helpers.js";
8
8
  import { Tracker } from "../transform/tracker.js";
@@ -161,18 +161,29 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
161
161
  };
162
162
 
163
163
  // A batch is answered when its response carries an entry for EVERY requested
164
- // 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
165
165
  // with only `state` (no `block`) is an authoritative "absent", which counts as
166
- // answered — not a gap. An `unavailable` entry is the peer saying its own answer
167
- // 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.
168
171
  const isAuthoritative = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
169
172
  if (!hasValidResponse(b)) return false;
170
173
  const resp = b.request!.response! as GetBlockResults;
171
- 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);
172
177
  };
173
178
 
174
179
  // Retry only genuine no-response / partial-response batches. An authoritative
175
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.
176
187
  const retryable = Array.from(allBatches(batches)).filter(b =>
177
188
  !isAuthoritative(b as any)
178
189
  ) as CoordinatorBatch<BlockId[], GetBlockResults>[];
@@ -216,22 +227,40 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
216
227
  // Cache the completed batches that had actual responses (not just coordinator not found)
217
228
  const completedBatches = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !isRecordEmpty(b.request!.response!)));
218
229
 
219
- // Three-way ranking per block id: a materialized block beats an authoritative
220
- // absent, which beats an `unavailable` guess one peer that positively knows the
221
- // block is absent outranks another that could not find out. Non-object junk ranks
222
- // 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.
223
240
  // NOTE: `materializedRev` is not part of the ranking, so two peers answering the same
224
241
  // pinned get with block-carrying entries at DIFFERENT materialized revisions resolve
225
242
  // to whichever arrived first. Not a concern today — cohort peers share the block's
226
243
  // revision log, so they agree on the highest committed rev at or below a pin — and the
227
244
  // failure direction is safe (a lower recorded revision spuriously stale-rejects rather
228
245
  // than wrongly accepting). If peers are ever seen to disagree here, break the tie on
229
- // 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;
230
258
  const rankOf = (r: unknown): number => {
231
259
  if (!r || typeof r !== 'object') return -1;
232
260
  const entry = r as GetBlockResults[BlockId];
233
- if (entry.block != null) return 2;
234
- 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;
235
264
  };
236
265
 
237
266
  // Create a lookup map from successful responses only
@@ -280,12 +309,18 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
280
309
  // A block whose repo could not determine whether it exists carries no status either:
281
310
  // its empty `state` would read below as `aborted`, turning "I could not find out" into
282
311
  // a definite verdict on someone's action. Fail loudly instead, like every other read of
283
- // 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.
284
316
  for (const blockId of allBlockIds) {
285
317
  const entry = blockStates[blockId];
286
318
  if (entry?.unavailable !== undefined && entry.block == null) {
287
319
  throw new BlockUnavailableError(blockId, entry.unavailable);
288
320
  }
321
+ if (entry?.unconfirmedAheadRev !== undefined) {
322
+ throw new BlockPossiblyStaleError(blockId, entry.unconfirmedAheadRev);
323
+ }
289
324
  }
290
325
 
291
326
  // Determine status for each action ref
@@ -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