letopis 1.1.0 → 1.2.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();
@@ -273,6 +279,24 @@ begin
273
279
  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
280
  perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s', l.class));
275
281
  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
298
+ perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл удаление: purge — после его run()');
299
+ end if;
276
300
  select count(*)::int into n from @SCHEMA@.log x where x.id = p_id;
277
301
  if not coalesce(p_confirm, false) then
278
302
  return jsonb_build_object('id', p_id, 'class', l.class, 'versions', n, 'purged', false);
@@ -386,6 +410,7 @@ declare
386
410
  lk record;
387
411
  nid uuid;
388
412
  denied text;
413
+ n_denied int;
389
414
  begin
390
415
  select * into c from @SCHEMA@.ctx();
391
416
  if c.account is null then
@@ -410,9 +435,15 @@ begin
410
435
  perform @SCHEMA@.family_lock(cls, false);
411
436
  cls := @SCHEMA@.class_row(o.class, o.tenant);
412
437
  res := cls.data->'resolved';
438
+ if res ? 'derivedBy' then
439
+ perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', o.class, res->>'derivedBy'));
440
+ end if;
413
441
  if jsonb_array_length(coalesce(res->'key', '[]'::jsonb)) = 0 then
414
442
  perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s нет ключа: id выдаёт база, менять нечего', o.class));
415
443
  end if;
444
+ if res ? 'aggregate' and coalesce(p_data, '{}'::jsonb) ?| @SCHEMA@.agg_fields(res) then
445
+ perform @SCHEMA@.lt_fail('aggregate_field', format('агрегатные поля класса %s считает база — в rekey их не передают', o.class));
446
+ end if;
416
447
  d := @SCHEMA@.apply_defaults(res->'schema', @SCHEMA@.merge(o.data, coalesce(p_data, '{}'::jsonb)));
417
448
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
418
449
  if bad <> '[]'::jsonb then
@@ -432,13 +463,16 @@ begin
432
463
  end if;
433
464
  return o.id;
434
465
  end if;
435
- -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE
436
- select min(x.class) into denied from @SCHEMA@.entity x
466
+ -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE (глубже первого уровня — move_row); класс
467
+ -- такой строки называется, только если актор её видит (доработки после точки Б)
468
+ 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')),
469
+ count(*) into denied, n_denied from @SCHEMA@.entity x
437
470
  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));
471
+ if n_denied > 0 then
472
+ perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права WRITE на ссылающиеся строки класса %s', denied)
473
+ else 'нет права WRITE на ссылающиеся строки' end);
440
474
  end if;
441
- perform @SCHEMA@.move_row(o, nid, o.class, d, lk.links, 'rekey');
475
+ perform @SCHEMA@.move_row(o, nid, o.class, d, lk.links, 'rekey', wplan);
442
476
  return nid;
443
477
  end $$;
444
478
 
@@ -462,6 +496,7 @@ declare
462
496
  nid uuid;
463
497
  n int := 0;
464
498
  conflicts jsonb;
499
+ dname text;
465
500
  begin
466
501
  select * into c from @SCHEMA@.ctx();
467
502
  if c.account is null then
@@ -478,9 +513,26 @@ begin
478
513
  if coalesce((cr.data->>'system')::boolean, false) then
479
514
  perform @SCHEMA@.lt_fail('invalid_class', 'ключ системного класса не меняется');
480
515
  end if;
516
+ -- строки производного класса (и его потомков) переносит только владелец: ключ меняют в описании, а строки
517
+ -- строит заново проектор (rebuild)
518
+ if cr.data->'resolved' ? 'derivedBy' then
519
+ perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', p_class, cr.data->'resolved'->>'derivedBy'));
520
+ end if;
521
+ -- и потомки: производный потомок непроизводного предка наследует его ключ, а его строки переносит только проектор
522
+ -- (доработки после точки Б)
523
+ select x.data->>'name' into dname from @SCHEMA@.entity x
524
+ where x.class = 'Class' and x.tenant = cr.tenant and x.id <> cr.id
525
+ and x.data->'resolved'->'ancestors' ? p_class and x.data->'resolved' ? 'derivedBy'
526
+ order by x.data->>'name' limit 1;
527
+ if dname is not null then
528
+ perform @SCHEMA@.lt_fail('derived_class', format('у класса %s производный потомок %s: его строки пишет проектор', p_class, dname));
529
+ end if;
481
530
  if not @SCHEMA@.acl_row_ok(wplan, 'Class', cr.id, cr.owner, cr.tenant, cr.tags, cr.data, cr.links) then
