letopis 1.4.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
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
- -- reset() — сброс sessions | tenant | all (флаг allow_reset, право letopis.reset,
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
- -- Роль подключения — администраторская (суперпользователь или может стать владельцем): verify,
93
- -- reset и maintain доступны ей и без сессии (CLI, обслуживание по расписанию).
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('acl_denied', 'нет права letopis.signals');
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, purge, обрезка истории =========================
89
+ -- ========================= restore =========================
162
90
 
163
91
  -- Последняя запись журнала id под блокировкой, если она видна актору в его арендаторе: параллельные
164
- -- restore, purge, обрезка и воскрешение (trg_entity_bi берёт ту же блокировку) упорядочены.
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,17 +100,22 @@ 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 or l.op in ('purge', 'reset')
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 $$;
181
109
 
182
110
  -- Восстановление удалённого объекта (этап 6, п. 2): последний снимок новой версией с видом restore.
183
111
  -- Снимок перепроверяется по текущему описанию класса (данные, концы с видимостью целей, ключ), право
184
- -- WRITE — на восстановленные значения. id прежний, в том числе у класса без ключа.
185
- create or replace function @SCHEMA@.restore(p_id uuid) returns uuid
112
+ -- WRITE — на восстановленные значения. id прежний, в том числе у класса без ключа. p_rev (ревизия 9, план 2.1, A1) —
113
+ -- ожидаемый номер последней записи журнала (надгробия из ответа delete или withDeleted): другой — отказ restore_stale,
114
+ -- проверка под той же блокировкой записи журнала, что у параллельного restore и воскрешения. Прежняя restore(uuid)
115
+ -- снимается миграцией 0009: вызов restore(id) попадает сюда с p_rev = null. Отказ — по записи журнала за последним
116
+ -- надгробием (второй раунд 2.1, A2): restore — restore_already, create — restore_id_taken (объект живой); иначе с p_rev —
117
+ -- restore_stale с after (вид записи сразу за p_rev), deleted и live, без p_rev — restore_not_deleted.
118
+ create or replace function @SCHEMA@.restore(p_id uuid, p_rev int default null) returns uuid
186
119
  language plpgsql volatile security definer
187
120
  set search_path = pg_catalog, @SCHEMA@, pg_temp
188
121
  as $$
@@ -199,47 +132,67 @@ declare
199
132
  own uuid;
200
133
  ts timestamptz;
201
134
  n @SCHEMA@.entity;
135
+ v_live boolean;
136
+ v_tomb int;
137
+ v_next text;
202
138
  begin
203
139
  select * into c from @SCHEMA@.ctx();
204
140
  if c.account is null then
205
- perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
141
+ perform @SCHEMA@.lt_fail('sql-time.no_session');
206
142
  end if;
207
143
  rd := @SCHEMA@.acl_actor('READ');
208
144
  l := @SCHEMA@.last_version_visible(p_id, c.tenant, rd);
209
- if l.op <> 'delete' or exists (select 1 from @SCHEMA@.entity e where e.id = p_id) then
210
- perform @SCHEMA@.lt_fail('not_deleted', 'объект не удалён: restore — только для удалённых');
145
+ -- проигравший гонку: блокировка дождалась чужой фиксации, а запись l прочитана до неё. Новая версия видна следующему
146
+ -- оператору (read committed) — тогда последняя запись берётся заново, под той же блокировкой и проверкой видимости
147
+ if exists (select 1 from @SCHEMA@.log x where x.id = p_id and x.tenant = c.tenant and x.rev > l.rev) then
148
+ l := @SCHEMA@.last_version_visible(p_id, c.tenant, rd);
149
+ end if;
150
+ v_live := exists (select 1 from @SCHEMA@.entity e where e.id = p_id);
151
+ if l.op <> 'delete' or v_live or (p_rev is not null and l.rev <> p_rev) then
152
+ -- что за последним надгробием: restore — уже восстановлен, create — id занял новый объект с тем же ключом
153
+ select max(x.rev) into v_tomb from @SCHEMA@.log x where x.id = p_id and x.tenant = c.tenant and x.op = 'delete';
154
+ select x.op into v_next from @SCHEMA@.log x where x.id = p_id and x.tenant = c.tenant and x.rev > v_tomb order by x.rev limit 1;
155
+ if v_live and v_next = 'restore' then
156
+ perform @SCHEMA@.lt_fail('sql-time.restore_already', jsonb_build_object('id', p_id), jsonb_build_object('id', p_id, 'rev', l.rev));
157
+ elsif v_live and v_next = 'create' then
158
+ perform @SCHEMA@.lt_fail('sql-time.restore_id_taken', jsonb_build_object('id', p_id), jsonb_build_object('id', p_id, 'rev', l.rev));
159
+ elsif p_rev is null then
160
+ perform @SCHEMA@.lt_fail('sql-time.restore_not_deleted');
161
+ end if;
162
+ -- удалён снова, не удалялся, гонка: что было сразу после показанного удаления
163
+ select x.op into v_next from @SCHEMA@.log x where x.id = p_id and x.tenant = c.tenant and x.rev > p_rev order by x.rev limit 1;
164
+ perform @SCHEMA@.lt_fail('sql-time.restore_stale',
165
+ jsonb_build_object('id', p_id, 'rev', l.rev, 'expected', p_rev, 'after', coalesce(v_next, '—')),
166
+ jsonb_build_object('id', p_id, 'rev', l.rev, 'expected', p_rev, 'after', v_next, 'deleted', l.op = 'delete', 'live', v_live));
211
167
  end if;
212
168
  if l.class = 'Class' then
213
- perform @SCHEMA@.lt_fail('invalid_class', 'описание класса восстанавливается созданием строки Class');
169
+ perform @SCHEMA@.lt_fail('sql-time.restore_class_row');
214
170
  end if;
215
171
  cls := @SCHEMA@.class_row(l.class, l.tenant);
216
172
  if cls.id is null then
217
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', l.class));
173
+ perform @SCHEMA@.lt_fail('sql-time.class_not_found', jsonb_build_object('cls', l.class));
218
174
  end if;
219
175
  perform @SCHEMA@.family_lock(cls, false);
220
176
  cls := @SCHEMA@.class_row(l.class, l.tenant);
221
177
  if cls.id is null then
222
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', l.class));
178
+ perform @SCHEMA@.lt_fail('sql-time.class_deleted', jsonb_build_object('cls', l.class));
223
179
  end if;
224
180
  res := cls.data->'resolved';
225
181
  if coalesce((res->>'abstract')::boolean, false) then
226
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', l.class));
227
- end if;
228
- if res ? 'derivedBy' then
229
- perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', l.class, res->>'derivedBy'));
182
+ perform @SCHEMA@.lt_fail('sql-time.class_abstract', jsonb_build_object('cls', l.class));
230
183
  end if;
231
184
  d := @SCHEMA@.apply_defaults(res->'schema', l.data);
232
185
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
233
186
  if bad <> '[]'::jsonb then
234
- perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', l.class), jsonb_build_object('issues', bad));
187
+ perform @SCHEMA@.lt_fail('sql-time.invalid_data', jsonb_build_object('cls', l.class), jsonb_build_object('issues', bad));
235
188
  end if;
236
189
  select * into lk from @SCHEMA@.check_links(res, l.links, l.tenant, true, rd);
237
- if lk.code is not null then
238
- perform @SCHEMA@.lt_fail(lk.code, lk.msg);
190
+ if lk.key is not null then
191
+ perform @SCHEMA@.lt_fail(lk.key, lk.params);
239
192
  end if;
240
193
  kid := @SCHEMA@.key_id(res, l.class, l.tenant, d, lk.links);
241
194
  if kid is not null and kid <> p_id then
242
- perform @SCHEMA@.lt_fail('id_mismatch', format('ключ класса %s изменился: id объекта не равен v5 от ключа', l.class), jsonb_build_object('expected', kid));
195
+ perform @SCHEMA@.lt_fail('sql-time.restore_key_changed', jsonb_build_object('cls', l.class), jsonb_build_object('expected', kid));
243
196
  end if;
244
197
  -- владелец — существующий аккаунт или арендатор
245
198
  own := l.owner;
@@ -247,16 +200,13 @@ begin
247
200
  own := l.tenant;
248
201
  end if;
249
202
  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('acl_denied', format('нет права WRITE на строку класса %s', l.class));
203
+ perform @SCHEMA@.lt_fail('sql-time.no_write_row', jsonb_build_object('cls', l.class));
251
204
  end if;
252
205
  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, moved, prev_hash, validator)
