letopis 1.4.0 → 1.4.2

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 (62) hide show
  1. package/AGENT-CHEATSHEET.en.md +15 -7
  2. package/AGENT-CHEATSHEET.md +19 -11
  3. package/CHANGELOG.md +299 -1
  4. package/README.en.md +180 -62
  5. package/README.md +165 -55
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +53 -18
  9. package/dist/auth.d.ts +61 -17
  10. package/dist/auth.js +16 -2
  11. package/dist/cache.d.ts +5 -1
  12. package/dist/cache.js +3 -2
  13. package/dist/chain.d.ts +92 -15
  14. package/dist/chain.js +56 -7
  15. package/dist/cli.js +16 -2
  16. package/dist/errors.d.ts +2 -2
  17. package/dist/errors.js +5 -5
  18. package/dist/import.js +7 -0
  19. package/dist/index.d.ts +73 -45
  20. package/dist/index.js +69 -14
  21. package/dist/internal.d.ts +7 -0
  22. package/dist/internal.js +5 -0
  23. package/dist/load.d.ts +10 -3
  24. package/dist/load.js +39 -11
  25. package/dist/mcp.d.ts +20 -11
  26. package/dist/mcp.js +8 -6
  27. package/dist/model.d.ts +4 -1
  28. package/dist/model.js +7 -1
  29. package/dist/ops.d.ts +58 -18
  30. package/dist/ops.js +58 -18
  31. package/dist/project.d.ts +39 -11
  32. package/dist/project.js +75 -15
  33. package/dist/registry.d.ts +4 -1
  34. package/dist/registry.js +4 -1
  35. package/dist/signals.d.ts +4 -4
  36. package/dist/signals.js +4 -4
  37. package/dist/sql.d.ts +8 -4
  38. package/dist/sql.js +110 -25
  39. package/dist/sync.js +11 -4
  40. package/dist/tx.d.ts +15 -0
  41. package/dist/tx.js +22 -4
  42. package/dist/types.d.ts +21 -7
  43. package/dist/types.js +13 -6
  44. package/dist/up.d.ts +48 -25
  45. package/dist/up.js +94 -41
  46. package/dist/validate.d.ts +9 -2
  47. package/dist/validate.js +19 -3
  48. package/dist/watch.d.ts +29 -5
  49. package/dist/watch.js +62 -13
  50. package/dist/write.d.ts +3 -2
  51. package/dist/write.js +52 -19
  52. package/package.json +8 -4
  53. package/sql/10-core.sql +6 -1
  54. package/sql/60-write.sql +47 -18
  55. package/sql/70-read.sql +13 -7
  56. package/sql/75-history.sql +106 -28
  57. package/sql/80-auth.sql +41 -3
  58. package/sql/88-derived.sql +3 -1
  59. package/sql/90-time.sql +118 -30
  60. package/sql/95-seed.system.sql +6 -3
  61. package/sql/99-revision.sql +1 -1
  62. package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/sql/60-write.sql CHANGED
@@ -472,8 +472,12 @@ as $$
472
472
  declare
473
473
  e @SCHEMA@.entity;
474
474
  c record;
475
+ was text;
475
476
  begin
476
477
  select * into c from @SCHEMA@.ctx();
478
+ if p_class is not null then
479
+ select x.class into was from @SCHEMA@.entity x where x.id = p_id;
480
+ end if;
477
481
  update @SCHEMA@.entity x
478
482
  set class = coalesce(p_class, x.class), data = p_data, links = p_links, validator = p_validator,
479
483
  rev = x.rev + 1, prev_hash = x.hash,
@@ -489,6 +493,11 @@ begin
489
493
  select x.* into e from @SCHEMA@.entity x where x.id = p_id;
490
494
  end if;
491
495
  perform @SCHEMA@.log_version(e);
496
+ -- смена класса: сигнал и прежнего класса — его ответы кэша и подписки тоже устарели (log_version сигналит только новый;
497
+ -- путь через триггер шлёт оба; план 2.0, 7.4 №4)
498
+ if was is not null and was <> e.class then
499
+ perform @SCHEMA@.signal(array[was], array[e.tenant]);
500
+ end if;
492
501
  return e;
493
502
  end $$;
494
503
 
@@ -1217,7 +1226,8 @@ begin
1217
1226
  select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash
1218
1227
  from added n
1219
1228
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
1220
- where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
1229
+ where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
1230
+ order by n.at, n.id; -- порядок seq — порядок входа, а не плана соединения
1221
1231
  -- сигнал — по паре «класс, арендатор»
1222
1232
  select array_agg(k.class), array_agg(k.tenant) into sc, st from (select distinct a.class, a.tenant from added a) k;
1223
1233
  perform @SCHEMA@.signal(sc, st);
@@ -1273,7 +1283,8 @@ begin
1273
1283
  from new_rows n
1274
1284
  join old_rows o on o.id = n.id and o.rev <> n.rev
1275
1285
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
1276
- where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
1286
+ where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
1287
+ order by n.id; -- порядок seq не зависит от плана: правка многих строк пишет версии в порядке id
1277
1288
  -- сигнал — по парам нового и прежнего класса: при смене класса без ключа ответы прежнего класса в
1278
1289
  -- кэше других процессов устарели бы (в 1.0.1 сигнал шёл только по новому)
1279
1290
  select array_agg(k.class), array_agg(k.tenant) into sc, st