482
531
  perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на класс Class');
483
532
  end if;
533
+ -- смена ключа проверяет все строки семейства и отвечает числом нарушающих — как ужесточение, только с правом READ без
534
+ -- условий на все строки класса и потомков (доработки после точки Б)
535
+ perform @SCHEMA@.class_rows_admit(p_class, cr.tenant, array['READ'], format('смена ключа класса %s', p_class));
484
536
  perform @SCHEMA@.family_lock(cr, true);
485
537
  select x.* into cr from @SCHEMA@.entity x where x.id = cr.id for update;
486
538
  select p.data, p.validator into prep from @SCHEMA@.class_prepare(cr.data || jsonb_build_object('key', p_key), cr.tenant) p;
@@ -507,7 +559,7 @@ begin
507
559
  res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
508
560
  nid := coalesce(@SCHEMA@.key_id(res, r.class, r.tenant, r.data, r.links), r.id);
509
561
  if nid <> r.id then
510
- perform @SCHEMA@.move_row(r, nid, r.class, r.data, r.links, 'rekey');
562
+ perform @SCHEMA@.move_row(r, nid, r.class, r.data, r.links, 'rekey', wplan);
511
563
  n := n + 1;
512
564
  end if;
513
565
  end loop;
@@ -631,6 +683,7 @@ declare
631
683
  n int;
632
684
  m int;
633
685
  key text;
686
+ held boolean;
634
687
  begin
635
688
  if not @ALLOW_RESET@ then
636
689
  perform @SCHEMA@.lt_fail('reset_disabled', 'reset выключен в этой установке (up({ allowReset }))');
@@ -657,9 +710,15 @@ begin
657
710
  if not exists (select 1 from @SCHEMA@.entity a where a.id = t and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
658
711
  perform @SCHEMA@.lt_fail('target_not_found', 'арендатор не найден');
659
712
  end if;
713
+ -- строки арендатора стираются пачками в любом порядке: агрегаты его целей (план 1.1, §2.4) не пересчитываются —
714
+ -- цели уходят вместе с источниками, а промежуточный итог пачки мог бы выйти за правило класса
715
+ held := @SCHEMA@.agg_hold_begin('off');
660
716
  with d as (delete from @SCHEMA@.entity x where x.id in (
661
717
  select y.id from @SCHEMA@.entity y where y.tenant = t limit coalesce(p_limit, 2147483647)) returning 1)
662
718
  select count(*)::int into n from d;
719
+ if held then
720
+ perform @SCHEMA@.agg_hold_end(false);
721
+ end if;
663
722
  with d as (delete from @SCHEMA@.log x where x.seq in (
664
723
  select y.seq from @SCHEMA@.log y where y.tenant = t limit coalesce(p_limit, 2147483647)) returning 1)
665
724
  select count(*)::int into m from d;
@@ -781,6 +840,11 @@ begin
781
840
  if not @SCHEMA@.letopis_allowed('letopis.maintain') then
782
841
  perform @SCHEMA@.lt_fail('acl_denied', 'нет права letopis.maintain');
783
842
  end if;
843
+ -- один исполнитель: строка-замок до конца транзакции пачки (lock_try) — посторонняя роль её не займёт (ревью
844
+ -- безопасности №3, №2); занята — null, обслуживание продолжит держатель
845
+ if not @SCHEMA@.lock_try('@SCHEMA_NAME@:maintain') then
846
+ return null;
847
+ end if;
784
848
  if p_now is not null then
785
849
  if not @ALLOW_RESET@ then
786
850
  perform @SCHEMA@.lt_fail('reset_disabled', 'maintain(now) — только в установке с allow_reset');
@@ -805,7 +869,8 @@ end $$;
805
869
  -- прореживание — пропуск после версии старше яруса all класса, у которого есть (или была) политика
806
870
  -- хранения — своя или умолчание установки (метка default_policy в строке ключа). p_data — ещё и все строки: данные по проверке класса, концы (цель существует, её
807
871
  -- арендатор — тот же или System, класс принимается концом), арендатор и владелец существуют, id
808
- -- класса с ключом — v5 от ключа. Возвращает находки и якорь — хэш всех голов цепочек. Только
872
+ -- класса с ключом — v5 от ключа, агрегатные поля равны итогу живых источников (план 1.1, §2.4; ремонт —
873
+ -- rebuild_aggregates). Возвращает находки и якорь — хэш всех голов цепочек. Только
809
874
  -- системный администратор (право letopis.verify) или администраторское подключение.
810
875
  create or replace function @SCHEMA@.verify(p_data boolean default false) returns jsonb
811
876
  language plpgsql stable security definer
@@ -913,6 +978,11 @@ begin
913
978
  from @SCHEMA@.entity k2
914
979
  where k2.class = 'Class' and k2.data->>'name' = t.class and k2.tenant in (t.tenant, '@SYSTEM@'::uuid)
915
980
  order by k2.tenant = '@SYSTEM@'::uuid limit 1), '{}')))
