@serfab/cadre-core 1.7.0 → 1.9.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 (188) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +527 -206
  3. package/dist/cadre-node.js +1084 -528
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-authorization.d.ts +2 -2
  6. package/dist/control-authorization.js +1 -0
  7. package/dist/control-authorization.js.map +1 -1
  8. package/dist/control-cohort.d.ts +6 -0
  9. package/dist/control-cohort.js +6 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +46 -8
  12. package/dist/control-database.js +97 -18
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +37 -14
  15. package/dist/control-formation-recorder.js +60 -10
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +13 -12
  18. package/dist/control-read-retry.js +14 -12
  19. package/dist/control-read-retry.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +67 -5
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +9 -1
  24. package/dist/control-stream.js +24 -1
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +12 -2
  27. package/dist/control-write-retry.js +13 -2
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +11 -8
  30. package/dist/delegate-admission.js +19 -11
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/diagnostics/connection-path.d.ts +0 -7
  33. package/dist/diagnostics/connection-path.js +0 -7
  34. package/dist/diagnostics/connection-path.js.map +1 -1
  35. package/dist/formation-approval.d.ts +7 -0
  36. package/dist/formation-approval.js +9 -3
  37. package/dist/formation-approval.js.map +1 -1
  38. package/dist/fs-atomic.d.ts +11 -2
  39. package/dist/fs-atomic.js +13 -3
  40. package/dist/fs-atomic.js.map +1 -1
  41. package/dist/hibernation-manager.d.ts +111 -8
  42. package/dist/hibernation-manager.js +234 -49
  43. package/dist/hibernation-manager.js.map +1 -1
  44. package/dist/index.d.ts +10 -10
  45. package/dist/index.js +25 -20
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +90 -25
  48. package/dist/joined-strand-store.js +223 -55
  49. package/dist/joined-strand-store.js.map +1 -1
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/link-budget.d.ts +367 -69
  53. package/dist/link-budget.js +375 -72
  54. package/dist/link-budget.js.map +1 -1
  55. package/dist/membership-connection-gater.d.ts +30 -43
  56. package/dist/membership-connection-gater.js +31 -42
  57. package/dist/membership-connection-gater.js.map +1 -1
  58. package/dist/node-local-snapshot.d.ts +6 -4
  59. package/dist/node-local-snapshot.js +7 -3
  60. package/dist/node-local-snapshot.js.map +1 -1
  61. package/dist/peer-dial.d.ts +24 -4
  62. package/dist/peer-dial.js +74 -5
  63. package/dist/peer-dial.js.map +1 -1
  64. package/dist/peer-join-backfill.d.ts +2 -10
  65. package/dist/peer-join-backfill.js +8 -8
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/push-fanout.d.ts +6 -0
  68. package/dist/push-fanout.js +6 -0
  69. package/dist/push-fanout.js.map +1 -1
  70. package/dist/relay-reservation.d.ts +3 -1
  71. package/dist/relay-reservation.js +3 -1
  72. package/dist/relay-reservation.js.map +1 -1
  73. package/dist/relay-server.d.ts +79 -0
  74. package/dist/relay-server.js +74 -0
  75. package/dist/relay-server.js.map +1 -0
  76. package/dist/seed-bootstrap.d.ts +57 -4
  77. package/dist/seed-bootstrap.js +107 -83
  78. package/dist/seed-bootstrap.js.map +1 -1
  79. package/dist/storage-scope.d.ts +33 -17
  80. package/dist/storage-scope.js +44 -23
  81. package/dist/storage-scope.js.map +1 -1
  82. package/dist/strand-addr-protocol.d.ts +58 -21
  83. package/dist/strand-addr-protocol.js +133 -85
  84. package/dist/strand-addr-protocol.js.map +1 -1
  85. package/dist/strand-database.d.ts +20 -2
  86. package/dist/strand-database.js +42 -5
  87. package/dist/strand-database.js.map +1 -1
  88. package/dist/strand-first-sync-gate.d.ts +59 -21
  89. package/dist/strand-first-sync-gate.js +58 -19
  90. package/dist/strand-first-sync-gate.js.map +1 -1
  91. package/dist/strand-formation-deadlines.d.ts +151 -0
  92. package/dist/strand-formation-deadlines.js +171 -0
  93. package/dist/strand-formation-deadlines.js.map +1 -0
  94. package/dist/strand-formation-manager.d.ts +63 -30
  95. package/dist/strand-formation-manager.js +67 -41
  96. package/dist/strand-formation-manager.js.map +1 -1
  97. package/dist/strand-formation-protocol.d.ts +45 -46
  98. package/dist/strand-formation-protocol.js +62 -126
  99. package/dist/strand-formation-protocol.js.map +1 -1
  100. package/dist/strand-fret-addrs.d.ts +54 -0
  101. package/dist/strand-fret-addrs.js +108 -0
  102. package/dist/strand-fret-addrs.js.map +1 -0
  103. package/dist/strand-instance-manager.d.ts +122 -44
  104. package/dist/strand-instance-manager.js +270 -114
  105. package/dist/strand-instance-manager.js.map +1 -1
  106. package/dist/strand-membership-writer.d.ts +6 -0
  107. package/dist/strand-membership-writer.js +11 -0
  108. package/dist/strand-membership-writer.js.map +1 -1
  109. package/dist/strand-network-state-file.d.ts +12 -0
  110. package/dist/strand-network-state-file.js +38 -0
  111. package/dist/strand-network-state-file.js.map +1 -0
  112. package/dist/strand-network-state.d.ts +111 -0
  113. package/dist/strand-network-state.js +191 -0
  114. package/dist/strand-network-state.js.map +1 -0
  115. package/dist/strand-revocation-enforcer.js +2 -1
  116. package/dist/strand-revocation-enforcer.js.map +1 -1
  117. package/dist/strand-solicitation.d.ts +36 -20
  118. package/dist/strand-solicitation.js +4 -4
  119. package/dist/strand-solicitation.js.map +1 -1
  120. package/dist/strand-wake-protocol.d.ts +80 -23
  121. package/dist/strand-wake-protocol.js +89 -64
  122. package/dist/strand-wake-protocol.js.map +1 -1
  123. package/dist/types.d.ts +148 -61
  124. package/dist/types.js +4 -3
  125. package/dist/types.js.map +1 -1
  126. package/package.json +11 -10
  127. package/src/cadre-node.ts +8400 -7776
  128. package/src/control-authorization.ts +2 -1
  129. package/src/control-cohort.ts +6 -0
  130. package/src/control-database.ts +122 -18
  131. package/src/control-formation-recorder.ts +354 -305
  132. package/src/control-read-retry.ts +14 -12
  133. package/src/control-schema.ts +67 -5
  134. package/src/control-stream.ts +25 -1
  135. package/src/control-write-retry.ts +13 -2
  136. package/src/delegate-admission.ts +23 -13
  137. package/src/diagnostics/connection-path.ts +0 -7
  138. package/src/formation-approval.ts +610 -604
  139. package/src/fs-atomic.ts +14 -4
  140. package/src/hibernation-manager.ts +638 -437
  141. package/src/index.ts +43 -65
  142. package/src/joined-strand-store.ts +247 -57
  143. package/src/key-store-file.ts +14 -5
  144. package/src/link-budget.ts +393 -74
  145. package/src/membership-connection-gater.ts +31 -45
  146. package/src/node-local-snapshot.ts +12 -7
  147. package/src/peer-dial.ts +81 -7
  148. package/src/peer-join-backfill.ts +10 -9
  149. package/src/push-fanout.ts +6 -0
  150. package/src/relay-reservation.ts +3 -1
  151. package/src/relay-server.ts +98 -0
  152. package/src/seed-bootstrap.ts +133 -91
  153. package/src/storage-scope.ts +44 -23
  154. package/src/strand-addr-protocol.ts +547 -458
  155. package/src/strand-database.ts +287 -242
  156. package/src/strand-first-sync-gate.ts +58 -19
  157. package/src/strand-formation-deadlines.ts +227 -0
  158. package/src/strand-formation-manager.ts +700 -647
  159. package/src/strand-formation-protocol.ts +106 -154
  160. package/src/strand-fret-addrs.ts +145 -0
  161. package/src/strand-instance-manager.ts +307 -140
  162. package/src/strand-membership-writer.ts +12 -0
  163. package/src/strand-network-state-file.ts +40 -0
  164. package/src/strand-network-state.ts +243 -0
  165. package/src/strand-revocation-enforcer.ts +2 -1
  166. package/src/strand-solicitation.ts +519 -500
  167. package/src/strand-wake-protocol.ts +401 -355
  168. package/src/types.ts +150 -61
  169. package/dist/strand-peer-book-file.d.ts +0 -12
  170. package/dist/strand-peer-book-file.js +0 -37
  171. package/dist/strand-peer-book-file.js.map +0 -1
  172. package/dist/strand-peer-book-protocol.d.ts +0 -159
  173. package/dist/strand-peer-book-protocol.js +0 -360
  174. package/dist/strand-peer-book-protocol.js.map +0 -1
  175. package/dist/strand-peer-book-swap.d.ts +0 -104
  176. package/dist/strand-peer-book-swap.js +0 -284
  177. package/dist/strand-peer-book-swap.js.map +0 -1
  178. package/dist/strand-peer-book.d.ts +0 -159
  179. package/dist/strand-peer-book.js +0 -354
  180. package/dist/strand-peer-book.js.map +0 -1
  181. package/dist/strand-peer-observer.d.ts +0 -88
  182. package/dist/strand-peer-observer.js +0 -180
  183. package/dist/strand-peer-observer.js.map +0 -1
  184. package/src/strand-peer-book-file.ts +0 -39
  185. package/src/strand-peer-book-protocol.ts +0 -463
  186. package/src/strand-peer-book-swap.ts +0 -338
  187. package/src/strand-peer-book.ts +0 -468
  188. package/src/strand-peer-observer.ts +0 -235
