letopis 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/AGENT-CHEATSHEET.md +491 -414
  2. package/CHANGELOG.md +361 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +969 -210
  6. package/api-contract.json +385 -0
  7. package/dist/acl.d.ts +7 -2
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +7 -1
  10. package/dist/admin.js +16 -9
  11. package/dist/auth.d.ts +47 -11
  12. package/dist/auth.js +129 -15
  13. package/dist/chain.d.ts +52 -25
  14. package/dist/chain.js +445 -64
  15. package/dist/cli.js +72 -20
  16. package/dist/errors.d.ts +23 -6
  17. package/dist/errors.js +52 -13
  18. package/dist/index.d.ts +32 -15
  19. package/dist/index.js +209 -68
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +10 -6
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +654 -0
  25. package/dist/mcp.d.ts +1 -1
  26. package/dist/mcp.js +34 -16
  27. package/dist/messages/access.d.ts +10 -0
  28. package/dist/messages/access.js +13 -0
  29. package/dist/messages/api.d.ts +76 -0
  30. package/dist/messages/api.js +109 -7
  31. package/dist/messages/boot.d.ts +5 -0
  32. package/dist/messages/boot.js +11 -4
  33. package/dist/messages/codes.js +4 -4
  34. package/dist/messages/index.d.ts +5 -2
  35. package/dist/messages/index.js +5 -2
  36. package/dist/messages/log.d.ts +232 -0
  37. package/dist/messages/log.js +105 -0
  38. package/dist/messages/mcp.js +7 -7
  39. package/dist/messages/model.d.ts +40 -1
  40. package/dist/messages/model.js +52 -3
  41. package/dist/messages/query.d.ts +10 -0
  42. package/dist/messages/query.js +13 -0
  43. package/dist/messages/sql-core.d.ts +15 -1
  44. package/dist/messages/sql-core.js +20 -3
  45. package/dist/messages/sql-time.d.ts +15 -0
  46. package/dist/messages/sql-time.js +23 -0
  47. package/dist/messages/write.d.ts +52 -0
  48. package/dist/messages/write.js +77 -6
  49. package/dist/model.d.ts +10 -2
  50. package/dist/model.js +28 -6
  51. package/dist/registry.d.ts +6 -0
  52. package/dist/registry.js +6 -0
  53. package/dist/sql.d.ts +2 -0
  54. package/dist/sql.js +77 -25
  55. package/dist/sync.d.ts +4 -5
  56. package/dist/sync.js +53 -4
  57. package/dist/tx.d.ts +10 -1
  58. package/dist/tx.js +44 -2
  59. package/dist/typed.d.ts +18 -14
  60. package/dist/types.d.ts +91 -8
  61. package/dist/types.js +8 -3
  62. package/dist/up.d.ts +8 -5
  63. package/dist/up.js +71 -23
  64. package/dist/uuid.d.ts +8 -0
  65. package/dist/uuid.js +9 -1
  66. package/dist/validate.js +24 -7
  67. package/dist/watch.d.ts +5 -0
  68. package/dist/watch.js +13 -0
  69. package/dist/write.d.ts +84 -3
  70. package/dist/write.js +427 -40
  71. package/llms.txt +38 -17
  72. package/package.json +12 -6
  73. package/sql/20-context.sql +11 -3
  74. package/sql/30-validate.sql +51 -10
  75. package/sql/40-class.sql +21 -1
  76. package/sql/60-write.sql +18 -6
  77. package/sql/80-auth.sql +88 -17
  78. package/sql/90-time.sql +34 -6
  79. package/sql/99-revision.sql +5 -1
  80. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  81. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  82. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  83. package/sql/migrate/README.md +5 -1
package/dist/mcp.js CHANGED
@@ -27,9 +27,11 @@ import { createRequire } from 'node:module';
27
27
  import { describeModel } from './describe.js';
28
28
  import { LetopisError, checkOptions, lerr, lmsg, similar } from './errors.js';
29
29
  import { internalsOf } from './internal.js';
30
+ import { NOLOG } from './log.js';
30
31
  import { OPERATORS, opHint, or } from './ops.js';
31
32
  import { cursorOf } from './sql.js';
32
33
  import { ACL_CLASS_NAMES, RESERVED_CLASS_NAMES } from './types.js';
34
+ import { UUID_RE } from './uuid.js';
33
35
  /** Все опции createMcpServer: неизвестная — invalid_data с похожими именами (checkOptions; план 2.0, 7.10 №4). */
