letopis 0.20.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +348 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3466
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +369 -551
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -250
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -351
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -600
  89. package/sql/seed.auth.sql +0 -73
package/dist/write.js CHANGED
@@ -1,817 +1,755 @@
1
1
  /**
2
- * Запись: операции .create(data) / .update(data) / .delete(opts) / .anonymize(fields) —
3
- * ЗВЕНЬЯ цепочки; терминал (rows/first/ids/count/run/versions/агрегации) исполняет весь
4
- * план ОДНОЙ транзакцией (runPlan) — любой отказ (валидация/ACL) откатывает всё.
2
+ * Запись планами (план, §2.5, этап 4, п. 1–7, 10): глаголы create, update, upsert, delete, anonymize
3
+ * и reclass — звенья цепочки; терминал исполняет весь план одной транзакцией. Этап 6: restore и
4
+ * purge — над удалёнными объектами шага, rekey — новое значение ключа (функции базы).
5
5
  *
6
- * Связи задаются dot-цепочкой: шаги до операции (контекст) резолвятся в ровно одну
7
- * сущность каждый и дают:
8
- * - containment-фильтр целей при update/delete (links ⊇ {Класс: id}),
9
- * - links создаваемой строки при create.
6
+ * await db.Org(org).Staff().create({ name: 'Вася', phone: '+7' }).rows() // концы из пути: { Org }
7
+ * await db.Customer(c).booking({ notes: 'x' }).update({ notes: 'y' }).rows()
8
+ * await db.Wallet(w).update({ balance: inc(-300) }).first() // приращение в базе
9
+ * await db.Staff(s).booking().create({ … }).Customer.set(c).Item.set(svc).rows() // слоты концов
10
10
  *
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 по записанным строкам).
11
+ * Библиотека пишет обычными операторами SQL — теми же, что выполнил бы человек в консоли:
12
+ * insert, update … set data = merge(data, $patch), insert … on conflict do update, delete; всё
13
+ * проверяют триггеры базы. Продолжение после глагола идёт от его результата (fan-out по строкам),
14
+ * глагол сразу после глагола пишет в те же сущности. Всё, что проверяется без базы, проверяется
15
+ * до отправки первого оператора (precheckPlan): такая ошибка не ломает явную транзакцию.
16
+ * Если update или delete затронул меньше строк, чем шаг нашёл, план откатывается:
17
+ * строка видна, но править нельзя — acl_denied; строки уже нет — target_not_found; номер версии
18
+ * разошёлся со { rev } — conflict.
16
19
  */
17
- //
18
- // FILE: lib/src/write.ts
19
- // VERSION: 1.1.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.1.0 - resolveAccount зовёт guardScoped: при enforceAccount без scope (db.as)
40
- // запись запрещена с внятной подсказкой; ctx.account теперь приходит из scope, не из connect.
41
- // Ранее: Documented existing module: reverse-engineered contract + markup]
42
- // END_CHANGE_SUMMARY
43
- import { randomUUID } from 'node:crypto';
44
- import { uuidv5, uuidv7 } from './uuid.js';
45
- import { buildRead, guardScoped, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, purgeCallSql, isTransient, retryDelay, RETRIES, } from './sql.js';
46
- import { aclDenied } from './acl.js';
47
- // START_CONTRACT: ValidationError
48
- // PURPOSE: Ошибка валидации класса, несущая список проблемных полей (issues).
49
- // INPUTS: { cls: string; issues: {field, message?}[] }
50
- // OUTPUTS: { Error - message с перечнем «поле — сообщение» }
51
- // SIDE_EFFECTS: none
52
- // LINKS: M-WRITE, V-M-WRITE
53
- // END_CONTRACT: ValidationError
54
- export class ValidationError extends Error {
55
- issues;
56
- constructor(cls, issues) {
57
- super(`letopis: validation failed for "${cls}": ${issues.map((i) => `${i.field} — ${i.message ?? 'invalid'}`).join('; ')}`);
58
- this.issues = issues;
59
- }
60
- }
61
- export function toRow(r) {
62
- return {
63
- id: r.id,
64
- class: r.class,
65
- data: r.data ?? {},
66
- links: r.links ?? {},
67
- tags: r.tags ?? [],
68
- account: r.account,
69
- owner: r.owner,
70
- updated: r.updated instanceof Date ? r.updated.toISOString() : r.updated,
71
- };
72
- }
73
- /** Прочитать актуальные строки по цепочке. */
74
- export async function readRows(ctx, steps, mods = {}) {
75
- const q = buildRead(ctx, steps, mods, 'rows');
76
- const res = await runQuery(ctx, q.text, q.params, 'rows', steps.map((s) => s.cls.id));
77
- return res.map((r) => toRow(r.row));
78
- }
79
- const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date);
20
+ import { LetopisError, ValidationError } from './errors.js';
21
+ import { isOp } from './ops.js';
22
+ import { accepts, buildRead, nextNodeKey, resolveHop, stepClasses, toRow } from './sql.js';
23
+ import { idOf } from './uuid.js';
24
+ import { applyDefaults } from './validate.js';
25
+ // ---------------------------------------------------------------------------
26
+ // Приращение: маркер, которого не получить из JSON (§2.5)
27
+ // ---------------------------------------------------------------------------
28
+ /** Метка приращения; Symbol.for — маркер узнаёт и вторая копия пакета в процессе. */
29
+ export const INC = Symbol.for('letopis.inc');
80
30
  /**
81
- * Deep-merge патча в базу: меняются ТОЛЬКО указанные листья.
82
- * Вложенные plain-объекты сливаются рекурсивно; массивы/скаляры/null — заменяются.
31
+ * Приращение в update: `update({ balance: inc(-300) })` — величина прибавляется в базе, внутри
32
+ * оператора, над свежей строкой (функция inc). Поля нет — ошибка; `{ start }` — начать с него.
83
33
  */
