letopis 1.4.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
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,12 +17,11 @@
17
17
  * строка видна, но править нельзя — acl_denied; строки уже нет — target_not_found; номер версии
18
18
  * разошёлся со { rev } — conflict.
19
19
  */
20
- import { LetopisError, ValidationError } from './errors.js';
20
+ import { fromDbError, issuesFromDb, lerr, lmsg, LetopisError, 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
- import { applyDefaults } from './validate.js';
24
+ import { applyDefaults, leavesToDb } from './validate.js';
26
25
  // ---------------------------------------------------------------------------
27
26
  // Приращение: маркер, которого не получить из JSON (§2.5)
28
27
  // ---------------------------------------------------------------------------
@@ -37,26 +36,48 @@ export function inc(by, opts = {}) {
37
36
  return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
38
37
  }
39
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
+ }
40
62
  /** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
41
63
  export function checkReason(v, where) {
42
64
  if (typeof v !== 'string' || v.length > 1000 || v.includes('\0')) {
43
- throw new LetopisError('invalid_query', `${where}: reason — строка до 1000 символов`);
65
+ throw lerr('write.bad_reason', { where });
44
66
  }
45
67
  return v;
46
68
  }
47
- const fail = (code, msg, detail) => {
48
- throw new LetopisError(code, msg, detail);
49
- };
50
69
  const asList = (v) => (Array.isArray(v) ? v : [v]);
51
- 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
+ }
52
73
  /** Патч update → остаток для merge и список приращений. Недопустимое — invalid_data до отправки. */
