letopis 1.4.2 → 2.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 (112) hide show
  1. package/AGENT-CHEATSHEET.md +329 -281
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +572 -1053
  4. package/dist/acl.d.ts +78 -1
  5. package/dist/acl.js +264 -13
  6. package/dist/admin.d.ts +10 -141
  7. package/dist/admin.js +24 -184
  8. package/dist/auth.d.ts +2 -2
  9. package/dist/auth.js +5 -15
  10. package/dist/cache.d.ts +8 -1
  11. package/dist/cache.js +30 -8
  12. package/dist/chain.d.ts +18 -41
  13. package/dist/chain.js +165 -203
  14. package/dist/cli.js +45 -90
  15. package/dist/demo/booking.d.ts +352 -36
  16. package/dist/describe.d.ts +47 -0
  17. package/dist/describe.js +195 -0
  18. package/dist/errors.d.ts +59 -13
  19. package/dist/errors.js +190 -29
  20. package/dist/index.d.ts +52 -70
  21. package/dist/index.js +189 -122
  22. package/dist/internal.d.ts +5 -0
  23. package/dist/jsonschema.js +18 -17
  24. package/dist/load.d.ts +20 -12
  25. package/dist/load.js +207 -181
  26. package/dist/mcp.d.ts +5 -7
  27. package/dist/mcp.js +168 -214
  28. package/dist/messages/access.d.ts +90 -0
  29. package/dist/messages/access.js +113 -0
  30. package/dist/messages/api.d.ts +171 -0
  31. package/dist/messages/api.js +215 -0
  32. package/dist/messages/boot.d.ts +201 -0
  33. package/dist/messages/boot.js +256 -0
  34. package/dist/messages/codes.d.ts +172 -0
  35. package/dist/messages/codes.js +173 -0
  36. package/dist/messages/index.d.ts +95 -0
  37. package/dist/messages/index.js +118 -0
  38. package/dist/messages/mcp.d.ts +306 -0
  39. package/dist/messages/mcp.js +387 -0
  40. package/dist/messages/model.d.ts +233 -0
  41. package/dist/messages/model.js +283 -0
  42. package/dist/messages/query.d.ts +131 -0
  43. package/dist/messages/query.js +158 -0
  44. package/dist/messages/sql-access.d.ts +70 -0
  45. package/dist/messages/sql-access.js +87 -0
  46. package/dist/messages/sql-core.d.ts +150 -0
  47. package/dist/messages/sql-core.js +181 -0
  48. package/dist/messages/sql-time.d.ts +71 -0
  49. package/dist/messages/sql-time.js +88 -0
  50. package/dist/messages/sql-write.d.ts +246 -0
  51. package/dist/messages/sql-write.js +270 -0
  52. package/dist/messages/time.d.ts +23 -0
  53. package/dist/messages/time.js +29 -0
  54. package/dist/messages/types.d.ts +27 -0
  55. package/dist/messages/types.js +5 -0
  56. package/dist/messages/write.d.ts +405 -0
  57. package/dist/messages/write.js +509 -0
  58. package/dist/model.d.ts +73 -60
  59. package/dist/model.js +20 -29
  60. package/dist/ops.d.ts +35 -0
  61. package/dist/ops.js +101 -0
  62. package/dist/pglite.d.ts +0 -9
  63. package/dist/pglite.js +3 -1
  64. package/dist/registry.d.ts +3 -19
  65. package/dist/registry.js +3 -16
  66. package/dist/signals.d.ts +1 -5
  67. package/dist/signals.js +1 -5
  68. package/dist/sql.d.ts +7 -1
  69. package/dist/sql.js +101 -85
  70. package/dist/sync.d.ts +9 -3
  71. package/dist/sync.js +40 -17
  72. package/dist/tx.d.ts +11 -1
  73. package/dist/tx.js +32 -11
  74. package/dist/typed.d.ts +240 -45
  75. package/dist/types.d.ts +15 -90
  76. package/dist/types.js +12 -14
  77. package/dist/up.d.ts +53 -32
  78. package/dist/up.js +180 -74
  79. package/dist/uuid.js +5 -5
  80. package/dist/validate.d.ts +13 -1
  81. package/dist/validate.js +61 -29
  82. package/dist/watch.d.ts +5 -16
  83. package/dist/watch.js +23 -77
  84. package/dist/write.d.ts +9 -16
  85. package/dist/write.js +102 -185
  86. package/llms.txt +22 -25
  87. package/package.json +2 -9
  88. package/sql/10-core.sql +7 -50
  89. package/sql/15-errors.sql +64 -10
  90. package/sql/20-context.sql +7 -6
  91. package/sql/30-validate.sql +35 -27
  92. package/sql/40-class.sql +20 -114
  93. package/sql/50-acl.sql +8 -13
  94. package/sql/60-write.sql +220 -676
  95. package/sql/70-read.sql +11 -13
  96. package/sql/80-auth.sql +23 -27
  97. package/sql/90-time.sql +77 -944
  98. package/sql/95-seed.system.sql +7 -14
  99. package/sql/99-revision.sql +2 -1
  100. package/sql/README.md +32 -24
  101. package/sql/migrate/README.md +14 -0
  102. package/AGENT-CHEATSHEET.en.md +0 -488
  103. package/MIGRATION.md +0 -190
  104. package/README.en.md +0 -2910
  105. package/dist/import.d.ts +0 -185
  106. package/dist/import.js +0 -820
  107. package/dist/project.d.ts +0 -359
  108. package/dist/project.js +0 -1258
  109. package/sql/75-history.sql +0 -436
  110. package/sql/88-derived.sql +0 -1236
  111. package/sql/migrate/0006-changed-paths.sql +0 -3
  112. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/index.js CHANGED
