letopis 1.0.1 → 1.2.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 (60) hide show
  1. package/AGENT-CHEATSHEET.en.md +117 -10
  2. package/AGENT-CHEATSHEET.md +110 -8
  3. package/CHANGELOG.md +739 -0
  4. package/README.en.md +873 -68
  5. package/README.md +830 -66
  6. package/dist/admin.d.ts +24 -4
  7. package/dist/admin.js +39 -9
  8. package/dist/cache.d.ts +33 -9
  9. package/dist/cache.js +81 -32
  10. package/dist/chain.d.ts +29 -7
  11. package/dist/chain.js +61 -12
  12. package/dist/cli.js +59 -7
  13. package/dist/errors.js +4 -2
  14. package/dist/import.d.ts +6 -0
  15. package/dist/import.js +196 -175
  16. package/dist/index.d.ts +27 -5
  17. package/dist/index.js +191 -29
  18. package/dist/internal.d.ts +16 -0
  19. package/dist/internal.js +14 -0
  20. package/dist/load.d.ts +63 -0
  21. package/dist/load.js +331 -44
  22. package/dist/mcp.d.ts +28 -0
  23. package/dist/mcp.js +810 -0
  24. package/dist/model.d.ts +34 -0
  25. package/dist/model.js +17 -0
  26. package/dist/project.d.ts +309 -0
  27. package/dist/project.js +1097 -0
  28. package/dist/registry.d.ts +17 -0
  29. package/dist/registry.js +13 -0
  30. package/dist/signals.d.ts +73 -0
  31. package/dist/signals.js +169 -0
  32. package/dist/sql.d.ts +1 -1
  33. package/dist/sql.js +26 -7
  34. package/dist/tx.d.ts +9 -7
  35. package/dist/tx.js +15 -5
  36. package/dist/typed.d.ts +21 -6
  37. package/dist/types.d.ts +77 -4
  38. package/dist/types.js +8 -3
  39. package/dist/up.d.ts +16 -2
  40. package/dist/up.js +34 -4
  41. package/dist/uuid.js +19 -8
  42. package/dist/watch.d.ts +23 -2
  43. package/dist/watch.js +44 -7
  44. package/dist/write.d.ts +11 -2
  45. package/dist/write.js +57 -10
  46. package/llms.txt +9 -2
  47. package/package.json +13 -2
  48. package/sql/10-core.sql +13 -2
  49. package/sql/15-errors.sql +4 -1
  50. package/sql/40-class.sql +57 -2
  51. package/sql/60-write.sql +611 -82
  52. package/sql/70-read.sql +33 -8
  53. package/sql/75-history.sql +358 -0
  54. package/sql/80-auth.sql +7 -7
  55. package/sql/88-derived.sql +1154 -0
  56. package/sql/90-time.sql +134 -21
  57. package/sql/95-seed.system.sql +24 -6
  58. package/sql/99-revision.sql +1 -1
  59. package/sql/README.md +5 -1
  60. package/sql/migrate/0006-changed-paths.sql +3 -0
