@serfab/cadre-core 1.17.0 → 1.19.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 (148) 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 +1182 -566
  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 +15 -14
  35. package/dist/index.js +17 -14
  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 +96 -30
  56. package/dist/peer-join-backfill.js +272 -128
  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/self-address-update.d.ts +13 -0
  65. package/dist/self-address-update.js +19 -0
  66. package/dist/self-address-update.js.map +1 -0
  67. package/dist/strand-database.d.ts +23 -2
  68. package/dist/strand-database.js +26 -6
  69. package/dist/strand-database.js.map +1 -1
  70. package/dist/strand-first-sync-gate.d.ts +176 -89
  71. package/dist/strand-first-sync-gate.js +288 -122
  72. package/dist/strand-first-sync-gate.js.map +1 -1
  73. package/dist/strand-formation-deadlines.d.ts +9 -9
  74. package/dist/strand-formation-deadlines.js +9 -9
  75. package/dist/strand-formation-manager.d.ts +23 -17
  76. package/dist/strand-formation-manager.js +46 -44
  77. package/dist/strand-formation-manager.js.map +1 -1
  78. package/dist/strand-formation-protocol.d.ts +5 -8
  79. package/dist/strand-formation-protocol.js +101 -16
  80. package/dist/strand-formation-protocol.js.map +1 -1
  81. package/dist/strand-instance-manager.d.ts +26 -17
  82. package/dist/strand-instance-manager.js +119 -50
  83. package/dist/strand-instance-manager.js.map +1 -1
  84. package/dist/strand-membership-reconciler.d.ts +16 -13
  85. package/dist/strand-membership-reconciler.js +28 -22
  86. package/dist/strand-membership-reconciler.js.map +1 -1
  87. package/dist/strand-membership-writer.d.ts +40 -28
  88. package/dist/strand-membership-writer.js +212 -153
  89. package/dist/strand-membership-writer.js.map +1 -1
  90. package/dist/strand-move.js +5 -2
  91. package/dist/strand-move.js.map +1 -1
  92. package/dist/strand-revocation-enforcer.d.ts +134 -60
  93. package/dist/strand-revocation-enforcer.js +144 -71
  94. package/dist/strand-revocation-enforcer.js.map +1 -1
  95. package/dist/strand-solicitation.d.ts +50 -39
  96. package/dist/strand-solicitation.js +10 -3
  97. package/dist/strand-solicitation.js.map +1 -1
  98. package/dist/strand-watcher.d.ts +80 -13
  99. package/dist/strand-watcher.js +152 -48
  100. package/dist/strand-watcher.js.map +1 -1
  101. package/dist/table-change-watch.d.ts +63 -0
  102. package/dist/table-change-watch.js +209 -0
  103. package/dist/table-change-watch.js.map +1 -0
  104. package/dist/timeout-scheduler.d.ts +4 -3
  105. package/dist/timeout-scheduler.js +4 -3
  106. package/dist/timeout-scheduler.js.map +1 -1
  107. package/dist/types.d.ts +90 -49
  108. package/dist/types.js +4 -4
  109. package/dist/types.js.map +1 -1
  110. package/package.json +8 -8
  111. package/src/cadre-invite-protocol.ts +119 -31
  112. package/src/cadre-node.ts +1289 -588
  113. package/src/control-cohort.ts +86 -9
  114. package/src/control-database.ts +848 -403
  115. package/src/control-formation-recorder.ts +359 -353
  116. package/src/control-protocol-guard.ts +16 -2
  117. package/src/control-schema-version.ts +101 -51
  118. package/src/control-schema.ts +16 -16
  119. package/src/control-write-retry.ts +91 -19
  120. package/src/delegate-admission.ts +351 -351
  121. package/src/enrolled-machine-store.ts +3 -3
  122. package/src/formation-token.ts +63 -0
  123. package/src/index.ts +30 -18
  124. package/src/joined-strand-store.ts +69 -24
  125. package/src/link-budget.ts +9 -7
  126. package/src/member-redemption.ts +20 -13
  127. package/src/membership-view.ts +144 -0
  128. package/src/owner-grant-protocol.ts +33 -18
  129. package/src/peer-addr-book.ts +4 -3
  130. package/src/peer-join-backfill.ts +961 -761
  131. package/src/pending-join-runner.ts +76 -53
  132. package/src/seed-bootstrap.ts +72 -67
  133. package/src/self-address-update.ts +19 -0
  134. package/src/strand-database.ts +352 -322
  135. package/src/strand-first-sync-gate.ts +334 -134
  136. package/src/strand-formation-deadlines.ts +227 -227
  137. package/src/strand-formation-manager.ts +731 -725
  138. package/src/strand-formation-protocol.ts +122 -24
  139. package/src/strand-instance-manager.ts +2092 -2026
  140. package/src/strand-membership-reconciler.ts +31 -25
  141. package/src/strand-membership-writer.ts +2093 -2019
  142. package/src/strand-move.ts +5 -2
  143. package/src/strand-revocation-enforcer.ts +740 -654
  144. package/src/strand-solicitation.ts +61 -34
  145. package/src/strand-watcher.ts +175 -56
  146. package/src/table-change-watch.ts +285 -0
  147. package/src/timeout-scheduler.ts +29 -28
  148. package/src/types.ts +89 -51
