letopis 1.4.2 → 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 (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/index.js CHANGED
@@ -5,71 +5,75 @@
5
5
  * Этап 2: connect() — подключение под сессией, реестр классов, отложенный сигнал, db.sql.
6
6
  * Этап 3: цепочки чтения t.Класс(…).Связь().rows(); этап 4 — запись цепочками и загрузчик.
7
7
  * Этап 5: db.auth (креды, вход, сессии), db.acl, db.as(аккаунт, { tenant }) — имперсонация.
8
- * Этап 6: история (restore, purge, rekey, trimHistory), verify, maintain, reset, watch, кэш результатов.
8
+ * Этап 6: история (versions, asOf, restore), maintain, watch, кэш результатов.
9
9
  */
10
10
  import postgres from 'postgres';
11
- import { LetopisError, fromDbError, removed } 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
- import { load } from './load.js';
20
+ import { load, LOAD_OPTIONS } from './load.js';
20
21
  import { makeBatch, startEntity, startStep } from './chain.js';
21
- import { DDL_REVISION } from './types.js';
22
+ import { or as orFilters } from './ops.js';
23
+ import { DDL_REVISION, SCHEMA_VERSION } from './types.js';
22
24
  import { ACL_CLASSES, ResultCache } from './cache.js';
23
25
  import { Subscription } from './watch.js';
24
- import { Projector } from './project.js';
25
26
  import { SIGNAL_MODES, Signals, bucket, throttle } from './signals.js';
26
- import { describe as describeModel, maintain as runMaintain, rebuildAggregates as runRebuildAggregates, rekeyClass as runRekeyClass, reset as runReset, MAINTAIN_STOP, parseInterval, scheduleMaintain, trimHistory as runTrim, verify as runVerify, } from './admin.js';
27
- import { stampDrift } from './up.js';
27
+ import { maintain as runMaintain, parseInterval, scheduleMaintain, } from './admin.js';
28
+ import { refuse1x, stampDrift } from './up.js';
28
29
  export { DDL_REVISION } from './types.js';
29
30
  export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
30
31
  export { LetopisError, ValidationError, fromDbError } from './errors.js';
31
32
  export { Registry } from './registry.js';
32
33
  export { compileValidator, applyDefaults } from './validate.js';
33
34
  export { syncModels, generateTypes, schemaToTs } from './sync.js';
34
- export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, isOp } from './ops.js';
35
+ export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, isOp } from './ops.js';
36
+ /**
37
+ * Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
38
+ * вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и». В шаге подключения
39
+ * с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт шаг (план 2.0, этап 4, п. 1). Та же
40
+ * фабрика, что в реестре операторов (ops.ts), — другой у неё только тип.
41
+ */
42
+ export const or = orFilters;
35
43
  export { cursorOf } from './sql.js';
36
44
  export { inc, isInc } from './write.js';
37
45
  export { totpCode, hashPassword, verifyHash } from './auth.js';
38
- export { describeText } from './admin.js';
39
46
  export { up, createTenant } from './up.js';
40
47
  export { RESERVED_CLASS_NAMES, CHAIN_METHODS, DB_METHODS } from './types.js';
41
- /** Снесено в 1.0.0: id v7 больше не выдаются (§2.3). */
42
- export function uuidv7() {
43
- throw removed('uuidv7()', 'uuidv5() и idOf(): id объекта вычисляется из ключа');
44
- }
45
- /** Снесено в 1.0.0: пространство имён v5 — id арендатора. Любое использование значения — ошибка. */
46
- export const LETOPIS_NS = new Proxy({}, {
47
- get(_t, p) {
48
- if (p === 'then')
49
- return undefined;
50
- throw removed('LETOPIS_NS', 'пространство имён v5 — id арендатора: idOf(класс, ключ, { tenant })');
51
- },
52
- });
48
+ export { LOG_EVENTS } from './messages/index.js';
53
49
  const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
54
50
  const SCHEMA_NAME = /^v\d+\.[a-z_][a-z0-9_]{0,40}$/;
