letopis 0.18.0 → 0.19.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.
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Миграция классов: diff JSON-файла схемы и таблицы Schema + отчёт совместимости.
4
+ * Ловит ломающие изменения ДО применения: новый валидатор прогоняется по живым
5
+ * latest-строкам каждого изменённого класса (строгая валидация, как в рантайме).
6
+ *
7
+ * node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
8
+ * node scripts/schema-sync.mjs --file=… --dsn=… --schema=v1.booking --apply
9
+ *
10
+ * Без --apply — только отчёт (exit 1, если есть несовместимые строки).
11
+ * Классы, отсутствующие в файле, НЕ удаляются (append-only дух; удаление — вручную).
12
+ * ancestors/descendants пересчитывает триггер schema_lineage.
13
+ */
14
+ //
15
+ // FILE: lib/scripts/schema-sync.mjs
16
+ // VERSION: 1.0.0
17
+ // START_MODULE_CONTRACT
18
+ // PURPOSE: CLI-миграция схемы — diff JSON-файла и таблицы Schema, ревалидация выборки живых строк, upsert классов при --apply.
19
+ // SCOPE: парсинг аргументов, канон-сериализация (stable/linkEnd), diff added/changed/removed, проверка совместимости, apply.
20
+ // DEPENDS: none
21
+ // LINKS: M-SCHEMA-SYNC, V-M-SCHEMA-SYNC
22
+ // ROLE: SCRIPT
23
+ // MAP_MODE: LOCALS
24
+ // END_MODULE_CONTRACT
25
+ //
26
+ // START_MODULE_MAP
27
+ // stable - стабильная сериализация для сравнения
28
+ // linkEnd/linksCanon - канон элементов Schema.links
29
+ // FIELDS - поля класса для diff/upsert
30
+ // added/changed/removed - результат diff файла и БД
31
+ // breakingRows - живые строки, ломающиеся о новую валидацию
32
+ // END_MODULE_MAP
33
+ //
34
+ // START_CHANGE_SUMMARY
35
+ // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
36
+ // END_CHANGE_SUMMARY
37
+ import { readFile } from 'node:fs/promises';
38
+ import { createRequire } from 'node:module';
39
+ import postgres from 'postgres';
40
+
41
+ const Validator = createRequire(import.meta.url)('fastest-validator');
42
+ const v = new Validator({ useNewCustomCheckerFunction: true });
43
+
44
+ // START_BLOCK_PARSE_ARGS
45
+ const args = Object.fromEntries(
46
+ process.argv.slice(2).map((a) => {
47
+ const m = a.match(/^--([^=]+)(?:=(.*))?$/);
48
+ return m ? [m[1], m[2] ?? true] : [a, true];
49
+ }),
50
+ );
51
+ const { file, schema } = args;
52
+ const dsn = args.dsn ?? process.env.ENTITY_DSN;
53
+ const partition = args.partition ?? 'entity';
54
+ const sample = Number(args.sample ?? 200);
55
+ if (!file || !dsn || !schema || typeof schema !== 'string') {
56
+ console.error('usage: node scripts/schema-sync.mjs --file=schema.json --dsn=… --schema=<pg_schema> [--partition=entity] [--sample=200] [--apply]');
57
+ process.exit(1);
58
+ }
59
+
60
+ // END_BLOCK_PARSE_ARGS
61
+ const ident = `"${schema.replaceAll('"', '""')}"`;
62
+ /** Поля класса, участвующие в diff и upsert (lineage-колонки считает триггер). */
63
+ const FIELDS = ['alias', 'category', 'ancestor', 'attributes', 'links', 'meta', 'order'];
64
+
65
+ // START_CONTRACT: stable
66
+ // PURPOSE: Стабильная сериализация значения (сортировка ключей на всех уровнях) для сравнения diff.
67
+ // INPUTS: { x: unknown }
68
+ // OUTPUTS: { string - канонический вид }
69
+ // SIDE_EFFECTS: none
70
+ // LINKS: M-SCHEMA-SYNC, V-M-SCHEMA-SYNC
71
+ // END_CONTRACT: stable
72
+ /** Стабильная сериализация для сравнения (сортировка ключей на всех уровнях). */
73
+ function stable(x) {
74
+ if (Array.isArray(x)) return `[${x.map(stable).join(',')}]`;
75
+ if (typeof x === 'object' && x !== null)
76
+ return `{${Object.keys(x).sort().map((k) => `${JSON.stringify(k)}:${stable(x[k])}`).join(',')}}`;
77
+ return JSON.stringify(x ?? null);
78
+ }
79
+
80
+ // START_CONTRACT: linkEnd
81
+ // PURPOSE: Привести элемент links к канону (конец v2: в БД JSON-текст в text[], в файле — объект).
82
+ // INPUTS: { e: string|object }
83
+ // OUTPUTS: { string }
84
+ // SIDE_EFFECTS: none
85
+ // LINKS: M-SCHEMA-SYNC, V-M-SCHEMA-SYNC
86
+ // END_CONTRACT: linkEnd
87
+ /** Элемент links к канону: в БД конец v2 лежит JSON-текстом в text[], в файле — объектом. */
88
+ function linkEnd(e) {
89
+ if (typeof e === 'string' && e.trimStart().startsWith('{')) return stable(JSON.parse(e));
90
+ return typeof e === 'string' ? JSON.stringify(e) : stable(e);
91
+ }
92
+ const linksCanon = (arr) => `[${(arr ?? []).map(linkEnd).join(',')}]`;
93
+
94
+ const fileClasses = JSON.parse(await readFile(file, 'utf8'));
95
+ const sql = postgres(dsn, { max: 1 });
96
+
97
+ try {
98
+ // START_BLOCK_DIFF
99
+ const dbRows = await sql.unsafe(
100
+ `SELECT id, alias, category, ancestor, attributes, links, meta, "order" FROM ${ident}."Schema" WHERE partition = $1`,
101
+ [partition],
102
+ );
103
+ const inDb = new Map(dbRows.map((r) => [r.id, r]));
104
+ const inFile = new Map(fileClasses.map((c) => [c.id, c]));
105
+
106
+ const added = fileClasses.filter((c) => !inDb.has(c.id));
107
+ const removed = dbRows.filter((r) => !inFile.has(r.id));
108
+ const changed = [];
109
+ for (const c of fileClasses) {
110
+ const db = inDb.get(c.id);
111
+ if (!db) continue;
112
+ const diff = FIELDS.filter((f) =>
113
+ f === 'links'
114
+ ? linksCanon(c.links) !== linksCanon(db.links)
115
+ : stable(c[f] ?? (f === 'order' ? 0 : f === 'ancestor' ? null : {})) !== stable(db[f]));
116
+ if (diff.length) changed.push({ cls: c, fields: diff });
117
+ }
118
+
119
+ // END_BLOCK_DIFF
120
+ console.log(`schema-sync: ${file} ↔ ${schema}.Schema (partition ${partition})`);
121
+ for (const c of added) console.log(` + ${c.id} (${c.category} · ${c.alias}) — новый класс`);
122
+ for (const { cls, fields } of changed) console.log(` ~ ${cls.id} — изменены: ${fields.join(', ')}`);
123
+ for (const r of removed) console.log(` - ${r.id} — в файле отсутствует (НЕ удаляется)`);
124
+ if (!added.length && !changed.length && !removed.length) console.log(' без изменений');
125
+
126
+ // совместимость: живые latest-строки изменённых классов против НОВОЙ строгой валидации
127
+ // START_BLOCK_COMPAT_CHECK
128
+ let breakingRows = 0;
129
+ for (const { cls, fields } of changed) {
130
+ if (!fields.includes('attributes')) continue;
131
+ const check = v.compile({ ...cls.attributes, $$strict: true });
132
+ const rows = await sql.unsafe(
133
+ `SELECT t.id, t.data FROM (
134
+ SELECT DISTINCT ON (e.id) e.id, e.data, e.deleted FROM ${ident}."Entity" e
135
+ WHERE e.partition = $1 AND e.class = $2
136
+ ORDER BY e.id, e.updated DESC
137
+ ) t WHERE t.deleted IS NULL LIMIT $3`,
138
+ [partition, cls.id, sample],
139
+ );
140
+ const bad = [];
141
+ for (const r of rows) {
142
+ const res = check({ ...r.data, id: r.id });
143
+ if (res !== true) bad.push({ id: r.id, issues: res });
144
+ }
145
+ if (bad.length) {
146
+ breakingRows += bad.length;
147
+ console.log(` ! ${cls.id}: ${bad.length}/${rows.length} живых строк НЕ пройдут новую валидацию:`);
148
+ for (const b of bad.slice(0, 3)) {
149
+ console.log(` id=${b.id} → ${b.issues.map((i) => `${i.field} — ${i.message ?? 'invalid'}`).join('; ')}`);
150
+ }
151
+ if (bad.length > 3) console.log(` … и ещё ${bad.length - 3}`);
152
+ } else if (rows.length) {
153
+ console.log(` ✓ ${cls.id}: ${rows.length} живых строк проходят новую валидацию`);
154
+ }
155
+ }
156
+
157
+ // END_BLOCK_COMPAT_CHECK
158
+ // START_BLOCK_APPLY
159
+ if (!args.apply) {
160
+ if (breakingRows) {
161
+ console.log(`ИТОГ: ломающие изменения (${breakingRows} строк). Применение (--apply) сломает запись этих сущностей.`);
162
+ process.exitCode = 1;
163
+ } else {
164
+ console.log(`ИТОГ: ${added.length} новых, ${changed.length} изменённых. Применить: --apply`);
165
+ }
166
+ } else {
167
+ if (breakingRows) console.warn(`ВНИМАНИЕ: применяю НЕсовместимую схему (${breakingRows} строк перестанут проходить валидацию при записи).`);
168
+ for (const c of [...added, ...changed.map((x) => x.cls)]) {
169
+ await sql.unsafe(
170
+ `INSERT INTO ${ident}."Schema" (partition, id, alias, category, ancestor, attributes, links, meta, "order")
171
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
172
+ ON CONFLICT (partition, id) DO UPDATE SET
173
+ alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor,
174
+ attributes = EXCLUDED.attributes, links = EXCLUDED.links, meta = EXCLUDED.meta, "order" = EXCLUDED."order"`,
175
+ // links — jsonb-массив (объекты-концы v2 / legacy-строки); postgres.js сериализует сам
176
+ [partition, c.id, c.alias, c.category, c.ancestor ?? null, c.attributes ?? {},
177
+ c.links ?? [], c.meta ?? {}, c.order ?? 0],
178
+ );
179
+ }
180
+ console.log(`применено: ${added.length} новых, ${changed.length} изменённых классов`);
181
+ }
182
+ // END_BLOCK_APPLY
183
+ } finally {
184
+ await sql.end();
185
+ }
package/sql/ddl.sql CHANGED
@@ -321,7 +321,8 @@ CREATE TRIGGER entity_update
321
321
  -- * бьёт только актуальную ЖИВУЮ строку (история и tombstone неприкосновенны);
