letopis 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENT-CHEATSHEET.md +12 -5
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +202 -46
  4. package/api-contract.json +4 -0
  5. package/dist/acl.d.ts +4 -2
  6. package/dist/admin.d.ts +5 -0
  7. package/dist/admin.js +10 -5
  8. package/dist/auth.d.ts +29 -8
  9. package/dist/auth.js +54 -9
  10. package/dist/chain.d.ts +19 -13
  11. package/dist/chain.js +17 -7
  12. package/dist/cli.js +46 -16
  13. package/dist/errors.d.ts +4 -5
  14. package/dist/errors.js +6 -7
  15. package/dist/index.d.ts +24 -13
  16. package/dist/index.js +75 -24
  17. package/dist/load.d.ts +1 -1
  18. package/dist/log.js +23 -2
  19. package/dist/mcp.d.ts +1 -1
  20. package/dist/mcp.js +8 -5
  21. package/dist/messages/access.d.ts +10 -0
  22. package/dist/messages/access.js +13 -0
  23. package/dist/messages/api.d.ts +10 -0
  24. package/dist/messages/api.js +23 -10
  25. package/dist/messages/boot.d.ts +5 -0
  26. package/dist/messages/boot.js +7 -0
  27. package/dist/messages/codes.js +2 -2
  28. package/dist/messages/log.d.ts +8 -5
  29. package/dist/messages/log.js +8 -5
  30. package/dist/messages/mcp.js +7 -7
  31. package/dist/messages/model.d.ts +16 -0
  32. package/dist/messages/model.js +20 -2
  33. package/dist/messages/write.d.ts +5 -0
  34. package/dist/messages/write.js +22 -14
  35. package/dist/model.d.ts +6 -2
  36. package/dist/model.js +5 -1
  37. package/dist/registry.d.ts +6 -0
  38. package/dist/registry.js +6 -0
  39. package/dist/sync.d.ts +3 -4
  40. package/dist/sync.js +3 -4
  41. package/dist/typed.d.ts +6 -6
  42. package/dist/types.d.ts +14 -9
  43. package/dist/types.js +2 -2
  44. package/dist/up.d.ts +5 -2
  45. package/dist/up.js +14 -6
  46. package/dist/write.d.ts +13 -9
  47. package/dist/write.js +81 -12
  48. package/llms.txt +2 -1
  49. package/package.json +1 -1
  50. package/sql/60-write.sql +18 -6
package/dist/index.d.ts CHANGED
@@ -29,8 +29,7 @@ export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has,
29
29
  /**
30
30
  * Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
31
31
  * вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и». В шаге подключения
32
- * с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт шаг (план 2.0, этап 4, п. 1). Та же
33
- * фабрика, что в реестре операторов (ops.ts), — другой у неё только тип.
32
+ * с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт шаг.
34
33
  */
35
34
  export declare const or: <F extends object>(...filters: NoInfer<F>[]) => OrOp<F>;
36
35
  export { cursorOf } from './sql.js';
