letopis 1.4.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/AGENT-CHEATSHEET.md +329 -278
  2. package/CHANGELOG.md +558 -1
  3. package/README.md +606 -1037
  4. package/dist/acl.d.ts +78 -1
  5. package/dist/acl.js +264 -13
  6. package/dist/admin.d.ts +10 -141
  7. package/dist/admin.js +24 -175
  8. package/dist/auth.d.ts +2 -2
  9. package/dist/auth.js +5 -15
  10. package/dist/cache.d.ts +8 -1
  11. package/dist/cache.js +33 -10
  12. package/dist/chain.d.ts +19 -41
  13. package/dist/chain.js +169 -201
  14. package/dist/cli.js +51 -88
  15. package/dist/demo/booking.d.ts +352 -36
  16. package/dist/describe.d.ts +47 -0
  17. package/dist/describe.js +195 -0
  18. package/dist/errors.d.ts +59 -13
  19. package/dist/errors.js +190 -29
  20. package/dist/index.d.ts +52 -71
  21. package/dist/index.js +245 -130
  22. package/dist/internal.d.ts +12 -0
  23. package/dist/internal.js +5 -0
  24. package/dist/jsonschema.js +18 -17
  25. package/dist/load.d.ts +20 -12
  26. package/dist/load.js +225 -186
  27. package/dist/mcp.d.ts +5 -7
  28. package/dist/mcp.js +171 -215
  29. package/dist/messages/access.d.ts +90 -0
  30. package/dist/messages/access.js +113 -0
  31. package/dist/messages/api.d.ts +171 -0
  32. package/dist/messages/api.js +215 -0
  33. package/dist/messages/boot.d.ts +201 -0
  34. package/dist/messages/boot.js +256 -0
  35. package/dist/messages/codes.d.ts +172 -0
  36. package/dist/messages/codes.js +173 -0
  37. package/dist/messages/index.d.ts +95 -0
  38. package/dist/messages/index.js +118 -0
  39. package/dist/messages/mcp.d.ts +306 -0
  40. package/dist/messages/mcp.js +387 -0
  41. package/dist/messages/model.d.ts +233 -0
  42. package/dist/messages/model.js +283 -0
  43. package/dist/messages/query.d.ts +131 -0
  44. package/dist/messages/query.js +158 -0
  45. package/dist/messages/sql-access.d.ts +70 -0
  46. package/dist/messages/sql-access.js +87 -0
  47. package/dist/messages/sql-core.d.ts +150 -0
  48. package/dist/messages/sql-core.js +181 -0
  49. package/dist/messages/sql-time.d.ts +71 -0
  50. package/dist/messages/sql-time.js +88 -0
  51. package/dist/messages/sql-write.d.ts +246 -0
  52. package/dist/messages/sql-write.js +270 -0
  53. package/dist/messages/time.d.ts +23 -0
  54. package/dist/messages/time.js +29 -0
  55. package/dist/messages/types.d.ts +27 -0
  56. package/dist/messages/types.js +5 -0
  57. package/dist/messages/write.d.ts +405 -0
  58. package/dist/messages/write.js +509 -0
  59. package/dist/model.d.ts +73 -60
  60. package/dist/model.js +26 -29
  61. package/dist/ops.d.ts +35 -0
  62. package/dist/ops.js +101 -0
  63. package/dist/pglite.d.ts +0 -9
  64. package/dist/pglite.js +3 -1
  65. package/dist/registry.d.ts +3 -19
  66. package/dist/registry.js +3 -16
  67. package/dist/signals.d.ts +5 -9
  68. package/dist/signals.js +5 -9
  69. package/dist/sql.d.ts +13 -6
  70. package/dist/sql.js +176 -83
  71. package/dist/sync.d.ts +9 -3
  72. package/dist/sync.js +51 -21
  73. package/dist/tx.d.ts +26 -1
  74. package/dist/tx.js +44 -14
  75. package/dist/typed.d.ts +240 -45
  76. package/dist/types.d.ts +23 -91
  77. package/dist/types.js +12 -14
  78. package/dist/up.d.ts +53 -32
  79. package/dist/up.js +184 -73
  80. package/dist/uuid.js +5 -5
  81. package/dist/validate.d.ts +13 -1
  82. package/dist/validate.js +61 -29
  83. package/dist/watch.d.ts +11 -10
  84. package/dist/watch.js +33 -70
  85. package/dist/write.d.ts +9 -16
  86. package/dist/write.js +118 -187
  87. package/llms.txt +22 -25
  88. package/package.json +8 -11
  89. package/sql/10-core.sql +7 -50
  90. package/sql/15-errors.sql +64 -10
  91. package/sql/20-context.sql +7 -6
  92. package/sql/30-validate.sql +35 -27
  93. package/sql/40-class.sql +20 -114
  94. package/sql/50-acl.sql +8 -13
  95. package/sql/60-write.sql +220 -667
  96. package/sql/70-read.sql +20 -16
  97. package/sql/80-auth.sql +24 -27
  98. package/sql/90-time.sql +70 -859
  99. package/sql/95-seed.system.sql +7 -14
  100. package/sql/99-revision.sql +2 -1
  101. package/sql/README.md +32 -24
  102. package/sql/migrate/README.md +14 -0
  103. package/AGENT-CHEATSHEET.en.md +0 -485
  104. package/MIGRATION.md +0 -190
  105. package/README.en.md +0 -2859
  106. package/dist/import.d.ts +0 -185
  107. package/dist/import.js +0 -820
  108. package/dist/project.d.ts +0 -350
  109. package/dist/project.js +0 -1224
  110. package/sql/75-history.sql +0 -358
  111. package/sql/88-derived.sql +0 -1235
  112. package/sql/migrate/0006-changed-paths.sql +0 -3
  113. package/sql/migrate/0007-secret-account-fk.sql +0 -12
