@serfab/cadre-core 1.19.0 → 1.19.1

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 (64) hide show
  1. package/dist/cadre-node.d.ts +65 -2
  2. package/dist/cadre-node.js +114 -2
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-read-retry.d.ts +19 -16
  5. package/dist/control-read-retry.js +22 -54
  6. package/dist/control-read-retry.js.map +1 -1
  7. package/dist/control-retry.d.ts +50 -6
  8. package/dist/control-retry.js +53 -6
  9. package/dist/control-retry.js.map +1 -1
  10. package/dist/control-write-retry.d.ts +24 -16
  11. package/dist/control-write-retry.js +58 -30
  12. package/dist/control-write-retry.js.map +1 -1
  13. package/dist/edge-node/config.d.ts +8 -2
  14. package/dist/edge-node/config.js +5 -1
  15. package/dist/edge-node/config.js.map +1 -1
  16. package/dist/edge-node/edge-join.js +2 -0
  17. package/dist/edge-node/edge-join.js.map +1 -1
  18. package/dist/edge-node/edge-node.d.ts +10 -0
  19. package/dist/edge-node/edge-node.js +52 -8
  20. package/dist/edge-node/edge-node.js.map +1 -1
  21. package/dist/edge-node/options.d.ts +11 -2
  22. package/dist/edge-node/options.js +11 -4
  23. package/dist/edge-node/options.js.map +1 -1
  24. package/dist/edge-node/platform.d.ts +15 -3
  25. package/dist/edge-node/strands.js +3 -2
  26. package/dist/edge-node/strands.js.map +1 -1
  27. package/dist/index.d.ts +4 -2
  28. package/dist/index.js +6 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/strand-database.d.ts +56 -0
  31. package/dist/strand-database.js +87 -0
  32. package/dist/strand-database.js.map +1 -1
  33. package/dist/strand-instance-manager.d.ts +67 -2
  34. package/dist/strand-instance-manager.js +105 -4
  35. package/dist/strand-instance-manager.js.map +1 -1
  36. package/dist/strand-membership-reconciler.d.ts +13 -1
  37. package/dist/strand-membership-reconciler.js +18 -7
  38. package/dist/strand-membership-reconciler.js.map +1 -1
  39. package/dist/strand-membership-writer.d.ts +16 -2
  40. package/dist/strand-membership-writer.js +54 -8
  41. package/dist/strand-membership-writer.js.map +1 -1
  42. package/dist/strand-offline.d.ts +131 -0
  43. package/dist/strand-offline.js +142 -0
  44. package/dist/strand-offline.js.map +1 -0
  45. package/dist/types.d.ts +35 -0
  46. package/dist/types.js.map +1 -1
  47. package/package.json +8 -8
  48. package/src/cadre-node.ts +11456 -11329
  49. package/src/control-read-retry.ts +22 -58
  50. package/src/control-retry.ts +54 -6
  51. package/src/control-write-retry.ts +58 -30
  52. package/src/edge-node/config.ts +11 -2
  53. package/src/edge-node/edge-join.ts +1 -0
  54. package/src/edge-node/edge-node.ts +55 -6
  55. package/src/edge-node/options.ts +20 -4
  56. package/src/edge-node/platform.ts +15 -3
  57. package/src/edge-node/strands.ts +3 -2
  58. package/src/index.ts +917 -898
  59. package/src/strand-database.ts +466 -352
  60. package/src/strand-instance-manager.ts +2210 -2092
  61. package/src/strand-membership-reconciler.ts +24 -7
  62. package/src/strand-membership-writer.ts +61 -14
  63. package/src/strand-offline.ts +247 -0
  64. package/src/types.ts +2960 -2926
