@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.d.ts +83 -3
- package/lib/api.js +1209 -1
- package/lib/cursor.d.ts +1 -0
- package/lib/cursor.js +7 -1
- package/lib/definition.js +7 -1
- package/lib/errors.d.ts +34 -0
- package/lib/errors.js +45 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.js +7 -1
- package/lib/migrations.d.ts +13 -0
- package/lib/migrations.js +100 -1
- package/lib/query-builder.d.ts +9 -3
- package/lib/query-builder.js +370 -1
- package/lib/tables.d.ts +43 -0
- package/lib/tables.js +1 -1
- package/package.json +18 -18
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
|
+
}
|