@serfab/cadre-core 1.17.0 → 1.18.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 (144) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1176 -564
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +14 -13
  35. package/dist/index.js +16 -13
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +89 -20
  56. package/dist/peer-join-backfill.js +265 -108
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/strand-database.d.ts +23 -2
  65. package/dist/strand-database.js +26 -6
  66. package/dist/strand-database.js.map +1 -1
  67. package/dist/strand-first-sync-gate.d.ts +176 -89
  68. package/dist/strand-first-sync-gate.js +288 -122
  69. package/dist/strand-first-sync-gate.js.map +1 -1
  70. package/dist/strand-formation-deadlines.d.ts +9 -9
  71. package/dist/strand-formation-deadlines.js +9 -9
  72. package/dist/strand-formation-manager.d.ts +23 -17
  73. package/dist/strand-formation-manager.js +46 -44
  74. package/dist/strand-formation-manager.js.map +1 -1
  75. package/dist/strand-formation-protocol.d.ts +5 -8
  76. package/dist/strand-formation-protocol.js +101 -16
  77. package/dist/strand-formation-protocol.js.map +1 -1
  78. package/dist/strand-instance-manager.d.ts +26 -17
  79. package/dist/strand-instance-manager.js +119 -50
  80. package/dist/strand-instance-manager.js.map +1 -1
  81. package/dist/strand-membership-reconciler.d.ts +16 -13
  82. package/dist/strand-membership-reconciler.js +30 -22
  83. package/dist/strand-membership-reconciler.js.map +1 -1
  84. package/dist/strand-membership-writer.d.ts +40 -28
  85. package/dist/strand-membership-writer.js +212 -153
  86. package/dist/strand-membership-writer.js.map +1 -1
  87. package/dist/strand-move.js +5 -2
  88. package/dist/strand-move.js.map +1 -1
  89. package/dist/strand-revocation-enforcer.d.ts +134 -60
  90. package/dist/strand-revocation-enforcer.js +144 -71
  91. package/dist/strand-revocation-enforcer.js.map +1 -1
  92. package/dist/strand-solicitation.d.ts +50 -39
  93. package/dist/strand-solicitation.js +10 -3
  94. package/dist/strand-solicitation.js.map +1 -1
  95. package/dist/strand-watcher.d.ts +80 -13
  96. package/dist/strand-watcher.js +152 -48
  97. package/dist/strand-watcher.js.map +1 -1
  98. package/dist/table-change-watch.d.ts +63 -0
  99. package/dist/table-change-watch.js +209 -0
  100. package/dist/table-change-watch.js.map +1 -0
  101. package/dist/timeout-scheduler.d.ts +4 -3
  102. package/dist/timeout-scheduler.js +4 -3
  103. package/dist/timeout-scheduler.js.map +1 -1
  104. package/dist/types.d.ts +90 -49
  105. package/dist/types.js +4 -4
  106. package/dist/types.js.map +1 -1
  107. package/package.json +8 -8
  108. package/src/cadre-invite-protocol.ts +119 -31
  109. package/src/cadre-node.ts +1282 -584
  110. package/src/control-cohort.ts +86 -9
  111. package/src/control-database.ts +848 -403
  112. package/src/control-formation-recorder.ts +359 -353
  113. package/src/control-protocol-guard.ts +16 -2
  114. package/src/control-schema-version.ts +101 -51
  115. package/src/control-schema.ts +16 -16
  116. package/src/control-write-retry.ts +91 -19
  117. package/src/delegate-admission.ts +351 -351
  118. package/src/enrolled-machine-store.ts +3 -3
  119. package/src/formation-token.ts +63 -0
  120. package/src/index.ts +30 -17
  121. package/src/joined-strand-store.ts +69 -24
  122. package/src/link-budget.ts +9 -7
  123. package/src/member-redemption.ts +20 -13
  124. package/src/membership-view.ts +144 -0
  125. package/src/owner-grant-protocol.ts +33 -18
  126. package/src/peer-addr-book.ts +4 -3
  127. package/src/peer-join-backfill.ts +971 -761
  128. package/src/pending-join-runner.ts +76 -53
  129. package/src/seed-bootstrap.ts +72 -67
  130. package/src/strand-database.ts +352 -322
  131. package/src/strand-first-sync-gate.ts +334 -134
  132. package/src/strand-formation-deadlines.ts +227 -227
  133. package/src/strand-formation-manager.ts +731 -725
  134. package/src/strand-formation-protocol.ts +122 -24
  135. package/src/strand-instance-manager.ts +2092 -2026
  136. package/src/strand-membership-reconciler.ts +33 -25
  137. package/src/strand-membership-writer.ts +2093 -2019
  138. package/src/strand-move.ts +5 -2
  139. package/src/strand-revocation-enforcer.ts +740 -654
  140. package/src/strand-solicitation.ts +61 -34
  141. package/src/strand-watcher.ts +175 -56
  142. package/src/table-change-watch.ts +285 -0
  143. package/src/timeout-scheduler.ts +29 -28
  144. package/src/types.ts +89 -51