@@ -1,242 +1,287 @@
1
- import debug from 'debug';
2
- import { Database } from '@quereus/quereus';
3
- import { connectToStrand } from '@serfab/quereus-plugin-sereus';
4
- import type { SereusPluginResult } from '@serfab/quereus-plugin-sereus';
5
- import type { Libp2p } from '@libp2p/interface';
6
- import type { IRepo } from '@optimystic/db-core';
7
- import type { SAppConfig } from './types.js';
8
- import { bootstrapFounderMembership } from './strand-membership-writer.js';
9
- import { strandMemberKeyPair } from './strand-member-key.js';
10
-
11
- const log = debug('sereus:cadre:strand-db');
12
- const timing = debug('sereus:cadre:timing');
13
-
14
- export interface StrandDatabaseConfig {
15
- /** The strand ID */
16
- strandId: string;
17
- /** sApp configuration containing the schema */
18
- sAppConfig: SAppConfig;
19
- /** Libp2p node for the strand network */
20
- libp2pNode: Libp2p;
21
- /** Coordinated repo from the libp2p node */
22
- coordinatedRepo: IRepo;
23
- /**
24
- * Strand type (`'o'` open / `'c'` closed) from the control-network strand row.
25
- * Drives the founder bootstrap: open strands get a `Header` only; closed strands
26
- * also get the founding `Member`+`Manager`.
27
- */
28
- strandType: 'o' | 'c';
29
- /**
30
- * The closed-strand `MemberPrivateKey` (base64 protobuf) from the strand row —
31
- * the strand-wide read secret every joining party receives. Carried for the
32
- * strand's read-gating story only; it derives NOBODY's identity (that used to be
33
- * this key's second job, which let any joiner sign as the founding manager —
34
- * gotchoices/sereus#4). Absent for open strands.
35
- */
36
- memberPrivateKey?: string;
37
- /**
38
- * THIS party's own strand membership private key (base64 protobuf), from the
39
- * control-layer `StrandPartyKey` row (or supplied explicitly at attach). Required
40
- * when `founder` is true and `strandType` is `'c'` — it derives the founding
41
- * `Member.Key`/`Manager.MemberKey`. Never shared outside the party; absent for
42
- * open strands and for joiners that have not yet persisted one.
43
- */
44
- partyMemberPrivateKey?: string;
45
- /**
46
- * Whether this node founds the strand. When true, {@link initialize} runs the
47
- * one-time founder membership bootstrap after the schema is applied. Joiners
48
- * leave this false and write nothing (rows arrive via sync). Defaults to false.
49
- */
50
- founder?: boolean;
51
- }
52
-
53
- /**
54
- * StrandDatabase manages the sApp schema for a strand using Quereus with the
55
- * Optimystic backend. Each strand instance has its own isolated database with
56
- * the sApp's schema applied.
57
- *
58
- * This class owns the `Database` lifecycle (creation, `getDatabase()`, `close()`)
59
- * but delegates the actual SQL-surface composition — plugin registration, node
60
- * wiring, catalog hydration, schema apply — to `connectToStrand` from
61
- * `@serfab/quereus-plugin-sereus`, the single shared composition. The libp2p
62
- * node is injected here, so `connectToStrand` never *creates* the node;
63
- * `StrandInstanceManager` owns the node lifecycle. (The strand connection's
64
- * `shutdown` still stops the injected node via the collection factory, so the
65
- * manager's own `node.stop()` is an idempotent second stop — see `close()`.)
66
- */
67
- export class StrandDatabase {
68
- private db: Database | null = null;
69
- private shutdownStrand: SereusPluginResult['shutdown'] | null = null;
70
- private resolvedTransactor: SereusPluginResult['transactor'] | null = null;
71
- private readonly config: StrandDatabaseConfig;
72
- private initialized = false;
73
-
74
- constructor(config: StrandDatabaseConfig) {
75
- this.config = config;
76
- }
77
-
78
- /**
79
- * Initialize the database — create the `Database` and delegate the strand
80
- * SQL-surface composition (plugins, node wiring, hydrate, schema apply) to
81
- * `connectToStrand` with the injected libp2p node.
82
- */
83
- async initialize(): Promise<void> {
84
- if (this.initialized) {
85
- log('StrandDatabase for strand %s already initialized', this.config.strandId);
86
- return;
87
- }
88
-
89
- const sid = this.config.strandId;
90
- log('Initializing StrandDatabase for strand: %s (sApp: %s v%s)',
91
- sid, this.config.sAppConfig.id, this.config.sAppConfig.version);
92
-
93
- this.db = new Database();
94
-
95
- // Delegate to the shared composition. The node is injected, so:
96
- // - `connectToStrand` never creates a node (its `createdNode` stays null);
97
- // `StrandInstanceManager` owns the node lifecycle. Its `shutdown` does
98
- // still stop the injected node via the collection factory, so the
99
- // manager's later `node.stop()` is an idempotent second stop;
100
- // - no `storage` is passed: the plugin consumes it only to build a local
101
- // transactor's raw-storage factory or to create a node it was not given,
102
- // and cadre-core always injects the node and runs the network transactor.
103
- // (The plugin keeps its `storage` option for the browser entry point.)
104
- const t0 = performance.now();
105
- const result = await connectToStrand(this.db, {
106
- strandId: sid,
107
- schema: this.config.sAppConfig.schema,
108
- libp2pNode: this.config.libp2pNode,
109
- coordinatedRepo: this.config.coordinatedRepo,
110
- enableCache: true,
111
- });
112
- this.shutdownStrand = result.shutdown;
113
- this.resolvedTransactor = result.transactor;
114
- timing('[strandDb:%s] connectToStrand: %dms (hydrated tables=%d, indexes=%d)',
115
- sid, Math.round(performance.now() - t0),
116
- result.hydrated?.tables ?? 0, result.hydrated?.indexes ?? 0);
117
-
118
- // Founder-only: write the one-time membership bootstrap now that the schema is
119
- // applied (connectToStrand has returned). A throw here propagates out of
120
- // initialize() so buildStrandRuntime's rollback tears the half-built strand
121
- // down rather than leaking a node. Joiners skip this — their rows arrive via sync.
122
- if (this.config.founder === true) {
123
- await this.bootstrapFounder();
124
- }
125
-
126
- this.initialized = true;
127
- log('StrandDatabase for strand %s initialized successfully', sid);
128
- }
129
-
130
- /**
131
- * Run the founder membership bootstrap against the freshly-composed strand DB.
132
- *
133
- * Derives the founding keypair from this PARTY's own `partyMemberPrivateKey` (the
134
- * `Member.Key`/`Manager.MemberKey` are its public key) — deliberately NOT from the
135
- * strand row's shared `MemberPrivateKey`, which every joining party receives and could
136
- * therefore use to forge the founder's identity. A closed strand with no
137
- * `partyMemberPrivateKey` throws, because it could never seat a founding manager.
138
- * The shared key's public half IS passed, but only so the bootstrap can refuse a strand
139
- * founded under it before the split (`PreSplitStrandIdentityError`). Open strands derive
140
- * no keypair (Header only). Idempotent — see {@link bootstrapFounderMembership}.
141
- */
142
- private async bootstrapFounder(): Promise<void> {
143
- const { strandId, strandType, memberPrivateKey, partyMemberPrivateKey, sAppConfig } = this.config;
144
- const closed = strandType === 'c';
145
- await bootstrapFounderMembership(this.db!, {
146
- strandId,
147
- type: strandType,
148
- sApp: sAppConfig,
149
- founderKeyPair: closed ? this.deriveFounderKeyPair(strandId, partyMemberPrivateKey) : undefined,
150
- sharedMemberPublicKey: closed && memberPrivateKey
151
- ? strandMemberKeyPair(memberPrivateKey).publicKeyB64
152
- : undefined,
153
- });
154
- }
155
-
156
- /**
157
- * Derive the founding keypair for a closed strand from the party's own
158
- * `partyMemberPrivateKey`, failing loudly when the key is absent (a closed strand
159
- * with no founding Manager can never admit anyone).
160
- */
161
- private deriveFounderKeyPair(strandId: string, partyMemberPrivateKey: string | undefined) {
162
- if (!partyMemberPrivateKey) {
163
- throw new Error(
164
- `Cannot found closed strand ${strandId}: this party has no StrandPartyKey for it. ` +
165
- 'A closed strand needs a founding Member/Manager derived from the party\'s own ' +
166
- 'membership key (minted at publishStrand, or at the founder launch that follows a ' +
167
- 'publish interrupted before its mint) — the shared MemberPrivateKey deliberately ' +
168
- 'no longer derives anyone\'s identity.',
169
- );
170
- }
171
- return strandMemberKeyPair(partyMemberPrivateKey);
172
- }
173
-
174
- /**
175
- * Run the founder membership bootstrap against an ALREADY-LIVE database — the
176
- * seam {@link StrandInstanceManager.foundExistingStrand} uses when a founder
177
- * request arrives for an instance that was first launched as a joiner.
178
- * Idempotent: every bootstrap write is insert-if-absent
179
- * ({@link bootstrapFounderMembership}), so calling it on an instance that
180
- * already founded writes nothing. On success also flips the captured config's
181
- * `founder`, so this object's own record of how it was launched stays coherent
182
- * with what actually ran (a construction-time `founder: false` is a statement
183
- * about the launch, not a permanent identity); a refused bootstrap leaves it a joiner.
184
- *
185
- * @param partyMemberPrivateKey - The party's own membership key, for a closed strand
186
- * whose original (joiner) launch resolved none — e.g. the `StrandPartyKey` row had
187
- * not been written or replicated yet. The captured config's key wins when both
188
- * exist: it is the identity this instance launched under.
189
- */
190
- async ensureFounderBootstrap(partyMemberPrivateKey?: string): Promise<void> {
191
- this.ensureInitialized();
192
- this.config.partyMemberPrivateKey ??= partyMemberPrivateKey;
193
- await this.bootstrapFounder();
194
- this.config.founder = true;
195
- }
196
-
197
- /**
198
- * Get the underlying database for queries
199
- */
200
- getDatabase(): Database {
201
- this.ensureInitialized();
202
- return this.db!;
203
- }
204
-
205
- /**
206
- * The Optimystic transactor this strand's connection resolved to — always
207
- * `'network'` here, since cadre-core passes no `transactor` option and takes
208
- * the plugin's default. Exposed so a spec measuring or asserting the network
209
- * path (`strand-solo-write-budget.spec.ts`) can pin the engine it ran on
210
- * rather than assume it.
211
- */
212
- getTransactor(): SereusPluginResult['transactor'] {
213
- this.ensureInitialized();
214
- return this.resolvedTransactor!;
215
- }
216
-
217
- /**
218
- * Close the database and cleanup resources. Runs the strand-connection
219
- * shutdown (collection-factory teardown, which also stops the injected node),
220
- * then closes the `Database`. `StrandInstanceManager.releaseRuntime` issues a
221
- * further idempotent `node.stop()` after this returns.
222
- */
223
- async close(): Promise<void> {
224
- if (this.shutdownStrand) {
225
- await this.shutdownStrand();
226
- this.shutdownStrand = null;
227
- }
228
- if (this.db) {
229
- void this.db.close();
230
- this.db = null;
231
- }
232
- this.resolvedTransactor = null;
233
- this.initialized = false;
234
- log('StrandDatabase for strand %s closed', this.config.strandId);
235
- }
236
-
237
- private ensureInitialized(): void {
238
- if (!this.initialized || !this.db) {
239
- throw new Error(`StrandDatabase for strand ${this.config.strandId} not initialized. Call initialize() first.`);
240
- }
241
- }
242
- }
1
+ import debug from 'debug';
2
+ import { Database } from '@quereus/quereus';
3
+ import { applyAppSchema, connectToStrand } from '@serfab/quereus-plugin-sereus';
4
+ import type { SereusPluginResult } from '@serfab/quereus-plugin-sereus';
5
+ import type { Libp2p } from '@libp2p/interface';
6
+ import type { IRepo } from '@optimystic/db-core';
7
+ import type { SAppConfig } from './types.js';
8
+ import { bootstrapFounderMembership } from './strand-membership-writer.js';
9
+ import { strandMemberKeyPair } from './strand-member-key.js';
10
+
11
+ const log = debug('sereus:cadre:strand-db');
12
+ const timing = debug('sereus:cadre:timing');
13
+
14
+ export interface StrandDatabaseConfig {
15
+ /** The strand ID */
16
+ strandId: string;
17
+ /**
18
+ * sApp configuration containing the schema. Absent for a storage replica — a strand
19
+ * this node stores and serves without the app installed: only the `Strand` membership
20
+ * schema is applied, and such a database can never found (see {@link founderSApp}).
21
+ */
22
+ sAppConfig?: SAppConfig;
23
+ /** Libp2p node for the strand network */
24
+ libp2pNode: Libp2p;
25
+ /** Coordinated repo from the libp2p node */
26
+ coordinatedRepo: IRepo;
27
+ /**
28
+ * Strand type (`'o'` open / `'c'` closed) from the control-network strand row.
29
+ * Drives the founder bootstrap: open strands get a `Header` only; closed strands
30
+ * also get the founding `Member`+`Manager`.
31
+ */
32
+ strandType: 'o' | 'c';
33
+ /**
34
+ * The closed-strand `MemberPrivateKey` (base64 protobuf) from the strand row —
35
+ * the strand-wide read secret every joining party receives. Carried for the
36
+ * strand's read-gating story only; it derives NOBODY's identity (that used to be
37
+ * this key's second job, which let any joiner sign as the founding manager —
38
+ * gotchoices/sereus#4). Absent for open strands.
39
+ */
40
+ memberPrivateKey?: string;
41
+ /**
42
+ * THIS party's own strand membership private key (base64 protobuf), from the
43
+ * control-layer `StrandPartyKey` row (or supplied explicitly at attach). Required
44
+ * when `founder` is true and `strandType` is `'c'` — it derives the founding
45
+ * `Member.Key`/`Manager.MemberKey`. Never shared outside the party; absent for
46
+ * open strands and for joiners that have not yet persisted one.
47
+ */
48
+ partyMemberPrivateKey?: string;
49
+ /**
50
+ * Whether this node founds the strand. When true, {@link initialize} runs the
51
+ * one-time founder membership bootstrap after the schema is applied. Joiners
52
+ * leave this false and write nothing (rows arrive via sync). Defaults to false.
53
+ */
54
+ founder?: boolean;
55
+ }
56
+
57
+ /**
58
+ * StrandDatabase manages the sApp schema for a strand using Quereus with the
59
+ * Optimystic backend. Each strand instance has its own isolated database with
60
+ * the sApp's schema applied.
61
+ *
62
+ * This class owns the `Database` lifecycle (creation, `getDatabase()`, `close()`)
63
+ * but delegates the actual SQL-surface composition — plugin registration, node
64
+ * wiring, catalog hydration, schema apply — to `connectToStrand` from
65
+ * `@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()`.)
70
+ */
71
+ export class StrandDatabase {
72
+ private db: Database | null = null;
73
+ private shutdownStrand: SereusPluginResult['shutdown'] | null = null;
74
+ private resolvedTransactor: SereusPluginResult['transactor'] | null = null;
75
+ private readonly config: StrandDatabaseConfig;
76
+ private initialized = false;
77
+
78
+ constructor(config: StrandDatabaseConfig) {
79
+ this.config = config;
80
+ }
81
+
82
+ /**
83
+ * Initialize the database — create the `Database` and delegate the strand
84
+ * SQL-surface composition (plugins, node wiring, hydrate, schema apply) to
85
+ * `connectToStrand` with the injected libp2p node.
86
+ */
87
+ async initialize(): Promise<void> {
88
+ if (this.initialized) {
89
+ log('StrandDatabase for strand %s already initialized', this.config.strandId);
90
+ return;
91
+ }
92
+
93
+ const sid = this.config.strandId;
94
+ const { sAppConfig } = this.config;
95
+ if (this.config.founder === true) {
96
+ // Before composing anything: a founder that cannot write its Header must not
97
+ // leave a half-built connection behind.
98
+ this.founderSApp();
99
+ }
100
+ log('Initializing StrandDatabase for strand: %s (%s)', sid,
101
+ sAppConfig ? `sApp: ${sAppConfig.id} v${sAppConfig.version}` : 'storage replica');
102
+
103
+ this.db = new Database();
104
+
105
+ // 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;
110
+ // - no `storage` is passed: the plugin consumes it only to build a local
111
+ // transactor's raw-storage factory or to create a node it was not given,
112
+ // and cadre-core always injects the node and runs the network transactor.
113
+ // (The plugin keeps its `storage` option for the browser entry point.)
114
+ const t0 = performance.now();
115
+ //
116
+ // A storage replica passes no schema, so no `App` tables are declared. A warm restart's
117
+ // hydrate may still load `App` table definitions persisted in the replicated store into
118
+ // the catalog — declarative metadata, nothing the app supplied runs — and the first-sync
119
+ // probe then reads those tables once from blocks this node holds.
120
+ const result = await connectToStrand(this.db, {
121
+ strandId: sid,
122
+ schema: sAppConfig?.schema,
123
+ libp2pNode: this.config.libp2pNode,
124
+ coordinatedRepo: this.config.coordinatedRepo,
125
+ enableCache: true,
126
+ });
127
+ this.shutdownStrand = result.shutdown;
128
+ this.resolvedTransactor = result.transactor;
129
+ timing('[strandDb:%s] connectToStrand: %dms (hydrated tables=%d, indexes=%d)',
130
+ sid, Math.round(performance.now() - t0),
131
+ result.hydrated?.tables ?? 0, result.hydrated?.indexes ?? 0);
132
+
133
+ // Founder-only: write the one-time membership bootstrap now that the schema is
134
+ // 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.
137
+ if (this.config.founder === true) {
138
+ await this.bootstrapFounder();
139
+ }
140
+
141
+ this.initialized = true;
142
+ log('StrandDatabase for strand %s initialized successfully', sid);
143
+ }
144
+
145
+ /**
146
+ * Run the founder membership bootstrap against the freshly-composed strand DB.
147
+ *
148
+ * Derives the founding keypair from this PARTY's own `partyMemberPrivateKey` (the
149
+ * `Member.Key`/`Manager.MemberKey` are its public key) — deliberately NOT from the
150
+ * strand row's shared `MemberPrivateKey`, which every joining party receives and could
151
+ * therefore use to forge the founder's identity. A closed strand with no
152
+ * `partyMemberPrivateKey` throws, because it could never seat a founding manager.
153
+ * The shared key's public half IS passed, but only so the bootstrap can refuse a strand
154
+ * founded under it before the split (`PreSplitStrandIdentityError`). Open strands derive
155
+ * no keypair (Header only). Idempotent — see {@link bootstrapFounderMembership}.
156
+ */
157
+ private async bootstrapFounder(): Promise<void> {
158
+ const { strandId, strandType, memberPrivateKey, partyMemberPrivateKey } = this.config;
159
+ const closed = strandType === 'c';
160
+ await bootstrapFounderMembership(this.db!, {
161
+ strandId,
162
+ type: strandType,
163
+ sApp: this.founderSApp(),
164
+ founderKeyPair: closed ? this.deriveFounderKeyPair(strandId, partyMemberPrivateKey) : undefined,
165
+ sharedMemberPublicKey: closed && memberPrivateKey
166
+ ? strandMemberKeyPair(memberPrivateKey).publicKeyB64
167
+ : undefined,
168
+ });
169
+ }
170
+
171
+ /**
172
+ * The sApp a founder bootstrap records in the `Strand.Header`. Throws for a storage
173
+ * replica (no sApp config): its Header's sApp columns would have nothing to say.
174
+ * `CadreNode` always launches a replica as a joiner, so this guards a future caller.
175
+ */
176
+ private founderSApp(): SAppConfig {
177
+ const { sAppConfig, strandId } = this.config;
178
+ if (!sAppConfig) {
179
+ throw new Error(
180
+ `Cannot found strand ${strandId} as a storage replica: the founder bootstrap writes ` +
181
+ 'the sApp id and version into Strand.Header, and a replica has no sApp config.',
182
+ );
183
+ }
184
+ return sAppConfig;
185
+ }
186
+
187
+ /**
188
+ * Derive the founding keypair for a closed strand from the party's own
189
+ * `partyMemberPrivateKey`, failing loudly when the key is absent (a closed strand
190
+ * with no founding Manager can never admit anyone).
191
+ */
192
+ private deriveFounderKeyPair(strandId: string, partyMemberPrivateKey: string | undefined) {
193
+ if (!partyMemberPrivateKey) {
194
+ throw new Error(
195
+ `Cannot found closed strand ${strandId}: this party has no StrandPartyKey for it. ` +
196
+ 'A closed strand needs a founding Member/Manager derived from the party\'s own ' +
197
+ 'membership key (minted at publishStrand, or at the founder launch that follows a ' +
198
+ 'publish interrupted before its mint) — the shared MemberPrivateKey deliberately ' +
199
+ 'no longer derives anyone\'s identity.',
200
+ );
201
+ }
202
+ return strandMemberKeyPair(partyMemberPrivateKey);
203
+ }
204
+
205
+ /**
206
+ * Run the founder membership bootstrap against an ALREADY-LIVE database — the
207
+ * seam {@link StrandInstanceManager.foundExistingStrand} uses when a founder
208
+ * request arrives for an instance that was first launched as a joiner.
209
+ * Idempotent: every bootstrap write is insert-if-absent
210
+ * ({@link bootstrapFounderMembership}), so calling it on an instance that
211
+ * already founded writes nothing. On success also flips the captured config's
212
+ * `founder`, so this object's own record of how it was launched stays coherent
213
+ * with what actually ran (a construction-time `founder: false` is a statement
214
+ * about the launch, not a permanent identity); a refused bootstrap leaves it a joiner.
215
+ *
216
+ * @param partyMemberPrivateKey - The party's own membership key, for a closed strand
217
+ * whose original (joiner) launch resolved none — e.g. the `StrandPartyKey` row had
218
+ * not been written or replicated yet. The captured config's key wins when both
219
+ * exist: it is the identity this instance launched under.
220
+ */
221
+ async ensureFounderBootstrap(partyMemberPrivateKey?: string): Promise<void> {
222
+ this.ensureInitialized();
223
+ this.config.partyMemberPrivateKey ??= partyMemberPrivateKey;
224
+ await this.bootstrapFounder();
225
+ this.config.founder = true;
226
+ }
227
+
228
+ /**
229
+ * Give a live storage replica the app's schema: apply `App` to the already-composed
230
+ * database — same libp2p node, same store, so the new tables read the blocks this node
231
+ * already holds — and record the sApp, so a later {@link ensureFounderBootstrap} has it
232
+ * for the `Header`. Idempotent: the apply is a declarative diff, so re-applying the same
233
+ * schema emits nothing. A failed apply records nothing.
234
+ */
235
+ async attachAppSchema(sAppConfig: SAppConfig): Promise<void> {
236
+ this.ensureInitialized();
237
+ await applyAppSchema(this.db!, sAppConfig.schema);
238
+ this.config.sAppConfig = sAppConfig;
239
+ log('StrandDatabase for strand %s attached sApp %s v%s', this.config.strandId, sAppConfig.id, sAppConfig.version);
240
+ }
241
+
242
+ /**
243
+ * Get the underlying database for queries
244
+ */
245
+ getDatabase(): Database {
246
+ this.ensureInitialized();
247
+ return this.db!;
248
+ }
249
+
250
+ /**
251
+ * The Optimystic transactor this strand's connection resolved to — always
252
+ * `'network'` here, since cadre-core passes no `transactor` option and takes
253
+ * the plugin's default. Exposed so a spec measuring or asserting the network
254
+ * path (`strand-solo-write-budget.spec.ts`) can pin the engine it ran on
255
+ * rather than assume it.
256
+ */
257
+ getTransactor(): SereusPluginResult['transactor'] {
258
+ this.ensureInitialized();
259
+ return this.resolvedTransactor!;
260
+ }
261
+
262
+ /**
263
+ * 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.
267
+ */
268
+ async close(): Promise<void> {
269
+ if (this.shutdownStrand) {
270
+ await this.shutdownStrand();
271
+ this.shutdownStrand = null;
272
+ }
273
+ if (this.db) {
274
+ void this.db.close();
275
+ this.db = null;
276
+ }
277
+ this.resolvedTransactor = null;
278
+ this.initialized = false;
279
+ log('StrandDatabase for strand %s closed', this.config.strandId);
280
+ }
281
+
282
+ private ensureInitialized(): void {
283
+ if (!this.initialized || !this.db) {
284
+ throw new Error(`StrandDatabase for strand ${this.config.strandId} not initialized. Call initialize() first.`);
285
+ }
286
+ }
287
+ }