letopis 1.4.1 → 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 (113) hide show
  1. package/AGENT-CHEATSHEET.md +329 -278
  2. package/CHANGELOG.md +558 -1
  3. package/README.md +606 -1037
  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 -175
  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 +33 -10
  12. package/dist/chain.d.ts +19 -41
  13. package/dist/chain.js +169 -201
  14. package/dist/cli.js +51 -88
  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 -71
  21. package/dist/index.js +245 -130
  22. package/dist/internal.d.ts +12 -0
  23. package/dist/internal.js +5 -0
  24. package/dist/jsonschema.js +18 -17
  25. package/dist/load.d.ts +20 -12
  26. package/dist/load.js +225 -186
  27. package/dist/mcp.d.ts +5 -7
  28. package/dist/mcp.js +171 -215
  29. package/dist/messages/access.d.ts +90 -0
  30. package/dist/messages/access.js +113 -0
  31. package/dist/messages/api.d.ts +171 -0
  32. package/dist/messages/api.js +215 -0
  33. package/dist/messages/boot.d.ts +201 -0
  34. package/dist/messages/boot.js +256 -0
  35. package/dist/messages/codes.d.ts +172 -0
  36. package/dist/messages/codes.js +173 -0
  37. package/dist/messages/index.d.ts +95 -0
  38. package/dist/messages/index.js +118 -0
  39. package/dist/messages/mcp.d.ts +306 -0
  40. package/dist/messages/mcp.js +387 -0
  41. package/dist/messages/model.d.ts +233 -0
  42. package/dist/messages/model.js +283 -0
  43. package/dist/messages/query.d.ts +131 -0
  44. package/dist/messages/query.js +158 -0
  45. package/dist/messages/sql-access.d.ts +70 -0
  46. package/dist/messages/sql-access.js +87 -0
  47. package/dist/messages/sql-core.d.ts +150 -0
  48. package/dist/messages/sql-core.js +181 -0
  49. package/dist/messages/sql-time.d.ts +71 -0
  50. package/dist/messages/sql-time.js +88 -0
  51. package/dist/messages/sql-write.d.ts +246 -0
  52. package/dist/messages/sql-write.js +270 -0
  53. package/dist/messages/time.d.ts +23 -0
  54. package/dist/messages/time.js +29 -0
  55. package/dist/messages/types.d.ts +27 -0
  56. package/dist/messages/types.js +5 -0
  57. package/dist/messages/write.d.ts +405 -0
  58. package/dist/messages/write.js +509 -0
  59. package/dist/model.d.ts +73 -60
  60. package/dist/model.js +26 -29
  61. package/dist/ops.d.ts +35 -0
  62. package/dist/ops.js +101 -0
  63. package/dist/pglite.d.ts +0 -9
  64. package/dist/pglite.js +3 -1
  65. package/dist/registry.d.ts +3 -19
  66. package/dist/registry.js +3 -16
  67. package/dist/signals.d.ts +5 -9
  68. package/dist/signals.js +5 -9
  69. package/dist/sql.d.ts +13 -6
  70. package/dist/sql.js +176 -83
  71. package/dist/sync.d.ts +9 -3
  72. package/dist/sync.js +51 -21
  73. package/dist/tx.d.ts +26 -1
  74. package/dist/tx.js +44 -14
  75. package/dist/typed.d.ts +240 -45
  76. package/dist/types.d.ts +23 -91
  77. package/dist/types.js +12 -14
  78. package/dist/up.d.ts +53 -32
  79. package/dist/up.js +184 -73
  80. package/dist/uuid.js +5 -5
  81. package/dist/validate.d.ts +13 -1
  82. package/dist/validate.js +61 -29
  83. package/dist/watch.d.ts +11 -10
  84. package/dist/watch.js +33 -70
  85. package/dist/write.d.ts +9 -16
  86. package/dist/write.js +118 -187
  87. package/llms.txt +22 -25
  88. package/package.json +8 -11
  89. package/sql/10-core.sql +7 -50
  90. package/sql/15-errors.sql +64 -10
  91. package/sql/20-context.sql +7 -6
  92. package/sql/30-validate.sql +35 -27
  93. package/sql/40-class.sql +20 -114
  94. package/sql/50-acl.sql +8 -13
  95. package/sql/60-write.sql +220 -667
  96. package/sql/70-read.sql +20 -16
  97. package/sql/80-auth.sql +24 -27
  98. package/sql/90-time.sql +70 -859
  99. package/sql/95-seed.system.sql +7 -14
  100. package/sql/99-revision.sql +2 -1
  101. package/sql/README.md +32 -24
  102. package/sql/migrate/README.md +14 -0
  103. package/AGENT-CHEATSHEET.en.md +0 -485
  104. package/MIGRATION.md +0 -190
  105. package/README.en.md +0 -2859
  106. package/dist/import.d.ts +0 -185
  107. package/dist/import.js +0 -820
  108. package/dist/project.d.ts +0 -350
  109. package/dist/project.js +0 -1224
  110. package/sql/75-history.sql +0 -358
  111. package/sql/88-derived.sql +0 -1235
  112. package/sql/migrate/0006-changed-paths.sql +0 -3
  113. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/index.js CHANGED
