letopis 0.19.0 → 0.20.1

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,38 @@ 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[];
271
+ /**
272
+ * Ревизия движка (`lib/sql/ddl.sql`), которую ожидает ЭТА версия либы.
273
+ *
274
+ * Зачем: `ddl.sql` растёт аддитивно внутри одной версии движка (в 0.19.0, например,
275
+ * добавились функции purge/purge_closure/purge_account). Схема, накатанная раньше, их
276
+ * НЕ получает — приложение обновляет пакет и падает сырым `PostgresError: function
277
+ * "v1.x".purge(...) does not exist` вместо внятного объяснения. `connect()` сравнивает
278
+ * ожидаемую ревизию с меткой в схеме (`COMMENT ON SCHEMA` — её ставит последняя строка
279
+ * ddl.sql) и один раз на процесс предупреждает, что и как обновить.
280
+ *
281
+ * Файл идемпотентен, поэтому аддитивные правки доезжают повторным накатом:
282
+ * `up({ upgrade: true })` либо `node db/apply.mjs --upgrade` — данные целы.
283
+ * Несовместимая правка СТРУКТУРЫ таблиц — это смена version в имени схемы (v1 → v2).
284
+ *
285
+ * Совпадение константы с маркером `-- DDL_REVISION:` в ddl.sql проверяет
286
+ * `scripts/check-docs.mjs` — иначе одно уедет без другого.
287
+ */
288
+ export declare const DDL_REVISION = 1;
289
+ /** Метка ревизии в комментарии схемы: 'letopis ddl_revision=N' → N; иначе null. */
290
+ export declare function parseDdlRevision(comment: string | null | undefined): number | null;
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,60 @@
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
89
+ // START_BLOCK_DDL_REVISION
90
+ /**
91
+ * Ревизия движка (`lib/sql/ddl.sql`), которую ожидает ЭТА версия либы.
92
+ *
93
+ * Зачем: `ddl.sql` растёт аддитивно внутри одной версии движка (в 0.19.0, например,
94
+ * добавились функции purge/purge_closure/purge_account). Схема, накатанная раньше, их
95
+ * НЕ получает — приложение обновляет пакет и падает сырым `PostgresError: function
96
+ * "v1.x".purge(...) does not exist` вместо внятного объяснения. `connect()` сравнивает
97
+ * ожидаемую ревизию с меткой в схеме (`COMMENT ON SCHEMA` — её ставит последняя строка
98
+ * ddl.sql) и один раз на процесс предупреждает, что и как обновить.
99
+ *
100
+ * Файл идемпотентен, поэтому аддитивные правки доезжают повторным накатом:
101
+ * `up({ upgrade: true })` либо `node db/apply.mjs --upgrade` — данные целы.
102
+ * Несовместимая правка СТРУКТУРЫ таблиц — это смена version в имени схемы (v1 → v2).
103
+ *
104
+ * Совпадение константы с маркером `-- DDL_REVISION:` в ddl.sql проверяет
105
+ * `scripts/check-docs.mjs` — иначе одно уедет без другого.
106
+ */
107
+ export const DDL_REVISION = 1;
108
+ /** Метка ревизии в комментарии схемы: 'letopis ddl_revision=N' → N; иначе null. */
109
+ export function parseDdlRevision(comment) {
110
+ const m = /letopis ddl_revision=(\d+)/.exec(comment ?? '');
111
+ return m ? Number(m[1]) : null;
112
+ }
113
+ // END_BLOCK_DDL_REVISION
package/dist/up.d.ts CHANGED
@@ -28,6 +28,14 @@ export interface UpOpts extends Omit<ConnectOpts, 'dsn' | 'schema'> {
28
28
  seeds?: string[] | false;
29
29
  /** Дропнуть схему и накатить заново. ДАННЫЕ СХЕМЫ ТЕРЯЮТСЯ. */
30
30
  fresh?: boolean;
31
+ /**
32
+ * Перекатить `ddl.sql` на СУЩЕСТВУЮЩУЮ схему (сиды не трогаются, данные целы).
33
+ * Так доезжают аддитивные правки движка: схема, накатанная старой либой, не имеет новых
34
+ * функций/триггеров (например `purge`/`purge_account` из 0.19.0) и падает сырым
35
+ * «function … does not exist». О расхождении предупреждает `connect()` (см. `DDL_REVISION`).
36
+ * Идемпотентно; на несуществующей схеме — обычный первый накат.
37
+ */
38
+ upgrade?: boolean;
31
39
  /** Без console.log-прогресса. */
32
40
  quiet?: boolean;
33
41
  /** Максимум ожидания готовности, мс. Default 120 000 (первый запуск: pull образа + initdb). */
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
@@ -17,7 +17,7 @@
17
17
  // END_MODULE_CONTRACT
18
18
  //
19
19
  // START_MODULE_MAP
20
- // UpOpts - опции up(): dsn, schema, version, контейнер/образ, dataDir, seeds, fresh, quiet, таймаут
20
+ // UpOpts - опции up(): dsn, schema, version, контейнер/образ, dataDir, seeds, fresh, upgrade, quiet, таймаут
21
21
  // dockerRunArgs - чистая сборка argv для `docker run` (bind/volume, trust/пароль)
22
22
  // RunCfg - конфиг запуска контейнера (порты, креды, база, dataDir)
23
23
  // up - точка входа: контейнер -> база -> схема -> готовность -> connect (возвращает EntityDb)
@@ -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 (upgrade) -> накат 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';
@@ -181,12 +184,12 @@ function tcpAlive(host, port) {
181
184
  }
182
185
  // START_CONTRACT: applySchema
183
186
  // PURPOSE: Применяет DDL-схему и сиды — при fresh дропает схему; если схемы нет, накатывает ddl.sql (+ дефолтные booking/auth или свои сиды), подставляя <SCHEMA-NAME>.
184
- // INPUTS: { dsn: string; full: string - полное имя схемы v<N>.<schema>; seeds: string[] | false | undefined - свои пути / false (без сидов) / undefined (дефолт); fresh: boolean; log: (m: string) => void }
187
+ // INPUTS: { dsn: string; full: string - полное имя схемы v<N>.<schema>; seeds: string[] | false | undefined - свои пути / false (без сидов) / undefined (дефолт); fresh: boolean; upgrade: boolean - перекатить ddl на существующую схему; log: (m: string) => void }
185
188
  // OUTPUTS: { Promise<void> }
186
189
  // SIDE_EFFECTS: читает sql-файлы; выполняет DROP/накат схемы в postgres; закрывает соединение
187
190
  // LINKS: M-UP, V-M-UP
188
191
  // END_CONTRACT: applySchema
189
- async function applySchema(dsn, full, seeds, fresh, log) {
192
+ async function applySchema(dsn, full, seeds, fresh, upgrade, log) {
190
193
  const sql = postgres(dsn, { max: 1, onnotice: () => { } });
191
194
  try {
192
195
  if (fresh) {
@@ -195,6 +198,21 @@ async function applySchema(dsn, full, seeds, fresh, log) {
195
198
  }
196
199
  const have = await sql `SELECT 1 FROM information_schema.schemata WHERE schema_name = ${full}`;
197
200
  if (have.length > 0) {
201
+ // START_BLOCK_UPGRADE_DDL
202
+ // upgrade: перекатить ТОЛЬКО ddl.sql на существующую схему. Сиды не трогаем — они
203
+ // вставляют данные, повторный прогон дал бы дубли смысла. Файл идемпотентен
204
+ // (CREATE OR REPLACE у функций, DROP IF EXISTS + CREATE у триггеров, IF NOT EXISTS
205
+ // у таблиц/индексов), поэтому данные целы, а аддитивные правки движка доезжают.
206
+ // Так закрывается разрыв: схема, накатанная старой либой, не имела новых функций
207
+ // (purge/purge_account из 0.19.0) и падала сырым «function … does not exist».
208
+ if (upgrade) {
209
+ const text = (await readFile(pkgPath('../sql/ddl.sql'), 'utf8')).replaceAll('<SCHEMA-NAME>', full);
210
+ await sql.unsafe(text);
211
+ await sql.unsafe(`ANALYZE ${qi(full)}."Entity"`);
212
+ log(`schema "${full}" upgraded (ddl re-applied, data intact)`);
213
+ return;
214
+ }
215
+ // END_BLOCK_UPGRADE_DDL
198
216
  log(`schema "${full}" already exists — apply skipped`);
199
217
  return;
200
218
  }
@@ -207,7 +225,15 @@ async function applySchema(dsn, full, seeds, fresh, log) {
207
225
  const text = (await readFile(f, 'utf8')).replaceAll('<SCHEMA-NAME>', full);
208
226
  await sql.unsafe(text); // multi-statement simple query
209
227
  }
210
- log(`schema "${full}" applied (${files.length} files)`);
228
+ // START_BLOCK_ANALYZE_ENTITY
229
+ // ANALYZE обязателен: Entity — гипертаблица, у РОДИТЕЛЯ своих строк нет, и без статистики
230
+ // планировщик берёт дефолт «200 уникальных id». Ядро всех чтений либы — semi-join с
231
+ // подзапросом кандидатов (`e.id IN (SELECT c.id …)`), и на оценке 200 вместо сотен тысяч
232
+ // он выбирает Nested Loop: на полигоне 980k это давало 57 с вместо 3 с (×18).
233
+ // Стоит копейки на свежей схеме; после МАССОВОЙ заливки данных ANALYZE нужно повторить.
234
+ await sql.unsafe(`ANALYZE ${qi(full)}."Entity"`);
235
+ // END_BLOCK_ANALYZE_ENTITY
236
+ log(`schema "${full}" applied (${files.length} files, statistics collected)`);
211
237
  }
212
238
  finally {
213
239
  await sql.end();
@@ -215,13 +241,13 @@ async function applySchema(dsn, full, seeds, fresh, log) {
215
241
  }
216
242
  // START_CONTRACT: up
217
243
  // PURPOSE: Единая точка входа dev-bootstrap — валидирует схему/версию, при живом postgres пропускает docker (иначе поднимает контейнер и ждёт готовности PG+Redis), применяет схему и подключается.
218
- // INPUTS: { opts: UpOpts - dsn, schema, version, контейнер/образ, dataDir, redisPort, seeds, fresh, quiet, waitTimeoutMs + прочие ConnectOpts }
244
+ // INPUTS: { opts: UpOpts - dsn, schema, version, контейнер/образ, dataDir, redisPort, seeds, fresh, upgrade, quiet, waitTimeoutMs + прочие ConnectOpts }
219
245
  // OUTPUTS: { Promise<EntityDb> - подключённый фасад на схеме "v<version>.<schema>" }
220
246
  // SIDE_EFFECTS: shell docker через M-UP.ensureContainer; TCP-пинги; чтение sql и накат схемы; console.log с префиксом [letopis.up]; connect (M-CONNECT); бросает при плохом имени схемы/версии и таймауте готовности
221
247
  // LINKS: M-UP, V-M-UP
222
248
  // END_CONTRACT: up
223
249
  export async function up(opts) {
224
- const { dsn = DEFAULT_DSN, schema, version, container = 'letopis-timescale', image = 'letopis-db', dataDir, redisPort = 16379, seeds, fresh, quiet, waitTimeoutMs = 120_000, ...connectRest } = opts;
250
+ const { dsn = DEFAULT_DSN, schema, version, container = 'letopis-timescale', image = 'letopis-db', dataDir, redisPort = 16379, seeds, fresh, upgrade, quiet, waitTimeoutMs = 120_000, ...connectRest } = opts;
225
251
  if (!/^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(schema))
226
252
  throw new Error(`letopis.up: bad schema name "${schema}" (базовое имя без версии и точек; версию задаёт version)`);
227
253
  if (!Number.isInteger(version) || version < 1)
@@ -284,7 +310,7 @@ export async function up(opts) {
284
310
  // END_BLOCK_WAIT_READY
285
311
  }
286
312
  // START_BLOCK_APPLY_SCHEMA
287
- await applySchema(dsn, full, seeds, fresh ?? false, log);
313
+ await applySchema(dsn, full, seeds, fresh ?? false, upgrade ?? false, log);
288
314
  const db = await connect({ ...connectRest, dsn, schema: full });
289
315
  log(`connected (schema "${full}")`);
290
316
  // END_BLOCK_APPLY_SCHEMA
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
  }