@serfab/quereus-plugin-sereus 0.11.0 → 0.12.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.
- package/dist/cached-storage.d.ts +45 -16
- package/dist/cached-storage.js +87 -34
- package/dist/cached-storage.js.map +1 -1
- package/dist/compose-strand.d.ts +14 -1
- package/dist/compose-strand.js +83 -33
- package/dist/compose-strand.js.map +1 -1
- package/dist/connect-browser.js +2 -1
- package/dist/connect-browser.js.map +1 -1
- package/dist/connect.js +2 -1
- package/dist/connect.js.map +1 -1
- package/dist/plugin-browser.js +76848 -77386
- package/dist/plugin-browser.js.map +4 -4
- package/dist/strand-schema.d.ts +1 -1
- package/dist/strand-schema.js +62 -21
- package/dist/strand-schema.js.map +1 -1
- package/package.json +8 -8
- package/src/cached-storage.ts +105 -34
- package/src/compose-strand.ts +396 -323
- package/src/connect-browser.ts +58 -57
- package/src/connect.ts +39 -38
- package/src/strand-schema.ts +62 -21
package/dist/strand-schema.d.ts
CHANGED
|
@@ -27,4 +27,4 @@
|
|
|
27
27
|
* the founding `Manager`/`Member`, and the invite/peer flows is owned by
|
|
28
28
|
* `strand-membership-lifecycle-population`.
|
|
29
29
|
*/
|
|
30
|
-
export declare const STRAND_SCHEMA = " table Header (\n Id text, -- UUID of this network - generated at inception - immutable\n\n -- Types: 'o' = Open, 'c' = Closed\n -- Any peers can join an open network, but only members can join a closed network\n -- Open can still control writes in the app, but only Closed prevents reads\n Type text check (Type in ('o', 'c')),\n\n -- The app that this strand faciliates - public key from author to prevent tampering\n sAppId text,\n\n -- The version of the sApp that this strand faciliates\n sAppVersion text,\n\n -- The schema of the sApp that this strand faciliates\n sAppSchema text,\n\n -- The author's signature on the schema to guarantee authorship\n sAppSignature text,\n\n -- The engine (rules logic system) that is assumed to manage this strand\n Engine text,\n\n -- The version of the engine that is assumed to manage this strand\n EngineVersion text,\n\n primary key (/* empty - singleton */),\n constraint InsertOnly check on update, delete (false), -- One-time insert only for now - TODO: revisit for versioning\n );\n\n -- An invitation to join a strand as a member\n table Invite (\n Key text primary key,\n Expiration datetime null,\n -- Deactivation is a CancelledInvite tombstone (below), not a delete of this row\n -- or a state column on it \u2014 rationale on that table.\n constraint InsertOnly check on update, delete (false),\n constraint OnlyClosed check (\n exists (select 1 from Header H where H.Type = 'c')\n ),\n constraint InviteValid check on insert (\n -- Can only be inserted by a manager,\n exists (select 1 from Manager A\n where A.MemberKey = context.ManagerKey\n and verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), context.ManagerSignature, A.MemberKey, 'ed25519')\n )\n -- and must also prove invite private key held by issuing manager\n and verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), context.InviteSignature, new.Key, 'ed25519')\n )\n ) with context (ManagerKey text null, ManagerSignature text null, InviteSignature text null);\n\n -- Invite [InviteKey] has been used to add [MemberKey] as a member\n -- No StampId here or on Invite: both tables are insert-only and their primary keys are\n -- minted fresh per issuance/consumption, so a replayed insert collides on the PK.\n table ConsumedInvite (\n InviteKey text primary key,\n MemberKey text,\n constraint InsertOnly check on update, delete (false),\n constraint InviteExists check (exists (select 1 from Invite I where I.Key = new.InviteKey)),\n constraint MemberExists check (exists (select 1 from Member M where M.Key = new.MemberKey)),\n constraint ValidUsage check on insert (\n exists (select 1 from Invite I where I.Key = new.InviteKey and verify(digest(new.InviteKey || '|' || new.MemberKey), context.InviteSignature, new.InviteKey, 'ed25519'))\n ),\n -- An invite with a non-null Expiration may only be consumed while it is still\n -- in the future. context.Now is the canonical-datetime \"now\" supplied by the\n -- consumeInvite writer (same canonicalDatetime() transform used to store\n -- Invite.Expiration), so both sides of the comparison are byte-identical\n -- canonical strings and the lexical \">\" comparison orders chronologically. A\n -- null Expiration never expires. Mirrors CadreControl.FormationUsage's\n -- \"FI.ExpiresAt is null or FI.ExpiresAt > context.Now\" gate.\n constraint NotExpired check on insert (\n exists (select 1 from Invite I where I.Key = new.InviteKey and (I.Expiration is null or I.Expiration > context.Now))\n ),\n -- A cancelled invitation may never be consumed. Blocking the ConsumedInvite\n -- insert is sufficient to block the JOIN: Member.Authorized's invite branch\n -- requires a same-transaction FRESH ConsumedInvite row, and consumeInvite writes\n -- both rows in one transaction, so the whole join rolls back. The rule lives here\n -- alone rather than being duplicated into Member.Authorized. Reads only stored\n -- rows \u2014 no caller-supplied context decides admission (unlike NotExpired's Now).\n constraint NotCancelled check on insert (\n not exists (select 1 from CancelledInvite C where C.InviteKey = new.InviteKey)\n )\n ) with context (InviteSignature text null, Now datetime null);\n\n -- A manager has cancelled invitation [InviteKey]: it may never be consumed.\n --\n -- A monotone TOMBSTONE, not a delete of the Invite row and not a state column on it:\n -- 1. Deleting the Invite row would re-open a replay surface. InviteValid signs only\n -- (Key, Expiration) \u2014 there is no per-row StampId on Invite \u2014 so a deleted Invite\n -- row can be re-created by replaying the captured issue signatures. Closing that\n -- would mean adding Invite.StampId and widening Revocation.RowIsGone to a fourth\n -- table. (CadreControl.FormationInvite IS deletable \u2014 it carries a StampId for\n -- exactly this reason.)\n -- 2. A state column needs an UPDATE, and Invite is insert-only on purpose.\n -- 3. Insert-only converges: a growing set of dead markers merges cleanly across\n -- nodes, a delete/re-insert pair does not. Same rationale as Strand.Revocation.\n -- No StampId here: the table is insert-only and its primary key IS the invite key, so\n -- a replayed insert collides on the PK \u2014 the argument the comment above ConsumedInvite\n -- makes for Invite/ConsumedInvite.\n -- Deliberately NO InviteExists gate: a node that has not yet converged on the Invite\n -- row must still be able to record the cancellation. Refusing it would fail OPEN on a\n -- security control, which is strictly worse than accepting a junk row.\n -- NOTE: this table only grows \u2014 one row per cancellation, plus any junk a manager\n -- chooses to file, and Immutable means rows never go away (the tradeoff\n -- Revocation.RowIsGone already documents for junk stamps). Fine at strand scale; if\n -- invite churn ever gets large it needs pruning bounded by something other than\n -- \"forever\".\n -- NOTE: a cancellation must PROPAGATE before the holder redeems \u2014 a holder consuming\n -- on a node that has not yet seen this row still gets in. Same convergence class as\n -- the NotRevoked / MinOneMember / MinOneManager local-visibility notes; not solved here.\n table CancelledInvite (\n InviteKey text primary key,\n constraint OnlyClosed check (\n exists (select 1 from Header H where H.Type = 'c')\n ),\n -- Cancellation is permanent: un-cancelling would restore the re-entry it closed.\n -- To re-invite a party, issue a fresh invitation.\n constraint Immutable check on update, delete (false),\n -- committed.Manager, not Manager: this check has a subquery so it defers to\n -- commit, by which point a manager seated in the SAME transaction would otherwise\n -- be able to authorize its own cancellation. Member, MemberPeer and Revocation\n -- read committed.* for the same reason. (Manager.Authorized reads the LIVE table\n -- instead \u2014 there it is the strict Generation ordering, not a snapshot, that\n -- excludes an authorizer seated in the same transaction.)\n constraint Authorized check on insert (\n exists (select 1 from committed.Manager A\n where A.MemberKey = context.ManagerKey\n and verify(digest('Strand.CancelledInvite', 'cancel', new.InviteKey),\n context.ManagerSignature, A.MemberKey, 'ed25519'))\n )\n ) with context (ManagerKey text, ManagerSignature text);\n\n -- A party in the closed strand network\n table Member (\n Key text primary key,\n StampId text not null unique, -- per-row authorization nonce, bound into the signed digests below.\n -- unique holds over LIVE rows only; a removed row's stamp is retired\n -- permanently into Revocation (NotRevoked below), so the never-expiring\n -- admission signature cannot resurrect a revoked member.\n -- A removed row's StampId is retired into Revocation, and this refuses any insert\n -- naming a retired stamp: the approval that seated this row can never re-seat it\n -- after removal. unique alone did not do this \u2014 it only holds over LIVE rows, so\n -- a delete freed the stamp and the original never-expiring signature verified\n -- again. A legitimate re-add mints a FRESH StampId and a fresh signature, so it is\n -- unaffected. Mirrors CadreControl.OwnerKey.NotRevoked (rationale in full there).\n constraint NotRevoked check on insert (\n not exists (select 1 from Revocation R where R.TableName = 'Member' and R.StampId = new.StampId)\n ),\n -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the\n -- same transaction, or the stamp would free up again. Deferred (subquery), so the\n -- sibling insert is visible at commit regardless of statement order.\n constraint RevocationRecorded check on delete (\n exists (select 1 from Revocation R where R.TableName = 'Member' and R.StampId = old.StampId)\n ),\n -- Membership is insert + delete only; a re-keyed member is a remove + fresh add.\n constraint NoUpdate check on update (false),\n -- Mask is explicit on all three operations: a bare check defaults to\n -- insert|update and silently never runs on DELETE.\n constraint OnlyClosed check on insert, update, delete (\n exists (select 1 from Header H where H.Type = 'c')\n ),\n -- A closed strand must never lose its last member: Member is the strand's read\n -- gate, and the founding Manager is only seatable beside an existing Member, so\n -- an empty member set is a permanent denial of the whole strand. Deferred\n -- (subquery), so the count it sees is the POST-delete count.\n -- NOTE: this is a per-transaction check against locally visible rows. Two nodes\n -- that concurrently remove different members can each see a surviving one and\n -- still converge to zero; if partitioned removal ever becomes a real workflow,\n -- the floor needs a cross-node guard, not a local count.\n constraint MinOneMember check on delete (\n (select count(1) from Member) >= 1\n ),\n -- A member still holding a Manager row cannot be un-membered \u2014 it must resign\n -- its Manager row first. Deferred (subquery), so it reads the POST-image: a\n -- single transaction deleting BOTH the Manager and the Member row passes.\n constraint NotAManager check on delete (\n not exists (select 1 from Manager A where A.MemberKey = old.Key)\n ),\n constraint Authorized check on insert, delete (\n -- Every authorizer is read from the PRE-transaction snapshot (committed.*):\n -- this CHECK auto-defers to commit (it has a subquery), by which point rows\n -- inserted alongside are live, so a plain from-Manager would let a manager\n -- seated in this same transaction authorize a removal. committed.* states\n -- the rule directly: the authorizer existed BEFORE this transaction.\n\n -- Every signed digest below leads with two fixed literals \u2014 the\n -- 'Strand.Member' domain tag and an action tag \u2014 so an approval verifies\n -- ONLY against the one rule it was minted for (the idiom of CadreControl's\n -- control-authorization.ts), and binds the row's StampId, so it authorizes\n -- exactly one INCARNATION of the row: once the row is deleted (retiring the\n -- stamp) or re-created under a fresh stamp, the captured approval matches\n -- nothing.\n\n -- Bootstrap: the FOUNDING transaction \u2014 one whose pre-transaction member\n -- set is empty \u2014 seats the first member, AND ONLY THAT ONE, with no\n -- authorization. Both counts are load-bearing. The committed (PRE-image)\n -- count keeps the branch off for a DELETE and for the insert half of a\n -- same-transaction wipe-then-seat (the wiped rows were committed). The\n -- live (POST-image) count keeps a single founding transaction from\n -- seating an unbounded unauthenticated member set \u2014 which would also\n -- brick the strand, since Manager's own bootstrap branch requires at\n -- most one Member.\n (old.Key is null\n and (select count(1) from committed.Member) = 0\n and (select count(1) from Member) <= 1)\n\n -- or added directly by a pre-existing manager signing the add-tagged\n -- digest over (Key, StampId) \u2014 bound to this row's fresh stamp, so the\n -- approval seats exactly this incarnation and dies with it.\n or (old.Key is null and exists (\n select 1 from committed.Manager A\n where A.MemberKey = context.ManagerKey\n and verify(digest('Strand.Member', 'add', new.Key, new.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')\n ))\n\n -- or added by consuming an invite. The ConsumedInvite admitting this\n -- member must be same-transaction FRESH \u2014 its InviteKey absent from the\n -- committed snapshot. consumeInvite inserts Member + ConsumedInvite in\n -- one transaction, so a genuine join passes; a revoked member's STALE\n -- committed row no longer re-admits it, while a fresh manager-issued\n -- invite still does (new InviteKey \u2192 uncommitted row). InviteExists +\n -- ValidUsage on ConsumedInvite keep this branch mintable only via a\n -- real invite and possession of its private key.\n or (old.Key is null and exists (\n select 1 from ConsumedInvite CI\n where CI.MemberKey = new.Key\n and not exists (select 1 from committed.ConsumedInvite CC where CC.InviteKey = CI.InviteKey)\n ))\n\n -- or a removal authorized by a pre-existing manager over the DISTINCT\n -- remove-tagged digest bound to the departing row's (Key, StampId).\n or (new.Key is null and exists (\n select 1 from committed.Manager A\n where A.MemberKey = context.ManagerKey\n and verify(digest('Strand.Member', 'remove', old.Key, old.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')\n ))\n\n -- or the member removes ITSELF (leaving): the leave-tagged signature\n -- verifies against old.Key itself, so only the departing key's holder\n -- can produce it \u2014 no manager involved. The 'leave' tag (vs the manager\n -- branch's 'remove') is belt-and-braces ONLY: the two branches verify\n -- against DIFFERENT keys, so the former shared tag was never\n -- exploitable \u2014 the anti-replay fix here is the StampId binding, not\n -- this tag.\n or (new.Key is null\n and verify(digest('Strand.Member', 'leave', old.Key, old.StampId), context.MemberSignature, old.Key, 'ed25519'))\n ),\n ) with context (ManagerKey text null, ManagerSignature text null, MemberSignature text null);\n\n -- A member-associated peer (node)\n --\n -- NOTE: a MemberPeer row can OUTLIVE the Member row it names. MemberExists runs on\n -- insert only, and nothing cascades a Member delete, so revoking a member leaves its\n -- peer bindings behind as orphans until a manager clears them (removeMemberPeer).\n -- Any consumer that treats a MemberPeer row as evidence of membership MUST join to\n -- Member rather than trusting the row alone.\n table MemberPeer (\n MemberKey text,\n PeerId text,\n StampId text not null unique, -- per-row authorization nonce, bound into the signed\n -- digests below; retired into Revocation on delete\n -- (same idiom as Member.StampId, rationale there).\n primary key (MemberKey, PeerId),\n -- Refuses any insert naming a retired stamp \u2014 see Member.NotRevoked.\n constraint NotRevoked check on insert (\n not exists (select 1 from Revocation R where R.TableName = 'MemberPeer' and R.StampId = new.StampId)\n ),\n -- A delete must retire its stamp in the same transaction \u2014 see Member.RevocationRecorded.\n constraint RevocationRecorded check on delete (\n exists (select 1 from Revocation R where R.TableName = 'MemberPeer' and R.StampId = old.StampId)\n ),\n -- Binding is insert + delete only, as on Member and Manager; a re-binding is a\n -- remove plus a fresh register. The whole visible identity IS the primary key, so\n -- an update is never an edit \u2014 and Authorized reads the NEW image, so an update\n -- would let ANY member re-point another member's row at its OWN key, signing only\n -- over its own new values, and thereby clear a binding it has no signature to\n -- delete.\n constraint NoUpdate check on update (false),\n -- Mask is explicit: a bare check defaults to insert|update and silently never runs on\n -- DELETE. Insert is the only reachable case \u2014 update is forbidden above, and a delete\n -- leaves no new row image to validate.\n constraint MemberExists check on insert (exists (select 1 from Member M where M.Key = new.MemberKey)),\n constraint Authorized check on insert, delete (\n -- The member itself signs its own binding, verified against the MemberKey the\n -- row names \u2014 so only that member registers (or removes) its own peers. The\n -- registration and removal approvals are DISTINCT action-tagged digests, each\n -- bound to the row's StampId, so a captured registration approval neither\n -- re-registers a cleared binding (its stamp is retired) nor authorizes a\n -- removal (wrong tag).\n (old.MemberKey is null\n and verify(digest('Strand.MemberPeer', 'add', new.MemberKey, new.PeerId, new.StampId), context.Signature, new.MemberKey, 'ed25519'))\n\n -- or the member clears its OWN binding.\n or (new.MemberKey is null\n and verify(digest('Strand.MemberPeer', 'remove', old.MemberKey, old.PeerId, old.StampId), context.Signature, old.MemberKey, 'ed25519'))\n\n -- or a manager clears ANOTHER member's binding \u2014 the cleanup path after a\n -- revocation, which the departing member has no reason to cooperate with.\n -- Gated to DELETE and bound to a manager-remove-tagged digest over the exact\n -- row + stamp. The 'manager-remove' tag (vs the self branch's 'remove') is\n -- belt-and-braces ONLY \u2014 the two branches verify against different keys.\n -- The authorizer is read from the PRE-transaction snapshot\n -- (committed.Manager) for the same reason Member.Authorized does: this check\n -- defers to commit, by which point a manager seated in the SAME transaction\n -- would otherwise be able to authorize its own cleanup.\n or (new.MemberKey is null and exists (\n select 1 from committed.Manager A\n where A.MemberKey = context.ManagerKey\n and verify(digest('Strand.MemberPeer', 'manager-remove', old.MemberKey, old.PeerId, old.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')\n ))\n ),\n ) with context (Signature text null, ManagerKey text null, ManagerSignature text null);\n\n -- A manager is a member that can issue invites, authorize members, and rotate managers\n table Manager (\n MemberKey text primary key,\n -- Lineage ordering. The founding manager is generation 0; every later manager is\n -- seated strictly after (greater than) the manager that appointed it. This is what\n -- makes same-transaction mutual promotion impossible: a deferred CHECK sees only\n -- the post-image, so \"did my authorizer exist before this transaction?\" is not\n -- directly askable \u2014 but the minimum-generation row of any inserted set must find\n -- its authorizer among rows of strictly smaller generation, which can only be a\n -- pre-existing one. Generation is NOT a privilege level: a generation-5 manager\n -- has exactly the same powers as a generation-1 manager.\n Generation integer not null,\n StampId text not null unique, -- per-row authorization nonce, bound into the signed\n -- digests below; retired into Revocation on delete\n -- (same idiom as Member.StampId, rationale there).\n -- Refuses any insert naming a retired stamp \u2014 see Member.NotRevoked. This is what\n -- makes a captured promotion approval single-use: once the manager is removed (its\n -- stamp retired), the approval that seated it can never re-seat it.\n constraint NotRevoked check on insert (\n not exists (select 1 from Revocation R where R.TableName = 'Manager' and R.StampId = new.StampId)\n ),\n -- A delete must retire its stamp in the same transaction \u2014 see Member.RevocationRecorded.\n constraint RevocationRecorded check on delete (\n exists (select 1 from Revocation R where R.TableName = 'Manager' and R.StampId = old.StampId)\n ),\n constraint OnlyClosed check (\n exists (select 1 from Header H where H.Type = 'c')\n ),\n -- A manager IS a member: the table comment has always said so, and since the\n -- single-use-approval work it is load-bearing \u2014 Revocation.Authorized verifies a\n -- delete's tombstone against a committed Member row, so a manager with no Member\n -- row cannot revoke, clear a peer binding, or even resign. Reads the LIVE Member\n -- table, not committed.Member, so an admit-then-promote in ONE transaction passes\n -- (the same shape ConsumedInvite.MemberExists allows for the invite join); the\n -- sibling Member insert is authorized at that same commit, so nothing is waived.\n -- Mask is explicit: a bare check defaults to insert|update, and update is\n -- forbidden outright by NoUpdate below. Nothing is needed on DELETE \u2014 the other\n -- half of the invariant is Member.NotAManager, which refuses to un-member a key\n -- that still holds a Manager row.\n -- NOTE: a per-transaction check against locally visible rows. Two partitioned nodes\n -- \u2014 one promoting X, one removing X's Member and Manager rows \u2014 can each pass\n -- locally and converge to a Manager row with no Member row. Same convergence class\n -- as the MinOneMember / MinOneManager / NotRevoked notes; not solved here.\n constraint MemberExists check on insert (\n exists (select 1 from Member M where M.Key = new.MemberKey)\n ),\n -- Rotation is insert + delete only. An update would let a resignation signature\n -- (which proves only that old.MemberKey consented) double as a hand-off: the\n -- former-manager branch would accept re-pointing the row at any new key.\n constraint NoUpdate check on update (false),\n -- A closed strand must never lose its last manager. Every admit path (Invite,\n -- addMemberByManager, addManager) requires an existing Manager row, so an\n -- admin-less strand can never admit anyone again. Deferred (subquery), so the\n -- count it sees is the POST-delete count.\n -- NOTE: this is a per-transaction check against locally visible rows. Two nodes\n -- that concurrently remove different managers can each see a surviving one and\n -- still converge to zero; if partitioned rotation ever becomes a real workflow,\n -- the floor needs a cross-node guard, not a local count.\n constraint MinOneManager check on delete (\n (select count(1) from Manager) >= 1\n ),\n constraint Authorized check on insert, delete (\n -- Every signed branch below is a domain/action-tagged digest bound to the\n -- row's StampId \u2014 the same single-use idiom as Member.Authorized: an\n -- approval seats (or removes) exactly one incarnation of one row.\n\n -- Bootstrap: the founding manager is seated with no prior signer, at\n -- generation 0. Gated to INSERT (old.MemberKey is null) AND to the founding\n -- state \u2014 at most one Member, and this manager IS that member. Deferred\n -- checks see post-image state, so an ungated count test is also true for a\n -- DELETE that drops the count to <= 1, and for the INSERT half of a\n -- same-transaction swap of the sole manager. The trailing Member-exists test\n -- is belt-and-braces since MemberExists above enforces it on EVERY insert \u2014\n -- kept so this branch's own claim (\"this manager IS that member\") reads in\n -- place rather than by cross-reference.\n (old.MemberKey is null\n and new.Generation = 0\n and (select count(1) from Manager) <= 1\n and (select count(1) from Member) <= 1\n and exists (select 1 from Member M where M.Key = new.MemberKey))\n\n -- or authorized by this former manager (self-resignation), over the\n -- resign-tagged digest bound to its own row's stamp. The 'resign' tag\n -- (vs the admin branch's 'remove') is belt-and-braces \u2014 the two\n -- branches verify against different keys; the anti-replay fix is the\n -- stamp: once this manager is re-promoted under a fresh stamp, a\n -- captured resignation no longer removes it.\n or (\n old.MemberKey is not null\n and old.MemberKey = context.ManagerKey\n and verify(digest('Strand.Manager', 'resign', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')\n )\n\n -- or a promotion signed by an EARLIER-generation manager, over the\n -- add-tagged digest binding (MemberKey, Generation, StampId) \u2014 so a\n -- captured promotion replays at neither a different generation nor a\n -- second incarnation of the row. The strict\n -- A.Generation < new.Generation is what closes same-transaction mutual\n -- promotion: this subquery runs at commit against the post-insert row\n -- set, so sibling rows inserted in the same transaction are visible \u2014\n -- but the minimum-generation row of any inserted set cannot find its\n -- authorizer among its siblings (that would contradict minimality), so\n -- that authorizer must be a pre-existing manager who really signed.\n -- A mutual pair or ring needs each generation strictly below another's\n -- in a cycle; impossible. Nothing sits below the founder's 0, so a\n -- chain cannot duck underneath either. The <> is subsumed by the\n -- ordering (a row's generation is never below its own) but kept to\n -- state the no-self-promotion intent locally.\n or (old.MemberKey is null and exists (\n select 1 from Manager A\n where A.MemberKey = context.ManagerKey\n and A.MemberKey <> new.MemberKey\n and A.Generation < new.Generation\n and verify(digest('Strand.Manager', 'add', new.MemberKey, new.Generation, new.StampId), context.Signature, A.MemberKey, 'ed25519')))\n\n -- or a removal authorized by ANOTHER existing manager, over the\n -- remove-tagged digest bound to (MemberKey, StampId). Deliberately no\n -- generation condition here: deletes are safe once inserts are (every\n -- accepting branch requires a Manager row in the post-image, and no\n -- attacker row can get there), and a generation gate would break a\n -- later-generation manager removing an earlier-generation one.\n or (new.MemberKey is null and exists (\n select 1 from Manager A\n where A.MemberKey = context.ManagerKey\n and A.MemberKey <> old.MemberKey\n and verify(digest('Strand.Manager', 'remove', old.MemberKey, old.StampId), context.Signature, A.MemberKey, 'ed25519')))\n )\n ) with context (ManagerKey text null, Signature text null);\n\n -- Permanent tombstones for retired per-row authorization nonces (StampIds) \u2014 the\n -- delete half of the retired-stamp anti-replay idiom on Member / Manager /\n -- MemberPeer. Adapted from CadreControl.Revocation (the idiom is stated in full on\n -- CadreControl.OwnerKey); two deliberate differences:\n -- 1. TableName is confined to the three guarded STRAND tables (RowIsGone below).\n -- 2. Authorized verifies against a committed MEMBER row, not an owner key: every\n -- delete path in this schema is driven by a manager or by the departing party,\n -- and both are members. The action tag is 'retire' where the control layer\n -- says 'remove'; the domain tag already keeps the two schemas' approvals\n -- disjoint, so the divergence is cosmetic \u2014 kept because 'retire' names the\n -- act (retiring a stamp), not a row operation.\n -- NOTE: the guarded tables' NotRevoked CHECK runs against locally visible rows, so\n -- a node that has not yet converged on a Revocation row can still accept a replayed\n -- add and the resurrected row coexists with the tombstone after merge \u2014 the same\n -- convergence class as the MinOneMember / MinOneManager local-count notes.\n -- NOTE: this table only grows \u2014 one row per membership deletion, plus any junk any\n -- member chooses to file, and Immutable means rows never go away. Fine at strand\n -- scale; if membership churn ever gets large it needs pruning bounded by something\n -- other than \"forever\".\n -- NOTE: nothing on the strand read side drops a row because its stamp is retired\n -- (NotRevoked is insert-only), which is why a tombstone filed against a live but\n -- not-yet-converged row is inert here. If a read path ever starts filtering on\n -- Revocation (as the control layer's listAuthorizedMembers does), filing a tombstone\n -- becomes a remote eviction primitive and Authorized below must narrow from \"any\n -- member\" to \"a manager, or the row's own owner\".\n table Revocation (\n TableName text, -- 'Member' | 'Manager' | 'MemberPeer' (confined by RowIsGone below)\n StampId text, -- the retired nonce\n primary key (TableName, StampId),\n constraint OnlyClosed check (\n exists (select 1 from Header H where H.Type = 'c')\n ),\n -- Retirement is permanent: clearing or re-pointing a tombstone would restore the replay.\n constraint Immutable check on update, delete (false),\n -- A stamp may only be retired once its row is actually gone. Deferred (subquery), so a\n -- delete in the same transaction has already landed. Retiring a stamp that never\n -- existed is permitted and harmless: a stamp carries 256 bits of CSPRNG output\n -- (strand-membership-writer.ts generateStrandStampId), so a future legitimate one\n -- cannot be guessed and pre-planted \u2014 junk stamps only grow the table.\n constraint RowIsGone check on insert (\n (new.TableName = 'Member' and not exists (select 1 from Member M where M.StampId = new.StampId))\n or (new.TableName = 'Manager' and not exists (select 1 from Manager A where A.StampId = new.StampId))\n or (new.TableName = 'MemberPeer' and not exists (select 1 from MemberPeer P where P.StampId = new.StampId))\n ),\n -- Filing a tombstone is a MEMBER action: every legitimate delete in this schema is\n -- driven by a manager or by the departing party \u2014 both members \u2014 and the tombstone\n -- rides the delete's own transaction. The authorizer is read from the\n -- PRE-transaction snapshot (committed.Member) for the same reason the sibling\n -- tables read committed.*: this check defers to commit, by which point a member\n -- seated in the same transaction is live. The digest binds the WHOLE row\n -- (TableName, StampId) under its own domain tag, so it is disjoint from every\n -- other rule \u2014 including the remove-tagged approval the same party signs in the\n -- SAME transaction for the delete this tombstone accompanies. RowIsGone and\n -- Immutable guard what authorization does not: retiring a stamp early, or\n -- withdrawing one later.\n constraint Authorized check on insert (\n exists (select 1 from committed.Member M\n where M.Key = context.MemberKey\n and verify(digest('Strand.Revocation', 'retire', new.TableName, new.StampId), context.Signature, M.Key, 'ed25519'))\n )\n ) with context (MemberKey text, Signature text);\n";
|
|
30
|
+
export declare const STRAND_SCHEMA = " table Header (\n Id text, -- UUID of this network - generated at inception - immutable\n\n -- Types: 'o' = Open, 'c' = Closed\n -- Any peers can join an open network, but only members can join a closed network\n -- Open can still control writes in the app, but only Closed prevents reads\n Type text check (Type in ('o', 'c')),\n\n -- The app that this strand faciliates - public key from author to prevent tampering\n sAppId text,\n\n -- The version of the sApp that this strand faciliates\n sAppVersion text,\n\n -- The schema of the sApp that this strand faciliates\n sAppSchema text,\n\n -- The author's signature on the schema to guarantee authorship\n sAppSignature text,\n\n -- The engine (rules logic system) that is assumed to manage this strand\n Engine text,\n\n -- The version of the engine that is assumed to manage this strand\n EngineVersion text,\n\n primary key (/* empty - singleton */),\n constraint InsertOnly check on update, delete (false), -- One-time insert only for now - TODO: revisit for versioning\n );\n\n -- An invitation to join a strand as a member\n table Invite (\n Key text primary key,\n Expiration datetime null,\n -- Deactivation is a CancelledInvite tombstone (below), not a delete of this row\n -- or a state column on it \u2014 rationale on that table.\n constraint InsertOnly check on update, delete (false),\n constraint OnlyClosed check (\n exists (select 1 from Header H where H.Type = 'c')\n ),\n constraint InviteValid check on insert (\n -- Can only be inserted by a manager,\n exists (select 1 from Manager A\n where A.MemberKey = context.ManagerKey\n and verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), context.ManagerSignature, A.MemberKey, 'ed25519')\n )\n -- and must also prove invite private key held by issuing manager\n and verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), context.InviteSignature, new.Key, 'ed25519')\n )\n ) with context (ManagerKey text null, ManagerSignature text null, InviteSignature text null);\n\n -- Invite [InviteKey] has been used to add [MemberKey] as a member\n -- No StampId here or on Invite: both tables are insert-only and their primary keys are\n -- minted fresh per issuance/consumption, so a replayed insert collides on the PK.\n table ConsumedInvite (\n InviteKey text primary key,\n MemberKey text,\n constraint InsertOnly check on update, delete (false),\n constraint InviteExists check (exists (select 1 from Invite I where I.Key = new.InviteKey)),\n constraint MemberExists check (exists (select 1 from Member M where M.Key = new.MemberKey)),\n constraint ValidUsage check on insert (\n exists (select 1 from Invite I where I.Key = new.InviteKey and verify(digest(new.InviteKey || '|' || new.MemberKey), context.InviteSignature, new.InviteKey, 'ed25519'))\n ),\n -- An invite with a non-null Expiration may only be consumed while it is still\n -- in the future. context.Now is the canonical-datetime \"now\" supplied by the\n -- consumeInvite writer (same canonicalDatetime() transform used to store\n -- Invite.Expiration), so both sides of the comparison are byte-identical\n -- canonical strings and the lexical \">\" comparison orders chronologically. A\n -- null Expiration never expires. Mirrors CadreControl.FormationUsage's\n -- \"FI.ExpiresAt is null or FI.ExpiresAt > context.Now\" gate.\n constraint NotExpired check on insert (\n exists (select 1 from Invite I where I.Key = new.InviteKey and (I.Expiration is null or I.Expiration > context.Now))\n ),\n -- A cancelled invitation may never be consumed. Blocking the ConsumedInvite\n -- insert is sufficient to block the JOIN: Member.Authorized's invite branch\n -- requires a same-transaction FRESH ConsumedInvite row, and consumeInvite writes\n -- both rows in one transaction, so the whole join rolls back. The rule lives here\n -- alone rather than being duplicated into Member.Authorized. Reads only stored\n -- rows \u2014 no caller-supplied context decides admission (unlike NotExpired's Now).\n constraint NotCancelled check on insert (\n not exists (select 1 from CancelledInvite C where C.InviteKey = new.InviteKey)\n ),\n -- A SEALED strand (a closed strand whose Manager table its sole manager has\n -- deliberately emptied \u2014 see the Manager table comment) admits nobody, and that\n -- includes the holder of an unspent invitation issued BEFORE the seal.\n -- cancelInvite itself needs a manager, so after sealing nobody could kill such\n -- an invitation \u2014 this gate is what kills it. The subquery reads the LIVE\n -- Manager table and defers to commit, so it sees the post-image: at least one\n -- manager must survive the transaction, and sealing plus consuming in the SAME\n -- transaction is rejected too. Blocking this insert blocks the whole join, for\n -- exactly the reason NotCancelled gives above.\n constraint NotSealed check on insert (\n exists (select 1 from Manager)\n )\n ) with context (InviteSignature text null, Now datetime null);\n\n -- A manager has cancelled invitation [InviteKey]: it may never be consumed.\n --\n -- A monotone TOMBSTONE, not a delete of the Invite row and not a state column on it:\n -- 1. Deleting the Invite row would re-open a replay surface. InviteValid signs only\n -- (Key, Expiration) \u2014 there is no per-row StampId on Invite \u2014 so a deleted Invite\n -- row can be re-created by replaying the captured issue signatures. Closing that\n -- would mean adding Invite.StampId and widening Revocation.RowIsGone to a fourth\n -- table. (CadreControl.FormationInvite IS deletable \u2014 it carries a StampId for\n -- exactly this reason.)\n -- 2. A state column needs an UPDATE, and Invite is insert-only on purpose.\n -- 3. Insert-only converges: a growing set of dead markers merges cleanly across\n -- nodes, a delete/re-insert pair does not. Same rationale as Strand.Revocation.\n -- No StampId here: the table is insert-only and its primary key IS the invite key, so\n -- a replayed insert collides on the PK \u2014 the argument the comment above ConsumedInvite\n -- makes for Invite/ConsumedInvite.\n -- Deliberately NO InviteExists gate: a node that has not yet converged on the Invite\n -- row must still be able to record the cancellation. Refusing it would fail OPEN on a\n -- security control, which is strictly worse than accepting a junk row.\n -- NOTE: this table only grows \u2014 one row per cancellation, plus any junk a manager\n -- chooses to file, and Immutable means rows never go away (the tradeoff\n -- Revocation.RowIsGone already documents for junk stamps). Fine at strand scale; if\n -- invite churn ever gets large it needs pruning bounded by something other than\n -- \"forever\".\n -- NOTE: a cancellation must PROPAGATE before the holder redeems \u2014 a holder consuming\n -- on a node that has not yet seen this row still gets in. Same convergence class as\n -- the NotRevoked / MinOneMember local-visibility notes and the Manager table's\n -- seal-propagation note; not solved here.\n table CancelledInvite (\n InviteKey text primary key,\n constraint OnlyClosed check (\n exists (select 1 from Header H where H.Type = 'c')\n ),\n -- Cancellation is permanent: un-cancelling would restore the re-entry it closed.\n -- To re-invite a party, issue a fresh invitation.\n constraint Immutable check on update, delete (false),\n -- committed.Manager, not Manager: this check has a subquery so it defers to\n -- commit, by which point a manager seated in the SAME transaction would otherwise\n -- be able to authorize its own cancellation. Member, MemberPeer and Revocation\n -- read committed.* for the same reason. (Manager.Authorized reads the LIVE table\n -- instead \u2014 there it is the strict Generation ordering, not a snapshot, that\n -- excludes an authorizer seated in the same transaction.)\n constraint Authorized check on insert (\n exists (select 1 from committed.Manager A\n where A.MemberKey = context.ManagerKey\n and verify(digest('Strand.CancelledInvite', 'cancel', new.InviteKey),\n context.ManagerSignature, A.MemberKey, 'ed25519'))\n )\n ) with context (ManagerKey text, ManagerSignature text);\n\n -- A party in the closed strand network\n table Member (\n Key text primary key,\n StampId text not null unique, -- per-row authorization nonce, bound into the signed digests below.\n -- unique holds over LIVE rows only; a removed row's stamp is retired\n -- permanently into Revocation (NotRevoked below), so the never-expiring\n -- admission signature cannot resurrect a revoked member.\n -- A removed row's StampId is retired into Revocation, and this refuses any insert\n -- naming a retired stamp: the approval that seated this row can never re-seat it\n -- after removal. unique alone did not do this \u2014 it only holds over LIVE rows, so\n -- a delete freed the stamp and the original never-expiring signature verified\n -- again. A legitimate re-add mints a FRESH StampId and a fresh signature, so it is\n -- unaffected. Mirrors CadreControl.OwnerKey.NotRevoked (rationale in full there).\n constraint NotRevoked check on insert (\n not exists (select 1 from Revocation R where R.TableName = 'Member' and R.StampId = new.StampId)\n ),\n -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the\n -- same transaction, or the stamp would free up again. Deferred (subquery), so the\n -- sibling insert is visible at commit regardless of statement order.\n constraint RevocationRecorded check on delete (\n exists (select 1 from Revocation R where R.TableName = 'Member' and R.StampId = old.StampId)\n ),\n -- Membership is insert + delete only; a re-keyed member is a remove + fresh add.\n constraint NoUpdate check on update (false),\n -- Mask is explicit on all three operations: a bare check defaults to\n -- insert|update and silently never runs on DELETE.\n constraint OnlyClosed check on insert, update, delete (\n exists (select 1 from Header H where H.Type = 'c')\n ),\n -- A closed strand must never lose its last member: Member is the strand's read\n -- gate, and the founding Manager is only seatable beside an existing Member, so\n -- an empty member set is a permanent denial of the whole strand. Deferred\n -- (subquery), so the count it sees is the POST-delete count.\n -- NOTE: this is a per-transaction check against locally visible rows. Two nodes\n -- that concurrently remove different members can each see a surviving one and\n -- still converge to zero; if partitioned removal ever becomes a real workflow,\n -- the floor needs a cross-node guard, not a local count.\n constraint MinOneMember check on delete (\n (select count(1) from Member) >= 1\n ),\n -- A member still holding a Manager row cannot be un-membered \u2014 it must resign\n -- its Manager row first. Deferred (subquery), so it reads the POST-image: a\n -- single transaction deleting BOTH the Manager and the Member row passes.\n constraint NotAManager check on delete (\n not exists (select 1 from Manager A where A.MemberKey = old.Key)\n ),\n constraint Authorized check on insert, delete (\n -- Every authorizer is read from the PRE-transaction snapshot (committed.*):\n -- this CHECK auto-defers to commit (it has a subquery), by which point rows\n -- inserted alongside are live, so a plain from-Manager would let a manager\n -- seated in this same transaction authorize a removal. committed.* states\n -- the rule directly: the authorizer existed BEFORE this transaction.\n\n -- Every signed digest below leads with two fixed literals \u2014 the\n -- 'Strand.Member' domain tag and an action tag \u2014 so an approval verifies\n -- ONLY against the one rule it was minted for (the idiom of CadreControl's\n -- control-authorization.ts), and binds the row's StampId, so it authorizes\n -- exactly one INCARNATION of the row: once the row is deleted (retiring the\n -- stamp) or re-created under a fresh stamp, the captured approval matches\n -- nothing.\n\n -- Bootstrap: the FOUNDING transaction \u2014 one whose pre-transaction member\n -- set is empty \u2014 seats the first member, AND ONLY THAT ONE, with no\n -- authorization. Both counts are load-bearing. The committed (PRE-image)\n -- count keeps the branch off for a DELETE and for the insert half of a\n -- same-transaction wipe-then-seat (the wiped rows were committed). The\n -- live (POST-image) count keeps a single founding transaction from\n -- seating an unbounded unauthenticated member set \u2014 which would also\n -- brick the strand, since Manager's own bootstrap branch requires at\n -- most one Member.\n (old.Key is null\n and (select count(1) from committed.Member) = 0\n and (select count(1) from Member) <= 1)\n\n -- or added directly by a pre-existing manager signing the add-tagged\n -- digest over (Key, StampId) \u2014 bound to this row's fresh stamp, so the\n -- approval seats exactly this incarnation and dies with it.\n or (old.Key is null and exists (\n select 1 from committed.Manager A\n where A.MemberKey = context.ManagerKey\n and verify(digest('Strand.Member', 'add', new.Key, new.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')\n ))\n\n -- or added by consuming an invite. The ConsumedInvite admitting this\n -- member must be same-transaction FRESH \u2014 its InviteKey absent from the\n -- committed snapshot. consumeInvite inserts Member + ConsumedInvite in\n -- one transaction, so a genuine join passes; a revoked member's STALE\n -- committed row no longer re-admits it, while a fresh manager-issued\n -- invite still does (new InviteKey \u2192 uncommitted row). InviteExists +\n -- ValidUsage on ConsumedInvite keep this branch mintable only via a\n -- real invite and possession of its private key.\n or (old.Key is null and exists (\n select 1 from ConsumedInvite CI\n where CI.MemberKey = new.Key\n and not exists (select 1 from committed.ConsumedInvite CC where CC.InviteKey = CI.InviteKey)\n ))\n\n -- or a removal authorized by a pre-existing manager over the DISTINCT\n -- remove-tagged digest bound to the departing row's (Key, StampId).\n or (new.Key is null and exists (\n select 1 from committed.Manager A\n where A.MemberKey = context.ManagerKey\n and verify(digest('Strand.Member', 'remove', old.Key, old.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')\n ))\n\n -- or the member removes ITSELF (leaving): the leave-tagged signature\n -- verifies against old.Key itself, so only the departing key's holder\n -- can produce it \u2014 no manager involved. The 'leave' tag (vs the manager\n -- branch's 'remove') is belt-and-braces ONLY: the two branches verify\n -- against DIFFERENT keys, so the former shared tag was never\n -- exploitable \u2014 the anti-replay fix here is the StampId binding, not\n -- this tag.\n or (new.Key is null\n and verify(digest('Strand.Member', 'leave', old.Key, old.StampId), context.MemberSignature, old.Key, 'ed25519'))\n ),\n ) with context (ManagerKey text null, ManagerSignature text null, MemberSignature text null);\n\n -- A member-associated peer (node)\n --\n -- NOTE: a MemberPeer row can OUTLIVE the Member row it names. MemberExists runs on\n -- insert only, and nothing cascades a Member delete, so revoking a member leaves its\n -- peer bindings behind as orphans until a manager clears them (removeMemberPeer).\n -- Any consumer that treats a MemberPeer row as evidence of membership MUST join to\n -- Member rather than trusting the row alone.\n table MemberPeer (\n MemberKey text,\n PeerId text,\n StampId text not null unique, -- per-row authorization nonce, bound into the signed\n -- digests below; retired into Revocation on delete\n -- (same idiom as Member.StampId, rationale there).\n primary key (MemberKey, PeerId),\n -- Refuses any insert naming a retired stamp \u2014 see Member.NotRevoked.\n constraint NotRevoked check on insert (\n not exists (select 1 from Revocation R where R.TableName = 'MemberPeer' and R.StampId = new.StampId)\n ),\n -- A delete must retire its stamp in the same transaction \u2014 see Member.RevocationRecorded.\n constraint RevocationRecorded check on delete (\n exists (select 1 from Revocation R where R.TableName = 'MemberPeer' and R.StampId = old.StampId)\n ),\n -- Binding is insert + delete only, as on Member and Manager; a re-binding is a\n -- remove plus a fresh register. The whole visible identity IS the primary key, so\n -- an update is never an edit \u2014 and Authorized reads the NEW image, so an update\n -- would let ANY member re-point another member's row at its OWN key, signing only\n -- over its own new values, and thereby clear a binding it has no signature to\n -- delete.\n constraint NoUpdate check on update (false),\n -- Mask is explicit: a bare check defaults to insert|update and silently never runs on\n -- DELETE. Insert is the only reachable case \u2014 update is forbidden above, and a delete\n -- leaves no new row image to validate.\n constraint MemberExists check on insert (exists (select 1 from Member M where M.Key = new.MemberKey)),\n constraint Authorized check on insert, delete (\n -- The member itself signs its own binding, verified against the MemberKey the\n -- row names \u2014 so only that member registers (or removes) its own peers. The\n -- registration and removal approvals are DISTINCT action-tagged digests, each\n -- bound to the row's StampId, so a captured registration approval neither\n -- re-registers a cleared binding (its stamp is retired) nor authorizes a\n -- removal (wrong tag).\n (old.MemberKey is null\n and verify(digest('Strand.MemberPeer', 'add', new.MemberKey, new.PeerId, new.StampId), context.Signature, new.MemberKey, 'ed25519'))\n\n -- or the member clears its OWN binding.\n or (new.MemberKey is null\n and verify(digest('Strand.MemberPeer', 'remove', old.MemberKey, old.PeerId, old.StampId), context.Signature, old.MemberKey, 'ed25519'))\n\n -- or a manager clears ANOTHER member's binding \u2014 the cleanup path after a\n -- revocation, which the departing member has no reason to cooperate with.\n -- Gated to DELETE and bound to a manager-remove-tagged digest over the exact\n -- row + stamp. The 'manager-remove' tag (vs the self branch's 'remove') is\n -- belt-and-braces ONLY \u2014 the two branches verify against different keys.\n -- The authorizer is read from the PRE-transaction snapshot\n -- (committed.Manager) for the same reason Member.Authorized does: this check\n -- defers to commit, by which point a manager seated in the SAME transaction\n -- would otherwise be able to authorize its own cleanup.\n or (new.MemberKey is null and exists (\n select 1 from committed.Manager A\n where A.MemberKey = context.ManagerKey\n and verify(digest('Strand.MemberPeer', 'manager-remove', old.MemberKey, old.PeerId, old.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')\n ))\n ),\n ) with context (Signature text null, ManagerKey text null, ManagerSignature text null);\n\n -- A manager is a member that can issue invites, authorize members, and rotate managers\n table Manager (\n MemberKey text primary key,\n -- Lineage ordering. The founding manager is generation 0; every later manager is\n -- seated strictly after (greater than) the manager that appointed it. This is what\n -- makes same-transaction mutual promotion impossible: a deferred CHECK sees only\n -- the post-image, so \"did my authorizer exist before this transaction?\" is not\n -- directly askable \u2014 but the minimum-generation row of any inserted set must find\n -- its authorizer among rows of strictly smaller generation, which can only be a\n -- pre-existing one. Generation is NOT a privilege level: a generation-5 manager\n -- has exactly the same powers as a generation-1 manager.\n Generation integer not null,\n StampId text not null unique, -- per-row authorization nonce, bound into the signed\n -- digests below; retired into Revocation on delete\n -- (same idiom as Member.StampId, rationale there).\n -- Refuses any insert naming a retired stamp \u2014 see Member.NotRevoked. This is what\n -- makes a captured promotion approval single-use: once the manager is removed (its\n -- stamp retired), the approval that seated it can never re-seat it.\n constraint NotRevoked check on insert (\n not exists (select 1 from Revocation R where R.TableName = 'Manager' and R.StampId = new.StampId)\n ),\n -- A delete must retire its stamp in the same transaction \u2014 see Member.RevocationRecorded.\n constraint RevocationRecorded check on delete (\n exists (select 1 from Revocation R where R.TableName = 'Manager' and R.StampId = old.StampId)\n ),\n constraint OnlyClosed check (\n exists (select 1 from Header H where H.Type = 'c')\n ),\n -- A manager IS a member: the table comment has always said so, and since the\n -- single-use-approval work it is load-bearing \u2014 Revocation.Authorized verifies a\n -- delete's tombstone against a committed Member row, so a manager with no Member\n -- row cannot revoke, clear a peer binding, or even resign. Reads the LIVE Member\n -- table, not committed.Member, so an admit-then-promote in ONE transaction passes\n -- (the same shape ConsumedInvite.MemberExists allows for the invite join); the\n -- sibling Member insert is authorized at that same commit, so nothing is waived.\n -- Mask is explicit: a bare check defaults to insert|update, and update is\n -- forbidden outright by NoUpdate below. Nothing is needed on DELETE \u2014 the other\n -- half of the invariant is Member.NotAManager, which refuses to un-member a key\n -- that still holds a Manager row.\n -- NOTE: a per-transaction check against locally visible rows. Two partitioned nodes\n -- \u2014 one promoting X, one removing X's Member and Manager rows \u2014 can each pass\n -- locally and converge to a Manager row with no Member row. Same convergence class\n -- as the MinOneMember / NotRevoked / seal-propagation notes; not solved here.\n constraint MemberExists check on insert (\n exists (select 1 from Member M where M.Key = new.MemberKey)\n ),\n -- Rotation is insert + delete only. An update would let a resignation signature\n -- (which proves only that old.MemberKey consented) double as a hand-off: the\n -- former-manager branch would accept re-pointing the row at any new key.\n constraint NoUpdate check on update (false),\n -- A closed strand loses its LAST manager only by SEALING \u2014 a distinct,\n -- self-signed act (the 'seal' branch of Authorized below), never an ordinary\n -- resignation and never an admin removal. There is deliberately NO\n -- min-one-manager floor: a strand with no managers is frozen, not bricked.\n -- Every admission path (Invite, addMemberByManager, addManager) requires a\n -- Manager row, so a sealed strand's membership can never grow again \u2014 and that\n -- freeze is the privacy guarantee its remaining members are buying: nobody\n -- holds the power to admit a party who would then read the strand's whole\n -- history. Sealing is irreversible: the founding branch of Authorized closes\n -- for good once any manager stamp has been retired into Revocation.\n -- NOTE: a seal must PROPAGATE before it binds a given node \u2014 a node that has\n -- not yet converged on the delete still shows the manager row, so the\n -- ex-manager's OWN key could still admit there until the delete arrives. Only\n -- that one key; no stranger gains anything. Same convergence class as the\n -- MinOneMember / NotRevoked local-visibility notes; not solved here.\n constraint Authorized check on insert, delete (\n -- Every signed branch below is a domain/action-tagged digest bound to the\n -- row's StampId \u2014 the same single-use idiom as Member.Authorized: an\n -- approval seats (or removes) exactly one incarnation of one row.\n\n -- Bootstrap: the founding manager is seated with no prior signer, at\n -- generation 0. Gated to INSERT (old.MemberKey is null) AND to the founding\n -- state \u2014 at most one Member, and this manager IS that member. Deferred\n -- checks see post-image state, so an ungated count test is also true for a\n -- DELETE that drops the count to <= 1, and for the INSERT half of a\n -- same-transaction swap of the sole manager. The trailing Member-exists test\n -- is belt-and-braces since MemberExists above enforces it on EVERY insert \u2014\n -- kept so this branch's own claim (\"this manager IS that member\") reads in\n -- place rather than by cross-reference.\n -- The Revocation gate closes this branch FOREVER once any manager seat has\n -- been retired: founding happens exactly once per strand (Header, then\n -- Member, then Manager, with no Revocation rows in existence), so on a\n -- SEALED strand a lone surviving member cannot re-seat itself as a\n -- generation-0 founder and start admitting. Accepted, deliberate: between\n -- its own founding Member and Manager inserts a founder could, with its own\n -- key, file a junk Revocation('Manager', ...) tombstone (RowIsGone permits\n -- stamps naming no real row) and permanently block its own founding \u2014 pure\n -- self-harm needing the founder's private key; no other party can reach\n -- that window (there are no other committed members yet).\n (old.MemberKey is null\n and new.Generation = 0\n and (select count(1) from Manager) <= 1\n and (select count(1) from Member) <= 1\n and exists (select 1 from Member M where M.Key = new.MemberKey)\n and not exists (select 1 from Revocation R where R.TableName = 'Manager'))\n\n -- or removed by the departing manager ITSELF, over one of two\n -- self-signed, action-tagged digests bound to its own row's stamp. The\n -- POST-image manager count is what distinguishes the two: 'resign' is\n -- an ordinary departure that leaves at least one manager standing,\n -- while 'seal' is the delete that empties the table and permanently\n -- freezes admission (see the table comment above). The tag is what\n -- makes each a signed statement of THAT intent \u2014 a captured\n -- resignation approval can never seal, nor a seal approval merely\n -- resign \u2014 and the stamp is still what makes each single-use: once\n -- this manager is re-promoted under a fresh stamp, neither captured\n -- approval removes the new row. Both counts are subqueries, so the\n -- whole check defers to commit and the counts are the post-image.\n or (\n old.MemberKey is not null\n and old.MemberKey = context.ManagerKey\n and (select count(1) from Manager) >= 1\n and verify(digest('Strand.Manager', 'resign', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')\n )\n or (\n old.MemberKey is not null\n and old.MemberKey = context.ManagerKey\n and (select count(1) from Manager) = 0\n and verify(digest('Strand.Manager', 'seal', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')\n )\n\n -- or a promotion signed by an EARLIER-generation manager, over the\n -- add-tagged digest binding (MemberKey, Generation, StampId) \u2014 so a\n -- captured promotion replays at neither a different generation nor a\n -- second incarnation of the row. The strict\n -- A.Generation < new.Generation is what closes same-transaction mutual\n -- promotion: this subquery runs at commit against the post-insert row\n -- set, so sibling rows inserted in the same transaction are visible \u2014\n -- but the minimum-generation row of any inserted set cannot find its\n -- authorizer among its siblings (that would contradict minimality), so\n -- that authorizer must be a pre-existing manager who really signed.\n -- A mutual pair or ring needs each generation strictly below another's\n -- in a cycle; impossible. Nothing sits below the founder's 0, so a\n -- chain cannot duck underneath either. The <> is subsumed by the\n -- ordering (a row's generation is never below its own) but kept to\n -- state the no-self-promotion intent locally.\n or (old.MemberKey is null and exists (\n select 1 from Manager A\n where A.MemberKey = context.ManagerKey\n and A.MemberKey <> new.MemberKey\n and A.Generation < new.Generation\n and verify(digest('Strand.Manager', 'add', new.MemberKey, new.Generation, new.StampId), context.Signature, A.MemberKey, 'ed25519')))\n\n -- or a removal authorized by ANOTHER existing manager, over the\n -- remove-tagged digest bound to (MemberKey, StampId). Deliberately no\n -- generation condition here: deletes are safe once inserts are (every\n -- accepting branch requires a Manager row in the post-image, and no\n -- attacker row can get there), and a generation gate would break a\n -- later-generation manager removing an earlier-generation one.\n or (new.MemberKey is null and exists (\n select 1 from Manager A\n where A.MemberKey = context.ManagerKey\n and A.MemberKey <> old.MemberKey\n and verify(digest('Strand.Manager', 'remove', old.MemberKey, old.StampId), context.Signature, A.MemberKey, 'ed25519')))\n )\n ) with context (ManagerKey text null, Signature text null);\n\n -- Permanent tombstones for retired per-row authorization nonces (StampIds) \u2014 the\n -- delete half of the retired-stamp anti-replay idiom on Member / Manager /\n -- MemberPeer. Adapted from CadreControl.Revocation (the idiom is stated in full on\n -- CadreControl.OwnerKey); two deliberate differences:\n -- 1. TableName is confined to the three guarded STRAND tables (RowIsGone below).\n -- 2. Authorized verifies against a committed MEMBER row, not an owner key: every\n -- delete path in this schema is driven by a manager or by the departing party,\n -- and both are members. The action tag is 'retire' where the control layer\n -- says 'remove'; the domain tag already keeps the two schemas' approvals\n -- disjoint, so the divergence is cosmetic \u2014 kept because 'retire' names the\n -- act (retiring a stamp), not a row operation.\n -- NOTE: the guarded tables' NotRevoked CHECK runs against locally visible rows, so\n -- a node that has not yet converged on a Revocation row can still accept a replayed\n -- add and the resurrected row coexists with the tombstone after merge \u2014 the same\n -- convergence class as the MinOneMember / Manager seal-propagation notes.\n -- NOTE: this table only grows \u2014 one row per membership deletion, plus any junk any\n -- member chooses to file, and Immutable means rows never go away. Fine at strand\n -- scale; if membership churn ever gets large it needs pruning bounded by something\n -- other than \"forever\".\n -- NOTE: nothing on the strand read side drops a row because its stamp is retired\n -- (NotRevoked is insert-only), which is why a tombstone filed against a live but\n -- not-yet-converged row is inert here. If a read path ever starts filtering on\n -- Revocation (as the control layer's listAuthorizedMembers does), filing a tombstone\n -- becomes a remote eviction primitive and Authorized below must narrow from \"any\n -- member\" to \"a manager, or the row's own owner\".\n table Revocation (\n TableName text, -- 'Member' | 'Manager' | 'MemberPeer' (confined by RowIsGone below)\n StampId text, -- the retired nonce\n primary key (TableName, StampId),\n constraint OnlyClosed check (\n exists (select 1 from Header H where H.Type = 'c')\n ),\n -- Retirement is permanent: clearing or re-pointing a tombstone would restore the replay.\n constraint Immutable check on update, delete (false),\n -- A stamp may only be retired once its row is actually gone. Deferred (subquery), so a\n -- delete in the same transaction has already landed. Retiring a stamp that never\n -- existed is permitted and harmless: a stamp carries 256 bits of CSPRNG output\n -- (strand-membership-writer.ts generateStrandStampId), so a future legitimate one\n -- cannot be guessed and pre-planted \u2014 junk stamps only grow the table.\n constraint RowIsGone check on insert (\n (new.TableName = 'Member' and not exists (select 1 from Member M where M.StampId = new.StampId))\n or (new.TableName = 'Manager' and not exists (select 1 from Manager A where A.StampId = new.StampId))\n or (new.TableName = 'MemberPeer' and not exists (select 1 from MemberPeer P where P.StampId = new.StampId))\n ),\n -- Filing a tombstone is a MEMBER action: every legitimate delete in this schema is\n -- driven by a manager or by the departing party \u2014 both members \u2014 and the tombstone\n -- rides the delete's own transaction. The authorizer is read from the\n -- PRE-transaction snapshot (committed.Member) for the same reason the sibling\n -- tables read committed.*: this check defers to commit, by which point a member\n -- seated in the same transaction is live. The digest binds the WHOLE row\n -- (TableName, StampId) under its own domain tag, so it is disjoint from every\n -- other rule \u2014 including the remove-tagged approval the same party signs in the\n -- SAME transaction for the delete this tombstone accompanies. RowIsGone and\n -- Immutable guard what authorization does not: retiring a stamp early, or\n -- withdrawing one later.\n constraint Authorized check on insert (\n exists (select 1 from committed.Member M\n where M.Key = context.MemberKey\n and verify(digest('Strand.Revocation', 'retire', new.TableName, new.StampId), context.Signature, M.Key, 'ed25519'))\n )\n ) with context (MemberKey text, Signature text);\n";
|
package/dist/strand-schema.js
CHANGED
|
@@ -108,6 +108,18 @@ export const STRAND_SCHEMA = ` table Header (
|
|
|
108
108
|
-- rows — no caller-supplied context decides admission (unlike NotExpired's Now).
|
|
109
109
|
constraint NotCancelled check on insert (
|
|
110
110
|
not exists (select 1 from CancelledInvite C where C.InviteKey = new.InviteKey)
|
|
111
|
+
),
|
|
112
|
+
-- A SEALED strand (a closed strand whose Manager table its sole manager has
|
|
113
|
+
-- deliberately emptied — see the Manager table comment) admits nobody, and that
|
|
114
|
+
-- includes the holder of an unspent invitation issued BEFORE the seal.
|
|
115
|
+
-- cancelInvite itself needs a manager, so after sealing nobody could kill such
|
|
116
|
+
-- an invitation — this gate is what kills it. The subquery reads the LIVE
|
|
117
|
+
-- Manager table and defers to commit, so it sees the post-image: at least one
|
|
118
|
+
-- manager must survive the transaction, and sealing plus consuming in the SAME
|
|
119
|
+
-- transaction is rejected too. Blocking this insert blocks the whole join, for
|
|
120
|
+
-- exactly the reason NotCancelled gives above.
|
|
121
|
+
constraint NotSealed check on insert (
|
|
122
|
+
exists (select 1 from Manager)
|
|
111
123
|
)
|
|
112
124
|
) with context (InviteSignature text null, Now datetime null);
|
|
113
125
|
|
|
@@ -136,7 +148,8 @@ export const STRAND_SCHEMA = ` table Header (
|
|
|
136
148
|
-- "forever".
|
|
137
149
|
-- NOTE: a cancellation must PROPAGATE before the holder redeems — a holder consuming
|
|
138
150
|
-- on a node that has not yet seen this row still gets in. Same convergence class as
|
|
139
|
-
-- the NotRevoked / MinOneMember
|
|
151
|
+
-- the NotRevoked / MinOneMember local-visibility notes and the Manager table's
|
|
152
|
+
-- seal-propagation note; not solved here.
|
|
140
153
|
table CancelledInvite (
|
|
141
154
|
InviteKey text primary key,
|
|
142
155
|
constraint OnlyClosed check (
|
|
@@ -382,7 +395,7 @@ export const STRAND_SCHEMA = ` table Header (
|
|
|
382
395
|
-- NOTE: a per-transaction check against locally visible rows. Two partitioned nodes
|
|
383
396
|
-- — one promoting X, one removing X's Member and Manager rows — can each pass
|
|
384
397
|
-- locally and converge to a Manager row with no Member row. Same convergence class
|
|
385
|
-
-- as the MinOneMember /
|
|
398
|
+
-- as the MinOneMember / NotRevoked / seal-propagation notes; not solved here.
|
|
386
399
|
constraint MemberExists check on insert (
|
|
387
400
|
exists (select 1 from Member M where M.Key = new.MemberKey)
|
|
388
401
|
),
|
|
@@ -390,17 +403,21 @@ export const STRAND_SCHEMA = ` table Header (
|
|
|
390
403
|
-- (which proves only that old.MemberKey consented) double as a hand-off: the
|
|
391
404
|
-- former-manager branch would accept re-pointing the row at any new key.
|
|
392
405
|
constraint NoUpdate check on update (false),
|
|
393
|
-
-- A closed strand
|
|
394
|
-
--
|
|
395
|
-
--
|
|
396
|
-
--
|
|
397
|
-
--
|
|
398
|
-
--
|
|
399
|
-
--
|
|
400
|
-
-- the
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
406
|
+
-- A closed strand loses its LAST manager only by SEALING — a distinct,
|
|
407
|
+
-- self-signed act (the 'seal' branch of Authorized below), never an ordinary
|
|
408
|
+
-- resignation and never an admin removal. There is deliberately NO
|
|
409
|
+
-- min-one-manager floor: a strand with no managers is frozen, not bricked.
|
|
410
|
+
-- Every admission path (Invite, addMemberByManager, addManager) requires a
|
|
411
|
+
-- Manager row, so a sealed strand's membership can never grow again — and that
|
|
412
|
+
-- freeze is the privacy guarantee its remaining members are buying: nobody
|
|
413
|
+
-- holds the power to admit a party who would then read the strand's whole
|
|
414
|
+
-- history. Sealing is irreversible: the founding branch of Authorized closes
|
|
415
|
+
-- for good once any manager stamp has been retired into Revocation.
|
|
416
|
+
-- NOTE: a seal must PROPAGATE before it binds a given node — a node that has
|
|
417
|
+
-- not yet converged on the delete still shows the manager row, so the
|
|
418
|
+
-- ex-manager's OWN key could still admit there until the delete arrives. Only
|
|
419
|
+
-- that one key; no stranger gains anything. Same convergence class as the
|
|
420
|
+
-- MinOneMember / NotRevoked local-visibility notes; not solved here.
|
|
404
421
|
constraint Authorized check on insert, delete (
|
|
405
422
|
-- Every signed branch below is a domain/action-tagged digest bound to the
|
|
406
423
|
-- row's StampId — the same single-use idiom as Member.Authorized: an
|
|
@@ -415,23 +432,47 @@ export const STRAND_SCHEMA = ` table Header (
|
|
|
415
432
|
-- is belt-and-braces since MemberExists above enforces it on EVERY insert —
|
|
416
433
|
-- kept so this branch's own claim ("this manager IS that member") reads in
|
|
417
434
|
-- place rather than by cross-reference.
|
|
435
|
+
-- The Revocation gate closes this branch FOREVER once any manager seat has
|
|
436
|
+
-- been retired: founding happens exactly once per strand (Header, then
|
|
437
|
+
-- Member, then Manager, with no Revocation rows in existence), so on a
|
|
438
|
+
-- SEALED strand a lone surviving member cannot re-seat itself as a
|
|
439
|
+
-- generation-0 founder and start admitting. Accepted, deliberate: between
|
|
440
|
+
-- its own founding Member and Manager inserts a founder could, with its own
|
|
441
|
+
-- key, file a junk Revocation('Manager', ...) tombstone (RowIsGone permits
|
|
442
|
+
-- stamps naming no real row) and permanently block its own founding — pure
|
|
443
|
+
-- self-harm needing the founder's private key; no other party can reach
|
|
444
|
+
-- that window (there are no other committed members yet).
|
|
418
445
|
(old.MemberKey is null
|
|
419
446
|
and new.Generation = 0
|
|
420
447
|
and (select count(1) from Manager) <= 1
|
|
421
448
|
and (select count(1) from Member) <= 1
|
|
422
|
-
and exists (select 1 from Member M where M.Key = new.MemberKey)
|
|
449
|
+
and exists (select 1 from Member M where M.Key = new.MemberKey)
|
|
450
|
+
and not exists (select 1 from Revocation R where R.TableName = 'Manager'))
|
|
423
451
|
|
|
424
|
-
-- or
|
|
425
|
-
--
|
|
426
|
-
--
|
|
427
|
-
--
|
|
428
|
-
--
|
|
429
|
-
--
|
|
452
|
+
-- or removed by the departing manager ITSELF, over one of two
|
|
453
|
+
-- self-signed, action-tagged digests bound to its own row's stamp. The
|
|
454
|
+
-- POST-image manager count is what distinguishes the two: 'resign' is
|
|
455
|
+
-- an ordinary departure that leaves at least one manager standing,
|
|
456
|
+
-- while 'seal' is the delete that empties the table and permanently
|
|
457
|
+
-- freezes admission (see the table comment above). The tag is what
|
|
458
|
+
-- makes each a signed statement of THAT intent — a captured
|
|
459
|
+
-- resignation approval can never seal, nor a seal approval merely
|
|
460
|
+
-- resign — and the stamp is still what makes each single-use: once
|
|
461
|
+
-- this manager is re-promoted under a fresh stamp, neither captured
|
|
462
|
+
-- approval removes the new row. Both counts are subqueries, so the
|
|
463
|
+
-- whole check defers to commit and the counts are the post-image.
|
|
430
464
|
or (
|
|
431
465
|
old.MemberKey is not null
|
|
432
466
|
and old.MemberKey = context.ManagerKey
|
|
467
|
+
and (select count(1) from Manager) >= 1
|
|
433
468
|
and verify(digest('Strand.Manager', 'resign', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')
|
|
434
469
|
)
|
|
470
|
+
or (
|
|
471
|
+
old.MemberKey is not null
|
|
472
|
+
and old.MemberKey = context.ManagerKey
|
|
473
|
+
and (select count(1) from Manager) = 0
|
|
474
|
+
and verify(digest('Strand.Manager', 'seal', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')
|
|
475
|
+
)
|
|
435
476
|
|
|
436
477
|
-- or a promotion signed by an EARLIER-generation manager, over the
|
|
437
478
|
-- add-tagged digest binding (MemberKey, Generation, StampId) — so a
|
|
@@ -483,7 +524,7 @@ export const STRAND_SCHEMA = ` table Header (
|
|
|
483
524
|
-- NOTE: the guarded tables' NotRevoked CHECK runs against locally visible rows, so
|
|
484
525
|
-- a node that has not yet converged on a Revocation row can still accept a replayed
|
|
485
526
|
-- add and the resurrected row coexists with the tombstone after merge — the same
|
|
486
|
-
-- convergence class as the MinOneMember /
|
|
527
|
+
-- convergence class as the MinOneMember / Manager seal-propagation notes.
|
|
487
528
|
-- NOTE: this table only grows — one row per membership deletion, plus any junk any
|
|
488
529
|
-- member chooses to file, and Immutable means rows never go away. Fine at strand
|
|
489
530
|
-- scale; if membership churn ever gets large it needs pruning bounded by something
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"strand-schema.js","sourceRoot":"","sources":["../src/strand-schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG
|
|
1
|
+
{"version":3,"file":"strand-schema.js","sourceRoot":"","sources":["../src/strand-schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgiB5B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serfab/quereus-plugin-sereus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Quereus plugin that connects to a Sereus strand for SQL access",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -67,13 +67,13 @@
|
|
|
67
67
|
"@libp2p/circuit-relay-v2": "^4.1.3",
|
|
68
68
|
"@libp2p/interface": "^3.1.0",
|
|
69
69
|
"@libp2p/websockets": "^10.1.3",
|
|
70
|
-
"@optimystic/db-core": "^0.
|
|
71
|
-
"@optimystic/db-p2p": "^0.
|
|
72
|
-
"@optimystic/db-p2p-storage-fs": "^0.
|
|
73
|
-
"@optimystic/db-p2p-storage-web": "^0.
|
|
74
|
-
"@optimystic/quereus-plugin-crypto": "^0.
|
|
75
|
-
"@optimystic/quereus-plugin-optimystic": "^0.
|
|
76
|
-
"@quereus/quereus": "^4.
|
|
70
|
+
"@optimystic/db-core": "^0.27.0",
|
|
71
|
+
"@optimystic/db-p2p": "^0.27.0",
|
|
72
|
+
"@optimystic/db-p2p-storage-fs": "^0.27.0",
|
|
73
|
+
"@optimystic/db-p2p-storage-web": "^0.27.0",
|
|
74
|
+
"@optimystic/quereus-plugin-crypto": "^0.27.0",
|
|
75
|
+
"@optimystic/quereus-plugin-optimystic": "^0.27.0",
|
|
76
|
+
"@quereus/quereus": "^4.18.0",
|
|
77
77
|
"debug": "^4.4.3"
|
|
78
78
|
},
|
|
79
79
|
"quereus": {
|
package/src/cached-storage.ts
CHANGED
|
@@ -31,6 +31,19 @@
|
|
|
31
31
|
* `MemoryRawStorage` is returned unwrapped on upstream's own guidance: the backend
|
|
32
32
|
* is already in-memory, so the cache adds bookkeeping with nothing to save.
|
|
33
33
|
*
|
|
34
|
+
* **{@link wrapStorageWithCache} and {@link disposeStorageCache} are a matched pair
|
|
35
|
+
* with a holder count between them.** Every wrap that hands back a cache this module
|
|
36
|
+
* created takes one claim on it; every dispose of that cache releases one. The cache
|
|
37
|
+
* is emptied, unregistered from the shared pool, and forgotten only when the LAST
|
|
38
|
+
* claim goes. That is what makes overlapping scopes safe: `CadreNodeConfig.storage.provider`
|
|
39
|
+
* may be a single `IRawStorage` shared by the control database and every workspace,
|
|
40
|
+
* and without a count the first scope to stop would retire the cache the others are
|
|
41
|
+
* still reading through — then the next scope to start, finding the memo empty, would
|
|
42
|
+
* build a SECOND cache over the same backend, and each would serve its own stale view
|
|
43
|
+
* (including remembered "this block does not exist" answers). It is also what lets
|
|
44
|
+
* `composeStrand` release its own claim unconditionally on `shutdown()` without having
|
|
45
|
+
* to guess whether it or cadre-core created the wrapper.
|
|
46
|
+
*
|
|
34
47
|
* The memo dedupes *concurrent* seams; it is not what keeps a cache alive across a
|
|
35
48
|
* runtime rebuild. That is cadre-core's job, and it does it by OWNERSHIP: one
|
|
36
49
|
* resolved store per scope per runtime lifetime — a strand's store for the life of
|
|
@@ -39,10 +52,21 @@
|
|
|
39
52
|
* `CadreNode.start()`. Because the store outlives the rebuild, so does its wrap,
|
|
40
53
|
* whether or not the embedder's provider returns a stable instance.
|
|
41
54
|
*
|
|
42
|
-
* When a scope's runtime stops for good,
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
55
|
+
* When a scope's runtime stops for good, it calls {@link disposeStorageCache} — the
|
|
56
|
+
* release half of that ownership. Without it the retired wrapper's `CacheStoreHandle`
|
|
57
|
+
* would stay in the shared pool's registry for the process lifetime (`unregisterStore`
|
|
58
|
+
* is the only removal), one orphan per stop.
|
|
59
|
+
*
|
|
60
|
+
* NOTE: upstream ships an equivalent counted wrap of its own — `withReadCache`, which
|
|
61
|
+
* hands back a `ReadCacheLease` per caller and additionally dedupes by
|
|
62
|
+
* `getStoreIdentity()`, so two DISTINCT instances over one directory share one cache.
|
|
63
|
+
* This module keeps its own count instead, because its public surface releases by
|
|
64
|
+
* WRAPPER (`disposeStorageCache(storage)`) rather than by lease, and `withReadCache`
|
|
65
|
+
* returns no lease on its already-cached pass-through — which would leave
|
|
66
|
+
* `composeStrand`'s unconditional release unable to tell its own claim from cadre-core's.
|
|
67
|
+
* Adopting the lease API would mean changing that surface at all three call sites; worth
|
|
68
|
+
* doing if Sereus ever needs the identity-keyed dedupe (two `FileRawStorage` over one
|
|
69
|
+
* path), and not before.
|
|
46
70
|
*/
|
|
47
71
|
|
|
48
72
|
// Imported from the `/rn` entry — the platform-neutral surface (no Node-only
|
|
@@ -54,54 +78,101 @@ import { CachedRawStorage, MemoryRawStorage, type IRawStorage } from '@optimysti
|
|
|
54
78
|
const wraps = new WeakMap<IRawStorage, CachedRawStorage>();
|
|
55
79
|
|
|
56
80
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
81
|
+
* One live cache this module created, and the number of holders claiming it.
|
|
82
|
+
*
|
|
83
|
+
* `inner` is the reverse of `wraps`, so {@link disposeStorageCache} can evict the memo
|
|
84
|
+
* entry it cannot otherwise find — `wraps` is keyed by the inner instance, and dispose
|
|
85
|
+
* is handed the wrapper.
|
|
86
|
+
*
|
|
87
|
+
* Presence in this map is also how the module answers "is this cache mine to retire?":
|
|
88
|
+
* an entry exists from construction until the last holder releases, so a cache the
|
|
89
|
+
* embedder built itself — and one whose last holder already released it — has none, and
|
|
90
|
+
* both take no claim and are never disposed here.
|
|
91
|
+
*/
|
|
92
|
+
type CacheClaim = {
|
|
93
|
+
readonly inner: IRawStorage;
|
|
94
|
+
holders: number;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const claims = new WeakMap<CachedRawStorage, CacheClaim>();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Count one more holder of `wrapped`, if this module created it. A cache built outside
|
|
101
|
+
* this module stays its creator's to release, so it is left uncounted and
|
|
102
|
+
* {@link disposeStorageCache} no-ops on it.
|
|
60
103
|
*/
|
|
61
|
-
|
|
104
|
+
function addHolder(wrapped: CachedRawStorage): void {
|
|
105
|
+
const claim = claims.get(wrapped);
|
|
106
|
+
if (claim) {
|
|
107
|
+
claim.holders += 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
62
110
|
|
|
63
111
|
/**
|
|
64
|
-
* The cached view of `storage`, memoized per instance
|
|
65
|
-
*
|
|
112
|
+
* The cached view of `storage`, memoized per instance, with one holder claim taken for
|
|
113
|
+
* this caller — release it with {@link disposeStorageCache} when this caller's scope
|
|
114
|
+
* ends. `label` names the store in the shared pool's `stats()` (e.g. `control`, or the
|
|
115
|
+
* strand id); on a memo hit the first caller's label sticks, since a second cache over
|
|
116
|
+
* one backend is the failure that matters and a stale pool label is not.
|
|
66
117
|
*/
|
|
67
118
|
export function wrapStorageWithCache(storage: IRawStorage, label: string): IRawStorage {
|
|
68
|
-
if (storage instanceof MemoryRawStorage
|
|
119
|
+
if (storage instanceof MemoryRawStorage) {
|
|
120
|
+
return storage;
|
|
121
|
+
}
|
|
122
|
+
if (storage instanceof CachedRawStorage) {
|
|
123
|
+
// Already the wrapper this module handed out: cadre-core wraps at its own seams
|
|
124
|
+
// and passes the wrapper down to `composeStrand`, which wraps again. Count the
|
|
125
|
+
// new holder rather than stacking a second cache over one backend.
|
|
126
|
+
// NOTE: a RETIRED wrapper (last holder already released) is also returned as-is,
|
|
127
|
+
// uncounted — the caller gets a dead cache with no error. No call site can reach
|
|
128
|
+
// that today: both retire sites drop their reference in the same block that
|
|
129
|
+
// releases. If a caller ever holds a wrapper across its own release, make this
|
|
130
|
+
// branch throw on an absent claim rather than hand back the corpse.
|
|
131
|
+
addHolder(storage);
|
|
69
132
|
return storage;
|
|
70
133
|
}
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
inners.set(wrapped, storage);
|
|
134
|
+
const memoized = wraps.get(storage);
|
|
135
|
+
if (memoized) {
|
|
136
|
+
addHolder(memoized);
|
|
137
|
+
return memoized;
|
|
76
138
|
}
|
|
139
|
+
const wrapped = new CachedRawStorage(storage, undefined, label);
|
|
140
|
+
wraps.set(storage, wrapped);
|
|
141
|
+
claims.set(wrapped, { inner: storage, holders: 1 });
|
|
77
142
|
return wrapped;
|
|
78
143
|
}
|
|
79
144
|
|
|
80
145
|
/**
|
|
81
|
-
* Release the cache wrapper {@link wrapStorageWithCache} returned
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* the integration harness's `captureRawStorage`) would get exactly that back on a
|
|
87
|
-
* relaunch if the memo were left in place.
|
|
146
|
+
* Release this caller's claim on the cache wrapper {@link wrapStorageWithCache} returned.
|
|
147
|
+
* The wrapper is emptied, unregistered from the shared pool, and dropped from the memo
|
|
148
|
+
* only when the LAST holder releases — so a scope stopping never retires a cache another
|
|
149
|
+
* live scope is still reading through, and a still-held wrapper is never replaced by a
|
|
150
|
+
* competing second cache over the same backend.
|
|
88
151
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
152
|
+
* A wrapper with no live claim is left alone: that covers both a cache the embedder built
|
|
153
|
+
* itself (theirs to dispose) and a late second dispose of an already-retired wrapper,
|
|
154
|
+
* whose successor in the memo must not be dropped.
|
|
155
|
+
*
|
|
156
|
+
* No-op for anything this module returned unwrapped (e.g. `MemoryRawStorage`), so callers
|
|
157
|
+
* need no `instanceof` test. Only the wrapper is released: the inner store the embedder
|
|
158
|
+
* returned is never closed — that handle stays the embedder's to manage.
|
|
92
159
|
*/
|
|
93
160
|
export async function disposeStorageCache(storage: IRawStorage): Promise<void> {
|
|
94
161
|
if (!(storage instanceof CachedRawStorage)) {
|
|
95
162
|
return;
|
|
96
163
|
}
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
164
|
+
const claim = claims.get(storage);
|
|
165
|
+
if (!claim) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Decrement, and on zero retire the bookkeeping, all in this SYNCHRONOUS prefix —
|
|
169
|
+
// before the await below. A wrap of the same inner instance interleaved between the
|
|
170
|
+
// two would otherwise find a memo entry pointing at a dying cache.
|
|
171
|
+
claim.holders -= 1;
|
|
172
|
+
if (claim.holders > 0) {
|
|
173
|
+
return;
|
|
105
174
|
}
|
|
175
|
+
claims.delete(storage);
|
|
176
|
+
wraps.delete(claim.inner);
|
|
106
177
|
await storage.dispose();
|
|
107
178
|
}
|