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/write.js CHANGED
@@ -17,11 +17,11 @@
17
17
  * строка видна, но править нельзя — acl_denied; строки уже нет — target_not_found; номер версии
18
18
  * разошёлся со { rev } — conflict.
19
19
  */
20
- import { lerr, ValidationError } from './errors.js';
20
+ import { fromDbError, issuesFromDb, lerr, lmsg, LetopisError, ValidationError } from './errors.js';
21
21
  import { isOp } from './ops.js';
22
22
  import { accepts, buildRead, nextNodeKey, precheckStep, resolveHop, stepClasses, toRow } from './sql.js';
23
23
  import { idOf } from './uuid.js';
24
- import { applyDefaults } from './validate.js';
24
+ import { applyDefaults, leavesToDb } from './validate.js';
25
25
  // ---------------------------------------------------------------------------
26
26
  // Приращение: маркер, которого не получить из JSON (§2.5)
27
27
  // ---------------------------------------------------------------------------
@@ -36,6 +36,29 @@ export function inc(by, opts = {}) {
36
36
  return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
37
37
  }
38
38
  export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === true;
39
+ /**
40
+ * Данные create() и upsert() без ключей со значением undefined — как у патча update() (splitPatch): ключ undefined JSON
41
+ * не передаёт, а валидатор на TypeScript принимал его за поле не того типа (план 2.1, A12). Вложенные объекты — тоже;
42
+ * массивы, даты, маркеры и операторы — как есть.
43
+ */
44
+ export function dropUndefined(data) {
45
+ if (!isPlain(data))
46
+ return data;
47
+ const out = {};
48
+ for (const [k, v] of Object.entries(data)) {
49
+ if (v === undefined)
50
+ continue;
51
+ out[k] = isPlain(v) ? dropUndefined(v) : v;
52
+ }
53
+ return out;
54
+ }
55
+ /** expect — список строк { id, rev } (и $action у превью удаления): иначе invalid_query до записи. */
56
+ function checkExpect(expect, verb) {
57
+ const ok = Array.isArray(expect) && expect.every((x) => x !== null && typeof x === 'object' && typeof x.id === 'string'
58
+ && Number.isInteger(x.rev) && x.rev >= 1);
59
+ if (!ok)
60
+ throw lerr('write.bad_expect', { verb });
61
+ }
39
62
  /** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
40
63
  export function checkReason(v, where) {
41
64
  if (typeof v !== 'string' || v.length > 1000 || v.includes('\0')) {
@@ -44,7 +67,9 @@ export function checkReason(v, where) {
44
67
  return v;
45
68
  }
46
69
  const asList = (v) => (Array.isArray(v) ? v : [v]);
47
- const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date) && !isInc(v) && !isOp(v);
70
+ function isPlain(v) {
71
+ return typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date) && !isInc(v) && !isOp(v);
72
+ }
48
73
  /** Патч update → остаток для merge и список приращений. Недопустимое — invalid_data до отправки. */
