letopis 1.4.2 → 2.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 (112) hide show
  1. package/AGENT-CHEATSHEET.md +329 -281
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +572 -1053
  4. package/dist/acl.d.ts +78 -1
  5. package/dist/acl.js +264 -13
  6. package/dist/admin.d.ts +10 -141
  7. package/dist/admin.js +24 -184
  8. package/dist/auth.d.ts +2 -2
  9. package/dist/auth.js +5 -15
  10. package/dist/cache.d.ts +8 -1
  11. package/dist/cache.js +30 -8
  12. package/dist/chain.d.ts +18 -41
  13. package/dist/chain.js +165 -203
  14. package/dist/cli.js +45 -90
  15. package/dist/demo/booking.d.ts +352 -36
  16. package/dist/describe.d.ts +47 -0
  17. package/dist/describe.js +195 -0
  18. package/dist/errors.d.ts +59 -13
  19. package/dist/errors.js +190 -29
  20. package/dist/index.d.ts +52 -70
  21. package/dist/index.js +189 -122
  22. package/dist/internal.d.ts +5 -0
  23. package/dist/jsonschema.js +18 -17
  24. package/dist/load.d.ts +20 -12
  25. package/dist/load.js +207 -181
  26. package/dist/mcp.d.ts +5 -7
  27. package/dist/mcp.js +168 -214
  28. package/dist/messages/access.d.ts +90 -0
  29. package/dist/messages/access.js +113 -0
  30. package/dist/messages/api.d.ts +171 -0
  31. package/dist/messages/api.js +215 -0
  32. package/dist/messages/boot.d.ts +201 -0
  33. package/dist/messages/boot.js +256 -0
  34. package/dist/messages/codes.d.ts +172 -0
  35. package/dist/messages/codes.js +173 -0
  36. package/dist/messages/index.d.ts +95 -0
  37. package/dist/messages/index.js +118 -0
  38. package/dist/messages/mcp.d.ts +306 -0
  39. package/dist/messages/mcp.js +387 -0
  40. package/dist/messages/model.d.ts +233 -0
  41. package/dist/messages/model.js +283 -0
  42. package/dist/messages/query.d.ts +131 -0
  43. package/dist/messages/query.js +158 -0
  44. package/dist/messages/sql-access.d.ts +70 -0
  45. package/dist/messages/sql-access.js +87 -0
  46. package/dist/messages/sql-core.d.ts +150 -0
  47. package/dist/messages/sql-core.js +181 -0
  48. package/dist/messages/sql-time.d.ts +71 -0
  49. package/dist/messages/sql-time.js +88 -0
  50. package/dist/messages/sql-write.d.ts +246 -0
  51. package/dist/messages/sql-write.js +270 -0
  52. package/dist/messages/time.d.ts +23 -0
  53. package/dist/messages/time.js +29 -0
  54. package/dist/messages/types.d.ts +27 -0
  55. package/dist/messages/types.js +5 -0
  56. package/dist/messages/write.d.ts +405 -0
  57. package/dist/messages/write.js +509 -0
  58. package/dist/model.d.ts +73 -60
  59. package/dist/model.js +20 -29
  60. package/dist/ops.d.ts +35 -0
  61. package/dist/ops.js +101 -0
  62. package/dist/pglite.d.ts +0 -9
  63. package/dist/pglite.js +3 -1
  64. package/dist/registry.d.ts +3 -19
  65. package/dist/registry.js +3 -16
  66. package/dist/signals.d.ts +1 -5
  67. package/dist/signals.js +1 -5
  68. package/dist/sql.d.ts +7 -1
  69. package/dist/sql.js +101 -85
  70. package/dist/sync.d.ts +9 -3
  71. package/dist/sync.js +40 -17
  72. package/dist/tx.d.ts +11 -1
  73. package/dist/tx.js +32 -11
  74. package/dist/typed.d.ts +240 -45
  75. package/dist/types.d.ts +15 -90
  76. package/dist/types.js +12 -14
  77. package/dist/up.d.ts +53 -32
  78. package/dist/up.js +180 -74
  79. package/dist/uuid.js +5 -5
  80. package/dist/validate.d.ts +13 -1
  81. package/dist/validate.js +61 -29
  82. package/dist/watch.d.ts +5 -16
  83. package/dist/watch.js +23 -77
  84. package/dist/write.d.ts +9 -16
  85. package/dist/write.js +102 -185
  86. package/llms.txt +22 -25
  87. package/package.json +2 -9
  88. package/sql/10-core.sql +7 -50
  89. package/sql/15-errors.sql +64 -10
  90. package/sql/20-context.sql +7 -6
  91. package/sql/30-validate.sql +35 -27
  92. package/sql/40-class.sql +20 -114
  93. package/sql/50-acl.sql +8 -13
  94. package/sql/60-write.sql +220 -676
  95. package/sql/70-read.sql +11 -13
  96. package/sql/80-auth.sql +23 -27
  97. package/sql/90-time.sql +77 -944
  98. package/sql/95-seed.system.sql +7 -14
  99. package/sql/99-revision.sql +2 -1
  100. package/sql/README.md +32 -24
  101. package/sql/migrate/README.md +14 -0
  102. package/AGENT-CHEATSHEET.en.md +0 -488
  103. package/MIGRATION.md +0 -190
  104. package/README.en.md +0 -2910
  105. package/dist/import.d.ts +0 -185
  106. package/dist/import.js +0 -820
  107. package/dist/project.d.ts +0 -359
  108. package/dist/project.js +0 -1258
  109. package/sql/75-history.sql +0 -436
  110. package/sql/88-derived.sql +0 -1236
  111. package/sql/migrate/0006-changed-paths.sql +0 -3
  112. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/sql/60-write.sql CHANGED
@@ -10,9 +10,9 @@ create or replace function @SCHEMA@.now_ts() returns timestamptz
10
10
  language sql volatile
11
11
  return clock_timestamp();
12
12
 
13
- -- Внутренние блокировки (семейства, проектор, обслуживание) — строки-замки: строки вида LOCK таблицы secret, которой у
13
+ -- Внутренние блокировки (семейства, обслуживание) — строки-замки: строки вида LOCK таблицы secret, которой у
14
14
  -- приложения нет, а не advisory-блокировки. Advisory-блокировку берёт любая роль базы, а её ключ виден всем в pg_locks:
15
- -- посторонняя роль занимала бы её и останавливала запись семейства, проектор или обслуживание для всех (ревью
15
+ -- посторонняя роль занимала бы её и останавливала запись семейства или обслуживание для всех (ревью
16
16
  -- безопасности №3, №2). id строки-замка — lock_id имени; строки семейств заводятся заранее (class_admit, seed_system),
17
17
  -- иначе строку вставляет первое обращение — и параллельное ждёт его транзакцию.
18
18
  -- lock_id — первые 16 байт sha256 имени: его считает каждая строка записи, а v5_id собирает jsonb и зовёт pgcrypto.
@@ -34,18 +34,14 @@ begin
34
34
  insert into @SCHEMA@.secret (id, account, kind, meta) values (v, '@SYSTEM@'::uuid, 'LOCK', jsonb_build_object('name', p_name))
35
35
  on conflict do nothing;
36
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);
37
+ -- внутренняя ошибка (SQLSTATE XX000) по ключу каталога: библиотека отдаёт её кодом internal (fromDbError)
38
+ perform @SCHEMA@.lt_raise('XX000', 'sql-write.lock_row_not_created', jsonb_build_object('name', p_name), 'null'::jsonb);
38
39
  end if;
39
40
  end if;
40
41
  return v;
41
42
  end $$;
42
43
 
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 — её
44
+ -- Строка-замок без ожидания (обслуживание — один исполнитель): true — взята до конца транзакции, false — её
49
45
  -- держит другая транзакция. Блокировка — отдельным оператором: строку, которую вставила lock_row, видит уже он.
50
46
  create or replace function @SCHEMA@.lock_try(p_name text) returns boolean
51
47
  language plpgsql volatile
@@ -59,8 +55,8 @@ begin
59
55
  end $$;
60
56
 
61
57
  -- Блокировки семейства (этап 1, п. 10): писатели и загрузчик берут разделяемую блокировку корня семейства, ужесточение
62
- -- описания и rekeyClass — исключительную. Имя — арендатор корневого класса и имя корня (итоговое ревью безопасности):
63
- -- ужесточение или rekeyClass одноимённого класса в другом арендаторе запись сюда не останавливает; корень из System
58
+ -- описания и удаление класса — исключительную. Имя — арендатор корневого класса и имя корня (итоговое ревью
59
+ -- безопасности): ужесточение одноимённого класса в другом арендаторе запись сюда не останавливает; корень из System
64
60
  -- (общий для всех арендаторов) — арендатор System. p_data — описание класса с resolved.
65
61
  create or replace function @SCHEMA@.family_name(p_tenant uuid, p_data jsonb) returns text
66
62
  language sql stable
@@ -104,7 +100,7 @@ begin
104
100
  perform @SCHEMA@.lock_row(n);
105
101
  perform 1 from @SCHEMA@.secret s where s.id = k for update;
106
102
  if not found then
107
- raise exception using errcode = 'XX000', message = format('строка-замок %s не найдена', n);
103
+ perform @SCHEMA@.lt_raise('XX000', 'sql-write.lock_row_not_found', jsonb_build_object('name', n), 'null'::jsonb);
108
104
  end if;
109
105
  else
110
106
  if strpos(held, '|' || n || '|') = 0 then
@@ -121,7 +117,7 @@ begin
121
117
  perform @SCHEMA@.lock_row(n);
122
118
  perform 1 from @SCHEMA@.secret s where s.id = k for key share;
123
119
  if not found then
124
- raise exception using errcode = 'XX000', message = format('строка-замок %s не найдена', n);
120
+ perform @SCHEMA@.lt_raise('XX000', 'sql-write.lock_row_not_found', jsonb_build_object('name', n), 'null'::jsonb);
125
121
  end if;
126
122
  end if;
127
123
  end if;
@@ -129,8 +125,6 @@ begin
129
125
  perform set_config('letopis.family_locks', held || '|' || n || '|', true);
130
126
  end if;
131
127
  end $$;
132
- -- до итогового ревью безопасности ключ был без арендатора
133
- drop function if exists @SCHEMA@.family_lock(text, boolean);
134
128
 
135
129
  -- Список классов конца: строка или массив строк → отсортированный jsonb-массив.
136
130
  create or replace function @SCHEMA@.class_set(p jsonb) returns jsonb
@@ -195,8 +189,9 @@ end $$;
195
189
  -- Разбор и нормализация концов (этап 1, п. 7): объявлены, обязательные на месте, у множественных
196
190
  -- нет повторов, соблюдены min и max; uuid — в каноническом виде строчными. К таблицам не обращается:
197
191
  -- цели проверяет targets_bad() — триггером после оператора, одним запросом на все строки.
198
- -- Ошибку не бросает: возвращает код и сообщение.
199
- create or replace function @SCHEMA@.links_shape(p_res jsonb, p_links jsonb, out links jsonb, out code text, out msg text)
192
+ -- Ошибку не бросает: возвращает ключ каталога сообщений и параметры отказа (план 2.0, этап 3, п. 3; вызывающий бросает
193
+ -- ошибку по ним).
194
+ create or replace function @SCHEMA@.links_shape(p_res jsonb, p_links jsonb, out links jsonb, out key text, out params jsonb)
200
195
  language plpgsql immutable
201
196
  as $$
202
197
  #variable_conflict use_variable
@@ -212,47 +207,48 @@ begin
212
207
  p_links := '{}'::jsonb;
213
208
  end if;
214
209
  if jsonb_typeof(p_links) <> 'object' then
215
- code := 'invalid_data'; msg := 'links — не объект'; return;
210
+ key := 'sql-write.links_not_object'; params := '{}'::jsonb; return;
216
211
  end if;
217
- for l in select key, value from jsonb_each(p_links) loop
212
+ -- столбцы jsonb_each — с псевдонимом: key — ещё и выходной параметр (variable_conflict use_variable)
213
+ for l in select j.key, j.value from jsonb_each(p_links) j loop
218
214
  if not coalesce(p_res->'ends' ? l.key, false) then
219
- code := 'undeclared_end'; msg := format('конец %s не объявлен в классе %s', l.key, p_res->>'name'); return;
215
+ key := 'sql-write.end_undeclared'; params := jsonb_build_object('end', l.key, 'cls', p_res->>'name'); return;
220
216
  end if;
221
217
  continue when l.value = 'null'::jsonb;
222
218
  if coalesce((p_res->'ends'->l.key->>'many')::boolean, false) then
223
219
  if jsonb_typeof(l.value) <> 'array' then
224
- code := 'invalid_data'; msg := format('конец %s — список id', l.key); return;
220
+ key := 'sql-write.end_not_list'; params := jsonb_build_object('end', l.key); return;
225
221
  end if;
226
222
  arr := '[]'::jsonb;
227
223
  for v in select value from jsonb_array_elements(l.value) loop
