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/dist/write.js CHANGED
@@ -1,20 +1,21 @@
1
1
  /**
2
- * Запись: операции .set(data) / .delete(opts) / .anonymize(fields) — ЗВЕНЬЯ цепочки;
3
- * терминал (rows/first/ids/count/run/versions/агрегации) исполняет весь план
4
- * ОДНОЙ транзакцией (runPlan) — любой отказ (валидация/ACL) откатывает всё.
2
+ * Запись: операции .create(data) / .update(data) / .delete(opts) / .anonymize(fields) —
3
+ * ЗВЕНЬЯ цепочки; терминал (rows/first/ids/count/run/versions/агрегации) исполняет весь
4
+ * план ОДНОЙ транзакцией (runPlan) — любой отказ (валидация/ACL) откатывает всё.
5
5
  *
6
6
  * Связи задаются dot-цепочкой: шаги до операции (контекст) резолвятся в ровно одну
7
7
  * сущность каждый и дают:
8
- * - containment-фильтр целей при UPDATE/DELETE (links ⊇ {Класс: id}),
9
- * - links создаваемой строки при INSERT.
8
+ * - containment-фильтр целей при update/delete (links ⊇ {Класс: id}),
9
+ * - links создаваемой строки при create.
10
10
  *
11
- * await db.Организация(org).Сотрудник().set({ name: 'Вася' }).rows() // INSERT + links {Org}
12
- * await db.Клиент(c).Запись({ status: 'created' }).set({ status: 'confirmed' }).rows()
13
- * await db.Клиент({ vip: true }).set({ bonus: 500 }) // сегмент 1: UPDATE всех vip
14
- * .Запись().delete({ confirm: true }).rows() // сегмент 2: снести записи каждого
11
+ * await db.Организация(org).Сотрудник().create({ name: 'Вася' }).rows() // INSERT + links {Org}
12
+ * await db.Клиент(c).Запись({ status: 'created' }).update({ status: 'confirmed' }).rows()
13
+ * await db.Клиент({ vip: true }).update({ bonus: 500 }) // сегмент 1: версия каждого vip
14
+ * .Запись().delete({ confirm: true }).rows() // сегмент 2: снести записи каждого
15
15
  * Продолжение после операции идёт ОТ ЕЁ РЕЗУЛЬТАТА (fan-out по записанным строкам).
16
16
  */
