@serfab/cadre-core 1.16.0 → 1.18.0

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