letopis 1.0.1 → 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/AGENT-CHEATSHEET.en.md +117 -10
- package/AGENT-CHEATSHEET.md +110 -8
- package/CHANGELOG.md +739 -0
- package/README.en.md +873 -68
- package/README.md +830 -66
- package/dist/admin.d.ts +24 -4
- package/dist/admin.js +39 -9
- package/dist/cache.d.ts +33 -9
- package/dist/cache.js +81 -32
- package/dist/chain.d.ts +29 -7
- package/dist/chain.js +61 -12
- package/dist/cli.js +59 -7
- package/dist/errors.js +4 -2
- package/dist/import.d.ts +6 -0
- package/dist/import.js +196 -175
- package/dist/index.d.ts +27 -5
- package/dist/index.js +191 -29
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.d.ts +63 -0
- package/dist/load.js +331 -44
- 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 +309 -0
- package/dist/project.js +1097 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/signals.d.ts +73 -0
- package/dist/signals.js +169 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +26 -7
- package/dist/tx.d.ts +9 -7
- package/dist/tx.js +15 -5
- package/dist/typed.d.ts +21 -6
- package/dist/types.d.ts +77 -4
- package/dist/types.js +8 -3
- package/dist/up.d.ts +16 -2
- package/dist/up.js +34 -4
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +23 -2
- package/dist/watch.js +44 -7
- package/dist/write.d.ts +11 -2
- package/dist/write.js +57 -10
- package/llms.txt +9 -2
- package/package.json +13 -2
- package/sql/10-core.sql +13 -2
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +611 -82
- package/sql/70-read.sql +33 -8
- package/sql/75-history.sql +358 -0
- package/sql/80-auth.sql +7 -7
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +134 -21
- package/sql/95-seed.system.sql +24 -6
- package/sql/99-revision.sql +1 -1
- package/sql/README.md +5 -1
- package/sql/migrate/0006-changed-paths.sql +3 -0
package/sql/90-time.sql
CHANGED
|
@@ -119,6 +119,36 @@ begin
|
|
|
119
119
|
return (select s.expires_at from @SCHEMA@.secret s where s.id = c.session);
|
|
120
120
|
end $$;
|
|
121
121
|
|
|
122
|
+
-- ========================= сигналы (план 1.1, §2.2) =========================
|
|
123
|
+
|
|
124
|
+
-- Режим сигналов установки — константа up({ signals }): class, tenant, opaque, none.
|
|
125
|
+
create or replace function @SCHEMA@.signal_mode() returns text
|
|
126
|
+
language sql immutable parallel safe
|
|
127
|
+
return '@SIGNALS@';
|
|
128
|
+
|
|
129
|
+
-- Ключ сигналов (hex) для сессии с правом letopis.signals по правилам System (сид выдаёт его сервисам) или
|
|
130
|
+
-- администраторского подключения: по нему процесс узнаёт в нагрузке токены классов и арендаторов.
|
|
131
|
+
-- Отдаётся производный ключ HMAC(ключ строки KEY, 'letopis:signals'), а не сам ключ: им подписаны пропуска прав.
|
|
132
|
+
create or replace function @SCHEMA@.signal_key() returns text
|
|
133
|
+
language plpgsql stable security definer
|
|
134
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
135
|
+
as $$
|
|
136
|
+
begin
|
|
137
|
+
if not @SCHEMA@.letopis_allowed('letopis.signals') then
|
|
138
|
+
perform @SCHEMA@.lt_fail('acl_denied', 'нет права letopis.signals');
|
|
139
|
+
end if;
|
|
140
|
+
return (select encode(@PGCRYPTO@.hmac(convert_to('letopis:signals', 'UTF8'), s.material, 'sha256'), 'hex')
|
|
141
|
+
from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY');
|
|
142
|
+
end $$;
|
|
143
|
+
|
|
144
|
+
-- Отпечаток описаний классов, видимых сессии (свои и System): в режиме сигналов none реестр библиотеки
|
|
145
|
+
-- сверяет его раз в 5 секунд вместо сигнала Class. Под RLS — тот же набор строк, что читает реестр.
|
|
146
|
+
create or replace function @SCHEMA@.class_stamp() returns text
|
|
147
|
+
language sql stable
|
|
148
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
149
|
+
return (select encode(sha256(convert_to(coalesce(string_agg(e.id::text || ':' || e.rev::text, ',' order by e.id), ''), 'UTF8')), 'hex')
|
|
150
|
+
from @SCHEMA@.entity e where e.class = 'Class' and e.tenant in (@SCHEMA@.ctx_tenant(), '@SYSTEM@'::uuid));
|
|
151
|
+
|
|
122
152
|
-- ========================= restore, purge, обрезка истории =========================
|
|
123
153
|
|
|
124
154
|
-- Последняя запись журнала id под блокировкой, если она видна актору в его арендаторе: параллельные
|
|
@@ -183,6 +213,9 @@ begin
|
|
|
183
213
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
184
214
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', l.class));
|
|
185
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;
|
|
186
219
|
d := @SCHEMA@.apply_defaults(res->'schema', l.data);
|
|
187
220
|
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
|
|
188
221
|
if bad <> '[]'::jsonb then
|
|
@@ -209,6 +242,9 @@ begin
|
|
|
209
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',
|
|
210
243
|
nullif(current_setting('letopis.reason', true), ''), null, l.hash, null)
|
|
211
244
|
returning * into n;
|
|
245
|
+
-- агрегатные поля восстановленной цели триггер после вставки посчитал из живых источников в эту же версию, а не
|
|
246
|
+
-- взял из надгробия (план 1.1, §2.4): в журнал идёт строка таблицы
|
|
247
|
+
select x.* into n from @SCHEMA@.entity x where x.id = p_id;
|
|
212
248
|
perform @SCHEMA@.log_version(n);
|
|
213
249
|
if l.class in ('rule', 'Resource', 'member', 'Account') then
|
|
214
250
|
perform @SCHEMA@.acl_bump();
|
|
@@ -243,6 +279,24 @@ begin
|
|
|
243
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
|
|
244
280
|
perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s', l.class));
|
|
245
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;
|
|
246
300
|
select count(*)::int into n from @SCHEMA@.log x where x.id = p_id;
|
|
247
301
|
if not coalesce(p_confirm, false) then
|
|
248
302
|
return jsonb_build_object('id', p_id, 'class', l.class, 'versions', n, 'purged', false);
|
|
@@ -256,7 +310,7 @@ begin
|
|
|
256
310
|
if exists (select 1 from @SCHEMA@.entity e where e.id = p_id) then
|
|
257
311
|
perform @SCHEMA@.lt_fail('not_deleted', 'объект восстановлен во время purge');
|
|
258
312
|
end if;
|
|
259
|
-
perform @SCHEMA@.signal(array[l.class]);
|
|
313
|
+
perform @SCHEMA@.signal(array[l.class], array[l.tenant]);
|
|
260
314
|
return jsonb_build_object('id', p_id, 'class', l.class, 'versions', n, 'purged', true);
|
|
261
315
|
end $$;
|
|
262
316
|
|
|
@@ -330,7 +384,7 @@ begin
|
|
|
330
384
|
total := total + k - case when first.op = 'trim' then 1 else 0 end;
|
|
331
385
|
classes := classes || r.class;
|
|
332
386
|
end loop;
|
|
333
|
-
perform @SCHEMA@.signal(
|
|
387
|
+
perform @SCHEMA@.signal(array_agg(d.x), array_agg(c.tenant)) from (select distinct x from unnest(classes) x) d;
|
|
334
388
|
return total;
|
|
335
389
|
end $$;
|
|
336
390
|
|
|
@@ -356,6 +410,7 @@ declare
|
|
|
356
410
|
lk record;
|
|
357
411
|
nid uuid;
|
|
358
412
|
denied text;
|
|
413
|
+
n_denied int;
|
|
359
414
|
begin
|
|
360
415
|
select * into c from @SCHEMA@.ctx();
|
|
361
416
|
if c.account is null then
|
|
@@ -380,9 +435,15 @@ begin
|
|
|
380
435
|
perform @SCHEMA@.family_lock(cls, false);
|
|
381
436
|
cls := @SCHEMA@.class_row(o.class, o.tenant);
|
|
382
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;
|
|
383
441
|
if jsonb_array_length(coalesce(res->'key', '[]'::jsonb)) = 0 then
|
|
384
442
|
perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s нет ключа: id выдаёт база, менять нечего', o.class));
|
|
385
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;
|
|
386
447
|
d := @SCHEMA@.apply_defaults(res->'schema', @SCHEMA@.merge(o.data, coalesce(p_data, '{}'::jsonb)));
|
|
387
448
|
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
|
|
388
449
|
if bad <> '[]'::jsonb then
|
|
@@ -402,13 +463,16 @@ begin
|
|
|
402
463
|
end if;
|
|
403
464
|
return o.id;
|
|
404
465
|
end if;
|
|
405
|
-
-- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE
|
|
406
|
-
|
|
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
|
|
407
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);
|
|
408
|
-
if
|
|
409
|
-
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);
|
|
410
474
|
end if;
|
|
411
|
-
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);
|
|
412
476
|
return nid;
|
|
413
477
|
end $$;
|
|
414
478
|
|
|
@@ -432,6 +496,7 @@ declare
|
|
|
432
496
|
nid uuid;
|
|
433
497
|
n int := 0;
|
|
434
498
|
conflicts jsonb;
|
|
499
|
+
dname text;
|
|
435
500
|
begin
|
|
436
501
|
select * into c from @SCHEMA@.ctx();
|
|
437
502
|
if c.account is null then
|
|
@@ -448,9 +513,26 @@ begin
|
|
|
448
513
|
if coalesce((cr.data->>'system')::boolean, false) then
|
|
449
514
|
perform @SCHEMA@.lt_fail('invalid_class', 'ключ системного класса не меняется');
|
|
450
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;
|
|
451
530
|
if not @SCHEMA@.acl_row_ok(wplan, 'Class', cr.id, cr.owner, cr.tenant, cr.tags, cr.data, cr.links) then
|
|
452
531
|
perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на класс Class');
|
|
453
532
|
end if;
|
|
533
|
+
-- смена ключа проверяет все строки семейства и отвечает числом нарушающих — как ужесточение, только с правом READ без
|
|
534
|
+
-- условий на все строки класса и потомков (доработки после точки Б)
|
|
535
|
+
perform @SCHEMA@.class_rows_admit(p_class, cr.tenant, array['READ'], format('смена ключа класса %s', p_class));
|
|
454
536
|
perform @SCHEMA@.family_lock(cr, true);
|
|
455
537
|
select x.* into cr from @SCHEMA@.entity x where x.id = cr.id for update;
|
|
456
538
|
select p.data, p.validator into prep from @SCHEMA@.class_prepare(cr.data || jsonb_build_object('key', p_key), cr.tenant) p;
|
|
@@ -477,7 +559,7 @@ begin
|
|
|
477
559
|
res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
|
|
478
560
|
nid := coalesce(@SCHEMA@.key_id(res, r.class, r.tenant, r.data, r.links), r.id);
|
|
479
561
|
if nid <> r.id then
|
|
480
|
-
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);
|
|
481
563
|
n := n + 1;
|
|
482
564
|
end if;
|
|
483
565
|
end loop;
|
|
@@ -566,7 +648,9 @@ begin
|
|
|
566
648
|
perform @SCHEMA@.tombstone(a);
|
|
567
649
|
perform @SCHEMA@.auth_kill(p_account, null);
|
|
568
650
|
delete from @SCHEMA@.secret s where s.account = p_account;
|
|
569
|
-
|
|
651
|
+
-- данные арендатора-цели — его парами; Account и member — классы прав, их сигнал сбрасывает всё
|
|
652
|
+
perform @SCHEMA@.signal(classes || array['Account', 'member'],
|
|
653
|
+
array(select p_account from unnest(classes)) || array['@SYSTEM@'::uuid, '@SYSTEM@'::uuid]);
|
|
570
654
|
perform @SCHEMA@.acl_bump();
|
|
571
655
|
return jsonb_build_object('account', p_account, 'rows', n_rows, 'versions', n_log, 'memberships', n_mem, 'owned', n_own);
|
|
572
656
|
end $$;
|
|
@@ -599,6 +683,7 @@ declare
|
|
|
599
683
|
n int;
|
|
600
684
|
m int;
|
|
601
685
|
key text;
|
|
686
|
+
held boolean;
|
|
602
687
|
begin
|
|
603
688
|
if not @ALLOW_RESET@ then
|
|
604
689
|
perform @SCHEMA@.lt_fail('reset_disabled', 'reset выключен в этой установке (up({ allowReset }))');
|
|
@@ -615,7 +700,7 @@ begin
|
|
|
615
700
|
with d as (delete from @SCHEMA@.secret s where s.kind = 'SESSION' and s.id is distinct from c.session returning 1)
|
|
616
701
|
select count(*)::int into n from d;
|
|
617
702
|
perform @SCHEMA@.reset_mark('@SYSTEM@'::uuid, p_level, c.author);
|
|
618
|
-
perform @SCHEMA@.
|
|
703
|
+
perform @SCHEMA@.signal_raw(array['session:*']);
|
|
619
704
|
return jsonb_build_object('level', p_level, 'sessions', n);
|
|
620
705
|
when 'tenant' then
|
|
621
706
|
t := coalesce(p_tenant, c.tenant);
|
|
@@ -625,9 +710,15 @@ begin
|
|
|
625
710
|
if not exists (select 1 from @SCHEMA@.entity a where a.id = t and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
|
|
626
711
|
perform @SCHEMA@.lt_fail('target_not_found', 'арендатор не найден');
|
|
627
712
|
end if;
|
|
713
|
+
-- строки арендатора стираются пачками в любом порядке: агрегаты его целей (план 1.1, §2.4) не пересчитываются —
|
|
714
|
+
-- цели уходят вместе с источниками, а промежуточный итог пачки мог бы выйти за правило класса
|
|
715
|
+
held := @SCHEMA@.agg_hold_begin('off');
|
|
628
716
|
with d as (delete from @SCHEMA@.entity x where x.id in (
|
|
629
717
|
select y.id from @SCHEMA@.entity y where y.tenant = t limit coalesce(p_limit, 2147483647)) returning 1)
|
|
630
718
|
select count(*)::int into n from d;
|
|
719
|
+
if held then
|
|
720
|
+
perform @SCHEMA@.agg_hold_end(false);
|
|
721
|
+
end if;
|
|
631
722
|
with d as (delete from @SCHEMA@.log x where x.seq in (
|
|
632
723
|
select y.seq from @SCHEMA@.log y where y.tenant = t limit coalesce(p_limit, 2147483647)) returning 1)
|
|
633
724
|
select count(*)::int into m from d;
|
|
@@ -635,7 +726,7 @@ begin
|
|
|
635
726
|
return jsonb_build_object('level', p_level, 'tenant', t, 'rows', n, 'versions', m, 'done', false);
|
|
636
727
|
end if;
|
|
637
728
|
perform @SCHEMA@.reset_mark(t, p_level, c.author);
|
|
638
|
-
perform @SCHEMA@.
|
|
729
|
+
perform @SCHEMA@.signal_raw(array['*']);
|
|
639
730
|
perform @SCHEMA@.acl_bump();
|
|
640
731
|
return jsonb_build_object('level', p_level, 'tenant', t, 'rows', n, 'versions', m, 'done', true);
|
|
641
732
|
when 'all' then
|
|
@@ -644,7 +735,7 @@ begin
|
|
|
644
735
|
perform @SCHEMA@.seed_system();
|
|
645
736
|
key := 'lts_' || encode(@PGCRYPTO@.gen_random_bytes(32), 'hex');
|
|
646
737
|
perform @SCHEMA@.bootstrap_service(key);
|
|
647
|
-
perform @SCHEMA@.
|
|
738
|
+
perform @SCHEMA@.signal_raw(array['*', 'session:*']);
|
|
648
739
|
return jsonb_build_object('level', p_level, 'serviceKey', key);
|
|
649
740
|
else
|
|
650
741
|
perform @SCHEMA@.lt_fail('invalid_data', format('уровень сброса %s: sessions, tenant или all', p_level));
|
|
@@ -749,6 +840,11 @@ begin
|
|
|
749
840
|
if not @SCHEMA@.letopis_allowed('letopis.maintain') then
|
|
750
841
|
perform @SCHEMA@.lt_fail('acl_denied', 'нет права letopis.maintain');
|
|
751
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;
|
|
752
848
|
if p_now is not null then
|
|
753
849
|
if not @ALLOW_RESET@ then
|
|
754
850
|
perform @SCHEMA@.lt_fail('reset_disabled', 'maintain(now) — только в установке с allow_reset');
|
|
@@ -773,7 +869,8 @@ end $$;
|
|
|
773
869
|
-- прореживание — пропуск после версии старше яруса all класса, у которого есть (или была) политика
|
|
774
870
|
-- хранения — своя или умолчание установки (метка default_policy в строке ключа). p_data — ещё и все строки: данные по проверке класса, концы (цель существует, её
|
|
775
871
|
-- арендатор — тот же или System, класс принимается концом), арендатор и владелец существуют, id
|
|
776
|
-
-- класса с ключом — v5 от
|
|
872
|
+
-- класса с ключом — v5 от ключа, агрегатные поля равны итогу живых источников (план 1.1, §2.4; ремонт —
|
|
873
|
+
-- rebuild_aggregates). Возвращает находки и якорь — хэш всех голов цепочек. Только
|
|
777
874
|
-- системный администратор (право letopis.verify) или администраторское подключение.
|
|
778
875
|
create or replace function @SCHEMA@.verify(p_data boolean default false) returns jsonb
|
|
779
876
|
language plpgsql stable security definer
|
|
@@ -881,6 +978,11 @@ begin
|
|
|
881
978
|
from @SCHEMA@.entity k2
|
|
882
979
|
where k2.class = 'Class' and k2.data->>'name' = t.class and k2.tenant in (t.tenant, '@SYSTEM@'::uuid)
|
|
883
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
|
|
884
986
|
)
|
|
885
987
|
select coalesce(jsonb_agg(f), '[]'::jsonb) into data_issues from found;
|
|
886
988
|
end if;
|
|
@@ -898,8 +1000,10 @@ end $$;
|
|
|
898
1000
|
-- события курсора: старше яруса all какого-либо класса подписки — cursor_expired (события могли
|
|
899
1001
|
-- проредиться). p_classes null — все классы. p_snap — снимок момента подписки «now»: транзакции,
|
|
900
1002
|
-- видимые в нём (зафиксированные до подписки), не отдаются.
|
|
1003
|
+
-- p_leaving (проектор, ревью безопасности №3, №4): ещё смены класса без смены id (reclass без moved), уводящие строку из
|
|
1004
|
+
-- p_classes, — версия уже другого класса, а прошлая была в p_classes; иначе уход строки из семейства не виден.
|
|
901
1005
|
create or replace function @SCHEMA@.watch_read(p_tx xid8, p_seq bigint, p_at timestamptz, p_classes text[], p_limit int,
|
|
902
|
-
p_snap pg_snapshot default null)
|
|
1006
|
+
p_snap pg_snapshot default null, p_leaving boolean default false)
|
|
903
1007
|
returns setof @SCHEMA@.log
|
|
904
1008
|
language plpgsql stable
|
|
905
1009
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
@@ -917,8 +1021,15 @@ begin
|
|
|
917
1021
|
message = 'letopis: cursor_expired: курсор подписки старше яруса all — события могли проредиться, перечитайте текущее состояние';
|
|
918
1022
|
end if;
|
|
919
1023
|
end if;
|
|
920
|
-
|
|
921
|
-
|
|
1024
|
+
-- колонки — явно: changed приложению не выдана (ревью №1 точки А плана 1.1), в ответе подписки она null
|
|
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,
|
|
1026
|
+
l.agent, l.op, l.reason, l.moved, l.prev_hash, l.hash, null::jsonb
|
|
1027
|
+
from @SCHEMA@.log l
|
|
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)))
|
|
922
1033
|
and (p_snap is null or not pg_visible_in_snapshot(l.tx, p_snap))
|
|
923
1034
|
order by l.tx, l.seq limit p_limit;
|
|
924
1035
|
end $$;
|
|
@@ -932,19 +1043,20 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
|
932
1043
|
as $$
|
|
933
1044
|
select pg_snapshot_xmin(s), 0::bigint, s from pg_current_snapshot() s
|
|
934
1045
|
$$;
|
|
935
|
-
-- до исправления подписки «now»: без
|
|
1046
|
+
-- до исправления подписки «now»: без снимка; до ревью безопасности №3 — без p_leaving
|
|
936
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);
|
|
937
1049
|
|
|
938
1050
|
do $$
|
|
939
1051
|
declare f text;
|
|
940
1052
|
begin
|
|
941
1053
|
foreach f in array array[
|
|
942
1054
|
'history_default()', 'history_policy(jsonb)', 'history_iv(text)', 'history_horizon(jsonb)',
|
|
943
|
-
'history_keep(jsonb, timestamp, timestamp, timestamp)', 'admin_role()', 'letopis_allowed(text)', 'session_expires()',
|
|
1055
|
+
'history_keep(jsonb, timestamp, timestamp, timestamp)', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
|
|
944
1056
|
'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
|
|
945
1057
|
'rekey(uuid, jsonb, jsonb)', 'rekey_class(text, jsonb)', 'account_purge(uuid)', 'reset_mark(uuid, text, uuid)',
|
|
946
1058
|
'reset(text, text, uuid, int)', 'thin_batch(timestamptz, int)', 'maintain(timestamptz, int)', 'verify(boolean)',
|
|
947
|
-
'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
|
|
948
1060
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
949
1061
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
950
1062
|
end loop;
|
|
@@ -953,5 +1065,6 @@ end $$;
|
|
|
953
1065
|
grant execute on function @SCHEMA@.restore(uuid), @SCHEMA@.purge(uuid, boolean), @SCHEMA@.trim_history(uuid[], text[], timestamptz),
|
|
954
1066
|
@SCHEMA@.rekey(uuid, jsonb, jsonb), @SCHEMA@.rekey_class(text, jsonb), @SCHEMA@.account_purge(uuid),
|
|
955
1067
|
@SCHEMA@.reset(text, text, uuid, int), @SCHEMA@.maintain(timestamptz, int), @SCHEMA@.verify(boolean),
|
|
956
|
-
@SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot), @SCHEMA@.watch_head(),
|
|
957
|
-
@SCHEMA@.history_default(), @SCHEMA@.history_policy(jsonb), @SCHEMA@.history_iv(text)
|
|
1068
|
+
@SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean), @SCHEMA@.watch_head(),
|
|
1069
|
+
@SCHEMA@.history_default(), @SCHEMA@.history_policy(jsonb), @SCHEMA@.history_iv(text),
|
|
1070
|
+
@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@;
|
|
@@ -69,12 +75,17 @@ declare
|
|
|
69
75
|
o_api uuid;
|
|
70
76
|
o_auth uuid;
|
|
71
77
|
o_letopis uuid;
|
|
78
|
+
o_signals uuid;
|
|
72
79
|
op text;
|
|
73
80
|
begin
|
|
74
|
-
-- Ключ подписи пропуска прав и счётчик изменений прав (50-acl.sql: acl_token, acl_bump)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
-- Ключ подписи пропуска прав и счётчик изменений прав (50-acl.sql: acl_token, acl_bump); от ключа же
|
|
82
|
+
-- выводится ключ сигналов. Режим сигналов (up({ signals })) хранится в meta этой строки: установщик
|
|
83
|
+
-- читает его перед накатом, поэтому повторный up() без опции режим не меняет, а reset({ all }) кладёт
|
|
84
|
+
-- его заново из константы установки.
|
|
85
|
+
insert into @SCHEMA@.secret as k (id, account, kind, material, meta)
|
|
86
|
+
values ('@SYSTEM@'::uuid, '@SYSTEM@'::uuid, 'KEY', @PGCRYPTO@.gen_random_bytes(32), '{"v": 0, "signals": "@SIGNALS@"}')
|
|
87
|
+
on conflict (id) do update set meta = k.meta || '{"signals": "@SIGNALS@"}'
|
|
88
|
+
where k.meta->>'signals' is distinct from '@SIGNALS@';
|
|
78
89
|
|
|
79
90
|
perform @SCHEMA@.sys_create('@SYSTEM@'::uuid, 'Account', '@SYSTEM@'::uuid, '{}',
|
|
80
91
|
'{"name": "System", "categories": ["System"], "enabled": true}');
|
|
@@ -124,13 +135,20 @@ begin
|
|
|
124
135
|
o_api := @SCHEMA@.sys_resource(sys, 'any:API', 'API', null);
|
|
125
136
|
o_auth := @SCHEMA@.sys_resource(sys, 'auth:API', 'API', '{"endpoint": "auth.*"}');
|
|
126
137
|
o_letopis := @SCHEMA@.sys_resource(sys, 'letopis:API', 'API', '{"endpoint": "letopis.*"}');
|
|
138
|
+
o_signals := @SCHEMA@.sys_resource(sys, 'signals:API', 'API', '{"endpoint": "letopis.signals"}');
|
|
127
139
|
perform @SCHEMA@.sys_rule(sys, g_any, o_api, 'deny', 0, '{"code": 403, "message": "Доступ запрещён по умолчанию"}');
|
|
128
140
|
perform @SCHEMA@.sys_rule(sys, g_svc, o_auth, 'allow', 100);
|
|
141
|
+
-- ключ сигналов (signal_key(), план 1.1): сервисы узнают токены классов и арендаторов в нагрузке
|
|
142
|
+
perform @SCHEMA@.sys_rule(sys, g_svc, o_signals, 'allow', 100);
|
|
129
143
|
perform @SCHEMA@.sys_rule(sys, g_admin, o_auth, 'allow', 100);
|
|
130
144
|
perform @SCHEMA@.sys_rule(sys, g_admin, o_letopis, 'allow', 100);
|
|
131
145
|
foreach op in array array['READ', 'WRITE', 'DELETE'] loop
|
|
132
146
|
perform @SCHEMA@.sys_rule(sys, g_admin, @SCHEMA@.sys_resource(sys, 'all:' || op, op, null), 'allow', 100);
|
|
133
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');
|
|
134
152
|
end $$;
|
|
135
153
|
alter function @SCHEMA@.seed_system() owner to @OWNER@;
|
|
136
154
|
revoke all on function @SCHEMA@.seed_system() from public;
|
package/sql/99-revision.sql
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
-- letopis 1.0 — штамп ревизии движка (план, §2.2, этап 1, п. 11): последний оператор наката.
|
|
2
2
|
-- Номер совпадает с DDL_REVISION в lib/src/types.ts (проверяет check-docs).
|
|
3
|
-
comment on schema @SCHEMA@ is 'letopis ddl_revision=
|
|
3
|
+
comment on schema @SCHEMA@ is 'letopis ddl_revision=6';
|
package/sql/README.md
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
| `@ALLOW_RESET@` | `true` или `false` — константа установки |
|
|
17
17
|
| `@HISTORY_DEFAULT@` | политика хранения по умолчанию `up({ history })` литералом JSON или `'null'` — константа установки |
|
|
18
18
|
| `@RESERVED_NAMES@` | методы цепочки — `RESERVED_CLASS_NAMES` из `lib/src/types.ts`, литералами через запятую |
|
|
19
|
+
| `@SIGNALS@` | режим сигналов `class`, `tenant`, `opaque` или `none` (план 1.1, §2.2): опция `up({ signals })`, иначе сохранённый в `meta.signals` строки ключа `secret`, иначе `class` — константа установки |
|
|
19
20
|
|
|
20
21
|
## Колонки снимка версии
|
|
21
22
|
|
|
@@ -50,7 +51,10 @@
|
|
|
50
51
|
| LT018 | `not_deleted` | `restore` и `purge` — только для удалённых объектов (этап 6) |
|
|
51
52
|
| LT019 | `reset_disabled` | флаг `allowReset` установки выключен: `reset` и `maintain(now)` недоступны (этап 6) |
|
|
52
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) |
|
|
53
57
|
|
|
54
58
|
## Порядок файлов
|
|
55
59
|
|
|
56
|
-
`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 })` до повторного наката.
|