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