206
+ insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, validator)
254
207
  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, l.hash, null)
208
+ nullif(current_setting('letopis.reason', true), ''), null)
256
209
  returning * into n;
257
- -- агрегатные поля восстановленной цели триггер после вставки посчитал из живых источников в эту же версию, а не
258
- -- взял из надгробия (план 1.1, §2.4): в журнал идёт строка таблицы
259
- select x.* into n from @SCHEMA@.entity x where x.id = p_id;
260
210
  perform @SCHEMA@.log_version(n);
261
211
  if l.class in ('rule', 'Resource', 'member', 'Account') then
262
212
  perform @SCHEMA@.acl_bump();
@@ -264,360 +214,6 @@ begin
264
214
  return p_id;
265
215
  end $$;
266
216
 
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
217
  -- ========================= удаление аккаунта =========================
622
218
 
623
219
  -- Удаление аккаунта (этап 6, п. 3; accounts.purge 0.21): вызывающий — системный администратор или
@@ -644,10 +240,10 @@ declare
644
240
  begin
645
241
  select * into c from @SCHEMA@.ctx();
646
242
  if c.account is null then
647
- perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
243
+ perform @SCHEMA@.lt_fail('sql-time.no_session');
648
244
  end if;
649
245
  if p_account = c.account then
650
- perform @SCHEMA@.lt_fail('acl_denied', 'удалить себя нельзя');
246
+ perform @SCHEMA@.lt_fail('sql-time.purge_self');
651
247
  end if;
