letopis 0.18.1 → 0.20.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
@@ -414,16 +414,31 @@ const whereOf = (conds) => {
414
414
  const list = conds.filter((c) => !!c);
415
415
  return list.length ? ` WHERE ${list.join(' AND ')}` : '';
416
416
  };
417
+ // START_CONTRACT: guardScoped
418
+ // PURPOSE: enforceAccount без идентичности вызова — фильтровать нечем, значит изоляция не гарантирована: ошибка с подсказкой на db.as().
419
+ // INPUTS: { ctx: Ctx }
420
+ // OUTPUTS: { void }
421
+ // SIDE_EFFECTS: бросает 'letopis: enforceAccount is on — call db.as(account)…' на безличном (корневом) хендле
422
+ // LINKS: M-SQL, V-M-SQL, M-CHAIN, M-WRITE
423
+ // END_CONTRACT: guardScoped
424
+ /** Корневой хендл при enforceAccount читать/писать не может: арендатора называет db.as(). */
425
+ export function guardScoped(ctx) {
426
+ if (ctx.enforceAccount && !ctx.account) {
427
+ throw new Error('letopis: enforceAccount is on — call db.as(account) to name the tenant of this call, ' +
428
+ 'or connect({ enforceAccount: false }) for admin access');
429
+ }
430
+ }
417
431
  // START_CONTRACT: buildRead
418
432
  // PURPOSE: Скомпилировать цепочку шагов+модов в один параметризованный read-запрос (латеральный обход путей, latest-версии, asOf/versions/agg, keyset).
419
433
  // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; mode: ReadMode }
420
434
  // 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>
435
+ // SIDE_EFFECTS: none (строит SQL; guardScoped и ACL-проверка READ могут бросить)
436
+ // ERRORS: enforceAccount without db.as(); pivot node not in path; enforceAccount pin; acl denies READ; deep on non-self hop; agg needs data.<path>
423
437
  // LINKS: M-SQL, V-M-SQL, M-ACL, M-DDL
424
438
  // END_CONTRACT: buildRead
425
439
  /** Построить читающий запрос по цепочке. */
