@serfab/quereus-plugin-sereus 0.11.0 → 0.13.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.
@@ -1,323 +1,396 @@
1
- import debug from 'debug';
2
- import type { Database, VTablePluginInfo, FunctionPluginInfo, CollationPluginInfo } from '@quereus/quereus';
3
- import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
4
- import type { Libp2p } from '@libp2p/interface';
5
- import type { IRepo } from '@optimystic/db-core';
6
- import type { IRawStorage } from '@optimystic/db-p2p';
7
- import type { StrandConnectionOptions, SereusPluginResult, StrandTransactor, Libp2pNodeWithRepo } from './types.js';
8
- import { STRAND_SCHEMA } from './strand-schema.js';
9
- import { resolveStrandClusterSize } from './cluster-size.js';
10
- import { wrapStorageWithCache } from './cached-storage.js';
11
-
12
- const log = debug('sereus:plugin:strand');
13
- const timing = debug('sereus:plugin:strand:timing');
14
-
15
- /**
16
- * Minimal interface for the CollectionFactory returned by the optimystic plugin.
17
- * We only need the methods this composition actually uses.
18
- */
19
- interface CollectionFactory {
20
- registerLibp2pNode(networkName: string, node: Libp2p, coordinatedRepo: IRepo): void;
21
- shutdown(): Promise<void>;
22
- }
23
-
24
- /**
25
- * Result of registering the optimystic plugin. The published plugin signature
26
- * does not surface `collectionFactory` / `hydrate`, so this is the local shim
27
- * over what `@optimystic/quereus-plugin-optimystic/plugin` actually returns
28
- * (see its `plugin.ts` — `hydrate` delegates to `optimysticModule.hydrateCatalog`).
29
- */
30
- interface OptimysticPluginResult {
31
- collectionFactory: CollectionFactory;
32
- vtables: VTablePluginInfo[];
33
- functions: FunctionPluginInfo[];
34
- collations?: CollationPluginInfo[];
35
- /**
36
- * Hydrate Quereus's in-memory catalog from persisted optimystic vtab schemas.
37
- * Must run BEFORE `apply schema App;` on a warm restart so the declarative
38
- * diff sees the existing tables and skips re-emitting CREATE TABLE / CREATE
39
- * INDEX for each persisted object. Idempotent; `{ tables: 0, indexes: 0 }`
40
- * on cold start.
41
- */
42
- hydrate: (db: Database) => Promise<{ tables: number; indexes: number }>;
43
- [key: string]: unknown;
44
- }
45
-
46
- /** Registration shape shared by the crypto and optimystic plugin results. */
47
- interface PluginRegistrations {
48
- vtables?: VTablePluginInfo[];
49
- functions?: FunctionPluginInfo[];
50
- collations?: CollationPluginInfo[];
51
- }
52
-
53
- /**
54
- * Inline equivalent of `@quereus/quereus`'s `registerPlugin` for plugins that
55
- * only return functions/vtables/collations. Used directly for the optimystic
56
- * result (so the composition can read `collectionFactory` off the same object),
57
- * and by the browser crypto strategy to keep the browser bundle from pulling a
58
- * duplicate `@quereus/quereus` next to the host's instance.
59
- */
60
- export function applyRegistrations(db: Database, result: PluginRegistrations): void {
61
- for (const vtable of result.vtables ?? []) {
62
- db.registerModule(vtable.name, vtable.module, vtable.auxData);
63
- }
64
- for (const func of result.functions ?? []) {
65
- db.registerFunction(func.schema);
66
- }
67
- for (const collation of result.collations ?? []) {
68
- db.registerCollation(collation.name, collation.func, collation.normalizer);
69
- }
70
- }
71
-
72
- /** Context passed to the platform's node-creation strategy. */
73
- export interface CreateNodeContext {
74
- networkName: string;
75
- bootstrapNodes: string[];
76
- fretProfile: 'edge' | 'core';
77
- /** libp2p listening port (Node TCP). Browser transports ignore it. */
78
- port: number;
79
- /**
80
- * Resolved replication cluster size. Already defaulted — pass it to
81
- * `createLibp2pNode` verbatim; omitting it falls back to Optimystic's own
82
- * default of 10, which gates writes on any smaller party.
83
- */
84
- clusterSize: number;
85
- /** Resolved persistent storage to back the node, if any. */
86
- storage?: IRawStorage;
87
- }
88
-
89
- /** Context passed to the platform's storage-resolution strategy. */
90
- export interface ResolveStorageContext {
91
- strandId: string;
92
- resolvedTransactor: StrandTransactor;
93
- /** The storage the caller passed in `options.storage`, if any. */
94
- requestedStorage?: IRawStorage;
95
- }
96
-
97
- /**
98
- * Platform-specific seams the shared composition delegates to. Everything else
99
- * (transactor resolution, plugin config, registration, default vtab, hydrate,
100
- * schema apply, cleanup/shutdown) is identical across Node and browser and lives
101
- * in {@link composeStrand}.
102
- */
103
- export interface StrandPlatform {
104
- /**
105
- * Register the crypto plugin against `db`. Node uses `@quereus/quereus`'s
106
- * `registerPlugin`; the browser inlines `applyRegistrations` to avoid bundling
107
- * a second `@quereus/quereus`.
108
- */
109
- registerCrypto(db: Database): void | Promise<void>;
110
- /**
111
- * Resolve the persistent storage to use. Optional — when omitted the caller's
112
- * `options.storage` is used verbatim (Node). The browser supplies this to
113
- * default to IndexedDB so a reload survives.
114
- */
115
- resolveStorage?(ctx: ResolveStorageContext): Promise<IRawStorage | undefined> | IRawStorage | undefined;
116
- /**
117
- * Create a libp2p node. Called only when no node is injected and the
118
- * transactor needs one. Node creates a TCP node via `@optimystic/db-p2p`;
119
- * the browser creates a WebSockets + circuit-relay node via the `/rn` entry.
120
- * Returns the created node (its `coordinatedRepo` is read by the composition).
121
- */
122
- createNode(ctx: CreateNodeContext): Promise<Libp2p>;
123
- }
124
-
125
- /**
126
- * Connect a Quereus `Database` to a Sereus strand. This is the single shared
127
- * SQL-surface composition: `connectToStrand` (Node), `connectToStrandBrowser`
128
- * (browser), and `cadre-core`'s `StrandDatabase` all flow through here, so the
129
- * hydrate-before-apply fix and any future schema wiring land in one place.
130
- *
131
- * Steps: resolve transactor + storage, register crypto + optimystic plugins,
132
- * acquire (inject or create) the libp2p node, set optimystic as the default
133
- * vtab, hydrate the catalog, then apply the sApp schema.
134
- */
135
- export async function composeStrand(
136
- db: Database,
137
- options: StrandConnectionOptions,
138
- platform: StrandPlatform,
139
- ): Promise<SereusPluginResult> {
140
- const {
141
- strandId,
142
- bootstrapNodes = [],
143
- schema,
144
- port = 0,
145
- enableCache = true,
146
- fretProfile = 'edge',
147
- transactor = 'network',
148
- } = options;
149
-
150
- // Resolve (and validate) up front so a nonsense value fails before any plugin
151
- // registration or node creation has happened.
152
- const clusterSize = resolveStrandClusterSize(options.clusterSize);
153
-
154
- // The storage engine every write and read below goes through — see
155
- // `StrandConnectionOptions.transactor`. Named separately from the option so
156
- // the default is applied exactly once and reported on the result.
157
- const resolvedTransactor: StrandTransactor = transactor;
158
-
159
- const networkName = `strand-${strandId}`;
160
- log('Connecting to strand %s (network: %s, transactor=%s)',
161
- strandId, networkName, resolvedTransactor);
162
-
163
- // Resolve storage. Node passes `options.storage` through; the browser
164
- // defaults to IndexedDB. The resolved instance feeds BOTH the local
165
- // transactor's `rawStorageFactory` and node creation, so it must be decided
166
- // up front (before pluginConfig is built). Wrapped in the write-through
167
- // raw-storage cache (cached-storage.ts) — idempotent, so a caller that
168
- // already wrapped (cadre-core's seams) passes through unchanged.
169
- const resolvedStorage = platform.resolveStorage
170
- ? await platform.resolveStorage({ strandId, resolvedTransactor, requestedStorage: options.storage })
171
- : options.storage;
172
- const storage = resolvedStorage ? wrapStorageWithCache(resolvedStorage, strandId) : resolvedStorage;
173
-
174
- // 1. Register crypto plugin (digest, sign, verify, etc.)
175
- await platform.registerCrypto(db);
176
- log('Registered crypto plugin');
177
-
178
- // 2. Register optimystic plugin with transactor defaults. On the local
179
- // transactor with persistent storage, hand the same instance to the plugin so
180
- // DML persists on the host backend (not in-memory).
181
- const pluginConfig: Record<string, unknown> = {
182
- default_transactor: resolvedTransactor,
183
- default_key_network: 'libp2p',
184
- default_network_name: networkName,
185
- enable_cache: enableCache,
186
- };
187
- if (resolvedTransactor === 'local' && storage) {
188
- pluginConfig.rawStorageFactory = () => storage;
189
- }
190
- // The plugin's published signature is `Record<string, SqlValue>` but it
191
- // also reads `rawStorageFactory` (a function reference) from the same map.
192
- // Cast through unknown rather than widen the public type.
193
- const pluginResult = optimysticPlugin(
194
- db,
195
- pluginConfig as unknown as Parameters<typeof optimysticPlugin>[1],
196
- ) as unknown as OptimysticPluginResult;
197
- applyRegistrations(db, pluginResult);
198
- log('Registered optimystic vtables and functions');
199
-
200
- const { collectionFactory } = pluginResult;
201
-
202
- let createdNode: Libp2p | null = null;
203
- let hydrated: { tables: number; indexes: number } | undefined;
204
-
205
- try {
206
- // 3. Acquire the libp2p node. Skip only when this is the unit-test fake
207
- // transactor with no injected node — every real path needs a node.
208
- if (resolvedTransactor !== 'test' || options.libp2pNode) {
209
- let node: Libp2p;
210
- let coordinatedRepo: IRepo;
211
-
212
- if (options.libp2pNode) {
213
- node = options.libp2pNode;
214
- if (!options.coordinatedRepo) {
215
- throw new Error('coordinatedRepo is required when libp2pNode is provided');
216
- }
217
- coordinatedRepo = options.coordinatedRepo;
218
- log('Using injected libp2p node');
219
- } else {
220
- const created = await platform.createNode({ networkName, bootstrapNodes, fretProfile, port, clusterSize, storage });
221
- createdNode = created;
222
- node = created;
223
- const repo = (created as Libp2pNodeWithRepo).coordinatedRepo;
224
- if (!repo) {
225
- throw new Error('coordinatedRepo not available on created libp2p node');
226
- }
227
- coordinatedRepo = repo;
228
- log('Created libp2p node (port: %d, fretProfile: %s, storage=%s)', port, fretProfile, !!storage);
229
- }
230
-
231
- collectionFactory.registerLibp2pNode(networkName, node, coordinatedRepo);
232
- log('Registered libp2p node with collection factory');
233
- }
234
-
235
- // 4. Set optimystic as default vtab so `declare schema` tables use it.
236
- db.setDefaultVtabName('optimystic');
237
- db.setDefaultVtabArgs({
238
- networkName,
239
- transactor: resolvedTransactor,
240
- keyNetwork: 'libp2p',
241
- });
242
- log('Set default vtab to optimystic (networkName=%s, transactor=%s)', networkName, resolvedTransactor);
243
-
244
- // 5. Hydrate Quereus's catalog from persisted optimystic vtab schemas
245
- // BEFORE applying the sApp schema. Without this, a warm restart diffs the
246
- // wrapped DDL against an empty catalog and re-emits CREATE TABLE / CREATE
247
- // INDEX for every persisted object — each round-tripping through optimystic
248
- // storage (the measured ~160s warm-start regression). No-op on first launch.
249
- const t0 = performance.now();
250
- hydrated = await pluginResult.hydrate(db);
251
- timing('[strand:%s] hydrate: %dms (tables=%d, indexes=%d)',
252
- strandId, Math.round(performance.now() - t0), hydrated.tables, hydrated.indexes);
253
- log('Hydrated catalog for strand %s (tables=%d, indexes=%d)', strandId, hydrated.tables, hydrated.indexes);
254
-
255
- // 6. Apply the strand membership/RBAC schema (`schemas/strand.qsql`,
256
- // embedded as STRAND_SCHEMA) UNCONDITIONALLY — every strand has membership
257
- // semantics whether or not an sApp schema is supplied. There are no
258
- // cross-schema references between `Strand` and `App`, so order is
259
- // irrelevant; apply `Strand` first for clarity. Because this runs AFTER
260
- // hydrate, a warm restart diffs the membership DDL against the already-
261
- // hydrated catalog and re-emits nothing (same fix as the sApp apply).
262
- //
263
- // This ticket only makes the tables present and their constraints active;
264
- // nothing here writes membership rows. Founder bootstrap (Header, founding
265
- // Manager/Member, invite/peer flows) is owned by the lifecycle ticket
266
- // `strand-membership-lifecycle-population`.
267
- log('Applying Strand membership schema for strand %s', strandId);
268
- await db.exec(`
269
- declare schema Strand {
270
- ${STRAND_SCHEMA}
271
- }
272
- apply schema Strand;
273
- `);
274
- log('Strand membership schema applied');
275
-
276
- // 7. Apply the sApp schema, if provided.
277
- //
278
- // SEAM: this and the Strand apply above are the single composition point
279
- // where strand-level declarative DDL is applied. Keep new schema wiring
280
- // here so it stays a one-location edit across Node, browser, and cadre-core.
281
- if (schema) {
282
- log('Applying sApp schema for strand %s', strandId);
283
- await db.exec(`
284
- declare schema App {
285
- ${schema}
286
- }
287
- apply schema App;
288
- `);
289
- log('sApp schema applied');
290
- }
291
- } catch (err) {
292
- // Clean up resources if setup fails after partial initialization.
293
- await collectionFactory.shutdown();
294
- if (createdNode) {
295
- await createdNode.stop();
296
- }
297
- throw err;
298
- }
299
-
300
- // 8. Return result with hydrate counts + shutdown handler. `shutdown` tears
301
- // down the collection factory, and `collectionFactory.shutdown()` stops EVERY
302
- // node registered with it — including an injected one (registered via
303
- // `registerLibp2pNode`). So an injected node IS stopped on `shutdown`; the
304
- // `createdNode` guard below only avoids a redundant second `stop()` on a node
305
- // we created ourselves, it is not what spares an injected node. A caller that
306
- // needs an injected node to outlive `shutdown` must re-create it (cadre-core's
307
- // `StrandInstanceManager` instead re-stops it, which is idempotent).
308
- return {
309
- vtables: [],
310
- functions: [],
311
- collations: [],
312
- hydrated,
313
- transactor: resolvedTransactor,
314
- async shutdown() {
315
- log('Shutting down strand connection %s', strandId);
316
- await collectionFactory.shutdown();
317
- if (createdNode) {
318
- await createdNode.stop();
319
- }
320
- log('Strand connection %s shut down', strandId);
321
- },
322
- };
323
- }
1
+ import debug from 'debug';
2
+ import type { Database, VTablePluginInfo, FunctionPluginInfo, CollationPluginInfo } from '@quereus/quereus';
3
+ import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
4
+ import type { Libp2p } from '@libp2p/interface';
5
+ import type { IRepo } from '@optimystic/db-core';
6
+ import type { IRawStorage, NodeOptions } from '@optimystic/db-p2p';
7
+ import type { StrandConnectionOptions, SereusPluginResult, StrandTransactor, Libp2pNodeWithRepo } from './types.js';
8
+ import { STRAND_SCHEMA } from './strand-schema.js';
9
+ import { resolveStrandClusterSize, STRAND_CLUSTER_POLICY } from './cluster-size.js';
10
+ import { wrapStorageWithCache, disposeStorageCache } from './cached-storage.js';
11
+
12
+ const log = debug('sereus:plugin:strand');
13
+ const timing = debug('sereus:plugin:strand:timing');
14
+
15
+ /**
16
+ * Minimal interface for the CollectionFactory returned by the optimystic plugin.
17
+ * We only need the methods this composition actually uses.
18
+ */
19
+ interface CollectionFactory {
20
+ registerLibp2pNode(networkName: string, node: Libp2p, coordinatedRepo: IRepo): void;
21
+ shutdown(): Promise<void>;
22
+ }
23
+
24
+ /**
25
+ * Result of registering the optimystic plugin. The published plugin signature
26
+ * does not surface `collectionFactory` / `hydrate`, so this is the local shim
27
+ * over what `@optimystic/quereus-plugin-optimystic/plugin` actually returns
28
+ * (see its `plugin.ts` — `hydrate` delegates to `optimysticModule.hydrateCatalog`).
29
+ */
30
+ interface OptimysticPluginResult {
31
+ collectionFactory: CollectionFactory;
32
+ vtables: VTablePluginInfo[];
33
+ functions: FunctionPluginInfo[];
34
+ collations?: CollationPluginInfo[];
35
+ /**
36
+ * Hydrate Quereus's in-memory catalog from persisted optimystic vtab schemas.
37
+ * Must run BEFORE `apply schema App;` on a warm restart so the declarative
38
+ * diff sees the existing tables and skips re-emitting CREATE TABLE / CREATE
39
+ * INDEX for each persisted object. Idempotent; `{ tables: 0, indexes: 0 }`
40
+ * on cold start.
41
+ */
42
+ hydrate: (db: Database) => Promise<{ tables: number; indexes: number }>;
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ /** Registration shape shared by the crypto and optimystic plugin results. */
47
+ interface PluginRegistrations {
48
+ vtables?: VTablePluginInfo[];
49
+ functions?: FunctionPluginInfo[];
50
+ collations?: CollationPluginInfo[];
51
+ }
52
+
53
+ /**
54
+ * Inline equivalent of `@quereus/quereus`'s `registerPlugin` for plugins that
55
+ * only return functions/vtables/collations. Used directly for the optimystic
56
+ * result (so the composition can read `collectionFactory` off the same object),
57
+ * and by the browser crypto strategy to keep the browser bundle from pulling a
58
+ * duplicate `@quereus/quereus` next to the host's instance.
59
+ */
60
+ export function applyRegistrations(db: Database, result: PluginRegistrations): void {
61
+ for (const vtable of result.vtables ?? []) {
62
+ db.registerModule(vtable.name, vtable.module, vtable.auxData);
63
+ }
64
+ for (const func of result.functions ?? []) {
65
+ db.registerFunction(func.schema);
66
+ }
67
+ for (const collation of result.collations ?? []) {
68
+ db.registerCollation(collation.name, collation.func, collation.normalizer);
69
+ }
70
+ }
71
+
72
+ /** Context passed to the platform's node-creation strategy. */
73
+ export interface CreateNodeContext {
74
+ networkName: string;
75
+ bootstrapNodes: string[];
76
+ fretProfile: 'edge' | 'core';
77
+ /** libp2p listening port (Node TCP). Browser transports ignore it. */
78
+ port: number;
79
+ /**
80
+ * Resolved replication cluster size. Already defaulted — pass it to
81
+ * `createLibp2pNode` verbatim; omitting it falls back to Optimystic's own
82
+ * default of 10, which gates writes on any smaller party.
83
+ */
84
+ clusterSize: number;
85
+ /**
86
+ * The strand cluster policy, resolved here so no platform has to remember it.
87
+ * Pass it to `createLibp2pNode` verbatim.
88
+ *
89
+ * Omitting it does NOT fall back to something equivalent: Optimystic then computes
90
+ * its read-repair corroboration floor against `clusterSize` instead of against
91
+ * {@link STRAND_CLUSTER_POLICY}'s `assumedClusterSize`, which is a stricter demand
92
+ * than a two-machine strand can meet — so repair of a damaged block gives up where
93
+ * it would otherwise succeed. That is the same defect class that took the *control*
94
+ * network's e2e suite down before `42cd12c`; see `cluster-size.ts` for the reasoning
95
+ * written out in full.
96
+ */
97
+ clusterPolicy: NonNullable<NodeOptions['clusterPolicy']>;
98
+ /** Resolved persistent storage to back the node, if any. */
99
+ storage?: IRawStorage;
100
+ }
101
+
102
+ /** Context passed to the platform's storage-resolution strategy. */
103
+ export interface ResolveStorageContext {
104
+ strandId: string;
105
+ resolvedTransactor: StrandTransactor;
106
+ /** The storage the caller passed in `options.storage`, if any. */
107
+ requestedStorage?: IRawStorage;
108
+ }
109
+
110
+ /**
111
+ * Platform-specific seams the shared composition delegates to. Everything else
112
+ * (transactor resolution, plugin config, registration, default vtab, hydrate,
113
+ * schema apply, cleanup/shutdown) is identical across Node and browser and lives
114
+ * in {@link composeStrand}.
115
+ */
116
+ export interface StrandPlatform {
117
+ /**
118
+ * Register the crypto plugin against `db`. Node uses `@quereus/quereus`'s
119
+ * `registerPlugin`; the browser inlines `applyRegistrations` to avoid bundling
120
+ * a second `@quereus/quereus`.
121
+ */
122
+ registerCrypto(db: Database): void | Promise<void>;
123
+ /**
124
+ * Resolve the persistent storage to use. Optional — when omitted the caller's
125
+ * `options.storage` is used verbatim (Node). The browser supplies this to
126
+ * default to IndexedDB so a reload survives.
127
+ */
128
+ resolveStorage?(ctx: ResolveStorageContext): Promise<IRawStorage | undefined> | IRawStorage | undefined;
129
+ /**
130
+ * Create a libp2p node. Called only when no node is injected and the
131
+ * transactor needs one. Node creates a TCP node via `@optimystic/db-p2p`;
132
+ * the browser creates a WebSockets + circuit-relay node via the `/rn` entry.
133
+ * Returns the created node (its `coordinatedRepo` is read by the composition).
134
+ */
135
+ createNode(ctx: CreateNodeContext): Promise<Libp2p>;
136
+ }
137
+
138
+ /**
139
+ * Connect a Quereus `Database` to a Sereus strand. This is the single shared
140
+ * SQL-surface composition: `connectToStrand` (Node), `connectToStrandBrowser`
141
+ * (browser), and `cadre-core`'s `StrandDatabase` all flow through here, so the
142
+ * hydrate-before-apply fix and any future schema wiring land in one place.
143
+ *
144
+ * Steps: resolve transactor + storage, register crypto + optimystic plugins,
145
+ * acquire (inject or create) the libp2p node, set optimystic as the default
146
+ * vtab, hydrate the catalog, then apply the sApp schema.
147
+ */
148
+ export async function composeStrand(
149
+ db: Database,
150
+ options: StrandConnectionOptions,
151
+ platform: StrandPlatform,
152
+ ): Promise<SereusPluginResult> {
153
+ const {
154
+ strandId,
155
+ bootstrapNodes = [],
156
+ schema,
157
+ port = 0,
158
+ enableCache = true,
159
+ fretProfile = 'edge',
160
+ transactor = 'network',
161
+ } = options;
162
+
163
+ // Resolve (and validate) up front so a nonsense value fails before any plugin
164
+ // registration or node creation has happened.
165
+ const clusterSize = resolveStrandClusterSize(options.clusterSize);
166
+
167
+ // The storage engine every write and read below goes through — see
168
+ // `StrandConnectionOptions.transactor`. Named separately from the option so
169
+ // the default is applied exactly once and reported on the result.
170
+ const resolvedTransactor: StrandTransactor = transactor;
171
+
172
+ const networkName = `strand-${strandId}`;
173
+ log('Connecting to strand %s (network: %s, transactor=%s)',
174
+ strandId, networkName, resolvedTransactor);
175
+
176
+ // Resolve storage. Node passes `options.storage` through; the browser
177
+ // defaults to IndexedDB. The resolved instance feeds BOTH the local
178
+ // transactor's `rawStorageFactory` and node creation, so it must be decided
179
+ // up front (before pluginConfig is built). Wrapped in the write-through
180
+ // raw-storage cache (cached-storage.ts) — idempotent, so a caller that
181
+ // already wrapped (cadre-core's seams) passes through unchanged, with this
182
+ // composition counted as one more holder of that same cache.
183
+ const resolvedStorage = platform.resolveStorage
184
+ ? await platform.resolveStorage({ strandId, resolvedTransactor, requestedStorage: options.storage })
185
+ : options.storage;
186
+ const storage = resolvedStorage ? wrapStorageWithCache(resolvedStorage, strandId) : resolvedStorage;
187
+
188
+ /**
189
+ * Release THIS composition's claim on the storage cache — the matching half of the
190
+ * wrap above, which took one. `wrapStorageWithCache` counts holders, so this is
191
+ * unconditionally correct whoever created the wrapper: the cache stays live while
192
+ * cadre-core (or any other seam over the same store) still holds a claim, and is
193
+ * emptied and unregistered from the shared cache pool when the last one goes. Holding
194
+ * a claim past the strand's life is not free — the registration claims the backing
195
+ * store in the pool, and a claim outliving its strand makes the next connect over that
196
+ * same directory throw.
197
+ *
198
+ * Latched, so a caller that calls `shutdown()` twice releases one claim, not two — a
199
+ * second release would consume another scope's claim.
200
+ */
201
+ let storageCacheReleased = false;
202
+ const releaseStorageCache = async (): Promise<void> => {
203
+ if (storageCacheReleased || !storage) {
204
+ return;
205
+ }
206
+ storageCacheReleased = true;
207
+ await disposeStorageCache(storage);
208
+ };
209
+
210
+ // 1-2. Register the crypto and optimystic plugins. Guarded on its own, because the
211
+ // wrap above has ALREADY taken a holder claim: a registration that throws must release
212
+ // it, or the claim — and the backing store's registration in the shared cache pool —
213
+ // outlives the failed connect for the process lifetime, and no other scope can retire
214
+ // the cache. Separate from the larger try below, which has a collection factory and
215
+ // possibly a node to tear down as well; neither exists yet here.
216
+ let pluginResult: OptimysticPluginResult;
217
+ try {
218
+ pluginResult = await registerStrandPlugins(db, platform, {
219
+ resolvedTransactor,
220
+ networkName,
221
+ enableCache,
222
+ storage,
223
+ });
224
+ } catch (err) {
225
+ await releaseStorageCache();
226
+ throw err;
227
+ }
228
+
229
+ const { collectionFactory } = pluginResult;
230
+
231
+ let createdNode: Libp2p | null = null;
232
+ let hydrated: { tables: number; indexes: number } | undefined;
233
+
234
+ try {
235
+ // 3. Acquire the libp2p node. Skip only when this is the unit-test fake
236
+ // transactor with no injected node — every real path needs a node.
237
+ if (resolvedTransactor !== 'test' || options.libp2pNode) {
238
+ let node: Libp2p;
239
+ let coordinatedRepo: IRepo;
240
+
241
+ if (options.libp2pNode) {
242
+ node = options.libp2pNode;
243
+ if (!options.coordinatedRepo) {
244
+ throw new Error('coordinatedRepo is required when libp2pNode is provided');
245
+ }
246
+ coordinatedRepo = options.coordinatedRepo;
247
+ log('Using injected libp2p node');
248
+ } else {
249
+ const created = await platform.createNode({ networkName, bootstrapNodes, fretProfile, port, clusterSize, clusterPolicy: STRAND_CLUSTER_POLICY, storage });
250
+ createdNode = created;
251
+ node = created;
252
+ const repo = (created as Libp2pNodeWithRepo).coordinatedRepo;
253
+ if (!repo) {
254
+ throw new Error('coordinatedRepo not available on created libp2p node');
255
+ }
256
+ coordinatedRepo = repo;
257
+ log('Created libp2p node (port: %d, fretProfile: %s, storage=%s)', port, fretProfile, !!storage);
258
+ }
259
+
260
+ collectionFactory.registerLibp2pNode(networkName, node, coordinatedRepo);
261
+ log('Registered libp2p node with collection factory');
262
+ }
263
+
264
+ // 4. Set optimystic as default vtab so `declare schema` tables use it.
265
+ db.setDefaultVtabName('optimystic');
266
+ db.setDefaultVtabArgs({
267
+ networkName,
268
+ transactor: resolvedTransactor,
269
+ keyNetwork: 'libp2p',
270
+ });
271
+ log('Set default vtab to optimystic (networkName=%s, transactor=%s)', networkName, resolvedTransactor);
272
+
273
+ // 5. Hydrate Quereus's catalog from persisted optimystic vtab schemas
274
+ // BEFORE applying the sApp schema. Without this, a warm restart diffs the
275
+ // wrapped DDL against an empty catalog and re-emits CREATE TABLE / CREATE
276
+ // INDEX for every persisted object — each round-tripping through optimystic
277
+ // storage (the measured ~160s warm-start regression). No-op on first launch.
278
+ const t0 = performance.now();
279
+ hydrated = await pluginResult.hydrate(db);
280
+ timing('[strand:%s] hydrate: %dms (tables=%d, indexes=%d)',
281
+ strandId, Math.round(performance.now() - t0), hydrated.tables, hydrated.indexes);
282
+ log('Hydrated catalog for strand %s (tables=%d, indexes=%d)', strandId, hydrated.tables, hydrated.indexes);
283
+
284
+ // 6. Apply the strand membership/RBAC schema (`schemas/strand.qsql`,
285
+ // embedded as STRAND_SCHEMA) UNCONDITIONALLY — every strand has membership
286
+ // semantics whether or not an sApp schema is supplied. There are no
287
+ // cross-schema references between `Strand` and `App`, so order is
288
+ // irrelevant; apply `Strand` first for clarity. Because this runs AFTER
289
+ // hydrate, a warm restart diffs the membership DDL against the already-
290
+ // hydrated catalog and re-emits nothing (same fix as the sApp apply).
291
+ //
292
+ // This ticket only makes the tables present and their constraints active;
293
+ // nothing here writes membership rows. Founder bootstrap (Header, founding
294
+ // Manager/Member, invite/peer flows) is owned by the lifecycle ticket
295
+ // `strand-membership-lifecycle-population`.
296
+ log('Applying Strand membership schema for strand %s', strandId);
297
+ await db.exec(`
298
+ declare schema Strand {
299
+ ${STRAND_SCHEMA}
300
+ }
301
+ apply schema Strand;
302
+ `);
303
+ log('Strand membership schema applied');
304
+
305
+ // 7. Apply the sApp schema, if provided.
306
+ //
307
+ // SEAM: this and the Strand apply above are the single composition point
308
+ // where strand-level declarative DDL is applied. Keep new schema wiring
309
+ // here so it stays a one-location edit across Node, browser, and cadre-core.
310
+ if (schema) {
311
+ log('Applying sApp schema for strand %s', strandId);
312
+ await db.exec(`
313
+ declare schema App {
314
+ ${schema}
315
+ }
316
+ apply schema App;
317
+ `);
318
+ log('sApp schema applied');
319
+ }
320
+ } catch (err) {
321
+ // Clean up resources if setup fails after partial initialization.
322
+ await collectionFactory.shutdown();
323
+ if (createdNode) {
324
+ await createdNode.stop();
325
+ }
326
+ await releaseStorageCache();
327
+ throw err;
328
+ }
329
+
330
+ // 8. Return result with hydrate counts + shutdown handler. `shutdown` tears
331
+ // down the collection factory, and `collectionFactory.shutdown()` stops EVERY
332
+ // node registered with it — including an injected one (registered via
333
+ // `registerLibp2pNode`). So an injected node IS stopped on `shutdown`; the
334
+ // `createdNode` guard below only avoids a redundant second `stop()` on a node
335
+ // we created ourselves, it is not what spares an injected node. A caller that
336
+ // needs an injected node to outlive `shutdown` must re-create it (cadre-core's
337
+ // `StrandInstanceManager` instead re-stops it, which is idempotent).
338
+ return {
339
+ vtables: [],
340
+ functions: [],
341
+ collations: [],
342
+ hydrated,
343
+ transactor: resolvedTransactor,
344
+ async shutdown() {
345
+ log('Shutting down strand connection %s', strandId);
346
+ await collectionFactory.shutdown();
347
+ if (createdNode) {
348
+ await createdNode.stop();
349
+ }
350
+ await releaseStorageCache();
351
+ log('Strand connection %s shut down', strandId);
352
+ },
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Steps 1-2 of {@link composeStrand}: register the crypto plugin, then the optimystic
358
+ * plugin carrying this strand's transactor defaults. Split out so the composition can
359
+ * guard the pair against the storage-cache claim it has already taken.
360
+ */
361
+ async function registerStrandPlugins(
362
+ db: Database,
363
+ platform: StrandPlatform,
364
+ strand: {
365
+ resolvedTransactor: StrandTransactor;
366
+ networkName: string;
367
+ enableCache: boolean;
368
+ storage: IRawStorage | undefined;
369
+ },
370
+ ): Promise<OptimysticPluginResult> {
371
+ await platform.registerCrypto(db);
372
+ log('Registered crypto plugin');
373
+
374
+ // On the local transactor with persistent storage, hand the same instance to the
375
+ // plugin so DML persists on the host backend (not in-memory).
376
+ const pluginConfig: Record<string, unknown> = {
377
+ default_transactor: strand.resolvedTransactor,
378
+ default_key_network: 'libp2p',
379
+ default_network_name: strand.networkName,
380
+ enable_cache: strand.enableCache,
381
+ };
382
+ if (strand.resolvedTransactor === 'local' && strand.storage) {
383
+ const storage = strand.storage;
384
+ pluginConfig.rawStorageFactory = () => storage;
385
+ }
386
+ // The plugin's published signature is `Record<string, SqlValue>` but it also reads
387
+ // `rawStorageFactory` (a function reference) from the same map. Cast through unknown
388
+ // rather than widen the public type.
389
+ const pluginResult = optimysticPlugin(
390
+ db,
391
+ pluginConfig as unknown as Parameters<typeof optimysticPlugin>[1],
392
+ ) as unknown as OptimysticPluginResult;
393
+ applyRegistrations(db, pluginResult);
394
+ log('Registered optimystic vtables and functions');
395
+ return pluginResult;
396
+ }