letopis 1.4.2 → 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 (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
@@ -0,0 +1,195 @@
1
+ import { accepts } from './sql.js';
2
+ const SYSTEM_CLASSES = new Set(['Class', 'Account', 'member', 'Resource', 'rule']);
3
+ // ---------------------------------------------------------------------------
4
+ // Примеры цепочек (план 2.0, этап 3, п. 7): из реестра — ключ, обязательные поля, концы
5
+ // ---------------------------------------------------------------------------
6
+ /** Ключ объекта без кавычек: буквы, цифры, _ и $, не с цифры (имена классов и ролей такие всегда — метасхема Class). */
7
+ const BARE_KEY = /^[A-Za-zА-Яа-яЁё_$][A-Za-zА-Яа-яЁё0-9_$]*$/;
8
+ const ESCAPES = { '\\': '\\\\', "'": "\\'", '\n': '\\n', '\r': '\\r', '\t': '\\t' };
9
+ /** Значение JSON литералом TypeScript, как в README: строки в одинарных кавычках с экранированием, ключи без кавычек, где можно. */
10
+ function lit(v) {
11
+ if (typeof v === 'string') {
12
+ return `'${v.replace(/[\\'\u0000-\u001f\u2028\u2029]/g, (ch) => ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`)}'`;
13
+ }
14
+ if (Array.isArray(v))
15
+ return `[${v.map(lit).join(', ')}]`;
16
+ if (v !== null && typeof v === 'object') {
17
+ const entries = Object.entries(v);
18
+ return entries.length ? `{ ${entries.map(([k, x]) => `${BARE_KEY.test(k) ? k : lit(k)}: ${lit(x)}`).join(', ')} }` : '{}';
19
+ }
20
+ return typeof v === 'number' && !Number.isFinite(v) ? '0' : String(v);
21
+ }
22
+ /** Место для id объекта класса конца: '<id Org>', у полиморфного конца — '<id Service|Product>'. */
23
+ const idPlace = (cls) => `<id ${(Array.isArray(cls) ? cls : [cls]).join('|')}>`;
24
+ const isSchema = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
25
+ const hasDefault = (v) => isSchema(v) && 'default' in v;
26
+ const SAMPLE_DATE = '2026-01-01';
27
+ /** Образцы строк с форматом — первый, что проходит pattern поля (у z.iso.datetime он задаёт знаки после запятой и пояс). */
28
+ const FORMAT_SAMPLES = {
29
+ 'date-time': ['.000Z', 'Z', '.000000Z', '.0Z', '.00Z', '+00:00', '.000+00:00'].map((tail) => `${SAMPLE_DATE}T00:00:00${tail}`),
30
+ date: [SAMPLE_DATE],
31
+ email: ['user@example.com'],
32
+ uuid: ['00000000-0000-4000-8000-000000000000'],
33
+ };
34
+ /** Строка: образец формата или имя поля — с учётом minLength, maxLength и pattern. */
35
+ function stringSample(s, name) {
36
+ // описание класса задаёт арендатор, а описание модели получает агент через MCP: длина образца ограничена (minLength в
37
+ // миллиард не выделит гигабайтную строку), а свой pattern здесь не исполняется вовсе — шаблон с катастрофическим
38
+ // перебором повесил бы процесс и на коротком образце (ревью безопасности коммитов 2e5e6bc, 1e80cf1). Поле с pattern
39
+ // без examples может получить образец, который проверку не пройдёт, — это оговорено в README
40
+ const SAMPLE = 16;
41
+ const min = typeof s.minLength === 'number' ? Math.min(Math.max(0, s.minLength), SAMPLE) : 0;
42
+ const max = typeof s.maxLength === 'number' ? Math.max(0, s.maxLength) : Infinity;
43
+ const fit = (x) => (x.length < min ? x.padEnd(min, 'x') : x).slice(0, Math.min(max, SAMPLE * 4));
44
+ return fit((FORMAT_SAMPLES[s.format] ?? [name || 'x'])[0]);
45
+ }
46
+ /** Число: 1 в пределах minimum, maximum (и исключающих) и кратное multipleOf; integer — целое. */
47
+ function numberSample(s, integer) {
48
+ const num = (k) => (typeof s[k] === 'number' ? s[k] : undefined);
49
+ const step = num('multipleOf') ?? (integer ? 1 : undefined);
50
+ const [min, xmin, max, xmax] = [num('minimum'), num('exclusiveMinimum'), num('maximum'), num('exclusiveMaximum')];
51
+ let v = 1;
52
+ if (min !== undefined && v < min)
53
+ v = min;
54
+ if (xmin !== undefined && v <= xmin)
55
+ v = xmin + (step ?? 1);
56
+ if (max !== undefined && v > max)
57
+ v = max;
58
+ if (xmax !== undefined && v >= xmax)
59
+ v = xmax - (step ?? 1);
60
+ return step ? Math.ceil(v / step) * step : v;
61
+ }
62
+ /** Поля объекта, которые надо передать: обязательные без умолчания (умолчание подставит база), в порядке описания. */
63
+ function neededFields(s, extra = []) {
64
+ const props = isSchema(s.properties) ? s.properties : {};
65
+ const need = new Set([...(Array.isArray(s.required) ? s.required : []), ...extra]);
66
+ return [...new Set([...Object.keys(props), ...need])].filter((k) => need.has(k) && !hasDefault(props[k]));
67
+ }
68
+ /**
69
+ * Образец значения по JSON Schema: examples[0], const, default, первое из enum, первая ветка anyOf или oneOf, иначе по
70
+ * типу — строка, число, true, массив из minItems образцов, объект из обязательных полей.
71
+ */
72
+ function sample(s, name, depth = 0) {
73
+ if (!isSchema(s))
74
+ return name;
75
+ if (Array.isArray(s.examples) && s.examples.length)
76
+ return s.examples[0];
77
+ if ('const' in s)
78
+ return s.const;
79
+ if ('default' in s)
80
+ return s.default;
81
+ if (Array.isArray(s.enum) && s.enum.length)
82
+ return s.enum[0];
83
+ const branch = Array.isArray(s.anyOf) ? s.anyOf[0] : Array.isArray(s.oneOf) ? s.oneOf[0] : undefined;
84
+ if (isSchema(branch)) {
85
+ const { anyOf: _a, oneOf: _o, ...rest } = s;
86
+ return sample({ ...rest, ...branch }, name, depth);
87
+ }
88
+ const types = (Array.isArray(s.type) ? s.type : typeof s.type === 'string' ? [s.type] : []);
89
+ const type = types.find((x) => x !== 'null') ?? (types.length ? 'null' : isSchema(s.properties) ? 'object' : s.items ? 'array' : 'string');
90
+ if (depth > 8 && (type === 'object' || type === 'array'))
91
+ return type === 'object' ? {} : [];
92
+ switch (type) {
93
+ case 'null': return null;
94
+ case 'boolean': return true;
95
+ case 'integer':
96
+ case 'number': return numberSample(s, type === 'integer');
97
+ case 'array': return Array.from({ length: Math.min(typeof s.minItems === 'number' ? s.minItems : 0, 3) }, () => sample(s.items, name, depth + 1));
98
+ case 'object': {
99
+ const props = isSchema(s.properties) ? s.properties : {};
100
+ return Object.fromEntries(neededFields(s).map((k) => [k, sample(props[k] ?? s.additionalProperties, k, depth + 1)]));
101
+ }
102
+ default: return stringSample(s, name);
103
+ }
104
+ }
105
+ /** Класс, строки которого цепочкой не создают: абстрактный, системный или его потомок. */
106
+ const notCreatable = (c) => c.abstract || c.ancestors.some((n) => SYSTEM_CLASSES.has(n));
107
+ /** Данные create: поля ключа, обязательные поля без умолчания и поля первой ветки условий anyOf и oneOf. */
108
+ function createData(c) {
109
+ const s = c.schema;
110
+ const props = isSchema(s.properties) ? s.properties : {};
111
+ const conds = [s.anyOf, s.oneOf].map((x) => (Array.isArray(x) ? x[0] : undefined));
112
+ const extra = conds.flatMap((x) => (isSchema(x) && Array.isArray(x.required) ? x.required : []));
113
+ const keyFields = c.key.filter((k) => !c.ends[k]);
114
+ const fields = [...new Set([...neededFields(s, extra), ...keyFields])];
115
+ return Object.fromEntries(fields.map((k) => [k, sample(props[k], k)]));
116
+ }
117
+ /** «Создать»: данные и слоты обязательных концов и концов ключа; у множественного конца с min — столько разных id. */
118
+ function createExample(c) {
119
+ if (notCreatable(c))
120
+ return undefined;
121
+ let chain = `db.${c.name}().create(${lit(createData(c))})`;
122
+ for (const [role, e] of Object.entries(c.ends)) {
123
+ const min = e.min ?? 0;
124
+ if (!e.required && min === 0 && !c.key.includes(role))
125
+ continue;
126
+ const place = idPlace(e.class);
127
+ if (!e.many)
128
+ chain += `.${role}.set(${lit(place)})`;
129
+ else
130
+ chain += `.${role}.add(${lit(min > 1 ? Array.from({ length: min }, (_, i) => `${place.slice(0, -1)} #${i + 1}>`) : place)})`;
131
+ }
132
+ return `${chain}.rows()`;
133
+ }
134
+ /** «Найти»: по ключу (поля — те же образцы, что у create; концы — id) или по первому обязательному простому полю. */
135
+ function findExample(c) {
136
+ const props = isSchema(c.schema.properties) ? c.schema.properties : {};
137
+ if (c.key.length) {
138
+ const filter = Object.fromEntries(c.key.map((k) => [k, c.ends[k] ? idPlace(c.ends[k].class) : sample(props[k], k)]));
139
+ return `db.${c.name}(${lit(filter)}).first()`;
140
+ }
141
+ const required = Array.isArray(c.schema.required) ? c.schema.required : [];
142
+ const simple = (v) => ['string', 'number', 'boolean'].includes(typeof v);
143
+ const field = Object.keys(props).find((k) => required.includes(k) && simple(sample(props[k], k)));
144
+ if (field)
145
+ return `db.${c.name}(${lit({ [field]: sample(props[field], field) })}).rows()`;
146
+ const end = Object.entries(c.ends).find(([, e]) => e.required && !e.many);
147
+ return end ? `db.${c.name}(${lit({ [end[0]]: idPlace(end[1].class) })}).rows()` : `db.${c.name}().rows()`;
148
+ }
149
+ /**
150
+ * «Перейти к соседям»: вперёд по каждому концу класса (шаг-роль; если так зовут класс или алиас, которого конец не
151
+ * принимает, — шаг по классу цели) и назад к классам, чьи концы принимают этот класс (у наследника, получившего конец
152
+ * от предка, — только предок: шаг к нему находит и наследников).
153
+ */
154
+ function neighborExamples(reg, c, all) {
155
+ const list = (v) => (Array.isArray(v) ? v : [v]);
156
+ const steps = Object.entries(c.ends).map(([role, e]) => {
157
+ const named = reg.find(role);
158
+ return !named || accepts(reg, list(e.class), named.name) ? role : list(e.class)[0];
159
+ });
160
+ const back = all.filter((o) => Object.entries(o.ends).some(([role, e]) => accepts(reg, list(e.class), c.name) && !reg.find(o.ancestors[1] ?? '')?.ends[role]));
161
+ steps.push(...back.map((o) => o.name).sort((x, y) => x.localeCompare(y)));
162
+ return [...new Set(steps)].map((step) => `db.${c.name}(${lit(idPlace(c.name))}).${step}().rows()`);
163
+ }
164
+ function examplesOf(reg, c, all) {
165
+ const create = createExample(c);
166
+ return { ...(create ? { create } : {}), find: findExample(c), neighbors: neighborExamples(reg, c, all) };
167
+ }
168
+ /**
169
+ * Описание модели арендатора вместе с метаданными классов (этап 6, п. 12) и примерами цепочек (план 2.0, этап 3, п. 7):
170
+ * reg — реестр подключения (классы арендатора сессии и System), schema и tenant — схема установки и арендатор сессии.
171
+ */
172
+ export function describeModel(reg, schema, tenant) {
173
+ const all = reg.all();
174
+ const classes = all.map((c) => ({
175
+ name: c.name,
176
+ kind: c.kind,
177
+ ...(c.alias ? { alias: c.alias } : {}),
178
+ abstract: c.abstract,
179
+ ...(c.ancestors[1] ? { parent: c.ancestors[1] } : {}),
180
+ ancestors: c.ancestors,
181
+ system: SYSTEM_CLASSES.has(c.name),
182
+ fields: (c.schema.properties ?? {}),
183
+ required: (c.schema.required ?? []),
184
+ ...(Array.isArray(c.schema.anyOf) ? { anyOf: c.schema.anyOf } : {}),
185
+ ...(Array.isArray(c.schema.oneOf) ? { oneOf: c.schema.oneOf } : {}),
186
+ ends: c.ends,
187
+ key: c.key,
188
+ history: c.history,
189
+ ownerDefault: c.ownerDefault,
190
+ ...(c.cache ? { cache: c.cache } : {}),
191
+ meta: c.meta,
192
+ examples: examplesOf(reg, c, all),
193
+ })).sort((x, y) => Number(x.system) - Number(y.system) || x.name.localeCompare(y.name));
194
+ return { schema, tenant, classes };
195
+ }
package/dist/errors.d.ts CHANGED
@@ -1,29 +1,93 @@
1
- /**
2
- * Ошибки letopis 1.0 (план, §7.1, этап 2, п. 10). Текст — «letopis: <код>: <сообщение>», как у ошибок
3
- * базы; код — имя из каталога (`error_catalog()` в lib/sql/15-errors.sql) или код самой библиотеки.
4
- */
1
+ import { type MsgKey, type Params } from './messages/index.js';
2
+ export type { MsgKey, Params } from './messages/index.js';
5
3
  /** Нарушение правила данных: путь, ключевое слово JSON Schema, сообщение. Один формат для Zod, валидатора и базы. */
