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/60-write.sql CHANGED
@@ -10,25 +10,123 @@ create or replace function @SCHEMA@.now_ts() returns timestamptz
10
10
  language sql volatile
11
11
  return clock_timestamp();
12
12
 
13
- -- Блокировки семейства (этап 1, п. 10): писатели и загрузчик берут разделяемую блокировку корня
14
- -- семейства, ужесточение описания — исключительную. Форма с двумя int4 — отдельное от
15
- -- пользовательского lock() (один bigint) пространство ключей advisory-блокировок. p_cls — строка
16
- -- Class класса семейства. Ключ — арендатор корневого класса и имя корня (итоговое ревью
17
- -- безопасности): ужесточение или rekeyClass одноимённого класса в другом арендаторе запись сюда не
18
- -- останавливает; корень из System (общий для всех арендаторов) — ключ System.
13
+ -- Внутренние блокировки (семейства, проектор, обслуживание) — строки-замки: строки вида LOCK таблицы secret, которой у
14
+ -- приложения нет, а не advisory-блокировки. Advisory-блокировку берёт любая роль базы, а её ключ виден всем в pg_locks:
15
+ -- посторонняя роль занимала бы её и останавливала запись семейства, проектор или обслуживание для всех (ревью
16
+ -- безопасности №3, №2). id строки-замка — lock_id имени; строки семейств заводятся заранее (class_admit, seed_system),
17
+ -- иначе строку вставляет первое обращение — и параллельное ждёт его транзакцию.
18
+ -- lock_id — первые 16 байт sha256 имени: его считает каждая строка записи, а v5_id собирает jsonb и зовёт pgcrypto.
19
+ create or replace function @SCHEMA@.lock_id(p_name text) returns uuid
20
+ language sql immutable parallel safe
21
+ return encode(substring(sha256(convert_to('letopis:lock:' || p_name, 'UTF8')) from 1 for 16), 'hex')::uuid;
22
+
23
+ create or replace function @SCHEMA@.lock_row(p_name text) returns uuid
24
+ language plpgsql volatile
25
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
26
+ as $$
27
+ declare
28
+ v uuid := @SCHEMA@.lock_id(p_name);
29
+ begin
30
+ if not exists (select 1 from @SCHEMA@.secret s where s.id = v) then
31
+ -- имя — в meta, а не в ident: уникальный индекс ident сравнивает без регистра, и строка-замок семейства, чей корень
32
+ -- отличается от другого только регистром, не вставлялась — его блокировка молча не бралась (/security-review после
33
+ -- ревью безопасности №3). Строки нет и после вставки — ошибка, а не запись без блокировки.
34
+ insert into @SCHEMA@.secret (id, account, kind, meta) values (v, '@SYSTEM@'::uuid, 'LOCK', jsonb_build_object('name', p_name))
35
+ on conflict do nothing;
36
+ if not exists (select 1 from @SCHEMA@.secret s where s.id = v) then
37
+ raise exception using errcode = 'XX000', message = format('строка-замок %s не создана', p_name);
38
+ end if;
39
+ end if;
40
+ return v;
41
+ end $$;
42
+
43
+ -- Строки вида LOCK других аккаунтов — не строки-замки: в 1.1 вид LOCK был открыт функциям кредов. При накате они
44
+ -- помечаются удалёнными; повторный накат их уже не находит.
45
+ update @SCHEMA@.secret s set deleted_at = clock_timestamp()
46
+ where s.kind = 'LOCK' and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
47
+
48
+ -- Строка-замок без ожидания (обслуживание и проектор — один исполнитель): true — взята до конца транзакции, false — её
49
+ -- держит другая транзакция. Блокировка — отдельным оператором: строку, которую вставила lock_row, видит уже он.
50
+ create or replace function @SCHEMA@.lock_try(p_name text) returns boolean
51
+ language plpgsql volatile
52
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
53
+ as $$
54
+ declare
55
+ k uuid := @SCHEMA@.lock_row(p_name);
56
+ begin
57
+ perform 1 from @SCHEMA@.secret s where s.id = k for update skip locked;
58
+ return found;
59
+ end $$;
60
+
61
+ -- Блокировки семейства (этап 1, п. 10): писатели и загрузчик берут разделяемую блокировку корня семейства, ужесточение
62
+ -- описания и rekeyClass — исключительную. Имя — арендатор корневого класса и имя корня (итоговое ревью безопасности):
63
+ -- ужесточение или rekeyClass одноимённого класса в другом арендаторе запись сюда не останавливает; корень из System
64
+ -- (общий для всех арендаторов) — арендатор System. p_data — описание класса с resolved.
65
+ create or replace function @SCHEMA@.family_name(p_tenant uuid, p_data jsonb) returns text
66
+ language sql stable
67
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
68
+ return '@SCHEMA_NAME@:family:' || (case when p_tenant = '@SYSTEM@'::uuid or p_data->>'name' = p_data->'resolved'->'ancestors'->>-1 then p_tenant
69
+ else coalesce((@SCHEMA@.class_row(p_data->'resolved'->'ancestors'->>-1, p_tenant)).tenant, p_tenant) end)::text
70
+ || ':' || (p_data->'resolved'->'ancestors'->>-1);
71
+
72
+ -- Строки-замки семейства класса — заранее, при записи строки Class и в seed_system: первая запись в семейство их не вставляет.
73
+ create or replace function @SCHEMA@.family_rows(p_tenant uuid, p_data jsonb) returns void
74
+ language plpgsql volatile
75
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
76
+ as $$
77
+ declare
78
+ n text := @SCHEMA@.family_name(p_tenant, p_data);
79
+ begin
80
+ perform @SCHEMA@.lock_row(n), @SCHEMA@.lock_row(n || ':gate');
81
+ end $$;
82
+
83
+ -- p_cls — строка Class класса семейства. Писатели — for key share строки-замка семейства, исключительная — for update:
84
+ -- писатели друг другу не мешают. Строковые блокировки, в отличие от advisory, очереди не держат: for key share проходит
85
+ -- мимо ждущего for update, и поток записей не дал бы исключительной случиться никогда. Поэтому у семейства есть шлюз —
86
+ -- вторая строка-замок: исключительная занимает его первым, а писатель проходит шлюз в подтранзакции — ждёт, пока шлюз
87
+ -- занят, и сразу отпускает. Шлюз писатель проходит один раз за транзакцию: тот, кто уже держит строку-замок, у шлюза
88
+ -- ждал бы исключительную, а та — его самого. Справка letopis.family_locks (подтранзакция, откат которой снимает
89
+ -- блокировку, откатывает и её) пропускает только шлюз, строку-замок функция берёт всегда — повторно взятая своя
90
+ -- блокировка не ждёт: справку выставит и клиент, а без блокировки запись прошла бы мимо ужесточения (автоматическое
91
+ -- ревью коммита 1bc6584).
19
92
  create or replace function @SCHEMA@.family_lock(p_cls @SCHEMA@.entity, p_exclusive boolean) returns void
20
93
  language plpgsql volatile
21
94
  as $$
22
95
  declare
23
- root text := p_cls.data->'resolved'->'ancestors'->>-1;
24
- k int;
96
+ n text := @SCHEMA@.family_name(p_cls.tenant, p_cls.data);
97
+ k uuid := @SCHEMA@.lock_id(n);
98
+ held text := coalesce(current_setting('letopis.family_locks', true), '');
99
+ g uuid;
25
100
  begin
26
- k := hashtext('@SCHEMA_NAME@:' || (case when p_cls.tenant = '@SYSTEM@'::uuid or p_cls.data->>'name' = root then p_cls.tenant
27
- else coalesce((@SCHEMA@.class_row(root, p_cls.tenant)).tenant, p_cls.tenant) end)::text || ':' || root);
28
101
  if p_exclusive then
29
- perform pg_advisory_xact_lock(19540, k);
102
+ g := @SCHEMA@.lock_row(n || ':gate');
103
+ perform 1 from @SCHEMA@.secret s where s.id = g for update;
104
+ perform @SCHEMA@.lock_row(n);
105
+ perform 1 from @SCHEMA@.secret s where s.id = k for update;
106
+ if not found then
107
+ raise exception using errcode = 'XX000', message = format('строка-замок %s не найдена', n);
108
+ end if;
30
109
  else
31
- perform pg_advisory_xact_lock_shared(19540, k);
110
+ if strpos(held, '|' || n || '|') = 0 then
111
+ g := @SCHEMA@.lock_row(n || ':gate');
112
+ begin
113
+ perform 1 from @SCHEMA@.secret s where s.id = g for key share;
114
+ raise exception 'шлюз пройден';
115
+ exception when raise_exception then
116
+ null;
117
+ end;
118
+ end if;
119
+ perform 1 from @SCHEMA@.secret s where s.id = k for key share;
120
+ if not found then
121
+ perform @SCHEMA@.lock_row(n);
122
+ perform 1 from @SCHEMA@.secret s where s.id = k for key share;
123
+ if not found then
124
+ raise exception using errcode = 'XX000', message = format('строка-замок %s не найдена', n);
125
+ end if;
126
+ end if;
127
+ end if;
128
+ if strpos(held, '|' || n || '|') = 0 then
129
+ perform set_config('letopis.family_locks', held || '|' || n || '|', true);
32
130
  end if;
