@optimystic/db-core 0.24.2 → 0.25.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 (103) hide show
  1. package/dist/src/blocks/helpers.d.ts +5 -0
  2. package/dist/src/blocks/helpers.d.ts.map +1 -1
  3. package/dist/src/blocks/helpers.js +12 -0
  4. package/dist/src/blocks/helpers.js.map +1 -1
  5. package/dist/src/cluster/membership.d.ts +7 -0
  6. package/dist/src/cluster/membership.d.ts.map +1 -1
  7. package/dist/src/cluster/membership.js +12 -8
  8. package/dist/src/cluster/membership.js.map +1 -1
  9. package/dist/src/cluster/structs.d.ts +27 -1
  10. package/dist/src/cluster/structs.d.ts.map +1 -1
  11. package/dist/src/cluster/structs.js.map +1 -1
  12. package/dist/src/collection/action.d.ts +14 -0
  13. package/dist/src/collection/action.d.ts.map +1 -1
  14. package/dist/src/collection/action.js +16 -1
  15. package/dist/src/collection/action.js.map +1 -1
  16. package/dist/src/collection/collection.d.ts +273 -4
  17. package/dist/src/collection/collection.d.ts.map +1 -1
  18. package/dist/src/collection/collection.js +427 -30
  19. package/dist/src/collection/collection.js.map +1 -1
  20. package/dist/src/collections/tree/tree.d.ts +16 -1
  21. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  22. package/dist/src/collections/tree/tree.js +19 -0
  23. package/dist/src/collections/tree/tree.js.map +1 -1
  24. package/dist/src/index.d.ts +1 -0
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/index.js +1 -0
  27. package/dist/src/index.js.map +1 -1
  28. package/dist/src/network/i-repo.d.ts +11 -1
  29. package/dist/src/network/i-repo.d.ts.map +1 -1
  30. package/dist/src/network/stale-failure.d.ts +21 -0
  31. package/dist/src/network/stale-failure.d.ts.map +1 -1
  32. package/dist/src/network/stale-failure.js +22 -0
  33. package/dist/src/network/stale-failure.js.map +1 -1
  34. package/dist/src/network/struct.d.ts +66 -21
  35. package/dist/src/network/struct.d.ts.map +1 -1
  36. package/dist/src/network/struct.js.map +1 -1
  37. package/dist/src/testing/test-transactor.d.ts +22 -0
  38. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  39. package/dist/src/testing/test-transactor.js +44 -5
  40. package/dist/src/testing/test-transactor.js.map +1 -1
  41. package/dist/src/transaction/coordinator.d.ts +20 -0
  42. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  43. package/dist/src/transaction/coordinator.js +256 -113
  44. package/dist/src/transaction/coordinator.js.map +1 -1
  45. package/dist/src/transaction/operations-hash.d.ts +1 -1
  46. package/dist/src/transaction/operations-hash.js +1 -1
  47. package/dist/src/transaction/transaction.d.ts +4 -2
  48. package/dist/src/transaction/transaction.d.ts.map +1 -1
  49. package/dist/src/transactor/network-transactor.d.ts +21 -1
  50. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  51. package/dist/src/transactor/network-transactor.js +122 -29
  52. package/dist/src/transactor/network-transactor.js.map +1 -1
  53. package/dist/src/transactor/transactor-source.d.ts +11 -5
  54. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  55. package/dist/src/transactor/transactor-source.js +16 -8
  56. package/dist/src/transactor/transactor-source.js.map +1 -1
  57. package/dist/src/transform/cache-source.d.ts +13 -0
  58. package/dist/src/transform/cache-source.d.ts.map +1 -1
  59. package/dist/src/transform/cache-source.js +18 -0
  60. package/dist/src/transform/cache-source.js.map +1 -1
  61. package/dist/src/transform/digest.d.ts +18 -0
  62. package/dist/src/transform/digest.d.ts.map +1 -0
  63. package/dist/src/transform/digest.js +65 -0
  64. package/dist/src/transform/digest.js.map +1 -0
  65. package/dist/src/transform/index.d.ts +1 -0
  66. package/dist/src/transform/index.d.ts.map +1 -1
  67. package/dist/src/transform/index.js +1 -0
  68. package/dist/src/transform/index.js.map +1 -1
  69. package/dist/src/transform/tracker.d.ts +16 -0
  70. package/dist/src/transform/tracker.d.ts.map +1 -1
  71. package/dist/src/transform/tracker.js +40 -1
  72. package/dist/src/transform/tracker.js.map +1 -1
  73. package/dist/src/utility/canonical-json.d.ts +11 -0
  74. package/dist/src/utility/canonical-json.d.ts.map +1 -0
  75. package/dist/src/utility/canonical-json.js +15 -0
  76. package/dist/src/utility/canonical-json.js.map +1 -0
  77. package/dist/src/utility/lru-map.d.ts +2 -0
  78. package/dist/src/utility/lru-map.d.ts.map +1 -1
  79. package/dist/src/utility/lru-map.js +4 -0
  80. package/dist/src/utility/lru-map.js.map +1 -1
  81. package/package.json +2 -1
  82. package/src/blocks/helpers.ts +26 -13
  83. package/src/cluster/membership.ts +87 -85
  84. package/src/cluster/structs.ts +28 -1
  85. package/src/collection/action.ts +17 -0
  86. package/src/collection/collection.ts +1129 -688
  87. package/src/collections/tree/tree.ts +341 -320
  88. package/src/index.ts +24 -23
  89. package/src/network/i-repo.ts +59 -46
  90. package/src/network/stale-failure.ts +67 -43
  91. package/src/network/struct.ts +332 -270
  92. package/src/testing/test-transactor.ts +680 -638
  93. package/src/transaction/coordinator.ts +1266 -1110
  94. package/src/transaction/operations-hash.ts +1 -1
  95. package/src/transaction/transaction.ts +4 -2
  96. package/src/transactor/network-transactor.ts +123 -32
  97. package/src/transactor/transactor-source.ts +17 -9
  98. package/src/transform/cache-source.ts +20 -0
  99. package/src/transform/digest.ts +72 -0
  100. package/src/transform/index.ts +1 -0
  101. package/src/transform/tracker.ts +42 -1
  102. package/src/utility/canonical-json.ts +16 -0
  103. package/src/utility/lru-map.ts +5 -0
