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
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,64 +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
- const t = reg.leafType(s.cls.name, [f]);
189
- if (t !== 'text')
190
- 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 });
191
194
  }
192
195
  break;
193
- }
194
- case 'reclass':
195
- reg.resolve(op.cls);
196
- if (op.data !== undefined)
197
- checkFullData(reg.resolve(op.cls), op.data, 'reclass');
198
- break;
199
196
  case 'delete':
200
- case 'restore':
201
- 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 });
202
200
  break;
203
- case 'rekey':
204
- if (!s.cls.key.length)
205
- fail('no_key', `rekey(): у класса ${s.cls.name} нет ключа — id выдаёт база, менять нечего`);
206
- checkFullData(s.cls, op.data, 'rekey', false);
201
+ case 'restore':
207
202
  break;
208
203
  }
209
204
  for (const sl of s.slots ?? []) {
210
205
  if (op.kind !== 'create' && op.kind !== 'update' && op.kind !== 'upsert')
211
- fail('invalid_query', `слот .${sl.role} после ${verb}() — слоты у create(), update() и upsert()`);
206
+ throw lerr('write.slot_bad_verb', { role: sl.role, verb });
212
207
  }
208
+ precheckStep(reg, s, s.opMods?.order);
209
+ if (tail && (order !== undefined || agg !== undefined))
210
+ precheckStep(reg, s, order, agg);
213
211
  prevOp = op;
214
212
  }
215
213
  }
@@ -225,7 +223,7 @@ const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
225
223
  /** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
226
224
  const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
227
225
  + `'links', ${a}.links, 'data', ${a}.data, 'tags', ${a}.tags, 'at', ${a}.at, 'author', ${a}.author, 'agent', ${a}.agent, `
228
- + `'op', ${a}.op, 'reason', ${a}.reason, 'moved', ${a}.moved)`;
226
+ + `'op', ${a}.op, 'reason', ${a}.reason)`;
229
227
  async function query(w, text, params) {
230
228
  return (await w.q.unsafe(text, params, { prepare: true }));
231
229
  }
@@ -251,16 +249,18 @@ export function slotRole(reg, owner, name) {
251
249
  return name;
252
250
  const c = reg.find(name);
253
251
  if (!c)
254
- fail('invalid_class', `класс или роль ${name} не найдены`);
252
+ throw lerr('write.slot_unknown', { name });
255
253
  const roles = rolesAccepting(reg, owner, c.name);
256
254
  if (roles.length === 1)
257
255
  return roles[0];
258
256
  if (!roles.length)
259
- fail('invalid_query', `${name} — не конец класса ${owner.name} (концы: ${Object.keys(owner.ends).join(', ') || '—'})`);
260
- 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(', ') });
261
259
  }
262
260
  /** Неоднозначная роль конца из пути. */
