@optimystic/db-core 0.25.0 → 0.26.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 (104) hide show
  1. package/dist/src/cohort-topic/membership/verifier.d.ts +8 -0
  2. package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
  3. package/dist/src/cohort-topic/membership/verifier.js +17 -0
  4. package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
  5. package/dist/src/cohort-topic/promotion.d.ts +25 -0
  6. package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
  7. package/dist/src/cohort-topic/promotion.js +58 -11
  8. package/dist/src/cohort-topic/promotion.js.map +1 -1
  9. package/dist/src/cohort-topic/wire/primitives.d.ts +6 -0
  10. package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -1
  11. package/dist/src/cohort-topic/wire/primitives.js +6 -0
  12. package/dist/src/cohort-topic/wire/primitives.js.map +1 -1
  13. package/dist/src/cohort-topic/wire/types.d.ts +5 -0
  14. package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
  16. package/dist/src/cohort-topic/wire/validate.js +10 -19
  17. package/dist/src/cohort-topic/wire/validate.js.map +1 -1
  18. package/dist/src/collection/collection.d.ts +8 -1
  19. package/dist/src/collection/collection.d.ts.map +1 -1
  20. package/dist/src/collection/collection.js +19 -3
  21. package/dist/src/collection/collection.js.map +1 -1
  22. package/dist/src/matchmaking/wire.d.ts +2 -2
  23. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  24. package/dist/src/matchmaking/wire.js +3 -3
  25. package/dist/src/matchmaking/wire.js.map +1 -1
  26. package/dist/src/network/struct.d.ts +5 -5
  27. package/dist/src/network/struct.d.ts.map +1 -1
  28. package/dist/src/testing/index.d.ts +1 -0
  29. package/dist/src/testing/index.d.ts.map +1 -1
  30. package/dist/src/testing/index.js +1 -0
  31. package/dist/src/testing/index.js.map +1 -1
  32. package/dist/src/testing/refresh-probe.d.ts +31 -0
  33. package/dist/src/testing/refresh-probe.d.ts.map +1 -0
  34. package/dist/src/testing/refresh-probe.js +39 -0
  35. package/dist/src/testing/refresh-probe.js.map +1 -0
  36. package/dist/src/testing/test-transactor.d.ts +58 -5
  37. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  38. package/dist/src/testing/test-transactor.js +87 -4
  39. package/dist/src/testing/test-transactor.js.map +1 -1
  40. package/dist/src/transaction/coordinator.d.ts +90 -6
  41. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  42. package/dist/src/transaction/coordinator.js +311 -90
  43. package/dist/src/transaction/coordinator.js.map +1 -1
  44. package/dist/src/transaction/errors.d.ts +42 -0
  45. package/dist/src/transaction/errors.d.ts.map +1 -1
  46. package/dist/src/transaction/errors.js +50 -0
  47. package/dist/src/transaction/errors.js.map +1 -1
  48. package/dist/src/transaction/index.d.ts +1 -1
  49. package/dist/src/transaction/index.d.ts.map +1 -1
  50. package/dist/src/transaction/index.js +1 -1
  51. package/dist/src/transaction/index.js.map +1 -1
  52. package/dist/src/transaction/session.d.ts +7 -3
  53. package/dist/src/transaction/session.d.ts.map +1 -1
  54. package/dist/src/transaction/session.js +7 -3
  55. package/dist/src/transaction/session.js.map +1 -1
  56. package/dist/src/transaction/transaction.d.ts +12 -2
  57. package/dist/src/transaction/transaction.d.ts.map +1 -1
  58. package/dist/src/transaction/transaction.js.map +1 -1
  59. package/dist/src/transactor/network-transactor.d.ts +1 -1
  60. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  61. package/dist/src/transactor/network-transactor.js +6 -26
  62. package/dist/src/transactor/network-transactor.js.map +1 -1
  63. package/dist/src/transactor/transactor-source.d.ts +4 -6
  64. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  65. package/dist/src/transactor/transactor-source.js +4 -6
  66. package/dist/src/transactor/transactor-source.js.map +1 -1
  67. package/dist/src/transform/atomic.d.ts.map +1 -1
  68. package/dist/src/transform/atomic.js +7 -0
  69. package/dist/src/transform/atomic.js.map +1 -1
  70. package/dist/src/transform/base-pins.d.ts +48 -0
  71. package/dist/src/transform/base-pins.d.ts.map +1 -0
  72. package/dist/src/transform/base-pins.js +65 -0
  73. package/dist/src/transform/base-pins.js.map +1 -0
  74. package/dist/src/transform/digest.d.ts +4 -2
  75. package/dist/src/transform/digest.d.ts.map +1 -1
  76. package/dist/src/transform/digest.js +14 -19
  77. package/dist/src/transform/digest.js.map +1 -1
  78. package/dist/src/transform/tracker.d.ts +31 -3
  79. package/dist/src/transform/tracker.d.ts.map +1 -1
  80. package/dist/src/transform/tracker.js +105 -13
  81. package/dist/src/transform/tracker.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/cohort-topic/membership/verifier.ts +487 -461
  84. package/src/cohort-topic/promotion.ts +86 -14
  85. package/src/cohort-topic/wire/primitives.ts +195 -188
  86. package/src/cohort-topic/wire/types.ts +480 -475
  87. package/src/cohort-topic/wire/validate.ts +504 -512
  88. package/src/collection/collection.ts +19 -3
  89. package/src/matchmaking/wire.ts +605 -603
  90. package/src/network/struct.ts +5 -5
  91. package/src/testing/index.ts +1 -0
  92. package/src/testing/refresh-probe.ts +52 -0
  93. package/src/testing/test-transactor.ts +100 -4
  94. package/src/transaction/coordinator.ts +312 -99
  95. package/src/transaction/errors.ts +141 -91
  96. package/src/transaction/index.ts +1 -1
  97. package/src/transaction/session.ts +7 -3
  98. package/src/transaction/transaction.ts +12 -2
  99. package/src/transactor/network-transactor.ts +1025 -1045
  100. package/src/transactor/transactor-source.ts +170 -172
  101. package/src/transform/atomic.ts +6 -0
  102. package/src/transform/base-pins.ts +83 -0
  103. package/src/transform/digest.ts +14 -19
  104. package/src/transform/tracker.ts +108 -15