228
224
  if jsonb_typeof(v) <> 'string' or not pg_input_is_valid(v #>> '{}', 'uuid') then
229
- code := 'invalid_data'; msg := format('конец %s: значение не uuid', l.key); return;
225
+ key := 'sql-write.end_not_uuid'; params := jsonb_build_object('end', l.key); return;
230
226
  end if;
231
227
  v := to_jsonb((v #>> '{}')::uuid::text);
232
228
  if arr @> jsonb_build_array(v) then
233
- code := 'invalid_data'; msg := format('конец %s: цель повторяется', l.key); return;
229
+ key := 'sql-write.end_target_repeated'; params := jsonb_build_object('end', l.key); return;
234
230
  end if;
235
231
  arr := arr || jsonb_build_array(v);
236
232
  end loop;
237
233
  links := links || jsonb_build_object(l.key, arr);
238
234
  else
239
235
  if jsonb_typeof(l.value) <> 'string' or not pg_input_is_valid(l.value #>> '{}', 'uuid') then
240
- code := 'invalid_data'; msg := format('конец %s: значение не uuid', l.key); return;
236
+ key := 'sql-write.end_not_uuid'; params := jsonb_build_object('end', l.key); return;
241
237
  end if;
242
238
  links := links || jsonb_build_object(l.key, (l.value #>> '{}')::uuid::text);
243
239
  end if;
244
240
  end loop;
245
- for e in select key, value from jsonb_each(coalesce(p_res->'ends', '{}'::jsonb)) loop
241
+ for e in select j.key, j.value from jsonb_each(coalesce(p_res->'ends', '{}'::jsonb)) j loop
246
242
  if coalesce((e.value->>'many')::boolean, false) then
247
243
  n := coalesce(jsonb_array_length(links->e.key), 0);
248
244
  if n < greatest(coalesce((e.value->>'min')::int, 0), case when coalesce((e.value->>'required')::boolean, false) then 1 else 0 end) then
249
- code := 'invalid_data'; msg := format('у конца %s слишком мало целей', e.key); return;
245
+ key := 'sql-write.end_too_few'; params := jsonb_build_object('end', e.key); return;
250
246
  end if;
251
247
  if e.value ? 'max' and n > (e.value->>'max')::int then
252
- code := 'invalid_data'; msg := format('у конца %s слишком много целей', e.key); return;
248
+ key := 'sql-write.end_too_many'; params := jsonb_build_object('end', e.key); return;
253
249
  end if;
254
250
  elsif coalesce((e.value->>'required')::boolean, false) and not links ? e.key then
255
- code := 'invalid_data'; msg := format('обязательный конец %s не задан', e.key); return;
251
+ key := 'sql-write.end_required'; params := jsonb_build_object('end', e.key); return;
256
252
  end if;
257
253
  end loop;
258
254
  end $$;
@@ -262,13 +258,16 @@ end $$;
262
258
  -- (ends — описание концов, если его ещё нет в строке Class). При p_lock цели сначала блокируются
263
259
  -- for key share в порядке id — каждая один раз на оператор, только свои и системные: строку чужого
264
260
  -- арендатора не заблокировать и по ожиданию не обнаружить. Проверка идёт после блокировки, по
265
- -- свежим строкам: цель, которую успели удалить или перевести в чужой класс, не пройдёт. Класс цели
261
+ -- свежим строкам: цель, которую успели удалить, не пройдёт. Класс цели
266
262
  -- прямо в списке конца — без поиска описания; иначе — предки из строки Class цели.
267
263
  -- При p_acl ({tenant, account, plan} — план READ актора) цель ещё и должна быть видна актору (этап 5,
268
264
  -- п. 6): строку, которую он не видит, нельзя использовать как цель — ответ тот же, что для
269
265
  -- несуществующей. Цели-аккаунты (Account) не проверяются: на них ссылаются членства и владельцы.
270
- -- Возвращает роль первой непрошедшей цели или null; цель — строка производного класса: «!роль»
271
- -- (вызывающий отвечает derived_class).
266
+ -- Роль сеанса, к которой RLS не применяется (суперпользователь, BYPASSRLS, член владельца),
267
+ -- видит любую строку, а пишет и строки, которых план актора не показывает: прятать от неё цель незачем, и p_acl
268
+ -- она не проверяет. До этого администраторское подключение с сессией без прав заводило ресурсы, а правило на них —
269
+ -- нет: target_not_found (внедрение, агентский прогон плана 2.0).
270
+ -- Возвращает роль первой непрошедшей цели или null.
272
271
  create or replace function @SCHEMA@.targets_bad(p_rows jsonb, p_lock boolean, p_acl jsonb default null) returns text
273
272
  language plpgsql volatile
274
273
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -279,6 +278,11 @@ begin
279
278
  if p_rows is null or jsonb_array_length(p_rows) = 0 then
280
279
  return null;
281
280
  end if;
281
+ if p_acl is not null
282
+ and (exists (select 1 from pg_roles r where r.rolname = @SCHEMA@.session_role() and (r.rolsuper or r.rolbypassrls))
283
+ or pg_has_role(@SCHEMA@.session_role(), '@OWNER_NAME@', 'USAGE')) then
284
+ p_acl := null;
285
+ end if;
282
286
  if p_lock then
283
287
  perform 1 from @SCHEMA@.entity t
284
288
  where t.id in (select (v #>> '{}')::uuid from jsonb_array_elements(p_rows) r
@@ -299,7 +303,7 @@ begin
299
303
  left join lateral jsonb_array_elements(case jsonb_typeof(k.value) when 'array' then k.value else '[]'::jsonb end) a on true
300
304
  where jsonb_typeof(k.value) = 'string' or a.value is not null
301
305
  )
302
- select case when x.miss then x.role else '!' || x.role end into bad
306
+ select x.role into bad
303
307
  from (
304
308
  select p.role,
305
309
  t.id is null
@@ -308,14 +312,11 @@ begin
308
312
  or @SCHEMA@.class_set(c.ends->p.role->'class') ?| array(select jsonb_array_elements_text(kr.res->'ancestors')), false)
309
313
  or (p_acl is not null and t.class <> 'Account'
310
314
  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
315
+ (p_acl->>'tenant')::uuid, (p_acl->>'account')::uuid, p_acl->'plan')) as miss
313
316
  from p
314
317
  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
318
  left join @SCHEMA@.entity t on t.id = p.tid
316
- -- описание класса цели — только когда класс не назван в конце прямо: тогда нужны его предки, а цель
317
- -- производного класса (конец, названный предком; план 1.1, §2.4) не проходит — прямо назвать производный
318
- -- класс в конце не даёт описание (class_admit)
319
+ -- описание класса цели — только когда класс не назван в конце прямо: тогда нужны его предки
319
320
  left join lateral (
320
321
  select case when t.id is null or @SCHEMA@.class_set(c.ends->p.role->'class') ? t.class then null
321
322
  else (select k.data->'resolved' from @SCHEMA@.entity k
@@ -323,18 +324,17 @@ begin
323
324
  order by k.tenant = '@SYSTEM@'::uuid limit 1) end as res
324
325
  ) kr on true
325
326
  ) x
326
- where x.miss or x.derived
327
+ where x.miss
327
328
  limit 1;
328
329
  return bad;
329
330
  end $$;
330
- -- до этапа 5 цели не сверялись с правами актора
331
- drop function if exists @SCHEMA@.targets_bad(jsonb, boolean);
332
331
 
333
- -- Разбор концов и проверка целей одной строки — для функций владельца (reclass, проверка строк
332
+ -- Разбор концов и проверка целей одной строки — для функций владельца (restore, проверка строк
334
333
  -- семейства), которые пишут мимо триггеров. При p_lock цели блокируются for key share; p_acl — как
335
- -- у targets_bad (цели видны актору).
334
+ -- у targets_bad (цели видны актору). Отказ — ключ каталога и параметры, как у links_shape (вызывающий бросает
335
+ -- ошибку по ним).
336
336
  create or replace function @SCHEMA@.check_links(p_res jsonb, p_links jsonb, p_tenant uuid, p_lock boolean, p_acl jsonb,
337
- out links jsonb, out code text, out msg text)
337
+ out links jsonb, out key text, out params jsonb)
338
338
  language plpgsql volatile
339
339
  set search_path = pg_catalog, @SCHEMA@, pg_temp
340
340
  as $$
@@ -342,19 +342,16 @@ as $$
342
342
  declare
343
343
  bad text;
344
344
  begin
345
- select s.links, s.code, s.msg into links, code, msg from @SCHEMA@.links_shape(p_res, p_links) s;
346
- if code is not null or links = '{}'::jsonb then
345
+ select s.links, s.key, s.params into links, key, params from @SCHEMA@.links_shape(p_res, p_links) s;
346
+ if key is not null or links = '{}'::jsonb then
347
347
  return;
348
348
  end if;
349
349
  bad := @SCHEMA@.targets_bad(jsonb_build_array(jsonb_build_object(
350
350
  'class', p_res->>'name', 'tenant', p_tenant, 'links', links, 'ends', coalesce(p_res->'ends', '{}'::jsonb))), p_lock, p_acl);
351
- if bad like '!%' then
352
- code := 'derived_class'; msg := format('конец %s ведёт на строку производного класса', substr(bad, 2));
353
- elsif bad is not null then
354
- code := 'target_not_found'; msg := format('цель конца %s не найдена', bad);
351
+ if bad is not null then
352
+ key := 'sql-write.end_target_missing'; params := jsonb_build_object('end', bad);
355
353
  end if;
356
354
  end $$;
357
- drop function if exists @SCHEMA@.check_links(jsonb, jsonb, uuid, boolean);
358
355
 
359
356
  -- id класса с ключом — v5 от арендатора, класса и значений ключа (§2.3); у класса без ключа — null.
360
357
  create or replace function @SCHEMA@.key_id(p_res jsonb, p_class text, p_tenant uuid, p_data jsonb, p_links jsonb) returns uuid
@@ -377,8 +374,6 @@ return coalesce((@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved'->>'hist
377
374
  -- 96 бит HMAC-SHA256 от «назначение:значение» на ключе сигналов (HMAC ключа строки KEY по
378
375
  -- 'letopis:signals', его отдаёт signal_key()): без ключа псевдоним не сопоставить с именем, а
379
376
  -- нагрузка детерминирована — PostgreSQL склеивает одинаковые сигналы транзакции.
380
- -- до плана 1.1 сигнал принимал только имена классов
381
- drop function if exists @SCHEMA@.signal(text[]);
382
377
 
383
378
  create or replace function @SCHEMA@.signal_token(p_purpose text, p_value text, p_key bytea) returns text
384
379
  language sql immutable parallel safe
@@ -440,31 +435,23 @@ begin
440
435
  end $$;
441
436
 
442
437
  -- Версия одной строки в журнал и сигнал — для функций владельца, которые пишут строку сами
443
- -- (пересчёт потомков, снятие конца, перенос на новый id). Классы с history: false журнал не ведут,
438
+ -- (пересчёт потомков, снятие конца, восстановление, надгробие). Классы с history: false журнал не ведут,
444
439
  -- но сигнал посылают. Обычную запись журналируют триггеры после оператора — одной вставкой.
445
440
  create or replace function @SCHEMA@.log_version(e @SCHEMA@.entity) returns void
446
441
  language plpgsql volatile
447
442
  set search_path = pg_catalog, @SCHEMA@, pg_temp
448
443
  as $$
449
- declare
450
- p @SCHEMA@.log;
451
444
  begin
452
445
  if @SCHEMA@.history_on(e.class, e.tenant) then
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);
446
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason)
447
+ 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);
460
448
  end if;
461
449
  perform @SCHEMA@.signal(array[e.class], array[e.tenant]);
462
450
  end $$;
463
451
 
464
452
  -- Новая версия строки от имени владельца (пересчёт описаний потомков, снятие конца при удалении):
465
453
  -- служебные колонки, журнал, сигнал. Триггеры при этом не срабатывают.
466
- create or replace function @SCHEMA@.sys_update(p_id uuid, p_data jsonb, p_links jsonb, p_validator jsonpath, p_op text,
467
- p_class text default null)
454
+ create or replace function @SCHEMA@.sys_update(p_id uuid, p_data jsonb, p_links jsonb, p_validator jsonpath, p_op text)
468
455
  returns @SCHEMA@.entity
469
456
  language plpgsql volatile
470
457
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -472,38 +459,23 @@ as $$
472
459
  declare
473
460
  e @SCHEMA@.entity;
474
461
  c record;
475
- was text;
476
462
  begin
477
463
  select * into c from @SCHEMA@.ctx();
478
- if p_class is not null then
479
- select x.class into was from @SCHEMA@.entity x where x.id = p_id;
480
- end if;
481
464
  update @SCHEMA@.entity x
482
- set class = coalesce(p_class, x.class), data = p_data, links = p_links, validator = p_validator,
483
- rev = x.rev + 1, prev_hash = x.hash,
465
+ set data = p_data, links = p_links, validator = p_validator,
466
+ rev = x.rev + 1,
484
467
  at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
485
468
  author = coalesce(c.author, '@SYSTEM@'::uuid), agent = c.agent, op = p_op,
486
- reason = nullif(current_setting('letopis.reason', true), ''), moved = null,
487
- cv = coalesce((@SCHEMA@.class_row(coalesce(p_class, x.class), x.tenant)).rev, x.cv)
469
+ reason = nullif(current_setting('letopis.reason', true), ''),
470
+ cv = coalesce((@SCHEMA@.class_row(x.class, x.tenant)).rev, x.cv)
488
471
  where x.id = p_id
489
472
  returning x.* into e;
490
- -- смена класса: агрегатные поля новой цели триггер после правки посчитал в эту же версию (план 1.1, §2.4) —
491
- -- в журнал идёт строка таблицы, а не returning
492
- if p_class is not null then
493
- select x.* into e from @SCHEMA@.entity x where x.id = p_id;
494
- end if;
495
473
  perform @SCHEMA@.log_version(e);
496
- -- смена класса: сигнал и прежнего класса — его ответы кэша и подписки тоже устарели (log_version сигналит только новый;
497
- -- путь через триггер шлёт оба; план 2.0, 7.4 №4)
498
- if was is not null and was <> e.class then
499
- perform @SCHEMA@.signal(array[was], array[e.tenant]);
500
- end if;
501
474
  return e;
502
475
  end $$;
503
476
 
504
- -- Надгробие: последний снимок удалённой строки следующей версией с видом delete; при переносе на
505
- -- новый id (reclass, rekey) moved ссылается на новый id.
506
- create or replace function @SCHEMA@.tombstone(e @SCHEMA@.entity, p_op text default 'delete', p_moved uuid default null) returns void
477
+ -- Надгробие: последний снимок удалённой строки следующей версией с видом delete.
478
+ create or replace function @SCHEMA@.tombstone(e @SCHEMA@.entity) returns void
507
479
  language plpgsql volatile
508
480
  set search_path = pg_catalog, @SCHEMA@, pg_temp
509
481
  as $$
@@ -512,17 +484,15 @@ declare
512
484
  begin
513
485
  select * into c from @SCHEMA@.ctx();
514
486
  e.rev := e.rev + 1;
515
- e.prev_hash := e.hash;
516
487
  e.at := greatest(@SCHEMA@.now_ts(), e.at + interval '1 microsecond');
517
488
  e.author := coalesce(c.author, '@SYSTEM@'::uuid);
518
489
  e.agent := c.agent;
519
- e.op := p_op;
490
+ e.op := 'delete';
520
491
  e.reason := nullif(current_setting('letopis.reason', true), '');
521
- e.moved := p_moved;
522
492
  perform @SCHEMA@.log_version(e);
523
493
  end $$;
524
494
 
525
- -- Входящие ссылки на строку p_id, роли которых не принимают класс p_class (смена класса, смена родителя).
495
+ -- Входящие ссылки на строку p_id, роли которых не принимают класс p_class (смена родителя класса).
526
496
  create or replace function @SCHEMA@.incoming_conflicts(p_id uuid, p_class text, p_tenant uuid) returns jsonb
527
497
  language sql stable
528
498
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -535,7 +505,7 @@ as $$
535
505
  and not @SCHEMA@.role_accepts(r.class, r.tenant, k.key, p_class, p_tenant)
536
506
  $$;
537
507
 
538
- -- Перечень видимых актору строк и число остальных — для ошибок restrict, смены класса, ужесточения.
508
+ -- Перечень видимых актору строк и число остальных — для ошибок restrict и ужесточения.
539
509
  -- Видимость — та же, что у чтения (row_visible с планом READ актора), а не только арендатор.
540
510
  create or replace function @SCHEMA@.visible_detail(p_rows jsonb, p_tenant uuid) returns jsonb
541
511
  language plpgsql stable
@@ -567,16 +537,13 @@ return (select min(r->>'class') from jsonb_array_elements(p_rows) r, (select @SC
567
537
  (x.a->>'tenant')::uuid, (x.a->>'account')::uuid, x.a->'plan'));
568
538
 
569
539
  -- Безопасно ли изменение описания класса (этап 1, п. 10): новые необязательные поля и концы,
570
- -- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
571
- -- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись),
572
- -- признака производного класса derivedBy и описания агрегатов aggregate (план 1.1, §2.6).
540
+ -- метаданные, алиас, history, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
541
+ -- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись).
573
542
  create or replace function @SCHEMA@.class_change_safe(o jsonb, n jsonb) returns boolean
574
543
  language sql immutable
575
544
  return
576
545
  o->'key' = n->'key'
577
546
  and o->'ancestors' = n->'ancestors'
578
- and o->'derivedBy' is not distinct from n->'derivedBy'
579
- and o->'aggregate' is not distinct from n->'aggregate'
580
547
  and (o->'abstract' = n->'abstract' or n->>'abstract' = 'false')
581
548
  and o->'schema'->'anyOf' is not distinct from n->'schema'->'anyOf'
582
549
  and o->'schema'->'oneOf' is not distinct from n->'schema'->'oneOf'
@@ -590,7 +557,8 @@ return
590
557
  and (coalesce((e.value->>'required')::boolean, false) or coalesce((e.value->>'min')::int, 0) > 0));
591
558
 
592
559
  -- Строки семейства, которые не проходят текущие описания своих классов (после ужесточения).
593
- -- Не больше 100 строк; p_incoming — проверять ещё и входящие ссылки (смена родителя).
560
+ -- Не больше 100 строк; p_incoming — проверять ещё и входящие ссылки (смена родителя). У строки — problem (русский текст
561
+ -- по ключу каталога) и сами key и params: библиотека их убирает (fromDbError).
594
562
  create or replace function @SCHEMA@.family_conflicts(p_classes text[], p_tenant uuid, p_incoming boolean) returns jsonb
595
563
  language plpgsql volatile
596
564
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -602,6 +570,7 @@ declare
602
570
  bad jsonb;
603
571
  lk record;
604
572
  problem text;
573
+ msg jsonb; -- ключ и параметры problem
605
574
  inc jsonb;
606
575
  outv jsonb := '[]'::jsonb;
607
576
  a jsonb := @SCHEMA@.acl_actor('READ');
@@ -614,18 +583,26 @@ begin
614
583
  cls := @SCHEMA@.class_row(r.class, r.tenant);
615
584
  res := cls.data->'resolved';
616
585
  problem := null;
586
+ msg := null;
617
587
  if coalesce((res->>'abstract')::boolean, false) then
618
- problem := 'класс абстрактный';
588
+ problem := @SCHEMA@.lt_text('sql-write.problem_abstract');
589
+ msg := jsonb_build_object('key', 'sql-write.problem_abstract', 'params', '{}'::jsonb);
619
590
  else
620
591
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', r.data);
621
592
  if bad <> '[]'::jsonb then
622
- problem := 'данные: ' || bad::text;
593
+ -- в русском тексте — нарушения без key и params (текст прежний), а в params строки — сами нарушения с ключами:
594
+ -- по ним библиотека переведёт и их сообщения
595
+ problem := @SCHEMA@.lt_text('sql-write.problem_data', jsonb_build_object('issues',
596
+ (select jsonb_agg(x.i - 'key' - 'params' order by x.n) from jsonb_array_elements(bad) with ordinality x(i, n))::text));
597
+ msg := jsonb_build_object('key', 'sql-write.problem_data', 'params', jsonb_build_object('issues', bad));
623
598
  else
624
599
  select * into lk from @SCHEMA@.check_links(res, r.links, r.tenant, false, null);
625
- if lk.code is not null then
626
- problem := lk.msg;
600
+ if lk.key is not null then
601
+ problem := @SCHEMA@.lt_text(lk.key, lk.params);
602
+ msg := jsonb_build_object('key', lk.key, 'params', coalesce(lk.params, '{}'::jsonb));
627
603
  elsif coalesce(@SCHEMA@.key_id(res, r.class, r.tenant, r.data, r.links), r.id) <> r.id then
628
- problem := 'id не соответствует ключу класса';
604
+ problem := @SCHEMA@.lt_text('sql-write.problem_key');
605
+ msg := jsonb_build_object('key', 'sql-write.problem_key', 'params', '{}'::jsonb);
629
606
  end if;
630
607
  end if;
631
608
  end if;
@@ -640,13 +617,14 @@ begin
640
617
  from jsonb_array_elements(inc) i(v)
641
618
  left join @SCHEMA@.entity x on x.id = (i.v->>'id')::uuid
642
619
  order by seen desc, i.v->>'id' limit 1;
643
- problem := case when vis
644
- then format('на строку ссылается роль %s строки %s, которая не принимает класс', ref->>'role', ref->>'id')
645
- else 'на строку ссылается строка, которой актор не видит, и она не принимает класс' end;
620
+ msg := case when vis
621
+ then jsonb_build_object('key', 'sql-write.problem_ref', 'params', jsonb_build_object('role', ref->>'role', 'id', ref->>'id'))
622
+ else jsonb_build_object('key', 'sql-write.problem_ref_hidden', 'params', '{}'::jsonb) end;
623
+ problem := @SCHEMA@.lt_text(msg->>'key', msg->'params');
646
624
  end if;
647
625
  end if;
648
626
  if problem is not null then
649
- outv := outv || jsonb_build_object('id', r.id, 'class', r.class, 'tenant', r.tenant, 'problem', problem);
627
+ outv := outv || (jsonb_build_object('id', r.id, 'class', r.class, 'tenant', r.tenant, 'problem', problem) || coalesce(msg, '{}'::jsonb));
650
628
  exit when jsonb_array_length(outv) >= 100;
651
629
  end if;
652
630
  end loop;
@@ -660,26 +638,26 @@ language sql immutable parallel safe
660
638
  return array[@RESERVED_NAMES@]::text[];
661
639
 
662
640
  -- Правка класса p_class арендатора p_tenant меняет имена, по которым его строки находят правила прав: предков
663
- -- (parent), алиас или derivedBy (ревью безопасности №2). План прав берёт для класса имена предков и их алиасы
641
+ -- (parent) или алиас (ревью безопасности №2). План прав берёт для класса имена предков и их алиасы
664
642
  -- (acl_plan_for), поэтому строки класса и его потомков попали бы под правила READ, WRITE и DELETE нового предка или
665
643
  -- алиаса — право WRITE на Class открывало бы чужие строки. Актору нужно право READ, WRITE и DELETE без условий на
666
644
  -- все строки самого класса и каждого потомка своего арендатора, иначе acl_denied. Потомков у арендаторов класса
667
- -- System план прав сессии System не видит — они пропускаются, как у агрегатов (agg_source_admit). Проверка — до
645
+ -- System план прав сессии System не видит — они пропускаются. Проверка — до
668
646
  -- правки (class_admit): план видит прежние имена, по которым строки уже защищены.
669
647
  create or replace function @SCHEMA@.class_acl_admit(p_class text, p_tenant uuid) returns void
670
648
  language plpgsql stable
671
649
  set search_path = pg_catalog, @SCHEMA@, pg_temp
672
650
  as $$
673
651
  begin
674
- perform @SCHEMA@.class_rows_admit(p_class, p_tenant, array['READ', 'WRITE', 'DELETE'],
675
- format('класс %s меняет предков, алиас или derivedBy', p_class));
652
+ perform @SCHEMA@.class_rows_admit(p_class, p_tenant, array['READ', 'WRITE', 'DELETE'], 'reparent');
676
653
  end $$;
677
654
 
678
655
  -- Право актора на все строки класса p_class и каждого его потомка арендатора p_tenant: по каждой операции p_ops —
679
- -- без условий, иначе acl_denied с текстом «<p_what>: нужно право <op> на все строки класса <класс>». Сам класс первым,
656
+ -- без условий, иначе acl_denied «<что меняется>: нужно право <op> на все строки класса <класс>». Что меняется — p_what,
657
+ -- по нему ключ каталога (план 2.0, этап 3, п. 3): reparent, history, tighten; иное — готовый текст. Сам класс первым,
680
658
  -- потомки — по глубине и имени; потомков у арендаторов класса System план сессии System не видит — они пропускаются.
681
- -- Зовут class_acl_admit (смена предков, алиаса, derivedBy), ужесточение (class_evolve, rekey_class) и смена политики
682
- -- хранения истории (class_admit; доработки после точки Б).
659
+ -- Зовут class_acl_admit (смена предков и алиаса), ужесточение (class_evolve) и смена history — включение и выключение
660
+ -- журнала (class_admit; доработки после точки Б).
683
661
  create or replace function @SCHEMA@.class_rows_admit(p_class text, p_tenant uuid, p_ops text[], p_what text) returns void
684
662
  language plpgsql stable
685
663
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -688,6 +666,7 @@ declare
688
666
  c text;
689
667
  op text;
690
668
  chk jsonb;
669
+ p jsonb;
691
670
  begin
692
671
  for c in select p_class
693
672
  union all
@@ -698,21 +677,27 @@ begin
698
677
  foreach op in array p_ops loop
699
678
  chk := @SCHEMA@.acl_check_data(c, op);
700
679
  if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
701
- perform @SCHEMA@.lt_fail('acl_denied', format('%s: нужно право %s на все строки класса %s', p_what, op, c));
680
+ p := jsonb_build_object('cls', p_class, 'op', op, 'target', c);
681
+ case p_what
682
+ when 'reparent' then perform @SCHEMA@.lt_fail('sql-write.reparent_needs_all', p);
683
+ when 'history' then perform @SCHEMA@.lt_fail('sql-write.history_needs_all', p);
684
+ when 'tighten' then perform @SCHEMA@.lt_fail('sql-write.tighten_needs_all', p);
685
+ else perform @SCHEMA@.lt_fail('sql-write.change_needs_all', jsonb_build_object('what', p_what, 'op', op, 'target', c));
686
+ end case;
702
687
  end if;
703
688
  end loop;
704
689
  end loop;
705
690
  end $$;
706
691
 
707
- -- Есть ли в журнале версии строк класса p_name арендатора p_tenant; записи purge, trim и reset — не версии (доработки
708
- -- после точки Б: удаление и пересоздание класса, остаток Н2 ревью №2). У класса System — строки любого арендатора, кроме
692
+ -- Есть ли в журнале версии строк класса p_name арендатора p_tenant (доработки после точки Б: удаление и пересоздание
693
+ -- класса, остаток Н2 ревью №2). У класса System — строки любого арендатора, кроме
709
694
  -- арендаторов со своим классом с тем же именем: их строки — строки своего класса, как их резолвит class_row (ревью
710
695
  -- безопасности №3: история такого класса не мешает администратору создать и удалить класс System)
711
696
  create or replace function @SCHEMA@.class_has_history(p_name text, p_tenant uuid) returns boolean
712
697
  language sql stable
713
698
  set search_path = pg_catalog, @SCHEMA@, pg_temp
714
699
  return exists (select 1 from @SCHEMA@.log l
715
- where l.class = p_name and l.op not in ('purge', 'trim', 'reset')
700
+ where l.class = p_name
716
701
  and (l.tenant = p_tenant
717
702
  or p_tenant = '@SYSTEM@'::uuid
718
703
  and l.tenant <> all (array(select x.tenant from @SCHEMA@.entity x
@@ -738,8 +723,8 @@ return exists (select 1 from @SCHEMA@.log t
738
723
  and @SCHEMA@.class_has_history(t.data->>'name', t.tenant));
739
724
 
740
725
  -- Проверки строки Class сверх метасхемы (этап 1, п. 6): признак system, имя системного класса,
741
- -- имена связей против методов цепочки, однозначность имени конца во всей схеме, право на агрегаты,
742
- -- алиас и право на смену предков, алиаса и derivedBy (ревью безопасности №2).
726
+ -- имена связей и хабов против методов цепочки, однозначность имени конца во всей схеме,
727
+ -- алиас и право на смену предков и алиаса (ревью безопасности №2).
743
728
  create or replace function @SCHEMA@.class_admit(p_data jsonb, p_tenant uuid, out data jsonb, out validator jsonpath)
744
729
  language plpgsql volatile
745
730
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -748,25 +733,19 @@ as $$
748
733
  declare
749
734
  e record;
750
735
  o record;
751
- dname text;
752
- drole text;
753
736
  tc @SCHEMA@.entity;
754
737
  prevd jsonb;
755
- src text;
756
- chk jsonb;
757
- m jsonb;
758
- dkid text;
759
738
  begin
760
739
  if p_tenant <> '@SYSTEM@'::uuid then
761
740
  if (p_data->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
762
- perform @SCHEMA@.lt_fail('invalid_class', format('имя %s зарезервировано за системным классом', p_data->>'name'));
741
+ perform @SCHEMA@.lt_fail('sql-write.system_name_reserved', jsonb_build_object('name', p_data->>'name'));
763
742
  end if;
764
743
  if coalesce((p_data->>'system')::boolean, false) then
765
- perform @SCHEMA@.lt_fail('invalid_class', 'признак system задаёт только установка');
744
+ perform @SCHEMA@.lt_fail('sql-write.system_flag');
766
745
  end if;
767
746
  if exists (select 1 from @SCHEMA@.entity s
768
747
  where s.id = @SCHEMA@.v5_id('@SYSTEM@'::uuid, 'Class', array[p_data->>'name']) and s.class = 'Class') then
769
- perform @SCHEMA@.lt_fail('invalid_class', format('имя %s занято системным классом', p_data->>'name'));
748
+ perform @SCHEMA@.lt_fail('sql-write.system_name_taken', jsonb_build_object('name', p_data->>'name'));
770
749
  end if;
771
750
  end if;
772
751
  -- родитель — под разделяемой блокировкой своего семейства (хвост ревью доработок после 1.2.0): удаление класса берёт
@@ -779,7 +758,12 @@ begin
779
758
  end if;
780
759
  select c.data, c.validator into data, validator from @SCHEMA@.class_prepare(p_data, p_tenant) c;
781
760
  if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
782
- perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
761
+ perform @SCHEMA@.lt_fail('sql-write.link_name_reserved', jsonb_build_object('name', data->>'name'));
762
+ end if;
763
+ -- и хаб (план 2.0, этап 4, п. 2): шаг с именем метода цепочки или корня db до класса не дойдёт. Прежние такие хабы
764
+ -- остаются (up() называет их в предупреждении), но их строку Class база больше не пишет — ни создание, ни правку
765
+ if data->>'kind' = 'hub' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
766
+ perform @SCHEMA@.lt_fail('sql-write.hub_name_reserved', jsonb_build_object('name', data->>'name'));
783
767
  end if;
784
768
  -- алиас — второе имя класса и для правил прав (ревью безопасности №2): совпади он с именем другого класса (в том
785
769
  -- числе Class и системных) или с чужим алиасом арендатора и System, правила одного класса действовали бы на строки
@@ -789,107 +773,36 @@ begin
789
773
  or exists (select 1 from @SCHEMA@.entity x
790
774
  where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
791
775
  and (x.data->>'name' = data->>'alias' or x.data->>'alias' = data->>'alias'))) then
792
- perform @SCHEMA@.lt_fail('invalid_class', format('алиас %s класса %s совпадает с именем или алиасом другого класса',
793
- data->>'alias', data->>'name'));
776
+ perform @SCHEMA@.lt_fail('sql-write.alias_taken', jsonb_build_object('alias', data->>'alias', 'cls', data->>'name'));
794
777
  end if;
795
778
  -- и наоборот: имя класса — не чужой алиас арендатора и System, иначе правила, написанные для нового класса, действовали
796
779
  -- бы и на строки класса с этим алиасом
797
780
  if exists (select 1 from @SCHEMA@.entity x
798
781
  where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
799
782
  and x.data->>'alias' = data->>'name') then
800
- perform @SCHEMA@.lt_fail('invalid_class', format('имя класса %s совпадает с алиасом другого класса', data->>'name'));
783
+ perform @SCHEMA@.lt_fail('sql-write.name_is_alias', jsonb_build_object('cls', data->>'name'));
801
784
  end if;
802
- -- агрегаты (план 1.1, §2.4; ревью коммита этапа 4б): поле считают функции владельца по всем строкам источника
803
- -- арендатора, без прав чтения, поэтому строки попадают в агрегат только с ведома того, кто читает их все. Пути
804
- -- владельца (сид, class_evolve, rekey_class) сюда не заходят. Описание до правки — для сравнения (нет — новый класс)
785
+ -- описание до правки — для сравнения (нет — новый класс). Пути владельца (сид, class_evolve) сюда не заходят
805
786
  select x.data into prevd from @SCHEMA@.entity x
806
787
  where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' = data->>'name';
807
- -- производный класс (план 1.1, §2.4) заводит, меняет и удаляет только администратор (/security-review после точки Б):
808
- -- проектор берёт арендатора в участники по его классам с пометкой и пишет туда итоги по всем строкам источников,
809
- -- поэтому пользователь с правом WRITE на Class, заведя такой класс сам, читал бы итоги по строкам, которые ему читать
810
- -- запрещено. Пометка своя или унаследованная, до правки или после; право letopis.projector — у администраторской роли
811
- -- подключения и по правилам System (системный администратор — маска letopis.*)
812
- if (data->'resolved' ? 'derivedBy' or coalesce(prevd->'resolved' ? 'derivedBy', false))
813
- and not @SCHEMA@.letopis_allowed('letopis.projector') then
814
- perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)',
815
- data->>'name'));
816
- end if;
817
- -- предок производного класса (ревью безопасности №3): правка предка пересчитывает описание потомка (class_evolve) —
818
- -- поля, ключ, history, — поэтому класс с производными потомками (своего арендатора, у класса System — всех, кроме
819
- -- арендаторов со своим одноимённым классом: их потомки наследуют свой класс) меняет тоже только администратор
820
- if prevd is not null and not @SCHEMA@.letopis_allowed('letopis.projector') then
821
- select x.data->>'name' into dkid from @SCHEMA@.entity x
822
- where x.class = 'Class' and x.data->>'name' <> data->>'name'
823
- and (x.tenant = p_tenant
824
- or p_tenant = '@SYSTEM@'::uuid
825
- and x.tenant <> all (array(select y.tenant from @SCHEMA@.entity y
826
- where y.class = 'Class' and y.data->>'name' = data->>'name' and y.tenant <> '@SYSTEM@'::uuid)))
827
- and x.data->'resolved'->'ancestors' ? (data->>'name') and x.data->'resolved' ? 'derivedBy'
828
- order by x.data->>'name' limit 1;
829
- if dkid is not null then
830
- perform @SCHEMA@.lt_fail('acl_denied', format('класс %s — предок производного класса %s: его описание задаёт только администратор (право letopis.projector)',
831
- data->>'name', dkid));
832
- end if;
833
- end if;
834
- -- агрегат развёрнутого описания (свой или унаследованный), новый или изменённый против прежнего — описание
835
- -- агрегата, новый потомок цели, смена parent, — задаёт только тот, кто без условий читает все строки каждого
836
- -- класса-источника: класса of и его потомков (agg_classes: классы арендатора, у класса System — классы System).
837
- -- Неизменённый агрегат не проверяется
838
- if data->'resolved' ? 'aggregate' then
839
- for e in select key, value from jsonb_each(data->'resolved'->'aggregate') order by key loop
840
- continue when prevd->'resolved'->'aggregate'->e.key is not distinct from e.value;
841
- -- класс-источник должен уже быть (ревью безопасности №3, /security-review): пока его нет, проверять право READ не
842
- -- на чем, а когда класс заведёт другой пользователь, проверялись бы только его права — и описавший агрегат читал бы
843
- -- в истории цели вклад каждой строки источника. Поэтому источник описывают раньше агрегата
844
- if cardinality(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) = 0 then
845
- perform @SCHEMA@.lt_fail('invalid_class', format('агрегат %s.%s: класса-источника %s и его потомков %s нет — источник описывают раньше агрегата',
846
- data->>'name', e.key, e.value->>'of', case when p_tenant = '@SYSTEM@'::uuid then 'в System' else 'у арендатора' end));
847
- end if;
848
- -- сам класс of первым, потомки — по имени
849
- foreach src in array array(select n from unnest(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) n
850
- order by n = (e.value->>'of') desc, n) loop
851
- chk := @SCHEMA@.acl_check_data(src, 'READ');
852
- if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
853
- perform @SCHEMA@.lt_fail('acl_denied', format('агрегат %s.%s: нужно право READ на все строки класса %s', data->>'name', e.key, src));
854
- end if;
855
- end loop;
856
- end loop;
857
- end if;
858
- -- класс становится источником агрегата (новая метка aggregatedBy — смена parent): в агрегат войдут его строки и строки
859
- -- потомков — нужно право READ без условий на них до правки, по прежним предкам (здесь), и после (agg_source_admit в
860
- -- триггере после оператора; новый класс проверяется только там: до вставки план прав его не видит)
861
- m := @SCHEMA@.agg_new_mark(prevd->'resolved', data->'resolved');
862
- if m is not null and prevd is not null then
863
- perform @SCHEMA@.agg_source_admit(data->>'name', m);
864
- for src in select x.data->>'name' from @SCHEMA@.entity x
865
- where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' <> data->>'name'
866
- and x.data->'resolved'->'ancestors' ? (data->>'name')
867
- order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.data->>'name' loop
868
- perform @SCHEMA@.agg_source_admit(src, m);
869
- end loop;
870
- end if;
871
- -- правка меняет предков, алиас или derivedBy (ревью безопасности №2): строки класса и потомков попали бы под правила
788
+ -- правка меняет предков или алиас (ревью безопасности №2): строки класса и потомков попали бы под правила
872
789
  -- других имён — нужно право READ, WRITE и DELETE без условий на все их строки. У нового класса строк ещё нет
873
790
  if prevd is not null
874
791
  and (prevd->'resolved'->'ancestors' is distinct from data->'resolved'->'ancestors'
875
- or prevd->>'alias' is distinct from data->>'alias'
876
- or prevd->'resolved'->'derivedBy' is distinct from data->'resolved'->'derivedBy') then
792
+ or prevd->>'alias' is distinct from data->>'alias') then
877
793
  perform @SCHEMA@.class_acl_admit(data->>'name', p_tenant);
878
794
  end if;
879
795
  -- алиас класса, у которого удалённые потомки хранят историю строк (ревью безопасности №3): правила находят их старые
880
- -- версии и по алиасам предков, а надгробие потомка их не записывает — алиас меняется после purge() их строк
796
+ -- версии и по алиасам предков, а надгробие потомка их не записывает — алиас не меняется
881
797
  if prevd is not null and prevd->>'alias' is distinct from data->>'alias'
882
798
  and @SCHEMA@.class_orphan_history(data->>'name', p_tenant) then
883
- perform @SCHEMA@.lt_fail('invalid_class', format('удалённые потомки класса %s хранят историю строк: алиас меняется после purge() их строк',
884
- data->>'name'));
799
+ perform @SCHEMA@.lt_fail('sql-write.alias_orphan_history', jsonb_build_object('cls', data->>'name'));
885
800
  end if;
886
- -- политика хранения истории (доработки после точки Б): по ней maintain() прореживает журнал строк класса, а
887
- -- history: false выключает журнал. Одним правом WRITE на Class автор правки стирал бы аудит строк, на которые у него
888
- -- нет прав, поэтому смена развёрнутой history существующего класса требует права READ и DELETE без условий на все
889
- -- строки класса и его потомков своего арендатора — как trimHistory требует их на каждую цель
801
+ -- history (доработки после точки Б): history: false выключает журнал. Одним правом WRITE на Class автор правки
802
+ -- останавливал бы аудит строк, на которые у него нет прав, поэтому смена развёрнутой history существующего класса
803
+ -- требует права READ и DELETE без условий на все строки класса и его потомков своего арендатора
890
804
  if prevd is not null and prevd->'resolved'->'history' is distinct from data->'resolved'->'history' then
891
- perform @SCHEMA@.class_rows_admit(data->>'name', p_tenant, array['READ', 'DELETE'],
892
- format('класс %s меняет политику хранения истории', data->>'name'));
805
+ perform @SCHEMA@.class_rows_admit(data->>'name', p_tenant, array['READ', 'DELETE'], 'history');
893
806
  end if;
894
807
  for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
895
808
  select x.data->>'name' as name into o
@@ -900,52 +813,9 @@ begin
900
813
  and @SCHEMA@.class_set(x.data->'resolved'->'ends'->e.key->'class') <> @SCHEMA@.class_set(e.value->'class')
901
814
  limit 1;
902
815
  if o.name is not null then
903
- perform @SCHEMA@.lt_fail('invalid_class', format('конец %s в классе %s ведёт на другой класс', e.key, o.name));
904
- end if;
905
- end loop;
906
- -- на строки производного класса (план 1.1, §2.4) не ссылаются: проектор удаляет и строит их заново.
907
- -- Классы целей — под разделяемой блокировкой семейства, а класс, который становится производным, — под
908
- -- исключительной: описание со ссылкой и пометка derivedBy не проходят параллельно мимо друг друга
909
- for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
910
- for tc in select c.* from jsonb_array_elements_text(@SCHEMA@.class_set(e.value->'class')) n(name)
911
- cross join lateral @SCHEMA@.class_row(n.name, p_tenant) c where c.id is not null order by c.id loop
912
- perform @SCHEMA@.family_lock(tc, false);
913
- end loop;
914
- dname := null;
915
- select x.data->>'name' into dname
916
- from @SCHEMA@.entity x
917
- where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
918
- and @SCHEMA@.class_set(e.value->'class') ? (x.data->>'name') and x.data->'resolved' ? 'derivedBy'
919
- limit 1;
920
- if dname is null and data->'resolved' ? 'derivedBy' and @SCHEMA@.class_set(e.value->'class') ? (data->>'name') then
921
- dname := data->>'name';
922
- end if;
923
- if dname is not null then
924
- perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на производный класс %s', e.key, dname));
816
+ perform @SCHEMA@.lt_fail('sql-write.end_other_class', jsonb_build_object('end', e.key, 'cls', o.name));
925
817
  end if;
926
818
  end loop;
927
- if data->'resolved' ? 'derivedBy' then
928
- tc := @SCHEMA@.class_row(data->>'name', p_tenant);
929
- if tc.id is not null then
930
- perform @SCHEMA@.family_lock(tc, true);
931
- end if;
932
- -- класс становится производным, а на него или его потомков уже ссылаются концы других классов (класс
933
- -- System видят все арендаторы — его ссылки ищутся во всех)
934
- select x.data->>'name', k.key into dname, drole
935
- from @SCHEMA@.entity x
936
- cross join lateral jsonb_each(coalesce(x.data->'resolved'->'ends', '{}'::jsonb)) k
937
- where x.class = 'Class' and (x.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
938
- and x.data->>'name' <> data->>'name'
939
- and @SCHEMA@.class_set(k.value->'class') ?| (array[data->>'name'] || array(
940
- select y.data->>'name' from @SCHEMA@.entity y
941
- where y.class = 'Class' and (y.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
942
- and y.data->'resolved'->'ancestors' ? (data->>'name')))
943
- limit 1;
944
- if dname is not null then
945
- perform @SCHEMA@.lt_fail('derived_class', format('класс %s не может стать производным: на него ссылается конец %s класса %s',
946
- data->>'name', drole, dname));
947
- end if;
948
- end if;
949
819
  -- строки-замки семейства (family_lock) — заранее: первая запись в новое семейство их не вставляет
950
820
  perform @SCHEMA@.family_rows(p_tenant, data);
951
821
  end $$;
@@ -968,98 +838,81 @@ declare
968
838
  begin
969
839
  select * into c from @SCHEMA@.ctx();
970
840
  if c.account is null then
971
- perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
841
+ perform @SCHEMA@.lt_fail('sql-write.no_session');
972
842
  end if;
973
843
  NEW.tenant := coalesce(NEW.tenant, c.tenant);
974
844
  if NEW.tenant <> c.tenant then
975
- perform @SCHEMA@.lt_fail('tenant_denied', 'запись в чужого арендатора');
845
+ perform @SCHEMA@.lt_fail('sql-write.foreign_tenant');
976
846
  end if;
977
847
  -- аккаунты (с их категориями) — только в System: их пишет системный администратор (этап 5)
978
848
  if NEW.class = 'Account' and NEW.tenant <> '@SYSTEM@'::uuid then
979
- perform @SCHEMA@.lt_fail('tenant_denied', 'аккаунты лежат в арендаторе System');
849
+ perform @SCHEMA@.lt_fail('sql-write.account_tenant');
980
850
  end if;
981
851
  cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
982
852
  if cls.id is null then
983
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
853
+ perform @SCHEMA@.lt_fail('sql-write.class_not_found', jsonb_build_object('cls', NEW.class));
984
854
  end if;
985
855
  -- разделяемая блокировка корня семейства, затем описание перечитывается под ней (класс могли удалить, пока ждали)
986
856
  perform @SCHEMA@.family_lock(cls, false);
987
857
  cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
988
858
  if cls.id is null then
989
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', NEW.class));
859
+ perform @SCHEMA@.lt_fail('sql-write.class_deleted', jsonb_build_object('cls', NEW.class));
990
860
  end if;
991
861
  res := cls.data->'resolved';
992
862
  if coalesce((res->>'abstract')::boolean, false) then
993
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
994
- end if;
995
- if res ? 'derivedBy' then
996
- perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', NEW.class, res->>'derivedBy'));
863
+ perform @SCHEMA@.lt_fail('sql-write.class_abstract', jsonb_build_object('cls', NEW.class));
997
864
  end if;
998
865
  if NEW.class = 'Class' then
999
866
  select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(coalesce(NEW.data, '{}'::jsonb), NEW.tenant) a;
1000
867
  else
1001
- -- агрегатное поле считает база (план 1.1, §2.4): переданное значение — отказ, новое — 0 до умолчаний и
1002
- -- проверки; итог из живых источников ставит триггер после оператора
1003
- if res ? 'aggregate' then
1004
- if coalesce(NEW.data, '{}'::jsonb) ?| @SCHEMA@.agg_fields(res) then
1005
- perform @SCHEMA@.lt_fail('aggregate_field', format('поля %s класса %s считает база (aggregate) — их не передают',
1006
- array_to_string(array(select x from unnest(@SCHEMA@.agg_fields(res)) x where NEW.data ? x), ', '), NEW.class));
1007
- end if;
1008
- NEW.data := @SCHEMA@.agg_zero(res, coalesce(NEW.data, '{}'::jsonb));
1009
- end if;
1010
868
  NEW.data := @SCHEMA@.apply_defaults(res->'schema', coalesce(NEW.data, '{}'::jsonb));
1011
869
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
1012
870
  if bad <> '[]'::jsonb then
1013
- perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', NEW.class), jsonb_build_object('issues', bad));
871
+ perform @SCHEMA@.lt_fail('sql-write.invalid_data', jsonb_build_object('cls', NEW.class), jsonb_build_object('issues', bad));
1014
872
  end if;
1015
873
  NEW.validator := null;
1016
874
  end if;
1017
875
  -- цели проверяет и блокирует триггер после оператора (targets_bad) — один раз на оператор
1018
876
  select * into lk from @SCHEMA@.links_shape(res, NEW.links);
1019
- if lk.code is not null then
1020
- perform @SCHEMA@.lt_fail(lk.code, lk.msg);
877
+ if lk.key is not null then
878
+ perform @SCHEMA@.lt_fail(lk.key, lk.params);
1021
879
  end if;
1022
880
  NEW.links := lk.links;
1023
881
  if NEW.class = 'member' and NEW.links->>'Tenant' is distinct from NEW.tenant::text then
1024
- perform @SCHEMA@.lt_fail('invalid_data', 'конец Tenant членства — арендатор строки');
882
+ perform @SCHEMA@.lt_fail('sql-write.member_tenant');
1025
883
  end if;
1026
884
  kid := @SCHEMA@.key_id(res, NEW.class, NEW.tenant, NEW.data, NEW.links);
1027
885
  if kid is null then
1028
886
  if NEW.id is not null then
1029
- perform @SCHEMA@.lt_fail('id_from_db', format('id класса без ключа %s выдаёт база', NEW.class));
887
+ perform @SCHEMA@.lt_fail('sql-write.id_from_db', jsonb_build_object('cls', NEW.class));
1030
888
  end if;
1031
889
  NEW.id := @SCHEMA@.v5_id(NEW.tenant, NEW.class, array[gen_random_uuid()::text]);
1032
890
  else
1033
891
  if NEW.id is not null and NEW.id <> kid then
1034
- perform @SCHEMA@.lt_fail('id_mismatch', format('id не равен v5 от ключа класса %s', NEW.class), jsonb_build_object('expected', kid));
892
+ perform @SCHEMA@.lt_fail('sql-write.id_mismatch', jsonb_build_object('cls', NEW.class), jsonb_build_object('expected', kid));
1035
893
  end if;
1036
894
  NEW.id := kid;
1037
895
  end if;
1038
- -- воскрешение: номер версии и цепочка хэшей продолжаются от последней записи журнала; запись
1039
- -- блокируется — параллельные purge, обрезка и restore того же id ждут (этап 6), а если purge успел
1040
- -- стереть её, пара (id, rev) столкнётся с его записью purge в журнале, и операция повторится
1041
- 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;
896
+ -- воскрешение: номер версии продолжается от последней записи журнала; запись блокируется — параллельный restore того
897
+ -- же id ждёт (этап 6)
898
+ select l.rev, 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;
1042
899
  -- класс с историей строк в журнале (остаток Н2 ревью №2; доработки после точки Б): правила прав находят строки, и
1043
- -- старые версии тоже, по именам класса, предков и алиасов — класс с другим parent, alias или derivedBy подставил бы
900
+ -- старые версии тоже, по именам класса, предков и алиасов — класс с другим parent или alias подставил бы
1044
901
  -- старую историю под чужие правила. Поэтому, если журнал арендатора хранит версии строк класса с этим именем, класс
1045
902
  -- создаётся только заново и с тем же описанием, что в надгробии его строки Class. Надгробия нет (история осталась от
1046
- -- класса System, удалённого раньше, или строку Class стёрли мимо триггеров) — отказ: сначала purge() строк
903
+ -- класса System, удалённого раньше, или строку Class стёрли мимо триггеров) — отказ
1047
904
  -- Так же — если историю хранят удалённые потомки класса (их правила находят старые версии и по алиасу предка), и
1048
- -- политика хранения прежняя: по ней maintain() прореживает старые версии (ревью безопасности №3; у описаний без ключа
1049
- -- history в resolved — значение по умолчанию true)
905
+ -- history прежняя (ревью безопасности №3; у описаний без ключа history в resolved — значение по умолчанию true)
1050
906
  if NEW.class = 'Class'
1051
907
  and (@SCHEMA@.class_has_history(NEW.data->>'name', NEW.tenant) or @SCHEMA@.class_orphan_history(NEW.data->>'name', NEW.tenant))
1052
908
  and not (coalesce(last.op = 'delete', false)
1053
909
  and last.data->'resolved'->'ancestors' is not distinct from NEW.data->'resolved'->'ancestors'
1054
910
  and last.data->>'alias' is not distinct from NEW.data->>'alias'
1055
- and last.data->'resolved'->'derivedBy' is not distinct from NEW.data->'resolved'->'derivedBy'
1056
911
  and coalesce(last.data->'resolved'->'history', 'true'::jsonb) is not distinct from coalesce(NEW.data->'resolved'->'history', 'true'::jsonb)) then
1057
- perform @SCHEMA@.lt_fail('invalid_class', format('журнал хранит историю строк класса %s или его удалённых потомков: создать его можно только заново и с прежними parent, alias, derivedBy и history (или сначала стереть её purge())',
1058
- NEW.data->>'name'));
912
+ perform @SCHEMA@.lt_fail('sql-write.history_recreate', jsonb_build_object('cls', NEW.data->>'name'));
1059
913
  end if;
1060
914
  ts := @SCHEMA@.now_ts();
1061
915
  NEW.rev := coalesce(last.rev, 0) + 1;
1062
- NEW.prev_hash := last.hash;
1063
916
  NEW.at := greatest(ts, last.at + interval '1 microsecond');
1064
917
  NEW.created_at := NEW.at;
1065
918
  NEW.cv := cls.rev;
@@ -1068,13 +921,12 @@ begin
1068
921
  elsif NEW.owner not in (NEW.tenant, c.account)
1069
922
  and not exists (select 1 from @SCHEMA@.entity a where a.id = NEW.owner and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
1070
923
  -- явный владелец — существующий аккаунт; разрешает ли его условие ACL, решает политика вставки
1071
- perform @SCHEMA@.lt_fail('target_not_found', 'владелец строки не найден');
924
+ perform @SCHEMA@.lt_fail('sql-write.owner_not_found');
1072
925
  end if;
1073
926
  NEW.author := c.author;
1074
927
  NEW.agent := c.agent;
1075
928
  NEW.op := 'create';
1076
929
  NEW.reason := nullif(current_setting('letopis.reason', true), '');
1077
- NEW.moved := null;
1078
930
  NEW.tags := coalesce(NEW.tags, '{}');
1079
931
  return NEW;
1080
932
  end $$;
@@ -1087,118 +939,72 @@ declare
1087
939
  c record;
1088
940
  cls @SCHEMA@.entity;
1089
941
  res jsonb;
1090
- old_res jsonb;
1091
942
  bad jsonb;
1092
943
  lk record;
1093
944
  kid uuid;
1094
- op text := 'update';
1095
- inc jsonb;
1096
945
  begin
1097
946
  select * into c from @SCHEMA@.ctx();
1098
947
  if c.account is null then
1099
- perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
948
+ perform @SCHEMA@.lt_fail('sql-write.no_session');
1100
949
  end if;
1101
950
  if NEW.id <> OLD.id or NEW.tenant <> OLD.tenant or NEW.owner <> OLD.owner or NEW.created_at <> OLD.created_at then
1102
- perform @SCHEMA@.lt_fail('immutable_key', 'id, арендатора, владельца и время создания менять нельзя');
951
+ perform @SCHEMA@.lt_fail('sql-write.immutable_columns');
1103
952
  end if;
1104
- if (NEW.class = 'Class') <> (OLD.class = 'Class') then
1105
- perform @SCHEMA@.lt_fail('invalid_class', 'строку Class нельзя превратить в данные и наоборот');
1106
- end if;
1107
- -- строки прав не получаются сменой класса: аккаунт с категориями — только в System (ревью этапа 5)
1108
- if NEW.class <> OLD.class and (NEW.class in ('Account', 'member', 'Resource', 'rule') or OLD.class in ('Account', 'member', 'Resource', 'rule')) then
1109
- perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
953
+ -- класс строки неизменен (план 2.0, редакция 2): смены класса нет ни правкой, ни функцией
954
+ if NEW.class <> OLD.class then
955
+ perform @SCHEMA@.lt_fail('sql-write.class_immutable', jsonb_build_object('cls', OLD.class));
1110
956
  end if;
1111
957
  cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
1112
958
  if cls.id is null then
1113
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
959
+ perform @SCHEMA@.lt_fail('sql-write.class_not_found', jsonb_build_object('cls', NEW.class));
1114
960
  end if;
1115
961
  perform @SCHEMA@.family_lock(cls, false);
1116
962
  cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
1117
963
  if cls.id is null then
1118
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', NEW.class));
964
+ perform @SCHEMA@.lt_fail('sql-write.class_deleted', jsonb_build_object('cls', NEW.class));
1119
965
  end if;
1120
966
  res := cls.data->'resolved';
1121
967
  if coalesce((res->>'abstract')::boolean, false) then
1122
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
1123
- end if;
1124
- if res ? 'derivedBy' or (NEW.class <> OLD.class and (@SCHEMA@.class_row(OLD.class, OLD.tenant)).data->'resolved' ? 'derivedBy') then
1125
- perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор',
1126
- case when res ? 'derivedBy' then NEW.class else OLD.class end));
1127
- end if;
1128
- if NEW.class <> OLD.class then
1129
- -- смена класса (этап 1, п. 9): только read committed, только для классов без ключа
1130
- if current_setting('transaction_isolation') <> 'read committed' then
1131
- perform @SCHEMA@.lt_fail('isolation_level', 'смена класса разрешена только в read committed');
1132
- end if;
1133
- old_res := (@SCHEMA@.class_row(OLD.class, OLD.tenant)).data->'resolved';
1134
- if jsonb_array_length(coalesce(old_res->'key', '[]'::jsonb)) > 0 or jsonb_array_length(coalesce(res->'key', '[]'::jsonb)) > 0 then
1135
- perform @SCHEMA@.lt_fail('use_reclass', format('у класса %s или %s есть ключ: id меняется, используйте reclass()', OLD.class, NEW.class));
1136
- end if;
1137
- -- обычный update берёт слабую блокировку; смене класса нужна for update, чтобы дождаться
1138
- -- транзакций, которые как раз пишут ссылку на эту строку
1139
- perform 1 from @SCHEMA@.entity x where x.id = OLD.id for update;
1140
- -- строка уходит из своего класса — как удаление из него: нужно право DELETE на прежнюю строку (доработки после
1141
- -- точки Б; иначе с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там)
1142
- 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
1143
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на строку класса %s: смена класса убирает её из класса', OLD.class));
1144
- end if;
1145
- inc := @SCHEMA@.incoming_conflicts(OLD.id, NEW.class, OLD.tenant);
1146
- if inc <> '[]'::jsonb then
1147
- perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', NEW.class),
1148
- @SCHEMA@.visible_detail(inc, c.tenant));
1149
- end if;
1150
- op := 'reclass';
968
+ perform @SCHEMA@.lt_fail('sql-write.class_abstract', jsonb_build_object('cls', NEW.class));
1151
969
  end if;
