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/load.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* await db.load({ Org: orgs, Staff: staff }) // объект «класс → строки»
|
|
10
10
|
* await db.load(rows) // массив или поток строк с полем $class
|
|
11
11
|
*
|
|
12
|
+
* Первая заливка (план 1.1, §2.3, этап 3) — load(rows, { initial: true }) и initialSession: пустая схема,
|
|
13
|
+
* одна транзакция, без индексов и триггеров на время COPY (FREEZE), перестройка в конце.
|
|
14
|
+
*
|
|
12
15
|
* Строка — объект: поля данных и роли концов на верхнем уровне; $class — класс строки, $ext —
|
|
13
16
|
* внешний ключ класса без ключа (повторяемый импорт), $tags — теги, $owner — владелец (аккаунт; не
|
|
14
17
|
* с { as }: там владелец по ownerDefault). Значение конца — id, { $key: [...] } или { $ext: '…' }, у
|
|
@@ -17,19 +20,24 @@
|
|
|
17
20
|
import { randomUUID } from 'node:crypto';
|
|
18
21
|
import { LetopisError, fromDbError, removed } from './errors.js';
|
|
19
22
|
import { isOp } from './ops.js';
|
|
20
|
-
import { Registry } from './registry.js';
|
|
23
|
+
import { Registry, aggregateFields } from './registry.js';
|
|
21
24
|
import { accepts } from './sql.js';
|
|
22
25
|
import { v5Id } from './uuid.js';
|
|
23
26
|
import { applyDefaults } from './validate.js';
|
|
24
27
|
import { isInc } from './write.js';
|
|
28
|
+
import { ACL_CLASS_NAMES } from './types.js';
|
|
25
29
|
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
26
|
-
/** Классы, строки которых меняют права: загрузка их строк повышает счётчик изменений прав. */
|
|
27
|
-
const ACL_CLASSES = ['rule', 'Resource', 'member', 'Account', 'Class'];
|
|
28
30
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
31
|
+
/** Классы, строки которых первая заливка сохраняет и возвращает: описания классов, аккаунты, членства, права. */
|
|
32
|
+
const SYSTEM_CLASSES = ['Class', 'Account', 'member', 'Resource', 'rule'];
|
|
33
|
+
// значения без обратной косой, табуляции и переводов строки (почти все: uuid, числа, время) — как есть, без четырёх
|
|
34
|
+
// замен (профиль первой заливки: esc — 14 % процессора)
|
|
35
|
+
const ESCAPED = /[\\\t\n\r]/;
|
|
29
36
|
const esc = (v) => {
|
|
30
37
|
if (v === null || v === undefined)
|
|
31
38
|
return '\\N';
|
|
32
|
-
|
|
39
|
+
const s = String(v);
|
|
40
|
+
return ESCAPED.test(s) ? s.replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r') : s;
|
|
33
41
|
};
|
|
34
42
|
const hex = (b) => (b ? '\\x' + b.toString('hex') : null);
|
|
35
43
|
const ENTITY_COLS = 'id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator';
|
|
@@ -147,6 +155,10 @@ function shape(reg, tenant, cls, raw, check) {
|
|
|
147
155
|
data[k] = v;
|
|
148
156
|
}
|
|
149
157
|
}
|
|
158
|
+
// агрегатные поля считает база (план 1.1, §2.4): переданное значение не пишется — 0, итог посчитает триггер
|
|
159
|
+
// после оператора, а первая заливка — agg_fill
|
|
160
|
+
for (const f of aggregateFields(cls))
|
|
161
|
+
data[f] = 0;
|
|
150
162
|
if (!check)
|
|
151
163
|
return { data, links };
|
|
152
164
|
const scan = (v, path) => {
|
|
@@ -260,10 +272,16 @@ function classGroups(reg, names) {
|
|
|
260
272
|
visit(n);
|
|
261
273
|
return groups;
|
|
262
274
|
}
|
|
275
|
+
/** Строки COPY уходят кусками примерно такой длины: postgres.js шлёт каждую запись в поток отдельным CopyData. */
|
|
276
|
+
const COPY_CHUNK = 262144;
|
|
277
|
+
/** Уступить циклу событий: запись в сокет и колбэки потока COPY идут в нём же. */
|
|
278
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
263
279
|
/**
|
|
264
280
|
* COPY … from stdin. postgres.js 3.4 теряет ошибку сервера, пришедшую после конца потока: запрос
|
|
265
281
|
* уже разрешён потоком, а колбэк завершения не вызывается — промис повис бы. Поэтому reject
|
|
266
|
-
* запроса перехватывается до начала потока и отклоняет ожидание.
|
|
282
|
+
* запроса перехватывается до начала потока и отклоняет ожидание. Строки склеиваются в куски до
|
|
283
|
+
* COPY_CHUNK символов: запись в сокет на каждую строку стоила дороже подготовки самой строки
|
|
284
|
+
* (замер первой заливки, план 1.1, этап 3), а границы сообщений протокол COPY не связывает со строками.
|
|
267
285
|
*/
|
|
268
286
|
async function copyLines(conn, text, lines) {
|
|
269
287
|
if (!lines.length)
|
|
@@ -288,7 +306,10 @@ async function copyLines(conn, text, lines) {
|
|
|
288
306
|
let i = 0;
|
|
289
307
|
const pump = () => {
|
|
290
308
|
while (i < lines.length) {
|
|
291
|
-
|
|
309
|
+
let chunk = lines[i++];
|
|
310
|
+
while (i < lines.length && chunk.length < COPY_CHUNK)
|
|
311
|
+
chunk += lines[i++];
|
|
312
|
+
if (!w.write(chunk)) {
|
|
292
313
|
w.once('drain', pump);
|
|
293
314
|
return;
|
|
294
315
|
}
|
|
@@ -298,6 +319,23 @@ async function copyLines(conn, text, lines) {
|
|
|
298
319
|
pump();
|
|
299
320
|
});
|
|
300
321
|
}
|
|
322
|
+
/** verify: true — загруженные строки перепроверяет проверка базы (данные и цели концов); нарушения — в to. */
|
|
323
|
+
async function verifyIds(conn, S, ids, to) {
|
|
324
|
+
const bad = await conn.unsafe(`select e.id, e.class, ${S}.validate_data(c.data->'resolved'->'rules', c.validator, c.data->'resolved'->'vars', e.data) as issues
|
|
325
|
+
from ${S}.entity e cross join lateral ${S}.class_row(e.class, e.tenant) c
|
|
326
|
+
where e.id = any ($1::uuid[]) and ${S}.validate_data(c.data->'resolved'->'rules', c.validator, c.data->'resolved'->'vars', e.data) <> '[]'::jsonb limit ${ISSUES_LIMIT}`, [ids]);
|
|
327
|
+
for (const r of bad)
|
|
328
|
+
to.add({ line: 0, code: 'invalid_data', message: `проверка базы: строка ${r.id} не проходит описание класса ${r.class}`, id: r.id, issues: r.issues });
|
|
329
|
+
if (bad.length >= ISSUES_LIMIT)
|
|
330
|
+
to.unchecked.add(`проверка базы называет не больше ${ISSUES_LIMIT} строк`);
|
|
331
|
+
const [t] = await conn.unsafe(`select ${S}.targets_bad((select jsonb_agg(jsonb_build_object('class', e.class, 'tenant', e.tenant, 'links', e.links))
|
|
332
|
+
from ${S}.entity e where e.id = any ($1::uuid[]) and e.links <> '{}'::jsonb), false) as role`, [ids]);
|
|
333
|
+
const role = t?.role;
|
|
334
|
+
if (role?.startsWith('!'))
|
|
335
|
+
to.add({ line: 0, code: 'derived_class', message: `проверка базы: конец ${role.slice(1)} ведёт на строку производного класса` });
|
|
336
|
+
else if (role)
|
|
337
|
+
to.add({ line: 0, code: 'target_not_found', message: `проверка базы: цель конца ${role} не найдена` });
|
|
338
|
+
}
|
|
301
339
|
/**
|
|
302
340
|
* Загрузить строки (§2.6). Одна транзакция (или порции commitEvery): описания классов под
|
|
303
341
|
* разделяемыми блокировками семейств, проверки в коде, COPY пачками, запрос по целям вне загрузки,
|
|
@@ -316,6 +354,16 @@ export async function load(env, input, opts = {}) {
|
|
|
316
354
|
throw new LetopisError('invalid_data', 'load({ as }) и режим истории несовместимы: историю импортирует администратор');
|
|
317
355
|
if (opts.withHistory && opts.existing === 'update')
|
|
318
356
|
throw new LetopisError('invalid_data', "режим истории: existing — 'error' или 'skip' (новую версию существующего объекта пишет загрузка без истории)");
|
|
357
|
+
const ini = env.session ?? null;
|
|
358
|
+
if (opts.initial || ini) {
|
|
359
|
+
// снятые индексы и TRUNCATE — одна транзакция; в пустой схеме пропускать и обновлять нечего
|
|
360
|
+
if (opts.commitEvery)
|
|
361
|
+
throw new LetopisError('invalid_data', 'первая заливка (initial) несовместима с commitEvery: снятые индексы и TRUNCATE требуют одной транзакции');
|
|
362
|
+
if (opts.existing !== undefined && opts.existing !== 'error')
|
|
363
|
+
throw new LetopisError('invalid_data', "первая заливка (initial): existing — только 'error', в пустой схеме пропускать и обновлять нечего");
|
|
364
|
+
if (asAcc !== undefined)
|
|
365
|
+
throw new LetopisError('invalid_data', 'первая заливка (initial) — от администратора, без { as }');
|
|
366
|
+
}
|
|
319
367
|
const tenant = opts.tenant ?? asAcc ?? env.tenant;
|
|
320
368
|
if (!tenant)
|
|
321
369
|
throw new LetopisError('invalid_data', 'load(): нет арендатора — сессия без арендатора и не задан { tenant }');
|
|
@@ -330,6 +378,13 @@ export async function load(env, input, opts = {}) {
|
|
|
330
378
|
const report = { rows: rows.length, loaded: 0, updated: 0, skipped: 0, duplicates: 0, byClass: {}, external: 0, portions: 0, ms: 0 };
|
|
331
379
|
if (!rows.length)
|
|
332
380
|
return report;
|
|
381
|
+
// первая заливка одной загрузкой — своя сессия; вход уже прочитан (поток второй раз не читается)
|
|
382
|
+
if (opts.initial && !ini) {
|
|
383
|
+
const r = await initialSession(env, (sess) => sess.load(rows, opts));
|
|
384
|
+
// время — вместе с перестройкой, отложенными проверками и ANALYZE сессии
|
|
385
|
+
r.ms = performance.now() - t0;
|
|
386
|
+
return r;
|
|
387
|
+
}
|
|
333
388
|
const readRegistry = async (q) => {
|
|
334
389
|
const reg = new Registry();
|
|
335
390
|
reg.load(await q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in ($1::uuid, $2::uuid)`, [tenant, SYSTEM_ID]));
|
|
@@ -345,7 +400,8 @@ export async function load(env, input, opts = {}) {
|
|
|
345
400
|
const targets = new Map();
|
|
346
401
|
/** Владельцы ($owner) порции, кроме арендатора: id → первая строка; сверяются запросом в конце. */
|
|
347
402
|
const owners = new Map();
|
|
348
|
-
|
|
403
|
+
// в первой заливке таблицы заблокированы сессией: читать — на её соединении
|
|
404
|
+
const reg0 = await readRegistry(ini?.conn ?? env.sql);
|
|
349
405
|
const p1 = new Array(rows.length);
|
|
350
406
|
const lineOf = new Map();
|
|
351
407
|
const chosen = new Set();
|
|
@@ -370,12 +426,24 @@ export async function load(env, input, opts = {}) {
|
|
|
370
426
|
p1[i] = null;
|
|
371
427
|
continue;
|
|
372
428
|
}
|
|
429
|
+
// строки производного класса пишет только его проектор (план 1.1, §2.4)
|
|
430
|
+
if (cls.derivedBy) {
|
|
431
|
+
issues.add({ line, code: 'derived_class', message: `класс ${cls.name} производный: его строки пишет проектор ${cls.derivedBy}`, class: cls.name });
|
|
432
|
+
p1[i] = null;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
373
435
|
const id = idOfRow(reg0, tenant, cls, raw);
|
|
374
436
|
if (typeof id !== 'string') {
|
|
375
437
|
issues.add({ line, code: 'invalid_data', message: id.error, class: cls.name });
|
|
376
438
|
p1[i] = null;
|
|
377
439
|
continue;
|
|
378
440
|
}
|
|
441
|
+
// первая заливка: первичного ключа нет — повтор id со строками системных классов и прошлых загрузок сессии ловит проход 1
|
|
442
|
+
if (ini?.ids.has(id)) {
|
|
443
|
+
issues.add({ line, code: 'exists', message: 'объект уже есть в схеме: строка системного класса или запись этой же первой заливки', id, class: cls.name });
|
|
444
|
+
p1[i] = null;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
379
447
|
p1[i] = { cls, id };
|
|
380
448
|
if (opts.withHistory) {
|
|
381
449
|
const list = versions.get(id) ?? [];
|
|
@@ -457,13 +525,16 @@ export async function load(env, input, opts = {}) {
|
|
|
457
525
|
owners.clear();
|
|
458
526
|
issues.unchecked.clear();
|
|
459
527
|
laterUnchecked(portion, issues);
|
|
460
|
-
|
|
528
|
+
// первая заливка: транзакцию, роль и пояс поставила сессия, фиксирует и откатывает тоже она
|
|
529
|
+
const conn = ini ? ini.conn : await env.sql.reserve();
|
|
461
530
|
const written = [];
|
|
462
531
|
let failed = null;
|
|
463
532
|
try {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
533
|
+
if (!ini) {
|
|
534
|
+
await conn.unsafe('begin isolation level read committed');
|
|
535
|
+
await conn.unsafe(`set local role ${`"${env.ownerRole.replace(/"/g, '""')}"`}`);
|
|
536
|
+
await conn.unsafe(`select set_config('TimeZone', 'UTC', true)`);
|
|
537
|
+
}
|
|
467
538
|
// описания классов под разделяемыми блокировками семейств, затем — свежие
|
|
468
539
|
const roots = [...new Set(portion.map((i) => p1[i].cls.ancestors[p1[i].cls.ancestors.length - 1]))].sort();
|
|
469
540
|
// ключ блокировки — арендатор корневого класса и имя корня (корень из System — ключ System)
|
|
@@ -531,7 +602,10 @@ export async function load(env, input, opts = {}) {
|
|
|
531
602
|
todo = portion.filter((i) => !taken.has(p1[i].id));
|
|
532
603
|
}
|
|
533
604
|
if (opts.withHistory) {
|
|
534
|
-
await historyWrite(conn, reg, todo, inLoad, external, xid, classesTouched);
|
|
605
|
+
await historyWrite(conn, reg, todo, inLoad, external, xid, classesTouched, written);
|
|
606
|
+
}
|
|
607
|
+
else if (ini) {
|
|
608
|
+
await pipeline(conn, reg, portion, inLoad, external, ts, xid, classesTouched, written, live, past);
|
|
535
609
|
}
|
|
536
610
|
else {
|
|
537
611
|
for (let b = 0; b < portion.length; b += batch) {
|
|
@@ -541,6 +615,19 @@ export async function load(env, input, opts = {}) {
|
|
|
541
615
|
}
|
|
542
616
|
if (issues.any)
|
|
543
617
|
issues.fail();
|
|
618
|
+
if (ini) {
|
|
619
|
+
// цели, владельцы, арендатор и проверка базы — в конце сессии, по перестроенным индексам
|
|
620
|
+
report.external += external.size;
|
|
621
|
+
ini.pending.push({ reg, tenant: tenant, external, owners: new Map(owners), verify: opts.verify ? written : null });
|
|
622
|
+
for (const id of inLoad.keys())
|
|
623
|
+
ini.ids.add(id);
|
|
624
|
+
let set = ini.touched.get(tenant);
|
|
625
|
+
if (!set)
|
|
626
|
+
ini.touched.set(tenant, (set = new Set()));
|
|
627
|
+
for (const c of classesTouched)
|
|
628
|
+
set.add(c);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
544
631
|
// цели вне загрузки — один запрос в конце с блокировкой до фиксации (§2.6, п. 7)
|
|
545
632
|
const ext = [...external.keys()].sort();
|
|
546
633
|
report.external += ext.length;
|
|
@@ -553,6 +640,9 @@ export async function load(env, input, opts = {}) {
|
|
|
553
640
|
if (!f || (f.tenant !== tenant && f.tenant !== SYSTEM_ID) || !accepts(reg, ref.allowed, f.class)) {
|
|
554
641
|
issues.add({ line: ref.line, code: 'target_not_found', message: `цель конца ${ref.role} не найдена`, id });
|
|
555
642
|
}
|
|
643
|
+
else if (reg.find(f.class)?.derivedBy) {
|
|
644
|
+
issues.add({ line: ref.line, code: 'derived_class', message: `цель конца ${ref.role} — строка производного класса ${f.class}`, id });
|
|
645
|
+
}
|
|
556
646
|
}
|
|
557
647
|
}
|
|
558
648
|
// владельцы ($owner) — аккаунты: из загрузки (живые) или из базы, с блокировкой до фиксации
|
|
@@ -581,12 +671,18 @@ export async function load(env, input, opts = {}) {
|
|
|
581
671
|
issues.add({ line: portion.length ? portion[0] + 1 : 0, code: 'tenant_denied', message: `арендатор ${tenant} не найден или отключён` });
|
|
582
672
|
if (issues.any)
|
|
583
673
|
issues.fail();
|
|
584
|
-
if (opts.verify && written.length)
|
|
585
|
-
await
|
|
586
|
-
|
|
587
|
-
|
|
674
|
+
if (opts.verify && written.length) {
|
|
675
|
+
await verifyIds(conn, S, written, issues);
|
|
676
|
+
if (issues.any)
|
|
677
|
+
issues.fail();
|
|
678
|
+
}
|
|
679
|
+
// сигнал — функцией базы, как у триггеров: нагрузка по режиму сигналов установки (план 1.1)
|
|
680
|
+
if (classesTouched.size) {
|
|
681
|
+
const touched = [...classesTouched];
|
|
682
|
+
await conn.unsafe(`select ${S}.signal($1::text[], $2::uuid[])`, [touched, touched.map(() => tenant)]);
|
|
683
|
+
}
|
|
588
684
|
// строки прав меняют планы: пропуска прав клиентов — заново
|
|
589
|
-
if ([...classesTouched].some((c) =>
|
|
685
|
+
if ([...classesTouched].some((c) => ACL_CLASS_NAMES.includes(c)))
|
|
590
686
|
await conn.unsafe(`select ${S}.acl_bump()`);
|
|
591
687
|
if (portion.length >= 10000)
|
|
592
688
|
await conn.unsafe(`analyze ${S}.entity; analyze ${S}.log`);
|
|
@@ -594,14 +690,16 @@ export async function load(env, input, opts = {}) {
|
|
|
594
690
|
}
|
|
595
691
|
catch (e) {
|
|
596
692
|
failed = e;
|
|
597
|
-
|
|
693
|
+
if (!ini)
|
|
694
|
+
await conn.unsafe('rollback').catch(() => { });
|
|
598
695
|
}
|
|
599
696
|
finally {
|
|
600
|
-
|
|
697
|
+
if (!ini)
|
|
698
|
+
conn.release();
|
|
601
699
|
}
|
|
602
700
|
if (failed) {
|
|
603
701
|
const err = failed;
|
|
604
|
-
if (err.code === '23505' && (err.constraint_name === 'entity_pkey' || err.constraint_name === 'log_id_rev')) {
|
|
702
|
+
if (!ini && err.code === '23505' && (err.constraint_name === 'entity_pkey' || err.constraint_name === 'log_id_rev')) {
|
|
605
703
|
await explainTaken(portion);
|
|
606
704
|
}
|
|
607
705
|
throw fromDbError(failed);
|
|
@@ -691,12 +789,56 @@ export async function load(env, input, opts = {}) {
|
|
|
691
789
|
}
|
|
692
790
|
return sh;
|
|
693
791
|
}
|
|
792
|
+
/** Пачка — подготовка и запись по очереди. */
|
|
694
793
|
async function writeBatch(conn, reg, part, inLoad, external, ts, xid, touched, written, live, past) {
|
|
794
|
+
const p = await prepareBatch(reg, part, inLoad, external, ts, xid, touched, written, live, past, 0);
|
|
795
|
+
if (p)
|
|
796
|
+
await flushBatch(conn, p, ts, touched, written);
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Первая заливка — конвейером (решение точки А плана 1.1): следующая пачка готовится, пока база пишет текущую.
|
|
800
|
+
* Подготовка уступает циклу событий каждые 256 строк — иначе COPY в полёте не получал бы следующих кусков, пока
|
|
801
|
+
* идёт синхронная работа. Отказ записи до её await ловит пустой обработчик (иначе — необработанный отказ
|
|
802
|
+
* процесса); при ошибке подготовки запись в полёте доходит до конца, и откат идёт по готовому соединению.
|
|
803
|
+
*/
|
|
804
|
+
async function pipeline(conn, reg, portion, inLoad, external, ts, xid, touched, written, live, past) {
|
|
805
|
+
let flushing = null;
|
|
806
|
+
try {
|
|
807
|
+
for (let b = 0; b < portion.length; b += batch) {
|
|
808
|
+
const p = await prepareBatch(reg, portion.slice(b, b + batch), inLoad, external, ts, xid, touched, written, live, past, 256);
|
|
809
|
+
const prev = flushing;
|
|
810
|
+
flushing = null;
|
|
811
|
+
if (prev)
|
|
812
|
+
await prev;
|
|
813
|
+
if (!p || issues.any)
|
|
814
|
+
return;
|
|
815
|
+
flushing = flushBatch(conn, p, ts, touched, written);
|
|
816
|
+
flushing.catch(() => { });
|
|
817
|
+
}
|
|
818
|
+
const last = flushing;
|
|
819
|
+
flushing = null;
|
|
820
|
+
if (last)
|
|
821
|
+
await last;
|
|
822
|
+
}
|
|
823
|
+
catch (e) {
|
|
824
|
+
if (flushing)
|
|
825
|
+
await flushing.catch(() => { });
|
|
826
|
+
throw e;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Подготовка пачки без запросов к базе: проверки, id, концы, строки COPY. yieldEvery — уступать циклу событий
|
|
831
|
+
* каждые столько строк (конвейер первой заливки). Нарушения — null.
|
|
832
|
+
*/
|
|
833
|
+
async function prepareBatch(reg, part, inLoad, external, ts, xid, touched, written, live, past, yieldEvery) {
|
|
695
834
|
const ent = [];
|
|
696
835
|
const log = [];
|
|
697
836
|
const updates = [];
|
|
698
837
|
const rowsAcl = [];
|
|
838
|
+
let n = 0;
|
|
699
839
|
for (const i of part) {
|
|
840
|
+
if (yieldEvery && ++n % yieldEvery === 0)
|
|
841
|
+
await tick();
|
|
700
842
|
const { id, cls } = p1[i];
|
|
701
843
|
if (existing === 'skip' && (live.has(id) || past.has(id))) {
|
|
702
844
|
report.skipped++;
|
|
@@ -719,10 +861,12 @@ export async function load(env, input, opts = {}) {
|
|
|
719
861
|
const tags = sh.tags ?? [];
|
|
720
862
|
if (acl)
|
|
721
863
|
rowsAcl.push({ i, class: cls.name, id, owner, tags, data: sh.data, links: sh.links });
|
|
722
|
-
|
|
723
|
-
|
|
864
|
+
// общая голова строк entity и log — одной сборкой и одним экранированием
|
|
865
|
+
const head = [id, rev, cls.name, cur.rev, tenant, owner, JSON.stringify(sh.links), JSON.stringify(sh.data), pgArray(tags), atv].map(esc).join('\t');
|
|
866
|
+
const ph = esc(hex(prev?.hash));
|
|
867
|
+
ent.push(`${head}\t${esc(atv)}\t${esc(author)}\t${esc(agent)}\tcreate\t\\N\t\\N\t${ph}\t\\N\n`);
|
|
724
868
|
if (cur.history !== false)
|
|
725
|
-
log.push(
|
|
869
|
+
log.push(`${head}\t${esc(author)}\t${esc(agent)}\tcreate\t\\N\t\\N\t${ph}\t${esc(xid)}\n`);
|
|
726
870
|
report.loaded++;
|
|
727
871
|
report.byClass[cls.name] = (report.byClass[cls.name] ?? 0) + 1;
|
|
728
872
|
touched.add(cls.name);
|
|
@@ -731,8 +875,13 @@ export async function load(env, input, opts = {}) {
|
|
|
731
875
|
if (issues.any) {
|
|
732
876
|
if (aclRan)
|
|
733
877
|
issues.unchecked.add('условия ACL следующих пачек не проверялись');
|
|
734
|
-
return;
|
|
878
|
+
return null;
|
|
735
879
|
}
|
|
880
|
+
return { ent, log, updates, rowsAcl };
|
|
881
|
+
}
|
|
882
|
+
/** Запись подготовленной пачки: условия ACL ({ as }) — запросом на пачку, COPY в entity и log, правки существующих. */
|
|
883
|
+
async function flushBatch(conn, p, ts, touched, written) {
|
|
884
|
+
const { ent, log, updates, rowsAcl } = p;
|
|
736
885
|
if (acl) {
|
|
737
886
|
aclRan = true;
|
|
738
887
|
// условия ACL на строки — одним запросом на пачку: файл пользователя — не доверенные данные
|
|
@@ -756,11 +905,15 @@ export async function load(env, input, opts = {}) {
|
|
|
756
905
|
if (issues.any)
|
|
757
906
|
return;
|
|
758
907
|
}
|
|
759
|
-
|
|
760
|
-
|
|
908
|
+
// первая заливка: FREEZE — таблицы опустошены этой же транзакцией. Журнал — раньше строк: агрегатные поля новой
|
|
909
|
+
// цели триггер после COPY досчитывает в ту же версию — и в entity, и в уже записанную версию журнала (план 1.1, §2.4)
|
|
910
|
+
await copyLines(conn, `copy ${S}.log (${LOG_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, log);
|
|
911
|
+
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, ent);
|
|
761
912
|
if (updates.length) {
|
|
762
|
-
// новая версия из заблокированных значений — одним оператором; без изменений — без
|
|
763
|
-
|
|
913
|
+
// новая версия из заблокированных значений — одним оператором; без изменений — без версии; агрегатные поля —
|
|
914
|
+
// прежние (agg_keep): их считает база
|
|
915
|
+
const res = await conn.unsafe(`with u as (select x.id, ${S}.agg_keep(e.class, e.tenant, e.data, x.data) as data, x.links, x.tags
|
|
916
|
+
from jsonb_to_recordset($1::jsonb) as x(id uuid, data jsonb, links jsonb, tags text[]) join ${S}.entity e on e.id = x.id),
|
|
764
917
|
up as (
|
|
765
918
|
update ${S}.entity e set data = u.data, links = u.links, tags = coalesce(u.tags, e.tags), rev = e.rev + 1, prev_hash = e.hash,
|
|
766
919
|
at = greatest($2::timestamptz, e.at + interval '1 microsecond'), author = $3::uuid, agent = $4::text, op = 'update',
|
|
@@ -782,7 +935,7 @@ export async function load(env, input, opts = {}) {
|
|
|
782
935
|
}
|
|
783
936
|
}
|
|
784
937
|
/** Режим истории: версии волнами — после каждой волны база отдаёт хэши, они prev_hash следующей. */
|
|
785
|
-
async function historyWrite(conn, reg, portion, inLoad, external, xid, touched) {
|
|
938
|
+
async function historyWrite(conn, reg, portion, inLoad, external, xid, touched, written) {
|
|
786
939
|
const byId = new Map();
|
|
787
940
|
for (const i of portion) {
|
|
788
941
|
const id = p1[i].id;
|
|
@@ -861,24 +1014,158 @@ export async function load(env, input, opts = {}) {
|
|
|
861
1014
|
rows[l[0]].$at, SYSTEM_ID, agent, op, null, null, hex(cur.history === false ? null : lastPrev.get(id) ?? null), null].map(esc).join('\t') + '\n');
|
|
862
1015
|
report.loaded++;
|
|
863
1016
|
report.byClass[cls.name] = (report.byClass[cls.name] ?? 0) + 1;
|
|
1017
|
+
// verify: true проверяет и строки режима истории (в 1.0.1 список был пуст — проверка не шла)
|
|
1018
|
+
written.push(id);
|
|
864
1019
|
}
|
|
865
|
-
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin`, ent);
|
|
1020
|
+
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, ent);
|
|
866
1021
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1022
|
+
}
|
|
1023
|
+
/** Определения из каталога: ограничения (первичный ключ, уникальные), прочие индексы, триггеры с режимом, колонки. */
|
|
1024
|
+
async function catalogOf(conn, S) {
|
|
1025
|
+
const rels = [`${S}.entity`, `${S}.log`];
|
|
1026
|
+
const sel = `(select x::regclass from unnest($1::text[]) x)`;
|
|
1027
|
+
const constraints = await conn.unsafe(`select conname as name, conrelid::regclass::text as tbl, pg_get_constraintdef(oid) as def
|
|
1028
|
+
from pg_constraint where conrelid in ${sel} and contype in ('p', 'u') order by conrelid, conname`, [rels]);
|
|
1029
|
+
const indexes = await conn.unsafe(`select i.indexrelid::regclass::text as name, pg_get_indexdef(i.indexrelid) as def
|
|
1030
|
+
from pg_index i where i.indrelid in ${sel}
|
|
1031
|
+
and not exists (select 1 from pg_constraint c where c.conindid = i.indexrelid and c.conrelid = i.indrelid and c.contype in ('p', 'u', 'x'))
|
|
1032
|
+
order by i.indrelid, i.indexrelid`, [rels]);
|
|
1033
|
+
const triggers = await conn.unsafe(`select tgname as name, tgrelid::regclass::text as tbl, pg_get_triggerdef(oid) as def, tgenabled::text as enabled
|
|
1034
|
+
from pg_trigger where tgrelid in ${sel} and not tgisinternal order by tgrelid, tgname`, [rels]);
|
|
1035
|
+
// колонки без генерируемых: их значения считает база
|
|
1036
|
+
const cols = async (rel) => (await conn.unsafe(`select string_agg(quote_ident(attname), ', ' order by attnum) as c from pg_attribute
|
|
1037
|
+
where attrelid = $1::regclass and attnum > 0 and not attisdropped and attgenerated = ''`, [rel]))[0].c;
|
|
1038
|
+
return {
|
|
1039
|
+
constraints: constraints,
|
|
1040
|
+
indexes: indexes,
|
|
1041
|
+
triggers: triggers,
|
|
1042
|
+
entityCols: await cols(rels[0]),
|
|
1043
|
+
logCols: await cols(rels[1]),
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Первая заливка (план 1.1, §2.3): загрузки fn — одной транзакцией в пустую схему. Блокировка таблиц до
|
|
1048
|
+
* проверки; в схеме только строки системных классов и записи reset (иначе not_empty); они (с seq и tx
|
|
1049
|
+
* журнала) и определения ограничений, индексов и триггеров из каталога (с режимом включения tgenabled:
|
|
1050
|
+
* pg_get_triggerdef его не передаёт) сохраняются в памяти; TRUNCATE; триггеры, ограничения и индексы
|
|
1051
|
+
* снимаются — BEFORE ROW делает COPY построчным одним своим наличием, триггеры после оператора копят
|
|
1052
|
+
* переходные таблицы; системные строки возвращаются (seq журнала — generated always: overriding system
|
|
1053
|
+
* value); загрузки пишут COPY FREEZE. В конце — ограничения (дубликат найдёт сортировка), индексы,
|
|
1054
|
+
* триггеры с прежним режимом, отложенные проверки загрузок, сигнал, счётчик прав и ANALYZE. Любая ошибка
|
|
1055
|
+
* откатывает всё: DDL в PostgreSQL транзакционен.
|
|
1056
|
+
*/
|
|
1057
|
+
export async function initialSession(env, fn, tune = {}) {
|
|
1058
|
+
if (!env.admin)
|
|
1059
|
+
throw new LetopisError('admin_required', 'загрузчику нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
1060
|
+
const S = `"${env.schema.replace(/"/g, '""')}"`;
|
|
1061
|
+
const ident = (n) => `"${n.replace(/"/g, '""')}"`;
|
|
1062
|
+
const conn = await env.sql.reserve();
|
|
1063
|
+
const q = (text, params = []) => conn.unsafe(text, params);
|
|
1064
|
+
try {
|
|
1065
|
+
await q('begin isolation level read committed');
|
|
1066
|
+
await q(`set local role ${ident(env.ownerRole)}`);
|
|
1067
|
+
await q(`select set_config('TimeZone', 'UTC', true)`);
|
|
1068
|
+
// 1. блокировка — до проверки: строка, вставленная между проверкой и TRUNCATE, пропала бы молча
|
|
1069
|
+
await q(`lock table ${S}.entity, ${S}.log in access exclusive mode`);
|
|
1070
|
+
// 2. пустая схема: кроме строк системных классов и записей reset — ничего
|
|
1071
|
+
const [busy] = await q(`select exists (select 1 from ${S}.entity where class <> all ($1::text[]))
|
|
1072
|
+
or exists (select 1 from ${S}.log where class <> all ($2::text[])) as b`, [SYSTEM_CLASSES, [...SYSTEM_CLASSES, '#reset']]);
|
|
1073
|
+
if (busy.b) {
|
|
1074
|
+
throw new LetopisError('not_empty', 'первая заливка — только в пустую схему: в entity и log, кроме строк системных классов (Class, Account, member, Resource, rule) и записей reset, ничего быть не должно');
|
|
1075
|
+
}
|
|
1076
|
+
// 3. системные строки и определения из каталога — в память процесса (так режим не повторяет 10-core.sql)
|
|
1077
|
+
const [sys] = await q(`select coalesce((select jsonb_agg(to_jsonb(e)) from ${S}.entity e), '[]') as ent,
|
|
1078
|
+
coalesce((select jsonb_agg(to_jsonb(l)) from ${S}.log l), '[]') as log`);
|
|
1079
|
+
const ids = new Set([...sys.ent, ...sys.log].map((r) => r.id));
|
|
1080
|
+
const cat = await catalogOf(conn, S);
|
|
1081
|
+
const triggers = tune.keepTriggers ? [] : cat.triggers;
|
|
1082
|
+
const keys = tune.keepKeys ? [] : cat.constraints;
|
|
1083
|
+
const indexes = cat.indexes.filter((i) => !(tune.keepGin && /\busing gin\b/i.test(i.def)));
|
|
1084
|
+
// 4. TRUNCATE; триггеры, ограничения и индексы — снять
|
|
1085
|
+
await q(`truncate ${S}.entity, ${S}.log`);
|
|
1086
|
+
for (const t of triggers)
|
|
1087
|
+
await q(`drop trigger ${ident(t.name)} on ${t.tbl}`);
|
|
1088
|
+
for (const c of keys)
|
|
1089
|
+
await q(`alter table ${c.tbl} drop constraint ${ident(c.name)}`);
|
|
1090
|
+
for (const i of indexes)
|
|
1091
|
+
await q(`drop index ${i.name}`);
|
|
1092
|
+
// 5. системные строки — обратно, с прежними seq и tx
|
|
1093
|
+
await q(`insert into ${S}.entity (${cat.entityCols}) select ${cat.entityCols} from jsonb_populate_recordset(null::${S}.entity, $1::jsonb)`, [sys.ent]);
|
|
1094
|
+
await q(`insert into ${S}.log (${cat.logCols}) overriding system value select ${cat.logCols} from jsonb_populate_recordset(null::${S}.log, $1::jsonb)`, [sys.log]);
|
|
1095
|
+
const state = { conn: conn, ids, pending: [], touched: new Map(), freeze: tune.freeze !== false };
|
|
1096
|
+
const out = await fn({
|
|
1097
|
+
sql: conn,
|
|
1098
|
+
load: (input, opts = {}) => load({ ...env, session: state }, input, opts),
|
|
1099
|
+
taken: (id) => state.ids.has(id.toLowerCase()),
|
|
1100
|
+
});
|
|
1101
|
+
// 6. агрегаты в базе (план 1.1, §2.4): триггеров нет — поле целей считает agg_fill, одним оператором на агрегат, и
|
|
1102
|
+
// пишет его в ту же (последнюю) версию в entity и log: строки ещё никто не видел, хэши — генерируемые колонки
|
|
1103
|
+
await q(`select ${S}.agg_fill()`);
|
|
1104
|
+
// 7. ограничения, затем индексы; память и параллельные исполнители сборки — не меньше серверных
|
|
1105
|
+
await q(`select set_config('maintenance_work_mem', (greatest(pg_size_bytes(current_setting('maintenance_work_mem')), 268435456) / 1024)::bigint || 'kB', true),
|
|
1106
|
+
set_config('max_parallel_maintenance_workers', greatest(current_setting('max_parallel_maintenance_workers')::int,
|
|
1107
|
+
least(current_setting('max_parallel_workers')::int, 4))::text, true)`);
|
|
1108
|
+
for (const c of keys)
|
|
1109
|
+
await q(`alter table ${c.tbl} add constraint ${ident(c.name)} ${c.def}`);
|
|
1110
|
+
for (const i of indexes)
|
|
1111
|
+
await q(i.def);
|
|
1112
|
+
// 8. триггеры — с прежним режимом включения (ENABLE ALWAYS работает и при session_replication_role = replica)
|
|
1113
|
+
for (const t of triggers) {
|
|
1114
|
+
await q(t.def);
|
|
1115
|
+
const mode = t.enabled === 'A' ? 'enable always' : t.enabled === 'R' ? 'enable replica' : t.enabled === 'D' ? 'disable' : null;
|
|
1116
|
+
if (mode)
|
|
1117
|
+
await q(`alter table ${t.tbl} ${mode} trigger ${ident(t.name)}`);
|
|
1118
|
+
}
|
|
1119
|
+
// 10. отложенные проверки загрузок, сигнал, счётчик прав
|
|
1120
|
+
const issues = new Issues();
|
|
1121
|
+
for (const p of state.pending) {
|
|
1122
|
+
const ext = [...p.external.keys()].sort();
|
|
1123
|
+
if (ext.length) {
|
|
1124
|
+
const found = new Map((await q(`select e.id, e.tenant, e.class from ${S}.entity e where e.id = any ($1::uuid[])`, [ext]))
|
|
1125
|
+
.map((r) => [r.id, r]));
|
|
1126
|
+
for (const id of ext) {
|
|
1127
|
+
const ref = p.external.get(id);
|
|
1128
|
+
const f = found.get(id);
|
|
1129
|
+
if (!f || (f.tenant !== p.tenant && f.tenant !== SYSTEM_ID) || !accepts(p.reg, ref.allowed, f.class)) {
|
|
1130
|
+
issues.add({ line: ref.line, code: 'target_not_found', message: `цель конца ${ref.role} не найдена`, id });
|
|
1131
|
+
}
|
|
1132
|
+
else if (p.reg.find(f.class)?.derivedBy) {
|
|
1133
|
+
issues.add({ line: ref.line, code: 'derived_class', message: `цель конца ${ref.role} — строка производного класса ${f.class}`, id });
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
if (p.owners.size) {
|
|
1138
|
+
const own = [...p.owners.keys()].sort();
|
|
1139
|
+
const found = new Set((await q(`select e.id from ${S}.entity e where e.id = any ($1::uuid[]) and e.class = 'Account'`, [own])).map((r) => r.id));
|
|
1140
|
+
for (const id of own)
|
|
1141
|
+
if (!found.has(id))
|
|
1142
|
+
issues.add({ line: p.owners.get(id), code: 'target_not_found', message: `владелец ${id} не найден`, id });
|
|
1143
|
+
}
|
|
1144
|
+
const [acc] = await q(`select coalesce((data->>'enabled')::boolean, true) as enabled from ${S}.entity where id = $1::uuid and class = 'Account'`, [p.tenant]);
|
|
1145
|
+
if (!acc || !acc.enabled)
|
|
1146
|
+
issues.add({ line: 0, code: 'tenant_denied', message: `арендатор ${p.tenant} не найден или отключён` });
|
|
1147
|
+
if (p.verify?.length)
|
|
1148
|
+
await verifyIds(conn, S, p.verify, issues);
|
|
1149
|
+
}
|
|
881
1150
|
if (issues.any)
|
|
882
1151
|
issues.fail();
|
|
1152
|
+
const pairs = [...state.touched].flatMap(([t, cs]) => [...cs].map((c) => [c, t]));
|
|
1153
|
+
if (pairs.length)
|
|
1154
|
+
await q(`select ${S}.signal($1::text[], $2::uuid[])`, [pairs.map((x) => x[0]), pairs.map((x) => x[1])]);
|
|
1155
|
+
if (pairs.some(([c]) => ACL_CLASS_NAMES.includes(c)))
|
|
1156
|
+
await q(`select ${S}.acl_bump()`);
|
|
1157
|
+
// ANALYZE обязателен: в PostgreSQL до 18.6 параллельная сборка GIN могла записать неверный reltuples (вплоть
|
|
1158
|
+
// до бесконечности), и автоочистка переставала обрабатывать таблицу
|
|
1159
|
+
await q(`analyze ${S}.entity`);
|
|
1160
|
+
await q(`analyze ${S}.log`);
|
|
1161
|
+
await q('commit');
|
|
1162
|
+
return out;
|
|
1163
|
+
}
|
|
1164
|
+
catch (e) {
|
|
1165
|
+
await q('rollback').catch(() => { });
|
|
1166
|
+
throw e instanceof LetopisError ? e : fromDbError(e);
|
|
1167
|
+
}
|
|
1168
|
+
finally {
|
|
1169
|
+
conn.release();
|
|
883
1170
|
}
|
|
884
1171
|
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import type { Db } from './index.js';
|
|
3
|
+
export interface McpOptions {
|
|
4
|
+
/** Инструмент letopis_write (create, update, upsert, delete); по умолчанию сервер только читает. */
|
|
5
|
+
write?: boolean;
|
|
6
|
+
/** Строк в ответе не больше (по умолчанию 100); дальше — курсор. */
|
|
7
|
+
maxRows?: number;
|
|
8
|
+
/** Байт JSON строк в ответе не больше (по умолчанию 50 000); дальше — курсор. */
|
|
9
|
+
maxBytes?: number;
|
|
10
|
+
/**
|
|
11
|
+
* letopis_write пишет и классы прав — rule, Resource, member, Account, Class и их потомков (правила, членства,
|
|
12
|
+
* описания классов). По умолчанию — отказ acl_denied: агент под инъекцией в данных выдал бы постороннему членство или
|
|
13
|
+
* правило без подтверждения человека (ревью безопасности №2, О5).
|
|
14
|
+
*/
|
|
15
|
+
allowAclWrite?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Сессия в арендаторе System — так работает системный администратор: его роль admin даёт права на аккаунты,
|
|
18
|
+
* членства, правила и letopis.*. По умолчанию — отказ user_session_required (ревью безопасности №2, О5).
|
|
19
|
+
*/
|
|
20
|
+
allowSystemAdmin?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Сервер MCP для одного пользователя letopis: инструменты letopis_model, letopis_query, letopis_history и при
|
|
24
|
+
* { write: true } — letopis_write. Подключение — сессия пользователя (connect({ token })) или фасад db.as(пользователь).
|
|
25
|
+
* Транспорт выбирает приложение: server.connect(транспорт), serveStdio(() => createMcpServer(db)) или
|
|
26
|
+
* createMcpHandler(() => createMcpServer(db)) SDK — со своей авторизацией.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createMcpServer(db: Db, opts?: McpOptions): Promise<McpServer>;
|