@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.
- package/dist/cadre-node.d.ts +65 -2
- package/dist/cadre-node.js +114 -2
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-read-retry.d.ts +19 -16
- package/dist/control-read-retry.js +22 -54
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-retry.d.ts +50 -6
- package/dist/control-retry.js +53 -6
- package/dist/control-retry.js.map +1 -1
- package/dist/control-write-retry.d.ts +24 -16
- package/dist/control-write-retry.js +58 -30
- package/dist/control-write-retry.js.map +1 -1
- package/dist/edge-node/config.d.ts +8 -2
- package/dist/edge-node/config.js +5 -1
- package/dist/edge-node/config.js.map +1 -1
- package/dist/edge-node/edge-join.js +2 -0
- package/dist/edge-node/edge-join.js.map +1 -1
- package/dist/edge-node/edge-node.d.ts +10 -0
- package/dist/edge-node/edge-node.js +52 -8
- package/dist/edge-node/edge-node.js.map +1 -1
- package/dist/edge-node/options.d.ts +11 -2
- package/dist/edge-node/options.js +11 -4
- package/dist/edge-node/options.js.map +1 -1
- package/dist/edge-node/platform.d.ts +15 -3
- package/dist/edge-node/strands.js +3 -2
- package/dist/edge-node/strands.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/strand-database.d.ts +56 -0
- package/dist/strand-database.js +87 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +67 -2
- package/dist/strand-instance-manager.js +105 -4
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +13 -1
- package/dist/strand-membership-reconciler.js +18 -7
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +16 -2
- package/dist/strand-membership-writer.js +54 -8
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-offline.d.ts +131 -0
- package/dist/strand-offline.js +142 -0
- package/dist/strand-offline.js.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/cadre-node.ts +11456 -11329
- package/src/control-read-retry.ts +22 -58
- package/src/control-retry.ts +54 -6
- package/src/control-write-retry.ts +58 -30
- package/src/edge-node/config.ts +11 -2
- package/src/edge-node/edge-join.ts +1 -0
- package/src/edge-node/edge-node.ts +55 -6
- package/src/edge-node/options.ts +20 -4
- package/src/edge-node/platform.ts +15 -3
- package/src/edge-node/strands.ts +3 -2
- package/src/index.ts +917 -898
- package/src/strand-database.ts +466 -352
- package/src/strand-instance-manager.ts +2210 -2092
- package/src/strand-membership-reconciler.ts +24 -7
- package/src/strand-membership-writer.ts +61 -14
- package/src/strand-offline.ts +247 -0
- package/src/types.ts +2960 -2926
package/src/strand-database.ts
CHANGED
|
@@ -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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
this.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*/
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
* the
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*/
|
|
327
|
-
async
|
|
328
|
-
|
|
329
|
-
this.
|
|
330
|
-
this.
|
|
331
|
-
this.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
this.
|
|
343
|
-
this
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
+
}
|