@@ -0,0 +1,144 @@
1
+ /**
2
+ * An in-memory copy of one small control table's rows, kept until the table changes, so the
3
+ * membership, owner, peer-record and admission-chain reads that every flow repeats cost nothing
4
+ * after the first.
5
+ * `ControlDatabase` keeps one per cached table and drops it from its table-change subscription
6
+ * (`docs/architecture.md` → "Membership rows view"); this class holds only the caching rules, so
7
+ * they are testable without a database.
8
+ */
9
+ /**
10
+ * How long installed rows are served before the next read reloads them, change or no change.
11
+ * The reload is the only path for a change that reaches this node with no storage event: a
12
+ * machine outside the block's cohort (a cadre larger than its replication breadth), or one in
13
+ * the cohort that the writer could not reach when it committed (one side of a NAT pair; the
14
+ * cohort auto-connect scenarios). Longer than Optimystic's read-repair window
15
+ * ({@link OPTIMYSTIC_READ_REPAIR_WINDOW_MS}), so the reload's storage read starts the cohort
16
+ * consult that brings such a change in instead of being served from the local copy, and
17
+ * shorter than any reconcile backstop (`DEFAULT_CONTROL_COHORT_RECONCILE_MS`, 2 minutes), so
18
+ * the backstop pass, the only reader on an idle party, reloads rather than serving kept rows.
19
+ * The reload rate on an idle party is therefore the pass rate, and the staleness bound for
20
+ * such a change is the backstop, not this value.
21
+ *
22
+ * A machine that was offline when a sibling committed is covered sooner than either:
23
+ * `CadreNode` drops every view when a sibling becomes reachable (its identify on a new
24
+ * connection; `ControlDatabase.invalidateMembershipViews`), so the pass that starts then reads
25
+ * storage.
26
+ */
27
+ export const MEMBERSHIP_VIEW_MAX_AGE_MS = 15_000;
28
+
29
+ /**
30
+ * Optimystic's lazy read-repair window (`clusterPolicy.readRepairWindowMs`, which cadre does
31
+ * not set, so this is Optimystic's default): a read of a held block checked less than this
32
+ * long ago is served from the local copy without asking the cohort. A read after it is
33
+ * served locally too, but starts a cohort consult whose landing raises a table change.
34
+ *
35
+ * NOTE: a copy, since db-p2p exports no default for it. If cadre ever sets
36
+ * `readRepairWindowMs` in its cluster policy, derive this from that value.
37
+ */
38
+ const OPTIMYSTIC_READ_REPAIR_WINDOW_MS = 10_000;
39
+
40
+ /**
41
+ * How long after a sibling becomes reachable (its identify on a new connection) `CadreNode`
42
+ * drops the views a second time: one read-repair window, plus a second for timer slop. The
43
+ * drop at the identify is not enough by itself. A node's reads before the sibling arrived
44
+ * (its start pass, with no cohort to ask) stamp the window, so the reload the identify
45
+ * starts is served from the local copy without a consult and the view would keep the
46
+ * pre-connection rows until {@link MEMBERSHIP_VIEW_MAX_AGE_MS}. The reload past the window
47
+ * starts the consults that bring in what the sibling committed while this node was away.
48
+ */
49
+ export const MEMBERSHIP_VIEW_RECONNECT_RELOAD_MS = OPTIMYSTIC_READ_REPAIR_WINDOW_MS + 1_000;
50
+
51
+ /** One load's rows, and whether the view may keep them. */
52
+ export interface MembershipViewLoad<Row> {
53
+ rows: readonly Row[];
54
+ /**
55
+ * False when no later change event is guaranteed to reach the view for these rows (the
56
+ * read that produced them did not arm the table's change bridge), so they serve only the
57
+ * callers of this load.
58
+ */
59
+ installable: boolean;
60
+ }
61
+
62
+ /**
63
+ * Reads the whole table. `retry` is the caller's: false from a caller holding the control
64
+ * write lock, whose read must not sleep a retry backoff.
65
+ */
66
+ export type MembershipViewLoader<Row> = (retry: boolean) => Promise<MembershipViewLoad<Row>>;
67
+
68
+ export interface MembershipViewOptions {
69
+ /** Default {@link MEMBERSHIP_VIEW_MAX_AGE_MS}. */
70
+ maxAgeMs?: number;
71
+ /** Monotonic clock in milliseconds; default `performance.now`. */
72
+ now?: () => number;
73
+ }
74
+
75
+ export class MembershipView<Row> {
76
+ private rows: readonly Row[] | undefined;
77
+ /** When the load that produced {@link rows} started: the rows are no older than this. */
78
+ private loadedAt = 0;
79
+ /** Bumped by every {@link invalidate}; a load installs only if it is unchanged since the load began. */
80
+ private generation = 0;
81
+ /**
82
+ * The load each `retry` flag's callers share, current generation only. Separate per flag
83
+ * so a caller under the write lock never awaits a retrying load's backoff sleep.
84
+ */
85
+ private readonly inFlight = new Map<boolean, Promise<readonly Row[]>>();
86
+ private readonly maxAgeMs: number;
87
+ private readonly now: () => number;
88
+
89
+ constructor(private readonly loader: MembershipViewLoader<Row>, options: MembershipViewOptions = {}) {
90
+ this.maxAgeMs = options.maxAgeMs ?? MEMBERSHIP_VIEW_MAX_AGE_MS;
91
+ this.now = options.now ?? (() => performance.now());
92
+ }
93
+
94
+ /**
95
+ * The table's rows: the installed copy while it is younger than the maximum age, otherwise a
96
+ * load, shared with every caller of the same `retry` flag that arrives while it runs. A load
97
+ * that raced an {@link invalidate} answers its own callers with what it read, as an uncached
98
+ * read would have, and keeps nothing. A failed load keeps nothing and rejects.
99
+ */
100
+ read(retry: boolean): Promise<readonly Row[]> {
101
+ if (this.rows !== undefined && this.now() - this.loadedAt < this.maxAgeMs) {
102
+ return Promise.resolve(this.rows);
103
+ }
104
+ return this.inFlight.get(retry) ?? this.startLoad(retry);
105
+ }
106
+
107
+ /** Read the table without serving, joining or keeping anything. */
108
+ async readThrough(retry: boolean): Promise<readonly Row[]> {
109
+ return (await this.loader(retry)).rows;
110
+ }
111
+
112
+ /**
113
+ * Drop the installed rows and every load in flight, so the next read loads afresh and no
114
+ * earlier load installs. Synchronous and flag-setting only: the table-change handler calls
115
+ * it inside the committing call.
116
+ */
117
+ invalidate(): void {
118
+ this.generation++;
119
+ this.rows = undefined;
120
+ this.inFlight.clear();
121
+ }
122
+
123
+ private startLoad(retry: boolean): Promise<readonly Row[]> {
124
+ const load = this.load(retry, this.generation, this.now());
125
+ this.inFlight.set(retry, load);
126
+ const forget = (): void => {
127
+ if (this.inFlight.get(retry) === load) {
128
+ this.inFlight.delete(retry);
129
+ }
130
+ };
131
+ // Both arms handled, so a failed load rejects only for the callers awaiting it.
132
+ void load.then(forget, forget);
133
+ return load;
134
+ }
135
+
136
+ private async load(retry: boolean, generation: number, startedAt: number): Promise<readonly Row[]> {
137
+ const { rows, installable } = await this.loader(retry);
138
+ if (installable && generation === this.generation) {
139
+ this.rows = rows;
140
+ this.loadedAt = startedAt;
141
+ }
142
+ return rows;
143
+ }
144
+ }
@@ -368,7 +368,7 @@ export function verifyOwnerGrantReply(
368
368
  // ── Member side (handler) ────────────────────────────────────────────────────
369
369
 
370
370
  /** The control-database surface a redemption needs; a `ControlDatabase` satisfies it. */
371
- export type OwnerGrantStore = Pick<ControlDatabase, 'seatOwnerGrant' | 'isOwnerGrantLive' | 'redeemOwnerGrant'>;
371
+ export type OwnerGrantStore = Pick<ControlDatabase, 'queryOwnerGrant' | 'seatOwnerGrant' | 'isOwnerGrantLive' | 'redeemOwnerGrant'>;
372
372
 
373
373
  export interface OwnerGrantHandlerOptions extends MemberRedemptionHandlerOptions {
374
374
  store: OwnerGrantStore;
@@ -385,10 +385,11 @@ const REDEEM_CONSTRAINT_CODES: ReadonlyArray<ConstraintRefusalRow<OwnerGrantReje
385
385
  * Member side of the grant protocol (`MemberRedemptionHandler` has the shape): after the
386
386
  * request's shape, it checks the party and both signatures — nothing about the connecting
387
387
  * peer, whose identity is unrelated to the key it seats — then seats the row from the bundle
388
- * and redeems. An issuer this member does not know as an owner is the one retryable refusal
389
- * left: the holder tries the next address, which may hold the issuer's row.
388
+ * when this member holds none and redeems. An issuer this member does not know as an owner is
389
+ * the one retryable refusal left: the holder tries the next address, which may hold the
390
+ * issuer's row.
390
391
  */
391
- export class OwnerGrantHandler extends MemberRedemptionHandler<OwnerGrantRedeemRequest, OwnerGrantRedeemReply> {
392
+ export class OwnerGrantHandler extends MemberRedemptionHandler<OwnerGrantRedeemRequest, OwnerGrantRedeemReply, OwnerGrantRow> {
392
393
  private readonly store: OwnerGrantStore;
393
394
  protected readonly busyRefusal = rejection('busy', 'Too many concurrent redemptions');
394
395
  protected readonly internalRefusal = rejection('internal', 'Internal redemption error');
@@ -414,25 +415,25 @@ export class OwnerGrantHandler extends MemberRedemptionHandler<OwnerGrantRedeemR
414
415
  return null;
415
416
  }
416
417
 
417
- protected isLive(request: OwnerGrantRedeemRequest): Promise<boolean> {
418
- return this.store.isOwnerGrantLive(request.grant);
418
+ protected readHeld(request: OwnerGrantRedeemRequest): Promise<OwnerGrantRow | null> {
419
+ return this.store.queryOwnerGrant(request.grant.key);
419
420
  }
420
421
 
421
- /** The two writes — the seat (its own transaction; a no-op when held) and the redemption — each answered by name when it fails. */
422
- protected async redeemAdmitted(request: OwnerGrantRedeemRequest, remotePeerId: string): Promise<OwnerGrantRedeemReply> {
423
- try {
424
- await this.store.seatOwnerGrant(request.grant);
425
- } catch (error) {
426
- if (error instanceof OwnerGrantIssuerUnknownError) {
427
- return rejection('issuer-unknown', 'The grant\'s issuer is not known as an owner here');
428
- }
429
- log('seating grant %s from %s failed: %o', request.grant.key, remotePeerId, error);
430
- return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
422
+ protected isLive(request: OwnerGrantRedeemRequest, held: OwnerGrantRow | null): Promise<boolean> {
423
+ return this.store.isOwnerGrantLive(request.grant, held);
424
+ }
425
+
426
+ /** The two writes — the seat only when this member holds no row, and the redemption, carrying the held row — as `CadreInviteHandler.redeemAdmitted`. */
427
+ protected async redeemAdmitted(request: OwnerGrantRedeemRequest, remotePeerId: string, held: OwnerGrantRow | null): Promise<OwnerGrantRedeemReply> {
428
+ if (held === null) {
429
+ const refused = await this.seat(request, remotePeerId);
430
+ if (refused) return refused;
431
431
  }
432
432
  let alreadyOwner: boolean;
433
433
  try {
434
434
  const result = await this.store.redeemOwnerGrant({
435
435
  grantKey: request.grant.key,
436
+ ...(held ? { held } : {}),
436
437
  seatedKey: request.ownerKey,
437
438
  usageStampId: request.usageStampId,
438
439
  grantSig: request.grantSig,
@@ -447,8 +448,22 @@ export class OwnerGrantHandler extends MemberRedemptionHandler<OwnerGrantRedeemR
447
448
  log('redeeming grant %s for %s failed: %o', request.grant.key, request.ownerKey, error);
448
449
  return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
449
450
  }
450
- // The row as held: the seat inserted this copy verbatim, or found the same row.
451
- return { accepted: true, partyId: this.partyId, grant: request.grant, alreadyOwner };
451
+ // The row as held: read before the write, or the bundle's copy the seat inserted verbatim or found.
452
+ return { accepted: true, partyId: this.partyId, grant: held ?? request.grant, alreadyOwner };
453
+ }
454
+
455
+ /** Seat the bundle's copy of the row; a refusal answered by name, or null once it is held. */
456
+ private async seat(request: OwnerGrantRedeemRequest, remotePeerId: string): Promise<OwnerGrantRejection | null> {
457
+ try {
458
+ await this.store.seatOwnerGrant(request.grant);
459
+ return null;
460
+ } catch (error) {
461
+ if (error instanceof OwnerGrantIssuerUnknownError) {
462
+ return rejection('issuer-unknown', 'The grant\'s issuer is not known as an owner here');
463
+ }
464
+ log('seating grant %s from %s failed: %o', request.grant.key, remotePeerId, error);
465
+ return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
466
+ }
452
467
  }
453
468
  }
454
469
 
@@ -4,7 +4,7 @@
4
4
  * — Optimystic's cluster client, repo client, FRET ping/announce, all funnelling
5
5
  * into `libp2p.dialProtocol(peerId, …)` — has an address to use for a sibling
6
6
  * whose connection has dropped, instead of failing with `NoValidAddressesError`
7
- * until the next reconcile pass happens to re-dial it.
7
+ * until the reconcile pass the drop starts re-dials it.
8
8
  *
9
9
  * Shared by the control-cohort reconcile pass and the strand one (both in
10
10
  * `CadreNode`): each resolves a peer's addresses its own way and then needs
@@ -190,9 +190,10 @@ function lastAddressedPeerId(parsed: Multiaddr): string | null {
190
190
  * that id is the address's FIRST `/p2p/` component (`dedupe-addresses.js`), so a
191
191
  * plain address round-trips without its suffix while a relayed one — whose first
192
192
  * `/p2p/` names the relay — keeps it. Comparing and de-duplicating on this
193
- * suffix-stripped form matches either shape.
193
+ * suffix-stripped form matches either shape. `CadreNode.claimNode` compares a QR
194
+ * code's addresses with a connection's remote address on it for the same reason.
194
195
  */
195
- function addrKey(addr: string, peerId: PeerId): string {
196
+ export function addrKey(addr: string, peerId: PeerId): string {
196
197
  const suffix = `/p2p/${peerId.toString()}`;
197
198
  return addr.endsWith(suffix) ? addr.slice(0, -suffix.length) : addr;
198
199
  }