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