@@ -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,75 @@
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
+ /**
12
+ * Предупреждение по записи каталога — «letopis: <текст>» в console.warn; once — ключ «один раз на процесс». Неизвестное
13
+ * поле фильтра, sort() и агрегата с 2.0 — уже не предупреждение, а отказ query.unknown_field (sql.ts, checkField).
14
+ */
15
+ export declare function lwarn(key: MsgKey, params?: Params, opts?: {
16
+ once?: string;
17
+ }): void;
11
18
  export declare class LetopisError extends Error {
12
19
  readonly code: string;
13
20
  readonly detail: unknown;
14
- constructor(code: string, message: string, detail?: unknown);
21
+ /** Ключ каталога сообщений: у ошибок lerr и у ошибок базы с ключом в DETAIL; у ошибок без ключа — нет. */
22
+ readonly key?: string;
23
+ /** Параметры шаблона ключа (similar — похожие имена). */
24
+ readonly params?: Params;
25
+ /** Что делать: своя подсказка записи каталога или подсказка кода. */
26
+ readonly hint?: string;
27
+ /** Строка кода в разделе «Ошибки» README на GitHub по тегу версии пакета. */
28
+ readonly docs?: string;
29
+ constructor(code: string, message: string, detail?: unknown, msg?: {
30
+ key: string;
31
+ params?: Params;
32
+ });
15
33
  }
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`). */
34
+ /**
35
+ * Ошибка по ключу каталога: код — из записи, текст — шаблон с параметрами (params.similar — похожие имена дописываются
36
+ * сами); extra.detail — подробности (`err.detail`), extra.cause — исходная ошибка.
37
+ */
38
+ export declare function lerr(key: MsgKey, params?: Params, extra?: {
39
+ detail?: unknown;
40
+ cause?: unknown;
41
+ }): LetopisError;
42
+ /** Похожие имена: до пяти ближайших по Левенштейну (без учёта регистра) или содержащих искомое. */
43
+ export declare function similar(name: string, candidates: Iterable<string>, n?: number): string[];
44
+ /**
45
+ * Неизвестный ключ опций — invalid_data с похожими именами (план 2.0, этап 4, п. 2; 7.10 №4; в 1.5 было предупреждение,
46
+ * 7.9 №6). known — все ключи опций: объект `satisfies Record<keyof Опции, true>`, пропуск ключа — ошибка типов.
47
+ */
48
+ export declare function checkOptions(where: string, opts: unknown, known: Readonly<Record<string, true>>): void;
49
+ /** Данные не проходят описание класса (`invalid_data`); текст — по ключу (по умолчанию api.validation_failed). */
21
50
  export declare class ValidationError extends LetopisError {
22
51
  readonly issues: Issue[];
23
- constructor(cls: string, issues: Issue[]);
52
+ constructor(cls: string, issues: Issue[], msg?: {
53
+ key: string;
54
+ params?: Params;
55
+ });
24
56
  }
25
57
  /**
26
- * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL
27
- * (JSON) переносятся в detail; прочие ошибки возвращаются как есть.
58
+ * Нарушения из базы (validate_data, inc): у нарушения базы ещё key и params, наружу они не идут — форма та же,
59
+ * { path, keyword, message }, message — текст, который собрала база. Нарушение без ключа (схема прежней сборки) — как
60
+ * есть.
61
+ */
62
+ export declare function issuesFromDb(issues: unknown): Issue[];
63
+ /**
64
+ * Строки отказа ужесточения из базы (detail.rows от family_conflicts): у строки ещё key и params, наружу они не идут
65
+ * (problem — текст базы), у нарушений в поле issues — тоже. Строка без ключа — как есть.
66
+ */
67
+ export declare function findingsFromDb<T>(rows: readonly T[]): T[];
68
+ /**
69
+ * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL (JSON) переносятся в
70
+ * detail; ключ каталога и параметры из DETAIL (lt_fail(ключ, …)) — в key и params, текст — тот, что база собрала по ним
71
+ * из русского шаблона; без ключа — текст базы. У нарушений (issues) и строк отказа (detail.rows) key и params наружу не
72
+ * идут. Внутренняя ошибка базы (XX000) с ключом в DETAIL — код internal. Повторяемый конфликт (40001, 40P01) и прочие
73
+ * ошибки возвращаются как есть: на SQLSTATE конфликта держатся повторы (tx.ts).
28
74
  */
29
75
  export declare function fromDbError(e: unknown): unknown;
package/dist/errors.js CHANGED
@@ -1,30 +1,131 @@
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
+ /**
34
+ * Предупреждение по записи каталога — «letopis: <текст>» в console.warn; once — ключ «один раз на процесс». Неизвестное
35
+ * поле фильтра, sort() и агрегата с 2.0 — уже не предупреждение, а отказ query.unknown_field (sql.ts, checkField).
36
+ */
37
+ export function lwarn(key, params, opts = {}) {
38
+ if (opts.once !== undefined) {
39
+ if (warned.has(opts.once))
40
+ return;
41
+ if (warned.size >= 1000)
42
+ warned.clear();
43
+ warned.add(opts.once);
44
+ }
45
+ console.warn(`letopis: ${textOf(key, params)}`);
46
+ }
5
47
  export class LetopisError extends Error {
6
48
  code;
7
49
  detail;
8
- constructor(code, message, detail) {
50
+ constructor(code, message, detail, msg) {
9
51
  super(`letopis: ${code}: ${message}`);
10
52
  this.name = 'LetopisError';
11
53
  this.code = code;
12
54
  this.detail = detail;
55
+ const self = this;
56
+ if (msg) {
57
+ self.key = msg.key;
58
+ self.params = msg.params ?? {};
59
+ }
60
+ const sign = codeOf(code);
61
+ const hint = (msg ? msgOf(msg.key)?.hint : undefined) ?? sign?.hint;
62
+ if (hint)
63
+ self.hint = hint;
64
+ if (sign)
65
+ self.docs = `${DOCS}#${code}`;
13
66
  }
