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/AGENT-CHEATSHEET.en.md +113 -16
- package/AGENT-CHEATSHEET.md +106 -14
- package/CHANGELOG.md +702 -0
- package/README.en.md +798 -74
- package/README.md +756 -68
- package/dist/admin.d.ts +20 -4
- package/dist/admin.js +33 -9
- package/dist/cli.js +53 -4
- package/dist/errors.js +4 -2
- package/dist/index.d.ts +16 -2
- package/dist/index.js +94 -23
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.js +42 -11
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +810 -0
- package/dist/model.d.ts +34 -0
- package/dist/model.js +17 -0
- package/dist/project.d.ts +318 -0
- package/dist/project.js +1142 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/sql.js +9 -5
- package/dist/sync.js +11 -2
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +12 -0
- package/dist/watch.js +48 -4
- package/dist/write.d.ts +1 -1
- package/dist/write.js +25 -5
- package/llms.txt +6 -2
- package/package.json +13 -2
- package/sql/10-core.sql +8 -2
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +629 -56
- package/sql/70-read.sql +33 -8
- package/sql/80-auth.sql +4 -4
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +154 -13
- package/sql/95-seed.system.sql +12 -2
- package/sql/README.md +4 -1
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
|
-
|
|
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
|
|
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
|
|
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@;
|
package/sql/95-seed.system.sql
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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 })` до повторного наката.
|