letopis 1.2.0 → 1.4.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/sql/90-time.sql CHANGED
@@ -80,13 +80,22 @@ begin
80
80
  return false;
81
81
  end $$;
82
82
 
83
+ -- Действующая роль сеанса, под которой вызывающий работает вне функций владельца: роль SET ROLE (и параметра запуска
84
+ -- role), иначе роль входа. Внутри функции владельца current_user — владелец, а session_user — роль входа, даже если
85
+ -- поверх неё поставлена роль приложения: привилегированный вход с ролью приложения выглядел бы администратором
86
+ -- (автоматическое ревью коммита 1734d21). Подменить её нельзя: SET ROLE проверяет членство.
87
+ create or replace function @SCHEMA@.session_role() returns name
88
+ language sql stable
89
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
90
+ return coalesce(nullif(current_setting('role'), 'none'), session_user)::name;
91
+
83
92
  -- Роль подключения — администраторская (суперпользователь или может стать владельцем): verify,
84
93
  -- reset и maintain доступны ей и без сессии (CLI, обслуживание по расписанию).
85
94
  create or replace function @SCHEMA@.admin_role() returns boolean
86
95
  language sql stable
87
96
  set search_path = pg_catalog, @SCHEMA@, pg_temp
88
- return exists (select 1 from pg_roles where rolname = session_user and rolsuper)
89
- or pg_has_role(session_user, '@OWNER_NAME@', 'SET');
97
+ return exists (select 1 from pg_roles where rolname = @SCHEMA@.session_role() and rolsuper)
98
+ or pg_has_role(@SCHEMA@.session_role(), '@OWNER_NAME@', 'SET');
90
99
 
91
100
  -- Право letopis.* актора сессии по правилам System или администраторская роль подключения.
92
101
  create or replace function @SCHEMA@.letopis_allowed(p_endpoint text) returns boolean
@@ -209,6 +218,9 @@ begin
209
218
  end if;
210
219
  perform @SCHEMA@.family_lock(cls, false);
211
220
  cls := @SCHEMA@.class_row(l.class, l.tenant);
221
+ if cls.id is null then
222
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', l.class));
223
+ end if;
212
224
  res := cls.data->'resolved';
213
225
  if coalesce((res->>'abstract')::boolean, false) then
214
226
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', l.class));
@@ -256,6 +268,39 @@ end $$;
256
268
  -- все версии удаляются, а на место версии 1 встаёт запись purge с числом стёртых версий и хэшем
257
269
  -- последней. Право DELETE на класс по последнему снимку. История системных классов — аудит прав и
258
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
+
259
304
  create or replace function @SCHEMA@.purge(p_id uuid, p_confirm boolean default false) returns jsonb
260
305
  language plpgsql volatile security definer
261
306
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -279,22 +324,9 @@ begin
279
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
280
325
  perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s', l.class));
281
326
  end if;
282
- -- проектор источника, чей курсор ещё не прошёл надгробие (в том числе надгробие этой транзакции), об удалении не
283
- -- узнал бы — вклад строки остался бы в итогах навсегда (ревью безопасности №3, №5). Проектор касается арендатора, у
284
- -- которого есть его производный класс (derivedBy — имя проектора): брошенный проектор с удалёнными производными
285
- -- классами purge не держит. Курсор «tx:seq~снимок»: транзакции, видимые в снимке, учла перестройка. Чекпоинт без from
286
- -- (до 1.2.0) касается всех классов.
287
- if exists (select 1 from @SCHEMA@.secret s
288
- cross join lateral (select split_part(s.meta->>'cursor', '~', 1) as head,
289
- nullif(split_part(s.meta->>'cursor', '~', 2), '') as snap) k
290
- where s.kind = 'PROJECTOR' and s.account = '@SYSTEM@'::uuid and s.deleted_at is null
291
- and coalesce(s.meta->>'cursor', '') <> ''
292
- and exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.tenant = l.tenant
293
- and x.data->'resolved'->>'derivedBy' = s.ident)
294
- and (not s.meta ? 'from' or coalesce((@SCHEMA@.class_row(l.class, l.tenant)).data->'resolved'->'ancestors',
295
- jsonb_build_array(l.class)) ?| array(select jsonb_array_elements_text(s.meta->'from')))
296
- and (l.tx, l.seq) > (split_part(k.head, ':', 1)::xid8, split_part(k.head, ':', 2)::bigint)
297
- and not coalesce(pg_visible_in_snapshot(l.tx, k.snap::pg_snapshot), false)) then
327
+ -- проектор источника, который ещё не прочёл какую-то версию строки (в том числе надгробие этой транзакции), об
328
+ -- удалении не узнал бы — вклад строки остался бы в итогах навсегда (ревью безопасности №3, №5)
329
+ if @SCHEMA@.projector_unread(l.tenant, p_id, null) then
298
330
  perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл удаление: purge — после его run()');