@@ -176,7 +176,7 @@ export function canonicalOperationsPayload(operations: readonly Operation[]): st
176
176
  * stringify, SHA-256 (base64url) via {@link hashString}, then prefix the versioned
177
177
  * {@link OPS_HASH_PREFIX} token (`ops.v1:`) so the wire string is self-describing.
178
178
  *
179
- * This is the fingerprint the coordinator sends in PendRequest.operationsHash and
179
+ * This is the fingerprint the coordinator sends in PendRequest.validation.operationsHash and
180
180
  * the validator recomputes; equality of this string is the cross-node agreement
181
181
  * that a transaction's operations match.
182
182
  *
@@ -392,8 +392,10 @@ export type ValidationResult = {
392
392
  * Transaction validator interface.
393
393
  * Pluggable validators implement this to verify transaction integrity.
394
394
  *
395
- * Validators are invoked when a node receives a PendRequest with a transaction.
396
- * They re-execute the transaction and verify the operations match.
395
+ * Validators are invoked when a node receives a PendRequest carrying a `validation`
396
+ * payload (the transaction plus its operations hash). They re-execute the transaction
397
+ * and verify the operations match. A throw out of {@link validate} is treated by
398
+ * callers as a rejection ("validator-fault: …"), never as an approval or an abstain.
397
399
  */
398
400
  export interface ITransactionValidator {
399
401
  /**
@@ -2,10 +2,11 @@ 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
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";
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, BlockContentDigests } 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";
9
+ import { blockDigestsField } from "../transform/digest.js";
9
10
  import { CacheSource } from "../transform/cache-source.js";
10
11
  import { TransactorSource } from "./transactor-source.js";
11
12
  import { Log } from "../log/log.js";
@@ -237,13 +238,13 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
237
238
  // — and only strictly-greater rank replaces, so first-arrival (the stale one)
238
239
  // would win the very merge the retry exists to fix. Non-object junk ranks below
239
240
  // everything so any real entry replaces it.
240
- // NOTE: `materializedRev` is not part of the ranking, so two peers answering the same
241
+ // NOTE: `materialized` (the revision the content actually is) is not part of the ranking, so two peers answering the same
241
242
  // pinned get with block-carrying entries at DIFFERENT materialized revisions resolve
242
243
  // to whichever arrived first. Not a concern today — cohort peers share the block's
243
244
  // revision log, so they agree on the highest committed rev at or below a pin — and the
244
245
  // failure direction is safe (a lower recorded revision spuriously stale-rejects rather
245
246
  // than wrongly accepting). If peers are ever seen to disagree here, break the tie on
246
- // the HIGHEST materializedRev among top-rank entries.
247
+ // the HIGHEST `materialized.rev` among top-rank entries.
247
248
  // `unavailable` answers rank among THEMSELVES by how much they establish, so the merged
248
249
  // entry never presents a weaker doubt than some peer actually returned. This matters
249
250
  // because the reason travels out verbatim on `BlockUnavailableError` and callers act on
@@ -687,29 +688,84 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
687
688
  // consensus commit op → each committing node's StorageRepo.commit stamps it onto the emitted
688
689
  // CollectionChangeEvent (the reactivity topic anchor). Without this the per-block RepoCommitRequest
689
690
  // drops the collection tail and reactivity origination is gated off (undefined tail → non-member).
691
+ // `request.blockDigests` (when present) is the FULL per-block declaration map for the action;
692
+ // it is threaded whole through commitBlock/commitBlocks and subset per batch at send time (see
693
+ // commitBlocks) so each cohort only signs for the blocks it is actually driving.
690
694
  if (request.headerId && !request.blockIds.includes(request.headerId)) {
691
- const headerResult = await this.commitBlock(request.headerId, request.actionId, request.rev, request.tailId);
695
+ const headerResult = await this.commitBlock(request.headerId, request.actionId, request.rev, request.tailId, request.blockDigests);
692
696
  if (!headerResult.success) {
693
697
  return headerResult;
694
698
  }
695
699
  }
696
700
 
697
701
  // Commit the tail block
698
- const tailResult = await this.commitBlock(request.tailId, request.actionId, request.rev, request.tailId);
702
+ const tailResult = await this.commitBlock(request.tailId, request.actionId, request.rev, request.tailId, request.blockDigests);
699
703
  if (!tailResult.success) {
700
704
  return tailResult;
701
705
  }
702
706
 
703
- // Commit all remaining block ids (excluding tail and header if it was already handled)
704
- const remainingBlocks = request.blockIds.filter(bid =>
705
- bid !== request.tailId &&
706
- !(request.headerId && bid === request.headerId && !request.blockIds.includes(request.headerId))
707
- );
707
+ // Sweep every non-tail block. The tail is the ONLY exclusion needed: the header-first commit
708
+ // above fires only when the header is NOT in `blockIds`, so a header that IS in `blockIds`
709
+ // belongs here, in this sweep. (The removed second filter clause tested
710
+ // `!blockIds.includes(headerId)` against a `bid` drawn from `blockIds` — unsatisfiable
711
+ // whenever `bid === headerId`, so it never excluded anything.)
712
+ //
713
+ // NOTE: measured, not assumed — the header-first branch above is currently UNREACHABLE from
714
+ // the only production producer of `headerId`. `TransactorSource.transact` sets it solely when
715
+ // the header is a fresh insert, and an inserted id is by construction in the pend's
716
+ // `blockIds`, so `!blockIds.includes(headerId)` is never true there. Instrumenting the branch
717
+ // and running both suites: 0 hits across every db-p2p mesh test, 2 hits in db-core, both from
718
+ // `commit-digest-threading.spec.ts` requests hand-built with the header held out of
719
+ // `blockIds`. So the order that actually runs in production is tail -> sweep, with the header
720
+ // (when the action touches it) inside the sweep — i.e. after the tail. Whether to wire the
721
+ // branch up or delete it, along with the three contracts that still describe it as live, is
722
+ // `tickets/backlog/debt-commit-header-first-branch-is-unreachable`.
723
+ //
724
+ // The tail -> sweep half of that order IS load-bearing; do not reorder it to put the
725
+ // contested blocks first. `Collection.bootstrapContext` documents the guarantee it rests on:
726
+ // "The tail is always committed first (commit protocol guarantee), so it's readable with
727
+ // context=undefined" — that bootstrap is what makes pending non-tail blocks visible to a
728
+ // chain walk. Sweeping before the tail would also let a committed header point at a
729
+ // never-committed tail: a dangling pointer, strictly worse than an orphaned block.
730
+ const remainingBlocks = request.blockIds.filter(bid => bid !== request.tailId);
708
731
  if (remainingBlocks.length > 0) {
709
- const { error } = await this.commitBlocks({ blockIds: remainingBlocks, actionId: request.actionId, rev: request.rev, tailId: request.tailId });
732
+ const { batches, error } = await this.commitBlocks({ blockIds: remainingBlocks, actionId: request.actionId, rev: request.rev, tailId: request.tailId, blockDigests: request.blockDigests });
710
733
  if (error) {
711
- // Non-tail block commit failures should not fail the overall action once the tail has committed.
712
- // Proceed and rely on reconciliation paths (e.g. reads with context) to finalize state on lagging peers.
734
+ // Split by the failure's NATURE, exactly as commitBlock does for the tail: a RETURNED
735
+ // `success:false` from a cohort coordinator is a confirmed optimistic-concurrency loss
736
+ // (post conversion-arms, coordinators only return non-success for confirmed conflicts) —
737
+ // a rival holds one of these blocks' revisions, no reconciliation will ever apply OUR
738
+ // transform there, and proceeding would acknowledge a torn action (tail committed, the
739
+ // conflicted block permanently pointing elsewhere — the acknowledged-but-absent write).
740
+ // Surface it so the caller cancels and re-drives at a fresh revision.
741
+ const stale = this.staleFromBatches(batches);
742
+ if (stale) {
743
+ return stale;
744
+ }
745
+ // The tail (and header) already committed durably when this returns — a torn action.
746
+ // Refusing is still right, and must NOT be softened back into a blanket tolerance:
747
+ // acknowledging a torn action would report a write as durable while one of its blocks
748
+ // permanently points somewhere else. The two consequences that used to make the refusal
749
+ // itself harmful are now handled upstream, so the writer recovers on its own:
750
+ // - its re-pend no longer meets its OWN durable revision as if a rival held it. All
751
+ // three pend-tier checks carve out `latest.rev === request.rev &&
752
+ // latest.actionId === request.actionId` (StorageRepo.pend, which also skips saving
753
+ // a pending record for such a block; ClusterMember.validatePendOperations; and
754
+ // CoordinatorRepo's stale classification). The retry no longer wedges.
755
+ // - its retry consumes its own already-committed log entry instead of replaying it
756
+ // (Collection.updateInternal's `inFlightActionId` arm, threaded by syncInternal,
757
+ // which reuses one actionId across all of a sync's attempts). It no longer appends
758
+ // a duplicate entry.
759
+ // NOTE: that second half covers the single-collection path only. The multi-collection
760
+ // path (TransactionCoordinator's retry loop) still refreshes without telling the
761
+ // collection which of its own actions is in flight, so a torn multi-collection commit
762
+ // can still record its entry twice — tracked as
763
+ // tickets/fix/refresh-must-always-know-its-own-in-flight-action, and to be fixed there
764
+ // rather than by tolerating the failure here.
765
+ //
766
+ // Transport-shaped failures (throws, no returned refusal) keep the tolerance: the commit
767
+ // consensus for these blocks exists, so lagging peers converge via reconciliation paths
768
+ // (e.g. reads with context).
713
769
  try { log('WARN: non-tail commit had errors; proceeding after tail commit: %s', error.message); } catch { /* ignore */ }
714
770
  }
