letopis 0.18.1 → 0.20.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/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().
@@ -1,11 +1,11 @@
1
1
  -- =============================================================================
2
- -- Сид таблицы Schema — ИСТОЧНИК ПРАВДЫ демо-домена booking (19 классов, 0.17).
2
+ -- Сид таблицы Schema — ИСТОЧНИК ПРАВДЫ демо-домена booking (20 классов: 12 HUB + 8 LINK).
3
3
  -- Редактируется руками; накат: db/apply.mjs / up() (маркер <SCHEMA-NAME>).
4
4
  -- Идемпотентен (ON CONFLICT). Концы связей — Schema.links v2 (JSON-объекты в text[]).
5
5
  -- =============================================================================
6
6
  --
7
7
  -- FILE: lib/sql/seed.booking.sql
8
- -- VERSION: 1.0.0
8
+ -- VERSION: 1.0.1
9
9
  -- START_MODULE_CONTRACT
10
10
  -- PURPOSE: Идемпотентный сид таблицы Schema — источник правды демо-домена booking (классы HUB + LINK).
11
11
  -- SCOPE: 20 INSERT-ов классов (12 HUB: Entity/Org/Person/Staff/Customer/Location/Element/Service/Product/Complex/Schedule/Folder; 8 LINK: link/address/slot/booking/content/compo/skill/price).
@@ -18,11 +18,12 @@
18
18
  -- START_MODULE_MAP
19
19
  -- HUB-классы - сущности домена (наследование от Entity; id v5/v7)
20
20
  -- LINK-классы - связи (концы Schema.links v2; id v5 из концов)
21
- -- NB: фактически 20 классов (12 HUB + 8 LINK); заголовок файла говорит «19» — расхождение
21
+ -- NB: 20 классов (12 HUB + 8 LINK); число в заголовке файла сверяет scripts/check-docs.mjs
22
22
  -- END_MODULE_MAP
23
23
  --
24
24
  -- START_CHANGE_SUMMARY
25
- -- LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
25
+ -- LAST_CHANGE: [v1.0.1 - Docs-only: заголовок файла приведён к факту (20 классов: 12 HUB + 8 LINK);
26
+ -- число теперь проверяется автоматически. INSERT-ы не менялись]
26
27
  -- END_CHANGE_SUMMARY
27
28
  --
28
29
  -- START_BLOCK_SEED_HUB_CLASSES