@@ -55,7 +54,7 @@ export interface ConnectOptions extends LogOptions {
55
54
  sql?: postgres.Sql;
56
55
  /** Схема установки, например `v2.salon`; имя без точки (`salon`) — как у `up()`: `v2.salon`. */
57
56
  schema: string;
58
- /** Токен сессии; либо ключ `APIKEY` в `apiKey` (он же `service` — ключ сервиса) — его обменяют на сессию. */
57
+ /** Токен сессии; либо ключ API в `apiKey` (он же `service` — ключ сервиса) — его обменяют на сессию. */
59
58
  token?: string;
60
59
  apiKey?: string;
61
60
  service?: string;
@@ -121,7 +120,15 @@ export interface DbCore {
121
120
  * класса. Сами права база применяет при каждом запросе.
122
121
  */
123
122
  readonly acl: AclApi;
124
- /** Сырой SQL под сессией и RLS, одной транзакцией (в `db.begin()` — в ней): `` db.sql`select …` ``. */
123
+ /**
124
+ * Сырой SQL под сессией и RLS, одной транзакцией (в `db.begin()` — в ней); права те же, что у цепочек. Путь поиска
125
+ * схем у запроса — по умолчанию PostgreSQL, поэтому таблицы `entity`, `log`, `secret` пишутся со схемой установки в
126
+ * двойных кавычках, значения — параметрами шаблона:
127
+ * ```ts
128
+ * const rows = await db.sql`select id, data->>'name' as name from "v2.shop".entity where class = ${'Shop'}`;
129
+ * ```
130
+ * Без схемы (`from entity`) — ошибка PostgreSQL 42P01 «relation does not exist».
131
+ */
125
132
  sql(strings: TemplateStringsArray, ...values: unknown[]): Promise<postgres.Row[]>;
126
133
  /** Транзакция под сессией: `fn` получает соединение postgres.js (в `db.begin()` — ту же транзакцию). */
127
134
  transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
@@ -162,14 +169,15 @@ export interface DbCore {
162
169
  }): Promise<DbTx>;
163
170
  /**
164
171
  * Массовая загрузка строк любых классов одной транзакцией (или порциями `commitEvery`) — быстрее записи цепочками.
165
- * Строки проверяются по тому же описанию класса, что и при обычной записи (данные, ссылки, ключ), уходят в базу
166
- * командой `COPY`, а ссылки на объекты вне загрузки сверяет один запрос в конце; права проверяются только при загрузке
172
+ * Строки проверяются по тому же описанию класса, что и при обычной записи (данные, концы, ключ), уходят в базу
173
+ * командой `COPY`, а концы к объектам вне загрузки сверяет один запрос в конце; права проверяются только при загрузке
167
174
  * от имени пользователя (опция `as`). Работает только на администраторском подключении, иначе `admin_required`.
168
175
  */
169
176
  load(input: LoadInput, opts?: LoadOptions): Promise<LoadReport>;
170
177
  /**
171
- * Именованная очередь планов фасада: `batch.Класс(…).create(…)`; `batch.run()` — одной транзакцией. У корня,
172
- * у каждого `db.as()` и у каждой `db.begin()` очереди свои: план исполняется от имени фасада, который его поставил.
178
+ * Именованная очередь планов подключения (db, db.as(), db.begin()): `batch.Класс(…).create(…)`; `batch.run()` — одной
179
+ * транзакцией. У корня, у каждого `db.as()` и у каждой `db.begin()` очереди свои: план исполняется от имени того
180
+ * подключения, которое его поставило.
173
181
  */
174
182
  batch(name: string, opts?: {
175
183
  reason?: string;
@@ -181,8 +189,11 @@ export interface DbCore {
181
189
  /** Откатить транзакцию `tr` (то же, что `tr.rollback()`). */
182
190
  rollback(tr?: DbTx): Promise<void>;
183
191
  /**
184
- * Закрыть подключение: у корня — пул и подписки, в `db.begin()` — откат транзакции; у фасада `db.as()` ничего не делает
185
- * (пул общий с корнем).
192
+ * Закрыть подключение: у корня — пул, подписки и обслуживание по расписанию (идущий запуск `maintain()` close() ждёт),
193
+ * в `db.begin()` — откат транзакции; у `db.as()` ничего не делает (пул общий с корнем). После `close()` корня любой
194
+ * вызов этого подключения и производных от него (`db.as()`, `db.begin()`, цепочки, созданные раньше) — `invalid_query`
195
+ * «подключение закрыто (db.close()) — откройте новое connect()»; пул заново не открывается, процесс может завершиться.
196
+ * Повторный `close()` — без ошибки.
186
197
  */
187
198
  close(): Promise<void>;
188
199
  }
@@ -194,15 +205,15 @@ export type Db = DbCore & {
194
205
  export type DbTx = Db;
195
206
  /**
196
207
  * Подключение со строгими типами по моделям: `connect({ models })` или `connect<Models>()` с объявлениями
197
- * `letopis types`. Шаг — только известный класс или алиас (модели и системные классы), переходы — по концам, слоты — роли
198
- * класса; имена, известные только во время выполнения, — `loose(db)` (план 2.0, этап 4, п. 1). Модели без литеральных
208
+ * `letopis types`. Шаг — только известный класс или алиас (модели и системные классы), переходы — по концам, шаги ролей
209
+ * после глагола — роли класса; имена, известные только во время выполнения, — `loose(db)`. Модели без литеральных
199
210
  * имён (`AnyModel[]` вместо списка `as const`) строгим типам не помогут — тогда это `Db`.
200
211
  */
201
212
  export type TypedDb<M extends readonly AnyModelLike[]> = string extends M[number]['name'] ? Db : DbCore & StepsOf<M>;
202
213
  export type { Batch };
203
214
  /**
204
215
  * То же подключение без строгих типов: шагом может быть любое имя, в том числе известное только во время выполнения, —
205
- * `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, слот) не меняются.
216
+ * `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, шаг роли) не меняются.
206
217
  */
207
218
  export declare function loose(db: DbCore): Db;
208
219
  export declare function connect<const M extends readonly AnyModelLike[]>(opts: ConnectOptions & {
package/dist/index.js CHANGED
@@ -36,8 +36,7 @@ export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has,
36
36
  /**
37
37
  * Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
38
38
  * вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и». В шаге подключения
39
- * с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт шаг (план 2.0, этап 4, п. 1). Та же
40
- * фабрика, что в реестре операторов (ops.ts), — другой у неё только тип.
39
+ * с моделями (`TypedDb`) фильтры внутри — поля и роли класса шага: тип им даёт шаг.
41
40
  */
42
41
  export const or = orFilters;
43
42
  export { cursorOf } from './sql.js';
@@ -60,19 +59,42 @@ const WATCH_OPTIONS = { from: true, classes: true, batch: true, pollMs: true };
60
59
  const BEGIN_OPTIONS = { reason: true };
61
60
  /**
62
61
  * То же подключение без строгих типов: шагом может быть любое имя, в том числе известное только во время выполнения, —
63
- * `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, слот) не меняются.
62
+ * `loose(db)[имя]()`. Объект тот же: проверки во время выполнения (неизвестное имя или поле, переход, шаг роли) не меняются.
64
63
  */
65
64
  export function loose(db) {
66
65
  return db;
67
66
  }
68
67
  const quote = (name) => `"${name.replace(/"/g, '""')}"`;
68
+ /** Методы фасада, которые отвечают сразу, а не обещанием: после close() они бросают api.closed синхронно. */
69
+ const SYNC_MEMBERS = new Set(['idOf', 'entity', 'watch', 'batch']);
70
+ /** Свойства фасада, которые после close() читаются как прежде: не обращаются к базе. */
71
+ const PASSIVE_MEMBERS = new Set(['close', 'schema', 'token', 'tenant', 'registry', 'cache']);
72
+ /** Исполнитель, который после close() корня отказывает api.closed: postgres.js reserve() открыл бы закрытый пул заново. */
73
+ function openOnly(sh, exec) {
74
+ return {
75
+ explicit: exec.explicit,
76
+ run: (fn, opts) => (sh.closed ? Promise.reject(lerr('api.closed')) : exec.run(fn, opts)),
77
+ };
78
+ }
79
+ /** Служебный объект фасада (auth, acl) после close(): каждый метод — отказ api.closed. */
80
+ function closedApi(api) {
81
+ return new Proxy(api, {
82
+ get(t, prop, r) {
83
+ const v = Reflect.get(t, prop, r);
84
+ return typeof v === 'function' ? () => Promise.reject(lerr('api.closed')) : v;
85
+ },
86
+ });
87
+ }
69
88
  /** Фасад db: пул (exec — исполнитель пула) или явная транзакция (tx). */
70
- function makeDb(sh, scope, exec, owner, tx) {
89
+ function makeDb(sh, scope, raw, owner, tx) {
71
90
  const txc = scope.txc;
91
+ // после close() корня цепочки (и созданные до него), auth, acl, db.sql — отказ api.closed; raw — у maintain(), который
92
+ // идёт по расписанию: close() его дожидается
93
+ const exec = openOnly(sh, raw);
72
94
  // логгер фасада: арендатор и аккаунт — в момент события (корень меняет их auth.switch), метка транзакции — от db.begin()
73
95
  const log = (txc.log ?? sh.log).child({ who: () => ({ tenant: scope.tenant(), account: txc.account ?? sh.root.account }) });
74
96
  const chainCtx = {
75
- reg: scope.registry(), schema: sh.schema, exec, tenant: scope.tenant, hasToken: () => Boolean(txc.token), owner, onQuery: sh.onQuery, slowMs: sh.slowMs,
97
+ reg: scope.registry(), schema: sh.schema, exec, tenant: scope.tenant, account: () => txc.account ?? sh.root.account, hasToken: () => Boolean(txc.token), owner, onQuery: sh.onQuery, slowMs: sh.slowMs,
76
98
  reason: txc.reason ?? '', txReason: txc.reason ?? '', log,
77
99
  ...(sh.cache ? { cache: { store: sh.cache, who: () => ({ token: txc.token, account: txc.account, tenant: scope.tenant() }) } } : {}),
78
100
  };
@@ -90,7 +112,7 @@ function makeDb(sh, scope, exec, owner, tx) {
90
112
  }, scope.root && !tx), log, 'auth', AUTH_LOG, { switch: () => scope.tenant() });
91
113
  // запись правил (grant, deny, revoke, roles) идёт цепочками самого фасада — он объявлен ниже, берётся при вызове
92
114
  const acl = instrument(makeAcl(sh.S, exec, () => facade), log, 'acl', ACL_LOG);
93
- const adm = { S: sh.S, txc, exec };
115
+ const adm = { S: sh.S, txc, exec: raw };
94
116
  // один процесс (план 2.0, 7.9 №3): у корня с connect({ admin }) администраторские вызовы идут вторым пулом с той же
95
117
  // сессией, а цепочки и db.sql — пулом приложения под RLS; db.as() и db.begin() администраторский пул не берут
96
118
  const viaAdmin = scope.root && !tx && sh.adminSql ? sh.adminSql : null;
@@ -228,10 +250,13 @@ function makeDb(sh, scope, exec, owner, tx) {
228
250
  commit: (tr) => traced(log, null, { op: 'commit' }, async () => (tr ? tr.commit() : tx ? tx.commit() : noTx('commit()'))),
229
251
  rollback: (tr) => traced(log, null, { op: 'rollback' }, async () => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()'))),
230
252
  // подключение из 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())),
253
+ // повторный close() и close() у db.as() или db.begin() закрытого подключения — без ошибки
254
+ close: () => traced(log, !tx && scope.root ? 'close' : null, { op: 'close', detail: { schema: sh.schema } }, () => (tx ? (sh.closed ? Promise.resolve() : tx.rollback()) : scope.root ? sh.close() : Promise.resolve())),
232
255
  };
233
256
  /** Свойство фасада: служебное — из core, иначе шаг класса, алиаса или роли. */
234
257
  const member = (t, prop, r) => {
258
+ if (sh.closed && typeof prop === 'string' && prop !== 'then' && !PASSIVE_MEMBERS.has(prop))
259
+ return closedMember(t, prop, r);
235
260
  if (typeof prop === 'symbol' || prop in t)
236
261
  return Reflect.get(t, prop, r);
237
262
  if (prop === 'then')
@@ -239,6 +264,21 @@ function makeDb(sh, scope, exec, owner, tx) {
239
264
  // неизвестный класс или роль — событие error с именем шага
240
265
  return log.enabled ? guarded(log, { op: prop, chain: prop }, () => startStep(chainCtx, prop)) : startStep(chainCtx, prop);
241
266
  };
267
+ /** Свойство фасада после close(): методы и шаги — отказ api.closed (обещанием, у шагов и синхронных методов — сразу). */
268
+ const closedMember = (t, prop, r) => {
269
+ if (prop in t) {
270
+ const v = Reflect.get(t, prop, r);
271
+ if (prop === 'auth' || prop === 'acl')
272
+ return closedApi(v);
273
+ if (typeof v !== 'function')
274
+ return v;
275
+ if (!SYNC_MEMBERS.has(prop))
276
+ return () => Promise.reject(lerr('api.closed'));
277
+ }
278
+ return () => {
279
+ throw lerr('api.closed');
280
+ };
281
+ };
242
282
  const facade = new Proxy(core, {
243
283
  get(t, prop, r) {
244
284
  return member(t, prop, r);
@@ -650,6 +690,7 @@ async function open(opts, base, info) {
650
690
  stamper.unref?.();
651
691
  }
652
692
  let stopMaintain = null;
693
+ let closing = null;
653
694
  const subs = new Set();
654
695
  const shared = {
655
696
  sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs, log: base,
@@ -666,23 +707,33 @@ async function open(opts, base, info) {
666
707
  subs.add(s);
667
708
  return s;
668
709
  },
669
- async close() {
670
- // живые подписки закрываются: иначе с чужим пулом (connect({ sql })) они опрашивали бы журнал и после close()
671
- // (план 2.0, 7.4 №8)
672
- for (const s of [...subs])
673
- s.close();
674
- // идущий maintain() по расписанию доделывается: пул закрывается после него, а не посреди транзакции
675
- await stopMaintain?.();
676
- resync.stop();
677
- startReload.stop();
678
- if (stamper)
679
- clearInterval(stamper);
680
- await notifier.close();
681
- await listener?.unlisten();
682
- if (own)
683
- await sql.end({ timeout: 5 });
684
- if (ownAdmin)
685
- await adminSql.end({ timeout: 5 });
710
+ closed: false,
711
+ close() {
712
+ // повторный close() ждёт первого; флаг — сразу: вызовы после close() отказывают api.closed, а не открывают пул
713
+ // заново (postgres.js reserve() после end() соединяется снова)
714
+ if (closing)
715
+ return closing;
716
+ shared.closed = true;
717
+ closing = (async () => {
718
+ // живые подписки закрываются: иначе с чужим пулом (connect({ sql })) они опрашивали бы журнал и после close()
719
+ // (план 2.0, 7.4 №8)
720
+ for (const s of [...subs])
721
+ s.close();
722
+ // идущий maintain() по расписанию доделывается: пул закрывается после него, а не посреди транзакции
723
+ await stopMaintain?.();
724
+ resync.stop();
725
+ startReload.stop();
726
+ if (stamper)
727
+ clearInterval(stamper);
728
+ cache?.clear();
729
+ await notifier.close();
730
+ await listener?.unlisten();
731
+ if (own)
732
+ await sql.end({ timeout: 5 });
733
+ if (ownAdmin)
734
+ await adminSql.end({ timeout: 5 });
735
+ })();
736
+ return closing;
686
737
  },
687
738
  };
688
739
  const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
package/dist/load.d.ts CHANGED
@@ -37,7 +37,7 @@ export interface LoadOptions {
37
37
  /**
38
38
  * Загрузка от имени пользователя (§2.6, п. 10): автор версий — он; арендатор (по умолчанию —
39
39
  * собственный аккаунта) доступен ему; право WRITE на каждый класс и условия ACL на новые (и в
40
- * existing: 'update' — прежние) значения строк; цели ссылок ему видны; владелец — по ownerDefault.
40
+ * existing: 'update' — прежние) значения строк; цели концов ему видны; владелец — по ownerDefault.
41
41
  */
42
42
  as?: string;
43
43
  /**
package/dist/log.js CHANGED
@@ -25,8 +25,29 @@ import { LetopisError, lerr, lmsg, setEnvWarnSink } from './errors.js';
25
25
  import { LOG_EVENTS } from './messages/index.js';
26
26
  const RANK = { debug: 10, info: 20, warn: 30, error: 40 };
27
27
  const LEVELS = ['debug', 'info', 'warn', 'error', 'off'];
28
- /** Имена полей с секретами: маскируются в любом месте detail всегда. */
29
- const SECRET_KEY = /pass(?:word|wd|phrase)?$|^pass|^pwd$|secret|token|api_?key|service_?key|authorization|cookie|credential|^hash$|^material$|^totp$|^otp$/i;
28
+ /**
29
+ * Имена полей с секретами: маскируются в любом месте detail всегда. Имя делится на слова (camelCase, snake_case,
30
+ * kebab-case, точки, цифры): секрет — слово из SECRET_WORDS (password, newPassword, refresh_token, clientSecret …), пара
31
+ * слов api/service + key (apiKey, api_key, serviceKey) или имя целиком из SECRET_EXACT. Слово, где «pass» — часть другого
32
+ * слова (bypass, passage, passport, compass, isPassive), секретом не считается.
33
+ */
34
+ const SECRET_WORDS = new Set(['password', 'passwords', 'passwd', 'passphrase', 'pwd', 'pass', 'secret', 'secrets', 'token', 'tokens',
35
+ 'apikey', 'apikeys', 'servicekey', 'authorization', 'cookie', 'cookies', 'credential', 'credentials', 'totp', 'otp']);
36
+ const SECRET_EXACT = new Set(['hash', 'material']);
37
+ const SECRET_PAIR = new Set(['api', 'service']);
38
+ const SECRET_STEM = /passw|passphrase|pwd|secret|token|api[-_]?keys?|service[-_]?keys?|credential|cookie|authorization/i;
39
+ const SECRET_KEY = {
40
+ test(key) {
41
+ if (SECRET_EXACT.has(key.toLowerCase()))
42
+ return true;
43
+ // корни секретов подстрокой — и в склеенных именах (dbpassword, apitoken, x-api-key); голое «pass» сюда не входит,
44
+ // поэтому bypass, passport, compass секретами не считаются
45
+ if (SECRET_STEM.test(key))
46
+ return true;
47
+ const words = key.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').toLowerCase().split(/[^a-z]+/);
48
+ return words.some((w, i) => SECRET_WORDS.has(w) || ((w === 'key' || w === 'keys') && i > 0 && SECRET_PAIR.has(words[i - 1])));
49
+ },
50
+ };
30
51
  /**
31
52
  * Учётные данные в строке подключения: схема://пользователь:пароль@хост — всё до последней `@` в части хоста
32
53
  * (пароль может содержать `@`, `:` и `/`).
package/dist/mcp.d.ts CHANGED
@@ -28,7 +28,7 @@ export interface McpOptions {
28
28
  }
29
29
  /**
30
30
  * Сервер MCP для одного пользователя letopis: инструменты `letopis_model`, `letopis_query`, `letopis_history`, а с
31
- * `{ write: true }` — ещё `letopis_write`. Подключение — сессия пользователя (`connect({ token })`) или фасад
31
+ * `{ write: true }` — ещё `letopis_write`. Подключение — сессия пользователя (`connect({ token })`) или подключение
32
32
  * `db.as(пользователь)`. Транспорт выбирает приложение: `server.connect(транспорт)`, `serveStdio(() => createMcpServer(db))`
33
33
  * или обработчик HTTP `createMcpHandler(() => createMcpServer(db))` из SDK; токен запроса HTTP проверяет приложение.
34
34
  */
package/dist/mcp.js CHANGED
@@ -545,7 +545,7 @@ function withLinks(db, cls, c, links) {
545
545
  }
546
546
  /** Хэш операции удаления: схема, арендатор, класс, цели и превью каскада — подтверждение человека привязано к нему. */
547
547
  function operationOf(db, cls, targets, preview) {
548
- const rows = preview.map((r) => [r.id, r.class, r.rev, r.$action ?? null, r.$depth ?? null]).sort((x, y) => (String(x[0]) < String(y[0]) ? -1 : 1));
548
+ const rows = preview.map((r) => [r.id, r.class, r.rev, r.$action ?? null, r.$depth ?? null, r.$count ?? null]).sort((x, y) => (String(x[0]) < String(y[0]) ? -1 : 1));
549
549
  return createHash('sha256').update(JSON.stringify({ schema: db.schema, tenant: db.tenant, cls, targets: [...targets].sort(), rows })).digest('hex');
550
550
  }
551
551
  const hmacOf = (who, exp, op) => createHmac('sha256', STATE_KEY).update(`letopis:mcp:delete:${who.account}:${who.tenant}:${exp}:${op}`).digest('hex');
@@ -662,15 +662,18 @@ async function write(db, a, ctx, sdk, server, o, who) {
662
662
  throw lerr('mcp.delete_operation_changed', undefined, { detail: { operation: op } });
663
663
  if (!preview.some((r) => (r.$depth ?? 0) === 0))
664
664
  throw lerr('mcp.delete_nothing');
665
- if (preview.some((r) => r.$action === 'restrict'))
665
+ // мешают и скрытые правами строки: сводка $hidden 'restrict' (3.0.1)
666
+ if (preview.some((r) => r.$action === 'restrict' || r.$hidden === 'restrict'))
666
667
  throw lerr('mcp.delete_restricted', undefined, { detail: { preview } });
667
- const count = (act) => preview.filter((r) => r.$action === act).length;
668
+ // сводка скрытых строк считается их числом ($count)
669
+ const count = (act) => preview.filter((r) => act === undefined || r.$action === act || r.$hidden === act)
670
+ .reduce((n, r) => n + (r.$action === 'hidden' ? r.$count ?? 0 : 1), 0);
668
671
  // окно видит человек: каноническое имя класса (имя — идентификатор по метасхеме) и id целей; алиас и meta — текст
669
672
  // арендатора, в окно они не попадают (ревью безопасности №2)
670
673
  const first = targets.slice(0, CONFIRM_IDS).join(', ');
671
674
  const shown = targets.length > CONFIRM_IDS ? lmsg('mcp.delete_ids_more', { ids: first, more: targets.length - CONFIRM_IDS }) : first;
672
675
  const message = lmsg('mcp.delete_message', {
673
- count: targets.length, cls: db.registry.resolve(cls).name, ids: shown, rows: preview.length,
676
+ count: targets.length, cls: db.registry.resolve(cls).name, ids: shown, rows: count(),
674
677
  deleted: count('delete'), unset: count('unset'), operation: op.slice(0, 12),
675
678
  });
676
679
  const c = await confirmDelete(sdk, server, ctx, op, message, who);
@@ -738,7 +741,7 @@ async function checkCredentials(db, o) {
738
741
  }
739
742
  /**
740
743
  * Сервер MCP для одного пользователя letopis: инструменты `letopis_model`, `letopis_query`, `letopis_history`, а с
741
- * `{ write: true }` — ещё `letopis_write`. Подключение — сессия пользователя (`connect({ token })`) или фасад
744
+ * `{ write: true }` — ещё `letopis_write`. Подключение — сессия пользователя (`connect({ token })`) или подключение
742
745
  * `db.as(пользователь)`. Транспорт выбирает приложение: `server.connect(транспорт)`, `serveStdio(() => createMcpServer(db))`
743
746
  * или обработчик HTTP `createMcpHandler(() => createMcpServer(db))` из SDK; токен запроса HTTP проверяет приложение.
744
747
  */
@@ -87,4 +87,14 @@ export declare const access: {
87
87
  ru: string;
88
88
  hint: string;
89
89
  };
90
+ 'access.bad_max_attempts': {
91
+ code: "invalid_data";
92
+ ru: string;
93
+ hint: string;
94
+ };
95
+ 'access.bad_ttl': {
96
+ code: "invalid_data";
97
+ ru: string;
98
+ hint: string;
99
+ };
90
100
  };
@@ -110,4 +110,17 @@ export const access = {
110
110
  ru: 'секрет TOTP — не base32',
111
111
  hint: 'передать секрет из auth.enrollTotp() — строку base32 (A–Z, 2–7)',
112
112
  },
113
+ // auth.ts, issueOtp (3.0.1): maxAttempts не целое или вне 1–10 (раньше база молча приводила к пределу); value — как передан
114
+ 'access.bad_max_attempts': {
115
+ code: 'invalid_data',
116
+ ru: 'issueOtp: maxAttempts {value} — целое от 1 до 10',
117
+ hint: 'maxAttempts — число неверных попыток до того, как код сгорит: целое от 1 до 10 (по умолчанию 5)',
118
+ },
119
+ // auth.ts, ttlOf (3.0.1): срок ttl (sessionFor, issueOtp, issueApiKey, verifyPassword, lookup, refresh, createTenant) — не
120
+ // число секунд, не короткая форма и не интервал PostgreSQL; where — вызов, value — как передан
121
+ 'access.bad_ttl': {
122
+ code: 'invalid_data',
123
+ ru: "{where}: ttl {value} — нужно число секунд, '15m' или интервал PostgreSQL '15 minutes'",
124
+ hint: "срок ttl больше нуля: число секунд (600), короткая форма '30s', '15m', '2h', '1d' или интервал PostgreSQL '15 minutes'",
125
+ },
113
126
  };
@@ -18,6 +18,11 @@ export declare const api: {
18
18
  ru: string;
19
19
  hint: string;
20
20
  };
21
+ 'api.unknown_step_empty_tenant': {
22
+ code: "invalid_class";
23
+ ru: string;
24
+ hint: string;
25
+ };
21
26
  'api.chain_not_rows': {
22
27
  code: "invalid_query";
23
28
  ru: string;
@@ -170,6 +175,11 @@ export declare const api: {
170
175
  ru: string;
171
176
  hint: string;
172
177
  };
178
+ 'api.closed': {
179
+ code: "invalid_query";
180
+ ru: string;
181
+ hint: string;
182
+ };
173
183
  'api.nested_begin': {
174
184
  code: "invalid_query";
175
185
  ru: string;
@@ -24,6 +24,13 @@ export const api = {
24
24
  ru: 'класс или роль {name} не найдены: в реестре сессии нет ни одного класса арендатора — сессия, похоже, в личном арендаторе',
25
25
  hint: 'после входа сессия работает в личном арендаторе пользователя: найти нужного — db.auth.tenants(), перейти — db.auth.switch(tenant) или db.as(аккаунт, { tenant }); классы в арендатор записывает syncModels',
26
26
  },
27
+ // chain.ts (3.0.1): шаг не найден, в реестре сессии только классы System, а арендатор сессии не личный — в нём просто
28
+ // нет классов; похожие имена — params.similar
29
+ 'api.unknown_step_empty_tenant': {
30
+ code: 'invalid_class',
31
+ ru: 'класс или роль {name} не найдены: в этом арендаторе нет классов — вызовите syncModels в нём',
32
+ hint: 'классы в арендатор записывает syncModels — в сессии этого арендатора (подключение арендатора или db.as(аккаунт, { tenant })); имя — как в модели',
33
+ },
27
34
  // chain.ts: у цепочки спросили свойство массива (length, map, filter, 0 …) или перебрали её — она не массив строк
28
35
  'api.chain_not_rows': {
29
36
  code: 'invalid_query',
@@ -52,8 +59,8 @@ export const api = {
52
59
  // запроса; text — текст сервера
53
60
  'api.db_bad_value': {
54
61
  code: 'invalid_query',
55
- ru: 'значение не того типа: {text}',
56
- hint: 'id — строка uuid, числа — числа, даты — строки ISO; проверьте значения фильтра, курсора и опций',
62
+ ru: 'база не приняла значение: оно не того типа, что поле или параметр запроса (ответ PostgreSQL: {text})',
63
+ hint: 'передавайте id строкой uuid, числа — числами, даты — строками ISO 8601; проверьте значения фильтра, курсора, опций и параметров db.sql и повторите запрос',
57
64
  },
58
65
  // errors.ts, ValidationError: первое нарушение в тексте ошибки — путь и сообщение; текст не для ошибки (lmsg)
59
66
  'api.validation_issue': {
@@ -136,27 +143,27 @@ export const api = {
136
143
  // chain.ts, slotValue: значение .Роль.set(…), .add(…), .remove(…)
137
144
  'api.slot_value': {
138
145
  code: 'invalid_query',
139
- ru: 'значение слота — id, список id, строка ответа или цепочка',
146
+ ru: 'значение шага роли (.Роль.set(), .add(), .remove()) — id, список id, строка ответа или цепочка',
140
147
  },
141
148
  // chain.ts: слот не сразу после create(), update() или upsert(); name — роль, как вызвана, op — set, unset, add, remove
142
149
  'api.slot_without_write': {
143
150
  code: 'invalid_query',
144
- ru: 'слот .{name}.{op}() — после create(), update() или upsert(): db.Класс(…).update(…).{name}.{op}(…)',
151
+ ru: 'шаг роли .{name}.{op}() — после create(), update() или upsert(): db.Класс(…).update(…).{name}.{op}(…)',
145
152
  },
146
153
  // chain.ts: add() или remove() у одиночного конца
147
154
  'api.slot_single': {
148
155
  code: 'invalid_query',
149
- ru: 'слот .{name}.{op}(): конец {role} одиночный — set() или unset()',
156
+ ru: 'шаг роли .{name}.{op}(): конец {role} одиночный — set() или unset()',
150
157
  },
151
158
  // chain.ts: unset() у обязательного конца
152
159
  'api.slot_required': {
153
160
  code: 'invalid_query',
154
- ru: 'слот .{name}.unset(): конец {role} обязателен',
161
+ ru: 'шаг роли .{name}.unset(): конец {role} обязателен',
155
162
  },
156
163
  // chain.ts: set() или unset() одного конца дважды в одной записи
157
164
  'api.slot_twice': {
158
165
  code: 'invalid_query',
159
- ru: 'слот {role} задан дважды в одной записи',
166
+ ru: 'конец {role} задан дважды в одной записи',
160
167
  },
161
168
  // chain.ts, withLast: deep(), exact(), alias(), tags(), owner() сразу после глагола; verb — глагол
162
169
  'api.step_mod_after_verb': {
@@ -181,7 +188,7 @@ export const api = {
181
188
  // chain.ts: set, unset, add, remove у цепочки, а не у слота роли; method — имя метода
182
189
  'api.slot_method': {
183
190
  code: 'invalid_query',
184
- ru: '{method}() — метод слота: db.Класс(…).update(…).Роль.{method}(…)',
191
+ ru: '{method}() — метод шага роли после глагола: db.Класс(…).update(…).Роль.{method}(…)',
185
192
  },
186
193
  // chain.ts: cache({ ttl })
187
194
  'api.cache_ttl': {
@@ -219,8 +226,14 @@ export const api = {
219
226
  // аргумента (array — массив)
220
227
  'api.begin_arg': {
221
228
  code: 'invalid_query',
222
- ru: 'db.begin() принимает только опции { reason }, а не {type}: транзакцию открывает begin(), закрывают commit() и rollback()',
223
- hint: 'const tr = await db.begin(); try { …; await tr.commit() } catch (e) { await tr.rollback(); throw e }',
229
+ ru: 'db.begin() принимает только объект опций с полем reason или ничего; передано значение типа {type}. Функцию begin() не вызывает: транзакцию открывает begin(), закрывают commit() и rollback()',
230
+ hint: 'откройте транзакцию без функции и закройте её сами: const tr = await db.begin(); try { …запросы через tr…; await tr.commit() } catch (e) { await tr.rollback(); throw e }',
231
+ },
232
+ // index.ts: вызов цепочки или метода подключения после db.close() корня (и у db.as(), db.begin() этого подключения)
233
+ 'api.closed': {
234
+ code: 'invalid_query',
235
+ ru: 'подключение закрыто (db.close()) — откройте новое connect()',
236
+ hint: 'db.close() — в конце работы процесса; цепочки, db.as() и db.begin() закрытого подключения не работают — для новой работы const db = await connect({ … })',
224
237
  },
225
238
  'api.nested_begin': {
226
239
  code: 'invalid_query',
@@ -64,6 +64,11 @@ export declare const boot: {
64
64
  ru: string;
65
65
  hint: string;
66
66
  };
67
+ 'boot.login_grant_denied': {
68
+ code: "invalid_data";
69
+ ru: string;
70
+ hint: string;
71
+ };
67
72
  'boot.tenant_name_required': {
68
73
  code: "invalid_data";
69
74
  ru: string;
@@ -83,6 +83,13 @@ export const boot = {
83
83
  ru: 'up({ login }): роль {user} — привилегированная (суперпользователь, CREATEROLE, CREATEDB, REPLICATION, BYPASSRLS, роль-владелец или её член) или сама роль приложения: роль приложения — обычная роль входа, привилегированную up() не трогает',
84
84
  hint: 'выбрать для login другое имя — роль без этих атрибутов и без членства в роли владельца',
85
85
  },
86
+ // up.ts, ensureLogin (3.0.1): установщик не может выдать роль приложения (PostgreSQL 16+: нужен ADMIN OPTION, SQLSTATE
87
+ // 42501 «permission denied to grant role»); app — роль приложения, installer — роль установщика (current_user)
88
+ 'boot.login_grant_denied': {
89
+ code: 'invalid_data',
90
+ ru: 'up({ login }): роль {installer} не может выдать роль {app} роли {user} — нужен ADMIN OPTION на роль {app} (grant {app} to {installer} with admin option) или суперпользователь',
91
+ hint: 'выполнить от суперпользователя grant <роль приложения> to <установщик> with admin option (имена — в тексте ошибки) и повторить up({ login }) — или ставить от суперпользователя',
92
+ },
86
93
  // up.ts, createTenant() и up({ tenant }): нет имени арендатора
87
94
  'boot.tenant_name_required': {
88
95
  code: 'invalid_data',
@@ -91,8 +91,8 @@ export const codes = {
91
91
  },
92
92
  'codes.conflict': {
93
93
  code: 'conflict',
94
- ru: 'то, что вы видели, уже изменилось — ничего не записано: `update` с `{ rev }` (строку изменили после чтения, `write.rev_conflict`), `delete({ confirm: true, expect })` (превью изменилось, `write.preview_changed`; в том числе цели уже удалены или не найдены — `detail.preview: []`), `restore({ expect })` (объект меняли после удаления, `write.restore_stale`)',
95
- hint: 'перечитать и повторить с новыми данными: `update` — с новым `rev` (для чисел — `inc` вместо записи итога); `delete` — показать новое превью `err.detail.preview` и удалить с ним; `restore` — сообщить пользователю (вещь уже у другого) или `restore({ expect, partial: true })`',
94
+ ru: 'то, что вы видели, уже изменилось — ничего не записано: `update` с `{ rev }` (строку изменили после чтения, `write.rev_conflict`), `delete({ confirm: true, expect })` (предпросмотр изменился, `write.preview_changed`; в том числе цели уже удалены или не найдены — `detail.preview: []`), `restore({ expect })` (объект меняли после удаления, `write.restore_stale`)',
95
+ hint: 'перечитать и повторить с новыми данными: `update` — с новым `rev` (для чисел — `inc` вместо записи итога); `delete` — показать новый предпросмотр `err.detail.preview` и удалить с ним; `restore` — сообщить пользователю (вещь уже у другого) или `restore({ expect, partial: true })`',
96
96
  },
97
97
  'codes.rev_ambiguous': {
98
98
  code: 'rev_ambiguous',
@@ -39,9 +39,12 @@ export interface LogEventInfo {
39
39
  ru: string;
40
40
  }
41
41
  /**
42
- * Каталог событий логирования. Имена основных событий зафиксированы спецификацией 3.0 (connect, close, up, migrate,
43
- * sync, read, cache.hit, cache.miss, write, delete.preview, delete, restore, tx.*, auth.login, auth.logout, auth.revoke,
44
- * auth.switch, watch.start, watch.stop, signal, maintain, warning, error, sql); остальные — дополнительные.
42
+ * Каталог событий логирования — объект (не массив): имя события → `{ level, ru }`, где `level` — уровень события по
43
+ * умолчанию (`debug`, `info`, `warn`, `error`), `ru` — что значит событие. Тип — `Readonly<Record<LogEventName,
44
+ * LogEventInfo>>`: `LOG_EVENTS.write.level` — `'info'`, список имён — `Object.keys(LOG_EVENTS)`. Основные события:
45
+ * connect, close, up, migrate, sync, read, cache.hit, cache.miss, write, delete.preview, delete, restore, tx.*,
46
+ * auth.login, auth.logout, auth.revoke, auth.switch, watch.start, watch.stop, signal, maintain, warning, error, sql;
47
+ * остальные — дополнительные.
45
48
  */
46
49
  export declare const LOG_EVENTS: {
47
50
  readonly connect: {
@@ -98,11 +101,11 @@ export declare const LOG_EVENTS: {
98
101
  };
99
102
  readonly write: {
100
103
  readonly level: "info";
101
- readonly ru: "запись цепочкой (create, update, upsert, слоты) или батч";
104
+ readonly ru: "запись цепочкой (create, update, upsert, шаги ролей) или батч";
102
105
  };
103
106
  readonly 'delete.preview': {
104
107
  readonly level: "debug";
105
- readonly ru: "превью удаления: delete() без confirm, ничего не удалено";
108
+ readonly ru: "предпросмотр удаления: delete() без confirm, ничего не удалено";
106
109
  };
107
110
  readonly delete: {
108
111
  readonly level: "info";
@@ -40,9 +40,12 @@ export const log = {
40
40
  },
41
41
  };
42
42
  /**
43
- * Каталог событий логирования. Имена основных событий зафиксированы спецификацией 3.0 (connect, close, up, migrate,
44
- * sync, read, cache.hit, cache.miss, write, delete.preview, delete, restore, tx.*, auth.login, auth.logout, auth.revoke,
45
- * auth.switch, watch.start, watch.stop, signal, maintain, warning, error, sql); остальные — дополнительные.
43
+ * Каталог событий логирования — объект (не массив): имя события → `{ level, ru }`, где `level` — уровень события по
44
+ * умолчанию (`debug`, `info`, `warn`, `error`), `ru` — что значит событие. Тип — `Readonly<Record<LogEventName,
45
+ * LogEventInfo>>`: `LOG_EVENTS.write.level` — `'info'`, список имён — `Object.keys(LOG_EVENTS)`. Основные события:
46
+ * connect, close, up, migrate, sync, read, cache.hit, cache.miss, write, delete.preview, delete, restore, tx.*,
47
+ * auth.login, auth.logout, auth.revoke, auth.switch, watch.start, watch.stop, signal, maintain, warning, error, sql;
48
+ * остальные — дополнительные.
46
49
  */
47
50
  export const LOG_EVENTS = {
48
51
  // подключение
@@ -62,8 +65,8 @@ export const LOG_EVENTS = {
62
65
  'cache.miss': { level: 'debug', ru: 'ответа нет в кэше результатов — запрос к базе' },
63
66
  'raw': { level: 'debug', ru: 'сырой SQL db.sql' },
64
67
  // запись
65
- 'write': { level: 'info', ru: 'запись цепочкой (create, update, upsert, слоты) или батч' },
66
- 'delete.preview': { level: 'debug', ru: 'превью удаления: delete() без confirm, ничего не удалено' },
68
+ 'write': { level: 'info', ru: 'запись цепочкой (create, update, upsert, шаги ролей) или батч' },
69
+ 'delete.preview': { level: 'debug', ru: 'предпросмотр удаления: delete() без confirm, ничего не удалено' },
67
70
  'delete': { level: 'info', ru: 'удаление выполнено: состав каскада, сверка с expect' },
68
71
  'restore': { level: 'info', ru: 'восстановление выполнено: восстановленные и пропущенные цели' },
69
72
  'load': { level: 'info', ru: 'db.load(): массовая загрузка завершена' },