55
- /** Опции 0.21, снятые в 1.0.0, и их замены (этап 3, п. 11). */
56
- const REMOVED_OPTIONS = {
57
- partition: 'отдельные схемы установки (v2.<имя>)',
58
- enforceAccount: 'изоляция арендаторов включена всегда (RLS)',
59
- enforceAcl: 'права включены всегда (этап 5)',
60
- container: 'Docker в up() не нужен',
61
- image: 'Docker в up() не нужен',
62
- dataDir: 'Docker в up() не нужен',
63
- redisPort: 'Redis не нужен: сессии в базе',
64
- version: 'версия схемы — константа движка',
51
+ /** Все опции connect(): неизвестная — invalid_data с похожими именами (checkOptions; план 2.0, 7.10 №4). */
52
+ const CONNECT_OPTIONS = {
53
+ dsn: true, sql: true, schema: true, token: true, apiKey: true, service: true, refreshSession: true, allowBypassRls: true,
54
+ admin: true, owner: true, notifyIntervalMs: true, max: true, onQuery: true, slowMs: true, models: true, listen: true,
55
+ cache: true, maintain: true, log: true, logLevel: true, redact: true,
65
56
  };
57
+ /** Опции watch() (WatchOptions). */
58
+ const WATCH_OPTIONS = { from: true, classes: true, batch: true, pollMs: true };
59
+ /** Опции db.begin() (второй раунд 2.1, A4). */
60
+ const BEGIN_OPTIONS = { reason: true };
61
+ /**
62
+ * То же подключение без строгих типов: шагом может быть любое имя, в том числе известное только во время выполнения, —
63
+ * `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, слот) не меняются.
64
+ */
65
+ export function loose(db) {
66
+ return db;
67
+ }
66
68
  const quote = (name) => `"${name.replace(/"/g, '""')}"`;
67
69
  /** Фасад db: пул (exec — исполнитель пула) или явная транзакция (tx). */
68
70
  function makeDb(sh, scope, exec, owner, tx) {
69
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 }) });
70
74
  const chainCtx = {
71
- reg: scope.registry(), schema: sh.schema, exec, tenant: scope.tenant, owner, onQuery: sh.onQuery, slowMs: sh.slowMs,
72
- 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,
73
77
  ...(sh.cache ? { cache: { store: sh.cache, who: () => ({ token: txc.token, account: txc.account, tenant: scope.tenant() }) } } : {}),
74
78
  };
75
79
  // реестр корня меняется после auth.switch — цепочки берут действующий
@@ -78,14 +82,23 @@ function makeDb(sh, scope, exec, owner, tx) {
78
82
  // (актор, арендатор, владелец, транзакция), значит, в ней только планы, поставленные этим же фасадом
79
83
  const batches = new Map();
80
84
  const noTx = (what) => {
81
- throw new LetopisError('tx_required', `${what} — только внутри db.begin()`);
85
+ throw lerr('api.tx_only', { what });
82
86
  };
83
- const auth = makeAuth(sh.S, exec, async () => {
87
+ const auth = instrument(makeAuth(sh.S, exec, async () => {
84
88
  sh.passes.clear();
85
89
  await sh.refresh();
86
- }, scope.root && !tx);
87
- const acl = makeAcl(sh.S, exec);
88
- const adm = { sql: sh.sql, schema: sh.schema, S: sh.S, txc, exec, registry: scope.registry, tenant: scope.tenant };
90
+ }, scope.root && !tx), log, 'auth', AUTH_LOG, { switch: () => scope.tenant() });
91
+ // запись правил (grant, deny, revoke, roles) идёт цепочками самого фасада — он объявлен ниже, берётся при вызове
92
+ const acl = instrument(makeAcl(sh.S, exec, () => facade), log, 'acl', ACL_LOG);
93
+ const adm = { S: sh.S, txc, exec };
94
+ // один процесс (план 2.0, 7.9 №3): у корня с connect({ admin }) администраторские вызовы идут вторым пулом с той же
95
+ // сессией, а цепочки и db.sql — пулом приложения под RLS; db.as() и db.begin() администраторский пул не берут
96
+ const viaAdmin = scope.root && !tx && sh.adminSql ? sh.adminSql : null;
97
+ const adminReady = sh.adminReady ?? (async () => { });
98
+ const admCtx = () => {
99
+ const t = { ...txc, sql: viaAdmin };
100
+ return { ...adm, txc: t, exec: poolExecutor(t) };
101
+ };
89
102
  const core = {
90
103
  schema: sh.schema,
91
104
  get token() { return txc.token; },
@@ -93,31 +106,36 @@ function makeDb(sh, scope, exec, owner, tx) {
93
106
  get registry() { return scope.registry(); },
94
107
  auth,
95
108
  acl,
96
- sql: (strings, ...values) => exec.run((q) => q(strings, ...values), { raw: true }),
97
- transaction: (fn) => exec.run(fn),
98
- 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' }, () => {
99
128
  const tenant = scope.tenant();
100
129
  if (!tenant)
101
- throw new LetopisError('no_session', 'idOf: нет арендатора сессии');
130
+ throw lerr('api.id_of_no_tenant');
102
131
  const c = scope.registry().resolve(cls);
103
132
  return idOfClass({ name: c.name, key: c.key, ends: Object.keys(c.ends) }, key, { tenant });
104
- },
105
- refresh: sh.refresh,
133
+ }),
134
+ refresh: () => traced(log, null, { op: 'refresh' }, sh.refresh),
106
135
  flushSignals: () => sh.notifier.flush(),
