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.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Сигналы базы по режимам установки (план 1.1, §2.2, этап 2): разбор нагрузки и токены.
3
+ *
4
+ * Канал — имя схемы; нагрузку строит функция базы signal() по режиму up({ signals }):
5
+ * - class — `Booking` (как в 1.0); tenant — `Booking@<токен арендатора>`;
6
+ * - opaque — `<токен класса>@<токен арендатора>`; none — сигналов нет;
7
+ * - сессии — `session:<16 hex>` и `session:*`, сброс — `*` (кроме none).
8
+ * Токен — первые 96 бит HMAC-SHA256 от «назначение:значение» на ключе сигналов (signal_key(): HMAC
9
+ * ключа строки KEY по 'letopis:signals'), 24 строчных шестнадцатеричных знака. Имя класса не содержит
10
+ * `@`, `:` и `*` (метасхема Class), поэтому нагрузка разбирается однозначно.
11
+ *
12
+ * Процесс с ключом (право letopis.signals, сид выдаёт его сервисам) узнаёт классы по токенам и сбрасывает
13
+ * кэш с точностью до арендатора. Процесс без ключа работает «грубо»: в режиме tenant сбрасывает класс во
14
+ * всех арендаторах, в режиме opaque любой сигнал сбрасывает весь кэш.
15
+ */
16
+ import { createHmac } from 'node:crypto';
17
+ import { ACL_CLASS_NAMES } from './types.js';
18
+ export const SIGNAL_MODES = ['class', 'tenant', 'opaque', 'none'];
19
+ const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
20
+ /** Имя класса — по метасхеме Class (40-class.sql). */
21
+ const NAME = /^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{0,62}$/;
22
+ const TOKEN = /^[0-9a-f]{24}$/;
23
+ export class Signals {
24
+ mode;
25
+ key = null;
26
+ hex = null;
27
+ names = new Set(ACL_CLASS_NAMES);
28
+ byToken = new Map();
29
+ tenants = new Map();
30
+ system = null;
31
+ constructor(mode = 'class') {
32
+ this.mode = mode;
33
+ }
34
+ /** У процесса есть ключ сигналов. */
35
+ get keyed() {
36
+ return this.key !== null;
37
+ }
38
+ /** Ключ из signal_key() (hex); null — процесс без ключа. true — ключ сменился (токены другие). */
39
+ setKey(hex) {
40
+ if (hex === this.hex)
41
+ return false;
42
+ this.hex = hex;
43
+ this.key = hex ? Buffer.from(hex, 'hex') : null;
44
+ this.tenants.clear();
45
+ this.system = this.key ? this.token('tenant', SYSTEM_ID) : null;
46
+ this.rebuild();
47
+ return true;
48
+ }
49
+ /** Имена классов реестров процесса: по ним узнаются токены классов в режиме opaque. */
50
+ setClasses(names) {
51
+ this.names = new Set([...ACL_CLASS_NAMES, ...names]);
52
+ this.rebuild();
53
+ }
54
+ rebuild() {
55
+ this.byToken.clear();
56
+ if (!this.key || this.mode !== 'opaque')
57
+ return;
58
+ for (const n of this.names)
59
+ this.byToken.set(this.token('class', n), n);
60
+ }
61
+ /** Токен — как signal_token() базы. */
62
+ token(purpose, value) {
63
+ return createHmac('sha256', this.key).update(`${purpose}:${value}`, 'utf8').digest('hex').slice(0, 24);
64
+ }
65
+ /** Токен арендатора для записей кэша и фильтра подписки; null — точности по арендатору нет. */
66
+ tenantToken(id) {
67
+ if (!id || !this.key || (this.mode !== 'tenant' && this.mode !== 'opaque'))
68
+ return null;
69
+ let t = this.tenants.get(id);
70
+ if (t === undefined) {
71
+ if (this.tenants.size >= 10_000)
72
+ this.tenants.clear();
73
+ t = this.token('tenant', id.toLowerCase());
74
+ this.tenants.set(id, t);
75
+ }
76
+ return t;
77
+ }
78
+ /** Разобрать нагрузку сигнала или элемент letopis.changed своей транзакции. */
79
+ parse(p) {
80
+ if (p === '*')
81
+ return { kind: 'all' };
82
+ if (p === 'session:*')
83
+ return { kind: 'sessions' };
84
+ if (p.startsWith('session:'))
85
+ return /^session:[0-9a-f]{16}$/.test(p) ? { kind: 'session', tag: p.slice(8) } : { kind: 'foreign' };
86
+ const at = p.indexOf('@');
87
+ const head = at < 0 ? p : p.slice(0, at);
88
+ const tail = at < 0 ? null : p.slice(at + 1);
89
+ // в режиме none сигналов нет, но своя транзакция копит в letopis.changed имена классов
90
+ if (this.mode === 'class' || this.mode === 'none') {
91
+ return tail === null && NAME.test(head) ? { kind: 'class', cls: head, tenant: null } : { kind: 'foreign' };
92
+ }
93
+ if (tail === null || !TOKEN.test(tail))
94
+ return { kind: 'foreign' };
95
+ // токен System — строки, общие для всех арендаторов: сброс класса везде
96
+ const tenant = this.key && tail !== this.system ? tail : null;
97
+ if (this.mode === 'tenant')
98
+ return NAME.test(head) ? { kind: 'class', cls: head, tenant } : { kind: 'foreign' };
99
+ if (!TOKEN.test(head))
100
+ return { kind: 'foreign' };
101
+ if (!this.key)
102
+ return { kind: 'opaque' };
103
+ const cls = this.byToken.get(head);
104
+ return cls ? { kind: 'class', cls, tenant } : { kind: 'unknown', tenant };
105
+ }
106
+ }
107
+ /**
108
+ * Не чаще раза в ms: первый вызов — сразу, вызовы внутри окна — один раз в его конце. Так заливка
109
+ * канала сигналами (в том числе поддельными) не превращается в поток перечитываний реестра.
110
+ */
111
+ /**
112
+ * Ведро жетонов: count вызовов подряд — сразу, дальше не чаще раза в ms (жетон возвращается раз в ms); вызовы
113
+ * в ожидании сливаются в один. Короткая серия проходит без задержки, заливка — с заданной частотой.
114
+ */
115
+ export function bucket(fn, count, ms) {
116
+ let tokens = count;
117
+ let at = Date.now();
118
+ let timer = null;
119
+ const call = () => {
120
+ if (timer)
121
+ return;
122
+ const now = Date.now();
123
+ tokens = Math.min(count, tokens + (now - at) / ms);
124
+ at = now;
125
+ if (tokens >= 1) {
126
+ tokens -= 1;
127
+ return fn();
128
+ }
129
+ timer = setTimeout(() => {
130
+ timer = null;
131
+ call();
132
+ }, Math.ceil((1 - tokens) * ms));
133
+ timer.unref?.();
134
+ };
135
+ return Object.assign(call, {
136
+ stop: () => {
137
+ if (timer)
138
+ clearTimeout(timer);
139
+ timer = null;
140
+ },
141
+ });
142
+ }
143
+ export function throttle(fn, ms) {
144
+ let last = -Infinity;
145
+ let timer = null;
146
+ const run = () => {
147
+ last = Date.now();
148
+ fn();
149
+ };
150
+ const call = () => {
151
+ if (timer)
152
+ return;
153
+ const wait = last + ms - Date.now();
154
+ if (wait <= 0)
155
+ return run();
156
+ timer = setTimeout(() => {
157
+ timer = null;
158
+ run();
159
+ }, wait);
160
+ timer.unref?.();
161
+ };
162
+ return Object.assign(call, {
163
+ stop: () => {
164
+ if (timer)
165
+ clearTimeout(timer);
166
+ timer = null;
167
+ },
168
+ });
169
+ }
package/dist/sql.d.ts CHANGED
@@ -29,7 +29,7 @@ export interface Step {
29
29
  }
