@kubun/store-graph 0.10.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api.d.ts +83 -3
- package/lib/api.js +1209 -1
- package/lib/cursor.d.ts +1 -0
- package/lib/cursor.js +7 -1
- package/lib/definition.js +7 -1
- package/lib/errors.d.ts +34 -0
- package/lib/errors.js +45 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.js +7 -1
- package/lib/migrations.d.ts +13 -0
- package/lib/migrations.js +100 -1
- package/lib/query-builder.d.ts +9 -3
- package/lib/query-builder.js +370 -1
- package/lib/tables.d.ts +43 -0
- package/lib/tables.js +1 -1
- package/package.json +18 -18
package/lib/query-builder.js
CHANGED
|
@@ -1 +1,370 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import { parseCursor } from './cursor.js';
|
|
3
|
+
const DEFAULT_LIMIT = 50;
|
|
4
|
+
const MAX_LIMIT = 100;
|
|
5
|
+
function getLimit(value = DEFAULT_LIMIT) {
|
|
6
|
+
return Math.min(value, MAX_LIMIT);
|
|
7
|
+
}
|
|
8
|
+
function getKeyPath(eb, keys) {
|
|
9
|
+
let keyPath = eb.ref('data', '->>');
|
|
10
|
+
for (const key of keys){
|
|
11
|
+
keyPath = keyPath.key(key);
|
|
12
|
+
}
|
|
13
|
+
return keyPath;
|
|
14
|
+
}
|
|
15
|
+
export function aggregateFieldName(keys) {
|
|
16
|
+
return keys.join('.');
|
|
17
|
+
}
|
|
18
|
+
export function applyAggregateSelections(eb, specs, adapter) {
|
|
19
|
+
const selections = [];
|
|
20
|
+
for (const spec of specs){
|
|
21
|
+
const field = aggregateFieldName(spec.keys);
|
|
22
|
+
const raw = getKeyPath(eb, spec.keys);
|
|
23
|
+
const value = spec.numeric ? adapter.numericCast(raw) : raw;
|
|
24
|
+
switch(spec.op){
|
|
25
|
+
case 'sum':
|
|
26
|
+
selections.push(eb.fn.sum(value).as(`sum_${field}`));
|
|
27
|
+
break;
|
|
28
|
+
case 'average':
|
|
29
|
+
selections.push(eb.fn.sum(value).as(`sum_${field}`));
|
|
30
|
+
selections.push(eb.fn.count(value).as(`count_${field}`));
|
|
31
|
+
break;
|
|
32
|
+
case 'min':
|
|
33
|
+
selections.push(eb.fn.min(value).as(`min_${field}`));
|
|
34
|
+
break;
|
|
35
|
+
case 'max':
|
|
36
|
+
selections.push(eb.fn.max(value).as(`max_${field}`));
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
throw new Error(`Invalid aggregate op: ${spec.op}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return selections;
|
|
43
|
+
}
|
|
44
|
+
export function groupKeyAlias(keys) {
|
|
45
|
+
return `key_${keys.join('.')}`;
|
|
46
|
+
}
|
|
47
|
+
// Builds the aliased group-key select expressions plus the matching GROUP BY
|
|
48
|
+
// expressions. Grouping is on the raw text extraction (no numeric cast): equality
|
|
49
|
+
// bucketing is correct on text, and the JSON encoder serialises a given value
|
|
50
|
+
// identically across model tables.
|
|
51
|
+
export function applyGroupBySelections(eb, groupBy) {
|
|
52
|
+
const selections = [];
|
|
53
|
+
const expressions = [];
|
|
54
|
+
for (const group of groupBy){
|
|
55
|
+
const raw = getKeyPath(eb, group.keys);
|
|
56
|
+
selections.push(raw.as(groupKeyAlias(group.keys)));
|
|
57
|
+
expressions.push(raw);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
selections,
|
|
61
|
+
expressions
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function getOrderByFieldEntry(field) {
|
|
65
|
+
const keys = [];
|
|
66
|
+
let current = field;
|
|
67
|
+
do {
|
|
68
|
+
const [key, value] = Object.entries(current)[0];
|
|
69
|
+
keys.push(key);
|
|
70
|
+
if (typeof value === 'string') {
|
|
71
|
+
return {
|
|
72
|
+
keys,
|
|
73
|
+
direction: value
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
current = value;
|
|
77
|
+
}while (current != null)
|
|
78
|
+
throw new Error('Could not extract field entry');
|
|
79
|
+
}
|
|
80
|
+
function orderByDirection(direction, isReversed = false) {
|
|
81
|
+
return isReversed ? direction === 'asc' ? 'desc' : 'asc' : direction;
|
|
82
|
+
}
|
|
83
|
+
export function applyPagination(query, args, orderBy = [], coerce) {
|
|
84
|
+
const { first, last, before, after } = args;
|
|
85
|
+
if (first != null) {
|
|
86
|
+
const limit = getLimit(first);
|
|
87
|
+
return [
|
|
88
|
+
applyForwardPagination(query, limit + 1, after, orderBy, coerce),
|
|
89
|
+
limit
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
if (last != null) {
|
|
93
|
+
const limit = getLimit(last);
|
|
94
|
+
return [
|
|
95
|
+
applyBackwardPagination(query, limit + 1, before, orderBy, coerce),
|
|
96
|
+
limit
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
const limit = getLimit();
|
|
100
|
+
return [
|
|
101
|
+
query.limit(limit + 1),
|
|
102
|
+
limit
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
// Resolves an ordered field to the same SQL expression the ORDER BY uses, so the
|
|
106
|
+
// keyset predicate compares against the exact column being sorted. Known fields map
|
|
107
|
+
// to real columns (created_at, owner); everything else is a JSON path extraction.
|
|
108
|
+
function orderByFieldExpression(eb, keys) {
|
|
109
|
+
const knownField = knownFieldColumn(keys);
|
|
110
|
+
if (knownField != null) {
|
|
111
|
+
return eb.ref(knownField);
|
|
112
|
+
}
|
|
113
|
+
return getKeyPath(eb, keys);
|
|
114
|
+
}
|
|
115
|
+
// The term selecting rows that strictly advance past the cursor on this field (prior
|
|
116
|
+
// fields equal). Encodes the null bucket per SQL-standard position:
|
|
117
|
+
// ascending (nulls last): cursor non-null -> f > v OR f IS NULL ; cursor null -> nothing
|
|
118
|
+
// descending (nulls first): cursor non-null -> f < v ; cursor null -> f IS NOT NULL
|
|
119
|
+
function advanceTerm(eb, field) {
|
|
120
|
+
if (field.value == null) {
|
|
121
|
+
// Ascending nulls sort last, so nothing advances past a null cursor on this field.
|
|
122
|
+
return field.ascending ? sql`1 = 0` : eb(field.expr, 'is not', null);
|
|
123
|
+
}
|
|
124
|
+
if (field.ascending) {
|
|
125
|
+
return eb.or([
|
|
126
|
+
eb(field.expr, '>', field.value),
|
|
127
|
+
eb(field.expr, 'is', null)
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
return eb(field.expr, '<', field.value);
|
|
131
|
+
}
|
|
132
|
+
// The term selecting rows that tie this field with the cursor (continue to the next
|
|
133
|
+
// field). A null cursor value ties with other null rows.
|
|
134
|
+
function equalTerm(eb, field) {
|
|
135
|
+
return field.value == null ? eb(field.expr, 'is', null) : eb(field.expr, '=', field.value);
|
|
136
|
+
}
|
|
137
|
+
// Builds the lexicographic keyset predicate that pairs with an ORDER BY whose terminal
|
|
138
|
+
// column is `id`. For ORDER BY (f1..fn, id) the rows strictly after the cursor are:
|
|
139
|
+
// advance(f1)
|
|
140
|
+
// OR (eq(f1) AND advance(f2))
|
|
141
|
+
// ...
|
|
142
|
+
// OR (eq(f1) AND ... AND eq(fn) AND id idCmp idCursor)
|
|
143
|
+
// The tie-break on `id` guarantees no duplicate or skipped row when ordered values
|
|
144
|
+
// repeat. Null ordered values stay in the chain (tagged via `nullKeys`) and sort in
|
|
145
|
+
// SQL-standard position, so concatenated pages match the full ordered set across the
|
|
146
|
+
// null boundary. `forward` true is a forward scan; false flips every field's effective
|
|
147
|
+
// direction (and thus its null position) for a reversed scan.
|
|
148
|
+
function buildKeysetPredicate(eb, orderBy, values, nullKeys, id, forward, coerce) {
|
|
149
|
+
// Cursor comparison values must be coerced the same way filter values are: a JSON
|
|
150
|
+
// path extraction (`data ->> field`) yields adapter-specific text/integer forms
|
|
151
|
+
// (e.g. 1/0 for booleans on SQLite), so an uncoerced cursor value would misfire and
|
|
152
|
+
// drift pages. Known-column fields (created_at, owner) are not JSON-extracted.
|
|
153
|
+
const c = coerce ?? ((v)=>v);
|
|
154
|
+
const nullSet = new Set(nullKeys);
|
|
155
|
+
const fields = [];
|
|
156
|
+
for (const orderByField of orderBy){
|
|
157
|
+
const entry = getOrderByFieldEntry(orderByField);
|
|
158
|
+
const key = entry.keys.join('.');
|
|
159
|
+
const ascending = entry.direction === 'asc' === forward;
|
|
160
|
+
const isKnownField = knownFieldColumn(entry.keys) != null;
|
|
161
|
+
// A key absent from both buckets (stale or malformed cursor) is treated as the null
|
|
162
|
+
// bucket rather than binding an undefined comparison value.
|
|
163
|
+
const isNull = nullSet.has(key) || !(key in values);
|
|
164
|
+
const rawValue = values[key];
|
|
165
|
+
fields.push({
|
|
166
|
+
expr: orderByFieldExpression(eb, entry.keys),
|
|
167
|
+
value: isNull ? null : isKnownField ? rawValue : c(rawValue),
|
|
168
|
+
ascending
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const idCmp = forward ? '>' : '<';
|
|
172
|
+
const clauses = [];
|
|
173
|
+
for(let level = 0; level < fields.length; level += 1){
|
|
174
|
+
const conjuncts = [];
|
|
175
|
+
for(let prior = 0; prior < level; prior += 1){
|
|
176
|
+
conjuncts.push(equalTerm(eb, fields[prior]));
|
|
177
|
+
}
|
|
178
|
+
conjuncts.push(advanceTerm(eb, fields[level]));
|
|
179
|
+
clauses.push(eb.and(conjuncts));
|
|
180
|
+
}
|
|
181
|
+
const tieBreak = [];
|
|
182
|
+
for (const field of fields){
|
|
183
|
+
tieBreak.push(equalTerm(eb, field));
|
|
184
|
+
}
|
|
185
|
+
tieBreak.push(eb('id', idCmp, id));
|
|
186
|
+
clauses.push(eb.and(tieBreak));
|
|
187
|
+
return eb.or(clauses);
|
|
188
|
+
}
|
|
189
|
+
function applyForwardPagination(queryBuilder, limit, after, orderBy, coerce) {
|
|
190
|
+
let query = queryBuilder;
|
|
191
|
+
if (after != null) {
|
|
192
|
+
const { id, ts, values, nullKeys } = parseCursor(after);
|
|
193
|
+
if (ts != null) {
|
|
194
|
+
query = query.where((eb)=>{
|
|
195
|
+
return eb.or([
|
|
196
|
+
eb('created_at', '>', ts),
|
|
197
|
+
eb('created_at', '=', ts).and('id', '>', id)
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
} else if (values != null || nullKeys != null) {
|
|
201
|
+
query = query.where((eb)=>buildKeysetPredicate(eb, orderBy, values ?? {}, nullKeys ?? [], id, true, coerce));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return query.limit(limit);
|
|
205
|
+
}
|
|
206
|
+
function applyBackwardPagination(queryBuilder, limit, before, orderBy, coerce) {
|
|
207
|
+
let query = queryBuilder;
|
|
208
|
+
if (before != null) {
|
|
209
|
+
const { id, ts, values, nullKeys } = parseCursor(before);
|
|
210
|
+
if (ts != null) {
|
|
211
|
+
query = query.where((eb)=>{
|
|
212
|
+
return eb.or([
|
|
213
|
+
eb('created_at', '<', ts),
|
|
214
|
+
eb('created_at', '=', ts).and('id', '<', id)
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
} else if (values != null || nullKeys != null) {
|
|
218
|
+
query = query.where((eb)=>buildKeysetPredicate(eb, orderBy, values ?? {}, nullKeys ?? [], id, false, coerce));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return query.limit(limit);
|
|
222
|
+
}
|
|
223
|
+
export function applyDocumentFilter(eb, filter, path = [], coerce) {
|
|
224
|
+
const entries = Object.entries(filter);
|
|
225
|
+
if (entries.length !== 1) {
|
|
226
|
+
throw new Error('Invalid document filter');
|
|
227
|
+
}
|
|
228
|
+
const [type, value] = entries[0];
|
|
229
|
+
switch(type){
|
|
230
|
+
case 'where':
|
|
231
|
+
return applyObjectFilter(eb, value, path, coerce);
|
|
232
|
+
case 'and':
|
|
233
|
+
return eb.and(value.map((filter)=>{
|
|
234
|
+
return applyDocumentFilter(eb, filter, path, coerce);
|
|
235
|
+
}));
|
|
236
|
+
case 'or':
|
|
237
|
+
return eb.or(value.map((filter)=>{
|
|
238
|
+
return applyDocumentFilter(eb, filter, path, coerce);
|
|
239
|
+
}));
|
|
240
|
+
case 'not':
|
|
241
|
+
return eb.not(applyDocumentFilter(eb, value, path, coerce));
|
|
242
|
+
default:
|
|
243
|
+
throw new Error(`Invalid document filter type: ${type}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function applyObjectFilter(eb, filter, path, coerce) {
|
|
247
|
+
const criteria = Object.entries(filter).map(([fieldName, valueFilter])=>{
|
|
248
|
+
// TODO: check if value filter or nested object filter should be applied
|
|
249
|
+
// for nested object, check if embedded or related object
|
|
250
|
+
return applyValueFilter(eb, [
|
|
251
|
+
...path,
|
|
252
|
+
fieldName
|
|
253
|
+
], valueFilter, coerce);
|
|
254
|
+
});
|
|
255
|
+
return eb.and(criteria);
|
|
256
|
+
}
|
|
257
|
+
export function applyValueFilter(eb, keys, filter, coerce) {
|
|
258
|
+
const entries = Object.entries(filter);
|
|
259
|
+
if (entries.length !== 1) {
|
|
260
|
+
throw new Error('Invalid value filter');
|
|
261
|
+
}
|
|
262
|
+
const fieldName = getKeyPath(eb, keys);
|
|
263
|
+
const [type, value] = entries[0];
|
|
264
|
+
const c = coerce ?? ((v)=>v);
|
|
265
|
+
switch(type){
|
|
266
|
+
case 'isNull':
|
|
267
|
+
return eb(fieldName, value === true ? 'is' : 'is not', null);
|
|
268
|
+
case 'equalTo':
|
|
269
|
+
return eb(fieldName, '=', c(value));
|
|
270
|
+
case 'notEqualTo':
|
|
271
|
+
return eb(fieldName, '!=', c(value));
|
|
272
|
+
case 'in':
|
|
273
|
+
return eb(fieldName, 'in', value.map(c));
|
|
274
|
+
case 'notIn':
|
|
275
|
+
return eb(fieldName, 'not in', value.map(c));
|
|
276
|
+
case 'lessThan':
|
|
277
|
+
return eb(fieldName, '<', c(value));
|
|
278
|
+
case 'lessThanOrEqualTo':
|
|
279
|
+
return eb(fieldName, '<=', c(value));
|
|
280
|
+
case 'greaterThan':
|
|
281
|
+
return eb(fieldName, '>', c(value));
|
|
282
|
+
case 'greaterThanOrEqualTo':
|
|
283
|
+
return eb(fieldName, '>=', c(value));
|
|
284
|
+
default:
|
|
285
|
+
throw new Error(`Invalid value filter type: ${type}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
export function applyDocumentOrderBy(queryBuilder, adapter, orderBy = [], isReverse = false) {
|
|
289
|
+
// `id` is the terminal sort column on every path so the cursor keyset can tie-break
|
|
290
|
+
// on a column that is actually part of the ORDER BY. Without it, repeated ordered
|
|
291
|
+
// values would let pagination duplicate or skip rows. `id` sorts in the scan
|
|
292
|
+
// direction (reversed when isReverse) so the keyset comparator stays consistent.
|
|
293
|
+
const idDirection = orderByDirection('asc', isReverse);
|
|
294
|
+
if (orderBy.length === 0) {
|
|
295
|
+
return [
|
|
296
|
+
queryBuilder.orderBy('created_at', idDirection).orderBy('id', idDirection),
|
|
297
|
+
null
|
|
298
|
+
];
|
|
299
|
+
}
|
|
300
|
+
let query = queryBuilder;
|
|
301
|
+
const paths = [];
|
|
302
|
+
for (const entry of orderBy){
|
|
303
|
+
const [newQuery, path] = applyOrderByField(query, adapter, entry, isReverse);
|
|
304
|
+
query = newQuery;
|
|
305
|
+
paths.push(path);
|
|
306
|
+
}
|
|
307
|
+
query = query.orderBy('id', idDirection);
|
|
308
|
+
return [
|
|
309
|
+
query,
|
|
310
|
+
paths
|
|
311
|
+
];
|
|
312
|
+
}
|
|
313
|
+
// Ordered fields that map directly to non-null table columns instead of a JSON-path
|
|
314
|
+
// extraction out of `data`. Keys match the GraphQL orderBy field names; values are the
|
|
315
|
+
// physical column names. Cursor construction must read these from the row column, not
|
|
316
|
+
// from `data`, or the ordered value comes back undefined and the keyset predicate drops
|
|
317
|
+
// every row past the first page.
|
|
318
|
+
export const KNOWN_FIELDS = {
|
|
319
|
+
_createdAt: 'created_at',
|
|
320
|
+
_docOwner: 'owner'
|
|
321
|
+
};
|
|
322
|
+
export function knownFieldColumn(keys) {
|
|
323
|
+
if (keys.length !== 1) return undefined;
|
|
324
|
+
return KNOWN_FIELDS[keys[0]];
|
|
325
|
+
}
|
|
326
|
+
function applyOrderByField(query, adapter, orderBy, isReverse, path = []) {
|
|
327
|
+
const entries = Object.entries(orderBy);
|
|
328
|
+
if (entries.length !== 1) {
|
|
329
|
+
throw new Error('Invalid order by field');
|
|
330
|
+
}
|
|
331
|
+
const [key, value] = entries[0];
|
|
332
|
+
const keys = [
|
|
333
|
+
...path,
|
|
334
|
+
key
|
|
335
|
+
];
|
|
336
|
+
const knownField = knownFieldColumn(keys);
|
|
337
|
+
if (knownField != null) {
|
|
338
|
+
// Known columns (created_at, owner) are non-null, so no null positioning is needed.
|
|
339
|
+
return [
|
|
340
|
+
query.orderBy(knownField, orderByDirection(value, isReverse)),
|
|
341
|
+
keys
|
|
342
|
+
];
|
|
343
|
+
}
|
|
344
|
+
if (typeof value !== 'string') {
|
|
345
|
+
return applyOrderByField(query, adapter, value, isReverse, keys);
|
|
346
|
+
}
|
|
347
|
+
// JSON-extracted fields can be null. Sort them in SQL-standard null position (last on
|
|
348
|
+
// ascending, first on descending) on every adapter via the adapter's null-ordering term.
|
|
349
|
+
const direction = orderByDirection(value, isReverse);
|
|
350
|
+
const probe = adapter.nullOrdering(sql`1`, direction);
|
|
351
|
+
if (probe.kind === 'lead') {
|
|
352
|
+
// Prepend an ascending rank column that buckets nulls last/first, then sort on the
|
|
353
|
+
// field itself. The rank expression embeds the field's own JSON-path extraction.
|
|
354
|
+
return [
|
|
355
|
+
query.orderBy((eb)=>adapterLeadExpression(adapter, eb, keys, direction), 'asc').orderBy((eb)=>getKeyPath(eb, keys), direction),
|
|
356
|
+
keys
|
|
357
|
+
];
|
|
358
|
+
}
|
|
359
|
+
return [
|
|
360
|
+
query.orderBy((eb)=>getKeyPath(eb, keys), (ob)=>direction === 'asc' ? ob.asc().nullsLast() : ob.desc().nullsFirst()),
|
|
361
|
+
keys
|
|
362
|
+
];
|
|
363
|
+
}
|
|
364
|
+
function adapterLeadExpression(adapter, eb, keys, direction) {
|
|
365
|
+
const term = adapter.nullOrdering(getKeyPath(eb, keys), direction);
|
|
366
|
+
if (term.kind !== 'lead') {
|
|
367
|
+
throw new Error('Expected a leading null-ordering term');
|
|
368
|
+
}
|
|
369
|
+
return term.expression;
|
|
370
|
+
}
|
package/lib/tables.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type DocumentTable<Data extends DocumentData = DocumentData> = {
|
|
|
10
10
|
model: string;
|
|
11
11
|
data: JSONValueColumn<Data> | null;
|
|
12
12
|
field_hlcs: JSONValueColumn<Record<string, string>> | null;
|
|
13
|
+
field_values: JSONValueColumn<Record<string, unknown>> | null;
|
|
13
14
|
unique: Uint8Array;
|
|
14
15
|
created_at: CreatedAtColumn;
|
|
15
16
|
updated_at: UpdatedAtColumn;
|
|
@@ -98,6 +99,10 @@ export type UserModelAccessDefaultTable = {
|
|
|
98
99
|
allowed_dids: JSONValueColumn<Array<string>> | null;
|
|
99
100
|
allowed_circles: JSONValueColumn<Array<string>> | null;
|
|
100
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;
|
|
101
106
|
created_at: CreatedAtColumn;
|
|
102
107
|
updated_at: UpdatedAtColumn;
|
|
103
108
|
};
|
|
@@ -111,6 +116,30 @@ export type CatalogTable = {
|
|
|
111
116
|
description: string;
|
|
112
117
|
filter_criteria: JSONValueColumn<CatalogFilterCriteria>;
|
|
113
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>;
|
|
114
143
|
created_at: CreatedAtColumn;
|
|
115
144
|
updated_at: UpdatedAtColumn;
|
|
116
145
|
};
|
|
@@ -141,6 +170,13 @@ export type CreateDocumentParams = DocumentParams & {
|
|
|
141
170
|
export type SaveDocumentParams = DocumentParams & {
|
|
142
171
|
existing: DocumentNode;
|
|
143
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
|
+
};
|
|
144
180
|
export type CreateGraphParams = {
|
|
145
181
|
aliases?: Record<string, string>;
|
|
146
182
|
extensionSDL?: string;
|
|
@@ -149,6 +185,13 @@ export type CreateGraphParams = {
|
|
|
149
185
|
pluginConfig?: Record<string, Record<string, unknown>>;
|
|
150
186
|
record: DocumentModelsRecord;
|
|
151
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>;
|
|
152
195
|
};
|
|
153
196
|
export type AddDocumentModelParams = {
|
|
154
197
|
id: string;
|
package/lib/tables.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{};
|
|
1
|
+
export { };
|
package/package.json
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/store-graph",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"license": "see LICENSE.md",
|
|
3
|
+
"version": "0.12.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
|
-
"@
|
|
19
|
-
"kysely": "^0.29.
|
|
20
|
-
"@kubun/db": "^0.
|
|
21
|
-
"@kubun/
|
|
22
|
-
"@kubun/
|
|
23
|
-
"@kubun/
|
|
18
|
+
"@sozai/codec": "^0.4.0",
|
|
19
|
+
"kysely": "^0.29.4",
|
|
20
|
+
"@kubun/db": "^0.12.0",
|
|
21
|
+
"@kubun/db-adapter": "^0.12.0",
|
|
22
|
+
"@kubun/protocol": "^0.12.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.0",
|
|
28
|
+
"@kubun/db-postgres": "^0.12.0"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
31
32
|
"build:clean": "del lib",
|
|
32
|
-
"build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
|
|
33
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
33
34
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
34
35
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
35
|
-
"
|
|
36
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
36
37
|
"test:types": "tsc --noEmit -p tsconfig.test.json",
|
|
37
|
-
"test:unit": "vitest run"
|
|
38
|
-
"test": "pnpm run test:types && pnpm run test:unit"
|
|
38
|
+
"test:unit": "vitest run"
|
|
39
39
|
}
|
|
40
40
|
}
|