@@ -5,10 +5,10 @@
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 } from './errors.js';
12
12
  import { Registry } from './registry.js';
13
13
  import { AclPasses, ExplicitTx, Notifier, poolExecutor, runTx } from './tx.js';
14
14
  import { checkReason } from './write.js';
@@ -16,53 +16,51 @@ import { limits, registerDb } from './internal.js';
16
16
  import { makeAuth } from './auth.js';
17
17
  import { makeAcl } from './acl.js';
18
18
  import { idOf as idOfClass } from './uuid.js';
19
- import { load } from './load.js';
19
+ import { load, LOAD_OPTIONS } from './load.js';
20
20
  import { makeBatch, startEntity, startStep } from './chain.js';
21
+ import { or as orFilters } from './ops.js';
21
22
  import { DDL_REVISION } from './types.js';
22
23
  import { ACL_CLASSES, ResultCache } from './cache.js';
23
24
  import { Subscription } from './watch.js';
24
- import { Projector } from './project.js';
25
25
  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';
26
+ import { maintain as runMaintain, parseInterval, scheduleMaintain, } from './admin.js';
27
+ import { refuse1x, stampDrift } from './up.js';
28
28
  export { DDL_REVISION } from './types.js';
29
29
  export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
30
30
  export { LetopisError, ValidationError, fromDbError } from './errors.js';
31
31
  export { Registry } from './registry.js';
32
32
  export { compileValidator, applyDefaults } from './validate.js';
33
33
  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';
34
+ export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, isOp } from './ops.js';
35
+ /**
36
+ * Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
37
+ * вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и». В шаге подключения
38
+ * с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт шаг (план 2.0, этап 4, п. 1). Та же
39
+ * фабрика, что в реестре операторов (ops.ts), — другой у неё только тип.
40
+ */
41
+ export const or = orFilters;
35
42
  export { cursorOf } from './sql.js';
36
43
  export { inc, isInc } from './write.js';
37
44
  export { totpCode, hashPassword, verifyHash } from './auth.js';
38
- export { describeText } from './admin.js';
39
45
  export { up, createTenant } from './up.js';
40
46
  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
- });
53
47
  const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
54
48
  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: 'версия схемы — константа движка',
49
+ /** Все опции connect(): неизвестная — invalid_data с похожими именами (checkOptions; план 2.0, 7.10 №4). */
50
+ const CONNECT_OPTIONS = {
51
+ dsn: true, sql: true, schema: true, token: true, apiKey: true, service: true, refreshSession: true, allowBypassRls: true,
52
+ admin: true, owner: true, notifyIntervalMs: true, max: true, onQuery: true, slowMs: true, models: true, listen: true,
53
+ cache: true, maintain: true,
65
54
  };