@@ -0,0 +1,1154 @@
1
+ -- letopis 1.2 — производные представления (план 1.1, §2.4): проекторы из журнала (этап 4а) и агрегаты в базе
2
+ -- (этап 4б, раздел «Агрегаты» ниже).
3
+ --
4
+ -- Проектор — процесс администратора, как загрузчик: работает от роли владельца (set local role),
5
+ -- читает журнал всех арендаторов функцией watch_read и пишет строки производных классов (derivedBy)
6
+ -- только функцией derived_apply. Чекпоинт — строка secret вида PROJECTOR (account = System, ident —
7
+ -- имя проектора): курсор журнала со снимком, версия проектора, состояние, выбывшие арендаторы. Курсор
8
+ -- сдвигается в той же транзакции, что и запись результатов; писатель один — транзакционная
9
+ -- advisory-блокировка (19542, «схема:имя») на каждую пачку. Функции кредов вид PROJECTOR не видят
10
+ -- (80-auth.sql). Приложению функции файла не выданы: их вызывает только роль владельца.
11
+
12
+ -- Чекпоинт проектора: строка создаётся при первом обращении и блокируется до конца транзакции. Чекпоинт — только
13
+ -- строка System (ревью безопасности №2): строка вида PROJECTOR другого аккаунта им не бывает. Такая строка с тем же
14
+ -- именем заняла бы уникальный индекс secret_kind_ident, и своя не записалась бы, — она помечается удалённой, как при
15
+ -- накате (ниже).
16
+ create or replace function @SCHEMA@.projector_state(p_name text) returns jsonb
17
+ language plpgsql volatile
18
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
19
+ as $$
20
+ declare
21
+ m jsonb;
22
+ begin
23
+ select s.meta into m from @SCHEMA@.secret s
24
+ where s.kind = 'PROJECTOR' and s.ident = p_name and s.account = '@SYSTEM@'::uuid and s.deleted_at is null
25
+ for update;
26
+ if not found then
27
+ update @SCHEMA@.secret s set deleted_at = clock_timestamp()
28
+ where s.kind = 'PROJECTOR' and casefold(s.ident) = casefold(p_name) and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
29
+ insert into @SCHEMA@.secret (account, kind, ident, meta) values ('@SYSTEM@'::uuid, 'PROJECTOR', p_name, '{}'::jsonb);
30
+ return '{}'::jsonb;
31
+ end if;
32
+ return coalesce(m, '{}'::jsonb);
33
+ end $$;
34
+
35
+ -- Сдвиг чекпоинта: только если курсор тот, что прочитан в начале пачки (иначе false — пачку откатывают).
36
+ create or replace function @SCHEMA@.projector_advance(p_name text, p_expected text, p_meta jsonb) returns boolean
37
+ language plpgsql volatile
38
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
39
+ as $$
40
+ begin
41
+ update @SCHEMA@.secret s set meta = p_meta
42
+ where s.kind = 'PROJECTOR' and s.ident = p_name and s.account = '@SYSTEM@'::uuid and s.deleted_at is null
43
+ and s.meta->>'cursor' is not distinct from p_expected;
44
+ return found;
45
+ end $$;
46
+
47
+ -- Строки вида PROJECTOR других аккаунтов — не чекпоинты (ревью безопасности №2): при накате помечаются удалёнными;
48
+ -- повторный накат их уже не находит.
49
+ update @SCHEMA@.secret s set deleted_at = clock_timestamp()
50
+ where s.kind = 'PROJECTOR' and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
51
+
52
+ -- Строка проектора в формате загрузчика: поля и роли концов на верхнем уровне, служебные $tags, $owner,
53
+ -- $id; ключи на $ внутри данных — обычные данные. Делится на данные и концы по описанию класса.
54
+ create or replace function @SCHEMA@.derived_split(p_res jsonb, p_row jsonb, out data jsonb, out links jsonb)
55
+ language sql immutable
56
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
57
+ as $$
58
+ select coalesce(jsonb_object_agg(x.key, x.value) filter (where not coalesce(p_res->'ends' ? x.key, false) and left(x.key, 1) <> '$'), '{}'::jsonb),
59
+ coalesce(jsonb_object_agg(x.key, x.value) filter (where coalesce(p_res->'ends' ? x.key, false)), '{}'::jsonb)
60
+ from jsonb_each(coalesce(p_row, '{}'::jsonb)) x
61
+ $$;
62
+
63
+ -- id строки проектора: v5 от ключа класса; у класса без ключа — $id строки (null — не задан).
64
+ create or replace function @SCHEMA@.derived_id(p_class text, p_tenant uuid, p_row jsonb) returns uuid
65
+ language plpgsql stable
66
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
67
+ as $$
68
+ declare
69
+ res jsonb := (@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved';
70
+ sp record;
71
+ kid uuid;
72
+ begin
73
+ select * into sp from @SCHEMA@.derived_split(res, p_row);
74
+ kid := @SCHEMA@.key_id(res, p_class, p_tenant, sp.data, sp.links);
75
+ if kid is null and pg_input_is_valid(p_row->>'$id', 'uuid') then
76
+ kid := (p_row->>'$id')::uuid;
77
+ end if;
78
+ return kid;
79
+ end $$;
80
+
81
+ -- Запись результатов проектора p_name. Операции — [{op, class, tenant, row, deltas}]:
82
+ -- upsert — создать или заменить целиком по ключу;
83
+ -- inc — создать или дополнить данными строки и прибавить deltas {поле: число} (нет поля — от 0);
84
+ -- delete — удалить по ключу или $id (строки нет — ничего).
85
+ -- row — строка в формате загрузчика (derived_split); $id нужен только классу без ключа, $owner — при
86
+ -- создании. Класс — производный именно этого проектора в арендаторе строки; данные — по описанию класса
87
+ -- (умолчания, проверка); концы — живые цели своего арендатора или System подходящего класса, не производные;
88
+ -- id — v5 от ключа. Пустая правка версии не даёт. Журнал — по history класса, сигнал — всегда (log_version,
89
+ -- tombstone). Текст ошибки называет проектор, арендатора и id строки (ревью безопасности №2): по нему видно, чьи
90
+ -- данные проектор не записал. Возвращает {created, updated, deleted, unchanged}.
91
+ create or replace function @SCHEMA@.derived_apply(p_name text, p_ops jsonb) returns jsonb
92
+ language plpgsql volatile
93
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
94
+ as $$
95
+ declare
96
+ o jsonb;
97
+ op text;
98
+ cname text;
99
+ tid uuid;
100
+ cls @SCHEMA@.entity;
101
+ res jsonb;
102
+ rw jsonb;
103
+ sp record;
104
+ d jsonb;
105
+ bad jsonb;
106
+ lk record;
107
+ kid uuid;
108
+ cur @SCHEMA@.entity;
109
+ e @SCHEMA@.entity;
110
+ last record;
111
+ ts timestamptz;
112
+ k record;
113
+ tg text[];
114
+ own uuid;
115
+ -- где ошибка: проектор, арендатор и (когда известен) id строки
116
+ w text;
117
+ v_agent text := 'letopis projector ' || p_name;
118
+ v_reason text := nullif(current_setting('letopis.reason', true), '');
119
+ n_created int := 0;
120
+ n_updated int := 0;
121
+ n_deleted int := 0;
122
+ n_unchanged int := 0;
123
+ begin
124
+ for o in select x from jsonb_array_elements(coalesce(p_ops, '[]'::jsonb)) x loop
125
+ op := o->>'op';
126
+ cname := o->>'class';
127
+ rw := coalesce(o->'row', '{}'::jsonb);
128
+ if op is null or op not in ('upsert', 'inc', 'delete') then
129
+ perform @SCHEMA@.lt_fail('invalid_data', format('проектор %s: неизвестная операция %s', p_name, coalesce(op, 'null')));
130
+ end if;
131
+ if jsonb_typeof(rw) <> 'object' then
132
+ perform @SCHEMA@.lt_fail('invalid_data', format('проектор %s: строка — не объект', p_name));
133
+ end if;
134
+ if not pg_input_is_valid(o->>'tenant', 'uuid') then
135
+ perform @SCHEMA@.lt_fail('invalid_data', format('проектор %s: арендатор строки — не uuid', p_name));
136
+ end if;
137
+ tid := (o->>'tenant')::uuid;
138
+ w := format(' (проектор %s, арендатор %s)', p_name, tid);
139
+ cls := @SCHEMA@.class_row(cname, tid);
140
+ if cls.id is null then
141
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', cname) || w);
142
+ end if;
143
+ -- как у писателей приложения: разделяемая блокировка семейства, описание — свежее под ней
144
+ perform @SCHEMA@.family_lock(cls, false);
145
+ cls := @SCHEMA@.class_row(cname, tid);
146
+ res := cls.data->'resolved';
147
+ if res->>'derivedBy' is distinct from p_name then
148
+ perform @SCHEMA@.lt_fail('derived_class', format('класс %s не производный проектора %s', cname, p_name) || w);
149
+ end if;
150
+ if coalesce((res->>'abstract')::boolean, false) then
151
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', cname) || w);
152
+ end if;
153
+ select * into sp from @SCHEMA@.derived_split(res, rw);
154
+ -- id: v5 от ключа; у класса без ключа — $id строки
155
+ kid := @SCHEMA@.key_id(res, cname, tid, sp.data, sp.links);
156
+ if kid is null then
157
+ if not pg_input_is_valid(rw->>'$id', 'uuid') then
158
+ perform @SCHEMA@.lt_fail('invalid_data', format('класс %s без ключа: проектору нужен $id строки', cname) || w);
159
+ end if;
160
+ kid := (rw->>'$id')::uuid;
161
+ elsif rw ? '$id' and (not pg_input_is_valid(rw->>'$id', 'uuid') or (rw->>'$id')::uuid <> kid) then
162
+ perform @SCHEMA@.lt_fail('id_mismatch', format('$id не равен v5 от ключа класса %s', cname) || w, jsonb_build_object('expected', kid));
163
+ end if;
164
+ w := format(' (проектор %s, арендатор %s, строка %s)', p_name, tid, kid);
165
+ select x.* into cur from @SCHEMA@.entity x where x.id = kid for update;
166
+ if cur.id is not null and (cur.class <> cname or cur.tenant <> tid) then
167
+ perform @SCHEMA@.lt_fail('id_mismatch', 'id занят строкой другого класса или арендатора' || w);
168
+ end if;
169
+ if op = 'delete' then
170
+ if cur.id is not null then
171
+ delete from @SCHEMA@.entity x where x.id = kid returning x.* into e;
172
+ perform @SCHEMA@.tombstone(e);
173
+ n_deleted := n_deleted + 1;
174
+ end if;
175
+ continue;
176
+ end if;
177
+ if op = 'inc' then
178
+ d := @SCHEMA@.merge(coalesce(cur.data, '{}'::jsonb), sp.data);
179
+ for k in select key, value from jsonb_each(coalesce(o->'deltas', '{}'::jsonb)) loop
180
+ if jsonb_typeof(k.value) <> 'number' then
181
+ perform @SCHEMA@.lt_fail('invalid_data', format('приращение поля %s — не число', k.key) || w);
182
+ end if;
183
+ d := @SCHEMA@.inc(d, array[k.key], (k.value #>> '{}')::numeric, 0);
184
+ end loop;
185
+ else
186
+ d := sp.data;
187
+ end if;
188
+ d := @SCHEMA@.apply_defaults(res->'schema', d);
189
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
190
+ if bad <> '[]'::jsonb then
191
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', cname) || w,
192
+ jsonb_build_object('issues', bad, 'tenant', tid, 'id', kid));
193
+ end if;
194
+ -- концы: у inc без концов в строке — прежние
195
+ select * into lk from @SCHEMA@.check_links(res,
196
+ case when op = 'inc' and sp.links = '{}'::jsonb then coalesce(cur.links, '{}'::jsonb) else sp.links end,
197
+ tid, true, null);
198
+ if lk.code is not null then
199
+ perform @SCHEMA@.lt_fail(lk.code, lk.msg || w);
200
+ end if;
201
+ if rw ? '$tags' and jsonb_typeof(rw->'$tags') <> 'array' then
202
+ perform @SCHEMA@.lt_fail('invalid_data', '$tags — список строк' || w);
203
+ end if;
204
+ tg := case when rw ? '$tags' then array(select jsonb_array_elements_text(rw->'$tags')) else coalesce(cur.tags, '{}') end;
205
+ if cur.id is null then
206
+ own := tid;
207
+ if rw->>'$owner' is not null then
208
+ if not pg_input_is_valid(rw->>'$owner', 'uuid') then
209
+ perform @SCHEMA@.lt_fail('invalid_data', '$owner — не uuid' || w);
210
+ end if;
211
+ own := (rw->>'$owner')::uuid;
212
+ if own <> tid and not exists (select 1 from @SCHEMA@.entity a where a.id = own and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
213
+ perform @SCHEMA@.lt_fail('target_not_found', 'владелец строки не найден' || w);
214
+ end if;
215
+ end if;
216
+ -- воскрешение: номер версии и цепочка хэшей продолжаются от последней записи журнала (как trg_entity_bi)
217
+ select l.rev, l.hash, l.at into last from @SCHEMA@.log l where l.id = kid order by l.rev desc limit 1 for update;
218
+ ts := greatest(@SCHEMA@.now_ts(), last.at + interval '1 microsecond');
219
+ insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator)
220
+ values (kid, coalesce(last.rev, 0) + 1, cname, cls.rev, tid, own, lk.links, d, tg, ts, ts, '@SYSTEM@'::uuid, v_agent, 'create',
221
+ v_reason, null, last.hash, null)
222
+ returning * into e;
223
+ perform @SCHEMA@.log_version(e);
224
+ n_created := n_created + 1;
225
+ elsif cur.data = d and cur.links = lk.links and cur.tags = tg then
226
+ n_unchanged := n_unchanged + 1;
227
+ else
228
+ update @SCHEMA@.entity x
229
+ set data = d, links = lk.links, tags = tg, rev = x.rev + 1, prev_hash = x.hash,
230
+ at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
231
+ author = '@SYSTEM@'::uuid, agent = v_agent, op = 'update', reason = v_reason, moved = null, cv = cls.rev
232
+ where x.id = kid
233
+ returning x.* into e;
234
+ perform @SCHEMA@.log_version(e);
235
+ n_updated := n_updated + 1;
236
+ end if;
237
+ end loop;
238
+ return jsonb_build_object('created', n_created, 'updated', n_updated, 'deleted', n_deleted, 'unchanged', n_unchanged);
239
+ end $$;
240
+
241
+ -- Перестройка проектора: строки его производных классов удаляются одним оператором — только пары «арендатор, класс»,
242
+ -- которые передал проектор (p_tenants[i], p_classes[i]: участники и их семейства into), и только классы с пометкой
243
+ -- derivedBy этого проектора. Прежде удалялись строки всех классов с пометкой во всех арендаторах — и тех, кто
244
+ -- участником не был (ревью безопасности №2). Надгробия — по history класса одной вставкой, сигнал — по парам «класс,
245
+ -- арендатор». Возвращает число удалённых строк.
246
+ create or replace function @SCHEMA@.derived_clear(p_name text, p_tenants uuid[], p_classes text[]) returns int
247
+ language plpgsql volatile
248
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
249
+ as $$
250
+ declare
251
+ n int;
252
+ pairs text[];
253
+ v_author uuid := '@SYSTEM@'::uuid;
254
+ v_agent text := 'letopis projector ' || p_name;
255
+ v_reason text := nullif(current_setting('letopis.reason', true), '');
256
+ begin
257
+ with c as (
258
+ select distinct x.data->>'name' as name, x.tenant
259
+ from unnest(coalesce(p_tenants, '{}'), coalesce(p_classes, '{}')) p(tenant, name)
260
+ join @SCHEMA@.entity x on x.class = 'Class' and x.tenant = p.tenant and x.data->>'name' = p.name
261
+ where x.data->'resolved'->>'derivedBy' = p_name
262
+ ), g as (
263
+ delete from @SCHEMA@.entity e using c where e.class = c.name and e.tenant = c.tenant
264
+ returning e.*
265
+ ), t as (
266
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
267
+ select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
268
+ greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, v_agent, 'delete', v_reason, null, g.hash, '[]'::jsonb
269
+ from g
270
+ join (select d.class, d.tenant from (select distinct y.class, y.tenant from g y) d
271
+ where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
272
+ )
273
+ select count(*)::int, coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}') into n, pairs from g;
274
+ if n > 0 then
275
+ perform @SCHEMA@.signal(array_agg(split_part(x.p, ' ', 1)), array_agg(split_part(x.p, ' ', 2)::uuid))
276
+ from unnest(pairs) x(p);
277
+ end if;
278
+ return n;
279
+ end $$;
280
+
281
+ -- до ревью безопасности №2 перестройка удаляла строки всех классов с пометкой во всех арендаторах
282
+ drop function if exists @SCHEMA@.derived_clear(text);
283
+
284
+ do $$
285
+ declare f text;
286
+ begin
287
+ foreach f in array array['projector_state(text)', 'projector_advance(text, text, jsonb)', 'derived_split(jsonb, jsonb)',
288
+ 'derived_id(text, uuid, jsonb)', 'derived_apply(text, jsonb)', 'derived_clear(text, uuid[], text[])'] loop
289
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
290
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
291
+ end loop;
292
+ end $$;
293
+
294
+ -- ========================= агрегаты в базе (этап 4б) =========================
295
+ --
296
+ -- Опция класса aggregate (40-class.sql): { <поле>: { sum: <поле источника> | count: true, of: <класс-источник>,
297
+ -- via: <роль конца источника>, where?: JSON Schema } }. Поле цели — сумма поля sum или число живых строк класса of
298
+ -- (с потомками) того же арендатора, чей одиночный конец via ведёт на цель и чьи данные проходят where. Строка System
299
+ -- и строки других арендаторов в агрегат не входят: общий справочник не считает данные всех арендаторов. Агрегат класса
300
+ -- арендатора считает только классы своего арендатора: класс of и потомков ищут среди них, класс System с тем же именем
301
+ -- его источником не бывает — везде одинаково (agg_prepare, agg_source, agg_classes, agg_apply, agg_fill); агрегат
302
+ -- класса System считает классы System и классы арендатора строки-цели.
303
+ --
304
+ -- Держит поле триггер после оператора entity_total_* (без условия WHEN — и для владельца, enable always): по
305
+ -- переходным таблицам вклады строк оператора (прежние версии со знаком минус, новые — плюс) группируются по цели,
306
+ -- цели блокируются в порядке id, приращение — inc, итог проверяет описание класса цели (minimum: 0 → invalid_data),
307
+ -- версия — sys_update (автор, агент и причина — из контекста оператора, журнал по history, сигнал). Новые цели
308
+ -- оператора (вставка, restore, смена класса) считаются целиком из живых источников и пишутся в ту же версию — в entity
309
+ -- и в уже записанную версию журнала (хэши — генерируемые колонки); поэтому функции владельца, пишущие журнал после
310
+ -- вставки, перечитывают строку (restore, sys_update со сменой класса), а sys_create и загрузчик пишут журнал раньше.
311
+ -- Перенос строки (move_row: reclass, rekey) — несколько операторов: на это время триггер копит вклады
312
+ -- (agg_hold_begin), а перенос в конце применяет их одним итогом. Признак — letopis.agg_hold: HMAC ключом строки KEY
313
+ -- от «режим:транзакция», поэтому выставить его приложение не может.
314
+ --
315
+ -- Источник узнаётся по метке aggregatedBy в resolved его класса: [{ class, field, via }] — какие цели считают его
316
+ -- строки (агрегаты своего арендатора и System). Метку считает class_prepare (agg_source), а при появлении, правке и
317
+ -- удалении цели — agg_mark: класс-источник часто описывают после цели. Описание агрегата — ужесточение
318
+ -- (class_change_safe): исключительные блокировки семейства цели и семейств источников, пересчёт поля у всех целей
319
+ -- (class_evolve). Ремонт — rebuild_aggregates, расхождения показывает verify(true) (agg_issues), первая заливка —
320
+ -- agg_fill.
321
+ --
322
+ -- Права (ревью коммита этапа 4б): поле считают функции владельца по всем строкам источника, без прав чтения, поэтому
323
+ -- строки попадают в агрегат только с ведома того, кто читает их все. Описать или изменить агрегат приложение может с
324
+ -- правом READ без условий на каждый класс семейства источника (class_admit), а сделать класс источником (новый класс,
325
+ -- смена parent, источник после цели) — с таким правом на сам класс и его потомков, до правки и после
326
+ -- (agg_source_admit).
327
+
328
+ -- Агрегатные поля класса по развёрнутому описанию.
329
+ create or replace function @SCHEMA@.agg_fields(p_res jsonb) returns text[]
330
+ language sql immutable parallel safe
331
+ return array(select jsonb_object_keys(coalesce(p_res->'aggregate', '{}'::jsonb)));
332
+
333
+ -- Классы-источники агрегатов класса (of).
334
+ create or replace function @SCHEMA@.agg_of(p_res jsonb) returns text[]
335
+ language sql immutable parallel safe
336
+ return array(select distinct f.value->>'of' from jsonb_each(coalesce(p_res->'aggregate', '{}'::jsonb)) f);
337
+
338
+ -- Данные с агрегатными полями: число остаётся (его пересчитают), иначе 0.
339
+ create or replace function @SCHEMA@.agg_zero(p_res jsonb, p_data jsonb) returns jsonb
340
+ language sql immutable parallel safe
341
+ return coalesce(p_data, '{}'::jsonb) || coalesce((select jsonb_object_agg(f, case when jsonb_typeof(p_data->f) = 'number' then p_data->f else '0'::jsonb end)
342
+ from unnest(@SCHEMA@.agg_fields(p_res)) f), '{}'::jsonb);
343
+
344
+ -- Число из значения источника: не число — 0.
345
+ create or replace function @SCHEMA@.agg_num(p_v jsonb) returns numeric
346
+ language sql immutable parallel safe
347
+ return case when jsonb_typeof(p_v) = 'number' then (p_v #>> '{}')::numeric else 0 end;
348
+
349
+ -- Проходят ли данные источника условие where агрегата (скомпилированное: rules, path, vars); условия нет — да.
350
+ create or replace function @SCHEMA@.agg_where_ok(p_m jsonb, p_data jsonb) returns boolean
351
+ language sql stable
352
+ return p_m is null or @SCHEMA@.validate_data(p_m->'rules', (p_m->>'path')::jsonpath, p_m->'vars', p_data) = '[]'::jsonb;
353
+
354
+ -- Правка строки приложением (trg_entity_bu): агрегатное поле не меняется — изменённое значение отклоняется, без поля
355
+ -- (замена данных целиком) возвращается прежнее; при смене класса — прежнее число или 0 (итог посчитает триггер).
356
+ create or replace function @SCHEMA@.agg_guard(p_res jsonb, p_old jsonb, p_new jsonb, p_reclass boolean) returns jsonb
357
+ language plpgsql stable
358
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
359
+ as $$
360
+ declare
361
+ f text;
362
+ d jsonb := coalesce(p_new, '{}'::jsonb);
363
+ begin
364
+ if p_reclass then
365
+ return @SCHEMA@.agg_zero(p_res, d);
366
+ end if;
367
+ foreach f in array @SCHEMA@.agg_fields(p_res) loop
368
+ if not (d ? f) then
369
+ d := d || jsonb_build_object(f, coalesce(p_old->f, '0'::jsonb));
370
+ elsif d->f is distinct from p_old->f then
371
+ perform @SCHEMA@.lt_fail('aggregate_field', format('поле %s класса %s считает база (aggregate) — его не меняют', f, p_res->>'name'));
372
+ end if;
373
+ end loop;
374
+ return d;
375
+ end $$;
376
+
377
+ -- Правка существующей строки загрузчиком (existing: 'update'): агрегатные поля — прежние.
378
+ create or replace function @SCHEMA@.agg_keep(p_class text, p_tenant uuid, p_old jsonb, p_new jsonb) returns jsonb
379
+ language plpgsql stable
380
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
381
+ as $$
382
+ declare
383
+ res jsonb := (@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved';
384
+ begin
385
+ if res is null or not (res ? 'aggregate') then
386
+ return p_new;
387
+ end if;
388
+ return coalesce(p_new, '{}'::jsonb) || coalesce((select jsonb_object_agg(f, coalesce(p_old->f, '0'::jsonb))
389
+ from unnest(@SCHEMA@.agg_fields(res)) f), '{}'::jsonb);
390
+ end $$;
391
+
392
+ -- Сверка источника с агрегатом цели: класс не системный и не производный, роль via — одиночный конец, принимающий
393
+ -- класс цели (или предка), поле суммы — число (сумма number в поле integer не помещается). p_src, p_tgt — resolved.
394
+ create or replace function @SCHEMA@.agg_check_source(p_src jsonb, p_tgt jsonb, p_field text, p_def jsonb) returns void
395
+ language plpgsql stable
396
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
397
+ as $$
398
+ declare
399
+ w text := format('агрегат %s.%s', p_tgt->>'name', p_field);
400
+ e jsonb := p_src->'ends'->(p_def->>'via');
401
+ fld jsonb;
402
+ begin
403
+ if (p_src->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
404
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('%s: системный класс %s не бывает источником агрегата', w, p_src->>'name'));
405
+ end if;
406
+ if p_src ? 'derivedBy' then
407
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('%s: производный класс %s не бывает источником агрегата', w, p_src->>'name'));
408
+ end if;
409
+ if e is null then
410
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('%s: у источника %s нет конца %s', w, p_src->>'name', p_def->>'via'));
411
+ end if;
412
+ if coalesce((e->>'many')::boolean, false) then
413
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class',
414
+ format('%s: конец %s источника %s множественный — агрегат считается через одиночный конец', w, p_def->>'via', p_src->>'name'));
415
+ end if;
416
+ if not coalesce(@SCHEMA@.class_set(e->'class') ?| array(select jsonb_array_elements_text(p_tgt->'ancestors')), false) then
417
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class',
418
+ format('%s: конец %s источника %s не принимает класс %s', w, p_def->>'via', p_src->>'name', p_tgt->>'name'));
419
+ end if;
420
+ if p_def ? 'sum' then
421
+ fld := p_src->'schema'->'properties'->(p_def->>'sum');
422
+ if fld is null or coalesce(fld->>'type', '') not in ('integer', 'number') then
423
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('%s: поле суммы %s источника %s — число (integer или number)', w, p_def->>'sum', p_src->>'name'));
424
+ end if;
425
+ if fld->>'type' = 'number' and p_tgt->'schema'->'properties'->p_field->>'type' = 'integer' then
426
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('%s: сумма поля number %s.%s в поле integer не помещается', w, p_src->>'name', p_def->>'sum'));
427
+ end if;
428
+ end if;
429
+ end $$;
430
+
431
+ -- Цикл агрегатов через класс p_res (арендатор и System; описание p_res — вместо его строки): цель a зависит от цели
432
+ -- b, если считает строки семейства, в которое входит b (агрегат класса арендатора — только классы арендатора). Нет
433
+ -- цикла — null, иначе путь «A → B → A».
434
+ create or replace function @SCHEMA@.agg_cycle(p_tenant uuid, p_res jsonb) returns text
435
+ language sql stable
436
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
437
+ as $$
438
+ with recursive tg as (
439
+ select x.tenant, x.data->>'name' as name, x.data->'resolved' as res from @SCHEMA@.entity x
440
+ where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> p_res->>'name'
441
+ and x.data->'resolved' ? 'aggregate'
442
+ union all
443
+ select p_tenant, p_res->>'name', p_res
444
+ ), edge as (
445
+ select distinct a.name as a, b.name as b
446
+ from tg a
447
+ cross join lateral jsonb_each(a.res->'aggregate') g
448
+ join tg b on b.res->'ancestors' ? (g.value->>'of') and (a.tenant = '@SYSTEM@'::uuid or b.tenant = a.tenant)
449
+ ), walk(node, path) as (
450
+ select e.b, array[e.a, e.b] from edge e where e.a = p_res->>'name'
451
+ union all
452
+ select e.b, w.path || e.b from walk w join edge e on e.a = w.node
453
+ where w.node <> p_res->>'name' and not (e.b = any (w.path[2:]))
454
+ )
455
+ select array_to_string(w.path, ' → ') from walk w where w.node = p_res->>'name' limit 1
456
+ $$;
457
+
458
+ -- Описание агрегатов цели (из class_prepare): ровно одно из sum и count; поле — число из схемы класса, не в ключе;
459
+ -- уже описанные источники (класс of и его потомки) сверены; цели нет в цикле; условие where скомпилировано (match).
460
+ -- Цель — не системный и не производный класс. Источники агрегата класса арендатора ищут только среди классов
461
+ -- арендатора: класс System с именем of источником не бывает, а нет такого класса у арендатора — источник ещё не
462
+ -- описан (ревью коммита этапа 4б); у класса System — во всех арендаторах.
463
+ create or replace function @SCHEMA@.agg_prepare(p_res jsonb, p_data jsonb, p_tenant uuid) returns jsonb
464
+ language plpgsql stable
465
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
466
+ as $$
467
+ declare
468
+ nm text := p_res->>'name';
469
+ f record;
470
+ d jsonb;
471
+ fld jsonb;
472
+ s record;
473
+ c record;
474
+ outv jsonb := '{}'::jsonb;
475
+ cyc text;
476
+ begin
477
+ if coalesce((p_data->>'system')::boolean, false) then
478
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', 'системный класс не бывает целью агрегата');
479
+ end if;
480
+ if p_res ? 'derivedBy' then
481
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('производный класс %s не бывает целью агрегата: его строки строит проектор', nm));
482
+ end if;
483
+ for f in select key, value from jsonb_each(p_res->'aggregate') order by key loop
484
+ d := f.value - 'match';
485
+ if (d ? 'sum') = (d ? 'count') then
486
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('агрегат %s.%s: нужно ровно одно из sum (поле источника) и count: true', nm, f.key));
487
+ end if;
488
+ fld := p_res->'schema'->'properties'->f.key;
489
+ if fld is null then
490
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('агрегатного поля %s нет среди полей класса %s', f.key, nm));
491
+ end if;
492
+ if coalesce(fld->>'type', '') not in ('integer', 'number') then
493
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('агрегатное поле %s.%s — число (integer или number)', nm, f.key));
494
+ end if;
495
+ if coalesce(p_res->'key' ? f.key, false) then
496
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('агрегатное поле %s.%s в ключ не входит', nm, f.key));
497
+ end if;
498
+ for s in select x.data->'resolved' as res from @SCHEMA@.entity x
499
+ where x.class = 'Class' and x.data->>'name' <> nm
500
+ and (x.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
501
+ and x.data->'resolved'->'ancestors' ? (d->>'of')
502
+ order by x.id loop
503
+ perform @SCHEMA@.agg_check_source(s.res, p_res, f.key, d);
504
+ end loop;
505
+ if d ? 'where' then
506
+ select * into c from @SCHEMA@.compile_validator(d->'where');
507
+ d := d || jsonb_build_object('match', jsonb_build_object('rules', c.rules, 'path', c.validator::text, 'vars', c.vars));
508
+ end if;
509
+ outv := outv || jsonb_build_object(f.key, d);
510
+ end loop;
511
+ p_res := p_res || jsonb_build_object('aggregate', outv);
512
+ cyc := @SCHEMA@.agg_cycle(p_tenant, p_res);
513
+ if cyc is not null then
514
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('цикл агрегатов: %s', cyc));
515
+ end if;
516
+ return p_res;
517
+ end $$;
518
+
519
+ -- Метка источника (из class_prepare): агрегаты своего арендатора и System, которые считают строки класса (of — сам
520
+ -- класс или предок), и та же сверка, что при описании цели. Классу System метка — только от агрегатов System:
521
+ -- агрегаты арендаторов пропускаются, а не отклоняют правку класса System (ревью коммита этапа 4б).
522
+ create or replace function @SCHEMA@.agg_source(p_res jsonb, p_tenant uuid) returns jsonb
523
+ language plpgsql stable
524
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
525
+ as $$
526
+ declare
527
+ k record;
528
+ f record;
529
+ marks jsonb := '[]'::jsonb;
530
+ begin
531
+ for k in select x.tenant, x.data->'resolved' as res from @SCHEMA@.entity x
532
+ where x.class = 'Class' and x.data->>'name' <> p_res->>'name' and x.data->'resolved' ? 'aggregate'
533
+ and (x.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
534
+ order by x.data->>'name', x.tenant loop
535
+ for f in select key, value from jsonb_each(k.res->'aggregate') where p_res->'ancestors' ? (value->>'of') order by key loop
536
+ if p_tenant = '@SYSTEM@'::uuid and k.tenant <> '@SYSTEM@'::uuid then
537
+ -- агрегат арендатора считает только классы своего арендатора: класс System его источником не бывает и
538
+ -- метки не получает. Отказывать здесь нельзя — иначе описание арендатора останавливало бы правку System
539
+ continue;
540
+ end if;
541
+ perform @SCHEMA@.agg_check_source(p_res, k.res, f.key, f.value);
542
+ marks := marks || jsonb_build_array(jsonb_build_object('class', k.res->>'name', 'field', f.key, 'via', f.value->>'via'));
543
+ end loop;
544
+ end loop;
545
+ return case when marks = '[]'::jsonb then p_res - 'aggregatedBy' else p_res || jsonb_build_object('aggregatedBy', marks) end;
546
+ end $$;
547
+
548
+ -- Цели, чей набор источников изменился вместе с меткой класса (смена предков): их поле считается заново.
549
+ create or replace function @SCHEMA@.agg_marked(p_old jsonb, p_new jsonb) returns text[]
550
+ language sql immutable parallel safe
551
+ return case when p_old->'aggregatedBy' is not distinct from p_new->'aggregatedBy' then '{}'::text[]
552
+ else array(select distinct m->>'class' from jsonb_array_elements(coalesce(p_old->'aggregatedBy', '[]'::jsonb)
553
+ || coalesce(p_new->'aggregatedBy', '[]'::jsonb)) m) end;
554
+
555
+ -- Первая метка aggregatedBy описания p_new, которой нет в p_old: класс становится источником этого агрегата (новый
556
+ -- класс, смена parent, источник описан после цели). Новых меток нет — null.
557
+ create or replace function @SCHEMA@.agg_new_mark(p_old jsonb, p_new jsonb) returns jsonb
558
+ language sql immutable parallel safe
559
+ return (select x from jsonb_array_elements(coalesce(p_new->'aggregatedBy', '[]'::jsonb)) x
560
+ where not coalesce(p_old->'aggregatedBy', '[]'::jsonb) @> jsonb_build_array(x)
561
+ order by x->>'class', x->>'field' limit 1);
562
+
563
+ -- Класс p_class становится источником агрегата p_mark (ревью коммита этапа 4б): его строки войдут в агрегат, поэтому
564
+ -- актору нужно право READ без условий на все строки класса, иначе acl_denied; p_mark null — проверять нечего. Путь
565
+ -- приложения: class_admit — до правки, по прежним предкам (строки уже есть, в том числе у потомков), триггеры после
566
+ -- оператора и class_evolve — после: строка Class записана, и план прав видит новый класс и его новых предков.
567
+ create or replace function @SCHEMA@.agg_source_admit(p_class text, p_mark jsonb) returns void
568
+ language plpgsql stable
569
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
570
+ as $$
571
+ declare
572
+ chk jsonb;
573
+ begin
574
+ if p_mark is null then
575
+ return;
576
+ end if;
577
+ chk := @SCHEMA@.acl_check_data(p_class, 'READ');
578
+ if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
579
+ perform @SCHEMA@.lt_fail('acl_denied', format('класс %s становится источником агрегата %s.%s: нужно право READ на все его строки',
580
+ p_class, p_mark->>'class', p_mark->>'field'));
581
+ end if;
582
+ end $$;
583
+
584
+ -- Метки источников заново: классы арендатора p_tenant (System — всех), в предках которых есть одно из имён p_names;
585
+ -- изменившееся описание — новой версией строки Class (журнал, сигнал).
586
+ create or replace function @SCHEMA@.agg_mark(p_tenant uuid, p_names text[]) returns void
587
+ language plpgsql volatile
588
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
589
+ as $$
590
+ declare
591
+ d @SCHEMA@.entity;
592
+ prep record;
593
+ begin
594
+ if p_names is null or cardinality(p_names) = 0 then
595
+ return;
596
+ end if;
597
+ for d in select x.* from @SCHEMA@.entity x
598
+ where x.class = 'Class' and (x.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
599
+ and x.data->'resolved'->'ancestors' ?| p_names
600
+ order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.id loop
601
+ select p.data, p.validator into prep from @SCHEMA@.class_prepare(d.data, d.tenant) p;
602
+ if prep.data is distinct from d.data or prep.validator::text is distinct from d.validator::text then
603
+ perform @SCHEMA@.sys_update(d.id, prep.data, d.links, prep.validator, 'update');
604
+ end if;
605
+ end loop;
606
+ end $$;
607
+
608
+ -- Исключительные блокировки семейств источников прежнего и нового описания агрегатов (смена описания, ремонт):
609
+ -- писатели источников держат разделяемую, поэтому зафиксированное до пересчёта в него войдёт, а ожидающие допишут
610
+ -- вклад по новому описанию. Цель System — источники во всех арендаторах.
611
+ create or replace function @SCHEMA@.agg_lock_sources(p_old jsonb, p_new jsonb, p_tenant uuid) returns void
612
+ language plpgsql volatile
613
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
614
+ as $$
615
+ declare
616
+ tc @SCHEMA@.entity;
617
+ begin
618
+ for tc in select x.* from @SCHEMA@.entity x
619
+ where x.class = 'Class' and x.data->>'name' = any (@SCHEMA@.agg_of(p_old) || @SCHEMA@.agg_of(p_new))
620
+ and (x.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
621
+ order by x.id loop
622
+ perform @SCHEMA@.family_lock(tc, true);
623
+ end loop;
624
+ end $$;
625
+
626
+ -- Классы-источники of для строк цели арендатора p_tenant: сам класс и потомки среди классов арендатора, а у агрегата
627
+ -- класса System (p_system) — ещё классы System. Агрегат класса арендатора классы System не считает (ревью коммита
628
+ -- этапа 4б), как и метки (agg_source) и приращения (agg_apply).
629
+ create or replace function @SCHEMA@.agg_classes(p_of text, p_tenant uuid, p_system boolean) returns text[]
630
+ language sql stable
631
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
632
+ as $$
633
+ select coalesce(array_agg(x.data->>'name'), '{}') from @SCHEMA@.entity x
634
+ where x.class = 'Class' and (x.tenant = p_tenant or (p_system and x.tenant = '@SYSTEM@'::uuid))
635
+ and x.data->'resolved'->'ancestors' ? p_of
636
+ $$;
637
+
638
+ -- до ревью коммита этапа 4б семейство брало классы System и для агрегата класса арендатора
639
+ drop function if exists @SCHEMA@.agg_classes(text, uuid);
640
+
641
+ -- Агрегатные поля цели p_id целиком из живых источников её арендатора: { поле: итог }. Класс цели — по строке
642
+ -- арендатора p_tenant (свой или System): от него зависит, входят ли в семейство классы System.
643
+ create or replace function @SCHEMA@.agg_compute(p_res jsonb, p_id uuid, p_tenant uuid) returns jsonb
644
+ language sql stable
645
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
646
+ as $$
647
+ with d as (
648
+ select f.key as field, f.value as def, @SCHEMA@.agg_classes(f.value->>'of', p_tenant, k.sys) as cls
649
+ from jsonb_each(coalesce(p_res->'aggregate', '{}'::jsonb)) f
650
+ cross join (select (@SCHEMA@.class_row(p_res->>'name', p_tenant)).tenant = '@SYSTEM@'::uuid as sys) k
651
+ )
652
+ select coalesce(jsonb_object_agg(d.field, to_jsonb(coalesce(x.v, 0))), '{}'::jsonb)
653
+ from d
654
+ left join lateral (
655
+ select sum(case when d.def ? 'count' then 1 else @SCHEMA@.agg_num(s.data->(d.def->>'sum')) end) as v
656
+ from @SCHEMA@.entity s
657
+ where s.ends @> array[p_id] and s.tenant = p_tenant and s.class = any (d.cls)
658
+ and s.links->>(d.def->>'via') = p_id::text and @SCHEMA@.agg_where_ok(d.def->'match', s.data)
659
+ ) x on true
660
+ $$;
661
+
662
+ -- Итог цели проходит описание её класса (minimum: 0 и прочие правила), иначе invalid_data с нарушениями.
663
+ create or replace function @SCHEMA@.agg_check(p_class text, p_tenant uuid, p_data jsonb) returns void
664
+ language plpgsql stable
665
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
666
+ as $$
667
+ declare
668
+ cls @SCHEMA@.entity := @SCHEMA@.class_row(p_class, p_tenant);
669
+ bad jsonb;
670
+ begin
671
+ bad := @SCHEMA@.validate_data(cls.data->'resolved'->'rules', cls.validator, cls.data->'resolved'->'vars', p_data);
672
+ if bad <> '[]'::jsonb then
673
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s (агрегатное поле)', p_class), jsonb_build_object('issues', bad));
674
+ end if;
675
+ end $$;
676
+
677
+ -- Новое значение цели — версией от имени владельца (sys_update); то же значение — без версии. Возвращает «записано».
678
+ create or replace function @SCHEMA@.agg_write(r @SCHEMA@.entity, p_data jsonb, p_check boolean) returns boolean
679
+ language plpgsql volatile
680
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
681
+ as $$
682
+ begin
683
+ if p_data is not distinct from r.data then
684
+ return false;
685
+ end if;
686
+ if p_check then
687
+ perform @SCHEMA@.agg_check(r.class, r.tenant, p_data);
688
+ end if;
689
+ perform @SCHEMA@.sys_update(r.id, p_data, r.links, r.validator, 'update');
690
+ return true;
691
+ end $$;
692
+
693
+ -- Признак «триггер агрегатов копит» (acc) или «молчит» (off) в этой транзакции: HMAC ключом строки KEY.
694
+ create or replace function @SCHEMA@.agg_token(p_mode text) returns text
695
+ language sql volatile
696
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
697
+ as $$
698
+ select encode(@PGCRYPTO@.hmac(convert_to('letopis:aggregates:' || p_mode || ':' || pg_current_xact_id()::text, 'UTF8'), s.material, 'sha256'), 'hex')
699
+ from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY'
700
+ $$;
701
+
702
+ create or replace function @SCHEMA@.agg_held() returns text
703
+ language plpgsql volatile
704
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
705
+ as $$
706
+ declare
707
+ h text := current_setting('letopis.agg_hold', true);
708
+ begin
709
+ if h is null or h = '' then
710
+ return null;
711
+ end if;
712
+ if h = @SCHEMA@.agg_token('acc') then
713
+ return 'acc';
714
+ end if;
715
+ if h = @SCHEMA@.agg_token('off') then
716
+ return 'off';
717
+ end if;
718
+ return null;
719
+ end $$;
720
+
721
+ -- Начало несколькооператорной операции владельца: acc — вклады копятся до agg_hold_end, off — не считаются (сброс
722
+ -- арендатора). Уже начато (вложенный перенос) — false: итог применит внешний вызов.
723
+ create or replace function @SCHEMA@.agg_hold_begin(p_mode text) returns boolean
724
+ language plpgsql volatile
725
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
726
+ as $$
727
+ declare
728
+ tok text;
729
+ begin
730
+ if @SCHEMA@.agg_held() is not null then
731
+ return false;
732
+ end if;
733
+ tok := @SCHEMA@.agg_token(p_mode);
734
+ if tok is null then
735
+ return false;
736
+ end if;
737
+ perform set_config('letopis.agg_acc', '{}', true);
738
+ perform set_config('letopis.agg_hold', tok, true);
739
+ return true;
740
+ end $$;
741
+
742
+ -- Конец: признак снимается до применения — записи итога идут обычным путём (и считаются агрегатами выше по цепочке).
743
+ create or replace function @SCHEMA@.agg_hold_end(p_apply boolean) returns void
744
+ language plpgsql volatile
745
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
746
+ as $$
747
+ declare
748
+ acc jsonb := case when p_apply then nullif(current_setting('letopis.agg_acc', true), '')::jsonb end;
749
+ begin
750
+ perform set_config('letopis.agg_hold', '', true);
751
+ perform set_config('letopis.agg_acc', '', true);
752
+ if acc is not null then
753
+ perform @SCHEMA@.agg_flush(acc);
754
+ end if;
755
+ end $$;
756
+
757
+ -- Сложение накопленных приращений { id: { поле: число } }.
758
+ create or replace function @SCHEMA@.agg_merge(a jsonb, b jsonb) returns jsonb
759
+ language sql immutable parallel safe
760
+ return (
761
+ select coalesce(jsonb_object_agg(z.id, z.f), '{}'::jsonb)
762
+ from (select y.id, jsonb_object_agg(y.field, y.v) as f
763
+ from (select i.key as id, f.key as field, sum((f.value #>> '{}')::numeric) as v
764
+ from (select * from jsonb_each(coalesce(a, '{}'::jsonb)) union all select * from jsonb_each(coalesce(b, '{}'::jsonb))) i
765
+ cross join lateral jsonb_each(i.value) f
766
+ group by 1, 2) y
767
+ group by y.id) z
768
+ );
769
+
770
+ -- Итог накопленного: цели по возрастанию id, новые (f) — целиком из живых источников, остальные — приращения (d).
771
+ create or replace function @SCHEMA@.agg_flush(p_acc jsonb) returns void
772
+ language plpgsql volatile
773
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
774
+ as $$
775
+ declare
776
+ ids uuid[] := array(select distinct y.x::uuid from (
777
+ select jsonb_object_keys(coalesce(p_acc->'d', '{}'::jsonb)) as x
778
+ union select jsonb_array_elements_text(coalesce(p_acc->'f', '[]'::jsonb))) y);
779
+ r @SCHEMA@.entity;
780
+ res jsonb;
781
+ d jsonb;
782
+ k record;
783
+ begin
784
+ for r in select x.* from @SCHEMA@.entity x where x.id = any (ids) order by x.id for no key update loop
785
+ res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
786
+ continue when not coalesce(res ? 'aggregate', false);
787
+ if coalesce(p_acc->'f', '[]'::jsonb) ? r.id::text then
788
+ d := r.data || @SCHEMA@.agg_compute(res, r.id, r.tenant);
789
+ else
790
+ d := r.data;
791
+ for k in select key, value from jsonb_each(p_acc->'d'->(r.id::text)) where res->'aggregate' ? key loop
792
+ d := @SCHEMA@.inc(d, array[k.key], (k.value #>> '{}')::numeric, 0);
793
+ end loop;
794
+ end if;
795
+ perform @SCHEMA@.agg_write(r, d, true);
796
+ end loop;
797
+ end $$;
798
+
799
+ -- Касается ли класс агрегатов в арендаторе: target — у него есть агрегаты, source — его строки считают, both — оба.
800
+ create or replace function @SCHEMA@.agg_kind(p_class text, p_tenant uuid) returns text
801
+ language sql stable
802
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
803
+ as $$
804
+ select case when x.r ? 'aggregate' then case when x.r ? 'aggregatedBy' then 'both' else 'target' end
805
+ when x.r ? 'aggregatedBy' then 'source' end
806
+ from (select (@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved' as r) x
807
+ $$;
808
+
809
+ -- Вклады строк одного оператора (из триггеров entity_total_*): p_old — прежние версии (вклад вычитается), p_new —
810
+ -- новые (прибавляется), [{id, class, tenant, links, data}] только классов, касающихся агрегатов; p_fresh — новые цели
811
+ -- оператора, их поле считается целиком.
812
+ create or replace function @SCHEMA@.agg_apply(p_old jsonb, p_new jsonb, p_fresh uuid[]) returns void
813
+ language plpgsql volatile
814
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
815
+ as $$
816
+ declare
817
+ hold text := @SCHEMA@.agg_held();
818
+ fresh uuid[] := coalesce(p_fresh, '{}');
819
+ tids uuid[];
820
+ deltas jsonb := '{}'::jsonb;
821
+ acc jsonb;
822
+ r @SCHEMA@.entity;
823
+ res jsonb;
824
+ d jsonb;
825
+ k record;
826
+ begin
827
+ if hold = 'off' then
828
+ return;
829
+ end if;
830
+ -- цели вкладов: живые строки того же арендатора по концам via из меток классов-источников; новые цели оператора
831
+ -- приращения обходят — их поле считается целиком ниже
832
+ with src as (
833
+ select x.* from jsonb_to_recordset(coalesce(p_old, '[]'::jsonb) || coalesce(p_new, '[]'::jsonb))
834
+ as x(id uuid, class text, tenant uuid, links jsonb, data jsonb)
835
+ ), mk as (
836
+ select distinct p.class, p.tenant, m.value->>'via' as via
837
+ from (select distinct s.class, s.tenant from src s) p
838
+ cross join lateral jsonb_array_elements(coalesce((@SCHEMA@.class_row(p.class, p.tenant)).data->'resolved'->'aggregatedBy', '[]'::jsonb)) m
839
+ )
840
+ select coalesce(array_agg(distinct t.id order by t.id), '{}') into tids
841
+ from src s
842
+ join mk on mk.class = s.class and mk.tenant = s.tenant
843
+ join @SCHEMA@.entity t on t.id = case when jsonb_typeof(s.links->mk.via) = 'string' then (s.links->>mk.via)::uuid end
844
+ and t.tenant = s.tenant
845
+ where not (t.id = any (fresh));
846
+ if cardinality(tids) > 0 then
847
+ -- блокировка до подсчёта: описание класса цели и её строка — свежие
848
+ perform 1 from @SCHEMA@.entity x where x.id = any (tids) order by x.id for no key update;
849
+ with src as (
850
+ select -1 as sign, x.* from jsonb_to_recordset(coalesce(p_old, '[]'::jsonb)) as x(id uuid, class text, tenant uuid, links jsonb, data jsonb)
851
+ union all
852
+ select 1, x.* from jsonb_to_recordset(coalesce(p_new, '[]'::jsonb)) as x(id uuid, class text, tenant uuid, links jsonb, data jsonb)
853
+ ), mk as (
854
+ select distinct p.class, p.tenant, m.value->>'via' as via
855
+ from (select distinct s.class, s.tenant from src s) p
856
+ cross join lateral jsonb_array_elements(coalesce((@SCHEMA@.class_row(p.class, p.tenant)).data->'resolved'->'aggregatedBy', '[]'::jsonb)) m
857
+ ), lk as (
858
+ select s.sign, s.class, s.tenant, s.data, mk.via,
859
+ case when jsonb_typeof(s.links->mk.via) = 'string' then (s.links->>mk.via)::uuid end as tid
860
+ from src s join mk on mk.class = s.class and mk.tenant = s.tenant
861
+ ), tg as (
862
+ select lk.*, t.class as tclass from lk join @SCHEMA@.entity t on t.id = lk.tid and t.tenant = lk.tenant
863
+ where lk.tid = any (tids)
864
+ ), td as (
865
+ select p.tclass, p.tenant, c.tenant as ct, c.data->'resolved'->'aggregate' as agg
866
+ from (select distinct tg.tclass, tg.tenant from tg) p
867
+ cross join lateral @SCHEMA@.class_row(p.tclass, p.tenant) c
868
+ ), sa as (
869
+ select p.class, p.tenant, c.tenant as ct, c.data->'resolved'->'ancestors' as anc
870
+ from (select distinct tg.class, tg.tenant from tg) p
871
+ cross join lateral @SCHEMA@.class_row(p.class, p.tenant) c
872
+ ), ct as (
873
+ -- класс System — источник только агрегатов класса System (ревью коммита этапа 4б): метки он получает только от
874
+ -- них, но конец via у них бывает общий с агрегатом класса арендатора
875
+ select tg.tid, f.key as field,
876
+ sum(tg.sign * case when f.value ? 'count' then 1 else @SCHEMA@.agg_num(tg.data->(f.value->>'sum')) end) as delta
877
+ from tg
878
+ join td on td.tclass = tg.tclass and td.tenant = tg.tenant
879
+ join sa on sa.class = tg.class and sa.tenant = tg.tenant
880
+ cross join lateral jsonb_each(coalesce(td.agg, '{}'::jsonb)) f
881
+ where f.value->>'via' = tg.via and sa.anc ? (f.value->>'of') and (sa.ct <> '@SYSTEM@'::uuid or td.ct = '@SYSTEM@'::uuid)
882
+ and @SCHEMA@.agg_where_ok(f.value->'match', tg.data)
883
+ group by tg.tid, f.key
884
+ )
885
+ select coalesce(jsonb_object_agg(z.tid, z.f), '{}'::jsonb) into deltas
886
+ from (select ct.tid, jsonb_object_agg(ct.field, ct.delta) as f from ct where ct.delta <> 0 group by ct.tid) z;
887
+ end if;
888
+ -- перенос строки (move_row): вклады и новые цели копятся до его конца
889
+ if hold = 'acc' then
890
+ acc := coalesce(nullif(current_setting('letopis.agg_acc', true), '')::jsonb, '{}'::jsonb);
891
+ acc := jsonb_build_object('d', @SCHEMA@.agg_merge(acc->'d', deltas),
892
+ 'f', to_jsonb(array(select distinct x from unnest(array(select jsonb_array_elements_text(coalesce(acc->'f', '[]'::jsonb))) || fresh::text[]) x
893
+ order by x)));
894
+ perform set_config('letopis.agg_acc', acc::text, true);
895
+ return;
896
+ end if;
897
+ -- приращения: inc по пути, итог — по описанию класса, версия — sys_update
898
+ for r in select x.* from @SCHEMA@.entity x where x.id = any (tids) and deltas ? x.id::text order by x.id loop
899
+ d := r.data;
900
+ for k in select key, value from jsonb_each(deltas->(r.id::text)) loop
901
+ d := @SCHEMA@.inc(d, array[k.key], (k.value #>> '{}')::numeric, 0);
902
+ end loop;
903
+ perform @SCHEMA@.agg_write(r, d, true);
904
+ end loop;
905
+ -- новые цели оператора: поле целиком из живых источников — в ту же версию, в entity и в журнал
906
+ for r in select x.* from @SCHEMA@.entity x where x.id = any (fresh) order by x.id loop
907
+ res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
908
+ continue when not coalesce(res ? 'aggregate', false);
909
+ d := r.data || @SCHEMA@.agg_compute(res, r.id, r.tenant);
910
+ continue when d = r.data;
911
+ perform @SCHEMA@.agg_check(r.class, r.tenant, d);
912
+ update @SCHEMA@.entity x set data = d where x.id = r.id;
913
+ update @SCHEMA@.log l set data = d, changed = null where l.id = r.id and l.rev = r.rev;
914
+ end loop;
915
+ end $$;
916
+
917
+ -- Поле целей классов p_classes арендатора p_tenant (System — всех арендаторов) целиком из живых источников: строки по
918
+ -- возрастанию id под блокировкой, версия — только там, где значение изменилось. p_check — проверять итог описанием
919
+ -- класса (пересчёт при ужесточении проверяет ужесточение). Возвращает число исправленных строк.
920
+ create or replace function @SCHEMA@.agg_refresh(p_classes text[], p_tenant uuid, p_check boolean) returns int
921
+ language plpgsql volatile
922
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
923
+ as $$
924
+ declare
925
+ r @SCHEMA@.entity;
926
+ res jsonb;
927
+ n int := 0;
928
+ begin
929
+ for r in select x.* from @SCHEMA@.entity x
930
+ where x.class = any (p_classes) and (x.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
931
+ order by x.id for no key update loop
932
+ res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
933
+ continue when not coalesce(res ? 'aggregate', false);
934
+ if @SCHEMA@.agg_write(r, r.data || @SCHEMA@.agg_compute(res, r.id, r.tenant), p_check) then
935
+ n := n + 1;
936
+ end if;
937
+ end loop;
938
+ return n;
939
+ end $$;
940
+
941
+ -- Ремонт (db.rebuildAggregates): агрегаты класса и его потомков во всех арендаторах — из текущих строк, под
942
+ -- исключительными блокировками семейств целей и источников. Только роль владельца (администраторское подключение).
943
+ create or replace function @SCHEMA@.rebuild_aggregates(p_class text) returns int
944
+ language plpgsql volatile
945
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
946
+ as $$
947
+ declare
948
+ cr @SCHEMA@.entity;
949
+ names text[] := '{}';
950
+ begin
951
+ for cr in select x.* from @SCHEMA@.entity x
952
+ where x.class = 'Class' and x.data->'resolved' ? 'aggregate' and x.data->'resolved'->'ancestors' ? p_class
953
+ order by x.id loop
954
+ perform @SCHEMA@.family_lock(cr, true);
955
+ perform @SCHEMA@.agg_lock_sources(cr.data->'resolved', null, cr.tenant);
956
+ names := names || (cr.data->>'name');
957
+ end loop;
958
+ if cardinality(names) = 0 then
959
+ perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s и его потомков нет агрегатов (aggregate) — пересчитывать нечего', p_class));
960
+ end if;
961
+ return @SCHEMA@.agg_refresh(array(select distinct x from unnest(names) x), '@SYSTEM@'::uuid, true);
962
+ end $$;
963
+
964
+ -- Расхождения агрегатов (verify с p_data): строка, поле, значение в строке и итог живых источников.
965
+ create or replace function @SCHEMA@.agg_issues() returns table (id uuid, class text, field text, stored jsonb, expected jsonb)
966
+ language sql stable
967
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
968
+ as $$
969
+ select x.id, x.class, f.key, x.data->f.key, f.value
970
+ from @SCHEMA@.entity x
971
+ cross join lateral (select (@SCHEMA@.class_row(x.class, x.tenant)).data->'resolved' as res) c
972
+ cross join lateral jsonb_each(@SCHEMA@.agg_compute(c.res, x.id, x.tenant)) f
973
+ where x.class in (select k.data->>'name' from @SCHEMA@.entity k where k.class = 'Class' and k.data->'resolved' ? 'aggregate')
974
+ and coalesce(c.res ? 'aggregate', false)
975
+ and x.data->f.key is distinct from f.value
976
+ order by x.id, f.key
977
+ $$;
978
+
979
+ -- Первая заливка (load.ts:initialSession): триггеров и индексов нет, поэтому поле целей считается одним оператором на
980
+ -- агрегат и пишется в ту же (последнюю) версию в entity и log — строки ещё никто не видел, хэши — генерируемые колонки.
981
+ -- Цепочки агрегатов (сумма агрегатного поля) сходятся повторными проходами. Итог проверяется описанием класса.
982
+ -- Семейство источника — как agg_classes: классы арендатора строки, у агрегата класса System — ещё классы System.
983
+ create or replace function @SCHEMA@.agg_fill() returns int
984
+ language plpgsql volatile
985
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
986
+ as $$
987
+ declare
988
+ k record;
989
+ f record;
990
+ m int;
991
+ n int := 0;
992
+ pass int := 0;
993
+ more boolean;
994
+ bad record;
995
+ begin
996
+ loop
997
+ more := false;
998
+ for k in select x.tenant as ct, x.data->>'name' as name, x.data->'resolved' as res from @SCHEMA@.entity x
999
+ where x.class = 'Class' and x.data->'resolved' ? 'aggregate' order by x.id loop
1000
+ for f in select key, value from jsonb_each(k.res->'aggregate') order by key loop
1001
+ with sc as (
1002
+ select y.tenant as ct, y.data->>'name' as name from @SCHEMA@.entity y
1003
+ where y.class = 'Class' and y.data->'resolved'->'ancestors' ? (f.value->>'of')
1004
+ ), tot as (
1005
+ select (s.links->>(f.value->>'via'))::uuid as tid, s.tenant,
1006
+ sum(case when f.value ? 'count' then 1 else @SCHEMA@.agg_num(s.data->(f.value->>'sum')) end) as v
1007
+ from @SCHEMA@.entity s
1008
+ join sc on sc.name = s.class and (sc.ct = s.tenant or (k.ct = '@SYSTEM@'::uuid and sc.ct = '@SYSTEM@'::uuid))
1009
+ where jsonb_typeof(s.links->(f.value->>'via')) = 'string' and @SCHEMA@.agg_where_ok(f.value->'match', s.data)
1010
+ group by 1, 2
1011
+ ), nv as (
1012
+ select t.id, t.rev, jsonb_set(t.data, array[f.key], to_jsonb(coalesce(tot.v, 0))) as data
1013
+ from @SCHEMA@.entity t
1014
+ left join tot on tot.tid = t.id and tot.tenant = t.tenant
1015
+ where t.class = k.name and (t.tenant = k.ct or k.ct = '@SYSTEM@'::uuid)
1016
+ and t.data->f.key is distinct from to_jsonb(coalesce(tot.v, 0))
1017
+ ), ue as (
1018
+ update @SCHEMA@.entity x set data = nv.data from nv where x.id = nv.id returning 1
1019
+ ), ul as (
1020
+ update @SCHEMA@.log l set data = nv.data from nv where l.id = nv.id and l.rev = nv.rev returning 1
1021
+ )
1022
+ select count(*)::int into m from ue;
1023
+ if m > 0 then
1024
+ more := true;
1025
+ n := n + m;
1026
+ end if;
1027
+ end loop;
1028
+ end loop;
1029
+ exit when not more;
1030
+ pass := pass + 1;
1031
+ if pass > 32 then
1032
+ perform @SCHEMA@.lt_fail('invalid_class', 'первая заливка: агрегаты не сошлись за 32 прохода');
1033
+ end if;
1034
+ end loop;
1035
+ select x.id, x.class, v.bad into bad
1036
+ from @SCHEMA@.entity x
1037
+ cross join lateral (select c.data->'resolved' as res, c.validator from @SCHEMA@.class_row(x.class, x.tenant) c) c
1038
+ cross join lateral (select @SCHEMA@.validate_data(c.res->'rules', c.validator, c.res->'vars', x.data) as bad) v
1039
+ where x.class in (select k2.data->>'name' from @SCHEMA@.entity k2 where k2.class = 'Class' and k2.data->'resolved' ? 'aggregate')
1040
+ and v.bad <> '[]'::jsonb
1041
+ limit 1;
1042
+ if bad.id is not null then
1043
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s (агрегатное поле строки %s после первой заливки)', bad.class, bad.id),
1044
+ jsonb_build_object('issues', bad.bad, 'id', bad.id));
1045
+ end if;
1046
+ return n;
1047
+ end $$;
1048
+
1049
+ -- Триггеры агрегатов: после оператора, по одному на событие, с переходными таблицами, без условия WHEN — и для
1050
+ -- владельца (функции владельца, каскад, загрузчик, сырой SQL); имена — после entity_a*: AFTER-триггеры одного события
1051
+ -- срабатывают по алфавиту, журнал и проверки целей оператора уже записаны. Быстрый выход — среди пар «класс,
1052
+ -- арендатор» оператора нет ни цели, ни источника агрегата.
1053
+ create or replace function @SCHEMA@.trg_entity_total_ai() returns trigger
1054
+ language plpgsql security definer
1055
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1056
+ as $$
1057
+ declare
1058
+ rel text[];
1059
+ tgt text[];
1060
+ begin
1061
+ select coalesce(array_agg(p.k) filter (where p.kind is not null), '{}'),
1062
+ coalesce(array_agg(p.k) filter (where p.kind in ('target', 'both')), '{}')
1063
+ into rel, tgt
1064
+ from (select k.c || ' ' || k.t as k, @SCHEMA@.agg_kind(k.c, k.t) as kind
1065
+ from (select distinct a.class as c, a.tenant as t from added a) k) p;
1066
+ if cardinality(rel) = 0 then
1067
+ return null;
1068
+ end if;
1069
+ perform @SCHEMA@.agg_apply('[]'::jsonb,
1070
+ (select jsonb_agg(jsonb_build_object('id', a.id, 'class', a.class, 'tenant', a.tenant, 'links', a.links, 'data', a.data))
1071
+ from added a where a.class || ' ' || a.tenant = any (rel)),
1072
+ array(select a.id from added a where a.class || ' ' || a.tenant = any (tgt)));
1073
+ return null;
1074
+ end $$;
1075
+
1076
+ create or replace function @SCHEMA@.trg_entity_total_au() returns trigger
1077
+ language plpgsql security definer
1078
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1079
+ as $$
1080
+ declare
1081
+ rel text[];
1082
+ tgt text[];
1083
+ begin
1084
+ -- строки, у которых изменились класс, концы или данные (владелец и теги вклада не меняют)
1085
+ select coalesce(array_agg(p.k) filter (where p.kind is not null), '{}'),
1086
+ coalesce(array_agg(p.k) filter (where p.kind in ('target', 'both')), '{}')
1087
+ into rel, tgt
1088
+ from (select k.c || ' ' || k.t as k, @SCHEMA@.agg_kind(k.c, k.t) as kind
1089
+ from (select distinct v.c, v.t from new_rows n join old_rows o on o.id = n.id
1090
+ cross join lateral (values (n.class, n.tenant), (o.class, o.tenant)) v(c, t)
1091
+ where n.class <> o.class or n.links <> o.links or n.data <> o.data) k) p;
1092
+ if cardinality(rel) = 0 then
1093
+ return null;
1094
+ end if;
1095
+ perform @SCHEMA@.agg_apply(
1096
+ (select jsonb_agg(jsonb_build_object('id', o.id, 'class', o.class, 'tenant', o.tenant, 'links', o.links, 'data', o.data))
1097
+ from old_rows o join new_rows n on n.id = o.id
1098
+ where (n.class <> o.class or n.links <> o.links or n.data <> o.data) and o.class || ' ' || o.tenant = any (rel)),
1099
+ (select jsonb_agg(jsonb_build_object('id', n.id, 'class', n.class, 'tenant', n.tenant, 'links', n.links, 'data', n.data))
1100
+ from new_rows n join old_rows o on o.id = n.id
1101
+ where (n.class <> o.class or n.links <> o.links or n.data <> o.data) and n.class || ' ' || n.tenant = any (rel)),
1102
+ -- смена класса в цель: поле считается целиком
1103
+ array(select n.id from new_rows n join old_rows o on o.id = n.id
1104
+ where n.class <> o.class and n.class || ' ' || n.tenant = any (tgt)));
1105
+ return null;
1106
+ end $$;
1107
+
1108
+ create or replace function @SCHEMA@.trg_entity_total_ad() returns trigger
1109
+ language plpgsql security definer
1110
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1111
+ as $$
1112
+ declare
1113
+ rel text[];
1114
+ begin
1115
+ select coalesce(array_agg(p.k) filter (where p.kind is not null), '{}') into rel
1116
+ from (select k.c || ' ' || k.t as k, @SCHEMA@.agg_kind(k.c, k.t) as kind
1117
+ from (select distinct g.class as c, g.tenant as t from gone g) k) p;
1118
+ if cardinality(rel) = 0 then
1119
+ return null;
1120
+ end if;
1121
+ perform @SCHEMA@.agg_apply(
1122
+ (select jsonb_agg(jsonb_build_object('id', g.id, 'class', g.class, 'tenant', g.tenant, 'links', g.links, 'data', g.data))
1123
+ from gone g where g.class || ' ' || g.tenant = any (rel)),
1124
+ '[]'::jsonb, '{}'::uuid[]);
1125
+ return null;
1126
+ end $$;
1127
+
1128
+ create or replace trigger entity_total_ai after insert on @SCHEMA@.entity referencing new table as added
1129
+ for each statement execute function @SCHEMA@.trg_entity_total_ai();
1130
+ create or replace trigger entity_total_au after update on @SCHEMA@.entity referencing old table as old_rows new table as new_rows
1131
+ for each statement execute function @SCHEMA@.trg_entity_total_au();
1132
+ create or replace trigger entity_total_ad after delete on @SCHEMA@.entity referencing old table as gone
1133
+ for each statement execute function @SCHEMA@.trg_entity_total_ad();
1134
+ -- и в режиме репликации (session_replication_role = replica)
1135
+ alter table @SCHEMA@.entity enable always trigger entity_total_ai;
1136
+ alter table @SCHEMA@.entity enable always trigger entity_total_au;
1137
+ alter table @SCHEMA@.entity enable always trigger entity_total_ad;
1138
+
1139
+ do $$
1140
+ declare f text;
1141
+ begin
1142
+ foreach f in array array['agg_fields(jsonb)', 'agg_of(jsonb)', 'agg_zero(jsonb, jsonb)', 'agg_num(jsonb)', 'agg_where_ok(jsonb, jsonb)',
1143
+ 'agg_guard(jsonb, jsonb, jsonb, boolean)', 'agg_keep(text, uuid, jsonb, jsonb)', 'agg_check_source(jsonb, jsonb, text, jsonb)',
1144
+ 'agg_cycle(uuid, jsonb)', 'agg_prepare(jsonb, jsonb, uuid)', 'agg_source(jsonb, uuid)', 'agg_marked(jsonb, jsonb)',
1145
+ 'agg_new_mark(jsonb, jsonb)', 'agg_source_admit(text, jsonb)', 'agg_mark(uuid, text[])', 'agg_lock_sources(jsonb, jsonb, uuid)',
1146
+ 'agg_classes(text, uuid, boolean)', 'agg_compute(jsonb, uuid, uuid)',
1147
+ 'agg_check(text, uuid, jsonb)', 'agg_write(@SCHEMA@.entity, jsonb, boolean)', 'agg_token(text)', 'agg_held()',
1148
+ 'agg_hold_begin(text)', 'agg_hold_end(boolean)', 'agg_merge(jsonb, jsonb)', 'agg_flush(jsonb)', 'agg_kind(text, uuid)',
1149
+ 'agg_apply(jsonb, jsonb, uuid[])', 'agg_refresh(text[], uuid, boolean)', 'rebuild_aggregates(text)', 'agg_issues()', 'agg_fill()',
1150
+ 'trg_entity_total_ai()', 'trg_entity_total_au()', 'trg_entity_total_ad()'] loop
1151
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
1152
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
1153
+ end loop;
1154
+ end $$;