letopis 1.4.1 → 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 (113) hide show
  1. package/AGENT-CHEATSHEET.md +329 -278
  2. package/CHANGELOG.md +558 -1
  3. package/README.md +606 -1037
  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 -175
  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 +33 -10
  12. package/dist/chain.d.ts +19 -41
  13. package/dist/chain.js +169 -201
  14. package/dist/cli.js +51 -88
  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 -71
  21. package/dist/index.js +245 -130
  22. package/dist/internal.d.ts +12 -0
  23. package/dist/internal.js +5 -0
  24. package/dist/jsonschema.js +18 -17
  25. package/dist/load.d.ts +20 -12
  26. package/dist/load.js +225 -186
  27. package/dist/mcp.d.ts +5 -7
  28. package/dist/mcp.js +171 -215
  29. package/dist/messages/access.d.ts +90 -0
  30. package/dist/messages/access.js +113 -0
  31. package/dist/messages/api.d.ts +171 -0
  32. package/dist/messages/api.js +215 -0
  33. package/dist/messages/boot.d.ts +201 -0
  34. package/dist/messages/boot.js +256 -0
  35. package/dist/messages/codes.d.ts +172 -0
  36. package/dist/messages/codes.js +173 -0
  37. package/dist/messages/index.d.ts +95 -0
  38. package/dist/messages/index.js +118 -0
  39. package/dist/messages/mcp.d.ts +306 -0
  40. package/dist/messages/mcp.js +387 -0
  41. package/dist/messages/model.d.ts +233 -0
  42. package/dist/messages/model.js +283 -0
  43. package/dist/messages/query.d.ts +131 -0
  44. package/dist/messages/query.js +158 -0
  45. package/dist/messages/sql-access.d.ts +70 -0
  46. package/dist/messages/sql-access.js +87 -0
  47. package/dist/messages/sql-core.d.ts +150 -0
  48. package/dist/messages/sql-core.js +181 -0
  49. package/dist/messages/sql-time.d.ts +71 -0
  50. package/dist/messages/sql-time.js +88 -0
  51. package/dist/messages/sql-write.d.ts +246 -0
  52. package/dist/messages/sql-write.js +270 -0
  53. package/dist/messages/time.d.ts +23 -0
  54. package/dist/messages/time.js +29 -0
  55. package/dist/messages/types.d.ts +27 -0
  56. package/dist/messages/types.js +5 -0
  57. package/dist/messages/write.d.ts +405 -0
  58. package/dist/messages/write.js +509 -0
  59. package/dist/model.d.ts +73 -60
  60. package/dist/model.js +26 -29
  61. package/dist/ops.d.ts +35 -0
  62. package/dist/ops.js +101 -0
  63. package/dist/pglite.d.ts +0 -9
  64. package/dist/pglite.js +3 -1
  65. package/dist/registry.d.ts +3 -19
  66. package/dist/registry.js +3 -16
  67. package/dist/signals.d.ts +5 -9
  68. package/dist/signals.js +5 -9
  69. package/dist/sql.d.ts +13 -6
  70. package/dist/sql.js +176 -83
  71. package/dist/sync.d.ts +9 -3
  72. package/dist/sync.js +51 -21
  73. package/dist/tx.d.ts +26 -1
  74. package/dist/tx.js +44 -14
  75. package/dist/typed.d.ts +240 -45
  76. package/dist/types.d.ts +23 -91
  77. package/dist/types.js +12 -14
  78. package/dist/up.d.ts +53 -32
  79. package/dist/up.js +184 -73
  80. package/dist/uuid.js +5 -5
  81. package/dist/validate.d.ts +13 -1
  82. package/dist/validate.js +61 -29
  83. package/dist/watch.d.ts +11 -10
  84. package/dist/watch.js +33 -70
  85. package/dist/write.d.ts +9 -16
  86. package/dist/write.js +118 -187
  87. package/llms.txt +22 -25
  88. package/package.json +8 -11
  89. package/sql/10-core.sql +7 -50
  90. package/sql/15-errors.sql +64 -10
  91. package/sql/20-context.sql +7 -6
  92. package/sql/30-validate.sql +35 -27
  93. package/sql/40-class.sql +20 -114
  94. package/sql/50-acl.sql +8 -13
  95. package/sql/60-write.sql +220 -667
  96. package/sql/70-read.sql +20 -16
  97. package/sql/80-auth.sql +24 -27
  98. package/sql/90-time.sql +70 -859
  99. package/sql/95-seed.system.sql +7 -14
  100. package/sql/99-revision.sql +2 -1
  101. package/sql/README.md +32 -24
  102. package/sql/migrate/README.md +14 -0
  103. package/AGENT-CHEATSHEET.en.md +0 -485
  104. package/MIGRATION.md +0 -190
  105. package/README.en.md +0 -2859
  106. package/dist/import.d.ts +0 -185
  107. package/dist/import.js +0 -820
  108. package/dist/project.d.ts +0 -350
  109. package/dist/project.js +0 -1224
  110. package/sql/75-history.sql +0 -358
  111. package/sql/88-derived.sql +0 -1235
  112. package/sql/migrate/0006-changed-paths.sql +0 -3
  113. 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
@@ -475,26 +462,20 @@ declare
475
462
  begin
476
463
  select * into c from @SCHEMA@.ctx();
477
464
  update @SCHEMA@.entity x
478
- set class = coalesce(p_class, x.class), data = p_data, links = p_links, validator = p_validator,
479
- rev = x.rev + 1, prev_hash = x.hash,
465
+ set data = p_data, links = p_links, validator = p_validator,
466
+ rev = x.rev + 1,
480
467
  at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
481
468
  author = coalesce(c.author, '@SYSTEM@'::uuid), agent = c.agent, op = p_op,
482
- reason = nullif(current_setting('letopis.reason', true), ''), moved = null,
483
- 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)
484
471
  where x.id = p_id
485
472
  returning x.* into e;
486
- -- смена класса: агрегатные поля новой цели триггер после правки посчитал в эту же версию (план 1.1, §2.4) —
487
- -- в журнал идёт строка таблицы, а не returning
488
- if p_class is not null then
489
- select x.* into e from @SCHEMA@.entity x where x.id = p_id;
490
- end if;
491
473
  perform @SCHEMA@.log_version(e);
492
474
  return e;
493
475
  end $$;
494
476
 
495
- -- Надгробие: последний снимок удалённой строки следующей версией с видом delete; при переносе на
496
- -- новый id (reclass, rekey) moved ссылается на новый id.
497
- 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
498
479
  language plpgsql volatile
499
480
  set search_path = pg_catalog, @SCHEMA@, pg_temp
500
481
  as $$
@@ -503,17 +484,15 @@ declare
503
484
  begin
504
485
  select * into c from @SCHEMA@.ctx();
505
486
  e.rev := e.rev + 1;
506
- e.prev_hash := e.hash;
507
487
  e.at := greatest(@SCHEMA@.now_ts(), e.at + interval '1 microsecond');
508
488
  e.author := coalesce(c.author, '@SYSTEM@'::uuid);
509
489
  e.agent := c.agent;
510
- e.op := p_op;
490
+ e.op := 'delete';
511
491
  e.reason := nullif(current_setting('letopis.reason', true), '');
512
- e.moved := p_moved;
513
492
  perform @SCHEMA@.log_version(e);
514
493
  end $$;
515
494
 
516
- -- Входящие ссылки на строку p_id, роли которых не принимают класс p_class (смена класса, смена родителя).
495
+ -- Входящие ссылки на строку p_id, роли которых не принимают класс p_class (смена родителя класса).
517
496
  create or replace function @SCHEMA@.incoming_conflicts(p_id uuid, p_class text, p_tenant uuid) returns jsonb
518
497
  language sql stable
519
498
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -526,7 +505,7 @@ as $$
526
505
  and not @SCHEMA@.role_accepts(r.class, r.tenant, k.key, p_class, p_tenant)
527
506
  $$;
528
507
 
529
- -- Перечень видимых актору строк и число остальных — для ошибок restrict, смены класса, ужесточения.
508
+ -- Перечень видимых актору строк и число остальных — для ошибок restrict и ужесточения.
530
509
  -- Видимость — та же, что у чтения (row_visible с планом READ актора), а не только арендатор.
531
510
  create or replace function @SCHEMA@.visible_detail(p_rows jsonb, p_tenant uuid) returns jsonb
532
511
  language plpgsql stable
@@ -558,16 +537,13 @@ return (select min(r->>'class') from jsonb_array_elements(p_rows) r, (select @SC
558
537
  (x.a->>'tenant')::uuid, (x.a->>'account')::uuid, x.a->'plan'));
559
538
 
560
539
  -- Безопасно ли изменение описания класса (этап 1, п. 10): новые необязательные поля и концы,
561
- -- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
562
- -- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись),
563
- -- признака производного класса derivedBy и описания агрегатов aggregate (план 1.1, §2.6).
540
+ -- метаданные, алиас, history, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
541
+ -- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись).
564
542
  create or replace function @SCHEMA@.class_change_safe(o jsonb, n jsonb) returns boolean
565
543
  language sql immutable
566
544
  return
567
545
  o->'key' = n->'key'
568
546
  and o->'ancestors' = n->'ancestors'
569
- and o->'derivedBy' is not distinct from n->'derivedBy'
570
- and o->'aggregate' is not distinct from n->'aggregate'
571
547
  and (o->'abstract' = n->'abstract' or n->>'abstract' = 'false')
572
548
  and o->'schema'->'anyOf' is not distinct from n->'schema'->'anyOf'
573
549
  and o->'schema'->'oneOf' is not distinct from n->'schema'->'oneOf'
@@ -581,7 +557,8 @@ return
581
557
  and (coalesce((e.value->>'required')::boolean, false) or coalesce((e.value->>'min')::int, 0) > 0));
582
558
 
583
559
  -- Строки семейства, которые не проходят текущие описания своих классов (после ужесточения).
584
- -- Не больше 100 строк; p_incoming — проверять ещё и входящие ссылки (смена родителя).
560
+ -- Не больше 100 строк; p_incoming — проверять ещё и входящие ссылки (смена родителя). У строки — problem (русский текст
561
+ -- по ключу каталога) и сами key и params: библиотека их убирает (fromDbError).
585
562
  create or replace function @SCHEMA@.family_conflicts(p_classes text[], p_tenant uuid, p_incoming boolean) returns jsonb
