letopis 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/AGENT-CHEATSHEET.md +484 -414
  2. package/CHANGELOG.md +321 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +786 -183
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +3 -0
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +2 -1
  10. package/dist/admin.js +6 -4
  11. package/dist/auth.d.ts +18 -3
  12. package/dist/auth.js +75 -6
  13. package/dist/chain.d.ts +38 -17
  14. package/dist/chain.js +434 -63
  15. package/dist/cli.js +28 -6
  16. package/dist/errors.d.ts +19 -1
  17. package/dist/errors.js +46 -6
  18. package/dist/index.d.ts +10 -4
  19. package/dist/index.js +136 -46
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +9 -5
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +633 -0
  25. package/dist/mcp.js +26 -11
  26. package/dist/messages/api.d.ts +66 -0
  27. package/dist/messages/api.js +90 -1
  28. package/dist/messages/boot.js +4 -4
  29. package/dist/messages/codes.js +4 -4
  30. package/dist/messages/index.d.ts +5 -2
  31. package/dist/messages/index.js +5 -2
  32. package/dist/messages/log.d.ts +229 -0
  33. package/dist/messages/log.js +102 -0
  34. package/dist/messages/model.d.ts +24 -1
  35. package/dist/messages/model.js +34 -3
  36. package/dist/messages/query.d.ts +10 -0
  37. package/dist/messages/query.js +13 -0
  38. package/dist/messages/sql-core.d.ts +15 -1
  39. package/dist/messages/sql-core.js +20 -3
  40. package/dist/messages/sql-time.d.ts +15 -0
  41. package/dist/messages/sql-time.js +23 -0
  42. package/dist/messages/write.d.ts +47 -0
  43. package/dist/messages/write.js +63 -0
  44. package/dist/model.d.ts +4 -0
  45. package/dist/model.js +23 -5
  46. package/dist/sql.d.ts +2 -0
  47. package/dist/sql.js +77 -25
  48. package/dist/sync.d.ts +1 -1
  49. package/dist/sync.js +50 -0
  50. package/dist/tx.d.ts +10 -1
  51. package/dist/tx.js +44 -2
  52. package/dist/typed.d.ts +14 -10
  53. package/dist/types.d.ts +82 -4
  54. package/dist/types.js +6 -1
  55. package/dist/up.d.ts +3 -3
  56. package/dist/up.js +57 -17
  57. package/dist/uuid.d.ts +8 -0
  58. package/dist/uuid.js +9 -1
  59. package/dist/validate.js +24 -7
  60. package/dist/watch.d.ts +5 -0
  61. package/dist/watch.js +13 -0
  62. package/dist/write.d.ts +78 -1
  63. package/dist/write.js +357 -39
  64. package/llms.txt +37 -17
  65. package/package.json +12 -6
  66. package/sql/20-context.sql +11 -3
  67. package/sql/30-validate.sql +51 -10
  68. package/sql/40-class.sql +21 -1
  69. package/sql/80-auth.sql +88 -17
  70. package/sql/90-time.sql +34 -6
  71. package/sql/99-revision.sql +5 -1
  72. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  73. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  74. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  75. package/sql/migrate/README.md +5 -1
package/dist/cli.js CHANGED
@@ -13,6 +13,8 @@
13
13
  * letopis maintain --dsn … --schema v2.x [--token … | --api-key …]
14
14
  * letopis mcp --dsn … --schema v2.x --token … [--write] — сервер MCP по stdio (план 1.1, §2.5); dsn, schema и
15
15
  * token — ещё из LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; только токен пользователя: --api-key — отказ
16
+ * --log[=уровень] — у любой команды (логирование 3.0): события JSON-строками в stderr, уровень debug|info|warn|error|off,
17
+ * без значения — info; без флага действует LETOPIS_LOG
16
18
  * справка — USAGE (её же читает справочник сайта, guide/scripts/gen-api.ts)
17
19
  */
18
20
  import { parseArgs } from 'node:util';
@@ -23,13 +25,15 @@ import { resolve } from 'node:path';
23
25
  import { connect } from './index.js';
