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