299
331
  end if;
300
332
  select count(*)::int into n from @SCHEMA@.log x where x.id = p_id;
@@ -330,6 +362,7 @@ declare
330
362
  dplan jsonb;
331
363
  r @SCHEMA@.log;
332
364
  first @SCHEMA@.log;
365
+ nxt record;
333
366
  j int;
334
367
  h bytea;
335
368
  k int;
@@ -374,6 +407,13 @@ begin
374
407
  select x.* into first from @SCHEMA@.log x where x.id = r.id order by x.rev limit 1;
375
408
  -- до момента — только сама запись trim: стирать нечего
376
409
  continue when j = 1 and first.op = 'trim';
410
+ -- проектор источника, который ещё не прочёл стираемые версии или первую оставшуюся: читая её, он возьмёт прошлую
411
+ -- версию для before, а её уже не будет — свёртка прибавила бы новую, не вычитая прежнюю (хвост ревью безопасности
412
+ -- №3); каждая версия до первой оставшейся — у более поздней версии бывает меньший xid
413
+ select x.rev into nxt from @SCHEMA@.log x where x.id = r.id and x.rev > j order by x.rev limit 1;
414
+ if @SCHEMA@.projector_unread(r.tenant, r.id, nxt.rev) then
415
+ perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл версии: trimHistory — после его run()');
416
+ end if;
377
417
  select x.hash into h from @SCHEMA@.log x where x.id = r.id and x.rev = j;
378
418
  with d as (delete from @SCHEMA@.log x where x.id = r.id and x.rev <= j returning 1)
379
419
  select count(*)::int into k from d;
@@ -434,6 +474,9 @@ begin
434
474
  cls := @SCHEMA@.class_row(o.class, o.tenant);
435
475
  perform @SCHEMA@.family_lock(cls, false);
436
476
  cls := @SCHEMA@.class_row(o.class, o.tenant);
477
+ if cls.id is null then
478
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', o.class));
479
+ end if;
437
480
  res := cls.data->'resolved';
438
481
  if res ? 'derivedBy' then
439
482
  perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', o.class, res->>'derivedBy'));
@@ -1034,6 +1077,54 @@ begin
1034
1077
  order by l.tx, l.seq limit p_limit;
1035
1078
  end $$;
1036
1079
 
