letopis 1.1.0 → 1.3.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
@@ -213,6 +213,9 @@ begin
213
213
  if coalesce((res->>'abstract')::boolean, false) then
214
214
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', l.class));
215
215
  end if;
216
+ if res ? 'derivedBy' then
217
+ perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', l.class, res->>'derivedBy'));
218
+ end if;
216
219
  d := @SCHEMA@.apply_defaults(res->'schema', l.data);
217
220
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
218
221
  if bad <> '[]'::jsonb then
@@ -239,6 +242,9 @@ begin
239
242
  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',
240
243
  nullif(current_setting('letopis.reason', true), ''), null, l.hash, null)
241
244
  returning * into n;
245
+ -- агрегатные поля восстановленной цели триггер после вставки посчитал из живых источников в эту же версию, а не
246
+ -- взял из надгробия (план 1.1, §2.4): в журнал идёт строка таблицы
247
+ select x.* into n from @SCHEMA@.entity x where x.id = p_id;
242
248
  perform @SCHEMA@.log_version(n);
243
249
  if l.class in ('rule', 'Resource', 'member', 'Account') then
244
250
  perform @SCHEMA@.acl_bump();
@@ -250,6 +256,28 @@ end $$;
250
256
  -- все версии удаляются, а на место версии 1 встаёт запись purge с числом стёртых версий и хэшем
251
257
  -- последней. Право DELETE на класс по последнему снимку. История системных классов — аудит прав и
252
258
  -- схемы: её не стирают.
259
+ -- Проектор источника ещё не прочёл версию (p_tx, p_seq) строки арендатора p_tenant класса из p_classes (ревью
260
+ -- безопасности №3, №5 — purge; хвост ревью №3 — обрезка истории). Проектор касается арендатора, у которого есть его
261
+ -- производный класс (derivedBy — имя проектора): брошенный проектор с удалёнными производными классами не держит.
262
+ -- Курсор «tx:seq~снимок»: транзакции, видимые в снимке, учла перестройка. Чекпоинт без from (до 1.2.0) касается всех
263
+ -- классов.
264
+ create or replace function @SCHEMA@.projector_behind(p_tenant uuid, p_classes text[], p_tx xid8, p_seq bigint) returns boolean
265
+ language sql stable
266
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
267
+ return exists (select 1 from @SCHEMA@.secret s
268
+ cross join lateral (select split_part(s.meta->>'cursor', '~', 1) as head,
269
+ nullif(split_part(s.meta->>'cursor', '~', 2), '') as snap) k
270
+ where s.kind = 'PROJECTOR' and s.account = '@SYSTEM@'::uuid and s.deleted_at is null
271
+ and coalesce(s.meta->>'cursor', '') <> ''
272
+ and exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.tenant = p_tenant
273
+ and x.data->'resolved'->>'derivedBy' = s.ident)
274
+ and (not s.meta ? 'from'
275
+ or exists (select 1 from unnest(p_classes) c
276
+ where coalesce((@SCHEMA@.class_row(c, p_tenant)).data->'resolved'->'ancestors', jsonb_build_array(c))
277
+ ?| array(select jsonb_array_elements_text(s.meta->'from'))))
278
+ and (p_tx, p_seq) > (split_part(k.head, ':', 1)::xid8, split_part(k.head, ':', 2)::bigint)
279
+ and not coalesce(pg_visible_in_snapshot(p_tx, k.snap::pg_snapshot), false));
280
+
253
281
  create or replace function @SCHEMA@.purge(p_id uuid, p_confirm boolean default false) returns jsonb
254
282
  language plpgsql volatile security definer
255
283
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -273,6 +301,11 @@ begin
273
301
  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
274
302
  perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s', l.class));
275
303
  end if;
304
+ -- проектор источника, чей курсор ещё не прошёл надгробие (в том числе надгробие этой транзакции), об удалении не
305
+ -- узнал бы — вклад строки остался бы в итогах навсегда (ревью безопасности №3, №5)
306
+ if @SCHEMA@.projector_behind(l.tenant, array[l.class], l.tx, l.seq) then
307
+ perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл удаление: purge — после его run()');
308
+ end if;
276
309
  select count(*)::int into n from @SCHEMA@.log x where x.id = p_id;
