letopis 0.20.3 → 1.0.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/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -250
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -351
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -600
- package/sql/seed.auth.sql +0 -73
package/dist/index.js
CHANGED
|
@@ -1,188 +1,314 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* letopis
|
|
2
|
+
* letopis 1.0 — основная точка входа пакета. Руководство — README.md пакета, машинный каталог API —
|
|
3
|
+
* docs/api-contract.json репозитория; решения и их причины — reports/PLAN-LETOPIS-1.0.md (этапы ниже).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* const db = await connect({ dsn, schema: 'v1.booking' }) // безличный пул
|
|
9
|
-
* const t = await db.as(accountId) // арендатор ВЫЗОВА
|
|
10
|
-
* await t.Мастер({ name: 'Вася' }).навык().Услуга().run()
|
|
5
|
+
* Этап 2: connect() — подключение под сессией, реестр классов, отложенный сигнал, db.sql.
|
|
6
|
+
* Этап 3: цепочки чтения t.Класс(…).Связь().rows(); этап 4 — запись цепочками и загрузчик.
|
|
7
|
+
* Этап 5: db.auth (креды, вход, сессии), db.acl, db.as(аккаунт, { tenant }) — имперсонация.
|
|
8
|
+
* Этап 6: история (restore, purge, rekey, trimHistory), verify, maintain, reset, watch, кэш результатов.
|
|
11
9
|
*/
|
|
12
|
-
// FILE: lib/src/index.ts
|
|
13
|
-
// VERSION: 1.3.0
|
|
14
|
-
// START_MODULE_CONTRACT
|
|
15
|
-
// PURPOSE: Публичная точка входа пакета — открыть соединение (реестр, опциональный ACL, сборка db) и ре-экспорт публичной поверхности; хелпер курсора.
|
|
16
|
-
// SCOPE: connect, cursorOf + barrel-реэкспорты
|
|
17
|
-
// DEPENDS: M-SCHEMA, M-CHAIN, M-TABLES, M-ACL, M-SQL, M-TYPES, M-UP
|
|
18
|
-
// LINKS: M-CONNECT, V-M-CONNECT
|
|
19
|
-
// ROLE: RUNTIME
|
|
20
|
-
// MAP_MODE: EXPORTS
|
|
21
|
-
// END_MODULE_CONTRACT
|
|
22
|
-
//
|
|
23
|
-
// START_MODULE_MAP
|
|
24
|
-
// connect - открывает pg-соединение, грузит реестр, опц. включает ACL (снимок Resource/Rule на подключение), собирает EntityDb
|
|
25
|
-
// cursorOf - курсор keyset-пагинации из последней строки страницы
|
|
26
|
-
// NOTE (re-exports) - up/UpOpts←M-UP; uuidv5/uuidv7/LETOPIS_NS←M-UUID; ops←M-OPS; totpCode/AuthApi/AuthResult←M-AUTH; AclApi←M-ACL; ValidationError←M-WRITE; Registry←M-SCHEMA; Session-типы←M-SESSIONS; базовые типы←M-TYPES; типы chain/tables
|
|
27
|
-
// END_MODULE_MAP
|
|
28
|
-
//
|
|
29
|
-
// START_CHANGE_SUMMARY
|
|
30
|
-
// LAST_CHANGE: [v1.3.0 - Словарь Resource/Rule снимается ОДИН раз на подключение и
|
|
31
|
-
// переиспользуется всеми db.as(): scope-per-request под enforceAcl платил два лишних
|
|
32
|
-
// SELECT на каждый HTTP-запрос. Per-scope остались чтение своего аккаунта и компиляция
|
|
33
|
-
// энфорсера (один энфорсер = один субъект). Снимок сбрасывает reloadSchema().
|
|
34
|
-
// Ранее: BREAKING — opts.account/opts.owner сняты, арендатора называет db.as()]
|
|
35
|
-
// END_CHANGE_SUMMARY
|
|
36
10
|
import postgres from 'postgres';
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
`);
|
|
66
|
-
const have = parseDdlRevision(rows[0]?.c);
|
|
67
|
-
if (have === DDL_REVISION)
|
|
68
|
-
return;
|
|
69
|
-
if (warnedDdl.has(schema))
|
|
70
|
-
return;
|
|
71
|
-
warnedDdl.add(schema);
|
|
72
|
-
const чего = have === null
|
|
73
|
-
? 'ревизия не помечена (схема накатана либой до появления метки)'
|
|
74
|
-
: `ревизия схемы ${have}, либа ожидает ${DDL_REVISION}`;
|
|
75
|
-
console.warn(`letopis: движок схемы "${schema}" отстал — ${чего}. Схема продолжит работать, но новых ` +
|
|
76
|
-
'функций/триггеров в ней нет (например purge/purge_account из 0.19.0) — вызов упадёт ' +
|
|
77
|
-
'ошибкой PostgreSQL "function … does not exist". ddl.sql идемпотентен, накат повторный ' +
|
|
78
|
-
'и данные целы: up({ …, upgrade: true }) либо node db/apply.mjs --dsn=… --schema=<имя> ' +
|
|
79
|
-
'--version=<N> --upgrade');
|
|
11
|
+
import { LetopisError, fromDbError, removed } from './errors.js';
|
|
12
|
+
import { Registry } from './registry.js';
|
|
13
|
+
import { AclPasses, ExplicitTx, Notifier, poolExecutor, runTx } from './tx.js';
|
|
14
|
+
import { makeAuth } from './auth.js';
|
|
15
|
+
import { makeAcl } from './acl.js';
|
|
16
|
+
import { idOf as idOfClass } from './uuid.js';
|
|
17
|
+
import { load } from './load.js';
|
|
18
|
+
import { makeBatch, startEntity, startStep } from './chain.js';
|
|
19
|
+
import { DDL_REVISION } from './types.js';
|
|
20
|
+
import { ACL_CLASSES, ResultCache } from './cache.js';
|
|
21
|
+
import { Subscription } from './watch.js';
|
|
22
|
+
import { describe as describeModel, maintain as runMaintain, rekeyClass as runRekeyClass, reset as runReset, scheduleMaintain, trimHistory as runTrim, verify as runVerify, } from './admin.js';
|
|
23
|
+
export { DDL_REVISION } from './types.js';
|
|
24
|
+
export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
|
|
25
|
+
export { LetopisError, ValidationError, fromDbError } from './errors.js';
|
|
26
|
+
export { Registry } from './registry.js';
|
|
27
|
+
export { compileValidator, applyDefaults } from './validate.js';
|
|
28
|
+
export { syncModels, generateTypes, schemaToTs } from './sync.js';
|
|
29
|
+
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, isOp } from './ops.js';
|
|
30
|
+
export { cursorOf } from './sql.js';
|
|
31
|
+
export { inc, isInc } from './write.js';
|
|
32
|
+
export { totpCode, hashPassword, verifyHash } from './auth.js';
|
|
33
|
+
export { describeText } from './admin.js';
|
|
34
|
+
export { up, createTenant } from './up.js';
|
|
35
|
+
export { RESERVED_CLASS_NAMES, CHAIN_METHODS, DB_METHODS } from './types.js';
|
|
36
|
+
/** Снесено в 1.0.0: id v7 больше не выдаются (§2.3). */
|
|
37
|
+
export function uuidv7() {
|
|
38
|
+
throw removed('uuidv7()', 'uuidv5() и idOf(): id объекта вычисляется из ключа');
|
|
80
39
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
enforceAccount: opts.enforceAccount ?? true,
|
|
110
|
-
onQuery: opts.onQuery,
|
|
111
|
-
slowMs: opts.slowMs,
|
|
112
|
-
};
|
|
113
|
-
// START_BLOCK_ENFORCE_ACL
|
|
114
|
-
// enforceAcl: правила и категории субъекта компилятся в резолвер ПОД КОНКРЕТНЫЙ scope.
|
|
115
|
-
// Функция живёт на ctx, потому что зовут её двое: db.as() (новый субъект) и reload
|
|
116
|
-
// (тот же субъект на свежем реестре). Субъект берётся из c.account, т.е. из scope.
|
|
117
|
-
//
|
|
118
|
-
// Словарь Resource/Rule ОДИН для всех субъектов — снимается один раз на подключение и
|
|
119
|
-
// переиспользуется каждым db.as(). Иначе scope-per-request (штатный режим в вебе) платил
|
|
120
|
-
// бы два лишних SELECT на КАЖДЫЙ HTTP-запрос. Per-scope остаётся только чтение своего
|
|
121
|
-
// аккаунта (категории субъекта) и сама компиляция: один энфорсер = один субъект, иначе
|
|
122
|
-
// memo в acl.ts (ключ «класс + операция», без аккаунта) отдал бы чужое решение.
|
|
123
|
-
// Снимок сбрасывает reloadSchema() — как и обещано в README §9.2/§11.10.
|
|
124
|
-
let aclDict = null;
|
|
125
|
-
const aclSource = (c) => {
|
|
126
|
-
aclDict ??= (async () => {
|
|
127
|
-
const tables = makeTables(c);
|
|
128
|
-
const [resources, rules] = await Promise.all([tables.resources.find(), tables.rules.find({ enabled: true })]);
|
|
129
|
-
return { resources, rules };
|
|
130
|
-
})().catch((e) => {
|
|
131
|
-
aclDict = null; // не кэшируем провал: следующий db.as() попробует снова
|
|
132
|
-
throw e;
|
|
133
|
-
});
|
|
134
|
-
return aclDict;
|
|
40
|
+
/** Снесено в 1.0.0: пространство имён v5 — id арендатора. Любое использование значения — ошибка. */
|
|
41
|
+
export const LETOPIS_NS = new Proxy({}, {
|
|
42
|
+
get(_t, p) {
|
|
43
|
+
if (p === 'then')
|
|
44
|
+
return undefined;
|
|
45
|
+
throw removed('LETOPIS_NS', 'пространство имён v5 — id арендатора: idOf(класс, ключ, { tenant })');
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
49
|
+
const SCHEMA_NAME = /^v\d+\.[a-z_][a-z0-9_]{0,40}$/;
|
|
50
|
+
/** Опции 0.21, снятые в 1.0.0, и их замены (этап 3, п. 11). */
|
|
51
|
+
const REMOVED_OPTIONS = {
|
|
52
|
+
partition: 'отдельные схемы установки (v2.<имя>)',
|
|
53
|
+
enforceAccount: 'изоляция арендаторов включена всегда (RLS)',
|
|
54
|
+
enforceAcl: 'права включены всегда (этап 5)',
|
|
55
|
+
container: 'Docker в up() не нужен',
|
|
56
|
+
image: 'Docker в up() не нужен',
|
|
57
|
+
dataDir: 'Docker в up() не нужен',
|
|
58
|
+
redisPort: 'Redis не нужен: сессии в базе',
|
|
59
|
+
version: 'версия схемы — константа движка',
|
|
60
|
+
};
|
|
61
|
+
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
62
|
+
/** Фасад db: пул (exec — исполнитель пула) или явная транзакция (tx). */
|
|
63
|
+
function makeDb(sh, scope, exec, owner, tx) {
|
|
64
|
+
const txc = scope.txc;
|
|
65
|
+
const chainCtx = {
|
|
66
|
+
reg: scope.registry(), schema: sh.schema, exec, tenant: scope.tenant, owner, onQuery: sh.onQuery, slowMs: sh.slowMs,
|
|
67
|
+
...(sh.cache ? { cache: { store: sh.cache, who: () => ({ token: txc.token, account: txc.account, tenant: scope.tenant() }) } } : {}),
|
|
135
68
|
};
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
throw new Error(`letopis: enforceAcl — account "${c.account}" not found`);
|
|
144
|
-
c.aclDecide = compileEnforcer(dict, account.id, account.categories, c.registry);
|
|
69
|
+
// реестр корня меняется после auth.switch — цепочки берут действующий
|
|
70
|
+
Object.defineProperty(chainCtx, 'reg', { get: scope.registry, enumerable: true });
|
|
71
|
+
// очереди батчей — свои у фасада, а не общие на подключение: run() исполняет очередь в контексте фасада
|
|
72
|
+
// (актор, арендатор, владелец, транзакция), значит, в ней только планы, поставленные этим же фасадом
|
|
73
|
+
const batches = new Map();
|
|
74
|
+
const noTx = (what) => {
|
|
75
|
+
throw new LetopisError('tx_required', `${what} — только внутри db.begin()`);
|
|
145
76
|
};
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
77
|
+
const auth = makeAuth(sh.S, exec, async () => {
|
|
78
|
+
sh.passes.clear();
|
|
79
|
+
await sh.refresh();
|
|
80
|
+
}, scope.root && !tx);
|
|
81
|
+
const acl = makeAcl(sh.S, exec);
|
|
82
|
+
const adm = { sql: sh.sql, schema: sh.schema, S: sh.S, txc, exec, registry: scope.registry, tenant: scope.tenant };
|
|
83
|
+
const core = {
|
|
84
|
+
schema: sh.schema,
|
|
85
|
+
get token() { return txc.token; },
|
|
86
|
+
get tenant() { return scope.tenant(); },
|
|
87
|
+
get registry() { return scope.registry(); },
|
|
88
|
+
auth,
|
|
89
|
+
acl,
|
|
90
|
+
sql: (strings, ...values) => exec.run((q) => q(strings, ...values), { raw: true }),
|
|
91
|
+
transaction: (fn) => exec.run(fn),
|
|
92
|
+
idOf: (cls, key) => {
|
|
93
|
+
const tenant = scope.tenant();
|
|
94
|
+
if (!tenant)
|
|
95
|
+
throw new LetopisError('no_session', 'idOf: нет арендатора сессии');
|
|
96
|
+
const c = scope.registry().resolve(cls);
|
|
97
|
+
return idOfClass({ name: c.name, key: c.key, ends: Object.keys(c.ends) }, key, { tenant });
|
|
98
|
+
},
|
|
99
|
+
refresh: sh.refresh,
|
|
100
|
+
flushSignals: () => sh.notifier.flush(),
|
|
101
|
+
verify: (o) => runVerify(adm, o),
|
|
102
|
+
maintain: (o) => runMaintain(adm, o),
|
|
103
|
+
reset: async (o) => {
|
|
104
|
+
const r = await runReset(adm, o);
|
|
105
|
+
sh.passes.clear();
|
|
106
|
+
sh.cache?.clear();
|
|
107
|
+
return r;
|
|
108
|
+
},
|
|
109
|
+
trimHistory: (target, before) => runTrim(adm, target, before),
|
|
110
|
+
rekeyClass: (cls, key) => runRekeyClass(adm, cls, key),
|
|
111
|
+
describe: () => describeModel(adm),
|
|
112
|
+
watch: (o = {}) => {
|
|
113
|
+
if (typeof o === 'function' || typeof o === 'string')
|
|
114
|
+
throw removed('watch(cb) и watch(класс, cb)', 'watch({ from }) — подписка по курсору');
|
|
115
|
+
const reg = scope.registry();
|
|
116
|
+
const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
|
|
117
|
+
return new Subscription({
|
|
118
|
+
S: sh.S, exec: poolExecutor(txc), classes,
|
|
119
|
+
...(sh.listen ? { onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); } } : {}),
|
|
120
|
+
}, o);
|
|
121
|
+
},
|
|
122
|
+
cache: {
|
|
123
|
+
clear: () => sh.cache?.clear(),
|
|
124
|
+
stats: () => sh.cache?.stats() ?? { size: 0, hits: 0, misses: 0, stores: 0, raced: 0, signals: 0, listens: 0, enabled: false },
|
|
125
|
+
},
|
|
126
|
+
entity: (x) => startEntity(chainCtx, x),
|
|
127
|
+
as: async (account, o) => {
|
|
128
|
+
if (tx)
|
|
129
|
+
throw new LetopisError('invalid_query', 'db.as() — от корня подключения, а не внутри db.begin()');
|
|
130
|
+
const id = account === null ? undefined : typeof account === 'object' ? account.id : account;
|
|
131
|
+
const nextOwner = o?.owner ?? owner;
|
|
132
|
+
if (id === undefined && o?.tenant === undefined) {
|
|
133
|
+
return makeDb(sh, scope, poolExecutor(txc), nextOwner);
|
|
134
|
+
}
|
|
135
|
+
// имперсонация и арендатор проверяются базой сразу: ошибка — здесь, а не на первой цепочке
|
|
136
|
+
const scoped = { ...txc, account: id ?? txc.account, tenant: o?.tenant ?? (id === undefined ? txc.tenant : undefined) };
|
|
137
|
+
const [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
138
|
+
if (!r?.account)
|
|
139
|
+
throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
|
|
140
|
+
const tenant = r.tenant;
|
|
141
|
+
const registry = await sh.registryFor(tenant, scoped);
|
|
142
|
+
return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
|
|
143
|
+
},
|
|
144
|
+
begin: async () => {
|
|
145
|
+
if (tx)
|
|
146
|
+
throw new LetopisError('invalid_query', 'вложенных транзакций нет: db.begin() внутри db.begin()');
|
|
147
|
+
const t = await ExplicitTx.open(txc);
|
|
148
|
+
return makeDb(sh, scope, t, owner, t);
|
|
149
|
+
},
|
|
150
|
+
load: async (input, o) => {
|
|
151
|
+
if (tx)
|
|
152
|
+
throw new LetopisError('invalid_query', 'load() — своей транзакцией, не внутри db.begin()');
|
|
153
|
+
return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
|
|
154
|
+
},
|
|
155
|
+
batch: (name) => {
|
|
156
|
+
let q = batches.get(name);
|
|
157
|
+
if (!q)
|
|
158
|
+
batches.set(name, (q = []));
|
|
159
|
+
return makeBatch(chainCtx, q);
|
|
160
|
+
},
|
|
161
|
+
lock: async (...keys) => (tx ? tx.lock(...keys) : noTx('lock()')),
|
|
162
|
+
commit: async (tr) => (tr ? tr.commit() : tx ? tx.commit() : noTx('commit()')),
|
|
163
|
+
rollback: async (tr) => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()')),
|
|
164
|
+
close: () => (tx ? tx.rollback() : sh.close()),
|
|
162
165
|
};
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
return new Proxy(core, {
|
|
167
|
+
get(t, prop, r) {
|
|
168
|
+
if (typeof prop === 'symbol' || prop in t)
|
|
169
|
+
return Reflect.get(t, prop, r);
|
|
170
|
+
switch (prop) {
|
|
171
|
+
case 'then':
|
|
172
|
+
return undefined;
|
|
173
|
+
case 'reloadSchema':
|
|
174
|
+
throw removed('reloadSchema()', 'реестр обновляется сам по сигналу; принудительно — db.refresh()');
|
|
175
|
+
case 'accounts':
|
|
176
|
+
case 'credentials':
|
|
177
|
+
case 'resources':
|
|
178
|
+
case 'rules':
|
|
179
|
+
throw removed(`фасад db.${prop}`, 'системные классы Account, Resource, rule, member и db.auth (accounts.purge — db.auth.purgeAccount)');
|
|
180
|
+
case 'compact':
|
|
181
|
+
throw removed('compact()', 'db.trimHistory(цель, момент), политика хранения history и history: false');
|
|
182
|
+
}
|
|
183
|
+
return startStep(chainCtx, prop);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
165
186
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
:
|
|
178
|
-
|
|
187
|
+
export async function connect(opts) {
|
|
188
|
+
if (!SCHEMA_NAME.test(opts.schema))
|
|
189
|
+
throw new LetopisError('invalid_data', `connect: схема ${opts.schema} — нужно v<N>.<имя>`);
|
|
190
|
+
if (!opts.sql && !opts.dsn)
|
|
191
|
+
throw new LetopisError('invalid_data', 'connect: нужен dsn или sql');
|
|
192
|
+
for (const k of Object.keys(opts))
|
|
193
|
+
if (k in REMOVED_OPTIONS)
|
|
194
|
+
throw removed(`опция connect({ ${k} })`, REMOVED_OPTIONS[k]);
|
|
195
|
+
const own = !opts.sql;
|
|
196
|
+
// timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
|
|
197
|
+
const sql = (opts.sql ?? postgres(opts.dsn, {
|
|
198
|
+
max: opts.max ?? 10,
|
|
199
|
+
onnotice: () => { },
|
|
200
|
+
types: { timestamptz: { to: 1184, from: [1184], serialize: (v) => (v instanceof Date ? v.toISOString() : v), parse: (v) => v } },
|
|
201
|
+
}));
|
|
202
|
+
const S = quote(opts.schema);
|
|
203
|
+
const owner = opts.owner ?? 'letopis_owner';
|
|
204
|
+
try {
|
|
205
|
+
// роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
|
|
206
|
+
const [role] = await sql `select r.rolsuper, r.rolbypassrls,
|
|
207
|
+
pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
|
|
208
|
+
from pg_roles r where r.rolname = current_user`;
|
|
209
|
+
const bypass = role.rolsuper || role.rolbypassrls || role.inherits || role.can_set;
|
|
210
|
+
if (bypass && !opts.allowBypassRls) {
|
|
211
|
+
throw new LetopisError('bypass_rls', 'роль подключения обходит RLS (суперпользователь, BYPASSRLS или член роли-владельца); администраторское подключение — только с опцией allowBypassRls');
|
|
212
|
+
}
|
|
213
|
+
if (bypass)
|
|
214
|
+
console.warn('letopis: администраторское подключение — RLS не действует, данные всех арендаторов видны');
|
|
215
|
+
let token = opts.token ?? null;
|
|
216
|
+
const apiKey = opts.apiKey ?? opts.service;
|
|
217
|
+
if (!token && apiKey) {
|
|
218
|
+
const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
|
|
219
|
+
throw fromDbError(e);
|
|
220
|
+
});
|
|
221
|
+
token = r.t;
|
|
222
|
+
}
|
|
223
|
+
// ревизия движка: библиотека новее схемы — накат отстал (этап 6, п. 10)
|
|
224
|
+
const [rv] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${opts.schema}`;
|
|
225
|
+
const rev = Number(/ddl_revision=(\d+)/.exec(String(rv?.c ?? ''))?.[1] ?? 0);
|
|
226
|
+
if (!rv)
|
|
227
|
+
throw new LetopisError('invalid_data', `connect: схемы ${opts.schema} нет — установите её: up({ schema })`);
|
|
228
|
+
if (rev < DDL_REVISION)
|
|
229
|
+
console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev}, библиотека ждёт ${DDL_REVISION} — обновите: up({ schema: '${opts.schema}', upgrade: true })`);
|
|
230
|
+
const notifier = new Notifier(sql, opts.schema, opts.notifyIntervalMs ?? 100);
|
|
231
|
+
const passes = new AclPasses(S);
|
|
232
|
+
const listen = opts.listen !== false;
|
|
233
|
+
const cache = opts.cache === false ? null : new ResultCache(opts.cache ?? {});
|
|
234
|
+
const signals = new Set();
|
|
235
|
+
// продление сессии (§2.9): отдельным вызовом не чаще раза в минуту; ошибка (реплика,
|
|
236
|
+
// транзакции только для чтения) не мешает работе
|
|
237
|
+
let refreshedAt = Date.now();
|
|
238
|
+
const touch = () => {
|
|
239
|
+
if (!token || opts.refreshSession === false || Date.now() - refreshedAt < 60_000)
|
|
240
|
+
return;
|
|
241
|
+
refreshedAt = Date.now();
|
|
242
|
+
void runTx({ sql, token, notifier: null }, (q) => q.unsafe(`select ${S}.auth_refresh()`), { retries: 0 }).catch(() => { });
|
|
243
|
+
};
|
|
244
|
+
const ctx = { sql, token, notifier, passes, after: touch, ...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
|
|
245
|
+
const root = { tenant: null, registry: new Registry() };
|
|
246
|
+
const loadClasses = (txc) => runTx(txc, (q) => q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`), { read: true });
|
|
247
|
+
const others = new Map();
|
|
248
|
+
const refresh = async () => {
|
|
249
|
+
const rows = await runTx(ctx, async (q) => {
|
|
250
|
+
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
|
|
251
|
+
root.tenant = t?.t ?? null;
|
|
252
|
+
return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
|
|
253
|
+
});
|
|
254
|
+
root.registry.load(rows);
|
|
255
|
+
for (const o of others.values())
|
|
256
|
+
o.registry.load(await loadClasses(o.txc).catch(() => []));
|
|
257
|
+
};
|
|
258
|
+
const registryFor = async (tenant, txc) => {
|
|
259
|
+
if (tenant === root.tenant)
|
|
260
|
+
return root.registry;
|
|
261
|
+
const known = others.get(tenant);
|
|
262
|
+
if (known)
|
|
263
|
+
return known.registry;
|
|
264
|
+
const registry = new Registry();
|
|
265
|
+
registry.load(await loadClasses(txc));
|
|
266
|
+
others.set(tenant, { registry, txc });
|
|
267
|
+
return registry;
|
|
268
|
+
};
|
|
269
|
+
await refresh();
|
|
270
|
+
// описания классов меняются — реестр перечитывается по сигналу; строки прав — пропуска заново;
|
|
271
|
+
// кэш результатов и подписки — по именам классов; (пере)подключение слушателя — кэш целиком
|
|
272
|
+
const onSignal = (cls) => {
|
|
273
|
+
if (ACL_CLASSES.has(cls) || cls === '*')
|
|
274
|
+
passes.clear();
|
|
275
|
+
if (cls === 'Class' || cls === '*')
|
|
276
|
+
void refresh().catch(() => { });
|
|
277
|
+
cache?.signal(cls);
|
|
278
|
+
for (const fn of signals)
|
|
279
|
+
fn(cls);
|
|
280
|
+
};
|
|
281
|
+
const listener = listen
|
|
282
|
+
? await sql.listen(opts.schema, onSignal, () => {
|
|
283
|
+
cache?.listen();
|
|
284
|
+
for (const fn of signals)
|
|
285
|
+
fn('*');
|
|
286
|
+
})
|
|
287
|
+
: null;
|
|
288
|
+
let stopMaintain = null;
|
|
289
|
+
const shared = {
|
|
290
|
+
sql, schema: opts.schema, S, notifier, passes, root, registryFor, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
|
|
291
|
+
admin: Boolean(bypass && (role.rolsuper || role.can_set)),
|
|
292
|
+
ownerRole: owner,
|
|
293
|
+
cache,
|
|
294
|
+
signals,
|
|
295
|
+
listen,
|
|
296
|
+
async close() {
|
|
297
|
+
stopMaintain?.();
|
|
298
|
+
await notifier.close();
|
|
299
|
+
await listener?.unlisten();
|
|
300
|
+
if (own)
|
|
301
|
+
await sql.end({ timeout: 5 });
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
|
|
305
|
+
if (opts.maintain !== undefined)
|
|
306
|
+
stopMaintain = scheduleMaintain(() => db.maintain(), opts.maintain);
|
|
307
|
+
return db;
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
if (own)
|
|
311
|
+
await sql.end({ timeout: 1 });
|
|
312
|
+
throw e;
|
|
313
|
+
}
|
|
179
314
|
}
|
|
180
|
-
// одна точка входа: контейнер + готовность + схема + connect
|
|
181
|
-
export { up } from './up.js';
|
|
182
|
-
// генерация id (attributes.id): формула v5 открыта — id считается ДО создания
|
|
183
|
-
export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
|
|
184
|
-
// операторы фильтров
|
|
185
|
-
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
|
|
186
|
-
export { totpCode } from './auth.js';
|
|
187
|
-
export { ValidationError } from './write.js';
|
|
188
|
-
export { Registry } from './schema.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
type Json = null | boolean | number | string | Json[] | {
|
|
2
|
+
[k: string]: Json;
|
|
3
|
+
};
|
|
4
|
+
export type JsonSchema = {
|
|
5
|
+
[k: string]: Json;
|
|
6
|
+
};
|
|
7
|
+
/** Подмножество, которое понимают база и валидатор. */
|
|
8
|
+
export declare const SUPPORTED: Set<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Перевод шаблона JavaScript в форму, которую одинаково понимают JavaScript (без флага u) и
|
|
11
|
+
* PostgreSQL: обозначения классов заменяются явными наборами, остальное не трогается.
|
|
12
|
+
*/
|
|
13
|
+
export declare function translatePattern(p: string, where?: string): string;
|
|
14
|
+
export interface NormalizeOptions {
|
|
15
|
+
/** Имя модели или поля — для сообщений. */
|
|
16
|
+
where?: string;
|
|
17
|
+
/** Проверять, что имена полей верхнего уровня не начинаются с $. */
|
|
18
|
+
topLevel?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Нормализует JSON Schema; бросает LetopisError('invalid_class') с названием поля и ключевого слова. */
|
|
21
|
+
export declare function normalizeSchema(input: JsonSchema, opts?: NormalizeOptions): JsonSchema;
|
|
22
|
+
export {};
|