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/70-read.sql CHANGED
@@ -40,7 +40,7 @@ return exists (select 1 from unnest(p_roles) r(role) where (p_links -> r.role) @
40
40
  -- не позже p_at; p_at null — последняя вообще), которые ссылаются на p_src: кандидаты — версии,
41
41
  -- ссылавшиеся на p_src (GIN log_ends), затем последняя версия каждого кандидата (log_id_rev); ссылку
42
42
  -- проверяет она. Класс и время кандидатов — после пробы (offset 0): иначе планировщик объединяет пробу
43
- -- с log_class_at и читает весь класс в индексе. Без прав: вызывают только функции владельца find_*,
43
+ -- с log_class_tenant_at и читает весь класс в индексе. Без прав: вызывают только функции владельца find_*,
44
44
  -- видимость — на них.
45
45
  create or replace function @SCHEMA@.log_refs_at(p_classes text[], p_src uuid, p_at timestamptz)
46
46
  returns setof @SCHEMA@.log
@@ -60,6 +60,10 @@ $$;
60
60
 
61
61
  -- id строк классов p_classes: по вложению в data (GIN entity_data), по целям концов (GIN entity_ends),
62
62
  -- по списку id (первичный ключ) или по классу (entity_class_tenant).
63
+ -- Арендатор строки — явным условием (доработки после точки Б): row_visible пропускает только строки арендатора
64
+ -- сессии и System и свои членства (member) в других арендаторах, поэтому поиск сразу берёт строки этих двух
65
+ -- арендаторов, а свои членства — отдельным запросом по концу User; иначе цена поиска росла с данными других
66
+ -- арендаторов с теми же именами классов. По списку id условие ничего не даёт — там первичный ключ.
63
67
  create or replace function @SCHEMA@.find_ids(p_classes text[], p_data jsonb, p_ends uuid[], p_ids uuid[],
64
68
  p_at timestamptz default null, p_dead boolean default false)
65
69
  returns table (id uuid, rev int)
@@ -72,6 +76,8 @@ declare
72
76
  c_tenant uuid := (a->>'tenant')::uuid;
73
77
  c_account uuid := (a->>'account')::uuid;
74
78
  c_plan jsonb := a->'plan';
79
+ v_tenants uuid[] := array[(a->>'tenant')::uuid, '@SYSTEM@'::uuid];
80
+ v_member boolean := 'member' = any (p_classes);
75
81
  begin
76
82
  if c_account is null then
77
83
  return;
@@ -83,21 +89,29 @@ begin
83
89
  where e.id = any (p_ids) and e.class = any (p_classes)
84
90
  and (p_data is null or e.data @> p_data) and (p_ends is null or e.ends @> p_ends)
85
91
  and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
92
+ return;
86
93
  elsif p_data is not null and p_ends is not null then
87
94
  return query select e.id, e.rev from @SCHEMA@.entity e
88
- where e.class = any (p_classes) and e.data @> p_data and e.ends @> p_ends
95
+ where e.class = any (p_classes) and e.tenant = any (v_tenants) and e.data @> p_data and e.ends @> p_ends
89
96
  and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
90
97
  elsif p_data is not null then
91
98
  return query select e.id, e.rev from @SCHEMA@.entity e
92
- where e.class = any (p_classes) and e.data @> p_data
99
+ where e.class = any (p_classes) and e.tenant = any (v_tenants) and e.data @> p_data
93
100
  and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
94
101
  elsif p_ends is not null then
95
102
  return query select e.id, e.rev from @SCHEMA@.entity e
96
- where e.class = any (p_classes) and e.ends @> p_ends
103
+ where e.class = any (p_classes) and e.tenant = any (v_tenants) and e.ends @> p_ends
97
104
  and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
98
105
  else
99
106
  return query select e.id, e.rev from @SCHEMA@.entity e
100
- where e.class = any (p_classes)
107
+ where e.class = any (p_classes) and e.tenant = any (v_tenants)
108
+ and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
109
+ end if;
110
+ -- свои членства в других арендаторах: по концу User (GIN entity_ends)
111
+ if v_member then
112
+ return query select e.id, e.rev from @SCHEMA@.entity e
113
+ where e.class = 'member' and e.ends @> array[c_account] and not (e.tenant = any (v_tenants))
114
+ and (p_data is null or e.data @> p_data) and (p_ends is null or e.ends @> p_ends)
101
115
  and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
