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.
Files changed (60) hide show
  1. package/AGENT-CHEATSHEET.en.md +117 -10
  2. package/AGENT-CHEATSHEET.md +110 -8
  3. package/CHANGELOG.md +739 -0
  4. package/README.en.md +873 -68
  5. package/README.md +830 -66
  6. package/dist/admin.d.ts +24 -4
  7. package/dist/admin.js +39 -9
  8. package/dist/cache.d.ts +33 -9
  9. package/dist/cache.js +81 -32
  10. package/dist/chain.d.ts +29 -7
  11. package/dist/chain.js +61 -12
  12. package/dist/cli.js +59 -7
  13. package/dist/errors.js +4 -2
  14. package/dist/import.d.ts +6 -0
  15. package/dist/import.js +196 -175
  16. package/dist/index.d.ts +27 -5
  17. package/dist/index.js +191 -29
  18. package/dist/internal.d.ts +16 -0
  19. package/dist/internal.js +14 -0
  20. package/dist/load.d.ts +63 -0
  21. package/dist/load.js +331 -44
  22. package/dist/mcp.d.ts +28 -0
  23. package/dist/mcp.js +810 -0
  24. package/dist/model.d.ts +34 -0
  25. package/dist/model.js +17 -0
  26. package/dist/project.d.ts +309 -0
  27. package/dist/project.js +1097 -0
  28. package/dist/registry.d.ts +17 -0
  29. package/dist/registry.js +13 -0
  30. package/dist/signals.d.ts +73 -0
  31. package/dist/signals.js +169 -0
  32. package/dist/sql.d.ts +1 -1
  33. package/dist/sql.js +26 -7
  34. package/dist/tx.d.ts +9 -7
  35. package/dist/tx.js +15 -5
  36. package/dist/typed.d.ts +21 -6
  37. package/dist/types.d.ts +77 -4
  38. package/dist/types.js +8 -3
  39. package/dist/up.d.ts +16 -2
  40. package/dist/up.js +34 -4
  41. package/dist/uuid.js +19 -8
  42. package/dist/watch.d.ts +23 -2
  43. package/dist/watch.js +44 -7
  44. package/dist/write.d.ts +11 -2
  45. package/dist/write.js +57 -10
  46. package/llms.txt +9 -2
  47. package/package.json +13 -2
  48. package/sql/10-core.sql +13 -2
  49. package/sql/15-errors.sql +4 -1
  50. package/sql/40-class.sql +57 -2
  51. package/sql/60-write.sql +611 -82
  52. package/sql/70-read.sql +33 -8
  53. package/sql/75-history.sql +358 -0
  54. package/sql/80-auth.sql +7 -7
  55. package/sql/88-derived.sql +1154 -0
  56. package/sql/90-time.sql +134 -21
  57. package/sql/95-seed.system.sql +24 -6
  58. package/sql/99-revision.sql +1 -1
  59. package/sql/README.md +5 -1
  60. 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(array(select distinct x from unnest(classes) x));
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
- 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
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 denied is not null then
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
- perform @SCHEMA@.signal(array(select distinct x from unnest(classes || array['Account', 'member']) x));
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@.signal(array['session:*']);
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@.signal(array['*']);
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@.signal(array['*', 'Class', 'session:*']);
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
- return query select l.* from @SCHEMA@.log l
921
- where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon and (p_classes is null or l.class = any (p_classes))
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) to @APP@;
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@;
@@ -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@;
@@ -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
- insert into @SCHEMA@.secret (id, account, kind, material, meta)
76
- values ('@SYSTEM@'::uuid, '@SYSTEM@'::uuid, 'KEY', @PGCRYPTO@.gen_random_bytes(32), '{"v": 0}')
77
- on conflict (id) do nothing;
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;
@@ -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=5';
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 })` до повторного наката.
@@ -0,0 +1,3 @@
1
+ -- Ревизия 6 (план 1.1, этап 1б): пути изменения версии в журнале — для быстрого blame.
2
+ -- Старые версии остаются с null: blame досчитывает их сравнением соседних версий.
3
+ alter table @SCHEMA@.log add column if not exists changed jsonb;