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/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +430 -100
- package/dist/acl.d.ts +7 -2
- package/dist/acl.js +3 -2
- package/dist/chain.d.ts +25 -0
- package/dist/chain.js +28 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.js +45 -22
- package/dist/schema.js +48 -6
- package/dist/sql.d.ts +18 -1
- package/dist/sql.js +111 -36
- package/dist/tables.js +9 -1
- package/dist/types.d.ts +30 -7
- package/dist/types.js +40 -3
- package/dist/up.js +15 -4
- package/dist/write.js +8 -5
- package/package.json +9 -3
- package/scripts/check-docs.mjs +338 -0
- package/scripts/gen-api-contract.mjs +221 -0
- package/scripts/gen-types.mjs +238 -42
- package/sql/seed.booking.sql +5 -4
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
|
-
|
|
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-право на КАЖДЫЙ
|
|
523
|
+
// enforceAcl: READ-право на КАЖДЫЙ шаг. Шаг полиморфен, поэтому решение берётся по
|
|
524
|
+
// КОНКРЕТНОМУ классу каждой строки: запрещённые потомки выпадают из выборки, у каждого
|
|
525
|
+
// разрешённого свой row-предикат. Иначе deny на потомке обходился бы шагом по родителю.
|
|
499
526
|
if (ctx.aclDecide) {
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
541
|
-
candWhere.push(`c.links @> jsonb_build_object(${
|
|
542
|
-
outerWhere.push(`t.links @> jsonb_build_object(${
|
|
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
|
-
|
|
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 (
|
|
550
|
-
`ORDER BY
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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(
|
|
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
|
-
|
|
610
|
-
|
|
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
|
-
|
|
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 =
|
|
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 —
|
|
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
|
-
/**
|
|
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 до сортировки/лимита.
|
|
240
|
-
*
|
|
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.
|
|
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/
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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: модификатор →
|
|
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(…) /
|
|
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.
|
|
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",
|