277
310
  if not coalesce(p_confirm, false) then
278
311
  return jsonb_build_object('id', p_id, 'class', l.class, 'versions', n, 'purged', false);
@@ -306,6 +339,8 @@ declare
306
339
  dplan jsonb;
307
340
  r @SCHEMA@.log;
308
341
  first @SCHEMA@.log;
342
+ nxt record;
343
+ late record;
309
344
  j int;
310
345
  h bytea;
311
346
  k int;
@@ -350,6 +385,17 @@ begin
350
385
  select x.* into first from @SCHEMA@.log x where x.id = r.id order by x.rev limit 1;
351
386
  -- до момента — только сама запись trim: стирать нечего
352
387
  continue when j = 1 and first.op = 'trim';
388
+ -- проектор источника, чей курсор ещё не прошёл первую оставшуюся версию: читая её, он возьмёт прошлую версию для
389
+ -- before, а её уже не будет — свёртка прибавила бы новую, не вычитая прежнюю (хвост ревью безопасности №3). Класс —
390
+ -- обеих версий у стыка: строка могла уйти из семейства источников сменой класса
391
+ select x.rev, x.class into nxt from @SCHEMA@.log x where x.id = r.id and x.rev > j order by x.rev limit 1;
392
+ -- позиция — самая поздняя по (tx, seq) среди стираемых версий и первой оставшейся: у более поздней версии бывает
393
+ -- меньший xid (автоматическое ревью)
394
+ select x.tx, x.seq into late from @SCHEMA@.log x where x.id = r.id and x.rev <= nxt.rev order by x.tx desc, x.seq desc limit 1;
395
+ if @SCHEMA@.projector_behind(r.tenant, array[(select x.class from @SCHEMA@.log x where x.id = r.id and x.rev = j), nxt.class],
396
+ late.tx, late.seq) then
397
+ perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл версии: trimHistory — после его run()');
398
+ end if;
353
399
  select x.hash into h from @SCHEMA@.log x where x.id = r.id and x.rev = j;
354
400
  with d as (delete from @SCHEMA@.log x where x.id = r.id and x.rev <= j returning 1)
355
401
  select count(*)::int into k from d;
@@ -386,6 +432,7 @@ declare
386
432
  lk record;
387
433
  nid uuid;
388
434
  denied text;
435
+ n_denied int;
389
436
  begin
390
437
  select * into c from @SCHEMA@.ctx();
391
438
  if c.account is null then
@@ -410,9 +457,15 @@ begin
410
457
  perform @SCHEMA@.family_lock(cls, false);
411
458
  cls := @SCHEMA@.class_row(o.class, o.tenant);
412
459
  res := cls.data->'resolved';
460
+ if res ? 'derivedBy' then
461
+ perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', o.class, res->>'derivedBy'));
462
+ end if;
413
463
  if jsonb_array_length(coalesce(res->'key', '[]'::jsonb)) = 0 then
414
464
  perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s нет ключа: id выдаёт база, менять нечего', o.class));
415
465
  end if;
466
+ if res ? 'aggregate' and coalesce(p_data, '{}'::jsonb) ?| @SCHEMA@.agg_fields(res) then
467
+ perform @SCHEMA@.lt_fail('aggregate_field', format('агрегатные поля класса %s считает база — в rekey их не передают', o.class));
468
+ end if;
416
469
  d := @SCHEMA@.apply_defaults(res->'schema', @SCHEMA@.merge(o.data, coalesce(p_data, '{}'::jsonb)));
417
470
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
418
471
  if bad <> '[]'::jsonb then
@@ -432,13 +485,16 @@ begin
432
485
  end if;
433
486
  return o.id;
434
487
  end if;
