letopis 0.13.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +203 -3
- package/README.md +825 -602
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.d.ts +44 -16
- package/dist/chain.js +309 -26
- package/dist/index.d.ts +7 -0
- package/dist/index.js +38 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +169 -4
- package/dist/sessions.js +66 -0
- package/dist/sql.d.ts +23 -3
- package/dist/sql.js +161 -25
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.d.ts +40 -4
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +68 -0
- package/dist/write.d.ts +12 -5
- package/dist/write.js +359 -95
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +1 -1
- package/sql/ddl.sql +124 -12
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +64 -17
package/dist/sql.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { OP } from './types.js';
|
|
2
2
|
import { isOp } from './ops.js';
|
|
3
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
|
|
4
11
|
/** Транзиентные ошибки PG — гонка, снимаемая повтором: deadlock / serialization failure. */
|
|
5
12
|
export function isTransient(e) {
|
|
6
13
|
const code = e.code;
|
|
@@ -25,7 +32,15 @@ const READ_MODES = new Set([
|
|
|
25
32
|
* повтор запрещён семантикой PG — ошибка уходит наружу с подсказкой.
|
|
26
33
|
* Служебные запросы (loadRegistry, tables, listen) через неё не идут.
|
|
27
34
|
*/
|
|
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
|
|
28
42
|
export async function runQuery(ctx, text, params, mode, classes) {
|
|
43
|
+
// START_BLOCK_RUN_QUERY_RETRY
|
|
29
44
|
for (let attempt = 1;; attempt++) {
|
|
30
45
|
const t0 = performance.now();
|
|
31
46
|
try {
|
|
@@ -54,7 +69,11 @@ export async function runQuery(ctx, text, params, mode, classes) {
|
|
|
54
69
|
throw e;
|
|
55
70
|
}
|
|
56
71
|
}
|
|
72
|
+
// END_BLOCK_RUN_QUERY_RETRY
|
|
57
73
|
}
|
|
74
|
+
let nodeSeq = 0;
|
|
75
|
+
/** Уникальная метка узла пути (для pivot-возвратов; переживает нарезку плана). */
|
|
76
|
+
export const nextNodeKey = () => ++nodeSeq;
|
|
58
77
|
/** Накопитель позиционных параметров. */
|
|
59
78
|
class Params {
|
|
60
79
|
list = [];
|
|
@@ -120,6 +139,13 @@ function accessor(path) {
|
|
|
120
139
|
return (a) => `${jsonbAt(head)(a)}->>'${escLit(last)}'`;
|
|
121
140
|
}
|
|
122
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
|
|
123
149
|
export function leafType(cls, path) {
|
|
124
150
|
let ft = cls.fieldTypes.get(path[0]);
|
|
125
151
|
for (let i = 1; i < path.length && ft; i++) {
|
|
@@ -127,7 +153,16 @@ export function leafType(cls, path) {
|
|
|
127
153
|
}
|
|
128
154
|
return ft;
|
|
129
155
|
}
|
|
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
|
|
130
164
|
function compileOp(path, ft, o, p) {
|
|
165
|
+
// START_BLOCK_COMPILE_OP
|
|
131
166
|
const lhs = accessor(path);
|
|
132
167
|
const cast = castOf(ft);
|
|
133
168
|
// скобки обязательны: '::' сильнее '->>' (data->>'f'::ts кастил бы литерал 'f')
|
|
@@ -189,6 +224,7 @@ function compileOp(path, ft, o, p) {
|
|
|
189
224
|
default:
|
|
190
225
|
throw new Error(`letopis: unknown operator "${name}"`);
|
|
191
226
|
}
|
|
227
|
+
// END_BLOCK_COMPILE_OP
|
|
192
228
|
}
|
|
193
229
|
/** Операторы на колонку tags (text[]): строка | string[] (все) | has/hasAny/hasAll. */
|
|
194
230
|
function compileTags(v, p) {
|
|
@@ -212,6 +248,14 @@ function compileTags(v, p) {
|
|
|
212
248
|
}
|
|
213
249
|
throw new Error('letopis: $tags accepts a string or has/hasAny/hasAll');
|
|
214
250
|
}
|
|
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
|
|
215
259
|
function compileFilter(cls, filter, p) {
|
|
216
260
|
const out = { idConds: [], candFrags: [], finalFrags: [] };
|
|
217
261
|
if (filter === undefined)
|
|
@@ -238,6 +282,7 @@ function compileFilter(cls, filter, p) {
|
|
|
238
282
|
out.idConds.push((a) => `${a}.id IN (${filter.map((x) => p.push(x)).join(', ')})`);
|
|
239
283
|
return out;
|
|
240
284
|
}
|
|
285
|
+
// START_BLOCK_COMPILE_FILTER_WALK
|
|
241
286
|
const eqData = {};
|
|
242
287
|
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
|
|
243
288
|
/**
|
|
@@ -303,8 +348,21 @@ function compileFilter(cls, filter, p) {
|
|
|
303
348
|
out.candFrags.push(frag);
|
|
304
349
|
out.finalFrags.push(frag);
|
|
305
350
|
}
|
|
351
|
+
// END_BLOCK_COMPILE_FILTER_WALK
|
|
306
352
|
return out;
|
|
307
353
|
}
|
|
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
|
|
308
366
|
/** Правило обхода между шагами (по реестру Schema). */
|
|
309
367
|
export function resolveHop(prev, next) {
|
|
310
368
|
if (prev.category === 'HUB' && next.category === 'LINK')
|
|
@@ -315,9 +373,9 @@ export function resolveHop(prev, next) {
|
|
|
315
373
|
// self-переход (Папка→Папка) = reverse: дети; родитель и так лежит в row.links
|
|
316
374
|
if (prev.id === next.id)
|
|
317
375
|
return 'reverse';
|
|
318
|
-
if (prev
|
|
376
|
+
if (linksTo(prev, next.id))
|
|
319
377
|
return 'forward';
|
|
320
|
-
if (next
|
|
378
|
+
if (linksTo(next, prev.id))
|
|
321
379
|
return 'reverse';
|
|
322
380
|
throw new Error(`letopis: no path ${prev.id} → ${next.id}: neither embeds the other (Schema.links)`);
|
|
323
381
|
}
|
|
@@ -336,27 +394,76 @@ function sortField(alias, cls, order) {
|
|
|
336
394
|
function orderExpr(alias, cls, mods) {
|
|
337
395
|
if (!mods.order)
|
|
338
396
|
return '';
|
|
339
|
-
|
|
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}`;
|
|
340
401
|
}
|
|
341
|
-
/** Keyset-пагинация: (поле сортировки, id) строго после курсора. Требует .sort(). */
|
|
342
|
-
function
|
|
402
|
+
/** Keyset-пагинация: (поле сортировки, id) строго после курсора. Требует .sort(). Выражение без WHERE. */
|
|
403
|
+
function afterExpr(alias, cls, mods, p) {
|
|
343
404
|
if (!mods.after)
|
|
344
|
-
return
|
|
405
|
+
return undefined;
|
|
345
406
|
if (!mods.order)
|
|
346
407
|
throw new Error('letopis: .after(cursor) requires .sort(field)');
|
|
347
408
|
const expr = sortField(alias, cls, mods.order);
|
|
348
409
|
const cast = mods.order === 'updated' ? '::timestamptz' : castOf(leafType(cls, mods.order.slice(5).split('.')));
|
|
349
410
|
const cmp = mods.desc ? '<' : '>';
|
|
350
|
-
return `
|
|
411
|
+
return `(${expr}, ${alias}.id) ${cmp} (${p.push(mods.after.v)}${cast}, ${p.push(mods.after.id)})`;
|
|
351
412
|
}
|
|
413
|
+
const whereOf = (conds) => {
|
|
414
|
+
const list = conds.filter((c) => !!c);
|
|
415
|
+
return list.length ? ` WHERE ${list.join(' AND ')}` : '';
|
|
416
|
+
};
|
|
417
|
+
// START_CONTRACT: buildRead
|
|
418
|
+
// PURPOSE: Скомпилировать цепочку шагов+модов в один параметризованный read-запрос (латеральный обход путей, latest-версии, asOf/versions/agg, keyset).
|
|
419
|
+
// INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; mode: ReadMode }
|
|
420
|
+
// OUTPUTS: { BuiltQuery - { text, params, keys } }
|
|
421
|
+
// SIDE_EFFECTS: none (строит SQL; ACL-проверка READ может бросить)
|
|
422
|
+
// ERRORS: pivot node not in path; enforceAccount pin; acl denies READ; deep on non-self hop; agg needs data.<path>
|
|
423
|
+
// LINKS: M-SQL, V-M-SQL, M-ACL, M-DDL
|
|
424
|
+
// END_CONTRACT: buildRead
|
|
352
425
|
/** Построить читающий запрос по цепочке. */
|
|
353
426
|
export function buildRead(ctx, steps, mods, mode) {
|
|
354
427
|
const p = new Params();
|
|
355
428
|
const ent = entityTable(ctx.pgSchema);
|
|
356
429
|
const pPart = p.push(ctx.partition);
|
|
430
|
+
// pivot: карта эффективных блоков. real[i] — индекс блока-узла шага i
|
|
431
|
+
// (pivot-шаг блока не строит, ссылается на узел по pivotKey).
|
|
432
|
+
// START_BLOCK_BUILD_PIVOT_MAP
|
|
433
|
+
const real = [];
|
|
434
|
+
const byNodeKey = new Map();
|
|
435
|
+
const tailConds = []; // дофильтры pivot-шагов (alias подставлен) — в хвостовой WHERE
|
|
436
|
+
for (let i = 0; i < steps.length; i++) {
|
|
437
|
+
const s = steps[i];
|
|
438
|
+
if (s.pivotKey !== undefined) {
|
|
439
|
+
const at = byNodeKey.get(s.pivotKey);
|
|
440
|
+
if (at === undefined)
|
|
441
|
+
throw new Error(`letopis: pivot "${s.name}" — node is not in this path`);
|
|
442
|
+
real[i] = at;
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
real[i] = i;
|
|
446
|
+
if (s.nodeKey !== undefined)
|
|
447
|
+
byNodeKey.set(s.nodeKey, i);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// END_BLOCK_BUILD_PIVOT_MAP
|
|
451
|
+
// START_BLOCK_BUILD_STEP_LOOP
|
|
357
452
|
const blocks = [];
|
|
358
453
|
for (let i = 0; i < steps.length; i++) {
|
|
359
454
|
const step = steps[i];
|
|
455
|
+
// pivot-шаг: узел уже есть — только дофильтры (AND на алиас узла)
|
|
456
|
+
if (step.pivotKey !== undefined) {
|
|
457
|
+
const a = `h${real[i]}`;
|
|
458
|
+
const f = compileFilter(step.cls, step.filter, p);
|
|
459
|
+
for (const c of f.idConds)
|
|
460
|
+
tailConds.push(c(a));
|
|
461
|
+
for (const fr of f.finalFrags)
|
|
462
|
+
tailConds.push(fr(a));
|
|
463
|
+
if (step.tagsFilter !== undefined)
|
|
464
|
+
tailConds.push(compileTags(step.tagsFilter, p)(a));
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
360
467
|
const pCls = p.push(step.cls.id);
|
|
361
468
|
const f = compileFilter(step.cls, step.filter, p);
|
|
362
469
|
// модификаторы шага: .tags() / .account() / .owner()
|
|
@@ -420,16 +527,17 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
420
527
|
const isDeep = step.deepMax !== undefined;
|
|
421
528
|
if (i > 0 && !isDeep) { // deep строит containment сам (recursive CTE)
|
|
422
529
|
const prev = steps[i - 1];
|
|
530
|
+
const prevAlias = `h${real[i - 1]}`; // pivot: ветвление от узла-возврата
|
|
423
531
|
const hop = resolveHop(prev.cls, step.cls);
|
|
424
532
|
if (hop === 'forward') {
|
|
425
533
|
// id неизменен — условие сразу во внутренний WHERE, перепроверка не нужна
|
|
426
|
-
innerWhere.push(`e.id =
|
|
534
|
+
innerWhere.push(`e.id = ${prevAlias}.links->>${p.push(step.cls.id)}`);
|
|
427
535
|
}
|
|
428
536
|
else {
|
|
429
537
|
// reverse containment: кандидаты по GIN + перепроверка на latest
|
|
430
538
|
const pKey = p.push(prev.cls.id);
|
|
431
|
-
candWhere.push(`c.links @> jsonb_build_object(${pKey}::text,
|
|
432
|
-
outerWhere.push(`t.links @> jsonb_build_object(${pKey}::text,
|
|
539
|
+
candWhere.push(`c.links @> jsonb_build_object(${pKey}::text, ${prevAlias}.id)`);
|
|
540
|
+
outerWhere.push(`t.links @> jsonb_build_object(${pKey}::text, ${prevAlias}.id)`);
|
|
433
541
|
}
|
|
434
542
|
}
|
|
435
543
|
if (candWhere.length) {
|
|
@@ -456,53 +564,59 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
456
564
|
` WHERE t.deleted IS NULL AND t.links @> jsonb_build_object(${p.push(step.cls.id)}::text, ${ref}))`;
|
|
457
565
|
block =
|
|
458
566
|
`(WITH RECURSIVE d AS (` +
|
|
459
|
-
`SELECT k.*, 1 AS depth FROM ${kids(`h${i - 1}.id`)} k` +
|
|
567
|
+
`SELECT k.*, 1 AS depth FROM ${kids(`h${real[i - 1]}.id`)} k` +
|
|
460
568
|
` UNION ALL ` +
|
|
461
569
|
`SELECT k.*, d.depth + 1 FROM d JOIN LATERAL ${kids('d.id')} k ON true WHERE d.depth < ${pMax}::int` +
|
|
462
570
|
`) SELECT * FROM d) h${i}`;
|
|
463
571
|
}
|
|
464
572
|
blocks.push(i === 0 ? `FROM ${block}` : `JOIN LATERAL ${block} ON true`);
|
|
465
573
|
}
|
|
574
|
+
// END_BLOCK_BUILD_STEP_LOOP
|
|
466
575
|
const fromClause = blocks.join('\n');
|
|
467
|
-
const last = steps.length - 1;
|
|
468
|
-
const lastCls = steps[
|
|
469
|
-
// ключи шагов в путях: alias → имя вызова; дубликаты
|
|
470
|
-
const
|
|
576
|
+
const last = real[steps.length - 1]; // pivot в конце → терминал на его узле
|
|
577
|
+
const lastCls = steps[steps.length - 1].cls;
|
|
578
|
+
// ключи шагов в путях: alias → имя вызова; pivot узла не добавляет; дубликаты — _2, _3…
|
|
579
|
+
const keyed = [];
|
|
471
580
|
const seen = new Map();
|
|
472
|
-
for (
|
|
581
|
+
for (let i = 0; i < steps.length; i++) {
|
|
582
|
+
const s = steps[i];
|
|
583
|
+
if (s.pivotKey !== undefined)
|
|
584
|
+
continue;
|
|
473
585
|
const base = s.aliasKey ?? s.name;
|
|
474
586
|
const n = (seen.get(base) ?? 0) + 1;
|
|
475
587
|
seen.set(base, n);
|
|
476
|
-
|
|
588
|
+
keyed.push({ key: n === 1 ? base : `${base}_${n}`, block: i });
|
|
477
589
|
}
|
|
590
|
+
const keys = keyed.map((k) => k.key);
|
|
478
591
|
const limitOffset = (mods.limit !== undefined ? ` LIMIT ${p.push(mods.limit)}` : '') +
|
|
479
592
|
(mods.offset !== undefined ? ` OFFSET ${p.push(mods.offset)}` : '');
|
|
593
|
+
// START_BLOCK_BUILD_MODE_SQL
|
|
480
594
|
let text;
|
|
481
595
|
switch (mode) {
|
|
482
596
|
case 'paths': {
|
|
483
|
-
const obj =
|
|
597
|
+
const obj = keyed.map(({ key, block }) => `${p.push(key)}::text, to_jsonb(h${block})`).join(', ');
|
|
484
598
|
text =
|
|
485
599
|
`SELECT jsonb_build_object(${obj}) AS path\n${fromClause}` +
|
|
486
|
-
|
|
600
|
+
whereOf([...tailConds, afterExpr(`h${last}`, lastCls, mods, p)]) +
|
|
487
601
|
orderExpr(`h${last}`, lastCls, mods) +
|
|
488
602
|
limitOffset;
|
|
489
603
|
break;
|
|
490
604
|
}
|
|
491
605
|
case 'rows':
|
|
492
606
|
case 'ids': {
|
|
493
|
-
const inner = `SELECT DISTINCT ON (h${last}.id) h${last}.* \n${fromClause}\n` +
|
|
607
|
+
const inner = `SELECT DISTINCT ON (h${last}.id) h${last}.* \n${fromClause}${whereOf(tailConds)}\n` +
|
|
494
608
|
`ORDER BY h${last}.id, h${last}.updated DESC`;
|
|
495
609
|
const sel = mode === 'rows' ? 'to_jsonb(z) AS row' : 'z.id';
|
|
496
610
|
text =
|
|
497
611
|
`SELECT ${sel} FROM (${inner}) z` +
|
|
498
|
-
|
|
612
|
+
whereOf([afterExpr('z', lastCls, mods, p)]) +
|
|
499
613
|
orderExpr('z', lastCls, mods) +
|
|
500
614
|
limitOffset;
|
|
501
615
|
break;
|
|
502
616
|
}
|
|
503
617
|
case 'versions': {
|
|
504
618
|
// ВСЕ версии (включая tombstone) сущностей последнего шага, по возрастанию updated
|
|
505
|
-
const inner = `SELECT DISTINCT h${last}.id \n${fromClause}`;
|
|
619
|
+
const inner = `SELECT DISTINCT h${last}.id \n${fromClause}${whereOf(tailConds)}`;
|
|
506
620
|
text =
|
|
507
621
|
`SELECT to_jsonb(v) AS row FROM (${inner}) z ` +
|
|
508
622
|
`JOIN ${ent} v ON v.partition = ${pPart} AND v.class = ${p.push(lastCls.id)} AND v.id = z.id ` +
|
|
@@ -511,7 +625,7 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
511
625
|
break;
|
|
512
626
|
}
|
|
513
627
|
case 'count':
|
|
514
|
-
text = `SELECT count(*)::int AS n\n${fromClause}`;
|
|
628
|
+
text = `SELECT count(*)::int AS n\n${fromClause}${whereOf(tailConds)}`;
|
|
515
629
|
break;
|
|
516
630
|
case 'agg': {
|
|
517
631
|
const { aggFn, aggField } = mods;
|
|
@@ -521,18 +635,26 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
521
635
|
const path = aggField.slice(5).split('.');
|
|
522
636
|
const acc = accessor(path)(`h${last}`); // data-путь любой глубины
|
|
523
637
|
if (aggFn === 'countBy') {
|
|
524
|
-
text = `SELECT ${acc} AS k, count(*)::int AS n\n${fromClause}\nGROUP BY 1 ORDER BY 2 DESC`;
|
|
638
|
+
text = `SELECT ${acc} AS k, count(*)::int AS n\n${fromClause}${whereOf(tailConds)}\nGROUP BY 1 ORDER BY 2 DESC`;
|
|
525
639
|
}
|
|
526
640
|
else {
|
|
527
641
|
// sum/avg — всегда numeric; min/max — по типу листа
|
|
528
642
|
const cast = aggFn === 'sum' || aggFn === 'avg' ? '::numeric' : castOf(leafType(lastCls, path));
|
|
529
|
-
text = `SELECT ${aggFn}((${acc})${cast}) AS v\n${fromClause}`;
|
|
643
|
+
text = `SELECT ${aggFn}((${acc})${cast}) AS v\n${fromClause}${whereOf(tailConds)}`;
|
|
530
644
|
}
|
|
531
645
|
break;
|
|
532
646
|
}
|
|
533
647
|
}
|
|
648
|
+
// END_BLOCK_BUILD_MODE_SQL
|
|
534
649
|
return { text, params: p.list, keys };
|
|
535
650
|
}
|
|
651
|
+
// START_CONTRACT: insertSql
|
|
652
|
+
// PURPOSE: SQL одиночного INSERT новой версии/tombstone ($9 = updated прошлой версии, $10 = deleted).
|
|
653
|
+
// INPUTS: { pgSchema: string }
|
|
654
|
+
// OUTPUTS: { string - INSERT ... RETURNING * }
|
|
655
|
+
// SIDE_EFFECTS: none
|
|
656
|
+
// LINKS: M-SQL, V-M-SQL, M-DDL
|
|
657
|
+
// END_CONTRACT: insertSql
|
|
536
658
|
/** INSERT новой версии/tombstone. $9 = updated прошлой версии (или null), $10 = deleted. */
|
|
537
659
|
export function insertSql(pgSchema) {
|
|
538
660
|
return (`INSERT INTO ${entityTable(pgSchema)} ` +
|
|
@@ -558,6 +680,13 @@ export function multiInsertSql(pgSchema, n) {
|
|
|
558
680
|
* tombstone актуальной живой версии + рекурсивный каскад по links, всё в БД.
|
|
559
681
|
* $1 partition, $2 class, дальше — id-шники.
|
|
560
682
|
*/
|
|
683
|
+
// START_CONTRACT: deleteSql
|
|
684
|
+
// PURPOSE: SQL серверного удаления n сущностей — триггер entity_delete делает tombstone + рекурсивный каскад.
|
|
685
|
+
// INPUTS: { pgSchema: string; n: number }
|
|
686
|
+
// OUTPUTS: { string - DELETE ... WHERE id IN (...) }
|
|
687
|
+
// SIDE_EFFECTS: none
|
|
688
|
+
// LINKS: M-SQL, V-M-SQL, M-DDL
|
|
689
|
+
// END_CONTRACT: deleteSql
|
|
561
690
|
export function deleteSql(pgSchema, n) {
|
|
562
691
|
const ids = Array.from({ length: n }, (_, i) => `$${i + 3}`).join(', ');
|
|
563
692
|
return `DELETE FROM ${entityTable(pgSchema)} WHERE partition = $1 AND class = $2 AND id IN (${ids})`;
|
|
@@ -566,6 +695,13 @@ export function deleteSql(pgSchema, n) {
|
|
|
566
695
|
* Замыкание удаления: цели + все живые зависимые рекурсивно (то, что каскад затомбстоунит).
|
|
567
696
|
* Рекурсивный CTE; каждый узел — актуальная живая версия. $1 partition, $2 class, $3+ — ids.
|
|
568
697
|
*/
|
|
698
|
+
// START_CONTRACT: closureSql
|
|
699
|
+
// PURPOSE: Рекурсивный CTE: цели + все живые зависимые (то, что затомбстоунит каскад entity_delete).
|
|
700
|
+
// INPUTS: { pgSchema: string; n: number }
|
|
701
|
+
// OUTPUTS: { string - WITH RECURSIVE ... }
|
|
702
|
+
// SIDE_EFFECTS: none
|
|
703
|
+
// LINKS: M-SQL, V-M-SQL, M-DDL
|
|
704
|
+
// END_CONTRACT: closureSql
|
|
569
705
|
export function closureSql(pgSchema, n) {
|
|
570
706
|
const ent = entityTable(pgSchema);
|
|
571
707
|
const ids = Array.from({ length: n }, (_, i) => `$${i + 3}`).join(', ');
|
package/dist/tables.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
const T = (ctx, name) => `"${ctx.pgSchema.replace(/"/g, '""')}"."${name}"`;
|
|
2
|
+
// START_CONTRACT: where
|
|
3
|
+
// PURPOSE: Собрать SQL WHERE из простых equality-условий, добавляя значения в params.
|
|
4
|
+
// INPUTS: { conds: Cond[] - условия с генераторами текста; params: unknown[] - аккумулятор значений }
|
|
5
|
+
// OUTPUTS: { string - " WHERE ..." или пустая строка }
|
|
6
|
+
// SIDE_EFFECTS: пушит значения в params (мутация массива)
|
|
7
|
+
// LINKS: M-TABLES, V-M-TABLES
|
|
8
|
+
// END_CONTRACT: where
|
|
2
9
|
/** WHERE из простых equality-условий (+спец-обработчики). */
|
|
3
10
|
function where(conds, params) {
|
|
4
11
|
if (!conds.length)
|
|
@@ -11,8 +18,16 @@ function where(conds, params) {
|
|
|
11
18
|
}
|
|
12
19
|
const eq = (col) => (n) => `"${col}" = $${n}`;
|
|
13
20
|
// ---------------------------------------------------------------------------
|
|
21
|
+
// START_CONTRACT: makeTables
|
|
22
|
+
// PURPOSE: Построить фасад Tables (accounts/credentials/resources/rules) поверх Ctx.
|
|
23
|
+
// INPUTS: { ctx: Ctx - runtime-контекст с sql и pgSchema }
|
|
24
|
+
// OUTPUTS: { Tables - четыре API с find/get/set/delete }
|
|
25
|
+
// SIDE_EFFECTS: каждый метод выполняет SELECT/INSERT/UPDATE/DELETE через ctx.sql
|
|
26
|
+
// LINKS: M-TABLES, V-M-TABLES, M-DDL
|
|
27
|
+
// END_CONTRACT: makeTables
|
|
14
28
|
export function makeTables(ctx) {
|
|
15
29
|
const run = (text, params) => ctx.sql.unsafe(text, params);
|
|
30
|
+
// START_BLOCK_ACCOUNTS_API
|
|
16
31
|
const accounts = {
|
|
17
32
|
async find(f = {}) {
|
|
18
33
|
const conds = [];
|
|
@@ -28,6 +43,14 @@ export function makeTables(ctx) {
|
|
|
28
43
|
async get(id) {
|
|
29
44
|
return (await run(`SELECT * FROM ${T(ctx, 'Account')} WHERE id = $1`, [id]))[0] ?? null;
|
|
30
45
|
},
|
|
46
|
+
// START_CONTRACT: accounts.set
|
|
47
|
+
// PURPOSE: INSERT (без id) либо UPDATE по id аккаунта; вернуть строку Account.
|
|
48
|
+
// INPUTS: { a: Partial<Account> - поля categories/data/meta/avatar/enabled (+id для update) }
|
|
49
|
+
// OUTPUTS: { Promise<Account> - созданная/обновлённая строка }
|
|
50
|
+
// SIDE_EFFECTS: INSERT или UPDATE в таблице Account
|
|
51
|
+
// ERRORS: account "<id>" not found (UPDATE не нашёл строку)
|
|
52
|
+
// LINKS: M-TABLES, V-M-TABLES
|
|
53
|
+
// END_CONTRACT: accounts.set
|
|
31
54
|
async set(a) {
|
|
32
55
|
if (a.id) {
|
|
33
56
|
const sets = ['updated = now()'];
|
|
@@ -63,6 +86,8 @@ export function makeTables(ctx) {
|
|
|
63
86
|
return rows.length > 0;
|
|
64
87
|
},
|
|
65
88
|
};
|
|
89
|
+
// END_BLOCK_ACCOUNTS_API
|
|
90
|
+
// START_BLOCK_CREDENTIALS_API
|
|
66
91
|
const credentials = {
|
|
67
92
|
async find(f = {}) {
|
|
68
93
|
const conds = [];
|
|
@@ -82,6 +107,13 @@ export function makeTables(ctx) {
|
|
|
82
107
|
sql += conds.length ? ' AND deleted IS NULL' : ' WHERE deleted IS NULL';
|
|
83
108
|
return run(sql + ' ORDER BY created', params);
|
|
84
109
|
},
|
|
110
|
+
// START_CONTRACT: credentials.set
|
|
111
|
+
// PURPOSE: upsert креденшела по UNIQUE (account, category, identifier); повторный set воскрешает (deleted → NULL).
|
|
112
|
+
// INPUTS: { c: { account, category, identifier, meta?, confirmed? } }
|
|
113
|
+
// OUTPUTS: { Promise<Credential> - upsert-нутая строка }
|
|
114
|
+
// SIDE_EFFECTS: INSERT ... ON CONFLICT DO UPDATE в таблице Credential
|
|
115
|
+
// LINKS: M-TABLES, V-M-TABLES
|
|
116
|
+
// END_CONTRACT: credentials.set
|
|
85
117
|
async set(c) {
|
|
86
118
|
const rows = await run(`INSERT INTO ${T(ctx, 'Credential')} (account, category, identifier, meta, confirmed)
|
|
87
119
|
VALUES ($1, $2, $3, $4, $5)
|
|
@@ -95,6 +127,8 @@ export function makeTables(ctx) {
|
|
|
95
127
|
return rows.length > 0;
|
|
96
128
|
},
|
|
97
129
|
};
|
|
130
|
+
// END_BLOCK_CREDENTIALS_API
|
|
131
|
+
// START_BLOCK_RESOURCES_API
|
|
98
132
|
const resources = {
|
|
99
133
|
async find(f = {}) {
|
|
100
134
|
const conds = [];
|
|
@@ -117,6 +151,8 @@ export function makeTables(ctx) {
|
|
|
117
151
|
return rows.length > 0;
|
|
118
152
|
},
|
|
119
153
|
};
|
|
154
|
+
// END_BLOCK_RESOURCES_API
|
|
155
|
+
// START_BLOCK_RULES_API
|
|
120
156
|
const rules = {
|
|
121
157
|
async find(f = {}) {
|
|
122
158
|
const conds = [];
|
|
@@ -144,5 +180,6 @@ export function makeTables(ctx) {
|
|
|
144
180
|
return rows.length > 0;
|
|
145
181
|
},
|
|
146
182
|
};
|
|
183
|
+
// END_BLOCK_RULES_API
|
|
147
184
|
return { accounts, credentials, resources, rules };
|
|
148
185
|
}
|
package/dist/tx.js
CHANGED
|
@@ -3,7 +3,34 @@
|
|
|
3
3
|
* db.commit(tr) / db.rollback(tr) / tr.commit() / tr.rollback().
|
|
4
4
|
* tr.lock(...) — pg_advisory_xact_lock (сериализация гонок, напр. двойная бронь).
|
|
5
5
|
*/
|
|
6
|
+
// FILE: lib/src/tx.ts
|
|
7
|
+
// VERSION: 1.0.0
|
|
8
|
+
// START_MODULE_CONTRACT
|
|
9
|
+
// PURPOSE: Ручные транзакции и advisory-локи поверх зарезервированного pg-соединения.
|
|
10
|
+
// SCOPE: beginTx, lock
|
|
11
|
+
// DEPENDS: M-SQL
|
|
12
|
+
// LINKS: M-TX, V-M-TX
|
|
13
|
+
// ROLE: RUNTIME
|
|
14
|
+
// MAP_MODE: EXPORTS
|
|
15
|
+
// END_MODULE_CONTRACT
|
|
16
|
+
//
|
|
17
|
+
// START_MODULE_MAP
|
|
18
|
+
// TxCtx - Ctx транзакции с методами commit()/rollback()
|
|
19
|
+
// beginTx - резервирует соединение, шлёт BEGIN, возвращает TxCtx
|
|
20
|
+
// lock - pg_advisory_xact_lock на составной ключ (только внутри транзакции)
|
|
21
|
+
// END_MODULE_MAP
|
|
22
|
+
//
|
|
23
|
+
// START_CHANGE_SUMMARY
|
|
24
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
25
|
+
// END_CHANGE_SUMMARY
|
|
6
26
|
import { hintTxRetry, isTransient } from './sql.js';
|
|
27
|
+
// START_CONTRACT: beginTx
|
|
28
|
+
// PURPOSE: Начать ручную транзакцию на зарезервированном соединении и вернуть TxCtx.
|
|
29
|
+
// INPUTS: { ctx: Ctx - базовый контекст с пулом sql }
|
|
30
|
+
// OUTPUTS: { Promise<TxCtx> - контекст с sql на резерве + commit()/rollback() }
|
|
31
|
+
// SIDE_EFFECTS: reserve() соединения из пула и BEGIN; commit/rollback шлют COMMIT/ROLLBACK и release() (идемпотентно через флаг done)
|
|
32
|
+
// LINKS: M-TX, V-M-TX, M-SQL
|
|
33
|
+
// END_CONTRACT: beginTx
|
|
7
34
|
export async function beginTx(ctx) {
|
|
8
35
|
const reserved = await ctx.sql.reserve();
|
|
9
36
|
const r = reserved;
|
|
@@ -30,10 +57,18 @@ export async function beginTx(ctx) {
|
|
|
30
57
|
};
|
|
31
58
|
}
|
|
32
59
|
/** Advisory-lock на составной ключ; живёт до конца транзакции. */
|
|
60
|
+
// START_CONTRACT: lock
|
|
61
|
+
// PURPOSE: Взять pg_advisory_xact_lock на составной ключ (сериализация гонок; живёт до конца транзакции).
|
|
62
|
+
// INPUTS: { ctx: Ctx - контекст (должен быть inTx); keys: (string|number)[] - части составного ключа }
|
|
63
|
+
// OUTPUTS: { Promise<void> }
|
|
64
|
+
// SIDE_EFFECTS: SELECT pg_advisory_xact_lock(hashtextextended(...)); вне транзакции бросает 'letopis: lock() works only inside db.begin() transaction'; на transient-ошибке (напр. deadlock 40P01) вызывает hintTxRetry и пробрасывает
|
|
65
|
+
// LINKS: M-TX, V-M-TX, M-SQL
|
|
66
|
+
// END_CONTRACT: lock
|
|
33
67
|
export async function lock(ctx, ...keys) {
|
|
34
68
|
if (!ctx.inTx) {
|
|
35
69
|
throw new Error('letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock)');
|
|
36
70
|
}
|
|
71
|
+
// START_BLOCK_ADVISORY_LOCK
|
|
37
72
|
try {
|
|
38
73
|
await ctx.sql.unsafe('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [keys.join('|')]);
|
|
39
74
|
}
|
|
@@ -43,4 +78,5 @@ export async function lock(ctx, ...keys) {
|
|
|
43
78
|
hintTxRetry(e);
|
|
44
79
|
throw e;
|
|
45
80
|
}
|
|
81
|
+
// END_BLOCK_ADVISORY_LOCK
|
|
46
82
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -18,6 +18,32 @@ export interface Row {
|
|
|
18
18
|
}
|
|
19
19
|
/** Результат run(): вариант пути — узел на каждый шаг цепочки. */
|
|
20
20
|
export type Path = Record<string, Row>;
|
|
21
|
+
/**
|
|
22
|
+
* Конец связи класса (Schema.links v2). В БД элемент text[]: JSON-объект
|
|
23
|
+
* `{"class":"Org","cardinality":1}` / `{"classes":["Service","Complex"],"cardinality":1}`
|
|
24
|
+
* (союз ролей); legacy-строка 'Org' — сахар для {classes:['Org']} (старые схемы).
|
|
25
|
+
*/
|
|
26
|
+
export interface LinkEnd {
|
|
27
|
+
/** Допустимые классы конца; ровно один из них присутствует в Entity.links. */
|
|
28
|
+
classes: string[];
|
|
29
|
+
/** Конец может отсутствовать. Default false. */
|
|
30
|
+
optional?: boolean;
|
|
31
|
+
/** ЗАРЕЗЕРВИРОВАНО (не имплементировано): 0 — безлимит, N — точное число. Default 1. */
|
|
32
|
+
cardinality?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Генерация id класса — из attributes.id:
|
|
36
|
+
* "uuid" | {type:'uuid'} → v4 (random, дефолт);
|
|
37
|
+
* {type:'uuid', generate: 7} → v7 (время в старших битах);
|
|
38
|
+
* {type:'uuid', generate: 5, from: ['Slot','Staff']} → v5: детерминированный id из значений
|
|
39
|
+
* from — имена ОБЯЗАТЕЛЬНЫХ концов Schema.links (класс или полное имя союза
|
|
40
|
+
* 'Service|Complex') и/или скалярных полей data.
|
|
41
|
+
*/
|
|
42
|
+
export interface IdGen {
|
|
43
|
+
version: 4 | 5 | 7;
|
|
44
|
+
/** Только v5: источники имени (порядок значим). */
|
|
45
|
+
from?: string[];
|
|
46
|
+
}
|
|
21
47
|
/** Класс из таблицы Schema. */
|
|
22
48
|
export interface ClassDef {
|
|
23
49
|
id: string;
|
|
@@ -27,11 +53,20 @@ export interface ClassDef {
|
|
|
27
53
|
ancestors: string[];
|
|
28
54
|
/** Все потомки (транзитивно) — считает триггер schema_lineage. */
|
|
29
55
|
descendants: string[];
|
|
56
|
+
/** Поля data с НАСЛЕДОВАНИЕМ по ancestor-цепочке (потомок поверх предка). */
|
|
30
57
|
attributes: Record<string, unknown>;
|
|
31
|
-
links:
|
|
58
|
+
links: LinkEnd[];
|
|
59
|
+
/**
|
|
60
|
+
* true — хотя бы один конец объявлен объектом (схема v2): строгая валидация
|
|
61
|
+
* (жадный матчинг по порядку, союзы, optional, лишние связи — ошибка).
|
|
62
|
+
* false — все концы legacy-строками: старое поведение ('Entity' = полиморф, лишние молчат).
|
|
63
|
+
*/
|
|
64
|
+
strictEnds: boolean;
|
|
32
65
|
meta: Record<string, unknown>;
|
|
33
66
|
abstract: boolean;
|
|
34
67
|
order: number;
|
|
68
|
+
/** Как генерить id новой сущности (attributes.id). */
|
|
69
|
+
idGen: IdGen;
|
|
35
70
|
/** Скомпилированный fastest-validator: true | ошибки. */
|
|
36
71
|
check: (data: Record<string, unknown>) => true | {
|
|
37
72
|
field: string;
|
|
@@ -85,12 +120,13 @@ export interface Cursor {
|
|
|
85
120
|
id: string;
|
|
86
121
|
}
|
|
87
122
|
/**
|
|
88
|
-
* Операция записи, привязанная к шагу цепочки:
|
|
123
|
+
* Операция записи, привязанная к шагу цепочки:
|
|
124
|
+
* .create() / .update() / .delete() / .anonymize().
|
|
89
125
|
* Терминал исполняет план (все операции + финальное чтение) одной транзакцией.
|
|
90
126
|
*/
|
|
91
127
|
export interface PlanOp {
|
|
92
|
-
kind: '
|
|
93
|
-
/**
|
|
128
|
+
kind: 'create' | 'update' | 'delete' | 'anonymize';
|
|
129
|
+
/** create/update: данные новой версии (deep-merge листьев). */
|
|
94
130
|
data?: Record<string, unknown>;
|
|
95
131
|
/** anonymize: string-поля под '[erased]'. */
|
|
96
132
|
fields?: string[];
|
package/dist/types.js
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* letopis: типы.
|
|
3
3
|
*/
|
|
4
|
+
//
|
|
5
|
+
// FILE: lib/src/types.ts
|
|
6
|
+
// VERSION: 1.0.0
|
|
7
|
+
// START_MODULE_CONTRACT
|
|
8
|
+
// PURPOSE: Общий словарь типов всей библиотеки letopis.
|
|
9
|
+
// SCOPE: сущности, пути, определения классов, типы полей, фильтры и операторы, курсоры/план записи/модификаторы цепочки, записи auth/ACL, событие запроса и опции connect + символ OP.
|
|
10
|
+
// DEPENDS: none
|
|
11
|
+
// LINKS: M-TYPES, V-M-TYPES
|
|
12
|
+
// ROLE: TYPES
|
|
13
|
+
// MAP_MODE: EXPORTS
|
|
14
|
+
// END_MODULE_CONTRACT
|
|
15
|
+
//
|
|
16
|
+
// START_MODULE_MAP
|
|
17
|
+
// Row - строка Entity (актуальная версия): id/class/data/links/tags/account/owner/updated + служебные $deleted/$depth.
|
|
18
|
+
// Path - вариант пути run(): по одному узлу Row на каждый шаг цепочки.
|
|
19
|
+
// LinkEnd - конец связи класса (Schema.links v2): допустимые классы, optional, cardinality.
|
|
20
|
+
// IdGen - способ генерации id класса (версия 4/5/7) + источники from для v5.
|
|
21
|
+
// ClassDef - класс из таблицы Schema: категория, наследование, атрибуты, концы связей, idGen, валидатор, fieldTypes.
|
|
22
|
+
// FieldType - тип поля attributes для SQL-кастов (number/date/boolean/string/array/record/object/any).
|
|
23
|
+
// OP - символьная метка операторов фильтра (Symbol — не конфликтует с данными).
|
|
24
|
+
// Op - узел оператора фильтра: { [OP]: имя, args }.
|
|
25
|
+
// Scalar - скалярное значение фильтра: string | number | boolean | null.
|
|
26
|
+
// FieldFilter - значение фильтра по полю: скаляр (eq) / массив / оператор Op / вложенный record.
|
|
27
|
+
// Filter - фильтр шага: id-строка/массив, Op (or) или объект полей data.
|
|
28
|
+
// Cursor - курсор keyset-пагинации: значение поля сортировки + id последней строки.
|
|
29
|
+
// PlanOp - операция записи шага (create/update/delete/anonymize) + снапшот модификаторов.
|
|
30
|
+
// ChainMods - модификаторы выборки цепочки (limit/offset/order/desc/asOf/after + внутренняя агрегация).
|
|
31
|
+
// Account - запись аккаунта служебной таблицы auth.
|
|
32
|
+
// Credential - учётные данные аккаунта (identifier/category/confirmed/…).
|
|
33
|
+
// Resource - ресурс ACL: alias/category/pattern/meta.
|
|
34
|
+
// Rule - правило ACL: account/resource/permission/weight/enabled.
|
|
35
|
+
// AclOp - операция над данными = категория Resource: 'READ' | 'WRITE' | 'DELETE'.
|
|
36
|
+
// AclDecision - решение ACL: allow + победившее правило + остаточный filter + code/message.
|
|
37
|
+
// QueryEvent - событие хука onQuery: режим, классы, ms, rows, slow.
|
|
38
|
+
// ConnectOpts - опции connect(): dsn/schema/partition/account/owner/max/enforceAccount/enforceAcl/onQuery/slowMs.
|
|
39
|
+
// END_MODULE_MAP
|
|
40
|
+
//
|
|
41
|
+
// START_CHANGE_SUMMARY
|
|
42
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
43
|
+
// END_CHANGE_SUMMARY
|
|
44
|
+
//
|
|
45
|
+
// START_BLOCK_ENTITY_TYPES
|
|
46
|
+
// END_BLOCK_ENTITY_TYPES
|
|
47
|
+
//
|
|
48
|
+
// START_BLOCK_FILTER_TYPES
|
|
4
49
|
/** Метка операторов фильтра (Symbol — не конфликтует с данными). */
|
|
5
50
|
export const OP = Symbol('letopis.op');
|
|
51
|
+
// END_BLOCK_CONNECT_TYPES
|