@kubun/store-graph 0.10.2 → 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/api.js CHANGED
@@ -1 +1,1209 @@
1
- import{DocumentID as e,DocumentModelID as t}from"@kubun/id";import{serializeCursor as l}from"./cursor.js";function n(e,t){if(!(e instanceof Error))return!1;let l=e.message;return l.includes("no such table")&&l.includes(t)||l.includes("does not exist")&&l.includes(t)}import{aggregateFieldName as r,applyAggregateSelections as a,applyDocumentFilter as o,applyDocumentOrderBy as u,applyGroupBySelections as s,applyPagination as i,groupKeyAlias as c}from"./query-builder.js";function d(e){if(null==e)return null;let t="number"==typeof e?e:Number(e);return Number.isNaN(t)?null:t}function m(){return{count:0,sums:new Map,fieldCounts:new Map,mins:new Map,maxes:new Map}}function _(e,t,l){e.count+=Number(l.count??0);let n=new Set,a=new Set;for(let o of t){let t=r(o.keys);switch(o.op){case"sum":case"average":if(!n.has(t)){n.add(t);let r=d(l[`sum_${t}`]);null!=r&&e.sums.set(t,(e.sums.get(t)??0)+r)}if("average"===o.op&&!a.has(t)){a.add(t);let n=d(l[`count_${t}`]);null!=n&&e.fieldCounts.set(t,(e.fieldCounts.get(t)??0)+n)}break;case"min":{let n=o.numeric?d(l[`min_${t}`]):l[`min_${t}`]??null;null!=n&&(!e.mins.has(t)||function(e,t,l){if(l){let l=d(e),n=d(t);return null!=l&&(null==n||l<n)}return e<t}(n,e.mins.get(t),o.numeric))&&e.mins.set(t,n);break}case"max":{let n=o.numeric?d(l[`max_${t}`]):l[`max_${t}`]??null;null!=n&&(!e.maxes.has(t)||function(e,t,l){if(l){let l=d(e),n=d(t);return null!=l&&(null==n||l>n)}return e>t}(n,e.maxes.get(t),o.numeric))&&e.maxes.set(t,n)}}}}function h(e,t,l){let n={};for(let a of(e&&(n.count=l.count),t)){let e=r(a.keys);switch(a.op){case"sum":n.sum??={},n.sum[e]=l.sums.get(e)??null;break;case"average":{n.average??={};let t=l.sums.get(e),r=l.fieldCounts.get(e)??0;n.average[e]=null==t||0===r?null:t/r;break}case"min":n.min??={},n.min[e]=l.mins.get(e)??null;break;case"max":n.max??={},n.max[e]=l.maxes.get(e)??null}}return n}function f(e,t){return null==e?null:e<t?-1:+(e>t)}function w(e,t){if(null!=t.isNull&&t.isNull!==(null==e)||null!=t.equalTo&&e!==t.equalTo||null!=t.notEqualTo&&e===t.notEqualTo||null!=t.in&&!t.in.includes(e)||t.notIn?.includes(e))return!1;if(null!=t.greaterThan){let l=f(e,t.greaterThan);if(null==l||l<=0)return!1}if(null!=t.greaterThanOrEqualTo){let l=f(e,t.greaterThanOrEqualTo);if(null==l||l<0)return!1}if(null!=t.lessThan){let l=f(e,t.lessThan);if(null==l||l>=0)return!1}if(null!=t.lessThanOrEqualTo){let l=f(e,t.lessThanOrEqualTo);if(null==l||l>0)return!1}return!0}function g(e,t){let l=e;for(let e of t){if(null==l||"object"!=typeof l)return null;l=l[e]}return l??null}function p(e){let t={version:e.version,name:e.name,behavior:e.behavior,interfaces:e.interfaces,schema:e.schema,fieldsMeta:e.fields_meta};return"unique"===t.behavior&&(t.uniqueFields=e.unique_fields),t}function y(e,t){return{id:t.id,model:t.model,owner:t.owner,data:t.data,createdAt:e.decodeTimestamp(t.created_at),updatedAt:t.updated_at?e.decodeTimestamp(t.updated_at):null}}function k(e,l){let n=t.fromString(l);return n.isLocal?n.toGlobal(t.fromString(e)).toString():l}async function x(e,t,l){let{id:n,graphModelID:r,model:a}=l,o=t.types;for(let l of(await e.schema.createTable(`k_${n}`).ifNotExists().addColumn("id",o.text,e=>e.notNull().primaryKey()).addColumn("owner",o.text,e=>e.notNull()).addColumn("model",o.text,e=>e.notNull()).addColumn("data",o.json).addColumn("field_hlcs",o.json).addColumn("unique",o.binary,e=>e.notNull()).addColumn("created_at",o.timestamp,e=>e.defaultTo(t.functions.now).notNull()).addColumn("updated_at",o.timestamp).execute(),await e.insertInto("kubun_graph_document_models").values({id:n,version:a.version,name:a.name,behavior:a.behavior,unique_fields:"unique"===a.behavior?t.encodeJSON(a.uniqueFields):null,interfaces:t.encodeJSON(a.interfaces),schema:t.encodeJSON(a.schema),fields_meta:t.encodeJSON(a.fieldsMeta)}).onConflict(e=>e.doNothing()).execute(),a.interfaces))await e.insertInto("kubun_graph_document_model_interfaces").values({interface_id:k(n,l),implementation_id:n}).onConflict(e=>e.doNothing()).execute();await e.insertInto("kubun_graph_document_model_links").values({graph_model_id:r,document_model_id:n}).onConflict(e=>e.doNothing()).execute()}export function createGraphStore(t,f){let b=f.coerceFilterValue.bind(f);async function S(e,t){if(null==t)return{effectiveModelIDs:e,effectiveOwners:void 0};let{models:l,owners:n}=await v.resolveCatalogScope(t),r=e;if(null!=l){let e=new Set(l);r=r.filter(t=>e.has(t))}return{effectiveModelIDs:r,effectiveOwners:n}}let v={async getDocument(l){let n=e.from(l),r=await t.selectFrom(`k_${n.model.toString()}`).selectAll().where("id","=",n.toString()).executeTakeFirst();return r?y(f,r):null},getDocumentModel:async e=>p(await t.selectFrom("kubun_graph_document_models").selectAll().where("id","=",e.toString()).executeTakeFirstOrThrow()),getModelInterfaces:async e=>(await t.selectFrom("kubun_graph_document_model_interfaces").select("interface_id").where("implementation_id","=",e).execute()).map(e=>e.interface_id),async createDocument(e){let l=e.id.toString(),n=e.id.model.toString(),r=await t.insertInto(`k_${n}`).values({id:l,owner:e.owner,model:n,data:f.encodeJSON(e.data),unique:f.encodeBinary(e.unique)}).returningAll().executeTakeFirstOrThrow(()=>Error(`createDocument failed to return row for ${l}`));return y(f,r)},async saveDocument(e){let l=e.id.toString(),n=await t.updateTable(`k_${e.id.model.toString()}`).set({data:e.data?f.encodeJSON(e.data):null,updated_at:f.encodeTimestamp(new Date)}).where("id","=",l).returningAll().executeTakeFirstOrThrow(()=>Error(`saveDocument: document not found for id=${l}`));return y(f,n)},async queryDocuments(e){let{catalogID:n,filter:r,modelIDs:a,orderBy:s,owner:c,...d}=e,m=null!=e.last,{effectiveModelIDs:_,effectiveOwners:h}=await S(a,n);if(0===_.length)return{entries:[],hasMore:!1};let[w,...g]=_,p=t.selectFrom(`k_${w}`).selectAll().where("data","is not",null);for(let e of(null!=c&&(p=p.where("owner","=",c)),null!=h&&(p=p.where("owner","in",h)),null!=r&&(p=p.where(e=>o(e,r,[],b))),g))p=p.unionAll(t=>{let l=t.selectFrom(`k_${e}`).selectAll().where("data","is not",null);return null!=c&&(l=l.where("owner","=",c)),null!=h&&(l=l.where("owner","in",h)),null!=r&&(l=l.where(e=>o(e,r,[],b))),l});let[k,x]=u(p,s,m),[v,F]=i(k,d,s),D=await v.execute(),C=m?D.slice(0,F).reverse():D.slice(0,F);return{entries:x?C.map(e=>{let t={};for(let l of x){let n=e.data;for(let e of l)null!=n&&(n=n[e]);null!=n&&(t[l.join(".")]=n)}return{cursor:l({id:e.id,values:t}),document:y(f,e)}}):C.map(e=>({cursor:l({id:e.id,ts:+e.created_at}),document:y(f,e)})),hasMore:D.length>F}},async aggregateDocuments(e){let{aggregates:l,catalogID:u,count:s,filter:i,modelIDs:c,owner:d,readAccess:w}=e,g=function(e,t){let l={};for(let n of(e&&(l.count=0),t)){let e=r(n.keys);switch(n.op){case"sum":l.sum??={},l.sum[e]=null;break;case"average":l.average??={},l.average[e]=null;break;case"min":l.min??={},l.min[e]=null;break;case"max":l.max??={},l.max[e]=null}}return l}(s,l);if(!s&&0===l.length)return g;let{effectiveModelIDs:p,effectiveOwners:y}=await S(c,u);if(0===p.length)return g;let k=await Promise.all(p.map(async e=>{let r=t.selectFrom(`k_${e}`).select(e=>{let t=a(e,l,f);return s&&t.push(e.fn.countAll().as("count")),t});r=r.where("data","is not",null),null!=d&&(r=r.where("owner","=",d)),null!=y&&(r=r.where("owner","in",y)),null!=i&&(r=r.where(e=>o(e,i,[],b))),null!=w&&(r=r.where(f.readAccessPredicate(w,e)));try{return await r.executeTakeFirst()}catch(t){if(n(t,`k_${e}`))return;throw t}})),x=m(),v=!1;for(let e of k)null!=e&&(v=!0,_(x,l,e));return v?h(s,l,x):g},async groupedAggregateDocuments(e){let{aggregates:l,catalogID:u,count:i,filter:p,groupBy:y,having:k,limit:x,modelIDs:v,orderBy:F,owner:D,readAccess:C}=e,{effectiveModelIDs:T,effectiveOwners:N}=await S(v,u);if(0===T.length)return[];let I=await Promise.all(T.map(async e=>{let r=t.selectFrom(`k_${e}`).select(e=>{let{selections:t}=s(e,y),n=[...t,...a(e,l,f)];return i&&n.push(e.fn.countAll().as("count")),n});r=(r=r.groupBy(e=>s(e,y).expressions)).where("data","is not",null),null!=D&&(r=r.where("owner","=",D)),null!=N&&(r=r.where("owner","in",N)),null!=p&&(r=r.where(e=>o(e,p,[],b))),null!=C&&(r=r.where(f.readAccessPredicate(C,e)));try{return await r.execute()}catch(t){if(n(t,`k_${e}`))return[];throw t}})),A=new Map;for(let e of I)for(let t of e){let e={};for(let l of y){let n=t[c(l.keys)];e[r(l.keys)]=n??null}let n=JSON.stringify(y.map(t=>e[r(t.keys)])),a=A.get(n);null==a&&(a={keys:e,acc:m()},A.set(n,a)),_(a.acc,l,t)}let O=Array.from(A.values()).map(e=>({keys:e.keys,...h(i,l,e.acc)})),M=null==k?O:O.filter(e=>(function(e,t){if(null!=t.count&&!w(e.count??null,t.count))return!1;for(let l of["sum","average","min","max"]){let n=t[l];if(null==n)continue;let r=e[l];for(let[e,t]of Object.entries(n))if(!w(r?.[e]??null,t))return!1}return!0})(e,k)),$=null!=F&&F.length>0?[...M].sort((e,t)=>(function(e,t,l){for(let n of l){let l=function(e,t,l){let n,r=g(e,l.path),a=g(t,l.path),o=l.numeric?d(r):r,u=l.numeric?d(a):a,s=null==o,i=null==u;return s&&i?0:s?1:i?-1:(n=o<u?-1:+(o>u),"desc"===l.direction?-n:n)}(e,t,n);if(0!==l)return l}return 0})(e,t,F)):M;return null!=x?$.slice(0,Math.max(0,x)):$},async listDocuments(e){let[l,...n]=e.modelIDs,r=t.selectFrom(`k_${l}`).selectAll().where("id","in",e.docIDs).where("data","is not",null);for(let t of n)r=r.unionAll(l=>l.selectFrom(`k_${t}`).selectAll().where("id","in",e.docIDs).where("data","is not",null));return(await r.execute()).map(e=>y(f,e))},listDocumentModelIDs:async()=>(await t.selectFrom("kubun_graph_document_models").select("id").execute()).map(e=>e.id),async getDistinctOwnersForModel(e){try{return(await t.selectFrom(`k_${e}`).select("owner").distinct().execute()).map(e=>e.owner)}catch(t){if(n(t,`k_${e}`))return[];throw t}},queryDocumentsByOwner:async(e,l)=>(await t.selectFrom(`k_${e}`).selectAll().where("owner","=",l).execute()).map(e=>y(f,e)),async insertMutationLogEntry(e){await t.insertInto("kubun_graph_mutation_log").values(e).onConflict(e=>e.doNothing()).execute()},hasMutationHash:async e=>null!=await t.selectFrom("kubun_graph_mutation_log").select("mutation_hash").where("mutation_hash","=",e).executeTakeFirst(),async updateMutationStatus(e,l){await t.updateTable("kubun_graph_mutation_log").set({status:l}).where("mutation_hash","=",e).execute()},getMutationLogForDocuments:async e=>0===e.length?[]:await t.selectFrom("kubun_graph_mutation_log").selectAll().where("document_id","in",e).orderBy("hlc","asc").execute(),async getMaxMutationHLC(){let e=await t.selectFrom("kubun_graph_mutation_log").select(e=>e.fn.max("hlc").as("max_hlc")).executeTakeFirst();return e?.max_hlc??null},getPendingMutations:async e=>await t.selectFrom("kubun_graph_mutation_log").selectAll().where("document_id","=",e).where("status","=","pending").orderBy("hlc","asc").execute(),async getFieldHLCs(l){let n=e.fromString(l).model.toString(),r=await t.selectFrom(`k_${n}`).select("field_hlcs").where("id","=",l).executeTakeFirst();if(r?.field_hlcs==null)return null;let a=r.field_hlcs;return"string"==typeof a?JSON.parse(a):a},async updateFieldHLCs(e,l){let n=e.model.toString();await t.updateTable(`k_${n}`).set({field_hlcs:f.encodeJSON(l)}).where("id","=",e.toString()).execute()},listGraphs:async()=>await t.selectFrom("kubun_graph_models").selectAll().execute(),async createGraph(e){let l=e.id,n=e.name??l;return await t.transaction().setIsolationLevel("read committed").execute(async t=>{let r=e.pluginConfig?f.encodeJSON(e.pluginConfig):null;await t.insertInto("kubun_graph_models").values({aliases:f.encodeJSON(e.aliases??{}),extension_sdl:e.extensionSDL??null,id:l,name:n,plugin_config:r,search:e.search?f.encodeJSON(e.search):null}).onConflict(t=>null!=e.pluginConfig?t.column("id").doUpdateSet({extension_sdl:e.extensionSDL??null,plugin_config:r}):t.doNothing()).execute();let a=Object.entries(e.record),o=new Set;for(;o.size<a.length;){let e=a.filter(([e,t])=>!o.has(e)&&t.interfaces.every(t=>o.has(k(e,t))));if(0===e.length)throw Error("Could not create graph: no valid model to add");await Promise.all(e.map(async([e,n])=>{await x(t,f,{id:e,graphModelID:l,model:n}),o.add(e)}))}}),l},async getGraph(e){let l=await t.selectFrom("kubun_graph_models").selectAll().where("id","=",e).executeTakeFirst();if(null==l)return null;let n={};for(let l of(await t.selectFrom("kubun_graph_document_model_links").innerJoin("kubun_graph_document_models","kubun_graph_document_model_links.document_model_id","kubun_graph_document_models.id").selectAll("kubun_graph_document_models").where("kubun_graph_document_model_links.graph_model_id","=",e).execute()))n[l.id]=p(l);return{...l,record:n}},async getClusterForModel(e){let l=await t.selectFrom("kubun_graph_cluster_models").select("cluster_id").where("model_id","=",e).executeTakeFirst();return l?.cluster_id},getClusterModels:async e=>await t.selectFrom("kubun_graph_cluster_models").selectAll().where("cluster_id","=",e).orderBy("cluster_index").execute(),async saveCluster(e,l){await t.insertInto("kubun_graph_clusters").values({id:e,definition:f.encodeJSON(l)}).onConflict(e=>e.column("id").doUpdateSet(e=>({definition:e.ref("excluded.definition")}))).execute()},getCluster:async e=>await t.selectFrom("kubun_graph_clusters").selectAll().where("id","=",e).executeTakeFirst(),async getClusters(e){if(0===e.length)return{};let l=await t.selectFrom("kubun_graph_clusters").selectAll().where("id","in",e).execute(),n={};for(let e of l)n[e.id]=e.definition;return n},async addAttachments(e){await t.insertInto("kubun_graph_document_attachments").values(e).onConflict(e=>e.doNothing()).execute()},async createSearchIndex(e,l){await f.createSearchIndex(t,{modelID:e,fields:l})},async dropSearchIndex(e){await f.dropSearchIndex(t,e)},async updateSearchEntry(e,l,n,r){let a={};for(let e of r){let t=function(e,t){let l=t.split("."),n=e;for(let e of l){if(null==n||"object"!=typeof n)return null;n=n[e]}return n}(n,e);null!=t&&(a[e]=String(t))}await f.updateSearchEntry(t,e,l,a,r)},async removeSearchEntry(e,l){await f.removeSearchEntry(t,e,l)},async searchDocuments(e){let l=[];for(let n of e.modelIDs)for(let r of(await f.searchIndex(t,{query:e.query,modelID:n,limit:e.first??50})))l.push({documentID:r.documentID,modelID:n,rank:r.rank});return(l.sort((e,t)=>Math.abs(t.rank)-Math.abs(e.rank)),e.first)?l.slice(0,e.first):l},async createCatalog(e){await t.insertInto("kubun_graph_catalogs").values({id:e.id,owner_did:e.owner_did,name:e.name,description:e.description,filter_criteria:f.encodeJSON(e.filter_criteria),hlc:e.hlc}).execute()},getCatalog:async e=>await t.selectFrom("kubun_graph_catalogs").selectAll().where("id","=",e).executeTakeFirst(),async getCatalogs(e){let l=new Map;if(0===e.length)return l;for(let n of(await t.selectFrom("kubun_graph_catalogs").selectAll().where("id","in",e).execute()))l.set(n.id,n);return l},async updateCatalog(e,l){let n={};null!=l.name&&(n.name=l.name),null!=l.description&&(n.description=l.description),null!=l.filter_criteria&&(n.filter_criteria=f.encodeJSON(l.filter_criteria)),null!=l.hlc&&(n.hlc=l.hlc),n.updated_at=f.encodeTimestamp(new Date),await t.updateTable("kubun_graph_catalogs").set(n).where("id","=",e).execute()},async deleteCatalog(e){await t.deleteFrom("kubun_graph_catalogs").where("id","=",e).execute()},listCatalogs:async e=>await t.selectFrom("kubun_graph_catalogs").selectAll().where("owner_did","=",e).execute(),async resolveCatalogScope(e){let t,l=await v.getCatalog(e);if(null==l)return{models:void 0,owners:void 0};let n=l.filter_criteria,r=null!=n.models&&n.models.length>0?n.models:void 0;return null!=n.owners&&n.owners.length>0&&(t=[...n.owners]),{models:r,owners:t}},async registerClusterModels(e,l){0!==l.length&&await t.insertInto("kubun_graph_cluster_models").values(l).onConflict(e=>e.column("model_id").doUpdateSet(e=>({cluster_id:e.ref("excluded.cluster_id"),cluster_index:e.ref("excluded.cluster_index")}))).execute()},async getUserModelAccessDefault(e,l,n){let r=await t.selectFrom("kubun_graph_user_model_access_defaults").select(["access_level","allowed_dids","allowed_circles","allowed_groups"]).where("owner_did","=",e).where("model_id","=",l).where("permission_type","=",n).executeTakeFirst();return r?{level:r.access_level,allowedDIDs:r.allowed_dids,allowedCircles:r.allowed_circles,allowedGroups:r.allowed_groups}:null},async setUserModelAccessDefault(e){await t.insertInto("kubun_graph_user_model_access_defaults").values({owner_did:e.ownerDID,model_id:e.modelID,permission_type:e.permissionType,access_level:e.accessLevel,allowed_dids:e.allowedDIDs?f.encodeJSON(e.allowedDIDs):null,allowed_circles:e.allowedCircles?f.encodeJSON(e.allowedCircles):null,allowed_groups:e.allowedGroups?f.encodeJSON(e.allowedGroups):null}).onConflict(t=>t.columns(["owner_did","model_id","permission_type"]).doUpdateSet({access_level:e.accessLevel,allowed_dids:e.allowedDIDs?f.encodeJSON(e.allowedDIDs):null,allowed_circles:e.allowedCircles?f.encodeJSON(e.allowedCircles):null,allowed_groups:e.allowedGroups?f.encodeJSON(e.allowedGroups):null,updated_at:f.encodeTimestamp(new Date)})).execute()},async removeUserModelAccessDefaults(e,l,n){await t.deleteFrom("kubun_graph_user_model_access_defaults").where("owner_did","=",e).where("model_id","=",l).where("permission_type","in",n).execute()},async getDocumentIDsForScope(e,l=[]){let r=[];for(let l of e)try{let e=await t.selectFrom(`k_${l.modelID}`).select("id").where("owner","=",l.ownerDID).execute();r.push(...e.map(e=>e.id))}catch(e){if(!n(e,`k_${l.modelID}`))throw e}if(l.length>0){let e=new Set(l);return r.filter(t=>!e.has(t))}return r},getDocumentMetadataForSync:async(e,l)=>0===l.length?[]:(await t.selectFrom(`k_${e}`).select(["id","model","owner"]).where("id","in",l).execute()).map(e=>({id:e.id,model:e.model,owner:e.owner}))};return v}
1
+ import { DocumentID, DocumentModelID } from '@kubun/id';
2
+ import { serializeCursor } from './cursor.js';
3
+ import { isMissingTableError, ModelNotDeployedError } from './errors.js';
4
+ import { aggregateFieldName, applyAggregateSelections, applyDocumentFilter, applyDocumentOrderBy, applyGroupBySelections, applyPagination, groupKeyAlias, knownFieldColumn } from './query-builder.js';
5
+ // --- Helper functions ---
6
+ // SQL numeric results round-trip as strings on some adapters (Postgres `numeric`/`sum`),
7
+ // so normalise to a JS number, treating NULL/empty as null.
8
+ function toNumberOrNull(value) {
9
+ if (value == null) return null;
10
+ const num = typeof value === 'number' ? value : Number(value);
11
+ return Number.isNaN(num) ? null : num;
12
+ }
13
+ function emptyAggregateValues(count, aggregates) {
14
+ const result = {};
15
+ if (count) result.count = 0;
16
+ for (const spec of aggregates){
17
+ const field = aggregateFieldName(spec.keys);
18
+ switch(spec.op){
19
+ case 'sum':
20
+ result.sum ??= {};
21
+ result.sum[field] = null;
22
+ break;
23
+ case 'average':
24
+ result.average ??= {};
25
+ result.average[field] = null;
26
+ break;
27
+ case 'min':
28
+ result.min ??= {};
29
+ result.min[field] = null;
30
+ break;
31
+ case 'max':
32
+ result.max ??= {};
33
+ result.max[field] = null;
34
+ break;
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+ function createAggregateAccumulator() {
40
+ return {
41
+ count: 0,
42
+ sums: new Map(),
43
+ fieldCounts: new Map(),
44
+ mins: new Map(),
45
+ maxes: new Map()
46
+ };
47
+ }
48
+ function isLess(candidate, current, numeric) {
49
+ if (numeric) {
50
+ const a = toNumberOrNull(candidate);
51
+ const b = toNumberOrNull(current);
52
+ if (a == null) return false;
53
+ if (b == null) return true;
54
+ return a < b;
55
+ }
56
+ return candidate < current;
57
+ }
58
+ function isGreater(candidate, current, numeric) {
59
+ if (numeric) {
60
+ const a = toNumberOrNull(candidate);
61
+ const b = toNumberOrNull(current);
62
+ if (a == null) return false;
63
+ if (b == null) return true;
64
+ return a > b;
65
+ }
66
+ return candidate > current;
67
+ }
68
+ function accumulateAggregateRow(acc, aggregates, row) {
69
+ acc.count += Number(row.count ?? 0);
70
+ // `sum` and `average` on the same field share the `sum_<field>` column, so add each
71
+ // raw column at most once per row to avoid double-counting overlapping specs.
72
+ const summedFields = new Set();
73
+ const countedFields = new Set();
74
+ for (const spec of aggregates){
75
+ const field = aggregateFieldName(spec.keys);
76
+ switch(spec.op){
77
+ case 'sum':
78
+ case 'average':
79
+ {
80
+ if (!summedFields.has(field)) {
81
+ summedFields.add(field);
82
+ const value = toNumberOrNull(row[`sum_${field}`]);
83
+ if (value != null) {
84
+ acc.sums.set(field, (acc.sums.get(field) ?? 0) + value);
85
+ }
86
+ }
87
+ if (spec.op === 'average' && !countedFields.has(field)) {
88
+ countedFields.add(field);
89
+ const fieldCount = toNumberOrNull(row[`count_${field}`]);
90
+ if (fieldCount != null) {
91
+ acc.fieldCounts.set(field, (acc.fieldCounts.get(field) ?? 0) + fieldCount);
92
+ }
93
+ }
94
+ break;
95
+ }
96
+ case 'min':
97
+ {
98
+ const value = spec.numeric ? toNumberOrNull(row[`min_${field}`]) : row[`min_${field}`] ?? null;
99
+ if (value != null) {
100
+ if (!acc.mins.has(field) || isLess(value, acc.mins.get(field), spec.numeric)) {
101
+ acc.mins.set(field, value);
102
+ }
103
+ }
104
+ break;
105
+ }
106
+ case 'max':
107
+ {
108
+ const value = spec.numeric ? toNumberOrNull(row[`max_${field}`]) : row[`max_${field}`] ?? null;
109
+ if (value != null) {
110
+ if (!acc.maxes.has(field) || isGreater(value, acc.maxes.get(field), spec.numeric)) {
111
+ acc.maxes.set(field, value);
112
+ }
113
+ }
114
+ break;
115
+ }
116
+ }
117
+ }
118
+ }
119
+ function finalizeAggregateValues(count, aggregates, acc) {
120
+ const result = {};
121
+ if (count) result.count = acc.count;
122
+ for (const spec of aggregates){
123
+ const field = aggregateFieldName(spec.keys);
124
+ switch(spec.op){
125
+ case 'sum':
126
+ {
127
+ result.sum ??= {};
128
+ result.sum[field] = acc.sums.get(field) ?? null;
129
+ break;
130
+ }
131
+ case 'average':
132
+ {
133
+ result.average ??= {};
134
+ const sum = acc.sums.get(field);
135
+ const fieldCount = acc.fieldCounts.get(field) ?? 0;
136
+ result.average[field] = sum == null || fieldCount === 0 ? null : sum / fieldCount;
137
+ break;
138
+ }
139
+ case 'min':
140
+ {
141
+ result.min ??= {};
142
+ result.min[field] = acc.mins.get(field) ?? null;
143
+ break;
144
+ }
145
+ case 'max':
146
+ {
147
+ result.max ??= {};
148
+ result.max[field] = acc.maxes.get(field) ?? null;
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ return result;
154
+ }
155
+ // Ordered comparison between a group value and a bound. Numeric when both operands
156
+ // are numbers ('9' < '10'), otherwise lexical, matching the min/max/aggregate merge
157
+ // logic in this file. A null group value yields null (it satisfies no ordering),
158
+ // letting the caller fail ordered operators on nulls per SQL semantics.
159
+ function compareHavingValue(value, bound) {
160
+ if (value == null) return null;
161
+ if (typeof value === 'number' && typeof bound === 'number') {
162
+ return value < bound ? -1 : value > bound ? 1 : 0;
163
+ }
164
+ return value < bound ? -1 : value > bound ? 1 : 0;
165
+ }
166
+ // Evaluate one comparison clause against a finalised group value, supporting the
167
+ // full value-filter operator set. Every present operator must hold (logical AND).
168
+ // `isNull` is the only operator a null value can satisfy; the ordered operators
169
+ // (lessThan etc.) fail on null, matching SQL NULL semantics.
170
+ function passesHavingComparison(value, comparison) {
171
+ if (comparison.isNull != null && comparison.isNull !== (value == null)) return false;
172
+ if (comparison.equalTo != null && value !== comparison.equalTo) return false;
173
+ if (comparison.notEqualTo != null && value === comparison.notEqualTo) return false;
174
+ if (comparison.in != null && !comparison.in.includes(value)) return false;
175
+ if (comparison.notIn?.includes(value)) return false;
176
+ if (comparison.greaterThan != null) {
177
+ const cmp = compareHavingValue(value, comparison.greaterThan);
178
+ if (cmp == null || cmp <= 0) return false;
179
+ }
180
+ if (comparison.greaterThanOrEqualTo != null) {
181
+ const cmp = compareHavingValue(value, comparison.greaterThanOrEqualTo);
182
+ if (cmp == null || cmp < 0) return false;
183
+ }
184
+ if (comparison.lessThan != null) {
185
+ const cmp = compareHavingValue(value, comparison.lessThan);
186
+ if (cmp == null || cmp >= 0) return false;
187
+ }
188
+ if (comparison.lessThanOrEqualTo != null) {
189
+ const cmp = compareHavingValue(value, comparison.lessThanOrEqualTo);
190
+ if (cmp == null || cmp > 0) return false;
191
+ }
192
+ return true;
193
+ }
194
+ // Whether a finalised, merged group satisfies the having predicate. Every present
195
+ // clause (count, and each field under sum/average/min/max) must pass.
196
+ function passesHaving(group, having) {
197
+ if (having.count != null && !passesHavingComparison(group.count ?? null, having.count)) {
198
+ return false;
199
+ }
200
+ for (const op of [
201
+ 'sum',
202
+ 'average',
203
+ 'min',
204
+ 'max'
205
+ ]){
206
+ const clauses = having[op];
207
+ if (clauses == null) continue;
208
+ const values = group[op];
209
+ for (const [field, comparison] of Object.entries(clauses)){
210
+ // Pass the raw value: count/sum/average finalise as numbers, while min/max may
211
+ // be strings, which must compare lexically rather than coerce to null.
212
+ if (!passesHavingComparison(values?.[field] ?? null, comparison)) return false;
213
+ }
214
+ }
215
+ return true;
216
+ }
217
+ // Read the value at an ordering path from a finalised group object, e.g.
218
+ // ['sum', 'delta'] or ['keys', 'area']. A missing intermediate or leaf yields null.
219
+ function readGroupPath(group, path) {
220
+ let value = group;
221
+ for (const key of path){
222
+ if (value == null || typeof value !== 'object') return null;
223
+ value = value[key];
224
+ }
225
+ return value ?? null;
226
+ }
227
+ // Compare two group values for one ordering term. Returns a negative, zero, or
228
+ // positive number. A null/absent value always sorts last regardless of direction;
229
+ // the non-null ordering is flipped for 'desc'. Numeric terms coerce both sides to a
230
+ // number, otherwise comparison is lexical (so '9' < '10' only when numeric).
231
+ function compareGroupTerm(a, b, term) {
232
+ const rawA = readGroupPath(a, term.path);
233
+ const rawB = readGroupPath(b, term.path);
234
+ const valueA = term.numeric ? toNumberOrNull(rawA) : rawA;
235
+ const valueB = term.numeric ? toNumberOrNull(rawB) : rawB;
236
+ const aMissing = valueA == null;
237
+ const bMissing = valueB == null;
238
+ if (aMissing && bMissing) return 0;
239
+ if (aMissing) return 1;
240
+ if (bMissing) return -1;
241
+ let result;
242
+ if (valueA < valueB) result = -1;
243
+ else if (valueA > valueB) result = 1;
244
+ else result = 0;
245
+ return term.direction === 'desc' ? -result : result;
246
+ }
247
+ // Stable multi-term comparator over finalised groups: terms are tried in order and
248
+ // the first non-equal term decides; later terms break ties.
249
+ function compareGroups(a, b, orderBy) {
250
+ for (const term of orderBy){
251
+ const result = compareGroupTerm(a, b, term);
252
+ if (result !== 0) return result;
253
+ }
254
+ return 0;
255
+ }
256
+ function documentModelFromTable(row) {
257
+ const model = {
258
+ version: row.version,
259
+ name: row.name,
260
+ behavior: row.behavior,
261
+ interfaces: row.interfaces,
262
+ schema: row.schema,
263
+ fieldsMeta: row.fields_meta
264
+ };
265
+ if (model.behavior === 'unique') {
266
+ model.uniqueFields = row.unique_fields;
267
+ }
268
+ return model;
269
+ }
270
+ function documentModelToTable(adapter, id, model) {
271
+ return {
272
+ id,
273
+ version: model.version,
274
+ name: model.name,
275
+ behavior: model.behavior,
276
+ unique_fields: model.behavior === 'unique' ? adapter.encodeJSON(model.uniqueFields) : null,
277
+ interfaces: adapter.encodeJSON(model.interfaces),
278
+ schema: adapter.encodeJSON(model.schema),
279
+ fields_meta: adapter.encodeJSON(model.fieldsMeta)
280
+ };
281
+ }
282
+ function documentNodeFromTable(adapter, doc) {
283
+ return {
284
+ id: doc.id,
285
+ model: doc.model,
286
+ owner: doc.owner,
287
+ data: doc.data,
288
+ createdAt: adapter.decodeTimestamp(doc.created_at),
289
+ updatedAt: doc.updated_at ? adapter.decodeTimestamp(doc.updated_at) : null
290
+ };
291
+ }
292
+ function toGlobalInterfaceID(modelID, interfaceID) {
293
+ const id = DocumentModelID.fromString(interfaceID);
294
+ return id.isLocal ? id.toGlobal(DocumentModelID.fromString(modelID)).toString() : interfaceID;
295
+ }
296
+ function extractFieldValue(data, fieldPath) {
297
+ const keys = fieldPath.split('.');
298
+ let value = data;
299
+ for (const key of keys){
300
+ if (value == null || typeof value !== 'object') return null;
301
+ value = value[key];
302
+ }
303
+ return value;
304
+ }
305
+ // --- Private helper for adding a document model within a transaction ---
306
+ async function addDocumentModel(transaction, adapter, params) {
307
+ const { id, graphModelID, model } = params;
308
+ const t = adapter.types;
309
+ await transaction.schema.createTable(`k_${id}`).ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('owner', t.text, (col)=>col.notNull()).addColumn('model', t.text, (col)=>col.notNull()).addColumn('data', t.json).addColumn('field_hlcs', t.json).addColumn('field_values', t.json).addColumn('unique', t.binary, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>{
310
+ return col.defaultTo(adapter.functions.now).notNull();
311
+ }).addColumn('updated_at', t.timestamp).execute();
312
+ await transaction.insertInto('kubun_graph_document_models').values(documentModelToTable(adapter, id, model)).onConflict((oc)=>oc.doNothing()).execute();
313
+ for (const interfaceID of model.interfaces){
314
+ await transaction.insertInto('kubun_graph_document_model_interfaces').values({
315
+ interface_id: toGlobalInterfaceID(id, interfaceID),
316
+ implementation_id: id
317
+ }).onConflict((oc)=>oc.doNothing()).execute();
318
+ }
319
+ await transaction.insertInto('kubun_graph_document_model_links').values({
320
+ graph_model_id: graphModelID,
321
+ document_model_id: id
322
+ }).onConflict((oc)=>oc.doNothing()).execute();
323
+ }
324
+ // --- Factory function ---
325
+ export function createGraphStore(db, adapter) {
326
+ const coerce = adapter.coerceFilterValue.bind(adapter);
327
+ async function resolveCatalogFilter(modelIDs, catalogID) {
328
+ if (catalogID == null) {
329
+ return {
330
+ effectiveModelIDs: modelIDs,
331
+ effectiveOwners: undefined
332
+ };
333
+ }
334
+ const { models, owners } = await api.resolveCatalogScope(catalogID);
335
+ let effectiveModelIDs = modelIDs;
336
+ if (models != null) {
337
+ const catalogModelSet = new Set(models);
338
+ effectiveModelIDs = effectiveModelIDs.filter((id)=>catalogModelSet.has(id));
339
+ }
340
+ return {
341
+ effectiveModelIDs,
342
+ effectiveOwners: owners
343
+ };
344
+ }
345
+ const api = {
346
+ // --- Document operations ---
347
+ async getDocument (id) {
348
+ const parsedID = DocumentID.from(id);
349
+ const modelID = parsedID.model.toString();
350
+ const doc = await db.selectFrom(`k_${modelID}`).selectAll().where('id', '=', parsedID.toString()).executeTakeFirst().catch((err)=>{
351
+ // A single-document read names its model, so an undeployed one is a
352
+ // distinct answer from an absent document and not an empty result:
353
+ // the id may well exist on a peer that holds the model.
354
+ if (isMissingTableError(err, `k_${modelID}`)) {
355
+ throw new ModelNotDeployedError(modelID, {
356
+ cause: err
357
+ });
358
+ }
359
+ throw err;
360
+ });
361
+ return doc ? documentNodeFromTable(adapter, doc) : null;
362
+ },
363
+ async getDocuments (ids) {
364
+ const result = new Map();
365
+ if (ids.length === 0) return result;
366
+ // Group by model so each model's table is read once with every id asked
367
+ // for, rather than once per id.
368
+ const idsByModel = new Map();
369
+ for (const id of ids){
370
+ const parsedID = DocumentID.from(id);
371
+ const modelID = parsedID.model.toString();
372
+ const modelIDs = idsByModel.get(modelID);
373
+ if (modelIDs == null) {
374
+ idsByModel.set(modelID, [
375
+ parsedID.toString()
376
+ ]);
377
+ } else {
378
+ modelIDs.push(parsedID.toString());
379
+ }
380
+ }
381
+ // One read per model, all issued together: they touch disjoint tables, so
382
+ // a pooled adapter overlaps them and a serial one costs what it did. The
383
+ // rows are folded in `idsByModel` order afterwards, so what wins a
384
+ // duplicate id does not depend on which query returned first.
385
+ const rowsByModel = await Promise.all(Array.from(idsByModel, async ([modelID, documentIDs])=>{
386
+ try {
387
+ return await db.selectFrom(`k_${modelID}`).selectAll().where('id', 'in', documentIDs).execute();
388
+ } catch (err) {
389
+ // A batch spans models, so one undeployed model contributes nothing
390
+ // rather than failing the read for every other id asked for. The
391
+ // single-id read throws `ModelNotDeployedError` instead: there, the
392
+ // undeployed model is the whole answer.
393
+ if (!isMissingTableError(err, `k_${modelID}`)) throw err;
394
+ return [];
395
+ }
396
+ }));
397
+ for (const docs of rowsByModel){
398
+ for (const doc of docs){
399
+ const node = documentNodeFromTable(adapter, doc);
400
+ result.set(node.id, node);
401
+ }
402
+ }
403
+ return result;
404
+ },
405
+ async getDocumentModel (id) {
406
+ const row = await db.selectFrom('kubun_graph_document_models').selectAll().where('id', '=', id.toString()).executeTakeFirstOrThrow();
407
+ return documentModelFromTable(row);
408
+ },
409
+ async getModelInterfaces (modelID) {
410
+ const rows = await db.selectFrom('kubun_graph_document_model_interfaces').select('interface_id').where('implementation_id', '=', modelID).execute();
411
+ return rows.map((row)=>row.interface_id);
412
+ },
413
+ async createDocument (params) {
414
+ const id = params.id.toString();
415
+ const model = params.id.model.toString();
416
+ const doc = await db.insertInto(`k_${model}`).values({
417
+ id,
418
+ owner: params.owner,
419
+ model,
420
+ data: adapter.encodeJSON(params.data),
421
+ unique: adapter.encodeBinary(params.unique)
422
+ }).returningAll().executeTakeFirstOrThrow(()=>new Error(`createDocument failed to return row for ${id}`));
423
+ return documentNodeFromTable(adapter, doc);
424
+ },
425
+ async saveDocument (params) {
426
+ const id = params.id.toString();
427
+ const doc = await db.updateTable(`k_${params.id.model.toString()}`).set({
428
+ data: params.data ? adapter.encodeJSON(params.data) : null,
429
+ updated_at: adapter.encodeTimestamp(new Date())
430
+ }).where('id', '=', id).returningAll().executeTakeFirstOrThrow(()=>new Error(`saveDocument: document not found for id=${id}`));
431
+ return documentNodeFromTable(adapter, doc);
432
+ },
433
+ async queryDocuments (params) {
434
+ const { catalogID, filter, modelIDs, orderBy, owner, ...pagination } = params;
435
+ const isReverse = params.last != null;
436
+ const { effectiveModelIDs, effectiveOwners } = await resolveCatalogFilter(modelIDs, catalogID);
437
+ if (effectiveModelIDs.length === 0) {
438
+ return {
439
+ entries: [],
440
+ hasMore: false
441
+ };
442
+ }
443
+ const [first, ...rest] = effectiveModelIDs;
444
+ // Tombstoned documents (data IS NULL) stay on disk for sync/CRDT but must
445
+ // never surface in read queries.
446
+ let query = db.selectFrom(`k_${first}`).selectAll().where('data', 'is not', null);
447
+ if (owner != null) {
448
+ query = query.where('owner', '=', owner);
449
+ }
450
+ if (effectiveOwners != null) {
451
+ query = query.where('owner', 'in', effectiveOwners);
452
+ }
453
+ if (filter != null) {
454
+ query = query.where((eb)=>applyDocumentFilter(eb, filter, [], coerce));
455
+ }
456
+ for (const model of rest){
457
+ query = query.unionAll((eb)=>{
458
+ let q = eb.selectFrom(`k_${model}`).selectAll().where('data', 'is not', null);
459
+ if (owner != null) {
460
+ q = q.where('owner', '=', owner);
461
+ }
462
+ if (effectiveOwners != null) {
463
+ q = q.where('owner', 'in', effectiveOwners);
464
+ }
465
+ if (filter != null) {
466
+ q = q.where((eb)=>applyDocumentFilter(eb, filter, [], coerce));
467
+ }
468
+ return q;
469
+ });
470
+ }
471
+ const [orderedQuery, orderedFieldsPaths] = applyDocumentOrderBy(query, adapter, orderBy, isReverse);
472
+ const [paginatedQuery, limit] = applyPagination(orderedQuery, pagination, orderBy, coerce);
473
+ const results = await paginatedQuery.execute();
474
+ const docs = isReverse ? results.slice(0, limit).reverse() : results.slice(0, limit);
475
+ const entries = orderedFieldsPaths ? docs.map((document)=>{
476
+ const values = {};
477
+ const nullKeys = [];
478
+ for (const path of orderedFieldsPaths){
479
+ const column = knownFieldColumn(path);
480
+ let value;
481
+ if (column != null) {
482
+ // Known fields order on a non-null table column, not a JSON key inside
483
+ // `data`. Read the ordered value from the row column so the next page's
484
+ // keyset predicate compares against a real value instead of treating
485
+ // every row as the null bucket.
486
+ value = document[column];
487
+ } else {
488
+ value = document.data;
489
+ for (const key of path){
490
+ if (value != null) {
491
+ value = value[key];
492
+ } else {
493
+ value = undefined;
494
+ }
495
+ }
496
+ }
497
+ const key = path.join('.');
498
+ if (value == null) {
499
+ // Tag the null ordered value explicitly so the keyset predicate places
500
+ // this row in the null bucket instead of losing the ordering dimension.
501
+ nullKeys.push(key);
502
+ } else {
503
+ values[key] = value;
504
+ }
505
+ }
506
+ const cursor = serializeCursor({
507
+ id: document.id,
508
+ values,
509
+ nullKeys
510
+ });
511
+ return {
512
+ cursor,
513
+ document: documentNodeFromTable(adapter, document)
514
+ };
515
+ }) : docs.map((document)=>{
516
+ const cursor = serializeCursor({
517
+ id: document.id,
518
+ ts: +document.created_at
519
+ });
520
+ return {
521
+ cursor,
522
+ document: documentNodeFromTable(adapter, document)
523
+ };
524
+ });
525
+ return {
526
+ entries,
527
+ hasMore: results.length > limit
528
+ };
529
+ },
530
+ async aggregateDocuments (params) {
531
+ const { aggregates, catalogID, count, filter, modelIDs, owner, readAccess } = params;
532
+ const empty = emptyAggregateValues(count, aggregates);
533
+ // No count and no aggregate ops requested: there is nothing to select, so
534
+ // skip the query entirely rather than build an invalid empty SELECT.
535
+ if (!count && aggregates.length === 0) return empty;
536
+ const { effectiveModelIDs, effectiveOwners } = await resolveCatalogFilter(modelIDs, catalogID);
537
+ if (effectiveModelIDs.length === 0) return empty;
538
+ // Aggregate each model in its own table, then combine at the raw sum/count/min/max
539
+ // level so interface-connection results stay correct: averages combine as Σsum /
540
+ // Σcount across the implementing models rather than averaging per-model means.
541
+ const rows = await Promise.all(effectiveModelIDs.map(async (modelID)=>{
542
+ let query = db.selectFrom(`k_${modelID}`).select((eb)=>{
543
+ const selections = applyAggregateSelections(eb, aggregates, adapter);
544
+ if (count) {
545
+ selections.push(eb.fn.countAll().as('count'));
546
+ }
547
+ return selections;
548
+ });
549
+ query = query.where('data', 'is not', null);
550
+ if (owner != null) {
551
+ query = query.where('owner', '=', owner);
552
+ }
553
+ if (effectiveOwners != null) {
554
+ query = query.where('owner', 'in', effectiveOwners);
555
+ }
556
+ if (filter != null) {
557
+ query = query.where((eb)=>applyDocumentFilter(eb, filter, [], coerce));
558
+ }
559
+ if (readAccess != null) {
560
+ query = query.where(adapter.readAccessPredicate(readAccess, modelID));
561
+ }
562
+ try {
563
+ return await query.executeTakeFirst();
564
+ } catch (err) {
565
+ // A model whose table was never materialised contributes nothing, matching
566
+ // the not-yet-materialised-table tolerance used by getDistinctOwnersForModel
567
+ // and getDocumentIDsForScope in this file.
568
+ if (isMissingTableError(err, `k_${modelID}`)) return undefined;
569
+ throw err;
570
+ }
571
+ }));
572
+ const acc = createAggregateAccumulator();
573
+ let contributed = false;
574
+ for (const row of rows){
575
+ if (row == null) continue;
576
+ contributed = true;
577
+ accumulateAggregateRow(acc, aggregates, row);
578
+ }
579
+ if (!contributed) return empty;
580
+ return finalizeAggregateValues(count, aggregates, acc);
581
+ },
582
+ async groupedAggregateDocuments (params) {
583
+ const { aggregates, catalogID, count, filter, groupBy, having, limit, modelIDs, orderBy, owner, readAccess } = params;
584
+ const { effectiveModelIDs, effectiveOwners } = await resolveCatalogFilter(modelIDs, catalogID);
585
+ if (effectiveModelIDs.length === 0) return [];
586
+ // Run the same GROUP BY query per model table in parallel, tolerating tables
587
+ // that were never materialised (they contribute nothing), mirroring the
588
+ // multi-model fan-out in aggregateDocuments.
589
+ const modelRows = await Promise.all(effectiveModelIDs.map(async (modelID)=>{
590
+ let query = db.selectFrom(`k_${modelID}`).select((eb)=>{
591
+ const { selections: keySelections } = applyGroupBySelections(eb, groupBy);
592
+ const aggregateSelections = applyAggregateSelections(eb, aggregates, adapter);
593
+ const selections = [
594
+ ...keySelections,
595
+ ...aggregateSelections
596
+ ];
597
+ if (count) {
598
+ selections.push(eb.fn.countAll().as('count'));
599
+ }
600
+ return selections;
601
+ });
602
+ query = query.groupBy((eb)=>applyGroupBySelections(eb, groupBy).expressions);
603
+ query = query.where('data', 'is not', null);
604
+ if (owner != null) {
605
+ query = query.where('owner', '=', owner);
606
+ }
607
+ if (effectiveOwners != null) {
608
+ query = query.where('owner', 'in', effectiveOwners);
609
+ }
610
+ if (filter != null) {
611
+ query = query.where((eb)=>applyDocumentFilter(eb, filter, [], coerce));
612
+ }
613
+ if (readAccess != null) {
614
+ query = query.where(adapter.readAccessPredicate(readAccess, modelID));
615
+ }
616
+ try {
617
+ return await query.execute();
618
+ } catch (err) {
619
+ if (isMissingTableError(err, `k_${modelID}`)) return [];
620
+ throw err;
621
+ }
622
+ }));
623
+ // Merge every grouped row from every model into one bucket per distinct
624
+ // group-key combination. State stays at the raw sum/count/min/max level (the
625
+ // v1 accumulator) so the same group appearing in multiple model tables combines
626
+ // correctly: sums/counts add and min/max merge, making averages Σsum / Σcount
627
+ // across models rather than the mean of per-model means. The bucket key is a
628
+ // stable serialisation of the ordered group-key values; a null/absent key value
629
+ // forms its own bucket (it is never coalesced with other values).
630
+ const buckets = new Map();
631
+ for (const rows of modelRows){
632
+ for (const row of rows){
633
+ const keys = {};
634
+ for (const group of groupBy){
635
+ const value = row[groupKeyAlias(group.keys)];
636
+ keys[aggregateFieldName(group.keys)] = value ?? null;
637
+ }
638
+ const bucketKey = JSON.stringify(groupBy.map((group)=>keys[aggregateFieldName(group.keys)]));
639
+ let bucket = buckets.get(bucketKey);
640
+ if (bucket == null) {
641
+ bucket = {
642
+ keys,
643
+ acc: createAggregateAccumulator()
644
+ };
645
+ buckets.set(bucketKey, bucket);
646
+ }
647
+ accumulateAggregateRow(bucket.acc, aggregates, row);
648
+ }
649
+ }
650
+ const groups = Array.from(buckets.values()).map((bucket)=>({
651
+ keys: bucket.keys,
652
+ ...finalizeAggregateValues(count, aggregates, bucket.acc)
653
+ }));
654
+ // Apply `having` after merging across models and finalising, so derived values
655
+ // like `average` are available and the predicate runs on the merged group rather
656
+ // than per-table partials.
657
+ const filtered = having == null ? groups : groups.filter((group)=>passesHaving(group, having));
658
+ // Order then limit the merged set, strictly after `having`. Ordering is numeric
659
+ // or lexical per term with nulls last; the limit takes the top-N of that order.
660
+ // Without `orderBy` the retained set under a limit is unspecified.
661
+ const ordered = orderBy != null && orderBy.length > 0 ? [
662
+ ...filtered
663
+ ].sort((a, b)=>compareGroups(a, b, orderBy)) : filtered;
664
+ // Clamp the limit: a negative value would otherwise drop groups from the
665
+ // end via slice's negative-index semantics rather than capping the count.
666
+ return limit != null ? ordered.slice(0, Math.max(0, limit)) : ordered;
667
+ },
668
+ async listDocuments (params) {
669
+ const [first, ...rest] = params.modelIDs;
670
+ let query = db.selectFrom(`k_${first}`).selectAll().where('id', 'in', params.docIDs).where('data', 'is not', null);
671
+ for (const model of rest){
672
+ query = query.unionAll((eb)=>{
673
+ return eb.selectFrom(`k_${model}`).selectAll().where('id', 'in', params.docIDs).where('data', 'is not', null);
674
+ });
675
+ }
676
+ const rows = await query.execute();
677
+ return rows.map((row)=>documentNodeFromTable(adapter, row));
678
+ },
679
+ async listDocumentModelIDs () {
680
+ const models = await db.selectFrom('kubun_graph_document_models').select('id').execute();
681
+ return models.map((m)=>m.id);
682
+ },
683
+ async getDistinctOwnersForModel (modelID) {
684
+ try {
685
+ const result = await db.selectFrom(`k_${modelID}`).select('owner').distinct().execute();
686
+ return result.map((r)=>r.owner);
687
+ } catch (err) {
688
+ if (isMissingTableError(err, `k_${modelID}`)) return [];
689
+ throw err;
690
+ }
691
+ },
692
+ async queryDocumentsByOwner (modelID, ownerDID) {
693
+ const docs = await db.selectFrom(`k_${modelID}`).selectAll().where('owner', '=', ownerDID).execute();
694
+ return docs.map((doc)=>documentNodeFromTable(adapter, doc));
695
+ },
696
+ // --- Mutation log ---
697
+ async insertMutationLogEntry (entry) {
698
+ await db.insertInto('kubun_graph_mutation_log').values(entry).onConflict((oc)=>oc.doNothing()).execute();
699
+ },
700
+ async hasMutationHash (hash) {
701
+ const row = await db.selectFrom('kubun_graph_mutation_log').select('mutation_hash').where('mutation_hash', '=', hash).executeTakeFirst();
702
+ return row != null;
703
+ },
704
+ async updateMutationStatus (mutationHash, status) {
705
+ await db.updateTable('kubun_graph_mutation_log').set({
706
+ status
707
+ }).where('mutation_hash', '=', mutationHash).execute();
708
+ },
709
+ async getMutationLogForDocuments (documentIDs) {
710
+ if (documentIDs.length === 0) return [];
711
+ return await db.selectFrom('kubun_graph_mutation_log').selectAll().where('document_id', 'in', documentIDs).orderBy('hlc', 'asc').execute();
712
+ },
713
+ async getMaxMutationHLC () {
714
+ const row = await db.selectFrom('kubun_graph_mutation_log').select((eb)=>eb.fn.max('hlc').as('max_hlc')).executeTakeFirst();
715
+ return row?.max_hlc ?? null;
716
+ },
717
+ async getPendingMutations (documentID) {
718
+ return await db.selectFrom('kubun_graph_mutation_log').selectAll().where('document_id', '=', documentID).where('status', '=', 'pending').orderBy('hlc', 'asc').execute();
719
+ },
720
+ // --- Field HLCs ---
721
+ async getFieldHLCs (documentID) {
722
+ const modelID = DocumentID.fromString(documentID).model.toString();
723
+ const row = await db.selectFrom(`k_${modelID}`).select('field_hlcs').where('id', '=', documentID).executeTakeFirst();
724
+ if (row?.field_hlcs == null) return null;
725
+ const hlcs = row.field_hlcs;
726
+ // ParseJSONResultsPlugin may have already parsed the value
727
+ if (typeof hlcs === 'string') {
728
+ return JSON.parse(hlcs);
729
+ }
730
+ return hlcs;
731
+ },
732
+ async getFieldValues (documentID) {
733
+ const modelID = DocumentID.fromString(documentID).model.toString();
734
+ const row = await db.selectFrom(`k_${modelID}`).select('field_values').where('id', '=', documentID).executeTakeFirst();
735
+ if (row?.field_values == null) return null;
736
+ const values = row.field_values;
737
+ // ParseJSONResultsPlugin may have already parsed the value
738
+ if (typeof values === 'string') {
739
+ return JSON.parse(values);
740
+ }
741
+ return values;
742
+ },
743
+ async updateFieldHLCs (docID, fieldHLCs, fieldValues) {
744
+ const modelID = docID.model.toString();
745
+ await db.updateTable(`k_${modelID}`).set({
746
+ field_hlcs: adapter.encodeJSON(fieldHLCs),
747
+ field_values: adapter.encodeJSON(fieldValues)
748
+ }).where('id', '=', docID.toString()).execute();
749
+ },
750
+ // --- Graph operations ---
751
+ async listGraphs () {
752
+ return await db.selectFrom('kubun_graph_models').selectAll().execute();
753
+ },
754
+ async createGraph (params) {
755
+ const graphModelID = params.id;
756
+ const name = params.name ?? graphModelID;
757
+ await db.transaction().setIsolationLevel('read committed').execute(async (trx)=>{
758
+ const pluginConfigValue = params.pluginConfig ? adapter.encodeJSON(params.pluginConfig) : null;
759
+ await trx.insertInto('kubun_graph_models').values({
760
+ aliases: adapter.encodeJSON(params.aliases ?? {}),
761
+ extension_sdl: params.extensionSDL ?? null,
762
+ id: graphModelID,
763
+ name,
764
+ plugin_config: pluginConfigValue,
765
+ search: params.search ? adapter.encodeJSON(params.search) : null
766
+ })// A re-deploy under an existing id that supplies plugins grows the
767
+ // graph's plugin schema (e.g. adding P2P group types later), so persist
768
+ // the new extension SDL + plugin config rather than freezing them at
769
+ // first deploy. A re-deploy without plugins keeps the existing config.
770
+ // This replaces the whole plugin set, not a per-plugin merge: a
771
+ // multi-plugin graph re-deployed with a subset would drop the omitted
772
+ // plugins for all readers. Merge is avoided on purpose — it would
773
+ // require regenerating SDL from the union, and an engine missing one of
774
+ // the existing plugins throws "Plugin not found". No call site combines
775
+ // plugins today; a future multi-plugin deploy would need merge here.
776
+ .onConflict((oc)=>params.pluginConfig != null ? oc.column('id').doUpdateSet({
777
+ extension_sdl: params.extensionSDL ?? null,
778
+ plugin_config: pluginConfigValue
779
+ }) : oc.doNothing()).execute();
780
+ const entries = Object.entries(params.record);
781
+ const added = new Set();
782
+ while(added.size < entries.length){
783
+ const toAdd = entries.filter(([id, model])=>{
784
+ return !added.has(id) && model.interfaces.every((iid)=>added.has(toGlobalInterfaceID(id, iid)));
785
+ });
786
+ if (toAdd.length === 0) {
787
+ throw new Error('Could not create graph: no valid model to add');
788
+ }
789
+ await Promise.all(toAdd.map(async ([id, model])=>{
790
+ await addDocumentModel(trx, adapter, {
791
+ id,
792
+ graphModelID,
793
+ model
794
+ });
795
+ added.add(id);
796
+ }));
797
+ }
798
+ for (const [clusterID, cluster] of Object.entries(params.clusters ?? {})){
799
+ const entries = Object.entries(cluster.models).map(([modelID, index])=>({
800
+ model_id: modelID,
801
+ cluster_id: clusterID,
802
+ cluster_index: index
803
+ }));
804
+ if (entries.length > 0) {
805
+ await trx.insertInto('kubun_graph_cluster_models').values(entries).onConflict((oc)=>oc.column('model_id').doUpdateSet((eb)=>({
806
+ cluster_id: eb.ref('excluded.cluster_id'),
807
+ cluster_index: eb.ref('excluded.cluster_index')
808
+ }))).execute();
809
+ }
810
+ await trx.insertInto('kubun_graph_clusters').values({
811
+ id: clusterID,
812
+ definition: adapter.encodeJSON(cluster.definition)
813
+ }).onConflict((oc)=>oc.column('id').doUpdateSet((eb)=>({
814
+ definition: eb.ref('excluded.definition')
815
+ }))).execute();
816
+ }
817
+ });
818
+ return graphModelID;
819
+ },
820
+ async getGraph (id) {
821
+ const graph = await db.selectFrom('kubun_graph_models').selectAll().where('id', '=', id).executeTakeFirst();
822
+ if (graph == null) {
823
+ return null;
824
+ }
825
+ const record = {};
826
+ const rows = await db.selectFrom('kubun_graph_document_model_links').innerJoin('kubun_graph_document_models', 'kubun_graph_document_model_links.document_model_id', 'kubun_graph_document_models.id').selectAll('kubun_graph_document_models').where('kubun_graph_document_model_links.graph_model_id', '=', id).execute();
827
+ for (const row of rows){
828
+ record[row.id] = documentModelFromTable(row);
829
+ }
830
+ return {
831
+ ...graph,
832
+ record
833
+ };
834
+ },
835
+ // --- Cluster operations ---
836
+ async getClusterForModel (modelID) {
837
+ const result = await db.selectFrom('kubun_graph_cluster_models').select('cluster_id').where('model_id', '=', modelID).executeTakeFirst();
838
+ return result?.cluster_id;
839
+ },
840
+ async getClusterModels (clusterID) {
841
+ return await db.selectFrom('kubun_graph_cluster_models').selectAll().where('cluster_id', '=', clusterID).orderBy('cluster_index').execute();
842
+ },
843
+ async saveCluster (id, definition) {
844
+ await db.insertInto('kubun_graph_clusters').values({
845
+ id,
846
+ definition: adapter.encodeJSON(definition)
847
+ }).onConflict((oc)=>oc.column('id').doUpdateSet((eb)=>({
848
+ definition: eb.ref('excluded.definition')
849
+ }))).execute();
850
+ },
851
+ async getCluster (id) {
852
+ return await db.selectFrom('kubun_graph_clusters').selectAll().where('id', '=', id).executeTakeFirst();
853
+ },
854
+ async getClusters (ids) {
855
+ if (ids.length === 0) return {};
856
+ const rows = await db.selectFrom('kubun_graph_clusters').selectAll().where('id', 'in', ids).execute();
857
+ const result = {};
858
+ for (const row of rows){
859
+ result[row.id] = row.definition;
860
+ }
861
+ return result;
862
+ },
863
+ // --- Attachments ---
864
+ async addAttachments (attachments) {
865
+ await db.insertInto('kubun_graph_document_attachments').values(attachments).onConflict((oc)=>oc.doNothing()).execute();
866
+ },
867
+ // --- Search ---
868
+ async createSearchIndex (modelID, fields) {
869
+ await adapter.createSearchIndex(db, {
870
+ modelID,
871
+ fields
872
+ });
873
+ },
874
+ async dropSearchIndex (modelID) {
875
+ await adapter.dropSearchIndex(db, modelID);
876
+ },
877
+ async updateSearchEntry (modelID, documentID, data, fields) {
878
+ const fieldValues = {};
879
+ for (const field of fields){
880
+ const value = extractFieldValue(data, field);
881
+ if (value != null) {
882
+ fieldValues[field] = String(value);
883
+ }
884
+ }
885
+ await adapter.updateSearchEntry(db, modelID, documentID, fieldValues, fields);
886
+ },
887
+ async removeSearchEntry (modelID, documentID) {
888
+ await adapter.removeSearchEntry(db, modelID, documentID);
889
+ },
890
+ async searchDocuments (params) {
891
+ const allResults = [];
892
+ for (const modelID of params.modelIDs){
893
+ const hits = await adapter.searchIndex(db, {
894
+ query: params.query,
895
+ modelID,
896
+ limit: params.first ?? 50
897
+ });
898
+ for (const hit of hits){
899
+ allResults.push({
900
+ documentID: hit.documentID,
901
+ modelID,
902
+ rank: hit.rank
903
+ });
904
+ }
905
+ }
906
+ // Sort by absolute rank descending (SQLite bm25 returns negative, PG ts_rank positive)
907
+ allResults.sort((a, b)=>Math.abs(b.rank) - Math.abs(a.rank));
908
+ if (params.first) {
909
+ return allResults.slice(0, params.first);
910
+ }
911
+ return allResults;
912
+ },
913
+ // --- Catalog operations ---
914
+ async createCatalog (catalog) {
915
+ await db.insertInto('kubun_graph_catalogs').values({
916
+ id: catalog.id,
917
+ owner_did: catalog.owner_did,
918
+ name: catalog.name,
919
+ description: catalog.description,
920
+ filter_criteria: adapter.encodeJSON(catalog.filter_criteria),
921
+ hlc: catalog.hlc,
922
+ // Own catalogs scope sync immediately; no discovery provenance.
923
+ active: 1,
924
+ source_group_id: null,
925
+ source_circle_id: null,
926
+ signed_token: catalog.signed_token ?? null
927
+ }).execute();
928
+ },
929
+ async upsertDiscoveredCatalog ({ catalog, sourceGroupID, sourceCircleID }) {
930
+ await db.insertInto('kubun_graph_catalogs').values({
931
+ id: catalog.id,
932
+ owner_did: catalog.owner_did,
933
+ name: catalog.name,
934
+ description: catalog.description,
935
+ filter_criteria: adapter.encodeJSON(catalog.filter_criteria),
936
+ hlc: catalog.hlc,
937
+ // Discovery applies no sync — the catalog arrives known, awaiting an
938
+ // explicit local activation decision.
939
+ active: 0,
940
+ source_group_id: sourceGroupID,
941
+ source_circle_id: sourceCircleID,
942
+ signed_token: catalog.signed_token ?? null
943
+ })// First-discovery-wins: an existing row (already known or locally
944
+ // activated) keeps its `active` flag and source columns untouched.
945
+ .onConflict((oc)=>oc.column('id').doNothing()).execute();
946
+ },
947
+ async setCatalogActive (id, active) {
948
+ await db.updateTable('kubun_graph_catalogs').set({
949
+ active: active ? 1 : 0,
950
+ updated_at: adapter.encodeTimestamp(new Date())
951
+ }).where('id', '=', id).execute();
952
+ },
953
+ async setCatalogsActive (ids, active) {
954
+ if (ids.length === 0) {
955
+ return;
956
+ }
957
+ await db.updateTable('kubun_graph_catalogs').set({
958
+ active: active ? 1 : 0,
959
+ updated_at: adapter.encodeTimestamp(new Date())
960
+ }).where('id', 'in', ids).execute();
961
+ },
962
+ async listStoredCatalogs (filter) {
963
+ let query = db.selectFrom('kubun_graph_catalogs').selectAll();
964
+ if (filter?.active === true) {
965
+ query = query.where('active', '=', 1);
966
+ } else if (filter?.active === false) {
967
+ query = query.where((eb)=>eb.or([
968
+ eb('active', 'is', null),
969
+ eb('active', '=', 0)
970
+ ]));
971
+ }
972
+ return await query.execute();
973
+ },
974
+ async getCatalog (id) {
975
+ return await db.selectFrom('kubun_graph_catalogs').selectAll().where('id', '=', id).executeTakeFirst();
976
+ },
977
+ async getCatalogs (ids) {
978
+ const result = new Map();
979
+ if (ids.length === 0) return result;
980
+ const rows = await db.selectFrom('kubun_graph_catalogs').selectAll().where('id', 'in', ids).execute();
981
+ for (const row of rows){
982
+ result.set(row.id, row);
983
+ }
984
+ return result;
985
+ },
986
+ async updateCatalog (id, update) {
987
+ const values = {};
988
+ if (update.name != null) values.name = update.name;
989
+ if (update.description != null) values.description = update.description;
990
+ if (update.filter_criteria != null) {
991
+ values.filter_criteria = adapter.encodeJSON(update.filter_criteria);
992
+ }
993
+ if (update.hlc != null) values.hlc = update.hlc;
994
+ if (update.signed_token != null) values.signed_token = update.signed_token;
995
+ values.updated_at = adapter.encodeTimestamp(new Date());
996
+ await db.updateTable('kubun_graph_catalogs').set(values).where('id', '=', id).execute();
997
+ },
998
+ async deleteCatalog (id) {
999
+ await db.deleteFrom('kubun_graph_catalogs').where('id', '=', id).execute();
1000
+ },
1001
+ async listCatalogs (ownerDID) {
1002
+ return await db.selectFrom('kubun_graph_catalogs').selectAll().where('owner_did', '=', ownerDID).execute();
1003
+ },
1004
+ async resolveCatalogScope (catalogID) {
1005
+ const catalog = await api.getCatalog(catalogID);
1006
+ if (catalog == null) {
1007
+ return {
1008
+ models: undefined,
1009
+ owners: undefined
1010
+ };
1011
+ }
1012
+ const criteria = catalog.filter_criteria;
1013
+ const models = criteria.models != null && criteria.models.length > 0 ? criteria.models : undefined;
1014
+ let owners;
1015
+ if (criteria.owners != null && criteria.owners.length > 0) {
1016
+ owners = [
1017
+ ...criteria.owners
1018
+ ];
1019
+ }
1020
+ // Note: circle-based owner resolution is not included here because
1021
+ // circle tables are in a separate store. Consumers should resolve
1022
+ // circle members externally if needed.
1023
+ return {
1024
+ models,
1025
+ owners
1026
+ };
1027
+ },
1028
+ // --- Cluster registration ---
1029
+ async registerClusterModels (_clusterID, entries) {
1030
+ if (entries.length === 0) return;
1031
+ await db.insertInto('kubun_graph_cluster_models').values(entries).onConflict((oc)=>oc.column('model_id').doUpdateSet((eb)=>({
1032
+ cluster_id: eb.ref('excluded.cluster_id'),
1033
+ cluster_index: eb.ref('excluded.cluster_index')
1034
+ }))).execute();
1035
+ },
1036
+ // --- User model access defaults ---
1037
+ async getUserModelAccessDefault (ownerDID, modelID, permissionType) {
1038
+ const result = await db.selectFrom('kubun_graph_user_model_access_defaults').select([
1039
+ 'access_level',
1040
+ 'allowed_dids',
1041
+ 'allowed_circles',
1042
+ 'allowed_groups',
1043
+ 'removed'
1044
+ ]).where('owner_did', '=', ownerDID).where('model_id', '=', modelID).where('permission_type', '=', permissionType).executeTakeFirst();
1045
+ // A tombstoned row reads as absent so a removed rule falls back to the
1046
+ // server default rather than surfacing a nulled rule.
1047
+ if (!result || result.removed) {
1048
+ return null;
1049
+ }
1050
+ return {
1051
+ level: result.access_level,
1052
+ allowedDIDs: result.allowed_dids,
1053
+ allowedCircles: result.allowed_circles,
1054
+ allowedGroups: result.allowed_groups
1055
+ };
1056
+ },
1057
+ async listUserModelAccessDefaults (ownerDID) {
1058
+ const results = await db.selectFrom('kubun_graph_user_model_access_defaults').select([
1059
+ 'model_id',
1060
+ 'permission_type',
1061
+ 'access_level',
1062
+ 'allowed_dids',
1063
+ 'allowed_circles',
1064
+ 'allowed_groups',
1065
+ 'hlc',
1066
+ 'removed'
1067
+ ]).where('owner_did', '=', ownerDID).execute();
1068
+ return results.filter((result)=>!result.removed).map((result)=>({
1069
+ modelID: result.model_id,
1070
+ permissionType: result.permission_type,
1071
+ level: result.access_level,
1072
+ allowedDIDs: result.allowed_dids,
1073
+ allowedCircles: result.allowed_circles,
1074
+ allowedGroups: result.allowed_groups,
1075
+ hlc: result.hlc ?? null
1076
+ }));
1077
+ },
1078
+ async getUserModelAccessDefaultHLC (ownerDID, modelID, permissionType) {
1079
+ const result = await db.selectFrom('kubun_graph_user_model_access_defaults').select([
1080
+ 'hlc'
1081
+ ]).where('owner_did', '=', ownerDID).where('model_id', '=', modelID).where('permission_type', '=', permissionType).executeTakeFirst();
1082
+ return result?.hlc ?? null;
1083
+ },
1084
+ async setUserModelAccessDefault (params) {
1085
+ await db.insertInto('kubun_graph_user_model_access_defaults').values({
1086
+ owner_did: params.ownerDID,
1087
+ model_id: params.modelID,
1088
+ permission_type: params.permissionType,
1089
+ access_level: params.accessLevel,
1090
+ allowed_dids: params.allowedDIDs ? adapter.encodeJSON(params.allowedDIDs) : null,
1091
+ allowed_circles: params.allowedCircles ? adapter.encodeJSON(params.allowedCircles) : null,
1092
+ allowed_groups: params.allowedGroups ? adapter.encodeJSON(params.allowedGroups) : null,
1093
+ hlc: params.hlc ?? null,
1094
+ // A set clears any prior tombstone so a re-established rule reads again.
1095
+ removed: 0
1096
+ }).onConflict((oc)=>{
1097
+ const updateValues = {
1098
+ access_level: params.accessLevel,
1099
+ allowed_dids: params.allowedDIDs ? adapter.encodeJSON(params.allowedDIDs) : null,
1100
+ allowed_circles: params.allowedCircles ? adapter.encodeJSON(params.allowedCircles) : null,
1101
+ allowed_groups: params.allowedGroups ? adapter.encodeJSON(params.allowedGroups) : null,
1102
+ removed: 0,
1103
+ updated_at: adapter.encodeTimestamp(new Date())
1104
+ };
1105
+ if (params.hlc != null) {
1106
+ updateValues.hlc = params.hlc;
1107
+ }
1108
+ return oc.columns([
1109
+ 'owner_did',
1110
+ 'model_id',
1111
+ 'permission_type'
1112
+ ]).doUpdateSet(updateValues);
1113
+ }).execute();
1114
+ },
1115
+ async removeUserModelAccessDefaults (ownerDID, modelID, permissionTypes, hlc) {
1116
+ // Tombstone rather than delete: retain the row with rule fields nulled,
1117
+ // `removed` set, and `hlc` stamped so it stays the LWW anchor. Deleting
1118
+ // would erase the anchor and let a stale set revive the rule.
1119
+ //
1120
+ // Upsert rather than update-only, for the same reason. Broadcast delivery
1121
+ // is unordered, so a removal can arrive on a device that never stored the
1122
+ // rule it removes. An update would match no row, land no anchor, and the
1123
+ // older `set` arriving afterwards would then apply against a null anchor —
1124
+ // resurrecting a revoked grant. Inserting the tombstone makes the anchor
1125
+ // land whether or not the rule was ever seen.
1126
+ //
1127
+ // `access_level` is a non-null column that a tombstone has no meaningful
1128
+ // value for; a fresh tombstone stores the most restrictive level, and an
1129
+ // existing row keeps whatever it had. Either way the `removed` marker
1130
+ // hides it from reads.
1131
+ if (permissionTypes.length === 0) {
1132
+ return;
1133
+ }
1134
+ const updatedAt = adapter.encodeTimestamp(new Date());
1135
+ await db.insertInto('kubun_graph_user_model_access_defaults').values(permissionTypes.map((permissionType)=>({
1136
+ owner_did: ownerDID,
1137
+ model_id: modelID,
1138
+ permission_type: permissionType,
1139
+ access_level: 'only_owner',
1140
+ allowed_dids: null,
1141
+ allowed_circles: null,
1142
+ allowed_groups: null,
1143
+ hlc,
1144
+ removed: 1
1145
+ }))).onConflict((oc)=>oc.columns([
1146
+ 'owner_did',
1147
+ 'model_id',
1148
+ 'permission_type'
1149
+ ]).doUpdateSet({
1150
+ allowed_dids: null,
1151
+ allowed_circles: null,
1152
+ allowed_groups: null,
1153
+ hlc,
1154
+ removed: 1,
1155
+ updated_at: updatedAt
1156
+ })).execute();
1157
+ },
1158
+ // --- Document scope / metadata helpers ---
1159
+ async getDocumentIDsForScope (scopes, excludedDocumentIDs = []) {
1160
+ // Group by model so each model's table is hit once with every owner it was
1161
+ // asked for, rather than once per (model, owner) pair.
1162
+ const ownersByModel = new Map();
1163
+ for (const scope of scopes){
1164
+ const owners = ownersByModel.get(scope.modelID);
1165
+ if (owners == null) {
1166
+ ownersByModel.set(scope.modelID, new Set([
1167
+ scope.ownerDID
1168
+ ]));
1169
+ } else {
1170
+ owners.add(scope.ownerDID);
1171
+ }
1172
+ }
1173
+ // Disjoint tables, so the reads go out together. Flattened in
1174
+ // `ownersByModel` order rather than completion order — the returned array
1175
+ // feeds merkle-tree construction, which must not vary run to run.
1176
+ const idsByModel = await Promise.all(Array.from(ownersByModel, async ([modelID, owners])=>{
1177
+ try {
1178
+ const docs = await db.selectFrom(`k_${modelID}`).select('id').where('owner', 'in', Array.from(owners)).execute();
1179
+ return docs.map((d)=>d.id);
1180
+ } catch (err) {
1181
+ // A model with no table contributes nothing — one missing table
1182
+ // skips that model's whole owner set at once.
1183
+ if (!isMissingTableError(err, `k_${modelID}`)) throw err;
1184
+ return [];
1185
+ }
1186
+ }));
1187
+ const docIDs = idsByModel.flat();
1188
+ if (excludedDocumentIDs.length > 0) {
1189
+ const excluded = new Set(excludedDocumentIDs);
1190
+ return docIDs.filter((id)=>!excluded.has(id));
1191
+ }
1192
+ return docIDs;
1193
+ },
1194
+ async getDocumentMetadataForSync (modelID, documentIDs) {
1195
+ if (documentIDs.length === 0) return [];
1196
+ const rows = await db.selectFrom(`k_${modelID}`).select([
1197
+ 'id',
1198
+ 'model',
1199
+ 'owner'
1200
+ ]).where('id', 'in', documentIDs).execute();
1201
+ return rows.map((r)=>({
1202
+ id: r.id,
1203
+ model: r.model,
1204
+ owner: r.owner
1205
+ }));
1206
+ }
1207
+ };
1208
+ return api;
1209
+ }