107
- verify: (o) => runVerify(adm, o),
108
- maintain: (o) => runMaintain(adm, o),
109
- reset: async (o) => {
110
- const r = await runReset(adm, o);
111
- sh.passes.clear();
112
- sh.cache?.clear();
113
- return r;
114
- },
115
- trimHistory: (target, before) => runTrim(adm, target, before),
116
- rekeyClass: (cls, key) => runRekeyClass(adm, cls, key),
117
- describe: () => describeModel(adm),
118
- watch: (o = {}) => {
119
- if (typeof o === 'function' || typeof o === 'string')
120
- throw removed('watch(cb) и watch(класс, cb)', 'watch({ from }) — подписка по курсору');
136
+ maintain: () => traced(log, 'maintain', { op: 'maintain' }, () => (viaAdmin ? adminReady().then(() => runMaintain(admCtx())) : runMaintain(adm)), (r) => ({ detail: { ...r } })),
137
+ watch: (o = {}) => guarded(log, { op: 'watch' }, () => {
138
+ checkOptions('watch', o, WATCH_OPTIONS);
121
139
  const reg = scope.registry();
122
140
  const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
123
141
  return sh.track(new Subscription({
@@ -136,16 +154,17 @@ function makeDb(sh, scope, exec, owner, tx) {
136
154
  onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); },
137
155
  tenant: () => (sh.allTenants ? null : sh.sig.tenantToken(scope.tenant())),
138
156
  } : {}),
157
+ ...(log.enabled ? { log } : {}),
139
158
  }, o));
140
- },
159
+ }),
141
160
  cache: {
142
161
  clear: () => sh.cache?.clear(),
143
162
  stats: () => sh.cache?.stats() ?? { size: 0, hits: 0, misses: 0, stores: 0, raced: 0, signals: 0, listens: 0, enabled: false },
144
163
  },