263
- 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
+ };
264
264
  /**
265
265
  * Роль из нескольких кандидатов: роли, заданные слотами, не участвуют; из оставшихся — роль с именем
266
266
  * класса шага (duty.Member для шага Member, а Backup — слотом). Иначе — неоднозначно.
@@ -314,7 +314,7 @@ async function pathEnds(w, steps, taken) {
314
314
  else {
315
315
  const rows = await readRows(w, steps.slice(0, k + 1).map(bare), { limit: 2 });
316
316
  if (rows.length !== 1)
317
- 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 });
318
318
  id = rows[0].id;
319
319
  if (!roles) {
320
320
  roles = pickRole(s, tcls, rolesAccepting(w.reg, tcls, rows[0].class), taken, rows[0].class);
@@ -343,9 +343,17 @@ async function slotIds(w, v) {
343
343
  if (Array.isArray(v))
344
344
  return v;
345
345
  if ('plan' in v) {
346
- const rows = v.plan.some((s) => s.op)
347
- ? (await execPlan(w, v.plan, v.mods, 'rows')).rows
348
- : await readRows(w, v.plan, v.mods);
346
+ let rows;
347
+ if (v.plan.some((s) => s.op)) {
348
+ // вложенный план — те же проверки до базы, что у плана терминала; с читающим хвостом после глагола execPlan
349
+ // отдаёт строки чтения (план 2.0, 7.2 №3: раньше — TypeError)
350
+ precheckPlan(w.reg, v.plan);
351
+ const r = await execPlan(w, v.plan, v.mods, 'rows');
352
+ rows = r.kind === 'rows' ? r.rows : r.res;
353
+ }
354
+ else {
355
+ rows = await readRows(w, v.plan, v.mods);
356
+ }
349
357
  return rows.map((r) => r.id);
350
358
  }
351
359
  return [v.id];
@@ -357,7 +365,7 @@ async function resolveSlots(w, cls, slots) {
357
365
  const end = cls.ends[sl.role];
358
366
  const ids = sl.op === 'unset' ? [] : await slotIds(w, sl.value);
359
367
  if (sl.op === 'set' && sl.value !== null && !end.many && ids.length !== 1) {
360
- fail('invalid_query', `слот .${sl.role}.set(): конец одиночный, а значение дало ${ids.length} сущностей`);
368
+ throw lerr('write.slot_single_many', { role: sl.role, count: ids.length });
361
369
  }
362
370
  out.push({ role: sl.role, op: sl.op, value: sl.op === 'unset' || sl.value === null ? null : end.many ? ids : ids[0] });
363
371
  }
@@ -394,7 +402,8 @@ function existsError(w, cls, data, links, given, cause) {
394
402
  id = undefined;
395
403
  }
396
404
  }
397
- 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 });
398
407
  }
399
408
  const isPkConflict = (e) => {
400
409
  const err = e;
@@ -408,16 +417,11 @@ async function createOp(w, steps, opStep, upsert) {
408
417
  const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
409
418
  const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
410
419
  const owner = opStep.ownerFilter ?? w.owner ?? null;
411
- // агрегатные поля (план 1.1, §2.4): в excluded их поставил триггер вставки (0) — замена данных идёт без них, и
412
- // триггер правки возвращает прежние значения
413
- const agg = aggregateFields(cls);
414
- const conflict = upsert
415
- ? ` on conflict (id) do update set data = excluded.data${agg.length ? ' - $7::text[]' : ''}, links = excluded.links, tags = excluded.tags`
416
- : '';
420
+ const conflict = upsert ? ' on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags' : '';
417
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)`
418
422
  + `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
419
423
  try {
420
- 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]);
421
425
  return res.map((r) => {
422
426
  const row = toRow(r.row);
423
427
  if (upsert)
@@ -438,12 +442,17 @@ async function createOp(w, steps, opStep, upsert) {
438
442
  async function explainMissing(w, missing, rev) {
439
443
  const S = quoteId(w.schema);
440
444
  const seen = await query(w, `select id, rev from ${S}.entity where id = any ($1::uuid[])`, [missing]);
441
- if (!seen.length)
442
- 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
+ }
443
450
  if (rev !== undefined && seen.some((r) => r.rev !== rev)) {
444
- 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 });
445
453
  }
446
- 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 } });
447
456
  }
448
457
  /**
449
458
  * Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
@@ -453,14 +462,14 @@ function inTargetOrder(ids, rows) {
453
462
  const pos = new Map(ids.map((id, i) => [id, i]));
454
463
  return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
455
464
  }
456
- /** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
465
+ /** Цели update и delete: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
457
466
  async function targetIds(w, steps, opStep, dataId) {
458
467
  const path = steps.map(bare);
459
468
  if (opStep.filter === undefined && dataId !== undefined)
460
469
  path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
461
470
  const ids = await readIds(w, path, opStep.opMods ?? {});
462
- // без явного порядка — по id: глаголы по списку целей (reclass, rekey и др.) пишут журнал по очереди, и порядок
463
- // записей не должен зависеть от плана чтения
471
+ // без явного порядка — по id: глаголы по списку целей пишут журнал по очереди, и порядок записей не должен
472
+ // зависеть от плана чтения
464
473
  return opStep.opMods?.order ? ids : [...ids].sort();
465
474
  }
466
475
  async function updateOp(w, steps, opStep) {
@@ -472,9 +481,9 @@ async function updateOp(w, steps, opStep) {
472
481
  const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
473
482
  if (op.rev !== undefined) {
474
483
  if (ids.length > 1)
475
- fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — проверить версию { rev } можно только у одной цели`);
484
+ throw lerr('write.rev_many_targets', { step: opStep.name, count: ids.length });
476
485
  if (!ids.length)
477
- fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
486
+ throw lerr('write.rev_no_target', { step: opStep.name });
478
487
  }
479
488
  if (!ids.length)
480
489
  return [];
@@ -539,40 +548,13 @@ async function deleteOp(w, steps, opStep) {
539
548
  }
540
549
  return out;
541
550
  }
542
- async function anonymizeOp(w, steps, opStep) {
543
- const op = opStep.op;
544
- const S = quoteId(w.schema);
545
- const ids = await targetIds(w, steps, opStep);
546
- if (!ids.length)
547
- return [];
548
- 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), `
549
- + `tags = (select array_agg(distinct t order by t) from unnest(e.tags || array['anonymized']) t) `
550
- + `where e.id = any ($1::uuid[]) returning ${rowJson('new')} as row`, [ids, op.fields]);
551
- if (res.length < ids.length) {
552
- const got = new Set(res.map((r) => r.row.id));
553
- await explainMissing(w, ids.filter((x) => !got.has(x)));
554
- }
555
- return inTargetOrder(ids, res.map((r) => toRow(r.row)));
556
- }
557
- async function reclassOp(w, steps, opStep) {
558
- const op = opStep.op;
559
- const S = quoteId(w.schema);
560
- const ids = await targetIds(w, steps, opStep);
561
- if (!ids.length)
562
- return [];
563
- const cls = w.reg.resolve(op.cls).name;
564
- // у класса без ключа id сохраняется, у класса с ключом — новый id и перевод ссылок (reclass() в базе)
565
- 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]);
566
- 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)]);
567
- return res.map((r) => toRow(r.row));
568
- }
569
- /** Удалённые объекты шага (restore, purge): живые цели без удалённых — not_deleted. */
551
+ /** Удалённые объекты шага (restore): живые цели без удалённых — not_deleted. */
570
552
  async function deletedTargets(w, steps, opStep, verb) {
571
553
  const rows = await readRows(w, steps.map(bare), { ...(opStep.opMods ?? {}), withDeleted: true });
572
554
  const dead = rows.filter((r) => r.$deleted);
573
555
  if (!dead.length && rows.length)
574
- fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
575
- // без явного порядка — по id: restore и purge пишут журнал по очереди целей
556
+ throw lerr('write.not_deleted', { verb }, { detail: { ids: rows.map((r) => r.id) } });
557
+ // без явного порядка — по id: restore пишет журнал по очереди целей
576
558
  return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
577
559
  }