426
440
  export function buildRead(ctx, steps, mods, mode) {
441
+ guardScoped(ctx);
427
442
  const p = new Params();
428
443
  const ent = entityTable(ctx.pgSchema);
429
444
  const pPart = p.push(ctx.partition);
@@ -462,9 +477,19 @@ export function buildRead(ctx, steps, mods, mode) {
462
477
  tailConds.push(fr(a));
463
478
  if (step.tagsFilter !== undefined)
464
479
  tailConds.push(compileTags(step.tagsFilter, p)(a));
480
+ // .exact() на pivot: узел уже материализован полиморфно, поэтому сужаем его
481
+ // дофильтром по классу — иначе модификатор молча ничего не делал бы
482
+ if (step.exactClass)
483
+ tailConds.push(`${a}.class = ${p.push(step.cls.id)}`);
465
484
  continue;
466
485
  }
467
- const pCls = p.push(step.cls.id);
486
+ // ПОЛИМОРФНОЕ чтение: шаг по родителю отдаёт объединение с потомками (descendants
487
+ // считает триггер schema_lineage). Запись остаётся строго по точному классу — см. M-WRITE.
488
+ const family = step.exactClass ? [step.cls.id] : [step.cls.id, ...step.cls.descendants];
489
+ /** Классы, реально попадающие в выборку: family минус запрещённые ACL. */
490
+ let classes = family;
491
+ /** Классы → их row-предикаты из ACL (у каждого потомка может быть своё правило). */
492
+ const aclFrags = new Map();
468
493
  const f = compileFilter(step.cls, step.filter, p);
469
494
  // модификаторы шага: .tags() / .account() / .owner()
470
495
  if (step.tagsFilter !== undefined) {
@@ -495,17 +520,27 @@ export function buildRead(ctx, steps, mods, mode) {
495
520
  f.candFrags.push(frag);
496
521
  f.finalFrags.push(frag);
497
522
  }
498
- // enforceAcl: READ-право на КАЖДЫЙ шаг; предикат победившего правила — в WHERE заранее
523
+ // enforceAcl: READ-право на КАЖДЫЙ шаг. Шаг полиморфен, поэтому решение берётся по
524
+ // КОНКРЕТНОМУ классу каждой строки: запрещённые потомки выпадают из выборки, у каждого
525
+ // разрешённого свой row-предикат. Иначе deny на потомке обходился бы шагом по родителю.
499
526
  if (ctx.aclDecide) {
500
- const d = ctx.aclDecide(step.cls, 'READ');
501
- if (!d.allow)
502
- throw aclDenied('READ', step.cls.id, d);
503
- if (d.filter) {
504
- for (const fr of rowTemplateFrags(d.filter, p)) {
505
- f.candFrags.push(fr);
506
- f.finalFrags.push(fr);
507
- }
527
+ const own = ctx.aclDecide(step.cls, 'READ');
528
+ const allowed = [];
529
+ for (const id of family) {
530
+ const cd = ctx.registry.find(id);
531
+ if (!cd)
532
+ continue;
533
+ const d = id === step.cls.id ? own : ctx.aclDecide(cd, 'READ');
534
+ if (!d.allow)
535
+ continue;
536
+ allowed.push(id);
537
+ if (d.filter)
538
+ aclFrags.set(id, rowTemplateFrags(d.filter, p));
508
539
  }
540
+ // ни один класс семейства не разрешён — отказ шага (как было для одиночного класса)
541
+ if (!allowed.length)
542
+ throw aclDenied('READ', step.cls.id, own);
543
+ classes = allowed;
509
544
  }
510
545
  // предикат WRITE/DELETE-правила (поиск целей записи): только свои строки
511
546
  if (step.aclFilter) {
@@ -514,11 +549,30 @@ export function buildRead(ctx, steps, mods, mode) {
514
549
  f.finalFrags.push(fr);
515
550
  }
516
551
  }
517
- const innerWhere = [`e.partition = ${pPart}`, `e.class = ${pCls}`];
552
+ // Условие по классу: один класс → быстрое равенство (план не меняется); семейство →
553
+ // OR-ветви, каждая со своим ACL-предикатом. Ставится и во внутренний WHERE, и в
554
+ // перепроверку на актуальной версии (finalFrags) — иначе предикат обходится версиями.
555
+ const poly = classes.length > 1;
556
+ const clsPh = new Map(classes.map((id) => [id, p.push(id)]));
557
+ const classFrag = (a) => classes
558
+ .map((id) => {
559
+ const preds = (aclFrags.get(id) ?? []).map((fr) => fr(a));
560
+ const eq = `${a}.class = ${clsPh.get(id)}`;
561
+ return preds.length ? `(${eq} AND ${preds.join(' AND ')})` : eq;
562
+ })
563
+ .join(' OR ');
564
+ const classCond = poly || aclFrags.size ? (a) => `(${classFrag(a)})` : classFrag;
565
+ // ТОЛЬКО в finalFrags: в candFrags нельзя — иначе кандидатный подзапрос появлялся бы
566
+ // на КАЖДОМ запросе (условие по классу непустое всегда), а он нужен лишь когда есть
567
+ // GIN-предикаты. В основной скан условие попадает через innerWhere ниже.
568
+ f.finalFrags.push(classCond);
569
+ const innerWhere = [`e.partition = ${pPart}`, classCond('e')];
518
570
  if (mods.asOf !== undefined)
519
571
  innerWhere.push(`e.updated <= ${p.push(mods.asOf)}::timestamptz`); // «как было на T»
520
- // versions: последний шаг включает и удалённые сущности (история существует после delete)
521
- const outerWhere = [mode === 'versions' && i === steps.length - 1 ? 'TRUE' : 't.deleted IS NULL'];
572
+ // versions/.withDeleted(): последний шаг включает и удалённые (история/tombstone видны после delete).
573
+ // Снимается ТОЛЬКО фильтр deleted — enforceAccount/enforceAcl/контекст-фраги ниже действуют (изоляция цела).
574
+ const keepDeleted = i === steps.length - 1 && (mode === 'versions' || mods.withDeleted === true);
575
+ const outerWhere = [keepDeleted ? 'TRUE' : 't.deleted IS NULL'];
522
576
  const candWhere = f.candFrags.map((fr) => fr('c'));
523
577
  for (const c of f.idConds)
524
578
  innerWhere.push(c('e'));
@@ -529,23 +583,35 @@ export function buildRead(ctx, steps, mods, mode) {
529
583
  const prev = steps[i - 1];
530
584
  const prevAlias = `h${real[i - 1]}`; // pivot: ветвление от узла-возврата
531
585
  const hop = resolveHop(prev.cls, step.cls);
586
+ // ключи Entity.links — ВСЕГДА конкретные id классов, не родительские. Поэтому при
587
+ // полиморфном шаге ключ берётся из колонки class самой строки, а не из литерала.
588
+ const prevPoly = prev.cls.descendants.length > 0;
532
589
  if (hop === 'forward') {
533
590
  // id неизменен — условие сразу во внутренний WHERE, перепроверка не нужна
534
- innerWhere.push(`e.id = ${prevAlias}.links->>${p.push(step.cls.id)}`);
591
+ innerWhere.push(poly
592
+ ? `${prevAlias}.links @> jsonb_build_object(e.class::text, e.id)`
593
+ : `e.id = ${prevAlias}.links->>${p.push(step.cls.id)}`);
535
594
  }
536
595
  else {
537
596
  // reverse containment: кандидаты по GIN + перепроверка на latest
538
- const pKey = p.push(prev.cls.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)`);
597
+ const key = prevPoly ? `${prevAlias}.class::text` : `${p.push(prev.cls.id)}::text`;
598
+ candWhere.push(`c.links @> jsonb_build_object(${key}, ${prevAlias}.id)`);
599
+ outerWhere.push(`t.links @> jsonb_build_object(${key}, ${prevAlias}.id)`);
541
600
  }
542
601
  }
543
602
  if (candWhere.length) {
544
- innerWhere.push(`e.id IN (SELECT c.id FROM ${ent} c WHERE c.partition = ${pPart} AND c.class = ${pCls} AND ${candWhere.join(' AND ')})`);
545
- }
603
+ // подзапрос кандидатов нужен только при GIN-предикатах; класс добавляем здесь, а не
604
+ // через candFrags. При семействе id не уникален между классами → сопоставляем (class, id)
605
+ const cand = [classCond('c'), ...candWhere].join(' AND ');
606
+ innerWhere.push(poly
607
+ ? `(e.class, e.id) IN (SELECT c.class, c.id FROM ${ent} c WHERE c.partition = ${pPart} AND ${cand})`
608
+ : `e.id IN (SELECT c.id FROM ${ent} c WHERE c.partition = ${pPart} AND ${cand})`);
609
+ }
610
+ // «актуальная версия» — по (class, id) при семействе: один id может жить в разных классах
611
+ const dedupe = poly ? 'e.class, e.id' : 'e.id';
546
612
  let block = `(SELECT * FROM (` +
547
- `SELECT DISTINCT ON (e.id) e.* FROM ${ent} e WHERE ${innerWhere.join(' AND ')} ` +
548
- `ORDER BY e.id, e.updated DESC` +
613
+ `SELECT DISTINCT ON (${dedupe}) e.* FROM ${ent} e WHERE ${innerWhere.join(' AND ')} ` +
614
+ `ORDER BY ${dedupe}, e.updated DESC` +
549
615
  `) t WHERE ${outerWhere.join(' AND ')}) h${i}`;
550
616
  // .deep(): рекурсивный обход детей того же класса (recursive CTE поверх latest-паттерна)
551
617
  if (isDeep) {
@@ -554,19 +620,24 @@ export function buildRead(ctx, steps, mods, mode) {
554
620
  throw new Error('letopis: .deep() works on a self hop (same class as previous step)');
555
621
  }
556
622
  const pMax = p.push(step.deepMax);
557
- // latest живые дети узла ref (id-выражение родителя)
558
- const kids = (ref) => `(SELECT * FROM (` +
559
- `SELECT DISTINCT ON (e.id) e.* FROM ${ent} e WHERE e.partition = ${pPart} AND e.class = ${pCls}` +
560
- (mods.asOf !== undefined ? ` AND e.updated <= ${p.push(mods.asOf)}::timestamptz` : '') +
561
- ` AND e.id IN (SELECT c.id FROM ${ent} c WHERE c.partition = ${pPart} AND c.class = ${pCls}` +
562
- ` AND c.links @> jsonb_build_object(${p.push(step.cls.id)}::text, ${ref}))` +
563
- ` ORDER BY e.id, e.updated DESC) t` +
564
- ` WHERE t.deleted IS NULL AND t.links @> jsonb_build_object(${p.push(step.cls.id)}::text, ${ref}))`;
623
+ // latest живые дети узла ref (id-выражение родителя); refCls — класс родителя:
624
+ // ключ links конкретен, поэтому при семействе берём его из колонки, а не из литерала
625
+ const kids = (ref, refCls) => {
626
+ const key = poly ? `${refCls}::text` : `${p.push(step.cls.id)}::text`;
627
+ return (`(SELECT * FROM (` +
628
+ `SELECT DISTINCT ON (${dedupe}) e.* FROM ${ent} e WHERE e.partition = ${pPart} AND ${classCond('e')}` +
629
+ (mods.asOf !== undefined ? ` AND e.updated <= ${p.push(mods.asOf)}::timestamptz` : '') +
630
+ ` AND e.id IN (SELECT c.id FROM ${ent} c WHERE c.partition = ${pPart} AND ${classCond('c')}` +
631
+ ` AND c.links @> jsonb_build_object(${key}, ${ref}))` +
632
+ ` ORDER BY ${dedupe}, e.updated DESC) t` +
633
+ ` WHERE t.deleted IS NULL AND t.links @> jsonb_build_object(${key}, ${ref}))`);
634
+ };
635
+ const prevA = `h${real[i - 1]}`;
565
636
  block =
566
637
  `(WITH RECURSIVE d AS (` +
567
- `SELECT k.*, 1 AS depth FROM ${kids(`h${real[i - 1]}.id`)} k` +
638
+ `SELECT k.*, 1 AS depth FROM ${kids(`${prevA}.id`, `${prevA}.class`)} k` +
568
639
  ` UNION ALL ` +
569
- `SELECT k.*, d.depth + 1 FROM d JOIN LATERAL ${kids('d.id')} k ON true WHERE d.depth < ${pMax}::int` +
640
+ `SELECT k.*, d.depth + 1 FROM d JOIN LATERAL ${kids('d.id', 'd.class')} k ON true WHERE d.depth < ${pMax}::int` +
570
641
  `) SELECT * FROM d) h${i}`;
571
642
  }
572
643
  blocks.push(i === 0 ? `FROM ${block}` : `JOIN LATERAL ${block} ON true`);
@@ -575,6 +646,8 @@ export function buildRead(ctx, steps, mods, mode) {
575
646
  const fromClause = blocks.join('\n');
576
647
  const last = real[steps.length - 1]; // pivot в конце → терминал на его узле
577
648
  const lastCls = steps[steps.length - 1].cls;
649
+ /** Последний шаг полиморфен (есть потомки и не снят .exact()) — уникальность по (class, id). */
650
+ const lastPoly = !steps[steps.length - 1].exactClass && lastCls.descendants.length > 0;
578
651
  // ключи шагов в путях: alias → имя вызова; pivot узла не добавляет; дубликаты — _2, _3…
579
652
  const keyed = [];
580
653
  const seen = new Map();
@@ -604,8 +677,10 @@ export function buildRead(ctx, steps, mods, mode) {
604
677
  }
605
678
  case 'rows':
606
679
  case 'ids': {
607
- const inner = `SELECT DISTINCT ON (h${last}.id) h${last}.* \n${fromClause}${whereOf(tailConds)}\n` +
608
- `ORDER BY h${last}.id, h${last}.updated DESC`;
680
+ // при полиморфном последнем шаге уникальность сущности — пара (class, id)
681
+ const key = lastPoly ? `h${last}.class, h${last}.id` : `h${last}.id`;
682
+ const inner = `SELECT DISTINCT ON (${key}) h${last}.* \n${fromClause}${whereOf(tailConds)}\n` +
683
+ `ORDER BY ${key}, h${last}.updated DESC`;
609
684
  const sel = mode === 'rows' ? 'to_jsonb(z) AS row' : 'z.id';
610
685
  text =
611
686
  `SELECT ${sel} FROM (${inner}) z` +
@@ -616,11 +691,13 @@ export function buildRead(ctx, steps, mods, mode) {
616
691
  }
617
692
  case 'versions': {
618
693
  // ВСЕ версии (включая tombstone) сущностей последнего шага, по возрастанию updated
619
- const inner = `SELECT DISTINCT h${last}.id \n${fromClause}${whereOf(tailConds)}`;
694
+ // класс берём из найденной строки (а не литералом): при полиморфном шаге история
695
+ // собирается по КАЖДОМУ конкретному классу-потомку
696
+ const inner = `SELECT DISTINCT h${last}.class, h${last}.id \n${fromClause}${whereOf(tailConds)}`;
620
697
  text =
621
698
  `SELECT to_jsonb(v) AS row FROM (${inner}) z ` +
622
- `JOIN ${ent} v ON v.partition = ${pPart} AND v.class = ${p.push(lastCls.id)} AND v.id = z.id ` +
623
- `ORDER BY v.id, v.updated` +
699
+ `JOIN ${ent} v ON v.partition = ${pPart} AND v.class = z.class AND v.id = z.id ` +
700
+ `ORDER BY v.class, v.id, v.updated` +
624
701
  limitOffset;
625
702
  break;
626
703
  }
@@ -726,3 +803,18 @@ export function closureSql(pgSchema, n) {
726
803
  )
727
804
  SELECT DISTINCT ON (class, id) * FROM node ORDER BY class, id, updated DESC`);
728
805
  }
806
+ /**
807
+ * Вызов серверной purge(): $4 dry → превью (актуальные версии замыкания, БД цела), иначе двухфазный
808
+ * физический снос корня+поддерева. RETURNS SETOF Entity (по строке на сущность). Вся логика
809
+ * (замыкание, двухфазность, отключение триггера через SET LOCAL) — в БД. $1 partition, $2 class, $3 id, $4 dry.
810
+ */
811
+ // START_CONTRACT: purgeCallSql
812
+ // PURPOSE: SQL-обёртка вызова серверной purge() (снос/превью) — вся логика в БД, JS лишь зовёт.
813
+ // INPUTS: { pgSchema: string }
814
+ // OUTPUTS: { string - SELECT * FROM "<schema>".purge($1,$2,$3,$4) }
815
+ // SIDE_EFFECTS: none
816
+ // LINKS: M-SQL, V-M-SQL, M-DDL, M-WRITE
817
+ // END_CONTRACT: purgeCallSql
818
+ export function purgeCallSql(pgSchema) {
819
+ return `SELECT * FROM ${escId(pgSchema)}.purge($1, $2, $3, $4)`;
820
+ }
package/dist/tables.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface AccountsApi {
17
17
  set(a: Partial<Account>): Promise<Account>;
18
18
  /** Физический DELETE; Credential снесётся FK-каскадом. */
19
19
  delete(id: string): Promise<boolean>;
20
+ /**
21
+ * Полный физический офбординг тенанта (необратимо): все Entity (account|owner=id) + сам Account
22
+ * (Credential — FK-каскад), через серверную purge_account(). Предохранители: только Owner/System-вызов;
23
+ * нельзя снести последний enabled Owner (лок-аут) и свой аккаунт. → true если Account снесён.
24
+ */
25
+ purge(id: string): Promise<boolean>;
20
26
  }
21
27
  export interface CredentialsApi {
22
28
  /** deleted IS NULL по умолчанию; withDeleted: true — включая удалённые. */
@@ -72,10 +78,28 @@ export interface RulesApi {
72
78
  }): Promise<Rule>;
73
79
  delete(account: string, resource: string): Promise<boolean>;
74
80
  }
81
+ export interface SchemaApi {
82
+ /**
83
+ * Upsert определения класса в таблицу Schema (ON CONFLICT (partition,id) DO UPDATE) + reloadSchema().
84
+ * Правка «простым SQL», подхватывается сразу без реконнекта. attributes — DSL fastest-validator
85
+ * (валидирует либа при чтении реестра); ancestors/descendants считает триггер schema_lineage.
86
+ */
87
+ define(def: {
88
+ id: string;
89
+ alias: string;
90
+ category: 'HUB' | 'LINK';
91
+ ancestor?: string | null;
92
+ attributes?: Record<string, unknown>;
93
+ meta?: Record<string, unknown>;
94
+ links?: unknown[];
95
+ order?: number;
96
+ }): Promise<void>;
97
+ }
75
98
  export interface Tables {
76
99
  accounts: AccountsApi;
77
100
  credentials: CredentialsApi;
78
101
  resources: ResourcesApi;
79
102
  rules: RulesApi;
103
+ schema: SchemaApi;
80
104
  }
81
105
  export declare function makeTables(ctx: Ctx): Tables;
package/dist/tables.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { reservedNamesOf } from './types.js';
1
2
  const T = (ctx, name) => `"${ctx.pgSchema.replace(/"/g, '""')}"."${name}"`;
2
3
  // START_CONTRACT: where
3
4
  // PURPOSE: Собрать SQL WHERE из простых equality-условий, добавляя значения в params.
@@ -85,6 +86,45 @@ export function makeTables(ctx) {
85
86
  const rows = await run(`DELETE FROM ${T(ctx, 'Account')} WHERE id = $1 RETURNING id`, [id]);
86
87
  return rows.length > 0;
87
88
  },
89
+ // START_CONTRACT: accounts.purge
90
+ // PURPOSE: Физический офбординг тенанта через purge_account(); гарды Owner/System, последний Owner, не-себя — до сноса, в одной tx.
91
+ // INPUTS: { id: string - целевой аккаунт }
92
+ // OUTPUTS: { Promise<boolean> - true если Account физически снесён }
93
+ // SIDE_EFFECTS: физ. DELETE всех Entity аккаунта + Account (Credential FK-каскад) через SQL purge_account()
94
+ // ERRORS: нет caller/ACL; вызов не Owner/System; снос последнего Owner; снос своего аккаунта
95
+ // LINKS: M-TABLES, V-M-TABLES, M-DDL
96
+ // END_CONTRACT: accounts.purge
97
+ async purge(id) {
98
+ if (!ctx.account) {
99
+ throw new Error('letopis: accounts.purge requires an authenticated caller — call it from a scoped handle: ' +
100
+ '(await db.as(caller)).accounts.purge(id)');
101
+ }
102
+ if (id === ctx.account) {
103
+ throw new Error('letopis: accounts.purge cannot purge the calling account itself');
104
+ }
105
+ const schema = `"${ctx.pgSchema.replace(/"/g, '""')}"`;
106
+ return ctx.sql.begin(async (tsql) => {
107
+ const q = (t, p) => tsql.unsafe(t, p);
108
+ // вызывающий обязан быть Owner/System
109
+ const caller = await q(`SELECT categories FROM ${T(ctx, 'Account')} WHERE id = $1`, [ctx.account]);
110
+ const cats = caller[0]?.categories ?? [];
111
+ if (!cats.includes('Owner') && !cats.includes('System')) {
112
+ throw new Error('letopis: accounts.purge is restricted to Owner/System accounts');
113
+ }
114
+ const tgt = await q(`SELECT categories FROM ${T(ctx, 'Account')} WHERE id = $1`, [id]);
115
+ if (!tgt.length)
116
+ return false;
117
+ // нельзя снести последний enabled Owner → тенант без владельца (лок-аут)
118
+ if ((tgt[0].categories ?? []).includes('Owner')) {
119
+ const cnt = await q(`SELECT count(*)::text AS n FROM ${T(ctx, 'Account')} WHERE 'Owner' = ANY(categories) AND enabled`, []);
120
+ if (Number(cnt[0]?.n ?? 0) <= 1) {
121
+ throw new Error('letopis: refusing to purge the last enabled Owner (tenant lock-out)');
122
+ }
123
+ }
124
+ const res = await q(`SELECT ${schema}.purge_account($1::uuid) AS id`, [id]);
125
+ return res[0]?.id != null;
126
+ });
127
+ },
88
128
  };
89
129
  // END_BLOCK_ACCOUNTS_API
90
130
  // START_BLOCK_CREDENTIALS_API
@@ -181,5 +221,28 @@ export function makeTables(ctx) {
181
221
  },
182
222
  };