33
131
  end $$;
34
132
  -- до итогового ревью безопасности ключ был без арендатора
@@ -169,7 +267,8 @@ end $$;
169
267
  -- При p_acl ({tenant, account, plan} — план READ актора) цель ещё и должна быть видна актору (этап 5,
170
268
  -- п. 6): строку, которую он не видит, нельзя использовать как цель — ответ тот же, что для
171
269
  -- несуществующей. Цели-аккаунты (Account) не проверяются: на них ссылаются членства и владельцы.
172
- -- Возвращает роль первой непрошедшей цели или null.
270
+ -- Возвращает роль первой непрошедшей цели или null; цель — строка производного класса: «!роль»
271
+ -- (вызывающий отвечает derived_class).
173
272
  create or replace function @SCHEMA@.targets_bad(p_rows jsonb, p_lock boolean, p_acl jsonb default null) returns text
174
273
  language plpgsql volatile
175
274
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -200,21 +299,31 @@ begin
200
299
  left join lateral jsonb_array_elements(case jsonb_typeof(k.value) when 'array' then k.value else '[]'::jsonb end) a on true
201
300
  where jsonb_typeof(k.value) = 'string' or a.value is not null
202
301
  )
203
- select p.role into bad
204
- from p
205
- join c on c.cls = p.cls and c.tenant = p.tenant and c.ends is not distinct from coalesce(p.ends, c.ends)
206
- left join @SCHEMA@.entity t on t.id = p.tid
207
- where t.id is null
208
- or t.tenant not in (p.tenant, '@SYSTEM@'::uuid)
209
- or not coalesce(@SCHEMA@.class_set(c.ends->p.role->'class') ? t.class
210
- or @SCHEMA@.class_set(c.ends->p.role->'class') ?|
211
- (select array(select jsonb_array_elements_text(k.data->'resolved'->'ancestors'))
212
- from @SCHEMA@.entity k
213
- where k.class = 'Class' and k.data->>'name' = t.class and k.tenant in (t.tenant, '@SYSTEM@'::uuid)
214
- order by k.tenant = '@SYSTEM@'::uuid limit 1), false)
215
- or (p_acl is not null and t.class <> 'Account'
216
- and not @SCHEMA@.row_visible(t.tenant, t.class, t.id, t.owner, t.tags, t.data, t.links,
217
- (p_acl->>'tenant')::uuid, (p_acl->>'account')::uuid, p_acl->'plan'))
302
+ select case when x.miss then x.role else '!' || x.role end into bad
303
+ from (
304
+ select p.role,
305
+ t.id is null
306
+ or t.tenant not in (p.tenant, '@SYSTEM@'::uuid)
307
+ or not coalesce(@SCHEMA@.class_set(c.ends->p.role->'class') ? t.class
308
+ or @SCHEMA@.class_set(c.ends->p.role->'class') ?| array(select jsonb_array_elements_text(kr.res->'ancestors')), false)
309
+ or (p_acl is not null and t.class <> 'Account'
310
+ and not @SCHEMA@.row_visible(t.tenant, t.class, t.id, t.owner, t.tags, t.data, t.links,
311
+ (p_acl->>'tenant')::uuid, (p_acl->>'account')::uuid, p_acl->'plan')) as miss,
312
+ coalesce(kr.res ? 'derivedBy', false) as derived
313
+ from p
314
+ join c on c.cls = p.cls and c.tenant = p.tenant and c.ends is not distinct from coalesce(p.ends, c.ends)
315
+ left join @SCHEMA@.entity t on t.id = p.tid
316
+ -- описание класса цели — только когда класс не назван в конце прямо: тогда нужны его предки, а цель
317
+ -- производного класса (конец, названный предком; план 1.1, §2.4) не проходит — прямо назвать производный
318
+ -- класс в конце не даёт описание (class_admit)
319
+ left join lateral (
320
+ select case when t.id is null or @SCHEMA@.class_set(c.ends->p.role->'class') ? t.class then null
321
+ else (select k.data->'resolved' from @SCHEMA@.entity k
322
+ where k.class = 'Class' and k.data->>'name' = t.class and k.tenant in (t.tenant, '@SYSTEM@'::uuid)
323
+ order by k.tenant = '@SYSTEM@'::uuid limit 1) end as res
324
+ ) kr on true
325
+ ) x
326
+ where x.miss or x.derived
218
327
  limit 1;
219
328
  return bad;
220
329
  end $$;
@@ -239,7 +348,9 @@ begin
239
348
  end if;
240
349
  bad := @SCHEMA@.targets_bad(jsonb_build_array(jsonb_build_object(
241
350
  'class', p_res->>'name', 'tenant', p_tenant, 'links', links, 'ends', coalesce(p_res->'ends', '{}'::jsonb))), p_lock, p_acl);
242
- if bad is not null then
351
+ if bad like '!%' then
352
+ code := 'derived_class'; msg := format('конец %s ведёт на строку производного класса', substr(bad, 2));
353
+ elsif bad is not null then
243
354
  code := 'target_not_found'; msg := format('цель конца %s не найдена', bad);
244
355
  end if;
245
356
  end $$;
@@ -260,34 +371,74 @@ language sql stable
260
371
  set search_path = pg_catalog, @SCHEMA@, pg_temp
