letopis 1.4.2 → 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 (112) hide show
  1. package/AGENT-CHEATSHEET.md +329 -281
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +572 -1053
  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 -184
  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 +30 -8
  12. package/dist/chain.d.ts +18 -41
  13. package/dist/chain.js +165 -203
  14. package/dist/cli.js +45 -90
  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 -70
  21. package/dist/index.js +189 -122
  22. package/dist/internal.d.ts +5 -0
  23. package/dist/jsonschema.js +18 -17
  24. package/dist/load.d.ts +20 -12
  25. package/dist/load.js +207 -181
  26. package/dist/mcp.d.ts +5 -7
  27. package/dist/mcp.js +168 -214
  28. package/dist/messages/access.d.ts +90 -0
  29. package/dist/messages/access.js +113 -0
  30. package/dist/messages/api.d.ts +171 -0
  31. package/dist/messages/api.js +215 -0
  32. package/dist/messages/boot.d.ts +201 -0
  33. package/dist/messages/boot.js +256 -0
  34. package/dist/messages/codes.d.ts +172 -0
  35. package/dist/messages/codes.js +173 -0
  36. package/dist/messages/index.d.ts +95 -0
  37. package/dist/messages/index.js +118 -0
  38. package/dist/messages/mcp.d.ts +306 -0
  39. package/dist/messages/mcp.js +387 -0
  40. package/dist/messages/model.d.ts +233 -0
  41. package/dist/messages/model.js +283 -0
  42. package/dist/messages/query.d.ts +131 -0
  43. package/dist/messages/query.js +158 -0
  44. package/dist/messages/sql-access.d.ts +70 -0
  45. package/dist/messages/sql-access.js +87 -0
  46. package/dist/messages/sql-core.d.ts +150 -0
  47. package/dist/messages/sql-core.js +181 -0
  48. package/dist/messages/sql-time.d.ts +71 -0
  49. package/dist/messages/sql-time.js +88 -0
  50. package/dist/messages/sql-write.d.ts +246 -0
  51. package/dist/messages/sql-write.js +270 -0
  52. package/dist/messages/time.d.ts +23 -0
  53. package/dist/messages/time.js +29 -0
  54. package/dist/messages/types.d.ts +27 -0
  55. package/dist/messages/types.js +5 -0
  56. package/dist/messages/write.d.ts +405 -0
  57. package/dist/messages/write.js +509 -0
  58. package/dist/model.d.ts +73 -60
  59. package/dist/model.js +20 -29
  60. package/dist/ops.d.ts +35 -0
  61. package/dist/ops.js +101 -0
  62. package/dist/pglite.d.ts +0 -9
  63. package/dist/pglite.js +3 -1
  64. package/dist/registry.d.ts +3 -19
  65. package/dist/registry.js +3 -16
  66. package/dist/signals.d.ts +1 -5
  67. package/dist/signals.js +1 -5
  68. package/dist/sql.d.ts +7 -1
  69. package/dist/sql.js +101 -85
  70. package/dist/sync.d.ts +9 -3
  71. package/dist/sync.js +40 -17
  72. package/dist/tx.d.ts +11 -1
  73. package/dist/tx.js +32 -11
  74. package/dist/typed.d.ts +240 -45
  75. package/dist/types.d.ts +15 -90
  76. package/dist/types.js +12 -14
  77. package/dist/up.d.ts +53 -32
  78. package/dist/up.js +180 -74
  79. package/dist/uuid.js +5 -5
  80. package/dist/validate.d.ts +13 -1
  81. package/dist/validate.js +61 -29
  82. package/dist/watch.d.ts +5 -16
  83. package/dist/watch.js +23 -77
  84. package/dist/write.d.ts +9 -16
  85. package/dist/write.js +102 -185
  86. package/llms.txt +22 -25
  87. package/package.json +2 -9
  88. package/sql/10-core.sql +7 -50
  89. package/sql/15-errors.sql +64 -10
  90. package/sql/20-context.sql +7 -6
  91. package/sql/30-validate.sql +35 -27
  92. package/sql/40-class.sql +20 -114
  93. package/sql/50-acl.sql +8 -13
  94. package/sql/60-write.sql +220 -676
  95. package/sql/70-read.sql +11 -13
  96. package/sql/80-auth.sql +23 -27
  97. package/sql/90-time.sql +77 -944
  98. package/sql/95-seed.system.sql +7 -14
  99. package/sql/99-revision.sql +2 -1
  100. package/sql/README.md +32 -24
  101. package/sql/migrate/README.md +14 -0
  102. package/AGENT-CHEATSHEET.en.md +0 -488
  103. package/MIGRATION.md +0 -190
  104. package/README.en.md +0 -2910
  105. package/dist/import.d.ts +0 -185
  106. package/dist/import.js +0 -820
  107. package/dist/project.d.ts +0 -359
  108. package/dist/project.js +0 -1258
  109. package/sql/75-history.sql +0 -436
  110. package/sql/88-derived.sql +0 -1236
  111. package/sql/migrate/0006-changed-paths.sql +0 -3
  112. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/write.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Запись планами (план, §2.5, этап 4, п. 1–7, 10): глаголы create, update, upsert, delete, anonymize
