letopis 1.1.0 → 1.3.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
@@ -20,7 +20,7 @@
20
20
  import { randomUUID } from 'node:crypto';
21
21
  import { LetopisError, fromDbError, removed } from './errors.js';
22
22
  import { isOp } from './ops.js';
23
- import { Registry } from './registry.js';
23
+ import { Registry, aggregateFields } from './registry.js';
24
24
  import { accepts } from './sql.js';
25
25
  import { v5Id } from './uuid.js';
26
26
  import { applyDefaults } from './validate.js';
@@ -30,10 +30,14 @@ const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
30
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
31
  /** Классы, строки которых первая заливка сохраняет и возвращает: описания классов, аккаунты, членства, права. */
32
32
  const SYSTEM_CLASSES = ['Class', 'Account', 'member', 'Resource', 'rule'];
33
+ // значения без обратной косой, табуляции и переводов строки (почти все: uuid, числа, время) — как есть, без четырёх
34
+ // замен (профиль первой заливки: esc — 14 % процессора)
35
+ const ESCAPED = /[\\\t\n\r]/;
33
36
  const esc = (v) => {
34
37
  if (v === null || v === undefined)
35
38
  return '\\N';
36
- 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;
37
41
  };
38
42
  const hex = (b) => (b ? '\\x' + b.toString('hex') : null);
39
43
  const ENTITY_COLS = 'id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator';
@@ -151,6 +155,10 @@ function shape(reg, tenant, cls, raw, check) {
151
155
  data[k] = v;
152
156
  }
153
157
  }
158
+ // агрегатные поля считает база (план 1.1, §2.4): переданное значение не пишется — 0, итог посчитает триггер
159
+ // после оператора, а первая заливка — agg_fill
160
+ for (const f of aggregateFields(cls))
161
+ data[f] = 0;
154
162
  if (!check)
155
163
  return { data, links };
156
164
  const scan = (v, path) => {
@@ -322,8 +330,11 @@ async function verifyIds(conn, S, ids, to) {
322
330
  to.unchecked.add(`проверка базы называет не больше ${ISSUES_LIMIT} строк`);
323
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))
324
332
  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} не найдена` });
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} не найдена` });
327
338
  }
328
339
  /**
329
340
  * Загрузить строки (§2.6). Одна транзакция (или порции commitEvery): описания классов под
@@ -415,6 +426,12 @@ export async function load(env, input, opts = {}) {
415
426
  p1[i] = null;
416
427
  continue;
417
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
+ }
418
435
  const id = idOfRow(reg0, tenant, cls, raw);
419
436
  if (typeof id !== 'string') {
420
437
  issues.add({ line, code: 'invalid_data', message: id.error, class: cls.name });
@@ -623,6 +640,9 @@ export async function load(env, input, opts = {}) {
623
640
  if (!f || (f.tenant !== tenant && f.tenant !== SYSTEM_ID) || !accepts(reg, ref.allowed, f.class)) {
624
641
  issues.add({ line: ref.line, code: 'target_not_found', message: `цель конца ${ref.role} не найдена`, id });
625
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
+ }
626
646
  }
627
647
  }
628
648
  // владельцы ($owner) — аккаунты: из загрузки (живые) или из базы, с блокировкой до фиксации
@@ -841,10 +861,12 @@ export async function load(env, input, opts = {}) {
841
861
  const tags = sh.tags ?? [];
842
862
  if (acl)
843
863
  rowsAcl.push({ i, class: cls.name, id, owner, tags, data: sh.data, links: sh.links });
844
- const common = [id, rev, cls.name, cur.rev, tenant, owner, JSON.stringify(sh.links), JSON.stringify(sh.data), pgArray(tags), atv];
845
- 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`);
846
868
  if (cur.history !== false)
847
- 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`);
848
870
  report.loaded++;
849
871
  report.byClass[cls.name] = (report.byClass[cls.name] ?? 0) + 1;
850
872
  touched.add(cls.name);
@@ -883,12 +905,15 @@ export async function load(env, input, opts = {}) {
883
905
  if (issues.any)
884
906
  return;
885
907
  }
886
- // первая заливка: FREEZE — таблицы опустошены этой же транзакцией
887
- await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, ent);
908
+ // первая заливка: FREEZE — таблицы опустошены этой же транзакцией. Журнал — раньше строк: агрегатные поля новой
909
+ // цели триггер после COPY досчитывает в ту же версию — и в entity, и в уже записанную версию журнала (план 1.1, §2.4)
888
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);
889
912
  if (updates.length) {
890
- // новая версия из заблокированных значений — одним оператором; без изменений — без версии
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[])),
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),
892
917
  up as (
893
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,
894
919
  at = greatest($2::timestamptz, e.at + interval '1 microsecond'), author = $3::uuid, agent = $4::text, op = 'update',
@@ -1073,6 +1098,9 @@ export async function initialSession(env, fn, tune = {}) {
1073
1098
  load: (input, opts = {}) => load({ ...env, session: state }, input, opts),
1074
1099
  taken: (id) => state.ids.has(id.toLowerCase()),
1075
1100
  });
1101
+ // 6. агрегаты в базе (план 1.1, §2.4): триггеров нет — поле целей считает agg_fill, одним оператором на агрегат, и
1102
+ // пишет его в ту же (последнюю) версию в entity и log: строки ещё никто не видел, хэши — генерируемые колонки
1103
+ await q(`select ${S}.agg_fill()`);
1076
1104
  // 7. ограничения, затем индексы; память и параллельные исполнители сборки — не меньше серверных
1077
1105
  await q(`select set_config('maintenance_work_mem', (greatest(pg_size_bytes(current_setting('maintenance_work_mem')), 268435456) / 1024)::bigint || 'kB', true),
1078
1106
  set_config('max_parallel_maintenance_workers', greatest(current_setting('max_parallel_maintenance_workers')::int,
@@ -1101,6 +1129,9 @@ export async function initialSession(env, fn, tune = {}) {
1101
1129
  if (!f || (f.tenant !== p.tenant && f.tenant !== SYSTEM_ID) || !accepts(p.reg, ref.allowed, f.class)) {
1102
1130
  issues.add({ line: ref.line, code: 'target_not_found', message: `цель конца ${ref.role} не найдена`, id });
1103
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
+ }
1104
1135
  }
1105
1136
  }
1106
1137
  if (p.owners.size) {
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>;