322
322
  -- * advisory-lock сериализует параллельные удаления сущности;
323
323
  -- * каскад: DELETE живых зависимых (links ⊃ {класс: id}) → рекурсия этого же триггера;
324
- -- * физического удаления не происходит никогда (очистка истории — retention-политики).
324
+ -- * физического удаления не происходит никогда — КРОМЕ явного purge (см. ниже):
325
+ -- при SET LOCAL letopis.purge='on' триггер целиком пропускается (WHEN) → физический DELETE.
325
326
  -- Работает и для голого SQL: DELETE FROM "<SCHEMA-NAME>"."Entity" WHERE partition=… AND class=… AND id=…
326
327
  -- -----------------------------------------------------------------------------
327
328
  -- START_CONTRACT: entity_delete
@@ -363,7 +364,94 @@ END $$;
363
364
  DROP TRIGGER IF EXISTS entity_delete ON "<SCHEMA-NAME>"."Entity";
364
365
  CREATE TRIGGER entity_delete
365
366
  BEFORE DELETE ON "<SCHEMA-NAME>"."Entity"
366
- FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_delete();
367
+ FOR EACH ROW
368
+ -- purge-режим: при SET LOCAL letopis.purge='on' триггер НЕ срабатывает → физический DELETE.
369
+ -- Обычный путь (флаг не выставлен) видит NULL → IS DISTINCT FROM 'on' = true → триггер работает (tombstone).
370
+ WHEN (current_setting('letopis.purge', true) IS DISTINCT FROM 'on')
371
+ EXECUTE FUNCTION "<SCHEMA-NAME>".entity_delete();
372
+
373
+ -- -----------------------------------------------------------------------------
374
+ -- Физический hard-erase (purge). Вся логика — в БД (две функции); либа лишь зовёт purge():
375
+ -- * purge_closure(partition,class,ids[]) — (class,id) всего поддерева по links (вкл. tombstone);
376
+ -- единый источник замыкания (UNION отсекает циклы/диаманты).
377
+ -- * purge(partition,class,id[,dry]) RETURNS SETOF Entity — двухфазный снос корня + поддерева:
378
+ -- - ДВУХФАЗНОСТЬ: только если сущность уже логически удалена (актуальная версия — tombstone);
379
+ -- живую не трогает (0 строк);
380
+ -- - dry=true → превью (актуальные версии замыкания), БД не трогается;
381
+ -- - dry=false → SET LOCAL letopis.purge='on' отключает entity_delete (см. WHEN выше) → плоский DELETE
382
+ -- замыкания без вложенного DML (без TM_SelfModified, детерминированно), возвращает снесённое;
383
+ -- - флаг транзакционный (is_local) + явный сброс → дальше в той же tx снова tombstone; привилегий не требует.
384
+ -- Голый SQL: SELECT * FROM "<SCHEMA-NAME>".purge('entity','Order','X'); -- снос
385
+ -- SELECT * FROM "<SCHEMA-NAME>".purge('entity','Order','X', true); -- превью
386
+ -- -----------------------------------------------------------------------------
387
+ -- START_CONTRACT: purge_closure
388
+ -- PURPOSE: (class,id)-замыкание поддерева по links (вкл. tombstone) — что снесёт purge(); переиспользуется самой purge() (снос и dry-превью).
389
+ -- INPUTS: { p_partition text; p_class text; p_ids text[] }
390
+ -- OUTPUTS: { SETOF (class text, id text) - узлы замыкания }
391
+ -- SIDE_EFFECTS: none (STABLE)
392
+ -- LINKS: M-DDL, V-M-DDL, M-WRITE, M-SQL
393
+ -- END_CONTRACT: purge_closure
394
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".purge_closure(p_partition text, p_class text, p_ids text[])
395
+ RETURNS TABLE(class text, id text) LANGUAGE sql STABLE AS $$
396
+ WITH RECURSIVE closure(cls, cid) AS (
397
+ SELECT p_class, x FROM unnest(p_ids) AS x
398
+ UNION -- не ALL → отсекает циклы/диаманты links
399
+ SELECT e.class, e.id
400
+ FROM "<SCHEMA-NAME>"."Entity" e
401
+ JOIN closure c ON e.links @> jsonb_build_object(c.cls, c.cid)
402
+ WHERE e.partition = p_partition
403
+ )
404
+ SELECT cls, cid FROM closure;
405
+ $$;
406
+
407
+ -- START_CONTRACT: purge
408
+ -- PURPOSE: Двухфазно физически стереть логически удалённый корень и всё поддерево (по links, все версии); dry=превью.
409
+ -- INPUTS: { p_partition text; p_class text; p_id text; p_dry boolean = false }
410
+ -- OUTPUTS: { SETOF Entity - снесённые (по одному на сущность, latest) либо превью (dry); пусто если не tombstone }
411
+ -- SIDE_EFFECTS: при dry=false — SET LOCAL letopis.purge (отключает entity_delete на tx) + физический DELETE; advisory-lock
412
+ -- LINKS: M-DDL, V-M-DDL, M-WRITE, M-TABLES
413
+ -- END_CONTRACT: purge
414
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".purge(
415
+ p_partition text, p_class text, p_id text, p_dry boolean DEFAULT false)
416
+ RETURNS SETOF "<SCHEMA-NAME>"."Entity" LANGUAGE plpgsql AS $$
417
+ BEGIN
418
+ PERFORM pg_advisory_xact_lock(
419
+ hashtextextended(p_partition || '|' || p_class || '|' || p_id, 0));
420
+
421
+ -- двухфазность: hard-purge только ПОСЛЕ логического удаления — актуальная версия обязана быть tombstone.
422
+ -- Живую (не удалённую) сущность purge НЕ трогает (возвращает 0 строк).
423
+ IF NOT EXISTS (
424
+ SELECT 1 FROM "<SCHEMA-NAME>"."Entity" e
425
+ WHERE e.partition = p_partition AND e.class = p_class AND e.id = p_id AND e.deleted IS NOT NULL
426
+ AND e.updated = (SELECT max(updated) FROM "<SCHEMA-NAME>"."Entity"
427
+ WHERE partition = p_partition AND class = p_class AND id = p_id)
428
+ ) THEN
429
+ RETURN;
430
+ END IF;
431
+
432
+ IF p_dry THEN
433
+ -- превью: актуальная версия каждого члена замыкания, БД не трогаем
434
+ RETURN QUERY
435
+ SELECT DISTINCT ON (e.class, e.id) e.* FROM "<SCHEMA-NAME>"."Entity" e
436
+ JOIN "<SCHEMA-NAME>".purge_closure(p_partition, p_class, ARRAY[p_id]) c
437
+ ON e.class = c.class AND e.id = c.id
438
+ WHERE e.partition = p_partition
439
+ ORDER BY e.class, e.id, e.updated DESC;
440
+ RETURN;
441
+ END IF;
442
+
443
+ PERFORM set_config('letopis.purge', 'on', true); -- SET LOCAL: entity_delete отключён на эту tx
444
+ RETURN QUERY
445
+ WITH del AS (
446
+ DELETE FROM "<SCHEMA-NAME>"."Entity" e
447
+ USING "<SCHEMA-NAME>".purge_closure(p_partition, p_class, ARRAY[p_id]) c
448
+ WHERE e.partition = p_partition AND e.class = c.class AND e.id = c.id -- триггер пропущен (WHEN)
449
+ RETURNING e.*
450
+ )
451
+ SELECT DISTINCT ON (class, id) * FROM del ORDER BY class, id, updated DESC;
452
+ PERFORM set_config('letopis.purge', '', true); -- сброс: дальше в той же tx снова tombstone
453
+ RETURN;
454
+ END $$;
367
455
 
