@serfab/cadre-core 1.14.0 → 1.15.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 (171) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +64 -29
  3. package/dist/cadre-invite-protocol.js +207 -38
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +375 -79
  9. package/dist/cadre-node.js +758 -186
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-database.d.ts +92 -12
  12. package/dist/control-database.js +323 -142
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +19 -3
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/ed25519-key.d.ts +7 -9
  18. package/dist/ed25519-key.js +20 -10
  19. package/dist/ed25519-key.js.map +1 -1
  20. package/dist/fetch-impl.d.ts +12 -0
  21. package/dist/fetch-impl.js +23 -0
  22. package/dist/fetch-impl.js.map +1 -0
  23. package/dist/file-durable-slot.js +2 -1
  24. package/dist/file-durable-slot.js.map +1 -1
  25. package/dist/file-safe-name.d.ts +25 -0
  26. package/dist/file-safe-name.js +29 -0
  27. package/dist/file-safe-name.js.map +1 -0
  28. package/dist/formation-approval.js +5 -11
  29. package/dist/formation-approval.js.map +1 -1
  30. package/dist/fs-atomic.d.ts +0 -19
  31. package/dist/fs-atomic.js +3 -24
  32. package/dist/fs-atomic.js.map +1 -1
  33. package/dist/index.d.ts +12 -5
  34. package/dist/index.js +31 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/invitation-bootstrap.d.ts +50 -12
  37. package/dist/invitation-bootstrap.js +76 -13
  38. package/dist/invitation-bootstrap.js.map +1 -1
  39. package/dist/joined-strand-store.d.ts +7 -0
  40. package/dist/joined-strand-store.js +9 -0
  41. package/dist/joined-strand-store.js.map +1 -1
  42. package/dist/key-store-file.js +2 -1
  43. package/dist/key-store-file.js.map +1 -1
  44. package/dist/link-budget.d.ts +9 -4
  45. package/dist/link-budget.js +9 -4
  46. package/dist/link-budget.js.map +1 -1
  47. package/dist/machine-removal.d.ts +78 -0
  48. package/dist/machine-removal.js +166 -0
  49. package/dist/machine-removal.js.map +1 -0
  50. package/dist/membership-connection-gater.d.ts +6 -5
  51. package/dist/membership-connection-gater.js +6 -5
  52. package/dist/membership-connection-gater.js.map +1 -1
  53. package/dist/node-claim-payload.d.ts +55 -0
  54. package/dist/node-claim-payload.js +108 -4
  55. package/dist/node-claim-payload.js.map +1 -1
  56. package/dist/node-default-transports.d.ts +27 -0
  57. package/dist/node-default-transports.js +35 -0
  58. package/dist/node-default-transports.js.map +1 -0
  59. package/dist/node-transports.d.ts +30 -0
  60. package/dist/node-transports.js +40 -0
  61. package/dist/node-transports.js.map +1 -0
  62. package/dist/peer-dial.d.ts +124 -27
  63. package/dist/peer-dial.js +254 -30
  64. package/dist/peer-dial.js.map +1 -1
  65. package/dist/peer-join-backfill.js +6 -0
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/peer-record.d.ts +6 -45
  68. package/dist/peer-record.js +8 -50
  69. package/dist/peer-record.js.map +1 -1
  70. package/dist/primary-lan-address.d.ts +8 -0
  71. package/dist/primary-lan-address.js +46 -0
  72. package/dist/primary-lan-address.js.map +1 -0
  73. package/dist/provider/client.d.ts +131 -0
  74. package/dist/provider/client.js +226 -0
  75. package/dist/provider/client.js.map +1 -0
  76. package/dist/provider/descriptor.d.ts +41 -0
  77. package/dist/provider/descriptor.js +103 -0
  78. package/dist/provider/descriptor.js.map +1 -0
  79. package/dist/provider/index.d.ts +9 -0
  80. package/dist/provider/index.js +10 -0
  81. package/dist/provider/index.js.map +1 -0
  82. package/dist/provider/oauth.d.ts +125 -0
  83. package/dist/provider/oauth.js +199 -0
  84. package/dist/provider/oauth.js.map +1 -0
  85. package/dist/provider/rent-node.d.ts +101 -0
  86. package/dist/provider/rent-node.js +220 -0
  87. package/dist/provider/rent-node.js.map +1 -0
  88. package/dist/relay-addrs.d.ts +9 -4
  89. package/dist/relay-addrs.js +9 -4
  90. package/dist/relay-addrs.js.map +1 -1
  91. package/dist/relay-reservation.d.ts +51 -16
  92. package/dist/relay-reservation.js +107 -24
  93. package/dist/relay-reservation.js.map +1 -1
  94. package/dist/seed-bootstrap.d.ts +22 -5
  95. package/dist/seed-bootstrap.js +48 -25
  96. package/dist/seed-bootstrap.js.map +1 -1
  97. package/dist/strand-commit-durability.d.ts +54 -0
  98. package/dist/strand-commit-durability.js +74 -0
  99. package/dist/strand-commit-durability.js.map +1 -0
  100. package/dist/strand-database.d.ts +23 -8
  101. package/dist/strand-database.js +41 -13
  102. package/dist/strand-database.js.map +1 -1
  103. package/dist/strand-first-sync-gate.d.ts +7 -1
  104. package/dist/strand-first-sync-gate.js +7 -1
  105. package/dist/strand-first-sync-gate.js.map +1 -1
  106. package/dist/strand-formation-protocol.js +4 -1
  107. package/dist/strand-formation-protocol.js.map +1 -1
  108. package/dist/strand-fret-addrs.d.ts +19 -1
  109. package/dist/strand-fret-addrs.js +27 -9
  110. package/dist/strand-fret-addrs.js.map +1 -1
  111. package/dist/strand-instance-manager.d.ts +81 -19
  112. package/dist/strand-instance-manager.js +216 -105
  113. package/dist/strand-instance-manager.js.map +1 -1
  114. package/dist/strand-move.d.ts +124 -0
  115. package/dist/strand-move.js +438 -0
  116. package/dist/strand-move.js.map +1 -0
  117. package/dist/strand-network-config.js +7 -6
  118. package/dist/strand-network-config.js.map +1 -1
  119. package/dist/strand-peer-redial.d.ts +113 -0
  120. package/dist/strand-peer-redial.js +236 -0
  121. package/dist/strand-peer-redial.js.map +1 -0
  122. package/dist/strand-wake-protocol.js +10 -7
  123. package/dist/strand-wake-protocol.js.map +1 -1
  124. package/dist/types.d.ts +212 -4
  125. package/dist/types.js +10 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +18 -10
  128. package/src/cadre-invite-protocol.ts +233 -45
  129. package/src/cadre-join-request.ts +167 -0
  130. package/src/cadre-node.ts +10003 -9381
  131. package/src/control-database.ts +4663 -4451
  132. package/src/control-schema.ts +19 -3
  133. package/src/ed25519-key.ts +20 -10
  134. package/src/fetch-impl.ts +23 -0
  135. package/src/file-durable-slot.ts +64 -63
  136. package/src/file-safe-name.ts +32 -0
  137. package/src/formation-approval.ts +604 -610
  138. package/src/fs-atomic.ts +92 -117
  139. package/src/index.ts +789 -722
  140. package/src/invitation-bootstrap.ts +91 -13
  141. package/src/joined-strand-store.ts +508 -498
  142. package/src/key-store-file.ts +142 -141
  143. package/src/link-budget.ts +9 -4
  144. package/src/machine-removal.ts +216 -0
  145. package/src/membership-connection-gater.ts +638 -637
  146. package/src/node-claim-payload.ts +138 -4
  147. package/src/node-default-transports.ts +38 -0
  148. package/src/node-transports.ts +49 -0
  149. package/src/peer-dial.ts +604 -336
  150. package/src/peer-join-backfill.ts +6 -0
  151. package/src/peer-record.ts +159 -202
  152. package/src/primary-lan-address.ts +47 -0
  153. package/src/provider/client.ts +326 -0
  154. package/src/provider/descriptor.ts +129 -0
  155. package/src/provider/index.ts +59 -0
  156. package/src/provider/oauth.ts +285 -0
  157. package/src/provider/rent-node.ts +275 -0
  158. package/src/relay-addrs.ts +9 -4
  159. package/src/relay-reservation.ts +1211 -1121
  160. package/src/seed-bootstrap.ts +1502 -1471
  161. package/src/strand-commit-durability.ts +116 -0
  162. package/src/strand-database.ts +46 -14
  163. package/src/strand-first-sync-gate.ts +7 -1
  164. package/src/strand-formation-protocol.ts +1039 -1036
  165. package/src/strand-fret-addrs.ts +179 -145
  166. package/src/strand-instance-manager.ts +2026 -1883
  167. package/src/strand-move.ts +585 -0
  168. package/src/strand-network-config.ts +7 -6
  169. package/src/strand-peer-redial.ts +307 -0
  170. package/src/strand-wake-protocol.ts +10 -7
  171. package/src/types.ts +220 -4
