letopis 0.21.0 → 1.0.1

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.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +337 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1938 -0
  6. package/README.md +1493 -3469
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +362 -563
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +178 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -252
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1372 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -352
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -685
  89. package/sql/seed.auth.sql +0 -73
@@ -0,0 +1,1372 @@
1
+ -- letopis 1.0 — конвейер записи (план, §2.5, §2.7, §2.10, этап 1, п. 7–11).
2
+ --
3
+ -- Приложение пишет обычными insert, update, delete; всё проверяют триггеры entity. У каждого
4
+ -- триггера условие when (current_user <> владелец): изменения, которые делает функция владельца
5
+ -- (в том числе сами триггерные функции — они security definer), триггеры не перепроверяют,
6
+ -- поэтому функции владельца обязаны проверять всё сами, сами писать журнал и сами посылать NOTIFY.
7
+
8
+ -- Время версии: обёртка над clock_timestamp(); тест монотонности подменяет её в своей схеме.
9
+ create or replace function @SCHEMA@.now_ts() returns timestamptz
10
+ language sql volatile
11
+ return clock_timestamp();
12
+
13
+ -- Блокировки семейства (этап 1, п. 10): писатели и загрузчик берут разделяемую блокировку корня
14
+ -- семейства, ужесточение описания — исключительную. Форма с двумя int4 — отдельное от
15
+ -- пользовательского lock() (один bigint) пространство ключей advisory-блокировок. p_cls — строка
16
+ -- Class класса семейства. Ключ — арендатор корневого класса и имя корня (итоговое ревью
17
+ -- безопасности): ужесточение или rekeyClass одноимённого класса в другом арендаторе запись сюда не
18
+ -- останавливает; корень из System (общий для всех арендаторов) — ключ System.
19
+ create or replace function @SCHEMA@.family_lock(p_cls @SCHEMA@.entity, p_exclusive boolean) returns void
20
+ language plpgsql volatile
21
+ as $$
22
+ declare
23
+ root text := p_cls.data->'resolved'->'ancestors'->>-1;
24
+ k int;
25
+ begin
26
+ k := hashtext('@SCHEMA_NAME@:' || (case when p_cls.tenant = '@SYSTEM@'::uuid or p_cls.data->>'name' = root then p_cls.tenant
27
+ else coalesce((@SCHEMA@.class_row(root, p_cls.tenant)).tenant, p_cls.tenant) end)::text || ':' || root);
28
+ if p_exclusive then
29
+ perform pg_advisory_xact_lock(19540, k);
30
+ else
31
+ perform pg_advisory_xact_lock_shared(19540, k);
32
+ end if;
33
+ end $$;
34
+ -- до итогового ревью безопасности ключ был без арендатора
35
+ drop function if exists @SCHEMA@.family_lock(text, boolean);
36
+
37
+ -- Список классов конца: строка или массив строк → отсортированный jsonb-массив.
38
+ create or replace function @SCHEMA@.class_set(p jsonb) returns jsonb
39
+ language sql immutable parallel safe
40
+ return (
41
+ select coalesce(jsonb_agg(x order by x), '[]'::jsonb)
42
+ from jsonb_array_elements_text(case jsonb_typeof(p) when 'array' then p else jsonb_build_array(p) end) x
43
+ );
44
+
45
+ -- Входит ли класс цели (с учётом его предков) в список допустимых классов конца.
46
+ create or replace function @SCHEMA@.class_accepts(p_allowed jsonb, p_target_class text, p_target_tenant uuid) returns boolean
47
+ language sql stable
48
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
49
+ as $$
50
+ select exists (
51
+ select 1
52
+ from jsonb_array_elements_text(@SCHEMA@.class_set(p_allowed)) a(name)
53
+ where a.name in (select jsonb_array_elements_text(
54
+ (@SCHEMA@.class_row(p_target_class, p_target_tenant)).data->'resolved'->'ancestors'))
55
+ )
56
+ $$;
57
+
58
+ -- Принимает ли роль ref_role класса ref_class цель класса target_class (с учётом предков цели).
59
+ create or replace function @SCHEMA@.role_accepts(p_ref_class text, p_ref_tenant uuid, p_role text,
60
+ p_target_class text, p_target_tenant uuid) returns boolean
61
+ language sql stable
62
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
63
+ as $$
64
+ select exists (
65
+ select 1
66
+ from jsonb_array_elements_text(@SCHEMA@.class_set(
67
+ (@SCHEMA@.class_row(p_ref_class, p_ref_tenant)).data->'resolved'->'ends'->p_role->'class')) a(name)
68
+ where a.name in (select jsonb_array_elements_text(
69
+ (@SCHEMA@.class_row(p_target_class, p_target_tenant)).data->'resolved'->'ancestors'))
70
+ )
71
+ $$;
72
+
73
+ -- Умолчания из JSON Schema: на каждом уровне для отсутствующих полей; во вложенные объекты —
74
+ -- только если объект уже есть.
75
+ create or replace function @SCHEMA@.apply_defaults(p_schema jsonb, p_data jsonb) returns jsonb
76
+ language plpgsql immutable
77
+ as $$
78
+ declare
79
+ p record;
80
+ d jsonb := p_data;
81
+ begin
82
+ if jsonb_typeof(d) is distinct from 'object' or jsonb_typeof(p_schema->'properties') is distinct from 'object' then
83
+ return d;
84
+ end if;
85
+ for p in select key, value from jsonb_each(p_schema->'properties') loop
86
+ if not (d ? p.key) then
87
+ if p.value ? 'default' then
88
+ d := d || jsonb_build_object(p.key, p.value->'default');
89
+ end if;
90
+ elsif jsonb_typeof(d->p.key) = 'object' then
91
+ d := jsonb_set(d, array[p.key], @SCHEMA@.apply_defaults(p.value, d->p.key));
92
+ end if;
93
+ end loop;
94
+ return d;
95
+ end $$;
96
+
97
+ -- Разбор и нормализация концов (этап 1, п. 7): объявлены, обязательные на месте, у множественных
98
+ -- нет повторов, соблюдены min и max; uuid — в каноническом виде строчными. К таблицам не обращается:
99
+ -- цели проверяет targets_bad() — триггером после оператора, одним запросом на все строки.
100
+ -- Ошибку не бросает: возвращает код и сообщение.
101
+ create or replace function @SCHEMA@.links_shape(p_res jsonb, p_links jsonb, out links jsonb, out code text, out msg text)
102
+ language plpgsql immutable
103
+ as $$
104
+ #variable_conflict use_variable
105
+ declare
106
+ l record;
107
+ e record;
108
+ v jsonb;
109
+ arr jsonb;
110
+ n int;
111
+ begin
112
+ links := '{}'::jsonb;
113
+ if p_links is null or p_links = 'null'::jsonb then
114
+ p_links := '{}'::jsonb;
115
+ end if;
116
+ if jsonb_typeof(p_links) <> 'object' then
117
+ code := 'invalid_data'; msg := 'links — не объект'; return;
118
+ end if;
119
+ for l in select key, value from jsonb_each(p_links) loop
120
+ if not coalesce(p_res->'ends' ? l.key, false) then
121
+ code := 'undeclared_end'; msg := format('конец %s не объявлен в классе %s', l.key, p_res->>'name'); return;
122
+ end if;
123
+ continue when l.value = 'null'::jsonb;
124
+ if coalesce((p_res->'ends'->l.key->>'many')::boolean, false) then
125
+ if jsonb_typeof(l.value) <> 'array' then
126
+ code := 'invalid_data'; msg := format('конец %s — список id', l.key); return;
127
+ end if;
128
+ arr := '[]'::jsonb;
129
+ for v in select value from jsonb_array_elements(l.value) loop
130
+ if jsonb_typeof(v) <> 'string' or not pg_input_is_valid(v #>> '{}', 'uuid') then
131
+ code := 'invalid_data'; msg := format('конец %s: значение не uuid', l.key); return;
132
+ end if;
133
+ v := to_jsonb((v #>> '{}')::uuid::text);
134
+ if arr @> jsonb_build_array(v) then
135
+ code := 'invalid_data'; msg := format('конец %s: цель повторяется', l.key); return;
136
+ end if;
137
+ arr := arr || jsonb_build_array(v);
138
+ end loop;
139
+ links := links || jsonb_build_object(l.key, arr);
140
+ else
141
+ if jsonb_typeof(l.value) <> 'string' or not pg_input_is_valid(l.value #>> '{}', 'uuid') then
142
+ code := 'invalid_data'; msg := format('конец %s: значение не uuid', l.key); return;
143
+ end if;
144
+ links := links || jsonb_build_object(l.key, (l.value #>> '{}')::uuid::text);
145
+ end if;
146
+ end loop;
147
+ for e in select key, value from jsonb_each(coalesce(p_res->'ends', '{}'::jsonb)) loop
148
+ if coalesce((e.value->>'many')::boolean, false) then
149
+ n := coalesce(jsonb_array_length(links->e.key), 0);
150
+ if n < greatest(coalesce((e.value->>'min')::int, 0), case when coalesce((e.value->>'required')::boolean, false) then 1 else 0 end) then
151
+ code := 'invalid_data'; msg := format('у конца %s слишком мало целей', e.key); return;
152
+ end if;
153
+ if e.value ? 'max' and n > (e.value->>'max')::int then
154
+ code := 'invalid_data'; msg := format('у конца %s слишком много целей', e.key); return;
155
+ end if;
156
+ elsif coalesce((e.value->>'required')::boolean, false) and not links ? e.key then
157
+ code := 'invalid_data'; msg := format('обязательный конец %s не задан', e.key); return;
158
+ end if;
159
+ end loop;
160
+ end $$;
161
+
162
+ -- Цели концов набора строк (решение 2 точки А): существуют, принадлежат арендатору строки или
163
+ -- System и относятся к классу конца или его потомку. p_rows — [{class, tenant, links[, ends]}]
164
+ -- (ends — описание концов, если его ещё нет в строке Class). При p_lock цели сначала блокируются
165
+ -- for key share в порядке id — каждая один раз на оператор, только свои и системные: строку чужого
166
+ -- арендатора не заблокировать и по ожиданию не обнаружить. Проверка идёт после блокировки, по
167
+ -- свежим строкам: цель, которую успели удалить или перевести в чужой класс, не пройдёт. Класс цели
168
+ -- прямо в списке конца — без поиска описания; иначе — предки из строки Class цели.
169
+ -- При p_acl ({tenant, account, plan} — план READ актора) цель ещё и должна быть видна актору (этап 5,
170
+ -- п. 6): строку, которую он не видит, нельзя использовать как цель — ответ тот же, что для
171
+ -- несуществующей. Цели-аккаунты (Account) не проверяются: на них ссылаются членства и владельцы.
172
+ -- Возвращает роль первой непрошедшей цели или null.
173
+ create or replace function @SCHEMA@.targets_bad(p_rows jsonb, p_lock boolean, p_acl jsonb default null) returns text
174
+ language plpgsql volatile
175
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
176
+ as $$
177
+ declare
178
+ bad text;
179
+ begin
180
+ if p_rows is null or jsonb_array_length(p_rows) = 0 then
181
+ return null;
182
+ end if;
183
+ if p_lock then
184
+ perform 1 from @SCHEMA@.entity t
185
+ where t.id in (select (v #>> '{}')::uuid from jsonb_array_elements(p_rows) r
186
+ cross join lateral jsonb_path_query(r->'links', 'lax $.*[*]') v)
187
+ and t.tenant in (select (r->>'tenant')::uuid from jsonb_array_elements(p_rows) r union all select '@SYSTEM@'::uuid)
188
+ order by t.id for key share;
189
+ end if;
190
+ with r as (
191
+ select x.value as item, (x.value->>'tenant')::uuid as tenant from jsonb_array_elements(p_rows) x
192
+ ), c as (
193
+ select d.cls, d.tenant, coalesce(d.ends, (@SCHEMA@.class_row(d.cls, d.tenant)).data->'resolved'->'ends') as ends
194
+ from (select distinct r.item->>'class' as cls, r.tenant, r.item->'ends' as ends from r) d
195
+ ), p as (
196
+ select r.item->>'class' as cls, r.tenant, r.item->'ends' as ends, k.key as role,
197
+ (case jsonb_typeof(k.value) when 'array' then a.value else k.value end #>> '{}')::uuid as tid
198
+ from r
199
+ cross join lateral jsonb_each(r.item->'links') k
200
+ left join lateral jsonb_array_elements(case jsonb_typeof(k.value) when 'array' then k.value else '[]'::jsonb end) a on true
201
+ where jsonb_typeof(k.value) = 'string' or a.value is not null
202
+ )
203
+ select p.role into bad
204
+ from p
205
+ join c on c.cls = p.cls and c.tenant = p.tenant and c.ends is not distinct from coalesce(p.ends, c.ends)
206
+ left join @SCHEMA@.entity t on t.id = p.tid
207
+ where t.id is null
208
+ or t.tenant not in (p.tenant, '@SYSTEM@'::uuid)
209
+ or not coalesce(@SCHEMA@.class_set(c.ends->p.role->'class') ? t.class
210
+ or @SCHEMA@.class_set(c.ends->p.role->'class') ?|
211
+ (select array(select jsonb_array_elements_text(k.data->'resolved'->'ancestors'))
212
+ from @SCHEMA@.entity k
213
+ where k.class = 'Class' and k.data->>'name' = t.class and k.tenant in (t.tenant, '@SYSTEM@'::uuid)
214
+ order by k.tenant = '@SYSTEM@'::uuid limit 1), false)
215
+ or (p_acl is not null and t.class <> 'Account'
216
+ and not @SCHEMA@.row_visible(t.tenant, t.class, t.id, t.owner, t.tags, t.data, t.links,
217
+ (p_acl->>'tenant')::uuid, (p_acl->>'account')::uuid, p_acl->'plan'))
218
+ limit 1;
219
+ return bad;
220
+ end $$;
221
+ -- до этапа 5 цели не сверялись с правами актора
222
+ drop function if exists @SCHEMA@.targets_bad(jsonb, boolean);
223
+
224
+ -- Разбор концов и проверка целей одной строки — для функций владельца (reclass, проверка строк
225
+ -- семейства), которые пишут мимо триггеров. При p_lock цели блокируются for key share; p_acl — как
226
+ -- у targets_bad (цели видны актору).
227
+ create or replace function @SCHEMA@.check_links(p_res jsonb, p_links jsonb, p_tenant uuid, p_lock boolean, p_acl jsonb,
228
+ out links jsonb, out code text, out msg text)
229
+ language plpgsql volatile
230
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
231
+ as $$
232
+ #variable_conflict use_variable
233
+ declare
234
+ bad text;
235
+ begin
236
+ select s.links, s.code, s.msg into links, code, msg from @SCHEMA@.links_shape(p_res, p_links) s;
237
+ if code is not null or links = '{}'::jsonb then
238
+ return;
239
+ end if;
240
+ bad := @SCHEMA@.targets_bad(jsonb_build_array(jsonb_build_object(
241
+ 'class', p_res->>'name', 'tenant', p_tenant, 'links', links, 'ends', coalesce(p_res->'ends', '{}'::jsonb))), p_lock, p_acl);
242
+ if bad is not null then
243
+ code := 'target_not_found'; msg := format('цель конца %s не найдена', bad);
244
+ end if;
245
+ end $$;
246
+ drop function if exists @SCHEMA@.check_links(jsonb, jsonb, uuid, boolean);
247
+
248
+ -- id класса с ключом — v5 от арендатора, класса и значений ключа (§2.3); у класса без ключа — null.
249
+ create or replace function @SCHEMA@.key_id(p_res jsonb, p_class text, p_tenant uuid, p_data jsonb, p_links jsonb) returns uuid
250
+ language sql immutable
251
+ return case when jsonb_array_length(coalesce(p_res->'key', '[]'::jsonb)) = 0 then null else
252
+ @SCHEMA@.v5_id(p_tenant, p_class, array(
253
+ select case when p_res->'schema'->'properties' ? u.k then p_data->>u.k else lower(p_links->>u.k) end
254
+ from jsonb_array_elements_text(p_res->'key') with ordinality as u(k, i)
255
+ order by u.i)) end;
256
+
257
+ -- Ведёт ли класс журнал (history: false — нет).
258
+ create or replace function @SCHEMA@.history_on(p_class text, p_tenant uuid) returns boolean
259
+ language sql stable
260
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
261
+ return coalesce((@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved'->>'history', 'true') <> 'false';
262
+
263
+ -- Сигнал с именами изменённых классов (§2.12, решение 9 точки А). Транзакция с NOTIFY фиксируется
264
+ -- под общей блокировкой кластера вместе с fsync, поэтому библиотека помечает свои транзакции
265
+ -- letopis.notify = 'deferred': имена копятся в letopis.changed, а библиотека после фиксации
266
+ -- отправляет их одной транзакцией без данных. Сырой SQL шлёт NOTIFY здесь же, вместе с фиксацией.
267
+ create or replace function @SCHEMA@.signal(p_classes text[]) returns void
268
+ language plpgsql volatile
269
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
270
+ as $$
271
+ declare
272
+ acc text[];
273
+ cl text;
274
+ begin
275
+ if p_classes is null or cardinality(p_classes) = 0 then
276
+ return;
277
+ end if;
278
+ if coalesce(current_setting('letopis.notify', true), '') = 'deferred' then
279
+ acc := coalesce(string_to_array(nullif(current_setting('letopis.changed', true), ''), ','), '{}');
280
+ if not (acc @> p_classes) then
281
+ perform set_config('letopis.changed',
282
+ array_to_string(array(select distinct x from unnest(acc || p_classes) x order by x), ','), true);
283
+ end if;
284
+ else
285
+ foreach cl in array p_classes loop
286
+ perform pg_notify('@SCHEMA_NAME@', cl);
287
+ end loop;
288
+ end if;
289
+ end $$;
290
+
291
+ -- Версия одной строки в журнал и сигнал — для функций владельца, которые пишут строку сами
292
+ -- (пересчёт потомков, снятие конца, перенос на новый id). Классы с history: false журнал не ведут,
293
+ -- но сигнал посылают. Обычную запись журналируют триггеры после оператора — одной вставкой.
294
+ create or replace function @SCHEMA@.log_version(e @SCHEMA@.entity) returns void
295
+ language plpgsql volatile
296
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
297
+ as $$
298
+ begin
299
+ if @SCHEMA@.history_on(e.class, e.tenant) then
300
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
301
+ values (e.id, e.rev, e.class, e.cv, e.tenant, e.owner, e.links, e.data, e.tags, e.at, e.author, e.agent, e.op, e.reason, e.moved, e.prev_hash);
302
+ end if;
303
+ perform @SCHEMA@.signal(array[e.class]);
304
+ end $$;
305
+
306
+ -- Новая версия строки от имени владельца (пересчёт описаний потомков, снятие конца при удалении):
307
+ -- служебные колонки, журнал, сигнал. Триггеры при этом не срабатывают.
308
+ create or replace function @SCHEMA@.sys_update(p_id uuid, p_data jsonb, p_links jsonb, p_validator jsonpath, p_op text,
309
+ p_class text default null)
310
+ returns @SCHEMA@.entity
311
+ language plpgsql volatile
312
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
313
+ as $$
314
+ declare
315
+ e @SCHEMA@.entity;
316
+ c record;
317
+ begin
318
+ select * into c from @SCHEMA@.ctx();
319
+ update @SCHEMA@.entity x
320
+ set class = coalesce(p_class, x.class), data = p_data, links = p_links, validator = p_validator,
321
+ rev = x.rev + 1, prev_hash = x.hash,
322
+ at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
323
+ author = coalesce(c.author, '@SYSTEM@'::uuid), agent = c.agent, op = p_op,
324
+ reason = nullif(current_setting('letopis.reason', true), ''), moved = null,
325
+ cv = coalesce((@SCHEMA@.class_row(coalesce(p_class, x.class), x.tenant)).rev, x.cv)
326
+ where x.id = p_id
327
+ returning x.* into e;
328
+ perform @SCHEMA@.log_version(e);
329
+ return e;
330
+ end $$;
331
+
332
+ -- Надгробие: последний снимок удалённой строки следующей версией с видом delete; при переносе на
333
+ -- новый id (reclass, rekey) moved ссылается на новый id.
334
+ create or replace function @SCHEMA@.tombstone(e @SCHEMA@.entity, p_op text default 'delete', p_moved uuid default null) returns void
335
+ language plpgsql volatile
336
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
337
+ as $$
338
+ declare
339
+ c record;
340
+ begin
341
+ select * into c from @SCHEMA@.ctx();
342
+ e.rev := e.rev + 1;
343
+ e.prev_hash := e.hash;
344
+ e.at := greatest(@SCHEMA@.now_ts(), e.at + interval '1 microsecond');
345
+ e.author := coalesce(c.author, '@SYSTEM@'::uuid);
346
+ e.agent := c.agent;
347
+ e.op := p_op;
348
+ e.reason := nullif(current_setting('letopis.reason', true), '');
349
+ e.moved := p_moved;
350
+ perform @SCHEMA@.log_version(e);
351
+ end $$;
352
+
353
+ -- Входящие ссылки на строку p_id, роли которых не принимают класс p_class (смена класса, смена родителя).
354
+ create or replace function @SCHEMA@.incoming_conflicts(p_id uuid, p_class text, p_tenant uuid) returns jsonb
355
+ language sql stable
356
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
357
+ as $$
358
+ select coalesce(jsonb_agg(jsonb_build_object('id', r.id, 'class', r.class, 'role', k.key, 'tenant', r.tenant)), '[]'::jsonb)
359
+ from @SCHEMA@.entity r
360
+ cross join lateral jsonb_each(r.links) k
361
+ where r.ends @> array[p_id]
362
+ and (k.value = to_jsonb(p_id::text) or (jsonb_typeof(k.value) = 'array' and k.value @> jsonb_build_array(p_id::text)))
363
+ and not @SCHEMA@.role_accepts(r.class, r.tenant, k.key, p_class, p_tenant)
364
+ $$;
365
+
366
+ -- Перечень видимых актору строк и число остальных — для ошибок restrict, смены класса, ужесточения.
367
+ -- Видимость — та же, что у чтения (row_visible с планом READ актора), а не только арендатор.
368
+ create or replace function @SCHEMA@.visible_detail(p_rows jsonb, p_tenant uuid) returns jsonb
369
+ language plpgsql stable
370
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
371
+ as $$
372
+ declare
373
+ a jsonb := @SCHEMA@.acl_actor('READ');
374
+ vis jsonb;
375
+ begin
376
+ select coalesce(jsonb_agg(r - 'tenant'), '[]'::jsonb) into vis
377
+ from jsonb_array_elements(p_rows) r
378
+ join @SCHEMA@.entity e on e.id = (r->>'id')::uuid
379
+ where (r->>'tenant')::uuid = p_tenant and a is not null
380
+ and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links,
381
+ (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan');
382
+ return jsonb_build_object('rows', vis, 'others', jsonb_array_length(p_rows) - jsonb_array_length(vis));
383
+ end $$;
384
+
385
+ -- Безопасно ли изменение описания класса (этап 1, п. 10): новые необязательные поля и концы,
386
+ -- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
387
+ -- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись).
388
+ create or replace function @SCHEMA@.class_change_safe(o jsonb, n jsonb) returns boolean
389
+ language sql immutable
390
+ return
391
+ o->'key' = n->'key'
392
+ and o->'ancestors' = n->'ancestors'
393
+ and (o->'abstract' = n->'abstract' or n->>'abstract' = 'false')
394
+ and o->'schema'->'anyOf' is not distinct from n->'schema'->'anyOf'
395
+ and o->'schema'->'oneOf' is not distinct from n->'schema'->'oneOf'
396
+ and coalesce(o->'schema'->'required', '[]'::jsonb) @> coalesce(n->'schema'->'required', '[]'::jsonb)
397
+ and not exists (select 1 from jsonb_each(coalesce(o->'schema'->'properties', '{}'::jsonb)) p
398
+ where n->'schema'->'properties'->p.key is distinct from p.value)
399
+ and not exists (select 1 from jsonb_each(coalesce(o->'ends', '{}'::jsonb)) e
400
+ where n->'ends'->e.key is distinct from e.value)
401
+ and not exists (select 1 from jsonb_each(coalesce(n->'ends', '{}'::jsonb)) e
402
+ where not coalesce(o->'ends' ? e.key, false)
403
+ and (coalesce((e.value->>'required')::boolean, false) or coalesce((e.value->>'min')::int, 0) > 0));
404
+
405
+ -- Строки семейства, которые не проходят текущие описания своих классов (после ужесточения).
406
+ -- Не больше 100 строк; p_incoming — проверять ещё и входящие ссылки (смена родителя).
407
+ create or replace function @SCHEMA@.family_conflicts(p_classes text[], p_tenant uuid, p_incoming boolean) returns jsonb
408
+ language plpgsql volatile
409
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
410
+ as $$
411
+ declare
412
+ r @SCHEMA@.entity;
413
+ cls @SCHEMA@.entity;
414
+ res jsonb;
415
+ bad jsonb;
416
+ lk record;
417
+ problem text;
418
+ inc jsonb;
419
+ outv jsonb := '[]'::jsonb;
420
+ begin
421
+ for r in select e.* from @SCHEMA@.entity e
422
+ where e.class = any (p_classes) and (e.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
423
+ order by e.id loop
424
+ cls := @SCHEMA@.class_row(r.class, r.tenant);
425
+ res := cls.data->'resolved';
426
+ problem := null;
427
+ if coalesce((res->>'abstract')::boolean, false) then
428
+ problem := 'класс абстрактный';
429
+ else
430
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', r.data);
431
+ if bad <> '[]'::jsonb then
432
+ problem := 'данные: ' || bad::text;
433
+ else
434
+ select * into lk from @SCHEMA@.check_links(res, r.links, r.tenant, false, null);
435
+ if lk.code is not null then
436
+ problem := lk.msg;
437
+ elsif coalesce(@SCHEMA@.key_id(res, r.class, r.tenant, r.data, r.links), r.id) <> r.id then
438
+ problem := 'id не соответствует ключу класса';
439
+ end if;
440
+ end if;
441
+ end if;
442
+ if problem is null and p_incoming then
443
+ inc := @SCHEMA@.incoming_conflicts(r.id, r.class, r.tenant);
444
+ if inc <> '[]'::jsonb then
445
+ problem := format('на строку ссылается роль %s строки %s, которая не принимает класс', inc->0->>'role', inc->0->>'id');
446
+ end if;
447
+ end if;
448
+ if problem is not null then
449
+ outv := outv || jsonb_build_object('id', r.id, 'class', r.class, 'tenant', r.tenant, 'problem', problem);
450
+ exit when jsonb_array_length(outv) >= 100;
451
+ end if;
452
+ end loop;
453
+ return outv;
454
+ end $$;
455
+
456
+ -- Методы цепочки — зарезервированные имена (§2.14). Список подставляет установщик из
457
+ -- RESERVED_CLASS_NAMES (lib/src/types.ts): при изменении списка функция пересоздаётся накатом.
458
+ create or replace function @SCHEMA@.reserved_names() returns text[]
459
+ language sql immutable parallel safe
460
+ return array[@RESERVED_NAMES@]::text[];
461
+
462
+ -- Проверки строки Class сверх метасхемы (этап 1, п. 6): признак system, имя системного класса,
463
+ -- имена связей против методов цепочки, однозначность имени конца во всей схеме.
464
+ create or replace function @SCHEMA@.class_admit(p_data jsonb, p_tenant uuid, out data jsonb, out validator jsonpath)
465
+ language plpgsql volatile
466
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
467
+ as $$
468
+ #variable_conflict use_variable
469
+ declare
470
+ e record;
471
+ o record;
472
+ begin
473
+ if p_tenant <> '@SYSTEM@'::uuid then
474
+ if (p_data->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
475
+ perform @SCHEMA@.lt_fail('invalid_class', format('имя %s зарезервировано за системным классом', p_data->>'name'));
476
+ end if;
477
+ if coalesce((p_data->>'system')::boolean, false) then
478
+ perform @SCHEMA@.lt_fail('invalid_class', 'признак system задаёт только установка');
479
+ end if;
480
+ if exists (select 1 from @SCHEMA@.entity s
481
+ where s.id = @SCHEMA@.v5_id('@SYSTEM@'::uuid, 'Class', array[p_data->>'name']) and s.class = 'Class') then
482
+ perform @SCHEMA@.lt_fail('invalid_class', format('имя %s занято системным классом', p_data->>'name'));
483
+ end if;
484
+ end if;
485
+ select c.data, c.validator into data, validator from @SCHEMA@.class_prepare(p_data, p_tenant) c;
486
+ if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
487
+ perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
488
+ end if;
489
+ for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
490
+ select x.data->>'name' as name into o
491
+ from @SCHEMA@.entity x
492
+ where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid)
493
+ and x.data->>'name' <> data->>'name'
494
+ and x.data->'resolved'->'ends' ? e.key
495
+ and @SCHEMA@.class_set(x.data->'resolved'->'ends'->e.key->'class') <> @SCHEMA@.class_set(e.value->'class')
496
+ limit 1;
497
+ if o.name is not null then
498
+ perform @SCHEMA@.lt_fail('invalid_class', format('конец %s в классе %s ведёт на другой класс', e.key, o.name));
499
+ end if;
500
+ end loop;
501
+ end $$;
502
+
503
+ -- ========================= триггеры =========================
504
+
505
+ create or replace function @SCHEMA@.trg_entity_bi() returns trigger
506
+ language plpgsql security definer
507
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
508
+ as $$
509
+ declare
510
+ c record;
511
+ cls @SCHEMA@.entity;
512
+ res jsonb;
513
+ bad jsonb;
514
+ lk record;
515
+ kid uuid;
516
+ last record;
517
+ ts timestamptz;
518
+ begin
519
+ select * into c from @SCHEMA@.ctx();
520
+ if c.account is null then
521
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
522
+ end if;
523
+ NEW.tenant := coalesce(NEW.tenant, c.tenant);
524
+ if NEW.tenant <> c.tenant then
525
+ perform @SCHEMA@.lt_fail('tenant_denied', 'запись в чужого арендатора');
526
+ end if;
527
+ -- аккаунты (с их категориями) — только в System: их пишет системный администратор (этап 5)
528
+ if NEW.class = 'Account' and NEW.tenant <> '@SYSTEM@'::uuid then
529
+ perform @SCHEMA@.lt_fail('tenant_denied', 'аккаунты лежат в арендаторе System');
530
+ end if;
531
+ cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
532
+ if cls.id is null then
533
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
534
+ end if;
535
+ -- разделяемая блокировка корня семейства, затем описание перечитывается под ней
536
+ perform @SCHEMA@.family_lock(cls, false);
537
+ cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
538
+ res := cls.data->'resolved';
539
+ if coalesce((res->>'abstract')::boolean, false) then
540
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
541
+ end if;
542
+ if NEW.class = 'Class' then
543
+ select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(coalesce(NEW.data, '{}'::jsonb), NEW.tenant) a;
544
+ else
545
+ NEW.data := @SCHEMA@.apply_defaults(res->'schema', coalesce(NEW.data, '{}'::jsonb));
546
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
547
+ if bad <> '[]'::jsonb then
548
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', NEW.class), jsonb_build_object('issues', bad));
549
+ end if;
550
+ NEW.validator := null;
551
+ end if;
552
+ -- цели проверяет и блокирует триггер после оператора (targets_bad) — один раз на оператор
553
+ select * into lk from @SCHEMA@.links_shape(res, NEW.links);
554
+ if lk.code is not null then
555
+ perform @SCHEMA@.lt_fail(lk.code, lk.msg);
556
+ end if;
557
+ NEW.links := lk.links;
558
+ if NEW.class = 'member' and NEW.links->>'Tenant' is distinct from NEW.tenant::text then
559
+ perform @SCHEMA@.lt_fail('invalid_data', 'конец Tenant членства — арендатор строки');
560
+ end if;
561
+ kid := @SCHEMA@.key_id(res, NEW.class, NEW.tenant, NEW.data, NEW.links);
562
+ if kid is null then
563
+ if NEW.id is not null then
564
+ perform @SCHEMA@.lt_fail('id_from_db', format('id класса без ключа %s выдаёт база', NEW.class));
565
+ end if;
566
+ NEW.id := @SCHEMA@.v5_id(NEW.tenant, NEW.class, array[gen_random_uuid()::text]);
567
+ else
568
+ if NEW.id is not null and NEW.id <> kid then
569
+ perform @SCHEMA@.lt_fail('id_mismatch', format('id не равен v5 от ключа класса %s', NEW.class), jsonb_build_object('expected', kid));
570
+ end if;
571
+ NEW.id := kid;
572
+ end if;
573
+ -- воскрешение: номер версии и цепочка хэшей продолжаются от последней записи журнала; запись
574
+ -- блокируется — параллельные purge, обрезка и restore того же id ждут (этап 6), а если purge успел
575
+ -- стереть её, пара (id, rev) столкнётся с его записью purge в журнале, и операция повторится
576
+ select l.rev, l.hash, l.at into last from @SCHEMA@.log l where l.id = NEW.id order by l.rev desc limit 1 for update;
577
+ ts := @SCHEMA@.now_ts();
578
+ NEW.rev := coalesce(last.rev, 0) + 1;
579
+ NEW.prev_hash := last.hash;
580
+ NEW.at := greatest(ts, last.at + interval '1 microsecond');
581
+ NEW.created_at := NEW.at;
582
+ NEW.cv := cls.rev;
583
+ if NEW.owner is null then
584
+ NEW.owner := case res->>'ownerDefault' when 'actor' then c.account else NEW.tenant end;
585
+ elsif NEW.owner not in (NEW.tenant, c.account)
586
+ and not exists (select 1 from @SCHEMA@.entity a where a.id = NEW.owner and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
587
+ -- явный владелец — существующий аккаунт; разрешает ли его условие ACL, решает политика вставки
588
+ perform @SCHEMA@.lt_fail('target_not_found', 'владелец строки не найден');
589
+ end if;
590
+ NEW.author := c.author;
591
+ NEW.agent := c.agent;
592
+ NEW.op := 'create';
593
+ NEW.reason := nullif(current_setting('letopis.reason', true), '');
594
+ NEW.moved := null;
595
+ NEW.tags := coalesce(NEW.tags, '{}');
596
+ return NEW;
597
+ end $$;
598
+
599
+ create or replace function @SCHEMA@.trg_entity_bu() returns trigger
600
+ language plpgsql security definer
601
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
602
+ as $$
603
+ declare
604
+ c record;
605
+ cls @SCHEMA@.entity;
606
+ res jsonb;
607
+ old_res jsonb;
608
+ bad jsonb;
609
+ lk record;
610
+ kid uuid;
611
+ op text := 'update';
612
+ inc jsonb;
613
+ begin
614
+ select * into c from @SCHEMA@.ctx();
615
+ if c.account is null then
616
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
617
+ end if;
618
+ if NEW.id <> OLD.id or NEW.tenant <> OLD.tenant or NEW.owner <> OLD.owner or NEW.created_at <> OLD.created_at then
619
+ perform @SCHEMA@.lt_fail('immutable_key', 'id, арендатора, владельца и время создания менять нельзя');
620
+ end if;
621
+ if (NEW.class = 'Class') <> (OLD.class = 'Class') then
622
+ perform @SCHEMA@.lt_fail('invalid_class', 'строку Class нельзя превратить в данные и наоборот');
623
+ end if;
624
+ -- строки прав не получаются сменой класса: аккаунт с категориями — только в System (ревью этапа 5)
625
+ if NEW.class <> OLD.class and (NEW.class in ('Account', 'member', 'Resource', 'rule') or OLD.class in ('Account', 'member', 'Resource', 'rule')) then
626
+ perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
627
+ end if;
628
+ cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
629
+ if cls.id is null then
630
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
631
+ end if;
632
+ perform @SCHEMA@.family_lock(cls, false);
633
+ cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
634
+ res := cls.data->'resolved';
635
+ if coalesce((res->>'abstract')::boolean, false) then
636
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
637
+ end if;
638
+ if NEW.class <> OLD.class then
639
+ -- смена класса (этап 1, п. 9): только read committed, только для классов без ключа
640
+ if current_setting('transaction_isolation') <> 'read committed' then
641
+ perform @SCHEMA@.lt_fail('isolation_level', 'смена класса разрешена только в read committed');
642
+ end if;
643
+ old_res := (@SCHEMA@.class_row(OLD.class, OLD.tenant)).data->'resolved';
644
+ if jsonb_array_length(coalesce(old_res->'key', '[]'::jsonb)) > 0 or jsonb_array_length(coalesce(res->'key', '[]'::jsonb)) > 0 then
645
+ perform @SCHEMA@.lt_fail('use_reclass', format('у класса %s или %s есть ключ: id меняется, используйте reclass()', OLD.class, NEW.class));
646
+ end if;
647
+ -- обычный update берёт слабую блокировку; смене класса нужна for update, чтобы дождаться
648
+ -- транзакций, которые как раз пишут ссылку на эту строку
649
+ perform 1 from @SCHEMA@.entity x where x.id = OLD.id for update;
650
+ inc := @SCHEMA@.incoming_conflicts(OLD.id, NEW.class, OLD.tenant);
651
+ if inc <> '[]'::jsonb then
652
+ perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', NEW.class),
653
+ @SCHEMA@.visible_detail(inc, c.tenant));
654
+ end if;
655
+ op := 'reclass';
656
+ end if;
657
+ if NEW.class = 'Class' then
658
+ select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(NEW.data, OLD.tenant) a;
659
+ else
660
+ NEW.data := @SCHEMA@.apply_defaults(res->'schema', NEW.data);
661
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
662
+ if bad <> '[]'::jsonb then
663
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', NEW.class), jsonb_build_object('issues', bad));
664
+ end if;
665
+ NEW.validator := null;
666
+ end if;
667
+ select * into lk from @SCHEMA@.links_shape(res, NEW.links);
668
+ if lk.code is not null then
669
+ perform @SCHEMA@.lt_fail(lk.code, lk.msg);
670
+ end if;
671
+ NEW.links := lk.links;
672
+ if NEW.class = 'member' and NEW.links->>'Tenant' is distinct from OLD.tenant::text then
673
+ perform @SCHEMA@.lt_fail('invalid_data', 'конец Tenant членства — арендатор строки');
674
+ end if;
675
+ kid := @SCHEMA@.key_id(res, NEW.class, OLD.tenant, NEW.data, NEW.links);
676
+ if kid is not null and kid <> OLD.id then
677
+ perform @SCHEMA@.lt_fail('immutable_key', format('ключевое поле класса %s менять нельзя: id изменился бы (rekey)', NEW.class));
678
+ end if;
679
+ -- ничего не изменилось: строка возвращается как есть, версии нет, оператор считает её затронутой
680
+ if NEW.class = OLD.class and NEW.data = OLD.data and NEW.links = OLD.links and NEW.tags = OLD.tags then
681
+ return OLD;
682
+ end if;
683
+ if NEW.class = 'Class' and not @SCHEMA@.class_change_safe(OLD.data->'resolved', NEW.data->'resolved') then
684
+ -- ужесточение: исключительная блокировка корня семейства (и нового корня при смене родителя)
685
+ perform @SCHEMA@.family_lock(OLD, true);
686
+ perform @SCHEMA@.family_lock(NEW, true);
687
+ end if;
688
+ NEW.rev := OLD.rev + 1;
689
+ NEW.prev_hash := OLD.hash;
690
+ NEW.at := greatest(@SCHEMA@.now_ts(), OLD.at + interval '1 microsecond');
691
+ NEW.cv := cls.rev;
692
+ NEW.author := c.author;
693
+ NEW.agent := c.agent;
694
+ NEW.op := op;
695
+ NEW.reason := nullif(current_setting('letopis.reason', true), '');
696
+ NEW.moved := null;
697
+ return NEW;
698
+ end $$;
699
+
700
+ -- После оператора insert (решение 2 точки А): все версии оператора — одной вставкой в журнал
701
+ -- (по переходной таблице), сигнал — по одному на класс.
702
+ create or replace function @SCHEMA@.trg_entity_ai() returns trigger
703
+ language plpgsql security definer
704
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
705
+ as $$
706
+ declare
707
+ cls text[];
708
+ bad text;
709
+ begin
710
+ if exists (select 1 from added n where n.links <> '{}'::jsonb) then
711
+ bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
712
+ from added n where n.links <> '{}'::jsonb), true, @SCHEMA@.acl_actor('READ'));
713
+ end if;
714
+ if bad is not null then
715
+ perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
716
+ end if;
717
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
718
+ select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash
719
+ from added n
720
+ join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
721
+ where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
722
+ select array_agg(distinct a.class) into cls from added a;
723
+ perform @SCHEMA@.signal(cls);
724
+ if cls && array['rule', 'Resource', 'member', 'Account', 'Class'] then
725
+ perform @SCHEMA@.acl_bump();
726
+ end if;
727
+ return null;
728
+ end $$;
729
+
730
+ -- После оператора update: версии строк, у которых вырос номер версии (пустая правка версии не
731
+ -- даёт), — одной вставкой; сигнал; для строк Class — пересчёт потомков и проверка строк семейства
732
+ -- при ужесточении.
733
+ create or replace function @SCHEMA@.trg_entity_au() returns trigger
734
+ language plpgsql security definer
735
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
736
+ as $$
737
+ declare
738
+ cls text[];
739
+ o_row @SCHEMA@.entity;
740
+ n_row @SCHEMA@.entity;
741
+ cid uuid;
742
+ bad text;
743
+ begin
744
+ -- цели проверяются у строк, у которых изменились концы или класс
745
+ if exists (select 1 from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
746
+ where n.links <> '{}'::jsonb and (n.links <> o.links or n.class <> o.class)) then
747
+ bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
748
+ from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
749
+ where n.links <> '{}'::jsonb and (n.links <> o.links or n.class <> o.class)), true, @SCHEMA@.acl_actor('READ'));
750
+ end if;
751
+ if bad is not null then
752
+ perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
753
+ end if;
754
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
755
+ select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash
756
+ from new_rows n
757
+ join old_rows o on o.id = n.id and o.rev <> n.rev
758
+ join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
759
+ where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
760
+ select array_agg(distinct n.class) into cls from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev;
761
+ perform @SCHEMA@.signal(cls);
762
+ if cls && array['rule', 'Resource', 'member', 'Account', 'Class'] then
763
+ perform @SCHEMA@.acl_bump();
764
+ end if;
765
+ for cid in select n.id from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
766
+ where n.class = 'Class' order by n.id loop
767
+ select * into o_row from old_rows x where x.id = cid;
768
+ select * into n_row from new_rows x where x.id = cid;
769
+ perform @SCHEMA@.class_evolve(o_row, n_row);
770
+ end loop;
771
+ -- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
772
+ for cid in select n.id from new_rows n join old_rows o on o.id = n.id
773
+ where n.class = 'Account' and coalesce((o.data->>'enabled')::boolean, true)
774
+ and not coalesce((n.data->>'enabled')::boolean, true) loop
775
+ perform @SCHEMA@.auth_kill(cid, null);
776
+ end loop;
777
+ return null;
778
+ end $$;
779
+
780
+ create or replace function @SCHEMA@.class_evolve(o @SCHEMA@.entity, n @SCHEMA@.entity) returns void
781
+ language plpgsql volatile
782
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
783
+ as $$
784
+ declare
785
+ d @SCHEMA@.entity;
786
+ prep record;
787
+ classes text[] := array[n.data->>'name'];
788
+ conflicts jsonb;
789
+ c record;
790
+ begin
791
+ -- потомки: описание resolved и validator пересчитываются, каждый пересчёт — версия класса в журнале
792
+ for d in select x.* from @SCHEMA@.entity x
793
+ where x.class = 'Class' and x.id <> n.id
794
+ and (x.tenant = n.tenant or n.tenant = '@SYSTEM@'::uuid)
795
+ and x.data->'resolved'->'ancestors' ? (n.data->>'name')
796
+ order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.id loop
797
+ select p.data, p.validator into prep from @SCHEMA@.class_prepare(d.data, d.tenant) p;
798
+ if prep.data is distinct from d.data or prep.validator::text is distinct from d.validator::text then
799
+ perform @SCHEMA@.sys_update(d.id, prep.data, d.links, prep.validator, 'update');
800
+ end if;
801
+ classes := classes || (d.data->>'name');
802
+ end loop;
803
+ if not @SCHEMA@.class_change_safe(o.data->'resolved', n.data->'resolved') then
804
+ conflicts := @SCHEMA@.family_conflicts(classes, n.tenant, o.data->'resolved'->'ancestors' is distinct from n.data->'resolved'->'ancestors');
805
+ if conflicts <> '[]'::jsonb then
806
+ select * into c from @SCHEMA@.ctx();
807
+ perform @SCHEMA@.lt_fail('tightening_conflict', format('новому описанию класса %s не соответствуют строки', n.data->>'name'),
808
+ @SCHEMA@.visible_detail(conflicts, c.tenant));
809
+ end if;
810
+ end if;
811
+ end $$;
812
+
813
+ -- Перед оператором delete: только read committed (§2.10).
814
+ create or replace function @SCHEMA@.trg_entity_bd() returns trigger
815
+ language plpgsql security definer
816
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
817
+ as $$
818
+ begin
819
+ if current_setting('transaction_isolation') <> 'read committed' then
820
+ perform @SCHEMA@.lt_fail('isolation_level', 'удаление разрешено только в read committed');
821
+ end if;
822
+ return null;
823
+ end $$;
824
+
825
+ -- Действие со строкой, которая ссылается на удаляемые id (p_ids), по правилам её концов (p_ends):
826
+ -- cascade сильнее restrict, restrict сильнее unset; null — строка на них не ссылается. Общее правило
827
+ -- для триггера удаления и превью delete_preview() (этап 4, п. 6): превью не расходится с удалением.
828
+ create or replace function @SCHEMA@.ref_action(p_ends jsonb, p_links jsonb, p_ids uuid[]) returns text
829
+ language sql immutable parallel safe
830
+ return (
831
+ select case when bool_or(x.a = 'cascade') then 'cascade' when bool_or(x.a = 'restrict') then 'restrict'
832
+ when bool_or(x.a = 'unset') then 'unset' end
833
+ from (select coalesce(p_ends->k.key->>'onDelete', 'restrict') as a
834
+ from jsonb_each(p_links) k
835
+ where exists (select 1 from jsonb_array_elements_text(case jsonb_typeof(k.value) when 'array' then k.value
836
+ else jsonb_build_array(k.value) end) t
837
+ where t::uuid = any (p_ids))) x
838
+ );
839
+
840
+ -- Строки арендатора, которыми владел пользователь, переходят арендатору (выход из членства, §2.10):
841
+ -- новая версия каждой строки с журналом. Индекс entity_owner — только строки с владельцем-не арендатором.
842
+ create or replace function @SCHEMA@.owner_to_tenant(p_tenant uuid, p_user uuid) returns int
843
+ language plpgsql volatile
844
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
845
+ as $$
846
+ declare
847
+ c record;
848
+ r @SCHEMA@.entity;
849
+ n int := 0;
850
+ begin
851
+ if p_tenant is null or p_user is null or p_tenant = p_user then
852
+ return 0;
853
+ end if;
854
+ select * into c from @SCHEMA@.ctx();
855
+ for r in select x.* from @SCHEMA@.entity x where x.owner = p_user and x.owner <> x.tenant and x.tenant = p_tenant
856
+ order by x.id for update loop
857
+ update @SCHEMA@.entity x
858
+ set owner = p_tenant, rev = x.rev + 1, prev_hash = x.hash,
859
+ at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
860
+ author = coalesce(c.author, '@SYSTEM@'::uuid), agent = c.agent, op = 'update',
861
+ reason = nullif(current_setting('letopis.reason', true), ''), moved = null
862
+ where x.id = r.id
863
+ returning x.* into r;
864
+ perform @SCHEMA@.log_version(r);
865
+ n := n + 1;
866
+ end loop;
867
+ return n;
868
+ end $$;
869
+
870
+ -- После оператора delete (§2.5, этап 1, п. 8): надгробия, каскад по уровням, restrict по итогу.
871
+ -- Строки, которые удалил сам оператор, каскад уже не видит, поэтому сырой delete любого набора
872
+ -- связанных строк проходит без ошибки 27000. Надгробия уровня пишутся одной вставкой.
873
+ create or replace function @SCHEMA@.trg_entity_ad() returns trigger
874
+ language plpgsql security definer
875
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
876
+ as $$
877
+ declare
878
+ c record;
879
+ r record;
880
+ lvl uuid[];
881
+ all_ids uuid[];
882
+ casc uuid[];
883
+ unsets uuid[];
884
+ classes text[];
885
+ lvl_classes text[];
886
+ v_author uuid;
887
+ v_reason text := nullif(current_setting('letopis.reason', true), '');
888
+ newlinks jsonb;
889
+ k record;
890
+ blockers jsonb;
891
+ dplan jsonb;
892
+ wplan jsonb;
893
+ denied text;
894
+ accs uuid[];
895
+ mems jsonb;
896
+ lvl_accs uuid[];
897
+ lvl_mems jsonb;
898
+ begin
899
+ select * into c from @SCHEMA@.ctx();
900
+ -- права в замыкании (этап 5, п. 6): DELETE на каждую удаляемую каскадом строку, WRITE — на строку,
901
+ -- у которой снимается конец
902
+ dplan := @SCHEMA@.acl_plan('DELETE');
903
+ wplan := @SCHEMA@.acl_plan('WRITE');
904
+ if exists (select 1 from @SCHEMA@.entity x join gone g on g.id = x.id) then
905
+ perform @SCHEMA@.lt_fail('recreated_in_statement', 'id удалён и создан заново одним оператором');
906
+ end if;
907
+ for r in select g.id, g.tenant, g.data->>'name' as name from gone g where g.class = 'Class' loop
908
+ if exists (select 1 from @SCHEMA@.entity x where x.class = r.name and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid))
909
+ or exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.data->>'parent' = r.name
910
+ and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid)) then
911
+ perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть строки или потомки', r.name));
912
+ end if;
913
+ end loop;
914
+ v_author := coalesce(c.author, '@SYSTEM@'::uuid);
915
+ -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
916
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
917
+ select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
918
+ greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash
919
+ from gone g
920
+ join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
921
+ where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant;
922
+ select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
923
+ coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
924
+ coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
925
+ into lvl, classes, accs, mems from gone g;
926
+ all_ids := lvl;
927
+ while cardinality(lvl) > 0 loop
928
+ -- кто ссылается на уровень: действие по правилам концов (общее с превью)
929
+ with refs as (
930
+ select x.id, x.class, x.tenant, x.links from @SCHEMA@.entity x where x.ends && lvl
931
+ ), cls as (
932
+ select d.class, d.tenant, (@SCHEMA@.class_row(d.class, d.tenant)).data->'resolved'->'ends' as ends
933
+ from (select distinct refs.class, refs.tenant from refs) d
934
+ ), act as (
935
+ select refs.id, @SCHEMA@.ref_action(cls.ends, refs.links, all_ids) as a
936
+ from refs join cls on cls.class = refs.class and cls.tenant = refs.tenant
937
+ )
938
+ select coalesce(array_agg(act.id order by act.id) filter (where act.a = 'cascade'), '{}'),
939
+ coalesce(array_agg(act.id order by act.id) filter (where act.a = 'unset'), '{}')
940
+ into casc, unsets from act;
941
+ -- каскад: удаление в порядке id берёт блокировки и ждёт тех, кто как раз пишет ссылки
942
+ with d as (
943
+ delete from @SCHEMA@.entity x
944
+ where x.id in (select y.id from @SCHEMA@.entity y where y.id = any (casc) order by y.id for update)
945
+ returning x.*
946
+ ), t as (
947
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
948
+ select d.id, d.rev + 1, d.class, d.cv, d.tenant, d.owner, d.links, d.data, d.tags,
949
+ greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash
950
+ from d
951
+ join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
952
+ where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
953
+ )
954
+ select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
955
+ min(d.class) filter (where not @SCHEMA@.acl_row_ok(dplan, d.class, d.id, d.owner, d.tenant, d.tags, d.data, d.links)),
956
+ coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
957
+ coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
958
+ into lvl, lvl_classes, denied, lvl_accs, lvl_mems from d;
959
+ if denied is not null then
960
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s в каскаде удаления', denied));
961
+ end if;
962
+ accs := accs || lvl_accs;
963
+ mems := mems || lvl_mems;
964
+ classes := classes || lvl_classes;
965
+ -- unset: снять конец, новая версия
966
+ for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
967
+ if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
968
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s: снятие конца при удалении', r.class));
969
+ end if;
970
+ newlinks := '{}'::jsonb;
971
+ for k in select key, value from jsonb_each(r.links) loop
972
+ if jsonb_typeof(k.value) = 'array' then
973
+ newlinks := newlinks || jsonb_build_object(k.key, coalesce(
974
+ (select jsonb_agg(t) from jsonb_array_elements_text(k.value) t where not (t::uuid = any (all_ids))), '[]'::jsonb));
975
+ elsif not ((k.value #>> '{}')::uuid = any (all_ids)) then
976
+ newlinks := newlinks || jsonb_build_object(k.key, k.value);
977
+ end if;
978
+ end loop;
979
+ perform @SCHEMA@.sys_update(r.id, r.data, newlinks, r.validator, 'update');
980
+ end loop;
981
+ all_ids := all_ids || lvl;
982
+ end loop;
983
+ -- выход из членства (и удаление аккаунта, которое снимает его членства): строки арендатора, которыми
984
+ -- владел участник, переходят арендатору (§2.10) — новой версией
985
+ for r in select distinct (m->>'tenant')::uuid as tenant, (m->>'user')::uuid as usr from jsonb_array_elements(mems) m loop
986
+ perform @SCHEMA@.owner_to_tenant(r.tenant, r.usr);
987
+ end loop;
988
+ -- удалённый аккаунт: сессии гаснут, креды удаляются (идентификаторы освобождаются)
989
+ for r in select distinct u as acc from unnest(accs) u loop
990
+ perform @SCHEMA@.auth_kill(r.acc, null);
991
+ update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
992
+ end loop;
993
+ perform @SCHEMA@.signal(array(select distinct x from unnest(classes) x));
994
+ if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
995
+ perform @SCHEMA@.acl_bump();
996
+ end if;
997
+ -- restrict — одним запросом после всего каскада
998
+ select coalesce(jsonb_agg(jsonb_build_object('id', x.id, 'class', x.class, 'tenant', x.tenant)), '[]'::jsonb) into blockers
999
+ from (select x.id, x.class, x.tenant from @SCHEMA@.entity x where x.ends && all_ids order by x.id limit 1000) x;
1000
+ if blockers <> '[]'::jsonb then
1001
+ perform @SCHEMA@.lt_fail('delete_restricted', 'на удаляемые строки ссылаются другие строки (onDelete: restrict)',
1002
+ @SCHEMA@.visible_detail(blockers, c.tenant));
1003
+ end if;
1004
+ return null;
1005
+ end $$;
1006
+
1007
+ -- Превью удаления (этап 4, п. 6): что удалит delete этих строк — те же уровни и то же правило
1008
+ -- ref_action(), что у триггера, но без изменений. Строки: action delete (цели — depth 0, каскад —
1009
+ -- уровень), unset (конец будет снят) и restrict (мешает удалению). Функция владельца ищет по
1010
+ -- индексу ends без RLS (§3 «Индексы под RLS»), поэтому и цели, и выдачу пропускает через
1011
+ -- row_visible() — то же условие, что у политики чтения (как find_*): невидимых строк не раскрывает
1012
+ -- ни id, ни классом. О невидимых мешающих строках скажет само удаление (visible_detail: число).
1013
+ create or replace function @SCHEMA@.delete_preview(p_ids uuid[]) returns table(id uuid, class text, action text, depth int)
1014
+ language plpgsql stable security definer
1015
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1016
+ as $$
1017
+ #variable_conflict use_column
1018
+ declare
1019
+ a jsonb := @SCHEMA@.acl_actor('READ');
1020
+ c_tenant uuid := (a->>'tenant')::uuid;
1021
+ c_account uuid := (a->>'account')::uuid;
1022
+ c_plan jsonb := a->'plan';
1023
+ lvl uuid[];
1024
+ all_ids uuid[];
1025
+ casc uuid[];
1026
+ d int := 0;
1027
+ out_rows jsonb := '[]'::jsonb;
1028
+ begin
1029
+ if c_account is null then
1030
+ return;
1031
+ end if;
1032
+ -- цели — только видимые: превью не подтверждает существование чужих строк
1033
+ select coalesce(array_agg(x.id order by x.id), '{}') into lvl
1034
+ from @SCHEMA@.entity x
1035
+ where x.id = any (p_ids) and @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links, c_tenant, c_account, c_plan);
1036
+ out_rows := (select coalesce(jsonb_agg(jsonb_build_array(u, 'delete', 0)), '[]'::jsonb) from unnest(lvl) u);
1037
+ all_ids := lvl;
1038
+ while cardinality(lvl) > 0 loop
1039
+ d := d + 1;
1040
+ with refs as (
1041
+ select x.id, x.class, x.tenant, x.links from @SCHEMA@.entity x
1042
+ where x.ends && lvl and not (x.id = any (all_ids))
1043
+ ), cls as (
1044
+ select dd.class, dd.tenant, (@SCHEMA@.class_row(dd.class, dd.tenant)).data->'resolved'->'ends' as ends
1045
+ from (select distinct refs.class, refs.tenant from refs) dd
1046
+ ), act as (
1047
+ select refs.id, @SCHEMA@.ref_action(cls.ends, refs.links, all_ids) as a
1048
+ from refs join cls on cls.class = refs.class and cls.tenant = refs.tenant
1049
+ )
1050
+ select coalesce(array_agg(act.id order by act.id) filter (where act.a = 'cascade'), '{}') into casc from act;
1051
+ out_rows := out_rows || (select coalesce(jsonb_agg(jsonb_build_array(u, 'delete', d)), '[]'::jsonb) from unnest(casc) u);
1052
+ all_ids := all_ids || casc;
1053
+ lvl := casc;
1054
+ end loop;
1055
+ -- кто останется ссылаться на удалённое: снимаемые концы и мешающие строки
1056
+ out_rows := out_rows || (
1057
+ select coalesce(jsonb_agg(jsonb_build_array(r.id, case when r.a = 'unset' then 'unset' else 'restrict' end, null)), '[]'::jsonb)
1058
+ from (select x.id, @SCHEMA@.ref_action((@SCHEMA@.class_row(x.class, x.tenant)).data->'resolved'->'ends', x.links, all_ids) as a
1059
+ from @SCHEMA@.entity x where x.ends && all_ids and not (x.id = any (all_ids))) r);
1060
+ return query
1061
+ select (o->>0)::uuid, e.class, o->>1, (o->>2)::int
1062
+ from jsonb_array_elements(out_rows) o
1063
+ join @SCHEMA@.entity e on e.id = (o->>0)::uuid
1064
+ where @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
1065
+ end $$;
1066
+
1067
+ create or replace trigger entity_bi before insert on @SCHEMA@.entity
1068
+ for each row when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bi();
1069
+ create or replace trigger entity_bu before update on @SCHEMA@.entity
1070
+ for each row when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bu();
1071
+ -- до ревизии 2 журнал писал строковый триггер entity_ai после insert или update
1072
+ drop trigger if exists entity_ai on @SCHEMA@.entity;
1073
+ create trigger entity_ai after insert on @SCHEMA@.entity referencing new table as added
1074
+ for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ai();
1075
+ create or replace trigger entity_au after update on @SCHEMA@.entity referencing old table as old_rows new table as new_rows
1076
+ for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_au();
1077
+ create or replace trigger entity_bd before delete on @SCHEMA@.entity
1078
+ for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bd();
1079
+ create or replace trigger entity_ad after delete on @SCHEMA@.entity referencing old table as gone
1080
+ for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ad();
1081
+ -- в режиме репликации (session_replication_role = replica) триггеры тоже работают
1082
+ alter table @SCHEMA@.entity enable always trigger entity_bi;
1083
+ alter table @SCHEMA@.entity enable always trigger entity_bu;
1084
+ alter table @SCHEMA@.entity enable always trigger entity_ai;
1085
+ alter table @SCHEMA@.entity enable always trigger entity_au;
1086
+ alter table @SCHEMA@.entity enable always trigger entity_bd;
1087
+ alter table @SCHEMA@.entity enable always trigger entity_ad;
1088
+
1089
+ -- ========================= смена класса с ключом =========================
1090
+
1091
+ -- Замена id цели во всех концах.
1092
+ create or replace function @SCHEMA@.links_replace(p_links jsonb, p_old uuid, p_new uuid) returns jsonb
1093
+ language sql immutable parallel safe
1094
+ return (
1095
+ select coalesce(jsonb_object_agg(k.key, case jsonb_typeof(k.value)
1096
+ when 'array' then coalesce((select jsonb_agg(case when x = p_old::text then to_jsonb(p_new::text) else to_jsonb(x) end)
1097
+ from jsonb_array_elements_text(k.value) x), '[]'::jsonb)
1098
+ else case when k.value #>> '{}' = p_old::text then to_jsonb(p_new::text) else k.value end end), '{}'::jsonb)
1099
+ from jsonb_each(p_links) k
1100
+ );
1101
+
1102
+ -- Перенос строки на новый id (смена класса с ключом, смена ключа): новая строка продолжает журнал
1103
+ -- нового id и ссылается на старый (moved), ссылки переводятся — если переведённый конец входит
1104
+ -- в ключ ссылающейся строки, её id тоже меняется, и перевод идёт дальше рекурсивно; старая строка
1105
+ -- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая функция.
1106
+ create or replace function @SCHEMA@.move_row(o @SCHEMA@.entity, p_new_id uuid, p_class text, p_data jsonb, p_links jsonb, p_op text)
1107
+ returns @SCHEMA@.entity
1108
+ language plpgsql volatile
1109
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1110
+ as $$
1111
+ declare
1112
+ c record;
1113
+ last record;
1114
+ ts timestamptz;
1115
+ n @SCHEMA@.entity;
1116
+ r @SCHEMA@.entity;
1117
+ nl jsonb;
1118
+ rid uuid;
1119
+ begin
1120
+ select * into c from @SCHEMA@.ctx();
1121
+ select l.rev, l.hash, l.at into last from @SCHEMA@.log l where l.id = p_new_id order by l.rev desc limit 1 for update;
1122
+ ts := greatest(@SCHEMA@.now_ts(), last.at + interval '1 microsecond');
1123
+ insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator)
1124
+ values (p_new_id, coalesce(last.rev, 0) + 1, p_class, coalesce((@SCHEMA@.class_row(p_class, o.tenant)).rev, 0), o.tenant, o.owner,
1125
+ p_links, p_data, o.tags, ts, ts, coalesce(c.author, '@SYSTEM@'::uuid), c.agent, p_op,
1126
+ nullif(current_setting('letopis.reason', true), ''), o.id, last.hash, null)
1127
+ returning * into n;
1128
+ perform @SCHEMA@.log_version(n);
1129
+ for r in select x.* from @SCHEMA@.entity x where x.ends @> array[o.id] and x.id <> p_new_id order by x.id for update loop
1130
+ nl := @SCHEMA@.links_replace(r.links, o.id, p_new_id);
1131
+ rid := coalesce(@SCHEMA@.key_id((@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved', r.class, r.tenant, r.data, nl), r.id);
1132
+ if rid <> r.id then
1133
+ perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey');
1134
+ else
1135
+ perform @SCHEMA@.sys_update(r.id, r.data, nl, r.validator, 'update');
1136
+ end if;
1137
+ end loop;
1138
+ delete from @SCHEMA@.entity x where x.id = o.id returning x.* into r;
1139
+ -- надгробие всегда вида delete (по нему срез на момент отличает удалённый id), moved — новый id
1140
+ perform @SCHEMA@.tombstone(r, 'delete', p_new_id);
1141
+ return n;
1142
+ end $$;
1143
+
1144
+ -- Смена класса (этап 1, п. 9): у класса с ключом id вычисляется заново для нового класса, ссылки
1145
+ -- переводятся в той же транзакции, журнал связывает старый и новый id. У класса без ключа id
1146
+ -- сохраняется. Только read committed; на строку сначала берётся for update.
1147
+ create or replace function @SCHEMA@.reclass(p_id uuid, p_class text, p_data jsonb default null) returns uuid
1148
+ language plpgsql volatile security definer
1149
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1150
+ as $$
1151
+ declare
1152
+ c record;
1153
+ o @SCHEMA@.entity;
1154
+ cls @SCHEMA@.entity;
1155
+ res jsonb;
1156
+ d jsonb;
1157
+ bad jsonb;
1158
+ lk record;
1159
+ nid uuid;
1160
+ inc jsonb;
1161
+ rd jsonb;
1162
+ wplan jsonb;
1163
+ denied text;
1164
+ begin
1165
+ select * into c from @SCHEMA@.ctx();
1166
+ if c.account is null then
1167
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
1168
+ end if;
1169
+ if current_setting('transaction_isolation') <> 'read committed' then
1170
+ perform @SCHEMA@.lt_fail('isolation_level', 'смена класса разрешена только в read committed');
1171
+ end if;
1172
+ rd := @SCHEMA@.acl_actor('READ');
1173
+ wplan := @SCHEMA@.acl_plan('WRITE');
1174
+ select x.* into o from @SCHEMA@.entity x where x.id = p_id and x.tenant = c.tenant for update;
1175
+ if o.id is null or not @SCHEMA@.row_visible(o.tenant, o.class, o.id, o.owner, o.tags, o.data, o.links, c.tenant, c.account, rd->'plan') then
1176
+ perform @SCHEMA@.lt_fail('target_not_found', 'цель не найдена');
1177
+ end if;
1178
+ if not @SCHEMA@.acl_row_ok(wplan, o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
1179
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', o.class));
1180
+ end if;
1181
+ -- строки прав и классов функция не трогает
1182
+ if o.class in ('Class', 'Account', 'member', 'Resource', 'rule') or p_class in ('Class', 'Account', 'member', 'Resource', 'rule') then
1183
+ perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
1184
+ end if;
1185
+ cls := @SCHEMA@.class_row(p_class, o.tenant);
1186
+ if cls.id is null then
1187
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', p_class));
1188
+ end if;
1189
+ perform @SCHEMA@.family_lock(cls, false);
1190
+ cls := @SCHEMA@.class_row(p_class, o.tenant);
1191
+ res := cls.data->'resolved';
1192
+ if coalesce((res->>'abstract')::boolean, false) then
1193
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
1194
+ end if;
1195
+ d := @SCHEMA@.apply_defaults(res->'schema', coalesce(p_data, o.data));
1196
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
1197
+ if bad <> '[]'::jsonb then
1198
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', p_class), jsonb_build_object('issues', bad));
1199
+ end if;
1200
+ select * into lk from @SCHEMA@.check_links(res, o.links, o.tenant, true, rd);
1201
+ if lk.code is not null then
1202
+ perform @SCHEMA@.lt_fail(lk.code, lk.msg);
1203
+ end if;
1204
+ inc := @SCHEMA@.incoming_conflicts(o.id, p_class, o.tenant);
1205
+ if inc <> '[]'::jsonb then
1206
+ perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', p_class),
1207
+ @SCHEMA@.visible_detail(inc, c.tenant));
1208
+ end if;
1209
+ nid := coalesce(@SCHEMA@.key_id(res, p_class, o.tenant, d, lk.links), o.id);
1210
+ if not @SCHEMA@.acl_row_ok(wplan, p_class, nid, o.owner, o.tenant, o.tags, d, lk.links) then
1211
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s', p_class));
1212
+ end if;
1213
+ if nid <> o.id then
1214
+ -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE
1215
+ select min(x.class) into denied from @SCHEMA@.entity x
1216
+ where x.ends @> array[o.id] and not @SCHEMA@.acl_row_ok(wplan, x.class, x.id, x.owner, x.tenant, x.tags, x.data, x.links);
1217
+ if denied is not null then
1218
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на ссылающиеся строки класса %s', denied));
1219
+ end if;
1220
+ end if;
1221
+ if nid = o.id then
1222
+ perform @SCHEMA@.sys_update(o.id, d, lk.links, null, 'reclass', p_class);
1223
+ else
1224
+ perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass');
1225
+ end if;
1226
+ return nid;
1227
+ end $$;
1228
+
1229
+ -- ========================= merge, inc, links_patch =========================
1230
+
1231
+ -- Правка концов слотами (этап 4, п. 4): .Роль.set(), .unset(), а для множественных концов .add()
1232
+ -- и .remove() — атомарно над свежей строкой, без предварительного чтения списка. p_ops — список
1233
+ -- [{role, op: set | unset | add | remove, value}]: у set — id, список id или null; у add и remove —
1234
+ -- список id. Повтор в add не добавляется, отсутствующий в remove — пропускается. Проверку концов
1235
+ -- делают триггеры, как при любой правке.
1236
+ create or replace function @SCHEMA@.links_patch(p_links jsonb, p_ops jsonb) returns jsonb
1237
+ language plpgsql immutable parallel safe
1238
+ as $$
1239
+ declare
1240
+ o jsonb;
1241
+ role text;
1242
+ res jsonb := coalesce(p_links, '{}'::jsonb);
1243
+ cur jsonb;
1244
+ v jsonb;
1245
+ begin
1246
+ for o in select x from jsonb_array_elements(coalesce(p_ops, '[]'::jsonb)) x loop
1247
+ role := o->>'role';
1248
+ case o->>'op'
1249
+ when 'set' then
1250
+ if o->'value' is null or o->'value' = 'null'::jsonb then
1251
+ res := res - role;
1252
+ else
1253
+ res := jsonb_set(res, array[role], o->'value');
1254
+ end if;
1255
+ when 'unset' then
1256
+ res := res - role;
1257
+ when 'add' then
1258
+ cur := coalesce(res->role, '[]'::jsonb);
1259
+ if jsonb_typeof(cur) = 'array' then
1260
+ for v in select x from jsonb_array_elements(o->'value') x loop
1261
+ v := to_jsonb(lower(v #>> '{}'));
1262
+ if not cur @> jsonb_build_array(v) then
1263
+ cur := cur || jsonb_build_array(v);
1264
+ end if;
1265
+ end loop;
1266
+ end if;
1267
+ res := jsonb_set(res, array[role], cur);
1268
+ when 'remove' then
1269
+ cur := coalesce(res->role, '[]'::jsonb);
1270
+ if jsonb_typeof(cur) = 'array' then
1271
+ cur := coalesce((select jsonb_agg(x) from jsonb_array_elements(cur) x
1272
+ where not exists (select 1 from jsonb_array_elements_text(o->'value') r where lower(r) = x #>> '{}')), '[]'::jsonb);
1273
+ end if;
1274
+ res := jsonb_set(res, array[role], cur);
1275
+ else
1276
+ raise exception using errcode = 'LT003', message = 'letopis: invalid_data: links_patch: неизвестная операция ' || coalesce(o->>'op', 'null');
1277
+ end case;
1278
+ end loop;
1279
+ return res;
1280
+ end $$;
1281
+
1282
+ -- Слияние патча (§2.5): повторяет deepMerge 0.21 без исключений — объекты сливаются рекурсивно,
1283
+ -- массивы, скаляры и null заменяются. Ключи на $ — обычные данные.
1284
+ create or replace function @SCHEMA@.merge(base jsonb, patch jsonb) returns jsonb
1285
+ language plpgsql immutable parallel safe
1286
+ as $$
1287
+ declare
1288
+ k text;
1289
+ v jsonb;
1290
+ res jsonb := coalesce(base, '{}'::jsonb);
1291
+ begin
1292
+ if patch is null then
1293
+ return res;
1294
+ end if;
1295
+ if jsonb_typeof(patch) <> 'object' or jsonb_typeof(res) <> 'object' then
1296
+ return patch;
1297
+ end if;
1298
+ for k, v in select key, value from jsonb_each(patch) loop
1299
+ if jsonb_typeof(v) = 'object' and jsonb_typeof(res->k) = 'object' then
1300
+ res := jsonb_set(res, array[k], @SCHEMA@.merge(res->k, v));
1301
+ else
1302
+ res := res || jsonb_build_object(k, v);
1303
+ end if;
1304
+ end loop;
1305
+ return res;
1306
+ end $$;
1307
+
1308
+ -- Приращение числа по пути (§2.5): путь только через существующие объекты; отсутствующее поле —
1309
+ -- ошибка без start; null и не число — ошибка и со start. Сумма считается в numeric точно.
1310
+ create or replace function @SCHEMA@.inc(data jsonb, path text[], delta numeric, start numeric default null) returns jsonb
1311
+ language plpgsql immutable parallel safe
1312
+ as $$
1313
+ declare
1314
+ cur jsonb := data;
1315
+ v jsonb;
1316
+ i int;
1317
+ fail text;
1318
+ begin
1319
+ if path is null or cardinality(path) = 0 or delta is null then
1320
+ fail := 'inc: нужны путь и величина';
1321
+ else
1322
+ for i in 1 .. cardinality(path) - 1 loop
1323
+ if jsonb_typeof(cur) is distinct from 'object' or not cur ? path[i] then
1324
+ fail := format('inc: путь %s идёт через отсутствующий объект', array_to_string(path, '.'));
1325
+ exit;
1326
+ end if;
1327
+ cur := cur->path[i];
1328
+ end loop;
1329
+ if fail is null and jsonb_typeof(cur) is distinct from 'object' then
1330
+ fail := format('inc: путь %s идёт не через объект', array_to_string(path, '.'));
1331
+ end if;
1332
+ if fail is null then
1333
+ v := cur->path[cardinality(path)];
1334
+ if v is null then
1335
+ if start is null then
1336
+ fail := format('inc: поля %s нет — задайте start', array_to_string(path, '.'));
1337
+ else
1338
+ return jsonb_set(data, path, to_jsonb(start + delta), true);
1339
+ end if;
1340
+ elsif jsonb_typeof(v) <> 'number' then
1341
+ fail := format('inc: в поле %s не число', array_to_string(path, '.'));
1342
+ else
1343
+ return jsonb_set(data, path, to_jsonb((v #>> '{}')::numeric + delta));
1344
+ end if;
1345
+ end if;
1346
+ end if;
1347
+ raise exception using errcode = 'LT003', message = 'letopis: invalid_data: ' || fail,
1348
+ detail = jsonb_build_object('issues', jsonb_build_array(jsonb_build_object(
1349
+ 'path', to_jsonb(coalesce(path, '{}')), 'keyword', 'inc', 'message', fail)))::text;
1350
+ end $$;
1351
+
1352
+ do $$
1353
+ declare f text;
1354
+ begin
1355
+ foreach f in array array[
1356
+ 'now_ts()', 'family_lock(@SCHEMA@.entity, boolean)', 'class_set(jsonb)', 'class_accepts(jsonb, text, uuid)', 'role_accepts(text, uuid, text, text, uuid)',
1357
+ 'apply_defaults(jsonb, jsonb)', 'links_shape(jsonb, jsonb)', 'targets_bad(jsonb, boolean, jsonb)', 'check_links(jsonb, jsonb, uuid, boolean, jsonb)', 'key_id(jsonb, text, uuid, jsonb, jsonb)',
1358
+ 'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text, text)', 'tombstone(@SCHEMA@.entity, text, uuid)',
1359
+ 'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text)', 'reclass(uuid, text, jsonb)',
1360
+ 'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'class_change_safe(jsonb, jsonb)',
1361
+ 'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_admit(jsonb, uuid)',
1362
+ 'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal(text[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
1363
+ 'trg_entity_bd()', 'trg_entity_ad()', 'owner_to_tenant(uuid, uuid)', 'ref_action(jsonb, jsonb, uuid[])', 'delete_preview(uuid[])', 'merge(jsonb, jsonb)', 'inc(jsonb, text[], numeric, numeric)', 'links_patch(jsonb, jsonb)'] loop
1364
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
1365
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
1366
+ end loop;
1367
+ end $$;
1368
+ -- сырой SQL пишет теми же merge, inc и links_patch, что и библиотека; verify() — в 90-time.sql
1369
+ -- до этапа 6 verify() был без параметров
1370
+ drop function if exists @SCHEMA@.verify();
1371
+ grant execute on function @SCHEMA@.merge(jsonb, jsonb), @SCHEMA@.inc(jsonb, text[], numeric, numeric), @SCHEMA@.links_patch(jsonb, jsonb),
1372
+ @SCHEMA@.reserved_names(), @SCHEMA@.reclass(uuid, text, jsonb), @SCHEMA@.delete_preview(uuid[]) to @APP@;