@@ -1,172 +1,170 @@
1
- import { randomBytes } from '@noble/hashes/utils.js'
2
- import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
- import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms, BlockContentDigests } from "../index.js";
4
- import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
5
- import type { ReadDependency } from "../transaction/transaction.js";
6
- import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
7
- import { blockDigestsField } from "../transform/digest.js";
8
-
9
- export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlock> {
10
- /** Shared with this collection's CacheSource so cache hits also record dependencies.
11
- * Defaults to a private instance so internal log-walk sources (which never need a
12
- * transaction read set) work standalone. */
13
- private readonly collector: ReadDependencyCollector;
14
- /** Last revision observed per id, so CacheSource can learn the revision on a miss-load
15
- * (it calls {@link getReadRevision} right after this source serves the block). */
16
- private readRevisions = new Map<BlockId, number>();
17
-
18
- constructor(
19
- private readonly collectionId: BlockId,
20
- private readonly transactor: ITransactor,
21
- public actionContext: ActionContext | undefined,
22
- collector?: ReadDependencyCollector,
23
- ) {
24
- this.collector = collector ?? new ReadDependencyCollector();
25
- }
26
-
27
- createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader {
28
- return {
29
- type,
30
- id: newId ?? this.generateId(),
31
- collectionId: this.collectionId,
32
- };
33
- }
34
-
35
- generateId(): BlockId {
36
- // 256-bits to fully utilize DHT address space
37
- return uint8ArrayToString(randomBytes(32), 'base64url')
38
- }
39
-
40
- async tryGet(id: BlockId, purpose: ReadPurpose = 'value'): Promise<TBlock | undefined> {
41
- const result = await this.transactor.get({ blockIds: [id], context: this.actionContext });
42
- // Guard the per-key entry: some transactors return a sparse result that omits `id`
43
- // entirely (e.g. block genuinely not found), so `result` is a truthy object but
44
- // `result[id]` is undefined. Destructuring that would throw a TypeError.
45
- const entry = result?.[id];
46
- if (entry) {
47
- const { block, state, materialized, unavailable, unconfirmedAheadRev } = entry;
48
- // An entry flagged `unavailable` with no block is the repo saying "I could not find
49
- // out whether this exists" — an answer that must not be read as absent. Throw rather
50
- // than return undefined, and record no read dependency (dependencies are recorded
51
- // only for blocks that actually exist). A repo that omits the flag stays authoritative.
52
- if (!block && unavailable) {
53
- throw new BlockUnavailableError(id, unavailable);
54
- }
55
- // A read whose surviving answer is marked possibly-behind (`unconfirmedAheadRev`
56
- // outlived the transactor's retry round: every reachable coordinator served content it
57
- // could not confirm current) must not pose as an answer for a view that should CONTAIN
58
- // the claimed revision. Two such views, the same test the coordinator applies when it
59
- // stamps: an UNPINNED "give me latest" read — the tail read is the one seam where a
60
- // lagging collection can learn the truth (Collection.bootstrapContext), and silently
61
- // serving doubted content there is exactly how a collection view freezes forever — and
62
- // a read PINNED AT OR ABOVE the claim, whose snapshot is missing a revision the cohort
63
- // says exists inside it. A read pinned strictly BELOW the claim keeps working: it
64
- // legitimately asks for an older view, which is being served correctly.
65
- // No read dependency is recorded: the throw means nothing was read.
66
- // NOTE: accepted tradeoff — this converts a silent wrong answer into a loud failure. A
67
- // node partitioned from every coordinator able to confirm currency used to read (stale)
68
- // data indefinitely without any signal; it now raises BlockPossiblyStaleError on the
69
- // reads that should contain the claim, until the partition heals or the claim is
70
- // settled. Deliberate: the
71
- // silent alternative is a collection view that forks and freezes with no report
72
- // (ticket coordinator-serves-stale-data-as-if-confirmed). Revisit only if a
73
- // degraded-read mode (serve-with-warning) becomes a product requirement.
74
- if (unconfirmedAheadRev !== undefined
75
- && (this.actionContext === undefined || this.actionContext.rev >= unconfirmedAheadRev)) {
76
- throw new BlockPossiblyStaleError(id, unconfirmedAheadRev);
77
- }
78
- // Record a read dependency only for a block that actually exists. A transactor may return a
79
- // populated entry with `block: undefined` for a genuinely-missing block (TestTransactor does;
80
- // the Network transactor always populates the key); recording there would add a phantom
81
- // dependency for a nonexistent block. This makes the "absent reads nothing" contract uniform
82
- // with the sparse-result case (entry omitted) — see transactor-source.spec.ts sparse test.
83
- if (block) {
84
- // Record read dependency for optimistic concurrency control, carrying the caller's
85
- // read purpose (default `value`) so a purely-structural navigation read can later be
86
- // dropped from the conflict set (see ReadDependencyCollector / Theorem 5).
87
- // Record the revision the content was MATERIALIZED at, not the newest the repo holds —
88
- // see {@link GetBlockResult.materialized} for why `state.latest` is the wrong number
89
- // and why the fallback preserves today's behaviour for repos that omit the field.
90
- // Both sinks must take the SAME value: CacheSource learns it via getReadRevision on a
91
- // miss-load and re-emits it on every later hit, so a split would stamp the cache
92
- // differently from the collector.
93
- const rev = materialized?.rev ?? state.latest?.rev ?? 0;
94
- this.collector.record(id, rev, purpose);
95
- this.readRevisions.set(id, rev);
96
- }
97
- // TODO: if the state reports that there is a pending action, record this so that we are sure to update before syncing
98
- //state.pendings
99
- return block as TBlock;
100
- }
101
- }
102
-
103
- /** The revision observed the last time this source served {@link id} (from its committed
104
- * state), or undefined if this source has never served it. CacheSource reads this on a
105
- * miss-load to learn the revision to record and store. */
106
- getReadRevision(id: BlockId): number | undefined {
107
- return this.readRevisions.get(id);
108
- }
109
-
110
- getReadDependencies(): ReadDependency[] {
111
- return this.collector.getReadDependencies();
112
- }
113
-
114
- /** The collector this source records into — the one shared with the collection's
115
- * CacheSource. Exposed ONLY so a pinned read view built with `recordReads: true`
116
- * can feed the same per-transaction read set (see Collection.createReadTracker);
117
- * every other consumer should go through {@link getReadDependencies}. */
118
- getCollector(): ReadDependencyCollector {
119
- return this.collector;
120
- }
121
-
122
- clearReadDependencies(): void {
123
- this.collector.clear();
124
- }
125
-
126
- /**
127
- * Attempts to apply the given transforms in a transactional manner.
128
- * @param transform - The transforms to apply.
129
- * @param actionId - The action id.
130
- * @param rev - The revision number.
131
- * @param headerId - The Id of the collection's header block. Forwarded to the commit only when the header is a
132
- * fresh insert, which is the create-the-collection race this was meant to order. NOTE: that forwarding no longer
133
- * has any effect — `NetworkTransactor.commit` runs its header-first step only for a header held OUT of `blockIds`,
134
- * and an inserted id is always IN it, so the header commits inside the ordinary sweep (after the tail) like any
135
- * other touched block. Wire it up or drop it: `tickets/backlog/debt-commit-header-first-branch-is-unreachable`.
136
- * @param tailId - The Id of the collection's log tail block. If specified, this block's transform is performed next
137
- * (prior to the rest of the block operations), to resolve the "winner" of a race to commit to the collection.
138
- * @param priority - Aged, advisory retry priority (default 0). Rides on the pend so a repeatedly-losing
139
- * single-collection sync out-ranks fresh rivals in a concurrent race (`resolveRace`); fairness-only, never
140
- * affects validity. Omitted from the pend when 0 so the common first-attempt pend serializes exactly as before.
141
- * @param blockDigests - Optional per-block content declarations for this commit (see {@link BlockContentDigests}),
142
- * computed by the caller from the same tracker that produced `transform`. Omitted from the commit request when
143
- * undefined, so a caller that declares nothing produces exactly the request shape as before the field rides
144
- * inside every cohort signature's hash preimage, so keeping the shape clean keeps those preimages clean.
145
- * @returns A promise that resolves to undefined if the action is successful, or a StaleFailure if the action is stale.
146
- */
147
- async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId, priority = 0, blockDigests?: BlockContentDigests): Promise<undefined | StaleFailure> {
148
- const pendResult = await this.transactor.pend({ transforms: transform, actionId, rev, policy: 'r', ...(priority > 0 ? { priority } : {}) });
149
- if (!pendResult.success) {
150
- return pendResult;
151
- }
152
- const isNew = transform.inserts && Object.hasOwn(transform.inserts, headerId);
153
- try {
154
- const commitResult = await this.transactor.commit({
155
- headerId: isNew ? headerId : undefined,
156
- tailId,
157
- blockIds: pendResult.blockIds,
158
- actionId,
159
- rev,
160
- ...blockDigestsField(blockDigests)
161
- });
162
- if (!commitResult.success) {
163
- await this.transactor.cancel({ actionId, blockIds: pendResult.blockIds });
164
- return commitResult;
165
- }
166
- } catch (e) {
167
- await this.transactor.cancel({ actionId, blockIds: pendResult.blockIds });
168
- throw e;
169
- }
170
- }
171
- }
172
-
1
+ import { randomBytes } from '@noble/hashes/utils.js'
2
+ import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
+ import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms, BlockContentDigests } from "../index.js";
4
+ import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
5
+ import type { ReadDependency } from "../transaction/transaction.js";
6
+ import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
7
+ import { blockDigestsField } from "../transform/digest.js";
8
+
9
+ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlock> {
10
+ /** Shared with this collection's CacheSource so cache hits also record dependencies.
11
+ * Defaults to a private instance so internal log-walk sources (which never need a
12
+ * transaction read set) work standalone. */
13
+ private readonly collector: ReadDependencyCollector;
14
+ /** Last revision observed per id, so CacheSource can learn the revision on a miss-load
15
+ * (it calls {@link getReadRevision} right after this source serves the block). */
16
+ private readRevisions = new Map<BlockId, number>();
17
+
18
+ constructor(
19
+ private readonly collectionId: BlockId,
20
+ private readonly transactor: ITransactor,
21
+ public actionContext: ActionContext | undefined,
22
+ collector?: ReadDependencyCollector,
23
+ ) {
24
+ this.collector = collector ?? new ReadDependencyCollector();
25
+ }
26
+
27
+ createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader {
28
+ return {
29
+ type,
30
+ id: newId ?? this.generateId(),
31
+ collectionId: this.collectionId,
32
+ };
33
+ }
34
+
35
+ generateId(): BlockId {
36
+ // 256-bits to fully utilize DHT address space
37
+ return uint8ArrayToString(randomBytes(32), 'base64url')
38
+ }
39
+
40
+ async tryGet(id: BlockId, purpose: ReadPurpose = 'value'): Promise<TBlock | undefined> {
41
+ const result = await this.transactor.get({ blockIds: [id], context: this.actionContext });
42
+ // Guard the per-key entry: some transactors return a sparse result that omits `id`
43
+ // entirely (e.g. block genuinely not found), so `result` is a truthy object but
44
+ // `result[id]` is undefined. Destructuring that would throw a TypeError.
45
+ const entry = result?.[id];
46
+ if (entry) {
47
+ const { block, state, materialized, unavailable, unconfirmedAheadRev } = entry;
48
+ // An entry flagged `unavailable` with no block is the repo saying "I could not find
49
+ // out whether this exists" — an answer that must not be read as absent. Throw rather
50
+ // than return undefined, and record no read dependency (dependencies are recorded
51
+ // only for blocks that actually exist). A repo that omits the flag stays authoritative.
52
+ if (!block && unavailable) {
53
+ throw new BlockUnavailableError(id, unavailable);
54
+ }
55
+ // A read whose surviving answer is marked possibly-behind (`unconfirmedAheadRev`
56
+ // outlived the transactor's retry round: every reachable coordinator served content it
57
+ // could not confirm current) must not pose as an answer for a view that should CONTAIN
58
+ // the claimed revision. Two such views, the same test the coordinator applies when it
59
+ // stamps: an UNPINNED "give me latest" read — the tail read is the one seam where a
60
+ // lagging collection can learn the truth (Collection.bootstrapContext), and silently
61
+ // serving doubted content there is exactly how a collection view freezes forever — and
62
+ // a read PINNED AT OR ABOVE the claim, whose snapshot is missing a revision the cohort
63
+ // says exists inside it. A read pinned strictly BELOW the claim keeps working: it
64
+ // legitimately asks for an older view, which is being served correctly.
65
+ // No read dependency is recorded: the throw means nothing was read.
66
+ // NOTE: accepted tradeoff — this converts a silent wrong answer into a loud failure. A
67
+ // node partitioned from every coordinator able to confirm currency used to read (stale)
68
+ // data indefinitely without any signal; it now raises BlockPossiblyStaleError on the
69
+ // reads that should contain the claim, until the partition heals or the claim is
70
+ // settled. Deliberate: the
71
+ // silent alternative is a collection view that forks and freezes with no report
72
+ // (ticket coordinator-serves-stale-data-as-if-confirmed). Revisit only if a
73
+ // degraded-read mode (serve-with-warning) becomes a product requirement.
74
+ if (unconfirmedAheadRev !== undefined
75
+ && (this.actionContext === undefined || this.actionContext.rev >= unconfirmedAheadRev)) {
76
+ throw new BlockPossiblyStaleError(id, unconfirmedAheadRev);
77
+ }
78
+ // Record a read dependency only for a block that actually exists. A transactor may return a
79
+ // populated entry with `block: undefined` for a genuinely-missing block (TestTransactor does;
80
+ // the Network transactor always populates the key); recording there would add a phantom
81
+ // dependency for a nonexistent block. This makes the "absent reads nothing" contract uniform
82
+ // with the sparse-result case (entry omitted) — see transactor-source.spec.ts sparse test.
83
+ if (block) {
84
+ // Record read dependency for optimistic concurrency control, carrying the caller's
85
+ // read purpose (default `value`) so a purely-structural navigation read can later be
86
+ // dropped from the conflict set (see ReadDependencyCollector / Theorem 5).
87
+ // Record the revision the content was MATERIALIZED at, not the newest the repo holds —
88
+ // see {@link GetBlockResult.materialized} for why `state.latest` is the wrong number
89
+ // and why the fallback preserves today's behaviour for repos that omit the field.
90
+ // Both sinks must take the SAME value: CacheSource learns it via getReadRevision on a
91
+ // miss-load and re-emits it on every later hit, so a split would stamp the cache
92
+ // differently from the collector.
93
+ const rev = materialized?.rev ?? state.latest?.rev ?? 0;
94
+ this.collector.record(id, rev, purpose);
95
+ this.readRevisions.set(id, rev);
96
+ }
97
+ // TODO: if the state reports that there is a pending action, record this so that we are sure to update before syncing
98
+ //state.pendings
99
+ return block as TBlock;
100
+ }
101
+ }
102
+
103
+ /** The revision observed the last time this source served {@link id} (from its committed
104
+ * state), or undefined if this source has never served it. CacheSource reads this on a
105
+ * miss-load to learn the revision to record and store. */
106
+ getReadRevision(id: BlockId): number | undefined {
107
+ return this.readRevisions.get(id);
108
+ }
109
+
110
+ getReadDependencies(): ReadDependency[] {
111
+ return this.collector.getReadDependencies();
112
+ }
113
+
114
+ /** The collector this source records into — the one shared with the collection's
115
+ * CacheSource. Exposed ONLY so a pinned read view built with `recordReads: true`
116
+ * can feed the same per-transaction read set (see Collection.createReadTracker);
117
+ * every other consumer should go through {@link getReadDependencies}. */
118
+ getCollector(): ReadDependencyCollector {
119
+ return this.collector;
120
+ }
121
+
122
+ clearReadDependencies(): void {
123
+ this.collector.clear();
124
+ }
125
+
126
+ /**
127
+ * Attempts to apply the given transforms in a transactional manner.
128
+ * @param transform - The transforms to apply.
129
+ * @param actionId - The action id.
130
+ * @param rev - The revision number.
131
+ * @param headerId - The Id of the collection's header block. Forwarded to the commit only when the header is a
132
+ * fresh insert, so the collection-identifying metadata (see `CommitRequest.headerId`) is present on the commit
133
+ * that creates it.
134
+ * @param tailId - The Id of the collection's log tail block. This block's transform is committed FIRST
135
+ * (prior to the rest of the block operations), to resolve the "winner" of a race to commit to the collection.
136
+ * @param priority - Aged, advisory retry priority (default 0). Rides on the pend so a repeatedly-losing
137
+ * single-collection sync out-ranks fresh rivals in a concurrent race (`resolveRace`); fairness-only, never
138
+ * affects validity. Omitted from the pend when 0 so the common first-attempt pend serializes exactly as before.
139
+ * @param blockDigests - Optional per-block content declarations for this commit (see {@link BlockContentDigests}),
140
+ * computed by the caller from the same tracker that produced `transform`. Omitted from the commit request when
141
+ * undefined, so a caller that declares nothing produces exactly the request shape as before — the field rides
142
+ * inside every cohort signature's hash preimage, so keeping the shape clean keeps those preimages clean.
143
+ * @returns A promise that resolves to undefined if the action is successful, or a StaleFailure if the action is stale.
144
+ */
145
+ async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId, priority = 0, blockDigests?: BlockContentDigests): Promise<undefined | StaleFailure> {
146
+ const pendResult = await this.transactor.pend({ transforms: transform, actionId, rev, policy: 'r', ...(priority > 0 ? { priority } : {}) });
147
+ if (!pendResult.success) {
148
+ return pendResult;
149
+ }
150
+ const isNew = transform.inserts && Object.hasOwn(transform.inserts, headerId);
151
+ try {
152
+ const commitResult = await this.transactor.commit({
153
+ headerId: isNew ? headerId : undefined,
154
+ tailId,
155
+ blockIds: pendResult.blockIds,
156
+ actionId,
157
+ rev,
158
+ ...blockDigestsField(blockDigests)
159
+ });
160
+ if (!commitResult.success) {
161
+ await this.transactor.cancel({ actionId, blockIds: pendResult.blockIds });
162
+ return commitResult;
163
+ }
164
+ } catch (e) {
165
+ await this.transactor.cancel({ actionId, blockIds: pendResult.blockIds });
166
+ throw e;
167
+ }
168
+ }
169
+ }
170
+
@@ -8,6 +8,12 @@ export class Atomic<TBlock extends IBlock> extends Tracker<TBlock> {
8
8
  }