30
30
  /** Уникальная метка узла пути. */
31
31
  export declare const nextNodeKey: () => number;
32
- export type ReadMode = 'paths' | 'rows' | 'ids' | 'count' | 'countPaths' | 'versions' | 'agg';
32
+ export type ReadMode = 'paths' | 'rows' | 'ids' | 'count' | 'countPaths' | 'versions' | 'agg' | 'diff' | 'blame';
33
33
  export interface AggSpec {
34
34
  fn: 'sum' | 'avg' | 'min' | 'max' | 'countBy';
35
35
  field: string;
package/dist/sql.js CHANGED
@@ -439,6 +439,9 @@ export function cursorOf(row, field) {
439
439
  // Построение запроса
440
440
  // ---------------------------------------------------------------------------
441
441
  const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason', 'moved'];
442
+ /** Колонки журнала, выданные приложению: все, кроме changed (ревью №1 точки А плана 1.1, 10-core.sql). */
443
+ const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason',
444
+ 'moved', 'prev_hash', 'hash'].map((c) => 'l.' + c).join(', ');
442
445
  const colsOf = (a) => COLS.map((c) => `${a}.${c}`).join(', ');
443
446
  export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
444
447
  if (!steps.length)
@@ -534,7 +537,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
534
537
  const logPart = (pairs) =>
535
538
  // версия журнала по паре (id, rev): вложенный цикл по индексу log_id_rev, без перебора журнала
536
539
  `select ${colsOf('x')}, null::int as depth from (${pairs}) f(id, rev) `
537
- + `cross join lateral (select * from ${S}.log l where l.id = f.id and l.rev = f.rev) x where ${where('x')}`;
540
+ + `cross join lateral (select ${LOG_COLS} from ${S}.log l where l.id = f.id and l.rev = f.rev) x where ${where('x')}`;
538
541
  const parts = [];
539
542
  if (i === 0) {
540
543
  if (step.deepMax !== undefined)
@@ -698,6 +701,18 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
698
701
  : `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
699
702
  + `where l.id in (select z.id from ${ents} z)) v order by v.id, v.rev${limitOffset()}`;
700
703
  break;
704
+ case 'diff':
705
+ case 'blame': {
706
+ // план 1.1, §2.1: функции базы считают по журналу под RLS — по одному документу на объект шага
707
+ const h = mods.hist ?? {};
708
+ const num = (v) => (typeof v === 'number' ? `${p.push(v)}::int` : 'null');
709
+ const ts = (v) => (typeof v === 'string' ? `${p.push(v)}::text::timestamptz` : 'null');
710
+ const call = mode === 'diff'
711
+ ? `${S}.diff_versions(z.id, ${num(h.from)}, ${num(h.to)}, ${ts(h.from)}, ${ts(h.to)}, ${h.follow ? 'true' : 'false'})`
712
+ : `${S}.blame(z.id, ${ts(h.at)}, ${num(h.limit)})`;
713
+ text = `${withClause}select ${call} as row from (select distinct z.id from ${ents} z) z order by z.id`;
714
+ break;
715
+ }
701
716
  case 'agg': {
702
717
  if (!agg || !agg.field.startsWith('data.'))
703
718
  fail("агрегат по полю 'data.<путь>'");
@@ -718,7 +733,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
718
733
  }
719
734
  /** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
720
735
  export function toRow(raw) {
721
- const { depth, agent, reason, moved, ...rest } = raw;
736
+ const { depth, agent, reason, moved, changed: _changed, ...rest } = raw;
722
737
  const row = rest;
723
738
  if (agent != null)
724
739
  row.agent = agent;
package/dist/tx.d.ts CHANGED
@@ -17,9 +17,10 @@
17
17
  * исходную ошибку — при каждом вызове. Обрыв связи во время COMMIT — commit_unknown, без повтора.
18
18
  *
19
19
  * Сигналы (решение 9 точки А): транзакции библиотеки помечены `letopis.notify = 'deferred'` —
20
- * триггер не шлёт NOTIFY, а копит имена изменённых классов в `letopis.changed`; после фиксации
21
- * их забирает нотификатор процесса и раз в интервал (по умолчанию 100 мс) отправляет одной
22
- * транзакцией без данных: такая фиксация не ждёт диск и не задерживает чужие записи.
20
+ * триггер не шлёт NOTIFY, а копит готовые нагрузки (по режиму сигналов установки, план 1.1) в
21
+ * `letopis.changed`; после фиксации их забирает нотификатор процесса и раз в интервал (по умолчанию
22
+ * 100 мс) отправляет одной транзакцией без данных: такая фиксация не ждёт диск и не задерживает чужие
23
+ * записи. Неотправленное (ошибка отправки) остаётся в очереди до следующего интервала.
23
24
  */
24
25
  import type postgres from 'postgres';
25
26
  type Sql = postgres.Sql<Record<string, unknown>>;
@@ -32,7 +33,7 @@ export declare function isLogConflict(e: unknown): boolean;
32
33
  export declare function isConnectionError(e: unknown): boolean;
33
34
  export declare const RETRIES = 3;
34
35
  export declare const retryDelay: (attempt: number) => Promise<void>;
35
- /** Нотификатор процесса: копит имена классов и отправляет их пачкой раз в интервал. */
36
+ /** Нотификатор процесса: копит нагрузки сигналов и отправляет их пачкой раз в интервал. */
36
37
  export declare class Notifier {
37
38
  private sql;
38
39
  private channel;
@@ -43,8 +44,9 @@ export declare class Notifier {
43
44
  private stopped;
44
45
  private flushing;
45
46
  constructor(sql: Sql, channel: string, intervalMs: number, onError?: (e: unknown) => void);
46
- /** Добавить классы из letopis.changed (строка через запятую). */
47
+ /** Добавить нагрузки из letopis.changed (строка через запятую). */
47
48
  add(changed: string | null | undefined): void;
49
+ private schedule;
48
50
  /** Отправить накопленное сейчас (вызывается по таймеру и при закрытии). */
49
51
  flush(): Promise<void>;
50
52
  close(): Promise<void>;
@@ -77,8 +79,8 @@ export interface TxContext {
77
79
  /** После успешной операции (продление сессии раз в минуту). */
78
80
  after?: () => void;
79
81
  /**
80
- * После фиксации записи: имена из letopis.changed (классы, session:<тег>) — кэш результатов
81
- * сбрасывает свои ответы сразу (§2.12); raw — запись через db.sql.
82
+ * После фиксации записи: нагрузки из letopis.changed (сигналы записи, session:<тег>) — кэш
83
+ * результатов сбрасывает свои ответы сразу (§2.12); raw — запись через db.sql.
82
84
  */
83
85
  onChanged?: (names: string[], raw: boolean) => void;
84
86
  }
package/dist/tx.js CHANGED
@@ -39,7 +39,7 @@ function hintTxRetry(e) {
39
39
  }
40
40
  export const RETRIES = 3;
41
41
  export const retryDelay = (attempt) => new Promise((r) => setTimeout(r, 40 * attempt + Math.random() * 40));
42
- /** Нотификатор процесса: копит имена классов и отправляет их пачкой раз в интервал. */
42
+ /** Нотификатор процесса: копит нагрузки сигналов и отправляет их пачкой раз в интервал. */
43
43
  export class Notifier {
44
44
  sql;
45
45
  channel;
@@ -55,14 +55,17 @@ export class Notifier {
55
55
  this.intervalMs = intervalMs;
56
56
  this.onError = onError;
57
57
  }
58
- /** Добавить классы из letopis.changed (строка через запятую). */
58
+ /** Добавить нагрузки из letopis.changed (строка через запятую). */
59
59
  add(changed) {
60
60
  if (!changed || this.stopped)
61
61
  return;
62
62
  for (const c of changed.split(','))
63
63
  if (c)
64
64
  this.queue.add(c);
65
- if (!this.timer && this.queue.size)
65
+ this.schedule();
66
+ }
67
+ schedule() {
68
+ if (!this.timer && this.queue.size && !this.stopped)
66
69
  this.timer = setTimeout(() => void this.flush(), this.intervalMs);
67
70
  }
68
71
  /** Отправить накопленное сейчас (вызывается по таймеру и при закрытии). */
@@ -73,9 +76,16 @@ export class Notifier {
73
76
  }
74
77
  if (!this.queue.size)
75
78
  return this.flushing;
76
- const classes = [...this.queue];
79
+ const payloads = [...this.queue];
77
80
  this.queue.clear();
78
- this.flushing = this.flushing.then(() => this.sql `select pg_notify(${this.channel}, c) from unnest(${classes}::text[]) as c`.then(() => undefined, this.onError));
81
+ this.flushing = this.flushing.then(() => this.sql `select pg_notify(${this.channel}, c) from unnest(${payloads}::text[]) as c`.then(() => undefined, (e) => {
82
+ // не отправлено (обрыв связи, отказ базы) — обратно в очередь: уйдёт следующим интервалом
83
+ // (в 1.0.1 такие сигналы терялись, и кэш других процессов устаревал до срока)
84
+ for (const c of payloads)
85
+ this.queue.add(c);
86
+ this.schedule();
87
+ this.onError(e);
88
+ }));
79
89
  return this.flushing;
80
90
  }
81
91
  async close() {
package/dist/typed.d.ts CHANGED
@@ -70,24 +70,39 @@ export type TypedChain<C, M extends readonly AnyModelLike[]> = Omit<ChainCore, R
70
70
  owner(v: string | {
71
71
  id: string;
72
72
  }): TypedChain<C, M>;
73
- create(data: Partial<DataOf<C>>): TypedChain<C, M>;
73
+ create(data: Partial<DataOf<C>>, opts?: {
74
+ reason?: string;
75
+ }): TypedChain<C, M>;
74
76
  update(patch?: PatchOf<DataOf<C>>, opts?: {
75
77
  rev?: number;
78
+ reason?: string;
79
+ }): TypedChain<C, M>;
80
+ upsert(data: Partial<DataOf<C>>, opts?: {
81
+ reason?: string;
76
82
  }): TypedChain<C, M>;
77
- upsert(data: Partial<DataOf<C>>): TypedChain<C, M>;
78
83
  delete(opts?: {
79
84
  confirm?: boolean;
85
+ reason?: string;
86
+ }): TypedChain<C, M>;
87
+ anonymize(fields: (keyof DataOf<C> & string)[], opts?: {
88
+ reason?: string;
80
89
  }): TypedChain<C, M>;
81
- anonymize(fields: (keyof DataOf<C> & string)[]): TypedChain<C, M>;
82
- reclass(cls: string, data?: Record<string, unknown>): TypedChain<unknown, M>;
90
+ reclass(cls: string, data?: Record<string, unknown>, opts?: {
91
+ reason?: string;
92
+ }): TypedChain<unknown, M>;
83
93
  forUpdate(): TypedChain<C, M>;
84
- restore(): TypedChain<C, M>;
94
+ restore(opts?: {
95
+ reason?: string;
96
+ }): TypedChain<C, M>;
85
97
  purge(opts?: {
86
98
  confirm?: boolean;
99
+ reason?: string;
87
100
  }): TypedChain<C, M>;
88
101
  rekey(patch: Partial<DataOf<C>> & Partial<Record<keyof LinksOf<C>, string | {
89
102
  id: string;
90
- }>>): TypedChain<C, M>;
103
+ }>>, opts?: {
104
+ reason?: string;
105
+ }): TypedChain<C, M>;
91
106
  cache(opts?: {
92
107
  ttl?: number;
93
108
  }): TypedChain<C, M>;
package/dist/types.d.ts CHANGED
@@ -2,9 +2,14 @@
2
2
  * Общие типы letopis 1.0. Файл наполняется по этапам плана (reports/PLAN-LETOPIS-1.0.md).
3
3
  */
4
4
  /** Ревизия движка в базе: штамп в lib/sql/99-revision.sql должен совпадать (этап 1). */
5
- export declare const DDL_REVISION = 5;
5
+ export declare const DDL_REVISION = 6;
6
+ /**
7
+ * Классы, строки которых меняют права и схему: их запись повышает счётчик прав (загрузчик), а сигнал
8
+ * сбрасывает весь кэш результатов и пропуска прав. Тот же список — в триггерах базы (60-write.sql).
9
+ */
10
+ export declare const ACL_CLASS_NAMES: readonly string[];
6
11
  /** Методы цепочки: терминалы, модификаторы, глаголы записи (этап 4), охранники снесённого API. */
7
- export declare const CHAIN_METHODS: readonly ["then", "entity", "paths", "rows", "first", "ids", "count", "limit", "offset", "sort", "asOf", "withDeleted", "deep", "exact", "sum", "avg", "min", "max", "countBy", "after", "versions", "create", "update", "upsert", "set", "unset", "add", "remove", "delete", "anonymize", "reclass", "restore", "purge", "alias", "tags", "owner", "forUpdate", "cache", "rekey", "run", "execute", "account"];
12
+ export declare const CHAIN_METHODS: readonly ["then", "entity", "paths", "rows", "first", "ids", "count", "limit", "offset", "sort", "asOf", "withDeleted", "deep", "exact", "sum", "avg", "min", "max", "countBy", "after", "versions", "create", "update", "upsert", "set", "unset", "add", "remove", "delete", "anonymize", "reclass", "restore", "purge", "alias", "tags", "owner", "forUpdate", "cache", "rekey", "run", "execute", "account", "diff", "blame"];
8
13
  /** Свойства корня db (и транзакции db.begin()). */
9
14
  export declare const DB_METHODS: readonly ["as", "begin", "commit", "rollback", "lock", "batch", "watch", "close", "registry", "sql", "load", "verify", "maintain", "reset", "describe", "auth", "acl", "idOf", "refresh", "transaction", "flushSignals", "schema", "token", "tenant", "reloadSchema", "accounts", "credentials", "resources", "rules", "compact", "trimHistory", "rekeyClass", "cache"];
10
15
  /** Свойства батча db.batch(name). */
@@ -98,10 +103,18 @@ export interface ChainMods {
98
103
  cache?: {
99
104
  ttl?: number;
100
105
  };
106
+ /** Параметры терминалов diff() и blame() (план 1.1, §2.1): номера версий или моменты (ISO). */
107
+ hist?: {
108
+ from?: number | string;
109
+ to?: number | string;
110
+ at?: string;
111
+ follow?: boolean;
112
+ limit?: number;
113
+ };
101
114
  }
102
115
  /** Событие хука onQuery: один запрос цепочки. */
103
116
  export interface QueryEvent {
104
- mode: 'paths' | 'rows' | 'ids' | 'count' | 'versions' | 'agg';
117
+ mode: 'paths' | 'rows' | 'ids' | 'count' | 'versions' | 'agg' | 'diff' | 'blame';
105
118
  /** Классы шагов цепочки. */
106
119
  classes: string[];
107
120
  ms: number;
@@ -109,3 +122,63 @@ export interface QueryEvent {
109
122
  /** ms > slowMs. */
110
123
  slow: boolean;
111
124
  }
125
+ /** Изменение пути: added — не было, removed — не стало, changed — другое значение. */
126
+ export interface DiffChange {
127
+ /** Путь: первый сегмент — раздел строки (data, links, tags, owner, class). */
128
+ path: string[];
129
+ kind: 'added' | 'removed' | 'changed';
130
+ before?: unknown;
131
+ after?: unknown;
132
+ }
133
+ /** Различия объекта между двумя версиями (diff()). */
134
+ export interface DiffResult {
135
+ id: string;
136
+ class: string | null;
137
+ from: {
138
+ rev: number;
139
+ at: string;
140
+ id?: string;
141
+ } | null;
142
+ to: {
143
+ rev: number;
144
+ at: string;
145
+ id?: string;
146
+ deleted?: true;
147
+ } | null;
148
+ changes: DiffChange[];
149
+ }
150
+ /** Кто и почему последним изменил лист версии (blame()). */
151
+ export interface BlameField {
152
+ path: string[];
153
+ rev: number;
154
+ at: string;
155
+ author: string;
156
+ agent?: string;
157
+ op: string;
158
+ reason?: string;
159
+ /** Другой id (история прошла через смену id при reclass или rekey). */
160
+ id?: string;
161
+ /** false — до найденной версии история неполная: изменение могло быть раньше. */
162
+ exact: boolean;
163
+ }
164
+ /** Поля версии объекта с версиями, которые их изменили (blame()). */
165
+ export interface BlameResult {
166
+ id: string;
167
+ class: string;
168
+ rev: number;
169
+ deleted?: true;
170
+ /** История просмотрена целиком и без пропусков. */
171
+ complete: boolean;
172
+ fields: BlameField[];
173
+ }
174
+ /** diff(): «до» и «после» — номер версии или момент (ISO-строка, Date); follow — через смену id. */
175
+ export interface DiffOptions {
176
+ from?: number | string | Date;
177
+ to?: number | string | Date;
178
+ follow?: boolean;
179
+ }
180
+ /** blame(): версия на момент at (по умолчанию текущая); limit — сколько версий просматривать (1000). */
181
+ export interface BlameOptions {
182
+ at?: string | Date;
183
+ limit?: number;
184
+ }
package/dist/types.js CHANGED
@@ -2,7 +2,12 @@
2
2
  * Общие типы letopis 1.0. Файл наполняется по этапам плана (reports/PLAN-LETOPIS-1.0.md).
3
3
  */
4
4
  /** Ревизия движка в базе: штамп в lib/sql/99-revision.sql должен совпадать (этап 1). */
5
- export const DDL_REVISION = 5;
5
+ export const DDL_REVISION = 6;
6
+ /**
7
+ * Классы, строки которых меняют права и схему: их запись повышает счётчик прав (загрузчик), а сигнал
8
+ * сбрасывает весь кэш результатов и пропуска прав. Тот же список — в триггерах базы (60-write.sql).
9
+ */
10
+ export const ACL_CLASS_NAMES = ['rule', 'Resource', 'member', 'Account', 'Class'];
6
11
  // ---------------------------------------------------------------------------
7
12
  // Методы цепочки — единственный источник зарезервированных имён (план, §2.14, этап 3, п. 10)
8
13
  // ---------------------------------------------------------------------------
@@ -11,7 +16,7 @@ export const CHAIN_METHODS = [
11
16
  'then', 'entity', 'paths', 'rows', 'first', 'ids', 'count', 'limit', 'offset', 'sort', 'asOf', 'withDeleted',
12
17
  'deep', 'exact', 'sum', 'avg', 'min', 'max', 'countBy', 'after', 'versions', 'create', 'update', 'upsert', 'set',
13
18
  'unset', 'add', 'remove', 'delete', 'anonymize', 'reclass', 'restore', 'purge', 'alias', 'tags', 'owner',
14
- 'forUpdate', 'cache', 'rekey', 'run', 'execute', 'account',
19
+ 'forUpdate', 'cache', 'rekey', 'run', 'execute', 'account', 'diff', 'blame',
15
20
  ];
16
21
  /** Свойства корня db (и транзакции db.begin()). */
17
22
  export const DB_METHODS = [
package/dist/up.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import postgres from 'postgres';
2
2
  import { type PgliteOptions, type PgliteServer } from './pglite.js';
3
+ import { type SignalMode } from './signals.js';
3
4
  export declare const SYSTEM_ID = "2eba6d0a-1edd-4bb6-a85a-a703dab49035";
4
5
  export declare const SCHEMA_VERSION = "v2";
5
6
  export interface InstallOptions {
@@ -25,6 +26,12 @@ export interface InstallOptions {
25
26
  migrationsDir?: string;
26
27
  /** Свой ключ сервиса: зарегистрировать, если такого ещё нет (вместо выпуска случайного). */
27
28
  serviceKey?: string;
29
+ /**
30
+ * Режим сигналов NOTIFY (план 1.1, §2.2): 'class' (по умолчанию, как в 1.0) — имена классов; 'tenant' —
31
+ * имя класса и токен арендатора; 'opaque' — только токены; 'none' — сигналов нет. Режим хранится в
32
+ * схеме: накат без опции его не меняет, reset({ all }) — сохраняет.
33
+ */
34
+ signals?: SignalMode;
28
35
  }
29
36
  /** Политика хранения истории (§2.11): сроки ярусов по возрастанию, 'forever' — бессрочно. */
30
37
  export interface HistoryPolicy {
@@ -44,6 +51,8 @@ export interface InstallResult {
44
51
  serviceKey: string | null;
45
52
  /** Применённые миграции. */
46
53
  migrations: string[];
54
+ /** Режим сигналов установки после наката. */
55
+ signals: SignalMode;
47
56
  }
48
57
  export declare const quoteIdent: (name: string) => string;
49
58
  /** Файлы ядра по номеру: `10-core.sql` … `99-revision.sql`; сиды демо-домена сюда не входят. */
@@ -88,6 +97,8 @@ export interface UpResult extends InstallResult {
88
97
  pglite?: PgliteServer;
89
98
  /** up({ tenant }): арендатор (и его первый пользователь с сессией). */
90
99
  tenant?: TenantResult;
100
+ /** Предупреждения (версия сервера); без quiet каждое уже напечатано через console.warn. */
101
+ warnings: string[];
91
102
  }
92
103
  type Ping = {
93
104
  ok: true;
@@ -100,8 +111,11 @@ type Ping = {
100
111
  export declare function pgPing(dsn: string): Promise<Ping>;
101
112
  /** Создать базу строки подключения, если её нет (гонка параллельных up() — 42P04 — не ошибка). */
102
113
  export declare function ensureDatabase(dsn: string, log?: (m: string) => void): Promise<boolean>;
103
- /** Версия сервера и кодировка базы: PostgreSQL 18+ и UTF8 (casefold и pg_unicode_fast требуют UTF8). */
104
- export declare function checkServer(versionNum: number, encoding: string): void;
114
+ /**
115
+ * Версия сервера и кодировка базы: PostgreSQL 18+ и UTF8 (casefold и pg_unicode_fast требуют UTF8) —
116
+ * иначе отказ. Возвращает предупреждения: с чем установка работает, но рискует.
117
+ */
118
+ export declare function checkServer(versionNum: number, encoding: string): string[];
105
119
  /** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
106
120
  export declare function schemaRevision(sql: postgres.Sql, schema: string): Promise<number | null>;
107
121
  /**
package/dist/up.js CHANGED
@@ -20,6 +20,7 @@ import { startPglite } from './pglite.js';
20
20
  import { hashPassword } from './auth.js';
21
21
  import { LetopisError } from './errors.js';
22
22
  import { v5Id } from './uuid.js';
23
+ import { SIGNAL_MODES } from './signals.js';
23
24
  export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
24
25
  export const SCHEMA_VERSION = 'v2';
25
26
  const SQL_DIR = fileURLToPath(new URL('../sql/', import.meta.url));
@@ -48,6 +49,9 @@ export async function install(sql, opts) {
48
49
  for (const r of [owner, app])
49
50
  if (!ROLE_NAME.test(r))
50
51
  throw new Error(`letopis.up: bad role name "${r}"`);
52
+ if (opts.signals !== undefined && !SIGNAL_MODES.includes(opts.signals)) {
53
+ throw new Error(`letopis.up: signals — ${SIGNAL_MODES.join(', ')}, а не ${String(opts.signals)}`);
54
+ }
51
55
  const [{ v }] = await sql `select current_setting('server_version_num') as v`;
52
56
  if (Number(v) < 180000)
53
57
  throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${v}`);