@@ -1385,6 +1396,24 @@ begin
1385
1396
  return null;
1386
1397
  end $$;
1387
1398
 
1399
+ -- Перед удалением строки Account (ревизия 7, решение владельца). Строки secret аккаунта — сессии, хэши паролей и ключей,
1400
+ -- секреты TOTP, внешние учётные записи — уносит каскад внешнего ключа secret.account в конце оператора, раньше триггера
1401
+ -- после него, поэтому сессии гаснут с сигналом session:<хэш> здесь: процессы сбрасывают кэш ответов этих сессий.
1402
+ -- Срабатывает для всех ролей, в том числе владельца: аккаунты удаляют и функции владельца — каскад trg_entity_ad и
1403
+ -- account_purge. Строка Account у System не удаляется: ей принадлежат служебные строки secret (KEY — ключ прав и
1404
+ -- сигналов, LOCK — строки-замки, PROJECTOR — чекпоинты проекторов), которые каскад стёр бы, а без них установка не пишет.
1405
+ create or replace function @SCHEMA@.trg_entity_bd_account() returns trigger
1406
+ language plpgsql security definer
1407
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1408
+ as $$
1409
+ begin
1410
+ if OLD.id = '@SYSTEM@'::uuid then
1411
+ perform @SCHEMA@.lt_fail('acl_denied', 'аккаунт System не удаляется: ему принадлежат служебные строки установки');
1412
+ end if;
1413
+ perform @SCHEMA@.auth_kill(OLD.id, null);
1414
+ return OLD;
1415
+ end $$;
1416
+
1388
1417
  -- Действие со строкой, которая ссылается на удаляемые id (p_ids), по правилам её концов (p_ends):
1389
1418
  -- cascade сильнее restrict, restrict сильнее unset; null — строка на них не ссылается. Общее правило
1390
1419
  -- для триггера удаления и превью delete_preview() (этап 4, п. 6): превью не расходится с удалением.
@@ -1456,9 +1485,7 @@ declare
1456
1485
  dplan jsonb;
1457
1486
  wplan jsonb;
1458
1487
  denied text;
1459
- accs uuid[];
1460
1488
  mems jsonb;
1461
- lvl_accs uuid[];
1462
1489
  lvl_mems jsonb;
1463
1490
  drows jsonb;
1464
1491
  own uuid[];
@@ -1526,19 +1553,21 @@ begin
1526
1553
  perform @SCHEMA@.agg_mark(g.tenant, @SCHEMA@.agg_of(g.data->'resolved'))
1527
1554
  from gone g where g.class = 'Class' and g.data->'resolved' ? 'aggregate';
1528
1555
  v_author := coalesce(c.author, '@SYSTEM@'::uuid);
1529
- -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
1556
+ -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень, в порядке id: без
1557
+ -- order by порядок seq (а с ним итог delete() и время at) зависел от плана соединения и менялся от прогона к
1558
+ -- прогону; время now_ts() (volatile) планировщик вычисляет уже после сортировки
1530
1559
  insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
1531
1560
  select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
1532
1561
  greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash, '[]'::jsonb
1533
1562
  from gone g
1534
1563
  join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
1535
- where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant;
1564
+ where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
1565
+ order by g.id;
1536
1566
  -- пары «класс, арендатор» для сигнала — строкой «класс арендатор» (пробела нет ни в имени, ни в uuid)
1537
1567
  select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
1538
1568
  coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}'),
1539
- coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
1540
1569
  coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
1541
- into lvl, classes, pairs, accs, mems from gone g;
1570
+ into lvl, classes, pairs, mems from gone g;
1542
1571
  all_ids := lvl;
1543
1572
  while cardinality(lvl) > 0 loop
1544
1573
  -- кто ссылается на уровень: действие по правилам концов (общее с превью)
@@ -1566,20 +1595,19 @@ begin
1566
1595
  from d
1567
1596
  join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
1568
1597
  where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
1598
+ order by d.id -- надгробия уровня в порядке id, как и на уровне 0
1569
1599
  )
1570
1600
  select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
1571
1601
  coalesce(array_agg(distinct d.class || ' ' || d.tenant::text), '{}'),
1572
1602
  jsonb_agg(to_jsonb(d)) filter (where not @SCHEMA@.acl_row_ok(dplan, d.class, d.id, d.owner, d.tenant, d.tags, d.data, d.links)),
1573
- coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
1574
1603
  coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
1575
- into lvl, lvl_classes, lvl_pairs, drows, lvl_accs, lvl_mems from d;
1604
+ into lvl, lvl_classes, lvl_pairs, drows, lvl_mems from d;
1576
1605
  -- класс отказавшей строки называется, только если актор её видит (ревью безопасности №3)
1577
1606
  if drows is not null then
1578
1607
  denied := @SCHEMA@.visible_class(drows);
1579
1608
  perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права DELETE на класс %s в каскаде удаления', denied)
1580
1609
  else 'нет права DELETE на строки в каскаде удаления' end);
1581
1610
  end if;
1582
- accs := accs || lvl_accs;
1583
1611
  mems := mems || lvl_mems;
1584
1612
  classes := classes || lvl_classes;
1585
1613
  pairs := pairs || lvl_pairs;
@@ -1605,14 +1633,11 @@ begin
1605
1633
  end loop;
1606
1634
  -- выход из членства (и удаление аккаунта, которое снимает его членства): строки арендатора, которыми