9
9
 
10
10
  commit() {
11
+ // Hand the bases pinned inside this atomic to the parent tracker BEFORE reset() wipes this
12
+ // store — without this, a single act() carrying more actions than the read cache holds loses
13
+ // digest coverage, because by the flush below the cache has already evicted the early bases.
14
+ // The parent gets a COPY (adopt), not this store itself: sharing the store would let the
15
+ // reset() below wipe the parent's pins a line before flushing into it.
16
+ if (this.store instanceof Tracker) this.store.pins.adopt(this.pins);
11
17
  const transform = this.reset();
12
18
  applyTransformToStore(transform, this.store);
13
19
  }
@@ -0,0 +1,83 @@
1
+ import type { BlockId, IBlock } from "../index.js";
2
+
3
+ /** The committed base of one block, captured at the moment an update for it was staged. */
4
+ export type PinnedBase = {
5
+ /** Cloned base content. Callers MUST clone again before applying a transform — applyTransform
6
+ * mutates, and the same pin is re-used by every retry attempt's digest pass. */
7
+ block: IBlock;
8
+ /** Committed revision of that base (CacheSource.getCachedRevision at pin time). */
9
+ rev: number;
10
+ /** Source drift generation at pin time (CacheSource.getGeneration). A pin whose generation no
11
+ * longer matches the source's is STALE and must not be used. */
12
+ gen: number;
13
+ };
14
+
15
+ /** Per-transaction map of block id -> {@link PinnedBase}. Owned by a {@link Tracker}, shared by
16
+ * reference across the trackers of one transaction (the collection's live tracker and each
17
+ * per-attempt snapshot tracker), so a base pinned when an update was staged is still available
18
+ * when the digest pass runs — regardless of whether the read cache has since evicted it.
19
+ *
20
+ * NOTE: memory shape — one cloned base block per update-carrying block, held from the first
21
+ * update staged for it until the owning tracker's next reset(). Peak retention is proportional
22
+ * to the transaction's own write footprint (the same set of blocks whose ids and ops the commit
23
+ * request already carries), and every transaction boundary reclaims it via reset(). */
24
+ export class BasePins {
25
+ private pins = new Map<BlockId, PinnedBase>();
26
+ /** The base source every `rev`/`gen` in here was read from — see {@link bindAuthority}. */
27
+ private authority: unknown;
28
+ private bound = false;
29
+
30
+ /** Claim this store for `source`, the base source at the bottom of the binding tracker's stack.
31
+ * A fresh store binds; a store being joined must present the same source. Both `rev` and `gen`
32
+ * are counters private to one source — a second CacheSource numbers generations from 0 for the
33
+ * same ids — so a store shared across two sources would let a pin taken from one pass the
34
+ * freshness check against the other and declare the wrong content. Throwing here makes that
35
+ * mistake impossible to make quietly. */
36
+ bindAuthority(source: unknown): void {
37
+ if (!this.bound) {
38
+ this.authority = source;
39
+ this.bound = true;
40
+ } else if (this.authority !== source) {
41
+ throw new Error('BasePins shared between trackers over different base sources: pin revisions and generations are per-source counters and are not comparable across them.');
42
+ }
43
+ }
44
+
45
+ get(id: BlockId): PinnedBase | undefined {
46
+ return this.pins.get(id);
47
+ }
48
+
49
+ set(id: BlockId, pin: PinnedBase): void {
50
+ this.pins.set(id, pin);
51
+ }
52
+
53
+ delete(id: BlockId): void {
54
+ this.pins.delete(id);
55
+ }
56
+
57
+ /** Drop every pin whose id is not in `keep`. Called from Tracker.reset with the ids still
58
+ * carried by the new transforms' `updates`, so a rollback keeps its pins and a plain reset
59
+ * clears them. */
60
+ retainOnly(keep: Iterable<BlockId>): void {
61
+ const keepSet = new Set(keep);
62
+ for (const id of this.pins.keys()) {
63
+ if (!keepSet.has(id)) this.pins.delete(id);
64
+ }
65
+ }
66
+
67
+ /** Copy every entry of `other` in, overwriting. Called from Atomic.commit so pins captured
68
+ * inside the atomic survive into the parent tracker's store. Overwriting is deliberate: the
69
+ * atomic's pin is the later observation of the same base chain, and the use-time freshness
70
+ * check re-validates it anyway. */
71
+ adopt(other: BasePins): void {
72
+ if (other.authority !== this.authority) {
73
+ throw new Error('BasePins.adopt across different base sources: pin revisions and generations are not comparable across them.');
74
+ }
75
+ for (const [id, pin] of other.pins) {
76
+ this.pins.set(id, pin);
77
+ }
78
+ }
79
+
80
+ get size(): number {
81
+ return this.pins.size;
82
+ }
83
+ }
@@ -8,28 +8,23 @@ import type { Tracker } from "./tracker.js";
8
8
  const log = createLogger('digest');