@@ -5,64 +5,62 @@
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';
15
- import { registerDb } from './internal.js';
15
+ 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,35 +105,34 @@ 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
- return new Subscription({
124
- S: sh.S, exec: poolExecutor(txc), classes,
119
+ return sh.track(new Subscription({
120
+ S: sh.S, exec: poolExecutor(txc), classes, onClose: (s) => void sh.subs.delete(s),
121
+ // не бросает: класс удалили — список, снятый при watch(); иначе ошибка в общем разборе сигналов остановила бы
122
+ // доставку другим подписчикам
123
+ ...(o.classes ? { classesOf: () => {
124
+ try {
125
+ return [...new Set(o.classes.flatMap((c) => scope.registry().family(c).map((x) => x.name)))];
126
+ }
127
+ catch {
128
+ return classes;
129
+ }
130
+ } } : {}),
125
131
  ...(sh.listen ? {
126
132
  onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); },
127
133
  tenant: () => (sh.allTenants ? null : sh.sig.tenantToken(scope.tenant())),
128
134
  } : {}),
129
- }, o);
135
+ }, o));
130
136
  },
131
137
  cache: {
132
138
  clear: () => sh.cache?.clear(),
@@ -135,7 +141,7 @@ function makeDb(sh, scope, exec, owner, tx) {
135
141
  entity: (x) => startEntity(chainCtx, x),
136
142
  as: async (account, o) => {
137
143
  if (tx)
138
- throw new LetopisError('invalid_query', 'db.as() — от корня подключения, а не внутри db.begin()');
144
+ throw lerr('api.as_in_tx');
139
145
  const id = account === null ? undefined : typeof account === 'object' ? account.id : account;
140
146
  const nextOwner = o?.owner ?? owner;
141
147
  if (id === undefined && o?.tenant === undefined) {
@@ -145,51 +151,40 @@ function makeDb(sh, scope, exec, owner, tx) {
145
151
  const scoped = { ...txc, account: id ?? txc.account, tenant: o?.tenant ?? (id === undefined ? txc.tenant : undefined) };
146
152
  let [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
147
153
  if (!r?.account)
148
- throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
154
+ throw lerr('api.as_no_session');
149
155
  // контекст сессии между запросами ушёл из арендатора (сменился или кончился): реестр не загружен и не
150
156
  // кэширован (хвост ревью безопасности №3) — контекст читается заново, не больше трёх раз
151
157
  for (let attempt = 1;; attempt++) {
152
158
  const tenant = r.tenant;
153
159
  const registry = await sh.registryFor(tenant, scoped);
154
160
  if (registry)
155
- return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
161
+ return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => sh.registryOf(tenant, registry, scoped), root: false }, poolExecutor(scoped), nextOwner);
156
162
  if (attempt === 3)
157
- throw new LetopisError('no_session', 'db.as(): контекст сессии меняется — реестр арендатора не загружен');
163
+ throw lerr('api.as_context_changing');
158
164
  [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
159
165
  if (!r?.account)
160
- throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
166
+ throw lerr('api.as_no_session');
161
167
  }
162
168
  },
163
169
  begin: async (o) => {
164
170
  if (tx)
165
- throw new LetopisError('invalid_query', 'вложенных транзакций нет: db.begin() внутри db.begin()');
171
+ throw lerr('api.nested_begin');
166
172
  // причина блока ставится в начале транзакции (setupSql) и действует на все её операции
167
173
  const btx = o?.reason !== undefined ? { ...txc, reason: checkReason(o.reason, 'db.begin()') } : txc;
168
174
  const t = await ExplicitTx.open(btx);
169
175
  return makeDb(sh, btx === txc ? scope : { ...scope, txc: btx }, t, owner, t);
170
176
  },
171
177
  load: async (input, o) => {
178
+ // опции — до всего прочего: неизвестная — отказ раньше admin_required пула администратора (план 2.0, 7.10 №4)
179
+ checkOptions('load', o, LOAD_OPTIONS);
172
180
  if (tx)
173
- 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
+ }
174
186
  return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
175
187
  },
176
- projector: (name, o) => {
177
- if (tx)
178
- throw new LetopisError('invalid_query', 'projector() — от корня подключения, а не внутри db.begin()');
179
- if (!sh.admin)
180
- throw new LetopisError('admin_required', 'проектору нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
181
- return new Projector({
182
- sql: sh.sql, schema: sh.schema, S: sh.S, ownerRole: sh.ownerRole,
183
- ...(sh.listen ? { onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); } } : {}),
184
- }, name, o);
185
- },
186
- rebuildAggregates: async (cls) => {
187
- if (tx)
188
- throw new LetopisError('invalid_query', 'rebuildAggregates() — своей транзакцией, не внутри db.begin()');
189
- if (!sh.admin)
190
- throw new LetopisError('admin_required', 'ремонту агрегатов нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
191
- return runRebuildAggregates({ sql: sh.sql, S: sh.S, ownerRole: sh.ownerRole }, cls);
192
- },
193
188
  batch: (name, o) => {
194
189
  let q = batches.get(name);
195
190
  if (!q)
@@ -201,89 +196,104 @@ function makeDb(sh, scope, exec, owner, tx) {
201
196
  rollback: async (tr) => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()')),
202
197
  close: () => (tx ? tx.rollback() : sh.close()),
203
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
+ };
204
207
  const facade = new Proxy(core, {
205
208
  get(t, prop, r) {
206
- if (typeof prop === 'symbol' || prop in t)
207
- return Reflect.get(t, prop, r);
208
- switch (prop) {
209
- case 'then':
210
- return undefined;
211
- case 'reloadSchema':
212
- throw removed('reloadSchema()', 'реестр обновляется сам по сигналу; принудительно — db.refresh()');
213
- case 'accounts':
214
- case 'credentials':
215
- case 'resources':
216
- case 'rules':
217
- throw removed(`фасад db.${prop}`, 'системные классы Account, Resource, rule, member и db.auth (accounts.purge — db.auth.purgeAccount)');
218
- case 'compact':
219
- throw removed('compact()', 'db.trimHistory(цель, момент), политика хранения history и history: false');
220
- }
221
- return startStep(chainCtx, prop);
209
+ return member(t, prop, r);
222
210
  },
223
211
  });
224
212
  // модули пакета (letopis/mcp) узнают обход RLS по самому подключению, а не со слов вызывающего (internal.ts)
225
- 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
+ });
226
217
  return facade;
227
218
  }
228
219
  export async function connect(opts) {
220
+ // опции — первыми: неизвестная — invalid_data с похожими именами до прочих проверок (план 2.0, 7.10 №4)
221
+ checkOptions('connect', opts, CONNECT_OPTIONS);
229
222
  if (!SCHEMA_NAME.test(opts.schema))
230
- throw new LetopisError('invalid_data', `connect: схема ${opts.schema} — нужно v<N>.<имя>`);
223
+ throw lerr('api.connect_schema', { schema: opts.schema });
231
224
  if (!opts.sql && !opts.dsn)
232
- throw new LetopisError('invalid_data', 'connect: нужен dsn или sql');
233
- for (const k of Object.keys(opts))
234
- if (k in REMOVED_OPTIONS)
235
- throw removed(`опция connect({ ${k} })`, REMOVED_OPTIONS[k]);
225
+ throw lerr('api.connect_no_dsn');
236
226
  // интервал обслуживания — до пула и таймеров: неверный не оставляет за собой открытых соединений
237
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
+ }
238
231
  const own = !opts.sql;
239
232
  // timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
240
- const sql = (opts.sql ?? postgres(opts.dsn, {
241
- max: opts.max ?? 10,
242
- onnotice: () => { },
243
- types: { timestamptz: { to: 1184, from: [1184], serialize: (v) => (v instanceof Date ? v.toISOString() : v), parse: (v) => v } },
244
- }));
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);
245
241
  const S = quote(opts.schema);