49
74
  export function splitPatch(patch) {
50
75
  const incs = [];
@@ -97,6 +122,51 @@ export function splitPatch(patch) {
97
122
  // ---------------------------------------------------------------------------
98
123
  // Проверки до отправки (precheckPlan)
99
124
  // ---------------------------------------------------------------------------
125
+ /**
126
+ * Нарушения, найденные валидатором на TypeScript у класса с правилами только для базы (`pattern`): полный список даст
127
+ * база (completeIssues). Ключ — сама ошибка.
128
+ */
129
+ const PENDING = new WeakMap();
130
+ /**
131
+ * Ключи данных, которые не поля класса, а его концы (связи): имя роли конца или имя класса на конце (у роли с другим
132
+ * именем). Пары [ключ, роль] — для текста «задайте .Роль.set(id)» (write.link_in_data, 3.0): подсказка в самом тексте
133
+ * ошибки, а не только в err.hint.
134
+ */
135
+ function endsAsFields(cls, data, skip) {
136
+ const props = (cls.schema.properties ?? {});
137
+ const out = [];
138
+ for (const k of Object.keys(data)) {
139
+ if (data[k] === undefined || k in props || k === skip)
140
+ continue;
141
+ if (cls.ends[k] !== undefined) {
142
+ out.push([k, k]);
143
+ continue;
144
+ }
145
+ const role = Object.entries(cls.ends).find(([, e]) => (Array.isArray(e.class) ? e.class : [e.class]).includes(k))?.[0];
146
+ if (role !== undefined)
147
+ out.push([k, role]);
148
+ }
149
+ return out;
150
+ }
151
+ /** Ключ и параметры ошибки «конец передан полем данных» (write.link_in_data). */
152
+ function linkInData(cls, verb, found) {
153
+ return { key: 'write.link_in_data', params: { verb, cls: cls.name, keys: found.map(([k]) => k).join(', '), role: found[0][1] } };
154
+ }
155
+ /**
156
+ * Патч update() с ключом — именем конца класса, которого нет среди полей: база отклонила бы его (лишний ключ верхнего
157
+ * уровня) — отказ до записи с подсказкой «конец — из пути или .Роль.set()» (план 2.1, A8). data.id цели — не данные.
158
+ */
159
+ function checkPatchEnds(s, patch) {
160
+ const cls = s.cls;
161
+ if (cls.name === 'Class' || cls.abstract || cls.schema.additionalProperties !== false)
162
+ return;
163
+ const useDataId = s.filter === undefined && typeof patch.id === 'string';
164
+ const found = endsAsFields(cls, patch, useDataId ? 'id' : undefined);
165
+ if (!found.length)
166
+ return;
167
+ const issue = { path: [], keyword: 'additionalProperties', message: lmsg('model.issue_additional', { keys: found.map(([k]) => k).join(', ') }) };
168
+ throw new ValidationError(cls.name, [issue], linkInData(cls, 'update', found));
169
+ }
100
170
  /** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript. */
101
171
  function checkFullData(cls, data, verb) {
102
172
  if (!isPlain(data))
@@ -116,9 +186,57 @@ function checkFullData(cls, data, verb) {
116
186
  // описания системных классов проверяет только база (метасхема Class, class_admit)
117
187
  if (cls.name === 'Class' || cls.abstract)
118
188
  return;
119
- const issues = cls.validator()(applyDefaults(cls.schema, data));
120
- if (issues.length)
121
- throw new ValidationError(cls.name, issues);
189
+ const v = cls.validator();
190
+ const full = applyDefaults(cls.schema, data);
191
+ const issues = v(full);
192
+ if (!issues.length)
193
+ return;
194
+ const found = endsAsFields(cls, data);
195
+ const e = new ValidationError(cls.name, issues, found.length ? linkInData(cls, verb, found) : undefined);
196
+ if (leavesToDb(v))
197
+ PENDING.set(e, { cls: cls.name, data: full });
198
+ throw e;
199
+ }
200
+ /**
201
+ * Ошибка данных до записи у класса с правилами только для базы (`pattern`): валидатор на TypeScript нашёл не всё — полный
202
+ * список нарушений даёт база одним запросом (`validate_row`, план 2.1, A8), под точкой сохранения: неудача запроса
203
+ * (схема прежней ревизии) не ломает db.begin(), остаётся прежняя ошибка. Прочие ошибки — как есть.
204
+ */
205
+ export async function completeIssues(ctx, e) {
206
+ const p = e instanceof ValidationError ? PENDING.get(e) : undefined;
207
+ if (!p)
208
+ return e;
209
+ const S = quoteId(ctx.schema);
210
+ let raw;
211
+ try {
212
+ raw = await ctx.exec.run(async (q) => {
213
+ await q.unsafe('savepoint letopis_issues');
214
+ try {
215
+ const [r] = (await q.unsafe(`select ${S}.validate_row($1::text, $2::jsonb) as v`, [p.cls, p.data]));
216
+ await q.unsafe('release savepoint letopis_issues');
217
+ return r?.v;
218
+ }
219
+ catch {
220
+ await q.unsafe('rollback to savepoint letopis_issues');
221
+ return null;
222
+ }
223
+ }, { read: true });
224
+ }
225
+ catch {
226
+ return e;
227
+ }
228
+ if (!Array.isArray(raw) || !raw.length)
229
+ return e;
230
+ const v = e;
231
+ return Object.assign(new ValidationError(p.cls, issuesFromDb(raw), v.key ? { key: v.key, params: v.params } : undefined), { cause: e });
232
+ }
233
+ /**
234
+ * Блокировка строк в порядке id (lockedRead, updateOp — for no key update; deleteOp с expect — for update): id, которые
235
+ * удалось заблокировать.
236
+ */
237
+ export async function lockIds(q, S, ids, mode = 'no key update') {
238
+ const rows = (await q.unsafe(`select id from ${S}.entity where id = any ($1::uuid[]) order by id for ${mode}`, [ids], { prepare: true }));
239
+ return new Set(rows.map((r) => r.id));
122
240
  }
123
241
  /** Фильтр шага сужает цели: id, список id, оператор, строка ответа, объект хотя бы с одним заданным полем. */
124
242
  const narrowingFilter = (f) => f !== undefined && f !== null && (typeof f !== 'object' || Array.isArray(f) || isOp(f) || Object.values(f).some((v) => v !== undefined));
@@ -139,6 +257,13 @@ function narrowed(steps, i) {
139
257
  }
140
258
  return false;
141
259
  }
260
+ /** Шаги сегмента глагола шага i: от шага после прежнего глагола до него самого. */
261
+ function segmentOf(steps, i) {
262
+ let j = i - 1;
263
+ while (j >= 0 && !steps[j].op)
264
+ j--;
265
+ return steps.slice(j + 1, i + 1);
266
+ }
142
267
  /**
143
268
  * Всё, что проверяется без базы, — до отправки первого оператора (§2.5). Фильтры шагов и поля sort() и агрегатов
144
269
  * (неизвестное поле — invalid_query, план 2.0, этап 3, п. 3) — precheckStep построителя; order и agg — модификатор и
@@ -161,8 +286,9 @@ export function precheckPlan(reg, steps, order, agg) {
161
286
  const m = s.opMods ?? {};
162
287
  if (m.asOf !== undefined)
163
288
  throw lerr('write.as_of_write', { verb });
164
- // restore сам ищет удалённые объекты шага
165
- if (m.withDeleted && verb !== 'restore')
289
+ // restore сам ищет удалённые объекты шага; withDeleted() — модификатор шага (план 2.1, A9): у шага глагола или шага
290
+ // до него в сегменте — отказ, как прежде у модификатора цепочки
291
+ if (verb !== 'restore' && segmentOf(steps, i).some((x) => x.withDeleted))
166
292
  throw lerr('write.with_deleted_write', { verb });
167
293
  switch (op.kind) {
168
294
  case 'create':
@@ -185,6 +311,7 @@ export function precheckPlan(reg, steps, order, agg) {
185
311
  if (!isPlain(op.data))
186
312
  throw lerr('write.patch_not_object');
187
313
  splitPatch(op.data);
314
+ checkPatchEnds(s, op.data);
188
315
  if (op.rev !== undefined && (!Number.isInteger(op.rev) || op.rev < 1))
189
316
  throw lerr('write.bad_rev');
190
317
  // строгий режим (план 2.0, этап 4, п. 2; 7.10 №2): правка без сужения целей — отказ до записи, все строки —
@@ -194,11 +321,28 @@ export function precheckPlan(reg, steps, order, agg) {
194
321
  }
195
322
  break;
196
323
  case 'delete':
197
- // превью (без confirm) ничего не удаляет — его не сужают
324
+ // превью (без confirm) ничего не удаляет — его не сужают и не сверяют
325
+ if (op.expect !== undefined) {
326
+ if (!op.confirm)
327
+ throw lerr('write.expect_without_confirm');
328
+ checkExpect(op.expect, verb);
329
+ }
330
+ // 3.0: удаление сверяется с превью, которое видел пользователь (expect); без сверки — только осознанно, { all: true }
331
+ if (op.confirm && op.expect === undefined && !op.all)
332
+ throw lerr('write.confirm_without_expect');
198
333
  if (op.confirm && !op.all && !narrowed(steps, i))
199
- throw lerr('write.no_filter', { call: 'delete({ confirm: true })', cls: s.cls.name });
334
+ throw lerr('write.no_filter', { call: 'delete({ confirm: true, expect })', cls: s.cls.name });
200
335
  break;
201
336
  case 'restore':
337
+ // partial (второй раунд 2.1, A3) — только с expect: пропуск считается по показанным надгробиям
338
+ if (op.partial && op.expect === undefined)
339
+ throw lerr('write.partial_without_expect');
340
+ if (op.expect !== undefined)
341
+ checkExpect(op.expect, verb);
342
+ // 3.0: без expect — отказ, и для явных id тоже: restore вернул бы последнее надгробие, а не то, что видел
343
+ // пользователь (объект могли восстановить, поменять и удалить снова); вернуть всё без сверки — { all: true }
344
+ else if (!op.all)
345
+ throw lerr('write.restore_without_expect');
202
346
  break;
203
347
  }
204
348
  for (const sl of s.slots ?? []) {
@@ -349,7 +493,8 @@ async function slotIds(w, v) {
349
493
  // отдаёт строки чтения (план 2.0, 7.2 №3: раньше — TypeError)
350
494
  precheckPlan(w.reg, v.plan);
351
495
  const r = await execPlan(w, v.plan, v.mods, 'rows');
352
- rows = r.kind === 'rows' ? r.rows : r.res;
496
+ // чтение хвоста отдаёт строки в обёртке { row } (readIn, режим 'rows') — как в батче, распаковать
497
+ rows = r.kind === 'rows' ? r.rows : r.res.map((x) => toRow(x.row));
353
498
  }
354
499
  else {
355
500
  rows = await readRows(w, v.plan, v.mods);
@@ -462,6 +607,11 @@ function inTargetOrder(ids, rows) {
462
607
  const pos = new Map(ids.map((id, i) => [id, i]));
463
608
  return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
464
609
  }
610
+ /** Шаги сужены только по id: фильтр — id или список id, без tags(), owner() и обхода deep(). */
611
+ function idsOnly(steps) {
612
+ return steps.every((s) => (s.filter === undefined || typeof s.filter === 'string' || (Array.isArray(s.filter) && s.filter.every((x) => typeof x === 'string')))
613
+ && s.tagsFilter === undefined && s.ownerFilter === undefined);
614
+ }
465
615
  /** Цели update и delete: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
466
616
  async function targetIds(w, steps, opStep, dataId) {
467
617
  const path = steps.map(bare);
@@ -478,7 +628,15 @@ async function updateOp(w, steps, opStep) {
478
628
  // data.id — цель, только если у шага нет своего фильтра (write.ts:461-466 в 0.21); тогда id не данные
479
629
  const useDataId = opStep.filter === undefined && typeof op.data.id === 'string';
480
630
  const { id: dataId, ...data } = op.data;
481
- const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
631
+ let ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
632
+ // цели сужены не только по id (данные, концы, tags(), owner()): как lockedRead — блокировка целей и то же чтение заново;
633
+ // правятся только строки, которые и после ожидания блокировки подходят под условия (план 2.1, A4). Без гонки — те же
634
+ // цели; строка, которую не дала заблокировать RLS, остаётся в целях — её объяснит explainMissing
635
+ if (ids.length && !idsOnly(steps)) {
636
+ const locked = await lockIds(w.q, S, ids);
637
+ const fresh = new Set(await targetIds(w, steps, opStep, useDataId ? dataId : undefined));
638
+ ids = ids.filter((id) => fresh.has(id) || !locked.has(id));
639
+ }
482
640
  if (op.rev !== undefined) {
483
641
  if (ids.length > 1)
484
642
  throw lerr('write.rev_many_targets', { step: opStep.name, count: ids.length });
@@ -508,24 +666,53 @@ async function updateOp(w, steps, opStep) {
508
666
  }
509
667
  return inTargetOrder(ids, res.map((r) => toRow(r.row)));
510
668
  }
669
+ /** Превью удаления целей: то же замыкание, что у триггера удаления (ref_action), база не меняется. */
670
+ async function previewOf(w, ids) {
671
+ const S = quoteId(w.schema);
672
+ const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
673
+ + `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
674
+ return res.map((r) => {
675
+ const row = toRow(r.row);
676
+ row.$action = r.action;
677
+ if (r.depth !== null)
678
+ row.$depth = r.depth;
679
+ return row;
680
+ });
681
+ }
682
+ /** Превью совпадает с показанным: те же строки по id, с теми же rev и $action (порядок не важен). */
683
+ function samePreview(now, expect) {
684
+ if (now.length !== expect.length)
685
+ return false;
686
+ const want = new Map(expect.map((x) => [x.id.toLowerCase(), x]));
687
+ return now.every((r) => {
688
+ const x = want.get(r.id.toLowerCase());
689
+ return x !== undefined && x.rev === r.rev && (x.$action === undefined || x.$action === r.$action);
690
+ });
691
+ }
511
692
  async function deleteOp(w, steps, opStep) {
512
693
  const op = opStep.op;
513
694
  const S = quoteId(w.schema);
514
695
  const ids = await targetIds(w, steps, opStep);
696
+ if (!op.confirm)
697
+ return ids.length ? previewOf(w, ids) : [];
698
+ // delete({ confirm: true, expect }): удалить ровно показанное (план 2.1, A2) — превью заново в той же транзакции.
699
+ // Атомарно (второй раунд 2.1, A1): всё замыкание каскада (строки превью с $action 'delete') блокируется for update в
700
+ // порядке id, и превью строится ещё раз уже под блокировкой. Вставка связи берёт на своих концах for key share
701
+ // (60-write.sql, targets_bad): с for update она конфликтует, с for no key update — нет. Вставщик успел первым —
702
+ // пересчёт видит его строку (preview_changed); мы первые — он получает end_target_missing. Взаимоблокировку при росте
703
+ // замыкания повторяет runTx (40P01)
704
+ if (op.expect !== undefined) {
705
+ let now = ids.length ? await previewOf(w, ids) : [];
706
+ const closure = now.filter((r) => r.$action === 'delete').map((r) => r.id);
707
+ if (closure.length) {
708
+ await lockIds(w.q, S, closure, 'update');
709
+ now = await previewOf(w, ids);
710
+ }
711
+ if (!samePreview(now, op.expect))
712
+ throw lerr('write.preview_changed', undefined, { detail: { preview: now } });
713
+ }
515
714
  if (!ids.length)
516
715
  return [];
517
- if (!op.confirm) {
518
- // превью: то же замыкание, что у триггера удаления (ref_action), база не меняется
519
- const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
520
- + `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
521
- return res.map((r) => {
522
- const row = toRow(r.row);
523
- row.$action = r.action;
524
- if (r.depth !== null)
525
- row.$depth = r.depth;
526
- return row;
527
- });
528
- }
529
716
  // результат — надгробия этой транзакции после метки: delete … returning не видит каскад триггера
530
717
  const [{ mark }] = await query(w, `select coalesce(max(seq), 0)::text as mark from ${S}.log where tx = pg_current_xact_id()`, []);
531
718
  const gone = await query(w, `delete from ${S}.entity e where e.id = any ($1::uuid[]) returning ${rowJson('e')} as row`, [ids]);
@@ -548,25 +735,155 @@ async function deleteOp(w, steps, opStep) {
548
735
  }
549
736
  return out;
550
737
  }
551
- /** Удалённые объекты шага (restore): живые цели без удалённых — not_deleted. */
552
- async function deletedTargets(w, steps, opStep, verb) {
553
- const rows = await readRows(w, steps.map(bare), { ...(opStep.opMods ?? {}), withDeleted: true });
738
+ /** Причины отказа для строк шага restore (живых или надгробий не той версии): одним запросом к журналу. */
739
+ async function refusals(w, rows, want) {
740
+ if (!rows.length)
741
+ return [];
742
+ const S = quoteId(w.schema);
743
+ const exp = rows.map((r) => want?.get(r.id.toLowerCase()) ?? null);
744
+ const log = await query(w, `select x.id::text as id, `
745
+ + `(select l.op from ${S}.log l where l.id = x.id and l.rev > x.exp order by l.rev limit 1) as after, `
746
+ + `(select l.op from ${S}.log l where l.id = x.id and l.rev > (select max(t.rev) from ${S}.log t where t.id = x.id and t.op = 'delete') `
747
+ + `order by l.rev limit 1) as after_last from unnest($1::uuid[], $2::int[]) as x(id, exp)`, [rows.map((r) => r.id), exp]);
748
+ const by = new Map(log.map((r) => [r.id.toLowerCase(), r]));
749
+ return rows.map((r, i) => {
750
+ const l = by.get(r.id.toLowerCase());
751
+ const live = r.$deleted !== true;
752
+ const last = l?.after_last ?? null;
753
+ const why = live && last === 'restore' ? 'restored' : live && last === 'create' ? 'reissued' : 'changed';
754
+ return { id: r.id, rev: r.rev, expected: exp[i], after: l?.after ?? null, deleted: !live, live, why };
755
+ });
756
+ }
757
+ /**
758
+ * Отказ restore по смыслу (второй раунд 2.1, A2): есть `changed` — restore_stale (conflict), иначе есть `reissued` —
759
+ * restore_id_taken, иначе restore_already (оба — not_deleted). detail.ids — цели этого ключа; с expect (и всегда у
760
+ * restore_stale) detail.stale — все цели, которые не восстановить, с причиной.
761
+ */
762
+ function restoreRefusal(verb, list, withExpect, cause) {
763
+ const of = (why) => list.filter((x) => x.why === why).map((x) => x.id);
764
+ const extra = (detail) => ({ detail, ...(cause !== undefined ? { cause } : {}) });
765
+ const stale = withExpect ? { stale: list } : {};
766
+ const changed = of('changed');
767
+ if (changed.length)
768
+ return lerr('write.restore_stale', { ids: changed.join(', ') }, extra({ ids: changed, stale: list }));
769
+ const taken = of('reissued');
770
+ if (taken.length)
771
+ return lerr('write.restore_id_taken', { verb, ids: taken.join(', ') }, extra({ ids: taken, ...stale }));
772
+ const done = of('restored');
773
+ return lerr('write.restore_already', { verb, ids: done.join(', ') }, extra({ ids: done, ...stale }));
774
+ }
775
+ /** Строка ответа restore({ expect, partial: true }) для цели, которую не восстановить (второй раунд 2.1, A3). */
776
+ const skipRow = (r, why) => ({ ...r, $action: 'skip', $why: why });
777
+ const byIdOrder = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
778
+ /**
779
+ * Удалённые объекты шага (restore). Шаг цели читается с надгробиями; у шагов до него withDeleted() — свой (план 2.1, A9).
780
+ * Без expect: восстанавливаются удалённые цели; если удалённых нет, а живые есть — отказ по смыслу (restoreRefusal: уже
781
+ * восстановлен, id занят новым объектом) или not_deleted (не удалялся). С expect — только цели из expect, и последняя
782
+ * версия каждой — то же надгробие; иначе отказ по смыслу и ничего не восстановлено, а с partial — строка-пропуск.
783
+ */
784
+ async function deletedTargets(w, steps, opStep, verb, expect, partial) {
785
+ const path = steps.map(bare);
786
+ path[path.length - 1] = { ...path[path.length - 1], withDeleted: true };
787
+ const rows = await readRows(w, path, opStep.opMods ?? {});
788
+ // без явного порядка — по id: restore пишет журнал по очереди целей
789
+ const ordered = (xs) => (opStep.opMods?.order ? xs : [...xs].sort(byIdOrder));
790
+ if (expect !== undefined) {
791
+ const want = new Map(expect.map((x) => [x.id.toLowerCase(), x.rev]));
792
+ const mine = rows.filter((r) => want.has(r.id.toLowerCase()));
793
+ const good = (r) => r.$deleted === true && r.rev === want.get(r.id.toLowerCase());
794
+ const bad = await refusals(w, mine.filter((r) => !good(r)), want);
795
+ if (bad.length && !partial)
796
+ throw restoreRefusal(verb, bad, true);
797
+ const why = new Map(bad.map((x) => [x.id, x.why]));
798
+ const all = ordered(mine);
799
+ return { dead: all.filter(good), skip: all.filter((r) => !good(r)).map((r) => skipRow(r, why.get(r.id) ?? 'changed')), order: all.map((r) => r.id) };
800
+ }
554
801
  const dead = rows.filter((r) => r.$deleted);
555
- if (!dead.length && rows.length)
802
+ if (!dead.length && rows.length) {
803
+ const live = await refusals(w, rows, null);
804
+ if (live.some((x) => x.why !== 'changed'))
805
+ throw restoreRefusal(verb, live.filter((x) => x.why !== 'changed'), false);
556
806
  throw lerr('write.not_deleted', { verb }, { detail: { ids: rows.map((r) => r.id) } });
557
- // без явного порядка — по id: restore пишет журнал по очереди целей
558
- return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
807
+ }
808
+ const all = ordered(dead);
809
+ return { dead: all, skip: [], order: all.map((r) => r.id) };
810
+ }
811
+ /** Отказ функции базы restore() по смыслу (уже восстановлен, id занят, версия не та) — причина и подробности; иначе null. */
812
+ function dbRefusal(e) {
813
+ const le = fromDbError(e);
814
+ if (!(le instanceof LetopisError))
815
+ return null;
816
+ const why = le.key === 'sql-time.restore_already' ? 'restored' : le.key === 'sql-time.restore_id_taken' ? 'reissued'
817
+ : le.key === 'sql-time.restore_stale' ? 'changed' : undefined;
818
+ if (!why)
819
+ return null;
820
+ const d = (le.detail ?? {});
821
+ const p = le.params ?? {};
822
+ return {
823
+ id: String(d.id ?? p.id ?? ''), rev: Number(d.rev ?? p.rev ?? 0), expected: d.expected ?? (typeof p.expected === 'number' ? p.expected : null),
824
+ after: d.after ?? null, deleted: d.deleted ?? false, live: d.live ?? why !== 'changed', why,
825
+ };
559
826
  }
560
- /** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
827
+ /**
828
+ * Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. С expect
829
+ * база ещё раз сверяет номер последней версии под блокировкой записи журнала (restore(id, rev), план 2.1, A1): гонку
830
+ * между чтением и восстановлением ловит она, и её отказ — по смыслу, как у проверки до записи (второй раунд 2.1, A2).
831
+ * partial (A3): цели, которые не восстановить, — строки-пропуски { …строка, $action: 'skip', $why }, остальные
832
+ * восстанавливаются; каждая цель — под своей точкой сохранения, проигранная гонка тоже становится пропуском.
833
+ */
561
834
  async function restoreOp(w, steps, opStep) {
562
835
  const S = quoteId(w.schema);
563
- const dead = await deletedTargets(w, steps, opStep, 'restore');
836
+ const { expect, partial } = opStep.op;
837
+ const { dead, skip, order } = await deletedTargets(w, steps, opStep, 'restore', expect, partial);
564
838
  if (!dead.length)
565
- return [];
566
- const ids = dead.map((r) => r.id);
567
- await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
568
- const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids]);
569
- return inTargetOrder(ids, res.map((r) => toRow(r.row)));
839
+ return inTargetOrder(order, skip);
840
+ let ids = dead.map((r) => r.id);
841
+ if (expect === undefined) {
842
+ // прежний вызов restore(id): работает и со схемой прежней ревизии
843
+ try {
844
+ await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
845
+ }
846
+ catch (e) {
847
+ const r = dbRefusal(e);
848
+ if (r && r.why !== 'changed')
849
+ throw restoreRefusal('restore', [r], false, e);
850
+ throw e;
851
+ }
852
+ }
853
+ else if (!partial) {
854
+ try {
855
+ await query(w, `select ${S}.restore(u.x, u.r) from unnest($1::uuid[], $2::int[]) with ordinality as u(x, r, i) order by u.i`, [ids, dead.map((r) => r.rev)]);
856
+ }
857
+ catch (e) {
858
+ const r = dbRefusal(e);
859
+ if (r)
860
+ throw restoreRefusal('restore', [r], true, e);
861
+ throw e;
862
+ }
863
+ }
864
+ else {
865
+ const lost = new Set();
866
+ for (const r of dead) {
867
+ await w.q.unsafe('savepoint letopis_restore');
868
+ try {
869
+ await query(w, `select ${S}.restore($1::uuid, $2::int)`, [r.id, r.rev]);
870
+ await w.q.unsafe('release savepoint letopis_restore');
871
+ }
872
+ catch (e) {
873
+ await w.q.unsafe('rollback to savepoint letopis_restore');
874
+ const why = dbRefusal(e)?.why;
875
+ if (!why)
876
+ throw e;
877
+ lost.add(r.id);
878
+ skip.push(skipRow(r, why));
879
+ }
880
+ }
881
+ ids = ids.filter((id) => !lost.has(id));
882
+ }
883
+ const res = ids.length
884
+ ? await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids])
885
+ : [];
886
+ return inTargetOrder(order, [...res.map((r) => toRow(r.row)), ...skip]);
570
887
  }
