@kubun/store-graph 0.11.0 → 0.13.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 CHANGED
@@ -12,8 +12,36 @@ export type StoredAccessRule = {
12
12
  allowedCircles: Array<string> | null;
13
13
  allowedGroups: Array<string> | null;
14
14
  };
15
+ /**
16
+ * A single stored access-default row, tagged with the model and permission
17
+ * type it belongs to. Returned by `listUserModelAccessDefaults`, which -
18
+ * unlike `getUserModelAccessDefault` - is not scoped to one (model,
19
+ * permissionType) pair.
20
+ */
21
+ export type StoredAccessDefaultRow = StoredAccessRule & {
22
+ modelID: string;
23
+ permissionType: 'read' | 'write';
24
+ /**
25
+ * The row's LWW anchor, or null when written without a stamp. Carried here so
26
+ * a caller needing rule + anchor for several models reads one list rather than
27
+ * a `getUserModelAccessDefault` / `getUserModelAccessDefaultHLC` pair each.
28
+ */
29
+ hlc: string | null;
30
+ };
15
31
  export type GraphStoreAPI = {
16
32
  getDocument(id: DocumentID | string): Promise<DocumentNode | null>;
33
+ /**
34
+ * Batch fetch documents, returning a Map keyed by the document ID string. A
35
+ * missing id is absent from the map, exactly as `getDocument` returning `null`.
36
+ *
37
+ * Documents live in a per-model table, so the reads are grouped by model and
38
+ * cost one query per DISTINCT model rather than one per id. A model with no
39
+ * table contributes nothing, matching `getDocument`'s behaviour for an id whose
40
+ * model was never deployed. Empty input short-circuits with no SQL.
41
+ *
42
+ * Caller is responsible for deduplicating ids.
43
+ */
44
+ getDocuments(ids: Array<DocumentID | string>): Promise<Map<string, DocumentNode>>;
17
45
  getDocumentModel(id: DocumentModelID | string): Promise<DocumentModel>;
18
46
  /**
19
47
  * Return the interface model IDs declared by `modelID` from the
@@ -61,7 +89,30 @@ export type GraphStoreAPI = {
61
89
  updateSearchEntry(modelID: string, documentID: string, data: Record<string, unknown>, fields: Array<string>): Promise<void>;
62
90
  removeSearchEntry(modelID: string, documentID: string): Promise<void>;
63
91
  searchDocuments(params: SearchDocumentsParams): Promise<Array<SearchDocumentResult>>;
92
+ /** Create an own catalog: stored active (scopes sync) with null provenance. */
64
93
  createCatalog(catalog: InsertCatalog): Promise<void>;
94
+ /**
95
+ * Insert a discovered catalog as known (not active) with first-discovery
96
+ * provenance. Idempotent on the id: an already-stored catalog is left
97
+ * untouched, so its local `active` flag and first-discovery source columns
98
+ * are never overwritten by a later re-discovery.
99
+ */
100
+ upsertDiscoveredCatalog(params: {
101
+ catalog: InsertCatalog;
102
+ sourceGroupID: string | null;
103
+ sourceCircleID: string | null;
104
+ }): Promise<void>;
105
+ /** Toggle a catalog's local sync activation. */
106
+ setCatalogActive(id: string, active: boolean): Promise<void>;
107
+ /** Flip several catalogs together — one statement, not one per id. */
108
+ setCatalogsActive(ids: Array<string>, active: boolean): Promise<void>;
109
+ /**
110
+ * All locally-stored catalog rows regardless of owner, optionally filtered by
111
+ * activation state. Backs the viewer's known-catalogs surface.
112
+ */
113
+ listStoredCatalogs(filter?: {
114
+ active?: boolean;
115
+ }): Promise<Array<Catalog>>;
65
116
  getCatalog(id: string): Promise<Catalog | undefined>;
66
117
  /**
67
118
  * Batch fetch catalogs by ID. Returns a Map keyed by ID; missing IDs are
@@ -71,7 +122,7 @@ export type GraphStoreAPI = {
71
122
  * Caller is responsible for deduplicating IDs (e.g. via `Array.from(new Set(...))`).
72
123
  */
73
124
  getCatalogs(ids: Array<string>): Promise<Map<string, Catalog>>;
74
- updateCatalog(id: string, update: Partial<Pick<InsertCatalog, 'name' | 'description' | 'filter_criteria' | 'hlc'>>): Promise<void>;
125
+ updateCatalog(id: string, update: Partial<Pick<InsertCatalog, 'name' | 'description' | 'filter_criteria' | 'hlc' | 'signed_token'>>): Promise<void>;
75
126
  deleteCatalog(id: string): Promise<void>;
76
127
  listCatalogs(ownerDID: string): Promise<Array<Catalog>>;
77
128
  resolveCatalogScope(catalogID: string): Promise<{
@@ -79,7 +130,22 @@ export type GraphStoreAPI = {
79
130
  owners: Array<string> | undefined;
80
131
  }>;
81
132
  registerClusterModels(clusterID: string, entries: Array<InsertClusterModel>): Promise<void>;
133
+ /** A tombstoned (removed) rule reads as absent. */
82
134
  getUserModelAccessDefault(ownerDID: string, modelID: string, permissionType: 'read' | 'write'): Promise<StoredAccessRule | null>;
135
+ /**
136
+ * All active stored access-default rows for `ownerDID`, across every model and
137
+ * permission type. Tombstoned (removed) rows are excluded. Bounded by the
138
+ * owner's model count, no pagination.
139
+ */
140
+ listUserModelAccessDefaults(ownerDID: string): Promise<Array<StoredAccessDefaultRow>>;
141
+ /**
142
+ * The LWW anchor (`hlc`) stamped on a stored rule, or null when the rule is
143
+ * absent or was written without a stamp (a legacy or unsynced local policy).
144
+ * A caller applying a replicated rule reads this to reject an older write.
145
+ * A tombstoned row keeps its anchor: this returns the removal's `hlc` so a
146
+ * stale set arriving after the remove is still rejected.
147
+ */
148
+ getUserModelAccessDefaultHLC(ownerDID: string, modelID: string, permissionType: 'read' | 'write'): Promise<string | null>;
83
149
  setUserModelAccessDefault(params: {
84
150
  ownerDID: string;
85
151
  modelID: string;
@@ -88,8 +154,21 @@ export type GraphStoreAPI = {
88
154
  allowedDIDs: Array<string> | null;
89
155
  allowedCircles: Array<string> | null;
90
156
  allowedGroups: Array<string> | null;
157
+ /** LWW anchor. Omitted by callers that do not participate in replication. */
158
+ hlc?: string;
91
159
  }): Promise<void>;
92
- removeUserModelAccessDefaults(ownerDID: string, modelID: string, permissionTypes: Array<string>): Promise<void>;
160
+ /**
161
+ * Tombstone the given permission types: retain each row with its rule fields
162
+ * nulled, `removed` set, and `hlc` stamped as the LWW anchor. The row is kept
163
+ * (not deleted) so a stale set arriving after the removal is rejected by HLC.
164
+ * A type with no existing row is skipped (nothing to tombstone).
165
+ */
166
+ removeUserModelAccessDefaults(ownerDID: string, modelID: string, permissionTypes: Array<string>, hlc: string): Promise<void>;
167
+ /**
168
+ * Document IDs across every scope, one query per distinct model. Repeating a
169
+ * (model, owner) pair yields its documents once — the result is a set, and the
170
+ * caller builds a Merkle tree from it, where a duplicate would be a defect.
171
+ */
93
172
  getDocumentIDsForScope(scopes: Array<{
94
173
  modelID: string;
95
174
  ownerDID: string;
package/lib/api.js CHANGED
@@ -1,12 +1,6 @@
1
1
  import { DocumentID, DocumentModelID } from '@kubun/id';
2
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
- }
3
+ import { isMissingTableError, ModelNotDeployedError } from './errors.js';
10
4
  import { aggregateFieldName, applyAggregateSelections, applyDocumentFilter, applyDocumentOrderBy, applyGroupBySelections, applyPagination, groupKeyAlias, knownFieldColumn } from './query-builder.js';
11
5
  // --- Helper functions ---
12
6
  // SQL numeric results round-trip as strings on some adapters (Postgres `numeric`/`sum`),
@@ -352,9 +346,62 @@ export function createGraphStore(db, adapter) {
352
346
  // --- Document operations ---
353
347
  async getDocument (id) {
354
348
  const parsedID = DocumentID.from(id);
355
- const doc = await db.selectFrom(`k_${parsedID.model.toString()}`).selectAll().where('id', '=', parsedID.toString()).executeTakeFirst();
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
+ });
356
361
  return doc ? documentNodeFromTable(adapter, doc) : null;
357
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
+ },
358
405
  async getDocumentModel (id) {
359
406
  const row = await db.selectFrom('kubun_graph_document_models').selectAll().where('id', '=', id.toString()).executeTakeFirstOrThrow();
360
407
  return documentModelFromTable(row);
@@ -404,7 +451,7 @@ export function createGraphStore(db, adapter) {
404
451
  query = query.where('owner', 'in', effectiveOwners);
405
452
  }
406
453
  if (filter != null) {
407
- query = query.where((eb)=>applyDocumentFilter(eb, filter, [], coerce));
454
+ query = query.where((eb)=>applyDocumentFilter(eb, filter, adapter, [], coerce));
408
455
  }
409
456
  for (const model of rest){
410
457
  query = query.unionAll((eb)=>{
@@ -416,7 +463,7 @@ export function createGraphStore(db, adapter) {
416
463
  q = q.where('owner', 'in', effectiveOwners);
417
464
  }
418
465
  if (filter != null) {
419
- q = q.where((eb)=>applyDocumentFilter(eb, filter, [], coerce));
466
+ q = q.where((eb)=>applyDocumentFilter(eb, filter, adapter, [], coerce));
420
467
  }
421
468
  return q;
422
469
  });
@@ -507,7 +554,7 @@ export function createGraphStore(db, adapter) {
507
554
  query = query.where('owner', 'in', effectiveOwners);
508
555
  }
509
556
  if (filter != null) {
510
- query = query.where((eb)=>applyDocumentFilter(eb, filter, [], coerce));
557
+ query = query.where((eb)=>applyDocumentFilter(eb, filter, adapter, [], coerce));
511
558
  }
512
559
  if (readAccess != null) {
513
560
  query = query.where(adapter.readAccessPredicate(readAccess, modelID));
@@ -561,7 +608,7 @@ export function createGraphStore(db, adapter) {
561
608
  query = query.where('owner', 'in', effectiveOwners);
562
609
  }
563
610
  if (filter != null) {
564
- query = query.where((eb)=>applyDocumentFilter(eb, filter, [], coerce));
611
+ query = query.where((eb)=>applyDocumentFilter(eb, filter, adapter, [], coerce));
565
612
  }
566
613
  if (readAccess != null) {
567
614
  query = query.where(adapter.readAccessPredicate(readAccess, modelID));
@@ -748,6 +795,25 @@ export function createGraphStore(db, adapter) {
748
795
  added.add(id);
749
796
  }));
750
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
+ }
751
817
  });
752
818
  return graphModelID;
753
819
  },
@@ -852,9 +918,59 @@ export function createGraphStore(db, adapter) {
852
918
  name: catalog.name,
853
919
  description: catalog.description,
854
920
  filter_criteria: adapter.encodeJSON(catalog.filter_criteria),
855
- hlc: catalog.hlc
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
856
927
  }).execute();