17
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
- for (const end of cls.links) {
74
- if (end === 'Entity')
75
- polymorphic++;
76
- else if (present.has(end))
77
- present.delete(end);
78
- else
79
- throw new Error(`letopis: link "${cls.id}" requires end "${end}"`);
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
- if (present.size < polymorphic) {
82
- throw new Error(`letopis: link "${cls.id}" requires ${polymorphic} polymorphic end(s) (any class), got ${present.size}`);
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
- * Контекст-шаги (все, кроме последнего) → {КлассId: id}.
170
- * id-фильтр берётся как есть (без запроса); иной фильтр обязан дать ровно одну сущность.
187
+ * Связи создаваемой сущности из ПУТИ: каждый реальный шаг-предок, чей класс — конец
188
+ * target, обязан дать ровно одну сущность (id-фильтр — как есть; иначе чтение путём
189
+ * от начала до него: контекст честно учитывается).
171
190
  */
172
- async function resolveContext(ctx, steps) {
191
+ async function resolvePathEnds(ctx, steps) {
192
+ const target = steps[steps.length - 1].cls;
173
193
  const links = {};
174
- for (const step of steps) {
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 if (!noFilter(step.filter) || step.tagsFilter !== undefined || step.accountFilter || step.ownerFilter) {
180
- const rows = await readRows(ctx, [step], { limit: 2 });
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
- /** Шаг поиска целей: фильтр последнего шага + containment контекст-связей. */
194
- function targetStep(target, contextLinks, explicitId) {
195
- return {
196
- ...target,
197
- filter: explicitId ?? target.filter,
198
- linksFilter: Object.keys(contextLinks).length ? contextLinks : undefined,
199
- };
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
- * .set(data):
203
- * - пустой фильтр последнего шага и нет data.id → INSERT (links = контекст);
204
- * - id (фильтр-строка или data.id) → UPSERT: есть → новая версия (deep-merge), нет → создать;
205
- * - фильтр-объект → новая версия каждого найденного (в границах контекста); пусто → [].
249
+ * Детерминированный id (uuid v5): имя = «схема:партиция:класс:значения from».
250
+ * from-источник — конец Schema.links (класс или полное имя союза 'Service|Complex';
251
+ * значение — id присутствующего класса конца) или скалярное поле data.
206
252
  */
207
- export async function setOp(ctx, steps, mods, data) {
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
- // контекст-шаги: и фильтр целей, и значения; .link() — ТОЛЬКО значения (фильтр не трогает)
212
- const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
213
- const writeLinks = { ...contextLinks, ...(target.extraLinks ?? {}) };
214
- const dataId = typeof data.id === 'string' ? data.id : undefined;
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
- const explicitId = filterId ?? dataId;
217
- const insertMode = noFilter(target.filter); // Класс() без аргумента
218
- const found = insertMode && !explicitId
219
- ? []
220
- : await readRows(ctx, [targetStep(target, contextLinks, explicitId)], mods);
221
- if (found.length) {
222
- const out = [];
223
- for (const row of found) {
224
- const mergedData = deepMerge(row.data, data); // только указанные листья
225
- const mergedLinks = { ...row.links, ...writeLinks }; // контекст + .link()
226
- const full = validate(cls, row.id, mergedData, mergedLinks);
227
- out.push(await insertOne(ctx, {
228
- id: row.id, cls, data: full, links: mergedLinks,
229
- tags: tagsValue(target) ?? row.tags, account: row.account, owner: row.owner,
230
- prevUpdated: row.updated, deleted: null,
231
- }));
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
- return out;
328
+ explicitId = v5Id(ctx, cls, writeLinks, data);
234
329
  }
235
- // не найдено: INSERT только при явном id или Класс() без аргумента
236
- if (!explicitId && !insertMode)
237
- return [];
238
- // enforceAcl-предикат + явный id: «не нашёл среди доступных» НЕ значит «можно создать» —
239
- // существующая, но недоступная сущность иначе перехватывалась бы новой версией с чужим owner.
240
- // Проверка существования — БЕЗ предиката и БЕЗ пришпиленных им колонок.
241
- if (explicitId && target.aclFilter) {
242
- const bare = { ...ctx, aclDecide: undefined };
243
- const probe = {
244
- ...targetStep(target, contextLinks, explicitId),
245
- aclFilter: undefined,
246
- ownerFilter: undefined,
247
- accountFilter: undefined,
248
- linksFilter: undefined,
249
- };
250
- const taken = await readRows(bare, [probe], {});
251
- if (taken.length)
252
- return [];
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 ?? randomUUID();
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 contextLinks = await resolveContext(ctx, steps.slice(0, -1));
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 contextLinks = await resolveContext(ctx, steps.slice(0, -1));
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 'set':
357
- return setOp(c, steps, op.mods, op.data ?? {});
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 === 'set' &&
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 идёт мимо setOp — право+пришпиливание на каждый
497
- const id = randomUUID();
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.13.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 text[] NOT NULL DEFAULT '{}', -- HUB: встраиваемые цели; LINK: обязательные концы ('Entity' = полиморфный)
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 содержит все обязательные концы из Schema.links
136
- -- ('Entity' — полиморфный конец, его резолвит либа).
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 RECORD;
143
- bad text;
144
- k text;
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
- FOREACH k IN ARRAY COALESCE(s.links, '{}') LOOP
178
- IF k <> 'Entity' AND NOT (COALESCE(NEW.links, '{}'::jsonb) ? k) THEN
179
- RAISE EXCEPTION 'ENTITY_CHECK: link "%" requires end "%"', NEW.class, k;
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
- END LOOP;
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;