24
26
  import { syncModels, generateTypes } from './sync.js';
25
27
  import { lerr, lmsg } from './errors.js';
28
+ import { stderrLog } from './log.js';
26
29
  const USAGE = `letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply | --check]
27
30
  letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>] [--system]
28
31
  letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
29
32
  [--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--initial]
30
33
  letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>]
31
34
  letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
32
- (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)`;
35
+ (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
36
+ у любой команды: --log[=debug|info|warn|error|off] — события JSON-строками в stderr (без значения — info)`;
33
37
  /** Команды CLI: остальное — USAGE с кодом 2 ещё до подключения. */
34
38
  const COMMANDS = new Set(['sync', 'types', 'load', 'maintain', 'mcp']);
35
39
  const isModel = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function' && typeof v.name === 'string';
@@ -38,9 +42,27 @@ async function loadModels(path) {
38
42
  const mod = await import(pathToFileURL(resolve(path)).href);
39
43
  return [...new Set(Object.values(mod).flatMap((v) => (Array.isArray(v) ? v : [v])).filter(isModel))];
40
44
  }
45
+ /**
46
+ * Флаг --log[=уровень] (логирование 3.0): вынимается из аргументов до parseArgs — у строкового флага parseArgs требует
47
+ * значение, а --log без него значит info.
48
+ */
49
+ function logFlag(argv) {
50
+ let level;
51
+ const args = [];
52
+ for (const a of argv) {
53
+ if (a === '--log')
54
+ level = 'info';
55
+ else if (a.startsWith('--log='))
56
+ level = (a.slice('--log='.length) || 'info');
57
+ else
58
+ args.push(a);
59
+ }
60
+ return { args, log: level === undefined ? {} : { log: stderrLog, logLevel: level } };
61
+ }
41
62
  export async function main(argv) {
63
+ const { args, log } = logFlag(argv);
42
64
  const { values, positionals } = parseArgs({
43
- args: argv,
65
+ args,
44
66
  allowPositionals: true,
45
67
  options: {
46
68
  dsn: { type: 'string' }, schema: { type: 'string' }, token: { type: 'string' }, 'api-key': { type: 'string' },
@@ -67,9 +89,9 @@ export async function main(argv) {
67
89
  return cmd ? 2 : 0;
68
90
  }
69
91
  if (cmd === 'mcp')
70
- return mcp(values);
92
+ return mcp(values, log);
71
93
  // загрузчику нужно администраторское подключение (§2.6, п. 1)
72
- const db = await connect({ dsn: values.dsn, schema: values.schema, token: values.token, apiKey: values['api-key'], max: 2,
94
+ const db = await connect({ dsn: values.dsn, schema: values.schema, token: values.token, apiKey: values['api-key'], max: 2, ...log,
73
95
  // maintain работает и на администраторском подключении без сессии; классы со строками синхронизирует
74
96
  // администраторское подключение с сессией арендатора
75
97
  ...(cmd === 'load' || cmd === 'maintain' || cmd === 'sync' ? { allowBypassRls: true } : {}) });
@@ -145,7 +167,7 @@ export async function main(argv) {
145
167
  * грузятся только здесь: letopis --help и прочие команды работают без SDK. В stdout — только протокол (предупреждения
146
168
  * connect() идут в stderr); процесс живёт, пока клиент не закроет stdin.
147
169
  */
148
- async function mcp(v) {
170
+ async function mcp(v, log = {}) {
149
171
  if (v['api-key'] !== undefined) {
150
172
  console.error(lerr('model.mcp_api_key_refused').message);
151
173
  return 2;
@@ -155,7 +177,7 @@ async function mcp(v) {
155
177
  return 2;
156
178
  }
157
179
  const { createMcpServer } = await import('./mcp.js');
158
- const db = await connect({ dsn: v.dsn, schema: v.schema, token: v.token, max: 4 });
180
+ const db = await connect({ dsn: v.dsn, schema: v.schema, token: v.token, max: 4, ...log });
159
181
  try {
160
182
  const opts = { write: v.write === true };
161
183
  // учётные данные — до начала протокола: отказ виден кодом выхода и текстом в stderr
package/dist/errors.d.ts CHANGED
@@ -8,13 +8,26 @@ export interface Issue {
8
8
  }
9
9
  /** Текст записи каталога с параметрами (params.similar — «— похожие: …»): сообщения нарушений, описания, отчёты CLI. */
10
10
  export declare function lmsg(key: MsgKey, params?: Params): string;
11
+ /** Логгер, в который уходит предупреждение (lib/src/log.ts, Log): только то, что нужно lwarn. */
12
+ interface WarnSink {
13
+ readonly active: boolean;
14
+ warning(key: string | undefined, msg: string, detail?: Record<string, unknown>): void;
15
+ }
16
+ export declare function setEnvWarnSink(fn: () => WarnSink): void;
11
17
  /**
12
18
  * Предупреждение по записи каталога — «letopis: <текст>» в console.warn; once — ключ «один раз на процесс». Неизвестное
13
19
  * поле фильтра, sort() и агрегата с 2.0 — уже не предупреждение, а отказ query.unknown_field (sql.ts, checkField).
20
+ * Логирование 3.0: задан логгер (opts.log подключения или LETOPIS_LOG) — событие warning в него вместо console.warn.
14
21
  */
15
22
  export declare function lwarn(key: MsgKey, params?: Params, opts?: {
16
23
  once?: string;
24
+ log?: WarnSink;
17
25
  }): void;
26
+ /**
27
+ * Готовый текст предупреждения: в логгер (событие warning с ключом и параметрами), если он задан, иначе console.warn.
28
+ * Ошибка логгера предупреждение не теряет и операцию не прерывает.
29
+ */
30
+ export declare function warnText(text: string, key?: string, log?: WarnSink, params?: Params): void;
18
31
  export declare class LetopisError extends Error {
19
32
  readonly code: string;
20
33
  readonly detail: unknown;
@@ -46,7 +59,12 @@ export declare function similar(name: string, candidates: Iterable<string>, n?:
46
59
  * 7.9 №6). known — все ключи опций: объект `satisfies Record<keyof Опции, true>`, пропуск ключа — ошибка типов.
47
60
  */
48
61
  export declare function checkOptions(where: string, opts: unknown, known: Readonly<Record<string, true>>): void;
49
- /** Данные не проходят описание класса (`invalid_data`); текст — по ключу (по умолчанию api.validation_failed). */
62
+ /**
63
+ * Первое нарушение для текста ошибки (план 2.1, A8): «: <путь> — <сообщение>», при нескольких — ещё их число. Полный
64
+ * список — err.issues.
65
+ */
66
+ export declare function issuesSuffix(issues: readonly Issue[]): string;
67
+ /** Данные не проходят описание класса (`invalid_data`); текст — по ключу (по умолчанию api.validation_failed) и первое нарушение. */
50
68
  export declare class ValidationError extends LetopisError {
51
69
  readonly issues: Issue[];
52
70
  constructor(cls: string, issues: Issue[], msg?: {
package/dist/errors.js CHANGED
@@ -30,9 +30,15 @@ export function lmsg(key, params) {
30
30
  return textOf(key, params);
31
31
  }
32
32
  const warned = new Set();
33
+ /** Логгер по LETOPIS_LOG для предупреждений без подключения — ставит log.ts (без цикла импорта значений). */
34
+ let envSink = null;
35
+ export function setEnvWarnSink(fn) {
36
+ envSink = fn;
37
+ }
33
38
  /**
34
39
  * Предупреждение по записи каталога — «letopis: <текст>» в console.warn; once — ключ «один раз на процесс». Неизвестное
35
40
  * поле фильтра, sort() и агрегата с 2.0 — уже не предупреждение, а отказ query.unknown_field (sql.ts, checkField).
41
+ * Логирование 3.0: задан логгер (opts.log подключения или LETOPIS_LOG) — событие warning в него вместо console.warn.
36
42
  */
37
43
  export function lwarn(key, params, opts = {}) {
38
44
  if (opts.once !== undefined) {
@@ -42,7 +48,24 @@ export function lwarn(key, params, opts = {}) {
42
48
  warned.clear();
43
49
  warned.add(opts.once);
44
50
  }
45
- console.warn(`letopis: ${textOf(key, params)}`);
51
+ warnText(`letopis: ${textOf(key, params)}`, key, opts.log, params);
52
+ }
53
+ /**
54
+ * Готовый текст предупреждения: в логгер (событие warning с ключом и параметрами), если он задан, иначе console.warn.
55
+ * Ошибка логгера предупреждение не теряет и операцию не прерывает.
56
+ */
57
+ export function warnText(text, key, log, params) {
58
+ const sink = log?.active ? log : envSink?.();
59
+ if (sink?.active) {
60
+ try {
61
+ sink.warning(key, text, params && Object.keys(params).length ? { ...params } : undefined);
62
+ return;
63
+ }
64
+ catch {
65
+ // логгер не принял — как без него
66
+ }
67
+ }
68
+ console.warn(text);
46
69
  }
47
70
  export class LetopisError extends Error {
48
71
  code;
@@ -120,12 +143,24 @@ export function checkOptions(where, opts, known) {
120
143
  throw lerr('api.unknown_option', { where, name: k, similar: similar(k, Object.keys(known)) });
121
144
  }
122
145
  }
123
- /** Данные не проходят описание класса (`invalid_data`); текст — по ключу (по умолчанию api.validation_failed). */
146
+ /**
147
+ * Первое нарушение для текста ошибки (план 2.1, A8): «: <путь> — <сообщение>», при нескольких — ещё их число. Полный
148
+ * список — err.issues.
149
+ */
150
+ export function issuesSuffix(issues) {
151
+ const first = issues[0];
152
+ if (!first)
153
+ return '';
154
+ const path = Array.isArray(first.path) && first.path.length ? first.path.join('.') : textOf('api.validation_root', undefined);
155
+ const more = issues.length > 1 ? textOf('api.validation_more', { n: issues.length }) : '';
156
+ return `: ${textOf('api.validation_issue', { path, issue: first.message })}${more}`;
157
+ }
158
+ /** Данные не проходят описание класса (`invalid_data`); текст — по ключу (по умолчанию api.validation_failed) и первое нарушение. */
124
159
  export class ValidationError extends LetopisError {
125
160
  issues;
126
161
  constructor(cls, issues, msg) {
127
162
  const m = msg ?? { key: 'api.validation_failed', params: { cls } };
128
- super('invalid_data', textOf(m.key, m.params), { issues }, m);
163
+ super('invalid_data', textOf(m.key, m.params) + issuesSuffix(issues), { issues }, m);
129
164
  this.name = 'ValidationError';
130
165
  this.issues = issues;
131
166
  }
@@ -192,6 +227,10 @@ export function fromDbError(e) {
192
227
  if (err.code === '42501' && err.routine === 'ExecWithCheckOptions' && !/USING/.test(err.message ?? '')) {
193
228
  return lerr('api.row_check_denied', undefined, { cause: e });
194
229
  }
230
+ // значение не того типа (22P02: «invalid input syntax for type uuid» и т. п.), которое не поймала проверка до запроса,
231
+ // — ошибка letopis, а не сырая ошибка PostgreSQL (план 2.1, A7); 40001 и 40P01 остаются как есть — на них держатся повторы
232
+ if (err.code === '22P02')
233
+ return lerr('api.db_bad_value', { text: err.message ?? '' }, { cause: e });
195
234
  let name;
196
235
  let detail;
197
236
  if (err.code === 'XX000') {
@@ -225,9 +264,10 @@ export function fromDbError(e) {
225
264
  const issues = detail?.issues;
226
265
  if (name === 'invalid_data' && Array.isArray(issues)) {
227
266
  const cls = msg ? String(msg.params.cls ?? '?') : /класса (\S+)/.exec(text)?.[1] ?? '?';
228
- const v = new ValidationError(cls, issuesFromDb(issues), msg);
229
- // текст целиком — тот, что собрала база по шаблону записи
230
- return Object.assign(v, { cause: e, ...(text ? { message: `letopis: invalid_data: ${text}` } : {}) });
267
+ const list = issuesFromDb(issues);
268
+ const v = new ValidationError(cls, list, msg);
269
+ // текст — тот, что собрала база по шаблону записи, и первое нарушение (план 2.1, A8)
270
+ return Object.assign(v, { cause: e, ...(text ? { message: `letopis: invalid_data: ${text}${issuesSuffix(list)}` } : {}) });
231
271
  }
232
272
  return Object.assign(new LetopisError(name, text, detail, msg), { cause: e });
233
273
  }
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ import { type AuthApi } from './auth.js';
14
14
  import { type AclApi } from './acl.js';
15
15
  import { type LoadInput, type LoadOptions, type LoadReport } from './load.js';
16
16
  import { type Batch, type Chain, type ChainRef, type StepFn } from './chain.js';
17
- import type { QueryEvent, Row } from './types.js';
17
+ import type { LogOptions, QueryEvent, Row } from './types.js';
18
18
  import { type CacheOptions, type CacheStats } from './cache.js';
19
19
  import { Subscription, type WatchOptions } from './watch.js';
20
20
  import { type MaintainReport } from './admin.js';
@@ -34,7 +34,7 @@ export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has,
34
34
  */
35
35
  export declare const or: <F extends object>(...filters: NoInfer<F>[]) => OrOp<F>;
36
36
  export { cursorOf } from './sql.js';
37
- export { inc, isInc, type IncMarker, type WriteRow } from './write.js';
37
+ export { inc, isInc, type DeleteOptions, type Expected, type IncMarker, type RestoreOptions, type WriteRow } from './write.js';
38
38
  export { totpCode, hashPassword, verifyHash, type AuthApi, type LoginResult, type Credential, type SessionInfo } from './auth.js';
39
39
  export type { AclApi, AclDecision, AclDataDecision, AclOp, AclRule, AclRuleSpec, AclTarget } from './acl.js';
40
40
  export type { LoadOptions, LoadReport, LoadIssue, LoadInput } from './load.js';
@@ -47,11 +47,13 @@ export type { WatchOptions, WatchEvent, Subscription } from './watch.js';
47
47
  export type { MaintainReport } from './admin.js';
48
48
  export { up, createTenant, type UpOptions, type UpResult, type TenantOptions, type TenantResult, type LoginOptions } from './up.js';
49
49
  export { RESERVED_CLASS_NAMES, CHAIN_METHODS, DB_METHODS } from './types.js';
50
- export interface ConnectOptions {
50
+ export type { LogEvent, LogFn, LogLevel, LogObject, LogOptions, Logger } from './types.js';
51
+ export { LOG_EVENTS, type LogEventName } from './messages/index.js';
52
+ export interface ConnectOptions extends LogOptions {
51
53
  /** Строка подключения; либо готовый пул postgres.js в `sql`. */
52
54
  dsn?: string;
53
55
  sql?: postgres.Sql;
54
- /** Схема установки, например `v2.salon`. */
56
+ /** Схема установки, например `v2.salon`; имя без точки (`salon`) — как у `up()`: `v2.salon`. */
55
57
  schema: string;
56
58
  /** Токен сессии; либо ключ `APIKEY` в `apiKey` (он же `service` — ключ сервиса) — его обменяют на сессию. */
57
59
  token?: string;
@@ -178,6 +180,10 @@ export interface DbCore {
178
180
  commit(tr?: DbTx): Promise<void>;
179
181
  /** Откатить транзакцию `tr` (то же, что `tr.rollback()`). */
180
182
  rollback(tr?: DbTx): Promise<void>;
183
+ /**
184
+ * Закрыть подключение: у корня — пул и подписки, в `db.begin()` — откат транзакции; у фасада `db.as()` ничего не делает
185
+ * (пул общий с корнем).
186
+ */
181
187
  close(): Promise<void>;
182
188
  }
183
189
  /** Подключение: служебные методы и классы как стартовые шаги цепочек — `db.Класс(фильтр)`. */