586
563
  language plpgsql volatile
587
564
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -593,6 +570,7 @@ declare
593
570
  bad jsonb;
594
571
  lk record;
595
572
  problem text;
573
+ msg jsonb; -- ключ и параметры problem
596
574
  inc jsonb;
597
575
  outv jsonb := '[]'::jsonb;
598
576
  a jsonb := @SCHEMA@.acl_actor('READ');
@@ -605,18 +583,26 @@ begin
605
583
  cls := @SCHEMA@.class_row(r.class, r.tenant);
606
584
  res := cls.data->'resolved';
607
585
  problem := null;
586
+ msg := null;
608
587
  if coalesce((res->>'abstract')::boolean, false) then
609
- problem := 'класс абстрактный';
588
+ problem := @SCHEMA@.lt_text('sql-write.problem_abstract');
589
+ msg := jsonb_build_object('key', 'sql-write.problem_abstract', 'params', '{}'::jsonb);
610
590
  else
611
591
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', r.data);
612
592
  if bad <> '[]'::jsonb then
613
- 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));
614
598
  else
615
599
  select * into lk from @SCHEMA@.check_links(res, r.links, r.tenant, false, null);
616
- if lk.code is not null then
617
- 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));
618
603
  elsif coalesce(@SCHEMA@.key_id(res, r.class, r.tenant, r.data, r.links), r.id) <> r.id then
619
- problem := 'id не соответствует ключу класса';
604
+ problem := @SCHEMA@.lt_text('sql-write.problem_key');
605
+ msg := jsonb_build_object('key', 'sql-write.problem_key', 'params', '{}'::jsonb);
620
606
  end if;
621
607
  end if;
622
608
  end if;
@@ -631,13 +617,14 @@ begin
631
617
  from jsonb_array_elements(inc) i(v)
632
618
  left join @SCHEMA@.entity x on x.id = (i.v->>'id')::uuid
633
619
  order by seen desc, i.v->>'id' limit 1;
634
- problem := case when vis
635
- then format('на строку ссылается роль %s строки %s, которая не принимает класс', ref->>'role', ref->>'id')
636
- 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');
637
624
  end if;
638
625
  end if;
639
626
  if problem is not null then
640
- 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));
641
628
  exit when jsonb_array_length(outv) >= 100;
642
629
  end if;
643
630
  end loop;
@@ -651,26 +638,26 @@ language sql immutable parallel safe
651
638
  return array[@RESERVED_NAMES@]::text[];
652
639
 
653
640
  -- Правка класса p_class арендатора p_tenant меняет имена, по которым его строки находят правила прав: предков
654
- -- (parent), алиас или derivedBy (ревью безопасности №2). План прав берёт для класса имена предков и их алиасы
641
+ -- (parent) или алиас (ревью безопасности №2). План прав берёт для класса имена предков и их алиасы
655
642
  -- (acl_plan_for), поэтому строки класса и его потомков попали бы под правила READ, WRITE и DELETE нового предка или
656
643
  -- алиаса — право WRITE на Class открывало бы чужие строки. Актору нужно право READ, WRITE и DELETE без условий на
657
644
  -- все строки самого класса и каждого потомка своего арендатора, иначе acl_denied. Потомков у арендаторов класса
658
- -- System план прав сессии System не видит — они пропускаются, как у агрегатов (agg_source_admit). Проверка — до
645
+ -- System план прав сессии System не видит — они пропускаются. Проверка — до
659
646
  -- правки (class_admit): план видит прежние имена, по которым строки уже защищены.
660
647
  create or replace function @SCHEMA@.class_acl_admit(p_class text, p_tenant uuid) returns void
661
648
  language plpgsql stable
662
649
  set search_path = pg_catalog, @SCHEMA@, pg_temp
663
650
  as $$
664
651
  begin
665
- perform @SCHEMA@.class_rows_admit(p_class, p_tenant, array['READ', 'WRITE', 'DELETE'],
666
- format('класс %s меняет предков, алиас или derivedBy', p_class));
652
+ perform @SCHEMA@.class_rows_admit(p_class, p_tenant, array['READ', 'WRITE', 'DELETE'], 'reparent');
667
653
  end $$;
668
654
 
669
655
  -- Право актора на все строки класса p_class и каждого его потомка арендатора p_tenant: по каждой операции p_ops —
670
- -- без условий, иначе acl_denied с текстом «<p_what>: нужно право <op> на все строки класса <класс>». Сам класс первым,
656
+ -- без условий, иначе acl_denied «<что меняется>: нужно право <op> на все строки класса <класс>». Что меняется — p_what,
657
+ -- по нему ключ каталога (план 2.0, этап 3, п. 3): reparent, history, tighten; иное — готовый текст. Сам класс первым,
671
658
  -- потомки — по глубине и имени; потомков у арендаторов класса System план сессии System не видит — они пропускаются.
672
- -- Зовут class_acl_admit (смена предков, алиаса, derivedBy), ужесточение (class_evolve, rekey_class) и смена политики
673
- -- хранения истории (class_admit; доработки после точки Б).
659
+ -- Зовут class_acl_admit (смена предков и алиаса), ужесточение (class_evolve) и смена history — включение и выключение
660
+ -- журнала (class_admit; доработки после точки Б).
674
661
  create or replace function @SCHEMA@.class_rows_admit(p_class text, p_tenant uuid, p_ops text[], p_what text) returns void
675
662
  language plpgsql stable
676
663
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -679,6 +666,7 @@ declare
679
666
  c text;
680
667
  op text;
681
668
  chk jsonb;
669
+ p jsonb;
682
670
  begin
683
671
  for c in select p_class
684
672
  union all
@@ -689,21 +677,27 @@ begin
689
677
  foreach op in array p_ops loop
690
678
  chk := @SCHEMA@.acl_check_data(c, op);
691
679
  if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
692
- 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;
693
687
  end if;
694
688
  end loop;
695
689
  end loop;
696
690
  end $$;
697
691
 
698
- -- Есть ли в журнале версии строк класса p_name арендатора p_tenant; записи purge, trim и reset — не версии (доработки
699
- -- после точки Б: удаление и пересоздание класса, остаток Н2 ревью №2). У класса System — строки любого арендатора, кроме
692
+ -- Есть ли в журнале версии строк класса p_name арендатора p_tenant (доработки после точки Б: удаление и пересоздание
693
+ -- класса, остаток Н2 ревью №2). У класса System — строки любого арендатора, кроме
700
694
  -- арендаторов со своим классом с тем же именем: их строки — строки своего класса, как их резолвит class_row (ревью
701
695
  -- безопасности №3: история такого класса не мешает администратору создать и удалить класс System)
702
696
  create or replace function @SCHEMA@.class_has_history(p_name text, p_tenant uuid) returns boolean
703
697
  language sql stable
704
698
  set search_path = pg_catalog, @SCHEMA@, pg_temp
