letopis 0.20.3 → 1.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 (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +348 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3466
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +369 -551
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -250
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -351
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -600
  89. package/sql/seed.auth.sql +0 -73
@@ -0,0 +1,245 @@
1
+ -- letopis 1.0 — поиск id для шагов цепочки (план, §2.7, §3 «Индексы под RLS», этап 3, п. 3).
2
+ --
3
+ -- Под RLS PostgreSQL пользуется индексом только для операторов, помеченных leakproof (равенство
4
+ -- uuid и текста); поиск внутри jsonb и массивов (@>) к ним не относится, и пометить его так может
5
+ -- только суперпользователь. Поэтому шаг цепочки делится на две части:
6
+ -- 1) функции владельца find_* выполняются без RLS и находят id по индексам GIN (entity_ends,
7
+ -- entity_data), сами применяя то же условие видимости, что и политики RLS (row_visible с планом
8
+ -- прав READ актора — acl_actor);
9
+ -- 2) строки читаются по этим id через первичный ключ под RLS, и там же применяются остальные
10
+ -- условия шага: операторы с приведением типов, диапазоны, like.
11
+ -- Прямой вызов find_* сырым SQL возвращает только id строк, которые пользователь и так видит.
12
+ -- Принимаются только условия вложения (@>), которые не падают на неожиданных данных.
13
+ -- Обратный переход и deep пробуют индекс entity_ends на каждый id отдельно (lateral … offset 0 —
14
+ -- подзапрос не поднимается в общий план, и планировщик не меняет пробы на перебор таблицы), а класс,
15
+ -- видимость и условия на data проверяют уже на найденных строках: пересечение каждой пробы с
16
+ -- индексами (class, tenant) или data перебирало бы весь класс.
17
+ --
18
+ -- Источник — текущее состояние (entity) или журнал: версии, действовавшие на момент p_at (срез
19
+ -- asOf), либо надгробия удалённых (p_dead, для withDeleted). Возвращается rev версии: строки
20
+ -- журнала читаются по паре (id, rev) через уникальный индекс log_id_rev. Записи purge, trim и reset
21
+ -- (этап 6) — не версии объекта: на момент после них объекта в срезе нет.
22
+
23
+ -- Цели концов строки: всех (p_roles is null) или только перечисленных ролей.
24
+ create or replace function @SCHEMA@.role_ids(p_links jsonb, p_roles text[]) returns uuid[]
25
+ language sql immutable parallel safe
26
+ return (
27
+ select coalesce(array_agg(distinct (v #>> '{}')::uuid), '{}'::uuid[])
28
+ from jsonb_each(coalesce(p_links, '{}'::jsonb)) k
29
+ cross join lateral jsonb_array_elements(case jsonb_typeof(k.value) when 'array' then k.value else jsonb_build_array(k.value) end) v
30
+ where (p_roles is null or k.key = any (p_roles)) and jsonb_typeof(v) = 'string'
31
+ );
32
+
33
+ -- Ссылается ли строка на p_id хотя бы одной из ролей (одиночный конец или элемент множественного:
34
+ -- массив jsonb содержит строку-примитив).
35
+ create or replace function @SCHEMA@.role_has(p_links jsonb, p_roles text[], p_id uuid) returns boolean
36
+ language sql immutable parallel safe
37
+ return exists (select 1 from unnest(p_roles) r(role) where (p_links -> r.role) @> to_jsonb(p_id::text));
38
+
39
+ -- Версии классов p_classes, действовавшие на момент p_at (последняя версия id среди версий этих классов
40
+ -- не позже p_at; p_at null — последняя вообще), которые ссылаются на p_src: кандидаты — версии,
41
+ -- ссылавшиеся на p_src (GIN log_ends), затем последняя версия каждого кандидата (log_id_rev); ссылку
42
+ -- проверяет она. Класс и время кандидатов — после пробы (offset 0): иначе планировщик объединяет пробу
43
+ -- с log_class_at и читает весь класс в индексе. Без прав: вызывают только функции владельца find_*,
44
+ -- видимость — на них.
45
+ create or replace function @SCHEMA@.log_refs_at(p_classes text[], p_src uuid, p_at timestamptz)
46
+ returns setof @SCHEMA@.log
47
+ language sql stable
48
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
49
+ as $$
50
+ select w.* from (
51
+ select distinct r.id from (
52
+ select l.id, l.class, l.at from @SCHEMA@.log l where @SCHEMA@.link_ids(l.links) @> array[p_src] offset 0) r
53
+ where r.class = any (p_classes) and r.at <= coalesce(p_at, 'infinity'::timestamptz)) c
54
+ cross join lateral (
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)
57
+ order by y.rev desc limit 1) w
58
+ where @SCHEMA@.link_ids(w.links) @> array[p_src]
59
+ $$;
60
+
61
+ -- id строк классов p_classes: по вложению в data (GIN entity_data), по целям концов (GIN entity_ends),
62
+ -- по списку id (первичный ключ) или по классу (entity_class_tenant).
63
+ create or replace function @SCHEMA@.find_ids(p_classes text[], p_data jsonb, p_ends uuid[], p_ids uuid[],
64
+ p_at timestamptz default null, p_dead boolean default false)
65
+ returns table (id uuid, rev int)
66
+ language plpgsql stable security definer
67
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
68
+ as $$
69
+ #variable_conflict use_column
70
+ declare
71
+ a jsonb := @SCHEMA@.acl_actor('READ');
72
+ c_tenant uuid := (a->>'tenant')::uuid;
73
+ c_account uuid := (a->>'account')::uuid;
74
+ c_plan jsonb := a->'plan';
75
+ begin
76
+ if c_account is null then
77
+ return;
78
+ end if;
79
+ if p_at is null and not p_dead then
80
+ -- отдельные ветви вместо «p is null or …»: иначе общий план не выбрал бы нужный индекс
81
+ if p_ids is not null then
82
+ return query select e.id, e.rev from @SCHEMA@.entity e
83
+ where e.id = any (p_ids) and e.class = any (p_classes)
84
+ and (p_data is null or e.data @> p_data) and (p_ends is null or e.ends @> p_ends)
85
+ and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
86
+ elsif p_data is not null and p_ends is not null then
87
+ 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
89
+ 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
+ elsif p_data is not null then
91
+ return query select e.id, e.rev from @SCHEMA@.entity e
92
+ where e.class = any (p_classes) and e.data @> p_data
93
+ 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
+ elsif p_ends is not null then
95
+ return query select e.id, e.rev from @SCHEMA@.entity e
96
+ where e.class = any (p_classes) and e.ends @> p_ends
97
+ 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
+ else
99
+ return query select e.id, e.rev from @SCHEMA@.entity e
100
+ where e.class = any (p_classes)
101
+ 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
+ end if;
103
+ elsif p_ids is not null then
104
+ -- журнал: последняя версия каждого id не позже p_at; удалён — если это надгробие
105
+ return query select v.id, v.rev from (
106
+ select distinct on (l.id) l.id, l.rev, l.op, l.class, l.tenant, l.owner, l.tags, l.data, l.links
107
+ from @SCHEMA@.log l
108
+ where l.id = any (p_ids) and l.at <= coalesce(p_at, 'infinity'::timestamptz)
109
+ order by l.id, l.rev desc) v
110
+ where v.class = any (p_classes) and (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
111
+ and (p_data is null or v.data @> p_data) and (p_ends is null or @SCHEMA@.link_ids(v.links) @> p_ends)
112
+ 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
+ elsif p_ends is not null then
114
+ -- по целям концов — от версий, ссылавшихся на первую цель (GIN log_ends)
115
+ 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')
117
+ and (p_data is null or v.data @> p_data) and @SCHEMA@.link_ids(v.links) @> p_ends
118
+ 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
+ else
120
+ return query select v.id, v.rev from (
121
+ select distinct on (l.id) l.id, l.rev, l.op, l.class, l.tenant, l.owner, l.tags, l.data, l.links
122
+ from @SCHEMA@.log l
123
+ where l.class = any (p_classes) and l.at <= coalesce(p_at, 'infinity'::timestamptz)
124
+ order by l.id, l.rev desc) v
125
+ where (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
126
+ and (p_data is null or v.data @> p_data) and (p_ends is null or @SCHEMA@.link_ids(v.links) @> p_ends)
127
+ and @SCHEMA@.row_visible(v.tenant, v.class, v.id, v.owner, v.tags, v.data, v.links, c_tenant, c_account, c_plan);
128
+ end if;
129
+ end $$;
130
+
131
+ -- Обратный переход: пары «откуда, куда» — строки классов p_classes, которые ссылаются на id из p_src
132
+ -- (любым концом или только ролями p_roles). Соединение с развёрнутым списком id: на каждый id —
133
+ -- одна проба индекса entity_ends (решение 5 точки А: «ends && <массив>» на широких списках медленно).
134
+ create or replace function @SCHEMA@.find_hop(p_classes text[], p_data jsonb, p_ends uuid[], p_roles text[], p_src uuid[],
135
+ p_at timestamptz default null, p_dead boolean default false)
136
+ returns table (src uuid, id uuid, rev int)
137
+ language plpgsql stable security definer
138
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
139
+ as $$
140
+ #variable_conflict use_column
141
+ declare
142
+ a jsonb := @SCHEMA@.acl_actor('READ');
143
+ c_tenant uuid := (a->>'tenant')::uuid;
144
+ c_account uuid := (a->>'account')::uuid;
145
+ c_plan jsonb := a->'plan';
146
+ begin
147
+ if c_account is null or p_src is null then
148
+ return;
149
+ end if;
150
+ if p_at is null and not p_dead then
151
+ return query select s.src, e.id, e.rev
152
+ from (select distinct u.x as src from unnest(p_src) u(x)) s
153
+ cross join lateral (
154
+ select x.id, x.rev, x.class, x.tenant, x.owner, x.tags, x.data, x.ends, x.links from @SCHEMA@.entity x
155
+ where x.ends @> array[s.src] offset 0) e
156
+ where e.class = any (p_classes)
157
+ and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan)
158
+ and (p_data is null or e.data @> p_data) and (p_ends is null or e.ends @> p_ends)
159
+ and (p_roles is null or @SCHEMA@.role_has(e.links, p_roles, s.src));
160
+ else
161
+ -- на каждый id источника — версии, ссылавшиеся на него (log_refs_at), а не все версии класса
162
+ return query select s.src, v.id, v.rev
163
+ from (select distinct u.x as src from unnest(p_src) u(x)) s
164
+ cross join lateral @SCHEMA@.log_refs_at(p_classes, s.src, p_at) v
165
+ where (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
166
+ and (p_data is null or v.data @> p_data) and (p_ends is null or @SCHEMA@.link_ids(v.links) @> p_ends)
167
+ and (p_roles is null or @SCHEMA@.role_has(v.links, p_roles, s.src))
168
+ and @SCHEMA@.row_visible(v.tenant, v.class, v.id, v.owner, v.tags, v.data, v.links, c_tenant, c_account, c_plan);
169
+ end if;
170
+ end $$;
171
+
172
+ -- Рекурсивный обход (deep): потомки любой глубины до p_max по обратным ссылкам строк классов
173
+ -- p_classes. Видимость проверяется на каждом уровне: невидимый узел обрывает своё поддерево.
174
+ -- Циклы в данных обход не зацикливают. Для каждой пары (src, id) — наименьшая глубина.
175
+ create or replace function @SCHEMA@.find_deep(p_classes text[], p_roles text[], p_src uuid[], p_max int,
176
+ p_at timestamptz default null)
177
+ returns table (src uuid, id uuid, rev int, depth int)
178
+ language plpgsql stable security definer
179
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
180
+ as $$
181
+ #variable_conflict use_column
182
+ declare
183
+ a jsonb := @SCHEMA@.acl_actor('READ');
184
+ c_tenant uuid := (a->>'tenant')::uuid;
185
+ c_account uuid := (a->>'account')::uuid;
186
+ c_plan jsonb := a->'plan';
187
+ begin
188
+ if c_account is null or p_src is null then
189
+ return;
190
+ end if;
191
+ if p_at is null then
192
+ return query with recursive d(src, id, rev, depth, seen) as (
193
+ select s.src, e.id, e.rev, 1, array[s.src, e.id]
194
+ from (select distinct u.x as src from unnest(p_src) u(x)) s
195
+ cross join lateral (
196
+ select x.id, x.rev, x.class, x.tenant, x.owner, x.tags, x.data, x.links from @SCHEMA@.entity x where x.ends @> array[s.src] offset 0) e
197
+ where e.class = any (p_classes)
198
+ and (p_roles is null or @SCHEMA@.role_has(e.links, p_roles, s.src))
199
+ and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan)
200
+ union all
201
+ select d.src, e.id, e.rev, d.depth + 1, d.seen || e.id
202
+ from d
203
+ cross join lateral (
204
+ select x.id, x.rev, x.class, x.tenant, x.owner, x.tags, x.data, x.links from @SCHEMA@.entity x where x.ends @> array[d.id] offset 0) e
205
+ where d.depth < p_max and not (e.id = any (d.seen)) and e.class = any (p_classes)
206
+ and (p_roles is null or @SCHEMA@.role_has(e.links, p_roles, d.id))
207
+ and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan))
208
+ select distinct on (d.src, d.id) d.src, d.id, d.rev, d.depth from d order by d.src, d.id, d.depth;
209
+ else
210
+ -- уровень за уровнем: версии на момент, ссылавшиеся на узел (log_refs_at), а не все версии класса
211
+ return query with recursive d(src, id, rev, depth, seen) as (
212
+ select s.src, v.id, v.rev, 1, array[s.src, v.id]
213
+ from (select distinct u.x as src from unnest(p_src) u(x)) s
214
+ cross join lateral @SCHEMA@.log_refs_at(p_classes, s.src, p_at) v
215
+ where v.op not in ('delete', 'purge', 'trim', 'reset')
216
+ and (p_roles is null or @SCHEMA@.role_has(v.links, p_roles, s.src))
217
+ and @SCHEMA@.row_visible(v.tenant, v.class, v.id, v.owner, v.tags, v.data, v.links, c_tenant, c_account, c_plan)
218
+ union all
219
+ select d.src, v.id, v.rev, d.depth + 1, d.seen || v.id
220
+ from d
221
+ cross join lateral @SCHEMA@.log_refs_at(p_classes, d.id, p_at) v
222
+ where d.depth < p_max and not (v.id = any (d.seen)) and v.op not in ('delete', 'purge', 'trim', 'reset')
223
+ and (p_roles is null or @SCHEMA@.role_has(v.links, p_roles, d.id))
224
+ and @SCHEMA@.row_visible(v.tenant, v.class, v.id, v.owner, v.tags, v.data, v.links, c_tenant, c_account, c_plan))
225
+ select distinct on (d.src, d.id) d.src, d.id, d.rev, d.depth from d order by d.src, d.id, d.depth;
226
+ end if;
227
+ end $$;
228
+
229
+ do $$
230
+ declare f text;
231
+ begin
232
+ foreach f in array array[
233
+ 'role_ids(jsonb, text[])', 'role_has(jsonb, text[], uuid)', 'log_refs_at(text[], uuid, timestamptz)',
234
+ 'find_ids(text[], jsonb, uuid[], uuid[], timestamptz, boolean)',
235
+ 'find_hop(text[], jsonb, uuid[], text[], uuid[], timestamptz, boolean)',
236
+ 'find_deep(text[], text[], uuid[], int, timestamptz)'] loop
237
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
238
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
239
+ end loop;
240
+ end $$;
241
+ -- построитель цепочек вызывает их от приложения; row_visible — внутри find_*
242
+ grant execute on function @SCHEMA@.role_ids(jsonb, text[]), @SCHEMA@.role_has(jsonb, text[], uuid),
243
+ @SCHEMA@.find_ids(text[], jsonb, uuid[], uuid[], timestamptz, boolean),
244
+ @SCHEMA@.find_hop(text[], jsonb, uuid[], text[], uuid[], timestamptz, boolean),
245
+ @SCHEMA@.find_deep(text[], text[], uuid[], int, timestamptz) to @APP@;