102
116
  end if;
103
117
  elsif p_ids is not null then
@@ -111,20 +125,31 @@ begin
111
125
  and (p_data is null or v.data @> p_data) and (p_ends is null or @SCHEMA@.link_ids(v.links) @> p_ends)
112
126
  and @SCHEMA@.row_visible(v.tenant, v.class, v.id, v.owner, v.tags, v.data, v.links, c_tenant, c_account, c_plan);
113
127
  elsif p_ends is not null then
114
- -- по целям концов — от версий, ссылавшихся на первую цель (GIN log_ends)
128
+ -- по целям концов — от версий, ссылавшихся на первую цель (GIN log_ends); арендатор — после пробы
115
129
  return query select v.id, v.rev from @SCHEMA@.log_refs_at(p_classes, p_ends[1], p_at) v
116
- where (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
130
+ where (v.tenant = any (v_tenants) or v.class = 'member')
131
+ and (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
117
132
  and (p_data is null or v.data @> p_data) and @SCHEMA@.link_ids(v.links) @> p_ends
118
133
  and @SCHEMA@.row_visible(v.tenant, v.class, v.id, v.owner, v.tags, v.data, v.links, c_tenant, c_account, c_plan);
119
134
  else
135
+ -- арендатор id неизменен, записи purge и trim несут арендатора объекта: условие до distinct on не меняет, какая
136
+ -- версия id последняя (индекс log_class_tenant_at)
120
137
  return query select v.id, v.rev from (
121
138
  select distinct on (l.id) l.id, l.rev, l.op, l.class, l.tenant, l.owner, l.tags, l.data, l.links
122
139
  from @SCHEMA@.log l
123
- where l.class = any (p_classes) and l.at <= coalesce(p_at, 'infinity'::timestamptz)
140
+ where l.class = any (p_classes) and l.tenant = any (v_tenants) and l.at <= coalesce(p_at, 'infinity'::timestamptz)
124
141
  order by l.id, l.rev desc) v
125
142
  where (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
126
143
  and (p_data is null or v.data @> p_data) and (p_ends is null or @SCHEMA@.link_ids(v.links) @> p_ends)
127
144
  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
+ -- свои членства в других арендаторах: версии, ссылавшиеся на аккаунт (GIN log_ends)
146
+ if v_member then
147
+ return query select v.id, v.rev from @SCHEMA@.log_refs_at(array['member'], c_account, p_at) v
148
+ where not (v.tenant = any (v_tenants))
149
+ and (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
150
+ and (p_data is null or v.data @> p_data) and (p_ends is null or @SCHEMA@.link_ids(v.links) @> p_ends)
151
+ and @SCHEMA@.row_visible(v.tenant, v.class, v.id, v.owner, v.tags, v.data, v.links, c_tenant, c_account, c_plan);
152
+ end if;
128
153
  end if;
129
154
  end $$;
130
155
 
@@ -0,0 +1,358 @@
1
+ -- letopis 1.1 — история по журналу: diff и blame (план 1.1, §2.1, этап 1).
2
+ --
3
+ -- diff_versions и blame — с правами вызывающего (security invoker): журнал читается под политикой log_select,
4
+ -- поэтому чужие и скрытые версии в расчёт не попадают. Невидимая версия выглядит как пропуск номера
5
+ -- версии — такие результаты помечаются exact: false. Колонка log.changed приложению не выдана (ревью №1
6
+ -- точки А плана 1.1): пути версии описывают разницу с предыдущей версией, а та читателю может быть не видна;
7
+ -- blame получает их функцией владельца version_paths, только когда предыдущая версия видна. Строку журнала
8
+ -- целиком (l.*) приложение не читает — функции перечисляют колонки.
9
+ -- jsonb_changes — различия двух значений jsonb по путям;
10
+ -- row_changes — различия двух снимков строки по разделам;
11
+ -- change_paths — пути для колонки changed (пишут триггер правки и log_version);
12
+ -- row_leaves — листья снимка строки (то, что объясняет blame);
13
+ -- version_paths — пути из changed тех версий, у которых предыдущая версия видна вызывающему;
14
+ -- diff_versions — что изменилось между двумя версиями или моментами (jsonb в виде ответа diff());
15
+ -- blame — какая версия, кто, когда и почему последней изменила каждый лист (ответ blame()).
16
+
17
+ -- Различия значений по путям: объекты — по ключам рекурсивно, массивы и скаляры — целиком. Отсутствующий
18
+ -- ключ (SQL NULL) и JSON null различаются: added — ключа не было, removed — ключа не стало, changed —
19
+ -- значение другое. Добавленный или удалённый объект раскрывается до листьев; пустой объект — лист.
20
+ create or replace function @SCHEMA@.jsonb_changes(p_a jsonb, p_b jsonb, p_prefix text[] default '{}')
21
+ returns table (path text[], kind text, before jsonb, after jsonb)
22
+ language sql immutable parallel safe
23
+ begin atomic
24
+ with recursive w(path, a, b) as (
25
+ select p_prefix, p_a, p_b
26
+ union all
27
+ select w.path || k.key, w.a -> k.key, w.b -> k.key
28
+ from w cross join lateral (
29
+ select jsonb_object_keys(case when jsonb_typeof(w.a) = 'object' then w.a else '{}'::jsonb end) as key
30
+ union
31
+ select jsonb_object_keys(case when jsonb_typeof(w.b) = 'object' then w.b else '{}'::jsonb end)
32
+ ) k
33
+ where (w.a is null or jsonb_typeof(w.a) = 'object') and (w.b is null or jsonb_typeof(w.b) = 'object')
34
+ and w.a is distinct from w.b
35
+ and not (w.a is null and w.b = '{}'::jsonb) and not (w.b is null and w.a = '{}'::jsonb)
36
+ )
37
+ select w.path, case when w.a is null then 'added' when w.b is null then 'removed' else 'changed' end, w.a, w.b
38
+ from w
39
+ where w.a is distinct from w.b
40
+ and not ((w.a is null or jsonb_typeof(w.a) = 'object') and (w.b is null or jsonb_typeof(w.b) = 'object')
41
+ and not (w.a is null and w.b = '{}'::jsonb) and not (w.b is null and w.a = '{}'::jsonb))
42
+ order by 1;
43
+ end;
44
+
45
+ -- Различия двух снимков строки по разделам: data — jsonb_changes с путём от 'data'; одиночный конец —
46
+ -- значение целиком; множественный конец и теги — множества: added и removed по каждому элементу (elem —
47
+ -- сам элемент); owner и class — значение. Снимок «до» пуст (объекта не было): всё added.
48
+ create or replace function @SCHEMA@.row_changes(a_data jsonb, a_links jsonb, a_tags text[], a_owner uuid, a_class text,
49
+ b_data jsonb, b_links jsonb, b_tags text[], b_owner uuid, b_class text)
50
+ returns table (path text[], kind text, before jsonb, after jsonb, elem text)
51
+ language sql immutable parallel safe
52
+ begin atomic
53
+ with r as (
54
+ select k.key, a_links -> k.key as a, b_links -> k.key as b
55
+ from (select jsonb_object_keys(coalesce(a_links, '{}'::jsonb)) as key
56
+ union select jsonb_object_keys(coalesce(b_links, '{}'::jsonb))) k
57
+ ), m as (
58
+ -- изменившиеся множественные концы: только они раскладываются на элементы, и разностью множеств, а не
59
+ -- проверкой вхождения на каждый элемент — та шла квадратом длины, и правка любого поля объекта с тысячами
60
+ -- целей стоила секунды в триггере правки (ревью №1 точки А)
61
+ select r.key, coalesce(r.a, '[]'::jsonb) as a, coalesce(r.b, '[]'::jsonb) as b from r
62
+ where r.a is distinct from r.b
63
+ and (r.a is null or jsonb_typeof(r.a) = 'array') and (r.b is null or jsonb_typeof(r.b) = 'array')
64
+ )
65
+ select c.path, c.kind, c.before, c.after, null::text from @SCHEMA@.jsonb_changes(a_data, b_data, '{data}') c
66
+ union all
67
+ select array['links', r.key], case when r.a is null then 'added' when r.b is null then 'removed' else 'changed' end, r.a, r.b, null::text
68
+ from r
69
+ where r.a is distinct from r.b
70
+ and not ((r.a is null or jsonb_typeof(r.a) = 'array') and (r.b is null or jsonb_typeof(r.b) = 'array'))
71
+ union all
72
+ select array['links', m.key], 'added', null::jsonb, d.v, d.v #>> '{}'
73
+ from m cross join lateral (select jsonb_array_elements(m.b) except select jsonb_array_elements(m.a)) d(v)
74
+ union all
75
+ select array['links', m.key], 'removed', d.v, null::jsonb, d.v #>> '{}'
76
+ from m cross join lateral (select jsonb_array_elements(m.a) except select jsonb_array_elements(m.b)) d(v)
77
+ union all
78
+ select array['tags'], 'added', null::jsonb, to_jsonb(d.t), d.t
79
+ from (select unnest(coalesce(b_tags, '{}'::text[])) except select unnest(coalesce(a_tags, '{}'::text[]))) d(t)
80
+ union all
81
+ select array['tags'], 'removed', to_jsonb(d.t), null::jsonb, d.t
82
+ from (select unnest(coalesce(a_tags, '{}'::text[])) except select unnest(coalesce(b_tags, '{}'::text[]))) d(t)
83
+ union all
84
+ select array['owner'], case when a_owner is null then 'added' when b_owner is null then 'removed' else 'changed' end,
85
+ to_jsonb(a_owner), to_jsonb(b_owner), null::text
86
+ where a_owner is distinct from b_owner
87
+ union all
88
+ select array['class'], case when a_class is null then 'added' when b_class is null then 'removed' else 'changed' end,
89
+ to_jsonb(a_class), to_jsonb(b_class), null::text
90
+ where a_class is distinct from b_class;
91
+ end;
92
+
93
+ -- до ревью №1 точки А помощники принимали строку журнала целиком; приложению она теперь недоступна (changed)
94
+ drop function if exists @SCHEMA@.version_changes(@SCHEMA@.log, @SCHEMA@.log);
95
+ drop function if exists @SCHEMA@.version_leaves(@SCHEMA@.log);
96
+
97
+ -- Пути изменения для колонки log.changed (ревизия 6): пути row_changes, у элемента множества — с самим
98
+ -- элементом в конце (['links', роль, id], ['tags', тег]). Пишут триггер правки и log_version.
99
+ create or replace function @SCHEMA@.change_paths(a_data jsonb, a_links jsonb, a_tags text[], a_owner uuid, a_class text,
100
+ b_data jsonb, b_links jsonb, b_tags text[], b_owner uuid, b_class text)
101
+ returns jsonb
102
+ language sql immutable parallel safe
103
+ begin atomic
104
+ select coalesce(jsonb_agg(to_jsonb(c.path || case when c.elem is not null then array[c.elem] else '{}'::text[] end)
105
+ order by c.path, c.elem), '[]'::jsonb)
106
+ from @SCHEMA@.row_changes(a_data, a_links, a_tags, a_owner, a_class, b_data, b_links, b_tags, b_owner, b_class) c;
107
+ end;
108
+
109
+ -- Листья снимка строки для blame: скаляры, массивы и пустые объекты data; роль одиночного конца; каждый id
110
+ -- множественного конца (путь — роль и id); каждый тег; owner; class.
111
+ create or replace function @SCHEMA@.row_leaves(p_data jsonb, p_links jsonb, p_tags text[], p_owner uuid, p_class text)
112
+ returns setof text[]
113
+ language sql immutable parallel safe
114
+ begin atomic
115
+ with recursive w(path, v) as (
116
+ select '{data}'::text[], p_data
117
+ union all
118
+ select w.path || k.key, w.v -> k.key
119
+ from w cross join lateral jsonb_object_keys(case when jsonb_typeof(w.v) = 'object' then w.v else '{}'::jsonb end) k(key)
120
+ where jsonb_typeof(w.v) = 'object' and w.v <> '{}'::jsonb
121
+ )
122
+ select w.path from w where w.v is not null and not (jsonb_typeof(w.v) = 'object' and w.v <> '{}'::jsonb)
123
+ union all
124
+ select array['links', k.key] from jsonb_each(coalesce(p_links, '{}'::jsonb)) k(key, val) where jsonb_typeof(k.val) <> 'array'
125
+ union all
126
+ select array['links', k.key, e.v #>> '{}'] from jsonb_each(coalesce(p_links, '{}'::jsonb)) k(key, val)
127
+ cross join lateral jsonb_array_elements(case when jsonb_typeof(k.val) = 'array' then k.val else '[]'::jsonb end) e(v)
128
+ union all
129
+ select array['tags', t.t] from unnest(coalesce(p_tags, '{}'::text[])) t(t)
130
+ union all
131
+ select array['owner'] where p_owner is not null
132
+ union all
133
+ select array['class'] where p_class is not null;
134
+ end;
135
+
136
+ -- Пути из колонки changed для blame. Колонка приложению не выдана: пути версии описывают разницу с предыдущей
137
+ -- версией, а та читателю может быть не видна — условие ACL на теги, данные или концы скрывает одну версию и
138
+ -- пропускает следующую, и в путях видны снятые теги, цели и ключи скрытой (ревью №1 точки А). Пути отдаются
139
+ -- только для версии, у которой и она сама, и предыдущая версия того же id — настоящие версии, видимые
140
+ -- вызывающему (то же условие, что у политики log_select: row_visible с планом прав чтения). Такую разницу
141
+ -- вызывающий и так получит сравнением двух видимых ему строк.
142
+ create or replace function @SCHEMA@.version_paths(p_ids uuid[], p_revs int[])
143
+ returns table (id uuid, rev int, changed jsonb)
144
+ language plpgsql stable security definer
145
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
146
+ as $$
147
+ #variable_conflict use_column
148
+ declare
149
+ a jsonb := @SCHEMA@.acl_actor('READ');
150
+ c_tenant uuid := (a->>'tenant')::uuid;
151
+ c_account uuid := (a->>'account')::uuid;
152
+ c_plan jsonb := a->'plan';
153
+ begin
154
+ if c_account is null then
155
+ return;
156
+ end if;
157
+ return query
158
+ select n.id, n.rev, n.changed
159
+ 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);
166
+ end $$;
167
+
168
+ -- diff: что изменилось между двумя версиями объекта (ответ терминала diff()). «После» — номер версии
169
+ -- p_to, последняя версия на момент p_to_at или последняя вообще; «до» — номер p_from, последняя живая
170
+ -- версия на момент p_from_at (объекта не было — null, все пути added) или версия перед «после».
171
+ -- p_follow — через смену id (reclass, rekey) по колонке moved; надгробие переноса — не версия.
172
+ -- «После» — надгробие: to.deleted, изменения полей не перечисляются.
173
+ create or replace function @SCHEMA@.diff_versions(p_id uuid, p_from int default null, p_to int default null,
174
+ p_from_at timestamptz default null, p_to_at timestamptz default null, p_follow boolean default false)
175
+ returns jsonb
176
+ language plpgsql stable
177
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
178
+ -- JIT: оценка стоимости запроса по журналу завышена (jsonb_array_elements — 100 строк на вызов), и компиляция
179
+ -- JIT занимала бы больше секунды при исполнении в миллисекунды (замер этапа 1б)
180
+ set jit = off
181
+ as $$
182
+ declare
183
+ ids uuid[] := array[p_id];
184
+ t record;
185
+ f record;
186
+ has_f boolean;
187
+ begin
188
+ 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;
196
+ end if;
197
+ 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
+ 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)
199
+ 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
+ order by l.at desc, l.seq desc limit 1;
201
+ if t.id is null then
202
+ return jsonb_build_object('id', p_id, 'class', null, 'from', null, 'to', null, 'changes', '[]'::jsonb);
203
+ end if;
204
+ if p_from is not null then
205
+ 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
206
+ where l.id = p_id and l.rev = p_from and l.op not in ('purge', 'trim', 'reset');
207
+ has_f := f.id is not null;
208
+ elsif p_from_at is not null then
209
+ 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
+ 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)
211
+ and l.at <= p_from_at
212
+ order by l.at desc, l.seq desc limit 1;
213
+ -- срез на момент: удалённого объекта тогда не было
214
+ has_f := f.id is not null and f.op <> 'delete';
215
+ else
216
+ 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
+ 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)
218
+ and (l.at, l.seq) < (t.at, t.seq)
219
+ order by l.at desc, l.seq desc limit 1;
220
+ has_f := f.id is not null;
221
+ end if;
222
+ return jsonb_build_object(
223
+ 'id', p_id,
224
+ 'class', t.class,
225
+ 'from', case when not has_f then null
226
+ else jsonb_build_object('rev', f.rev, 'at', f.at)
227
+ || case when f.id <> p_id then jsonb_build_object('id', f.id) else '{}'::jsonb end end,
228
+ 'to', jsonb_build_object('rev', t.rev, 'at', t.at)
229
+ || case when t.id <> p_id then jsonb_build_object('id', t.id) else '{}'::jsonb end
230
+ || case when t.op = 'delete' then '{"deleted": true}'::jsonb else '{}'::jsonb end,
231
+ 'changes', case when t.op = 'delete' then '[]'::jsonb else coalesce((
232
+ select jsonb_agg(jsonb_build_object('path', to_jsonb(c.path), 'kind', c.kind)
233
+ || case when c.before is not null then jsonb_build_object('before', c.before) else '{}'::jsonb end
234
+ || case when c.after is not null then jsonb_build_object('after', c.after) else '{}'::jsonb end
235
+ order by c.path, c.kind, c.before::text, c.after::text)
236
+ from @SCHEMA@.row_changes(case when has_f then f.data end, case when has_f then f.links end,
237
+ case when has_f then f.tags end, case when has_f then f.owner end, case when has_f then f.class end,
238
+ t.data, t.links, t.tags, t.owner, t.class) c), '[]'::jsonb) end);
239
+ end $$;
240
+
241
+ -- blame: для каждого листа версии (последней на момент p_at или текущей) — версия, которая последней его
242
+ -- изменила, с автором, агентом, видом операции и причиной (ответ терминала blame()). Просматривается не
243
+ -- больше p_limit последних версий, назад через смену id (первая версия нового id указывает на прежний
244
+ -- колонкой moved). Пропуск номера версии (прореживание, обрезка, невидимая версия) — выпавшая версия могла
245
+ -- изменить лист и вернуть значение, поэтому лист точен (exact), только если от текущей версии до найденной
246
+ -- пропусков нет; лист, не менявшийся в просмотренной истории, принадлежит самой старой просмотренной версии —
247
+ -- exact, только если это версия 1 и пропусков нет вовсе. complete — история просмотрена целиком и без пропусков.
248
+ create or replace function @SCHEMA@.blame(p_id uuid, p_at timestamptz default null, p_limit int default 1000)
249
+ returns jsonb
250
+ language plpgsql stable
251
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
252
+ -- JIT: оценка стоимости запроса по журналу завышена (jsonb_array_elements — 100 строк на вызов), и компиляция
253
+ -- JIT занимала бы больше секунды при исполнении в миллисекунды (замер этапа 1б)
254
+ set jit = off
255
+ as $$
256
+ declare
257
+ ids uuid[] := array[p_id];
258
+ t record;
259
+ prev uuid;
260
+ lim int := greatest(coalesce(p_limit, 1000), 1);
261
+ res jsonb;
262
+ begin
263
+ 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
264
+ where l.id = p_id and l.op not in ('purge', 'trim', 'reset') and (p_at is null or l.at <= p_at)
265
+ order by l.rev desc limit 1;
266
+ 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;
273
+ with v as (
274
+ -- соседняя более старая версия — окном (lead), а не соединением CTE с собой: соединение шло бы вложенным
275
+ -- циклом по квадрату числа версий
276
+ select l.id, l.rev, l.at, l.seq, l.op, l.author, l.agent, l.reason, l.data, l.links, l.tags, l.owner, l.class,
277
+ row_number() over o as n, lead(l.id) over o as o_id, lead(l.rev) over o as o_rev,
278
+ lead(l.data) over o as o_data, lead(l.links) over o as o_links, lead(l.tags) over o as o_tags,
279
+ lead(l.owner) over o as o_owner, lead(l.class) over o as o_class
280
+ from @SCHEMA@.log l
281
+ where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (l.op = 'delete' and l.moved is not null)
282
+ and (l.at, l.seq) <= (t.at, t.seq)
283
+ window o as (order by l.at desc, l.seq desc)
284
+ order by l.at desc, l.seq desc limit lim + 1
285
+ ), w as (
286
+ select * from v where v.n <= lim
287
+ ), gaps as (
288
+ select w.n from w where w.o_id = w.id and w.rev - w.o_rev > 1
289
+ ), stored as materialized (
290
+ -- пути колонки changed (ревизия 6) — только у пар одного id без пропуска номера: version_paths отдаёт их,
291
+ -- лишь когда и предыдущая версия видна вызывающему; после пропуска пути описывали бы разницу с выпавшей
292
+ -- версией, а не с соседней в окне (ревью №1 точки А)
293
+ select p.id, p.rev, p.changed from @SCHEMA@.version_paths(
294
+ array(select w.id from w where w.o_id = w.id and w.o_rev = w.rev - 1),
295
+ array(select w.rev from w where w.o_id = w.id and w.o_rev = w.rev - 1)) p
296
+ ), ch as (
297
+ select w.n, array(select jsonb_array_elements_text(c.p)) as path
298
+ from w join stored s on s.id = w.id and s.rev = w.rev cross join lateral jsonb_array_elements(s.changed) c(p)
299
+ union all
300
+ -- остальные пары (пропуск номера, смена id, версия без путей — создание, загрузчик, импорт) — сравнение
301
+ -- соседних версий окна
302
+ select w.n, c.path || case when c.elem is not null then array[c.elem] else '{}'::text[] end
303
+ from w cross join lateral @SCHEMA@.row_changes(w.o_data, w.o_links, w.o_tags, w.o_owner, w.o_class,
304
+ w.data, w.links, w.tags, w.owner, w.class) c
305
+ where w.o_id is not null and not exists (select 1 from stored s where s.id = w.id and s.rev = w.rev)
306
+ ), leaves as (
307
+ select x as path from @SCHEMA@.row_leaves(t.data, t.links, t.tags, t.owner, t.class) x
308
+ ), hit as (
309
+ select lv.path, min(ch.n) as n
310
+ -- лист и путь изменения совпадают по префиксу в любую сторону: изменён сам лист, объект над ним или
311
+ -- лист внутри (пустой объект стал непустым); элемент множества — по пути с элементом
312
+ from leaves lv join ch on lv.path[1:cardinality(ch.path)] = ch.path or ch.path[1:cardinality(lv.path)] = lv.path
313
+ group by lv.path
314
+ ), oldest as (
315
+ select w.n, w.rev from w order by w.n desc limit 1
316
+ ), cut as (
317
+ select count(*) > lim as cut from v
318
+ ), fields as (
319
+ select lv.path,
320
+ jsonb_build_object('path', to_jsonb(lv.path), 'rev', cv.rev, 'at', cv.at, 'author', cv.author, 'op', cv.op,
321
+ '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)
323
+ || case when cv.id <> p_id then jsonb_build_object('id', cv.id) else '{}'::jsonb end
324
+ || case when cv.agent is not null then jsonb_build_object('agent', cv.agent) else '{}'::jsonb end
325
+ || case when cv.reason is not null then jsonb_build_object('reason', cv.reason) else '{}'::jsonb end as obj
326
+ from leaves lv
327
+ left join hit h on h.path = lv.path
328
+ join w cv on cv.n = coalesce(h.n, (select o.n from oldest o))
329
+ )
330
+ 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),
332
+ 'fields', coalesce((select jsonb_agg(f.obj order by f.path) from fields f), '[]'::jsonb))
333
+ || case when t.op = 'delete' then '{"deleted": true}'::jsonb else '{}'::jsonb end
334
+ into res;
335
+ return res;
336
+ end $$;
337
+
338
+ do $$
339
+ declare f text;
340
+ begin
341
+ foreach f in array array[
342
+ 'jsonb_changes(jsonb, jsonb, text[])',
343
+ 'row_changes(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text)',
344
+ 'change_paths(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text)',
345
+ 'row_leaves(jsonb, jsonb, text[], uuid, text)', 'version_paths(uuid[], int[])',
346
+ 'diff_versions(uuid, int, int, timestamptz, timestamptz, boolean)', 'blame(uuid, timestamptz, int)'] loop
347
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
348
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
349
+ end loop;
350
+ end $$;
351
+ -- терминалы diff() и blame() вызывают их от приложения; сырой SQL — так же, под RLS журнала (version_paths
352
+ -- проверяет видимость обеих версий сама)
353
+ grant execute on function @SCHEMA@.jsonb_changes(jsonb, jsonb, text[]),
354
+ @SCHEMA@.row_changes(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text),
355
+ @SCHEMA@.change_paths(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text),
356
+ @SCHEMA@.row_leaves(jsonb, jsonb, text[], uuid, text), @SCHEMA@.version_paths(uuid[], int[]),
357
+ @SCHEMA@.diff_versions(uuid, int, int, timestamptz, timestamptz, boolean),
358
+ @SCHEMA@.blame(uuid, timestamptz, int) to @APP@;
package/sql/80-auth.sql CHANGED
@@ -17,7 +17,7 @@ return interval '10 minutes';
17
17
 