6
4
  export interface Issue {
7
5
  path: (string | number)[];
8
6
  keyword: string;
9
7
  message: string;
10
8
  }
9
+ /** Текст записи каталога с параметрами (params.similar — «— похожие: …»): сообщения нарушений, описания, отчёты CLI. */
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;
17
+ /**
18
+ * Предупреждение по записи каталога — «letopis: <текст>» в console.warn; once — ключ «один раз на процесс». Неизвестное
19
+ * поле фильтра, sort() и агрегата с 2.0 — уже не предупреждение, а отказ query.unknown_field (sql.ts, checkField).
20
+ * Логирование 3.0: задан логгер (opts.log подключения или LETOPIS_LOG) — событие warning в него вместо console.warn.
21
+ */
22
+ export declare function lwarn(key: MsgKey, params?: Params, opts?: {
23
+ once?: string;
24
+ log?: WarnSink;
25
+ }): void;
26
+ /**
27
+ * Готовый текст предупреждения: в логгер (событие warning с ключом и параметрами), если он задан, иначе console.warn.
28
+ * Ошибка логгера предупреждение не теряет и операцию не прерывает.
29
+ */
30
+ export declare function warnText(text: string, key?: string, log?: WarnSink, params?: Params): void;
11
31
  export declare class LetopisError extends Error {
12
32
  readonly code: string;
13
33
  readonly detail: unknown;
14
- constructor(code: string, message: string, detail?: unknown);
34
+ /** Ключ каталога сообщений: у ошибок lerr и у ошибок базы с ключом в DETAIL; у ошибок без ключа — нет. */
35
+ readonly key?: string;
36
+ /** Параметры шаблона ключа (similar — похожие имена). */
37
+ readonly params?: Params;
38
+ /** Что делать: своя подсказка записи каталога или подсказка кода. */
39
+ readonly hint?: string;
40
+ /** Строка кода в разделе «Ошибки» README на GitHub по тегу версии пакета. */
41
+ readonly docs?: string;
42
+ constructor(code: string, message: string, detail?: unknown, msg?: {
43
+ key: string;
44
+ params?: Params;
45
+ });
15
46
  }