145
- entity: (x) => startEntity(chainCtx, x),
146
- 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 () => {
147
166
  if (tx)
148
- throw new LetopisError('invalid_query', 'db.as() — от корня подключения, а не внутри db.begin()');
167
+ throw lerr('api.as_in_tx');
149
168
  const id = account === null ? undefined : typeof account === 'object' ? account.id : account;
150
169
  const nextOwner = o?.owner ?? owner;
151
170
  if (id === undefined && o?.tenant === undefined) {
@@ -155,7 +174,7 @@ function makeDb(sh, scope, exec, owner, tx) {
155
174
  const scoped = { ...txc, account: id ?? txc.account, tenant: o?.tenant ?? (id === undefined ? txc.tenant : undefined) };
156
175
  let [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
157
176
  if (!r?.account)
158
- throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
177
+ throw lerr('api.as_no_session');
159
178
  // контекст сессии между запросами ушёл из арендатора (сменился или кончился): реестр не загружен и не
160
179
  // кэширован (хвост ревью безопасности №3) — контекст читается заново, не больше трёх раз
161
180
  for (let attempt = 1;; attempt++) {
@@ -164,145 +183,181 @@ function makeDb(sh, scope, exec, owner, tx) {
164
183
  if (registry)
165
184
  return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => sh.registryOf(tenant, registry, scoped), root: false }, poolExecutor(scoped), nextOwner);
166
185
  if (attempt === 3)
167
- throw new LetopisError('no_session', 'db.as(): контекст сессии меняется — реестр арендатора не загружен');
186
+ throw lerr('api.as_context_changing');
168
187
  [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
169
188
  if (!r?.account)
170
- throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
189
+ throw lerr('api.as_no_session');
171
190
  }
172
- },
173
- 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);
174
199
  if (tx)
175
- throw new LetopisError('invalid_query', 'вложенных транзакций нет: db.begin() внутри db.begin()');
176
- // причина блока ставится в начале транзакции (setupSql) и действует на все её операции
177
- const btx = o?.reason !== undefined ? { ...txc, reason: checkReason(o.reason, 'db.begin()') } : txc;
200
+ throw lerr('api.nested_begin');
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;
178
207
  const t = await ExplicitTx.open(btx);
179
208
  return makeDb(sh, btx === txc ? scope : { ...scope, txc: btx }, t, owner, t);
180
- },
181
- load: async (input, o) => {
209
+ }),
210
+ load: (input, o) => traced(log, 'load', { op: 'load' }, async () => {
211
+ // опции — до всего прочего: неизвестная — отказ раньше admin_required пула администратора (план 2.0, 7.10 №4)
212
+ checkOptions('load', o, LOAD_OPTIONS);
182
213
  if (tx)
183
- throw new LetopisError('invalid_query', 'load() — своей транзакцией, не внутри db.begin()');
214
+ throw lerr('api.load_in_tx');
215
+ if (viaAdmin) {
216
+ await adminReady();
217
+ return load({ sql: viaAdmin, schema: sh.schema, tenant: scope.tenant(), admin: true, ownerRole: sh.ownerRole }, input, o);
218
+ }
184
219
  return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
185
- },
186
- projector: (name, o) => {
187
- if (tx)
188
- throw new LetopisError('invalid_query', 'projector() — от корня подключения, а не внутри db.begin()');
189
- if (!sh.admin)
190
- throw new LetopisError('admin_required', 'проектору нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
191
- return new Projector({
192
- sql: sh.sql, schema: sh.schema, S: sh.S, ownerRole: sh.ownerRole,
193
- ...(sh.listen ? { onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); } } : {}),
194
- }, name, o);
195
- },
196
- rebuildAggregates: async (cls) => {
197
- if (tx)
198
- throw new LetopisError('invalid_query', 'rebuildAggregates() — своей транзакцией, не внутри db.begin()');
199
- if (!sh.admin)
200
- throw new LetopisError('admin_required', 'ремонту агрегатов нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
201
- return runRebuildAggregates({ sql: sh.sql, S: sh.S, ownerRole: sh.ownerRole }, cls);
202
- },
220
+ }, (r) => ({ rows: r.loaded + r.updated, detail: { ...r } })),
203
221
  batch: (name, o) => {
204
222
  let q = batches.get(name);
205
223
  if (!q)
206
224
  batches.set(name, (q = []));
207
225
  return makeBatch(o?.reason !== undefined ? { ...chainCtx, reason: checkReason(o.reason, 'db.batch()') } : chainCtx, q);
208
226
  },
209
- lock: async (...keys) => (tx ? tx.lock(...keys) : noTx('lock()')),
210
- commit: async (tr) => (tr ? tr.commit() : tx ? tx.commit() : noTx('commit()')),
211
- rollback: async (tr) => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()')),
212
- 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())),
232
+ };
233
+ /** Свойство фасада: служебное — из core, иначе шаг класса, алиаса или роли. */
234
+ const member = (t, prop, r) => {
235
+ if (typeof prop === 'symbol' || prop in t)
236
+ return Reflect.get(t, prop, r);
237
+ if (prop === 'then')
238
+ return undefined;
239
+ // неизвестный класс или роль — событие error с именем шага
240
+ return log.enabled ? guarded(log, { op: prop, chain: prop }, () => startStep(chainCtx, prop)) : startStep(chainCtx, prop);
213
241
  };
214
242
  const facade = new Proxy(core, {
215
243
  get(t, prop, r) {
216
- if (typeof prop === 'symbol' || prop in t)
217
- return Reflect.get(t, prop, r);
218
- switch (prop) {
219
- case 'then':
220
- return undefined;
221
- case 'reloadSchema':
222
- throw removed('reloadSchema()', 'реестр обновляется сам по сигналу; принудительно — db.refresh()');
223
- case 'accounts':
224
- case 'credentials':
225
- case 'resources':
226
- case 'rules':
227
- throw removed(`фасад db.${prop}`, 'системные классы Account, Resource, rule, member и db.auth (accounts.purge — db.auth.purgeAccount)');
228
- case 'compact':
229
- throw removed('compact()', 'db.trimHistory(цель, момент), политика хранения history и history: false');
230
- }
231
- return startStep(chainCtx, prop);
244
+ return member(t, prop, r);
232
245
  },
233
246
  });
