@serfab/cadre-core 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1176 -564
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +14 -13
  35. package/dist/index.js +16 -13
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +89 -20
  56. package/dist/peer-join-backfill.js +265 -108
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/strand-database.d.ts +23 -2
  65. package/dist/strand-database.js +26 -6
  66. package/dist/strand-database.js.map +1 -1
  67. package/dist/strand-first-sync-gate.d.ts +176 -89
  68. package/dist/strand-first-sync-gate.js +288 -122
  69. package/dist/strand-first-sync-gate.js.map +1 -1
  70. package/dist/strand-formation-deadlines.d.ts +9 -9
  71. package/dist/strand-formation-deadlines.js +9 -9
  72. package/dist/strand-formation-manager.d.ts +23 -17
  73. package/dist/strand-formation-manager.js +46 -44
  74. package/dist/strand-formation-manager.js.map +1 -1
  75. package/dist/strand-formation-protocol.d.ts +5 -8
  76. package/dist/strand-formation-protocol.js +101 -16
  77. package/dist/strand-formation-protocol.js.map +1 -1
  78. package/dist/strand-instance-manager.d.ts +26 -17
  79. package/dist/strand-instance-manager.js +119 -50
  80. package/dist/strand-instance-manager.js.map +1 -1
  81. package/dist/strand-membership-reconciler.d.ts +16 -13
  82. package/dist/strand-membership-reconciler.js +30 -22
  83. package/dist/strand-membership-reconciler.js.map +1 -1
  84. package/dist/strand-membership-writer.d.ts +40 -28
  85. package/dist/strand-membership-writer.js +212 -153
  86. package/dist/strand-membership-writer.js.map +1 -1
  87. package/dist/strand-move.js +5 -2
  88. package/dist/strand-move.js.map +1 -1
  89. package/dist/strand-revocation-enforcer.d.ts +134 -60
  90. package/dist/strand-revocation-enforcer.js +144 -71
  91. package/dist/strand-revocation-enforcer.js.map +1 -1
  92. package/dist/strand-solicitation.d.ts +50 -39
  93. package/dist/strand-solicitation.js +10 -3
  94. package/dist/strand-solicitation.js.map +1 -1
  95. package/dist/strand-watcher.d.ts +80 -13
  96. package/dist/strand-watcher.js +152 -48
  97. package/dist/strand-watcher.js.map +1 -1
  98. package/dist/table-change-watch.d.ts +63 -0
  99. package/dist/table-change-watch.js +209 -0
  100. package/dist/table-change-watch.js.map +1 -0
  101. package/dist/timeout-scheduler.d.ts +4 -3
  102. package/dist/timeout-scheduler.js +4 -3
  103. package/dist/timeout-scheduler.js.map +1 -1
  104. package/dist/types.d.ts +90 -49
  105. package/dist/types.js +4 -4
  106. package/dist/types.js.map +1 -1
  107. package/package.json +8 -8
  108. package/src/cadre-invite-protocol.ts +119 -31
  109. package/src/cadre-node.ts +1282 -584
  110. package/src/control-cohort.ts +86 -9
  111. package/src/control-database.ts +848 -403
  112. package/src/control-formation-recorder.ts +359 -353
  113. package/src/control-protocol-guard.ts +16 -2
  114. package/src/control-schema-version.ts +101 -51
  115. package/src/control-schema.ts +16 -16
  116. package/src/control-write-retry.ts +91 -19
  117. package/src/delegate-admission.ts +351 -351
  118. package/src/enrolled-machine-store.ts +3 -3
  119. package/src/formation-token.ts +63 -0
  120. package/src/index.ts +30 -17
  121. package/src/joined-strand-store.ts +69 -24
  122. package/src/link-budget.ts +9 -7
  123. package/src/member-redemption.ts +20 -13
  124. package/src/membership-view.ts +144 -0
  125. package/src/owner-grant-protocol.ts +33 -18
  126. package/src/peer-addr-book.ts +4 -3
  127. package/src/peer-join-backfill.ts +971 -761
  128. package/src/pending-join-runner.ts +76 -53
  129. package/src/seed-bootstrap.ts +72 -67
  130. package/src/strand-database.ts +352 -322
  131. package/src/strand-first-sync-gate.ts +334 -134
  132. package/src/strand-formation-deadlines.ts +227 -227
  133. package/src/strand-formation-manager.ts +731 -725
  134. package/src/strand-formation-protocol.ts +122 -24
  135. package/src/strand-instance-manager.ts +2092 -2026
  136. package/src/strand-membership-reconciler.ts +33 -25
  137. package/src/strand-membership-writer.ts +2093 -2019
  138. package/src/strand-move.ts +5 -2
  139. package/src/strand-revocation-enforcer.ts +740 -654
  140. package/src/strand-solicitation.ts +61 -34
  141. package/src/strand-watcher.ts +175 -56
  142. package/src/table-change-watch.ts +285 -0
  143. package/src/timeout-scheduler.ts +29 -28
  144. package/src/types.ts +89 -51