18
18
  create or replace function @SCHEMA@.auth_kind_ok(p_kind text) returns boolean
19
19
  language sql immutable parallel safe
20
- return p_kind ~ '^[A-Z][A-Z0-9_]{1,31}$' and p_kind not in ('SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY');
20
+ return p_kind ~ '^[A-Z][A-Z0-9_]{1,31}$' and p_kind not in ('SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'PROJECTOR', 'LOCK');
21
21
 
22
22
  -- Действующая сессия обязательна (иначе no_session).
23
23
  create or replace function @SCHEMA@.auth_session_required(p_account uuid) returns void
@@ -165,7 +165,7 @@ begin
165
165
  where s.account = p_account and s.kind = 'SESSION' and s.deleted_at is null and s.id is distinct from p_except
166
166
  returning s.material)
167
167
  select array_agg('session:' || left(encode(k.material, 'hex'), 16)) into sig from k;
168
- perform @SCHEMA@.signal(sig);
168
+ perform @SCHEMA@.signal_raw(sig);
169
169
  return coalesce(cardinality(sig), 0);
170
170
  end $$;
171
171
 
@@ -185,7 +185,7 @@ begin
185
185
  and (s.meta->>'cred' = p_cred::text or (not s.meta ? 'cred' and coalesce(s.meta->'methods', '[]'::jsonb) ? p_kind))
