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.
@@ -9,6 +9,17 @@ export interface EndInfo {
9
9
  onDelete?: 'restrict' | 'cascade' | 'unset';
10
10
  meta?: Record<string, unknown>;
11
11
  }
12
+ /**
13
+ * Агрегат в базе (план 1.1, §2.4): поле — сумма поля sum или число (count) живых строк класса of с потомками того же
14
+ * арендатора, чей одиночный конец via ведёт на строку; where — JSON Schema над данными строки-источника.
15
+ */
16
+ export interface AggregateInfo {
17
+ sum?: string;
18
+ count?: true;
19
+ of: string;
20
+ via: string;
21
+ where?: JsonSchema;
22
+ }
12
23
  /** Тип листа данных для фильтров, сортировки и агрегатов (замена fieldTypeOf 0.21). */
13
24
  export type LeafType = 'text' | 'numeric' | 'boolean' | 'timestamptz' | 'date' | 'uuid' | 'jsonb';
14
25
  export interface ClassInfo {
@@ -29,6 +40,10 @@ export interface ClassInfo {
29
40
  ttl?: number;
30
41
  };
31
42
  meta: Record<string, unknown>;
43
+ /** Производный класс: строки пишет только проектор с этим именем (план 1.1, §2.4). */
44
+ derivedBy?: string;
45
+ /** Агрегатные поля: их считает база, приложение не пишет (план 1.1, §2.4). */
46
+ aggregate?: Record<string, AggregateInfo>;
32
47
  /** Валидатор на TypeScript (компилируется при первом обращении). */
33
48
  validator(): Validator;
34
49
  }
@@ -40,6 +55,8 @@ interface ClassRow {
40
55
  resolved: Record<string, unknown>;
41
56
  };
42
57
  }