705
699
  return exists (select 1 from @SCHEMA@.log l
706
- where l.class = p_name and l.op not in ('purge', 'trim', 'reset')
700
+ where l.class = p_name
707
701
  and (l.tenant = p_tenant
708
702
  or p_tenant = '@SYSTEM@'::uuid
709
703
  and l.tenant <> all (array(select x.tenant from @SCHEMA@.entity x
@@ -729,8 +723,8 @@ return exists (select 1 from @SCHEMA@.log t
729
723
  and @SCHEMA@.class_has_history(t.data->>'name', t.tenant));
730
724
 
731
725
  -- Проверки строки Class сверх метасхемы (этап 1, п. 6): признак system, имя системного класса,
732
- -- имена связей против методов цепочки, однозначность имени конца во всей схеме, право на агрегаты,
733
- -- алиас и право на смену предков, алиаса и derivedBy (ревью безопасности №2).
726
+ -- имена связей и хабов против методов цепочки, однозначность имени конца во всей схеме,
727
+ -- алиас и право на смену предков и алиаса (ревью безопасности №2).
734
728
  create or replace function @SCHEMA@.class_admit(p_data jsonb, p_tenant uuid, out data jsonb, out validator jsonpath)
735
729
  language plpgsql volatile
736
730
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -739,25 +733,19 @@ as $$
739
733
  declare
740
734
  e record;
741
735
  o record;
742
- dname text;
743
- drole text;
744
736
  tc @SCHEMA@.entity;
745
737
  prevd jsonb;
746
- src text;
747
- chk jsonb;
748
- m jsonb;
749
- dkid text;
750
738
  begin
751
739
  if p_tenant <> '@SYSTEM@'::uuid then
752
740
  if (p_data->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
753
- 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'));
754
742
  end if;
755
743
  if coalesce((p_data->>'system')::boolean, false) then
756
- perform @SCHEMA@.lt_fail('invalid_class', 'признак system задаёт только установка');
744
+ perform @SCHEMA@.lt_fail('sql-write.system_flag');
757
745
  end if;
758
746
  if exists (select 1 from @SCHEMA@.entity s
759
747
  where s.id = @SCHEMA@.v5_id('@SYSTEM@'::uuid, 'Class', array[p_data->>'name']) and s.class = 'Class') then
760
- 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'));
761
749
  end if;
762
750
  end if;
763
751
  -- родитель — под разделяемой блокировкой своего семейства (хвост ревью доработок после 1.2.0): удаление класса берёт
@@ -770,7 +758,12 @@ begin
770
758
  end if;
771
759
  select c.data, c.validator into data, validator from @SCHEMA@.class_prepare(p_data, p_tenant) c;
772
760
  if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
773
- 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'));
774
767
  end if;
775
768
  -- алиас — второе имя класса и для правил прав (ревью безопасности №2): совпади он с именем другого класса (в том
776
769
  -- числе Class и системных) или с чужим алиасом арендатора и System, правила одного класса действовали бы на строки
@@ -780,107 +773,36 @@ begin
780
773
  or exists (select 1 from @SCHEMA@.entity x
781
774
  where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
782
775
  and (x.data->>'name' = data->>'alias' or x.data->>'alias' = data->>'alias'))) then
783
- perform @SCHEMA@.lt_fail('invalid_class', format('алиас %s класса %s совпадает с именем или алиасом другого класса',
784
- data->>'alias', data->>'name'));
776
+ perform @SCHEMA@.lt_fail('sql-write.alias_taken', jsonb_build_object('alias', data->>'alias', 'cls', data->>'name'));
785
777
  end if;
786
778
  -- и наоборот: имя класса — не чужой алиас арендатора и System, иначе правила, написанные для нового класса, действовали
787
779
  -- бы и на строки класса с этим алиасом
788
780
  if exists (select 1 from @SCHEMA@.entity x
789
781
  where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
790
782
  and x.data->>'alias' = data->>'name') then
791
- 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'));
792
784
  end if;
793
- -- агрегаты (план 1.1, §2.4; ревью коммита этапа 4б): поле считают функции владельца по всем строкам источника
794
- -- арендатора, без прав чтения, поэтому строки попадают в агрегат только с ведома того, кто читает их все. Пути
795
- -- владельца (сид, class_evolve, rekey_class) сюда не заходят. Описание до правки — для сравнения (нет — новый класс)
785
+ -- описание до правки — для сравнения (нет — новый класс). Пути владельца (сид, class_evolve) сюда не заходят
796
786
  select x.data into prevd from @SCHEMA@.entity x
797
787
  where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' = data->>'name';
798
- -- производный класс (план 1.1, §2.4) заводит, меняет и удаляет только администратор (/security-review после точки Б):
799
- -- проектор берёт арендатора в участники по его классам с пометкой и пишет туда итоги по всем строкам источников,
800
- -- поэтому пользователь с правом WRITE на Class, заведя такой класс сам, читал бы итоги по строкам, которые ему читать
801
- -- запрещено. Пометка своя или унаследованная, до правки или после; право letopis.projector — у администраторской роли
802
- -- подключения и по правилам System (системный администратор — маска letopis.*)
803
- if (data->'resolved' ? 'derivedBy' or coalesce(prevd->'resolved' ? 'derivedBy', false))
804
- and not @SCHEMA@.letopis_allowed('letopis.projector') then
805
- perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)',
806
- data->>'name'));
807
- end if;
808
- -- предок производного класса (ревью безопасности №3): правка предка пересчитывает описание потомка (class_evolve) —
809
- -- поля, ключ, history, — поэтому класс с производными потомками (своего арендатора, у класса System — всех, кроме
810
- -- арендаторов со своим одноимённым классом: их потомки наследуют свой класс) меняет тоже только администратор
811
- if prevd is not null and not @SCHEMA@.letopis_allowed('letopis.projector') then
812
- select x.data->>'name' into dkid from @SCHEMA@.entity x
813
- where x.class = 'Class' and x.data->>'name' <> data->>'name'
814
- and (x.tenant = p_tenant
815
- or p_tenant = '@SYSTEM@'::uuid
816
- and x.tenant <> all (array(select y.tenant from @SCHEMA@.entity y
817
- where y.class = 'Class' and y.data->>'name' = data->>'name' and y.tenant <> '@SYSTEM@'::uuid)))
818
- and x.data->'resolved'->'ancestors' ? (data->>'name') and x.data->'resolved' ? 'derivedBy'
819
- order by x.data->>'name' limit 1;
820
- if dkid is not null then
821
- perform @SCHEMA@.lt_fail('acl_denied', format('класс %s — предок производного класса %s: его описание задаёт только администратор (право letopis.projector)',
822
- data->>'name', dkid));
823
- end if;
824
- end if;
825
- -- агрегат развёрнутого описания (свой или унаследованный), новый или изменённый против прежнего — описание
826
- -- агрегата, новый потомок цели, смена parent, — задаёт только тот, кто без условий читает все строки каждого
827
- -- класса-источника: класса of и его потомков (agg_classes: классы арендатора, у класса System — классы System).
828
- -- Неизменённый агрегат не проверяется
829
- if data->'resolved' ? 'aggregate' then
830
- for e in select key, value from jsonb_each(data->'resolved'->'aggregate') order by key loop
831
- continue when prevd->'resolved'->'aggregate'->e.key is not distinct from e.value;
832
- -- класс-источник должен уже быть (ревью безопасности №3, /security-review): пока его нет, проверять право READ не
833
- -- на чем, а когда класс заведёт другой пользователь, проверялись бы только его права — и описавший агрегат читал бы
834
- -- в истории цели вклад каждой строки источника. Поэтому источник описывают раньше агрегата
835
- if cardinality(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) = 0 then
836
- perform @SCHEMA@.lt_fail('invalid_class', format('агрегат %s.%s: класса-источника %s и его потомков %s нет — источник описывают раньше агрегата',
837
- data->>'name', e.key, e.value->>'of', case when p_tenant = '@SYSTEM@'::uuid then 'в System' else 'у арендатора' end));
838
- end if;
839
- -- сам класс of первым, потомки — по имени
840
- foreach src in array array(select n from unnest(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) n
841
- order by n = (e.value->>'of') desc, n) loop
842
- chk := @SCHEMA@.acl_check_data(src, 'READ');
843
- if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
844
- perform @SCHEMA@.lt_fail('acl_denied', format('агрегат %s.%s: нужно право READ на все строки класса %s', data->>'name', e.key, src));
845
- end if;
846
- end loop;
847
- end loop;
848
- end if;
849
- -- класс становится источником агрегата (новая метка aggregatedBy — смена parent): в агрегат войдут его строки и строки
850
- -- потомков — нужно право READ без условий на них до правки, по прежним предкам (здесь), и после (agg_source_admit в
851
- -- триггере после оператора; новый класс проверяется только там: до вставки план прав его не видит)
852
- m := @SCHEMA@.agg_new_mark(prevd->'resolved', data->'resolved');
853
- if m is not null and prevd is not null then
854
- perform @SCHEMA@.agg_source_admit(data->>'name', m);
855
- for src in select x.data->>'name' from @SCHEMA@.entity x
856
- where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' <> data->>'name'
857
- and x.data->'resolved'->'ancestors' ? (data->>'name')
858
- order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.data->>'name' loop
859
- perform @SCHEMA@.agg_source_admit(src, m);
860
- end loop;
861
- end if;
862
- -- правка меняет предков, алиас или derivedBy (ревью безопасности №2): строки класса и потомков попали бы под правила
788
+ -- правка меняет предков или алиас (ревью безопасности №2): строки класса и потомков попали бы под правила
863
789
  -- других имён — нужно право READ, WRITE и DELETE без условий на все их строки. У нового класса строк ещё нет
864
790
  if prevd is not null
865
791
  and (prevd->'resolved'->'ancestors' is distinct from data->'resolved'->'ancestors'
866
- or prevd->>'alias' is distinct from data->>'alias'
867
- or prevd->'resolved'->'derivedBy' is distinct from data->'resolved'->'derivedBy') then
792
+ or prevd->>'alias' is distinct from data->>'alias') then
868
793
  perform @SCHEMA@.class_acl_admit(data->>'name', p_tenant);
869
794
  end if;
870
795
  -- алиас класса, у которого удалённые потомки хранят историю строк (ревью безопасности №3): правила находят их старые
871
- -- версии и по алиасам предков, а надгробие потомка их не записывает — алиас меняется после purge() их строк
796
+ -- версии и по алиасам предков, а надгробие потомка их не записывает — алиас не меняется
872
797
  if prevd is not null and prevd->>'alias' is distinct from data->>'alias'
873
798
  and @SCHEMA@.class_orphan_history(data->>'name', p_tenant) then
874
- perform @SCHEMA@.lt_fail('invalid_class', format('удалённые потомки класса %s хранят историю строк: алиас меняется после purge() их строк',
875
- data->>'name'));
799
+ perform @SCHEMA@.lt_fail('sql-write.alias_orphan_history', jsonb_build_object('cls', data->>'name'));
876
800
  end if;
877
- -- политика хранения истории (доработки после точки Б): по ней maintain() прореживает журнал строк класса, а
878
- -- history: false выключает журнал. Одним правом WRITE на Class автор правки стирал бы аудит строк, на которые у него
879
- -- нет прав, поэтому смена развёрнутой history существующего класса требует права READ и DELETE без условий на все
880
- -- строки класса и его потомков своего арендатора — как trimHistory требует их на каждую цель
801
+ -- history (доработки после точки Б): history: false выключает журнал. Одним правом WRITE на Class автор правки
802
+ -- останавливал бы аудит строк, на которые у него нет прав, поэтому смена развёрнутой history существующего класса
803
+ -- требует права READ и DELETE без условий на все строки класса и его потомков своего арендатора
881
804
  if prevd is not null and prevd->'resolved'->'history' is distinct from data->'resolved'->'history' then
882
- perform @SCHEMA@.class_rows_admit(data->>'name', p_tenant, array['READ', 'DELETE'],
883
- format('класс %s меняет политику хранения истории', data->>'name'));
805
+ perform @SCHEMA@.class_rows_admit(data->>'name', p_tenant, array['READ', 'DELETE'], 'history');
884
806
  end if;
885
807
  for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
886
808
  select x.data->>'name' as name into o
@@ -891,52 +813,9 @@ begin
891
813
  and @SCHEMA@.class_set(x.data->'resolved'->'ends'->e.key->'class') <> @SCHEMA@.class_set(e.value->'class')
892
814
  limit 1;
893
815
  if o.name is not null then
894
- perform @SCHEMA@.lt_fail('invalid_class', format('конец %s в классе %s ведёт на другой класс', e.key, o.name));
816
+ perform @SCHEMA@.lt_fail('sql-write.end_other_class', jsonb_build_object('end', e.key, 'cls', o.name));
895
817
  end if;
896
818
  end loop;
