@kubun/engine 0.10.2 → 0.11.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/lib/engine.js CHANGED
@@ -1 +1,1511 @@
1
- import{createRevocationChecker as e}from"@enkaku/capability";import{createRuntime as t}from"@enkaku/runtime";import{asType as i,createValidator as s}from"@enkaku/schema";import{isSigningIdentity as r,stringifyToken as a,verifyToken as n}from"@enkaku/token";import{KubunDB as o}from"@kubun/db";import{createReadContext as l,createSchema as u}from"@kubun/graphql";import{HLC as c}from"@kubun/hlc";import{DocumentID as d}from"@kubun/id";import{getKubunLogger as h}from"@kubun/logger";import{applyMutation as p,convertPatchInput as m,createMutationOperations as g,WriteAccessDeniedError as f}from"@kubun/mutation";import{clusterToRecord as v,documentMutation as w,GraphModel as y}from"@kubun/protocol";import{GRAPH_STORE as D,getGraphStore as b,graphStoreDefinition as x}from"@kubun/store-graph";import{createP2PRevocationBackend as I,getP2PStore as M}from"@kubun/store-p2p";import{execute as A,GraphQLError as C,Kind as G,parse as k,subscribe as E,validate as R}from"graphql";import{createAccessChecker as O}from"./access-control.js";import{EngineEventBus as S}from"./events.js";import{checkMembership as L}from"./membership-check.js";import{computeMutationHash as F}from"./mutation-hash.js";import{runPolicies as T}from"./policies.js";import{createRegistry as j}from"./registry.js";let U=s(w);function q(e){if(null==e)return;let t=c.parse(e).wallTime;return Number.isFinite(t)?Math.floor(t/1e3):void 0}export function buildWriteAccessChecker(e){let{store:t,defaultAccessLevel:i,extraTokensByIssuer:s,revocationChecker:r}=e,a=new Map,n={getUserModelAccessDefault:(e,i,s)=>t.getUserModelAccessDefault(e,i,s),isMemberOfAnyGroup:async()=>!1,isMemberOfAnyCircle:async()=>!1,getModelInterfaces:async e=>{let i=a.get(e);if(null!=i)return i;let s=await t.getModelInterfaces(e);return a.set(e,s),s},revocationChecker:r};return(e,t)=>{var r;let a=(null==(r=t.cap)?void 0:Array.isArray(r)?r:[r])??[],o=s?.get(t.iss)??[],l=0===a.length&&0===o.length?void 0:[...a,...o];return O({viewerDID:t.iss,delegationTokens:l,db:n,defaultAccessLevel:i,atTime:q(t.hlc)})(e,"write")}}function P(){throw Error("Graph mutations require mutateGraph() — the core context does not sign or log writes. Use KubunEngine.mutateGraph() instead.")}function B(){throw Error("Access control mutations are not supported by the engine core — plugin override required.")}function _(){throw Error("Transaction mutations are not supported by the engine core — plugin override required (see plugin-rpc).")}let V={executeCreateMutation:P,executeSetMutation:P,executeUpdateMutation:P,executeRemoveMutation:P,executeSetModelAccessDefaults:B,executeRemoveModelAccessDefaults:B,executeSetDocumentAccessOverride:B,executeRemoveDocumentAccessOverride:B,beginTransaction:_,commitTransaction:_,rollbackTransaction:_};export class KubunEngine{#e=[];#t=[];#i;#s;#r;#a={};#n;#o;#l;#u={};#c=new Map;#d=[];#h;#p;#m={};#g={};constructor(e){this.#i=e.db instanceof o?e.db:new o({adapter:e.db}),this.#i.register(x),this.#o=e.identity,this.#s=e.defaultAccessLevel??{read:"anyone",write:"only_owner"},this.#n=new c({nodeID:e.identity.id}),this.#l=e.logger??h("engine"),this.#r=e.eventBus??new S({logger:this.#l.getChild("events")}),this.#p=t(e.runtime),this.#h=j();let i=e.plugins??[],s={engine:this,graph:{execute:e=>this.#f(e),subscribe:e=>this.#v(e),applyVerifiedMutation:e=>this.#w(e),applyVerifiedMutations:e=>this.#y(e)},db:this.#i,runtime:this.#p,identity:this.#o,eventBus:this.#r,hlc:this.#n,getLogger:e=>this.#l.getChild(e)};for(let e of i){let t=e(s);this.#d.push(t),null!=t.api&&this.#h.registerPlugin(t.name,t.api)}this.#h.closeGate();let r=new Set;for(let e of this.#d){if(null!=e.schemaExtension&&this.#c.set(e.name,e.schemaExtension),null!=e.createContextFactory){if(r.has(e.name))throw Error(`Duplicate plugin namespace: ${e.name}`);r.add(e.name),this.#e.push({name:e.name,factory:e.createContextFactory()})}if(null!=e.policies)for(let[t,i]of Object.entries(e.policies)){null==this.#u[t]&&(this.#u[t]={sync:[],async:[]});let e=this.#u[t];null!=i.sync&&(e.sync=[...e.sync??[],...i.sync]),null!=i.async&&(e.async=[...e.async??[],...i.async])}}this.#l.info("engine initialized with {count} plugin(s)",{count:this.#d.length})}get did(){return this.#o.id}get identity(){return this.#o}get eventBus(){return this.#r}async dispose(){await Promise.all(this.#d.filter(e=>null!=e.dispose).map(e=>e.dispose?.()))}getGraphQLSource(e){let{graphID:t}=e,i=e.getViewerDID??(()=>this.#o.id);return{query:async e=>await this.queryGraph({id:t,text:e.text,variables:e.variables,viewerDID:i()??void 0}),mutate:async e=>await this.mutateGraph({id:t,text:e.text,variables:e.variables,viewerDID:i()??void 0}),subscribe:e=>this.#v({graphID:t,text:e.text,variables:e.variables,viewerDID:i()??void 0})}}registerContextFactory(e){this.#t.push(e)}async #D(){return await b(this.#i)}async #b(t){var i;let s,r,a,n,o=t.viewerDID??this.#o.id,u=this.#x(o),c=t.stores??this.#i,d={};for(let{name:e,factory:t}of this.#e)d[e]=t(u,c);for(let e of this.#t)d={...d,...e(u,c)};null!=t.contextExtensions&&(d={...d,...t.contextExtensions});let h=await b(c);try{s=await M(c)}catch{s=void 0}if(null!=s){let t=s;r={getUserModelAccessDefault:(e,t,i)=>h.getUserModelAccessDefault(e,t,i),isMemberOfAnyGroup:(e,i)=>t.isMemberOfAnyGroup(e,i),isMemberOfAnyCircle:(e,i)=>t.isMemberOfAnyCircle(e,i),getModelInterfaces:e=>h.getModelInterfaces(e),revocationChecker:e(I(t))};let[i,n]=await Promise.all([t.getGroupsForMember(o),t.getCirclesForMember(o)]);a={viewerDID:o,groupIDs:i.map(e=>e.id),circleIDs:n.map(e=>e.id),serverDefault:this.#s.read}}else r={getUserModelAccessDefault:(e,t,i)=>h.getUserModelAccessDefault(e,t,i),isMemberOfAnyGroup:async()=>!1,isMemberOfAnyCircle:async()=>!1,getModelInterfaces:e=>h.getModelInterfaces(e)},a={viewerDID:o,groupIDs:[],circleIDs:[],serverDefault:this.#s.read};let p=O({viewerDID:o,db:r,defaultAccessLevel:this.#s});return n={...l({store:(i={store:h,viewerDID:o,eventBus:this.#r,accessChecker:p,viewerReadAccess:a,contextExtensions:Object.keys(d).length>0?d:void 0}).store,viewerDID:i.viewerDID,events:i.eventBus,accessChecker:i.accessChecker,viewerReadAccess:i.viewerReadAccess}),...V},null!=i.contextExtensions?{...n,...i.contextExtensions}:n}#x(e){return{viewerDID:e}}async #I(e){return null==this.#a[e]&&(this.#a[e]=this.#D().then(async t=>{let i=await t.getGraph(e);if(null==i)throw this.#l.warn("graph {id} not found",{id:e}),delete this.#a[e],Error(`Graph not found: ${e}`);return this.#l.debug("cached model for graph {id}",{id:e}),{aliases:i.aliases,record:i.record,extensionSDL:i.extension_sdl??void 0,pluginConfig:i.plugin_config??void 0}})),await this.#a[e]}async getGraphQLSchema(e){return null==this.#m[e]&&(this.#m[e]=this.#I(e).then(t=>{let i;if(null!=t.pluginConfig){let e={},s={},r={},a={},n={};for(let[i,o]of Object.entries(t.pluginConfig)){let t=this.#c.get(i);if(null!=t){let i=t(o);if(Object.assign(e,i.resolvers.queryFields??{}),Object.assign(s,i.resolvers.mutationFields??{}),Object.assign(r,i.resolvers.subscriptionFields??{}),Object.assign(n,i.resolvers.nodeResolvers??{}),null!=i.resolvers.typeFields)for(let[e,t]of Object.entries(i.resolvers.typeFields))a[e]={...a[e],...t}}}i={queryFields:e,mutationFields:s,subscriptionFields:r,typeFields:a,nodeResolvers:n}}let s=u({...t,extensionResolvers:i});return this.#l.debug("cached schema for graph {id}",{id:e}),s}).catch(t=>{throw delete this.#m[e],t})),await this.#m[e]}async #M(e){let t=k(e.text),i=this.#A(t),s=await this.#C(i,e.viewerDID??this.#o.id);if(null!=s)return{result:s};let r=await this.getGraphQLSchema(e.graphID),a=R(r,t);return a.length>0?{result:{errors:a}}:{document:t,schema:r}}async #G(e,t,i){if(null==t.definitions[0])throw Error("Missing GraphQL document definition");return{schema:i,document:t,contextValue:await this.#b(e),variableValues:e.variables}}#A(e){let t=e.definitions[0];return null!=t&&t.kind===G.OPERATION_DEFINITION?t.operation:"query"}async #C(e,t){let i=await T(this.#u,e,{viewerDID:t});return"issues"in i&&null!=i.issues&&i.issues.length>0?{errors:i.issues.map(e=>new C(e.message,{extensions:null!=e.path?{path:e.path}:void 0}))}:null}async listGraphs(){let e=await this.#D();return{graphs:(await e.listGraphs()).map(e=>({id:e.id,name:e.name}))}}async loadGraph(e){let t=await this.#I(e.id);return{aliases:t.aliases,record:t.record,extensionSDL:t.extensionSDL}}async deployGraph(e){let t,i,s={};for(let t of e.clusters)Object.assign(s,v(t));let r=y.fromClusters({clusters:s});if(null!=e.plugins){i=e.plugins;let s=[];for(let[e,t]of Object.entries(i)){let i=this.#c.get(e);if(null==i)throw Error(`Plugin "${e}" not found or does not provide schema extensions`);let r=i(t);s.push(r.sdl)}s.length>0&&(t=s.join("\n"))}let a=await this.#D(),n=await a.createGraph({id:e.id??this.#p.getRandomID(),name:e.name,record:r.record,extensionSDL:t,pluginConfig:i}),o=r.toJSON();return this.#a[n]=Promise.resolve({...o,extensionSDL:t,pluginConfig:i}),delete this.#m[n],this.#l.info("deployed graph {id}",{id:n}),{id:n,...o}}async #k(e,t){let i="local"===t?"engine:mutation:authored":"engine:mutation:received",s=e.filter(e=>!e.dropped),r=new Map,a=[];for(let e of s){let t=function(e){if(null==e.document)throw Error("Cannot classify a dropped mutation result");return null==e.previousDoc?"create":null===e.document.data?"remove":"update"}(e);a.push(t);let i=e.document.id,s=r.get(i);null==s&&(s={docID:i,hasCreate:!1,hasUpdate:!1,hasRemove:!1,lastUpdateResult:null,lastRemoveResult:null,lastResult:e},r.set(i,s)),s.lastResult=e,"create"===t?s.hasCreate=!0:"update"===t?(s.hasUpdate=!0,s.lastUpdateResult=e):(s.hasRemove=!0,s.lastRemoveResult=e)}for(let e of r.values()){if(e.hasCreate&&e.hasRemove)continue;let t=e.lastResult.document;if(e.hasRemove){let t=e.lastRemoveResult??e.lastResult,i=t.document;await this.#r.emit("engine:document:removed",{documentID:i.id,viewerDID:t.authorDID});continue}if(e.hasCreate&&await this.#r.emit("engine:document:created",{documentID:t.id,modelID:t.model,viewerDID:e.lastResult.authorDID}),e.hasUpdate){let t=e.lastUpdateResult??e.lastResult,i=t.document;await this.#r.emit("engine:document:updated",{documentID:i.id,modelID:i.model,viewerDID:t.authorDID})}let i={type:e.hasCreate&&!e.hasUpdate?"create":"update",document:t,previous:{data:e.lastResult.previousDoc?.data??null},getCursor:()=>t.id};await this.#r.emit("document:saved",i)}for(let e=0;e<s.length;e++){let t=s[e],r=t.document;await this.#r.emit(i,{type:a[e],documentID:r.id,viewerDID:t.authorDID,mutationJWT:t.token,version:t.mutation.hlc,modelID:r.model})}}#E(e,t,i){return buildWriteAccessChecker({store:e,defaultAccessLevel:this.#s,extraTokensByIssuer:t,revocationChecker:i})}async #w(t,s){let r,a,{token:o,accessGate:l}=t,u=t.origin??"local",c=i(U,(await n(o)).payload),h=null!=s?await b(s):await this.#D(),m=s??this.#i,g=q(c.hlc)??Math.floor(Date.now()/1e3),v=new Map;try{a=await M(m);let t=await a.getHeldTokens({audience:c.iss,atTime:g});t.length>0&&v.set(c.iss,t.map(e=>e.token)),r=e(I(a))}catch{a=void 0}let w={store:h,validators:this.#g,hlc:this.#n,checkWriteAccess:this.#E(h,v,r),postStateGate:l},y=await h.getDocument(d.fromString(c.sub));if(null!=a&&null!=y&&y.owner!==c.iss){let e=await L({p2pStore:a,graphStore:h,selfDID:this.did,iss:c.iss,mutationHLC:c.hlc,doc:y,arrivalGroupID:t.arrivalGroupID});if(!e.ok){if(this.#l.warn("mutation denied: issuer removed from group",{iss:c.iss,docID:y.id,mutationHLC:c.hlc,removedGroupID:e.removedGroupID,removedAtHLC:e.removedAtHLC,origin:u}),"peer"===u)return{document:null,previousDoc:y,mutation:c,authorDID:c.iss,hash:F(o),token:o,dropped:!0};throw new f(y.id)}}let D=await p(w,c),x=F(o),A=c.iss;if(null==D)return{document:null,previousDoc:y,mutation:c,authorDID:A,hash:x,token:o,dropped:!0};await h.insertMutationLogEntry({mutation_hash:x,model_id:D.model,document_id:D.id,author_did:A,hlc:c.hlc,mutation_jwt:o,status:"applied"});let C={document:D,previousDoc:y,mutation:c,authorDID:A,hash:x,token:o,dropped:!1};return null==s&&await this.#k([C],u),C}async #y(t){let{tokens:s,accessGate:r}=t,a=t.origin??"local",o=await Promise.all(s.map(async e=>{let t=i(U,(await n(e)).payload);return{token:e,mutation:t}})),l=[];return await this.#i.withTransaction(async t=>{let i,s,n=await t.getStore(D),u=new Map,c=new Map,h=new Set,m=Math.floor(Date.now()/1e3);for(let{mutation:e}of o){let t=q(e.hlc);if(null==t){h.add(e.iss);continue}let i=c.get(e.iss);(null==i||t<i)&&c.set(e.iss,t)}for(let r of new Set(o.map(e=>e.mutation.iss))){let a=c.get(r),n=h.has(r)||null==a?Math.min(a??m,m):a;try{let a=await M(t);s=a;let o=await a.getHeldTokens({audience:r,atTime:n});o.length>0&&u.set(r,o.map(e=>e.token)),i??=e(I(a))}catch{}}let g=this.#E(n,u,i);for(let{token:e,mutation:t}of o){let i={store:n,validators:this.#g,hlc:this.#n,checkWriteAccess:g,postStateGate:r},o=await n.getDocument(d.fromString(t.sub));if(null!=s&&null!=o&&o.owner!==t.iss){let e=await L({p2pStore:s,graphStore:n,selfDID:this.did,iss:t.iss,mutationHLC:t.hlc,doc:o,arrivalGroupID:void 0});if(!e.ok)throw this.#l.warn("mutation denied: issuer removed from group",{iss:t.iss,docID:o.id,mutationHLC:t.hlc,removedGroupID:e.removedGroupID,removedAtHLC:e.removedAtHLC,origin:a}),new f(o.id)}let u=await p(i,t),c=F(e);if(null==u){l.push({document:null,previousDoc:o,mutation:t,authorDID:t.iss,hash:c,token:e,dropped:!0});continue}await n.insertMutationLogEntry({mutation_hash:c,model_id:u.model,document_id:u.id,author_did:t.iss,hlc:t.hlc,mutation_jwt:e,status:"applied"}),l.push({document:u,previousDoc:o,mutation:t,authorDID:t.iss,hash:c,token:e,dropped:!1})}}),await this.#k(l,a),{results:l,dropped:l.filter(e=>e.dropped).length}}async #f(e){let t=await this.#M(e);if("result"in t)return t.result;let i=await this.#G(e,t.document,t.schema);return await A(i)}async #v(e){let t=await this.#M(e);if("result"in t)return t.result;let i=await this.#G(e,t.document,t.schema);return await E(i)}async queryGraph(e){let t=k(e.text);if("mutation"===this.#A(t))throw Error("queryGraph() does not accept mutation operations. Use mutateGraph() instead.");return await this.#f({graphID:e.id,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID})}async mutateGraph(e){if(!r(this.#o))throw Error("mutateGraph requires a SigningIdentity");let t=this.#o,i=e.id;await this.getGraphQLSchema(i);let s=[],n=await this.#i.withTransaction(async r=>{let n=async e=>{let i=a(await t.signToken(e)),n=await this.#w({token:i},r);if(s.push(n),null==n.document)throw Error("Unexpected dropped mutation in mutateGraph (no gate configured)");return n.document},o=Math.floor(Date.now()/1e3),l=new Set(e.delegationTokens??[]);try{let e=await M(r);for(let i of(await e.getHeldTokens({audience:t.id,atTime:o})))l.add(i.token)}catch{}let u=l.size>0?Array.from(l):void 0,c=g({issuer:t.id,hlc:this.#n,getRandomValues:this.#p.getRandomValues,cap:u,processSetMutation:e=>n(e),processChangeMutation:e=>n(e)});return await this.#f({graphID:i,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID??t.id,stores:r,contextExtensions:{executeCreateMutation:async e=>await c.createDocument({modelID:e.modelID,data:e.data}),executeSetMutation:async e=>await c.setDocument({modelID:e.modelID,unique:e.unique,data:e.data}),executeUpdateMutation:async e=>await c.updateDocument({docID:e.input.id,patch:m(e.input.patch)}),executeRemoveMutation:async e=>{await c.removeDocument({docID:e.id})}}})});return await this.#k(s,"local"),n}async subscribeToGraph(e){return await this.#v({graphID:e.id,text:e.text,variables:e.variables??{},viewerDID:e.viewerDID})}async getAPI(e){return await this.#h.getAPI(e)}}
1
+ import { createRevocationChecker } from '@kokuin/capability';
2
+ import { isOwnIdentity, isSigningIdentity, stringifyToken, verifyToken } from '@kokuin/token';
3
+ import { KubunDB } from '@kubun/db';
4
+ import { createReadContext, createSchema } from '@kubun/graphql';
5
+ import { HLC } from '@kubun/hlc';
6
+ import { DocumentID } from '@kubun/id';
7
+ import { getKubunLogger } from '@kubun/logger';
8
+ import { applyMutation, convertPatchInput, createMutationOperations, WriteAccessDeniedError } from '@kubun/mutation';
9
+ import { clusterToRecord, documentMutation, GraphModel } from '@kubun/protocol';
10
+ import { createRevocationBackend, delegationStoreDefinition, getDelegationStore } from '@kubun/store-delegation';
11
+ import { GRAPH_STORE, getGraphStore, graphStoreDefinition } from '@kubun/store-graph';
12
+ import { getP2PStore, P2P_STORE } from '@kubun/store-p2p';
13
+ import { createRuntime } from '@sozai/runtime';
14
+ import { asType, createValidator } from '@sozai/schema';
15
+ import { execute, GraphQLError, Kind, parse, subscribe, validate } from 'graphql';
16
+ import { createAccessChecker } from './access-control.js';
17
+ import { createDefaultCipher, deriveAtRestKey } from './cipher.js';
18
+ import { isTransactionFatal, MutateGraphWriteRollback } from './errors.js';
19
+ import { EngineEventBus } from './events.js';
20
+ import { checkMembership } from './membership-check.js';
21
+ import { computeMutationHash } from './mutation-hash.js';
22
+ import { runPolicies } from './policies.js';
23
+ import { createRegistry } from './registry.js';
24
+ const validateMutation = createValidator(documentMutation);
25
+ /**
26
+ * Normalize a mutation's `cap` field (a single token or an array) into the
27
+ * array shape the access checker expects, preserving absence as `undefined`.
28
+ */ function normalizeCap(cap) {
29
+ return cap == null ? undefined : Array.isArray(cap) ? cap : [
30
+ cap
31
+ ];
32
+ }
33
+ /**
34
+ * Derive a mutation's signing time as epoch seconds from its HLC string.
35
+ * Returns `undefined` when the HLC is missing or un-parseable so callers fall
36
+ * back to now() rather than failing the access check.
37
+ */ function mutationSigningTimeSeconds(hlc) {
38
+ if (hlc == null) return undefined;
39
+ const wallTimeMS = HLC.parse(hlc).wallTime;
40
+ return Number.isFinite(wallTimeMS) ? Math.floor(wallTimeMS / 1000) : undefined;
41
+ }
42
+ /**
43
+ * Build a write-access checker closure bound to a graph store. When provided,
44
+ * `extraTokensByIssuer` supplies additional delegation tokens held locally for
45
+ * a given issuer DID; they are unioned with the mutation's inline `cap` so
46
+ * server-side application can authorize a write whose held credentials live in
47
+ * the receiving peer's p2p store rather than on the wire.
48
+ *
49
+ * Exported (without `#`) so unit tests can construct the checker with a
50
+ * hand-built map and exercise the union math directly. The Engine method
51
+ * `#buildWriteAccessChecker` delegates to this helper.
52
+ */ export function buildWriteAccessChecker(params) {
53
+ const { store, defaultAccessLevel, extraTokensByIssuer, revocationChecker } = params;
54
+ // Per-closure cache: a single write-apply transaction enters this helper
55
+ // once and may resolve the same model's interface list multiple times
56
+ // (e.g. during delegation resource enumeration). Memoizing per call keeps
57
+ // the cost at one underlying store lookup per modelID. The cache is scoped
58
+ // to this closure so it never leaks across transactions.
59
+ const interfaceCache = new Map();
60
+ const writeAccessDB = {
61
+ getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>store.getUserModelAccessDefault(ownerDID, modelID, permissionType),
62
+ isMemberOfAnyGroup: async ()=>false,
63
+ isMemberOfAnyCircle: async ()=>false,
64
+ getModelInterfaces: async (modelID)=>{
65
+ const cached = interfaceCache.get(modelID);
66
+ if (cached != null) return cached;
67
+ const fetched = await store.getModelInterfaces(modelID);
68
+ interfaceCache.set(modelID, fetched);
69
+ return fetched;
70
+ },
71
+ revocationChecker
72
+ };
73
+ return (doc, mutation)=>{
74
+ const inline = normalizeCap(mutation.cap) ?? [];
75
+ const extra = extraTokensByIssuer?.get(mutation.iss) ?? [];
76
+ const delegationTokens = inline.length === 0 && extra.length === 0 ? undefined : [
77
+ ...inline,
78
+ ...extra
79
+ ];
80
+ return createAccessChecker({
81
+ viewerDID: mutation.iss,
82
+ delegationTokens,
83
+ db: writeAccessDB,
84
+ defaultAccessLevel,
85
+ // Honor a capability that was valid when the mutation was signed, even
86
+ // if it has since expired during an offline sync delay. Falls back to
87
+ // now() when the HLC is missing or un-parseable.
88
+ atTime: mutationSigningTimeSeconds(mutation.hlc)
89
+ })(doc, 'write');
90
+ };
91
+ }
92
+ /**
93
+ * Find the operation definition in a parsed GraphQL document. A document may
94
+ * lead with fragment definitions (e.g. `fragment ... query ...`), so the
95
+ * operation is not necessarily `definitions[0]`. Returns `undefined` when the
96
+ * document contains no operation definition at all.
97
+ */ function getOperationDefinition(document) {
98
+ return document.definitions.find((definition)=>definition.kind === Kind.OPERATION_DEFINITION);
99
+ }
100
+ /**
101
+ * Classify an applied mutation result as a create, update, or remove based on
102
+ * the pre-apply snapshot and the post-apply document data. Shared by the
103
+ * per-doc dedup logic in `#emitMutationEvents` and kept as a module-level
104
+ * helper for clarity. Callers must filter out `dropped` results before
105
+ * classification — a dropped result has no post-state to classify.
106
+ */ function classifyMutationResult(result) {
107
+ if (result.document == null) {
108
+ throw new Error('Cannot classify a dropped mutation result');
109
+ }
110
+ if (result.previousDoc == null) {
111
+ return 'create';
112
+ }
113
+ if (result.document.data === null) {
114
+ return 'remove';
115
+ }
116
+ return 'update';
117
+ }
118
+ function graphMutationNotSupported() {
119
+ throw new Error('Graph mutations require mutateGraph() — the core context does not sign or log writes. Use KubunEngine.mutateGraph() instead.');
120
+ }
121
+ function accessControlNotSupported() {
122
+ throw new Error('Access control mutations are not supported by the engine core — plugin override required.');
123
+ }
124
+ function transactionNotSupported() {
125
+ throw new Error('Transaction mutations are not supported by the engine core — plugin override required (see plugin-rpc).');
126
+ }
127
+ const baseMutationStubs = {
128
+ executeCreateMutation: graphMutationNotSupported,
129
+ executeSetMutation: graphMutationNotSupported,
130
+ executeUpdateMutation: graphMutationNotSupported,
131
+ executeRemoveMutation: graphMutationNotSupported,
132
+ executeSetModelAccessDefaults: accessControlNotSupported,
133
+ executeRemoveModelAccessDefaults: accessControlNotSupported,
134
+ executeSetDocumentAccessOverride: accessControlNotSupported,
135
+ executeRemoveDocumentAccessOverride: accessControlNotSupported,
136
+ beginTransaction: transactionNotSupported,
137
+ commitTransaction: transactionNotSupported,
138
+ rollbackTransaction: transactionNotSupported
139
+ };
140
+ function createContext(params) {
141
+ const coreContext = {
142
+ ...createReadContext({
143
+ store: params.store,
144
+ viewerDID: params.viewerDID,
145
+ events: params.eventBus,
146
+ accessChecker: params.accessChecker,
147
+ viewerReadAccess: params.viewerReadAccess,
148
+ maxSubscriptionQueueSize: params.maxSubscriptionQueueSize
149
+ }),
150
+ ...baseMutationStubs
151
+ };
152
+ // Merge core context with any plugin context extensions
153
+ if (params.contextExtensions != null) {
154
+ return {
155
+ ...coreContext,
156
+ ...params.contextExtensions
157
+ };
158
+ }
159
+ return coreContext;
160
+ }
161
+ export class KubunEngine {
162
+ /**
163
+ * Plugin-scoped context factories. Each factory's return value is placed
164
+ * under `ctx[name]` at request time — structural collision safety.
165
+ */ #disposed = false;
166
+ #pluginContextFactories = [];
167
+ /**
168
+ * Imperative context factories from `registerContextFactory`. Flat-merged
169
+ * onto ctx. Escape hatch for test fixtures — not subject to the duplicate
170
+ * plugin namespace check.
171
+ */ #imperativeFactories = [];
172
+ #cipher;
173
+ #db;
174
+ #defaultAccessLevel;
175
+ #eventBus;
176
+ #graphModels = {};
177
+ #hlc;
178
+ #hlcFloorPromise;
179
+ #identity;
180
+ #maxDriftMS;
181
+ #maxSubscriptionQueueSize;
182
+ #logger;
183
+ #mergedPolicies = {};
184
+ #pluginExtensions = new Map();
185
+ #plugins = [];
186
+ #registry;
187
+ #runtime;
188
+ #schemas = {};
189
+ #validators = {};
190
+ constructor(params){
191
+ this.#db = params.db instanceof KubunDB ? params.db : new KubunDB({
192
+ adapter: params.db
193
+ });
194
+ this.#db.register(graphStoreDefinition);
195
+ this.#db.register(delegationStoreDefinition);
196
+ this.#identity = params.identity;
197
+ this.#cipher = params.cipher ?? (isOwnIdentity(params.identity) ? createDefaultCipher(deriveAtRestKey(params.identity.privateKey)) : undefined);
198
+ this.#defaultAccessLevel = params.defaultAccessLevel ?? {
199
+ read: 'anyone',
200
+ write: 'only_owner'
201
+ };
202
+ this.#hlc = new HLC({
203
+ nodeID: params.identity.id
204
+ });
205
+ this.#maxDriftMS = params.maxDriftMS ?? 3_600_000;
206
+ this.#maxSubscriptionQueueSize = params.maxSubscriptionQueueSize;
207
+ this.#logger = params.logger ?? getKubunLogger('engine');
208
+ this.#eventBus = params.eventBus ?? new EngineEventBus({
209
+ logger: this.#logger.getChild('events')
210
+ });
211
+ this.#runtime = createRuntime(params.runtime);
212
+ this.#registry = createRegistry();
213
+ // Synchronous initialization — no async #initialize needed
214
+ const pluginFactories = params.plugins ?? [];
215
+ // Build factory params
216
+ const factoryParams = {
217
+ engine: this,
218
+ graph: {
219
+ execute: (p)=>this.#execute(p),
220
+ subscribe: (p)=>this.#subscribe(p),
221
+ applyVerifiedMutation: (p)=>this.#applyVerifiedMutation(p),
222
+ applyVerifiedMutations: (p)=>this.#applyVerifiedMutations(p),
223
+ mutateDocuments: (p)=>this.mutateDocuments(p)
224
+ },
225
+ db: this.#db,
226
+ runtime: this.#runtime,
227
+ identity: this.#identity,
228
+ eventBus: this.#eventBus,
229
+ hlc: this.#hlc,
230
+ getLogger: (name)=>this.#logger.getChild(name),
231
+ cipher: this.#cipher
232
+ };
233
+ // Call all plugin factories
234
+ for (const factory of pluginFactories){
235
+ const plugin = factory(factoryParams);
236
+ this.#plugins.push(plugin);
237
+ // Register plugin API
238
+ if (plugin.api != null) {
239
+ this.#registry.registerPlugin(plugin.name, plugin.api);
240
+ }
241
+ }
242
+ // Close the gate — all factories returned, registry populated
243
+ this.#registry.closeGate();
244
+ // Collect extensions, context factories, and policies from plugins
245
+ const seenPluginNames = new Set();
246
+ for (const plugin of this.#plugins){
247
+ if (plugin.schemaExtension != null) {
248
+ this.#pluginExtensions.set(plugin.name, plugin.schemaExtension);
249
+ }
250
+ if (plugin.createContextFactory != null) {
251
+ if (seenPluginNames.has(plugin.name)) {
252
+ throw new Error(`Duplicate plugin namespace: ${plugin.name}`);
253
+ }
254
+ seenPluginNames.add(plugin.name);
255
+ this.#pluginContextFactories.push({
256
+ name: plugin.name,
257
+ factory: plugin.createContextFactory()
258
+ });
259
+ }
260
+ if (plugin.policies != null) {
261
+ for (const [gateType, gate] of Object.entries(plugin.policies)){
262
+ if (this.#mergedPolicies[gateType] == null) {
263
+ this.#mergedPolicies[gateType] = {
264
+ sync: [],
265
+ async: []
266
+ };
267
+ }
268
+ const merged = this.#mergedPolicies[gateType];
269
+ if (gate.sync != null) {
270
+ merged.sync = [
271
+ ...merged.sync ?? [],
272
+ ...gate.sync
273
+ ];
274
+ }
275
+ if (gate.async != null) {
276
+ merged.async = [
277
+ ...merged.async ?? [],
278
+ ...gate.async
279
+ ];
280
+ }
281
+ }
282
+ }
283
+ }
284
+ this.#logger.info('engine initialized with {count} plugin(s)', {
285
+ count: this.#plugins.length
286
+ });
287
+ }
288
+ get did() {
289
+ return this.#identity.id;
290
+ }
291
+ get identity() {
292
+ return this.#identity;
293
+ }
294
+ get eventBus() {
295
+ return this.#eventBus;
296
+ }
297
+ /**
298
+ * Dispose the engine by shutting down all plugins in reverse registration order.
299
+ * Second and subsequent calls are no-ops. Each plugin's disposal failure is
300
+ * logged but does not prevent remaining plugins from being disposed.
301
+ */ async dispose() {
302
+ if (this.#disposed) {
303
+ return;
304
+ }
305
+ this.#disposed = true;
306
+ for(let i = this.#plugins.length - 1; i >= 0; i--){
307
+ const plugin = this.#plugins[i];
308
+ if (plugin.dispose != null) {
309
+ try {
310
+ await plugin.dispose();
311
+ } catch (error) {
312
+ this.#logger.error('plugin dispose failed', {
313
+ pluginName: plugin.name,
314
+ error
315
+ });
316
+ }
317
+ }
318
+ }
319
+ }
320
+ /**
321
+ * Return a GraphQLSource bound to a specific graph (and optionally a viewer).
322
+ * The returned object delegates query/mutate/subscribe to the engine, injecting
323
+ * the graphID and viewerDID from the source params on each call.
324
+ */ getGraphQLSource(params) {
325
+ const { graphID } = params;
326
+ const getViewerDID = params.getViewerDID ?? (()=>this.#identity.id);
327
+ return {
328
+ query: async (opParams)=>{
329
+ return await this.queryGraph({
330
+ id: graphID,
331
+ text: opParams.text,
332
+ variables: opParams.variables,
333
+ viewerDID: getViewerDID() ?? undefined
334
+ });
335
+ },
336
+ mutate: async (opParams)=>{
337
+ return await this.mutateGraph({
338
+ id: graphID,
339
+ text: opParams.text,
340
+ variables: opParams.variables,
341
+ viewerDID: getViewerDID() ?? undefined
342
+ });
343
+ },
344
+ subscribe: (opParams)=>{
345
+ return this.#subscribe({
346
+ graphID,
347
+ text: opParams.text,
348
+ variables: opParams.variables,
349
+ viewerDID: getViewerDID() ?? undefined
350
+ });
351
+ }
352
+ };
353
+ }
354
+ /**
355
+ * Register a context factory that contributes per-request fields flatly to
356
+ * the GraphQL execution context. Escape hatch for test fixtures — not
357
+ * subject to the plugin-namespace collision check. Prefer `KubunPlugin`'s
358
+ * `createContextFactory` for plugin-scoped, namespaced contributions.
359
+ */ registerContextFactory(factory) {
360
+ this.#imperativeFactories.push(factory);
361
+ }
362
+ /**
363
+ * Get the graph store API. Lazily migrates on first access.
364
+ */ async #getGraphStore() {
365
+ return await getGraphStore(this.#db);
366
+ }
367
+ /**
368
+ * Floor the shared clock to the greatest HLC already in the mutation log,
369
+ * exactly once per engine instance. The engine constructor is synchronous, so
370
+ * this lazy read runs on the first write path instead. After a restart where
371
+ * the OS clock stepped backward, a freshly constructed HLC (`lastWallTime` 0)
372
+ * would mint a local timestamp below the node's own prior writes and lose
373
+ * last-writer-wins against its own history; observing the stored maximum via
374
+ * `receive()` advances the clock past it so a new local write out-orders
375
+ * everything currently stored. Reuses `receive()` (monotonic, idempotent) — no
376
+ * dedicated seed API. The memoized promise makes concurrent first-writes await
377
+ * a single floor read with no race.
378
+ */ async #ensureHLCFloored(provider) {
379
+ if (this.#hlcFloorPromise != null) {
380
+ return await this.#hlcFloorPromise;
381
+ }
382
+ this.#hlcFloorPromise = (async ()=>{
383
+ const store = provider != null ? await getGraphStore(provider) : await this.#getGraphStore();
384
+ const max = await store.getMaxMutationHLC();
385
+ if (max != null) {
386
+ // After a large backward clock step the stored max can exceed
387
+ // `now + maxDriftMS`. Flooring to it unclamped would push the clock past
388
+ // the drift bound, so the node's own next local write would then exceed
389
+ // the bound and be rejected by the uniform future-drift check. Cap the
390
+ // floored wall-time at the bound so local writes always pass; a remote
391
+ // HLC beyond the bound is already rejected at ingest, so the floor is the
392
+ // only source that needs clamping.
393
+ const parsed = HLC.parse(max);
394
+ const ceiling = Date.now() + this.#maxDriftMS;
395
+ const flooredWallTime = Math.min(parsed.wallTime, ceiling);
396
+ this.#hlc.receive({
397
+ ...parsed,
398
+ wallTime: flooredWallTime
399
+ });
400
+ }
401
+ })();
402
+ return await this.#hlcFloorPromise;
403
+ }
404
+ /**
405
+ * Build a fresh context for a single request, merging core context
406
+ * with all registered plugin context factories and any inline extensions.
407
+ */ async #buildRequestContext(params) {
408
+ const viewerDID = params.viewerDID ?? this.#identity.id;
409
+ const executionContext = this.#createExecutionContext(viewerDID);
410
+ const provider = params.stores ?? this.#db;
411
+ // Plugin-scoped factories: output goes under ctx[pluginName]
412
+ let extensions = {};
413
+ for (const { name, factory } of this.#pluginContextFactories){
414
+ extensions[name] = factory(executionContext, provider);
415
+ }
416
+ // Imperative factories: flat-merged (escape hatch)
417
+ for (const factory of this.#imperativeFactories){
418
+ extensions = {
419
+ ...extensions,
420
+ ...factory(executionContext, provider)
421
+ };
422
+ }
423
+ // Merge inline contextExtensions (from execute params, e.g. mutateGraph()
424
+ // injecting core mutation ops). Spreads last — intentional so inline ops
425
+ // can override plugin/imperative fields for a specific request. Keys here
426
+ // should not collide with plugin namespaces; collisions would silently
427
+ // overwrite plugin ctx. Current inline callers use engine-reserved names
428
+ // (`executeCreateMutation`, `executeSetMutation`, etc.).
429
+ if (params.contextExtensions != null) {
430
+ extensions = {
431
+ ...extensions,
432
+ ...params.contextExtensions
433
+ };
434
+ }
435
+ const store = await getGraphStore(provider);
436
+ // The P2P store is optional (e.g. light client): only its absence is caught
437
+ // here. Once it is present, membership-query failures propagate rather than
438
+ // being silently swallowed into a no-groups fallback.
439
+ let p2pStore;
440
+ try {
441
+ p2pStore = await getP2PStore(provider);
442
+ } catch {
443
+ p2pStore = undefined;
444
+ }
445
+ // Delegation storage is core-registered and always present, so the read
446
+ // path's delegated-token revocation check is unconditional: a light client
447
+ // with no p2p store still holds delegation/revocation rows and must honor
448
+ // them when validating a delegated read chain.
449
+ const delegationStore = await getDelegationStore(provider);
450
+ const revocationChecker = createRevocationChecker(createRevocationBackend(delegationStore));
451
+ let accessControlDB;
452
+ let viewerReadAccess;
453
+ if (p2pStore != null) {
454
+ const memberStore = p2pStore;
455
+ accessControlDB = {
456
+ getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>store.getUserModelAccessDefault(ownerDID, modelID, permissionType),
457
+ isMemberOfAnyGroup: (did, groupIDs)=>memberStore.isMemberOfAnyGroup(did, groupIDs),
458
+ isMemberOfAnyCircle: (did, circleIDs)=>memberStore.isMemberOfAnyCircle(did, circleIDs),
459
+ getModelInterfaces: (modelID)=>store.getModelInterfaces(modelID),
460
+ revocationChecker
461
+ };
462
+ const [groups, circles] = await Promise.all([
463
+ memberStore.getGroupsForMember(viewerDID),
464
+ memberStore.getCirclesForMember(viewerDID)
465
+ ]);
466
+ viewerReadAccess = {
467
+ viewerDID,
468
+ groupIDs: groups.map((group)=>group.id),
469
+ circleIDs: circles.map((circle)=>circle.id),
470
+ serverDefault: this.#defaultAccessLevel.read
471
+ };
472
+ } else {
473
+ // No P2P store: no group/circle memberships exist, so membership grants
474
+ // resolve to empty and the checker enforces only_owner and allowedDIDs
475
+ // rules correctly.
476
+ accessControlDB = {
477
+ getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>store.getUserModelAccessDefault(ownerDID, modelID, permissionType),
478
+ isMemberOfAnyGroup: async ()=>false,
479
+ isMemberOfAnyCircle: async ()=>false,
480
+ getModelInterfaces: (modelID)=>store.getModelInterfaces(modelID),
481
+ revocationChecker
482
+ };
483
+ viewerReadAccess = {
484
+ viewerDID,
485
+ groupIDs: [],
486
+ circleIDs: [],
487
+ serverDefault: this.#defaultAccessLevel.read
488
+ };
489
+ }
490
+ const accessChecker = createAccessChecker({
491
+ viewerDID,
492
+ db: accessControlDB,
493
+ defaultAccessLevel: this.#defaultAccessLevel
494
+ });
495
+ return createContext({
496
+ store,
497
+ viewerDID,
498
+ eventBus: this.#eventBus,
499
+ accessChecker,
500
+ viewerReadAccess,
501
+ contextExtensions: Object.keys(extensions).length > 0 ? extensions : undefined,
502
+ maxSubscriptionQueueSize: this.#maxSubscriptionQueueSize
503
+ });
504
+ }
505
+ /**
506
+ * Create an ExecutionContext for a single request.
507
+ */ #createExecutionContext(viewerDID) {
508
+ return {
509
+ viewerDID
510
+ };
511
+ }
512
+ async #getGraphModel(id) {
513
+ if (this.#graphModels[id] == null) {
514
+ this.#graphModels[id] = this.#getGraphStore().then(async (store)=>{
515
+ const graph = await store.getGraph(id);
516
+ if (graph == null) {
517
+ this.#logger.warn('graph {id} not found', {
518
+ id
519
+ });
520
+ delete this.#graphModels[id];
521
+ throw new Error(`Graph not found: ${id}`);
522
+ }
523
+ this.#logger.debug('cached model for graph {id}', {
524
+ id
525
+ });
526
+ return {
527
+ aliases: graph.aliases,
528
+ record: graph.record,
529
+ extensionSDL: graph.extension_sdl ?? undefined,
530
+ pluginConfig: graph.plugin_config ?? undefined
531
+ };
532
+ }).catch((err)=>{
533
+ delete this.#graphModels[id];
534
+ throw err;
535
+ });
536
+ }
537
+ return await this.#graphModels[id];
538
+ }
539
+ async getGraphQLSchema(id) {
540
+ if (this.#schemas[id] == null) {
541
+ this.#schemas[id] = this.#getGraphModel(id).then((model)=>{
542
+ // Build extension resolvers from plugins if this graph has plugin config
543
+ let extensionResolvers;
544
+ if (model.pluginConfig != null) {
545
+ const qf = {};
546
+ const mf = {};
547
+ const sf = {};
548
+ const tf = {};
549
+ const nr = {};
550
+ for (const [pluginName, config] of Object.entries(model.pluginConfig)){
551
+ const extensionFn = this.#pluginExtensions.get(pluginName);
552
+ if (extensionFn != null) {
553
+ const ext = extensionFn(config);
554
+ Object.assign(qf, ext.resolvers.queryFields ?? {});
555
+ Object.assign(mf, ext.resolvers.mutationFields ?? {});
556
+ Object.assign(sf, ext.resolvers.subscriptionFields ?? {});
557
+ Object.assign(nr, ext.resolvers.nodeResolvers ?? {});
558
+ if (ext.resolvers.typeFields != null) {
559
+ for (const [typeName, fields] of Object.entries(ext.resolvers.typeFields)){
560
+ tf[typeName] = {
561
+ ...tf[typeName],
562
+ ...fields
563
+ };
564
+ }
565
+ }
566
+ }
567
+ }
568
+ extensionResolvers = {
569
+ queryFields: qf,
570
+ mutationFields: mf,
571
+ subscriptionFields: sf,
572
+ typeFields: tf,
573
+ nodeResolvers: nr
574
+ };
575
+ }
576
+ const schema = createSchema({
577
+ ...model,
578
+ extensionResolvers
579
+ });
580
+ this.#logger.debug('cached schema for graph {id}', {
581
+ id
582
+ });
583
+ return schema;
584
+ }).catch((err)=>{
585
+ delete this.#schemas[id];
586
+ throw err;
587
+ });
588
+ }
589
+ return await this.#schemas[id];
590
+ }
591
+ /**
592
+ * Shared preparation for #execute and #subscribe: parse, run policies, then
593
+ * validate the document against the bound schema. Policies run before
594
+ * validation so a denied viewer receives the policy denial rather than a
595
+ * schema-validity probe. Returns the parsed document and the bound schema to
596
+ * proceed, or an ExecutionResult to short-circuit (policy denial or validation
597
+ * errors).
598
+ */ async #prepareOperation(params) {
599
+ const document = parse(params.text);
600
+ const operationType = this.#getOperationType(document);
601
+ const policyResult = await this.#runPolicies(operationType, params.viewerDID ?? this.#identity.id);
602
+ if (policyResult != null) {
603
+ return {
604
+ result: policyResult
605
+ };
606
+ }
607
+ const schema = await this.getGraphQLSchema(params.graphID);
608
+ const errors = validate(schema, document);
609
+ if (errors.length > 0) {
610
+ return {
611
+ result: {
612
+ errors
613
+ }
614
+ };
615
+ }
616
+ return {
617
+ document,
618
+ schema
619
+ };
620
+ }
621
+ // `document` and `schema` are always produced by `#prepareOperation`, which has
622
+ // already validated the document against that schema. Requiring them here keeps
623
+ // the "validated before executed" invariant structural: there is no path to
624
+ // build execution args from an unvalidated document or a re-fetched schema.
625
+ async #getExecutionArgs(params, document, schema) {
626
+ if (getOperationDefinition(document) == null) {
627
+ throw new Error('Missing GraphQL operation definition');
628
+ }
629
+ return {
630
+ schema,
631
+ document,
632
+ contextValue: await this.#buildRequestContext(params),
633
+ variableValues: params.variables
634
+ };
635
+ }
636
+ /**
637
+ * Determine the GraphQL operation type from a parsed document.
638
+ * Returns 'query', 'mutation', or 'subscription'.
639
+ */ #getOperationType(document) {
640
+ const operation = getOperationDefinition(document);
641
+ return operation?.operation ?? 'query';
642
+ }
643
+ /**
644
+ * Run merged policies for the given gate type. Returns an ExecutionResult
645
+ * with errors if policies deny the operation, or null if allowed.
646
+ */ async #runPolicies(gateType, viewerDID) {
647
+ const policyContext = {
648
+ viewerDID
649
+ };
650
+ const result = await runPolicies(this.#mergedPolicies, gateType, policyContext);
651
+ if ('issues' in result && result.issues != null && result.issues.length > 0) {
652
+ return {
653
+ errors: result.issues.map((issue)=>new GraphQLError(issue.message, {
654
+ extensions: issue.path != null ? {
655
+ path: issue.path
656
+ } : undefined
657
+ }))
658
+ };
659
+ }
660
+ return null;
661
+ }
662
+ async listGraphs() {
663
+ const store = await this.#getGraphStore();
664
+ const graphs = await store.listGraphs();
665
+ return {
666
+ graphs: graphs.map((g)=>({
667
+ id: g.id,
668
+ name: g.name
669
+ }))
670
+ };
671
+ }
672
+ async loadGraph(params) {
673
+ const model = await this.#getGraphModel(params.id);
674
+ return {
675
+ aliases: model.aliases,
676
+ record: model.record,
677
+ extensionSDL: model.extensionSDL
678
+ };
679
+ }
680
+ async deployGraph(params) {
681
+ const clustersRecord = {};
682
+ for (const cluster of params.clusters){
683
+ Object.assign(clustersRecord, clusterToRecord(cluster));
684
+ }
685
+ const model = GraphModel.fromClusters({
686
+ clusters: clustersRecord
687
+ });
688
+ // Generate extension SDL from plugins
689
+ let extensionSDL;
690
+ let pluginConfig;
691
+ if (params.plugins != null) {
692
+ pluginConfig = params.plugins;
693
+ const sdlParts = [];
694
+ for (const [pluginName, config] of Object.entries(pluginConfig)){
695
+ const extensionFn = this.#pluginExtensions.get(pluginName);
696
+ if (extensionFn == null) {
697
+ throw new Error(`Plugin "${pluginName}" not found or does not provide schema extensions`);
698
+ }
699
+ const ext = extensionFn(config);
700
+ sdlParts.push(ext.sdl);
701
+ }
702
+ if (sdlParts.length > 0) {
703
+ extensionSDL = sdlParts.join('\n');
704
+ }
705
+ }
706
+ const store = await this.#getGraphStore();
707
+ const id = await store.createGraph({
708
+ id: params.id ?? this.#runtime.getRandomID(),
709
+ name: params.name,
710
+ record: model.record,
711
+ extensionSDL,
712
+ pluginConfig
713
+ });
714
+ // The store write is additive (it accumulates models across deploys), so the
715
+ // in-memory model and schema must reflect the full merged graph, not just
716
+ // this call's clusters. Invalidate both caches and re-read the full graph
717
+ // from the store; the schema rebuilds lazily on the next getGraphQLSchema.
718
+ delete this.#graphModels[id];
719
+ delete this.#schemas[id];
720
+ this.#logger.info('deployed graph {id}', {
721
+ id
722
+ });
723
+ const full = await this.#getGraphModel(id);
724
+ return {
725
+ id,
726
+ aliases: full.aliases,
727
+ record: full.record
728
+ };
729
+ }
730
+ async #emitMutationEvents(results, origin) {
731
+ const mutationEventName = origin === 'local' ? 'engine:mutation:authored' : 'engine:mutation:received';
732
+ // Filter out results dropped by the access gate — they have no post-state
733
+ // and contribute nothing to events. This filter is the only behavioral
734
+ // difference for the no-gate hot path: when no gate is supplied, no
735
+ // entry is ever marked `dropped`, so this filter is a no-op pass-through.
736
+ const appliedResults = results.filter((r)=>!r.dropped);
737
+ const groups = new Map();
738
+ const classifications = [];
739
+ for (const result of appliedResults){
740
+ const type = classifyMutationResult(result);
741
+ classifications.push(type);
742
+ // applied results carry a non-null document — checked above by filter.
743
+ const document = result.document;
744
+ const docID = document.id;
745
+ let group = groups.get(docID);
746
+ if (group == null) {
747
+ group = {
748
+ docID,
749
+ hasCreate: false,
750
+ hasUpdate: false,
751
+ hasRemove: false,
752
+ lastUpdateResult: null,
753
+ lastRemoveResult: null,
754
+ lastResult: result
755
+ };
756
+ groups.set(docID, group);
757
+ }
758
+ group.lastResult = result;
759
+ if (type === 'create') {
760
+ group.hasCreate = true;
761
+ } else if (type === 'update') {
762
+ group.hasUpdate = true;
763
+ group.lastUpdateResult = result;
764
+ } else {
765
+ group.hasRemove = true;
766
+ group.lastRemoveResult = result;
767
+ }
768
+ }
769
+ // Emit deduped lifecycle events per document.
770
+ for (const group of groups.values()){
771
+ // create + remove within the same batch cancel out — no lifecycle event
772
+ // fires for the doc. Mutation events still fire per result below.
773
+ if (group.hasCreate && group.hasRemove) {
774
+ continue;
775
+ }
776
+ // appliedResults guarantees non-null document on every group entry.
777
+ const lastDoc = group.lastResult.document;
778
+ if (group.hasRemove) {
779
+ const removeResult = group.lastRemoveResult ?? group.lastResult;
780
+ const removeDoc = removeResult.document;
781
+ await this.#eventBus.emit('engine:document:removed', {
782
+ documentID: removeDoc.id,
783
+ viewerDID: removeResult.authorDID
784
+ });
785
+ continue;
786
+ }
787
+ // No remove at this point — hasCreate and/or hasUpdate is true.
788
+ if (group.hasCreate) {
789
+ // First mutation classified as `create` determines create payload.
790
+ // `document.model` is stable across a batch so using lastResult's
791
+ // model is safe.
792
+ await this.#eventBus.emit('engine:document:created', {
793
+ documentID: lastDoc.id,
794
+ modelID: lastDoc.model,
795
+ viewerDID: group.lastResult.authorDID
796
+ });
797
+ }
798
+ if (group.hasUpdate) {
799
+ const updateResult = group.lastUpdateResult ?? group.lastResult;
800
+ const updateDoc = updateResult.document;
801
+ await this.#eventBus.emit('engine:document:updated', {
802
+ documentID: updateDoc.id,
803
+ modelID: updateDoc.model,
804
+ viewerDID: updateResult.authorDID
805
+ });
806
+ }
807
+ // document:saved: emit the net effect using the last result's snapshot.
808
+ // `type` is 'create' only when the doc was created and not updated
809
+ // afterwards within this batch; otherwise 'update' reflects the final
810
+ // state. We use lastResult's previousDoc to populate `previous.data` —
811
+ // this reflects the state immediately before the final mutation, which
812
+ // is what relation-tracking subscribers (e.g. edge-removed) need.
813
+ const savedType = group.hasCreate && !group.hasUpdate ? 'create' : 'update';
814
+ const savedEvent = {
815
+ type: savedType,
816
+ document: lastDoc,
817
+ previous: {
818
+ data: group.lastResult.previousDoc?.data ?? null
819
+ },
820
+ getCursor: ()=>lastDoc.id
821
+ };
822
+ await this.#eventBus.emit('document:saved', savedEvent);
823
+ }
824
+ // Mutation events fire per applied result, in input order — no dedup here.
825
+ // Dropped entries contribute no mutation event (they were never applied).
826
+ for(let i = 0; i < appliedResults.length; i++){
827
+ const result = appliedResults[i];
828
+ const document = result.document;
829
+ await this.#eventBus.emit(mutationEventName, {
830
+ type: classifications[i],
831
+ documentID: document.id,
832
+ viewerDID: result.authorDID,
833
+ mutationJWT: result.token,
834
+ version: result.mutation.hlc,
835
+ modelID: document.model
836
+ });
837
+ }
838
+ }
839
+ /**
840
+ * Build the per-mutation write authorizer bound to a graph store. When a
841
+ * mutation's issuer is not the document owner, the write is allowed only if
842
+ * the mutation carries a valid delegation chain (`cap`) from the owner.
843
+ * Write enforcement never consults group/circle membership, so those checks
844
+ * are no-ops — only the model-default lookup (graph store) is needed.
845
+ *
846
+ * `extraTokensByIssuer`, when provided, supplies additional delegation tokens
847
+ * held locally for a given issuer (looked up once outside the closure). The
848
+ * checker unions them with the mutation's inline `cap` so server-side
849
+ * application can authorize a write whose held credentials live in the
850
+ * receiving peer's p2p store rather than on the wire.
851
+ */ #buildWriteAccessChecker(store, extraTokensByIssuer, revocationChecker) {
852
+ return buildWriteAccessChecker({
853
+ store,
854
+ defaultAccessLevel: this.#defaultAccessLevel,
855
+ extraTokensByIssuer,
856
+ revocationChecker
857
+ });
858
+ }
859
+ /**
860
+ * Verify a signed mutation JWT, apply it to the database, and capture it
861
+ * in the mutation log.
862
+ *
863
+ * When a StoreProvider is given, the caller owns the transaction and the
864
+ * post-commit emit: apply directly against the provider and return the
865
+ * result for the caller to emit. Without a provider this is the peer-ingest
866
+ * path: the read-modify-write (getDocument → membership → applyMutation →
867
+ * insertMutationLogEntry) is wrapped in a single DB transaction so a crash
868
+ * between updateFieldHLCs and saveDocument cannot advance a field HLC
869
+ * without its data, then events are emitted after the commit.
870
+ */ async #applyVerifiedMutation(params, provider) {
871
+ const origin = params.origin ?? 'local';
872
+ // Floor the clock to the stored mutation maximum once per engine, before
873
+ // any mint or receive on this path and before the no-provider transaction
874
+ // opens (a self-read inside the open transaction would deadlock on
875
+ // single-connection SQLite). Harmless before an ingest receive() (receive
876
+ // is monotonic); load-bearing for any local mint that reaches here.
877
+ await this.#ensureHLCFloored(provider);
878
+ // Verify the JWT signature and validate the payload before opening the
879
+ // transaction, mirroring the batch path which verifies upfront.
880
+ const verified = await verifyToken(params.token);
881
+ const mutation = asType(validateMutation, verified.payload);
882
+ // With a caller-supplied provider the caller owns both the transaction and
883
+ // the emit — apply directly and hand back the result.
884
+ if (provider != null) {
885
+ return await this.#applyVerifiedMutationBody(params, mutation, origin, provider);
886
+ }
887
+ // No provider: this is the real peer-ingest path. Wrap the apply body in a
888
+ // single transaction so the multi-statement read-modify-write commits
889
+ // atomically; all reads and writes route through the transaction provider
890
+ // `tx`, never the main connection — a separate read on single-connection
891
+ // SQLite would deadlock against the open write transaction.
892
+ const result = await this.#db.withTransaction(async (tx)=>{
893
+ return await this.#applyVerifiedMutationBody(params, mutation, origin, tx);
894
+ });
895
+ // Emit after the transaction commits, never inside — events for a mutation
896
+ // that rolled back must never fire. `#emitMutationEvents` filters dropped
897
+ // and null-document entries, so a membership-denied / gate-denied result is
898
+ // a no-op here.
899
+ await this.#emitMutationEvents([
900
+ result
901
+ ], origin);
902
+ return result;
903
+ }
904
+ /**
905
+ * Apply a verified mutation against an effective store provider and capture
906
+ * it in the mutation log, returning the result without emitting events.
907
+ * The caller (or the wrapping transaction in `#applyVerifiedMutation`) owns
908
+ * emission and transaction boundaries; every read and write here routes
909
+ * through the supplied `provider`.
910
+ */ async #applyVerifiedMutationBody(params, mutation, origin, provider) {
911
+ const { token, accessGate } = params;
912
+ // Get graph store and apply mutation. Validators cache is keyed by
913
+ // content-addressed modelID globally — no per-graph indirection.
914
+ const store = await getGraphStore(provider);
915
+ // Auto-attach delegation tokens this device holds for the mutation's
916
+ // issuer, and resolve the membership gate's p2p store. Single-issuer
917
+ // assumption: a single apply call carries exactly one issuer
918
+ // (`mutation.iss`); the per-issuer / per-time generalization for
919
+ // multi-mutation batches lives in the batch path.
920
+ const atTime = mutationSigningTimeSeconds(mutation.hlc) ?? Math.floor(Date.now() / 1000);
921
+ const extraTokensByIssuer = new Map();
922
+ // Delegation storage is core-registered and always present: held-token
923
+ // auto-attach and the revocation checker are unconditional. A light client
924
+ // with no p2p store still holds delegation grants and their revocations and
925
+ // must honor both on apply.
926
+ const delegationStore = await getDelegationStore(provider);
927
+ const held = await delegationStore.getHeldTokens({
928
+ audience: mutation.iss,
929
+ atTime
930
+ });
931
+ if (held.length > 0) {
932
+ extraTokensByIssuer.set(mutation.iss, held.map((row)=>row.token));
933
+ }
934
+ const revocationChecker = createRevocationChecker(createRevocationBackend(delegationStore));
935
+ // The MLS/membership gate is the only piece that needs the p2p store. Gate
936
+ // on store registration, not on whether the lookup throws. An unregistered
937
+ // p2p store is a light client with no membership to enforce, so the gate is
938
+ // legitimately skipped. A registered store that errors must propagate and
939
+ // fail the apply — swallowing it here would let a transient DB error open
940
+ // the membership gate and apply a removed/non-owner change.
941
+ let p2pStore;
942
+ if (provider.hasStore(P2P_STORE)) {
943
+ p2pStore = await getP2PStore(provider);
944
+ }
945
+ const mutationCtx = {
946
+ store,
947
+ validators: this.#validators,
948
+ hlc: this.#hlc,
949
+ maxDriftMS: this.#maxDriftMS,
950
+ checkWriteAccess: this.#buildWriteAccessChecker(store, extraTokensByIssuer, revocationChecker),
951
+ // Build the gate from this apply's transaction-scoped stores so its
952
+ // reads run inside the transaction. When no factory is supplied (or it
953
+ // returns undefined), apply.ts takes its existing no-gate branch.
954
+ postStateGate: accessGate?.({
955
+ graphStore: store,
956
+ p2pStore
957
+ })
958
+ };
959
+ // Snapshot the doc before apply so emitters can compute the correct
960
+ // event type (create vs update) and populate `previous.data`.
961
+ const previousDoc = await store.getDocument(DocumentID.fromString(mutation.sub));
962
+ // Membership gate: a peer removed from the doc's MLS group before (or at)
963
+ // the mutation's HLC can no longer write to it. Skipped for the light
964
+ // client (no p2p store), for creates (creator owns the new doc), and for
965
+ // owner self-writes (membership never gates the owner).
966
+ if (p2pStore != null && previousDoc != null && previousDoc.owner !== mutation.iss) {
967
+ const membership = await checkMembership({
968
+ p2pStore,
969
+ graphStore: store,
970
+ selfDID: this.did,
971
+ iss: mutation.iss,
972
+ mutationHLC: mutation.hlc,
973
+ doc: previousDoc,
974
+ arrivalGroupID: params.arrivalGroupID
975
+ });
976
+ if (!membership.ok) {
977
+ this.#logger.warn('mutation denied: issuer removed from group', {
978
+ iss: mutation.iss,
979
+ docID: previousDoc.id,
980
+ mutationHLC: mutation.hlc,
981
+ removedGroupID: membership.removedGroupID,
982
+ removedAtHLC: membership.removedAtHLC,
983
+ origin
984
+ });
985
+ if (origin === 'peer') {
986
+ // Received from a peer: drop silently — no apply, no log, no events.
987
+ return {
988
+ document: null,
989
+ previousDoc,
990
+ mutation,
991
+ authorDID: mutation.iss,
992
+ hash: computeMutationHash(token),
993
+ token,
994
+ dropped: true
995
+ };
996
+ }
997
+ throw new WriteAccessDeniedError(previousDoc.id);
998
+ }
999
+ }
1000
+ const document = await applyMutation(mutationCtx, mutation);
1001
+ // Advance the shared clock toward a remote observation's timestamp. A
1002
+ // 'local' mutation's HLC was just minted by this.#hlc.now(), so receiving
1003
+ // it back would double-bump the counter; only 'peer' origin is remote.
1004
+ // Placed after applyMutation so a future-over-drift mutation (which
1005
+ // throws inside applyMutation) never advances the clock. A mutation that
1006
+ // passes drift but loses LWW still advances — it is a real observation. The
1007
+ // anchor lets a local write issued right after ingesting an ahead-of-clock
1008
+ // remote mutation win last-writer-wins instead of being silently lost.
1009
+ if (origin !== 'local') {
1010
+ this.#hlc.receive(HLC.parse(mutation.hlc));
1011
+ }
1012
+ const hash = computeMutationHash(token);
1013
+ const authorDID = mutation.iss;
1014
+ // Gate denied: no log insert, no events. Return the dropped result with
1015
+ // pre-state preserved.
1016
+ if (document == null) {
1017
+ return {
1018
+ document: null,
1019
+ previousDoc,
1020
+ mutation,
1021
+ authorDID,
1022
+ hash,
1023
+ token,
1024
+ dropped: true
1025
+ };
1026
+ }
1027
+ // Capture to mutation log.
1028
+ await store.insertMutationLogEntry({
1029
+ mutation_hash: hash,
1030
+ model_id: document.model,
1031
+ document_id: document.id,
1032
+ author_did: authorDID,
1033
+ hlc: mutation.hlc,
1034
+ mutation_jwt: token,
1035
+ status: 'applied'
1036
+ });
1037
+ return {
1038
+ document,
1039
+ previousDoc,
1040
+ mutation,
1041
+ authorDID,
1042
+ hash,
1043
+ token,
1044
+ dropped: false
1045
+ };
1046
+ }
1047
+ /**
1048
+ * Verify and apply multiple signed mutation JWTs atomically within a single
1049
+ * database transaction. All tokens are verified upfront (fail fast); if any
1050
+ * verification fails no mutations are applied. If any mutation application
1051
+ * fails mid-transaction the entire batch is rolled back.
1052
+ */ async #applyVerifiedMutations(params) {
1053
+ const { tokens, accessGate } = params;
1054
+ const origin = params.origin ?? 'local';
1055
+ // Floor the clock to the stored mutation maximum once per engine, before the
1056
+ // apply transaction opens. Done on the default store ahead of the write
1057
+ // transaction to avoid a self-read inside the open transaction on
1058
+ // single-connection SQLite. Harmless before ingest receive() (monotonic).
1059
+ await this.#ensureHLCFloored();
1060
+ // Verify all tokens upfront — fail fast before starting transaction
1061
+ const verified = await Promise.all(tokens.map(async (token)=>{
1062
+ const v = await verifyToken(token);
1063
+ const mutation = asType(validateMutation, v.payload);
1064
+ return {
1065
+ token,
1066
+ mutation
1067
+ };
1068
+ }));
1069
+ // Apply all within a single DB transaction. Each entry's gate is
1070
+ // evaluated independently inside apply.ts; a denied entry skips its
1071
+ // own writes but does not affect siblings. The transaction still rolls
1072
+ // back on actual errors (verify failures already happened above; this
1073
+ // covers store-level failures).
1074
+ const results = [];
1075
+ await this.#db.withTransaction(async (tx)=>{
1076
+ const txStore = await tx.getStore(GRAPH_STORE);
1077
+ // Auto-attach held delegation tokens, grouped per issuer. Mixed-issuer
1078
+ // batches are uncommon but legal: each issuer gets its own held-tokens
1079
+ // lookup so issuer B's tokens are never unioned into issuer A's
1080
+ // mutation. Read through the transaction provider `tx`, not the main
1081
+ // connection — a separate read on single-connection SQLite would
1082
+ // deadlock against the open write transaction.
1083
+ //
1084
+ // The per-issuer lookup `atTime` is the MIN signing time across that
1085
+ // issuer's mutations. `getHeldTokens` filters `exp > atTime`, so a
1086
+ // smaller `atTime` returns a broader (more permissive) set; the
1087
+ // minimum signing time yields the widest superset that still bounds
1088
+ // out tokens no mutation in this batch could possibly admit.
1089
+ // Per-mutation expiry is then re-validated inside `checkCapability`
1090
+ // via `mutationSigningTimeSeconds(mutation.hlc)` in the access
1091
+ // checker, so a token that was valid at mutation A's signing time but
1092
+ // expired by mutation B's signing time is admitted for A and rejected
1093
+ // for B — preserving time-of-signing semantics symmetrically with
1094
+ // mutateGraph. (A MAX strategy would incorrectly drop A's token from
1095
+ // extras when any sibling mutation's signing time exceeded the
1096
+ // token's exp.) If any of the issuer's mutations has a missing or
1097
+ // un-parseable HLC, the per-mutation check falls back to now(), so
1098
+ // the lookup must include now() as a candidate `atTime` to remain a
1099
+ // superset; we MIN against now() in that case as well.
1100
+ // Per-issuer try/catch keeps one issuer's p2p failure (light client,
1101
+ // missing table, schema drift) from suppressing extras for other
1102
+ // issuers; on failure that issuer falls back to `mutation.cap` only.
1103
+ const extraTokensByIssuer = new Map();
1104
+ const issuerAtTimes = new Map();
1105
+ const issuerHasMissingHLC = new Set();
1106
+ const fallbackNow = Math.floor(Date.now() / 1000);
1107
+ for (const { mutation } of verified){
1108
+ const t = mutationSigningTimeSeconds(mutation.hlc);
1109
+ if (t == null) {
1110
+ issuerHasMissingHLC.add(mutation.iss);
1111
+ continue;
1112
+ }
1113
+ const prev = issuerAtTimes.get(mutation.iss);
1114
+ if (prev == null || t < prev) {
1115
+ issuerAtTimes.set(mutation.iss, t);
1116
+ }
1117
+ }
1118
+ // When the issuer has at least one parsed HLC, that mutation must use
1119
+ // its signing-time atTime; the missing-HLC siblings (if any) use now().
1120
+ // The widest superset is therefore the MIN of the two — and when every
1121
+ // mutation has a missing HLC, only `now()` applies.
1122
+ // Delegation storage is core-registered and always present, so held-token
1123
+ // auto-attach and the revocation checker run unconditionally. Read through
1124
+ // the transaction provider `tx` so the lookups run inside this batch's
1125
+ // transaction.
1126
+ const delegationStore = await getDelegationStore(tx);
1127
+ const revocationChecker = createRevocationChecker(createRevocationBackend(delegationStore));
1128
+ for (const iss of new Set(verified.map((v)=>v.mutation.iss))){
1129
+ const minSigningTime = issuerAtTimes.get(iss);
1130
+ const includeFallback = issuerHasMissingHLC.has(iss) || minSigningTime == null;
1131
+ const atTime = includeFallback ? Math.min(minSigningTime ?? fallbackNow, fallbackNow) : minSigningTime;
1132
+ const held = await delegationStore.getHeldTokens({
1133
+ audience: iss,
1134
+ atTime
1135
+ });
1136
+ if (held.length > 0) {
1137
+ extraTokensByIssuer.set(iss, held.map((row)=>row.token));
1138
+ }
1139
+ }
1140
+ // The MLS/membership gate is the only piece that needs the p2p store. Gate
1141
+ // on store registration, not on whether a lookup throws — mirrors the
1142
+ // single-apply path. An unregistered p2p store is a light client with no
1143
+ // membership to enforce, so the gate is legitimately skipped. A registered
1144
+ // store that errors must propagate and fail the whole atomic batch —
1145
+ // swallowing it here would let a transient DB error open the membership
1146
+ // gate and apply removed/non-owner changes.
1147
+ let p2pStore;
1148
+ if (tx.hasStore(P2P_STORE)) {
1149
+ p2pStore = await getP2PStore(tx);
1150
+ }
1151
+ const checkWriteAccess = this.#buildWriteAccessChecker(txStore, extraTokensByIssuer, revocationChecker);
1152
+ // Build the gate once from the transaction-scoped stores so its reads run
1153
+ // inside this transaction; reused for every entry's independent decision.
1154
+ const postStateGate = accessGate?.({
1155
+ graphStore: txStore,
1156
+ p2pStore
1157
+ });
1158
+ for (const { token, mutation } of verified){
1159
+ const mutationCtx = {
1160
+ store: txStore,
1161
+ validators: this.#validators,
1162
+ hlc: this.#hlc,
1163
+ maxDriftMS: this.#maxDriftMS,
1164
+ checkWriteAccess,
1165
+ // When no gate is supplied, omitted from ctx — apply.ts takes its
1166
+ // existing no-gate path.
1167
+ postStateGate
1168
+ };
1169
+ // Snapshot the doc before apply so emitters can compute the correct
1170
+ // event type (create vs update) and populate `previous.data`.
1171
+ const previousDoc = await txStore.getDocument(DocumentID.fromString(mutation.sub));
1172
+ // Membership gate (RPC batch — walk resolution, no arrival group). A
1173
+ // denial throws to abort the whole atomic batch. Skipped for the light
1174
+ // client, creates, and owner self-writes (see single-apply path).
1175
+ if (p2pStore != null && previousDoc != null && previousDoc.owner !== mutation.iss) {
1176
+ const membership = await checkMembership({
1177
+ p2pStore,
1178
+ graphStore: txStore,
1179
+ selfDID: this.did,
1180
+ iss: mutation.iss,
1181
+ mutationHLC: mutation.hlc,
1182
+ doc: previousDoc,
1183
+ arrivalGroupID: undefined
1184
+ });
1185
+ if (!membership.ok) {
1186
+ this.#logger.warn('mutation denied: issuer removed from group', {
1187
+ iss: mutation.iss,
1188
+ docID: previousDoc.id,
1189
+ mutationHLC: mutation.hlc,
1190
+ removedGroupID: membership.removedGroupID,
1191
+ removedAtHLC: membership.removedAtHLC,
1192
+ origin
1193
+ });
1194
+ throw new WriteAccessDeniedError(previousDoc.id);
1195
+ }
1196
+ }
1197
+ const document = await applyMutation(mutationCtx, mutation);
1198
+ // Advance the shared clock toward a remote observation's timestamp.
1199
+ // Mirror the single-apply path: skip 'local' (its HLC was just minted
1200
+ // by this.#hlc.now()), advance for 'peer'. Placed after
1201
+ // applyMutation so a future-over-drift mutation never advances the
1202
+ // clock; a drift-ok mutation that loses LWW still advances.
1203
+ if (origin !== 'local') {
1204
+ this.#hlc.receive(HLC.parse(mutation.hlc));
1205
+ }
1206
+ const hash = computeMutationHash(token);
1207
+ if (document == null) {
1208
+ // Gate denied: no log insert, no event contribution. Pre-state on
1209
+ // disk is unchanged for this entry.
1210
+ results.push({
1211
+ document: null,
1212
+ previousDoc,
1213
+ mutation,
1214
+ authorDID: mutation.iss,
1215
+ hash,
1216
+ token,
1217
+ dropped: true
1218
+ });
1219
+ continue;
1220
+ }
1221
+ await txStore.insertMutationLogEntry({
1222
+ mutation_hash: hash,
1223
+ model_id: document.model,
1224
+ document_id: document.id,
1225
+ author_did: mutation.iss,
1226
+ hlc: mutation.hlc,
1227
+ mutation_jwt: token,
1228
+ status: 'applied'
1229
+ });
1230
+ results.push({
1231
+ document,
1232
+ previousDoc,
1233
+ mutation,
1234
+ authorDID: mutation.iss,
1235
+ hash,
1236
+ token,
1237
+ dropped: false
1238
+ });
1239
+ }
1240
+ });
1241
+ // Emit events after transaction commits — not inside, to avoid
1242
+ // emitting events for mutations that may be rolled back. `#emitMutationEvents`
1243
+ // filters dropped entries internally.
1244
+ await this.#emitMutationEvents(results, origin);
1245
+ return {
1246
+ results,
1247
+ dropped: results.filter((r)=>r.dropped).length
1248
+ };
1249
+ }
1250
+ async #execute(params) {
1251
+ const prepared = await this.#prepareOperation(params);
1252
+ if ('result' in prepared) {
1253
+ return prepared.result;
1254
+ }
1255
+ const args = await this.#getExecutionArgs(params, prepared.document, prepared.schema);
1256
+ return await execute(args);
1257
+ }
1258
+ async #subscribe(params) {
1259
+ const prepared = await this.#prepareOperation(params);
1260
+ if ('result' in prepared) {
1261
+ return prepared.result;
1262
+ }
1263
+ const args = await this.#getExecutionArgs(params, prepared.document, prepared.schema);
1264
+ return await subscribe(args);
1265
+ }
1266
+ async queryGraph(params) {
1267
+ const document = parse(params.text);
1268
+ const operationType = this.#getOperationType(document);
1269
+ if (operationType === 'mutation') {
1270
+ throw new Error('queryGraph() does not accept mutation operations. Use mutateGraph() instead.');
1271
+ }
1272
+ return await this.#execute({
1273
+ graphID: params.id,
1274
+ text: params.text,
1275
+ variables: params.variables ?? {},
1276
+ viewerDID: params.viewerDID
1277
+ });
1278
+ }
1279
+ /**
1280
+ * Shared machinery for a signed write transaction: opens a write transaction,
1281
+ * builds a `signAndApply` choke point that signs each mutation with this
1282
+ * engine's identity and applies it, auto-attaches held delegation tokens as
1283
+ * `cap`, floors the HLC, and exposes `MutationOperations` to a `drive`
1284
+ * callback. After the transaction commits, mutation events are emitted.
1285
+ *
1286
+ * The `drive` callback consumes the operations and returns the transaction
1287
+ * result. It may inspect `writeErrors` (populated when a `signAndApply` throw
1288
+ * was swallowed by an inner layer, e.g. graphql-js) and throw
1289
+ * `MutateGraphWriteRollback` to force a rollback while carrying a result out;
1290
+ * that result is returned WITHOUT emitting events. Any other throw propagates
1291
+ * after the transaction rolls back, also without emitting events.
1292
+ */ async #signedWriteTransaction(params) {
1293
+ if (!isSigningIdentity(this.#identity)) {
1294
+ throw new Error('signed write requires a SigningIdentity');
1295
+ }
1296
+ const signingIdentity = this.#identity;
1297
+ const mutationResults = [];
1298
+ // Write failures recorded so the whole transaction can roll back even after
1299
+ // an inner layer (e.g. graphql-js) has swallowed the throw. Only
1300
+ // `signAndApply` (the write choke point) pushes here.
1301
+ const writeErrors = [];
1302
+ const transaction = async (tx)=>{
1303
+ const signAndApply = async (mutation)=>{
1304
+ const signed = await signingIdentity.signToken(mutation);
1305
+ const jwt = stringifyToken(signed);
1306
+ let applied;
1307
+ try {
1308
+ applied = await this.#applyVerifiedMutation({
1309
+ token: jwt
1310
+ }, tx);
1311
+ } catch (err) {
1312
+ // Record the failure so the transaction rolls back, then rethrow so an
1313
+ // inner layer (e.g. graphql-js) still records it in its errors.
1314
+ writeErrors.push(err);
1315
+ throw err;
1316
+ }
1317
+ mutationResults.push(applied);
1318
+ // No `accessGate` is passed on this code path — `applied.document` is
1319
+ // guaranteed non-null.
1320
+ if (applied.document == null) {
1321
+ throw new Error('Unexpected dropped mutation in signed write (no gate configured)');
1322
+ }
1323
+ return applied.document;
1324
+ };
1325
+ // Auto-attach delegation tokens this device holds so a delegate can write
1326
+ // to documents it does not own without the app passing tokens manually.
1327
+ // Delegation storage is core-registered and always present. Read through
1328
+ // the transaction provider `tx`, not the main connection: querying the
1329
+ // main connection while this write transaction is open deadlocks on
1330
+ // single-connection SQLite.
1331
+ const atTime = Math.floor(Date.now() / 1000);
1332
+ const capTokens = new Set(params.delegationTokens ?? []);
1333
+ const delegationStore = await getDelegationStore(tx);
1334
+ const held = await delegationStore.getHeldTokens({
1335
+ audience: signingIdentity.id,
1336
+ atTime
1337
+ });
1338
+ for (const row of held){
1339
+ capTokens.add(row.token);
1340
+ }
1341
+ const cap = capTokens.size > 0 ? Array.from(capTokens) : undefined;
1342
+ // Floor the clock to the stored mutation maximum before any local mint.
1343
+ // `createMutationOperations` calls `hlc.now()` while building each
1344
+ // mutation below, so the floor must complete first for a post-restart
1345
+ // write to out-order the node's pre-restart writes under a backward clock.
1346
+ await this.#ensureHLCFloored(tx);
1347
+ const ops = createMutationOperations({
1348
+ issuer: signingIdentity.id,
1349
+ hlc: this.#hlc,
1350
+ getRandomValues: this.#runtime.getRandomValues,
1351
+ owner: params.owner,
1352
+ cap,
1353
+ processSetMutation: (mutation)=>signAndApply(mutation),
1354
+ processChangeMutation: (mutation)=>signAndApply(mutation)
1355
+ });
1356
+ return await params.drive({
1357
+ ops,
1358
+ tx,
1359
+ mutationResults,
1360
+ writeErrors
1361
+ });
1362
+ };
1363
+ let result;
1364
+ try {
1365
+ result = await this.#db.withTransaction(transaction);
1366
+ } catch (err) {
1367
+ // The transaction rolled back because a write failed. When the drive
1368
+ // carried a result out on the rollback sentinel, return it WITHOUT
1369
+ // emitting events — the writes were rolled back, so no event must fire.
1370
+ // Any other error propagates, also without emitting events.
1371
+ if (err instanceof MutateGraphWriteRollback) {
1372
+ return err.result;
1373
+ }
1374
+ throw err;
1375
+ }
1376
+ // Emit events after transaction commits.
1377
+ await this.#emitMutationEvents(mutationResults, 'local');
1378
+ return result;
1379
+ }
1380
+ async mutateGraph(params) {
1381
+ if (!isSigningIdentity(this.#identity)) {
1382
+ throw new Error('mutateGraph requires a SigningIdentity');
1383
+ }
1384
+ const signingIdentity = this.#identity;
1385
+ const graphID = params.id;
1386
+ // Warm the schema/model cache before opening the write transaction. Building
1387
+ // the schema reads the graph model from the main connection; doing that
1388
+ // inside the transaction deadlocks on single-connection SQLite, where a read
1389
+ // cannot run while the connection's own transaction is open.
1390
+ await this.getGraphQLSchema(graphID);
1391
+ return await this.#signedWriteTransaction({
1392
+ owner: params.owner,
1393
+ delegationTokens: params.delegationTokens,
1394
+ drive: async ({ ops, tx, writeErrors })=>{
1395
+ const executed = await this.#execute({
1396
+ graphID,
1397
+ text: params.text,
1398
+ variables: params.variables ?? {},
1399
+ viewerDID: params.viewerDID ?? signingIdentity.id,
1400
+ stores: tx,
1401
+ contextExtensions: {
1402
+ executeCreateMutation: async (p)=>{
1403
+ return await ops.createDocument({
1404
+ modelID: p.modelID,
1405
+ data: p.data,
1406
+ owner: p.owner
1407
+ });
1408
+ },
1409
+ executeSetMutation: async (p)=>{
1410
+ return await ops.setDocument({
1411
+ modelID: p.modelID,
1412
+ unique: p.unique,
1413
+ data: p.data,
1414
+ owner: p.owner
1415
+ });
1416
+ },
1417
+ executeUpdateMutation: async (p)=>{
1418
+ return await ops.updateDocument({
1419
+ docID: p.input.id,
1420
+ patch: convertPatchInput(p.input.patch)
1421
+ });
1422
+ },
1423
+ executeRemoveMutation: async (p)=>{
1424
+ await ops.removeDocument({
1425
+ docID: p.id
1426
+ });
1427
+ }
1428
+ }
1429
+ });
1430
+ // A plugin mutation resolver can mark its transaction fatal by throwing
1431
+ // `TransactionFatalError`; graphql-js wraps that throw into a
1432
+ // GraphQLError with `.originalError` set to the marker (one level deep),
1433
+ // so scan both. This forces the same rollback path as a core-model write
1434
+ // failure. Ordinary read/projection resolver errors (no marker) still
1435
+ // commit, so a core-model batch's per-issuer partial success is
1436
+ // preserved.
1437
+ const pluginFatal = (executed.errors ?? []).some((e)=>isTransactionFatal(e) || isTransactionFatal(e.originalError));
1438
+ // If any write resolver failed, force the whole transaction to roll back
1439
+ // so no document is committed in part. The graphql result is carried out
1440
+ // on the sentinel so the caller still receives the `errors` payload.
1441
+ // `data` is nulled: the partial payload graphql-js built for the fields
1442
+ // that ran before the failure refers to writes that did not persist, so
1443
+ // surfacing it would be a phantom success for a caller that reads `data`
1444
+ // without checking `errors`.
1445
+ if (writeErrors.length > 0 || pluginFatal) {
1446
+ throw new MutateGraphWriteRollback({
1447
+ ...executed,
1448
+ data: null
1449
+ });
1450
+ }
1451
+ return executed;
1452
+ }
1453
+ });
1454
+ }
1455
+ /**
1456
+ * Apply a batch of document writes signed by this engine's identity, all
1457
+ * within a single signed write transaction. Unlike `mutateGraph` this does not
1458
+ * parse or execute any GraphQL text — writes are driven directly onto the
1459
+ * mutation operations. Returns the applied result for each write, in order.
1460
+ */ async mutateDocuments(params) {
1461
+ return await this.#signedWriteTransaction({
1462
+ owner: params.owner,
1463
+ delegationTokens: params.delegationTokens,
1464
+ drive: async ({ ops, mutationResults })=>{
1465
+ // Track how many results existed before this drive so only the writes
1466
+ // applied here are returned (the accumulator is shared machinery).
1467
+ const startIndex = mutationResults.length;
1468
+ for (const write of params.writes){
1469
+ switch(write.type){
1470
+ case 'create':
1471
+ await ops.createDocument({
1472
+ modelID: write.modelID,
1473
+ data: write.data
1474
+ });
1475
+ break;
1476
+ case 'set':
1477
+ await ops.setDocument({
1478
+ modelID: write.modelID,
1479
+ unique: write.unique,
1480
+ data: write.data
1481
+ });
1482
+ break;
1483
+ case 'update':
1484
+ await ops.updateDocument({
1485
+ docID: write.docID,
1486
+ patch: write.patch
1487
+ });
1488
+ break;
1489
+ case 'remove':
1490
+ await ops.removeDocument({
1491
+ docID: write.docID
1492
+ });
1493
+ break;
1494
+ }
1495
+ }
1496
+ return mutationResults.slice(startIndex);
1497
+ }
1498
+ });
1499
+ }
1500
+ async subscribeToGraph(params) {
1501
+ return await this.#subscribe({
1502
+ graphID: params.id,
1503
+ text: params.text,
1504
+ variables: params.variables ?? {},
1505
+ viewerDID: params.viewerDID
1506
+ });
1507
+ }
1508
+ async getAPI(name) {
1509
+ return await this.#registry.getAPI(name);
1510
+ }
1511
+ }