857
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
+ },
858
974
  async getCatalog (id) {
859
975
  return await db.selectFrom('kubun_graph_catalogs').selectAll().where('id', '=', id).executeTakeFirst();
860
976
  },
@@ -875,6 +991,7 @@ export function createGraphStore(db, adapter) {
875
991
  values.filter_criteria = adapter.encodeJSON(update.filter_criteria);
876
992
  }
877
993
  if (update.hlc != null) values.hlc = update.hlc;
994
+ if (update.signed_token != null) values.signed_token = update.signed_token;
878
995
  values.updated_at = adapter.encodeTimestamp(new Date());
879
996
  await db.updateTable('kubun_graph_catalogs').set(values).where('id', '=', id).execute();
880
997
  },
@@ -922,9 +1039,12 @@ export function createGraphStore(db, adapter) {
922
1039
  'access_level',
923
1040
  'allowed_dids',
924
1041
  'allowed_circles',
925
- 'allowed_groups'
1042
+ 'allowed_groups',
1043
+ 'removed'
926
1044
  ]).where('owner_did', '=', ownerDID).where('model_id', '=', modelID).where('permission_type', '=', permissionType).executeTakeFirst();
927
- if (!result) {
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) {
928
1048
  return null;
929
1049
  }
930
1050
  return {
@@ -934,6 +1054,33 @@ export function createGraphStore(db, adapter) {
934
1054
  allowedGroups: result.allowed_groups
935
1055
  };
936
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
+ },
937
1084
  async setUserModelAccessDefault (params) {
938
1085
  await db.insertInto('kubun_graph_user_model_access_defaults').values({
939
1086
  owner_did: params.ownerDID,
@@ -942,33 +1089,102 @@ export function createGraphStore(db, adapter) {
942
1089
  access_level: params.accessLevel,
943
1090
  allowed_dids: params.allowedDIDs ? adapter.encodeJSON(params.allowedDIDs) : null,
944
1091
  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({
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 = {
951
1098
  access_level: params.accessLevel,
952
1099
  allowed_dids: params.allowedDIDs ? adapter.encodeJSON(params.allowedDIDs) : null,
953
1100
  allowed_circles: params.allowedCircles ? adapter.encodeJSON(params.allowedCircles) : null,
954
1101
  allowed_groups: params.allowedGroups ? adapter.encodeJSON(params.allowedGroups) : null,
1102
+ removed: 0,
955
1103
  updated_at: adapter.encodeTimestamp(new Date())
956
- })).execute();
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();
957
1114
  },
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();
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();
960
1157
  },
961
1158
  // --- Document scope / metadata helpers ---
962
1159
  async getDocumentIDsForScope (scopes, excludedDocumentIDs = []) {
963
- const docIDs = [];
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();
964
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])=>{
965
1177
  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));