652
248
  -- право — до поиска и блокировки цели: без права ответ один (acl_denied), есть ли такой аккаунт,
653
249
  -- не видно, и чужую строку Account вызов не блокирует (итоговое ревью безопасности)
@@ -655,11 +251,11 @@ begin
655
251
  select 1 from @SCHEMA@.entity x
656
252
  where x.id = @SCHEMA@.v5_id(p_account, 'member', array[c.account::text, p_account::text]) and x.class = 'member'
657
253
  and x.tenant = p_account and coalesce(x.data->'roles' ? 'owner', false))) then
658
- perform @SCHEMA@.lt_fail('acl_denied', 'удалить аккаунт может системный администратор или владелец его арендатора');
254
+ perform @SCHEMA@.lt_fail('sql-time.account_purge_denied');
659
255
  end if;
660
256
  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
257
  if a.id is null or p_account = '@SYSTEM@'::uuid then
662
- perform @SCHEMA@.lt_fail('target_not_found', 'аккаунт не найден');
258
+ perform @SCHEMA@.lt_fail('sql-time.account_not_found');
663
259
  end if;
664
260
  -- владелец стирает арендатора, в котором работает: его членство уйдёт вместе с данными, а надгробия и
665
261
  -- передача владения ниже перечитывают ctx() — остаток операции идёт в личном арендаторе вызывающего,
@@ -677,7 +273,7 @@ begin
677
273
  join @SCHEMA@.entity u on u.id = (x.links->>'User')::uuid and u.class = 'Account' and u.tenant = '@SYSTEM@'::uuid
678
274
  where x.class = 'member' and x.tenant = t.tenant and x.links->>'User' <> p_account::text
679
275
  and coalesce(x.data->'roles' ? 'owner', false) and coalesce((u.data->>'enabled')::boolean, true)) then
680
- perform @SCHEMA@.lt_fail('acl_denied', format('аккаунт — последний владелец арендатора %s', t.tenant), jsonb_build_object('tenant', t.tenant));
276
+ perform @SCHEMA@.lt_fail('sql-time.last_owner', jsonb_build_object('tenant', t.tenant), jsonb_build_object('tenant', t.tenant));
681
277
  end if;
682
278
  end loop;
683
279
  -- данные арендатора-цели — физически, вместе с журналом
@@ -696,7 +292,7 @@ begin
696
292
  select coalesce(jsonb_agg(jsonb_build_object('id', x.id, 'class', x.class, 'tenant', x.tenant)), '[]'::jsonb) into blockers
