letopis 1.4.0 → 1.4.2
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 +15 -7
- package/AGENT-CHEATSHEET.md +19 -11
- package/CHANGELOG.md +299 -1
- package/README.en.md +180 -62
- package/README.md +165 -55
- package/dist/acl.d.ts +11 -5
- package/dist/admin.d.ts +30 -14
- package/dist/admin.js +53 -18
- package/dist/auth.d.ts +61 -17
- package/dist/auth.js +16 -2
- package/dist/cache.d.ts +5 -1
- package/dist/cache.js +3 -2
- package/dist/chain.d.ts +92 -15
- package/dist/chain.js +56 -7
- package/dist/cli.js +16 -2
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -5
- package/dist/import.js +7 -0
- package/dist/index.d.ts +73 -45
- package/dist/index.js +69 -14
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +5 -0
- package/dist/load.d.ts +10 -3
- package/dist/load.js +39 -11
- package/dist/mcp.d.ts +20 -11
- package/dist/mcp.js +8 -6
- package/dist/model.d.ts +4 -1
- package/dist/model.js +7 -1
- package/dist/ops.d.ts +58 -18
- package/dist/ops.js +58 -18
- package/dist/project.d.ts +39 -11
- package/dist/project.js +75 -15
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -1
- package/dist/signals.d.ts +4 -4
- package/dist/signals.js +4 -4
- package/dist/sql.d.ts +8 -4
- package/dist/sql.js +110 -25
- package/dist/sync.js +11 -4
- package/dist/tx.d.ts +15 -0
- package/dist/tx.js +22 -4
- package/dist/types.d.ts +21 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +48 -25
- package/dist/up.js +94 -41
- package/dist/validate.d.ts +9 -2
- package/dist/validate.js +19 -3
- package/dist/watch.d.ts +29 -5
- package/dist/watch.js +62 -13
- package/dist/write.d.ts +3 -2
- package/dist/write.js +52 -19
- package/package.json +8 -4
- package/sql/10-core.sql +6 -1
- package/sql/60-write.sql +47 -18
- package/sql/70-read.sql +13 -7
- package/sql/75-history.sql +106 -28
- package/sql/80-auth.sql +41 -3
- package/sql/88-derived.sql +3 -1
- package/sql/90-time.sql +118 -30
- package/sql/95-seed.system.sql +6 -3
- package/sql/99-revision.sql +1 -1
- package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/validate.js
CHANGED
|
@@ -136,7 +136,16 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
136
136
|
if (sch.multipleOf !== undefined)
|
|
137
137
|
out.push({ path, keyword: 'multipleOf', select: sel, test: num((v, b) => isMultiple(v, b), sch.multipleOf) });
|
|
138
138
|
if (sch.pattern !== undefined) {
|
|
139
|
-
const
|
|
139
|
+
const src = translatePattern(sch.pattern); // то, чего база не понимает, — своя ошибка invalid_class
|
|
140
|
+
let re;
|
|
141
|
+
try {
|
|
142
|
+
re = new RegExp(src);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// шаблон, неверный уже для JavaScript (незакрытая скобка и т. п.): translatePattern его пропускает, RegExp — нет.
|
|
146
|
+
// Текст V8 в сообщение не идёт: он зависит от версии движка
|
|
147
|
+
fail(path, `pattern ${JSON.stringify(sch.pattern)} — неверное регулярное выражение`);
|
|
148
|
+
}
|
|
140
149
|
out.push({ path, keyword: 'pattern', select: sel, test: (v) => typeof v !== 'string' || re.test(v) });
|
|
141
150
|
}
|
|
142
151
|
if (sch.format !== undefined) {
|
|
@@ -211,7 +220,10 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
211
220
|
: (v) => branches.filter((b) => b(v)).length === 1 });
|
|
212
221
|
}
|
|
213
222
|
}
|
|
214
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи. Схема
|
|
225
|
+
* класса — `db.registry.resolve(имя).schema`.
|
|
226
|
+
*/
|
|
215
227
|
export function compileValidator(schema) {
|
|
216
228
|
const rules = [];
|
|
217
229
|
compileNode(schema, (root) => [root], [], false, rules);
|
|
@@ -233,7 +245,11 @@ export function compileValidator(schema) {
|
|
|
233
245
|
return issues;
|
|
234
246
|
};
|
|
235
247
|
}
|
|
236
|
-
/**
|
|
248
|
+
/**
|
|
249
|
+
* Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
|
|
250
|
+
* записи: только в отсутствующие поля, а во вложенный объект — только если он в данных уже есть. Исходные данные не
|
|
251
|
+
* меняются.
|
|
252
|
+
*/
|
|
237
253
|
export function applyDefaults(schema, data) {
|
|
238
254
|
if (!isObj(data) || !isObj(schema.properties))
|
|
239
255
|
return data;
|
package/dist/watch.d.ts
CHANGED
|
@@ -2,13 +2,17 @@ import type { Executor } from './tx.js';
|
|
|
2
2
|
import type { Row } from './types.js';
|
|
3
3
|
import type { Signal } from './signals.js';
|
|
4
4
|
export interface WatchOptions {
|
|
5
|
-
/** Откуда: курсор
|
|
5
|
+
/** Откуда: курсор (sub.cursor или курсор события), 'now' (по умолчанию — изменения после подписки) или 'start' (весь журнал). */
|
|
6
6
|
from?: string;
|
|
7
7
|
/** Только эти классы (с потомками). */
|
|
8
8
|
classes?: string[];
|
|
9
9
|
/** Событий за запрос (по умолчанию 500). */
|
|
10
10
|
batch?: number;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Как часто подписка сама проверяет журнал, мс (по умолчанию 1000): так она находит события, даже если сигнал базы не
|
|
13
|
+
* пришёл. Без сигналов (`connect({ listen: false })` или установка с `up({ signals: 'none' })`) новые события она
|
|
14
|
+
* узнаёт только так.
|
|
15
|
+
*/
|
|
12
16
|
pollMs?: number;
|
|
13
17
|
}
|
|
14
18
|
export interface WatchEvent {
|
|
@@ -27,10 +31,14 @@ export interface WatchCtx {
|
|
|
27
31
|
exec: Executor;
|
|
28
32
|
/** Классы подписки с потомками (null — все). */
|
|
29
33
|
classes: string[] | null;
|
|
34
|
+
/** Классы с потомками заново — по текущему реестру: потомки, заведённые после watch(), тоже в подписке (план 2.0, 7.4 №2). */
|
|
35
|
+
classesOf?: () => string[] | null;
|
|
30
36
|
/** Подписка на сигналы базы (разобранные); возвращает отписку. */
|
|
31
37
|
onSignal?: (fn: (s: Signal) => void) => () => void;
|
|
32
38
|
/** Токен арендатора подписки (null — процесс без ключа или режим без токенов). */
|
|
33
39
|
tenant?: () => string | null;
|
|
40
|
+
/** Подписка закрылась: подключение перестаёт её учитывать (план 2.0, 7.4 №8). */
|
|
41
|
+
onClose?: (s: Subscription) => void;
|
|
34
42
|
}
|
|
35
43
|
/**
|
|
36
44
|
* Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
|
|
@@ -43,9 +51,19 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
43
51
|
private opts;
|
|
44
52
|
private pos;
|
|
45
53
|
private buf;
|
|
54
|
+
/** Курсор после последнего выданного события, пока в буфере есть невыданные (pos — уже после всей пачки). */
|
|
55
|
+
private given;
|
|
46
56
|
private closed;
|
|
47
57
|
private wake;
|
|
48
58
|
private dirty;
|
|
59
|
+
/** Пришёл сигнал: пустой ответ — ещё не горизонт, нужны короткие повторы. Таймер их не запускает (план 2.0, 7.4 №1). */
|
|
60
|
+
private burst;
|
|
61
|
+
/**
|
|
62
|
+
* from: 'start' — пока подписка не догнала журнал, свои чтения она шлёт без времени курсора: весь журнал, без
|
|
63
|
+
* cursor_expired на старых событиях (план 2.0, 7.4 №3). Курсоры событий и sub.cursor время хранят: продолжение с них
|
|
64
|
+
* после прореживания — cursor_expired, как обещано.
|
|
65
|
+
*/
|
|
66
|
+
private catchingUp;
|
|
49
67
|
private off;
|
|
50
68
|
private timer;
|
|
51
69
|
private failure;
|
|
@@ -56,11 +74,17 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
56
74
|
/** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
|
|
57
75
|
private queue;
|
|
58
76
|
constructor(ctx: WatchCtx, opts: WatchOptions);
|
|
59
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
|
|
79
|
+
* в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
|
|
80
|
+
* курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
|
|
81
|
+
* cursor_expired.
|
|
82
|
+
*/
|
|
60
83
|
get cursor(): string | null;
|
|
61
84
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
85
|
+
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток
|
|
86
|
+
* (доработки после точки Б). У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
|
|
87
|
+
* выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
|
|
64
88
|
*/
|
|
65
89
|
ready(): Promise<void>;
|
|
66
90
|
private head;
|
package/dist/watch.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* без LISTEN и на случай потери сигнала — запасной опрос.
|
|
14
14
|
*
|
|
15
15
|
* Курсор подписчика старше яруса all политики хранения его классов — cursor_expired: события могли
|
|
16
|
-
* проредиться, нужно перечитать текущее состояние и подписаться заново.
|
|
16
|
+
* проредиться, нужно перечитать текущее состояние и подписаться заново. Время курсора подписки — время последнего
|
|
17
|
+
* события, а догнавшей журнал подписки — время опроса (не позже самой старой ещё не отданной записи): подписка без
|
|
18
|
+
* событий не стареет. Курсор события хранит время события.
|
|
17
19
|
*/
|
|
18
20
|
import { LetopisError } from './errors.js';
|
|
19
21
|
import { toRow } from './sql.js';
|
|
@@ -54,9 +56,19 @@ export class Subscription {
|
|
|
54
56
|
opts;
|
|
55
57
|
pos = null;
|
|
56
58
|
buf = [];
|
|
59
|
+
/** Курсор после последнего выданного события, пока в буфере есть невыданные (pos — уже после всей пачки). */
|
|
60
|
+
given = null;
|
|
57
61
|
closed = false;
|
|
58
62
|
wake = null;
|
|
59
63
|
dirty = true;
|
|
64
|
+
/** Пришёл сигнал: пустой ответ — ещё не горизонт, нужны короткие повторы. Таймер их не запускает (план 2.0, 7.4 №1). */
|
|
65
|
+
burst = false;
|
|
66
|
+
/**
|
|
67
|
+
* from: 'start' — пока подписка не догнала журнал, свои чтения она шлёт без времени курсора: весь журнал, без
|
|
68
|
+
* cursor_expired на старых событиях (план 2.0, 7.4 №3). Курсоры событий и sub.cursor время хранят: продолжение с них
|
|
69
|
+
* после прореживания — cursor_expired, как обещано.
|
|
70
|
+
*/
|
|
71
|
+
catchingUp = false;
|
|
60
72
|
off = null;
|
|
61
73
|
timer = null;
|
|
62
74
|
failure = null;
|
|
@@ -72,8 +84,10 @@ export class Subscription {
|
|
|
72
84
|
this.batch = opts.batch ?? 500;
|
|
73
85
|
this.pollMs = opts.pollMs ?? 1000;
|
|
74
86
|
const from = opts.from ?? 'now';
|
|
75
|
-
if (from === 'start')
|
|
87
|
+
if (from === 'start') {
|
|
76
88
|
this.pos = { tx: '0', seq: '0', at: null };
|
|
89
|
+
this.catchingUp = true;
|
|
90
|
+
}
|
|
77
91
|
else if (from !== 'now')
|
|
78
92
|
this.pos = decode(from);
|
|
79
93
|
else {
|
|
@@ -81,11 +95,12 @@ export class Subscription {
|
|
|
81
95
|
this.start.catch(() => { });
|
|
82
96
|
}
|
|
83
97
|
if (ctx.onSignal) {
|
|
84
|
-
const watched = ctx.classes ? new Set(ctx.classes) : null;
|
|
85
98
|
this.off = ctx.onSignal((s) => {
|
|
86
|
-
|
|
99
|
+
const cls = ctx.classesOf?.() ?? ctx.classes;
|
|
100
|
+
if (!wakes(s, cls ? new Set(cls) : null, ctx.tenant?.() ?? null))
|
|
87
101
|
return;
|
|
88
102
|
this.dirty = true;
|
|
103
|
+
this.burst = true;
|
|
89
104
|
this.wake?.();
|
|
90
105
|
});
|
|
91
106
|
}
|
|
@@ -95,13 +110,21 @@ export class Subscription {
|
|
|
95
110
|
}, this.pollMs);
|
|
96
111
|
this.timer.unref?.();
|
|
97
112
|
}
|
|
98
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
|
|
115
|
+
* в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
|
|
116
|
+
* курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
|
|
117
|
+
* cursor_expired.
|
|
118
|
+
*/
|
|
99
119
|
get cursor() {
|
|
120
|
+
if (this.buf.length)
|
|
121
|
+
return this.given;
|
|
100
122
|
return this.pos ? encode(this.pos) : null;
|
|
101
123
|
}
|
|
102
124
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
125
|
+
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток
|
|
126
|
+
* (доработки после точки Б). У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
|
|
127
|
+
* выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
|
|
105
128
|
*/
|
|
106
129
|
async ready() {
|
|
107
130
|
if (!this.pos && this.start)
|
|
@@ -122,18 +145,28 @@ export class Subscription {
|
|
|
122
145
|
// уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3) — надгробием прежней
|
|
123
146
|
// версии в том же окне: полная пачка watch_read ограничивает окно своей последней строкой. Оба потока — одним
|
|
124
147
|
// оператором: общий снимок и горизонт, иначе курсор перескочил бы событие, зафиксированное между запросами
|
|
125
|
-
// (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного
|
|
148
|
+
// (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом.
|
|
149
|
+
// Третья строка (src 2) — время курсора, когда пачка неполная (подписка догнала журнал): «сейчас», но не позже самой
|
|
150
|
+
// старой записи подписки, которая уже зафиксирована, а горизонт ещё не прошла (её держит более старая транзакция;
|
|
151
|
+
// снимок «now» — без записей, которые подписка не отдаёт). Тот же снимок, что у watch_read
|
|
126
152
|
const S = this.ctx.S;
|
|
127
153
|
const both = await this.ctx.exec.run((q) => q.unsafe(`with r as (select l.* from ${S}.watch_read($1::text::xid8, $2::bigint, $3::text::timestamptz, $4::text[], $5::int, $6::text::pg_snapshot) l),
|
|
128
154
|
u as (select r.tx, r.seq from r where (select count(*) from r) = $5 order by r.tx desc, r.seq desc limit 1)
|
|
129
155
|
select 0 as src, ${cols} from r l
|
|
130
156
|
union all
|
|
131
157
|
select 1 as src, ${cols} from ${S}.watch_leaving($1::text::xid8, $2::bigint, $4::text[], $5::int, $6::text::pg_snapshot,
|
|
132
|
-
(select tx from u), (select seq from u)) l
|
|
158
|
+
(select tx from u), (select seq from u)) l
|
|
159
|
+
union all
|
|
160
|
+
select 2 as src, null, null, jsonb_build_object('at', least(statement_timestamp(), min(l.at))) from ${S}.log l
|
|
161
|
+
where (select count(*) from r) < $5 and (l.tx, l.seq) > ($1::text::xid8, $2::bigint)
|
|
162
|
+
and l.tx >= pg_snapshot_xmin(pg_current_snapshot()) and ($4::text[] is null or l.class = any ($4::text[]))
|
|
163
|
+
and ($6::text::pg_snapshot is null or not pg_visible_in_snapshot(l.tx, $6::text::pg_snapshot))
|
|
164
|
+
having (select count(*) from r) < $5`, [p.tx, p.seq, this.catchingUp ? null : p.at, this.ctx.classesOf?.() ?? this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
|
|
133
165
|
const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
|
|
134
166
|
const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
|
|
135
167
|
const read = both.filter((r) => r.src === 0).sort(order);
|
|
136
168
|
const away = both.filter((r) => r.src === 1).sort(order);
|
|
169
|
+
const caught = both.find((r) => r.src === 2);
|
|
137
170
|
let rows = read;
|
|
138
171
|
{
|
|
139
172
|
const upto = read.length === this.batch ? read[read.length - 1] : null;
|
|
@@ -145,12 +178,21 @@ export class Subscription {
|
|
|
145
178
|
rows = [...read, ...away].sort(order).filter((r) => !bound || !before(bound, r));
|
|
146
179
|
}
|
|
147
180
|
}
|
|
181
|
+
// пока события пачки не выданы, курсор подписки — позиция до пачки
|
|
182
|
+
if (rows.length)
|
|
183
|
+
this.given = encode(p);
|
|
148
184
|
for (const r of rows) {
|
|
149
185
|
const row = toRow(r.row);
|
|
150
186
|
const snap = keepSnap(p.snap, r.tx);
|
|
151
187
|
this.pos = { tx: r.tx, seq: r.seq, at: row.at, ...(snap ? { snap } : {}) };
|
|
152
188
|
this.buf.push({ cursor: encode(this.pos), row, id: row.id, class: row.class, op: row.op, rev: row.rev, at: row.at });
|
|
153
189
|
}
|
|
190
|
+
// догнала журнал — время курсора подписки (sub.cursor) обновляется и на пустых опросах: прореживать после него нечего,
|
|
191
|
+
// и подписка, к которой долго не было событий, не получает ложный cursor_expired. Курсоры событий хранят время события
|
|
192
|
+
if (caught && away.length < this.batch) {
|
|
193
|
+
this.pos = { ...(this.pos ?? p), at: String(caught.row.at) };
|
|
194
|
+
this.catchingUp = false;
|
|
195
|
+
}
|
|
154
196
|
return rows.length;
|
|
155
197
|
}
|
|
156
198
|
/**
|
|
@@ -167,8 +209,11 @@ export class Subscription {
|
|
|
167
209
|
for (;;) {
|
|
168
210
|
if (this.failure)
|
|
169
211
|
throw this.failure;
|
|
170
|
-
if (this.buf.length)
|
|
171
|
-
|
|
212
|
+
if (this.buf.length) {
|
|
213
|
+
const ev = this.buf.shift();
|
|
214
|
+
this.given = ev.cursor;
|
|
215
|
+
return { value: ev, done: false };
|
|
216
|
+
}
|
|
172
217
|
if (this.closed)
|
|
173
218
|
return { value: undefined, done: true };
|
|
174
219
|
if (this.dirty) {
|
|
@@ -184,12 +229,15 @@ export class Subscription {
|
|
|
184
229
|
this.close();
|
|
185
230
|
throw e;
|
|
186
231
|
}
|
|
187
|
-
// сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной
|
|
188
|
-
|
|
232
|
+
// сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной опрос. Опрос по таймеру —
|
|
233
|
+
// один запрос: серия повторов на каждый тик давала около 4 запросов в секунду на молчащую подписку (план 2.0,
|
|
234
|
+
// 7.4 №1)
|
|
235
|
+
if (this.burst && ++empty < 40) {
|
|
189
236
|
await sleep(Math.min(25 * empty, 250));
|
|
190
237
|
continue;
|
|
191
238
|
}
|
|
192
239
|
this.dirty = false;
|
|
240
|
+
this.burst = false;
|
|
193
241
|
empty = 0;
|
|
194
242
|
}
|
|
195
243
|
await new Promise((r) => {
|
|
@@ -216,6 +264,7 @@ export class Subscription {
|
|
|
216
264
|
return;
|
|
217
265
|
this.closed = true;
|
|
218
266
|
this.off?.();
|
|
267
|
+
this.ctx.onClose?.(this);
|
|
219
268
|
if (this.timer)
|
|
220
269
|
clearInterval(this.timer);
|
|
221
270
|
this.wake?.();
|
package/dist/write.d.ts
CHANGED
|
@@ -10,8 +10,9 @@ export interface IncMarker {
|
|
|
10
10
|
readonly start?: number;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Приращение в update
|
|
14
|
-
*
|
|
13
|
+
* Приращение числового поля в `update()`: `db.Wallet(w).update({ balance: inc(-300) })`. Сложение делает база над
|
|
14
|
+
* текущим значением поля в момент записи, поэтому одновременные приращения не теряются. Если поля ещё нет, `{ start }`
|
|
15
|
+
* задаёт, с чего начать (поле получит `start + by`), а без него — ошибка `invalid_data`.
|
|
15
16
|
*/
|
|
16
17
|
export declare function inc(by: number, opts?: {
|
|
17
18
|
start?: number;
|
package/dist/write.js
CHANGED
|
@@ -29,8 +29,9 @@ import { applyDefaults } from './validate.js';
|
|
|
29
29
|
/** Метка приращения; Symbol.for — маркер узнаёт и вторая копия пакета в процессе. */
|
|
30
30
|
export const INC = Symbol.for('letopis.inc');
|
|
31
31
|
/**
|
|
32
|
-
* Приращение в update
|
|
33
|
-
*
|
|
32
|
+
* Приращение числового поля в `update()`: `db.Wallet(w).update({ balance: inc(-300) })`. Сложение делает база над
|
|
33
|
+
* текущим значением поля в момент записи, поэтому одновременные приращения не теряются. Если поля ещё нет, `{ start }`
|
|
34
|
+
* задаёт, с чего начать (поле получит `start + by`), а без него — ошибка `invalid_data`.
|
|
34
35
|
*/
|
|
35
36
|
export function inc(by, opts = {}) {
|
|
36
37
|
return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
|
|
@@ -184,6 +185,9 @@ export function precheckPlan(reg, steps) {
|
|
|
184
185
|
if (!Array.isArray(op.fields) || !op.fields.length)
|
|
185
186
|
fail('invalid_query', 'anonymize(поля): нужен список полей');
|
|
186
187
|
for (const f of op.fields) {
|
|
188
|
+
// поля нет в классе — так и сказано, а не «поле jsonb» (план 2.0, 7.5 №3)
|
|
189
|
+
if (!(f in (s.cls.schema.properties ?? {})))
|
|
190
|
+
fail('invalid_query', `anonymize(): поля ${f} нет в классе ${s.cls.name}`);
|
|
187
191
|
const t = reg.leafType(s.cls.name, [f]);
|
|
188
192
|
if (t !== 'text')
|
|
189
193
|
fail('invalid_query', `anonymize() затирает только строковые поля; ${f} — ${t}`);
|
|
@@ -342,9 +346,17 @@ async function slotIds(w, v) {
|
|
|
342
346
|
if (Array.isArray(v))
|
|
343
347
|
return v;
|
|
344
348
|
if ('plan' in v) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
349
|
+
let rows;
|
|
350
|
+
if (v.plan.some((s) => s.op)) {
|
|
351
|
+
// вложенный план — те же проверки до базы, что у плана терминала; с читающим хвостом после глагола execPlan
|
|
352
|
+
// отдаёт строки чтения (план 2.0, 7.2 №3: раньше — TypeError)
|
|
353
|
+
precheckPlan(w.reg, v.plan);
|
|
354
|
+
const r = await execPlan(w, v.plan, v.mods, 'rows');
|
|
355
|
+
rows = r.kind === 'rows' ? r.rows : r.res;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
rows = await readRows(w, v.plan, v.mods);
|
|
359
|
+
}
|
|
348
360
|
return rows.map((r) => r.id);
|
|
349
361
|
}
|
|
350
362
|
return [v.id];
|
|
@@ -377,7 +389,11 @@ function applySlotsLocal(links, ops) {
|
|
|
377
389
|
}
|
|
378
390
|
return out;
|
|
379
391
|
}
|
|
380
|
-
|
|
392
|
+
/**
|
|
393
|
+
* Теги, которые пишет глагол: строка или список из `.tags(…)` (перед update они же и отбирают цели). Оператор
|
|
394
|
+
* (has, hasAny, hasAll) только отбирает — теги целей не меняются; create и upsert его не принимают (precheck).
|
|
395
|
+
*/
|
|
396
|
+
const tagsValue = (s) => typeof s.tagsFilter === 'string' ? [s.tagsFilter] : Array.isArray(s.tagsFilter) ? s.tagsFilter : null;
|
|
381
397
|
/** «Уже существует»: id вычисляет библиотека (idOf), а не разбирает текст ошибки сервера (§2.5). */
|
|
382
398
|
function existsError(w, cls, data, links, given, cause) {
|
|
383
399
|
let id = given;
|
|
@@ -440,12 +456,23 @@ async function explainMissing(w, missing, rev) {
|
|
|
440
456
|
}
|
|
441
457
|
return fail('acl_denied', `строки видны, но изменить их нельзя: ${seen.map((r) => r.id).join(', ')}`, { ids: seen.map((r) => r.id) });
|
|
442
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
|
|
461
|
+
* отдаёт их в порядке обхода таблицы, и `first()` после глагола брал бы случайную из исправленных строк.
|
|
462
|
+
*/
|
|
463
|
+
function inTargetOrder(ids, rows) {
|
|
464
|
+
const pos = new Map(ids.map((id, i) => [id, i]));
|
|
465
|
+
return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
|
|
466
|
+
}
|
|
443
467
|
/** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
|
|
444
468
|
async function targetIds(w, steps, opStep, dataId) {
|
|
445
469
|
const path = steps.map(bare);
|
|
446
470
|
if (opStep.filter === undefined && dataId !== undefined)
|
|
447
471
|
path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
|
|
448
|
-
|
|
472
|
+
const ids = await readIds(w, path, opStep.opMods ?? {});
|
|
473
|
+
// без явного порядка — по id: глаголы по списку целей (reclass, rekey и др.) пишут журнал по очереди, и порядок
|
|
474
|
+
// записей не должен зависеть от плана чтения
|
|
475
|
+
return opStep.opMods?.order ? ids : [...ids].sort();
|
|
449
476
|
}
|
|
450
477
|
async function updateOp(w, steps, opStep) {
|
|
451
478
|
const op = opStep.op;
|
|
@@ -456,7 +483,7 @@ async function updateOp(w, steps, opStep) {
|
|
|
456
483
|
const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
|
|
457
484
|
if (op.rev !== undefined) {
|
|
458
485
|
if (ids.length > 1)
|
|
459
|
-
fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей —
|
|
486
|
+
fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — проверить версию { rev } можно только у одной цели`);
|
|
460
487
|
if (!ids.length)
|
|
461
488
|
fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
|
|
462
489
|
}
|
|
@@ -481,7 +508,7 @@ async function updateOp(w, steps, opStep) {
|
|
|
481
508
|
const got = new Set(res.map((r) => r.row.id));
|
|
482
509
|
await explainMissing(w, ids.filter((x) => !got.has(x)), op.rev);
|
|
483
510
|
}
|
|
484
|
-
return res.map((r) => toRow(r.row));
|
|
511
|
+
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
485
512
|
}
|
|
486
513
|
async function deleteOp(w, steps, opStep) {
|
|
487
514
|
const op = opStep.op;
|
|
@@ -510,9 +537,11 @@ async function deleteOp(w, steps, opStep) {
|
|
|
510
537
|
}
|
|
511
538
|
const tombs = await query(w, `select ${rowJson('l')} as row from ${S}.log l where l.tx = pg_current_xact_id() and l.seq > $1::bigint and l.op = 'delete' order by l.seq`, [mark]);
|
|
512
539
|
const out = tombs.map((r) => toRow(r.row));
|
|
513
|
-
// строки классов без журнала (history: false) надгробий не оставляют — берём их из returning
|
|
540
|
+
// строки классов без журнала (history: false) надгробий не оставляют — берём их из returning, по id (порядок
|
|
541
|
+
// returning зависит от плана, а итог delete() — нет: надгробия идут по seq, триггер пишет их в порядке id)
|
|
514
542
|
const have = new Set(out.map((r) => r.id));
|
|
515
|
-
|
|
543
|
+
const byId = (a, b) => String(a.row.id) < String(b.row.id) ? -1 : String(a.row.id) > String(b.row.id) ? 1 : 0;
|
|
544
|
+
for (const g of [...gone].sort(byId)) {
|
|
516
545
|
if (have.has(g.row.id))
|
|
517
546
|
continue;
|
|
518
547
|
const row = toRow(g.row);
|
|
@@ -534,7 +563,7 @@ async function anonymizeOp(w, steps, opStep) {
|
|
|
534
563
|
const got = new Set(res.map((r) => r.row.id));
|
|
535
564
|
await explainMissing(w, ids.filter((x) => !got.has(x)));
|
|
536
565
|
}
|
|
537
|
-
return res.map((r) => toRow(r.row));
|
|
566
|
+
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
538
567
|
}
|
|
539
568
|
async function reclassOp(w, steps, opStep) {
|
|
540
569
|
const op = opStep.op;
|
|
@@ -546,7 +575,8 @@ async function reclassOp(w, steps, opStep) {
|
|
|
546
575
|
// у класса без ключа id сохраняется, у класса с ключом — новый id и перевод ссылок (reclass() в базе)
|
|
547
576
|
const moved = await query(w, `select ${S}.reclass(x, $2::text, $3::jsonb) as id from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids, cls, op.data ?? null]);
|
|
548
577
|
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [moved.map((m) => m.id)]);
|
|
549
|
-
|
|
578
|
+
// строки ответа — в порядке целей (moved — по порядку целей, план 2.0, 7.2 №2)
|
|
579
|
+
return inTargetOrder(moved.map((m) => m.id), res.map((r) => toRow(r.row)));
|
|
550
580
|
}
|
|
551
581
|
/** Удалённые объекты шага (restore, purge): живые цели без удалённых — not_deleted. */
|
|
552
582
|
async function deletedTargets(w, steps, opStep, verb) {
|
|
@@ -554,7 +584,8 @@ async function deletedTargets(w, steps, opStep, verb) {
|
|
|
554
584
|
const dead = rows.filter((r) => r.$deleted);
|
|
555
585
|
if (!dead.length && rows.length)
|
|
556
586
|
fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
|
|
557
|
-
|
|
587
|
+
// без явного порядка — по id: restore и purge пишут журнал по очереди целей
|
|
588
|
+
return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
558
589
|
}
|
|
559
590
|
/** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
|
|
560
591
|
async function restoreOp(w, steps, opStep) {
|
|
@@ -564,8 +595,8 @@ async function restoreOp(w, steps, opStep) {
|
|
|
564
595
|
return [];
|
|
565
596
|
const ids = dead.map((r) => r.id);
|
|
566
597
|
await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
|
|
567
|
-
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])
|
|
568
|
-
return res.map((r) => toRow(r.row));
|
|
598
|
+
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids]);
|
|
599
|
+
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
569
600
|
}
|
|
570
601
|
/**
|
|
571
602
|
* Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
|
|
@@ -610,8 +641,8 @@ async function rekeyOp(w, steps, opStep) {
|
|
|
610
641
|
throw e;
|
|
611
642
|
}
|
|
612
643
|
}
|
|
613
|
-
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])
|
|
614
|
-
return res.map((r) => toRow(r.row));
|
|
644
|
+
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [moved]);
|
|
645
|
+
return inTargetOrder(moved, res.map((r) => toRow(r.row)));
|
|
615
646
|
}
|
|
616
647
|
async function opCall(w, steps, opStep) {
|
|
617
648
|
await useReason(w, reasonFor(w, opStep));
|
|
@@ -752,7 +783,9 @@ export async function execBatch(w, plans) {
|
|
|
752
783
|
&& reasonFor(w, plans[j][0]) === why) {
|
|
753
784
|
const s = plans[j][0];
|
|
754
785
|
const id = idFor(s);
|
|
755
|
-
|
|
786
|
+
// повтор id режет группу и у create: иначе вторая строка «находила» строку первой, и батч проходил молча
|
|
787
|
+
// (план 2.0, 7.2 №1); повтор уходит следующим оператором и получает exists
|
|
788
|
+
if (!id || seen.has(id))
|
|
756
789
|
break;
|
|
757
790
|
seen.add(id);
|
|
758
791
|
group.push({ at: j, s, id });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"postgres",
|
|
@@ -68,16 +68,19 @@
|
|
|
68
68
|
"seed:booking": "tsx scripts/gen-seed-booking.mjs",
|
|
69
69
|
"test:import-salon": "tsx bench/import-salon.mjs",
|
|
70
70
|
"api:contract": "node scripts/gen-api-contract.mjs",
|
|
71
|
-
"prepublishOnly": "npm run typecheck && npm run check:docs && npm run build"
|
|
71
|
+
"prepublishOnly": "npm run typecheck && npm run check:docs && npm run build",
|
|
72
|
+
"check:semver": "node scripts/check-semver.mjs",
|
|
73
|
+
"prerelease": "node scripts/prerelease.mjs",
|
|
74
|
+
"soak": "tsx bench/soak.mjs"
|
|
72
75
|
},
|
|
73
76
|
"dependencies": {
|
|
74
77
|
"postgres": "^3.4.9"
|
|
75
78
|
},
|
|
76
79
|
"peerDependencies": {
|
|
77
|
-
"zod": "^4",
|
|
78
80
|
"@electric-sql/pglite": "^0.5.8",
|
|
79
81
|
"@electric-sql/pglite-socket": "^0.2.11",
|
|
80
|
-
"@modelcontextprotocol/server": "^2.4.0"
|
|
82
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
83
|
+
"zod": "^4"
|
|
81
84
|
},
|
|
82
85
|
"peerDependenciesMeta": {
|
|
83
86
|
"zod": {
|
|
@@ -102,6 +105,7 @@
|
|
|
102
105
|
"letopis-0": "npm:letopis@0.21.0",
|
|
103
106
|
"letopis-1": "npm:letopis@1.0.1",
|
|
104
107
|
"letopis-11": "npm:letopis@1.1.0",
|
|
108
|
+
"letopis-141": "npm:letopis@1.4.1",
|
|
105
109
|
"tsx": "^4.23.15",
|
|
106
110
|
"typescript": "~5.9.3",
|
|
107
111
|
"zod": "4.6.5"
|
package/sql/10-core.sql
CHANGED
|
@@ -107,9 +107,12 @@ create index if not exists log_ends on @SCHEMA@.log using gin (@SCHEMA@.link_ids
|
|
|
107
107
|
-- журнал часто прореживается (§2.11): чаще чистим
|
|
108
108
|
alter table @SCHEMA@.log set (autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.02);
|
|
109
109
|
|
|
110
|
+
-- account — строка entity (аккаунт; у служебных строк KEY, LOCK, PROJECTOR — System): удалённый аккаунт уносит свои строки
|
|
111
|
+
-- каскадом внешнего ключа — сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (ревизия 7; в схему
|
|
112
|
+
-- прежней ревизии ключ добавляет миграция 0007). Строка Account у System не удаляется (trg_entity_bd_account, 60-write.sql)
|
|
110
113
|
create table if not exists @SCHEMA@.secret (
|
|
111
114
|
id uuid primary key default gen_random_uuid(),
|
|
112
|
-
account uuid not null,
|
|
115
|
+
account uuid not null constraint secret_account_fkey references @SCHEMA@.entity (id) on delete cascade,
|
|
113
116
|
kind text not null, -- PASSWORD, APIKEY, KEYSECRET, OTP, TOTP, IDENTITY, SESSION
|
|
114
117
|
ident text, -- email, телефон, префикс ключа, внешний id
|
|
115
118
|
material bytea, -- хэш пароля, ключа, кода или токена; секрет TOTP
|
|
@@ -121,6 +124,8 @@ create table if not exists @SCHEMA@.secret (
|
|
|
121
124
|
create unique index if not exists secret_kind_ident on @SCHEMA@.secret (kind, casefold(ident) collate pg_unicode_fast)
|
|
122
125
|
where deleted_at is null and ident is not null;
|
|
123
126
|
create index if not exists secret_material on @SCHEMA@.secret (material) where kind = 'SESSION' and deleted_at is null;
|
|
127
|
+
-- строки аккаунта: каскад внешнего ключа при удалении аккаунта и гашение его сессий (auth_kill) — без перебора таблицы
|
|
128
|
+
create index if not exists secret_account on @SCHEMA@.secret (account);
|
|
124
129
|
|
|
125
130
|
alter table @SCHEMA@.entity owner to @OWNER@;
|
|
126
131
|
alter table @SCHEMA@.log owner to @OWNER@;
|