letopis 0.5.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/CHANGELOG.md +77 -0
- package/README.md +799 -0
- package/dist/chain.d.ts +126 -0
- package/dist/chain.js +278 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +43 -0
- package/dist/ops.d.ts +42 -0
- package/dist/ops.js +43 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.js +133 -0
- package/dist/sql.d.ts +92 -0
- package/dist/sql.js +480 -0
- package/dist/tables.d.ts +81 -0
- package/dist/tables.js +148 -0
- package/dist/tx.d.ts +13 -0
- package/dist/tx.js +31 -0
- package/dist/types.d.ts +166 -0
- package/dist/types.js +5 -0
- package/dist/write.d.ts +60 -0
- package/dist/write.js +302 -0
- package/package.json +46 -0
package/dist/sql.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { OP } from './types.js';
|
|
2
|
+
import { isOp } from './ops.js';
|
|
3
|
+
/**
|
|
4
|
+
* Единая точка исполнения запросов цепочек: sql.unsafe + событие onQuery/slowMs.
|
|
5
|
+
* Служебные запросы (loadRegistry, tables, listen) через неё не идут.
|
|
6
|
+
*/
|
|
7
|
+
export async function runQuery(ctx, text, params, mode, classes) {
|
|
8
|
+
const t0 = performance.now();
|
|
9
|
+
const res = (await ctx.sql.unsafe(text, params));
|
|
10
|
+
if (ctx.onQuery || ctx.slowMs !== undefined) {
|
|
11
|
+
const ms = performance.now() - t0;
|
|
12
|
+
const slow = ctx.slowMs !== undefined && ms > ctx.slowMs;
|
|
13
|
+
if (ctx.onQuery)
|
|
14
|
+
ctx.onQuery({ mode, classes, ms, rows: res.length, slow });
|
|
15
|
+
else if (slow)
|
|
16
|
+
console.warn(`letopis: slow query ${ms.toFixed(0)}ms — ${mode} ${classes.join('→')}`);
|
|
17
|
+
}
|
|
18
|
+
return res;
|
|
19
|
+
}
|
|
20
|
+
/** Накопитель позиционных параметров. */
|
|
21
|
+
class Params {
|
|
22
|
+
list = [];
|
|
23
|
+
push(v) {
|
|
24
|
+
this.list.push(v);
|
|
25
|
+
return '$' + this.list.length;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const escId = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
29
|
+
const escLit = (s) => s.replace(/'/g, "''");
|
|
30
|
+
export const entityTable = (pgSchema) => `${escId(pgSchema)}.${escId('Entity')}`;
|
|
31
|
+
function castOf(ft) {
|
|
32
|
+
switch (ft?.kind) {
|
|
33
|
+
case 'number':
|
|
34
|
+
return '::numeric';
|
|
35
|
+
case 'date':
|
|
36
|
+
return '::timestamptz';
|
|
37
|
+
case 'boolean':
|
|
38
|
+
return '::boolean';
|
|
39
|
+
default:
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Каст параметра по JS-типу (для jsonb_build_array). */
|
|
44
|
+
const jsCast = (v) => typeof v === 'number' ? '::numeric' : typeof v === 'boolean' ? '::boolean' : '::text';
|
|
45
|
+
/** Доступ к полю data: 1 уровень — data->>'f'; 2 (record) — data->'f'->>'k'. */
|
|
46
|
+
function accessor(path) {
|
|
47
|
+
if (path.length === 1)
|
|
48
|
+
return (a) => `${a}.data->>'${escLit(path[0])}'`;
|
|
49
|
+
return (a) => `${a}.data->'${escLit(path[0])}'->>'${escLit(path[1])}'`;
|
|
50
|
+
}
|
|
51
|
+
function compileOp(path, ft, o, p) {
|
|
52
|
+
const lhs = accessor(path);
|
|
53
|
+
const cast = castOf(ft);
|
|
54
|
+
// скобки обязательны: '::' сильнее '->>' (data->>'f'::ts кастил бы литерал 'f')
|
|
55
|
+
const L = cast ? (a) => `(${lhs(a)})${cast}` : lhs;
|
|
56
|
+
const name = o[OP];
|
|
57
|
+
const args = o.args;
|
|
58
|
+
switch (name) {
|
|
59
|
+
case 'ne':
|
|
60
|
+
return (a) => `${L(a)} IS DISTINCT FROM ${p.push(args[0])}${cast}`;
|
|
61
|
+
case 'gt':
|
|
62
|
+
case 'gte':
|
|
63
|
+
case 'lt':
|
|
64
|
+
case 'lte': {
|
|
65
|
+
const sym = { gt: '>', gte: '>=', lt: '<', lte: '<=' }[name];
|
|
66
|
+
return (a) => `${L(a)} ${sym} ${p.push(args[0])}${cast}`;
|
|
67
|
+
}
|
|
68
|
+
case 'between':
|
|
69
|
+
return (a) => `${L(a)} BETWEEN ${p.push(args[0])}${cast} AND ${p.push(args[1])}${cast}`;
|
|
70
|
+
case 'in': {
|
|
71
|
+
const vs = args[0];
|
|
72
|
+
if (!vs.length)
|
|
73
|
+
return () => 'FALSE';
|
|
74
|
+
return (a) => `${L(a)} IN (${vs.map((v) => p.push(v) + cast).join(', ')})`;
|
|
75
|
+
}
|
|
76
|
+
case 'like':
|
|
77
|
+
return (a) => `${lhs(a)} LIKE ${p.push(args[0])}`;
|
|
78
|
+
case 'ilike':
|
|
79
|
+
return (a) => `${lhs(a)} ILIKE ${p.push(args[0])}`;
|
|
80
|
+
case 'has':
|
|
81
|
+
// jsonb_build_array с кастом по JS-типу — JS-массив postgres.js слал бы как PG-array
|
|
82
|
+
return (a) => `${a}.data->'${escLit(path[0])}' @> jsonb_build_array(${p.push(args[0])}${jsCast(args[0])})`;
|
|
83
|
+
case 'hasAll': {
|
|
84
|
+
const vs = args[0];
|
|
85
|
+
if (!vs.length)
|
|
86
|
+
return () => 'TRUE';
|
|
87
|
+
return (a) => `${a}.data->'${escLit(path[0])}' @> jsonb_build_array(${vs.map((x) => p.push(x) + jsCast(x)).join(', ')})`;
|
|
88
|
+
}
|
|
89
|
+
case 'hasAny': {
|
|
90
|
+
const vs = args[0];
|
|
91
|
+
if (!vs.length)
|
|
92
|
+
return () => 'FALSE';
|
|
93
|
+
return (a) => `${a}.data->'${escLit(path[0])}' ?| ARRAY[${vs.map((x) => p.push(String(x))).join(', ')}]::text[]`;
|
|
94
|
+
}
|
|
95
|
+
case 'exists':
|
|
96
|
+
return args[0]
|
|
97
|
+
? (a) => `${a}.data ? '${escLit(path[0])}'`
|
|
98
|
+
: (a) => `NOT (${a}.data ? '${escLit(path[0])}')`;
|
|
99
|
+
case 'isNull':
|
|
100
|
+
return (a) => `${lhs(a)} IS NULL`;
|
|
101
|
+
case 'not': {
|
|
102
|
+
const inner = isOp(args[0])
|
|
103
|
+
? compileOp(path, ft, args[0], p)
|
|
104
|
+
: compileOp(path, ft, { [OP]: 'ne', args: [args[0]] }, p);
|
|
105
|
+
return isOp(args[0]) ? (a) => `NOT (${inner(a)})` : inner; // not(скаляр) = ne
|
|
106
|
+
}
|
|
107
|
+
default:
|
|
108
|
+
throw new Error(`letopis: unknown operator "${name}"`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Операторы на колонку tags (text[]): строка | string[] (все) | has/hasAny/hasAll. */
|
|
112
|
+
function compileTags(v, p) {
|
|
113
|
+
if (typeof v === 'string')
|
|
114
|
+
return (a) => `${a}.tags @> ARRAY[${p.push(v)}]::text[]`;
|
|
115
|
+
if (Array.isArray(v)) {
|
|
116
|
+
if (!v.length)
|
|
117
|
+
return () => 'TRUE';
|
|
118
|
+
return (a) => `${a}.tags @> ARRAY[${v.map((x) => p.push(x)).join(', ')}]::text[]`;
|
|
119
|
+
}
|
|
120
|
+
if (isOp(v)) {
|
|
121
|
+
const args = v.args;
|
|
122
|
+
switch (v[OP]) {
|
|
123
|
+
case 'has':
|
|
124
|
+
return (a) => `${a}.tags @> ARRAY[${p.push(args[0])}]::text[]`;
|
|
125
|
+
case 'hasAll':
|
|
126
|
+
return (a) => `${a}.tags @> ARRAY[${args[0].map((x) => p.push(x)).join(', ')}]::text[]`;
|
|
127
|
+
case 'hasAny':
|
|
128
|
+
return (a) => `${a}.tags && ARRAY[${args[0].map((x) => p.push(x)).join(', ')}]::text[]`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw new Error('letopis: $tags accepts a string or has/hasAny/hasAll');
|
|
132
|
+
}
|
|
133
|
+
function compileFilter(cls, filter, p) {
|
|
134
|
+
const out = { idConds: [], candFrags: [], finalFrags: [] };
|
|
135
|
+
if (filter === undefined)
|
|
136
|
+
return out;
|
|
137
|
+
// or(...фильтры): дизъюнкция под-фильтров; только перепроверка (в GIN-кандидаты не идёт)
|
|
138
|
+
if (isOp(filter) && filter[OP] === 'or') {
|
|
139
|
+
const parts = filter.args[0].map((f) => {
|
|
140
|
+
const sub = compileFilter(cls, f, p);
|
|
141
|
+
const frags = [...sub.idConds, ...sub.finalFrags];
|
|
142
|
+
return (a) => (frags.length ? `(${frags.map((fr) => fr(a)).join(' AND ')})` : 'TRUE');
|
|
143
|
+
});
|
|
144
|
+
if (parts.length)
|
|
145
|
+
out.finalFrags.push((a) => `(${parts.map((fr) => fr(a)).join(' OR ')})`);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
if (typeof filter === 'string') {
|
|
149
|
+
out.idConds.push((a) => `${a}.id = ${p.push(filter)}`);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(filter)) {
|
|
153
|
+
if (!filter.length)
|
|
154
|
+
out.idConds.push(() => 'FALSE');
|
|
155
|
+
else
|
|
156
|
+
out.idConds.push((a) => `${a}.id IN (${filter.map((x) => p.push(x)).join(', ')})`);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
const eqData = {};
|
|
160
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
161
|
+
if (value === undefined)
|
|
162
|
+
continue;
|
|
163
|
+
if (key === 'id') {
|
|
164
|
+
if (typeof value === 'string')
|
|
165
|
+
out.idConds.push((a) => `${a}.id = ${p.push(value)}`);
|
|
166
|
+
else if (Array.isArray(value))
|
|
167
|
+
out.idConds.push((a) => `${a}.id IN (${value.map((x) => p.push(x)).join(', ')})`);
|
|
168
|
+
else
|
|
169
|
+
throw new Error('letopis: filter.id accepts string | string[]');
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const ft = cls.fieldTypes.get(key);
|
|
173
|
+
if (isOp(value)) {
|
|
174
|
+
out.finalFrags.push(compileOp([key], ft, value, p));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
178
|
+
// объект: операторы по вложенным путям (record) и/или containment-скаляры
|
|
179
|
+
const nestedEq = {};
|
|
180
|
+
let hasNested = false;
|
|
181
|
+
for (const [sub, sv] of Object.entries(value)) {
|
|
182
|
+
if (isOp(sv)) {
|
|
183
|
+
const vt = ft?.kind === 'record' ? ft.value : undefined;
|
|
184
|
+
out.finalFrags.push(compileOp([key, sub], vt, sv, p));
|
|
185
|
+
hasNested = true;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
nestedEq[sub] = sv;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (Object.keys(nestedEq).length)
|
|
192
|
+
eqData[key] = nestedEq;
|
|
193
|
+
else if (!hasNested)
|
|
194
|
+
eqData[key] = value;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// скаляр / массив — точное совпадение через containment (GIN)
|
|
198
|
+
eqData[key] = value;
|
|
199
|
+
}
|
|
200
|
+
if (Object.keys(eqData).length) {
|
|
201
|
+
const ph = p.push(eqData); // объектом — postgres.js сериализует в jsonb сам
|
|
202
|
+
const frag = (a) => `${a}.data @> ${ph}::jsonb`;
|
|
203
|
+
out.candFrags.push(frag);
|
|
204
|
+
out.finalFrags.push(frag);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
/** Правило обхода между шагами (по реестру Schema). */
|
|
209
|
+
export function resolveHop(prev, next) {
|
|
210
|
+
if (prev.category === 'HUB' && next.category === 'LINK')
|
|
211
|
+
return 'reverse';
|
|
212
|
+
if (prev.category === 'LINK' && next.category === 'HUB')
|
|
213
|
+
return 'forward';
|
|
214
|
+
if (prev.category === 'HUB' && next.category === 'HUB') {
|
|
215
|
+
// self-переход (Папка→Папка) = reverse: дети; родитель и так лежит в row.links
|
|
216
|
+
if (prev.id === next.id)
|
|
217
|
+
return 'reverse';
|
|
218
|
+
if (prev.links.includes(next.id))
|
|
219
|
+
return 'forward';
|
|
220
|
+
if (next.links.includes(prev.id))
|
|
221
|
+
return 'reverse';
|
|
222
|
+
throw new Error(`letopis: no path ${prev.id} → ${next.id}: neither embeds the other (Schema.links)`);
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`letopis: LINK → LINK traversal is not supported (${prev.id} → ${next.id})`);
|
|
225
|
+
}
|
|
226
|
+
/** Тип листа data-пути (для каста агрегаций): 1 уровень или record-значение. */
|
|
227
|
+
function cls0Field(cls, path) {
|
|
228
|
+
const ft = cls.fieldTypes.get(path[0]);
|
|
229
|
+
return path.length === 1 ? ft : ft?.kind === 'record' ? ft.value : undefined;
|
|
230
|
+
}
|
|
231
|
+
function sortField(alias, cls, order) {
|
|
232
|
+
if (order === 'updated')
|
|
233
|
+
return `${alias}.updated`;
|
|
234
|
+
if (order.startsWith('data.')) {
|
|
235
|
+
const field = order.slice(5);
|
|
236
|
+
return `(${alias}.data->>'${escLit(field)}')${castOf(cls.fieldTypes.get(field))}`;
|
|
237
|
+
}
|
|
238
|
+
throw new Error(`letopis: order must be 'updated' or 'data.<field>'`);
|
|
239
|
+
}
|
|
240
|
+
function orderExpr(alias, cls, mods) {
|
|
241
|
+
if (!mods.order)
|
|
242
|
+
return '';
|
|
243
|
+
return ` ORDER BY ${sortField(alias, cls, mods.order)}${mods.desc ? ' DESC' : ''}`;
|
|
244
|
+
}
|
|
245
|
+
/** Keyset-пагинация: (поле сортировки, id) строго после курсора. Требует .sort(). */
|
|
246
|
+
function afterCond(alias, cls, mods, p) {
|
|
247
|
+
if (!mods.after)
|
|
248
|
+
return '';
|
|
249
|
+
if (!mods.order)
|
|
250
|
+
throw new Error('letopis: .after(cursor) requires .sort(field)');
|
|
251
|
+
const expr = sortField(alias, cls, mods.order);
|
|
252
|
+
const cast = mods.order === 'updated' ? '::timestamptz' : castOf(cls.fieldTypes.get(mods.order.slice(5)));
|
|
253
|
+
const cmp = mods.desc ? '<' : '>';
|
|
254
|
+
return ` WHERE (${expr}, ${alias}.id) ${cmp} (${p.push(mods.after.v)}${cast}, ${p.push(mods.after.id)})`;
|
|
255
|
+
}
|
|
256
|
+
/** Построить читающий запрос по цепочке. */
|
|
257
|
+
export function buildRead(ctx, steps, mods, mode) {
|
|
258
|
+
const p = new Params();
|
|
259
|
+
const ent = entityTable(ctx.pgSchema);
|
|
260
|
+
const pPart = p.push(ctx.partition);
|
|
261
|
+
const blocks = [];
|
|
262
|
+
for (let i = 0; i < steps.length; i++) {
|
|
263
|
+
const step = steps[i];
|
|
264
|
+
const pCls = p.push(step.cls.id);
|
|
265
|
+
const f = compileFilter(step.cls, step.filter, p);
|
|
266
|
+
// модификаторы шага: .tags() / .account() / .owner()
|
|
267
|
+
if (step.tagsFilter !== undefined) {
|
|
268
|
+
const frag = compileTags(step.tagsFilter, p);
|
|
269
|
+
f.candFrags.push(frag);
|
|
270
|
+
f.finalFrags.push(frag);
|
|
271
|
+
}
|
|
272
|
+
// контекст-связи write-цепочек: containment по links
|
|
273
|
+
if (step.linksFilter && Object.keys(step.linksFilter).length) {
|
|
274
|
+
const ph = p.push(step.linksFilter);
|
|
275
|
+
const frag = (a) => `${a}.links @> ${ph}::jsonb`;
|
|
276
|
+
f.candFrags.push(frag);
|
|
277
|
+
f.finalFrags.push(frag);
|
|
278
|
+
}
|
|
279
|
+
// enforceAccount: изоляция арендатора на КАЖДОМ шаге (явный .account(чужой) — ошибка)
|
|
280
|
+
let accFilter = step.accountFilter;
|
|
281
|
+
if (ctx.enforceAccount && ctx.account) {
|
|
282
|
+
if (accFilter && accFilter !== ctx.account) {
|
|
283
|
+
throw new Error(`letopis: enforceAccount is on — reads are pinned to account ${ctx.account}`);
|
|
284
|
+
}
|
|
285
|
+
accFilter = ctx.account;
|
|
286
|
+
}
|
|
287
|
+
for (const [col, val] of [['account', accFilter], ['owner', step.ownerFilter]]) {
|
|
288
|
+
if (val === undefined)
|
|
289
|
+
continue;
|
|
290
|
+
const ph = p.push(val);
|
|
291
|
+
const frag = (a) => `${a}.${col} = ${ph}::uuid`;
|
|
292
|
+
f.candFrags.push(frag);
|
|
293
|
+
f.finalFrags.push(frag);
|
|
294
|
+
}
|
|
295
|
+
const innerWhere = [`e.partition = ${pPart}`, `e.class = ${pCls}`];
|
|
296
|
+
if (mods.asOf !== undefined)
|
|
297
|
+
innerWhere.push(`e.updated <= ${p.push(mods.asOf)}::timestamptz`); // «как было на T»
|
|
298
|
+
// versions: последний шаг включает и удалённые сущности (история существует после delete)
|
|
299
|
+
const outerWhere = [mode === 'versions' && i === steps.length - 1 ? 'TRUE' : 't.deleted IS NULL'];
|
|
300
|
+
const candWhere = f.candFrags.map((fr) => fr('c'));
|
|
301
|
+
for (const c of f.idConds)
|
|
302
|
+
innerWhere.push(c('e'));
|
|
303
|
+
for (const fr of f.finalFrags)
|
|
304
|
+
outerWhere.push(fr('t'));
|
|
305
|
+
const isDeep = step.deepMax !== undefined;
|
|
306
|
+
if (i > 0 && !isDeep) { // deep строит containment сам (recursive CTE)
|
|
307
|
+
const prev = steps[i - 1];
|
|
308
|
+
const hop = resolveHop(prev.cls, step.cls);
|
|
309
|
+
if (hop === 'forward') {
|
|
310
|
+
// id неизменен — условие сразу во внутренний WHERE, перепроверка не нужна
|
|
311
|
+
innerWhere.push(`e.id = h${i - 1}.links->>${p.push(step.cls.id)}`);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
// reverse containment: кандидаты по GIN + перепроверка на latest
|
|
315
|
+
const pKey = p.push(prev.cls.id);
|
|
316
|
+
candWhere.push(`c.links @> jsonb_build_object(${pKey}::text, h${i - 1}.id)`);
|
|
317
|
+
outerWhere.push(`t.links @> jsonb_build_object(${pKey}::text, h${i - 1}.id)`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (candWhere.length) {
|
|
321
|
+
innerWhere.push(`e.id IN (SELECT c.id FROM ${ent} c WHERE c.partition = ${pPart} AND c.class = ${pCls} AND ${candWhere.join(' AND ')})`);
|
|
322
|
+
}
|
|
323
|
+
let block = `(SELECT * FROM (` +
|
|
324
|
+
`SELECT DISTINCT ON (e.id) e.* FROM ${ent} e WHERE ${innerWhere.join(' AND ')} ` +
|
|
325
|
+
`ORDER BY e.id, e.updated DESC` +
|
|
326
|
+
`) t WHERE ${outerWhere.join(' AND ')}) h${i}`;
|
|
327
|
+
// .deep(): рекурсивный обход детей того же класса (recursive CTE поверх latest-паттерна)
|
|
328
|
+
if (isDeep) {
|
|
329
|
+
const prev = steps[i - 1];
|
|
330
|
+
if (i === 0 || prev.cls.id !== step.cls.id) {
|
|
331
|
+
throw new Error('letopis: .deep() works on a self hop (same class as previous step)');
|
|
332
|
+
}
|
|
333
|
+
const pMax = p.push(step.deepMax);
|
|
334
|
+
// latest живые дети узла ref (id-выражение родителя)
|
|
335
|
+
const kids = (ref) => `(SELECT * FROM (` +
|
|
336
|
+
`SELECT DISTINCT ON (e.id) e.* FROM ${ent} e WHERE e.partition = ${pPart} AND e.class = ${pCls}` +
|
|
337
|
+
(mods.asOf !== undefined ? ` AND e.updated <= ${p.push(mods.asOf)}::timestamptz` : '') +
|
|
338
|
+
` AND e.id IN (SELECT c.id FROM ${ent} c WHERE c.partition = ${pPart} AND c.class = ${pCls}` +
|
|
339
|
+
` AND c.links @> jsonb_build_object(${p.push(step.cls.id)}::text, ${ref}))` +
|
|
340
|
+
` ORDER BY e.id, e.updated DESC) t` +
|
|
341
|
+
` WHERE t.deleted IS NULL AND t.links @> jsonb_build_object(${p.push(step.cls.id)}::text, ${ref}))`;
|
|
342
|
+
block =
|
|
343
|
+
`(WITH RECURSIVE d AS (` +
|
|
344
|
+
`SELECT k.*, 1 AS depth FROM ${kids(`h${i - 1}.id`)} k` +
|
|
345
|
+
` UNION ALL ` +
|
|
346
|
+
`SELECT k.*, d.depth + 1 FROM d JOIN LATERAL ${kids('d.id')} k ON true WHERE d.depth < ${pMax}::int` +
|
|
347
|
+
`) SELECT * FROM d) h${i}`;
|
|
348
|
+
}
|
|
349
|
+
blocks.push(i === 0 ? `FROM ${block}` : `JOIN LATERAL ${block} ON true`);
|
|
350
|
+
}
|
|
351
|
+
const fromClause = blocks.join('\n');
|
|
352
|
+
const last = steps.length - 1;
|
|
353
|
+
const lastCls = steps[last].cls;
|
|
354
|
+
// ключи шагов в путях: alias → имя вызова; дубликаты получают _2, _3…
|
|
355
|
+
const keys = [];
|
|
356
|
+
const seen = new Map();
|
|
357
|
+
for (const s of steps) {
|
|
358
|
+
const base = s.aliasKey ?? s.name;
|
|
359
|
+
const n = (seen.get(base) ?? 0) + 1;
|
|
360
|
+
seen.set(base, n);
|
|
361
|
+
keys.push(n === 1 ? base : `${base}_${n}`);
|
|
362
|
+
}
|
|
363
|
+
const limitOffset = (mods.limit !== undefined ? ` LIMIT ${p.push(mods.limit)}` : '') +
|
|
364
|
+
(mods.offset !== undefined ? ` OFFSET ${p.push(mods.offset)}` : '');
|
|
365
|
+
let text;
|
|
366
|
+
switch (mode) {
|
|
367
|
+
case 'paths': {
|
|
368
|
+
const obj = keys.map((k, i) => `${p.push(k)}::text, to_jsonb(h${i})`).join(', ');
|
|
369
|
+
text =
|
|
370
|
+
`SELECT jsonb_build_object(${obj}) AS path\n${fromClause}` +
|
|
371
|
+
afterCond(`h${last}`, lastCls, mods, p) +
|
|
372
|
+
orderExpr(`h${last}`, lastCls, mods) +
|
|
373
|
+
limitOffset;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
case 'rows':
|
|
377
|
+
case 'ids': {
|
|
378
|
+
const inner = `SELECT DISTINCT ON (h${last}.id) h${last}.* \n${fromClause}\n` +
|
|
379
|
+
`ORDER BY h${last}.id, h${last}.updated DESC`;
|
|
380
|
+
const sel = mode === 'rows' ? 'to_jsonb(z) AS row' : 'z.id';
|
|
381
|
+
text =
|
|
382
|
+
`SELECT ${sel} FROM (${inner}) z` +
|
|
383
|
+
afterCond('z', lastCls, mods, p) +
|
|
384
|
+
orderExpr('z', lastCls, mods) +
|
|
385
|
+
limitOffset;
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case 'versions': {
|
|
389
|
+
// ВСЕ версии (включая tombstone) сущностей последнего шага, по возрастанию updated
|
|
390
|
+
const inner = `SELECT DISTINCT h${last}.id \n${fromClause}`;
|
|
391
|
+
text =
|
|
392
|
+
`SELECT to_jsonb(v) AS row FROM (${inner}) z ` +
|
|
393
|
+
`JOIN ${ent} v ON v.partition = ${pPart} AND v.class = ${p.push(lastCls.id)} AND v.id = z.id ` +
|
|
394
|
+
`ORDER BY v.id, v.updated` +
|
|
395
|
+
limitOffset;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
case 'count':
|
|
399
|
+
text = `SELECT count(*)::int AS n\n${fromClause}`;
|
|
400
|
+
break;
|
|
401
|
+
case 'agg': {
|
|
402
|
+
const { aggFn, aggField } = mods;
|
|
403
|
+
if (!aggFn || !aggField?.startsWith('data.')) {
|
|
404
|
+
throw new Error("letopis: aggregation needs field 'data.<путь>'");
|
|
405
|
+
}
|
|
406
|
+
const path = aggField.slice(5).split('.');
|
|
407
|
+
const acc = path.length === 1
|
|
408
|
+
? `h${last}.data->>'${escLit(path[0])}'`
|
|
409
|
+
: `h${last}.data->'${escLit(path[0])}'->>'${escLit(path[1])}'`;
|
|
410
|
+
if (aggFn === 'countBy') {
|
|
411
|
+
text = `SELECT ${acc} AS k, count(*)::int AS n\n${fromClause}\nGROUP BY 1 ORDER BY 2 DESC`;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
// sum/avg — всегда numeric; min/max — по типу поля
|
|
415
|
+
const ft = cls0Field(lastCls, path);
|
|
416
|
+
const cast = aggFn === 'sum' || aggFn === 'avg' ? '::numeric' : castOf(ft);
|
|
417
|
+
text = `SELECT ${aggFn}((${acc})${cast}) AS v\n${fromClause}`;
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { text, params: p.list, keys };
|
|
423
|
+
}
|
|
424
|
+
/** INSERT новой версии/tombstone. $9 = updated прошлой версии (или null), $10 = deleted. */
|
|
425
|
+
export function insertSql(pgSchema) {
|
|
426
|
+
return (`INSERT INTO ${entityTable(pgSchema)} ` +
|
|
427
|
+
`(partition, id, class, data, links, tags, account, owner, updated, deleted) ` +
|
|
428
|
+
`VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ` +
|
|
429
|
+
`CASE WHEN $9::timestamptz IS NULL THEN clock_timestamp() ` +
|
|
430
|
+
`ELSE GREATEST(clock_timestamp(), $9::timestamptz + interval '1 microsecond') END, $10) ` +
|
|
431
|
+
`RETURNING *`);
|
|
432
|
+
}
|
|
433
|
+
/** Мульти-INSERT новых строк (батч): n строк × 8 параметров, updated/deleted по умолчанию. */
|
|
434
|
+
export function multiInsertSql(pgSchema, n) {
|
|
435
|
+
const rows = [];
|
|
436
|
+
for (let i = 0; i < n; i++) {
|
|
437
|
+
const b = i * 8;
|
|
438
|
+
rows.push(`($${b + 1}, $${b + 2}, $${b + 3}, $${b + 4}, $${b + 5}, $${b + 6}, $${b + 7}, $${b + 8})`);
|
|
439
|
+
}
|
|
440
|
+
return (`INSERT INTO ${entityTable(pgSchema)} ` +
|
|
441
|
+
`(partition, id, class, data, links, tags, account, owner) ` +
|
|
442
|
+
`VALUES ${rows.join(', ')} RETURNING *`);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Серверное удаление: DELETE перехватывает триггер entity_delete —
|
|
446
|
+
* tombstone актуальной живой версии + рекурсивный каскад по links, всё в БД.
|
|
447
|
+
* $1 partition, $2 class, дальше — id-шники.
|
|
448
|
+
*/
|
|
449
|
+
export function deleteSql(pgSchema, n) {
|
|
450
|
+
const ids = Array.from({ length: n }, (_, i) => `$${i + 3}`).join(', ');
|
|
451
|
+
return `DELETE FROM ${entityTable(pgSchema)} WHERE partition = $1 AND class = $2 AND id IN (${ids})`;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Замыкание удаления: цели + все живые зависимые рекурсивно (то, что каскад затомбстоунит).
|
|
455
|
+
* Рекурсивный CTE; каждый узел — актуальная живая версия. $1 partition, $2 class, $3+ — ids.
|
|
456
|
+
*/
|
|
457
|
+
export function closureSql(pgSchema, n) {
|
|
458
|
+
const ent = entityTable(pgSchema);
|
|
459
|
+
const ids = Array.from({ length: n }, (_, i) => `$${i + 3}`).join(', ');
|
|
460
|
+
return (`WITH RECURSIVE node AS (
|
|
461
|
+
SELECT * FROM (
|
|
462
|
+
SELECT DISTINCT ON (e.id) e.* FROM ${ent} e
|
|
463
|
+
WHERE e.partition = $1 AND e.class = $2 AND e.id IN (${ids})
|
|
464
|
+
ORDER BY e.id, e.updated DESC
|
|
465
|
+
) t WHERE t.deleted IS NULL
|
|
466
|
+
UNION
|
|
467
|
+
SELECT dep.* FROM node n
|
|
468
|
+
JOIN LATERAL (
|
|
469
|
+
SELECT * FROM (
|
|
470
|
+
SELECT DISTINCT ON (e2.class, e2.id) e2.* FROM ${ent} e2
|
|
471
|
+
WHERE e2.partition = $1
|
|
472
|
+
AND (e2.class, e2.id) IN (
|
|
473
|
+
SELECT c.class, c.id FROM ${ent} c
|
|
474
|
+
WHERE c.partition = $1 AND c.links @> jsonb_build_object(n.class, n.id))
|
|
475
|
+
ORDER BY e2.class, e2.id, e2.updated DESC
|
|
476
|
+
) x WHERE x.deleted IS NULL AND x.links @> jsonb_build_object(n.class, n.id)
|
|
477
|
+
) dep ON true
|
|
478
|
+
)
|
|
479
|
+
SELECT DISTINCT ON (class, id) * FROM node ORDER BY class, id, updated DESC`);
|
|
480
|
+
}
|
package/dist/tables.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Фасады служебных таблиц auth/ACL: Account, Credential, Resource, Rule.
|
|
3
|
+
* Обычные таблицы (UPDATE/DELETE разрешены) — цепочки/версии тут не применяются.
|
|
4
|
+
* Доступны как db.accounts / db.credentials / db.resources / db.rules (и в транзакции).
|
|
5
|
+
*/
|
|
6
|
+
import type { Ctx } from './sql.js';
|
|
7
|
+
import type { Account, Credential, Resource, Rule } from './types.js';
|
|
8
|
+
export interface AccountsApi {
|
|
9
|
+
/** Фильтры: id, enabled, category (строка — вхождение в categories). */
|
|
10
|
+
find(f?: {
|
|
11
|
+
id?: string;
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
category?: string;
|
|
14
|
+
}): Promise<Account[]>;
|
|
15
|
+
get(id: string): Promise<Account | null>;
|
|
16
|
+
/** insert (без id) | update по id. jsonb-поля мержатся на уровне значения целиком. */
|
|
17
|
+
set(a: Partial<Account>): Promise<Account>;
|
|
18
|
+
/** Физический DELETE; Credential снесётся FK-каскадом. */
|
|
19
|
+
delete(id: string): Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
export interface CredentialsApi {
|
|
22
|
+
/** deleted IS NULL по умолчанию; withDeleted: true — включая удалённые. */
|
|
23
|
+
find(f?: {
|
|
24
|
+
id?: string;
|
|
25
|
+
account?: string;
|
|
26
|
+
category?: string;
|
|
27
|
+
identifier?: string;
|
|
28
|
+
confirmed?: boolean;
|
|
29
|
+
withDeleted?: boolean;
|
|
30
|
+
}): Promise<Credential[]>;
|
|
31
|
+
/** upsert по UNIQUE (account, category, identifier); повторный set воскрешает (deleted → NULL). */
|
|
32
|
+
set(c: {
|
|
33
|
+
account: string;
|
|
34
|
+
category: string;
|
|
35
|
+
identifier: string;
|
|
36
|
+
meta?: Record<string, unknown>;
|
|
37
|
+
confirmed?: boolean;
|
|
38
|
+
}): Promise<Credential>;
|
|
39
|
+
/** Мягкое удаление: deleted = now(). */
|
|
40
|
+
delete(id: string): Promise<boolean>;
|
|
41
|
+
}
|
|
42
|
+
export interface ResourcesApi {
|
|
43
|
+
find(f?: {
|
|
44
|
+
category?: string;
|
|
45
|
+
}): Promise<Resource[]>;
|
|
46
|
+
get(alias: string): Promise<Resource | null>;
|
|
47
|
+
/** upsert по alias. */
|
|
48
|
+
set(r: {
|
|
49
|
+
alias: string;
|
|
50
|
+
category: string;
|
|
51
|
+
pattern?: Record<string, unknown> | null;
|
|
52
|
+
meta?: Record<string, unknown> | null;
|
|
53
|
+
}): Promise<Resource>;
|
|
54
|
+
/** Физический DELETE; Rule на этот ресурс снесутся FK-каскадом. */
|
|
55
|
+
delete(alias: string): Promise<boolean>;
|
|
56
|
+
}
|
|
57
|
+
export interface RulesApi {
|
|
58
|
+
find(f?: {
|
|
59
|
+
account?: string;
|
|
60
|
+
resource?: string;
|
|
61
|
+
permission?: string;
|
|
62
|
+
enabled?: boolean;
|
|
63
|
+
}): Promise<Rule[]>;
|
|
64
|
+
/** upsert по PK (account, resource). Оба конца — Resource.alias (FK). */
|
|
65
|
+
set(r: {
|
|
66
|
+
account: string;
|
|
67
|
+
resource: string;
|
|
68
|
+
permission: string;
|
|
69
|
+
weight?: number | null;
|
|
70
|
+
meta?: Record<string, unknown> | null;
|
|
71
|
+
enabled?: boolean;
|
|
72
|
+
}): Promise<Rule>;
|
|
73
|
+
delete(account: string, resource: string): Promise<boolean>;
|
|
74
|
+
}
|
|
75
|
+
export interface Tables {
|
|
76
|
+
accounts: AccountsApi;
|
|
77
|
+
credentials: CredentialsApi;
|
|
78
|
+
resources: ResourcesApi;
|
|
79
|
+
rules: RulesApi;
|
|
80
|
+
}
|
|
81
|
+
export declare function makeTables(ctx: Ctx): Tables;
|