16
- /** Версия, в которой снято старое API (её берёт и контракт API — scripts/gen-api-contract.mjs). */
17
- export declare const REMOVED_IN = "1.0.0";
18
- /** Охранник снесённого API (план, этап 3, п. 11): понятная ошибка с версией и заменой. */
19
- export declare function removed(name: string, replacement: string): LetopisError;
20
- /** Данные не проходят описание класса (`invalid_data`). */
47
+ /**
48
+ * Ошибка по ключу каталога: код — из записи, текст — шаблон с параметрами (params.similar — похожие имена дописываются
49
+ * сами); extra.detail — подробности (`err.detail`), extra.cause — исходная ошибка.
50
+ */
51
+ export declare function lerr(key: MsgKey, params?: Params, extra?: {
52
+ detail?: unknown;
53
+ cause?: unknown;
54
+ }): LetopisError;
55
+ /** Похожие имена: до пяти ближайших по Левенштейну (без учёта регистра) или содержащих искомое. */
56
+ export declare function similar(name: string, candidates: Iterable<string>, n?: number): string[];
57
+ /**
58
+ * Неизвестный ключ опций — invalid_data с похожими именами (план 2.0, этап 4, п. 2; 7.10 №4; в 1.5 было предупреждение,
59
+ * 7.9 №6). known — все ключи опций: объект `satisfies Record<keyof Опции, true>`, пропуск ключа — ошибка типов.
60
+ */
61
+ export declare function checkOptions(where: string, opts: unknown, known: Readonly<Record<string, true>>): void;
62
+ /**
63
+ * Первое нарушение для текста ошибки (план 2.1, A8): «: <путь> — <сообщение>», при нескольких — ещё их число. Полный
64
+ * список — err.issues.
65
+ */
66
+ export declare function issuesSuffix(issues: readonly Issue[]): string;
67
+ /** Данные не проходят описание класса (`invalid_data`); текст — по ключу (по умолчанию api.validation_failed) и первое нарушение. */
21
68
  export declare class ValidationError extends LetopisError {
22
69
  readonly issues: Issue[];
23
- constructor(cls: string, issues: Issue[]);
70
+ constructor(cls: string, issues: Issue[], msg?: {
71
+ key: string;
72
+ params?: Params;
73
+ });
24
74
  }