@@ -0,0 +1,285 @@
1
+ /**
2
+ * One way for cadre-core to be told that a control or strand table changed, so an idle loop
3
+ * can wait for a change instead of re-reading on a timer. Built on Quereus's
4
+ * `Database.watch`, which hears both a commit made on this `Database` and, through the
5
+ * Optimystic plugin's `notifyExternalChange` bridge, every change this node's storage takes
6
+ * in from elsewhere: a consensus commit it took part in as a cohort member, a block-transfer
7
+ * push such as join backfill, and a read-repair landing. Events are table-wide; there are no
8
+ * row keys.
9
+ *
10
+ * The consumer's `onChange` never runs inside a commit. The watch handler only sets flags
11
+ * and arms a timer, and runs are single-flight: changes inside the coalescing window share
12
+ * one run, a change during a run produces exactly one follow-up run, and a consumer's own
13
+ * `trigger` (start, retry backoff) and the backstop go through the same rule.
14
+ * `docs/architecture.md` → "Table change subscriptions" says what the seam does not hear,
15
+ * which is why consumers keep a backstop.
16
+ */
17
+ import debug from 'debug';
18
+ import type { ChangeScope, Database, Subscription, TableWatch, WatchEvent } from '@quereus/quereus';
19
+ import { defaultTimeoutScheduler } from './timeout-scheduler.js';
20
+ import type { TimeoutScheduler } from './timeout-scheduler.js';
21
+
22
+ const log = debug('sereus:cadre:table-watch');
23
+
24
+ /**
25
+ * Default coalescing window. One local commit fires the watch twice (Quereus's own
26
+ * post-commit dispatch, then the plugin's storage event); this folds both into one run.
27
+ */
28
+ export const TABLE_CHANGE_COALESCE_MS = 50;
29
+
30
+ export interface TableChangeWatchOptions {
31
+ /** Short label for log lines (e.g. 'strand-watcher'). */
32
+ label: string;
33
+ /** Coalescing window after the first change; default {@link TABLE_CHANGE_COALESCE_MS}. */
34
+ coalesceMs?: number;
35
+ /**
36
+ * Run anyway this long after the last run settled, change or not. Omit for none.
37
+ * The path for a change that arrives without a storage event (see the module doc).
38
+ */
39
+ backstopMs?: number;
40
+ /**
41
+ * Called inline, synchronously, from the watch handler — inside the committing call,
42
+ * possibly with the control write lock held — once per changed table per event, with the
43
+ * name as the consumer passed it. Only for setting a flag (cache invalidation). Must not
44
+ * read, write or await.
45
+ */
46
+ onInvalidate?: (qualifiedTable: string) => void;
47
+ /** Timer seam; default real, unref'd timers. */
48
+ scheduler?: TimeoutScheduler;
49
+ }
50
+
51
+ export interface TableChangeWatch {
52
+ /** Schedule a run after `delayMs` (default 0) through the same single-flight rule; an earlier pending run wins. */
53
+ trigger(delayMs?: number): void;
54
+ /** Stop: unsubscribe, clear timers; no run starts afterwards. Idempotent. Does not wait for a run in flight. */
55
+ dispose(): void;
56
+ }
57
+
58
+ /**
59
+ * Subscribe `onChange` to changes in `tables` (qualified `'Schema.Table'` names, e.g.
60
+ * `'CadreControl.Strand'`). Throws synchronously on a name that is not qualified, or a table
61
+ * that does not exist.
62
+ *
63
+ * NOTE: the plugin arms its storage bridge for a table on that table's first full
64
+ * initialization on this `Database`, so a table not yet touched hears local commits only,
65
+ * not replicated ones. Consumers' start runs touch their tables; the seam does not, because
66
+ * touching a never-written collection consults the network.
67
+ *
68
+ * NOTE: Quereus silently disposes a subscription whose table is dropped or altered. Control
69
+ * and strand schemas are fixed for a database's life (an app-schema attach adds `App` tables
70
+ * and alters no `Strand` one), so nothing re-subscribes; revisit if that stops holding.
71
+ */
72
+ export function watchTables(
73
+ db: Database,
74
+ tables: readonly string[],
75
+ onChange: () => Promise<void>,
76
+ options: TableChangeWatchOptions,
77
+ ): TableChangeWatch {
78
+ return new TableChangeWatcher({ db, tables }, onChange, options);
79
+ }
80
+
81
+ /**
82
+ * {@link watchTables}' schedule — single-flight runs, `trigger`, the backstop — with no table
83
+ * subscription, for a consumer with no database to watch (tests, fakes).
84
+ */
85
+ export function scheduleRuns(onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch {
86
+ return new TableChangeWatcher(undefined, onChange, options);
87
+ }
88
+
89
+ /**
90
+ * The subscriptions one database owner has handed out, so its `close()` can dispose those
91
+ * still live — synchronously, ahead of its own awaits, so nothing a closing commit does
92
+ * schedules a run. (`Database.close()` disposes them too, but only after its own awaits.)
93
+ */
94
+ export class TableWatchRegistry {
95
+ private readonly live = new Set<TableChangeWatch>();
96
+
97
+ watch(
98
+ db: Database,
99
+ tables: readonly string[],
100
+ onChange: () => Promise<void>,
101
+ options: TableChangeWatchOptions,
102
+ ): TableChangeWatch {
103
+ const inner = watchTables(db, tables, onChange, options);
104
+ const tracked: TableChangeWatch = {
105
+ trigger: delayMs => inner.trigger(delayMs),
106
+ dispose: () => {
107
+ this.live.delete(tracked);
108
+ inner.dispose();
109
+ },
110
+ };
111
+ this.live.add(tracked);
112
+ return tracked;
113
+ }
114
+
115
+ disposeAll(): void {
116
+ for (const watch of [...this.live]) {
117
+ watch.dispose();
118
+ }
119
+ }
120
+ }
121
+
122
+ /** One armed timer; an object of its own so the scheduler's handles need not be unique. */
123
+ interface Wake {
124
+ handle: unknown;
125
+ }
126
+
127
+ /** The tables a {@link TableChangeWatcher} subscribes to; absent for {@link scheduleRuns}. */
128
+ interface WatchedTables {
129
+ db: Database;
130
+ tables: readonly string[];
131
+ }
132
+
133
+ class TableChangeWatcher implements TableChangeWatch {
134
+ private readonly subscription: Subscription | undefined;
135
+ /** The consumer's name for each watched table, by Quereus's lowercased `schema.table` key. */
136
+ private readonly names = new Map<string, string>();
137
+ private readonly scheduler: TimeoutScheduler;
138
+ private readonly coalesceMs: number;
139
+ /**
140
+ * Every armed timer: the coalescing window, `trigger` delays and the backstop. Whichever
141
+ * fires first starts the run, and starting a run clears the rest.
142
+ */
143
+ private readonly wakes = new Set<Wake>();
144
+ /** The coalescing window, while armed: a change joins it instead of extending it. */
145
+ private coalesceWake: Wake | undefined;
146
+ private running = false;
147
+ /** A timer fired while a run was in flight: one more run, a window after it settles. */
148
+ private rerun = false;
149
+ private disposed = false;
150
+ private warnedThenable = false;
151
+
152
+ constructor(
153
+ watched: WatchedTables | undefined,
154
+ private readonly onChange: () => Promise<void>,
155
+ private readonly options: TableChangeWatchOptions,
156
+ ) {
157
+ this.scheduler = options.scheduler ?? defaultTimeoutScheduler;
158
+ this.coalesceMs = options.coalesceMs ?? TABLE_CHANGE_COALESCE_MS;
159
+ this.subscription = watched && this.subscribe(watched);
160
+ this.armBackstop();
161
+ }
162
+
163
+ trigger(delayMs = 0): void {
164
+ if (!this.disposed) {
165
+ this.arm(delayMs);
166
+ }
167
+ }
168
+
169
+ dispose(): void {
170
+ if (this.disposed) {
171
+ return;
172
+ }
173
+ this.disposed = true;
174
+ this.subscription?.unsubscribe();
175
+ this.clearWakes();
176
+ }
177
+
178
+ private subscribe({ db, tables }: WatchedTables): Subscription {
179
+ const watches = tables.map(fullTableWatch);
180
+ watches.forEach((watch, i) => this.names.set(tableKey(watch), tables[i]));
181
+ const scope: ChangeScope = { watches, nonDeterministicSources: [], unboundParameters: [] };
182
+ // Returns undefined, so Quereus never awaits consumer work inside a commit.
183
+ return db.watch(scope, event => this.handleChange(event));
184
+ }
185
+
186
+ /** Runs inside the committing call, possibly under the control write lock: flags and a timer only. */
187
+ private handleChange(event: WatchEvent): void {
188
+ if (this.disposed) {
189
+ return;
190
+ }
191
+ this.coalesceWake ??= this.arm(this.coalesceMs);
192
+ for (const { watch } of event.matched) {
193
+ this.invalidate(this.names.get(tableKey(watch)) ?? tableKey(watch));
194
+ }
195
+ }
196
+
197
+ private invalidate(table: string): void {
198
+ let result: unknown;
199
+ try {
200
+ result = this.options.onInvalidate?.(table);
201
+ } catch (err) {
202
+ log('%s: onInvalidate threw for %s: %O', this.options.label, table, err);
203
+ return;
204
+ }
205
+ if (!this.warnedThenable && isThenable(result)) {
206
+ this.warnedThenable = true;
207
+ log('%s: misuse — onInvalidate returned a promise for %s; it is never awaited, and must only set a flag', this.options.label, table);
208
+ }
209
+ }
210
+
211
+ private arm(delayMs: number): Wake {
212
+ const wake: Wake = { handle: undefined };
213
+ wake.handle = this.scheduler.setTimeout(() => this.fire(wake), delayMs);
214
+ this.wakes.add(wake);
215
+ return wake;
216
+ }
217
+
218
+ private armBackstop(): void {
219
+ if (this.options.backstopMs !== undefined) {
220
+ this.arm(this.options.backstopMs);
221
+ }
222
+ }
223
+
224
+ private clearWakes(): void {
225
+ for (const wake of this.wakes) {
226
+ this.scheduler.clearTimeout(wake.handle);
227
+ }
228
+ this.wakes.clear();
229
+ this.coalesceWake = undefined;
230
+ }
231
+
232
+ private fire(wake: Wake): void {
233
+ if (!this.wakes.delete(wake)) {
234
+ return;
235
+ }
236
+ if (wake === this.coalesceWake) {
237
+ this.coalesceWake = undefined;
238
+ }
239
+ if (this.running) {
240
+ this.rerun = true;
241
+ return;
242
+ }
243
+ this.clearWakes();
244
+ this.rerun = false;
245
+ this.running = true;
246
+ void this.run();
247
+ }
248
+
249
+ private async run(): Promise<void> {
250
+ try {
251
+ await this.onChange();
252
+ } catch (err) {
253
+ log('%s: change run failed: %O', this.options.label, err);
254
+ }
255
+ this.running = false;
256
+ this.settled();
257
+ }
258
+
259
+ private settled(): void {
260
+ if (this.disposed) {
261
+ return;
262
+ }
263
+ if (this.rerun) {
264
+ this.coalesceWake ??= this.arm(this.coalesceMs);
265
+ }
266
+ this.armBackstop();
267
+ }
268
+ }
269
+
270
+ function fullTableWatch(qualifiedTable: string): TableWatch {
271
+ const [schema, table, ...rest] = qualifiedTable.split('.');
272
+ if (!schema || !table || rest.length > 0) {
273
+ throw new Error(`watchTables: '${qualifiedTable}' is not a qualified 'Schema.Table' name`);
274
+ }
275
+ return { table: { schema, table }, columns: 'all', scope: { kind: 'full' } };
276
+ }
277
+
278
+ /** Quereus's own matching key for a watched table. */
279
+ function tableKey(watch: TableWatch): string {
280
+ return `${watch.table.schema}.${watch.table.table}`.toLowerCase();
281
+ }
282
+
283
+ function isThenable(value: unknown): boolean {
284
+ return typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function';
285
+ }
@@ -1,28 +1,29 @@
1
- /**
2
- * The timer seam shared by the strand bring-up loops that re-arm themselves one step at a
3
- * time — the first-sync write gate's probe loop (`strand-first-sync-gate.ts`) and the
4
- * membership reconciler's retry ladder (`strand-membership-reconciler.ts`). `setTimeout`
5
- * rather than a repeating interval because the next step is armed only once the previous
6
- * one settles, so a slow network read never stacks steps behind it, and because the delay
7
- * can differ per step.
8
- *
9
- * Tests inject a hand-cranked clock; production omits it and gets real timers, `unref`'d so
10
- * a loop that is only waiting never holds a Node process open (the call is optional —
11
- * browsers and React Native return a plain handle with no `unref`).
12
- */
13
-
14
- /** Timer seam for a self-rescheduling loop; omit for real (unref'd) timeouts. */
15
- export interface TimeoutScheduler {
16
- setTimeout(fn: () => void, ms: number): unknown;
17
- clearTimeout(handle: unknown): void;
18
- }
19
-
20
- /** Real timers, `unref`'d where the runtime supports it. */
21
- export const defaultTimeoutScheduler: TimeoutScheduler = {
22
- setTimeout: (fn, ms) => {
23
- const handle = setTimeout(fn, ms);
24
- (handle as { unref?: () => void }).unref?.();
25
- return handle;
26
- },
27
- clearTimeout: (handle) => clearTimeout(handle as Parameters<typeof clearTimeout>[0])
28
- };
1
+ /**
2
+ * The timer seam shared by the loops that re-arm themselves one step at a time — the
3
+ * membership reconciler's retry ladder (`strand-membership-reconciler.ts`) — and by the table
4
+ * change subscription's coalescing, trigger and backstop timers (`table-change-watch.ts`), on
5
+ * which the first-sync write gate's probes run (`strand-first-sync-gate.ts`). `setTimeout`
6
+ * rather than a repeating interval because the next step is armed only once the previous
7
+ * one settles, so a slow network read never stacks steps behind it, and because the delay
8
+ * can differ per step.
9
+ *
10
+ * Tests inject a hand-cranked clock; production omits it and gets real timers, `unref`'d so
11
+ * a loop that is only waiting never holds a Node process open (the call is optional —
12
+ * browsers and React Native return a plain handle with no `unref`).
13
+ */
14
+
15
+ /** Timer seam for a self-rescheduling loop; omit for real (unref'd) timeouts. */
16
+ export interface TimeoutScheduler {
17
+ setTimeout(fn: () => void, ms: number): unknown;
18
+ clearTimeout(handle: unknown): void;
19
+ }
20
+
21
+ /** Real timers, `unref`'d where the runtime supports it. */
22
+ export const defaultTimeoutScheduler: TimeoutScheduler = {
23
+ setTimeout: (fn, ms) => {
24
+ const handle = setTimeout(fn, ms);
25
+ (handle as { unref?: () => void }).unref?.();
26
+ return handle;
27
+ },
28
+ clearTimeout: (handle) => clearTimeout(handle as Parameters<typeof clearTimeout>[0])
29
+ };
package/src/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ConnectionGater, Libp2p, PeerId, PrivateKey } from '@libp2p/interface';
2
2
  import type { CircuitRelayServerInit } from '@libp2p/circuit-relay-v2';