261
372
  return coalesce((@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved'->>'history', 'true') <> 'false';
262
373
 
263
- -- Сигнал с именами изменённых классов (§2.12, решение 9 точки А). Транзакция с NOTIFY фиксируется
264
- -- под общей блокировкой кластера вместе с fsync, поэтому библиотека помечает свои транзакции
265
- -- letopis.notify = 'deferred': имена копятся в letopis.changed, а библиотека после фиксации
266
- -- отправляет их одной транзакцией без данных. Сырой SQL шлёт NOTIFY здесь же, вместе с фиксацией.
267
- create or replace function @SCHEMA@.signal(p_classes text[]) returns void
374
+ -- Сигналы (план 1.1, §2.2): канал — имя схемы, нагрузка — по режиму установки '@SIGNALS@'
375
+ -- (up({ signals })): class — имя класса; tenant — «Класс@токен арендатора»; opaque — «токен класса@токен
376
+ -- арендатора»; none — сигналов нет. Сессии — session:<16 hex>, сброс — * (кроме none). Токен — первые
377
+ -- 96 бит HMAC-SHA256 от «назначение:значение» на ключе сигналов (HMAC ключа строки KEY по
378
+ -- 'letopis:signals', его отдаёт signal_key()): без ключа псевдоним не сопоставить с именем, а
379
+ -- нагрузка детерминирована — PostgreSQL склеивает одинаковые сигналы транзакции.
380
+ -- до плана 1.1 сигнал принимал только имена классов
381
+ drop function if exists @SCHEMA@.signal(text[]);
382
+
383
+ create or replace function @SCHEMA@.signal_token(p_purpose text, p_value text, p_key bytea) returns text
384
+ language sql immutable parallel safe
385
+ return left(encode(@PGCRYPTO@.hmac(convert_to(p_purpose || ':' || p_value, 'UTF8'), p_key, 'sha256'), 'hex'), 24);
386
+
387
+ -- Готовые нагрузки: сигналы записи от signal(), сессии и сброс. Транзакция с NOTIFY фиксируется под
388
+ -- общей блокировкой кластера вместе с fsync, поэтому библиотека помечает свои транзакции
389
+ -- letopis.notify = 'deferred': нагрузки копятся в letopis.changed, а библиотека после фиксации
390
+ -- сбрасывает по ним свой кэш и отправляет их одной транзакцией без данных (в режиме none — не
391
+ -- отправляет). Сырой SQL шлёт NOTIFY здесь же, вместе с фиксацией; в режиме none — ничего.
392
+ create or replace function @SCHEMA@.signal_raw(p_payloads text[]) returns void
268
393
  language plpgsql volatile
269
394
  set search_path = pg_catalog, @SCHEMA@, pg_temp
270
395
  as $$
271
396
  declare
272
397
  acc text[];
273
- cl text;
398
+ pl text;
274
399
  begin
275
- if p_classes is null or cardinality(p_classes) = 0 then
400
+ p_payloads := array(select distinct x from unnest(p_payloads) x where x <> '' order by x);
401
+ if cardinality(p_payloads) = 0 then
276
402
  return;
277
403
  end if;
278
404
  if coalesce(current_setting('letopis.notify', true), '') = 'deferred' then
279
405
  acc := coalesce(string_to_array(nullif(current_setting('letopis.changed', true), ''), ','), '{}');
280
- if not (acc @> p_classes) then
406
+ if not (acc @> p_payloads) then
281
407
  perform set_config('letopis.changed',
282
- array_to_string(array(select distinct x from unnest(acc || p_classes) x order by x), ','), true);
408
+ array_to_string(array(select distinct x from unnest(acc || p_payloads) x order by x), ','), true);
283
409
  end if;
284
- else
285
- foreach cl in array p_classes loop
286
- perform pg_notify('@SCHEMA_NAME@', cl);
410
+ elsif '@SIGNALS@' <> 'none' then
411
+ foreach pl in array p_payloads loop
412
+ perform pg_notify('@SCHEMA_NAME@', pl);
287
413
  end loop;
288
414
  end if;
289
415
  end $$;
290
416
 
417
+ -- Сигнал записи: пары «класс, арендатор» изменённых строк (арендатор null — System) → нагрузки по
418
+ -- режиму. Одинаковые пары склеиваются: транзакция, записавшая тысячу строк одного класса одного
419
+ -- арендатора, посылает один сигнал.
420
+ create or replace function @SCHEMA@.signal(p_classes text[], p_tenants uuid[]) returns void
421
+ language plpgsql volatile
422
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
423
+ as $$
424
+ declare
425
+ k bytea;
426
+ begin
427
+ if p_classes is null or cardinality(p_classes) = 0 then
428
+ return;
429
+ end if;
430
+ if '@SIGNALS@' in ('tenant', 'opaque') then
431
+ select @PGCRYPTO@.hmac(convert_to('letopis:signals', 'UTF8'), s.material, 'sha256') into k
432
+ from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY';
433
+ end if;
434
+ perform @SCHEMA@.signal_raw(array(
435
+ select case '@SIGNALS@'
436
+ when 'tenant' then p.c || '@' || @SCHEMA@.signal_token('tenant', p.t::text, k)
437
+ when 'opaque' then @SCHEMA@.signal_token('class', p.c, k) || '@' || @SCHEMA@.signal_token('tenant', p.t::text, k)
438
+ else p.c end
439
+ from (select distinct u.c, coalesce(u.t, '@SYSTEM@'::uuid) as t from unnest(p_classes, p_tenants) u(c, t) where u.c is not null) p));
440
+ end $$;
441
+
291
442
  -- Версия одной строки в журнал и сигнал — для функций владельца, которые пишут строку сами
292
443
  -- (пересчёт потомков, снятие конца, перенос на новый id). Классы с history: false журнал не ведут,
293
444
  -- но сигнал посылают. Обычную запись журналируют триггеры после оператора — одной вставкой.
@@ -295,12 +446,19 @@ create or replace function @SCHEMA@.log_version(e @SCHEMA@.entity) returns void
295
446
  language plpgsql volatile
296
447
  set search_path = pg_catalog, @SCHEMA@, pg_temp
297
448
  as $$
449
+ declare
450
+ p @SCHEMA@.log;
298
451
  begin
299
452
  if @SCHEMA@.history_on(e.class, e.tenant) then
300
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
301
- values (e.id, e.rev, e.class, e.cv, e.tenant, e.owner, e.links, e.data, e.tags, e.at, e.author, e.agent, e.op, e.reason, e.moved, e.prev_hash);
302
- end if;
303
- perform @SCHEMA@.signal(array[e.class]);
453
+ -- пути изменения: против предыдущей версии id, у первой версии переноса — против прежнего id (moved)
454
+ select l.* into p from @SCHEMA@.log l
455
+ where l.id = case when e.rev > 1 then e.id else e.moved end and l.op not in ('purge', 'trim', 'reset')
456
+ order by l.rev desc limit 1;
457
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
458
+ values (e.id, e.rev, e.class, e.cv, e.tenant, e.owner, e.links, e.data, e.tags, e.at, e.author, e.agent, e.op, e.reason, e.moved, e.prev_hash,
459
+ case when p.id is not null then @SCHEMA@.change_paths(p.data, p.links, p.tags, p.owner, p.class, e.data, e.links, e.tags, e.owner, e.class) end);
460
+ end if;
461
+ perform @SCHEMA@.signal(array[e.class], array[e.tenant]);
304
462
  end $$;
305
463
 
306
464
  -- Новая версия строки от имени владельца (пересчёт описаний потомков, снятие конца при удалении):
@@ -325,6 +483,11 @@ begin
325
483
  cv = coalesce((@SCHEMA@.class_row(coalesce(p_class, x.class), x.tenant)).rev, x.cv)
326
484
  where x.id = p_id
327
485
  returning x.* into e;
486
+ -- смена класса: агрегатные поля новой цели триггер после правки посчитал в эту же версию (план 1.1, §2.4) —
487
+ -- в журнал идёт строка таблицы, а не returning
488
+ if p_class is not null then
489
+ select x.* into e from @SCHEMA@.entity x where x.id = p_id;
490
+ end if;
328
491
  perform @SCHEMA@.log_version(e);
329
492
  return e;
330
493
  end $$;
@@ -384,12 +547,15 @@ end $$;
384
547
 
385
548
  -- Безопасно ли изменение описания класса (этап 1, п. 10): новые необязательные поля и концы,
386
549
  -- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
387
- -- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись).
550
+ -- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись),
551
+ -- признака производного класса derivedBy и описания агрегатов aggregate (план 1.1, §2.6).
388
552
  create or replace function @SCHEMA@.class_change_safe(o jsonb, n jsonb) returns boolean
389
553
  language sql immutable
390
554
  return
391
555
  o->'key' = n->'key'
392
556
  and o->'ancestors' = n->'ancestors'
557
+ and o->'derivedBy' is not distinct from n->'derivedBy'
558
+ and o->'aggregate' is not distinct from n->'aggregate'
393
559
  and (o->'abstract' = n->'abstract' or n->>'abstract' = 'false')
394
560
  and o->'schema'->'anyOf' is not distinct from n->'schema'->'anyOf'
395
561
  and o->'schema'->'oneOf' is not distinct from n->'schema'->'oneOf'
@@ -417,6 +583,8 @@ declare
417
583
  problem text;
418
584
  inc jsonb;
419
585
  outv jsonb := '[]'::jsonb;
586
+ a jsonb := @SCHEMA@.acl_actor('READ');
587
+ vis boolean;
420
588
  begin
421
589
  for r in select e.* from @SCHEMA@.entity e