53
74
  export function splitPatch(patch) {
54
75
  const incs = [];
55
76
  const noMarkers = (v, path) => {
56
77
  if (isInc(v))
57
- fail('invalid_data', `inc() внутри массива (${path.join('.')}) не поддерживается`);
78
+ throw lerr('write.inc_in_array', { path: path.join('.') });
58
79
  if (isOp(v))
59
- fail('invalid_data', `оператор фильтра в данных (${path.join('.')}) — данные записываются значениями`);
80
+ throw lerr('write.op_in_data', { path: path.join('.') });
60
81
  if (Array.isArray(v))
61
82
  v.forEach((x, i) => noMarkers(x, [...path, String(i)]));
62
83
  else if (isPlain(v))
@@ -71,13 +92,13 @@ export function splitPatch(patch) {
71
92
  const p = [...path, k];
72
93
  if (isInc(v)) {
73
94
  if (!Number.isFinite(v.by))
74
- fail('invalid_data', `inc(${v.by}) по пути ${p.join('.')}: величина — конечное число`);
95
+ throw lerr('write.inc_by_not_finite', { by: v.by, path: p.join('.') });
75
96
  if (v.start !== undefined && !Number.isFinite(v.start))
76
- fail('invalid_data', `inc: start по пути ${p.join('.')} — конечное число`);
97
+ throw lerr('write.inc_start_not_finite', { path: p.join('.') });
77
98
  incs.push({ path: p, by: v.by, ...(v.start !== undefined ? { start: v.start } : {}) });
78
99
  }
79
100
  else if (isOp(v)) {
80
- fail('invalid_data', `оператор фильтра в патче (${p.join('.')}) — update записывает значения`);
101
+ throw lerr('write.op_in_patch', { path: p.join('.') });
81
102
  }
82
103
  else if (Array.isArray(v)) {
83
104
  noMarkers(v, p);
@@ -101,15 +122,60 @@ export function splitPatch(patch) {
101
122
  // ---------------------------------------------------------------------------
102
123
  // Проверки до отправки (precheckPlan)
103
124
  // ---------------------------------------------------------------------------
104
- /** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript (full — объект целиком). */
105
- function checkFullData(cls, data, verb, full = true) {
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
+ }
170
+ /** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript. */
171
+ function checkFullData(cls, data, verb) {
106
172
  if (!isPlain(data))
107
- fail('invalid_data', `${verb}(): данные — объект`);
173
+ throw lerr('write.data_not_object', { verb });
108
174
  const scan = (v, path) => {
109
175
  if (isInc(v))
110
- fail('invalid_data', `inc() работает только в update(): ${verb}() по пути ${path.join('.')}`);
176
+ throw lerr('write.inc_outside_update', { verb, path: path.join('.') });
111
177
  if (isOp(v))
112
- fail('invalid_data', `оператор фильтра в данных ${verb}() (${path.join('.')}) — данные записываются значениями`);
178
+ throw lerr('write.op_in_verb_data', { verb, path: path.join('.') });
113
179
  if (Array.isArray(v))
114
180
  v.forEach((x, i) => scan(x, [...path, String(i)]));
115
181
  else if (isPlain(v))
@@ -117,33 +183,100 @@ function checkFullData(cls, data, verb, full = true) {
117
183
  scan(x, [...path, k]);
118
184
  };
119
185
  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
186
  // описания системных классов проверяет только база (метасхема Class, class_admit)
128
- if (!full || cls.name === 'Class' || cls.abstract)
187
+ if (cls.name === 'Class' || cls.abstract)
129
188
  return;
130
- const issues = cls.validator()(applyDefaults(cls.schema, agg.length ? { ...obj, ...Object.fromEntries(agg.map((f) => [f, 0])) } : obj));
131
- if (issues.length)
132
- throw new ValidationError(cls.name, issues);
133
- }
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 их не меняет`);
139
- }
140
- /** Всё, что проверяется без базы, — до отправки первого оператора (§2.5). */
141
- export function precheckPlan(reg, steps) {
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));
240
+ }
241
+ /** Фильтр шага сужает цели: id, список id, оператор, строка ответа, объект хотя бы с одним заданным полем. */
242
+ const narrowingFilter = (f) => f !== undefined && f !== null && (typeof f !== 'object' || Array.isArray(f) || isOp(f) || Object.values(f).some((v) => v !== undefined));
243
+ /**
244
+ * Цели глагола шага i сужены (план 2.0, этап 4, п. 2; 7.10 №2): у него или у шага до него есть фильтр, id, tags()
245
+ * (пустой список подходит любой строке), owner() или limit(), либо до него был create() или upsert() — дальше путь
246
+ * идёт от записанных строк.
247
+ */
248
+ function narrowed(steps, i) {
249
+ for (let j = 0; j <= i; j++) {
250
+ const s = steps[j];
251
+ if (narrowingFilter(s.filter) || s.ownerFilter !== undefined || s.opMods?.limit !== undefined)
252
+ return true;
253
+ if (s.tagsFilter !== undefined && s.tagsFilter !== null && !(Array.isArray(s.tagsFilter) && !s.tagsFilter.length))
254
+ return true;
255
+ if (j < i && (s.op?.kind === 'create' || s.op?.kind === 'upsert'))
256
+ return true;
257
+ }
258
+ return false;
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
+ }
267
+ /**
268
+ * Всё, что проверяется без базы, — до отправки первого оператора (§2.5). Фильтры шагов и поля sort() и агрегатов
269
+ * (неизвестное поле — invalid_query, план 2.0, этап 3, п. 3) — precheckStep построителя; order и agg — модификатор и
270
+ * агрегат чтения за последним глаголом (терминал цепочки).
271
+ */
272
+ export function precheckPlan(reg, steps, order, agg) {
142
273
  let prevOp;
143
274
  for (let i = 0; i < steps.length; i++) {
144
275
  const s = steps[i];
145
276
  const op = s.op;
277
+ const tail = i === steps.length - 1;
146
278
  if (!op) {
279
+ precheckStep(reg, s, tail ? order : undefined, tail ? agg : undefined);
147
280
  prevOp = undefined;
148
281
  continue;
149
282
  }
@@ -152,67 +285,73 @@ export function precheckPlan(reg, steps) {
152
285
  checkReason(op.reason, `${verb}()`);
153
286
  const m = s.opMods ?? {};
154
287
  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 описывает удалённые — записывать в них нельзя`);
288
+ throw lerr('write.as_of_write', { verb });
289
+ // restore сам ищет удалённые объекты шага; withDeleted() — модификатор шага (план 2.1, A9): у шага глагола или шага
290
+ // до него в сегменте — отказ, как прежде у модификатора цепочки
291
+ if (verb !== 'restore' && segmentOf(steps, i).some((x) => x.withDeleted))
292
+ throw lerr('write.with_deleted_write', { verb });
159
293
  switch (op.kind) {
160
294
  case 'create':
161
295
  case 'upsert':
162
296
  if (s.self)
163
- fail('invalid_query', `${verb}() сразу после ${prevOp?.kind}() — между глаголами нужен шаг`);
297
+ throw lerr('write.verb_after_verb', { verb, prev: prevOp?.kind });
164
298
  if (s.pivotKey !== undefined)
165
- fail('invalid_query', `${verb}() на возврате к узлу — он ведёт к существующей сущности, используйте update()`);
299
+ throw lerr('write.create_on_pivot', { verb });
166
300
  if (s.filter !== undefined && typeof s.filter !== 'string') {
167
- fail('invalid_query', `${verb}() не ищет: ${s.name}(id).${verb}(…) задаёт id, поиск — это update()`);
301
+ throw lerr('write.create_with_filter', { verb, step: s.name });
168
302
  }
169
303
  if (op.kind === 'upsert' && !s.cls.key.length)
170
- fail('no_key', `upsert(): у класса ${s.cls.name} нет ключа — сравнивать не с чем, используйте create()`);
304
+ throw lerr('write.upsert_no_key', { cls: s.cls.name });
171
305
  checkFullData(s.cls, op.data, verb);
172
306
  if (s.tagsFilter !== undefined && typeof s.tagsFilter !== 'string' && !Array.isArray(s.tagsFilter)) {
173
- fail('invalid_query', `.tags() перед ${verb}() — строка или список`);
307
+ throw lerr('write.bad_tags', { verb });
174
308
  }
175
309
  break;
176
310
  case 'update':
177
311
  if (!isPlain(op.data))
178
- fail('invalid_data', 'update(): патч — объект');
312
+ throw lerr('write.patch_not_object');
179
313
  splitPatch(op.data);
180
- checkAggregatePatch(s.cls, op.data);
314
+ checkPatchEnds(s, op.data);
181
315
  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}`);
316
+ throw lerr('write.bad_rev');
317
+ // строгий режим (план 2.0, этап 4, п. 2; 7.10 №2): правка без сужения целей — отказ до записи, все строки —
318
+ // { all: true }; data.id у шага без фильтра — тоже цель (updateOp)
319
+ if (!op.all && !(s.filter === undefined && typeof op.data.id === 'string') && !narrowed(steps, i)) {
320
+ throw lerr('write.no_filter', { call: 'update()', cls: s.cls.name });
194
321
  }
195
322
  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
323
  case 'delete':
203
- case 'restore':
204
- case 'purge':
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');
333
+ if (op.confirm && !op.all && !narrowed(steps, i))
334
+ throw lerr('write.no_filter', { call: 'delete({ confirm: true, expect })', cls: s.cls.name });
205
335
  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);
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');
210
346
  break;
211
347
  }
212
348
  for (const sl of s.slots ?? []) {
213
349
  if (op.kind !== 'create' && op.kind !== 'update' && op.kind !== 'upsert')
214
- fail('invalid_query', `слот .${sl.role} после ${verb}() — слоты у create(), update() и upsert()`);
350
+ throw lerr('write.slot_bad_verb', { role: sl.role, verb });
215
351
  }
352
+ precheckStep(reg, s, s.opMods?.order);
353
+ if (tail && (order !== undefined || agg !== undefined))
354
+ precheckStep(reg, s, order, agg);
216
355
  prevOp = op;
217
356
  }
218
357
  }
@@ -228,7 +367,7 @@ const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
228
367
  /** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
229
368
  const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
230
369
  + `'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)`;
370
+ + `'op', ${a}.op, 'reason', ${a}.reason)`;
232
371
  async function query(w, text, params) {
233
372
  return (await w.q.unsafe(text, params, { prepare: true }));
234
373
  }
@@ -254,16 +393,18 @@ export function slotRole(reg, owner, name) {
254
393
  return name;
255
394
  const c = reg.find(name);
256
395
  if (!c)
257
- fail('invalid_class', `класс или роль ${name} не найдены`);
396
+ throw lerr('write.slot_unknown', { name });
258
397
  const roles = rolesAccepting(reg, owner, c.name);
259
398
  if (roles.length === 1)
260
399
  return roles[0];
261
400
  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(', ')} — назовите роль`);
401
+ throw lerr('write.slot_not_end', { name, cls: owner.name, ends: Object.keys(owner.ends).join(', ') || '—' });
402
+ throw lerr('write.slot_ambiguous', { name, cls: owner.name, roles: roles.join(', ') });
264
403
  }
265
404
  /** Неоднозначная роль конца из пути. */
266
- const ambiguous = (step, target, roles) => fail('invalid_query', `конец класса ${target.name} для шага ${step.name} неоднозначен: роли ${roles.join(', ')} — назовите шаг ролью или задайте слот`);
405
+ const ambiguous = (step, target, roles) => {
406
+ throw lerr('write.end_ambiguous', { cls: target.name, step: step.name, roles: roles.join(', ') });
407
+ };
267
408
  /**
268
409
  * Роль из нескольких кандидатов: роли, заданные слотами, не участвуют; из оставшихся — роль с именем
269
410
  * класса шага (duty.Member для шага Member, а Backup — слотом). Иначе — неоднозначно.
@@ -317,7 +458,7 @@ async function pathEnds(w, steps, taken) {
317
458
  else {
318
459
  const rows = await readRows(w, steps.slice(0, k + 1).map(bare), { limit: 2 });
319
460
  if (rows.length !== 1)
320
- fail('invalid_query', `шаг ${s.name} должен дать ровно одну сущность для конца ${tcls.name} (найдено ${rows.length})`);
461
+ throw lerr('write.end_not_single', { step: s.name, cls: tcls.name, count: rows.length });
321
462
  id = rows[0].id;
322
463
  if (!roles) {
323
464
  roles = pickRole(s, tcls, rolesAccepting(w.reg, tcls, rows[0].class), taken, rows[0].class);
@@ -352,7 +493,8 @@ async function slotIds(w, v) {
352
493
  // отдаёт строки чтения (план 2.0, 7.2 №3: раньше — TypeError)
353
494
  precheckPlan(w.reg, v.plan);
354
495
  const r = await execPlan(w, v.plan, v.mods, 'rows');
355
- 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));
356
498
  }
357
499
  else {
358
500
  rows = await readRows(w, v.plan, v.mods);
@@ -368,7 +510,7 @@ async function resolveSlots(w, cls, slots) {
368
510
  const end = cls.ends[sl.role];
369
511
  const ids = sl.op === 'unset' ? [] : await slotIds(w, sl.value);
370
512
  if (sl.op === 'set' && sl.value !== null && !end.many && ids.length !== 1) {
371
- fail('invalid_query', `слот .${sl.role}.set(): конец одиночный, а значение дало ${ids.length} сущностей`);
513
+ throw lerr('write.slot_single_many', { role: sl.role, count: ids.length });
372
514
  }
373
515
  out.push({ role: sl.role, op: sl.op, value: sl.op === 'unset' || sl.value === null ? null : end.many ? ids : ids[0] });
374
516
  }
@@ -405,7 +547,8 @@ function existsError(w, cls, data, links, given, cause) {
405
547
  id = undefined;
406
548
  }
407
549
  }
408
- return Object.assign(new LetopisError('exists', `объект класса ${cls.name} уже существует${id ? ` (id ${id})` : ''}`, { id: id ?? null }), { cause });
550
+ const detail = { id: id ?? null };
551
+ return id ? lerr('write.exists_id', { cls: cls.name, id }, { detail, cause }) : lerr('write.exists', { cls: cls.name }, { detail, cause });
409
552
  }
410
553
  const isPkConflict = (e) => {
411
554
  const err = e;
@@ -419,16 +562,11 @@ async function createOp(w, steps, opStep, upsert) {
419
562
  const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
420
563
  const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
421
564
  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
- : '';
565
+ const conflict = upsert ? ' on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags' : '';
428
566
  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
567
  + `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
430
568
  try {
431
- const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner, ...(upsert && agg.length ? [agg] : [])]);
569
+ const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner]);
432
570
  return res.map((r) => {
433
571
  const row = toRow(r.row);
434
572
  if (upsert)
@@ -449,12 +587,17 @@ async function createOp(w, steps, opStep, upsert) {
449
587
  async function explainMissing(w, missing, rev) {
450
588
  const S = quoteId(w.schema);
451
589
  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 });
590
+ if (!seen.length) {
591
+ throw missing.length === 1
592
+ ? lerr('write.target_missing', { id: missing[0] }, { detail: { ids: missing } })
593
+ : lerr('write.targets_missing', { ids: missing.join(', ') }, { detail: { ids: missing } });
594
+ }
454
595
  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 });
596
+ const detail = { id: seen[0].id, rev: seen[0].rev, expected: rev };
597
+ throw lerr('write.rev_conflict', detail, { detail });
456
598
  }
457
- return fail('acl_denied', `строки видны, но изменить их нельзя: ${seen.map((r) => r.id).join(', ')}`, { ids: seen.map((r) => r.id) });
599
+ const ids = seen.map((r) => r.id);
600
+ throw lerr('write.rows_denied', { ids: ids.join(', ') }, { detail: { ids } });
458
601
  }