@@ -1,352 +1,466 @@
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
- }
1
+ import debug from 'debug';
2
+ import { Database } from '@quereus/quereus';
3
+ import { applyAppSchema, connectToStrand } from '@serfab/quereus-plugin-sereus';
4
+ import type { SereusPluginResult, StrandOfflineController } from '@serfab/quereus-plugin-sereus';
5
+ import type { Libp2p } from '@libp2p/interface';
6
+ import type { IRepo } from '@optimystic/db-core';
7
+ import type { IKVStore } from '@optimystic/db-p2p';
8
+ import type { SAppConfig } from './types.js';
9
+ import { bootstrapFounderMembership } from './strand-membership-writer.js';
10
+ import { strandMemberKeyPair } from './strand-member-key.js';
11
+ import { StrandCommitRecord } from './strand-commit-durability.js';
12
+ import type { StrandCommitDurability, StrandCommitListeners } from './strand-commit-durability.js';
13
+ import { TableWatchRegistry } from './table-change-watch.js';
14
+ import type { TableChangeWatch, TableChangeWatchOptions } from './table-change-watch.js';
15
+ import { discardQueued, StrandOfflineReport } from './strand-offline.js';
16
+ import type { StrandAppSchemaState, StrandOfflineListeners, StrandOfflineMode, StrandOfflineQueue } from './strand-offline.js';
17
+
18
+ const log = debug('sereus:cadre:strand-db');
19
+ const timing = debug('sereus:cadre:timing');
20
+
21
+ export interface StrandDatabaseConfig extends StrandCommitListeners {
22
+ /** The strand ID */
23
+ strandId: string;
24
+ /**
25
+ * sApp configuration containing the schema. Absent for a storage replica — a strand
26
+ * this node stores and serves without the app installed: only the `Strand` membership
27
+ * schema is applied, and such a database can never found (see {@link founderSApp}).
28
+ */
29
+ sAppConfig?: SAppConfig;
30
+ /** Libp2p node for the strand network */
31
+ libp2pNode: Libp2p;
32
+ /** Coordinated repo from the libp2p node */
33
+ coordinatedRepo: IRepo;
34
+ /**
35
+ * Strand type (`'o'` open / `'c'` closed) from the control-network strand row.
36
+ * Drives the founder bootstrap: open strands get a `Header` only; closed strands
37
+ * also get the founding `Member`+`Manager`.
38
+ */
39
+ strandType: 'o' | 'c';
40
+ /**
41
+ * The closed-strand `MemberPrivateKey` (base64 protobuf) from the strand row —
42
+ * the strand-wide read secret every joining party receives. Carried for the
43
+ * strand's read-gating story only; it derives NOBODY's identity (that used to be
44
+ * this key's second job, which let any joiner sign as the founding manager —
45
+ * gotchoices/sereus#4). Absent for open strands.
46
+ */
47
+ memberPrivateKey?: string;
48
+ /**
49
+ * THIS party's own strand membership private key (base64 protobuf), from the
50
+ * control-layer `StrandPartyKey` row (or supplied explicitly at attach). Required
51
+ * when `founder` is true and `strandType` is `'c'` — it derives the founding
52
+ * `Member.Key`/`Manager.MemberKey`. Never shared outside the party; absent for
53
+ * open strands and for joiners that have not yet persisted one.
54
+ */
55
+ partyMemberPrivateKey?: string;
56
+ /**
57
+ * Whether this node founds the strand. When true, {@link initialize} runs the
58
+ * one-time founder membership bootstrap after the schema is applied. Joiners
59
+ * leave this false and write nothing (rows arrive via sync). Defaults to false.
60
+ */
61
+ founder?: boolean;
62
+ /**
63
+ * Whether a closed strand's founder bootstrap also writes this machine's `Strand.MemberPeer`
64
+ * binding (for `libp2pNode`'s peer id) in the founding transaction. That binding is the
65
+ * membership reconciler's job; the founding run that seats the founding `Member` writes it
66
+ * too, which saves the reconciler's commit. False whenever the reconciler is disarmed, so a
67
+ * fixture that hand-drives the membership writers sees only the rows it wrote. Defaults to false.
68
+ */
69
+ bindFounderPeer?: boolean;
70
+ /**
71
+ * Turn on the connection's offline queue (docs/strands.md → "Offline mode"). Absent: no queue,
72
+ * and {@link StrandDatabase.offline} is undefined.
73
+ */
74
+ offline?: StrandDatabaseOffline;
75
+ }
76
+
77
+ /** {@link StrandDatabaseConfig.offline}: one build's journal, the strand's mode, and where to report. */
78
+ export interface StrandDatabaseOffline extends StrandOfflineListeners {
79
+ /**
80
+ * This build's journal, resolved by {@link StrandDatabase.initialize}, so a launch released while
81
+ * it resolves is caught like one released during the rest of the build. A throw fails initialize.
82
+ */
83
+ journal: () => IKVStore | Promise<IKVStore>;
84
+ /**
85
+ * The strand's mode, read at the end of {@link StrandDatabase.initialize} and at every
86
+ * {@link StrandDatabase.applyOfflineMode}, never captured, so the newest choice wins.
87
+ */
88
+ mode: () => StrandOfflineMode;
89
+ }
90
+
91
+ /**
92
+ * StrandDatabase manages the sApp schema for a strand using Quereus with the
93
+ * Optimystic backend. Each strand instance has its own isolated database with
94
+ * the sApp's schema applied.
95
+ *
96
+ * This class owns the `Database` lifecycle (creation, `getDatabase()`, `close()`)
97
+ * but delegates the actual SQL-surface composition — plugin registration, node
98
+ * wiring, catalog hydration, schema apply — to `connectToStrand` from
99
+ * `@serfab/quereus-plugin-sereus`, the single shared composition. The libp2p
100
+ * node is injected here, so `connectToStrand` neither creates nor stops it:
101
+ * `StrandInstanceManager` owns the node lifecycle, and a failed or closed
102
+ * database leaves the node running.
103
+ *
104
+ * One instance is one attempt: a failed {@link initialize} is closed and a new
105
+ * instance built for the next attempt.
106
+ */
107
+ export class StrandDatabase {
108
+ private db: Database | null = null;
109
+ private shutdownStrand: SereusPluginResult['shutdown'] | null = null;
110
+ private resolvedTransactor: SereusPluginResult['transactor'] | null = null;
111
+ private createEmptyCollections: SereusPluginResult['createEmptyCollections'] | null = null;
112
+ private readonly config: StrandDatabaseConfig;
113
+ private initialized = false;
114
+ private readonly commits: StrandCommitRecord;
115
+ private unsubscribeCommits: (() => void) | null = null;
116
+ private offlineQueue: StrandOfflineController | null = null;
117
+ private offlineReport: StrandOfflineReport | null = null;
118
+ /** Subscriptions handed out by {@link watchTables}. */
119
+ private readonly tableWatches = new TableWatchRegistry();
120
+
121
+ constructor(config: StrandDatabaseConfig) {
122
+ this.config = config;
123
+ this.commits = new StrandCommitRecord(config.strandId, config);
124
+ }
125
+
126
+ /**
127
+ * Initialize the database — create the `Database` and delegate the strand
128
+ * SQL-surface composition (plugins, node wiring, hydrate, schema apply) to
129
+ * `connectToStrand` with the injected libp2p node.
130
+ */
131
+ async initialize(): Promise<void> {
132
+ if (this.initialized) {
133
+ log('StrandDatabase for strand %s already initialized', this.config.strandId);
134
+ return;
135
+ }
136
+
137
+ const sid = this.config.strandId;
138
+ const { sAppConfig } = this.config;
139
+ if (this.config.founder === true) {
140
+ // Before composing anything: a founder that cannot write its Header must not
141
+ // leave a half-built connection behind.
142
+ this.founderSApp();
143
+ }
144
+ log('Initializing StrandDatabase for strand: %s (%s)', sid,
145
+ sAppConfig ? `sApp: ${sAppConfig.id} v${sAppConfig.version}` : 'storage replica');
146
+
147
+ const offline = this.config.offline && { journal: await this.config.offline.journal() };
148
+ this.db = new Database();
149
+
150
+ // Delegate to the shared composition. The node is injected, so:
151
+ // - `connectToStrand` never creates or stops a node; `StrandInstanceManager`
152
+ // owns the node lifecycle;
153
+ // - no `storage` is passed: the plugin consumes it only to build a local
154
+ // transactor's raw-storage factory or to create a node it was not given,
155
+ // and cadre-core always injects the node and runs the network transactor.
156
+ // (The plugin keeps its `storage` option for the browser entry point.)
157
+ const t0 = performance.now();
158
+ //
159
+ // A storage replica passes no schema, so no `App` tables are declared. A warm restart's
160
+ // hydrate may still load `App` table definitions persisted in the replicated store into
161
+ // the catalog — declarative metadata, nothing the app supplied runs — and the first-sync
162
+ // probe then reads those tables once from blocks this node holds.
163
+ const result = await connectToStrand(this.db, {
164
+ strandId: sid,
165
+ schema: sAppConfig?.schema,
166
+ libp2pNode: this.config.libp2pNode,
167
+ coordinatedRepo: this.config.coordinatedRepo,
168
+ enableCache: true,
169
+ offline,
170
+ });
171
+ this.shutdownStrand = result.shutdown;
172
+ this.resolvedTransactor = result.transactor;
173
+ this.createEmptyCollections = result.createEmptyCollections;
174
+ // Before the founder bootstrap, so its commits are reported like any other.
175
+ this.subscribeToCommits(result);
176
+ this.followOffline(result);
177
+ timing('[strandDb:%s] connectToStrand: %dms (hydrated tables=%d, indexes=%d)',
178
+ sid, Math.round(performance.now() - t0),
179
+ result.hydrated?.tables ?? 0, result.hydrated?.indexes ?? 0);
180
+
181
+ // Founder-only: write the one-time membership bootstrap now that the schema is
182
+ // applied (connectToStrand has returned). A throw here propagates out of
183
+ // initialize(), and the caller closes this half-built database (close() runs the
184
+ // composition's shutdown). Joiners skip this — their rows arrive via sync.
185
+ // NOTE: a bootstrap with rows to write while `hydrate` restored queued transactions is refused
186
+ // (`StrandOfflineQueueBusyError`), failing every build, so that queue could never drain. Not
187
+ // reachable today: rows are written only while the founding is incomplete (Member empty, or no
188
+ // Manager and none retired), and an incomplete founding fails the build before the database is
189
+ // ever published for the app to queue in. If a founder is ever published with its founding
190
+ // incomplete, skip the bootstrap here while the queue is non-empty and run it once the queue drains.
191
+ if (this.config.founder === true) {
192
+ await this.bootstrapFounder();
193
+ }
194
+
195
+ // After `hydrate` restored the queue and after the founder bootstrap (its `Strand` writes are
196
+ // network-acknowledged in either mode); before the first-sync probe, so in offline mode a read
197
+ // the cohort cannot answer is served from this machine's copy there as it is to the app.
198
+ this.applyOfflineMode();
199
+ this.initialized = true;
200
+ log('StrandDatabase for strand %s initialized successfully', sid);
201
+ }
202
+
203
+ private subscribeToCommits(result: SereusPluginResult): void {
204
+ const unsubscribeDurability = result.onCommitDurability(report => this.commits.reportCommit(report));
205
+ const unsubscribeHeld = result.onCommitFullyHeld(event => this.commits.reportHeld(event));
206
+ this.unsubscribeCommits = () => {
207
+ unsubscribeDurability();
208
+ unsubscribeHeld();
209
+ };
210
+ }
211
+
212
+ private followOffline(result: SereusPluginResult): void {
213
+ const { offline } = this.config;
214
+ if (!offline) {
215
+ return;
216
+ }
217
+ this.offlineQueue = result.offline ?? null;
218
+ this.offlineReport = new StrandOfflineReport(this.config.strandId, offline, result);
219
+ }
220
+
221
+ /**
222
+ * Put the queue in the strand's current mode ({@link StrandDatabaseOffline.mode}). It sets the
223
+ * acknowledgement of transactions begun from now on; an open one keeps the one it began with.
224
+ * No-op without a queue, and before {@link initialize} has composed one (it applies the mode itself).
225
+ */
226
+ applyOfflineMode(): void {
227
+ const queue = this.offlineQueue;
228
+ if (!queue || !this.config.offline) {
229
+ return;
230
+ }
231
+ if (this.config.offline.mode() === 'offline') {
232
+ queue.enter();
233
+ } else {
234
+ queue.exit();
235
+ }
236
+ }
237
+
238
+ /**
239
+ * The database was handed to the app (`StrandInstanceManager.publishDatabase`): report the
240
+ * queue's state, since restored transactions' `queued` statuses were delivered before anything
241
+ * subscribed, and an `App` schema apply still waiting for the queue.
242
+ */
243
+ reportPublished(): void {
244
+ this.offlineReport?.published();
245
+ }
246
+
247
+ /**
248
+ * Run the founder membership bootstrap against the freshly-composed strand DB.
249
+ *
250
+ * Derives the founding keypair from this PARTY's own `partyMemberPrivateKey` (the
251
+ * `Member.Key`/`Manager.MemberKey` are its public key) — deliberately NOT from the
252
+ * strand row's shared `MemberPrivateKey`, which every joining party receives and could
253
+ * therefore use to forge the founder's identity. A closed strand with no
254
+ * `partyMemberPrivateKey` throws, because it could never seat a founding manager.
255
+ * The shared key's public half IS passed, but only so the bootstrap can refuse a strand
256
+ * founded under it before the split (`PreSplitStrandIdentityError`). The strand node's
257
+ * peer id is passed under {@link StrandDatabaseConfig.bindFounderPeer}: the same node, so
258
+ * the same id, the membership reconciler binds. Open strands derive no keypair (Header
259
+ * only). Idempotent — see {@link bootstrapFounderMembership}.
260
+ */
261
+ private async bootstrapFounder(): Promise<void> {
262
+ const { strandId, strandType, memberPrivateKey, partyMemberPrivateKey, libp2pNode, bindFounderPeer } = this.config;
263
+ const closed = strandType === 'c';
264
+ await bootstrapFounderMembership(this.db!, {
265
+ strandId,
266
+ type: strandType,
267
+ sApp: this.founderSApp(),
268
+ founderKeyPair: closed ? this.deriveFounderKeyPair(strandId, partyMemberPrivateKey) : undefined,
269
+ sharedMemberPublicKey: closed && memberPrivateKey
270
+ ? strandMemberKeyPair(memberPrivateKey).publicKeyB64
271
+ : undefined,
272
+ founderPeerId: closed && bindFounderPeer === true ? libp2pNode.peerId.toString() : undefined,
273
+ createEmptyCollections: this.createEmptyCollections!,
274
+ });
275
+ }
276
+
277
+ /**
278
+ * The sApp a founder bootstrap records in the `Strand.Header`. Throws for a storage
279
+ * replica (no sApp config): its Header's sApp columns would have nothing to say.
280
+ * `CadreNode` always launches a replica as a joiner, so this guards a future caller.
281
+ */
282
+ private founderSApp(): SAppConfig {
283
+ const { sAppConfig, strandId } = this.config;
284
+ if (!sAppConfig) {
285
+ throw new Error(
286
+ `Cannot found strand ${strandId} as a storage replica: the founder bootstrap writes ` +
287
+ 'the sApp id and version into Strand.Header, and a replica has no sApp config.',
288
+ );
289
+ }
290
+ return sAppConfig;
291
+ }
292
+
293
+ /**
294
+ * Derive the founding keypair for a closed strand from the party's own
295
+ * `partyMemberPrivateKey`, failing loudly when the key is absent (a closed strand
296
+ * with no founding Manager can never admit anyone).
297
+ */
298
+ private deriveFounderKeyPair(strandId: string, partyMemberPrivateKey: string | undefined) {
299
+ if (!partyMemberPrivateKey) {
300
+ throw new Error(
301
+ `Cannot found closed strand ${strandId}: this party has no StrandPartyKey for it. ` +
302
+ 'A closed strand needs a founding Member/Manager derived from the party\'s own ' +
303
+ 'membership key (minted when the strand is published, or at the founder launch for ' +
304
+ 'a closed row stored without one) — the shared MemberPrivateKey deliberately ' +
305
+ 'no longer derives anyone\'s identity.',
306
+ );
307
+ }
308
+ return strandMemberKeyPair(partyMemberPrivateKey);
309
+ }
310
+
311
+ /**
312
+ * Run the founder membership bootstrap against an ALREADY-LIVE database — the
313
+ * seam {@link StrandInstanceManager.foundExistingStrand} uses when a founder
314
+ * request arrives for an instance that was first launched as a joiner.
315
+ * Idempotent: every bootstrap write is insert-if-absent
316
+ * ({@link bootstrapFounderMembership}), so calling it on an instance that
317
+ * already founded writes nothing. On success also flips the captured config's
318
+ * `founder`, so this object's own record of how it was launched stays coherent
319
+ * with what actually ran (a construction-time `founder: false` is a statement
320
+ * about the launch, not a permanent identity); a refused bootstrap leaves it a joiner.
321
+ *
322
+ * @param partyMemberPrivateKey - The party's own membership key, for a closed strand
323
+ * whose original (joiner) launch resolved none — e.g. the `StrandPartyKey` row had
324
+ * not been written or replicated yet. The captured config's key wins when both
325
+ * exist: it is the identity this instance launched under.
326
+ */
327
+ async ensureFounderBootstrap(partyMemberPrivateKey?: string): Promise<void> {
328
+ this.ensureInitialized();
329
+ this.config.partyMemberPrivateKey ??= partyMemberPrivateKey;
330
+ await this.bootstrapFounder();
331
+ this.config.founder = true;
332
+ }
333
+
334
+ /**
335
+ * Give a live storage replica the app's schema: apply `App` to the already-composed
336
+ * database — same libp2p node, same store, so the new tables read the blocks this node
337
+ * already holds — and record the sApp, so a later {@link ensureFounderBootstrap} has it
338
+ * for the `Header`. Idempotent: the apply is a declarative diff, so re-applying the same
339
+ * schema emits nothing. A failed apply records nothing.
340
+ */
341
+ async attachAppSchema(sAppConfig: SAppConfig): Promise<void> {
342
+ this.ensureInitialized();
343
+ // NOTE: unlike the connect-time apply (`applyAppSchemaAfterQueue`), this is not deferred behind
344
+ // the offline queue: a replica writes no app transactions, so no queued transaction wrote an
345
+ // `App` table and `QueuedEntriesBlockSchemaChangeError` cannot refuse it. If a replica ever
346
+ // writes app rows offline, route this through the deferral instead of throwing.
347
+ await applyAppSchema(this.db!, sAppConfig.schema);
348
+ this.config.sAppConfig = sAppConfig;
349
+ log('StrandDatabase for strand %s attached sApp %s v%s', this.config.strandId, sAppConfig.id, sAppConfig.version);
350
+ }
351
+
352
+ /**
353
+ * Get the underlying database for queries
354
+ */
355
+ getDatabase(): Database {
356
+ this.ensureInitialized();
357
+ return this.db!;
358
+ }
359
+
360
+ /**
361
+ * Subscribe `onChange` to changes in this strand's tables (`'Strand.Member'`, …): commits
362
+ * made here and changes replicated into this node's storage alike, on the single-flight
363
+ * schedule `table-change-watch.ts` describes. {@link close} disposes every subscription
364
+ * still live; a hibernated strand's database is closed and rebuilt on wake, so a consumer
365
+ * subscribes again on the new instance.
366
+ */
367
+ watchTables(tables: readonly string[], onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch {
368
+ this.ensureInitialized();
369
+ return this.tableWatches.watch(this.db!, tables, onChange, options);
370
+ }
371
+
372
+ /**
373
+ * The durability of a recent commit on this database, by its `StampId()` — the id a single
374
+ * `insert … returning StampId() as Tx` statement returns. Read it after that statement
375
+ * resolves: the plugin reports the commit before the statement returns, and a commit already
376
+ * held everywhere is marked held by then too. `undefined` for a commit that wrote nothing, one
377
+ * made before this database was built, or one more than 256 commits back. Each value is a
378
+ * snapshot: read again after `strand:commit-held` to see the change.
379
+ */
380
+ commitDurability(transactionId: string): StrandCommitDurability | undefined {
381
+ this.ensureInitialized();
382
+ return this.commits.get(transactionId);
383
+ }
384
+
385
+ /**
386
+ * The strand connection's offline queue (docs/strands.md → "Offline mode"), or `undefined` on a
387
+ * node without `strandOffline`. The mode is set through `CadreNode.setStrandOfflineMode`, which
388
+ * outlives this database; listeners taken here die with it, as {@link watchTables} subscriptions do.
389
+ */
390
+ get offline(): StrandOfflineQueue | undefined {
391
+ this.ensureInitialized();
392
+ return this.offlineQueue ?? undefined;
393
+ }
394
+
395
+ /**
396
+ * Whether this database runs the `App` schema its launch was given. `'waiting-for-queue'` while
397
+ * queued transactions hold the apply back, so `StrandInstance.sAppInfo` names a version whose new
398
+ * tables or columns are not here yet; check this before relying on them.
399
+ */
400
+ appSchemaState(): StrandAppSchemaState {
401
+ this.ensureInitialized();
402
+ return this.offlineReport?.appSchemaState() ?? 'applied';
403
+ }
404
+
405
+ /**
406
+ * The transaction ids the offline queue lists (`entries()`, the ones a restore has not put back
407
+ * yet included), or `undefined` with no queue: none configured, not composed yet, or closed.
408
+ * Unlike {@link offline} it does not require {@link initialize} to have finished, so the manager
409
+ * can read a database still being built or about to close.
410
+ */
411
+ queuedTransactionIds(): readonly string[] | undefined {
412
+ return this.offlineQueue?.entries().map((entry) => entry.transactionId);
413
+ }
414
+
415
+ /** {@link discardQueued} on this database's queue; `undefined` with no queue, as {@link queuedTransactionIds}. */
416
+ discardQueuedTransactions(): readonly string[] | undefined {
417
+ return this.offlineQueue ? discardQueued(this.offlineQueue) : undefined;
418
+ }
419
+
420
+ /**
421
+ * The Optimystic transactor this strand's connection resolved to — always
422
+ * `'network'` here, since cadre-core passes no `transactor` option and takes
423
+ * the plugin's default. Exposed so a spec measuring or asserting the network
424
+ * path (`strand-solo-write-budget.spec.ts`) can pin the engine it ran on
425
+ * rather than assume it.
426
+ */
427
+ getTransactor(): SereusPluginResult['transactor'] {
428
+ this.ensureInitialized();
429
+ return this.resolvedTransactor!;
430
+ }
431
+
432
+ /**
433
+ * Close the database and cleanup resources. Runs the strand-connection
434
+ * shutdown (collection-factory teardown; the injected node keeps running),
435
+ * then closes the `Database`. Safe on an instance whose {@link initialize}
436
+ * failed part-way.
437
+ */
438
+ async close(): Promise<void> {
439
+ // First, so an event from the closing connection reaches no app handler or table watch.
440
+ this.unsubscribeCommits?.();
441
+ this.unsubscribeCommits = null;
442
+ this.offlineReport?.release();
443
+ this.offlineReport = null;
444
+ this.offlineQueue = null;
445
+ this.tableWatches.disposeAll();
446
+ if (this.shutdownStrand) {
447
+ await this.shutdownStrand();
448
+ this.shutdownStrand = null;
449
+ }
450
+ // Again: a subscription taken during the await above would keep its timers past close.
451
+ this.tableWatches.disposeAll();
452
+ if (this.db) {
453
+ void this.db.close();
454
+ this.db = null;
455
+ }
456
+ this.resolvedTransactor = null;
457
+ this.initialized = false;
458
+ log('StrandDatabase for strand %s closed', this.config.strandId);
459
+ }
460
+
461
+ private ensureInitialized(): void {
462
+ if (!this.initialized || !this.db) {
463
+ throw new Error(`StrandDatabase for strand ${this.config.strandId} not initialized. Call initialize() first.`);
464
+ }
465
+ }
466
+ }