1080
+ -- Уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3): версия пишется уже с новым
1081
+ -- классом, и подписка по классам её не видит. Здесь — те же окно, горизонт и снимок, что у watch_read (и верхняя граница
1082
+ -- окна p_upto — последняя строка полной пачки watch_read), а строки — синтетические надгробия прежней версии: класс,
1083
+ -- концы и данные прежние; номер, время, автор, tx и seq — версии смены класса; вид delete; агента и причины нет. Только
1084
+ -- если нового класса нет в p_classes, а прежнюю версию актор видит (row_visible с его планом READ): о строке, которой он
1085
+ -- не читал, подписчик не узнает. Функция владельца: версию с новым классом актору видеть не нужно. Подключение в обход
1086
+ -- RLS (суперпользователь, BYPASSRLS, член роли-владельца с наследованием — connect() пускает его только с
1087
+ -- allowBypassRls) видит в watch_read журнал всех арендаторов, поэтому и события ухода получает все, с автором (хвост
1088
+ -- ревью доработок после 1.2.0). Роль — действующая роль сеанса (session_role): та, к которой watch_read применяет RLS.
1089
+ create or replace function @SCHEMA@.watch_leaving(p_tx xid8, p_seq bigint, p_classes text[], p_limit int,
1090
+ p_snap pg_snapshot default null, p_upto_tx xid8 default null, p_upto_seq bigint default null)
1091
+ returns setof @SCHEMA@.log
1092
+ language plpgsql stable security definer
1093
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1094
+ as $$
1095
+ declare
1096
+ horizon xid8 := pg_snapshot_xmin(pg_current_snapshot());
1097
+ a jsonb;
1098
+ bypass boolean;
1099
+ begin
1100
+ if p_classes is null then
1101
+ return;
1102
+ end if;
1103
+ bypass := exists (select 1 from pg_roles r where r.rolname = @SCHEMA@.session_role() and (r.rolsuper or r.rolbypassrls))
1104
+ or pg_has_role(@SCHEMA@.session_role(), '@OWNER_NAME@', 'USAGE');
1105
+ a := case when not bypass then @SCHEMA@.acl_actor('READ') end;
1106
+ if a is null and not bypass then
1107
+ return;
1108
+ end if;
1109
+ -- новую версию актор не видит — надгробие при любом p_classes и без автора: перебором классов подписки он узнал бы её
1110
+ -- класс (автоматическое ревью доработок после 1.2.0)
1111
+ 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,
1112
+ case when v.seen then l.author end, null::text, 'delete'::text, null::text, null::uuid, null::bytea, null::bytea, null::jsonb
1113
+ from @SCHEMA@.log l
1114
+ 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')
1115
+ order by x.rev desc limit 1) p
1116
+ cross join lateral (select bypass or @SCHEMA@.row_visible(l.tenant, l.class, l.id, l.owner, l.tags, l.data, l.links,
1117
+ (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan') as seen) v
1118
+ where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon
1119
+ and (p_upto_tx is null or (l.tx, l.seq) <= (p_upto_tx, p_upto_seq))
1120
+ and l.op = 'reclass' and l.moved is null and (not (l.class = any (p_classes)) or not v.seen)
1121
+ and p.class = any (p_classes) and p.op <> 'delete'
1122
+ and (p_snap is null or not pg_visible_in_snapshot(l.tx, p_snap))
1123
+ and (bypass or @SCHEMA@.row_visible(p.tenant, p.class, p.id, p.owner, p.tags, p.data, p.links,
1124
+ (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan'))
1125
+ order by l.tx, l.seq limit p_limit;
1126
+ end $$;
1127
+
1037
1128
  -- Точка подписки «с этого момента»: курсор — горизонт завершённых транзакций, снимок — чтобы не
1038
1129
  -- отдавать транзакции, зафиксированные до подписки, но ещё не ушедшие за горизонт.
1039
1130
  drop function if exists @SCHEMA@.watch_head();
@@ -1052,11 +1143,11 @@ declare f text;
1052
1143
  begin
1053
1144
  foreach f in array array[
1054
1145
  'history_default()', 'history_policy(jsonb)', 'history_iv(text)', 'history_horizon(jsonb)',
1055
- 'history_keep(jsonb, timestamp, timestamp, timestamp)', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
1056
- 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
1146
+ 'history_keep(jsonb, timestamp, timestamp, timestamp)', 'session_role()', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
1147
+ 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'projector_unread(uuid, uuid, int)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
1057
1148
  'rekey(uuid, jsonb, jsonb)', 'rekey_class(text, jsonb)', 'account_purge(uuid)', 'reset_mark(uuid, text, uuid)',
1058
1149
  'reset(text, text, uuid, int)', 'thin_batch(timestamptz, int)', 'maintain(timestamptz, int)', 'verify(boolean)',
1059
- 'watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean)', 'watch_head()'] loop
1150
+ 'watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean)', 'watch_leaving(xid8, bigint, text[], int, pg_snapshot, xid8, bigint)', 'watch_head()'] loop
1060
1151
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
1061
1152
  execute format('revoke all on function @SCHEMA@.%s from public', f);
1062
1153
  end loop;
@@ -1065,6 +1156,6 @@ end $$;
1065
1156
  grant execute on function @SCHEMA@.restore(uuid), @SCHEMA@.purge(uuid, boolean), @SCHEMA@.trim_history(uuid[], text[], timestamptz),
1066
1157
  @SCHEMA@.rekey(uuid, jsonb, jsonb), @SCHEMA@.rekey_class(text, jsonb), @SCHEMA@.account_purge(uuid),
1067
1158
  @SCHEMA@.reset(text, text, uuid, int), @SCHEMA@.maintain(timestamptz, int), @SCHEMA@.verify(boolean),
1068
- @SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean), @SCHEMA@.watch_head(),
1159
+ @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(),
1069
1160
  @SCHEMA@.history_default(), @SCHEMA@.history_policy(jsonb), @SCHEMA@.history_iv(text),
1070
1161
  @SCHEMA@.signal_mode(), @SCHEMA@.signal_key(), @SCHEMA@.class_stamp() to @APP@;
@@ -1,3 +1,4 @@
1
1
  -- letopis 1.0 — штамп ревизии движка (план, §2.2, этап 1, п. 11): последний оператор наката.
2
- -- Номер совпадает с DDL_REVISION в lib/src/types.ts (проверяет check-docs).
3
- comment on schema @SCHEMA@ is 'letopis ddl_revision=6';
2
+ -- Номер совпадает с DDL_REVISION в lib/src/types.ts (проверяет check-docs). С 1.4 в штампе ещё версия пакета и хэш
3
+ -- файлов ядра (engine, sql): connect() предупреждает, если функции схемы ставила другая версия движка той же ревизии.
4
+ comment on schema @SCHEMA@ is 'letopis ddl_revision=6 engine=@ENGINE_VERSION@ sql=@ENGINE_SQL@';
package/sql/README.md CHANGED
@@ -17,6 +17,8 @@
17
17
  | `@HISTORY_DEFAULT@` | политика хранения по умолчанию `up({ history })` литералом JSON или `'null'` — константа установки |
18
18
  | `@RESERVED_NAMES@` | методы цепочки — `RESERVED_CLASS_NAMES` из `lib/src/types.ts`, литералами через запятую |
19
19
  | `@SIGNALS@` | режим сигналов `class`, `tenant`, `opaque` или `none` (план 1.1, §2.2): опция `up({ signals })`, иначе сохранённый в `meta.signals` строки ключа `secret`, иначе `class` — константа установки |
20
+ | `@ENGINE_VERSION@` | версия пакета (`lib/package.json`) — штамп движка в комментарии схемы (`99-revision.sql`) |
21
+ | `@ENGINE_SQL@` | 16 знаков sha256 исходного текста файлов `NN-*.sql` до подстановок (переводы строк — LF): `connect()` сверяет его с файлами своего пакета |
20
22
 
21
23
  ## Колонки снимка версии
22
24
 
@@ -53,7 +55,7 @@
53
55
  | LT020 | `confirm_mismatch` | подтверждение сброса не совпадает с именем схемы (этап 6) |
54
56
  | LT021 | `derived_class` | строки производного класса (`derivedBy`) пишет и удаляет только его проектор; на них не ссылаются концы (план 1.1, §2.4) |
55
57
  | LT022 | `aggregate_field` | агрегатное поле (`aggregate`) считает база: приложение и сырой SQL его не передают и не меняют (план 1.1, §2.4) |
56
- | LT023 | `projector_pending` | `purge` удалённого объекта, чьё надгробие ещё не прочёл проектор его класса (ревью безопасности №3, №5) |
58
+ | LT023 | `projector_pending` | `purge` удалённого объекта, чьё надгробие ещё не прочёл проектор его класса (ревью безопасности №3, №5), или `trim_history`, чью первую оставшуюся версию он ещё не прочёл (хвост ревью №3) |
57
59
 
58
60
  ## Порядок файлов
59
61