@kubun/store-graph 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api.d.ts +2 -1
- package/lib/api.js +993 -1
- package/lib/cursor.d.ts +1 -0
- package/lib/cursor.js +7 -1
- package/lib/definition.js +7 -1
- package/lib/index.js +6 -1
- package/lib/migrations.js +62 -1
- package/lib/query-builder.d.ts +9 -3
- package/lib/query-builder.js +370 -1
- package/lib/tables.d.ts +1 -0
- package/lib/tables.js +1 -1
- package/package.json +10 -10
package/lib/cursor.d.ts
CHANGED
package/lib/cursor.js
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
-
import{b64uFromJSON
|
|
1
|
+
import { b64uFromJSON, b64uToJSON } from '@sozai/codec';
|
|
2
|
+
export function parseCursor(cursor) {
|
|
3
|
+
return b64uToJSON(cursor);
|
|
4
|
+
}
|
|
5
|
+
export function serializeCursor(data) {
|
|
6
|
+
return b64uFromJSON(data);
|
|
7
|
+
}
|
package/lib/definition.js
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
-
import{createGraphStore
|
|
1
|
+
import { createGraphStore } from './api.js';
|
|
2
|
+
import { getGraphMigrations } from './migrations.js';
|
|
3
|
+
export const graphStoreDefinition = {
|
|
4
|
+
name: 'graph',
|
|
5
|
+
migrations: getGraphMigrations,
|
|
6
|
+
createAPI: createGraphStore
|
|
7
|
+
};
|
package/lib/index.js
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
export{createGraphStore}from
|
|
1
|
+
export { createGraphStore } from './api.js';
|
|
2
|
+
export { graphStoreDefinition } from './definition.js';
|
|
3
|
+
export const GRAPH_STORE = 'graph';
|
|
4
|
+
export function getGraphStore(provider) {
|
|
5
|
+
return provider.getStore(GRAPH_STORE);
|
|
6
|
+
}
|
package/lib/migrations.js
CHANGED
|
@@ -1 +1,62 @@
|
|
|
1
|
-
export function getGraphMigrations(
|
|
1
|
+
export function getGraphMigrations(ctx) {
|
|
2
|
+
const t = ctx.types;
|
|
3
|
+
const now = ctx.functions.now;
|
|
4
|
+
const init = {
|
|
5
|
+
async up (db) {
|
|
6
|
+
// Document attachments
|
|
7
|
+
await db.schema.createTable('kubun_graph_document_attachments').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('data', t.binary, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
|
|
8
|
+
// Document models
|
|
9
|
+
await db.schema.createTable('kubun_graph_document_models').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('version', t.text, (col)=>col.notNull()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('behavior', t.text, (col)=>col.notNull()).addColumn('unique_fields', t.json).addColumn('interfaces', t.json, (col)=>col.notNull()).addColumn('schema', t.json, (col)=>col.notNull()).addColumn('fields_meta', t.json, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
|
|
10
|
+
// Document model interfaces
|
|
11
|
+
await db.schema.createTable('kubun_graph_document_model_interfaces').ifNotExists().addColumn('interface_id', t.text, (col)=>col.notNull().references('kubun_graph_document_models.id')).addColumn('implementation_id', t.text, (col)=>col.notNull().references('kubun_graph_document_models.id')).addUniqueConstraint('kubun_graph_document_model_interfaces_pkey', [
|
|
12
|
+
'interface_id',
|
|
13
|
+
'implementation_id'
|
|
14
|
+
]).execute();
|
|
15
|
+
// Graph models
|
|
16
|
+
await db.schema.createTable('kubun_graph_models').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('aliases', t.json, (col)=>col.defaultTo('{}').notNull()).addColumn('search', t.json).addColumn('extension_sdl', t.text).addColumn('plugin_config', t.json).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).execute();
|
|
17
|
+
// Graph document model links (junction: graph-model ↔ document-model)
|
|
18
|
+
await db.schema.createTable('kubun_graph_document_model_links').ifNotExists().addColumn('graph_model_id', t.text, (col)=>col.notNull().references('kubun_graph_models.id')).addColumn('document_model_id', t.text, (col)=>col.notNull().references('kubun_graph_document_models.id')).addUniqueConstraint('kubun_graph_document_model_links_pkey', [
|
|
19
|
+
'graph_model_id',
|
|
20
|
+
'document_model_id'
|
|
21
|
+
]).execute();
|
|
22
|
+
// 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', [
|
|
24
|
+
'owner_did',
|
|
25
|
+
'model_id',
|
|
26
|
+
'permission_type'
|
|
27
|
+
]).execute();
|
|
28
|
+
await db.schema.createIndex('idx_graph_user_model_access_owner_model').ifNotExists().on('kubun_graph_user_model_access_defaults').columns([
|
|
29
|
+
'owner_did',
|
|
30
|
+
'model_id'
|
|
31
|
+
]).execute();
|
|
32
|
+
// Mutation log
|
|
33
|
+
await db.schema.createTable('kubun_graph_mutation_log').ifNotExists().addColumn('mutation_hash', t.text, (col)=>col.primaryKey()).addColumn('model_id', t.text, (col)=>col.notNull()).addColumn('document_id', t.text, (col)=>col.notNull()).addColumn('author_did', t.text, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('mutation_jwt', t.text, (col)=>col.notNull()).addColumn('status', t.text, (col)=>col.notNull().defaultTo('applied')).execute();
|
|
34
|
+
await db.schema.createIndex('idx_graph_mutation_log_hlc').ifNotExists().on('kubun_graph_mutation_log').column('hlc').execute();
|
|
35
|
+
await db.schema.createIndex('idx_graph_mutation_log_document').ifNotExists().on('kubun_graph_mutation_log').column('document_id').execute();
|
|
36
|
+
await db.schema.createIndex('idx_graph_mutation_log_status').ifNotExists().on('kubun_graph_mutation_log').column('status').execute();
|
|
37
|
+
// Cluster models (model -> cluster mapping)
|
|
38
|
+
await db.schema.createTable('kubun_graph_cluster_models').ifNotExists().addColumn('model_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('cluster_id', t.text, (col)=>col.notNull()).addColumn('cluster_index', 'integer', (col)=>col.notNull()).execute();
|
|
39
|
+
await db.schema.createIndex('idx_graph_cluster_models_cluster').ifNotExists().on('kubun_graph_cluster_models').column('cluster_id').execute();
|
|
40
|
+
// Cluster definitions
|
|
41
|
+
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
|
+
// 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();
|
|
44
|
+
await db.schema.createIndex('idx_graph_catalogs_owner').ifNotExists().on('kubun_graph_catalogs').column('owner_did').execute();
|
|
45
|
+
},
|
|
46
|
+
async down (db) {
|
|
47
|
+
await db.schema.dropTable('kubun_graph_catalogs').ifExists().execute();
|
|
48
|
+
await db.schema.dropTable('kubun_graph_clusters').ifExists().execute();
|
|
49
|
+
await db.schema.dropTable('kubun_graph_cluster_models').ifExists().execute();
|
|
50
|
+
await db.schema.dropTable('kubun_graph_mutation_log').ifExists().execute();
|
|
51
|
+
await db.schema.dropTable('kubun_graph_user_model_access_defaults').ifExists().execute();
|
|
52
|
+
await db.schema.dropTable('kubun_graph_document_model_links').ifExists().execute();
|
|
53
|
+
await db.schema.dropTable('kubun_graph_models').ifExists().execute();
|
|
54
|
+
await db.schema.dropTable('kubun_graph_document_model_interfaces').ifExists().execute();
|
|
55
|
+
await db.schema.dropTable('kubun_graph_document_models').ifExists().execute();
|
|
56
|
+
await db.schema.dropTable('kubun_graph_document_attachments').ifExists().execute();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
'0-init': init
|
|
61
|
+
};
|
|
62
|
+
}
|
package/lib/query-builder.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Adapter } from '@kubun/db-adapter';
|
|
2
2
|
import type { AggregateSpec, AnyValueFilter, DocumentFilter, DocumentOrderBy } from '@kubun/protocol';
|
|
3
|
-
import type
|
|
3
|
+
import { type AliasedExpression, type ExpressionBuilder, type ExpressionWrapper, type SelectQueryBuilder } from 'kysely';
|
|
4
4
|
import type { ConnectionArguments, Document, GraphTables } from './tables.js';
|
|
5
5
|
export type CoerceFilterValue = (value: unknown) => unknown;
|
|
6
6
|
type DocumentExpressionBuilder = ExpressionBuilder<GraphTables, 'k_document'>;
|
|
@@ -16,8 +16,14 @@ export declare function applyGroupBySelections(eb: DocumentExpressionBuilder, gr
|
|
|
16
16
|
selections: Array<AggregateSelection>;
|
|
17
17
|
expressions: Array<DocumentExpressionWrapper>;
|
|
18
18
|
};
|
|
19
|
-
export declare function applyPagination(query: DocumentQueryBuilder, args: ConnectionArguments, orderBy?: DocumentOrderBy): [DocumentQueryBuilder, number];
|
|
19
|
+
export declare function applyPagination(query: DocumentQueryBuilder, args: ConnectionArguments, orderBy?: DocumentOrderBy, coerce?: CoerceFilterValue): [DocumentQueryBuilder, number];
|
|
20
20
|
export declare function applyDocumentFilter(eb: DocumentExpressionBuilder, filter: DocumentFilter, path?: Array<string>, coerce?: CoerceFilterValue): DocumentExpressionWrapper;
|
|
21
21
|
export declare function applyValueFilter(eb: DocumentExpressionBuilder, keys: Array<string>, filter: AnyValueFilter, coerce?: CoerceFilterValue): DocumentExpressionWrapper;
|
|
22
|
-
export declare function applyDocumentOrderBy(queryBuilder: DocumentQueryBuilder, orderBy?: DocumentOrderBy, isReverse?: boolean): [DocumentQueryBuilder, Array<Array<string>> | null];
|
|
22
|
+
export declare function applyDocumentOrderBy(queryBuilder: DocumentQueryBuilder, adapter: Adapter, orderBy?: DocumentOrderBy, isReverse?: boolean): [DocumentQueryBuilder, Array<Array<string>> | null];
|
|
23
|
+
export declare const KNOWN_FIELDS: {
|
|
24
|
+
readonly _createdAt: "created_at";
|
|
25
|
+
readonly _docOwner: "owner";
|
|
26
|
+
};
|
|
27
|
+
export type KnownFieldColumn = (typeof KNOWN_FIELDS)[keyof typeof KNOWN_FIELDS];
|
|
28
|
+
export declare function knownFieldColumn(keys: Array<string>): KnownFieldColumn | undefined;
|
|
23
29
|
export {};
|
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;
|
package/lib/tables.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{};
|
|
1
|
+
export { };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/store-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"type": "module",
|
|
@@ -15,21 +15,21 @@
|
|
|
15
15
|
],
|
|
16
16
|
"sideEffects": false,
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@
|
|
18
|
+
"@sozai/codec": "^0.1.0",
|
|
19
19
|
"kysely": "^0.29.2",
|
|
20
|
-
"@kubun/db": "^0.
|
|
21
|
-
"@kubun/
|
|
22
|
-
"@kubun/
|
|
23
|
-
"@kubun/protocol": "^0.
|
|
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"
|
|
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.0.4",
|
|
27
|
+
"@kubun/db-better-sqlite": "^0.11.0",
|
|
28
|
+
"@kubun/db-postgres": "^0.11.0"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build:clean": "del lib",
|
|
32
|
-
"build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
|
|
32
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
33
33
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
34
34
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
35
35
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|