@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 +81 -2
- package/lib/api.js +245 -29
- package/lib/errors.d.ts +34 -0
- package/lib/errors.js +45 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.js +1 -0
- package/lib/migrations.d.ts +13 -0
- package/lib/migrations.js +41 -3
- package/lib/query-builder.d.ts +3 -2
- package/lib/query-builder.js +48 -8
- package/lib/tables.d.ts +42 -0
- package/package.json +20 -17
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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_${
|
|
967
|
-
|
|
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
|
-
|
|
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));
|
package/lib/errors.d.ts
ADDED
|
@@ -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);
|
package/lib/migrations.d.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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())
|
|
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();
|
package/lib/query-builder.d.ts
CHANGED
|
@@ -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
|
|
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";
|
package/lib/query-builder.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
19
|
-
"kysely": "^0.29.
|
|
20
|
-
"@kubun/db": "^0.
|
|
21
|
-
"@kubun/
|
|
22
|
-
"@kubun/
|
|
23
|
-
"@kubun/
|
|
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
|
|
27
|
-
"@kubun/db-better-sqlite": "^0.
|
|
28
|
-
"@kubun/db-postgres": "^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
|
-
"
|
|
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
|
}
|