letopis 0.13.0 → 0.18.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,22 +1,54 @@
1
1
  /**
2
- * Запись: операции .set(data) / .delete(opts) / .anonymize(fields) — ЗВЕНЬЯ цепочки;
3
- * терминал (rows/first/ids/count/run/versions/агрегации) исполняет весь план
4
- * ОДНОЙ транзакцией (runPlan) — любой отказ (валидация/ACL) откатывает всё.
2
+ * Запись: операции .create(data) / .update(data) / .delete(opts) / .anonymize(fields) —
3
+ * ЗВЕНЬЯ цепочки; терминал (rows/first/ids/count/run/versions/агрегации) исполняет весь
4
+ * план ОДНОЙ транзакцией (runPlan) — любой отказ (валидация/ACL) откатывает всё.
5
5
  *
6
6
  * Связи задаются dot-цепочкой: шаги до операции (контекст) резолвятся в ровно одну
7
7
  * сущность каждый и дают:
8
- * - containment-фильтр целей при UPDATE/DELETE (links ⊇ {Класс: id}),
9
- * - links создаваемой строки при INSERT.
8
+ * - containment-фильтр целей при update/delete (links ⊇ {Класс: id}),
9
+ * - links создаваемой строки при create.
10
10
  *
11
- * await db.Организация(org).Сотрудник().set({ name: 'Вася' }).rows() // INSERT + links {Org}
12
- * await db.Клиент(c).Запись({ status: 'created' }).set({ status: 'confirmed' }).rows()
13
- * await db.Клиент({ vip: true }).set({ bonus: 500 }) // сегмент 1: UPDATE всех vip
14
- * .Запись().delete({ confirm: true }).rows() // сегмент 2: снести записи каждого
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
15
  * Продолжение после операции идёт ОТ ЕЁ РЕЗУЛЬТАТА (fan-out по записанным строкам).
16
16
  */
17
+ //
18
+ // FILE: lib/src/write.ts
19
+ // VERSION: 1.0.0
20
+ // START_MODULE_CONTRACT
21
+ // PURPOSE: Write-side движок: валидация, резолв концов/слотов, вычисление id (v5/v7), вставка новых версий, каскадное удаление, анонимизация, исполнение планов и батчей в транзакции.
22
+ // SCOPE: ValidationError, runPlan, executeBatch, createOp/updateOp/delOp/anonymizeOp, toRow/readRows/deepMerge, BatchPlan.
23
+ // DEPENDS: M-UUID, M-TYPES, M-SQL, M-ACL
24
+ // LINKS: M-WRITE, V-M-WRITE
25
+ // ROLE: RUNTIME
26
+ // MAP_MODE: EXPORTS
27
+ // END_MODULE_CONTRACT
28
+ //
29
+ // START_MODULE_MAP
30
+ // ValidationError - ошибка валидации с полями issues
31
+ // toRow/readRows/deepMerge - нормализация строки, чтение, deep-merge патча
32
+ // createOp/updateOp/anonymizeOp/delOp - операции записи (create/update/anonymize/delete)
33
+ // runPlan - исполнить план (сегменты op + читающий хвост) одной транзакцией
34
+ // executeBatch - исполнить очередь планов (fast-path multi-insert для чистых INSERT)
35
+ // BatchPlan/PlanMode - модель плана батча и режимы чтения
36
+ // END_MODULE_MAP
37
+ //
38
+ // START_CHANGE_SUMMARY
39
+ // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
40
+ // END_CHANGE_SUMMARY
17
41
  import { randomUUID } from 'node:crypto';
42
+ import { uuidv5, uuidv7 } from './uuid.js';
18
43
  import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, isTransient, retryDelay, RETRIES, } from './sql.js';
19
44
  import { aclDenied } from './acl.js';