897
- -- на строки производного класса (план 1.1, §2.4) не ссылаются: проектор удаляет и строит их заново.
898
- -- Классы целей — под разделяемой блокировкой семейства, а класс, который становится производным, — под
899
- -- исключительной: описание со ссылкой и пометка derivedBy не проходят параллельно мимо друг друга
900
- for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
901
- for tc in select c.* from jsonb_array_elements_text(@SCHEMA@.class_set(e.value->'class')) n(name)
902
- cross join lateral @SCHEMA@.class_row(n.name, p_tenant) c where c.id is not null order by c.id loop
903
- perform @SCHEMA@.family_lock(tc, false);
904
- end loop;
905
- dname := null;
906
- select x.data->>'name' into dname
907
- from @SCHEMA@.entity x
908
- where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
909
- and @SCHEMA@.class_set(e.value->'class') ? (x.data->>'name') and x.data->'resolved' ? 'derivedBy'
910
- limit 1;
911
- if dname is null and data->'resolved' ? 'derivedBy' and @SCHEMA@.class_set(e.value->'class') ? (data->>'name') then
912
- dname := data->>'name';
913
- end if;
914
- if dname is not null then
915
- perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на производный класс %s', e.key, dname));
916
- end if;
917
- end loop;
918
- if data->'resolved' ? 'derivedBy' then
919
- tc := @SCHEMA@.class_row(data->>'name', p_tenant);
920
- if tc.id is not null then
921
- perform @SCHEMA@.family_lock(tc, true);
922
- end if;
923
- -- класс становится производным, а на него или его потомков уже ссылаются концы других классов (класс
924
- -- System видят все арендаторы — его ссылки ищутся во всех)
925
- select x.data->>'name', k.key into dname, drole
926
- from @SCHEMA@.entity x
927
- cross join lateral jsonb_each(coalesce(x.data->'resolved'->'ends', '{}'::jsonb)) k
928
- where x.class = 'Class' and (x.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
929
- and x.data->>'name' <> data->>'name'
930
- and @SCHEMA@.class_set(k.value->'class') ?| (array[data->>'name'] || array(
931
- select y.data->>'name' from @SCHEMA@.entity y
932
- where y.class = 'Class' and (y.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
933
- and y.data->'resolved'->'ancestors' ? (data->>'name')))
934
- limit 1;
935
- if dname is not null then
936
- perform @SCHEMA@.lt_fail('derived_class', format('класс %s не может стать производным: на него ссылается конец %s класса %s',
937
- data->>'name', drole, dname));
938
- end if;
939
- end if;
940
819
  -- строки-замки семейства (family_lock) — заранее: первая запись в новое семейство их не вставляет
941
820
  perform @SCHEMA@.family_rows(p_tenant, data);
942
821
  end $$;
@@ -959,98 +838,81 @@ declare
959
838
  begin
960
839
  select * into c from @SCHEMA@.ctx();
961
840
  if c.account is null then
962
- perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
841
+ perform @SCHEMA@.lt_fail('sql-write.no_session');
963
842
  end if;
964
843
  NEW.tenant := coalesce(NEW.tenant, c.tenant);
965
844
  if NEW.tenant <> c.tenant then
966
- perform @SCHEMA@.lt_fail('tenant_denied', 'запись в чужого арендатора');
845
+ perform @SCHEMA@.lt_fail('sql-write.foreign_tenant');
967
846
  end if;
968
847
  -- аккаунты (с их категориями) — только в System: их пишет системный администратор (этап 5)
969
848
  if NEW.class = 'Account' and NEW.tenant <> '@SYSTEM@'::uuid then
970
- perform @SCHEMA@.lt_fail('tenant_denied', 'аккаунты лежат в арендаторе System');
849
+ perform @SCHEMA@.lt_fail('sql-write.account_tenant');
971
850
  end if;
972
851
  cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
973
852
  if cls.id is null then
974
- 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));
975
854
  end if;
976
855
  -- разделяемая блокировка корня семейства, затем описание перечитывается под ней (класс могли удалить, пока ждали)
977
856
  perform @SCHEMA@.family_lock(cls, false);
978
857
  cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
979
858
  if cls.id is null then
980
- 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));
981
860
  end if;
982
861
  res := cls.data->'resolved';
983
862
  if coalesce((res->>'abstract')::boolean, false) then
984
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
985
- end if;
986
- if res ? 'derivedBy' then
987
- 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));
988
864
  end if;
989
865
  if NEW.class = 'Class' then
990
866
  select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(coalesce(NEW.data, '{}'::jsonb), NEW.tenant) a;
991
867
  else
992
- -- агрегатное поле считает база (план 1.1, §2.4): переданное значение — отказ, новое — 0 до умолчаний и
993
- -- проверки; итог из живых источников ставит триггер после оператора
994
- if res ? 'aggregate' then
995
- if coalesce(NEW.data, '{}'::jsonb) ?| @SCHEMA@.agg_fields(res) then
996
- perform @SCHEMA@.lt_fail('aggregate_field', format('поля %s класса %s считает база (aggregate) — их не передают',
997
- array_to_string(array(select x from unnest(@SCHEMA@.agg_fields(res)) x where NEW.data ? x), ', '), NEW.class));
998
- end if;
999
- NEW.data := @SCHEMA@.agg_zero(res, coalesce(NEW.data, '{}'::jsonb));
1000
- end if;
1001
868
  NEW.data := @SCHEMA@.apply_defaults(res->'schema', coalesce(NEW.data, '{}'::jsonb));
1002
869
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
1003
870
  if bad <> '[]'::jsonb then
1004
- 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));
1005
872
  end if;
1006
873
  NEW.validator := null;
1007
874
  end if;
1008
875
  -- цели проверяет и блокирует триггер после оператора (targets_bad) — один раз на оператор
1009
876
  select * into lk from @SCHEMA@.links_shape(res, NEW.links);
1010
- if lk.code is not null then
1011
- 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);
1012
879
  end if;
1013
880
  NEW.links := lk.links;
1014
881
  if NEW.class = 'member' and NEW.links->>'Tenant' is distinct from NEW.tenant::text then
1015
- perform @SCHEMA@.lt_fail('invalid_data', 'конец Tenant членства — арендатор строки');
882
+ perform @SCHEMA@.lt_fail('sql-write.member_tenant');
1016
883
  end if;
1017
884
  kid := @SCHEMA@.key_id(res, NEW.class, NEW.tenant, NEW.data, NEW.links);
1018
885
  if kid is null then
1019
886
  if NEW.id is not null then
1020
- 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));
1021
888
  end if;
1022
889
  NEW.id := @SCHEMA@.v5_id(NEW.tenant, NEW.class, array[gen_random_uuid()::text]);
1023
890
  else
1024
891
  if NEW.id is not null and NEW.id <> kid then
1025
- 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));
1026
893
  end if;
1027
894
  NEW.id := kid;
1028
895
  end if;
1029
- -- воскрешение: номер версии и цепочка хэшей продолжаются от последней записи журнала; запись
1030
- -- блокируется — параллельные purge, обрезка и restore того же id ждут (этап 6), а если purge успел
1031
- -- стереть её, пара (id, rev) столкнётся с его записью purge в журнале, и операция повторится
1032
- 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;
1033
899
  -- класс с историей строк в журнале (остаток Н2 ревью №2; доработки после точки Б): правила прав находят строки, и
1034
- -- старые версии тоже, по именам класса, предков и алиасов — класс с другим parent, alias или derivedBy подставил бы
900
+ -- старые версии тоже, по именам класса, предков и алиасов — класс с другим parent или alias подставил бы
1035
901
  -- старую историю под чужие правила. Поэтому, если журнал арендатора хранит версии строк класса с этим именем, класс
1036
902
  -- создаётся только заново и с тем же описанием, что в надгробии его строки Class. Надгробия нет (история осталась от
1037
- -- класса System, удалённого раньше, или строку Class стёрли мимо триггеров) — отказ: сначала purge() строк
903
+ -- класса System, удалённого раньше, или строку Class стёрли мимо триггеров) — отказ
1038
904
  -- Так же — если историю хранят удалённые потомки класса (их правила находят старые версии и по алиасу предка), и
1039
- -- политика хранения прежняя: по ней maintain() прореживает старые версии (ревью безопасности №3; у описаний без ключа
1040
- -- history в resolved — значение по умолчанию true)
905
+ -- history прежняя (ревью безопасности №3; у описаний без ключа history в resolved — значение по умолчанию true)
1041
906
  if NEW.class = 'Class'
1042
907
  and (@SCHEMA@.class_has_history(NEW.data->>'name', NEW.tenant) or @SCHEMA@.class_orphan_history(NEW.data->>'name', NEW.tenant))
1043
908
  and not (coalesce(last.op = 'delete', false)
1044
909
  and last.data->'resolved'->'ancestors' is not distinct from NEW.data->'resolved'->'ancestors'
1045
910
  and last.data->>'alias' is not distinct from NEW.data->>'alias'
1046
- and last.data->'resolved'->'derivedBy' is not distinct from NEW.data->'resolved'->'derivedBy'
1047
911
  and coalesce(last.data->'resolved'->'history', 'true'::jsonb) is not distinct from coalesce(NEW.data->'resolved'->'history', 'true'::jsonb)) then
1048
- perform @SCHEMA@.lt_fail('invalid_class', format('журнал хранит историю строк класса %s или его удалённых потомков: создать его можно только заново и с прежними parent, alias, derivedBy и history (или сначала стереть её purge())',
1049
- NEW.data->>'name'));
912
+ perform @SCHEMA@.lt_fail('sql-write.history_recreate', jsonb_build_object('cls', NEW.data->>'name'));
1050
913
  end if;
1051
914
  ts := @SCHEMA@.now_ts();
1052
915
  NEW.rev := coalesce(last.rev, 0) + 1;
1053
- NEW.prev_hash := last.hash;
1054
916
  NEW.at := greatest(ts, last.at + interval '1 microsecond');
1055
917
  NEW.created_at := NEW.at;
1056
918
  NEW.cv := cls.rev;
@@ -1059,13 +921,12 @@ begin
1059
921
  elsif NEW.owner not in (NEW.tenant, c.account)
1060
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
1061
923
  -- явный владелец — существующий аккаунт; разрешает ли его условие ACL, решает политика вставки
1062
- perform @SCHEMA@.lt_fail('target_not_found', 'владелец строки не найден');
924
+ perform @SCHEMA@.lt_fail('sql-write.owner_not_found');
1063
925
  end if;
1064
926
  NEW.author := c.author;
1065
927
  NEW.agent := c.agent;
1066
928
  NEW.op := 'create';
1067
929
  NEW.reason := nullif(current_setting('letopis.reason', true), '');
1068
- NEW.moved := null;
1069
930
  NEW.tags := coalesce(NEW.tags, '{}');
1070
931
  return NEW;
1071
932
  end $$;
