letopis 1.4.2 → 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.
- package/AGENT-CHEATSHEET.md +329 -281
- package/CHANGELOG.md +418 -0
- package/README.md +572 -1053
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -184
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +18 -41
- package/dist/chain.js +165 -203
- package/dist/cli.js +45 -90
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -70
- package/dist/index.js +189 -122
- package/dist/internal.d.ts +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +207 -181
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +168 -214
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +20 -29
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +7 -1
- package/dist/sql.js +101 -85
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +40 -17
- package/dist/tx.d.ts +11 -1
- package/dist/tx.js +32 -11
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +15 -90
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +180 -74
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +5 -16
- package/dist/watch.js +23 -77
- package/dist/write.d.ts +9 -16
- package/dist/write.js +102 -185
- package/llms.txt +22 -25
- package/package.json +2 -9
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +23 -27
- package/sql/90-time.sql +77 -944
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/sql/90-time.sql
CHANGED
|
@@ -3,83 +3,11 @@
|
|
|
3
3
|
-- Функции владельца проверяют всё сами, сами пишут журнал и сами посылают сигнал (§2.7):
|
|
4
4
|
-- restore() — последний снимок удалённого объекта новой версией (вид restore), с проверкой
|
|
5
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
6
|
-- account_purge() — удаление аккаунта с данными его арендатора (предохранители 0.21);
|
|
13
|
-
--
|
|
14
|
-
-- подтверждение именем схемы); первая запись журнала после сброса — reset;
|
|
15
|
-
-- maintain() — истёкшие сессии и коды, одна пачка прореживания журнала по политике хранения;
|
|
16
|
-
-- verify() — цепочки хэшей с законными разрывами (purge, trim, reset, прореживание),
|
|
17
|
-
-- якорь, а с p_data — данные, концы, арендаторы, владельцы и id всех строк.
|
|
7
|
+
-- maintain() — истёкшие сессии и коды, строки-замки удалённых семейств.
|
|
18
8
|
-- У id, у которого в журнале есть хоть одна запись, номер версии 1 всегда занят (этап 6, п. 3):
|
|
19
9
|
-- на нём держится режим existing: 'error' загрузчика.
|
|
20
10
|
|
|
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
11
|
-- Действующая роль сеанса, под которой вызывающий работает вне функций владельца: роль SET ROLE (и параметра запуска
|
|
84
12
|
-- role), иначе роль входа. Внутри функции владельца current_user — владелец, а session_user — роль входа, даже если
|
|
85
13
|
-- поверх неё поставлена роль приложения: привилегированный вход с ролью приложения выглядел бы администратором
|
|
@@ -89,8 +17,8 @@ language sql stable
|
|
|
89
17
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
90
18
|
return coalesce(nullif(current_setting('role'), 'none'), session_user)::name;
|
|
91
19
|
|
|
92
|
-
-- Роль подключения — администраторская (суперпользователь или может стать владельцем):
|
|
93
|
-
--
|
|
20
|
+
-- Роль подключения — администраторская (суперпользователь или может стать владельцем): maintain доступен ей и без
|
|
21
|
+
-- сессии (CLI, обслуживание по расписанию).
|
|
94
22
|
create or replace function @SCHEMA@.admin_role() returns boolean
|
|
95
23
|
language sql stable
|
|
96
24
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
@@ -144,7 +72,7 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
|
144
72
|
as $$
|
|
145
73
|
begin
|
|
146
74
|
if not @SCHEMA@.letopis_allowed('letopis.signals') then
|
|
147
|
-
perform @SCHEMA@.lt_fail('
|
|
75
|
+
perform @SCHEMA@.lt_fail('sql-time.signals_denied');
|
|
148
76
|
end if;
|
|
149
77
|
return (select encode(@PGCRYPTO@.hmac(convert_to('letopis:signals', 'UTF8'), s.material, 'sha256'), 'hex')
|
|
150
78
|
from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY');
|
|
@@ -158,10 +86,10 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
|
158
86
|
return (select encode(sha256(convert_to(coalesce(string_agg(e.id::text || ':' || e.rev::text, ',' order by e.id), ''), 'UTF8')), 'hex')
|
|
159
87
|
from @SCHEMA@.entity e where e.class = 'Class' and e.tenant in (@SCHEMA@.ctx_tenant(), '@SYSTEM@'::uuid));
|
|
160
88
|
|
|
161
|
-
-- ========================= restore
|
|
89
|
+
-- ========================= restore =========================
|
|
162
90
|
|
|
163
91
|
-- Последняя запись журнала id под блокировкой, если она видна актору в его арендаторе: параллельные
|
|
164
|
-
-- restore
|
|
92
|
+
-- restore и воскрешение (trg_entity_bi берёт ту же блокировку) упорядочены.
|
|
165
93
|
create or replace function @SCHEMA@.last_version_visible(p_id uuid, p_tenant uuid, p_acl jsonb) returns @SCHEMA@.log
|
|
166
94
|
language plpgsql volatile
|
|
167
95
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
@@ -172,9 +100,9 @@ begin
|
|
|
172
100
|
-- арендатор id неизменен: блокируется только запись своего арендатора, чужую вызов не задерживает
|
|
173
101
|
-- и по ожиданию не обнаружит (итоговое ревью безопасности)
|
|
174
102
|
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;
|
|
175
|
-
if l.id is null or l.tenant <> p_tenant
|
|
103
|
+
if l.id is null or l.tenant <> p_tenant
|
|
176
104
|
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
|
|
177
|
-
perform @SCHEMA@.lt_fail('target_not_found'
|
|
105
|
+
perform @SCHEMA@.lt_fail('sql-time.target_not_found');
|
|
178
106
|
end if;
|
|
179
107
|
return l;
|
|
180
108
|
end $$;
|
|
@@ -202,44 +130,41 @@ declare
|
|
|
202
130
|
begin
|
|
203
131
|
select * into c from @SCHEMA@.ctx();
|
|
204
132
|
if c.account is null then
|
|
205
|
-
perform @SCHEMA@.lt_fail('no_session'
|
|
133
|
+
perform @SCHEMA@.lt_fail('sql-time.no_session');
|
|
206
134
|
end if;
|
|
207
135
|
rd := @SCHEMA@.acl_actor('READ');
|
|
208
136
|
l := @SCHEMA@.last_version_visible(p_id, c.tenant, rd);
|
|
209
137
|
if l.op <> 'delete' or exists (select 1 from @SCHEMA@.entity e where e.id = p_id) then
|
|
210
|
-
perform @SCHEMA@.lt_fail('
|
|
138
|
+
perform @SCHEMA@.lt_fail('sql-time.restore_not_deleted');
|
|
211
139
|
end if;
|
|
212
140
|
if l.class = 'Class' then
|
|
213
|
-
perform @SCHEMA@.lt_fail('
|
|
141
|
+
perform @SCHEMA@.lt_fail('sql-time.restore_class_row');
|
|
214
142
|
end if;
|
|
215
143
|
cls := @SCHEMA@.class_row(l.class, l.tenant);
|
|
216
144
|
if cls.id is null then
|
|
217
|
-
perform @SCHEMA@.lt_fail('
|
|
145
|
+
perform @SCHEMA@.lt_fail('sql-time.class_not_found', jsonb_build_object('cls', l.class));
|
|
218
146
|
end if;
|
|
219
147
|
perform @SCHEMA@.family_lock(cls, false);
|
|
220
148
|
cls := @SCHEMA@.class_row(l.class, l.tenant);
|
|
221
149
|
if cls.id is null then
|
|
222
|
-
perform @SCHEMA@.lt_fail('
|
|
150
|
+
perform @SCHEMA@.lt_fail('sql-time.class_deleted', jsonb_build_object('cls', l.class));
|
|
223
151
|
end if;
|
|
224
152
|
res := cls.data->'resolved';
|
|
225
153
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
226
|
-
perform @SCHEMA@.lt_fail('
|
|
227
|
-
end if;
|
|
228
|
-
if res ? 'derivedBy' then
|
|
229
|
-
perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', l.class, res->>'derivedBy'));
|
|
154
|
+
perform @SCHEMA@.lt_fail('sql-time.class_abstract', jsonb_build_object('cls', l.class));
|
|
230
155
|
end if;
|
|
231
156
|
d := @SCHEMA@.apply_defaults(res->'schema', l.data);
|
|
232
157
|
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
|
|
233
158
|
if bad <> '[]'::jsonb then
|
|
234
|
-
perform @SCHEMA@.lt_fail('invalid_data',
|
|
159
|
+
perform @SCHEMA@.lt_fail('sql-time.invalid_data', jsonb_build_object('cls', l.class), jsonb_build_object('issues', bad));
|
|
235
160
|
end if;
|
|
236
161
|
select * into lk from @SCHEMA@.check_links(res, l.links, l.tenant, true, rd);
|
|
237
|
-
if lk.
|
|
238
|
-
perform @SCHEMA@.lt_fail(lk.
|
|
162
|
+
if lk.key is not null then
|
|
163
|
+
perform @SCHEMA@.lt_fail(lk.key, lk.params);
|
|
239
164
|
end if;
|
|
240
165
|
kid := @SCHEMA@.key_id(res, l.class, l.tenant, d, lk.links);
|
|
241
166
|
if kid is not null and kid <> p_id then
|
|
242
|
-
perform @SCHEMA@.lt_fail('
|
|
167
|
+
perform @SCHEMA@.lt_fail('sql-time.restore_key_changed', jsonb_build_object('cls', l.class), jsonb_build_object('expected', kid));
|
|
243
168
|
end if;
|
|
244
169
|
-- владелец — существующий аккаунт или арендатор
|
|
245
170
|
own := l.owner;
|
|
@@ -247,16 +172,13 @@ begin
|
|
|
247
172
|
own := l.tenant;
|
|
248
173
|
end if;
|
|
249
174
|
if not @SCHEMA@.acl_row_ok(@SCHEMA@.acl_plan('WRITE'), l.class, p_id, own, l.tenant, l.tags, d, lk.links) then
|
|
250
|
-
perform @SCHEMA@.lt_fail('
|
|
175
|
+
perform @SCHEMA@.lt_fail('sql-time.no_write_row', jsonb_build_object('cls', l.class));
|
|
251
176
|
end if;
|
|
252
177
|
ts := greatest(@SCHEMA@.now_ts(), l.at + interval '1 microsecond');
|
|
253
|
-
insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason,
|
|
178
|
+
insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, validator)
|
|
254
179
|
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',
|
|
255
|
-
nullif(current_setting('letopis.reason', true), ''), null
|
|
180
|
+
nullif(current_setting('letopis.reason', true), ''), null)
|
|
256
181
|
returning * into n;
|
|
257
|
-
-- агрегатные поля восстановленной цели триггер после вставки посчитал из живых источников в эту же версию, а не
|
|
258
|
-
-- взял из надгробия (план 1.1, §2.4): в журнал идёт строка таблицы
|
|
259
|
-
select x.* into n from @SCHEMA@.entity x where x.id = p_id;
|
|
260
182
|
perform @SCHEMA@.log_version(n);
|
|
261
183
|
if l.class in ('rule', 'Resource', 'member', 'Account') then
|
|
262
184
|
perform @SCHEMA@.acl_bump();
|
|
@@ -264,360 +186,6 @@ begin
|
|
|
264
186
|
return p_id;
|
|
265
187
|
end $$;
|
|
266
188
|
|
|
267
|
-
-- Стирание истории удалённого объекта (этап 6, п. 3): без p_confirm — превью (число версий), с ним —
|
|
268
|
-
-- все версии удаляются, а на место версии 1 встаёт запись purge с числом стёртых версий и хэшем
|
|
269
|
-
-- последней. Право DELETE на класс по последнему снимку. История системных классов — аудит прав и
|
|
270
|
-
-- схемы: её не стирают.
|
|
271
|
-
-- Проектор источника ещё не прочёл одну из версий p_id арендатора p_tenant с номером до p_rev включительно (null — все
|
|
272
|
-
-- версии): курсор не прошёл её позицию (tx, seq), и снимок перестройки её не учёл (ревью безопасности №3, №5 — purge;
|
|
273
|
-
-- хвост ревью №3 — обрезка истории). Проверяется каждая версия (хвост ревью доработок после 1.2.0): у более поздней
|
|
274
|
-
-- версии бывает меньший xid, а снимок перестройки учитывает транзакции, зафиксированные до него, — непрочитанной может
|
|
275
|
-
-- оказаться и версия между самой поздней по (tx, seq) и последней. Проектор касается арендатора, у которого есть его
|
|
276
|
-
-- производный класс (derivedBy — имя проектора): брошенный проектор с удалёнными производными классами не держит, — и
|
|
277
|
-
-- строки, у которой хоть одна из этих версий — в классах его источника: уход из семейства источников сменой класса он
|
|
278
|
-
-- тоже читает. Курсор «tx:seq~снимок»; чекпоинт без from (до 1.2.0) касается всех классов. Записи purge, trim и reset —
|
|
279
|
-
-- не версии.
|
|
280
|
-
create or replace function @SCHEMA@.projector_unread(p_tenant uuid, p_id uuid, p_rev int) returns boolean
|
|
281
|
-
language sql stable
|
|
282
|
-
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
283
|
-
return exists (select 1 from @SCHEMA@.secret s
|
|
284
|
-
cross join lateral (select split_part(s.meta->>'cursor', '~', 1) as head,
|
|
285
|
-
nullif(split_part(s.meta->>'cursor', '~', 2), '') as snap) k
|
|
286
|
-
where s.kind = 'PROJECTOR' and s.account = '@SYSTEM@'::uuid and s.deleted_at is null
|
|
287
|
-
and coalesce(s.meta->>'cursor', '') <> ''
|
|
288
|
-
and exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.tenant = p_tenant
|
|
289
|
-
and x.data->'resolved'->>'derivedBy' = s.ident)
|
|
290
|
-
and (not s.meta ? 'from'
|
|
291
|
-
or exists (select 1 from (select distinct v.class from @SCHEMA@.log v
|
|
292
|
-
where v.id = p_id and (p_rev is null or v.rev <= p_rev)
|
|
293
|
-
and v.op not in ('purge', 'trim', 'reset')) c
|
|
294
|
-
where coalesce((@SCHEMA@.class_row(c.class, p_tenant)).data->'resolved'->'ancestors', jsonb_build_array(c.class))
|
|
295
|
-
?| array(select jsonb_array_elements_text(s.meta->'from'))))
|
|
296
|
-
and exists (select 1 from @SCHEMA@.log v
|
|
297
|
-
where v.id = p_id and (p_rev is null or v.rev <= p_rev) and v.op not in ('purge', 'trim', 'reset')
|
|
298
|
-
and (v.tx, v.seq) > (split_part(k.head, ':', 1)::xid8, split_part(k.head, ':', 2)::bigint)
|
|
299
|
-
and not coalesce(pg_visible_in_snapshot(v.tx, k.snap::pg_snapshot), false)));
|
|
300
|
-
|
|
301
|
-
-- до 1.4 проверялась одна позиция: надгробие у purge, самая поздняя по (tx, seq) версия у стыка обрезки
|
|
302
|
-
drop function if exists @SCHEMA@.projector_behind(uuid, text[], xid8, bigint);
|
|
303
|
-
|
|
304
|
-
create or replace function @SCHEMA@.purge(p_id uuid, p_confirm boolean default false) returns jsonb
|
|
305
|
-
language plpgsql volatile security definer
|
|
306
|
-
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
307
|
-
as $$
|
|
308
|
-
declare
|
|
309
|
-
c record;
|
|
310
|
-
l @SCHEMA@.log;
|
|
311
|
-
n int;
|
|
312
|
-
begin
|
|
313
|
-
select * into c from @SCHEMA@.ctx();
|
|
314
|
-
if c.account is null then
|
|
315
|
-
perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
|
|
316
|
-
end if;
|
|
317
|
-
l := @SCHEMA@.last_version_visible(p_id, c.tenant, @SCHEMA@.acl_actor('READ'));
|
|
318
|
-
if l.op <> 'delete' or exists (select 1 from @SCHEMA@.entity e where e.id = p_id) then
|
|
319
|
-
perform @SCHEMA@.lt_fail('not_deleted', 'объект не удалён: purge — только для удалённых, сначала delete()');
|
|
320
|
-
end if;
|
|
321
|
-
if l.class in ('Class', 'Account', 'member', 'Resource', 'rule') then
|
|
322
|
-
perform @SCHEMA@.lt_fail('invalid_class', 'история системных классов — аудит прав и схемы: purge недоступен');
|
|
323
|
-
end if;
|
|
324
|
-
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
|
|
325
|
-
perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s', l.class));
|
|
326
|
-
end if;
|
|
327
|
-
-- проектор источника, который ещё не прочёл какую-то версию строки (в том числе надгробие этой транзакции), об
|
|
328
|
-
-- удалении не узнал бы — вклад строки остался бы в итогах навсегда (ревью безопасности №3, №5)
|
|
329
|
-
if @SCHEMA@.projector_unread(l.tenant, p_id, null) then
|
|
330
|
-
perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл удаление: purge — после его run()');
|
|
331
|
-
end if;
|
|
332
|
-
select count(*)::int into n from @SCHEMA@.log x where x.id = p_id;
|
|
333
|
-
if not coalesce(p_confirm, false) then
|
|
334
|
-
return jsonb_build_object('id', p_id, 'class', l.class, 'versions', n, 'purged', false);
|
|
335
|
-
end if;
|
|
336
|
-
delete from @SCHEMA@.log x where x.id = p_id;
|
|
337
|
-
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
338
|
-
values (p_id, 1, l.class, l.cv, l.tenant, l.tenant, '{}', jsonb_build_object('$purged', n, '$hash', encode(l.hash, 'hex')), '{}',
|
|
339
|
-
greatest(@SCHEMA@.now_ts(), l.at + interval '1 microsecond'), coalesce(c.author, '@SYSTEM@'::uuid), c.agent, 'purge',
|
|
340
|
-
nullif(current_setting('letopis.reason', true), ''), null, null);
|
|
341
|
-
-- воскрешение, зафиксированное, пока purge ждал блокировку, — отказ (снимок нового оператора его видит)
|
|
342
|
-
if exists (select 1 from @SCHEMA@.entity e where e.id = p_id) then
|
|
343
|
-
perform @SCHEMA@.lt_fail('not_deleted', 'объект восстановлен во время purge');
|
|
344
|
-
end if;
|
|
345
|
-
perform @SCHEMA@.signal(array[l.class], array[l.tenant]);
|
|
346
|
-
return jsonb_build_object('id', p_id, 'class', l.class, 'versions', n, 'purged', true);
|
|
347
|
-
end $$;
|
|
348
|
-
|
|
349
|
-
-- Обрезка истории (этап 6, п. 3; замена compact 0.21): версии строже момента p_before удаляются,
|
|
350
|
-
-- текущая — никогда. Время версий одного id монотонно, поэтому удаляется начало истории — и всегда
|
|
351
|
-
-- версия 1: на её место встаёт запись trim со временем прежней версии 1 (срез на момент знает, когда
|
|
352
|
-
-- объект появился), номером последней стёртой версии ($trimmed) и её хэшем ($hash) — по ним verify()
|
|
353
|
-
-- проверяет стык. Цели — p_ids или все объекты классов p_classes арендатора; право DELETE на каждую
|
|
354
|
-
-- (по последней версии). Возвращает число стёртых версий.
|
|
355
|
-
create or replace function @SCHEMA@.trim_history(p_ids uuid[], p_classes text[], p_before timestamptz) returns int
|
|
356
|
-
language plpgsql volatile security definer
|
|
357
|
-
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
358
|
-
as $$
|
|
359
|
-
declare
|
|
360
|
-
c record;
|
|
361
|
-
rd jsonb;
|
|
362
|
-
dplan jsonb;
|
|
363
|
-
r @SCHEMA@.log;
|
|
364
|
-
first @SCHEMA@.log;
|
|
365
|
-
nxt record;
|
|
366
|
-
j int;
|
|
367
|
-
h bytea;
|
|
368
|
-
k int;
|
|
369
|
-
total int := 0;
|
|
370
|
-
classes text[] := '{}';
|
|
371
|
-
begin
|
|
372
|
-
select * into c from @SCHEMA@.ctx();
|
|
373
|
-
if c.account is null then
|
|
374
|
-
perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
|
|
375
|
-
end if;
|
|
376
|
-
if p_before is null or (p_ids is null and p_classes is null) then
|
|
377
|
-
perform @SCHEMA@.lt_fail('invalid_data', 'обрезка истории: нужны цели и момент');
|
|
378
|
-
end if;
|
|
379
|
-
rd := @SCHEMA@.acl_actor('READ');
|
|
380
|
-
dplan := @SCHEMA@.acl_plan('DELETE');
|
|
381
|
-
if p_ids is not null then
|
|
382
|
-
-- явные цели: каждая обязана найтись
|
|
383
|
-
for r in select distinct on (u.x) l.* from unnest(p_ids) u(x)
|
|
384
|
-
left join lateral (select * from @SCHEMA@.log x where x.id = u.x order by x.rev desc limit 1) l on true
|
|
385
|
-
order by u.x loop
|
|
386
|
-
if r.id is null or r.tenant <> c.tenant or r.op in ('purge', 'reset')
|
|
387
|
-
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
|
|
388
|
-
perform @SCHEMA@.lt_fail('target_not_found', 'цель не найдена');
|
|
389
|
-
end if;
|
|
390
|
-
end loop;
|
|
391
|
-
end if;
|
|
392
|
-
-- по классам: последняя версия id — любого класса, класс проверяется у неё (план 2.0, 7.1 №1)
|
|
393
|
-
for r in select distinct on (l.id) l.* from @SCHEMA@.log l
|
|
394
|
-
where l.tenant = c.tenant and (case when p_ids is not null then l.id = any (p_ids)
|
|
395
|
-
else l.id in (select x.id from @SCHEMA@.log x where x.tenant = c.tenant and x.class = any (p_classes)) end)
|
|
396
|
-
order by l.id, l.rev desc loop
|
|
397
|
-
continue when p_ids is null and not (r.class = any (p_classes));
|
|
398
|
-
continue when r.op in ('purge', 'reset')
|
|
399
|
-
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');
|
|
400
|
-
if r.class in ('Class', 'Account', 'member', 'Resource', 'rule') then
|
|
401
|
-
perform @SCHEMA@.lt_fail('invalid_class', 'история системных классов — аудит прав и схемы: обрезка недоступна');
|
|
402
|
-
end if;
|
|
403
|
-
if not @SCHEMA@.acl_row_ok(dplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
|
|
404
|
-
perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s', r.class));
|
|
405
|
-
end if;
|
|
406
|
-
-- версии id под блокировкой: параллельная обрезка и прореживание ждут
|
|
407
|
-
perform 1 from @SCHEMA@.log x where x.id = r.id order by x.rev for update;
|
|
408
|
-
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;
|
|
409
|
-
continue when j is null;
|
|
410
|
-
select x.* into first from @SCHEMA@.log x where x.id = r.id order by x.rev limit 1;
|
|
411
|
-
-- до момента — только сама запись trim: стирать нечего
|
|
412
|
-
continue when j = 1 and first.op = 'trim';
|
|
413
|
-
-- проектор источника, который ещё не прочёл стираемые версии или первую оставшуюся: читая её, он возьмёт прошлую
|
|
414
|
-
-- версию для before, а её уже не будет — свёртка прибавила бы новую, не вычитая прежнюю (хвост ревью безопасности
|
|
415
|
-
-- №3); каждая версия до первой оставшейся — у более поздней версии бывает меньший xid
|
|
416
|
-
select x.rev into nxt from @SCHEMA@.log x where x.id = r.id and x.rev > j order by x.rev limit 1;
|
|
417
|
-
if @SCHEMA@.projector_unread(r.tenant, r.id, nxt.rev) then
|
|
418
|
-
perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл версии: trimHistory — после его run()');
|
|
419
|
-
end if;
|
|
420
|
-
select x.hash into h from @SCHEMA@.log x where x.id = r.id and x.rev = j;
|
|
421
|
-
with d as (delete from @SCHEMA@.log x where x.id = r.id and x.rev <= j returning 1)
|
|
422
|
-
select count(*)::int into k from d;
|
|
423
|
-
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
424
|
-
values (r.id, 1, r.class, r.cv, r.tenant, r.tenant, '{}', jsonb_build_object('$trimmed', j, '$hash', encode(h, 'hex')), '{}',
|
|
425
|
-
first.at, coalesce(c.author, '@SYSTEM@'::uuid), c.agent, 'trim', nullif(current_setting('letopis.reason', true), ''), null, null);
|
|
426
|
-
-- запись trim заменяет версию 1: стёрто на одну меньше, если версия 1 была прежней записью trim
|
|
427
|
-
total := total + k - case when first.op = 'trim' then 1 else 0 end;
|
|
428
|
-
classes := classes || r.class;
|
|
429
|
-
end loop;
|
|
430
|
-
perform @SCHEMA@.signal(array_agg(d.x), array_agg(c.tenant)) from (select distinct x from unnest(classes) x) d;
|
|
431
|
-
return total;
|
|
432
|
-
end $$;
|
|
433
|
-
|
|
434
|
-
-- ========================= смена ключа =========================
|
|
435
|
-
|
|
436
|
-
-- Новое значение ключа объекта (этап 6, п. 7): p_data сливается с данными, p_links заменяет концы
|
|
437
|
-
-- по ролям; новый id — v5 от нового ключа. Механизм — тот же, что у reclass(): строка с новым id,
|
|
438
|
-
-- перевод ссылок (рекурсивно для строк, в ключ которых входит конец), надгробие старого id со
|
|
439
|
-
-- ссылкой на новый. Ключ не изменился — обычная правка неключевых полей.
|
|
440
|
-
create or replace function @SCHEMA@.rekey(p_id uuid, p_data jsonb default null, p_links jsonb default null) returns uuid
|
|
441
|
-
language plpgsql volatile security definer
|
|
442
|
-
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
443
|
-
as $$
|
|
444
|
-
declare
|
|
445
|
-
c record;
|
|
446
|
-
rd jsonb;
|
|
447
|
-
wplan jsonb;
|
|
448
|
-
o @SCHEMA@.entity;
|
|
449
|
-
cls @SCHEMA@.entity;
|
|
450
|
-
res jsonb;
|
|
451
|
-
d jsonb;
|
|
452
|
-
bad jsonb;
|
|
453
|
-
lk record;
|
|
454
|
-
nid uuid;
|
|
455
|
-
denied text;
|
|
456
|
-
n_denied int;
|
|
457
|
-
begin
|
|
458
|
-
select * into c from @SCHEMA@.ctx();
|
|
459
|
-
if c.account is null then
|
|
460
|
-
perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
|
|
461
|
-
end if;
|
|
462
|
-
if current_setting('transaction_isolation') <> 'read committed' then
|
|
463
|
-
perform @SCHEMA@.lt_fail('isolation_level', 'смена ключа разрешена только в read committed');
|
|
464
|
-
end if;
|
|
465
|
-
rd := @SCHEMA@.acl_actor('READ');
|
|
466
|
-
wplan := @SCHEMA@.acl_plan('WRITE');
|
|
467
|
-
select x.* into o from @SCHEMA@.entity x where x.id = p_id and x.tenant = c.tenant for update;
|
|
468
|
-
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
|
|
469
|
-
perform @SCHEMA@.lt_fail('target_not_found', 'цель не найдена');
|
|
470
|
-
end if;
|
|
471
|
-
if not @SCHEMA@.acl_row_ok(wplan, o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
|
|
472
|
-
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', o.class));
|
|
473
|
-
end if;
|
|
474
|
-
if o.class in ('Class', 'Account', 'member', 'Resource', 'rule') then
|
|
475
|
-
perform @SCHEMA@.lt_fail('invalid_class', 'ключ системной строки не меняется');
|
|
476
|
-
end if;
|
|
477
|
-
cls := @SCHEMA@.class_row(o.class, o.tenant);
|
|
478
|
-
perform @SCHEMA@.family_lock(cls, false);
|
|
479
|
-
cls := @SCHEMA@.class_row(o.class, o.tenant);
|
|
480
|
-
if cls.id is null then
|
|
481
|
-
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', o.class));
|
|
482
|
-
end if;
|
|
483
|
-
res := cls.data->'resolved';
|
|
484
|
-
if res ? 'derivedBy' then
|
|
485
|
-
perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', o.class, res->>'derivedBy'));
|
|
486
|
-
end if;
|
|
487
|
-
if jsonb_array_length(coalesce(res->'key', '[]'::jsonb)) = 0 then
|
|
488
|
-
perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s нет ключа: id выдаёт база, менять нечего', o.class));
|
|
489
|
-
end if;
|
|
490
|
-
if res ? 'aggregate' and coalesce(p_data, '{}'::jsonb) ?| @SCHEMA@.agg_fields(res) then
|
|
491
|
-
perform @SCHEMA@.lt_fail('aggregate_field', format('агрегатные поля класса %s считает база — в rekey их не передают', o.class));
|
|
492
|
-
end if;
|
|
493
|
-
d := @SCHEMA@.apply_defaults(res->'schema', @SCHEMA@.merge(o.data, coalesce(p_data, '{}'::jsonb)));
|
|
494
|
-
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
|
|
495
|
-
if bad <> '[]'::jsonb then
|
|
496
|
-
perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', o.class), jsonb_build_object('issues', bad));
|
|
497
|
-
end if;
|
|
498
|
-
select * into lk from @SCHEMA@.check_links(res, o.links || coalesce(p_links, '{}'::jsonb), o.tenant, true, rd);
|
|
499
|
-
if lk.code is not null then
|
|
500
|
-
perform @SCHEMA@.lt_fail(lk.code, lk.msg);
|
|
501
|
-
end if;
|
|
502
|
-
nid := @SCHEMA@.key_id(res, o.class, o.tenant, d, lk.links);
|
|
503
|
-
if not @SCHEMA@.acl_row_ok(wplan, o.class, nid, o.owner, o.tenant, o.tags, d, lk.links) then
|
|
504
|
-
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s', o.class));
|
|
505
|
-
end if;
|
|
506
|
-
if nid = o.id then
|
|
507
|
-
if d is distinct from o.data or lk.links is distinct from o.links then
|
|
508
|
-
perform @SCHEMA@.sys_update(o.id, d, lk.links, null, 'update');
|
|
509
|
-
end if;
|
|
510
|
-
return o.id;
|
|
511
|
-
end if;
|
|
512
|
-
-- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE (глубже первого уровня — move_row); класс
|
|
513
|
-
-- такой строки называется, только если актор её видит (доработки после точки Б)
|
|
514
|
-
select min(x.class) filter (where @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links, c.tenant, c.account, rd->'plan')),
|
|
515
|
-
count(*) into denied, n_denied from @SCHEMA@.entity x
|
|
516
|
-
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);
|
|
517
|
-
if n_denied > 0 then
|
|
518
|
-
perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права WRITE на ссылающиеся строки класса %s', denied)
|
|
519
|
-
else 'нет права WRITE на ссылающиеся строки' end);
|
|
520
|
-
end if;
|
|
521
|
-
perform @SCHEMA@.move_row(o, nid, o.class, d, lk.links, 'rekey', wplan);
|
|
522
|
-
return nid;
|
|
523
|
-
end $$;
|
|
524
|
-
|
|
525
|
-
-- Новый ключ класса (этап 6, п. 7): описание класса и потомков, наследующих ключ, пересчитывается,
|
|
526
|
-
-- каждая строка семейства с другим id переносится на новый id (move_row). Исключительная
|
|
527
|
-
-- блокировка корня семейства приостанавливает запись в семейство до фиксации. Возвращает число
|
|
528
|
-
-- перенесённых строк класса (ссылающиеся строки с концом в ключе переносятся рекурсивно и не считаются).
|
|
529
|
-
create or replace function @SCHEMA@.rekey_class(p_class text, p_key jsonb) returns int
|
|
530
|
-
language plpgsql volatile security definer
|
|
531
|
-
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
532
|
-
as $$
|
|
533
|
-
declare
|
|
534
|
-
c record;
|
|
535
|
-
wplan jsonb;
|
|
536
|
-
cr @SCHEMA@.entity;
|
|
537
|
-
prep record;
|
|
538
|
-
dsc @SCHEMA@.entity;
|
|
539
|
-
classes text[];
|
|
540
|
-
r @SCHEMA@.entity;
|
|
541
|
-
res jsonb;
|
|
542
|
-
nid uuid;
|
|
543
|
-
n int := 0;
|
|
544
|
-
conflicts jsonb;
|
|
545
|
-
dname text;
|
|
546
|
-
begin
|
|
547
|
-
select * into c from @SCHEMA@.ctx();
|
|
548
|
-
if c.account is null then
|
|
549
|
-
perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
|
|
550
|
-
end if;
|
|
551
|
-
if current_setting('transaction_isolation') <> 'read committed' then
|
|
552
|
-
perform @SCHEMA@.lt_fail('isolation_level', 'смена ключа разрешена только в read committed');
|
|
553
|
-
end if;
|
|
554
|
-
wplan := @SCHEMA@.acl_plan('WRITE');
|
|
555
|
-
cr := @SCHEMA@.class_row(p_class, c.tenant);
|
|
556
|
-
if cr.id is null or cr.tenant <> c.tenant then
|
|
557
|
-
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден среди классов арендатора', p_class));
|
|
558
|
-
end if;
|
|
559
|
-
if coalesce((cr.data->>'system')::boolean, false) then
|
|
560
|
-
perform @SCHEMA@.lt_fail('invalid_class', 'ключ системного класса не меняется');
|
|
561
|
-
end if;
|
|
562
|
-
-- строки производного класса (и его потомков) переносит только владелец: ключ меняют в описании, а строки
|
|
563
|
-
-- строит заново проектор (rebuild)
|
|
564
|
-
if cr.data->'resolved' ? 'derivedBy' then
|
|
565
|
-
perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', p_class, cr.data->'resolved'->>'derivedBy'));
|
|
566
|
-
end if;
|
|
567
|
-
-- и потомки: производный потомок непроизводного предка наследует его ключ, а его строки переносит только проектор
|
|
568
|
-
-- (доработки после точки Б)
|
|
569
|
-
select x.data->>'name' into dname from @SCHEMA@.entity x
|
|
570
|
-
where x.class = 'Class' and x.tenant = cr.tenant and x.id <> cr.id
|
|
571
|
-
and x.data->'resolved'->'ancestors' ? p_class and x.data->'resolved' ? 'derivedBy'
|
|
572
|
-
order by x.data->>'name' limit 1;
|
|
573
|
-
if dname is not null then
|
|
574
|
-
perform @SCHEMA@.lt_fail('derived_class', format('у класса %s производный потомок %s: его строки пишет проектор', p_class, dname));
|
|
575
|
-
end if;
|
|
576
|
-
if not @SCHEMA@.acl_row_ok(wplan, 'Class', cr.id, cr.owner, cr.tenant, cr.tags, cr.data, cr.links) then
|
|
577
|
-
perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на класс Class');
|
|
578
|
-
end if;
|
|
579
|
-
-- смена ключа проверяет все строки семейства и отвечает числом нарушающих — как ужесточение, только с правом READ без
|
|
580
|
-
-- условий на все строки класса и потомков (доработки после точки Б)
|
|
581
|
-
perform @SCHEMA@.class_rows_admit(p_class, cr.tenant, array['READ'], format('смена ключа класса %s', p_class));
|
|
582
|
-
perform @SCHEMA@.family_lock(cr, true);
|
|
583
|
-
select x.* into cr from @SCHEMA@.entity x where x.id = cr.id for update;
|
|
584
|
-
select p.data, p.validator into prep from @SCHEMA@.class_prepare(cr.data || jsonb_build_object('key', p_key), cr.tenant) p;
|
|
585
|
-
perform @SCHEMA@.sys_update(cr.id, prep.data, cr.links, prep.validator, 'update');
|
|
586
|
-
classes := array[p_class];
|
|
587
|
-
-- потомки: описание пересчитывается (каждый пересчёт — версия класса в журнале)
|
|
588
|
-
for dsc in select x.* from @SCHEMA@.entity x
|
|
589
|
-
where x.class = 'Class' and x.tenant = cr.tenant and x.id <> cr.id
|
|
590
|
-
and x.data->'resolved'->'ancestors' ? p_class
|
|
591
|
-
order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.id loop
|
|
592
|
-
select p.data, p.validator into prep from @SCHEMA@.class_prepare(dsc.data, dsc.tenant) p;
|
|
593
|
-
if prep.data is distinct from dsc.data or prep.validator::text is distinct from dsc.validator::text then
|
|
594
|
-
perform @SCHEMA@.sys_update(dsc.id, prep.data, dsc.links, prep.validator, 'update');
|
|
595
|
-
end if;
|
|
596
|
-
classes := classes || (dsc.data->>'name');
|
|
597
|
-
end loop;
|
|
598
|
-
for r in select x.* from @SCHEMA@.entity x where x.class = any (classes) and x.tenant = cr.tenant order by x.id loop
|
|
599
|
-
-- строку мог уже перенести перевод ссылок (конец в её ключе): берём свежую
|
|
600
|
-
select x.* into r from @SCHEMA@.entity x where x.id = r.id for update;
|
|
601
|
-
continue when r.id is null;
|
|
602
|
-
if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
|
|
603
|
-
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', r.class));
|
|
604
|
-
end if;
|
|
605
|
-
res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
|
|
606
|
-
nid := coalesce(@SCHEMA@.key_id(res, r.class, r.tenant, r.data, r.links), r.id);
|
|
607
|
-
if nid <> r.id then
|
|
608
|
-
perform @SCHEMA@.move_row(r, nid, r.class, r.data, r.links, 'rekey', wplan);
|
|
609
|
-
n := n + 1;
|
|
610
|
-
end if;
|
|
611
|
-
end loop;
|
|
612
|
-
conflicts := @SCHEMA@.family_conflicts(classes, cr.tenant, false);
|
|
613
|
-
if conflicts <> '[]'::jsonb then
|
|
614
|
-
perform @SCHEMA@.lt_fail('tightening_conflict', format('новому ключу класса %s не соответствуют строки', p_class),
|
|
615
|
-
@SCHEMA@.visible_detail(conflicts, c.tenant));
|
|
616
|
-
end if;
|
|
617
|
-
perform @SCHEMA@.acl_bump();
|
|
618
|
-
return n;
|
|
619
|
-
end $$;
|
|
620
|
-
|
|
621
189
|
-- ========================= удаление аккаунта =========================
|
|
622
190
|
|
|
623
191
|
-- Удаление аккаунта (этап 6, п. 3; accounts.purge 0.21): вызывающий — системный администратор или
|
|
@@ -644,10 +212,10 @@ declare
|
|
|
644
212
|
begin
|
|
645
213
|
select * into c from @SCHEMA@.ctx();
|
|
646
214
|
if c.account is null then
|
|
647
|
-
perform @SCHEMA@.lt_fail('no_session'
|
|
215
|
+
perform @SCHEMA@.lt_fail('sql-time.no_session');
|
|
648
216
|
end if;
|
|
649
217
|
if p_account = c.account then
|
|
650
|
-
perform @SCHEMA@.lt_fail('
|
|
218
|
+
perform @SCHEMA@.lt_fail('sql-time.purge_self');
|
|
651
219
|
end if;
|
|
652
220
|
-- право — до поиска и блокировки цели: без права ответ один (acl_denied), есть ли такой аккаунт,
|
|
653
221
|
-- не видно, и чужую строку Account вызов не блокирует (итоговое ревью безопасности)
|
|
@@ -655,11 +223,11 @@ begin
|
|
|
655
223
|
select 1 from @SCHEMA@.entity x
|
|
656
224
|
where x.id = @SCHEMA@.v5_id(p_account, 'member', array[c.account::text, p_account::text]) and x.class = 'member'
|
|
657
225
|
and x.tenant = p_account and coalesce(x.data->'roles' ? 'owner', false))) then
|
|
658
|
-
perform @SCHEMA@.lt_fail('
|
|
226
|
+
perform @SCHEMA@.lt_fail('sql-time.account_purge_denied');
|
|
659
227
|
end if;
|
|
660
228
|
select x.* into a from @SCHEMA@.entity x where x.id = p_account and x.class = 'Account' and x.tenant = '@SYSTEM@'::uuid for update;
|
|
661
229
|
if a.id is null or p_account = '@SYSTEM@'::uuid then
|
|
662
|
-
perform @SCHEMA@.lt_fail('
|
|
230
|
+
perform @SCHEMA@.lt_fail('sql-time.account_not_found');
|
|
663
231
|
end if;
|
|
664
232
|
-- владелец стирает арендатора, в котором работает: его членство уйдёт вместе с данными, а надгробия и
|
|
665
233
|
-- передача владения ниже перечитывают ctx() — остаток операции идёт в личном арендаторе вызывающего,
|
|
@@ -677,7 +245,7 @@ begin
|
|
|
677
245
|
join @SCHEMA@.entity u on u.id = (x.links->>'User')::uuid and u.class = 'Account' and u.tenant = '@SYSTEM@'::uuid
|
|
678
246
|
where x.class = 'member' and x.tenant = t.tenant and x.links->>'User' <> p_account::text
|
|
679
247
|
and coalesce(x.data->'roles' ? 'owner', false) and coalesce((u.data->>'enabled')::boolean, true)) then
|
|
680
|
-
perform @SCHEMA@.lt_fail('
|
|
248
|
+
perform @SCHEMA@.lt_fail('sql-time.last_owner', jsonb_build_object('tenant', t.tenant), jsonb_build_object('tenant', t.tenant));
|
|
681
249
|
end if;
|
|
682
250
|
end loop;
|
|
683
251
|
-- данные арендатора-цели — физически, вместе с журналом
|
|
@@ -696,7 +264,7 @@ begin
|
|
|
696
264
|
select coalesce(jsonb_agg(jsonb_build_object('id', x.id, 'class', x.class, 'tenant', x.tenant)), '[]'::jsonb) into blockers
|
|
697
265
|
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;
|
|
698
266
|
if blockers <> '[]'::jsonb then
|
|
699
|
-
perform @SCHEMA@.lt_fail('
|
|
267
|
+
perform @SCHEMA@.lt_fail('sql-time.account_referenced', '{}'::jsonb, @SCHEMA@.visible_detail(blockers, c.tenant));
|
|
700
268
|
end if;
|
|
701
269
|
-- сессии аккаунта гасит с сигналом триггер до удаления (trg_entity_bd_account, срабатывает и для владельца), а его
|
|
702
270
|
-- строки secret — сессии и креды — стирает каскад внешнего ключа secret.account (ревизия 7)
|
|
@@ -709,541 +277,106 @@ begin
|
|
|
709
277
|
return jsonb_build_object('account', p_account, 'rows', n_rows, 'versions', n_log, 'memberships', n_mem, 'owned', n_own);
|
|
710
278
|
end $$;
|
|
711
279
|
|
|
712
|
-
-- ========================= сброс =========================
|
|
713
|
-
|
|
714
|
-
-- Запись reset — первая запись журнала после сброса: свой id в арендаторе, класс #reset (такое имя
|
|
715
|
-
-- класс получить не может), вид reset.
|
|
716
|
-
create or replace function @SCHEMA@.reset_mark(p_tenant uuid, p_level text, p_author uuid) returns void
|
|
717
|
-
language sql volatile
|
|
718
|
-
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
719
|
-
as $$
|
|
720
|
-
insert into @SCHEMA@.log (id, rev, class, tenant, owner, links, data, tags, at, author, agent, op, reason)
|
|
721
|
-
values (@SCHEMA@.v5_id(p_tenant, '#reset', array[gen_random_uuid()::text]), 1, '#reset', p_tenant, p_tenant, '{}',
|
|
722
|
-
jsonb_build_object('$reset', p_level), '{}', @SCHEMA@.now_ts(), coalesce(p_author, '@SYSTEM@'::uuid), null, 'reset',
|
|
723
|
-
nullif(current_setting('letopis.reason', true), ''))
|
|
724
|
-
$$;
|
|
725
|
-
|
|
726
|
-
-- Сброс (этап 6, п. 6): sessions — все сессии, кроме текущей; tenant — строки и журнал арендатора
|
|
727
|
-
-- (пачками по p_limit: done = false — вызвать ещё раз); all — всё, затем системный сид и новый
|
|
728
|
-
-- сервисный ключ (возвращается). Предохранители: флаг allow_reset (по умолчанию выключен — отказ
|
|
729
|
-
-- даже System), право letopis.reset, подтверждение именем схемы.
|
|
730
|
-
create or replace function @SCHEMA@.reset(p_level text, p_confirm text, p_tenant uuid default null, p_limit int default null) returns jsonb
|
|
731
|
-
language plpgsql volatile security definer
|
|
732
|
-
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
733
|
-
as $$
|
|
734
|
-
declare
|
|
735
|
-
c record;
|
|
736
|
-
t uuid;
|
|
737
|
-
n int;
|
|
738
|
-
m int;
|
|
739
|
-
key text;
|
|
740
|
-
held boolean;
|
|
741
|
-
begin
|
|
742
|
-
if not @ALLOW_RESET@ then
|
|
743
|
-
perform @SCHEMA@.lt_fail('reset_disabled', 'reset выключен в этой установке (up({ allowReset }))');
|
|
744
|
-
end if;
|
|
745
|
-
select * into c from @SCHEMA@.ctx();
|
|
746
|
-
if not @SCHEMA@.letopis_allowed('letopis.reset') then
|
|
747
|
-
perform @SCHEMA@.lt_fail('acl_denied', 'нет права letopis.reset');
|
|
748
|
-
end if;
|
|
749
|
-
if p_confirm is distinct from '@SCHEMA_NAME@' then
|
|
750
|
-
perform @SCHEMA@.lt_fail('confirm_mismatch', 'подтверждение сброса — имя схемы установки');
|
|
751
|
-
end if;
|
|
752
|
-
case p_level
|
|
753
|
-
when 'sessions' then
|
|
754
|
-
with d as (delete from @SCHEMA@.secret s where s.kind = 'SESSION' and s.id is distinct from c.session returning 1)
|
|
755
|
-
select count(*)::int into n from d;
|
|
756
|
-
perform @SCHEMA@.reset_mark('@SYSTEM@'::uuid, p_level, c.author);
|
|
757
|
-
perform @SCHEMA@.signal_raw(array['session:*']);
|
|
758
|
-
return jsonb_build_object('level', p_level, 'sessions', n);
|
|
759
|
-
when 'tenant' then
|
|
760
|
-
t := coalesce(p_tenant, c.tenant);
|
|
761
|
-
if t is null or t = '@SYSTEM@'::uuid then
|
|
762
|
-
perform @SCHEMA@.lt_fail('tenant_denied', 'System сбрасывается только целиком (level all)');
|
|
763
|
-
end if;
|
|
764
|
-
if not exists (select 1 from @SCHEMA@.entity a where a.id = t and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
|
|
765
|
-
perform @SCHEMA@.lt_fail('target_not_found', 'арендатор не найден');
|
|
766
|
-
end if;
|
|
767
|
-
-- строки арендатора стираются пачками в любом порядке: агрегаты его целей (план 1.1, §2.4) не пересчитываются —
|
|
768
|
-
-- цели уходят вместе с источниками, а промежуточный итог пачки мог бы выйти за правило класса
|
|
769
|
-
held := @SCHEMA@.agg_hold_begin('off');
|
|
770
|
-
with d as (delete from @SCHEMA@.entity x where x.id in (
|
|
771
|
-
select y.id from @SCHEMA@.entity y where y.tenant = t limit coalesce(p_limit, 2147483647)) returning 1)
|
|
772
|
-
select count(*)::int into n from d;
|
|
773
|
-
if held then
|
|
774
|
-
perform @SCHEMA@.agg_hold_end(false);
|
|
775
|
-
end if;
|
|
776
|
-
with d as (delete from @SCHEMA@.log x where x.seq in (
|
|
777
|
-
select y.seq from @SCHEMA@.log y where y.tenant = t limit coalesce(p_limit, 2147483647)) returning 1)
|
|
778
|
-
select count(*)::int into m from d;
|
|
779
|
-
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
|
|
780
|
-
return jsonb_build_object('level', p_level, 'tenant', t, 'rows', n, 'versions', m, 'done', false);
|
|
781
|
-
end if;
|
|
782
|
-
perform @SCHEMA@.reset_mark(t, p_level, c.author);
|
|
783
|
-
perform @SCHEMA@.signal_raw(array['*']);
|
|
784
|
-
perform @SCHEMA@.acl_bump();
|
|
785
|
-
return jsonb_build_object('level', p_level, 'tenant', t, 'rows', n, 'versions', m, 'done', true);
|
|
786
|
-
when 'all' then
|
|
787
|
-
truncate @SCHEMA@.entity, @SCHEMA@.log, @SCHEMA@.secret;
|
|
788
|
-
perform @SCHEMA@.reset_mark('@SYSTEM@'::uuid, p_level, c.author);
|
|
789
|
-
perform @SCHEMA@.seed_system();
|
|
790
|
-
key := 'lts_' || encode(@PGCRYPTO@.gen_random_bytes(32), 'hex');
|
|
791
|
-
perform @SCHEMA@.bootstrap_service(key);
|
|
792
|
-
perform @SCHEMA@.signal_raw(array['*', 'session:*']);
|
|
793
|
-
return jsonb_build_object('level', p_level, 'serviceKey', key);
|
|
794
|
-
else
|
|
795
|
-
perform @SCHEMA@.lt_fail('invalid_data', format('уровень сброса %s: sessions, tenant или all', p_level));
|
|
796
|
-
end case;
|
|
797
|
-
return null;
|
|
798
|
-
end $$;
|
|
799
|
-
|
|
800
280
|
-- ========================= обслуживание =========================
|
|
801
281
|
|
|
802
|
-
--
|
|
803
|
-
--
|
|
804
|
-
--
|
|
805
|
-
|
|
806
|
-
-- rekey, restore, purge, reset, trim); история удалённого объекта, надгробие которого старше
|
|
807
|
-
-- последнего яруса со сроком, стирается целиком. Пачка — до 100 объектов после курсора (meta
|
|
808
|
-
-- строки ключа: участки по порядку id), не больше p_limit версий; объект, не вошедший целиком,
|
|
809
|
-
-- достаётся следующей пачке. Курсор дошёл до конца — сбрасывается (more = false).
|
|
810
|
-
create or replace function @SCHEMA@.thin_batch(p_now timestamptz, p_limit int) returns jsonb
|
|
811
|
-
language plpgsql volatile
|
|
812
|
-
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
813
|
-
as $$
|
|
814
|
-
declare
|
|
815
|
-
cur uuid;
|
|
816
|
-
ids uuid[];
|
|
817
|
-
vid uuid[];
|
|
818
|
-
vrev int[];
|
|
819
|
-
total int;
|
|
820
|
-
k int;
|
|
821
|
-
nxt uuid;
|
|
822
|
-
more boolean;
|
|
823
|
-
begin
|
|
824
|
-
select (s.meta->>'thin')::uuid into cur from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid for update;
|
|
825
|
-
-- умолчание установки прореживает журнал: verify() и после его снятия считает такие пропуски законными
|
|
826
|
-
if @SCHEMA@.history_default() is not null then
|
|
827
|
-
update @SCHEMA@.secret s set meta = s.meta || '{"default_policy": true}' where s.id = '@SYSTEM@'::uuid and not (s.meta ? 'default_policy');
|
|
828
|
-
end if;
|
|
829
|
-
with pol as (
|
|
830
|
-
select x.data->>'name' as cls, x.tenant as ct, @SCHEMA@.history_policy(x.data->'resolved') as p
|
|
831
|
-
from @SCHEMA@.entity x where x.class = 'Class'
|
|
832
|
-
)
|
|
833
|
-
select array_agg(q.id order by q.id) into ids from (
|
|
834
|
-
select distinct l.id from @SCHEMA@.log l
|
|
835
|
-
join pol on pol.p is not null and pol.cls = l.class
|
|
836
|
-
and (pol.ct = l.tenant
|
|
837
|
-
or pol.ct = '@SYSTEM@'::uuid
|
|
838
|
-
and not exists (select 1 from @SCHEMA@.entity y
|
|
839
|
-
where y.class = 'Class' and y.tenant = l.tenant and y.data->>'name' = l.class))
|
|
840
|
-
where l.at < p_now - @SCHEMA@.history_iv(pol.p->>'all') and (cur is null or l.id > cur)
|
|
841
|
-
order by l.id limit 100) q;
|
|
842
|
-
if ids is null then
|
|
843
|
-
update @SCHEMA@.secret s set meta = s.meta - 'thin' where s.id = '@SYSTEM@'::uuid;
|
|
844
|
-
return jsonb_build_object('thinned', 0, 'more', false);
|
|
845
|
-
end if;
|
|
846
|
-
-- последняя запись каждого id пачки — for update: restore(), purge() и новая версия того же id берут её же (инвариант
|
|
847
|
-
-- M-TIME); отбор ниже видит то, что они успели зафиксировать, и не стирает историю объекта, который только что
|
|
848
|
-
-- восстановили (план 2.0, 7.3 №3)
|
|
849
|
-
perform 1 from @SCHEMA@.log l
|
|
850
|
-
where l.id = any (ids) and not exists (select 1 from @SCHEMA@.log z where z.id = l.id and z.rev > l.rev)
|
|
851
|
-
order by l.id
|
|
852
|
-
for update of l;
|
|
853
|
-
with pol as (
|
|
854
|
-
select x.data->>'name' as cls, x.tenant as ct, @SCHEMA@.history_policy(x.data->'resolved') as p
|
|
855
|
-
from @SCHEMA@.entity x where x.class = 'Class'
|
|
856
|
-
), v as (
|
|
857
|
-
-- все версии id, любого класса: последняя версия и следующая по времени — по всей цепочке (план 2.0, 7.3 №2: после
|
|
858
|
-
-- reclass версии прежнего класса без политики выпадали из отбора и переживали стирание истории удалённого)
|
|
859
|
-
select l.id, l.rev, l.at, l.op, l.class, l.tenant,
|
|
860
|
-
max(l.rev) over (partition by l.id) as last_rev,
|
|
861
|
-
first_value(l.op) over (partition by l.id order by l.rev desc) as last_op,
|
|
862
|
-
first_value(l.at) over (partition by l.id order by l.rev desc) as last_at,
|
|
863
|
-
first_value(l.class) over (partition by l.id order by l.rev desc) as last_class,
|
|
864
|
-
lead(l.at) over (partition by l.id order by l.rev) as next_at
|
|
865
|
-
from @SCHEMA@.log l
|
|
866
|
-
where l.id = any (ids)
|
|
867
|
-
), vp as (
|
|
868
|
-
-- прореживание версии — по политике её класса; стирание удалённого целиком — по политике класса надгробия
|
|
869
|
-
select v.*, pv.p, coalesce(pv.p->>'tz', 'UTC') as tz, pl.p as lp, coalesce(pl.p->>'tz', 'UTC') as ltz
|
|
870
|
-
from v
|
|
871
|
-
left join pol pv on pv.cls = v.class
|
|
872
|
-
and (pv.ct = v.tenant
|
|
873
|
-
or pv.ct = '@SYSTEM@'::uuid
|
|
874
|
-
and not exists (select 1 from @SCHEMA@.entity y
|
|
875
|
-
where y.class = 'Class' and y.tenant = v.tenant and y.data->>'name' = v.class))
|
|
876
|
-
left join pol pl on pl.cls = v.last_class
|
|
877
|
-
and (pl.ct = v.tenant
|
|
878
|
-
or pl.ct = '@SYSTEM@'::uuid
|
|
879
|
-
and not exists (select 1 from @SCHEMA@.entity y
|
|
880
|
-
where y.class = 'Class' and y.tenant = v.tenant and y.data->>'name' = v.last_class))
|
|
881
|
-
)
|
|
882
|
-
select array_agg(vp.id order by vp.id, vp.rev), array_agg(vp.rev order by vp.id, vp.rev) into vid, vrev
|
|
883
|
-
from vp
|
|
884
|
-
where (vp.last_op = 'delete' and vp.lp is not null
|
|
885
|
-
and timezone(vp.ltz, vp.last_at) <= timezone(vp.ltz, p_now) - @SCHEMA@.history_horizon(vp.lp))
|
|
886
|
-
or (vp.p is not null and vp.rev <> 1 and vp.rev <> vp.last_rev
|
|
887
|
-
and vp.op not in ('create', 'delete', 'reclass', 'rekey', 'restore', 'purge', 'reset', 'trim')
|
|
888
|
-
and not @SCHEMA@.history_keep(vp.p, timezone(vp.tz, vp.at), timezone(vp.tz, p_now), timezone(vp.tz, vp.next_at)));
|
|
889
|
-
total := coalesce(cardinality(vid), 0);
|
|
890
|
-
k := least(total, p_limit);
|
|
891
|
-
if k > 0 then
|
|
892
|
-
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;
|
|
893
|
-
end if;
|
|
894
|
-
if total > p_limit then
|
|
895
|
-
-- объект на границе пачки — снова в следующей
|
|
896
|
-
nxt := vid[p_limit + 1];
|
|
897
|
-
cur := (select x from unnest(ids) x where x < nxt order by x desc limit 1);
|
|
898
|
-
more := true;
|
|
899
|
-
elsif cardinality(ids) = 100 then
|
|
900
|
-
cur := ids[100];
|
|
901
|
-
more := true;
|
|
902
|
-
else
|
|
903
|
-
cur := null;
|
|
904
|
-
more := false;
|
|
905
|
-
end if;
|
|
906
|
-
update @SCHEMA@.secret s set meta = case when cur is null then s.meta - 'thin' else s.meta || jsonb_build_object('thin', cur) end
|
|
907
|
-
where s.id = '@SYSTEM@'::uuid;
|
|
908
|
-
return jsonb_build_object('thinned', k, 'more', more);
|
|
909
|
-
end $$;
|
|
910
|
-
|
|
911
|
-
-- Обслуживание (этап 6, п. 8): истёкшие и погашенные сессии и одноразовые коды, строки-замки удалённых семейств, одна
|
|
912
|
-
-- пачка прореживания журнала, заполнение очереди сигналов. Вызывает системный администратор, роль с
|
|
913
|
-
-- правом letopis.maintain или администраторское подключение. p_now — момент «сейчас» для
|
|
914
|
-
-- прореживания, только при allow_reset (тесты): иначе одним вызовом можно было бы проредить всю историю. p_cleanup —
|
|
915
|
-
-- удалять ли истёкшие сессии, коды и строки-замки: библиотека передаёт true только в первой пачке вызова, иначе каждая
|
|
916
|
-
-- из ≤100 пачек заново просматривала бы secret (план 2.0, 7.3 №4); прежний вызов без третьего аргумента чистит, как раньше.
|
|
917
|
-
drop function if exists @SCHEMA@.maintain(timestamptz, int);
|
|
918
|
-
create or replace function @SCHEMA@.maintain(p_now timestamptz default null, p_limit int default 10000,
|
|
919
|
-
p_cleanup boolean default true) returns jsonb
|
|
282
|
+
-- Обслуживание (этап 6, п. 8): истёкшие и погашенные сессии и одноразовые коды, строки-замки удалённых семейств,
|
|
283
|
+
-- заполнение очереди сигналов. Вызывает системный администратор, роль с правом letopis.maintain или администраторское
|
|
284
|
+
-- подключение. Один исполнитель: обслуживание идёт одной транзакцией под строкой-замком; занята — null.
|
|
285
|
+
create or replace function @SCHEMA@.maintain() returns jsonb
|
|
920
286
|
language plpgsql volatile security definer
|
|
921
287
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
922
288
|
as $$
|
|
923
289
|
declare
|
|
924
|
-
ts timestamptz := clock_timestamp();
|
|
925
290
|
s int;
|
|
926
291
|
o int;
|
|
927
292
|
fam text := '@SCHEMA_NAME@:family:';
|
|
928
293
|
begin
|
|
929
294
|
if not @SCHEMA@.letopis_allowed('letopis.maintain') then
|
|
930
|
-
perform @SCHEMA@.lt_fail('
|
|
295
|
+
perform @SCHEMA@.lt_fail('sql-time.maintain_denied');
|
|
931
296
|
end if;
|
|
932
|
-
-- один исполнитель: строка-замок до конца транзакции
|
|
297
|
+
-- один исполнитель: строка-замок до конца транзакции (lock_try) — посторонняя роль её не займёт (ревью
|
|
933
298
|
-- безопасности №3, №2); занята — null, обслуживание продолжит держатель
|
|
934
299
|
if not @SCHEMA@.lock_try('@SCHEMA_NAME@:maintain') then
|
|
935
300
|
return null;
|
|
936
301
|
end if;
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
o
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
select
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
and c.data->>'name' = split_part(substr(l.meta->>'name', length(fam) + 1), ':', 2)
|
|
970
|
-
and c.tenant::text = split_part(substr(l.meta->>'name', length(fam) + 1), ':', 1))
|
|
971
|
-
for update of l skip locked)
|
|
972
|
-
delete from @SCHEMA@.secret x using stale st
|
|
973
|
-
where x.id = st.id
|
|
974
|
-
and (st.n like '%:gate'
|
|
975
|
-
or exists (select 1 from stale g where g.n = st.n || ':gate')
|
|
976
|
-
or not exists (select 1 from @SCHEMA@.secret y where y.id = @SCHEMA@.lock_id(st.n || ':gate')));
|
|
977
|
-
end if;
|
|
978
|
-
return jsonb_build_object('sessions', s, 'codes', o, 'queue', pg_notification_queue_usage())
|
|
979
|
-
|| @SCHEMA@.thin_batch(ts, greatest(coalesce(p_limit, 10000), 1));
|
|
980
|
-
end $$;
|
|
981
|
-
|
|
982
|
-
-- ========================= verify =========================
|
|
983
|
-
|
|
984
|
-
-- Проверка цепочек хэшей (этап 1, п. 11; этап 6, п. 4): номера версий подряд с 1, prev_hash каждой
|
|
985
|
-
-- версии равен хэшу предыдущей, строка entity совпадает с последней версией журнала. Законные
|
|
986
|
-
-- разрывы: после записи trim (стык — по $trimmed и $hash), запись purge и reset — начало цепочки,
|
|
987
|
-
-- прореживание — пропуск после версии старше яруса all класса, у которого есть (или была) политика
|
|
988
|
-
-- хранения — своя или умолчание установки (метка default_policy в строке ключа). p_data — ещё и все строки: данные по проверке класса, концы (цель существует, её
|
|
989
|
-
-- арендатор — тот же или System, класс принимается концом), арендатор и владелец существуют, id
|
|
990
|
-
-- класса с ключом — v5 от ключа, агрегатные поля равны итогу живых источников (план 1.1, §2.4; ремонт —
|
|
991
|
-
-- rebuild_aggregates). Возвращает находки и якорь — хэш всех голов цепочек. Только
|
|
992
|
-
-- системный администратор (право letopis.verify) или администраторское подключение.
|
|
993
|
-
create or replace function @SCHEMA@.verify(p_data boolean default false) returns jsonb
|
|
994
|
-
language plpgsql stable security definer
|
|
995
|
-
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
996
|
-
as $$
|
|
997
|
-
declare
|
|
998
|
-
issues jsonb;
|
|
999
|
-
data_issues jsonb := '[]'::jsonb;
|
|
1000
|
-
anchor text;
|
|
1001
|
-
ts timestamptz := clock_timestamp();
|
|
1002
|
-
begin
|
|
1003
|
-
if not @SCHEMA@.letopis_allowed('letopis.verify') then
|
|
1004
|
-
perform @SCHEMA@.lt_fail('acl_denied', 'verify доступен только системному администратору');
|
|
1005
|
-
end if;
|
|
1006
|
-
with pol as (
|
|
1007
|
-
-- классы с политикой хранения сейчас или в истории строки Class; ярус all — действующий или сутки
|
|
1008
|
-
select x.data->>'name' as cls, x.tenant as ct,
|
|
1009
|
-
coalesce(@SCHEMA@.history_iv(@SCHEMA@.history_policy(x.data->'resolved')->>'all'), interval '1 day') as all_iv
|
|
1010
|
-
from @SCHEMA@.entity x
|
|
1011
|
-
where x.class = 'Class'
|
|
1012
|
-
and (@SCHEMA@.history_policy(x.data->'resolved') is not null
|
|
1013
|
-
or exists (select 1 from @SCHEMA@.log h where h.id = x.id and jsonb_typeof(h.data->'resolved'->'history') = 'object')
|
|
1014
|
-
or (x.data->'resolved'->'history' = 'true'::jsonb and x.data->>'name' not in ('Class', 'Account', 'member', 'Resource', 'rule')
|
|
1015
|
-
and exists (select 1 from @SCHEMA@.secret k where k.id = '@SYSTEM@'::uuid and k.meta ? 'default_policy')))
|
|
1016
|
-
), chain as (
|
|
1017
|
-
select l.id, l.rev, l.class, l.tenant, l.prev_hash,
|
|
1018
|
-
lag(l.rev) over w as prev_rev, lag(l.hash) over w as prev_h, lag(l.op) over w as prev_op,
|
|
1019
|
-
lag(l.data) over w as prev_data, lag(l.at) over w as prev_at
|
|
1020
|
-
from @SCHEMA@.log l
|
|
1021
|
-
window w as (partition by l.id order by l.rev)
|
|
1022
|
-
), exp as (
|
|
1023
|
-
select chain.*,
|
|
1024
|
-
case when prev_op = 'trim' then (prev_data->>'$trimmed')::int + 1 else prev_rev + 1 end as want_rev,
|
|
1025
|
-
case when prev_op = 'trim' then decode(prev_data->>'$hash', 'hex') else prev_h end as want_hash
|
|
1026
|
-
from chain where prev_rev is not null
|
|
1027
|
-
), found as (
|
|
1028
|
-
select jsonb_build_object('id', id, 'rev', rev, 'problem', 'цепочка начинается не с версии 1') as f
|
|
1029
|
-
from chain where prev_rev is null and (rev <> 1 or prev_hash is not null)
|
|
1030
|
-
union all
|
|
1031
|
-
select jsonb_build_object('id', id, 'rev', prev_rev, 'problem', 'хэш версии не совпадает с prev_hash следующей')
|
|
1032
|
-
from exp where rev = want_rev and prev_hash is distinct from want_hash
|
|
1033
|
-
union all
|
|
1034
|
-
select jsonb_build_object('id', id, 'rev', want_rev, 'problem', 'разрыв: нет версий ' || want_rev || '…' || (rev - 1))
|
|
1035
|
-
from exp
|
|
1036
|
-
where rev > want_rev
|
|
1037
|
-
and not exists (select 1 from pol where pol.cls = exp.class and exp.prev_at < ts - pol.all_iv
|
|
1038
|
-
and (pol.ct = exp.tenant
|
|
1039
|
-
or pol.ct = '@SYSTEM@'::uuid
|
|
1040
|
-
and not exists (select 1 from @SCHEMA@.entity y
|
|
1041
|
-
where y.class = 'Class' and y.tenant = exp.tenant
|
|
1042
|
-
and y.data->>'name' = exp.class)))
|
|
1043
|
-
union all
|
|
1044
|
-
select jsonb_build_object('id', id, 'rev', rev, 'problem', 'номер версии меньше ожидаемого после обрезки')
|
|
1045
|
-
from exp where rev < want_rev
|
|
1046
|
-
union all
|
|
1047
|
-
select jsonb_build_object('id', e.id, 'rev', e.rev, 'problem', 'строка entity не совпадает с последней версией журнала')
|
|
1048
|
-
from @SCHEMA@.entity e
|
|
1049
|
-
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
|
|
1050
|
-
where coalesce((@SCHEMA@.class_row(e.class, e.tenant)).data->'resolved'->>'history', 'true') <> 'false'
|
|
1051
|
-
and (l.hash is distinct from e.hash or l.rev is distinct from e.rev)
|
|
1052
|
-
)
|
|
1053
|
-
select coalesce(jsonb_agg(f), '[]'::jsonb) into issues from found;
|
|
1054
|
-
if coalesce(p_data, false) then
|
|
1055
|
-
with cls as (
|
|
1056
|
-
select x.tenant as ct, x.data->>'name' as name, x.data->'resolved' as res, x.validator
|
|
1057
|
-
from @SCHEMA@.entity x where x.class = 'Class'
|
|
1058
|
-
), rows_ as (
|
|
1059
|
-
select e.*, k.res, k.validator as vld
|
|
1060
|
-
from @SCHEMA@.entity e
|
|
1061
|
-
left join lateral (select cls.res, cls.validator from cls where cls.name = e.class and cls.ct in (e.tenant, '@SYSTEM@'::uuid)
|
|
1062
|
-
order by cls.ct = '@SYSTEM@'::uuid limit 1) k on true
|
|
1063
|
-
), found as (
|
|
1064
|
-
select jsonb_build_object('id', r.id, 'class', r.class, 'problem', 'класс строки не найден или абстрактный') as f
|
|
1065
|
-
from rows_ r where r.class <> 'Class' and (r.res is null or coalesce((r.res->>'abstract')::boolean, false))
|
|
1066
|
-
union all
|
|
1067
|
-
select jsonb_build_object('id', r.id, 'class', r.class, 'problem', 'данные не проходят описание класса',
|
|
1068
|
-
'issues', @SCHEMA@.validate_data(r.res->'rules', r.vld, r.res->'vars', r.data))
|
|
1069
|
-
from rows_ r where r.class <> 'Class' and r.res is not null
|
|
1070
|
-
and @SCHEMA@.validate_data(r.res->'rules', r.vld, r.res->'vars', r.data) <> '[]'::jsonb
|
|
1071
|
-
union all
|
|
1072
|
-
select jsonb_build_object('id', r.id, 'class', r.class, 'problem', 'id не равен v5 от ключа класса')
|
|
1073
|
-
from rows_ r where r.res is not null and jsonb_array_length(coalesce(r.res->'key', '[]'::jsonb)) > 0
|
|
1074
|
-
and @SCHEMA@.key_id(r.res, r.class, r.tenant, r.data, r.links) is distinct from r.id
|
|
1075
|
-
union all
|
|
1076
|
-
select jsonb_build_object('id', r.id, 'class', r.class, 'problem', 'арендатор строки не существует', 'tenant', r.tenant)
|
|
1077
|
-
from rows_ r
|
|
1078
|
-
where r.tenant <> '@SYSTEM@'::uuid
|
|
1079
|
-
and not exists (select 1 from @SCHEMA@.entity a where a.id = r.tenant and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid)
|
|
1080
|
-
union all
|
|
1081
|
-
select jsonb_build_object('id', r.id, 'class', r.class, 'problem', 'владелец строки не существует', 'owner', r.owner)
|
|
1082
|
-
from rows_ r
|
|
1083
|
-
where r.owner <> r.tenant
|
|
1084
|
-
and not exists (select 1 from @SCHEMA@.entity a where a.id = r.owner and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid)
|
|
1085
|
-
union all
|
|
1086
|
-
select jsonb_build_object('id', p.id, 'class', p.class, 'problem', 'цель конца не найдена', 'role', p.role, 'target', p.tid)
|
|
1087
|
-
from (select r.id, r.class, r.tenant, r.res, k.key as role,
|
|
1088
|
-
(case jsonb_typeof(k.value) when 'array' then a.value else k.value end #>> '{}')::uuid as tid
|
|
1089
|
-
from rows_ r
|
|
1090
|
-
cross join lateral jsonb_each(r.links) k
|
|
1091
|
-
left join lateral jsonb_array_elements(case jsonb_typeof(k.value) when 'array' then k.value else '[]'::jsonb end) a on true
|
|
1092
|
-
where jsonb_typeof(k.value) = 'string' or a.value is not null) p
|
|
1093
|
-
left join @SCHEMA@.entity t on t.id = p.tid
|
|
1094
|
-
where t.id is null
|
|
1095
|
-
or t.tenant not in (p.tenant, '@SYSTEM@'::uuid)
|
|
1096
|
-
or (p.res is not null and not (
|
|
1097
|
-
@SCHEMA@.class_set(p.res->'ends'->p.role->'class') ? t.class
|
|
1098
|
-
or @SCHEMA@.class_set(p.res->'ends'->p.role->'class') ?| coalesce(
|
|
1099
|
-
(select array(select jsonb_array_elements_text(k2.data->'resolved'->'ancestors'))
|
|
1100
|
-
from @SCHEMA@.entity k2
|
|
1101
|
-
where k2.class = 'Class' and k2.data->>'name' = t.class and k2.tenant in (t.tenant, '@SYSTEM@'::uuid)
|
|
1102
|
-
order by k2.tenant = '@SYSTEM@'::uuid limit 1), '{}')))
|
|
1103
|
-
union all
|
|
1104
|
-
select jsonb_build_object('id', a.id, 'class', a.class, 'field', a.field, 'stored', a.stored, 'expected', a.expected,
|
|
1105
|
-
'problem', format('агрегат %s не равен итогу живых источников: в строке %s, по источникам %s — ремонт rebuildAggregates',
|
|
1106
|
-
a.field, coalesce(a.stored::text, 'нет значения'), a.expected::text))
|
|
1107
|
-
from @SCHEMA@.agg_issues() a
|
|
1108
|
-
)
|
|
1109
|
-
select coalesce(jsonb_agg(f), '[]'::jsonb) into data_issues from found;
|
|
1110
|
-
end if;
|
|
1111
|
-
select encode(sha256(coalesce(string_agg(e.hash, ''::bytea order by e.id), ''::bytea)), 'hex') into anchor
|
|
1112
|
-
from @SCHEMA@.entity e;
|
|
1113
|
-
issues := issues || data_issues;
|
|
1114
|
-
return jsonb_build_object('ok', issues = '[]'::jsonb, 'issues', issues, 'anchor', anchor);
|
|
302
|
+
with d as (delete from @SCHEMA@.secret x where x.kind = 'SESSION'
|
|
303
|
+
and (x.deleted_at is not null or x.expires_at <= clock_timestamp()) returning 1)
|
|
304
|
+
select count(*)::int into s from d;
|
|
305
|
+
-- коды всех видов, которые выпускает auth_issue_otp: OTP и OTP_<имя> (тот же шаблон вида), — использованные,
|
|
306
|
+
-- заменённые, исчерпавшие попытки (deleted_at) и истёкшие
|
|
307
|
+
with d as (delete from @SCHEMA@.secret x where x.kind ~ '^OTP(_[A-Z0-9]{1,27})?$'
|
|
308
|
+
and (x.deleted_at is not null or x.expires_at <= clock_timestamp()) returning 1)
|
|
309
|
+
select count(*)::int into o from d;
|
|
310
|
+
-- строки-замки семейств, чьего корня больше нет — удалён класс или его арендатор (план 2.0, 7.7 №8, долгий
|
|
311
|
+
-- прогон): их заранее заводит запись строки Class (family_rows), а сами они не уходили — заведение и удаление
|
|
312
|
+
-- классов копило их без предела. Корень жив — строка Class с этим именем у арендатора семейства (family_name;
|
|
313
|
+
-- в имени класса двоеточия нет). Занятую строку не трогаем (skip locked), а строку-замок семейства — только вместе
|
|
314
|
+
-- с её шлюзом или без шлюза: исключительная блокировка берёт шлюз первым, и строка-замок, до которой она ещё не
|
|
315
|
+
-- дошла, у неё не пропадёт. Пропавшую строку lock_row и family_lock заводят заново, если класс с этим именем
|
|
316
|
+
-- появится снова.
|
|
317
|
+
with stale as (
|
|
318
|
+
select l.id, l.meta->>'name' as n
|
|
319
|
+
from @SCHEMA@.secret l
|
|
320
|
+
where l.kind = 'LOCK' and l.account = '@SYSTEM@'::uuid and l.deleted_at is null
|
|
321
|
+
and left(l.meta->>'name', length(fam)) = fam
|
|
322
|
+
and not exists (
|
|
323
|
+
select 1 from @SCHEMA@.entity c
|
|
324
|
+
where c.class = 'Class'
|
|
325
|
+
and c.data->>'name' = split_part(substr(l.meta->>'name', length(fam) + 1), ':', 2)
|
|
326
|
+
and c.tenant::text = split_part(substr(l.meta->>'name', length(fam) + 1), ':', 1))
|
|
327
|
+
for update of l skip locked)
|
|
328
|
+
delete from @SCHEMA@.secret x using stale st
|
|
329
|
+
where x.id = st.id
|
|
330
|
+
and (st.n like '%:gate'
|
|
331
|
+
or exists (select 1 from stale g where g.n = st.n || ':gate')
|
|
332
|
+
or not exists (select 1 from @SCHEMA@.secret y where y.id = @SCHEMA@.lock_id(st.n || ':gate')));
|
|
333
|
+
return jsonb_build_object('sessions', s, 'codes', o, 'queue', pg_notification_queue_usage());
|
|
1115
334
|
end $$;
|
|
1116
335
|
|
|
1117
336
|
-- ========================= подписка =========================
|
|
1118
337
|
|
|
1119
338
|
-- Пачка событий подписки (этап 6, п. 5): версии журнала после курсора (tx, seq) ниже горизонта
|
|
1120
339
|
-- завершённых транзакций (pg_snapshot_xmin) — их уже ничто не обгонит, поэтому без потерь и повторов.
|
|
1121
|
-
-- Выполняется с правами вызывающего: RLS журнала показывает только своё.
|
|
1122
|
-
--
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
-- p_leaving (проектор, ревью безопасности №3, №4): ещё смены класса без смены id (reclass без moved), уводящие строку из
|
|
1126
|
-
-- p_classes, — версия уже другого класса, а прошлая была в p_classes; иначе уход строки из семейства не виден.
|
|
1127
|
-
create or replace function @SCHEMA@.watch_read(p_tx xid8, p_seq bigint, p_at timestamptz, p_classes text[], p_limit int,
|
|
1128
|
-
p_snap pg_snapshot default null, p_leaving boolean default false)
|
|
340
|
+
-- Выполняется с правами вызывающего: RLS журнала показывает только своё. p_classes null — все классы. p_snap — снимок
|
|
341
|
+
-- момента подписки «now»: транзакции, видимые в нём (зафиксированные до подписки), не отдаются.
|
|
342
|
+
create or replace function @SCHEMA@.watch_read(p_tx xid8, p_seq bigint, p_classes text[], p_limit int,
|
|
343
|
+
p_snap pg_snapshot default null)
|
|
1129
344
|
returns setof @SCHEMA@.log
|
|
1130
345
|
language plpgsql stable
|
|
1131
346
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
1132
347
|
as $$
|
|
1133
348
|
declare
|
|
1134
349
|
horizon xid8 := pg_snapshot_xmin(pg_current_snapshot());
|
|
1135
|
-
lim interval;
|
|
1136
|
-
t uuid := @SCHEMA@.ctx_tenant();
|
|
1137
350
|
begin
|
|
1138
|
-
|
|
1139
|
-
-- ярус all классов, которые видит подписка: арендатора сессии и System (кроме одноимённых своих); политика класса
|
|
1140
|
-
-- чужого арендатора с тем же именем курсор не просрочивает (план 2.0, 7.3 №1). Без арендатора в контексте
|
|
1141
|
-
-- (администраторское подключение видит всех) — все классы
|
|
1142
|
-
select min(@SCHEMA@.history_iv(p.p->>'all')) into lim
|
|
1143
|
-
from @SCHEMA@.entity x cross join lateral (select @SCHEMA@.history_policy(x.data->'resolved') as p) p
|
|
1144
|
-
where x.class = 'Class' and p.p is not null and (p_classes is null or x.data->>'name' = any (p_classes))
|
|
1145
|
-
and (t is null
|
|
1146
|
-
or x.tenant = t
|
|
1147
|
-
or x.tenant = '@SYSTEM@'::uuid
|
|
1148
|
-
and not exists (select 1 from @SCHEMA@.entity y
|
|
1149
|
-
where y.class = 'Class' and y.tenant = t and y.data->>'name' = x.data->>'name'));
|
|
1150
|
-
if lim is not null and p_at < clock_timestamp() - lim then
|
|
1151
|
-
raise exception using errcode = 'LT016',
|
|
1152
|
-
message = 'letopis: cursor_expired: курсор подписки старше яруса all — события могли проредиться, перечитайте текущее состояние';
|
|
1153
|
-
end if;
|
|
1154
|
-
end if;
|
|
1155
|
-
-- колонки — явно: changed приложению не выдана (ревью №1 точки А плана 1.1), в ответе подписки она null
|
|
1156
|
-
return query select l.seq, l.tx, l.id, l.rev, l.class, l.cv, l.tenant, l.owner, l.links, l.data, l.tags, l.at, l.author,
|
|
1157
|
-
l.agent, l.op, l.reason, l.moved, l.prev_hash, l.hash, null::jsonb
|
|
1158
|
-
from @SCHEMA@.log l
|
|
1159
|
-
where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon
|
|
1160
|
-
and (p_classes is null or l.class = any (p_classes)
|
|
1161
|
-
or (p_leaving and l.op = 'reclass' and l.moved is null
|
|
1162
|
-
and (select p.class from @SCHEMA@.log p where p.id = l.id and p.rev < l.rev and p.op not in ('purge', 'trim', 'reset')
|
|
1163
|
-
order by p.rev desc limit 1) = any (p_classes)))
|
|
1164
|
-
and (p_snap is null or not pg_visible_in_snapshot(l.tx, p_snap))
|
|
1165
|
-
order by l.tx, l.seq limit p_limit;
|
|
1166
|
-
end $$;
|
|
1167
|
-
|
|
1168
|
-
-- Уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3): версия пишется уже с новым
|
|
1169
|
-
-- классом, и подписка по классам её не видит. Здесь — те же окно, горизонт и снимок, что у watch_read (и верхняя граница
|
|
1170
|
-
-- окна p_upto — последняя строка полной пачки watch_read), а строки — синтетические надгробия прежней версии: класс,
|
|
1171
|
-
-- концы и данные прежние; номер, время, автор, tx и seq — версии смены класса; вид delete; агента и причины нет. Только
|
|
1172
|
-
-- если нового класса нет в p_classes, а прежнюю версию актор видит (row_visible с его планом READ): о строке, которой он
|
|
1173
|
-
-- не читал, подписчик не узнает. Функция владельца: версию с новым классом актору видеть не нужно. Подключение в обход
|
|
1174
|
-
-- RLS (суперпользователь, BYPASSRLS, член роли-владельца с наследованием — connect() пускает его только с
|
|
1175
|
-
-- allowBypassRls) видит в watch_read журнал всех арендаторов, поэтому и события ухода получает все, с автором (хвост
|
|
1176
|
-
-- ревью доработок после 1.2.0). Роль — действующая роль сеанса (session_role): та, к которой watch_read применяет RLS.
|
|
1177
|
-
create or replace function @SCHEMA@.watch_leaving(p_tx xid8, p_seq bigint, p_classes text[], p_limit int,
|
|
1178
|
-
p_snap pg_snapshot default null, p_upto_tx xid8 default null, p_upto_seq bigint default null)
|
|
1179
|
-
returns setof @SCHEMA@.log
|
|
1180
|
-
language plpgsql stable security definer
|
|
1181
|
-
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
1182
|
-
as $$
|
|
1183
|
-
declare
|
|
1184
|
-
horizon xid8 := pg_snapshot_xmin(pg_current_snapshot());
|
|
1185
|
-
a jsonb;
|
|
1186
|
-
bypass boolean;
|
|
1187
|
-
begin
|
|
1188
|
-
if p_classes is null then
|
|
1189
|
-
return;
|
|
1190
|
-
end if;
|
|
1191
|
-
bypass := exists (select 1 from pg_roles r where r.rolname = @SCHEMA@.session_role() and (r.rolsuper or r.rolbypassrls))
|
|
1192
|
-
or pg_has_role(@SCHEMA@.session_role(), '@OWNER_NAME@', 'USAGE');
|
|
1193
|
-
a := case when not bypass then @SCHEMA@.acl_actor('READ') end;
|
|
1194
|
-
if a is null and not bypass then
|
|
1195
|
-
return;
|
|
1196
|
-
end if;
|
|
1197
|
-
-- новую версию актор не видит — надгробие при любом p_classes и без автора: перебором классов подписки он узнал бы её
|
|
1198
|
-
-- класс (автоматическое ревью доработок после 1.2.0)
|
|
1199
|
-
return query select l.seq, l.tx, p.id, l.rev, p.class, p.cv, p.tenant, p.owner, p.links, p.data, p.tags, l.at,
|
|
1200
|
-
case when v.seen then l.author end, null::text, 'delete'::text, null::text, null::uuid, null::bytea, null::bytea, null::jsonb
|
|
351
|
+
return query select l.*
|
|
1201
352
|
from @SCHEMA@.log l
|
|
1202
|
-
cross join lateral (select x.* from @SCHEMA@.log x where x.id = l.id and x.rev < l.rev and x.op not in ('purge', 'trim', 'reset')
|
|
1203
|
-
order by x.rev desc limit 1) p
|
|
1204
|
-
cross join lateral (select bypass or @SCHEMA@.row_visible(l.tenant, l.class, l.id, l.owner, l.tags, l.data, l.links,
|
|
1205
|
-
(a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan') as seen) v
|
|
1206
353
|
where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon
|
|
1207
|
-
and (
|
|
1208
|
-
and l.op = 'reclass' and l.moved is null and (not (l.class = any (p_classes)) or not v.seen)
|
|
1209
|
-
and p.class = any (p_classes) and p.op <> 'delete'
|
|
354
|
+
and (p_classes is null or l.class = any (p_classes))
|
|
1210
355
|
and (p_snap is null or not pg_visible_in_snapshot(l.tx, p_snap))
|
|
1211
|
-
and (bypass or @SCHEMA@.row_visible(p.tenant, p.class, p.id, p.owner, p.tags, p.data, p.links,
|
|
1212
|
-
(a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan'))
|
|
1213
356
|
order by l.tx, l.seq limit p_limit;
|
|
1214
357
|
end $$;
|
|
1215
358
|
|
|
1216
359
|
-- Точка подписки «с этого момента»: курсор — горизонт завершённых транзакций, снимок — чтобы не
|
|
1217
360
|
-- отдавать транзакции, зафиксированные до подписки, но ещё не ушедшие за горизонт.
|
|
1218
|
-
drop function if exists @SCHEMA@.watch_head();
|
|
1219
361
|
create or replace function @SCHEMA@.watch_head(out tx xid8, out seq bigint, out snap pg_snapshot)
|
|
1220
362
|
language sql stable
|
|
1221
363
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
1222
364
|
as $$
|
|
1223
365
|
select pg_snapshot_xmin(s), 0::bigint, s from pg_current_snapshot() s
|
|
1224
366
|
$$;
|
|
1225
|
-
-- до исправления подписки «now»: без снимка; до ревью безопасности №3 — без p_leaving
|
|
1226
|
-
drop function if exists @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int);
|
|
1227
|
-
drop function if exists @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot);
|
|
1228
367
|
|
|
1229
368
|
do $$
|
|
1230
369
|
declare f text;
|
|
1231
370
|
begin
|
|
1232
371
|
foreach f in array array[
|
|
1233
|
-
'
|
|
1234
|
-
'
|
|
1235
|
-
'
|
|
1236
|
-
'rekey(uuid, jsonb, jsonb)', 'rekey_class(text, jsonb)', 'account_purge(uuid)', 'reset_mark(uuid, text, uuid)',
|
|
1237
|
-
'reset(text, text, uuid, int)', 'thin_batch(timestamptz, int)', 'maintain(timestamptz, int, boolean)', 'verify(boolean)',
|
|
1238
|
-
'watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean)', 'watch_leaving(xid8, bigint, text[], int, pg_snapshot, xid8, bigint)', 'watch_head()'] loop
|
|
372
|
+
'session_role()', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
|
|
373
|
+
'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'account_purge(uuid)', 'maintain()',
|
|
374
|
+
'watch_read(xid8, bigint, text[], int, pg_snapshot)', 'watch_head()'] loop
|
|
1239
375
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
1240
376
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
1241
377
|
end loop;
|
|
1242
378
|
end $$;
|
|
1243
|
-
-- приложению: функции владельца этапа 6 (проверяют права сами)
|
|
1244
|
-
grant execute on function @SCHEMA@.restore(uuid), @SCHEMA@.
|
|
1245
|
-
@SCHEMA@.
|
|
1246
|
-
@SCHEMA@.reset(text, text, uuid, int), @SCHEMA@.maintain(timestamptz, int, boolean), @SCHEMA@.verify(boolean),
|
|
1247
|
-
@SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean), @SCHEMA@.watch_leaving(xid8, bigint, text[], int, pg_snapshot, xid8, bigint), @SCHEMA@.watch_head(),
|
|
1248
|
-
@SCHEMA@.history_default(), @SCHEMA@.history_policy(jsonb), @SCHEMA@.history_iv(text),
|
|
379
|
+
-- приложению: функции владельца этапа 6 (проверяют права сами) и подписка (под RLS)
|
|
380
|
+
grant execute on function @SCHEMA@.restore(uuid), @SCHEMA@.account_purge(uuid), @SCHEMA@.maintain(),
|
|
381
|
+
@SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, text[], int, pg_snapshot), @SCHEMA@.watch_head(),
|
|
1249
382
|
@SCHEMA@.signal_mode(), @SCHEMA@.signal_key(), @SCHEMA@.class_stamp() to @APP@;
|