letopis 0.5.0 → 0.16.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.
package/dist/write.js CHANGED
@@ -1,17 +1,23 @@
1
1
  /**
2
- * Запись: терминалы .set(data) / .delete() на цепочке + исполнение батчей.
2
+ * Запись: операции .create(data) / .update(data) / .delete(opts) / .anonymize(fields) —
3
+ * ЗВЕНЬЯ цепочки; терминал (rows/first/ids/count/run/versions/агрегации) исполняет весь
4
+ * план ОДНОЙ транзакцией (runPlan) — любой отказ (валидация/ACL) откатывает всё.
3
5
  *
4
- * Связи задаются dot-цепочкой: НЕ-последние шаги (контекст) резолвятся в ровно одну
6
+ * Связи задаются dot-цепочкой: шаги до операции (контекст) резолвятся в ровно одну
5
7
  * сущность каждый и дают:
6
- * - containment-фильтр целей при UPDATE/DELETE (links ⊇ {Класс: id}),
7
- * - links создаваемой строки при INSERT.
8
+ * - containment-фильтр целей при update/delete (links ⊇ {Класс: id}),
9
+ * - links создаваемой строки при create.
8
10
  *
9
- * db.Организация(org).Сотрудник().set({ name: 'Вася' }) // INSERT + links {Org}
10
- * tr.Сотрудник(s).Окно(w).Запись(b).занятость().set({ id, kind }) // UPSERT по data.id
11
- * db.Клиент(c).Запись({ status: 'created' }).set({ status: 'confirmed' }) // UPDATE найденных
11
+ * await db.Организация(org).Сотрудник().create({ name: 'Вася' }).rows() // INSERT + links {Org}
12
+ * await db.Клиент(c).Запись({ status: 'created' }).update({ status: 'confirmed' }).rows()
13
+ * await db.Клиент({ vip: true }).update({ bonus: 500 }) // сегмент 1: версия каждого vip
14
+ * .Запись().delete({ confirm: true }).rows() // сегмент 2: снести записи каждого
15
+ * Продолжение после операции идёт ОТ ЕЁ РЕЗУЛЬТАТА (fan-out по записанным строкам).
12
16
  */
13
17
  import { randomUUID } from 'node:crypto';
