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/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
|
-
/**
|
|
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
|
-
/** Синхронный резолвер для цепочек: категории субъекта и правила
|
|
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 с мемоизацией — субъект и правила
|
|
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
|
-
/** Синхронный резолвер для цепочек: категории субъекта и правила
|
|
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
|
@@ -42,6 +42,14 @@ export interface ChainCore {
|
|
|
42
42
|
versions(): Promise<Row[]>;
|
|
43
43
|
/** Рекурсивный self-обход: дети любой глубины (тот же класс), $depth в Row. */
|
|
44
44
|
deep(max?: number): Chain;
|
|
45
|
+
/**
|
|
46
|
+
* Только ЭТОТ класс, без классов-потомков. По умолчанию шаг полиморфен: родитель отдаёт
|
|
47
|
+
* объединение с потомками (`db.Контрагент()` → Мастера + Клиенты). `.exact()` нужен, когда
|
|
48
|
+
* наследование в домене — переиспользование attributes, а не «is-a» для выборки:
|
|
49
|
+
* в демо `запись` наследует `окно`, но смена ≠ бронь, поэтому «смены мастера» —
|
|
50
|
+
* `db.Мастер(id).окно().exact()`.
|
|
51
|
+
*/
|
|
52
|
+
exact(): Chain;
|
|
45
53
|
/** Агрегации по полю последнего шага ('data.<путь>'), считает БД. */
|
|
46
54
|
sum(field: string): Promise<number | null>;
|
|
47
55
|
avg(field: string): Promise<number | null>;
|
|
@@ -125,6 +133,23 @@ export type Chain = ChainCore & {
|
|
|
125
133
|
[className: string]: StepProp;
|
|
126
134
|
};
|
|
127
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>;
|
|
128
153
|
/** Открыть транзакцию: tr — тот же API + commit/rollback/lock. */
|
|
129
154
|
begin(): Promise<EntityTx>;
|
|
130
155
|
commit(tr: EntityTx): Promise<void>;
|
package/dist/chain.js
CHANGED
|
@@ -358,6 +358,9 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
358
358
|
return () => withMods({ withDeleted: true });
|
|
359
359
|
case 'deep':
|
|
360
360
|
return (max = 32) => withLast({ deepMax: max });
|
|
361
|
+
case 'exact':
|
|
362
|
+
// снять полиморфизм ТЕКУЩЕГО шага: только свой класс, без потомков
|
|
363
|
+
return () => withLast({ exactClass: true });
|
|
361
364
|
// END_BLOCK_READ_MODIFIERS
|
|
362
365
|
// START_BLOCK_AGGREGATIONS
|
|
363
366
|
case 'sum':
|
|
@@ -448,8 +451,10 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
448
451
|
return (v) => withLast({ accountFilter: typeof v === 'object' ? v.id : v });
|
|
449
452
|
case 'owner':
|
|
450
453
|
return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
|
|
451
|
-
case 'link'
|
|
452
|
-
|
|
454
|
+
// NB: охранника `case 'link'` здесь больше нет (снят в 0.20.0). Он затенял РЕАЛЬНЫЙ
|
|
455
|
+
// класс схемы: в демо-домене `link` — абстрактный корень всех связок, и шаг
|
|
456
|
+
// db.Клиент(c).link() падал migration-ошибкой вместо обхода. Обратная совместимость
|
|
457
|
+
// со снесённым в 0.15.0 методом .link() принесена в жертву достижимости класса.
|
|
453
458
|
// END_BLOCK_COLUMN_MODS
|
|
454
459
|
}
|
|
455
460
|
// START_BLOCK_CLASS_RESOLVE
|
|
@@ -532,6 +537,27 @@ export function makeDb(ctx, root) {
|
|
|
532
537
|
}
|
|
533
538
|
// END_BLOCK_TABLES_AUTH_ACL
|
|
534
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
|
|
535
561
|
// START_BLOCK_TX_LOCK
|
|
536
562
|
case 'begin':
|
|
537
563
|
return async () => makeDb(await beginTx(ctx), state);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* letopis: dot-цепочки над append-only Entity-хранилищем (TimescaleDB).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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';
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* letopis: dot-цепочки над append-only Entity-хранилищем (TimescaleDB).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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.
|
|
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.
|
|
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-аккаунт,
|
|
34
|
-
// INPUTS: { opts: ConnectOpts { dsn, schema, partition?='entity', max?=10,
|
|
35
|
-
// OUTPUTS: { Promise<EntityDb> - dot-цепочный db }
|
|
36
|
-
// SIDE_EFFECTS: открывает пул postgres (timestamptz строкой), читает реестр и System-аккаунт;
|
|
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,35 +62,48 @@ export async function connect(opts) {
|
|
|
52
62
|
registry,
|
|
53
63
|
pgSchema: opts.schema,
|
|
54
64
|
partition,
|
|
55
|
-
account
|
|
56
|
-
owner: opts.owner,
|
|
65
|
+
// account/owner тут НЕ задаются: подключение безличное, арендатора называет db.as().
|
|
57
66
|
systemAccount: sys[0]?.id,
|
|
58
|
-
|
|
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: правила и категории субъекта компилятся в резолвер
|
|
64
|
-
|
|
75
|
+
// enforceAcl: правила и категории субъекта компилятся в резолвер ПОД КОНКРЕТНЫЙ scope.
|
|
76
|
+
// Функция живёт на ctx, потому что зовут её двое: db.as() (новый субъект) и reload
|
|
77
|
+
// (тот же субъект на свежем реестре). Субъект берётся из c.account, т.е. из scope.
|
|
78
|
+
ctx.compileAcl = async (c) => {
|
|
65
79
|
if (!opts.enforceAcl)
|
|
66
80
|
return;
|
|
67
|
-
if (!
|
|
68
|
-
throw new Error('letopis: enforceAcl
|
|
69
|
-
const tables = makeTables(
|
|
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);
|
|
70
84
|
const [account, resources, rules] = await Promise.all([
|
|
71
|
-
tables.accounts.get(
|
|
85
|
+
tables.accounts.get(c.account),
|
|
72
86
|
tables.resources.find(),
|
|
73
87
|
tables.rules.find({ enabled: true }),
|
|
74
88
|
]);
|
|
75
89
|
if (!account)
|
|
76
|
-
throw new Error(`letopis: enforceAcl — account "${
|
|
77
|
-
|
|
90
|
+
throw new Error(`letopis: enforceAcl — account "${c.account}" not found`);
|
|
91
|
+
c.aclDecide = compileEnforcer({ resources, rules }, account.id, account.categories, c.registry);
|
|
78
92
|
};
|
|
79
|
-
|
|
80
|
-
//
|
|
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).');
|
|
101
|
+
}
|
|
102
|
+
// db.reloadSchema(): перечитать определения классов из таблицы Schema без реконнекта.
|
|
103
|
+
// На корневом хендле обновляется только реестр (субъекта тут нет); scoped-хендлы
|
|
104
|
+
// подхватывают его и перекомпилируют свой энфорсер сами (chain.ts, case 'as').
|
|
81
105
|
ctx.reload = async () => {
|
|
82
106
|
ctx.registry = await loadRegistry(sql, opts.schema, partition);
|
|
83
|
-
await compileAcl();
|
|
84
107
|
};
|
|
85
108
|
// END_BLOCK_ENFORCE_ACL
|
|
86
109
|
return makeDb(ctx);
|
package/dist/schema.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
//
|
|
11
11
|
// FILE: lib/src/schema.ts
|
|
12
|
-
// VERSION: 1.
|
|
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.
|
|
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
|
-
|
|
299
|
-
|
|
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: скомпилированный
|
|
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. */
|
|
@@ -78,6 +86,13 @@ export interface Step {
|
|
|
78
86
|
pivotKey?: number;
|
|
79
87
|
/** .alias(name) — ключ шага в путях. */
|
|
80
88
|
aliasKey?: string;
|
|
89
|
+
/**
|
|
90
|
+
* .exact() — только ЭТОТ класс, без классов-потомков. По умолчанию шаг полиморфен
|
|
91
|
+
* (родитель отдаёт объединение с потомками); нужно, когда наследование в домене
|
|
92
|
+
* использовано для переиспользования attributes, а не как «is-a» для выборки
|
|
93
|
+
* (демо: `запись` наследует `окно`, но смена ≠ бронь).
|
|
94
|
+
*/
|
|
95
|
+
exactClass?: boolean;
|
|
81
96
|
/** .tags(…) — фильтр по колонке tags: строка | string[] (все) | has/hasAny/hasAll. */
|
|
82
97
|
tagsFilter?: unknown;
|
|
83
98
|
/** .account(uuid|Row) — фильтр по колонке account. */
|
|
@@ -116,6 +131,8 @@ export interface BuiltQuery {
|
|
|
116
131
|
/** Ключи шагов в путях (после alias/dedupe). */
|
|
117
132
|
keys: string[];
|
|
118
133
|
}
|
|
134
|
+
/** Корневой хендл при enforceAccount читать/писать не может: арендатора называет db.as(). */
|
|
135
|
+
export declare function guardScoped(ctx: Ctx): void;
|
|
119
136
|
/** Построить читающий запрос по цепочке. */
|
|
120
137
|
export declare function buildRead(ctx: Ctx, steps: Step[], mods: ChainMods, mode: ReadMode): BuiltQuery;
|
|
121
138
|
/** INSERT новой версии/tombstone. $9 = updated прошлой версии (или null), $10 = deleted. */
|