3
- * и reclass — звенья цепочки; терминал исполняет весь план одной транзакцией. Этап 6: restore и
4
- * purge — над удалёнными объектами шага, rekey — новое значение ключа (функции базы).
2
+ * Запись планами (план, §2.5, этап 4, п. 1–7, 10): глаголы create, update, upsert и delete — звенья
3
+ * цепочки; терминал исполняет весь план одной транзакцией. Этап 6: restore — над удалёнными объектами
4
+ * шага (функция базы).
5
5
  *
6
6
  * await db.Org(org).Staff().create({ name: 'Вася', phone: '+7' }).rows() // концы из пути: { Org }
7
7
  * await db.Customer(c).booking({ notes: 'x' }).update({ notes: 'y' }).rows()
@@ -17,10 +17,9 @@
17
17
  * строка видна, но править нельзя — acl_denied; строки уже нет — target_not_found; номер версии
18
18
  * разошёлся со { rev } — conflict.
19
19
  */
20
- import { LetopisError, ValidationError } from './errors.js';
20
+ import { lerr, ValidationError } from './errors.js';
21
21
  import { isOp } from './ops.js';
22
- import { aggregateFields } from './registry.js';
23
- import { accepts, buildRead, nextNodeKey, resolveHop, stepClasses, toRow } from './sql.js';
22
+ import { accepts, buildRead, nextNodeKey, precheckStep, resolveHop, stepClasses, toRow } from './sql.js';
24
23
  import { idOf } from './uuid.js';
25
24
  import { applyDefaults } from './validate.js';
26
25
  // ---------------------------------------------------------------------------
@@ -40,13 +39,10 @@ export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === tr
40
39
  /** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
41
40
  export function checkReason(v, where) {
42
41
  if (typeof v !== 'string' || v.length > 1000 || v.includes('\0')) {
43
- throw new LetopisError('invalid_query', `${where}: reason — строка до 1000 символов`);
42
+ throw lerr('write.bad_reason', { where });
44
43
  }
45
44
  return v;
46
45
  }
47
- const fail = (code, msg, detail) => {
48
- throw new LetopisError(code, msg, detail);
49
- };
50
46
  const asList = (v) => (Array.isArray(v) ? v : [v]);
51
47
  const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date) && !isInc(v) && !isOp(v);
52
48
  /** Патч update → остаток для merge и список приращений. Недопустимое — invalid_data до отправки. */