715
771
  }
@@ -718,36 +774,57 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
718
774
  return { success: true };
719
775
  }
720
776
 
721
- private async commitBlock(blockId: BlockId, actionId: ActionId, rev: number, tailId?: BlockId): Promise<CommitResult> {
722
- const { batches: tailBatches, error: tailError } = await this.commitBlocks({ blockIds: [blockId], actionId, rev, tailId });
777
+ private async commitBlock(blockId: BlockId, actionId: ActionId, rev: number, tailId?: BlockId, blockDigests?: BlockContentDigests): Promise<CommitResult> {
778
+ const { batches: tailBatches, error: tailError } = await this.commitBlocks({ blockIds: [blockId], actionId, rev, tailId, blockDigests });
723
779
  if (tailError) {
724
780
  // commit is a pure attempt: stale → { success:false }, transient → throw. Cancellation
725
781
  // is the CALLER's responsibility (coordinator cancelPhase; TransactorSource.transact),
726
782
  // which owns the retry budget and the committed-vs-pending picture — self-cancelling here
727
783
  // would tear down a pend a caller's retry loop is still working against, and double-cancel.
728
784
  // Collect and return any active stale failures
729
- const stale = Array.from(allBatches(tailBatches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
730
- if (stale.length > 0) {
731
- // NOTE: a reason-only StaleFailure (success:false, no `missing`) lands here too and
732
- // returns { missing: [], success:false } — the `reason` PROSE is still dropped rather
733
- // than surfaced via `throw tailError`. `staleAt` is carried, so the one machine-readable
734
- // fact in that prose (which block is at which revision) now survives; only the free-form
735
- // wording is lost. If the wording itself is ever needed, gate this branch on non-empty
736
- // missing rather than reinstating it unconditionally.
737
- const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
738
- return {
739
- missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
740
- ...(staleAt === undefined ? {} : { staleAt }),
741
- success: false as const
742
- };
785
+ const stale = this.staleFromBatches(tailBatches);
786
+ if (stale) {
787
+ return stale;
743
788
  }
744
789
  throw tailError;
745
790
  }
746
791
  return { success: true };
747
792
  }
748
793
 
749
- /** Attempts to commit a set of blocks, and handles failures and errors */
750
- private async commitBlocks({ blockIds, actionId, rev, tailId }: RepoCommitRequest) {
794
+ /**
795
+ * Merge the RETURNED `success:false` responses out of a set of commit batches into one
796
+ * {@link StaleFailure}, or `undefined` when every failure was transport-shaped (thrown, no
797
+ * response). Shared by {@link commitBlock} (tail/header) and {@link commit}'s non-tail sweep —
798
+ * both must distinguish a confirmed conflict (return it; the caller cancels and re-drives) from
799
+ * a transient fault (throw / tolerate).
800
+ *
801
+ * NOTE: a reason-only StaleFailure (success:false, no `missing`) lands here too and returns
802
+ * `{ missing: [], success:false }` — the `reason` PROSE is dropped rather than surfaced.
803
+ * `staleAt` is carried, so the one machine-readable fact in that prose (which block is at which
804
+ * revision) survives; only the free-form wording is lost. If the wording itself is ever needed,
805
+ * gate this on non-empty missing rather than reinstating it unconditionally.
806
+ */
807
+ private staleFromBatches(batches: CoordinatorBatch<BlockId[], CommitResult>[]): StaleFailure | undefined {
808
+ const stale = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
809
+ if (stale.length === 0) {
810
+ return undefined;
811
+ }
812
+ const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
813
+ return {
814
+ missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
815
+ ...(staleAt === undefined ? {} : { staleAt }),
816
+ success: false as const
817
+ };
818
+ }
819
+
820
+ /** Attempts to commit a set of blocks, and handles failures and errors.
821
+ *
822
+ * `blockDigests` arrives as the action's FULL declaration map and is narrowed to each batch's own
823
+ * block ids inside the send callback below — never up front. Each batch's message becomes its own
824
+ * cluster record, so shipping the whole map would make one cohort sign for blocks it is not
825
+ * responsible for; and `processBatches` re-batches failed blocks onto different coordinators, so
826
+ * only a send-time subset stays correct across retries. */
827
+ private async commitBlocks({ blockIds, actionId, rev, tailId, blockDigests }: RepoCommitRequest) {
751
828
  const expiration = Date.now() + this.timeoutMs;
752
829
  // Thread the transaction's actionId so both the initial batch assembly and any
753
830
  // per-block retry re-resolution prefer the coordinator pend already resolved.
@@ -757,7 +834,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
757
834
  try {
758
835
  await processBatches(
759
836
  batches,
760
- (batch) => this.getRepo(batch.peerId).commit({ actionId, blockIds: batch.payload, rev, tailId }, { expiration, dialTimeoutMs: this.dialTimeoutMs }),
837
+ (batch) => this.getRepo(batch.peerId).commit({ actionId, blockIds: batch.payload, rev, tailId, ...digestsFor(blockDigests, batch.payload) }, { expiration, dialTimeoutMs: this.dialTimeoutMs }),
761
838
  batch => batch.payload,
762
839
  mergeBlocks,
763
840
  expiration,
@@ -898,6 +975,20 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
898
975
  }
899
976
 
900
977
 
978
+ /** The subset of `all` whose ids appear in `batchBlockIds`, wrapped (via {@link blockDigestsField})
979
+ * so it spreads to nothing when the batch declares no digests. Called at SEND time, once per attempt,
980
+ * because `processBatches` re-batches failed blocks onto different coordinators — a subset computed
981
+ * up front would follow the wrong batch on retry. */
982
+ function digestsFor(all: BlockContentDigests | undefined, batchBlockIds: BlockId[]): { blockDigests?: BlockContentDigests } {
983
+ if (!all) return {};
984
+ const subset: BlockContentDigests = {};
985
+ for (const id of batchBlockIds) {
986
+ const digest = all[id];
987
+ if (digest !== undefined) subset[id] = digest;
988
+ }
989
+ return blockDigestsField(subset);
990
+ }
991
+
901
992
  /**
902
993
  * The owning collection id for an action ref, read from any fetched block's header. A
903
994
  * committed-then-invalidated action still has a materialized (compensating) block whose header carries
@@ -1,9 +1,10 @@
1
1
  import { randomBytes } from '@noble/hashes/utils.js'
2
2
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
- import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms } from "../index.js";
3
+ import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms, BlockContentDigests } from "../index.js";
4
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
+ import { blockDigestsField } from "../transform/digest.js";
7
8
 
8
9
  export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlock> {
9
10
  /** Shared with this collection's CacheSource so cache hits also record dependencies.
@@ -43,7 +44,7 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
43
44
  // `result[id]` is undefined. Destructuring that would throw a TypeError.
44
45
  const entry = result?.[id];
45
46
  if (entry) {
46
- const { block, state, materializedRev, unavailable, unconfirmedAheadRev } = entry;
47
+ const { block, state, materialized, unavailable, unconfirmedAheadRev } = entry;
47
48
  // An entry flagged `unavailable` with no block is the repo saying "I could not find
48
49
  // out whether this exists" — an answer that must not be read as absent. Throw rather
49
50
  // than return undefined, and record no read dependency (dependencies are recorded
@@ -84,12 +85,12 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
84
85
  // read purpose (default `value`) so a purely-structural navigation read can later be
85
86
  // dropped from the conflict set (see ReadDependencyCollector / Theorem 5).
86
87
  // Record the revision the content was MATERIALIZED at, not the newest the repo holds —
87
- // see {@link GetBlockResult.materializedRev} for why `state.latest` is the wrong number
88
+ // see {@link GetBlockResult.materialized} for why `state.latest` is the wrong number
88
89
  // and why the fallback preserves today's behaviour for repos that omit the field.
89
90
  // Both sinks must take the SAME value: CacheSource learns it via getReadRevision on a
90
91
  // miss-load and re-emits it on every later hit, so a split would stamp the cache
91
92
  // differently from the collector.
92
- const rev = materializedRev ?? state.latest?.rev ?? 0;
93
+ const rev = materialized?.rev ?? state.latest?.rev ?? 0;
93
94
  this.collector.record(id, rev, purpose);
94
95
  this.readRevisions.set(id, rev);
95
96
  }
@@ -127,17 +128,23 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
127
128
  * @param transform - The transforms to apply.
128
129
  * @param actionId - The action id.
129
130
  * @param rev - The revision number.
130
- * @param headerId - The Id of the collection's header block. If specified, this block's transform is performed first,
131
- * in the event that there is a race to create the collection itself, or in the event that the tail block is full and
132
- * is transitioning to a new block. Ignored if the given headerId is not present in the transforms.
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`.
133
136
  * @param tailId - The Id of the collection's log tail block. If specified, this block's transform is performed next
134
137
  * (prior to the rest of the block operations), to resolve the "winner" of a race to commit to the collection.
135
138
  * @param priority - Aged, advisory retry priority (default 0). Rides on the pend so a repeatedly-losing
136
139
  * single-collection sync out-ranks fresh rivals in a concurrent race (`resolveRace`); fairness-only, never
137
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.
138
145
  * @returns A promise that resolves to undefined if the action is successful, or a StaleFailure if the action is stale.
139
146
  */
140
- async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId, priority = 0): Promise<undefined | StaleFailure> {
147
+ async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId, priority = 0, blockDigests?: BlockContentDigests): Promise<undefined | StaleFailure> {
141
148
  const pendResult = await this.transactor.pend({ transforms: transform, actionId, rev, policy: 'r', ...(priority > 0 ? { priority } : {}) });
142
149
  if (!pendResult.success) {
143
150
  return pendResult;
@@ -149,7 +156,8 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
149
156
  tailId,
150
157
  blockIds: pendResult.blockIds,
151
158
  actionId,
152
- rev
159
+ rev,
160
+ ...blockDigestsField(blockDigests)
153
161
  });
154
162
  if (!commitResult.success) {
155
163
  await this.transactor.cancel({ actionId, blockIds: pendResult.blockIds });
@@ -100,6 +100,26 @@ export class CacheSource<T extends IBlock> implements BlockSource<T> {
100
100
  return structuredClone(block);
101
101
  }
102
102
 
103
+ /** The block currently cached for `id`, without consulting the source. Cloned (callers apply ops
104
+ * to it) and recency-neutral ({@link LruMap.peek}) — an observation pass must neither pay a
105
+ * network read nor reshape eviction order. Records no read dependency: the caller that peeks
106
+ * already read the block through {@link tryGet} (that is how it got cached), so the dependency
107
+ * exists; a digest pass merely re-describes it. */
108
+ peek(id: BlockId): T | undefined {
109
+ const block = this.cache.peek(id);
110
+ return block === undefined ? undefined : structuredClone(block);
111
+ }
112
+
113
+ /** The committed revision of the content currently cached for `id` — the source-reported
114
+ * materialized revision learned on miss-load (see {@link revisions}), NOT the block's own
115
+ * `state.latest.rev`. An LRU-evicted id can leave a stale entry here (see the NOTE on
116
+ * {@link revisions}); callers must therefore require BOTH {@link peek} and this to be present —
117
+ * {@link peek} returns `undefined` for the evicted id, so a stale revision never pairs with a
118
+ * peeked block. */
119
+ getCachedRevision(id: BlockId): number | undefined {
120
+ return this.revisions.get(id);
121
+ }
122
+
103
123
  /** Upgrade an already-captured read of `id` to a `value` read in the shared collector,
104
124
  * retaining it in the conflict set. The B-tree point-lookup descent calls this (through the
105
125
  * Tracker, which forwards) to pin the terminal leaf after recording the interior nodes as
@@ -0,0 +1,72 @@
1
+ import type { BlockId, IBlock } from "../index.js";
2
+ import type { BlockContentDigests } from "../network/struct.js";
3
+ import { canonicalBlockHash } from "../blocks/helpers.js";
4
+ import { createLogger } from "../logger.js";
5
+ import { isRecordEmpty } from "../utility/is-record-empty.js";
6
+ import type { Tracker } from "./tracker.js";
7
+
8
+ const log = createLogger('digest');
9
+
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).
13
+ * Each digest is the {@link canonicalBlockHash} of what {@link Tracker.peekMaterialized} says the
14
+ * block will contain at the committing revision; `baseRev` rides along except for base-independent
15
+ * (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.
33
+ export async function computeBlockContentDigests<T extends IBlock>(
34
+ tracker: Tracker<T>,
35
+ blockIds: BlockId[]
36
+ ): Promise<BlockContentDigests> {
37
+ const digests: BlockContentDigests = {};
38
+ for (const id of blockIds) {
39
+ const peeked = peekOrSkip(tracker, id);
40
+ if (!peeked) continue;
41
+ digests[id] = {
42
+ digest: await canonicalBlockHash(peeked.block),
43
+ ...(peeked.baseRev !== undefined ? { baseRev: peeked.baseRev } : {}),
44
+ };
45
+ }
46
+ return digests;
47
+ }
48
+
49
+ /** Wraps `blockDigests` so it spreads onto a request only when there is something to declare. The
50
+ * empty map omits the key entirely rather than sending `{}`: the request is hashed verbatim into
51
+ * every cohort signature preimage, so a commit that declares nothing must serialize exactly as it
52
+ * did before this field existed. Every producer of the field goes through here. */
53
+ export function blockDigestsField(digests: BlockContentDigests | undefined): { blockDigests?: BlockContentDigests } {
54
+ return digests && !isRecordEmpty(digests) ? { blockDigests: digests } : {};
55
+ }
56
+
57
+ /** {@link Tracker.peekMaterialized}, degraded to "undeclared" when materializing throws.
58
+ *
59
+ * Declaring content must never break committing it. Materializing replays the staged ops against the
60
+ * LOCALLY CACHED base, which can legitimately fail — e.g. another action's commit folded into the
61
+ * cache (`CacheSource.transformCache`) shrank an array a staged splice indexes into. That transaction
62
+ * is doomed, but it must die as a retryable stale failure from the pend/commit round trip, not as a
63
+ * TypeError thrown out of `sync()` before the pend. The failure is logged rather than silently eaten,
64
+ * because the same swallow would also hide a genuine `applyTransform` bug. */
65
+ function peekOrSkip<T extends IBlock>(tracker: Tracker<T>, id: BlockId): { block: IBlock; baseRev?: number } | undefined {
66
+ try {
67
+ return tracker.peekMaterialized(id);
68
+ } catch (e) {
69
+ log('block %s left undeclared: materializing against the cached base failed: %o', id, e);
70
+ return undefined;
71
+ }
72
+ }
@@ -1,6 +1,7 @@
1
1
  export * from "./atomic.js";
2
2
  export * from "./atomic-proxy.js";
3
3
  export * from "./cache-source.js";
4
+ export * from "./digest.js";
4
5
  export * from "./helpers.js";
5
6
  export * from "./struct.js";
6
7
  export * from "./tracker.js";
@@ -1,5 +1,5 @@
1
1
  import type { IBlock, BlockId, BlockStore as IBlockStore, BlockHeader, BlockOperation, BlockType, BlockSource as IBlockSource, ReadPurpose } from "../index.js";
2
- import { applyOperation, applyOperations, emptyTransforms, blockIdsForTransforms } from "./helpers.js";
2
+ import { applyOperation, applyOperations, applyTransform, emptyTransforms, blockIdsForTransforms, transformForBlockId } from "./helpers.js";
3
3
  import { ensured } from "../utility/ensured.js";
4
4
 
5
5
  /** A block store that collects transformations, without applying them to the underlying source.
@@ -65,6 +65,47 @@ export class Tracker<T extends IBlock> implements IBlockStore<T> {
65
65
  return block; // no-ops path unchanged (source already cloned)
66
66
  }
67
67
 
68
+ /** The block `id` materializes to under the staged transforms, computed WITHOUT loading from the
69
+ * source, plus the committed revision of the base used. `undefined` when not computable here —
70
+ * nothing staged for the id, the result is a delete, or an update's base is not locally cached
71
+ * (a commit must not pay a network round trip to describe itself).
72
+ *
73
+ * Materializes with the canonical {@link applyTransform} — the exact function the member side
74
+ * uses at commit — so client and member can never disagree on semantics (insert replaces the
75
+ * block, then updates apply, then delete wins). An insert makes the result base-independent, so
76
+ * `baseRev` is absent; updates-only returns the base's cached committed revision, probed from the
77
+ * source via `peek`/`getCachedRevision` (duck-typed like {@link sourceGeneration}, because Tracker
78
+ * layers over test doubles; `peek` must return a clone — CacheSource's does). Recency-neutral and
79
+ * memo-neutral: observably changes no tracker or source state. */
80
+ peekMaterialized(id: BlockId): { block: IBlock; baseRev?: number } | undefined {
81
+ const transform = transformForBlockId(this.transforms, id);
82
+ if (transform.insert === undefined && transform.updates === undefined && transform.delete === undefined) {
83
+ return undefined; // nothing staged for this id
84
+ }
85
+ if (transform.delete) {
86
+ return undefined; // delete-last-wins: materializes to nothing
87
+ }
88
+ if (transform.insert) {
89
+ // applyTransform mutates the insert in place when updates ride along; transformForBlockId
90
+ // clones `updates` but NOT `insert`, so clone here to keep the staged transform pristine.
91
+ transform.insert = structuredClone(transform.insert);
92
+ const block = applyTransform(undefined, transform);
93
+ return block ? { block } : undefined;
94
+ }
95
+ const src = this.source as {
96
+ peek?: (id: BlockId) => T | undefined;
97
+ getCachedRevision?: (id: BlockId) => number | undefined;
98
+ };
99
+ if (typeof src.peek !== 'function' || typeof src.getCachedRevision !== 'function') return undefined;
100
+ const base = src.peek(id);
101
+ const baseRev = src.getCachedRevision(id);
102
+ // Require BOTH: an LRU-evicted id can leave a stale cached revision behind (see the NOTE on
103
+ // CacheSource's revisions map); peek returning undefined keeps it from pairing with a block.
104
+ if (base === undefined || baseRev === undefined) return undefined;
105
+ const block = applyTransform(base, transform); // base already a clone (peek contract)
106
+ return block ? { block, baseRev } : undefined;
107
+ }
108
+
68
109
  /** Forward a leaf-value upgrade down to the source's read collector (duck-typed: only the
69
110
  * CacheSource layer implements it). Lets the B-tree point-lookup descent, which reads through
70
111
  * this tracker, pin its terminal leaf as a `value` read after tagging interior nodes
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Deterministic JSON: sorts object keys so the encoding — and therefore any hash over it — is
3
+ * independent of key insertion order. Arrays keep their order (position is meaning).
4
+ *
5
+ * The ONE implementation behind every db-core agreement hash: block content
6
+ * ({@link canonicalBlockHash}) and cluster record/membership hashes ({@link membershipDigest},
7
+ * `computeMessageHash` and friends). Two nodes that encode differently disagree on honest data, so
8
+ * a second copy is a correctness hazard, not a style nit — import this rather than re-deriving it.
9
+ */
10
+ export function canonicalJson(value: unknown): string {
11
+ return JSON.stringify(value, (_, v) =>
12
+ v && typeof v === 'object' && !Array.isArray(v)
13
+ ? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = v[k]; return o; }, {})
14
+ : v
15
+ );
16
+ }
@@ -20,6 +20,11 @@ export class LruMap<K, V> {
20
20
  return value;
21
21
  }
22
22
 
23
+ /** Read without refreshing recency — for observation passes that must not reshape eviction order. */
24
+ peek(key: K): V | undefined {
25
+ return this.map.get(key);
26
+ }
27
+
23
28
  set(key: K, value: V): this {
24
29
  // If already present, delete first to refresh position
25
30
  if (this.map.has(key)) {