1607
1635
  -- владел участник, переходят арендатору (§2.10) — новой версией
1608
- for r in select distinct (m->>'tenant')::uuid as tenant, (m->>'user')::uuid as usr from jsonb_array_elements(mems) m loop
1636
+ for r in select distinct (m->>'tenant')::uuid as tenant, (m->>'user')::uuid as usr from jsonb_array_elements(mems) m order by 1, 2 loop
1609
1637
  perform @SCHEMA@.owner_to_tenant(r.tenant, r.usr);
1610
1638
  end loop;
1611
- -- удалённый аккаунт: сессии гаснут, креды удаляются (идентификаторы освобождаются)
1612
- for r in select distinct u as acc from unnest(accs) u loop
1613
- perform @SCHEMA@.auth_kill(r.acc, null);
1614
- update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
1615
- end loop;
1639
+ -- удалённый аккаунт (ревизия 7): сессии погасил с сигналом триггер до удаления (trg_entity_bd_account), его строки
1640
+ -- secret стёр каскад внешнего ключа secret.account — идентификаторы входа свободны
1616
1641
  perform @SCHEMA@.signal(array_agg(split_part(x.p, ' ', 1)), array_agg(split_part(x.p, ' ', 2)::uuid))
1617
1642
  from (select distinct p from unnest(pairs) p) x;
1618
1643
  if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
@@ -1700,6 +1725,9 @@ create or replace trigger entity_au after update on @SCHEMA@.entity referencing
1700
1725
  for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_au();
1701
1726
  create or replace trigger entity_bd before delete on @SCHEMA@.entity
1702
1727
  for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bd();
1728
+ -- удаление аккаунта — для всех ролей, в том числе владельца (каскад trg_entity_ad, account_purge): ревизия 7
1729
+ create or replace trigger entity_bd_account before delete on @SCHEMA@.entity
1730
+ for each row when (OLD.class = 'Account') execute function @SCHEMA@.trg_entity_bd_account();
1703
1731
  create or replace trigger entity_ad after delete on @SCHEMA@.entity referencing old table as gone
1704
1732
  for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ad();
1705
1733
  -- в режиме репликации (session_replication_role = replica) триггеры тоже работают
@@ -1708,6 +1736,7 @@ alter table @SCHEMA@.entity enable always trigger entity_bu;
1708
1736
  alter table @SCHEMA@.entity enable always trigger entity_ai;
1709
1737
  alter table @SCHEMA@.entity enable always trigger entity_au;
1710
1738
  alter table @SCHEMA@.entity enable always trigger entity_bd;
1739
+ alter table @SCHEMA@.entity enable always trigger entity_bd_account;
1711
1740
  alter table @SCHEMA@.entity enable always trigger entity_ad;
1712
1741
 
1713
1742
  -- ========================= смена класса с ключом =========================
@@ -2030,7 +2059,7 @@ begin
2030
2059
  'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'visible_class(jsonb)', 'class_change_safe(jsonb, jsonb)',
2031
2060
  'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_acl_admit(text, uuid)', 'class_rows_admit(text, uuid, text[], text)', 'class_has_history(text, uuid)', 'class_orphan_history(text, uuid)', 'class_admit(jsonb, uuid)',
2032
2061
  'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal_token(text, text, bytea)', 'signal_raw(text[])', 'signal(text[], uuid[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
2033
- 'trg_entity_bd()', 'trg_entity_ad()', 'owner_to_tenant(uuid, uuid)', 'ref_action(jsonb, jsonb, uuid[])', 'delete_preview(uuid[])', 'merge(jsonb, jsonb)', 'inc(jsonb, text[], numeric, numeric)', 'links_patch(jsonb, jsonb)'] loop
2062
+ 'trg_entity_bd()', 'trg_entity_bd_account()', 'trg_entity_ad()', 'owner_to_tenant(uuid, uuid)', 'ref_action(jsonb, jsonb, uuid[])', 'delete_preview(uuid[])', 'merge(jsonb, jsonb)', 'inc(jsonb, text[], numeric, numeric)', 'links_patch(jsonb, jsonb)'] loop
2034
2063
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
2035
2064
  execute format('revoke all on function @SCHEMA@.%s from public', f);
2036
2065
  end loop;
package/sql/70-read.sql CHANGED
@@ -53,9 +53,11 @@ as $$
53
53
  where r.class = any (p_classes) and r.at <= coalesce(p_at, 'infinity'::timestamptz)) c
54
54
  cross join lateral (
55
55
  select y.* from (select x.* from @SCHEMA@.log x where x.id = c.id offset 0) y
56
- where y.class = any (p_classes) and y.at <= coalesce(p_at, 'infinity'::timestamptz)
56
+ where y.at <= coalesce(p_at, 'infinity'::timestamptz)
57
57
  order by y.rev desc limit 1) w
58
- where @SCHEMA@.link_ids(w.links) @> array[p_src]
58
+ -- класс — у последней версии на момент, а не среди версий классов шага: после смены класса без смены id объект
59
+ -- не числится в прежнем классе (план 2.0, 7.1 №1)
60
+ where w.class = any (p_classes) and @SCHEMA@.link_ids(w.links) @> array[p_src]
59
61
  $$;
60
62
 
61
63
  -- id строк классов p_classes: по вложению в data (GIN entity_data), по целям концов (GIN entity_ends),
@@ -134,12 +136,16 @@ begin
134
136
  else
135
137
  -- арендатор id неизменен, записи purge и trim несут арендатора объекта: условие до distinct on не меняет, какая
136
138
  -- версия id последняя (индекс log_class_tenant_at)
139
+ -- кандидаты — по классу (индекс log_class_tenant_at), последняя версия id на момент — любого класса, и класс
140
+ -- проверяется у неё: иначе после смены класса без смены id объект числился бы в прежнем классе (план 2.0, 7.1 №1)
137
141
  return query select v.id, v.rev from (
138
- select distinct on (l.id) l.id, l.rev, l.op, l.class, l.tenant, l.owner, l.tags, l.data, l.links
139
- from @SCHEMA@.log l
140
- where l.class = any (p_classes) and l.tenant = any (v_tenants) and l.at <= coalesce(p_at, 'infinity'::timestamptz)
141
- order by l.id, l.rev desc) v
142
- where (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
142
+ select distinct l.id from @SCHEMA@.log l
143
+ where l.class = any (p_classes) and l.tenant = any (v_tenants) and l.at <= coalesce(p_at, 'infinity'::timestamptz)) c
144
+ cross join lateral (
145
+ select y.id, y.rev, y.op, y.class, y.tenant, y.owner, y.tags, y.data, y.links
146
+ from @SCHEMA@.log y where y.id = c.id and y.at <= coalesce(p_at, 'infinity'::timestamptz)
147
+ order by y.rev desc limit 1) v
148
+ where v.class = any (p_classes) and (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
143
149
  and (p_data is null or v.data @> p_data) and (p_ends is null or @SCHEMA@.link_ids(v.links) @> p_ends)
144
150
  and @SCHEMA@.row_visible(v.tenant, v.class, v.id, v.owner, v.tags, v.data, v.links, c_tenant, c_account, c_plan);
145
151
  -- свои членства в других арендаторах: версии, ссылавшиеся на аккаунт (GIN log_ends)
@@ -11,6 +11,7 @@
11
11
  -- change_paths — пути для колонки changed (пишут триггер правки и log_version);
12
12
  -- row_leaves — листья снимка строки (то, что объясняет blame);
13
13
  -- version_paths — пути из changed тех версий, у которых предыдущая версия видна вызывающему;
14
+ -- history_segments — отрезки истории объекта по переносам id (follow и blame);
14
15
  -- diff_versions — что изменилось между двумя версиями или моментами (jsonb в виде ответа diff());
15
16
  -- blame — какая версия, кто, когда и почему последней изменила каждый лист (ответ blame()).
16
17
 
@@ -154,21 +155,79 @@ begin
154
155
  if c_account is null then
155
156
  return;
156
157
  end if;
158
+ -- обе версии — поиском по (id, rev) на каждую пару: lateral с limit 1 не даёт планировщику соединить журнал хэшем.
159
+ -- Свой план вызова (первые пять на соединении) по фильтру видимости оценивал версии n в 2 строки и искал
160
+ -- предыдущую версию по одному id, проверяя номер фильтром, — квадрат от числа версий: blame по 1000 версиям
161
+ -- ~250 мс, по 10 000 — секунды (план 2.0, попутно с 7.1 №2)
157
162
  return query
158
163
  select n.id, n.rev, n.changed
159
164
  from unnest(p_ids, p_revs) q(id, rev)
160
- join @SCHEMA@.log n on n.id = q.id and n.rev = q.rev
161
- join @SCHEMA@.log o on o.id = n.id and o.rev = n.rev - 1
162
- where n.changed is not null
163
- and n.op not in ('purge', 'trim', 'reset') and o.op not in ('purge', 'trim', 'reset')
164
- and @SCHEMA@.row_visible(n.tenant, n.class, n.id, n.owner, n.tags, n.data, n.links, c_tenant, c_account, c_plan)
165
- and @SCHEMA@.row_visible(o.tenant, o.class, o.id, o.owner, o.tags, o.data, o.links, c_tenant, c_account, c_plan);
165
+ cross join lateral (select x.id, x.rev, x.changed from @SCHEMA@.log x
166
+ where x.id = q.id and x.rev = q.rev and x.changed is not null and x.op not in ('purge', 'trim', 'reset')
167
+ and @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links, c_tenant, c_account, c_plan)
168
+ limit 1) n
169
+ cross join lateral (select 1 from @SCHEMA@.log o
170
+ where o.id = n.id and o.rev = n.rev - 1 and o.op not in ('purge', 'trim', 'reset')
171
+ and @SCHEMA@.row_visible(o.tenant, o.class, o.id, o.owner, o.tags, o.data, o.links, c_tenant, c_account, c_plan)
172
+ limit 1) o;
173
+ end $$;
174
+
175
+ -- Отрезки истории объекта (план 2.0, 7.1 №2): версии id с номерами (lo, hi], hi null — до последней. Границы
176
+ -- отрезков на id — переносы: надгробие переноса (delete с moved) кончает отрезок, первая версия на новом id (reclass,
177
+ -- rekey с moved) начинает. Объект, заведённый на прежнем id после переноса, — другой отрезок: follow и blame его не
178
+ -- берут (прежде брали все версии всех связанных id). Начало — отрезок с версией p_rev объекта p_id (n = 0), дальше по
179
+ -- переносам назад (n < 0) и, если не p_back, вперёд (n > 0). Перенос пишется парой строк с moved друг на друга
180
+ -- (надгробие на старом id → новый id, первая версия на новом → старый): строки переноса id находит индекс log_moved
181
+ -- через парную строку, без прохода по версиям id; у объекта без переносов — один пустой поиск. Строка, чья пара стёрта
182
+ -- (история другой стороны удалена целиком или обрезана), границей не считается. Пара одного переноса — ближайшая по
183
+ -- seq (время у них может разойтись на микросекунды). Тот же обход строит versions({ follow }) в построителе
184
+ -- (lib/src/sql.ts:buildRead): меняются вместе. plpgsql, а не sql: план запроса кэшируется (тело функции на sql
185
+ -- планировалось на каждом вызове).
186
+ create or replace function @SCHEMA@.history_segments(p_id uuid, p_rev int, p_back boolean default false)
187
+ returns table (id uuid, lo int, hi int, n int)
188
+ language plpgsql stable
189
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
190
+ as $$
191
+ #variable_conflict use_column
192
+ begin
193
+ return query with recursive s(id, lo, hi, n) as (
194
+ select p_id, b.lo, b.hi, 0 from (
195
+ select greatest(coalesce(max(x.rev) filter (where x.op = 'delete' and x.rev < p_rev), 0),
196
+ coalesce(max(x.rev - 1) filter (where x.op <> 'delete' and x.rev <= p_rev), 0)) as lo,
197
+ least(min(x.rev) filter (where x.op = 'delete' and x.rev >= p_rev),
198
+ min(x.rev - 1) filter (where x.op <> 'delete' and x.rev > p_rev)) as hi
199
+ from @SCHEMA@.log m join @SCHEMA@.log x on x.moved = m.id and x.id = p_id where m.moved = p_id) b
200
+ union
201
+ select g.id, b.lo, b.hi, g.n
202
+ from s cross join lateral (
203
+ -- назад: первая версия отрезка — прибытие с прежнего id; прежний отрезок кончается надгробием переноса сюда
204
+ select a.moved as id, (select t.rev from @SCHEMA@.log t where t.id = a.moved and t.op = 'delete' and t.moved = s.id
205
+ order by abs(t.seq - a.seq) limit 1) as r, s.n - 1 as n
206
+ from @SCHEMA@.log a
207
+ where s.n <= 0 and a.id = s.id and a.rev = s.lo + 1 and a.moved is not null and a.op <> 'delete'
208
+ union all
209
+ -- вперёд: последняя версия отрезка — надгробие переноса; следующий отрезок начинается прибытием отсюда
210
+ select t.moved, (select a.rev from @SCHEMA@.log a where a.id = t.moved and a.op <> 'delete' and a.moved = s.id
211
+ order by abs(a.seq - t.seq) limit 1), s.n + 1
212
+ from @SCHEMA@.log t
213
+ where not p_back and s.n >= 0 and t.id = s.id and t.rev = s.hi and t.op = 'delete' and t.moved is not null
214
+ ) g
215
+ cross join lateral (
216
+ select greatest(coalesce(max(x.rev) filter (where x.op = 'delete' and x.rev < g.r), 0),
217
+ coalesce(max(x.rev - 1) filter (where x.op <> 'delete' and x.rev <= g.r), 0)) as lo,
218
+ least(min(x.rev) filter (where x.op = 'delete' and x.rev >= g.r),
219
+ min(x.rev - 1) filter (where x.op <> 'delete' and x.rev > g.r)) as hi
220
+ from @SCHEMA@.log m join @SCHEMA@.log x on x.moved = m.id and x.id = g.id where m.moved = g.id) b
221
+ where g.r is not null and abs(g.n) <= 1000
222
+ )
223
+ select s.id, s.lo, s.hi, s.n from s;
166
224
  end $$;
167
225
 
168
226
  -- diff: что изменилось между двумя версиями объекта (ответ терминала diff()). «После» — номер версии
169
227
  -- p_to, последняя версия на момент p_to_at или последняя вообще; «до» — номер p_from, последняя живая
170
228
  -- версия на момент p_from_at (объекта не было — null, все пути added) или версия перед «после».
171
- -- p_follow — через смену id (reclass, rekey) по колонке moved; надгробие переноса — не версия.
229
+ -- p_follow — через смену id (reclass, rekey) по колонке moved: отрезки истории этого объекта (history_segments) от
230
+ -- версии p_to или последней версии p_id; надгробие переноса — не версия.
172
231
  -- «После» — надгробие: to.deleted, изменения полей не перечисляются.
173
232
  create or replace function @SCHEMA@.diff_versions(p_id uuid, p_from int default null, p_to int default null,
174
233
  p_from_at timestamptz default null, p_to_at timestamptz default null, p_follow boolean default false)
@@ -181,21 +240,26 @@ set jit = off
181
240
  as $$
182
241
  declare
183
242
  ids uuid[] := array[p_id];
243
+ los int[] := array[0];
244
+ his int[] := array[null::int];
245
+ multi boolean := false;
246
+ whole boolean := true;
184
247
  t record;
185
248
  f record;
186
249
  has_f boolean;
187
250
  begin
188
251
  if p_follow then