45
+ // START_CONTRACT: ValidationError
46
+ // PURPOSE: Ошибка валидации класса, несущая список проблемных полей (issues).
47
+ // INPUTS: { cls: string; issues: {field, message?}[] }
48
+ // OUTPUTS: { Error - message с перечнем «поле — сообщение» }
49
+ // SIDE_EFFECTS: none
50
+ // LINKS: M-WRITE, V-M-WRITE
51
+ // END_CONTRACT: ValidationError
20
52
  export class ValidationError extends Error {
21
53
  issues;
22
54
  constructor(cls, issues) {
@@ -58,6 +90,14 @@ export function deepMerge(base, patch) {
58
90
  }
59
91
  return out;
60
92
  }
93
+ // START_CONTRACT: validate
94
+ // PURPOSE: Проверить данные по fastest-validator (строго, + id) и концы LINK; вернуть очищенные данные.
95
+ // INPUTS: { cls: ClassDef; id: string; data: object; links: object }
96
+ // OUTPUTS: { object - валидные данные (без id) }
97
+ // SIDE_EFFECTS: none
98
+ // ERRORS: class is abstract; ValidationError; link requires end; stray link(s)
99
+ // LINKS: M-WRITE, V-M-WRITE, M-SCHEMA
100
+ // END_CONTRACT: validate
61
101
  /** Валидация данных по fastest-validator (строгая, + id) и концов LINK. */
62
102
  function validate(cls, id, data, links) {
63
103
  if (cls.abstract)
@@ -67,23 +107,50 @@ function validate(cls, id, data, links) {
67
107
  if (res !== true)
68
108
  throw new ValidationError(cls.id, res);
69
109
  delete merged.id;
110
+ // START_BLOCK_VALIDATE_LINK_ENDS
70
111
  if (cls.category === 'LINK') {
71
- let polymorphic = 0;
72
112
  const present = new Set(Object.keys(links));
73
- for (const end of cls.links) {
74
- if (end === 'Entity')
75
- polymorphic++;
76
- else if (present.has(end))
77
- present.delete(end);
78
- else
79
- throw new Error(`letopis: link "${cls.id}" requires end "${end}"`);
113
+ if (cls.strictEnds) {
114
+ // схема v2: жадный матчинг по порядку объявления концов; союз занимает один ключ;
115
+ // обязательный конец без ключа и ключ вне концов — ошибки
116
+ for (const end of cls.links) {
117
+ const hit = end.classes.find((c) => present.has(c));
118
+ if (hit)
119
+ present.delete(hit);
120
+ else if (!end.optional)
121
+ throw new Error(`letopis: link "${cls.id}" requires end "${end.classes.join('|')}"`);
122
+ }
123
+ if (present.size) {
124
+ throw new Error(`letopis: link "${cls.id}" has stray link(s) ${[...present].map((k) => `"${k}"`).join(', ')} — not an end in Schema.links`);
125
+ }
80
126
  }
81
- if (present.size < polymorphic) {
82
- throw new Error(`letopis: link "${cls.id}" requires ${polymorphic} polymorphic end(s) (any class), got ${present.size}`);
127
+ else {
128
+ // legacy-схема (строковые концы): 'Entity' — полиморф-счётчик, лишние связи молчат
129
+ let polymorphic = 0;
130
+ for (const end of cls.links) {
131
+ const name = end.classes[0];
132
+ if (name === 'Entity')
133
+ polymorphic++;
134
+ else if (present.has(name))
135
+ present.delete(name);
136
+ else
137
+ throw new Error(`letopis: link "${cls.id}" requires end "${name}"`);
138
+ }
139
+ if (present.size < polymorphic) {
140
+ throw new Error(`letopis: link "${cls.id}" requires ${polymorphic} polymorphic end(s) (any class), got ${present.size}`);
141
+ }
83
142
  }
84
143
  }
144
+ // END_BLOCK_VALIDATE_LINK_ENDS
85
145
  return merged;
86
146
  }
147
+ // START_CONTRACT: insertOne
148
+ // PURPOSE: INSERT одной строки-версии с ретраем гонок (23505 коллизия updated, 40P01/40001 transient) вне транзакции.
149
+ // INPUTS: { ctx: Ctx; a: InsertArgs }
150
+ // OUTPUTS: { Promise<Row> - вставленная строка }
151
+ // SIDE_EFFECTS: INSERT в Entity через runQuery
152
+ // LINKS: M-WRITE, V-M-WRITE, M-SQL, M-DDL
153
+ // END_CONTRACT: insertOne
87
154
  /** INSERT одной строки с ретраем гонок: 23505 (коллизия updated) и transient (40P01/40001). */
88
155
  async function insertOne(ctx, a) {
89
156
  // jsonb-параметры — JS-объектами (postgres.js сериализует сам)
@@ -91,6 +158,7 @@ async function insertOne(ctx, a) {
91
158
  ctx.partition, a.id, a.cls.id, a.data, a.links, a.tags,
92
159
  a.account, a.owner, a.prevUpdated, a.deleted,
93
160
  ];
161
+ // START_BLOCK_INSERT_ONE_RETRY
94
162
  for (let attempt = 1;; attempt++) {
95
163
  try {
96
164
  const res = await runQuery(ctx, insertSql(ctx.pgSchema), params, 'insert', [a.cls.id]);
@@ -109,6 +177,7 @@ async function insertOne(ctx, a) {
109
177
  throw e;
110
178
  }
111
179
  }
180
+ // END_BLOCK_INSERT_ONE_RETRY
112
181
  }
113
182
  /**
114
183
  * enforceAcl: право операции на класс цели. Предикат победившего правила:
@@ -117,6 +186,14 @@ async function insertOne(ctx, a) {
117
186
  * (явный чужой модификатор на цепочке → ошибка).
118
187
  * Мутирует step (объект создаётся цепочкой на вызов — локально безопасно).
119
188
  */
189
+ // START_CONTRACT: aclWrite
190
+ // PURPOSE: enforceAcl-проверка права записи/удаления на класс цели; предикат правила пришпиливает owner/account и фильтрует цели.
191
+ // INPUTS: { ctx: Ctx; step: Step; op: 'WRITE'|'DELETE' }
192
+ // OUTPUTS: { void - мутирует step (aclFilter/ownerFilter/accountFilter) }
193
+ // SIDE_EFFECTS: мутирует step
194
+ // ERRORS: acl denies WRITE/DELETE; acl pins writes to owner/account
195
+ // LINKS: M-WRITE, V-M-WRITE, M-ACL
196
+ // END_CONTRACT: aclWrite
120
197
  function aclWrite(ctx, step, op) {
121
198
  if (!ctx.aclDecide)
122
199
  return;
@@ -137,11 +214,6 @@ function aclWrite(ctx, step, op) {
137
214
  step[key] = v;
138
215
  }
139
216
  }
140
- /**
141
- * Различие форм фильтра в записи:
142
- * Класс() — БЕЗ аргумента → INSERT новой сущности;
143
- * Класс({}) — пустой объект → «все найденные» (UPDATE в границах контекста).
144
- */
145
217
  const noFilter = (f) => f === undefined;
146
218
  /** Entity.account NOT NULL: модификатор → connect() → System-аккаунт; иначе ошибка. */
147
219
  function resolveAccount(ctx, step) {
@@ -165,93 +237,229 @@ function tagsValue(step) {
165
237
  return t;
166
238
  throw new Error('letopis: .tags() before set() accepts string | string[]');
167
239
  }
240
+ /** Конец target, принимающий класс cls (legacy: 'Entity'-конец берёт любой HUB). */
241
+ function endFor(target, cls) {
242
+ return target.links.find((e) => e.classes.includes(cls.id) || (!target.strictEnds && e.classes.includes('Entity') && cls.category === 'HUB'));
243
+ }
168
244
  /**
169
- * Контекст-шаги (все, кроме последнего) → {КлассId: id}.
170
- * id-фильтр берётся как есть (без запроса); иной фильтр обязан дать ровно одну сущность.
245
+ * Связи создаваемой сущности из ПУТИ: каждый реальный шаг-предок, чей класс — конец
246
+ * target, обязан дать ровно одну сущность (id-фильтр — как есть; иначе чтение путём
247
+ * от начала до него: контекст честно учитывается).
171
248
  */
172
- async function resolveContext(ctx, steps) {
249
+ // START_CONTRACT: resolvePathEnds
250
+ // PURPOSE: Связи создаваемой сущности из пути: каждый шаг-предок, чей класс — конец target, должен дать ровно одну сущность.
251
+ // INPUTS: { ctx: Ctx; steps: Step[] }
252
+ // OUTPUTS: { Promise<Record<string,string>> - класс конца → id }
253
+ // SIDE_EFFECTS: чтения контекста (readRows)
254
+ // ERRORS: context step must resolve to exactly one entity
255
+ // LINKS: M-WRITE, V-M-WRITE
256
+ // END_CONTRACT: resolvePathEnds
257
+ async function resolvePathEnds(ctx, steps) {
258
+ const target = steps[steps.length - 1].cls;
173
259
  const links = {};
174
- for (const step of steps) {
260
+ for (let k = 0; k < steps.length - 1; k++) {
261
+ const step = steps[k];
262
+ if (step.pivotKey !== undefined || step.op)
263
+ continue;
264
+ if (!endFor(target, step.cls))
265
+ continue;
175
266
  let id;
176
267
  if (typeof step.filter === 'string') {
177
268
  id = step.filter;
178
269
  }
179
- else if (!noFilter(step.filter) || step.tagsFilter !== undefined || step.accountFilter || step.ownerFilter) {
180
- const rows = await readRows(ctx, [step], { limit: 2 });
270
+ else {
271
+ const rows = await readRows(ctx, steps.slice(0, k + 1), { limit: 2 });
181
272
  if (rows.length !== 1) {
182
273
  throw new Error(`letopis: context step "${step.name}" must resolve to exactly one entity (got ${rows.length})`);
183
274
  }
184
275
  id = rows[0].id;
185
276
  }
186
- else {
187
- throw new Error(`letopis: context step "${step.name}" needs an id or a unique filter`);
188
- }
189
277
  links[step.cls.id] = id;
190
278
  }
191
279
  return links;
192
280
  }
193
- /** Шаг поиска целей: фильтр последнего шага + containment контекст-связей. */
194
- function targetStep(target, contextLinks, explicitId) {
195
- return {
196
- ...target,
197
- filter: explicitId ?? target.filter,
198
- linksFilter: Object.keys(contextLinks).length ? contextLinks : undefined,
199
- };
281
+ /** Резолв слот-значений: строки как есть; вложенные планы — в той же транзакции, ровно одна сущность. */
282
+ // START_CONTRACT: resolveSlots
283
+ // PURPOSE: Резолв слот-значений (.Класс.set/.unset): строки как есть, вложенные планы — та же транзакция и ровно одна сущность; союз-конец снимается целиком.
284
+ // INPUTS: { ctx: Ctx; target: ClassDef; extra: Step['extraLinks'] }
285
+ // OUTPUTS: { Promise<{ vals: Record<string,string>; drops: Set<string> }> }
286
+ // SIDE_EFFECTS: может исполнять вложенные планы/чтения
287
+ // ERRORS: slot value plan must resolve to exactly one entity; resolved to class "<x>"
288
+ // LINKS: M-WRITE, V-M-WRITE
289
+ // END_CONTRACT: resolveSlots
290
+ async function resolveSlots(ctx, target, extra) {
291
+ const vals = {};
292
+ const drops = new Set();
293
+ for (const [clsId, v] of Object.entries(extra ?? {})) {
294
+ // союз-замещение: слот занимает КОНЕЦ целиком — соседние классы союза снимаются
295
+ const end = endFor(target, ctx.registry.resolve(clsId));
296
+ for (const c of end?.classes ?? [clsId])
297
+ drops.add(c);
298
+ if (v === null)
299
+ continue; // .delete() — только снятие
300
+ let id;
301
+ if (typeof v === 'string') {
302
+ id = v;
303
+ }
304
+ else {
305
+ const rows = v.plan.some((s) => s.op)
306
+ ? (await runPlan(ctx, v.plan, v.mods ?? {}, 'rows'))
307
+ : await readRows(ctx, v.plan, { ...(v.mods ?? {}), limit: 2 });
308
+ if (rows.length !== 1) {
309
+ throw new Error(`letopis: slot "${clsId}" value plan must resolve to exactly one entity (got ${rows.length})`);
310
+ }
311
+ if (rows[0].class !== clsId) {
312
+ throw new Error(`letopis: slot "${clsId}" value plan resolved to class "${rows[0].class}"`);
313
+ }
314
+ id = rows[0].id;
315
+ }
316
+ vals[clsId] = id;
317
+ }
318
+ return { vals, drops };
319
+ }
320
+ /** id новой сущности по IdGen класса (v5 считается отдельно — нужны концы/данные). */
321
+ const genId = (cls) => (cls.idGen.version === 7 ? uuidv7() : randomUUID());
322
+ /**
323
+ * default поля из правила Schema.attributes: объект-правило `{default: …}` или
324
+ * DSL-строка `'string|default:базовая'`. Нужен v5Id: id считается ДО валидации (та
325
+ * подставляет defaults позже), поэтому необязательное from-поле берёт дефолт здесь.
326
+ */
327
+ function defaultOf(rule) {
328
+ if (rule && typeof rule === 'object' && !Array.isArray(rule) && 'default' in rule) {
329
+ return rule.default;
330
+ }
331
+ if (typeof rule === 'string') {
332
+ const seg = rule.split('|').map((s) => s.trim()).find((s) => s.startsWith('default:'));
333
+ if (seg)
334
+ return seg.slice('default:'.length);
335
+ }
336
+ return undefined;
337
+ }
338
+ /**
339
+ * Детерминированный id (uuid v5): имя = «схема:партиция:класс:значения from».
340
+ * from-источник — конец Schema.links (класс или полное имя союза 'Service|Complex';
341
+ * значение — id присутствующего класса конца) или скалярное поле data (если не задано —
342
+ * его default из Schema.attributes: id стабилен и без явного ввода необязательного поля).
343
+ */
344
+ // START_CONTRACT: v5Id
345
+ // PURPOSE: Детерминированный id (uuid v5) из «схема:партиция:класс:значения from» (концы Schema.links или скалярные поля/дефолты).
346
+ // INPUTS: { ctx: Ctx; cls: ClassDef; links: object; data: object }
347
+ // OUTPUTS: { string - uuid v5 }
348
+ // SIDE_EFFECTS: none
349
+ // ERRORS: needs end "<...>"; needs scalar data field "<f>"
350
+ // LINKS: M-WRITE, V-M-WRITE, M-UUID
351
+ // END_CONTRACT: v5Id
352
+ function v5Id(ctx, cls, links, data) {
353
+ const parts = [];
354
+ for (const f of cls.idGen.from) {
355
+ const end = cls.links.find((e) => e.classes.includes(f) || e.classes.join('|') === f);
356
+ if (end) {
357
+ const hit = end.classes.find((c) => c in links);
358
+ if (!hit) {
359
+ throw new Error(`letopis: id (uuid v5) of "${cls.id}" needs end "${end.classes.join('|')}" — set it in the path or with a slot`);
360
+ }
361
+ parts.push(links[hit]);
362
+ }
363
+ else {
364
+ const v = data[f] === undefined ? defaultOf(cls.attributes[f]) : data[f];
365
+ if (v === undefined || v === null || typeof v === 'object') {
366
+ throw new Error(`letopis: id (uuid v5) of "${cls.id}" needs scalar data field "${f}"`);
367
+ }
368
+ parts.push(String(v));
369
+ }
370
+ }
371
+ return uuidv5(`${ctx.pgSchema}:${ctx.partition}:${cls.id}:${parts.join(':')}`);
372
+ }
373
+ /** Новая версия каждой строки: deep-merge данных + слоты концов (союз-конец замещается целиком). */
374
+ async function versionRows(ctx, target, found, data, slots) {
375
+ const cls = target.cls;
376
+ const out = [];
377
+ for (const row of found) {
378
+ const mergedData = deepMerge(row.data, data); // только указанные листья
379
+ const mergedLinks = { ...row.links };
380
+ for (const c of slots.drops)
381
+ delete mergedLinks[c];
382
+ Object.assign(mergedLinks, slots.vals);
383
+ const full = validate(cls, row.id, mergedData, mergedLinks);
384
+ out.push(await insertOne(ctx, {
385
+ id: row.id, cls, data: full, links: mergedLinks,
386
+ tags: tagsValue(target) ?? row.tags, account: row.account, owner: row.owner,
387
+ prevUpdated: row.updated, deleted: null,
388
+ }));
389
+ }
390
+ return out;
200
391
  }
201
392
  /**
202
- * .set(data):
203
- * - пустой фильтр последнего шага и нет data.id → INSERT (links = контекст);
204
- * - id (фильтр-строка или data.id) → UPSERT: есть → новая версия (deep-merge), нет → создать;
205
- * - фильтр-объект → новая версия каждого найденного (в границах контекста); пусто → [].
393
+ * .create(data) — «чтобы сущность существовала»:
394
+ * - id не задан → INSERT (id по IdGen класса; links = концы из пути + слоты);
395
+ * - id известен (Класс(id) / data.id / вычислен v5): есть → новая версия (deep-merge),
396
+ * нет → INSERT с этим id (идемпотентный create, REST-PUT семантика);
397
+ * - фильтр-объект / pivot — ошибка: create не ищет, это update().
206
398
  */
207
- export async function setOp(ctx, steps, mods, data) {
399
+ // START_CONTRACT: createOp
400
+ // PURPOSE: .create(data) — INSERT новой сущности либо новая версия при известном/вычисленном id (идемпотентный create); поиск — это update().
401
+ // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; data: object }
402
+ // OUTPUTS: { Promise<Row[]> }
403
+ // SIDE_EFFECTS: INSERT в Entity (в транзакции runPlan)
404
+ // ERRORS: create() on a pivot; takes no filter; got two ids; computes id (remove explicit); acl denies WRITE
405
+ // LINKS: M-WRITE, V-M-WRITE, M-ACL
406
+ // END_CONTRACT: createOp
407
+ export async function createOp(ctx, steps, mods, data) {
408
+ // START_BLOCK_CREATE_RESOLVE
208
409
  const target = steps[steps.length - 1];
209
410
  const cls = target.cls;
210
411
  aclWrite(ctx, target, 'WRITE');
211
- // контекст-шаги: и фильтр целей, и значения; .link() — ТОЛЬКО значения (фильтр не трогает)
212
- const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
213
- const writeLinks = { ...contextLinks, ...(target.extraLinks ?? {}) };
214
- const dataId = typeof data.id === 'string' ? data.id : undefined;
412
+ if (target.pivotKey !== undefined) {
413
+ throw new Error(`letopis: create() on a pivot step — pivot returns to an existing node, use update()`);
414
+ }
215
415
  const filterId = typeof target.filter === 'string' ? target.filter : undefined;
216
- const explicitId = filterId ?? dataId;
217
- const insertMode = noFilter(target.filter); // Класс() без аргумента
218
- const found = insertMode && !explicitId
219
- ? []
220
- : await readRows(ctx, [targetStep(target, contextLinks, explicitId)], mods);
221
- if (found.length) {
222
- const out = [];
223
- for (const row of found) {
224
- const mergedData = deepMerge(row.data, data); // только указанные листья
225
- const mergedLinks = { ...row.links, ...writeLinks }; // контекст + .link()
226
- const full = validate(cls, row.id, mergedData, mergedLinks);
227
- out.push(await insertOne(ctx, {
228
- id: row.id, cls, data: full, links: mergedLinks,
229
- tags: tagsValue(target) ?? row.tags, account: row.account, owner: row.owner,
230
- prevUpdated: row.updated, deleted: null,
231
- }));
416
+ if (!noFilter(target.filter) && filterId === undefined) {
417
+ throw new Error(`letopis: create() takes no filter — ${cls.alias}(id).create(…) fixes the id, searching is update()`);
418
+ }
419
+ // слоты (.Класс.set/.unset): значения/снятия концов; союз-конец замещается целиком
420
+ const slots = await resolveSlots(ctx, cls, target.extraLinks);
421
+ const dataId = typeof data.id === 'string' ? data.id : undefined;
422
+ if (filterId && dataId && filterId !== dataId) {
423
+ throw new Error(`letopis: create() got two ids — step "${filterId}" vs data.id "${dataId}"`);
424
+ }
425
+ let explicitId = filterId ?? dataId;
426
+ // связи создаваемого: концы из пути + слоты (слоты выигрывают, unset снимает)
427
+ const pathEnds = await resolvePathEnds(ctx, steps);
428
+ const writeLinks = { ...pathEnds };
429
+ for (const c of slots.drops)
430
+ delete writeLinks[c];
431
+ Object.assign(writeLinks, slots.vals);
432
+ if (cls.idGen.version === 5) {
433
+ if (explicitId) {
434
+ throw new Error(`letopis: class "${cls.id}" computes id (uuid v5 from ${cls.idGen.from.join(', ')}) — remove the explicit id`);
232
435
  }
233
- return out;
436
+ explicitId = v5Id(ctx, cls, writeLinks, data);
234
437
  }
235
- // не найдено: INSERT только при явном id или Класс() без аргумента
236
- if (!explicitId && !insertMode)
237
- return [];
238
- // enforceAcl-предикат + явный id: «не нашёл среди доступных» НЕ значит «можно создать» —
239
- // существующая, но недоступная сущность иначе перехватывалась бы новой версией с чужим owner.
240
- // Проверка существования — БЕЗ предиката и БЕЗ пришпиленных им колонок.
241
- if (explicitId && target.aclFilter) {
242
- const bare = { ...ctx, aclDecide: undefined };
243
- const probe = {
244
- ...targetStep(target, contextLinks, explicitId),
245
- aclFilter: undefined,
246
- ownerFilter: undefined,
247
- accountFilter: undefined,
248
- linksFilter: undefined,
249
- };
250
- const taken = await readRows(bare, [probe], {});
251
- if (taken.length)
252
- return [];
438
+ if (explicitId !== undefined) {
439
+ // id известен и существует (в границах пути) → новая версия
440
+ const pathSteps = [...steps.slice(0, -1), { ...target, filter: explicitId }];
441
+ const found = await readRows(ctx, pathSteps, mods);
442
+ if (found.length)
443
+ return versionRows(ctx, target, found, data, slots);
444
+ // enforceAcl-предикат: «не нашёл среди доступных» НЕ значит «можно создать» —
445
+ // существующая, но недоступная сущность иначе перехватывалась бы новой версией
446
+ // с чужим owner. Проверка существования — БЕЗ предиката и пришпиленных колонок.
447
+ if (target.aclFilter) {
448
+ const bare = { ...ctx, aclDecide: undefined };
449
+ const probe = {
450
+ ...target,
451
+ filter: explicitId,
452
+ aclFilter: undefined,
453
+ ownerFilter: undefined,
454
+ accountFilter: undefined,
455
+ linksFilter: undefined,
456
+ };
457
+ const taken = await readRows(bare, [probe], {});
458
+ if (taken.length)
459
+ return [];
460
+ }
253
461
  }
254
- const id = explicitId ?? randomUUID();
462
+ const id = explicitId ?? genId(cls);
255
463
  const account = resolveAccount(ctx, target);
256
464
  const owner = target.ownerFilter ?? ctx.owner ?? account;
257
465
  const full = validate(cls, id, data, writeLinks);
@@ -261,6 +469,31 @@ export async function setOp(ctx, steps, mods, data) {
261
469
  tags: tagsValue(target) ?? [], account, owner, prevUpdated: null, deleted: null,
262
470
  }),
263
471
  ];
472
+ // END_BLOCK_CREATE_RESOLVE
473
+ }
474
+ /**
475
+ * .update(data?) — новая версия КАЖДОГО найденного путём (deep-merge листьев);
476
+ * цели: Класс() ≡ Класс({}) — все в границах контекста, id/фильтр/pivot — как в чтении.
477
+ * Не найдено → [] — update НИКОГДА не создаёт.
478
+ */
479
+ // START_CONTRACT: updateOp
480
+ // PURPOSE: .update(data?) — новая версия каждого найденного путём (deep-merge листьев); никогда не создаёт.
481
+ // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; data: object }
482
+ // OUTPUTS: { Promise<Row[]> }
483
+ // SIDE_EFFECTS: INSERT новых версий
484
+ // LINKS: M-WRITE, V-M-WRITE
485
+ // END_CONTRACT: updateOp
486
+ export async function updateOp(ctx, steps, mods, data) {
487
+ const target = steps[steps.length - 1];
488
+ aclWrite(ctx, target, 'WRITE');
489
+ const slots = await resolveSlots(ctx, target.cls, target.extraLinks);
490
+ // data.id — цель, только если у шага нет своего фильтра
491
+ const dataId = typeof data.id === 'string' ? data.id : undefined;
492
+ const pathSteps = noFilter(target.filter) && dataId !== undefined
493
+ ? [...steps.slice(0, -1), { ...target, filter: dataId }]
494
+ : steps;
495
+ const found = await readRows(ctx, pathSteps, mods); // честный путь: переходы, pivot
496
+ return versionRows(ctx, target, found, data, slots);
264
497
  }
265
498
  /**
266
499
  * .anonymize(fields): GDPR-затирание — новая версия с '[erased]' в указанных string-полях
@@ -275,8 +508,7 @@ export async function anonymizeOp(ctx, steps, mods, fields) {
275
508
  throw new Error(`letopis: anonymize() erases string fields only; "${f}" is ${cls.fieldTypes.get(f)?.kind ?? 'unknown'}`);
276
509
  }
277
510
  }
278
- const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
279
- const found = await readRows(ctx, [targetStep(target, contextLinks)], mods);
511
+ const found = await readRows(ctx, steps, mods); // цели — честный путь
280
512
  const out = [];
281
513
  for (const row of found) {
282
514
  const patch = Object.fromEntries(fields.filter((f) => f in row.data).map((f) => [f, '[erased]']));
@@ -295,6 +527,7 @@ export async function anonymizeOp(ctx, steps, mods, fields) {
295
527
  async function inTransaction(ctx, fn) {
296
528
  if (ctx.inTx)
297
529
  return fn(ctx);
530
+ // START_BLOCK_IN_TRANSACTION_RETRY
298
531
  for (let attempt = 1;; attempt++) {
299
532
  try {
300
533
  return (await ctx.sql.begin(async (tsql) => fn({ ...ctx, sql: tsql, inTx: true })));
@@ -307,6 +540,7 @@ async function inTransaction(ctx, fn) {
307
540
  throw e;
308
541
  }
309
542
  }
543
+ // END_BLOCK_IN_TRANSACTION_RETRY
310
544
  }
311
545
  /**
312
546
  * .delete({confirm}): цели = фильтр последнего шага + контекст-связи.
@@ -314,14 +548,22 @@ async function inTransaction(ctx, fn) {
314
548
  * каскад + advisory-lock); возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
315
549
  * Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
316
550
  */
551
+ // START_CONTRACT: delOp
552
+ // PURPOSE: .delete({confirm}) — серверное удаление (tombstone + рекурсивный каскад) при confirm, иначе превью замыкания; DELETE-право на каждый класс каскада.
553
+ // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; confirm: boolean }
554
+ // OUTPUTS: { Promise<Row[]> - удалённые (цели+каскад) с $deleted, либо превью }
555
+ // SIDE_EFFECTS: DELETE в Entity (триггер entity_delete) при confirm
556
+ // ERRORS: acl denies DELETE (в каскаде)
557
+ // LINKS: M-WRITE, V-M-WRITE, M-ACL, M-DDL
558
+ // END_CONTRACT: delOp
317
559
  export async function delOp(ctx, steps, mods, confirm) {
318
560
  const target = steps[steps.length - 1];
319
561
  aclWrite(ctx, target, 'DELETE');
320
- const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
321
- const targets = await readRows(ctx, [targetStep(target, contextLinks)], mods);
562
+ const targets = await readRows(ctx, steps, mods); // цели — честный путь
322
563
  if (!targets.length)
323
564
  return [];
324
565
  const params = [ctx.partition, target.cls.id, ...targets.map((r) => r.id)];
566
+ // START_BLOCK_DELETE_CASCADE
325
567
  return inTransaction(ctx, async (c) => {
326
568
  const closure = await runQuery(c, closureSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
327
569
  if (!confirm)
@@ -337,6 +579,7 @@ export async function delOp(ctx, steps, mods, confirm) {
337
579
  await runQuery(c, deleteSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
338
580
  return closure.map((r) => ({ ...toRow(r), $deleted: true }));
339
581
  });
582
+ // END_BLOCK_DELETE_CASCADE
340
583
  }
341
584
  /** Разрезать план: сегменты (…шаги + op-шаг) и читающий хвост после последней операции. */
342
585
  function splitPlan(steps) {
@@ -353,8 +596,10 @@ function splitPlan(steps) {
353
596
  }
354
597
  function opCall(c, steps, op) {
355
598
  switch (op.kind) {
356
- case 'set':
357
- return setOp(c, steps, op.mods, op.data ?? {});
599
+ case 'create':
600
+ return createOp(c, steps, op.mods, op.data ?? {});
601
+ case 'update':
602
+ return updateOp(c, steps, op.mods, op.data ?? {});
358
603
  case 'anonymize':
359
604
  return anonymizeOp(c, steps, op.mods, op.fields ?? []);
360
605
  case 'delete':
@@ -378,9 +623,17 @@ const RawRowFmt = {
378
623
  * строки результата (контекст = строка); self-шаг (op сразу после op) пишет в те же строки.
379
624
  * После delete продолжение идёт от строк КЛАССА ЦЕЛИ (замыкание каскада шире).
380
625
  */
626
+ // START_CONTRACT: runPlan
627
+ // PURPOSE: Исполнить план целиком (сегменты операций + читающий хвост) одной транзакцией; продолжение = fan-out по строкам результата.
628
+ // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; mode: PlanMode }
629
+ // OUTPUTS: { Promise<unknown> - результат в запрошенном режиме }
630
+ // SIDE_EFFECTS: записи + чтения в одной транзакции
631
+ // LINKS: M-WRITE, V-M-WRITE, M-SQL
632
+ // END_CONTRACT: runPlan
381
633
  export async function runPlan(ctx, steps, mods, mode) {
382
634
  const { segments, tail } = splitPlan(steps);
383
635
  return inTransaction(ctx, async (c) => {
636
+ // START_BLOCK_RUNPLAN_SEGMENTS
384
637
  let last = []; // полный результат последней операции (для терминала)
385
638
  let start = []; // строки для продолжения (класс цели)
386
639
  for (let i = 0; i < segments.length; i++) {
@@ -400,6 +653,7 @@ export async function runPlan(ctx, steps, mods, mode) {
400
653
  }
401
654
  start = seg.op.kind === 'delete' ? last.filter((r) => r.class === targetCls.id) : last;
402
655
  }
656
+ // END_BLOCK_RUNPLAN_SEGMENTS
403
657
  const opStep = segments[segments.length - 1].steps.at(-1);
404
658
  const keyName = opStep.aliasKey ?? opStep.name;
405
659
  // хвост-чтение от результата последней операции (в той же транзакции)
@@ -473,10 +727,18 @@ async function readByMode(c, steps, mods, mode) {
473
727
  }
474
728
  }
475
729
  const isPureInsertPlan = (p) => p.steps.length === 1 &&
476
- p.steps[0].op?.kind === 'set' &&
730
+ p.steps[0].op?.kind === 'create' &&
477
731
  noFilter(p.steps[0].filter) &&
478
732
  typeof p.steps[0].op.data?.id !== 'string' &&
479
- !Object.keys(p.steps[0].extraLinks ?? {}).length;
733
+ !Object.keys(p.steps[0].extraLinks ?? {}).length &&
734
+ p.steps[0].cls.idGen.version !== 5;
735
+ // START_CONTRACT: executeBatch
736
+ // PURPOSE: Исполнить очередь планов одной транзакцией; подряд идущие чистые INSERT одного класса склеиваются в multi-VALUES.
737
+ // INPUTS: { ctx: Ctx; queue: BatchPlan[] }
738
+ // OUTPUTS: { Promise<Row[][]> - результат по каждому плану }
739
+ // SIDE_EFFECTS: INSERT (multi/по-плану) в транзакции
740
+ // LINKS: M-WRITE, V-M-WRITE, M-SQL
741
+ // END_CONTRACT: executeBatch
480
742
  export async function executeBatch(ctx, queue) {
481
743
  if (!queue.length)
482
744
  return [];
@@ -484,6 +746,7 @@ export async function executeBatch(ctx, queue) {
484
746
  const results = new Array(queue.length);
485
747
  let i = 0;
486
748
  while (i < queue.length) {
749
+ // START_BLOCK_BATCH_FASTPATH
487
750
  if (isPureInsertPlan(queue[i])) {
488
751
  const cls = queue[i].steps[0].cls;
489
752
  let j = i;
@@ -493,8 +756,8 @@ export async function executeBatch(ctx, queue) {
493
756
  const params = [];
494
757
  for (const g of group) {
495
758
  const s = { ...g.steps[0] };
496
- aclWrite(c, s, 'WRITE'); // склейка multi-VALUES идёт мимо setOp — право+пришпиливание на каждый
497
- const id = randomUUID();
759
+ aclWrite(c, s, 'WRITE'); // склейка multi-VALUES идёт мимо createOp — право+пришпиливание на каждый
760
+ const id = genId(cls);
498
761
  const full = validate(cls, id, s.op?.data ?? {}, {});
499
762
  const account = resolveAccount(c, s);
500
763
  params.push(c.partition, id, cls.id, full, {}, tagsValue(s) ?? [], account, s.ownerFilter ?? c.owner ?? account);
@@ -505,6 +768,7 @@ export async function executeBatch(ctx, queue) {
505
768
  i = j;
506
769
  continue;
507
770
  }
771
+ // END_BLOCK_BATCH_FASTPATH
508
772
  results[i] = (await runPlan(c, queue[i].steps, {}, 'rows'));
509
773
  i++;
510
774
  }