letopis 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENT-CHEATSHEET.en.md +117 -10
  2. package/AGENT-CHEATSHEET.md +110 -8
  3. package/CHANGELOG.md +739 -0
  4. package/README.en.md +873 -68
  5. package/README.md +830 -66
  6. package/dist/admin.d.ts +24 -4
  7. package/dist/admin.js +39 -9
  8. package/dist/cache.d.ts +33 -9
  9. package/dist/cache.js +81 -32
  10. package/dist/chain.d.ts +29 -7
  11. package/dist/chain.js +61 -12
  12. package/dist/cli.js +59 -7
  13. package/dist/errors.js +4 -2
  14. package/dist/import.d.ts +6 -0
  15. package/dist/import.js +196 -175
  16. package/dist/index.d.ts +27 -5
  17. package/dist/index.js +191 -29
  18. package/dist/internal.d.ts +16 -0
  19. package/dist/internal.js +14 -0
  20. package/dist/load.d.ts +63 -0
  21. package/dist/load.js +331 -44
  22. package/dist/mcp.d.ts +28 -0
  23. package/dist/mcp.js +810 -0
  24. package/dist/model.d.ts +34 -0
  25. package/dist/model.js +17 -0
  26. package/dist/project.d.ts +309 -0
  27. package/dist/project.js +1097 -0
  28. package/dist/registry.d.ts +17 -0
  29. package/dist/registry.js +13 -0
  30. package/dist/signals.d.ts +73 -0
  31. package/dist/signals.js +169 -0
  32. package/dist/sql.d.ts +1 -1
  33. package/dist/sql.js +26 -7
  34. package/dist/tx.d.ts +9 -7
  35. package/dist/tx.js +15 -5
  36. package/dist/typed.d.ts +21 -6
  37. package/dist/types.d.ts +77 -4
  38. package/dist/types.js +8 -3
  39. package/dist/up.d.ts +16 -2
  40. package/dist/up.js +34 -4
  41. package/dist/uuid.js +19 -8
  42. package/dist/watch.d.ts +23 -2
  43. package/dist/watch.js +44 -7
  44. package/dist/write.d.ts +11 -2
  45. package/dist/write.js +57 -10
  46. package/llms.txt +9 -2
  47. package/package.json +13 -2
  48. package/sql/10-core.sql +13 -2
  49. package/sql/15-errors.sql +4 -1
  50. package/sql/40-class.sql +57 -2
  51. package/sql/60-write.sql +611 -82
  52. package/sql/70-read.sql +33 -8
  53. package/sql/75-history.sql +358 -0
  54. package/sql/80-auth.sql +7 -7
  55. package/sql/88-derived.sql +1154 -0
  56. package/sql/90-time.sql +134 -21
  57. package/sql/95-seed.system.sql +24 -6
  58. package/sql/99-revision.sql +1 -1
  59. package/sql/README.md +5 -1
  60. package/sql/migrate/0006-changed-paths.sql +3 -0
@@ -9,6 +9,17 @@ export interface EndInfo {
9
9
  onDelete?: 'restrict' | 'cascade' | 'unset';
10
10
  meta?: Record<string, unknown>;
11
11
  }
12
+ /**
13
+ * Агрегат в базе (план 1.1, §2.4): поле — сумма поля sum или число (count) живых строк класса of с потомками того же
14
+ * арендатора, чей одиночный конец via ведёт на строку; where — JSON Schema над данными строки-источника.
15
+ */
16
+ export interface AggregateInfo {
17
+ sum?: string;
18
+ count?: true;
19
+ of: string;
20
+ via: string;
21
+ where?: JsonSchema;
22
+ }
12
23
  /** Тип листа данных для фильтров, сортировки и агрегатов (замена fieldTypeOf 0.21). */
13
24
  export type LeafType = 'text' | 'numeric' | 'boolean' | 'timestamptz' | 'date' | 'uuid' | 'jsonb';
14
25
  export interface ClassInfo {
@@ -29,6 +40,10 @@ export interface ClassInfo {
29
40
  ttl?: number;
30
41
  };
31
42
  meta: Record<string, unknown>;
43
+ /** Производный класс: строки пишет только проектор с этим именем (план 1.1, §2.4). */
44
+ derivedBy?: string;
45
+ /** Агрегатные поля: их считает база, приложение не пишет (план 1.1, §2.4). */
46
+ aggregate?: Record<string, AggregateInfo>;
32
47
  /** Валидатор на TypeScript (компилируется при первом обращении). */
33
48
  validator(): Validator;
34
49
  }