1152
970
  if NEW.class = 'Class' then
1153
971
  select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(NEW.data, OLD.tenant) a;
1154
972
  else
1155
- -- агрегатное поле (план 1.1, §2.4): изменить его нельзя, без поля (замена данных целиком) — прежнее значение;
1156
- -- при смене класса — прежнее число или 0, итог посчитает триггер после оператора
1157
- if res ? 'aggregate' then
1158
- NEW.data := @SCHEMA@.agg_guard(res, OLD.data, NEW.data, NEW.class <> OLD.class);
1159
- end if;
1160
973
  NEW.data := @SCHEMA@.apply_defaults(res->'schema', NEW.data);
1161
974
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
1162
975
  if bad <> '[]'::jsonb then
1163
- perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', NEW.class), jsonb_build_object('issues', bad));
976
+ perform @SCHEMA@.lt_fail('sql-write.invalid_data', jsonb_build_object('cls', NEW.class), jsonb_build_object('issues', bad));
1164
977
  end if;
1165
978
  NEW.validator := null;
1166
979
  end if;
1167
980
  select * into lk from @SCHEMA@.links_shape(res, NEW.links);
1168
- if lk.code is not null then
1169
- perform @SCHEMA@.lt_fail(lk.code, lk.msg);
981
+ if lk.key is not null then
982
+ perform @SCHEMA@.lt_fail(lk.key, lk.params);
1170
983
  end if;