981
+ union all
982
+ select jsonb_build_object('id', a.id, 'class', a.class, 'field', a.field, 'stored', a.stored, 'expected', a.expected,
983
+ 'problem', format('агрегат %s не равен итогу живых источников: в строке %s, по источникам %s — ремонт rebuildAggregates',
984
+ a.field, coalesce(a.stored::text, 'нет значения'), a.expected::text))
985
+ from @SCHEMA@.agg_issues() a
916
986
  )
917
987
  select coalesce(jsonb_agg(f), '[]'::jsonb) into data_issues from found;
918
988
  end if;
@@ -930,8 +1000,10 @@ end $$;
930
1000
  -- события курсора: старше яруса all какого-либо класса подписки — cursor_expired (события могли
931
1001
  -- проредиться). p_classes null — все классы. p_snap — снимок момента подписки «now»: транзакции,
932
1002
  -- видимые в нём (зафиксированные до подписки), не отдаются.
1003
+ -- p_leaving (проектор, ревью безопасности №3, №4): ещё смены класса без смены id (reclass без moved), уводящие строку из
1004
+ -- p_classes, — версия уже другого класса, а прошлая была в p_classes; иначе уход строки из семейства не виден.
933
1005
  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)
1006
+ p_snap pg_snapshot default null, p_leaving boolean default false)
935
1007
  returns setof @SCHEMA@.log
936
1008
  language plpgsql stable
937
1009
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -953,7 +1025,11 @@ begin
953
1025
  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
1026
  l.agent, l.op, l.reason, l.moved, l.prev_hash, l.hash, null::jsonb
955
1027
  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))
1028
+ where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon
1029
+ and (p_classes is null or l.class = any (p_classes)
1030
+ or (p_leaving and l.op = 'reclass' and l.moved is null
1031
+ 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')
1032
+ order by p.rev desc limit 1) = any (p_classes)))
957
1033
  and (p_snap is null or not pg_visible_in_snapshot(l.tx, p_snap))
958
1034
  order by l.tx, l.seq limit p_limit;
959
1035
  end $$;
@@ -967,8 +1043,9 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
967
1043
  as $$
968
1044
  select pg_snapshot_xmin(s), 0::bigint, s from pg_current_snapshot() s
969
1045
  $$;
970
- -- до исправления подписки «now»: без снимка
1046
+ -- до исправления подписки «now»: без снимка; до ревью безопасности №3 — без p_leaving
971
1047
  drop function if exists @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int);
1048
+ drop function if exists @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot);
972
1049
 
973
1050
  do $$
974
1051
  declare f text;
@@ -979,7 +1056,7 @@ begin
979
1056
  'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
980
1057
  'rekey(uuid, jsonb, jsonb)', 'rekey_class(text, jsonb)', 'account_purge(uuid)', 'reset_mark(uuid, text, uuid)',
981
1058
  '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
1059
+ 'watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean)', 'watch_head()'] loop
983
1060
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
984
1061
  execute format('revoke all on function @SCHEMA@.%s from public', f);
985
1062
  end loop;
@@ -988,6 +1065,6 @@ end $$;
988
1065
  grant execute on function @SCHEMA@.restore(uuid), @SCHEMA@.purge(uuid, boolean), @SCHEMA@.trim_history(uuid[], text[], timestamptz),
989
1066
  @SCHEMA@.rekey(uuid, jsonb, jsonb), @SCHEMA@.rekey_class(text, jsonb), @SCHEMA@.account_purge(uuid),
990
1067
  @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(),
1068
+ @SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean), @SCHEMA@.watch_head(),
992
1069
  @SCHEMA@.history_default(), @SCHEMA@.history_policy(jsonb), @SCHEMA@.history_iv(text),
993
1070
  @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) |
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 })` до повторного наката.