letopis 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.en.md +14 -2
- package/AGENT-CHEATSHEET.md +14 -2
- package/CHANGELOG.md +186 -0
- package/README.en.md +134 -24
- package/README.md +124 -25
- package/dist/admin.d.ts +4 -0
- package/dist/admin.js +6 -0
- package/dist/cache.d.ts +33 -9
- package/dist/cache.js +81 -32
- package/dist/chain.d.ts +29 -7
- package/dist/chain.js +61 -12
- package/dist/cli.js +24 -7
- package/dist/import.d.ts +6 -0
- package/dist/import.js +196 -175
- package/dist/index.d.ts +11 -3
- package/dist/index.js +122 -18
- package/dist/load.d.ts +63 -0
- package/dist/load.js +293 -37
- package/dist/signals.d.ts +73 -0
- package/dist/signals.js +169 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +17 -2
- package/dist/tx.d.ts +9 -7
- package/dist/tx.js +15 -5
- package/dist/typed.d.ts +21 -6
- package/dist/types.d.ts +76 -3
- package/dist/types.js +7 -2
- package/dist/up.d.ts +16 -2
- package/dist/up.js +34 -4
- package/dist/watch.d.ts +11 -2
- package/dist/watch.js +24 -6
- package/dist/write.d.ts +10 -1
- package/dist/write.js +32 -5
- package/llms.txt +4 -1
- package/package.json +2 -1
- package/sql/10-core.sql +8 -1
- package/sql/60-write.sql +100 -35
- package/sql/75-history.sql +358 -0
- package/sql/80-auth.sql +3 -3
- package/sql/90-time.sql +45 -9
- package/sql/95-seed.system.sql +12 -4
- package/sql/99-revision.sql +1 -1
- package/sql/README.md +1 -0
- package/sql/migrate/0006-changed-paths.sql +3 -0
package/dist/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: '…' }, у
|
|
@@ -22,10 +25,11 @@ 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'];
|
|
29
33
|
const esc = (v) => {
|
|
30
34
|
if (v === null || v === undefined)
|
|
31
35
|
return '\\N';
|
|
@@ -260,10 +264,16 @@ function classGroups(reg, names) {
|
|
|
260
264
|
visit(n);
|
|
261
265
|
return groups;
|
|
262
266
|
}
|
|
267
|
+
/** Строки COPY уходят кусками примерно такой длины: postgres.js шлёт каждую запись в поток отдельным CopyData. */
|
|
268
|
+
const COPY_CHUNK = 262144;
|
|
269
|
+
/** Уступить циклу событий: запись в сокет и колбэки потока COPY идут в нём же. */
|
|
270
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
263
271
|
/**
|
|
264
272
|
* COPY … from stdin. postgres.js 3.4 теряет ошибку сервера, пришедшую после конца потока: запрос
|
|
265
273
|
* уже разрешён потоком, а колбэк завершения не вызывается — промис повис бы. Поэтому reject
|
|
266
|
-
* запроса перехватывается до начала потока и отклоняет ожидание.
|
|
274
|
+
* запроса перехватывается до начала потока и отклоняет ожидание. Строки склеиваются в куски до
|
|
275
|
+
* COPY_CHUNK символов: запись в сокет на каждую строку стоила дороже подготовки самой строки
|
|
276
|
+
* (замер первой заливки, план 1.1, этап 3), а границы сообщений протокол COPY не связывает со строками.
|
|
267
277
|
*/
|
|
268
278
|
async function copyLines(conn, text, lines) {
|
|
269
279
|
if (!lines.length)
|
|
@@ -288,7 +298,10 @@ async function copyLines(conn, text, lines) {
|
|
|
288
298
|
let i = 0;
|
|
289
299
|
const pump = () => {
|
|
290
300
|
while (i < lines.length) {
|
|
291
|
-
|
|
301
|
+
let chunk = lines[i++];
|
|
302
|
+
while (i < lines.length && chunk.length < COPY_CHUNK)
|
|
303
|
+
chunk += lines[i++];
|
|
304
|
+
if (!w.write(chunk)) {
|
|
292
305
|
w.once('drain', pump);
|
|
293
306
|
return;
|
|
294
307
|
}
|
|
@@ -298,6 +311,20 @@ async function copyLines(conn, text, lines) {
|
|
|
298
311
|
pump();
|
|
299
312
|
});
|
|
300
313
|
}
|
|
314
|
+
/** verify: true — загруженные строки перепроверяет проверка базы (данные и цели концов); нарушения — в to. */
|
|
315
|
+
async function verifyIds(conn, S, ids, to) {
|
|
316
|
+
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
|
|
317
|
+
from ${S}.entity e cross join lateral ${S}.class_row(e.class, e.tenant) c
|
|
318
|
+
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]);
|
|
319
|
+
for (const r of bad)
|
|
320
|
+
to.add({ line: 0, code: 'invalid_data', message: `проверка базы: строка ${r.id} не проходит описание класса ${r.class}`, id: r.id, issues: r.issues });
|
|
321
|
+
if (bad.length >= ISSUES_LIMIT)
|
|
322
|
+
to.unchecked.add(`проверка базы называет не больше ${ISSUES_LIMIT} строк`);
|
|
323
|
+
const [t] = await conn.unsafe(`select ${S}.targets_bad((select jsonb_agg(jsonb_build_object('class', e.class, 'tenant', e.tenant, 'links', e.links))
|
|
324
|
+
from ${S}.entity e where e.id = any ($1::uuid[]) and e.links <> '{}'::jsonb), false) as role`, [ids]);
|
|
325
|
+
if (t?.role)
|
|
326
|
+
to.add({ line: 0, code: 'target_not_found', message: `проверка базы: цель конца ${t.role} не найдена` });
|
|
327
|
+
}
|
|
301
328
|
/**
|
|
302
329
|
* Загрузить строки (§2.6). Одна транзакция (или порции commitEvery): описания классов под
|
|
303
330
|
* разделяемыми блокировками семейств, проверки в коде, COPY пачками, запрос по целям вне загрузки,
|
|
@@ -316,6 +343,16 @@ export async function load(env, input, opts = {}) {
|
|
|
316
343
|
throw new LetopisError('invalid_data', 'load({ as }) и режим истории несовместимы: историю импортирует администратор');
|
|
317
344
|
if (opts.withHistory && opts.existing === 'update')
|
|
318
345
|
throw new LetopisError('invalid_data', "режим истории: existing — 'error' или 'skip' (новую версию существующего объекта пишет загрузка без истории)");
|
|
346
|
+
const ini = env.session ?? null;
|
|
347
|
+
if (opts.initial || ini) {
|
|
348
|
+
// снятые индексы и TRUNCATE — одна транзакция; в пустой схеме пропускать и обновлять нечего
|
|
349
|
+
if (opts.commitEvery)
|
|
350
|
+
throw new LetopisError('invalid_data', 'первая заливка (initial) несовместима с commitEvery: снятые индексы и TRUNCATE требуют одной транзакции');
|
|
351
|
+
if (opts.existing !== undefined && opts.existing !== 'error')
|
|
352
|
+
throw new LetopisError('invalid_data', "первая заливка (initial): existing — только 'error', в пустой схеме пропускать и обновлять нечего");
|
|
353
|
+
if (asAcc !== undefined)
|
|
354
|
+
throw new LetopisError('invalid_data', 'первая заливка (initial) — от администратора, без { as }');
|
|
355
|
+
}
|
|
319
356
|
const tenant = opts.tenant ?? asAcc ?? env.tenant;
|
|
320
357
|
if (!tenant)
|
|
321
358
|
throw new LetopisError('invalid_data', 'load(): нет арендатора — сессия без арендатора и не задан { tenant }');
|
|
@@ -330,6 +367,13 @@ export async function load(env, input, opts = {}) {
|
|
|
330
367
|
const report = { rows: rows.length, loaded: 0, updated: 0, skipped: 0, duplicates: 0, byClass: {}, external: 0, portions: 0, ms: 0 };
|
|
331
368
|
if (!rows.length)
|
|
332
369
|
return report;
|
|
370
|
+
// первая заливка одной загрузкой — своя сессия; вход уже прочитан (поток второй раз не читается)
|
|
371
|
+
if (opts.initial && !ini) {
|
|
372
|
+
const r = await initialSession(env, (sess) => sess.load(rows, opts));
|
|
373
|
+
// время — вместе с перестройкой, отложенными проверками и ANALYZE сессии
|
|
374
|
+
r.ms = performance.now() - t0;
|
|
375
|
+
return r;
|
|
376
|
+
}
|
|
333
377
|
const readRegistry = async (q) => {
|
|
334
378
|
const reg = new Registry();
|
|
335
379
|
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 +389,8 @@ export async function load(env, input, opts = {}) {
|
|
|
345
389
|
const targets = new Map();
|
|
346
390
|
/** Владельцы ($owner) порции, кроме арендатора: id → первая строка; сверяются запросом в конце. */
|
|
347
391
|
const owners = new Map();
|
|
348
|
-
|
|
392
|
+
// в первой заливке таблицы заблокированы сессией: читать — на её соединении
|
|
393
|
+
const reg0 = await readRegistry(ini?.conn ?? env.sql);
|
|
349
394
|
const p1 = new Array(rows.length);
|
|
350
395
|
const lineOf = new Map();
|
|
351
396
|
const chosen = new Set();
|
|
@@ -376,6 +421,12 @@ export async function load(env, input, opts = {}) {
|
|
|
376
421
|
p1[i] = null;
|
|
377
422
|
continue;
|
|
378
423
|
}
|
|
424
|
+
// первая заливка: первичного ключа нет — повтор id со строками системных классов и прошлых загрузок сессии ловит проход 1
|
|
425
|
+
if (ini?.ids.has(id)) {
|
|
426
|
+
issues.add({ line, code: 'exists', message: 'объект уже есть в схеме: строка системного класса или запись этой же первой заливки', id, class: cls.name });
|
|
427
|
+
p1[i] = null;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
379
430
|
p1[i] = { cls, id };
|
|
380
431
|
if (opts.withHistory) {
|
|
381
432
|
const list = versions.get(id) ?? [];
|
|
@@ -457,13 +508,16 @@ export async function load(env, input, opts = {}) {
|
|
|
457
508
|
owners.clear();
|
|
458
509
|
issues.unchecked.clear();
|
|
459
510
|
laterUnchecked(portion, issues);
|
|
460
|
-
|
|
511
|
+
// первая заливка: транзакцию, роль и пояс поставила сессия, фиксирует и откатывает тоже она
|
|
512
|
+
const conn = ini ? ini.conn : await env.sql.reserve();
|
|
461
513
|
const written = [];
|
|
462
514
|
let failed = null;
|
|
463
515
|
try {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
516
|
+
if (!ini) {
|
|
517
|
+
await conn.unsafe('begin isolation level read committed');
|
|
518
|
+
await conn.unsafe(`set local role ${`"${env.ownerRole.replace(/"/g, '""')}"`}`);
|
|
519
|
+
await conn.unsafe(`select set_config('TimeZone', 'UTC', true)`);
|
|
520
|
+
}
|
|
467
521
|
// описания классов под разделяемыми блокировками семейств, затем — свежие
|
|
468
522
|
const roots = [...new Set(portion.map((i) => p1[i].cls.ancestors[p1[i].cls.ancestors.length - 1]))].sort();
|
|
469
523
|
// ключ блокировки — арендатор корневого класса и имя корня (корень из System — ключ System)
|
|
@@ -531,7 +585,10 @@ export async function load(env, input, opts = {}) {
|
|
|
531
585
|
todo = portion.filter((i) => !taken.has(p1[i].id));
|
|
532
586
|
}
|
|
533
587
|
if (opts.withHistory) {
|
|
534
|
-
await historyWrite(conn, reg, todo, inLoad, external, xid, classesTouched);
|
|
588
|
+
await historyWrite(conn, reg, todo, inLoad, external, xid, classesTouched, written);
|
|
589
|
+
}
|
|
590
|
+
else if (ini) {
|
|
591
|
+
await pipeline(conn, reg, portion, inLoad, external, ts, xid, classesTouched, written, live, past);
|
|
535
592
|
}
|
|
536
593
|
else {
|
|
537
594
|
for (let b = 0; b < portion.length; b += batch) {
|
|
@@ -541,6 +598,19 @@ export async function load(env, input, opts = {}) {
|
|
|
541
598
|
}
|
|
542
599
|
if (issues.any)
|
|
543
600
|
issues.fail();
|
|
601
|
+
if (ini) {
|
|
602
|
+
// цели, владельцы, арендатор и проверка базы — в конце сессии, по перестроенным индексам
|
|
603
|
+
report.external += external.size;
|
|
604
|
+
ini.pending.push({ reg, tenant: tenant, external, owners: new Map(owners), verify: opts.verify ? written : null });
|
|
605
|
+
for (const id of inLoad.keys())
|
|
606
|
+
ini.ids.add(id);
|
|
607
|
+
let set = ini.touched.get(tenant);
|
|
608
|
+
if (!set)
|
|
609
|
+
ini.touched.set(tenant, (set = new Set()));
|
|
610
|
+
for (const c of classesTouched)
|
|
611
|
+
set.add(c);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
544
614
|
// цели вне загрузки — один запрос в конце с блокировкой до фиксации (§2.6, п. 7)
|
|
545
615
|
const ext = [...external.keys()].sort();
|
|
546
616
|
report.external += ext.length;
|
|
@@ -581,12 +651,18 @@ export async function load(env, input, opts = {}) {
|
|
|
581
651
|
issues.add({ line: portion.length ? portion[0] + 1 : 0, code: 'tenant_denied', message: `арендатор ${tenant} не найден или отключён` });
|
|
582
652
|
if (issues.any)
|
|
583
653
|
issues.fail();
|
|
584
|
-
if (opts.verify && written.length)
|
|
585
|
-
await
|
|
586
|
-
|
|
587
|
-
|
|
654
|
+
if (opts.verify && written.length) {
|
|
655
|
+
await verifyIds(conn, S, written, issues);
|
|
656
|
+
if (issues.any)
|
|
657
|
+
issues.fail();
|
|
658
|
+
}
|
|
659
|
+
// сигнал — функцией базы, как у триггеров: нагрузка по режиму сигналов установки (план 1.1)
|
|
660
|
+
if (classesTouched.size) {
|
|
661
|
+
const touched = [...classesTouched];
|
|
662
|
+
await conn.unsafe(`select ${S}.signal($1::text[], $2::uuid[])`, [touched, touched.map(() => tenant)]);
|
|
663
|
+
}
|
|
588
664
|
// строки прав меняют планы: пропуска прав клиентов — заново
|
|
589
|
-
if ([...classesTouched].some((c) =>
|
|
665
|
+
if ([...classesTouched].some((c) => ACL_CLASS_NAMES.includes(c)))
|
|
590
666
|
await conn.unsafe(`select ${S}.acl_bump()`);
|
|
591
667
|
if (portion.length >= 10000)
|
|
592
668
|
await conn.unsafe(`analyze ${S}.entity; analyze ${S}.log`);
|
|
@@ -594,14 +670,16 @@ export async function load(env, input, opts = {}) {
|
|
|
594
670
|
}
|
|
595
671
|
catch (e) {
|
|
596
672
|
failed = e;
|
|
597
|
-
|
|
673
|
+
if (!ini)
|
|
674
|
+
await conn.unsafe('rollback').catch(() => { });
|
|
598
675
|
}
|
|
599
676
|
finally {
|
|
600
|
-
|
|
677
|
+
if (!ini)
|
|
678
|
+
conn.release();
|
|
601
679
|
}
|
|
602
680
|
if (failed) {
|
|
603
681
|
const err = failed;
|
|
604
|
-
if (err.code === '23505' && (err.constraint_name === 'entity_pkey' || err.constraint_name === 'log_id_rev')) {
|
|
682
|
+
if (!ini && err.code === '23505' && (err.constraint_name === 'entity_pkey' || err.constraint_name === 'log_id_rev')) {
|
|
605
683
|
await explainTaken(portion);
|
|
606
684
|
}
|
|
607
685
|
throw fromDbError(failed);
|
|
@@ -691,12 +769,56 @@ export async function load(env, input, opts = {}) {
|
|
|
691
769
|
}
|
|
692
770
|
return sh;
|
|
693
771
|
}
|
|
772
|
+
/** Пачка — подготовка и запись по очереди. */
|
|
694
773
|
async function writeBatch(conn, reg, part, inLoad, external, ts, xid, touched, written, live, past) {
|
|
774
|
+
const p = await prepareBatch(reg, part, inLoad, external, ts, xid, touched, written, live, past, 0);
|
|
775
|
+
if (p)
|
|
776
|
+
await flushBatch(conn, p, ts, touched, written);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Первая заливка — конвейером (решение точки А плана 1.1): следующая пачка готовится, пока база пишет текущую.
|
|
780
|
+
* Подготовка уступает циклу событий каждые 256 строк — иначе COPY в полёте не получал бы следующих кусков, пока
|
|
781
|
+
* идёт синхронная работа. Отказ записи до её await ловит пустой обработчик (иначе — необработанный отказ
|
|
782
|
+
* процесса); при ошибке подготовки запись в полёте доходит до конца, и откат идёт по готовому соединению.
|
|
783
|
+
*/
|
|
784
|
+
async function pipeline(conn, reg, portion, inLoad, external, ts, xid, touched, written, live, past) {
|
|
785
|
+
let flushing = null;
|
|
786
|
+
try {
|
|
787
|
+
for (let b = 0; b < portion.length; b += batch) {
|
|
788
|
+
const p = await prepareBatch(reg, portion.slice(b, b + batch), inLoad, external, ts, xid, touched, written, live, past, 256);
|
|
789
|
+
const prev = flushing;
|
|
790
|
+
flushing = null;
|
|
791
|
+
if (prev)
|
|
792
|
+
await prev;
|
|
793
|
+
if (!p || issues.any)
|
|
794
|
+
return;
|
|
795
|
+
flushing = flushBatch(conn, p, ts, touched, written);
|
|
796
|
+
flushing.catch(() => { });
|
|
797
|
+
}
|
|
798
|
+
const last = flushing;
|
|
799
|
+
flushing = null;
|
|
800
|
+
if (last)
|
|
801
|
+
await last;
|
|
802
|
+
}
|
|
803
|
+
catch (e) {
|
|
804
|
+
if (flushing)
|
|
805
|
+
await flushing.catch(() => { });
|
|
806
|
+
throw e;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Подготовка пачки без запросов к базе: проверки, id, концы, строки COPY. yieldEvery — уступать циклу событий
|
|
811
|
+
* каждые столько строк (конвейер первой заливки). Нарушения — null.
|
|
812
|
+
*/
|
|
813
|
+
async function prepareBatch(reg, part, inLoad, external, ts, xid, touched, written, live, past, yieldEvery) {
|
|
695
814
|
const ent = [];
|
|
696
815
|
const log = [];
|
|
697
816
|
const updates = [];
|
|
698
817
|
const rowsAcl = [];
|
|
818
|
+
let n = 0;
|
|
699
819
|
for (const i of part) {
|
|
820
|
+
if (yieldEvery && ++n % yieldEvery === 0)
|
|
821
|
+
await tick();
|
|
700
822
|
const { id, cls } = p1[i];
|
|
701
823
|
if (existing === 'skip' && (live.has(id) || past.has(id))) {
|
|
702
824
|
report.skipped++;
|
|
@@ -731,8 +853,13 @@ export async function load(env, input, opts = {}) {
|
|
|
731
853
|
if (issues.any) {
|
|
732
854
|
if (aclRan)
|
|
733
855
|
issues.unchecked.add('условия ACL следующих пачек не проверялись');
|
|
734
|
-
return;
|
|
856
|
+
return null;
|
|
735
857
|
}
|
|
858
|
+
return { ent, log, updates, rowsAcl };
|
|
859
|
+
}
|
|
860
|
+
/** Запись подготовленной пачки: условия ACL ({ as }) — запросом на пачку, COPY в entity и log, правки существующих. */
|
|
861
|
+
async function flushBatch(conn, p, ts, touched, written) {
|
|
862
|
+
const { ent, log, updates, rowsAcl } = p;
|
|
736
863
|
if (acl) {
|
|
737
864
|
aclRan = true;
|
|
738
865
|
// условия ACL на строки — одним запросом на пачку: файл пользователя — не доверенные данные
|
|
@@ -756,8 +883,9 @@ export async function load(env, input, opts = {}) {
|
|
|
756
883
|
if (issues.any)
|
|
757
884
|
return;
|
|
758
885
|
}
|
|
759
|
-
|
|
760
|
-
await copyLines(conn, `copy ${S}.
|
|
886
|
+
// первая заливка: FREEZE — таблицы опустошены этой же транзакцией
|
|
887
|
+
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, ent);
|
|
888
|
+
await copyLines(conn, `copy ${S}.log (${LOG_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, log);
|
|
761
889
|
if (updates.length) {
|
|
762
890
|
// новая версия из заблокированных значений — одним оператором; без изменений — без версии
|
|
763
891
|
const res = await conn.unsafe(`with u as (select * from jsonb_to_recordset($1::jsonb) as x(id uuid, data jsonb, links jsonb, tags text[])),
|
|
@@ -782,7 +910,7 @@ export async function load(env, input, opts = {}) {
|
|
|
782
910
|
}
|
|
783
911
|
}
|
|
784
912
|
/** Режим истории: версии волнами — после каждой волны база отдаёт хэши, они prev_hash следующей. */
|
|
785
|
-
async function historyWrite(conn, reg, portion, inLoad, external, xid, touched) {
|
|
913
|
+
async function historyWrite(conn, reg, portion, inLoad, external, xid, touched, written) {
|
|
786
914
|
const byId = new Map();
|
|
787
915
|
for (const i of portion) {
|
|
788
916
|
const id = p1[i].id;
|
|
@@ -861,24 +989,152 @@ export async function load(env, input, opts = {}) {
|
|
|
861
989
|
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
990
|
report.loaded++;
|
|
863
991
|
report.byClass[cls.name] = (report.byClass[cls.name] ?? 0) + 1;
|
|
992
|
+
// verify: true проверяет и строки режима истории (в 1.0.1 список был пуст — проверка не шла)
|
|
993
|
+
written.push(id);
|
|
864
994
|
}
|
|
865
|
-
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin`, ent);
|
|
995
|
+
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, ent);
|
|
866
996
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
997
|
+
}
|
|
998
|
+
/** Определения из каталога: ограничения (первичный ключ, уникальные), прочие индексы, триггеры с режимом, колонки. */
|
|
999
|
+
async function catalogOf(conn, S) {
|
|
1000
|
+
const rels = [`${S}.entity`, `${S}.log`];
|
|
1001
|
+
const sel = `(select x::regclass from unnest($1::text[]) x)`;
|
|
1002
|
+
const constraints = await conn.unsafe(`select conname as name, conrelid::regclass::text as tbl, pg_get_constraintdef(oid) as def
|
|
1003
|
+
from pg_constraint where conrelid in ${sel} and contype in ('p', 'u') order by conrelid, conname`, [rels]);
|
|
1004
|
+
const indexes = await conn.unsafe(`select i.indexrelid::regclass::text as name, pg_get_indexdef(i.indexrelid) as def
|
|
1005
|
+
from pg_index i where i.indrelid in ${sel}
|
|
1006
|
+
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'))
|
|
1007
|
+
order by i.indrelid, i.indexrelid`, [rels]);
|
|
1008
|
+
const triggers = await conn.unsafe(`select tgname as name, tgrelid::regclass::text as tbl, pg_get_triggerdef(oid) as def, tgenabled::text as enabled
|
|
1009
|
+
from pg_trigger where tgrelid in ${sel} and not tgisinternal order by tgrelid, tgname`, [rels]);
|
|
1010
|
+
// колонки без генерируемых: их значения считает база
|
|
1011
|
+
const cols = async (rel) => (await conn.unsafe(`select string_agg(quote_ident(attname), ', ' order by attnum) as c from pg_attribute
|
|
1012
|
+
where attrelid = $1::regclass and attnum > 0 and not attisdropped and attgenerated = ''`, [rel]))[0].c;
|
|
1013
|
+
return {
|
|
1014
|
+
constraints: constraints,
|
|
1015
|
+
indexes: indexes,
|
|
1016
|
+
triggers: triggers,
|
|
1017
|
+
entityCols: await cols(rels[0]),
|
|
1018
|
+
logCols: await cols(rels[1]),
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Первая заливка (план 1.1, §2.3): загрузки fn — одной транзакцией в пустую схему. Блокировка таблиц до
|
|
1023
|
+
* проверки; в схеме только строки системных классов и записи reset (иначе not_empty); они (с seq и tx
|
|
1024
|
+
* журнала) и определения ограничений, индексов и триггеров из каталога (с режимом включения tgenabled:
|
|
1025
|
+
* pg_get_triggerdef его не передаёт) сохраняются в памяти; TRUNCATE; триггеры, ограничения и индексы
|
|
1026
|
+
* снимаются — BEFORE ROW делает COPY построчным одним своим наличием, триггеры после оператора копят
|
|
1027
|
+
* переходные таблицы; системные строки возвращаются (seq журнала — generated always: overriding system
|
|
1028
|
+
* value); загрузки пишут COPY FREEZE. В конце — ограничения (дубликат найдёт сортировка), индексы,
|
|
1029
|
+
* триггеры с прежним режимом, отложенные проверки загрузок, сигнал, счётчик прав и ANALYZE. Любая ошибка
|
|
1030
|
+
* откатывает всё: DDL в PostgreSQL транзакционен.
|
|
1031
|
+
*/
|
|
1032
|
+
export async function initialSession(env, fn, tune = {}) {
|
|
1033
|
+
if (!env.admin)
|
|
1034
|
+
throw new LetopisError('admin_required', 'загрузчику нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
1035
|
+
const S = `"${env.schema.replace(/"/g, '""')}"`;
|
|
1036
|
+
const ident = (n) => `"${n.replace(/"/g, '""')}"`;
|
|
1037
|
+
const conn = await env.sql.reserve();
|
|
1038
|
+
const q = (text, params = []) => conn.unsafe(text, params);
|
|
1039
|
+
try {
|
|
1040
|
+
await q('begin isolation level read committed');
|
|
1041
|
+
await q(`set local role ${ident(env.ownerRole)}`);
|
|
1042
|
+
await q(`select set_config('TimeZone', 'UTC', true)`);
|
|
1043
|
+
// 1. блокировка — до проверки: строка, вставленная между проверкой и TRUNCATE, пропала бы молча
|
|
1044
|
+
await q(`lock table ${S}.entity, ${S}.log in access exclusive mode`);
|
|
1045
|
+
// 2. пустая схема: кроме строк системных классов и записей reset — ничего
|
|
1046
|
+
const [busy] = await q(`select exists (select 1 from ${S}.entity where class <> all ($1::text[]))
|
|
1047
|
+
or exists (select 1 from ${S}.log where class <> all ($2::text[])) as b`, [SYSTEM_CLASSES, [...SYSTEM_CLASSES, '#reset']]);
|
|
1048
|
+
if (busy.b) {
|
|
1049
|
+
throw new LetopisError('not_empty', 'первая заливка — только в пустую схему: в entity и log, кроме строк системных классов (Class, Account, member, Resource, rule) и записей reset, ничего быть не должно');
|
|
1050
|
+
}
|
|
1051
|
+
// 3. системные строки и определения из каталога — в память процесса (так режим не повторяет 10-core.sql)
|
|
1052
|
+
const [sys] = await q(`select coalesce((select jsonb_agg(to_jsonb(e)) from ${S}.entity e), '[]') as ent,
|
|
1053
|
+
coalesce((select jsonb_agg(to_jsonb(l)) from ${S}.log l), '[]') as log`);
|
|
1054
|
+
const ids = new Set([...sys.ent, ...sys.log].map((r) => r.id));
|
|
1055
|
+
const cat = await catalogOf(conn, S);
|
|
1056
|
+
const triggers = tune.keepTriggers ? [] : cat.triggers;
|
|
1057
|
+
const keys = tune.keepKeys ? [] : cat.constraints;
|
|
1058
|
+
const indexes = cat.indexes.filter((i) => !(tune.keepGin && /\busing gin\b/i.test(i.def)));
|
|
1059
|
+
// 4. TRUNCATE; триггеры, ограничения и индексы — снять
|
|
1060
|
+
await q(`truncate ${S}.entity, ${S}.log`);
|
|
1061
|
+
for (const t of triggers)
|
|
1062
|
+
await q(`drop trigger ${ident(t.name)} on ${t.tbl}`);
|
|
1063
|
+
for (const c of keys)
|
|
1064
|
+
await q(`alter table ${c.tbl} drop constraint ${ident(c.name)}`);
|
|
1065
|
+
for (const i of indexes)
|
|
1066
|
+
await q(`drop index ${i.name}`);
|
|
1067
|
+
// 5. системные строки — обратно, с прежними seq и tx
|
|
1068
|
+
await q(`insert into ${S}.entity (${cat.entityCols}) select ${cat.entityCols} from jsonb_populate_recordset(null::${S}.entity, $1::jsonb)`, [sys.ent]);
|
|
1069
|
+
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]);
|
|
1070
|
+
const state = { conn: conn, ids, pending: [], touched: new Map(), freeze: tune.freeze !== false };
|
|
1071
|
+
const out = await fn({
|
|
1072
|
+
sql: conn,
|
|
1073
|
+
load: (input, opts = {}) => load({ ...env, session: state }, input, opts),
|
|
1074
|
+
taken: (id) => state.ids.has(id.toLowerCase()),
|
|
1075
|
+
});
|
|
1076
|
+
// 7. ограничения, затем индексы; память и параллельные исполнители сборки — не меньше серверных
|
|
1077
|
+
await q(`select set_config('maintenance_work_mem', (greatest(pg_size_bytes(current_setting('maintenance_work_mem')), 268435456) / 1024)::bigint || 'kB', true),
|
|
1078
|
+
set_config('max_parallel_maintenance_workers', greatest(current_setting('max_parallel_maintenance_workers')::int,
|
|
1079
|
+
least(current_setting('max_parallel_workers')::int, 4))::text, true)`);
|
|
1080
|
+
for (const c of keys)
|
|
1081
|
+
await q(`alter table ${c.tbl} add constraint ${ident(c.name)} ${c.def}`);
|
|
1082
|
+
for (const i of indexes)
|
|
1083
|
+
await q(i.def);
|
|
1084
|
+
// 8. триггеры — с прежним режимом включения (ENABLE ALWAYS работает и при session_replication_role = replica)
|
|
1085
|
+
for (const t of triggers) {
|
|
1086
|
+
await q(t.def);
|
|
1087
|
+
const mode = t.enabled === 'A' ? 'enable always' : t.enabled === 'R' ? 'enable replica' : t.enabled === 'D' ? 'disable' : null;
|
|
1088
|
+
if (mode)
|
|
1089
|
+
await q(`alter table ${t.tbl} ${mode} trigger ${ident(t.name)}`);
|
|
1090
|
+
}
|
|
1091
|
+
// 10. отложенные проверки загрузок, сигнал, счётчик прав
|
|
1092
|
+
const issues = new Issues();
|
|
1093
|
+
for (const p of state.pending) {
|
|
1094
|
+
const ext = [...p.external.keys()].sort();
|
|
1095
|
+
if (ext.length) {
|
|
1096
|
+
const found = new Map((await q(`select e.id, e.tenant, e.class from ${S}.entity e where e.id = any ($1::uuid[])`, [ext]))
|
|
1097
|
+
.map((r) => [r.id, r]));
|
|
1098
|
+
for (const id of ext) {
|
|
1099
|
+
const ref = p.external.get(id);
|
|
1100
|
+
const f = found.get(id);
|
|
1101
|
+
if (!f || (f.tenant !== p.tenant && f.tenant !== SYSTEM_ID) || !accepts(p.reg, ref.allowed, f.class)) {
|
|
1102
|
+
issues.add({ line: ref.line, code: 'target_not_found', message: `цель конца ${ref.role} не найдена`, id });
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
if (p.owners.size) {
|
|
1107
|
+
const own = [...p.owners.keys()].sort();
|
|
1108
|
+
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));
|
|
1109
|
+
for (const id of own)
|
|
1110
|
+
if (!found.has(id))
|
|
1111
|
+
issues.add({ line: p.owners.get(id), code: 'target_not_found', message: `владелец ${id} не найден`, id });
|
|
1112
|
+
}
|
|
1113
|
+
const [acc] = await q(`select coalesce((data->>'enabled')::boolean, true) as enabled from ${S}.entity where id = $1::uuid and class = 'Account'`, [p.tenant]);
|
|
1114
|
+
if (!acc || !acc.enabled)
|
|
1115
|
+
issues.add({ line: 0, code: 'tenant_denied', message: `арендатор ${p.tenant} не найден или отключён` });
|
|
1116
|
+
if (p.verify?.length)
|
|
1117
|
+
await verifyIds(conn, S, p.verify, issues);
|
|
1118
|
+
}
|
|
881
1119
|
if (issues.any)
|
|
882
1120
|
issues.fail();
|
|
1121
|
+
const pairs = [...state.touched].flatMap(([t, cs]) => [...cs].map((c) => [c, t]));
|
|
1122
|
+
if (pairs.length)
|
|
1123
|
+
await q(`select ${S}.signal($1::text[], $2::uuid[])`, [pairs.map((x) => x[0]), pairs.map((x) => x[1])]);
|
|
1124
|
+
if (pairs.some(([c]) => ACL_CLASS_NAMES.includes(c)))
|
|
1125
|
+
await q(`select ${S}.acl_bump()`);
|
|
1126
|
+
// ANALYZE обязателен: в PostgreSQL до 18.6 параллельная сборка GIN могла записать неверный reltuples (вплоть
|
|
1127
|
+
// до бесконечности), и автоочистка переставала обрабатывать таблицу
|
|
1128
|
+
await q(`analyze ${S}.entity`);
|
|
1129
|
+
await q(`analyze ${S}.log`);
|
|
1130
|
+
await q('commit');
|
|
1131
|
+
return out;
|
|
1132
|
+
}
|
|
1133
|
+
catch (e) {
|
|
1134
|
+
await q('rollback').catch(() => { });
|
|
1135
|
+
throw e instanceof LetopisError ? e : fromDbError(e);
|
|
1136
|
+
}
|
|
1137
|
+
finally {
|
|
1138
|
+
conn.release();
|
|
883
1139
|
}
|
|
884
1140
|
}
|
|
@@ -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
|
+
};
|