578
560
  /** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
@@ -583,54 +565,8 @@ async function restoreOp(w, steps, opStep) {
583
565
  return [];
584
566
  const ids = dead.map((r) => r.id);
585
567
  await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
586
- const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [ids]);
587
- return res.map((r) => toRow(r.row));
588
- }
589
- /**
590
- * Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
591
- * версий $versions), с ним — версии стёрты, на месте версии 1 — запись purge ($purged).
592
- */
593
- async function purgeOp(w, steps, opStep) {
594
- const op = opStep.op;
595
- const S = quoteId(w.schema);
596
- const dead = await deletedTargets(w, steps, opStep, 'purge');
597
- const out = [];
598
- for (const row of dead) {
599
- const [{ r }] = await query(w, `select ${S}.purge($1::uuid, $2::boolean) as r`, [row.id, op.confirm]);
600
- out.push({ ...row, $versions: r.versions, ...(r.purged ? { $purged: true } : {}) });
601
- }
602
- return out;
603
- }
604
- /** Новое значение ключа (этап 6, п. 7): строка с новым id, ссылки переведены, журнал связывает id. */
605
- async function rekeyOp(w, steps, opStep) {
606
- const op = opStep.op;
607
- const S = quoteId(w.schema);
608
- const ids = await targetIds(w, steps, opStep);
609
- if (!ids.length)
610
- return [];
611
- // поля — в данные, роли концов — в концы
612
- const data = {};
613
- const links = {};
614
- for (const [k, v] of Object.entries(op.data)) {
615
- if (opStep.cls.ends[k])
616
- links[k] = typeof v === 'object' && v !== null && !Array.isArray(v) ? v.id : v;
617
- else
618
- data[k] = v;
619
- }
620
- const moved = [];
621
- for (const id of ids) {
622
- try {
623
- const [{ id: nid }] = await query(w, `select ${S}.rekey($1::uuid, $2::jsonb, $3::jsonb) as id`, [id, data, links]);
624
- moved.push(nid);
625
- }
626
- catch (e) {
627
- if (isPkConflict(e))
628
- throw Object.assign(new LetopisError('exists', `rekey(): объект класса ${opStep.cls.name} с таким ключом уже существует`, { id }), { cause: e });
629
- throw e;
630
- }
631
- }
632
- const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [moved]);
633
- return res.map((r) => toRow(r.row));
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)));
634
570
  }
