letopis 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acl.d.ts CHANGED
@@ -33,7 +33,11 @@ export interface AclApi {
33
33
  checkData(account: string | {
34
34
  id: string;
35
35
  }, className: string, op: AclOp): Promise<AclDecision>;
36
- /** Сбросить кэш Resource/Rule фасада (enforceAcl-цепочки перечитывают только новым connect). */
36
+ /**
37
+ * Сбросить кэш Resource/Rule ЭТОГО фасада (db.acl.check/checkData).
38
+ * NB: энфорсер цепочек под `enforceAcl` — отдельная подсистема; его пересобирает
39
+ * `db.reloadSchema()` (реестр + ACL-резолвер), а не этот reload().
40
+ */
37
41
  reload(): void;
38
42
  }
39
43
  interface AclSource {
@@ -41,7 +45,8 @@ interface AclSource {
41
45
  rules: Rule[];
42
46
  }
43
47
  export declare function makeAcl(tables: Tables, registry: Registry): AclApi;
44
- /** Синхронный резолвер для цепочек: категории субъекта и правила зафиксированы на connect. */
48
+ /** Синхронный резолвер для цепочек: категории субъекта и правила — снимок на момент
49
+ * компиляции (connect либо db.reloadSchema(), который пересобирает энфорсер). */
45
50
  export declare function compileEnforcer(src: AclSource, account: string, cats: string[], registry: Registry): (cls: ClassDef, op: AclOp) => AclDecision;
46
51
  /** Читаемая ошибка отказа для цепочек. */
47
52
  export declare function aclDenied(op: AclOp, clsId: string, d: AclDecision): Error;
package/dist/acl.js CHANGED
@@ -232,13 +232,14 @@ export function makeAcl(tables, registry) {
232
232
  }
233
233
  // --- enforce-компилятор (connect({enforceAcl: true})) ----------------------------
234
234
  // START_CONTRACT: compileEnforcer
235
- // PURPOSE: Скомпилировать синхронный энфорсер (ClassDef, op) → AclDecision с мемоизацией — субъект и правила зафиксированы на connect.
235
+ // PURPOSE: Скомпилировать синхронный энфорсер (ClassDef, op) → AclDecision с мемоизацией — субъект и правила снимаются В МОМЕНТ вызова (на connect либо на db.reloadSchema()).
236
236
  // INPUTS: { src: AclSource; account: string; cats: string[]; registry: Registry }
237
237
  // OUTPUTS: { (cls: ClassDef, op: AclOp) => AclDecision }
238
238
  // SIDE_EFFECTS: none (мемо-кэш решений)
239
239
  // LINKS: M-ACL, V-M-ACL, M-SQL, M-WRITE
240
240
  // END_CONTRACT: compileEnforcer
241
- /** Синхронный резолвер для цепочек: категории субъекта и правила зафиксированы на connect. */
241
+ /** Синхронный резолвер для цепочек: категории субъекта и правила — снимок на момент
242
+ * компиляции (connect либо db.reloadSchema(), который пересобирает энфорсер). */
242
243
  export function compileEnforcer(src, account, cats, registry) {
243
244
  // START_BLOCK_ENFORCER_MEMO
244
245
  const subjects = subjectAliases(src.resources, cats);
package/dist/chain.d.ts CHANGED
@@ -33,10 +33,23 @@ export interface ChainCore {
33
33
  asOf(t: string | Date): Chain;
34
34
  /** Keyset-пагинация: строго после курсора (см. cursorOf). Требует .sort(). */
35
35
  after(cursor: import('./types.js').Cursor): Chain;
36
+ /**
37
+ * Включить в выдачу последнего шага удалённые (tombstone). Снимает ТОЛЬКО фильтр deleted —
38
+ * изоляция арендатора (enforceAccount) и ACL (enforceAcl) остаются в силе.
39
+ */
40
+ withDeleted(): Chain;
36
41
  /** ВСЕ версии сущностей последнего шага (включая tombstone → $deleted), по возрастанию updated. */
37
42
  versions(): Promise<Row[]>;
38
43
  /** Рекурсивный self-обход: дети любой глубины (тот же класс), $depth в Row. */
39
44
  deep(max?: number): Chain;
45
+ /**
46
+ * Только ЭТОТ класс, без классов-потомков. По умолчанию шаг полиморфен: родитель отдаёт
47
+ * объединение с потомками (`db.Контрагент()` → Мастера + Клиенты). `.exact()` нужен, когда
48
+ * наследование в домене — переиспользование attributes, а не «is-a» для выборки:
49
+ * в демо `запись` наследует `окно`, но смена ≠ бронь, поэтому «смены мастера» —
50
+ * `db.Мастер(id).окно().exact()`.
51
+ */
52
+ exact(): Chain;
40
53
  /** Агрегации по полю последнего шага ('data.<путь>'), считает БД. */
41
54
  sum(field: string): Promise<number | null>;
42
55
  avg(field: string): Promise<number | null>;
@@ -89,6 +102,15 @@ export interface ChainCore {
89
102
  delete(opts?: {
90
103
  confirm?: boolean;
91
104
  }): Chain;
105
+ /**
106
+ * ФИЗИЧЕСКИЙ hard-erase (необратимо): сносит УЖЕ логически удалённые (tombstone) цели + всё
107
+ * поддерево по links (все версии). Живую сущность не трогает — сначала .delete(). { confirm: true }
108
+ * — стирает; без confirm — превью замыкания (что сотрётся), БД не тронута. Историю НЕ сохраняет
109
+ * (в отличие от .delete()). Реализуется серверной purge() (SET LOCAL letopis.purge отключает триггер).
110
+ */
111
+ purge(opts?: {
112
+ confirm?: boolean;
113
+ }): Chain;
92
114
  }
93
115
  /**
94
116
  * Свойство-класс на цепочке:
@@ -111,6 +133,23 @@ export type Chain = ChainCore & {
111
133
  [className: string]: StepProp;
112
134
  };
113
135
  export interface DbCore {
136
+ /**
137
+ * Идентичность ВЫЗОВА: хендл того же пула, работающий от имени account.
138
+ * Арендатор — свойство вызова, а не подключения (в connect() опции account нет):
139
+ *
140
+ * const db = await connect({ dsn, schema }) // пул, безличный
141
+ * const t = await db.as(req.accountId) // кто именно делает этот вызов
142
+ * await t.Клиент().rows() // изолировано по account
143
+ *
144
+ * При enforceAccount (default) чтение/запись возможны только с такого хендла.
145
+ * При enforceAcl энфорсер компилится под ЭТОГО субъекта (у каждого scope свой).
146
+ * owner по умолчанию = account; переопределяется вторым аргументом.
147
+ */
148
+ as(account: string | {
149
+ id: string;
150
+ }, opts?: {
151
+ owner?: string;
152
+ }): Promise<EntityDb>;
114
153
  /** Открыть транзакцию: tr — тот же API + commit/rollback/lock. */
115
154
  begin(): Promise<EntityTx>;
116
155
  commit(tr: EntityTx): Promise<void>;
@@ -126,6 +165,11 @@ export interface DbCore {
126
165
  watch(cb: (e: WatchEvent) => void, opts?: WatchOpts): Promise<() => void>;
127
166
  watch(cls: string, cb: (e: WatchEvent) => void, opts?: WatchOpts): Promise<() => void>;
128
167
  close(): Promise<void>;
168
+ /**
169
+ * Перечитать определения классов из таблицы Schema (после правки её простым SQL / db.schema.define):
170
+ * пересобирает registry и, при enforceAcl, ACL-резолвер — без реконнекта. Подхват «сразу».
171
+ */
172
+ reloadSchema(): Promise<void>;
129
173
  registry: Registry;
130
174
  /** Голый postgres-клиент (тесты, EXPLAIN). */
131
175
  sql: Ctx['sql'];
@@ -134,6 +178,8 @@ export interface DbCore {
134
178
  credentials: Tables['credentials'];
135
179
  resources: Tables['resources'];
136
180
  rules: Tables['rules'];
181
+ /** Определения классов: db.schema.define(def) — upsert в таблицу Schema + reloadSchema(). */
182
+ schema: Tables['schema'];
137
183
  /** Вход по кредам (пароль/api-key/key-secret/внешние identity) + сессии в Redis. */
138
184
  auth: AuthApi;
139
185
  /** ACL по Resource/Rule: check(эндпоинт) / checkData(класс, READ|WRITE|DELETE) / reload. */
package/dist/chain.js CHANGED
@@ -354,8 +354,13 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
354
354
  return (field, dir) => withMods({ order: field, desc: dir === true || dir === 'desc' });
355
355
  case 'asOf':
356
356
  return (t) => withMods({ asOf: t instanceof Date ? t.toISOString() : t });
357
+ case 'withDeleted':
358
+ return () => withMods({ withDeleted: true });
357
359
  case 'deep':
358
360
  return (max = 32) => withLast({ deepMax: max });
361
+ case 'exact':
362
+ // снять полиморфизм ТЕКУЩЕГО шага: только свой класс, без потомков
363
+ return () => withLast({ exactClass: true });
359
364
  // END_BLOCK_READ_MODIFIERS
360
365
  // START_BLOCK_AGGREGATIONS
361
366
  case 'sum':
@@ -434,6 +439,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
434
439
  return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, mods });
435
440
  case 'anonymize':
436
441
  return (fields) => withOp({ kind: 'anonymize', fields, mods });
442
+ case 'purge':
443
+ return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true, mods });
437
444
  // END_BLOCK_WRITE_TERMINALS
438
445
  // START_BLOCK_COLUMN_MODS
439
446
  case 'alias':
@@ -444,8 +451,10 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
444
451
  return (v) => withLast({ accountFilter: typeof v === 'object' ? v.id : v });
445
452
  case 'owner':
446
453
  return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
447
- case 'link':
448
- throw new Error('letopis: .link() removed (0.15.0) — use the link slot: .Класс.set(target)');
454
+ // NB: охранника `case 'link'` здесь больше нет (снят в 0.20.0). Он затенял РЕАЛЬНЫЙ
455
+ // класс схемы: в демо-домене `link` — абстрактный корень всех связок, и шаг
456
+ // db.Клиент(c).link() падал migration-ошибкой вместо обхода. Обратная совместимость
457
+ // со снесённым в 0.15.0 методом .link() принесена в жертву достижимости класса.
449
458
  // END_BLOCK_COLUMN_MODS
450
459
  }
