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/AGENT-CHEATSHEET.en.md +14 -2
- package/AGENT-CHEATSHEET.md +14 -2
- package/CHANGELOG.md +186 -0
- package/README.en.md +134 -24
- package/README.md +124 -25
- package/dist/admin.d.ts +4 -0
- package/dist/admin.js +6 -0
- package/dist/cache.d.ts +33 -9
- package/dist/cache.js +81 -32
- package/dist/chain.d.ts +29 -7
- package/dist/chain.js +61 -12
- package/dist/cli.js +24 -7
- package/dist/import.d.ts +6 -0
- package/dist/import.js +196 -175
- package/dist/index.d.ts +11 -3
- package/dist/index.js +122 -18
- package/dist/load.d.ts +63 -0
- package/dist/load.js +293 -37
- package/dist/signals.d.ts +73 -0
- package/dist/signals.js +169 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +17 -2
- package/dist/tx.d.ts +9 -7
- package/dist/tx.js +15 -5
- package/dist/typed.d.ts +21 -6
- package/dist/types.d.ts +76 -3
- package/dist/types.js +7 -2
- package/dist/up.d.ts +16 -2
- package/dist/up.js +34 -4
- package/dist/watch.d.ts +11 -2
- package/dist/watch.js +24 -6
- package/dist/write.d.ts +10 -1
- package/dist/write.js +32 -5
- package/llms.txt +4 -1
- package/package.json +2 -1
- package/sql/10-core.sql +8 -1
- package/sql/60-write.sql +100 -35
- package/sql/75-history.sql +358 -0
- package/sql/80-auth.sql +3 -3
- package/sql/90-time.sql +45 -9
- package/sql/95-seed.system.sql +12 -4
- package/sql/99-revision.sql +1 -1
- package/sql/README.md +1 -0
- package/sql/migrate/0006-changed-paths.sql +3 -0
package/dist/signals.js
ADDED
|
@@ -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
|
|
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, а копит
|
|
21
|
-
* их забирает нотификатор процесса и раз в интервал (по умолчанию
|
|
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
|
-
/** Добавить
|
|
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
|
-
* После фиксации записи:
|
|
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
|
-
/** Добавить
|
|
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
|
-
|
|
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
|
|
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(${
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
90
|
+
reclass(cls: string, data?: Record<string, unknown>, opts?: {
|
|
91
|
+
reason?: string;
|
|
92
|
+
}): TypedChain<unknown, M>;
|
|
83
93
|
forUpdate(): TypedChain<C, M>;
|
|
84
|
-
restore(
|
|
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
|
-
}
|
|
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
|
+
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
|
+
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
|
-
/**
|
|
104
|
-
|
|
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
|
-
/**
|
|
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) {
|