letopis 0.19.0 → 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,7 +549,24 @@ 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
572
  // versions/.withDeleted(): последний шаг включает и удалённые (история/tombstone видны после delete).
@@ -531,23 +583,35 @@ export function buildRead(ctx, steps, mods, mode) {
531
583
  const prev = steps[i - 1];
532
584
  const prevAlias = `h${real[i - 1]}`; // pivot: ветвление от узла-возврата
533
585
  const hop = resolveHop(prev.cls, step.cls);
586
+ // ключи Entity.links — ВСЕГДА конкретные id классов, не родительские. Поэтому при
587
+ // полиморфном шаге ключ берётся из колонки class самой строки, а не из литерала.
588
+ const prevPoly = prev.cls.descendants.length > 0;
534
589
  if (hop === 'forward') {
535
590
  // id неизменен — условие сразу во внутренний WHERE, перепроверка не нужна
536
- 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)}`);
537
594
  }
538
595
  else {
539
596
  // reverse containment: кандидаты по GIN + перепроверка на latest
540
- const pKey = p.push(prev.cls.id);
541
- candWhere.push(`c.links @> jsonb_build_object(${pKey}::text, ${prevAlias}.id)`);
542
- 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)`);
543
600
  }
544
601
  }
545
602
  if (candWhere.length) {
546
- innerWhere.push(`e.id IN (SELECT c.id FROM ${ent} c WHERE c.partition = ${pPart} AND c.class = ${pCls} AND ${candWhere.join(' AND ')})`);
547
- }
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';
548
612
  let block = `(SELECT * FROM (` +
549
- `SELECT DISTINCT ON (e.id) e.* FROM ${ent} e WHERE ${innerWhere.join(' AND ')} ` +
550
- `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` +
551
615
  `) t WHERE ${outerWhere.join(' AND ')}) h${i}`;
552
616
  // .deep(): рекурсивный обход детей того же класса (recursive CTE поверх latest-паттерна)
553
617
  if (isDeep) {
@@ -556,19 +620,24 @@ export function buildRead(ctx, steps, mods, mode) {
556
620
  throw new Error('letopis: .deep() works on a self hop (same class as previous step)');
557
621
  }
558
622
  const pMax = p.push(step.deepMax);
559
- // latest живые дети узла ref (id-выражение родителя)
560
- const kids = (ref) => `(SELECT * FROM (` +
561
- `SELECT DISTINCT ON (e.id) e.* FROM ${ent} e WHERE e.partition = ${pPart} AND e.class = ${pCls}` +
562
- (mods.asOf !== undefined ? ` AND e.updated <= ${p.push(mods.asOf)}::timestamptz` : '') +
563
- ` AND e.id IN (SELECT c.id FROM ${ent} c WHERE c.partition = ${pPart} AND c.class = ${pCls}` +
564
- ` AND c.links @> jsonb_build_object(${p.push(step.cls.id)}::text, ${ref}))` +
565
- ` ORDER BY e.id, e.updated DESC) t` +
566
- ` 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]}`;
567
636
  block =
568
637
  `(WITH RECURSIVE d AS (` +
569
- `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` +
570
639
  ` UNION ALL ` +
571
- `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` +
572
641
  `) SELECT * FROM d) h${i}`;
573
642
  }
574
643
  blocks.push(i === 0 ? `FROM ${block}` : `JOIN LATERAL ${block} ON true`);