3
3
  import type { IRawStorage, Libp2pConnectionMonitorInit, Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
4
- import type { IPeerNetwork, IRepo } from '@optimystic/db-core';
4
+ import type { CollectionChangeListener, IPeerNetwork, IRepo } from '@optimystic/db-core';
5
5
  import type { PeerJoinBackfillConfig } from './peer-join-backfill.js';
6
6
  import type { StrandReactivityConfig } from './strand-reactivity.js';
7
7
  import type { StrandRevocationEnforcementConfig } from './strand-revocation-enforcer.js';
@@ -18,6 +18,7 @@ import type { EnrolledMachineStore } from './enrolled-machine-store.js';
18
18
  import type { JoinedStrandStore } from './joined-strand-store.js';
19
19
  import type { PushNotifier } from './push-notifier.js';
20
20
  import type { CadreInviteSignedFields, OwnerGrantSignedFields, RevocableTable } from './control-authorization.js';
21
+ import type { FoundingInvite } from './control-database.js';
21
22
  import type { CadreInvitation } from './cadre-invite-protocol.js';
22
23
  import type { OwnerGrantBundle } from './owner-grant-protocol.js';
23
24
  import type { RedemptionAddressOutcome } from './member-redemption.js';
@@ -39,6 +40,21 @@ export interface Libp2pNodeWithRepo extends Libp2p {
39
40
  * backfill (`peer-join-backfill.ts`) logs once and stays inert when it is absent.
40
41
  */
41
42
  keyNetwork?: IPeerNetwork;
43
+ /**
44
+ * The node's block store, attached by the same factory (Optimystic's
45
+ * `OptimysticNodeAttachments.storageRepo`), narrowed to the storage-wide change feed: it
46
+ * fires for every block that lands in this node's store, a push or a commit. Optional for
47
+ * the same reason as `keyNetwork`; the first-sync gate (`strand-first-sync-gate.ts`) wakes
48
+ * on it, and logs once and probes on its backstop alone when it is absent.
49
+ */
50
+ storageRepo?: { onAnyCollectionChange(listener: CollectionChangeListener): () => void };
51
+ /**
52
+ * Tells the node a peer holds a block at a revision or later (Optimystic's
53
+ * `OptimysticNodeAttachments.reportBlockHeld`), so its under-replication drain and rebalance
54
+ * monitor do not push that peer the same block again. Optional for the same reason as
55
+ * `keyNetwork`; the join catch-up (`peer-join-backfill.ts`) reports each block a peer accepted.
56
+ */
57
+ reportBlockHeld?: (blockId: string, rev: number, peerId: string) => Promise<void>;
42
58
  }
43
59
 
44
60
  /**
@@ -578,8 +594,13 @@ export interface NetworkConfig {
578
594
  */
579
595
  targetDegree?: number;
580
596
  /**
581
- * Recurring reconcile cadence in ms. Defaults to
582
- * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS}.
597
+ * The reconcile pass's backstop in ms: a pass runs this long after the last one
598
+ * settled when nothing has started one sooner (a membership table change, a sibling
599
+ * connecting or disconnecting, an address change, a redial or strand-address wake).
600
+ * It also caps the redial ladder. Defaults to
601
+ * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS} (2 minutes). The membership rows view's
602
+ * maximum age (`MEMBERSHIP_VIEW_MAX_AGE_MS`, 15 s) does not follow this field: a backstop
603
+ * longer than it reads storage on every backstop pass, a shorter one every 15 s at most.
583
604
  */
584
605
  reconcileMs?: number;
585
606
  /**
@@ -648,10 +669,10 @@ export interface NetworkConfig {
648
669
  * pinging node's monitor sees the reset and aborts the connection exactly as it would on its
649
670
  * own timeout. So this deadline is only real up to the handler timeout of the node being
650
671
  * pinged, which has to stay above it. The deployed relay sets its handler to 60 s for that
651
- * reason (`ops/docker/libp2p-infra/src/main.ts`). Every cadre node's handler is Optimystic's
652
- * bare `ping()`, `@libp2p/ping`'s 10 s, and nothing here can move it
653
- * (`tickets/blocked/optimystic-ping-handler-timeout-below-the-monitor-deadline.md`): between
654
- * two cadre nodes, a ping whose echo takes longer than 10 s still drops the connection.
672
+ * reason (`ops/docker/libp2p-infra/src/main.ts`). Every cadre node's handler is Optimystic's,
673
+ * which derives it from this object: twice `pingTimeout.maxTimeout`, so 60 s
674
+ * (`NodeOptions.pingHandlerTimeoutMs`, unset here, since Optimystic 1.16.0). An app that
675
+ * replaces this object without a `pingTimeout` gets `@libp2p/ping`'s 10 s handler back.
655
676
  *
656
677
  * WHAT THE LINK NEEDS OF IT. One ping opens a fresh stream and echoes over it: a protocol
657
678
  * negotiation plus the echo, two link round trips by `link-budget.ts`'s counts (a `newStream`
@@ -845,10 +866,11 @@ export interface CadreNodeConfig {
845
866
 
846
867
  /**
847
868
  * Tuning for the strand peer-join block catch-up (see `peer-join-backfill.ts`):
848
- * when a strand's libp2p node connects to a peer this runtime has not yet
849
- * caught up, every block in the strand's own raw store is pushed to that peer,
850
- * so a machine that joined the strand after blocks were committed still ends
851
- * up physically holding them. Applies to every strand this node starts.
869
+ * when a strand's libp2p node connects (or reconnects) to a peer, every
870
+ * block in the strand's own raw store that the peer is not
871
+ * known to hold is pushed to it, each block once per peer, so a machine that
872
+ * joined the strand after blocks were committed still ends up physically
873
+ * holding them. Applies to every strand this node starts.
852
874
  * Omit for the defaults (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }`
853
875
  * restores the no-backfill behaviour. The control network runs its own,
854
876
  * separately tuned catch-up — see {@link controlBackfill}.
@@ -877,13 +899,14 @@ export interface CadreNodeConfig {
877
899
  /**
878
900
  * Tuning for the CONTROL network's peer-join block catch-up (same module,
879
901
  * `peer-join-backfill.ts`, wired in `CadreNode.start`): when the control
880
- * libp2p node connects to an AUTHORIZED party member this runtime has not yet
881
- * caught up, every block in the control database's own raw store is pushed to
882
- * it. This is what makes a control block committed while the writer was alone
883
- * (collection headers written at genesis above all) physically reach members
884
- * that joined later — without it, such a member that restarts offline reads
885
- * the affected tables as empty. Pushes are gated on
886
- * `CadreNode.isAuthorizedMember`, checked at push time, because the control
902
+ * libp2p node connects (or reconnects) to an AUTHORIZED party member, every
903
+ * block in the control database's own raw store that the
904
+ * member is not known to hold is pushed to it. This is what makes a control
905
+ * block committed while the writer was alone (collection headers written at
906
+ * genesis above all) physically reach members that joined later — without it,
907
+ * such a member that restarts offline reads the affected tables as empty.
908
+ * Pushes are gated on the node's authorized-member snapshot (the rule
909
+ * `CadreNode.isAuthorizedMember` reads), checked at push time, because the control
887
910
  * network's inbound gate deliberately admits non-members in several states
888
911
  * (seed delivery, enrollment, bootstrap/relay peers). Omit for the defaults
889
912
  * (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }` disables it.
@@ -896,9 +919,10 @@ export interface CadreNodeConfig {
896
919
  * starts: each strand node materializes the peer ids bound to REMOVED members
897
920
  * (orphaned `Strand.MemberPeer` rows) and refuses them at the stream and
898
921
  * connection layers, so removing a party cuts the network to its machines
899
- * rather than only deleting its row. Open strands never arm it. Omit for the
900
- * defaults (`DEFAULT_REVOCATION_POLL_INTERVAL_MS` refresh cadence);
901
- * `{ enabled: false }` restores the pre-existing behaviour.
922
+ * rather than only deleting its row. Open strands never arm it. Each strand
923
+ * re-reads its membership when `Strand.Member` or `Strand.MemberPeer` changes,
924
+ * and at the latest every `DEFAULT_REVOCATION_BACKSTOP_MS`. Omit for the
925
+ * defaults; `{ enabled: false }` restores the pre-existing behaviour.
902
926
  */
903
927
  strandRevocationEnforcement?: StrandRevocationEnforcementConfig;
904
928
 
@@ -911,9 +935,8 @@ export interface CadreNodeConfig {
911
935
  * background without blocking bring-up. `pollIntervalMs` is the IDLE cadence
912
936
  * (how often a machine nobody has admitted re-checks) and the cap of the
913
937
  * unfinished-join retry ladder, NOT the retry cadence itself — an unfinished
914
- * join retries from 1 s, doubling. Omit for the defaults (it mirrors
915
- * {@link strandRevocationEnforcement}'s `pollIntervalMs`);
916
- * `{ enabled: false }` disarms the loop — for test fixtures that hand-drive
938
+ * join retries from 1 s, doubling. Omit for the defaults
939
+ * (`DEFAULT_MEMBERSHIP_RECONCILE_INTERVAL_MS`); `{ enabled: false }` disarms the loop — for test fixtures that hand-drive
917
940
  * the membership writers and assert exact row sets.
918
941
  */
919
942
  strandMembershipReconciliation?: StrandMembershipReconciliationConfig;
@@ -923,15 +946,17 @@ export interface CadreNodeConfig {
923
946
  * (`strand-first-sync-gate.ts`), applied to every strand this node launches as a
924
947
  * non-founder: the strand's database is withheld from the app (`StrandInstance.database`
925
948
  * unset, status `'syncing'`) until the strand's `Strand.Header` has been received from
926
- * another member and every `App` table has been read once, because a machine that writes
927
- * to a table it has never fetched creates a private copy of it that never merges.
949
+ * another member and every `Strand` and `App` table has been read once, because a
950
+ * machine that writes to a table it has never fetched creates a private copy of it that
951
+ * never merges.
928
952
  * `timeoutMs` bounds how long {@link CadreNode.addStrand} waits before rejecting with
929
- * `StrandAwaitingFirstSyncError` (retryable — the launch stays up and keeps probing);
930
- * `pollIntervalMs` is the probe cadence. Omit for the defaults (`DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`,
953
+ * `StrandAwaitingFirstSyncError` (retryable — the launch stays up and keeps probing).
954
+ * While gated the machine probes whenever data lands in the strand's store, and
955
+ * `backstopMs` after its last probe when none has. Omit for the defaults (`DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`,
931
956
  * sized for a machine re-attaching through a relay on a slow link, the slowest attach
932
957
  * measured; that constant's doc comment carries the number, the measurement behind it and
933
- * what the budget costs, and is the only copy of them; `DEFAULT_STRAND_FIRST_SYNC_POLL_MS`,
934
- * 500 ms). There is deliberately no way to disable the gate: a machine whose store already
958
+ * what the budget costs, and is the only copy of them; `DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS`,
959
+ * 2 s). There is deliberately no way to disable the gate: a machine whose store already
935
960
  * holds the Header is never gated, so nothing that works today is blocked by it.
936
961
  */
937
962
  strandFirstSync?: StrandFirstSyncConfig;
@@ -939,7 +964,11 @@ export interface CadreNodeConfig {
939
964
  /** Hibernation configuration */
940
965
  hibernation?: HibernationConfig;
941
966
 
942
- /** Polling interval for strand watcher in ms (default: 5000) */
967
+ /**
968
+ * The strand watcher's backstop in ms (default 300000): the longest it goes without
969
+ * re-reading `Strand` and `JoinedStrand` when no change to them is heard. It otherwise runs
970
+ * on a change. Also caps the first step of its retry ladders, which is 5 s otherwise.
971
+ */
943
972
  strandWatchInterval?: number;
944
973
 
945
974
  /**
@@ -1463,6 +1492,14 @@ export interface FoundStrandConfig {
1463
1492
  memberPrivateKey?: string;
1464
1493
  /** sApp configuration the hosting application provides, as for {@link StrandConfig}. */
1465
1494
  sAppConfig: SAppConfig;
1495
+ /**
1496
+ * A formation invitation to publish bound to this strand, for the app `sAppConfig.id`, in the
1497
+ * same control transaction and owner approval as the strand row. Mint it first with
1498
+ * `createOpenInvitation` (its `expiration` is `expiresAtMs`). When the strand is already
1499
+ * published the invitation is published on its own, under its own approval, as
1500
+ * `publishFormationInvite` would; one already stored under `token` for this strand is kept.
1501
+ */
1502
+ formationInvite?: Omit<FoundingInvite, 'sAppId'>;
1466
1503
  /**
1467
1504
  * Forwarded to the attach ({@link StrandConfig.awaitFirstSync}). Matters only when the
1468
1505
  * call ATTACHES rather than founds — the stored row was published by a sibling machine
@@ -1783,10 +1820,10 @@ export interface CadreNodeEvents {
1783
1820
  * row is the product of the formation's consent, so claiming it needs no second one.
1784
1821
  *
1785
1822
  * **Fired once per strand per session, and it can fire before your listener is
1786
- * attached.** The strand watcher's first poll runs inside `CadreNode.start()`
1823
+ * attached.** The strand watcher's first run is inside `CadreNode.start()`
1787
1824
  * (100 ms after the watcher starts), so every strand already stored for this
1788
1825
  * party is normally offered while the embedding app is still inside its own
1789
- * `start()` await. The watcher then records the strand as seen and no later poll
1826
+ * `start()` await. The watcher then records the strand as seen and no later run
1790
1827
  * re-offers it, so a listener attached a moment late misses those strands for the
1791
1828
  * life of the process.
1792
1829
  *
@@ -1841,8 +1878,14 @@ export interface CadreNodeEvents {
1841
1878
  'pendingJoin:changed': PendingJoinStatus;
1842
1879
  /** Emitted when a seed is received via the seed protocol */
1843
1880
  'seed:received': { partyId: string; peerId: string };
1844
- /** Emitted when a seed is successfully applied */
1845
- 'seed:applied': { partyId: string; peersAdded: number };
1881
+ /**
1882
+ * Emitted when a seed delivered over the seed protocol has been verified, merged and
1883
+ * acknowledged to its sender. The dials of its preferred peers follow this event, so it
1884
+ * waits on no connection. `dialTargets` are those peers (`seedDialTargets`): an embedder
1885
+ * that restarts into another party's stores carries them there, since this node retained
1886
+ * them in its own.
1887
+ */
1888
+ 'seed:applied': { partyId: string; peersAdded: number; dialTargets: SeedDialTarget[] };
1846
1889
  /** Emitted when seed application fails */
1847
1890
  'seed:error': { partyId: string; error: string };
1848
1891
  /**
@@ -1850,10 +1893,10 @@ export interface CadreNodeEvents {
1850
1893
  * valid claim proof has been recorded (`claim.record`) and has durably anchored its
1851
1894
  * signer as this node's owner. Carries the claim's party beside the owner key: the
1852
1895
  * party this node should serve from now on, which its own config did not know. Fires
1853
- * BEFORE the seed is acknowledged to the claimant, its peers merged and its owners
1854
- * dialed, so it precedes that seed's `seed:applied`; an embedder that restarts into
1855
- * the claimed party on this event should wait for that `seed:applied` (or `seed:error`)
1856
- * so the acknowledgement is not lost with the node. `CadreNode.isAwaitingClaim` is the
1896
+ * BEFORE the seed is acknowledged to the claimant and its peers merged, so it precedes
1897
+ * that seed's `seed:applied`; an embedder that restarts into the claimed party on this
1898
+ * event should wait for that `seed:applied` (or `seed:error`) so the acknowledgement is
1899
+ * not lost with the node. `CadreNode.isAwaitingClaim` is the
1857
1900
  * matching predicate for a reader that subscribed too late to see the event.
1858
1901
  */
1859
1902
  'claim:accepted': NodeClaimRecord;
@@ -2113,7 +2156,7 @@ export interface CadreOwnerKey {
2113
2156
 
2114
2157
  /** Why `CadreNode.addOwner` refuses by name (`OwnerKeyAddRefusedError.code`). */
2115
2158
  export type OwnerKeyAddRefusal =
2116
- /** No control connection while the party has other members: the row would stay on this node, and nothing re-issues an `OwnerKey` add. */
2159
+ /** No sibling reachable (`CadreNode.hasReachableControlSibling`) while the party has other members: the row would stay on this node, and nothing re-issues an `OwnerKey` add. */
2117
2160
  | 'alone';
2118
2161
 
2119
2162
  /** Why `CadreNode.removeMachine` refuses by name (`MachineRemovalRefusedError.code`). */
@@ -2154,7 +2197,7 @@ export type OwnerKeyRemovalRefusal =
2154
2197
  | 'last-owner'
2155
2198
  /** The key is this node's own owner key: an owner cannot sign its own row's removal (`A.Key <> old.Key`), so ask another owner key. */
2156
2199
  | 'self'
2157
- /** A re-vouch of an `OwnerKey` row was asked for while this node has no control connection and the party has other members: the re-added row has no re-issue path. Decided at call time, so never planned. */
2200
+ /** A re-vouch of an `OwnerKey` row was asked for while this node reaches no sibling and the party has other members: the re-added row has no re-issue path. Decided at call time, so never planned. */
2158
2201
  | 'alone';
2159
2202
 
2160
2203
  /** The rows sharing the owner keys' voucher column that a removed owner key strands unless re-vouched first. */
@@ -2268,8 +2311,8 @@ export interface OwnerGrantRedemptionResult {
2268
2311
  * `ControlDatabase.queryRevocations`: the identity triple plus the `ReissuedAt`
2269
2312
  * counter an owner bumps (`ControlDatabase.reissueRevocations`) to re-write — and
2270
2313
  * therefore re-broadcast — a tombstone that committed while the node was alone.
2271
- * Never the ledger marker: that row names no guarded table, and `queryRevocations`
2272
- * skips it, which is what keeps `tableName` a {@link RevocableTable}.
2314
+ * Never the ledger marker an older party may hold: that row names no guarded table, and
2315
+ * `queryRevocations` skips it, which is what keeps `tableName` a {@link RevocableTable}.
2273
2316
  */
2274
2317
  export interface RevocationRow {
2275
2318
  /** Which guarded table's stamp was retired. */
@@ -2286,14 +2329,6 @@ export interface RevocationRow {
2286
2329
  signerSig: string;
2287
2330
  }
2288
2331
 
2289
- /**
2290
- * Outcome of `ControlDatabase.openRevocationLedger`: `'opened'` when this call filed the
2291
- * singleton `Revocation` ledger marker, `'already-open'` when it was already filed (seen
2292
- * by the in-lock guard, or filed first by another owner and refused on the primary key).
2293
- * Either way the marker exists afterwards.
2294
- */
2295
- export type RevocationLedgerOpenResult = 'opened' | 'already-open';
2296
-
2297
2332
  /**
2298
2333
  * Outcome of {@link CadreNode.registerSelf}, surfaced so callers (e.g. the CLI
2299
2334
  * `--owner` branch) can log what happened:
@@ -2493,6 +2528,9 @@ export interface SeedPeer {
2493
2528
  preferred: boolean;
2494
2529
  }
2495
2530
 
2531
+ /** A peer a seed nominates for dialing, and the addresses to dial it at (`seedDialTargets`). */
2532
+ export type SeedDialTarget = Pick<SeedPeer, 'peerId' | 'multiaddrs'>;
2533
+
2496
2534
  /**
2497
2535
  * Control network seed for bootstrapping new nodes.
2498
2536
  * Pre-populates a new node's cache to solve the cold-start problem: