letopis 1.3.0 → 1.4.1

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 (56) hide show
  1. package/AGENT-CHEATSHEET.en.md +14 -6
  2. package/AGENT-CHEATSHEET.md +18 -10
  3. package/CHANGELOG.md +247 -0
  4. package/README.en.md +146 -58
  5. package/README.md +129 -51
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +42 -16
  9. package/dist/auth.d.ts +61 -17
  10. package/dist/auth.js +16 -2
  11. package/dist/cache.d.ts +5 -1
  12. package/dist/chain.d.ts +91 -15
  13. package/dist/chain.js +47 -4
  14. package/dist/cli.js +6 -0
  15. package/dist/errors.d.ts +2 -2
  16. package/dist/errors.js +5 -5
  17. package/dist/import.js +7 -0
  18. package/dist/index.d.ts +74 -45
  19. package/dist/index.js +18 -4
  20. package/dist/load.d.ts +10 -3
  21. package/dist/load.js +19 -4
  22. package/dist/mcp.d.ts +20 -11
  23. package/dist/mcp.js +4 -4
  24. package/dist/model.d.ts +4 -1
  25. package/dist/ops.d.ts +58 -18
  26. package/dist/ops.js +58 -18
  27. package/dist/project.d.ts +44 -12
  28. package/dist/project.js +90 -8
  29. package/dist/registry.d.ts +4 -1
  30. package/dist/registry.js +4 -1
  31. package/dist/sql.d.ts +4 -1
  32. package/dist/sql.js +12 -4
  33. package/dist/sync.js +11 -5
  34. package/dist/tx.js +10 -1
  35. package/dist/types.d.ts +14 -7
  36. package/dist/types.js +13 -6
  37. package/dist/up.d.ts +65 -25
  38. package/dist/up.js +152 -40
  39. package/dist/validate.d.ts +9 -2
  40. package/dist/validate.js +19 -3
  41. package/dist/watch.d.ts +17 -5
  42. package/dist/watch.js +40 -8
  43. package/dist/write.d.ts +3 -2
  44. package/dist/write.js +29 -10
  45. package/llms.txt +1 -1
  46. package/package.json +1 -1
  47. package/sql/10-core.sql +6 -1
  48. package/sql/50-acl.sql +5 -1
  49. package/sql/60-write.sql +77 -22
  50. package/sql/80-auth.sql +39 -2
  51. package/sql/88-derived.sql +82 -1
  52. package/sql/90-time.sql +75 -38
  53. package/sql/95-seed.system.sql +6 -3
  54. package/sql/99-revision.sql +3 -2
  55. package/sql/README.md +2 -0
  56. package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/write.js CHANGED
@@ -29,8 +29,9 @@ import { applyDefaults } from './validate.js';
29
29
  /** Метка приращения; Symbol.for — маркер узнаёт и вторая копия пакета в процессе. */
30
30
  export const INC = Symbol.for('letopis.inc');
31
31
  /**
32
- * Приращение в update: `update({ balance: inc(-300) })` — величина прибавляется в базе, внутри
33
- * оператора, над свежей строкой (функция inc). Поля нет — ошибка; `{ start }` — начать с него.
32
+ * Приращение числового поля в `update()`: `db.Wallet(w).update({ balance: inc(-300) })`. Сложение делает база над
33
+ * текущим значением поля в момент записи, поэтому одновременные приращения не теряются. Если поля ещё нет, `{ start }`
34
+ * задаёт, с чего начать (поле получит `start + by`), а без него — ошибка `invalid_data`.
34
35
  */
35
36
  export function inc(by, opts = {}) {
36
37
  return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
@@ -377,7 +378,11 @@ function applySlotsLocal(links, ops) {
377
378
  }
378
379
  return out;
379
380
  }
380
- const tagsValue = (s) => s.tagsFilter === undefined ? null : typeof s.tagsFilter === 'string' ? [s.tagsFilter] : s.tagsFilter;
381
+ /**
382
+ * Теги, которые пишет глагол: строка или список из `.tags(…)` (перед update они же и отбирают цели). Оператор
383
+ * (has, hasAny, hasAll) только отбирает — теги целей не меняются; create и upsert его не принимают (precheck).
384
+ */
385
+ const tagsValue = (s) => typeof s.tagsFilter === 'string' ? [s.tagsFilter] : Array.isArray(s.tagsFilter) ? s.tagsFilter : null;
381
386
  /** «Уже существует»: id вычисляет библиотека (idOf), а не разбирает текст ошибки сервера (§2.5). */
382
387
  function existsError(w, cls, data, links, given, cause) {
383
388
  let id = given;
@@ -440,12 +445,23 @@ async function explainMissing(w, missing, rev) {
440
445
  }
441
446
  return fail('acl_denied', `строки видны, но изменить их нельзя: ${seen.map((r) => r.id).join(', ')}`, { ids: seen.map((r) => r.id) });
442
447
  }