186
186
  returning s.material)
187
187
  select array_agg('session:' || left(encode(k.material, 'hex'), 16)) into sig from k;
188
- perform @SCHEMA@.signal(sig);
188
+ perform @SCHEMA@.signal_raw(sig);
189
189
  return coalesce(cardinality(sig), 0);
190
190
  end $$;
191
191
 
@@ -617,7 +617,7 @@ begin
617
617
  coalesce((s.meta->>'confirmed')::boolean, false), coalesce((s.meta->>'enabled')::boolean, true),
618
618
  s.meta - 'confirmed' - 'enabled' - 'attempts' - 'last_step', s.created_at, s.expires_at
619
619
  from @SCHEMA@.secret s
620
- where s.account = p_account and s.kind not in ('SESSION', 'KEY') and s.deleted_at is null
620
+ where s.account = p_account and s.kind not in ('SESSION', 'KEY', 'PROJECTOR', 'LOCK') and s.deleted_at is null
621
621
  and (s.expires_at is null or s.expires_at > clock_timestamp())
622
622
  order by s.created_at, s.id;
623
623
  end $$;
@@ -639,7 +639,7 @@ begin
639
639
  perform @SCHEMA@.auth_session_required(c.account);
640
640
  select s.account, s.kind,
641
641
  (s.expires_at is null or s.expires_at > clock_timestamp()) and coalesce((s.meta->>'enabled')::boolean, true) as live
