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/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.links.includes(next.id))
376
+ if (linksTo(prev, next.id))
319
377
  return 'forward';
320
- if (next.links.includes(prev.id))
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
- return ` ORDER BY ${sortField(alias, cls, mods.order)}${mods.desc ? ' DESC' : ''}`;
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 afterCond(alias, cls, mods, p) {
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 ` WHERE (${expr}, ${alias}.id) ${cmp} (${p.push(mods.after.v)}${cast}, ${p.push(mods.after.id)})`;
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 = h${i - 1}.links->>${p.push(step.cls.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, h${i - 1}.id)`);
432
- outerWhere.push(`t.links @> jsonb_build_object(${pKey}::text, h${i - 1}.id)`);
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[last].cls;
469
- // ключи шагов в путях: alias → имя вызова; дубликаты получают _2, _3…
470
- const keys = [];
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 (const s of steps) {
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
- keys.push(n === 1 ? base : `${base}_${n}`);
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 = keys.map((k, i) => `${p.push(k)}::text, to_jsonb(h${i})`).join(', ');
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
- afterCond(`h${last}`, lastCls, mods, p) +
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
- afterCond('z', lastCls, mods, p) +
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: string[];
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
- * Операция записи, привязанная к шагу цепочки: .set() / .delete() / .anonymize().
123
+ * Операция записи, привязанная к шагу цепочки:
124
+ * .create() / .update() / .delete() / .anonymize().
89
125
  * Терминал исполняет план (все операции + финальное чтение) одной транзакцией.
90
126
  */
91
127
  export interface PlanOp {
92
- kind: 'set' | 'delete' | 'anonymize';
93
- /** set: данные новой версии (deep-merge листьев). */
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