@@ -1078,118 +939,72 @@ declare
1078
939
  c record;
1079
940
  cls @SCHEMA@.entity;
1080
941
  res jsonb;
1081
- old_res jsonb;
1082
942
  bad jsonb;
1083
943
  lk record;
1084
944
  kid uuid;
1085
- op text := 'update';
1086
- inc jsonb;
1087
945
  begin
1088
946
  select * into c from @SCHEMA@.ctx();
1089
947
  if c.account is null then
1090
- perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
948
+ perform @SCHEMA@.lt_fail('sql-write.no_session');
1091
949
  end if;
1092
950
  if NEW.id <> OLD.id or NEW.tenant <> OLD.tenant or NEW.owner <> OLD.owner or NEW.created_at <> OLD.created_at then
1093
- perform @SCHEMA@.lt_fail('immutable_key', 'id, арендатора, владельца и время создания менять нельзя');
1094
- end if;
1095
- if (NEW.class = 'Class') <> (OLD.class = 'Class') then
1096
- perform @SCHEMA@.lt_fail('invalid_class', 'строку Class нельзя превратить в данные и наоборот');
951
+ perform @SCHEMA@.lt_fail('sql-write.immutable_columns');
1097
952
  end if;
1098
- -- строки прав не получаются сменой класса: аккаунт с категориями — только в System (ревью этапа 5)
1099
- if NEW.class <> OLD.class and (NEW.class in ('Account', 'member', 'Resource', 'rule') or OLD.class in ('Account', 'member', 'Resource', 'rule')) then
1100
- 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));
1101
956
  end if;
1102
957
  cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
1103
958
  if cls.id is null then
1104
- 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));
1105
960
  end if;
1106
961
  perform @SCHEMA@.family_lock(cls, false);
1107
962
  cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
1108
963
  if cls.id is null then
1109
- 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));
1110
965
  end if;
1111
966
  res := cls.data->'resolved';
1112
967
  if coalesce((res->>'abstract')::boolean, false) then
1113
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
1114
- end if;
1115
- if res ? 'derivedBy' or (NEW.class <> OLD.class and (@SCHEMA@.class_row(OLD.class, OLD.tenant)).data->'resolved' ? 'derivedBy') then
1116
- perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор',
1117
- case when res ? 'derivedBy' then NEW.class else OLD.class end));
1118
- end if;
1119
- if NEW.class <> OLD.class then
1120
- -- смена класса (этап 1, п. 9): только read committed, только для классов без ключа
1121
- if current_setting('transaction_isolation') <> 'read committed' then
1122
- perform @SCHEMA@.lt_fail('isolation_level', 'смена класса разрешена только в read committed');
1123
- end if;
1124
- old_res := (@SCHEMA@.class_row(OLD.class, OLD.tenant)).data->'resolved';
1125
- if jsonb_array_length(coalesce(old_res->'key', '[]'::jsonb)) > 0 or jsonb_array_length(coalesce(res->'key', '[]'::jsonb)) > 0 then
1126
- perform @SCHEMA@.lt_fail('use_reclass', format('у класса %s или %s есть ключ: id меняется, используйте reclass()', OLD.class, NEW.class));
1127
- end if;
1128
- -- обычный update берёт слабую блокировку; смене класса нужна for update, чтобы дождаться
1129
- -- транзакций, которые как раз пишут ссылку на эту строку
1130
- perform 1 from @SCHEMA@.entity x where x.id = OLD.id for update;
1131
- -- строка уходит из своего класса — как удаление из него: нужно право DELETE на прежнюю строку (доработки после
1132
- -- точки Б; иначе с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там)
1133
- 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
1134
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на строку класса %s: смена класса убирает её из класса', OLD.class));
1135
- end if;
1136
- inc := @SCHEMA@.incoming_conflicts(OLD.id, NEW.class, OLD.tenant);
1137
- if inc <> '[]'::jsonb then
1138
- perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', NEW.class),
1139
- @SCHEMA@.visible_detail(inc, c.tenant));
1140
- end if;
1141
- op := 'reclass';
968
+ perform @SCHEMA@.lt_fail('sql-write.class_abstract', jsonb_build_object('cls', NEW.class));
1142
969
  end if;
1143
970
  if NEW.class = 'Class' then
1144
971
  select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(NEW.data, OLD.tenant) a;
1145
972
  else
1146
- -- агрегатное поле (план 1.1, §2.4): изменить его нельзя, без поля (замена данных целиком) — прежнее значение;
1147
- -- при смене класса — прежнее число или 0, итог посчитает триггер после оператора
1148
- if res ? 'aggregate' then
1149
- NEW.data := @SCHEMA@.agg_guard(res, OLD.data, NEW.data, NEW.class <> OLD.class);
1150
- end if;
1151
973
  NEW.data := @SCHEMA@.apply_defaults(res->'schema', NEW.data);
1152
974
  bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
1153
975
  if bad <> '[]'::jsonb then
1154
- 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));
1155
977
  end if;
1156
978
  NEW.validator := null;
1157
979
  end if;
1158
980
  select * into lk from @SCHEMA@.links_shape(res, NEW.links);
1159
- if lk.code is not null then
1160
- 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);
1161
983
  end if;
1162
984
  NEW.links := lk.links;
1163
985
  if NEW.class = 'member' and NEW.links->>'Tenant' is distinct from OLD.tenant::text then
1164
- perform @SCHEMA@.lt_fail('invalid_data', 'конец Tenant членства — арендатор строки');
986
+ perform @SCHEMA@.lt_fail('sql-write.member_tenant');
1165
987
  end if;
1166
988
  kid := @SCHEMA@.key_id(res, NEW.class, OLD.tenant, NEW.data, NEW.links);
1167
989
  if kid is not null and kid <> OLD.id then
1168
- 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));
1169
991
  end if;
1170
992
  -- ничего не изменилось: строка возвращается как есть, версии нет, оператор считает её затронутой
1171
- 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
1172
994
  return OLD;
1173
995
  end if;
1174
996
  if NEW.class = 'Class' and not @SCHEMA@.class_change_safe(OLD.data->'resolved', NEW.data->'resolved') then
1175
997
  -- ужесточение: исключительная блокировка корня семейства (и нового корня при смене родителя)
1176
998
  perform @SCHEMA@.family_lock(OLD, true);
1177
999
  perform @SCHEMA@.family_lock(NEW, true);
1178
- -- смена агрегатов — ещё семейства источников (план 1.1, §2.4): операция, зафиксированная до пересчёта, в него
1179
- -- войдёт, а ожидающая допишет вклад уже по новому описанию
1180
- if OLD.data->'resolved'->'aggregate' is distinct from NEW.data->'resolved'->'aggregate' then
1181
- perform @SCHEMA@.agg_lock_sources(OLD.data->'resolved', NEW.data->'resolved', OLD.tenant);
1182
- end if;
1183
1000
  end if;
1184
1001
  NEW.rev := OLD.rev + 1;
1185
- NEW.prev_hash := OLD.hash;
1186
1002
  NEW.at := greatest(@SCHEMA@.now_ts(), OLD.at + interval '1 microsecond');
1187
1003
  NEW.cv := cls.rev;
1188
1004
  NEW.author := c.author;
1189
1005
  NEW.agent := c.agent;
1190
- NEW.op := op;
1006
+ NEW.op := 'update';
1191
1007
  NEW.reason := nullif(current_setting('letopis.reason', true), '');
1192
- NEW.moved := null;
1193
1008
  return NEW;
1194
1009
  end $$;
1195
1010
 
@@ -1208,13 +1023,11 @@ begin
1208
1023
  bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
1209
1024
  from added n where n.links <> '{}'::jsonb), true, @SCHEMA@.acl_actor('READ'));
1210
1025
  end if;
1211
- if bad like '!%' then
1212
- perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на строку производного класса', substr(bad, 2)));
1213
- elsif bad is not null then
1214
- 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));
1215
1028
  end if;
1216
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
1217
- 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
1218
1031
  from added n
1219
1032
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
1220
1033
  where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
@@ -1225,19 +1038,6 @@ begin
1225
1038
  if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
1226
1039
  perform @SCHEMA@.acl_bump();
1227
1040
  end if;
1228
- if 'Class' = any (sc) then
1229
- -- новый класс — источник агрегата (ревью коммита этапа 4б): нужно право READ без условий на все его строки; план
1230
- -- прав после acl_bump видит новый класс
1231
- perform @SCHEMA@.agg_source_admit(n.data->>'name', @SCHEMA@.agg_new_mark(null, n.data->'resolved'))
1232
- from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
1233
- -- и описавшему агрегат (хвост ревью доработок после 1.2.0): заведя потомка источника, читающий всё пользователь
1234
- -- иначе открыл бы его строки тому, кто описал агрегат без права на них
1235
- perform @SCHEMA@.agg_author_admit(n.data->>'name', n.tenant, null, n.data->'resolved')
1236
- from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
1237
- -- новая цель агрегатов (план 1.1, §2.4): метки её источников (aggregatedBy) — заново
1238
- perform @SCHEMA@.agg_mark(n.tenant, @SCHEMA@.agg_of(n.data->'resolved'))
1239
- from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregate';
1240
- end if;
1241
1041
  return null;
1242
1042
  end $$;
1243
1043
 
@@ -1256,31 +1056,26 @@ declare
1256
1056
  cid uuid;
1257
1057
  bad text;
1258
1058
  begin
1259
- -- цели проверяются у строк, у которых изменились концы или класс
1059
+ -- цели проверяются у строк, у которых изменились концы
1260
1060
  if exists (select 1 from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
1261
- 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
1262
1062
  bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
1263
1063
  from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
1264
- 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'));
1265
1065
  end if;
1266
- if bad like '!%' then
1267
- perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на строку производного класса', substr(bad, 2)));
1268
- elsif bad is not null then
1269
- 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));
1270
1068
  end if;
1271
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
1272
- 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,
1273
- @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
1274
1071
  from new_rows n
1275
1072
  join old_rows o on o.id = n.id and o.rev <> n.rev
1276
1073
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
1277
1074
  where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
1278
1075
  order by n.id; -- порядок seq не зависит от плана: правка многих строк пишет версии в порядке id