234
247
  // модули пакета (letopis/mcp) узнают обход RLS по самому подключению, а не со слов вызывающего (internal.ts)
235
- registerDb(facade, { allTenants: sh.allTenants, explicit: Boolean(tx) });
248
+ registerDb(facade, {
249
+ allTenants: sh.allTenants, explicit: Boolean(tx), log,
250
+ ...(viaAdmin ? { adminTx: (fn) => adminReady().then(() => runTx({ ...txc, sql: viaAdmin }, fn)) } : {}),
251
+ });
236
252
  return facade;
237
253
  }
238
254
  export async function connect(opts) {
255
+ // опции — первыми: неизвестная — invalid_data с похожими именами до прочих проверок (план 2.0, 7.10 №4)
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}` };
239
267
  if (!SCHEMA_NAME.test(opts.schema))
240
- throw new LetopisError('invalid_data', `connect: схема ${opts.schema} — нужно v<N>.<имя>`);
268
+ throw lerr('api.connect_schema', { schema: opts.schema });
241
269
  if (!opts.sql && !opts.dsn)
242
- throw new LetopisError('invalid_data', 'connect: нужен dsn или sql');
243
- for (const k of Object.keys(opts))
244
- if (k in REMOVED_OPTIONS)
245
- throw removed(`опция connect({ ${k} })`, REMOVED_OPTIONS[k]);
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
+ }
246
279
  // интервал обслуживания — до пула и таймеров: неверный не оставляет за собой открытых соединений
247
280
  const maintainEvery = opts.maintain === undefined ? undefined : parseInterval(opts.maintain);
281
+ if (opts.admin !== undefined && typeof opts.admin !== 'string' && typeof opts.admin !== 'function') {
282
+ throw lerr('api.connect_admin_type');
283
+ }
248
284
  const own = !opts.sql;
249
285
  // timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
250
- const sql = (opts.sql ?? postgres(opts.dsn, {
251
- max: opts.max ?? 10,
252
- onnotice: () => { },
253
- types: { timestamptz: { to: 1184, from: [1184], serialize: (v) => (v instanceof Date ? v.toISOString() : v), parse: (v) => v } },
254
- }));
286
+ const types = { timestamptz: { to: 1184, from: [1184], serialize: (v) => (v instanceof Date ? v.toISOString() : v), parse: (v) => v } };
287
+ // порог debug — каждый запрос пула даёт событие sql (traceSql); иначе пул как есть
288
+ const sql = traceSql((opts.sql ?? postgres(opts.dsn, { max: opts.max ?? 10, onnotice: () => { }, types })), base);
289
+ // один процесс (план 2.0, 7.9 №3): пул администратора postgres.js соединяется при первом запросе — до первого
290
+ // администраторского вызова соединений у него нет
291
+ const ownAdmin = typeof opts.admin === 'string';
292
+ const adminRaw = (typeof opts.admin === 'string'
293
+ ? postgres(opts.admin, { max: 2, onnotice: () => { }, types, connection: { application_name: 'letopis admin' } })
294
+ : opts.admin);
295
+ const adminSql = adminRaw ? traceSql(adminRaw, base) : undefined;
255
296
  const S = quote(opts.schema);
256
297
  const owner = opts.owner ?? 'letopis_owner';
298
+ let adminChecked;
299
+ const adminReady = adminSql ? () => (adminChecked ??= (async () => {
300
+ const [a] = await adminSql `select r.rolsuper, pg_has_role(current_user, ${owner}, 'SET') as can_set from pg_roles r where r.rolname = current_user`;
301
+ if (!a?.rolsuper && !a?.can_set) {
302
+ throw lerr('api.connect_admin_role', { owner });
303
+ }
304
+ })().catch((e) => {
305
+ adminChecked = undefined;
306
+ throw e;
307
+ })) : undefined;
257
308
  try {
258
309
  // роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
259
310
  // роли владельца нет (опечатка в owner) — понятная ошибка, а не сырая 42704 PostgreSQL (план 2.0, 7.5 №3)
260
311
  const [known] = await sql `select exists (select 1 from pg_roles where rolname = ${owner}) as ok`;
261
312
  if (!known.ok)
262
- throw new LetopisError('invalid_data', `connect: роли владельца ${owner} в базе нет — проверьте опцию owner (по умолчанию letopis_owner)`);
313
+ throw lerr('api.connect_no_owner', { owner });
263
314
  const [role] = await sql `select r.rolsuper, r.rolbypassrls,
