letopis 1.4.2 → 3.0.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 (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
@@ -1,436 +0,0 @@
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
- -- history_segments — отрезки истории объекта по переносам id (follow и blame);
15
- -- diff_versions — что изменилось между двумя версиями или моментами (jsonb в виде ответа diff());
16
- -- blame — какая версия, кто, когда и почему последней изменила каждый лист (ответ blame()).
17
-
18
- -- Различия значений по путям: объекты — по ключам рекурсивно, массивы и скаляры — целиком. Отсутствующий
19
- -- ключ (SQL NULL) и JSON null различаются: added — ключа не было, removed — ключа не стало, changed —
20
- -- значение другое. Добавленный или удалённый объект раскрывается до листьев; пустой объект — лист.
21
- create or replace function @SCHEMA@.jsonb_changes(p_a jsonb, p_b jsonb, p_prefix text[] default '{}')
22
- returns table (path text[], kind text, before jsonb, after jsonb)
23
- language sql immutable parallel safe
24
- begin atomic
25
- with recursive w(path, a, b) as (
26
- select p_prefix, p_a, p_b
27
- union all
28
- select w.path || k.key, w.a -> k.key, w.b -> k.key
29
- from w cross join lateral (
30
- select jsonb_object_keys(case when jsonb_typeof(w.a) = 'object' then w.a else '{}'::jsonb end) as key
31
- union
32
- select jsonb_object_keys(case when jsonb_typeof(w.b) = 'object' then w.b else '{}'::jsonb end)
33
- ) k
34
- where (w.a is null or jsonb_typeof(w.a) = 'object') and (w.b is null or jsonb_typeof(w.b) = 'object')
35
- and w.a is distinct from w.b
36
- and not (w.a is null and w.b = '{}'::jsonb) and not (w.b is null and w.a = '{}'::jsonb)
37
- )
38
- 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
39
- from w
40
- where w.a is distinct from w.b
41
- and not ((w.a is null or jsonb_typeof(w.a) = 'object') and (w.b is null or jsonb_typeof(w.b) = 'object')
42
- and not (w.a is null and w.b = '{}'::jsonb) and not (w.b is null and w.a = '{}'::jsonb))
43
- order by 1;
44
- end;
45
-
46
- -- Различия двух снимков строки по разделам: data — jsonb_changes с путём от 'data'; одиночный конец —
47
- -- значение целиком; множественный конец и теги — множества: added и removed по каждому элементу (elem —
48
- -- сам элемент); owner и class — значение. Снимок «до» пуст (объекта не было): всё added.
49
- create or replace function @SCHEMA@.row_changes(a_data jsonb, a_links jsonb, a_tags text[], a_owner uuid, a_class text,
50
- b_data jsonb, b_links jsonb, b_tags text[], b_owner uuid, b_class text)
51
- returns table (path text[], kind text, before jsonb, after jsonb, elem text)
52
- language sql immutable parallel safe
53
- begin atomic
54
- with r as (
55
- select k.key, a_links -> k.key as a, b_links -> k.key as b
56
- from (select jsonb_object_keys(coalesce(a_links, '{}'::jsonb)) as key
57
- union select jsonb_object_keys(coalesce(b_links, '{}'::jsonb))) k
58
- ), m as (
59
- -- изменившиеся множественные концы: только они раскладываются на элементы, и разностью множеств, а не
60
- -- проверкой вхождения на каждый элемент — та шла квадратом длины, и правка любого поля объекта с тысячами
61
- -- целей стоила секунды в триггере правки (ревью №1 точки А)
62
- select r.key, coalesce(r.a, '[]'::jsonb) as a, coalesce(r.b, '[]'::jsonb) as b from r
63
- where r.a is distinct from r.b
64
- and (r.a is null or jsonb_typeof(r.a) = 'array') and (r.b is null or jsonb_typeof(r.b) = 'array')
65
- )
66
- select c.path, c.kind, c.before, c.after, null::text from @SCHEMA@.jsonb_changes(a_data, b_data, '{data}') c
67
- union all
68
- 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
69
- from r
70
- where r.a is distinct from r.b
71
- and not ((r.a is null or jsonb_typeof(r.a) = 'array') and (r.b is null or jsonb_typeof(r.b) = 'array'))
72
- union all
73
- select array['links', m.key], 'added', null::jsonb, d.v, d.v #>> '{}'
74
- from m cross join lateral (select jsonb_array_elements(m.b) except select jsonb_array_elements(m.a)) d(v)
75
- union all
76
- select array['links', m.key], 'removed', d.v, null::jsonb, d.v #>> '{}'
77
- from m cross join lateral (select jsonb_array_elements(m.a) except select jsonb_array_elements(m.b)) d(v)
78
- union all
79
- select array['tags'], 'added', null::jsonb, to_jsonb(d.t), d.t
80
- from (select unnest(coalesce(b_tags, '{}'::text[])) except select unnest(coalesce(a_tags, '{}'::text[]))) d(t)
81
- union all
82
- select array['tags'], 'removed', to_jsonb(d.t), null::jsonb, d.t
83
- from (select unnest(coalesce(a_tags, '{}'::text[])) except select unnest(coalesce(b_tags, '{}'::text[]))) d(t)
84
- union all
85
- select array['owner'], case when a_owner is null then 'added' when b_owner is null then 'removed' else 'changed' end,
86
- to_jsonb(a_owner), to_jsonb(b_owner), null::text
87
- where a_owner is distinct from b_owner
88
- union all
89
- select array['class'], case when a_class is null then 'added' when b_class is null then 'removed' else 'changed' end,
90
- to_jsonb(a_class), to_jsonb(b_class), null::text
91
- where a_class is distinct from b_class;
92
- end;
93
-
94
- -- до ревью №1 точки А помощники принимали строку журнала целиком; приложению она теперь недоступна (changed)
95
- drop function if exists @SCHEMA@.version_changes(@SCHEMA@.log, @SCHEMA@.log);
96
- drop function if exists @SCHEMA@.version_leaves(@SCHEMA@.log);
97
-
98
- -- Пути изменения для колонки log.changed (ревизия 6): пути row_changes, у элемента множества — с самим
99
- -- элементом в конце (['links', роль, id], ['tags', тег]). Пишут триггер правки и log_version.
100
- create or replace function @SCHEMA@.change_paths(a_data jsonb, a_links jsonb, a_tags text[], a_owner uuid, a_class text,
101
- b_data jsonb, b_links jsonb, b_tags text[], b_owner uuid, b_class text)
102
- returns jsonb
103
- language sql immutable parallel safe
104
- begin atomic
105
- select coalesce(jsonb_agg(to_jsonb(c.path || case when c.elem is not null then array[c.elem] else '{}'::text[] end)
106
- order by c.path, c.elem), '[]'::jsonb)
107
- 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;
108
- end;
109
-
110
- -- Листья снимка строки для blame: скаляры, массивы и пустые объекты data; роль одиночного конца; каждый id
111
- -- множественного конца (путь — роль и id); каждый тег; owner; class.
112
- create or replace function @SCHEMA@.row_leaves(p_data jsonb, p_links jsonb, p_tags text[], p_owner uuid, p_class text)
113
- returns setof text[]
114
- language sql immutable parallel safe
115
- begin atomic
116
- with recursive w(path, v) as (
117
- select '{data}'::text[], p_data
118
- union all
119
- select w.path || k.key, w.v -> k.key
120
- from w cross join lateral jsonb_object_keys(case when jsonb_typeof(w.v) = 'object' then w.v else '{}'::jsonb end) k(key)
121
- where jsonb_typeof(w.v) = 'object' and w.v <> '{}'::jsonb
122
- )
123
- select w.path from w where w.v is not null and not (jsonb_typeof(w.v) = 'object' and w.v <> '{}'::jsonb)
124
- union all
125
- select array['links', k.key] from jsonb_each(coalesce(p_links, '{}'::jsonb)) k(key, val) where jsonb_typeof(k.val) <> 'array'
126
- union all
127
- select array['links', k.key, e.v #>> '{}'] from jsonb_each(coalesce(p_links, '{}'::jsonb)) k(key, val)
128
- cross join lateral jsonb_array_elements(case when jsonb_typeof(k.val) = 'array' then k.val else '[]'::jsonb end) e(v)
129
- union all
130
- select array['tags', t.t] from unnest(coalesce(p_tags, '{}'::text[])) t(t)
131
- union all
132
- select array['owner'] where p_owner is not null
133
- union all
134
- select array['class'] where p_class is not null;
135
- end;
136
-
137
- -- Пути из колонки changed для blame. Колонка приложению не выдана: пути версии описывают разницу с предыдущей
138
- -- версией, а та читателю может быть не видна — условие ACL на теги, данные или концы скрывает одну версию и
139
- -- пропускает следующую, и в путях видны снятые теги, цели и ключи скрытой (ревью №1 точки А). Пути отдаются
140
- -- только для версии, у которой и она сама, и предыдущая версия того же id — настоящие версии, видимые
141
- -- вызывающему (то же условие, что у политики log_select: row_visible с планом прав чтения). Такую разницу
142
- -- вызывающий и так получит сравнением двух видимых ему строк.
143
- create or replace function @SCHEMA@.version_paths(p_ids uuid[], p_revs int[])
144
- returns table (id uuid, rev int, changed jsonb)
145
- language plpgsql stable security definer
146
- set search_path = pg_catalog, @SCHEMA@, pg_temp
147
- as $$
148
- #variable_conflict use_column
149
- declare
150
- a jsonb := @SCHEMA@.acl_actor('READ');
151
- c_tenant uuid := (a->>'tenant')::uuid;
152
- c_account uuid := (a->>'account')::uuid;
153
- c_plan jsonb := a->'plan';
154
- begin
155
- if c_account is null then
156
- return;
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)
162
- return query
163
- select n.id, n.rev, n.changed
164
- from unnest(p_ids, p_revs) q(id, rev)
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;
224
- end $$;
225
-
226
- -- diff: что изменилось между двумя версиями объекта (ответ терминала diff()). «После» — номер версии
227
- -- p_to, последняя версия на момент p_to_at или последняя вообще; «до» — номер p_from, последняя живая
228
- -- версия на момент p_from_at (объекта не было — null, все пути added) или версия перед «после».
229
- -- p_follow — через смену id (reclass, rekey) по колонке moved: отрезки истории этого объекта (history_segments) от
230
- -- версии p_to или последней версии p_id; надгробие переноса — не версия.
231
- -- «После» — надгробие: to.deleted, изменения полей не перечисляются.
232
- create or replace function @SCHEMA@.diff_versions(p_id uuid, p_from int default null, p_to int default null,
233
- p_from_at timestamptz default null, p_to_at timestamptz default null, p_follow boolean default false)
234
- returns jsonb
235
- language plpgsql stable
236
- set search_path = pg_catalog, @SCHEMA@, pg_temp
237
- -- JIT: оценка стоимости запроса по журналу завышена (jsonb_array_elements — 100 строк на вызов), и компиляция
238
- -- JIT занимала бы больше секунды при исполнении в миллисекунды (замер этапа 1б)
239
- set jit = off
240
- as $$
241
- declare
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;
247
- t record;
248
- f record;
249
- has_f boolean;
250
- begin
251
- if p_follow then
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;
257
- end if;
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
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
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)
264
- order by l.at desc, l.seq desc limit 1;
265
- if t.id is null then
266
- return jsonb_build_object('id', p_id, 'class', null, 'from', null, 'to', null, 'changes', '[]'::jsonb);
267
- end if;
268
- if p_from is not null then
269
- 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
270
- where l.id = p_id and l.rev = p_from and l.op not in ('purge', 'trim', 'reset');
271
- has_f := f.id is not null;
272
- elsif p_from_at is not null then
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
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
278
- and l.at <= p_from_at
279
- order by l.at desc, l.seq desc limit 1;
280
- -- срез на момент: удалённого объекта тогда не было
281
- has_f := f.id is not null and f.op <> 'delete';
282
- else
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
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
288
- and (l.at, l.seq) < (t.at, t.seq)
289
- order by l.at desc, l.seq desc limit 1;
290
- has_f := f.id is not null;
291
- end if;
292
- return jsonb_build_object(
293
- 'id', p_id,
294
- 'class', t.class,
295
- 'from', case when not has_f then null
296
- else jsonb_build_object('rev', f.rev, 'at', f.at)
297
- || case when f.id <> p_id then jsonb_build_object('id', f.id) else '{}'::jsonb end end,
298
- 'to', jsonb_build_object('rev', t.rev, 'at', t.at)
299
- || case when t.id <> p_id then jsonb_build_object('id', t.id) else '{}'::jsonb end
300
- || case when t.op = 'delete' then '{"deleted": true}'::jsonb else '{}'::jsonb end,
301
- 'changes', case when t.op = 'delete' then '[]'::jsonb else coalesce((
302
- select jsonb_agg(jsonb_build_object('path', to_jsonb(c.path), 'kind', c.kind)
303
- || case when c.before is not null then jsonb_build_object('before', c.before) else '{}'::jsonb end
304
- || case when c.after is not null then jsonb_build_object('after', c.after) else '{}'::jsonb end
305
- order by c.path, c.kind, c.before::text, c.after::text)
306
- from @SCHEMA@.row_changes(case when has_f then f.data end, case when has_f then f.links end,
307
- case when has_f then f.tags end, case when has_f then f.owner end, case when has_f then f.class end,
308
- t.data, t.links, t.tags, t.owner, t.class) c), '[]'::jsonb) end);
309
- end $$;
310
-
311
- -- blame: для каждого листа версии (последней на момент p_at или текущей) — версия, которая последней его
312
- -- изменила, с автором, агентом, видом операции и причиной (ответ терминала blame()). Просматривается не
313
- -- больше p_limit последних версий, назад через смену id (первая версия нового id указывает на прежний
314
- -- колонкой moved) — по отрезкам истории этого объекта (history_segments): объект, что жил на этом id до переноса
315
- -- или заведён на прежнем id после, — не его история; начало истории — первая версия самого раннего отрезка (у
316
- -- объекта без переносов — версия 1). Пропуск номера версии (прореживание, обрезка, невидимая версия) — выпавшая версия могла
317
- -- изменить лист и вернуть значение, поэтому лист точен (exact), только если от текущей версии до найденной
318
- -- пропусков нет; лист, не менявшийся в просмотренной истории, принадлежит самой старой просмотренной версии —
319
- -- exact, только если это версия 1 и пропусков нет вовсе. complete — история просмотрена целиком и без пропусков.
320
- create or replace function @SCHEMA@.blame(p_id uuid, p_at timestamptz default null, p_limit int default 1000)
321
- returns jsonb
322
- language plpgsql stable
323
- set search_path = pg_catalog, @SCHEMA@, pg_temp
324
- -- JIT: оценка стоимости запроса по журналу завышена (jsonb_array_elements — 100 строк на вызов), и компиляция
325
- -- JIT занимала бы больше секунды при исполнении в миллисекунды (замер этапа 1б)
326
- set jit = off
327
- as $$
328
- declare
329
- ids uuid[];
330
- los int[];
331
- his int[];
332
- multi boolean;
333
- whole boolean;
334
- t record;
335
- lim int := greatest(coalesce(p_limit, 1000), 1);
336
- res jsonb;
337
- begin
338
- 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
339
- where l.id = p_id and l.op not in ('purge', 'trim', 'reset') and (p_at is null or l.at <= p_at)
340
- order by l.rev desc limit 1;
341
- if t.id is null then return null; end if;
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;
347
- with v as (
348
- -- соседняя более старая версия — окном (lead), а не соединением CTE с собой: соединение шло бы вложенным
349
- -- циклом по квадрату числа версий
350
- 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,
351
- row_number() over o as n, lead(l.id) over o as o_id, lead(l.rev) over o as o_rev,
352
- lead(l.data) over o as o_data, lead(l.links) over o as o_links, lead(l.tags) over o as o_tags,
353
- lead(l.owner) over o as o_owner, lead(l.class) over o as o_class
354
- from @SCHEMA@.log l
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
359
- and (l.at, l.seq) <= (t.at, t.seq)
360
- window o as (order by l.at desc, l.seq desc)
361
- order by l.at desc, l.seq desc limit lim + 1
362
- ), w as (
363
- select * from v where v.n <= lim
364
- ), gaps as (
365
- select w.n from w where w.o_id = w.id and w.rev - w.o_rev > 1
366
- ), stored as materialized (
367
- -- пути колонки changed (ревизия 6) — только у пар одного id без пропуска номера: version_paths отдаёт их,
368
- -- лишь когда и предыдущая версия видна вызывающему; после пропуска пути описывали бы разницу с выпавшей
369
- -- версией, а не с соседней в окне (ревью №1 точки А)
370
- select p.id, p.rev, p.changed from @SCHEMA@.version_paths(
371
- array(select w.id from w where w.o_id = w.id and w.o_rev = w.rev - 1),
372
- array(select w.rev from w where w.o_id = w.id and w.o_rev = w.rev - 1)) p
373
- ), ch as (
374
- select w.n, array(select jsonb_array_elements_text(c.p)) as path
375
- 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)
376
- union all
377
- -- остальные пары (пропуск номера, смена id, версия без путей — создание, загрузчик, импорт) — сравнение
378
- -- соседних версий окна
379
- select w.n, c.path || case when c.elem is not null then array[c.elem] else '{}'::text[] end
380
- from w cross join lateral @SCHEMA@.row_changes(w.o_data, w.o_links, w.o_tags, w.o_owner, w.o_class,
381
- w.data, w.links, w.tags, w.owner, w.class) c
382
- 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)
383
- ), leaves as (
384
- select x as path from @SCHEMA@.row_leaves(t.data, t.links, t.tags, t.owner, t.class) x
385
- ), hit as (
386
- select lv.path, min(ch.n) as n
387
- -- лист и путь изменения совпадают по префиксу в любую сторону: изменён сам лист, объект над ним или
388
- -- лист внутри (пустой объект стал непустым); элемент множества — по пути с элементом
389
- from leaves lv join ch on lv.path[1:cardinality(ch.path)] = ch.path or ch.path[1:cardinality(lv.path)] = lv.path
390
- group by lv.path
391
- ), oldest as (
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
394
- ), cut as (
395
- select count(*) > lim as cut from v
396
- ), fields as (
397
- select lv.path,
398
- jsonb_build_object('path', to_jsonb(lv.path), 'rev', cv.rev, 'at', cv.at, 'author', cv.author, 'op', cv.op,
399
- 'exact', case when h.n is not null then not exists (select 1 from gaps g where g.n <= h.n)
400
- else (select o.first from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps) end)
401
- || case when cv.id <> p_id then jsonb_build_object('id', cv.id) else '{}'::jsonb end
402
- || case when cv.agent is not null then jsonb_build_object('agent', cv.agent) else '{}'::jsonb end
403
- || case when cv.reason is not null then jsonb_build_object('reason', cv.reason) else '{}'::jsonb end as obj
404
- from leaves lv
405
- left join hit h on h.path = lv.path
406
- join w cv on cv.n = coalesce(h.n, (select o.n from oldest o))
407
- )
408
- select jsonb_build_object('id', p_id, 'class', t.class, 'rev', t.rev,
409
- 'complete', (select o.first from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps),
410
- 'fields', coalesce((select jsonb_agg(f.obj order by f.path) from fields f), '[]'::jsonb))
411
- || case when t.op = 'delete' then '{"deleted": true}'::jsonb else '{}'::jsonb end
412
- into res;
413
- return res;
414
- end $$;
415
-
416
- do $$
417
- declare f text;
418
- begin
419
- foreach f in array array[
420
- 'jsonb_changes(jsonb, jsonb, text[])',
421
- 'row_changes(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text)',
422
- 'change_paths(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text)',
423
- 'row_leaves(jsonb, jsonb, text[], uuid, text)', 'version_paths(uuid[], int[])', 'history_segments(uuid, int, boolean)',
424
- 'diff_versions(uuid, int, int, timestamptz, timestamptz, boolean)', 'blame(uuid, timestamptz, int)'] loop
425
- execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
426
- execute format('revoke all on function @SCHEMA@.%s from public', f);
427
- end loop;
428
- end $$;
429
- -- терминалы diff() и blame() вызывают их от приложения; сырой SQL — так же, под RLS журнала (version_paths
430
- -- проверяет видимость обеих версий сама)
431
- grant execute on function @SCHEMA@.jsonb_changes(jsonb, jsonb, text[]),
432
- @SCHEMA@.row_changes(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text),
433
- @SCHEMA@.change_paths(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text),
434
- @SCHEMA@.row_leaves(jsonb, jsonb, text[], uuid, text), @SCHEMA@.version_paths(uuid[], int[]),
435
- @SCHEMA@.history_segments(uuid, int, boolean), @SCHEMA@.diff_versions(uuid, int, int, timestamptz, timestamptz, boolean),
436
- @SCHEMA@.blame(uuid, timestamptz, int) to @APP@;