435
- -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE
436
- select min(x.class) into denied from @SCHEMA@.entity x
488
+ -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE (глубже первого уровня — move_row); класс
489
+ -- такой строки называется, только если актор её видит (доработки после точки Б)
490
+ 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')),
491
+ count(*) into denied, n_denied from @SCHEMA@.entity x
437
492
  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);
438
- if denied is not null then
439
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на ссылающиеся строки класса %s', denied));
493
+ if n_denied > 0 then
494
+ perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права WRITE на ссылающиеся строки класса %s', denied)
495
+ else 'нет права WRITE на ссылающиеся строки' end);
440
496
  end if;
441
- perform @SCHEMA@.move_row(o, nid, o.class, d, lk.links, 'rekey');
497
+ perform @SCHEMA@.move_row(o, nid, o.class, d, lk.links, 'rekey', wplan);
442
498
  return nid;
443
499
  end $$;
444
500
 
@@ -462,6 +518,7 @@ declare
462
518
  nid uuid;
463
519
  n int := 0;
464
520
  conflicts jsonb;
521
+ dname text;
465
522
  begin
466
523
  select * into c from @SCHEMA@.ctx();
467
524
  if c.account is null then
@@ -478,9 +535,26 @@ begin
478
535
  if coalesce((cr.data->>'system')::boolean, false) then
479
536
  perform @SCHEMA@.lt_fail('invalid_class', 'ключ системного класса не меняется');
480
537
  end if;
538
+ -- строки производного класса (и его потомков) переносит только владелец: ключ меняют в описании, а строки
539
+ -- строит заново проектор (rebuild)
540
+ if cr.data->'resolved' ? 'derivedBy' then
541
+ perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', p_class, cr.data->'resolved'->>'derivedBy'));
542
+ end if;
543
+ -- и потомки: производный потомок непроизводного предка наследует его ключ, а его строки переносит только проектор
544
+ -- (доработки после точки Б)
545
+ select x.data->>'name' into dname from @SCHEMA@.entity x
546
+ where x.class = 'Class' and x.tenant = cr.tenant and x.id <> cr.id
547
+ and x.data->'resolved'->'ancestors' ? p_class and x.data->'resolved' ? 'derivedBy'
548
+ order by x.data->>'name' limit 1;
549
+ if dname is not null then
550
+ perform @SCHEMA@.lt_fail('derived_class', format('у класса %s производный потомок %s: его строки пишет проектор', p_class, dname));
551
+ end if;
481
552
  if not @SCHEMA@.acl_row_ok(wplan, 'Class', cr.id, cr.owner, cr.tenant, cr.tags, cr.data, cr.links) then
482
553
  perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на класс Class');
483
554
  end if;
555
+ -- смена ключа проверяет все строки семейства и отвечает числом нарушающих — как ужесточение, только с правом READ без
556
+ -- условий на все строки класса и потомков (доработки после точки Б)
557
+ perform @SCHEMA@.class_rows_admit(p_class, cr.tenant, array['READ'], format('смена ключа класса %s', p_class));
484
558
  perform @SCHEMA@.family_lock(cr, true);
485
559
  select x.* into cr from @SCHEMA@.entity x where x.id = cr.id for update;
486
560
  select p.data, p.validator into prep from @SCHEMA@.class_prepare(cr.data || jsonb_build_object('key', p_key), cr.tenant) p;
@@ -507,7 +581,7 @@ begin
507
581
  res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
508
582
  nid := coalesce(@SCHEMA@.key_id(res, r.class, r.tenant, r.data, r.links), r.id);
509
583
  if nid <> r.id then
510
- perform @SCHEMA@.move_row(r, nid, r.class, r.data, r.links, 'rekey');
584
+ perform @SCHEMA@.move_row(r, nid, r.class, r.data, r.links, 'rekey', wplan);
511
585
  n := n + 1;
512
586
  end if;
513
587
  end loop;
@@ -631,6 +705,7 @@ declare
631
705
  n int;
632
706
  m int;
633
707
  key text;
708
+ held boolean;
634
709
  begin
635
710
  if not @ALLOW_RESET@ then
636
711
  perform @SCHEMA@.lt_fail('reset_disabled', 'reset выключен в этой установке (up({ allowReset }))');