183
223
  // END_BLOCK_RULES_API
184
- return { accounts, credentials, resources, rules };
224
+ // START_BLOCK_SCHEMA_API
225
+ const schema = {
226
+ async define(def) {
227
+ // класс с именем, которое Proxy разбирает до резолва класса, был бы недостижим как шаг
228
+ const clash = reservedNamesOf(def);
229
+ if (clash.length) {
230
+ throw new Error(`letopis: class name ${clash.map((n) => `"${n}"`).join(' / ')} is reserved by the chain API ` +
231
+ `(a step under that name resolves to the method, not the class) — rename the ${clash.includes(def.id) ? 'id' : 'alias'}`);
232
+ }
233
+ // jsonb-значения через sql.json() — postgres.js кладёт как jsonb (не PG-массив/строку)
234
+ const j = (v) => ctx.sql.json(v);
235
+ await run(`INSERT INTO ${T(ctx, 'Schema')} (partition, id, alias, category, ancestor, attributes, meta, links, "order")
236
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
237
+ ON CONFLICT (partition, id) DO UPDATE SET
238
+ alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor,
239
+ attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order"`, [
240
+ ctx.partition, def.id, def.alias, def.category, def.ancestor ?? null,
241
+ j(def.attributes ?? {}), j(def.meta ?? {}), j(def.links ?? []), def.order ?? 0,
242
+ ]);
243
+ await ctx.reload?.(); // подхватить новое определение сразу (registry + enforcer)
244
+ },
245
+ };
246
+ // END_BLOCK_SCHEMA_API
247
+ return { accounts, credentials, resources, rules, schema };
185
248
  }
