letopis 2.0.0 → 3.0.1

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 (83) hide show
  1. package/AGENT-CHEATSHEET.md +491 -414
  2. package/CHANGELOG.md +361 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +969 -210
  6. package/api-contract.json +385 -0
  7. package/dist/acl.d.ts +7 -2
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +7 -1
  10. package/dist/admin.js +16 -9
  11. package/dist/auth.d.ts +47 -11
  12. package/dist/auth.js +129 -15
  13. package/dist/chain.d.ts +52 -25
  14. package/dist/chain.js +445 -64
  15. package/dist/cli.js +72 -20
  16. package/dist/errors.d.ts +23 -6
  17. package/dist/errors.js +52 -13
  18. package/dist/index.d.ts +32 -15
  19. package/dist/index.js +209 -68
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +10 -6
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +654 -0
  25. package/dist/mcp.d.ts +1 -1
  26. package/dist/mcp.js +34 -16
  27. package/dist/messages/access.d.ts +10 -0
  28. package/dist/messages/access.js +13 -0
  29. package/dist/messages/api.d.ts +76 -0
  30. package/dist/messages/api.js +109 -7
  31. package/dist/messages/boot.d.ts +5 -0
  32. package/dist/messages/boot.js +11 -4
  33. package/dist/messages/codes.js +4 -4
  34. package/dist/messages/index.d.ts +5 -2
  35. package/dist/messages/index.js +5 -2
  36. package/dist/messages/log.d.ts +232 -0
  37. package/dist/messages/log.js +105 -0
  38. package/dist/messages/mcp.js +7 -7
  39. package/dist/messages/model.d.ts +40 -1
  40. package/dist/messages/model.js +52 -3
  41. package/dist/messages/query.d.ts +10 -0
  42. package/dist/messages/query.js +13 -0
  43. package/dist/messages/sql-core.d.ts +15 -1
  44. package/dist/messages/sql-core.js +20 -3
  45. package/dist/messages/sql-time.d.ts +15 -0
  46. package/dist/messages/sql-time.js +23 -0
  47. package/dist/messages/write.d.ts +52 -0
  48. package/dist/messages/write.js +77 -6
  49. package/dist/model.d.ts +10 -2
  50. package/dist/model.js +28 -6
  51. package/dist/registry.d.ts +6 -0
  52. package/dist/registry.js +6 -0
  53. package/dist/sql.d.ts +2 -0
  54. package/dist/sql.js +77 -25
  55. package/dist/sync.d.ts +4 -5
  56. package/dist/sync.js +53 -4
  57. package/dist/tx.d.ts +10 -1
  58. package/dist/tx.js +44 -2
  59. package/dist/typed.d.ts +18 -14
  60. package/dist/types.d.ts +91 -8
  61. package/dist/types.js +8 -3
  62. package/dist/up.d.ts +8 -5
  63. package/dist/up.js +71 -23
  64. package/dist/uuid.d.ts +8 -0
  65. package/dist/uuid.js +9 -1
  66. package/dist/validate.js +24 -7
  67. package/dist/watch.d.ts +5 -0
  68. package/dist/watch.js +13 -0
  69. package/dist/write.d.ts +84 -3
  70. package/dist/write.js +427 -40
  71. package/llms.txt +38 -17
  72. package/package.json +12 -6
  73. package/sql/20-context.sql +11 -3
  74. package/sql/30-validate.sql +51 -10
  75. package/sql/40-class.sql +21 -1
  76. package/sql/60-write.sql +18 -6
  77. package/sql/80-auth.sql +88 -17
  78. package/sql/90-time.sql +34 -6
  79. package/sql/99-revision.sql +5 -1
  80. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  81. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  82. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  83. package/sql/migrate/README.md +5 -1
package/dist/write.js CHANGED
@@ -6,7 +6,7 @@
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()
8
8
  * await db.Wallet(w).update({ balance: inc(-300) }).first() // приращение в базе
9
- * await db.Staff(s).booking().create({ … }).Customer.set(c).Item.set(svc).rows() // слоты концов
9
+ * await db.Staff(s).booking().create({ … }).Customer.set(c).Item.set(svc).rows() // шаги ролей
10
10
  *
11
11
  * Библиотека пишет обычными операторами SQL — теми же, что выполнил бы человек в консоли:
12
12
  * insert, update … set data = merge(data, $patch), insert … on conflict do update, delete; всё
@@ -17,11 +17,11 @@
17
17
  * строка видна, но править нельзя — acl_denied; строки уже нет — target_not_found; номер версии
18
18
  * разошёлся со { rev } — conflict.
19
19
  */
20
- import { lerr, ValidationError } from './errors.js';
20
+ import { fromDbError, issuesFromDb, lerr, lmsg, LetopisError, ValidationError } from './errors.js';
21
21
  import { isOp } from './ops.js';
