letopis 1.0.0 → 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.0",
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
  -- Новая версия строки от имени владельца (пересчёт описаний потомков, снятие конца при удалении):
@@ -383,13 +430,16 @@ begin
383
430
  end $$;
384
431
 
385
432
  -- Безопасно ли изменение описания класса (этап 1, п. 10): новые необязательные поля и концы,
386
- -- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк.
433
+ -- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
434
+ -- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись).
387
435
  create or replace function @SCHEMA@.class_change_safe(o jsonb, n jsonb) returns boolean
388
436
  language sql immutable
389
437
  return
390
438
  o->'key' = n->'key'
391
439
  and o->'ancestors' = n->'ancestors'
392
440
  and (o->'abstract' = n->'abstract' or n->>'abstract' = 'false')
441
+ and o->'schema'->'anyOf' is not distinct from n->'schema'->'anyOf'
442
+ and o->'schema'->'oneOf' is not distinct from n->'schema'->'oneOf'
393
443
  and coalesce(o->'schema'->'required', '[]'::jsonb) @> coalesce(n->'schema'->'required', '[]'::jsonb)
394
444
  and not exists (select 1 from jsonb_each(coalesce(o->'schema'->'properties', '{}'::jsonb)) p
395
445
  where n->'schema'->'properties'->p.key is distinct from p.value)
@@ -701,7 +751,8 @@ language plpgsql security definer
701
751
  set search_path = pg_catalog, @SCHEMA@, pg_temp
702
752
  as $$
703
753
  declare
704
- cls text[];
754
+ sc text[];
755
+ st uuid[];
705
756
  bad text;
706
757
  begin
707
758
  if exists (select 1 from added n where n.links <> '{}'::jsonb) then
@@ -716,9 +767,10 @@ begin
716
767
  from added n
717
768
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
718
769
  where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
719
- select array_agg(distinct a.class) into cls from added a;
720
- perform @SCHEMA@.signal(cls);
721
- 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
722
774
  perform @SCHEMA@.acl_bump();
723
775
  end if;
724
776
  return null;
@@ -732,7 +784,8 @@ language plpgsql security definer
732
784
  set search_path = pg_catalog, @SCHEMA@, pg_temp
733
785
  as $$
734
786
  declare
735
- cls text[];
787
+ sc text[];
788
+ st uuid[];
736
789
  o_row @SCHEMA@.entity;
737
790
  n_row @SCHEMA@.entity;
738
791
  cid uuid;
@@ -748,15 +801,20 @@ begin
748
801
  if bad is not null then
749
802
  perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
750
803
  end if;
751
- insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
752
- 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)
753
807
  from new_rows n
754
808
  join old_rows o on o.id = n.id and o.rev <> n.rev
755
809
  join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
756
810
  where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
757
- 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;
758
- perform @SCHEMA@.signal(cls);
759
- 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
760
818
  perform @SCHEMA@.acl_bump();
761
819
  end if;
762
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
@@ -880,6 +938,8 @@ declare
880
938
  unsets uuid[];
881
939
  classes text[];
882
940
  lvl_classes text[];
941
+ pairs text[];
942
+ lvl_pairs text[];
883
943
  v_author uuid;
884
944
  v_reason text := nullif(current_setting('letopis.reason', true), '');
885
945
  newlinks jsonb;
@@ -910,16 +970,18 @@ begin
910
970
  end loop;
911
971
  v_author := coalesce(c.author, '@SYSTEM@'::uuid);
912
972
  -- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
913
- 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)
914
974
  select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
915
- 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
916
976
  from gone g
917
977
  join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
918
978
  where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant;
979
+ -- пары «класс, арендатор» для сигнала — строкой «класс арендатор» (пробела нет ни в имени, ни в uuid)
919
980
  select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
981
+ coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}'),
920
982
  coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
921
983
  coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
922
- into lvl, classes, accs, mems from gone g;
984
+ into lvl, classes, pairs, accs, mems from gone g;
923
985
  all_ids := lvl;
924
986
  while cardinality(lvl) > 0 loop
925
987
  -- кто ссылается на уровень: действие по правилам концов (общее с превью)
@@ -941,24 +1003,26 @@ begin
941
1003
  where x.id in (select y.id from @SCHEMA@.entity y where y.id = any (casc) order by y.id for update)
942
1004
  returning x.*
943
1005
  ), t as (
944
- 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)
945
1007
  select d.id, d.rev + 1, d.class, d.cv, d.tenant, d.owner, d.links, d.data, d.tags,
946
- 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
947
1009
  from d
948
1010
  join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
949
1011
  where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
950
1012
  )
951
1013
  select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
1014
+ coalesce(array_agg(distinct d.class || ' ' || d.tenant::text), '{}'),
952
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)),
953
1016
  coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
954
1017
  coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
955
- 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;
956
1019
  if denied is not null then
957
1020
  perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s в каскаде удаления', denied));
958
1021
  end if;
959
1022
  accs := accs || lvl_accs;
960
1023
  mems := mems || lvl_mems;
961
1024
  classes := classes || lvl_classes;
1025
+ pairs := pairs || lvl_pairs;
962
1026
  -- unset: снять конец, новая версия
963
1027
  for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
964
1028
  if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
@@ -987,7 +1051,8 @@ begin
987
1051
  perform @SCHEMA@.auth_kill(r.acc, null);
988
1052
  update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
989
1053
  end loop;
990
- 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;
991
1056
  if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
992
1057
  perform @SCHEMA@.acl_bump();
993
1058
  end if;
@@ -1356,7 +1421,7 @@ begin
1356
1421
  'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text)', 'reclass(uuid, text, jsonb)',
1357
1422
  'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'class_change_safe(jsonb, jsonb)',
1358
1423
  'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_admit(jsonb, uuid)',
1359
- '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)',
1360
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
1361
1426
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
1362
1427
  execute format('revoke all on function @SCHEMA@.%s from public', f);