1171
984
  NEW.links := lk.links;
1172
985
  if NEW.class = 'member' and NEW.links->>'Tenant' is distinct from OLD.tenant::text then
1173
- perform @SCHEMA@.lt_fail('invalid_data', 'конец Tenant членства — арендатор строки');
986
+ perform @SCHEMA@.lt_fail('sql-write.member_tenant');
1174
987
  end if;
1175
988
  kid := @SCHEMA@.key_id(res, NEW.class, OLD.tenant, NEW.data, NEW.links);
1176
989
  if kid is not null and kid <> OLD.id then
1177
- perform @SCHEMA@.lt_fail('immutable_key', format('ключевое поле класса %s менять нельзя: id изменился бы (rekey)', NEW.class));
990
+ perform @SCHEMA@.lt_fail('sql-write.key_changed', jsonb_build_object('cls', NEW.class));
1178
991
  end if;
1179
992
  -- ничего не изменилось: строка возвращается как есть, версии нет, оператор считает её затронутой
1180
- if NEW.class = OLD.class and NEW.data = OLD.data and NEW.links = OLD.links and NEW.tags = OLD.tags then
993
+ if NEW.data = OLD.data and NEW.links = OLD.links and NEW.tags = OLD.tags then
1181
994
  return OLD;
1182
995
  end if;
