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/CHANGELOG.md +203 -3
- package/README.md +825 -602
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.d.ts +44 -16
- package/dist/chain.js +309 -26
- package/dist/index.d.ts +7 -0
- package/dist/index.js +38 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +169 -4
- package/dist/sessions.js +66 -0
- package/dist/sql.d.ts +23 -3
- package/dist/sql.js +161 -25
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.d.ts +40 -4
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +68 -0
- package/dist/write.d.ts +12 -5
- package/dist/write.js +359 -95
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +1 -1
- package/sql/ddl.sql +124 -12
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +64 -17
package/dist/write.js
CHANGED
|
@@ -1,22 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Запись: операции .
|
|
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-фильтр целей при
|
|
9
|
-
* - links создаваемой строки при
|
|
8
|
+
* - containment-фильтр целей при update/delete (links ⊇ {Класс: id}),
|
|
9
|
+
* - links создаваемой строки при create.
|
|
10
10
|
*
|
|
11
|
-
* await db.Организация(org).Сотрудник().
|
|
12
|
-
* await db.Клиент(c).Запись({ status: 'created' }).
|
|
13
|
-
* await db.Клиент({ vip: true }).
|
|
14
|
-
* .Запись().delete({ confirm: true }).rows()
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
present.
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
*
|
|
170
|
-
*
|
|
245
|
+
* Связи создаваемой сущности из ПУТИ: каждый реальный шаг-предок, чей класс — конец
|
|
246
|
+
* target, обязан дать ровно одну сущность (id-фильтр — как есть; иначе чтение путём
|
|
247
|
+
* от начала до него: контекст честно учитывается).
|
|
171
248
|
*/
|
|
172
|
-
|
|
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 (
|
|
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
|
|
180
|
-
const rows = await readRows(ctx,
|
|
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
|
-
/**
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
* .
|
|
203
|
-
* -
|
|
204
|
-
* - id (
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
436
|
+
explicitId = v5Id(ctx, cls, writeLinks, data);
|
|
234
437
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 ??
|
|
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
|
|
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
|
|
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 '
|
|
357
|
-
return
|
|
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 === '
|
|
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 идёт мимо
|
|
497
|
-
const id =
|
|
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
|
}
|