1178
+ const docs = await db.selectFrom(`k_${modelID}`).select('id').where('owner', 'in', Array.from(owners)).execute();
1179
+ return docs.map((d)=>d.id);
968
1180
  } catch (err) {
969
- if (!isMissingTableError(err, `k_${scope.modelID}`)) throw 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 [];
970
1185
  }
971
- }
1186
+ }));
1187
+ const docIDs = idsByModel.flat();
972
1188
  if (excludedDocumentIDs.length > 0) {
973
1189
  const excluded = new Set(excludedDocumentIDs);
974
1190
  return docIDs.filter((id)=>!excluded.has(id));
@@ -0,0 +1,34 @@
1
+ /**
2
+ * True when `err` is the driver's way of saying `tableName` does not exist.
3
+ * Each adapter words it differently, so the match is the only place the two
4
+ * spellings are known.
5
+ */
6
+ export declare function isMissingTableError(err: unknown, tableName: string): boolean;
7
+ /**
8
+ * Thrown when a read names a document model this device never deployed. The
9
+ * model's table is created at deploy time, so its absence is a fact about this
10
+ * device rather than about the document — a peer holding the model reads the
11
+ * same id fine.
12
+ *
13
+ * Tagged with the protocol-level error code `KB15` so a caller branches on the
14
+ * class instead of matching a driver's wording, which differs per adapter. Not
15
+ * a permanent apply error: a model may be deployed later, so a mutation naming
16
+ * one stays retryable.
17
+ */
18
+ export declare class ModelNotDeployedError extends Error {
19
+ #private;
20
+ constructor(modelID: string, options?: {
21
+ cause?: unknown;
22
+ });
23
+ get code(): 'KB15';
24
+ get modelID(): string;
25
+ /**
26
+ * graphql-js copies a wrapped error's `extensions` onto the error it locates,
27
+ * so a resolver that lets this through surfaces the code to the app without
28
+ * translating it at the throw site.
29
+ */
30
+ get extensions(): {
31
+ code: 'KB15';
32
+ modelID: string;
33
+ };
34
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * True when `err` is the driver's way of saying `tableName` does not exist.
3
+ * Each adapter words it differently, so the match is the only place the two
4
+ * spellings are known.
5
+ */ export function isMissingTableError(err, tableName) {
6
+ if (!(err instanceof Error)) return false;
7
+ const message = err.message;
8
+ // SQLite: "no such table: k_<modelID>"
9
+ // Postgres: `relation "k_<modelID>" does not exist`
10
+ return message.includes('no such table') && message.includes(tableName) || message.includes('does not exist') && message.includes(tableName);
11
+ }
12
+ /**
13
+ * Thrown when a read names a document model this device never deployed. The
14
+ * model's table is created at deploy time, so its absence is a fact about this
15
+ * device rather than about the document — a peer holding the model reads the
16
+ * same id fine.
17
+ *
18
+ * Tagged with the protocol-level error code `KB15` so a caller branches on the
19
+ * class instead of matching a driver's wording, which differs per adapter. Not
20
+ * a permanent apply error: a model may be deployed later, so a mutation naming
21
+ * one stays retryable.
22
+ */ export class ModelNotDeployedError extends Error {
23
+ #modelID;
24
+ constructor(modelID, options){
25
+ super(`Document model ${modelID} is not deployed on this device`, options);
26
+ this.name = 'ModelNotDeployedError';
27
+ this.#modelID = modelID;
28
+ }
29
+ get code() {
30
+ return 'KB15';
31
+ }
32
+ get modelID() {
33
+ return this.#modelID;
34
+ }
35
+ /**
36
+ * graphql-js copies a wrapped error's `extensions` onto the error it locates,
37
+ * so a resolver that lets this through surfaces the code to the app without
38
+ * translating it at the throw site.
39
+ */ get extensions() {
40
+ return {
41
+ code: 'KB15',
42
+ modelID: this.#modelID
43
+ };
44
+ }
45
+ }
package/lib/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { StoreProvider } from '@kubun/db';
2
2
  import type { GraphStoreAPI } from './api.js';
3
- export type { GraphStoreAPI, StoredAccessRule } from './api.js';
3
+ export type { GraphStoreAPI, StoredAccessDefaultRow, StoredAccessRule } from './api.js';
4
4
  export { createGraphStore } from './api.js';
5
5
  export { graphStoreDefinition } from './definition.js';
6
+ export { ModelNotDeployedError } from './errors.js';
6
7
  export declare const GRAPH_STORE: "graph";
7
8
  export declare function getGraphStore(provider: StoreProvider): Promise<GraphStoreAPI>;
8
9
  export type { AccessLevel, AccessPermissions, AddDocumentModelParams, Catalog, ClusterDefinitionRow, ClusterModel, ConnectionArguments, CreateDocumentParams, CreateGraphParams, CursorDocument, Document, DocumentAttachment, DocumentData, DocumentModelRow, DocumentParams, GraphModel, GraphModelWithRecord, GraphTables, InsertCatalog, InsertClusterModel, InsertDocument, InsertDocumentAttachment, InsertDocumentModel, InsertDocumentModelInterface, InsertMutationLogEntry, ListDocumentsParams, MutationLogEntry, PaginatedResult, PaginationParams, QueryDocumentsParams, QueryDocumentsResult, SaveDocumentParams, SearchConfig, SearchDocumentResult, SearchDocumentsParams, UpdateDocument, UpdateDocumentModel, ViewerReadAccess, } from './tables.js';
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { createGraphStore } from './api.js';
2
2
  export { graphStoreDefinition } from './definition.js';
3
+ export { ModelNotDeployedError } from './errors.js';
3
4
  export const GRAPH_STORE = 'graph';
4
5
  export function getGraphStore(provider) {
5
6
  return provider.getStore(GRAPH_STORE);
@@ -1,3 +1,16 @@
1
1
  import type { MigrationContext } from '@kubun/db';
2
2
  import type { Migration } from 'kysely/migration';
3
+ /**
4
+ * A shipped migration is immutable: once a database has run it, editing it
5
+ * changes nothing on that database and silently diverges the schema from the
6
+ * code. Every schema change after a release therefore goes in a NEW migration,
7
+ * which is why the later ones here say so.
8
+ *
9
+ * `0-init` is a deliberate exception on this change: it was edited in place
10
+ * rather than extended. That is a BREAKING change — a database created before it
11
+ * will not pick the new columns up and must be recreated. It was taken because
12
+ * this release already requires recreating local databases for unrelated reasons,
13
+ * so folding the change into the initial schema costs nothing and keeps `0-init`
14
+ * readable as the whole schema. Do NOT repeat it after this release ships.
15
+ */
3
16
  export declare function getGraphMigrations(ctx: MigrationContext): Record<string, Migration>;
package/lib/migrations.js CHANGED
@@ -1,4 +1,16 @@
1
- export function getGraphMigrations(ctx) {
1
+ /**
2
+ * A shipped migration is immutable: once a database has run it, editing it
3
+ * changes nothing on that database and silently diverges the schema from the
4
+ * code. Every schema change after a release therefore goes in a NEW migration,
5
+ * which is why the later ones here say so.
6
+ *
7
+ * `0-init` is a deliberate exception on this change: it was edited in place
8
+ * rather than extended. That is a BREAKING change — a database created before it
9
+ * will not pick the new columns up and must be recreated. It was taken because
10
+ * this release already requires recreating local databases for unrelated reasons,
11
+ * so folding the change into the initial schema costs nothing and keeps `0-init`
12
+ * readable as the whole schema. Do NOT repeat it after this release ships.
13
+ */ export function getGraphMigrations(ctx) {
2
14
  const t = ctx.types;
3
15
  const now = ctx.functions.now;
4
16
  const init = {
@@ -20,7 +32,10 @@ export function getGraphMigrations(ctx) {
20
32
  'document_model_id'
21
33
  ]).execute();
22
34
  // User model access defaults
23
- await db.schema.createTable('kubun_graph_user_model_access_defaults').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('model_id', t.text, (col)=>col.notNull()).addColumn('permission_type', t.text, (col)=>col.notNull()).addColumn('access_level', t.text, (col)=>col.notNull()).addColumn('allowed_dids', t.json).addColumn('allowed_circles', t.json).addColumn('allowed_groups', t.json).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('kubun_graph_user_model_access_defaults_pkey', [
35
+ await db.schema.createTable('kubun_graph_user_model_access_defaults').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('model_id', t.text, (col)=>col.notNull()).addColumn('permission_type', t.text, (col)=>col.notNull()).addColumn('access_level', t.text, (col)=>col.notNull()).addColumn('allowed_dids', t.json).addColumn('allowed_circles', t.json).addColumn('allowed_groups', t.json)// LWW anchor for replicated rules; null for rows never stamped by a broadcast.
36
+ .addColumn('hlc', t.text)// Retained-tombstone marker (0/null = active, 1 = removed): removal keeps
37
+ // the row so its `hlc` stays the LWW anchor and hides it from reads.
38
+ .addColumn('removed', 'integer').addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('kubun_graph_user_model_access_defaults_pkey', [
24
39
  'owner_did',
25
40
  'model_id',
26
41
  'permission_type'
@@ -40,8 +55,31 @@ export function getGraphMigrations(ctx) {
40
55
  // Cluster definitions
41
56
  await db.schema.createTable('kubun_graph_clusters').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('definition', t.json, (col)=>col.notNull()).execute();
42
57
  // Catalogs
43
- await db.schema.createTable('kubun_graph_catalogs').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('description', t.text, (col)=>col.notNull().defaultTo('')).addColumn('filter_criteria', t.json, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).execute();
58
+ await db.schema.createTable('kubun_graph_catalogs').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('description', t.text, (col)=>col.notNull().defaultTo('')).addColumn('filter_criteria', t.json, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull())// Local-only sync activation (1 = active, 0/null = known-not-activated).
59
+ .addColumn('active', 'integer')// First-discovery provenance (nullable; never overwritten on re-discovery).
60
+ .addColumn('source_group_id', t.text).addColumn('source_circle_id', t.text)// Creator-signed `catalog:set` token; forwarded verbatim in invite seeds.
61
+ .addColumn('signed_token', t.text).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).execute();
44
62
  await db.schema.createIndex('idx_graph_catalogs_owner').ifNotExists().on('kubun_graph_catalogs').column('owner_did').execute();
63
+ // `.ifNotExists()` protects a half-created table from being recreated —
64
+ // and, because this migration was edited in place, it equally protects a
65
+ // table an EARLIER run created from ever acquiring the columns that edit
66
+ // added. A database that ran the pre-edit `0-init`, or crashed after
67
+ // creating this table but before Kysely recorded the migration (SQLite
68
+ // DDL is not transactional), would otherwise complete this migration with
69
+ // a table that has no `hlc` and no `removed` — and every subsequent
70
+ // `setUserModelAccessDefault` would fail with "no such column: hlc",
71
+ // permanently, with no repair path. Healing it here is what makes the
72
+ // half-upgraded shape unrepresentable rather than merely unlikely.
73
+ const accessTable = (await db.introspection.getTables()).find((table)=>table.name === 'kubun_graph_user_model_access_defaults');
74
+ if (accessTable != null) {
75
+ const existing = new Set(accessTable.columns.map((column)=>column.name));
76
+ if (!existing.has('hlc')) {
77
+ await db.schema.alterTable('kubun_graph_user_model_access_defaults').addColumn('hlc', t.text).execute();
78
+ }
79
+ if (!existing.has('removed')) {
80
+ await db.schema.alterTable('kubun_graph_user_model_access_defaults').addColumn('removed', 'integer').execute();
81
+ }
82
+ }
45
83
  },
46
84
  async down (db) {
47
85
  await db.schema.dropTable('kubun_graph_catalogs').ifExists().execute();
@@ -17,8 +17,9 @@ export declare function applyGroupBySelections(eb: DocumentExpressionBuilder, gr
17
17
  expressions: Array<DocumentExpressionWrapper>;
18
18
  };
19
19
  export declare function applyPagination(query: DocumentQueryBuilder, args: ConnectionArguments, orderBy?: DocumentOrderBy, coerce?: CoerceFilterValue): [DocumentQueryBuilder, number];
20
- export declare function applyDocumentFilter(eb: DocumentExpressionBuilder, filter: DocumentFilter, path?: Array<string>, coerce?: CoerceFilterValue): DocumentExpressionWrapper;
21
- export declare function applyValueFilter(eb: DocumentExpressionBuilder, keys: Array<string>, filter: AnyValueFilter, coerce?: CoerceFilterValue): DocumentExpressionWrapper;
20
+ export declare function applyDocumentFilter(eb: DocumentExpressionBuilder, filter: DocumentFilter, adapter: Adapter, path?: Array<string>, coerce?: CoerceFilterValue): DocumentExpressionWrapper;
21
+ export declare function escapeLikePattern(value: string): string;
22
+ export declare function applyValueFilter(eb: DocumentExpressionBuilder, keys: Array<string>, filter: AnyValueFilter, adapter: Adapter, coerce?: CoerceFilterValue): DocumentExpressionWrapper;
22
23
  export declare function applyDocumentOrderBy(queryBuilder: DocumentQueryBuilder, adapter: Adapter, orderBy?: DocumentOrderBy, isReverse?: boolean): [DocumentQueryBuilder, Array<Array<string>> | null];
23
24
  export declare const KNOWN_FIELDS: {
24
25
  readonly _createdAt: "created_at";
@@ -12,6 +12,16 @@ function getKeyPath(eb, keys) {
12
12
  }
13
13
  return keyPath;
14
14
  }
15
+ // Same traversal as getKeyPath, but keeps the raw JSON node (`->`) instead of extracting
16
+ // text (`->>`), so array-membership predicates can hand adapters a JSON array to iterate
17
+ // (json_each / jsonb_array_elements_text / `@>`) rather than a text scalar.
18
+ function getRawKeyPath(eb, keys) {
19
+ let keyPath = eb.ref('data', '->');
20
+ for (const key of keys){
21
+ keyPath = keyPath.key(key);
22
+ }
23
+ return keyPath;
24
+ }
15
25
  export function aggregateFieldName(keys) {
16
26
  return keys.join('.');
17
27
  }
@@ -220,7 +230,7 @@ function applyBackwardPagination(queryBuilder, limit, before, orderBy, coerce) {
220
230
  }
221
231
  return query.limit(limit);
222
232
  }
223
- export function applyDocumentFilter(eb, filter, path = [], coerce) {
233
+ export function applyDocumentFilter(eb, filter, adapter, path = [], coerce) {
224
234
  const entries = Object.entries(filter);
225
235
  if (entries.length !== 1) {
226
236
  throw new Error('Invalid document filter');
@@ -228,33 +238,40 @@ export function applyDocumentFilter(eb, filter, path = [], coerce) {
228
238
  const [type, value] = entries[0];
229
239
  switch(type){
230
240
  case 'where':
231
- return applyObjectFilter(eb, value, path, coerce);
241
+ return applyObjectFilter(eb, value, adapter, path, coerce);
232
242
  case 'and':
233
243
  return eb.and(value.map((filter)=>{
234
- return applyDocumentFilter(eb, filter, path, coerce);
244
+ return applyDocumentFilter(eb, filter, adapter, path, coerce);
235
245
  }));
236
246
  case 'or':
237
247
  return eb.or(value.map((filter)=>{
238
- return applyDocumentFilter(eb, filter, path, coerce);
248
+ return applyDocumentFilter(eb, filter, adapter, path, coerce);
239
249
  }));
240
250
  case 'not':
241
- return eb.not(applyDocumentFilter(eb, value, path, coerce));
251
+ return eb.not(applyDocumentFilter(eb, value, adapter, path, coerce));
242
252
  default:
243
253
  throw new Error(`Invalid document filter type: ${type}`);
244
254
  }
245
255
  }
246
- function applyObjectFilter(eb, filter, path, coerce) {
256
+ function applyObjectFilter(eb, filter, adapter, path, coerce) {
247
257
  const criteria = Object.entries(filter).map(([fieldName, valueFilter])=>{
248
258
  // TODO: check if value filter or nested object filter should be applied
249
259
  // for nested object, check if embedded or related object
250
260
  return applyValueFilter(eb, [
251
261
  ...path,
252
262
  fieldName
253
- ], valueFilter, coerce);
263
+ ], valueFilter, adapter, coerce);
254
264
  });
255
265
  return eb.and(criteria);
256
266
  }
257
- export function applyValueFilter(eb, keys, filter, coerce) {
267
+ // Escapes a LIKE pattern's special characters (backslash first, then the wildcards) and
268
+ // wraps it for substring match. Order matters: escaping `%`/`_` before the backslash
269
+ // would double-escape them.
270
+ export function escapeLikePattern(value) {
271
+ const escaped = value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
272
+ return `%${escaped}%`;
273
+ }
274
+ export function applyValueFilter(eb, keys, filter, adapter, coerce) {
258
275
  const entries = Object.entries(filter);
259
276
  if (entries.length !== 1) {
260
277
  throw new Error('Invalid value filter');
@@ -281,6 +298,29 @@ export function applyValueFilter(eb, keys, filter, coerce) {
281
298
  return eb(fieldName, '>', c(value));
282
299
  case 'greaterThanOrEqualTo':
283
300
  return eb(fieldName, '>=', c(value));
301
+ case 'contains':
302
+ return adapter.containsPredicate(fieldName, escapeLikePattern(String(value)));
303
+ case 'includesAll':
304
+ {
305
+ // Dedupe the candidate set so SQLite's COUNT(DISTINCT value) = set-size test matches
306
+ // Postgres @> containment: duplicate inputs must not inflate the required count.
307
+ const items = [
308
+ ...new Set(value)
309
+ ];
310
+ // Vacuously true (D7): every array includes the empty set. Handled here, not in
311
+ // SQL, so the adapter seam never sees an empty IN-list.
312
+ return items.length === 0 ? sql`1 = 1` : adapter.arrayIncludesAllPredicate(getRawKeyPath(eb, keys), items);
313
+ }
314
+ case 'includesAny':
315
+ {
316
+ const items = [
317
+ ...new Set(value)
318
+ ];
319
+ // An empty candidate set matches nothing.
320
+ return items.length === 0 ? sql`1 = 0` : adapter.arrayIncludesAnyPredicate(getRawKeyPath(eb, keys), items);
321
+ }
322
+ case 'presence':
323
+ return adapter.arrayPresencePredicate(getRawKeyPath(eb, keys), value);
284
324
  default:
285
325
  throw new Error(`Invalid value filter type: ${type}`);
286
326
  }
package/lib/tables.d.ts CHANGED
@@ -99,6 +99,10 @@ export type UserModelAccessDefaultTable = {
99
99
  allowed_dids: JSONValueColumn<Array<string>> | null;
100
100
  allowed_circles: JSONValueColumn<Array<string>> | null;
101
101
  allowed_groups: JSONValueColumn<Array<string>> | null;
102
+ /** LWW anchor for replicated rules. Null for rows never stamped by a broadcast. */
103
+ hlc: string | null;
104
+ /** Retained-tombstone marker: 1 = removed, 0/null = active. */
105
+ removed: number | null;
102
106
  created_at: CreatedAtColumn;
103
107
  updated_at: UpdatedAtColumn;
104
108
  };
@@ -112,6 +116,30 @@ export type CatalogTable = {
112
116
  description: string;
113
117
  filter_criteria: JSONValueColumn<CatalogFilterCriteria>;
114
118
  hlc: string;
119
+ /**
120
+ * Local-only sync activation: 1 = active (scopes sync), 0/null = known (a
121
+ * discovered catalog that has not been activated). Insert-optional; own
122
+ * catalogs are created active, discovered ones arrive known.
123
+ */
124
+ active: ColumnType<number | null, number | null | undefined, number | null>;
125
+ /**
126
+ * Provenance: the group this catalog was first discovered from, null for own
127
+ * catalogs. First-discovery only — never overwritten on re-discovery.
128
+ */
129
+ source_group_id: ColumnType<string | null, string | null | undefined, string | null>;
130
+ /**
131
+ * Provenance: the circle whose `catalog_ids` first referenced this catalog
132
+ * (set only for invite-seeded discovery), null otherwise. First-discovery only.
133
+ */
134
+ source_circle_id: ColumnType<string | null, string | null | undefined, string | null>;
135
+ /**
136
+ * The creator-signed `catalog:set` token that authenticated this catalog: own
137
+ * catalogs sign it at creation, discovered ones store the verified token that
138
+ * delivered them. Forwarded verbatim in invite seeds so a joiner re-verifies
139
+ * creator-binding (`ownerDID === iss`) with the same helper the broadcast
140
+ * receive path uses. Null for rows that predate token capture.
141
+ */
142
+ signed_token: ColumnType<string | null, string | null | undefined, string | null>;
115
143
  created_at: CreatedAtColumn;
116
144
  updated_at: UpdatedAtColumn;
117
145
  };
@@ -142,6 +170,13 @@ export type CreateDocumentParams = DocumentParams & {
142
170
  export type SaveDocumentParams = DocumentParams & {
143
171
  existing: DocumentNode;
144
172
  };
173
+ /** One cluster's registration, written with the graph that deploys it. */
174
+ export type CreateGraphCluster = {
175
+ /** The cluster definition, stored verbatim for a peer to ship onward. */
176
+ definition: unknown;
177
+ /** modelID → index within the cluster. */
178
+ models: Record<string, number>;
179
+ };
145
180
  export type CreateGraphParams = {
146
181
  aliases?: Record<string, string>;
147
182
  extensionSDL?: string;
@@ -150,6 +185,13 @@ export type CreateGraphParams = {
150
185
  pluginConfig?: Record<string, Record<string, unknown>>;
151
186
  record: DocumentModelsRecord;
152
187
  search?: SearchConfig;
188
+ /**
189
+ * Clusters to register in the SAME transaction as the graph. The cluster a
190
+ * model was deployed in is what lets a peer ship that model's definition to a
191
+ * device that lacks it, so a graph written without its clusters is one that
192
+ * works locally and cannot be synced to a device that has never seen it.
193
+ */
194
+ clusters?: Record<string, CreateGraphCluster>;
153
195
  };
154
196
  export type AddDocumentModelParams = {
155
197
  id: string;
package/package.json CHANGED
@@ -1,40 +1,43 @@
1
1
  {
2
2
  "name": "@kubun/store-graph",
3
- "version": "0.11.0",
4
- "license": "see LICENSE.md",
3
+ "version": "0.13.0",
5
4
  "keywords": [],
5
+ "license": "see LICENSE.md",
6
+ "sideEffects": false,
6
7
  "type": "module",
7
- "main": "lib/index.js",
8
- "types": "lib/index.d.ts",
9
8
  "exports": {
10
9
  ".": "./lib/index.js"
11
10
  },
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
12
13
  "files": [
13
14
  "lib/*",
14
15
  "LICENSE.md"
15
16
  ],
16
- "sideEffects": false,
17
17
  "dependencies": {
18
- "@sozai/codec": "^0.1.0",
19
- "kysely": "^0.29.2",
20
- "@kubun/db": "^0.11.0",
21
- "@kubun/db-adapter": "^0.11.0",
22
- "@kubun/id": "^0.11.0",
23
- "@kubun/protocol": "^0.11.0"
18
+ "@sozai/codec": "^0.4.0",
19
+ "kysely": "^0.29.5",
20
+ "@kubun/db": "^0.12.1",
21
+ "@kubun/protocol": "^0.13.0",
22
+ "@kubun/db-adapter": "^0.13.0",
23
+ "@kubun/id": "^0.12.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@testcontainers/postgresql": "^12.0.4",
27
- "@kubun/db-better-sqlite": "^0.11.0",
28
- "@kubun/db-postgres": "^0.11.0"
26
+ "@testcontainers/postgresql": "^12.1.0",
27
+ "@kubun/db-better-sqlite": "^0.12.1",
28
+ "@kubun/db-postgres": "^0.12.1"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
29
32
  },
30
33
  "scripts": {
34
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
31
35
  "build:clean": "del lib",
32
36
  "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
33
37
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
34
38
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
35
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
39
+ "test": "pnpm run test:types && pnpm run test:unit",
36
40
  "test:types": "tsc --noEmit -p tsconfig.test.json",
37
- "test:unit": "vitest run",
38
- "test": "pnpm run test:types && pnpm run test:unit"
41
+ "test:unit": "vitest run"
39
42
  }
40
43
  }