189
- with recursive g(id) as (
190
- select p_id
191
- union
192
- select x.id from g cross join lateral (
193
- select l.moved as id from @SCHEMA@.log l where l.id = g.id and l.moved is not null
194
- union select l.id from @SCHEMA@.log l where l.moved = g.id) x)
195
- select array_agg(g.id) into ids from g;
252
+ select array_agg(s.id), array_agg(s.lo), array_agg(s.hi) into ids, los, his
253
+ from (select distinct g.id, g.lo, g.hi from @SCHEMA@.history_segments(p_id, coalesce(p_to, (select max(l.rev) from @SCHEMA@.log l
254
+ where l.id = p_id and l.op not in ('purge', 'trim', 'reset')))) g) s;
255
+ multi := cardinality(ids) > (select count(distinct x) from unnest(ids) x);
256
+ whole := cardinality(ids) = 1 and los[1] = 0 and his[1] is null;
196
257
  end if;
197
258
  select l.id, l.rev, l.at, l.seq, l.op, l.class, l.data, l.links, l.tags, l.owner into t from @SCHEMA@.log l
198
259
  where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (p_follow and l.op = 'delete' and l.moved is not null)
260
+ and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
261
+ and l.rev <= coalesce(s.hi, 2147483647))
262
+ else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
199
263
  and (p_to is null or (l.id = p_id and l.rev = p_to)) and (p_to_at is null or l.at <= p_to_at)
200
264
  order by l.at desc, l.seq desc limit 1;
201
265
  if t.id is null then
@@ -208,6 +272,9 @@ begin
208
272
  elsif p_from_at is not null then
209
273
  select l.id, l.rev, l.at, l.op, l.class, l.data, l.links, l.tags, l.owner into f from @SCHEMA@.log l
210
274
  where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (p_follow and l.op = 'delete' and l.moved is not null)
275
+ and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
276
+ and l.rev <= coalesce(s.hi, 2147483647))
277
+ else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
211
278
  and l.at <= p_from_at
212
279
  order by l.at desc, l.seq desc limit 1;
213
280
  -- срез на момент: удалённого объекта тогда не было
@@ -215,6 +282,9 @@ begin
215
282
  else
216
283
  select l.id, l.rev, l.at, l.op, l.class, l.data, l.links, l.tags, l.owner into f from @SCHEMA@.log l
217
284
  where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (p_follow and l.op = 'delete' and l.moved is not null)
285
+ and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
286
+ and l.rev <= coalesce(s.hi, 2147483647))
287
+ else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
218
288
  and (l.at, l.seq) < (t.at, t.seq)
219
289
  order by l.at desc, l.seq desc limit 1;
220
290
  has_f := f.id is not null;
@@ -241,7 +311,9 @@ end $$;
241
311
  -- blame: для каждого листа версии (последней на момент p_at или текущей) — версия, которая последней его
242
312
  -- изменила, с автором, агентом, видом операции и причиной (ответ терминала blame()). Просматривается не
243
313
  -- больше p_limit последних версий, назад через смену id (первая версия нового id указывает на прежний
244
- -- колонкой moved). Пропуск номера версии (прореживание, обрезка, невидимая версия) — выпавшая версия могла
314
+ -- колонкой moved) — по отрезкам истории этого объекта (history_segments): объект, что жил на этом id до переноса
315
+ -- или заведён на прежнем id после, — не его история; начало истории — первая версия самого раннего отрезка (у
316
+ -- объекта без переносов — версия 1). Пропуск номера версии (прореживание, обрезка, невидимая версия) — выпавшая версия могла
245
317
  -- изменить лист и вернуть значение, поэтому лист точен (exact), только если от текущей версии до найденной
246
318
  -- пропусков нет; лист, не менявшийся в просмотренной истории, принадлежит самой старой просмотренной версии —
247
319
  -- exact, только если это версия 1 и пропусков нет вовсе. complete — история просмотрена целиком и без пропусков.
@@ -254,9 +326,12 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
254
326
  set jit = off
255
327
  as $$
256
328
  declare
257
- ids uuid[] := array[p_id];
329
+ ids uuid[];
330
+ los int[];
331
+ his int[];
332
+ multi boolean;
333
+ whole boolean;
258
334
  t record;
259
- prev uuid;
260
335
  lim int := greatest(coalesce(p_limit, 1000), 1);
261
336
  res jsonb;
262
337
  begin
@@ -264,12 +339,11 @@ begin
264
339
  where l.id = p_id and l.op not in ('purge', 'trim', 'reset') and (p_at is null or l.at <= p_at)
265
340
  order by l.rev desc limit 1;
266
341
  if t.id is null then return null; end if;
267
- loop
268
- select l.moved into prev from @SCHEMA@.log l
269
- where l.id = ids[cardinality(ids)] and l.moved is not null and l.op in ('reclass', 'rekey') order by l.rev limit 1;
270
- exit when prev is null or prev = any (ids);
271
- ids := ids || prev;
272
- end loop;
342
+ -- отрезки от самого раннего: ids[1], los[1] — начало истории объекта
343
+ select array_agg(s.id order by s.n), array_agg(s.lo order by s.n), array_agg(s.hi order by s.n) into ids, los, his
344
+ from (select g.id, g.lo, g.hi, min(g.n) as n from @SCHEMA@.history_segments(p_id, t.rev, true) g group by 1, 2, 3) s;
345
+ multi := cardinality(ids) > (select count(distinct x) from unnest(ids) x);
346
+ whole := cardinality(ids) = 1 and los[1] = 0 and his[1] is null;
273
347
  with v as (
274
348
  -- соседняя более старая версия — окном (lead), а не соединением CTE с собой: соединение шло бы вложенным
275
349
  -- циклом по квадрату числа версий
@@ -279,6 +353,9 @@ begin
279
353
  lead(l.owner) over o as o_owner, lead(l.class) over o as o_class
280
354
  from @SCHEMA@.log l
281
355
  where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (l.op = 'delete' and l.moved is not null)
356
+ and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
357
+ and l.rev <= coalesce(s.hi, 2147483647))
358
+ else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
282
359
  and (l.at, l.seq) <= (t.at, t.seq)