422
590
  where e.class = any (p_classes) and (e.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
@@ -442,7 +610,13 @@ begin
442
610
  if problem is null and p_incoming then
443
611
  inc := @SCHEMA@.incoming_conflicts(r.id, r.class, r.tenant);
444
612
  if inc <> '[]'::jsonb then
445
- problem := format('на строку ссылается роль %s строки %s, которая не принимает класс', inc->0->>'role', inc->0->>'id');
613
+ -- id ссылающейся строки — только если актор её видит (доработки после точки Б)
614
+ select @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links,
615
+ (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan') into vis
616
+ from @SCHEMA@.entity x where x.id = (inc->0->>'id')::uuid;
617
+ problem := case when coalesce(vis, false)
618
+ then format('на строку ссылается роль %s строки %s, которая не принимает класс', inc->0->>'role', inc->0->>'id')
619
+ else format('на строку ссылается роль %s строки, которой актор не видит, и она не принимает класс', inc->0->>'role') end;
446
620
  end if;
447
621
  end if;
448
622
  if problem is not null then
@@ -459,8 +633,63 @@ create or replace function @SCHEMA@.reserved_names() returns text[]
459
633
  language sql immutable parallel safe
460
634
  return array[@RESERVED_NAMES@]::text[];
461
635
 
636
+ -- Правка класса p_class арендатора p_tenant меняет имена, по которым его строки находят правила прав: предков
637
+ -- (parent), алиас или derivedBy (ревью безопасности №2). План прав берёт для класса имена предков и их алиасы
638
+ -- (acl_plan_for), поэтому строки класса и его потомков попали бы под правила READ, WRITE и DELETE нового предка или
639
+ -- алиаса — право WRITE на Class открывало бы чужие строки. Актору нужно право READ, WRITE и DELETE без условий на
640
+ -- все строки самого класса и каждого потомка своего арендатора, иначе acl_denied. Потомков у арендаторов класса
641
+ -- System план прав сессии System не видит — они пропускаются, как у агрегатов (agg_source_admit). Проверка — до
642
+ -- правки (class_admit): план видит прежние имена, по которым строки уже защищены.
643
+ create or replace function @SCHEMA@.class_acl_admit(p_class text, p_tenant uuid) returns void
644
+ language plpgsql stable
645
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
646
+ as $$
647
+ begin
648
+ perform @SCHEMA@.class_rows_admit(p_class, p_tenant, array['READ', 'WRITE', 'DELETE'],
649
+ format('класс %s меняет предков, алиас или derivedBy', p_class));
650
+ end $$;
651
+
652
+ -- Право актора на все строки класса p_class и каждого его потомка арендатора p_tenant: по каждой операции p_ops —
653
+ -- без условий, иначе acl_denied с текстом «<p_what>: нужно право <op> на все строки класса <класс>». Сам класс первым,
654
+ -- потомки — по глубине и имени; потомков у арендаторов класса System план сессии System не видит — они пропускаются.
655
+ -- Зовут class_acl_admit (смена предков, алиаса, derivedBy), ужесточение (class_evolve, rekey_class) и смена политики
656
+ -- хранения истории (class_admit; доработки после точки Б).
657
+ create or replace function @SCHEMA@.class_rows_admit(p_class text, p_tenant uuid, p_ops text[], p_what text) returns void
658
+ language plpgsql stable
659
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
660
+ as $$
661
+ declare
662
+ c text;
663
+ op text;
664
+ chk jsonb;
665
+ begin
666
+ for c in select p_class
667
+ union all
668
+ (select x.data->>'name' from @SCHEMA@.entity x
669
+ where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' <> p_class
670
+ and x.data->'resolved'->'ancestors' ? p_class
671
+ order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.data->>'name') loop
672
+ foreach op in array p_ops loop
673
+ chk := @SCHEMA@.acl_check_data(c, op);
674
+ if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
675
+ perform @SCHEMA@.lt_fail('acl_denied', format('%s: нужно право %s на все строки класса %s', p_what, op, c));
676
+ end if;
677
+ end loop;
678
+ end loop;
679
+ end $$;
680
+
681
+ -- Есть ли в журнале версии строк класса p_name арендатора p_tenant (у класса System — любого арендатора); записи purge,
682
+ -- trim и reset — не версии (доработки после точки Б: удаление и пересоздание класса, остаток Н2 ревью №2).
683
+ create or replace function @SCHEMA@.class_has_history(p_name text, p_tenant uuid) returns boolean
684
+ language sql stable
685
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
686
+ return exists (select 1 from @SCHEMA@.log l
687
+ where l.class = p_name and (l.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
688
+ and l.op not in ('purge', 'trim', 'reset'));
689
+
462
690
  -- Проверки строки Class сверх метасхемы (этап 1, п. 6): признак system, имя системного класса,
463
- -- имена связей против методов цепочки, однозначность имени конца во всей схеме.
691
+ -- имена связей против методов цепочки, однозначность имени конца во всей схеме, право на агрегаты,
692
+ -- алиас и право на смену предков, алиаса и derivedBy (ревью безопасности №2).
464
693
  create or replace function @SCHEMA@.class_admit(p_data jsonb, p_tenant uuid, out data jsonb, out validator jsonpath)
465
694
  language plpgsql volatile
466
695
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -469,6 +698,13 @@ as $$
469
698
  declare
470
699
  e record;
471
700
  o record;
701
+ dname text;
702
+ drole text;
703
+ tc @SCHEMA@.entity;
704
+ prevd jsonb;
705
+ src text;
706
+ chk jsonb;
707
+ m jsonb;
472
708
  begin
473
709
  if p_tenant <> '@SYSTEM@'::uuid then
474
710
  if (p_data->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
@@ -486,6 +722,85 @@ begin
486
722
  if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
487
723
  perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
488
724
  end if;
725
+ -- алиас — второе имя класса и для правил прав (ревью безопасности №2): совпади он с именем другого класса (в том
726
+ -- числе Class и системных) или с чужим алиасом арендатора и System, правила одного класса действовали бы на строки
727
+ -- другого
728
+ if data->>'alias' is not null
729
+ and ((data->>'alias') = any (array['Class', 'Account', 'member', 'Resource', 'rule'])
730
+ or exists (select 1 from @SCHEMA@.entity x
731
+ where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
732
+ and (x.data->>'name' = data->>'alias' or x.data->>'alias' = data->>'alias'))) then
733
+ perform @SCHEMA@.lt_fail('invalid_class', format('алиас %s класса %s совпадает с именем или алиасом другого класса',
734
+ data->>'alias', data->>'name'));
735
+ end if;
736
+ -- и наоборот: имя класса — не чужой алиас арендатора и System, иначе правила, написанные для нового класса, действовали
737
+ -- бы и на строки класса с этим алиасом
738
+ if exists (select 1 from @SCHEMA@.entity x
739
+ where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
740
+ and x.data->>'alias' = data->>'name') then
741
+ perform @SCHEMA@.lt_fail('invalid_class', format('имя класса %s совпадает с алиасом другого класса', data->>'name'));
742
+ end if;
743
+ -- агрегаты (план 1.1, §2.4; ревью коммита этапа 4б): поле считают функции владельца по всем строкам источника
744
+ -- арендатора, без прав чтения, поэтому строки попадают в агрегат только с ведома того, кто читает их все. Пути
745
+ -- владельца (сид, class_evolve, rekey_class) сюда не заходят. Описание до правки — для сравнения (нет — новый класс)
746
+ select x.data into prevd from @SCHEMA@.entity x
747
+ where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' = data->>'name';
748
+ -- производный класс (план 1.1, §2.4) заводит, меняет и удаляет только администратор (/security-review после точки Б):
749
+ -- проектор берёт арендатора в участники по его классам с пометкой и пишет туда итоги по всем строкам источников,
750
+ -- поэтому пользователь с правом WRITE на Class, заведя такой класс сам, читал бы итоги по строкам, которые ему читать
751
+ -- запрещено. Пометка своя или унаследованная, до правки или после; право letopis.projector — у администраторской роли
752
+ -- подключения и по правилам System (системный администратор — маска letopis.*)
753
+ if (data->'resolved' ? 'derivedBy' or coalesce(prevd->'resolved' ? 'derivedBy', false))
754
+ and not @SCHEMA@.letopis_allowed('letopis.projector') then
755
+ perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)',
756
+ data->>'name'));
757
+ end if;
758
+ -- агрегат развёрнутого описания (свой или унаследованный), новый или изменённый против прежнего — описание
759
+ -- агрегата, новый потомок цели, смена parent, — задаёт только тот, кто без условий читает все строки каждого
760
+ -- класса-источника: класса of и его потомков (agg_classes: классы арендатора, у класса System — классы System).
761
+ -- Неизменённый агрегат не проверяется
762
+ if data->'resolved' ? 'aggregate' then
763
+ for e in select key, value from jsonb_each(data->'resolved'->'aggregate') order by key loop
764
+ continue when prevd->'resolved'->'aggregate'->e.key is not distinct from e.value;
765
+ -- сам класс of первым, потомки — по имени
766
+ foreach src in array array(select n from unnest(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) n
767
+ order by n = (e.value->>'of') desc, n) loop
768
+ chk := @SCHEMA@.acl_check_data(src, 'READ');
769
+ if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
770
+ perform @SCHEMA@.lt_fail('acl_denied', format('агрегат %s.%s: нужно право READ на все строки класса %s', data->>'name', e.key, src));
771
+ end if;
772
+ end loop;
773
+ end loop;
774
+ end if;
775
+ -- класс становится источником агрегата (новая метка aggregatedBy — смена parent): в агрегат войдут его строки и строки
776
+ -- потомков — нужно право READ без условий на них до правки, по прежним предкам (здесь), и после (agg_source_admit в
777
+ -- триггере после оператора; новый класс проверяется только там: до вставки план прав его не видит)
778
+ m := @SCHEMA@.agg_new_mark(prevd->'resolved', data->'resolved');
779
+ if m is not null and prevd is not null then
780
+ perform @SCHEMA@.agg_source_admit(data->>'name', m);
781
+ for src in select x.data->>'name' from @SCHEMA@.entity x
782
+ where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' <> data->>'name'
783
+ and x.data->'resolved'->'ancestors' ? (data->>'name')
784
+ order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.data->>'name' loop
785
+ perform @SCHEMA@.agg_source_admit(src, m);
786
+ end loop;
787
+ end if;
788
+ -- правка меняет предков, алиас или derivedBy (ревью безопасности №2): строки класса и потомков попали бы под правила
789
+ -- других имён — нужно право READ, WRITE и DELETE без условий на все их строки. У нового класса строк ещё нет
790
+ if prevd is not null
791
+ and (prevd->'resolved'->'ancestors' is distinct from data->'resolved'->'ancestors'
792
+ or prevd->>'alias' is distinct from data->>'alias'
793
+ or prevd->'resolved'->'derivedBy' is distinct from data->'resolved'->'derivedBy') then
794
+ perform @SCHEMA@.class_acl_admit(data->>'name', p_tenant);
795
+ end if;
796
+ -- политика хранения истории (доработки после точки Б): по ней maintain() прореживает журнал строк класса, а
797
+ -- history: false выключает журнал. Одним правом WRITE на Class автор правки стирал бы аудит строк, на которые у него
798
+ -- нет прав, поэтому смена развёрнутой history существующего класса требует права READ и DELETE без условий на все
799
+ -- строки класса и его потомков своего арендатора — как trimHistory требует их на каждую цель
800
+ if prevd is not null and prevd->'resolved'->'history' is distinct from data->'resolved'->'history' then
801
+ perform @SCHEMA@.class_rows_admit(data->>'name', p_tenant, array['READ', 'DELETE'],
802
+ format('класс %s меняет политику хранения истории', data->>'name'));
803
+ end if;
489
804
  for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
490
805
  select x.data->>'name' as name into o
491
806
  from @SCHEMA@.entity x
@@ -498,6 +813,51 @@ begin
498
813
  perform @SCHEMA@.lt_fail('invalid_class', format('конец %s в классе %s ведёт на другой класс', e.key, o.name));
499
814
  end if;
500
815
  end loop;
816
+ -- на строки производного класса (план 1.1, §2.4) не ссылаются: проектор удаляет и строит их заново.
817
+ -- Классы целей — под разделяемой блокировкой семейства, а класс, который становится производным, — под
818
+ -- исключительной: описание со ссылкой и пометка derivedBy не проходят параллельно мимо друг друга
819
+ for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
820
+ for tc in select c.* from jsonb_array_elements_text(@SCHEMA@.class_set(e.value->'class')) n(name)
821
+ cross join lateral @SCHEMA@.class_row(n.name, p_tenant) c where c.id is not null order by c.id loop
822
+ perform @SCHEMA@.family_lock(tc, false);
823
+ end loop;
824
+ dname := null;
825
+ select x.data->>'name' into dname
826
+ from @SCHEMA@.entity x
827
+ where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
828
+ and @SCHEMA@.class_set(e.value->'class') ? (x.data->>'name') and x.data->'resolved' ? 'derivedBy'
829
+ limit 1;
830
+ if dname is null and data->'resolved' ? 'derivedBy' and @SCHEMA@.class_set(e.value->'class') ? (data->>'name') then
831
+ dname := data->>'name';
832
+ end if;
833
+ if dname is not null then
834
+ perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на производный класс %s', e.key, dname));
835
+ end if;
836
+ end loop;
837
+ if data->'resolved' ? 'derivedBy' then
838
+ tc := @SCHEMA@.class_row(data->>'name', p_tenant);
839
+ if tc.id is not null then
840
+ perform @SCHEMA@.family_lock(tc, true);
841
+ end if;
842
+ -- класс становится производным, а на него или его потомков уже ссылаются концы других классов (класс
843
+ -- System видят все арендаторы — его ссылки ищутся во всех)
844
+ select x.data->>'name', k.key into dname, drole
845
+ from @SCHEMA@.entity x
846
+ cross join lateral jsonb_each(coalesce(x.data->'resolved'->'ends', '{}'::jsonb)) k
847
+ where x.class = 'Class' and (x.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
848
+ and x.data->>'name' <> data->>'name'
849
+ and @SCHEMA@.class_set(k.value->'class') ?| (array[data->>'name'] || array(
850
+ select y.data->>'name' from @SCHEMA@.entity y
851
+ where y.class = 'Class' and (y.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
852
+ and y.data->'resolved'->'ancestors' ? (data->>'name')))
853
+ limit 1;
854
+ if dname is not null then
855
+ perform @SCHEMA@.lt_fail('derived_class', format('класс %s не может стать производным: на него ссылается конец %s класса %s',
856
+ data->>'name', drole, dname));
857
+ end if;
858
+ end if;
859
+ -- строки-замки семейства (family_lock) — заранее: первая запись в новое семейство их не вставляет
860
+ perform @SCHEMA@.family_rows(p_tenant, data);
501
861
  end $$;
502
862
 
503
863
  -- ========================= триггеры =========================
@@ -539,9 +899,21 @@ begin
539
899
  if coalesce((res->>'abstract')::boolean, false) then
540
900
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
541
901
  end if;
902
+ if res ? 'derivedBy' then
903
+ perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', NEW.class, res->>'derivedBy'));
904
+ end if;
542
905
  if NEW.class = 'Class' then
543
906
  select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(coalesce(NEW.data, '{}'::jsonb), NEW.tenant) a;
544
907
  else
908
+ -- агрегатное поле считает база (план 1.1, §2.4): переданное значение — отказ, новое — 0 до умолчаний и
909
+ -- проверки; итог из живых источников ставит триггер после оператора
910
+ if res ? 'aggregate' then
911
+ if coalesce(NEW.data, '{}'::jsonb) ?| @SCHEMA@.agg_fields(res) then
912
+ perform @SCHEMA@.lt_fail('aggregate_field', format('поля %s класса %s считает база (aggregate) — их не передают',
913
+ array_to_string(array(select x from unnest(@SCHEMA@.agg_fields(res)) x where NEW.data ? x), ', '), NEW.class));
914
+ end if;
915
+ NEW.data := @SCHEMA@.agg_zero(res, coalesce(NEW.data, '{}'::jsonb));
916
+ end if;
545
917
  NEW.data := @SCHEMA@.apply_defaults(res->'schema', coalesce(NEW.data, '{}'::jsonb));
546
918
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
547
919
  if bad <> '[]'::jsonb then
@@ -573,7 +945,20 @@ begin
573
945
  -- воскрешение: номер версии и цепочка хэшей продолжаются от последней записи журнала; запись
574
946
  -- блокируется — параллельные purge, обрезка и restore того же id ждут (этап 6), а если purge успел
575
947
  -- стереть её, пара (id, rev) столкнётся с его записью purge в журнале, и операция повторится
576
- select l.rev, l.hash, l.at into last from @SCHEMA@.log l where l.id = NEW.id order by l.rev desc limit 1 for update;
948
+ select l.rev, l.hash, l.at, l.op, l.data into last from @SCHEMA@.log l where l.id = NEW.id order by l.rev desc limit 1 for update;
949
+ -- класс с историей строк в журнале (остаток Н2 ревью №2; доработки после точки Б): правила прав находят строки, и
950
+ -- старые версии тоже, по именам класса, предков и алиасов — класс с другим parent, alias или derivedBy подставил бы
951
+ -- старую историю под чужие правила. Поэтому, если журнал арендатора хранит версии строк класса с этим именем, класс
952
+ -- создаётся только заново и с тем же описанием, что в надгробии его строки Class. Надгробия нет (история осталась от
953
+ -- класса System, удалённого раньше, или строку Class стёрли мимо триггеров) — отказ: сначала purge() строк
954
+ if NEW.class = 'Class' and @SCHEMA@.class_has_history(NEW.data->>'name', NEW.tenant)
955
+ and not (coalesce(last.op = 'delete', false)
956
+ and last.data->'resolved'->'ancestors' is not distinct from NEW.data->'resolved'->'ancestors'
957
+ and last.data->>'alias' is not distinct from NEW.data->>'alias'
958
+ and last.data->'resolved'->'derivedBy' is not distinct from NEW.data->'resolved'->'derivedBy') then
959
+ perform @SCHEMA@.lt_fail('invalid_class', format('журнал хранит историю строк класса %s: создать его можно только заново и с прежними parent, alias и derivedBy (или сначала стереть её purge())',
960
+ NEW.data->>'name'));
961
+ end if;
577
962
  ts := @SCHEMA@.now_ts();
578
963
  NEW.rev := coalesce(last.rev, 0) + 1;
579
964
  NEW.prev_hash := last.hash;
@@ -635,6 +1020,10 @@ begin
635
1020
  if coalesce((res->>'abstract')::boolean, false) then
636
1021
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
637
1022
  end if;
1023
+ if res ? 'derivedBy' or (NEW.class <> OLD.class and (@SCHEMA@.class_row(OLD.class, OLD.tenant)).data->'resolved' ? 'derivedBy') then
1024
+ perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор',
1025
+ case when res ? 'derivedBy' then NEW.class else OLD.class end));
1026
+ end if;
638
1027
  if NEW.class <> OLD.class then
639
1028
  -- смена класса (этап 1, п. 9): только read committed, только для классов без ключа
640
1029
  if current_setting('transaction_isolation') <> 'read committed' then
@@ -647,6 +1036,11 @@ begin
647
1036
  -- обычный update берёт слабую блокировку; смене класса нужна for update, чтобы дождаться
648
1037
  -- транзакций, которые как раз пишут ссылку на эту строку
649
1038
  perform 1 from @SCHEMA@.entity x where x.id = OLD.id for update;
1039
+ -- строка уходит из своего класса — как удаление из него: нужно право DELETE на прежнюю строку (доработки после
1040
+ -- точки Б; иначе с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там)
1041
+ if not @SCHEMA@.acl_row_ok(@SCHEMA@.acl_plan('DELETE'), OLD.class, OLD.id, OLD.owner, OLD.tenant, OLD.tags, OLD.data, OLD.links) then
1042
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на строку класса %s: смена класса убирает её из класса', OLD.class));
1043
+ end if;
650
1044
  inc := @SCHEMA@.incoming_conflicts(OLD.id, NEW.class, OLD.tenant);
651
1045
  if inc <> '[]'::jsonb then
652
1046
  perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', NEW.class),
@@ -657,6 +1051,11 @@ begin
657
1051
  if NEW.class = 'Class' then
658
1052
  select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(NEW.data, OLD.tenant) a;
659
1053
  else
1054
+ -- агрегатное поле (план 1.1, §2.4): изменить его нельзя, без поля (замена данных целиком) — прежнее значение;
1055
+ -- при смене класса — прежнее число или 0, итог посчитает триггер после оператора
1056
+ if res ? 'aggregate' then
1057
+ NEW.data := @SCHEMA@.agg_guard(res, OLD.data, NEW.data, NEW.class <> OLD.class);
1058
+ end if;
660
1059
  NEW.data := @SCHEMA@.apply_defaults(res->'schema', NEW.data);
661
1060
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
662
1061
  if bad <> '[]'::jsonb then
@@ -684,6 +1083,11 @@ begin
684
1083
  -- ужесточение: исключительная блокировка корня семейства (и нового корня при смене родителя)
685
1084
  perform @SCHEMA@.family_lock(OLD, true);
686
1085
  perform @SCHEMA@.family_lock(NEW, true);
1086
+ -- смена агрегатов — ещё семейства источников (план 1.1, §2.4): операция, зафиксированная до пересчёта, в него
1087
+ -- войдёт, а ожидающая допишет вклад уже по новому описанию
1088
+ if OLD.data->'resolved'->'aggregate' is distinct from NEW.data->'resolved'->'aggregate' then
1089
+ perform @SCHEMA@.agg_lock_sources(OLD.data->'resolved', NEW.data->'resolved', OLD.tenant);
1090
+ end if;
687
1091
  end if;
688
1092
  NEW.rev := OLD.rev + 1;
689
1093
  NEW.prev_hash := OLD.hash;
@@ -704,14 +1108,17 @@ language plpgsql security definer
704
1108
  set search_path = pg_catalog, @SCHEMA@, pg_temp
705
1109
  as $$
706
1110
  declare
707
- cls text[];
1111
+ sc text[];
1112
+ st uuid[];
708
1113
  bad text;
709
1114
  begin
710
1115
  if exists (select 1 from added n where n.links <> '{}'::jsonb) then
711
1116
  bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
712
1117
  from added n where n.links <> '{}'::jsonb), true, @SCHEMA@.acl_actor('READ'));
