letopis 1.4.2 → 2.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.md +329 -281
- package/CHANGELOG.md +418 -0
- package/README.md +572 -1053
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -184
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +18 -41
- package/dist/chain.js +165 -203
- package/dist/cli.js +45 -90
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -70
- package/dist/index.js +189 -122
- package/dist/internal.d.ts +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +207 -181
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +168 -214
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +20 -29
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +7 -1
- package/dist/sql.js +101 -85
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +40 -17
- package/dist/tx.d.ts +11 -1
- package/dist/tx.js +32 -11
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +15 -90
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +180 -74
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +5 -16
- package/dist/watch.js +23 -77
- package/dist/write.d.ts +9 -16
- package/dist/write.js +102 -185
- package/llms.txt +22 -25
- package/package.json +2 -9
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +23 -27
- package/sql/90-time.sql +77 -944
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/index.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* Этап 2: connect() — подключение под сессией, реестр классов, отложенный сигнал, db.sql.
|
|
6
6
|
* Этап 3: цепочки чтения t.Класс(…).Связь().rows(); этап 4 — запись цепочками и загрузчик.
|
|
7
7
|
* Этап 5: db.auth (креды, вход, сессии), db.acl, db.as(аккаунт, { tenant }) — имперсонация.
|
|
8
|
-
* Этап 6: история (
|
|
8
|
+
* Этап 6: история (versions, asOf, restore), maintain, watch, кэш результатов.
|
|
9
9
|
*/
|
|
10
10
|
import postgres from 'postgres';
|
|
11
|
-
import { LetopisError, fromDbError,
|
|
11
|
+
import { LetopisError, checkOptions, fromDbError, lerr, lwarn } from './errors.js';
|
|
12
12
|
import { Registry } from './registry.js';
|
|
13
13
|
import { AclPasses, ExplicitTx, Notifier, poolExecutor, runTx } from './tx.js';
|
|
14
14
|
import { checkReason } from './write.js';
|
|
@@ -16,53 +16,51 @@ import { limits, registerDb } from './internal.js';
|
|
|
16
16
|
import { makeAuth } from './auth.js';
|
|
17
17
|
import { makeAcl } from './acl.js';
|
|
18
18
|
import { idOf as idOfClass } from './uuid.js';
|
|
19
|
-
import { load } from './load.js';
|
|
19
|
+
import { load, LOAD_OPTIONS } from './load.js';
|
|
20
20
|
import { makeBatch, startEntity, startStep } from './chain.js';
|
|
21
|
+
import { or as orFilters } from './ops.js';
|
|
21
22
|
import { DDL_REVISION } from './types.js';
|
|
22
23
|
import { ACL_CLASSES, ResultCache } from './cache.js';
|
|
23
24
|
import { Subscription } from './watch.js';
|
|
24
|
-
import { Projector } from './project.js';
|
|
25
25
|
import { SIGNAL_MODES, Signals, bucket, throttle } from './signals.js';
|
|
26
|
-
import {
|
|
27
|
-
import { stampDrift } from './up.js';
|
|
26
|
+
import { maintain as runMaintain, parseInterval, scheduleMaintain, } from './admin.js';
|
|
27
|
+
import { refuse1x, stampDrift } from './up.js';
|
|
28
28
|
export { DDL_REVISION } from './types.js';
|
|
29
29
|
export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
|
|
30
30
|
export { LetopisError, ValidationError, fromDbError } from './errors.js';
|
|
31
31
|
export { Registry } from './registry.js';
|
|
32
32
|
export { compileValidator, applyDefaults } from './validate.js';
|
|
33
33
|
export { syncModels, generateTypes, schemaToTs } from './sync.js';
|
|
34
|
-
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not,
|
|
34
|
+
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, isOp } from './ops.js';
|
|
35
|
+
/**
|
|
36
|
+
* Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
|
|
37
|
+
* вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и». В шаге подключения
|
|
38
|
+
* с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт шаг (план 2.0, этап 4, п. 1). Та же
|
|
39
|
+
* фабрика, что в реестре операторов (ops.ts), — другой у неё только тип.
|
|
40
|
+
*/
|
|
41
|
+
export const or = orFilters;
|
|
35
42
|
export { cursorOf } from './sql.js';
|
|
36
43
|
export { inc, isInc } from './write.js';
|
|
37
44
|
export { totpCode, hashPassword, verifyHash } from './auth.js';
|
|
38
|
-
export { describeText } from './admin.js';
|
|
39
45
|
export { up, createTenant } from './up.js';
|
|
40
46
|
export { RESERVED_CLASS_NAMES, CHAIN_METHODS, DB_METHODS } from './types.js';
|
|
41
|
-
/** Снесено в 1.0.0: id v7 больше не выдаются (§2.3). */
|
|
42
|
-
export function uuidv7() {
|
|
43
|
-
throw removed('uuidv7()', 'uuidv5() и idOf(): id объекта вычисляется из ключа');
|
|
44
|
-
}
|
|
45
|
-
/** Снесено в 1.0.0: пространство имён v5 — id арендатора. Любое использование значения — ошибка. */
|
|
46
|
-
export const LETOPIS_NS = new Proxy({}, {
|
|
47
|
-
get(_t, p) {
|
|
48
|
-
if (p === 'then')
|
|
49
|
-
return undefined;
|
|
50
|
-
throw removed('LETOPIS_NS', 'пространство имён v5 — id арендатора: idOf(класс, ключ, { tenant })');
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
47
|
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
54
48
|
const SCHEMA_NAME = /^v\d+\.[a-z_][a-z0-9_]{0,40}$/;
|
|
55
|
-
/**
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
container: 'Docker в up() не нужен',
|
|
61
|
-
image: 'Docker в up() не нужен',
|
|
62
|
-
dataDir: 'Docker в up() не нужен',
|
|
63
|
-
redisPort: 'Redis не нужен: сессии в базе',
|
|
64
|
-
version: 'версия схемы — константа движка',
|
|
49
|
+
/** Все опции connect(): неизвестная — invalid_data с похожими именами (checkOptions; план 2.0, 7.10 №4). */
|
|
50
|
+
const CONNECT_OPTIONS = {
|
|
51
|
+
dsn: true, sql: true, schema: true, token: true, apiKey: true, service: true, refreshSession: true, allowBypassRls: true,
|
|
52
|
+
admin: true, owner: true, notifyIntervalMs: true, max: true, onQuery: true, slowMs: true, models: true, listen: true,
|
|
53
|
+
cache: true, maintain: true,
|
|
65
54
|
};
|
|
55
|
+
/** Опции watch() (WatchOptions). */
|
|
56
|
+
const WATCH_OPTIONS = { from: true, classes: true, batch: true, pollMs: true };
|
|
57
|
+
/**
|
|
58
|
+
* То же подключение без строгих типов: шагом может быть любое имя, в том числе известное только во время выполнения, —
|
|
59
|
+
* `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, слот) не меняются.
|
|
60
|
+
*/
|
|
61
|
+
export function loose(db) {
|
|
62
|
+
return db;
|
|
63
|
+
}
|
|
66
64
|
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
67
65
|
/** Фасад db: пул (exec — исполнитель пула) или явная транзакция (tx). */
|
|
68
66
|
function makeDb(sh, scope, exec, owner, tx) {
|
|
@@ -78,14 +76,23 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
78
76
|
// (актор, арендатор, владелец, транзакция), значит, в ней только планы, поставленные этим же фасадом
|
|
79
77
|
const batches = new Map();
|
|
80
78
|
const noTx = (what) => {
|
|
81
|
-
throw
|
|
79
|
+
throw lerr('api.tx_only', { what });
|
|
82
80
|
};
|
|
83
81
|
const auth = makeAuth(sh.S, exec, async () => {
|
|
84
82
|
sh.passes.clear();
|
|
85
83
|
await sh.refresh();
|
|
86
84
|
}, scope.root && !tx);
|
|
87
|
-
|
|
88
|
-
const
|
|
85
|
+
// запись правил (grant, deny, revoke, roles) идёт цепочками самого фасада — он объявлен ниже, берётся при вызове
|
|
86
|
+
const acl = makeAcl(sh.S, exec, () => facade);
|
|
87
|
+
const adm = { S: sh.S, txc, exec };
|
|
88
|
+
// один процесс (план 2.0, 7.9 №3): у корня с connect({ admin }) администраторские вызовы идут вторым пулом с той же
|
|
89
|
+
// сессией, а цепочки и db.sql — пулом приложения под RLS; db.as() и db.begin() администраторский пул не берут
|
|
90
|
+
const viaAdmin = scope.root && !tx && sh.adminSql ? sh.adminSql : null;
|
|
91
|
+
const adminReady = sh.adminReady ?? (async () => { });
|
|
92
|
+
const admCtx = () => {
|
|
93
|
+
const t = { ...txc, sql: viaAdmin };
|
|
94
|
+
return { ...adm, txc: t, exec: poolExecutor(t) };
|
|
95
|
+
};
|
|
89
96
|
const core = {
|
|
90
97
|
schema: sh.schema,
|
|
91
98
|
get token() { return txc.token; },
|
|
@@ -98,26 +105,15 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
98
105
|
idOf: (cls, key) => {
|
|
99
106
|
const tenant = scope.tenant();
|
|
100
107
|
if (!tenant)
|
|
101
|
-
throw
|
|
108
|
+
throw lerr('api.id_of_no_tenant');
|
|
102
109
|
const c = scope.registry().resolve(cls);
|
|
103
110
|
return idOfClass({ name: c.name, key: c.key, ends: Object.keys(c.ends) }, key, { tenant });
|
|
104
111
|
},
|
|
105
112
|
refresh: sh.refresh,
|
|
106
113
|
flushSignals: () => sh.notifier.flush(),
|
|
107
|
-
|
|
108
|
-
maintain: (o) => runMaintain(adm, o),
|
|
109
|
-
reset: async (o) => {
|
|
110
|
-
const r = await runReset(adm, o);
|
|
111
|
-
sh.passes.clear();
|
|
112
|
-
sh.cache?.clear();
|
|
113
|
-
return r;
|
|
114
|
-
},
|
|
115
|
-
trimHistory: (target, before) => runTrim(adm, target, before),
|
|
116
|
-
rekeyClass: (cls, key) => runRekeyClass(adm, cls, key),
|
|
117
|
-
describe: () => describeModel(adm),
|
|
114
|
+
maintain: () => (viaAdmin ? adminReady().then(() => runMaintain(admCtx())) : runMaintain(adm)),
|
|
118
115
|
watch: (o = {}) => {
|
|
119
|
-
|
|
120
|
-
throw removed('watch(cb) и watch(класс, cb)', 'watch({ from }) — подписка по курсору');
|
|
116
|
+
checkOptions('watch', o, WATCH_OPTIONS);
|
|
121
117
|
const reg = scope.registry();
|
|
122
118
|
const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
|
|
123
119
|
return sh.track(new Subscription({
|
|
@@ -145,7 +141,7 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
145
141
|
entity: (x) => startEntity(chainCtx, x),
|
|
146
142
|
as: async (account, o) => {
|
|
147
143
|
if (tx)
|
|
148
|
-
throw
|
|
144
|
+
throw lerr('api.as_in_tx');
|
|
149
145
|
const id = account === null ? undefined : typeof account === 'object' ? account.id : account;
|
|
150
146
|
const nextOwner = o?.owner ?? owner;
|
|
151
147
|
if (id === undefined && o?.tenant === undefined) {
|
|
@@ -155,7 +151,7 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
155
151
|
const scoped = { ...txc, account: id ?? txc.account, tenant: o?.tenant ?? (id === undefined ? txc.tenant : undefined) };
|
|
156
152
|
let [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
157
153
|
if (!r?.account)
|
|
158
|
-
throw
|
|
154
|
+
throw lerr('api.as_no_session');
|
|
159
155
|
// контекст сессии между запросами ушёл из арендатора (сменился или кончился): реестр не загружен и не
|
|
160
156
|
// кэширован (хвост ревью безопасности №3) — контекст читается заново, не больше трёх раз
|
|
161
157
|
for (let attempt = 1;; attempt++) {
|
|
@@ -164,42 +160,31 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
164
160
|
if (registry)
|
|
165
161
|
return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => sh.registryOf(tenant, registry, scoped), root: false }, poolExecutor(scoped), nextOwner);
|
|
166
162
|
if (attempt === 3)
|
|
167
|
-
throw
|
|
163
|
+
throw lerr('api.as_context_changing');
|
|
168
164
|
[r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
169
165
|
if (!r?.account)
|
|
170
|
-
throw
|
|
166
|
+
throw lerr('api.as_no_session');
|
|
171
167
|
}
|
|
172
168
|
},
|
|
173
169
|
begin: async (o) => {
|
|
174
170
|
if (tx)
|
|
175
|
-
throw
|
|
171
|
+
throw lerr('api.nested_begin');
|
|
176
172
|
// причина блока ставится в начале транзакции (setupSql) и действует на все её операции
|
|
177
173
|
const btx = o?.reason !== undefined ? { ...txc, reason: checkReason(o.reason, 'db.begin()') } : txc;
|
|
178
174
|
const t = await ExplicitTx.open(btx);
|
|
179
175
|
return makeDb(sh, btx === txc ? scope : { ...scope, txc: btx }, t, owner, t);
|
|
180
176
|
},
|
|
181
177
|
load: async (input, o) => {
|
|
178
|
+
// опции — до всего прочего: неизвестная — отказ раньше admin_required пула администратора (план 2.0, 7.10 №4)
|
|
179
|
+
checkOptions('load', o, LOAD_OPTIONS);
|
|
182
180
|
if (tx)
|
|
183
|
-
throw
|
|
181
|
+
throw lerr('api.load_in_tx');
|
|
182
|
+
if (viaAdmin) {
|
|
183
|
+
await adminReady();
|
|
184
|
+
return load({ sql: viaAdmin, schema: sh.schema, tenant: scope.tenant(), admin: true, ownerRole: sh.ownerRole }, input, o);
|
|
185
|
+
}
|
|
184
186
|
return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
|
|
185
187
|
},
|
|
186
|
-
projector: (name, o) => {
|
|
187
|
-
if (tx)
|
|
188
|
-
throw new LetopisError('invalid_query', 'projector() — от корня подключения, а не внутри db.begin()');
|
|
189
|
-
if (!sh.admin)
|
|
190
|
-
throw new LetopisError('admin_required', 'проектору нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
191
|
-
return new Projector({
|
|
192
|
-
sql: sh.sql, schema: sh.schema, S: sh.S, ownerRole: sh.ownerRole,
|
|
193
|
-
...(sh.listen ? { onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); } } : {}),
|
|
194
|
-
}, name, o);
|
|
195
|
-
},
|
|
196
|
-
rebuildAggregates: async (cls) => {
|
|
197
|
-
if (tx)
|
|
198
|
-
throw new LetopisError('invalid_query', 'rebuildAggregates() — своей транзакцией, не внутри db.begin()');
|
|
199
|
-
if (!sh.admin)
|
|
200
|
-
throw new LetopisError('admin_required', 'ремонту агрегатов нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
201
|
-
return runRebuildAggregates({ sql: sh.sql, S: sh.S, ownerRole: sh.ownerRole }, cls);
|
|
202
|
-
},
|
|
203
188
|
batch: (name, o) => {
|
|
204
189
|
let q = batches.get(name);
|
|
205
190
|
if (!q)
|
|
@@ -211,93 +196,104 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
211
196
|
rollback: async (tr) => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()')),
|
|
212
197
|
close: () => (tx ? tx.rollback() : sh.close()),
|
|
213
198
|
};
|
|
199
|
+
/** Свойство фасада: служебное — из core, иначе шаг класса, алиаса или роли. */
|
|
200
|
+
const member = (t, prop, r) => {
|
|
201
|
+
if (typeof prop === 'symbol' || prop in t)
|
|
202
|
+
return Reflect.get(t, prop, r);
|
|
203
|
+
if (prop === 'then')
|
|
204
|
+
return undefined;
|
|
205
|
+
return startStep(chainCtx, prop);
|
|
206
|
+
};
|
|
214
207
|
const facade = new Proxy(core, {
|
|
215
208
|
get(t, prop, r) {
|
|
216
|
-
|
|
217
|
-
return Reflect.get(t, prop, r);
|
|
218
|
-
switch (prop) {
|
|
219
|
-
case 'then':
|
|
220
|
-
return undefined;
|
|
221
|
-
case 'reloadSchema':
|
|
222
|
-
throw removed('reloadSchema()', 'реестр обновляется сам по сигналу; принудительно — db.refresh()');
|
|
223
|
-
case 'accounts':
|
|
224
|
-
case 'credentials':
|
|
225
|
-
case 'resources':
|
|
226
|
-
case 'rules':
|
|
227
|
-
throw removed(`фасад db.${prop}`, 'системные классы Account, Resource, rule, member и db.auth (accounts.purge — db.auth.purgeAccount)');
|
|
228
|
-
case 'compact':
|
|
229
|
-
throw removed('compact()', 'db.trimHistory(цель, момент), политика хранения history и history: false');
|
|
230
|
-
}
|
|
231
|
-
return startStep(chainCtx, prop);
|
|
209
|
+
return member(t, prop, r);
|
|
232
210
|
},
|
|
233
211
|
});
|
|
234
212
|
// модули пакета (letopis/mcp) узнают обход RLS по самому подключению, а не со слов вызывающего (internal.ts)
|
|
235
|
-
registerDb(facade, {
|
|
213
|
+
registerDb(facade, {
|
|
214
|
+
allTenants: sh.allTenants, explicit: Boolean(tx),
|
|
215
|
+
...(viaAdmin ? { adminTx: (fn) => adminReady().then(() => runTx({ ...txc, sql: viaAdmin }, fn)) } : {}),
|
|
216
|
+
});
|
|
236
217
|
return facade;
|
|
237
218
|
}
|
|
238
219
|
export async function connect(opts) {
|
|
220
|
+
// опции — первыми: неизвестная — invalid_data с похожими именами до прочих проверок (план 2.0, 7.10 №4)
|
|
221
|
+
checkOptions('connect', opts, CONNECT_OPTIONS);
|
|
239
222
|
if (!SCHEMA_NAME.test(opts.schema))
|
|
240
|
-
throw
|
|
223
|
+
throw lerr('api.connect_schema', { schema: opts.schema });
|
|
241
224
|
if (!opts.sql && !opts.dsn)
|
|
242
|
-
throw
|
|
243
|
-
for (const k of Object.keys(opts))
|
|
244
|
-
if (k in REMOVED_OPTIONS)
|
|
245
|
-
throw removed(`опция connect({ ${k} })`, REMOVED_OPTIONS[k]);
|
|
225
|
+
throw lerr('api.connect_no_dsn');
|
|
246
226
|
// интервал обслуживания — до пула и таймеров: неверный не оставляет за собой открытых соединений
|
|
247
227
|
const maintainEvery = opts.maintain === undefined ? undefined : parseInterval(opts.maintain);
|
|
228
|
+
if (opts.admin !== undefined && typeof opts.admin !== 'string' && typeof opts.admin !== 'function') {
|
|
229
|
+
throw lerr('api.connect_admin_type');
|
|
230
|
+
}
|
|
248
231
|
const own = !opts.sql;
|
|
249
232
|
// timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
233
|
+
const types = { timestamptz: { to: 1184, from: [1184], serialize: (v) => (v instanceof Date ? v.toISOString() : v), parse: (v) => v } };
|
|
234
|
+
const sql = (opts.sql ?? postgres(opts.dsn, { max: opts.max ?? 10, onnotice: () => { }, types }));
|
|
235
|
+
// один процесс (план 2.0, 7.9 №3): пул администратора postgres.js соединяется при первом запросе — до первого
|
|
236
|
+
// администраторского вызова соединений у него нет
|
|
237
|
+
const ownAdmin = typeof opts.admin === 'string';
|
|
238
|
+
const adminSql = (typeof opts.admin === 'string'
|
|
239
|
+
? postgres(opts.admin, { max: 2, onnotice: () => { }, types, connection: { application_name: 'letopis admin' } })
|
|
240
|
+
: opts.admin);
|
|
255
241
|
const S = quote(opts.schema);
|
|
256
242
|
const owner = opts.owner ?? 'letopis_owner';
|
|
243
|
+
let adminChecked;
|
|
244
|
+
const adminReady = adminSql ? () => (adminChecked ??= (async () => {
|
|
245
|
+
const [a] = await adminSql `select r.rolsuper, pg_has_role(current_user, ${owner}, 'SET') as can_set from pg_roles r where r.rolname = current_user`;
|
|
246
|
+
if (!a?.rolsuper && !a?.can_set) {
|
|
247
|
+
throw lerr('api.connect_admin_role', { owner });
|
|
248
|
+
}
|
|
249
|
+
})().catch((e) => {
|
|
250
|
+
adminChecked = undefined;
|
|
251
|
+
throw e;
|
|
252
|
+
})) : undefined;
|
|
257
253
|
try {
|
|
258
254
|
// роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
|
|
259
255
|
// роли владельца нет (опечатка в owner) — понятная ошибка, а не сырая 42704 PostgreSQL (план 2.0, 7.5 №3)
|
|
260
256
|
const [known] = await sql `select exists (select 1 from pg_roles where rolname = ${owner}) as ok`;
|
|
261
257
|
if (!known.ok)
|
|
262
|
-
throw
|
|
258
|
+
throw lerr('api.connect_no_owner', { owner });
|
|
263
259
|
const [role] = await sql `select r.rolsuper, r.rolbypassrls,
|
|
264
260
|
pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
|
|
265
261
|
from pg_roles r where r.rolname = current_user`;
|
|
266
262
|
const bypass = role.rolsuper || role.rolbypassrls || role.inherits || role.can_set;
|
|
267
263
|
if (bypass && !opts.allowBypassRls) {
|
|
268
|
-
throw
|
|
264
|
+
throw lerr('api.connect_bypass_rls');
|
|
269
265
|
}
|
|
270
266
|
if (bypass)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const apiKey = opts.apiKey ?? opts.service;
|
|
274
|
-
if (!token && apiKey) {
|
|
275
|
-
const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
|
|
276
|
-
throw fromDbError(e);
|
|
277
|
-
});
|
|
278
|
-
token = r.t;
|
|
279
|
-
}
|
|
280
|
-
// ревизия движка: библиотека новее схемы — накат отстал (этап 6, п. 10)
|
|
267
|
+
lwarn('api.admin_connection');
|
|
268
|
+
// ревизия движка — до первого вызова функций схемы (вход по ключу): схема letopis 1.x — отказ (план 2.0, 7.10 №5)
|
|
281
269
|
const [rv] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${opts.schema}`;
|
|
282
|
-
const rev = Number(/ddl_revision=(\d+)/.exec(String(rv?.c ?? ''))?.[1] ?? 0);
|
|
283
270
|
if (!rv)
|
|
284
|
-
throw
|
|
271
|
+
throw lerr('api.connect_no_schema', { schema: opts.schema });
|
|
272
|
+
const rev = Number(/ddl_revision=(\d+)/.exec(String(rv.c ?? ''))?.[1] ?? 0);
|
|
273
|
+
refuse1x(opts.schema, rev);
|
|
274
|
+
// библиотека новее схемы 2.x — накат отстал (этап 6, п. 10)
|
|
285
275
|
if (rev < DDL_REVISION)
|
|
286
|
-
|
|
276
|
+
lwarn('api.schema_older', { schema: opts.schema, rev, want: DDL_REVISION });
|
|
287
277
|
// схему обновил более новый пакет (во время раскатки старые экземпляры ещё работают): подключаемся, но предупреждаем —
|
|
288
278
|
// этот пакет может не знать функций и колонок новой ревизии; up() такую схему не трогает и отказывает
|
|
289
279
|
else if (rev > DDL_REVISION)
|
|
290
|
-
|
|
280
|
+
lwarn('api.schema_newer', { schema: opts.schema, rev, known: DDL_REVISION });
|
|
291
281
|
else {
|
|
292
282
|
// ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
|
|
293
283
|
const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
|
|
294
284
|
if (drift)
|
|
295
285
|
console.warn(drift);
|
|
296
286
|
}
|
|
297
|
-
|
|
287
|
+
let token = opts.token ?? null;
|
|
288
|
+
const apiKey = opts.apiKey ?? opts.service;
|
|
289
|
+
if (!token && apiKey) {
|
|
290
|
+
const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
|
|
291
|
+
throw fromDbError(e);
|
|
292
|
+
});
|
|
293
|
+
token = r.t;
|
|
294
|
+
}
|
|
295
|
+
// режим сигналов установки (план 1.1, §2.2)
|
|
298
296
|
const [sm] = await sql.unsafe(`select ${S}.signal_mode() as m`).catch((e) => {
|
|
299
|
-
if (e.code === '42883')
|
|
300
|
-
return [{ m: 'class' }];
|
|
301
297
|
throw fromDbError(e);
|
|
302
298
|
});
|
|
303
299
|
const mode = SIGNAL_MODES.includes(sm?.m) ? sm.m : 'class';
|
|
@@ -312,7 +308,10 @@ export async function connect(opts) {
|
|
|
312
308
|
// продление сессии (§2.9): отдельным вызовом не чаще раза в минуту; ошибка (реплика,
|
|
313
309
|
// транзакции только для чтения) не мешает работе
|
|
314
310
|
let refreshedAt = Date.now();
|
|
311
|
+
// время последней удачной операции: после простоя дольше минуты сессию проверяют до операции (ctx.before)
|
|
312
|
+
let lastOk = Date.now();
|
|
315
313
|
const touch = () => {
|
|
314
|
+
lastOk = Date.now();
|
|
316
315
|
if (!token || opts.refreshSession === false || Date.now() - refreshedAt < 60_000)
|
|
317
316
|
return;
|
|
318
317
|
refreshedAt = Date.now();
|
|
@@ -322,6 +321,69 @@ export async function connect(opts) {
|
|
|
322
321
|
const ctx = { sql, token, notifier: mode === 'none' ? null : notifier, passes, after: touch,
|
|
323
322
|
...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
|
|
324
323
|
const root = { tenant: null, registry: new Registry() };
|
|
324
|
+
// подключение по ключу APIKEY: сессия истекает по простою (срок — 7 суток), и процесс, простоявший дольше, получал
|
|
325
|
+
// бы no_session на каждый вызов. Ключ обменивается на новую сессию заново (один обмен на все ждущие вызовы), с тем
|
|
326
|
+
// же рабочим арендатором, что выбрал auth.switch(); операция повторяется один раз (runTx, ExplicitTx.open)
|
|
327
|
+
if (apiKey && !opts.token) {
|
|
328
|
+
let renewing = null;
|
|
329
|
+
const exchange = async () => {
|
|
330
|
+
const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]);
|
|
331
|
+
const next = r?.t ? String(r.t) : null;
|
|
332
|
+
const want = root.tenant;
|
|
333
|
+
if (next && want) {
|
|
334
|
+
await runTx({ sql, token: next, notifier: null }, async (q) => {
|
|
335
|
+
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
|
|
336
|
+
if (String(t?.t ?? '').toLowerCase() !== want.toLowerCase())
|
|
337
|
+
await q.unsafe(`select ${S}.auth_switch($1::uuid)`, [want]);
|
|
338
|
+
}, { retries: 0 });
|
|
339
|
+
}
|
|
340
|
+
return next;
|
|
341
|
+
};
|
|
342
|
+
ctx.renew = async (failed) => {
|
|
343
|
+
// ключ уже обменян другим вызовом — достаточно нового токена
|
|
344
|
+
if (token && failed.token !== token) {
|
|
345
|
+
failed.token = token;
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
renewing ??= exchange().catch(() => null).finally(() => {
|
|
349
|
+
renewing = null;
|
|
350
|
+
});
|
|
351
|
+
const next = await renewing;
|
|
352
|
+
if (!next)
|
|
353
|
+
return false;
|
|
354
|
+
token = next;
|
|
355
|
+
ctx.token = next;
|
|
356
|
+
failed.token = next;
|
|
357
|
+
refreshedAt = Date.now();
|
|
358
|
+
return true;
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
// после простоя дольше минуты — продление до операции: сессия, истёкшая по простою, даёт no_session (подключение по
|
|
362
|
+
// ключу выдаёт новую), а не анонимное чтение с пустым ответом; ошибка продления другого рода (реплика, транзакция
|
|
363
|
+
// только для чтения) работе не мешает, как у touch
|
|
364
|
+
let checking = null;
|
|
365
|
+
const checkSession = async () => {
|
|
366
|
+
try {
|
|
367
|
+
await runTx({ sql, token, notifier: null }, (q) => q.unsafe(`select ${S}.auth_refresh()`), { retries: 0 });
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
if (!(e instanceof LetopisError && e.code === 'no_session'))
|
|
371
|
+
return;
|
|
372
|
+
if (!ctx.renew || !(await ctx.renew(ctx)))
|
|
373
|
+
throw e;
|
|
374
|
+
}
|
|
375
|
+
refreshedAt = lastOk = Date.now();
|
|
376
|
+
};
|
|
377
|
+
ctx.before = async (c) => {
|
|
378
|
+
if (!token || opts.refreshSession === false || Date.now() - lastOk < 60_000)
|
|
379
|
+
return;
|
|
380
|
+
checking ??= checkSession().finally(() => {
|
|
381
|
+
checking = null;
|
|
382
|
+
});
|
|
383
|
+
await checking;
|
|
384
|
+
if (token && c.token !== token)
|
|
385
|
+
c.token = token;
|
|
386
|
+
};
|
|
325
387
|
// классы арендатора tenant под контекстом txc; null — контекст уже не в этом арендаторе (сессия кончилась,
|
|
326
388
|
// пользователь выбыл из арендатора): тогда прежний реестр остаётся, а не пустеет (доработки после точки Б)
|
|
327
389
|
const loadClasses = (txc, tenant) => runTx(txc, async (q) => {
|
|
@@ -411,15 +473,15 @@ export async function connect(opts) {
|
|
|
411
473
|
return null;
|
|
412
474
|
throw e;
|
|
413
475
|
});
|
|
414
|
-
// ключ
|
|
476
|
+
// ключ сменился: ответы с токенами старого ключа сигналы больше не узнают
|
|
415
477
|
if (sig.setKey(key))
|
|
416
478
|
cache?.clear();
|
|
417
479
|
};
|
|
418
480
|
await fetchKey();
|
|
419
481
|
await refresh();
|
|
420
|
-
// ключ, реестр и пропуска прав — заново по
|
|
421
|
-
// без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том
|
|
422
|
-
// поддельными сигналами) не становится потоком перечитываний
|
|
482
|
+
// ключ, реестр и пропуска прав — заново после переподключения слушателя, по незнакомому токену, нагрузке не по
|
|
483
|
+
// режиму, а у процесса без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том
|
|
484
|
+
// числе поддельными сигналами) не становится потоком перечитываний
|
|
423
485
|
const resync = throttle(() => {
|
|
424
486
|
passes.clear();
|
|
425
487
|
void fetchKey().then(() => refresh()).catch(() => { });
|
|
@@ -511,6 +573,7 @@ export async function connect(opts) {
|
|
|
511
573
|
sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
|
|
512
574
|
admin: Boolean(bypass && (role.rolsuper || role.can_set)),
|
|
513
575
|
ownerRole: owner,
|
|
576
|
+
...(adminSql ? { adminSql, adminReady } : {}),
|
|
514
577
|
cache,
|
|
515
578
|
sig,
|
|
516
579
|
allTenants: Boolean(bypass),
|
|
@@ -526,7 +589,7 @@ export async function connect(opts) {
|
|
|
526
589
|
// (план 2.0, 7.4 №8)
|
|
527
590
|
for (const s of [...subs])
|
|
528
591
|
s.close();
|
|
529
|
-
// идущий maintain() по расписанию
|
|
592
|
+
// идущий maintain() по расписанию доделывается: пул закрывается после него, а не посреди транзакции
|
|
530
593
|
await stopMaintain?.();
|
|
531
594
|
resync.stop();
|
|
532
595
|
startReload.stop();
|
|
@@ -536,16 +599,20 @@ export async function connect(opts) {
|
|
|
536
599
|
await listener?.unlisten();
|
|
537
600
|
if (own)
|
|
538
601
|
await sql.end({ timeout: 5 });
|
|
602
|
+
if (ownAdmin)
|
|
603
|
+
await adminSql.end({ timeout: 5 });
|
|
539
604
|
},
|
|
540
605
|
};
|
|
541
606
|
const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
|
|
542
607
|
if (maintainEvery !== undefined)
|
|
543
|
-
stopMaintain = scheduleMaintain((
|
|
608
|
+
stopMaintain = scheduleMaintain(() => db.maintain(), maintainEvery);
|
|
544
609
|
return db;
|
|
545
610
|
}
|
|
546
611
|
catch (e) {
|
|
547
612
|
if (own)
|
|
548
613
|
await sql.end({ timeout: 1 });
|
|
614
|
+
if (ownAdmin)
|
|
615
|
+
await adminSql.end({ timeout: 1 });
|
|
549
616
|
throw e;
|
|
550
617
|
}
|
|
551
618
|
}
|
package/dist/internal.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export interface DbInternals {
|
|
|
9
9
|
readonly allTenants: boolean;
|
|
10
10
|
/** Фасад явной транзакции db.begin(). */
|
|
11
11
|
readonly explicit: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Транзакция на администраторском пуле connect({ admin }) с сессией фасада (план 2.0, 7.9 №3): ею пишет syncModels.
|
|
14
|
+
* Только у корня подключения с опцией admin.
|
|
15
|
+
*/
|
|
16
|
+
readonly adminTx?: <T>(fn: (tx: import('./tx.js').Tx) => Promise<T>) => Promise<T>;
|
|
12
17
|
}
|
|
13
18
|
/**
|
|
14
19
|
* Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,
|