58
+ /** Агрегатные поля класса: их считает база (план 1.1, §2.4). */
59
+ export declare const aggregateFields: (cls: Pick<ClassInfo, "aggregate">) => string[];
43
60
  export declare class Registry {
44
61
  private byName;
45
62
  private byAlias;
package/dist/registry.js CHANGED
@@ -5,6 +5,17 @@
5
5
  */
6
6
  import { LetopisError } from './errors.js';
7
7
  import { compileValidator } from './validate.js';
8
+ /** Агрегатные поля класса: их считает база (план 1.1, §2.4). */
9
+ export const aggregateFields = (cls) => Object.keys(cls.aggregate ?? {});
10
+ /** Описание агрегатов из resolved — без скомпилированного условия (match): оно для базы. */
11
+ function aggregateOf(agg) {
12
+ return Object.fromEntries(Object.entries(agg).map(([field, d]) => [field, {
13
+ ...(typeof d.sum === 'string' ? { sum: d.sum } : { count: true }),
14
+ of: d.of,
15
+ via: d.via,
16
+ ...(d.where && typeof d.where === 'object' ? { where: d.where } : {}),
17
+ }]));
18
+ }
8
19
  export class Registry {
9
20
  byName = new Map();
10
21
  byAlias = new Map();
@@ -30,6 +41,8 @@ export class Registry {
30
41
  ownerDefault: (res.ownerDefault ?? 'tenant'),
31
42
  cache: (res.cache ?? undefined),
32
43
  meta: (res.meta ?? {}),
44
+ ...(typeof res.derivedBy === 'string' ? { derivedBy: res.derivedBy } : {}),
45
+ ...(res.aggregate && typeof res.aggregate === 'object' ? { aggregate: aggregateOf(res.aggregate) } : {}),
33
46
  validator: () => (compiled ??= compileValidator(res.schema)),
34
47
  };
35
48
  this.byName.set(info.name, info);
package/dist/sql.js CHANGED
@@ -693,14 +693,17 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
693
693
  case 'countPaths':
694
694
  text = `${withClause}select count(*)::int as n ${from}${whereOf(tail)}`;
695
695
  break;
696
- case 'versions':
697
- // с follow — по времени через все связанные id; без — по id и номеру версии
696
+ case 'versions': {
697
+ // с sort — по полю и направлению (последние версии первыми — sort('at', 'desc'), с limit — в самом запросе);
698
+ // без sort: с follow — по времени через все связанные id, без follow — по id и номеру версии
699
+ const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` : mods.follow ? 'v.at, v.id, v.rev' : 'v.id, v.rev';
698
700
  text = mods.follow
699
701
  ? `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
700
- + `where l.id in (select f.id from f)) v order by v.at, v.id, v.rev${limitOffset()}`
702
+ + `where l.id in (select f.id from f)) v order by ${order}${limitOffset()}`
701
703
  : `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
702
- + `where l.id in (select z.id from ${ents} z)) v order by v.id, v.rev${limitOffset()}`;
704
+ + `where l.id in (select z.id from ${ents} z)) v order by ${order}${limitOffset()}`;
703
705
  break;
706
+ }
704
707
  case 'diff':
705
708
  case 'blame': {
706
709
  // план 1.1, §2.1: функции базы считают по журналу под RLS — по одному документу на объект шага
@@ -720,7 +723,8 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
720
723
  const acc = accessor(path)('z');
721
724
  aggLeaf = leaf(path);
722
725
  if (agg.fn === 'countBy') {
723
- text = `${withClause}select ${acc} as k, count(*)::int as n from ${ents} z group by 1 order by 2 desc, 1`;
726
+ // limit и offset — группы по убыванию числа (самые частые значения), а не строки
727
+ text = `${withClause}select ${acc} as k, count(*)::int as n from ${ents} z group by 1 order by 2 desc, 1${limitOffset()}`;
724
728
  }
725
729
  else {
726
730
  const cast = agg.fn === 'sum' || agg.fn === 'avg' ? '::numeric' : CAST[aggLeaf];
package/dist/types.d.ts CHANGED
@@ -11,7 +11,7 @@ export declare const ACL_CLASS_NAMES: readonly string[];
11
11
  /** Методы цепочки: терминалы, модификаторы, глаголы записи (этап 4), охранники снесённого API. */
12
12
  export declare const CHAIN_METHODS: readonly ["then", "entity", "paths", "rows", "first", "ids", "count", "limit", "offset", "sort", "asOf", "withDeleted", "deep", "exact", "sum", "avg", "min", "max", "countBy", "after", "versions", "create", "update", "upsert", "set", "unset", "add", "remove", "delete", "anonymize", "reclass", "restore", "purge", "alias", "tags", "owner", "forUpdate", "cache", "rekey", "run", "execute", "account", "diff", "blame"];
13
13
  /** Свойства корня db (и транзакции db.begin()). */
14
- export declare const DB_METHODS: readonly ["as", "begin", "commit", "rollback", "lock", "batch", "watch", "close", "registry", "sql", "load", "verify", "maintain", "reset", "describe", "auth", "acl", "idOf", "refresh", "transaction", "flushSignals", "schema", "token", "tenant", "reloadSchema", "accounts", "credentials", "resources", "rules", "compact", "trimHistory", "rekeyClass", "cache"];
14
+ export declare const DB_METHODS: readonly ["as", "begin", "commit", "rollback", "lock", "batch", "watch", "close", "registry", "sql", "load", "verify", "maintain", "reset", "describe", "auth", "acl", "idOf", "refresh", "transaction", "flushSignals", "schema", "token", "tenant", "reloadSchema", "accounts", "credentials", "resources", "rules", "compact", "trimHistory", "rekeyClass", "cache", "projector", "rebuildAggregates"];
15
15
  /** Свойства батча db.batch(name). */
16
16
  export declare const BATCH_METHODS: readonly ["discard", "size"];
17
17
  /**
package/dist/types.js CHANGED
@@ -23,7 +23,7 @@ export const DB_METHODS = [
23
23
  'as', 'begin', 'commit', 'rollback', 'lock', 'batch', 'watch', 'close', 'registry', 'sql', 'load', 'verify',
24
24
  'maintain', 'reset', 'describe', 'auth', 'acl', 'idOf', 'refresh', 'transaction', 'flushSignals', 'schema',
25
25
  'token', 'tenant', 'reloadSchema', 'accounts', 'credentials', 'resources', 'rules', 'compact',
26
- 'trimHistory', 'rekeyClass', 'cache',
26
+ 'trimHistory', 'rekeyClass', 'cache', 'projector', 'rebuildAggregates',
27
27
  ];
28
28
  /** Свойства батча db.batch(name). */
29
29
  export const BATCH_METHODS = ['discard', 'size'];
package/dist/uuid.js CHANGED
@@ -5,18 +5,29 @@
5
5
  * (`v5_name` в lib/sql/30-validate.sql — текстовый вид jsonb-массива): элементы через JSON.stringify,
6
6
  * разделитель — запятая с пробелом. Совпадение закрепляет общий файл векторов lib/test/vectors/v5.json.
7
7
  */
8
- import { createHash } from 'node:crypto';
8
+ import { hash } from 'node:crypto';
9
9
  import { LetopisError } from './errors.js';
10
10
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ // байты пространств имён (арендаторов): в загрузке их единицы, а проверка и разбор шли на каждый id
12
+ const NS = new Map();
13
+ function nsBytes(ns) {
14
+ let b = NS.get(ns);
15
+ if (!b) {
16
+ if (!UUID.test(ns))
17
+ throw new LetopisError('invalid_data', `пространство имён v5 — не uuid: ${ns}`);
18
+ b = Buffer.from(ns.replace(/-/g, ''), 'hex');
19
+ if (NS.size >= 10_000)
20
+ NS.clear();
21
+ NS.set(ns, b);
22
+ }
23
+ return b;
24
+ }
11
25
  /** RFC 4122 v5: sha1(пространство + имя); одно имя в одном пространстве — один id. */
12
26
  export function uuidv5(name, ns) {
13
- if (!UUID.test(ns))
14
- throw new LetopisError('invalid_data', `пространство имён v5 — не uuid: ${ns}`);
15
- const h = createHash('sha1').update(Buffer.from(ns.replace(/-/g, ''), 'hex')).update(name, 'utf8').digest().subarray(0, 16);
16
- h[6] = (h[6] & 0x0f) | 0x50; // версия 5
17
- h[8] = (h[8] & 0x3f) | 0x80; // вариант 10xx
18
- const x = h.toString('hex');
19
- return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;
27
+ const x = hash('sha1', Buffer.concat([nsBytes(ns), Buffer.from(name, 'utf8')]), 'hex');
28
+ // версия 5 — старшая тетрада байта 6; вариант 10xx — старшие биты байта 8
29
+ const variant = ((parseInt(x[16], 16) & 0x3) | 0x8).toString(16);
30
+ return `${x.slice(0, 8)}-${x.slice(8, 12)}-5${x.slice(13, 16)}-${variant}${x.slice(17, 20)}-${x.slice(20, 32)}`;
20
31
  }
21
32
  /** Имя v5: JSON-массив из имени класса и значений ключа — как `jsonb_build_array(…)::text` в базе. */
22
33
  export function v5Name(cls, values) {
package/dist/watch.d.ts CHANGED
@@ -53,13 +53,25 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
53
53
  private readonly pollMs;
54
54
  /** 'now' — голова журнала в момент подписки (запрос уходит сразу). */
55
55
  private start;
56
+ /** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
57
+ private queue;
56
58
  constructor(ctx: WatchCtx, opts: WatchOptions);
57
59
  /** Курсор последнего выданного события (или начальной точки). */
58
60
  get cursor(): string | null;
61
+ /**
62
+ * Начальная точка взята (доработки после точки Б): у from: 'now' — горизонт и снимок момента подписки (watch_head());
63
+ * у 'start' и курсора — сразу. Запись после await sub.ready() придёт в поток; ошибка начальной точки — отказ.
64
+ */
65
+ ready(): Promise<void>;
59
66
  private head;
60
67
  /** Следующая пачка событий ниже горизонта; пусто — горизонт ещё не дошёл или событий нет. */
61
68
  private fetch;
69
+ /**
70
+ * Следующее событие. Вызовы идут по очереди: параллельные next() получают разные события, без двойных. Вызов,
71
+ * брошенный по таймауту (Promise.race), всё равно получит своё событие — держите его обещание.
72
+ */
62
73
  next(): Promise<IteratorResult<WatchEvent>>;
74
+ private take;
63
75
  /**
64
76
  * Отставание, мс: сколько ждёт самое старое событие, которое уже зафиксировано, но ещё не ушло за
65
77
  * горизонт (его держит более старая открытая транзакция); 0 — ждать нечего.
package/dist/watch.js CHANGED
@@ -64,6 +64,8 @@ export class Subscription {
64
64
  pollMs;
65
65
  /** 'now' — голова журнала в момент подписки (запрос уходит сразу). */
66
66
  start = null;
67
+ /** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
68
+ queue = Promise.resolve();
67
69
  constructor(ctx, opts) {
68
70
  this.ctx = ctx;
69
71
  this.opts = opts;
@@ -97,6 +99,14 @@ export class Subscription {
97
99
  get cursor() {
98
100
  return this.pos ? encode(this.pos) : null;
99
101
  }
102
+ /**
103
+ * Начальная точка взята (доработки после точки Б): у from: 'now' — горизонт и снимок момента подписки (watch_head());
104
+ * у 'start' и курсора — сразу. Запись после await sub.ready() придёт в поток; ошибка начальной точки — отказ.
105
+ */
106
+ async ready() {
107
+ if (!this.pos && this.start)
108
+ this.pos = await this.start;
109
+ }
100
110
  async head() {
101
111
  const [r] = await this.ctx.exec.run((q) => q.unsafe(`select tx::text as tx, seq::text as seq, snap::text as snap from ${this.ctx.S}.watch_head()`), { read: true });
102
112
  return { tx: r.tx, seq: r.seq, at: null, snap: r.snap };
@@ -118,7 +128,16 @@ export class Subscription {
118
128
  }
119
129
  return rows.length;
120
130
  }
121
- async next() {
131
+ /**
132
+ * Следующее событие. Вызовы идут по очереди: параллельные next() получают разные события, без двойных. Вызов,
133
+ * брошенный по таймауту (Promise.race), всё равно получит своё событие — держите его обещание.
134
+ */
135
+ next() {
136
+ const p = this.queue.then(() => this.take(), () => this.take());
137
+ this.queue = p.catch(() => undefined);
138
+ return p;
139
+ }
140
+ async take() {
122
141
  let empty = 0;
123
142
  for (;;) {
124
143
  if (this.failure)
package/dist/write.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ClassInfo, Registry } from './registry.js';
1
+ import { type ClassInfo, type Registry } from './registry.js';
2
2
  import { type AggSpec, type BuiltQuery, type ReadMode, type Step } from './sql.js';
3
3
  import type { Tx } from './tx.js';
4
4
  import type { ChainMods, Row } from './types.js';
package/dist/write.js CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { LetopisError, ValidationError } from './errors.js';
21
21
  import { isOp } from './ops.js';
22
+ import { aggregateFields } from './registry.js';
22
23
  import { accepts, buildRead, nextNodeKey, resolveHop, stepClasses, toRow } from './sql.js';
23
24
  import { idOf } from './uuid.js';
24
25
  import { applyDefaults } from './validate.js';
@@ -115,13 +116,26 @@ function checkFullData(cls, data, verb, full = true) {
115
116
  scan(x, [...path, k]);
116
117
  };
117
118
  scan(data, []);
119
+ // агрегатные поля считает база (план 1.1, §2.4): переданное значение — aggregate_field до отправки, а для ранней
120
+ // проверки поле — 0, как его поставит база
121
+ const obj = data;
122
+ const agg = aggregateFields(cls);
123
+ const given = agg.filter((f) => f in obj);
124
+ if (given.length)
125
+ fail('aggregate_field', `поля ${given.join(', ')} класса ${cls.name} считает база (aggregate) — их не передают`);
118
126
  // описания системных классов проверяет только база (метасхема Class, class_admit)
119
127
  if (!full || cls.name === 'Class' || cls.abstract)
120
128
  return;
121
- const issues = cls.validator()(applyDefaults(cls.schema, data));
129
+ const issues = cls.validator()(applyDefaults(cls.schema, agg.length ? { ...obj, ...Object.fromEntries(agg.map((f) => [f, 0])) } : obj));
122
130
  if (issues.length)
123
131
  throw new ValidationError(cls.name, issues);
124
132
  }
133
+ /** Патч update не трогает агрегатные поля класса шага (план 1.1, §2.4): их считает база. */
134
+ function checkAggregatePatch(cls, patch) {
135
+ const given = aggregateFields(cls).filter((f) => f in patch);
136
+ if (given.length)
137
+ fail('aggregate_field', `поля ${given.join(', ')} класса ${cls.name} считает база (aggregate) — update их не меняет`);
138
+ }
125
139
  /** Всё, что проверяется без базы, — до отправки первого оператора (§2.5). */
126
140
  export function precheckPlan(reg, steps) {
127
141
  let prevOp;
@@ -162,6 +176,7 @@ export function precheckPlan(reg, steps) {
162
176
  if (!isPlain(op.data))
163
177
  fail('invalid_data', 'update(): патч — объект');
164
178
  splitPatch(op.data);
179
+ checkAggregatePatch(s.cls, op.data);
165
180
  if (op.rev !== undefined && (!Number.isInteger(op.rev) || op.rev < 1))
166
181
  fail('invalid_query', 'update(patch, { rev }): rev — номер версии строки');
167
182
  break;
@@ -388,13 +403,16 @@ async function createOp(w, steps, opStep, upsert) {
388
403
  const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
389
404
  const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
390
405
  const owner = opStep.ownerFilter ?? w.owner ?? null;
406
+ // агрегатные поля (план 1.1, §2.4): в excluded их поставил триггер вставки (0) — замена данных идёт без них, и
407
+ // триггер правки возвращает прежние значения
408
+ const agg = aggregateFields(cls);
391
409
  const conflict = upsert
392
- ? ` on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags`
410
+ ? ` on conflict (id) do update set data = excluded.data${agg.length ? ' - $7::text[]' : ''}, links = excluded.links, tags = excluded.tags`
393
411
  : '';
394
412
  const text = `insert into ${S}.entity (id, class, links, data, tags, owner) values ($1::uuid, $2::text, $3::jsonb, $4::jsonb, $5::text[], $6::uuid)`
395
413
  + `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
396
414
  try {
397
- const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner]);
415
+ const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner, ...(upsert && agg.length ? [agg] : [])]);
398
416
  return res.map((r) => {
399
417
  const row = toRow(r.row);
400
418
  if (upsert)
@@ -742,11 +760,13 @@ export async function execBatch(w, plans) {
742
760
  }
743
761
  await useReason(w, why);
744
762
  const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
763
+ // агрегатные поля — без них, как у одиночного upsert (план 1.1, §2.4)
764
+ const agg = aggregateFields(head.cls);
745
765
  const conflict = verb === 'upsert'
746
- ? 'on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags'
766
+ ? `on conflict (id) do update set data = excluded.data${agg.length ? ' - $3::text[]' : ''}, links = excluded.links, tags = excluded.tags`
747
767
  : 'on conflict (id) do nothing';
748
768
  const res = await query(w, `insert into ${S}.entity (class, data, tags, owner) select $1::text, x.d, x.t, x.o `
749
- + `from jsonb_to_recordset($2::jsonb) as x(d jsonb, t text[], o uuid) ${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`, [head.cls.name, input]);
769
+ + `from jsonb_to_recordset($2::jsonb) as x(d jsonb, t text[], o uuid) ${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`, [head.cls.name, input, ...(verb === 'upsert' && agg.length ? [agg] : [])]);
750
770
  const byId = new Map(res.map((r) => [r.row.id, r]));
751
771
  for (const g of group) {
752
772
  const r = byId.get(g.id);
package/llms.txt CHANGED
@@ -2,17 +2,21 @@
2
2
 
3
3
  > Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
4
4
 
5
- Key facts for writing code with letopis 1.1:
5
+ Key facts for writing code with letopis 1.2:
6
6
 
7
- - ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`.
7
+ - ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`; `@modelcontextprotocol/server`, only by `letopis/mcp`.
8
8
  - Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
9
9
  - Classes are hubs (objects) and links (relations with ends), described with `hub()`/`link()` from `letopis/model` and written to the database with `syncModels()` or `letopis sync`. A class with a key gets a deterministic UUID v5 id (`db.idOf`); uniqueness is the key.
10
10
  - Verbs return a chain; a terminal (`rows`, `first`, `ids`, `count`, …) executes the whole plan in one transaction. `create` only creates (a taken key is `exists`), `update` merges and never creates, `upsert` replaces the whole object (keyed classes only), `delete()` is a preview and `delete({ confirm: true })` deletes.
11
11
  - Money and counters: `update({ balance: inc(n) })` adds inside the statement; `update(patch, { rev })` fails with `conflict` if the row changed; `forUpdate()` locks rows inside `db.begin()`.
12
12
  - History: `versions()`, `diff({ from, to })` and `blame()` (who, when and why last changed each field); verbs, `db.begin()` and `db.batch()` take `{ reason }`.
13
13
  - Bulk loading from an admin connection: `db.load(rows)`; into an empty schema `db.load(rows, { initial: true })` — one transaction, indexes and triggers rebuilt at the end.
14
+ - Projectors from an admin connection: `db.projector(name, { from, into, version, handle })` builds rows of derived classes (`derivedBy: name`) from the journal; each batch is one transaction and every change is applied exactly once; `run()`, `rebuild()`, `status()`. Only the projector writes derived rows: the application, raw SQL and the loader get `derived_class`. Only an administrator creates, changes and deletes derived classes (an admin connection with a tenant session, or `letopis sync` with the admin `--dsn`); for an application session it is `acl_denied`. `purge()` of a deleted source object waits until the projector has read the deletion (`projector_pending`).
15
+ - Aggregates in the database: the class option `aggregate: { balance: { sum: 'amount', of: 'Movement', via: 'Wallet' } }` (or `count: true`, `where` as a JSON Schema) keeps the field equal to the total of live source rows of the same tenant; a trigger after each statement recalculates it for the library, raw SQL, cascades and the loader; only the database writes the field (`aggregate_field`); repair with `db.rebuildAggregates(class)` on an admin connection, check with `db.verify({ data: true })`.
16
+ - Subscriptions: `db.watch({ from, classes })` — `await sub.ready()` before a write that must reach the stream (`from: 'now'` fixes its starting point with the first query); parallel `next()` calls get different events.
14
17
  - NOTIFY signals are visible to any database role: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` chooses what they reveal (default `class`).
15
18
  - Access rules are always on and deny by default; `up({ tenant })` or `createTenant()` creates a tenant with the rule "authenticated users may do everything" and its first user.
19
+ - MCP: `createMcpServer(db)` from `letopis/mcp` and `letopis mcp` (stdio, a user session token only) give an AI agent the tools `letopis_model`, `letopis_query` (a JSON chain, no SQL), `letopis_history` and, with `write: true`, `letopis_write`; the agent acts with the rights of one user, and a deletion needs a human confirmation through elicitation; by default `letopis_write` does not write the permission classes (`allowAclWrite`), and a session in the System tenant is refused (`allowSystemAdmin`). `structuredContent` of a tool result is `{ notice, data }` (an error — `{ notice, error }`): `notice` warns that `data` holds untrusted stored data, not instructions.
16
20
  - Errors carry a lowercase `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, `removed` (0.21 API with its replacement), …
17
21
 
18
22
  ## Docs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
5
5
  "keywords": [
6
6
  "postgres",
@@ -35,6 +35,10 @@
35
35
  "./model": {
36
36
  "types": "./dist/model.d.ts",
37
37
  "import": "./dist/model.js"
38
+ },
39
+ "./mcp": {
40
+ "types": "./dist/mcp.d.ts",
41
+ "import": "./dist/mcp.js"
38
42
  }
39
43
  },
40
44
  "engines": {
@@ -72,7 +76,8 @@
72
76
  "peerDependencies": {
73
77
  "zod": "^4",
74
78
  "@electric-sql/pglite": "^0.5.8",
75
- "@electric-sql/pglite-socket": "^0.2.11"
79
+ "@electric-sql/pglite-socket": "^0.2.11",
80
+ "@modelcontextprotocol/server": "^2.4.0"
76
81
  },
77
82
  "peerDependenciesMeta": {
78
83
  "zod": {
@@ -83,11 +88,16 @@
83
88
  },
84
89
  "@electric-sql/pglite-socket": {
85
90
  "optional": true
91
+ },
92
+ "@modelcontextprotocol/server": {
93
+ "optional": true
86
94
  }
87
95
  },
88
96
  "devDependencies": {
89
97
  "@electric-sql/pglite": "^0.5.8",
90
98
  "@electric-sql/pglite-socket": "^0.2.11",
99
+ "@modelcontextprotocol/client": "^2.4.0",
100
+ "@modelcontextprotocol/server": "^2.4.0",
91
101
  "@types/node": "^22.20.5",
92
102
  "letopis-0": "npm:letopis@0.21.0",
93
103
  "letopis-1": "npm:letopis@1.0.1",
package/sql/10-core.sql CHANGED
@@ -91,7 +91,11 @@ create table if not exists @SCHEMA@.log (
91
91
  changed jsonb,
92
92
  constraint log_id_rev unique (id, rev)
93
93
  );
94
- create index if not exists log_class_at on @SCHEMA@.log (class, at);
94
+ -- срез журнала по классу (asOf, withDeleted) — по арендатору (доработки после точки Б): цена запроса не растёт с
95
+ -- журналом других арендаторов с тем же именем класса. Прежний log_class_at (class, at) снимается при накате; на
96
+ -- большой установке новый индекс можно построить заранее — create index concurrently log_class_tenant_at …
97
+ create index if not exists log_class_tenant_at on @SCHEMA@.log (class, tenant, at);
98
+ drop index if exists @SCHEMA@.log_class_at;
95
99
  create index if not exists log_tx_seq on @SCHEMA@.log (tx, seq);
96
100
  -- versions({ follow: true }): переход по связи старого и нового id при reclass и rekey (этап 6)
97
101
  create index if not exists log_moved on @SCHEMA@.log (moved) where moved is not null;
package/sql/15-errors.sql CHANGED
@@ -25,7 +25,10 @@ as $$
25
25
  ('LT017', 'delete_restricted', 'letopis: delete_restricted: удалению мешают ссылающиеся строки'),
26
26
  ('LT018', 'not_deleted', 'letopis: not_deleted: объект не удалён — restore и purge только для удалённых'),
27
27
  ('LT019', 'reset_disabled', 'letopis: reset_disabled: reset выключен в этой установке'),
28
- ('LT020', 'confirm_mismatch', 'letopis: confirm_mismatch: подтверждение не совпадает с именем схемы')
28
+ ('LT020', 'confirm_mismatch', 'letopis: confirm_mismatch: подтверждение не совпадает с именем схемы'),
29
+ ('LT021', 'derived_class', 'letopis: derived_class: строки производного класса пишет только его проектор'),
30
+ ('LT022', 'aggregate_field', 'letopis: aggregate_field: агрегатное поле считает база'),
31
+ ('LT023', 'projector_pending', 'letopis: projector_pending: проектор источника ещё не прочёл удаление')
29
32
  $$;
30
33
 
31
34
  create or replace function @SCHEMA@.lt_raise(p_code text, p_name text, p_msg text, p_detail jsonb default null)
package/sql/40-class.sql CHANGED
@@ -12,7 +12,12 @@
12
12
  -- key [<поле или роль>…] — из них вычисляется id (§2.3); [] — класс без ключа
13
13
  -- history true | false | политика хранения (§2.11)
14
14
  -- ownerDefault 'tenant' | 'actor'; cache { ttl }; meta {…} до 64 КБ
15
- -- resolved развёрнутое описание с учётом предков — пишет триггер, присланное значение перезаписывается
15
+ -- derivedBy имя проектора (план 1.1, §2.4): строки класса пишет только он; наследуется, потомок не меняет
16
+ -- aggregate { <поле>: { sum: <поле источника> | count: true, of: <класс-источник>, via: <роль>, where?: JSON Schema } }
17
+ -- агрегаты в базе (план 1.1, §2.4): поле держит триггер по живым строкам источника; наследуется,
18
+ -- потомок заменяет целиком
19
+ -- resolved развёрнутое описание с учётом предков — пишет триггер, присланное значение перезаписывается; у
20
+ -- источника агрегатов в нём ещё метка aggregatedBy — какие цели считают его строки (88-derived.sql)
16
21
  -- Скомпилированная проверка данных класса лежит в колонке validator строки Class.
17
22
 
18
23
  create or replace function @SCHEMA@.class_metaschema() returns jsonb
@@ -69,6 +74,23 @@ return $json${
69
74
  "ownerDefault": {"type": "string", "enum": ["tenant", "actor"]},
70
75
  "cache": {"type": "object"},
71
76
  "meta": {"type": "object"},
77
+ "derivedBy": {"type": "string", "pattern": "^[a-z0-9][a-z0-9_.:-]{0,63}$"},
78
+ "aggregate": {
79
+ "type": "object",
80
+ "propertyNames": {"type": "string", "pattern": "^[^$]"},
81
+ "additionalProperties": {
82
+ "type": "object",
83
+ "required": ["of", "via"],
84
+ "additionalProperties": false,
85
+ "properties": {
86
+ "sum": {"type": "string", "pattern": "^[^$]"},
87
+ "count": {"type": "boolean", "enum": [true]},
88
+ "of": {"type": "string", "pattern": "^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{0,62}$"},
89
+ "via": {"type": "string", "pattern": "^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{0,62}$"},
90
+ "where": {"type": "object"}
91
+ }
92
+ }
93
+ },
72
94
  "resolved": {"type": "object"}
73
95
  }
74
96
  }$json$::jsonb;
@@ -114,6 +136,8 @@ declare
114
136
  hist jsonb;
115
137
  od text;
116
138
  cache jsonb;
139
+ der text;
140
+ agg jsonb;
117
141
  comp jsonb := '{}';
118
142
  i int;
119
143
  v jsonb;
@@ -143,6 +167,16 @@ begin
143
167
  if v ? 'history' then hist := v->'history'; end if;
144
168
  if v ? 'ownerDefault' then od := v->>'ownerDefault'; end if;
145
169
  if v ? 'cache' then cache := v->'cache'; end if;
170
+ -- производный класс (план 1.1, §2.4): проектор задаёт предок, потомок его не меняет
171
+ if v ? 'derivedBy' then
172
+ if der is not null and der <> v->>'derivedBy' then
173
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class',
174
+ format('derivedBy класса %s (%s) не совпадает с предком (%s)', v->>'name', v->>'derivedBy', der));
175
+ end if;
176
+ der := v->>'derivedBy';
177
+ end if;
178
+ -- агрегаты (план 1.1, §2.4): потомок заменяет описание предка целиком, {} — снимает унаследованное
179
+ if v ? 'aggregate' then agg := v->'aggregate'; end if;
146
180
  for m in select * from jsonb_each(coalesce(v->'meta', '{}')) loop
147
181
  if m.value = 'null'::jsonb then meta := meta - m.key; else meta := meta || jsonb_build_object(m.key, m.value); end if;
148
182
  end loop;
@@ -158,7 +192,9 @@ begin
158
192
  'history', coalesce(hist, 'true'),
159
193
  'ownerDefault', coalesce(od, 'tenant'),
160
194
  'cache', cache,
161
- 'meta', meta);
195
+ -- ключи только у производных и у целей агрегатов: у описаний до 1.2 их нет, и сравнение «было — стало» их не задевает
196
+ 'meta', meta) || case when der is not null then jsonb_build_object('derivedBy', der) else '{}'::jsonb end
197
+ || case when agg is not null and agg <> '{}'::jsonb then jsonb_build_object('aggregate', agg) else '{}'::jsonb end;
162
198
  end $$;