14
67
  }
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('опция ') ? 'снята' : 'снят';
21
- return new LetopisError('removed', `${name} ${verb} в ${REMOVED_IN} — ${replacement}`);
68
+ /**
69
+ * Ошибка по ключу каталога: код — из записи, текст — шаблон с параметрами (params.similar — похожие имена дописываются
70
+ * сами); extra.detail — подробности (`err.detail`), extra.cause — исходная ошибка.
71
+ */
72
+ export function lerr(key, params, extra) {
73
+ const e = new LetopisError(msgOf(key)?.code ?? 'invalid_query', lmsg(key, params), extra?.detail, { key, ...(params ? { params } : {}) });
74
+ return extra && 'cause' in extra ? Object.assign(e, { cause: extra.cause }) : e;
22
75
  }
23
- /** Данные не проходят описание класса (`invalid_data`). */
76
+ // ---------------------------------------------------------------------------
77
+ // Похожие имена (общие: цепочки, фильтры, опции, MCP)
78
+ // ---------------------------------------------------------------------------
79
+ /** Расстояние Левенштейна (вставка, удаление, замена). */
80
+ function distance(a, b) {
81
+ if (!a.length)
82
+ return b.length;
83
+ if (!b.length)
84
+ return a.length;
85
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
86
+ for (let i = 1; i <= a.length; i++) {
87
+ const cur = [i];
88
+ for (let j = 1; j <= b.length; j++)
89
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
90
+ prev = cur;
91
+ }
92
+ return prev[b.length];
93
+ }
94
+ /** Похожие имена: до пяти ближайших по Левенштейну (без учёта регистра) или содержащих искомое. */
95
+ export function similar(name, candidates, n = 5) {
96
+ if (name.length > 64)
97
+ return [];
98
+ const want = name.toLowerCase();
99
+ const max = Math.max(2, Math.ceil(want.length / 3));
100
+ const scored = [];
101
+ for (const x of new Set(candidates)) {
102
+ if (x === name)
103
+ continue;
104
+ const low = x.toLowerCase();
105
+ const d = distance(want, low);
106
+ if (d <= max || (want.length >= 3 && low.includes(want)))
107
+ scored.push({ x, d });
108
+ }
109
+ 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);
110
+ }
111
+ /**
112
+ * Неизвестный ключ опций — invalid_data с похожими именами (план 2.0, этап 4, п. 2; 7.10 №4; в 1.5 было предупреждение,
113
+ * 7.9 №6). known — все ключи опций: объект `satisfies Record<keyof Опции, true>`, пропуск ключа — ошибка типов.
114
+ */
115
+ export function checkOptions(where, opts, known) {
116
+ if (!opts || typeof opts !== 'object')
117
+ return;
118
+ for (const k of Object.keys(opts)) {
119
+ if (!Object.hasOwn(known, k))
120
+ throw lerr('api.unknown_option', { where, name: k, similar: similar(k, Object.keys(known)) });
121
+ }
122
+ }
123
+ /** Данные не проходят описание класса (`invalid_data`); текст — по ключу (по умолчанию api.validation_failed). */
24
124
  export class ValidationError extends LetopisError {
25
125
  issues;
26
- constructor(cls, issues) {
27
- super('invalid_data', `данные не проходят описание класса ${cls}`, { issues });
126
+ constructor(cls, issues, msg) {
127
+ const m = msg ?? { key: 'api.validation_failed', params: { cls } };
128
+ super('invalid_data', textOf(m.key, m.params), { issues }, m);
28
129
  this.name = 'ValidationError';
29
130
  this.issues = issues;
30
131
  }
@@ -33,14 +134,55 @@ export class ValidationError extends LetopisError {
33
134
  const LT_NAMES = {
34
135
  LT001: 'no_session', LT002: 'target_not_found', LT003: 'invalid_data', LT004: 'undeclared_end', LT005: 'tenant_denied',
35
136
  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',
137
+ LT013: 'tightening_conflict', LT014: 'isolation_level',
138
+ LT015: 'recreated_in_statement', LT017: 'delete_restricted', LT018: 'not_deleted',
40
139
  };
140
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
141
+ /**
142
+ * Нарушения из базы (validate_data, inc): у нарушения базы ещё key и params, наружу они не идут — форма та же,
143
+ * { path, keyword, message }, message — текст, который собрала база. Нарушение без ключа (схема прежней сборки) — как
144
+ * есть.
145
+ */
146
+ export function issuesFromDb(issues) {
147
+ if (!Array.isArray(issues))
148
+ return issues;
149
+ return issues.map((i) => {
150
+ if (!isObj(i) || typeof i.key !== 'string')
151
+ return i;
152
+ const { key: _key, params: _params, ...rest } = i;
153
+ rest.message = String(rest.message ?? '');
154
+ return rest;
155
+ });
156
+ }
41
157
  /**
42
- * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL
43
- * (JSON) переносятся в detail; прочие ошибки возвращаются как есть.
158
+ * Строки отказа ужесточения из базы (detail.rows от family_conflicts): у строки ещё key и params, наружу они не идут
159
+ * (problem — текст базы), у нарушений в поле issues — тоже. Строка без ключа — как есть.
160
+ */
161
+ export function findingsFromDb(rows) {
162
+ return rows.map((r) => {
163
+ if (!isObj(r) || typeof r.key !== 'string')
164
+ return r;
165
+ const { key: _key, params: _params, ...rest } = r;
166
+ if (Array.isArray(rest.issues))
167
+ rest.issues = issuesFromDb(rest.issues);
168
+ return rest;
169
+ });
170
+ }
171
+ /** DETAIL ошибки базы: JSON — разобранный, иначе строка как есть. */
172
+ function parseDetail(detail) {
173
+ try {
174
+ return detail ? JSON.parse(detail) : undefined;
175
+ }
176
+ catch {
177
+ return detail;
178
+ }
179
+ }
180
+ /**
181
+ * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL (JSON) переносятся в
182
+ * detail; ключ каталога и параметры из DETAIL (lt_fail(ключ, …)) — в key и params, текст — тот, что база собрала по ним
183
+ * из русского шаблона; без ключа — текст базы. У нарушений (issues) и строк отказа (detail.rows) key и params наружу не
184
+ * идут. Внутренняя ошибка базы (XX000) с ключом в DETAIL — код internal. Повторяемый конфликт (40001, 40P01) и прочие
185
+ * ошибки возвращаются как есть: на SQLSTATE конфликта держатся повторы (tx.ts).
44
186
  */
45
187
  export function fromDbError(e) {
46
188
  const err = e;
@@ -48,25 +190,44 @@ export function fromDbError(e) {
48
190
  // которому существующую строку менять нельзя («USING expression»), остаётся 42501 (§2.5). Признак —
49
191
  // функция сервера, а не текст: текст зависит от языка сервера
50
192
  if (err.code === '42501' && err.routine === 'ExecWithCheckOptions' && !/USING/.test(err.message ?? '')) {
51
- return Object.assign(new LetopisError('acl_denied', 'новые значения строки запрещают правила доступа'), { cause: e });
193
+ return lerr('api.row_check_denied', undefined, { cause: e });
52
194
  }
53
- const name = err.code ? LT_NAMES[err.code] : undefined;
54
- if (!name)
55
- return e;
195
+ let name;
56
196
  let detail;
57
- try {
58
- detail = err.detail ? JSON.parse(err.detail) : undefined;
197
+ if (err.code === 'XX000') {
198
+ // своя внутренняя ошибка — lt_raise('XX000', ключ, …): ключ каталога в DETAIL; голые XX000 самого PostgreSQL («tuple
199
+ // concurrently updated» и т. п.) — как есть
200
+ detail = parseDetail(err.detail);
201
+ if (isObj(detail) && typeof detail.key === 'string')
202
+ name = 'internal';
59
203
  }
60
- catch {
61
- detail = err.detail;
204
+ else if (err.code) {
205
+ name = LT_NAMES[err.code];
206
+ if (name)
207
+ detail = parseDetail(err.detail);
208
+ }
209
+ if (!name)
210
+ return e;
211
+ // ключ каталога (план 2.0, этап 3, п. 3): в detail остаются прежние поля, а ключ и параметры — в key и params
212
+ let msg;
213
+ if (isObj(detail) && typeof detail.key === 'string') {
214
+ const { key, params, nodetail, ...rest } = detail;
215
+ msg = { key: key, params: isObj(params) ? params : {} };
216
+ // detail — как до каталога: прежние поля, {} в том числе (lt_fail без detail давала {}); признак nodetail — DETAIL у
217
+ // места не было (raise без detail), и detail нет. Сам признак наружу не идёт
218
+ detail = nodetail === true ? undefined : rest;
62
219
  }
220
+ // текст база собрала по ключу и параметрам из того же русского шаблона — он остаётся как есть
63
221
  const text = (err.message ?? '').replace(/^letopis: [a-z_]+: /, '');
222
+ // строки отказа с находками (ужесточение: problem по ключу) — без key и params
223
+ if (isObj(detail) && Array.isArray(detail.rows))
224
+ detail = { ...detail, rows: findingsFromDb(detail.rows) };
64
225
  const issues = detail?.issues;
65
226
  if (name === 'invalid_data' && Array.isArray(issues)) {
66
- const cls = /класса (\S+)/.exec(text)?.[1] ?? '?';
67
- const v = new ValidationError(cls, issues);
68
- // текст базы целиком: в нём бывает больше, чем имя класса (у проектора — арендатор и id строки)
227
+ const cls = msg ? String(msg.params.cls ?? '?') : /класса (\S+)/.exec(text)?.[1] ?? '?';
228
+ const v = new ValidationError(cls, issuesFromDb(issues), msg);
229
+ // текст целиком — тот, что собрала база по шаблону записи
69
230
  return Object.assign(v, { cause: e, ...(text ? { message: `letopis: invalid_data: ${text}` } : {}) });
70
231
  }
71
- return Object.assign(new LetopisError(name, text, detail), { cause: e });
232
+ return Object.assign(new LetopisError(name, text, detail, msg), { cause: e });
72
233
  }