1279
- -- сигнал — по парам нового и прежнего класса: при смене класса без ключа ответы прежнего класса в
1280
- -- кэше других процессов устарели бы (в 1.0.1 сигнал шёл только по новому)
1076
+ -- сигнал — по паре «класс, арендатор» (класс и арендатор строки неизменны)
1281
1077
  select array_agg(k.class), array_agg(k.tenant) into sc, st
1282
- 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
1283
- 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;
1284
1079
  perform @SCHEMA@.signal(sc, st);
1285
1080
  if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
1286
1081
  perform @SCHEMA@.acl_bump();
@@ -1289,10 +1084,6 @@ begin
1289
1084
  where n.class = 'Class' order by n.id loop
1290
1085
  select * into o_row from old_rows x where x.id = cid;
1291
1086
  select * into n_row from new_rows x where x.id = cid;
1292
- -- класс становится источником агрегата (смена parent; ревью коммита этапа 4б): право READ без условий на его строки
1293
- -- и после правки — по новым предкам (до правки проверил class_admit)
1294
- perform @SCHEMA@.agg_source_admit(n_row.data->>'name', @SCHEMA@.agg_new_mark(o_row.data->'resolved', n_row.data->'resolved'));
1295
- perform @SCHEMA@.agg_author_admit(n_row.data->>'name', n_row.tenant, o_row.data->'resolved', n_row.data->'resolved');
1296
1087
  perform @SCHEMA@.class_evolve(o_row, n_row);
1297
1088
  end loop;
1298
1089
  -- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
@@ -1314,15 +1105,7 @@ declare
1314
1105
  classes text[] := array[n.data->>'name'];
1315
1106
  conflicts jsonb;
1316
1107
  c record;
1317
- -- агрегаты (план 1.1, §2.4): источники, чьи метки пересчитываются; цели, поле которых считается заново
1318
- agg_names text[] := '{}';
1319
- agg_own text[] := '{}';
1320
- agg_other text[] := @SCHEMA@.agg_marked(o.data->'resolved', n.data->'resolved');
1321
1108
  begin
1322
- if o.data->'resolved'->'aggregate' is distinct from n.data->'resolved'->'aggregate' then
1323
- agg_names := @SCHEMA@.agg_of(o.data->'resolved') || @SCHEMA@.agg_of(n.data->'resolved');
1324
- agg_own := agg_own || (n.data->>'name');
1325
- end if;
1326
1109
  -- потомки: описание resolved и validator пересчитываются, каждый пересчёт — версия класса в журнале
1327
1110
  for d in select x.* from @SCHEMA@.entity x
1328
1111
  where x.class = 'Class' and x.id <> n.id
@@ -1333,43 +1116,18 @@ begin
1333
1116
  if prep.data is distinct from d.data or prep.validator::text is distinct from d.validator::text then
1334
1117
  perform @SCHEMA@.sys_update(d.id, prep.data, d.links, prep.validator, 'update');
1335
1118
  end if;
1336
- -- потомок становится источником вместе с классом (смена parent; ревью коммита этапа 4б) — то же право READ на его
1337
- -- строки по новым предкам (до правки проверил class_admit). class_evolve зовёт только триггер правки приложения;
1338
- -- потомков класса System у арендаторов план прав сессии System не видит, их строки считают агрегаты System для
1339
- -- целей того же арендатора
1340
- if d.tenant = n.tenant then
1341
- perform @SCHEMA@.agg_source_admit(d.data->>'name', @SCHEMA@.agg_new_mark(d.data->'resolved', prep.data->'resolved'));
1342
- end if;
1343
- -- и описавшему агрегат — в объёме описания (agg_author_admit сам пропускает класс арендатора под агрегатом System)
1344
- perform @SCHEMA@.agg_author_admit(d.data->>'name', d.tenant, d.data->'resolved', prep.data->'resolved');
1345
1119
  classes := classes || (d.data->>'name');
1346
- if d.data->'resolved'->'aggregate' is distinct from prep.data->'resolved'->'aggregate' then
1347
- agg_names := agg_names || @SCHEMA@.agg_of(d.data->'resolved') || @SCHEMA@.agg_of(prep.data->'resolved');
1348
- agg_own := agg_own || (d.data->>'name');
1349
- end if;
1350
- agg_other := agg_other || @SCHEMA@.agg_marked(d.data->'resolved', prep.data->'resolved');
1351
1120
  end loop;
1352
- -- агрегаты семейства изменились: метки источников заново и поле у всех целей — из живых источников (строки
1353
- -- проверит ужесточение ниже); у источника сменились предки — заново цели, которые его считают
1354
- if cardinality(agg_names) > 0 then
1355
- perform @SCHEMA@.agg_mark(n.tenant, agg_names);
1356
- end if;
1357
- if cardinality(agg_own) > 0 then
1358
- perform @SCHEMA@.agg_refresh(agg_own, n.tenant, false);
1359
- end if;
1360
- if cardinality(agg_other) > 0 then
1361
- perform @SCHEMA@.agg_refresh(agg_other, n.tenant, true);
1362
- end if;
1363
1121
  if not @SCHEMA@.class_change_safe(o.data->'resolved', n.data->'resolved') then
1364
1122
  -- ужесточение проверяет все строки семейства, в том числе невидимые актору, и отвечает их числом: пробное
1365
1123
  -- ужесточение (maximum, enum, pattern…) было оракулом по чужим строкам (доработки после точки Б). Нужно право READ
1366
1124
  -- без условий на все строки класса и потомков своего арендатора; строки других арендаторов (класс System) —
1367
1125
  -- уровень установки, их число остаётся в others
1368
- 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');
1369
1127
  conflicts := @SCHEMA@.family_conflicts(classes, n.tenant, o.data->'resolved'->'ancestors' is distinct from n.data->'resolved'->'ancestors');
1370
1128
  if conflicts <> '[]'::jsonb then
1371
1129
  select * into c from @SCHEMA@.ctx();
1372
- 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'),
1373
1131
  @SCHEMA@.visible_detail(conflicts, c.tenant));
1374
1132
  end if;
1375
1133
  end if;
@@ -1382,7 +1140,7 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
1382
1140
  as $$
1383
1141
  begin
1384
1142
  if current_setting('transaction_isolation') <> 'read committed' then
1385
- perform @SCHEMA@.lt_fail('isolation_level', 'удаление разрешено только в read committed');
1143
+ perform @SCHEMA@.lt_fail('sql-write.delete_isolation');
1386
1144
  end if;
1387
1145
  return null;
1388
1146
  end $$;
@@ -1392,14 +1150,14 @@ end $$;
1392
1150
  -- после него, поэтому сессии гаснут с сигналом session:<хэш> здесь: процессы сбрасывают кэш ответов этих сессий.
1393
1151
  -- Срабатывает для всех ролей, в том числе владельца: аккаунты удаляют и функции владельца — каскад trg_entity_ad и
1394
1152
  -- account_purge. Строка Account у System не удаляется: ей принадлежат служебные строки secret (KEY — ключ прав и
1395
- -- сигналов, LOCK — строки-замки, PROJECTOR — чекпоинты проекторов), которые каскад стёр бы, а без них установка не пишет.
1153
+ -- сигналов, LOCK — строки-замки), которые каскад стёр бы, а без них установка не пишет.
1396
1154
  create or replace function @SCHEMA@.trg_entity_bd_account() returns trigger
1397
1155
  language plpgsql security definer
1398
1156
  set search_path = pg_catalog, @SCHEMA@, pg_temp
1399
1157
  as $$
1400
1158
  begin
1401
1159
  if OLD.id = '@SYSTEM@'::uuid then
1402
- perform @SCHEMA@.lt_fail('acl_denied', 'аккаунт System не удаляется: ему принадлежат служебные строки установки');
1160
+ perform @SCHEMA@.lt_fail('sql-write.system_account_delete');
1403
1161
  end if;
1404
1162
  perform @SCHEMA@.auth_kill(OLD.id, null);
1405
1163
  return OLD;
@@ -1438,10 +1196,10 @@ begin
1438
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
1439
1197
  order by x.id for update loop
1440
1198
  update @SCHEMA@.entity x
1441
- set owner = p_tenant, rev = x.rev + 1, prev_hash = x.hash,
1199
+ set owner = p_tenant, rev = x.rev + 1,
1442
1200
  at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
1443
1201
  author = coalesce(c.author, '@SYSTEM@'::uuid), agent = c.agent, op = 'update',
1444
- reason = nullif(current_setting('letopis.reason', true), ''), moved = null
1202
+ reason = nullif(current_setting('letopis.reason', true), '')
1445
1203
  where x.id = r.id
1446
1204
  returning x.* into r;
1447
1205
  perform @SCHEMA@.log_version(r);
@@ -1488,13 +1246,7 @@ begin
1488
1246
  dplan := @SCHEMA@.acl_plan('DELETE');
1489
1247
  wplan := @SCHEMA@.acl_plan('WRITE');
1490
1248
  if exists (select 1 from @SCHEMA@.entity x join gone g on g.id = x.id) then
1491
- perform @SCHEMA@.lt_fail('recreated_in_statement', 'id удалён и создан заново одним оператором');
1492
- end if;
1493
- -- строки производного класса удаляет только проектор (каскад функции владельца сюда не доходит)
1494
- select d.class into denied from (select distinct g.class, g.tenant from gone g) d
1495
- where (@SCHEMA@.class_row(d.class, d.tenant)).data->'resolved' ? 'derivedBy' limit 1;
1496
- if denied is not null then
1497
- perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки удаляет проектор', denied));
1249
+ perform @SCHEMA@.lt_fail('sql-write.recreated_in_statement');
1498
1250
  end if;
1499
1251
  -- удаление класса (хвост ревью безопасности доработок после 1.2.0): проверки ниже — нет строк, потомков и истории —
1500
1252
  -- не видят строку, которую писатель ещё не зафиксировал, и она осталась бы без класса, а с ней история удалённого
@@ -1503,25 +1255,7 @@ begin
1503
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
1504
1256
  perform @SCHEMA@.family_lock(cl, true);
1505
1257
  end loop;
