letopis 1.4.0 → 1.4.2
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 +15 -7
- package/AGENT-CHEATSHEET.md +19 -11
- package/CHANGELOG.md +299 -1
- package/README.en.md +180 -62
- package/README.md +165 -55
- package/dist/acl.d.ts +11 -5
- package/dist/admin.d.ts +30 -14
- package/dist/admin.js +53 -18
- package/dist/auth.d.ts +61 -17
- package/dist/auth.js +16 -2
- package/dist/cache.d.ts +5 -1
- package/dist/cache.js +3 -2
- package/dist/chain.d.ts +92 -15
- package/dist/chain.js +56 -7
- package/dist/cli.js +16 -2
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -5
- package/dist/import.js +7 -0
- package/dist/index.d.ts +73 -45
- package/dist/index.js +69 -14
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +5 -0
- package/dist/load.d.ts +10 -3
- package/dist/load.js +39 -11
- package/dist/mcp.d.ts +20 -11
- package/dist/mcp.js +8 -6
- package/dist/model.d.ts +4 -1
- package/dist/model.js +7 -1
- package/dist/ops.d.ts +58 -18
- package/dist/ops.js +58 -18
- package/dist/project.d.ts +39 -11
- package/dist/project.js +75 -15
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -1
- package/dist/signals.d.ts +4 -4
- package/dist/signals.js +4 -4
- package/dist/sql.d.ts +8 -4
- package/dist/sql.js +110 -25
- package/dist/sync.js +11 -4
- package/dist/tx.d.ts +15 -0
- package/dist/tx.js +22 -4
- package/dist/types.d.ts +21 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +48 -25
- package/dist/up.js +94 -41
- package/dist/validate.d.ts +9 -2
- package/dist/validate.js +19 -3
- package/dist/watch.d.ts +29 -5
- package/dist/watch.js +62 -13
- package/dist/write.d.ts +3 -2
- package/dist/write.js +52 -19
- package/package.json +8 -4
- package/sql/10-core.sql +6 -1
- package/sql/60-write.sql +47 -18
- package/sql/70-read.sql +13 -7
- package/sql/75-history.sql +106 -28
- package/sql/80-auth.sql +41 -3
- package/sql/88-derived.sql +3 -1
- package/sql/90-time.sql +118 -30
- package/sql/95-seed.system.sql +6 -3
- package/sql/99-revision.sql +1 -1
- package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/load.js
CHANGED
|
@@ -26,6 +26,7 @@ import { v5Id } from './uuid.js';
|
|
|
26
26
|
import { applyDefaults } from './validate.js';
|
|
27
27
|
import { isInc } from './write.js';
|
|
28
28
|
import { ACL_CLASS_NAMES } from './types.js';
|
|
29
|
+
import { isConnectionError, release, rollbackAfter } from './tx.js';
|
|
29
30
|
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
30
31
|
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
|
/** Классы, строки которых первая заливка сохраняет и возвращает: описания классов, аккаунты, членства, права. */
|
|
@@ -568,14 +569,15 @@ export async function load(env, input, opts = {}) {
|
|
|
568
569
|
const classesTouched = new Set();
|
|
569
570
|
// существующие объекты — только в режимах skip и update, одним запросом на пачку и до проверок:
|
|
570
571
|
// пропущенный объект для ссылок на него — вне загрузки (его сверит запрос в конце)
|
|
571
|
-
|
|
572
|
+
// живые строки пачки: id → класс (existing: 'update' правит только объект того же класса)
|
|
573
|
+
const live = new Map();
|
|
572
574
|
const past = new Map();
|
|
573
575
|
if (existing !== 'error' && !opts.withHistory) {
|
|
574
576
|
for (let b = 0; b < portion.length; b += batch) {
|
|
575
577
|
const ids = portion.slice(b, b + batch).map((i) => p1[i].id);
|
|
576
578
|
const lock = existing === 'update' ? ' order by e.id for no key update' : '';
|
|
577
|
-
for (const r of await conn.unsafe(`select e.id from ${S}.entity e where e.id = any ($1::uuid[])${lock}`, [ids]))
|
|
578
|
-
live.
|
|
579
|
+
for (const r of await conn.unsafe(`select e.id, e.class from ${S}.entity e where e.id = any ($1::uuid[])${lock}`, [ids]))
|
|
580
|
+
live.set(r.id, r.class);
|
|
579
581
|
for (const r of await conn.unsafe(`select distinct on (l.id) l.id, l.rev, l.hash, l.at::text as at from ${S}.log l
|
|
580
582
|
where l.id = any ($1::uuid[]) order by l.id, l.rev desc`, [ids]))
|
|
581
583
|
past.set(r.id, r);
|
|
@@ -690,12 +692,13 @@ export async function load(env, input, opts = {}) {
|
|
|
690
692
|
}
|
|
691
693
|
catch (e) {
|
|
692
694
|
failed = e;
|
|
695
|
+
// обрыв: в мёртвое соединение не пишут и в пул его не возвращают (план 2.0, 7.4 №7)
|
|
693
696
|
if (!ini)
|
|
694
|
-
await conn
|
|
697
|
+
await rollbackAfter(conn, e);
|
|
695
698
|
}
|
|
696
699
|
finally {
|
|
697
700
|
if (!ini)
|
|
698
|
-
|
|
701
|
+
release(conn, failed);
|
|
699
702
|
}
|
|
700
703
|
if (failed) {
|
|
701
704
|
const err = failed;
|
|
@@ -848,6 +851,13 @@ export async function load(env, input, opts = {}) {
|
|
|
848
851
|
if (!sh || issues.any)
|
|
849
852
|
continue;
|
|
850
853
|
if (live.has(id)) {
|
|
854
|
+
// строка с тем же id, но другого класса (класс без ключа после reclass, повторный импорт по $ext): правка
|
|
855
|
+
// прошла бы мимо триггеров с данными, проверенными по чужой схеме, — exists (план 2.0, 7.2 №4)
|
|
856
|
+
const was = live.get(id);
|
|
857
|
+
if (was !== cls.name) {
|
|
858
|
+
issues.add({ line: i + 1, code: 'exists', message: `объект с этим id уже есть в классе ${was}: строка класса ${cls.name} его не правит`, id, class: cls.name });
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
851
861
|
updates.push({ id, data: sh.data, links: sh.links, tags: sh.tags ?? null, line: i + 1 });
|
|
852
862
|
continue;
|
|
853
863
|
}
|
|
@@ -924,7 +934,7 @@ export async function load(env, input, opts = {}) {
|
|
|
924
934
|
lg as (
|
|
925
935
|
insert into ${S}.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
926
936
|
select id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash from up
|
|
927
|
-
where ${S}.history_on(up.class, up.tenant))
|
|
937
|
+
where ${S}.history_on(up.class, up.tenant) order by up.id)
|
|
928
938
|
select up.id, up.class from up`, [updates.map((u) => ({ id: u.id, data: u.data, links: u.links, tags: u.tags })), ts, author, agent]);
|
|
929
939
|
report.updated += res.length;
|
|
930
940
|
report.skipped += updates.length - res.length;
|
|
@@ -1020,12 +1030,17 @@ export async function load(env, input, opts = {}) {
|
|
|
1020
1030
|
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, ent);
|
|
1021
1031
|
}
|
|
1022
1032
|
}
|
|
1023
|
-
/**
|
|
1033
|
+
/**
|
|
1034
|
+
* Определения из каталога: ограничения (первичный ключ, уникальные), внешние ключи других таблиц на entity и log
|
|
1035
|
+
* (secret.account, ревизия 7), прочие индексы, триггеры с режимом, колонки.
|
|
1036
|
+
*/
|
|
1024
1037
|
async function catalogOf(conn, S) {
|
|
1025
1038
|
const rels = [`${S}.entity`, `${S}.log`];
|
|
1026
1039
|
const sel = `(select x::regclass from unnest($1::text[]) x)`;
|
|
1027
1040
|
const constraints = await conn.unsafe(`select conname as name, conrelid::regclass::text as tbl, pg_get_constraintdef(oid) as def
|
|
1028
1041
|
from pg_constraint where conrelid in ${sel} and contype in ('p', 'u') order by conrelid, conname`, [rels]);
|
|
1042
|
+
const foreign = await conn.unsafe(`select conname as name, conrelid::regclass::text as tbl, pg_get_constraintdef(oid) as def
|
|
1043
|
+
from pg_constraint where confrelid in ${sel} and contype = 'f' order by conrelid, conname`, [rels]);
|
|
1029
1044
|
const indexes = await conn.unsafe(`select i.indexrelid::regclass::text as name, pg_get_indexdef(i.indexrelid) as def
|
|
1030
1045
|
from pg_index i where i.indrelid in ${sel}
|
|
1031
1046
|
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'))
|
|
@@ -1037,6 +1052,7 @@ async function catalogOf(conn, S) {
|
|
|
1037
1052
|
where attrelid = $1::regclass and attnum > 0 and not attisdropped and attgenerated = ''`, [rel]))[0].c;
|
|
1038
1053
|
return {
|
|
1039
1054
|
constraints: constraints,
|
|
1055
|
+
foreign: foreign,
|
|
1040
1056
|
indexes: indexes,
|
|
1041
1057
|
triggers: triggers,
|
|
1042
1058
|
entityCols: await cols(rels[0]),
|
|
@@ -1047,7 +1063,8 @@ async function catalogOf(conn, S) {
|
|
|
1047
1063
|
* Первая заливка (план 1.1, §2.3): загрузки fn — одной транзакцией в пустую схему. Блокировка таблиц до
|
|
1048
1064
|
* проверки; в схеме только строки системных классов и записи reset (иначе not_empty); они (с seq и tx
|
|
1049
1065
|
* журнала) и определения ограничений, индексов и триггеров из каталога (с режимом включения tgenabled:
|
|
1050
|
-
* pg_get_triggerdef его не передаёт) сохраняются в памяти;
|
|
1066
|
+
* pg_get_triggerdef его не передаёт) сохраняются в памяти; внешний ключ secret.account снимается до TRUNCATE и
|
|
1067
|
+
* возвращается после первичного ключа entity; TRUNCATE; триггеры, ограничения и индексы
|
|
1051
1068
|
* снимаются — BEFORE ROW делает COPY построчным одним своим наличием, триггеры после оператора копят
|
|
1052
1069
|
* переходные таблицы; системные строки возвращаются (seq журнала — generated always: overriding system
|
|
1053
1070
|
* value); загрузки пишут COPY FREEZE. В конце — ограничения (дубликат найдёт сортировка), индексы,
|
|
@@ -1061,6 +1078,7 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1061
1078
|
const ident = (n) => `"${n.replace(/"/g, '""')}"`;
|
|
1062
1079
|
const conn = await env.sql.reserve();
|
|
1063
1080
|
const q = (text, params = []) => conn.unsafe(text, params);
|
|
1081
|
+
let initialFailure = null;
|
|
1064
1082
|
try {
|
|
1065
1083
|
await q('begin isolation level read committed');
|
|
1066
1084
|
await q(`set local role ${ident(env.ownerRole)}`);
|
|
@@ -1081,7 +1099,11 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1081
1099
|
const triggers = tune.keepTriggers ? [] : cat.triggers;
|
|
1082
1100
|
const keys = tune.keepKeys ? [] : cat.constraints;
|
|
1083
1101
|
const indexes = cat.indexes.filter((i) => !(tune.keepGin && /\busing gin\b/i.test(i.def)));
|
|
1084
|
-
// 4. TRUNCATE; триггеры, ограничения и индексы —
|
|
1102
|
+
// 4. TRUNCATE; триггеры, ограничения и индексы — снять. Внешний ключ secret.account на entity (ревизия 7) — до
|
|
1103
|
+
// TRUNCATE при любом keepKeys: таблицу, на которую ссылается внешний ключ, TRUNCATE без неё не очищает, а secret
|
|
1104
|
+
// (ключ установки, сессии, креды) заливка не трогает
|
|
1105
|
+
for (const f of cat.foreign)
|
|
1106
|
+
await q(`alter table ${f.tbl} drop constraint ${ident(f.name)}`);
|
|
1085
1107
|
await q(`truncate ${S}.entity, ${S}.log`);
|
|
1086
1108
|
for (const t of triggers)
|
|
1087
1109
|
await q(`drop trigger ${ident(t.name)} on ${t.tbl}`);
|
|
@@ -1107,6 +1129,10 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1107
1129
|
least(current_setting('max_parallel_workers')::int, 4))::text, true)`);
|
|
1108
1130
|
for (const c of keys)
|
|
1109
1131
|
await q(`alter table ${c.tbl} add constraint ${ident(c.name)} ${c.def}`);
|
|
1132
|
+
// внешние ключи на entity — после её первичного ключа: системные строки вернулись с прежними id, строки secret
|
|
1133
|
+
// ссылаются на них
|
|
1134
|
+
for (const f of cat.foreign)
|
|
1135
|
+
await q(`alter table ${f.tbl} add constraint ${ident(f.name)} ${f.def}`);
|
|
1110
1136
|
for (const i of indexes)
|
|
1111
1137
|
await q(i.def);
|
|
1112
1138
|
// 8. триггеры — с прежним режимом включения (ENABLE ALWAYS работает и при session_replication_role = replica)
|
|
@@ -1162,10 +1188,12 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1162
1188
|
return out;
|
|
1163
1189
|
}
|
|
1164
1190
|
catch (e) {
|
|
1165
|
-
|
|
1191
|
+
initialFailure = e;
|
|
1192
|
+
if (!isConnectionError(e))
|
|
1193
|
+
await q('rollback').catch(() => { });
|
|
1166
1194
|
throw e instanceof LetopisError ? e : fromDbError(e);
|
|
1167
1195
|
}
|
|
1168
1196
|
finally {
|
|
1169
|
-
|
|
1197
|
+
release(conn, initialFailure);
|
|
1170
1198
|
}
|
|
1171
1199
|
}
|
package/dist/mcp.d.ts
CHANGED
|
@@ -3,26 +3,35 @@ import type { Db } from './index.js';
|
|
|
3
3
|
export interface McpOptions {
|
|
4
4
|
/** Инструмент letopis_write (create, update, upsert, delete); по умолчанию сервер только читает. */
|
|
5
5
|
write?: boolean;
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Предел строк в одном ответе (по умолчанию 100). Остальные строки `letopis_query` отдаёт на следующих страницах по
|
|
8
|
+
* курсору, а версии, группы `countBy` и изменения `diff` и `blame` сверх предела обрезаются с пометкой `more` или
|
|
9
|
+
* `truncated`.
|
|
10
|
+
*/
|
|
7
11
|
maxRows?: number;
|
|
8
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Предел размера строк одного ответа в байтах JSON (по умолчанию 50 000); что не поместилось — так же, как сверх
|
|
14
|
+
* `maxRows`. Описание модели больше предела `letopis_model` отдаёт кратким.
|
|
15
|
+
*/
|
|
9
16
|
maxBytes?: number;
|
|
10
17
|
/**
|
|
11
|
-
* letopis_write
|
|
12
|
-
* описания классов). По умолчанию — отказ acl_denied
|
|
13
|
-
*
|
|
18
|
+
* Разрешить `letopis_write` писать и классы прав — `rule`, `Resource`, `member`, `Account`, `Class` и их потомков
|
|
19
|
+
* (правила, ресурсы, членства, аккаунты, описания классов). По умолчанию — отказ `acl_denied`: агент, которому в данных
|
|
20
|
+
* подсунули чужие инструкции, мог бы без подтверждения человека выдать постороннему членство или право
|
|
21
|
+
* (ревью безопасности №2, О5).
|
|
14
22
|
*/
|
|
15
23
|
allowAclWrite?: boolean;
|
|
16
24
|
/**
|
|
17
|
-
*
|
|
18
|
-
* членства, правила и letopis
|
|
25
|
+
* Принять сессию в арендаторе System, то есть сессию системного администратора: с `true` агент получает его права на
|
|
26
|
+
* аккаунты, членства, правила и `letopis.*`, но писать классы прав по-прежнему может только с `allowAclWrite`. По
|
|
27
|
+
* умолчанию такая сессия — отказ `user_session_required` (ревью безопасности №2, О5).
|
|
19
28
|
*/
|
|
20
29
|
allowSystemAdmin?: boolean;
|
|
21
30
|
}
|
|
22
31
|
/**
|
|
23
|
-
* Сервер MCP для одного пользователя letopis: инструменты letopis_model
|
|
24
|
-
* { write: true } — letopis_write
|
|
25
|
-
* Транспорт выбирает приложение: server.connect(транспорт)
|
|
26
|
-
* createMcpHandler(() => createMcpServer(db)) SDK
|
|
32
|
+
* Сервер MCP для одного пользователя letopis: инструменты `letopis_model`, `letopis_query`, `letopis_history`, а с
|
|
33
|
+
* `{ write: true }` — ещё `letopis_write`. Подключение — сессия пользователя (`connect({ token })`) или фасад
|
|
34
|
+
* `db.as(пользователь)`. Транспорт выбирает приложение: `server.connect(транспорт)`, `serveStdio(() => createMcpServer(db))`
|
|
35
|
+
* или обработчик HTTP `createMcpHandler(() => createMcpServer(db))` из SDK; токен запроса HTTP проверяет приложение.
|
|
27
36
|
*/
|
|
28
37
|
export declare function createMcpServer(db: Db, opts?: McpOptions): Promise<McpServer>;
|
package/dist/mcp.js
CHANGED
|
@@ -78,7 +78,7 @@ const describeQuery = (o) => [
|
|
|
78
78
|
'Чтение JSON-цепочкой, без SQL. Старт: class (класс, алиас или роль) и filter, id или ids.',
|
|
79
79
|
'steps — переходы по связям: [{ step: "Роль или Класс", filter? | id? | ids? }].',
|
|
80
80
|
'Фильтр: { поле: значение } — равенство; вложенные поля — вложенным объектом; конец — { Роль: id };',
|
|
81
|
-
'операторы — { поле: { $gt: 5 } }: $ne, $gt, $gte, $lt, $lte, $between [a, b], $in [..], $like, $ilike, $starts, $ends, $has, $hasAny [..], $hasAll [..], $exists true|false, $isNull true, $not; ИЛИ — { $or: [фильтр, …] }.',
|
|
81
|
+
'операторы — { поле: { $gt: 5 } }: $ne, $gt, $gte, $lt, $lte, $between [a, b], $in [..], $like, $ilike, $starts, $ends (% и _ в значении $starts и $ends — шаблон LIKE, не экранируются), $has, $hasAny [..], $hasAll [..], $exists true|false, $isNull true, $not; ИЛИ — { $or: [фильтр, …] }.',
|
|
82
82
|
'Модификаторы: asOf (момент ISO — срез на дату), withDeleted, sort { field: "at" | "rev" | "id" | "data.<путь>", dir }, limit, cursor (из прошлого ответа).',
|
|
83
83
|
'terminal: rows (по умолчанию), first, ids, count, sum, avg, min, max, countBy — с field "data.<путь>".',
|
|
84
84
|
`Ответ — не больше ${o.maxRows} строк и ${o.maxBytes} байт; дальше — cursor. Без sort строки идут по id. Только чтение.`,
|
|
@@ -522,7 +522,9 @@ async function query(db, a, o) {
|
|
|
522
522
|
const got = await chain.limit(want + 1).rows();
|
|
523
523
|
const page = got.slice(0, fit(got.slice(0, want), o));
|
|
524
524
|
const more = got.length > page.length;
|
|
525
|
-
const
|
|
525
|
+
const last = more && page.length ? cursorOf(page[page.length - 1], field) : undefined;
|
|
526
|
+
// курсор MCP — { f, d, v, id }: списки rows и ids листаются без rev (он разводит только версии)
|
|
527
|
+
const next = last ? encodeCursor({ f: field, d: dir, v: last.v, id: last.id }) : undefined;
|
|
526
528
|
const body = terminal === 'ids' ? { ids: page.map((r) => r.id) } : { rows: page };
|
|
527
529
|
return ok({ ...body, count: page.length, more, ...(next ? { cursor: next } : {}) });
|
|
528
530
|
}
|
|
@@ -782,10 +784,10 @@ async function checkCredentials(db, o) {
|
|
|
782
784
|
return { account: c.account, tenant: String(c.tenant ?? '') };
|
|
783
785
|
}
|
|
784
786
|
/**
|
|
785
|
-
* Сервер MCP для одного пользователя letopis: инструменты letopis_model
|
|
786
|
-
* { write: true } — letopis_write
|
|
787
|
-
* Транспорт выбирает приложение: server.connect(транспорт)
|
|
788
|
-
* createMcpHandler(() => createMcpServer(db)) SDK
|
|
787
|
+
* Сервер MCP для одного пользователя letopis: инструменты `letopis_model`, `letopis_query`, `letopis_history`, а с
|
|
788
|
+
* `{ write: true }` — ещё `letopis_write`. Подключение — сессия пользователя (`connect({ token })`) или фасад
|
|
789
|
+
* `db.as(пользователь)`. Транспорт выбирает приложение: `server.connect(транспорт)`, `serveStdio(() => createMcpServer(db))`
|
|
790
|
+
* или обработчик HTTP `createMcpHandler(() => createMcpServer(db))` из SDK; токен запроса HTTP проверяет приложение.
|
|
789
791
|
*/
|
|
790
792
|
export async function createMcpServer(db, opts = {}) {
|
|
791
793
|
const o = limitsOf(opts);
|
package/dist/model.d.ts
CHANGED
|
@@ -191,7 +191,10 @@ type ShapeOf<F> = F extends z.ZodObject<infer S> ? S : F extends z.ZodRawShape ?
|
|
|
191
191
|
type AliasOf<D> = D extends {
|
|
192
192
|
alias: infer A extends string;
|
|
193
193
|
} ? A : string;
|
|
194
|
-
/**
|
|
194
|
+
/**
|
|
195
|
+
* Описывает класс объектов (хаб): поля, ключ, концы и свойства класса. Класс-связь объектов со своими данными
|
|
196
|
+
* описывает `link()`.
|
|
197
|
+
*/
|
|
195
198
|
export declare function hub<N extends string, S extends z.ZodRawShape>(name: N, shape: S, opts: Omit<ClassDef, 'fields'>): Model<N, z.output<z.ZodObject<S>>, Record<never, never>>;
|
|
196
199
|
export declare function hub<N extends string, const D extends ClassDef<z.ZodRawShape, EndsInput, AnyModel | undefined>>(name: N, def?: D): Model<N, DataOf<D['extends'], ShapeOf<D['fields']>>, AllLinks<D['extends'], D['ends'] extends EndsInput ? D['ends'] : Record<never, never>>, AliasOf<D>>;
|
|
197
200
|
/** Связь — отношение между объектами со своими данными (бронь, навык). */
|
package/dist/model.js
CHANGED
|
@@ -64,6 +64,8 @@ export function many(target, opts = {}) {
|
|
|
64
64
|
return new End(targets, { many: true, required: (opts.min ?? 0) > 0, min: opts.min, max: opts.max, onDelete: opts.onDelete });
|
|
65
65
|
}
|
|
66
66
|
const NAME = /^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{0,62}$/;
|
|
67
|
+
/** Ключи полной формы описания класса (ClassDef). */
|
|
68
|
+
const DEF_KEYS = new Set(['alias', 'extends', 'abstract', 'ends', 'fields', 'key', 'onDelete', 'history', 'ownerDefault', 'cache', 'meta', 'derivedBy', 'aggregate']);
|
|
67
69
|
function isZod(v) {
|
|
68
70
|
return typeof v === 'object' && v !== null && '_zod' in v;
|
|
69
71
|
}
|
|
@@ -94,7 +96,11 @@ function makeModel(kind, name, defArg, optsArg) {
|
|
|
94
96
|
throw new LetopisError('invalid_class', `имя класса ${name} недопустимо`);
|
|
95
97
|
// короткая форма: hub(name, { поле: z…, … }, { key, meta, … })
|
|
96
98
|
let def;
|
|
97
|
-
|
|
99
|
+
// { fields: z.object(…) } без других ключей описания — полная форма, а не класс с единственным полем fields (план 2.0,
|
|
100
|
+
// 7.5 №2); короткая форма — все значения Zod и хотя бы один ключ вне описания класса
|
|
101
|
+
const fullForm = !!defArg && typeof defArg === 'object' && defArg.fields instanceof z.ZodObject
|
|
102
|
+
&& Object.keys(defArg).every((k) => DEF_KEYS.has(k));
|
|
103
|
+
const isShape = !fullForm && defArg && typeof defArg === 'object' && Object.values(defArg).length > 0 && Object.values(defArg).every(isZod);
|
|
98
104
|
if (optsArg !== undefined || isShape) {
|
|
99
105
|
def = { ...optsArg, fields: (defArg ?? {}) };
|
|
100
106
|
}
|
package/dist/ops.d.ts
CHANGED
|
@@ -5,39 +5,79 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { type Op, type Scalar } from './types.js';
|
|
7
7
|
export declare const isOp: (v: unknown) => v is Op;
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Не равно: `db.Order({ status: ne('cancelled') })`. Подходят и строки, где поля нет или в нём `null`, а `ne(null)` —
|
|
10
|
+
* строки, где у поля есть значение.
|
|
11
|
+
*/
|
|
9
12
|
export declare const ne: (v: Scalar) => Op;
|
|
10
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Больше: `db.Product({ price: gt(1000) })`. Сравнение — по типу поля из описания класса (числа — как числа, даты — как
|
|
15
|
+
* даты), поле без описания сравнивается как текст; строки без поля не подходят.
|
|
16
|
+
*/
|
|
11
17
|
export declare const gt: (v: Scalar) => Op;
|
|
12
|
-
/**
|
|
18
|
+
/** Больше или равно: `db.Product({ price: gte(500) })`. Сравнение — как у `gt()`. */
|
|
13
19
|
export declare const gte: (v: Scalar) => Op;
|
|
14
|
-
/**
|
|
20
|
+
/** Меньше: `db.Product({ price: lt(1000) })`. Сравнение — как у `gt()`. */
|
|
15
21
|
export declare const lt: (v: Scalar) => Op;
|
|
16
|
-
/**
|
|
22
|
+
/** Меньше или равно: `db.Product({ price: lte(900) })`. Сравнение — как у `gt()`. */
|
|
17
23
|
export declare const lte: (v: Scalar) => Op;
|
|
18
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* От `a` до `b` включительно: `db.Product({ price: between(500, 3000) })`. Сравнение — как у `gt()`; если `a` больше
|
|
26
|
+
* `b`, не подходит ни одна строка.
|
|
27
|
+
*/
|
|
19
28
|
export declare const between: (a: Scalar, b: Scalar) => Op;
|
|
20
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Одно из значений списка: `db.Product({ sku: inList(['K-1', 'M-1']) })`. Сравнение — по типу поля, как у `gt()`;
|
|
31
|
+
* пустой список не находит ничего, а `null` в списке не находит строк без значения — для них есть `isNull()`.
|
|
32
|
+
*/
|
|
21
33
|
export declare const inList: (vs: Scalar[]) => Op;
|
|
22
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один символ;
|
|
36
|
+
* `db.Product({ title: like('%чай%') })`. Поле берётся как текст; буквальные `%` и `_` пишут с обратной косой чертой:
|
|
37
|
+
* `like('100\\%')`.
|
|
38
|
+
*/
|
|
23
39
|
export declare const like: (s: string) => Op;
|
|
24
|
-
/**
|
|
40
|
+
/** Как `like()`, но без учёта регистра: `db.Product({ title: ilike('%чай%') })`. */
|
|
25
41
|
export declare const ilike: (s: string) => Op;
|
|
26
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Текст начинается с `s`, с учётом регистра: `db.Product({ sku: starts('T-') })`. Это `like(s + '%')`: `%`, `_` и
|
|
44
|
+
* обратная косая черта внутри `s` не экранируются и работают как в шаблоне `like()`.
|
|
45
|
+
*/
|
|
27
46
|
export declare const starts: (s: string) => Op;
|
|
28
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Текст заканчивается на `s`, с учётом регистра: `db.Product({ sku: ends('-1') })`. Это `like('%' + s)`: `%`, `_` и
|
|
49
|
+
* обратная косая черта внутри `s` не экранируются и работают как в шаблоне `like()`.
|
|
50
|
+
*/
|
|
29
51
|
export declare const ends: (s: string) => Op;
|
|
30
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Поле-массив содержит элемент `v`: `db.Product({ labels: has('хит') })`. Тип важен: `has(5)` найдёт число 5, но не
|
|
54
|
+
* строку `'5'`.
|
|
55
|
+
*/
|
|
31
56
|
export declare const has: (v: Scalar) => Op;
|
|
32
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Поле-массив содержит хотя бы один элемент из списка: `db.Product({ labels: hasAny(['хит', 'акция']) })`. Находит
|
|
59
|
+
* только строковые элементы; пустой список не находит ничего.
|
|
60
|
+
*/
|
|
33
61
|
export declare const hasAny: (vs: Scalar[]) => Op;
|
|
34
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Поле-массив содержит все элементы списка, в любом порядке: `db.Product({ labels: hasAll(['хит', 'стекло']) })`. Тип
|
|
64
|
+
* элементов важен, как у `has()`; пустой список подходит любой строке, даже без этого поля.
|
|
65
|
+
*/
|
|
35
66
|
export declare const hasAll: (vs: Scalar[]) => Op;
|
|
36
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Поле есть в данных (`true`, по умолчанию) или его нет (`false`); значение не важно — поле с `null` тоже есть:
|
|
69
|
+
* `db.Product({ labels: exists(false) })` — товары без поля `labels`.
|
|
70
|
+
*/
|
|
37
71
|
export declare const exists: (yes?: boolean) => Op;
|
|
38
|
-
/** поле null или
|
|
72
|
+
/** В поле `null` или поля нет совсем: `db.Product({ specs: isNull() })`. Отличить одно от другого помогает `exists()`. */
|
|
39
73
|
export declare const isNull: () => Op;
|
|
40
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Отрицание оператора: `db.Product({ price: not(gte(1000)) })` — цена меньше 1000 или поля нет. Простое значение вместо
|
|
76
|
+
* оператора значит «не равно», как `ne()`: `db.Order({ status: not('cancelled') })`.
|
|
77
|
+
*/
|
|
41
78
|
export declare const not: (v: Scalar | Op) => Op;
|
|
42
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
|
|
81
|
+
* вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и».
|
|
82
|
+
*/
|
|
43
83
|
export declare const or: (...filters: Record<string, unknown>[]) => Op;
|
package/dist/ops.js
CHANGED
|
@@ -6,39 +6,79 @@
|
|
|
6
6
|
import { OP } from './types.js';
|
|
7
7
|
const op = (name, ...args) => ({ [OP]: name, args });
|
|
8
8
|
export const isOp = (v) => typeof v === 'object' && v !== null && OP in v;
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Не равно: `db.Order({ status: ne('cancelled') })`. Подходят и строки, где поля нет или в нём `null`, а `ne(null)` —
|
|
11
|
+
* строки, где у поля есть значение.
|
|
12
|
+
*/
|
|
10
13
|
export const ne = (v) => op('ne', v);
|
|
11
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Больше: `db.Product({ price: gt(1000) })`. Сравнение — по типу поля из описания класса (числа — как числа, даты — как
|
|
16
|
+
* даты), поле без описания сравнивается как текст; строки без поля не подходят.
|
|
17
|
+
*/
|
|
12
18
|
export const gt = (v) => op('gt', v);
|
|
13
|
-
/**
|
|
19
|
+
/** Больше или равно: `db.Product({ price: gte(500) })`. Сравнение — как у `gt()`. */
|
|
14
20
|
export const gte = (v) => op('gte', v);
|
|
15
|
-
/**
|
|
21
|
+
/** Меньше: `db.Product({ price: lt(1000) })`. Сравнение — как у `gt()`. */
|
|
16
22
|
export const lt = (v) => op('lt', v);
|
|
17
|
-
/**
|
|
23
|
+
/** Меньше или равно: `db.Product({ price: lte(900) })`. Сравнение — как у `gt()`. */
|
|
18
24
|
export const lte = (v) => op('lte', v);
|
|
19
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* От `a` до `b` включительно: `db.Product({ price: between(500, 3000) })`. Сравнение — как у `gt()`; если `a` больше
|
|
27
|
+
* `b`, не подходит ни одна строка.
|
|
28
|
+
*/
|
|
20
29
|
export const between = (a, b) => op('between', a, b);
|
|
21
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Одно из значений списка: `db.Product({ sku: inList(['K-1', 'M-1']) })`. Сравнение — по типу поля, как у `gt()`;
|
|
32
|
+
* пустой список не находит ничего, а `null` в списке не находит строк без значения — для них есть `isNull()`.
|
|
33
|
+
*/
|
|
22
34
|
export const inList = (vs) => op('in', vs);
|
|
23
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один символ;
|
|
37
|
+
* `db.Product({ title: like('%чай%') })`. Поле берётся как текст; буквальные `%` и `_` пишут с обратной косой чертой:
|
|
38
|
+
* `like('100\\%')`.
|
|
39
|
+
*/
|
|
24
40
|
export const like = (s) => op('like', s);
|
|
25
|
-
/**
|
|
41
|
+
/** Как `like()`, но без учёта регистра: `db.Product({ title: ilike('%чай%') })`. */
|
|
26
42
|
export const ilike = (s) => op('ilike', s);
|
|
27
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Текст начинается с `s`, с учётом регистра: `db.Product({ sku: starts('T-') })`. Это `like(s + '%')`: `%`, `_` и
|
|
45
|
+
* обратная косая черта внутри `s` не экранируются и работают как в шаблоне `like()`.
|
|
46
|
+
*/
|
|
28
47
|
export const starts = (s) => op('like', s + '%');
|
|
29
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Текст заканчивается на `s`, с учётом регистра: `db.Product({ sku: ends('-1') })`. Это `like('%' + s)`: `%`, `_` и
|
|
50
|
+
* обратная косая черта внутри `s` не экранируются и работают как в шаблоне `like()`.
|
|
51
|
+
*/
|
|
30
52
|
export const ends = (s) => op('like', '%' + s);
|
|
31
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Поле-массив содержит элемент `v`: `db.Product({ labels: has('хит') })`. Тип важен: `has(5)` найдёт число 5, но не
|
|
55
|
+
* строку `'5'`.
|
|
56
|
+
*/
|
|
32
57
|
export const has = (v) => op('has', v);
|
|
33
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Поле-массив содержит хотя бы один элемент из списка: `db.Product({ labels: hasAny(['хит', 'акция']) })`. Находит
|
|
60
|
+
* только строковые элементы; пустой список не находит ничего.
|
|
61
|
+
*/
|
|
34
62
|
export const hasAny = (vs) => op('hasAny', vs);
|
|
35
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Поле-массив содержит все элементы списка, в любом порядке: `db.Product({ labels: hasAll(['хит', 'стекло']) })`. Тип
|
|
65
|
+
* элементов важен, как у `has()`; пустой список подходит любой строке, даже без этого поля.
|
|
66
|
+
*/
|
|
36
67
|
export const hasAll = (vs) => op('hasAll', vs);
|
|
37
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Поле есть в данных (`true`, по умолчанию) или его нет (`false`); значение не важно — поле с `null` тоже есть:
|
|
70
|
+
* `db.Product({ labels: exists(false) })` — товары без поля `labels`.
|
|
71
|
+
*/
|
|
38
72
|
export const exists = (yes = true) => op('exists', yes);
|
|
39
|
-
/** поле null или
|
|
73
|
+
/** В поле `null` или поля нет совсем: `db.Product({ specs: isNull() })`. Отличить одно от другого помогает `exists()`. */
|
|
40
74
|
export const isNull = () => op('isNull');
|
|
41
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Отрицание оператора: `db.Product({ price: not(gte(1000)) })` — цена меньше 1000 или поля нет. Простое значение вместо
|
|
77
|
+
* оператора значит «не равно», как `ne()`: `db.Order({ status: not('cancelled') })`.
|
|
78
|
+
*/
|
|
42
79
|
export const not = (v) => op('not', v);
|
|
43
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
|
|
82
|
+
* вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и».
|
|
83
|
+
*/
|
|
44
84
|
export const or = (...filters) => op('or', filters);
|
package/dist/project.d.ts
CHANGED
|
@@ -61,18 +61,25 @@ export interface ProjectorOut {
|
|
|
61
61
|
tenant: string;
|
|
62
62
|
}): void;
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
64
|
+
* Прибавляет к полям строки числа из `deltas`: читать строку не нужно, и итог не зависит от порядка приращений. Поле
|
|
65
|
+
* приращения, которого в строке нет, считается от 0, а если нет самой строки, она создаётся; поля `row` дописываются в
|
|
66
|
+
* её данные. Приращения одной строки, заданной только полями ключа (или только `$id`), до отправки в базу складываются
|
|
67
|
+
* в одну правку, пока между ними нет `upsert`, `delete` или `get`.
|
|
67
68
|
*/
|
|
68
69
|
inc(cls: string, row: ProjectorRow, deltas: Record<string, number>, opts: {
|
|
69
70
|
tenant: string;
|
|
70
71
|
}): void;
|
|
71
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Удалить строку: у класса с ключом — по полям ключа, у класса без ключа — по id (строкой или { $id }); строки нет —
|
|
74
|
+
* ничего. Один id у класса с ключом — invalid_data.
|
|
75
|
+
*/
|
|
72
76
|
delete(cls: string, keyOrId: string | ProjectorRow, opts: {
|
|
73
77
|
tenant: string;
|
|
74
78
|
}): void;
|
|
75
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Текущая строка внутри пачки (с записями пачки, сделанными до вызова); нет — null. Строку называют, как в delete: у
|
|
81
|
+
* класса с ключом — полями ключа, у класса без ключа — id; один id у класса с ключом — invalid_data.
|
|
82
|
+
*/
|
|
76
83
|
get(cls: string, keyOrId: string | ProjectorRow, opts: {
|
|
77
84
|
tenant: string;
|
|
78
85
|
}): Promise<Row | null>;
|
|
@@ -82,21 +89,28 @@ export interface ProjectorOptions {
|
|
|
82
89
|
from: string[];
|
|
83
90
|
/** Производные классы (с потомками): описание каждого — derivedBy: имя проектора. */
|
|
84
91
|
into: string[];
|
|
85
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Версия логики проектора — целое число от 1. Вместе с позицией в журнале проектор хранит в базе версию, которой
|
|
94
|
+
* построена проекция: если она другая, `run()` и `rebuild({ tenant })` отклоняются с `rebuild_required`, а полная
|
|
95
|
+
* перестройка `rebuild()` строит проекцию заново и запоминает новую версию.
|
|
96
|
+
*/
|
|
86
97
|
version: number;
|
|
87
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Функция `handle(changes, out)`: получает изменения одного арендатора и пишет его результаты через `out`. Пачку журнала
|
|
100
|
+
* проектор делит по арендаторам и вызывает `handle` для каждого по очереди.
|
|
101
|
+
*/
|
|
88
102
|
handle(changes: ProjectorChange[], out: ProjectorOut): void | Promise<void>;
|
|
89
103
|
/** Подавать прошлую версию объекта (before). */
|
|
90
104
|
withBefore?: boolean;
|
|
91
105
|
/**
|
|
92
106
|
* Курсор старше яруса all политики хранения источников (cursor_expired): 'rebuild' — перестроить самому, пачками.
|
|
93
|
-
* Ярус — по арендатору: истёк у части участников — выбывают они (с 'rebuild' — перестраиваются сами,
|
|
94
|
-
*
|
|
107
|
+
* Ярус — по арендатору: истёк у части участников — выбывают они (с 'rebuild' — перестраиваются сами, по одному, пачками), у
|
|
108
|
+
* всех — вся проекция.
|
|
95
109
|
*/
|
|
96
110
|
onExpired?: 'rebuild' | 'error';
|
|
97
111
|
/** Изменений в пачке (по умолчанию 500). */
|
|
98
112
|
batch?: number;
|
|
99
|
-
/**
|
|
113
|
+
/** Как часто проектор сам проверяет журнал, если сигнал не пришёл, мс (по умолчанию 1000); сигналы источников будят его раньше. */
|
|
100
114
|
pollMs?: number;
|
|
101
115
|
/**
|
|
102
116
|
* Сколько транзакция проектора ждёт чужую блокировку строки, мс (по умолчанию 5000; 0 — без предела). Дольше —
|
|
@@ -181,6 +195,10 @@ export declare class Projector {
|
|
|
181
195
|
/** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
|
|
182
196
|
private blind;
|
|
183
197
|
private stale;
|
|
198
|
+
/** Когда состав читался в последний раз: без LISTEN сигнала о новых классах нет (план 2.0, 7.4 №6). */
|
|
199
|
+
private resolvedAt;
|
|
200
|
+
/** Участники, которых чекпоинт ещё не знает: им начальная перестройка пачками (tenantWork). */
|
|
201
|
+
private newcomers;
|
|
184
202
|
/** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
|
|
185
203
|
private tenantsPending;
|
|
186
204
|
private running;
|
|
@@ -194,6 +212,11 @@ export declare class Projector {
|
|
|
194
212
|
/** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
|
|
195
213
|
private readonly lockHits;
|
|
196
214
|
private readonly only;
|
|
215
|
+
/**
|
|
216
|
+
* Перечитать состав: по сигналу классов, а без LISTEN (сигналов нет) — не реже раза в 5 с, как реестр подключения в
|
|
217
|
+
* режиме none, и в начале run() (план 2.0, 7.4 №6): иначе арендатор, вступивший после запуска, не участвовал бы.
|
|
218
|
+
*/
|
|
219
|
+
private due;
|
|
197
220
|
constructor(env: ProjectorEnv, name: string, def: ProjectorOptions);
|
|
198
221
|
/**
|
|
199
222
|
* Догнать журнал и следовать за ним до stop(); { once: true } — только догнать. Первый запуск (проекции
|
|
@@ -280,8 +303,13 @@ export declare class Projector {
|
|
|
280
303
|
private ours;
|
|
281
304
|
/** Смена класса без смены id, после которой строка вне семейства from арендатора, — возможно, уход из него (leaving). */
|
|
282
305
|
private leaves;
|
|
283
|
-
/**
|
|
306
|
+
/**
|
|
307
|
+
* Входит ли класс `cls` в классы `into` проектора у арендатора `tenant` — сами эти классы или их потомки. Служебный
|
|
308
|
+
* метод: им `out` проверяет, что `handle` пишет только в эти классы.
|
|
309
|
+
*/
|
|
284
310
|
intoOf(tenant: string, cls: string): boolean;
|
|
311
|
+
/** Поля ключа класса into арендатора (пусто — класс без ключа). */
|
|
312
|
+
keyOf(tenant: string, cls: string): string[];
|
|
285
313
|
/**
|
|
286
314
|
* Ключ свёртки inc: класс и строка из одних полей ключа класса или одного $id; иначе null. Строку с другими полями
|
|
287
315
|
* (данные, $tags, $owner) inc не складывает: её поля правят строку, и порядок таких правок важен.
|