@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,57 +1,58 @@
1
- import type { Database, VTablePluginInfo, FunctionPluginInfo, CollationPluginInfo } from '@quereus/quereus';
2
- import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
3
- import { createLibp2pNode } from '@optimystic/db-p2p/rn';
4
- import { webSockets } from '@libp2p/websockets';
5
- import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
6
- import { IndexedDBRawStorage, openOptimysticWebDb } from '@optimystic/db-p2p-storage-web';
7
- import type { Libp2p } from '@libp2p/interface';
8
- import type { StrandConnectionOptions, SereusPluginResult } from './types.js';
9
- import { composeStrand, applyRegistrations } from './compose-strand.js';
10
-
11
- /** The subset of the crypto plugin result we register inline in the browser. */
12
- interface CryptoPluginResult {
13
- vtables: VTablePluginInfo[];
14
- functions: FunctionPluginInfo[];
15
- collations: CollationPluginInfo[];
16
- }
17
-
18
- /**
19
- * Connect a Quereus Database to a Sereus strand from a browser/worker.
20
- *
21
- * Thin adapter over the shared {@link composeStrand} composition, supplying the
22
- * browser platform seams:
23
- * - crypto registered inline (no runtime `@quereus/quereus` import, so the
24
- * bundle does not duplicate the host's instance),
25
- * - storage defaulting to `IndexedDBRawStorage` keyed by `sereus-strand-<id>`,
26
- * - a node created via the TCP-free `@optimystic/db-p2p/rn` entry with explicit
27
- * `webSockets()` + `circuitRelayTransport()` transports.
28
- */
29
- export async function connectToStrandBrowser(
30
- db: Database,
31
- options: StrandConnectionOptions,
32
- ): Promise<SereusPluginResult> {
33
- return composeStrand(db, options, {
34
- registerCrypto(database) {
35
- applyRegistrations(database, cryptoPlugin(database, {}) as unknown as CryptoPluginResult);
36
- },
37
- async resolveStorage({ strandId, resolvedTransactor, requestedStorage }) {
38
- if (requestedStorage) return requestedStorage;
39
- // Browsers always default to IndexedDB so a reload survives; the
40
- // unit-test fake transactor needs no storage.
41
- if (resolvedTransactor === 'test') return undefined;
42
- const dbHandle = await openOptimysticWebDb(`sereus-strand-${strandId}`);
43
- return new IndexedDBRawStorage(dbHandle);
44
- },
45
- async createNode({ networkName, bootstrapNodes, fretProfile, clusterSize, storage }) {
46
- return createLibp2pNode({
47
- transports: [webSockets(), circuitRelayTransport()],
48
- listenAddrs: [],
49
- bootstrapNodes,
50
- networkName,
51
- fretProfile,
52
- clusterSize,
53
- ...(storage && { storage }),
54
- }) as Promise<Libp2p>;
55
- },
56
- });
57
- }
1
+ import type { Database, VTablePluginInfo, FunctionPluginInfo, CollationPluginInfo } from '@quereus/quereus';
2
+ import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
3
+ import { createLibp2pNode } from '@optimystic/db-p2p/rn';
4
+ import { webSockets } from '@libp2p/websockets';
5
+ import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
6
+ import { IndexedDBRawStorage, openOptimysticWebDb } from '@optimystic/db-p2p-storage-web';
7
+ import type { Libp2p } from '@libp2p/interface';
8
+ import type { StrandConnectionOptions, SereusPluginResult } from './types.js';
9
+ import { composeStrand, applyRegistrations } from './compose-strand.js';
10
+
11
+ /** The subset of the crypto plugin result we register inline in the browser. */
12
+ interface CryptoPluginResult {
13
+ vtables: VTablePluginInfo[];
14
+ functions: FunctionPluginInfo[];
15
+ collations: CollationPluginInfo[];
16
+ }
17
+
18
+ /**
19
+ * Connect a Quereus Database to a Sereus strand from a browser/worker.
20
+ *
21
+ * Thin adapter over the shared {@link composeStrand} composition, supplying the
22
+ * browser platform seams:
23
+ * - crypto registered inline (no runtime `@quereus/quereus` import, so the
24
+ * bundle does not duplicate the host's instance),
25
+ * - storage defaulting to `IndexedDBRawStorage` keyed by `sereus-strand-<id>`,
26
+ * - a node created via the TCP-free `@optimystic/db-p2p/rn` entry with explicit
27
+ * `webSockets()` + `circuitRelayTransport()` transports.
28
+ */
29
+ export async function connectToStrandBrowser(
30
+ db: Database,
31
+ options: StrandConnectionOptions,
32
+ ): Promise<SereusPluginResult> {
33
+ return composeStrand(db, options, {
34
+ registerCrypto(database) {
35
+ applyRegistrations(database, cryptoPlugin(database, {}) as unknown as CryptoPluginResult);
36
+ },
37
+ async resolveStorage({ strandId, resolvedTransactor, requestedStorage }) {
38
+ if (requestedStorage) return requestedStorage;
39
+ // Browsers always default to IndexedDB so a reload survives; the
40
+ // unit-test fake transactor needs no storage.
41
+ if (resolvedTransactor === 'test') return undefined;
42
+ const dbHandle = await openOptimysticWebDb(`sereus-strand-${strandId}`);
43
+ return new IndexedDBRawStorage(dbHandle);
44
+ },
45
+ async createNode({ networkName, bootstrapNodes, fretProfile, clusterSize, clusterPolicy, storage }) {
46
+ return createLibp2pNode({
47
+ transports: [webSockets(), circuitRelayTransport()],
48
+ listenAddrs: [],
49
+ bootstrapNodes,
50
+ networkName,
51
+ fretProfile,
52
+ clusterSize,
53
+ clusterPolicy,
54
+ ...(storage && { storage }),
55
+ }) as Promise<Libp2p>;
56
+ },
57
+ });
58
+ }
package/src/connect.ts CHANGED
@@ -1,38 +1,39 @@
1
- import type { Database } from '@quereus/quereus';
2
- import { registerPlugin } from '@quereus/quereus';
3
- import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
4
- import type { Libp2p } from '@libp2p/interface';
5
- import type { StrandConnectionOptions, SereusPluginResult } from './types.js';
6
- import { composeStrand } from './compose-strand.js';
7
-
8
- /**
9
- * Connect a Quereus Database to a Sereus strand (Node).
10
- *
11
- * Thin adapter over the shared {@link composeStrand} composition, supplying the
12
- * Node platform seams: crypto via `@quereus/quereus`'s `registerPlugin`, and a
13
- * TCP libp2p node created (only when not injected) via `@optimystic/db-p2p`.
14
- */
15
- export async function connectToStrand(
16
- db: Database,
17
- options: StrandConnectionOptions,
18
- ): Promise<SereusPluginResult> {
19
- return composeStrand(db, options, {
20
- async registerCrypto(database) {
21
- await registerPlugin(database, cryptoPlugin);
22
- },
23
- async createNode({ networkName, bootstrapNodes, fretProfile, port, clusterSize, storage }) {
24
- // Dynamically import to keep the module cross-platform friendly: this
25
- // pulls the Node-only `@optimystic/db-p2p` (TCP) entry, and is only
26
- // reached when actually creating a node (never for injected nodes).
27
- const { createLibp2pNode } = await import('@optimystic/db-p2p');
28
- return createLibp2pNode({
29
- port,
30
- bootstrapNodes,
31
- networkName,
32
- fretProfile,
33
- clusterSize,
34
- ...(storage && { storage }),
35
- }) as Promise<Libp2p>;
36
- },
37
- });
38
- }
1
+ import type { Database } from '@quereus/quereus';
2
+ import { registerPlugin } from '@quereus/quereus';
3
+ import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
4
+ import type { Libp2p } from '@libp2p/interface';
5
+ import type { StrandConnectionOptions, SereusPluginResult } from './types.js';
6
+ import { composeStrand } from './compose-strand.js';
7
+
8
+ /**
9
+ * Connect a Quereus Database to a Sereus strand (Node).
10
+ *
11
+ * Thin adapter over the shared {@link composeStrand} composition, supplying the
12
+ * Node platform seams: crypto via `@quereus/quereus`'s `registerPlugin`, and a
13
+ * TCP libp2p node created (only when not injected) via `@optimystic/db-p2p`.
14
+ */
15
+ export async function connectToStrand(
16
+ db: Database,
17
+ options: StrandConnectionOptions,
18
+ ): Promise<SereusPluginResult> {
19
+ return composeStrand(db, options, {
20
+ async registerCrypto(database) {
21
+ await registerPlugin(database, cryptoPlugin);
22
+ },
23
+ async createNode({ networkName, bootstrapNodes, fretProfile, port, clusterSize, clusterPolicy, storage }) {
24
+ // Dynamically import to keep the module cross-platform friendly: this
25
+ // pulls the Node-only `@optimystic/db-p2p` (TCP) entry, and is only
26
+ // reached when actually creating a node (never for injected nodes).
27
+ const { createLibp2pNode } = await import('@optimystic/db-p2p');
28
+ return createLibp2pNode({
29
+ port,
30
+ bootstrapNodes,
31
+ networkName,
32
+ fretProfile,
33
+ clusterSize,
34
+ clusterPolicy,
35
+ ...(storage && { storage }),
36
+ }) as Promise<Libp2p>;
37
+ },
38
+ });
39
+ }
package/src/index.ts CHANGED
@@ -5,7 +5,10 @@ export {
5
5
  CONTROL_CLUSTER_POLICY,
6
6
  DEFAULT_STRAND_CLUSTER_SIZE,
7
7
  STRAND_CLUSTER_POLICY,
8
- resolveStrandClusterSize
8
+ resolveStrandClusterSize,
9
+ resolveRepairYardstick,
10
+ controlClusterPolicy,
11
+ strandClusterPolicy
9
12
  } from './cluster-size.js';