25
75
  /**
26
- * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL
27
- * (JSON) переносятся в detail; прочие ошибки возвращаются как есть.
76
+ * Нарушения из базы (validate_data, inc): у нарушения базы ещё key и params, наружу они не идут — форма та же,
77
+ * { path, keyword, message }, message — текст, который собрала база. Нарушение без ключа (схема прежней сборки) — как
78
+ * есть.
79
+ */
80
+ export declare function issuesFromDb(issues: unknown): Issue[];
81
+ /**
82
+ * Строки отказа ужесточения из базы (detail.rows от family_conflicts): у строки ещё key и params, наружу они не идут
83
+ * (problem — текст базы), у нарушений в поле issues — тоже. Строка без ключа — как есть.
84
+ */
85
+ export declare function findingsFromDb<T>(rows: readonly T[]): T[];
86
+ /**
87
+ * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL (JSON) переносятся в
88
+ * detail; ключ каталога и параметры из DETAIL (lt_fail(ключ, …)) — в key и params, текст — тот, что база собрала по ним
89
+ * из русского шаблона; без ключа — текст базы. У нарушений (issues) и строк отказа (detail.rows) key и params наружу не
90
+ * идут. Внутренняя ошибка базы (XX000) с ключом в DETAIL — код internal. Повторяемый конфликт (40001, 40P01) и прочие
91
+ * ошибки возвращаются как есть: на SQLSTATE конфликта держатся повторы (tx.ts).
28
92
  */
