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/log.js
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Логирование letopis 3.0: события всех операций библиотеки в логгер приложения.
|
|
3
|
+
*
|
|
4
|
+
* Опции `connect({ log, logLevel, redact })` и `up({ log, logLevel, redact })`:
|
|
5
|
+
* - `log` — функция `(событие) => void` или объект с методами `debug`, `info`, `warn`, `error` (pino, console): вызов
|
|
6
|
+
* `logger[уровень](событие, событие.event)`; метода уровня нет — `logger.log`, иначе событие пропускается;
|
|
7
|
+
* - `logLevel` — порог: `'debug' | 'info' | 'warn' | 'error' | 'off'`, по умолчанию `'info'` (или уровень из
|
|
8
|
+
* `LETOPIS_LOG`). Событие ниже порога не собирается: проверка уровня — до построения объекта и текста цепочки;
|
|
9
|
+
* - `redact` — ещё имена полей (без учёта регистра), значения которых маскируются.
|
|
10
|
+
* Без `log` и при заданной `LETOPIS_LOG=<уровень>` события идут JSON-строками в stderr. Без того и другого слой выключен
|
|
11
|
+
* (NOLOG): обёртки сразу отдают исходный вызов.
|
|
12
|
+
*
|
|
13
|
+
* Секреты маскируются всегда: поля с паролями, токенами, ключами, секретами и кредами (SECRET_KEY), пароль в строке
|
|
14
|
+
* подключения и в тексте SQL (`password '…'`); значения параметров SQL не пишутся вовсе — только их число. Ошибка
|
|
15
|
+
* PostgreSQL (сама или причиной ошибки letopis) в событии — только SQLSTATE и имена объектов схемы: её текст, DETAIL,
|
|
16
|
+
* WHERE, HINT и внутренний запрос могут нести значения и не пишутся; у ошибки letopis параметры value и text — '***',
|
|
17
|
+
* поле data строк в подробностях (превью удаления, снимки restore) — '***'. Событие error штатного отказа (exists,
|
|
18
|
+
* conflict, target_not_found, …; errorLevel) — уровня warn. В db.begin() события записи несут tx, а tx.commit транзакции
|
|
19
|
+
* с записью — уровня info (detail.writes): фиксацию доказывает он, а не событие write. Исключение
|
|
20
|
+
* в логгере не ломает операцию: событие пропускается, в stderr один раз на процесс — log.logger_failed.
|
|
21
|
+
*
|
|
22
|
+
* Каталог событий (имя, уровень, описание) — lib/src/messages/log.ts (LOG_EVENTS).
|
|
23
|
+
*/
|
|
24
|
+
import { LetopisError, lerr, lmsg, setEnvWarnSink } from './errors.js';
|
|
25
|
+
import { LOG_EVENTS } from './messages/index.js';
|
|
26
|
+
const RANK = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
27
|
+
const LEVELS = ['debug', 'info', 'warn', 'error', 'off'];
|
|
28
|
+
/** Имена полей с секретами: маскируются в любом месте detail всегда. */
|
|
29
|
+
const SECRET_KEY = /pass(?:word|wd|phrase)?$|^pass|^pwd$|secret|token|api_?key|service_?key|authorization|cookie|credential|^hash$|^material$|^totp$|^otp$/i;
|
|
30
|
+
/**
|
|
31
|
+
* Учётные данные в строке подключения: схема://пользователь:пароль@хост — всё до последней `@` в части хоста
|
|
32
|
+
* (пароль может содержать `@`, `:` и `/`).
|
|
33
|
+
*/
|
|
34
|
+
const DSN_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)[^\s'"]*@(?=[^@\s'"]*(?:[\s'"]|$))/gi;
|
|
35
|
+
/** Пароль параметром: `?password=…`, `&pass=…`, `password=…` в строке libpq. */
|
|
36
|
+
const KV_PASSWORD = /(\b(?:password|pass|passwd|pwd|sslpassword)\s*=\s*)(?:'(?:[^'\\]|\\.)*'|[^\s&;]+)/gi;
|
|
37
|
+
/** Пароль в тексте SQL: `password '…'`, `password E'…'`, `password $$…$$` / `$tag$…$tag$` (create/alter role). */
|
|
38
|
+
const SQL_PASSWORD = /(\bpassword\s+)(?:[eE]?'(?:[^'\\]|\\.|'')*'|(\$[A-Za-z_0-9]*\$)[\s\S]*?\2)/gi;
|
|
39
|
+
/** Строка без секретов: учётные данные в строке подключения, пароль параметром и в тексте SQL. */
|
|
40
|
+
export function maskText(s) {
|
|
41
|
+
if (!s.includes('://') && !/pass|pwd/i.test(s))
|
|
42
|
+
return s;
|
|
43
|
+
return s.replace(DSN_USERINFO, '$1***@').replace(KV_PASSWORD, '$1***').replace(SQL_PASSWORD, "$1'***'");
|
|
44
|
+
}
|
|
45
|
+
const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…(+${s.length - n})` : s);
|
|
46
|
+
const round = (ms) => Math.round(ms * 10) / 10;
|
|
47
|
+
/**
|
|
48
|
+
* События операций, которые пишут (или могут писать — db.sql): в явной транзакции каждое увеличивает счётчик writes,
|
|
49
|
+
* и её tx.commit идёт уровнем info с detail.writes.
|
|
50
|
+
*/
|
|
51
|
+
const WRITE_EVENTS = new Set(['write', 'delete', 'restore', 'raw', 'load', 'acl.change', 'auth.credential',
|
|
52
|
+
'auth.revoke', 'auth.logout', 'auth.session', 'account.purge']);
|
|
53
|
+
/** Ошибки, уже записанные событием error: одна ошибка — одно событие, сколько бы обёрток её ни пробрасывали. */
|
|
54
|
+
const logged = new WeakSet();
|
|
55
|
+
let loggerFailed = false;
|
|
56
|
+
function loggerFailure(e, event) {
|
|
57
|
+
if (loggerFailed)
|
|
58
|
+
return;
|
|
59
|
+
loggerFailed = true;
|
|
60
|
+
try {
|
|
61
|
+
process.stderr.write(`letopis: ${lmsg('log.logger_failed', { event, message: e instanceof Error ? e.message : String(e) })}\n`);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// stderr недоступен — молчим: операцию это не прерывает
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Ошибка PostgreSQL (PostgresError postgres.js, DatabaseError PGlite): пятизначный SQLSTATE и поле severity сервера.
|
|
69
|
+
* Её текст (message), DETAIL, WHERE, HINT, внутренний запрос и позиция могут нести значения параметров и данных
|
|
70
|
+
* («invalid input syntax for type uuid: "…"», «Key (email)=(…) already exists»), поэтому в событие они не идут никогда.
|
|
71
|
+
*/
|
|
72
|
+
function pgOf(x) {
|
|
73
|
+
if (x === null || typeof x !== 'object')
|
|
74
|
+
return undefined;
|
|
75
|
+
const o = x;
|
|
76
|
+
if (typeof o.code !== 'string' || !/^[0-9A-Z]{5}$/.test(o.code))
|
|
77
|
+
return undefined;
|
|
78
|
+
return o.name === 'PostgresError' || typeof o.severity === 'string' || typeof o.severity_local === 'string' ? o : undefined;
|
|
79
|
+
}
|
|
80
|
+
/** Из ошибки PostgreSQL — только SQLSTATE и имена объектов схемы (ограничение, таблица, колонка, схема, тип, функция сервера). */
|
|
81
|
+
function pgNames(o) {
|
|
82
|
+
const out = { sqlstate: o.code };
|
|
83
|
+
const pick = (to, ...from) => {
|
|
84
|
+
for (const f of from) {
|
|
85
|
+
const v = o[f];
|
|
86
|
+
if (typeof v === 'string' && v !== '') {
|
|
87
|
+
out[to] = v;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
pick('constraint', 'constraint_name', 'constraint');
|
|
93
|
+
pick('table', 'table_name', 'table');
|
|
94
|
+
pick('column', 'column_name', 'column');
|
|
95
|
+
pick('schema', 'schema_name', 'schema');
|
|
96
|
+
pick('datatype', 'data_type_name', 'dataType');
|
|
97
|
+
pick('routine', 'routine');
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Текст ошибки для предупреждения или события: у ошибки PostgreSQL — только «PostgreSQL <SQLSTATE>» (без её текста),
|
|
102
|
+
* у прочих — текст без паролей.
|
|
103
|
+
*/
|
|
104
|
+
export function safeErrorText(e) {
|
|
105
|
+
const pg = pgOf(e);
|
|
106
|
+
if (pg)
|
|
107
|
+
return `PostgreSQL ${pg.code}`;
|
|
108
|
+
return maskText(e instanceof Error ? e.message : String(e));
|
|
109
|
+
}
|
|
110
|
+
/** Причина ошибки для события: имя, текст, код — без стека и параметров запроса; у ошибки PostgreSQL — без её текста. */
|
|
111
|
+
function causeOf(c) {
|
|
112
|
+
if (c === undefined || c === null)
|
|
113
|
+
return undefined;
|
|
114
|
+
const pg = pgOf(c);
|
|
115
|
+
if (pg) {
|
|
116
|
+
// code — SQLSTATE; рядом имена объектов схемы (constraint, table, column, …), но не текст базы
|
|
117
|
+
const { sqlstate, ...names } = pgNames(pg);
|
|
118
|
+
const out = { name: typeof pg.name === 'string' ? pg.name : 'PostgresError', message: lmsg('log.db_error', { sqlstate }), code: sqlstate };
|
|
119
|
+
return Object.assign(out, names);
|
|
120
|
+
}
|
|
121
|
+
if (c instanceof Error) {
|
|
122
|
+
const code = c.code;
|
|
123
|
+
return { name: c.name, message: maskText(c.message), ...(typeof code === 'string' ? { code } : {}) };
|
|
124
|
+
}
|
|
125
|
+
return { message: maskText(String(c)) };
|
|
126
|
+
}
|
|
127
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
128
|
+
/** Параметры текста ошибки, которые несут значение аргумента или текст PostgreSQL: в событии — '***'. */
|
|
129
|
+
const VALUE_PARAMS = ['value', 'text'];
|
|
130
|
+
/** Текст ошибки letopis для события: параметры-значения (VALUE_PARAMS) — '***', прочее — как в ошибке. */
|
|
131
|
+
function safeMessage(e) {
|
|
132
|
+
const p = e.params;
|
|
133
|
+
if (!e.key || !p || !VALUE_PARAMS.some((k) => Object.hasOwn(p, k)))
|
|
134
|
+
return e.message;
|
|
135
|
+
const masked = { ...p };
|
|
136
|
+
for (const k of VALUE_PARAMS)
|
|
137
|
+
if (Object.hasOwn(masked, k))
|
|
138
|
+
masked[k] = '***';
|
|
139
|
+
// префикс «letopis: <код>: » — из самой ошибки: заменяется только текст записи каталога
|
|
140
|
+
const orig = lmsg(e.key, p);
|
|
141
|
+
const safe = lmsg(e.key, masked);
|
|
142
|
+
return e.message.endsWith(orig) ? e.message.slice(0, e.message.length - orig.length) + safe : safe;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Подробности ошибки для события: у строк (превью удаления, снимки restore, строки отказа) поле `data` — '***': в событии
|
|
146
|
+
* error — id, класс, версия, действие, но не значения полей.
|
|
147
|
+
*/
|
|
148
|
+
function stripData(v, depth = 0) {
|
|
149
|
+
if (depth > 6 || v === null || typeof v !== 'object')
|
|
150
|
+
return v;
|
|
151
|
+
if (Array.isArray(v))
|
|
152
|
+
return v.map((x) => stripData(x, depth + 1));
|
|
153
|
+
if (v instanceof Date || v instanceof Error || ArrayBuffer.isView(v))
|
|
154
|
+
return v;
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const [k, x] of Object.entries(v))
|
|
157
|
+
out[k] = k === 'data' && x !== null && typeof x === 'object' ? '***' : stripData(x, depth + 1);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Коды штатных отказов: исход, который приложение ожидает и показывает пользователю (повтор по ключу, устаревшее
|
|
162
|
+
* превью, чужой или удалённый объект, нет прав или сессии). Их событие error — уровня warn; invalid_data — только с
|
|
163
|
+
* нарушениями данных (ValidationError, err.issues), а не неверная опция вызова.
|
|
164
|
+
*/
|
|
165
|
+
const EXPECTED_CODES = new Set([
|
|
166
|
+
'exists', 'conflict', 'target_not_found', 'not_deleted', 'delete_restricted', 'acl_denied', 'tenant_denied',
|
|
167
|
+
'no_session', 'duplicate', 'deleted', 'confirmation_required',
|
|
168
|
+
]);
|
|
169
|
+
/** Уровень события error: warn — штатный отказ (EXPECTED_CODES, нарушения данных), иначе error. */
|
|
170
|
+
export function errorLevel(e) {
|
|
171
|
+
if (!(e instanceof LetopisError))
|
|
172
|
+
return 'error';
|
|
173
|
+
if (EXPECTED_CODES.has(e.code))
|
|
174
|
+
return 'warn';
|
|
175
|
+
return e.code === 'invalid_data' && Array.isArray(e.issues) ? 'warn' : 'error';
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Поля события error по ошибке: код, ключ каталога, текст, подробности, причина. Ошибка PostgreSQL (сама или причиной)
|
|
179
|
+
* — только SQLSTATE и имена объектов схемы: её текст, DETAIL, WHERE и HINT в событие не идут.
|
|
180
|
+
*/
|
|
181
|
+
export function errorFields(e) {
|
|
182
|
+
if (e instanceof LetopisError) {
|
|
183
|
+
return {
|
|
184
|
+
code: e.code, ...(e.key ? { key: e.key } : {}), msg: safeMessage(e),
|
|
185
|
+
// detail не объектом при причине из PostgreSQL — сырой DETAIL базы (не JSON): в событие не идёт
|
|
186
|
+
...(e.detail !== undefined && (isObj(e.detail) || !pgOf(e.cause))
|
|
187
|
+
? { detail: isObj(e.detail) ? stripData(e.detail) : { value: stripData(e.detail) } } : {}),
|
|
188
|
+
...(e.cause !== undefined ? { cause: causeOf(e.cause) } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const pg = pgOf(e);
|
|
192
|
+
if (pg) {
|
|
193
|
+
const names = pgNames(pg);
|
|
194
|
+
const cause = e.cause;
|
|
195
|
+
return {
|
|
196
|
+
code: names.sqlstate, msg: lmsg('log.db_error', { sqlstate: names.sqlstate }),
|
|
197
|
+
detail: { name: typeof pg.name === 'string' ? pg.name : 'PostgresError', ...names },
|
|
198
|
+
...(cause !== undefined ? { cause: causeOf(cause) } : {}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (e instanceof Error) {
|
|
202
|
+
const code = e.code;
|
|
203
|
+
return {
|
|
204
|
+
...(typeof code === 'string' ? { code } : {}), msg: e.message, detail: { name: e.name },
|
|
205
|
+
...(e.cause !== undefined ? { cause: causeOf(e.cause) } : {}),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return { msg: String(e) };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Логгер подключения: приёмник, порог, имена полей для маскировки и привязка (кто действует, метка транзакции).
|
|
212
|
+
* Производные фасады (db.as(), db.begin(), батч) получают child() — тот же приёмник и порог.
|
|
213
|
+
*/
|
|
214
|
+
export class Log {
|
|
215
|
+
sink;
|
|
216
|
+
min;
|
|
217
|
+
redactKeys;
|
|
218
|
+
bound;
|
|
219
|
+
constructor(sink, min, redactKeys, bound = {}) {
|
|
220
|
+
this.sink = sink;
|
|
221
|
+
this.min = min;
|
|
222
|
+
this.redactKeys = redactKeys;
|
|
223
|
+
this.bound = bound;
|
|
224
|
+
}
|
|
225
|
+
/** Хоть одно событие может уйти (есть приёмник и порог не off). */
|
|
226
|
+
get enabled() {
|
|
227
|
+
return this.sink !== null && this.min <= RANK.error;
|
|
228
|
+
}
|
|
229
|
+
/** Логгер задан (log или LETOPIS_LOG): предупреждения идут в него, а не в console.warn. */
|
|
230
|
+
get active() {
|
|
231
|
+
return this.sink !== null;
|
|
232
|
+
}
|
|
233
|
+
/** Метка транзакции привязки. */
|
|
234
|
+
get tx() {
|
|
235
|
+
return this.bound.tx;
|
|
236
|
+
}
|
|
237
|
+
/** Операций записи в явной транзакции привязки (undefined — привязки к db.begin() нет). */
|
|
238
|
+
get writes() {
|
|
239
|
+
return this.bound.writes?.n;
|
|
240
|
+
}
|
|
241
|
+
/** Учесть удавшуюся операцию в счётчике записи транзакции — до проверки порога, событие может и не уйти. */
|
|
242
|
+
mark(event) {
|
|
243
|
+
if (this.bound.writes && WRITE_EVENTS.has(event))
|
|
244
|
+
this.bound.writes.n++;
|
|
245
|
+
}
|
|
246
|
+
/** Уровень проходит порог. */
|
|
247
|
+
level(l) {
|
|
248
|
+
return this.sink !== null && RANK[l] >= this.min;
|
|
249
|
+
}
|
|
250
|
+
/** Событие (его уровень по каталогу) проходит порог — проверка до сборки полей. */
|
|
251
|
+
wants(event) {
|
|
252
|
+
return this.level(LOG_EVENTS[event].level);
|
|
253
|
+
}
|
|
254
|
+
child(b) {
|
|
255
|
+
return this.sink === null ? this : new Log(this.sink, this.min, this.redactKeys, { ...this.bound, ...b });
|
|
256
|
+
}
|
|
257
|
+
/** Событие: уровень — из каталога (или fields.level); ниже порога — ничего. */
|
|
258
|
+
emit(event, f = {}) {
|
|
259
|
+
const level = f.level ?? LOG_EVENTS[event].level;
|
|
260
|
+
if (!this.level(level))
|
|
261
|
+
return;
|
|
262
|
+
let ev;
|
|
263
|
+
try {
|
|
264
|
+
ev = this.build(level, event, f);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
this.sink(level, ev);
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
loggerFailure(e, event);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Событие error по ошибке; одна ошибка — одно событие. Уровень — errorLevel: штатный отказ (exists, conflict,
|
|
278
|
+
* target_not_found, нарушения данных и т. п.) — warn, прочее — error.
|
|
279
|
+
*/
|
|
280
|
+
error(e, f = {}) {
|
|
281
|
+
const level = errorLevel(e);
|
|
282
|
+
if (!this.level(level))
|
|
283
|
+
return;
|
|
284
|
+
if (e !== null && typeof e === 'object') {
|
|
285
|
+
if (logged.has(e))
|
|
286
|
+
return;
|
|
287
|
+
logged.add(e);
|
|
288
|
+
}
|
|
289
|
+
this.emit('error', { ...f, ...errorFields(e), level });
|
|
290
|
+
}
|
|
291
|
+
/** Предупреждение: событие warning (ключ каталога и текст). */
|
|
292
|
+
warning(key, msg, detail) {
|
|
293
|
+
this.emit('warning', { ...(key ? { key } : {}), msg, ...(detail ? { detail } : {}) });
|
|
294
|
+
}
|
|
295
|
+
/** Имя поля — секрет: всегда или по redact. */
|
|
296
|
+
secret(key) {
|
|
297
|
+
return SECRET_KEY.test(key) || this.redactKeys.has(key.toLowerCase());
|
|
298
|
+
}
|
|
299
|
+
/** Значение для события: секреты — '***', строки без паролей, массивы и вложенность ограничены. */
|
|
300
|
+
clean(v, depth = 0, key) {
|
|
301
|
+
if (key !== undefined && v !== undefined && v !== null && v !== false && this.secret(key))
|
|
302
|
+
return '***';
|
|
303
|
+
if (typeof v === 'string')
|
|
304
|
+
return clip(maskText(v), 1000);
|
|
305
|
+
if (typeof v === 'bigint')
|
|
306
|
+
return String(v);
|
|
307
|
+
if (typeof v === 'function' || typeof v === 'symbol')
|
|
308
|
+
return undefined;
|
|
309
|
+
if (v === null || typeof v !== 'object')
|
|
310
|
+
return v;
|
|
311
|
+
if (depth >= 6)
|
|
312
|
+
return '…';
|
|
313
|
+
if (v instanceof Date)
|
|
314
|
+
return v.toISOString();
|
|
315
|
+
if (ArrayBuffer.isView(v))
|
|
316
|
+
return `[${v.byteLength} bytes]`;
|
|
317
|
+
if (v instanceof Error)
|
|
318
|
+
return causeOf(v);
|
|
319
|
+
if (Array.isArray(v)) {
|
|
320
|
+
const out = v.slice(0, 50).map((x) => this.clean(x, depth + 1));
|
|
321
|
+
if (v.length > 50)
|
|
322
|
+
out.push(`…+${v.length - 50}`);
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
const out = {};
|
|
326
|
+
let n = 0;
|
|
327
|
+
for (const [k, x] of Object.entries(v)) {
|
|
328
|
+
if (++n > 100) {
|
|
329
|
+
out['…'] = `+${Object.keys(v).length - 100}`;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
const c = this.clean(x, depth + 1, k);
|
|
333
|
+
if (c !== undefined)
|
|
334
|
+
out[k] = c;
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
build(level, event, f) {
|
|
339
|
+
let who;
|
|
340
|
+
try {
|
|
341
|
+
who = this.bound.who?.();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
who = undefined;
|
|
345
|
+
}
|
|
346
|
+
const ev = { ts: new Date().toISOString(), level, event };
|
|
347
|
+
const put = (k, v) => {
|
|
348
|
+
if (v !== undefined && v !== null && v !== '')
|
|
349
|
+
ev[k] = v;
|
|
350
|
+
};
|
|
351
|
+
put('op', f.op);
|
|
352
|
+
put('ms', f.ms === undefined ? undefined : round(f.ms));
|
|
353
|
+
put('tenant', f.tenant ?? who?.tenant);
|
|
354
|
+
put('account', f.account ?? who?.account);
|
|
355
|
+
put('tx', f.tx ?? this.bound.tx);
|
|
356
|
+
put('chain', f.chain === undefined ? undefined : clip(maskText(f.chain), 2000));
|
|
357
|
+
put('rows', f.rows);
|
|
358
|
+
put('code', f.code);
|
|
359
|
+
put('key', f.key);
|
|
360
|
+
put('msg', f.msg === undefined ? undefined : clip(maskText(f.msg), 2000));
|
|
361
|
+
if (f.detail !== undefined)
|
|
362
|
+
put('detail', this.clean(f.detail));
|
|
363
|
+
put('cause', f.cause);
|
|
364
|
+
return ev;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/** Логирование выключено: ни приёмника, ни событий. */
|
|
368
|
+
export const NOLOG = new Log(null, Infinity, new Set());
|
|
369
|
+
/** JSON-строка события: bigint — строкой. */
|
|
370
|
+
const json = (ev) => JSON.stringify(ev, (_k, v) => (typeof v === 'bigint' ? String(v) : v));
|
|
371
|
+
/** Логгер-функция: события JSON-строками в stderr (LETOPIS_LOG, флаг CLI --log). */
|
|
372
|
+
export const stderrLog = (ev) => {
|
|
373
|
+
process.stderr.write(`${json(ev)}\n`);
|
|
374
|
+
};
|
|
375
|
+
function sinkOf(logger) {
|
|
376
|
+
if (typeof logger === 'function') {
|
|
377
|
+
return (_l, ev) => {
|
|
378
|
+
const r = logger(ev);
|
|
379
|
+
// асинхронный логгер: отказ промиса — не необработанное отклонение
|
|
380
|
+
if (r && typeof r.then === 'function')
|
|
381
|
+
r.then(undefined, (e) => loggerFailure(e, ev.event));
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return (level, ev) => {
|
|
385
|
+
const m = logger[level] ?? logger.log;
|
|
386
|
+
if (typeof m !== 'function')
|
|
387
|
+
return;
|
|
388
|
+
const r = m.call(logger, ev, ev.event);
|
|
389
|
+
if (r && typeof r.then === 'function')
|
|
390
|
+
r.then(undefined, (e) => loggerFailure(e, ev.event));
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Уровень из LETOPIS_LOG: `debug|info|warn|error`; `1`, `true`, `yes`, `on` и прочее непустое — `info`; не задана, пусто,
|
|
395
|
+
* `0`, `false`, `no`, `off` — логирования по переменной нет.
|
|
396
|
+
*/
|
|
397
|
+
export function envLevel(v = process.env.LETOPIS_LOG) {
|
|
398
|
+
const s = (v ?? '').trim().toLowerCase();
|
|
399
|
+
if (!s || s === '0' || s === 'false' || s === 'no' || s === 'off')
|
|
400
|
+
return undefined;
|
|
401
|
+
return s in RANK ? s : 'info';
|
|
402
|
+
}
|
|
403
|
+
const shown = (v) => (typeof v === 'string' ? `'${v}'` : typeof v === 'number' || typeof v === 'boolean' || v == null ? String(v) : typeof v);
|
|
404
|
+
/**
|
|
405
|
+
* Логгер по опциям: log (или stderr по LETOPIS_LOG), порог logLevel (или из LETOPIS_LOG, иначе 'info'), redact. Неверные
|
|
406
|
+
* опции — invalid_data (log.bad_level, log.bad_logger, log.bad_redact) до подключения.
|
|
407
|
+
*/
|
|
408
|
+
export function makeLog(o = {}) {
|
|
409
|
+
const { log: logger, logLevel, redact } = o;
|
|
410
|
+
if (logLevel !== undefined && !LEVELS.includes(logLevel))
|
|
411
|
+
throw lerr('log.bad_level', { value: shown(logLevel) });
|
|
412
|
+
if (logger !== undefined && typeof logger !== 'function' && (logger === null || typeof logger !== 'object'))
|
|
413
|
+
throw lerr('log.bad_logger');
|
|
414
|
+
if (redact !== undefined && (!Array.isArray(redact) || redact.some((x) => typeof x !== 'string')))
|
|
415
|
+
throw lerr('log.bad_redact');
|
|
416
|
+
const env = envLevel();
|
|
417
|
+
const sink = logger !== undefined ? sinkOf(logger) : env !== undefined ? sinkOf(stderrLog) : null;
|
|
418
|
+
if (!sink)
|
|
419
|
+
return NOLOG;
|
|
420
|
+
const level = logLevel ?? env ?? 'info';
|
|
421
|
+
return new Log(sink, level === 'off' ? Infinity : RANK[level], new Set((redact ?? []).map((k) => k.toLowerCase())));
|
|
422
|
+
}
|
|
423
|
+
let envCache = null;
|
|
424
|
+
/** Логгер по одной LETOPIS_LOG — для мест без подключения (предупреждения, createTenant). */
|
|
425
|
+
export function envLog() {
|
|
426
|
+
const v = process.env.LETOPIS_LOG;
|
|
427
|
+
if (!envCache || envCache.v !== v)
|
|
428
|
+
envCache = { v, log: makeLog({}) };
|
|
429
|
+
return envCache.log;
|
|
430
|
+
}
|
|
431
|
+
// предупреждения без подключения (lwarn без log) — в логгер LETOPIS_LOG, если он задан
|
|
432
|
+
setEnvWarnSink(envLog);
|
|
433
|
+
let txSeq = 0;
|
|
434
|
+
/** Метка транзакции: b — db.begin(), t — db.transaction(); номер в процессе. */
|
|
435
|
+
export const nextTx = (kind) => `${kind}${(++txSeq).toString(36)}`;
|
|
436
|
+
/** Строка подключения без пароля. */
|
|
437
|
+
export const maskDsn = (dsn) => {
|
|
438
|
+
if (dsn === undefined)
|
|
439
|
+
return undefined;
|
|
440
|
+
// В журнал — только схема, хост, порт и база: без пользователя, пароля и параметров строки.
|
|
441
|
+
try {
|
|
442
|
+
const u = new URL(dsn);
|
|
443
|
+
return `${u.protocol}//${u.host}${u.pathname}`;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return maskText(dsn);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
/**
|
|
450
|
+
* Операция с событием: время, событие успеха (done может дополнить поля, сменить событие или вернуть null — без
|
|
451
|
+
* события) и error при отказе. Выключенный логгер — сам вызов без обёртки.
|
|
452
|
+
*/
|
|
453
|
+
export async function traced(log, event, f, fn, done) {
|
|
454
|
+
if (!log.enabled)
|
|
455
|
+
return fn();
|
|
456
|
+
const t0 = performance.now();
|
|
457
|
+
let r;
|
|
458
|
+
try {
|
|
459
|
+
r = await fn();
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
log.error(e, { ...f, ms: performance.now() - t0 });
|
|
463
|
+
throw e;
|
|
464
|
+
}
|
|
465
|
+
if (event || done) {
|
|
466
|
+
try {
|
|
467
|
+
const extra = done ? done(r) : undefined;
|
|
468
|
+
if (extra !== null) {
|
|
469
|
+
const { event: ev2, ...rest } = extra ?? {};
|
|
470
|
+
const name = ev2 ?? event;
|
|
471
|
+
if (name)
|
|
472
|
+
log.mark(name);
|
|
473
|
+
if (name && log.wants(name))
|
|
474
|
+
log.emit(name, { ...f, ms: performance.now() - t0, ...rest });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// событие не собралось — операция всё равно удалась
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return r;
|
|
482
|
+
}
|
|
483
|
+
/** Синхронный вызов: ошибка — событие error и проброс (шаги и модификаторы цепочки, Proxy фасада). */
|
|
484
|
+
export function guarded(log, f, fn) {
|
|
485
|
+
if (!log.enabled)
|
|
486
|
+
return fn();
|
|
487
|
+
try {
|
|
488
|
+
return fn();
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
log.error(e, typeof f === 'function' ? f() : f);
|
|
492
|
+
throw e;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Обёртка API-объекта (db.auth, db.acl): каждый метод — время, событие по spec и error при отказе. pre — снимок до вызова
|
|
497
|
+
* (например, прежний арендатор у auth.switch). Выключенный логгер — тот же объект.
|
|
498
|
+
*/
|
|
499
|
+
export function instrument(api, log, area, specs, pre = {}) {
|
|
500
|
+
if (!log.enabled)
|
|
501
|
+
return api;
|
|
502
|
+
const out = {};
|
|
503
|
+
for (const k of Object.keys(api)) {
|
|
504
|
+
const v = api[k];
|
|
505
|
+
if (typeof v !== 'function') {
|
|
506
|
+
out[k] = v;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const spec = specs[k];
|
|
510
|
+
const op = `${area}.${k}`;
|
|
511
|
+
out[k] = async (...args) => {
|
|
512
|
+
let before;
|
|
513
|
+
try {
|
|
514
|
+
before = pre[k]?.();
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
before = undefined;
|
|
518
|
+
}
|
|
519
|
+
const t0 = performance.now();
|
|
520
|
+
let r;
|
|
521
|
+
try {
|
|
522
|
+
r = await v.apply(api, args);
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
log.error(e, { op, ms: performance.now() - t0 });
|
|
526
|
+
throw e;
|
|
527
|
+
}
|
|
528
|
+
if (spec) {
|
|
529
|
+
try {
|
|
530
|
+
const s = spec(args, r, before);
|
|
531
|
+
if (s) {
|
|
532
|
+
const { event, ...rest } = s;
|
|
533
|
+
log.mark(event);
|
|
534
|
+
if (log.wants(event))
|
|
535
|
+
log.emit(event, { op, ms: performance.now() - t0, ...rest });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
// событие не собралось — вызов всё равно удался
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return r;
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
// SQL: событие sql на каждый запрос (уровень debug)
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
/** Текст запроса для события: одной строкой, без паролей, не длиннее 2000 символов. */
|
|
551
|
+
export function sqlText(text) {
|
|
552
|
+
return clip(maskText(text.replace(/\s+/g, ' ').trim()), 2000);
|
|
553
|
+
}
|
|
554
|
+
/** Строк в ответе postgres.js (массив с count или число затронутых). */
|
|
555
|
+
function rowsOf(x) {
|
|
556
|
+
if (Array.isArray(x)) {
|
|
557
|
+
const c = x.count;
|
|
558
|
+
return typeof c === 'number' ? Math.max(c, x.length) : x.length;
|
|
559
|
+
}
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
/** Запрос postgres.js: событие sql по его завершении (время от постановки), без значений параметров. */
|
|
563
|
+
function watchQuery(log, q, text, params) {
|
|
564
|
+
const t0 = performance.now();
|
|
565
|
+
const query = q;
|
|
566
|
+
const send = (rows, sqlstate) => log.emit('sql', {
|
|
567
|
+
ms: performance.now() - t0, ...(rows !== undefined ? { rows } : {}),
|
|
568
|
+
detail: { text: sqlText(text), params, ...(typeof sqlstate === 'string' ? { sqlstate } : {}) },
|
|
569
|
+
});
|
|
570
|
+
if (!query || typeof query.resolve !== 'function' || typeof query.reject !== 'function') {
|
|
571
|
+
send(undefined);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const resolve = query.resolve;
|
|
575
|
+
const reject = query.reject;
|
|
576
|
+
query.resolve = (x) => {
|
|
577
|
+
try {
|
|
578
|
+
send(rowsOf(x));
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// событие не собралось — ответ всё равно уходит
|
|
582
|
+
}
|
|
583
|
+
return resolve(x);
|
|
584
|
+
};
|
|
585
|
+
query.reject = (e) => {
|
|
586
|
+
try {
|
|
587
|
+
send(undefined, e?.code);
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// как выше
|
|
591
|
+
}
|
|
592
|
+
return reject(e);
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
const isTemplate = (a) => Array.isArray(a) && Array.isArray(a.raw);
|
|
596
|
+
/**
|
|
597
|
+
* Пул postgres.js (или зарезервированное соединение, транзакция), каждый запрос которого даёт событие sql: шаблон
|
|
598
|
+
* sql`…`, unsafe(), а также соединения reserve() и транзакции begin()/savepoint(). Только при пороге debug — иначе тот
|
|
599
|
+
* же объект. Значения параметров в событие не попадают.
|
|
600
|
+
*/
|
|
601
|
+
export function traceSql(sql, log) {
|
|
602
|
+
if (!log.wants('sql'))
|
|
603
|
+
return sql;
|
|
604
|
+
const wrap = (target) => new Proxy(target, {
|
|
605
|
+
apply(t, self, args) {
|
|
606
|
+
const r = Reflect.apply(t, self, args);
|
|
607
|
+
if (isTemplate(args[0]))
|
|
608
|
+
watchQuery(log, r, args[0].reduce((a, s, i) => (i ? `${a}$${i}${s}` : s), ''), args.length - 1);
|
|
609
|
+
return r;
|
|
610
|
+
},
|
|
611
|
+
get(t, p) {
|
|
612
|
+
const v = Reflect.get(t, p, t);
|
|
613
|
+
if (typeof v !== 'function')
|
|
614
|
+
return v;
|
|
615
|
+
const fn = v;
|
|
616
|
+
if (p === 'unsafe') {
|
|
617
|
+
return (text, params, o) => {
|
|
618
|
+
const r = fn.call(t, text, params, o);
|
|
619
|
+
watchQuery(log, r, String(text), Array.isArray(params) ? params.length : 0);
|
|
620
|
+
return r;
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
if (p === 'reserve')
|
|
624
|
+
return async (...a) => wrap((await fn.apply(t, a)));
|
|
625
|
+
if (p === 'begin' || p === 'savepoint') {
|
|
626
|
+
return (...a) => fn.apply(t, a.map((x) => (typeof x === 'function'
|
|
627
|
+
? (tx) => x(wrap(tx)) : x)));
|
|
628
|
+
}
|
|
629
|
+
return v;
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
return wrap(sql);
|
|
633
|
+
}
|