451
460
  // START_BLOCK_CLASS_RESOLVE
@@ -512,7 +521,7 @@ export function makeDb(ctx, root) {
512
521
  if (typeof prop === 'symbol' || prop === 'then')
513
522
  return undefined;
514
523
  // START_BLOCK_TABLES_AUTH_ACL
515
- if (prop === 'accounts' || prop === 'credentials' || prop === 'resources' || prop === 'rules') {
524
+ if (prop === 'accounts' || prop === 'credentials' || prop === 'resources' || prop === 'rules' || prop === 'schema') {
516
525
  tables ??= makeTables(ctx);
517
526
  return tables[prop];
518
527
  }
@@ -528,6 +537,27 @@ export function makeDb(ctx, root) {
528
537
  }
529
538
  // END_BLOCK_TABLES_AUTH_ACL
530
539
  switch (prop) {
540
+ // START_BLOCK_SCOPE_AS
541
+ // db.as(account): тот же пул, но вызов от имени этого арендатора. Дешёвый клон ctx
542
+ // (как beginTx), НЕ новое соединение. Батчи не наследуются: очередь планов, общая
543
+ // для разных арендаторов, — это утечка записи между ними.
544
+ case 'as':
545
+ return async (a, o) => {
546
+ const account = typeof a === 'object' ? a?.id : a;
547
+ if (!account)
548
+ throw new Error('letopis: db.as(account) requires an account id (uuid or { id })');
549
+ const scoped = { ...ctx, account, owner: o?.owner, aclDecide: undefined };
550
+ // reloadSchema() со scope: реестр перечитывает КОРЕНЬ (единый источник), затем
551
+ // scope подхватывает его и перекомпилирует свой энфорсер под своего субъекта.
552
+ scoped.reload = async () => {
553
+ await ctx.reload?.();
554
+ scoped.registry = ctx.registry;
555
+ await ctx.compileAcl?.(scoped);
556
+ };
557
+ await ctx.compileAcl?.(scoped);
558
+ return makeDb(scoped);
559
+ };
560
+ // END_BLOCK_SCOPE_AS
531
561
  // START_BLOCK_TX_LOCK
532
562
  case 'begin':
533
563
  return async () => makeDb(await beginTx(ctx), state);
@@ -607,6 +637,11 @@ export function makeDb(ctx, root) {
607
637
  // START_BLOCK_CLOSE_META
608
638
  case 'close':
609
639
  return () => ctx.sql.end();
640
+ case 'reloadSchema':
641
+ return async () => {
642
+ await ctx.reload?.();
643
+ acl = undefined; // db.acl-фасад перестроится на свежем registry при следующем доступе
644
+ };
610
645
  case 'registry':
611
646
  return ctx.registry;
612
647
  case 'sql':
package/dist/index.d.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * letopis: dot-цепочки над append-only Entity-хранилищем (TimescaleDB).
3
3
  *
4
- * const db = await connect({ dsn, schema: 'booking' })
5
- * await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run()
4
+ * schema — ПОЛНОЕ имя PG-схемы С версией движка ("v1.booking"); префикс либа НЕ достраивает
5
+ * (создаёт up({ schema: 'booking', version: 1 }) либо db/apply.mjs). Шаг цепочки — id ИЛИ
6
+ * alias класса из таблицы Schema (db.Staff ≡ db.Мастер).
7
+ *
8
+ * const db = await connect({ dsn, schema: 'v1.booking' }) // безличный пул
9
+ * const t = await db.as(accountId) // арендатор ВЫЗОВА
10
+ * await t.Мастер({ name: 'Вася' }).навык().Услуга().run()
6
11
  */
7
12
  import { type EntityDb } from './chain.js';
8
13
  import type { ConnectOpts } from './types.js';
@@ -15,7 +20,7 @@ export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
15
20
  export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
16
21
  export type { Row, Path, Filter, ChainMods, Cursor, ConnectOpts, QueryEvent, Account, Credential, Resource, Rule, AclOp, AclDecision, } from './types.js';
17
22
  export type { EntityDb, EntityTx, Chain, Batch, WatchEvent, WatchOpts } from './chain.js';
18
- export type { Tables, AccountsApi, CredentialsApi, ResourcesApi, RulesApi } from './tables.js';
23
+ export type { Tables, AccountsApi, CredentialsApi, ResourcesApi, RulesApi, SchemaApi } from './tables.js';
19
24
  export { totpCode } from './auth.js';
20
25
  export type { AuthApi, AuthResult } from './auth.js';
21
26
  export type { AclApi } from './acl.js';
package/dist/index.js CHANGED
@@ -1,11 +1,16 @@
1
1
  /**
2
2
  * letopis: dot-цепочки над append-only Entity-хранилищем (TimescaleDB).
3
3
  *
4
- * const db = await connect({ dsn, schema: 'booking' })
5
- * await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run()
4
+ * schema — ПОЛНОЕ имя PG-схемы С версией движка ("v1.booking"); префикс либа НЕ достраивает
5
+ * (создаёт up({ schema: 'booking', version: 1 }) либо db/apply.mjs). Шаг цепочки — id ИЛИ
6
+ * alias класса из таблицы Schema (db.Staff ≡ db.Мастер).
7
+ *
8
+ * const db = await connect({ dsn, schema: 'v1.booking' }) // безличный пул
9
+ * const t = await db.as(accountId) // арендатор ВЫЗОВА
10
+ * await t.Мастер({ name: 'Вася' }).навык().Услуга().run()
6
11
  */
7
12
  // FILE: lib/src/index.ts
8
- // VERSION: 1.0.0
13
+ // VERSION: 1.2.0
9
14
  // START_MODULE_CONTRACT
10
15
  // PURPOSE: Публичная точка входа пакета — открыть соединение (реестр, опциональный ACL, сборка db) и ре-экспорт публичной поверхности; хелпер курсора.
11
16
  // SCOPE: connect, cursorOf + barrel-реэкспорты
@@ -22,7 +27,10 @@
22
27
  // END_MODULE_MAP
23
28
  //
24
29
  // START_CHANGE_SUMMARY
25
- // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
30
+ // LAST_CHANGE: [v1.2.0 - BREAKING: opts.account/opts.owner сняты — подключение безличное,
31
+ // арендатора и субъекта ACL называет db.as(account, { owner }). Компиляция энфорсера
32
+ // вынесена в ctx.compileAcl(ctx-scope), ctx.reload обновляет только реестр.
33
+ // Ранее: enforceAccount по умолчанию true; предупреждение при выключенном enforceAcl]
26
34
  // END_CHANGE_SUMMARY
27
35
  import postgres from 'postgres';
28
36
  import { loadRegistry } from './schema.js';
@@ -30,12 +38,14 @@ import { makeDb } from './chain.js';
30
38
  import { makeTables } from './tables.js';
31
39
  import { compileEnforcer } from './acl.js';
32
40
  // START_CONTRACT: connect
33
- // PURPOSE: Открыть соединение: postgres-пул, реестр схемы, System-аккаунт, опциональный ACL — и собрать EntityDb.
34
- // INPUTS: { opts: ConnectOpts { dsn, schema, partition?='entity', max?=10, account?, owner?, enforceAcl?, enforceAccount?, onQuery?, slowMs? } }
35
- // OUTPUTS: { Promise<EntityDb> - dot-цепочный db }
36
- // SIDE_EFFECTS: открывает пул postgres (timestamptz строкой), читает реестр и System-аккаунт; при enforceAcl без account бросает 'letopis: enforceAcl requires connect({ account })', при ненайденном — 'letopis: enforceAcl — account "…" not found', иначе читает resources/rules и компилит aclDecide; NB: ESM-цикл M-CONNECT↔M-UP (up ре-экспортится здесь)
41
+ // PURPOSE: Открыть БЕЗЛИЧНОЕ соединение: postgres-пул, реестр схемы, System-аккаунт, хук компиляции ACL — и собрать EntityDb (арендатора вызова называет db.as()).
42
+ // INPUTS: { opts: ConnectOpts { dsn, schema, partition?='entity', max?=10, enforceAcl?, enforceAccount?=true, onQuery?, slowMs? } }
43
+ // OUTPUTS: { Promise<EntityDb> - dot-цепочный db без идентичности; db.as(account) даёт scoped-хендл }
44
+ // SIDE_EFFECTS: открывает пул postgres (timestamptz строкой), читает реестр и System-аккаунт; ставит ctx.compileAcl (зовётся из db.as: читает resources/rules и компилит aclDecide под субъекта scope; при ненайденном аккаунте — 'letopis: enforceAcl — account "…" not found'); без enforceAcl печатает предупреждение один раз на процесс; NB: ESM-цикл M-CONNECT↔M-UP (up ре-экспортится здесь)
37
45
  // LINKS: M-CONNECT, V-M-CONNECT, M-SCHEMA, M-CHAIN, M-TABLES, M-ACL, M-SQL
38
46
  // END_CONTRACT: connect
47
+ /** Предупреждение об отключённом enforceAcl печатается один раз на процесс. */
48
+ let warnedNoAcl = false;
39
49
  export async function connect(opts) {
40
50
  // timestamptz — строкой (JS Date режет микросекунды → ломал бы asOf/cursorOf по updated)
41
51
  const sql = postgres(opts.dsn, {
@@ -52,28 +62,49 @@ export async function connect(opts) {
52
62
  registry,
53
63
  pgSchema: opts.schema,
54
64
  partition,
55
- account: opts.account,
56
- owner: opts.owner,
65
+ // account/owner тут НЕ задаются: подключение безличное, арендатора называет db.as().
57
66
  systemAccount: sys[0]?.id,
58
- enforceAccount: opts.enforceAccount,
67
+ // Изоляция арендатора ВКЛЮЧЕНА по умолчанию (0.20.0): забыть её было слишком легко,
68
+ // а цена забывчивости — чужие строки в выдаче. Без идентичности вызова (корневой
69
+ // хендл) чтение/запись при ней запрещены — см. guardScoped в sql.ts.
70
+ enforceAccount: opts.enforceAccount ?? true,
59
71
  onQuery: opts.onQuery,
60
72
  slowMs: opts.slowMs,
61
73
  };
62
74
  // START_BLOCK_ENFORCE_ACL
63
- // enforceAcl: правила и категории субъекта фиксируются на connect (перечитка — новый connect)
64
- if (opts.enforceAcl) {
65
- if (!opts.account)
66
- throw new Error('letopis: enforceAcl requires connect({ account })');
67
- const tables = makeTables(ctx);
75
+ // enforceAcl: правила и категории субъекта компилятся в резолвер ПОД КОНКРЕТНЫЙ scope.
76
+ // Функция живёт на ctx, потому что зовут её двое: db.as() (новый субъект) и reload
77
+ // (тот же субъект на свежем реестре). Субъект берётся из c.account, т.е. из scope.
78
+ ctx.compileAcl = async (c) => {
79
+ if (!opts.enforceAcl)
80
+ return;
81
+ if (!c.account)
82
+ throw new Error('letopis: enforceAcl is on — call db.as(account) to name the subject');
83
+ const tables = makeTables(c);
68
84
  const [account, resources, rules] = await Promise.all([
69
- tables.accounts.get(opts.account),
85
+ tables.accounts.get(c.account),
70
86
  tables.resources.find(),
71
87
  tables.rules.find({ enabled: true }),
72
88
  ]);
73
89
  if (!account)
74
- throw new Error(`letopis: enforceAcl — account "${opts.account}" not found`);
75
- ctx.aclDecide = compileEnforcer({ resources, rules }, account.id, account.categories, registry);
90
+ throw new Error(`letopis: enforceAcl — account "${c.account}" not found`);
91
+ c.aclDecide = compileEnforcer({ resources, rules }, account.id, account.categories, c.registry);
92
+ };
93
+ // enforceAcl остаётся OPT-IN: включить его по умолчанию нельзя (он работает
94
+ // deny-by-default, т.е. без настроенных Resource/Rule выдача стала бы пустой).
95
+ // Поэтому — предупреждение: молчаливое отсутствие авторизации хуже шумного.
96
+ // Один раз на процесс, иначе утонет в логах приложения с пулом подключений.
97
+ if (!opts.enforceAcl && !warnedNoAcl) {
98
+ warnedNoAcl = true;
99
+ console.warn('letopis: enforceAcl is off — Resource/Rule are NOT checked, reads and writes pass ' +
100
+ 'authorization unconditionally. For production use connect({ enforceAcl: true }) + db.as(account).');
76
101
  }
102
+ // db.reloadSchema(): перечитать определения классов из таблицы Schema без реконнекта.
103
+ // На корневом хендле обновляется только реестр (субъекта тут нет); scoped-хендлы
104
+ // подхватывают его и перекомпилируют свой энфорсер сами (chain.ts, case 'as').
105
+ ctx.reload = async () => {
106
+ ctx.registry = await loadRegistry(sql, opts.schema, partition);
107
+ };
77
108
  // END_BLOCK_ENFORCE_ACL
78
109
  return makeDb(ctx);
79
110
  }
package/dist/schema.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  //
11
11
  // FILE: lib/src/schema.ts
12
- // VERSION: 1.0.0
12
+ // VERSION: 1.1.0
13
13
  // START_MODULE_CONTRACT
14
14
  // PURPOSE: Строит реестр классов из таблицы Schema — резолв наследования/link-ends, типы полей, компиляция валидаторов.
15
15
  // SCOPE: Registry (add/find/resolve/has), loadRegistry, fieldTypeOf; локальные parseEnd/parseIdGen/buildDef.
@@ -26,9 +26,11 @@
26
26
  // END_MODULE_MAP
27
27
  //
28
28
  // START_CHANGE_SUMMARY
29
- // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
29
+ // LAST_CHANGE: [v1.1.0 - loadRegistry: 42P01/3F000 перехватываются и заменяются адресной ошибкой
30
+ // (передано базовое имя вместо "vN.имя") со списком letopis-схем — паритет с валидацией up()]
30
31
  // END_CHANGE_SUMMARY
31
32
  import { createRequire } from 'node:module';
33
+ import { reservedNamesOf } from './types.js';
32
34
  const Validator = createRequire(import.meta.url)('fastest-validator');
33
35
  const v = new Validator({ useNewCustomCheckerFunction: true });
34
36
  export class Registry {
@@ -288,15 +290,34 @@ function buildDef(row, byId) {
288
290
  // PURPOSE: Прочитать классы партиции из таблицы Schema и построить Registry со скомпилированными check.
289
291
  // INPUTS: { sql: postgres.Sql; pgSchema: string; partition: string }
290
292
  // OUTPUTS: { Promise<Registry> - реестр всех классов партиции }
291
- // SIDE_EFFECTS: SELECT из "<pgSchema>"."Schema"
292
- // ERRORS: schema has no classes for partition
293
+ // SIDE_EFFECTS: SELECT из "<pgSchema>"."Schema"; на 42P01/3F000 — доп. SELECT списка схем для подсказки
294
+ // ERRORS: schema has no classes for partition; schema has no "Schema" table (передано базовое имя вместо "vN.имя")
293
295
  // LINKS: M-SCHEMA, V-M-SCHEMA, M-DDL
294
296
  // END_CONTRACT: loadRegistry
297
+ /** Уже предупреждённые «схема:класс» — чтобы не повторять на каждый connect/reloadSchema. */
298
+ const warnedReserved = new Set();
295
299
  export async function loadRegistry(sql, pgSchema, partition) {
296
300
  // START_BLOCK_LOAD_QUERY
297
301
  const ident = `"${pgSchema.replace(/"/g, '""')}"`;
298
- const rows = (await sql.unsafe(`SELECT id, alias, category, ancestor, attributes, links, meta, "order", ancestors, descendants
299
- FROM ${ident}."Schema" WHERE partition = $1 ORDER BY category, "order"`, [partition]));
302
+ let rows;
303
+ try {
304
+ rows = (await sql.unsafe(`SELECT id, alias, category, ancestor, attributes, links, meta, "order", ancestors, descendants
305
+ FROM ${ident}."Schema" WHERE partition = $1 ORDER BY category, "order"`, [partition]));
306
+ }
307
+ catch (e) {
308
+ // 42P01 undefined_table / 3F000 invalid_schema_name — типовая ошибка: передали БАЗОВОЕ имя
309
+ // ('booking') вместо полного с версией ('v1.booking'). Подсказываем вместо сырой ошибки PG.
310
+ const code = e.code;
311
+ if (code !== '42P01' && code !== '3F000')
312
+ throw e;
313
+ // перечисляем ТОЛЬКО схемы letopis (те, где есть таблица Schema), а не все namespace БД
314
+ const found = (await sql.unsafe(`SELECT table_schema AS s FROM information_schema.tables
315
+ WHERE table_name = 'Schema' ORDER BY 1`));
316
+ throw new Error(`letopis: schema "${pgSchema}" has no "Schema" table — connect({ schema }) takes the FULL ` +
317
+ `PG-schema name WITH the engine version ("v1.booking"); the library adds no prefix. ` +
318
+ `letopis schemas here: ${found.map((r) => r.s).join(', ') || '(none)'}. ` +
319
+ `Create one with up({ schema, version }) or db/apply.mjs --schema=… --version=…`);
320
+ }
300
321
  if (!rows.length) {
301
322
  throw new Error(`letopis: schema "${pgSchema}" has no classes for partition "${partition}"`);
302
323
  }
@@ -305,5 +326,26 @@ export async function loadRegistry(sql, pgSchema, partition) {
305
326
  const registry = new Registry();
306
327
  for (const row of rows)
307
328
  registry.add(buildDef(row, byId));
329
+ // START_BLOCK_RESERVED_WARN
330
+ // Имя, перехватываемое Proxy до резолва класса, делает шаг недостижимым ПОД ЭТИМ именем
331
+ // (второе имя класса, если оно свободно, работает). Предупреждаем, а НЕ бросаем: демо-сид
332
+ // содержит класс id "link", и throw уронил бы up() на штатной схеме.
333
+ // Один раз на (схема, класс) за процесс: loadRegistry зовётся на каждый connect и
334
+ // reloadSchema — иначе штатный прогон утонул бы в повторах.
335
+ for (const def of registry.all) {
336
+ const clash = reservedNamesOf(def);
337
+ if (!clash.length)
338
+ continue;
339
+ const seen = `${pgSchema}:${def.id}`;
340
+ if (warnedReserved.has(seen))
341
+ continue;
342
+ warnedReserved.add(seen);
343
+ const free = [def.id, def.alias].filter((n) => !clash.includes(n));
344
+ console.warn(`letopis: class name ${clash.map((n) => `"${n}"`).join(' / ')} is reserved by the chain API — ` +
345
+ (free.length
346
+ ? `use ${free.map((n) => `"${n}"`).join(' / ')} for chain steps`
347
+ : 'this class is unreachable as a chain step; rename it'));
348
+ }
349
+ // END_BLOCK_RESERVED_WARN
308
350
  return registry;
309
351
  }
package/dist/sql.d.ts CHANGED
@@ -25,14 +25,22 @@ export interface Ctx {
25
25
  registry: Registry;
26
26
  pgSchema: string;
27
27
  partition: string;
28
+ /** Арендатор ВЫЗОВА: заполнен у scoped-хендла db.as(account), пуст у корневого. */
28
29
  account?: string;
30
+ /** Дефолтный owner записей этого scope (db.as(account, { owner })). Default = account. */
29
31
  owner?: string;
30
32
  /** System-аккаунт — fallback для NOT NULL Entity.account (загружается при connect). */
31
33
  systemAccount?: string;
32
34
  /** Жёсткая изоляция арендатора: все чтения фильтруются, записи пришпилены к account. */
33
35
  enforceAccount?: boolean;
34
- /** enforceAcl: скомпилированный на connect резолвер Rule/Resource (READ/WRITE/DELETE). */
36
+ /** enforceAcl: скомпилированный резолвер Rule/Resource (READ/WRITE/DELETE) для account ЭТОГО ctx. */
35
37
  aclDecide?: (cls: ClassDef, op: AclOp) => AclDecision;
38
+ /**
39
+ * enforceAcl: скомпилировать энфорсер под субъекта переданного ctx (пишет в c.aclDecide).
40
+ * Ставит connect(); зовут db.as() и reload. Один энфорсер = один субъект: memo в acl.ts
41
+ * ключуется классом+операцией БЕЗ аккаунта, переиспользование между scope — утечка решения.
42
+ */
43
+ compileAcl?: (c: Ctx) => Promise<void>;
36
44
  /** true внутри db.begin()-транзакции (write не оборачивает в begin повторно). */
37
45
  inTx?: boolean;
38
46
  /** true только у db.begin()-транзакций (не у внутренних): управляет подсказкой при 40P01/40001. */
@@ -41,6 +49,8 @@ export interface Ctx {
41
49
  onQuery?: (e: QueryEvent) => void;
42
50
  /** Порог «медленного» запроса, мс (без onQuery — console.warn). */
43
51
  slowMs?: number;
52
+ /** Перечитать определения классов из таблицы Schema: пересобрать registry (+ enforcer при enforceAcl). Ставит connect(). */
53
+ reload?: () => Promise<void>;
44
54
  }
45
55
  /** Транзиентные ошибки PG — гонка, снимаемая повтором: deadlock / serialization failure. */
46
56
  export declare function isTransient(e: unknown): boolean;
@@ -76,6 +86,13 @@ export interface Step {
76
86
  pivotKey?: number;
77
87
  /** .alias(name) — ключ шага в путях. */
78
88
  aliasKey?: string;
89
+ /**
90
+ * .exact() — только ЭТОТ класс, без классов-потомков. По умолчанию шаг полиморфен
91
+ * (родитель отдаёт объединение с потомками); нужно, когда наследование в домене
92
+ * использовано для переиспользования attributes, а не как «is-a» для выборки
93
+ * (демо: `запись` наследует `окно`, но смена ≠ бронь).
94
+ */
95
+ exactClass?: boolean;
79
96
  /** .tags(…) — фильтр по колонке tags: строка | string[] (все) | has/hasAny/hasAll. */
80
97
  tagsFilter?: unknown;
81
98
  /** .account(uuid|Row) — фильтр по колонке account. */
@@ -114,6 +131,8 @@ export interface BuiltQuery {
114
131
  /** Ключи шагов в путях (после alias/dedupe). */
115
132
  keys: string[];
116
133
  }
134
+ /** Корневой хендл при enforceAccount читать/писать не может: арендатора называет db.as(). */
135
+ export declare function guardScoped(ctx: Ctx): void;
117
136
  /** Построить читающий запрос по цепочке. */
118
137
  export declare function buildRead(ctx: Ctx, steps: Step[], mods: ChainMods, mode: ReadMode): BuiltQuery;
119
138
  /** INSERT новой версии/tombstone. $9 = updated прошлой версии (или null), $10 = deleted. */
@@ -131,4 +150,10 @@ export declare function deleteSql(pgSchema: string, n: number): string;
131
150
  * Рекурсивный CTE; каждый узел — актуальная живая версия. $1 partition, $2 class, $3+ — ids.
132
151
  */
133
152
  export declare function closureSql(pgSchema: string, n: number): string;
153
+ /**
154
+ * Вызов серверной purge(): $4 dry → превью (актуальные версии замыкания, БД цела), иначе двухфазный
155
+ * физический снос корня+поддерева. RETURNS SETOF Entity (по строке на сущность). Вся логика
156
+ * (замыкание, двухфазность, отключение триггера через SET LOCAL) — в БД. $1 partition, $2 class, $3 id, $4 dry.
157
+ */
158
+ export declare function purgeCallSql(pgSchema: string): string;
134
159
  export {};