55
+ /** Опции watch() (WatchOptions). */
56
+ const WATCH_OPTIONS = { from: true, classes: true, batch: true, pollMs: true };
57
+ /**
58
+ * То же подключение без строгих типов: шагом может быть любое имя, в том числе известное только во время выполнения, —
59
+ * `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, слот) не меняются.
60
+ */
61
+ export function loose(db) {
62
+ return db;
63
+ }
66
64
  const quote = (name) => `"${name.replace(/"/g, '""')}"`;
67
65
  /** Фасад db: пул (exec — исполнитель пула) или явная транзакция (tx). */
68
66
  function makeDb(sh, scope, exec, owner, tx) {
@@ -78,14 +76,23 @@ function makeDb(sh, scope, exec, owner, tx) {
78
76
  // (актор, арендатор, владелец, транзакция), значит, в ней только планы, поставленные этим же фасадом
79
77
  const batches = new Map();
80
78
  const noTx = (what) => {
81
- throw new LetopisError('tx_required', `${what} — только внутри db.begin()`);
79
+ throw lerr('api.tx_only', { what });
82
80
  };
83
81
  const auth = makeAuth(sh.S, exec, async () => {
84
82
  sh.passes.clear();
85
83
  await sh.refresh();
86
84
  }, 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 };
85
+ // запись правил (grant, deny, revoke, roles) идёт цепочками самого фасада — он объявлен ниже, берётся при вызове
86
+ const acl = makeAcl(sh.S, exec, () => facade);
87
+ const adm = { S: sh.S, txc, exec };
88
+ // один процесс (план 2.0, 7.9 №3): у корня с connect({ admin }) администраторские вызовы идут вторым пулом с той же
89
+ // сессией, а цепочки и db.sql — пулом приложения под RLS; db.as() и db.begin() администраторский пул не берут
90
+ const viaAdmin = scope.root && !tx && sh.adminSql ? sh.adminSql : null;
91
+ const adminReady = sh.adminReady ?? (async () => { });
92
+ const admCtx = () => {
93
+ const t = { ...txc, sql: viaAdmin };
94
+ return { ...adm, txc: t, exec: poolExecutor(t) };
95
+ };
89
96
  const core = {
90
97
  schema: sh.schema,
91
98
  get token() { return txc.token; },
@@ -98,26 +105,15 @@ function makeDb(sh, scope, exec, owner, tx) {
98
105
  idOf: (cls, key) => {
99
106
  const tenant = scope.tenant();
100
107
  if (!tenant)
101
- throw new LetopisError('no_session', 'idOf: нет арендатора сессии');
108
+ throw lerr('api.id_of_no_tenant');
102
109
  const c = scope.registry().resolve(cls);
103
110
  return idOfClass({ name: c.name, key: c.key, ends: Object.keys(c.ends) }, key, { tenant });
104
111
  },
105
112
  refresh: sh.refresh,
106
113
  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),
114
+ maintain: () => (viaAdmin ? adminReady().then(() => runMaintain(admCtx())) : runMaintain(adm)),
118
115
  watch: (o = {}) => {
119
- if (typeof o === 'function' || typeof o === 'string')
120
- throw removed('watch(cb) и watch(класс, cb)', 'watch({ from }) — подписка по курсору');
116
+ checkOptions('watch', o, WATCH_OPTIONS);
121
117
  const reg = scope.registry();
122
118
  const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
123
119
  return sh.track(new Subscription({
@@ -145,7 +141,7 @@ function makeDb(sh, scope, exec, owner, tx) {
145
141
  entity: (x) => startEntity(chainCtx, x),
146
142
  as: async (account, o) => {
147
143
  if (tx)
148
- throw new LetopisError('invalid_query', 'db.as() — от корня подключения, а не внутри db.begin()');
144
+ throw lerr('api.as_in_tx');
149
145
  const id = account === null ? undefined : typeof account === 'object' ? account.id : account;
150
146
  const nextOwner = o?.owner ?? owner;
151
147
  if (id === undefined && o?.tenant === undefined) {
@@ -155,7 +151,7 @@ function makeDb(sh, scope, exec, owner, tx) {
155
151
  const scoped = { ...txc, account: id ?? txc.account, tenant: o?.tenant ?? (id === undefined ? txc.tenant : undefined) };
156
152
  let [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
157
153
  if (!r?.account)
158
- throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
154
+ throw lerr('api.as_no_session');
159
155
  // контекст сессии между запросами ушёл из арендатора (сменился или кончился): реестр не загружен и не
160
156
  // кэширован (хвост ревью безопасности №3) — контекст читается заново, не больше трёх раз
161
157
  for (let attempt = 1;; attempt++) {
@@ -164,42 +160,31 @@ function makeDb(sh, scope, exec, owner, tx) {
164
160
  if (registry)
165
161
  return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => sh.registryOf(tenant, registry, scoped), root: false }, poolExecutor(scoped), nextOwner);
166
162
  if (attempt === 3)
167
- throw new LetopisError('no_session', 'db.as(): контекст сессии меняется — реестр арендатора не загружен');
163
+ throw lerr('api.as_context_changing');
168
164
  [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
169
165
  if (!r?.account)
170
- throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
166
+ throw lerr('api.as_no_session');
171
167
  }
172
168
  },
173
169
  begin: async (o) => {
174
170
  if (tx)
175
- throw new LetopisError('invalid_query', 'вложенных транзакций нет: db.begin() внутри db.begin()');
171
+ throw lerr('api.nested_begin');
176
172
  // причина блока ставится в начале транзакции (setupSql) и действует на все её операции
177
173
  const btx = o?.reason !== undefined ? { ...txc, reason: checkReason(o.reason, 'db.begin()') } : txc;
178
174
  const t = await ExplicitTx.open(btx);
179
175
  return makeDb(sh, btx === txc ? scope : { ...scope, txc: btx }, t, owner, t);
180
176
  },
181
177
  load: async (input, o) => {
178
+ // опции — до всего прочего: неизвестная — отказ раньше admin_required пула администратора (план 2.0, 7.10 №4)
179
+ checkOptions('load', o, LOAD_OPTIONS);
182
180
  if (tx)
183
- throw new LetopisError('invalid_query', 'load() — своей транзакцией, не внутри db.begin()');
181
+ throw lerr('api.load_in_tx');
182
+ if (viaAdmin) {
183
+ await adminReady();
184
+ return load({ sql: viaAdmin, schema: sh.schema, tenant: scope.tenant(), admin: true, ownerRole: sh.ownerRole }, input, o);
185
+ }
184
186
  return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
185
187
  },
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
- },
203
188
  batch: (name, o) => {
204
189
  let q = batches.get(name);
205
190
  if (!q)
@@ -211,93 +196,104 @@ function makeDb(sh, scope, exec, owner, tx) {
211
196
  rollback: async (tr) => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()')),
212
197
  close: () => (tx ? tx.rollback() : sh.close()),
213
198
  };
199
+ /** Свойство фасада: служебное — из core, иначе шаг класса, алиаса или роли. */
200
+ const member = (t, prop, r) => {
201
+ if (typeof prop === 'symbol' || prop in t)
202
+ return Reflect.get(t, prop, r);
203
+ if (prop === 'then')
204
+ return undefined;
205
+ return startStep(chainCtx, prop);
206
+ };
214
207
  const facade = new Proxy(core, {
215
208
  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);
209
+ return member(t, prop, r);
232
210
  },
233
211
  });
234
212
  // модули пакета (letopis/mcp) узнают обход RLS по самому подключению, а не со слов вызывающего (internal.ts)
235
- registerDb(facade, { allTenants: sh.allTenants, explicit: Boolean(tx) });
213
+ registerDb(facade, {
214
+ allTenants: sh.allTenants, explicit: Boolean(tx),
215
+ ...(viaAdmin ? { adminTx: (fn) => adminReady().then(() => runTx({ ...txc, sql: viaAdmin }, fn)) } : {}),
216
+ });
236
217
  return facade;
237
218
  }
238
219
  export async function connect(opts) {
220
+ // опции — первыми: неизвестная — invalid_data с похожими именами до прочих проверок (план 2.0, 7.10 №4)
221
+ checkOptions('connect', opts, CONNECT_OPTIONS);
239
222
  if (!SCHEMA_NAME.test(opts.schema))
240
- throw new LetopisError('invalid_data', `connect: схема ${opts.schema} — нужно v<N>.<имя>`);
223
+ throw lerr('api.connect_schema', { schema: opts.schema });
241
224
  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]);
225
+ throw lerr('api.connect_no_dsn');
246
226
  // интервал обслуживания — до пула и таймеров: неверный не оставляет за собой открытых соединений
247
227
  const maintainEvery = opts.maintain === undefined ? undefined : parseInterval(opts.maintain);
228
+ if (opts.admin !== undefined && typeof opts.admin !== 'string' && typeof opts.admin !== 'function') {
229
+ throw lerr('api.connect_admin_type');
230
+ }
248
231
  const own = !opts.sql;
249
232
  // 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
- }));
233
+ 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 }));
235
+ // один процесс (план 2.0, 7.9 №3): пул администратора postgres.js соединяется при первом запросе — до первого
236
+ // администраторского вызова соединений у него нет
237
+ const ownAdmin = typeof opts.admin === 'string';
238
+ const adminSql = (typeof opts.admin === 'string'
239
+ ? postgres(opts.admin, { max: 2, onnotice: () => { }, types, connection: { application_name: 'letopis admin' } })
240
+ : opts.admin);
255
241
  const S = quote(opts.schema);
256
242
  const owner = opts.owner ?? 'letopis_owner';
243
+ let adminChecked;
244
+ const adminReady = adminSql ? () => (adminChecked ??= (async () => {
245
+ 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`;
246
+ if (!a?.rolsuper && !a?.can_set) {
247
+ throw lerr('api.connect_admin_role', { owner });
248
+ }
249
+ })().catch((e) => {
250
+ adminChecked = undefined;
251
+ throw e;
252
+ })) : undefined;
257
253
  try {
258
254
  // роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
259
255
  // роли владельца нет (опечатка в owner) — понятная ошибка, а не сырая 42704 PostgreSQL (план 2.0, 7.5 №3)
260
256
  const [known] = await sql `select exists (select 1 from pg_roles where rolname = ${owner}) as ok`;
261
257
  if (!known.ok)
262
- throw new LetopisError('invalid_data', `connect: роли владельца ${owner} в базе нет — проверьте опцию owner (по умолчанию letopis_owner)`);
258
+ throw lerr('api.connect_no_owner', { owner });
263
259
  const [role] = await sql `select r.rolsuper, r.rolbypassrls,
264
260
  pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
265
261
  from pg_roles r where r.rolname = current_user`;
266
262
  const bypass = role.rolsuper || role.rolbypassrls || role.inherits || role.can_set;
267
263
  if (bypass && !opts.allowBypassRls) {
268
- throw new LetopisError('bypass_rls', 'роль подключения обходит RLS (суперпользователь, BYPASSRLS или член роли-владельца); администраторское подключение — только с опцией allowBypassRls');
264
+ throw lerr('api.connect_bypass_rls');
269
265
  }
270
266
  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)
267
+ lwarn('api.admin_connection');
268
+ // ревизия движка — до первого вызова функций схемы (вход по ключу): схема letopis 1.x — отказ (план 2.0, 7.10 №5)
281
269
  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
270
  if (!rv)
284
- throw new LetopisError('invalid_data', `connect: схемы ${opts.schema} нет — установите её: up({ schema })`);
271
+ throw lerr('api.connect_no_schema', { schema: opts.schema });
272
+ const rev = Number(/ddl_revision=(\d+)/.exec(String(rv.c ?? ''))?.[1] ?? 0);
273
+ refuse1x(opts.schema, rev);
274
+ // библиотека новее схемы 2.x — накат отстал (этап 6, п. 10)
285
275
  if (rev < DDL_REVISION)
286
- console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev}, библиотека ждёт ${DDL_REVISION} — обновите: up({ schema: '${opts.schema}', upgrade: true })`);
276
+ lwarn('api.schema_older', { schema: opts.schema, rev, want: DDL_REVISION });
287
277
  // схему обновил более новый пакет (во время раскатки старые экземпляры ещё работают): подключаемся, но предупреждаем —
288
278
  // этот пакет может не знать функций и колонок новой ревизии; up() такую схему не трогает и отказывает
289
279
  else if (rev > DDL_REVISION)
290
- console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev} новее библиотеки (она знает ${DDL_REVISION}) — обновите пакет letopis`);
280
+ lwarn('api.schema_newer', { schema: opts.schema, rev, known: DDL_REVISION });
291
281
  else {
292
282
  // ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
293
283
  const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
294
284
  if (drift)
295
285
  console.warn(drift);
296
286
  }
297
- // режим сигналов установки (план 1.1, §2.2); схема, накатанная до него (нет signal_mode()), — class
287
+ let token = opts.token ?? null;
288
+ const apiKey = opts.apiKey ?? opts.service;
289
+ if (!token && apiKey) {
290
+ const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
291
+ throw fromDbError(e);
292
+ });
293
+ token = r.t;
294
+ }
295
+ // режим сигналов установки (план 1.1, §2.2)
298
296
  const [sm] = await sql.unsafe(`select ${S}.signal_mode() as m`).catch((e) => {
299
- if (e.code === '42883')
300
- return [{ m: 'class' }];
301
297
  throw fromDbError(e);
302
298
  });
303
299
  const mode = SIGNAL_MODES.includes(sm?.m) ? sm.m : 'class';
@@ -312,7 +308,10 @@ export async function connect(opts) {
312
308
  // продление сессии (§2.9): отдельным вызовом не чаще раза в минуту; ошибка (реплика,
313
309
  // транзакции только для чтения) не мешает работе
314
310
  let refreshedAt = Date.now();
311
+ // время последней удачной операции: после простоя дольше минуты сессию проверяют до операции (ctx.before)
312
+ let lastOk = Date.now();
315
313
  const touch = () => {
314
+ lastOk = Date.now();
316
315
  if (!token || opts.refreshSession === false || Date.now() - refreshedAt < 60_000)
317
316
  return;
318
317
  refreshedAt = Date.now();
@@ -322,6 +321,69 @@ export async function connect(opts) {
322
321
  const ctx = { sql, token, notifier: mode === 'none' ? null : notifier, passes, after: touch,
323
322
  ...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
324
323
  const root = { tenant: null, registry: new Registry() };
324
+ // подключение по ключу APIKEY: сессия истекает по простою (срок — 7 суток), и процесс, простоявший дольше, получал
325
+ // бы no_session на каждый вызов. Ключ обменивается на новую сессию заново (один обмен на все ждущие вызовы), с тем
326
+ // же рабочим арендатором, что выбрал auth.switch(); операция повторяется один раз (runTx, ExplicitTx.open)
327
+ if (apiKey && !opts.token) {
328
+ let renewing = null;
329
+ const exchange = async () => {
330
+ const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]);
331
+ const next = r?.t ? String(r.t) : null;
332
+ const want = root.tenant;
333
+ if (next && want) {
334
+ await runTx({ sql, token: next, notifier: null }, async (q) => {
335
+ const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
336
+ if (String(t?.t ?? '').toLowerCase() !== want.toLowerCase())
337
+ await q.unsafe(`select ${S}.auth_switch($1::uuid)`, [want]);
338
+ }, { retries: 0 });
339
+ }
340
+ return next;
341
+ };
342
+ ctx.renew = async (failed) => {
343
+ // ключ уже обменян другим вызовом — достаточно нового токена
344
+ if (token && failed.token !== token) {
345
+ failed.token = token;
346
+ return true;
347
+ }
348
+ renewing ??= exchange().catch(() => null).finally(() => {
349
+ renewing = null;
350
+ });
351
+ const next = await renewing;
352
+ if (!next)
353
+ return false;
354
+ token = next;
355
+ ctx.token = next;
356
+ failed.token = next;
357
+ refreshedAt = Date.now();
358
+ return true;
359
+ };
360
+ }
361
+ // после простоя дольше минуты — продление до операции: сессия, истёкшая по простою, даёт no_session (подключение по
362
+ // ключу выдаёт новую), а не анонимное чтение с пустым ответом; ошибка продления другого рода (реплика, транзакция
363
+ // только для чтения) работе не мешает, как у touch
364
+ let checking = null;
365
+ const checkSession = async () => {
366
+ try {
367
+ await runTx({ sql, token, notifier: null }, (q) => q.unsafe(`select ${S}.auth_refresh()`), { retries: 0 });
368
+ }
369
+ catch (e) {
370
+ if (!(e instanceof LetopisError && e.code === 'no_session'))
371
+ return;
372
+ if (!ctx.renew || !(await ctx.renew(ctx)))
373
+ throw e;
374
+ }
375
+ refreshedAt = lastOk = Date.now();
376
+ };
377
+ ctx.before = async (c) => {
378
+ if (!token || opts.refreshSession === false || Date.now() - lastOk < 60_000)
379
+ return;
380
+ checking ??= checkSession().finally(() => {
381
+ checking = null;
382
+ });
383
+ await checking;
384
+ if (token && c.token !== token)
385
+ c.token = token;
386
+ };
325
387
  // классы арендатора tenant под контекстом txc; null — контекст уже не в этом арендаторе (сессия кончилась,
326
388
  // пользователь выбыл из арендатора): тогда прежний реестр остаётся, а не пустеет (доработки после точки Б)
327
389
  const loadClasses = (txc, tenant) => runTx(txc, async (q) => {
@@ -411,15 +473,15 @@ export async function connect(opts) {
411
473
  return null;
412
474
  throw e;
413
475
  });
414
- // ключ сменился (reset({ all })): ответы с токенами старого ключа сигналы больше не узнают
476
+ // ключ сменился: ответы с токенами старого ключа сигналы больше не узнают
415
477
  if (sig.setKey(key))
416
478
  cache?.clear();
417
479
  };
418
480
  await fetchKey();
419
481
  await refresh();
420
- // ключ, реестр и пропуска прав — заново по *, незнакомому токену, нагрузке не по режиму, а у процесса
421
- // без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том числе
422
- // поддельными сигналами) не становится потоком перечитываний
482
+ // ключ, реестр и пропуска прав — заново после переподключения слушателя, по незнакомому токену, нагрузке не по
483
+ // режиму, а у процесса без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том
484
+ // числе поддельными сигналами) не становится потоком перечитываний
423
485
  const resync = throttle(() => {
424
486
  passes.clear();
425
487
  void fetchKey().then(() => refresh()).catch(() => { });
@@ -511,6 +573,7 @@ export async function connect(opts) {
511
573
  sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
512
574
  admin: Boolean(bypass && (role.rolsuper || role.can_set)),
513
575
  ownerRole: owner,
576
+ ...(adminSql ? { adminSql, adminReady } : {}),
514
577
  cache,
515
578
  sig,
516
579
  allTenants: Boolean(bypass),
@@ -526,7 +589,7 @@ export async function connect(opts) {
526
589
  // (план 2.0, 7.4 №8)
527
590
  for (const s of [...subs])
528
591
  s.close();
529
- // идущий maintain() по расписанию доделывает пачку: пул закрывается после неё, а не посреди транзакции
592
+ // идущий maintain() по расписанию доделывается: пул закрывается после него, а не посреди транзакции
530
593
  await stopMaintain?.();
531
594
  resync.stop();
532
595
  startReload.stop();
@@ -536,16 +599,20 @@ export async function connect(opts) {
536
599
  await listener?.unlisten();
537
600
  if (own)
538
601
  await sql.end({ timeout: 5 });
602
+ if (ownAdmin)
603
+ await adminSql.end({ timeout: 5 });
539
604
  },
540
605
  };
541
606
  const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
542
607
  if (maintainEvery !== undefined)
543
- stopMaintain = scheduleMaintain((stopped) => db.maintain({ [MAINTAIN_STOP]: stopped }), maintainEvery);
608
+ stopMaintain = scheduleMaintain(() => db.maintain(), maintainEvery);
544
609
  return db;
545
610
  }
546
611
  catch (e) {
547
612
  if (own)
548
613
  await sql.end({ timeout: 1 });
614
+ if (ownAdmin)
615
+ await adminSql.end({ timeout: 1 });
549
616
  throw e;
550
617
  }
551
618
  }
@@ -9,6 +9,11 @@ export interface DbInternals {
9
9
  readonly allTenants: boolean;
10
10
  /** Фасад явной транзакции db.begin(). */
11
11
  readonly explicit: boolean;
12
+ /**
13
+ * Транзакция на администраторском пуле connect({ admin }) с сессией фасада (план 2.0, 7.9 №3): ею пишет syncModels.
14
+ * Только у корня подключения с опцией admin.
15
+ */
16
+ readonly adminTx?: <T>(fn: (tx: import('./tx.js').Tx) => Promise<T>) => Promise<T>;
12
17
  }
13
18
  /**
14
19
  * Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,