letopis 0.13.0 → 0.16.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
@@ -55,6 +55,9 @@ export async function runQuery(ctx, text, params, mode, classes) {
55
55
  }
56
56
  }
57
57
  }
58
+ let nodeSeq = 0;
59
+ /** Уникальная метка узла пути (для pivot-возвратов; переживает нарезку плана). */
60
+ export const nextNodeKey = () => ++nodeSeq;
58
61
  /** Накопитель позиционных параметров. */
59
62
  class Params {
60
63
  list = [];
@@ -305,6 +308,10 @@ function compileFilter(cls, filter, p) {
305
308
  }
306
309
  return out;
307
310
  }
311
+ /** Класс target входит в какой-нибудь конец def (союзы учитываются). */
312
+ export function linksTo(def, target) {
313
+ return def.links.some((end) => end.classes.includes(target));
314
+ }
308
315
  /** Правило обхода между шагами (по реестру Schema). */
309
316
  export function resolveHop(prev, next) {
310
317
  if (prev.category === 'HUB' && next.category === 'LINK')
@@ -315,9 +322,9 @@ export function resolveHop(prev, next) {
315
322
  // self-переход (Папка→Папка) = reverse: дети; родитель и так лежит в row.links
316
323
  if (prev.id === next.id)
317
324
  return 'reverse';
318
- if (prev.links.includes(next.id))
325
+ if (linksTo(prev, next.id))
319
326
  return 'forward';
320
- if (next.links.includes(prev.id))
327
+ if (linksTo(next, prev.id))
321
328
  return 'reverse';
322
329
  throw new Error(`letopis: no path ${prev.id} → ${next.id}: neither embeds the other (Schema.links)`);
323
330
  }
@@ -338,25 +345,60 @@ function orderExpr(alias, cls, mods) {
338
345
  return '';
339
346
  return ` ORDER BY ${sortField(alias, cls, mods.order)}${mods.desc ? ' DESC' : ''}`;
340
347
  }
341
- /** Keyset-пагинация: (поле сортировки, id) строго после курсора. Требует .sort(). */
342
- function afterCond(alias, cls, mods, p) {
348
+ /** Keyset-пагинация: (поле сортировки, id) строго после курсора. Требует .sort(). Выражение без WHERE. */
349
+ function afterExpr(alias, cls, mods, p) {
343
350
  if (!mods.after)
344
- return '';
351
+ return undefined;
345
352
  if (!mods.order)
346
353
  throw new Error('letopis: .after(cursor) requires .sort(field)');
347
354
  const expr = sortField(alias, cls, mods.order);
348
355
  const cast = mods.order === 'updated' ? '::timestamptz' : castOf(leafType(cls, mods.order.slice(5).split('.')));
349
356
  const cmp = mods.desc ? '<' : '>';
350
- return ` WHERE (${expr}, ${alias}.id) ${cmp} (${p.push(mods.after.v)}${cast}, ${p.push(mods.after.id)})`;
357
+ return `(${expr}, ${alias}.id) ${cmp} (${p.push(mods.after.v)}${cast}, ${p.push(mods.after.id)})`;
351
358
  }
359
+ const whereOf = (conds) => {
360
+ const list = conds.filter((c) => !!c);
361
+ return list.length ? ` WHERE ${list.join(' AND ')}` : '';
362
+ };
352
363
  /** Построить читающий запрос по цепочке. */
353
364
  export function buildRead(ctx, steps, mods, mode) {
354
365
  const p = new Params();
355
366
  const ent = entityTable(ctx.pgSchema);
356
367
  const pPart = p.push(ctx.partition);
368
+ // pivot: карта эффективных блоков. real[i] — индекс блока-узла шага i
369
+ // (pivot-шаг блока не строит, ссылается на узел по pivotKey).
370
+ const real = [];
371
+ const byNodeKey = new Map();
372
+ const tailConds = []; // дофильтры pivot-шагов (alias подставлен) — в хвостовой WHERE
373
+ for (let i = 0; i < steps.length; i++) {
374
+ const s = steps[i];
375
+ if (s.pivotKey !== undefined) {
376
+ const at = byNodeKey.get(s.pivotKey);
377
+ if (at === undefined)
378
+ throw new Error(`letopis: pivot "${s.name}" — node is not in this path`);
379
+ real[i] = at;
380
+ }
381
+ else {
382
+ real[i] = i;
383
+ if (s.nodeKey !== undefined)
384
+ byNodeKey.set(s.nodeKey, i);
385
+ }
386
+ }
357
387
  const blocks = [];
358
388
  for (let i = 0; i < steps.length; i++) {
359
389
  const step = steps[i];
390
+ // pivot-шаг: узел уже есть — только дофильтры (AND на алиас узла)
391
+ if (step.pivotKey !== undefined) {
392
+ const a = `h${real[i]}`;
393
+ const f = compileFilter(step.cls, step.filter, p);
394
+ for (const c of f.idConds)
395
+ tailConds.push(c(a));
396
+ for (const fr of f.finalFrags)
397
+ tailConds.push(fr(a));
398
+ if (step.tagsFilter !== undefined)
399
+ tailConds.push(compileTags(step.tagsFilter, p)(a));
400
+ continue;
401
+ }
360
402
  const pCls = p.push(step.cls.id);
361
403
  const f = compileFilter(step.cls, step.filter, p);
362
404
  // модификаторы шага: .tags() / .account() / .owner()
@@ -420,16 +462,17 @@ export function buildRead(ctx, steps, mods, mode) {
420
462
  const isDeep = step.deepMax !== undefined;
421
463
  if (i > 0 && !isDeep) { // deep строит containment сам (recursive CTE)
422
464
  const prev = steps[i - 1];
465
+ const prevAlias = `h${real[i - 1]}`; // pivot: ветвление от узла-возврата
423
466
  const hop = resolveHop(prev.cls, step.cls);
424
467
  if (hop === 'forward') {
425
468
  // id неизменен — условие сразу во внутренний WHERE, перепроверка не нужна
426
- innerWhere.push(`e.id = h${i - 1}.links->>${p.push(step.cls.id)}`);
469
+ innerWhere.push(`e.id = ${prevAlias}.links->>${p.push(step.cls.id)}`);
427
470
  }
428
471
  else {
429
472
  // reverse containment: кандидаты по GIN + перепроверка на latest
430
473
  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)`);
474
+ candWhere.push(`c.links @> jsonb_build_object(${pKey}::text, ${prevAlias}.id)`);
475
+ outerWhere.push(`t.links @> jsonb_build_object(${pKey}::text, ${prevAlias}.id)`);
433
476
  }
434
477
  }
435
478
  if (candWhere.length) {
@@ -456,7 +499,7 @@ export function buildRead(ctx, steps, mods, mode) {
456
499
  ` WHERE t.deleted IS NULL AND t.links @> jsonb_build_object(${p.push(step.cls.id)}::text, ${ref}))`;
457
500
  block =
458
501
  `(WITH RECURSIVE d AS (` +
459
- `SELECT k.*, 1 AS depth FROM ${kids(`h${i - 1}.id`)} k` +
502
+ `SELECT k.*, 1 AS depth FROM ${kids(`h${real[i - 1]}.id`)} k` +
460
503
  ` UNION ALL ` +
461
504
  `SELECT k.*, d.depth + 1 FROM d JOIN LATERAL ${kids('d.id')} k ON true WHERE d.depth < ${pMax}::int` +
462
505
  `) SELECT * FROM d) h${i}`;
@@ -464,45 +507,49 @@ export function buildRead(ctx, steps, mods, mode) {
464
507
  blocks.push(i === 0 ? `FROM ${block}` : `JOIN LATERAL ${block} ON true`);
465
508
  }
466
509
  const fromClause = blocks.join('\n');
467
- const last = steps.length - 1;
468
- const lastCls = steps[last].cls;
469
- // ключи шагов в путях: alias → имя вызова; дубликаты получают _2, _3…
470
- const keys = [];
510
+ const last = real[steps.length - 1]; // pivot в конце → терминал на его узле
511
+ const lastCls = steps[steps.length - 1].cls;
512
+ // ключи шагов в путях: alias → имя вызова; pivot узла не добавляет; дубликаты — _2, _3…
513
+ const keyed = [];
471
514
  const seen = new Map();
472
- for (const s of steps) {
515
+ for (let i = 0; i < steps.length; i++) {
516
+ const s = steps[i];
517
+ if (s.pivotKey !== undefined)
518
+ continue;
473
519
  const base = s.aliasKey ?? s.name;
474
520
  const n = (seen.get(base) ?? 0) + 1;
475
521
  seen.set(base, n);
476
- keys.push(n === 1 ? base : `${base}_${n}`);
522
+ keyed.push({ key: n === 1 ? base : `${base}_${n}`, block: i });
477
523
  }
524
+ const keys = keyed.map((k) => k.key);
478
525
  const limitOffset = (mods.limit !== undefined ? ` LIMIT ${p.push(mods.limit)}` : '') +
479
526
  (mods.offset !== undefined ? ` OFFSET ${p.push(mods.offset)}` : '');
480
527
  let text;
481
528
  switch (mode) {
482
529
  case 'paths': {
483
- const obj = keys.map((k, i) => `${p.push(k)}::text, to_jsonb(h${i})`).join(', ');
530
+ const obj = keyed.map(({ key, block }) => `${p.push(key)}::text, to_jsonb(h${block})`).join(', ');
484
531
  text =
485
532
  `SELECT jsonb_build_object(${obj}) AS path\n${fromClause}` +
486
- afterCond(`h${last}`, lastCls, mods, p) +
533
+ whereOf([...tailConds, afterExpr(`h${last}`, lastCls, mods, p)]) +
487
534
  orderExpr(`h${last}`, lastCls, mods) +
488
535
  limitOffset;
489
536
  break;
490
537
  }
491
538
  case 'rows':
492
539
  case 'ids': {
493
- const inner = `SELECT DISTINCT ON (h${last}.id) h${last}.* \n${fromClause}\n` +
540
+ const inner = `SELECT DISTINCT ON (h${last}.id) h${last}.* \n${fromClause}${whereOf(tailConds)}\n` +
494
541
  `ORDER BY h${last}.id, h${last}.updated DESC`;
495
542
  const sel = mode === 'rows' ? 'to_jsonb(z) AS row' : 'z.id';
496
543
  text =
497
544
  `SELECT ${sel} FROM (${inner}) z` +
498
- afterCond('z', lastCls, mods, p) +
545
+ whereOf([afterExpr('z', lastCls, mods, p)]) +
499
546
  orderExpr('z', lastCls, mods) +
500
547
  limitOffset;
501
548
  break;
502
549
  }
503
550
  case 'versions': {
504
551
  // ВСЕ версии (включая tombstone) сущностей последнего шага, по возрастанию updated
505
- const inner = `SELECT DISTINCT h${last}.id \n${fromClause}`;
552
+ const inner = `SELECT DISTINCT h${last}.id \n${fromClause}${whereOf(tailConds)}`;
506
553
  text =
507
554
  `SELECT to_jsonb(v) AS row FROM (${inner}) z ` +
508
555
  `JOIN ${ent} v ON v.partition = ${pPart} AND v.class = ${p.push(lastCls.id)} AND v.id = z.id ` +
@@ -511,7 +558,7 @@ export function buildRead(ctx, steps, mods, mode) {
511
558
  break;
512
559
  }
513
560
  case 'count':
514
- text = `SELECT count(*)::int AS n\n${fromClause}`;
561
+ text = `SELECT count(*)::int AS n\n${fromClause}${whereOf(tailConds)}`;
515
562
  break;
516
563
  case 'agg': {
517
564
  const { aggFn, aggField } = mods;
@@ -521,12 +568,12 @@ export function buildRead(ctx, steps, mods, mode) {
521
568
  const path = aggField.slice(5).split('.');
522
569
  const acc = accessor(path)(`h${last}`); // data-путь любой глубины
523
570
  if (aggFn === 'countBy') {
524
- text = `SELECT ${acc} AS k, count(*)::int AS n\n${fromClause}\nGROUP BY 1 ORDER BY 2 DESC`;
571
+ text = `SELECT ${acc} AS k, count(*)::int AS n\n${fromClause}${whereOf(tailConds)}\nGROUP BY 1 ORDER BY 2 DESC`;
525
572
  }
526
573
  else {
527
574
  // sum/avg — всегда numeric; min/max — по типу листа
528
575
  const cast = aggFn === 'sum' || aggFn === 'avg' ? '::numeric' : castOf(leafType(lastCls, path));
529
- text = `SELECT ${aggFn}((${acc})${cast}) AS v\n${fromClause}`;
576
+ text = `SELECT ${aggFn}((${acc})${cast}) AS v\n${fromClause}${whereOf(tailConds)}`;
530
577
  }
531
578
  break;
532
579
  }
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/uuid.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Namespace letopis для uuidv5 (фиксированная константа — стабильность id между релизами). */
2
+ export declare const LETOPIS_NS = "c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90";
3
+ /** RFC 4122 v5: sha1(namespace + name); детерминирован — одно имя → один uuid. */
4
+ export declare function uuidv5(name: string, ns?: string): string;
5
+ /** RFC 9562 v7: 48 бит unix-ms + random — время в старших битах, вставки ложатся в хвост индекса. */
6
+ export declare function uuidv7(): string;
package/dist/uuid.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Генерация id по Schema (attributes.id):
3
+ * "uuid" → v4 (random) — дефолт;
4
+ * { type: 'uuid', generate: 7 } → v7 (unix-время в старших битах: btree-локальность);
5
+ * { type: 'uuid', generate: 5, from: […] } → v5: sha1 от «схема:партиция:класс:значения from» —
6
+ * детерминированный id из концов/полей: та же комбинация → тот же id (идемпотентный create,
7
+ * дубль невозможен даже в гонке — оба запроса вычислят один id, второй станет версией).
8
+ */
9
+ import { createHash, randomBytes } from 'node:crypto';
10
+ /** Namespace letopis для uuidv5 (фиксированная константа — стабильность id между релизами). */
11
+ export const LETOPIS_NS = 'c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90';
12
+ const fmt = (b) => {
13
+ const h = b.toString('hex');
14
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
15
+ };
16
+ /** RFC 4122 v5: sha1(namespace + name); детерминирован — одно имя → один uuid. */
17
+ export function uuidv5(name, ns = LETOPIS_NS) {
18
+ const nsBytes = Buffer.from(ns.replace(/-/g, ''), 'hex');
19
+ const h = createHash('sha1').update(nsBytes).update(name, 'utf8').digest().subarray(0, 16);
20
+ h[6] = (h[6] & 0x0f) | 0x50; // version 5
21
+ h[8] = (h[8] & 0x3f) | 0x80; // variant 10xx
22
+ return fmt(h);
23
+ }
24
+ /** RFC 9562 v7: 48 бит unix-ms + random — время в старших битах, вставки ложатся в хвост индекса. */
25
+ export function uuidv7() {
26
+ const b = Buffer.alloc(16);
27
+ b.writeUIntBE(Date.now(), 0, 6);
28
+ randomBytes(10).copy(b, 6);
29
+ b[6] = (b[6] & 0x0f) | 0x70; // version 7
30
+ b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx
31
+ return fmt(b);
32
+ }
package/dist/write.d.ts CHANGED
@@ -31,12 +31,19 @@ export declare function readRows(ctx: Ctx, steps: Step[], mods?: ChainMods): Pro
31
31
  */
32
32
  export declare function deepMerge(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
33
33
  /**
34
- * .set(data):
35
- * - пустой фильтр последнего шага и нет data.id → INSERT (links = контекст);
36
- * - id (фильтр-строка или data.id) → UPSERT: есть → новая версия (deep-merge), нет → создать;
37
- * - фильтр-объект → новая версия каждого найденного (в границах контекста); пусто → [].
34
+ * .create(data) — «чтобы сущность существовала»:
35
+ * - id не задан → INSERT (id по IdGen класса; links = концы из пути + слоты);
36
+ * - id известен (Класс(id) / data.id / вычислен v5): есть → новая версия (deep-merge),
37
+ * нет → INSERT с этим id (идемпотентный create, REST-PUT семантика);
38
+ * - фильтр-объект / pivot — ошибка: create не ищет, это update().
38
39
  */
39
- export declare function setOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown>): Promise<Row[]>;
40
+ export declare function createOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown>): Promise<Row[]>;
41
+ /**
42
+ * .update(data?) — новая версия КАЖДОГО найденного путём (deep-merge листьев);
43
+ * цели: Класс() ≡ Класс({}) — все в границах контекста, id/фильтр/pivot — как в чтении.
44
+ * Не найдено → [] — update НИКОГДА не создаёт.
45
+ */
46
+ export declare function updateOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown>): Promise<Row[]>;
40
47
  /**
41
48
  * .anonymize(fields): GDPR-затирание — новая версия с '[erased]' в указанных string-полях
42
49
  * + тег 'anonymized'. Только string-поля (по Schema); история сохраняется (см. README).