1183
996
  if NEW.class = 'Class' and not @SCHEMA@.class_change_safe(OLD.data->'resolved', NEW.data->'resolved') then
1184
997
  -- ужесточение: исключительная блокировка корня семейства (и нового корня при смене родителя)
1185
998
  perform @SCHEMA@.family_lock(OLD, true);
1186
999
  perform @SCHEMA@.family_lock(NEW, true);
1187
- -- смена агрегатов — ещё семейства источников (план 1.1, §2.4): операция, зафиксированная до пересчёта, в него
1188
- -- войдёт, а ожидающая допишет вклад уже по новому описанию
1189
- if OLD.data->'resolved'->'aggregate' is distinct from NEW.data->'resolved'->'aggregate' then
1190
- perform @SCHEMA@.agg_lock_sources(OLD.data->'resolved', NEW.data->'resolved', OLD.tenant);
1191
- end if;
1192
1000
  end if;
1193
1001
  NEW.rev := OLD.rev + 1;
1194
- NEW.prev_hash := OLD.hash;
1195
1002
  NEW.at := greatest(@SCHEMA@.now_ts(), OLD.at + interval '1 microsecond');
1196
1003
  NEW.cv := cls.rev;
1197
1004
  NEW.author := c.author;
1198
1005
  NEW.agent := c.agent;