246
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;
247
253
  try {
248
254
  // роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
255
+ // роли владельца нет (опечатка в owner) — понятная ошибка, а не сырая 42704 PostgreSQL (план 2.0, 7.5 №3)
256
+ const [known] = await sql `select exists (select 1 from pg_roles where rolname = ${owner}) as ok`;
257
+ if (!known.ok)
258
+ throw lerr('api.connect_no_owner', { owner });
249
259
  const [role] = await sql `select r.rolsuper, r.rolbypassrls,
250
260
  pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
251
261
  from pg_roles r where r.rolname = current_user`;
252
262
  const bypass = role.rolsuper || role.rolbypassrls || role.inherits || role.can_set;
253
263
  if (bypass && !opts.allowBypassRls) {
254
- throw new LetopisError('bypass_rls', 'роль подключения обходит RLS (суперпользователь, BYPASSRLS или член роли-владельца); администраторское подключение — только с опцией allowBypassRls');
264
+ throw lerr('api.connect_bypass_rls');
255
265
  }
256
266
  if (bypass)
257
- console.warn('letopis: администраторское подключение — RLS не действует, данные всех арендаторов видны');
258
- let token = opts.token ?? null;
259
- const apiKey = opts.apiKey ?? opts.service;
260
- if (!token && apiKey) {
261
- const [r] = await sql.unsafe(`select ${S}.login_apikey($1) as t`, [apiKey]).catch((e) => {
262
- throw fromDbError(e);
263
- });
264
- token = r.t;
265
- }
266
- // ревизия движка: библиотека новее схемы — накат отстал (этап 6, п. 10)
267
+ lwarn('api.admin_connection');
268
+ // ревизия движка — до первого вызова функций схемы (вход по ключу): схема letopis 1.x — отказ (план 2.0, 7.10 №5)
267
269
  const [rv] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${opts.schema}`;
268
- const rev = Number(/ddl_revision=(\d+)/.exec(String(rv?.c ?? ''))?.[1] ?? 0);
269
270
  if (!rv)
270
- 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)
271
275
  if (rev < DDL_REVISION)
272
- 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 });
273
277
  // схему обновил более новый пакет (во время раскатки старые экземпляры ещё работают): подключаемся, но предупреждаем —
274
278
  // этот пакет может не знать функций и колонок новой ревизии; up() такую схему не трогает и отказывает
275
279
  else if (rev > DDL_REVISION)
276
- console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev} новее библиотеки (она знает ${DDL_REVISION}) — обновите пакет letopis`);
280
+ lwarn('api.schema_newer', { schema: opts.schema, rev, known: DDL_REVISION });
277
281
  else {
278
282
  // ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
279
283
  const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
280
284
  if (drift)
281
285
  console.warn(drift);
282
286
  }
