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.
Files changed (75) hide show
  1. package/AGENT-CHEATSHEET.md +484 -414
  2. package/CHANGELOG.md +321 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +786 -183
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +3 -0
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +2 -1
  10. package/dist/admin.js +6 -4
  11. package/dist/auth.d.ts +18 -3
  12. package/dist/auth.js +75 -6
  13. package/dist/chain.d.ts +38 -17
  14. package/dist/chain.js +434 -63
  15. package/dist/cli.js +28 -6
  16. package/dist/errors.d.ts +19 -1
  17. package/dist/errors.js +46 -6
  18. package/dist/index.d.ts +10 -4
  19. package/dist/index.js +136 -46
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +9 -5
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +633 -0
  25. package/dist/mcp.js +26 -11
  26. package/dist/messages/api.d.ts +66 -0
  27. package/dist/messages/api.js +90 -1
  28. package/dist/messages/boot.js +4 -4
  29. package/dist/messages/codes.js +4 -4
  30. package/dist/messages/index.d.ts +5 -2
  31. package/dist/messages/index.js +5 -2
  32. package/dist/messages/log.d.ts +229 -0
  33. package/dist/messages/log.js +102 -0
  34. package/dist/messages/model.d.ts +24 -1
  35. package/dist/messages/model.js +34 -3
  36. package/dist/messages/query.d.ts +10 -0
  37. package/dist/messages/query.js +13 -0
  38. package/dist/messages/sql-core.d.ts +15 -1
  39. package/dist/messages/sql-core.js +20 -3
  40. package/dist/messages/sql-time.d.ts +15 -0
  41. package/dist/messages/sql-time.js +23 -0
  42. package/dist/messages/write.d.ts +47 -0
  43. package/dist/messages/write.js +63 -0
  44. package/dist/model.d.ts +4 -0
  45. package/dist/model.js +23 -5
  46. package/dist/sql.d.ts +2 -0
  47. package/dist/sql.js +77 -25
  48. package/dist/sync.d.ts +1 -1
  49. package/dist/sync.js +50 -0
  50. package/dist/tx.d.ts +10 -1
  51. package/dist/tx.js +44 -2
  52. package/dist/typed.d.ts +14 -10
  53. package/dist/types.d.ts +82 -4
  54. package/dist/types.js +6 -1
  55. package/dist/up.d.ts +3 -3
  56. package/dist/up.js +57 -17
  57. package/dist/uuid.d.ts +8 -0
  58. package/dist/uuid.js +9 -1
  59. package/dist/validate.js +24 -7
  60. package/dist/watch.d.ts +5 -0
  61. package/dist/watch.js +13 -0
  62. package/dist/write.d.ts +78 -1
  63. package/dist/write.js +357 -39
  64. package/llms.txt +37 -17
  65. package/package.json +12 -6
  66. package/sql/20-context.sql +11 -3
  67. package/sql/30-validate.sql +51 -10
  68. package/sql/40-class.sql +21 -1
  69. package/sql/80-auth.sql +88 -17
  70. package/sql/90-time.sql +34 -6
  71. package/sql/99-revision.sql +5 -1
  72. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  73. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  74. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  75. 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) => exec.run(fn),
105
- idOf: (cls, key) => {
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: async (account, o) => {
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
- begin: async (o) => {
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
- const btx = o?.reason !== undefined ? { ...txc, reason: checkReason(o.reason, 'db.begin()') } : txc;
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: async (input, o) => {
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: async (...keys) => (tx ? tx.lock(...keys) : noTx('lock()')),
195
- commit: async (tr) => (tr ? tr.commit() : tx ? tx.commit() : noTx('commit()')),
196
- rollback: async (tr) => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()')),
197
- close: () => (tx ? tx.rollback() : sh.close()),
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
- return startStep(chainCtx, prop);
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
- const sql = (opts.sql ?? postgres(opts.dsn, { max: opts.max ?? 10, onnotice: () => { }, types }));
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 adminSql = (typeof opts.admin === 'string'
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
- console.warn(drift);
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
- const notifier = new Notifier(sql, opts.schema, opts.notifyIntervalMs ?? 100);
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
- const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
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) {
@@ -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: Map<string, {
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 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
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
- if (!f || (f.tenant !== tenant && f.tenant !== SYSTEM_ID) || !accepts(reg, ref.allowed, f.class)) {
624
- issues.add({ line: ref.line, code: 'target_not_found', message: lmsg('write.load_target_not_found', { role: ref.role }), id });
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) ?? p1[i].cls;
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 if (!external.has(id)) {
762
- external.set(id, { line: i + 1, role, allowed });
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
- if (!f || (f.tenant !== p.tenant && f.tenant !== SYSTEM_ID) || !accepts(p.reg, ref.allowed, f.class)) {
1187
- issues.add({ line: ref.line, code: 'target_not_found', message: lmsg('write.load_target_not_found', { role: ref.role }), id });
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
  }