@@ -54,9 +50,9 @@ export function splitPatch(patch) {
54
50
  const incs = [];
55
51
  const noMarkers = (v, path) => {
56
52
  if (isInc(v))
57
- fail('invalid_data', `inc() внутри массива (${path.join('.')}) не поддерживается`);
53
+ throw lerr('write.inc_in_array', { path: path.join('.') });
58
54
  if (isOp(v))
59
- fail('invalid_data', `оператор фильтра в данных (${path.join('.')}) — данные записываются значениями`);
55
+ throw lerr('write.op_in_data', { path: path.join('.') });
60
56
  if (Array.isArray(v))
61
57
  v.forEach((x, i) => noMarkers(x, [...path, String(i)]));
62
58
  else if (isPlain(v))
@@ -71,13 +67,13 @@ export function splitPatch(patch) {
71
67
  const p = [...path, k];
72
68
  if (isInc(v)) {
73
69
  if (!Number.isFinite(v.by))
74
- fail('invalid_data', `inc(${v.by}) по пути ${p.join('.')}: величина — конечное число`);
70
+ throw lerr('write.inc_by_not_finite', { by: v.by, path: p.join('.') });
75
71
  if (v.start !== undefined && !Number.isFinite(v.start))
76
- fail('invalid_data', `inc: start по пути ${p.join('.')} — конечное число`);
72
+ throw lerr('write.inc_start_not_finite', { path: p.join('.') });
77
73
  incs.push({ path: p, by: v.by, ...(v.start !== undefined ? { start: v.start } : {}) });
78
74
  }
79
75
  else if (isOp(v)) {
80
- fail('invalid_data', `оператор фильтра в патче (${p.join('.')}) — update записывает значения`);
76
+ throw lerr('write.op_in_patch', { path: p.join('.') });
81
77
  }
82
78
  else if (Array.isArray(v)) {
83
79
  noMarkers(v, p);
@@ -101,15 +97,15 @@ export function splitPatch(patch) {
101
97
  // ---------------------------------------------------------------------------
102
98
  // Проверки до отправки (precheckPlan)
103
99
  // ---------------------------------------------------------------------------
104
- /** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript (full — объект целиком). */
105
- function checkFullData(cls, data, verb, full = true) {
100
+ /** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript. */
101
+ function checkFullData(cls, data, verb) {
106
102
  if (!isPlain(data))
107
- fail('invalid_data', `${verb}(): данные — объект`);
103
+ throw lerr('write.data_not_object', { verb });
108
104
  const scan = (v, path) => {
109
105
  if (isInc(v))
110
- fail('invalid_data', `inc() работает только в update(): ${verb}() по пути ${path.join('.')}`);
106
+ throw lerr('write.inc_outside_update', { verb, path: path.join('.') });
111
107
  if (isOp(v))
112
- fail('invalid_data', `оператор фильтра в данных ${verb}() (${path.join('.')}) — данные записываются значениями`);
108
+ throw lerr('write.op_in_verb_data', { verb, path: path.join('.') });
113
109
  if (Array.isArray(v))
114
110
  v.forEach((x, i) => scan(x, [...path, String(i)]));
115
111
  else if (isPlain(v))
@@ -117,33 +113,45 @@ function checkFullData(cls, data, verb, full = true) {
117
113
  scan(x, [...path, k]);
118
114
  };
119
115
  scan(data, []);
120
- // агрегатные поля считает база (план 1.1, §2.4): переданное значение — aggregate_field до отправки, а для ранней
121
- // проверки поле — 0, как его поставит база
122
- const obj = data;
123
- const agg = aggregateFields(cls);
124
- const given = agg.filter((f) => f in obj);
125
- if (given.length)
126
- fail('aggregate_field', `поля ${given.join(', ')} класса ${cls.name} считает база (aggregate) — их не передают`);
127
116
  // описания системных классов проверяет только база (метасхема Class, class_admit)
128
- if (!full || cls.name === 'Class' || cls.abstract)
117
+ if (cls.name === 'Class' || cls.abstract)
129
118
  return;
130
- const issues = cls.validator()(applyDefaults(cls.schema, agg.length ? { ...obj, ...Object.fromEntries(agg.map((f) => [f, 0])) } : obj));
119
+ const issues = cls.validator()(applyDefaults(cls.schema, data));
131
120
  if (issues.length)
132
121
  throw new ValidationError(cls.name, issues);
133
122
  }
134
- /** Патч update не трогает агрегатные поля класса шага (план 1.1, §2.4): их считает база. */
135
- function checkAggregatePatch(cls, patch) {
136
- const given = aggregateFields(cls).filter((f) => f in patch);
137
- if (given.length)
138
- fail('aggregate_field', `поля ${given.join(', ')} класса ${cls.name} считает база (aggregate) — update их не меняет`);
123
+ /** Фильтр шага сужает цели: id, список id, оператор, строка ответа, объект хотя бы с одним заданным полем. */
124
+ const narrowingFilter = (f) => f !== undefined && f !== null && (typeof f !== 'object' || Array.isArray(f) || isOp(f) || Object.values(f).some((v) => v !== undefined));
125
+ /**
126
+ * Цели глагола шага i сужены (план 2.0, этап 4, п. 2; 7.10 №2): у него или у шага до него есть фильтр, id, tags()
127
+ * (пустой список подходит любой строке), owner() или limit(), либо до него был create() или upsert() — дальше путь
128
+ * идёт от записанных строк.
129
+ */
130
+ function narrowed(steps, i) {
131
+ for (let j = 0; j <= i; j++) {
132
+ const s = steps[j];
133
+ if (narrowingFilter(s.filter) || s.ownerFilter !== undefined || s.opMods?.limit !== undefined)
134
+ return true;
135
+ if (s.tagsFilter !== undefined && s.tagsFilter !== null && !(Array.isArray(s.tagsFilter) && !s.tagsFilter.length))
136
+ return true;
137
+ if (j < i && (s.op?.kind === 'create' || s.op?.kind === 'upsert'))
138
+ return true;
139
+ }
140
+ return false;
139
141
  }
140
- /** Всё, что проверяется без базы, — до отправки первого оператора (§2.5). */
141
- export function precheckPlan(reg, steps) {
142
+ /**
143
+ * Всё, что проверяется без базы, — до отправки первого оператора (§2.5). Фильтры шагов и поля sort() и агрегатов
144
+ * (неизвестное поле — invalid_query, план 2.0, этап 3, п. 3) — precheckStep построителя; order и agg — модификатор и
145
+ * агрегат чтения за последним глаголом (терминал цепочки).
146
+ */
147
+ export function precheckPlan(reg, steps, order, agg) {
142
148
  let prevOp;
143
149
  for (let i = 0; i < steps.length; i++) {
144
150
  const s = steps[i];
145
151
  const op = s.op;
152
+ const tail = i === steps.length - 1;
146
153
  if (!op) {
154
+ precheckStep(reg, s, tail ? order : undefined, tail ? agg : undefined);
147
155
  prevOp = undefined;
148
156
  continue;
149
157
  }
@@ -152,67 +160,54 @@ export function precheckPlan(reg, steps) {
152
160
  checkReason(op.reason, `${verb}()`);
153
161
  const m = s.opMods ?? {};
154
162
  if (m.asOf !== undefined)
155
- fail('invalid_query', `${verb}(): asOf описывает прошлое — записывать в него нельзя`);
156
- // restore и purge сами ищут удалённые объекты шага
157
- if (m.withDeleted && verb !== 'restore' && verb !== 'purge')
158
- fail('invalid_query', `${verb}(): withDeleted описывает удалённые — записывать в них нельзя`);
163
+ throw lerr('write.as_of_write', { verb });
164
+ // restore сам ищет удалённые объекты шага
165
+ if (m.withDeleted && verb !== 'restore')
166
+ throw lerr('write.with_deleted_write', { verb });
159
167
  switch (op.kind) {
160
168
  case 'create':
161
169
  case 'upsert':
162
170
  if (s.self)
163
- fail('invalid_query', `${verb}() сразу после ${prevOp?.kind}() — между глаголами нужен шаг`);
171
+ throw lerr('write.verb_after_verb', { verb, prev: prevOp?.kind });
164
172
  if (s.pivotKey !== undefined)
165
- fail('invalid_query', `${verb}() на возврате к узлу — он ведёт к существующей сущности, используйте update()`);
173
+ throw lerr('write.create_on_pivot', { verb });
166
174
  if (s.filter !== undefined && typeof s.filter !== 'string') {
167
- fail('invalid_query', `${verb}() не ищет: ${s.name}(id).${verb}(…) задаёт id, поиск — это update()`);
175
+ throw lerr('write.create_with_filter', { verb, step: s.name });
168
176
  }
169
177
  if (op.kind === 'upsert' && !s.cls.key.length)
170
- fail('no_key', `upsert(): у класса ${s.cls.name} нет ключа — сравнивать не с чем, используйте create()`);
178
+ throw lerr('write.upsert_no_key', { cls: s.cls.name });
171
179
  checkFullData(s.cls, op.data, verb);
172
180
  if (s.tagsFilter !== undefined && typeof s.tagsFilter !== 'string' && !Array.isArray(s.tagsFilter)) {
173
- fail('invalid_query', `.tags() перед ${verb}() — строка или список`);
181
+ throw lerr('write.bad_tags', { verb });
174
182
  }
175
183
  break;
176
184
  case 'update':
177
185
  if (!isPlain(op.data))
178
- fail('invalid_data', 'update(): патч — объект');
186
+ throw lerr('write.patch_not_object');
179
187
  splitPatch(op.data);
180
- checkAggregatePatch(s.cls, op.data);
181
188
  if (op.rev !== undefined && (!Number.isInteger(op.rev) || op.rev < 1))
182
- fail('invalid_query', 'update(patch, { rev }): rev — номер версии строки');
183
- break;
184
- case 'anonymize': {
185
- if (!Array.isArray(op.fields) || !op.fields.length)
186
- fail('invalid_query', 'anonymize(поля): нужен список полей');
187
- for (const f of op.fields) {
188
- // поля нет в классе — так и сказано, а не «поле jsonb» (план 2.0, 7.5 №3)
189
- if (!(f in (s.cls.schema.properties ?? {})))
190
- fail('invalid_query', `anonymize(): поля ${f} нет в классе ${s.cls.name}`);
191
- const t = reg.leafType(s.cls.name, [f]);
192
- if (t !== 'text')
193
- fail('invalid_query', `anonymize() затирает только строковые поля; ${f} — ${t}`);
189
+ throw lerr('write.bad_rev');
190
+ // строгий режим (план 2.0, этап 4, п. 2; 7.10 №2): правка без сужения целей — отказ до записи, все строки —
191
+ // { all: true }; data.id у шага без фильтра — тоже цель (updateOp)
192
+ if (!op.all && !(s.filter === undefined && typeof op.data.id === 'string') && !narrowed(steps, i)) {
193
+ throw lerr('write.no_filter', { call: 'update()', cls: s.cls.name });
194
194
  }
195
195
  break;
196
- }
197
- case 'reclass':
198
- reg.resolve(op.cls);
199
- if (op.data !== undefined)
200
- checkFullData(reg.resolve(op.cls), op.data, 'reclass');
201
- break;
202
196
  case 'delete':
203
- case 'restore':
204
- case 'purge':
197
+ // превью (без confirm) ничего не удаляет — его не сужают
198
+ if (op.confirm && !op.all && !narrowed(steps, i))
199
+ throw lerr('write.no_filter', { call: 'delete({ confirm: true })', cls: s.cls.name });
205
200
  break;
206
- case 'rekey':
207
- if (!s.cls.key.length)
208
- fail('no_key', `rekey(): у класса ${s.cls.name} нет ключа — id выдаёт база, менять нечего`);
209
- checkFullData(s.cls, op.data, 'rekey', false);
201
+ case 'restore':
210
202
  break;
211
203
  }
212
204
  for (const sl of s.slots ?? []) {
213
205
  if (op.kind !== 'create' && op.kind !== 'update' && op.kind !== 'upsert')
214
- fail('invalid_query', `слот .${sl.role} после ${verb}() — слоты у create(), update() и upsert()`);
206
+ throw lerr('write.slot_bad_verb', { role: sl.role, verb });
215
207
  }
208
+ precheckStep(reg, s, s.opMods?.order);
209
+ if (tail && (order !== undefined || agg !== undefined))
210
+ precheckStep(reg, s, order, agg);
216
211
  prevOp = op;
217
212
  }
218
213
  }
@@ -228,7 +223,7 @@ const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
228
223
  /** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
229
224
  const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
230
225
  + `'links', ${a}.links, 'data', ${a}.data, 'tags', ${a}.tags, 'at', ${a}.at, 'author', ${a}.author, 'agent', ${a}.agent, `
231
- + `'op', ${a}.op, 'reason', ${a}.reason, 'moved', ${a}.moved)`;
226
+ + `'op', ${a}.op, 'reason', ${a}.reason)`;
232
227
  async function query(w, text, params) {
233
228
  return (await w.q.unsafe(text, params, { prepare: true }));
234
229
  }
@@ -254,16 +249,18 @@ export function slotRole(reg, owner, name) {
254
249
  return name;
255
250
  const c = reg.find(name);
256
251
  if (!c)
257
- fail('invalid_class', `класс или роль ${name} не найдены`);
252
+ throw lerr('write.slot_unknown', { name });
258
253
  const roles = rolesAccepting(reg, owner, c.name);
259
254
  if (roles.length === 1)
260
255
  return roles[0];
261
256
  if (!roles.length)
262
- fail('invalid_query', `${name} — не конец класса ${owner.name} (концы: ${Object.keys(owner.ends).join(', ') || '—'})`);
263
- return fail('invalid_query', `слот ${name} у класса ${owner.name} неоднозначен: роли ${roles.join(', ')} — назовите роль`);
257
+ throw lerr('write.slot_not_end', { name, cls: owner.name, ends: Object.keys(owner.ends).join(', ') || '—' });
258
+ throw lerr('write.slot_ambiguous', { name, cls: owner.name, roles: roles.join(', ') });
264
259
  }
265
260
  /** Неоднозначная роль конца из пути. */
266
- const ambiguous = (step, target, roles) => fail('invalid_query', `конец класса ${target.name} для шага ${step.name} неоднозначен: роли ${roles.join(', ')} — назовите шаг ролью или задайте слот`);
261
+ const ambiguous = (step, target, roles) => {
262
+ throw lerr('write.end_ambiguous', { cls: target.name, step: step.name, roles: roles.join(', ') });
263
+ };
267
264
  /**
268
265
  * Роль из нескольких кандидатов: роли, заданные слотами, не участвуют; из оставшихся — роль с именем
269
266
  * класса шага (duty.Member для шага Member, а Backup — слотом). Иначе — неоднозначно.
@@ -317,7 +314,7 @@ async function pathEnds(w, steps, taken) {
317
314
  else {
318
315
  const rows = await readRows(w, steps.slice(0, k + 1).map(bare), { limit: 2 });
319
316
  if (rows.length !== 1)
320
- fail('invalid_query', `шаг ${s.name} должен дать ровно одну сущность для конца ${tcls.name} (найдено ${rows.length})`);
317
+ throw lerr('write.end_not_single', { step: s.name, cls: tcls.name, count: rows.length });
321
318
  id = rows[0].id;
322
319
  if (!roles) {
323
320
  roles = pickRole(s, tcls, rolesAccepting(w.reg, tcls, rows[0].class), taken, rows[0].class);
@@ -368,7 +365,7 @@ async function resolveSlots(w, cls, slots) {
368
365
  const end = cls.ends[sl.role];
369
366
  const ids = sl.op === 'unset' ? [] : await slotIds(w, sl.value);
370
367
  if (sl.op === 'set' && sl.value !== null && !end.many && ids.length !== 1) {
371
- fail('invalid_query', `слот .${sl.role}.set(): конец одиночный, а значение дало ${ids.length} сущностей`);
368
+ throw lerr('write.slot_single_many', { role: sl.role, count: ids.length });
372
369
  }
373
370
  out.push({ role: sl.role, op: sl.op, value: sl.op === 'unset' || sl.value === null ? null : end.many ? ids : ids[0] });
374
371
  }
@@ -405,7 +402,8 @@ function existsError(w, cls, data, links, given, cause) {
405
402
  id = undefined;
406
403
  }
407
404
  }
408
- return Object.assign(new LetopisError('exists', `объект класса ${cls.name} уже существует${id ? ` (id ${id})` : ''}`, { id: id ?? null }), { cause });
405
+ const detail = { id: id ?? null };
406
+ return id ? lerr('write.exists_id', { cls: cls.name, id }, { detail, cause }) : lerr('write.exists', { cls: cls.name }, { detail, cause });
409
407
  }
410
408
  const isPkConflict = (e) => {
411
409
  const err = e;
@@ -419,16 +417,11 @@ async function createOp(w, steps, opStep, upsert) {
419
417
  const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
420
418
  const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
421
419
  const owner = opStep.ownerFilter ?? w.owner ?? null;
422
- // агрегатные поля (план 1.1, §2.4): в excluded их поставил триггер вставки (0) — замена данных идёт без них, и
423
- // триггер правки возвращает прежние значения
424
- const agg = aggregateFields(cls);
425
- const conflict = upsert
426
- ? ` on conflict (id) do update set data = excluded.data${agg.length ? ' - $7::text[]' : ''}, links = excluded.links, tags = excluded.tags`
427
- : '';
420
+ const conflict = upsert ? ' on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags' : '';
428
421
  const text = `insert into ${S}.entity (id, class, links, data, tags, owner) values ($1::uuid, $2::text, $3::jsonb, $4::jsonb, $5::text[], $6::uuid)`
429
422
  + `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
430
423
  try {
431
- const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner, ...(upsert && agg.length ? [agg] : [])]);
424
+ const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner]);
432
425
  return res.map((r) => {
433
426
  const row = toRow(r.row);
434
427
  if (upsert)
@@ -449,12 +442,17 @@ async function createOp(w, steps, opStep, upsert) {
449
442
  async function explainMissing(w, missing, rev) {
450
443
  const S = quoteId(w.schema);
451
444
  const seen = await query(w, `select id, rev from ${S}.entity where id = any ($1::uuid[])`, [missing]);
452
- if (!seen.length)
453
- return fail('target_not_found', missing.length === 1 ? `цель ${missing[0]} не найдена` : `цели не найдены: ${missing.join(', ')}`, { ids: missing });
445
+ if (!seen.length) {
446
+ throw missing.length === 1
447
+ ? lerr('write.target_missing', { id: missing[0] }, { detail: { ids: missing } })
448
+ : lerr('write.targets_missing', { ids: missing.join(', ') }, { detail: { ids: missing } });
449
+ }
454
450
  if (rev !== undefined && seen.some((r) => r.rev !== rev)) {
455
- return fail('conflict', `строку ${seen[0].id} успели изменить: версия ${seen[0].rev}, а не ${rev} — перечитайте и повторите`, { id: seen[0].id, rev: seen[0].rev, expected: rev });
451
+ const detail = { id: seen[0].id, rev: seen[0].rev, expected: rev };
452
+ throw lerr('write.rev_conflict', detail, { detail });
456
453
  }
457
- return fail('acl_denied', `строки видны, но изменить их нельзя: ${seen.map((r) => r.id).join(', ')}`, { ids: seen.map((r) => r.id) });
454
+ const ids = seen.map((r) => r.id);
455
+ throw lerr('write.rows_denied', { ids: ids.join(', ') }, { detail: { ids } });
458
456
  }
459
457
  /**
460
458
  * Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
@@ -464,14 +462,14 @@ function inTargetOrder(ids, rows) {
464
462
  const pos = new Map(ids.map((id, i) => [id, i]));
465
463
  return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
466
464
  }
467
- /** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
465
+ /** Цели update и delete: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
468
466
  async function targetIds(w, steps, opStep, dataId) {
469
467
  const path = steps.map(bare);
470
468
  if (opStep.filter === undefined && dataId !== undefined)
471
469
  path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
472
470
  const ids = await readIds(w, path, opStep.opMods ?? {});
473
- // без явного порядка — по id: глаголы по списку целей (reclass, rekey и др.) пишут журнал по очереди, и порядок
474
- // записей не должен зависеть от плана чтения
471
+ // без явного порядка — по id: глаголы по списку целей пишут журнал по очереди, и порядок записей не должен
472
+ // зависеть от плана чтения
475
473
  return opStep.opMods?.order ? ids : [...ids].sort();
476
474
  }
477
475
  async function updateOp(w, steps, opStep) {
@@ -483,9 +481,9 @@ async function updateOp(w, steps, opStep) {
483
481
  const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
484
482
  if (op.rev !== undefined) {
485
483
  if (ids.length > 1)
486
- fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — проверить версию { rev } можно только у одной цели`);
484
+ throw lerr('write.rev_many_targets', { step: opStep.name, count: ids.length });
487
485
  if (!ids.length)
488
- fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
486
+ throw lerr('write.rev_no_target', { step: opStep.name });
489
487
  }
490
488
  if (!ids.length)
491
489
  return [];
@@ -550,41 +548,13 @@ async function deleteOp(w, steps, opStep) {
550
548
  }
551
549
  return out;
552
550
  }
553
- async function anonymizeOp(w, steps, opStep) {
554
- const op = opStep.op;
555
- const S = quoteId(w.schema);
556
- const ids = await targetIds(w, steps, opStep);
557
- if (!ids.length)
558
- return [];
559
- const res = await query(w, `update ${S}.entity e set data = e.data || coalesce((select jsonb_object_agg(f, '[erased]'::text) from unnest($2::text[]) f where e.data ? f), '{}'::jsonb), `
560
- + `tags = (select array_agg(distinct t order by t) from unnest(e.tags || array['anonymized']) t) `
561
- + `where e.id = any ($1::uuid[]) returning ${rowJson('new')} as row`, [ids, op.fields]);
562
- if (res.length < ids.length) {
563
- const got = new Set(res.map((r) => r.row.id));
564
- await explainMissing(w, ids.filter((x) => !got.has(x)));
565
- }
566
- return inTargetOrder(ids, res.map((r) => toRow(r.row)));
567
- }
568
- async function reclassOp(w, steps, opStep) {
569
- const op = opStep.op;
570
- const S = quoteId(w.schema);
571
- const ids = await targetIds(w, steps, opStep);
572
- if (!ids.length)
573
- return [];
574
- const cls = w.reg.resolve(op.cls).name;
575
- // у класса без ключа id сохраняется, у класса с ключом — новый id и перевод ссылок (reclass() в базе)
576
- const moved = await query(w, `select ${S}.reclass(x, $2::text, $3::jsonb) as id from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids, cls, op.data ?? null]);
577
- const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [moved.map((m) => m.id)]);
578
- // строки ответа — в порядке целей (moved — по порядку целей, план 2.0, 7.2 №2)
579
- return inTargetOrder(moved.map((m) => m.id), res.map((r) => toRow(r.row)));
580
- }
581
- /** Удалённые объекты шага (restore, purge): живые цели без удалённых — not_deleted. */
551
+ /** Удалённые объекты шага (restore): живые цели без удалённых — not_deleted. */
582
552
  async function deletedTargets(w, steps, opStep, verb) {
583
553
  const rows = await readRows(w, steps.map(bare), { ...(opStep.opMods ?? {}), withDeleted: true });
584
554
  const dead = rows.filter((r) => r.$deleted);
585
555
  if (!dead.length && rows.length)
586
- fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
587
- // без явного порядка — по id: restore и purge пишут журнал по очереди целей
556
+ throw lerr('write.not_deleted', { verb }, { detail: { ids: rows.map((r) => r.id) } });
557
+ // без явного порядка — по id: restore пишет журнал по очереди целей
588
558
  return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
589
559
  }
590
560
  /** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
@@ -598,52 +568,6 @@ async function restoreOp(w, steps, opStep) {
598
568
  const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids]);
599
569
  return inTargetOrder(ids, res.map((r) => toRow(r.row)));
600
570
  }
601
- /**
602
- * Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
603
- * версий $versions), с ним — версии стёрты, на месте версии 1 — запись purge ($purged).
604
- */
605
- async function purgeOp(w, steps, opStep) {
606
- const op = opStep.op;
607
- const S = quoteId(w.schema);
608
- const dead = await deletedTargets(w, steps, opStep, 'purge');
609
- const out = [];
610
- for (const row of dead) {
611
- const [{ r }] = await query(w, `select ${S}.purge($1::uuid, $2::boolean) as r`, [row.id, op.confirm]);
612
- out.push({ ...row, $versions: r.versions, ...(r.purged ? { $purged: true } : {}) });
613
- }
614
- return out;
615
- }
616
- /** Новое значение ключа (этап 6, п. 7): строка с новым id, ссылки переведены, журнал связывает id. */
617
- async function rekeyOp(w, steps, opStep) {
618
- const op = opStep.op;
619
- const S = quoteId(w.schema);
620
- const ids = await targetIds(w, steps, opStep);
621
- if (!ids.length)
622
- return [];
623
- // поля — в данные, роли концов — в концы
624
- const data = {};
625
- const links = {};
626
- for (const [k, v] of Object.entries(op.data)) {
627
- if (opStep.cls.ends[k])
628
- links[k] = typeof v === 'object' && v !== null && !Array.isArray(v) ? v.id : v;
629
- else
630
- data[k] = v;
631
- }
632
- const moved = [];
633
- for (const id of ids) {
634
- try {
635
- const [{ id: nid }] = await query(w, `select ${S}.rekey($1::uuid, $2::jsonb, $3::jsonb) as id`, [id, data, links]);
636
- moved.push(nid);
637
- }
638
- catch (e) {
639
- if (isPkConflict(e))
640
- throw Object.assign(new LetopisError('exists', `rekey(): объект класса ${opStep.cls.name} с таким ключом уже существует`, { id }), { cause: e });
641
- throw e;
642
- }
643
- }
644
- const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [moved]);
645
- return inTargetOrder(moved, res.map((r) => toRow(r.row)));
646
- }
647
571
  async function opCall(w, steps, opStep) {
648
572
  await useReason(w, reasonFor(w, opStep));
649
573
  switch (opStep.op.kind) {
@@ -651,11 +575,7 @@ async function opCall(w, steps, opStep) {
651
575
  case 'upsert': return createOp(w, steps, opStep, true);
652
576
  case 'update': return updateOp(w, steps, opStep);
653
577
  case 'delete': return deleteOp(w, steps, opStep);
654
- case 'anonymize': return anonymizeOp(w, steps, opStep);
655
- case 'reclass': return reclassOp(w, steps, opStep);
656
578
  case 'restore': return restoreOp(w, steps, opStep);
657
- case 'purge': return purgeOp(w, steps, opStep);
658
- case 'rekey': return rekeyOp(w, steps, opStep);
659
579
  }
660
580
  }
661
581
  /** Разрезать план: сегменты (…шаги + шаг с глаголом) и читающий хвост после последнего глагола. */
@@ -712,18 +632,17 @@ export async function execPlan(w, steps, mods, mode, agg) {
712
632
  else {
713
633
  last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
714
634
  }
715
- // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после
716
- // стирания истории продолжать не от чего
635
+ // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире)
717
636
  start = kind === 'delete' && opStep.op.confirm
718
637
  ? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
719
- : kind === 'purge' ? [] : last;
638
+ : last;
720
639
  prevOp = opStep;
721
640
  }
722
641
  // причина глагола не переходит на следующие операторы транзакции
723
642
  await useReason(w, w.reason ?? '');
724
643
  const opStep = segments[segments.length - 1].steps.at(-1);
725
644
  const key = opStep.aliasKey ?? opStep.name;
726
- if (tail.length || mode === 'versions' || mode === 'agg' || mode === 'diff' || mode === 'blame') {
645
+ if (tail.length || mode === 'versions' || mode === 'agg') {
727
646
  if (!start.length)
728
647
  return { kind: 'read', q: null, res: [] };
729
648
  const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
@@ -793,13 +712,11 @@ export async function execBatch(w, plans) {
793
712
  }
794
713
  await useReason(w, why);
795
714
  const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
796
- // агрегатные поля — без них, как у одиночного upsert (план 1.1, §2.4)
797
- const agg = aggregateFields(head.cls);
798
715
  const conflict = verb === 'upsert'
799
- ? `on conflict (id) do update set data = excluded.data${agg.length ? ' - $3::text[]' : ''}, links = excluded.links, tags = excluded.tags`
716
+ ? 'on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags'
800
717
  : 'on conflict (id) do nothing';
801
718
  const res = await query(w, `insert into ${S}.entity (class, data, tags, owner) select $1::text, x.d, x.t, x.o `
802
- + `from jsonb_to_recordset($2::jsonb) as x(d jsonb, t text[], o uuid) ${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`, [head.cls.name, input, ...(verb === 'upsert' && agg.length ? [agg] : [])]);
719
+ + `from jsonb_to_recordset($2::jsonb) as x(d jsonb, t text[], o uuid) ${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`, [head.cls.name, input]);
803
720
  const byId = new Map(res.map((r) => [r.row.id, r]));
804
721
  for (const g of group) {
805
722
  const r = byId.get(g.id);
package/llms.txt CHANGED
@@ -1,33 +1,30 @@
1
1
  # letopis
2
2
 
3
- > Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
3
+ > Хранилище версионированных объектов для Node.js на PostgreSQL 18. Каждое изменение — версия в журнале (история, чтение на момент, подписка по курсору). Целостность, изоляцию арендаторов и права доступа проверяет сама база (триггеры и политики RLS), поэтому они действуют и для сырого SQL. Данные читают и пишут цепочками вида `db.Shop(shop).Product().rows()`.
4
4
 
5
- Key facts for writing code with letopis 1.4:
5
+ Главное для кода на letopis:
6
6
 
7
- - ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`; `@modelcontextprotocol/server`, only by `letopis/mcp`.
8
- - Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
9
- - Classes are hubs (objects) and links (relations with ends), described with `hub()`/`link()` from `letopis/model` and written to the database with `syncModels()` or `letopis sync`. A class with a key gets a deterministic UUID v5 id (`db.idOf`); uniqueness is the key.
10
- - Verbs return a chain; a terminal (`rows`, `first`, `ids`, `count`, …) executes the whole plan in one transaction. `create` only creates (a taken key is `exists`), `update` merges and never creates, `upsert` replaces the whole object (keyed classes only), `delete()` is a preview and `delete({ confirm: true })` deletes.
11
- - Money and counters: `update({ balance: inc(n) })` adds inside the statement; `update(patch, { rev })` fails with `conflict` if the row changed; `forUpdate()` locks rows inside `db.begin()`.
12
- - History: `versions()`, `diff({ from, to })` and `blame()` (who, when and why last changed each field); verbs, `db.begin()` and `db.batch()` take `{ reason }`.
13
- - Bulk loading from an admin connection: `db.load(rows)`; into an empty schema `db.load(rows, { initial: true })` — one transaction, indexes and triggers rebuilt at the end.
14
- - Projectors from an admin connection: `db.projector(name, { from, into, version, handle })` builds rows of derived classes (`derivedBy: name`) from the journal; each batch is one transaction and every change is applied exactly once; `run()`, `rebuild()`, `status()`. Only the projector writes derived rows: the application, raw SQL and the loader get `derived_class`. Only an administrator creates, changes and deletes derived classes (an admin connection with a tenant session, or `letopis sync` with the admin `--dsn`); for an application session it is `acl_denied`. `purge()` of a deleted source object waits until the projector has read the deletion (`projector_pending`).
15
- - Aggregates in the database: the class option `aggregate: { balance: { sum: 'amount', of: 'Movement', via: 'Wallet' } }` (or `count: true`, `where` as a JSON Schema) keeps the field equal to the total of live source rows of the same tenant; a trigger after each statement recalculates it for the library, raw SQL, cascades and the loader; only the database writes the field (`aggregate_field`); repair with `db.rebuildAggregates(class)` on an admin connection, check with `db.verify({ data: true })`.
16
- - Subscriptions: `db.watch({ from, classes })` — `await sub.ready()` before a write that must reach the stream (`from: 'now'` fixes its starting point with the first query); parallel `next()` calls get different events.
17
- - NOTIFY signals are visible to any database role: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` chooses what they reveal (default `class`).
18
- - Access rules are always on and deny by default; `up({ tenant })` or `createTenant()` creates a tenant with the rule "authenticated users may do everything" and its first user.
19
- - MCP: `createMcpServer(db)` from `letopis/mcp` and `letopis mcp` (stdio, a user session token only) give an AI agent the tools `letopis_model`, `letopis_query` (a JSON chain, no SQL), `letopis_history` and, with `write: true`, `letopis_write`; the agent acts with the rights of one user, and a deletion needs a human confirmation through elicitation; by default `letopis_write` does not write the permission classes (`allowAclWrite`), and a session in the System tenant is refused (`allowSystemAdmin`). `structuredContent` of a tool result is `{ notice, data }` (an error — `{ notice, error }`): `notice` warns that `data` holds untrusted stored data, not instructions.
20
- - Errors carry a lowercase `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, `removed` (0.21 API with its replacement), …
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.
21
20
 
22
- ## Docs
21
+ ## Документы
23
22
 
24
- - [Agent cheatsheet](AGENT-CHEATSHEET.en.md): one page for an AI agent writing code with letopis — working cycle and pitfalls
25
- - [Guide](README.en.md): task-based guide, API reference, errors, limitations
26
- - [Шпаргалка для агента](AGENT-CHEATSHEET.md): the cheatsheet in Russian (original)
27
- - [Руководство](README.md): the guide in Russian (original)
23
+ - [Шпаргалка для агента](AGENT-CHEATSHEET.md): одна страница для ИИ-агента, который пишет код на letopis, — рабочий цикл и грабли
24
+ - [Руководство](README.md): руководство по задачам, справочник API, ограничения
25
+ - [Ошибки](README.md#15-ошибки): все коды — что значит и что делать
28
26
 
29
- ## Optional
27
+ ## Дополнительно
30
28
 
31
- - [Migration from 0.21](MIGRATION.md): data import with `letopis import` and the API replacement table (Russian)
32
- - [Changelog](CHANGELOG.md): decisions by version, BREAKING changes of 1.0 (Russian)
33
- - Model of a concrete installation for an agent: `letopis describe --format text --out llms.txt` or `describeText(db.describe())` — classes, keys, fields and ends of that schema
29
+ - [Изменения по версиям](CHANGELOG.md): решения по версиям, несовместимые изменения
30
+ - Модель конкретной установки для агента — инструмент MCP `letopis_model`: классы, ключи, поля, концы и рабочие цепочки этой схемы