letopis 0.21.0 → 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.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +313 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -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 +164 -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 +1369 -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,1369 @@
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
+ create or replace function @SCHEMA@.class_change_safe(o jsonb, n jsonb) returns boolean
388
+ language sql immutable
389
+ return
390
+ o->'key' = n->'key'
391
+ and o->'ancestors' = n->'ancestors'
392
+ and (o->'abstract' = n->'abstract' or n->>'abstract' = 'false')
393
+ and coalesce(o->'schema'->'required', '[]'::jsonb) @> coalesce(n->'schema'->'required', '[]'::jsonb)
394
+ and not exists (select 1 from jsonb_each(coalesce(o->'schema'->'properties', '{}'::jsonb)) p
395
+ where n->'schema'->'properties'->p.key is distinct from p.value)
396
+ and not exists (select 1 from jsonb_each(coalesce(o->'ends', '{}'::jsonb)) e
397
+ where n->'ends'->e.key is distinct from e.value)
398
+ and not exists (select 1 from jsonb_each(coalesce(n->'ends', '{}'::jsonb)) e
399
+ where not coalesce(o->'ends' ? e.key, false)
400
+ and (coalesce((e.value->>'required')::boolean, false) or coalesce((e.value->>'min')::int, 0) > 0));
401
+
402
+ -- Строки семейства, которые не проходят текущие описания своих классов (после ужесточения).
403
+ -- Не больше 100 строк; p_incoming — проверять ещё и входящие ссылки (смена родителя).
404
+ create or replace function @SCHEMA@.family_conflicts(p_classes text[], p_tenant uuid, p_incoming boolean) returns jsonb
405
+ language plpgsql volatile
406
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
407
+ as $$
408
+ declare
409
+ r @SCHEMA@.entity;
410
+ cls @SCHEMA@.entity;
411
+ res jsonb;
412
+ bad jsonb;
413
+ lk record;
414
+ problem text;
415
+ inc jsonb;
416
+ outv jsonb := '[]'::jsonb;
417
+ begin
418
+ for r in select e.* from @SCHEMA@.entity e
419
+ where e.class = any (p_classes) and (e.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
420
+ order by e.id loop
421
+ cls := @SCHEMA@.class_row(r.class, r.tenant);
422
+ res := cls.data->'resolved';
423
+ problem := null;
424
+ if coalesce((res->>'abstract')::boolean, false) then
425
+ problem := 'класс абстрактный';
426
+ else
427
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', r.data);
428
+ if bad <> '[]'::jsonb then
429
+ problem := 'данные: ' || bad::text;
430
+ else
431
+ select * into lk from @SCHEMA@.check_links(res, r.links, r.tenant, false, null);
432
+ if lk.code is not null then
433
+ problem := lk.msg;
434
+ elsif coalesce(@SCHEMA@.key_id(res, r.class, r.tenant, r.data, r.links), r.id) <> r.id then
435
+ problem := 'id не соответствует ключу класса';
436
+ end if;
437
+ end if;
438
+ end if;
439
+ if problem is null and p_incoming then
440
+ inc := @SCHEMA@.incoming_conflicts(r.id, r.class, r.tenant);
441
+ if inc <> '[]'::jsonb then
442
+ problem := format('на строку ссылается роль %s строки %s, которая не принимает класс', inc->0->>'role', inc->0->>'id');
443
+ end if;
444
+ end if;
445
+ if problem is not null then
446
+ outv := outv || jsonb_build_object('id', r.id, 'class', r.class, 'tenant', r.tenant, 'problem', problem);
447
+ exit when jsonb_array_length(outv) >= 100;
448
+ end if;
449
+ end loop;
450
+ return outv;
451
+ end $$;
452
+
453
+ -- Методы цепочки — зарезервированные имена (§2.14). Список подставляет установщик из
454
+ -- RESERVED_CLASS_NAMES (lib/src/types.ts): при изменении списка функция пересоздаётся накатом.
455
+ create or replace function @SCHEMA@.reserved_names() returns text[]
456
+ language sql immutable parallel safe
457
+ return array[@RESERVED_NAMES@]::text[];
458
+
459
+ -- Проверки строки Class сверх метасхемы (этап 1, п. 6): признак system, имя системного класса,
460
+ -- имена связей против методов цепочки, однозначность имени конца во всей схеме.
461
+ create or replace function @SCHEMA@.class_admit(p_data jsonb, p_tenant uuid, out data jsonb, out validator jsonpath)
462
+ language plpgsql volatile
463
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
464
+ as $$
465
+ #variable_conflict use_variable
466
+ declare
467
+ e record;
468
+ o record;
469
+ begin
470
+ if p_tenant <> '@SYSTEM@'::uuid then
471
+ if (p_data->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
472
+ perform @SCHEMA@.lt_fail('invalid_class', format('имя %s зарезервировано за системным классом', p_data->>'name'));
473
+ end if;
474
+ if coalesce((p_data->>'system')::boolean, false) then
475
+ perform @SCHEMA@.lt_fail('invalid_class', 'признак system задаёт только установка');
476
+ end if;
477
+ if exists (select 1 from @SCHEMA@.entity s
478
+ where s.id = @SCHEMA@.v5_id('@SYSTEM@'::uuid, 'Class', array[p_data->>'name']) and s.class = 'Class') then
479
+ perform @SCHEMA@.lt_fail('invalid_class', format('имя %s занято системным классом', p_data->>'name'));
480
+ end if;
481
+ end if;
482
+ select c.data, c.validator into data, validator from @SCHEMA@.class_prepare(p_data, p_tenant) c;
483
+ if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
484
+ perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
485
+ end if;
486
+ for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
487
+ select x.data->>'name' as name into o
488
+ from @SCHEMA@.entity x
489
+ where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid)
490
+ and x.data->>'name' <> data->>'name'
491
+ and x.data->'resolved'->'ends' ? e.key
492
+ and @SCHEMA@.class_set(x.data->'resolved'->'ends'->e.key->'class') <> @SCHEMA@.class_set(e.value->'class')
493
+ limit 1;
494
+ if o.name is not null then
495
+ perform @SCHEMA@.lt_fail('invalid_class', format('конец %s в классе %s ведёт на другой класс', e.key, o.name));
496
+ end if;
497
+ end loop;
498
+ end $$;
499
+
500
+ -- ========================= триггеры =========================
501
+
502
+ create or replace function @SCHEMA@.trg_entity_bi() returns trigger
503
+ language plpgsql security definer
504
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
505
+ as $$
506
+ declare
507
+ c record;
508
+ cls @SCHEMA@.entity;
509
+ res jsonb;
510
+ bad jsonb;
511
+ lk record;
512
+ kid uuid;
513
+ last record;
514
+ ts timestamptz;
515
+ begin
516
+ select * into c from @SCHEMA@.ctx();
517
+ if c.account is null then
518
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
519
+ end if;
520
+ NEW.tenant := coalesce(NEW.tenant, c.tenant);
521
+ if NEW.tenant <> c.tenant then
522
+ perform @SCHEMA@.lt_fail('tenant_denied', 'запись в чужого арендатора');
523
+ end if;
524
+ -- аккаунты (с их категориями) — только в System: их пишет системный администратор (этап 5)
525
+ if NEW.class = 'Account' and NEW.tenant <> '@SYSTEM@'::uuid then
526
+ perform @SCHEMA@.lt_fail('tenant_denied', 'аккаунты лежат в арендаторе System');
527
+ end if;
528
+ cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
529
+ if cls.id is null then
530
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
531
+ end if;
532
+ -- разделяемая блокировка корня семейства, затем описание перечитывается под ней
533
+ perform @SCHEMA@.family_lock(cls, false);
534
+ cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
535
+ res := cls.data->'resolved';
536
+ if coalesce((res->>'abstract')::boolean, false) then
537
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
538
+ end if;
539
+ if NEW.class = 'Class' then
540
+ select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(coalesce(NEW.data, '{}'::jsonb), NEW.tenant) a;
541
+ else
542
+ NEW.data := @SCHEMA@.apply_defaults(res->'schema', coalesce(NEW.data, '{}'::jsonb));
543
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
544
+ if bad <> '[]'::jsonb then
545
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', NEW.class), jsonb_build_object('issues', bad));
546
+ end if;
547
+ NEW.validator := null;
548
+ end if;
549
+ -- цели проверяет и блокирует триггер после оператора (targets_bad) — один раз на оператор
550
+ select * into lk from @SCHEMA@.links_shape(res, NEW.links);
551
+ if lk.code is not null then
552
+ perform @SCHEMA@.lt_fail(lk.code, lk.msg);
553
+ end if;
554
+ NEW.links := lk.links;
555
+ if NEW.class = 'member' and NEW.links->>'Tenant' is distinct from NEW.tenant::text then
556
+ perform @SCHEMA@.lt_fail('invalid_data', 'конец Tenant членства — арендатор строки');
557
+ end if;
558
+ kid := @SCHEMA@.key_id(res, NEW.class, NEW.tenant, NEW.data, NEW.links);
559
+ if kid is null then
560
+ if NEW.id is not null then
561
+ perform @SCHEMA@.lt_fail('id_from_db', format('id класса без ключа %s выдаёт база', NEW.class));
562
+ end if;
563
+ NEW.id := @SCHEMA@.v5_id(NEW.tenant, NEW.class, array[gen_random_uuid()::text]);
564
+ else
565
+ if NEW.id is not null and NEW.id <> kid then
566
+ perform @SCHEMA@.lt_fail('id_mismatch', format('id не равен v5 от ключа класса %s', NEW.class), jsonb_build_object('expected', kid));
567
+ end if;
568
+ NEW.id := kid;
569
+ end if;
570
+ -- воскрешение: номер версии и цепочка хэшей продолжаются от последней записи журнала; запись
571
+ -- блокируется — параллельные purge, обрезка и restore того же id ждут (этап 6), а если purge успел
572
+ -- стереть её, пара (id, rev) столкнётся с его записью purge в журнале, и операция повторится
573
+ 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;
574
+ ts := @SCHEMA@.now_ts();
575
+ NEW.rev := coalesce(last.rev, 0) + 1;
576
+ NEW.prev_hash := last.hash;
577
+ NEW.at := greatest(ts, last.at + interval '1 microsecond');
578
+ NEW.created_at := NEW.at;
579
+ NEW.cv := cls.rev;
580
+ if NEW.owner is null then
581
+ NEW.owner := case res->>'ownerDefault' when 'actor' then c.account else NEW.tenant end;
582
+ elsif NEW.owner not in (NEW.tenant, c.account)
583
+ and not exists (select 1 from @SCHEMA@.entity a where a.id = NEW.owner and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
584
+ -- явный владелец — существующий аккаунт; разрешает ли его условие ACL, решает политика вставки
585
+ perform @SCHEMA@.lt_fail('target_not_found', 'владелец строки не найден');
586
+ end if;
587
+ NEW.author := c.author;
588
+ NEW.agent := c.agent;
589
+ NEW.op := 'create';
590
+ NEW.reason := nullif(current_setting('letopis.reason', true), '');
591
+ NEW.moved := null;
592
+ NEW.tags := coalesce(NEW.tags, '{}');
593
+ return NEW;
594
+ end $$;
595
+
596
+ create or replace function @SCHEMA@.trg_entity_bu() returns trigger
597
+ language plpgsql security definer
598
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
599
+ as $$
600
+ declare
601
+ c record;
602
+ cls @SCHEMA@.entity;
603
+ res jsonb;
604
+ old_res jsonb;
605
+ bad jsonb;
606
+ lk record;
607
+ kid uuid;
608
+ op text := 'update';
609
+ inc jsonb;
610
+ begin
611
+ select * into c from @SCHEMA@.ctx();
612
+ if c.account is null then
613
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
614
+ end if;
615
+ if NEW.id <> OLD.id or NEW.tenant <> OLD.tenant or NEW.owner <> OLD.owner or NEW.created_at <> OLD.created_at then
616
+ perform @SCHEMA@.lt_fail('immutable_key', 'id, арендатора, владельца и время создания менять нельзя');
617
+ end if;
618
+ if (NEW.class = 'Class') <> (OLD.class = 'Class') then
619
+ perform @SCHEMA@.lt_fail('invalid_class', 'строку Class нельзя превратить в данные и наоборот');
620
+ end if;
621
+ -- строки прав не получаются сменой класса: аккаунт с категориями — только в System (ревью этапа 5)
622
+ if NEW.class <> OLD.class and (NEW.class in ('Account', 'member', 'Resource', 'rule') or OLD.class in ('Account', 'member', 'Resource', 'rule')) then
623
+ perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
624
+ end if;
625
+ cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
626
+ if cls.id is null then
627
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
628
+ end if;
629
+ perform @SCHEMA@.family_lock(cls, false);
630
+ cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
631
+ res := cls.data->'resolved';
632
+ if coalesce((res->>'abstract')::boolean, false) then
633
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
634
+ end if;
635
+ if NEW.class <> OLD.class then
636
+ -- смена класса (этап 1, п. 9): только read committed, только для классов без ключа
637
+ if current_setting('transaction_isolation') <> 'read committed' then
638
+ perform @SCHEMA@.lt_fail('isolation_level', 'смена класса разрешена только в read committed');
639
+ end if;
640
+ old_res := (@SCHEMA@.class_row(OLD.class, OLD.tenant)).data->'resolved';
641
+ if jsonb_array_length(coalesce(old_res->'key', '[]'::jsonb)) > 0 or jsonb_array_length(coalesce(res->'key', '[]'::jsonb)) > 0 then
642
+ perform @SCHEMA@.lt_fail('use_reclass', format('у класса %s или %s есть ключ: id меняется, используйте reclass()', OLD.class, NEW.class));
643
+ end if;
644
+ -- обычный update берёт слабую блокировку; смене класса нужна for update, чтобы дождаться
645
+ -- транзакций, которые как раз пишут ссылку на эту строку
646
+ perform 1 from @SCHEMA@.entity x where x.id = OLD.id for update;
647
+ inc := @SCHEMA@.incoming_conflicts(OLD.id, NEW.class, OLD.tenant);
648
+ if inc <> '[]'::jsonb then
649
+ perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', NEW.class),
650
+ @SCHEMA@.visible_detail(inc, c.tenant));
651
+ end if;
652
+ op := 'reclass';
653
+ end if;
654
+ if NEW.class = 'Class' then
655
+ select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(NEW.data, OLD.tenant) a;
656
+ else
657
+ NEW.data := @SCHEMA@.apply_defaults(res->'schema', NEW.data);
658
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
659
+ if bad <> '[]'::jsonb then
660
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', NEW.class), jsonb_build_object('issues', bad));
661
+ end if;
662
+ NEW.validator := null;
663
+ end if;
664
+ select * into lk from @SCHEMA@.links_shape(res, NEW.links);
665
+ if lk.code is not null then
666
+ perform @SCHEMA@.lt_fail(lk.code, lk.msg);
667
+ end if;
668
+ NEW.links := lk.links;
669
+ if NEW.class = 'member' and NEW.links->>'Tenant' is distinct from OLD.tenant::text then
670
+ perform @SCHEMA@.lt_fail('invalid_data', 'конец Tenant членства — арендатор строки');
671
+ end if;
672
+ kid := @SCHEMA@.key_id(res, NEW.class, OLD.tenant, NEW.data, NEW.links);
673
+ if kid is not null and kid <> OLD.id then
674
+ perform @SCHEMA@.lt_fail('immutable_key', format('ключевое поле класса %s менять нельзя: id изменился бы (rekey)', NEW.class));
675
+ end if;
676
+ -- ничего не изменилось: строка возвращается как есть, версии нет, оператор считает её затронутой
677
+ if NEW.class = OLD.class and NEW.data = OLD.data and NEW.links = OLD.links and NEW.tags = OLD.tags then
678
+ return OLD;
679
+ end if;
680
+ if NEW.class = 'Class' and not @SCHEMA@.class_change_safe(OLD.data->'resolved', NEW.data->'resolved') then
681
+ -- ужесточение: исключительная блокировка корня семейства (и нового корня при смене родителя)
682
+ perform @SCHEMA@.family_lock(OLD, true);
683
+ perform @SCHEMA@.family_lock(NEW, true);
684
+ end if;
685
+ NEW.rev := OLD.rev + 1;
686
+ NEW.prev_hash := OLD.hash;
687
+ NEW.at := greatest(@SCHEMA@.now_ts(), OLD.at + interval '1 microsecond');
688
+ NEW.cv := cls.rev;
689
+ NEW.author := c.author;
690
+ NEW.agent := c.agent;
691
+ NEW.op := op;
692
+ NEW.reason := nullif(current_setting('letopis.reason', true), '');
693
+ NEW.moved := null;
694
+ return NEW;
695
+ end $$;
696
+
697
+ -- После оператора insert (решение 2 точки А): все версии оператора — одной вставкой в журнал
698
+ -- (по переходной таблице), сигнал — по одному на класс.
699
+ create or replace function @SCHEMA@.trg_entity_ai() returns trigger
700
+ language plpgsql security definer
701
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
702
+ as $$
703
+ declare
704
+ cls text[];
705
+ bad text;
706
+ begin
707
+ if exists (select 1 from added n where n.links <> '{}'::jsonb) then
708
+ bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
709
+ from added n where n.links <> '{}'::jsonb), true, @SCHEMA@.acl_actor('READ'));
710
+ end if;
711
+ if bad is not null then
712
+ perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
713
+ end if;
714
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
715
+ 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
716
+ from added n
717
+ join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
718
+ where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
719
+ select array_agg(distinct a.class) into cls from added a;
720
+ perform @SCHEMA@.signal(cls);
721
+ if cls && array['rule', 'Resource', 'member', 'Account', 'Class'] then
722
+ perform @SCHEMA@.acl_bump();
723
+ end if;
724
+ return null;
725
+ end $$;
726
+
727
+ -- После оператора update: версии строк, у которых вырос номер версии (пустая правка версии не
728
+ -- даёт), — одной вставкой; сигнал; для строк Class — пересчёт потомков и проверка строк семейства
729
+ -- при ужесточении.
730
+ create or replace function @SCHEMA@.trg_entity_au() returns trigger
731
+ language plpgsql security definer
732
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
733
+ as $$
734
+ declare
735
+ cls text[];
736
+ o_row @SCHEMA@.entity;
737
+ n_row @SCHEMA@.entity;
738
+ cid uuid;
739
+ bad text;
740
+ begin
741
+ -- цели проверяются у строк, у которых изменились концы или класс
742
+ if exists (select 1 from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
743
+ where n.links <> '{}'::jsonb and (n.links <> o.links or n.class <> o.class)) then
744
+ bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
745
+ 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)), true, @SCHEMA@.acl_actor('READ'));
747
+ end if;
748
+ if bad is not null then
749
+ perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
750
+ end if;
751
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
752
+ 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
753
+ from new_rows n
754
+ join old_rows o on o.id = n.id and o.rev <> n.rev
755
+ join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
756
+ where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
757
+ 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;
758
+ perform @SCHEMA@.signal(cls);
759
+ if cls && array['rule', 'Resource', 'member', 'Account', 'Class'] then
760
+ perform @SCHEMA@.acl_bump();
761
+ end if;
762
+ for cid in select n.id from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
763
+ where n.class = 'Class' order by n.id loop
764
+ select * into o_row from old_rows x where x.id = cid;
765
+ select * into n_row from new_rows x where x.id = cid;
766
+ perform @SCHEMA@.class_evolve(o_row, n_row);
767
+ end loop;
768
+ -- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
769
+ for cid in select n.id from new_rows n join old_rows o on o.id = n.id
770
+ where n.class = 'Account' and coalesce((o.data->>'enabled')::boolean, true)
771
+ and not coalesce((n.data->>'enabled')::boolean, true) loop
772
+ perform @SCHEMA@.auth_kill(cid, null);
773
+ end loop;
774
+ return null;
775
+ end $$;
776
+
777
+ create or replace function @SCHEMA@.class_evolve(o @SCHEMA@.entity, n @SCHEMA@.entity) returns void
778
+ language plpgsql volatile
779
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
780
+ as $$
781
+ declare
782
+ d @SCHEMA@.entity;
783
+ prep record;
784
+ classes text[] := array[n.data->>'name'];
785
+ conflicts jsonb;
786
+ c record;
787
+ begin
788
+ -- потомки: описание resolved и validator пересчитываются, каждый пересчёт — версия класса в журнале
789
+ for d in select x.* from @SCHEMA@.entity x
790
+ where x.class = 'Class' and x.id <> n.id
791
+ and (x.tenant = n.tenant or n.tenant = '@SYSTEM@'::uuid)
792
+ and x.data->'resolved'->'ancestors' ? (n.data->>'name')
793
+ order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.id loop
794
+ select p.data, p.validator into prep from @SCHEMA@.class_prepare(d.data, d.tenant) p;
795
+ if prep.data is distinct from d.data or prep.validator::text is distinct from d.validator::text then
796
+ perform @SCHEMA@.sys_update(d.id, prep.data, d.links, prep.validator, 'update');
797
+ end if;
798
+ classes := classes || (d.data->>'name');
799
+ end loop;
800
+ if not @SCHEMA@.class_change_safe(o.data->'resolved', n.data->'resolved') then
801
+ conflicts := @SCHEMA@.family_conflicts(classes, n.tenant, o.data->'resolved'->'ancestors' is distinct from n.data->'resolved'->'ancestors');
802
+ if conflicts <> '[]'::jsonb then
803
+ select * into c from @SCHEMA@.ctx();
804
+ perform @SCHEMA@.lt_fail('tightening_conflict', format('новому описанию класса %s не соответствуют строки', n.data->>'name'),
805
+ @SCHEMA@.visible_detail(conflicts, c.tenant));
806
+ end if;
807
+ end if;
808
+ end $$;
809
+
810
+ -- Перед оператором delete: только read committed (§2.10).
811
+ create or replace function @SCHEMA@.trg_entity_bd() returns trigger
812
+ language plpgsql security definer
813
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
814
+ as $$
815
+ begin
816
+ if current_setting('transaction_isolation') <> 'read committed' then
817
+ perform @SCHEMA@.lt_fail('isolation_level', 'удаление разрешено только в read committed');
818
+ end if;
819
+ return null;
820
+ end $$;
821
+
822
+ -- Действие со строкой, которая ссылается на удаляемые id (p_ids), по правилам её концов (p_ends):
823
+ -- cascade сильнее restrict, restrict сильнее unset; null — строка на них не ссылается. Общее правило
824
+ -- для триггера удаления и превью delete_preview() (этап 4, п. 6): превью не расходится с удалением.
825
+ create or replace function @SCHEMA@.ref_action(p_ends jsonb, p_links jsonb, p_ids uuid[]) returns text
826
+ language sql immutable parallel safe
827
+ return (
828
+ select case when bool_or(x.a = 'cascade') then 'cascade' when bool_or(x.a = 'restrict') then 'restrict'
829
+ when bool_or(x.a = 'unset') then 'unset' end
830
+ from (select coalesce(p_ends->k.key->>'onDelete', 'restrict') as a
831
+ from jsonb_each(p_links) k
832
+ where exists (select 1 from jsonb_array_elements_text(case jsonb_typeof(k.value) when 'array' then k.value
833
+ else jsonb_build_array(k.value) end) t
834
+ where t::uuid = any (p_ids))) x
835
+ );
836
+
837
+ -- Строки арендатора, которыми владел пользователь, переходят арендатору (выход из членства, §2.10):
838
+ -- новая версия каждой строки с журналом. Индекс entity_owner — только строки с владельцем-не арендатором.
839
+ create or replace function @SCHEMA@.owner_to_tenant(p_tenant uuid, p_user uuid) returns int
840
+ language plpgsql volatile
841
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
842
+ as $$
843
+ declare
844
+ c record;
845
+ r @SCHEMA@.entity;
846
+ n int := 0;
847
+ begin
848
+ if p_tenant is null or p_user is null or p_tenant = p_user then
849
+ return 0;
850
+ end if;
851
+ select * into c from @SCHEMA@.ctx();
852
+ for r in select x.* from @SCHEMA@.entity x where x.owner = p_user and x.owner <> x.tenant and x.tenant = p_tenant
853
+ order by x.id for update loop
854
+ update @SCHEMA@.entity x
855
+ set owner = p_tenant, rev = x.rev + 1, prev_hash = x.hash,
856
+ at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
857
+ author = coalesce(c.author, '@SYSTEM@'::uuid), agent = c.agent, op = 'update',
858
+ reason = nullif(current_setting('letopis.reason', true), ''), moved = null
859
+ where x.id = r.id
860
+ returning x.* into r;
861
+ perform @SCHEMA@.log_version(r);
862
+ n := n + 1;
863
+ end loop;
864
+ return n;
865
+ end $$;
866
+
867
+ -- После оператора delete (§2.5, этап 1, п. 8): надгробия, каскад по уровням, restrict по итогу.
868
+ -- Строки, которые удалил сам оператор, каскад уже не видит, поэтому сырой delete любого набора
869
+ -- связанных строк проходит без ошибки 27000. Надгробия уровня пишутся одной вставкой.
870
+ create or replace function @SCHEMA@.trg_entity_ad() returns trigger
871
+ language plpgsql security definer
872
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
873
+ as $$
874
+ declare
875
+ c record;
876
+ r record;
877
+ lvl uuid[];
878
+ all_ids uuid[];
879
+ casc uuid[];
880
+ unsets uuid[];
881
+ classes text[];
882
+ lvl_classes text[];
883
+ v_author uuid;
884
+ v_reason text := nullif(current_setting('letopis.reason', true), '');
885
+ newlinks jsonb;
886
+ k record;
887
+ blockers jsonb;
888
+ dplan jsonb;
889
+ wplan jsonb;
890
+ denied text;
891
+ accs uuid[];
892
+ mems jsonb;
893
+ lvl_accs uuid[];
894
+ lvl_mems jsonb;
895
+ begin
896
+ select * into c from @SCHEMA@.ctx();
897
+ -- права в замыкании (этап 5, п. 6): DELETE на каждую удаляемую каскадом строку, WRITE — на строку,
898
+ -- у которой снимается конец
899
+ dplan := @SCHEMA@.acl_plan('DELETE');
900
+ wplan := @SCHEMA@.acl_plan('WRITE');
901
+ if exists (select 1 from @SCHEMA@.entity x join gone g on g.id = x.id) then
902
+ perform @SCHEMA@.lt_fail('recreated_in_statement', 'id удалён и создан заново одним оператором');
903
+ end if;
904
+ for r in select g.id, g.tenant, g.data->>'name' as name from gone g where g.class = 'Class' loop
905
+ if exists (select 1 from @SCHEMA@.entity x where x.class = r.name and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid))
906
+ or exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.data->>'parent' = r.name
907
+ and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid)) then
908
+ perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть строки или потомки', r.name));
909
+ end if;
910
+ end loop;
911
+ v_author := coalesce(c.author, '@SYSTEM@'::uuid);
912
+ -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
913
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
914
+ select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
915
+ greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash
916
+ from gone g
917
+ join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
918
+ where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant;
919
+ select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
920
+ coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
921
+ coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
922
+ into lvl, classes, accs, mems from gone g;
923
+ all_ids := lvl;
924
+ while cardinality(lvl) > 0 loop
925
+ -- кто ссылается на уровень: действие по правилам концов (общее с превью)
926
+ with refs as (
927
+ select x.id, x.class, x.tenant, x.links from @SCHEMA@.entity x where x.ends && lvl
928
+ ), cls as (
929
+ select d.class, d.tenant, (@SCHEMA@.class_row(d.class, d.tenant)).data->'resolved'->'ends' as ends
930
+ from (select distinct refs.class, refs.tenant from refs) d
931
+ ), act as (
932
+ select refs.id, @SCHEMA@.ref_action(cls.ends, refs.links, all_ids) as a
933
+ from refs join cls on cls.class = refs.class and cls.tenant = refs.tenant
934
+ )
935
+ select coalesce(array_agg(act.id order by act.id) filter (where act.a = 'cascade'), '{}'),
936
+ coalesce(array_agg(act.id order by act.id) filter (where act.a = 'unset'), '{}')
937
+ into casc, unsets from act;
938
+ -- каскад: удаление в порядке id берёт блокировки и ждёт тех, кто как раз пишет ссылки
939
+ with d as (
940
+ delete from @SCHEMA@.entity x
941
+ where x.id in (select y.id from @SCHEMA@.entity y where y.id = any (casc) order by y.id for update)
942
+ returning x.*
943
+ ), t as (
944
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
945
+ select d.id, d.rev + 1, d.class, d.cv, d.tenant, d.owner, d.links, d.data, d.tags,
946
+ greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash
947
+ from d
948
+ join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
949
+ where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
950
+ )
951
+ select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
952
+ 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)),
953
+ coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
954
+ coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
955
+ into lvl, lvl_classes, denied, lvl_accs, lvl_mems from d;
956
+ if denied is not null then
957
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s в каскаде удаления', denied));
958
+ end if;
959
+ accs := accs || lvl_accs;
960
+ mems := mems || lvl_mems;
961
+ classes := classes || lvl_classes;
962
+ -- unset: снять конец, новая версия
963
+ for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
964
+ if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
965
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s: снятие конца при удалении', r.class));
966
+ end if;
967
+ newlinks := '{}'::jsonb;
968
+ for k in select key, value from jsonb_each(r.links) loop
969
+ if jsonb_typeof(k.value) = 'array' then
970
+ newlinks := newlinks || jsonb_build_object(k.key, coalesce(
971
+ (select jsonb_agg(t) from jsonb_array_elements_text(k.value) t where not (t::uuid = any (all_ids))), '[]'::jsonb));
972
+ elsif not ((k.value #>> '{}')::uuid = any (all_ids)) then
973
+ newlinks := newlinks || jsonb_build_object(k.key, k.value);
974
+ end if;
975
+ end loop;
976
+ perform @SCHEMA@.sys_update(r.id, r.data, newlinks, r.validator, 'update');
977
+ end loop;
978
+ all_ids := all_ids || lvl;
979
+ end loop;
980
+ -- выход из членства (и удаление аккаунта, которое снимает его членства): строки арендатора, которыми
981
+ -- владел участник, переходят арендатору (§2.10) — новой версией
982
+ for r in select distinct (m->>'tenant')::uuid as tenant, (m->>'user')::uuid as usr from jsonb_array_elements(mems) m loop
983
+ perform @SCHEMA@.owner_to_tenant(r.tenant, r.usr);
984
+ end loop;
985
+ -- удалённый аккаунт: сессии гаснут, креды удаляются (идентификаторы освобождаются)
986
+ for r in select distinct u as acc from unnest(accs) u loop
987
+ perform @SCHEMA@.auth_kill(r.acc, null);
988
+ update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
989
+ end loop;
990
+ perform @SCHEMA@.signal(array(select distinct x from unnest(classes) x));
991
+ if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
992
+ perform @SCHEMA@.acl_bump();
993
+ end if;
994
+ -- restrict — одним запросом после всего каскада
995
+ select coalesce(jsonb_agg(jsonb_build_object('id', x.id, 'class', x.class, 'tenant', x.tenant)), '[]'::jsonb) into blockers
996
+ from (select x.id, x.class, x.tenant from @SCHEMA@.entity x where x.ends && all_ids order by x.id limit 1000) x;
997
+ if blockers <> '[]'::jsonb then
998
+ perform @SCHEMA@.lt_fail('delete_restricted', 'на удаляемые строки ссылаются другие строки (onDelete: restrict)',
999
+ @SCHEMA@.visible_detail(blockers, c.tenant));
1000
+ end if;
1001
+ return null;
1002
+ end $$;
1003
+
1004
+ -- Превью удаления (этап 4, п. 6): что удалит delete этих строк — те же уровни и то же правило
1005
+ -- ref_action(), что у триггера, но без изменений. Строки: action delete (цели — depth 0, каскад —
1006
+ -- уровень), unset (конец будет снят) и restrict (мешает удалению). Функция владельца ищет по
1007
+ -- индексу ends без RLS (§3 «Индексы под RLS»), поэтому и цели, и выдачу пропускает через
1008
+ -- row_visible() — то же условие, что у политики чтения (как find_*): невидимых строк не раскрывает
1009
+ -- ни id, ни классом. О невидимых мешающих строках скажет само удаление (visible_detail: число).
1010
+ create or replace function @SCHEMA@.delete_preview(p_ids uuid[]) returns table(id uuid, class text, action text, depth int)
1011
+ language plpgsql stable security definer
1012
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1013
+ as $$
1014
+ #variable_conflict use_column
1015
+ declare
1016
+ a jsonb := @SCHEMA@.acl_actor('READ');
1017
+ c_tenant uuid := (a->>'tenant')::uuid;
1018
+ c_account uuid := (a->>'account')::uuid;
1019
+ c_plan jsonb := a->'plan';
1020
+ lvl uuid[];
1021
+ all_ids uuid[];
1022
+ casc uuid[];
1023
+ d int := 0;
1024
+ out_rows jsonb := '[]'::jsonb;
1025
+ begin
1026
+ if c_account is null then
1027
+ return;
1028
+ end if;
1029
+ -- цели — только видимые: превью не подтверждает существование чужих строк
1030
+ select coalesce(array_agg(x.id order by x.id), '{}') into lvl
1031
+ from @SCHEMA@.entity x
1032
+ 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);
1033
+ out_rows := (select coalesce(jsonb_agg(jsonb_build_array(u, 'delete', 0)), '[]'::jsonb) from unnest(lvl) u);
1034
+ all_ids := lvl;
1035
+ while cardinality(lvl) > 0 loop
1036
+ d := d + 1;
1037
+ with refs as (
1038
+ select x.id, x.class, x.tenant, x.links from @SCHEMA@.entity x
1039
+ where x.ends && lvl and not (x.id = any (all_ids))
1040
+ ), cls as (
1041
+ select dd.class, dd.tenant, (@SCHEMA@.class_row(dd.class, dd.tenant)).data->'resolved'->'ends' as ends
1042
+ from (select distinct refs.class, refs.tenant from refs) dd
1043
+ ), act as (
1044
+ select refs.id, @SCHEMA@.ref_action(cls.ends, refs.links, all_ids) as a
1045
+ from refs join cls on cls.class = refs.class and cls.tenant = refs.tenant
1046
+ )
1047
+ select coalesce(array_agg(act.id order by act.id) filter (where act.a = 'cascade'), '{}') into casc from act;
1048
+ out_rows := out_rows || (select coalesce(jsonb_agg(jsonb_build_array(u, 'delete', d)), '[]'::jsonb) from unnest(casc) u);
1049
+ all_ids := all_ids || casc;
1050
+ lvl := casc;
1051
+ end loop;
1052
+ -- кто останется ссылаться на удалённое: снимаемые концы и мешающие строки
1053
+ out_rows := out_rows || (
1054
+ select coalesce(jsonb_agg(jsonb_build_array(r.id, case when r.a = 'unset' then 'unset' else 'restrict' end, null)), '[]'::jsonb)
1055
+ from (select x.id, @SCHEMA@.ref_action((@SCHEMA@.class_row(x.class, x.tenant)).data->'resolved'->'ends', x.links, all_ids) as a
1056
+ from @SCHEMA@.entity x where x.ends && all_ids and not (x.id = any (all_ids))) r);
1057
+ return query
1058
+ select (o->>0)::uuid, e.class, o->>1, (o->>2)::int
1059
+ from jsonb_array_elements(out_rows) o
1060
+ join @SCHEMA@.entity e on e.id = (o->>0)::uuid
1061
+ where @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
1062
+ end $$;
1063
+
1064
+ create or replace trigger entity_bi before insert on @SCHEMA@.entity
1065
+ for each row when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bi();
1066
+ create or replace trigger entity_bu before update on @SCHEMA@.entity
1067
+ for each row when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bu();
1068
+ -- до ревизии 2 журнал писал строковый триггер entity_ai после insert или update
1069
+ drop trigger if exists entity_ai on @SCHEMA@.entity;
1070
+ create trigger entity_ai after insert on @SCHEMA@.entity referencing new table as added
1071
+ for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ai();
1072
+ create or replace trigger entity_au after update on @SCHEMA@.entity referencing old table as old_rows new table as new_rows
1073
+ for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_au();
1074
+ create or replace trigger entity_bd before delete on @SCHEMA@.entity
1075
+ for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bd();
1076
+ create or replace trigger entity_ad after delete on @SCHEMA@.entity referencing old table as gone
1077
+ for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ad();
1078
+ -- в режиме репликации (session_replication_role = replica) триггеры тоже работают
1079
+ alter table @SCHEMA@.entity enable always trigger entity_bi;
1080
+ alter table @SCHEMA@.entity enable always trigger entity_bu;
1081
+ alter table @SCHEMA@.entity enable always trigger entity_ai;
1082
+ alter table @SCHEMA@.entity enable always trigger entity_au;
1083
+ alter table @SCHEMA@.entity enable always trigger entity_bd;
1084
+ alter table @SCHEMA@.entity enable always trigger entity_ad;
1085
+
1086
+ -- ========================= смена класса с ключом =========================
1087
+
1088
+ -- Замена id цели во всех концах.
1089
+ create or replace function @SCHEMA@.links_replace(p_links jsonb, p_old uuid, p_new uuid) returns jsonb
1090
+ language sql immutable parallel safe
1091
+ return (
1092
+ select coalesce(jsonb_object_agg(k.key, case jsonb_typeof(k.value)
1093
+ when 'array' then coalesce((select jsonb_agg(case when x = p_old::text then to_jsonb(p_new::text) else to_jsonb(x) end)
1094
+ from jsonb_array_elements_text(k.value) x), '[]'::jsonb)
1095
+ else case when k.value #>> '{}' = p_old::text then to_jsonb(p_new::text) else k.value end end), '{}'::jsonb)
1096
+ from jsonb_each(p_links) k
1097
+ );
1098
+
1099
+ -- Перенос строки на новый id (смена класса с ключом, смена ключа): новая строка продолжает журнал
1100
+ -- нового id и ссылается на старый (moved), ссылки переводятся — если переведённый конец входит
1101
+ -- в ключ ссылающейся строки, её id тоже меняется, и перевод идёт дальше рекурсивно; старая строка
1102
+ -- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая функция.
1103
+ 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)
1104
+ returns @SCHEMA@.entity
1105
+ language plpgsql volatile
1106
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1107
+ as $$
1108
+ declare
1109
+ c record;
1110
+ last record;
1111
+ ts timestamptz;
1112
+ n @SCHEMA@.entity;
1113
+ r @SCHEMA@.entity;
1114
+ nl jsonb;
1115
+ rid uuid;
1116
+ begin
1117
+ select * into c from @SCHEMA@.ctx();
1118
+ 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;
1119
+ ts := greatest(@SCHEMA@.now_ts(), last.at + interval '1 microsecond');
1120
+ insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator)
1121
+ 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,
1122
+ p_links, p_data, o.tags, ts, ts, coalesce(c.author, '@SYSTEM@'::uuid), c.agent, p_op,
1123
+ nullif(current_setting('letopis.reason', true), ''), o.id, last.hash, null)
1124
+ returning * into n;
1125
+ perform @SCHEMA@.log_version(n);
1126
+ 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
1127
+ nl := @SCHEMA@.links_replace(r.links, o.id, p_new_id);
1128
+ rid := coalesce(@SCHEMA@.key_id((@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved', r.class, r.tenant, r.data, nl), r.id);
1129
+ if rid <> r.id then
1130
+ perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey');
1131
+ else
1132
+ perform @SCHEMA@.sys_update(r.id, r.data, nl, r.validator, 'update');
1133
+ end if;
1134
+ end loop;
1135
+ delete from @SCHEMA@.entity x where x.id = o.id returning x.* into r;
1136
+ -- надгробие всегда вида delete (по нему срез на момент отличает удалённый id), moved — новый id
1137
+ perform @SCHEMA@.tombstone(r, 'delete', p_new_id);
1138
+ return n;
1139
+ end $$;
1140
+
1141
+ -- Смена класса (этап 1, п. 9): у класса с ключом id вычисляется заново для нового класса, ссылки
1142
+ -- переводятся в той же транзакции, журнал связывает старый и новый id. У класса без ключа id
1143
+ -- сохраняется. Только read committed; на строку сначала берётся for update.
1144
+ create or replace function @SCHEMA@.reclass(p_id uuid, p_class text, p_data jsonb default null) returns uuid
1145
+ language plpgsql volatile security definer
1146
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1147
+ as $$
1148
+ declare
1149
+ c record;
1150
+ o @SCHEMA@.entity;
1151
+ cls @SCHEMA@.entity;
1152
+ res jsonb;
1153
+ d jsonb;
1154
+ bad jsonb;
1155
+ lk record;
1156
+ nid uuid;
1157
+ inc jsonb;
1158
+ rd jsonb;
1159
+ wplan jsonb;
1160
+ denied text;
1161
+ begin
1162
+ select * into c from @SCHEMA@.ctx();
1163
+ if c.account is null then
1164
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
1165
+ end if;
1166
+ if current_setting('transaction_isolation') <> 'read committed' then
1167
+ perform @SCHEMA@.lt_fail('isolation_level', 'смена класса разрешена только в read committed');
1168
+ end if;
1169
+ rd := @SCHEMA@.acl_actor('READ');
1170
+ wplan := @SCHEMA@.acl_plan('WRITE');
1171
+ select x.* into o from @SCHEMA@.entity x where x.id = p_id and x.tenant = c.tenant for update;
1172
+ 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
1173
+ perform @SCHEMA@.lt_fail('target_not_found', 'цель не найдена');
1174
+ end if;
1175
+ if not @SCHEMA@.acl_row_ok(wplan, o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
1176
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', o.class));
1177
+ end if;
1178
+ -- строки прав и классов функция не трогает
1179
+ if o.class in ('Class', 'Account', 'member', 'Resource', 'rule') or p_class in ('Class', 'Account', 'member', 'Resource', 'rule') then
1180
+ perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
1181
+ end if;
1182
+ cls := @SCHEMA@.class_row(p_class, o.tenant);
1183
+ if cls.id is null then
1184
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', p_class));
1185
+ end if;
1186
+ perform @SCHEMA@.family_lock(cls, false);
1187
+ cls := @SCHEMA@.class_row(p_class, o.tenant);
1188
+ res := cls.data->'resolved';
1189
+ if coalesce((res->>'abstract')::boolean, false) then
1190
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
1191
+ end if;
1192
+ d := @SCHEMA@.apply_defaults(res->'schema', coalesce(p_data, o.data));
1193
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
1194
+ if bad <> '[]'::jsonb then
1195
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', p_class), jsonb_build_object('issues', bad));
1196
+ end if;
1197
+ select * into lk from @SCHEMA@.check_links(res, o.links, o.tenant, true, rd);
1198
+ if lk.code is not null then
1199
+ perform @SCHEMA@.lt_fail(lk.code, lk.msg);
1200
+ end if;
1201
+ inc := @SCHEMA@.incoming_conflicts(o.id, p_class, o.tenant);
1202
+ if inc <> '[]'::jsonb then
1203
+ perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', p_class),
1204
+ @SCHEMA@.visible_detail(inc, c.tenant));
1205
+ end if;
1206
+ nid := coalesce(@SCHEMA@.key_id(res, p_class, o.tenant, d, lk.links), o.id);
1207
+ if not @SCHEMA@.acl_row_ok(wplan, p_class, nid, o.owner, o.tenant, o.tags, d, lk.links) then
1208
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s', p_class));
1209
+ end if;
1210
+ if nid <> o.id then
1211
+ -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE
1212
+ select min(x.class) into denied from @SCHEMA@.entity x
1213
+ 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);
1214
+ if denied is not null then
1215
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на ссылающиеся строки класса %s', denied));
1216
+ end if;
1217
+ end if;
1218
+ if nid = o.id then
1219
+ perform @SCHEMA@.sys_update(o.id, d, lk.links, null, 'reclass', p_class);
1220
+ else
1221
+ perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass');
1222
+ end if;
1223
+ return nid;
1224
+ end $$;
1225
+
1226
+ -- ========================= merge, inc, links_patch =========================
1227
+
1228
+ -- Правка концов слотами (этап 4, п. 4): .Роль.set(), .unset(), а для множественных концов .add()
1229
+ -- и .remove() — атомарно над свежей строкой, без предварительного чтения списка. p_ops — список
1230
+ -- [{role, op: set | unset | add | remove, value}]: у set — id, список id или null; у add и remove —
1231
+ -- список id. Повтор в add не добавляется, отсутствующий в remove — пропускается. Проверку концов
1232
+ -- делают триггеры, как при любой правке.
1233
+ create or replace function @SCHEMA@.links_patch(p_links jsonb, p_ops jsonb) returns jsonb
1234
+ language plpgsql immutable parallel safe
1235
+ as $$
1236
+ declare
1237
+ o jsonb;
1238
+ role text;
1239
+ res jsonb := coalesce(p_links, '{}'::jsonb);
1240
+ cur jsonb;
1241
+ v jsonb;
1242
+ begin
1243
+ for o in select x from jsonb_array_elements(coalesce(p_ops, '[]'::jsonb)) x loop
1244
+ role := o->>'role';
1245
+ case o->>'op'
1246
+ when 'set' then
1247
+ if o->'value' is null or o->'value' = 'null'::jsonb then
1248
+ res := res - role;
1249
+ else
1250
+ res := jsonb_set(res, array[role], o->'value');
1251
+ end if;
1252
+ when 'unset' then
1253
+ res := res - role;
1254
+ when 'add' then
1255
+ cur := coalesce(res->role, '[]'::jsonb);
1256
+ if jsonb_typeof(cur) = 'array' then
1257
+ for v in select x from jsonb_array_elements(o->'value') x loop
1258
+ v := to_jsonb(lower(v #>> '{}'));
1259
+ if not cur @> jsonb_build_array(v) then
1260
+ cur := cur || jsonb_build_array(v);
1261
+ end if;
1262
+ end loop;
1263
+ end if;
1264
+ res := jsonb_set(res, array[role], cur);
1265
+ when 'remove' then
1266
+ cur := coalesce(res->role, '[]'::jsonb);
1267
+ if jsonb_typeof(cur) = 'array' then
1268
+ cur := coalesce((select jsonb_agg(x) from jsonb_array_elements(cur) x
1269
+ where not exists (select 1 from jsonb_array_elements_text(o->'value') r where lower(r) = x #>> '{}')), '[]'::jsonb);
1270
+ end if;
1271
+ res := jsonb_set(res, array[role], cur);
1272
+ else
1273
+ raise exception using errcode = 'LT003', message = 'letopis: invalid_data: links_patch: неизвестная операция ' || coalesce(o->>'op', 'null');
1274
+ end case;
1275
+ end loop;
1276
+ return res;
1277
+ end $$;
1278
+
1279
+ -- Слияние патча (§2.5): повторяет deepMerge 0.21 без исключений — объекты сливаются рекурсивно,
1280
+ -- массивы, скаляры и null заменяются. Ключи на $ — обычные данные.
1281
+ create or replace function @SCHEMA@.merge(base jsonb, patch jsonb) returns jsonb
1282
+ language plpgsql immutable parallel safe
1283
+ as $$
1284
+ declare
1285
+ k text;
1286
+ v jsonb;
1287
+ res jsonb := coalesce(base, '{}'::jsonb);
1288
+ begin
1289
+ if patch is null then
1290
+ return res;
1291
+ end if;
1292
+ if jsonb_typeof(patch) <> 'object' or jsonb_typeof(res) <> 'object' then
1293
+ return patch;
1294
+ end if;
1295
+ for k, v in select key, value from jsonb_each(patch) loop
1296
+ if jsonb_typeof(v) = 'object' and jsonb_typeof(res->k) = 'object' then
1297
+ res := jsonb_set(res, array[k], @SCHEMA@.merge(res->k, v));
1298
+ else
1299
+ res := res || jsonb_build_object(k, v);
1300
+ end if;
1301
+ end loop;
1302
+ return res;
1303
+ end $$;
1304
+
1305
+ -- Приращение числа по пути (§2.5): путь только через существующие объекты; отсутствующее поле —
1306
+ -- ошибка без start; null и не число — ошибка и со start. Сумма считается в numeric точно.
1307
+ create or replace function @SCHEMA@.inc(data jsonb, path text[], delta numeric, start numeric default null) returns jsonb
1308
+ language plpgsql immutable parallel safe
1309
+ as $$
1310
+ declare
1311
+ cur jsonb := data;
1312
+ v jsonb;
1313
+ i int;
1314
+ fail text;
1315
+ begin
1316
+ if path is null or cardinality(path) = 0 or delta is null then
1317
+ fail := 'inc: нужны путь и величина';
1318
+ else
1319
+ for i in 1 .. cardinality(path) - 1 loop
1320
+ if jsonb_typeof(cur) is distinct from 'object' or not cur ? path[i] then
1321
+ fail := format('inc: путь %s идёт через отсутствующий объект', array_to_string(path, '.'));
1322
+ exit;
1323
+ end if;
1324
+ cur := cur->path[i];
1325
+ end loop;
1326
+ if fail is null and jsonb_typeof(cur) is distinct from 'object' then
1327
+ fail := format('inc: путь %s идёт не через объект', array_to_string(path, '.'));
1328
+ end if;
1329
+ if fail is null then
1330
+ v := cur->path[cardinality(path)];
1331
+ if v is null then
1332
+ if start is null then
1333
+ fail := format('inc: поля %s нет — задайте start', array_to_string(path, '.'));
1334
+ else
1335
+ return jsonb_set(data, path, to_jsonb(start + delta), true);
1336
+ end if;
1337
+ elsif jsonb_typeof(v) <> 'number' then
1338
+ fail := format('inc: в поле %s не число', array_to_string(path, '.'));
1339
+ else
1340
+ return jsonb_set(data, path, to_jsonb((v #>> '{}')::numeric + delta));
1341
+ end if;
1342
+ end if;
1343
+ end if;
1344
+ raise exception using errcode = 'LT003', message = 'letopis: invalid_data: ' || fail,
1345
+ detail = jsonb_build_object('issues', jsonb_build_array(jsonb_build_object(
1346
+ 'path', to_jsonb(coalesce(path, '{}')), 'keyword', 'inc', 'message', fail)))::text;
1347
+ end $$;
1348
+
1349
+ do $$
1350
+ declare f text;
1351
+ begin
1352
+ foreach f in array array[
1353
+ 'now_ts()', 'family_lock(@SCHEMA@.entity, boolean)', 'class_set(jsonb)', 'class_accepts(jsonb, text, uuid)', 'role_accepts(text, uuid, text, text, uuid)',
1354
+ '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)',
1355
+ 'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text, text)', 'tombstone(@SCHEMA@.entity, text, uuid)',
1356
+ 'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text)', 'reclass(uuid, text, jsonb)',
1357
+ 'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'class_change_safe(jsonb, jsonb)',
1358
+ 'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_admit(jsonb, uuid)',
1359
+ 'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal(text[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
1360
+ '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
1361
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
1362
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
1363
+ end loop;
1364
+ end $$;
1365
+ -- сырой SQL пишет теми же merge, inc и links_patch, что и библиотека; verify() — в 90-time.sql
1366
+ -- до этапа 6 verify() был без параметров
1367
+ drop function if exists @SCHEMA@.verify();
1368
+ grant execute on function @SCHEMA@.merge(jsonb, jsonb), @SCHEMA@.inc(jsonb, text[], numeric, numeric), @SCHEMA@.links_patch(jsonb, jsonb),
1369
+ @SCHEMA@.reserved_names(), @SCHEMA@.reclass(uuid, text, jsonb), @SCHEMA@.delete_preview(uuid[]) to @APP@;