459
602
  /**
460
603
  * Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
@@ -464,14 +607,19 @@ function inTargetOrder(ids, rows) {
464
607
  const pos = new Map(ids.map((id, i) => [id, i]));
465
608
  return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
466
609
  }
467
- /** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
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
+ }
615
+ /** Цели update и delete: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
468
616
  async function targetIds(w, steps, opStep, dataId) {
469
617
  const path = steps.map(bare);
470
618
  if (opStep.filter === undefined && dataId !== undefined)
471
619
  path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
472
620
  const ids = await readIds(w, path, opStep.opMods ?? {});
473
- // без явного порядка — по id: глаголы по списку целей (reclass, rekey и др.) пишут журнал по очереди, и порядок
474
- // записей не должен зависеть от плана чтения
621
+ // без явного порядка — по id: глаголы по списку целей пишут журнал по очереди, и порядок записей не должен
622
+ // зависеть от плана чтения
475
623
  return opStep.opMods?.order ? ids : [...ids].sort();
476
624
  }
477
625
  async function updateOp(w, steps, opStep) {
@@ -480,12 +628,20 @@ async function updateOp(w, steps, opStep) {
480
628
  // data.id — цель, только если у шага нет своего фильтра (write.ts:461-466 в 0.21); тогда id не данные
481
629
  const useDataId = opStep.filter === undefined && typeof op.data.id === 'string';
482
630
  const { id: dataId, ...data } = op.data;
483
- 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
+ }
484
640
  if (op.rev !== undefined) {
485
641
  if (ids.length > 1)
486
- fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — проверить версию { rev } можно только у одной цели`);
642
+ throw lerr('write.rev_many_targets', { step: opStep.name, count: ids.length });
487
643
  if (!ids.length)
488
- fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
644
+ throw lerr('write.rev_no_target', { step: opStep.name });
489
645
  }
490
646
  if (!ids.length)
491
647
  return [];
@@ -510,24 +666,53 @@ async function updateOp(w, steps, opStep) {
510
666
  }
511
667
  return inTargetOrder(ids, res.map((r) => toRow(r.row)));
512
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
+ }
513
692
  async function deleteOp(w, steps, opStep) {
514
693
  const op = opStep.op;
515
694
  const S = quoteId(w.schema);
516
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
+ }
517
714
  if (!ids.length)
518
715
  return [];
519
- if (!op.confirm) {
520
- // превью: то же замыкание, что у триггера удаления (ref_action), база не меняется
521
- const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
522
- + `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
523
- return res.map((r) => {
524
- const row = toRow(r.row);
525
- row.$action = r.action;
526
- if (r.depth !== null)
527
- row.$depth = r.depth;
528
- return row;
529
- });
530
- }
531
716
  // результат — надгробия этой транзакции после метки: delete … returning не видит каскад триггера
532
717
  const [{ mark }] = await query(w, `select coalesce(max(seq), 0)::text as mark from ${S}.log where tx = pg_current_xact_id()`, []);
533
718
  const gone = await query(w, `delete from ${S}.entity e where e.id = any ($1::uuid[]) returning ${rowJson('e')} as row`, [ids]);
@@ -550,99 +735,155 @@ async function deleteOp(w, steps, opStep) {
550
735
  }
551
736
  return out;
552
737
  }
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)
738
+ /** Причины отказа для строк шага restore (живых или надгробий не той версии): одним запросом к журналу. */
739
+ async function refusals(w, rows, want) {
740
+ if (!rows.length)
558
741
  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
742
  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. */
582
- async function deletedTargets(w, steps, opStep, verb) {
583
- const rows = await readRows(w, steps.map(bare), { ...(opStep.opMods ?? {}), withDeleted: true });
584
- const dead = rows.filter((r) => r.$deleted);
585
- if (!dead.length && rows.length)
586
- fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
587
- // без явного порядка — по id: restore и purge пишут журнал по очереди целей
588
- return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
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
+ });
589
756
  }