9
9
 
10
10
  /** Digests for the blocks the tracker's staged transforms touch, computed WITHOUT loading anything
11
- * from the source. An id whose base is not already cached is omitted rather than fetched — an
12
- * omitted id never fails the commit, it only forfeits what a declaration buys (see the NOTE below).
11
+ * from the source. An id whose base is neither pinned nor cached is omitted rather than fetched —
12
+ * an omitted id never fails the commit, it only forfeits what a declaration buys (an undeclared
13
+ * block retains no durable `BlockCommitProof` and so can never GAIN a holder by push; stated once,
14
+ * canonically, at {@link CommitRequest.blockDigests} in `network/struct.ts`).
13
15
  * Each digest is the {@link canonicalBlockHash} of what {@link Tracker.peekMaterialized} says the
14
16
  * block will contain at the committing revision; `baseRev` rides along except for base-independent
15
17
  * (inserted) blocks. */
16
- // NOTE: coverage is bounded by the read cache, not by the transaction. A commit whose update-carrying
17
- // blocks outnumber the CacheSource capacity (default 128) silently digests only the ids still
18
- // resident, and the declared count does not merely thin out it CAPS. Measured through the
19
- // production path (`Collection.act`/`sync`) in `test/digest-cache-coverage.spec.ts`: with N
20
- // update-carrying blocks the declared count is 32/32 at N=32, then 126 at N=128, 200, 256 AND 512
21
- // (126 = the 128 slots less the collection header and log tail), i.e. 100%, 98.4%, 63.0%, 49.2%,
22
- // 24.6%. Coverage therefore decays as 1/N and an arbitrarily large commit declares an arbitrarily
23
- // small fraction of itself. The survivors are the newest contiguous run, exactly as LRU eviction
24
- // predicts.
25
- // Omission also costs MORE than it used to: it still degrades gracefully on the read path, but an
26
- // undeclared block retains no durable `BlockCommitProof` and so can never GAIN a holder by push.
27
- // That consequence is stated once, canonically, at {@link CommitRequest.blockDigests} in
28
- // `network/struct.ts`; do not restate it here.
29
- // Still accepted here rather than fixed in place: both remedies are larger than this function —
30
- // size the cache to the transaction, or carry the base revision alongside the staged updates instead
31
- // of re-reading it here. Tracked as `debt-digest-coverage-capped-by-read-cache`. Revisit when a
32
- // workload legitimately commits more update-carrying blocks than the cache holds.
18
+ // NOTE: declarability follows what the transaction read and staged, not read-cache residency. Each
19
+ // updated block's committed base is pinned at the moment its update is staged (Tracker.update ->
20
+ // BasePins) and held until the transaction boundary, so a commit of any size declares 100% of the
21
+ // blocks whose bases it read — verified through the production path (`Collection.act`/`sync`) in
22
+ // `test/digest-cache-coverage.spec.ts` at 2x and 4x the cache capacity. The two remaining
23
+ // legitimate omissions: a delete (materializes to nothing) and a blind update to a block this node
24
+ // never read whose base is not cached (nothing to declare, and a commit must never pay a network
25
+ // read to describe itself). Residual gap: read-far-then-update a block read, then evicted by
26
+ // 128+ other reads, and only then updated, finds nothing to pin; see the NOTE at the pin site in
27
+ // `tracker.ts`.
33
28
  export async function computeBlockContentDigests<T extends IBlock>(
34
29
  tracker: Tracker<T>,
35
30
  blockIds: BlockId[]