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.
- package/AGENT-CHEATSHEET.en.md +117 -10
- package/AGENT-CHEATSHEET.md +110 -8
- package/CHANGELOG.md +739 -0
- package/README.en.md +873 -68
- package/README.md +830 -66
- package/dist/admin.d.ts +24 -4
- package/dist/admin.js +39 -9
- 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 +59 -7
- package/dist/errors.js +4 -2
- package/dist/import.d.ts +6 -0
- package/dist/import.js +196 -175
- package/dist/index.d.ts +27 -5
- package/dist/index.js +191 -29
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.d.ts +63 -0
- package/dist/load.js +331 -44
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +810 -0
- package/dist/model.d.ts +34 -0
- package/dist/model.js +17 -0
- package/dist/project.d.ts +309 -0
- package/dist/project.js +1097 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/signals.d.ts +73 -0
- package/dist/signals.js +169 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +26 -7
- 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 +77 -4
- package/dist/types.js +8 -3
- package/dist/up.d.ts +16 -2
- package/dist/up.js +34 -4
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +23 -2
- package/dist/watch.js +44 -7
- package/dist/write.d.ts +11 -2
- package/dist/write.js +57 -10
- package/llms.txt +9 -2
- package/package.json +13 -2
- package/sql/10-core.sql +13 -2
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +611 -82
- package/sql/70-read.sql +33 -8
- package/sql/75-history.sql +358 -0
- package/sql/80-auth.sql +7 -7
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +134 -21
- package/sql/95-seed.system.sql +24 -6
- package/sql/99-revision.sql +1 -1
- package/sql/README.md +5 -1
- package/sql/migrate/0006-changed-paths.sql +3 -0
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
+
};
|
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)
|
|
@@ -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
|
-
// с
|
|
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
|
|
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
|
|
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
|
-
|
|
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, а копит
|
|
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,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
|
+
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
|
+
}
|