letopis 2.0.0 → 3.0.1

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