10
13
  export { wrapStorageWithCache, disposeStorageCache } from './cached-storage.js';
11
14
  export type { StrandConnectionOptions, SereusPluginResult, StrandTransactor } from './types.js';
@@ -13,8 +13,9 @@
13
13
  *
14
14
  * Drift discipline: the table declarations here MUST stay byte-equivalent to the
15
15
  * body of `schemas/strand.qsql` (the on-disk canonical copy). Any edit here must
16
- * be mirrored there and vice versa — the same invariant the planned
17
- * `control-schema-drift-guard` enforces for the control schema.
16
+ * be mirrored there and vice versa. `test/strand-schema-drift.spec.ts` enforces it
17
+ * — but only from THIS package, so an edit made in `schemas/strand.qsql` alone
18
+ * stays green everywhere its author is likely to look; run that spec after either.
18
19
  *
19
20
  * Crypto idiom (matches `schemas/control.qsql` and the sApp RBAC fixture
20
21
  * `packages/integration-tests/fixtures/simple-sapp.qsql`): every signed write
@@ -108,6 +109,22 @@ export const STRAND_SCHEMA = ` table Header (
108
109
  -- rows — no caller-supplied context decides admission (unlike NotExpired's Now).
109
110
  constraint NotCancelled check on insert (
110
111
  not exists (select 1 from CancelledInvite C where C.InviteKey = new.InviteKey)
112
+ ),
113
+ -- A SEALED strand (a closed strand whose Manager table its sole manager has
114
+ -- deliberately emptied — see the Manager table comment) admits nobody, and that
115
+ -- includes the holder of an unspent invitation issued BEFORE the seal.
116
+ -- cancelInvite itself needs a manager, so after sealing nobody could kill such
117
+ -- an invitation — this gate is what kills it. The subquery reads the LIVE
118
+ -- Manager table and defers to commit, so it sees the post-image: at least one
119
+ -- manager must survive the transaction, and sealing plus consuming in the SAME
120
+ -- transaction is rejected too. Blocking this insert blocks the whole join, for
121
+ -- exactly the reason NotCancelled gives above.
122
+ -- The Manager rows this reads are the ones THIS NODE can see, so the gate binds
123
+ -- only once the seal has reached here — on a node still behind on the seal it
124
+ -- passes, and a stranger holding a pre-seal invitation joins (the Manager table's
125
+ -- seal-propagation NOTE).
126
+ constraint NotSealed check on insert (
127
+ exists (select 1 from Manager)
111
128
  )
112
129
  ) with context (InviteSignature text null, Now datetime null);
113
130
 
@@ -136,7 +153,8 @@ export const STRAND_SCHEMA = ` table Header (
136
153
  -- "forever".
137
154
  -- NOTE: a cancellation must PROPAGATE before the holder redeems — a holder consuming
138
155
  -- on a node that has not yet seen this row still gets in. Same convergence class as
139
- -- the NotRevoked / MinOneMember / MinOneManager local-visibility notes; not solved here.
156
+ -- the NotRevoked / MinOneMember local-visibility notes and the Manager table's
157
+ -- seal-propagation note; not solved here.
140
158
  table CancelledInvite (
141
159
  InviteKey text primary key,
142
160
  constraint OnlyClosed check (
@@ -382,7 +400,7 @@ export const STRAND_SCHEMA = ` table Header (
382
400
  -- NOTE: a per-transaction check against locally visible rows. Two partitioned nodes
383
401
  -- — one promoting X, one removing X's Member and Manager rows — can each pass
384
402
  -- locally and converge to a Manager row with no Member row. Same convergence class
385
- -- as the MinOneMember / MinOneManager / NotRevoked notes; not solved here.
403
+ -- as the MinOneMember / NotRevoked / seal-propagation notes; not solved here.
386
404
  constraint MemberExists check on insert (
387
405
  exists (select 1 from Member M where M.Key = new.MemberKey)
388
406
  ),
@@ -390,17 +408,26 @@ export const STRAND_SCHEMA = ` table Header (
390
408
  -- (which proves only that old.MemberKey consented) double as a hand-off: the
391
409
  -- former-manager branch would accept re-pointing the row at any new key.
392
410
  constraint NoUpdate check on update (false),
393
- -- A closed strand must never lose its last manager. Every admit path (Invite,
394
- -- addMemberByManager, addManager) requires an existing Manager row, so an
395
- -- admin-less strand can never admit anyone again. Deferred (subquery), so the
396
- -- count it sees is the POST-delete count.
397
- -- NOTE: this is a per-transaction check against locally visible rows. Two nodes
398
- -- that concurrently remove different managers can each see a surviving one and
399
- -- still converge to zero; if partitioned rotation ever becomes a real workflow,
400
- -- the floor needs a cross-node guard, not a local count.
401
- constraint MinOneManager check on delete (
402
- (select count(1) from Manager) >= 1
403
- ),
411
+ -- A closed strand loses its LAST manager only by SEALING — a distinct,
412
+ -- self-signed act (the 'seal' branch of Authorized below), never an ordinary
413
+ -- resignation and never an admin removal. There is deliberately NO
414
+ -- min-one-manager floor: a strand with no managers is frozen, not bricked.
415
+ -- Every admission path (Invite, addMemberByManager, addManager) requires a
416
+ -- Manager row, so a sealed strand's membership can never grow again — and that
417
+ -- freeze is the privacy guarantee its remaining members are buying: nobody
418
+ -- holds the power to admit a party who would then read the strand's whole
419
+ -- history. Sealing is irreversible: the founding branch of Authorized closes
420
+ -- for good once any manager stamp has been retired into Revocation.
421
+ -- NOTE: a seal must PROPAGATE before it binds a given node — a node that has
422
+ -- not yet converged on the delete still shows the manager row, so until the
423
+ -- delete arrives that node admits as if unsealed. It is NOT only the
424
+ -- ex-manager's own key that gains: every gate here reads the LOCALLY visible
425
+ -- Manager rows, and ConsumedInvite.NotSealed is one of them, so a STRANGER
426
+ -- holding an invitation issued before the seal can redeem it on such a node.
427
+ -- Same convergence class as the MinOneMember / NotRevoked local-visibility
428
+ -- notes; not solved here. Arrival IS covered, on the converged side:
429
+ -- integration-tests' strand-membership-closed-strand-e2e gates a second node on
430
+ -- the seal and then requires it to refuse every admission path.
404
431
  constraint Authorized check on insert, delete (
405
432
  -- Every signed branch below is a domain/action-tagged digest bound to the
406
433
  -- row's StampId — the same single-use idiom as Member.Authorized: an
@@ -415,23 +442,47 @@ export const STRAND_SCHEMA = ` table Header (
415
442
  -- is belt-and-braces since MemberExists above enforces it on EVERY insert —
416
443
  -- kept so this branch's own claim ("this manager IS that member") reads in
417
444
  -- place rather than by cross-reference.
445
+ -- The Revocation gate closes this branch FOREVER once any manager seat has
446
+ -- been retired: founding happens exactly once per strand (Header, then
447
+ -- Member, then Manager, with no Revocation rows in existence), so on a
448
+ -- SEALED strand a lone surviving member cannot re-seat itself as a
449
+ -- generation-0 founder and start admitting. Accepted, deliberate: between
450
+ -- its own founding Member and Manager inserts a founder could, with its own
451
+ -- key, file a junk Revocation('Manager', ...) tombstone (RowIsGone permits
452
+ -- stamps naming no real row) and permanently block its own founding — pure
453
+ -- self-harm needing the founder's private key; no other party can reach
454
+ -- that window (there are no other committed members yet).
418
455
  (old.MemberKey is null
419
456
  and new.Generation = 0
420
457
  and (select count(1) from Manager) <= 1
421
458
  and (select count(1) from Member) <= 1
422
- and exists (select 1 from Member M where M.Key = new.MemberKey))
459
+ and exists (select 1 from Member M where M.Key = new.MemberKey)
460
+ and not exists (select 1 from Revocation R where R.TableName = 'Manager'))
423
461
 
424
- -- or authorized by this former manager (self-resignation), over the
425
- -- resign-tagged digest bound to its own row's stamp. The 'resign' tag
426
- -- (vs the admin branch's 'remove') is belt-and-braces — the two
427
- -- branches verify against different keys; the anti-replay fix is the
428
- -- stamp: once this manager is re-promoted under a fresh stamp, a
429
- -- captured resignation no longer removes it.
462
+ -- or removed by the departing manager ITSELF, over one of two
463
+ -- self-signed, action-tagged digests bound to its own row's stamp. The
464
+ -- POST-image manager count is what distinguishes the two: 'resign' is
465
+ -- an ordinary departure that leaves at least one manager standing,
466
+ -- while 'seal' is the delete that empties the table and permanently
467
+ -- freezes admission (see the table comment above). The tag is what
468
+ -- makes each a signed statement of THAT intent — a captured
469
+ -- resignation approval can never seal, nor a seal approval merely
470
+ -- resign — and the stamp is still what makes each single-use: once
471
+ -- this manager is re-promoted under a fresh stamp, neither captured
472
+ -- approval removes the new row. Both counts are subqueries, so the
473
+ -- whole check defers to commit and the counts are the post-image.
430
474
  or (
431
475
  old.MemberKey is not null
432
476
  and old.MemberKey = context.ManagerKey
477
+ and (select count(1) from Manager) >= 1
433
478
  and verify(digest('Strand.Manager', 'resign', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')
434
479
  )
480
+ or (
481
+ old.MemberKey is not null
482
+ and old.MemberKey = context.ManagerKey
483
+ and (select count(1) from Manager) = 0
484
+ and verify(digest('Strand.Manager', 'seal', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')
485
+ )
435
486
 
436
487
  -- or a promotion signed by an EARLIER-generation manager, over the
437
488
  -- add-tagged digest binding (MemberKey, Generation, StampId) — so a
@@ -483,7 +534,7 @@ export const STRAND_SCHEMA = ` table Header (
483
534
  -- NOTE: the guarded tables' NotRevoked CHECK runs against locally visible rows, so
484
535
  -- a node that has not yet converged on a Revocation row can still accept a replayed
485
536
  -- add and the resurrected row coexists with the tombstone after merge — the same
486
- -- convergence class as the MinOneMember / MinOneManager local-count notes.
537
+ -- convergence class as the MinOneMember / Manager seal-propagation notes.
487
538
  -- NOTE: this table only grows — one row per membership deletion, plus any junk any
488
539
  -- member chooses to file, and Immutable means rows never go away. Fine at strand
489
540
  -- scale; if membership churn ever gets large it needs pruning bounded by something