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.
Files changed (62) hide show
  1. package/AGENT-CHEATSHEET.en.md +15 -7
  2. package/AGENT-CHEATSHEET.md +19 -11
  3. package/CHANGELOG.md +299 -1
  4. package/README.en.md +180 -62
  5. package/README.md +165 -55
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +53 -18
  9. package/dist/auth.d.ts +61 -17
  10. package/dist/auth.js +16 -2
  11. package/dist/cache.d.ts +5 -1
  12. package/dist/cache.js +3 -2
  13. package/dist/chain.d.ts +92 -15
  14. package/dist/chain.js +56 -7
  15. package/dist/cli.js +16 -2
  16. package/dist/errors.d.ts +2 -2
  17. package/dist/errors.js +5 -5
  18. package/dist/import.js +7 -0
  19. package/dist/index.d.ts +73 -45
  20. package/dist/index.js +69 -14
  21. package/dist/internal.d.ts +7 -0
  22. package/dist/internal.js +5 -0
  23. package/dist/load.d.ts +10 -3
  24. package/dist/load.js +39 -11
  25. package/dist/mcp.d.ts +20 -11
  26. package/dist/mcp.js +8 -6
  27. package/dist/model.d.ts +4 -1
  28. package/dist/model.js +7 -1
  29. package/dist/ops.d.ts +58 -18
  30. package/dist/ops.js +58 -18
  31. package/dist/project.d.ts +39 -11
  32. package/dist/project.js +75 -15
  33. package/dist/registry.d.ts +4 -1
  34. package/dist/registry.js +4 -1
  35. package/dist/signals.d.ts +4 -4
  36. package/dist/signals.js +4 -4
  37. package/dist/sql.d.ts +8 -4
  38. package/dist/sql.js +110 -25
  39. package/dist/sync.js +11 -4
  40. package/dist/tx.d.ts +15 -0
  41. package/dist/tx.js +22 -4
  42. package/dist/types.d.ts +21 -7
  43. package/dist/types.js +13 -6
  44. package/dist/up.d.ts +48 -25
  45. package/dist/up.js +94 -41
  46. package/dist/validate.d.ts +9 -2
  47. package/dist/validate.js +19 -3
  48. package/dist/watch.d.ts +29 -5
  49. package/dist/watch.js +62 -13
  50. package/dist/write.d.ts +3 -2
  51. package/dist/write.js +52 -19
  52. package/package.json +8 -4
  53. package/sql/10-core.sql +6 -1
  54. package/sql/60-write.sql +47 -18
  55. package/sql/70-read.sql +13 -7
  56. package/sql/75-history.sql +106 -28
  57. package/sql/80-auth.sql +41 -3
  58. package/sql/88-derived.sql +3 -1
  59. package/sql/90-time.sql +118 -30
  60. package/sql/95-seed.system.sql +6 -3
  61. package/sql/99-revision.sql +1 -1
  62. 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
- const live = new Set();
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.add(r.id);
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.unsafe('rollback').catch(() => { });
697
+ await rollbackAfter(conn, e);
695
698
  }
696
699
  finally {
697
700
  if (!ini)
698
- conn.release();
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 его не передаёт) сохраняются в памяти; TRUNCATE; триггеры, ограничения и индексы
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
- await q('rollback').catch(() => { });
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
- conn.release();
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
- /** Строк в ответе не больше (по умолчанию 100); дальше — курсор. */
6
+ /**
7
+ * Предел строк в одном ответе (по умолчанию 100). Остальные строки `letopis_query` отдаёт на следующих страницах по
8
+ * курсору, а версии, группы `countBy` и изменения `diff` и `blame` сверх предела обрезаются с пометкой `more` или
9
+ * `truncated`.
10
+ */
7
11
  maxRows?: number;
8
- /** Байт JSON строк в ответе не больше (по умолчанию 50 000); дальше — курсор. */
12
+ /**
13
+ * Предел размера строк одного ответа в байтах JSON (по умолчанию 50 000); что не поместилось — так же, как сверх
14
+ * `maxRows`. Описание модели больше предела `letopis_model` отдаёт кратким.
15
+ */
9
16
  maxBytes?: number;