713
1118
  end if;
714
- if bad is not null then
1119
+ if bad like '!%' then
1120
+ perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на строку производного класса', substr(bad, 2)));
1121
+ elsif bad is not null then
715
1122
  perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
716
1123
  end if;
717
1124
  insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
@@ -719,11 +1126,21 @@ begin
719
1126
  from added n
720
1127
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
721
1128
  where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
722
- select array_agg(distinct a.class) into cls from added a;
723
- perform @SCHEMA@.signal(cls);
724
- if cls && array['rule', 'Resource', 'member', 'Account', 'Class'] then
1129
+ -- сигнал — по паре «класс, арендатор»
1130
+ select array_agg(k.class), array_agg(k.tenant) into sc, st from (select distinct a.class, a.tenant from added a) k;
1131
+ perform @SCHEMA@.signal(sc, st);
1132
+ if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
725
1133
  perform @SCHEMA@.acl_bump();
726
1134
  end if;
1135
+ if 'Class' = any (sc) then
1136
+ -- новый класс — источник агрегата (ревью коммита этапа 4б): нужно право READ без условий на все его строки; план
1137
+ -- прав после acl_bump видит новый класс
1138
+ perform @SCHEMA@.agg_source_admit(n.data->>'name', @SCHEMA@.agg_new_mark(null, n.data->'resolved'))
1139
+ from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
1140
+ -- новая цель агрегатов (план 1.1, §2.4): метки её источников (aggregatedBy) — заново
1141
+ perform @SCHEMA@.agg_mark(n.tenant, @SCHEMA@.agg_of(n.data->'resolved'))
1142
+ from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregate';
1143
+ end if;
727
1144
  return null;