283
360
  window o as (order by l.at desc, l.seq desc)
284
361
  order by l.at desc, l.seq desc limit lim + 1
@@ -312,14 +389,15 @@ begin
312
389
  from leaves lv join ch on lv.path[1:cardinality(ch.path)] = ch.path or ch.path[1:cardinality(lv.path)] = lv.path
313
390
  group by lv.path
314
391
  ), oldest as (
315
- select w.n, w.rev from w order by w.n desc limit 1
392
+ -- first — самая старая версия окна и есть начало истории объекта
393
+ select w.n, w.rev, w.id = ids[1] and w.rev = los[1] + 1 as first from w order by w.n desc limit 1
316
394
  ), cut as (
317
395
  select count(*) > lim as cut from v
318
396
  ), fields as (
319
397
  select lv.path,
320
398
  jsonb_build_object('path', to_jsonb(lv.path), 'rev', cv.rev, 'at', cv.at, 'author', cv.author, 'op', cv.op,
321
399
  'exact', case when h.n is not null then not exists (select 1 from gaps g where g.n <= h.n)
322
- else (select o.rev = 1 from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps) end)
400
+ else (select o.first from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps) end)
323
401
  || case when cv.id <> p_id then jsonb_build_object('id', cv.id) else '{}'::jsonb end
324
402
  || case when cv.agent is not null then jsonb_build_object('agent', cv.agent) else '{}'::jsonb end
325
403
  || case when cv.reason is not null then jsonb_build_object('reason', cv.reason) else '{}'::jsonb end as obj
@@ -328,7 +406,7 @@ begin
328
406
  join w cv on cv.n = coalesce(h.n, (select o.n from oldest o))
329
407
  )
330
408
  select jsonb_build_object('id', p_id, 'class', t.class, 'rev', t.rev,
331
- 'complete', (select o.rev = 1 from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps),
409
+ 'complete', (select o.first from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps),
332
410
  'fields', coalesce((select jsonb_agg(f.obj order by f.path) from fields f), '[]'::jsonb))
333
411
  || case when t.op = 'delete' then '{"deleted": true}'::jsonb else '{}'::jsonb end
334
412
  into res;
@@ -342,7 +420,7 @@ begin
342
420
  'jsonb_changes(jsonb, jsonb, text[])',
343
421
  'row_changes(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text)',
344
422
  'change_paths(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text)',
345
- 'row_leaves(jsonb, jsonb, text[], uuid, text)', 'version_paths(uuid[], int[])',
423
+ 'row_leaves(jsonb, jsonb, text[], uuid, text)', 'version_paths(uuid[], int[])', 'history_segments(uuid, int, boolean)',
346
424
  'diff_versions(uuid, int, int, timestamptz, timestamptz, boolean)', 'blame(uuid, timestamptz, int)'] loop
347
425
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
348
426
  execute format('revoke all on function @SCHEMA@.%s from public', f);
@@ -354,5 +432,5 @@ grant execute on function @SCHEMA@.jsonb_changes(jsonb, jsonb, text[]),
354
432
  @SCHEMA@.row_changes(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text),
355
433
  @SCHEMA@.change_paths(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text),
356
434
  @SCHEMA@.row_leaves(jsonb, jsonb, text[], uuid, text), @SCHEMA@.version_paths(uuid[], int[]),
357
- @SCHEMA@.diff_versions(uuid, int, int, timestamptz, timestamptz, boolean),
435
+ @SCHEMA@.history_segments(uuid, int, boolean), @SCHEMA@.diff_versions(uuid, int, int, timestamptz, timestamptz, boolean),
358
436
  @SCHEMA@.blame(uuid, timestamptz, int) to @APP@;
package/sql/80-auth.sql CHANGED
@@ -755,7 +755,8 @@ declare
755
755
  s record;
756
756
  begin
757
757
  if nullif(current_setting('letopis.account', true), '') is not null then
758
- perform @SCHEMA@.lt_fail('invalid_query', 'auth.switch — для собственной сессии, не при имперсонации');
758
+ -- код из каталога базы (invalid_query в нём нет — lt_fail дал бы LT000 и сырую ошибку; план 2.0, 7.5 №3)
759
+ perform @SCHEMA@.lt_fail('acl_denied', 'auth.switch — для собственной сессии, не при имперсонации');
759
760
  end if;
760
761
  select x.id, x.account into s from @SCHEMA@.secret x
761
762
  where x.kind = 'SESSION' and x.deleted_at is null and x.material = sha256(convert_to(coalesce(tok, ''), 'UTF8'))
@@ -795,6 +796,43 @@ begin
795
796
  where m.class = 'member' and m.links->>'User' = c.account::text and @SCHEMA@.auth_enabled(m.tenant);
796
797
  end $$;