571
888
  async function opCall(w, steps, opStep) {
572
889
  await useReason(w, reasonFor(w, opStep));
@@ -632,10 +949,11 @@ export async function execPlan(w, steps, mods, mode, agg) {
632
949
  else {
633
950
  last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
634
951
  }
635
- // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире)
952
+ // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после restore — от
953
+ // восстановленных, без строк-пропусков partial
636
954
  start = kind === 'delete' && opStep.op.confirm
637
955
  ? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
638
- : last;
956
+ : kind === 'restore' ? last.filter((r) => r.$action !== 'skip') : last;
639
957
  prevOp = opStep;
640
958
  }
641
959
  // причина глагола не переходит на следующие операторы транзакции
package/llms.txt CHANGED
@@ -1,30 +1,50 @@
1
1
  # letopis
2
2
 
3
- > Хранилище версионированных объектов для Node.js на PostgreSQL 18. Каждое изменение — версия в журнале (история, чтение на момент, подписка по курсору). Целостность, изоляцию арендаторов и права доступа проверяет сама база (триггеры и политики RLS), поэтому они действуют и для сырого SQL. Данные читают и пишут цепочками вида `db.Shop(shop).Product().rows()`.
3
+ > ИИ-агенту: код на letopis пишите по [AGENT-CHEATSHEET.md](AGENT-CHEATSHEET.md) — памятка лежит в пакете рядом с этим файлом (`node_modules/letopis/AGENT-CHEATSHEET.md`); прочитайте её целиком: «Правила для любой задачи», рабочий цикл и «Грабли».
4
+ >
5
+ > Хранилище версионированных объектов для Node.js на PostgreSQL 18, версия 3.0. Каждое изменение — версия в журнале (история, чтение на момент, подписка по курсору). Целостность, изоляцию арендаторов и права доступа проверяет сама база (триггеры и политики RLS), поэтому они действуют и для сырого SQL. Данные читают и пишут цепочками вида `db.Team(g).Item().rows()`.
6
+
7
+ Безопасность по умолчанию (одна строка — одно правило):
8
+
9
+ - Удаление — только по превью: `delete()` → показать → `delete({ confirm: true, expect: превью })`; `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`).
10
+ - Восстановление — только по надгробиям: `restore({ expect: ответ delete() })`; `restore()` без `expect`, и по явному id тоже, — `invalid_query` (`write.restore_without_expect`).
11
+ - Массовое и без сверки — только явно и только в осознанных скриптах: `{ all: true }` у `update`, `delete({ confirm: true, all: true })`, `restore({ all: true })`.
12
+ - Арендатор — только из серверной сессии (`svc.auth.session(token)`), никогда из параметра клиента.
13
+ - Права по умолчанию запрещают; подключение в обход RLS — `bypass_rls`, пока нет `allowBypassRls: true`.
14
+ - `createTenant(admin, …)` — `admin` только пул `postgres()`, иначе `invalid_query` (`api.create_tenant_admin`).
15
+ - Журнал событий маскирует значения полей с паролями, токенами, ключами, секретами и кредами (и полей из `redact`), учётные данные в строке подключения и пароль в тексте SQL; значения параметров SQL, тексты ошибок PostgreSQL и поле `data` строк в подробностях ошибок не пишет. Значения фильтров чтения в `chain` пишутся как есть — персональные поля добавьте в `redact`.
4
16
 