@@ -657,9 +732,15 @@ begin
657
732
  if not exists (select 1 from @SCHEMA@.entity a where a.id = t and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
658
733
  perform @SCHEMA@.lt_fail('target_not_found', 'арендатор не найден');
659
734
  end if;
735
+ -- строки арендатора стираются пачками в любом порядке: агрегаты его целей (план 1.1, §2.4) не пересчитываются —
736
+ -- цели уходят вместе с источниками, а промежуточный итог пачки мог бы выйти за правило класса
737
+ held := @SCHEMA@.agg_hold_begin('off');
660
738
  with d as (delete from @SCHEMA@.entity x where x.id in (
661
739
  select y.id from @SCHEMA@.entity y where y.tenant = t limit coalesce(p_limit, 2147483647)) returning 1)
662
740
  select count(*)::int into n from d;
741
+ if held then
742
+ perform @SCHEMA@.agg_hold_end(false);
743
+ end if;
663
744
  with d as (delete from @SCHEMA@.log x where x.seq in (
664
745
  select y.seq from @SCHEMA@.log y where y.tenant = t limit coalesce(p_limit, 2147483647)) returning 1)
665
746
  select count(*)::int into m from d;
@@ -781,6 +862,11 @@ begin
781
862
  if not @SCHEMA@.letopis_allowed('letopis.maintain') then
782
863
  perform @SCHEMA@.lt_fail('acl_denied', 'нет права letopis.maintain');
783
864
  end if;
865
+ -- один исполнитель: строка-замок до конца транзакции пачки (lock_try) — посторонняя роль её не займёт (ревью
866
+ -- безопасности №3, №2); занята — null, обслуживание продолжит держатель
867
+ if not @SCHEMA@.lock_try('@SCHEMA_NAME@:maintain') then
868
+ return null;
869
+ end if;
784
870
  if p_now is not null then
785
871
  if not @ALLOW_RESET@ then
786
872
  perform @SCHEMA@.lt_fail('reset_disabled', 'maintain(now) — только в установке с allow_reset');
@@ -805,7 +891,8 @@ end $$;
805
891
  -- прореживание — пропуск после версии старше яруса all класса, у которого есть (или была) политика
806
892
  -- хранения — своя или умолчание установки (метка default_policy в строке ключа). p_data — ещё и все строки: данные по проверке класса, концы (цель существует, её
807
893
  -- арендатор — тот же или System, класс принимается концом), арендатор и владелец существуют, id
808
- -- класса с ключом — v5 от ключа. Возвращает находки и якорь — хэш всех голов цепочек. Только
894
+ -- класса с ключом — v5 от ключа, агрегатные поля равны итогу живых источников (план 1.1, §2.4; ремонт —
895
+ -- rebuild_aggregates). Возвращает находки и якорь — хэш всех голов цепочек. Только
809
896
  -- системный администратор (право letopis.verify) или администраторское подключение.
810
897
  create or replace function @SCHEMA@.verify(p_data boolean default false) returns jsonb
811
898
  language plpgsql stable security definer
@@ -913,6 +1000,11 @@ begin
913
1000
  from @SCHEMA@.entity k2
914
1001
  where k2.class = 'Class' and k2.data->>'name' = t.class and k2.tenant in (t.tenant, '@SYSTEM@'::uuid)
915
1002
  order by k2.tenant = '@SYSTEM@'::uuid limit 1), '{}')))
1003
+ union all
1004
+ select jsonb_build_object('id', a.id, 'class', a.class, 'field', a.field, 'stored', a.stored, 'expected', a.expected,
1005
+ 'problem', format('агрегат %s не равен итогу живых источников: в строке %s, по источникам %s — ремонт rebuildAggregates',
1006
+ a.field, coalesce(a.stored::text, 'нет значения'), a.expected::text))
1007
+ from @SCHEMA@.agg_issues() a
916
1008
  )
917
1009
  select coalesce(jsonb_agg(f), '[]'::jsonb) into data_issues from found;
918
1010
  end if;
