letopis 2.0.0 → 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 +484 -414
- package/CHANGELOG.md +321 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +786 -183
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +3 -0
- package/dist/acl.js +19 -2
- package/dist/admin.d.ts +2 -1
- package/dist/admin.js +6 -4
- package/dist/auth.d.ts +18 -3
- package/dist/auth.js +75 -6
- package/dist/chain.d.ts +38 -17
- package/dist/chain.js +434 -63
- package/dist/cli.js +28 -6
- package/dist/errors.d.ts +19 -1
- package/dist/errors.js +46 -6
- package/dist/index.d.ts +10 -4
- package/dist/index.js +136 -46
- package/dist/internal.d.ts +2 -0
- package/dist/load.d.ts +9 -5
- package/dist/load.js +30 -11
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.js +26 -11
- package/dist/messages/api.d.ts +66 -0
- package/dist/messages/api.js +90 -1
- package/dist/messages/boot.js +4 -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 +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/model.d.ts +24 -1
- package/dist/messages/model.js +34 -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 +47 -0
- package/dist/messages/write.js +63 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +23 -5
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +77 -25
- package/dist/sync.d.ts +1 -1
- package/dist/sync.js +50 -0
- package/dist/tx.d.ts +10 -1
- package/dist/tx.js +44 -2
- package/dist/typed.d.ts +14 -10
- package/dist/types.d.ts +82 -4
- package/dist/types.js +6 -1
- package/dist/up.d.ts +3 -3
- package/dist/up.js +57 -17
- 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 +78 -1
- package/dist/write.js +357 -39
- package/llms.txt +37 -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/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';
|
|
@@ -44,16 +45,19 @@ export { inc, isInc } from './write.js';
|
|
|
44
45
|
export { totpCode, hashPassword, verifyHash } from './auth.js';
|
|
45
46
|
export { up, createTenant } from './up.js';
|
|
46
47
|
export { RESERVED_CLASS_NAMES, CHAIN_METHODS, DB_METHODS } from './types.js';
|
|
48
|
+
export { LOG_EVENTS } from './messages/index.js';
|
|
47
49
|
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
48
50
|
const SCHEMA_NAME = /^v\d+\.[a-z_][a-z0-9_]{0,40}$/;
|
|
49
51
|
/** Все опции connect(): неизвестная — invalid_data с похожими именами (checkOptions; план 2.0, 7.10 №4). */
|
|
50
52
|
const CONNECT_OPTIONS = {
|
|
51
53
|
dsn: true, sql: true, schema: true, token: true, apiKey: true, service: true, refreshSession: true, allowBypassRls: true,
|
|
52
54
|
admin: true, owner: true, notifyIntervalMs: true, max: true, onQuery: true, slowMs: true, models: true, listen: true,
|
|
53
|
-
cache: true, maintain: true,
|
|
55
|
+
cache: true, maintain: true, log: true, logLevel: true, redact: true,
|
|
54
56
|
};
|
|
55
57
|
/** Опции watch() (WatchOptions). */
|
|
56
58
|
const WATCH_OPTIONS = { from: true, classes: true, batch: true, pollMs: true };
|
|
59
|
+
/** Опции db.begin() (второй раунд 2.1, A4). */
|
|
60
|
+
const BEGIN_OPTIONS = { reason: true };
|
|
57
61
|
/**
|
|
58
62
|
* То же подключение без строгих типов: шагом может быть любое имя, в том числе известное только во время выполнения, —
|
|
59
63
|
* `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, слот) не меняются.
|
|
@@ -65,9 +69,11 @@ const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
|
65
69
|
/** Фасад db: пул (exec — исполнитель пула) или явная транзакция (tx). */
|
|
66
70
|
function makeDb(sh, scope, exec, owner, tx) {
|
|
67
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 }) });
|
|
68
74
|
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 ?? '',
|
|
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,
|
|
71
77
|
...(sh.cache ? { cache: { store: sh.cache, who: () => ({ token: txc.token, account: txc.account, tenant: scope.tenant() }) } } : {}),
|
|
72
78
|
};
|
|
73
79
|
// реестр корня меняется после auth.switch — цепочки берут действующий
|
|
@@ -78,12 +84,12 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
78
84
|
const noTx = (what) => {
|
|
79
85
|
throw lerr('api.tx_only', { what });
|
|
80
86
|
};
|
|
81
|
-
const auth = makeAuth(sh.S, exec, async () => {
|
|
87
|
+
const auth = instrument(makeAuth(sh.S, exec, async () => {
|
|
82
88
|
sh.passes.clear();
|
|
83
89
|
await sh.refresh();
|
|
84
|
-
}, scope.root && !tx);
|
|
90
|
+
}, scope.root && !tx), log, 'auth', AUTH_LOG, { switch: () => scope.tenant() });
|
|
85
91
|
// запись правил (grant, deny, revoke, roles) идёт цепочками самого фасада — он объявлен ниже, берётся при вызове
|
|
86
|
-
const acl = makeAcl(sh.S, exec, () => facade);
|
|
92
|
+
const acl = instrument(makeAcl(sh.S, exec, () => facade), log, 'acl', ACL_LOG);
|
|
87
93
|
const adm = { S: sh.S, txc, exec };
|
|
88
94
|
// один процесс (план 2.0, 7.9 №3): у корня с connect({ admin }) администраторские вызовы идут вторым пулом с той же
|
|
89
95
|
// сессией, а цепочки и db.sql — пулом приложения под RLS; db.as() и db.begin() администраторский пул не берут
|
|
@@ -100,19 +106,35 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
100
106
|
get registry() { return scope.registry(); },
|
|
101
107
|
auth,
|
|
102
108
|
acl,
|
|
103
|
-
sql: (strings, ...values) => exec.run((q) => q(strings, ...values), { raw: true }),
|
|
104
|
-
transaction: (fn) =>
|
|
105
|
-
|
|
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' }, () => {
|
|
106
128
|
const tenant = scope.tenant();
|
|
107
129
|
if (!tenant)
|
|
108
130
|
throw lerr('api.id_of_no_tenant');
|
|
109
131
|
const c = scope.registry().resolve(cls);
|
|
110
132
|
return idOfClass({ name: c.name, key: c.key, ends: Object.keys(c.ends) }, key, { tenant });
|
|
111
|
-
},
|
|
112
|
-
refresh: sh.refresh,
|
|
133
|
+
}),
|
|
134
|
+
refresh: () => traced(log, null, { op: 'refresh' }, sh.refresh),
|
|
113
135
|
flushSignals: () => sh.notifier.flush(),
|
|
114
|
-
maintain: () => (viaAdmin ? adminReady().then(() => runMaintain(admCtx())) : runMaintain(adm)),
|
|
115
|
-
watch: (o = {}) => {
|
|
136
|
+
maintain: () => traced(log, 'maintain', { op: 'maintain' }, () => (viaAdmin ? adminReady().then(() => runMaintain(admCtx())) : runMaintain(adm)), (r) => ({ detail: { ...r } })),
|
|
137
|
+
watch: (o = {}) => guarded(log, { op: 'watch' }, () => {
|
|
116
138
|
checkOptions('watch', o, WATCH_OPTIONS);
|
|
117
139
|
const reg = scope.registry();
|
|
118
140
|
const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
|
|
@@ -132,14 +154,15 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
132
154
|
onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); },
|
|
133
155
|
tenant: () => (sh.allTenants ? null : sh.sig.tenantToken(scope.tenant())),
|
|
134
156
|
} : {}),
|
|
157
|
+
...(log.enabled ? { log } : {}),
|
|
135
158
|
}, o));
|
|
136
|
-
},
|
|
159
|
+
}),
|
|
137
160
|
cache: {
|
|
138
161
|
clear: () => sh.cache?.clear(),
|
|
139
162
|
stats: () => sh.cache?.stats() ?? { size: 0, hits: 0, misses: 0, stores: 0, raced: 0, signals: 0, listens: 0, enabled: false },
|
|
140
163
|
},
|
|
141
|
-
entity: (x) => startEntity(chainCtx, x),
|
|
142
|
-
as:
|
|
164
|
+
entity: (x) => guarded(log, { op: 'entity' }, () => startEntity(chainCtx, x)),
|
|
165
|
+
as: (account, o) => traced(log, 'auth.as', { op: 'as' }, async () => {
|
|
143
166
|
if (tx)
|
|
144
167
|
throw lerr('api.as_in_tx');
|
|
145
168
|
const id = account === null ? undefined : typeof account === 'object' ? account.id : account;
|
|
@@ -165,16 +188,26 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
165
188
|
if (!r?.account)
|
|
166
189
|
throw lerr('api.as_no_session');
|
|
167
190
|
}
|
|
168
|
-
},
|
|
169
|
-
|
|
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);
|
|
170
199
|
if (tx)
|
|
171
200
|
throw lerr('api.nested_begin');
|
|
172
|
-
// причина блока ставится в начале транзакции (setupSql) и действует на все её
|
|
173
|
-
|
|
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;
|
|
174
207
|
const t = await ExplicitTx.open(btx);
|
|
175
208
|
return makeDb(sh, btx === txc ? scope : { ...scope, txc: btx }, t, owner, t);
|
|
176
|
-
},
|
|
177
|
-
load:
|
|
209
|
+
}),
|
|
210
|
+
load: (input, o) => traced(log, 'load', { op: 'load' }, async () => {
|
|
178
211
|
// опции — до всего прочего: неизвестная — отказ раньше admin_required пула администратора (план 2.0, 7.10 №4)
|
|
179
212
|
checkOptions('load', o, LOAD_OPTIONS);
|
|
180
213
|
if (tx)
|
|
@@ -184,17 +217,18 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
184
217
|
return load({ sql: viaAdmin, schema: sh.schema, tenant: scope.tenant(), admin: true, ownerRole: sh.ownerRole }, input, o);
|
|
185
218
|
}
|
|
186
219
|
return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
|
|
187
|
-
},
|
|
220
|
+
}, (r) => ({ rows: r.loaded + r.updated, detail: { ...r } })),
|
|
188
221
|
batch: (name, o) => {
|
|
189
222
|
let q = batches.get(name);
|
|
190
223
|
if (!q)
|
|
191
224
|
batches.set(name, (q = []));
|
|
192
225
|
return makeBatch(o?.reason !== undefined ? { ...chainCtx, reason: checkReason(o.reason, 'db.batch()') } : chainCtx, q);
|
|
193
226
|
},
|
|
194
|
-
lock:
|
|
195
|
-
commit: async (
|
|
196
|
-
rollback: async (
|
|
197
|
-
|
|
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())),
|
|
198
232
|
};
|
|
199
233
|
/** Свойство фасада: служебное — из core, иначе шаг класса, алиаса или роли. */
|
|
200
234
|
const member = (t, prop, r) => {
|
|
@@ -202,7 +236,8 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
202
236
|
return Reflect.get(t, prop, r);
|
|
203
237
|
if (prop === 'then')
|
|
204
238
|
return undefined;
|
|
205
|
-
|
|
239
|
+
// неизвестный класс или роль — событие error с именем шага
|
|
240
|
+
return log.enabled ? guarded(log, { op: prop, chain: prop }, () => startStep(chainCtx, prop)) : startStep(chainCtx, prop);
|
|
206
241
|
};
|
|
207
242
|
const facade = new Proxy(core, {
|
|
208
243
|
get(t, prop, r) {
|
|
@@ -211,7 +246,7 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
211
246
|
});
|
|
212
247
|
// модули пакета (letopis/mcp) узнают обход RLS по самому подключению, а не со слов вызывающего (internal.ts)
|
|
213
248
|
registerDb(facade, {
|
|
214
|
-
allTenants: sh.allTenants, explicit: Boolean(tx),
|
|
249
|
+
allTenants: sh.allTenants, explicit: Boolean(tx), log,
|
|
215
250
|
...(viaAdmin ? { adminTx: (fn) => adminReady().then(() => runTx({ ...txc, sql: viaAdmin }, fn)) } : {}),
|
|
216
251
|
});
|
|
217
252
|
return facade;
|
|
@@ -219,10 +254,28 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
219
254
|
export async function connect(opts) {
|
|
220
255
|
// опции — первыми: неизвестная — invalid_data с похожими именами до прочих проверок (план 2.0, 7.10 №4)
|
|
221
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}` };
|
|
222
267
|
if (!SCHEMA_NAME.test(opts.schema))
|
|
223
268
|
throw lerr('api.connect_schema', { schema: opts.schema });
|
|
224
269
|
if (!opts.sql && !opts.dsn)
|
|
225
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
|
+
}
|
|
226
279
|
// интервал обслуживания — до пула и таймеров: неверный не оставляет за собой открытых соединений
|
|
227
280
|
const maintainEvery = opts.maintain === undefined ? undefined : parseInterval(opts.maintain);
|
|
228
281
|
if (opts.admin !== undefined && typeof opts.admin !== 'string' && typeof opts.admin !== 'function') {
|
|
@@ -231,13 +284,15 @@ export async function connect(opts) {
|
|
|
231
284
|
const own = !opts.sql;
|
|
232
285
|
// timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
|
|
233
286
|
const types = { timestamptz: { to: 1184, from: [1184], serialize: (v) => (v instanceof Date ? v.toISOString() : v), parse: (v) => v } };
|
|
234
|
-
|
|
287
|
+
// порог debug — каждый запрос пула даёт событие sql (traceSql); иначе пул как есть
|
|
288
|
+
const sql = traceSql((opts.sql ?? postgres(opts.dsn, { max: opts.max ?? 10, onnotice: () => { }, types })), base);
|
|
235
289
|
// один процесс (план 2.0, 7.9 №3): пул администратора postgres.js соединяется при первом запросе — до первого
|
|
236
290
|
// администраторского вызова соединений у него нет
|
|
237
291
|
const ownAdmin = typeof opts.admin === 'string';
|
|
238
|
-
const
|
|
292
|
+
const adminRaw = (typeof opts.admin === 'string'
|
|
239
293
|
? postgres(opts.admin, { max: 2, onnotice: () => { }, types, connection: { application_name: 'letopis admin' } })
|
|
240
294
|
: opts.admin);
|
|
295
|
+
const adminSql = adminRaw ? traceSql(adminRaw, base) : undefined;
|
|
241
296
|
const S = quote(opts.schema);
|
|
242
297
|
const owner = opts.owner ?? 'letopis_owner';
|
|
243
298
|
let adminChecked;
|
|
@@ -264,7 +319,7 @@ export async function connect(opts) {
|
|
|
264
319
|
throw lerr('api.connect_bypass_rls');
|
|
265
320
|
}
|
|
266
321
|
if (bypass)
|
|
267
|
-
lwarn('api.admin_connection');
|
|
322
|
+
lwarn('api.admin_connection', undefined, { log: base });
|
|
268
323
|
// ревизия движка — до первого вызова функций схемы (вход по ключу): схема letopis 1.x — отказ (план 2.0, 7.10 №5)
|
|
269
324
|
const [rv] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${opts.schema}`;
|
|
270
325
|
if (!rv)
|
|
@@ -273,16 +328,16 @@ export async function connect(opts) {
|
|
|
273
328
|
refuse1x(opts.schema, rev);
|
|
274
329
|
// библиотека новее схемы 2.x — накат отстал (этап 6, п. 10)
|
|
275
330
|
if (rev < DDL_REVISION)
|
|
276
|
-
lwarn('api.schema_older', { schema: opts.schema, rev, want: DDL_REVISION });
|
|
331
|
+
lwarn('api.schema_older', { schema: opts.schema, rev, want: DDL_REVISION }, { log: base });
|
|
277
332
|
// схему обновил более новый пакет (во время раскатки старые экземпляры ещё работают): подключаемся, но предупреждаем —
|
|
278
333
|
// этот пакет может не знать функций и колонок новой ревизии; up() такую схему не трогает и отказывает
|
|
279
334
|
else if (rev > DDL_REVISION)
|
|
280
|
-
lwarn('api.schema_newer', { schema: opts.schema, rev, known: DDL_REVISION });
|
|
335
|
+
lwarn('api.schema_newer', { schema: opts.schema, rev, known: DDL_REVISION }, { log: base });
|
|
281
336
|
else {
|
|
282
337
|
// ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
|
|
283
338
|
const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
|
|
284
339
|
if (drift)
|
|
285
|
-
|
|
340
|
+
warnText(drift, undefined, base);
|
|
286
341
|
}
|
|
287
342
|
let token = opts.token ?? null;
|
|
288
343
|
const apiKey = opts.apiKey ?? opts.service;
|
|
@@ -298,7 +353,11 @@ export async function connect(opts) {
|
|
|
298
353
|
});
|
|
299
354
|
const mode = SIGNAL_MODES.includes(sm?.m) ? sm.m : 'class';
|
|
300
355
|
const sig = new Signals(mode);
|
|
301
|
-
|
|
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);
|
|
302
361
|
const passes = new AclPasses(S);
|
|
303
362
|
// режим none: сигналов нет — без LISTEN, кэш только по сроку (ttlOnly), подписка опрашивает журнал
|
|
304
363
|
const listen = opts.listen !== false && mode !== 'none';
|
|
@@ -319,8 +378,8 @@ export async function connect(opts) {
|
|
|
319
378
|
};
|
|
320
379
|
// в режиме none нотификатор не отправляет: letopis.changed своей транзакции сбрасывает только свой кэш
|
|
321
380
|
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() };
|
|
381
|
+
...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}), ...(base.active ? { log: base } : {}) };
|
|
382
|
+
const root = { tenant: null, registry: new Registry(), account: null };
|
|
324
383
|
// подключение по ключу APIKEY: сессия истекает по простою (срок — 7 суток), и процесс, простоявший дольше, получал
|
|
325
384
|
// бы no_session на каждый вызов. Ключ обменивается на новую сессию заново (один обмен на все ждущие вызовы), с тем
|
|
326
385
|
// же рабочим арендатором, что выбрал auth.switch(); операция повторяется один раз (runTx, ExplicitTx.open)
|
|
@@ -349,8 +408,13 @@ export async function connect(opts) {
|
|
|
349
408
|
renewing = null;
|
|
350
409
|
});
|
|
351
410
|
const next = await renewing;
|
|
352
|
-
if (!next)
|
|
411
|
+
if (!next) {
|
|
412
|
+
if (base.active)
|
|
413
|
+
lwarn('log.renew_failed', undefined, { log: base });
|
|
353
414
|
return false;
|
|
415
|
+
}
|
|
416
|
+
if (base.wants('session.renew'))
|
|
417
|
+
base.emit('session.renew', { tenant: root.tenant ?? undefined, account: root.account ?? undefined });
|
|
354
418
|
token = next;
|
|
355
419
|
ctx.token = next;
|
|
356
420
|
failed.token = next;
|
|
@@ -409,12 +473,19 @@ export async function connect(opts) {
|
|
|
409
473
|
const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
|
|
410
474
|
// only — токены арендаторов из сигналов Class (режимы tenant и opaque): реестры db.as() только этих арендаторов
|
|
411
475
|
const refresh = async (only) => {
|
|
476
|
+
const t0 = performance.now();
|
|
412
477
|
const rows = await runTx(ctx, async (q) => {
|
|
413
|
-
|
|
478
|
+
// аккаунт сессии — для полей account событий лога корня
|
|
479
|
+
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t, ${S}.ctx_account() as a`);
|
|
414
480
|
root.tenant = t?.t ?? null;
|
|
481
|
+
root.account = t?.a ?? null;
|
|
415
482
|
return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
|
|
416
483
|
});
|
|
417
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
|
+
}
|
|
418
489
|
for (const [tenant, o] of others) {
|
|
419
490
|
if (only && !only.has(sig.tenantToken(tenant) ?? ''))
|
|
420
491
|
continue;
|
|
@@ -477,6 +548,13 @@ export async function connect(opts) {
|
|
|
477
548
|
if (sig.setKey(key))
|
|
478
549
|
cache?.clear();
|
|
479
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
|
+
}
|
|
480
558
|
await fetchKey();
|
|
481
559
|
await refresh();
|
|
482
560
|
// ключ, реестр и пропуска прав — заново после переподключения слушателя, по незнакомому токену, нагрузке не по
|
|
@@ -524,6 +602,8 @@ export async function connect(opts) {
|
|
|
524
602
|
// (пере)подключение слушателя — кэш целиком, после обрыва — и ключ с реестром (сигналы потеряны)
|
|
525
603
|
const onSignal = (payload) => {
|
|
526
604
|
const s = sig.parse(payload);
|
|
605
|
+
if (base.wants('signal'))
|
|
606
|
+
base.emit('signal', { detail: { ...s } });
|
|
527
607
|
if (s.kind === 'class' && ACL_CLASSES.has(s.cls))
|
|
528
608
|
passes.clear();
|
|
529
609
|
if (s.kind === 'class' && s.cls === 'Class')
|
|
@@ -537,6 +617,8 @@ export async function connect(opts) {
|
|
|
537
617
|
let listened = false;
|
|
538
618
|
const listener = listen
|
|
539
619
|
? await sql.listen(opts.schema, onSignal, () => {
|
|
620
|
+
if (base.wants('signal'))
|
|
621
|
+
base.emit('signal', { detail: { kind: 'listen', reconnect: listened } });
|
|
540
622
|
if (listened)
|
|
541
623
|
resync();
|
|
542
624
|
listened = true;
|
|
@@ -570,7 +652,7 @@ export async function connect(opts) {
|
|
|
570
652
|
let stopMaintain = null;
|
|
571
653
|
const subs = new Set();
|
|
572
654
|
const shared = {
|
|
573
|
-
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,
|
|
574
656
|
admin: Boolean(bypass && (role.rolsuper || role.can_set)),
|
|
575
657
|
ownerRole: owner,
|
|
576
658
|
...(adminSql ? { adminSql, adminReady } : {}),
|
|
@@ -605,7 +687,15 @@ export async function connect(opts) {
|
|
|
605
687
|
};
|
|
606
688
|
const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
|
|
607
689
|
if (maintainEvery !== undefined)
|
|
608
|
-
stopMaintain = scheduleMaintain(() => db.maintain(), maintainEvery);
|
|
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
|
+
}
|
|
609
699
|
return db;
|
|
610
700
|
}
|
|
611
701
|
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';
|
|
@@ -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
|
/** Состояние сессии первой заливки. */
|
package/dist/load.js
CHANGED
|
@@ -22,13 +22,16 @@ import { LetopisError, checkOptions, fromDbError, issuesFromDb, lerr, lmsg } fro
|
|
|
22
22
|
import { isOp } from './ops.js';
|
|
23
23
|
import { Registry } from './registry.js';
|
|
24
24
|
import { accepts } from './sql.js';
|
|
25
|
-
import { v5Id } from './uuid.js';
|
|
25
|
+
import { UUID_RE, v5Id } from './uuid.js';
|
|
26
26
|
import { applyDefaults, leavesToDb } from './validate.js';
|
|
27
27
|
import { isInc } from './write.js';
|
|
28
28
|
import { ACL_CLASS_NAMES } from './types.js';
|
|
29
29
|
import { isConnectionError, release, rollbackAfter } from './tx.js';
|
|
30
30
|
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
31
|
-
const UUID =
|
|
31
|
+
const UUID = UUID_RE;
|
|
32
|
+
function sameList(a, b) {
|
|
33
|
+
return a.length === b.length && a.every((x, k) => x === b[k]);
|
|
34
|
+
}
|
|
32
35
|
/**
|
|
33
36
|
* Все опции load(): неизвестная — invalid_data с похожими (checkOptions; план 2.0, 7.10 №4). Фасад db.load() сверяет их
|
|
34
37
|
* до пула администратора (index.ts), load() — ещё раз до проверки прав.
|
|
@@ -523,6 +526,14 @@ export async function load(env, input, opts = {}) {
|
|
|
523
526
|
for (const r of roots)
|
|
524
527
|
await conn.unsafe(`select ${S}.family_lock(${S}.class_row($1, $2::uuid), false)`, [r, tenant]);
|
|
525
528
|
const reg = await readRegistry(conn);
|
|
529
|
+
// класс строки, удалённый между первым чтением реестра и блокировкой семейства, — отказ, а не прежнее описание
|
|
530
|
+
for (const i of portion) {
|
|
531
|
+
const n = p1[i].cls.name;
|
|
532
|
+
if (!reg.find(n))
|
|
533
|
+
issues.add({ line: i + 1, code: 'invalid_class', message: lmsg('write.load_class_not_found', { name: n }), id: p1[i].id, class: n });
|
|
534
|
+
}
|
|
535
|
+
if (issues.any)
|
|
536
|
+
issues.fail();
|
|
526
537
|
const [{ ts, xid }] = await conn.unsafe(`select clock_timestamp()::text as ts, pg_current_xact_id()::text as xid`);
|
|
527
538
|
// от имени пользователя: арендатор доступен ему, планы прав READ и WRITE; класс без права — отказ
|
|
528
539
|
if (asAcc) {
|
|
@@ -618,10 +629,11 @@ export async function load(env, input, opts = {}) {
|
|
|
618
629
|
const found = new Map((await conn.unsafe(`select e.id, e.tenant, e.class from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id for key share`, [ext]))
|
|
619
630
|
.map((r) => [r.id, r]));
|
|
620
631
|
for (const id of ext) {
|
|
621
|
-
const ref = external.get(id);
|
|
622
632
|
const f = found.get(id);
|
|
623
|
-
|
|
624
|
-
|
|
633
|
+
for (const ref of external.get(id)) {
|
|
634
|
+
if (!f || (f.tenant !== tenant && f.tenant !== SYSTEM_ID) || !accepts(reg, ref.allowed, f.class)) {
|
|
635
|
+
issues.add({ line: ref.line, code: 'target_not_found', message: lmsg('write.load_target_not_found', { role: ref.role }), id });
|
|
636
|
+
}
|
|
625
637
|
}
|
|
626
638
|
}
|
|
627
639
|
}
|
|
@@ -713,7 +725,7 @@ export async function load(env, input, opts = {}) {
|
|
|
713
725
|
/** Проверки строки и ссылок; null — строка не пишется (нарушение уже в отчёте). */
|
|
714
726
|
function check(reg, i, inLoad, external, lastVersion) {
|
|
715
727
|
const raw = rows[i];
|
|
716
|
-
const cls = reg.find(p1[i].cls.name)
|
|
728
|
+
const cls = reg.find(p1[i].cls.name); // наличие в свежем реестре проверено после блокировки (loadPortion)
|
|
717
729
|
const sh = shape(reg, tenant, cls, raw, true);
|
|
718
730
|
if (sh.error) {
|
|
719
731
|
issues.add({ line: i + 1, code: sh.error.code, message: sh.error.message, id: p1[i].id, class: cls.name, ...(sh.error.issues ? { issues: sh.error.issues } : {}) });
|
|
@@ -758,8 +770,14 @@ export async function load(env, input, opts = {}) {
|
|
|
758
770
|
if (!t.alive || !accepts(reg, allowed, t.cls))
|
|
759
771
|
issues.add({ line: i + 1, code: 'target_not_found', message: lmsg('write.load_target_not_found', { role }), id: p1[i].id, class: cls.name });
|
|
760
772
|
}
|
|
761
|
-
else
|
|
762
|
-
|
|
773
|
+
else {
|
|
774
|
+
// первая ссылка на id с каждым набором допустимых классов: тот же id под другой ролью может требовать
|
|
775
|
+
// другой класс (сверяются все наборы), повторы того же набора — не копятся
|
|
776
|
+
const refs = external.get(id);
|
|
777
|
+
if (!refs)
|
|
778
|
+
external.set(id, [{ line: i + 1, role, allowed }]);
|
|
779
|
+
else if (!refs.some((r) => sameList(r.allowed, allowed)))
|
|
780
|
+
refs.push({ line: i + 1, role, allowed });
|
|
763
781
|
}
|
|
764
782
|
}
|
|
765
783
|
}
|
|
@@ -1181,10 +1199,11 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1181
1199
|
const found = new Map((await q(`select e.id, e.tenant, e.class from ${S}.entity e where e.id = any ($1::uuid[])`, [ext]))
|
|
1182
1200
|
.map((r) => [r.id, r]));
|
|
1183
1201
|
for (const id of ext) {
|
|
1184
|
-
const ref = p.external.get(id);
|
|
1185
1202
|
const f = found.get(id);
|
|
1186
|
-
|
|
1187
|
-
|
|
1203
|
+
for (const ref of p.external.get(id)) {
|
|
1204
|
+
if (!f || (f.tenant !== p.tenant && f.tenant !== SYSTEM_ID) || !accepts(p.reg, ref.allowed, f.class)) {
|
|
1205
|
+
issues.add({ line: ref.line, code: 'target_not_found', message: lmsg('write.load_target_not_found', { role: ref.role }), id });
|
|
1206
|
+
}
|
|
1188
1207
|
}
|
|
1189
1208
|
}
|
|
1190
1209
|
}
|