5
17
  Главное для кода на letopis:
6
18
 
7
- - Пакет ESM, Node 22+, PostgreSQL 18+ с pgcrypto, кодировка базы UTF8. Зависимость времени выполнения — `postgres`. Zod 4 — необязательная peer-зависимость, нужна только `letopis/model`; `@modelcontextprotocol/server` — только `letopis/mcp`.
8
- - Схему ставит `up({ dsn, schema })` от администраторской роли; имя схемы — `v2.<имя>`; `up({ upgrade: true })` обновляет схему отставшей ревизии (миграции и повторный накат), `up({ schema, fresh: true })` пересоздаёт схему — данные теряются. Приложение подключается ролью LOGIN — членом `letopis_app`: `connect({ dsn, schema: 'v2.<имя>', token | apiKey })`. Роль в обход RLS отклоняется (`bypass_rls`), если не передать `allowBypassRls: true`.
9
- - Классы — хабы (объекты) и связи (отношения с концами). Их описывают `hub()` и `link()` из `letopis/model` и пишут в базу `syncModels()` или `letopis sync`. У класса с ключом id детерминированный, UUID v5 (`db.idOf`); уникальность — по ключу.
10
- - Глаголы возвращают цепочку; терминал (`rows`, `first`, `ids`, `count`, …) исполняет весь план одной транзакцией. `create` только создаёт (занятый ключ — `exists`), `update` сливает и никогда не создаёт, `upsert` заменяет объект целиком (только у классов с ключом), `delete()` — превью, `delete({ confirm: true })` удаляет.
11
- - Строгий режим. `connect({ models })` или `connect<Models>()` с файлом `letopis types` дают строгие типы: шаг — только класс или алиас моделей и системных классов, переходы — по концам, слоты — роли класса, поля фильтров, `or()` и агрегатов — поля класса; имя, известное только во время выполнения, — `loose(db)[имя]()`. Во время выполнения: `update()` и `delete({ confirm: true })` без фильтра, id, `tags()`, `owner()` и `limit()` ни у одного шага — `invalid_query` (все строки — `{ all: true }`); неизвестное поле фильтра, `sort()` или агрегата — `invalid_query` с похожими именами (сверяется первый ключ пути), неизвестный класс или роль — `invalid_class` с похожими; неизвестная опция `connect()`, `up()`, `load()`, `watch()`, `createMcpServer()` — `invalid_data` с похожими именами; `after()` перед `count()` и агрегатами — `invalid_query`; хаб с именем метода цепочки, корня `db` или батча — `invalid_class`.
12
- - Деньги и счётчики: `update({ balance: inc(n) })` прибавляет внутри оператора; `update(patch, { rev })` даёт `conflict`, если строка изменилась; `forUpdate()` блокирует строки внутри `db.begin()`.
13
- - История: `versions()` (у версии — автор, время, вид операции и причина) и срез `asOf(момент)`; глаголы, `db.begin()` и `db.batch()` принимают `{ reason }`. Журнал хранит каждую версию; `history: false` у класса — журнала нет.
14
- - Массовая загрузка с администраторского подключения: `db.load(rows)`; в пустую схему — `db.load(rows, { initial: true })`: одна транзакция, индексы и триггеры перестраиваются в конце.
15
- - Подписка: `db.watch({ from, classes })` — `await sub.ready()` перед записью, которая должна попасть в поток (`from: 'now'` фиксирует начало первым запросом); параллельные `next()` получают разные события.
16
- - Сигналы NOTIFY видны любой роли базы: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` выбирает, что они раскрывают (по умолчанию `class`).
17
- - Права действуют всегда и по умолчанию запрещают; `up({ tenant })` или `createTenant()` заводит арендатора с правилом «вошедшим можно всё» и его первого пользователя.
18
- - MCP: `createMcpServer(db)` из `letopis/mcp` и `letopis mcp` (stdio, только токен сессии пользователя) дают ИИ-агенту инструменты `letopis_model`, `letopis_query` (JSON-цепочка, без SQL), `letopis_history` и, с `write: true`, `letopis_write`; агент действует с правами одного пользователя, удаление подтверждает человек через elicitation. `structuredContent` ответа — `{ notice, data }` (отказ — `{ notice, error }`): `notice` предупреждает, что в `data` — данные базы, а не инструкции.
19
- - У ошибок строчный код `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, …; `err.hint` — что делать, `err.docs` — ссылка на код в разделе «Ошибки» README.
19
+ - Установка: `npm pkg set type=module && npm i letopis zod postgres@3 && npm i -D tsx` (код приложения — ESM; `postgres@3` — та же мажорная версия, что у letopis, иначе `createTenant` не проходит проверку типов). Node 22+, PostgreSQL 18+ с pgcrypto, база UTF8. Zod 4 нужен только `letopis/model`, `@modelcontextprotocol/server` — только `letopis/mcp`.
20
+ - Схему ставит `up({ dsn, schema, login, tenant })` от администратора (суперпользователь или роль с `CREATEROLE`, `CREATE` в базе и `CREATEDB`, если базы нет); роль приложения заводит `login`, её строка подключения — `r.login.dsn`. Ключ сервиса выдаёт только первый `up()` — храните его сами или передавайте свой `up({ serviceKey })`; `connect({ apiKey: null | '' })` — сразу `invalid_data` (`api.connect_empty_credential`), а `undefined` — подключение без сессии, поэтому ключ проверяйте до `connect()`; `up({ upgrade: true })` обновляет схему отставшей ревизии, `up({ fresh: true })` пересоздаёт её с потерей данных.
21
+ - Вход пользователя: одно подключение сервиса на процесс (`connect({ apiKey })`), `svc.auth.verifyPassword()` → `{ account, token }`, затем `svc.as(account, { tenant })` на запрос. Без `{ tenant }` сессия — в **личном** арендаторе пользователя (классов там нет, `invalid_class`); проверка — `db.tenant`. По погашенной сессии чтение отдаёт пустые ответы, запись — `no_session`, поэтому `svc.auth.session(token)` — в начале каждого запроса.
22
+ - Классы — хабы (объекты) и связи (отношения с концами): `hub()` и `link()` из `letopis/model`, в базу — `syncModels()` или `letopis sync` (в арендатора сессии). Имя роли конца одно на арендатора; роли `Group`, `Object`, `User`, `Tenant` заняты системными классами `rule` и `member` — конец с таким именем на другой класс — `invalid_class` (`model.end_role_system`).
23
+ - Уникальность и инварианты держит ключ, а не проверка в коде: у класса с ключом id — UUID v5 (`db.idOf`), второй такой `create()` — `exists`, и под гонкой. «Одна открытая запись на объект» — связь с ключом по концу (`key: ['Obj']`); `restore()` бизнес-правил не проверяет, поэтому и при возврате инвариант держит только ключ.
24
+ - Глаголы возвращают цепочку; терминал (`rows`, `first`, `ids`, `count`, …) исполняет весь план одной транзакцией. `create` только создаёт, `update` сливает патч и никогда не создаёт (`undefined` в патче — поле не меняется), `upsert` заменяет данные целиком (поле с `undefined` пропадает; только у классов с ключом).
25
+ - Удаление ровно показанного атомарно: превью сверяется под блокировкой каскада, расхождение — `conflict` (`write.preview_changed`) с новым превью в `err.detail.preview`. Целей нет: непустой `expect` — `conflict` с `err.detail.preview: []` («уже удалено или не найдено»), `expect: []` — пустой ответ.
26
+ - Возврат — по ответу `delete()` (или надгробиям `withDeleted()` по id): `restore({ expect })` — всё или отказ по смыслу (`write.restore_already`, `write.restore_id_taken` — `not_deleted`; `write.restore_stale` — `conflict`); `restore({ expect, partial: true })` — что можно, пропуски — строки `$action: 'skip'` с `$why`. Цель конца — первой, каскад — следующим шагом, оба — в одной `db.begin()` с `commit()`/`rollback()`.
27
+ - Гонка за прочитанное — `forUpdate()` в `db.begin()` (только с `rows`, `first`, `ids`), `update(patch, { rev })` (`conflict`) или ключ; счётчик — `update({ n: inc(k) })`. Фильтр `update()` по данным, концам, `tags()`, `owner()` перепроверяется под блокировкой строки: выведенную параллельной транзакцией из-под фильтра строку он не правит (из двух одновременных правок со `status: 'new'` проходит одна); фильтр только по id — пройдут обе. Форма транзакции — `const tr = await db.begin(); try { …; await tr.commit() } catch (e) { await tr.rollback(); throw e }`; `db.begin(fn)` — `invalid_query`.
28
+ - Строгий режим: `connect({ models })` или `connect<Models>()` дают строгие типы; неизвестное поле, класс или опция — отказ с похожими именами; `update()` без фильтра — `invalid_query` (все строки — `{ all: true }`); id не uuid — `invalid_query` до запроса; фильтр `undefined` — шаг без фильтра и предупреждение.
29
+ - Концы связи — не поля данных: из пути или слотом `.Роль.set(id)`; имя конца в данных — `invalid_data` (`write.link_in_data`).
30
+ - Модификаторы выборки (`sort`, `limit`, `offset`, `after`, `forUpdate`) — последнему шагу; `withDeleted()` — своему шагу. `versions()` — терминал; по пути — только живые, с удалёнными — `withDeleted()` на шаге или явный id.
31
+ - Ответы и ошибки: строка — `{ id, class, rev, links, data, … }`, превью — с `$action`, надгробие — `$deleted: true`; ошибка — `LetopisError` с `err.code`, `err.key`, `err.detail`, `err.hint`, у данных — `ValidationError` с `err.issues: [{ path, keyword, message }]`. Штатные отказы (`exists`, `conflict`, `not_deleted`, `target_not_found`, `tenant_denied`, `acl_denied`, `no_session`, …) — ответ человеку своими фразами по `err.key`/`err.code`; ошибки кода — 500.
32
+ - Логирование: `connect({ log, logLevel, redact })` и `up({ … })` — функция или объект-логгер (`console`, pino), порог `debug|info|warn|error|off` (по умолчанию `info`); без кода — `LETOPIS_LOG=info` (JSON-строки в stderr), CLI — `--log[=уровень]`. События `delete` и `restore` — что удалено и что не вернулось; внутри `db.begin()` событие значит «выполнено в транзакции», фиксацию доказывает `tx.commit` с тем же `tx`. Штатные отказы — событие `error` уровня `warn`, ошибки кода — уровня `error`.
33
+ - Обслуживание — `maintain()` (администраторское подключение, системный администратор или право `letopis.maintain`, иначе `acl_denied`); загрузка — `db.load(rows)` с администраторского подключения; подписка — `db.watch({ from, classes })` и `await sub.ready()` перед записью.
34
+ - MCP: `createMcpServer(await svc.as(пользователь, { tenant }))` из `letopis/mcp` и `letopis mcp` дают ИИ-агенту `letopis_model`, `letopis_query`, `letopis_history` и, с `write: true`, `letopis_write`; удаление подтверждает человек, удаляется ровно показанное превью.
35
+ - Лицензия — PolyForm Noncommercial 1.0.0 (SPDX `PolyForm-Noncommercial-1.0.0`): исходный код доступен, некоммерческое использование бесплатно, коммерческое — только по отдельной лицензии владельца (запрос — issue на https://github.com/alepri51/letopis/issues).
20
36
 
21
37
  ## Документы
22
38
 
23
- - [Шпаргалка для агента](AGENT-CHEATSHEET.md): одна страница для ИИ-агента, который пишет код на letopis, — рабочий цикл и грабли
39
+ - [Памятка для агента](AGENT-CHEATSHEET.md): общие правила для любой задачи на letopis, рабочий цикл одной программой и грабли
24
40
  - [Руководство](README.md): руководство по задачам, справочник API, ограничения
25
- - [Ошибки](README.md#15-ошибки): все коды — что значит и что делать
41
+ - [Безопасность по умолчанию](README.md#безопасность-по-умолчанию): что отклоняется без сверки и как это сделать осознанно
42
+ - [Логирование](README.md#логирование): опции, уровни, `LETOPIS_LOG`, формат события, маскировка, список событий
43
+ - [Ошибки](README.md#15-ошибки): все коды и ключи — что значит, что делать и что сказать человеку
44
+ - Машинный каталог API и ошибок — `api-contract.json` в пакете (`node_modules/letopis/api-contract.json`)
26
45
 
27
46
  ## Дополнительно
28
47
 
29
- - [Изменения по версиям](CHANGELOG.md): решения по версиям, несовместимые изменения
48
+ - [Изменения по версиям](CHANGELOG.md): что изменилось при переходе на 3.0 и чем заменить снятое
49
+ - [Лицензия](LICENSE) и [коммерческое использование](LICENSE-COMMERCIAL.md): что можно без разрешения и как получить коммерческую лицензию
30
50
  - Модель конкретной установки для агента — инструмент MCP `letopis_model`: классы, ключи, поля, концы и рабочие цепочки этой схемы