14
- import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql } from './sql.js';
18
+ import { uuidv5, uuidv7 } from './uuid.js';
19
+ import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, isTransient, retryDelay, RETRIES, } from './sql.js';
20
+ import { aclDenied } from './acl.js';
15
21
  export class ValidationError extends Error {
16
22
  issues;
17
23
  constructor(cls, issues) {
@@ -63,23 +69,41 @@ function validate(cls, id, data, links) {
63
69
  throw new ValidationError(cls.id, res);
64
70
  delete merged.id;
65
71
  if (cls.category === 'LINK') {
66
- let polymorphic = 0;
67
72
  const present = new Set(Object.keys(links));
68
- for (const end of cls.links) {
69
- if (end === 'Entity')
70
- polymorphic++;
71
- else if (present.has(end))
72
- present.delete(end);
73
- else
74
- throw new Error(`letopis: link "${cls.id}" requires end "${end}"`);
73
+ if (cls.strictEnds) {
74
+ // схема v2: жадный матчинг по порядку объявления концов; союз занимает один ключ;
75
+ // обязательный конец без ключа и ключ вне концов — ошибки
76
+ for (const end of cls.links) {
77
+ const hit = end.classes.find((c) => present.has(c));
78
+ if (hit)
79
+ present.delete(hit);
80
+ else if (!end.optional)
81
+ throw new Error(`letopis: link "${cls.id}" requires end "${end.classes.join('|')}"`);
82
+ }
83
+ if (present.size) {
84
+ throw new Error(`letopis: link "${cls.id}" has stray link(s) ${[...present].map((k) => `"${k}"`).join(', ')} — not an end in Schema.links`);
85
+ }
75
86
  }
76
- if (present.size < polymorphic) {
77
- throw new Error(`letopis: link "${cls.id}" requires ${polymorphic} polymorphic end(s) (any class), got ${present.size}`);
87
+ else {
88
+ // legacy-схема (строковые концы): 'Entity' — полиморф-счётчик, лишние связи молчат
89
+ let polymorphic = 0;
90
+ for (const end of cls.links) {
91
+ const name = end.classes[0];
92
+ if (name === 'Entity')
93
+ polymorphic++;
94
+ else if (present.has(name))
95
+ present.delete(name);
96
+ else
97
+ throw new Error(`letopis: link "${cls.id}" requires end "${name}"`);
98
+ }
99
+ if (present.size < polymorphic) {
100
+ throw new Error(`letopis: link "${cls.id}" requires ${polymorphic} polymorphic end(s) (any class), got ${present.size}`);
101
+ }
78
102
  }
79
103
  }
80
104
  return merged;
81
105
  }
82
- /** INSERT одной строки с ретраем коллизии PK по updated (23505). */
106
+ /** INSERT одной строки с ретраем гонок: 23505 (коллизия updated) и transient (40P01/40001). */
83
107
  async function insertOne(ctx, a) {
84
108
  // jsonb-параметры — JS-объектами (postgres.js сериализует сам)
85
109
  const params = [
@@ -92,17 +116,46 @@ async function insertOne(ctx, a) {
92
116
  return toRow(res[0]);
93
117
  }
94
118
  catch (e) {
95
- if (e.code === '23505' && attempt < 3)
96
- continue;
119
+ // внутри транзакции повтор невозможен (aborted) — любая ошибка наружу
120
+ if (!ctx.inTx && attempt < RETRIES) {
121
+ if (e.code === '23505')
122
+ continue; // новый clock_timestamp сразу
123
+ if (isTransient(e)) {
124
+ await retryDelay(attempt);
125
+ continue;
126
+ }
127
+ }
97
128
  throw e;
98
129
  }
99
130
  }
100
131
  }
101
132
  /**
102
- * Различие форм фильтра в записи:
103
- * Класс() — БЕЗ аргумента → INSERT новой сущности;
104
- * Класс({}) — пустой объект → «все найденные» (UPDATE в границах контекста).
133
+ * enforceAcl: право операции на класс цели. Предикат победившего правила:
134
+ * - фильтрует цели UPDATE/DELETE (step.aclFilter → WHERE при поиске);
135
+ * - колоночные ключи owner/account пришпиливают значения INSERT
136
+ * (явный чужой модификатор на цепочке → ошибка).
137
+ * Мутирует step (объект создаётся цепочкой на вызов — локально безопасно).
105
138
  */
139
+ function aclWrite(ctx, step, op) {
140
+ if (!ctx.aclDecide)
141
+ return;
142
+ const d = ctx.aclDecide(step.cls, op);
143
+ if (!d.allow)
144
+ throw aclDenied(op, step.cls.id, d);
145
+ if (!d.filter)
146
+ return;
147
+ step.aclFilter = d.filter;
148
+ for (const col of ['owner', 'account']) {
149
+ const v = d.filter[col];
150
+ if (typeof v !== 'string')
151
+ continue;
152
+ const key = col === 'owner' ? 'ownerFilter' : 'accountFilter';
153
+ if (step[key] && step[key] !== v) {
154
+ throw new Error(`letopis: acl pins ${step.cls.id} writes to ${col} ${v}`);
155
+ }
156
+ step[key] = v;
157
+ }
158
+ }
106
159
  const noFilter = (f) => f === undefined;
107
160
  /** Entity.account NOT NULL: модификатор → connect() → System-аккаунт; иначе ошибка. */
108
161
  function resolveAccount(ctx, step) {
@@ -111,7 +164,7 @@ function resolveAccount(ctx, step) {
111
164
  }
112
165
  const acc = step.accountFilter ?? ctx.account ?? ctx.systemAccount;
113
166
  if (!acc) {
114
- throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / connect({account}) or seed a System account (db/seed.auth.sql)');
167
+ throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / connect({account}) or seed a System account (lib/sql/seed.auth.sql)');
115
168
  }
116
169
  return acc;
117
170
  }
@@ -126,76 +179,179 @@ function tagsValue(step) {
126
179
  return t;
127
180
  throw new Error('letopis: .tags() before set() accepts string | string[]');
128
181
  }
182
+ /** Конец target, принимающий класс cls (legacy: 'Entity'-конец берёт любой HUB). */
183
+ function endFor(target, cls) {
184
+ return target.links.find((e) => e.classes.includes(cls.id) || (!target.strictEnds && e.classes.includes('Entity') && cls.category === 'HUB'));
185
+ }
129
186
  /**
130
- * Контекст-шаги (все, кроме последнего) → {КлассId: id}.
131
- * id-фильтр берётся как есть (без запроса); иной фильтр обязан дать ровно одну сущность.
187
+ * Связи создаваемой сущности из ПУТИ: каждый реальный шаг-предок, чей класс — конец
188
+ * target, обязан дать ровно одну сущность (id-фильтр — как есть; иначе чтение путём
189
+ * от начала до него: контекст честно учитывается).
132
190
  */
133
- async function resolveContext(ctx, steps) {
191
+ async function resolvePathEnds(ctx, steps) {
192
+ const target = steps[steps.length - 1].cls;
134
193
  const links = {};
135
- for (const step of steps) {
194
+ for (let k = 0; k < steps.length - 1; k++) {
195
+ const step = steps[k];
196
+ if (step.pivotKey !== undefined || step.op)
197
+ continue;
198
+ if (!endFor(target, step.cls))
199
+ continue;
136
200
  let id;
137
201
  if (typeof step.filter === 'string') {
138
202
  id = step.filter;
139
203
  }
140
- else if (!noFilter(step.filter) || step.tagsFilter !== undefined || step.accountFilter || step.ownerFilter) {
141
- const rows = await readRows(ctx, [step], { limit: 2 });
204
+ else {
205
+ const rows = await readRows(ctx, steps.slice(0, k + 1), { limit: 2 });
142
206
  if (rows.length !== 1) {
143
207
  throw new Error(`letopis: context step "${step.name}" must resolve to exactly one entity (got ${rows.length})`);
144
208
  }
145
209
  id = rows[0].id;
146
210
  }
147
- else {
148
- throw new Error(`letopis: context step "${step.name}" needs an id or a unique filter`);
149
- }
150
211
  links[step.cls.id] = id;
151
212
  }
152
213
  return links;
153
214
  }
154
- /** Шаг поиска целей: фильтр последнего шага + containment контекст-связей. */
155
- function targetStep(target, contextLinks, explicitId) {
156
- return {
157
- ...target,
158
- filter: explicitId ?? target.filter,
159
- linksFilter: Object.keys(contextLinks).length ? contextLinks : undefined,
160
- };
215
+ /** Резолв слот-значений: строки как есть; вложенные планы — в той же транзакции, ровно одна сущность. */
216
+ async function resolveSlots(ctx, target, extra) {
217
+ const vals = {};
218
+ const drops = new Set();
219
+ for (const [clsId, v] of Object.entries(extra ?? {})) {
220
+ // союз-замещение: слот занимает КОНЕЦ целиком — соседние классы союза снимаются
221
+ const end = endFor(target, ctx.registry.resolve(clsId));
222
+ for (const c of end?.classes ?? [clsId])
223
+ drops.add(c);
224
+ if (v === null)
225
+ continue; // .delete() — только снятие
226
+ let id;
227
+ if (typeof v === 'string') {
228
+ id = v;
229
+ }
230
+ else {
231
+ const rows = v.plan.some((s) => s.op)
232
+ ? (await runPlan(ctx, v.plan, v.mods ?? {}, 'rows'))
233
+ : await readRows(ctx, v.plan, { ...(v.mods ?? {}), limit: 2 });
234
+ if (rows.length !== 1) {
235
+ throw new Error(`letopis: slot "${clsId}" value plan must resolve to exactly one entity (got ${rows.length})`);
236
+ }
237
+ if (rows[0].class !== clsId) {
238
+ throw new Error(`letopis: slot "${clsId}" value plan resolved to class "${rows[0].class}"`);
239
+ }
240
+ id = rows[0].id;
241
+ }
242
+ vals[clsId] = id;
243
+ }
244
+ return { vals, drops };
245
+ }
246
+ /** id новой сущности по IdGen класса (v5 считается отдельно — нужны концы/данные). */
247
+ const genId = (cls) => (cls.idGen.version === 7 ? uuidv7() : randomUUID());
248
+ /**
249
+ * Детерминированный id (uuid v5): имя = «схема:партиция:класс:значения from».
250
+ * from-источник — конец Schema.links (класс или полное имя союза 'Service|Complex';
251
+ * значение — id присутствующего класса конца) или скалярное поле data.
252
+ */
253
+ function v5Id(ctx, cls, links, data) {
254
+ const parts = [];
255
+ for (const f of cls.idGen.from) {
256
+ const end = cls.links.find((e) => e.classes.includes(f) || e.classes.join('|') === f);
257
+ if (end) {
258
+ const hit = end.classes.find((c) => c in links);
259
+ if (!hit) {
260
+ throw new Error(`letopis: id (uuid v5) of "${cls.id}" needs end "${end.classes.join('|')}" — set it in the path or with a slot`);
261
+ }
262
+ parts.push(links[hit]);
263
+ }
264
+ else {
265
+ const v = data[f];
266
+ if (v === undefined || v === null || typeof v === 'object') {
267
+ throw new Error(`letopis: id (uuid v5) of "${cls.id}" needs scalar data field "${f}"`);
268
+ }
269
+ parts.push(String(v));
270
+ }
271
+ }
272
+ return uuidv5(`${ctx.pgSchema}:${ctx.partition}:${cls.id}:${parts.join(':')}`);
273
+ }
274
+ /** Новая версия каждой строки: deep-merge данных + слоты концов (союз-конец замещается целиком). */
275
+ async function versionRows(ctx, target, found, data, slots) {
276
+ const cls = target.cls;
277
+ const out = [];
278
+ for (const row of found) {
279
+ const mergedData = deepMerge(row.data, data); // только указанные листья
280
+ const mergedLinks = { ...row.links };
281
+ for (const c of slots.drops)
282
+ delete mergedLinks[c];
283
+ Object.assign(mergedLinks, slots.vals);
284
+ const full = validate(cls, row.id, mergedData, mergedLinks);
285
+ out.push(await insertOne(ctx, {
286
+ id: row.id, cls, data: full, links: mergedLinks,
287
+ tags: tagsValue(target) ?? row.tags, account: row.account, owner: row.owner,
288
+ prevUpdated: row.updated, deleted: null,
289
+ }));
290
+ }
291
+ return out;
161
292
  }
162
293
  /**
163
- * .set(data):
164
- * - пустой фильтр последнего шага и нет data.id → INSERT (links = контекст);
165
- * - id (фильтр-строка или data.id) → UPSERT: есть → новая версия (deep-merge), нет → создать;
166
- * - фильтр-объект → новая версия каждого найденного (в границах контекста); пусто → [].
294
+ * .create(data) — «чтобы сущность существовала»:
295
+ * - id не задан → INSERT (id по IdGen класса; links = концы из пути + слоты);
296
+ * - id известен (Класс(id) / data.id / вычислен v5): есть → новая версия (deep-merge),
297
+ * нет → INSERT с этим id (идемпотентный create, REST-PUT семантика);
298
+ * - фильтр-объект / pivot — ошибка: create не ищет, это update().
167
299
  */
168
- export async function setOp(ctx, steps, mods, data, extraLinks = {}) {
300
+ export async function createOp(ctx, steps, mods, data) {
169
301
  const target = steps[steps.length - 1];
170
302
  const cls = target.cls;
171
- // контекст-шаги: и фильтр целей, и значения; довешенные extraLinks — ТОЛЬКО значения
172
- const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
173
- const writeLinks = { ...contextLinks, ...extraLinks };
174
- const dataId = typeof data.id === 'string' ? data.id : undefined;
303
+ aclWrite(ctx, target, 'WRITE');
304
+ if (target.pivotKey !== undefined) {
305
+ throw new Error(`letopis: create() on a pivot step — pivot returns to an existing node, use update()`);
306
+ }
175
307
  const filterId = typeof target.filter === 'string' ? target.filter : undefined;
176
- const explicitId = filterId ?? dataId;
177
- const insertMode = noFilter(target.filter); // Класс() без аргумента
178
- const found = insertMode && !explicitId
179
- ? []
180
- : await readRows(ctx, [targetStep(target, contextLinks, explicitId)], mods);
181
- if (found.length) {
182
- const out = [];
183
- for (const row of found) {
184
- const mergedData = deepMerge(row.data, data); // только указанные листья
185
- const mergedLinks = { ...row.links, ...writeLinks }; // контекст + довешенные
186
- const full = validate(cls, row.id, mergedData, mergedLinks);
187
- out.push(await insertOne(ctx, {
188
- id: row.id, cls, data: full, links: mergedLinks,
189
- tags: tagsValue(target) ?? row.tags, account: row.account, owner: row.owner,
190
- prevUpdated: row.updated, deleted: null,
191
- }));
308
+ if (!noFilter(target.filter) && filterId === undefined) {
309
+ throw new Error(`letopis: create() takes no filter — ${cls.alias}(id).create(…) fixes the id, searching is update()`);
310
+ }
311
+ // слоты (.Класс.set/.unset): значения/снятия концов; союз-конец замещается целиком
312
+ const slots = await resolveSlots(ctx, cls, target.extraLinks);
313
+ const dataId = typeof data.id === 'string' ? data.id : undefined;
314
+ if (filterId && dataId && filterId !== dataId) {
315
+ throw new Error(`letopis: create() got two ids — step "${filterId}" vs data.id "${dataId}"`);
316
+ }
317
+ let explicitId = filterId ?? dataId;
318
+ // связи создаваемого: концы из пути + слоты (слоты выигрывают, unset снимает)
319
+ const pathEnds = await resolvePathEnds(ctx, steps);
320
+ const writeLinks = { ...pathEnds };
321
+ for (const c of slots.drops)
322
+ delete writeLinks[c];
323
+ Object.assign(writeLinks, slots.vals);
324
+ if (cls.idGen.version === 5) {
325
+ if (explicitId) {
326
+ throw new Error(`letopis: class "${cls.id}" computes id (uuid v5 from ${cls.idGen.from.join(', ')}) — remove the explicit id`);
192
327
  }
193
- return out;
328
+ explicitId = v5Id(ctx, cls, writeLinks, data);
194
329
  }
195
- // не найдено: INSERT только при явном id или Класс() без аргумента
196
- if (!explicitId && !insertMode)
197
- return [];
198
- const id = explicitId ?? randomUUID();
330
+ if (explicitId !== undefined) {
331
+ // id известен и существует (в границах пути) → новая версия
332
+ const pathSteps = [...steps.slice(0, -1), { ...target, filter: explicitId }];
333
+ const found = await readRows(ctx, pathSteps, mods);
334
+ if (found.length)
335
+ return versionRows(ctx, target, found, data, slots);
336
+ // enforceAcl-предикат: «не нашёл среди доступных» НЕ значит «можно создать» —
337
+ // существующая, но недоступная сущность иначе перехватывалась бы новой версией
338
+ // с чужим owner. Проверка существования — БЕЗ предиката и пришпиленных колонок.
339
+ if (target.aclFilter) {
340
+ const bare = { ...ctx, aclDecide: undefined };
341
+ const probe = {
342
+ ...target,
343
+ filter: explicitId,
344
+ aclFilter: undefined,
345
+ ownerFilter: undefined,
346
+ accountFilter: undefined,
347
+ linksFilter: undefined,
348
+ };
349
+ const taken = await readRows(bare, [probe], {});
350
+ if (taken.length)
351
+ return [];
352
+ }
353
+ }
354
+ const id = explicitId ?? genId(cls);
199
355
  const account = resolveAccount(ctx, target);
200
356
  const owner = target.ownerFilter ?? ctx.owner ?? account;
201
357
  const full = validate(cls, id, data, writeLinks);
@@ -206,6 +362,23 @@ export async function setOp(ctx, steps, mods, data, extraLinks = {}) {
206
362
  }),
207
363
  ];
208
364
  }
365
+ /**
366
+ * .update(data?) — новая версия КАЖДОГО найденного путём (deep-merge листьев);
367
+ * цели: Класс() ≡ Класс({}) — все в границах контекста, id/фильтр/pivot — как в чтении.
368
+ * Не найдено → [] — update НИКОГДА не создаёт.
369
+ */
370
+ export async function updateOp(ctx, steps, mods, data) {
371
+ const target = steps[steps.length - 1];
372
+ aclWrite(ctx, target, 'WRITE');
373
+ const slots = await resolveSlots(ctx, target.cls, target.extraLinks);
374
+ // data.id — цель, только если у шага нет своего фильтра
375
+ const dataId = typeof data.id === 'string' ? data.id : undefined;
376
+ const pathSteps = noFilter(target.filter) && dataId !== undefined
377
+ ? [...steps.slice(0, -1), { ...target, filter: dataId }]
378
+ : steps;
379
+ const found = await readRows(ctx, pathSteps, mods); // честный путь: переходы, pivot
380
+ return versionRows(ctx, target, found, data, slots);
381
+ }
209
382
  /**
210
383
  * .anonymize(fields): GDPR-затирание — новая версия с '[erased]' в указанных string-полях
211
384
  * + тег 'anonymized'. Только string-поля (по Schema); история сохраняется (см. README).
@@ -213,13 +386,13 @@ export async function setOp(ctx, steps, mods, data, extraLinks = {}) {
213
386
  export async function anonymizeOp(ctx, steps, mods, fields) {
214
387
  const target = steps[steps.length - 1];
215
388
  const cls = target.cls;
389
+ aclWrite(ctx, target, 'WRITE');
216
390
  for (const f of fields) {
217
391
  if (cls.fieldTypes.get(f)?.kind !== 'string') {
218
392
  throw new Error(`letopis: anonymize() erases string fields only; "${f}" is ${cls.fieldTypes.get(f)?.kind ?? 'unknown'}`);
219
393
  }
220
394
  }
221
- const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
222
- const found = await readRows(ctx, [targetStep(target, contextLinks)], mods);
395
+ const found = await readRows(ctx, steps, mods); // цели — честный путь
223
396
  const out = [];
224
397
  for (const row of found) {
225
398
  const patch = Object.fromEntries(fields.filter((f) => f in row.data).map((f) => [f, '[erased]']));
@@ -233,35 +406,195 @@ export async function anonymizeOp(ctx, steps, mods, fields) {
233
406
  }
234
407
  return out;
235
408
  }
236
- /** Транзакционная обёртка: в tr исполняем как есть, вне — sql.begin. */
409
+ /** Транзакционная обёртка: в tr исполняем как есть, вне — sql.begin с ретраем transient
410
+ * (deadlock/serialization откатывает ВСЮ внутреннюю транзакцию — повтор честен). */
237
411
  async function inTransaction(ctx, fn) {
238
412
  if (ctx.inTx)
239
413
  return fn(ctx);
240
- return (await ctx.sql.begin(async (tsql) => fn({ ...ctx, sql: tsql, inTx: true })));
414
+ for (let attempt = 1;; attempt++) {
415
+ try {
416
+ return (await ctx.sql.begin(async (tsql) => fn({ ...ctx, sql: tsql, inTx: true })));
417
+ }
418
+ catch (e) {
419
+ if (isTransient(e) && attempt < RETRIES) {
420
+ await retryDelay(attempt);
421
+ continue;
422
+ }
423
+ throw e;
424
+ }
425
+ }
241
426
  }
242
427
  /**
243
- * .delete(): цели = фильтр последнего шага + контекст-связи; серверное удаление.
244
- * Триггер entity_delete: tombstone + рекурсивный каскад (+advisory-lock).
245
- * Возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
428
+ * .delete({confirm}): цели = фильтр последнего шага + контекст-связи.
429
+ * confirm: true — серверное удаление (триггер entity_delete: tombstone + рекурсивный
430
+ * каскад + advisory-lock); возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
431
+ * Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
246
432
  */
247
- export async function delOp(ctx, steps, mods) {
433
+ export async function delOp(ctx, steps, mods, confirm) {
248
434
  const target = steps[steps.length - 1];
249
- const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
250
- const targets = await readRows(ctx, [targetStep(target, contextLinks)], mods);
435
+ aclWrite(ctx, target, 'DELETE');
436
+ const targets = await readRows(ctx, steps, mods); // цели — честный путь
251
437
  if (!targets.length)
252
438
  return [];
253
439
  const params = [ctx.partition, target.cls.id, ...targets.map((r) => r.id)];
254
440
  return inTransaction(ctx, async (c) => {
255
441
  const closure = await runQuery(c, closureSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
442
+ if (!confirm)
443
+ return closure.map((r) => toRow(r)); // превью: кандидаты живы, БД не тронута
444
+ // каскад дотянется до зависимых ЛЮБЫХ классов — на каждый нужно DELETE-право
445
+ if (c.aclDecide) {
446
+ for (const cn of new Set(closure.map((r) => r.class))) {
447
+ const d = c.aclDecide(c.registry.resolve(cn), 'DELETE');
448
+ if (!d.allow)
449
+ throw aclDenied('DELETE', `${cn} (в каскаде от ${target.cls.id})`, d);
450
+ }
451
+ }
256
452
  await runQuery(c, deleteSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
257
453
  return closure.map((r) => ({ ...toRow(r), $deleted: true }));
258
454
  });
259
455
  }
260
- const isPureInsert = (op) => op.kind === 'set' &&
261
- op.steps.length === 1 &&
262
- noFilter(op.steps[0].filter) &&
263
- typeof op.data?.id !== 'string' &&
264
- !Object.keys(op.extraLinks ?? {}).length;
456
+ /** Разрезать план: сегменты (…шаги + op-шаг) и читающий хвост после последней операции. */
457
+ function splitPlan(steps) {
458
+ const segments = [];
459
+ let buf = [];
460
+ for (const s of steps) {
461
+ buf.push(s);
462
+ if (s.op) {
463
+ segments.push({ steps: buf, op: s.op });
464
+ buf = [];
465
+ }
466
+ }
467
+ return { segments, tail: buf };
468
+ }
469
+ function opCall(c, steps, op) {
470
+ switch (op.kind) {
471
+ case 'create':
472
+ return createOp(c, steps, op.mods, op.data ?? {});
473
+ case 'update':
474
+ return updateOp(c, steps, op.mods, op.data ?? {});
475
+ case 'anonymize':
476
+ return anonymizeOp(c, steps, op.mods, op.fields ?? []);
477
+ case 'delete':
478
+ return delOp(c, steps, op.mods, op.confirm === true);
479
+ }
480
+ }
481
+ /** Виртуальный старт-шаг «от этих строк»: контекст/чтение следующего сегмента. */
482
+ const virtualStep = (c, row) => ({ name: row.class, cls: c.registry.resolve(row.class), filter: row.id });
483
+ const RawRowFmt = {
484
+ rows: (res) => res.map((r) => {
485
+ const row = toRow(r.row);
486
+ return r.row.depth != null ? { ...row, $depth: r.row.depth } : row;
487
+ }),
488
+ paths: (res) => res.map((r) => Object.fromEntries(Object.entries(r.path).map(([k, v]) => [k, toRow(v)]))),
489
+ versions: (res) => res.map((r) => (r.row.deleted ? { ...toRow(r.row), $deleted: true } : toRow(r.row))),
490
+ };
491
+ /**
492
+ * Исполнить план целиком: все операции + финальное чтение — одна транзакция
493
+ * (внутренняя, с ретраем transient; внутри db.begin() — транзакция пользователя).
494
+ * Продолжение после операции: fan-out — каждый следующий сегмент исполняется от каждой
495
+ * строки результата (контекст = строка); self-шаг (op сразу после op) пишет в те же строки.
496
+ * После delete продолжение идёт от строк КЛАССА ЦЕЛИ (замыкание каскада шире).
497
+ */
498
+ export async function runPlan(ctx, steps, mods, mode) {
499
+ const { segments, tail } = splitPlan(steps);
500
+ return inTransaction(ctx, async (c) => {
501
+ let last = []; // полный результат последней операции (для терминала)
502
+ let start = []; // строки для продолжения (класс цели)
503
+ for (let i = 0; i < segments.length; i++) {
504
+ const seg = segments[i];
505
+ const targetCls = seg.steps[seg.steps.length - 1].cls;
506
+ if (i === 0) {
507
+ last = await opCall(c, seg.steps, seg.op);
508
+ }
509
+ else {
510
+ last = [];
511
+ for (const row of start) {
512
+ const rowSteps = seg.steps[0].self
513
+ ? [{ ...seg.steps[0], self: undefined, filter: row.id, op: undefined }]
514
+ : [virtualStep(c, row), ...seg.steps.map((s) => ({ ...s, op: undefined }))];
515
+ last.push(...await opCall(c, rowSteps, seg.op));
516
+ }
517
+ }
518
+ start = seg.op.kind === 'delete' ? last.filter((r) => r.class === targetCls.id) : last;
519
+ }
520
+ const opStep = segments[segments.length - 1].steps.at(-1);
521
+ const keyName = opStep.aliasKey ?? opStep.name;
522
+ // хвост-чтение от результата последней операции (в той же транзакции)
523
+ if (tail.length) {
524
+ if (!start.length)
525
+ return emptyFor(mode, mods);
526
+ const virt = { name: keyName, cls: opStep.cls, filter: start.map((r) => r.id) };
527
+ return readByMode(c, [virt, ...tail], mods, mode);
528
+ }
529
+ // терминал прямо на операции
530
+ switch (mode) {
531
+ case 'rows':
532
+ return last;
533
+ case 'ids':
534
+ return last.map((r) => r.id);
535
+ case 'count':
536
+ return last.length;
537
+ case 'paths':
538
+ return last.map((r) => ({ [keyName]: r }));
539
+ case 'versions':
540
+ case 'agg': {
541
+ if (!start.length)
542
+ return emptyFor(mode, mods);
543
+ const virt = { name: keyName, cls: opStep.cls, filter: start.map((r) => r.id) };
544
+ return readByMode(c, [virt], mods, mode);
545
+ }
546
+ }
547
+ });
548
+ }
549
+ function emptyFor(mode, mods) {
550
+ switch (mode) {
551
+ case 'rows':
552
+ case 'versions':
553
+ case 'paths':
554
+ case 'ids':
555
+ return [];
556
+ case 'count':
557
+ return 0;
558
+ case 'agg':
559
+ return mods.aggFn === 'countBy' ? [] : [{ v: null }];
560
+ }
561
+ }
562
+ /** Финальное чтение плана: тот же buildRead, что у обычных цепочек. */
563
+ async function readByMode(c, steps, mods, mode) {
564
+ const classes = steps.map((s) => s.cls.id);
565
+ switch (mode) {
566
+ case 'rows': {
567
+ const q = buildRead(c, steps, mods, 'rows');
568
+ return RawRowFmt.rows(await runQuery(c, q.text, q.params, 'rows', classes));
569
+ }
570
+ case 'paths': {
571
+ const q = buildRead(c, steps, mods, 'paths');
572
+ return RawRowFmt.paths(await runQuery(c, q.text, q.params, 'paths', classes));
573
+ }
574
+ case 'versions': {
575
+ const q = buildRead(c, steps, mods, 'versions');
576
+ return RawRowFmt.versions(await runQuery(c, q.text, q.params, 'versions', classes));
577
+ }
578
+ case 'ids': {
579
+ const q = buildRead(c, steps, mods, 'ids');
580
+ return (await runQuery(c, q.text, q.params, 'ids', classes)).map((r) => r.id);
581
+ }
582
+ case 'count': {
583
+ const q = buildRead(c, steps, mods, 'count');
584
+ return (await runQuery(c, q.text, q.params, 'count', classes))[0].n;
585
+ }
586
+ case 'agg': {
587
+ const q = buildRead(c, steps, mods, 'agg');
588
+ return runQuery(c, q.text, q.params, 'agg', classes);
589
+ }
590
+ }
591
+ }
592
+ const isPureInsertPlan = (p) => p.steps.length === 1 &&
593
+ p.steps[0].op?.kind === 'create' &&
594
+ noFilter(p.steps[0].filter) &&
595
+ typeof p.steps[0].op.data?.id !== 'string' &&
596
+ !Object.keys(p.steps[0].extraLinks ?? {}).length &&
597
+ p.steps[0].cls.idGen.version !== 5;
265
598
  export async function executeBatch(ctx, queue) {
266
599
  if (!queue.length)
267
600
  return [];
@@ -269,19 +602,18 @@ export async function executeBatch(ctx, queue) {
269
602
  const results = new Array(queue.length);
270
603
  let i = 0;
271
604
  while (i < queue.length) {
272
- const op = queue[i];
273
- if (isPureInsert(op)) {
274
- const step = op.steps[0];
275
- const cls = step.cls;
605
+ if (isPureInsertPlan(queue[i])) {
606
+ const cls = queue[i].steps[0].cls;
276
607
  let j = i;
277
- while (j < queue.length && isPureInsert(queue[j]) && queue[j].steps[0].cls.id === cls.id)
608
+ while (j < queue.length && isPureInsertPlan(queue[j]) && queue[j].steps[0].cls.id === cls.id)
278
609
  j++;
279
610
  const group = queue.slice(i, j);
280
611
  const params = [];
281
612
  for (const g of group) {
282
- const s = g.steps[0];
283
- const id = randomUUID();
284
- const full = validate(cls, id, g.data ?? {}, {});
613
+ const s = { ...g.steps[0] };
614
+ aclWrite(c, s, 'WRITE'); // склейка multi-VALUES идёт мимо createOp — право+пришпиливание на каждый
615
+ const id = genId(cls);
616
+ const full = validate(cls, id, s.op?.data ?? {}, {});
285
617
  const account = resolveAccount(c, s);
286
618
  params.push(c.partition, id, cls.id, full, {}, tagsValue(s) ?? [], account, s.ownerFilter ?? c.owner ?? account);
287
619
  }
@@ -291,10 +623,7 @@ export async function executeBatch(ctx, queue) {
291
623
  i = j;
292
624
  continue;
293
625
  }
294
- results[i] =
295
- op.kind === 'set'
296
- ? await setOp(c, op.steps, op.mods, op.data ?? {}, op.extraLinks)
297
- : await delOp(c, op.steps, op.mods);
626
+ results[i] = (await runPlan(c, queue[i].steps, {}, 'rows'));
298
627
  i++;
299
628
  }
300
629
  return results;