@@ -40,6 +55,8 @@ interface ClassRow {
40
55
  resolved: Record<string, unknown>;
41
56
  };
42
57
  }
58
+ /** Агрегатные поля класса: их считает база (план 1.1, §2.4). */
59
+ export declare const aggregateFields: (cls: Pick<ClassInfo, "aggregate">) => string[];
43
60
  export declare class Registry {
44
61
  private byName;
45
62
  private byAlias;
package/dist/registry.js CHANGED
@@ -5,6 +5,17 @@
5
5
  */
6
6
  import { LetopisError } from './errors.js';
7
7
  import { compileValidator } from './validate.js';
8
+ /** Агрегатные поля класса: их считает база (план 1.1, §2.4). */
9
+ export const aggregateFields = (cls) => Object.keys(cls.aggregate ?? {});
10
+ /** Описание агрегатов из resolved — без скомпилированного условия (match): оно для базы. */
11
+ function aggregateOf(agg) {
12
+ return Object.fromEntries(Object.entries(agg).map(([field, d]) => [field, {
13
+ ...(typeof d.sum === 'string' ? { sum: d.sum } : { count: true }),
14
+ of: d.of,
15
+ via: d.via,
16
+ ...(d.where && typeof d.where === 'object' ? { where: d.where } : {}),
17
+ }]));
18
+ }
8
19
  export class Registry {
9
20
  byName = new Map();
10
21
  byAlias = new Map();
@@ -30,6 +41,8 @@ export class Registry {
30
41
  ownerDefault: (res.ownerDefault ?? 'tenant'),
31
42
  cache: (res.cache ?? undefined),
32
43
  meta: (res.meta ?? {}),
44
+ ...(typeof res.derivedBy === 'string' ? { derivedBy: res.derivedBy } : {}),
45
+ ...(res.aggregate && typeof res.aggregate === 'object' ? { aggregate: aggregateOf(res.aggregate) } : {}),
33
46
  validator: () => (compiled ??= compileValidator(res.schema)),
34
47
  };
35
48
  this.byName.set(info.name, info);
@@ -0,0 +1,73 @@
1
+ export type SignalMode = 'class' | 'tenant' | 'opaque' | 'none';
2
+ export declare const SIGNAL_MODES: readonly SignalMode[];
3
+ /** Разобранный сигнал. */
4
+ export type Signal =
5
+ /** `*` — сброс: кэш, пропуска прав, реестр и ключ — заново. */
6
+ {
7
+ kind: 'all';
8
+ }
9
+ /** `session:*` — все сессии. */
10
+ | {
11
+ kind: 'sessions';
12
+ }
13
+ /** `session:<тег>` — погашенная сессия (тег — начало sha256 токена). */
14
+ | {
15
+ kind: 'session';
16
+ tag: string;
17
+ }
18
+ /** Запись класса; tenant — токен арендатора, null — все арендаторы (System, режим class, процесс без ключа). */
19
+ | {
20
+ kind: 'class';
21
+ cls: string;
22
+ tenant: string | null;
23
+ }
24
+ /** Режим opaque, процесс без ключа: что записано, неизвестно. */
25
+ | {
26
+ kind: 'opaque';
27
+ }
28
+ /** Незнакомый токен класса: класса нет в реестрах процесса (класс другого арендатора или новый). */
29
+ | {
30
+ kind: 'unknown';
31
+ tenant: string | null;
32
+ }
33
+ /** Нагрузка не по режиму установки: режим сменили без перезапуска процесса или сигнал поддельный. */
34
+ | {
35
+ kind: 'foreign';
36
+ };
37
+ export declare class Signals {
38
+ readonly mode: SignalMode;
39
+ private key;
40
+ private hex;
41
+ private names;
42
+ private byToken;
43
+ private tenants;
44
+ private system;
45
+ constructor(mode?: SignalMode);
46
+ /** У процесса есть ключ сигналов. */
47
+ get keyed(): boolean;
48
+ /** Ключ из signal_key() (hex); null — процесс без ключа. true — ключ сменился (токены другие). */
49
+ setKey(hex: string | null): boolean;
50
+ /** Имена классов реестров процесса: по ним узнаются токены классов в режиме opaque. */
51
+ setClasses(names: Iterable<string>): void;
52
+ private rebuild;
53
+ /** Токен — как signal_token() базы. */
54
+ private token;
55
+ /** Токен арендатора для записей кэша и фильтра подписки; null — точности по арендатору нет. */
56
+ tenantToken(id: string | null | undefined): string | null;
57
+ /** Разобрать нагрузку сигнала или элемент letopis.changed своей транзакции. */
58
+ parse(p: string): Signal;
59
+ }
60
+ /**
61
+ * Не чаще раза в ms: первый вызов — сразу, вызовы внутри окна — один раз в его конце. Так заливка
62
+ * канала сигналами (в том числе поддельными) не превращается в поток перечитываний реестра.
63
+ */
64
+ /**
65
+ * Ведро жетонов: count вызовов подряд — сразу, дальше не чаще раза в ms (жетон возвращается раз в ms); вызовы
66
+ * в ожидании сливаются в один. Короткая серия проходит без задержки, заливка — с заданной частотой.
67
+ */
68
+ export declare function bucket(fn: () => void, count: number, ms: number): (() => void) & {
69
+ stop(): void;
70
+ };
71
+ export declare function throttle(fn: () => void, ms: number): (() => void) & {
72
+ stop(): void;
73
+ };
@@ -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)
@@ -690,14 +693,29 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
690
693
  case 'countPaths':
691
694
  text = `${withClause}select count(*)::int as n ${from}${whereOf(tail)}`;
692
695
  break;
693
- case 'versions':
694
- // с follow — по времени через все связанные id; без — по id и номеру версии
696
+ case 'versions': {
697
+ // с sort — по полю и направлению (последние версии первыми — sort('at', 'desc'), с limit — в самом запросе);
698
+ // без sort: с follow — по времени через все связанные id, без follow — по id и номеру версии
699
+ const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` : mods.follow ? 'v.at, v.id, v.rev' : 'v.id, v.rev';
695
700
  text = mods.follow
696
701
  ? `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
697
- + `where l.id in (select f.id from f)) v order by v.at, v.id, v.rev${limitOffset()}`
702
+ + `where l.id in (select f.id from f)) v order by ${order}${limitOffset()}`
698
703
  : `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
699
- + `where l.id in (select z.id from ${ents} z)) v order by v.id, v.rev${limitOffset()}`;
704
+ + `where l.id in (select z.id from ${ents} z)) v order by ${order}${limitOffset()}`;
700
705
  break;
706
+ }
707
+ case 'diff':
708
+ case 'blame': {
709
+ // план 1.1, §2.1: функции базы считают по журналу под RLS — по одному документу на объект шага
710
+ const h = mods.hist ?? {};
711
+ const num = (v) => (typeof v === 'number' ? `${p.push(v)}::int` : 'null');
712
+ const ts = (v) => (typeof v === 'string' ? `${p.push(v)}::text::timestamptz` : 'null');
713
+ const call = mode === 'diff'
714
+ ? `${S}.diff_versions(z.id, ${num(h.from)}, ${num(h.to)}, ${ts(h.from)}, ${ts(h.to)}, ${h.follow ? 'true' : 'false'})`
715
+ : `${S}.blame(z.id, ${ts(h.at)}, ${num(h.limit)})`;
716
+ text = `${withClause}select ${call} as row from (select distinct z.id from ${ents} z) z order by z.id`;
717
+ break;
718
+ }
701
719
  case 'agg': {
702
720
  if (!agg || !agg.field.startsWith('data.'))
703
721
  fail("агрегат по полю 'data.<путь>'");
@@ -705,7 +723,8 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
705
723
  const acc = accessor(path)('z');
706
724
  aggLeaf = leaf(path);
707
725
  if (agg.fn === 'countBy') {
708
- text = `${withClause}select ${acc} as k, count(*)::int as n from ${ents} z group by 1 order by 2 desc, 1`;
726
+ // limit и offset — группы по убыванию числа (самые частые значения), а не строки
727
+ text = `${withClause}select ${acc} as k, count(*)::int as n from ${ents} z group by 1 order by 2 desc, 1${limitOffset()}`;
709
728
  }
710
729
  else {
711
730
  const cast = agg.fn === 'sum' || agg.fn === 'avg' ? '::numeric' : CAST[aggLeaf];
@@ -718,7 +737,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
718
737
  }
719
738
  /** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
720
739
  export function toRow(raw) {
721
- const { depth, agent, reason, moved, ...rest } = raw;
740
+ const { depth, agent, reason, moved, changed: _changed, ...rest } = raw;
722
741
  const row = rest;
723
742
  if (agent != null)
724
743
  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,11 +2,16 @@
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
- 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"];
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", "projector", "rebuildAggregates"];
10
15
  /** Свойства батча db.batch(name). */
11
16
  export declare const BATCH_METHODS: readonly ["discard", "size"];
12
17
  /**
@@ -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
+ }