283
- // режим сигналов установки (план 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)
284
296
  const [sm] = await sql.unsafe(`select ${S}.signal_mode() as m`).catch((e) => {
285
- if (e.code === '42883')
286
- return [{ m: 'class' }];
287
297
  throw fromDbError(e);
288
298
  });
289
299
  const mode = SIGNAL_MODES.includes(sm?.m) ? sm.m : 'class';
@@ -298,7 +308,10 @@ export async function connect(opts) {
298
308
  // продление сессии (§2.9): отдельным вызовом не чаще раза в минуту; ошибка (реплика,
299
309
  // транзакции только для чтения) не мешает работе
300
310
  let refreshedAt = Date.now();
311
+ // время последней удачной операции: после простоя дольше минуты сессию проверяют до операции (ctx.before)
312
+ let lastOk = Date.now();
301
313
  const touch = () => {
314
+ lastOk = Date.now();
302
315
  if (!token || opts.refreshSession === false || Date.now() - refreshedAt < 60_000)
303
316
  return;
304
317
  refreshedAt = Date.now();
@@ -308,6 +321,69 @@ export async function connect(opts) {
308
321
  const ctx = { sql, token, notifier: mode === 'none' ? null : notifier, passes, after: touch,
309
322
  ...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
310
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
+ };
311
387
  // классы арендатора tenant под контекстом txc; null — контекст уже не в этом арендаторе (сессия кончилась,
312
388
  // пользователь выбыл из арендатора): тогда прежний реестр остаётся, а не пустеет (доработки после точки Б)
313
389
  const loadClasses = (txc, tenant) => runTx(txc, async (q) => {
@@ -316,8 +392,18 @@ export async function connect(opts) {
316
392
  return null;
317
393
  return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
318
394
  }, { read: true });
319
- // реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого арендатора
395
+ // реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого арендатора. Порядок карты —
396
+ // давность обращения: сверх limits.asRegistries вытесняется самый давний (план 2.0, 7.4 №8 — иначе процесс,
397
+ // который ходит от имени многих арендаторов, копил бы реестры и перечитывал их все на каждый сигнал классов)
320
398
  const others = new Map();
399
+ let latest = null;
400
+ const keep = (tenant, entry) => {
401
+ latest = tenant;
402
+ others.delete(tenant);
403
+ others.set(tenant, entry);
404
+ while (others.size > Math.max(1, limits.asRegistries))
405
+ others.delete(others.keys().next().value);
406
+ };
321
407
  const loading = new Map();
322
408
  // токены классов (режим opaque) — по именам всех реестров подключения
323
409
  const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
@@ -343,7 +429,7 @@ export async function connect(opts) {
343
429
  const registryFor = async (tenant, txc) => {
344
430
  const known = others.get(tenant);
345
431
  if (known) {
346
- known.txc = txc;
432
+ keep(tenant, { registry: known.registry, txc });
347
433
  return known.registry;
348
434
  }
349
435
  // одновременные as() нового арендатора получают один реестр
@@ -355,7 +441,7 @@ export async function connect(opts) {
355
441
  return null;
356
442
  const registry = new Registry();
357
443
  registry.load(rows);
358
- others.set(tenant, { registry, txc });
444
+ keep(tenant, { registry, txc });
359
445
  tokens();
360
446
  return registry;
361
447
  })().finally(() => loading.delete(tenant));
@@ -363,6 +449,20 @@ export async function connect(opts) {
363
449
  }
364
450
  return pending;
365
451
  };
452
+ // фасад db.as() берёт реестр через карту: свежий реестр арендатора, если его перечитал другой as(); вытесненный —
453
+ // обратно в карту (его снова обновляют сигналы) и перечитать фоном: пока он был вне карты, сигналы его не трогали
454
+ const registryOf = (tenant, registry, txc) => {
455
+ const cur = others.get(tenant);
456
+ if (cur) {
457
+ if (latest !== tenant)
458
+ keep(tenant, cur);
459
+ return cur.registry;
460
+ }
461
+ keep(tenant, { registry, txc });
462
+ void loadClasses(txc, tenant).then((rows) => { if (rows)
463
+ registry.load(rows); }).catch(() => { }).finally(tokens);
464
+ return registry;
465
+ };
366
466
  // ключ сигналов (режимы tenant и opaque) — сессии с правом letopis.signals; без права процесс
367
467
  // работает без ключа: арендатора (а в opaque — и класса) в сигнале не различает
368
468
  const fetchKey = async () => {
@@ -373,15 +473,15 @@ export async function connect(opts) {
373
473
  return null;
374
474
  throw e;
375
475
  });
376
- // ключ сменился (reset({ all })): ответы с токенами старого ключа сигналы больше не узнают
476
+ // ключ сменился: ответы с токенами старого ключа сигналы больше не узнают
377
477
  if (sig.setKey(key))
378
478
  cache?.clear();
379
479
  };
380
480
  await fetchKey();
381
481
  await refresh();
382
- // ключ, реестр и пропуска прав — заново по *, незнакомому токену, нагрузке не по режиму, а у процесса
383
- // без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том числе
384
- // поддельными сигналами) не становится потоком перечитываний
482
+ // ключ, реестр и пропуска прав — заново после переподключения слушателя, по незнакомому токену, нагрузке не по
483
+ // режиму, а у процесса без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том
484
+ // числе поддельными сигналами) не становится потоком перечитываний
385
485
  const resync = throttle(() => {
386
486
  passes.clear();
387
487
  void fetchKey().then(() => refresh()).catch(() => { });
@@ -468,17 +568,28 @@ export async function connect(opts) {
468
568
  stamper.unref?.();
469
569
  }
470
570
  let stopMaintain = null;
571
+ const subs = new Set();
471
572
  const shared = {
472
- sql, schema: opts.schema, S, notifier, passes, root, registryFor, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
573
+ sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
473
574
  admin: Boolean(bypass && (role.rolsuper || role.can_set)),
474
575
  ownerRole: owner,
576
+ ...(adminSql ? { adminSql, adminReady } : {}),
475
577
  cache,
476
578
  sig,
477
579
  allTenants: Boolean(bypass),
478
580
  signals,
479
581
  listen,
582
+ subs,
583
+ track: (s) => {
584
+ subs.add(s);
585
+ return s;
586
+ },
480
587
  async close() {
481
- // идущий maintain() по расписанию доделывает пачку: пул закрывается после неё, а не посреди транзакции
588
+ // живые подписки закрываются: иначе с чужим пулом (connect({ sql })) они опрашивали бы журнал и после close()
589
+ // (план 2.0, 7.4 №8)
590
+ for (const s of [...subs])
591
+ s.close();
592
+ // идущий maintain() по расписанию доделывается: пул закрывается после него, а не посреди транзакции
482
593
  await stopMaintain?.();
483
594
  resync.stop();
484
595
  startReload.stop();
@@ -488,16 +599,20 @@ export async function connect(opts) {
488
599
  await listener?.unlisten();
489
600
  if (own)
490
601
  await sql.end({ timeout: 5 });
602
+ if (ownAdmin)
603
+ await adminSql.end({ timeout: 5 });
491
604
  },
492
605
  };
493
606
  const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
494
607
  if (maintainEvery !== undefined)
495
- stopMaintain = scheduleMaintain((stopped) => db.maintain({ [MAINTAIN_STOP]: stopped }), maintainEvery);
608
+ stopMaintain = scheduleMaintain(() => db.maintain(), maintainEvery);
496
609
  return db;
497
610
  }
498
611
  catch (e) {
499
612
  if (own)
500
613
  await sql.end({ timeout: 1 });
614
+ if (ownAdmin)
615
+ await adminSql.end({ timeout: 1 });
501
616
  throw e;
502
617
  }
503
618
  }
@@ -9,7 +9,19 @@ 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
  }
18
+ /**
19
+ * Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,
20
+ * 7.4 №8). Тест меняет предел, чтобы проверить вытеснение без тысячи арендаторов.
21
+ */
22
+ export declare const limits: {
23
+ asRegistries: number;
24
+ };
13
25
  /** Запомнить фасад (зовёт makeDb для каждого фасада, который отдаёт наружу). */
14
26
  export declare function registerDb(db: object, info: DbInternals): void;
15
27
  /** Свойства фасада; undefined — объект не фасад connect() этого пакета. */