1199
- NEW.op := op;
1006
+ NEW.op := 'update';
1200
1007
  NEW.reason := nullif(current_setting('letopis.reason', true), '');
1201
- NEW.moved := null;
1202
1008
  return NEW;
1203
1009
  end $$;
1204
1010
 
@@ -1217,13 +1023,11 @@ begin
1217
1023
  bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
1218
1024
  from added n where n.links <> '{}'::jsonb), true, @SCHEMA@.acl_actor('READ'));
1219
1025
  end if;
1220
- if bad like '!%' then
1221
- perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на строку производного класса', substr(bad, 2)));
1222
- elsif bad is not null then
1223
- perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
1026
+ if bad is not null then
1027
+ perform @SCHEMA@.lt_fail('sql-write.end_target_missing', jsonb_build_object('end', bad));
1224
1028
  end if;
1225
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
1226
- select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash
1029
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason)
1030
+ 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
1227
1031
  from added n
1228
1032
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
1229
1033
  where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
@@ -1234,19 +1038,6 @@ begin
1234
1038
  if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
1235
1039
  perform @SCHEMA@.acl_bump();
1236
1040
  end if;
1237
- if 'Class' = any (sc) then
1238
- -- новый класс — источник агрегата (ревью коммита этапа 4б): нужно право READ без условий на все его строки; план
1239
- -- прав после acl_bump видит новый класс
1240
- perform @SCHEMA@.agg_source_admit(n.data->>'name', @SCHEMA@.agg_new_mark(null, n.data->'resolved'))
1241
- from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
1242
- -- и описавшему агрегат (хвост ревью доработок после 1.2.0): заведя потомка источника, читающий всё пользователь
1243
- -- иначе открыл бы его строки тому, кто описал агрегат без права на них
1244
- perform @SCHEMA@.agg_author_admit(n.data->>'name', n.tenant, null, n.data->'resolved')
1245
- from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
1246
- -- новая цель агрегатов (план 1.1, §2.4): метки её источников (aggregatedBy) — заново
1247
- perform @SCHEMA@.agg_mark(n.tenant, @SCHEMA@.agg_of(n.data->'resolved'))
1248
- from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregate';
1249
- end if;
1250
1041
  return null;
1251
1042
  end $$;
1252
1043
 
@@ -1265,31 +1056,26 @@ declare
1265
1056
  cid uuid;
1266
1057
  bad text;
1267
1058
  begin
1268
- -- цели проверяются у строк, у которых изменились концы или класс
1059
+ -- цели проверяются у строк, у которых изменились концы
1269
1060
  if exists (select 1 from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
1270
- where n.links <> '{}'::jsonb and (n.links <> o.links or n.class <> o.class)) then
1061
+ where n.links <> '{}'::jsonb and n.links <> o.links) then
1271
1062
  bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
1272
1063
  from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
1273
- where n.links <> '{}'::jsonb and (n.links <> o.links or n.class <> o.class)), true, @SCHEMA@.acl_actor('READ'));
1064
+ where n.links <> '{}'::jsonb and n.links <> o.links), true, @SCHEMA@.acl_actor('READ'));
1274
1065
  end if;
1275
- if bad like '!%' then
1276
- perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на строку производного класса', substr(bad, 2)));
1277
- elsif bad is not null then
1278
- perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
1066
+ if bad is not null then
1067
+ perform @SCHEMA@.lt_fail('sql-write.end_target_missing', jsonb_build_object('end', bad));
1279
1068
  end if;
1280
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
1281
- 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,
1282
- @SCHEMA@.change_paths(o.data, o.links, o.tags, o.owner, o.class, n.data, n.links, n.tags, n.owner, n.class)
1069
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason)
1070
+ 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
1283
1071
  from new_rows n
1284
1072
  join old_rows o on o.id = n.id and o.rev <> n.rev
1285
1073
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
1286
1074
  where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
1287
1075
  order by n.id; -- порядок seq не зависит от плана: правка многих строк пишет версии в порядке id
1288
- -- сигнал — по парам нового и прежнего класса: при смене класса без ключа ответы прежнего класса в
1289
- -- кэше других процессов устарели бы (в 1.0.1 сигнал шёл только по новому)
1076
+ -- сигнал — по паре «класс, арендатор» (класс и арендатор строки неизменны)
1290
1077
  select array_agg(k.class), array_agg(k.tenant) into sc, st
1291
- 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
1292
- cross join lateral (values (n.class, n.tenant), (o.class, o.tenant)) v(class, tenant)) k;
1078
+ from (select distinct n.class, n.tenant from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev) k;
1293
1079
  perform @SCHEMA@.signal(sc, st);
1294
1080
  if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
1295
1081
  perform @SCHEMA@.acl_bump();
@@ -1298,10 +1084,6 @@ begin
1298
1084
  where n.class = 'Class' order by n.id loop
1299
1085
  select * into o_row from old_rows x where x.id = cid;
1300
1086
  select * into n_row from new_rows x where x.id = cid;
1301
- -- класс становится источником агрегата (смена parent; ревью коммита этапа 4б): право READ без условий на его строки
1302
- -- и после правки — по новым предкам (до правки проверил class_admit)
1303
- perform @SCHEMA@.agg_source_admit(n_row.data->>'name', @SCHEMA@.agg_new_mark(o_row.data->'resolved', n_row.data->'resolved'));
1304
- perform @SCHEMA@.agg_author_admit(n_row.data->>'name', n_row.tenant, o_row.data->'resolved', n_row.data->'resolved');
1305
1087
  perform @SCHEMA@.class_evolve(o_row, n_row);
1306
1088
  end loop;
1307
1089
  -- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
@@ -1323,15 +1105,7 @@ declare
1323
1105
  classes text[] := array[n.data->>'name'];
1324
1106
  conflicts jsonb;
1325
1107
  c record;
1326
- -- агрегаты (план 1.1, §2.4): источники, чьи метки пересчитываются; цели, поле которых считается заново
1327
- agg_names text[] := '{}';
1328
- agg_own text[] := '{}';
1329
- agg_other text[] := @SCHEMA@.agg_marked(o.data->'resolved', n.data->'resolved');
1330
1108
  begin
1331
- if o.data->'resolved'->'aggregate' is distinct from n.data->'resolved'->'aggregate' then
1332
- agg_names := @SCHEMA@.agg_of(o.data->'resolved') || @SCHEMA@.agg_of(n.data->'resolved');
1333
- agg_own := agg_own || (n.data->>'name');
1334
- end if;
1335
1109
  -- потомки: описание resolved и validator пересчитываются, каждый пересчёт — версия класса в журнале
1336
1110
  for d in select x.* from @SCHEMA@.entity x
1337
1111
  where x.class = 'Class' and x.id <> n.id
@@ -1342,43 +1116,18 @@ begin
1342
1116
  if prep.data is distinct from d.data or prep.validator::text is distinct from d.validator::text then
1343
1117
  perform @SCHEMA@.sys_update(d.id, prep.data, d.links, prep.validator, 'update');
1344
1118
  end if;
1345
- -- потомок становится источником вместе с классом (смена parent; ревью коммита этапа 4б) — то же право READ на его
1346
- -- строки по новым предкам (до правки проверил class_admit). class_evolve зовёт только триггер правки приложения;
1347
- -- потомков класса System у арендаторов план прав сессии System не видит, их строки считают агрегаты System для
1348
- -- целей того же арендатора
1349
- if d.tenant = n.tenant then
1350
- perform @SCHEMA@.agg_source_admit(d.data->>'name', @SCHEMA@.agg_new_mark(d.data->'resolved', prep.data->'resolved'));
1351
- end if;
1352
- -- и описавшему агрегат — в объёме описания (agg_author_admit сам пропускает класс арендатора под агрегатом System)
1353
- perform @SCHEMA@.agg_author_admit(d.data->>'name', d.tenant, d.data->'resolved', prep.data->'resolved');
1354
1119
  classes := classes || (d.data->>'name');
1355
- if d.data->'resolved'->'aggregate' is distinct from prep.data->'resolved'->'aggregate' then
1356
- agg_names := agg_names || @SCHEMA@.agg_of(d.data->'resolved') || @SCHEMA@.agg_of(prep.data->'resolved');
1357
- agg_own := agg_own || (d.data->>'name');
1358
- end if;
1359
- agg_other := agg_other || @SCHEMA@.agg_marked(d.data->'resolved', prep.data->'resolved');
1360
1120
  end loop;
1361
- -- агрегаты семейства изменились: метки источников заново и поле у всех целей — из живых источников (строки
1362
- -- проверит ужесточение ниже); у источника сменились предки — заново цели, которые его считают
1363
- if cardinality(agg_names) > 0 then
1364
- perform @SCHEMA@.agg_mark(n.tenant, agg_names);
1365
- end if;
1366
- if cardinality(agg_own) > 0 then
1367
- perform @SCHEMA@.agg_refresh(agg_own, n.tenant, false);
1368
- end if;
1369
- if cardinality(agg_other) > 0 then
1370
- perform @SCHEMA@.agg_refresh(agg_other, n.tenant, true);
1371
- end if;
1372
1121
  if not @SCHEMA@.class_change_safe(o.data->'resolved', n.data->'resolved') then
1373
1122
  -- ужесточение проверяет все строки семейства, в том числе невидимые актору, и отвечает их числом: пробное
1374
1123
  -- ужесточение (maximum, enum, pattern…) было оракулом по чужим строкам (доработки после точки Б). Нужно право READ
1375
1124
  -- без условий на все строки класса и потомков своего арендатора; строки других арендаторов (класс System) —
1376
1125
  -- уровень установки, их число остаётся в others
1377
- perform @SCHEMA@.class_rows_admit(n.data->>'name', n.tenant, array['READ'], format('ужесточение класса %s', n.data->>'name'));
1126
+ perform @SCHEMA@.class_rows_admit(n.data->>'name', n.tenant, array['READ'], 'tighten');
1378
1127
  conflicts := @SCHEMA@.family_conflicts(classes, n.tenant, o.data->'resolved'->'ancestors' is distinct from n.data->'resolved'->'ancestors');
1379
1128
  if conflicts <> '[]'::jsonb then
1380
1129
  select * into c from @SCHEMA@.ctx();