10
17
  /**
11
- * letopis_write пишет и классы прав — rule, Resource, member, Account, Class и их потомков (правила, членства,
12
- * описания классов). По умолчанию — отказ acl_denied: агент под инъекцией в данных выдал бы постороннему членство или
13
- * правило без подтверждения человека (ревью безопасности №2, О5).
18
+ * Разрешить `letopis_write` писать и классы прав — `rule`, `Resource`, `member`, `Account`, `Class` и их потомков
19
+ * (правила, ресурсы, членства, аккаунты, описания классов). По умолчанию — отказ `acl_denied`: агент, которому в данных
20
+ * подсунули чужие инструкции, мог бы без подтверждения человека выдать постороннему членство или право
21
+ * (ревью безопасности №2, О5).
14
22
  */
15
23
  allowAclWrite?: boolean;
16
24
  /**
17
- * Сессия в арендаторе System — так работает системный администратор: его роль admin даёт права на аккаунты,
18
- * членства, правила и letopis.*. По умолчанию — отказ user_session_required (ревью безопасности №2, О5).
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, letopis_query, letopis_history и при
24
- * { write: true } — letopis_write. Подключение — сессия пользователя (connect({ token })) или фасад db.as(пользователь).
25
- * Транспорт выбирает приложение: server.connect(транспорт), serveStdio(() => createMcpServer(db)) или
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 next = more && page.length ? encodeCursor({ f: field, d: dir, ...cursorOf(page[page.length - 1], field) }) : undefined;
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, letopis_query, letopis_history и при
786
- * { write: true } — letopis_write. Подключение — сессия пользователя (connect({ token })) или фасад db.as(пользователь).
787
- * Транспорт выбирает приложение: server.connect(транспорт), serveStdio(() => createMcpServer(db)) или
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
- const isShape = defArg && typeof defArg === 'object' && Object.values(defArg).length > 0 && Object.values(defArg).every(isZod);
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
- /** ≠ (IS DISTINCT FROM) */
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
- /** a ≤ x ≤ b */
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
- /** LIKE (шаблон с % и _) */
34
+ /**
35
+ * Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один символ;
36
+ * `db.Product({ title: like('%чай%') })`. Поле берётся как текст; буквальные `%` и `_` пишут с обратной косой чертой:
37
+ * `like('100\\%')`.
38
+ */
23
39
  export declare const like: (s: string) => Op;
24
- /** ILIKE (без регистра) */
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
- /** поле присутствует (true) или отсутствует (false) */
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
- /** НЕ-условие по полю: not(ilike('%тест%')); скаляр — «не равно» */
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
- /** ИЛИ на уровне фильтра шага: t.booking(or({ notes: 'a' }, { notes: 'b' })) */
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
- /** ≠ (IS DISTINCT FROM) */
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
- /** a ≤ x ≤ b */
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
- /** LIKE (шаблон с % и _) */
35
+ /**
36
+ * Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один символ;
37
+ * `db.Product({ title: like('%чай%') })`. Поле берётся как текст; буквальные `%` и `_` пишут с обратной косой чертой:
38
+ * `like('100\\%')`.
39
+ */
24
40
  export const like = (s) => op('like', s);
25
- /** ILIKE (без регистра) */
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
- /** поле присутствует (true) или отсутствует (false) */
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
- /** НЕ-условие по полю: not(ilike('%тест%')); скаляр — «не равно» */
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
- /** ИЛИ на уровне фильтра шага: t.booking(or({ notes: 'a' }, { notes: 'b' })) */
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
- * Создать (поля без строки — от 0) или дополнить строку и прибавить числа: коммутативная свёртка без чтения.
65
- * Приращения строки, заданной одними полями ключа (или одним $id), складываются до отправки в базу, пока между ними
66
- * нет upsert, delete или get: одна правка строки на пачку.
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
- /** Удалить по ключу или id; строки нет — ничего. */
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
- /** Текущая строка внутри пачки (с записями пачки, сделанными до вызова); нет — null. */
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
- /** Версия проектора: другая версия в чекпоинте — rebuild_required, нужна перестройка. */
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
- /** Запасной опрос журнала, мс (по умолчанию 1000; сигналы источников будят раньше). */
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
- /** Класс результата в арендаторе — из его семейства into. */
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 не складывает: её поля правят строку, и порядок таких правок важен.