163
199
 
164
200
  -- Проверка описания класса: метасхема, правила ключа (§2.3), правило unset (§2.10), размер meta.
@@ -243,6 +279,25 @@ begin
243
279
  perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('history: неизвестный часовой пояс %s', res->'history'->>'tz'));
244
280
  end if;
245
281
  end if;
282
+ -- производный класс (план 1.1, §2.4): системным не бывает; его строки проектор удаляет и строит заново,
283
+ -- поэтому они не мешают удалять строки, на которые ссылаются (onDelete только cascade или unset)
284
+ if res ? 'derivedBy' then
285
+ if coalesce((data->>'system')::boolean, false) then
286
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class', 'системный класс не бывает производным');
287
+ end if;
288
+ for e in select key, value from jsonb_each(res->'ends') loop
289
+ if coalesce(e.value->>'onDelete', 'restrict') not in ('cascade', 'unset') then
290
+ perform @SCHEMA@.lt_raise('LT010', 'invalid_class',
291
+ format('конец %s производного класса — onDelete cascade или unset: производная строка не мешает удалять цель', e.key));
292
+ end if;
293
+ end loop;
294
+ end if;
295
+ -- агрегаты (план 1.1, §2.4; 88-derived.sql): цель — поля, источники, цикл, условия where; источник — метка
296
+ -- aggregatedBy и та же сверка против агрегатов, которые считают его строки (источник часто описывают после цели)
297
+ if res ? 'aggregate' then
298
+ res := @SCHEMA@.agg_prepare(res, data, p_tenant);
299
+ end if;
300
+ res := @SCHEMA@.agg_source(res, p_tenant);
246
301
  select * into c from @SCHEMA@.compile_validator(res->'schema');
247
302
  data := data || jsonb_build_object('resolved', res || jsonb_build_object('rules', c.rules, 'vars', c.vars));
248
303
  validator := c.validator;