letopis 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.en.md +110 -15
- package/AGENT-CHEATSHEET.md +103 -13
- package/CHANGELOG.md +577 -0
- package/README.en.md +769 -73
- package/README.md +732 -67
- package/dist/admin.d.ts +20 -4
- package/dist/admin.js +33 -9
- package/dist/cli.js +53 -4
- package/dist/errors.js +4 -2
- package/dist/index.d.ts +16 -2
- package/dist/index.js +75 -17
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.js +42 -11
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +810 -0
- package/dist/model.d.ts +34 -0
- package/dist/model.js +17 -0
- package/dist/project.d.ts +309 -0
- package/dist/project.js +1097 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/sql.js +9 -5
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +12 -0
- package/dist/watch.js +20 -1
- package/dist/write.d.ts +1 -1
- package/dist/write.js +25 -5
- package/llms.txt +6 -2
- package/package.json +12 -2
- package/sql/10-core.sql +5 -1
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +515 -48
- package/sql/70-read.sql +33 -8
- package/sql/80-auth.sql +4 -4
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +89 -12
- package/sql/95-seed.system.sql +12 -2
- package/sql/README.md +4 -1
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
845
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|