@@ -1,322 +1,352 @@
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
- import { StrandCommitRecord } from './strand-commit-durability.js';
11
- import type { StrandCommitDurability, StrandCommitListeners } from './strand-commit-durability.js';
12
-
13
- const log = debug('sereus:cadre:strand-db');
14
- const timing = debug('sereus:cadre:timing');
15
-
16
- export interface StrandDatabaseConfig extends StrandCommitListeners {
17
- /** The strand ID */
18
- strandId: string;
19
- /**
20
- * sApp configuration containing the schema. Absent for a storage replica — a strand
21
- * this node stores and serves without the app installed: only the `Strand` membership
22
- * schema is applied, and such a database can never found (see {@link founderSApp}).
23
- */
24
- sAppConfig?: SAppConfig;
25
- /** Libp2p node for the strand network */
26
- libp2pNode: Libp2p;
27
- /** Coordinated repo from the libp2p node */
28
- coordinatedRepo: IRepo;
29
- /**
30
- * Strand type (`'o'` open / `'c'` closed) from the control-network strand row.
31
- * Drives the founder bootstrap: open strands get a `Header` only; closed strands
32
- * also get the founding `Member`+`Manager`.
33
- */
34
- strandType: 'o' | 'c';
35
- /**
36
- * The closed-strand `MemberPrivateKey` (base64 protobuf) from the strand row —
37
- * the strand-wide read secret every joining party receives. Carried for the
38
- * strand's read-gating story only; it derives NOBODY's identity (that used to be
39
- * this key's second job, which let any joiner sign as the founding manager —
40
- * gotchoices/sereus#4). Absent for open strands.
41
- */
42
- memberPrivateKey?: string;
43
- /**
44
- * THIS party's own strand membership private key (base64 protobuf), from the
45
- * control-layer `StrandPartyKey` row (or supplied explicitly at attach). Required
46
- * when `founder` is true and `strandType` is `'c'` — it derives the founding
47
- * `Member.Key`/`Manager.MemberKey`. Never shared outside the party; absent for
48
- * open strands and for joiners that have not yet persisted one.
49
- */
50
- partyMemberPrivateKey?: string;
51
- /**
52
- * Whether this node founds the strand. When true, {@link initialize} runs the
53
- * one-time founder membership bootstrap after the schema is applied. Joiners
54
- * leave this false and write nothing (rows arrive via sync). Defaults to false.
55
- */
56
- founder?: boolean;
57
- }
58
-
59
- /**
60
- * StrandDatabase manages the sApp schema for a strand using Quereus with the
61
- * Optimystic backend. Each strand instance has its own isolated database with
62
- * the sApp's schema applied.
63
- *
64
- * This class owns the `Database` lifecycle (creation, `getDatabase()`, `close()`)
65
- * but delegates the actual SQL-surface composition — plugin registration, node
66
- * wiring, catalog hydration, schema apply — to `connectToStrand` from
67
- * `@serfab/quereus-plugin-sereus`, the single shared composition. The libp2p
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.
74
- */
75
- export class StrandDatabase {
76
- private db: Database | null = null;
77
- private shutdownStrand: SereusPluginResult['shutdown'] | null = null;
78
- private resolvedTransactor: SereusPluginResult['transactor'] | null = null;
79
- private createEmptyCollections: SereusPluginResult['createEmptyCollections'] | null = null;
80
- private readonly config: StrandDatabaseConfig;
81
- private initialized = false;
82
- private readonly commits: StrandCommitRecord;
83
- private unsubscribeCommits: (() => void) | null = null;
84
-
85
- constructor(config: StrandDatabaseConfig) {
86
- this.config = config;
87
- this.commits = new StrandCommitRecord(config.strandId, config);
88
- }
89
-
90
- /**
91
- * Initialize the database — create the `Database` and delegate the strand
92
- * SQL-surface composition (plugins, node wiring, hydrate, schema apply) to
93
- * `connectToStrand` with the injected libp2p node.
94
- */
95
- async initialize(): Promise<void> {
96
- if (this.initialized) {
97
- log('StrandDatabase for strand %s already initialized', this.config.strandId);
98
- return;
99
- }
100
-
101
- const sid = this.config.strandId;
102
- const { sAppConfig } = this.config;
103
- if (this.config.founder === true) {
104
- // Before composing anything: a founder that cannot write its Header must not
105
- // leave a half-built connection behind.
106
- this.founderSApp();
107
- }
108
- log('Initializing StrandDatabase for strand: %s (%s)', sid,
109
- sAppConfig ? `sApp: ${sAppConfig.id} v${sAppConfig.version}` : 'storage replica');
110
-
111
- this.db = new Database();
112
-
113
- // Delegate to the shared composition. The node is injected, so:
114
- // - `connectToStrand` never creates or stops a node; `StrandInstanceManager`
115
- // owns the node lifecycle;
116
- // - no `storage` is passed: the plugin consumes it only to build a local
117
- // transactor's raw-storage factory or to create a node it was not given,
118
- // and cadre-core always injects the node and runs the network transactor.
119
- // (The plugin keeps its `storage` option for the browser entry point.)
120
- const t0 = performance.now();
121
- //
122
- // A storage replica passes no schema, so no `App` tables are declared. A warm restart's
123
- // hydrate may still load `App` table definitions persisted in the replicated store into
124
- // the catalog — declarative metadata, nothing the app supplied runs — and the first-sync
125
- // probe then reads those tables once from blocks this node holds.
126
- const result = await connectToStrand(this.db, {
127
- strandId: sid,
128
- schema: sAppConfig?.schema,
129
- libp2pNode: this.config.libp2pNode,
130
- coordinatedRepo: this.config.coordinatedRepo,
131
- enableCache: true,
132
- });
133
- this.shutdownStrand = result.shutdown;
134
- this.resolvedTransactor = result.transactor;
135
- this.createEmptyCollections = result.createEmptyCollections;
136
- // Before the founder bootstrap, so its commits are reported like any other.
137
- this.subscribeToCommits(result);
138
- timing('[strandDb:%s] connectToStrand: %dms (hydrated tables=%d, indexes=%d)',
139
- sid, Math.round(performance.now() - t0),
140
- result.hydrated?.tables ?? 0, result.hydrated?.indexes ?? 0);
141
-
142
- // Founder-only: write the one-time membership bootstrap now that the schema is
143
- // applied (connectToStrand has returned). A throw here propagates out of
144
- // initialize(), and the caller closes this half-built database (close() runs the
145
- // composition's shutdown). Joiners skip this — their rows arrive via sync.
146
- if (this.config.founder === true) {
147
- await this.bootstrapFounder();
148
- }
149
-
150
- this.initialized = true;
151
- log('StrandDatabase for strand %s initialized successfully', sid);
152
- }
153
-
154
- private subscribeToCommits(result: SereusPluginResult): void {
155
- const unsubscribeDurability = result.onCommitDurability(report => this.commits.reportCommit(report));
156
- const unsubscribeHeld = result.onCommitFullyHeld(event => this.commits.reportHeld(event));
157
- this.unsubscribeCommits = () => {
158
- unsubscribeDurability();
159
- unsubscribeHeld();
160
- };
161
- }
162
-
163
- /**
164
- * Run the founder membership bootstrap against the freshly-composed strand DB.
165
- *
166
- * Derives the founding keypair from this PARTY's own `partyMemberPrivateKey` (the
167
- * `Member.Key`/`Manager.MemberKey` are its public key) — deliberately NOT from the
168
- * strand row's shared `MemberPrivateKey`, which every joining party receives and could
169
- * therefore use to forge the founder's identity. A closed strand with no
170
- * `partyMemberPrivateKey` throws, because it could never seat a founding manager.
171
- * The shared key's public half IS passed, but only so the bootstrap can refuse a strand
172
- * founded under it before the split (`PreSplitStrandIdentityError`). Open strands derive
173
- * no keypair (Header only). Idempotent — see {@link bootstrapFounderMembership}.
174
- */
175
- private async bootstrapFounder(): Promise<void> {
176
- const { strandId, strandType, memberPrivateKey, partyMemberPrivateKey } = this.config;
177
- const closed = strandType === 'c';
178
- await bootstrapFounderMembership(this.db!, {
179
- strandId,
180
- type: strandType,
181
- sApp: this.founderSApp(),
182
- founderKeyPair: closed ? this.deriveFounderKeyPair(strandId, partyMemberPrivateKey) : undefined,
183
- sharedMemberPublicKey: closed && memberPrivateKey
184
- ? strandMemberKeyPair(memberPrivateKey).publicKeyB64
185
- : undefined,
186
- createEmptyCollections: this.createEmptyCollections!,
187
- });
188
- }
189
-
190
- /**
191
- * The sApp a founder bootstrap records in the `Strand.Header`. Throws for a storage
192
- * replica (no sApp config): its Header's sApp columns would have nothing to say.
193
- * `CadreNode` always launches a replica as a joiner, so this guards a future caller.
194
- */
195
- private founderSApp(): SAppConfig {
196
- const { sAppConfig, strandId } = this.config;
197
- if (!sAppConfig) {
198
- throw new Error(
199
- `Cannot found strand ${strandId} as a storage replica: the founder bootstrap writes ` +
200
- 'the sApp id and version into Strand.Header, and a replica has no sApp config.',
201
- );
202
- }
203
- return sAppConfig;
204
- }
205
-
206
- /**
207
- * Derive the founding keypair for a closed strand from the party's own
208
- * `partyMemberPrivateKey`, failing loudly when the key is absent (a closed strand
209
- * with no founding Manager can never admit anyone).
210
- */
211
- private deriveFounderKeyPair(strandId: string, partyMemberPrivateKey: string | undefined) {
212
- if (!partyMemberPrivateKey) {
213
- throw new Error(
214
- `Cannot found closed strand ${strandId}: this party has no StrandPartyKey for it. ` +
215
- 'A closed strand needs a founding Member/Manager derived from the party\'s own ' +
216
- 'membership key (minted at publishStrand, or at the founder launch that follows a ' +
217
- 'publish interrupted before its mint) — the shared MemberPrivateKey deliberately ' +
218
- 'no longer derives anyone\'s identity.',
219
- );
220
- }
221
- return strandMemberKeyPair(partyMemberPrivateKey);
222
- }
223
-
224
- /**
225
- * Run the founder membership bootstrap against an ALREADY-LIVE database — the
226
- * seam {@link StrandInstanceManager.foundExistingStrand} uses when a founder
227
- * request arrives for an instance that was first launched as a joiner.
228
- * Idempotent: every bootstrap write is insert-if-absent
229
- * ({@link bootstrapFounderMembership}), so calling it on an instance that
230
- * already founded writes nothing. On success also flips the captured config's
231
- * `founder`, so this object's own record of how it was launched stays coherent
232
- * with what actually ran (a construction-time `founder: false` is a statement
233
- * about the launch, not a permanent identity); a refused bootstrap leaves it a joiner.
234
- *
235
- * @param partyMemberPrivateKey - The party's own membership key, for a closed strand
236
- * whose original (joiner) launch resolved none — e.g. the `StrandPartyKey` row had
237
- * not been written or replicated yet. The captured config's key wins when both
238
- * exist: it is the identity this instance launched under.
239
- */
240
- async ensureFounderBootstrap(partyMemberPrivateKey?: string): Promise<void> {
241
- this.ensureInitialized();
242
- this.config.partyMemberPrivateKey ??= partyMemberPrivateKey;
243
- await this.bootstrapFounder();
244
- this.config.founder = true;
245
- }
246
-
247
- /**
248
- * Give a live storage replica the app's schema: apply `App` to the already-composed
249
- * database — same libp2p node, same store, so the new tables read the blocks this node
250
- * already holds — and record the sApp, so a later {@link ensureFounderBootstrap} has it
251
- * for the `Header`. Idempotent: the apply is a declarative diff, so re-applying the same
252
- * schema emits nothing. A failed apply records nothing.
253
- */
254
- async attachAppSchema(sAppConfig: SAppConfig): Promise<void> {
255
- this.ensureInitialized();
256
- await applyAppSchema(this.db!, sAppConfig.schema);
257
- this.config.sAppConfig = sAppConfig;
258
- log('StrandDatabase for strand %s attached sApp %s v%s', this.config.strandId, sAppConfig.id, sAppConfig.version);
259
- }
260
-
261
- /**
262
- * Get the underlying database for queries
263
- */
264
- getDatabase(): Database {
265
- this.ensureInitialized();
266
- return this.db!;
267
- }
268
-
269
- /**
270
- * The durability of a recent commit on this database, by its `StampId()` — the id a single
271
- * `insert … returning StampId() as Tx` statement returns. Read it after that statement
272
- * resolves: the plugin reports the commit before the statement returns, and a commit already
273
- * held everywhere is marked held by then too. `undefined` for a commit that wrote nothing, one
274
- * made before this database was built, or one more than 256 commits back. Each value is a
275
- * snapshot: read again after `strand:commit-held` to see the change.
276
- */
277
- commitDurability(transactionId: string): StrandCommitDurability | undefined {
278
- this.ensureInitialized();
279
- return this.commits.get(transactionId);
280
- }
281
-
282
- /**
283
- * The Optimystic transactor this strand's connection resolved to — always
284
- * `'network'` here, since cadre-core passes no `transactor` option and takes
285
- * the plugin's default. Exposed so a spec measuring or asserting the network
286
- * path (`strand-solo-write-budget.spec.ts`) can pin the engine it ran on
287
- * rather than assume it.
288
- */
289
- getTransactor(): SereusPluginResult['transactor'] {
290
- this.ensureInitialized();
291
- return this.resolvedTransactor!;
292
- }
293
-
294
- /**
295
- * Close the database and cleanup resources. Runs the strand-connection
296
- * shutdown (collection-factory teardown; the injected node keeps running),
297
- * then closes the `Database`. Safe on an instance whose {@link initialize}
298
- * failed part-way.
299
- */
300
- async close(): Promise<void> {
301
- // First, so an event from the closing connection reaches no app handler.
302
- this.unsubscribeCommits?.();
303
- this.unsubscribeCommits = null;
304
- if (this.shutdownStrand) {
305
- await this.shutdownStrand();
306
- this.shutdownStrand = null;
307
- }
308
- if (this.db) {
309
- void this.db.close();
310
- this.db = null;
311
- }
312
- this.resolvedTransactor = null;
313
- this.initialized = false;
314
- log('StrandDatabase for strand %s closed', this.config.strandId);
315
- }
316
-
317
- private ensureInitialized(): void {
318
- if (!this.initialized || !this.db) {
319
- throw new Error(`StrandDatabase for strand ${this.config.strandId} not initialized. Call initialize() first.`);
320
- }
321
- }
322
- }
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
+ import { StrandCommitRecord } from './strand-commit-durability.js';
11
+ import type { StrandCommitDurability, StrandCommitListeners } from './strand-commit-durability.js';
12
+ import { TableWatchRegistry } from './table-change-watch.js';
13
+ import type { TableChangeWatch, TableChangeWatchOptions } from './table-change-watch.js';
14
+
15
+ const log = debug('sereus:cadre:strand-db');
16
+ const timing = debug('sereus:cadre:timing');
17
+
18
+ export interface StrandDatabaseConfig extends StrandCommitListeners {
19
+ /** The strand ID */
20
+ strandId: string;
21
+ /**
22
+ * sApp configuration containing the schema. Absent for a storage replica — a strand
23
+ * this node stores and serves without the app installed: only the `Strand` membership
24
+ * schema is applied, and such a database can never found (see {@link founderSApp}).
25
+ */
26
+ sAppConfig?: SAppConfig;
27
+ /** Libp2p node for the strand network */
28
+ libp2pNode: Libp2p;
29
+ /** Coordinated repo from the libp2p node */
30
+ coordinatedRepo: IRepo;
31
+ /**
32
+ * Strand type (`'o'` open / `'c'` closed) from the control-network strand row.
33
+ * Drives the founder bootstrap: open strands get a `Header` only; closed strands
34
+ * also get the founding `Member`+`Manager`.
35
+ */
36
+ strandType: 'o' | 'c';
37
+ /**
38
+ * The closed-strand `MemberPrivateKey` (base64 protobuf) from the strand row —
39
+ * the strand-wide read secret every joining party receives. Carried for the
40
+ * strand's read-gating story only; it derives NOBODY's identity (that used to be
41
+ * this key's second job, which let any joiner sign as the founding manager —
42
+ * gotchoices/sereus#4). Absent for open strands.
43
+ */
44
+ memberPrivateKey?: string;
45
+ /**
46
+ * THIS party's own strand membership private key (base64 protobuf), from the
47
+ * control-layer `StrandPartyKey` row (or supplied explicitly at attach). Required
48
+ * when `founder` is true and `strandType` is `'c'` — it derives the founding
49
+ * `Member.Key`/`Manager.MemberKey`. Never shared outside the party; absent for
50
+ * open strands and for joiners that have not yet persisted one.
51
+ */
52
+ partyMemberPrivateKey?: string;
53
+ /**
54
+ * Whether this node founds the strand. When true, {@link initialize} runs the
55
+ * one-time founder membership bootstrap after the schema is applied. Joiners
56
+ * leave this false and write nothing (rows arrive via sync). Defaults to false.
57
+ */
58
+ founder?: boolean;
59
+ /**
60
+ * Whether a closed strand's founder bootstrap also writes this machine's `Strand.MemberPeer`
61
+ * binding (for `libp2pNode`'s peer id) in the founding transaction. That binding is the
62
+ * membership reconciler's job; the founding run that seats the founding `Member` writes it
63
+ * too, which saves the reconciler's commit. False whenever the reconciler is disarmed, so a
64
+ * fixture that hand-drives the membership writers sees only the rows it wrote. Defaults to false.
65
+ */
66
+ bindFounderPeer?: boolean;
67
+ }
68
+
69
+ /**
70
+ * StrandDatabase manages the sApp schema for a strand using Quereus with the
71
+ * Optimystic backend. Each strand instance has its own isolated database with
72
+ * the sApp's schema applied.
73
+ *
74
+ * This class owns the `Database` lifecycle (creation, `getDatabase()`, `close()`)
75
+ * but delegates the actual SQL-surface composition — plugin registration, node
76
+ * wiring, catalog hydration, schema apply — to `connectToStrand` from
77
+ * `@serfab/quereus-plugin-sereus`, the single shared composition. The libp2p
78
+ * node is injected here, so `connectToStrand` neither creates nor stops it:
79
+ * `StrandInstanceManager` owns the node lifecycle, and a failed or closed
80
+ * database leaves the node running.
81
+ *
82
+ * One instance is one attempt: a failed {@link initialize} is closed and a new
83
+ * instance built for the next attempt.
84
+ */
85
+ export class StrandDatabase {
86
+ private db: Database | null = null;
87
+ private shutdownStrand: SereusPluginResult['shutdown'] | null = null;
88
+ private resolvedTransactor: SereusPluginResult['transactor'] | null = null;
89
+ private createEmptyCollections: SereusPluginResult['createEmptyCollections'] | null = null;
90
+ private readonly config: StrandDatabaseConfig;
91
+ private initialized = false;
92
+ private readonly commits: StrandCommitRecord;
93
+ private unsubscribeCommits: (() => void) | null = null;
94
+ /** Subscriptions handed out by {@link watchTables}. */
95
+ private readonly tableWatches = new TableWatchRegistry();
96
+
97
+ constructor(config: StrandDatabaseConfig) {
98
+ this.config = config;
99
+ this.commits = new StrandCommitRecord(config.strandId, config);
100
+ }
101
+
102
+ /**
103
+ * Initialize the database — create the `Database` and delegate the strand
104
+ * SQL-surface composition (plugins, node wiring, hydrate, schema apply) to
105
+ * `connectToStrand` with the injected libp2p node.
106
+ */
107
+ async initialize(): Promise<void> {
108
+ if (this.initialized) {
109
+ log('StrandDatabase for strand %s already initialized', this.config.strandId);
110
+ return;
111
+ }
112
+
113
+ const sid = this.config.strandId;
114
+ const { sAppConfig } = this.config;
115
+ if (this.config.founder === true) {
116
+ // Before composing anything: a founder that cannot write its Header must not
117
+ // leave a half-built connection behind.
118
+ this.founderSApp();
119
+ }
120
+ log('Initializing StrandDatabase for strand: %s (%s)', sid,
121
+ sAppConfig ? `sApp: ${sAppConfig.id} v${sAppConfig.version}` : 'storage replica');
122
+
123
+ this.db = new Database();
124
+
125
+ // Delegate to the shared composition. The node is injected, so:
126
+ // - `connectToStrand` never creates or stops a node; `StrandInstanceManager`
127
+ // owns the node lifecycle;
128
+ // - no `storage` is passed: the plugin consumes it only to build a local
129
+ // transactor's raw-storage factory or to create a node it was not given,
130
+ // and cadre-core always injects the node and runs the network transactor.
131
+ // (The plugin keeps its `storage` option for the browser entry point.)
132
+ const t0 = performance.now();
133
+ //
134
+ // A storage replica passes no schema, so no `App` tables are declared. A warm restart's
135
+ // hydrate may still load `App` table definitions persisted in the replicated store into
136
+ // the catalog — declarative metadata, nothing the app supplied runs — and the first-sync
137
+ // probe then reads those tables once from blocks this node holds.
138
+ const result = await connectToStrand(this.db, {
139
+ strandId: sid,
140
+ schema: sAppConfig?.schema,
141
+ libp2pNode: this.config.libp2pNode,
142
+ coordinatedRepo: this.config.coordinatedRepo,
143
+ enableCache: true,
144
+ });
145
+ this.shutdownStrand = result.shutdown;
146
+ this.resolvedTransactor = result.transactor;
147
+ this.createEmptyCollections = result.createEmptyCollections;
148
+ // Before the founder bootstrap, so its commits are reported like any other.
149
+ this.subscribeToCommits(result);
150
+ timing('[strandDb:%s] connectToStrand: %dms (hydrated tables=%d, indexes=%d)',
151
+ sid, Math.round(performance.now() - t0),
152
+ result.hydrated?.tables ?? 0, result.hydrated?.indexes ?? 0);
153
+
154
+ // Founder-only: write the one-time membership bootstrap now that the schema is
155
+ // applied (connectToStrand has returned). A throw here propagates out of
156
+ // initialize(), and the caller closes this half-built database (close() runs the
157
+ // composition's shutdown). Joiners skip this — their rows arrive via sync.
158
+ if (this.config.founder === true) {
159
+ await this.bootstrapFounder();
160
+ }
161
+
162
+ this.initialized = true;
163
+ log('StrandDatabase for strand %s initialized successfully', sid);
164
+ }
165
+
166
+ private subscribeToCommits(result: SereusPluginResult): void {
167
+ const unsubscribeDurability = result.onCommitDurability(report => this.commits.reportCommit(report));
168
+ const unsubscribeHeld = result.onCommitFullyHeld(event => this.commits.reportHeld(event));
169
+ this.unsubscribeCommits = () => {
170
+ unsubscribeDurability();
171
+ unsubscribeHeld();
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Run the founder membership bootstrap against the freshly-composed strand DB.
177
+ *
178
+ * Derives the founding keypair from this PARTY's own `partyMemberPrivateKey` (the
179
+ * `Member.Key`/`Manager.MemberKey` are its public key) — deliberately NOT from the
180
+ * strand row's shared `MemberPrivateKey`, which every joining party receives and could
181
+ * therefore use to forge the founder's identity. A closed strand with no
182
+ * `partyMemberPrivateKey` throws, because it could never seat a founding manager.
183
+ * The shared key's public half IS passed, but only so the bootstrap can refuse a strand
184
+ * founded under it before the split (`PreSplitStrandIdentityError`). The strand node's
185
+ * peer id is passed under {@link StrandDatabaseConfig.bindFounderPeer}: the same node, so
186
+ * the same id, the membership reconciler binds. Open strands derive no keypair (Header
187
+ * only). Idempotent — see {@link bootstrapFounderMembership}.
188
+ */
189
+ private async bootstrapFounder(): Promise<void> {
190
+ const { strandId, strandType, memberPrivateKey, partyMemberPrivateKey, libp2pNode, bindFounderPeer } = this.config;
191
+ const closed = strandType === 'c';
192
+ await bootstrapFounderMembership(this.db!, {
193
+ strandId,
194
+ type: strandType,
195
+ sApp: this.founderSApp(),
196
+ founderKeyPair: closed ? this.deriveFounderKeyPair(strandId, partyMemberPrivateKey) : undefined,
197
+ sharedMemberPublicKey: closed && memberPrivateKey
198
+ ? strandMemberKeyPair(memberPrivateKey).publicKeyB64
199
+ : undefined,
200
+ founderPeerId: closed && bindFounderPeer === true ? libp2pNode.peerId.toString() : undefined,
201
+ createEmptyCollections: this.createEmptyCollections!,
202
+ });
203
+ }
204
+
205
+ /**
206
+ * The sApp a founder bootstrap records in the `Strand.Header`. Throws for a storage
207
+ * replica (no sApp config): its Header's sApp columns would have nothing to say.
208
+ * `CadreNode` always launches a replica as a joiner, so this guards a future caller.
209
+ */
210
+ private founderSApp(): SAppConfig {
211
+ const { sAppConfig, strandId } = this.config;
212
+ if (!sAppConfig) {
213
+ throw new Error(
214
+ `Cannot found strand ${strandId} as a storage replica: the founder bootstrap writes ` +
215
+ 'the sApp id and version into Strand.Header, and a replica has no sApp config.',
216
+ );
217
+ }
218
+ return sAppConfig;
219
+ }
220
+
221
+ /**
222
+ * Derive the founding keypair for a closed strand from the party's own
223
+ * `partyMemberPrivateKey`, failing loudly when the key is absent (a closed strand
224
+ * with no founding Manager can never admit anyone).
225
+ */
226
+ private deriveFounderKeyPair(strandId: string, partyMemberPrivateKey: string | undefined) {
227
+ if (!partyMemberPrivateKey) {
228
+ throw new Error(
229
+ `Cannot found closed strand ${strandId}: this party has no StrandPartyKey for it. ` +
230
+ 'A closed strand needs a founding Member/Manager derived from the party\'s own ' +
231
+ 'membership key (minted when the strand is published, or at the founder launch for ' +
232
+ 'a closed row stored without one) — the shared MemberPrivateKey deliberately ' +
233
+ 'no longer derives anyone\'s identity.',
234
+ );
235
+ }
236
+ return strandMemberKeyPair(partyMemberPrivateKey);
237
+ }
238
+
239
+ /**
240
+ * Run the founder membership bootstrap against an ALREADY-LIVE database — the
241
+ * seam {@link StrandInstanceManager.foundExistingStrand} uses when a founder
242
+ * request arrives for an instance that was first launched as a joiner.
243
+ * Idempotent: every bootstrap write is insert-if-absent
244
+ * ({@link bootstrapFounderMembership}), so calling it on an instance that
245
+ * already founded writes nothing. On success also flips the captured config's
246
+ * `founder`, so this object's own record of how it was launched stays coherent
247
+ * with what actually ran (a construction-time `founder: false` is a statement
248
+ * about the launch, not a permanent identity); a refused bootstrap leaves it a joiner.
249
+ *
250
+ * @param partyMemberPrivateKey - The party's own membership key, for a closed strand
251
+ * whose original (joiner) launch resolved none — e.g. the `StrandPartyKey` row had
252
+ * not been written or replicated yet. The captured config's key wins when both
253
+ * exist: it is the identity this instance launched under.
254
+ */
255
+ async ensureFounderBootstrap(partyMemberPrivateKey?: string): Promise<void> {
256
+ this.ensureInitialized();
257
+ this.config.partyMemberPrivateKey ??= partyMemberPrivateKey;
258
+ await this.bootstrapFounder();
259
+ this.config.founder = true;
260
+ }
261
+
262
+ /**
263
+ * Give a live storage replica the app's schema: apply `App` to the already-composed
264
+ * database — same libp2p node, same store, so the new tables read the blocks this node
265
+ * already holds — and record the sApp, so a later {@link ensureFounderBootstrap} has it
266
+ * for the `Header`. Idempotent: the apply is a declarative diff, so re-applying the same
267
+ * schema emits nothing. A failed apply records nothing.
268
+ */
269
+ async attachAppSchema(sAppConfig: SAppConfig): Promise<void> {
270
+ this.ensureInitialized();
271
+ await applyAppSchema(this.db!, sAppConfig.schema);
272
+ this.config.sAppConfig = sAppConfig;
273
+ log('StrandDatabase for strand %s attached sApp %s v%s', this.config.strandId, sAppConfig.id, sAppConfig.version);
274
+ }
275
+
276
+ /**
277
+ * Get the underlying database for queries
278
+ */
279
+ getDatabase(): Database {
280
+ this.ensureInitialized();
281
+ return this.db!;
282
+ }
283
+
284
+ /**
285
+ * Subscribe `onChange` to changes in this strand's tables (`'Strand.Member'`, …): commits
286
+ * made here and changes replicated into this node's storage alike, on the single-flight
287
+ * schedule `table-change-watch.ts` describes. {@link close} disposes every subscription
288
+ * still live; a hibernated strand's database is closed and rebuilt on wake, so a consumer
289
+ * subscribes again on the new instance.
290
+ */
291
+ watchTables(tables: readonly string[], onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch {
292
+ this.ensureInitialized();
293
+ return this.tableWatches.watch(this.db!, tables, onChange, options);
294
+ }
295
+
296
+ /**
297
+ * The durability of a recent commit on this database, by its `StampId()` — the id a single
298
+ * `insert … returning StampId() as Tx` statement returns. Read it after that statement
299
+ * resolves: the plugin reports the commit before the statement returns, and a commit already
300
+ * held everywhere is marked held by then too. `undefined` for a commit that wrote nothing, one
301
+ * made before this database was built, or one more than 256 commits back. Each value is a
302
+ * snapshot: read again after `strand:commit-held` to see the change.
303
+ */
304
+ commitDurability(transactionId: string): StrandCommitDurability | undefined {
305
+ this.ensureInitialized();
306
+ return this.commits.get(transactionId);
307
+ }
308
+
309
+ /**
310
+ * The Optimystic transactor this strand's connection resolved to — always
311
+ * `'network'` here, since cadre-core passes no `transactor` option and takes
312
+ * the plugin's default. Exposed so a spec measuring or asserting the network
313
+ * path (`strand-solo-write-budget.spec.ts`) can pin the engine it ran on
314
+ * rather than assume it.
315
+ */
316
+ getTransactor(): SereusPluginResult['transactor'] {
317
+ this.ensureInitialized();
318
+ return this.resolvedTransactor!;
319
+ }
320
+
321
+ /**
322
+ * Close the database and cleanup resources. Runs the strand-connection
323
+ * shutdown (collection-factory teardown; the injected node keeps running),
324
+ * then closes the `Database`. Safe on an instance whose {@link initialize}
325
+ * failed part-way.
326
+ */
327
+ async close(): Promise<void> {
328
+ // First, so an event from the closing connection reaches no app handler or table watch.
329
+ this.unsubscribeCommits?.();
330
+ this.unsubscribeCommits = null;
331
+ this.tableWatches.disposeAll();
332
+ if (this.shutdownStrand) {
333
+ await this.shutdownStrand();
334
+ this.shutdownStrand = null;
335
+ }
336
+ // Again: a subscription taken during the await above would keep its timers past close.
337
+ this.tableWatches.disposeAll();
338
+ if (this.db) {
339
+ void this.db.close();
340
+ this.db = null;
341
+ }
342
+ this.resolvedTransactor = null;
343
+ this.initialized = false;
344
+ log('StrandDatabase for strand %s closed', this.config.strandId);
345
+ }
346
+
347
+ private ensureInitialized(): void {
348
+ if (!this.initialized || !this.db) {
349
+ throw new Error(`StrandDatabase for strand ${this.config.strandId} not initialized. Call initialize() first.`);
350
+ }
351
+ }
352
+ }