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.
Files changed (60) hide show
  1. package/AGENT-CHEATSHEET.en.md +117 -10
  2. package/AGENT-CHEATSHEET.md +110 -8
  3. package/CHANGELOG.md +739 -0
  4. package/README.en.md +873 -68
  5. package/README.md +830 -66
  6. package/dist/admin.d.ts +24 -4
  7. package/dist/admin.js +39 -9
  8. package/dist/cache.d.ts +33 -9
  9. package/dist/cache.js +81 -32
  10. package/dist/chain.d.ts +29 -7
  11. package/dist/chain.js +61 -12
  12. package/dist/cli.js +59 -7
  13. package/dist/errors.js +4 -2
  14. package/dist/import.d.ts +6 -0
  15. package/dist/import.js +196 -175
  16. package/dist/index.d.ts +27 -5
  17. package/dist/index.js +191 -29
  18. package/dist/internal.d.ts +16 -0
  19. package/dist/internal.js +14 -0
  20. package/dist/load.d.ts +63 -0
  21. package/dist/load.js +331 -44
  22. package/dist/mcp.d.ts +28 -0
  23. package/dist/mcp.js +810 -0
  24. package/dist/model.d.ts +34 -0
  25. package/dist/model.js +17 -0
  26. package/dist/project.d.ts +309 -0
  27. package/dist/project.js +1097 -0
  28. package/dist/registry.d.ts +17 -0
  29. package/dist/registry.js +13 -0
  30. package/dist/signals.d.ts +73 -0
  31. package/dist/signals.js +169 -0
  32. package/dist/sql.d.ts +1 -1
  33. package/dist/sql.js +26 -7
  34. package/dist/tx.d.ts +9 -7
  35. package/dist/tx.js +15 -5
  36. package/dist/typed.d.ts +21 -6
  37. package/dist/types.d.ts +77 -4
  38. package/dist/types.js +8 -3
  39. package/dist/up.d.ts +16 -2
  40. package/dist/up.js +34 -4
  41. package/dist/uuid.js +19 -8
  42. package/dist/watch.d.ts +23 -2
  43. package/dist/watch.js +44 -7
  44. package/dist/write.d.ts +11 -2
  45. package/dist/write.js +57 -10
  46. package/llms.txt +9 -2
  47. package/package.json +13 -2
  48. package/sql/10-core.sql +13 -2
  49. package/sql/15-errors.sql +4 -1
  50. package/sql/40-class.sql +57 -2
  51. package/sql/60-write.sql +611 -82
  52. package/sql/70-read.sql +33 -8
  53. package/sql/75-history.sql +358 -0
  54. package/sql/80-auth.sql +7 -7
  55. package/sql/88-derived.sql +1154 -0
  56. package/sql/90-time.sql +134 -21
  57. package/sql/95-seed.system.sql +24 -6
  58. package/sql/99-revision.sql +1 -1
  59. package/sql/README.md +5 -1
  60. 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
- return String(v).replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
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
- if (!w.write(lines[i++])) {
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
- const reg0 = await readRegistry(env.sql);
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
- const conn = await env.sql.reserve();
528
+ // первая заливка: транзакцию, роль и пояс поставила сессия, фиксирует и откатывает тоже она
529
+ const conn = ini ? ini.conn : await env.sql.reserve();
461
530
  const written = [];
462
531
  let failed = null;
463
532
  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)`);
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 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]]);
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) => ACL_CLASSES.includes(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
- await conn.unsafe('rollback').catch(() => { });
693
+ if (!ini)
694
+ await conn.unsafe('rollback').catch(() => { });
598
695
  }
599
696
  finally {
600
- conn.release();
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
- const common = [id, rev, cls.name, cur.rev, tenant, owner, JSON.stringify(sh.links), JSON.stringify(sh.data), pgArray(tags), atv];
723
- ent.push([...common, atv, author, agent, 'create', null, null, hex(prev?.hash), null].map(esc).join('\t') + '\n');
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([...common, author, agent, 'create', null, null, hex(prev?.hash), xid].map(esc).join('\t') + '\n');
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
- await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin`, ent);
760
- await copyLines(conn, `copy ${S}.log (${LOG_COLS}) from stdin`, log);
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
- 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[])),
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
- /** 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;
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>;