letopis 2.0.0 → 3.0.1
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 +491 -414
- package/CHANGELOG.md +361 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +969 -210
- package/api-contract.json +385 -0
- package/dist/acl.d.ts +7 -2
- package/dist/acl.js +19 -2
- package/dist/admin.d.ts +7 -1
- package/dist/admin.js +16 -9
- package/dist/auth.d.ts +47 -11
- package/dist/auth.js +129 -15
- package/dist/chain.d.ts +52 -25
- package/dist/chain.js +445 -64
- package/dist/cli.js +72 -20
- package/dist/errors.d.ts +23 -6
- package/dist/errors.js +52 -13
- package/dist/index.d.ts +32 -15
- package/dist/index.js +209 -68
- package/dist/internal.d.ts +2 -0
- package/dist/load.d.ts +10 -6
- package/dist/load.js +30 -11
- package/dist/log.d.ts +121 -0
- package/dist/log.js +654 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +34 -16
- package/dist/messages/access.d.ts +10 -0
- package/dist/messages/access.js +13 -0
- package/dist/messages/api.d.ts +76 -0
- package/dist/messages/api.js +109 -7
- package/dist/messages/boot.d.ts +5 -0
- package/dist/messages/boot.js +11 -4
- package/dist/messages/codes.js +4 -4
- package/dist/messages/index.d.ts +5 -2
- package/dist/messages/index.js +5 -2
- package/dist/messages/log.d.ts +232 -0
- package/dist/messages/log.js +105 -0
- package/dist/messages/mcp.js +7 -7
- package/dist/messages/model.d.ts +40 -1
- package/dist/messages/model.js +52 -3
- package/dist/messages/query.d.ts +10 -0
- package/dist/messages/query.js +13 -0
- package/dist/messages/sql-core.d.ts +15 -1
- package/dist/messages/sql-core.js +20 -3
- package/dist/messages/sql-time.d.ts +15 -0
- package/dist/messages/sql-time.js +23 -0
- package/dist/messages/write.d.ts +52 -0
- package/dist/messages/write.js +77 -6
- package/dist/model.d.ts +10 -2
- package/dist/model.js +28 -6
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +6 -0
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +77 -25
- package/dist/sync.d.ts +4 -5
- package/dist/sync.js +53 -4
- package/dist/tx.d.ts +10 -1
- package/dist/tx.js +44 -2
- package/dist/typed.d.ts +18 -14
- package/dist/types.d.ts +91 -8
- package/dist/types.js +8 -3
- package/dist/up.d.ts +8 -5
- package/dist/up.js +71 -23
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +9 -1
- package/dist/validate.js +24 -7
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +13 -0
- package/dist/write.d.ts +84 -3
- package/dist/write.js +427 -40
- package/llms.txt +38 -17
- package/package.json +12 -6
- package/sql/20-context.sql +11 -3
- package/sql/30-validate.sql +51 -10
- package/sql/40-class.sql +21 -1
- package/sql/60-write.sql +18 -6
- package/sql/80-auth.sql +88 -17
- package/sql/90-time.sql +34 -6
- package/sql/99-revision.sql +5 -1
- 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 +5 -1
package/dist/index.js
CHANGED
|
@@ -8,18 +8,19 @@
|
|
|
8
8
|
* Этап 6: история (versions, asOf, restore), maintain, watch, кэш результатов.
|
|
9
9
|
*/
|
|
10
10
|
import postgres from 'postgres';
|
|
11
|
-
import { LetopisError, checkOptions, fromDbError, lerr, lwarn } from './errors.js';
|
|
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
20
|
import { load, LOAD_OPTIONS } from './load.js';
|
|
20
21
|
import { makeBatch, startEntity, startStep } from './chain.js';
|
|
21
22
|
import { or as orFilters } from './ops.js';
|
|
22
|
-
import { DDL_REVISION } from './types.js';
|
|
23
|
+
import { DDL_REVISION, SCHEMA_VERSION } from './types.js';
|
|
23
24
|
import { ACL_CLASSES, ResultCache } from './cache.js';
|
|
24
25
|
import { Subscription } from './watch.js';
|
|
25
26
|
import { SIGNAL_MODES, Signals, bucket, throttle } from './signals.js';
|
|
@@ -35,8 +36,7 @@ export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has,
|
|
|
35
36
|
/**
|
|
36
37
|
* Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
|
|
37
38
|
* вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и». В шаге подключения
|
|
38
|
-
* с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт
|
|
39
|
-
* фабрика, что в реестре операторов (ops.ts), — другой у неё только тип.
|
|
39
|
+
* с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт шаг.
|
|
40
40
|
*/
|
|
41
41
|
export const or = orFilters;
|
|
42
42
|
export { cursorOf } from './sql.js';
|
|
@@ -44,30 +44,58 @@ export { inc, isInc } from './write.js';
|
|
|
44
44
|
export { totpCode, hashPassword, verifyHash } from './auth.js';
|
|
45
45
|
export { up, createTenant } from './up.js';
|
|
46
46
|
export { RESERVED_CLASS_NAMES, CHAIN_METHODS, DB_METHODS } from './types.js';
|
|
47
|
+
export { LOG_EVENTS } from './messages/index.js';
|
|
47
48
|
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
48
49
|
const SCHEMA_NAME = /^v\d+\.[a-z_][a-z0-9_]{0,40}$/;
|
|
49
50
|
/** Все опции connect(): неизвестная — invalid_data с похожими именами (checkOptions; план 2.0, 7.10 №4). */
|
|
50
51
|
const CONNECT_OPTIONS = {
|
|
51
52
|
dsn: true, sql: true, schema: true, token: true, apiKey: true, service: true, refreshSession: true, allowBypassRls: true,
|
|
52
53
|
admin: true, owner: true, notifyIntervalMs: true, max: true, onQuery: true, slowMs: true, models: true, listen: true,
|
|
53
|
-
cache: true, maintain: true,
|
|
54
|
+
cache: true, maintain: true, log: true, logLevel: true, redact: true,
|
|
54
55
|
};
|
|
55
56
|
/** Опции watch() (WatchOptions). */
|
|
56
57
|
const WATCH_OPTIONS = { from: true, classes: true, batch: true, pollMs: true };
|
|
58
|
+
/** Опции db.begin() (второй раунд 2.1, A4). */
|
|
59
|
+
const BEGIN_OPTIONS = { reason: true };
|
|
57
60
|
/**
|
|
58
61
|
* То же подключение без строгих типов: шагом может быть любое имя, в том числе известное только во время выполнения, —
|
|
59
|
-
* `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход,
|
|
62
|
+
* `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, шаг роли) не меняются.
|
|
60
63
|
*/
|
|
61
64
|
export function loose(db) {
|
|
62
65
|
return db;
|
|
63
66
|
}
|
|
64
67
|
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
68
|
+
/** Методы фасада, которые отвечают сразу, а не обещанием: после close() они бросают api.closed синхронно. */
|
|
69
|
+
const SYNC_MEMBERS = new Set(['idOf', 'entity', 'watch', 'batch']);
|
|
70
|
+
/** Свойства фасада, которые после close() читаются как прежде: не обращаются к базе. */
|
|
71
|
+
const PASSIVE_MEMBERS = new Set(['close', 'schema', 'token', 'tenant', 'registry', 'cache']);
|
|
72
|
+
/** Исполнитель, который после close() корня отказывает api.closed: postgres.js reserve() открыл бы закрытый пул заново. */
|
|
73
|
+
function openOnly(sh, exec) {
|
|
74
|
+
return {
|
|
75
|
+
explicit: exec.explicit,
|
|
76
|
+
run: (fn, opts) => (sh.closed ? Promise.reject(lerr('api.closed')) : exec.run(fn, opts)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Служебный объект фасада (auth, acl) после close(): каждый метод — отказ api.closed. */
|
|
80
|
+
function closedApi(api) {
|
|
81
|
+
return new Proxy(api, {
|
|
82
|
+
get(t, prop, r) {
|
|
83
|
+
const v = Reflect.get(t, prop, r);
|
|
84
|
+
return typeof v === 'function' ? () => Promise.reject(lerr('api.closed')) : v;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
65
88
|
/** Фасад db: пул (exec — исполнитель пула) или явная транзакция (tx). */
|
|
66
|
-
function makeDb(sh, scope,
|
|
89
|
+
function makeDb(sh, scope, raw, owner, tx) {
|
|
67
90
|
const txc = scope.txc;
|
|
91
|
+
// после close() корня цепочки (и созданные до него), auth, acl, db.sql — отказ api.closed; raw — у maintain(), который
|
|
92
|
+
// идёт по расписанию: close() его дожидается
|
|
93
|
+
const exec = openOnly(sh, raw);
|
|
94
|
+
// логгер фасада: арендатор и аккаунт — в момент события (корень меняет их auth.switch), метка транзакции — от db.begin()
|
|
95
|
+
const log = (txc.log ?? sh.log).child({ who: () => ({ tenant: scope.tenant(), account: txc.account ?? sh.root.account }) });
|
|
68
96
|
const chainCtx = {
|
|
69
|
-
reg: scope.registry(), schema: sh.schema, exec, tenant: scope.tenant, owner, onQuery: sh.onQuery, slowMs: sh.slowMs,
|
|
70
|
-
reason: txc.reason ?? '', txReason: txc.reason ?? '',
|
|
97
|
+
reg: scope.registry(), schema: sh.schema, exec, tenant: scope.tenant, account: () => txc.account ?? sh.root.account, hasToken: () => Boolean(txc.token), owner, onQuery: sh.onQuery, slowMs: sh.slowMs,
|
|
98
|
+
reason: txc.reason ?? '', txReason: txc.reason ?? '', log,
|
|
71
99
|
...(sh.cache ? { cache: { store: sh.cache, who: () => ({ token: txc.token, account: txc.account, tenant: scope.tenant() }) } } : {}),
|
|
72
100
|
};
|
|
73
101
|
// реестр корня меняется после auth.switch — цепочки берут действующий
|
|
@@ -78,13 +106,13 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
78
106
|
const noTx = (what) => {
|
|
79
107
|
throw lerr('api.tx_only', { what });
|
|
80
108
|
};
|
|
81
|
-
const auth = makeAuth(sh.S, exec, async () => {
|
|
109
|
+
const auth = instrument(makeAuth(sh.S, exec, async () => {
|
|
82
110
|
sh.passes.clear();
|
|
83
111
|
await sh.refresh();
|
|
84
|
-
}, scope.root && !tx);
|
|
112
|
+
}, scope.root && !tx), log, 'auth', AUTH_LOG, { switch: () => scope.tenant() });
|
|
85
113
|
// запись правил (grant, deny, revoke, roles) идёт цепочками самого фасада — он объявлен ниже, берётся при вызове
|
|
86
|
-
const acl = makeAcl(sh.S, exec, () => facade);
|
|
87
|
-
const adm = { S: sh.S, txc, exec };
|
|
114
|
+
const acl = instrument(makeAcl(sh.S, exec, () => facade), log, 'acl', ACL_LOG);
|
|
115
|
+
const adm = { S: sh.S, txc, exec: raw };
|
|
88
116
|
// один процесс (план 2.0, 7.9 №3): у корня с connect({ admin }) администраторские вызовы идут вторым пулом с той же
|
|
89
117
|
// сессией, а цепочки и db.sql — пулом приложения под RLS; db.as() и db.begin() администраторский пул не берут
|
|
90
118
|
const viaAdmin = scope.root && !tx && sh.adminSql ? sh.adminSql : null;
|
|
@@ -100,19 +128,35 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
100
128
|
get registry() { return scope.registry(); },
|
|
101
129
|
auth,
|
|
102
130
|
acl,
|
|
103
|
-
sql: (strings, ...values) => exec.run((q) => q(strings, ...values), { raw: true }),
|
|
104
|
-
transaction: (fn) =>
|
|
105
|
-
|
|
131
|
+
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 } })),
|
|
132
|
+
transaction: (fn) => {
|
|
133
|
+
// в db.begin() — та же транзакция: своих событий tx.* нет
|
|
134
|
+
if (exec.explicit || !log.enabled)
|
|
135
|
+
return traced(log, null, { op: 'transaction' }, () => exec.run(fn));
|
|
136
|
+
const tl = log.child({ tx: nextTx('t') });
|
|
137
|
+
tl.emit('tx.begin', { op: 'transaction', detail: { isolation: 'read committed' } });
|
|
138
|
+
const t0 = performance.now();
|
|
139
|
+
return exec.run(fn).then((r) => {
|
|
140
|
+
// сырой SQL: была ли запись, не знаем — фиксация такой транзакции всегда info (доказательство фиксации)
|
|
141
|
+
tl.emit('tx.commit', { op: 'transaction', ms: performance.now() - t0, level: 'info', detail: { raw: true } });
|
|
142
|
+
return r;
|
|
143
|
+
}, (e) => {
|
|
144
|
+
tl.error(e, { op: 'transaction', ms: performance.now() - t0 });
|
|
145
|
+
tl.emit('tx.rollback', { op: 'transaction', ms: performance.now() - t0, detail: { reason: 'error' } });
|
|
146
|
+
throw e;
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
idOf: (cls, key) => guarded(log, { op: 'idOf' }, () => {
|
|
106
150
|
const tenant = scope.tenant();
|
|
107
151
|
if (!tenant)
|
|
108
152
|
throw lerr('api.id_of_no_tenant');
|
|
109
153
|
const c = scope.registry().resolve(cls);
|
|
110
154
|
return idOfClass({ name: c.name, key: c.key, ends: Object.keys(c.ends) }, key, { tenant });
|
|
111
|
-
},
|
|
112
|
-
refresh: sh.refresh,
|
|
155
|
+
}),
|
|
156
|
+
refresh: () => traced(log, null, { op: 'refresh' }, sh.refresh),
|
|
113
157
|
flushSignals: () => sh.notifier.flush(),
|
|
114
|
-
maintain: () => (viaAdmin ? adminReady().then(() => runMaintain(admCtx())) : runMaintain(adm)),
|
|
115
|
-
watch: (o = {}) => {
|
|
158
|
+
maintain: () => traced(log, 'maintain', { op: 'maintain' }, () => (viaAdmin ? adminReady().then(() => runMaintain(admCtx())) : runMaintain(adm)), (r) => ({ detail: { ...r } })),
|
|
159
|
+
watch: (o = {}) => guarded(log, { op: 'watch' }, () => {
|
|
116
160
|
checkOptions('watch', o, WATCH_OPTIONS);
|
|
117
161
|
const reg = scope.registry();
|
|
118
162
|
const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
|
|
@@ -132,14 +176,15 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
132
176
|
onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); },
|
|
133
177
|
tenant: () => (sh.allTenants ? null : sh.sig.tenantToken(scope.tenant())),
|
|
134
178
|
} : {}),
|
|
179
|
+
...(log.enabled ? { log } : {}),
|
|
135
180
|
}, o));
|
|
136
|
-
},
|
|
181
|
+
}),
|
|
137
182
|
cache: {
|
|
138
183
|
clear: () => sh.cache?.clear(),
|
|
139
184
|
stats: () => sh.cache?.stats() ?? { size: 0, hits: 0, misses: 0, stores: 0, raced: 0, signals: 0, listens: 0, enabled: false },
|
|
140
185
|
},
|
|
141
|
-
entity: (x) => startEntity(chainCtx, x),
|
|
142
|
-
as:
|
|
186
|
+
entity: (x) => guarded(log, { op: 'entity' }, () => startEntity(chainCtx, x)),
|
|
187
|
+
as: (account, o) => traced(log, 'auth.as', { op: 'as' }, async () => {
|
|
143
188
|
if (tx)
|
|
144
189
|
throw lerr('api.as_in_tx');
|
|
145
190
|
const id = account === null ? undefined : typeof account === 'object' ? account.id : account;
|
|
@@ -165,16 +210,26 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
165
210
|
if (!r?.account)
|
|
166
211
|
throw lerr('api.as_no_session');
|
|
167
212
|
}
|
|
168
|
-
},
|
|
169
|
-
|
|
213
|
+
}, (d) => ({ tenant: d.tenant ?? undefined, account: typeof account === 'object' && account !== null ? account.id : account ?? undefined,
|
|
214
|
+
detail: { ...(o?.tenant !== undefined ? { tenant: o.tenant } : {}), ...(o?.owner !== undefined ? { owner: o.owner } : {}) } })),
|
|
215
|
+
begin: (o) => traced(log, null, { op: 'begin' }, async () => {
|
|
216
|
+
// аргумент — только опции (второй раунд 2.1, A4): begin(fn) функцию не вызывал, а транзакция оставалась открытой
|
|
217
|
+
if (o !== undefined && o !== null && (typeof o !== 'object' || Array.isArray(o))) {
|
|
218
|
+
throw lerr('api.begin_arg', { type: Array.isArray(o) ? 'array' : typeof o });
|
|
219
|
+
}
|
|
220
|
+
checkOptions('begin', o, BEGIN_OPTIONS);
|
|
170
221
|
if (tx)
|
|
171
222
|
throw lerr('api.nested_begin');
|
|
172
|
-
// причина блока ставится в начале транзакции (setupSql) и действует на все её
|
|
173
|
-
|
|
223
|
+
// причина блока ставится в начале транзакции (setupSql) и действует на все её операции; логгер — с меткой
|
|
224
|
+
// транзакции: её события (tx.*) и все операции фасада db.begin() несут поле tx
|
|
225
|
+
const tl = log.enabled ? (txc.log ?? sh.log).child({ tx: nextTx('b'), writes: { n: 0 } }) : undefined;
|
|
226
|
+
const btx = o?.reason !== undefined || tl
|
|
227
|
+
? { ...txc, ...(o?.reason !== undefined ? { reason: checkReason(o.reason, 'db.begin()') } : {}), ...(tl ? { log: tl } : {}) }
|
|
228
|
+
: txc;
|
|
174
229
|
const t = await ExplicitTx.open(btx);
|
|
175
230
|
return makeDb(sh, btx === txc ? scope : { ...scope, txc: btx }, t, owner, t);
|
|
176
|
-
},
|
|
177
|
-
load:
|
|
231
|
+
}),
|
|
232
|
+
load: (input, o) => traced(log, 'load', { op: 'load' }, async () => {
|
|
178
233
|
// опции — до всего прочего: неизвестная — отказ раньше admin_required пула администратора (план 2.0, 7.10 №4)
|
|
179
234
|
checkOptions('load', o, LOAD_OPTIONS);
|
|
180
235
|
if (tx)
|
|
@@ -184,25 +239,45 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
184
239
|
return load({ sql: viaAdmin, schema: sh.schema, tenant: scope.tenant(), admin: true, ownerRole: sh.ownerRole }, input, o);
|
|
185
240
|
}
|
|
186
241
|
return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
|
|
187
|
-
},
|
|
242
|
+
}, (r) => ({ rows: r.loaded + r.updated, detail: { ...r } })),
|
|
188
243
|
batch: (name, o) => {
|
|
189
244
|
let q = batches.get(name);
|
|
190
245
|
if (!q)
|
|
191
246
|
batches.set(name, (q = []));
|
|
192
247
|
return makeBatch(o?.reason !== undefined ? { ...chainCtx, reason: checkReason(o.reason, 'db.batch()') } : chainCtx, q);
|
|
193
248
|
},
|
|
194
|
-
lock:
|
|
195
|
-
commit: async (
|
|
196
|
-
rollback: async (
|
|
197
|
-
|
|
249
|
+
lock: (...keys) => traced(log, 'lock', { op: 'lock', detail: { keys } }, async () => (tx ? tx.lock(...keys) : noTx('lock()'))),
|
|
250
|
+
commit: (tr) => traced(log, null, { op: 'commit' }, async () => (tr ? tr.commit() : tx ? tx.commit() : noTx('commit()'))),
|
|
251
|
+
rollback: (tr) => traced(log, null, { op: 'rollback' }, async () => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()'))),
|
|
252
|
+
// подключение из as() пул не закрывает: пул общий с корнем (план 2.1, A12) — закрывает его close() корня
|
|
253
|
+
// повторный close() и close() у db.as() или db.begin() закрытого подключения — без ошибки
|
|
254
|
+
close: () => traced(log, !tx && scope.root ? 'close' : null, { op: 'close', detail: { schema: sh.schema } }, () => (tx ? (sh.closed ? Promise.resolve() : tx.rollback()) : scope.root ? sh.close() : Promise.resolve())),
|
|
198
255
|
};
|
|
199
256
|
/** Свойство фасада: служебное — из core, иначе шаг класса, алиаса или роли. */
|
|
200
257
|
const member = (t, prop, r) => {
|
|
258
|
+
if (sh.closed && typeof prop === 'string' && prop !== 'then' && !PASSIVE_MEMBERS.has(prop))
|
|
259
|
+
return closedMember(t, prop, r);
|
|
201
260
|
if (typeof prop === 'symbol' || prop in t)
|
|
202
261
|
return Reflect.get(t, prop, r);
|
|
203
262
|
if (prop === 'then')
|
|
204
263
|
return undefined;
|
|
205
|
-
|
|
264
|
+
// неизвестный класс или роль — событие error с именем шага
|
|
265
|
+
return log.enabled ? guarded(log, { op: prop, chain: prop }, () => startStep(chainCtx, prop)) : startStep(chainCtx, prop);
|
|
266
|
+
};
|
|
267
|
+
/** Свойство фасада после close(): методы и шаги — отказ api.closed (обещанием, у шагов и синхронных методов — сразу). */
|
|
268
|
+
const closedMember = (t, prop, r) => {
|
|
269
|
+
if (prop in t) {
|
|
270
|
+
const v = Reflect.get(t, prop, r);
|
|
271
|
+
if (prop === 'auth' || prop === 'acl')
|
|
272
|
+
return closedApi(v);
|
|
273
|
+
if (typeof v !== 'function')
|
|
274
|
+
return v;
|
|
275
|
+
if (!SYNC_MEMBERS.has(prop))
|
|
276
|
+
return () => Promise.reject(lerr('api.closed'));
|
|
277
|
+
}
|
|
278
|
+
return () => {
|
|
279
|
+
throw lerr('api.closed');
|
|
280
|
+
};
|
|
206
281
|
};
|
|
207
282
|
const facade = new Proxy(core, {
|
|
208
283
|
get(t, prop, r) {
|
|
@@ -211,7 +286,7 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
211
286
|
});
|
|
212
287
|
// модули пакета (letopis/mcp) узнают обход RLS по самому подключению, а не со слов вызывающего (internal.ts)
|
|
213
288
|
registerDb(facade, {
|
|
214
|
-
allTenants: sh.allTenants, explicit: Boolean(tx),
|
|
289
|
+
allTenants: sh.allTenants, explicit: Boolean(tx), log,
|
|
215
290
|
...(viaAdmin ? { adminTx: (fn) => adminReady().then(() => runTx({ ...txc, sql: viaAdmin }, fn)) } : {}),
|
|
216
291
|
});
|
|
217
292
|
return facade;
|
|
@@ -219,10 +294,28 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
219
294
|
export async function connect(opts) {
|
|
220
295
|
// опции — первыми: неизвестная — invalid_data с похожими именами до прочих проверок (план 2.0, 7.10 №4)
|
|
221
296
|
checkOptions('connect', opts, CONNECT_OPTIONS);
|
|
297
|
+
// логгер — до всего прочего: отказы connect() тоже идут событием error (логирование 3.0)
|
|
298
|
+
const base = makeLog({ log: opts.log, logLevel: opts.logLevel, redact: opts.redact });
|
|
299
|
+
const info = {};
|
|
300
|
+
return traced(base, 'connect', { op: 'connect' }, () => open(opts, base, info), (db) => ({ tenant: db.tenant ?? undefined, account: info.account ?? undefined, detail: info }));
|
|
301
|
+
}
|
|
302
|
+
/** connect() после проверки опций: base — логгер подключения, info — подробности события connect. */
|
|
303
|
+
async function open(opts, base, info) {
|
|
304
|
+
// имя без точки — как у up(): v2.<имя> (план 2.1, A12); дальше всё, и канал LISTEN, — по полному имени
|
|
305
|
+
if (typeof opts.schema === 'string' && !opts.schema.includes('.'))
|
|
306
|
+
opts = { ...opts, schema: `${SCHEMA_VERSION}.${opts.schema}` };
|
|
222
307
|
if (!SCHEMA_NAME.test(opts.schema))
|
|
223
308
|
throw lerr('api.connect_schema', { schema: opts.schema });
|
|
224
309
|
if (!opts.sql && !opts.dsn)
|
|
225
310
|
throw lerr('api.connect_no_dsn');
|
|
311
|
+
// ключ сервиса или токен передан, но пуст (null, '', не строка — например, повторный up() вернул serviceKey: null):
|
|
312
|
+
// отказ сразу, а не подключение без сессии и no_session на первой записи. undefined — опции нет (CLI, необязательный токен)
|
|
313
|
+
for (const name of ['apiKey', 'service', 'token']) {
|
|
314
|
+
const v = opts[name];
|
|
315
|
+
if (v !== undefined && (typeof v !== 'string' || v.trim() === '')) {
|
|
316
|
+
throw lerr('api.connect_empty_credential', { name, value: v === null ? 'null' : typeof v === 'string' ? `'${v}'` : typeof v });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
226
319
|
// интервал обслуживания — до пула и таймеров: неверный не оставляет за собой открытых соединений
|
|
227
320
|
const maintainEvery = opts.maintain === undefined ? undefined : parseInterval(opts.maintain);
|
|
228
321
|
if (opts.admin !== undefined && typeof opts.admin !== 'string' && typeof opts.admin !== 'function') {
|
|
@@ -231,13 +324,15 @@ export async function connect(opts) {
|
|
|
231
324
|
const own = !opts.sql;
|
|
232
325
|
// timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
|
|
233
326
|
const types = { timestamptz: { to: 1184, from: [1184], serialize: (v) => (v instanceof Date ? v.toISOString() : v), parse: (v) => v } };
|
|
234
|
-
|
|
327
|
+
// порог debug — каждый запрос пула даёт событие sql (traceSql); иначе пул как есть
|
|
328
|
+
const sql = traceSql((opts.sql ?? postgres(opts.dsn, { max: opts.max ?? 10, onnotice: () => { }, types })), base);
|
|
235
329
|
// один процесс (план 2.0, 7.9 №3): пул администратора postgres.js соединяется при первом запросе — до первого
|
|
236
330
|
// администраторского вызова соединений у него нет
|
|
237
331
|
const ownAdmin = typeof opts.admin === 'string';
|
|
238
|
-
const
|
|
332
|
+
const adminRaw = (typeof opts.admin === 'string'
|
|
239
333
|
? postgres(opts.admin, { max: 2, onnotice: () => { }, types, connection: { application_name: 'letopis admin' } })
|
|
240
334
|
: opts.admin);
|
|
335
|
+
const adminSql = adminRaw ? traceSql(adminRaw, base) : undefined;
|
|
241
336
|
const S = quote(opts.schema);
|
|
242
337
|
const owner = opts.owner ?? 'letopis_owner';
|
|
243
338
|
let adminChecked;
|
|
@@ -264,7 +359,7 @@ export async function connect(opts) {
|
|
|
264
359
|
throw lerr('api.connect_bypass_rls');
|
|
265
360
|
}
|
|
266
361
|
if (bypass)
|
|
267
|
-
lwarn('api.admin_connection');
|
|
362
|
+
lwarn('api.admin_connection', undefined, { log: base });
|
|
268
363
|
// ревизия движка — до первого вызова функций схемы (вход по ключу): схема letopis 1.x — отказ (план 2.0, 7.10 №5)
|
|
269
364
|
const [rv] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${opts.schema}`;
|
|
270
365
|
if (!rv)
|
|
@@ -273,16 +368,16 @@ export async function connect(opts) {
|
|
|
273
368
|
refuse1x(opts.schema, rev);
|
|
274
369
|
// библиотека новее схемы 2.x — накат отстал (этап 6, п. 10)
|
|
275
370
|
if (rev < DDL_REVISION)
|
|
276
|
-
lwarn('api.schema_older', { schema: opts.schema, rev, want: DDL_REVISION });
|
|
371
|
+
lwarn('api.schema_older', { schema: opts.schema, rev, want: DDL_REVISION }, { log: base });
|
|
277
372
|
// схему обновил более новый пакет (во время раскатки старые экземпляры ещё работают): подключаемся, но предупреждаем —
|
|
278
373
|
// этот пакет может не знать функций и колонок новой ревизии; up() такую схему не трогает и отказывает
|
|
279
374
|
else if (rev > DDL_REVISION)
|
|
280
|
-
lwarn('api.schema_newer', { schema: opts.schema, rev, known: DDL_REVISION });
|
|
375
|
+
lwarn('api.schema_newer', { schema: opts.schema, rev, known: DDL_REVISION }, { log: base });
|
|
281
376
|
else {
|
|
282
377
|
// ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
|
|
283
378
|
const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
|
|
284
379
|
if (drift)
|
|
285
|
-
|
|
380
|
+
warnText(drift, undefined, base);
|
|
286
381
|
}
|
|
287
382
|
let token = opts.token ?? null;
|
|
288
383
|
const apiKey = opts.apiKey ?? opts.service;
|
|
@@ -298,7 +393,11 @@ export async function connect(opts) {
|
|
|
298
393
|
});
|
|
299
394
|
const mode = SIGNAL_MODES.includes(sm?.m) ? sm.m : 'class';
|
|
300
395
|
const sig = new Signals(mode);
|
|
301
|
-
|
|
396
|
+
// отправка сигналов не удалась — предупреждение только при заданном логгере (без него — молча, как прежде)
|
|
397
|
+
const notifier = new Notifier(sql, opts.schema, opts.notifyIntervalMs ?? 100, (e) => {
|
|
398
|
+
if (base.active)
|
|
399
|
+
lwarn('log.notify_failed', { message: safeErrorText(e) }, { log: base });
|
|
400
|
+
}, base);
|
|
302
401
|
const passes = new AclPasses(S);
|
|
303
402
|
// режим none: сигналов нет — без LISTEN, кэш только по сроку (ttlOnly), подписка опрашивает журнал
|
|
304
403
|
const listen = opts.listen !== false && mode !== 'none';
|
|
@@ -319,8 +418,8 @@ export async function connect(opts) {
|
|
|
319
418
|
};
|
|
320
419
|
// в режиме none нотификатор не отправляет: letopis.changed своей транзакции сбрасывает только свой кэш
|
|
321
420
|
const ctx = { sql, token, notifier: mode === 'none' ? null : notifier, passes, after: touch,
|
|
322
|
-
...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
|
|
323
|
-
const root = { tenant: null, registry: new Registry() };
|
|
421
|
+
...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}), ...(base.active ? { log: base } : {}) };
|
|
422
|
+
const root = { tenant: null, registry: new Registry(), account: null };
|
|
324
423
|
// подключение по ключу APIKEY: сессия истекает по простою (срок — 7 суток), и процесс, простоявший дольше, получал
|
|
325
424
|
// бы no_session на каждый вызов. Ключ обменивается на новую сессию заново (один обмен на все ждущие вызовы), с тем
|
|
326
425
|
// же рабочим арендатором, что выбрал auth.switch(); операция повторяется один раз (runTx, ExplicitTx.open)
|
|
@@ -349,8 +448,13 @@ export async function connect(opts) {
|
|
|
349
448
|
renewing = null;
|
|
350
449
|
});
|
|
351
450
|
const next = await renewing;
|
|
352
|
-
if (!next)
|
|
451
|
+
if (!next) {
|
|
452
|
+
if (base.active)
|
|
453
|
+
lwarn('log.renew_failed', undefined, { log: base });
|
|
353
454
|
return false;
|
|
455
|
+
}
|
|
456
|
+
if (base.wants('session.renew'))
|
|
457
|
+
base.emit('session.renew', { tenant: root.tenant ?? undefined, account: root.account ?? undefined });
|
|
354
458
|
token = next;
|
|
355
459
|
ctx.token = next;
|
|
356
460
|
failed.token = next;
|
|
@@ -409,12 +513,19 @@ export async function connect(opts) {
|
|
|
409
513
|
const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
|
|
410
514
|
// only — токены арендаторов из сигналов Class (режимы tenant и opaque): реестры db.as() только этих арендаторов
|
|
411
515
|
const refresh = async (only) => {
|
|
516
|
+
const t0 = performance.now();
|
|
412
517
|
const rows = await runTx(ctx, async (q) => {
|
|
413
|
-
|
|
518
|
+
// аккаунт сессии — для полей account событий лога корня
|
|
519
|
+
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t, ${S}.ctx_account() as a`);
|
|
414
520
|
root.tenant = t?.t ?? null;
|
|
521
|
+
root.account = t?.a ?? null;
|
|
415
522
|
return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
|
|
416
523
|
});
|
|
417
524
|
root.registry.load(rows);
|
|
525
|
+
if (base.wants('registry.refresh')) {
|
|
526
|
+
base.emit('registry.refresh', { op: 'refresh', ms: performance.now() - t0, rows: rows.length, tenant: root.tenant ?? undefined,
|
|
527
|
+
account: root.account ?? undefined, ...(only ? { detail: { tenants: only.size } } : {}) });
|
|
528
|
+
}
|
|
418
529
|
for (const [tenant, o] of others) {
|
|
419
530
|
if (only && !only.has(sig.tenantToken(tenant) ?? ''))
|
|
420
531
|
continue;
|
|
@@ -477,6 +588,13 @@ export async function connect(opts) {
|
|
|
477
588
|
if (sig.setKey(key))
|
|
478
589
|
cache?.clear();
|
|
479
590
|
};
|
|
591
|
+
// токен, который не даёт сессии (погашен, истёк, неверен), — отказ connect(), а не подключение без сессии, где первый
|
|
592
|
+
// же шаг дал бы invalid_class про личный арендатор (второй раунд 2.1, A5)
|
|
593
|
+
if (opts.token) {
|
|
594
|
+
const [s] = await runTx(ctx, (q) => q.unsafe(`select account from ${S}.ctx()`), { read: true, retries: 0 });
|
|
595
|
+
if (!s?.account)
|
|
596
|
+
throw lerr('api.token_not_active');
|
|
597
|
+
}
|
|
480
598
|
await fetchKey();
|
|
481
599
|
await refresh();
|
|
482
600
|
// ключ, реестр и пропуска прав — заново после переподключения слушателя, по незнакомому токену, нагрузке не по
|
|
@@ -524,6 +642,8 @@ export async function connect(opts) {
|
|
|
524
642
|
// (пере)подключение слушателя — кэш целиком, после обрыва — и ключ с реестром (сигналы потеряны)
|
|
525
643
|
const onSignal = (payload) => {
|
|
526
644
|
const s = sig.parse(payload);
|
|
645
|
+
if (base.wants('signal'))
|
|
646
|
+
base.emit('signal', { detail: { ...s } });
|
|
527
647
|
if (s.kind === 'class' && ACL_CLASSES.has(s.cls))
|
|
528
648
|
passes.clear();
|
|
529
649
|
if (s.kind === 'class' && s.cls === 'Class')
|
|
@@ -537,6 +657,8 @@ export async function connect(opts) {
|
|
|
537
657
|
let listened = false;
|
|
538
658
|
const listener = listen
|
|
539
659
|
? await sql.listen(opts.schema, onSignal, () => {
|
|
660
|
+
if (base.wants('signal'))
|
|
661
|
+
base.emit('signal', { detail: { kind: 'listen', reconnect: listened } });
|
|
540
662
|
if (listened)
|
|
541
663
|
resync();
|
|
542
664
|
listened = true;
|
|
@@ -568,9 +690,10 @@ export async function connect(opts) {
|
|
|
568
690
|
stamper.unref?.();
|
|
569
691
|
}
|
|
570
692
|
let stopMaintain = null;
|
|
693
|
+
let closing = null;
|
|
571
694
|
const subs = new Set();
|
|
572
695
|
const shared = {
|
|
573
|
-
sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
|
|
696
|
+
sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs, log: base,
|
|
574
697
|
admin: Boolean(bypass && (role.rolsuper || role.can_set)),
|
|
575
698
|
ownerRole: owner,
|
|
576
699
|
...(adminSql ? { adminSql, adminReady } : {}),
|
|
@@ -584,28 +707,46 @@ export async function connect(opts) {
|
|
|
584
707
|
subs.add(s);
|
|
585
708
|
return s;
|
|
586
709
|
},
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
// (
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
710
|
+
closed: false,
|
|
711
|
+
close() {
|
|
712
|
+
// повторный close() ждёт первого; флаг — сразу: вызовы после close() отказывают api.closed, а не открывают пул
|
|
713
|
+
// заново (postgres.js reserve() после end() соединяется снова)
|
|
714
|
+
if (closing)
|
|
715
|
+
return closing;
|
|
716
|
+
shared.closed = true;
|
|
717
|
+
closing = (async () => {
|
|
718
|
+
// живые подписки закрываются: иначе с чужим пулом (connect({ sql })) они опрашивали бы журнал и после close()
|
|
719
|
+
// (план 2.0, 7.4 №8)
|
|
720
|
+
for (const s of [...subs])
|
|
721
|
+
s.close();
|
|
722
|
+
// идущий maintain() по расписанию доделывается: пул закрывается после него, а не посреди транзакции
|
|
723
|
+
await stopMaintain?.();
|
|
724
|
+
resync.stop();
|
|
725
|
+
startReload.stop();
|
|
726
|
+
if (stamper)
|
|
727
|
+
clearInterval(stamper);
|
|
728
|
+
cache?.clear();
|
|
729
|
+
await notifier.close();
|
|
730
|
+
await listener?.unlisten();
|
|
731
|
+
if (own)
|
|
732
|
+
await sql.end({ timeout: 5 });
|
|
733
|
+
if (ownAdmin)
|
|
734
|
+
await adminSql.end({ timeout: 5 });
|
|
735
|
+
})();
|
|
736
|
+
return closing;
|
|
604
737
|
},
|
|
605
738
|
};
|
|
606
739
|
const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
|
|
607
740
|
if (maintainEvery !== undefined)
|
|
608
|
-
stopMaintain = scheduleMaintain(() => db.maintain(), maintainEvery);
|
|
741
|
+
stopMaintain = scheduleMaintain(() => db.maintain(), maintainEvery, base);
|
|
742
|
+
if (base.enabled) {
|
|
743
|
+
Object.assign(info, {
|
|
744
|
+
schema: opts.schema, ...(opts.dsn ? { dsn: maskDsn(opts.dsn) } : {}), pool: own ? 'own' : 'external', ...(own ? { max: opts.max ?? 10 } : {}),
|
|
745
|
+
...(adminSql ? { adminPool: ownAdmin ? 'own' : 'external' } : {}), bypass: Boolean(bypass), revision: rev, signals: mode, listen,
|
|
746
|
+
cache: Boolean(cache), ...(maintainEvery !== undefined ? { maintain: maintainEvery } : {}),
|
|
747
|
+
session: opts.token ? 'token' : apiKey ? 'service' : 'none', account: root.account,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
609
750
|
return db;
|
|
610
751
|
}
|
|
611
752
|
catch (e) {
|
package/dist/internal.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface DbInternals {
|
|
|
14
14
|
* Только у корня подключения с опцией admin.
|
|
15
15
|
*/
|
|
16
16
|
readonly adminTx?: <T>(fn: (tx: import('./tx.js').Tx) => Promise<T>) => Promise<T>;
|
|
17
|
+
/** Логгер фасада (lib/src/log.ts): им пишут syncModels и инструменты MCP. */
|
|
18
|
+
readonly log?: import('./log.js').Log;
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
19
21
|
* Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,
|
package/dist/load.d.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { type Issue } from './errors.js';
|
|
|
3
3
|
import { Registry } from './registry.js';
|
|
4
4
|
type Sql = postgres.Sql<Record<string, unknown>>;
|
|
5
5
|
type Raw = Record<string, unknown>;
|
|
6
|
+
/** Ссылка на цель вне загрузки: строка, роль и допустимые классы конца. */
|
|
7
|
+
type ExtRef = {
|
|
8
|
+
line: number;
|
|
9
|
+
role: string;
|
|
10
|
+
allowed: string[];
|
|
11
|
+
};
|
|
12
|
+
/** Цели вне загрузки: id → первая ссылка с каждым набором допустимых классов (сверяется каждый набор). */
|
|
13
|
+
type ExtRefs = Map<string, ExtRef[]>;
|
|
6
14
|
export interface LoadOptions {
|
|
7
15
|
/** Повтор id в загрузке: отменить загрузку (по умолчанию), оставить первую или последнюю строку. */
|
|
8
16
|
duplicates?: 'error' | 'first' | 'last';
|
|
@@ -29,7 +37,7 @@ export interface LoadOptions {
|
|
|
29
37
|
/**
|
|
30
38
|
* Загрузка от имени пользователя (§2.6, п. 10): автор версий — он; арендатор (по умолчанию —
|
|
31
39
|
* собственный аккаунта) доступен ему; право WRITE на каждый класс и условия ACL на новые (и в
|
|
32
|
-
* existing: 'update' — прежние) значения строк; цели
|
|
40
|
+
* existing: 'update' — прежние) значения строк; цели концов ему видны; владелец — по ownerDefault.
|
|
33
41
|
*/
|
|
34
42
|
as?: string;
|
|
35
43
|
/**
|
|
@@ -110,11 +118,7 @@ export interface InitialTuning {
|
|
|
110
118
|
interface Pending {
|
|
111
119
|
reg: Registry;
|
|
112
120
|
tenant: string;
|
|
113
|
-
external:
|
|
114
|
-
line: number;
|
|
115
|
-
role: string;
|
|
116
|
-
allowed: string[];
|
|
117
|
-
}>;
|
|
121
|
+
external: ExtRefs;
|
|
118
122
|
owners: Map<string, number>;
|
|
119
123
|
}
|
|
120
124
|
/** Состояние сессии первой заливки. */
|