642
- into cr from @SCHEMA@.secret s where s.id = p_id and s.kind not in ('SESSION', 'KEY') and s.deleted_at is null;
642
+ into cr from @SCHEMA@.secret s where s.id = p_id and s.kind not in ('SESSION', 'KEY', 'PROJECTOR', 'LOCK') and s.deleted_at is null;
643
643
  if cr.account is null then
644
644
  return false;
645
645
  end if;
@@ -679,7 +679,7 @@ declare
679
679
  acc uuid;
680
680
  begin
681
681
  perform @SCHEMA@.acl_require('auth.credential');
682
- select s.account into acc from @SCHEMA@.secret s where s.id = p_id and s.kind not in ('SESSION', 'KEY') and s.deleted_at is null;
682
+ select s.account into acc from @SCHEMA@.secret s where s.id = p_id and s.kind not in ('SESSION', 'KEY', 'PROJECTOR', 'LOCK') and s.deleted_at is null;
683
683
  if acc is null then
684
684
  return false;
685
685
  end if;
@@ -721,7 +721,7 @@ begin
721
721
  select * into c from @SCHEMA@.ctx();
722
722
  perform @SCHEMA@.auth_session_required(c.account);
723
723
  update @SCHEMA@.secret s set deleted_at = clock_timestamp() where s.id = c.session and s.deleted_at is null returning s.material into m;
724
- perform @SCHEMA@.signal(array['session:' || left(encode(m, 'hex'), 16)]);
724
+ perform @SCHEMA@.signal_raw(array['session:' || left(encode(m, 'hex'), 16)]);
725
725
  return m is not null;
726
726
  end $$;
727
727