1381
- perform @SCHEMA@.lt_fail('tightening_conflict', format('новому описанию класса %s не соответствуют строки', n.data->>'name'),
1130
+ perform @SCHEMA@.lt_fail('sql-write.tightening_mismatch', jsonb_build_object('cls', n.data->>'name'),
1382
1131
  @SCHEMA@.visible_detail(conflicts, c.tenant));
1383
1132
  end if;
1384
1133
  end if;
@@ -1391,7 +1140,7 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
1391
1140
  as $$
1392
1141
  begin
1393
1142
  if current_setting('transaction_isolation') <> 'read committed' then
1394
- perform @SCHEMA@.lt_fail('isolation_level', 'удаление разрешено только в read committed');
1143
+ perform @SCHEMA@.lt_fail('sql-write.delete_isolation');
1395
1144
  end if;
1396
1145
  return null;
1397
1146
  end $$;
@@ -1401,14 +1150,14 @@ end $$;
1401
1150
  -- после него, поэтому сессии гаснут с сигналом session:<хэш> здесь: процессы сбрасывают кэш ответов этих сессий.
1402
1151
  -- Срабатывает для всех ролей, в том числе владельца: аккаунты удаляют и функции владельца — каскад trg_entity_ad и
1403
1152
  -- account_purge. Строка Account у System не удаляется: ей принадлежат служебные строки secret (KEY — ключ прав и
1404
- -- сигналов, LOCK — строки-замки, PROJECTOR — чекпоинты проекторов), которые каскад стёр бы, а без них установка не пишет.
1153
+ -- сигналов, LOCK — строки-замки), которые каскад стёр бы, а без них установка не пишет.
1405
1154
  create or replace function @SCHEMA@.trg_entity_bd_account() returns trigger
1406
1155
  language plpgsql security definer
1407
1156
  set search_path = pg_catalog, @SCHEMA@, pg_temp
1408
1157
  as $$
1409
1158
  begin
1410
1159
  if OLD.id = '@SYSTEM@'::uuid then
1411
- perform @SCHEMA@.lt_fail('acl_denied', 'аккаунт System не удаляется: ему принадлежат служебные строки установки');
1160
+ perform @SCHEMA@.lt_fail('sql-write.system_account_delete');
1412
1161
  end if;
1413
1162
  perform @SCHEMA@.auth_kill(OLD.id, null);
1414
1163
  return OLD;
@@ -1447,10 +1196,10 @@ begin
1447
1196
  for r in select x.* from @SCHEMA@.entity x where x.owner = p_user and x.owner <> x.tenant and x.tenant = p_tenant
1448
1197
  order by x.id for update loop
1449
1198
  update @SCHEMA@.entity x
1450
- set owner = p_tenant, rev = x.rev + 1, prev_hash = x.hash,
1199
+ set owner = p_tenant, rev = x.rev + 1,
1451
1200
  at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
1452
1201
  author = coalesce(c.author, '@SYSTEM@'::uuid), agent = c.agent, op = 'update',
1453
- reason = nullif(current_setting('letopis.reason', true), ''), moved = null
1202
+ reason = nullif(current_setting('letopis.reason', true), '')
1454
1203
  where x.id = r.id
1455
1204
  returning x.* into r;
1456
1205
  perform @SCHEMA@.log_version(r);
@@ -1497,13 +1246,7 @@ begin
1497
1246
  dplan := @SCHEMA@.acl_plan('DELETE');
1498
1247
  wplan := @SCHEMA@.acl_plan('WRITE');
1499
1248
  if exists (select 1 from @SCHEMA@.entity x join gone g on g.id = x.id) then
1500
- perform @SCHEMA@.lt_fail('recreated_in_statement', 'id удалён и создан заново одним оператором');
1501
- end if;
1502
- -- строки производного класса удаляет только проектор (каскад функции владельца сюда не доходит)
1503
- select d.class into denied from (select distinct g.class, g.tenant from gone g) d
1504
- where (@SCHEMA@.class_row(d.class, d.tenant)).data->'resolved' ? 'derivedBy' limit 1;
1505
- if denied is not null then
1506
- perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки удаляет проектор', denied));
1249
+ perform @SCHEMA@.lt_fail('sql-write.recreated_in_statement');
1507
1250
  end if;
1508
1251
  -- удаление класса (хвост ревью безопасности доработок после 1.2.0): проверки ниже — нет строк, потомков и истории —
1509
1252
  -- не видят строку, которую писатель ещё не зафиксировал, и она осталась бы без класса, а с ней история удалённого
@@ -1512,25 +1255,7 @@ begin
1512
1255
  for cl in select g.* from gone g where g.class = 'Class' order by @SCHEMA@.family_name(g.tenant, g.data), g.id loop
1513
1256
  perform @SCHEMA@.family_lock(cl, true);
1514
1257
  end loop;
1515
- 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
1516
- -- производный класс удаляет только администратор (class_admit)
1517
- if r.derived and not @SCHEMA@.letopis_allowed('letopis.projector') then
1518
- perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)', r.name));
1519
- end if;
1520
- -- класс-источник агрегата (автоматическое ревью коммита 5290be1): пока его называет агрегат класса того же
1521
- -- арендатора, класс не удаляется — иначе класс с этим именем завёл бы потом другой пользователь, и право READ
1522
- -- описавшего агрегат на его строки уже не проверить (класс of проверяется только при описании агрегата). Агрегаты
1523
- -- классов System описывает системный администратор: он читает всё
1524
- if r.tenant <> '@SYSTEM@'::uuid then
1525
- select x.data->>'name' || '.' || a.key into denied
1526
- from @SCHEMA@.entity x cross join lateral jsonb_each(x.data->'resolved'->'aggregate') a
1527
- where x.class = 'Class' and x.tenant = r.tenant and x.data->'resolved' ? 'aggregate' and a.value->>'of' = r.name
1528
- order by 1 limit 1;
1529
- if denied is not null then
1530
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s — источник агрегата %s: сначала уберите агрегат из описания цели',
1531
- r.name, denied));
1532
- end if;
1533
- end if;
1258
+ for r in select g.id, g.tenant, g.data->>'name' as name from gone g where g.class = 'Class' loop
1534
1259
  -- у класса System — строки и потомки любого арендатора, кроме арендаторов со своим одноимённым классом: их строки и
1535
1260
  -- потомки относятся к своему классу (class_row; ревью безопасности №3)
1536
1261
  own := case when r.tenant = '@SYSTEM@'::uuid
@@ -1541,24 +1266,21 @@ begin
1541
1266
  where x.class = r.name and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid and x.tenant <> all (own)))
1542
1267
  or exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.data->>'parent' = r.name
1543
1268
  and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid and x.tenant <> all (own))) then
1544
- perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть строки или потомки', r.name));
1269
+ perform @SCHEMA@.lt_fail('sql-write.class_not_empty', jsonb_build_object('cls', r.name));
1545
1270
  end if;
1546
1271
  -- и история строк (остаток Н2; доработки после точки Б): удалённый класс с историей, созданный заново с другими
1547
- -- предками или алиасом, подставил бы её под чужие правила — сначала purge() удалённых строк
1272
+ -- предками или алиасом, подставил бы её под чужие правила — класс с историей строк не удаляется
1548
1273
  if @SCHEMA@.class_has_history(r.name, r.tenant) then
1549
- perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть история строк в журнале: сначала стереть её purge() удалённых строк', r.name));
1274
+ perform @SCHEMA@.lt_fail('sql-write.class_has_history', jsonb_build_object('cls', r.name));
1550
1275
  end if;
1551
1276
  end loop;
1552
- -- удалена цель агрегатов (план 1.1, §2.4): метки её источников — уже без неё
1553
- perform @SCHEMA@.agg_mark(g.tenant, @SCHEMA@.agg_of(g.data->'resolved'))
1554
- from gone g where g.class = 'Class' and g.data->'resolved' ? 'aggregate';
1555
1277
  v_author := coalesce(c.author, '@SYSTEM@'::uuid);
1556
1278
  -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень, в порядке id: без
1557
1279
  -- order by порядок seq (а с ним итог delete() и время at) зависел от плана соединения и менялся от прогона к
1558
1280
  -- прогону; время now_ts() (volatile) планировщик вычисляет уже после сортировки
1559
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
1281
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason)
1560
1282
  select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
1561
- greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash, '[]'::jsonb
1283
+ greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason
1562
1284
  from gone g
1563
1285
  join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
1564
1286
  where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
@@ -1589,9 +1311,9 @@ begin
1589
1311
  where x.id in (select y.id from @SCHEMA@.entity y where y.id = any (casc) order by y.id for update)
1590
1312
  returning x.*
1591
1313
  ), t as (
1592
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
1314
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason)
1593
1315
  select d.id, d.rev + 1, d.class, d.cv, d.tenant, d.owner, d.links, d.data, d.tags,
1594
- greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash, '[]'::jsonb
1316
+ greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason
1595
1317
  from d
1596
1318
  join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
1597
1319
  where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
@@ -1605,8 +1327,10 @@ begin
1605
1327
  -- класс отказавшей строки называется, только если актор её видит (ревью безопасности №3)
1606
1328
  if drows is not null then
1607
1329
  denied := @SCHEMA@.visible_class(drows);
1608
- perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права DELETE на класс %s в каскаде удаления', denied)
1609
- else 'нет права DELETE на строки в каскаде удаления' end);
1330
+ if denied is not null then
1331
+ perform @SCHEMA@.lt_fail('sql-write.cascade_delete_denied', jsonb_build_object('cls', denied));
1332
+ end if;
1333
+ perform @SCHEMA@.lt_fail('sql-write.cascade_delete_denied_hidden');
1610
1334
  end if;
1611
1335
  mems := mems || lvl_mems;
1612
1336
  classes := classes || lvl_classes;
@@ -1614,9 +1338,10 @@ begin
1614
1338
  -- unset: снять конец, новая версия
1615
1339
  for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
1616
1340
  if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
1617
- perform @SCHEMA@.lt_fail('acl_denied', case when @SCHEMA@.visible_class(jsonb_build_array(to_jsonb(r))) is not null
1618
- then format('нет права WRITE на класс %s: снятие конца при удалении', r.class)
1619
- else 'нет права WRITE на ссылающуюся строку: снятие конца при удалении' end);
1341
+ if @SCHEMA@.visible_class(jsonb_build_array(to_jsonb(r))) is not null then
1342
+ perform @SCHEMA@.lt_fail('sql-write.unset_write_denied', jsonb_build_object('cls', r.class));
1343
+ end if;
1344
+ perform @SCHEMA@.lt_fail('sql-write.unset_write_denied_hidden');
1620
1345
  end if;
1621
1346
  newlinks := '{}'::jsonb;
1622
1347
  for k in select key, value from jsonb_each(r.links) loop
@@ -1647,7 +1372,7 @@ begin
1647
1372
  select coalesce(jsonb_agg(jsonb_build_object('id', x.id, 'class', x.class, 'tenant', x.tenant)), '[]'::jsonb) into blockers
1648
1373
  from (select x.id, x.class, x.tenant from @SCHEMA@.entity x where x.ends && all_ids order by x.id limit 1000) x;
1649
1374
  if blockers <> '[]'::jsonb then