448
+ /**
449
+ * Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
450
+ * отдаёт их в порядке обхода таблицы, и `first()` после глагола брал бы случайную из исправленных строк.
451
+ */
452
+ function inTargetOrder(ids, rows) {
453
+ const pos = new Map(ids.map((id, i) => [id, i]));
454
+ return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
455
+ }
443
456
  /** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
444
457
  async function targetIds(w, steps, opStep, dataId) {
445
458
  const path = steps.map(bare);
446
459
  if (opStep.filter === undefined && dataId !== undefined)
447
460
  path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
448
- return readIds(w, path, opStep.opMods ?? {});
461
+ const ids = await readIds(w, path, opStep.opMods ?? {});
462
+ // без явного порядка — по id: глаголы по списку целей (reclass, rekey и др.) пишут журнал по очереди, и порядок
463
+ // записей не должен зависеть от плана чтения
464
+ return opStep.opMods?.order ? ids : [...ids].sort();
449
465
  }
450
466
  async function updateOp(w, steps, opStep) {
451
467
  const op = opStep.op;
@@ -456,7 +472,7 @@ async function updateOp(w, steps, opStep) {
456
472
  const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
457
473
  if (op.rev !== undefined) {
458
474
  if (ids.length > 1)
459
- fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — строгий режим только для одной`);
475
+ fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — проверить версию { rev } можно только у одной цели`);
460
476
  if (!ids.length)
461
477
  fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
462
478
  }
@@ -481,7 +497,7 @@ async function updateOp(w, steps, opStep) {
481
497
  const got = new Set(res.map((r) => r.row.id));
482
498
  await explainMissing(w, ids.filter((x) => !got.has(x)), op.rev);
483
499
  }
484
- return res.map((r) => toRow(r.row));
500
+ return inTargetOrder(ids, res.map((r) => toRow(r.row)));
485
501
  }
486
502
  async function deleteOp(w, steps, opStep) {
487
503
  const op = opStep.op;
@@ -510,9 +526,11 @@ async function deleteOp(w, steps, opStep) {
510
526
  }
511
527
  const tombs = await query(w, `select ${rowJson('l')} as row from ${S}.log l where l.tx = pg_current_xact_id() and l.seq > $1::bigint and l.op = 'delete' order by l.seq`, [mark]);
512
528
  const out = tombs.map((r) => toRow(r.row));
513
- // строки классов без журнала (history: false) надгробий не оставляют — берём их из returning
529
+ // строки классов без журнала (history: false) надгробий не оставляют — берём их из returning, по id (порядок
530
+ // returning зависит от плана, а итог delete() — нет: надгробия идут по seq, триггер пишет их в порядке id)
514
531
  const have = new Set(out.map((r) => r.id));
515
- for (const g of gone) {
532
+ const byId = (a, b) => String(a.row.id) < String(b.row.id) ? -1 : String(a.row.id) > String(b.row.id) ? 1 : 0;
533
+ for (const g of [...gone].sort(byId)) {
516
534
  if (have.has(g.row.id))
517
535
  continue;
518
536
  const row = toRow(g.row);
@@ -534,7 +552,7 @@ async function anonymizeOp(w, steps, opStep) {
534
552
  const got = new Set(res.map((r) => r.row.id));
535
553
  await explainMissing(w, ids.filter((x) => !got.has(x)));
536
554
  }
537
- return res.map((r) => toRow(r.row));
555
+ return inTargetOrder(ids, res.map((r) => toRow(r.row)));
538
556
  }
539
557
  async function reclassOp(w, steps, opStep) {
540
558
  const op = opStep.op;
@@ -554,7 +572,8 @@ async function deletedTargets(w, steps, opStep, verb) {
554
572
  const dead = rows.filter((r) => r.$deleted);
555
573
  if (!dead.length && rows.length)
556
574
  fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
557
- return dead;
575
+ // без явного порядка — по id: restore и purge пишут журнал по очереди целей
576
+ return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
558
577
  }
559
578
  /** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
560
579
  async function restoreOp(w, steps, opStep) {
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
4
4
 
5
- Key facts for writing code with letopis 1.3:
5
+ Key facts for writing code with letopis 1.4:
6
6
 
7
7
  - ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`; `@modelcontextprotocol/server`, only by `letopis/mcp`.
8
8
  - Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
5
5
  "keywords": [
6
6
  "postgres",
package/sql/10-core.sql CHANGED
@@ -107,9 +107,12 @@ create index if not exists log_ends on @SCHEMA@.log using gin (@SCHEMA@.link_ids
107
107
  -- журнал часто прореживается (§2.11): чаще чистим
108
108
  alter table @SCHEMA@.log set (autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.02);
109
109
 
110
+ -- account — строка entity (аккаунт; у служебных строк KEY, LOCK, PROJECTOR — System): удалённый аккаунт уносит свои строки
111
+ -- каскадом внешнего ключа — сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (ревизия 7; в схему
112
+ -- прежней ревизии ключ добавляет миграция 0007). Строка Account у System не удаляется (trg_entity_bd_account, 60-write.sql)
110
113
  create table if not exists @SCHEMA@.secret (
111
114
  id uuid primary key default gen_random_uuid(),
112
- account uuid not null,
115
+ account uuid not null constraint secret_account_fkey references @SCHEMA@.entity (id) on delete cascade,
113
116
  kind text not null, -- PASSWORD, APIKEY, KEYSECRET, OTP, TOTP, IDENTITY, SESSION
114
117
  ident text, -- email, телефон, префикс ключа, внешний id
115
118
  material bytea, -- хэш пароля, ключа, кода или токена; секрет TOTP
@@ -121,6 +124,8 @@ create table if not exists @SCHEMA@.secret (
121
124
  create unique index if not exists secret_kind_ident on @SCHEMA@.secret (kind, casefold(ident) collate pg_unicode_fast)
122
125
  where deleted_at is null and ident is not null;
123
126
  create index if not exists secret_material on @SCHEMA@.secret (material) where kind = 'SESSION' and deleted_at is null;
127
+ -- строки аккаунта: каскад внешнего ключа при удалении аккаунта и гашение его сессий (auth_kill) — без перебора таблицы
128
+ create index if not exists secret_account on @SCHEMA@.secret (account);
124
129
 
125
130
  alter table @SCHEMA@.entity owner to @OWNER@;
126
131
  alter table @SCHEMA@.log owner to @OWNER@;
package/sql/50-acl.sql CHANGED
@@ -228,9 +228,13 @@ begin
228
228
  and jsonb_typeof(x.pattern->'class') <> 'string', false) as bad) m
229
229
  where x.deny or not m.bad
230
230
  ), cl as (
231
- select c.data->>'name' as name, c.data->>'alias' as alias, c.data->'resolved'->'ancestors' as anc
231
+ -- один класс на имя — тот, что строки арендатора находят по имени (class_row): свой, иначе System. Одноимённый
232
+ -- класс System, заведённый после класса арендатора, в план не входит: его предки и алиас давали бы права на строки
233
+ -- класса арендатора (хвост ревью доработок после 1.2.0)
234
+ select distinct on (c.data->>'name') c.data->>'name' as name, c.data->>'alias' as alias, c.data->'resolved'->'ancestors' as anc
232
235
  from @SCHEMA@.entity c
233
236
  where c.class = 'Class' and c.tenant in (p_tenant, '@SYSTEM@'::uuid)
237
+ order by c.data->>'name', c.tenant = '@SYSTEM@'::uuid
234
238
  ), k as (
235
239
  select cl.name,
236
240
  array(select jsonb_array_elements_text(cl.anc))
package/sql/60-write.sql CHANGED
@@ -760,6 +760,14 @@ begin
760
760
  perform @SCHEMA@.lt_fail('invalid_class', format('имя %s занято системным классом', p_data->>'name'));
761
761
  end if;
762
762
  end if;
763
+ -- родитель — под разделяемой блокировкой своего семейства (хвост ревью доработок после 1.2.0): удаление класса берёт
764
+ -- исключительную, поэтому родитель не удалится мимо нового потомка, а удалённый, пока ждали, class_prepare не найдёт
765
+ if p_data->>'parent' is not null then
766
+ tc := @SCHEMA@.class_row(p_data->>'parent', p_tenant);
767
+ if tc.id is not null then
768
+ perform @SCHEMA@.family_lock(tc, false);
769
+ end if;
770
+ end if;
763
771
  select c.data, c.validator into data, validator from @SCHEMA@.class_prepare(p_data, p_tenant) c;
764
772
  if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
765
773
  perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
@@ -798,12 +806,15 @@ begin
798
806
  data->>'name'));
799
807
  end if;
800
808
  -- предок производного класса (ревью безопасности №3): правка предка пересчитывает описание потомка (class_evolve) —
801
- -- поля, ключ, history, — поэтому класс с производными потомками (своего арендатора, у класса System — всех) меняет
802
- -- тоже только администратор
809
+ -- поля, ключ, history, — поэтому класс с производными потомками (своего арендатора, у класса System — всех, кроме
810
+ -- арендаторов со своим одноимённым классом: их потомки наследуют свой класс) меняет тоже только администратор
803
811
  if prevd is not null and not @SCHEMA@.letopis_allowed('letopis.projector') then
804
812
  select x.data->>'name' into dkid from @SCHEMA@.entity x
805
813
  where x.class = 'Class' and x.data->>'name' <> data->>'name'
806
- and (x.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
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)))
807
818
  and x.data->'resolved'->'ancestors' ? (data->>'name') and x.data->'resolved' ? 'derivedBy'
808
819
  order by x.data->>'name' limit 1;
809
820
  if dkid is not null then
@@ -962,9 +973,12 @@ begin
962
973
  if cls.id is null then
963
974
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
964
975
  end if;
965
- -- разделяемая блокировка корня семейства, затем описание перечитывается под ней
976
+ -- разделяемая блокировка корня семейства, затем описание перечитывается под ней (класс могли удалить, пока ждали)
966
977
  perform @SCHEMA@.family_lock(cls, false);
967
978
  cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
979
+ if cls.id is null then
980
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', NEW.class));
981
+ end if;
968
982
  res := cls.data->'resolved';
969
983
  if coalesce((res->>'abstract')::boolean, false) then
970
984
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
@@ -1091,6 +1105,9 @@ begin
1091
1105
  end if;
1092
1106
  perform @SCHEMA@.family_lock(cls, false);
1093
1107
  cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
1108
+ if cls.id is null then
1109
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', NEW.class));
1110
+ end if;
1094
1111
  res := cls.data->'resolved';
1095
1112
  if coalesce((res->>'abstract')::boolean, false) then
1096
1113
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
@@ -1200,7 +1217,8 @@ begin
1200
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
1201
1218
  from added n
1202
1219
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
1203
- where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
1220
+ where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
1221
+ order by n.at, n.id; -- порядок seq — порядок входа, а не плана соединения
1204
1222
  -- сигнал — по паре «класс, арендатор»
1205
1223
  select array_agg(k.class), array_agg(k.tenant) into sc, st from (select distinct a.class, a.tenant from added a) k;
1206
1224
  perform @SCHEMA@.signal(sc, st);
@@ -1212,6 +1230,10 @@ begin
1212
1230
  -- прав после acl_bump видит новый класс
1213
1231
  perform @SCHEMA@.agg_source_admit(n.data->>'name', @SCHEMA@.agg_new_mark(null, n.data->'resolved'))
1214
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';
1215
1237
  -- новая цель агрегатов (план 1.1, §2.4): метки её источников (aggregatedBy) — заново
1216
1238
  perform @SCHEMA@.agg_mark(n.tenant, @SCHEMA@.agg_of(n.data->'resolved'))
1217
1239
  from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregate';
@@ -1252,7 +1274,8 @@ begin
1252
1274
  from new_rows n
1253
1275
  join old_rows o on o.id = n.id and o.rev <> n.rev
1254
1276
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
1255
- where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
1277
+ where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
1278
+ order by n.id; -- порядок seq не зависит от плана: правка многих строк пишет версии в порядке id
1256
1279
  -- сигнал — по парам нового и прежнего класса: при смене класса без ключа ответы прежнего класса в
1257
1280
  -- кэше других процессов устарели бы (в 1.0.1 сигнал шёл только по новому)
1258
1281
  select array_agg(k.class), array_agg(k.tenant) into sc, st
@@ -1269,6 +1292,7 @@ begin
1269
1292
  -- класс становится источником агрегата (смена parent; ревью коммита этапа 4б): право READ без условий на его строки
1270
1293
  -- и после правки — по новым предкам (до правки проверил class_admit)
1271
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');
1272
1296
  perform @SCHEMA@.class_evolve(o_row, n_row);
1273
1297
  end loop;
1274
1298
  -- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
@@ -1316,6 +1340,8 @@ begin
1316
1340
  if d.tenant = n.tenant then
1317
1341
  perform @SCHEMA@.agg_source_admit(d.data->>'name', @SCHEMA@.agg_new_mark(d.data->'resolved', prep.data->'resolved'));
1318
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');
1319
1345
  classes := classes || (d.data->>'name');
1320
1346
  if d.data->'resolved'->'aggregate' is distinct from prep.data->'resolved'->'aggregate' then
1321
1347
  agg_names := agg_names || @SCHEMA@.agg_of(d.data->'resolved') || @SCHEMA@.agg_of(prep.data->'resolved');
@@ -1361,6 +1387,24 @@ begin
1361
1387
  return null;
1362
1388
  end $$;
1363
1389
 
1390
+ -- Перед удалением строки Account (ревизия 7, решение владельца). Строки secret аккаунта — сессии, хэши паролей и ключей,
1391
+ -- секреты TOTP, внешние учётные записи — уносит каскад внешнего ключа secret.account в конце оператора, раньше триггера
1392
+ -- после него, поэтому сессии гаснут с сигналом session:<хэш> здесь: процессы сбрасывают кэш ответов этих сессий.
1393
+ -- Срабатывает для всех ролей, в том числе владельца: аккаунты удаляют и функции владельца — каскад trg_entity_ad и
1394
+ -- account_purge. Строка Account у System не удаляется: ей принадлежат служебные строки secret (KEY — ключ прав и
1395
+ -- сигналов, LOCK — строки-замки, PROJECTOR — чекпоинты проекторов), которые каскад стёр бы, а без них установка не пишет.
1396
+ create or replace function @SCHEMA@.trg_entity_bd_account() returns trigger
1397
+ language plpgsql security definer
1398
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1399
+ as $$
1400
+ begin
1401
+ if OLD.id = '@SYSTEM@'::uuid then
1402
+ perform @SCHEMA@.lt_fail('acl_denied', 'аккаунт System не удаляется: ему принадлежат служебные строки установки');
1403
+ end if;
1404
+ perform @SCHEMA@.auth_kill(OLD.id, null);
1405
+ return OLD;
1406
+ end $$;
1407
+
1364
1408
  -- Действие со строкой, которая ссылается на удаляемые id (p_ids), по правилам её концов (p_ends):
1365
1409
  -- cascade сильнее restrict, restrict сильнее unset; null — строка на них не ссылается. Общее правило
1366
1410
  -- для триггера удаления и превью delete_preview() (этап 4, п. 6): превью не расходится с удалением.
@@ -1432,12 +1476,11 @@ declare
1432
1476
  dplan jsonb;
1433
1477
  wplan jsonb;
1434
1478
  denied text;
1435
- accs uuid[];
1436
1479
  mems jsonb;
1437
- lvl_accs uuid[];
1438
1480
  lvl_mems jsonb;
1439
1481
  drows jsonb;
1440
1482
  own uuid[];
1483
+ cl @SCHEMA@.entity;
1441
1484
  begin
1442
1485
  select * into c from @SCHEMA@.ctx();
1443
1486
  -- права в замыкании (этап 5, п. 6): DELETE на каждую удаляемую каскадом строку, WRITE — на строку,
@@ -1453,6 +1496,13 @@ begin
1453
1496
  if denied is not null then
1454
1497
  perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки удаляет проектор', denied));
1455
1498
  end if;
1499
+ -- удаление класса (хвост ревью безопасности доработок после 1.2.0): проверки ниже — нет строк, потомков и истории —
1500
+ -- не видят строку, которую писатель ещё не зафиксировал, и она осталась бы без класса, а с ней история удалённого
1501
+ -- класса, от которой зависит смена алиаса предка. Писатели строк и создание потомка держат разделяемую блокировку
1502
+ -- семейства: исключительная ждёт их, и следующие операторы проверок видят зафиксированное
1503
+ 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
+ perform @SCHEMA@.family_lock(cl, true);
1505
+ end loop;
1456
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
1457
1507
  -- производный класс удаляет только администратор (class_admit)
1458
1508
  if r.derived and not @SCHEMA@.letopis_allowed('letopis.projector') then
@@ -1494,19 +1544,21 @@ begin
1494
1544
  perform @SCHEMA@.agg_mark(g.tenant, @SCHEMA@.agg_of(g.data->'resolved'))
1495
1545
  from gone g where g.class = 'Class' and g.data->'resolved' ? 'aggregate';
1496
1546
  v_author := coalesce(c.author, '@SYSTEM@'::uuid);
1497
- -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
1547
+ -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень, в порядке id: без
1548
+ -- order by порядок seq (а с ним итог delete() и время at) зависел от плана соединения и менялся от прогона к
1549
+ -- прогону; время now_ts() (volatile) планировщик вычисляет уже после сортировки
1498
1550
  insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
1499
1551
  select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
1500
1552
  greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash, '[]'::jsonb
1501
1553
  from gone g
1502
1554
  join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
1503
- where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant;
1555
+ where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
1556
+ order by g.id;
1504
1557
  -- пары «класс, арендатор» для сигнала — строкой «класс арендатор» (пробела нет ни в имени, ни в uuid)
1505
1558
  select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
1506
1559
  coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}'),
1507
- coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
1508
1560
  coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
1509
- into lvl, classes, pairs, accs, mems from gone g;
1561
+ into lvl, classes, pairs, mems from gone g;
1510
1562
  all_ids := lvl;
1511
1563
  while cardinality(lvl) > 0 loop
1512
1564
  -- кто ссылается на уровень: действие по правилам концов (общее с превью)
@@ -1534,20 +1586,19 @@ begin
1534
1586
  from d
1535
1587
  join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
1536
1588
  where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
1589
+ order by d.id -- надгробия уровня в порядке id, как и на уровне 0
1537
1590
  )
1538
1591
  select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
1539
1592
  coalesce(array_agg(distinct d.class || ' ' || d.tenant::text), '{}'),
1540
1593
  jsonb_agg(to_jsonb(d)) filter (where not @SCHEMA@.acl_row_ok(dplan, d.class, d.id, d.owner, d.tenant, d.tags, d.data, d.links)),
1541
- coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
1542
1594
  coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
1543
- into lvl, lvl_classes, lvl_pairs, drows, lvl_accs, lvl_mems from d;
1595
+ into lvl, lvl_classes, lvl_pairs, drows, lvl_mems from d;
1544
1596
  -- класс отказавшей строки называется, только если актор её видит (ревью безопасности №3)
1545
1597
  if drows is not null then
1546
1598
  denied := @SCHEMA@.visible_class(drows);
1547
1599
  perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права DELETE на класс %s в каскаде удаления', denied)
1548
1600
  else 'нет права DELETE на строки в каскаде удаления' end);
1549
1601
  end if;
1550
- accs := accs || lvl_accs;
1551
1602
  mems := mems || lvl_mems;
1552
1603
  classes := classes || lvl_classes;
1553
1604
  pairs := pairs || lvl_pairs;
@@ -1573,14 +1624,11 @@ begin
1573
1624
  end loop;
1574
1625
  -- выход из членства (и удаление аккаунта, которое снимает его членства): строки арендатора, которыми
1575
1626
  -- владел участник, переходят арендатору (§2.10) — новой версией
1576
- for r in select distinct (m->>'tenant')::uuid as tenant, (m->>'user')::uuid as usr from jsonb_array_elements(mems) m loop
1627
+ for r in select distinct (m->>'tenant')::uuid as tenant, (m->>'user')::uuid as usr from jsonb_array_elements(mems) m order by 1, 2 loop
1577
1628
  perform @SCHEMA@.owner_to_tenant(r.tenant, r.usr);
1578
1629
  end loop;
1579
- -- удалённый аккаунт: сессии гаснут, креды удаляются (идентификаторы освобождаются)
1580
- for r in select distinct u as acc from unnest(accs) u loop
1581
- perform @SCHEMA@.auth_kill(r.acc, null);
1582
- update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
1583
- end loop;
1630
+ -- удалённый аккаунт (ревизия 7): сессии погасил с сигналом триггер до удаления (trg_entity_bd_account), его строки
1631
+ -- secret стёр каскад внешнего ключа secret.account — идентификаторы входа свободны
1584
1632
  perform @SCHEMA@.signal(array_agg(split_part(x.p, ' ', 1)), array_agg(split_part(x.p, ' ', 2)::uuid))
1585
1633
  from (select distinct p from unnest(pairs) p) x;
1586
1634
  if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
@@ -1668,6 +1716,9 @@ create or replace trigger entity_au after update on @SCHEMA@.entity referencing
1668
1716
  for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_au();
1669
1717
  create or replace trigger entity_bd before delete on @SCHEMA@.entity
1670
1718
  for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bd();
1719
+ -- удаление аккаунта — для всех ролей, в том числе владельца (каскад trg_entity_ad, account_purge): ревизия 7
1720
+ create or replace trigger entity_bd_account before delete on @SCHEMA@.entity
1721
+ for each row when (OLD.class = 'Account') execute function @SCHEMA@.trg_entity_bd_account();
1671
1722
  create or replace trigger entity_ad after delete on @SCHEMA@.entity referencing old table as gone
1672
1723
  for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ad();
1673
1724
  -- в режиме репликации (session_replication_role = replica) триггеры тоже работают
@@ -1676,6 +1727,7 @@ alter table @SCHEMA@.entity enable always trigger entity_bu;
1676
1727
  alter table @SCHEMA@.entity enable always trigger entity_ai;
1677
1728
  alter table @SCHEMA@.entity enable always trigger entity_au;
1678
1729
  alter table @SCHEMA@.entity enable always trigger entity_bd;
1730
+ alter table @SCHEMA@.entity enable always trigger entity_bd_account;
1679
1731
  alter table @SCHEMA@.entity enable always trigger entity_ad;
1680
1732
 
1681
1733
  -- ========================= смена класса с ключом =========================
@@ -1808,6 +1860,9 @@ begin
1808
1860
  end if;
1809
1861
  perform @SCHEMA@.family_lock(cls, false);
1810
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;
1811
1866
  res := cls.data->'resolved';
1812
1867
  if coalesce((res->>'abstract')::boolean, false) then
1813
1868
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
@@ -1995,7 +2050,7 @@ begin
1995
2050
  'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'visible_class(jsonb)', 'class_change_safe(jsonb, jsonb)',
1996
2051
  '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)',
1997
2052
  '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)',
1998
- 'trg_entity_bd()', 'trg_entity_ad()', 'owner_to_tenant(uuid, uuid)', 'ref_action(jsonb, jsonb, uuid[])', 'delete_preview(uuid[])', 'merge(jsonb, jsonb)', 'inc(jsonb, text[], numeric, numeric)', 'links_patch(jsonb, jsonb)'] loop
2053
+ 'trg_entity_bd()', 'trg_entity_bd_account()', 'trg_entity_ad()', 'owner_to_tenant(uuid, uuid)', 'ref_action(jsonb, jsonb, uuid[])', 'delete_preview(uuid[])', 'merge(jsonb, jsonb)', 'inc(jsonb, text[], numeric, numeric)', 'links_patch(jsonb, jsonb)'] loop
1999
2054
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
2000
2055
  execute format('revoke all on function @SCHEMA@.%s from public', f);
2001
2056
  end loop;
package/sql/80-auth.sql CHANGED
@@ -795,6 +795,43 @@ begin
795
795
  where m.class = 'member' and m.links->>'User' = c.account::text and @SCHEMA@.auth_enabled(m.tenant);
796
796
  end $$;
797
797
 
798
+ -- Сведения о сессии по токену (db.auth.session): сервис, у которого есть токен пользователя (например, из
799
+ -- cookie), узнаёт, чья это сессия, жива ли она и в каком арендаторе работает, не подключаясь по этому токену.
800
+ -- Право auth.session, как у issue_session. Неверный, погашенный или истёкший токен и отключённый аккаунт — null:
801
+ -- причина не различается, как в ctx(). Сессия ищется только среди SESSION: ключ API или код с тем же sha256 не
802
+ -- подойдут. member — проверка членства из ctx() без исключения: членство в рабочем арендаторе цело и арендатор
803
+ -- включён; собственный арендатор — всегда true.
804
+ create or replace function @SCHEMA@.auth_session_info(p_token text) returns jsonb
805
+ language plpgsql stable security definer
806
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
807
+ as $$
808
+ declare
809
+ c record;
810
+ s record;
811
+ t uuid;
812
+ begin
813
+ select * into c from @SCHEMA@.ctx();
814
+ perform @SCHEMA@.auth_session_required(c.account);
815
+ perform @SCHEMA@.acl_require('auth.session');
816
+ select x.account, x.meta, x.expires_at into s from @SCHEMA@.secret x
817
+ where x.kind = 'SESSION' and x.deleted_at is null and x.material = sha256(convert_to(coalesce(p_token, ''), 'UTF8'))
818
+ and (x.expires_at is null or x.expires_at > clock_timestamp());
819
+ if s.account is null or not @SCHEMA@.auth_enabled(s.account) then
820
+ return null;
821
+ end if;
822
+ t := coalesce((s.meta->>'tenant')::uuid, s.account);
823
+ return jsonb_build_object(
824
+ 'account', s.account,
825
+ 'tenant', t,
826
+ 'member', t = s.account or (
827
+ exists (select 1 from @SCHEMA@.entity x
828
+ where x.id = @SCHEMA@.v5_id(t, 'member', array[s.account::text, t::text]) and x.class = 'member' and x.tenant = t)
829
+ and @SCHEMA@.auth_enabled(t)),
830
+ 'expiresAt', s.expires_at,
831
+ 'loginAt', s.meta->'login_at',
832
+ 'methods', coalesce(s.meta->'methods', '[]'::jsonb));
833
+ end $$;
834
+
798
835
  do $$
799
836
  declare f text;
800
837
  begin
@@ -807,7 +844,7 @@ begin
807
844
  'login_otp(text, text, text)', 'totp_code(bytea, bigint)', 'auth_enroll_totp(uuid)', 'auth_verify_totp(uuid, text, int)',
808
845
  'auth_totp_enabled(uuid)', 'auth_link(uuid, text, text, jsonb, boolean)', 'auth_lookup(text, text, boolean)',
809
846
  'auth_credentials(uuid)', 'auth_revoke_credential(uuid, boolean)', 'auth_set_flags(uuid, boolean, boolean)',
810
- 'auth_refresh(interval)', 'auth_logout()', 'auth_logout_all(uuid)', 'auth_switch(uuid)', 'auth_tenants()'] loop
847
+ 'auth_refresh(interval)', 'auth_logout()', 'auth_logout_all(uuid)', 'auth_switch(uuid)', 'auth_tenants()', 'auth_session_info(text)'] loop
811
848
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
812
849
  execute format('revoke all on function @SCHEMA@.%s from public', f);
813
850
  end loop;
@@ -824,4 +861,4 @@ grant execute on function
824
861
  @SCHEMA@.auth_totp_enabled(uuid), @SCHEMA@.auth_link(uuid, text, text, jsonb, boolean), @SCHEMA@.auth_lookup(text, text, boolean),
825
862
  @SCHEMA@.auth_credentials(uuid), @SCHEMA@.auth_revoke_credential(uuid, boolean), @SCHEMA@.auth_set_flags(uuid, boolean, boolean),
826
863
  @SCHEMA@.auth_refresh(interval), @SCHEMA@.auth_logout(), @SCHEMA@.auth_logout_all(uuid), @SCHEMA@.auth_switch(uuid),
827
- @SCHEMA@.auth_tenants() to @APP@;
864
+ @SCHEMA@.auth_tenants(), @SCHEMA@.auth_session_info(text) to @APP@;
@@ -143,6 +143,9 @@ begin
143
143
  -- как у писателей приложения: разделяемая блокировка семейства, описание — свежее под ней
144
144
  perform @SCHEMA@.family_lock(cls, false);
145
145
  cls := @SCHEMA@.class_row(cname, tid);
146
+ if cls.id is null then
147
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', cname) || w);
148
+ end if;
146
149
  res := cls.data->'resolved';
147
150
  if res->>'derivedBy' is distinct from p_name then
148
151
  perform @SCHEMA@.lt_fail('derived_class', format('класс %s не производный проектора %s', cname, p_name) || w);
@@ -269,6 +272,7 @@ begin
269
272
  from g
270
273
  join (select d.class, d.tenant from (select distinct y.class, y.tenant from g y) d
271
274
  where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
275
+ order by g.id -- надгробия в порядке id, как в trg_entity_ad
272
276
  )
273
277
  select count(*)::int, coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}') into n, pairs from g;
274
278
  if n > 0 then
@@ -581,6 +585,82 @@ begin
581
585
  end if;
582
586
  end $$;
583
587
 
588
+ -- Кто описал агрегат p_field цели p_target (строка Class): автор версии строки Class, с которой развёрнутое описание
589
+ -- агрегата (своё или унаследованное) стало нынешним; надгробие обрывает описание — пересоздавший класс описал агрегат
590
+ -- заново. Журнал строк Class не прореживается и не стирается (purge и обрезка их не берут). Сид и пути владельца без
591
+ -- сессии пишут автора System.
592
+ create or replace function @SCHEMA@.agg_author(p_target @SCHEMA@.entity, p_field text) returns uuid
593
+ language sql stable
594
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
595
+ return (select l.author from @SCHEMA@.log l
596
+ where l.id = p_target.id and l.op not in ('purge', 'trim', 'reset', 'delete')
597
+ and l.data->'resolved'->'aggregate'->p_field is not distinct from p_target.data->'resolved'->'aggregate'->p_field
598
+ and (select case when p.op = 'delete' then null else p.data->'resolved'->'aggregate'->p_field end
599
+ from @SCHEMA@.log p
600
+ where p.id = l.id and p.rev < l.rev and p.op not in ('purge', 'trim', 'reset')
601
+ order by p.rev desc limit 1) is distinct from p_target.data->'resolved'->'aggregate'->p_field
602
+ order by l.rev desc limit 1);
603
+
604
+ -- Аккаунт p_account читает без условий все строки класса p_class арендатора p_tenant: план READ — как у acl_subject
605
+ -- (аккаунт включён и лежит в System, арендатор — сам аккаунт или по членству), но без отказа: нет аккаунта или
606
+ -- членства — права нет.
607
+ create or replace function @SCHEMA@.agg_author_reads(p_account uuid, p_tenant uuid, p_class text) returns boolean
608
+ language plpgsql stable
609
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
610
+ as $$
611
+ declare
612
+ a jsonb;
613
+ m jsonb;
614
+ begin
615
+ select e.data into a from @SCHEMA@.entity e
616
+ where e.id = p_account and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid and coalesce((e.data->>'enabled')::boolean, true);
617
+ if a is null then
618
+ return false;
619
+ end if;
620
+ if p_tenant <> p_account then
621
+ select x.data into m from @SCHEMA@.entity x
622
+ where x.id = @SCHEMA@.v5_id(p_tenant, 'member', array[p_account::text, p_tenant::text]) and x.class = 'member' and x.tenant = p_tenant;
623
+ if m is null then
624
+ return false;
625
+ end if;
626
+ end if;
627
+ return coalesce(@SCHEMA@.acl_plan_for(p_account, p_tenant,
628
+ array(select jsonb_array_elements_text(coalesce(a->'categories', '[]'::jsonb))),
629
+ case when p_tenant = p_account then array['owner']
630
+ else array(select jsonb_array_elements_text(coalesce(m->'roles', '[]'::jsonb))) end,
631
+ 'READ') -> p_class = '{}'::jsonb, false);
632
+ end $$;
633
+
634
+ -- Класс p_class арендатора p_tenant становится источником агрегатов — новые метки aggregatedBy описания p_new против
635
+ -- p_old (хвост ревью безопасности доработок после 1.2.0, решение владельца): его строки войдут в агрегат, и описавший
636
+ -- агрегат читал бы в истории цели вклад каждой из них, — поэтому право READ без условий на все строки класса нужно и
637
+ -- описавшему, иначе acl_denied. Объём — тот же, что при описании (class_admit, agg_classes): у агрегата класса
638
+ -- арендатора — классы арендатора, у агрегата класса System — классы System. Класс арендатора под агрегатом System
639
+ -- описавший не проверял и при описании: его, как и прежде, проверяет только тот, кто заводит (agg_source_admit).
640
+ -- Описавший System (сид, путь владельца без сессии) не проверяется.
641
+ create or replace function @SCHEMA@.agg_author_admit(p_class text, p_tenant uuid, p_old jsonb, p_new jsonb) returns void
642
+ language plpgsql stable
643
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
644
+ as $$
645
+ declare
646
+ mk jsonb;
647
+ tc @SCHEMA@.entity;
648
+ who uuid;
649
+ begin
650
+ for mk in select x from jsonb_array_elements(coalesce(p_new->'aggregatedBy', '[]'::jsonb)) x
651
+ where not coalesce(p_old->'aggregatedBy', '[]'::jsonb) @> jsonb_build_array(x)
652
+ order by x->>'class', x->>'field' loop
653
+ tc := @SCHEMA@.class_row(mk->>'class', p_tenant);
654
+ continue when tc.id is null or (tc.tenant = '@SYSTEM@'::uuid and p_tenant <> '@SYSTEM@'::uuid);
655
+ who := @SCHEMA@.agg_author(tc, mk->>'field');
656
+ continue when who is null or who = '@SYSTEM@'::uuid;
657
+ if not @SCHEMA@.agg_author_reads(who, tc.tenant, p_class) then
658
+ perform @SCHEMA@.lt_fail('acl_denied', format('класс %s становится источником агрегата %s.%s, а описавший агрегат не читает все его строки (право READ без условий): агрегат описывает заново тот, кто их читает',
659
+ p_class, mk->>'class', mk->>'field'));
660
+ end if;
661
+ end loop;
662
+ end $$;
663
+
584
664
  -- Метки источников заново: классы арендатора p_tenant (System — всех), в предках которых есть одно из имён p_names;
585
665
  -- изменившееся описание — новой версией строки Class (журнал, сигнал).
586
666
  create or replace function @SCHEMA@.agg_mark(p_tenant uuid, p_names text[]) returns void
@@ -1142,7 +1222,8 @@ begin
1142
1222
  foreach f in array array['agg_fields(jsonb)', 'agg_of(jsonb)', 'agg_zero(jsonb, jsonb)', 'agg_num(jsonb)', 'agg_where_ok(jsonb, jsonb)',
1143
1223
  'agg_guard(jsonb, jsonb, jsonb, boolean)', 'agg_keep(text, uuid, jsonb, jsonb)', 'agg_check_source(jsonb, jsonb, text, jsonb)',
1144
1224
  'agg_cycle(uuid, jsonb)', 'agg_prepare(jsonb, jsonb, uuid)', 'agg_source(jsonb, uuid)', 'agg_marked(jsonb, jsonb)',
1145
- 'agg_new_mark(jsonb, jsonb)', 'agg_source_admit(text, jsonb)', 'agg_mark(uuid, text[])', 'agg_lock_sources(jsonb, jsonb, uuid)',
1225
+ 'agg_new_mark(jsonb, jsonb)', 'agg_source_admit(text, jsonb)', 'agg_author(@SCHEMA@.entity, text)',
1226
+ 'agg_author_reads(uuid, uuid, text)', 'agg_author_admit(text, uuid, jsonb, jsonb)', 'agg_mark(uuid, text[])', 'agg_lock_sources(jsonb, jsonb, uuid)',
1146
1227
  'agg_classes(text, uuid, boolean)', 'agg_compute(jsonb, uuid, uuid)',
1147
1228
  'agg_check(text, uuid, jsonb)', 'agg_write(@SCHEMA@.entity, jsonb, boolean)', 'agg_token(text)', 'agg_held()',
1148
1229
  'agg_hold_begin(text)', 'agg_hold_end(boolean)', 'agg_merge(jsonb, jsonb)', 'agg_flush(jsonb)', 'agg_kind(text, uuid)',