590
- /** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
591
- async function restoreOp(w, steps, opStep) {
592
- const S = quoteId(w.schema);
593
- const dead = await deletedTargets(w, steps, opStep, 'restore');
594
- if (!dead.length)
595
- return [];
596
- const ids = dead.map((r) => r.id);
597
- await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
598
- const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids]);
599
- return inTargetOrder(ids, res.map((r) => toRow(r.row)));
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 }));
600
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);
601
778
  /**
602
- * Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
603
- * версий $versions), с ним — версии стёрты, на месте версии 1 — запись purge ($purged).
779
+ * Удалённые объекты шага (restore). Шаг цели читается с надгробиями; у шагов до него withDeleted() — свой (план 2.1, A9).
780
+ * Без expect: восстанавливаются удалённые цели; если удалённых нет, а живые есть — отказ по смыслу (restoreRefusal: уже
781
+ * восстановлен, id занят новым объектом) или not_deleted (не удалялся). С expect — только цели из expect, и последняя
782
+ * версия каждой — то же надгробие; иначе отказ по смыслу и ничего не восстановлено, а с partial — строка-пропуск.
604
783
  */
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 } : {}) });
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) };
613
800
  }
614
- return out;
801
+ const dead = rows.filter((r) => r.$deleted);
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);
806
+ throw lerr('write.not_deleted', { verb }, { detail: { ids: rows.map((r) => r.id) } });
807
+ }
808
+ const all = ordered(dead);
809
+ return { dead: all, skip: [], order: all.map((r) => r.id) };
615
810
  }