728
1145
  end $$;
729
1146
 
@@ -735,7 +1152,8 @@ language plpgsql security definer
735
1152
  set search_path = pg_catalog, @SCHEMA@, pg_temp
736
1153
  as $$
737
1154
  declare
738
- cls text[];
1155
+ sc text[];
1156
+ st uuid[];
739
1157
  o_row @SCHEMA@.entity;
740
1158
  n_row @SCHEMA@.entity;
741
1159
  cid uuid;
@@ -748,24 +1166,34 @@ begin
748
1166
  from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
749
1167
  where n.links <> '{}'::jsonb and (n.links <> o.links or n.class <> o.class)), true, @SCHEMA@.acl_actor('READ'));
750
1168
  end if;
751
- if bad is not null then
1169
+ if bad like '!%' then
1170
+ perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на строку производного класса', substr(bad, 2)));
1171
+ elsif bad is not null then
752
1172
  perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
753
1173
  end if;
754
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
755
- 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
1174
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
1175
+ 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,
1176
+ @SCHEMA@.change_paths(o.data, o.links, o.tags, o.owner, o.class, n.data, n.links, n.tags, n.owner, n.class)
756
1177
  from new_rows n
757
1178
  join old_rows o on o.id = n.id and o.rev <> n.rev
758
1179
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
759
1180
  where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
760
- select array_agg(distinct n.class) into cls from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev;
761
- perform @SCHEMA@.signal(cls);
762
- if cls && array['rule', 'Resource', 'member', 'Account', 'Class'] then
1181
+ -- сигнал — по парам нового и прежнего класса: при смене класса без ключа ответы прежнего класса в
1182
+ -- кэше других процессов устарели бы (в 1.0.1 сигнал шёл только по новому)
1183
+ select array_agg(k.class), array_agg(k.tenant) into sc, st
1184
+ from (select distinct v.class, v.tenant from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
1185
+ cross join lateral (values (n.class, n.tenant), (o.class, o.tenant)) v(class, tenant)) k;
1186
+ perform @SCHEMA@.signal(sc, st);
1187
+ if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
763
1188
  perform @SCHEMA@.acl_bump();
764
1189
  end if;
765
1190
  for cid in select n.id from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
766
1191
  where n.class = 'Class' order by n.id loop
767
1192
  select * into o_row from old_rows x where x.id = cid;
768
1193
  select * into n_row from new_rows x where x.id = cid;
1194
+ -- класс становится источником агрегата (смена parent; ревью коммита этапа 4б): право READ без условий на его строки
1195
+ -- и после правки — по новым предкам (до правки проверил class_admit)
1196
+ perform @SCHEMA@.agg_source_admit(n_row.data->>'name', @SCHEMA@.agg_new_mark(o_row.data->'resolved', n_row.data->'resolved'));
769
1197
  perform @SCHEMA@.class_evolve(o_row, n_row);
770
1198
  end loop;
771
1199
  -- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
@@ -787,7 +1215,15 @@ declare
787
1215
  classes text[] := array[n.data->>'name'];
788
1216
  conflicts jsonb;
789
1217
  c record;
1218
+ -- агрегаты (план 1.1, §2.4): источники, чьи метки пересчитываются; цели, поле которых считается заново
1219
+ agg_names text[] := '{}';
1220
+ agg_own text[] := '{}';
1221
+ agg_other text[] := @SCHEMA@.agg_marked(o.data->'resolved', n.data->'resolved');
790
1222
  begin
1223
+ if o.data->'resolved'->'aggregate' is distinct from n.data->'resolved'->'aggregate' then
1224
+ agg_names := @SCHEMA@.agg_of(o.data->'resolved') || @SCHEMA@.agg_of(n.data->'resolved');
1225
+ agg_own := agg_own || (n.data->>'name');
1226
+ end if;
791
1227
  -- потомки: описание resolved и validator пересчитываются, каждый пересчёт — версия класса в журнале
792
1228
  for d in select x.* from @SCHEMA@.entity x
793
1229
  where x.class = 'Class' and x.id <> n.id
@@ -798,9 +1234,37 @@ begin
798
1234
  if prep.data is distinct from d.data or prep.validator::text is distinct from d.validator::text then
799
1235
  perform @SCHEMA@.sys_update(d.id, prep.data, d.links, prep.validator, 'update');
800
1236
  end if;
1237
+ -- потомок становится источником вместе с классом (смена parent; ревью коммита этапа 4б) — то же право READ на его
1238
+ -- строки по новым предкам (до правки проверил class_admit). class_evolve зовёт только триггер правки приложения;
1239
+ -- потомков класса System у арендаторов план прав сессии System не видит, их строки считают агрегаты System для
1240
+ -- целей того же арендатора
1241
+ if d.tenant = n.tenant then
1242
+ perform @SCHEMA@.agg_source_admit(d.data->>'name', @SCHEMA@.agg_new_mark(d.data->'resolved', prep.data->'resolved'));
1243
+ end if;
801
1244
  classes := classes || (d.data->>'name');