697
293
  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
294
  if blockers <> '[]'::jsonb then
699
- perform @SCHEMA@.lt_fail('delete_restricted', 'на аккаунт ссылаются другие строки', @SCHEMA@.visible_detail(blockers, c.tenant));
295
+ perform @SCHEMA@.lt_fail('sql-time.account_referenced', '{}'::jsonb, @SCHEMA@.visible_detail(blockers, c.tenant));
700
296
  end if;
701
297
  -- сессии аккаунта гасит с сигналом триггер до удаления (trg_entity_bd_account, срабатывает и для владельца), а его
702
298
  -- строки secret — сессии и креды — стирает каскад внешнего ключа secret.account (ревизия 7)
@@ -709,541 +305,106 @@ begin
709
305
  return jsonb_build_object('account', p_account, 'rows', n_rows, 'versions', n_log, 'memberships', n_mem, 'owned', n_own);
710
306
  end $$;
711
307
 
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
308
  -- ========================= обслуживание =========================
801
309
 
802
- -- Одна пачка прореживания журнала (§2.11): версии, которые не нужны действующей политике хранения
803
- -- своего класса — как его резолвит class_row: свой класс арендатора, иначе System; у арендатора со своим одноимённым
804
- -- классом политика класса System не действует (план 2.0, 7.3 №1: класс System, заведённый позже, дублировал строки
805
- -- журнала в отборе и стирал промежуточные версии). Не трогаются версия 1, текущая и версии особых видов (create, delete, reclass,
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
310
+ -- Обслуживание (этап 6, п. 8): истёкшие и погашенные сессии и одноразовые коды, строки-замки удалённых семейств,
311
+ -- заполнение очереди сигналов. Вызывает системный администратор, роль с правом letopis.maintain или администраторское
312
+ -- подключение. Один исполнитель: обслуживание идёт одной транзакцией под строкой-замком; занята — null.
313
+ create or replace function @SCHEMA@.maintain() returns jsonb
920
314
  language plpgsql volatile security definer
921
315
  set search_path = pg_catalog, @SCHEMA@, pg_temp
922
316
  as $$
923
317
  declare
924
- ts timestamptz := clock_timestamp();
925
318
  s int;
926
319
  o int;
927
320
  fam text := '@SCHEMA_NAME@:family:';
928
321
  begin
929
322
  if not @SCHEMA@.letopis_allowed('letopis.maintain') then
930
- perform @SCHEMA@.lt_fail('acl_denied', 'нет права letopis.maintain');
323
+ perform @SCHEMA@.lt_fail('sql-time.maintain_denied');
931
324
  end if;
932
- -- один исполнитель: строка-замок до конца транзакции пачки (lock_try) — посторонняя роль её не займёт (ревью
325
+ -- один исполнитель: строка-замок до конца транзакции (lock_try) — посторонняя роль её не займёт (ревью
933
326
  -- безопасности №3, №2); занята — null, обслуживание продолжит держатель
934
327
  if not @SCHEMA@.lock_try('@SCHEMA_NAME@:maintain') then
935
328
  return null;
936
329
  end if;
937
- if p_now is not null then
938
- if not @ALLOW_RESET@ then
939
- perform @SCHEMA@.lt_fail('reset_disabled', 'maintain(now) — только в установке с allow_reset');
940
- end if;
941
- ts := p_now;
942
- end if;
943
- s := 0;
944
- o := 0;
945
- if coalesce(p_cleanup, true) then
946
- with d as (delete from @SCHEMA@.secret x where x.kind = 'SESSION'
947
- and (x.deleted_at is not null or x.expires_at <= clock_timestamp()) returning 1)
948
- select count(*)::int into s from d;
949
- -- коды всех видов, которые выпускает auth_issue_otp: OTP и OTP_<имя> (тот же шаблон вида), — использованные,
950
- -- заменённые, исчерпавшие попытки (deleted_at) и истёкшие
951
- with d as (delete from @SCHEMA@.secret x where x.kind ~ '^OTP(_[A-Z0-9]{1,27})?$'
952
- and (x.deleted_at is not null or x.expires_at <= clock_timestamp()) returning 1)
953
- select count(*)::int into o from d;
954
- -- строки-замки семейств, чьего корня больше нет — удалён класс или его арендатор (план 2.0, 7.7 №8, долгий
955
- -- прогон): их заранее заводит запись строки Class (family_rows), а сами они не уходили — заведение и удаление
956
- -- классов копило их без предела. Корень жив — строка Class с этим именем у арендатора семейства (family_name;
957
- -- в имени класса двоеточия нет). Занятую строку не трогаем (skip locked), а строку-замок семейства — только вместе
958
- -- с её шлюзом или без шлюза: исключительная блокировка берёт шлюз первым, и строка-замок, до которой она ещё не
959
- -- дошла, у неё не пропадёт. Пропавшую строку lock_row и family_lock заводят заново, если класс с этим именем
960
- -- появится снова.
961
- with stale as (
962
- select l.id, l.meta->>'name' as n
963
- from @SCHEMA@.secret l
964
- where l.kind = 'LOCK' and l.account = '@SYSTEM@'::uuid and l.deleted_at is null
965
- and left(l.meta->>'name', length(fam)) = fam
966
- and not exists (
967
- select 1 from @SCHEMA@.entity c
968
- where c.class = 'Class'
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);
330
+ with d as (delete from @SCHEMA@.secret x where x.kind = 'SESSION'
331
+ and (x.deleted_at is not null or x.expires_at <= clock_timestamp()) returning 1)
332
+ select count(*)::int into s from d;
333
+ -- коды всех видов, которые выпускает auth_issue_otp: OTP и OTP_<имя> (тот же шаблон вида), — использованные,
334
+ -- заменённые, исчерпавшие попытки (deleted_at) и истёкшие
335
+ with d as (delete from @SCHEMA@.secret x where x.kind ~ '^OTP(_[A-Z0-9]{1,27})?$'
336
+ and (x.deleted_at is not null or x.expires_at <= clock_timestamp()) returning 1)
337
+ select count(*)::int into o from d;
338
+ -- строки-замки семейств, чьего корня больше нет — удалён класс или его арендатор (план 2.0, 7.7 №8, долгий
339
+ -- прогон): их заранее заводит запись строки Class (family_rows), а сами они не уходили — заведение и удаление
340
+ -- классов копило их без предела. Корень жив — строка Class с этим именем у арендатора семейства (family_name;
341
+ -- в имени класса двоеточия нет). Занятую строку не трогаем (skip locked), а строку-замок семейства — только вместе
342
+ -- с её шлюзом или без шлюза: исключительная блокировка берёт шлюз первым, и строка-замок, до которой она ещё не
343
+ -- дошла, у неё не пропадёт. Пропавшую строку lock_row и family_lock заводят заново, если класс с этим именем
344
+ -- появится снова.
345
+ with stale as (
346
+ select l.id, l.meta->>'name' as n
347
+ from @SCHEMA@.secret l
348
+ where l.kind = 'LOCK' and l.account = '@SYSTEM@'::uuid and l.deleted_at is null
349
+ and left(l.meta->>'name', length(fam)) = fam
350
+ and not exists (
351
+ select 1 from @SCHEMA@.entity c
352
+ where c.class = 'Class'
353
+ and c.data->>'name' = split_part(substr(l.meta->>'name', length(fam) + 1), ':', 2)
354
+ and c.tenant::text = split_part(substr(l.meta->>'name', length(fam) + 1), ':', 1))
355
+ for update of l skip locked)
356
+ delete from @SCHEMA@.secret x using stale st
357
+ where x.id = st.id
358
+ and (st.n like '%:gate'
359
+ or exists (select 1 from stale g where g.n = st.n || ':gate')
360
+ or not exists (select 1 from @SCHEMA@.secret y where y.id = @SCHEMA@.lock_id(st.n || ':gate')));
361
+ return jsonb_build_object('sessions', s, 'codes', o, 'queue', pg_notification_queue_usage());
1115
362
  end $$;
1116
363
 
1117
364
  -- ========================= подписка =========================
1118
365
 
1119
366
  -- Пачка событий подписки (этап 6, п. 5): версии журнала после курсора (tx, seq) ниже горизонта
1120
367
  -- завершённых транзакций (pg_snapshot_xmin) — их уже ничто не обгонит, поэтому без потерь и повторов.
1121
- -- Выполняется с правами вызывающего: RLS журнала показывает только своё. p_at — время последнего
1122
- -- события курсора: старше яруса all какого-либо класса подписки — cursor_expired (события могли
1123
- -- проредиться). p_classes null — все классы. p_snap — снимок момента подписки «now»: транзакции,
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)
368
+ -- Выполняется с правами вызывающего: RLS журнала показывает только своё. p_classes null — все классы. p_snap — снимок
369
+ -- момента подписки «now»: транзакции, видимые в нём (зафиксированные до подписки), не отдаются.
370
+ create or replace function @SCHEMA@.watch_read(p_tx xid8, p_seq bigint, p_classes text[], p_limit int,
371
+ p_snap pg_snapshot default null)
1129
372
  returns setof @SCHEMA@.log
1130
373
  language plpgsql stable
1131
374
  set search_path = pg_catalog, @SCHEMA@, pg_temp
1132
375
  as $$
1133
376
  declare
1134
377
  horizon xid8 := pg_snapshot_xmin(pg_current_snapshot());
1135
- lim interval;
1136
- t uuid := @SCHEMA@.ctx_tenant();
1137
378
  begin
1138
- if p_at is not null then
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
379
+ return query select l.*
1201
380
  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
381
  where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon
1207
- and (p_upto_tx is null or (l.tx, l.seq) <= (p_upto_tx, p_upto_seq))
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'
382
+ and (p_classes is null or l.class = any (p_classes))
1210
383
  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
384
  order by l.tx, l.seq limit p_limit;
1214
385
  end $$;
1215
386
 
1216
387
  -- Точка подписки «с этого момента»: курсор — горизонт завершённых транзакций, снимок — чтобы не
1217
388
  -- отдавать транзакции, зафиксированные до подписки, но ещё не ушедшие за горизонт.
1218
- drop function if exists @SCHEMA@.watch_head();
1219
389
  create or replace function @SCHEMA@.watch_head(out tx xid8, out seq bigint, out snap pg_snapshot)
1220
390
  language sql stable
1221
391
  set search_path = pg_catalog, @SCHEMA@, pg_temp
1222
392
  as $$
1223
393
  select pg_snapshot_xmin(s), 0::bigint, s from pg_current_snapshot() s
1224
394
  $$;
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
395
 
1229
396
  do $$
1230
397
  declare f text;
1231
398
  begin
1232
399
  foreach f in array array[
1233
- 'history_default()', 'history_policy(jsonb)', 'history_iv(text)', 'history_horizon(jsonb)',
1234
- 'history_keep(jsonb, timestamp, timestamp, timestamp)', 'session_role()', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
1235
- 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'projector_unread(uuid, uuid, int)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
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
400
+ 'session_role()', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
401
+ 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid, int)', 'account_purge(uuid)', 'maintain()',
402
+ 'watch_read(xid8, bigint, text[], int, pg_snapshot)', 'watch_head()'] loop
1239
403
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
1240
404
  execute format('revoke all on function @SCHEMA@.%s from public', f);
1241
405
  end loop;
1242
406
  end $$;
1243
- -- приложению: функции владельца этапа 6 (проверяют права сами), подписка (под RLS) и чистые помощники политики
1244
- grant execute on function @SCHEMA@.restore(uuid), @SCHEMA@.purge(uuid, boolean), @SCHEMA@.trim_history(uuid[], text[], timestamptz),
1245
- @SCHEMA@.rekey(uuid, jsonb, jsonb), @SCHEMA@.rekey_class(text, jsonb), @SCHEMA@.account_purge(uuid),
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),
407
+ -- приложению: функции владельца этапа 6 (проверяют права сами) и подписка (под RLS)
408
+ grant execute on function @SCHEMA@.restore(uuid, int), @SCHEMA@.account_purge(uuid), @SCHEMA@.maintain(),
409
+ @SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, text[], int, pg_snapshot), @SCHEMA@.watch_head(),
1249
410
  @SCHEMA@.signal_mode(), @SCHEMA@.signal_key(), @SCHEMA@.class_stamp() to @APP@;