letopis 1.4.2 → 3.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 +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- 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 +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -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 +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -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 +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -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 +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- 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 +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- 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 +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +18 -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,71 +5,75 @@
|
|
|
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, warnText } from './errors.js';
|
|
12
|
+
import { guarded, instrument, makeLog, maskDsn, nextTx, safeErrorText, sqlText, traceSql, traced } from './log.js';
|
|
12
13
|
import { Registry } from './registry.js';
|
|
13
14
|
import { AclPasses, ExplicitTx, Notifier, poolExecutor, runTx } from './tx.js';
|
|
14
15
|
import { checkReason } from './write.js';
|
|
15
16
|
import { limits, registerDb } from './internal.js';
|
|
16
|
-
import { makeAuth } from './auth.js';
|
|
17
|
-
import { makeAcl } from './acl.js';
|
|
17
|
+
import { AUTH_LOG, makeAuth } from './auth.js';
|
|
18
|
+
import { ACL_LOG, makeAcl } from './acl.js';
|
|
18
19
|
import { idOf as idOfClass } from './uuid.js';
|
|
19
|
-
import { load } from './load.js';
|
|
20
|
+
import { load, LOAD_OPTIONS } from './load.js';
|
|
20
21
|
import { makeBatch, startEntity, startStep } from './chain.js';
|
|
21
|
-
import {
|
|
22
|
+
import { or as orFilters } from './ops.js';
|
|
23
|
+
import { DDL_REVISION, SCHEMA_VERSION } from './types.js';
|
|
22
24
|
import { ACL_CLASSES, ResultCache } from './cache.js';
|
|
23
25
|
import { Subscription } from './watch.js';
|
|
24
|
-
import { Projector } from './project.js';
|
|
25
26
|
import { SIGNAL_MODES, Signals, bucket, throttle } from './signals.js';
|
|
26
|
-
import {
|
|
27
|
-
import { stampDrift } from './up.js';
|
|
27
|
+
import { maintain as runMaintain, parseInterval, scheduleMaintain, } from './admin.js';
|
|
28
|
+
import { refuse1x, stampDrift } from './up.js';
|
|
28
29
|
export { DDL_REVISION } from './types.js';
|
|
29
30
|
export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
|
|
30
31
|
export { LetopisError, ValidationError, fromDbError } from './errors.js';
|
|
31
32
|
export { Registry } from './registry.js';
|
|
32
33
|
export { compileValidator, applyDefaults } from './validate.js';
|
|
33
34
|
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,
|
|
35
|
+
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, isOp } from './ops.js';
|
|
36
|
+
/**
|
|
37
|
+
* Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
|
|
38
|
+
* вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и». В шаге подключения
|
|
39
|
+
* с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт шаг (план 2.0, этап 4, п. 1). Та же
|
|
40
|
+
* фабрика, что в реестре операторов (ops.ts), — другой у неё только тип.
|
|
41
|
+
*/
|
|
42
|
+
export const or = orFilters;
|
|
35
43
|
export { cursorOf } from './sql.js';
|
|
36
44
|
export { inc, isInc } from './write.js';
|
|
37
45
|
export { totpCode, hashPassword, verifyHash } from './auth.js';
|
|
38
|
-
export { describeText } from './admin.js';
|
|
39
46
|
export { up, createTenant } from './up.js';
|
|
40
47
|
export { RESERVED_CLASS_NAMES, CHAIN_METHODS, DB_METHODS } from './types.js';
|
|
41
|
-
|
|
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
|
-
});
|
|
48
|
+
export { LOG_EVENTS } from './messages/index.js';
|
|
53
49
|
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
54
50
|
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: 'версия схемы — константа движка',
|
|
51
|
+
/** Все опции connect(): неизвестная — invalid_data с похожими именами (checkOptions; план 2.0, 7.10 №4). */
|
|
52
|
+
const CONNECT_OPTIONS = {
|
|
53
|
+
dsn: true, sql: true, schema: true, token: true, apiKey: true, service: true, refreshSession: true, allowBypassRls: true,
|
|
54
|
+
admin: true, owner: true, notifyIntervalMs: true, max: true, onQuery: true, slowMs: true, models: true, listen: true,
|
|
55
|
+
cache: true, maintain: true, log: true, logLevel: true, redact: true,
|
|
65
56
|
};
|
|
57
|
+
/** Опции watch() (WatchOptions). */
|
|
58
|
+
const WATCH_OPTIONS = { from: true, classes: true, batch: true, pollMs: true };
|
|
59
|
+
/** Опции db.begin() (второй раунд 2.1, A4). */
|
|
60
|
+
const BEGIN_OPTIONS = { reason: true };
|
|
61
|
+
/**
|
|
62
|
+
* То же подключение без строгих типов: шагом может быть любое имя, в том числе известное только во время выполнения, —
|
|
63
|
+
* `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, слот) не меняются.
|
|
64
|
+
*/
|
|
65
|
+
export function loose(db) {
|
|
66
|
+
return db;
|
|
67
|
+
}
|
|
66
68
|
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
67
69
|
/** Фасад db: пул (exec — исполнитель пула) или явная транзакция (tx). */
|
|
68
70
|
function makeDb(sh, scope, exec, owner, tx) {
|
|
69
71
|
const txc = scope.txc;
|
|
72
|
+
// логгер фасада: арендатор и аккаунт — в момент события (корень меняет их auth.switch), метка транзакции — от db.begin()
|
|
73
|
+
const log = (txc.log ?? sh.log).child({ who: () => ({ tenant: scope.tenant(), account: txc.account ?? sh.root.account }) });
|
|
70
74
|
const chainCtx = {
|
|
71
|
-
reg: scope.registry(), schema: sh.schema, exec, tenant: scope.tenant, owner, onQuery: sh.onQuery, slowMs: sh.slowMs,
|
|
72
|
-
reason: txc.reason ?? '', txReason: txc.reason ?? '',
|
|
75
|
+
reg: scope.registry(), schema: sh.schema, exec, tenant: scope.tenant, hasToken: () => Boolean(txc.token), owner, onQuery: sh.onQuery, slowMs: sh.slowMs,
|
|
76
|
+
reason: txc.reason ?? '', txReason: txc.reason ?? '', log,
|
|
73
77
|
...(sh.cache ? { cache: { store: sh.cache, who: () => ({ token: txc.token, account: txc.account, tenant: scope.tenant() }) } } : {}),
|
|
74
78
|
};
|
|
75
79
|
// реестр корня меняется после auth.switch — цепочки берут действующий
|
|
@@ -78,14 +82,23 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
78
82
|
// (актор, арендатор, владелец, транзакция), значит, в ней только планы, поставленные этим же фасадом
|
|
79
83
|
const batches = new Map();
|
|
80
84
|
const noTx = (what) => {
|
|
81
|
-
throw
|
|
85
|
+
throw lerr('api.tx_only', { what });
|
|
82
86
|
};
|
|
83
|
-
const auth = makeAuth(sh.S, exec, async () => {
|
|
87
|
+
const auth = instrument(makeAuth(sh.S, exec, async () => {
|
|
84
88
|
sh.passes.clear();
|
|
85
89
|
await sh.refresh();
|
|
86
|
-
}, scope.root && !tx);
|
|
87
|
-
|
|
88
|
-
const
|
|
90
|
+
}, scope.root && !tx), log, 'auth', AUTH_LOG, { switch: () => scope.tenant() });
|
|
91
|
+
// запись правил (grant, deny, revoke, roles) идёт цепочками самого фасада — он объявлен ниже, берётся при вызове
|
|
92
|
+
const acl = instrument(makeAcl(sh.S, exec, () => facade), log, 'acl', ACL_LOG);
|
|
93
|
+
const adm = { S: sh.S, txc, exec };
|
|
94
|
+
// один процесс (план 2.0, 7.9 №3): у корня с connect({ admin }) администраторские вызовы идут вторым пулом с той же
|
|
95
|
+
// сессией, а цепочки и db.sql — пулом приложения под RLS; db.as() и db.begin() администраторский пул не берут
|
|
96
|
+
const viaAdmin = scope.root && !tx && sh.adminSql ? sh.adminSql : null;
|
|
97
|
+
const adminReady = sh.adminReady ?? (async () => { });
|
|
98
|
+
const admCtx = () => {
|
|
99
|
+
const t = { ...txc, sql: viaAdmin };
|
|
100
|
+
return { ...adm, txc: t, exec: poolExecutor(t) };
|
|
101
|
+
};
|
|
89
102
|
const core = {
|
|
90
103
|
schema: sh.schema,
|
|
91
104
|
get token() { return txc.token; },
|
|
@@ -93,31 +106,36 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
93
106
|
get registry() { return scope.registry(); },
|
|
94
107
|
auth,
|
|
95
108
|
acl,
|
|
96
|
-
sql: (strings, ...values) => exec.run((q) => q(strings, ...values), { raw: true }),
|
|
97
|
-
transaction: (fn) =>
|
|
98
|
-
|
|
109
|
+
sql: (strings, ...values) => traced(log, 'raw', { op: 'sql' }, () => exec.run((q) => q(strings, ...values), { raw: true }), (r) => ({ rows: r.length, detail: { text: sqlText(strings.reduce((a, x, i) => (i ? `${a}$${i}${x}` : x), '')), params: values.length } })),
|
|
110
|
+
transaction: (fn) => {
|
|
111
|
+
// в db.begin() — та же транзакция: своих событий tx.* нет
|
|
112
|
+
if (exec.explicit || !log.enabled)
|
|
113
|
+
return traced(log, null, { op: 'transaction' }, () => exec.run(fn));
|
|
114
|
+
const tl = log.child({ tx: nextTx('t') });
|
|
115
|
+
tl.emit('tx.begin', { op: 'transaction', detail: { isolation: 'read committed' } });
|
|
116
|
+
const t0 = performance.now();
|
|
117
|
+
return exec.run(fn).then((r) => {
|
|
118
|
+
// сырой SQL: была ли запись, не знаем — фиксация такой транзакции всегда info (доказательство фиксации)
|
|
119
|
+
tl.emit('tx.commit', { op: 'transaction', ms: performance.now() - t0, level: 'info', detail: { raw: true } });
|
|
120
|
+
return r;
|
|
121
|
+
}, (e) => {
|
|
122
|
+
tl.error(e, { op: 'transaction', ms: performance.now() - t0 });
|
|
123
|
+
tl.emit('tx.rollback', { op: 'transaction', ms: performance.now() - t0, detail: { reason: 'error' } });
|
|
124
|
+
throw e;
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
idOf: (cls, key) => guarded(log, { op: 'idOf' }, () => {
|
|
99
128
|
const tenant = scope.tenant();
|
|
100
129
|
if (!tenant)
|
|
101
|
-
throw
|
|
130
|
+
throw lerr('api.id_of_no_tenant');
|
|
102
131
|
const c = scope.registry().resolve(cls);
|
|
103
132
|
return idOfClass({ name: c.name, key: c.key, ends: Object.keys(c.ends) }, key, { tenant });
|
|
104
|
-
},
|
|
105
|
-
refresh: sh.refresh,
|
|
133
|
+
}),
|
|
134
|
+
refresh: () => traced(log, null, { op: 'refresh' }, sh.refresh),
|
|
106
135
|
flushSignals: () => sh.notifier.flush(),
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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),
|
|
118
|
-
watch: (o = {}) => {
|
|
119
|
-
if (typeof o === 'function' || typeof o === 'string')
|
|
120
|
-
throw removed('watch(cb) и watch(класс, cb)', 'watch({ from }) — подписка по курсору');
|
|
136
|
+
maintain: () => traced(log, 'maintain', { op: 'maintain' }, () => (viaAdmin ? adminReady().then(() => runMaintain(admCtx())) : runMaintain(adm)), (r) => ({ detail: { ...r } })),
|
|
137
|
+
watch: (o = {}) => guarded(log, { op: 'watch' }, () => {
|
|
138
|
+
checkOptions('watch', o, WATCH_OPTIONS);
|
|
121
139
|
const reg = scope.registry();
|
|
122
140
|
const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
|
|
123
141
|
return sh.track(new Subscription({
|
|
@@ -136,16 +154,17 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
136
154
|
onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); },
|
|
137
155
|
tenant: () => (sh.allTenants ? null : sh.sig.tenantToken(scope.tenant())),
|
|
138
156
|
} : {}),
|
|
157
|
+
...(log.enabled ? { log } : {}),
|
|
139
158
|
}, o));
|
|
140
|
-
},
|
|
159
|
+
}),
|
|
141
160
|
cache: {
|
|
142
161
|
clear: () => sh.cache?.clear(),
|
|
143
162
|
stats: () => sh.cache?.stats() ?? { size: 0, hits: 0, misses: 0, stores: 0, raced: 0, signals: 0, listens: 0, enabled: false },
|
|
144
163
|
},
|
|
145
|
-
entity: (x) => startEntity(chainCtx, x),
|
|
146
|
-
as:
|
|
164
|
+
entity: (x) => guarded(log, { op: 'entity' }, () => startEntity(chainCtx, x)),
|
|
165
|
+
as: (account, o) => traced(log, 'auth.as', { op: 'as' }, async () => {
|
|
147
166
|
if (tx)
|
|
148
|
-
throw
|
|
167
|
+
throw lerr('api.as_in_tx');
|
|
149
168
|
const id = account === null ? undefined : typeof account === 'object' ? account.id : account;
|
|
150
169
|
const nextOwner = o?.owner ?? owner;
|
|
151
170
|
if (id === undefined && o?.tenant === undefined) {
|
|
@@ -155,7 +174,7 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
155
174
|
const scoped = { ...txc, account: id ?? txc.account, tenant: o?.tenant ?? (id === undefined ? txc.tenant : undefined) };
|
|
156
175
|
let [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
157
176
|
if (!r?.account)
|
|
158
|
-
throw
|
|
177
|
+
throw lerr('api.as_no_session');
|
|
159
178
|
// контекст сессии между запросами ушёл из арендатора (сменился или кончился): реестр не загружен и не
|
|
160
179
|
// кэширован (хвост ревью безопасности №3) — контекст читается заново, не больше трёх раз
|
|
161
180
|
for (let attempt = 1;; attempt++) {
|
|
@@ -164,145 +183,181 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
164
183
|
if (registry)
|
|
165
184
|
return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => sh.registryOf(tenant, registry, scoped), root: false }, poolExecutor(scoped), nextOwner);
|
|
166
185
|
if (attempt === 3)
|
|
167
|
-
throw
|
|
186
|
+
throw lerr('api.as_context_changing');
|
|
168
187
|
[r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
169
188
|
if (!r?.account)
|
|
170
|
-
throw
|
|
189
|
+
throw lerr('api.as_no_session');
|
|
171
190
|
}
|
|
172
|
-
},
|
|
173
|
-
|
|
191
|
+
}, (d) => ({ tenant: d.tenant ?? undefined, account: typeof account === 'object' && account !== null ? account.id : account ?? undefined,
|
|
192
|
+
detail: { ...(o?.tenant !== undefined ? { tenant: o.tenant } : {}), ...(o?.owner !== undefined ? { owner: o.owner } : {}) } })),
|
|
193
|
+
begin: (o) => traced(log, null, { op: 'begin' }, async () => {
|
|
194
|
+
// аргумент — только опции (второй раунд 2.1, A4): begin(fn) функцию не вызывал, а транзакция оставалась открытой
|
|
195
|
+
if (o !== undefined && o !== null && (typeof o !== 'object' || Array.isArray(o))) {
|
|
196
|
+
throw lerr('api.begin_arg', { type: Array.isArray(o) ? 'array' : typeof o });
|
|
197
|
+
}
|
|
198
|
+
checkOptions('begin', o, BEGIN_OPTIONS);
|
|
174
199
|
if (tx)
|
|
175
|
-
throw
|
|
176
|
-
// причина блока ставится в начале транзакции (setupSql) и действует на все её
|
|
177
|
-
|
|
200
|
+
throw lerr('api.nested_begin');
|
|
201
|
+
// причина блока ставится в начале транзакции (setupSql) и действует на все её операции; логгер — с меткой
|
|
202
|
+
// транзакции: её события (tx.*) и все операции фасада db.begin() несут поле tx
|
|
203
|
+
const tl = log.enabled ? (txc.log ?? sh.log).child({ tx: nextTx('b'), writes: { n: 0 } }) : undefined;
|
|
204
|
+
const btx = o?.reason !== undefined || tl
|
|
205
|
+
? { ...txc, ...(o?.reason !== undefined ? { reason: checkReason(o.reason, 'db.begin()') } : {}), ...(tl ? { log: tl } : {}) }
|
|
206
|
+
: txc;
|
|
178
207
|
const t = await ExplicitTx.open(btx);
|
|
179
208
|
return makeDb(sh, btx === txc ? scope : { ...scope, txc: btx }, t, owner, t);
|
|
180
|
-
},
|
|
181
|
-
load:
|
|
209
|
+
}),
|
|
210
|
+
load: (input, o) => traced(log, 'load', { op: 'load' }, async () => {
|
|
211
|
+
// опции — до всего прочего: неизвестная — отказ раньше admin_required пула администратора (план 2.0, 7.10 №4)
|
|
212
|
+
checkOptions('load', o, LOAD_OPTIONS);
|
|
182
213
|
if (tx)
|
|
183
|
-
throw
|
|
214
|
+
throw lerr('api.load_in_tx');
|
|
215
|
+
if (viaAdmin) {
|
|
216
|
+
await adminReady();
|
|
217
|
+
return load({ sql: viaAdmin, schema: sh.schema, tenant: scope.tenant(), admin: true, ownerRole: sh.ownerRole }, input, o);
|
|
218
|
+
}
|
|
184
219
|
return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
|
|
185
|
-
},
|
|
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
|
-
},
|
|
220
|
+
}, (r) => ({ rows: r.loaded + r.updated, detail: { ...r } })),
|
|
203
221
|
batch: (name, o) => {
|
|
204
222
|
let q = batches.get(name);
|
|
205
223
|
if (!q)
|
|
206
224
|
batches.set(name, (q = []));
|
|
207
225
|
return makeBatch(o?.reason !== undefined ? { ...chainCtx, reason: checkReason(o.reason, 'db.batch()') } : chainCtx, q);
|
|
208
226
|
},
|
|
209
|
-
lock:
|
|
210
|
-
commit: async (
|
|
211
|
-
rollback: async (
|
|
212
|
-
|
|
227
|
+
lock: (...keys) => traced(log, 'lock', { op: 'lock', detail: { keys } }, async () => (tx ? tx.lock(...keys) : noTx('lock()'))),
|
|
228
|
+
commit: (tr) => traced(log, null, { op: 'commit' }, async () => (tr ? tr.commit() : tx ? tx.commit() : noTx('commit()'))),
|
|
229
|
+
rollback: (tr) => traced(log, null, { op: 'rollback' }, async () => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()'))),
|
|
230
|
+
// подключение из as() пул не закрывает: пул общий с корнем (план 2.1, A12) — закрывает его close() корня
|
|
231
|
+
close: () => traced(log, !tx && scope.root ? 'close' : null, { op: 'close', detail: { schema: sh.schema } }, () => (tx ? tx.rollback() : scope.root ? sh.close() : Promise.resolve())),
|
|
232
|
+
};
|
|
233
|
+
/** Свойство фасада: служебное — из core, иначе шаг класса, алиаса или роли. */
|
|
234
|
+
const member = (t, prop, r) => {
|
|
235
|
+
if (typeof prop === 'symbol' || prop in t)
|
|
236
|
+
return Reflect.get(t, prop, r);
|
|
237
|
+
if (prop === 'then')
|
|
238
|
+
return undefined;
|
|
239
|
+
// неизвестный класс или роль — событие error с именем шага
|
|
240
|
+
return log.enabled ? guarded(log, { op: prop, chain: prop }, () => startStep(chainCtx, prop)) : startStep(chainCtx, prop);
|
|
213
241
|
};
|
|
214
242
|
const facade = new Proxy(core, {
|
|
215
243
|
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);
|
|
244
|
+
return member(t, prop, r);
|
|
232
245
|
},
|
|
233
246
|
});
|
|
234
247
|
// модули пакета (letopis/mcp) узнают обход RLS по самому подключению, а не со слов вызывающего (internal.ts)
|
|
235
|
-
registerDb(facade, {
|
|
248
|
+
registerDb(facade, {
|
|
249
|
+
allTenants: sh.allTenants, explicit: Boolean(tx), log,
|
|
250
|
+
...(viaAdmin ? { adminTx: (fn) => adminReady().then(() => runTx({ ...txc, sql: viaAdmin }, fn)) } : {}),
|
|
251
|
+
});
|
|
236
252
|
return facade;
|
|
237
253
|
}
|
|
238
254
|
export async function connect(opts) {
|
|
255
|
+
// опции — первыми: неизвестная — invalid_data с похожими именами до прочих проверок (план 2.0, 7.10 №4)
|
|
256
|
+
checkOptions('connect', opts, CONNECT_OPTIONS);
|
|
257
|
+
// логгер — до всего прочего: отказы connect() тоже идут событием error (логирование 3.0)
|
|
258
|
+
const base = makeLog({ log: opts.log, logLevel: opts.logLevel, redact: opts.redact });
|
|
259
|
+
const info = {};
|
|
260
|
+
return traced(base, 'connect', { op: 'connect' }, () => open(opts, base, info), (db) => ({ tenant: db.tenant ?? undefined, account: info.account ?? undefined, detail: info }));
|
|
261
|
+
}
|
|
262
|
+
/** connect() после проверки опций: base — логгер подключения, info — подробности события connect. */
|
|
263
|
+
async function open(opts, base, info) {
|
|
264
|
+
// имя без точки — как у up(): v2.<имя> (план 2.1, A12); дальше всё, и канал LISTEN, — по полному имени
|
|
265
|
+
if (typeof opts.schema === 'string' && !opts.schema.includes('.'))
|
|
266
|
+
opts = { ...opts, schema: `${SCHEMA_VERSION}.${opts.schema}` };
|
|
239
267
|
if (!SCHEMA_NAME.test(opts.schema))
|
|
240
|
-
throw
|
|
268
|
+
throw lerr('api.connect_schema', { schema: opts.schema });
|
|
241
269
|
if (!opts.sql && !opts.dsn)
|
|
242
|
-
throw
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
270
|
+
throw lerr('api.connect_no_dsn');
|
|
271
|
+
// ключ сервиса или токен передан, но пуст (null, '', не строка — например, повторный up() вернул serviceKey: null):
|
|
272
|
+
// отказ сразу, а не подключение без сессии и no_session на первой записи. undefined — опции нет (CLI, необязательный токен)
|
|
273
|
+
for (const name of ['apiKey', 'service', 'token']) {
|
|
274
|
+
const v = opts[name];
|
|
275
|
+
if (v !== undefined && (typeof v !== 'string' || v.trim() === '')) {
|
|
276
|
+
throw lerr('api.connect_empty_credential', { name, value: v === null ? 'null' : typeof v === 'string' ? `'${v}'` : typeof v });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
246
279
|
// интервал обслуживания — до пула и таймеров: неверный не оставляет за собой открытых соединений
|
|
247
280
|
const maintainEvery = opts.maintain === undefined ? undefined : parseInterval(opts.maintain);
|
|
281
|
+
if (opts.admin !== undefined && typeof opts.admin !== 'string' && typeof opts.admin !== 'function') {
|
|
282
|
+
throw lerr('api.connect_admin_type');
|
|
283
|
+
}
|
|
248
284
|
const own = !opts.sql;
|
|
249
285
|
// timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
286
|
+
const types = { timestamptz: { to: 1184, from: [1184], serialize: (v) => (v instanceof Date ? v.toISOString() : v), parse: (v) => v } };
|
|
287
|
+
// порог debug — каждый запрос пула даёт событие sql (traceSql); иначе пул как есть
|
|
288
|
+
const sql = traceSql((opts.sql ?? postgres(opts.dsn, { max: opts.max ?? 10, onnotice: () => { }, types })), base);
|
|
289
|
+
// один процесс (план 2.0, 7.9 №3): пул администратора postgres.js соединяется при первом запросе — до первого
|
|
290
|
+
// администраторского вызова соединений у него нет
|
|
291
|
+
const ownAdmin = typeof opts.admin === 'string';
|
|
292
|
+
const adminRaw = (typeof opts.admin === 'string'
|
|
293
|
+
? postgres(opts.admin, { max: 2, onnotice: () => { }, types, connection: { application_name: 'letopis admin' } })
|
|
294
|
+
: opts.admin);
|
|
295
|
+
const adminSql = adminRaw ? traceSql(adminRaw, base) : undefined;
|
|
255
296
|
const S = quote(opts.schema);
|
|
256
297
|
const owner = opts.owner ?? 'letopis_owner';
|
|
298
|
+
let adminChecked;
|
|
299
|
+
const adminReady = adminSql ? () => (adminChecked ??= (async () => {
|
|
300
|
+
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`;
|
|
301
|
+
if (!a?.rolsuper && !a?.can_set) {
|
|
302
|
+
throw lerr('api.connect_admin_role', { owner });
|
|
303
|
+
}
|
|
304
|
+
})().catch((e) => {
|
|
305
|
+
adminChecked = undefined;
|
|
306
|
+
throw e;
|
|
307
|
+
})) : undefined;
|
|
257
308
|
try {
|
|
258
309
|
// роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
|
|
259
310
|
// роли владельца нет (опечатка в owner) — понятная ошибка, а не сырая 42704 PostgreSQL (план 2.0, 7.5 №3)
|
|
260
311
|
const [known] = await sql `select exists (select 1 from pg_roles where rolname = ${owner}) as ok`;
|
|
261
312
|
if (!known.ok)
|
|
262
|
-
throw
|
|
313
|
+
throw lerr('api.connect_no_owner', { owner });
|
|
263
314
|
const [role] = await sql `select r.rolsuper, r.rolbypassrls,
|
|
264
315
|
pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
|
|
265
316
|
from pg_roles r where r.rolname = current_user`;
|
|
266
317
|
const bypass = role.rolsuper || role.rolbypassrls || role.inherits || role.can_set;
|
|
267
318
|
if (bypass && !opts.allowBypassRls) {
|
|
268
|
-
throw
|
|
319
|
+
throw lerr('api.connect_bypass_rls');
|
|
269
320
|
}
|
|
270
321
|
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)
|
|
322
|
+
lwarn('api.admin_connection', undefined, { log: base });
|
|
323
|
+
// ревизия движка — до первого вызова функций схемы (вход по ключу): схема letopis 1.x — отказ (план 2.0, 7.10 №5)
|
|
281
324
|
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
325
|
if (!rv)
|
|
284
|
-
throw
|
|
326
|
+
throw lerr('api.connect_no_schema', { schema: opts.schema });
|
|
327
|
+
const rev = Number(/ddl_revision=(\d+)/.exec(String(rv.c ?? ''))?.[1] ?? 0);
|
|
328
|
+
refuse1x(opts.schema, rev);
|
|
329
|
+
// библиотека новее схемы 2.x — накат отстал (этап 6, п. 10)
|
|
285
330
|
if (rev < DDL_REVISION)
|
|
286
|
-
|
|
331
|
+
lwarn('api.schema_older', { schema: opts.schema, rev, want: DDL_REVISION }, { log: base });
|
|
287
332
|
// схему обновил более новый пакет (во время раскатки старые экземпляры ещё работают): подключаемся, но предупреждаем —
|
|
288
333
|
// этот пакет может не знать функций и колонок новой ревизии; up() такую схему не трогает и отказывает
|
|
289
334
|
else if (rev > DDL_REVISION)
|
|
290
|
-
|
|
335
|
+
lwarn('api.schema_newer', { schema: opts.schema, rev, known: DDL_REVISION }, { log: base });
|
|
291
336
|
else {
|
|
292
337
|
// ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
|
|
293
338
|
const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
|
|
294
339
|
if (drift)
|
|
295
|
-
|
|
340
|
+
warnText(drift, undefined, base);
|
|
341
|
+
}
|
|
342
|
+
let token = opts.token ?? null;
|
|
343
|
+
const apiKey = opts.apiKey ?? opts.service;
|
|
344
|
+
if (!token && apiKey) {
|
|
345
|
+
const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
|
|
346
|
+
throw fromDbError(e);
|
|
347
|
+
});
|
|
348
|
+
token = r.t;
|
|
296
349
|
}
|
|
297
|
-
// режим сигналов установки (план 1.1, §2.2)
|
|
350
|
+
// режим сигналов установки (план 1.1, §2.2)
|
|
298
351
|
const [sm] = await sql.unsafe(`select ${S}.signal_mode() as m`).catch((e) => {
|
|
299
|
-
if (e.code === '42883')
|
|
300
|
-
return [{ m: 'class' }];
|
|
301
352
|
throw fromDbError(e);
|
|
302
353
|
});
|
|
303
354
|
const mode = SIGNAL_MODES.includes(sm?.m) ? sm.m : 'class';
|
|
304
355
|
const sig = new Signals(mode);
|
|
305
|
-
|
|
356
|
+
// отправка сигналов не удалась — предупреждение только при заданном логгере (без него — молча, как прежде)
|
|
357
|
+
const notifier = new Notifier(sql, opts.schema, opts.notifyIntervalMs ?? 100, (e) => {
|
|
358
|
+
if (base.active)
|
|
359
|
+
lwarn('log.notify_failed', { message: safeErrorText(e) }, { log: base });
|
|
360
|
+
}, base);
|
|
306
361
|
const passes = new AclPasses(S);
|
|
307
362
|
// режим none: сигналов нет — без LISTEN, кэш только по сроку (ttlOnly), подписка опрашивает журнал
|
|
308
363
|
const listen = opts.listen !== false && mode !== 'none';
|
|
@@ -312,7 +367,10 @@ export async function connect(opts) {
|
|
|
312
367
|
// продление сессии (§2.9): отдельным вызовом не чаще раза в минуту; ошибка (реплика,
|
|
313
368
|
// транзакции только для чтения) не мешает работе
|
|
314
369
|
let refreshedAt = Date.now();
|
|
370
|
+
// время последней удачной операции: после простоя дольше минуты сессию проверяют до операции (ctx.before)
|
|
371
|
+
let lastOk = Date.now();
|
|
315
372
|
const touch = () => {
|
|
373
|
+
lastOk = Date.now();
|
|
316
374
|
if (!token || opts.refreshSession === false || Date.now() - refreshedAt < 60_000)
|
|
317
375
|
return;
|
|
318
376
|
refreshedAt = Date.now();
|
|
@@ -320,8 +378,76 @@ export async function connect(opts) {
|
|
|
320
378
|
};
|
|
321
379
|
// в режиме none нотификатор не отправляет: letopis.changed своей транзакции сбрасывает только свой кэш
|
|
322
380
|
const ctx = { sql, token, notifier: mode === 'none' ? null : notifier, passes, after: touch,
|
|
323
|
-
...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
|
|
324
|
-
const root = { tenant: null, registry: new Registry() };
|
|
381
|
+
...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}), ...(base.active ? { log: base } : {}) };
|
|
382
|
+
const root = { tenant: null, registry: new Registry(), account: null };
|
|
383
|
+
// подключение по ключу APIKEY: сессия истекает по простою (срок — 7 суток), и процесс, простоявший дольше, получал
|
|
384
|
+
// бы no_session на каждый вызов. Ключ обменивается на новую сессию заново (один обмен на все ждущие вызовы), с тем
|
|
385
|
+
// же рабочим арендатором, что выбрал auth.switch(); операция повторяется один раз (runTx, ExplicitTx.open)
|
|
386
|
+
if (apiKey && !opts.token) {
|
|
387
|
+
let renewing = null;
|
|
388
|
+
const exchange = async () => {
|
|
389
|
+
const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]);
|
|
390
|
+
const next = r?.t ? String(r.t) : null;
|
|
391
|
+
const want = root.tenant;
|
|
392
|
+
if (next && want) {
|
|
393
|
+
await runTx({ sql, token: next, notifier: null }, async (q) => {
|
|
394
|
+
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
|
|
395
|
+
if (String(t?.t ?? '').toLowerCase() !== want.toLowerCase())
|
|
396
|
+
await q.unsafe(`select ${S}.auth_switch($1::uuid)`, [want]);
|
|
397
|
+
}, { retries: 0 });
|
|
398
|
+
}
|
|
399
|
+
return next;
|
|
400
|
+
};
|
|
401
|
+
ctx.renew = async (failed) => {
|
|
402
|
+
// ключ уже обменян другим вызовом — достаточно нового токена
|
|
403
|
+
if (token && failed.token !== token) {
|
|
404
|
+
failed.token = token;
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
renewing ??= exchange().catch(() => null).finally(() => {
|
|
408
|
+
renewing = null;
|
|
409
|
+
});
|
|
410
|
+
const next = await renewing;
|
|
411
|
+
if (!next) {
|
|
412
|
+
if (base.active)
|
|
413
|
+
lwarn('log.renew_failed', undefined, { log: base });
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
if (base.wants('session.renew'))
|
|
417
|
+
base.emit('session.renew', { tenant: root.tenant ?? undefined, account: root.account ?? undefined });
|
|
418
|
+
token = next;
|
|
419
|
+
ctx.token = next;
|
|
420
|
+
failed.token = next;
|
|
421
|
+
refreshedAt = Date.now();
|
|
422
|
+
return true;
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
// после простоя дольше минуты — продление до операции: сессия, истёкшая по простою, даёт no_session (подключение по
|
|
426
|
+
// ключу выдаёт новую), а не анонимное чтение с пустым ответом; ошибка продления другого рода (реплика, транзакция
|
|
427
|
+
// только для чтения) работе не мешает, как у touch
|
|
428
|
+
let checking = null;
|
|
429
|
+
const checkSession = async () => {
|
|
430
|
+
try {
|
|
431
|
+
await runTx({ sql, token, notifier: null }, (q) => q.unsafe(`select ${S}.auth_refresh()`), { retries: 0 });
|
|
432
|
+
}
|
|
433
|
+
catch (e) {
|
|
434
|
+
if (!(e instanceof LetopisError && e.code === 'no_session'))
|
|
435
|
+
return;
|
|
436
|
+
if (!ctx.renew || !(await ctx.renew(ctx)))
|
|
437
|
+
throw e;
|
|
438
|
+
}
|
|
439
|
+
refreshedAt = lastOk = Date.now();
|
|
440
|
+
};
|
|
441
|
+
ctx.before = async (c) => {
|
|
442
|
+
if (!token || opts.refreshSession === false || Date.now() - lastOk < 60_000)
|
|
443
|
+
return;
|
|
444
|
+
checking ??= checkSession().finally(() => {
|
|
445
|
+
checking = null;
|
|
446
|
+
});
|
|
447
|
+
await checking;
|
|
448
|
+
if (token && c.token !== token)
|
|
449
|
+
c.token = token;
|
|
450
|
+
};
|
|
325
451
|
// классы арендатора tenant под контекстом txc; null — контекст уже не в этом арендаторе (сессия кончилась,
|
|
326
452
|
// пользователь выбыл из арендатора): тогда прежний реестр остаётся, а не пустеет (доработки после точки Б)
|
|
327
453
|
const loadClasses = (txc, tenant) => runTx(txc, async (q) => {
|
|
@@ -347,12 +473,19 @@ export async function connect(opts) {
|
|
|
347
473
|
const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
|
|
348
474
|
// only — токены арендаторов из сигналов Class (режимы tenant и opaque): реестры db.as() только этих арендаторов
|
|
349
475
|
const refresh = async (only) => {
|
|
476
|
+
const t0 = performance.now();
|
|
350
477
|
const rows = await runTx(ctx, async (q) => {
|
|
351
|
-
|
|
478
|
+
// аккаунт сессии — для полей account событий лога корня
|
|
479
|
+
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t, ${S}.ctx_account() as a`);
|
|
352
480
|
root.tenant = t?.t ?? null;
|
|
481
|
+
root.account = t?.a ?? null;
|
|
353
482
|
return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
|
|
354
483
|
});
|
|
355
484
|
root.registry.load(rows);
|
|
485
|
+
if (base.wants('registry.refresh')) {
|
|
486
|
+
base.emit('registry.refresh', { op: 'refresh', ms: performance.now() - t0, rows: rows.length, tenant: root.tenant ?? undefined,
|
|
487
|
+
account: root.account ?? undefined, ...(only ? { detail: { tenants: only.size } } : {}) });
|
|
488
|
+
}
|
|
356
489
|
for (const [tenant, o] of others) {
|
|
357
490
|
if (only && !only.has(sig.tenantToken(tenant) ?? ''))
|
|
358
491
|
continue;
|
|
@@ -411,15 +544,22 @@ export async function connect(opts) {
|
|
|
411
544
|
return null;
|
|
412
545
|
throw e;
|
|
413
546
|
});
|
|
414
|
-
// ключ
|
|
547
|
+
// ключ сменился: ответы с токенами старого ключа сигналы больше не узнают
|
|
415
548
|
if (sig.setKey(key))
|
|
416
549
|
cache?.clear();
|
|
417
550
|
};
|
|
551
|
+
// токен, который не даёт сессии (погашен, истёк, неверен), — отказ connect(), а не подключение без сессии, где первый
|
|
552
|
+
// же шаг дал бы invalid_class про личный арендатор (второй раунд 2.1, A5)
|
|
553
|
+
if (opts.token) {
|
|
554
|
+
const [s] = await runTx(ctx, (q) => q.unsafe(`select account from ${S}.ctx()`), { read: true, retries: 0 });
|
|
555
|
+
if (!s?.account)
|
|
556
|
+
throw lerr('api.token_not_active');
|
|
557
|
+
}
|
|
418
558
|
await fetchKey();
|
|
419
559
|
await refresh();
|
|
420
|
-
// ключ, реестр и пропуска прав — заново по
|
|
421
|
-
// без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том
|
|
422
|
-
// поддельными сигналами) не становится потоком перечитываний
|
|
560
|
+
// ключ, реестр и пропуска прав — заново после переподключения слушателя, по незнакомому токену, нагрузке не по
|
|
561
|
+
// режиму, а у процесса без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том
|
|
562
|
+
// числе поддельными сигналами) не становится потоком перечитываний
|
|
423
563
|
const resync = throttle(() => {
|
|
424
564
|
passes.clear();
|
|
425
565
|
void fetchKey().then(() => refresh()).catch(() => { });
|
|
@@ -462,6 +602,8 @@ export async function connect(opts) {
|
|
|
462
602
|
// (пере)подключение слушателя — кэш целиком, после обрыва — и ключ с реестром (сигналы потеряны)
|
|
463
603
|
const onSignal = (payload) => {
|
|
464
604
|
const s = sig.parse(payload);
|
|
605
|
+
if (base.wants('signal'))
|
|
606
|
+
base.emit('signal', { detail: { ...s } });
|
|
465
607
|
if (s.kind === 'class' && ACL_CLASSES.has(s.cls))
|
|
466
608
|
passes.clear();
|
|
467
609
|
if (s.kind === 'class' && s.cls === 'Class')
|
|
@@ -475,6 +617,8 @@ export async function connect(opts) {
|
|
|
475
617
|
let listened = false;
|
|
476
618
|
const listener = listen
|
|
477
619
|
? await sql.listen(opts.schema, onSignal, () => {
|
|
620
|
+
if (base.wants('signal'))
|
|
621
|
+
base.emit('signal', { detail: { kind: 'listen', reconnect: listened } });
|
|
478
622
|
if (listened)
|
|
479
623
|
resync();
|
|
480
624
|
listened = true;
|
|
@@ -508,9 +652,10 @@ export async function connect(opts) {
|
|
|
508
652
|
let stopMaintain = null;
|
|
509
653
|
const subs = new Set();
|
|
510
654
|
const shared = {
|
|
511
|
-
sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
|
|
655
|
+
sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs, log: base,
|
|
512
656
|
admin: Boolean(bypass && (role.rolsuper || role.can_set)),
|
|
513
657
|
ownerRole: owner,
|
|
658
|
+
...(adminSql ? { adminSql, adminReady } : {}),
|
|
514
659
|
cache,
|
|
515
660
|
sig,
|
|
516
661
|
allTenants: Boolean(bypass),
|
|
@@ -526,7 +671,7 @@ export async function connect(opts) {
|
|
|
526
671
|
// (план 2.0, 7.4 №8)
|
|
527
672
|
for (const s of [...subs])
|
|
528
673
|
s.close();
|
|
529
|
-
// идущий maintain() по расписанию
|
|
674
|
+
// идущий maintain() по расписанию доделывается: пул закрывается после него, а не посреди транзакции
|
|
530
675
|
await stopMaintain?.();
|
|
531
676
|
resync.stop();
|
|
532
677
|
startReload.stop();
|
|
@@ -536,16 +681,28 @@ export async function connect(opts) {
|
|
|
536
681
|
await listener?.unlisten();
|
|
537
682
|
if (own)
|
|
538
683
|
await sql.end({ timeout: 5 });
|
|
684
|
+
if (ownAdmin)
|
|
685
|
+
await adminSql.end({ timeout: 5 });
|
|
539
686
|
},
|
|
540
687
|
};
|
|
541
688
|
const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
|
|
542
689
|
if (maintainEvery !== undefined)
|
|
543
|
-
stopMaintain = scheduleMaintain((
|
|
690
|
+
stopMaintain = scheduleMaintain(() => db.maintain(), maintainEvery, base);
|
|
691
|
+
if (base.enabled) {
|
|
692
|
+
Object.assign(info, {
|
|
693
|
+
schema: opts.schema, ...(opts.dsn ? { dsn: maskDsn(opts.dsn) } : {}), pool: own ? 'own' : 'external', ...(own ? { max: opts.max ?? 10 } : {}),
|
|
694
|
+
...(adminSql ? { adminPool: ownAdmin ? 'own' : 'external' } : {}), bypass: Boolean(bypass), revision: rev, signals: mode, listen,
|
|
695
|
+
cache: Boolean(cache), ...(maintainEvery !== undefined ? { maintain: maintainEvery } : {}),
|
|
696
|
+
session: opts.token ? 'token' : apiKey ? 'service' : 'none', account: root.account,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
544
699
|
return db;
|
|
545
700
|
}
|
|
546
701
|
catch (e) {
|
|
547
702
|
if (own)
|
|
548
703
|
await sql.end({ timeout: 1 });
|
|
704
|
+
if (ownAdmin)
|
|
705
|
+
await adminSql.end({ timeout: 1 });
|
|
549
706
|
throw e;
|
|
550
707
|
}
|
|
551
708
|
}
|