22
22
  import { accepts, buildRead, nextNodeKey, precheckStep, resolveHop, stepClasses, toRow } from './sql.js';
23
23
  import { idOf } from './uuid.js';
24
- import { applyDefaults } from './validate.js';
24
+ import { applyDefaults, leavesToDb } from './validate.js';
25
25
  // ---------------------------------------------------------------------------
26
26
  // Приращение: маркер, которого не получить из JSON (§2.5)
27
27
  // ---------------------------------------------------------------------------
@@ -36,6 +36,52 @@ export function inc(by, opts = {}) {
36
36
  return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
37
37
  }
38
38
  export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === true;
39
+ const HIDDEN_ACTIONS = ['delete', 'unset', 'restrict'];
40
+ const HIDDEN_PREFIX = 'hidden:';
41
+ /**
42
+ * Действие строки-сводки предпросмотра удаления (3.0.1): `$hidden` или хвост id `hidden:<действие>`; не сводка — null.
43
+ * Сводку узнаёт и по одному id: expect, собранный из ответа как `{ id, rev }`, её не теряет.
44
+ */
45
+ function hiddenOf(x) {
46
+ if (x.$action !== 'hidden' && !(typeof x.id === 'string' && x.id.startsWith(HIDDEN_PREFIX)))
47
+ return null;
48
+ const a = x.$hidden ?? (typeof x.id === 'string' ? x.id.slice(HIDDEN_PREFIX.length) : undefined);
49
+ return HIDDEN_ACTIONS.includes(a) ? a : null;
50
+ }
51
+ /**
52
+ * Данные create() и upsert() без ключей со значением undefined — как у патча update() (splitPatch): ключ undefined JSON
53
+ * не передаёт, а валидатор на TypeScript принимал его за поле не того типа (план 2.1, A12). Вложенные объекты — тоже;
54
+ * массивы, даты, маркеры и операторы — как есть.
55
+ */
56
+ export function dropUndefined(data) {
57
+ if (!isPlain(data))
58
+ return data;
59
+ const out = {};
60
+ for (const [k, v] of Object.entries(data)) {
61
+ if (v === undefined)
62
+ continue;
63
+ out[k] = isPlain(v) ? dropUndefined(v) : v;
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * expect — список строк { id, rev } (и $action у предпросмотра удаления): иначе invalid_query до записи. Строка-сводка
69
+ * скрытых строк предпросмотра удаления (`$action: 'hidden'`, id `hidden:<действие>`, 3.0.1) — без rev, `$count` (если
70
+ * задан) — целое ≥ 1.
71
+ */
72
+ function checkExpect(expect, verb) {
73
+ const ok = Array.isArray(expect) && expect.every((x) => {
74
+ if (x === null || typeof x !== 'object' || typeof x.id !== 'string')
75
+ return false;
76
+ const e = x;
77
+ if (verb === 'delete' && (e.$action === 'hidden' || e.id.startsWith(HIDDEN_PREFIX))) {
78
+ return hiddenOf(e) !== null && (e.$count === undefined || (Number.isInteger(e.$count) && e.$count >= 1));
79
+ }
80
+ return Number.isInteger(e.rev) && e.rev >= 1;
81
+ });
82
+ if (!ok)
83
+ throw lerr('write.bad_expect', { verb });
84
+ }
39
85
  /** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
40
86
  export function checkReason(v, where) {
41
87
  if (typeof v !== 'string' || v.length > 1000 || v.includes('\0')) {
@@ -44,7 +90,9 @@ export function checkReason(v, where) {
44
90
  return v;
45
91
  }
46
92
  const asList = (v) => (Array.isArray(v) ? v : [v]);
47
- const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date) && !isInc(v) && !isOp(v);
93
+ function isPlain(v) {
94
+ return typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date) && !isInc(v) && !isOp(v);
95
+ }
48
96
  /** Патч update → остаток для merge и список приращений. Недопустимое — invalid_data до отправки. */
49
97
  export function splitPatch(patch) {
50
98
  const incs = [];
@@ -97,6 +145,51 @@ export function splitPatch(patch) {
97
145
  // ---------------------------------------------------------------------------
98
146
  // Проверки до отправки (precheckPlan)
99
147
  // ---------------------------------------------------------------------------
148
+ /**
149
+ * Нарушения, найденные валидатором на TypeScript у класса с правилами только для базы (`pattern`): полный список даст
150
+ * база (completeIssues). Ключ — сама ошибка.
151
+ */
152
+ const PENDING = new WeakMap();
153
+ /**
154
+ * Ключи данных, которые не поля класса, а его концы (связи): имя роли конца или имя класса на конце (у роли с другим
155
+ * именем). Пары [ключ, роль] — для текста «задайте .Роль.set(id)» (write.link_in_data, 3.0): подсказка в самом тексте
156
+ * ошибки, а не только в err.hint.
157
+ */
158
+ function endsAsFields(cls, data, skip) {
159
+ const props = (cls.schema.properties ?? {});
160
+ const out = [];
161
+ for (const k of Object.keys(data)) {
162
+ if (data[k] === undefined || k in props || k === skip)
163
+ continue;
164
+ if (cls.ends[k] !== undefined) {
165
+ out.push([k, k]);
166
+ continue;
167
+ }
168
+ const role = Object.entries(cls.ends).find(([, e]) => (Array.isArray(e.class) ? e.class : [e.class]).includes(k))?.[0];
169
+ if (role !== undefined)
170
+ out.push([k, role]);
171
+ }
172
+ return out;
173
+ }
174
+ /** Ключ и параметры ошибки «конец передан полем данных» (write.link_in_data). */
175
+ function linkInData(cls, verb, found) {
176
+ return { key: 'write.link_in_data', params: { verb, cls: cls.name, keys: found.map(([k]) => k).join(', '), role: found[0][1] } };
177
+ }
178
+ /**
179
+ * Патч update() с ключом — именем конца класса, которого нет среди полей: база отклонила бы его (лишний ключ верхнего
180
+ * уровня) — отказ до записи с подсказкой «конец — из пути или .Роль.set()» (план 2.1, A8). data.id цели — не данные.
181
+ */
182
+ function checkPatchEnds(s, patch) {
183
+ const cls = s.cls;
184
+ if (cls.name === 'Class' || cls.abstract || cls.schema.additionalProperties !== false)
185
+ return;
186
+ const useDataId = s.filter === undefined && typeof patch.id === 'string';
187
+ const found = endsAsFields(cls, patch, useDataId ? 'id' : undefined);
188
+ if (!found.length)
189
+ return;
190
+ const issue = { path: [], keyword: 'additionalProperties', message: lmsg('model.issue_additional', { keys: found.map(([k]) => k).join(', ') }) };
191
+ throw new ValidationError(cls.name, [issue], linkInData(cls, 'update', found));
192
+ }
100
193
  /** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript. */
101
194
  function checkFullData(cls, data, verb) {
102
195
  if (!isPlain(data))
@@ -116,9 +209,57 @@ function checkFullData(cls, data, verb) {
116
209
  // описания системных классов проверяет только база (метасхема Class, class_admit)
117
210
  if (cls.name === 'Class' || cls.abstract)
118
211
  return;
119
- const issues = cls.validator()(applyDefaults(cls.schema, data));
120
- if (issues.length)
121
- throw new ValidationError(cls.name, issues);
212
+ const v = cls.validator();
213
+ const full = applyDefaults(cls.schema, data);
214
+ const issues = v(full);
215
+ if (!issues.length)
216
+ return;
217
+ const found = endsAsFields(cls, data);
218
+ const e = new ValidationError(cls.name, issues, found.length ? linkInData(cls, verb, found) : undefined);
219
+ if (leavesToDb(v))
220
+ PENDING.set(e, { cls: cls.name, data: full });
221
+ throw e;
222
+ }
223
+ /**
224
+ * Ошибка данных до записи у класса с правилами только для базы (`pattern`): валидатор на TypeScript нашёл не всё — полный
225
+ * список нарушений даёт база одним запросом (`validate_row`, план 2.1, A8), под точкой сохранения: неудача запроса
226
+ * (схема прежней ревизии) не ломает db.begin(), остаётся прежняя ошибка. Прочие ошибки — как есть.
227
+ */
228
+ export async function completeIssues(ctx, e) {
229
+ const p = e instanceof ValidationError ? PENDING.get(e) : undefined;
230
+ if (!p)
231
+ return e;
232
+ const S = quoteId(ctx.schema);
233
+ let raw;
234
+ try {
235
+ raw = await ctx.exec.run(async (q) => {
236
+ await q.unsafe('savepoint letopis_issues');
237
+ try {
238
+ const [r] = (await q.unsafe(`select ${S}.validate_row($1::text, $2::jsonb) as v`, [p.cls, p.data]));
239
+ await q.unsafe('release savepoint letopis_issues');
240
+ return r?.v;
241
+ }
242
+ catch {
243
+ await q.unsafe('rollback to savepoint letopis_issues');
244
+ return null;
245
+ }
246
+ }, { read: true });
247
+ }
248
+ catch {
249
+ return e;
250
+ }
251
+ if (!Array.isArray(raw) || !raw.length)
252
+ return e;
253
+ const v = e;
254
+ return Object.assign(new ValidationError(p.cls, issuesFromDb(raw), v.key ? { key: v.key, params: v.params } : undefined), { cause: e });
255
+ }
256
+ /**
257
+ * Блокировка строк в порядке id (lockedRead, updateOp — for no key update; deleteOp с expect — for update): id, которые
258
+ * удалось заблокировать.
259
+ */
260
+ export async function lockIds(q, S, ids, mode = 'no key update') {
261
+ const rows = (await q.unsafe(`select id from ${S}.entity where id = any ($1::uuid[]) order by id for ${mode}`, [ids], { prepare: true }));
262
+ return new Set(rows.map((r) => r.id));
122
263
  }
123
264
  /** Фильтр шага сужает цели: id, список id, оператор, строка ответа, объект хотя бы с одним заданным полем. */
124
265
  const narrowingFilter = (f) => f !== undefined && f !== null && (typeof f !== 'object' || Array.isArray(f) || isOp(f) || Object.values(f).some((v) => v !== undefined));
@@ -139,6 +280,13 @@ function narrowed(steps, i) {
139
280
  }
140
281
  return false;
141
282
  }
283
+ /** Шаги сегмента глагола шага i: от шага после прежнего глагола до него самого. */
284
+ function segmentOf(steps, i) {
285
+ let j = i - 1;
286
+ while (j >= 0 && !steps[j].op)
287
+ j--;
288
+ return steps.slice(j + 1, i + 1);
289
+ }
142
290
  /**
143
291
  * Всё, что проверяется без базы, — до отправки первого оператора (§2.5). Фильтры шагов и поля sort() и агрегатов
144
292
  * (неизвестное поле — invalid_query, план 2.0, этап 3, п. 3) — precheckStep построителя; order и agg — модификатор и
@@ -161,8 +309,9 @@ export function precheckPlan(reg, steps, order, agg) {
161
309
  const m = s.opMods ?? {};
162
310
  if (m.asOf !== undefined)
163
311
  throw lerr('write.as_of_write', { verb });
164
- // restore сам ищет удалённые объекты шага
165
- if (m.withDeleted && verb !== 'restore')
312
+ // restore сам ищет удалённые объекты шага; withDeleted() — модификатор шага (план 2.1, A9): у шага глагола или шага
313
+ // до него в сегменте — отказ, как прежде у модификатора цепочки
314
+ if (verb !== 'restore' && segmentOf(steps, i).some((x) => x.withDeleted))
166
315
  throw lerr('write.with_deleted_write', { verb });
167
316
  switch (op.kind) {
168
317
  case 'create':
@@ -185,6 +334,7 @@ export function precheckPlan(reg, steps, order, agg) {
185
334
  if (!isPlain(op.data))
186
335
  throw lerr('write.patch_not_object');
187
336
  splitPatch(op.data);
337
+ checkPatchEnds(s, op.data);
188
338
  if (op.rev !== undefined && (!Number.isInteger(op.rev) || op.rev < 1))
189
339
  throw lerr('write.bad_rev');
190
340
  // строгий режим (план 2.0, этап 4, п. 2; 7.10 №2): правка без сужения целей — отказ до записи, все строки —
@@ -194,11 +344,28 @@ export function precheckPlan(reg, steps, order, agg) {
194
344
  }
195
345
  break;
196
346
  case 'delete':
197
- // превью (без confirm) ничего не удаляет — его не сужают
347
+ // превью (без confirm) ничего не удаляет — его не сужают и не сверяют
348
+ if (op.expect !== undefined) {
349
+ if (!op.confirm)
350
+ throw lerr('write.expect_without_confirm');
351
+ checkExpect(op.expect, verb);
352
+ }
353
+ // 3.0: удаление сверяется с превью, которое видел пользователь (expect); без сверки — только осознанно, { all: true }
354
+ if (op.confirm && op.expect === undefined && !op.all)
355
+ throw lerr('write.confirm_without_expect');
198
356
  if (op.confirm && !op.all && !narrowed(steps, i))
199
- throw lerr('write.no_filter', { call: 'delete({ confirm: true })', cls: s.cls.name });
357
+ throw lerr('write.no_filter', { call: 'delete({ confirm: true, expect })', cls: s.cls.name });
200
358
  break;
201
359
  case 'restore':
360
+ // partial (второй раунд 2.1, A3) — только с expect: пропуск считается по показанным надгробиям
361
+ if (op.partial && op.expect === undefined)
362
+ throw lerr('write.partial_without_expect');
363
+ if (op.expect !== undefined)
364
+ checkExpect(op.expect, verb);
365
+ // 3.0: без expect — отказ, и для явных id тоже: restore вернул бы последнее надгробие, а не то, что видел
366
+ // пользователь (объект могли восстановить, поменять и удалить снова); вернуть всё без сверки — { all: true }
367
+ else if (!op.all)
368
+ throw lerr('write.restore_without_expect');
202
369
  break;
203
370
  }
204
371
  for (const sl of s.slots ?? []) {
@@ -349,7 +516,8 @@ async function slotIds(w, v) {
349
516
  // отдаёт строки чтения (план 2.0, 7.2 №3: раньше — TypeError)
350
517
  precheckPlan(w.reg, v.plan);
351
518
  const r = await execPlan(w, v.plan, v.mods, 'rows');
352
- rows = r.kind === 'rows' ? r.rows : r.res;
519
+ // чтение хвоста отдаёт строки в обёртке { row } (readIn, режим 'rows') — как в батче, распаковать
520
+ rows = r.kind === 'rows' ? r.rows : r.res.map((x) => toRow(x.row));
353
521
  }
354
522
  else {
355
523
  rows = await readRows(w, v.plan, v.mods);
@@ -462,6 +630,11 @@ function inTargetOrder(ids, rows) {
462
630
  const pos = new Map(ids.map((id, i) => [id, i]));
463
631
  return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
464
632
  }
633
+ /** Шаги сужены только по id: фильтр — id или список id, без tags(), owner() и обхода deep(). */
634
+ function idsOnly(steps) {
635
+ return steps.every((s) => (s.filter === undefined || typeof s.filter === 'string' || (Array.isArray(s.filter) && s.filter.every((x) => typeof x === 'string')))
636
+ && s.tagsFilter === undefined && s.ownerFilter === undefined);
637
+ }
465
638
  /** Цели update и delete: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
466
639
  async function targetIds(w, steps, opStep, dataId) {
467
640
  const path = steps.map(bare);
@@ -478,7 +651,15 @@ async function updateOp(w, steps, opStep) {
478
651
  // data.id — цель, только если у шага нет своего фильтра (write.ts:461-466 в 0.21); тогда id не данные
479
652
  const useDataId = opStep.filter === undefined && typeof op.data.id === 'string';
480
653
  const { id: dataId, ...data } = op.data;
481
- const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
654
+ let ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
655
+ // цели сужены не только по id (данные, концы, tags(), owner()): как lockedRead — блокировка целей и то же чтение заново;
656
+ // правятся только строки, которые и после ожидания блокировки подходят под условия (план 2.1, A4). Без гонки — те же
657
+ // цели; строка, которую не дала заблокировать RLS, остаётся в целях — её объяснит explainMissing
658
+ if (ids.length && !idsOnly(steps)) {
659
+ const locked = await lockIds(w.q, S, ids);
660
+ const fresh = new Set(await targetIds(w, steps, opStep, useDataId ? dataId : undefined));
661
+ ids = ids.filter((id) => fresh.has(id) || !locked.has(id));
662
+ }
482
663
  if (op.rev !== undefined) {
483
664
  if (ids.length > 1)
484
665
  throw lerr('write.rev_many_targets', { step: opStep.name, count: ids.length });
@@ -508,24 +689,99 @@ async function updateOp(w, steps, opStep) {
508
689
  }
509
690
  return inTargetOrder(ids, res.map((r) => toRow(r.row)));
510
691
  }
692
+ /** Строка-сводка скрытых правами строк предпросмотра (3.0.1): ни класса, ни id, ни данных — только действие и число. */
693
+ const hiddenRow = (action, count) => ({
694
+ id: `${HIDDEN_PREFIX}${action}`, class: '', rev: 0, tenant: '', owner: '', links: {}, data: {}, tags: [], at: '', author: '',
695
+ op: 'hidden', $action: 'hidden', $hidden: action, $count: count,
696
+ });
697
+ /**
698
+ * Предпросмотр удаления целей: то же замыкание, что у триггера удаления (ref_action), база не меняется. Видимые строки —
699
+ * как есть, скрытые правами — в конце сводками: delete_preview отдаёт их строками с id null, action
700
+ * 'hidden:<действие>' и числом в depth; здесь — по строке на действие delete, unset, restrict, только непустые.
701
+ */
702
+ async function previewOf(w, ids) {
703
+ const S = quoteId(w.schema);
704
+ const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
705
+ + `left join ${S}.entity e on e.id = p.id where p.id is null or e.id is not null `
706
+ + `order by p.id is null, p.depth nulls last, p.action, e.id`, [ids]);
707
+ const out = [];
708
+ const hidden = new Map();
709
+ for (const r of res) {
710
+ if (r.action.startsWith(HIDDEN_PREFIX)) {
711
+ hidden.set(r.action.slice(HIDDEN_PREFIX.length), r.depth ?? 0);
712
+ continue;
713
+ }
714
+ const row = toRow(r.row);
715
+ row.$action = r.action;
716
+ if (r.depth !== null)
717
+ row.$depth = r.depth;
718
+ out.push(row);
719
+ }
720
+ for (const a of HIDDEN_ACTIONS) {
721
+ const n = hidden.get(a) ?? 0;
722
+ if (n > 0)
723
+ out.push(hiddenRow(a, n));
724
+ }
725
+ return out;
726
+ }
727
+ /**
728
+ * Превью совпадает с показанным: те же видимые строки по id, с теми же rev и $action (порядок не важен), и те же
729
+ * сводки скрытых строк (3.0.1) по действию и числу; у сводки без $count сверяется только её наличие.
730
+ */
731
+ function samePreview(now, expect) {
732
+ if (now.length !== expect.length)
733
+ return false;
734
+ const want = new Map();
735
+ for (const x of expect) {
736
+ const h = hiddenOf(x);
737
+ want.set(h ? `${HIDDEN_PREFIX}${h}` : x.id.toLowerCase(), x);
738
+ }
739
+ if (want.size !== expect.length)
740
+ return false;
741
+ return now.every((r) => {
742
+ if (r.$action === 'hidden') {
743
+ const x = want.get(r.id);
744
+ return x !== undefined && (x.$count === undefined || x.$count === r.$count);
745
+ }
746
+ const x = want.get(r.id.toLowerCase());
747
+ return x !== undefined && hiddenOf(x) === null && x.rev === r.rev && (x.$action === undefined || x.$action === r.$action);
748
+ });
749
+ }
750
+ /**
751
+ * Сверенный с expect предпросмотр, а в нём мешающие строки (restrict) — видимые или скрытые правами (3.0.1): отказ
752
+ * delete_restricted до удаления; detail — как у отказа триггера: видимые { id, class } и число остальных.
753
+ */
754
+ function checkRestrict(now) {
755
+ const rows = now.filter((r) => r.$action === 'restrict').map((r) => ({ id: r.id, class: r.class }));
756
+ const others = now.filter((r) => r.$action === 'hidden' && r.$hidden === 'restrict').reduce((n, r) => n + (r.$count ?? 0), 0);
757
+ if (rows.length || others)
758
+ throw lerr('write.delete_restricted', { visible: rows.length, others }, { detail: { rows, others } });
759
+ }
511
760
  async function deleteOp(w, steps, opStep) {
512
761
  const op = opStep.op;
513
762
  const S = quoteId(w.schema);
514
763
  const ids = await targetIds(w, steps, opStep);
764
+ if (!op.confirm)
765
+ return ids.length ? previewOf(w, ids) : [];
766
+ // delete({ confirm: true, expect }): удалить ровно показанное (план 2.1, A2) — превью заново в той же транзакции.
767
+ // Атомарно (второй раунд 2.1, A1): всё замыкание каскада (строки превью с $action 'delete') блокируется for update в
768
+ // порядке id, и превью строится ещё раз уже под блокировкой. Вставка связи берёт на своих концах for key share
769
+ // (60-write.sql, targets_bad): с for update она конфликтует, с for no key update — нет. Вставщик успел первым —
770
+ // пересчёт видит его строку (preview_changed); мы первые — он получает end_target_missing. Взаимоблокировку при росте
771
+ // замыкания повторяет runTx (40P01)
772
+ if (op.expect !== undefined) {
773
+ let now = ids.length ? await previewOf(w, ids) : [];
774
+ const closure = now.filter((r) => r.$action === 'delete').map((r) => r.id);
775
+ if (closure.length) {
776
+ await lockIds(w.q, S, closure, 'update');
777
+ now = await previewOf(w, ids);
778
+ }
779
+ if (!samePreview(now, op.expect))
780
+ throw lerr('write.preview_changed', undefined, { detail: { preview: now } });
781
+ checkRestrict(now);
782
+ }
515
783
  if (!ids.length)
516
784
  return [];
517
- if (!op.confirm) {
518
- // превью: то же замыкание, что у триггера удаления (ref_action), база не меняется
519
- const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
520
- + `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
521
- return res.map((r) => {
522
- const row = toRow(r.row);
523
- row.$action = r.action;
524
- if (r.depth !== null)
525
- row.$depth = r.depth;
526
- return row;
527
- });
528
- }
529
785
  // результат — надгробия этой транзакции после метки: delete … returning не видит каскад триггера
530
786
  const [{ mark }] = await query(w, `select coalesce(max(seq), 0)::text as mark from ${S}.log where tx = pg_current_xact_id()`, []);
531
787
  const gone = await query(w, `delete from ${S}.entity e where e.id = any ($1::uuid[]) returning ${rowJson('e')} as row`, [ids]);
@@ -548,25 +804,155 @@ async function deleteOp(w, steps, opStep) {
548
804
  }
549
805
  return out;
550
806
  }
551
- /** Удалённые объекты шага (restore): живые цели без удалённых — not_deleted. */
552
- async function deletedTargets(w, steps, opStep, verb) {
553
- const rows = await readRows(w, steps.map(bare), { ...(opStep.opMods ?? {}), withDeleted: true });
807
+ /** Причины отказа для строк шага restore (живых или надгробий не той версии): одним запросом к журналу. */
808
+ async function refusals(w, rows, want) {
809
+ if (!rows.length)
810
+ return [];
811
+ const S = quoteId(w.schema);
812
+ const exp = rows.map((r) => want?.get(r.id.toLowerCase()) ?? null);
813
+ const log = await query(w, `select x.id::text as id, `
814
+ + `(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, `
815
+ + `(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') `
816
+ + `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]);
817
+ const by = new Map(log.map((r) => [r.id.toLowerCase(), r]));
818
+ return rows.map((r, i) => {
819
+ const l = by.get(r.id.toLowerCase());
820
+ const live = r.$deleted !== true;
821
+ const last = l?.after_last ?? null;
822
+ const why = live && last === 'restore' ? 'restored' : live && last === 'create' ? 'reissued' : 'changed';
823
+ return { id: r.id, rev: r.rev, expected: exp[i], after: l?.after ?? null, deleted: !live, live, why };
824
+ });
825
+ }
826
+ /**
827
+ * Отказ restore по смыслу (второй раунд 2.1, A2): есть `changed` — restore_stale (conflict), иначе есть `reissued` —
828
+ * restore_id_taken, иначе restore_already (оба — not_deleted). detail.ids — цели этого ключа; с expect (и всегда у
829
+ * restore_stale) detail.stale — все цели, которые не восстановить, с причиной.
830
+ */
831
+ function restoreRefusal(verb, list, withExpect, cause) {
832
+ const of = (why) => list.filter((x) => x.why === why).map((x) => x.id);
833
+ const extra = (detail) => ({ detail, ...(cause !== undefined ? { cause } : {}) });
834
+ const stale = withExpect ? { stale: list } : {};
835
+ const changed = of('changed');
836
+ if (changed.length)
837
+ return lerr('write.restore_stale', { ids: changed.join(', ') }, extra({ ids: changed, stale: list }));
838
+ const taken = of('reissued');
839
+ if (taken.length)
840
+ return lerr('write.restore_id_taken', { verb, ids: taken.join(', ') }, extra({ ids: taken, ...stale }));
841
+ const done = of('restored');
842
+ return lerr('write.restore_already', { verb, ids: done.join(', ') }, extra({ ids: done, ...stale }));
843
+ }
844
+ /** Строка ответа restore({ expect, partial: true }) для цели, которую не восстановить (второй раунд 2.1, A3). */
845
+ const skipRow = (r, why) => ({ ...r, $action: 'skip', $why: why });
846
+ const byIdOrder = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
847
+ /**
848
+ * Удалённые объекты шага (restore). Шаг цели читается с надгробиями; у шагов до него withDeleted() — свой (план 2.1, A9).
849
+ * Без expect: восстанавливаются удалённые цели; если удалённых нет, а живые есть — отказ по смыслу (restoreRefusal: уже
850
+ * восстановлен, id занят новым объектом) или not_deleted (не удалялся). С expect — только цели из expect, и последняя
851
+ * версия каждой — то же надгробие; иначе отказ по смыслу и ничего не восстановлено, а с partial — строка-пропуск.
852
+ */
853
+ async function deletedTargets(w, steps, opStep, verb, expect, partial) {
854
+ const path = steps.map(bare);
855
+ path[path.length - 1] = { ...path[path.length - 1], withDeleted: true };
856
+ const rows = await readRows(w, path, opStep.opMods ?? {});
857
+ // без явного порядка — по id: restore пишет журнал по очереди целей
858
+ const ordered = (xs) => (opStep.opMods?.order ? xs : [...xs].sort(byIdOrder));
859
+ if (expect !== undefined) {
860
+ const want = new Map(expect.map((x) => [x.id.toLowerCase(), x.rev]));
861
+ const mine = rows.filter((r) => want.has(r.id.toLowerCase()));
862
+ const good = (r) => r.$deleted === true && r.rev === want.get(r.id.toLowerCase());
863
+ const bad = await refusals(w, mine.filter((r) => !good(r)), want);
864
+ if (bad.length && !partial)
865
+ throw restoreRefusal(verb, bad, true);
866
+ const why = new Map(bad.map((x) => [x.id, x.why]));
867
+ const all = ordered(mine);
868
+ 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) };
869
+ }
554
870
  const dead = rows.filter((r) => r.$deleted);
555
- if (!dead.length && rows.length)
871
+ if (!dead.length && rows.length) {
872
+ const live = await refusals(w, rows, null);
873
+ if (live.some((x) => x.why !== 'changed'))
874
+ throw restoreRefusal(verb, live.filter((x) => x.why !== 'changed'), false);
556
875
  throw lerr('write.not_deleted', { verb }, { detail: { ids: rows.map((r) => r.id) } });
557
- // без явного порядка — по id: restore пишет журнал по очереди целей
558
- return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
876
+ }
877
+ const all = ordered(dead);
878
+ return { dead: all, skip: [], order: all.map((r) => r.id) };
559
879
  }
560
- /** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
880
+ /** Отказ функции базы restore() по смыслу (уже восстановлен, id занят, версия не та) — причина и подробности; иначе null. */
881
+ function dbRefusal(e) {
882
+ const le = fromDbError(e);
883
+ if (!(le instanceof LetopisError))
884
+ return null;
885
+ const why = le.key === 'sql-time.restore_already' ? 'restored' : le.key === 'sql-time.restore_id_taken' ? 'reissued'
886
+ : le.key === 'sql-time.restore_stale' ? 'changed' : undefined;
887
+ if (!why)
888
+ return null;
889
+ const d = (le.detail ?? {});
890
+ const p = le.params ?? {};
891
+ return {
892
+ id: String(d.id ?? p.id ?? ''), rev: Number(d.rev ?? p.rev ?? 0), expected: d.expected ?? (typeof p.expected === 'number' ? p.expected : null),
893
+ after: d.after ?? null, deleted: d.deleted ?? false, live: d.live ?? why !== 'changed', why,
894
+ };
895
+ }
896
+ /**
897
+ * Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. С expect
898
+ * база ещё раз сверяет номер последней версии под блокировкой записи журнала (restore(id, rev), план 2.1, A1): гонку
899
+ * между чтением и восстановлением ловит она, и её отказ — по смыслу, как у проверки до записи (второй раунд 2.1, A2).
900
+ * partial (A3): цели, которые не восстановить, — строки-пропуски { …строка, $action: 'skip', $why }, остальные
901
+ * восстанавливаются; каждая цель — под своей точкой сохранения, проигранная гонка тоже становится пропуском.
902
+ */
561
903
  async function restoreOp(w, steps, opStep) {
562
904
  const S = quoteId(w.schema);
563
- const dead = await deletedTargets(w, steps, opStep, 'restore');
905
+ const { expect, partial } = opStep.op;
906
+ const { dead, skip, order } = await deletedTargets(w, steps, opStep, 'restore', expect, partial);
564
907
  if (!dead.length)
565
- return [];
566
- const ids = dead.map((r) => r.id);
567
- await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
568
- const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids]);
569
- return inTargetOrder(ids, res.map((r) => toRow(r.row)));
908
+ return inTargetOrder(order, skip);
909
+ let ids = dead.map((r) => r.id);
910
+ if (expect === undefined) {
911
+ // прежний вызов restore(id): работает и со схемой прежней ревизии
912
+ try {
913
+ await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
914
+ }
915
+ catch (e) {
916
+ const r = dbRefusal(e);
917
+ if (r && r.why !== 'changed')
918
+ throw restoreRefusal('restore', [r], false, e);
919
+ throw e;
920
+ }
921
+ }
922
+ else if (!partial) {
923
+ try {
924
+ 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)]);
925
+ }
926
+ catch (e) {
927
+ const r = dbRefusal(e);
928
+ if (r)
929
+ throw restoreRefusal('restore', [r], true, e);
930
+ throw e;
931
+ }
932
+ }
933
+ else {
934
+ const lost = new Set();
935
+ for (const r of dead) {
936
+ await w.q.unsafe('savepoint letopis_restore');
937
+ try {
938
+ await query(w, `select ${S}.restore($1::uuid, $2::int)`, [r.id, r.rev]);
939
+ await w.q.unsafe('release savepoint letopis_restore');
940
+ }
941
+ catch (e) {
942
+ await w.q.unsafe('rollback to savepoint letopis_restore');
943
+ const why = dbRefusal(e)?.why;
944
+ if (!why)
945
+ throw e;
946
+ lost.add(r.id);
947
+ skip.push(skipRow(r, why));
948
+ }
949
+ }
950
+ ids = ids.filter((id) => !lost.has(id));
951
+ }
952
+ const res = ids.length
953
+ ? await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids])
954
+ : [];
955
+ return inTargetOrder(order, [...res.map((r) => toRow(r.row)), ...skip]);
570
956
  }
571
957
  async function opCall(w, steps, opStep) {
572
958
  await useReason(w, reasonFor(w, opStep));
@@ -632,10 +1018,11 @@ export async function execPlan(w, steps, mods, mode, agg) {
632
1018
  else {
633
1019
  last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
634
1020
  }
635
- // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире)
1021
+ // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после restore — от
1022
+ // восстановленных, без строк-пропусков partial
636
1023
  start = kind === 'delete' && opStep.op.confirm
637
1024
  ? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
638
- : last;
1025
+ : kind === 'restore' ? last.filter((r) => r.$action !== 'skip') : last;
639
1026
  prevOp = opStep;
640
1027
  }
641
1028
  // причина глагола не переходит на следующие операторы транзакции