package/dist/types.d.ts CHANGED
@@ -125,12 +125,12 @@ export interface Cursor {
125
125
  * Терминал исполняет план (все операции + финальное чтение) одной транзакцией.
126
126
  */
127
127
  export interface PlanOp {
128
- kind: 'create' | 'update' | 'delete' | 'anonymize';
128
+ kind: 'create' | 'update' | 'delete' | 'anonymize' | 'purge';
129
129
  /** create/update: данные новой версии (deep-merge листьев). */
130
130
  data?: Record<string, unknown>;
131
131
  /** anonymize: string-поля под '[erased]'. */
132
132
  fields?: string[];
133
- /** delete: true — удалить; без confirm — превью (вернуть кандидатов, БД не трогать). */
133
+ /** delete/purge: true — выполнить; без confirm — превью (вернуть кандидатов/замыкание, БД не трогать). */
134
134
  confirm?: boolean;
135
135
  /** Снапшот модификаторов на момент вызова операции (limit/sort для поиска целей). */
136
136
  mods: ChainMods;
@@ -149,6 +149,8 @@ export interface ChainMods {
149
149
  /** Внутреннее: агрегация терминалов .sum/.avg/.min/.max/.countBy. */
150
150
  aggFn?: 'sum' | 'avg' | 'min' | 'max' | 'countBy';
151
151
  aggField?: string;
152
+ /** .withDeleted(): последний шаг включает удалённые (tombstone). Снимает ТОЛЬКО фильтр deleted; enforceAccount/enforceAcl действуют. */
153
+ withDeleted?: boolean;
152
154
  }
153
155
  export interface Account {
154
156
  id: string;
@@ -222,20 +224,28 @@ export interface ConnectOpts {
222
224
  schema: string;
223
225
  /** Партиция данных. Default 'entity'. */
224
226
  partition?: string;
225
- /** Дефолтный account для записей. */
226
- account?: string;
227
- /** Дефолтный owner для записей. Default = account. */
228
- owner?: string;
229
227
  /** Размер пула соединений. Default 10. */
230
228
  max?: number;
231
- /** Жёсткая изоляция арендатора: чтения фильтруются по account, записи пришпилены к нему. */
229
+ /**
230
+ * Жёсткая изоляция арендатора: чтения фильтруются по account, записи пришпилены к нему.
231
+ * **Default true** (с 0.20.0). Идентичность берётся у хендла `db.as(account)`; на
232
+ * безличном (корневом) хендле чтение/запись при включённой изоляции — ошибка с подсказкой.
233
+ * Явный `.account(чужой)` на scoped-хендле — тоже ошибка.
234
+ * Выключать (`false`) осмысленно лишь для админских/сервисных подключений,
235
+ * которым нужен доступ ко всем арендаторам.
236
+ */
232
237
  enforceAccount?: boolean;
233
238
  /**
234
239
  * ACL по Resource/Rule: категории READ/WRITE/DELETE, pattern — шаблон строки Entity
235
240
  * (колонки + "$account"). Каждый шаг цепочки проверяется на READ, записи — WRITE,
236
241
  * delete — DELETE (включая классы каскада); предикат победившего правила вливается
237
- * в SQL до сортировки/лимита. Требует account. Deny-by-default.
238
- * Правила читаются один раз при connect (перечитка — новый connect).
242
+ * в SQL до сортировки/лимита. Субъект даёт db.as() (энфорсер компилится на scope).
243
+ * Deny-by-default.
244
+ * **Default false** — остаётся opt-in: включённый по умолчанию deny-by-default без
245
+ * настроенных Resource/Rule давал бы пустые выборки. Пока выключен, connect() один раз
246
+ * на процесс печатает предупреждение.
247
+ * Правила снимаются на connect и компилятся в memo-энфорсер; перечитать без
248
+ * реконнекта — db.reloadSchema() (пересобирает и реестр, и ACL-резолвер).
239
249
  */
240
250
  enforceAcl?: boolean;
241
251
  /** Хук на каждый запрос цепочки (метрики, лог). */
@@ -243,3 +253,18 @@ export interface ConnectOpts {
243
253
  /** Порог «медленного» запроса, мс: событие получает slow: true; без onQuery — console.warn. */
244
254
  slowMs?: number;
245
255
  }
256
+ /**
257
+ * Имена, которые Proxy цепочки/db разбирает ДО резолва класса, поэтому класс с таким
258
+ * id/alias НЕДОСТИЖИМ как шаг под этим именем (`db.Клиент(c).link()` уйдёт в
259
+ * migration-ошибку `.link() removed`, а не в класс `link`).
260
+ *
261
+ * Источник истины — `case`-метки switch'ей в chain.ts; совпадение с этим списком
262
+ * проверяет scripts/check-docs.mjs. Используется как гвард: schema.define() отказывает,
263
+ * loadRegistry предупреждает.
264
+ */
265
+ export declare const RESERVED_CLASS_NAMES: readonly string[];
266
+ /** Зарезервированные имена класса среди {id, alias}; пусто — конфликта нет. */
267
+ export declare function reservedNamesOf(def: {
268
+ id: string;
269
+ alias?: string;
270
+ }): string[];
package/dist/types.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  //
5
5
  // FILE: lib/src/types.ts
6
- // VERSION: 1.0.0
6
+ // VERSION: 1.3.0
7
7
  // START_MODULE_CONTRACT
8
8
  // PURPOSE: Общий словарь типов всей библиотеки letopis.
9
9
  // SCOPE: сущности, пути, определения классов, типы полей, фильтры и операторы, курсоры/план записи/модификаторы цепочки, записи auth/ACL, событие запроса и опции connect + символ OP.
@@ -35,11 +35,16 @@
35
35
  // AclOp - операция над данными = категория Resource: 'READ' | 'WRITE' | 'DELETE'.
36
36
  // AclDecision - решение ACL: allow + победившее правило + остаточный filter + code/message.
37
37
  // QueryEvent - событие хука onQuery: режим, классы, ms, rows, slow.
38
- // ConnectOpts - опции connect(): dsn/schema/partition/account/owner/max/enforceAccount/enforceAcl/onQuery/slowMs.
38
+ // ConnectOpts - опции connect(): dsn/schema/partition/max/enforceAccount/enforceAcl/onQuery/slowMs (account/owner — не здесь, их даёт db.as()).
39
+ // RESERVED_CLASS_NAMES - имена, перехватываемые Proxy до резолва класса (шаг недостижим под этим именем).
40
+ // reservedNamesOf - зарезервированные имена среди {id, alias} определения класса.
39
41
  // END_MODULE_MAP
40
42
  //
41
43
  // START_CHANGE_SUMMARY
42
- // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
44
+ // LAST_CHANGE: [v1.3.0 - BREAKING: из ConnectOpts удалены account/owner (арендатор — свойство
45
+ // ВЫЗОВА: db.as(account, { owner })); 'as' добавлен в RESERVED_CLASS_NAMES → 52 имени.
46
+ // Ранее: 'link' убран из RESERVED_CLASS_NAMES (охранник цепочки снят, имя снова
47
+ // резолвится как класс); enforceAccount задокументирован как Default true]
43
48
  // END_CHANGE_SUMMARY
44
49
  //
45
50
  // START_BLOCK_ENTITY_TYPES
@@ -49,3 +54,35 @@
49
54
  /** Метка операторов фильтра (Symbol — не конфликтует с данными). */
50
55
  export const OP = Symbol('letopis.op');
51
56
  // END_BLOCK_CONNECT_TYPES
57
+ // START_BLOCK_RESERVED_NAMES
58
+ /**
59
+ * Имена, которые Proxy цепочки/db разбирает ДО резолва класса, поэтому класс с таким
60
+ * id/alias НЕДОСТИЖИМ как шаг под этим именем (`db.Клиент(c).link()` уйдёт в
61
+ * migration-ошибку `.link() removed`, а не в класс `link`).
62
+ *
63
+ * Источник истины — `case`-метки switch'ей в chain.ts; совпадение с этим списком
64
+ * проверяет scripts/check-docs.mjs. Используется как гвард: schema.define() отказывает,
65
+ * loadRegistry предупреждает.
66
+ */
67
+ export const RESERVED_CLASS_NAMES = [
68
+ // все три Proxy: then гасится, чтобы цепочка не выглядела thenable для await
69
+ 'then',
70
+ // chain-уровень (makeChain handler)
71
+ 'entity', 'run', 'execute', 'rows', 'first', 'ids', 'count', 'limit', 'offset', 'sort',
72
+ 'asOf', 'withDeleted', 'deep', 'exact', 'sum', 'avg', 'min', 'max', 'countBy', 'after', 'versions',
73
+ 'create', 'update', 'set', 'delete', 'anonymize', 'purge', 'alias', 'tags', 'account',
74
+ 'owner',
75
+ // db-уровень (makeDb handler): switch …
76
+ 'as', 'begin', 'commit', 'rollback', 'lock', 'batch', 'watch', 'close', 'reloadSchema',
77
+ 'registry', 'sql',
78
+ // … и фасады, которые разбираются if'ами ДО switch (служебные таблицы, auth, acl)
79
+ 'accounts', 'credentials', 'resources', 'rules', 'schema', 'auth', 'acl',
80
+ // batch-уровень (makeBatch handler)
81
+ 'discard', 'size',
82
+ ];
83
+ /** Зарезервированные имена класса среди {id, alias}; пусто — конфликта нет. */
84
+ export function reservedNamesOf(def) {
85
+ const set = new Set(RESERVED_CLASS_NAMES);
86
+ return [def.id, def.alias].filter((n) => !!n && set.has(n));
87
+ }
88
+ // END_BLOCK_RESERVED_NAMES
package/dist/up.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * const db = await up({ schema: 'booking', version: 1 }); // → PG-схема "v1.booking"
7
7
  */
8
8
  // FILE: lib/src/up.ts
9
- // VERSION: 1.0.0
9
+ // VERSION: 1.1.0
10
10
  // START_MODULE_CONTRACT
11
11
  // PURPOSE: Dev-bootstrap — поднять Docker-контейнер Postgres (TimescaleDB) + Redis, обеспечить базу, накатить DDL-схему и сиды, дождаться готовности и подключиться.
12
12
  // SCOPE: валидация схемы/версии; проба postgres; docker build/run/start контейнера; создание базы; ожидание готовности PG и Redis; применение схемы + сиды; connect
@@ -26,11 +26,14 @@
26
26
  // pgPing - (local) проба postgres: ok | no-server | no-database | starting | fatal
27
27
  // ensureDatabase - (local) создать базу, если её нет (гонка 42P04 глушится)
28
28
  // tcpAlive - (local) TCP-проба хоста:порта (для redis)
29
- // applySchema - (local) drop (fresh) -> накат ddl+сидов с подстановкой <SCHEMA-NAME>
29
+ // applySchema - (local) drop (fresh) -> накат ddl+сидов с подстановкой <SCHEMA-NAME> -> ANALYZE Entity
30
30
  // END_MODULE_MAP
31
31
  //
32
32
  // START_CHANGE_SUMMARY
33
- // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
33
+ // LAST_CHANGE: [v1.1.0 - applySchema делает ANALYZE Entity после наката: у гипертаблицы нет
34
+ // статистики родителя, и планировщик оценивает подзапрос кандидатов в 200 строк — выбирался
35
+ // Nested Loop, чтения были медленнее до x18 (замер в README 14.1).
36
+ // Ранее: Documented existing module: reverse-engineered contract + markup]
34
37
  // END_CHANGE_SUMMARY
35
38
  //
36
39
  import { execFile } from 'node:child_process';
@@ -207,7 +210,15 @@ async function applySchema(dsn, full, seeds, fresh, log) {
207
210
  const text = (await readFile(f, 'utf8')).replaceAll('<SCHEMA-NAME>', full);
208
211
  await sql.unsafe(text); // multi-statement simple query
209
212
  }
210
- log(`schema "${full}" applied (${files.length} files)`);
213
+ // START_BLOCK_ANALYZE_ENTITY
214
+ // ANALYZE обязателен: Entity — гипертаблица, у РОДИТЕЛЯ своих строк нет, и без статистики
215
+ // планировщик берёт дефолт «200 уникальных id». Ядро всех чтений либы — semi-join с
216
+ // подзапросом кандидатов (`e.id IN (SELECT c.id …)`), и на оценке 200 вместо сотен тысяч
217
+ // он выбирает Nested Loop: на полигоне 980k это давало 57 с вместо 3 с (×18).
218
+ // Стоит копейки на свежей схеме; после МАССОВОЙ заливки данных ANALYZE нужно повторить.
219
+ await sql.unsafe(`ANALYZE ${qi(full)}."Entity"`);
220
+ // END_BLOCK_ANALYZE_ENTITY
221
+ log(`schema "${full}" applied (${files.length} files, statistics collected)`);
211
222
  }
212
223
  finally {
213
224
  await sql.end();
package/dist/write.d.ts CHANGED
@@ -56,6 +56,15 @@ export declare function anonymizeOp(ctx: Ctx, steps: Step[], mods: ChainMods, fi
56
56
  * Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
57
57
  */
58
58
  export declare function delOp(ctx: Ctx, steps: Step[], mods: ChainMods, confirm: boolean): Promise<Row[]>;
59
+ /**
60
+ * .purge({confirm}): ФИЗИЧЕСКИЙ hard-erase — сносит логически удалённые цели + всё поддерево (все версии).
61
+ * Вся логика — в серверной purge() (двухфазность: живую цель не трогает; замыкание по links; SET LOCAL
62
+ * отключает entity_delete → плоский снос без TM_SelfModified). JS лишь резолвит цели и зовёт функцию.
63
+ * confirm: false → dry-превью (что сотрётся, БД цела); true → снос, возвращает снесённое ($deleted).
64
+ * gate: DELETE-право на класс цели (aclWrite) — поддерево уже было DELETE-авторизовано при мягком удалении.
65
+ * В отличие от .delete() (tombstone, обратимо) — необратимо, историю НЕ сохраняет.
66
+ */
67
+ export declare function purgeOp(ctx: Ctx, steps: Step[], mods: ChainMods, confirm: boolean): Promise<Row[]>;
59
68
  export type PlanMode = 'rows' | 'ids' | 'count' | 'paths' | 'versions' | 'agg';
60
69
  /**
61
70
  * Исполнить план целиком: все операции + финальное чтение — одна транзакция