letopis 1.4.1 → 2.0.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/AGENT-CHEATSHEET.md +329 -278
- package/CHANGELOG.md +558 -1
- package/README.md +606 -1037
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -175
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +33 -10
- package/dist/chain.d.ts +19 -41
- package/dist/chain.js +169 -201
- package/dist/cli.js +51 -88
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -71
- package/dist/index.js +245 -130
- package/dist/internal.d.ts +12 -0
- package/dist/internal.js +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +225 -186
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +171 -215
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +26 -29
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +5 -9
- package/dist/signals.js +5 -9
- package/dist/sql.d.ts +13 -6
- package/dist/sql.js +176 -83
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +51 -21
- package/dist/tx.d.ts +26 -1
- package/dist/tx.js +44 -14
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +23 -91
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +184 -73
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +11 -10
- package/dist/watch.js +33 -70
- package/dist/write.d.ts +9 -16
- package/dist/write.js +118 -187
- package/llms.txt +22 -25
- package/package.json +8 -11
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -667
- package/sql/70-read.sql +20 -16
- package/sql/80-auth.sql +24 -27
- package/sql/90-time.sql +70 -859
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -0
- package/AGENT-CHEATSHEET.en.md +0 -485
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2859
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -350
- package/dist/project.js +0 -1224
- package/sql/75-history.sql +0 -358
- package/sql/88-derived.sql +0 -1235
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/sql.js
CHANGED
|
@@ -17,15 +17,43 @@
|
|
|
17
17
|
* Значения всегда уходят параметрами, поэтому текст запроса зависит только от формы цепочки: его
|
|
18
18
|
* кэширует план (chain.ts), а postgres.js готовит оператор один раз на соединение.
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
20
|
+
import { lerr, lmsg, similar } from './errors.js';
|
|
21
21
|
import { isOp } from './ops.js';
|
|
22
22
|
import { OP } from './types.js';
|
|
23
23
|
let nodeSeq = 0;
|
|
24
24
|
/** Уникальная метка узла пути. */
|
|
25
25
|
export const nextNodeKey = () => ++nodeSeq;
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/** Отказ построителя по ключу каталога — там, где нужно выражение: `x ?? refuse(ключ)`; в операторах — throw lerr(…). */
|
|
27
|
+
const refuse = (key, params) => {
|
|
28
|
+
throw lerr(key, params);
|
|
28
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* Поля data строк системного класса Class — свойства метасхемы описания класса (class_metaschema() в 40-class.sql): строки
|
|
32
|
+
* Class проверяет она, а в описании самого класса Class только name. Ключи — свойства описания (ClassDescription) и
|
|
33
|
+
* служебные system и resolved: новое свойство описания без строки здесь — ошибка типов.
|
|
34
|
+
*/
|
|
35
|
+
const CLASS_ROW_FIELDS = Object.keys({
|
|
36
|
+
name: true, kind: true, parent: true, abstract: true, alias: true, system: true, fields: true, ends: true, key: true,
|
|
37
|
+
history: true, ownerDefault: true, cache: true, meta: true, resolved: true,
|
|
38
|
+
});
|
|
39
|
+
/** Поля верхнего уровня data строк класса: он закрыт (class_resolve, 40-class.sql) — в нём только поля описания. */
|
|
40
|
+
const dataFields = (c) => c.name === 'Class' ? CLASS_ROW_FIELDS : Object.keys((c.schema.properties ?? {}));
|
|
41
|
+
/**
|
|
42
|
+
* Поле фильтра, sort() или агрегата не описано ни в одном классе шага — такое условие ничего не найдёт: invalid_query с
|
|
43
|
+
* похожими именами (план 2.0, этап 3, п. 3; в 1.5 было предупреждение, 7.9 №6). Проверяется только верхний уровень:
|
|
44
|
+
* путь внутрь поля-объекта не сверяется. classes — класс шага и все классы, которые он матчит (потомки, если не exact(),
|
|
45
|
+
* у роли — её цели); ends — роли концов тоже годятся (фильтр).
|
|
46
|
+
*/
|
|
47
|
+
function checkField(classes, field, where, ends) {
|
|
48
|
+
if (!classes.length)
|
|
49
|
+
return;
|
|
50
|
+
if (classes.some((c) => dataFields(c).includes(field) || (ends && c.ends[field] !== undefined)))
|
|
51
|
+
return;
|
|
52
|
+
const names = classes.flatMap((c) => [...dataFields(c), ...(ends ? Object.keys(c.ends) : [])]);
|
|
53
|
+
throw lerr('query.unknown_field', { where, field, cls: classes[0].name, similar: similar(field, names) });
|
|
54
|
+
}
|
|
55
|
+
/** Поле sort('data.<путь>') или агрегата: первый ключ пути. */
|
|
56
|
+
const topField = (field) => field.slice(5).split('.')[0];
|
|
29
57
|
// ---------------------------------------------------------------------------
|
|
30
58
|
// Классы шага и правила переходов
|
|
31
59
|
// ---------------------------------------------------------------------------
|
|
@@ -47,6 +75,24 @@ export function stepClasses(reg, step) {
|
|
|
47
75
|
return base;
|
|
48
76
|
return uniq(base.flatMap((b) => (reg.has(b) ? reg.family(b).map((c) => c.name) : [b])));
|
|
49
77
|
}
|
|
78
|
+
/** Классы шага для типов листьев и сверки полей: класс шага первым, затем прочие классы, которые шаг матчит. */
|
|
79
|
+
function typingOf(reg, step, names = stepClasses(reg, step)) {
|
|
80
|
+
const infos = names.map((n) => reg.find(n)).filter((c) => !!c);
|
|
81
|
+
return [step.cls, ...infos.filter((c) => c !== step.cls)];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Отказы построителя, которым не нужны база и путь, — фильтр шага (в том числе неизвестное поле) и поле sort('data.…') или
|
|
85
|
+
* агрегата по классам шага: план записи проверяет их до первого оператора (write.ts:precheckPlan, §2.5), поэтому
|
|
86
|
+
* опечатка в фильтре цели не ломает db.begin(). Построитель при чтении проверяет то же самое ещё раз.
|
|
87
|
+
*/
|
|
88
|
+
export function precheckStep(reg, step, order, agg) {
|
|
89
|
+
const typing = typingOf(reg, step);
|
|
90
|
+
compileFilter(reg, typing, step.filter, new Params());
|
|
91
|
+
if (order?.startsWith('data.'))
|
|
92
|
+
checkField(typing, topField(order), `sort('${order}')`, false);
|
|
93
|
+
if (agg?.field.startsWith('data.'))
|
|
94
|
+
checkField(typing, topField(agg.field), `${agg.fn}('${agg.field}')`, false);
|
|
95
|
+
}
|
|
50
96
|
/** Принимает ли список классов конца класс cls (с учётом его предков). */
|
|
51
97
|
export const accepts = (reg, targets, cls) => (reg.find(cls)?.ancestors ?? [cls]).some((a) => targets.includes(a));
|
|
52
98
|
/** Роли классов from, цели которых принимают хотя бы один класс to. */
|
|
@@ -85,7 +131,7 @@ export function resolveHop(reg, prev, next) {
|
|
|
85
131
|
if ((dir === 'forward' ? fwd : rev).length)
|
|
86
132
|
return { dir, roles: null };
|
|
87
133
|
}
|
|
88
|
-
throw
|
|
134
|
+
throw lerr('query.no_path', { from: prev.name, to: next.name });
|
|
89
135
|
}
|
|
90
136
|
// ---------------------------------------------------------------------------
|
|
91
137
|
// Фильтры и операторы
|
|
@@ -188,7 +234,7 @@ function compileOp(path, leaf, o, p) {
|
|
|
188
234
|
return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p);
|
|
189
235
|
}
|
|
190
236
|
default:
|
|
191
|
-
|
|
237
|
+
throw lerr('query.unknown_op', { op: String(o[OP]) });
|
|
192
238
|
}
|
|
193
239
|
}
|
|
194
240
|
/** Колонка tags: строка | список (все) | has / hasAny / hasAll. */
|
|
@@ -215,7 +261,7 @@ function compileTags(v, p) {
|
|
|
215
261
|
}
|
|
216
262
|
}
|
|
217
263
|
}
|
|
218
|
-
|
|
264
|
+
throw lerr('query.tags_arg');
|
|
219
265
|
}
|
|
220
266
|
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
|
|
221
267
|
/** Объект-строка ответа как значение: берётся id. */
|
|
@@ -227,7 +273,7 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
227
273
|
// or(…): дизъюнкция под-фильтров — только перепроверка
|
|
228
274
|
if (isOp(filter)) {
|
|
229
275
|
if (filter[OP] !== 'or')
|
|
230
|
-
|
|
276
|
+
throw lerr('query.step_op', { op: String(filter[OP]) });
|
|
231
277
|
const parts = filter.args[0].map((f) => {
|
|
232
278
|
const sub = compileFilter(reg, classes, f, p);
|
|
233
279
|
const frags = [...asFrags(sub, p)];
|
|
@@ -241,7 +287,7 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
241
287
|
return out;
|
|
242
288
|
}
|
|
243
289
|
if (Array.isArray(filter)) {
|
|
244
|
-
out.ids = filter.map((x) => idOfValue(x) ??
|
|
290
|
+
out.ids = filter.map((x) => idOfValue(x) ?? refuse('query.id_list'));
|
|
245
291
|
return out;
|
|
246
292
|
}
|
|
247
293
|
const props = (classes[0]?.schema.properties ?? {});
|
|
@@ -281,9 +327,9 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
281
327
|
if (typeof value === 'string')
|
|
282
328
|
out.ids = [value];
|
|
283
329
|
else if (Array.isArray(value))
|
|
284
|
-
out.ids = value.map((x) => idOfValue(x) ??
|
|
330
|
+
out.ids = value.map((x) => idOfValue(x) ?? refuse('query.filter_id_list'));
|
|
285
331
|
else
|
|
286
|
-
|
|
332
|
+
throw lerr('query.filter_id');
|
|
287
333
|
continue;
|
|
288
334
|
}
|
|
289
335
|
if (isRole(key)) {
|
|
@@ -292,12 +338,14 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
292
338
|
out.frags.push((a) => `coalesce(${a}.links->${role}, 'null'::jsonb) in ('null'::jsonb, '[]'::jsonb)`);
|
|
293
339
|
continue;
|
|
294
340
|
}
|
|
295
|
-
const id = idOfValue(value) ??
|
|
341
|
+
const id = idOfValue(value) ?? refuse('query.end_value', { role: key });
|
|
296
342
|
out.ends.push(id);
|
|
297
343
|
const ph = p.push(id);
|
|
298
|
-
out.frags.push((a) =>
|
|
344
|
+
out.frags.push((a) => `${SCHEMA_MARK}.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
|
|
299
345
|
continue;
|
|
300
346
|
}
|
|
347
|
+
// поле данных: не описано ни в одном классе шага — invalid_query с похожими (план 2.0, этап 3, п. 3)
|
|
348
|
+
checkField(classes, key, `${classes[0]?.name ?? ''}({ ${key} })`, true);
|
|
301
349
|
if (isOp(value)) {
|
|
302
350
|
out.frags.push(compileOp([key], leafOf(reg, classes, [key]), value, p));
|
|
303
351
|
continue;
|
|
@@ -318,8 +366,12 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
318
366
|
function asFrags(cf, p) {
|
|
319
367
|
const frags = [...cf.frags];
|
|
320
368
|
if (cf.ids) {
|
|
321
|
-
|
|
322
|
-
|
|
369
|
+
// один id — равенство, а не = any (массив): общий план подготовленного запроса для = any PostgreSQL считает на ~10
|
|
370
|
+
// элементов, и при неудачной выборке ANALYZE чтение по ключу навсегда оставалось на планах под каждый вызов —
|
|
371
|
+
// 0,5–0,8 мс планирования на чтение (замер против 1.0.1); общий план равенства стоит как план вызова и выбирается всегда
|
|
372
|
+
const one = cf.ids.length === 1;
|
|
373
|
+
const ph = p.push(one ? cf.ids[0] : cf.ids);
|
|
374
|
+
frags.push((a) => (one ? `${a}.id = ${ph}::uuid` : `${a}.id = any (${ph}::uuid[])`));
|
|
323
375
|
}
|
|
324
376
|
if (cf.eq) {
|
|
325
377
|
const ph = p.push(cf.eq);
|
|
@@ -341,7 +393,7 @@ function sortSpec(order, leaf) {
|
|
|
341
393
|
const t = leaf(path);
|
|
342
394
|
return { expr: (a) => (CAST[t] ? `(${accessor(path)(a)})${CAST[t]}` : accessor(path)(a)), pcast: PCAST[t], field: order, type: t, nullable: true };
|
|
343
395
|
}
|
|
344
|
-
|
|
396
|
+
throw lerr('query.sort_field', { field: order });
|
|
345
397
|
}
|
|
346
398
|
/** Формы значений курсора — как у форматов uuid, date и date-time валидатора (validate.ts). */
|
|
347
399
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -398,9 +450,11 @@ function shown(v) {
|
|
|
398
450
|
}
|
|
399
451
|
return s.length > 60 ? `${s.slice(0, 59)}…` : s;
|
|
400
452
|
}
|
|
453
|
+
/** Какое значение курсора ждёт поле sort() по своему типу — запись каталога (параметр expect ошибки курсора). */
|
|
401
454
|
const CURSOR_EXPECT = {
|
|
402
|
-
numeric: '
|
|
403
|
-
|
|
455
|
+
numeric: 'query.cursor_expect_numeric', int: 'query.cursor_expect_int', boolean: 'query.cursor_expect_boolean',
|
|
456
|
+
text: 'query.cursor_expect_text', uuid: 'query.cursor_expect_uuid', timestamptz: 'query.cursor_expect_timestamptz',
|
|
457
|
+
date: 'query.cursor_expect_date', jsonb: 'query.cursor_expect_jsonb',
|
|
404
458
|
};
|
|
405
459
|
/**
|
|
406
460
|
* Параметры keyset из курсора: значение проверяется по типу поля сортировки до запроса. Курсор
|
|
@@ -409,46 +463,80 @@ const CURSOR_EXPECT = {
|
|
|
409
463
|
* Курсор того же типа с другого поля проверка не отличит.
|
|
410
464
|
*/
|
|
411
465
|
function cursorParams(c, sort) {
|
|
412
|
-
const how = `cursorOf(строка, '${sort.field}')`;
|
|
413
466
|
if (typeof c !== 'object' || c === null || c.v === undefined)
|
|
414
|
-
|
|
467
|
+
throw lerr('query.cursor_shape', { field: sort.field });
|
|
415
468
|
const { v, id } = c;
|
|
416
469
|
if (typeof id !== 'string' || !UUID_RE.test(id))
|
|
417
|
-
|
|
470
|
+
throw lerr('query.cursor_id', { id: shown(id) });
|
|
418
471
|
const value = v === null && sort.nullable ? null : cursorValue(v, sort.type);
|
|
419
472
|
if (value === undefined) {
|
|
420
|
-
|
|
421
|
-
|
|
473
|
+
// у поля данных подходит и курсор null — своя запись, а не склейка « или null»
|
|
474
|
+
throw lerr(sort.nullable ? 'query.cursor_value_nullable' : 'query.cursor_value', { value: shown(v), field: sort.field, expect: lmsg(CURSOR_EXPECT[sort.type]) });
|
|
422
475
|
}
|
|
423
476
|
return { v: value, id };
|
|
424
477
|
}
|
|
478
|
+
/** rev курсора: в versions() отделяет версии одного объекта с равным полем сортировки (план 2.0, 7.1 №4). */
|
|
479
|
+
function cursorRev(c) {
|
|
480
|
+
const rev = c.rev;
|
|
481
|
+
if (rev === undefined)
|
|
482
|
+
return undefined;
|
|
483
|
+
if (!Number.isInteger(rev) || rev < 1)
|
|
484
|
+
throw lerr('query.cursor_rev', { rev: shown(rev) });
|
|
485
|
+
return rev;
|
|
486
|
+
}
|
|
487
|
+
/** id узлов пути из курсора paths() по ключам узлов (план 2.0, 7.1 №3); курсор без path — undefined. */
|
|
488
|
+
function cursorPath(c, keys) {
|
|
489
|
+
const path = c.path;
|
|
490
|
+
if (path === undefined)
|
|
491
|
+
return undefined;
|
|
492
|
+
return keys.map((k) => {
|
|
493
|
+
const id = typeof path === 'object' && path !== null ? path[k] : undefined;
|
|
494
|
+
if (typeof id !== 'string' || !UUID_RE.test(id))
|
|
495
|
+
throw lerr('query.cursor_path', { key: k });
|
|
496
|
+
return id;
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
const isPath = (x) => typeof x.id !== 'string'
|
|
500
|
+
&& Object.values(x).length > 0 && Object.values(x).every((n) => typeof n === 'object' && n !== null && typeof n.id === 'string');
|
|
425
501
|
/**
|
|
426
|
-
* Курсор постраничного вывода для `after()`:
|
|
427
|
-
* Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`.
|
|
502
|
+
* Курсор постраничного вывода для `after()`: значение поля сортировки, id и номер версии последней строки страницы.
|
|
503
|
+
* Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`. Для пути из `paths()` — то же по его
|
|
504
|
+
* последнему узлу (узлу последнего шага) и id всех узлов: курсор отделяет пути с общим последним узлом.
|
|
428
505
|
*/
|
|
429
506
|
export function cursorOf(row, field) {
|
|
507
|
+
if (isPath(row)) {
|
|
508
|
+
const keys = Object.keys(row);
|
|
509
|
+
const { v, id } = cursorOf(row[keys[keys.length - 1]], field);
|
|
510
|
+
return { v, id, path: Object.fromEntries(keys.map((k) => [k, row[k].id])) };
|
|
511
|
+
}
|
|
430
512
|
const f = field === 'updated' ? 'at' : field;
|
|
513
|
+
const rev = typeof row.rev === 'number' ? { rev: row.rev } : {};
|
|
431
514
|
if (f === 'at' || f === 'rev' || f === 'id')
|
|
432
|
-
return { v: row[f], id: row.id };
|
|
515
|
+
return { v: row[f], id: row.id, ...rev };
|
|
433
516
|
if (f.startsWith('data.')) {
|
|
434
517
|
let v = row.data;
|
|
435
518
|
for (const k of f.slice(5).split('.'))
|
|
436
519
|
v = v?.[k];
|
|
437
|
-
return { v: (v ?? null), id: row.id };
|
|
520
|
+
return { v: (v ?? null), id: row.id, ...rev };
|
|
438
521
|
}
|
|
439
|
-
|
|
522
|
+
throw lerr('query.cursor_of_field', { field });
|
|
440
523
|
}
|
|
441
524
|
// ---------------------------------------------------------------------------
|
|
442
525
|
// Построение запроса
|
|
443
526
|
// ---------------------------------------------------------------------------
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
527
|
+
/**
|
|
528
|
+
* Метка схемы во фрагментах, собранных до имени схемы: символ NUL не бывает в тексте PostgreSQL, поэтому замена не
|
|
529
|
+
* заденет литералы — имя поля вида a@S@b прежде портилось (план 2.0, 7.1 №5).
|
|
530
|
+
*/
|
|
531
|
+
const SCHEMA_MARK = '\u0000S\u0000';
|
|
532
|
+
const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason'];
|
|
533
|
+
/** Колонки журнала (выданные приложению — 10-core.sql). */
|
|
534
|
+
const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason']
|
|
535
|
+
.map((c) => 'l.' + c).join(', ');
|
|
448
536
|
const colsOf = (a) => COLS.map((c) => `${a}.${c}`).join(', ');
|
|
449
537
|
export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
450
538
|
if (!steps.length)
|
|
451
|
-
|
|
539
|
+
throw lerr('query.empty_chain');
|
|
452
540
|
const S = `"${schema.replace(/"/g, '""')}"`;
|
|
453
541
|
const p = new Params();
|
|
454
542
|
const cur = mods.asOf === undefined;
|
|
@@ -460,7 +548,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
460
548
|
if (s.pivotKey !== undefined) {
|
|
461
549
|
const at = byNodeKey.get(s.pivotKey);
|
|
462
550
|
if (at === undefined)
|
|
463
|
-
|
|
551
|
+
throw lerr('query.pivot_missing', { name: s.name });
|
|
464
552
|
real[i] = at;
|
|
465
553
|
}
|
|
466
554
|
else {
|
|
@@ -478,8 +566,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
478
566
|
for (let i = 0; i < steps.length; i++) {
|
|
479
567
|
const step = steps[i];
|
|
480
568
|
const names = stepClasses(reg, step);
|
|
481
|
-
const
|
|
482
|
-
const typing = [step.cls, ...infos.filter((c) => c !== step.cls)];
|
|
569
|
+
const typing = typingOf(reg, step, names);
|
|
483
570
|
// возврат к узлу: только дофильтры
|
|
484
571
|
if (step.pivotKey !== undefined) {
|
|
485
572
|
const a = `h${real[i]}`;
|
|
@@ -526,8 +613,9 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
526
613
|
return `(select v.id, v.rev from ${S}.entity v where v.class = any (${clsPh})${dataCond('v')}${endsCond('v.ends')}`
|
|
527
614
|
+ `${ids ? ` and v.id = any (${ids})` : ''})`;
|
|
528
615
|
}
|
|
529
|
-
|
|
530
|
-
|
|
616
|
+
// по классу: id-кандидаты классов шага, последняя версия — любого класса (как find_ids, план 2.0, 7.1 №1)
|
|
617
|
+
return `(select v.id, v.rev from ${versionsAt(at, ids ? `l.id = any (${ids})` : `l.id in (select c.id from ${S}.log c where c.class = any (${clsPh}))`)} v `
|
|
618
|
+
+ `where v.class = any (${clsPh}) and (v.op = 'delete') = ${isDead}${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)})`;
|
|
531
619
|
};
|
|
532
620
|
const entityPart = (src, depth = 'null::int', from = `${S}.entity x`) => `select ${colsOf('x')}, ${depth} as depth from ${from} where ${where('x', src)}`;
|
|
533
621
|
/**
|
|
@@ -544,7 +632,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
544
632
|
const parts = [];
|
|
545
633
|
if (i === 0) {
|
|
546
634
|
if (step.deepMax !== undefined)
|
|
547
|
-
|
|
635
|
+
throw lerr('query.deep_first_step');
|
|
548
636
|
if (cur) {
|
|
549
637
|
// условие id уже в conds — первичный ключ; вложение — поиск по индексу GIN
|
|
550
638
|
parts.push(!cf.ids && (cf.eq || cf.ends.length) ? entityByIds(`select f.id from ${findIds(null, 'null', false)} f`) : entityPart(undefined));
|
|
@@ -565,15 +653,15 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
565
653
|
const rolesCond = (v, src) => (hop.roles ? ` and ${S}.role_has(${v}.links, ${rolesPh}, ${src})` : '');
|
|
566
654
|
if (step.deepMax !== undefined) {
|
|
567
655
|
if (hop.dir !== 'reverse' || !stepClasses(reg, steps[a]).some((c) => names.includes(c))) {
|
|
568
|
-
|
|
656
|
+
throw lerr('query.deep_hop', { from: steps[a].name, to: step.name });
|
|
569
657
|
}
|
|
570
658
|
if (dead)
|
|
571
|
-
|
|
659
|
+
throw lerr('query.deep_with_deleted');
|
|
572
660
|
const maxPh = `${p.push(step.deepMax)}::int`;
|
|
573
661
|
let deepSrc = `${S}.find_deep(${clsPh}, ${rolesPh}, ${srcArr}, ${maxPh}, ${atPh})`;
|
|
574
662
|
if (opts.direct) {
|
|
575
663
|
// тот же обход под RLS: невидимый узел обрывает поддерево политикой
|
|
576
|
-
const node = cur ? `${S}.entity` : `(select w.id, w.rev, w.links from ${versionsAt(atPh, `l.class = any (${clsPh})`)} w where w.op
|
|
664
|
+
const node = cur ? `${S}.entity` : `(select w.id, w.rev, w.links from ${versionsAt(atPh, `l.class = any (${clsPh})`)} w where w.op <> 'delete')`;
|
|
577
665
|
const hasSrc = (v, src) => (cur ? `${v}.ends @> array[${src}]` : `${S}.link_ids(${v}.links) @> array[${src}]`);
|
|
578
666
|
const cls = (v) => (cur ? ` and ${v}.class = any (${clsPh})` : '');
|
|
579
667
|
deepSrc = `(with recursive d(src, id, rev, depth, seen) as (`
|
|
@@ -609,7 +697,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
609
697
|
}
|
|
610
698
|
return `(select s.src, v.id, v.rev from ${versionsAt(at, `l.class = any (${clsPh})`)} v `
|
|
611
699
|
+ `cross join lateral unnest(${S}.link_ids(v.links)) t(x) join ${srcs} on s.src = t.x `
|
|
612
|
-
+ `where (v.op = 'delete') = ${isDead}
|
|
700
|
+
+ `where (v.op = 'delete') = ${isDead}${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)}${rolesCond('v', 's.src')})`;
|
|
613
701
|
};
|
|
614
702
|
ctes.push(`p${i}a as (select q.src, q.id, q.rev from ${hopCall(atPh, false)} q)`);
|
|
615
703
|
// пары переходов — по одной на (источник, цель): объект, который ссылается на несколько объектов прошлого шага,
|
|
@@ -650,43 +738,59 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
650
738
|
const single = tail.length === 0 && !steps.some((st) => st.pivotKey !== undefined);
|
|
651
739
|
const ents = single ? `n${lastNode}` : `(select z0.* from n${lastNode} z0 where z0.id in (select h${lastNode}.id ${from}${whereOf(tail)}))`;
|
|
652
740
|
const leaf = (path) => leafOf(reg, rowsNode(lastNode), path);
|
|
741
|
+
if (mods.order?.startsWith('data.'))
|
|
742
|
+
checkField(rowsNode(lastNode), topField(mods.order), `sort('${mods.order}')`, false);
|
|
653
743
|
const sort = mods.order ? sortSpec(mods.order, leaf) : undefined;
|
|
654
744
|
const dir = mods.desc ? ' desc' : '';
|
|
655
|
-
|
|
656
|
-
const
|
|
745
|
+
/** Порядок: поле sort, id строки и разделители равных (id прочих узлов пути). */
|
|
746
|
+
const orderBy = (a, more = []) => (sort ? ` order by ${[sort.expr(a), `${a}.id`, ...more].map((x) => `${x}${dir}`).join(', ')}` : '');
|
|
747
|
+
/**
|
|
748
|
+
* «Строго после курсора» в порядке order by: поле sort, id строки и разделители равных из курсора — rev версии
|
|
749
|
+
* (versions()) или id прочих узлов пути (paths()). Без них страница начиналась бы после всех строк с тем же полем
|
|
750
|
+
* и id: терялись бы другие версии объекта и другие пути к тому же узлу. Курсор без rev или path сравнивается по
|
|
751
|
+
* началу порядка, как прежде.
|
|
752
|
+
*/
|
|
753
|
+
const after = (a, ties = () => []) => {
|
|
657
754
|
if (!mods.after)
|
|
658
755
|
return undefined;
|
|
659
756
|
if (!sort)
|
|
660
|
-
|
|
757
|
+
throw lerr('query.after_without_sort');
|
|
661
758
|
const c = cursorParams(mods.after, sort);
|
|
662
|
-
|
|
663
|
-
|
|
759
|
+
const t = ties(mods.after);
|
|
760
|
+
const lt = mods.desc ? '<' : '>';
|
|
664
761
|
// Поле данных бывает null (или его нет): order by ставит такие строки в конец при возрастании и в
|
|
665
762
|
// начало при убывании, а сравнение строк с null даёт null — одно сравнение их теряло, курсор null
|
|
666
763
|
// давал пустую страницу. Здесь null — значение после всех прочих (asc) или перед ними (desc); текст
|
|
667
764
|
// один на оба вида курсора (параметр — с явным приведением: он стоит и в is null).
|
|
765
|
+
const v = `${p.push(c.v)}${sort.nullable ? sort.pcast || '::text' : sort.pcast}`;
|
|
766
|
+
const cols = [`${a}.id`, ...t.map((x) => x.col)];
|
|
767
|
+
const vals = [`${p.push(c.id)}::uuid`, ...t.map((x) => `${p.push(x.val)}${x.cast}`)];
|
|
768
|
+
if (!sort.nullable)
|
|
769
|
+
return `(${sort.expr(a)}, ${cols.join(', ')}) ${lt} (${v}, ${vals.join(', ')})`;
|
|
668
770
|
const e = `(${sort.expr(a)})`;
|
|
669
|
-
const
|
|
670
|
-
const id = `${p.push(c.id)}::uuid`;
|
|
771
|
+
const rest = cols.length === 1 ? `${cols[0]} ${lt} ${vals[0]}` : `(${cols.join(', ')}) ${lt} (${vals.join(', ')})`;
|
|
671
772
|
return mods.desc
|
|
672
|
-
? `((${e}, ${
|
|
673
|
-
: `((${e}, ${
|
|
773
|
+
? `((${e}, ${cols.join(', ')}) < (${v}, ${vals.join(', ')}) or (${v} is null and (${e} is not null or ${rest})))`
|
|
774
|
+
: `((${e}, ${cols.join(', ')}) > (${v}, ${vals.join(', ')}) or (${e} is null and (${v} is not null or ${rest})))`;
|
|
674
775
|
};
|
|
776
|
+
// ключи пути — в порядке шагов, узел последнего шага (его строки отдают rows()) — последним: по нему cursorOf(путь)
|
|
777
|
+
// берёт поле и id курсора, по прочим — разделители путей к одному узлу (план 2.0, 7.1 №3)
|
|
778
|
+
const others = keyed.filter((k) => k.node !== lastNode);
|
|
779
|
+
const pathKeys = [...others, ...keyed.filter((k) => k.node === lastNode)].map((k) => k.key);
|
|
675
780
|
const limitOffset = () => (mods.limit !== undefined ? ` limit ${p.push(mods.limit)}::bigint` : '') + (mods.offset !== undefined ? ` offset ${p.push(mods.offset)}::bigint` : '');
|
|
676
|
-
|
|
677
|
-
if (mode === 'versions' && mods.follow) {
|
|
678
|
-
ctes.push(`f(id) as (select z.id from ${ents} z union select x.id from f cross join lateral (`
|
|
679
|
-
+ `select l.moved as id from ${S}.log l where l.id = f.id and l.moved is not null `
|
|
680
|
-
+ `union select l.id from ${S}.log l where l.moved = f.id) x)`);
|
|
681
|
-
}
|
|
682
|
-
const withClause = `with ${mode === 'versions' && mods.follow ? 'recursive ' : ''}${ctes.join(',\n')}\n`;
|
|
781
|
+
const withClause = `with ${ctes.join(',\n')}\n`;
|
|
683
782
|
let text;
|
|
684
783
|
let aggLeaf;
|
|
685
784
|
switch (mode) {
|
|
686
785
|
case 'paths': {
|
|
687
786
|
const obj = keyed.map(({ key, node }) => `${lit(key)}::text, to_jsonb(h${node})`).join(', ');
|
|
688
787
|
const h = `h${lastNode}`;
|
|
689
|
-
|
|
788
|
+
const ties = (c) => {
|
|
789
|
+
const ids = cursorPath(c, others.map((k) => k.key));
|
|
790
|
+
return ids ? others.map((k, j) => ({ col: `h${k.node}.id`, val: ids[j], cast: '::uuid' })) : [];
|
|
791
|
+
};
|
|
792
|
+
text = `${withClause}select jsonb_build_object(${obj}) as path ${from}${whereOf([...tail, after(h, ties)])}`
|
|
793
|
+
+ `${orderBy(h, others.map((k) => `h${k.node}.id`))}${limitOffset()}`;
|
|
690
794
|
break;
|
|
691
795
|
}
|
|
692
796
|
case 'rows':
|
|
@@ -701,31 +805,22 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
701
805
|
break;
|
|
702
806
|
case 'versions': {
|
|
703
807
|
// с sort — по полю и направлению (последние версии первыми — sort('at', 'desc'), с limit — в самом запросе);
|
|
704
|
-
// без sort
|
|
705
|
-
const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` :
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
case 'diff':
|
|
714
|
-
case 'blame': {
|
|
715
|
-
// план 1.1, §2.1: функции базы считают по журналу под RLS — по одному документу на объект шага
|
|
716
|
-
const h = mods.hist ?? {};
|
|
717
|
-
const num = (v) => (typeof v === 'number' ? `${p.push(v)}::int` : 'null');
|
|
718
|
-
const ts = (v) => (typeof v === 'string' ? `${p.push(v)}::text::timestamptz` : 'null');
|
|
719
|
-
const call = mode === 'diff'
|
|
720
|
-
? `${S}.diff_versions(z.id, ${num(h.from)}, ${num(h.to)}, ${ts(h.from)}, ${ts(h.to)}, ${h.follow ? 'true' : 'false'})`
|
|
721
|
-
: `${S}.blame(z.id, ${ts(h.at)}, ${num(h.limit)})`;
|
|
722
|
-
text = `${withClause}select ${call} as row from (select distinct z.id from ${ents} z) z order by z.id`;
|
|
808
|
+
// без sort — по id и номеру версии
|
|
809
|
+
const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` : 'v.id, v.rev';
|
|
810
|
+
// курсор — поле sort, id и номер версии: версии одного объекта с равным полем не теряются (план 2.0, 7.1 №4)
|
|
811
|
+
const cond = whereOf([after('v', (c) => {
|
|
812
|
+
const rev = cursorRev(c);
|
|
813
|
+
return rev === undefined ? [] : [{ col: 'v.rev', val: rev, cast: '::int' }];
|
|
814
|
+
})]);
|
|
815
|
+
text = `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
|
|
816
|
+
+ `where l.id in (select z.id from ${ents} z)) v${cond} order by ${order}${limitOffset()}`;
|
|
723
817
|
break;
|
|
724
818
|
}
|
|
725
819
|
case 'agg': {
|
|
726
820
|
if (!agg || !agg.field.startsWith('data.'))
|
|
727
|
-
|
|
821
|
+
throw lerr('query.agg_field');
|
|
728
822
|
const path = agg.field.slice(5).split('.');
|
|
823
|
+
checkField(rowsNode(lastNode), path[0], `${agg.fn}('${agg.field}')`, false);
|
|
729
824
|
const acc = accessor(path)('z');
|
|
730
825
|
aggLeaf = leaf(path);
|
|
731
826
|
if (agg.fn === 'countBy') {
|
|
@@ -741,18 +836,16 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
741
836
|
break;
|
|
742
837
|
}
|
|
743
838
|
}
|
|
744
|
-
return { text: text.replaceAll(
|
|
839
|
+
return { text: text.replaceAll(SCHEMA_MARK, S), params: p.list, keys: pathKeys, aggLeaf };
|
|
745
840
|
}
|
|
746
841
|
/** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
|
|
747
842
|
export function toRow(raw) {
|
|
748
|
-
const { depth, agent, reason,
|
|
843
|
+
const { depth, agent, reason, ...rest } = raw;
|
|
749
844
|
const row = rest;
|
|
750
845
|
if (agent != null)
|
|
751
846
|
row.agent = agent;
|
|
752
847
|
if (reason != null)
|
|
753
848
|
row.reason = reason;
|
|
754
|
-
if (moved != null)
|
|
755
|
-
row.moved = moved;
|
|
756
849
|
if (row.op === 'delete')
|
|
757
850
|
row.$deleted = true;
|
|
758
851
|
if (depth != null)
|
package/dist/sync.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* мешающими строками отклоняет сама база (`tightening_conflict` с перечнем) уже в сухом прогоне.
|
|
7
7
|
* generateTypes: объявления TypeScript для классов из реестра — в том числе созданных данными.
|
|
8
8
|
*/
|
|
9
|
-
import type {
|
|
9
|
+
import type { DbCore } from './index.js';
|
|
10
10
|
import type { AnyModel, LostRule } from './model.js';
|
|
11
11
|
import type { JsonSchema } from './jsonschema.js';
|
|
12
12
|
import type { Registry } from './registry.js';
|
|
@@ -20,12 +20,18 @@ export interface SyncResult {
|
|
|
20
20
|
/** Правила refine, которых нет в базе. */
|
|
21
21
|
lost: LostRule[];
|
|
22
22
|
}
|
|
23
|
-
export declare function syncModels(db:
|
|
23
|
+
export declare function syncModels(db: DbCore, models: readonly AnyModel[], opts?: {
|
|
24
24
|
apply?: boolean;
|
|
25
25
|
}): Promise<SyncResult>;
|
|
26
26
|
/** Тип TypeScript по JSON Schema подмножества. */
|
|
27
27
|
export declare function schemaToTs(s: JsonSchema | undefined, indent?: string): string;
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Объявления `<Класс>Data` и `<Класс>Links` для классов реестра и `<Класс>Model` — имя, алиас, концы с классами целей
|
|
30
|
+
* и предки (ModelLike, typed.ts), — а в конце список `Models`: строгие типы цепочек без моделей Zod, `connect<Models>()`
|
|
31
|
+
* (план 2.0, этап 4, п. 1). Классы арендатора System (системные и классы сидов пакета) — только с includeSystem
|
|
32
|
+
* (`letopis types --system`); без него системные классы даёт встроенный набор SystemModels. Пометка в первой строке —
|
|
33
|
+
* запись каталога model.types_header.
|
|
34
|
+
*/
|
|
29
35
|
export declare function generateTypes(registry: Registry, opts?: {
|
|
30
36
|
includeSystem?: boolean;
|
|
31
37
|
}): string;
|