368
456
  -- =============================================================================
369
457
  -- Служебные таблицы (auth/ACL) — перенесены из legacy-дампа 1:1.
@@ -431,6 +519,30 @@ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Rule" (
431
519
  );
432
520
  -- END_BLOCK_AUTH_TABLES
433
521
 
522
+ -- -----------------------------------------------------------------------------
523
+ -- purge_account(): полный физический офбординг тенанта — все Entity (account|owner) + сам Account.
524
+ -- Credential уходит FK-каскадом (ON DELETE CASCADE). Флаг letopis.purge отключает entity_delete на
525
+ -- снос Entity (см. WHEN). Права/предохранители (Owner-only, последний Owner, не-себя) — в либе
526
+ -- (accounts.purge, им нужен ctx/ACL). Возвращает id снесённого Account либо NULL.
527
+ -- -----------------------------------------------------------------------------
528
+ -- START_CONTRACT: purge_account
529
+ -- PURPOSE: Физически стереть тенанта: все Entity с account|owner = id + сам Account (Credential — FK-каскад).
530
+ -- INPUTS: { p_id uuid }
531
+ -- OUTPUTS: { uuid - id снесённого Account, либо NULL если не найден }
532
+ -- SIDE_EFFECTS: SET LOCAL letopis.purge (отключает entity_delete); физический DELETE Entity + Account
533
+ -- LINKS: M-DDL, V-M-DDL, M-TABLES
534
+ -- END_CONTRACT: purge_account
535
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".purge_account(p_id uuid)
536
+ RETURNS uuid LANGUAGE plpgsql AS $$
537
+ DECLARE gone uuid;
538
+ BEGIN
539
+ PERFORM set_config('letopis.purge', 'on', true); -- SET LOCAL: entity_delete отключён (см. WHEN)
540
+ DELETE FROM "<SCHEMA-NAME>"."Entity" WHERE account = p_id OR owner = p_id; -- обе оси (иначе entity_owner_fk RESTRICT)
541
+ PERFORM set_config('letopis.purge', '', true);
542
+ DELETE FROM "<SCHEMA-NAME>"."Account" WHERE id = p_id RETURNING id INTO gone; -- Credential — FK-каскад
543
+ RETURN gone;
544
+ END $$;
545
+
434
546
  -- -----------------------------------------------------------------------------
435
547
  -- Триггер 4: realtime — факт каждой новой версии в pg_notify (канал = имя схемы).
436
548
  -- Payload лёгкий (без data): подписчик дочитывает нужное сам. Потребитель: db.watch().