1650
- perform @SCHEMA@.lt_fail('delete_restricted', 'на удаляемые строки ссылаются другие строки (onDelete: restrict)',
1375
+ perform @SCHEMA@.lt_fail('sql-write.delete_restricted', '{}'::jsonb,
1651
1376
  @SCHEMA@.visible_detail(blockers, c.tenant));
1652
1377
  end if;
1653
1378
  return null;
@@ -1717,9 +1442,7 @@ create or replace trigger entity_bi before insert on @SCHEMA@.entity
1717
1442
  for each row when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bi();
1718
1443
  create or replace trigger entity_bu before update on @SCHEMA@.entity
1719
1444
  for each row when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bu();
1720
- -- до ревизии 2 журнал писал строковый триггер entity_ai после insert или update
1721
- drop trigger if exists entity_ai on @SCHEMA@.entity;
1722
- create trigger entity_ai after insert on @SCHEMA@.entity referencing new table as added
1445
+ create or replace trigger entity_ai after insert on @SCHEMA@.entity referencing new table as added
1723
1446
  for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ai();
1724
1447
  create or replace trigger entity_au after update on @SCHEMA@.entity referencing old table as old_rows new table as new_rows
1725
1448
  for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_au();
@@ -1739,192 +1462,6 @@ alter table @SCHEMA@.entity enable always trigger entity_bd;
1739
1462
  alter table @SCHEMA@.entity enable always trigger entity_bd_account;
1740
1463
  alter table @SCHEMA@.entity enable always trigger entity_ad;
1741
1464
 
1742
- -- ========================= смена класса с ключом =========================
1743
-
1744
- -- Замена id цели во всех концах.
1745
- create or replace function @SCHEMA@.links_replace(p_links jsonb, p_old uuid, p_new uuid) returns jsonb
1746
- language sql immutable parallel safe
1747
- return (
1748
- select coalesce(jsonb_object_agg(k.key, case jsonb_typeof(k.value)
1749
- when 'array' then coalesce((select jsonb_agg(case when x = p_old::text then to_jsonb(p_new::text) else to_jsonb(x) end)
1750
- from jsonb_array_elements_text(k.value) x), '[]'::jsonb)
1751
- else case when k.value #>> '{}' = p_old::text then to_jsonb(p_new::text) else k.value end end), '{}'::jsonb)
1752
- from jsonb_each(p_links) k
1753
- );
1754
-
1755
- -- Перенос строки на новый id (смена класса с ключом, смена ключа): новая строка продолжает журнал
1756
- -- нового id и ссылается на старый (moved), ссылки переводятся — если переведённый конец входит
1757
- -- в ключ ссылающейся строки, её id тоже меняется, и перевод идёт дальше рекурсивно; старая строка
1758
- -- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая функция; p_plan — план WRITE
1759
- -- актора (reclass, rekey, rekey_class): каждая строка, чьи ссылки перевод переписывает, на всех уровнях рекурсии должна
1760
- -- им проходить (доработки после точки Б: прежде проверялся только первый уровень); null — путь владельца.
1761
- -- Агрегаты (план 1.1, §2.4): перенос — несколько операторов (новая строка, перевод ссылок, удаление старой), и
1762
- -- триггер агрегатов на это время только копит их вклады (agg_hold_begin), а в конце применяет одним итогом: сумма
1763
- -- не проходит через промежуточные значения, новая цель считается целиком из живых источников.
1764
- drop function if exists @SCHEMA@.move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text);
1765
- 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,
1766
- p_plan jsonb default null)
1767
- returns @SCHEMA@.entity
1768
- language plpgsql volatile
1769
- set search_path = pg_catalog, @SCHEMA@, pg_temp
1770
- as $$
1771
- declare
1772
- c record;
1773
- last record;
1774
- ts timestamptz;
1775
- n @SCHEMA@.entity;
1776
- r @SCHEMA@.entity;
1777
- nl jsonb;
1778
- rid uuid;
1779
- held boolean := @SCHEMA@.agg_hold_begin('acc');
1780
- begin
1781
- select * into c from @SCHEMA@.ctx();
1782
- 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;
1783
- ts := greatest(@SCHEMA@.now_ts(), last.at + interval '1 microsecond');
1784
- insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator)
1785
- values (p_new_id, coalesce(last.rev, 0) + 1, p_class, coalesce((@SCHEMA@.class_row(p_class, o.tenant)).rev, 0), o.tenant, o.owner,
1786
- p_links, p_data, o.tags, ts, ts, coalesce(c.author, '@SYSTEM@'::uuid), c.agent, p_op,
1787
- nullif(current_setting('letopis.reason', true), ''), o.id, last.hash, null)
1788
- returning * into n;
1789
- perform @SCHEMA@.log_version(n);
1790
- 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
1791
- -- строки производного класса пишет только проектор: перевод их ссылок мимо него оставил бы проекцию
1792
- -- несогласованной (ревью безопасности №3, №3)
1793
- if (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved' ? 'derivedBy' then
1794
- perform @SCHEMA@.lt_fail('derived_class',
1795
- case when p_plan is null or @SCHEMA@.visible_class(jsonb_build_array(to_jsonb(r))) is not null
1796
- then format('на строку ссылается строка производного класса %s: её пишет проектор', r.class)
1797
- else 'на строку ссылается строка производного класса: её пишет проектор' end);
1798
- end if;
1799
- 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
1800
- perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на ссылающиеся строки');
1801
- end if;
1802
- nl := @SCHEMA@.links_replace(r.links, o.id, p_new_id);
1803
- rid := coalesce(@SCHEMA@.key_id((@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved', r.class, r.tenant, r.data, nl), r.id);
1804
- if rid <> r.id then
1805
- perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey', p_plan);
1806
- else
1807
- perform @SCHEMA@.sys_update(r.id, r.data, nl, r.validator, 'update');
1808
- end if;
1809
- end loop;
1810
- delete from @SCHEMA@.entity x where x.id = o.id returning x.* into r;
1811
- -- надгробие всегда вида delete (по нему срез на момент отличает удалённый id), moved — новый id
1812
- perform @SCHEMA@.tombstone(r, 'delete', p_new_id);
1813
- if held then
1814
- perform @SCHEMA@.agg_hold_end(true);
1815
- end if;
1816
- return n;
1817
- end $$;
1818
-
1819
- -- Смена класса (этап 1, п. 9): у класса с ключом id вычисляется заново для нового класса, ссылки
1820
- -- переводятся в той же транзакции, журнал связывает старый и новый id. У класса без ключа id
1821
- -- сохраняется. Только read committed; на строку сначала берётся for update.
1822
- create or replace function @SCHEMA@.reclass(p_id uuid, p_class text, p_data jsonb default null) returns uuid
1823
- language plpgsql volatile security definer
1824
- set search_path = pg_catalog, @SCHEMA@, pg_temp
1825
- as $$
1826
- declare
1827
- c record;
1828
- o @SCHEMA@.entity;
1829
- cls @SCHEMA@.entity;
1830
- res jsonb;
1831
- d jsonb;
1832
- bad jsonb;
1833
- lk record;
1834
- nid uuid;
1835
- inc jsonb;
1836
- rd jsonb;
1837
- wplan jsonb;
1838
- denied text;
1839
- n_denied int;
1840
- begin
1841
- select * into c from @SCHEMA@.ctx();
1842
- if c.account is null then
1843
- perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
1844
- end if;
1845
- if current_setting('transaction_isolation') <> 'read committed' then
1846
- perform @SCHEMA@.lt_fail('isolation_level', 'смена класса разрешена только в read committed');
1847
- end if;
1848
- rd := @SCHEMA@.acl_actor('READ');
1849
- wplan := @SCHEMA@.acl_plan('WRITE');
1850
- select x.* into o from @SCHEMA@.entity x where x.id = p_id and x.tenant = c.tenant for update;
1851
- if o.id is null or not @SCHEMA@.row_visible(o.tenant, o.class, o.id, o.owner, o.tags, o.data, o.links, c.tenant, c.account, rd->'plan') then
1852
- perform @SCHEMA@.lt_fail('target_not_found', 'цель не найдена');
1853
- end if;
1854
- if not @SCHEMA@.acl_row_ok(wplan, o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
1855
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', o.class));
1856
- end if;
1857
- -- строка уходит из своего класса — как удаление из него: нужно право DELETE на прежнюю строку (доработки после
1858
- -- точки Б; иначе с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там)
1859
- 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
1860
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на строку класса %s: смена класса убирает её из класса', o.class));
1861
- end if;
1862
- -- строки прав и классов функция не трогает
1863
- if o.class in ('Class', 'Account', 'member', 'Resource', 'rule') or p_class in ('Class', 'Account', 'member', 'Resource', 'rule') then
1864
- perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
1865
- end if;
1866
- cls := @SCHEMA@.class_row(p_class, o.tenant);
1867
- if cls.id is null then
1868
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', p_class));
1869
- end if;
1870
- perform @SCHEMA@.family_lock(cls, false);
1871
- cls := @SCHEMA@.class_row(p_class, o.tenant);
1872
- if cls.id is null then
1873
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', p_class));
1874
- end if;
1875
- res := cls.data->'resolved';
1876
- if coalesce((res->>'abstract')::boolean, false) then
1877
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
1878
- end if;
1879
- if res ? 'derivedBy' or (@SCHEMA@.class_row(o.class, o.tenant)).data->'resolved' ? 'derivedBy' then
1880
- perform @SCHEMA@.lt_fail('derived_class', 'строки производного класса пишет только его проектор');
1881
- end if;
1882
- d := coalesce(p_data, o.data);
1883
- -- агрегатные поля нового класса считает база (план 1.1, §2.4): в данных их не передают; прежнее число остаётся
1884
- -- до пересчёта, иначе 0
1885
- if res ? 'aggregate' then
1886
- if p_data ?| @SCHEMA@.agg_fields(res) then
1887
- perform @SCHEMA@.lt_fail('aggregate_field', format('агрегатные поля класса %s считает база — в данных reclass их не передают', p_class));
1888
- end if;
1889
- d := @SCHEMA@.agg_zero(res, d);
1890
- end if;
1891
- d := @SCHEMA@.apply_defaults(res->'schema', d);
1892
- bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
1893
- if bad <> '[]'::jsonb then
1894
- perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', p_class), jsonb_build_object('issues', bad));
1895
- end if;
1896
- select * into lk from @SCHEMA@.check_links(res, o.links, o.tenant, true, rd);
1897
- if lk.code is not null then
1898
- perform @SCHEMA@.lt_fail(lk.code, lk.msg);
1899
- end if;
1900
- inc := @SCHEMA@.incoming_conflicts(o.id, p_class, o.tenant);
1901
- if inc <> '[]'::jsonb then
1902
- perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', p_class),
1903
- @SCHEMA@.visible_detail(inc, c.tenant));
1904
- end if;
1905
- nid := coalesce(@SCHEMA@.key_id(res, p_class, o.tenant, d, lk.links), o.id);
1906
- if not @SCHEMA@.acl_row_ok(wplan, p_class, nid, o.owner, o.tenant, o.tags, d, lk.links) then
1907
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s', p_class));
1908
- end if;
1909
- if nid <> o.id then
1910
- -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE (глубже первого уровня — move_row);
1911
- -- класс такой строки называется, только если актор её видит
1912
- 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')),
1913
- count(*) into denied, n_denied from @SCHEMA@.entity x
1914
- 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);
1915
- if n_denied > 0 then
1916
- perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права WRITE на ссылающиеся строки класса %s', denied)
1917
- else 'нет права WRITE на ссылающиеся строки' end);
1918
- end if;
1919
- end if;
1920
- if nid = o.id then
1921
- perform @SCHEMA@.sys_update(o.id, d, lk.links, null, 'reclass', p_class);
1922
- else
1923
- perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass', wplan);
1924
- end if;
1925
- return nid;
1926
- end $$;
1927
-
1928
1465
  -- ========================= merge, inc, links_patch =========================
1929
1466
 
1930
1467
  -- Правка концов слотами (этап 4, п. 4): .Роль.set(), .unset(), а для множественных концов .add()
@@ -1972,7 +1509,10 @@ begin
1972
1509
  end if;
1973
1510
  res := jsonb_set(res, array[role], cur);
1974
1511
  else
1975
- raise exception using errcode = 'LT003', message = 'letopis: invalid_data: links_patch: неизвестная операция ' || coalesce(o->>'op', 'null');
1512
+ raise exception using errcode = 'LT003', message = 'letopis: invalid_data: '
1513
+ || @SCHEMA@.lt_text('sql-write.links_patch_op', jsonb_build_object('op', coalesce(o->>'op', 'null'))),
1514
+ detail = jsonb_build_object('key', 'sql-write.links_patch_op', 'params', jsonb_build_object('op', coalesce(o->>'op', 'null')),
1515
+ 'nodetail', true)::text;
1976
1516
  end case;
1977
1517
  end loop;
1978
1518
  return res;
@@ -2013,39 +1553,46 @@ declare
2013
1553
  cur jsonb := data;
2014
1554
  v jsonb;
2015
1555
  i int;
2016
- fail text;
1556
+ fail text; -- ключ каталога отказа (sql-write.inc_*)
1557
+ p jsonb := '{}'::jsonb;
2017
1558
  begin
2018
1559
  if path is null or cardinality(path) = 0 or delta is null then
2019
- fail := 'inc: нужны путь и величина';
1560
+ fail := 'sql-write.inc_args';
2020
1561
  else
2021
1562
  for i in 1 .. cardinality(path) - 1 loop
2022
1563
  if jsonb_typeof(cur) is distinct from 'object' or not cur ? path[i] then
2023
- fail := format('inc: путь %s идёт через отсутствующий объект', array_to_string(path, '.'));
1564
+ fail := 'sql-write.inc_path_missing';
2024
1565
  exit;
2025
1566
  end if;
2026
1567
  cur := cur->path[i];
2027
1568
  end loop;
2028
1569
  if fail is null and jsonb_typeof(cur) is distinct from 'object' then
2029
- fail := format('inc: путь %s идёт не через объект', array_to_string(path, '.'));
1570
+ fail := 'sql-write.inc_path_not_object';
2030
1571
  end if;
2031
1572
  if fail is null then
2032
1573
  v := cur->path[cardinality(path)];
2033
1574
  if v is null then
2034
1575
  if start is null then
2035
- fail := format('inc: поля %s нет — задайте start', array_to_string(path, '.'));
1576
+ fail := 'sql-write.inc_no_field';
2036
1577
  else
2037
1578
  return jsonb_set(data, path, to_jsonb(start + delta), true);
2038
1579
  end if;
2039
1580
  elsif jsonb_typeof(v) <> 'number' then
2040
- fail := format('inc: в поле %s не число', array_to_string(path, '.'));
1581
+ fail := 'sql-write.inc_not_number';
2041
1582
  else
2042
1583
  return jsonb_set(data, path, to_jsonb((v #>> '{}')::numeric + delta));
2043
1584
  end if;
2044
1585
  end if;
2045
1586
  end if;
2046
- raise exception using errcode = 'LT003', message = 'letopis: invalid_data: ' || fail,
1587
+ -- путь — параметр всех текстов, кроме inc_args; нарушение в detail.issues — тот же русский текст с его key и params
1588
+ -- (их убирает fromDbError)
1589
+ if fail <> 'sql-write.inc_args' then
1590
+ p := jsonb_build_object('path', array_to_string(path, '.'));
1591
+ end if;
1592
+ raise exception using errcode = 'LT003', message = 'letopis: invalid_data: ' || @SCHEMA@.lt_text(fail, p),
2047
1593
  detail = jsonb_build_object('issues', jsonb_build_array(jsonb_build_object(
2048
- 'path', to_jsonb(coalesce(path, '{}')), 'keyword', 'inc', 'message', fail)))::text;
1594
+ 'path', to_jsonb(coalesce(path, '{}')), 'keyword', 'inc', 'message', @SCHEMA@.lt_text(fail, p), 'key', fail, 'params', p)),
1595
+ 'key', fail, 'params', p)::text;
2049
1596
  end $$;
2050
1597
 
2051
1598
  do $$
@@ -2054,8 +1601,7 @@ begin
2054
1601
  foreach f in array array[
2055
1602
  '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)',
2056
1603
  '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)',
2057
- 'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text, text)', 'tombstone(@SCHEMA@.entity, text, uuid)',
2058
- 'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text, jsonb)', 'reclass(uuid, text, jsonb)',
1604
+ 'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text)', 'tombstone(@SCHEMA@.entity)',
2059
1605
  'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'visible_class(jsonb)', 'class_change_safe(jsonb, jsonb)',
2060
1606
  'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_acl_admit(text, uuid)', 'class_rows_admit(text, uuid, text[], text)', 'class_has_history(text, uuid)', 'class_orphan_history(text, uuid)', 'class_admit(jsonb, uuid)',
2061
1607
  '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)',
@@ -2064,8 +1610,6 @@ begin
2064
1610
  execute format('revoke all on function @SCHEMA@.%s from public', f);
2065
1611
  end loop;
2066
1612
  end $$;
2067
- -- сырой SQL пишет теми же merge, inc и links_patch, что и библиотека; verify() — в 90-time.sql
2068
- -- до этапа 6 verify() был без параметров
2069
- drop function if exists @SCHEMA@.verify();
1613
+ -- сырой SQL пишет теми же merge, inc и links_patch, что и библиотека
2070
1614
  grant execute on function @SCHEMA@.merge(jsonb, jsonb), @SCHEMA@.inc(jsonb, text[], numeric, numeric), @SCHEMA@.links_patch(jsonb, jsonb),
2071
- @SCHEMA@.reserved_names(), @SCHEMA@.reclass(uuid, text, jsonb), @SCHEMA@.delete_preview(uuid[]) to @APP@;
1615
+ @SCHEMA@.reserved_names(), @SCHEMA@.delete_preview(uuid[]) to @APP@;