264
315
  pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
265
316
  from pg_roles r where r.rolname = current_user`;
266
317
  const bypass = role.rolsuper || role.rolbypassrls || role.inherits || role.can_set;
267
318
  if (bypass && !opts.allowBypassRls) {
268
- throw new LetopisError('bypass_rls', 'роль подключения обходит RLS (суперпользователь, BYPASSRLS или член роли-владельца); администраторское подключение — только с опцией allowBypassRls');
319
+ throw lerr('api.connect_bypass_rls');
269
320
  }
270
321
  if (bypass)
271
- console.warn('letopis: администраторское подключение — RLS не действует, данные всех арендаторов видны');
272
- let token = opts.token ?? null;
273
- const apiKey = opts.apiKey ?? opts.service;
274
- if (!token && apiKey) {
275
- const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
276
- throw fromDbError(e);
277
- });
278
- token = r.t;
279
- }
280
- // ревизия движка: библиотека новее схемы — накат отстал (этап 6, п. 10)
322
+ lwarn('api.admin_connection', undefined, { log: base });
323
+ // ревизия движка — до первого вызова функций схемы (вход по ключу): схема letopis 1.x — отказ (план 2.0, 7.10 №5)
281
324
  const [rv] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${opts.schema}`;
282
- const rev = Number(/ddl_revision=(\d+)/.exec(String(rv?.c ?? ''))?.[1] ?? 0);
283
325
  if (!rv)
284
- throw new LetopisError('invalid_data', `connect: схемы ${opts.schema} нет — установите её: up({ schema })`);
326
+ throw lerr('api.connect_no_schema', { schema: opts.schema });
327
+ const rev = Number(/ddl_revision=(\d+)/.exec(String(rv.c ?? ''))?.[1] ?? 0);
328
+ refuse1x(opts.schema, rev);
329
+ // библиотека новее схемы 2.x — накат отстал (этап 6, п. 10)
285
330
  if (rev < DDL_REVISION)
286
- console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev}, библиотека ждёт ${DDL_REVISION} — обновите: up({ schema: '${opts.schema}', upgrade: true })`);
331
+ lwarn('api.schema_older', { schema: opts.schema, rev, want: DDL_REVISION }, { log: base });
287
332
  // схему обновил более новый пакет (во время раскатки старые экземпляры ещё работают): подключаемся, но предупреждаем —
288
333
  // этот пакет может не знать функций и колонок новой ревизии; up() такую схему не трогает и отказывает
289
334
  else if (rev > DDL_REVISION)
290
- console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev} новее библиотеки (она знает ${DDL_REVISION}) — обновите пакет letopis`);
335
+ lwarn('api.schema_newer', { schema: opts.schema, rev, known: DDL_REVISION }, { log: base });
291
336
  else {
292
337
  // ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
293
338
  const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
294
339
  if (drift)
295
- console.warn(drift);
340
+ warnText(drift, undefined, base);
341
+ }
342
+ let token = opts.token ?? null;
343
+ const apiKey = opts.apiKey ?? opts.service;
344
+ if (!token && apiKey) {
345
+ const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
346
+ throw fromDbError(e);
347
+ });
348
+ token = r.t;
296
349
  }
297
- // режим сигналов установки (план 1.1, §2.2); схема, накатанная до него (нет signal_mode()), — class
350
+ // режим сигналов установки (план 1.1, §2.2)
298
351
  const [sm] = await sql.unsafe(`select ${S}.signal_mode() as m`).catch((e) => {
299
- if (e.code === '42883')
300
- return [{ m: 'class' }];
301
352
  throw fromDbError(e);
302
353
  });
303
354
  const mode = SIGNAL_MODES.includes(sm?.m) ? sm.m : 'class';
304
355
  const sig = new Signals(mode);
305
- 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);
306
361
  const passes = new AclPasses(S);
307
362
  // режим none: сигналов нет — без LISTEN, кэш только по сроку (ttlOnly), подписка опрашивает журнал
308
363
  const listen = opts.listen !== false && mode !== 'none';