@@ -577,6 +646,8 @@ export function buildRead(ctx, steps, mods, mode) {
577
646
  const fromClause = blocks.join('\n');
578
647
  const last = real[steps.length - 1]; // pivot в конце → терминал на его узле
579
648
  const lastCls = steps[steps.length - 1].cls;
649
+ /** Последний шаг полиморфен (есть потомки и не снят .exact()) — уникальность по (class, id). */
650
+ const lastPoly = !steps[steps.length - 1].exactClass && lastCls.descendants.length > 0;
580
651
  // ключи шагов в путях: alias → имя вызова; pivot узла не добавляет; дубликаты — _2, _3…
581
652
  const keyed = [];
582
653
  const seen = new Map();
@@ -606,8 +677,10 @@ export function buildRead(ctx, steps, mods, mode) {
606
677
  }
607
678
  case 'rows':
608
679
  case 'ids': {
609
- const inner = `SELECT DISTINCT ON (h${last}.id) h${last}.* \n${fromClause}${whereOf(tailConds)}\n` +
610
- `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`;
611
684
  const sel = mode === 'rows' ? 'to_jsonb(z) AS row' : 'z.id';
612
685
  text =
613
686
  `SELECT ${sel} FROM (${inner}) z` +
@@ -618,11 +691,13 @@ export function buildRead(ctx, steps, mods, mode) {
618
691
  }
619
692
  case 'versions': {
620
693
  // ВСЕ версии (включая tombstone) сущностей последнего шага, по возрастанию updated
621
- 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)}`;
622
697
  text =
623
698
  `SELECT to_jsonb(v) AS row FROM (${inner}) z ` +
624
- `JOIN ${ent} v ON v.partition = ${pPart} AND v.class = ${p.push(lastCls.id)} AND v.id = z.id ` +
625
- `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` +
626
701
  limitOffset;
627
702
  break;
628
703
  }
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.
@@ -95,7 +96,8 @@ export function makeTables(ctx) {
95
96
  // END_CONTRACT: accounts.purge
96
97
  async purge(id) {
97
98
  if (!ctx.account) {
98
- throw new Error('letopis: accounts.purge requires an authenticated caller — connect({ 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)');
99
101
  }
100
102
  if (id === ctx.account) {
101
103
  throw new Error('letopis: accounts.purge cannot purge the calling account itself');
@@ -222,6 +224,12 @@ export function makeTables(ctx) {
222
224
  // START_BLOCK_SCHEMA_API
223
225
  const schema = {
224
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
+ }
225
233
  // jsonb-значения через sql.json() — postgres.js кладёт как jsonb (не PG-массив/строку)
226
234
  const j = (v) => ctx.sql.json(v);
227
235
  await run(`INSERT INTO ${T(ctx, 'Schema')} (partition, id, alias, category, ancestor, attributes, meta, links, "order")
package/dist/types.d.ts CHANGED
@@ -224,20 +224,28 @@ export interface ConnectOpts {
224
224
  schema: string;
225
225
  /** Партиция данных. Default 'entity'. */
226
226
  partition?: string;
227
- /** Дефолтный account для записей. */
228
- account?: string;
229
- /** Дефолтный owner для записей. Default = account. */
230
- owner?: string;
231
227
  /** Размер пула соединений. Default 10. */
232
228
  max?: number;
233
- /** Жёсткая изоляция арендатора: чтения фильтруются по account, записи пришпилены к нему. */
229
+ /**
230
+ * Жёсткая изоляция арендатора: чтения фильтруются по account, записи пришпилены к нему.
231
+ * **Default true** (с 0.20.0). Идентичность берётся у хендла `db.as(account)`; на
232
+ * безличном (корневом) хендле чтение/запись при включённой изоляции — ошибка с подсказкой.
233
+ * Явный `.account(чужой)` на scoped-хендле — тоже ошибка.
234
+ * Выключать (`false`) осмысленно лишь для админских/сервисных подключений,
235
+ * которым нужен доступ ко всем арендаторам.
236
+ */
234
237
  enforceAccount?: boolean;
235
238
  /**
236
239
  * ACL по Resource/Rule: категории READ/WRITE/DELETE, pattern — шаблон строки Entity
237
240
  * (колонки + "$account"). Каждый шаг цепочки проверяется на READ, записи — WRITE,
238
241
  * delete — DELETE (включая классы каскада); предикат победившего правила вливается
239
- * в SQL до сортировки/лимита. Требует account. Deny-by-default.
240
- * Правила читаются один раз при 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-резолвер).
241
249
  */
242
250
  enforceAcl?: boolean;
243
251
  /** Хук на каждый запрос цепочки (метрики, лог). */
@@ -245,3 +253,18 @@ export interface ConnectOpts {
245
253
  /** Порог «медленного» запроса, мс: событие получает slow: true; без onQuery — console.warn. */
246
254
  slowMs?: number;
247
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.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  //
18
18
  // FILE: lib/src/write.ts
19
- // VERSION: 1.0.0
19
+ // VERSION: 1.1.0
20
20
  // START_MODULE_CONTRACT
21
21
  // PURPOSE: Write-side движок: валидация, резолв концов/слотов, вычисление id (v5/v7), вставка новых версий, каскадное удаление, анонимизация, исполнение планов и батчей в транзакции.
22
22
  // SCOPE: ValidationError, runPlan, executeBatch, createOp/updateOp/delOp/anonymizeOp, toRow/readRows/deepMerge, BatchPlan.
@@ -36,11 +36,13 @@
36
36
  // END_MODULE_MAP
37
37
  //
38
38
  // START_CHANGE_SUMMARY
39
- // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
39
+ // LAST_CHANGE: [v1.1.0 - resolveAccount зовёт guardScoped: при enforceAccount без scope (db.as)
40
+ // запись запрещена с внятной подсказкой; ctx.account теперь приходит из scope, не из connect.
41
+ // Ранее: Documented existing module: reverse-engineered contract + markup]
40
42
  // END_CHANGE_SUMMARY
41
43
  import { randomUUID } from 'node:crypto';
42
44
  import { uuidv5, uuidv7 } from './uuid.js';
43
- import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, purgeCallSql, isTransient, retryDelay, RETRIES, } from './sql.js';
45
+ import { buildRead, guardScoped, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, purgeCallSql, isTransient, retryDelay, RETRIES, } from './sql.js';
44
46
  import { aclDenied } from './acl.js';
45
47
  // START_CONTRACT: ValidationError
46
48
  // PURPOSE: Ошибка валидации класса, несущая список проблемных полей (issues).
@@ -215,14 +217,15 @@ function aclWrite(ctx, step, op) {
215
217
  }
216
218
  }
217
219
  const noFilter = (f) => f === undefined;
218
- /** Entity.account NOT NULL: модификатор → connect() → System-аккаунт; иначе ошибка. */
220
+ /** Entity.account NOT NULL: модификатор → scope db.as() → System-аккаунт; иначе ошибка. */
219
221
  function resolveAccount(ctx, step) {
222
+ guardScoped(ctx);
220
223
  if (ctx.enforceAccount && ctx.account && step.accountFilter && step.accountFilter !== ctx.account) {
221
224
  throw new Error(`letopis: enforceAccount is on — writes are pinned to account ${ctx.account}`);
222
225
  }
223
226
  const acc = step.accountFilter ?? ctx.account ?? ctx.systemAccount;
224
227
  if (!acc) {
225
- throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / connect({account}) or seed a System account (lib/sql/seed.auth.sql)');
228
+ throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / db.as(account) or seed a System account (lib/sql/seed.auth.sql)');
226
229
  }
227
230
  return acc;
228
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Letopis (летопись): append-only versioned entity store on TimescaleDB with dot-notation chains — every change is a new row, history is first-class (asOf, versions, watch, cascade tombstones)",
5
5
  "keywords": [
6
6
  "timescaledb",
@@ -33,20 +33,26 @@
33
33
  "import": "./dist/index.js"
34
34
  }
35
35
  },
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
36
39
  "files": [
37
40
  "dist",
38
41
  "scripts",
39
42
  "sql",
40
43
  "docker",
41
44
  "README.md",
42
- "CHANGELOG.md"
45
+ "CHANGELOG.md",
46
+ "LICENSE"
43
47
  ],
44
48
  "scripts": {
45
49
  "build": "tsc",
46
50
  "typecheck": "tsc --noEmit",
47
51
  "test": "tsx --test --test-concurrency=1 --test-force-exit test/*.test.ts",
52
+ "check:docs": "node scripts/check-docs.mjs",
53
+ "api:contract": "node scripts/gen-api-contract.mjs",
48
54
  "bench": "tsx bench/history.bench.mjs",
49
- "prepublishOnly": "npm run typecheck && npm run build"
55
+ "prepublishOnly": "npm run typecheck && npm run check:docs && npm run build"
50
56
  },
51
57
  "dependencies": {
52
58
  "fastest-validator": "^1.19.0",