letopis 0.20.3 → 1.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.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -250
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -351
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -600
- package/sql/seed.auth.sql +0 -73
package/dist/sql.js
CHANGED
|
@@ -1,80 +1,95 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Построитель чтения (план, этап 3, п. 2–8): цепочка шагов → один параметризованный запрос.
|
|
3
|
+
*
|
|
4
|
+
* Шаг из двух частей (§3 «Индексы под RLS»). Под RLS индексы GIN роли приложения недоступны, поэтому
|
|
5
|
+
* id находят функции владельца find_* (lib/sql/70-read.sql) — по индексам и с тем же условием
|
|
6
|
+
* видимости, что у политик, — а строки читаются по первичному ключу под RLS, где применяются
|
|
7
|
+
* остальные условия шага. Каждый шаг — пара CTE:
|
|
8
|
+
* p<i>(src, id[, rev, depth]) — пары «откуда, куда» перехода от узла предыдущего шага;
|
|
9
|
+
* n<i>(колонки строки, depth) — строки узла с условиями шага.
|
|
10
|
+
* Прямой переход (из концов строки) находит цели без поиска: id лежат в links. Обратный — find_hop()
|
|
11
|
+
* по развёрнутому списку id предыдущего узла (решение 5 точки А). Пути собирает соединение
|
|
12
|
+
* n0 ⋈ p1 ⋈ n1 ⋈ …; терминалы rows, ids, count и агрегаты считают сущности последнего узла.
|
|
13
|
+
*
|
|
14
|
+
* Источник узла — текущее состояние (entity) или журнал: версии на момент asOf и надгробия
|
|
15
|
+
* удалённых (withDeleted). Строки журнала читаются по паре (id, rev).
|
|
16
|
+
*
|
|
17
|
+
* Значения всегда уходят параметрами, поэтому текст запроса зависит только от формы цепочки: его
|
|
18
|
+
* кэширует план (chain.ts), а postgres.js готовит оператор один раз на соединение.
|
|
19
|
+
*/
|
|
20
|
+
import { LetopisError } from './errors.js';
|
|
2
21
|
import { isOp } from './ops.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
22
|
+
import { OP } from './types.js';
|
|
23
|
+
let nodeSeq = 0;
|
|
24
|
+
/** Уникальная метка узла пути. */
|
|
25
|
+
export const nextNodeKey = () => ++nodeSeq;
|
|
26
|
+
const fail = (msg) => {
|
|
27
|
+
throw new LetopisError('invalid_query', msg);
|
|
28
|
+
};
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Классы шага и правила переходов
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const asList = (v) => (Array.isArray(v) ? v : [v]);
|
|
33
|
+
const uniq = (xs) => [...new Set(xs)];
|
|
34
|
+
/** Классы, которые объявляют роль. */
|
|
35
|
+
export function roleOwners(reg, role) {
|
|
36
|
+
return reg.all().filter((c) => c.ends[role] !== undefined);
|
|
15
37
|
}
|
|
16
|
-
/**
|
|
17
|
-
export function
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
38
|
+
/** Классы целей роли (имя конца однозначно во всей схеме — проверяет база). */
|
|
39
|
+
export function roleTargets(reg, role) {
|
|
40
|
+
const owner = roleOwners(reg, role)[0];
|
|
41
|
+
return owner ? asList(owner.ends[role].class) : [];
|
|
42
|
+
}
|
|
43
|
+
/** Конкретные классы, которые матчит шаг: класс (роль — её цели) и потомки, если не exact(). */
|
|
44
|
+
export function stepClasses(reg, step) {
|
|
45
|
+
const base = step.role ? roleTargets(reg, step.role) : [step.cls.name];
|
|
46
|
+
if (step.exact)
|
|
47
|
+
return base;
|
|
48
|
+
return uniq(base.flatMap((b) => (reg.has(b) ? reg.family(b).map((c) => c.name) : [b])));
|
|
49
|
+
}
|
|
50
|
+
/** Принимает ли список классов конца класс cls (с учётом его предков). */
|
|
51
|
+
export const accepts = (reg, targets, cls) => (reg.find(cls)?.ancestors ?? [cls]).some((a) => targets.includes(a));
|
|
52
|
+
/** Роли классов from, цели которых принимают хотя бы один класс to. */
|
|
53
|
+
function rolesBetween(reg, from, to) {
|
|
54
|
+
const out = new Set();
|
|
55
|
+
for (const c of from) {
|
|
56
|
+
const ci = reg.find(c);
|
|
57
|
+
if (!ci)
|
|
58
|
+
continue;
|
|
59
|
+
for (const [role, end] of Object.entries(ci.ends)) {
|
|
60
|
+
const t = asList(end.class);
|
|
61
|
+
if (to.some((x) => accepts(reg, t, x)))
|
|
62
|
+
out.add(role);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...out];
|
|
23
66
|
}
|
|
24
|
-
export const RETRIES = 3;
|
|
25
|
-
export const retryDelay = (attempt) => new Promise((r) => setTimeout(r, 40 * attempt + Math.random() * 40));
|
|
26
|
-
const READ_MODES = new Set([
|
|
27
|
-
'paths', 'rows', 'ids', 'count', 'versions', 'agg',
|
|
28
|
-
]);
|
|
29
67
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
68
|
+
* Направление перехода между узлами (расширение resolveHop 0.21):
|
|
69
|
+
* - шаг-роль после владельца роли — вперёд этим концом; шаг после роли — назад этим концом;
|
|
70
|
+
* - иначе по концам всех классов обоих семейств: в своём семействе (папка → папки) — назад, к детям;
|
|
71
|
+
* хаб → связь — назад; связь → хаб и хаб → хаб — вперёд; нет нужного — другое направление.
|
|
34
72
|
*/
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const ms = performance.now() - t0;
|
|
50
|
-
const slow = ctx.slowMs !== undefined && ms > ctx.slowMs;
|
|
51
|
-
if (ctx.onQuery)
|
|
52
|
-
ctx.onQuery({ mode, classes, ms, rows: res.length, slow });
|
|
53
|
-
else if (slow)
|
|
54
|
-
console.warn(`letopis: slow query ${ms.toFixed(0)}ms — ${mode} ${classes.join('→')}`);
|
|
55
|
-
}
|
|
56
|
-
return res;
|
|
57
|
-
}
|
|
58
|
-
catch (e) {
|
|
59
|
-
if (isTransient(e)) {
|
|
60
|
-
if (ctx.inTx) {
|
|
61
|
-
if (ctx.userTx)
|
|
62
|
-
hintTxRetry(e);
|
|
63
|
-
}
|
|
64
|
-
else if (READ_MODES.has(mode) && attempt < RETRIES) {
|
|
65
|
-
await retryDelay(attempt);
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
throw e;
|
|
70
|
-
}
|
|
73
|
+
export function resolveHop(reg, prev, next) {
|
|
74
|
+
const P = stepClasses(reg, prev);
|
|
75
|
+
const N = stepClasses(reg, next);
|
|
76
|
+
if (next.role && P.some((c) => reg.find(c)?.ends[next.role]))
|
|
77
|
+
return { dir: 'forward', roles: [next.role] };
|
|
78
|
+
if (prev.role && N.some((c) => reg.find(c)?.ends[prev.role]))
|
|
79
|
+
return { dir: 'reverse', roles: [prev.role] };
|
|
80
|
+
const fwd = rolesBetween(reg, P, N);
|
|
81
|
+
const rev = rolesBetween(reg, N, P);
|
|
82
|
+
const same = P.some((c) => N.includes(c));
|
|
83
|
+
const preferRev = same || (prev.cls.kind === 'hub' && next.cls.kind === 'link');
|
|
84
|
+
for (const dir of preferRev ? ['reverse', 'forward'] : ['forward', 'reverse']) {
|
|
85
|
+
if ((dir === 'forward' ? fwd : rev).length)
|
|
86
|
+
return { dir, roles: null };
|
|
71
87
|
}
|
|
72
|
-
|
|
88
|
+
throw new LetopisError('no_path', `нет перехода ${prev.name} → ${next.name}: ни один класс не ссылается на другой`);
|
|
73
89
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
/** Накопитель позиционных параметров. */
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Фильтры и операторы
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
78
93
|
class Params {
|
|
79
94
|
list = [];
|
|
80
95
|
push(v) {
|
|
@@ -82,378 +97,364 @@ class Params {
|
|
|
82
97
|
return '$' + this.list.length;
|
|
83
98
|
}
|
|
84
99
|
}
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
*/
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
frags.push((a) => `${a}.tags @> ${ph}`);
|
|
102
|
-
}
|
|
103
|
-
else if (k === 'owner' || k === 'account') {
|
|
104
|
-
const ph = p.push(v);
|
|
105
|
-
frags.push((a) => `${a}.${k} = ${ph}::uuid`);
|
|
106
|
-
}
|
|
107
|
-
else if (k === 'id' || k === 'partition') {
|
|
108
|
-
const ph = p.push(v);
|
|
109
|
-
frags.push((a) => `${a}.${k} = ${ph}`);
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
throw new Error(`letopis: acl pattern key "${k}" is not an Entity column`);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return frags;
|
|
116
|
-
}
|
|
117
|
-
function castOf(ft) {
|
|
118
|
-
switch (ft?.kind) {
|
|
119
|
-
case 'number':
|
|
120
|
-
return '::numeric';
|
|
121
|
-
case 'date':
|
|
122
|
-
return '::timestamptz';
|
|
123
|
-
case 'boolean':
|
|
124
|
-
return '::boolean';
|
|
125
|
-
default:
|
|
126
|
-
return '';
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
/** Каст параметра по JS-типу (для jsonb_build_array). */
|
|
130
|
-
const jsCast = (v) => typeof v === 'number' ? '::numeric' : typeof v === 'boolean' ? '::boolean' : '::text';
|
|
131
|
-
/** jsonb-выражение до пути (без извлечения текста): data->'a'->'b'. */
|
|
132
|
-
function jsonbAt(path) {
|
|
133
|
-
return (a) => `${a}.data${path.map((k) => `->'${escLit(k)}'`).join('')}`;
|
|
134
|
-
}
|
|
135
|
-
/** Текст листа по data-пути ЛЮБОЙ глубины: data->'a'->'b'->>'c'. */
|
|
136
|
-
function accessor(path) {
|
|
137
|
-
const head = path.slice(0, -1);
|
|
138
|
-
const last = path[path.length - 1];
|
|
139
|
-
return (a) => `${jsonbAt(head)(a)}->>'${escLit(last)}'`;
|
|
140
|
-
}
|
|
141
|
-
/** Тип листа data-пути по Schema: спуск через record (значение) и object (props). */
|
|
142
|
-
// START_CONTRACT: leafType
|
|
143
|
-
// PURPOSE: Тип листа data-пути по Schema (спуск через record/object) — для SQL-каста.
|
|
144
|
-
// INPUTS: { cls: ClassDef; path: string[] - сегменты data-пути }
|
|
145
|
-
// OUTPUTS: { FieldType | undefined }
|
|
146
|
-
// SIDE_EFFECTS: none
|
|
147
|
-
// LINKS: M-SQL, V-M-SQL, type-FieldType
|
|
148
|
-
// END_CONTRACT: leafType
|
|
149
|
-
export function leafType(cls, path) {
|
|
150
|
-
let ft = cls.fieldTypes.get(path[0]);
|
|
151
|
-
for (let i = 1; i < path.length && ft; i++) {
|
|
152
|
-
ft = ft.kind === 'record' ? ft.value : ft.kind === 'object' ? ft.props.get(path[i]) : undefined;
|
|
100
|
+
const lit = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
101
|
+
const CAST = {
|
|
102
|
+
text: '', jsonb: '', numeric: '::numeric', boolean: '::boolean', timestamptz: '::timestamptz', date: '::date', uuid: '::uuid',
|
|
103
|
+
};
|
|
104
|
+
/** Приведение параметра: время — через text, иначе postgres.js отрезал бы микросекунды через Date. */
|
|
105
|
+
const PCAST = { ...CAST, timestamptz: '::text::timestamptz', date: '::text::date' };
|
|
106
|
+
/** Каст параметра по типу значения JS (для jsonb_build_array). */
|
|
107
|
+
const jsCast = (v) => (typeof v === 'number' ? '::numeric' : typeof v === 'boolean' ? '::boolean' : '::text');
|
|
108
|
+
const jsonbAt = (path) => (a) => `${a}.data${path.map((k) => `->${lit(k)}`).join('')}`;
|
|
109
|
+
const accessor = (path) => (a) => `${jsonbAt(path.slice(0, -1))(a)}->>${lit(path[path.length - 1])}`;
|
|
110
|
+
/** Тип листа по классам шага: первый класс, где поле описано. */
|
|
111
|
+
function leafOf(reg, classes, path) {
|
|
112
|
+
for (const c of classes) {
|
|
113
|
+
const t = reg.leafType(c.name, path);
|
|
114
|
+
if (t !== 'jsonb')
|
|
115
|
+
return t;
|
|
153
116
|
}
|
|
154
|
-
return
|
|
117
|
+
return 'jsonb';
|
|
155
118
|
}
|
|
156
|
-
|
|
157
|
-
// PURPOSE: Скомпилировать оператор фильтра (ne/gt/between/in/like/has*/exists/isNull/not) в SQL-фраг с кастом по типу листа.
|
|
158
|
-
// INPUTS: { path: string[]; ft?: FieldType; o: Op; p: Params }
|
|
159
|
-
// OUTPUTS: { Frag - (alias) => SQL-предикат }
|
|
160
|
-
// SIDE_EFFECTS: пушит значения в Params
|
|
161
|
-
// ERRORS: unknown operator "<name>"
|
|
162
|
-
// LINKS: M-SQL, V-M-SQL, M-OPS
|
|
163
|
-
// END_CONTRACT: compileOp
|
|
164
|
-
function compileOp(path, ft, o, p) {
|
|
165
|
-
// START_BLOCK_COMPILE_OP
|
|
119
|
+
function compileOp(path, leaf, o, p) {
|
|
166
120
|
const lhs = accessor(path);
|
|
167
|
-
const cast =
|
|
168
|
-
|
|
121
|
+
const cast = CAST[leaf];
|
|
122
|
+
const pc = PCAST[leaf];
|
|
123
|
+
// скобки обязательны: '::' сильнее '->>'
|
|
169
124
|
const L = cast ? (a) => `(${lhs(a)})${cast}` : lhs;
|
|
170
|
-
const name = o[OP];
|
|
171
125
|
const args = o.args;
|
|
172
|
-
switch (
|
|
126
|
+
switch (o[OP]) {
|
|
173
127
|
case 'ne':
|
|
174
|
-
return (a) => `${L(a)}
|
|
128
|
+
return (a) => `${L(a)} is distinct from ${p.push(args[0])}${pc}`;
|
|
175
129
|
case 'gt':
|
|
176
130
|
case 'gte':
|
|
177
131
|
case 'lt':
|
|
178
132
|
case 'lte': {
|
|
179
|
-
const sym = { gt: '>', gte: '>=', lt: '<', lte: '<=' }[
|
|
180
|
-
|
|
133
|
+
const sym = { gt: '>', gte: '>=', lt: '<', lte: '<=' }[o[OP]];
|
|
134
|
+
const ph = p.push(args[0]);
|
|
135
|
+
return (a) => `${L(a)} ${sym} ${ph}${pc}`;
|
|
136
|
+
}
|
|
137
|
+
case 'between': {
|
|
138
|
+
const lo = p.push(args[0]);
|
|
139
|
+
const hi = p.push(args[1]);
|
|
140
|
+
return (a) => `${L(a)} between ${lo}${pc} and ${hi}${pc}`;
|
|
181
141
|
}
|
|
182
|
-
case 'between':
|
|
183
|
-
return (a) => `${L(a)} BETWEEN ${p.push(args[0])}${cast} AND ${p.push(args[1])}${cast}`;
|
|
184
142
|
case 'in': {
|
|
185
143
|
const vs = args[0];
|
|
186
144
|
if (!vs.length)
|
|
187
|
-
return () => '
|
|
188
|
-
|
|
145
|
+
return () => 'false';
|
|
146
|
+
const ph = p.push(vs.map((v) => (v === null ? null : String(v))));
|
|
147
|
+
// список одним параметром: текст запроса не зависит от длины списка
|
|
148
|
+
return (a) => `${L(a)} = any (${ph}::text[]${cast ? `${cast}[]` : ''})`;
|
|
149
|
+
}
|
|
150
|
+
case 'like': {
|
|
151
|
+
const ph = p.push(args[0]);
|
|
152
|
+
return (a) => `${lhs(a)} like ${ph}`;
|
|
153
|
+
}
|
|
154
|
+
case 'ilike': {
|
|
155
|
+
const ph = p.push(args[0]);
|
|
156
|
+
return (a) => `${lhs(a)} ilike ${ph}`;
|
|
157
|
+
}
|
|
158
|
+
case 'has': {
|
|
159
|
+
const ph = p.push(args[0]) + jsCast(args[0]);
|
|
160
|
+
return (a) => `${jsonbAt(path)(a)} @> jsonb_build_array(${ph})`;
|
|
189
161
|
}
|
|
190
|
-
case 'like':
|
|
191
|
-
return (a) => `${lhs(a)} LIKE ${p.push(args[0])}`;
|
|
192
|
-
case 'ilike':
|
|
193
|
-
return (a) => `${lhs(a)} ILIKE ${p.push(args[0])}`;
|
|
194
|
-
case 'has':
|
|
195
|
-
// jsonb_build_array с кастом по JS-типу — JS-массив postgres.js слал бы как PG-array
|
|
196
|
-
return (a) => `${jsonbAt(path)(a)} @> jsonb_build_array(${p.push(args[0])}${jsCast(args[0])})`;
|
|
197
162
|
case 'hasAll': {
|
|
198
163
|
const vs = args[0];
|
|
199
164
|
if (!vs.length)
|
|
200
|
-
return () => '
|
|
201
|
-
|
|
165
|
+
return () => 'true';
|
|
166
|
+
const ph = p.push(vs);
|
|
167
|
+
return (a) => `${jsonbAt(path)(a)} @> ${ph}::jsonb`;
|
|
202
168
|
}
|
|
203
169
|
case 'hasAny': {
|
|
204
170
|
const vs = args[0];
|
|
205
171
|
if (!vs.length)
|
|
206
|
-
return () => '
|
|
207
|
-
|
|
172
|
+
return () => 'false';
|
|
173
|
+
const ph = p.push(vs.map(String));
|
|
174
|
+
return (a) => `${jsonbAt(path)(a)} ?| ${ph}::text[]`;
|
|
208
175
|
}
|
|
209
176
|
case 'exists': {
|
|
210
177
|
const holder = jsonbAt(path.slice(0, -1));
|
|
211
|
-
const
|
|
212
|
-
return args[0]
|
|
213
|
-
? (a) => `${holder(a)} ? '${last}'`
|
|
214
|
-
: (a) => `NOT (${holder(a)} ? '${last}')`;
|
|
178
|
+
const key = lit(path[path.length - 1]);
|
|
179
|
+
return args[0] ? (a) => `${holder(a)} ? ${key}` : (a) => `not coalesce(${holder(a)} ? ${key}, false)`;
|
|
215
180
|
}
|
|
216
181
|
case 'isNull':
|
|
217
|
-
return (a) => `${lhs(a)}
|
|
182
|
+
return (a) => `${lhs(a)} is null`;
|
|
218
183
|
case 'not': {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
184
|
+
if (isOp(args[0])) {
|
|
185
|
+
const inner = compileOp(path, leaf, args[0], p);
|
|
186
|
+
return (a) => `not coalesce(${inner(a)}, false)`;
|
|
187
|
+
}
|
|
188
|
+
return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p);
|
|
223
189
|
}
|
|
224
190
|
default:
|
|
225
|
-
|
|
191
|
+
return fail(`неизвестный оператор ${String(o[OP])}`);
|
|
226
192
|
}
|
|
227
|
-
// END_BLOCK_COMPILE_OP
|
|
228
193
|
}
|
|
229
|
-
/**
|
|
194
|
+
/** Колонка tags: строка | список (все) | has / hasAny / hasAll. */
|
|
230
195
|
function compileTags(v, p) {
|
|
196
|
+
const all = (xs) => {
|
|
197
|
+
if (!xs.length)
|
|
198
|
+
return () => 'true';
|
|
199
|
+
const ph = p.push(xs.map(String));
|
|
200
|
+
return (a) => `${a}.tags @> ${ph}::text[]`;
|
|
201
|
+
};
|
|
231
202
|
if (typeof v === 'string')
|
|
232
|
-
return (
|
|
233
|
-
if (Array.isArray(v))
|
|
234
|
-
|
|
235
|
-
return () => 'TRUE';
|
|
236
|
-
return (a) => `${a}.tags @> ARRAY[${v.map((x) => p.push(x)).join(', ')}]::text[]`;
|
|
237
|
-
}
|
|
203
|
+
return all([v]);
|
|
204
|
+
if (Array.isArray(v))
|
|
205
|
+
return all(v);
|
|
238
206
|
if (isOp(v)) {
|
|
239
|
-
const args = v.args;
|
|
240
207
|
switch (v[OP]) {
|
|
241
208
|
case 'has':
|
|
242
|
-
return (
|
|
209
|
+
return all([v.args[0]]);
|
|
243
210
|
case 'hasAll':
|
|
244
|
-
return (
|
|
245
|
-
case 'hasAny':
|
|
246
|
-
|
|
211
|
+
return all(v.args[0]);
|
|
212
|
+
case 'hasAny': {
|
|
213
|
+
const ph = p.push(v.args[0].map(String));
|
|
214
|
+
return (a) => `${a}.tags && ${ph}::text[]`;
|
|
215
|
+
}
|
|
247
216
|
}
|
|
248
217
|
}
|
|
249
|
-
|
|
218
|
+
return fail('tags() принимает строку, список или has/hasAny/hasAll');
|
|
250
219
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// ERRORS: filter.id accepts string | string[]
|
|
257
|
-
// LINKS: M-SQL, V-M-SQL
|
|
258
|
-
// END_CONTRACT: compileFilter
|
|
259
|
-
function compileFilter(cls, filter, p) {
|
|
260
|
-
const out = { idConds: [], candFrags: [], finalFrags: [] };
|
|
220
|
+
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
|
|
221
|
+
/** Объект-строка ответа как значение: берётся id. */
|
|
222
|
+
const idOfValue = (v) => typeof v === 'string' ? v : isPlain(v) && typeof v.id === 'string' ? v.id : undefined;
|
|
223
|
+
function compileFilter(reg, classes, filter, p) {
|
|
224
|
+
const out = { ends: [], frags: [] };
|
|
261
225
|
if (filter === undefined)
|
|
262
226
|
return out;
|
|
263
|
-
// or(
|
|
264
|
-
if (isOp(filter)
|
|
227
|
+
// or(…): дизъюнкция под-фильтров — только перепроверка
|
|
228
|
+
if (isOp(filter)) {
|
|
229
|
+
if (filter[OP] !== 'or')
|
|
230
|
+
fail(`оператор ${String(filter[OP])} на уровне фильтра шага — допустим только or(…)`);
|
|
265
231
|
const parts = filter.args[0].map((f) => {
|
|
266
|
-
const sub = compileFilter(
|
|
267
|
-
const frags = [...sub
|
|
268
|
-
return (a) => (frags.length ? `(${frags.map((fr) => fr(a)).join('
|
|
232
|
+
const sub = compileFilter(reg, classes, f, p);
|
|
233
|
+
const frags = [...asFrags(sub, p)];
|
|
234
|
+
return (a) => (frags.length ? `(${frags.map((fr) => fr(a)).join(' and ')})` : 'true');
|
|
269
235
|
});
|
|
270
|
-
|
|
271
|
-
out.finalFrags.push((a) => `(${parts.map((fr) => fr(a)).join(' OR ')})`);
|
|
236
|
+
out.frags.push(parts.length ? (a) => `(${parts.map((fr) => fr(a)).join(' or ')})` : () => 'false');
|
|
272
237
|
return out;
|
|
273
238
|
}
|
|
274
239
|
if (typeof filter === 'string') {
|
|
275
|
-
out.
|
|
240
|
+
out.ids = [filter];
|
|
276
241
|
return out;
|
|
277
242
|
}
|
|
278
243
|
if (Array.isArray(filter)) {
|
|
279
|
-
|
|
280
|
-
out.idConds.push(() => 'FALSE');
|
|
281
|
-
else
|
|
282
|
-
out.idConds.push((a) => `${a}.id IN (${filter.map((x) => p.push(x)).join(', ')})`);
|
|
244
|
+
out.ids = filter.map((x) => idOfValue(x) ?? fail('список id: ожидались строки'));
|
|
283
245
|
return out;
|
|
284
246
|
}
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
* операторы уходят в finalFrags по своему пути (каст по типу листа из Schema),
|
|
291
|
-
* скаляры/массивы собираются в containment-поддерево (GIN).
|
|
292
|
-
* Возврат: поддерево для eqData; undefined — внутри были только операторы.
|
|
293
|
-
*/
|
|
247
|
+
const props = (classes[0]?.schema.properties ?? {});
|
|
248
|
+
const isRole = (k) => !(k in props) && classes.some((c) => c.ends[k] !== undefined);
|
|
249
|
+
const eq = {};
|
|
250
|
+
// разбор объекта любой глубины: операторы — по своему пути с приведением по типу листа,
|
|
251
|
+
// скаляры и списки — в поддерево вложения (индекс GIN)
|
|
294
252
|
const walk = (path, obj) => {
|
|
295
|
-
const
|
|
253
|
+
const sub = {};
|
|
296
254
|
let hadOps = false;
|
|
297
255
|
for (const [k, v] of Object.entries(obj)) {
|
|
298
256
|
if (v === undefined)
|
|
299
257
|
continue;
|
|
300
|
-
const
|
|
258
|
+
const at = [...path, k];
|
|
301
259
|
if (isOp(v)) {
|
|
302
|
-
out.
|
|
260
|
+
out.frags.push(compileOp(at, leafOf(reg, classes, at), v, p));
|
|
303
261
|
hadOps = true;
|
|
304
262
|
}
|
|
305
263
|
else if (isPlain(v)) {
|
|
306
|
-
const nested = walk(
|
|
264
|
+
const nested = walk(at, v);
|
|
307
265
|
if (nested !== undefined)
|
|
308
|
-
|
|
266
|
+
sub[k] = nested;
|
|
309
267
|
else
|
|
310
268
|
hadOps = true;
|
|
311
269
|
}
|
|
312
|
-
else
|
|
313
|
-
|
|
314
|
-
}
|
|
270
|
+
else
|
|
271
|
+
sub[k] = v;
|
|
315
272
|
}
|
|
316
|
-
if (Object.keys(
|
|
317
|
-
return
|
|
318
|
-
return hadOps ? undefined : {};
|
|
273
|
+
if (Object.keys(sub).length)
|
|
274
|
+
return sub;
|
|
275
|
+
return hadOps ? undefined : {};
|
|
319
276
|
};
|
|
320
277
|
for (const [key, value] of Object.entries(filter)) {
|
|
321
278
|
if (value === undefined)
|
|
322
279
|
continue;
|
|
323
280
|
if (key === 'id') {
|
|
324
281
|
if (typeof value === 'string')
|
|
325
|
-
out.
|
|
282
|
+
out.ids = [value];
|
|
326
283
|
else if (Array.isArray(value))
|
|
327
|
-
out.
|
|
284
|
+
out.ids = value.map((x) => idOfValue(x) ?? fail('filter.id: ожидались строки'));
|
|
328
285
|
else
|
|
329
|
-
|
|
286
|
+
fail('filter.id принимает строку или список строк');
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (isRole(key)) {
|
|
290
|
+
const role = lit(key);
|
|
291
|
+
if (value === null) {
|
|
292
|
+
out.frags.push((a) => `coalesce(${a}.links->${role}, 'null'::jsonb) in ('null'::jsonb, '[]'::jsonb)`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const id = idOfValue(value) ?? fail(`конец ${key}: ожидался id, строка ответа или null`);
|
|
296
|
+
out.ends.push(id);
|
|
297
|
+
const ph = p.push(id);
|
|
298
|
+
out.frags.push((a) => `@S@.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
|
|
330
299
|
continue;
|
|
331
300
|
}
|
|
332
301
|
if (isOp(value)) {
|
|
333
|
-
out.
|
|
302
|
+
out.frags.push(compileOp([key], leafOf(reg, classes, [key]), value, p));
|
|
334
303
|
continue;
|
|
335
304
|
}
|
|
336
305
|
if (isPlain(value)) {
|
|
337
306
|
const nested = walk([key], value);
|
|
338
307
|
if (nested !== undefined)
|
|
339
|
-
|
|
308
|
+
eq[key] = nested;
|
|
340
309
|
continue;
|
|
341
310
|
}
|
|
342
|
-
|
|
343
|
-
eqData[key] = value;
|
|
311
|
+
eq[key] = value;
|
|
344
312
|
}
|
|
345
|
-
if (Object.keys(
|
|
346
|
-
|
|
347
|
-
const frag = (a) => `${a}.data @> ${ph}::jsonb`;
|
|
348
|
-
out.candFrags.push(frag);
|
|
349
|
-
out.finalFrags.push(frag);
|
|
350
|
-
}
|
|
351
|
-
// END_BLOCK_COMPILE_FILTER_WALK
|
|
313
|
+
if (Object.keys(eq).length)
|
|
314
|
+
out.eq = eq;
|
|
352
315
|
return out;
|
|
353
316
|
}
|
|
354
|
-
/**
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
// INPUTS: { prev: ClassDef; next: ClassDef }
|
|
361
|
-
// OUTPUTS: { HopMode - 'forward' | 'reverse' }
|
|
362
|
-
// SIDE_EFFECTS: none
|
|
363
|
-
// ERRORS: no path A→B (neither embeds the other); LINK → LINK traversal is not supported
|
|
364
|
-
// LINKS: M-SQL, V-M-SQL
|
|
365
|
-
// END_CONTRACT: resolveHop
|
|
366
|
-
/** Правило обхода между шагами (по реестру Schema). */
|
|
367
|
-
export function resolveHop(prev, next) {
|
|
368
|
-
if (prev.category === 'HUB' && next.category === 'LINK')
|
|
369
|
-
return 'reverse';
|
|
370
|
-
if (prev.category === 'LINK' && next.category === 'HUB')
|
|
371
|
-
return 'forward';
|
|
372
|
-
if (prev.category === 'HUB' && next.category === 'HUB') {
|
|
373
|
-
// self-переход (Папка→Папка) = reverse: дети; родитель и так лежит в row.links
|
|
374
|
-
if (prev.id === next.id)
|
|
375
|
-
return 'reverse';
|
|
376
|
-
if (linksTo(prev, next.id))
|
|
377
|
-
return 'forward';
|
|
378
|
-
if (linksTo(next, prev.id))
|
|
379
|
-
return 'reverse';
|
|
380
|
-
throw new Error(`letopis: no path ${prev.id} → ${next.id}: neither embeds the other (Schema.links)`);
|
|
317
|
+
/** Все условия фильтра как условия чтения (для or и pivot). */
|
|
318
|
+
function asFrags(cf, p) {
|
|
319
|
+
const frags = [...cf.frags];
|
|
320
|
+
if (cf.ids) {
|
|
321
|
+
const ph = p.push(cf.ids);
|
|
322
|
+
frags.push((a) => `${a}.id = any (${ph}::uuid[])`);
|
|
381
323
|
}
|
|
382
|
-
|
|
324
|
+
if (cf.eq) {
|
|
325
|
+
const ph = p.push(cf.eq);
|
|
326
|
+
frags.push((a) => `${a}.data @> ${ph}::jsonb`);
|
|
327
|
+
}
|
|
328
|
+
return frags;
|
|
383
329
|
}
|
|
384
|
-
function
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
return
|
|
330
|
+
function sortSpec(order, leaf) {
|
|
331
|
+
const f = order === 'updated' ? 'at' : order;
|
|
332
|
+
const col = { field: order, nullable: false };
|
|
333
|
+
if (f === 'at')
|
|
334
|
+
return { ...col, expr: (a) => `${a}.at`, pcast: '::text::timestamptz', type: 'timestamptz' };
|
|
335
|
+
if (f === 'rev')
|
|
336
|
+
return { ...col, expr: (a) => `${a}.rev`, pcast: '::int', type: 'int' };
|
|
337
|
+
if (f === 'id')
|
|
338
|
+
return { ...col, expr: (a) => `${a}.id`, pcast: '::uuid', type: 'uuid' };
|
|
339
|
+
if (f.startsWith('data.')) {
|
|
340
|
+
const path = f.slice(5).split('.');
|
|
341
|
+
const t = leaf(path);
|
|
342
|
+
return { expr: (a) => (CAST[t] ? `(${accessor(path)(a)})${CAST[t]}` : accessor(path)(a)), pcast: PCAST[t], field: order, type: t, nullable: true };
|
|
391
343
|
}
|
|
392
|
-
|
|
344
|
+
return fail(`sort: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${order}'`);
|
|
393
345
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
346
|
+
/** Формы значений курсора — как у форматов uuid, date и date-time валидатора (validate.ts). */
|
|
347
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
348
|
+
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
349
|
+
const TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?(?:Z|[+-](\d{2}):(\d{2}))$/;
|
|
350
|
+
/** Дата (и время), которую примет приведение базы ::date / ::timestamptz: форма и смысл полей. */
|
|
351
|
+
function dateOk(s, withTime) {
|
|
352
|
+
const m = (withTime ? TIME_RE : DATE_RE).exec(s);
|
|
353
|
+
if (!m)
|
|
354
|
+
return false;
|
|
355
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
356
|
+
const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
|
357
|
+
if (y < 1 || mo < 1 || mo > 12 || d < 1 || d > [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][mo - 1])
|
|
358
|
+
return false;
|
|
359
|
+
if (!withTime)
|
|
360
|
+
return true;
|
|
361
|
+
const [h, mi, se] = [Number(m[4]), Number(m[5]), Number(m[6])];
|
|
362
|
+
const frac = /[1-9]/.test(m[7] ?? '');
|
|
363
|
+
// 24:00:00 и секунду :60 база принимает, но без долей секунды; смещение — до ±15:59
|
|
364
|
+
if (h > 24 || mi > 59 || se > 60 || (h === 24 && (mi > 0 || se > 0 || frac)) || (se === 60 && frac))
|
|
365
|
+
return false;
|
|
366
|
+
return m[8] === undefined || (Number(m[8]) <= 15 && Number(m[9]) <= 59);
|
|
367
|
+
}
|
|
368
|
+
/** Значение курсора для параметра keyset по типу поля сортировки; undefined — не подходит. */
|
|
369
|
+
function cursorValue(v, type) {
|
|
370
|
+
const text = typeof v === 'string' && !v.includes('\0'); // нулевой символ текст PostgreSQL не примет
|
|
371
|
+
switch (type) {
|
|
372
|
+
case 'numeric':
|
|
373
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
374
|
+
case 'int':
|
|
375
|
+
return Number.isInteger(v) && v >= -2147483648 && v <= 2147483647 ? v : undefined;
|
|
376
|
+
case 'boolean':
|
|
377
|
+
return typeof v === 'boolean' ? v : undefined;
|
|
378
|
+
case 'uuid':
|
|
379
|
+
return typeof v === 'string' && UUID_RE.test(v) ? v : undefined;
|
|
380
|
+
case 'timestamptz':
|
|
381
|
+
case 'date':
|
|
382
|
+
return typeof v === 'string' && dateOk(v, type === 'timestamptz') ? v : undefined;
|
|
383
|
+
case 'text':
|
|
384
|
+
return text ? v : undefined;
|
|
385
|
+
case 'jsonb':
|
|
386
|
+
// поле без описания сравнивается текстом (->>): скаляр уходит своим текстом, как его отдаёт база
|
|
387
|
+
return text || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v)) ? String(v) : undefined;
|
|
388
|
+
}
|
|
401
389
|
}
|
|
402
|
-
/**
|
|
403
|
-
function
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return
|
|
390
|
+
/** Значение для текста ошибки: JSON, длинное — с многоточием. */
|
|
391
|
+
function shown(v) {
|
|
392
|
+
let s;
|
|
393
|
+
try {
|
|
394
|
+
s = typeof v === 'number' || typeof v === 'bigint' || v === undefined ? String(v) : (JSON.stringify(v) ?? String(v));
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
s = String(v);
|
|
398
|
+
}
|
|
399
|
+
return s.length > 60 ? `${s.slice(0, 59)}…` : s;
|
|
412
400
|
}
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
401
|
+
const CURSOR_EXPECT = {
|
|
402
|
+
numeric: 'число', int: 'целое число', boolean: 'true/false', text: 'строка', uuid: 'uuid строкой',
|
|
403
|
+
timestamptz: 'время строкой вида 2026-10-07T10:00:00Z', date: 'дата строкой вида 2026-10-07', jsonb: 'строка, число, true/false',
|
|
416
404
|
};
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
405
|
+
/**
|
|
406
|
+
* Параметры keyset из курсора: значение проверяется по типу поля сортировки до запроса. Курсор
|
|
407
|
+
* cursorOf() по полю sort() проходит всегда; курсор другого поля или собранный вручную — invalid_query,
|
|
408
|
+
* а не ошибка приведения сервера (22P02, 22007, 42883…) и не запрос, повисший на undefined в параметре.
|
|
409
|
+
* Курсор того же типа с другого поля проверка не отличит.
|
|
410
|
+
*/
|
|
411
|
+
function cursorParams(c, sort) {
|
|
412
|
+
const how = `cursorOf(строка, '${sort.field}')`;
|
|
413
|
+
if (typeof c !== 'object' || c === null || c.v === undefined)
|
|
414
|
+
return fail(`after(cursor): курсор — объект { v, id } из ${how}`);
|
|
415
|
+
const { v, id } = c;
|
|
416
|
+
if (typeof id !== 'string' || !UUID_RE.test(id))
|
|
417
|
+
return fail(`after(cursor): id курсора — uuid строки, а не ${shown(id)}`);
|
|
418
|
+
const value = v === null && sort.nullable ? null : cursorValue(v, sort.type);
|
|
419
|
+
if (value === undefined) {
|
|
420
|
+
return fail(`after(cursor): значение курсора ${shown(v)} не подходит к sort('${sort.field}') — ожидалось: ${CURSOR_EXPECT[sort.type]}`
|
|
421
|
+
+ `${sort.nullable ? ' или null' : ''}; курсор снят с другого поля? Нужен ${how}`);
|
|
422
|
+
}
|
|
423
|
+
return { v: value, id };
|
|
424
|
+
}
|
|
425
|
+
/** Курсор keyset-пагинации из последней строки страницы. */
|
|
426
|
+
export function cursorOf(row, field) {
|
|
427
|
+
const f = field === 'updated' ? 'at' : field;
|
|
428
|
+
if (f === 'at' || f === 'rev' || f === 'id')
|
|
429
|
+
return { v: row[f], id: row.id };
|
|
430
|
+
if (f.startsWith('data.')) {
|
|
431
|
+
let v = row.data;
|
|
432
|
+
for (const k of f.slice(5).split('.'))
|
|
433
|
+
v = v?.[k];
|
|
434
|
+
return { v: (v ?? null), id: row.id };
|
|
429
435
|
}
|
|
436
|
+
return fail(`cursorOf: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${field}'`);
|
|
430
437
|
}
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
export function buildRead(ctx, steps, mods, mode) {
|
|
441
|
-
guardScoped(ctx);
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Построение запроса
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason', 'moved'];
|
|
442
|
+
const colsOf = (a) => COLS.map((c) => `${a}.${c}`).join(', ');
|
|
443
|
+
export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
444
|
+
if (!steps.length)
|
|
445
|
+
fail('пустая цепочка');
|
|
446
|
+
const S = `"${schema.replace(/"/g, '""')}"`;
|
|
442
447
|
const p = new Params();
|
|
443
|
-
const
|
|
444
|
-
const
|
|
445
|
-
// pivot:
|
|
446
|
-
// (pivot-шаг блока не строит, ссылается на узел по pivotKey).
|
|
447
|
-
// START_BLOCK_BUILD_PIVOT_MAP
|
|
448
|
+
const cur = mods.asOf === undefined;
|
|
449
|
+
const atPh = cur ? 'null' : `${p.push(mods.asOf)}::text::timestamptz`;
|
|
450
|
+
// pivot: real[i] — индекс узла шага i
|
|
448
451
|
const real = [];
|
|
449
452
|
const byNodeKey = new Map();
|
|
450
|
-
|
|
451
|
-
for (let i = 0; i < steps.length; i++) {
|
|
452
|
-
const s = steps[i];
|
|
453
|
+
steps.forEach((s, i) => {
|
|
453
454
|
if (s.pivotKey !== undefined) {
|
|
454
455
|
const at = byNodeKey.get(s.pivotKey);
|
|
455
456
|
if (at === undefined)
|
|
456
|
-
|
|
457
|
+
fail(`возврат к узлу ${s.name}: узла нет в этом пути`);
|
|
457
458
|
real[i] = at;
|
|
458
459
|
}
|
|
459
460
|
else {
|
|
@@ -461,360 +462,273 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
461
462
|
if (s.nodeKey !== undefined)
|
|
462
463
|
byNodeKey.set(s.nodeKey, i);
|
|
463
464
|
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const
|
|
465
|
+
});
|
|
466
|
+
const lastNode = real[steps.length - 1];
|
|
467
|
+
const ctes = [];
|
|
468
|
+
const joins = [];
|
|
469
|
+
const tail = [];
|
|
470
|
+
const nodeClasses = new Map();
|
|
471
|
+
const rowsNode = (i) => nodeClasses.get(i) ?? [steps[i].cls];
|
|
468
472
|
for (let i = 0; i < steps.length; i++) {
|
|
469
473
|
const step = steps[i];
|
|
470
|
-
|
|
474
|
+
const names = stepClasses(reg, step);
|
|
475
|
+
const infos = names.map((n) => reg.find(n)).filter((c) => !!c);
|
|
476
|
+
const typing = [step.cls, ...infos.filter((c) => c !== step.cls)];
|
|
477
|
+
// возврат к узлу: только дофильтры
|
|
471
478
|
if (step.pivotKey !== undefined) {
|
|
472
479
|
const a = `h${real[i]}`;
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
tailConds.push(c(a));
|
|
476
|
-
for (const fr of f.finalFrags)
|
|
477
|
-
tailConds.push(fr(a));
|
|
480
|
+
for (const fr of asFrags(compileFilter(reg, typing, step.filter, p), p))
|
|
481
|
+
tail.push(fr(a));
|
|
478
482
|
if (step.tagsFilter !== undefined)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (step.
|
|
483
|
-
|
|
483
|
+
tail.push(compileTags(step.tagsFilter, p)(a));
|
|
484
|
+
if (step.ownerFilter !== undefined)
|
|
485
|
+
tail.push(`${a}.owner = ${p.push(step.ownerFilter)}::uuid`);
|
|
486
|
+
if (step.exact)
|
|
487
|
+
tail.push(`${a}.class = any (${p.push(names)}::text[])`);
|
|
484
488
|
continue;
|
|
485
489
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
490
|
+
nodeClasses.set(i, typing);
|
|
491
|
+
const cf = compileFilter(reg, typing, step.filter, p);
|
|
492
|
+
const clsPh = `${p.push(names)}::text[]`;
|
|
493
|
+
const conds = [(a) => `${a}.class = any (${clsPh})`, ...asFrags(cf, p)];
|
|
494
|
+
if (step.tagsFilter !== undefined)
|
|
495
|
+
conds.push(compileTags(step.tagsFilter, p));
|
|
496
|
+
if (step.ownerFilter !== undefined) {
|
|
497
|
+
const ph = p.push(step.ownerFilter);
|
|
498
|
+
conds.push((a) => `${a}.owner = ${ph}::uuid`);
|
|
499
|
+
}
|
|
500
|
+
const where = (a, extra) => [extra, ...conds.map((c) => c(a))].filter(Boolean).join(' and ');
|
|
501
|
+
// параметры поиска — по первому использованию: неиспользованный параметр PostgreSQL не примет
|
|
502
|
+
const lazy = (make) => {
|
|
503
|
+
let v;
|
|
504
|
+
return () => (v ??= make());
|
|
505
|
+
};
|
|
506
|
+
const dataP = lazy(() => (cf.eq ? `${p.push(cf.eq)}::jsonb` : 'null'));
|
|
507
|
+
const endsP = lazy(() => (cf.ends.length ? `${p.push(cf.ends)}::uuid[]` : 'null'));
|
|
508
|
+
const idsP = lazy(() => (cf.ids ? `${p.push(cf.ids)}::uuid[]` : 'null'));
|
|
509
|
+
const dead = mods.withDeleted === true && i === lastNode;
|
|
510
|
+
const dataCond = (v) => (cf.eq ? ` and ${v}.data @> ${dataP()}` : '');
|
|
511
|
+
const endsCond = (col) => (cf.ends.length ? ` and ${col} @> ${endsP()}` : '');
|
|
512
|
+
/** Версии журнала: последняя версия каждого id не позже момента. */
|
|
513
|
+
const versionsAt = (at, where) => `(select distinct on (l.id) l.id, l.rev, l.op, l.class, l.data, l.links from ${S}.log l where ${where} `
|
|
514
|
+
+ `and l.at <= coalesce(${at}, 'infinity'::timestamptz) order by l.id, l.rev desc)`;
|
|
515
|
+
/** Поиск id: find_ids() или (сверка) тот же запрос под RLS. */
|
|
516
|
+
const findIds = (ids, at, isDead) => {
|
|
517
|
+
if (!opts.direct)
|
|
518
|
+
return `${S}.find_ids(${clsPh}, ${dataP()}, ${endsP()}, ${ids ?? 'null'}, ${at}, ${isDead})`;
|
|
519
|
+
if (at === 'null' && !isDead) {
|
|
520
|
+
return `(select v.id, v.rev from ${S}.entity v where v.class = any (${clsPh})${dataCond('v')}${endsCond('v.ends')}`
|
|
521
|
+
+ `${ids ? ` and v.id = any (${ids})` : ''})`;
|
|
512
522
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
continue;
|
|
536
|
-
allowed.push(id);
|
|
537
|
-
if (d.filter)
|
|
538
|
-
aclFrags.set(id, rowTemplateFrags(d.filter, p));
|
|
523
|
+
return `(select v.id, v.rev from ${versionsAt(at, ids ? `l.id = any (${ids})` : `l.class = any (${clsPh})`)} v `
|
|
524
|
+
+ `where v.class = any (${clsPh}) and (v.op = 'delete') = ${isDead} and v.op not in ('purge', 'trim', 'reset')${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)})`;
|
|
525
|
+
};
|
|
526
|
+
const entityPart = (src, depth = 'null::int', from = `${S}.entity x`) => `select ${colsOf('x')}, ${depth} as depth from ${from} where ${where('x', src)}`;
|
|
527
|
+
/**
|
|
528
|
+
* Строки текущего состояния по списку id (запрос с колонкой id): вложенный цикл по первичному
|
|
529
|
+
* ключу. Через lateral, а не id = any(…): так планировщик видит число строк узла и соединяет
|
|
530
|
+
* узлы в пути хэшем, а не перебором.
|
|
531
|
+
*/
|
|
532
|
+
const entityByIds = (ids) => `select ${colsOf('x')}, null::int as depth from (select distinct w.id from (${ids}) w) f `
|
|
533
|
+
+ `cross join lateral (select * from ${S}.entity e where e.id = f.id) x where ${where('x')}`;
|
|
534
|
+
const logPart = (pairs) =>
|
|
535
|
+
// версия журнала по паре (id, rev): вложенный цикл по индексу log_id_rev, без перебора журнала
|
|
536
|
+
`select ${colsOf('x')}, null::int as depth from (${pairs}) f(id, rev) `
|
|
537
|
+
+ `cross join lateral (select * from ${S}.log l where l.id = f.id and l.rev = f.rev) x where ${where('x')}`;
|
|
538
|
+
const parts = [];
|
|
539
|
+
if (i === 0) {
|
|
540
|
+
if (step.deepMax !== undefined)
|
|
541
|
+
fail('deep() — на шаге перехода к тому же классу, а не на первом шаге');
|
|
542
|
+
if (cur) {
|
|
543
|
+
// условие id уже в conds — первичный ключ; вложение — поиск по индексу GIN
|
|
544
|
+
parts.push(!cf.ids && (cf.eq || cf.ends.length) ? entityByIds(`select f.id from ${findIds(null, 'null', false)} f`) : entityPart(undefined));
|
|
539
545
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
throw aclDenied('READ', step.cls.id, own);
|
|
543
|
-
classes = allowed;
|
|
544
|
-
}
|
|
545
|
-
// предикат WRITE/DELETE-правила (поиск целей записи): только свои строки
|
|
546
|
-
if (step.aclFilter) {
|
|
547
|
-
for (const fr of rowTemplateFrags(step.aclFilter, p)) {
|
|
548
|
-
f.candFrags.push(fr);
|
|
549
|
-
f.finalFrags.push(fr);
|
|
546
|
+
else {
|
|
547
|
+
parts.push(logPart(`select f.id, f.rev from ${findIds(cf.ids ? idsP() : null, atPh, false)} f`));
|
|
550
548
|
}
|
|
549
|
+
if (dead)
|
|
550
|
+
parts.push(logPart(`select f.id, f.rev from ${findIds(cf.ids ? idsP() : null, atPh, true)} f`));
|
|
551
|
+
ctes.push(`n0 as (${parts.join(' union all ')})`);
|
|
552
|
+
continue;
|
|
551
553
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
const
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
return preds.length ? `(${eq} AND ${preds.join(' AND ')})` : eq;
|
|
562
|
-
})
|
|
563
|
-
.join(' OR ');
|
|
564
|
-
const classCond = poly || aclFrags.size ? (a) => `(${classFrag(a)})` : classFrag;
|
|
565
|
-
// ТОЛЬКО в finalFrags: в candFrags нельзя — иначе кандидатный подзапрос появлялся бы
|
|
566
|
-
// на КАЖДОМ запросе (условие по классу непустое всегда), а он нужен лишь когда есть
|
|
567
|
-
// GIN-предикаты. В основной скан условие попадает через innerWhere ниже.
|
|
568
|
-
f.finalFrags.push(classCond);
|
|
569
|
-
const innerWhere = [`e.partition = ${pPart}`, classCond('e')];
|
|
570
|
-
if (mods.asOf !== undefined)
|
|
571
|
-
innerWhere.push(`e.updated <= ${p.push(mods.asOf)}::timestamptz`); // «как было на T»
|
|
572
|
-
// versions/.withDeleted(): последний шаг включает и удалённые (история/tombstone видны после delete).
|
|
573
|
-
// Снимается ТОЛЬКО фильтр deleted — enforceAccount/enforceAcl/контекст-фраги ниже действуют (изоляция цела).
|
|
574
|
-
const keepDeleted = i === steps.length - 1 && (mode === 'versions' || mods.withDeleted === true);
|
|
575
|
-
const outerWhere = [keepDeleted ? 'TRUE' : 't.deleted IS NULL'];
|
|
576
|
-
const candWhere = f.candFrags.map((fr) => fr('c'));
|
|
577
|
-
for (const c of f.idConds)
|
|
578
|
-
innerWhere.push(c('e'));
|
|
579
|
-
for (const fr of f.finalFrags)
|
|
580
|
-
outerWhere.push(fr('t'));
|
|
581
|
-
const isDeep = step.deepMax !== undefined;
|
|
582
|
-
if (i > 0 && !isDeep) { // deep строит containment сам (recursive CTE)
|
|
583
|
-
const prev = steps[i - 1];
|
|
584
|
-
const prevAlias = `h${real[i - 1]}`; // pivot: ветвление от узла-возврата
|
|
585
|
-
const hop = resolveHop(prev.cls, step.cls);
|
|
586
|
-
// ключи Entity.links — ВСЕГДА конкретные id классов, не родительские. Поэтому при
|
|
587
|
-
// полиморфном шаге ключ берётся из колонки class самой строки, а не из литерала.
|
|
588
|
-
const prevPoly = prev.cls.descendants.length > 0;
|
|
589
|
-
if (hop === 'forward') {
|
|
590
|
-
// id неизменен — условие сразу во внутренний WHERE, перепроверка не нужна
|
|
591
|
-
innerWhere.push(poly
|
|
592
|
-
? `${prevAlias}.links @> jsonb_build_object(e.class::text, e.id)`
|
|
593
|
-
: `e.id = ${prevAlias}.links->>${p.push(step.cls.id)}`);
|
|
554
|
+
const a = real[i - 1];
|
|
555
|
+
const hop = resolveHop(reg, steps[a], step);
|
|
556
|
+
const rolesPh = hop.roles ? `${p.push(hop.roles)}::text[]` : 'null';
|
|
557
|
+
const srcArr = `array(select distinct s.id from n${a} s)`;
|
|
558
|
+
const srcs = `(select distinct u.x as src from unnest(${srcArr}) u(x)) s`;
|
|
559
|
+
const rolesCond = (v, src) => (hop.roles ? ` and ${S}.role_has(${v}.links, ${rolesPh}, ${src})` : '');
|
|
560
|
+
if (step.deepMax !== undefined) {
|
|
561
|
+
if (hop.dir !== 'reverse' || !stepClasses(reg, steps[a]).some((c) => names.includes(c))) {
|
|
562
|
+
fail(`deep() — только на переходе к тому же классу (дети любой глубины): ${steps[a].name} → ${step.name}`);
|
|
594
563
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
564
|
+
if (dead)
|
|
565
|
+
fail('deep() и withDeleted() не сочетаются');
|
|
566
|
+
const maxPh = `${p.push(step.deepMax)}::int`;
|
|
567
|
+
let deepSrc = `${S}.find_deep(${clsPh}, ${rolesPh}, ${srcArr}, ${maxPh}, ${atPh})`;
|
|
568
|
+
if (opts.direct) {
|
|
569
|
+
// тот же обход под RLS: невидимый узел обрывает поддерево политикой
|
|
570
|
+
const node = cur ? `${S}.entity` : `(select w.id, w.rev, w.links from ${versionsAt(atPh, `l.class = any (${clsPh})`)} w where w.op not in ('delete', 'purge', 'trim', 'reset'))`;
|
|
571
|
+
const hasSrc = (v, src) => (cur ? `${v}.ends @> array[${src}]` : `${S}.link_ids(${v}.links) @> array[${src}]`);
|
|
572
|
+
const cls = (v) => (cur ? ` and ${v}.class = any (${clsPh})` : '');
|
|
573
|
+
deepSrc = `(with recursive d(src, id, rev, depth, seen) as (`
|
|
574
|
+
+ `select s.src, v.id, v.rev, 1, array[s.src, v.id] from ${srcs} join ${node} v on ${hasSrc('v', 's.src')} where true${cls('v')}${rolesCond('v', 's.src')} `
|
|
575
|
+
+ `union all select d.src, v.id, v.rev, d.depth + 1, d.seen || v.id from d join ${node} v on ${hasSrc('v', 'd.id')} `
|
|
576
|
+
+ `where d.depth < ${maxPh} and not (v.id = any (d.seen))${cls('v')}${rolesCond('v', 'd.id')}) `
|
|
577
|
+
+ `select distinct on (d.src, d.id) d.src, d.id, d.rev, d.depth from d order by d.src, d.id, d.depth)`;
|
|
600
578
|
}
|
|
579
|
+
ctes.push(`p${i} as (select q.src, q.id, q.rev, q.depth from ${deepSrc} q)`);
|
|
580
|
+
parts.push(cur
|
|
581
|
+
? entityPart(undefined, 'd.depth', `${S}.entity x join (select q.id, min(q.depth) as depth from p${i} q group by q.id) d on d.id = x.id`)
|
|
582
|
+
: `select ${colsOf('x')}, d.depth from ${S}.log x join (select q.id, q.rev, min(q.depth) as depth from p${i} q group by q.id, q.rev) d `
|
|
583
|
+
+ `on d.id = x.id and d.rev = x.rev where ${where('x')}`);
|
|
584
|
+
}
|
|
585
|
+
else if (hop.dir === 'forward') {
|
|
586
|
+
// цели лежат в концах строк предыдущего узла
|
|
587
|
+
ctes.push(`p${i} as (select s.id as src, d.id from n${a} s cross join lateral unnest(${S}.role_ids(s.links, ${rolesPh})) d(id))`);
|
|
588
|
+
const ids = `array(select distinct q.id from p${i} q)`;
|
|
589
|
+
if (cur)
|
|
590
|
+
parts.push(entityByIds(`select q.id from p${i} q`));
|
|
591
|
+
else
|
|
592
|
+
parts.push(logPart(`select f.id, f.rev from ${findIds(ids, atPh, false)} f`));
|
|
593
|
+
if (dead)
|
|
594
|
+
parts.push(logPart(`select f.id, f.rev from ${findIds(ids, atPh, true)} f`));
|
|
601
595
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
`SELECT DISTINCT ON (${dedupe}) e.* FROM ${ent} e WHERE ${innerWhere.join(' AND ')} ` +
|
|
614
|
-
`ORDER BY ${dedupe}, e.updated DESC` +
|
|
615
|
-
`) t WHERE ${outerWhere.join(' AND ')}) h${i}`;
|
|
616
|
-
// .deep(): рекурсивный обход детей того же класса (recursive CTE поверх latest-паттерна)
|
|
617
|
-
if (isDeep) {
|
|
618
|
-
const prev = steps[i - 1];
|
|
619
|
-
if (i === 0 || prev.cls.id !== step.cls.id) {
|
|
620
|
-
throw new Error('letopis: .deep() works on a self hop (same class as previous step)');
|
|
621
|
-
}
|
|
622
|
-
const pMax = p.push(step.deepMax);
|
|
623
|
-
// latest живые дети узла ref (id-выражение родителя); refCls — класс родителя:
|
|
624
|
-
// ключ links конкретен, поэтому при семействе берём его из колонки, а не из литерала
|
|
625
|
-
const kids = (ref, refCls) => {
|
|
626
|
-
const key = poly ? `${refCls}::text` : `${p.push(step.cls.id)}::text`;
|
|
627
|
-
return (`(SELECT * FROM (` +
|
|
628
|
-
`SELECT DISTINCT ON (${dedupe}) e.* FROM ${ent} e WHERE e.partition = ${pPart} AND ${classCond('e')}` +
|
|
629
|
-
(mods.asOf !== undefined ? ` AND e.updated <= ${p.push(mods.asOf)}::timestamptz` : '') +
|
|
630
|
-
` AND e.id IN (SELECT c.id FROM ${ent} c WHERE c.partition = ${pPart} AND ${classCond('c')}` +
|
|
631
|
-
` AND c.links @> jsonb_build_object(${key}, ${ref}))` +
|
|
632
|
-
` ORDER BY ${dedupe}, e.updated DESC) t` +
|
|
633
|
-
` WHERE t.deleted IS NULL AND t.links @> jsonb_build_object(${key}, ${ref}))`);
|
|
596
|
+
else {
|
|
597
|
+
const hopCall = (at, isDead) => {
|
|
598
|
+
if (!opts.direct)
|
|
599
|
+
return `${S}.find_hop(${clsPh}, ${dataP()}, ${endsP()}, ${rolesPh}, ${srcArr}, ${at}, ${isDead})`;
|
|
600
|
+
if (at === 'null' && !isDead) {
|
|
601
|
+
return `(select s.src, v.id, v.rev from ${srcs} join ${S}.entity v on v.ends @> array[s.src] `
|
|
602
|
+
+ `where v.class = any (${clsPh})${dataCond('v')}${endsCond('v.ends')}${rolesCond('v', 's.src')})`;
|
|
603
|
+
}
|
|
604
|
+
return `(select s.src, v.id, v.rev from ${versionsAt(at, `l.class = any (${clsPh})`)} v `
|
|
605
|
+
+ `cross join lateral unnest(${S}.link_ids(v.links)) t(x) join ${srcs} on s.src = t.x `
|
|
606
|
+
+ `where (v.op = 'delete') = ${isDead} and v.op not in ('purge', 'trim', 'reset')${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)}${rolesCond('v', 's.src')})`;
|
|
634
607
|
};
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
608
|
+
ctes.push(`p${i}a as (select q.src, q.id, q.rev from ${hopCall(atPh, false)} q)`);
|
|
609
|
+
parts.push(cur ? entityByIds(`select q.id from p${i}a q`) : logPart(`select q.id, q.rev from p${i}a q`));
|
|
610
|
+
if (dead) {
|
|
611
|
+
ctes.push(`p${i}d as (select q.src, q.id, q.rev from ${hopCall(atPh, true)} q)`);
|
|
612
|
+
parts.push(logPart(`select q.id, q.rev from p${i}d q`));
|
|
613
|
+
ctes.push(`p${i} as (select src, id from p${i}a union all select src, id from p${i}d)`);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
ctes.push(`p${i} as (select src, id from p${i}a)`);
|
|
617
|
+
}
|
|
642
618
|
}
|
|
643
|
-
|
|
619
|
+
ctes.push(`n${i} as (${parts.join(' union all ')})`);
|
|
620
|
+
joins.push(`join p${i} on p${i}.src = h${a}.id join n${i} h${i} on h${i}.id = p${i}.id`);
|
|
644
621
|
}
|
|
645
|
-
//
|
|
646
|
-
const fromClause = blocks.join('\n');
|
|
647
|
-
const last = real[steps.length - 1]; // pivot в конце → терминал на его узле
|
|
648
|
-
const lastCls = steps[steps.length - 1].cls;
|
|
649
|
-
/** Последний шаг полиморфен (есть потомки и не снят .exact()) — уникальность по (class, id). */
|
|
650
|
-
const lastPoly = !steps[steps.length - 1].exactClass && lastCls.descendants.length > 0;
|
|
651
|
-
// ключи шагов в путях: alias → имя вызова; pivot узла не добавляет; дубликаты — _2, _3…
|
|
622
|
+
// ключи шагов в путях
|
|
652
623
|
const keyed = [];
|
|
653
624
|
const seen = new Map();
|
|
654
|
-
|
|
655
|
-
const s = steps[i];
|
|
625
|
+
steps.forEach((s, i) => {
|
|
656
626
|
if (s.pivotKey !== undefined)
|
|
657
|
-
|
|
627
|
+
return;
|
|
658
628
|
const base = s.aliasKey ?? s.name;
|
|
659
629
|
const n = (seen.get(base) ?? 0) + 1;
|
|
660
630
|
seen.set(base, n);
|
|
661
|
-
keyed.push({ key: n === 1 ? base : `${base}_${n}`,
|
|
631
|
+
keyed.push({ key: n === 1 ? base : `${base}_${n}`, node: i });
|
|
632
|
+
});
|
|
633
|
+
const from = `from n0 h0${joins.length ? ' ' + joins.join(' ') : ''}`;
|
|
634
|
+
const whereOf = (conds) => {
|
|
635
|
+
const list = conds.filter((c) => !!c);
|
|
636
|
+
return list.length ? ` where ${list.join(' and ')}` : '';
|
|
637
|
+
};
|
|
638
|
+
// Без возвратов к узлам каждая строка узла получена из строк предыдущего, уже отфильтрованного
|
|
639
|
+
// узла, поэтому последний узел — ровно сущности полных путей, и соединять узлы не нужно.
|
|
640
|
+
// Возврат ветвит путь от прежнего узла: тогда сущности последнего шага — через соединение путей.
|
|
641
|
+
const single = tail.length === 0 && !steps.some((st) => st.pivotKey !== undefined);
|
|
642
|
+
const ents = single ? `n${lastNode}` : `(select z0.* from n${lastNode} z0 where z0.id in (select h${lastNode}.id ${from}${whereOf(tail)}))`;
|
|
643
|
+
const leaf = (path) => leafOf(reg, rowsNode(lastNode), path);
|
|
644
|
+
const sort = mods.order ? sortSpec(mods.order, leaf) : undefined;
|
|
645
|
+
const dir = mods.desc ? ' desc' : '';
|
|
646
|
+
const orderBy = (a) => (sort ? ` order by ${sort.expr(a)}${dir}, ${a}.id${dir}` : '');
|
|
647
|
+
const after = (a) => {
|
|
648
|
+
if (!mods.after)
|
|
649
|
+
return undefined;
|
|
650
|
+
if (!sort)
|
|
651
|
+
fail('after(cursor) требует sort(поле)');
|
|
652
|
+
const c = cursorParams(mods.after, sort);
|
|
653
|
+
if (!sort.nullable)
|
|
654
|
+
return `(${sort.expr(a)}, ${a}.id) ${mods.desc ? '<' : '>'} (${p.push(c.v)}${sort.pcast}, ${p.push(c.id)}::uuid)`;
|
|
655
|
+
// Поле данных бывает null (или его нет): order by ставит такие строки в конец при возрастании и в
|
|
656
|
+
// начало при убывании, а сравнение строк с null даёт null — одно сравнение их теряло, курсор null
|
|
657
|
+
// давал пустую страницу. Здесь null — значение после всех прочих (asc) или перед ними (desc); текст
|
|
658
|
+
// один на оба вида курсора (параметр — с явным приведением: он стоит и в is null).
|
|
659
|
+
const e = `(${sort.expr(a)})`;
|
|
660
|
+
const v = `${p.push(c.v)}${sort.pcast || '::text'}`;
|
|
661
|
+
const id = `${p.push(c.id)}::uuid`;
|
|
662
|
+
return mods.desc
|
|
663
|
+
? `((${e}, ${a}.id) < (${v}, ${id}) or (${v} is null and (${e} is not null or ${a}.id < ${id})))`
|
|
664
|
+
: `((${e}, ${a}.id) > (${v}, ${id}) or (${e} is null and (${v} is not null or ${a}.id > ${id})))`;
|
|
665
|
+
};
|
|
666
|
+
const limitOffset = () => (mods.limit !== undefined ? ` limit ${p.push(mods.limit)}::bigint` : '') + (mods.offset !== undefined ? ` offset ${p.push(mods.offset)}::bigint` : '');
|
|
667
|
+
// versions({ follow: true }): id, связанные колонкой moved (reclass, rekey), в обе стороны
|
|
668
|
+
if (mode === 'versions' && mods.follow) {
|
|
669
|
+
ctes.push(`f(id) as (select z.id from ${ents} z union select x.id from f cross join lateral (`
|
|
670
|
+
+ `select l.moved as id from ${S}.log l where l.id = f.id and l.moved is not null `
|
|
671
|
+
+ `union select l.id from ${S}.log l where l.moved = f.id) x)`);
|
|
662
672
|
}
|
|
663
|
-
const
|
|
664
|
-
const limitOffset = (mods.limit !== undefined ? ` LIMIT ${p.push(mods.limit)}` : '') +
|
|
665
|
-
(mods.offset !== undefined ? ` OFFSET ${p.push(mods.offset)}` : '');
|
|
666
|
-
// START_BLOCK_BUILD_MODE_SQL
|
|
673
|
+
const withClause = `with ${mode === 'versions' && mods.follow ? 'recursive ' : ''}${ctes.join(',\n')}\n`;
|
|
667
674
|
let text;
|
|
675
|
+
let aggLeaf;
|
|
668
676
|
switch (mode) {
|
|
669
677
|
case 'paths': {
|
|
670
|
-
const obj = keyed.map(({ key,
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
whereOf([...tailConds, afterExpr(`h${last}`, lastCls, mods, p)]) +
|
|
674
|
-
orderExpr(`h${last}`, lastCls, mods) +
|
|
675
|
-
limitOffset;
|
|
678
|
+
const obj = keyed.map(({ key, node }) => `${lit(key)}::text, to_jsonb(h${node})`).join(', ');
|
|
679
|
+
const h = `h${lastNode}`;
|
|
680
|
+
text = `${withClause}select jsonb_build_object(${obj}) as path ${from}${whereOf([...tail, after(h)])}${orderBy(h)}${limitOffset()}`;
|
|
676
681
|
break;
|
|
677
682
|
}
|
|
678
683
|
case 'rows':
|
|
679
|
-
case 'ids':
|
|
680
|
-
|
|
681
|
-
const key = lastPoly ? `h${last}.class, h${last}.id` : `h${last}.id`;
|
|
682
|
-
const inner = `SELECT DISTINCT ON (${key}) h${last}.* \n${fromClause}${whereOf(tailConds)}\n` +
|
|
683
|
-
`ORDER BY ${key}, h${last}.updated DESC`;
|
|
684
|
-
const sel = mode === 'rows' ? 'to_jsonb(z) AS row' : 'z.id';
|
|
685
|
-
text =
|
|
686
|
-
`SELECT ${sel} FROM (${inner}) z` +
|
|
687
|
-
whereOf([afterExpr('z', lastCls, mods, p)]) +
|
|
688
|
-
orderExpr('z', lastCls, mods) +
|
|
689
|
-
limitOffset;
|
|
684
|
+
case 'ids':
|
|
685
|
+
text = `${withClause}select ${mode === 'rows' ? 'to_jsonb(z) as row' : 'z.id'} from ${ents} z${whereOf([after('z')])}${orderBy('z')}${limitOffset()}`;
|
|
690
686
|
break;
|
|
691
|
-
}
|
|
692
|
-
case 'versions': {
|
|
693
|
-
// ВСЕ версии (включая tombstone) сущностей последнего шага, по возрастанию updated
|
|
694
|
-
// класс берём из найденной строки (а не литералом): при полиморфном шаге история
|
|
695
|
-
// собирается по КАЖДОМУ конкретному классу-потомку
|
|
696
|
-
const inner = `SELECT DISTINCT h${last}.class, h${last}.id \n${fromClause}${whereOf(tailConds)}`;
|
|
697
|
-
text =
|
|
698
|
-
`SELECT to_jsonb(v) AS row FROM (${inner}) z ` +
|
|
699
|
-
`JOIN ${ent} v ON v.partition = ${pPart} AND v.class = z.class AND v.id = z.id ` +
|
|
700
|
-
`ORDER BY v.class, v.id, v.updated` +
|
|
701
|
-
limitOffset;
|
|
702
|
-
break;
|
|
703
|
-
}
|
|
704
687
|
case 'count':
|
|
705
|
-
text =
|
|
688
|
+
text = `${withClause}select count(*)::int as n from ${ents} z`;
|
|
689
|
+
break;
|
|
690
|
+
case 'countPaths':
|
|
691
|
+
text = `${withClause}select count(*)::int as n ${from}${whereOf(tail)}`;
|
|
692
|
+
break;
|
|
693
|
+
case 'versions':
|
|
694
|
+
// с follow — по времени через все связанные id; без — по id и номеру версии
|
|
695
|
+
text = mods.follow
|
|
696
|
+
? `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
|
|
697
|
+
+ `where l.id in (select f.id from f)) v order by v.at, v.id, v.rev${limitOffset()}`
|
|
698
|
+
: `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
|
|
699
|
+
+ `where l.id in (select z.id from ${ents} z)) v order by v.id, v.rev${limitOffset()}`;
|
|
706
700
|
break;
|
|
707
701
|
case 'agg': {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
text = `SELECT ${acc} AS k, count(*)::int AS n\n${fromClause}${whereOf(tailConds)}\nGROUP BY 1 ORDER BY 2 DESC`;
|
|
702
|
+
if (!agg || !agg.field.startsWith('data.'))
|
|
703
|
+
fail("агрегат по полю 'data.<путь>'");
|
|
704
|
+
const path = agg.field.slice(5).split('.');
|
|
705
|
+
const acc = accessor(path)('z');
|
|
706
|
+
aggLeaf = leaf(path);
|
|
707
|
+
if (agg.fn === 'countBy') {
|
|
708
|
+
text = `${withClause}select ${acc} as k, count(*)::int as n from ${ents} z group by 1 order by 2 desc, 1`;
|
|
716
709
|
}
|
|
717
710
|
else {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
text = `SELECT ${aggFn}((${acc})${cast}) AS v\n${fromClause}${whereOf(tailConds)}`;
|
|
711
|
+
const cast = agg.fn === 'sum' || agg.fn === 'avg' ? '::numeric' : CAST[aggLeaf];
|
|
712
|
+
text = `${withClause}select ${agg.fn}((${acc})${cast}) as v from ${ents} z`;
|
|
721
713
|
}
|
|
722
714
|
break;
|
|
723
715
|
}
|
|
724
716
|
}
|
|
725
|
-
|
|
726
|
-
return { text, params: p.list, keys };
|
|
717
|
+
return { text: text.replaceAll('@S@', S), params: p.list, keys: keyed.map((k) => k.key), aggLeaf };
|
|
727
718
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
}
|
|
744
|
-
/** Мульти-INSERT новых строк (батч): n строк × 8 параметров, updated/deleted по умолчанию. */
|
|
745
|
-
export function multiInsertSql(pgSchema, n) {
|
|
746
|
-
const rows = [];
|
|
747
|
-
for (let i = 0; i < n; i++) {
|
|
748
|
-
const b = i * 8;
|
|
749
|
-
rows.push(`($${b + 1}, $${b + 2}, $${b + 3}, $${b + 4}, $${b + 5}, $${b + 6}, $${b + 7}, $${b + 8})`);
|
|
750
|
-
}
|
|
751
|
-
return (`INSERT INTO ${entityTable(pgSchema)} ` +
|
|
752
|
-
`(partition, id, class, data, links, tags, account, owner) ` +
|
|
753
|
-
`VALUES ${rows.join(', ')} RETURNING *`);
|
|
754
|
-
}
|
|
755
|
-
/**
|
|
756
|
-
* Серверное удаление: DELETE перехватывает триггер entity_delete —
|
|
757
|
-
* tombstone актуальной живой версии + рекурсивный каскад по links, всё в БД.
|
|
758
|
-
* $1 partition, $2 class, дальше — id-шники.
|
|
759
|
-
*/
|
|
760
|
-
// START_CONTRACT: deleteSql
|
|
761
|
-
// PURPOSE: SQL серверного удаления n сущностей — триггер entity_delete делает tombstone + рекурсивный каскад.
|
|
762
|
-
// INPUTS: { pgSchema: string; n: number }
|
|
763
|
-
// OUTPUTS: { string - DELETE ... WHERE id IN (...) }
|
|
764
|
-
// SIDE_EFFECTS: none
|
|
765
|
-
// LINKS: M-SQL, V-M-SQL, M-DDL
|
|
766
|
-
// END_CONTRACT: deleteSql
|
|
767
|
-
export function deleteSql(pgSchema, n) {
|
|
768
|
-
const ids = Array.from({ length: n }, (_, i) => `$${i + 3}`).join(', ');
|
|
769
|
-
return `DELETE FROM ${entityTable(pgSchema)} WHERE partition = $1 AND class = $2 AND id IN (${ids})`;
|
|
770
|
-
}
|
|
771
|
-
/**
|
|
772
|
-
* Замыкание удаления: цели + все живые зависимые рекурсивно (то, что каскад затомбстоунит).
|
|
773
|
-
* Рекурсивный CTE; каждый узел — актуальная живая версия. $1 partition, $2 class, $3+ — ids.
|
|
774
|
-
*/
|
|
775
|
-
// START_CONTRACT: closureSql
|
|
776
|
-
// PURPOSE: Рекурсивный CTE: цели + все живые зависимые (то, что затомбстоунит каскад entity_delete).
|
|
777
|
-
// INPUTS: { pgSchema: string; n: number }
|
|
778
|
-
// OUTPUTS: { string - WITH RECURSIVE ... }
|
|
779
|
-
// SIDE_EFFECTS: none
|
|
780
|
-
// LINKS: M-SQL, V-M-SQL, M-DDL
|
|
781
|
-
// END_CONTRACT: closureSql
|
|
782
|
-
export function closureSql(pgSchema, n) {
|
|
783
|
-
const ent = entityTable(pgSchema);
|
|
784
|
-
const ids = Array.from({ length: n }, (_, i) => `$${i + 3}`).join(', ');
|
|
785
|
-
return (`WITH RECURSIVE node AS (
|
|
786
|
-
SELECT * FROM (
|
|
787
|
-
SELECT DISTINCT ON (e.id) e.* FROM ${ent} e
|
|
788
|
-
WHERE e.partition = $1 AND e.class = $2 AND e.id IN (${ids})
|
|
789
|
-
ORDER BY e.id, e.updated DESC
|
|
790
|
-
) t WHERE t.deleted IS NULL
|
|
791
|
-
UNION
|
|
792
|
-
SELECT dep.* FROM node n
|
|
793
|
-
JOIN LATERAL (
|
|
794
|
-
SELECT * FROM (
|
|
795
|
-
SELECT DISTINCT ON (e2.class, e2.id) e2.* FROM ${ent} e2
|
|
796
|
-
WHERE e2.partition = $1
|
|
797
|
-
AND (e2.class, e2.id) IN (
|
|
798
|
-
SELECT c.class, c.id FROM ${ent} c
|
|
799
|
-
WHERE c.partition = $1 AND c.links @> jsonb_build_object(n.class, n.id))
|
|
800
|
-
ORDER BY e2.class, e2.id, e2.updated DESC
|
|
801
|
-
) x WHERE x.deleted IS NULL AND x.links @> jsonb_build_object(n.class, n.id)
|
|
802
|
-
) dep ON true
|
|
803
|
-
)
|
|
804
|
-
SELECT DISTINCT ON (class, id) * FROM node ORDER BY class, id, updated DESC`);
|
|
805
|
-
}
|
|
806
|
-
/**
|
|
807
|
-
* Вызов серверной purge(): $4 dry → превью (актуальные версии замыкания, БД цела), иначе двухфазный
|
|
808
|
-
* физический снос корня+поддерева. RETURNS SETOF Entity (по строке на сущность). Вся логика
|
|
809
|
-
* (замыкание, двухфазность, отключение триггера через SET LOCAL) — в БД. $1 partition, $2 class, $3 id, $4 dry.
|
|
810
|
-
*/
|
|
811
|
-
// START_CONTRACT: purgeCallSql
|
|
812
|
-
// PURPOSE: SQL-обёртка вызова серверной purge() (снос/превью) — вся логика в БД, JS лишь зовёт.
|
|
813
|
-
// INPUTS: { pgSchema: string }
|
|
814
|
-
// OUTPUTS: { string - SELECT * FROM "<schema>".purge($1,$2,$3,$4) }
|
|
815
|
-
// SIDE_EFFECTS: none
|
|
816
|
-
// LINKS: M-SQL, V-M-SQL, M-DDL, M-WRITE
|
|
817
|
-
// END_CONTRACT: purgeCallSql
|
|
818
|
-
export function purgeCallSql(pgSchema) {
|
|
819
|
-
return `SELECT * FROM ${escId(pgSchema)}.purge($1, $2, $3, $4)`;
|
|
719
|
+
/** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
|
|
720
|
+
export function toRow(raw) {
|
|
721
|
+
const { depth, agent, reason, moved, ...rest } = raw;
|
|
722
|
+
const row = rest;
|
|
723
|
+
if (agent != null)
|
|
724
|
+
row.agent = agent;
|
|
725
|
+
if (reason != null)
|
|
726
|
+
row.reason = reason;
|
|
727
|
+
if (moved != null)
|
|
728
|
+
row.moved = moved;
|
|
729
|
+
if (row.op === 'delete')
|
|
730
|
+
row.$deleted = true;
|
|
731
|
+
if (depth != null)
|
|
732
|
+
row.$depth = depth;
|
|
733
|
+
return row;
|
|
820
734
|
}
|