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.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +348 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3466
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +369 -551
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -250
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -351
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -600
  89. package/sql/seed.auth.sql +0 -73
package/dist/sql.js CHANGED
@@ -1,80 +1,95 @@
1
- import { OP } from './types.js';
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 { aclDenied } from './acl.js';
4
- // START_CONTRACT: isTransient
5
- // PURPOSE: Признак транзиентной ошибки PG (deadlock 40P01 / serialization failure 40001) — снимается повтором.
6
- // INPUTS: { e: unknown - пойманная ошибка }
7
- // OUTPUTS: { boolean }
8
- // SIDE_EFFECTS: none
9
- // LINKS: M-SQL, V-M-SQL
10
- // END_CONTRACT: isTransient
11
- /** Транзиентные ошибки PG — гонка, снимаемая повтором: deadlock / serialization failure. */
12
- export function isTransient(e) {
13
- const code = e.code;
14
- return code === '40P01' || code === '40001';
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
- /** Внутри db.begin() повтор невозможен (вся транзакция aborted) — дописываем подсказку. */
17
- export function hintTxRetry(e) {
18
- const err = e;
19
- if (err.letopisHinted || typeof err.message !== 'string')
20
- return;
21
- err.letopisHinted = true;
22
- err.message += ' — letopis: transaction is aborted, retry the whole db.begin() block';
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
- * Единая точка исполнения запросов цепочек: sql.unsafe + событие onQuery/slowMs.
31
- * Чтения в автокоммите ретраят transient-ошибки (40P01/40001); внутри db.begin()
32
- * повтор запрещён семантикой PG — ошибка уходит наружу с подсказкой.
33
- * Служебные запросы (loadRegistry, tables, listen) через неё не идут.
68
+ * Направление перехода между узлами (расширение resolveHop 0.21):
69
+ * - шаг-роль после владельца роли — вперёд этим концом; шаг после роли — назад этим концом;
70
+ * - иначе по концам всех классов обоих семейств: в своём семействе (папка → папки) — назад, к детям;
71
+ * хаб → связь — назад; связь → хаб и хаб → хаб — вперёд; нет нужного — другое направление.
34
72
  */
35
- // START_CONTRACT: runQuery
36
- // PURPOSE: Единая точка исполнения запросов цепочек: sql.unsafe + событие onQuery/slow-warn + ретрай transient в автокоммите.
37
- // INPUTS: { ctx: Ctx; text: string; params: unknown[]; mode: QueryEvent['mode']; classes: string[] }
38
- // OUTPUTS: { Promise<T[]> - строки результата }
39
- // SIDE_EFFECTS: выполняет SQL; вызывает onQuery-хук; console.warn при slowMs; ретрай до RETRIES вне транзакции
40
- // LINKS: M-SQL, V-M-SQL
41
- // END_CONTRACT: runQuery
42
- export async function runQuery(ctx, text, params, mode, classes) {
43
- // START_BLOCK_RUN_QUERY_RETRY
44
- for (let attempt = 1;; attempt++) {
45
- const t0 = performance.now();
46
- try {
47
- const res = (await ctx.sql.unsafe(text, params));
48
- if (ctx.onQuery || ctx.slowMs !== undefined) {
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
- // END_BLOCK_RUN_QUERY_RETRY
88
+ throw new LetopisError('no_path', `нет перехода ${prev.name} → ${next.name}: ни один класс не ссылается на другой`);
73
89
  }
74
- let nodeSeq = 0;
75
- /** Уникальная метка узла пути (для pivot-возвратов; переживает нарезку плана). */
76
- export const nextNodeKey = () => ++nodeSeq;
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 escId = (s) => `"${s.replace(/"/g, '""')}"`;
86
- const escLit = (s) => s.replace(/'/g, "''");
87
- export const entityTable = (pgSchema) => `${escId(pgSchema)}.${escId('Entity')}`;
88
- /**
89
- * ACL-шаблон строки Entity → WHERE-фраги: колонки равенством, jsonb/tags — containment.
90
- * Вливается и в кандидаты (GIN), и в перепроверку — ДО сортировки/лимита (пагинация цела).
91
- */
92
- function rowTemplateFrags(tpl, p) {
93
- const frags = [];
94
- for (const [k, v] of Object.entries(tpl)) {
95
- if (k === 'data' || k === 'links') {
96
- const ph = p.push(v);
97
- frags.push((a) => `${a}.${k} @> ${ph}::jsonb`);
98
- }
99
- else if (k === 'tags') {
100
- const ph = p.push(Array.isArray(v) ? v : [v]);
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 ft;
117
+ return 'jsonb';
155
118
  }
156
- // START_CONTRACT: compileOp
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 = castOf(ft);
168
- // скобки обязательны: '::' сильнее '->>' (data->>'f'::ts кастил бы литерал 'f')
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 (name) {
126
+ switch (o[OP]) {
173
127
  case 'ne':
174
- return (a) => `${L(a)} IS DISTINCT FROM ${p.push(args[0])}${cast}`;
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: '<=' }[name];
180
- return (a) => `${L(a)} ${sym} ${p.push(args[0])}${cast}`;
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 () => 'FALSE';
188
- return (a) => `${L(a)} IN (${vs.map((v) => p.push(v) + cast).join(', ')})`;
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 () => 'TRUE';
201
- return (a) => `${jsonbAt(path)(a)} @> jsonb_build_array(${vs.map((x) => p.push(x) + jsCast(x)).join(', ')})`;
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 () => 'FALSE';
207
- return (a) => `${jsonbAt(path)(a)} ?| ARRAY[${vs.map((x) => p.push(String(x))).join(', ')}]::text[]`;
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 last = escLit(path[path.length - 1]);
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)} IS NULL`;
182
+ return (a) => `${lhs(a)} is null`;
218
183
  case 'not': {
219
- const inner = isOp(args[0])
220
- ? compileOp(path, ft, args[0], p)
221
- : compileOp(path, ft, { [OP]: 'ne', args: [args[0]] }, p);
222
- return isOp(args[0]) ? (a) => `NOT (${inner(a)})` : inner; // not(скаляр) = ne
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
- throw new Error(`letopis: unknown operator "${name}"`);
191
+ return fail(`неизвестный оператор ${String(o[OP])}`);
226
192
  }
227
- // END_BLOCK_COMPILE_OP
228
193
  }
229
- /** Операторы на колонку tags (text[]): строка | string[] (все) | has/hasAny/hasAll. */
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 (a) => `${a}.tags @> ARRAY[${p.push(v)}]::text[]`;
233
- if (Array.isArray(v)) {
234
- if (!v.length)
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 (a) => `${a}.tags @> ARRAY[${p.push(args[0])}]::text[]`;
209
+ return all([v.args[0]]);
243
210
  case 'hasAll':
244
- return (a) => `${a}.tags @> ARRAY[${args[0].map((x) => p.push(x)).join(', ')}]::text[]`;
245
- case 'hasAny':
246
- return (a) => `${a}.tags && ARRAY[${args[0].map((x) => p.push(x)).join(', ')}]::text[]`;
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
- throw new Error('letopis: $tags accepts a string or has/hasAny/hasAll');
218
+ return fail('tags() принимает строку, список или has/hasAny/hasAll');
250
219
  }
251
- // START_CONTRACT: compileFilter
252
- // PURPOSE: Разобрать фильтр (id | string[] | or(...) | объект любой глубины) в idConds / candFrags(GIN) / finalFrags(перепроверка на latest).
253
- // INPUTS: { cls: ClassDef; filter?: Filter; p: Params }
254
- // OUTPUTS: { CompiledFilter }
255
- // SIDE_EFFECTS: пушит значения в Params
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(...фильтры): дизъюнкция под-фильтров; только перепроверка (в GIN-кандидаты не идёт)
264
- if (isOp(filter) && filter[OP] === 'or') {
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(cls, f, p);
267
- const frags = [...sub.idConds, ...sub.finalFrags];
268
- return (a) => (frags.length ? `(${frags.map((fr) => fr(a)).join(' AND ')})` : 'TRUE');
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
- if (parts.length)
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.idConds.push((a) => `${a}.id = ${p.push(filter)}`);
240
+ out.ids = [filter];
276
241
  return out;
277
242
  }
278
243
  if (Array.isArray(filter)) {
279
- if (!filter.length)
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
- // START_BLOCK_COMPILE_FILTER_WALK
286
- const eqData = {};
287
- const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
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 eq = {};
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 sub = [...path, k];
258
+ const at = [...path, k];
301
259
  if (isOp(v)) {
302
- out.finalFrags.push(compileOp(sub, leafType(cls, sub), v, p));
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(sub, v);
264
+ const nested = walk(at, v);
307
265
  if (nested !== undefined)
308
- eq[k] = nested;
266
+ sub[k] = nested;
309
267
  else
310
268
  hadOps = true;
311
269
  }
312
- else {
313
- eq[k] = v;
314
- }
270
+ else
271
+ sub[k] = v;
315
272
  }
316
- if (Object.keys(eq).length)
317
- return eq;
318
- return hadOps ? undefined : {}; // пустой объект без операторов → containment {}
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.idConds.push((a) => `${a}.id = ${p.push(value)}`);
282
+ out.ids = [value];
326
283
  else if (Array.isArray(value))
327
- out.idConds.push((a) => `${a}.id IN (${value.map((x) => p.push(x)).join(', ')})`);
284
+ out.ids = value.map((x) => idOfValue(x) ?? fail('filter.id: ожидались строки'));
328
285
  else
329
- throw new Error('letopis: filter.id accepts string | string[]');
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.finalFrags.push(compileOp([key], cls.fieldTypes.get(key), value, p));
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
- eqData[key] = nested;
308
+ eq[key] = nested;
340
309
  continue;
341
310
  }
342
- // скаляр / массив — точное совпадение через containment (GIN)
343
- eqData[key] = value;
311
+ eq[key] = value;
344
312
  }
345
- if (Object.keys(eqData).length) {
346
- const ph = p.push(eqData); // объектом — postgres.js сериализует в jsonb сам
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
- /** Класс target входит в какой-нибудь конец def (союзы учитываются). */
355
- export function linksTo(def, target) {
356
- return def.links.some((end) => end.classes.includes(target));
357
- }
358
- // START_CONTRACT: resolveHop
359
- // PURPOSE: Определить направление обхода между классами (forward/reverse) по Schema.links.
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
- throw new Error(`letopis: LINK → LINK traversal is not supported (${prev.id} → ${next.id})`);
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 sortField(alias, cls, order) {
385
- if (order === 'updated')
386
- return `${alias}.updated`;
387
- if (order.startsWith('data.')) {
388
- // data-путь любой глубины ('data.settings.booking.deposit.amount') + каст по типу листа
389
- const path = order.slice(5).split('.');
390
- return `(${accessor(path)(alias)})${castOf(leafType(cls, path))}`;
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
- throw new Error(`letopis: order must be 'updated' or 'data.<path>'`);
344
+ return fail(`sort: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${order}'`);
393
345
  }
394
- function orderExpr(alias, cls, mods) {
395
- if (!mods.order)
396
- return '';
397
- const dir = mods.desc ? ' DESC' : '';
398
- // id — стабильный tiebreaker ТЕМ ЖЕ направлением: ORDER BY совпадает с кортежом afterExpr
399
- // (sortExpr, id), поэтому keyset не даёт пересечения страниц при неуникальном sort-поле.
400
- return ` ORDER BY ${sortField(alias, cls, mods.order)}${dir}, ${alias}.id${dir}`;
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
- /** Keyset-пагинация: (поле сортировки, id) строго после курсора. Требует .sort(). Выражение без WHERE. */
403
- function afterExpr(alias, cls, mods, p) {
404
- if (!mods.after)
405
- return undefined;
406
- if (!mods.order)
407
- throw new Error('letopis: .after(cursor) requires .sort(field)');
408
- const expr = sortField(alias, cls, mods.order);
409
- const cast = mods.order === 'updated' ? '::timestamptz' : castOf(leafType(cls, mods.order.slice(5).split('.')));
410
- const cmp = mods.desc ? '<' : '>';
411
- return `(${expr}, ${alias}.id) ${cmp} (${p.push(mods.after.v)}${cast}, ${p.push(mods.after.id)})`;
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 whereOf = (conds) => {
414
- const list = conds.filter((c) => !!c);
415
- return list.length ? ` WHERE ${list.join(' AND ')}` : '';
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
- // START_CONTRACT: guardScoped
418
- // PURPOSE: enforceAccount без идентичности вызова — фильтровать нечем, значит изоляция не гарантирована: ошибка с подсказкой на db.as().
419
- // INPUTS: { ctx: Ctx }
420
- // OUTPUTS: { void }
421
- // SIDE_EFFECTS: бросает 'letopis: enforceAccount is on — call db.as(account)…' на безличном (корневом) хендле
422
- // LINKS: M-SQL, V-M-SQL, M-CHAIN, M-WRITE
423
- // END_CONTRACT: guardScoped
424
- /** Корневой хендл при enforceAccount читать/писать не может: арендатора называет db.as(). */
425
- export function guardScoped(ctx) {
426
- if (ctx.enforceAccount && !ctx.account) {
427
- throw new Error('letopis: enforceAccount is on — call db.as(account) to name the tenant of this call, ' +
428
- 'or connect({ enforceAccount: false }) for admin access');
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
- // START_CONTRACT: buildRead
432
- // PURPOSE: Скомпилировать цепочку шагов+модов в один параметризованный read-запрос (латеральный обход путей, latest-версии, asOf/versions/agg, keyset).
433
- // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; mode: ReadMode }
434
- // OUTPUTS: { BuiltQuery - { text, params, keys } }
435
- // SIDE_EFFECTS: none (строит SQL; guardScoped и ACL-проверка READ могут бросить)
436
- // ERRORS: enforceAccount without db.as(); pivot node not in path; enforceAccount pin; acl denies READ; deep on non-self hop; agg needs data.<path>
437
- // LINKS: M-SQL, V-M-SQL, M-ACL, M-DDL
438
- // END_CONTRACT: buildRead
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 ent = entityTable(ctx.pgSchema);
444
- const pPart = p.push(ctx.partition);
445
- // pivot: карта эффективных блоков. real[i] — индекс блока-узла шага i
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
- const tailConds = []; // дофильтры pivot-шагов (alias подставлен) — в хвостовой WHERE
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
- throw new Error(`letopis: pivot "${s.name}" — node is not in this path`);
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
- // END_BLOCK_BUILD_PIVOT_MAP
466
- // START_BLOCK_BUILD_STEP_LOOP
467
- const blocks = [];
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
- // pivot-шаг: узел уже есть — только дофильтры (AND на алиас узла)
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 f = compileFilter(step.cls, step.filter, p);
474
- for (const c of f.idConds)
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
- tailConds.push(compileTags(step.tagsFilter, p)(a));
480
- // .exact() на pivot: узел уже материализован полиморфно, поэтому сужаем его
481
- // дофильтром по классу — иначе модификатор молча ничего не делал бы
482
- if (step.exactClass)
483
- tailConds.push(`${a}.class = ${p.push(step.cls.id)}`);
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
- // ПОЛИМОРФНОЕ чтение: шаг по родителю отдаёт объединение с потомками (descendants
487
- // считает триггер schema_lineage). Запись остаётся строго по точному классу — см. M-WRITE.
488
- const family = step.exactClass ? [step.cls.id] : [step.cls.id, ...step.cls.descendants];
489
- /** Классы, реально попадающие в выборку: family минус запрещённые ACL. */
490
- let classes = family;
491
- /** Классы → их row-предикаты из ACL (у каждого потомка может быть своё правило). */
492
- const aclFrags = new Map();
493
- const f = compileFilter(step.cls, step.filter, p);
494
- // модификаторы шага: .tags() / .account() / .owner()
495
- if (step.tagsFilter !== undefined) {
496
- const frag = compileTags(step.tagsFilter, p);
497
- f.candFrags.push(frag);
498
- f.finalFrags.push(frag);
499
- }
500
- // контекст-связи write-цепочек: containment по links
501
- if (step.linksFilter && Object.keys(step.linksFilter).length) {
502
- const ph = p.push(step.linksFilter);
503
- const frag = (a) => `${a}.links @> ${ph}::jsonb`;
504
- f.candFrags.push(frag);
505
- f.finalFrags.push(frag);
506
- }
507
- // enforceAccount: изоляция арендатора на КАЖДОМ шаге (явный .account(чужой) — ошибка)
508
- let accFilter = step.accountFilter;
509
- if (ctx.enforceAccount && ctx.account) {
510
- if (accFilter && accFilter !== ctx.account) {
511
- throw new Error(`letopis: enforceAccount is on — reads are pinned to account ${ctx.account}`);
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
- accFilter = ctx.account;
514
- }
515
- for (const [col, val] of [['account', accFilter], ['owner', step.ownerFilter]]) {
516
- if (val === undefined)
517
- continue;
518
- const ph = p.push(val);
519
- const frag = (a) => `${a}.${col} = ${ph}::uuid`;
520
- f.candFrags.push(frag);
521
- f.finalFrags.push(frag);
522
- }
523
- // enforceAcl: READ-право на КАЖДЫЙ шаг. Шаг полиморфен, поэтому решение берётся по
524
- // КОНКРЕТНОМУ классу каждой строки: запрещённые потомки выпадают из выборки, у каждого
525
- // разрешённого свой row-предикат. Иначе deny на потомке обходился бы шагом по родителю.
526
- if (ctx.aclDecide) {
527
- const own = ctx.aclDecide(step.cls, 'READ');
528
- const allowed = [];
529
- for (const id of family) {
530
- const cd = ctx.registry.find(id);
531
- if (!cd)
532
- continue;
533
- const d = id === step.cls.id ? own : ctx.aclDecide(cd, 'READ');
534
- if (!d.allow)
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
- if (!allowed.length)
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
- // OR-ветви, каждая со своим ACL-предикатом. Ставится и во внутренний WHERE, и в
554
- // перепроверку на актуальной версии (finalFrags) — иначе предикат обходится версиями.
555
- const poly = classes.length > 1;
556
- const clsPh = new Map(classes.map((id) => [id, p.push(id)]));
557
- const classFrag = (a) => classes
558
- .map((id) => {
559
- const preds = (aclFrags.get(id) ?? []).map((fr) => fr(a));
560
- const eq = `${a}.class = ${clsPh.get(id)}`;
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
- else {
596
- // reverse containment: кандидаты по GIN + перепроверка на latest
597
- const key = prevPoly ? `${prevAlias}.class::text` : `${p.push(prev.cls.id)}::text`;
598
- candWhere.push(`c.links @> jsonb_build_object(${key}, ${prevAlias}.id)`);
599
- outerWhere.push(`t.links @> jsonb_build_object(${key}, ${prevAlias}.id)`);
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
- if (candWhere.length) {
603
- // подзапрос кандидатов нужен только при GIN-предикатах; класс добавляем здесь, а не
604
- // через candFrags. При семействе id не уникален между классами → сопоставляем (class, id)
605
- const cand = [classCond('c'), ...candWhere].join(' AND ');
606
- innerWhere.push(poly
607
- ? `(e.class, e.id) IN (SELECT c.class, c.id FROM ${ent} c WHERE c.partition = ${pPart} AND ${cand})`
608
- : `e.id IN (SELECT c.id FROM ${ent} c WHERE c.partition = ${pPart} AND ${cand})`);
609
- }
610
- // «актуальная версия» — по (class, id) при семействе: один id может жить в разных классах
611
- const dedupe = poly ? 'e.class, e.id' : 'e.id';
612
- let block = `(SELECT * FROM (` +
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
- const prevA = `h${real[i - 1]}`;
636
- block =
637
- `(WITH RECURSIVE d AS (` +
638
- `SELECT k.*, 1 AS depth FROM ${kids(`${prevA}.id`, `${prevA}.class`)} k` +
639
- ` UNION ALL ` +
640
- `SELECT k.*, d.depth + 1 FROM d JOIN LATERAL ${kids('d.id', 'd.class')} k ON true WHERE d.depth < ${pMax}::int` +
641
- `) SELECT * FROM d) h${i}`;
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
- blocks.push(i === 0 ? `FROM ${block}` : `JOIN LATERAL ${block} ON true`);
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
- // END_BLOCK_BUILD_STEP_LOOP
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
- for (let i = 0; i < steps.length; i++) {
655
- const s = steps[i];
625
+ steps.forEach((s, i) => {
656
626
  if (s.pivotKey !== undefined)
657
- continue;
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}`, block: i });
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 keys = keyed.map((k) => k.key);
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, block }) => `${p.push(key)}::text, to_jsonb(h${block})`).join(', ');
671
- text =
672
- `SELECT jsonb_build_object(${obj}) AS path\n${fromClause}` +
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
- // при полиморфном последнем шаге уникальность сущности — пара (class, id)
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 = `SELECT count(*)::int AS n\n${fromClause}${whereOf(tailConds)}`;
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
- const { aggFn, aggField } = mods;
709
- if (!aggFn || !aggField?.startsWith('data.')) {
710
- throw new Error("letopis: aggregation needs field 'data.<путь>'");
711
- }
712
- const path = aggField.slice(5).split('.');
713
- const acc = accessor(path)(`h${last}`); // data-путь любой глубины
714
- if (aggFn === 'countBy') {
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
- // sum/avg — всегда numeric; min/max — по типу листа
719
- const cast = aggFn === 'sum' || aggFn === 'avg' ? '::numeric' : castOf(leafType(lastCls, path));
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
- // END_BLOCK_BUILD_MODE_SQL
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
- // START_CONTRACT: insertSql
729
- // PURPOSE: SQL одиночного INSERT новой версии/tombstone ($9 = updated прошлой версии, $10 = deleted).
730
- // INPUTS: { pgSchema: string }
731
- // OUTPUTS: { string - INSERT ... RETURNING * }
732
- // SIDE_EFFECTS: none
733
- // LINKS: M-SQL, V-M-SQL, M-DDL
734
- // END_CONTRACT: insertSql
735
- /** INSERT новой версии/tombstone. $9 = updated прошлой версии (или null), $10 = deleted. */
736
- export function insertSql(pgSchema) {
737
- return (`INSERT INTO ${entityTable(pgSchema)} ` +
738
- `(partition, id, class, data, links, tags, account, owner, updated, deleted) ` +
739
- `VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ` +
740
- `CASE WHEN $9::timestamptz IS NULL THEN clock_timestamp() ` +
741
- `ELSE GREATEST(clock_timestamp(), $9::timestamptz + interval '1 microsecond') END, $10) ` +
742
- `RETURNING *`);
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
  }