letopis 0.13.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 +98 -0
- package/README.md +362 -199
- package/dist/chain.d.ts +44 -16
- package/dist/chain.js +197 -26
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/schema.js +96 -4
- package/dist/sql.d.ts +23 -3
- package/dist/sql.js +71 -24
- package/dist/types.d.ts +40 -4
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +32 -0
- package/dist/write.d.ts +12 -5
- package/dist/write.js +213 -95
- package/package.json +1 -1
- package/sql/ddl.sql +53 -11
- package/sql/seed.booking.sql +21 -16
package/dist/write.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
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
17
|
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { uuidv5, uuidv7 } from './uuid.js';
|
|
18
19
|
import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, isTransient, retryDelay, RETRIES, } from './sql.js';
|
|
19
20
|
import { aclDenied } from './acl.js';
|
|
20
21
|
export class ValidationError extends Error {
|
|
@@ -68,18 +69,36 @@ function validate(cls, id, data, links) {
|
|
|
68
69
|
throw new ValidationError(cls.id, res);
|
|
69
70
|
delete merged.id;
|
|
70
71
|
if (cls.category === 'LINK') {
|
|
71
|
-
let polymorphic = 0;
|
|
72
72
|
const present = new Set(Object.keys(links));
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
present.
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}
|
|
80
86
|
}
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
}
|
|
83
102
|
}
|
|
84
103
|
}
|
|
85
104
|
return merged;
|
|
@@ -137,11 +156,6 @@ function aclWrite(ctx, step, op) {
|
|
|
137
156
|
step[key] = v;
|
|
138
157
|
}
|
|
139
158
|
}
|
|
140
|
-
/**
|
|
141
|
-
* Различие форм фильтра в записи:
|
|
142
|
-
* Класс() — БЕЗ аргумента → INSERT новой сущности;
|
|
143
|
-
* Класс({}) — пустой объект → «все найденные» (UPDATE в границах контекста).
|
|
144
|
-
*/
|
|
145
159
|
const noFilter = (f) => f === undefined;
|
|
146
160
|
/** Entity.account NOT NULL: модификатор → connect() → System-аккаунт; иначе ошибка. */
|
|
147
161
|
function resolveAccount(ctx, step) {
|
|
@@ -165,93 +179,179 @@ function tagsValue(step) {
|
|
|
165
179
|
return t;
|
|
166
180
|
throw new Error('letopis: .tags() before set() accepts string | string[]');
|
|
167
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
|
+
}
|
|
168
186
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
187
|
+
* Связи создаваемой сущности из ПУТИ: каждый реальный шаг-предок, чей класс — конец
|
|
188
|
+
* target, обязан дать ровно одну сущность (id-фильтр — как есть; иначе чтение путём
|
|
189
|
+
* от начала до него: контекст честно учитывается).
|
|
171
190
|
*/
|
|
172
|
-
async function
|
|
191
|
+
async function resolvePathEnds(ctx, steps) {
|
|
192
|
+
const target = steps[steps.length - 1].cls;
|
|
173
193
|
const links = {};
|
|
174
|
-
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;
|
|
175
200
|
let id;
|
|
176
201
|
if (typeof step.filter === 'string') {
|
|
177
202
|
id = step.filter;
|
|
178
203
|
}
|
|
179
|
-
else
|
|
180
|
-
const rows = await readRows(ctx,
|
|
204
|
+
else {
|
|
205
|
+
const rows = await readRows(ctx, steps.slice(0, k + 1), { limit: 2 });
|
|
181
206
|
if (rows.length !== 1) {
|
|
182
207
|
throw new Error(`letopis: context step "${step.name}" must resolve to exactly one entity (got ${rows.length})`);
|
|
183
208
|
}
|
|
184
209
|
id = rows[0].id;
|
|
185
210
|
}
|
|
186
|
-
else {
|
|
187
|
-
throw new Error(`letopis: context step "${step.name}" needs an id or a unique filter`);
|
|
188
|
-
}
|
|
189
211
|
links[step.cls.id] = id;
|
|
190
212
|
}
|
|
191
213
|
return links;
|
|
192
214
|
}
|
|
193
|
-
/**
|
|
194
|
-
function
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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 };
|
|
200
245
|
}
|
|
246
|
+
/** id новой сущности по IdGen класса (v5 считается отдельно — нужны концы/данные). */
|
|
247
|
+
const genId = (cls) => (cls.idGen.version === 7 ? uuidv7() : randomUUID());
|
|
201
248
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* - фильтр-объект → новая версия каждого найденного (в границах контекста); пусто → [].
|
|
249
|
+
* Детерминированный id (uuid v5): имя = «схема:партиция:класс:значения from».
|
|
250
|
+
* from-источник — конец Schema.links (класс или полное имя союза 'Service|Complex';
|
|
251
|
+
* значение — id присутствующего класса конца) или скалярное поле data.
|
|
206
252
|
*/
|
|
207
|
-
|
|
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;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
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().
|
|
299
|
+
*/
|
|
300
|
+
export async function createOp(ctx, steps, mods, data) {
|
|
208
301
|
const target = steps[steps.length - 1];
|
|
209
302
|
const cls = target.cls;
|
|
210
303
|
aclWrite(ctx, target, 'WRITE');
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const dataId = typeof data.id === 'string' ? data.id : undefined;
|
|
304
|
+
if (target.pivotKey !== undefined) {
|
|
305
|
+
throw new Error(`letopis: create() on a pivot step — pivot returns to an existing node, use update()`);
|
|
306
|
+
}
|
|
215
307
|
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
|
-
|
|
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`);
|
|
232
327
|
}
|
|
233
|
-
|
|
328
|
+
explicitId = v5Id(ctx, cls, writeLinks, data);
|
|
234
329
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|
|
253
353
|
}
|
|
254
|
-
const id = explicitId ??
|
|
354
|
+
const id = explicitId ?? genId(cls);
|
|
255
355
|
const account = resolveAccount(ctx, target);
|
|
256
356
|
const owner = target.ownerFilter ?? ctx.owner ?? account;
|
|
257
357
|
const full = validate(cls, id, data, writeLinks);
|
|
@@ -262,6 +362,23 @@ export async function setOp(ctx, steps, mods, data) {
|
|
|
262
362
|
}),
|
|
263
363
|
];
|
|
264
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
|
+
}
|
|
265
382
|
/**
|
|
266
383
|
* .anonymize(fields): GDPR-затирание — новая версия с '[erased]' в указанных string-полях
|
|
267
384
|
* + тег 'anonymized'. Только string-поля (по Schema); история сохраняется (см. README).
|
|
@@ -275,8 +392,7 @@ export async function anonymizeOp(ctx, steps, mods, fields) {
|
|
|
275
392
|
throw new Error(`letopis: anonymize() erases string fields only; "${f}" is ${cls.fieldTypes.get(f)?.kind ?? 'unknown'}`);
|
|
276
393
|
}
|
|
277
394
|
}
|
|
278
|
-
const
|
|
279
|
-
const found = await readRows(ctx, [targetStep(target, contextLinks)], mods);
|
|
395
|
+
const found = await readRows(ctx, steps, mods); // цели — честный путь
|
|
280
396
|
const out = [];
|
|
281
397
|
for (const row of found) {
|
|
282
398
|
const patch = Object.fromEntries(fields.filter((f) => f in row.data).map((f) => [f, '[erased]']));
|
|
@@ -317,8 +433,7 @@ async function inTransaction(ctx, fn) {
|
|
|
317
433
|
export async function delOp(ctx, steps, mods, confirm) {
|
|
318
434
|
const target = steps[steps.length - 1];
|
|
319
435
|
aclWrite(ctx, target, 'DELETE');
|
|
320
|
-
const
|
|
321
|
-
const targets = await readRows(ctx, [targetStep(target, contextLinks)], mods);
|
|
436
|
+
const targets = await readRows(ctx, steps, mods); // цели — честный путь
|
|
322
437
|
if (!targets.length)
|
|
323
438
|
return [];
|
|
324
439
|
const params = [ctx.partition, target.cls.id, ...targets.map((r) => r.id)];
|
|
@@ -353,8 +468,10 @@ function splitPlan(steps) {
|
|
|
353
468
|
}
|
|
354
469
|
function opCall(c, steps, op) {
|
|
355
470
|
switch (op.kind) {
|
|
356
|
-
case '
|
|
357
|
-
return
|
|
471
|
+
case 'create':
|
|
472
|
+
return createOp(c, steps, op.mods, op.data ?? {});
|
|
473
|
+
case 'update':
|
|
474
|
+
return updateOp(c, steps, op.mods, op.data ?? {});
|
|
358
475
|
case 'anonymize':
|
|
359
476
|
return anonymizeOp(c, steps, op.mods, op.fields ?? []);
|
|
360
477
|
case 'delete':
|
|
@@ -473,10 +590,11 @@ async function readByMode(c, steps, mods, mode) {
|
|
|
473
590
|
}
|
|
474
591
|
}
|
|
475
592
|
const isPureInsertPlan = (p) => p.steps.length === 1 &&
|
|
476
|
-
p.steps[0].op?.kind === '
|
|
593
|
+
p.steps[0].op?.kind === 'create' &&
|
|
477
594
|
noFilter(p.steps[0].filter) &&
|
|
478
595
|
typeof p.steps[0].op.data?.id !== 'string' &&
|
|
479
|
-
!Object.keys(p.steps[0].extraLinks ?? {}).length
|
|
596
|
+
!Object.keys(p.steps[0].extraLinks ?? {}).length &&
|
|
597
|
+
p.steps[0].cls.idGen.version !== 5;
|
|
480
598
|
export async function executeBatch(ctx, queue) {
|
|
481
599
|
if (!queue.length)
|
|
482
600
|
return [];
|
|
@@ -493,8 +611,8 @@ export async function executeBatch(ctx, queue) {
|
|
|
493
611
|
const params = [];
|
|
494
612
|
for (const g of group) {
|
|
495
613
|
const s = { ...g.steps[0] };
|
|
496
|
-
aclWrite(c, s, 'WRITE'); // склейка multi-VALUES идёт мимо
|
|
497
|
-
const id =
|
|
614
|
+
aclWrite(c, s, 'WRITE'); // склейка multi-VALUES идёт мимо createOp — право+пришпиливание на каждый
|
|
615
|
+
const id = genId(cls);
|
|
498
616
|
const full = validate(cls, id, s.op?.data ?? {}, {});
|
|
499
617
|
const account = resolveAccount(c, s);
|
|
500
618
|
params.push(c.partition, id, cls.id, full, {}, tagsValue(s) ?? [], account, s.ownerFilter ?? c.owner ?? account);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Letopis (летопись): append-only versioned entity store on TimescaleDB with dot-notation chains — every change is a new row, history is first-class (asOf, versions, watch, cascade tombstones)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"timescaledb",
|
package/sql/ddl.sql
CHANGED
|
@@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
|
|
|
21
21
|
category text NOT NULL CHECK (category IN ('HUB', 'LINK')),
|
|
22
22
|
ancestor text, -- прямой родитель (наследование)
|
|
23
23
|
attributes jsonb NOT NULL DEFAULT '{}', -- fastest-validator DSL (валидирует либа)
|
|
24
|
-
links
|
|
24
|
+
links jsonb NOT NULL DEFAULT '[]', -- МАССИВ ОБЪЕКТОВ-концов: {"class":…}/{"classes":[…],"optional":…,"cardinality":…} (союзы ролей, матчинг по порядку); legacy-элемент-строка "Org" ("Entity" = полиморфный)
|
|
25
25
|
meta jsonb NOT NULL DEFAULT '{}', -- {abstract?, description?}
|
|
26
26
|
"order" int NOT NULL DEFAULT 0,
|
|
27
27
|
ancestors text[] NOT NULL DEFAULT '{}', -- [self, parent, …, root] — считает триггер schema_lineage
|
|
@@ -132,16 +132,26 @@ CREATE INDEX IF NOT EXISTS entity_owner_idx ON "<SCHEMA-NAME>"."Entity" (owner
|
|
|
132
132
|
-- * класс существует в Schema той же partition;
|
|
133
133
|
-- * класс не абстрактный;
|
|
134
134
|
-- * каждый ключ links — существующий класс, значение — строка (id);
|
|
135
|
-
-- * LINK
|
|
136
|
-
-- (
|
|
135
|
+
-- * LINK: концы из Schema.links.
|
|
136
|
+
-- Формат v2 (элемент — JSON-объект '{"class":…}' / '{"classes":[…]}'):
|
|
137
|
+
-- жадный матчинг по порядку объявления, optional-концы могут отсутствовать,
|
|
138
|
+
-- связи вне объявленных концов — ошибка.
|
|
139
|
+
-- Legacy (элемент — имя класса): конец обязателен по имени, 'Entity' —
|
|
140
|
+
-- полиморфный (его резолвит либа), лишние связи не проверяются.
|
|
137
141
|
-- Tombstone-версии (deleted IS NOT NULL) не валидируются.
|
|
138
142
|
-- -----------------------------------------------------------------------------
|
|
139
143
|
CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_check() RETURNS trigger
|
|
140
144
|
LANGUAGE plpgsql AS $$
|
|
141
145
|
DECLARE
|
|
142
|
-
s
|
|
143
|
-
bad
|
|
144
|
-
k
|
|
146
|
+
s RECORD;
|
|
147
|
+
bad text;
|
|
148
|
+
k text;
|
|
149
|
+
used text[];
|
|
150
|
+
ej jsonb;
|
|
151
|
+
ecs text[];
|
|
152
|
+
hit text;
|
|
153
|
+
c2 text;
|
|
154
|
+
is_v2 boolean;
|
|
145
155
|
BEGIN
|
|
146
156
|
IF NEW.deleted IS NOT NULL THEN
|
|
147
157
|
RETURN NEW;
|
|
@@ -172,13 +182,45 @@ BEGIN
|
|
|
172
182
|
RAISE EXCEPTION 'ENTITY_CHECK: links key "%" is not a class or its value is not an id string', bad;
|
|
173
183
|
END IF;
|
|
174
184
|
|
|
175
|
-
-- LINK:
|
|
185
|
+
-- LINK: концы по Schema.links — jsonb-массив; элемент-объект = v2 (строгий жадный
|
|
186
|
+
-- матчинг по порядку), элемент-строка = legacy-имя класса
|
|
176
187
|
IF s.category = 'LINK' THEN
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
188
|
+
is_v2 := EXISTS (
|
|
189
|
+
SELECT 1 FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le
|
|
190
|
+
WHERE jsonb_typeof(le.value) = 'object');
|
|
191
|
+
IF is_v2 THEN
|
|
192
|
+
used := ARRAY[]::text[];
|
|
193
|
+
FOR ej IN SELECT le.value FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le LOOP
|
|
194
|
+
ecs := COALESCE(
|
|
195
|
+
(SELECT array_agg(x) FROM jsonb_array_elements_text(ej->'classes') x),
|
|
196
|
+
ARRAY[ej->>'class']);
|
|
197
|
+
hit := NULL;
|
|
198
|
+
FOREACH c2 IN ARRAY ecs LOOP
|
|
199
|
+
IF (COALESCE(NEW.links, '{}'::jsonb) ? c2) AND NOT (c2 = ANY(used)) THEN
|
|
200
|
+
hit := c2;
|
|
201
|
+
EXIT;
|
|
202
|
+
END IF;
|
|
203
|
+
END LOOP;
|
|
204
|
+
IF hit IS NOT NULL THEN
|
|
205
|
+
used := used || hit;
|
|
206
|
+
ELSIF NOT COALESCE((ej->>'optional')::boolean, false) THEN
|
|
207
|
+
RAISE EXCEPTION 'ENTITY_CHECK: link "%" requires end "%"', NEW.class, array_to_string(ecs, '|');
|
|
208
|
+
END IF;
|
|
209
|
+
END LOOP;
|
|
210
|
+
SELECT e.key INTO bad
|
|
211
|
+
FROM jsonb_each(COALESCE(NEW.links, '{}'::jsonb)) e
|
|
212
|
+
WHERE NOT (e.key = ANY(used))
|
|
213
|
+
LIMIT 1;
|
|
214
|
+
IF bad IS NOT NULL THEN
|
|
215
|
+
RAISE EXCEPTION 'ENTITY_CHECK: link "%" has stray link "%" — not an end in Schema.links', NEW.class, bad;
|
|
180
216
|
END IF;
|
|
181
|
-
|
|
217
|
+
ELSE
|
|
218
|
+
FOR k IN SELECT le.value #>> '{}' FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le LOOP
|
|
219
|
+
IF k <> 'Entity' AND NOT (COALESCE(NEW.links, '{}'::jsonb) ? k) THEN
|
|
220
|
+
RAISE EXCEPTION 'ENTITY_CHECK: link "%" requires end "%"', NEW.class, k;
|
|
221
|
+
END IF;
|
|
222
|
+
END LOOP;
|
|
223
|
+
END IF;
|
|
182
224
|
END IF;
|
|
183
225
|
|
|
184
226
|
RETURN NEW;
|