letopis 1.4.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.md +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +18 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/sql.js
CHANGED
|
@@ -17,15 +17,44 @@
|
|
|
17
17
|
* Значения всегда уходят параметрами, поэтому текст запроса зависит только от формы цепочки: его
|
|
18
18
|
* кэширует план (chain.ts), а postgres.js готовит оператор один раз на соединение.
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
20
|
+
import { lerr, lmsg, similar } from './errors.js';
|
|
21
21
|
import { isOp } from './ops.js';
|
|
22
22
|
import { OP } from './types.js';
|
|
23
|
+
import { PG_UUID_RE, UUID_RE } from './uuid.js';
|
|
23
24
|
let nodeSeq = 0;
|
|
24
25
|
/** Уникальная метка узла пути. */
|
|
25
26
|
export const nextNodeKey = () => ++nodeSeq;
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
/** Отказ построителя по ключу каталога — там, где нужно выражение: `x ?? refuse(ключ)`; в операторах — throw lerr(…). */
|
|
28
|
+
const refuse = (key, params) => {
|
|
29
|
+
throw lerr(key, params);
|
|
28
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Поля data строк системного класса Class — свойства метасхемы описания класса (class_metaschema() в 40-class.sql): строки
|
|
33
|
+
* Class проверяет она, а в описании самого класса Class только name. Ключи — свойства описания (ClassDescription) и
|
|
34
|
+
* служебные system и resolved: новое свойство описания без строки здесь — ошибка типов.
|
|
35
|
+
*/
|
|
36
|
+
const CLASS_ROW_FIELDS = Object.keys({
|
|
37
|
+
name: true, kind: true, parent: true, abstract: true, alias: true, system: true, fields: true, ends: true, key: true,
|
|
38
|
+
history: true, ownerDefault: true, cache: true, meta: true, resolved: true,
|
|
39
|
+
});
|
|
40
|
+
/** Поля верхнего уровня data строк класса: он закрыт (class_resolve, 40-class.sql) — в нём только поля описания. */
|
|
41
|
+
const dataFields = (c) => c.name === 'Class' ? CLASS_ROW_FIELDS : Object.keys((c.schema.properties ?? {}));
|
|
42
|
+
/**
|
|
43
|
+
* Поле фильтра, sort() или агрегата не описано ни в одном классе шага — такое условие ничего не найдёт: invalid_query с
|
|
44
|
+
* похожими именами (план 2.0, этап 3, п. 3; в 1.5 было предупреждение, 7.9 №6). Проверяется только верхний уровень:
|
|
45
|
+
* путь внутрь поля-объекта не сверяется. classes — класс шага и все классы, которые он матчит (потомки, если не exact(),
|
|
46
|
+
* у роли — её цели); ends — роли концов тоже годятся (фильтр).
|
|
47
|
+
*/
|
|
48
|
+
function checkField(classes, field, where, ends) {
|
|
49
|
+
if (!classes.length)
|
|
50
|
+
return;
|
|
51
|
+
if (classes.some((c) => dataFields(c).includes(field) || (ends && c.ends[field] !== undefined)))
|
|
52
|
+
return;
|
|
53
|
+
const names = classes.flatMap((c) => [...dataFields(c), ...(ends ? Object.keys(c.ends) : [])]);
|
|
54
|
+
throw lerr('query.unknown_field', { where, field, cls: classes[0].name, similar: similar(field, names) });
|
|
55
|
+
}
|
|
56
|
+
/** Поле sort('data.<путь>') или агрегата: первый ключ пути. */
|
|
57
|
+
const topField = (field) => field.slice(5).split('.')[0];
|
|
29
58
|
// ---------------------------------------------------------------------------
|
|
30
59
|
// Классы шага и правила переходов
|
|
31
60
|
// ---------------------------------------------------------------------------
|
|
@@ -47,6 +76,24 @@ export function stepClasses(reg, step) {
|
|
|
47
76
|
return base;
|
|
48
77
|
return uniq(base.flatMap((b) => (reg.has(b) ? reg.family(b).map((c) => c.name) : [b])));
|
|
49
78
|
}
|
|
79
|
+
/** Классы шага для типов листьев и сверки полей: класс шага первым, затем прочие классы, которые шаг матчит. */
|
|
80
|
+
function typingOf(reg, step, names = stepClasses(reg, step)) {
|
|
81
|
+
const infos = names.map((n) => reg.find(n)).filter((c) => !!c);
|
|
82
|
+
return [step.cls, ...infos.filter((c) => c !== step.cls)];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Отказы построителя, которым не нужны база и путь, — фильтр шага (в том числе неизвестное поле) и поле sort('data.…') или
|
|
86
|
+
* агрегата по классам шага: план записи проверяет их до первого оператора (write.ts:precheckPlan, §2.5), поэтому
|
|
87
|
+
* опечатка в фильтре цели не ломает db.begin(). Построитель при чтении проверяет то же самое ещё раз.
|
|
88
|
+
*/
|
|
89
|
+
export function precheckStep(reg, step, order, agg) {
|
|
90
|
+
const typing = typingOf(reg, step);
|
|
91
|
+
compileFilter(reg, typing, step.filter, new Params());
|
|
92
|
+
if (order?.startsWith('data.'))
|
|
93
|
+
checkField(typing, topField(order), `sort('${order}')`, false);
|
|
94
|
+
if (agg?.field.startsWith('data.'))
|
|
95
|
+
checkField(typing, topField(agg.field), `${agg.fn}('${agg.field}')`, false);
|
|
96
|
+
}
|
|
50
97
|
/** Принимает ли список классов конца класс cls (с учётом его предков). */
|
|
51
98
|
export const accepts = (reg, targets, cls) => (reg.find(cls)?.ancestors ?? [cls]).some((a) => targets.includes(a));
|
|
52
99
|
/** Роли классов from, цели которых принимают хотя бы один класс to. */
|
|
@@ -85,7 +132,7 @@ export function resolveHop(reg, prev, next) {
|
|
|
85
132
|
if ((dir === 'forward' ? fwd : rev).length)
|
|
86
133
|
return { dir, roles: null };
|
|
87
134
|
}
|
|
88
|
-
throw
|
|
135
|
+
throw lerr('query.no_path', { from: prev.name, to: next.name });
|
|
89
136
|
}
|
|
90
137
|
// ---------------------------------------------------------------------------
|
|
91
138
|
// Фильтры и операторы
|
|
@@ -116,12 +163,33 @@ function leafOf(reg, classes, path) {
|
|
|
116
163
|
}
|
|
117
164
|
return 'jsonb';
|
|
118
165
|
}
|
|
119
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Тип поля у классов шага не один (потомок переопределил тип: Base.price — число, Child.price — строка; у другой цели
|
|
168
|
+
* полиморфного конца поля нет или оно другого типа): приведение к типу листа должно быть безопасным — значение, которое
|
|
169
|
+
* к нему не приводится, сравнивается и сортируется как null, а не роняет весь запрос ошибкой приведения.
|
|
170
|
+
*/
|
|
171
|
+
function leafMixed(reg, classes, path, leaf) {
|
|
172
|
+
return !!CAST[leaf] && classes.some((c) => reg.leafType(c.name, path) !== leaf);
|
|
173
|
+
}
|
|
174
|
+
/** Имя типа для pg_input_is_valid по типу листа. */
|
|
175
|
+
const PG_TYPE = {
|
|
176
|
+
text: 'text', jsonb: 'jsonb', numeric: 'numeric', boolean: 'boolean', timestamptz: 'timestamptz', date: 'date', uuid: 'uuid',
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Значение поля, приведённое к типу листа (скобки обязательны: '::' сильнее '->>'). safe — тип поля в семействе
|
|
180
|
+
* разный (leafMixed): то, что не приводится, — null.
|
|
181
|
+
*/
|
|
182
|
+
function castLeaf(expr, leaf, safe) {
|
|
183
|
+
const cast = CAST[leaf];
|
|
184
|
+
if (!cast)
|
|
185
|
+
return expr;
|
|
186
|
+
return safe ? `(case when pg_input_is_valid(${expr}, ${lit(PG_TYPE[leaf])}) then (${expr})${cast} end)` : `(${expr})${cast}`;
|
|
187
|
+
}
|
|
188
|
+
function compileOp(path, leaf, o, p, safe = false) {
|
|
120
189
|
const lhs = accessor(path);
|
|
121
190
|
const cast = CAST[leaf];
|
|
122
191
|
const pc = PCAST[leaf];
|
|
123
|
-
|
|
124
|
-
const L = cast ? (a) => `(${lhs(a)})${cast}` : lhs;
|
|
192
|
+
const L = cast ? (a) => castLeaf(lhs(a), leaf, safe) : lhs;
|
|
125
193
|
const args = o.args;
|
|
126
194
|
switch (o[OP]) {
|
|
127
195
|
case 'ne':
|
|
@@ -182,13 +250,13 @@ function compileOp(path, leaf, o, p) {
|
|
|
182
250
|
return (a) => `${lhs(a)} is null`;
|
|
183
251
|
case 'not': {
|
|
184
252
|
if (isOp(args[0])) {
|
|
185
|
-
const inner = compileOp(path, leaf, args[0], p);
|
|
253
|
+
const inner = compileOp(path, leaf, args[0], p, safe);
|
|
186
254
|
return (a) => `not coalesce(${inner(a)}, false)`;
|
|
187
255
|
}
|
|
188
|
-
return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p);
|
|
256
|
+
return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p, safe);
|
|
189
257
|
}
|
|
190
258
|
default:
|
|
191
|
-
|
|
259
|
+
throw lerr('query.unknown_op', { op: String(o[OP]) });
|
|
192
260
|
}
|
|
193
261
|
}
|
|
194
262
|
/** Колонка tags: строка | список (все) | has / hasAny / hasAll. */
|
|
@@ -215,19 +283,31 @@ function compileTags(v, p) {
|
|
|
215
283
|
}
|
|
216
284
|
}
|
|
217
285
|
}
|
|
218
|
-
|
|
286
|
+
throw lerr('query.tags_arg');
|
|
219
287
|
}
|
|
220
288
|
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
|
|
221
289
|
/** Объект-строка ответа как значение: берётся id. */
|
|
222
290
|
const idOfValue = (v) => typeof v === 'string' ? v : isPlain(v) && typeof v.id === 'string' ? v.id : undefined;
|
|
291
|
+
/**
|
|
292
|
+
* id фильтра шага, `{ id }` или конца — uuid, который примет приведение базы (PG_UUID_RE): иначе invalid_query до запроса,
|
|
293
|
+
* а не сырая ошибка PostgreSQL 22P02 (у null — TypeError; план 2.1, A7).
|
|
294
|
+
*/
|
|
295
|
+
function idCheck(v, where) {
|
|
296
|
+
if (typeof v === 'string' && PG_UUID_RE.test(v))
|
|
297
|
+
return v;
|
|
298
|
+
throw lerr('query.bad_uuid', { where, value: shown(v) });
|
|
299
|
+
}
|
|
223
300
|
function compileFilter(reg, classes, filter, p) {
|
|
224
301
|
const out = { ends: [], frags: [] };
|
|
225
302
|
if (filter === undefined)
|
|
226
303
|
return out;
|
|
304
|
+
const at = `${classes[0]?.name ?? ''}(…)`;
|
|
305
|
+
if (filter === null)
|
|
306
|
+
idCheck(filter, at);
|
|
227
307
|
// or(…): дизъюнкция под-фильтров — только перепроверка
|
|
228
308
|
if (isOp(filter)) {
|
|
229
309
|
if (filter[OP] !== 'or')
|
|
230
|
-
|
|
310
|
+
throw lerr('query.step_op', { op: String(filter[OP]) });
|
|
231
311
|
const parts = filter.args[0].map((f) => {
|
|
232
312
|
const sub = compileFilter(reg, classes, f, p);
|
|
233
313
|
const frags = [...asFrags(sub, p)];
|
|
@@ -237,11 +317,11 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
237
317
|
return out;
|
|
238
318
|
}
|
|
239
319
|
if (typeof filter === 'string') {
|
|
240
|
-
out.ids = [filter];
|
|
320
|
+
out.ids = [idCheck(filter, at)];
|
|
241
321
|
return out;
|
|
242
322
|
}
|
|
243
323
|
if (Array.isArray(filter)) {
|
|
244
|
-
out.ids = filter.map((x) => idOfValue(x) ??
|
|
324
|
+
out.ids = filter.map((x) => idCheck(idOfValue(x) ?? refuse('query.id_list'), at));
|
|
245
325
|
return out;
|
|
246
326
|
}
|
|
247
327
|
const props = (classes[0]?.schema.properties ?? {});
|
|
@@ -257,7 +337,8 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
257
337
|
continue;
|
|
258
338
|
const at = [...path, k];
|
|
259
339
|
if (isOp(v)) {
|
|
260
|
-
|
|
340
|
+
const t = leafOf(reg, classes, at);
|
|
341
|
+
out.frags.push(compileOp(at, t, v, p, leafMixed(reg, classes, at, t)));
|
|
261
342
|
hadOps = true;
|
|
262
343
|
}
|
|
263
344
|
else if (isPlain(v)) {
|
|
@@ -278,12 +359,13 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
278
359
|
if (value === undefined)
|
|
279
360
|
continue;
|
|
280
361
|
if (key === 'id') {
|
|
362
|
+
const w = `${classes[0]?.name ?? ''}({ id })`;
|
|
281
363
|
if (typeof value === 'string')
|
|
282
|
-
out.ids = [value];
|
|
364
|
+
out.ids = [idCheck(value, w)];
|
|
283
365
|
else if (Array.isArray(value))
|
|
284
|
-
out.ids = value.map((x) => idOfValue(x) ??
|
|
366
|
+
out.ids = value.map((x) => idCheck(idOfValue(x) ?? refuse('query.filter_id_list'), w));
|
|
285
367
|
else
|
|
286
|
-
|
|
368
|
+
throw lerr('query.filter_id');
|
|
287
369
|
continue;
|
|
288
370
|
}
|
|
289
371
|
if (isRole(key)) {
|
|
@@ -292,14 +374,17 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
292
374
|
out.frags.push((a) => `coalesce(${a}.links->${role}, 'null'::jsonb) in ('null'::jsonb, '[]'::jsonb)`);
|
|
293
375
|
continue;
|
|
294
376
|
}
|
|
295
|
-
const id = idOfValue(value) ??
|
|
377
|
+
const id = idCheck(idOfValue(value) ?? refuse('query.end_value', { role: key }), `${classes[0]?.name ?? ''}({ ${key} })`);
|
|
296
378
|
out.ends.push(id);
|
|
297
379
|
const ph = p.push(id);
|
|
298
380
|
out.frags.push((a) => `${SCHEMA_MARK}.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
|
|
299
381
|
continue;
|
|
300
382
|
}
|
|
383
|
+
// поле данных: не описано ни в одном классе шага — invalid_query с похожими (план 2.0, этап 3, п. 3)
|
|
384
|
+
checkField(classes, key, `${classes[0]?.name ?? ''}({ ${key} })`, true);
|
|
301
385
|
if (isOp(value)) {
|
|
302
|
-
|
|
386
|
+
const t = leafOf(reg, classes, [key]);
|
|
387
|
+
out.frags.push(compileOp([key], t, value, p, leafMixed(reg, classes, [key], t)));
|
|
303
388
|
continue;
|
|
304
389
|
}
|
|
305
390
|
if (isPlain(value)) {
|
|
@@ -318,8 +403,12 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
318
403
|
function asFrags(cf, p) {
|
|
319
404
|
const frags = [...cf.frags];
|
|
320
405
|
if (cf.ids) {
|
|
321
|
-
|
|
322
|
-
|
|
406
|
+
// один id — равенство, а не = any (массив): общий план подготовленного запроса для = any PostgreSQL считает на ~10
|
|
407
|
+
// элементов, и при неудачной выборке ANALYZE чтение по ключу навсегда оставалось на планах под каждый вызов —
|
|
408
|
+
// 0,5–0,8 мс планирования на чтение (замер против 1.0.1); общий план равенства стоит как план вызова и выбирается всегда
|
|
409
|
+
const one = cf.ids.length === 1;
|
|
410
|
+
const ph = p.push(one ? cf.ids[0] : cf.ids);
|
|
411
|
+
frags.push((a) => (one ? `${a}.id = ${ph}::uuid` : `${a}.id = any (${ph}::uuid[])`));
|
|
323
412
|
}
|
|
324
413
|
if (cf.eq) {
|
|
325
414
|
const ph = p.push(cf.eq);
|
|
@@ -327,7 +416,7 @@ function asFrags(cf, p) {
|
|
|
327
416
|
}
|
|
328
417
|
return frags;
|
|
329
418
|
}
|
|
330
|
-
function sortSpec(order, leaf) {
|
|
419
|
+
function sortSpec(order, leaf, mixed) {
|
|
331
420
|
const f = order === 'updated' ? 'at' : order;
|
|
332
421
|
const col = { field: order, nullable: false };
|
|
333
422
|
if (f === 'at')
|
|
@@ -339,12 +428,16 @@ function sortSpec(order, leaf) {
|
|
|
339
428
|
if (f.startsWith('data.')) {
|
|
340
429
|
const path = f.slice(5).split('.');
|
|
341
430
|
const t = leaf(path);
|
|
342
|
-
|
|
431
|
+
const safe = mixed(path, t);
|
|
432
|
+
return { expr: (a) => castLeaf(accessor(path)(a), t, safe), pcast: PCAST[t], field: order, type: t, nullable: true, safe };
|
|
343
433
|
}
|
|
344
|
-
|
|
434
|
+
// имя поля данных без префикса (sort('code')) — подсказка «data.code?»: принять его молча нельзя — колонки at, rev, id
|
|
435
|
+
// и поле данных с тем же именем разошлись бы, а курсор cursorOf(строка, 'code') — с ними
|
|
436
|
+
if (/^[\p{L}_$][\p{L}\p{N}_$]*(\.[\p{L}\p{N}_$]+)*$/u.test(order))
|
|
437
|
+
throw lerr('query.sort_field_data', { field: order });
|
|
438
|
+
throw lerr('query.sort_field', { field: order });
|
|
345
439
|
}
|
|
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;
|
|
440
|
+
/** Формы значений курсора — как у форматов uuid (UUID_RE, uuid.ts), date и date-time валидатора (validate.ts). */
|
|
348
441
|
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
349
442
|
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
443
|
/** Дата (и время), которую примет приведение базы ::date / ::timestamptz: форма и смысл полей. */
|
|
@@ -398,9 +491,11 @@ function shown(v) {
|
|
|
398
491
|
}
|
|
399
492
|
return s.length > 60 ? `${s.slice(0, 59)}…` : s;
|
|
400
493
|
}
|
|
494
|
+
/** Какое значение курсора ждёт поле sort() по своему типу — запись каталога (параметр expect ошибки курсора). */
|
|
401
495
|
const CURSOR_EXPECT = {
|
|
402
|
-
numeric: '
|
|
403
|
-
|
|
496
|
+
numeric: 'query.cursor_expect_numeric', int: 'query.cursor_expect_int', boolean: 'query.cursor_expect_boolean',
|
|
497
|
+
text: 'query.cursor_expect_text', uuid: 'query.cursor_expect_uuid', timestamptz: 'query.cursor_expect_timestamptz',
|
|
498
|
+
date: 'query.cursor_expect_date', jsonb: 'query.cursor_expect_jsonb',
|
|
404
499
|
};
|
|
405
500
|
/**
|
|
406
501
|
* Параметры keyset из курсора: значение проверяется по типу поля сортировки до запроса. Курсор
|
|
@@ -409,16 +504,18 @@ const CURSOR_EXPECT = {
|
|
|
409
504
|
* Курсор того же типа с другого поля проверка не отличит.
|
|
410
505
|
*/
|
|
411
506
|
function cursorParams(c, sort) {
|
|
412
|
-
const how = `cursorOf(строка, '${sort.field}')`;
|
|
413
507
|
if (typeof c !== 'object' || c === null || c.v === undefined)
|
|
414
|
-
|
|
508
|
+
throw lerr('query.cursor_shape', { field: sort.field });
|
|
415
509
|
const { v, id } = c;
|
|
416
510
|
if (typeof id !== 'string' || !UUID_RE.test(id))
|
|
417
|
-
|
|
418
|
-
|
|
511
|
+
throw lerr('query.cursor_id', { id: shown(id) });
|
|
512
|
+
let value = v === null && sort.nullable ? null : cursorValue(v, sort.type);
|
|
513
|
+
// строка потомка с другим типом поля (курсор её значения) стоит среди null — как в castLeaf
|
|
514
|
+
if (value === undefined && sort.safe && v !== undefined && typeof v !== 'object')
|
|
515
|
+
value = null;
|
|
419
516
|
if (value === undefined) {
|
|
420
|
-
|
|
421
|
-
|
|
517
|
+
// у поля данных подходит и курсор null — своя запись, а не склейка « или null»
|
|
518
|
+
throw lerr(sort.nullable ? 'query.cursor_value_nullable' : 'query.cursor_value', { value: shown(v), field: sort.field, expect: lmsg(CURSOR_EXPECT[sort.type]) });
|
|
422
519
|
}
|
|
423
520
|
return { v: value, id };
|
|
424
521
|
}
|
|
@@ -428,7 +525,7 @@ function cursorRev(c) {
|
|
|
428
525
|
if (rev === undefined)
|
|
429
526
|
return undefined;
|
|
430
527
|
if (!Number.isInteger(rev) || rev < 1)
|
|
431
|
-
|
|
528
|
+
throw lerr('query.cursor_rev', { rev: shown(rev) });
|
|
432
529
|
return rev;
|
|
433
530
|
}
|
|
434
531
|
/** id узлов пути из курсора paths() по ключам узлов (план 2.0, 7.1 №3); курсор без path — undefined. */
|
|
@@ -439,7 +536,7 @@ function cursorPath(c, keys) {
|
|
|
439
536
|
return keys.map((k) => {
|
|
440
537
|
const id = typeof path === 'object' && path !== null ? path[k] : undefined;
|
|
441
538
|
if (typeof id !== 'string' || !UUID_RE.test(id))
|
|
442
|
-
|
|
539
|
+
throw lerr('query.cursor_path', { key: k });
|
|
443
540
|
return id;
|
|
444
541
|
});
|
|
445
542
|
}
|
|
@@ -466,7 +563,7 @@ export function cursorOf(row, field) {
|
|
|
466
563
|
v = v?.[k];
|
|
467
564
|
return { v: (v ?? null), id: row.id, ...rev };
|
|
468
565
|
}
|
|
469
|
-
|
|
566
|
+
throw lerr('query.cursor_of_field', { field });
|
|
470
567
|
}
|
|
471
568
|
// ---------------------------------------------------------------------------
|
|
472
569
|
// Построение запроса
|
|
@@ -476,14 +573,14 @@ export function cursorOf(row, field) {
|
|
|
476
573
|
* заденет литералы — имя поля вида a@S@b прежде портилось (план 2.0, 7.1 №5).
|
|
477
574
|
*/
|
|
478
575
|
const SCHEMA_MARK = '\u0000S\u0000';
|
|
479
|
-
const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason'
|
|
480
|
-
/** Колонки
|
|
481
|
-
const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason'
|
|
482
|
-
|
|
576
|
+
const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason'];
|
|
577
|
+
/** Колонки журнала (выданные приложению — 10-core.sql). */
|
|
578
|
+
const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason']
|
|
579
|
+
.map((c) => 'l.' + c).join(', ');
|
|
483
580
|
const colsOf = (a) => COLS.map((c) => `${a}.${c}`).join(', ');
|
|
484
581
|
export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
485
582
|
if (!steps.length)
|
|
486
|
-
|
|
583
|
+
throw lerr('query.empty_chain');
|
|
487
584
|
const S = `"${schema.replace(/"/g, '""')}"`;
|
|
488
585
|
const p = new Params();
|
|
489
586
|
const cur = mods.asOf === undefined;
|
|
@@ -495,7 +592,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
495
592
|
if (s.pivotKey !== undefined) {
|
|
496
593
|
const at = byNodeKey.get(s.pivotKey);
|
|
497
594
|
if (at === undefined)
|
|
498
|
-
|
|
595
|
+
throw lerr('query.pivot_missing', { name: s.name });
|
|
499
596
|
real[i] = at;
|
|
500
597
|
}
|
|
501
598
|
else {
|
|
@@ -513,8 +610,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
513
610
|
for (let i = 0; i < steps.length; i++) {
|
|
514
611
|
const step = steps[i];
|
|
515
612
|
const names = stepClasses(reg, step);
|
|
516
|
-
const
|
|
517
|
-
const typing = [step.cls, ...infos.filter((c) => c !== step.cls)];
|
|
613
|
+
const typing = typingOf(reg, step, names);
|
|
518
614
|
// возврат к узлу: только дофильтры
|
|
519
615
|
if (step.pivotKey !== undefined) {
|
|
520
616
|
const a = `h${real[i]}`;
|
|
@@ -547,7 +643,9 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
547
643
|
const dataP = lazy(() => (cf.eq ? `${p.push(cf.eq)}::jsonb` : 'null'));
|
|
548
644
|
const endsP = lazy(() => (cf.ends.length ? `${p.push(cf.ends)}::uuid[]` : 'null'));
|
|
549
645
|
const idsP = lazy(() => (cf.ids ? `${p.push(cf.ids)}::uuid[]` : 'null'));
|
|
550
|
-
|
|
646
|
+
// надгробия узла: withDeleted() этого шага (план 2.1, A9) или versions() по явным id последнего шага — история
|
|
647
|
+
// удалённого объекта (план 2.1, A10)
|
|
648
|
+
const dead = step.withDeleted === true || (mode === 'versions' && i === lastNode && cf.ids !== undefined && step.deepMax === undefined);
|
|
551
649
|
const dataCond = (v) => (cf.eq ? ` and ${v}.data @> ${dataP()}` : '');
|
|
552
650
|
const endsCond = (col) => (cf.ends.length ? ` and ${col} @> ${endsP()}` : '');
|
|
553
651
|
/** Версии журнала: последняя версия каждого id не позже момента. */
|
|
@@ -563,7 +661,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
563
661
|
}
|
|
564
662
|
// по классу: id-кандидаты классов шага, последняя версия — любого класса (как find_ids, план 2.0, 7.1 №1)
|
|
565
663
|
return `(select v.id, v.rev from ${versionsAt(at, ids ? `l.id = any (${ids})` : `l.id in (select c.id from ${S}.log c where c.class = any (${clsPh}))`)} v `
|
|
566
|
-
+ `where v.class = any (${clsPh}) and (v.op = 'delete') = ${isDead}
|
|
664
|
+
+ `where v.class = any (${clsPh}) and (v.op = 'delete') = ${isDead}${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)})`;
|
|
567
665
|
};
|
|
568
666
|
const entityPart = (src, depth = 'null::int', from = `${S}.entity x`) => `select ${colsOf('x')}, ${depth} as depth from ${from} where ${where('x', src)}`;
|
|
569
667
|
/**
|
|
@@ -580,7 +678,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
580
678
|
const parts = [];
|
|
581
679
|
if (i === 0) {
|
|
582
680
|
if (step.deepMax !== undefined)
|
|
583
|
-
|
|
681
|
+
throw lerr('query.deep_first_step');
|
|
584
682
|
if (cur) {
|
|
585
683
|
// условие id уже в conds — первичный ключ; вложение — поиск по индексу GIN
|
|
586
684
|
parts.push(!cf.ids && (cf.eq || cf.ends.length) ? entityByIds(`select f.id from ${findIds(null, 'null', false)} f`) : entityPart(undefined));
|
|
@@ -601,15 +699,15 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
601
699
|
const rolesCond = (v, src) => (hop.roles ? ` and ${S}.role_has(${v}.links, ${rolesPh}, ${src})` : '');
|
|
602
700
|
if (step.deepMax !== undefined) {
|
|
603
701
|
if (hop.dir !== 'reverse' || !stepClasses(reg, steps[a]).some((c) => names.includes(c))) {
|
|
604
|
-
|
|
702
|
+
throw lerr('query.deep_hop', { from: steps[a].name, to: step.name });
|
|
605
703
|
}
|
|
606
704
|
if (dead)
|
|
607
|
-
|
|
705
|
+
throw lerr('query.deep_with_deleted');
|
|
608
706
|
const maxPh = `${p.push(step.deepMax)}::int`;
|
|
609
707
|
let deepSrc = `${S}.find_deep(${clsPh}, ${rolesPh}, ${srcArr}, ${maxPh}, ${atPh})`;
|
|
610
708
|
if (opts.direct) {
|
|
611
709
|
// тот же обход под RLS: невидимый узел обрывает поддерево политикой
|
|
612
|
-
const node = cur ? `${S}.entity` : `(select w.id, w.rev, w.links from ${versionsAt(atPh, `l.class = any (${clsPh})`)} w where w.op
|
|
710
|
+
const node = cur ? `${S}.entity` : `(select w.id, w.rev, w.links from ${versionsAt(atPh, `l.class = any (${clsPh})`)} w where w.op <> 'delete')`;
|
|
613
711
|
const hasSrc = (v, src) => (cur ? `${v}.ends @> array[${src}]` : `${S}.link_ids(${v}.links) @> array[${src}]`);
|
|
614
712
|
const cls = (v) => (cur ? ` and ${v}.class = any (${clsPh})` : '');
|
|
615
713
|
deepSrc = `(with recursive d(src, id, rev, depth, seen) as (`
|
|
@@ -645,7 +743,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
645
743
|
}
|
|
646
744
|
return `(select s.src, v.id, v.rev from ${versionsAt(at, `l.class = any (${clsPh})`)} v `
|
|
647
745
|
+ `cross join lateral unnest(${S}.link_ids(v.links)) t(x) join ${srcs} on s.src = t.x `
|
|
648
|
-
+ `where (v.op = 'delete') = ${isDead}
|
|
746
|
+
+ `where (v.op = 'delete') = ${isDead}${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)}${rolesCond('v', 's.src')})`;
|
|
649
747
|
};
|
|
650
748
|
ctes.push(`p${i}a as (select q.src, q.id, q.rev from ${hopCall(atPh, false)} q)`);
|
|
651
749
|
// пары переходов — по одной на (источник, цель): объект, который ссылается на несколько объектов прошлого шага,
|
|
@@ -665,15 +763,19 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
665
763
|
joins.push(`join p${i} on p${i}.src = h${a}.id join n${i} h${i} on h${i}.id = p${i}.id`);
|
|
666
764
|
}
|
|
667
765
|
// ключи шагов в путях
|
|
766
|
+
// повтор имени — суффикс _2, _3…, свободный среди всех уже выданных ключей: алиас x_2 и второй шаг x не
|
|
767
|
+
// совпадают (x, x_2, x → x, x_2, x_3), иначе узел терялся бы в jsonb_build_object
|
|
668
768
|
const keyed = [];
|
|
669
|
-
const
|
|
769
|
+
const used = new Set();
|
|
670
770
|
steps.forEach((s, i) => {
|
|
671
771
|
if (s.pivotKey !== undefined)
|
|
672
772
|
return;
|
|
673
773
|
const base = s.aliasKey ?? s.name;
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
774
|
+
let key = base;
|
|
775
|
+
for (let n = 2; used.has(key); n++)
|
|
776
|
+
key = `${base}_${n}`;
|
|
777
|
+
used.add(key);
|
|
778
|
+
keyed.push({ key, node: i });
|
|
677
779
|
});
|
|
678
780
|
const from = `from n0 h0${joins.length ? ' ' + joins.join(' ') : ''}`;
|
|
679
781
|
const whereOf = (conds) => {
|
|
@@ -686,7 +788,10 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
686
788
|
const single = tail.length === 0 && !steps.some((st) => st.pivotKey !== undefined);
|
|
687
789
|
const ents = single ? `n${lastNode}` : `(select z0.* from n${lastNode} z0 where z0.id in (select h${lastNode}.id ${from}${whereOf(tail)}))`;
|
|
688
790
|
const leaf = (path) => leafOf(reg, rowsNode(lastNode), path);
|
|
689
|
-
const
|
|
791
|
+
const mixed = (path, t) => leafMixed(reg, rowsNode(lastNode), path, t);
|
|
792
|
+
if (mods.order?.startsWith('data.'))
|
|
793
|
+
checkField(rowsNode(lastNode), topField(mods.order), `sort('${mods.order}')`, false);
|
|
794
|
+
const sort = mods.order ? sortSpec(mods.order, leaf, mixed) : undefined;
|
|
690
795
|
const dir = mods.desc ? ' desc' : '';
|
|
691
796
|
/** Порядок: поле sort, id строки и разделители равных (id прочих узлов пути). */
|
|
692
797
|
const orderBy = (a, more = []) => (sort ? ` order by ${[sort.expr(a), `${a}.id`, ...more].map((x) => `${x}${dir}`).join(', ')}` : '');
|
|
@@ -700,7 +805,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
700
805
|
if (!mods.after)
|
|
701
806
|
return undefined;
|
|
702
807
|
if (!sort)
|
|
703
|
-
|
|
808
|
+
throw lerr('query.after_without_sort');
|
|
704
809
|
const c = cursorParams(mods.after, sort);
|
|
705
810
|
const t = ties(mods.after);
|
|
706
811
|
const lt = mods.desc ? '<' : '>';
|
|
@@ -724,28 +829,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
724
829
|
const others = keyed.filter((k) => k.node !== lastNode);
|
|
725
830
|
const pathKeys = [...others, ...keyed.filter((k) => k.node === lastNode)].map((k) => k.key);
|
|
726
831
|
const limitOffset = () => (mods.limit !== undefined ? ` limit ${p.push(mods.limit)}::bigint` : '') + (mods.offset !== undefined ? ` offset ${p.push(mods.offset)}::bigint` : '');
|
|
727
|
-
|
|
728
|
-
// id с номерами (lo, hi]. Надгробие переноса кончает отрезок, первая версия на новом id начинает: объект, заведённый
|
|
729
|
-
// на прежнем id после переноса, в историю не попадает (план 2.0, 7.1 №2). Тот же обход — history_segments в
|
|
730
|
-
// lib/sql/75-history.sql (diff и blame); здесь он в самом запросе, чтобы работал и на схеме до up() этой версии.
|
|
731
|
-
if (mode === 'versions' && mods.follow) {
|
|
732
|
-
// границы отрезка с версией r: строки переноса id — через парную строку по индексу log_moved (moved друг на друга),
|
|
733
|
-
// без прохода по версиям id
|
|
734
|
-
const bounds = (i, r) => `cross join lateral (select greatest(coalesce(max(x.rev) filter (where x.op = 'delete' and x.rev < ${r}), 0), `
|
|
735
|
-
+ `coalesce(max(x.rev - 1) filter (where x.op <> 'delete' and x.rev <= ${r}), 0)) as lo, `
|
|
736
|
-
+ `least(min(x.rev) filter (where x.op = 'delete' and x.rev >= ${r}), min(x.rev - 1) filter (where x.op <> 'delete' and x.rev > ${r})) as hi `
|
|
737
|
-
+ `from ${S}.log m join ${S}.log x on x.moved = m.id and x.id = ${i} where m.moved = ${i}) b`;
|
|
738
|
-
ctes.push(`f(id, lo, hi, n) as (select z.id, b.lo, b.hi, 0 from ${ents} z ${bounds('z.id', 'z.rev')} `
|
|
739
|
-
+ `union select g.id, b.lo, b.hi, g.n from f cross join lateral (`
|
|
740
|
-
+ `select a.moved as id, (select t.rev from ${S}.log t where t.id = a.moved and t.op = 'delete' and t.moved = f.id `
|
|
741
|
-
+ `order by abs(t.seq - a.seq) limit 1) as r, f.n - 1 as n from ${S}.log a `
|
|
742
|
-
+ `where f.n <= 0 and a.id = f.id and a.rev = f.lo + 1 and a.moved is not null and a.op <> 'delete' `
|
|
743
|
-
+ `union all select t.moved, (select a.rev from ${S}.log a where a.id = t.moved and a.op <> 'delete' and a.moved = f.id `
|
|
744
|
-
+ `order by abs(a.seq - t.seq) limit 1), f.n + 1 from ${S}.log t `
|
|
745
|
-
+ `where f.n >= 0 and t.id = f.id and t.rev = f.hi and t.op = 'delete' and t.moved is not null) g ${bounds('g.id', 'g.r')} `
|
|
746
|
-
+ `where g.r is not null and abs(g.n) <= 1000)`);
|
|
747
|
-
}
|
|
748
|
-
const withClause = `with ${mode === 'versions' && mods.follow ? 'recursive ' : ''}${ctes.join(',\n')}\n`;
|
|
832
|
+
const withClause = `with ${ctes.join(',\n')}\n`;
|
|
749
833
|
let text;
|
|
750
834
|
let aggLeaf;
|
|
751
835
|
switch (mode) {
|
|
@@ -772,37 +856,22 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
772
856
|
break;
|
|
773
857
|
case 'versions': {
|
|
774
858
|
// с sort — по полю и направлению (последние версии первыми — sort('at', 'desc'), с limit — в самом запросе);
|
|
775
|
-
// без sort
|
|
776
|
-
const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` :
|
|
859
|
+
// без sort — по id и номеру версии
|
|
860
|
+
const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` : 'v.id, v.rev';
|
|
777
861
|
// курсор — поле sort, id и номер версии: версии одного объекта с равным полем не теряются (план 2.0, 7.1 №4)
|
|
778
862
|
const cond = whereOf([after('v', (c) => {
|
|
779
863
|
const rev = cursorRev(c);
|
|
780
864
|
return rev === undefined ? [] : [{ col: 'v.rev', val: rev, cast: '::int' }];
|
|
781
865
|
})]);
|
|
782
|
-
text =
|
|
783
|
-
|
|
784
|
-
+ `cross join lateral (select ${colsOf('l')} from ${S}.log l where l.id = s.id and l.rev > s.lo and l.rev <= coalesce(s.hi, 2147483647)) l) v`
|
|
785
|
-
+ `${cond} order by ${order}${limitOffset()}`
|
|
786
|
-
: `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
|
|
787
|
-
+ `where l.id in (select z.id from ${ents} z)) v${cond} order by ${order}${limitOffset()}`;
|
|
788
|
-
break;
|
|
789
|
-
}
|
|
790
|
-
case 'diff':
|
|
791
|
-
case 'blame': {
|
|
792
|
-
// план 1.1, §2.1: функции базы считают по журналу под RLS — по одному документу на объект шага
|
|
793
|
-
const h = mods.hist ?? {};
|
|
794
|
-
const num = (v) => (typeof v === 'number' ? `${p.push(v)}::int` : 'null');
|
|
795
|
-
const ts = (v) => (typeof v === 'string' ? `${p.push(v)}::text::timestamptz` : 'null');
|
|
796
|
-
const call = mode === 'diff'
|
|
797
|
-
? `${S}.diff_versions(z.id, ${num(h.from)}, ${num(h.to)}, ${ts(h.from)}, ${ts(h.to)}, ${h.follow ? 'true' : 'false'})`
|
|
798
|
-
: `${S}.blame(z.id, ${ts(h.at)}, ${num(h.limit)})`;
|
|
799
|
-
text = `${withClause}select ${call} as row from (select distinct z.id from ${ents} z) z order by z.id`;
|
|
866
|
+
text = `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
|
|
867
|
+
+ `where l.id in (select z.id from ${ents} z)) v${cond} order by ${order}${limitOffset()}`;
|
|
800
868
|
break;
|
|
801
869
|
}
|
|
802
870
|
case 'agg': {
|
|
803
871
|
if (!agg || !agg.field.startsWith('data.'))
|
|
804
|
-
|
|
872
|
+
throw lerr('query.agg_field');
|
|
805
873
|
const path = agg.field.slice(5).split('.');
|
|
874
|
+
checkField(rowsNode(lastNode), path[0], `${agg.fn}('${agg.field}')`, false);
|
|
806
875
|
const acc = accessor(path)('z');
|
|
807
876
|
aggLeaf = leaf(path);
|
|
808
877
|
if (agg.fn === 'countBy') {
|
|
@@ -810,10 +879,11 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
810
879
|
text = `${withClause}select ${acc} as k, count(*)::int as n from ${ents} z group by 1 order by 2 desc, 1${limitOffset()}`;
|
|
811
880
|
}
|
|
812
881
|
else {
|
|
813
|
-
const
|
|
882
|
+
const t = agg.fn === 'sum' || agg.fn === 'avg' ? 'numeric' : aggLeaf;
|
|
814
883
|
// у boolean в PostgreSQL нет min и max; false меньше true: наименьшее — «все ли true», наибольшее — «есть ли true»
|
|
815
884
|
const fn = aggLeaf === 'boolean' && agg.fn === 'min' ? 'bool_and' : aggLeaf === 'boolean' && agg.fn === 'max' ? 'bool_or' : agg.fn;
|
|
816
|
-
|
|
885
|
+
// тип поля в семействе разный — значения, которые не приводятся к типу листа, агрегат пропускает (как null)
|
|
886
|
+
text = `${withClause}select ${fn}(${castLeaf(acc, t, aggLeaf === t && mixed(path, t))}) as v from ${ents} z`;
|
|
817
887
|
}
|
|
818
888
|
break;
|
|
819
889
|
}
|
|
@@ -822,14 +892,12 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
822
892
|
}
|
|
823
893
|
/** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
|
|
824
894
|
export function toRow(raw) {
|
|
825
|
-
const { depth, agent, reason,
|
|
895
|
+
const { depth, agent, reason, ...rest } = raw;
|
|
826
896
|
const row = rest;
|
|
827
897
|
if (agent != null)
|
|
828
898
|
row.agent = agent;
|
|
829
899
|
if (reason != null)
|
|
830
900
|
row.reason = reason;
|
|
831
|
-
if (moved != null)
|
|
832
|
-
row.moved = moved;
|
|
833
901
|
if (row.op === 'delete')
|
|
834
902
|
row.$deleted = true;
|
|
835
903
|
if (depth != null)
|
package/dist/sync.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* мешающими строками отклоняет сама база (`tightening_conflict` с перечнем) уже в сухом прогоне.
|
|
7
7
|
* generateTypes: объявления TypeScript для классов из реестра — в том числе созданных данными.
|
|
8
8
|
*/
|
|
9
|
-
import type {
|
|
9
|
+
import type { DbCore } from './index.js';
|
|
10
10
|
import type { AnyModel, LostRule } from './model.js';
|
|
11
11
|
import type { JsonSchema } from './jsonschema.js';
|
|
12
12
|
import type { Registry } from './registry.js';
|
|
@@ -17,15 +17,21 @@ export interface SyncResult {
|
|
|
17
17
|
unchanged: string[];
|
|
18
18
|
/** Классы системного арендатора: их меняет только установка. */
|
|
19
19
|
skipped: string[];
|
|
20
|
-
/** Правила refine, которых нет в
|
|
20
|
+
/** Правила refine, которых нет в базе (преобразования Zod syncModels отклоняет — model.transform_unsupported). */
|
|
21
21
|
lost: LostRule[];
|
|
22
22
|
}
|
|
23
|
-
export declare function syncModels(db:
|
|
23
|
+
export declare function syncModels(db: DbCore, models: readonly AnyModel[], opts?: {
|
|
24
24
|
apply?: boolean;
|
|
25
25
|
}): Promise<SyncResult>;
|
|
26
26
|
/** Тип TypeScript по JSON Schema подмножества. */
|
|
27
27
|
export declare function schemaToTs(s: JsonSchema | undefined, indent?: string): string;
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Объявления `<Класс>Data` и `<Класс>Links` для классов реестра и `<Класс>Model` — имя, алиас, концы с классами целей
|
|
30
|
+
* и предки (ModelLike, typed.ts), — а в конце список `Models`: строгие типы цепочек без моделей Zod, `connect<Models>()`
|
|
31
|
+
* (план 2.0, этап 4, п. 1). Классы арендатора System (системные и классы сидов пакета) — только с includeSystem
|
|
32
|
+
* (`letopis types --system`); без него системные классы даёт встроенный набор SystemModels. Пометка в первой строке —
|
|
33
|
+
* запись каталога model.types_header.
|
|
34
|
+
*/
|
|
29
35
|
export declare function generateTypes(registry: Registry, opts?: {
|
|
30
36
|
includeSystem?: boolean;
|
|
31
37
|
}): string;
|