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