797
798
 
799
+ -- Сведения о сессии по токену (db.auth.session): сервис, у которого есть токен пользователя (например, из
800
+ -- cookie), узнаёт, чья это сессия, жива ли она и в каком арендаторе работает, не подключаясь по этому токену.
801
+ -- Право auth.session, как у issue_session. Неверный, погашенный или истёкший токен и отключённый аккаунт — null:
802
+ -- причина не различается, как в ctx(). Сессия ищется только среди SESSION: ключ API или код с тем же sha256 не
803
+ -- подойдут. member — проверка членства из ctx() без исключения: членство в рабочем арендаторе цело и арендатор
804
+ -- включён; собственный арендатор — всегда true.
805
+ create or replace function @SCHEMA@.auth_session_info(p_token text) returns jsonb
806
+ language plpgsql stable security definer
807
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
808
+ as $$
809
+ declare
810
+ c record;
811
+ s record;
812
+ t uuid;
813
+ begin
814
+ select * into c from @SCHEMA@.ctx();
815
+ perform @SCHEMA@.auth_session_required(c.account);
816
+ perform @SCHEMA@.acl_require('auth.session');
817
+ select x.account, x.meta, x.expires_at into s from @SCHEMA@.secret x
818
+ where x.kind = 'SESSION' and x.deleted_at is null and x.material = sha256(convert_to(coalesce(p_token, ''), 'UTF8'))
819
+ and (x.expires_at is null or x.expires_at > clock_timestamp());
820
+ if s.account is null or not @SCHEMA@.auth_enabled(s.account) then
821
+ return null;
822
+ end if;
823
+ t := coalesce((s.meta->>'tenant')::uuid, s.account);
824
+ return jsonb_build_object(
825
+ 'account', s.account,
826
+ 'tenant', t,
827
+ 'member', t = s.account or (
828
+ exists (select 1 from @SCHEMA@.entity x
829
+ where x.id = @SCHEMA@.v5_id(t, 'member', array[s.account::text, t::text]) and x.class = 'member' and x.tenant = t)
830
+ and @SCHEMA@.auth_enabled(t)),
831
+ 'expiresAt', s.expires_at,
832
+ 'loginAt', s.meta->'login_at',
833
+ 'methods', coalesce(s.meta->'methods', '[]'::jsonb));
834
+ end $$;
835
+
798
836
  do $$
799
837
  declare f text;
800
838
  begin
@@ -807,7 +845,7 @@ begin
807
845
  'login_otp(text, text, text)', 'totp_code(bytea, bigint)', 'auth_enroll_totp(uuid)', 'auth_verify_totp(uuid, text, int)',
808
846
  'auth_totp_enabled(uuid)', 'auth_link(uuid, text, text, jsonb, boolean)', 'auth_lookup(text, text, boolean)',
809
847
  'auth_credentials(uuid)', 'auth_revoke_credential(uuid, boolean)', 'auth_set_flags(uuid, boolean, boolean)',
810
- 'auth_refresh(interval)', 'auth_logout()', 'auth_logout_all(uuid)', 'auth_switch(uuid)', 'auth_tenants()'] loop
848
+ 'auth_refresh(interval)', 'auth_logout()', 'auth_logout_all(uuid)', 'auth_switch(uuid)', 'auth_tenants()', 'auth_session_info(text)'] loop
811
849
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
812
850
  execute format('revoke all on function @SCHEMA@.%s from public', f);
813
851
  end loop;
@@ -824,4 +862,4 @@ grant execute on function
824
862
  @SCHEMA@.auth_totp_enabled(uuid), @SCHEMA@.auth_link(uuid, text, text, jsonb, boolean), @SCHEMA@.auth_lookup(text, text, boolean),
825
863
  @SCHEMA@.auth_credentials(uuid), @SCHEMA@.auth_revoke_credential(uuid, boolean), @SCHEMA@.auth_set_flags(uuid, boolean, boolean),
826
864
  @SCHEMA@.auth_refresh(interval), @SCHEMA@.auth_logout(), @SCHEMA@.auth_logout_all(uuid), @SCHEMA@.auth_switch(uuid),
827
- @SCHEMA@.auth_tenants() to @APP@;
865
+ @SCHEMA@.auth_tenants(), @SCHEMA@.auth_session_info(text) to @APP@;
@@ -25,7 +25,8 @@ begin
25
25
  for update;
26
26
  if not found then
27
27
  update @SCHEMA@.secret s set deleted_at = clock_timestamp()
28
- where s.kind = 'PROJECTOR' and casefold(s.ident) = casefold(p_name) and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
28
+ where s.kind = 'PROJECTOR' and casefold(s.ident) collate pg_unicode_fast = casefold(p_name) collate pg_unicode_fast
29
+ and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
29
30
  insert into @SCHEMA@.secret (account, kind, ident, meta) values ('@SYSTEM@'::uuid, 'PROJECTOR', p_name, '{}'::jsonb);
30
31
  return '{}'::jsonb;
31
32
  end if;
@@ -272,6 +273,7 @@ begin
272
273
  from g
273
274
  join (select d.class, d.tenant from (select distinct y.class, y.tenant from g y) d
274
275
  where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
276
+ order by g.id -- надгробия в порядке id, как в trg_entity_ad
275
277
  )
276
278
  select count(*)::int, coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}') into n, pairs from g;
277
279
  if n > 0 then