1245
+ if d.data->'resolved'->'aggregate' is distinct from prep.data->'resolved'->'aggregate' then
1246
+ agg_names := agg_names || @SCHEMA@.agg_of(d.data->'resolved') || @SCHEMA@.agg_of(prep.data->'resolved');
1247
+ agg_own := agg_own || (d.data->>'name');
1248
+ end if;
1249
+ agg_other := agg_other || @SCHEMA@.agg_marked(d.data->'resolved', prep.data->'resolved');
802
1250
  end loop;
1251
+ -- агрегаты семейства изменились: метки источников заново и поле у всех целей — из живых источников (строки
1252
+ -- проверит ужесточение ниже); у источника сменились предки — заново цели, которые его считают
1253
+ if cardinality(agg_names) > 0 then
1254
+ perform @SCHEMA@.agg_mark(n.tenant, agg_names);
1255
+ end if;
1256
+ if cardinality(agg_own) > 0 then
1257
+ perform @SCHEMA@.agg_refresh(agg_own, n.tenant, false);
1258
+ end if;
1259
+ if cardinality(agg_other) > 0 then
1260
+ perform @SCHEMA@.agg_refresh(agg_other, n.tenant, true);
1261
+ end if;
803
1262
  if not @SCHEMA@.class_change_safe(o.data->'resolved', n.data->'resolved') then
1263
+ -- ужесточение проверяет все строки семейства, в том числе невидимые актору, и отвечает их числом: пробное
1264
+ -- ужесточение (maximum, enum, pattern…) было оракулом по чужим строкам (доработки после точки Б). Нужно право READ
1265
+ -- без условий на все строки класса и потомков своего арендатора; строки других арендаторов (класс System) —
1266
+ -- уровень установки, их число остаётся в others
1267
+ perform @SCHEMA@.class_rows_admit(n.data->>'name', n.tenant, array['READ'], format('ужесточение класса %s', n.data->>'name'));
804
1268
  conflicts := @SCHEMA@.family_conflicts(classes, n.tenant, o.data->'resolved'->'ancestors' is distinct from n.data->'resolved'->'ancestors');
805
1269
  if conflicts <> '[]'::jsonb then
806
1270
  select * into c from @SCHEMA@.ctx();
@@ -883,6 +1347,8 @@ declare
883
1347
  unsets uuid[];
884
1348
  classes text[];
885
1349
  lvl_classes text[];
1350
+ pairs text[];
1351
+ lvl_pairs text[];
886
1352
  v_author uuid;
887
1353
  v_reason text := nullif(current_setting('letopis.reason', true), '');
888
1354
  newlinks jsonb;
@@ -904,25 +1370,45 @@ begin
904
1370
  if exists (select 1 from @SCHEMA@.entity x join gone g on g.id = x.id) then
905
1371
  perform @SCHEMA@.lt_fail('recreated_in_statement', 'id удалён и создан заново одним оператором');
906
1372
  end if;
907
- for r in select g.id, g.tenant, g.data->>'name' as name from gone g where g.class = 'Class' loop
1373
+ -- строки производного класса удаляет только проектор (каскад функции владельца сюда не доходит)
1374
+ select d.class into denied from (select distinct g.class, g.tenant from gone g) d
1375
+ where (@SCHEMA@.class_row(d.class, d.tenant)).data->'resolved' ? 'derivedBy' limit 1;
1376
+ if denied is not null then
1377
+ perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки удаляет проектор', denied));
1378
+ end if;
1379
+ for r in select g.id, g.tenant, g.data->>'name' as name, g.data->'resolved' ? 'derivedBy' as derived from gone g where g.class = 'Class' loop
1380
+ -- производный класс удаляет только администратор (class_admit)
1381
+ if r.derived and not @SCHEMA@.letopis_allowed('letopis.projector') then
1382
+ perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)', r.name));
1383
+ end if;
908
1384
  if exists (select 1 from @SCHEMA@.entity x where x.class = r.name and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid))
909
1385
  or exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.data->>'parent' = r.name
910
1386
  and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid)) then
911
1387
  perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть строки или потомки', r.name));
912
1388
  end if;
1389
+ -- и история строк (остаток Н2; доработки после точки Б): удалённый класс с историей, созданный заново с другими
1390
+ -- предками или алиасом, подставил бы её под чужие правила — сначала purge() удалённых строк
1391
+ if @SCHEMA@.class_has_history(r.name, r.tenant) then
1392
+ perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть история строк в журнале: сначала стереть её purge() удалённых строк', r.name));
1393
+ end if;
913
1394
  end loop;
1395
+ -- удалена цель агрегатов (план 1.1, §2.4): метки её источников — уже без неё
1396
+ perform @SCHEMA@.agg_mark(g.tenant, @SCHEMA@.agg_of(g.data->'resolved'))
1397
+ from gone g where g.class = 'Class' and g.data->'resolved' ? 'aggregate';
914
1398
  v_author := coalesce(c.author, '@SYSTEM@'::uuid);
915
1399
  -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
916
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
1400
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
917
1401
  select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
918
- greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash
1402
+ greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash, '[]'::jsonb
919
1403
  from gone g
920
1404
  join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
921
1405
  where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant;
1406
+ -- пары «класс, арендатор» для сигнала — строкой «класс арендатор» (пробела нет ни в имени, ни в uuid)
922
1407
  select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
1408
+ coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}'),
923
1409
  coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
924
1410
  coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
925
- into lvl, classes, accs, mems from gone g;
1411
+ into lvl, classes, pairs, accs, mems from gone g;
926
1412
  all_ids := lvl;
927
1413
  while cardinality(lvl) > 0 loop
928
1414
  -- кто ссылается на уровень: действие по правилам концов (общее с превью)
@@ -944,24 +1430,26 @@ begin
944
1430
  where x.id in (select y.id from @SCHEMA@.entity y where y.id = any (casc) order by y.id for update)
945
1431
  returning x.*
946
1432
  ), t as (
947
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
1433
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
948
1434
  select d.id, d.rev + 1, d.class, d.cv, d.tenant, d.owner, d.links, d.data, d.tags,
949
- greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash
1435
+ greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash, '[]'::jsonb
950
1436
  from d
951
1437
  join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
952
1438
  where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
953
1439
  )
954
1440
  select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
1441
+ coalesce(array_agg(distinct d.class || ' ' || d.tenant::text), '{}'),
955
1442
  min(d.class) filter (where not @SCHEMA@.acl_row_ok(dplan, d.class, d.id, d.owner, d.tenant, d.tags, d.data, d.links)),
956
1443
  coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
957
1444
  coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
958
- into lvl, lvl_classes, denied, lvl_accs, lvl_mems from d;
1445
+ into lvl, lvl_classes, lvl_pairs, denied, lvl_accs, lvl_mems from d;
959
1446
  if denied is not null then
960
1447
  perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s в каскаде удаления', denied));
961
1448
  end if;
962
1449
  accs := accs || lvl_accs;
963
1450
  mems := mems || lvl_mems;
964
1451
  classes := classes || lvl_classes;
1452
+ pairs := pairs || lvl_pairs;
965
1453
  -- unset: снять конец, новая версия
966
1454
  for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
967
1455
  if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
@@ -990,7 +1478,8 @@ begin
990
1478
  perform @SCHEMA@.auth_kill(r.acc, null);
991
1479
  update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
992
1480
  end loop;
993
- perform @SCHEMA@.signal(array(select distinct x from unnest(classes) x));
1481
+ perform @SCHEMA@.signal(array_agg(split_part(x.p, ' ', 1)), array_agg(split_part(x.p, ' ', 2)::uuid))
1482
+ from (select distinct p from unnest(pairs) p) x;
994
1483
  if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
995
1484
  perform @SCHEMA@.acl_bump();
996
1485
  end if;
@@ -1102,8 +1591,15 @@ return (
1102
1591
  -- Перенос строки на новый id (смена класса с ключом, смена ключа): новая строка продолжает журнал
1103
1592
  -- нового id и ссылается на старый (moved), ссылки переводятся — если переведённый конец входит
1104
1593
  -- в ключ ссылающейся строки, её id тоже меняется, и перевод идёт дальше рекурсивно; старая строка
1105
- -- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая функция.
1106
- create or replace function @SCHEMA@.move_row(o @SCHEMA@.entity, p_new_id uuid, p_class text, p_data jsonb, p_links jsonb, p_op text)
1594
+ -- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая функция; p_plan — план WRITE
1595
+ -- актора (reclass, rekey, rekey_class): каждая строка, чьи ссылки перевод переписывает, на всех уровнях рекурсии должна
1596
+ -- им проходить (доработки после точки Б: прежде проверялся только первый уровень); null — путь владельца.
1597
+ -- Агрегаты (план 1.1, §2.4): перенос — несколько операторов (новая строка, перевод ссылок, удаление старой), и
1598
+ -- триггер агрегатов на это время только копит их вклады (agg_hold_begin), а в конце применяет одним итогом: сумма
1599
+ -- не проходит через промежуточные значения, новая цель считается целиком из живых источников.
1600
+ drop function if exists @SCHEMA@.move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text);
1601
+ create or replace function @SCHEMA@.move_row(o @SCHEMA@.entity, p_new_id uuid, p_class text, p_data jsonb, p_links jsonb, p_op text,
1602
+ p_plan jsonb default null)
1107
1603
  returns @SCHEMA@.entity