84
- export function deepMerge(base, patch) {
85
- const out = { ...base };
86
- for (const [k, v] of Object.entries(patch)) {
87
- if (v === undefined)
88
- continue;
89
- out[k] = isPlainObject(v) && isPlainObject(out[k])
90
- ? deepMerge(out[k], v)
91
- : v;
92
- }
93
- return out;
34
+ export function inc(by, opts = {}) {
35
+ return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
94
36
  }
95
- // START_CONTRACT: validate
96
- // PURPOSE: Проверить данные по fastest-validator (строго, + id) и концы LINK; вернуть очищенные данные.
97
- // INPUTS: { cls: ClassDef; id: string; data: object; links: object }
98
- // OUTPUTS: { object - валидные данные (без id) }
99
- // SIDE_EFFECTS: none
100
- // ERRORS: class is abstract; ValidationError; link requires end; stray link(s)
101
- // LINKS: M-WRITE, V-M-WRITE, M-SCHEMA
102
- // END_CONTRACT: validate
103
- /** Валидация данных по fastest-validator (строгая, + id) и концов LINK. */
104
- function validate(cls, id, data, links) {
105
- if (cls.abstract)
106
- throw new Error(`letopis: class "${cls.id}" is abstract`);
107
- const merged = { ...data, id };
108
- const res = cls.check(merged);
109
- if (res !== true)
110
- throw new ValidationError(cls.id, res);
111
- delete merged.id;
112
- // START_BLOCK_VALIDATE_LINK_ENDS
113
- if (cls.category === 'LINK') {
114
- const present = new Set(Object.keys(links));
115
- if (cls.strictEnds) {
116
- // схема v2: жадный матчинг по порядку объявления концов; союз занимает один ключ;
117
- // обязательный конец без ключа и ключ вне концов — ошибки
118
- for (const end of cls.links) {
119
- const hit = end.classes.find((c) => present.has(c));
120
- if (hit)
121
- present.delete(hit);
122
- else if (!end.optional)
123
- throw new Error(`letopis: link "${cls.id}" requires end "${end.classes.join('|')}"`);
37
+ export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === true;
38
+ const fail = (code, msg, detail) => {
39
+ throw new LetopisError(code, msg, detail);
40
+ };
41
+ const asList = (v) => (Array.isArray(v) ? v : [v]);
42
+ const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date) && !isInc(v) && !isOp(v);
43
+ /** Патч update → остаток для merge и список приращений. Недопустимое — invalid_data до отправки. */
44
+ export function splitPatch(patch) {
45
+ const incs = [];
46
+ const noMarkers = (v, path) => {
47
+ if (isInc(v))
48
+ fail('invalid_data', `inc() внутри массива (${path.join('.')}) не поддерживается`);
49
+ if (isOp(v))
50
+ fail('invalid_data', `оператор фильтра в данных (${path.join('.')}) — данные записываются значениями`);
51
+ if (Array.isArray(v))
52
+ v.forEach((x, i) => noMarkers(x, [...path, String(i)]));
53
+ else if (isPlain(v))
54
+ for (const [k, x] of Object.entries(v))
55
+ noMarkers(x, [...path, k]);
56
+ };
57
+ const walk = (obj, path) => {
58
+ const out = {};
59
+ for (const [k, v] of Object.entries(obj)) {
60
+ if (v === undefined)
61
+ continue;
62
+ const p = [...path, k];
63
+ if (isInc(v)) {
64
+ if (!Number.isFinite(v.by))
65
+ fail('invalid_data', `inc(${v.by}) по пути ${p.join('.')}: величина — конечное число`);
66
+ if (v.start !== undefined && !Number.isFinite(v.start))
67
+ fail('invalid_data', `inc: start по пути ${p.join('.')} — конечное число`);
68
+ incs.push({ path: p, by: v.by, ...(v.start !== undefined ? { start: v.start } : {}) });
124
69
  }
125
- if (present.size) {
126
- throw new Error(`letopis: link "${cls.id}" has stray link(s) ${[...present].map((k) => `"${k}"`).join(', ')} — not an end in Schema.links`);
70
+ else if (isOp(v)) {
71
+ fail('invalid_data', `оператор фильтра в патче (${p.join('.')}) — update записывает значения`);
127
72
  }
128
- }
129
- else {
130
- // legacy-схема (строковые концы): 'Entity' — полиморф-счётчик, лишние связи молчат
131
- let polymorphic = 0;
132
- for (const end of cls.links) {
133
- const name = end.classes[0];
134
- if (name === 'Entity')
135
- polymorphic++;
136
- else if (present.has(name))
137
- present.delete(name);
138
- else
139
- throw new Error(`letopis: link "${cls.id}" requires end "${name}"`);
73
+ else if (Array.isArray(v)) {
74
+ noMarkers(v, p);
75
+ out[k] = v;
140
76
  }
141
- if (present.size < polymorphic) {
142
- throw new Error(`letopis: link "${cls.id}" requires ${polymorphic} polymorphic end(s) (any class), got ${present.size}`);
77
+ else if (isPlain(v)) {
78
+ const before = incs.length;
79
+ const sub = walk(v, p);
80
+ // объект, опустевший после изъятия маркеров, не подставляется вместо значения на пути
81
+ if (Object.keys(sub).length || incs.length === before)
82
+ out[k] = sub;
83
+ }
84
+ else {
85
+ out[k] = v;
143
86
  }
144
87
  }
145
- }
146
- // END_BLOCK_VALIDATE_LINK_ENDS
147
- return merged;
88
+ return out;
89
+ };
90
+ return { rest: walk(patch, []), incs };
148
91
  }
149
- // START_CONTRACT: insertOne
150
- // PURPOSE: INSERT одной строки-версии с ретраем гонок (23505 коллизия updated, 40P01/40001 transient) вне транзакции.
151
- // INPUTS: { ctx: Ctx; a: InsertArgs }
152
- // OUTPUTS: { Promise<Row> - вставленная строка }
153
- // SIDE_EFFECTS: INSERT в Entity через runQuery
154
- // LINKS: M-WRITE, V-M-WRITE, M-SQL, M-DDL
155
- // END_CONTRACT: insertOne
156
- /** INSERT одной строки с ретраем гонок: 23505 (коллизия updated) и transient (40P01/40001). */
157
- async function insertOne(ctx, a) {
158
- // jsonb-параметры — JS-объектами (postgres.js сериализует сам)
159
- const params = [
160
- ctx.partition, a.id, a.cls.id, a.data, a.links, a.tags,
161
- a.account, a.owner, a.prevUpdated, a.deleted,
162
- ];
163
- // START_BLOCK_INSERT_ONE_RETRY
164
- for (let attempt = 1;; attempt++) {
165
- try {
166
- const res = await runQuery(ctx, insertSql(ctx.pgSchema), params, 'insert', [a.cls.id]);
167
- return toRow(res[0]);
92
+ // ---------------------------------------------------------------------------
93
+ // Проверки до отправки (precheckPlan)
94
+ // ---------------------------------------------------------------------------
95
+ /** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript (full — объект целиком). */
96
+ function checkFullData(cls, data, verb, full = true) {
97
+ if (!isPlain(data))
98
+ fail('invalid_data', `${verb}(): данные — объект`);
99
+ const scan = (v, path) => {
100
+ if (isInc(v))
101
+ fail('invalid_data', `inc() работает только в update(): ${verb}() по пути ${path.join('.')}`);
102
+ if (isOp(v))
103
+ fail('invalid_data', `оператор фильтра в данных ${verb}() (${path.join('.')}) — данные записываются значениями`);
104
+ if (Array.isArray(v))
105
+ v.forEach((x, i) => scan(x, [...path, String(i)]));
106
+ else if (isPlain(v))
107
+ for (const [k, x] of Object.entries(v))
108
+ scan(x, [...path, k]);
109
+ };
110
+ scan(data, []);
111
+ // описания системных классов проверяет только база (метасхема Class, class_admit)
112
+ if (!full || cls.name === 'Class' || cls.abstract)
113
+ return;
114
+ const issues = cls.validator()(applyDefaults(cls.schema, data));
115
+ if (issues.length)
116
+ throw new ValidationError(cls.name, issues);
117
+ }
118
+ /** Всё, что проверяется без базы, — до отправки первого оператора (§2.5). */
119
+ export function precheckPlan(reg, steps) {
120
+ let prevOp;
121
+ for (let i = 0; i < steps.length; i++) {
122
+ const s = steps[i];
123
+ const op = s.op;
124
+ if (!op) {
125
+ prevOp = undefined;
126
+ continue;
168
127
  }
169
- catch (e) {
170
- // внутри транзакции повтор невозможен (aborted) — любая ошибка наружу
171
- if (!ctx.inTx && attempt < RETRIES) {
172
- if (e.code === '23505')
173
- continue; // новый clock_timestamp сразу
174
- if (isTransient(e)) {
175
- await retryDelay(attempt);
176
- continue;
128
+ const verb = op.kind;
129
+ const m = s.opMods ?? {};
130
+ if (m.asOf !== undefined)
131
+ fail('invalid_query', `${verb}(): asOf описывает прошлое — записывать в него нельзя`);
132
+ // restore и purge сами ищут удалённые объекты шага
133
+ if (m.withDeleted && verb !== 'restore' && verb !== 'purge')
134
+ fail('invalid_query', `${verb}(): withDeleted описывает удалённые — записывать в них нельзя`);
135
+ switch (op.kind) {
136
+ case 'create':
137
+ case 'upsert':
138
+ if (s.self)
139
+ fail('invalid_query', `${verb}() сразу после ${prevOp?.kind}() — между глаголами нужен шаг`);
140
+ if (s.pivotKey !== undefined)
141
+ fail('invalid_query', `${verb}() на возврате к узлу — он ведёт к существующей сущности, используйте update()`);
142
+ if (s.filter !== undefined && typeof s.filter !== 'string') {
143
+ fail('invalid_query', `${verb}() не ищет: ${s.name}(id).${verb}(…) задаёт id, поиск — это update()`);
177
144
  }
145
+ if (op.kind === 'upsert' && !s.cls.key.length)
146
+ fail('no_key', `upsert(): у класса ${s.cls.name} нет ключа — сравнивать не с чем, используйте create()`);
147
+ checkFullData(s.cls, op.data, verb);
148
+ if (s.tagsFilter !== undefined && typeof s.tagsFilter !== 'string' && !Array.isArray(s.tagsFilter)) {
149
+ fail('invalid_query', `.tags() перед ${verb}() — строка или список`);
150
+ }
151
+ break;
152
+ case 'update':
153
+ if (!isPlain(op.data))
154
+ fail('invalid_data', 'update(): патч — объект');
155
+ splitPatch(op.data);
156
+ if (op.rev !== undefined && (!Number.isInteger(op.rev) || op.rev < 1))
157
+ fail('invalid_query', 'update(patch, { rev }): rev — номер версии строки');
158
+ break;
159
+ case 'anonymize': {
160
+ if (!Array.isArray(op.fields) || !op.fields.length)
161
+ fail('invalid_query', 'anonymize(поля): нужен список полей');
162
+ for (const f of op.fields) {
163
+ const t = reg.leafType(s.cls.name, [f]);
164
+ if (t !== 'text')
165
+ fail('invalid_query', `anonymize() затирает только строковые поля; ${f} — ${t}`);
166
+ }
167
+ break;
178
168
  }
179
- throw e;
169
+ case 'reclass':
170
+ reg.resolve(op.cls);
171
+ if (op.data !== undefined)
172
+ checkFullData(reg.resolve(op.cls), op.data, 'reclass');
173
+ break;
174
+ case 'delete':
175
+ case 'restore':
176
+ case 'purge':
177
+ break;
178
+ case 'rekey':
179
+ if (!s.cls.key.length)
180
+ fail('no_key', `rekey(): у класса ${s.cls.name} нет ключа — id выдаёт база, менять нечего`);
181
+ checkFullData(s.cls, op.data, 'rekey', false);
182
+ break;
180
183
  }
181
- }
182
- // END_BLOCK_INSERT_ONE_RETRY
183
- }
184
- /**
185
- * enforceAcl: право операции на класс цели. Предикат победившего правила:
186
- * - фильтрует цели UPDATE/DELETE (step.aclFilter → WHERE при поиске);
187
- * - колоночные ключи owner/account пришпиливают значения INSERT
188
- * (явный чужой модификатор на цепочке → ошибка).
189
- * Мутирует step (объект создаётся цепочкой на вызов — локально безопасно).
190
- */
191
- // START_CONTRACT: aclWrite
192
- // PURPOSE: enforceAcl-проверка права записи/удаления на класс цели; предикат правила пришпиливает owner/account и фильтрует цели.
193
- // INPUTS: { ctx: Ctx; step: Step; op: 'WRITE'|'DELETE' }
194
- // OUTPUTS: { void - мутирует step (aclFilter/ownerFilter/accountFilter) }
195
- // SIDE_EFFECTS: мутирует step
196
- // ERRORS: acl denies WRITE/DELETE; acl pins writes to owner/account
197
- // LINKS: M-WRITE, V-M-WRITE, M-ACL
198
- // END_CONTRACT: aclWrite
199
- function aclWrite(ctx, step, op) {
200
- if (!ctx.aclDecide)
201
- return;
202
- const d = ctx.aclDecide(step.cls, op);
203
- if (!d.allow)
204
- throw aclDenied(op, step.cls.id, d);
205
- if (!d.filter)
206
- return;
207
- step.aclFilter = d.filter;
208
- for (const col of ['owner', 'account']) {
209
- const v = d.filter[col];
210
- if (typeof v !== 'string')
211
- continue;
212
- const key = col === 'owner' ? 'ownerFilter' : 'accountFilter';
213
- if (step[key] && step[key] !== v) {
214
- throw new Error(`letopis: acl pins ${step.cls.id} writes to ${col} ${v}`);
184
+ for (const sl of s.slots ?? []) {
185
+ if (op.kind !== 'create' && op.kind !== 'update' && op.kind !== 'upsert')
186
+ fail('invalid_query', `слот .${sl.role} после ${verb}() — слоты у create(), update() и upsert()`);
215
187
  }
216
- step[key] = v;
188
+ prevOp = op;
217
189
  }
218
190
  }
219
- const noFilter = (f) => f === undefined;
220
- /** Entity.account NOT NULL: модификатор → scope db.as() → System-аккаунт; иначе ошибка. */
221
- function resolveAccount(ctx, step) {
222
- guardScoped(ctx);
223
- if (ctx.enforceAccount && ctx.account && step.accountFilter && step.accountFilter !== ctx.account) {
224
- throw new Error(`letopis: enforceAccount is on — writes are pinned to account ${ctx.account}`);
225
- }
226
- const acc = step.accountFilter ?? ctx.account ?? ctx.systemAccount;
227
- if (!acc) {
228
- throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / db.as(account) or seed a System account (lib/sql/seed.auth.sql)');
229
- }
230
- return acc;
191
+ const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
192
+ /** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
193
+ const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
194
+ + `'links', ${a}.links, 'data', ${a}.data, 'tags', ${a}.tags, 'at', ${a}.at, 'author', ${a}.author, 'agent', ${a}.agent, `
195
+ + `'op', ${a}.op, 'reason', ${a}.reason, 'moved', ${a}.moved)`;
196
+ async function query(w, text, params) {
197
+ return (await w.q.unsafe(text, params, { prepare: true }));
231
198
  }
232
- /** Значение тегов из модификатора .tags(): строка/массив; операторы в записи не годятся. */
233
- function tagsValue(step) {
234
- const t = step.tagsFilter;
235
- if (t === undefined)
236
- return undefined;
237
- if (typeof t === 'string')
238
- return [t];
239
- if (Array.isArray(t))
240
- return t;
241
- throw new Error('letopis: .tags() before set() accepts string | string[]');
199
+ /** Чтение в транзакции плана — тот же построитель, что у цепочек. */
200
+ export async function readIn(w, steps, mods, mode, agg) {
201
+ const q = buildRead(w.reg, w.schema, steps, mods, mode, agg);
202
+ return { q, res: await query(w, q.text, q.params) };
242
203
  }
243
- /** Конец target, принимающий класс cls (legacy: 'Entity'-конец берёт любой HUB). */
244
- function endFor(target, cls) {
245
- return target.links.find((e) => e.classes.includes(cls.id) || (!target.strictEnds && e.classes.includes('Entity') && cls.category === 'HUB'));
204
+ const readRows = async (w, steps, mods) => (await readIn(w, steps, mods, 'rows')).res.map((r) => toRow(r.row));
205
+ const readIds = async (w, steps, mods) => (await readIn(w, steps, mods, 'ids')).res.map((r) => r.id);
206
+ /** Шаг без звена записи — для чтения целей. */
207
+ const bare = (s) => {
208
+ const { op: _o, opMods: _m, slots: _s, self: _f, ...rest } = s;
209
+ return rest;
210
+ };
211
+ /** Роли класса owner, которые принимают класс cls. */
212
+ function rolesAccepting(reg, owner, cls) {
213
+ return Object.entries(owner.ends).filter(([, e]) => accepts(reg, asList(e.class), cls)).map(([r]) => r);
246
214
  }
215
+ /** Роль слота: имя роли записываемого класса или класс, который принимает ровно одна роль. */
216
+ export function slotRole(reg, owner, name) {
217
+ if (owner.ends[name])
218
+ return name;
219
+ const c = reg.find(name);
220
+ if (!c)
221
+ fail('invalid_class', `класс или роль ${name} не найдены`);
222
+ const roles = rolesAccepting(reg, owner, c.name);
223
+ if (roles.length === 1)
224
+ return roles[0];
225
+ if (!roles.length)
226
+ fail('invalid_query', `${name} — не конец класса ${owner.name} (концы: ${Object.keys(owner.ends).join(', ') || '—'})`);
227
+ return fail('invalid_query', `слот ${name} у класса ${owner.name} неоднозначен: роли ${roles.join(', ')} — назовите роль`);
228
+ }
229
+ /** Неоднозначная роль конца из пути. */
230
+ const ambiguous = (step, target, roles) => fail('invalid_query', `конец класса ${target.name} для шага ${step.name} неоднозначен: роли ${roles.join(', ')} — назовите шаг ролью или задайте слот`);
247
231
  /**
248
- * Связи создаваемой сущности из ПУТИ: каждый реальный шаг-предок, чей класс — конец
249
- * target, обязан дать ровно одну сущность (id-фильтр — как есть; иначе чтение путём
250
- * от начала до него: контекст честно учитывается).
232
+ * Роль из нескольких кандидатов: роли, заданные слотами, не участвуют; из оставшихся — роль с именем
233
+ * класса шага (duty.Member для шага Member, а Backup — слотом). Иначе — неоднозначно.
251
234
  */
252
- // START_CONTRACT: resolvePathEnds
253
- // PURPOSE: Связи создаваемой сущности из пути: каждый шаг-предок, чей класс — конец target, должен дать ровно одну сущность.
254
- // INPUTS: { ctx: Ctx; steps: Step[] }
255
- // OUTPUTS: { Promise<Record<string,string>> - класс конца → id }
256
- // SIDE_EFFECTS: чтения контекста (readRows)
257
- // ERRORS: context step must resolve to exactly one entity
258
- // LINKS: M-WRITE, V-M-WRITE
259
- // END_CONTRACT: resolvePathEnds
260
- async function resolvePathEnds(ctx, steps) {
261
- const target = steps[steps.length - 1].cls;
235
+ function pickRole(step, target, roles, taken, cls = step.cls.name) {
236
+ const free = roles.filter((r) => !taken.has(r));
237
+ if (free.length <= 1)
238
+ return free;
239
+ const named = free.filter((r) => r === cls || r === step.name);
240
+ return named.length === 1 ? named : ambiguous(step, target, free);
241
+ }
242
+ /**
243
+ * Концы создаваемой строки из пути (resolvePathEnds 0.21, этап 4, п. 4): каждый шаг до глагола,
244
+ * на класс которого ссылается конец создаваемого класса, даёт ровно одну сущность. Шаг-роль ведёт
245
+ * своей ролью; шаг, на который создаваемая строка не ссылается (переход вперёд), пропускается;
246
+ * шаг по базовому классу выбирает роль по классу найденной строки.
247
+ */
248
+ async function pathEnds(w, steps, taken) {
249
+ const target = steps[steps.length - 1];
250
+ const tcls = target.cls;
262
251
  const links = {};
263
252
  for (let k = 0; k < steps.length - 1; k++) {
264
- const step = steps[k];
265
- if (step.pivotKey !== undefined || step.op)
253
+ const s = steps[k];
254
+ if (s.pivotKey !== undefined || s.op)
266
255
  continue;
267
- if (!endFor(target, step.cls))
268
- continue;
269
- let id;
270
- if (typeof step.filter === 'string') {
271
- id = step.filter;
256
+ let roles;
257
+ if (s.role && tcls.ends[s.role]) {
258
+ roles = [s.role];
272
259
  }
273
260
  else {
274
- const rows = await readRows(ctx, steps.slice(0, k + 1), { limit: 2 });
275
- if (rows.length !== 1) {
276
- throw new Error(`letopis: context step "${step.name}" must resolve to exactly one entity (got ${rows.length})`);
261
+ if (k === steps.length - 2 && !target.role) {
262
+ const hop = resolveHop(w.reg, s, target);
263
+ if (hop.dir === 'forward')
264
+ continue; // предыдущий шаг ссылается на создаваемую — не наоборот
265
+ }
266
+ else if (k === steps.length - 2) {
267
+ continue; // создаётся цель роли предыдущего шага: ссылку ставит не она
268
+ }
269
+ roles = pickRole(s, tcls, rolesAccepting(w.reg, tcls, s.cls.name), taken);
270
+ if (!roles.length) {
271
+ const fam = stepClasses(w.reg, s);
272
+ if (!fam.some((c) => rolesAccepting(w.reg, tcls, c).length))
273
+ continue;
274
+ roles = null; // роль решит класс найденной строки
277
275
  }
278
- id = rows[0].id;
279
276
  }
280
- links[step.cls.id] = id;
281
- }
282
- return links;
283
- }
284
- /** Резолв слот-значений: строки как есть; вложенные планы — в той же транзакции, ровно одна сущность. */
285
- // START_CONTRACT: resolveSlots
286
- // PURPOSE: Резолв слот-значений (.Класс.set/.unset): строки как есть, вложенные планы — та же транзакция и ровно одна сущность; союз-конец снимается целиком.
287
- // INPUTS: { ctx: Ctx; target: ClassDef; extra: Step['extraLinks'] }
288
- // OUTPUTS: { Promise<{ vals: Record<string,string>; drops: Set<string> }> }
289
- // SIDE_EFFECTS: может исполнять вложенные планы/чтения
290
- // ERRORS: slot value plan must resolve to exactly one entity; resolved to class "<x>"
291
- // LINKS: M-WRITE, V-M-WRITE
292
- // END_CONTRACT: resolveSlots
293
- async function resolveSlots(ctx, target, extra) {
294
- const vals = {};
295
- const drops = new Set();
296
- for (const [clsId, v] of Object.entries(extra ?? {})) {
297
- // союз-замещение: слот занимает КОНЕЦ целиком — соседние классы союза снимаются
298
- const end = endFor(target, ctx.registry.resolve(clsId));
299
- for (const c of end?.classes ?? [clsId])
300
- drops.add(c);
301
- if (v === null)
302
- continue; // .delete() — только снятие
303
277
  let id;
304
- if (typeof v === 'string') {
305
- id = v;
278
+ if (typeof s.filter === 'string' && roles) {
279
+ id = s.filter;
306
280
  }
307
281
  else {
308
- const rows = v.plan.some((s) => s.op)
309
- ? (await runPlan(ctx, v.plan, v.mods ?? {}, 'rows'))
310
- : await readRows(ctx, v.plan, { ...(v.mods ?? {}), limit: 2 });
311
- if (rows.length !== 1) {
312
- throw new Error(`letopis: slot "${clsId}" value plan must resolve to exactly one entity (got ${rows.length})`);
313
- }
314
- if (rows[0].class !== clsId) {
315
- throw new Error(`letopis: slot "${clsId}" value plan resolved to class "${rows[0].class}"`);
316
- }
282
+ const rows = await readRows(w, steps.slice(0, k + 1).map(bare), { limit: 2 });
283
+ if (rows.length !== 1)
284
+ fail('invalid_query', `шаг ${s.name} должен дать ровно одну сущность для конца ${tcls.name} (найдено ${rows.length})`);
317
285
  id = rows[0].id;
286
+ if (!roles) {
287
+ roles = pickRole(s, tcls, rolesAccepting(w.reg, tcls, rows[0].class), taken, rows[0].class);
288
+ if (!roles.length)
289
+ continue;
290
+ }
291
+ }
292
+ const role = roles[0];
293
+ if (tcls.ends[role]?.many) {
294
+ const cur = asList(links[role] ?? []);
295
+ if (!cur.includes(id))
296
+ links[role] = [...cur, id];
297
+ }
298
+ else {
299
+ links[role] = id;
318
300
  }
319
- vals[clsId] = id;
320
301
  }
321
- return { vals, drops };
302
+ return links;
322
303
  }
323
- /** id новой сущности по IdGen класса (v5 считается отдельно — нужны концы/данные). */
324
- const genId = (cls) => (cls.idGen.version === 7 ? uuidv7() : randomUUID());
325
- /**
326
- * default поля из правила Schema.attributes: объект-правило `{default: …}` или
327
- * DSL-строка `'string|default:базовая'`. Нужен v5Id: id считается ДО валидации (та
328
- * подставляет defaults позже), поэтому необязательное from-поле берёт дефолт здесь.
329
- */
330
- function defaultOf(rule) {
331
- if (rule && typeof rule === 'object' && !Array.isArray(rule) && 'default' in rule) {
332
- return rule.default;
333
- }
334
- if (typeof rule === 'string') {
335
- const seg = rule.split('|').map((s) => s.trim()).find((s) => s.startsWith('default:'));
336
- if (seg)
337
- return seg.slice('default:'.length);
304
+ /** Значения слотов: строки как есть, цепочки — в той же транзакции. */
305
+ async function slotIds(w, v) {
306
+ if (v === null)
307
+ return [];
308
+ if (typeof v === 'string')
309
+ return [v];
310
+ if (Array.isArray(v))
311
+ return v;
312
+ if ('plan' in v) {
313
+ const rows = v.plan.some((s) => s.op)
314
+ ? (await execPlan(w, v.plan, v.mods, 'rows')).rows
315
+ : await readRows(w, v.plan, v.mods);
316
+ return rows.map((r) => r.id);
338
317
  }
339
- return undefined;
318
+ return [v.id];
340
319
  }
341
- /**
342
- * Детерминированный id (uuid v5): имя = «схема:партиция:класс:значения from».
343
- * from-источник — конец Schema.links (класс или полное имя союза 'Service|Complex';
344
- * значение — id присутствующего класса конца) или скалярное поле data (если не задано —
345
- * его default из Schema.attributes: id стабилен и без явного ввода необязательного поля).
346
- */
347
- // START_CONTRACT: v5Id
348
- // PURPOSE: Детерминированный id (uuid v5) из «схема:партиция:класс:значения from» (концы Schema.links или скалярные поля/дефолты).
349
- // INPUTS: { ctx: Ctx; cls: ClassDef; links: object; data: object }
350
- // OUTPUTS: { string - uuid v5 }
351
- // SIDE_EFFECTS: none
352
- // ERRORS: needs end "<...>"; needs scalar data field "<f>"
353
- // LINKS: M-WRITE, V-M-WRITE, M-UUID
354
- // END_CONTRACT: v5Id
355
- function v5Id(ctx, cls, links, data) {
356
- const parts = [];
357
- for (const f of cls.idGen.from) {
358
- const end = cls.links.find((e) => e.classes.includes(f) || e.classes.join('|') === f);
359
- if (end) {
360
- const hit = end.classes.find((c) => c in links);
361
- if (!hit) {
362
- throw new Error(`letopis: id (uuid v5) of "${cls.id}" needs end "${end.classes.join('|')}" — set it in the path or with a slot`);
363
- }
364
- parts.push(links[hit]);
320
+ /** Слоты → операции links_patch; для новой строки — применяются к концам из пути. */
321
+ async function resolveSlots(w, cls, slots) {
322
+ const out = [];
323
+ for (const sl of slots ?? []) {
324
+ const end = cls.ends[sl.role];
325
+ const ids = sl.op === 'unset' ? [] : await slotIds(w, sl.value);
326
+ if (sl.op === 'set' && sl.value !== null && !end.many && ids.length !== 1) {
327
+ fail('invalid_query', `слот .${sl.role}.set(): конец одиночный, а значение дало ${ids.length} сущностей`);
365
328
  }
329
+ out.push({ role: sl.role, op: sl.op, value: sl.op === 'unset' || sl.value === null ? null : end.many ? ids : ids[0] });
330
+ }
331
+ return out;
332
+ }
333
+ function applySlotsLocal(links, ops) {
334
+ const out = { ...links };
335
+ for (const o of ops) {
336
+ if (o.op === 'unset' || (o.op === 'set' && o.value === null))
337
+ delete out[o.role];
338
+ else if (o.op === 'set')
339
+ out[o.role] = o.value;
366
340
  else {
367
- const v = data[f] === undefined ? defaultOf(cls.attributes[f]) : data[f];
368
- if (v === undefined || v === null || typeof v === 'object') {
369
- throw new Error(`letopis: id (uuid v5) of "${cls.id}" needs scalar data field "${f}"`);
370
- }
371
- parts.push(String(v));
341
+ const cur = asList(out[o.role] ?? []).map((x) => x.toLowerCase());
342
+ const vals = asList(o.value ?? []).map((x) => x.toLowerCase());
343
+ out[o.role] = o.op === 'add' ? [...cur, ...vals.filter((x) => !cur.includes(x))] : cur.filter((x) => !vals.includes(x));
372
344
  }
373
345
  }
374
- return uuidv5(`${ctx.pgSchema}:${ctx.partition}:${cls.id}:${parts.join(':')}`);
346
+ return out;
375
347
  }
376
- /** Новая версия каждой строки: deep-merge данных + слоты концов (союз-конец замещается целиком). */
377
- async function versionRows(ctx, target, found, data, slots) {
378
- const cls = target.cls;
379
- const out = [];
380
- for (const row of found) {
381
- const mergedData = deepMerge(row.data, data); // только указанные листья
382
- const mergedLinks = { ...row.links };
383
- for (const c of slots.drops)
384
- delete mergedLinks[c];
385
- Object.assign(mergedLinks, slots.vals);
386
- const full = validate(cls, row.id, mergedData, mergedLinks);
387
- out.push(await insertOne(ctx, {
388
- id: row.id, cls, data: full, links: mergedLinks,
389
- tags: tagsValue(target) ?? row.tags, account: row.account, owner: row.owner,
390
- prevUpdated: row.updated, deleted: null,
391
- }));
348
+ const tagsValue = (s) => s.tagsFilter === undefined ? null : typeof s.tagsFilter === 'string' ? [s.tagsFilter] : s.tagsFilter;
349
+ /** «Уже существует»: id вычисляет библиотека (idOf), а не разбирает текст ошибки сервера (§2.5). */
350
+ function existsError(w, cls, data, links, given, cause) {
351
+ let id = given;
352
+ if (!id && cls.key.length && w.tenant) {
353
+ try {
354
+ id = idOf({ name: cls.name, key: cls.key, ends: Object.keys(cls.ends) }, { ...data, ...links }, { tenant: w.tenant });
355
+ }
356
+ catch {
357
+ id = undefined;
358
+ }
359
+ }
360
+ return Object.assign(new LetopisError('exists', `объект класса ${cls.name} уже существует${id ? ` (id ${id})` : ''}`, { id: id ?? null }), { cause });
361
+ }
362
+ const isPkConflict = (e) => {
363
+ const err = e;
364
+ return err.code === '23505' && err.constraint_name === 'entity_pkey';
365
+ };
366
+ async function createOp(w, steps, opStep, upsert) {
367
+ const op = opStep.op;
368
+ const cls = opStep.cls;
369
+ const S = quoteId(w.schema);
370
+ const slots = await resolveSlots(w, cls, opStep.slots);
371
+ const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
372
+ const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
373
+ const owner = opStep.ownerFilter ?? w.owner ?? null;
374
+ const conflict = upsert
375
+ ? ` on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags`
376
+ : '';
377
+ const text = `insert into ${S}.entity (id, class, links, data, tags, owner) values ($1::uuid, $2::text, $3::jsonb, $4::jsonb, $5::text[], $6::uuid)`
378
+ + `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
379
+ try {
380
+ const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner]);
381
+ return res.map((r) => {
382
+ const row = toRow(r.row);
383
+ if (upsert)
384
+ row.$upsert = r.old_rev === null ? 'created' : r.old_rev === row.rev ? 'unchanged' : 'updated';
385
+ return row;
386
+ });
387
+ }
388
+ catch (e) {
389
+ if (!upsert && isPkConflict(e))
390
+ throw existsError(w, cls, op.data, links, given, e);
391
+ throw e;
392
392
  }
393
- return out;
394
393
  }
395
394
  /**
396
- * .create(data) — «чтобы сущность существовала»:
397
- * - id не задан → INSERT (id по IdGen класса; links = концы из пути + слоты);
398
- * - id известен (Класс(id) / data.id / вычислен v5): есть → новая версия (deep-merge),
399
- * нет → INSERT с этим id (идемпотентный create, REST-PUT семантика);
400
- * - фильтр-объект / pivot — ошибка: create не ищет, это update().
395
+ * Меньше затронутых строк, чем нашёл шаг (§2.5): невидимые — target_not_found; видимые с другим
396
+ * номером версии в строгом режиме — conflict; остальные видимые — их не дала изменить RLS (acl_denied).
401
397
  */
402
- // START_CONTRACT: createOp
403
- // PURPOSE: .create(data) — INSERT новой сущности либо новая версия при известном/вычисленном id (идемпотентный create); поиск — это update().
404
- // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; data: object }
405
- // OUTPUTS: { Promise<Row[]> }
406
- // SIDE_EFFECTS: INSERT в Entity (в транзакции runPlan)
407
- // ERRORS: create() on a pivot; takes no filter; got two ids; computes id (remove explicit); acl denies WRITE
408
- // LINKS: M-WRITE, V-M-WRITE, M-ACL
409
- // END_CONTRACT: createOp
410
- export async function createOp(ctx, steps, mods, data) {
411
- // START_BLOCK_CREATE_RESOLVE
412
- const target = steps[steps.length - 1];
413
- const cls = target.cls;
414
- aclWrite(ctx, target, 'WRITE');
415
- if (target.pivotKey !== undefined) {
416
- throw new Error(`letopis: create() on a pivot step — pivot returns to an existing node, use update()`);
398
+ async function explainMissing(w, missing, rev) {
399
+ const S = quoteId(w.schema);
400
+ const seen = await query(w, `select id, rev from ${S}.entity where id = any ($1::uuid[])`, [missing]);
401
+ if (!seen.length)
402
+ return fail('target_not_found', missing.length === 1 ? `цель ${missing[0]} не найдена` : `цели не найдены: ${missing.join(', ')}`, { ids: missing });
403
+ if (rev !== undefined && seen.some((r) => r.rev !== rev)) {
404
+ return fail('conflict', `строку ${seen[0].id} успели изменить: версия ${seen[0].rev}, а не ${rev} — перечитайте и повторите`, { id: seen[0].id, rev: seen[0].rev, expected: rev });
417
405
  }
418
- const filterId = typeof target.filter === 'string' ? target.filter : undefined;
419
- if (!noFilter(target.filter) && filterId === undefined) {
420
- throw new Error(`letopis: create() takes no filter — ${cls.alias}(id).create(…) fixes the id, searching is update()`);
406
+ return fail('acl_denied', `строки видны, но изменить их нельзя: ${seen.map((r) => r.id).join(', ')}`, { ids: seen.map((r) => r.id) });
407
+ }
408
+ /** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
409
+ async function targetIds(w, steps, opStep, dataId) {
410
+ const path = steps.map(bare);
411
+ if (opStep.filter === undefined && dataId !== undefined)
412
+ path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
413
+ return readIds(w, path, opStep.opMods ?? {});
414
+ }
415
+ async function updateOp(w, steps, opStep) {
416
+ const op = opStep.op;
417
+ const S = quoteId(w.schema);
418
+ // data.id — цель, только если у шага нет своего фильтра (write.ts:461-466 в 0.21); тогда id не данные
419
+ const useDataId = opStep.filter === undefined && typeof op.data.id === 'string';
420
+ const { id: dataId, ...data } = op.data;
421
+ const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
422
+ if (op.rev !== undefined) {
423
+ if (ids.length > 1)
424
+ fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — строгий режим только для одной`);
425
+ if (!ids.length)
426
+ fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
421
427
  }
422
- // слоты (.Класс.set/.unset): значения/снятия концов; союз-конец замещается целиком
423
- const slots = await resolveSlots(ctx, cls, target.extraLinks);
424
- const dataId = typeof data.id === 'string' ? data.id : undefined;
425
- if (filterId && dataId && filterId !== dataId) {
426
- throw new Error(`letopis: create() got two ids — step "${filterId}" vs data.id "${dataId}"`);
428
+ if (!ids.length)
429
+ return [];
430
+ const { rest, incs } = splitPatch(useDataId ? data : op.data);
431
+ const params = [ids];
432
+ const ph = (v, cast) => `$${params.push(v)}::${cast}`;
433
+ let expr = `${S}.merge(e.data, ${ph(rest, 'jsonb')})`;
434
+ for (const i of incs)
435
+ expr = `${S}.inc(${expr}, ${ph(i.path, 'text[]')}, ${ph(i.by, 'numeric')}, ${ph(i.start ?? null, 'numeric')})`;
436
+ const sets = [`data = ${expr}`];
437
+ const slots = await resolveSlots(w, opStep.cls, opStep.slots);
438
+ if (slots.length)
439
+ sets.push(`links = ${S}.links_patch(e.links, ${ph(slots, 'jsonb')})`);
440
+ const tags = tagsValue(opStep);
441
+ if (tags)
442
+ sets.push(`tags = ${ph(tags, 'text[]')}`);
443
+ const revCond = op.rev !== undefined ? ` and e.rev = ${ph(op.rev, 'int')}` : '';
444
+ const res = await query(w, `update ${S}.entity e set ${sets.join(', ')} where e.id = any ($1::uuid[])${revCond} returning ${rowJson('new')} as row`, params);
445
+ if (res.length < ids.length) {
446
+ const got = new Set(res.map((r) => r.row.id));
447
+ await explainMissing(w, ids.filter((x) => !got.has(x)), op.rev);
427
448
  }
428
- let explicitId = filterId ?? dataId;
429
- // связи создаваемого: концы из пути + слоты (слоты выигрывают, unset снимает)
430
- const pathEnds = await resolvePathEnds(ctx, steps);
431
- const writeLinks = { ...pathEnds };
432
- for (const c of slots.drops)
433
- delete writeLinks[c];
434
- Object.assign(writeLinks, slots.vals);
435
- if (cls.idGen.version === 5) {
436
- if (explicitId) {
437
- throw new Error(`letopis: class "${cls.id}" computes id (uuid v5 from ${cls.idGen.from.join(', ')}) — remove the explicit id`);
438
- }
439
- explicitId = v5Id(ctx, cls, writeLinks, data);
449
+ return res.map((r) => toRow(r.row));
450
+ }
451
+ async function deleteOp(w, steps, opStep) {
452
+ const op = opStep.op;
453
+ const S = quoteId(w.schema);
454
+ const ids = await targetIds(w, steps, opStep);
455
+ if (!ids.length)
456
+ return [];
457
+ if (!op.confirm) {
458
+ // превью: то же замыкание, что у триггера удаления (ref_action), база не меняется
459
+ const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
460
+ + `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
461
+ return res.map((r) => {
462
+ const row = toRow(r.row);
463
+ row.$action = r.action;
464
+ if (r.depth !== null)
465
+ row.$depth = r.depth;
466
+ return row;
467
+ });
440
468
  }
441
- if (explicitId !== undefined) {
442
- // id известен и существует (в границах пути) → новая версия
443
- const pathSteps = [...steps.slice(0, -1), { ...target, filter: explicitId }];
444
- const found = await readRows(ctx, pathSteps, mods);
445
- if (found.length)
446
- return versionRows(ctx, target, found, data, slots);
447
- // enforceAcl-предикат: «не нашёл среди доступных» НЕ значит «можно создать» —
448
- // существующая, но недоступная сущность иначе перехватывалась бы новой версией
449
- // с чужим owner. Проверка существования — БЕЗ предиката и пришпиленных колонок.
450
- if (target.aclFilter) {
451
- const bare = { ...ctx, aclDecide: undefined };
452
- const probe = {
453
- ...target,
454
- filter: explicitId,
455
- aclFilter: undefined,
456
- ownerFilter: undefined,
457
- accountFilter: undefined,
458
- linksFilter: undefined,
459
- };
460
- const taken = await readRows(bare, [probe], {});
461
- if (taken.length)
462
- return [];
463
- }
469
+ // результат — надгробия этой транзакции после метки: delete … returning не видит каскад триггера
470
+ const [{ mark }] = await query(w, `select coalesce(max(seq), 0)::text as mark from ${S}.log where tx = pg_current_xact_id()`, []);
471
+ const gone = await query(w, `delete from ${S}.entity e where e.id = any ($1::uuid[]) returning ${rowJson('e')} as row`, [ids]);
472
+ if (gone.length < ids.length) {
473
+ const got = new Set(gone.map((r) => r.row.id));
474
+ await explainMissing(w, ids.filter((x) => !got.has(x)));
475
+ }
476
+ const tombs = await query(w, `select ${rowJson('l')} as row from ${S}.log l where l.tx = pg_current_xact_id() and l.seq > $1::bigint and l.op = 'delete' order by l.seq`, [mark]);
477
+ const out = tombs.map((r) => toRow(r.row));
478
+ // строки классов без журнала (history: false) надгробий не оставляют — берём их из returning
479
+ const have = new Set(out.map((r) => r.id));
480
+ for (const g of gone) {
481
+ if (have.has(g.row.id))
482
+ continue;
483
+ const row = toRow(g.row);
484
+ row.$deleted = true;
485
+ out.push(row);
464
486
  }
465
- const id = explicitId ?? genId(cls);
466
- const account = resolveAccount(ctx, target);
467
- const owner = target.ownerFilter ?? ctx.owner ?? account;
468
- const full = validate(cls, id, data, writeLinks);
469
- return [
470
- await insertOne(ctx, {
471
- id, cls, data: full, links: writeLinks,
472
- tags: tagsValue(target) ?? [], account, owner, prevUpdated: null, deleted: null,
473
- }),
474
- ];
475
- // END_BLOCK_CREATE_RESOLVE
487
+ return out;
476
488
  }
477
- /**
478
- * .update(data?) — новая версия КАЖДОГО найденного путём (deep-merge листьев);
479
- * цели: Класс() ≡ Класс({}) — все в границах контекста, id/фильтр/pivot — как в чтении.
480
- * Не найдено → [] — update НИКОГДА не создаёт.
481
- */
482
- // START_CONTRACT: updateOp
483
- // PURPOSE: .update(data?) — новая версия каждого найденного путём (deep-merge листьев); никогда не создаёт.
484
- // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; data: object }
485
- // OUTPUTS: { Promise<Row[]> }
486
- // SIDE_EFFECTS: INSERT новых версий
487
- // LINKS: M-WRITE, V-M-WRITE
488
- // END_CONTRACT: updateOp
489
- export async function updateOp(ctx, steps, mods, data) {
490
- const target = steps[steps.length - 1];
491
- aclWrite(ctx, target, 'WRITE');
492
- const slots = await resolveSlots(ctx, target.cls, target.extraLinks);
493
- // data.id — цель, только если у шага нет своего фильтра
494
- const dataId = typeof data.id === 'string' ? data.id : undefined;
495
- const pathSteps = noFilter(target.filter) && dataId !== undefined
496
- ? [...steps.slice(0, -1), { ...target, filter: dataId }]
497
- : steps;
498
- const found = await readRows(ctx, pathSteps, mods); // честный путь: переходы, pivot
499
- return versionRows(ctx, target, found, data, slots);
489
+ async function anonymizeOp(w, steps, opStep) {
490
+ const op = opStep.op;
491
+ const S = quoteId(w.schema);
492
+ const ids = await targetIds(w, steps, opStep);
493
+ if (!ids.length)
494
+ return [];
495
+ const res = await query(w, `update ${S}.entity e set data = e.data || coalesce((select jsonb_object_agg(f, '[erased]'::text) from unnest($2::text[]) f where e.data ? f), '{}'::jsonb), `
496
+ + `tags = (select array_agg(distinct t order by t) from unnest(e.tags || array['anonymized']) t) `
497
+ + `where e.id = any ($1::uuid[]) returning ${rowJson('new')} as row`, [ids, op.fields]);
498
+ if (res.length < ids.length) {
499
+ const got = new Set(res.map((r) => r.row.id));
500
+ await explainMissing(w, ids.filter((x) => !got.has(x)));
501
+ }
502
+ return res.map((r) => toRow(r.row));
503
+ }
504
+ async function reclassOp(w, steps, opStep) {
505
+ const op = opStep.op;
506
+ const S = quoteId(w.schema);
507
+ const ids = await targetIds(w, steps, opStep);
508
+ if (!ids.length)
509
+ return [];
510
+ const cls = w.reg.resolve(op.cls).name;
511
+ // у класса без ключа id сохраняется, у класса с ключом — новый id и перевод ссылок (reclass() в базе)
512
+ const moved = await query(w, `select ${S}.reclass(x, $2::text, $3::jsonb) as id from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids, cls, op.data ?? null]);
513
+ const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [moved.map((m) => m.id)]);
514
+ return res.map((r) => toRow(r.row));
515
+ }
516
+ /** Удалённые объекты шага (restore, purge): живые цели без удалённых — not_deleted. */
517
+ async function deletedTargets(w, steps, opStep, verb) {
518
+ const rows = await readRows(w, steps.map(bare), { ...(opStep.opMods ?? {}), withDeleted: true });
519
+ const dead = rows.filter((r) => r.$deleted);
520
+ if (!dead.length && rows.length)
521
+ fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
522
+ return dead;
523
+ }
524
+ /** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
525
+ async function restoreOp(w, steps, opStep) {
526
+ const S = quoteId(w.schema);
527
+ const dead = await deletedTargets(w, steps, opStep, 'restore');
528
+ if (!dead.length)
529
+ return [];
530
+ const ids = dead.map((r) => r.id);
531
+ await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
532
+ const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [ids]);
533
+ return res.map((r) => toRow(r.row));
500
534
  }
501
535
  /**
502
- * .anonymize(fields): GDPR-затирание — новая версия с '[erased]' в указанных string-полях
503
- * + тег 'anonymized'. Только string-поля (по Schema); история сохраняется (см. README).
536
+ * Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
537
+ * версий $versions), с ним — версии стёрты, на месте версии 1 — запись purge ($purged).
504
538
  */
505
- export async function anonymizeOp(ctx, steps, mods, fields) {
506
- const target = steps[steps.length - 1];
507
- const cls = target.cls;
508
- aclWrite(ctx, target, 'WRITE');
509
- for (const f of fields) {
510
- if (cls.fieldTypes.get(f)?.kind !== 'string') {
511
- throw new Error(`letopis: anonymize() erases string fields only; "${f}" is ${cls.fieldTypes.get(f)?.kind ?? 'unknown'}`);
512
- }
513
- }
514
- const found = await readRows(ctx, steps, mods); // цели — честный путь
539
+ async function purgeOp(w, steps, opStep) {
540
+ const op = opStep.op;
541
+ const S = quoteId(w.schema);
542
+ const dead = await deletedTargets(w, steps, opStep, 'purge');
515
543
  const out = [];
516
- for (const row of found) {
517
- const patch = Object.fromEntries(fields.filter((f) => f in row.data).map((f) => [f, '[erased]']));
518
- const mergedData = deepMerge(row.data, patch);
519
- const full = validate(cls, row.id, mergedData, row.links);
520
- out.push(await insertOne(ctx, {
521
- id: row.id, cls, data: full, links: row.links,
522
- tags: [...new Set([...row.tags, 'anonymized'])],
523
- account: row.account, owner: row.owner, prevUpdated: row.updated, deleted: null,
524
- }));
544
+ for (const row of dead) {
545
+ const [{ r }] = await query(w, `select ${S}.purge($1::uuid, $2::boolean) as r`, [row.id, op.confirm]);
546
+ out.push({ ...row, $versions: r.versions, ...(r.purged ? { $purged: true } : {}) });
525
547
  }
526
548
  return out;
527
549
  }
528
- /** Транзакционная обёртка: в tr исполняем как есть, вне — sql.begin с ретраем transient
529
- * (deadlock/serialization откатывает ВСЮ внутреннюю транзакцию — повтор честен). */
530
- async function inTransaction(ctx, fn) {
531
- if (ctx.inTx)
532
- return fn(ctx);
533
- // START_BLOCK_IN_TRANSACTION_RETRY
534
- for (let attempt = 1;; attempt++) {
550
+ /** Новое значение ключа (этап 6, п. 7): строка с новым id, ссылки переведены, журнал связывает id. */
551
+ async function rekeyOp(w, steps, opStep) {
552
+ const op = opStep.op;
553
+ const S = quoteId(w.schema);
554
+ const ids = await targetIds(w, steps, opStep);
555
+ if (!ids.length)
556
+ return [];
557
+ // поля — в данные, роли концов — в концы
558
+ const data = {};
559
+ const links = {};
560
+ for (const [k, v] of Object.entries(op.data)) {
561
+ if (opStep.cls.ends[k])
562
+ links[k] = typeof v === 'object' && v !== null && !Array.isArray(v) ? v.id : v;
563
+ else
564
+ data[k] = v;
565
+ }
566
+ const moved = [];
567
+ for (const id of ids) {
535
568
  try {
536
- return (await ctx.sql.begin(async (tsql) => fn({ ...ctx, sql: tsql, inTx: true })));
569
+ const [{ id: nid }] = await query(w, `select ${S}.rekey($1::uuid, $2::jsonb, $3::jsonb) as id`, [id, data, links]);
570
+ moved.push(nid);
537
571
  }
538
572
  catch (e) {
539
- if (isTransient(e) && attempt < RETRIES) {
540
- await retryDelay(attempt);
541
- continue;
542
- }
573
+ if (isPkConflict(e))
574
+ throw Object.assign(new LetopisError('exists', `rekey(): объект класса ${opStep.cls.name} с таким ключом уже существует`, { id }), { cause: e });
543
575
  throw e;
544
576
  }
545
577
  }
546
- // END_BLOCK_IN_TRANSACTION_RETRY
547
- }
548
- /**
549
- * .delete({confirm}): цели = фильтр последнего шага + контекст-связи.
550
- * confirm: true — серверное удаление (триггер entity_delete: tombstone + рекурсивный
551
- * каскад + advisory-lock); возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
552
- * Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
553
- */
554
- // START_CONTRACT: delOp
555
- // PURPOSE: .delete({confirm}) — серверное удаление (tombstone + рекурсивный каскад) при confirm, иначе превью замыкания; DELETE-право на каждый класс каскада.
556
- // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; confirm: boolean }
557
- // OUTPUTS: { Promise<Row[]> - удалённые (цели+каскад) с $deleted, либо превью }
558
- // SIDE_EFFECTS: DELETE в Entity (триггер entity_delete) при confirm
559
- // ERRORS: acl denies DELETE (в каскаде)
560
- // LINKS: M-WRITE, V-M-WRITE, M-ACL, M-DDL
561
- // END_CONTRACT: delOp
562
- export async function delOp(ctx, steps, mods, confirm) {
563
- const target = steps[steps.length - 1];
564
- aclWrite(ctx, target, 'DELETE');
565
- const targets = await readRows(ctx, steps, mods); // цели — честный путь
566
- if (!targets.length)
567
- return [];
568
- const params = [ctx.partition, target.cls.id, ...targets.map((r) => r.id)];
569
- // START_BLOCK_DELETE_CASCADE
570
- return inTransaction(ctx, async (c) => {
571
- const closure = await runQuery(c, closureSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
572
- if (!confirm)
573
- return closure.map((r) => toRow(r)); // превью: кандидаты живы, БД не тронута
574
- // каскад дотянется до зависимых ЛЮБЫХ классов — на каждый нужно DELETE-право
575
- if (c.aclDecide) {
576
- for (const cn of new Set(closure.map((r) => r.class))) {
577
- const d = c.aclDecide(c.registry.resolve(cn), 'DELETE');
578
- if (!d.allow)
579
- throw aclDenied('DELETE', `${cn} (в каскаде от ${target.cls.id})`, d);
580
- }
581
- }
582
- await runQuery(c, deleteSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
583
- return closure.map((r) => ({ ...toRow(r), $deleted: true }));
584
- });
585
- // END_BLOCK_DELETE_CASCADE
578
+ const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [moved]);
579
+ return res.map((r) => toRow(r.row));
586
580
  }
587
- /**
588
- * .purge({confirm}): ФИЗИЧЕСКИЙ hard-erase — сносит логически удалённые цели + всё поддерево (все версии).
589
- * Вся логика — в серверной purge() (двухфазность: живую цель не трогает; замыкание по links; SET LOCAL
590
- * отключает entity_delete → плоский снос без TM_SelfModified). JS лишь резолвит цели и зовёт функцию.
591
- * confirm: false → dry-превью (что сотрётся, БД цела); true → снос, возвращает снесённое ($deleted).
592
- * gate: DELETE-право на класс цели (aclWrite) — поддерево уже было DELETE-авторизовано при мягком удалении.
593
- * В отличие от .delete() (tombstone, обратимо) — необратимо, историю НЕ сохраняет.
594
- */
595
- // START_CONTRACT: purgeOp
596
- // PURPOSE: .purge({confirm}) — резолв целей (вкл. tombstone) + вызов серверной purge() (снос при confirm, иначе dry-превью).
597
- // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; confirm: boolean }
598
- // OUTPUTS: { Promise<Row[]> - снесённое с $deleted, либо dry-превью; [] если цели не tombstone/не найдены }
599
- // SIDE_EFFECTS: физический DELETE в Entity через purge() (SET LOCAL letopis.purge отключает триггер)
600
- // ERRORS: acl denies DELETE (класс цели)
601
- // LINKS: M-WRITE, V-M-WRITE, M-ACL, M-DDL, M-SQL
602
- // END_CONTRACT: purgeOp
603
- export async function purgeOp(ctx, steps, mods, confirm) {
604
- const target = steps[steps.length - 1];
605
- aclWrite(ctx, target, 'DELETE');
606
- // цели ищем ВКЛЮЧАЯ tombstone — .purge() работает по уже логически удалённым (двухфазность в purge())
607
- const targets = await readRows(ctx, steps, { ...mods, withDeleted: true });
608
- if (!targets.length)
609
- return [];
610
- return inTransaction(ctx, async (c) => {
611
- const out = [];
612
- // вся логика (двухфазность, замыкание, отключение триггера) — в серверной purge(); dry = !confirm
613
- for (const t of targets) {
614
- const rows = await runQuery(c, purgeCallSql(c.pgSchema), [c.partition, target.cls.id, t.id, !confirm], 'delete', [target.cls.id]);
615
- for (const r of rows)
616
- out.push(confirm ? { ...toRow(r), $deleted: true } : toRow(r));
617
- }
618
- return out;
619
- });
581
+ function opCall(w, steps, opStep) {
582
+ switch (opStep.op.kind) {
583
+ case 'create': return createOp(w, steps, opStep, false);
584
+ case 'upsert': return createOp(w, steps, opStep, true);
585
+ case 'update': return updateOp(w, steps, opStep);
586
+ case 'delete': return deleteOp(w, steps, opStep);
587
+ case 'anonymize': return anonymizeOp(w, steps, opStep);
588
+ case 'reclass': return reclassOp(w, steps, opStep);
589
+ case 'restore': return restoreOp(w, steps, opStep);
590
+ case 'purge': return purgeOp(w, steps, opStep);
591
+ case 'rekey': return rekeyOp(w, steps, opStep);
592
+ }
620
593
  }
621
- /** Разрезать план: сегменты (…шаги + op-шаг) и читающий хвост после последней операции. */
622
- function splitPlan(steps) {
594
+ /** Разрезать план: сегменты (…шаги + шаг с глаголом) и читающий хвост после последнего глагола. */
595
+ export function splitPlan(steps) {
623
596
  const segments = [];
624
597
  let buf = [];
625
598
  for (const s of steps) {
626
599
  buf.push(s);
627
600
  if (s.op) {
628
- segments.push({ steps: buf, op: s.op });
601
+ segments.push({ steps: buf });
629
602
  buf = [];
630
603
  }
631
604
  }
632
605
  return { segments, tail: buf };
633
606
  }
634
- function opCall(c, steps, op) {
635
- switch (op.kind) {
636
- case 'create':
637
- return createOp(c, steps, op.mods, op.data ?? {});
638
- case 'update':
639
- return updateOp(c, steps, op.mods, op.data ?? {});
640
- case 'anonymize':
641
- return anonymizeOp(c, steps, op.mods, op.fields ?? []);
642
- case 'delete':
643
- return delOp(c, steps, op.mods, op.confirm === true);
644
- case 'purge':
645
- return purgeOp(c, steps, op.mods, op.confirm === true);
646
- }
647
- }
648
- /** Виртуальный старт-шаг «от этих строк»: контекст/чтение следующего сегмента. */
649
- const virtualStep = (c, row) => ({ name: row.class, cls: c.registry.resolve(row.class), filter: row.id });
650
- const RawRowFmt = {
651
- rows: (res) => res.map((r) => {
652
- const row = toRow(r.row);
653
- return r.row.depth != null ? { ...row, $depth: r.row.depth } : row;
654
- }),
655
- paths: (res) => res.map((r) => Object.fromEntries(Object.entries(r.path).map(([k, v]) => [k, toRow(v)]))),
656
- versions: (res) => res.map((r) => (r.row.deleted ? { ...toRow(r.row), $deleted: true } : toRow(r.row))),
657
- };
607
+ /** Старт следующего сегмента «от этих строк»: шаг с их id (класс и роль — как у шага глагола). */
608
+ const fromRows = (opStep, ids, cls) => ({
609
+ name: opStep.name, cls: cls ?? opStep.cls, role: cls ? undefined : opStep.role, aliasKey: opStep.aliasKey,
610
+ filter: ids, nodeKey: nextNodeKey(),
611
+ });
658
612
  /**
659
- * Исполнить план целиком: все операции + финальное чтение — одна транзакция
660
- * (внутренняя, с ретраем transient; внутри db.begin() — транзакция пользователя).
661
- * Продолжение после операции: fan-out — каждый следующий сегмент исполняется от каждой
662
- * строки результата (контекст = строка); self-шаг (op сразу после op) пишет в те же строки.
663
- * После delete продолжение идёт от строк КЛАССА ЦЕЛИ (замыкание каскада шире).
613
+ * Исполнить план в транзакции w.q: сегменты по порядку, продолжение — от результата предыдущего
614
+ * глагола (create и upsert — по строке, остальные — от всех строк сразу); хвост — чтение от
615
+ * результата последнего глагола тем же построителем, что у цепочек.
664
616
  */
665
- // START_CONTRACT: runPlan
666
- // PURPOSE: Исполнить план целиком (сегменты операций + читающий хвост) одной транзакцией; продолжение = fan-out по строкам результата.
667
- // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; mode: PlanMode }
668
- // OUTPUTS: { Promise<unknown> - результат в запрошенном режиме }
669
- // SIDE_EFFECTS: записи + чтения в одной транзакции
670
- // LINKS: M-WRITE, V-M-WRITE, M-SQL
671
- // END_CONTRACT: runPlan
672
- export async function runPlan(ctx, steps, mods, mode) {
617
+ export async function execPlan(w, steps, mods, mode, agg) {
673
618
  const { segments, tail } = splitPlan(steps);
674
- return inTransaction(ctx, async (c) => {
675
- // START_BLOCK_RUNPLAN_SEGMENTS
676
- let last = []; // полный результат последней операции (для терминала)
677
- let start = []; // строки для продолжения (класс цели)
678
- for (let i = 0; i < segments.length; i++) {
679
- const seg = segments[i];
680
- const targetCls = seg.steps[seg.steps.length - 1].cls;
681
- if (i === 0) {
682
- last = await opCall(c, seg.steps, seg.op);
683
- }
684
- else {
685
- last = [];
686
- for (const row of start) {
687
- const rowSteps = seg.steps[0].self
688
- ? [{ ...seg.steps[0], self: undefined, filter: row.id, op: undefined }]
689
- : [virtualStep(c, row), ...seg.steps.map((s) => ({ ...s, op: undefined }))];
690
- last.push(...await opCall(c, rowSteps, seg.op));
691
- }
692
- }
693
- start = (seg.op.kind === 'delete' || seg.op.kind === 'purge')
694
- ? last.filter((r) => r.class === targetCls.id) : last;
619
+ let last = [];
620
+ let start = [];
621
+ let prevOp;
622
+ for (let i = 0; i < segments.length; i++) {
623
+ const seg = segments[i];
624
+ const opStep = seg.steps[seg.steps.length - 1];
625
+ const kind = opStep.op.kind;
626
+ const perRow = kind === 'create' || kind === 'upsert';
627
+ if (i === 0) {
628
+ last = await opCall(w, seg.steps, opStep);
629
+ }
630
+ else if (!start.length) {
631
+ last = [];
695
632
  }
696
- // END_BLOCK_RUNPLAN_SEGMENTS
697
- const opStep = segments[segments.length - 1].steps.at(-1);
698
- const keyName = opStep.aliasKey ?? opStep.name;
699
- // хвост-чтение от результата последней операции (в той же транзакции)
700
- if (tail.length) {
701
- if (!start.length)
702
- return emptyFor(mode, mods);
703
- const virt = { name: keyName, cls: opStep.cls, filter: start.map((r) => r.id) };
704
- return readByMode(c, [virt, ...tail], mods, mode);
633
+ else if (opStep.self) {
634
+ // те же сущности: шаг глагола с их id вместо фильтра (create и upsert так не продолжаются — precheckPlan)
635
+ const same = { ...opStep, filter: start.map((r) => r.id), pivotKey: undefined, nodeKey: nextNodeKey() };
636
+ last = await opCall(w, [same], same);
705
637
  }
706
- // терминал прямо на операции
707
- switch (mode) {
708
- case 'rows':
709
- return last;
710
- case 'ids':
711
- return last.map((r) => r.id);
712
- case 'count':
713
- return last.length;
714
- case 'paths':
715
- return last.map((r) => ({ [keyName]: r }));
716
- case 'versions':
717
- case 'agg': {
718
- if (!start.length)
719
- return emptyFor(mode, mods);
720
- const virt = { name: keyName, cls: opStep.cls, filter: start.map((r) => r.id) };
721
- return readByMode(c, [virt], mods, mode);
638
+ else if (perRow) {
639
+ last = [];
640
+ for (const r of start) {
641
+ const rowSteps = [fromRows(prevOp, r.id, w.reg.resolve(r.class)), ...seg.steps];
642
+ last.push(...await opCall(w, rowSteps, opStep));
722
643
  }
723
644
  }
724
- });
725
- }
726
- function emptyFor(mode, mods) {
727
- switch (mode) {
728
- case 'rows':
729
- case 'versions':
730
- case 'paths':
731
- case 'ids':
732
- return [];
733
- case 'count':
734
- return 0;
735
- case 'agg':
736
- return mods.aggFn === 'countBy' ? [] : [{ v: null }];
645
+ else {
646
+ last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
647
+ }
648
+ // после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после
649
+ // стирания истории продолжать не от чего
650
+ start = kind === 'delete' && opStep.op.confirm
651
+ ? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
652
+ : kind === 'purge' ? [] : last;
653
+ prevOp = opStep;
654
+ }
655
+ const opStep = segments[segments.length - 1].steps.at(-1);
656
+ const key = opStep.aliasKey ?? opStep.name;
657
+ if (tail.length || mode === 'versions' || mode === 'agg') {
658
+ if (!start.length)
659
+ return { kind: 'read', q: null, res: [] };
660
+ const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
661
+ return { kind: 'read', q, res };
737
662
  }
663
+ return { kind: 'rows', rows: last, key };
738
664
  }
739
- /** Финальное чтение плана: тот же buildRead, что у обычных цепочек. */
740
- async function readByMode(c, steps, mods, mode) {
741
- const classes = steps.map((s) => s.cls.id);
742
- switch (mode) {
743
- case 'rows': {
744
- const q = buildRead(c, steps, mods, 'rows');
745
- return RawRowFmt.rows(await runQuery(c, q.text, q.params, 'rows', classes));
746
- }
747
- case 'paths': {
748
- const q = buildRead(c, steps, mods, 'paths');
749
- return RawRowFmt.paths(await runQuery(c, q.text, q.params, 'paths', classes));
750
- }
751
- case 'versions': {
752
- const q = buildRead(c, steps, mods, 'versions');
753
- return RawRowFmt.versions(await runQuery(c, q.text, q.params, 'versions', classes));
665
+ // ---------------------------------------------------------------------------
666
+ // Батч (этап 4, п. 8): очередь планов одной транзакцией
667
+ // ---------------------------------------------------------------------------
668
+ /** Простая вставка: один шаг с create или upsert, без id, слотов и концов из пути. */
669
+ function pureKind(plan) {
670
+ if (plan.length !== 1)
671
+ return null;
672
+ const s = plan[0];
673
+ const kind = s.op?.kind;
674
+ if ((kind !== 'create' && kind !== 'upsert') || s.filter !== undefined || s.slots?.length || s.pivotKey !== undefined)
675
+ return null;
676
+ const props = (s.cls.schema.properties ?? {});
677
+ // ключ только из полей данных: id сопоставляется со входом без концов
678
+ if (s.cls.key.length && s.cls.key.every((k) => k in props))
679
+ return 'keyed';
680
+ return kind === 'create' && !s.cls.key.length ? 'keyless' : null;
681
+ }
682
+ /**
683
+ * Очередь планов одной транзакцией. Подряд идущие простые вставки одного класса с ключом — одна
684
+ * многострочная вставка, подряд идущие upsert — тоже (повтор id режет группу: один оператор не
685
+ * меняет строку дважды); результат сопоставляется со входом по вычисленному id. Вставки класса без
686
+ * ключа уходят однострочными операторами одним конвейером: порядок строк в returning
687
+ * многострочной вставки не гарантирован, сопоставить не по чему.
688
+ */
689
+ export async function execBatch(w, plans) {
690
+ const out = new Array(plans.length);
691
+ const S = quoteId(w.schema);
692
+ const idFor = (s) => {
693
+ if (!w.tenant)
694
+ return null;
695
+ try {
696
+ return idOf({ name: s.cls.name, key: s.cls.key, ends: [] }, s.op.data, { tenant: w.tenant });
754
697
  }
755
- case 'ids': {
756
- const q = buildRead(c, steps, mods, 'ids');
757
- return (await runQuery(c, q.text, q.params, 'ids', classes)).map((r) => r.id);
698
+ catch {
699
+ return null;
758
700
  }
759
- case 'count': {
760
- const q = buildRead(c, steps, mods, 'count');
761
- return (await runQuery(c, q.text, q.params, 'count', classes))[0].n;
701
+ };
702
+ let i = 0;
703
+ while (i < plans.length) {
704
+ const kind = pureKind(plans[i]);
705
+ const head = plans[i][0];
706
+ if (kind === 'keyed' && idFor(head)) {
707
+ const verb = head.op.kind;
708
+ const group = [];
709
+ const seen = new Set();
710
+ let j = i;
711
+ while (j < plans.length && pureKind(plans[j]) === 'keyed' && plans[j][0].op.kind === verb && plans[j][0].cls.name === head.cls.name) {
712
+ const s = plans[j][0];
713
+ const id = idFor(s);
714
+ if (!id || (verb === 'upsert' && seen.has(id)))
715
+ break;
716
+ seen.add(id);
717
+ group.push({ at: j, s, id });
718
+ j++;
719
+ }
720
+ const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
721
+ const conflict = verb === 'upsert'
722
+ ? 'on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags'
723
+ : 'on conflict (id) do nothing';
724
+ const res = await query(w, `insert into ${S}.entity (class, data, tags, owner) select $1::text, x.d, x.t, x.o `
725
+ + `from jsonb_to_recordset($2::jsonb) as x(d jsonb, t text[], o uuid) ${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`, [head.cls.name, input]);
726
+ const byId = new Map(res.map((r) => [r.row.id, r]));
727
+ for (const g of group) {
728
+ const r = byId.get(g.id);
729
+ // create: занятый ключ (или повтор в батче) — строка не вставлена
730
+ if (!r)
731
+ throw existsError(w, g.s.cls, g.s.op.data, {}, g.id, null);
732
+ const row = toRow(r.row);
733
+ if (verb === 'upsert')
734
+ row.$upsert = r.old_rev === null ? 'created' : r.old_rev === row.rev ? 'unchanged' : 'updated';
735
+ out[g.at] = [row];
736
+ }
737
+ i = j;
762
738
  }
763
- case 'agg': {
764
- const q = buildRead(c, steps, mods, 'agg');
765
- return runQuery(c, q.text, q.params, 'agg', classes);
739
+ else if (kind === 'keyless') {
740
+ let j = i;
741
+ while (j < plans.length && pureKind(plans[j]) === 'keyless')
742
+ j++;
743
+ // однострочные вставки без ожидания ответа каждой — postgres.js шлёт их конвейером
744
+ const rows = await Promise.all(plans.slice(i, j).map((p) => createOp(w, p, p[0], false)));
745
+ rows.forEach((r, k) => (out[i + k] = r));
746
+ i = j;
766
747
  }
767
- }
768
- }
769
- const isPureInsertPlan = (p) => p.steps.length === 1 &&
770
- p.steps[0].op?.kind === 'create' &&
771
- noFilter(p.steps[0].filter) &&
772
- typeof p.steps[0].op.data?.id !== 'string' &&
773
- !Object.keys(p.steps[0].extraLinks ?? {}).length &&
774
- p.steps[0].cls.idGen.version !== 5;
775
- // START_CONTRACT: executeBatch
776
- // PURPOSE: Исполнить очередь планов одной транзакцией; подряд идущие чистые INSERT одного класса склеиваются в multi-VALUES.
777
- // INPUTS: { ctx: Ctx; queue: BatchPlan[] }
778
- // OUTPUTS: { Promise<Row[][]> - результат по каждому плану }
779
- // SIDE_EFFECTS: INSERT (multi/по-плану) в транзакции
780
- // LINKS: M-WRITE, V-M-WRITE, M-SQL
781
- // END_CONTRACT: executeBatch
782
- export async function executeBatch(ctx, queue) {
783
- if (!queue.length)
784
- return [];
785
- return inTransaction(ctx, async (c) => {
786
- const results = new Array(queue.length);
787
- let i = 0;
788
- while (i < queue.length) {
789
- // START_BLOCK_BATCH_FASTPATH
790
- if (isPureInsertPlan(queue[i])) {
791
- const cls = queue[i].steps[0].cls;
792
- let j = i;
793
- while (j < queue.length && isPureInsertPlan(queue[j]) && queue[j].steps[0].cls.id === cls.id)
794
- j++;
795
- const group = queue.slice(i, j);
796
- const params = [];
797
- for (const g of group) {
798
- const s = { ...g.steps[0] };
799
- aclWrite(c, s, 'WRITE'); // склейка multi-VALUES идёт мимо createOp — право+пришпиливание на каждый
800
- const id = genId(cls);
801
- const full = validate(cls, id, s.op?.data ?? {}, {});
802
- const account = resolveAccount(c, s);
803
- params.push(c.partition, id, cls.id, full, {}, tagsValue(s) ?? [], account, s.ownerFilter ?? c.owner ?? account);
804
- }
805
- const res = await runQuery(c, multiInsertSql(c.pgSchema, group.length), params, 'insert', [cls.id]);
806
- for (let k = 0; k < group.length; k++)
807
- results[i + k] = [toRow(res[k])];
808
- i = j;
809
- continue;
810
- }
811
- // END_BLOCK_BATCH_FASTPATH
812
- results[i] = (await runPlan(c, queue[i].steps, {}, 'rows'));
748
+ else {
749
+ const r = await execPlan(w, plans[i], {}, 'rows');
750
+ out[i] = r.kind === 'rows' ? r.rows : r.res.map((x) => toRow(x.row));
813
751
  i++;
814
752
  }
815
- return results;
816
- });
753
+ }
754
+ return out;
817
755
  }