29
93
  export declare function fromDbError(e: unknown): unknown;
package/dist/errors.js CHANGED
@@ -1,30 +1,166 @@
1
1
  /**
2
2
  * Ошибки letopis 1.0 (план, §7.1, этап 2, п. 10). Текст — «letopis: <код>: <сообщение>», как у ошибок
3
3
  * базы; код — имя из каталога (`error_catalog()` в lib/sql/15-errors.sql) или код самой библиотеки.
4
+ *
5
+ * Каталог сообщений (план 2.0, этап 3, п. 3; lib/src/messages): ошибка по ключу — `lerr(ключ, параметры)`, текст —
6
+ * шаблон записи с параметрами. У каждой ошибки — подсказка «что делать» (`hint`) и ссылка на строку её кода в разделе
7
+ * «Ошибки» README на GitHub по тегу версии (`docs`). База собирает текст по ключу и параметрам из того же шаблона и
8
+ * кладёт их в DETAIL (`lt_fail(ключ, …)`), а fromDbError переносит их в поля ошибки (`key`, `params`); без ключа —
9
+ * текст базы как есть. У нарушений (validate_data) и строк отказа ужесточения key и params наружу не идут: issuesFromDb,
10
+ * findingsFromDb.
4
11
  */
12
+ import { createRequire } from 'node:module';
13
+ import { SIMILAR, codeOf, msgOf, render } from './messages/index.js';
14
+ const VERSION = createRequire(import.meta.url)('../package.json').version;
15
+ /** Раздел «Ошибки» README по тегу версии пакета; якорь — сам код (`<a id="код"></a>` в строке таблицы). */
16
+ const DOCS = `https://github.com/alepri51/letopis/blob/v${VERSION}/lib/README.md`;
17
+ // ---------------------------------------------------------------------------
18
+ // Тексты и ошибки по каталогу
19
+ // ---------------------------------------------------------------------------
20
+ function textOf(key, params) {
21
+ const m = msgOf(key);
22
+ if (!m)
23
+ return key;
24
+ const s = render(m.ru, params);
25
+ const sim = params?.similar;
26
+ return Array.isArray(sim) && sim.length ? s + render(SIMILAR, { similar: sim.join(', ') }) : s;
27
+ }
28
+ /** Текст записи каталога с параметрами (params.similar — «— похожие: …»): сообщения нарушений, описания, отчёты CLI. */
29
+ export function lmsg(key, params) {
30
+ return textOf(key, params);
31
+ }
32
+ const warned = new Set();
33
+ /** Логгер по LETOPIS_LOG для предупреждений без подключения — ставит log.ts (без цикла импорта значений). */
34
+ let envSink = null;
35
+ export function setEnvWarnSink(fn) {
36
+ envSink = fn;
37
+ }
38
+ /**
39
+ * Предупреждение по записи каталога — «letopis: <текст>» в console.warn; once — ключ «один раз на процесс». Неизвестное
40
+ * поле фильтра, sort() и агрегата с 2.0 — уже не предупреждение, а отказ query.unknown_field (sql.ts, checkField).
41
+ * Логирование 3.0: задан логгер (opts.log подключения или LETOPIS_LOG) — событие warning в него вместо console.warn.
42
+ */
43
+ export function lwarn(key, params, opts = {}) {
44
+ if (opts.once !== undefined) {
45
+ if (warned.has(opts.once))
46
+ return;
47
+ if (warned.size >= 1000)
48
+ warned.clear();
49
+ warned.add(opts.once);
50
+ }
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);
69
+ }
5
70
  export class LetopisError extends Error {
6
71
  code;
7
72
  detail;
8
- constructor(code, message, detail) {
73
+ constructor(code, message, detail, msg) {
9
74
  super(`letopis: ${code}: ${message}`);
10
75
  this.name = 'LetopisError';
11
76
  this.code = code;
12
77
  this.detail = detail;
78
+ const self = this;
79
+ if (msg) {
80
+ self.key = msg.key;
81
+ self.params = msg.params ?? {};
82
+ }
83
+ const sign = codeOf(code);
84
+ const hint = (msg ? msgOf(msg.key)?.hint : undefined) ?? sign?.hint;
85
+ if (hint)
86
+ self.hint = hint;
87
+ if (sign)
88
+ self.docs = `${DOCS}#${code}`;
89
+ }
90
+ }
91
+ /**
92
+ * Ошибка по ключу каталога: код — из записи, текст — шаблон с параметрами (params.similar — похожие имена дописываются
93
+ * сами); extra.detail — подробности (`err.detail`), extra.cause — исходная ошибка.
94
+ */
95
+ export function lerr(key, params, extra) {
96
+ const e = new LetopisError(msgOf(key)?.code ?? 'invalid_query', lmsg(key, params), extra?.detail, { key, ...(params ? { params } : {}) });
97
+ return extra && 'cause' in extra ? Object.assign(e, { cause: extra.cause }) : e;
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Похожие имена (общие: цепочки, фильтры, опции, MCP)
101
+ // ---------------------------------------------------------------------------
102
+ /** Расстояние Левенштейна (вставка, удаление, замена). */
103
+ function distance(a, b) {
104
+ if (!a.length)
105
+ return b.length;
106
+ if (!b.length)
107
+ return a.length;
108
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
109
+ for (let i = 1; i <= a.length; i++) {
110
+ const cur = [i];
111
+ for (let j = 1; j <= b.length; j++)
112
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
113
+ prev = cur;
114
+ }
115
+ return prev[b.length];
116
+ }
117
+ /** Похожие имена: до пяти ближайших по Левенштейну (без учёта регистра) или содержащих искомое. */
118
+ export function similar(name, candidates, n = 5) {
119
+ if (name.length > 64)
120
+ return [];
121
+ const want = name.toLowerCase();
122
+ const max = Math.max(2, Math.ceil(want.length / 3));
123
+ const scored = [];
124
+ for (const x of new Set(candidates)) {
125
+ if (x === name)
126
+ continue;
127
+ const low = x.toLowerCase();
128
+ const d = distance(want, low);
129
+ if (d <= max || (want.length >= 3 && low.includes(want)))
130
+ scored.push({ x, d });
131
+ }
132
+ return scored.sort((p, q) => p.d - q.d || (p.x < q.x ? -1 : p.x > q.x ? 1 : 0)).slice(0, n).map((s) => s.x);
133
+ }
134
+ /**
135
+ * Неизвестный ключ опций — invalid_data с похожими именами (план 2.0, этап 4, п. 2; 7.10 №4; в 1.5 было предупреждение,
136
+ * 7.9 №6). known — все ключи опций: объект `satisfies Record<keyof Опции, true>`, пропуск ключа — ошибка типов.
137
+ */
138
+ export function checkOptions(where, opts, known) {
139
+ if (!opts || typeof opts !== 'object')
140
+ return;
141
+ for (const k of Object.keys(opts)) {
142
+ if (!Object.hasOwn(known, k))
143
+ throw lerr('api.unknown_option', { where, name: k, similar: similar(k, Object.keys(known)) });
13
144
  }
14
145
  }
15
- /** Версия, в которой снято старое API (её берёт и контракт API — scripts/gen-api-contract.mjs). */
16
- export const REMOVED_IN = '1.0.0';
17
- /** Охранник снесённого API (план, этап 3, п. 11): понятная ошибка с версией и заменой. */
18
- export function removed(name, replacement) {
19
- // «опция …» — женского рода; методы и свойства — мужского
20
- const verb = name.startsWith('опция ') ? 'снята' : / и /.test(name) ? 'сняты' : 'снят';
21
- return new LetopisError('removed', `${name} ${verb} в ${REMOVED_IN} — ${replacement}`);
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}`;
22
157
  }
23
- /** Данные не проходят описание класса (`invalid_data`). */
158
+ /** Данные не проходят описание класса (`invalid_data`); текст — по ключу (по умолчанию api.validation_failed) и первое нарушение. */
24
159
  export class ValidationError extends LetopisError {
25
160
  issues;
26
- constructor(cls, issues) {
27
- super('invalid_data', `данные не проходят описание класса ${cls}`, { issues });
161
+ constructor(cls, issues, msg) {
162
+ const m = msg ?? { key: 'api.validation_failed', params: { cls } };
163
+ super('invalid_data', textOf(m.key, m.params) + issuesSuffix(issues), { issues }, m);
28
164
  this.name = 'ValidationError';
29
165
  this.issues = issues;
30
166
  }
@@ -33,14 +169,55 @@ export class ValidationError extends LetopisError {
33
169
  const LT_NAMES = {
34
170
  LT001: 'no_session', LT002: 'target_not_found', LT003: 'invalid_data', LT004: 'undeclared_end', LT005: 'tenant_denied',
35
171
  LT006: 'acl_denied', LT007: 'id_mismatch', LT008: 'id_from_db', LT009: 'immutable_key', LT010: 'invalid_class',
36
- LT011: 'use_reclass', LT012: 'reclass_denied', LT013: 'tightening_conflict', LT014: 'isolation_level',
37
- LT015: 'recreated_in_statement', LT016: 'cursor_expired', LT017: 'delete_restricted', LT018: 'not_deleted',
38
- LT019: 'reset_disabled', LT020: 'confirm_mismatch', LT021: 'derived_class', LT022: 'aggregate_field',
39
- LT023: 'projector_pending',
172
+ LT013: 'tightening_conflict', LT014: 'isolation_level',
173
+ LT015: 'recreated_in_statement', LT017: 'delete_restricted', LT018: 'not_deleted',
40
174
  };
175
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
176
+ /**
177
+ * Нарушения из базы (validate_data, inc): у нарушения базы ещё key и params, наружу они не идут — форма та же,
178
+ * { path, keyword, message }, message — текст, который собрала база. Нарушение без ключа (схема прежней сборки) — как
179
+ * есть.
180
+ */
181
+ export function issuesFromDb(issues) {
182
+ if (!Array.isArray(issues))
183
+ return issues;
184
+ return issues.map((i) => {
185
+ if (!isObj(i) || typeof i.key !== 'string')
186
+ return i;
187
+ const { key: _key, params: _params, ...rest } = i;
188
+ rest.message = String(rest.message ?? '');
189
+ return rest;
190
+ });
191
+ }
41
192
  /**
42
- * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL
43
- * (JSON) переносятся в detail; прочие ошибки возвращаются как есть.
193
+ * Строки отказа ужесточения из базы (detail.rows от family_conflicts): у строки ещё key и params, наружу они не идут
194
+ * (problem — текст базы), у нарушений в поле issues — тоже. Строка без ключа — как есть.
195
+ */
196
+ export function findingsFromDb(rows) {
197
+ return rows.map((r) => {
198
+ if (!isObj(r) || typeof r.key !== 'string')
199
+ return r;
200
+ const { key: _key, params: _params, ...rest } = r;
201
+ if (Array.isArray(rest.issues))
202
+ rest.issues = issuesFromDb(rest.issues);
203
+ return rest;
204
+ });
205
+ }
206
+ /** DETAIL ошибки базы: JSON — разобранный, иначе строка как есть. */
207
+ function parseDetail(detail) {
208
+ try {
209
+ return detail ? JSON.parse(detail) : undefined;
210
+ }
211
+ catch {
212
+ return detail;
213
+ }
214
+ }
215
+ /**
216
+ * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL (JSON) переносятся в
217
+ * detail; ключ каталога и параметры из DETAIL (lt_fail(ключ, …)) — в key и params, текст — тот, что база собрала по ним
218
+ * из русского шаблона; без ключа — текст базы. У нарушений (issues) и строк отказа (detail.rows) key и params наружу не
219
+ * идут. Внутренняя ошибка базы (XX000) с ключом в DETAIL — код internal. Повторяемый конфликт (40001, 40P01) и прочие
220
+ * ошибки возвращаются как есть: на SQLSTATE конфликта держатся повторы (tx.ts).
44
221
  */
45
222
  export function fromDbError(e) {
46
223
  const err = e;
@@ -48,25 +225,49 @@ export function fromDbError(e) {
48
225
  // которому существующую строку менять нельзя («USING expression»), остаётся 42501 (§2.5). Признак —
49
226
  // функция сервера, а не текст: текст зависит от языка сервера
50
227
  if (err.code === '42501' && err.routine === 'ExecWithCheckOptions' && !/USING/.test(err.message ?? '')) {
51
- return Object.assign(new LetopisError('acl_denied', 'новые значения строки запрещают правила доступа'), { cause: e });
228
+ return lerr('api.row_check_denied', undefined, { cause: e });
52
229
  }
53
- const name = err.code ? LT_NAMES[err.code] : undefined;
54
- if (!name)
55
- return e;
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 });
234
+ let name;
56
235
  let detail;
57
- try {
58
- detail = err.detail ? JSON.parse(err.detail) : undefined;
236
+ if (err.code === 'XX000') {
237
+ // своя внутренняя ошибка — lt_raise('XX000', ключ, …): ключ каталога в DETAIL; голые XX000 самого PostgreSQL («tuple
238
+ // concurrently updated» и т. п.) — как есть
239
+ detail = parseDetail(err.detail);
240
+ if (isObj(detail) && typeof detail.key === 'string')
241
+ name = 'internal';
59
242
  }
60
- catch {
61
- detail = err.detail;
243
+ else if (err.code) {
244
+ name = LT_NAMES[err.code];
245
+ if (name)
246
+ detail = parseDetail(err.detail);
247
+ }
248
+ if (!name)
249
+ return e;
250
+ // ключ каталога (план 2.0, этап 3, п. 3): в detail остаются прежние поля, а ключ и параметры — в key и params
251
+ let msg;
252
+ if (isObj(detail) && typeof detail.key === 'string') {
253
+ const { key, params, nodetail, ...rest } = detail;
254
+ msg = { key: key, params: isObj(params) ? params : {} };
255
+ // detail — как до каталога: прежние поля, {} в том числе (lt_fail без detail давала {}); признак nodetail — DETAIL у
256
+ // места не было (raise без detail), и detail нет. Сам признак наружу не идёт
257
+ detail = nodetail === true ? undefined : rest;
62
258
  }
259
+ // текст база собрала по ключу и параметрам из того же русского шаблона — он остаётся как есть
63
260
  const text = (err.message ?? '').replace(/^letopis: [a-z_]+: /, '');
261
+ // строки отказа с находками (ужесточение: problem по ключу) — без key и params
262
+ if (isObj(detail) && Array.isArray(detail.rows))
263
+ detail = { ...detail, rows: findingsFromDb(detail.rows) };
64
264
  const issues = detail?.issues;
65
265
  if (name === 'invalid_data' && Array.isArray(issues)) {
66
- const cls = /класса (\S+)/.exec(text)?.[1] ?? '?';
67
- const v = new ValidationError(cls, issues);
68
- // текст базы целиком: в нём бывает больше, чем имя класса (у проектора — арендатор и id строки)
69
- return Object.assign(v, { cause: e, ...(text ? { message: `letopis: invalid_data: ${text}` } : {}) });
266
+ const cls = msg ? String(msg.params.cls ?? '?') : /класса (\S+)/.exec(text)?.[1] ?? '?';
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)}` } : {}) });
70
271
  }
71
- return Object.assign(new LetopisError(name, text, detail), { cause: e });
272
+ return Object.assign(new LetopisError(name, text, detail, msg), { cause: e });
72
273
  }