34
36
  const MCP_OPTIONS = {
35
37
  write: true, maxRows: true, maxBytes: true, allowAclWrite: true, allowSystemAdmin: true,
@@ -43,7 +45,7 @@ const STATE_KEY = randomBytes(32);
43
45
  const CONFIRM_TTL_MS = 10 * 60_000;
44
46
  const RESERVED = new Set(RESERVED_CLASS_NAMES);
45
47
  const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
46
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
48
+ const UUID = UUID_RE;
47
49
  const TERMINALS = ['rows', 'first', 'ids', 'count', 'sum', 'avg', 'min', 'max', 'countBy'];
48
50
  const PAGED = new Set(['rows', 'first', 'ids']);
49
51
  /** Шагов цепочки и условий $or в одном фильтре не больше (ревью безопасности №2): запрос растёт с каждым. */
@@ -375,6 +377,8 @@ function stepFilter(a, where) {
375
377
  return undefined;
376
378
  }
377
379
  const step = (x, name) => x[name];
380
+ /** Шаг с фильтром или без: без фильтра — вызов без аргумента (X(undefined) цепочка отмечает предупреждением, A13). */
381
+ const stepWith = (fn, f) => (f === undefined ? fn() : fn(f));
378
382
  const encodeCursor = (c) => Buffer.from(JSON.stringify(c)).toString('base64url');
379
383
  function decodeCursor(s) {
380
384
  let c;
@@ -425,7 +429,7 @@ async function query(db, a, o) {
425
429
  if (typeof terminal !== 'string' || !TERMINALS.includes(terminal)) {
426
430
  throw lerr('mcp.unknown_terminal', { terminal: String(terminal), similar: similar(String(terminal), TERMINALS) });
427
431
  }
428
- let chain = step(db, stepName(reg, a.class, 'letopis_query.class'))(stepFilter(a, 'letopis_query'));
432
+ let chain = stepWith(step(db, stepName(reg, a.class, 'letopis_query.class')), stepFilter(a, 'letopis_query'));
429
433
  if (a.steps !== undefined) {
430
434
  if (!Array.isArray(a.steps))
431
435
  throw lerr('mcp.steps_not_list');
@@ -436,7 +440,7 @@ async function query(db, a, o) {
436
440
  if (!isObj(s))
437
441
  throw lerr('mcp.step_not_object', { where });
438
442
  checkKeys(s, ['step', 'id', 'ids', 'filter'], where);
439
- chain = step(chain, stepName(reg, s.step, `${where}.step`))(stepFilter(s, where));
443
+ chain = stepWith(step(chain, stepName(reg, s.step, `${where}.step`)), stepFilter(s, where));
440
444
  });
441
445
  }
442
446
  if (a.asOf !== undefined) {
@@ -541,7 +545,7 @@ function withLinks(db, cls, c, links) {
541
545
  }
542
546
  /** Хэш операции удаления: схема, арендатор, класс, цели и превью каскада — подтверждение человека привязано к нему. */
543
547
  function operationOf(db, cls, targets, preview) {
544
- 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));
545
549
  return createHash('sha256').update(JSON.stringify({ schema: db.schema, tenant: db.tenant, cls, targets: [...targets].sort(), rows })).digest('hex');
546
550
  }
547
551
  const hmacOf = (who, exp, op) => createHmac('sha256', STATE_KEY).update(`letopis:mcp:delete:${who.account}:${who.tenant}:${exp}:${op}`).digest('hex');
@@ -658,15 +662,18 @@ async function write(db, a, ctx, sdk, server, o, who) {
658
662
  throw lerr('mcp.delete_operation_changed', undefined, { detail: { operation: op } });
659
663
  if (!preview.some((r) => (r.$depth ?? 0) === 0))
660
664
  throw lerr('mcp.delete_nothing');
661
- if (preview.some((r) => r.$action === 'restrict'))
665
+ // мешают и скрытые правами строки: сводка $hidden 'restrict' (3.0.1)
666
+ if (preview.some((r) => r.$action === 'restrict' || r.$hidden === 'restrict'))
662
667
  throw lerr('mcp.delete_restricted', undefined, { detail: { preview } });
663
- 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);
664
671
  // окно видит человек: каноническое имя класса (имя — идентификатор по метасхеме) и id целей; алиас и meta — текст
665
672
  // арендатора, в окно они не попадают (ревью безопасности №2)
666
673
  const first = targets.slice(0, CONFIRM_IDS).join(', ');
667
674
  const shown = targets.length > CONFIRM_IDS ? lmsg('mcp.delete_ids_more', { ids: first, more: targets.length - CONFIRM_IDS }) : first;
668
675
  const message = lmsg('mcp.delete_message', {
669
- 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(),
670
677
  deleted: count('delete'), unset: count('unset'), operation: op.slice(0, 12),
671
678
  });
672
679
  const c = await confirmDelete(sdk, server, ctx, op, message, who);
@@ -674,13 +681,14 @@ async function write(db, a, ctx, sdk, server, o, who) {
674
681
  return c.ask;
675
682
  if (!c.granted)
676
683
  throw lerr(c.reason);
677
- // превью и удаление — одной транзакцией: подтверждено ровно то, что удаляется
684
+ // превью и удаление — одной транзакцией: подтверждено ровно то, что удаляется; delete сверяет превью сам (expect,
685
+ // 3.0) — под блокировкой замыкания каскада
678
686
  const tx = await db.begin();
679
687
  try {
680
688
  const again = await step(tx, cls)(targets).delete(opts).rows();
681
689
  if (operationOf(db, cls, targets, again) !== op)
682
690
  throw lerr('mcp.delete_data_changed');
683
- const rows = await step(tx, cls)(targets).delete({ confirm: true, ...opts }).rows();
691
+ const rows = await step(tx, cls)(targets).delete({ confirm: true, expect: again, ...opts }).rows();
684
692
  await tx.commit();
685
693
  return ok({ deleted: rows, operation: op });
686
694
  }
@@ -733,7 +741,7 @@ async function checkCredentials(db, o) {
733
741
  }
734
742
  /**
735
743
  * Сервер MCP для одного пользователя letopis: инструменты `letopis_model`, `letopis_query`, `letopis_history`, а с
736
- * `{ write: true }` — ещё `letopis_write`. Подключение — сессия пользователя (`connect({ token })`) или фасад
744
+ * `{ write: true }` — ещё `letopis_write`. Подключение — сессия пользователя (`connect({ token })`) или подключение
737
745
  * `db.as(пользователь)`. Транспорт выбирает приложение: `server.connect(транспорт)`, `serveStdio(() => createMcpServer(db))`
738
746
  * или обработчик HTTP `createMcpHandler(() => createMcpServer(db))` из SDK; токен запроса HTTP проверяет приложение.
739
747
  */
@@ -748,19 +756,29 @@ async function serve(db, opts) {
748
756
  const who = await checkCredentials(db, o);
749
757
  const sdk = await loadSdk();
750
758
  const server = new sdk.McpServer({ name: 'letopis', version: VERSION }, { instructions: instructions(o), capabilities: { tools: {} } });
751
- const guard = (fn) => async (args, ctx) => {
759
+ // логирование 3.0: вызов инструмента — mcp.call (имя, время, исход), отказ — ещё error; логгер — фасада db
760
+ const log = internalsOf(db)?.log ?? NOLOG;
761
+ const guard = (tool, fn) => async (args, ctx) => {
762
+ const t0 = performance.now();
752
763
  try {
753
- return await fn(isObj(args) ? args : {}, ctx);
764
+ const r = await fn(isObj(args) ? args : {}, ctx);
765
+ if (log.wants('mcp.call')) {
766
+ log.emit('mcp.call', { op: tool, ms: performance.now() - t0, detail: { ok: !r.isError } });
767
+ }
768
+ return r;
754
769
  }
755
770
  catch (e) {
771
+ log.error(e, { op: tool, ms: performance.now() - t0 });
772
+ if (log.wants('mcp.call'))
773
+ log.emit('mcp.call', { op: tool, ms: performance.now() - t0, detail: { ok: false } });
756
774
  return failure(e);
757
775
  }
758
776
  };
759
- server.registerTool('letopis_model', { title: 'Модель letopis', description: DESCRIBE_MODEL, inputSchema: sdk.fromJsonSchema(MODEL_SCHEMA), annotations: READ_ONLY }, guard((a) => model(db, a, o)));
760
- server.registerTool('letopis_query', { title: 'Чтение letopis', description: describeQuery(o), inputSchema: sdk.fromJsonSchema(QUERY_SCHEMA), annotations: READ_ONLY }, guard((a) => query(db, a, o)));
761
- server.registerTool('letopis_history', { title: 'История letopis', description: describeHistory(o), inputSchema: sdk.fromJsonSchema(HISTORY_SCHEMA), annotations: READ_ONLY }, guard((a) => history(db, a, o)));
777
+ server.registerTool('letopis_model', { title: 'Модель letopis', description: DESCRIBE_MODEL, inputSchema: sdk.fromJsonSchema(MODEL_SCHEMA), annotations: READ_ONLY }, guard('letopis_model', (a) => model(db, a, o)));
778
+ server.registerTool('letopis_query', { title: 'Чтение letopis', description: describeQuery(o), inputSchema: sdk.fromJsonSchema(QUERY_SCHEMA), annotations: READ_ONLY }, guard('letopis_query', (a) => query(db, a, o)));
779
+ server.registerTool('letopis_history', { title: 'История letopis', description: describeHistory(o), inputSchema: sdk.fromJsonSchema(HISTORY_SCHEMA), annotations: READ_ONLY }, guard('letopis_history', (a) => history(db, a, o)));
762
780
  if (o.write) {
763
- server.registerTool('letopis_write', { title: 'Запись letopis', description: describeWrite(o), inputSchema: sdk.fromJsonSchema(WRITE_SCHEMA), annotations: DESTRUCTIVE }, guard((a, ctx) => write(db, a, ctx, sdk, server, o, who)));
781
+ server.registerTool('letopis_write', { title: 'Запись letopis', description: describeWrite(o), inputSchema: sdk.fromJsonSchema(WRITE_SCHEMA), annotations: DESTRUCTIVE }, guard('letopis_write', (a, ctx) => write(db, a, ctx, sdk, server, o, who)));
764
782
  }
765
783
  return server;
766
784
  }
@@ -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
  };
@@ -6,6 +6,56 @@ export declare const api: {
6
6
  'api.unknown_step': {
7
7
  code: "invalid_class";
8
8
  ru: string;
9
+ hint: string;
10
+ };
11
+ 'api.token_not_active': {
12
+ code: "no_session";
13
+ ru: string;
14
+ hint: string;
15
+ };
16
+ 'api.unknown_step_no_classes': {
17
+ code: "invalid_class";
18
+ ru: string;
19
+ hint: string;
20
+ };
21
+ 'api.unknown_step_empty_tenant': {
22
+ code: "invalid_class";
23
+ ru: string;
24
+ hint: string;
25
+ };
26
+ 'api.chain_not_rows': {
27
+ code: "invalid_query";
28
+ ru: string;
29
+ hint: string;
30
+ };
31
+ 'api.step_undefined_filter': {
32
+ code: "invalid_query";
33
+ ru: string;
34
+ };
35
+ 'api.sort_not_last': {
36
+ code: "invalid_query";
37
+ ru: string;
38
+ };
39
+ 'api.with_deleted_step': {
40
+ code: "invalid_query";
41
+ ru: string;
42
+ };
43
+ 'api.db_bad_value': {
44
+ code: "invalid_query";
45
+ ru: string;
46
+ hint: string;
47
+ };
48
+ 'api.validation_issue': {
49
+ code: "invalid_data";
50
+ ru: string;
51
+ };
52
+ 'api.validation_root': {
53
+ code: "invalid_data";
54
+ ru: string;
55
+ };
56
+ 'api.validation_more': {
57
+ code: "invalid_data";
58
+ ru: string;
9
59
  };
10
60
  'api.connect_schema': {
11
61
  code: "invalid_data";
@@ -49,6 +99,7 @@ export declare const api: {
49
99
  'api.for_update_unsupported': {
50
100
  code: "lock_unsupported";
51
101
  ru: string;
102
+ hint: string;
52
103
  };
53
104
  'api.lock_denied': {
54
105
  code: "acl_denied";
@@ -119,6 +170,16 @@ export declare const api: {
119
170
  ru: string;
120
171
  hint: string;
121
172
  };
173
+ 'api.begin_arg': {
174
+ code: "invalid_query";
175
+ ru: string;
176
+ hint: string;
177
+ };
178
+ 'api.closed': {
179
+ code: "invalid_query";
180
+ ru: string;
181
+ hint: string;
182
+ };
122
183
  'api.nested_begin': {
123
184
  code: "invalid_query";
124
185
  ru: string;
@@ -132,6 +193,11 @@ export declare const api: {
132
193
  ru: string;
133
194
  hint: string;
134
195
  };
196
+ 'api.connect_empty_credential': {
197
+ code: "invalid_data";
198
+ ru: string;
199
+ hint: string;
200
+ };
135
201
  'api.connect_admin_type': {
136
202
  code: "invalid_data";
137
203
  ru: string;
@@ -168,4 +234,14 @@ export declare const api: {
168
234
  code: "invalid_data";
169
235
  ru: string;
170
236
  };
237
+ 'api.create_tenant_admin': {
238
+ code: "invalid_query";
239
+ ru: string;
240
+ hint: string;
241
+ };
242
+ 'api.versions_terminal': {
243
+ code: "invalid_query";
244
+ ru: string;
245
+ hint: string;
246
+ };
171
247
  };
@@ -8,6 +8,74 @@ export const api = {
8
8
  'api.unknown_step': {
9
9
  code: 'invalid_class',
10
10
  ru: 'класс или роль {name} не найдены',
11
+ hint: 'классы арендатора видны только в его сессии: арендаторы пользователя — db.auth.tenants(), переход — db.auth.switch(tenant) или db.as(аккаунт, { tenant }); имя — как в модели (классы записывает syncModels)',
12
+ },
13
+ // chain.ts: шаг не найден, а в реестре сессии нет ни одного класса, кроме классов System — сессия, похоже, в
14
+ // личном арендаторе (вход без switch); похожие имена — params.similar
15
+ // index.ts: connect({ token }) — токен не даёт сессии (погашен, истёк, неверен); chain.ts: шаг корня без сессии
16
+ // (сессия погасла после connect) — отказ терминала, а не синхронный
17
+ 'api.token_not_active': {
18
+ code: 'no_session',
19
+ ru: 'нет действующей сессии: токен погашен, истёк или неверен',
20
+ hint: 'войти заново и подключиться с новым токеном; веб-сервису — одно подключение сервиса и svc.as(account, { tenant }) на запрос, а не connect({ token }) на каждый вход',
21
+ },
22
+ 'api.unknown_step_no_classes': {
23
+ code: 'invalid_class',
24
+ ru: 'класс или роль {name} не найдены: в реестре сессии нет ни одного класса арендатора — сессия, похоже, в личном арендаторе',
25
+ hint: 'после входа сессия работает в личном арендаторе пользователя: найти нужного — db.auth.tenants(), перейти — db.auth.switch(tenant) или db.as(аккаунт, { tenant }); классы в арендатор записывает syncModels',
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
+ },
34
+ // chain.ts: у цепочки спросили свойство массива (length, map, filter, 0 …) или перебрали её — она не массив строк
35
+ 'api.chain_not_rows': {
36
+ code: 'invalid_query',
37
+ ru: 'цепочка — не массив: {name} есть у строк ответа, а не у цепочки',
38
+ hint: 'получить строки терминалом с await: const rows = await db.Класс(…).rows(), одну строку — await ….first()',
39
+ },
40
+ // chain.ts, A13: шаг вызван с аргументом undefined — фильтра нет, шаг находит все строки класса (как без аргумента);
41
+ // предупреждение (lwarn), один раз на имя шага
42
+ 'api.step_undefined_filter': {
43
+ code: 'invalid_query',
44
+ ru: '{name}(undefined) — фильтра нет: шаг находит все строки, как {name}(). Проверьте переменную с id',
45
+ },
46
+ // chain.ts, A13: шаг после sort() — сортировка относится к последнему шагу цепочки, а не к тому, после которого
47
+ // стоит; предупреждение (lwarn), один раз на пару
48
+ 'api.sort_not_last': {
49
+ code: 'invalid_query',
50
+ ru: "sort('{field}') перед шагом {step} сортирует строки последнего шага ({step}), а не прежнего: ставьте sort() в конец цепочки",
51
+ },
52
+ // chain.ts, A9: withDeleted() не у последнего шага, а у последнего — нет; предупреждение (lwarn), один раз на пару:
53
+ // с 2.1 withDeleted() — модификатор своего шага
54
+ 'api.with_deleted_step': {
55
+ code: 'invalid_query',
56
+ ru: 'withDeleted() у шага {step} относится к нему, а не к последнему шагу {last}: удалённые {last} — .{last}().withDeleted()',
57
+ },
58
+ // errors.ts, fromDbError: подстраховка — значение не того типа (SQLSTATE 22P02), которое не поймала проверка до
59
+ // запроса; text — текст сервера
60
+ 'api.db_bad_value': {
61
+ code: 'invalid_query',
62
+ ru: 'база не приняла значение: оно не того типа, что поле или параметр запроса (ответ PostgreSQL: {text})',
63
+ hint: 'передавайте id строкой uuid, числа — числами, даты — строками ISO 8601; проверьте значения фильтра, курсора, опций и параметров db.sql и повторите запрос',
64
+ },
65
+ // errors.ts, ValidationError: первое нарушение в тексте ошибки — путь и сообщение; текст не для ошибки (lmsg)
66
+ 'api.validation_issue': {
67
+ code: 'invalid_data',
68
+ ru: '{path} — {issue}',
69
+ },
70
+ // errors.ts, ValidationError: путь нарушения на верхнем уровне данных (пустой путь); текст не для ошибки (lmsg)
71
+ 'api.validation_root': {
72
+ code: 'invalid_data',
73
+ ru: 'данные',
74
+ },
75
+ // errors.ts, ValidationError: число нарушений, если их больше одного; текст не для ошибки (lmsg)
76
+ 'api.validation_more': {
77
+ code: 'invalid_data',
78
+ ru: ' (всего нарушений: {n}, все — err.issues)',
11
79
  },
12
80
  // index.ts: connect({ schema })
13
81
  'api.connect_schema': {
@@ -61,9 +129,11 @@ export const api = {
61
129
  ru: 'forUpdate() — только внутри db.begin(): вне явной транзакции блокировка снялась бы сразу',
62
130
  },
63
131
  // chain.ts, lockedRead: forUpdate() с другим терминалом, агрегатом, срезом, глаголом
132
+ // chain.ts, lockedRead: what — что мешает блокировке (терминал, asOf(), withDeleted(), deep(), глагол)
64
133
  'api.for_update_unsupported': {
65
134
  code: 'lock_unsupported',
66
- ru: 'forUpdate() — с терминалами rows, first, ids, без агрегатов, asOf, versions, withDeleted, deep и глаголов',
135
+ ru: 'forUpdate() нельзя с {what}: блокировка — только у чтения rows(), first(), ids()',
136
+ hint: 'forUpdate() — в db.begin(), перед rows(), first() или ids(); без asOf(), versions(), withDeleted(), deep(), count(), paths(), агрегатов и глаголов. Правка — отдельной цепочкой после чтения: await tr.X(id).forUpdate().first(); await tr.X(id).update(…).rows()',
67
137
  },
68
138
  // chain.ts, lockedRead: строки видны, но политика правки не даёт их заблокировать; ids — через запятую (detail.ids — списком)
69
139
  'api.lock_denied': {
@@ -73,27 +143,27 @@ export const api = {
73
143
  // chain.ts, slotValue: значение .Роль.set(…), .add(…), .remove(…)
74
144
  'api.slot_value': {
75
145
  code: 'invalid_query',
76
- ru: 'значение слота — id, список id, строка ответа или цепочка',
146
+ ru: 'значение шага роли (.Роль.set(), .add(), .remove()) — id, список id, строка ответа или цепочка',
77
147
  },
78
148
  // chain.ts: слот не сразу после create(), update() или upsert(); name — роль, как вызвана, op — set, unset, add, remove
79
149
  'api.slot_without_write': {
80
150
  code: 'invalid_query',
81
- ru: 'слот .{name}.{op}() — после create(), update() или upsert(): db.Класс(…).update(…).{name}.{op}(…)',
151
+ ru: 'шаг роли .{name}.{op}() — после create(), update() или upsert(): db.Класс(…).update(…).{name}.{op}(…)',
82
152
  },
83
153
  // chain.ts: add() или remove() у одиночного конца
84
154
  'api.slot_single': {
85
155
  code: 'invalid_query',
86
- ru: 'слот .{name}.{op}(): конец {role} одиночный — set() или unset()',
156
+ ru: 'шаг роли .{name}.{op}(): конец {role} одиночный — set() или unset()',
87
157
  },
88
158
  // chain.ts: unset() у обязательного конца
89
159
  'api.slot_required': {
90
160
  code: 'invalid_query',
91
- ru: 'слот .{name}.unset(): конец {role} обязателен',
161
+ ru: 'шаг роли .{name}.unset(): конец {role} обязателен',
92
162
  },
93
163
  // chain.ts: set() или unset() одного конца дважды в одной записи
94
164
  'api.slot_twice': {
95
165
  code: 'invalid_query',
96
- ru: 'слот {role} задан дважды в одной записи',
166
+ ru: 'конец {role} задан дважды в одной записи',
97
167
  },
98
168
  // chain.ts, withLast: deep(), exact(), alias(), tags(), owner() сразу после глагола; verb — глагол
99
169
  'api.step_mod_after_verb': {
@@ -118,7 +188,7 @@ export const api = {
118
188
  // chain.ts: set, unset, add, remove у цепочки, а не у слота роли; method — имя метода
119
189
  'api.slot_method': {
120
190
  code: 'invalid_query',
121
- ru: '{method}() — метод слота: db.Класс(…).update(…).Роль.{method}(…)',
191
+ ru: '{method}() — метод шага роли после глагола: db.Класс(…).update(…).Роль.{method}(…)',
122
192
  },
123
193
  // chain.ts: cache({ ttl })
124
194
  'api.cache_ttl': {
@@ -152,6 +222,19 @@ export const api = {
152
222
  hint: 'повторить вызов: между запросами db.as() сессия сменила арендатора или кончилась',
153
223
  },
154
224
  // index.ts: db.begin() внутри db.begin()
225
+ // index.ts: db.begin(аргумент) — не объект опций (функция, строка, число); функцию begin() не вызывает; type — typeof
226
+ // аргумента (array — массив)
227
+ 'api.begin_arg': {
228
+ code: 'invalid_query',
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({ … })',
237
+ },
155
238
  'api.nested_begin': {
156
239
  code: 'invalid_query',
157
240
  ru: 'вложенных транзакций нет: db.begin() внутри db.begin()',
@@ -167,6 +250,12 @@ export const api = {
167
250
  ru: 'connect: нужен dsn или sql',
168
251
  hint: 'передать dsn — строку подключения — или sql — готовый пул postgres.js',
169
252
  },
253
+ // index.ts: connect({ apiKey | service | token }) передан, но пуст (null, '', не строка) — опция есть, а значения нет
254
+ 'api.connect_empty_credential': {
255
+ code: 'invalid_data',
256
+ ru: 'connect: {name} передан, но пуст ({value}) — подключение без сессии не открывается',
257
+ hint: 'передать ключ сервиса или токен строкой (process.env.SERVICE_KEY не задана?) — либо убрать опцию, если сессия не нужна; ключ сервиса up() выдаёт один раз — храните его сами',
258
+ },
170
259
  // index.ts: connect({ admin }) — не строка и не пул
171
260
  'api.connect_admin_type': {
172
261
  code: 'invalid_data',
@@ -212,4 +301,17 @@ export const api = {
212
301
  code: 'invalid_data',
213
302
  ru: 'движок схемы {schema} ревизии {rev} новее библиотеки (она знает {known}) — обновите пакет letopis',
214
303
  },
304
+ // up.ts, createTenant(): первый аргумент — не пул postgres() (строка DSN, подключение connect(), объект без begin и
305
+ // unsafe); type — typeof значения или null
306
+ 'api.create_tenant_admin': {
307
+ code: 'invalid_query',
308
+ ru: 'createTenant(admin, …): admin — пул postgres() с правами администратора, а не {type}',
309
+ hint: "const admin = postgres(dsnАдминистратора); await createTenant(admin, 'salon', { name }); await admin.end(). DSN-строку и подключение connect() letopis createTenant не принимает",
310
+ },
311
+ // chain.ts, versions(): у результата versions() вызвали метод цепочки (rows, first, sort…); method — его имя
312
+ 'api.versions_terminal': {
313
+ code: 'invalid_query',
314
+ ru: 'versions() — терминал: отдаёт строки версий, у результата нет .{method}()',
315
+ hint: "const v = await db.Класс(id).versions(); модификаторы — до versions(): db.Класс(id).sort('at', 'desc').limit(10).versions()",
316
+ },
215
317
  };
@@ -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;
@@ -3,7 +3,7 @@ const LOGIN = 'передать login: { user, password } — имя обычн
3
3
  /** Подсказка отказов user.roles в createTenant() и up({ tenant }). */
4
4
  const ROLES = "передать user.roles — список разных имён ролей членства, например ['staff'], или не передавать: по умолчанию ['owner']";
5
5
  /** Подсказка отказов createTenant() об удалённой строке. */
6
- const GONE = 'вернуть удалённое restore() (текст называет, кто это может) или выбрать другое имя или логин: id вычисляется из них';
6
+ const GONE = 'вернуть удалённое restore({ expect }) — надгробие из withDeleted().rows() (текст называет, кто это может) или выбрать другое имя или логин: id вычисляется из них';
7
7
  export const boot = {
8
8
  // up.ts, substitute(): в тексте SQL (ядро, миграция, свой сид) маркер, которого установщик не знает
9
9
  'boot.unknown_marker': {
@@ -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',
@@ -123,19 +130,19 @@ export const boot = {
123
130
  // up.ts, createTenant(): строки арендатора нет, а его история в журнале есть
124
131
  'boot.tenant_deleted': {
125
132
  code: 'invalid_data',
126
- ru: 'createTenant: арендатор «{name}» ({id}) удалён, а его история осталась в журнале — повтор удалённое не воскрешает: верните его db.Account(id).restore() от имени системного администратора или выберите другое имя',
133
+ ru: 'createTenant: арендатор «{name}» ({id}) удалён, а его история осталась в журнале — повтор удалённое не воскрешает: верните его db.Account(id).restore({ expect: await db.Account(id).withDeleted().rows() }) от имени системного администратора или выберите другое имя',
127
134
  hint: GONE,
128
135
  },
129
136
  // up.ts, createTenant(): строки аккаунта пользователя нет, а его история в журнале есть
130
137
  'boot.tenant_user_deleted': {
131
138
  code: 'invalid_data',
132
- ru: 'createTenant: аккаунт пользователя {login} ({id}) удалён, а его история осталась в журнале — повтор удалённое не воскрешает: верните его db.Account(id).restore() от имени системного администратора или выберите другой логин',
139
+ ru: 'createTenant: аккаунт пользователя {login} ({id}) удалён, а его история осталась в журнале — повтор удалённое не воскрешает: верните его db.Account(id).restore({ expect: await db.Account(id).withDeleted().rows() }) от имени системного администратора или выберите другой логин',
133
140
  hint: GONE,
134
141
  },
135
142
  // up.ts, createTenant(): строки членства нет, а его история в журнале есть
136
143
  'boot.tenant_member_deleted': {
137
144
  code: 'invalid_data',
138
- ru: 'createTenant: членство {login} в арендаторе «{name}» ({id}) удалено, а его история осталась в журнале — повтор удалённое не воскрешает: верните его db.member(id).restore() в этом арендаторе или выберите другой логин',
145
+ ru: 'createTenant: членство {login} в арендаторе «{name}» ({id}) удалено, а его история осталась в журнале — повтор удалённое не воскрешает: верните его db.member(id).restore({ expect: await db.member(id).withDeleted().rows() }) в этом арендаторе или выберите другой логин',
139
146
  hint: GONE,
140
147
  },
141
148
  // up.ts, refuse1x(): схема letopis 1.x — ревизия из комментария схемы ниже первой ревизии 2.0 (FIRST_REVISION) или
@@ -27,7 +27,7 @@ export const codes = {
27
27
  'codes.acl_denied': {
28
28
  code: 'acl_denied',
29
29
  ru: 'правила доступа запрещают это действие',
30
- hint: 'добавить правило доступа (`Rule`) или выполнить действие от пользователя, которому оно разрешено; проверить заранее — `db.acl.checkData`, `db.acl.check`',
30
+ hint: 'выдать право `db.acl.grant` (правила — класс `rule`) или выполнить действие от пользователя, которому оно разрешено; проверить заранее — `db.acl.checkData`, `db.acl.check`',
31
31
  },
32
32
  'codes.id_mismatch': {
33
33
  code: 'id_mismatch',
@@ -72,7 +72,7 @@ export const codes = {
72
72
  'codes.not_deleted': {
73
73
  code: 'not_deleted',
74
74
  ru: '`restore` работает только для удалённых объектов',
75
- hint: 'сначала `delete()`, потом `restore()`',
75
+ hint: 'сначала `delete({ confirm: true, expect })`, потом `restore({ expect })` с надгробиями из ответа `delete()` или `withDeleted().rows()`',
76
76
  },
77
77
  'codes.exists': {
78
78
  code: 'exists',
@@ -91,8 +91,8 @@ export const codes = {
91
91
  },
92
92
  'codes.conflict': {
93
93
  code: 'conflict',
94
- ru: '`update` с `{ rev }`: строку уже изменили после того, как вы её прочли',
95
- hint: 'перечитать строку и повторить правку с новым `rev`; для чисел — `inc` вместо записи итога',
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',
@@ -9,7 +9,7 @@
9
9
  * 1. Раздел — по файлу, где брошена ошибка:
10
10
  * api — index, chain, typed, errors, internal; write — write, tx, load; query — sql, ops;
11
11
  * time — watch, admin, cache, signals; access — auth, acl; model — model, registry, sync, cli, validate,
12
- * jsonschema, uuid; boot — up, pglite; mcp — mcp, describe;
12
+ * jsonschema, uuid; boot — up, pglite; mcp — mcp, describe; log — log (и тексты логирования из index, tx);
13
13
  * sql-core — sql/10, 15, 20, 30, 40; sql-write — sql/60; sql-access — sql/50, 80;
14
14
  * sql-time — sql/70, 90, 95, 99 и sql/migrate.
15
15
  * codes.ts — запись на каждый код: заголовок и подсказка. Новый код — ещё ErrorCode (types.ts), для кода базы —
@@ -56,6 +56,7 @@ import { access } from './access.js';
56
56
  import { api } from './api.js';
57
57
  import { boot } from './boot.js';
58
58
  import { codes } from './codes.js';
59
+ import { log } from './log.js';
59
60
  import { mcp } from './mcp.js';
60
61
  import { model } from './model.js';
61
62
  import { query } from './query.js';
@@ -67,9 +68,11 @@ import { time } from './time.js';
67
68
  import { write } from './write.js';
68
69
  import type { ErrorCode, Msg, Params } from './types.js';
69
70
  export type { ErrorCode, Msg, Params, Section } from './types.js';
71
+ /** Каталог событий логирования (не сообщения об ошибках — в MESSAGES не входит). */
72
+ export { LOG_EVENTS, type LogEventInfo, type LogEventLevel, type LogEventName } from './log.js';
70
73
  /** Разделы, которые установщик кладёт в базу (message_catalog() в lib/sql/15-errors.sql). */
71
74
  export declare const SQL_SECTIONS: readonly ["sql-core", "sql-write", "sql-access", "sql-time"];
72
- type All = typeof codes & typeof api & typeof write & typeof query & typeof time & typeof access & typeof model & typeof boot & typeof mcp & typeof sqlCore & typeof sqlWrite & typeof sqlAccess & typeof sqlTime;
75
+ type All = typeof codes & typeof api & typeof write & typeof query & typeof time & typeof access & typeof model & typeof boot & typeof mcp & typeof log & typeof sqlCore & typeof sqlWrite & typeof sqlAccess & typeof sqlTime;
73
76
  /** Ключ каталога: опечатку в lerr('…') ловит проверка типов. */
74
77
  export type MsgKey = keyof All;
75
78
  /** Все записи каталога по ключу. */
@@ -9,7 +9,7 @@
9
9
  * 1. Раздел — по файлу, где брошена ошибка:
10
10
  * api — index, chain, typed, errors, internal; write — write, tx, load; query — sql, ops;
11
11
  * time — watch, admin, cache, signals; access — auth, acl; model — model, registry, sync, cli, validate,
12
- * jsonschema, uuid; boot — up, pglite; mcp — mcp, describe;
12
+ * jsonschema, uuid; boot — up, pglite; mcp — mcp, describe; log — log (и тексты логирования из index, tx);
13
13
  * sql-core — sql/10, 15, 20, 30, 40; sql-write — sql/60; sql-access — sql/50, 80;
14
14
  * sql-time — sql/70, 90, 95, 99 и sql/migrate.
15
15
  * codes.ts — запись на каждый код: заголовок и подсказка. Новый код — ещё ErrorCode (types.ts), для кода базы —
@@ -56,6 +56,7 @@ import { access } from './access.js';
56
56
  import { api } from './api.js';
57
57
  import { boot } from './boot.js';
58
58
  import { codes } from './codes.js';
59
+ import { log } from './log.js';
59
60
  import { mcp } from './mcp.js';
60
61
  import { model } from './model.js';
61
62
  import { query } from './query.js';
@@ -65,9 +66,11 @@ import { sqlTime } from './sql-time.js';
65
66
  import { sqlWrite } from './sql-write.js';
66
67
  import { time } from './time.js';
67
68
  import { write } from './write.js';
69
+ /** Каталог событий логирования (не сообщения об ошибках — в MESSAGES не входит). */
70
+ export { LOG_EVENTS } from './log.js';
68
71
  /** Разделы каталога: имя — префикс ключа и имя файла. */
69
72
  const SECTIONS = {
70
- codes, api, write, query, time, access, model, boot, mcp,
73
+ codes, api, write, query, time, access, model, boot, mcp, log,
71
74
  'sql-core': sqlCore, 'sql-write': sqlWrite, 'sql-access': sqlAccess, 'sql-time': sqlTime,
72
75
  };
73
76
  /** Разделы, которые установщик кладёт в базу (message_catalog() в lib/sql/15-errors.sql). */