letopis 0.20.3 → 1.0.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/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -250
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -351
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -600
- package/sql/seed.auth.sql +0 -73
package/sql/ddl.sql
DELETED
|
@@ -1,600 +0,0 @@
|
|
|
1
|
-
-- =============================================================================
|
|
2
|
-
-- Движок-хранилище: TimescaleDB, append-only.
|
|
3
|
-
-- В БД только: структура таблиц + индексы + триггеры ЦЕЛОСТНОСТИ.
|
|
4
|
-
-- Вся логика приложения (версии, каскады, локи, валидация data) — в либе letopis.
|
|
5
|
-
-- Идемпотентен: можно применять повторно.
|
|
6
|
-
-- "<SCHEMA-NAME>" — маркер: letopis.up() / db/apply.mjs подставляет полное имя схемы vN.<имя>.
|
|
7
|
-
-- =============================================================================
|
|
8
|
-
|
|
9
|
-
-- FILE: lib/sql/ddl.sql
|
|
10
|
-
-- VERSION: 1.1.0
|
|
11
|
-
-- DDL_REVISION: 1 (последняя строка файла штампует её в COMMENT ON SCHEMA; см. там же)
|
|
12
|
-
-- START_MODULE_CONTRACT
|
|
13
|
-
-- PURPOSE: Движок-хранилище PostgreSQL/TimescaleDB — таблицы, Entity-hypertable, индексы и триггеры целостности (валидация, версионирование, каскад-tombstone, lineage, notify).
|
|
14
|
-
-- SCOPE: таблицы Schema/Entity/Account/Credential/Resource/Rule + триггеры schema_lineage/entity_check/entity_update/entity_delete/entity_notify.
|
|
15
|
-
-- DEPENDS: none
|
|
16
|
-
-- LINKS: M-DDL, V-M-DDL
|
|
17
|
-
-- ROLE: RUNTIME
|
|
18
|
-
-- MAP_MODE: SUMMARY
|
|
19
|
-
-- END_MODULE_CONTRACT
|
|
20
|
-
--
|
|
21
|
-
-- START_MODULE_MAP
|
|
22
|
-
-- Schema - определения классов (HUB/LINK), наследование, attributes/links
|
|
23
|
-
-- Entity - append-only hypertable версий сущностей и связей
|
|
24
|
-
-- Account/Credential/Resource/Rule - служебные таблицы auth/ACL
|
|
25
|
-
-- schema_lineage - пересчёт ancestors/descendants (statement-level)
|
|
26
|
-
-- entity_check - валидация класса и концов LINK при INSERT
|
|
27
|
-
-- entity_update - UPDATE → вставка новой версии
|
|
28
|
-
-- entity_delete - DELETE → tombstone + рекурсивный каскад (advisory-lock)
|
|
29
|
-
-- entity_notify - pg_notify о новой версии (канал = имя схемы)
|
|
30
|
-
-- END_MODULE_MAP
|
|
31
|
-
--
|
|
32
|
-
-- START_CHANGE_SUMMARY
|
|
33
|
-
-- LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
34
|
-
-- END_CHANGE_SUMMARY
|
|
35
|
-
|
|
36
|
-
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
|
37
|
-
|
|
38
|
-
CREATE SCHEMA IF NOT EXISTS "<SCHEMA-NAME>";
|
|
39
|
-
|
|
40
|
-
-- -----------------------------------------------------------------------------
|
|
41
|
-
-- Schema: классы предметной области (HUB — сущности, LINK — связи).
|
|
42
|
-
-- Сидится демо-доменом booking: lib/sql/seed.booking.sql (источник правды).
|
|
43
|
-
-- -----------------------------------------------------------------------------
|
|
44
|
-
-- START_BLOCK_TABLE_SCHEMA
|
|
45
|
-
CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
|
|
46
|
-
partition text NOT NULL DEFAULT 'entity',
|
|
47
|
-
id text NOT NULL, -- англ. id класса: 'Staff', 'skill'
|
|
48
|
-
alias text NOT NULL, -- рус. алиас: 'Сотрудник', 'навык'
|
|
49
|
-
category text NOT NULL CHECK (category IN ('HUB', 'LINK')),
|
|
50
|
-
ancestor text, -- прямой родитель (наследование)
|
|
51
|
-
attributes jsonb NOT NULL DEFAULT '{}', -- fastest-validator DSL (валидирует либа)
|
|
52
|
-
links jsonb NOT NULL DEFAULT '[]', -- МАССИВ ОБЪЕКТОВ-концов: {"class":…}/{"classes":[…],"optional":…,"cardinality":…} (союзы ролей, матчинг по порядку); legacy-элемент-строка "Org" ("Entity" = полиморфный)
|
|
53
|
-
meta jsonb NOT NULL DEFAULT '{}', -- {abstract?, description?}
|
|
54
|
-
"order" int NOT NULL DEFAULT 0,
|
|
55
|
-
ancestors text[] NOT NULL DEFAULT '{}', -- [self, parent, …, root] — считает триггер schema_lineage
|
|
56
|
-
descendants text[] NOT NULL DEFAULT '{}', -- все потомки (транзитивно) — считает триггер schema_lineage
|
|
57
|
-
PRIMARY KEY (partition, id)
|
|
58
|
-
);
|
|
59
|
-
-- END_BLOCK_TABLE_SCHEMA
|
|
60
|
-
|
|
61
|
-
-- -----------------------------------------------------------------------------
|
|
62
|
-
-- Триггер lineage: ancestors + descendants пересчитываются автоматически
|
|
63
|
-
-- при любом INSERT/UPDATE(ancestor,id)/DELETE в Schema (statement-level).
|
|
64
|
-
-- Рекурсивные CTE вверх и вниз; защита от циклов (depth ≤ 32) и от
|
|
65
|
-
-- саморекурсии триггера (pg_trigger_depth).
|
|
66
|
-
-- -----------------------------------------------------------------------------
|
|
67
|
-
-- START_CONTRACT: schema_lineage
|
|
68
|
-
-- PURPOSE: Пересчитать ancestors + descendants всех классов при изменении Schema (statement-level; защита от циклов depth≤32 и саморекурсии).
|
|
69
|
-
-- INPUTS: { trigger: AFTER INSERT/UPDATE(ancestor,id)/DELETE ON Schema }
|
|
70
|
-
-- OUTPUTS: { NULL - обновляет колонки ancestors/descendants }
|
|
71
|
-
-- SIDE_EFFECTS: UPDATE Schema.ancestors/descendants
|
|
72
|
-
-- LINKS: M-DDL, V-M-DDL
|
|
73
|
-
-- END_CONTRACT: schema_lineage
|
|
74
|
-
CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".schema_lineage() RETURNS trigger
|
|
75
|
-
LANGUAGE plpgsql AS $$
|
|
76
|
-
BEGIN
|
|
77
|
-
IF pg_trigger_depth() > 1 THEN RETURN NULL; END IF;
|
|
78
|
-
|
|
79
|
-
WITH RECURSIVE up AS (
|
|
80
|
-
SELECT s.partition, s.id, s.id AS anc, 0 AS depth FROM "<SCHEMA-NAME>"."Schema" s
|
|
81
|
-
UNION ALL
|
|
82
|
-
SELECT u.partition, u.id, p.ancestor, u.depth + 1
|
|
83
|
-
FROM up u
|
|
84
|
-
JOIN "<SCHEMA-NAME>"."Schema" p ON p.partition = u.partition AND p.id = u.anc
|
|
85
|
-
WHERE p.ancestor IS NOT NULL AND u.depth < 32
|
|
86
|
-
),
|
|
87
|
-
anc AS (
|
|
88
|
-
SELECT partition, id, array_agg(anc ORDER BY depth) AS ancestors
|
|
89
|
-
FROM up GROUP BY partition, id
|
|
90
|
-
),
|
|
91
|
-
down AS (
|
|
92
|
-
SELECT s.partition, s.id, s.id AS des, 0 AS depth FROM "<SCHEMA-NAME>"."Schema" s
|
|
93
|
-
UNION ALL
|
|
94
|
-
SELECT d.partition, d.id, c.id, d.depth + 1
|
|
95
|
-
FROM down d
|
|
96
|
-
JOIN "<SCHEMA-NAME>"."Schema" c ON c.partition = d.partition AND c.ancestor = d.des
|
|
97
|
-
WHERE d.depth < 32
|
|
98
|
-
),
|
|
99
|
-
dsc AS (
|
|
100
|
-
SELECT partition, id,
|
|
101
|
-
COALESCE(array_agg(des ORDER BY depth, des) FILTER (WHERE depth > 0), '{}') AS descendants
|
|
102
|
-
FROM down GROUP BY partition, id
|
|
103
|
-
)
|
|
104
|
-
UPDATE "<SCHEMA-NAME>"."Schema" s
|
|
105
|
-
SET ancestors = anc.ancestors, descendants = dsc.descendants
|
|
106
|
-
FROM anc
|
|
107
|
-
JOIN dsc ON dsc.partition = anc.partition AND dsc.id = anc.id
|
|
108
|
-
WHERE s.partition = anc.partition AND s.id = anc.id
|
|
109
|
-
AND (s.ancestors IS DISTINCT FROM anc.ancestors
|
|
110
|
-
OR s.descendants IS DISTINCT FROM dsc.descendants);
|
|
111
|
-
|
|
112
|
-
RETURN NULL;
|
|
113
|
-
END $$;
|
|
114
|
-
|
|
115
|
-
DROP TRIGGER IF EXISTS schema_lineage ON "<SCHEMA-NAME>"."Schema";
|
|
116
|
-
CREATE TRIGGER schema_lineage
|
|
117
|
-
AFTER INSERT OR UPDATE OF ancestor, id OR DELETE ON "<SCHEMA-NAME>"."Schema"
|
|
118
|
-
FOR EACH STATEMENT EXECUTE FUNCTION "<SCHEMA-NAME>".schema_lineage();
|
|
119
|
-
|
|
120
|
-
-- -----------------------------------------------------------------------------
|
|
121
|
-
-- Entity: единое хранилище сущностей и связей. Append-only hypertable.
|
|
122
|
-
-- Версия = строка; актуальная версия = max(updated); удаление = tombstone (deleted).
|
|
123
|
-
-- links = {"Класс": "id", ...} — произвольное число ссылок в одной строке.
|
|
124
|
-
-- -----------------------------------------------------------------------------
|
|
125
|
-
-- START_BLOCK_ENTITY_STORAGE
|
|
126
|
-
CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Entity" (
|
|
127
|
-
partition text NOT NULL,
|
|
128
|
-
id text NOT NULL,
|
|
129
|
-
class text NOT NULL,
|
|
130
|
-
data jsonb NOT NULL DEFAULT '{}',
|
|
131
|
-
updated timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
132
|
-
account uuid NOT NULL, -- арендатор; default подставляет либа (connect.account → System)
|
|
133
|
-
tags text[] NOT NULL DEFAULT '{}',
|
|
134
|
-
owner uuid NOT NULL, -- владелец; default = account
|
|
135
|
-
links jsonb NOT NULL DEFAULT '{}',
|
|
136
|
-
deleted timestamptz,
|
|
137
|
-
PRIMARY KEY (partition, class, id, updated),
|
|
138
|
-
-- ссылочная целостность класса: Entity(partition, class) → Schema(partition, id).
|
|
139
|
-
-- Timescale поддерживает FK hypertable → обычная таблица.
|
|
140
|
-
-- RESTRICT: класс с данными нельзя удалить из Schema (проверка через префикс entity_latest_idx).
|
|
141
|
-
CONSTRAINT entity_class_fk FOREIGN KEY (partition, class)
|
|
142
|
-
REFERENCES "<SCHEMA-NAME>"."Schema"(partition, id)
|
|
143
|
-
ON UPDATE CASCADE ON DELETE RESTRICT
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
-- Партиционирование: ТОЛЬКО время updated (чанки по 30 дней). Hash-размерности
|
|
147
|
-
-- (partition/class/id) убраны по результатам bench/dimensions.bench.mjs (1.1M строк,
|
|
148
|
-
-- год истории): точечный доступ равен PK-btree, простые фильтры −10-20%, зато
|
|
149
|
-
-- цепочки LATERAL ×2…×140 медленнее (93 чанка/год против 14) и планирование дороже.
|
|
150
|
-
-- Локальность версий сущности даёт компрессия segmentby (db/policies.mjs), не hash.
|
|
151
|
-
SELECT create_hypertable(
|
|
152
|
-
'"<SCHEMA-NAME>"."Entity"', 'updated',
|
|
153
|
-
chunk_time_interval => INTERVAL '30 days',
|
|
154
|
-
if_not_exists => TRUE,
|
|
155
|
-
migrate_data => TRUE
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
-- Индексы: «последняя версия», обход графа, фильтры, теги, арендатор/владелец.
|
|
159
|
-
-- account/owner — полные (не partial): нужны и фильтрам $account/$owner, и RI-сканам FK.
|
|
160
|
-
CREATE INDEX IF NOT EXISTS entity_latest_idx ON "<SCHEMA-NAME>"."Entity" (partition, class, id, updated DESC);
|
|
161
|
-
CREATE INDEX IF NOT EXISTS entity_links_idx ON "<SCHEMA-NAME>"."Entity" USING gin (links jsonb_path_ops);
|
|
162
|
-
CREATE INDEX IF NOT EXISTS entity_data_idx ON "<SCHEMA-NAME>"."Entity" USING gin (data jsonb_path_ops);
|
|
163
|
-
CREATE INDEX IF NOT EXISTS entity_tags_idx ON "<SCHEMA-NAME>"."Entity" USING gin (tags);
|
|
164
|
-
CREATE INDEX IF NOT EXISTS entity_account_idx ON "<SCHEMA-NAME>"."Entity" (account);
|
|
165
|
-
CREATE INDEX IF NOT EXISTS entity_owner_idx ON "<SCHEMA-NAME>"."Entity" (owner);
|
|
166
|
-
-- END_BLOCK_ENTITY_STORAGE
|
|
167
|
-
|
|
168
|
-
-- -----------------------------------------------------------------------------
|
|
169
|
-
-- Триггер 1: целостность при INSERT (только проверки, никакой логики приложения).
|
|
170
|
-
-- * класс существует в Schema той же partition;
|
|
171
|
-
-- * класс не абстрактный;
|
|
172
|
-
-- * каждый ключ links — существующий класс, значение — строка (id);
|
|
173
|
-
-- * LINK: концы из Schema.links.
|
|
174
|
-
-- Формат v2 (элемент — JSON-объект '{"class":…}' / '{"classes":[…]}'):
|
|
175
|
-
-- жадный матчинг по порядку объявления, optional-концы могут отсутствовать,
|
|
176
|
-
-- связи вне объявленных концов — ошибка.
|
|
177
|
-
-- Legacy (элемент — имя класса): конец обязателен по имени, 'Entity' —
|
|
178
|
-
-- полиморфный (его резолвит либа), лишние связи не проверяются.
|
|
179
|
-
-- Tombstone-версии (deleted IS NOT NULL) не валидируются.
|
|
180
|
-
-- -----------------------------------------------------------------------------
|
|
181
|
-
-- START_CONTRACT: entity_check
|
|
182
|
-
-- PURPOSE: Проверить при INSERT — класс существует и не абстрактный; ключи links — существующие классы со строковыми id; концы LINK (v2 жадный матчинг / legacy).
|
|
183
|
-
-- INPUTS: { trigger: BEFORE INSERT ON Entity (NEW) }
|
|
184
|
-
-- OUTPUTS: { NEW - либо EXCEPTION }
|
|
185
|
-
-- SIDE_EFFECTS: none (валидация; tombstone-версии пропускаются)
|
|
186
|
-
-- ERRORS: unknown class; class is abstract; links key not a class; link requires end; stray link
|
|
187
|
-
-- LINKS: M-DDL, V-M-DDL, M-WRITE
|
|
188
|
-
-- END_CONTRACT: entity_check
|
|
189
|
-
CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_check() RETURNS trigger
|
|
190
|
-
LANGUAGE plpgsql AS $$
|
|
191
|
-
DECLARE
|
|
192
|
-
s RECORD;
|
|
193
|
-
bad text;
|
|
194
|
-
k text;
|
|
195
|
-
used text[];
|
|
196
|
-
ej jsonb;
|
|
197
|
-
ecs text[];
|
|
198
|
-
hit text;
|
|
199
|
-
c2 text;
|
|
200
|
-
is_v2 boolean;
|
|
201
|
-
BEGIN
|
|
202
|
-
IF NEW.deleted IS NOT NULL THEN
|
|
203
|
-
RETURN NEW;
|
|
204
|
-
END IF;
|
|
205
|
-
|
|
206
|
-
SELECT * INTO s
|
|
207
|
-
FROM "<SCHEMA-NAME>"."Schema"
|
|
208
|
-
WHERE partition = NEW.partition AND id = NEW.class;
|
|
209
|
-
|
|
210
|
-
IF NOT FOUND THEN
|
|
211
|
-
RAISE EXCEPTION 'ENTITY_CHECK: unknown class "%" (partition "%")', NEW.class, NEW.partition;
|
|
212
|
-
END IF;
|
|
213
|
-
|
|
214
|
-
IF s.meta->>'abstract' = 'true' THEN
|
|
215
|
-
RAISE EXCEPTION 'ENTITY_CHECK: class "%" is abstract', NEW.class;
|
|
216
|
-
END IF;
|
|
217
|
-
|
|
218
|
-
-- ключи links — существующие классы, значения — строки
|
|
219
|
-
SELECT e.key INTO bad
|
|
220
|
-
FROM jsonb_each(COALESCE(NEW.links, '{}'::jsonb)) e
|
|
221
|
-
WHERE jsonb_typeof(e.value) <> 'string'
|
|
222
|
-
OR NOT EXISTS (
|
|
223
|
-
SELECT 1 FROM "<SCHEMA-NAME>"."Schema" c
|
|
224
|
-
WHERE c.partition = NEW.partition AND c.id = e.key)
|
|
225
|
-
LIMIT 1;
|
|
226
|
-
|
|
227
|
-
IF bad IS NOT NULL THEN
|
|
228
|
-
RAISE EXCEPTION 'ENTITY_CHECK: links key "%" is not a class or its value is not an id string', bad;
|
|
229
|
-
END IF;
|
|
230
|
-
|
|
231
|
-
-- LINK: концы по Schema.links — jsonb-массив; элемент-объект = v2 (строгий жадный
|
|
232
|
-
-- матчинг по порядку), элемент-строка = legacy-имя класса
|
|
233
|
-
-- START_BLOCK_ENTITY_CHECK_LINK
|
|
234
|
-
IF s.category = 'LINK' THEN
|
|
235
|
-
is_v2 := EXISTS (
|
|
236
|
-
SELECT 1 FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le
|
|
237
|
-
WHERE jsonb_typeof(le.value) = 'object');
|
|
238
|
-
IF is_v2 THEN
|
|
239
|
-
used := ARRAY[]::text[];
|
|
240
|
-
FOR ej IN SELECT le.value FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le LOOP
|
|
241
|
-
ecs := COALESCE(
|
|
242
|
-
(SELECT array_agg(x) FROM jsonb_array_elements_text(ej->'classes') x),
|
|
243
|
-
ARRAY[ej->>'class']);
|
|
244
|
-
hit := NULL;
|
|
245
|
-
FOREACH c2 IN ARRAY ecs LOOP
|
|
246
|
-
IF (COALESCE(NEW.links, '{}'::jsonb) ? c2) AND NOT (c2 = ANY(used)) THEN
|
|
247
|
-
hit := c2;
|
|
248
|
-
EXIT;
|
|
249
|
-
END IF;
|
|
250
|
-
END LOOP;
|
|
251
|
-
IF hit IS NOT NULL THEN
|
|
252
|
-
used := used || hit;
|
|
253
|
-
ELSIF NOT COALESCE((ej->>'optional')::boolean, false) THEN
|
|
254
|
-
RAISE EXCEPTION 'ENTITY_CHECK: link "%" requires end "%"', NEW.class, array_to_string(ecs, '|');
|
|
255
|
-
END IF;
|
|
256
|
-
END LOOP;
|
|
257
|
-
SELECT e.key INTO bad
|
|
258
|
-
FROM jsonb_each(COALESCE(NEW.links, '{}'::jsonb)) e
|
|
259
|
-
WHERE NOT (e.key = ANY(used))
|
|
260
|
-
LIMIT 1;
|
|
261
|
-
IF bad IS NOT NULL THEN
|
|
262
|
-
RAISE EXCEPTION 'ENTITY_CHECK: link "%" has stray link "%" — not an end in Schema.links', NEW.class, bad;
|
|
263
|
-
END IF;
|
|
264
|
-
ELSE
|
|
265
|
-
FOR k IN SELECT le.value #>> '{}' FROM jsonb_array_elements(COALESCE(s.links, '[]'::jsonb)) le LOOP
|
|
266
|
-
IF k <> 'Entity' AND NOT (COALESCE(NEW.links, '{}'::jsonb) ? k) THEN
|
|
267
|
-
RAISE EXCEPTION 'ENTITY_CHECK: link "%" requires end "%"', NEW.class, k;
|
|
268
|
-
END IF;
|
|
269
|
-
END LOOP;
|
|
270
|
-
END IF;
|
|
271
|
-
END IF;
|
|
272
|
-
|
|
273
|
-
-- END_BLOCK_ENTITY_CHECK_LINK
|
|
274
|
-
RETURN NEW;
|
|
275
|
-
END $$;
|
|
276
|
-
|
|
277
|
-
DROP TRIGGER IF EXISTS entity_check ON "<SCHEMA-NAME>"."Entity";
|
|
278
|
-
CREATE TRIGGER entity_check
|
|
279
|
-
BEFORE INSERT ON "<SCHEMA-NAME>"."Entity"
|
|
280
|
-
FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_check();
|
|
281
|
-
|
|
282
|
-
-- -----------------------------------------------------------------------------
|
|
283
|
-
-- Триггер 2: UPDATE = новая версия. Физического UPDATE не происходит:
|
|
284
|
-
-- * бьёт только АКТУАЛЬНУЮ строку (не-latest молча игнорируются, история неизменна);
|
|
285
|
-
-- * вместо апдейта вставляется новая версия с монотонным updated;
|
|
286
|
-
-- * вставка проходит entity_check (валидация класса/links).
|
|
287
|
-
-- Работает и для голого SQL: UPDATE "<SCHEMA-NAME>"."Entity" SET data=… WHERE …
|
|
288
|
-
-- -----------------------------------------------------------------------------
|
|
289
|
-
-- START_CONTRACT: entity_update
|
|
290
|
-
-- PURPOSE: Превратить UPDATE в INSERT новой версии — бьёт только актуальную строку, историю не трогает, updated монотонно растёт.
|
|
291
|
-
-- INPUTS: { trigger: BEFORE UPDATE ON Entity (OLD/NEW) }
|
|
292
|
-
-- OUTPUTS: { NULL - физический UPDATE отменён }
|
|
293
|
-
-- SIDE_EFFECTS: INSERT новой версии (проходит entity_check)
|
|
294
|
-
-- LINKS: M-DDL, V-M-DDL
|
|
295
|
-
-- END_CONTRACT: entity_update
|
|
296
|
-
CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_update() RETURNS trigger
|
|
297
|
-
LANGUAGE plpgsql AS $$
|
|
298
|
-
DECLARE latest timestamptz;
|
|
299
|
-
BEGIN
|
|
300
|
-
SELECT max(updated) INTO latest FROM "<SCHEMA-NAME>"."Entity"
|
|
301
|
-
WHERE partition = OLD.partition AND class = OLD.class AND id = OLD.id;
|
|
302
|
-
IF OLD.updated <> latest THEN
|
|
303
|
-
RETURN NULL; -- историческая версия неприкосновенна
|
|
304
|
-
END IF;
|
|
305
|
-
|
|
306
|
-
INSERT INTO "<SCHEMA-NAME>"."Entity"
|
|
307
|
-
(partition, id, class, data, links, tags, account, owner, updated, deleted)
|
|
308
|
-
VALUES
|
|
309
|
-
(NEW.partition, NEW.id, NEW.class, NEW.data, NEW.links, NEW.tags, NEW.account, NEW.owner,
|
|
310
|
-
GREATEST(clock_timestamp(), OLD.updated + interval '1 microsecond'), NEW.deleted);
|
|
311
|
-
RETURN NULL; -- физический UPDATE отменён
|
|
312
|
-
END $$;
|
|
313
|
-
|
|
314
|
-
DROP TRIGGER IF EXISTS entity_no_update ON "<SCHEMA-NAME>"."Entity";
|
|
315
|
-
DROP TRIGGER IF EXISTS entity_update ON "<SCHEMA-NAME>"."Entity";
|
|
316
|
-
CREATE TRIGGER entity_update
|
|
317
|
-
BEFORE UPDATE ON "<SCHEMA-NAME>"."Entity"
|
|
318
|
-
FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_update();
|
|
319
|
-
|
|
320
|
-
-- -----------------------------------------------------------------------------
|
|
321
|
-
-- Триггер 3: DELETE = tombstone + РЕКУРСИВНЫЙ каскад по links — целиком в БД.
|
|
322
|
-
-- * бьёт только актуальную ЖИВУЮ строку (история и tombstone неприкосновенны);
|
|
323
|
-
-- * advisory-lock сериализует параллельные удаления сущности;
|
|
324
|
-
-- * каскад: DELETE живых зависимых (links ⊃ {класс: id}) → рекурсия этого же триггера;
|
|
325
|
-
-- * физического удаления не происходит никогда — КРОМЕ явного purge (см. ниже):
|
|
326
|
-
-- при SET LOCAL letopis.purge='on' триггер целиком пропускается (WHEN) → физический DELETE.
|
|
327
|
-
-- Работает и для голого SQL: DELETE FROM "<SCHEMA-NAME>"."Entity" WHERE partition=… AND class=… AND id=…
|
|
328
|
-
-- -----------------------------------------------------------------------------
|
|
329
|
-
-- START_CONTRACT: entity_delete
|
|
330
|
-
-- PURPOSE: Превратить DELETE в tombstone + рекурсивный каскад по links; advisory-lock сериализует параллельные удаления; физического удаления нет.
|
|
331
|
-
-- INPUTS: { trigger: BEFORE DELETE ON Entity (OLD) }
|
|
332
|
-
-- OUTPUTS: { NULL - физическое удаление отменено }
|
|
333
|
-
-- SIDE_EFFECTS: INSERT tombstone; DELETE живых зависимых (рекурсия триггера); pg_advisory_xact_lock
|
|
334
|
-
-- LINKS: M-DDL, V-M-DDL, M-WRITE
|
|
335
|
-
-- END_CONTRACT: entity_delete
|
|
336
|
-
CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_delete() RETURNS trigger
|
|
337
|
-
LANGUAGE plpgsql AS $$
|
|
338
|
-
DECLARE latest timestamptz;
|
|
339
|
-
BEGIN
|
|
340
|
-
PERFORM pg_advisory_xact_lock(
|
|
341
|
-
hashtextextended(OLD.partition || '|' || OLD.class || '|' || OLD.id, 0));
|
|
342
|
-
|
|
343
|
-
SELECT max(updated) INTO latest FROM "<SCHEMA-NAME>"."Entity"
|
|
344
|
-
WHERE partition = OLD.partition AND class = OLD.class AND id = OLD.id;
|
|
345
|
-
IF OLD.updated <> latest OR OLD.deleted IS NOT NULL THEN
|
|
346
|
-
RETURN NULL; -- не актуальная или уже tombstone: игнор
|
|
347
|
-
END IF;
|
|
348
|
-
|
|
349
|
-
-- tombstone-версия
|
|
350
|
-
INSERT INTO "<SCHEMA-NAME>"."Entity"
|
|
351
|
-
(partition, id, class, data, links, tags, account, owner, updated, deleted)
|
|
352
|
-
VALUES
|
|
353
|
-
(OLD.partition, OLD.id, OLD.class, OLD.data, OLD.links, OLD.tags, OLD.account, OLD.owner,
|
|
354
|
-
GREATEST(clock_timestamp(), OLD.updated + interval '1 microsecond'), clock_timestamp());
|
|
355
|
-
|
|
356
|
-
-- каскад: живые зависимые; их DELETE рекурсивно повторит этот триггер
|
|
357
|
-
DELETE FROM "<SCHEMA-NAME>"."Entity" e
|
|
358
|
-
WHERE e.partition = OLD.partition
|
|
359
|
-
AND e.deleted IS NULL
|
|
360
|
-
AND e.links @> jsonb_build_object(OLD.class, OLD.id);
|
|
361
|
-
|
|
362
|
-
RETURN NULL; -- физическое удаление отменено
|
|
363
|
-
END $$;
|
|
364
|
-
|
|
365
|
-
DROP TRIGGER IF EXISTS entity_delete ON "<SCHEMA-NAME>"."Entity";
|
|
366
|
-
CREATE TRIGGER entity_delete
|
|
367
|
-
BEFORE DELETE ON "<SCHEMA-NAME>"."Entity"
|
|
368
|
-
FOR EACH ROW
|
|
369
|
-
-- purge-режим: при SET LOCAL letopis.purge='on' триггер НЕ срабатывает → физический DELETE.
|
|
370
|
-
-- Обычный путь (флаг не выставлен) видит NULL → IS DISTINCT FROM 'on' = true → триггер работает (tombstone).
|
|
371
|
-
WHEN (current_setting('letopis.purge', true) IS DISTINCT FROM 'on')
|
|
372
|
-
EXECUTE FUNCTION "<SCHEMA-NAME>".entity_delete();
|
|
373
|
-
|
|
374
|
-
-- -----------------------------------------------------------------------------
|
|
375
|
-
-- Физический hard-erase (purge). Вся логика — в БД (две функции); либа лишь зовёт purge():
|
|
376
|
-
-- * purge_closure(partition,class,ids[]) — (class,id) всего поддерева по links (вкл. tombstone);
|
|
377
|
-
-- единый источник замыкания (UNION отсекает циклы/диаманты).
|
|
378
|
-
-- * purge(partition,class,id[,dry]) RETURNS SETOF Entity — двухфазный снос корня + поддерева:
|
|
379
|
-
-- - ДВУХФАЗНОСТЬ: только если сущность уже логически удалена (актуальная версия — tombstone);
|
|
380
|
-
-- живую не трогает (0 строк);
|
|
381
|
-
-- - dry=true → превью (актуальные версии замыкания), БД не трогается;
|
|
382
|
-
-- - dry=false → SET LOCAL letopis.purge='on' отключает entity_delete (см. WHEN выше) → плоский DELETE
|
|
383
|
-
-- замыкания без вложенного DML (без TM_SelfModified, детерминированно), возвращает снесённое;
|
|
384
|
-
-- - флаг транзакционный (is_local) + явный сброс → дальше в той же tx снова tombstone; привилегий не требует.
|
|
385
|
-
-- Голый SQL: SELECT * FROM "<SCHEMA-NAME>".purge('entity','Order','X'); -- снос
|
|
386
|
-
-- SELECT * FROM "<SCHEMA-NAME>".purge('entity','Order','X', true); -- превью
|
|
387
|
-
-- -----------------------------------------------------------------------------
|
|
388
|
-
-- START_CONTRACT: purge_closure
|
|
389
|
-
-- PURPOSE: (class,id)-замыкание поддерева по links (вкл. tombstone) — что снесёт purge(); переиспользуется самой purge() (снос и dry-превью).
|
|
390
|
-
-- INPUTS: { p_partition text; p_class text; p_ids text[] }
|
|
391
|
-
-- OUTPUTS: { SETOF (class text, id text) - узлы замыкания }
|
|
392
|
-
-- SIDE_EFFECTS: none (STABLE)
|
|
393
|
-
-- LINKS: M-DDL, V-M-DDL, M-WRITE, M-SQL
|
|
394
|
-
-- END_CONTRACT: purge_closure
|
|
395
|
-
CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".purge_closure(p_partition text, p_class text, p_ids text[])
|
|
396
|
-
RETURNS TABLE(class text, id text) LANGUAGE sql STABLE AS $$
|
|
397
|
-
WITH RECURSIVE closure(cls, cid) AS (
|
|
398
|
-
SELECT p_class, x FROM unnest(p_ids) AS x
|
|
399
|
-
UNION -- не ALL → отсекает циклы/диаманты links
|
|
400
|
-
SELECT e.class, e.id
|
|
401
|
-
FROM "<SCHEMA-NAME>"."Entity" e
|
|
402
|
-
JOIN closure c ON e.links @> jsonb_build_object(c.cls, c.cid)
|
|
403
|
-
WHERE e.partition = p_partition
|
|
404
|
-
)
|
|
405
|
-
SELECT cls, cid FROM closure;
|
|
406
|
-
$$;
|
|
407
|
-
|
|
408
|
-
-- START_CONTRACT: purge
|
|
409
|
-
-- PURPOSE: Двухфазно физически стереть логически удалённый корень и всё поддерево (по links, все версии); dry=превью.
|
|
410
|
-
-- INPUTS: { p_partition text; p_class text; p_id text; p_dry boolean = false }
|
|
411
|
-
-- OUTPUTS: { SETOF Entity - снесённые (по одному на сущность, latest) либо превью (dry); пусто если не tombstone }
|
|
412
|
-
-- SIDE_EFFECTS: при dry=false — SET LOCAL letopis.purge (отключает entity_delete на tx) + физический DELETE; advisory-lock
|
|
413
|
-
-- LINKS: M-DDL, V-M-DDL, M-WRITE, M-TABLES
|
|
414
|
-
-- END_CONTRACT: purge
|
|
415
|
-
CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".purge(
|
|
416
|
-
p_partition text, p_class text, p_id text, p_dry boolean DEFAULT false)
|
|
417
|
-
RETURNS SETOF "<SCHEMA-NAME>"."Entity" LANGUAGE plpgsql AS $$
|
|
418
|
-
BEGIN
|
|
419
|
-
PERFORM pg_advisory_xact_lock(
|
|
420
|
-
hashtextextended(p_partition || '|' || p_class || '|' || p_id, 0));
|
|
421
|
-
|
|
422
|
-
-- двухфазность: hard-purge только ПОСЛЕ логического удаления — актуальная версия обязана быть tombstone.
|
|
423
|
-
-- Живую (не удалённую) сущность purge НЕ трогает (возвращает 0 строк).
|
|
424
|
-
IF NOT EXISTS (
|
|
425
|
-
SELECT 1 FROM "<SCHEMA-NAME>"."Entity" e
|
|
426
|
-
WHERE e.partition = p_partition AND e.class = p_class AND e.id = p_id AND e.deleted IS NOT NULL
|
|
427
|
-
AND e.updated = (SELECT max(updated) FROM "<SCHEMA-NAME>"."Entity"
|
|
428
|
-
WHERE partition = p_partition AND class = p_class AND id = p_id)
|
|
429
|
-
) THEN
|
|
430
|
-
RETURN;
|
|
431
|
-
END IF;
|
|
432
|
-
|
|
433
|
-
IF p_dry THEN
|
|
434
|
-
-- превью: актуальная версия каждого члена замыкания, БД не трогаем
|
|
435
|
-
RETURN QUERY
|
|
436
|
-
SELECT DISTINCT ON (e.class, e.id) e.* FROM "<SCHEMA-NAME>"."Entity" e
|
|
437
|
-
JOIN "<SCHEMA-NAME>".purge_closure(p_partition, p_class, ARRAY[p_id]) c
|
|
438
|
-
ON e.class = c.class AND e.id = c.id
|
|
439
|
-
WHERE e.partition = p_partition
|
|
440
|
-
ORDER BY e.class, e.id, e.updated DESC;
|
|
441
|
-
RETURN;
|
|
442
|
-
END IF;
|
|
443
|
-
|
|
444
|
-
PERFORM set_config('letopis.purge', 'on', true); -- SET LOCAL: entity_delete отключён на эту tx
|
|
445
|
-
RETURN QUERY
|
|
446
|
-
WITH del AS (
|
|
447
|
-
DELETE FROM "<SCHEMA-NAME>"."Entity" e
|
|
448
|
-
USING "<SCHEMA-NAME>".purge_closure(p_partition, p_class, ARRAY[p_id]) c
|
|
449
|
-
WHERE e.partition = p_partition AND e.class = c.class AND e.id = c.id -- триггер пропущен (WHEN)
|
|
450
|
-
RETURNING e.*
|
|
451
|
-
)
|
|
452
|
-
SELECT DISTINCT ON (class, id) * FROM del ORDER BY class, id, updated DESC;
|
|
453
|
-
PERFORM set_config('letopis.purge', '', true); -- сброс: дальше в той же tx снова tombstone
|
|
454
|
-
RETURN;
|
|
455
|
-
END $$;
|
|
456
|
-
|
|
457
|
-
-- =============================================================================
|
|
458
|
-
-- Служебные таблицы (auth/ACL) — перенесены из legacy-дампа 1:1.
|
|
459
|
-
-- Обычные таблицы: UPDATE/DELETE разрешены, версии не ведутся.
|
|
460
|
-
-- =============================================================================
|
|
461
|
-
|
|
462
|
-
-- Аккаунты: пользователи и сервисы. Категории: System | User | Anonymous | Shadow…
|
|
463
|
-
-- START_BLOCK_AUTH_TABLES
|
|
464
|
-
CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Account" (
|
|
465
|
-
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
|
466
|
-
categories character varying[] DEFAULT ARRAY['User'::text] NOT NULL,
|
|
467
|
-
data jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
468
|
-
meta jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
469
|
-
avatar character varying(255) DEFAULT ('https://i.pravatar.cc/128?img='::text || (floor(((70)::double precision * random())) + (1)::double precision)) NOT NULL,
|
|
470
|
-
enabled boolean DEFAULT true NOT NULL,
|
|
471
|
-
created timestamptz DEFAULT now() NOT NULL,
|
|
472
|
-
updated timestamptz DEFAULT now() NOT NULL
|
|
473
|
-
);
|
|
474
|
-
|
|
475
|
-
-- Ссылочная целостность Entity → Account. ТОЛЬКО RESTRICT:
|
|
476
|
-
-- CASCADE/SET NULL невозможны — Entity append-only (физические UPDATE/DELETE запрещены триггером).
|
|
477
|
-
-- Аккаунт со строками Entity (включая историю/tombstone) не удаляется — вместо этого enabled = false.
|
|
478
|
-
ALTER TABLE "<SCHEMA-NAME>"."Entity" DROP CONSTRAINT IF EXISTS entity_account_fk;
|
|
479
|
-
ALTER TABLE "<SCHEMA-NAME>"."Entity" ADD CONSTRAINT entity_account_fk
|
|
480
|
-
FOREIGN KEY (account) REFERENCES "<SCHEMA-NAME>"."Account"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
|
|
481
|
-
ALTER TABLE "<SCHEMA-NAME>"."Entity" DROP CONSTRAINT IF EXISTS entity_owner_fk;
|
|
482
|
-
ALTER TABLE "<SCHEMA-NAME>"."Entity" ADD CONSTRAINT entity_owner_fk
|
|
483
|
-
FOREIGN KEY (owner) REFERENCES "<SCHEMA-NAME>"."Account"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
|
|
484
|
-
|
|
485
|
-
-- Способы входа аккаунта: EMAILPASSWORD | APIKEY | KEYSECRET | TELEGRAM …
|
|
486
|
-
-- Мягкое удаление колонкой deleted; уникальность способа на аккаунт.
|
|
487
|
-
CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Credential" (
|
|
488
|
-
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
|
489
|
-
account uuid NOT NULL REFERENCES "<SCHEMA-NAME>"."Account"(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
490
|
-
category character varying(255) DEFAULT 'EMAIL'::character varying NOT NULL,
|
|
491
|
-
identifier character varying(255) NOT NULL,
|
|
492
|
-
meta jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
493
|
-
confirmed boolean DEFAULT true NOT NULL,
|
|
494
|
-
created timestamptz DEFAULT now() NOT NULL,
|
|
495
|
-
updated timestamptz DEFAULT now() NOT NULL,
|
|
496
|
-
deleted timestamptz,
|
|
497
|
-
CONSTRAINT credential_unique UNIQUE (account, category, identifier)
|
|
498
|
-
);
|
|
499
|
-
CREATE INDEX IF NOT EXISTS credential_account_idx ON "<SCHEMA-NAME>"."Credential" (account) WHERE deleted IS NULL;
|
|
500
|
-
-- Вход по identifier должен быть однозначен: один живой кред на (category, identifier) во всей схеме.
|
|
501
|
-
CREATE UNIQUE INDEX IF NOT EXISTS credential_identity_udx ON "<SCHEMA-NAME>"."Credential" (category, identifier) WHERE deleted IS NULL;
|
|
502
|
-
|
|
503
|
-
-- Именованные ресурсы ACL: субъекты ('authenticated:ACCOUNT') и объекты ('payment:API').
|
|
504
|
-
CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Resource" (
|
|
505
|
-
alias character varying(255) NOT NULL PRIMARY KEY,
|
|
506
|
-
category character varying(255) NOT NULL,
|
|
507
|
-
pattern jsonb,
|
|
508
|
-
meta jsonb
|
|
509
|
-
);
|
|
510
|
-
|
|
511
|
-
-- Правила ACL: субъект → объект → allow|deny (+вес). Оба конца — Resource.alias (как в дампе).
|
|
512
|
-
CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Rule" (
|
|
513
|
-
account character varying(255) NOT NULL REFERENCES "<SCHEMA-NAME>"."Resource"(alias) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
514
|
-
resource character varying(255) NOT NULL REFERENCES "<SCHEMA-NAME>"."Resource"(alias) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
515
|
-
permission character varying(255) NOT NULL,
|
|
516
|
-
weight integer,
|
|
517
|
-
meta jsonb,
|
|
518
|
-
enabled boolean DEFAULT true NOT NULL,
|
|
519
|
-
PRIMARY KEY (account, resource)
|
|
520
|
-
);
|
|
521
|
-
-- END_BLOCK_AUTH_TABLES
|
|
522
|
-
|
|
523
|
-
-- -----------------------------------------------------------------------------
|
|
524
|
-
-- purge_account(): полный физический офбординг тенанта — все Entity (account|owner) + сам Account.
|
|
525
|
-
-- Credential уходит FK-каскадом (ON DELETE CASCADE). Флаг letopis.purge отключает entity_delete на
|
|
526
|
-
-- снос Entity (см. WHEN). Права/предохранители (Owner-only, последний Owner, не-себя) — в либе
|
|
527
|
-
-- (accounts.purge, им нужен ctx/ACL). Возвращает id снесённого Account либо NULL.
|
|
528
|
-
-- -----------------------------------------------------------------------------
|
|
529
|
-
-- START_CONTRACT: purge_account
|
|
530
|
-
-- PURPOSE: Физически стереть тенанта: все Entity с account|owner = id + сам Account (Credential — FK-каскад).
|
|
531
|
-
-- INPUTS: { p_id uuid }
|
|
532
|
-
-- OUTPUTS: { uuid - id снесённого Account, либо NULL если не найден }
|
|
533
|
-
-- SIDE_EFFECTS: SET LOCAL letopis.purge (отключает entity_delete); физический DELETE Entity + Account
|
|
534
|
-
-- LINKS: M-DDL, V-M-DDL, M-TABLES
|
|
535
|
-
-- END_CONTRACT: purge_account
|
|
536
|
-
CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".purge_account(p_id uuid)
|
|
537
|
-
RETURNS uuid LANGUAGE plpgsql AS $$
|
|
538
|
-
DECLARE gone uuid;
|
|
539
|
-
BEGIN
|
|
540
|
-
PERFORM set_config('letopis.purge', 'on', true); -- SET LOCAL: entity_delete отключён (см. WHEN)
|
|
541
|
-
DELETE FROM "<SCHEMA-NAME>"."Entity" WHERE account = p_id OR owner = p_id; -- обе оси (иначе entity_owner_fk RESTRICT)
|
|
542
|
-
PERFORM set_config('letopis.purge', '', true);
|
|
543
|
-
DELETE FROM "<SCHEMA-NAME>"."Account" WHERE id = p_id RETURNING id INTO gone; -- Credential — FK-каскад
|
|
544
|
-
RETURN gone;
|
|
545
|
-
END $$;
|
|
546
|
-
|
|
547
|
-
-- -----------------------------------------------------------------------------
|
|
548
|
-
-- Триггер 4: realtime — факт каждой новой версии в pg_notify (канал = имя схемы).
|
|
549
|
-
-- Payload лёгкий (без data): подписчик дочитывает нужное сам. Потребитель: db.watch().
|
|
550
|
-
-- -----------------------------------------------------------------------------
|
|
551
|
-
-- START_CONTRACT: entity_notify
|
|
552
|
-
-- PURPOSE: Опубликовать факт новой версии в pg_notify (канал = имя схемы), лёгкий payload без data.
|
|
553
|
-
-- INPUTS: { trigger: AFTER INSERT ON Entity (NEW) }
|
|
554
|
-
-- OUTPUTS: { NULL }
|
|
555
|
-
-- SIDE_EFFECTS: pg_notify(канал = имя схемы)
|
|
556
|
-
-- LINKS: M-DDL, V-M-DDL, M-CHAIN
|
|
557
|
-
-- END_CONTRACT: entity_notify
|
|
558
|
-
CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_notify() RETURNS trigger
|
|
559
|
-
LANGUAGE plpgsql AS $$
|
|
560
|
-
BEGIN
|
|
561
|
-
-- канал = имя схемы ЛИТЕРАЛОМ: триггер исполняется на чанке hypertable,
|
|
562
|
-
-- где TG_TABLE_SCHEMA = '_timescaledb_internal' (apply.mjs заменяет литерал)
|
|
563
|
-
PERFORM pg_notify('<SCHEMA-NAME>', json_build_object(
|
|
564
|
-
'partition', NEW.partition, 'class', NEW.class, 'id', NEW.id,
|
|
565
|
-
'updated', NEW.updated, 'deleted', NEW.deleted IS NOT NULL)::text);
|
|
566
|
-
RETURN NULL;
|
|
567
|
-
END $$;
|
|
568
|
-
|
|
569
|
-
DROP TRIGGER IF EXISTS entity_notify ON "<SCHEMA-NAME>"."Entity";
|
|
570
|
-
CREATE TRIGGER entity_notify
|
|
571
|
-
AFTER INSERT ON "<SCHEMA-NAME>"."Entity"
|
|
572
|
-
FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_notify();
|
|
573
|
-
|
|
574
|
-
-- -----------------------------------------------------------------------------
|
|
575
|
-
-- Метка ревизии движка — ПОСЛЕДНЕЙ строкой: комментарий появляется только если весь
|
|
576
|
-
-- файл применился. Её читает connect() и предупреждает, если схема отстала от либы
|
|
577
|
-
-- (DDL_REVISION в lib/src/types.ts; совпадение сверяет scripts/check-docs.mjs).
|
|
578
|
-
--
|
|
579
|
-
-- Ревизия — про АДДИТИВНЫЕ правки внутри одной версии движка (новые функции/триггеры/
|
|
580
|
-
-- индексы): файл идемпотентен, поэтому такие правки доезжают повторным накатом
|
|
581
|
-
-- (up({ upgrade: true }) / node db/apply.mjs --upgrade), данные при этом целы.
|
|
582
|
-
-- Несовместимая правка СТРУКТУРЫ таблиц — это смена version в имени схемы (v1 → v2),
|
|
583
|
-
-- а не ревизия.
|
|
584
|
-
-- -----------------------------------------------------------------------------
|
|
585
|
-
COMMENT ON SCHEMA "<SCHEMA-NAME>" IS 'letopis ddl_revision=1';
|
|
586
|
-
|
|
587
|
-
-- -----------------------------------------------------------------------------
|
|
588
|
-
-- Опционально (включать по мере роста данных):
|
|
589
|
-
--
|
|
590
|
-
-- Сжатие старых чанков (экономия на версиях занятости/записей):
|
|
591
|
-
-- ALTER TABLE "<SCHEMA-NAME>"."Entity" SET (
|
|
592
|
-
-- timescaledb.compress,
|
|
593
|
-
-- timescaledb.compress_segmentby = 'partition, class, id',
|
|
594
|
-
-- timescaledb.compress_orderby = 'updated DESC'
|
|
595
|
-
-- );
|
|
596
|
-
-- SELECT add_compression_policy('"<SCHEMA-NAME>"."Entity"', INTERVAL '30 days');
|
|
597
|
-
--
|
|
598
|
-
-- Retention (если старые версии не нужны вечно):
|
|
599
|
-
-- SELECT add_retention_policy('"<SCHEMA-NAME>"."Entity"', INTERVAL '2 years');
|
|
600
|
-
-- -----------------------------------------------------------------------------
|