1108
1604
  language plpgsql volatile
1109
1605
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -1116,6 +1612,7 @@ declare
1116
1612
  r @SCHEMA@.entity;
1117
1613
  nl jsonb;
1118
1614
  rid uuid;
1615
+ held boolean := @SCHEMA@.agg_hold_begin('acc');
1119
1616
  begin
1120
1617
  select * into c from @SCHEMA@.ctx();
1121
1618
  select l.rev, l.hash, l.at into last from @SCHEMA@.log l where l.id = p_new_id order by l.rev desc limit 1 for update;
@@ -1127,10 +1624,18 @@ begin
1127
1624
  returning * into n;
1128
1625
  perform @SCHEMA@.log_version(n);
1129
1626
  for r in select x.* from @SCHEMA@.entity x where x.ends @> array[o.id] and x.id <> p_new_id order by x.id for update loop
1627
+ -- строки производного класса пишет только проектор: перевод их ссылок мимо него оставил бы проекцию
1628
+ -- несогласованной (ревью безопасности №3, №3)
1629
+ if (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved' ? 'derivedBy' then
1630
+ perform @SCHEMA@.lt_fail('derived_class', format('на строку ссылается строка производного класса %s: её пишет проектор', r.class));
1631
+ end if;
1632
+ if p_plan is not null and not @SCHEMA@.acl_row_ok(p_plan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
1633
+ perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на ссылающиеся строки');
1634
+ end if;
1130
1635
  nl := @SCHEMA@.links_replace(r.links, o.id, p_new_id);
1131
1636
  rid := coalesce(@SCHEMA@.key_id((@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved', r.class, r.tenant, r.data, nl), r.id);
1132
1637
  if rid <> r.id then
1133
- perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey');
1638
+ perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey', p_plan);
1134
1639
  else
1135
1640
  perform @SCHEMA@.sys_update(r.id, r.data, nl, r.validator, 'update');
1136
1641
  end if;
@@ -1138,6 +1643,9 @@ begin
1138
1643
  delete from @SCHEMA@.entity x where x.id = o.id returning x.* into r;
1139
1644
  -- надгробие всегда вида delete (по нему срез на момент отличает удалённый id), moved — новый id
1140
1645
  perform @SCHEMA@.tombstone(r, 'delete', p_new_id);
1646
+ if held then
1647
+ perform @SCHEMA@.agg_hold_end(true);
1648
+ end if;
1141
1649
  return n;
1142
1650
  end $$;
1143
1651
 
@@ -1161,6 +1669,7 @@ declare
1161
1669
  rd jsonb;
1162
1670
  wplan jsonb;
1163
1671
  denied text;
1672
+ n_denied int;
1164
1673
  begin
1165
1674
  select * into c from @SCHEMA@.ctx();
1166
1675
  if c.account is null then
@@ -1178,6 +1687,11 @@ begin
1178
1687
  if not @SCHEMA@.acl_row_ok(wplan, o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
1179
1688
  perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', o.class));
1180
1689
  end if;
1690
+ -- строка уходит из своего класса — как удаление из него: нужно право DELETE на прежнюю строку (доработки после
1691
+ -- точки Б; иначе с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там)
1692
+ if not @SCHEMA@.acl_row_ok(@SCHEMA@.acl_plan('DELETE'), o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
1693
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на строку класса %s: смена класса убирает её из класса', o.class));
1694
+ end if;
1181
1695
  -- строки прав и классов функция не трогает
1182
1696
  if o.class in ('Class', 'Account', 'member', 'Resource', 'rule') or p_class in ('Class', 'Account', 'member', 'Resource', 'rule') then
1183
1697
  perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
@@ -1192,7 +1706,19 @@ begin
1192
1706
  if coalesce((res->>'abstract')::boolean, false) then
1193
1707
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
1194
1708
  end if;
1195
- d := @SCHEMA@.apply_defaults(res->'schema', coalesce(p_data, o.data));
1709
+ if res ? 'derivedBy' or (@SCHEMA@.class_row(o.class, o.tenant)).data->'resolved' ? 'derivedBy' then
1710
+ perform @SCHEMA@.lt_fail('derived_class', 'строки производного класса пишет только его проектор');
1711
+ end if;
1712
+ d := coalesce(p_data, o.data);
1713
+ -- агрегатные поля нового класса считает база (план 1.1, §2.4): в данных их не передают; прежнее число остаётся
1714
+ -- до пересчёта, иначе 0
1715
+ if res ? 'aggregate' then
1716
+ if p_data ?| @SCHEMA@.agg_fields(res) then
1717
+ perform @SCHEMA@.lt_fail('aggregate_field', format('агрегатные поля класса %s считает база — в данных reclass их не передают', p_class));
1718
+ end if;
1719
+ d := @SCHEMA@.agg_zero(res, d);
1720
+ end if;
1721
+ d := @SCHEMA@.apply_defaults(res->'schema', d);
1196
1722
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
1197
1723
  if bad <> '[]'::jsonb then
1198
1724
  perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', p_class), jsonb_build_object('issues', bad));
@@ -1211,17 +1737,20 @@ begin
1211
1737
  perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s', p_class));
1212
1738
  end if;
1213
1739
  if nid <> o.id then
1214
- -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE
1215
- select min(x.class) into denied from @SCHEMA@.entity x
1740
+ -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE (глубже первого уровня — move_row);
1741
+ -- класс такой строки называется, только если актор её видит
1742
+ select min(x.class) filter (where @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links, c.tenant, c.account, rd->'plan')),
1743
+ count(*) into denied, n_denied from @SCHEMA@.entity x
1216
1744
  where x.ends @> array[o.id] and not @SCHEMA@.acl_row_ok(wplan, x.class, x.id, x.owner, x.tenant, x.tags, x.data, x.links);
1217
- if denied is not null then
1218
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на ссылающиеся строки класса %s', denied));
1745
+ if n_denied > 0 then
1746
+ perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права WRITE на ссылающиеся строки класса %s', denied)
1747
+ else 'нет права WRITE на ссылающиеся строки' end);
1219
1748
  end if;
1220
1749
  end if;
1221
1750
  if nid = o.id then
1222
1751
  perform @SCHEMA@.sys_update(o.id, d, lk.links, null, 'reclass', p_class);
1223
1752
  else
1224
- perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass');
1753
+ perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass', wplan);
1225
1754
  end if;
1226
1755
  return nid;
1227
1756
  end $$;
@@ -1353,13 +1882,13 @@ do $$
1353
1882
  declare f text;
1354
1883
  begin
1355
1884
  foreach f in array array[
1356
- 'now_ts()', 'family_lock(@SCHEMA@.entity, boolean)', 'class_set(jsonb)', 'class_accepts(jsonb, text, uuid)', 'role_accepts(text, uuid, text, text, uuid)',
1885
+ 'now_ts()', 'lock_id(text)', 'lock_row(text)', 'lock_try(text)', 'family_name(uuid, jsonb)', 'family_rows(uuid, jsonb)', 'family_lock(@SCHEMA@.entity, boolean)', 'class_set(jsonb)', 'class_accepts(jsonb, text, uuid)', 'role_accepts(text, uuid, text, text, uuid)',
1357
1886
  'apply_defaults(jsonb, jsonb)', 'links_shape(jsonb, jsonb)', 'targets_bad(jsonb, boolean, jsonb)', 'check_links(jsonb, jsonb, uuid, boolean, jsonb)', 'key_id(jsonb, text, uuid, jsonb, jsonb)',
1358
1887
  'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text, text)', 'tombstone(@SCHEMA@.entity, text, uuid)',
1359
- 'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text)', 'reclass(uuid, text, jsonb)',
1888
+ 'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text, jsonb)', 'reclass(uuid, text, jsonb)',
1360
1889
  'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'class_change_safe(jsonb, jsonb)',
1361
- 'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_admit(jsonb, uuid)',
1362
- 'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal(text[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
1890
+ '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_admit(jsonb, uuid)',
1891
+ '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)',
1363
1892
  '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
1364
1893
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
1365
1894
  execute format('revoke all on function @SCHEMA@.%s from public', f);