1506
- 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
1507
- -- производный класс удаляет только администратор (class_admit)
1508
- if r.derived and not @SCHEMA@.letopis_allowed('letopis.projector') then
1509
- perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)', r.name));
1510
- end if;
1511
- -- класс-источник агрегата (автоматическое ревью коммита 5290be1): пока его называет агрегат класса того же
1512
- -- арендатора, класс не удаляется — иначе класс с этим именем завёл бы потом другой пользователь, и право READ
1513
- -- описавшего агрегат на его строки уже не проверить (класс of проверяется только при описании агрегата). Агрегаты
1514
- -- классов System описывает системный администратор: он читает всё
1515
- if r.tenant <> '@SYSTEM@'::uuid then
1516
- select x.data->>'name' || '.' || a.key into denied
1517
- from @SCHEMA@.entity x cross join lateral jsonb_each(x.data->'resolved'->'aggregate') a
1518
- where x.class = 'Class' and x.tenant = r.tenant and x.data->'resolved' ? 'aggregate' and a.value->>'of' = r.name
1519
- order by 1 limit 1;
1520
- if denied is not null then
1521
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s — источник агрегата %s: сначала уберите агрегат из описания цели',
1522
- r.name, denied));
1523
- end if;
1524
- end if;
1258
+ for r in select g.id, g.tenant, g.data->>'name' as name from gone g where g.class = 'Class' loop
1525
1259
  -- у класса System — строки и потомки любого арендатора, кроме арендаторов со своим одноимённым классом: их строки и
1526
1260
  -- потомки относятся к своему классу (class_row; ревью безопасности №3)
1527
1261
  own := case when r.tenant = '@SYSTEM@'::uuid
@@ -1532,24 +1266,21 @@ begin
1532
1266
  where x.class = r.name and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid and x.tenant <> all (own)))
1533
1267
  or exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.data->>'parent' = r.name
1534
1268
  and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid and x.tenant <> all (own))) then
1535
- 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));
1536
1270
  end if;
1537
1271
  -- и история строк (остаток Н2; доработки после точки Б): удалённый класс с историей, созданный заново с другими
1538
- -- предками или алиасом, подставил бы её под чужие правила — сначала purge() удалённых строк
1272
+ -- предками или алиасом, подставил бы её под чужие правила — класс с историей строк не удаляется
1539
1273
  if @SCHEMA@.class_has_history(r.name, r.tenant) then
1540
- 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));
1541
1275
  end if;
1542
1276
  end loop;
1543
- -- удалена цель агрегатов (план 1.1, §2.4): метки её источников — уже без неё
1544
- perform @SCHEMA@.agg_mark(g.tenant, @SCHEMA@.agg_of(g.data->'resolved'))
1545
- from gone g where g.class = 'Class' and g.data->'resolved' ? 'aggregate';
1546
1277
  v_author := coalesce(c.author, '@SYSTEM@'::uuid);
1547
1278
  -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень, в порядке id: без
1548
1279
  -- order by порядок seq (а с ним итог delete() и время at) зависел от плана соединения и менялся от прогона к
1549
1280
  -- прогону; время now_ts() (volatile) планировщик вычисляет уже после сортировки
1550
- 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)
1551
1282
  select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
1552
- 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
1553
1284
  from gone g
1554
1285
  join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
1555
1286
  where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
@@ -1580,9 +1311,9 @@ begin
1580
1311
  where x.id in (select y.id from @SCHEMA@.entity y where y.id = any (casc) order by y.id for update)
1581
1312
  returning x.*
1582
1313
  ), t as (
1583
- 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)
1584
1315
  select d.id, d.rev + 1, d.class, d.cv, d.tenant, d.owner, d.links, d.data, d.tags,
1585
- 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
1586
1317
  from d
1587
1318
  join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
1588
1319
  where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
@@ -1596,8 +1327,10 @@ begin
1596
1327
  -- класс отказавшей строки называется, только если актор её видит (ревью безопасности №3)
1597
1328
  if drows is not null then
1598
1329
  denied := @SCHEMA@.visible_class(drows);
1599
- perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права DELETE на класс %s в каскаде удаления', denied)
1600
- 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');
1601
1334
  end if;
1602
1335
  mems := mems || lvl_mems;
1603
1336
  classes := classes || lvl_classes;
@@ -1605,9 +1338,10 @@ begin
1605
1338
  -- unset: снять конец, новая версия
1606
1339
  for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
1607
1340
  if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