@@ -0,0 +1,116 @@
1
+ import debug from 'debug';
2
+ import { durabilityRank } from '@optimystic/db-core';
3
+ import type { WriteDurability } from '@optimystic/db-core';
4
+ import type { CommitFullyHeld, SqlCommitDurability } from '@serfab/quereus-plugin-sereus';
5
+
6
+ const log = debug('sereus:cadre:strand-commit-durability');
7
+
8
+ /** What one commit on a strand wrote, and whether it survives this machine stopping for good. */
9
+ export interface StrandCommitDurability {
10
+ readonly strandId: string;
11
+ /** `StampId()` inside the committed transaction. */
12
+ readonly transactionId: string;
13
+ /**
14
+ * Another member holds every block the commit wrote: the overall answer is `majority` or `full`
15
+ * and no block was abandoned (`torn`). False for `local`, `unrouted`, an unknown overall, or a
16
+ * torn commit. Becomes true once `strand:commit-held` fires for the commit.
17
+ */
18
+ readonly heldElsewhere: boolean;
19
+ /**
20
+ * When {@link heldElsewhere} is false: `strand:commit-held` will fire for this commit if it
21
+ * reaches the others while this database stays open. False means nothing will say so.
22
+ */
23
+ readonly followed: boolean;
24
+ /** The plugin's report, for per-table detail. */
25
+ readonly report: SqlCommitDurability;
26
+ }
27
+
28
+ /** A commit that was held only below full replication is now held by every member of its cohort. */
29
+ export interface StrandCommitHeld {
30
+ readonly strandId: string;
31
+ readonly transactionId: string;
32
+ }
33
+
34
+ /** Where a strand database forwards each commit's durability and each held commit. */
35
+ export interface StrandCommitListeners {
36
+ onCommitDurability?: (commit: StrandCommitDurability) => void;
37
+ onCommitHeld?: (event: StrandCommitHeld) => void;
38
+ }
39
+
40
+ // NOTE: if an app reads a commit's durability later than 256 commits after it, raise this or let
41
+ // the app pass the id up front.
42
+ const RECENT_COMMIT_LIMIT = 256;
43
+
44
+ /**
45
+ * `majority` counts: a strict majority of a cohort of at least two is at least two machines, so
46
+ * the write survives this one stopping. A torn commit does not, though it can succeed as `majority`
47
+ * (Optimystic clamps a torn `full` to it): its abandoned blocks are held by nobody. The one place
48
+ * this rule lives; apps read the field.
49
+ */
50
+ function isHeldElsewhere(overall: WriteDurability | undefined): boolean {
51
+ return overall !== undefined
52
+ && durabilityRank(overall.quorum) >= durabilityRank('majority')
53
+ && (overall.torn?.length ?? 0) === 0;
54
+ }
55
+
56
+ /**
57
+ * The durability of the most recent commits on one strand database, by transaction id. The
58
+ * plugin reports a commit — and a `covered` commit is held — before the statement that made it
59
+ * resolves, so an app that learns its id from `returning StampId()` reads the answer from here
60
+ * afterwards. Each entry is frozen and replaced when the commit becomes held, so a value read
61
+ * from {@link get} never changes under its reader.
62
+ */
63
+ export class StrandCommitRecord {
64
+ private readonly commits = new Map<string, StrandCommitDurability>();
65
+
66
+ constructor(
67
+ private readonly strandId: string,
68
+ private readonly listeners: StrandCommitListeners
69
+ ) {}
70
+
71
+ get(transactionId: string): StrandCommitDurability | undefined {
72
+ return this.commits.get(transactionId);
73
+ }
74
+
75
+ /** A plugin `onCommitDurability` report: record it, then forward it. */
76
+ reportCommit(report: SqlCommitDurability): void {
77
+ const commit = Object.freeze({
78
+ strandId: this.strandId,
79
+ transactionId: report.transactionId,
80
+ heldElsewhere: isHeldElsewhere(report.durability.overall),
81
+ followed: report.followed,
82
+ report
83
+ });
84
+ this.remember(commit);
85
+ this.forward('durability', report.transactionId, () => this.listeners.onCommitDurability?.(commit));
86
+ }
87
+
88
+ /** A plugin `onCommitFullyHeld` event: mark the commit held, then forward it — also for one no longer recorded. */
89
+ reportHeld({ transactionId }: CommitFullyHeld): void {
90
+ const recorded = this.commits.get(transactionId);
91
+ if (recorded) {
92
+ this.commits.set(transactionId, Object.freeze({ ...recorded, heldElsewhere: true }));
93
+ }
94
+ this.forward('held', transactionId, () => this.listeners.onCommitHeld?.({ strandId: this.strandId, transactionId }));
95
+ }
96
+
97
+ private remember(commit: StrandCommitDurability): void {
98
+ this.commits.set(commit.transactionId, commit);
99
+ if (this.commits.size > RECENT_COMMIT_LIMIT) {
100
+ const oldest = this.commits.keys().next().value!;
101
+ this.commits.delete(oldest);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Run an app's handler isolated: it runs inside the commit, and a throw reaching the plugin
107
+ * would be logged there as the plugin's own listener failing.
108
+ */
109
+ private forward(kind: string, transactionId: string, deliver: () => void): void {
110
+ try {
111
+ deliver();
112
+ } catch (error) {
113
+ log('strand %s: %s handler threw for transaction %s: %O', this.strandId, kind, transactionId, error);
114
+ }
115
+ }
116
+ }
@@ -7,11 +7,13 @@ import type { IRepo } from '@optimystic/db-core';
7
7
  import type { SAppConfig } from './types.js';
8
8
  import { bootstrapFounderMembership } from './strand-membership-writer.js';
9
9
  import { strandMemberKeyPair } from './strand-member-key.js';
10
+ import { StrandCommitRecord } from './strand-commit-durability.js';
11
+ import type { StrandCommitDurability, StrandCommitListeners } from './strand-commit-durability.js';
10
12
 
11
13
  const log = debug('sereus:cadre:strand-db');
12
14
  const timing = debug('sereus:cadre:timing');
13
15
 
14
- export interface StrandDatabaseConfig {
16
+ export interface StrandDatabaseConfig extends StrandCommitListeners {
15
17
  /** The strand ID */
16
18
  strandId: string;
17
19
  /**
@@ -63,10 +65,12 @@ export interface StrandDatabaseConfig {
63
65
  * but delegates the actual SQL-surface composition — plugin registration, node
64
66
  * wiring, catalog hydration, schema apply — to `connectToStrand` from
65
67
  * `@serfab/quereus-plugin-sereus`, the single shared composition. The libp2p
66
- * node is injected here, so `connectToStrand` never *creates* the node;
67
- * `StrandInstanceManager` owns the node lifecycle. (The strand connection's
68
- * `shutdown` still stops the injected node via the collection factory, so the
69
- * manager's own `node.stop()` is an idempotent second stop — see `close()`.)
68
+ * node is injected here, so `connectToStrand` neither creates nor stops it:
69
+ * `StrandInstanceManager` owns the node lifecycle, and a failed or closed
70
+ * database leaves the node running.
71
+ *
72
+ * One instance is one attempt: a failed {@link initialize} is closed and a new
73
+ * instance built for the next attempt.
70
74
  */
71
75
  export class StrandDatabase {
72
76
  private db: Database | null = null;
@@ -74,9 +78,12 @@ export class StrandDatabase {
74
78
  private resolvedTransactor: SereusPluginResult['transactor'] | null = null;
75
79
  private readonly config: StrandDatabaseConfig;
76
80
  private initialized = false;
81
+ private readonly commits: StrandCommitRecord;
82
+ private unsubscribeCommits: (() => void) | null = null;
77
83
 
78
84
  constructor(config: StrandDatabaseConfig) {
79
85
  this.config = config;
86
+ this.commits = new StrandCommitRecord(config.strandId, config);
80
87
  }
81
88
 
82
89
  /**
@@ -103,10 +110,8 @@ export class StrandDatabase {
103
110
  this.db = new Database();
104
111
 
105
112
  // Delegate to the shared composition. The node is injected, so:
106
- // - `connectToStrand` never creates a node (its `createdNode` stays null);
107
- // `StrandInstanceManager` owns the node lifecycle. Its `shutdown` does
108
- // still stop the injected node via the collection factory, so the
109
- // manager's later `node.stop()` is an idempotent second stop;
113
+ // - `connectToStrand` never creates or stops a node; `StrandInstanceManager`
114
+ // owns the node lifecycle;
110
115
  // - no `storage` is passed: the plugin consumes it only to build a local
111
116
  // transactor's raw-storage factory or to create a node it was not given,
112
117
  // and cadre-core always injects the node and runs the network transactor.
@@ -126,14 +131,16 @@ export class StrandDatabase {
126
131
  });
127
132
  this.shutdownStrand = result.shutdown;
128
133
  this.resolvedTransactor = result.transactor;
134
+ // Before the founder bootstrap, so its commits are reported like any other.
135
+ this.subscribeToCommits(result);
129
136
  timing('[strandDb:%s] connectToStrand: %dms (hydrated tables=%d, indexes=%d)',
130
137
  sid, Math.round(performance.now() - t0),
131
138
  result.hydrated?.tables ?? 0, result.hydrated?.indexes ?? 0);
132
139
 
133
140
  // Founder-only: write the one-time membership bootstrap now that the schema is
134
141
  // applied (connectToStrand has returned). A throw here propagates out of
135
- // initialize() so buildStrandRuntime's rollback tears the half-built strand
136
- // down rather than leaking a node. Joiners skip this — their rows arrive via sync.
142
+ // initialize(), and the caller closes this half-built database (close() runs the
143
+ // composition's shutdown). Joiners skip this — their rows arrive via sync.
137
144
  if (this.config.founder === true) {
138
145
  await this.bootstrapFounder();
139
146
  }
@@ -142,6 +149,15 @@ export class StrandDatabase {
142
149
  log('StrandDatabase for strand %s initialized successfully', sid);
143
150
  }
144
151
 
152
+ private subscribeToCommits(result: SereusPluginResult): void {
153
+ const unsubscribeDurability = result.onCommitDurability(report => this.commits.reportCommit(report));
154
+ const unsubscribeHeld = result.onCommitFullyHeld(event => this.commits.reportHeld(event));
155
+ this.unsubscribeCommits = () => {
156
+ unsubscribeDurability();
157
+ unsubscribeHeld();
158
+ };
159
+ }
160
+
145
161
  /**
146
162
  * Run the founder membership bootstrap against the freshly-composed strand DB.
147
163
  *
@@ -247,6 +263,19 @@ export class StrandDatabase {
247
263
  return this.db!;
248
264
  }
249
265
 
266
+ /**
267
+ * The durability of a recent commit on this database, by its `StampId()` — the id a single
268
+ * `insert … returning StampId() as Tx` statement returns. Read it after that statement
269
+ * resolves: the plugin reports the commit before the statement returns, and a commit already
270
+ * held everywhere is marked held by then too. `undefined` for a commit that wrote nothing, one
271
+ * made before this database was built, or one more than 256 commits back. Each value is a
272
+ * snapshot: read again after `strand:commit-held` to see the change.
273
+ */
274
+ commitDurability(transactionId: string): StrandCommitDurability | undefined {
275
+ this.ensureInitialized();
276
+ return this.commits.get(transactionId);
277
+ }
278
+
250
279
  /**
251
280
  * The Optimystic transactor this strand's connection resolved to — always
252
281
  * `'network'` here, since cadre-core passes no `transactor` option and takes
@@ -261,11 +290,14 @@ export class StrandDatabase {
261
290
 
262
291
  /**
263
292
  * Close the database and cleanup resources. Runs the strand-connection
264
- * shutdown (collection-factory teardown, which also stops the injected node),
265
- * then closes the `Database`. `StrandInstanceManager.releaseRuntime` issues a
266
- * further idempotent `node.stop()` after this returns.
293
+ * shutdown (collection-factory teardown; the injected node keeps running),
294
+ * then closes the `Database`. Safe on an instance whose {@link initialize}
295
+ * failed part-way.
267
296
  */
268
297
  async close(): Promise<void> {
298
+ // First, so an event from the closing connection reaches no app handler.
299
+ this.unsubscribeCommits?.();
300
+ this.unsubscribeCommits = null;
269
301
  if (this.shutdownStrand) {
270
302
  await this.shutdownStrand();
271
303
  this.shutdownStrand = null;
@@ -55,7 +55,7 @@
55
55
  * seed writes the catalog collection (`optimystic/schema` plus two hash-named blocks,
56
56
  * measured 2026-09-16) alone, during `connectToStrand`'s schema apply, before any peer
57
57
  * contact. (One with a seed cannot until a seed peer answers — Optimystic refuses the
58
- * absence ruling — and is rebuilt on a timer by `StrandInstanceManager.deferUnreachableJoin`.) That is the same "two histories under one id" shape this gate
58
+ * absence ruling — and retries its database build on a timer, `StrandInstanceManager.deferUnreachableJoin`.) That is the same "two histories under one id" shape this gate
59
59
  * exists to prevent, and it is only safe because both sides derive byte-identical content
60
60
  * from the same schema text. If the catalog ever carries per-machine or ordering-dependent
61
61
  * content (optimystic's own `0.5-same-named-tables-in-two-schemas-share-storage` re-keys
@@ -294,6 +294,12 @@ export interface StrandFirstSyncGateDeps {
294
294
  * Holds one launch's `StrandDatabase` until {@link strandFirstSyncComplete}. Probes are
295
295
  * sequential (the next is scheduled only after the previous read settles), so a slow
296
296
  * network read never stacks probes.
297
+ *
298
+ * NOTE: a probe's reads dial the founder, through the relay when that is the only route, even
299
+ * while the founder is unreachable: one run of `joiner-first-commit-over-slow-relay` logged 17
300
+ * `hop connect denied` at the relay in about 10 s, each a few frames on the joiner's relay
301
+ * connection. If that connection ever shows saturated while no strand member is connected, hold
302
+ * the probe while the strand member redial (`strand-peer-redial.ts`) reports none connected.
297
303
  */
298
304
  export class StrandFirstSyncGate {
299
305
  private readonly scheduler: TimeoutScheduler;