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