1608
- perform @SCHEMA@.lt_fail('acl_denied', case when @SCHEMA@.visible_class(jsonb_build_array(to_jsonb(r))) is not null
1609
- then format('нет права WRITE на класс %s: снятие конца при удалении', r.class)
1610
- 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');
1611
1345
  end if;
1612
1346
  newlinks := '{}'::jsonb;
1613
1347
  for k in select key, value from jsonb_each(r.links) loop
@@ -1638,7 +1372,7 @@ begin
1638
1372
  select coalesce(jsonb_agg(jsonb_build_object('id', x.id, 'class', x.class, 'tenant', x.tenant)), '[]'::jsonb) into blockers
1639
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;
1640
1374
  if blockers <> '[]'::jsonb then
1641
- perform @SCHEMA@.lt_fail('delete_restricted', 'на удаляемые строки ссылаются другие строки (onDelete: restrict)',
1375
+ perform @SCHEMA@.lt_fail('sql-write.delete_restricted', '{}'::jsonb,
1642
1376
  @SCHEMA@.visible_detail(blockers, c.tenant));
1643
1377
  end if;
1644
1378
  return null;
@@ -1708,9 +1442,7 @@ create or replace trigger entity_bi before insert on @SCHEMA@.entity
1708
1442
  for each row when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bi();
1709
1443
  create or replace trigger entity_bu before update on @SCHEMA@.entity
1710
1444
  for each row when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bu();
1711
- -- до ревизии 2 журнал писал строковый триггер entity_ai после insert или update
1712
- drop trigger if exists entity_ai on @SCHEMA@.entity;
1713
- 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
1714
1446
  for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ai();
1715
1447
  create or replace trigger entity_au after update on @SCHEMA@.entity referencing old table as old_rows new table as new_rows
1716
1448
  for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_au();
@@ -1730,192 +1462,6 @@ alter table @SCHEMA@.entity enable always trigger entity_bd;
1730
1462
  alter table @SCHEMA@.entity enable always trigger entity_bd_account;
1731
1463
  alter table @SCHEMA@.entity enable always trigger entity_ad;
1732
1464
 
1733
- -- ========================= смена класса с ключом =========================
1734
-
1735
- -- Замена id цели во всех концах.
1736
- create or replace function @SCHEMA@.links_replace(p_links jsonb, p_old uuid, p_new uuid) returns jsonb
1737
- language sql immutable parallel safe
1738
- return (
1739
- select coalesce(jsonb_object_agg(k.key, case jsonb_typeof(k.value)
1740
- when 'array' then coalesce((select jsonb_agg(case when x = p_old::text then to_jsonb(p_new::text) else to_jsonb(x) end)
1741
- from jsonb_array_elements_text(k.value) x), '[]'::jsonb)
1742
- else case when k.value #>> '{}' = p_old::text then to_jsonb(p_new::text) else k.value end end), '{}'::jsonb)
1743
- from jsonb_each(p_links) k
1744
- );
1745
-
1746
- -- Перенос строки на новый id (смена класса с ключом, смена ключа): новая строка продолжает журнал
1747
- -- нового id и ссылается на старый (moved), ссылки переводятся — если переведённый конец входит
1748
- -- в ключ ссылающейся строки, её id тоже меняется, и перевод идёт дальше рекурсивно; старая строка
1749
- -- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая функция; p_plan — план WRITE
1750
- -- актора (reclass, rekey, rekey_class): каждая строка, чьи ссылки перевод переписывает, на всех уровнях рекурсии должна
1751
- -- им проходить (доработки после точки Б: прежде проверялся только первый уровень); null — путь владельца.
1752
- -- Агрегаты (план 1.1, §2.4): перенос — несколько операторов (новая строка, перевод ссылок, удаление старой), и
1753
- -- триггер агрегатов на это время только копит их вклады (agg_hold_begin), а в конце применяет одним итогом: сумма
1754
- -- не проходит через промежуточные значения, новая цель считается целиком из живых источников.
1755
- drop function if exists @SCHEMA@.move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text);
1756
- 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,
1757
- p_plan jsonb default null)
1758
- returns @SCHEMA@.entity
1759
- language plpgsql volatile
1760
- set search_path = pg_catalog, @SCHEMA@, pg_temp
1761
- as $$
1762
- declare
1763
- c record;
1764
- last record;
1765
- ts timestamptz;
1766
- n @SCHEMA@.entity;
1767
- r @SCHEMA@.entity;
1768
- nl jsonb;
1769
- rid uuid;
1770
- held boolean := @SCHEMA@.agg_hold_begin('acc');
1771
- begin
1772
- select * into c from @SCHEMA@.ctx();
1773
- 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;
1774
- ts := greatest(@SCHEMA@.now_ts(), last.at + interval '1 microsecond');
1775
- insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator)
1776
- 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,
1777
- p_links, p_data, o.tags, ts, ts, coalesce(c.author, '@SYSTEM@'::uuid), c.agent, p_op,
1778
- nullif(current_setting('letopis.reason', true), ''), o.id, last.hash, null)
1779
- returning * into n;
1780
- perform @SCHEMA@.log_version(n);
1781
- 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
1782
- -- строки производного класса пишет только проектор: перевод их ссылок мимо него оставил бы проекцию
1783
- -- несогласованной (ревью безопасности №3, №3)
1784
- if (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved' ? 'derivedBy' then
1785
- perform @SCHEMA@.lt_fail('derived_class',
1786
- case when p_plan is null or @SCHEMA@.visible_class(jsonb_build_array(to_jsonb(r))) is not null
1787
- then format('на строку ссылается строка производного класса %s: её пишет проектор', r.class)
1788
- else 'на строку ссылается строка производного класса: её пишет проектор' end);
1789
- end if;
1790
- 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
1791
- perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на ссылающиеся строки');
1792
- end if;
1793
- nl := @SCHEMA@.links_replace(r.links, o.id, p_new_id);
1794
- rid := coalesce(@SCHEMA@.key_id((@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved', r.class, r.tenant, r.data, nl), r.id);
1795
- if rid <> r.id then
1796
- perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey', p_plan);
1797
- else
1798
- perform @SCHEMA@.sys_update(r.id, r.data, nl, r.validator, 'update');
1799
- end if;
1800
- end loop;
1801
- delete from @SCHEMA@.entity x where x.id = o.id returning x.* into r;
1802
- -- надгробие всегда вида delete (по нему срез на момент отличает удалённый id), moved — новый id
1803
- perform @SCHEMA@.tombstone(r, 'delete', p_new_id);
1804
- if held then
1805
- perform @SCHEMA@.agg_hold_end(true);
1806
- end if;
1807
- return n;
1808
- end $$;
1809
-
1810
- -- Смена класса (этап 1, п. 9): у класса с ключом id вычисляется заново для нового класса, ссылки
1811
- -- переводятся в той же транзакции, журнал связывает старый и новый id. У класса без ключа id
1812
- -- сохраняется. Только read committed; на строку сначала берётся for update.
1813
- create or replace function @SCHEMA@.reclass(p_id uuid, p_class text, p_data jsonb default null) returns uuid
1814
- language plpgsql volatile security definer
1815
- set search_path = pg_catalog, @SCHEMA@, pg_temp
1816
- as $$
1817
- declare
1818
- c record;
1819
- o @SCHEMA@.entity;
1820
- cls @SCHEMA@.entity;
1821
- res jsonb;
1822
- d jsonb;
1823
- bad jsonb;
1824
- lk record;
1825
- nid uuid;
1826
- inc jsonb;
1827
- rd jsonb;
1828
- wplan jsonb;
1829
- denied text;
1830
- n_denied int;
1831
- begin
1832
- select * into c from @SCHEMA@.ctx();
1833
- if c.account is null then
1834
- perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
1835
- end if;
1836
- if current_setting('transaction_isolation') <> 'read committed' then
1837
- perform @SCHEMA@.lt_fail('isolation_level', 'смена класса разрешена только в read committed');
1838
- end if;
1839
- rd := @SCHEMA@.acl_actor('READ');
1840
- wplan := @SCHEMA@.acl_plan('WRITE');
1841
- select x.* into o from @SCHEMA@.entity x where x.id = p_id and x.tenant = c.tenant for update;
1842
- 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
1843
- perform @SCHEMA@.lt_fail('target_not_found', 'цель не найдена');
1844
- end if;
1845
- if not @SCHEMA@.acl_row_ok(wplan, o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
1846
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', o.class));
1847
- end if;
1848
- -- строка уходит из своего класса — как удаление из него: нужно право DELETE на прежнюю строку (доработки после
1849
- -- точки Б; иначе с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там)
1850
- 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
1851
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на строку класса %s: смена класса убирает её из класса', o.class));
1852
- end if;
1853
- -- строки прав и классов функция не трогает
1854
- if o.class in ('Class', 'Account', 'member', 'Resource', 'rule') or p_class in ('Class', 'Account', 'member', 'Resource', 'rule') then
1855
- perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
1856
- end if;
1857
- cls := @SCHEMA@.class_row(p_class, o.tenant);
1858
- if cls.id is null then
1859
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', p_class));
1860
- end if;
1861
- perform @SCHEMA@.family_lock(cls, false);
1862
- cls := @SCHEMA@.class_row(p_class, o.tenant);
1863
- if cls.id is null then
1864
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', p_class));
1865
- end if;
1866
- res := cls.data->'resolved';
1867
- if coalesce((res->>'abstract')::boolean, false) then
1868
- perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
1869
- end if;
1870
- if res ? 'derivedBy' or (@SCHEMA@.class_row(o.class, o.tenant)).data->'resolved' ? 'derivedBy' then
1871
- perform @SCHEMA@.lt_fail('derived_class', 'строки производного класса пишет только его проектор');
1872
- end if;
1873
- d := coalesce(p_data, o.data);
1874
- -- агрегатные поля нового класса считает база (план 1.1, §2.4): в данных их не передают; прежнее число остаётся
1875
- -- до пересчёта, иначе 0
1876
- if res ? 'aggregate' then
1877
- if p_data ?| @SCHEMA@.agg_fields(res) then
1878
- perform @SCHEMA@.lt_fail('aggregate_field', format('агрегатные поля класса %s считает база — в данных reclass их не передают', p_class));
1879
- end if;
1880
- d := @SCHEMA@.agg_zero(res, d);
1881
- end if;
1882
- d := @SCHEMA@.apply_defaults(res->'schema', d);
1883
- bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
1884
- if bad <> '[]'::jsonb then
1885
- perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', p_class), jsonb_build_object('issues', bad));
1886
- end if;
1887
- select * into lk from @SCHEMA@.check_links(res, o.links, o.tenant, true, rd);
1888
- if lk.code is not null then
1889
- perform @SCHEMA@.lt_fail(lk.code, lk.msg);
1890
- end if;
1891
- inc := @SCHEMA@.incoming_conflicts(o.id, p_class, o.tenant);
1892
- if inc <> '[]'::jsonb then
1893
- perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', p_class),
1894
- @SCHEMA@.visible_detail(inc, c.tenant));
1895
- end if;
1896
- nid := coalesce(@SCHEMA@.key_id(res, p_class, o.tenant, d, lk.links), o.id);
1897
- if not @SCHEMA@.acl_row_ok(wplan, p_class, nid, o.owner, o.tenant, o.tags, d, lk.links) then
1898
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s', p_class));
1899
- end if;
1900
- if nid <> o.id then
1901
- -- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE (глубже первого уровня — move_row);
1902
- -- класс такой строки называется, только если актор её видит
1903
- 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')),
1904
- count(*) into denied, n_denied from @SCHEMA@.entity x
1905
- 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);
1906
- if n_denied > 0 then
1907
- perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права WRITE на ссылающиеся строки класса %s', denied)
1908
- else 'нет права WRITE на ссылающиеся строки' end);
1909
- end if;
1910
- end if;
1911
- if nid = o.id then
1912
- perform @SCHEMA@.sys_update(o.id, d, lk.links, null, 'reclass', p_class);
1913
- else
1914
- perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass', wplan);
1915
- end if;
1916
- return nid;
1917
- end $$;
1918
-
1919
1465
  -- ========================= merge, inc, links_patch =========================
1920
1466
 
1921
1467
  -- Правка концов слотами (этап 4, п. 4): .Роль.set(), .unset(), а для множественных концов .add()
@@ -1963,7 +1509,10 @@ begin
1963
1509
  end if;
1964
1510
  res := jsonb_set(res, array[role], cur);
1965
1511
  else
1966
- 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;
1967
1516
  end case;
1968
1517
  end loop;
1969
1518
  return res;
@@ -2004,39 +1553,46 @@ declare
2004
1553
  cur jsonb := data;
2005
1554
  v jsonb;
2006
1555
  i int;
2007
- fail text;
1556
+ fail text; -- ключ каталога отказа (sql-write.inc_*)
1557
+ p jsonb := '{}'::jsonb;
2008
1558
  begin
2009
1559
  if path is null or cardinality(path) = 0 or delta is null then
2010
- fail := 'inc: нужны путь и величина';
1560
+ fail := 'sql-write.inc_args';
2011
1561
  else
2012
1562
  for i in 1 .. cardinality(path) - 1 loop
2013
1563
  if jsonb_typeof(cur) is distinct from 'object' or not cur ? path[i] then
2014
- fail := format('inc: путь %s идёт через отсутствующий объект', array_to_string(path, '.'));
1564
+ fail := 'sql-write.inc_path_missing';
2015
1565
  exit;
2016
1566
  end if;
2017
1567
  cur := cur->path[i];
2018
1568
  end loop;
2019
1569
  if fail is null and jsonb_typeof(cur) is distinct from 'object' then
2020
- fail := format('inc: путь %s идёт не через объект', array_to_string(path, '.'));
1570
+ fail := 'sql-write.inc_path_not_object';
2021
1571
  end if;
2022
1572
  if fail is null then
2023
1573
  v := cur->path[cardinality(path)];
2024
1574
  if v is null then
2025
1575
  if start is null then
2026
- fail := format('inc: поля %s нет — задайте start', array_to_string(path, '.'));
1576
+ fail := 'sql-write.inc_no_field';
2027
1577
  else
2028
1578
  return jsonb_set(data, path, to_jsonb(start + delta), true);
2029
1579
  end if;
2030
1580
  elsif jsonb_typeof(v) <> 'number' then
2031
- fail := format('inc: в поле %s не число', array_to_string(path, '.'));
1581
+ fail := 'sql-write.inc_not_number';
2032
1582
  else
2033
1583
  return jsonb_set(data, path, to_jsonb((v #>> '{}')::numeric + delta));
2034
1584
  end if;
2035
1585
  end if;
2036
1586
  end if;
2037
- 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),
2038
1593
  detail = jsonb_build_object('issues', jsonb_build_array(jsonb_build_object(
2039
- '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;
2040
1596
  end $$;
2041
1597
 
2042
1598
  do $$
@@ -2045,8 +1601,7 @@ begin
2045
1601
  foreach f in array array[
2046
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)',
2047
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)',
2048
- 'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text, text)', 'tombstone(@SCHEMA@.entity, text, uuid)',
2049
- '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)',
2050
1605
  'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'visible_class(jsonb)', 'class_change_safe(jsonb, jsonb)',
2051
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)',
2052
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)',
@@ -2055,8 +1610,6 @@ begin
2055
1610
  execute format('revoke all on function @SCHEMA@.%s from public', f);
2056
1611
  end loop;
2057
1612
  end $$;
2058
- -- сырой SQL пишет теми же merge, inc и links_patch, что и библиотека; verify() — в 90-time.sql
2059
- -- до этапа 6 verify() был без параметров
2060
- drop function if exists @SCHEMA@.verify();
1613
+ -- сырой SQL пишет теми же merge, inc и links_patch, что и библиотека
2061
1614
  grant execute on function @SCHEMA@.merge(jsonb, jsonb), @SCHEMA@.inc(jsonb, text[], numeric, numeric), @SCHEMA@.links_patch(jsonb, jsonb),
2062
- @SCHEMA@.reserved_names(), @SCHEMA@.reclass(uuid, text, jsonb), @SCHEMA@.delete_preview(uuid[]) to @APP@;
1615
+ @SCHEMA@.reserved_names(), @SCHEMA@.delete_preview(uuid[]) to @APP@;