@@ -930,8 +1022,10 @@ end $$;
930
1022
  -- события курсора: старше яруса all какого-либо класса подписки — cursor_expired (события могли
931
1023
  -- проредиться). p_classes null — все классы. p_snap — снимок момента подписки «now»: транзакции,
932
1024
  -- видимые в нём (зафиксированные до подписки), не отдаются.
1025
+ -- p_leaving (проектор, ревью безопасности №3, №4): ещё смены класса без смены id (reclass без moved), уводящие строку из
1026
+ -- p_classes, — версия уже другого класса, а прошлая была в p_classes; иначе уход строки из семейства не виден.
933
1027
  create or replace function @SCHEMA@.watch_read(p_tx xid8, p_seq bigint, p_at timestamptz, p_classes text[], p_limit int,
934
- p_snap pg_snapshot default null)
1028
+ p_snap pg_snapshot default null, p_leaving boolean default false)
935
1029
  returns setof @SCHEMA@.log
936
1030
  language plpgsql stable
937
1031
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -953,8 +1047,54 @@ begin
953
1047
  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,
954
1048
  l.agent, l.op, l.reason, l.moved, l.prev_hash, l.hash, null::jsonb
955
1049
  from @SCHEMA@.log l
956
- where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon and (p_classes is null or l.class = any (p_classes))
1050
+ where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon
1051
+ and (p_classes is null or l.class = any (p_classes)
1052
+ or (p_leaving and l.op = 'reclass' and l.moved is null
1053
+ 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')
1054
+ order by p.rev desc limit 1) = any (p_classes)))
1055
+ and (p_snap is null or not pg_visible_in_snapshot(l.tx, p_snap))
1056
+ order by l.tx, l.seq limit p_limit;
1057
+ end $$;
1058
+
1059
+ -- Уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3): версия пишется уже с новым
1060
+ -- классом, и подписка по классам её не видит. Здесь — те же окно, горизонт и снимок, что у watch_read (и верхняя граница
1061
+ -- окна p_upto — последняя строка полной пачки watch_read), а строки — синтетические надгробия прежней версии: класс,
1062
+ -- концы и данные прежние; номер, время, автор, tx и seq — версии смены класса; вид delete; агента и причины нет. Только
1063
+ -- если нового класса нет в p_classes, а прежнюю версию актор видит (row_visible с его планом READ): о строке, которой он
1064
+ -- не читал, подписчик не узнает. Функция владельца: версию с новым классом актору видеть не нужно.
1065
+ create or replace function @SCHEMA@.watch_leaving(p_tx xid8, p_seq bigint, p_classes text[], p_limit int,
1066
+ p_snap pg_snapshot default null, p_upto_tx xid8 default null, p_upto_seq bigint default null)
1067
+ returns setof @SCHEMA@.log
1068
+ language plpgsql stable security definer
1069
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1070
+ as $$
1071
+ declare
1072
+ horizon xid8 := pg_snapshot_xmin(pg_current_snapshot());
1073
+ a jsonb;
1074
+ begin
1075
+ if p_classes is null then
1076
+ return;
1077
+ end if;
1078
+ a := @SCHEMA@.acl_actor('READ');
1079
+ if a is null then
1080
+ return;
1081
+ end if;
1082
+ -- новую версию актор не видит — надгробие при любом p_classes и без автора: перебором классов подписки он узнал бы её
1083
+ -- класс (автоматическое ревью доработок после 1.2.0)
1084
+ 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,
1085
+ case when v.seen then l.author end, null::text, 'delete'::text, null::text, null::uuid, null::bytea, null::bytea, null::jsonb
1086
+ from @SCHEMA@.log l
1087
+ 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')
1088
+ order by x.rev desc limit 1) p
1089
+ cross join lateral (select @SCHEMA@.row_visible(l.tenant, l.class, l.id, l.owner, l.tags, l.data, l.links,
1090
+ (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan') as seen) v
1091
+ where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon
1092
+ and (p_upto_tx is null or (l.tx, l.seq) <= (p_upto_tx, p_upto_seq))
1093
+ and l.op = 'reclass' and l.moved is null and (not (l.class = any (p_classes)) or not v.seen)
1094
+ and p.class = any (p_classes) and p.op <> 'delete'
957
1095
  and (p_snap is null or not pg_visible_in_snapshot(l.tx, p_snap))
1096
+ and @SCHEMA@.row_visible(p.tenant, p.class, p.id, p.owner, p.tags, p.data, p.links,
1097
+ (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan')
958
1098
  order by l.tx, l.seq limit p_limit;
959
1099
  end $$;
960
1100
 
@@ -967,8 +1107,9 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
967
1107
  as $$
968
1108
  select pg_snapshot_xmin(s), 0::bigint, s from pg_current_snapshot() s
969
1109
  $$;
970
- -- до исправления подписки «now»: без снимка
1110
+ -- до исправления подписки «now»: без снимка; до ревью безопасности №3 — без p_leaving
971
1111
  drop function if exists @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int);
1112
+ drop function if exists @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot);
972
1113
 
973
1114
  do $$
974
1115
  declare f text;
@@ -976,10 +1117,10 @@ begin
976
1117
  foreach f in array array[
977
1118
  'history_default()', 'history_policy(jsonb)', 'history_iv(text)', 'history_horizon(jsonb)',
978
1119
  'history_keep(jsonb, timestamp, timestamp, timestamp)', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
979
- 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
1120
+ 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'projector_behind(uuid, text[], xid8, bigint)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
980
1121
  'rekey(uuid, jsonb, jsonb)', 'rekey_class(text, jsonb)', 'account_purge(uuid)', 'reset_mark(uuid, text, uuid)',
981
1122
  'reset(text, text, uuid, int)', 'thin_batch(timestamptz, int)', 'maintain(timestamptz, int)', 'verify(boolean)',
982
- 'watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot)', 'watch_head()'] loop
1123
+ 'watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean)', 'watch_leaving(xid8, bigint, text[], int, pg_snapshot, xid8, bigint)', 'watch_head()'] loop
983
1124
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
984
1125
  execute format('revoke all on function @SCHEMA@.%s from public', f);
985
1126
  end loop;
@@ -988,6 +1129,6 @@ end $$;
988
1129
  grant execute on function @SCHEMA@.restore(uuid), @SCHEMA@.purge(uuid, boolean), @SCHEMA@.trim_history(uuid[], text[], timestamptz),
989
1130
  @SCHEMA@.rekey(uuid, jsonb, jsonb), @SCHEMA@.rekey_class(text, jsonb), @SCHEMA@.account_purge(uuid),
990
1131
  @SCHEMA@.reset(text, text, uuid, int), @SCHEMA@.maintain(timestamptz, int), @SCHEMA@.verify(boolean),
991
- @SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot), @SCHEMA@.watch_head(),
1132
+ @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(),
992
1133
  @SCHEMA@.history_default(), @SCHEMA@.history_policy(jsonb), @SCHEMA@.history_iv(text),
993
1134
  @SCHEMA@.signal_mode(), @SCHEMA@.signal_key(), @SCHEMA@.class_stamp() to @APP@;
@@ -21,13 +21,19 @@ begin
21
21
  if p_class = 'Class' then
22
22
  select c.data, c.validator into d, vld from @SCHEMA@.class_prepare(d, p_tenant) c;
23
23
  end if;
24
- insert into @SCHEMA@.entity (id, rev, class, tenant, owner, links, data, at, created_at, author, op, validator)
25
- values (p_id, 1, p_class, p_tenant, p_tenant, coalesce(p_links, '{}'), d, now_ts, now_ts, '@SYSTEM@', 'create', vld);
24
+ -- журнал раньше строки: агрегатные поля новой цели триггер после вставки досчитывает в ту же версию — и в entity,
25
+ -- и в уже записанную версию журнала (план 1.1, §2.4)
26
26
  insert into @SCHEMA@.log (id, rev, class, tenant, owner, links, data, tags, at, author, op)
27
27
  values (p_id, 1, p_class, p_tenant, p_tenant, coalesce(p_links, '{}'), d, '{}', now_ts, '@SYSTEM@', 'create');
28
+ insert into @SCHEMA@.entity (id, rev, class, tenant, owner, links, data, at, created_at, author, op, validator)
29
+ values (p_id, 1, p_class, p_tenant, p_tenant, coalesce(p_links, '{}'), d, now_ts, now_ts, '@SYSTEM@', 'create', vld);
28
30
  if p_class in ('rule', 'Resource', 'member', 'Account', 'Class') then
29
31
  perform @SCHEMA@.acl_bump();
30
32
  end if;
33
+ -- новая цель агрегатов: метки её источников — заново
34
+ if p_class = 'Class' and d->'resolved' ? 'aggregate' then
35
+ perform @SCHEMA@.agg_mark(p_tenant, @SCHEMA@.agg_of(d->'resolved'));
36
+ end if;
31
37
  return p_id;
32
38
  end $$;
33
39
  alter function @SCHEMA@.sys_create(uuid, text, uuid, jsonb, jsonb) owner to @OWNER@;
@@ -139,6 +145,10 @@ begin
139
145
  foreach op in array array['READ', 'WRITE', 'DELETE'] loop
140
146
  perform @SCHEMA@.sys_rule(sys, g_admin, @SCHEMA@.sys_resource(sys, 'all:' || op, op, null), 'allow', 100);
141
147
  end loop;
148
+ -- строки-замки (60-write.sql: lock_row) семейств всех классов, в том числе классов арендаторов, заведённых до 1.2, и
149
+ -- обслуживания — заранее: первое обращение под нагрузкой их не вставляет
150
+ perform @SCHEMA@.family_rows(c.tenant, c.data) from @SCHEMA@.entity c where c.class = 'Class';
151
+ perform @SCHEMA@.lock_row('@SCHEMA_NAME@:maintain');
142
152
  end $$;
143
153
  alter function @SCHEMA@.seed_system() owner to @OWNER@;
144
154
  revoke all on function @SCHEMA@.seed_system() from public;
package/sql/README.md CHANGED
@@ -51,7 +51,10 @@
51
51
  | LT018 | `not_deleted` | `restore` и `purge` — только для удалённых объектов (этап 6) |
52
52
  | LT019 | `reset_disabled` | флаг `allowReset` установки выключен: `reset` и `maintain(now)` недоступны (этап 6) |
53
53
  | LT020 | `confirm_mismatch` | подтверждение сброса не совпадает с именем схемы (этап 6) |
54
+ | LT021 | `derived_class` | строки производного класса (`derivedBy`) пишет и удаляет только его проектор; на них не ссылаются концы (план 1.1, §2.4) |
55
+ | LT022 | `aggregate_field` | агрегатное поле (`aggregate`) считает база: приложение и сырой SQL его не передают и не меняют (план 1.1, §2.4) |
56
+ | LT023 | `projector_pending` | `purge` удалённого объекта, чьё надгробие ещё не прочёл проектор его класса (ревью безопасности №3, №5), или `trim_history`, чью первую оставшуюся версию он ещё не прочёл (хвост ревью №3) |
54
57
 
55
58
  ## Порядок файлов
56
59
 
57
- `10-core` → `15-errors` → `20-context` → `30-validate` → `40-class` → `50-acl` → `60-write` → `70-read` → `80-auth` → `90-time` → `95-seed.system` → `99-revision`. Миграции между ревизиями — `migrate/NNNN-*.sql` (номер — ревизия, к которой ведёт), их применяет `up({ upgrade: true })` до повторного наката.
60
+ `10-core` → `15-errors` → `20-context` → `30-validate` → `40-class` → `50-acl` → `60-write` → `70-read` → `75-history` → `80-auth` → `88-derived` → `90-time` → `95-seed.system` → `99-revision`. Миграции между ревизиями — `migrate/NNNN-*.sql` (номер — ревизия, к которой ведёт), их применяет `up({ upgrade: true })` до повторного наката.