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/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
- if (!w.write(lines[i++])) {
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
- const reg0 = await readRegistry(env.sql);
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
- const conn = await env.sql.reserve();
511
+ // первая заливка: транзакцию, роль и пояс поставила сессия, фиксирует и откатывает тоже она
512
+ const conn = ini ? ini.conn : await env.sql.reserve();
461
513
  const written = [];
462
514
  let failed = null;
463
515
  try {
464
- await conn.unsafe('begin isolation level read committed');
465
- await conn.unsafe(`set local role ${`"${env.ownerRole.replace(/"/g, '""')}"`}`);
466
- await conn.unsafe(`select set_config('TimeZone', 'UTC', true)`);
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 verifyRows(conn, reg, written);
586
- if (classesTouched.size)
587
- await conn.unsafe(`select pg_notify($1, c) from unnest($2::text[]) c`, [env.schema, [...classesTouched]]);
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) => ACL_CLASSES.includes(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
- await conn.unsafe('rollback').catch(() => { });
673
+ if (!ini)
674
+ await conn.unsafe('rollback').catch(() => { });
598
675
  }
599
676
  finally {
600
- conn.release();
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
- await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin`, ent);
760
- await copyLines(conn, `copy ${S}.log (${LOG_COLS}) from stdin`, log);
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
- /** verify: true — загруженные строки перепроверяет проверка базы (данные и цели концов). */
868
- async function verifyRows(conn, reg, ids) {
869
- 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
870
- from ${S}.entity e cross join lateral ${S}.class_row(e.class, e.tenant) c
871
- 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]);
872
- for (const r of bad)
873
- issues.add({ line: 0, code: 'invalid_data', message: `проверка базы: строка ${r.id} не проходит описание класса ${r.class}`, id: r.id, issues: r.issues });
874
- if (bad.length >= ISSUES_LIMIT)
875
- issues.unchecked.add(`проверка базы называет не больше ${ISSUES_LIMIT} строк`);
876
- const [t] = await conn.unsafe(`select ${S}.targets_bad((select jsonb_agg(jsonb_build_object('class', e.class, 'tenant', e.tenant, 'links', e.links))
877
- from ${S}.entity e where e.id = any ($1::uuid[]) and e.links <> '{}'::jsonb), false) as role`, [ids]);
878
- if (t?.role)
879
- issues.add({ line: 0, code: 'target_not_found', message: `проверка базы: цель конца ${t.role} не найдена` });
880
- void reg;
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
+ };