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.
- package/CHANGELOG.md +53 -0
- package/README.md +384 -5
- package/dist/chain.d.ts +21 -0
- package/dist/chain.js +10 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +12 -4
- package/dist/sql.d.ts +8 -0
- package/dist/sql.js +19 -2
- package/dist/tables.d.ts +24 -0
- package/dist/tables.js +56 -1
- package/dist/types.d.ts +4 -2
- package/dist/write.d.ts +9 -0
- package/dist/write.js +39 -2
- package/package.json +2 -1
- package/scripts/gen-types.mjs +154 -0
- package/scripts/schema-sync.mjs +185 -0
- package/sql/ddl.sql +114 -2
|
@@ -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
|
-
-- * физического удаления не происходит никогда
|
|
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
|
|
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().
|