@@ -81,14 +85,27 @@ export async function install(sql, opts) {
81
85
  HISTORY_DEFAULT: quoteLiteral(JSON.stringify(opts.history ?? null)),
82
86
  // методы цепочки — в тексте функции reserved_names() (план, §2.14)
83
87
  RESERVED_NAMES: RESERVED_CLASS_NAMES.map(quoteLiteral).join(', '),
88
+ // режим сигналов — после set role: сохранённый режим лежит в строке ключа secret владельца
89
+ SIGNALS: 'class',
84
90
  };
85
91
  // Схема создаётся до set role: у владельца может не быть права CREATE в базе.
86
92
  await sql.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
87
93
  const files = await engineFiles(opts.upTo);
88
94
  const migrations = opts.migrateFrom === undefined ? [] : await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, opts.migrateFrom);
89
95
  let serviceKey = null;
96
+ let signals = 'class';
90
97
  await sql.begin(async (tx) => {
91
98
  await tx.unsafe(`set local role ${quoteIdent(owner)}`);
99
+ // режим сигналов (план 1.1, §2.6): опция, иначе сохранённый в схеме (meta строки ключа), иначе class
100
+ let saved = null;
101
+ const [reg] = await tx.unsafe(`select to_regclass($1) is not null as ok`, [`${quoteIdent(opts.schema)}.secret`]);
102
+ if (reg?.ok) {
103
+ const [k] = await tx.unsafe(`select meta->>'signals' as m from ${quoteIdent(opts.schema)}.secret where id = $1::uuid and kind = 'KEY'`, [SYSTEM_ID]);
104
+ if (SIGNAL_MODES.includes(k?.m))
105
+ saved = k.m;
106
+ }
107
+ signals = opts.signals ?? saved ?? 'class';
108
+ vars.SIGNALS = signals;
92
109
  // структурные изменения прежних ревизий — до повторного наката функций (этап 6, п. 10)
93
110
  for (const m of migrations) {
94
111
  try {
@@ -155,7 +172,7 @@ export async function install(sql, opts) {
155
172
  }
156
173
  }
157
174
  });
158
- return { schema: opts.schema, owner, app, serviceKey, migrations };
175
+ return { schema: opts.schema, owner, app, serviceKey, migrations, signals };
159
176
  }