635
571
  async function opCall(w, steps, opStep) {
636
572
  await useReason(w, reasonFor(w, opStep));
@@ -639,11 +575,7 @@ async function opCall(w, steps, opStep) {
639
575
  case 'upsert': return createOp(w, steps, opStep, true);
640
576
  case 'update': return updateOp(w, steps, opStep);
641
577
  case 'delete': return deleteOp(w, steps, opStep);
642
- case 'anonymize': return anonymizeOp(w, steps, opStep);
643
- case 'reclass': return reclassOp(w, steps, opStep);
644
578
  case 'restore': return restoreOp(w, steps, opStep);
645
- case 'purge': return purgeOp(w, steps, opStep);
646
- case 'rekey': return rekeyOp(w, steps, opStep);
647
579
  }
648
580
  }
649
581
  /** Разрезать план: сегменты (…шаги + шаг с глаголом) и читающий хвост после последнего глагола. */
@@ -700,18 +632,17 @@ export async function execPlan(w, steps, mods, mode, agg) {
700
632
  else {
701
633
  last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
702
634
  }
703
- // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после
704
- // стирания истории продолжать не от чего
635
+ // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире)
705
636
  start = kind === 'delete' && opStep.op.confirm
706
637
  ? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
707
- : kind === 'purge' ? [] : last;
638
+ : last;
708
639
  prevOp = opStep;
709
640
  }
710
641
  // причина глагола не переходит на следующие операторы транзакции
711
642
  await useReason(w, w.reason ?? '');
712
643
  const opStep = segments[segments.length - 1].steps.at(-1);
713
644
  const key = opStep.aliasKey ?? opStep.name;
714
- if (tail.length || mode === 'versions' || mode === 'agg' || mode === 'diff' || mode === 'blame') {
645
+ if (tail.length || mode === 'versions' || mode === 'agg') {
715
646
  if (!start.length)
716
647
  return { kind: 'read', q: null, res: [] };
717
648
  const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
@@ -771,7 +702,9 @@ export async function execBatch(w, plans) {
771
702
  && reasonFor(w, plans[j][0]) === why) {
772
703
  const s = plans[j][0];
773
704
  const id = idFor(s);
774
- if (!id || (verb === 'upsert' && seen.has(id)))
705
+ // повтор id режет группу и у create: иначе вторая строка «находила» строку первой, и батч проходил молча
706
+ // (план 2.0, 7.2 №1); повтор уходит следующим оператором и получает exists
707
+ if (!id || seen.has(id))
775
708
  break;
776
709
  seen.add(id);
777
710
  group.push({ at: j, s, id });
@@ -779,13 +712,11 @@ export async function execBatch(w, plans) {
779
712
  }
780
713
  await useReason(w, why);
781
714
  const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
782
- // агрегатные поля — без них, как у одиночного upsert (план 1.1, §2.4)
783
- const agg = aggregateFields(head.cls);
784
715
  const conflict = verb === 'upsert'
785
- ? `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'
786
717
  : 'on conflict (id) do nothing';
787
718
  const res = await query(w, `insert into ${S}.entity (class, data, tags, owner) select $1::text, x.d, x.t, x.o `
788
- + `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]);
789
720
  const byId = new Map(res.map((r) => [r.row.id, r]));
790
721
  for (const g of group) {
791
722
  const r = byId.get(g.id);