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