160
177
  /** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
161
178
  export async function migrationFiles(dir, from) {
@@ -213,12 +230,22 @@ export async function ensureDatabase(dsn, log = () => { }) {
213
230
  await sql.end({ timeout: 1 });
214
231
  }
215
232
  }
216
- /** Версия сервера и кодировка базы: PostgreSQL 18+ и UTF8 (casefold и pg_unicode_fast требуют UTF8). */
233
+ /**
234
+ * Версия сервера и кодировка базы: PostgreSQL 18+ и UTF8 (casefold и pg_unicode_fast требуют UTF8) —
235
+ * иначе отказ. Возвращает предупреждения: с чем установка работает, но рискует.
236
+ */
217
237
  export function checkServer(versionNum, encoding) {
218
238
  if (versionNum < 180000)
219
239
  throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${Math.floor(versionNum / 10000)} (${versionNum})`);
220
240
  if (encoding.toUpperCase() !== 'UTF8')
221
241
  throw new Error(`letopis.up: база в кодировке ${encoding}, нужна UTF8 (casefold и коллация pg_unicode_fast)`);
242
+ const warnings = [];
243
+ // план 1.1, этап 2: исправлено в 18.2
244
+ if (versionNum < 180002) {
245
+ warnings.push(`PostgreSQL ${Math.floor(versionNum / 10000)}.${versionNum % 10000}: до 18.2 сервер мог усечь CLOG, который ещё нужен `
246
+ + 'непрочитанным сигналам NOTIFY; обновите сервер до 18.2 или новее');
247
+ }
248
+ return warnings;
222
249
  }
223
250
  /** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
224
251
  export async function schemaRevision(sql, schema) {
@@ -267,7 +294,10 @@ export async function up(opts) {
267
294
  try {
268
295
  const [srv] = await sql `select current_setting('server_version_num')::int as v, pg_encoding_to_char(encoding) as enc
269
296
  from pg_database where datname = current_database()`;
270
- checkServer(srv.v, srv.enc);
297
+ const warnings = checkServer(srv.v, srv.enc);
298
+ if (!opts.quiet)
299
+ for (const w of warnings)
300
+ console.warn(`letopis.up: ${w}`);
271
301
  if (opts.fresh) {
272
302
  await sql.unsafe(`drop schema if exists ${quoteIdent(schema)} cascade`);
273
303
  log(`схема ${schema} удалена (fresh)`);
@@ -290,7 +320,7 @@ export async function up(opts) {
290
320
  const revision = (await schemaRevision(sql, schema)) ?? 0;
291
321
  log(`${before === null ? 'схема поставлена' : before < DDL_REVISION ? `схема обновлена с ревизии ${before}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
292
322
  const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
293
- return { ...res, created: before === null, upgraded: before !== null && before < DDL_REVISION, revision, analyzed,
323
+ return { ...res, created: before === null, upgraded: before !== null && before < DDL_REVISION, revision, analyzed, warnings,
294
324
  ...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
295
325
  }
296
326
  catch (e) {