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/CHANGELOG.md +254 -0
- package/LICENSE +21 -0
- package/README.md +492 -103
- package/dist/acl.d.ts +7 -2
- package/dist/acl.js +3 -2
- package/dist/chain.d.ts +46 -0
- package/dist/chain.js +38 -3
- package/dist/index.d.ts +8 -3
- package/dist/index.js +50 -19
- package/dist/schema.js +48 -6
- package/dist/sql.d.ts +26 -1
- package/dist/sql.js +130 -38
- package/dist/tables.d.ts +24 -0
- package/dist/tables.js +64 -1
- package/dist/types.d.ts +34 -9
- package/dist/types.js +40 -3
- package/dist/up.js +15 -4
- package/dist/write.d.ts +9 -0
- package/dist/write.js +46 -6
- 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/ddl.sql +114 -2
- 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
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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';
|
|
@@ -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
|
-
*
|
|
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,28 +62,49 @@ 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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
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 "${
|
|
75
|
-
|
|
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.
|
|
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. */
|
|
@@ -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 {};
|