letopis 0.20.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +348 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3466
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +369 -551
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -250
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -351
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -600
  89. package/sql/seed.auth.sql +0 -73
@@ -0,0 +1,957 @@
1
+ -- letopis 1.0 — время и эксплуатация (план, §2.11, этап 6, п. 2–4, 6–8).
2
+ --
3
+ -- Функции владельца проверяют всё сами, сами пишут журнал и сами посылают сигнал (§2.7):
4
+ -- restore() — последний снимок удалённого объекта новой версией (вид restore), с проверкой
5
+ -- по текущему описанию класса; id прежний;
6
+ -- purge() — стирание истории удалённого объекта: на место версии 1 встаёт запись purge
7
+ -- с числом стёртых версий и последним хэшем;
8
+ -- trim_history() — обрезка истории до момента: на место версии 1 встаёт запись trim со временем
9
+ -- прежней версии 1, числом стёртых версий и хэшем последней из них;
10
+ -- rekey(), rekey_class() — новое значение ключа объекта или новый ключ класса: строка с новым id,
11
+ -- перевод ссылок (move_row), журнал связывает id колонкой moved;
12
+ -- account_purge() — удаление аккаунта с данными его арендатора (предохранители 0.21);
13
+ -- reset() — сброс sessions | tenant | all (флаг allow_reset, право letopis.reset,
14
+ -- подтверждение именем схемы); первая запись журнала после сброса — reset;
15
+ -- maintain() — истёкшие сессии и коды, одна пачка прореживания журнала по политике хранения;
16
+ -- verify() — цепочки хэшей с законными разрывами (purge, trim, reset, прореживание),
17
+ -- якорь, а с p_data — данные, концы, арендаторы, владельцы и id всех строк.
18
+ -- У id, у которого в журнале есть хоть одна запись, номер версии 1 всегда занят (этап 6, п. 3):
19
+ -- на нём держится режим existing: 'error' загрузчика.
20
+
21
+ -- Умолчание политики хранения — константа установки up({ history }); null — хранить всё.
22
+ create or replace function @SCHEMA@.history_default() returns jsonb
23
+ language sql immutable parallel safe
24
+ return nullif((@HISTORY_DEFAULT@)::jsonb, 'null'::jsonb);
25
+
26
+ -- Действующая политика хранения класса по развёрнутому описанию: своя (объект), умолчание установки
27
+ -- для классов с журналом, null — хранить всё. Системные классы не прореживаются (§2.11).
28
+ create or replace function @SCHEMA@.history_policy(p_res jsonb) returns jsonb
29
+ language sql immutable parallel safe
30
+ return case
31
+ when (p_res->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then null
32
+ when jsonb_typeof(p_res->'history') = 'object' then p_res->'history'
33
+ when p_res->'history' = 'true'::jsonb then @SCHEMA@.history_default()
34
+ end;
35
+
36
+ -- Срок яруса политики: интервал или бесконечность ('forever').
37
+ create or replace function @SCHEMA@.history_iv(p_term text) returns interval
38
+ language sql immutable parallel safe
39
+ return case when p_term = 'forever' then 'infinity'::interval else p_term::interval end;
40
+
41
+ -- Конец последнего яруса политики: после него история удаляется (бесконечность — хранится всегда).
42
+ create or replace function @SCHEMA@.history_horizon(p_pol jsonb) returns interval
43
+ language sql immutable parallel safe
44
+ return (select @SCHEMA@.history_iv(p_pol->>t.n) from unnest(array['all', 'daily', 'weekly', 'monthly', 'yearly']) with ordinality t(n, i)
45
+ where p_pol ? t.n order by t.i desc limit 1);
46
+
47
+ -- Нужна ли политике версия (§2.11): в ярусе all — все; дальше — последняя в своём периоде текущего
48
+ -- яруса или любого более крупного (недели не вкладываются в месяцы); старше последнего яруса со
49
+ -- сроком — не нужна. Время — местное в поясе политики: p_lt — версии, p_nowl — «сейчас», p_nlt —
50
+ -- следующей версии того же id (null — следующей нет). Ярус — календарно: моложе срока, если позже
51
+ -- «сейчас» минус срок (месяц — месяц, а не 30 дней).
52
+ create or replace function @SCHEMA@.history_keep(p_pol jsonb, p_lt timestamp, p_nowl timestamp, p_nlt timestamp) returns boolean
53
+ language plpgsql immutable parallel safe
54
+ as $$
55
+ declare
56
+ tiers text[] := array['all', 'daily', 'weekly', 'monthly', 'yearly'];
57
+ units text[] := array[null, 'day', 'week', 'month', 'year'];
58
+ my int;
59
+ i int;
60
+ begin
61
+ for i in 1 .. 5 loop
62
+ continue when not (p_pol ? tiers[i]);
63
+ if p_lt > p_nowl - @SCHEMA@.history_iv(p_pol->>tiers[i]) then
64
+ my := i;
65
+ exit;
66
+ end if;
67
+ end loop;
68
+ if my is null then
69
+ return false;
70
+ end if;
71
+ if my = 1 or p_nlt is null then
72
+ return true;
73
+ end if;
74
+ for i in my .. 5 loop
75
+ continue when not (p_pol ? tiers[i]);
76
+ if date_trunc(units[i], p_nlt) <> date_trunc(units[i], p_lt) then
77
+ return true;
78
+ end if;
79
+ end loop;
80
+ return false;
81
+ end $$;
82
+
83
+ -- Роль подключения — администраторская (суперпользователь или может стать владельцем): verify,
84
+ -- reset и maintain доступны ей и без сессии (CLI, обслуживание по расписанию).
85
+ create or replace function @SCHEMA@.admin_role() returns boolean
86
+ language sql stable
87
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
88
+ return exists (select 1 from pg_roles where rolname = session_user and rolsuper)
89
+ or pg_has_role(session_user, '@OWNER_NAME@', 'SET');
90
+
91
+ -- Право letopis.* актора сессии по правилам System или администраторская роль подключения.
92
+ create or replace function @SCHEMA@.letopis_allowed(p_endpoint text) returns boolean
93
+ language plpgsql stable
94
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
95
+ as $$
96
+ declare
97
+ c record;
98
+ begin
99
+ if @SCHEMA@.admin_role() then
100
+ return true;
101
+ end if;
102
+ select * into c from @SCHEMA@.ctx();
103
+ return c.account is not null
104
+ and coalesce((@SCHEMA@.acl_api_for(c.tenant, c.categories, c.roles, p_endpoint, true)->>'allow')::boolean, false);
105
+ end $$;
106
+
107
+ -- Срок действующей сессии (кэш результатов не отдаёт ответы истёкшей сессии); нет сессии — null.
108
+ create or replace function @SCHEMA@.session_expires() returns timestamptz
109
+ language plpgsql stable security definer
110
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
111
+ as $$
112
+ declare
113
+ c record;
114
+ begin
115
+ select * into c from @SCHEMA@.ctx();
116
+ if c.session is null then
117
+ return null;
118
+ end if;
119
+ return (select s.expires_at from @SCHEMA@.secret s where s.id = c.session);
120
+ end $$;
121
+
122
+ -- ========================= restore, purge, обрезка истории =========================
123
+
124
+ -- Последняя запись журнала id под блокировкой, если она видна актору в его арендаторе: параллельные
125
+ -- restore, purge, обрезка и воскрешение (trg_entity_bi берёт ту же блокировку) упорядочены.
126
+ create or replace function @SCHEMA@.last_version_visible(p_id uuid, p_tenant uuid, p_acl jsonb) returns @SCHEMA@.log
127
+ language plpgsql volatile
128
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
129
+ as $$
130
+ declare
131
+ l @SCHEMA@.log;
132
+ begin
133
+ -- арендатор id неизменен: блокируется только запись своего арендатора, чужую вызов не задерживает
134
+ -- и по ожиданию не обнаружит (итоговое ревью безопасности)
135
+ select x.* into l from @SCHEMA@.log x where x.id = p_id and x.tenant = p_tenant order by x.rev desc limit 1 for update;
136
+ if l.id is null or l.tenant <> p_tenant or l.op in ('purge', 'reset')
137
+ or not @SCHEMA@.row_visible(l.tenant, l.class, l.id, l.owner, l.tags, l.data, l.links, p_tenant, (p_acl->>'account')::uuid, p_acl->'plan') then
138
+ perform @SCHEMA@.lt_fail('target_not_found', 'цель не найдена');
139
+ end if;
140
+ return l;
141
+ end $$;
142
+
143
+ -- Восстановление удалённого объекта (этап 6, п. 2): последний снимок новой версией с видом restore.
144
+ -- Снимок перепроверяется по текущему описанию класса (данные, концы с видимостью целей, ключ), право
145
+ -- WRITE — на восстановленные значения. id прежний, в том числе у класса без ключа.
146
+ create or replace function @SCHEMA@.restore(p_id uuid) returns uuid
147
+ language plpgsql volatile security definer
148
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
149
+ as $$
150
+ declare
151
+ c record;
152
+ rd jsonb;
153
+ l @SCHEMA@.log;
154
+ cls @SCHEMA@.entity;
155
+ res jsonb;
156
+ d jsonb;
157
+ bad jsonb;
158
+ lk record;
159
+ kid uuid;
160
+ own uuid;
161
+ ts timestamptz;
162
+ n @SCHEMA@.entity;
163
+ begin
164
+ select * into c from @SCHEMA@.ctx();
165
+ if c.account is null then
166
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
167
+ end if;
168
+ rd := @SCHEMA@.acl_actor('READ');
169
+ l := @SCHEMA@.last_version_visible(p_id, c.tenant, rd);
170
+ if l.op <> 'delete' or exists (select 1 from @SCHEMA@.entity e where e.id = p_id) then
171
+ perform @SCHEMA@.lt_fail('not_deleted', 'объект не удалён: restore — только для удалённых');
172
+ end if;
173
+ if l.class = 'Class' then
174
+ perform @SCHEMA@.lt_fail('invalid_class', 'описание класса восстанавливается созданием строки Class');
175
+ end if;
176
+ cls := @SCHEMA@.class_row(l.class, l.tenant);
177
+ if cls.id is null then
178
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', l.class));
179
+ end if;
180
+ perform @SCHEMA@.family_lock(cls, false);
181
+ cls := @SCHEMA@.class_row(l.class, l.tenant);
182
+ res := cls.data->'resolved';
183
+ if coalesce((res->>'abstract')::boolean, false) then
184
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', l.class));
185
+ end if;
186
+ d := @SCHEMA@.apply_defaults(res->'schema', l.data);
187
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
188
+ if bad <> '[]'::jsonb then
189
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', l.class), jsonb_build_object('issues', bad));
190
+ end if;
191
+ select * into lk from @SCHEMA@.check_links(res, l.links, l.tenant, true, rd);
192
+ if lk.code is not null then
193
+ perform @SCHEMA@.lt_fail(lk.code, lk.msg);
194
+ end if;
195
+ kid := @SCHEMA@.key_id(res, l.class, l.tenant, d, lk.links);
196
+ if kid is not null and kid <> p_id then
197
+ perform @SCHEMA@.lt_fail('id_mismatch', format('ключ класса %s изменился: id объекта не равен v5 от ключа', l.class), jsonb_build_object('expected', kid));
198
+ end if;
199
+ -- владелец — существующий аккаунт или арендатор
200
+ own := l.owner;
201
+ if own <> l.tenant and not exists (select 1 from @SCHEMA@.entity a where a.id = own and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
202
+ own := l.tenant;
203
+ end if;
204
+ if not @SCHEMA@.acl_row_ok(@SCHEMA@.acl_plan('WRITE'), l.class, p_id, own, l.tenant, l.tags, d, lk.links) then
205
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', l.class));
206
+ end if;
207
+ ts := greatest(@SCHEMA@.now_ts(), l.at + interval '1 microsecond');
208
+ insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator)
209
+ values (p_id, l.rev + 1, l.class, cls.rev, l.tenant, own, lk.links, d, l.tags, ts, ts, c.author, c.agent, 'restore',
210
+ nullif(current_setting('letopis.reason', true), ''), null, l.hash, null)
211
+ returning * into n;
212
+ perform @SCHEMA@.log_version(n);
213
+ if l.class in ('rule', 'Resource', 'member', 'Account') then
214
+ perform @SCHEMA@.acl_bump();
215
+ end if;
216
+ return p_id;
217
+ end $$;
218
+
219
+ -- Стирание истории удалённого объекта (этап 6, п. 3): без p_confirm — превью (число версий), с ним —
220
+ -- все версии удаляются, а на место версии 1 встаёт запись purge с числом стёртых версий и хэшем
221
+ -- последней. Право DELETE на класс по последнему снимку. История системных классов — аудит прав и
222
+ -- схемы: её не стирают.
223
+ create or replace function @SCHEMA@.purge(p_id uuid, p_confirm boolean default false) returns jsonb
224
+ language plpgsql volatile security definer
225
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
226
+ as $$
227
+ declare
228
+ c record;
229
+ l @SCHEMA@.log;
230
+ n int;
231
+ begin
232
+ select * into c from @SCHEMA@.ctx();
233
+ if c.account is null then
234
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
235
+ end if;
236
+ l := @SCHEMA@.last_version_visible(p_id, c.tenant, @SCHEMA@.acl_actor('READ'));
237
+ if l.op <> 'delete' or exists (select 1 from @SCHEMA@.entity e where e.id = p_id) then
238
+ perform @SCHEMA@.lt_fail('not_deleted', 'объект не удалён: purge — только для удалённых, сначала delete()');
239
+ end if;
240
+ if l.class in ('Class', 'Account', 'member', 'Resource', 'rule') then
241
+ perform @SCHEMA@.lt_fail('invalid_class', 'история системных классов — аудит прав и схемы: purge недоступен');
242
+ end if;
243
+ if not @SCHEMA@.acl_row_ok(@SCHEMA@.acl_plan('DELETE'), l.class, l.id, l.owner, l.tenant, l.tags, l.data, l.links) then
244
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s', l.class));
245
+ end if;
246
+ select count(*)::int into n from @SCHEMA@.log x where x.id = p_id;
247
+ if not coalesce(p_confirm, false) then
248
+ return jsonb_build_object('id', p_id, 'class', l.class, 'versions', n, 'purged', false);
249
+ end if;
250
+ delete from @SCHEMA@.log x where x.id = p_id;
251
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
252
+ values (p_id, 1, l.class, l.cv, l.tenant, l.tenant, '{}', jsonb_build_object('$purged', n, '$hash', encode(l.hash, 'hex')), '{}',
253
+ greatest(@SCHEMA@.now_ts(), l.at + interval '1 microsecond'), coalesce(c.author, '@SYSTEM@'::uuid), c.agent, 'purge',
254
+ nullif(current_setting('letopis.reason', true), ''), null, null);
255
+ -- воскрешение, зафиксированное, пока purge ждал блокировку, — отказ (снимок нового оператора его видит)
256
+ if exists (select 1 from @SCHEMA@.entity e where e.id = p_id) then
257
+ perform @SCHEMA@.lt_fail('not_deleted', 'объект восстановлен во время purge');
258
+ end if;
259
+ perform @SCHEMA@.signal(array[l.class]);
260
+ return jsonb_build_object('id', p_id, 'class', l.class, 'versions', n, 'purged', true);
261
+ end $$;
262
+
263
+ -- Обрезка истории (этап 6, п. 3; замена compact 0.21): версии строже момента p_before удаляются,
264
+ -- текущая — никогда. Время версий одного id монотонно, поэтому удаляется начало истории — и всегда
265
+ -- версия 1: на её место встаёт запись trim со временем прежней версии 1 (срез на момент знает, когда
266
+ -- объект появился), номером последней стёртой версии ($trimmed) и её хэшем ($hash) — по ним verify()
267
+ -- проверяет стык. Цели — p_ids или все объекты классов p_classes арендатора; право DELETE на каждую
268
+ -- (по последней версии). Возвращает число стёртых версий.
269
+ create or replace function @SCHEMA@.trim_history(p_ids uuid[], p_classes text[], p_before timestamptz) returns int
270
+ language plpgsql volatile security definer
271
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
272
+ as $$
273
+ declare
274
+ c record;
275
+ rd jsonb;
276
+ dplan jsonb;
277
+ r @SCHEMA@.log;
278
+ first @SCHEMA@.log;
279
+ j int;
280
+ h bytea;
281
+ k int;
282
+ total int := 0;
283
+ classes text[] := '{}';
284
+ begin
285
+ select * into c from @SCHEMA@.ctx();
286
+ if c.account is null then
287
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
288
+ end if;
289
+ if p_before is null or (p_ids is null and p_classes is null) then
290
+ perform @SCHEMA@.lt_fail('invalid_data', 'обрезка истории: нужны цели и момент');
291
+ end if;
292
+ rd := @SCHEMA@.acl_actor('READ');
293
+ dplan := @SCHEMA@.acl_plan('DELETE');
294
+ if p_ids is not null then
295
+ -- явные цели: каждая обязана найтись
296
+ for r in select distinct on (u.x) l.* from unnest(p_ids) u(x)
297
+ left join lateral (select * from @SCHEMA@.log x where x.id = u.x order by x.rev desc limit 1) l on true
298
+ order by u.x loop
299
+ if r.id is null or r.tenant <> c.tenant or r.op in ('purge', 'reset')
300
+ or not @SCHEMA@.row_visible(r.tenant, r.class, r.id, r.owner, r.tags, r.data, r.links, c.tenant, c.account, rd->'plan') then
301
+ perform @SCHEMA@.lt_fail('target_not_found', 'цель не найдена');
302
+ end if;
303
+ end loop;
304
+ end if;
305
+ for r in select distinct on (l.id) l.* from @SCHEMA@.log l
306
+ where l.tenant = c.tenant and (case when p_ids is not null then l.id = any (p_ids) else l.class = any (p_classes) end)
307
+ order by l.id, l.rev desc loop
308
+ continue when r.op in ('purge', 'reset')
309
+ or not @SCHEMA@.row_visible(r.tenant, r.class, r.id, r.owner, r.tags, r.data, r.links, c.tenant, c.account, rd->'plan');
310
+ if r.class in ('Class', 'Account', 'member', 'Resource', 'rule') then
311
+ perform @SCHEMA@.lt_fail('invalid_class', 'история системных классов — аудит прав и схемы: обрезка недоступна');
312
+ end if;
313
+ if not @SCHEMA@.acl_row_ok(dplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
314
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s', r.class));
315
+ end if;
316
+ -- версии id под блокировкой: параллельная обрезка и прореживание ждут
317
+ perform 1 from @SCHEMA@.log x where x.id = r.id order by x.rev for update;
318
+ select max(x.rev) into j from @SCHEMA@.log x where x.id = r.id and x.at < p_before and x.rev < r.rev;
319
+ continue when j is null;
320
+ select x.* into first from @SCHEMA@.log x where x.id = r.id order by x.rev limit 1;
321
+ -- до момента — только сама запись trim: стирать нечего
322
+ continue when j = 1 and first.op = 'trim';
323
+ select x.hash into h from @SCHEMA@.log x where x.id = r.id and x.rev = j;
324
+ with d as (delete from @SCHEMA@.log x where x.id = r.id and x.rev <= j returning 1)
325
+ select count(*)::int into k from d;
326
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
327
+ values (r.id, 1, r.class, r.cv, r.tenant, r.tenant, '{}', jsonb_build_object('$trimmed', j, '$hash', encode(h, 'hex')), '{}',
328
+ first.at, coalesce(c.author, '@SYSTEM@'::uuid), c.agent, 'trim', nullif(current_setting('letopis.reason', true), ''), null, null);
329
+ -- запись trim заменяет версию 1: стёрто на одну меньше, если версия 1 была прежней записью trim
330
+ total := total + k - case when first.op = 'trim' then 1 else 0 end;
331
+ classes := classes || r.class;
332
+ end loop;
333
+ perform @SCHEMA@.signal(array(select distinct x from unnest(classes) x));
334
+ return total;
335
+ end $$;
336
+
337
+ -- ========================= смена ключа =========================
338
+
339
+ -- Новое значение ключа объекта (этап 6, п. 7): p_data сливается с данными, p_links заменяет концы
340
+ -- по ролям; новый id — v5 от нового ключа. Механизм — тот же, что у reclass(): строка с новым id,
341
+ -- перевод ссылок (рекурсивно для строк, в ключ которых входит конец), надгробие старого id со
342
+ -- ссылкой на новый. Ключ не изменился — обычная правка неключевых полей.
343
+ create or replace function @SCHEMA@.rekey(p_id uuid, p_data jsonb default null, p_links jsonb default null) returns uuid
344
+ language plpgsql volatile security definer
345
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
346
+ as $$
347
+ declare
348
+ c record;
349
+ rd jsonb;
350
+ wplan jsonb;
351
+ o @SCHEMA@.entity;
352
+ cls @SCHEMA@.entity;
353
+ res jsonb;
354
+ d jsonb;
355
+ bad jsonb;
356
+ lk record;
357
+ nid uuid;
358
+ denied text;
359
+ begin
360
+ select * into c from @SCHEMA@.ctx();
361
+ if c.account is null then
362
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
363
+ end if;
364
+ if current_setting('transaction_isolation') <> 'read committed' then
365
+ perform @SCHEMA@.lt_fail('isolation_level', 'смена ключа разрешена только в read committed');
366
+ end if;
367
+ rd := @SCHEMA@.acl_actor('READ');
368
+ wplan := @SCHEMA@.acl_plan('WRITE');
369
+ select x.* into o from @SCHEMA@.entity x where x.id = p_id and x.tenant = c.tenant for update;
370
+ if o.id is null or not @SCHEMA@.row_visible(o.tenant, o.class, o.id, o.owner, o.tags, o.data, o.links, c.tenant, c.account, rd->'plan') then
371
+ perform @SCHEMA@.lt_fail('target_not_found', 'цель не найдена');
372
+ end if;
373
+ if not @SCHEMA@.acl_row_ok(wplan, o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
374
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', o.class));
375
+ end if;
376
+ if o.class in ('Class', 'Account', 'member', 'Resource', 'rule') then
377
+ perform @SCHEMA@.lt_fail('invalid_class', 'ключ системной строки не меняется');
378
+ end if;
379
+ cls := @SCHEMA@.class_row(o.class, o.tenant);
380
+ perform @SCHEMA@.family_lock(cls, false);
381
+ cls := @SCHEMA@.class_row(o.class, o.tenant);
382
+ res := cls.data->'resolved';
383
+ if jsonb_array_length(coalesce(res->'key', '[]'::jsonb)) = 0 then
384
+ perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s нет ключа: id выдаёт база, менять нечего', o.class));
385
+ end if;
386
+ d := @SCHEMA@.apply_defaults(res->'schema', @SCHEMA@.merge(o.data, coalesce(p_data, '{}'::jsonb)));
387
+ bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
388
+ if bad <> '[]'::jsonb then
389
+ perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', o.class), jsonb_build_object('issues', bad));
390
+ end if;
391
+ select * into lk from @SCHEMA@.check_links(res, o.links || coalesce(p_links, '{}'::jsonb), o.tenant, true, rd);
392
+ if lk.code is not null then
393
+ perform @SCHEMA@.lt_fail(lk.code, lk.msg);
394
+ end if;
395
+ nid := @SCHEMA@.key_id(res, o.class, o.tenant, d, lk.links);
396
+ if not @SCHEMA@.acl_row_ok(wplan, o.class, nid, o.owner, o.tenant, o.tags, d, lk.links) then
397
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s', o.class));
398
+ end if;
399
+ if nid = o.id then
400
+ if d is distinct from o.data or lk.links is distinct from o.links then
401
+ perform @SCHEMA@.sys_update(o.id, d, lk.links, null, 'update');
402
+ end if;
403
+ return o.id;
404
+ end if;
405
+ -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE
406
+ select min(x.class) into denied from @SCHEMA@.entity x
407
+ where x.ends @> array[o.id] and not @SCHEMA@.acl_row_ok(wplan, x.class, x.id, x.owner, x.tenant, x.tags, x.data, x.links);
408
+ if denied is not null then
409
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на ссылающиеся строки класса %s', denied));
410
+ end if;
411
+ perform @SCHEMA@.move_row(o, nid, o.class, d, lk.links, 'rekey');
412
+ return nid;
413
+ end $$;
414
+
415
+ -- Новый ключ класса (этап 6, п. 7): описание класса и потомков, наследующих ключ, пересчитывается,
416
+ -- каждая строка семейства с другим id переносится на новый id (move_row). Исключительная
417
+ -- блокировка корня семейства приостанавливает запись в семейство до фиксации. Возвращает число
418
+ -- перенесённых строк класса (ссылающиеся строки с концом в ключе переносятся рекурсивно и не считаются).
419
+ create or replace function @SCHEMA@.rekey_class(p_class text, p_key jsonb) returns int
420
+ language plpgsql volatile security definer
421
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
422
+ as $$
423
+ declare
424
+ c record;
425
+ wplan jsonb;
426
+ cr @SCHEMA@.entity;
427
+ prep record;
428
+ dsc @SCHEMA@.entity;
429
+ classes text[];
430
+ r @SCHEMA@.entity;
431
+ res jsonb;
432
+ nid uuid;
433
+ n int := 0;
434
+ conflicts jsonb;
435
+ begin
436
+ select * into c from @SCHEMA@.ctx();
437
+ if c.account is null then
438
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
439
+ end if;
440
+ if current_setting('transaction_isolation') <> 'read committed' then
441
+ perform @SCHEMA@.lt_fail('isolation_level', 'смена ключа разрешена только в read committed');
442
+ end if;
443
+ wplan := @SCHEMA@.acl_plan('WRITE');
444
+ cr := @SCHEMA@.class_row(p_class, c.tenant);
445
+ if cr.id is null or cr.tenant <> c.tenant then
446
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден среди классов арендатора', p_class));
447
+ end if;
448
+ if coalesce((cr.data->>'system')::boolean, false) then
449
+ perform @SCHEMA@.lt_fail('invalid_class', 'ключ системного класса не меняется');
450
+ end if;
451
+ if not @SCHEMA@.acl_row_ok(wplan, 'Class', cr.id, cr.owner, cr.tenant, cr.tags, cr.data, cr.links) then
452
+ perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на класс Class');
453
+ end if;
454
+ perform @SCHEMA@.family_lock(cr, true);
455
+ select x.* into cr from @SCHEMA@.entity x where x.id = cr.id for update;
456
+ select p.data, p.validator into prep from @SCHEMA@.class_prepare(cr.data || jsonb_build_object('key', p_key), cr.tenant) p;
457
+ perform @SCHEMA@.sys_update(cr.id, prep.data, cr.links, prep.validator, 'update');
458
+ classes := array[p_class];
459
+ -- потомки: описание пересчитывается (каждый пересчёт — версия класса в журнале)
460
+ for dsc in select x.* from @SCHEMA@.entity x
461
+ where x.class = 'Class' and x.tenant = cr.tenant and x.id <> cr.id
462
+ and x.data->'resolved'->'ancestors' ? p_class
463
+ order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.id loop
464
+ select p.data, p.validator into prep from @SCHEMA@.class_prepare(dsc.data, dsc.tenant) p;
465
+ if prep.data is distinct from dsc.data or prep.validator::text is distinct from dsc.validator::text then
466
+ perform @SCHEMA@.sys_update(dsc.id, prep.data, dsc.links, prep.validator, 'update');
467
+ end if;
468
+ classes := classes || (dsc.data->>'name');
469
+ end loop;
470
+ for r in select x.* from @SCHEMA@.entity x where x.class = any (classes) and x.tenant = cr.tenant order by x.id loop
471
+ -- строку мог уже перенести перевод ссылок (конец в её ключе): берём свежую
472
+ select x.* into r from @SCHEMA@.entity x where x.id = r.id for update;
473
+ continue when r.id is null;
474
+ if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
475
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', r.class));
476
+ end if;
477
+ res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
478
+ nid := coalesce(@SCHEMA@.key_id(res, r.class, r.tenant, r.data, r.links), r.id);
479
+ if nid <> r.id then
480
+ perform @SCHEMA@.move_row(r, nid, r.class, r.data, r.links, 'rekey');
481
+ n := n + 1;
482
+ end if;
483
+ end loop;
484
+ conflicts := @SCHEMA@.family_conflicts(classes, cr.tenant, false);
485
+ if conflicts <> '[]'::jsonb then
486
+ perform @SCHEMA@.lt_fail('tightening_conflict', format('новому ключу класса %s не соответствуют строки', p_class),
487
+ @SCHEMA@.visible_detail(conflicts, c.tenant));
488
+ end if;
489
+ perform @SCHEMA@.acl_bump();
490
+ return n;
491
+ end $$;
492
+
493
+ -- ========================= удаление аккаунта =========================
494
+
495
+ -- Удаление аккаунта (этап 6, п. 3; accounts.purge 0.21): вызывающий — системный администратор или
496
+ -- владелец арендатора-цели (член с ролью owner); удалить себя нельзя; нельзя оставить арендатора без
497
+ -- последнего включённого владельца. Данные и журнал арендатора-цели стираются физически; в чужих
498
+ -- арендаторах членства снимаются (надгробия), владение строками переходит арендаторам (§2.10);
499
+ -- остались ссылки на аккаунт — delete_restricted. Строка Account удаляется с надгробием (история
500
+ -- аккаунтов — аудит), сессии гаснут, креды удаляются.
501
+ create or replace function @SCHEMA@.account_purge(p_account uuid) returns jsonb
502
+ language plpgsql volatile security definer
503
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
504
+ as $$
505
+ declare
506
+ c record;
507
+ a @SCHEMA@.entity;
508
+ t record;
509
+ m @SCHEMA@.entity;
510
+ n_rows int;
511
+ n_log int;
512
+ n_mem int := 0;
513
+ n_own int := 0;
514
+ classes text[];
515
+ blockers jsonb;
516
+ begin
517
+ select * into c from @SCHEMA@.ctx();
518
+ if c.account is null then
519
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
520
+ end if;
521
+ if p_account = c.account then
522
+ perform @SCHEMA@.lt_fail('acl_denied', 'удалить себя нельзя');
523
+ end if;
524
+ -- право — до поиска и блокировки цели: без права ответ один (acl_denied), есть ли такой аккаунт,
525
+ -- не видно, и чужую строку Account вызов не блокирует (итоговое ревью безопасности)
526
+ if not (@SCHEMA@.auth_is_sysadmin() or exists (
527
+ select 1 from @SCHEMA@.entity x
528
+ where x.id = @SCHEMA@.v5_id(p_account, 'member', array[c.account::text, p_account::text]) and x.class = 'member'
529
+ and x.tenant = p_account and coalesce(x.data->'roles' ? 'owner', false))) then
530
+ perform @SCHEMA@.lt_fail('acl_denied', 'удалить аккаунт может системный администратор или владелец его арендатора');
531
+ end if;
532
+ select x.* into a from @SCHEMA@.entity x where x.id = p_account and x.class = 'Account' and x.tenant = '@SYSTEM@'::uuid for update;
533
+ if a.id is null or p_account = '@SYSTEM@'::uuid then
534
+ perform @SCHEMA@.lt_fail('target_not_found', 'аккаунт не найден');
535
+ end if;
536
+ -- последний владелец: в каждом чужом арендаторе, где цель — владелец, должен остаться другой
537
+ for t in select x.tenant from @SCHEMA@.entity x
538
+ where x.class = 'member' and x.links->>'User' = p_account::text and x.tenant <> p_account
539
+ and coalesce(x.data->'roles' ? 'owner', false) loop
540
+ if not exists (select 1 from @SCHEMA@.entity x
541
+ join @SCHEMA@.entity u on u.id = (x.links->>'User')::uuid and u.class = 'Account' and u.tenant = '@SYSTEM@'::uuid
542
+ where x.class = 'member' and x.tenant = t.tenant and x.links->>'User' <> p_account::text
543
+ and coalesce(x.data->'roles' ? 'owner', false) and coalesce((u.data->>'enabled')::boolean, true)) then
544
+ perform @SCHEMA@.lt_fail('acl_denied', format('аккаунт — последний владелец арендатора %s', t.tenant), jsonb_build_object('tenant', t.tenant));
545
+ end if;
546
+ end loop;
547
+ -- данные арендатора-цели — физически, вместе с журналом
548
+ select coalesce(array_agg(distinct x.class), '{}') into classes from @SCHEMA@.entity x where x.tenant = p_account;
549
+ with d as (delete from @SCHEMA@.entity x where x.tenant = p_account returning 1) select count(*)::int into n_rows from d;
550
+ with d as (delete from @SCHEMA@.log x where x.tenant = p_account returning 1) select count(*)::int into n_log from d;
551
+ -- членства в чужих арендаторах: надгробия; владение строками — арендаторам
552
+ for m in select x.* from @SCHEMA@.entity x where x.class = 'member' and x.links->>'User' = p_account::text order by x.id for update loop
553
+ delete from @SCHEMA@.entity x where x.id = m.id;
554
+ perform @SCHEMA@.tombstone(m);
555
+ n_mem := n_mem + 1;
556
+ end loop;
557
+ for t in select distinct x.tenant from @SCHEMA@.entity x where x.owner = p_account and x.owner <> x.tenant loop
558
+ n_own := n_own + @SCHEMA@.owner_to_tenant(t.tenant, p_account);
559
+ end loop;
560
+ select coalesce(jsonb_agg(jsonb_build_object('id', x.id, 'class', x.class, 'tenant', x.tenant)), '[]'::jsonb) into blockers
561
+ from (select x.id, x.class, x.tenant from @SCHEMA@.entity x where x.ends @> array[p_account] order by x.id limit 1000) x;
562
+ if blockers <> '[]'::jsonb then
563
+ perform @SCHEMA@.lt_fail('delete_restricted', 'на аккаунт ссылаются другие строки', @SCHEMA@.visible_detail(blockers, c.tenant));
564
+ end if;
565
+ delete from @SCHEMA@.entity x where x.id = p_account;
566
+ perform @SCHEMA@.tombstone(a);
567
+ perform @SCHEMA@.auth_kill(p_account, null);
568
+ delete from @SCHEMA@.secret s where s.account = p_account;
569
+ perform @SCHEMA@.signal(array(select distinct x from unnest(classes || array['Account', 'member']) x));
570
+ perform @SCHEMA@.acl_bump();
571
+ return jsonb_build_object('account', p_account, 'rows', n_rows, 'versions', n_log, 'memberships', n_mem, 'owned', n_own);
572
+ end $$;
573
+
574
+ -- ========================= сброс =========================
575
+
576
+ -- Запись reset — первая запись журнала после сброса: свой id в арендаторе, класс #reset (такое имя
577
+ -- класс получить не может), вид reset.
578
+ create or replace function @SCHEMA@.reset_mark(p_tenant uuid, p_level text, p_author uuid) returns void
579
+ language sql volatile
580
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
581
+ as $$
582
+ insert into @SCHEMA@.log (id, rev, class, tenant, owner, links, data, tags, at, author, agent, op, reason)
583
+ values (@SCHEMA@.v5_id(p_tenant, '#reset', array[gen_random_uuid()::text]), 1, '#reset', p_tenant, p_tenant, '{}',
584
+ jsonb_build_object('$reset', p_level), '{}', @SCHEMA@.now_ts(), coalesce(p_author, '@SYSTEM@'::uuid), null, 'reset',
585
+ nullif(current_setting('letopis.reason', true), ''))
586
+ $$;
587
+
588
+ -- Сброс (этап 6, п. 6): sessions — все сессии, кроме текущей; tenant — строки и журнал арендатора
589
+ -- (пачками по p_limit: done = false — вызвать ещё раз); all — всё, затем системный сид и новый
590
+ -- сервисный ключ (возвращается). Предохранители: флаг allow_reset (по умолчанию выключен — отказ
591
+ -- даже System), право letopis.reset, подтверждение именем схемы.
592
+ create or replace function @SCHEMA@.reset(p_level text, p_confirm text, p_tenant uuid default null, p_limit int default null) returns jsonb
593
+ language plpgsql volatile security definer
594
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
595
+ as $$
596
+ declare
597
+ c record;
598
+ t uuid;
599
+ n int;
600
+ m int;
601
+ key text;
602
+ begin
603
+ if not @ALLOW_RESET@ then
604
+ perform @SCHEMA@.lt_fail('reset_disabled', 'reset выключен в этой установке (up({ allowReset }))');
605
+ end if;
606
+ select * into c from @SCHEMA@.ctx();
607
+ if not @SCHEMA@.letopis_allowed('letopis.reset') then
608
+ perform @SCHEMA@.lt_fail('acl_denied', 'нет права letopis.reset');
609
+ end if;
610
+ if p_confirm is distinct from '@SCHEMA_NAME@' then
611
+ perform @SCHEMA@.lt_fail('confirm_mismatch', 'подтверждение сброса — имя схемы установки');
612
+ end if;
613
+ case p_level
614
+ when 'sessions' then
615
+ with d as (delete from @SCHEMA@.secret s where s.kind = 'SESSION' and s.id is distinct from c.session returning 1)
616
+ select count(*)::int into n from d;
617
+ perform @SCHEMA@.reset_mark('@SYSTEM@'::uuid, p_level, c.author);
618
+ perform @SCHEMA@.signal(array['session:*']);
619
+ return jsonb_build_object('level', p_level, 'sessions', n);
620
+ when 'tenant' then
621
+ t := coalesce(p_tenant, c.tenant);
622
+ if t is null or t = '@SYSTEM@'::uuid then
623
+ perform @SCHEMA@.lt_fail('tenant_denied', 'System сбрасывается только целиком (level all)');
624
+ end if;
625
+ if not exists (select 1 from @SCHEMA@.entity a where a.id = t and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
626
+ perform @SCHEMA@.lt_fail('target_not_found', 'арендатор не найден');
627
+ end if;
628
+ with d as (delete from @SCHEMA@.entity x where x.id in (
629
+ select y.id from @SCHEMA@.entity y where y.tenant = t limit coalesce(p_limit, 2147483647)) returning 1)
630
+ select count(*)::int into n from d;
631
+ with d as (delete from @SCHEMA@.log x where x.seq in (
632
+ select y.seq from @SCHEMA@.log y where y.tenant = t limit coalesce(p_limit, 2147483647)) returning 1)
633
+ select count(*)::int into m from d;
634
+ if exists (select 1 from @SCHEMA@.entity y where y.tenant = t) or exists (select 1 from @SCHEMA@.log y where y.tenant = t) then
635
+ return jsonb_build_object('level', p_level, 'tenant', t, 'rows', n, 'versions', m, 'done', false);
636
+ end if;
637
+ perform @SCHEMA@.reset_mark(t, p_level, c.author);
638
+ perform @SCHEMA@.signal(array['*']);
639
+ perform @SCHEMA@.acl_bump();
640
+ return jsonb_build_object('level', p_level, 'tenant', t, 'rows', n, 'versions', m, 'done', true);
641
+ when 'all' then
642
+ truncate @SCHEMA@.entity, @SCHEMA@.log, @SCHEMA@.secret;
643
+ perform @SCHEMA@.reset_mark('@SYSTEM@'::uuid, p_level, c.author);
644
+ perform @SCHEMA@.seed_system();
645
+ key := 'lts_' || encode(@PGCRYPTO@.gen_random_bytes(32), 'hex');
646
+ perform @SCHEMA@.bootstrap_service(key);
647
+ perform @SCHEMA@.signal(array['*', 'Class', 'session:*']);
648
+ return jsonb_build_object('level', p_level, 'serviceKey', key);
649
+ else
650
+ perform @SCHEMA@.lt_fail('invalid_data', format('уровень сброса %s: sessions, tenant или all', p_level));
651
+ end case;
652
+ return null;
653
+ end $$;
654
+
655
+ -- ========================= обслуживание =========================
656
+
657
+ -- Одна пачка прореживания журнала (§2.11): версии, которые не нужны действующей политике хранения
658
+ -- своего класса. Не трогаются версия 1, текущая и версии особых видов (create, delete, reclass,
659
+ -- rekey, restore, purge, reset, trim); история удалённого объекта, надгробие которого старше
660
+ -- последнего яруса со сроком, стирается целиком. Пачка — до 100 объектов после курсора (meta
661
+ -- строки ключа: участки по порядку id), не больше p_limit версий; объект, не вошедший целиком,
662
+ -- достаётся следующей пачке. Курсор дошёл до конца — сбрасывается (more = false).
663
+ create or replace function @SCHEMA@.thin_batch(p_now timestamptz, p_limit int) returns jsonb
664
+ language plpgsql volatile
665
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
666
+ as $$
667
+ declare
668
+ cur uuid;
669
+ ids uuid[];
670
+ vid uuid[];
671
+ vrev int[];
672
+ total int;
673
+ k int;
674
+ nxt uuid;
675
+ more boolean;
676
+ begin
677
+ select (s.meta->>'thin')::uuid into cur from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid for update;
678
+ -- умолчание установки прореживает журнал: verify() и после его снятия считает такие пропуски законными
679
+ if @SCHEMA@.history_default() is not null then
680
+ update @SCHEMA@.secret s set meta = s.meta || '{"default_policy": true}' where s.id = '@SYSTEM@'::uuid and not (s.meta ? 'default_policy');
681
+ end if;
682
+ with pol as (
683
+ select x.data->>'name' as cls, x.tenant as ct, @SCHEMA@.history_policy(x.data->'resolved') as p
684
+ from @SCHEMA@.entity x where x.class = 'Class'
685
+ )
686
+ select array_agg(q.id order by q.id) into ids from (
687
+ select distinct l.id from @SCHEMA@.log l
688
+ join pol on pol.p is not null and pol.cls = l.class and (pol.ct = l.tenant or pol.ct = '@SYSTEM@'::uuid)
689
+ where l.at < p_now - @SCHEMA@.history_iv(pol.p->>'all') and (cur is null or l.id > cur)
690
+ order by l.id limit 100) q;
691
+ if ids is null then
692
+ update @SCHEMA@.secret s set meta = s.meta - 'thin' where s.id = '@SYSTEM@'::uuid;
693
+ return jsonb_build_object('thinned', 0, 'more', false);
694
+ end if;
695
+ with pol as (
696
+ select x.data->>'name' as cls, x.tenant as ct, @SCHEMA@.history_policy(x.data->'resolved') as p
697
+ from @SCHEMA@.entity x where x.class = 'Class'
698
+ ), v as (
699
+ select l.id, l.rev, l.at, l.op, pol.p, coalesce(pol.p->>'tz', 'UTC') as tz,
700
+ max(l.rev) over (partition by l.id) as last_rev,
701
+ first_value(l.op) over (partition by l.id order by l.rev desc) as last_op,
702
+ first_value(l.at) over (partition by l.id order by l.rev desc) as last_at,
703
+ lead(l.at) over (partition by l.id order by l.rev) as next_at
704
+ from @SCHEMA@.log l
705
+ join pol on pol.p is not null and pol.cls = l.class and (pol.ct = l.tenant or pol.ct = '@SYSTEM@'::uuid)
706
+ where l.id = any (ids)
707
+ )
708
+ select array_agg(v.id order by v.id, v.rev), array_agg(v.rev order by v.id, v.rev) into vid, vrev
709
+ from v
710
+ where (v.last_op = 'delete' and timezone(v.tz, v.last_at) <= timezone(v.tz, p_now) - @SCHEMA@.history_horizon(v.p))
711
+ or (v.rev <> 1 and v.rev <> v.last_rev
712
+ and v.op not in ('create', 'delete', 'reclass', 'rekey', 'restore', 'purge', 'reset', 'trim')
713
+ and not @SCHEMA@.history_keep(v.p, timezone(v.tz, v.at), timezone(v.tz, p_now), timezone(v.tz, v.next_at)));
714
+ total := coalesce(cardinality(vid), 0);
715
+ k := least(total, p_limit);
716
+ if k > 0 then
717
+ delete from @SCHEMA@.log l using unnest(vid[1:k], vrev[1:k]) u(id, rev) where l.id = u.id and l.rev = u.rev;
718
+ end if;
719
+ if total > p_limit then
720
+ -- объект на границе пачки — снова в следующей
721
+ nxt := vid[p_limit + 1];
722
+ cur := (select x from unnest(ids) x where x < nxt order by x desc limit 1);
723
+ more := true;
724
+ elsif cardinality(ids) = 100 then
725
+ cur := ids[100];
726
+ more := true;
727
+ else
728
+ cur := null;
729
+ more := false;
730
+ end if;
731
+ update @SCHEMA@.secret s set meta = case when cur is null then s.meta - 'thin' else s.meta || jsonb_build_object('thin', cur) end
732
+ where s.id = '@SYSTEM@'::uuid;
733
+ return jsonb_build_object('thinned', k, 'more', more);
734
+ end $$;
735
+
736
+ -- Обслуживание (этап 6, п. 8): истёкшие и погашенные сессии и одноразовые коды, одна пачка
737
+ -- прореживания журнала, заполнение очереди сигналов. Вызывает системный администратор, роль с
738
+ -- правом letopis.maintain или администраторское подключение. p_now — момент «сейчас» для
739
+ -- прореживания, только при allow_reset (тесты): иначе одним вызовом можно было бы проредить всю историю.
740
+ create or replace function @SCHEMA@.maintain(p_now timestamptz default null, p_limit int default 10000) returns jsonb
741
+ language plpgsql volatile security definer
742
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
743
+ as $$
744
+ declare
745
+ ts timestamptz := clock_timestamp();
746
+ s int;
747
+ o int;
748
+ begin
749
+ if not @SCHEMA@.letopis_allowed('letopis.maintain') then
750
+ perform @SCHEMA@.lt_fail('acl_denied', 'нет права letopis.maintain');
751
+ end if;
752
+ if p_now is not null then
753
+ if not @ALLOW_RESET@ then
754
+ perform @SCHEMA@.lt_fail('reset_disabled', 'maintain(now) — только в установке с allow_reset');
755
+ end if;
756
+ ts := p_now;
757
+ end if;
758
+ with d as (delete from @SCHEMA@.secret x where x.kind = 'SESSION'
759
+ and (x.deleted_at is not null or x.expires_at <= clock_timestamp()) returning 1)
760
+ select count(*)::int into s from d;
761
+ with d as (delete from @SCHEMA@.secret x where x.kind = 'OTP'
762
+ and (x.deleted_at is not null or x.expires_at <= clock_timestamp()) returning 1)
763
+ select count(*)::int into o from d;
764
+ return jsonb_build_object('sessions', s, 'codes', o, 'queue', pg_notification_queue_usage())
765
+ || @SCHEMA@.thin_batch(ts, greatest(coalesce(p_limit, 10000), 1));
766
+ end $$;
767
+
768
+ -- ========================= verify =========================
769
+
770
+ -- Проверка цепочек хэшей (этап 1, п. 11; этап 6, п. 4): номера версий подряд с 1, prev_hash каждой
771
+ -- версии равен хэшу предыдущей, строка entity совпадает с последней версией журнала. Законные
772
+ -- разрывы: после записи trim (стык — по $trimmed и $hash), запись purge и reset — начало цепочки,
773
+ -- прореживание — пропуск после версии старше яруса all класса, у которого есть (или была) политика
774
+ -- хранения — своя или умолчание установки (метка default_policy в строке ключа). p_data — ещё и все строки: данные по проверке класса, концы (цель существует, её
775
+ -- арендатор — тот же или System, класс принимается концом), арендатор и владелец существуют, id
776
+ -- класса с ключом — v5 от ключа. Возвращает находки и якорь — хэш всех голов цепочек. Только
777
+ -- системный администратор (право letopis.verify) или администраторское подключение.
778
+ create or replace function @SCHEMA@.verify(p_data boolean default false) returns jsonb
779
+ language plpgsql stable security definer
780
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
781
+ as $$
782
+ declare
783
+ issues jsonb;
784
+ data_issues jsonb := '[]'::jsonb;
785
+ anchor text;
786
+ ts timestamptz := clock_timestamp();
787
+ begin
788
+ if not @SCHEMA@.letopis_allowed('letopis.verify') then
789
+ perform @SCHEMA@.lt_fail('acl_denied', 'verify доступен только системному администратору');
790
+ end if;
791
+ with pol as (
792
+ -- классы с политикой хранения сейчас или в истории строки Class; ярус all — действующий или сутки
793
+ select x.data->>'name' as cls, x.tenant as ct,
794
+ coalesce(@SCHEMA@.history_iv(@SCHEMA@.history_policy(x.data->'resolved')->>'all'), interval '1 day') as all_iv
795
+ from @SCHEMA@.entity x
796
+ where x.class = 'Class'
797
+ and (@SCHEMA@.history_policy(x.data->'resolved') is not null
798
+ or exists (select 1 from @SCHEMA@.log h where h.id = x.id and jsonb_typeof(h.data->'resolved'->'history') = 'object')
799
+ or (x.data->'resolved'->'history' = 'true'::jsonb and x.data->>'name' not in ('Class', 'Account', 'member', 'Resource', 'rule')
800
+ and exists (select 1 from @SCHEMA@.secret k where k.id = '@SYSTEM@'::uuid and k.meta ? 'default_policy')))
801
+ ), chain as (
802
+ select l.id, l.rev, l.class, l.tenant, l.prev_hash,
803
+ lag(l.rev) over w as prev_rev, lag(l.hash) over w as prev_h, lag(l.op) over w as prev_op,
804
+ lag(l.data) over w as prev_data, lag(l.at) over w as prev_at
805
+ from @SCHEMA@.log l
806
+ window w as (partition by l.id order by l.rev)
807
+ ), exp as (
808
+ select chain.*,
809
+ case when prev_op = 'trim' then (prev_data->>'$trimmed')::int + 1 else prev_rev + 1 end as want_rev,
810
+ case when prev_op = 'trim' then decode(prev_data->>'$hash', 'hex') else prev_h end as want_hash
811
+ from chain where prev_rev is not null
812
+ ), found as (
813
+ select jsonb_build_object('id', id, 'rev', rev, 'problem', 'цепочка начинается не с версии 1') as f
814
+ from chain where prev_rev is null and (rev <> 1 or prev_hash is not null)
815
+ union all
816
+ select jsonb_build_object('id', id, 'rev', prev_rev, 'problem', 'хэш версии не совпадает с prev_hash следующей')
817
+ from exp where rev = want_rev and prev_hash is distinct from want_hash
818
+ union all
819
+ select jsonb_build_object('id', id, 'rev', want_rev, 'problem', 'разрыв: нет версий ' || want_rev || '…' || (rev - 1))
820
+ from exp
821
+ where rev > want_rev
822
+ and not exists (select 1 from pol where pol.cls = exp.class and (pol.ct = exp.tenant or pol.ct = '@SYSTEM@'::uuid)
823
+ and exp.prev_at < ts - pol.all_iv)
824
+ union all
825
+ select jsonb_build_object('id', id, 'rev', rev, 'problem', 'номер версии меньше ожидаемого после обрезки')
826
+ from exp where rev < want_rev
827
+ union all
828
+ select jsonb_build_object('id', e.id, 'rev', e.rev, 'problem', 'строка entity не совпадает с последней версией журнала')
829
+ from @SCHEMA@.entity e
830
+ left join lateral (select l.hash, l.rev from @SCHEMA@.log l where l.id = e.id order by l.rev desc limit 1) l on true
831
+ where coalesce((@SCHEMA@.class_row(e.class, e.tenant)).data->'resolved'->>'history', 'true') <> 'false'
832
+ and (l.hash is distinct from e.hash or l.rev is distinct from e.rev)
833
+ )
834
+ select coalesce(jsonb_agg(f), '[]'::jsonb) into issues from found;
835
+ if coalesce(p_data, false) then
836
+ with cls as (
837
+ select x.tenant as ct, x.data->>'name' as name, x.data->'resolved' as res, x.validator
838
+ from @SCHEMA@.entity x where x.class = 'Class'
839
+ ), rows_ as (
840
+ select e.*, k.res, k.validator as vld
841
+ from @SCHEMA@.entity e
842
+ left join lateral (select cls.res, cls.validator from cls where cls.name = e.class and cls.ct in (e.tenant, '@SYSTEM@'::uuid)
843
+ order by cls.ct = '@SYSTEM@'::uuid limit 1) k on true
844
+ ), found as (
845
+ select jsonb_build_object('id', r.id, 'class', r.class, 'problem', 'класс строки не найден или абстрактный') as f
846
+ from rows_ r where r.class <> 'Class' and (r.res is null or coalesce((r.res->>'abstract')::boolean, false))
847
+ union all
848
+ select jsonb_build_object('id', r.id, 'class', r.class, 'problem', 'данные не проходят описание класса',
849
+ 'issues', @SCHEMA@.validate_data(r.res->'rules', r.vld, r.res->'vars', r.data))
850
+ from rows_ r where r.class <> 'Class' and r.res is not null
851
+ and @SCHEMA@.validate_data(r.res->'rules', r.vld, r.res->'vars', r.data) <> '[]'::jsonb
852
+ union all
853
+ select jsonb_build_object('id', r.id, 'class', r.class, 'problem', 'id не равен v5 от ключа класса')
854
+ from rows_ r where r.res is not null and jsonb_array_length(coalesce(r.res->'key', '[]'::jsonb)) > 0
855
+ and @SCHEMA@.key_id(r.res, r.class, r.tenant, r.data, r.links) is distinct from r.id
856
+ union all
857
+ select jsonb_build_object('id', r.id, 'class', r.class, 'problem', 'арендатор строки не существует', 'tenant', r.tenant)
858
+ from rows_ r
859
+ where r.tenant <> '@SYSTEM@'::uuid
860
+ and not exists (select 1 from @SCHEMA@.entity a where a.id = r.tenant and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid)
861
+ union all
862
+ select jsonb_build_object('id', r.id, 'class', r.class, 'problem', 'владелец строки не существует', 'owner', r.owner)
863
+ from rows_ r
864
+ where r.owner <> r.tenant
865
+ and not exists (select 1 from @SCHEMA@.entity a where a.id = r.owner and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid)
866
+ union all
867
+ select jsonb_build_object('id', p.id, 'class', p.class, 'problem', 'цель конца не найдена', 'role', p.role, 'target', p.tid)
868
+ from (select r.id, r.class, r.tenant, r.res, k.key as role,
869
+ (case jsonb_typeof(k.value) when 'array' then a.value else k.value end #>> '{}')::uuid as tid
870
+ from rows_ r
871
+ cross join lateral jsonb_each(r.links) k
872
+ left join lateral jsonb_array_elements(case jsonb_typeof(k.value) when 'array' then k.value else '[]'::jsonb end) a on true
873
+ where jsonb_typeof(k.value) = 'string' or a.value is not null) p
874
+ left join @SCHEMA@.entity t on t.id = p.tid
875
+ where t.id is null
876
+ or t.tenant not in (p.tenant, '@SYSTEM@'::uuid)
877
+ or (p.res is not null and not (
878
+ @SCHEMA@.class_set(p.res->'ends'->p.role->'class') ? t.class
879
+ or @SCHEMA@.class_set(p.res->'ends'->p.role->'class') ?| coalesce(
880
+ (select array(select jsonb_array_elements_text(k2.data->'resolved'->'ancestors'))
881
+ from @SCHEMA@.entity k2
882
+ where k2.class = 'Class' and k2.data->>'name' = t.class and k2.tenant in (t.tenant, '@SYSTEM@'::uuid)
883
+ order by k2.tenant = '@SYSTEM@'::uuid limit 1), '{}')))
884
+ )
885
+ select coalesce(jsonb_agg(f), '[]'::jsonb) into data_issues from found;
886
+ end if;
887
+ select encode(sha256(coalesce(string_agg(e.hash, ''::bytea order by e.id), ''::bytea)), 'hex') into anchor
888
+ from @SCHEMA@.entity e;
889
+ issues := issues || data_issues;
890
+ return jsonb_build_object('ok', issues = '[]'::jsonb, 'issues', issues, 'anchor', anchor);
891
+ end $$;
892
+
893
+ -- ========================= подписка =========================
894
+
895
+ -- Пачка событий подписки (этап 6, п. 5): версии журнала после курсора (tx, seq) ниже горизонта
896
+ -- завершённых транзакций (pg_snapshot_xmin) — их уже ничто не обгонит, поэтому без потерь и повторов.
897
+ -- Выполняется с правами вызывающего: RLS журнала показывает только своё. p_at — время последнего
898
+ -- события курсора: старше яруса all какого-либо класса подписки — cursor_expired (события могли
899
+ -- проредиться). p_classes null — все классы. p_snap — снимок момента подписки «now»: транзакции,
900
+ -- видимые в нём (зафиксированные до подписки), не отдаются.
901
+ create or replace function @SCHEMA@.watch_read(p_tx xid8, p_seq bigint, p_at timestamptz, p_classes text[], p_limit int,
902
+ p_snap pg_snapshot default null)
903
+ returns setof @SCHEMA@.log
904
+ language plpgsql stable
905
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
906
+ as $$
907
+ declare
908
+ horizon xid8 := pg_snapshot_xmin(pg_current_snapshot());
909
+ lim interval;
910
+ begin
911
+ if p_at is not null then
912
+ select min(@SCHEMA@.history_iv(p.p->>'all')) into lim
913
+ from @SCHEMA@.entity x cross join lateral (select @SCHEMA@.history_policy(x.data->'resolved') as p) p
914
+ where x.class = 'Class' and p.p is not null and (p_classes is null or x.data->>'name' = any (p_classes));
915
+ if lim is not null and p_at < clock_timestamp() - lim then
916
+ raise exception using errcode = 'LT016',
917
+ message = 'letopis: cursor_expired: курсор подписки старше яруса all — события могли проредиться, перечитайте текущее состояние';
918
+ end if;
919
+ end if;
920
+ return query select l.* from @SCHEMA@.log l
921
+ where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon and (p_classes is null or l.class = any (p_classes))
922
+ and (p_snap is null or not pg_visible_in_snapshot(l.tx, p_snap))
923
+ order by l.tx, l.seq limit p_limit;
924
+ end $$;
925
+
926
+ -- Точка подписки «с этого момента»: курсор — горизонт завершённых транзакций, снимок — чтобы не
927
+ -- отдавать транзакции, зафиксированные до подписки, но ещё не ушедшие за горизонт.
928
+ drop function if exists @SCHEMA@.watch_head();
929
+ create or replace function @SCHEMA@.watch_head(out tx xid8, out seq bigint, out snap pg_snapshot)
930
+ language sql stable
931
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
932
+ as $$
933
+ select pg_snapshot_xmin(s), 0::bigint, s from pg_current_snapshot() s
934
+ $$;
935
+ -- до исправления подписки «now»: без снимка
936
+ drop function if exists @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int);
937
+
938
+ do $$
939
+ declare f text;
940
+ begin
941
+ foreach f in array array[
942
+ 'history_default()', 'history_policy(jsonb)', 'history_iv(text)', 'history_horizon(jsonb)',
943
+ 'history_keep(jsonb, timestamp, timestamp, timestamp)', 'admin_role()', 'letopis_allowed(text)', 'session_expires()',
944
+ 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
945
+ 'rekey(uuid, jsonb, jsonb)', 'rekey_class(text, jsonb)', 'account_purge(uuid)', 'reset_mark(uuid, text, uuid)',
946
+ 'reset(text, text, uuid, int)', 'thin_batch(timestamptz, int)', 'maintain(timestamptz, int)', 'verify(boolean)',
947
+ 'watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot)', 'watch_head()'] loop
948
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
949
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
950
+ end loop;
951
+ end $$;
952
+ -- приложению: функции владельца этапа 6 (проверяют права сами), подписка (под RLS) и чистые помощники политики
953
+ grant execute on function @SCHEMA@.restore(uuid), @SCHEMA@.purge(uuid, boolean), @SCHEMA@.trim_history(uuid[], text[], timestamptz),
954
+ @SCHEMA@.rekey(uuid, jsonb, jsonb), @SCHEMA@.rekey_class(text, jsonb), @SCHEMA@.account_purge(uuid),
955
+ @SCHEMA@.reset(text, text, uuid, int), @SCHEMA@.maintain(timestamptz, int), @SCHEMA@.verify(boolean),
956
+ @SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot), @SCHEMA@.watch_head(),
957
+ @SCHEMA@.history_default(), @SCHEMA@.history_policy(jsonb), @SCHEMA@.history_iv(text) to @APP@;