616
- /** Новое значение ключа (этап 6, п. 7): строка с новым id, ссылки переведены, журнал связывает id. */
617
- async function rekeyOp(w, steps, opStep) {
618
- const op = opStep.op;
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
+ };
826
+ }
827
+ /**
828
+ * Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. С expect
829
+ * база ещё раз сверяет номер последней версии под блокировкой записи журнала (restore(id, rev), план 2.1, A1): гонку
830
+ * между чтением и восстановлением ловит она, и её отказ — по смыслу, как у проверки до записи (второй раунд 2.1, A2).
831
+ * partial (A3): цели, которые не восстановить, — строки-пропуски { …строка, $action: 'skip', $why }, остальные
832
+ * восстанавливаются; каждая цель — под своей точкой сохранения, проигранная гонка тоже становится пропуском.
833
+ */
834
+ async function restoreOp(w, steps, opStep) {
619
835
  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) {
836
+ const { expect, partial } = opStep.op;
837
+ const { dead, skip, order } = await deletedTargets(w, steps, opStep, 'restore', expect, partial);
838
+ if (!dead.length)
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) {
634
854
  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);
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)]);
637
856
  }
638
857
  catch (e) {
639
- if (isPkConflict(e))
640
- throw Object.assign(new LetopisError('exists', `rekey(): объект класса ${opStep.cls.name} с таким ключом уже существует`, { id }), { cause: e });
858
+ const r = dbRefusal(e);
859
+ if (r)
860
+ throw restoreRefusal('restore', [r], true, e);
641
861
  throw e;
642
862
  }
