letopis 1.0.1 → 1.1.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.
package/dist/watch.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Executor } from './tx.js';
2
2
  import type { Row } from './types.js';
3
+ import type { Signal } from './signals.js';
3
4
  export interface WatchOptions {
4
5
  /** Откуда: курсор события, 'now' (по умолчанию — изменения после подписки) или 'start' (весь журнал). */
5
6
  from?: string;
@@ -26,9 +27,17 @@ export interface WatchCtx {
26
27
  exec: Executor;
27
28
  /** Классы подписки с потомками (null — все). */
28
29
  classes: string[] | null;
29
- /** Подписка на сигналы базы; возвращает отписку. */
30
- onSignal?: (fn: (name: string) => void) => () => void;
30
+ /** Подписка на сигналы базы (разобранные); возвращает отписку. */
31
+ onSignal?: (fn: (s: Signal) => void) => () => void;
32
+ /** Токен арендатора подписки (null — процесс без ключа или режим без токенов). */
33
+ tenant?: () => string | null;
31
34
  }
35
+ /**
36
+ * Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
37
+ * любой); сессии не будят; *, opaque без ключа и нагрузка не по режиму — будят. Незнакомый токен класса
38
+ * будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
39
+ */
40
+ export declare function wakes(s: Signal, watched: ReadonlySet<string> | null, mine: string | null): boolean;
32
41
  export declare class Subscription implements AsyncIterable<WatchEvent> {
33
42
  private ctx;
34
43
  private opts;
package/dist/watch.js CHANGED
@@ -8,8 +8,9 @@
8
8
  * транзакций (pg_snapshot_xmin) — транзакцию с меньшим номером, зафиксированную позже, курсор уже
9
9
  * не обгонит, поэтому событий нет ни потерянных, ни повторных. Поток стоит, пока открыта более
10
10
  * старая пишущая транзакция (горизонт общий для кластера); метрика — lag(). Чтение идёт под RLS
11
- * журнала: каждый видит только своё. Сигнал базы с именем класса только будит подписку (чужие
12
- * классы — не будят); без LISTEN и на случай потери сигнала — запасной опрос.
11
+ * журнала: каждый видит только своё. Сигнал базы только будит подписку: свои классы и свой арендатор
12
+ * (чужие — не будят; процесс без ключа сигналов арендатора не различает, в режиме opaque — и класса);
13
+ * без LISTEN и на случай потери сигнала — запасной опрос.
13
14
  *
14
15
  * Курсор подписчика старше яруса all политики хранения его классов — cursor_expired: события могли
15
16
  * проредиться, нужно перечитать текущее состояние и подписаться заново.
@@ -28,6 +29,25 @@ function decode(c) {
28
29
  }
29
30
  /** Снимок ещё нужен: курсор не дошёл до его xmax. */
30
31
  const keepSnap = (snap, tx) => snap && BigInt(tx) < BigInt(snap.split(':')[1]) ? snap : undefined;
32
+ /**
33
+ * Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
34
+ * любой); сессии не будят; *, opaque без ключа и нагрузка не по режиму — будят. Незнакомый токен класса
35
+ * будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
36
+ */
37
+ export function wakes(s, watched, mine) {
38
+ const ours = (t) => t === null || mine === null || t === mine;
39
+ switch (s.kind) {
40
+ case 'session':
41
+ case 'sessions':
42
+ return false;
43
+ case 'class':
44
+ return (!watched || watched.has(s.cls)) && ours(s.tenant);
45
+ case 'unknown':
46
+ return !watched && ours(s.tenant);
47
+ default:
48
+ return true;
49
+ }
50
+ }
31
51
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
52
  export class Subscription {
33
53
  ctx;
@@ -60,10 +80,8 @@ export class Subscription {
60
80
  }
61
81
  if (ctx.onSignal) {
62
82
  const watched = ctx.classes ? new Set(ctx.classes) : null;
63
- this.off = ctx.onSignal((name) => {
64
- if (name === '' || name.startsWith('session:'))
65
- return;
66
- if (watched && name !== '*' && !watched.has(name))
83
+ this.off = ctx.onSignal((s) => {
84
+ if (!wakes(s, watched, ctx.tenant?.() ?? null))
67
85
  return;
68
86
  this.dirty = true;
69
87
  this.wake?.();
package/dist/write.d.ts CHANGED
@@ -17,7 +17,8 @@ export declare function inc(by: number, opts?: {
17
17
  start?: number;
18
18
  }): IncMarker;
19
19
  export declare const isInc: (v: unknown) => v is IncMarker;
20
- export type PlanOp = {
20
+ /** Глагол плана; reason — причина изменения этого глагола (план 1.1, §2.1). */
21
+ export type PlanOp = ({
21
22
  kind: 'create';
22
23
  data: Record<string, unknown>;
23
24
  } | {
@@ -45,7 +46,11 @@ export type PlanOp = {
45
46
  } | {
46
47
  kind: 'rekey';
47
48
  data: Record<string, unknown>;
49
+ }) & {
50
+ reason?: string;
48
51
  };
52
+ /** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
53
+ export declare function checkReason(v: unknown, where: string): string;
49
54
  /** Значение слота: id, список id, строка ответа или ленивая цепочка (в той же транзакции). */
50
55
  export type SlotValue = string | string[] | {
51
56
  id: string;
@@ -81,6 +86,10 @@ export interface WriteCtx {
81
86
  tenant: string | null;
82
87
  /** Владелец новых строк по умолчанию (db.as(…, { owner })). */
83
88
  owner?: string;
89
+ /** Причина по умолчанию для глаголов плана и та, что стоит в транзакции; cur — выставленная сейчас. */
90
+ reason?: string;
91
+ txReason?: string;
92
+ cur?: string;
84
93
  }
85
94
  /** Чтение в транзакции плана — тот же построитель, что у цепочек. */
86
95
  export declare function readIn<T>(w: WriteCtx, steps: Step[], mods: ChainMods, mode: ReadMode, agg?: AggSpec): Promise<{
package/dist/write.js CHANGED
@@ -35,6 +35,13 @@ export function inc(by, opts = {}) {
35
35
  return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
36
36
  }
37
37
  export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === true;
38
+ /** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
39
+ export function checkReason(v, where) {
40
+ if (typeof v !== 'string' || v.length > 1000 || v.includes('\0')) {
41
+ throw new LetopisError('invalid_query', `${where}: reason — строка до 1000 символов`);
42
+ }
43
+ return v;
44
+ }
38
45
  const fail = (code, msg, detail) => {
39
46
  throw new LetopisError(code, msg, detail);
40
47
  };
@@ -126,6 +133,8 @@ export function precheckPlan(reg, steps) {
126
133
  continue;
127
134
  }
128
135
  const verb = op.kind;
136
+ if (op.reason !== undefined)
137
+ checkReason(op.reason, `${verb}()`);
129
138
  const m = s.opMods ?? {};
130
139
  if (m.asOf !== undefined)
131
140
  fail('invalid_query', `${verb}(): asOf описывает прошлое — записывать в него нельзя`);
@@ -188,6 +197,14 @@ export function precheckPlan(reg, steps) {
188
197
  prevOp = op;
189
198
  }
190
199
  }
200
+ /** letopis.reason перед оператором глагола — только если причина меняется (план 1.1, §2.1). */
201
+ async function useReason(w, want) {
202
+ if (want === (w.cur ?? w.txReason ?? ''))
203
+ return;
204
+ await query(w, `select set_config('letopis.reason', $1, true)`, [want]);
205
+ w.cur = want;
206
+ }
207
+ const reasonFor = (w, s) => s.op?.reason ?? w.reason ?? '';
191
208
  const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
192
209
  /** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
193
210
  const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
@@ -578,7 +595,8 @@ async function rekeyOp(w, steps, opStep) {
578
595
  const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [moved]);
579
596
  return res.map((r) => toRow(r.row));
580
597
  }
581
- function opCall(w, steps, opStep) {
598
+ async function opCall(w, steps, opStep) {
599
+ await useReason(w, reasonFor(w, opStep));
582
600
  switch (opStep.op.kind) {
583
601
  case 'create': return createOp(w, steps, opStep, false);
584
602
  case 'upsert': return createOp(w, steps, opStep, true);
@@ -652,9 +670,11 @@ export async function execPlan(w, steps, mods, mode, agg) {
652
670
  : kind === 'purge' ? [] : last;
653
671
  prevOp = opStep;
654
672
  }
673
+ // причина глагола не переходит на следующие операторы транзакции
674
+ await useReason(w, w.reason ?? '');
655
675
  const opStep = segments[segments.length - 1].steps.at(-1);
656
676
  const key = opStep.aliasKey ?? opStep.name;
657
- if (tail.length || mode === 'versions' || mode === 'agg') {
677
+ if (tail.length || mode === 'versions' || mode === 'agg' || mode === 'diff' || mode === 'blame') {
658
678
  if (!start.length)
659
679
  return { kind: 'read', q: null, res: [] };
660
680
  const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
@@ -708,7 +728,10 @@ export async function execBatch(w, plans) {
708
728
  const group = [];
709
729
  const seen = new Set();
710
730
  let j = i;
711
- while (j < plans.length && pureKind(plans[j]) === 'keyed' && plans[j][0].op.kind === verb && plans[j][0].cls.name === head.cls.name) {
731
+ // одна причина на оператор: другая причина режет группу
732
+ const why = reasonFor(w, head);
733
+ while (j < plans.length && pureKind(plans[j]) === 'keyed' && plans[j][0].op.kind === verb && plans[j][0].cls.name === head.cls.name
734
+ && reasonFor(w, plans[j][0]) === why) {
712
735
  const s = plans[j][0];
713
736
  const id = idFor(s);
714
737
  if (!id || (verb === 'upsert' && seen.has(id)))
@@ -717,6 +740,7 @@ export async function execBatch(w, plans) {
717
740
  group.push({ at: j, s, id });
718
741
  j++;
719
742
  }
743
+ await useReason(w, why);
720
744
  const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
721
745
  const conflict = verb === 'upsert'
722
746
  ? 'on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags'
@@ -738,9 +762,11 @@ export async function execBatch(w, plans) {
738
762
  }
739
763
  else if (kind === 'keyless') {
740
764
  let j = i;
741
- while (j < plans.length && pureKind(plans[j]) === 'keyless')
765
+ const why = reasonFor(w, head);
766
+ while (j < plans.length && pureKind(plans[j]) === 'keyless' && reasonFor(w, plans[j][0]) === why)
742
767
  j++;
743
- // однострочные вставки без ожидания ответа каждой — postgres.js шлёт их конвейером
768
+ // причина — до конвейера; однострочные вставки без ожидания ответа каждой — postgres.js шлёт их конвейером
769
+ await useReason(w, why);
744
770
  const rows = await Promise.all(plans.slice(i, j).map((p) => createOp(w, p, p[0], false)));
745
771
  rows.forEach((r, k) => (out[i + k] = r));
746
772
  i = j;
@@ -751,5 +777,6 @@ export async function execBatch(w, plans) {
751
777
  i++;
752
778
  }
753
779
  }
780
+ await useReason(w, w.txReason ?? '');
754
781
  return out;
755
782
  }
package/llms.txt CHANGED
@@ -2,13 +2,16 @@
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.0:
5
+ Key facts for writing code with letopis 1.1:
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`.
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`.
9
9
  - Classes are hubs (objects) and links (relations with ends), described with `hub()`/`link()` from `letopis/model` and written to the database with `syncModels()` or `letopis sync`. A class with a key gets a deterministic UUID v5 id (`db.idOf`); uniqueness is the key.
10
10
  - Verbs return a chain; a terminal (`rows`, `first`, `ids`, `count`, …) executes the whole plan in one transaction. `create` only creates (a taken key is `exists`), `update` merges and never creates, `upsert` replaces the whole object (keyed classes only), `delete()` is a preview and `delete({ confirm: true })` deletes.
11
11
  - Money and counters: `update({ balance: inc(n) })` adds inside the statement; `update(patch, { rev })` fails with `conflict` if the row changed; `forUpdate()` locks rows inside `db.begin()`.
12
+ - History: `versions()`, `diff({ from, to })` and `blame()` (who, when and why last changed each field); verbs, `db.begin()` and `db.batch()` take `{ reason }`.
13
+ - Bulk loading from an admin connection: `db.load(rows)`; into an empty schema `db.load(rows, { initial: true })` — one transaction, indexes and triggers rebuilt at the end.
14
+ - NOTIFY signals are visible to any database role: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` chooses what they reveal (default `class`).
12
15
  - Access rules are always on and deny by default; `up({ tenant })` or `createTenant()` creates a tenant with the rule "authenticated users may do everything" and its first user.
13
16
  - Errors carry a lowercase `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, `removed` (0.21 API with its replacement), …
14
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
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",
@@ -90,6 +90,7 @@
90
90
  "@electric-sql/pglite-socket": "^0.2.11",
91
91
  "@types/node": "^22.20.5",
92
92
  "letopis-0": "npm:letopis@0.21.0",
93
+ "letopis-1": "npm:letopis@1.0.1",
93
94
  "tsx": "^4.23.15",
94
95
  "typescript": "~5.9.3",
95
96
  "zod": "4.6.5"
package/sql/10-core.sql CHANGED
@@ -86,6 +86,9 @@ create table if not exists @SCHEMA@.log (
86
86
  prev_hash bytea,
87
87
  hash bytea generated always as (@SCHEMA@.version_hash(prev_hash,
88
88
  @SCHEMA@.snapshot(id, rev, class, tenant, owner, links, data, tags, at, author, agent, op, reason, moved))) stored,
89
+ -- пути изменения против предыдущей версии (план 1.1, ревизия 6): производные, в снимок и хэш не входят;
90
+ -- null — не посчитано (создание, загрузчик, импорт), blame досчитывает сравнением
91
+ changed jsonb,
89
92
  constraint log_id_rev unique (id, rev)
90
93
  );
91
94
  create index if not exists log_class_at on @SCHEMA@.log (class, at);
@@ -125,7 +128,11 @@ revoke all on @SCHEMA@.entity, @SCHEMA@.log, @SCHEMA@.secret from public, @APP@;
125
128
  grant select, delete on @SCHEMA@.entity to @APP@;
126
129
  grant insert (id, tenant, class, links, data, tags, owner) on @SCHEMA@.entity to @APP@;
127
130
  grant update (class, links, data, tags) on @SCHEMA@.entity to @APP@;
128
- grant select on @SCHEMA@.log to @APP@;
131
+ -- журнал — без колонки changed (ревью №1 точки А плана 1.1): её пути описывают разницу с предыдущей версией, а та
132
+ -- читателю может быть не видна (условие ACL скрыло её и пропустило следующую); blame получает пути через
133
+ -- version_paths, которая проверяет видимость обеих версий (75-history.sql)
134
+ grant select (seq, tx, id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, hash)
135
+ on @SCHEMA@.log to @APP@;
129
136
  alter table @SCHEMA@.entity enable row level security;
130
137
  alter table @SCHEMA@.log enable row level security;
131
138
  alter table @SCHEMA@.secret enable row level security;
package/sql/60-write.sql CHANGED
@@ -260,34 +260,74 @@ language sql stable
260
260
  set search_path = pg_catalog, @SCHEMA@, pg_temp
261
261
  return coalesce((@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved'->>'history', 'true') <> 'false';
262
262
 
263
- -- Сигнал с именами изменённых классов (§2.12, решение 9 точки А). Транзакция с NOTIFY фиксируется
264
- -- под общей блокировкой кластера вместе с fsync, поэтому библиотека помечает свои транзакции
265
- -- letopis.notify = 'deferred': имена копятся в letopis.changed, а библиотека после фиксации
266
- -- отправляет их одной транзакцией без данных. Сырой SQL шлёт NOTIFY здесь же, вместе с фиксацией.
267
- create or replace function @SCHEMA@.signal(p_classes text[]) returns void
263
+ -- Сигналы (план 1.1, §2.2): канал — имя схемы, нагрузка — по режиму установки '@SIGNALS@'
264
+ -- (up({ signals })): class — имя класса; tenant — «Класс@токен арендатора»; opaque — «токен класса@токен
265
+ -- арендатора»; none — сигналов нет. Сессии — session:<16 hex>, сброс — * (кроме none). Токен — первые
266
+ -- 96 бит HMAC-SHA256 от «назначение:значение» на ключе сигналов (HMAC ключа строки KEY по
267
+ -- 'letopis:signals', его отдаёт signal_key()): без ключа псевдоним не сопоставить с именем, а
268
+ -- нагрузка детерминирована — PostgreSQL склеивает одинаковые сигналы транзакции.
269
+ -- до плана 1.1 сигнал принимал только имена классов
270
+ drop function if exists @SCHEMA@.signal(text[]);
271
+
272
+ create or replace function @SCHEMA@.signal_token(p_purpose text, p_value text, p_key bytea) returns text
273
+ language sql immutable parallel safe
274
+ return left(encode(@PGCRYPTO@.hmac(convert_to(p_purpose || ':' || p_value, 'UTF8'), p_key, 'sha256'), 'hex'), 24);
275
+
276
+ -- Готовые нагрузки: сигналы записи от signal(), сессии и сброс. Транзакция с NOTIFY фиксируется под
277
+ -- общей блокировкой кластера вместе с fsync, поэтому библиотека помечает свои транзакции
278
+ -- letopis.notify = 'deferred': нагрузки копятся в letopis.changed, а библиотека после фиксации
279
+ -- сбрасывает по ним свой кэш и отправляет их одной транзакцией без данных (в режиме none — не
280
+ -- отправляет). Сырой SQL шлёт NOTIFY здесь же, вместе с фиксацией; в режиме none — ничего.
281
+ create or replace function @SCHEMA@.signal_raw(p_payloads text[]) returns void
268
282
  language plpgsql volatile
269
283
  set search_path = pg_catalog, @SCHEMA@, pg_temp
270
284
  as $$
271
285
  declare
272
286
  acc text[];
273
- cl text;
287
+ pl text;
274
288
  begin
275
- if p_classes is null or cardinality(p_classes) = 0 then
289
+ p_payloads := array(select distinct x from unnest(p_payloads) x where x <> '' order by x);
290
+ if cardinality(p_payloads) = 0 then
276
291
  return;
277
292
  end if;
278
293
  if coalesce(current_setting('letopis.notify', true), '') = 'deferred' then
279
294
  acc := coalesce(string_to_array(nullif(current_setting('letopis.changed', true), ''), ','), '{}');
280
- if not (acc @> p_classes) then
295
+ if not (acc @> p_payloads) then
281
296
  perform set_config('letopis.changed',
282
- array_to_string(array(select distinct x from unnest(acc || p_classes) x order by x), ','), true);
297
+ array_to_string(array(select distinct x from unnest(acc || p_payloads) x order by x), ','), true);
283
298
  end if;
284
- else
285
- foreach cl in array p_classes loop
286
- perform pg_notify('@SCHEMA_NAME@', cl);
299
+ elsif '@SIGNALS@' <> 'none' then
300
+ foreach pl in array p_payloads loop
301
+ perform pg_notify('@SCHEMA_NAME@', pl);
287
302
  end loop;
288
303
  end if;
289
304
  end $$;
290
305
 
306
+ -- Сигнал записи: пары «класс, арендатор» изменённых строк (арендатор null — System) → нагрузки по
307
+ -- режиму. Одинаковые пары склеиваются: транзакция, записавшая тысячу строк одного класса одного
308
+ -- арендатора, посылает один сигнал.
309
+ create or replace function @SCHEMA@.signal(p_classes text[], p_tenants uuid[]) returns void
310
+ language plpgsql volatile
311
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
312
+ as $$
313
+ declare
314
+ k bytea;
315
+ begin
316
+ if p_classes is null or cardinality(p_classes) = 0 then
317
+ return;
318
+ end if;
319
+ if '@SIGNALS@' in ('tenant', 'opaque') then
320
+ select @PGCRYPTO@.hmac(convert_to('letopis:signals', 'UTF8'), s.material, 'sha256') into k
321
+ from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY';
322
+ end if;
323
+ perform @SCHEMA@.signal_raw(array(
324
+ select case '@SIGNALS@'
325
+ when 'tenant' then p.c || '@' || @SCHEMA@.signal_token('tenant', p.t::text, k)
326
+ when 'opaque' then @SCHEMA@.signal_token('class', p.c, k) || '@' || @SCHEMA@.signal_token('tenant', p.t::text, k)
327
+ else p.c end
328
+ from (select distinct u.c, coalesce(u.t, '@SYSTEM@'::uuid) as t from unnest(p_classes, p_tenants) u(c, t) where u.c is not null) p));
329
+ end $$;
330
+
291
331
  -- Версия одной строки в журнал и сигнал — для функций владельца, которые пишут строку сами
292
332
  -- (пересчёт потомков, снятие конца, перенос на новый id). Классы с history: false журнал не ведут,
293
333
  -- но сигнал посылают. Обычную запись журналируют триггеры после оператора — одной вставкой.
@@ -295,12 +335,19 @@ create or replace function @SCHEMA@.log_version(e @SCHEMA@.entity) returns void
295
335
  language plpgsql volatile
296
336
  set search_path = pg_catalog, @SCHEMA@, pg_temp
297
337
  as $$
338
+ declare
339
+ p @SCHEMA@.log;
298
340
  begin
299
341
  if @SCHEMA@.history_on(e.class, e.tenant) then
300
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
301
- values (e.id, e.rev, e.class, e.cv, e.tenant, e.owner, e.links, e.data, e.tags, e.at, e.author, e.agent, e.op, e.reason, e.moved, e.prev_hash);
302
- end if;
303
- perform @SCHEMA@.signal(array[e.class]);
342
+ -- пути изменения: против предыдущей версии id, у первой версии переноса — против прежнего id (moved)
343
+ select l.* into p from @SCHEMA@.log l
344
+ where l.id = case when e.rev > 1 then e.id else e.moved end and l.op not in ('purge', 'trim', 'reset')
345
+ order by l.rev desc limit 1;
346
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
347
+ 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,
348
+ 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);
349
+ end if;
350
+ perform @SCHEMA@.signal(array[e.class], array[e.tenant]);
304
351
  end $$;
305
352
 
306
353
  -- Новая версия строки от имени владельца (пересчёт описаний потомков, снятие конца при удалении):
@@ -704,7 +751,8 @@ language plpgsql security definer
704
751
  set search_path = pg_catalog, @SCHEMA@, pg_temp
705
752
  as $$
706
753
  declare
707
- cls text[];
754
+ sc text[];
755
+ st uuid[];
708
756
  bad text;
709
757
  begin
710
758
  if exists (select 1 from added n where n.links <> '{}'::jsonb) then
@@ -719,9 +767,10 @@ begin
719
767
  from added n
720
768
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
721
769
  where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
722
- select array_agg(distinct a.class) into cls from added a;
723
- perform @SCHEMA@.signal(cls);
724
- if cls && array['rule', 'Resource', 'member', 'Account', 'Class'] then
770
+ -- сигнал — по паре «класс, арендатор»
771
+ select array_agg(k.class), array_agg(k.tenant) into sc, st from (select distinct a.class, a.tenant from added a) k;
772
+ perform @SCHEMA@.signal(sc, st);
773
+ if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
725
774
  perform @SCHEMA@.acl_bump();
726
775
  end if;
727
776
  return null;
@@ -735,7 +784,8 @@ language plpgsql security definer
735
784
  set search_path = pg_catalog, @SCHEMA@, pg_temp
736
785
  as $$
737
786
  declare
738
- cls text[];
787
+ sc text[];
788
+ st uuid[];
739
789
  o_row @SCHEMA@.entity;
740
790
  n_row @SCHEMA@.entity;
741
791
  cid uuid;
@@ -751,15 +801,20 @@ begin
751
801
  if bad is not null then
752
802
  perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
753
803
  end if;
754
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
755
- select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash
804
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
805
+ 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,
806
+ @SCHEMA@.change_paths(o.data, o.links, o.tags, o.owner, o.class, n.data, n.links, n.tags, n.owner, n.class)
756
807
  from new_rows n
757
808
  join old_rows o on o.id = n.id and o.rev <> n.rev
758
809
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
759
810
  where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
760
- select array_agg(distinct n.class) into cls from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev;
761
- perform @SCHEMA@.signal(cls);
762
- if cls && array['rule', 'Resource', 'member', 'Account', 'Class'] then
811
+ -- сигнал — по парам нового и прежнего класса: при смене класса без ключа ответы прежнего класса в
812
+ -- кэше других процессов устарели бы (в 1.0.1 сигнал шёл только по новому)
813
+ select array_agg(k.class), array_agg(k.tenant) into sc, st
814
+ 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
815
+ cross join lateral (values (n.class, n.tenant), (o.class, o.tenant)) v(class, tenant)) k;
816
+ perform @SCHEMA@.signal(sc, st);
817
+ if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
763
818
  perform @SCHEMA@.acl_bump();
764
819
  end if;
765
820
  for cid in select n.id from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
@@ -883,6 +938,8 @@ declare
883
938
  unsets uuid[];
884
939
  classes text[];
885
940
  lvl_classes text[];
941
+ pairs text[];
942
+ lvl_pairs text[];
886
943
  v_author uuid;
887
944
  v_reason text := nullif(current_setting('letopis.reason', true), '');
888
945
  newlinks jsonb;
@@ -913,16 +970,18 @@ begin
913
970
  end loop;
914
971
  v_author := coalesce(c.author, '@SYSTEM@'::uuid);
915
972
  -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
916
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
973
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
917
974
  select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
918
- greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash
975
+ greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash, '[]'::jsonb
919
976
  from gone g
920
977
  join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
921
978
  where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant;
979
+ -- пары «класс, арендатор» для сигнала — строкой «класс арендатор» (пробела нет ни в имени, ни в uuid)
922
980
  select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
981
+ coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}'),
923
982
  coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
924
983
  coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
925
- into lvl, classes, accs, mems from gone g;
984
+ into lvl, classes, pairs, accs, mems from gone g;
926
985
  all_ids := lvl;
927
986
  while cardinality(lvl) > 0 loop
928
987
  -- кто ссылается на уровень: действие по правилам концов (общее с превью)
@@ -944,24 +1003,26 @@ begin
944
1003
  where x.id in (select y.id from @SCHEMA@.entity y where y.id = any (casc) order by y.id for update)
945
1004
  returning x.*
946
1005
  ), t as (
947
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
1006
+ insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
948
1007
  select d.id, d.rev + 1, d.class, d.cv, d.tenant, d.owner, d.links, d.data, d.tags,
949
- greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash
1008
+ greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash, '[]'::jsonb
950
1009
  from d
951
1010
  join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
952
1011
  where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
953
1012
  )
954
1013
  select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
1014
+ coalesce(array_agg(distinct d.class || ' ' || d.tenant::text), '{}'),
955
1015
  min(d.class) filter (where not @SCHEMA@.acl_row_ok(dplan, d.class, d.id, d.owner, d.tenant, d.tags, d.data, d.links)),
956
1016
  coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
957
1017
  coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
958
- into lvl, lvl_classes, denied, lvl_accs, lvl_mems from d;
1018
+ into lvl, lvl_classes, lvl_pairs, denied, lvl_accs, lvl_mems from d;
959
1019
  if denied is not null then
960
1020
  perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s в каскаде удаления', denied));
961
1021
  end if;
962
1022
  accs := accs || lvl_accs;
963
1023
  mems := mems || lvl_mems;
964
1024
  classes := classes || lvl_classes;
1025
+ pairs := pairs || lvl_pairs;
965
1026
  -- unset: снять конец, новая версия
966
1027
  for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
967
1028
  if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
@@ -990,7 +1051,8 @@ begin
990
1051
  perform @SCHEMA@.auth_kill(r.acc, null);
991
1052
  update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
992
1053
  end loop;
993
- perform @SCHEMA@.signal(array(select distinct x from unnest(classes) x));
1054
+ perform @SCHEMA@.signal(array_agg(split_part(x.p, ' ', 1)), array_agg(split_part(x.p, ' ', 2)::uuid))
1055
+ from (select distinct p from unnest(pairs) p) x;
994
1056
  if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
995
1057
  perform @SCHEMA@.acl_bump();
996
1058
  end if;
@@ -1359,7 +1421,7 @@ begin
1359
1421
  'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text)', 'reclass(uuid, text, jsonb)',
1360
1422
  'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'class_change_safe(jsonb, jsonb)',
1361
1423
  'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_admit(jsonb, uuid)',
1362
- 'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal(text[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
1424
+ 'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal_token(text, text, bytea)', 'signal_raw(text[])', 'signal(text[], uuid[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
1363
1425
  'trg_entity_bd()', 'trg_entity_ad()', 'owner_to_tenant(uuid, uuid)', 'ref_action(jsonb, jsonb, uuid[])', 'delete_preview(uuid[])', 'merge(jsonb, jsonb)', 'inc(jsonb, text[], numeric, numeric)', 'links_patch(jsonb, jsonb)'] loop
1364
1426
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
1365
1427
  execute format('revoke all on function @SCHEMA@.%s from public', f);