@@ -312,7 +367,10 @@ export async function connect(opts) {
312
367
  // продление сессии (§2.9): отдельным вызовом не чаще раза в минуту; ошибка (реплика,
313
368
  // транзакции только для чтения) не мешает работе
314
369
  let refreshedAt = Date.now();
370
+ // время последней удачной операции: после простоя дольше минуты сессию проверяют до операции (ctx.before)
371
+ let lastOk = Date.now();
315
372
  const touch = () => {
373
+ lastOk = Date.now();
316
374
  if (!token || opts.refreshSession === false || Date.now() - refreshedAt < 60_000)
317
375
  return;
318
376
  refreshedAt = Date.now();
@@ -320,8 +378,76 @@ export async function connect(opts) {
320
378
  };
321
379
  // в режиме none нотификатор не отправляет: letopis.changed своей транзакции сбрасывает только свой кэш
322
380
  const ctx = { sql, token, notifier: mode === 'none' ? null : notifier, passes, after: touch,
323
- ...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
324
- 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 };
383
+ // подключение по ключу APIKEY: сессия истекает по простою (срок — 7 суток), и процесс, простоявший дольше, получал
384
+ // бы no_session на каждый вызов. Ключ обменивается на новую сессию заново (один обмен на все ждущие вызовы), с тем
385
+ // же рабочим арендатором, что выбрал auth.switch(); операция повторяется один раз (runTx, ExplicitTx.open)
386
+ if (apiKey && !opts.token) {
387
+ let renewing = null;
388
+ const exchange = async () => {
389
+ const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]);
390
+ const next = r?.t ? String(r.t) : null;
391
+ const want = root.tenant;
392
+ if (next && want) {
393
+ await runTx({ sql, token: next, notifier: null }, async (q) => {
394
+ const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
395
+ if (String(t?.t ?? '').toLowerCase() !== want.toLowerCase())
396
+ await q.unsafe(`select ${S}.auth_switch($1::uuid)`, [want]);
397
+ }, { retries: 0 });
398
+ }
399
+ return next;
400
+ };
401
+ ctx.renew = async (failed) => {
402
+ // ключ уже обменян другим вызовом — достаточно нового токена
403
+ if (token && failed.token !== token) {
404
+ failed.token = token;
405
+ return true;
406
+ }
407
+ renewing ??= exchange().catch(() => null).finally(() => {
408
+ renewing = null;
409
+ });
410
+ const next = await renewing;
411
+ if (!next) {
412
+ if (base.active)
413
+ lwarn('log.renew_failed', undefined, { log: base });
414
+ return false;
415
+ }
416
+ if (base.wants('session.renew'))
417
+ base.emit('session.renew', { tenant: root.tenant ?? undefined, account: root.account ?? undefined });
418
+ token = next;
419
+ ctx.token = next;
420
+ failed.token = next;
421
+ refreshedAt = Date.now();
422
+ return true;
423
+ };
424
+ }
425
+ // после простоя дольше минуты — продление до операции: сессия, истёкшая по простою, даёт no_session (подключение по
426
+ // ключу выдаёт новую), а не анонимное чтение с пустым ответом; ошибка продления другого рода (реплика, транзакция
427
+ // только для чтения) работе не мешает, как у touch
428
+ let checking = null;
429
+ const checkSession = async () => {
430
+ try {
431
+ await runTx({ sql, token, notifier: null }, (q) => q.unsafe(`select ${S}.auth_refresh()`), { retries: 0 });
432
+ }
433
+ catch (e) {
434
+ if (!(e instanceof LetopisError && e.code === 'no_session'))
435
+ return;
436
+ if (!ctx.renew || !(await ctx.renew(ctx)))
437
+ throw e;
438
+ }
439
+ refreshedAt = lastOk = Date.now();
440
+ };
441
+ ctx.before = async (c) => {
442
+ if (!token || opts.refreshSession === false || Date.now() - lastOk < 60_000)
443
+ return;
444
+ checking ??= checkSession().finally(() => {
445
+ checking = null;
446
+ });
447
+ await checking;
448
+ if (token && c.token !== token)
449
+ c.token = token;
450
+ };
325
451
  // классы арендатора tenant под контекстом txc; null — контекст уже не в этом арендаторе (сессия кончилась,
326
452
  // пользователь выбыл из арендатора): тогда прежний реестр остаётся, а не пустеет (доработки после точки Б)