643
863
  }
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)));
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]);
646
887
  }
647
888
  async function opCall(w, steps, opStep) {
648
889
  await useReason(w, reasonFor(w, opStep));
@@ -651,11 +892,7 @@ async function opCall(w, steps, opStep) {
651
892
  case 'upsert': return createOp(w, steps, opStep, true);
652
893
  case 'update': return updateOp(w, steps, opStep);
653
894
  case 'delete': return deleteOp(w, steps, opStep);
654
- case 'anonymize': return anonymizeOp(w, steps, opStep);
655
- case 'reclass': return reclassOp(w, steps, opStep);
656
895
  case 'restore': return restoreOp(w, steps, opStep);
657
- case 'purge': return purgeOp(w, steps, opStep);
658
- case 'rekey': return rekeyOp(w, steps, opStep);
659
896
  }
660
897
  }
661
898
  /** Разрезать план: сегменты (…шаги + шаг с глаголом) и читающий хвост после последнего глагола. */
@@ -712,18 +949,18 @@ export async function execPlan(w, steps, mods, mode, agg) {
712
949
  else {
713
950
  last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
714
951
  }
715
- // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после
716
- // стирания истории продолжать не от чего
952
+ // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после restore — от
953
+ // восстановленных, без строк-пропусков partial
717
954
  start = kind === 'delete' && opStep.op.confirm
718
955
  ? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
719
- : kind === 'purge' ? [] : last;
956
+ : kind === 'restore' ? last.filter((r) => r.$action !== 'skip') : last;
720
957
  prevOp = opStep;
721
958
  }
722
959
  // причина глагола не переходит на следующие операторы транзакции
723
960
  await useReason(w, w.reason ?? '');
724
961
  const opStep = segments[segments.length - 1].steps.at(-1);
725
962
  const key = opStep.aliasKey ?? opStep.name;
726
- if (tail.length || mode === 'versions' || mode === 'agg' || mode === 'diff' || mode === 'blame') {
963
+ if (tail.length || mode === 'versions' || mode === 'agg') {
727
964
  if (!start.length)
728
965
  return { kind: 'read', q: null, res: [] };
729
966
  const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
@@ -793,13 +1030,11 @@ export async function execBatch(w, plans) {
793
1030
  }
794
1031
  await useReason(w, why);
795
1032
  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
1033
  const conflict = verb === 'upsert'
799
- ? `on conflict (id) do update set data = excluded.data${agg.length ? ' - $3::text[]' : ''}, links = excluded.links, tags = excluded.tags`
1034
+ ? 'on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags'
800
1035
  : 'on conflict (id) do nothing';
801
1036
  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] : [])]);
1037
+ + `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
1038
  const byId = new Map(res.map((r) => [r.row.id, r]));
804
1039
  for (const g of group) {
805
1040
  const r = byId.get(g.id);