327
453
  const loadClasses = (txc, tenant) => runTx(txc, async (q) => {
@@ -347,12 +473,19 @@ export async function connect(opts) {
347
473
  const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
348
474
  // only — токены арендаторов из сигналов Class (режимы tenant и opaque): реестры db.as() только этих арендаторов
349
475
  const refresh = async (only) => {
476
+ const t0 = performance.now();
350
477
  const rows = await runTx(ctx, async (q) => {
351
- 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`);
352
480
  root.tenant = t?.t ?? null;
481
+ root.account = t?.a ?? null;
353
482
  return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
354
483
  });
355
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
+ }
356
489
  for (const [tenant, o] of others) {
357
490
  if (only && !only.has(sig.tenantToken(tenant) ?? ''))
358
491
  continue;
@@ -411,15 +544,22 @@ export async function connect(opts) {
411
544
  return null;
412
545
  throw e;
413
546
  });
414
- // ключ сменился (reset({ all })): ответы с токенами старого ключа сигналы больше не узнают
547
+ // ключ сменился: ответы с токенами старого ключа сигналы больше не узнают
415
548
  if (sig.setKey(key))
416
549
  cache?.clear();
417
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
+ }
418
558
  await fetchKey();
419
559
  await refresh();
420
- // ключ, реестр и пропуска прав — заново по *, незнакомому токену, нагрузке не по режиму, а у процесса
421
- // без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том числе
422
- // поддельными сигналами) не становится потоком перечитываний
560
+ // ключ, реестр и пропуска прав — заново после переподключения слушателя, по незнакомому токену, нагрузке не по
561
+ // режиму, а у процесса без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том
562
+ // числе поддельными сигналами) не становится потоком перечитываний
423
563
  const resync = throttle(() => {
424
564
  passes.clear();
425
565
  void fetchKey().then(() => refresh()).catch(() => { });
@@ -462,6 +602,8 @@ export async function connect(opts) {
462
602
  // (пере)подключение слушателя — кэш целиком, после обрыва — и ключ с реестром (сигналы потеряны)
463
603
  const onSignal = (payload) => {
464
604
  const s = sig.parse(payload);
605
+ if (base.wants('signal'))
606
+ base.emit('signal', { detail: { ...s } });
465
607
  if (s.kind === 'class' && ACL_CLASSES.has(s.cls))
466
608
  passes.clear();
467
609
  if (s.kind === 'class' && s.cls === 'Class')
@@ -475,6 +617,8 @@ export async function connect(opts) {
475
617
  let listened = false;
476
618
  const listener = listen
477
619
  ? await sql.listen(opts.schema, onSignal, () => {
620
+ if (base.wants('signal'))
621
+ base.emit('signal', { detail: { kind: 'listen', reconnect: listened } });
478
622
  if (listened)
479
623
  resync();
480
624
  listened = true;
@@ -508,9 +652,10 @@ export async function connect(opts) {
508
652
  let stopMaintain = null;
509
653
  const subs = new Set();
510
654
  const shared = {
511
- 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,
512
656
  admin: Boolean(bypass && (role.rolsuper || role.can_set)),
513
657
  ownerRole: owner,
658
+ ...(adminSql ? { adminSql, adminReady } : {}),
514
659
  cache,
515
660
  sig,
516
661
  allTenants: Boolean(bypass),
@@ -526,7 +671,7 @@ export async function connect(opts) {
526
671
  // (план 2.0, 7.4 №8)
527
672
  for (const s of [...subs])
528
673
  s.close();
529
- // идущий maintain() по расписанию доделывает пачку: пул закрывается после неё, а не посреди транзакции
674
+ // идущий maintain() по расписанию доделывается: пул закрывается после него, а не посреди транзакции
530
675
  await stopMaintain?.();
531
676
  resync.stop();
532
677
  startReload.stop();
@@ -536,16 +681,28 @@ export async function connect(opts) {
536
681
  await listener?.unlisten();
537
682
  if (own)
538
683
  await sql.end({ timeout: 5 });
684
+ if (ownAdmin)
685
+ await adminSql.end({ timeout: 5 });
539
686
  },
540
687
  };
541
688
  const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
542
689
  if (maintainEvery !== undefined)
543
- stopMaintain = scheduleMaintain((stopped) => db.maintain({ [MAINTAIN_STOP]: stopped }), 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
+ }
544
699
  return db;
545
700
  }
546
701
  catch (e) {
547
702
  if (own)
548
703
  await sql.end({ timeout: 1 });
704
+ if (ownAdmin)
705
+ await adminSql.end({ timeout: 1 });
549
706
  throw e;
550
707
  }
551
708
  }