letopis 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # letopis 1.1
1
+ # letopis 1.2
2
2
 
3
3
  **letopis** — библиотека для Node.js, хранилище версионированных сущностей на PostgreSQL 18. Каждое изменение
4
4
  сохраняется версией в журнале. Поэтому у любого объекта есть история, состояние на любую дату и подписка на
@@ -15,11 +15,15 @@ RLS. Правила одинаково действуют и для библио
15
15
  - **Запись цепочками**: `create`, `update` со слиянием, `upsert`, удаление с превью и каскадом, приращение `inc`
16
16
  для денег и счётчиков — [разделы 5](#5-запись), [7](#7-удаление-и-восстановление) и [8](#8-транзакции-батчи-и-деньги).
17
17
  - **Чтение цепочками**: переходы по связям, фильтры, деревья, агрегаты, постраничный вывод — [раздел 6](#6-чтение).
18
- - **История**: версии, срез на дату, восстановление удалённого, политика хранения, проверка цепочки хэшей —
19
- [раздел 11](#11-история-и-срезы-на-дату).
18
+ - **История**: версии, срез на дату, что изменилось и кто (`diff`, `blame`), восстановление удалённого, политика
19
+ хранения, проверка цепочки хэшей — [раздел 11](#11-история-и-срезы-на-дату).
20
20
  - **Арендаторы, вход и права** — в самой базе: пароли, ключи API, одноразовые коды, TOTP, сессии, имперсонация,
21
21
  правила доступа — [раздел 10](#10-пользователи-вход-и-права).
22
22
  - **Подписка на изменения** по курсору без потерь и **кэш результатов** — [раздел 12](#12-подписка-на-изменения-и-кэш).
23
+ - **Производные данные**: агрегаты в базе — суммы и счётчики, которые держит сама база, — и проекторы, которые строят
24
+ сводки из журнала, — [разделы 4](#агрегаты-в-базе) и [12](#проекторы).
25
+ - **Агенты и MCP**: сервер MCP даёт ИИ-агенту модель, чтение, историю и, если разрешено, запись — с правами одного
26
+ пользователя — [раздел 13](#агенты-и-mcp).
23
27
  - **Массовая загрузка** миллионов строк и **импорт из 0.21** — [разделы 9](#9-массовая-загрузка) и [14](#14-переход-с-021).
24
28
 
25
29
  Требования:
@@ -28,7 +32,8 @@ RLS. Правила одинаково действуют и для библио
28
32
  - Node.js 22 или новее; пакет — модуль ES.
29
33
  - Рантайм-зависимость одна — `postgres`. Zod 4 — необязательная peer-зависимость: он нужен только подпути
30
34
  `letopis/model`, а `import { connect } from 'letopis'` работает и без него. Для разработки без PostgreSQL —
31
- необязательные `@electric-sql/pglite` и `@electric-sql/pglite-socket`.
35
+ необязательные `@electric-sql/pglite` и `@electric-sql/pglite-socket`. Серверу MCP (`letopis/mcp` и
36
+ `letopis mcp`) нужна необязательная `@modelcontextprotocol/server` версии 2.4 или новее в линейке 2.
32
37
 
33
38
  ## Оглавление
34
39
 
@@ -152,8 +157,8 @@ await svc.close()
152
157
 
153
158
  Таблиц три. `entity` — текущее состояние: одна строка на каждую живую сущность, включая строки системных классов
154
159
  (`Class`, `Account`, `Resource`, `member`, `rule`). `log` — журнал: полный снимок каждой версии с автором,
155
- временем, видом операции и хэшем. `secret` — креды и сессии: приложение не видит в ней ни одной строки, с ней
156
- работают только функции входа.
160
+ временем, видом операции и хэшем. `secret` — креды, сессии и чекпоинты проекторов: приложение не видит в ней ни
161
+ одной строки, с ней работают только функции входа и проекторов.
157
162
 
158
163
  Классы — тоже сущности. Описание класса лежит в строке `Class`; там же база хранит развёрнутое описание с учётом
159
164
  предков и скомпилированную проверку данных. Хэш версии — генерируемая колонка: sha256 от хэша прошлой версии и
@@ -188,22 +193,26 @@ LOGIN-роль состоит только в ней. Всё, что выпол
188
193
 
189
194
  ### Сигналы
190
195
 
191
- Запись сообщает об изменениях через `NOTIFY` в канал схемы. Сырой SQL шлёт сигнал из триггера, и PostgreSQL доставляет
192
- его при фиксации. Транзакции библиотеки шлют сигналы отложенно: нотификатор процесса отправляет их раз в 100 мс
193
- (`notifyIntervalMs`), а неотправленные из-за ошибки — повторяет. Что несёт сигнал записи, задаёт режим установки
194
- `up({ signals })`:
196
+ Запись сообщает об изменениях через `NOTIFY`. Канал — полное имя схемы установки вместе с версией, например
197
+ `v2.shop`; из-за точки в SQL его имя берут в двойные кавычки: `LISTEN "v2.shop"`. Запись сырым SQL на своём
198
+ соединении шлёт сигнал из триггера, и PostgreSQL доставляет его при фиксации. Транзакции библиотеки, в том числе
199
+ `db.sql` и `db.transaction()`, шлют сигналы отложенно: нотификатор процесса отправляет их раз в 100 мс
200
+ (`notifyIntervalMs`), а неотправленные из-за ошибки — повторяет. Что несёт сигнал записи (его нагрузка, payload),
201
+ задаёт режим установки `up({ signals })`:
195
202
 
196
- | Режим | Сигнал записи | Что видит любая роль, которая может подключиться к базе |
203
+ | Режим | Нагрузка сигнала записи | Что видит любая роль, которая может подключиться к базе |
197
204
  |---|---|---|
198
205
  | `class` (по умолчанию) | имя класса: `Booking` | имена классов и время записи |
199
- | `tenant` | имя класса и токен арендатора: `Booking@3f9c…` | ещё и псевдонимы арендаторов |
200
- | `opaque` | токен класса и токен арендатора | только время и частоту записи по псевдонимам |
206
+ | `tenant` | `Класс@токен арендатора`: `Booking@3f9c…` | ещё и псевдонимы арендаторов |
207
+ | `opaque` | `токен класса@токен арендатора`: `9a1e…@3f9c…` | только время и частоту записи по псевдонимам |
201
208
  | `none` | сигналов нет | ничего |
202
209
 
203
- Погашенная сессия шлёт `session:<начало хэша токена>`, сброс — `*`; ни id, ни данных в сигналах нет. Токен — ключевой
204
- HMAC: сопоставить псевдоним с именем может только процесс с ключом сигналов, а ключ выдаётся сервисам (право
205
- `letopis.signals`). По сигналам перечитывается реестр классов, сбрасывается кэш результатов и будится подписка;
206
- что меняет режим для кэша и подписки — в [разделе 12](#12-подписка-на-изменения-и-кэш).
210
+ Токен — 24 шестнадцатеричных знака ключевого HMAC: сопоставить псевдоним с именем может только процесс с ключом
211
+ сигналов, а ключ выдаётся сервисам (право `letopis.signals`). Кроме сигналов записи, есть служебные: погашенная
212
+ сессия шлёт `session:<16 hex>` (первые 16 шестнадцатеричных знаков sha256 её токена), сброс сессий — `session:*`,
213
+ сброс арендатора или всей установки — `*` (сброс всей установки шлёт ещё и `session:*`). В режиме `none` нет и их.
214
+ Ни id, ни данных в сигналах нет. По сигналам перечитывается реестр классов, сбрасывается кэш результатов и будится
215
+ подписка; что меняет режим для кэша и подписки — в [разделе 12](#12-подписка-на-изменения-и-кэш).
207
216
 
208
217
  ## 3. Установка и подключение
209
218
 
@@ -260,8 +269,8 @@ const ANNA = r.tenant!.user! // его первый пользовател
260
269
  Приложение работает от LOGIN-роли, которая состоит только в `letopis_app`:
261
270
  `create role shop_app login password '…'; grant letopis_app to shop_app;`. Тогда на него действуют RLS и
262
271
  триггеры, как и на любой сырой SQL от этой роли. Роль, которая обходит RLS, `connect()` отклоняет с ошибкой
263
- `bypass_rls`. Администраторское подключение (`allowBypassRls: true`) нужно только загрузчику, проверке и
264
- обслуживанию; держите его в отдельном процессе, а не в веб-приложении.
272
+ `bypass_rls`. Администраторское подключение (`allowBypassRls: true`) нужно только загрузчику, проекторам, ремонту
273
+ агрегатов (`rebuildAggregates`), проверке и обслуживанию; держите его в отдельном процессе, а не в веб-приложении.
265
274
 
266
275
  ### Первый арендатор и пользователь
267
276
 
@@ -333,7 +342,8 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
333
342
 
334
343
  ### Администраторское подключение
335
344
 
336
- Загрузчику нужно подключение роли, которая может стать владельцем, с `allowBypassRls: true`. `verify()` и
345
+ Загрузчику, проекторам и `rebuildAggregates` нужно подключение роли, которая может стать владельцем, с
346
+ `allowBypassRls: true`. `verify()` и
337
347
  `maintain()` работают и на нём, и у системного администратора по обычному подключению. Администраторское
338
348
  подключение печатает предупреждение «RLS не действует» — так и должно быть. Сессия задаёт арендатора, с которым
339
349
  оно работает. Отдельному процессу загрузки удобнее ключ сервиса: `connect({ dsn: ADMIN, schema, apiKey,
@@ -346,12 +356,18 @@ const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token
346
356
 
347
357
  ### Обновление пакета и схемы
348
358
 
349
- У схемы есть ревизия движка (сейчас 5). Если новая версия пакета принесла новую ревизию, `connect()` только
359
+ У схемы есть ревизия движка (сейчас 6). Если новая версия пакета принесла новую ревизию, `connect()` только
350
360
  предупредит и назовёт команду. Отказывает `up()`: на отставшую схему без `upgrade: true` и на схему новее пакета.
351
361
  Поэтому скрипт запуска, который вызывает `up()` при каждом старте, после обновления пакета остановится, пока не
352
362
  получит `upgrade: true`. `up({ dsn, schema, upgrade: true })` применяет миграции из `sql/migrate/` и повторно
353
363
  накатывает функции; данные сохраняются.
354
364
 
365
+ Первый `up()` после перехода на 1.2 строит индекс журнала `log_class_tenant_at (class, tenant, at)` вместо
366
+ `log_class_at`: срез `asOf` и `withDeleted` по классу больше не читает журнал других арендаторов с тем же именем
367
+ класса. Пока индекс строится, запись в журнал ждёт. На большой установке постройте его заранее, не останавливая
368
+ записи: `create index concurrently log_class_tenant_at on "v2.<схема>".log (class, tenant, at)` от роли владельца, —
369
+ тогда `up()` только снимет прежний.
370
+
355
371
  ### Разработка без PostgreSQL: PGlite
356
372
 
357
373
  `up({ pglite: true })` поднимает PGlite (PostgreSQL 18 на WebAssembly) с pgcrypto и сокетом на свободном порту.
@@ -478,11 +494,68 @@ id из email подбирается перебором и остаётся в
478
494
 
479
495
  - `history: false` — журнал для класса не ведётся: нет версий, среза на дату, восстановления и событий подписки.
480
496
  Политика хранения — `history: { all: '1 day', daily: '3 months', … }` ([раздел 11](#11-история-и-срезы-на-дату)).
497
+ Сменить `history` у существующего класса может только тот, у кого есть право `READ` и `DELETE` без условий на все
498
+ строки класса и его потомков: по политике `maintain()` прореживает журнал, а `false` его выключает.
481
499
  - `ownerDefault: 'actor'` — владелец новых строк — сам пользователь, а не арендатор (по умолчанию `'tenant'`).
482
500
  Пригодится для правила «каждый видит свои записи».
483
501
  - `cache: { ttl }` — чтения только этого класса кэшируются без `.cache()` ([раздел 12](#12-подписка-на-изменения-и-кэш)).
484
502
  - `meta` — свободные метаданные для приложения: название, иконка, цвет, порядок в меню. До 64 КБ, наследуются по
485
503
  ключам (`null` убирает унаследованное значение), видны всем, у кого есть сессия. У конца — `.meta()`.
504
+ - `derivedBy: 'имя-проектора'` — производный класс: его строки пишет только этот проектор ([«Проекторы»](#проекторы)).
505
+ Имя — строчные латинские буквы, цифры и `_.:-` (первый знак — буква или цифра), до 64 символов. Опция
506
+ наследуется, потомок её не меняет. Концы производного класса — только `onDelete: 'cascade'` или `'unset'`:
507
+ производная строка не мешает удалять цель. На строки производного класса не ссылаются. Приложение, сырой SQL и
508
+ загрузчик в такой класс не пишут: запись, правка и удаление его строк — `derived_class`. Сам производный класс
509
+ (свой или унаследованный) заводит, меняет и удаляет только администратор — администраторское подключение или
510
+ аккаунт с правом System `letopis.projector`; у сессии приложения это `acl_denied` ([«Проекторы»](#проекторы)).
511
+ - `aggregate: { поле: { sum | count, of, via, where } }` — агрегат в базе: сумму или счётчик строк источника держит
512
+ сама база ([«Агрегаты в базе»](#агрегаты-в-базе)).
513
+
514
+ ### Агрегаты в базе
515
+
516
+ Агрегат — поле, которое держит сама база: сумма поля или число живых строк класса-источника, чей конец ведёт на
517
+ строку. Он нужен там, где итог обязан совпадать с источником и для сырого SQL: баланс кошелька, число позиций заказа.
518
+
519
+ ```ts
520
+ const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0), deposits: z.number().int().optional() }, {
521
+ key: ['number'],
522
+ aggregate: {
523
+ balance: { sum: 'amount', of: 'Movement', via: 'Wallet' }, // сумма операций
524
+ deposits: { count: true, of: 'Movement', via: 'Wallet', where: { properties: { amount: { minimum: 1 } } } }, // число пополнений
525
+ },
526
+ })
527
+ const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet } })
528
+ ```
529
+
530
+ - `sum: 'поле'` — сумма числового поля источника, `count: true` — число строк. `of` — класс-источник (имя или
531
+ модель) вместе с потомками, `via` — его одиночный конец, который ведёт на строку-цель. `where` — JSON Schema над
532
+ данными источника: строки, которые её не проходят, в агрегат не входят.
533
+ - Считаются только живые строки того же арендатора, что и цель. Ссылка на строку System в агрегат не входит: общий
534
+ справочник не считает данные всех арендаторов. Агрегат класса арендатора, в том числе унаследованный от класса
535
+ System, считает только классы своего арендатора: класс System с именем `of` в него не входит, а свой потомок класса
536
+ System входит. Агрегат класса System считает классы System и классы арендатора строки-цели.
537
+ - Поле пересчитывает триггер после каждого оператора: запись библиотеки, сырой SQL, каскад удаления, `restore`,
538
+ `reclass`, `rekey`, загрузчик, удаление аккаунта. Операции одного оператора складываются в одно приращение цели, а
539
+ правило класса (`min(0)`) проверяется над итогом: операция, которая увела бы баланс в минус, отклоняется с
540
+ `invalid_data`. Каждое изменение цели — её новая версия с автором и причиной операции.
541
+ - Поле пишет только база. Значение в `create`, `update`, `upsert`, `rekey`, `reclass` или в сыром SQL — ошибка
542
+ `aggregate_field`. Новая строка получает итог уже существующих источников, обычно 0. Замена данных целиком без
543
+ поля сохраняет прежнее значение. Загрузчик переданное значение не пишет. Права на запись в цель не нужно:
544
+ достаточно права на запись в источник.
545
+ - Описание проверяет база (`invalid_class`): поле есть в схеме, оно числовое и не входит в ключ; конец `via` —
546
+ одиночный и принимает класс цели; поле `sum` — число; агрегаты не образуют цикл; цель и источник — не системные и
547
+ не производные классы. Источник, описанный позже цели, сверяется при появлении.
548
+ - Строки попадают в агрегат только с ведома того, кто читает их все: поле считает и строки, которые автору не видны.
549
+ Описать или изменить агрегат может только тот, кто без условий читает все строки источника и его потомков. Сделать
550
+ класс источником (новый класс, смена `parent`, источник после цели) может только тот, кто без условий читает все
551
+ строки этого класса и его потомков — и до правки, и после. Иначе `acl_denied`.
552
+ - Опция наследуется, потомок заменяет её целиком. Добавить или изменить агрегат — ужесточение: запись в цель и в
553
+ источники ждёт, пока поле пересчитывается у всех строк.
554
+ - `db.rebuildAggregates(класс)` пересчитывает поле из текущих строк — для ремонта. Версия пишется только там, где
555
+ значение изменилось. Расхождения показывает `db.verify({ data: true })`. Нужно администраторское подключение.
556
+ - Сырой `INSERT … ON CONFLICT DO UPDATE SET data = excluded.data` для цели отклоняется: поле в `excluded.data` уже
557
+ поставил триггер вставки. Пишите `excluded.data - array['balance']`, и поле останется прежним; `upsert` библиотеки
558
+ делает так сам.
486
559
 
487
560
  ### Записать модель в базу
488
561
 
@@ -620,6 +693,13 @@ await db.Product(kettle).update({ views: inc(1, { start: 0 }) }).rows() // п
620
693
  - маркер `inc` нельзя получить из JSON: `{ "$inc": 5 }`, пришедший из сети, остаётся данными;
621
694
  - деньги храните целым числом мелких единиц (копеек) не больше 2^53 − 1.
622
695
 
696
+ Счётчик, который обязан совпадать с источником и для сырого SQL, удобнее держать агрегатом
697
+ ([«Агрегаты в базе»](#агрегаты-в-базе)): его пересчитывает сама база, и забыть `inc` нельзя. Цена у агрегата и `inc`
698
+ одна: операции одного объекта идут друг за другом под блокировкой его строки, и каждая пишет его версию. На машине
699
+ замеров один объект так принимал 150–190 операций в секунду, сколько бы писателей его ни ждало
700
+ ([раздел 17](#17-производительность)). Для более горячих счётчиков берите проектор со свёрткой пачкой
701
+ ([«Проекторы»](#проекторы)): итог отстаёт от источника, зато писатели не ждут друг друга.
702
+
623
703
  ### Защита от параллельной правки: `{ rev }`
624
704
 
625
705
  <!-- run: guide -->
@@ -650,9 +730,12 @@ const [vip] = await db.Customer(olga).reclass('Vip', { name: 'Ольга', email
650
730
  - `rekey(патч)` — новое значение ключа: строка с новым id, все ссылки переведены, старая строка удалена. Если
651
731
  ссылающаяся строка включает этот конец в свой ключ, её id тоже меняется, и перевод идёт дальше. Журнал связывает
652
732
  старый и новый id (`moved`).
653
- - `db.rekeyClass(класс, ключ)` — новый ключ для всех строк семейства, одной транзакцией.
654
- - `reclass(класс, данные)` — смена класса; данные необязательны, а если переданы — задаются целиком. Если у нового
655
- класса нет ключа, id сохраняется (как у `vip` выше); если есть — id вычисляется заново, а ссылки переводятся.
733
+ - `db.rekeyClass(класс, ключ)` — новый ключ для всех строк семейства, одной транзакцией. У производного класса и
734
+ у класса с производным потомком — `derived_class`: строки производного класса пишет только проектор.
735
+ - `reclass(класс, данные)` — смена класса; данные необязательны, а если переданы — задаются целиком. Строка уходит из
736
+ прежнего класса, поэтому нужно право `DELETE` на неё, `WRITE` — на новый класс и на каждую строку, чьи ссылки
737
+ переписывает перевод, на любой глубине. Если у нового класса нет ключа, id сохраняется (как у `vip` выше); если
738
+ есть — id вычисляется заново, а ссылки переводятся.
656
739
  Роли ссылающихся строк должны принимать новый класс, иначе `reclass_denied`. Сырой `UPDATE … SET class`
657
740
  проходит, только если ключа нет ни у старого, ни у нового класса (иначе `use_reclass`).
658
741
  - Удаление, смена класса и смена ключа работают только в `read committed`: транзакции библиотеки всегда открываются
@@ -801,7 +884,8 @@ const avgPrice = await db.Shop(shop).Product().avg('data.price')
801
884
  const byStatus = await db.Order().countBy('data.status') // { new: 1 }
802
885
  ```
803
886
 
804
- Агрегаты считает база, по сущностям последнего шага.
887
+ Агрегаты считает база, по сущностям последнего шага. `limit(n)` и `offset(n)` перед `countBy()` относятся к группам:
888
+ `db.Order().limit(3).countBy('data.status')` — три самых частых значения.
805
889
 
806
890
  ### Пути
807
891
 
@@ -868,8 +952,11 @@ await db.Customer(temp).purge({ confirm: true }).rows() // история
868
952
  ```
869
953
 
870
954
  `purge` работает только для удалённых объектов (живой — `not_deleted`) и стирает их историю целиком; на месте
871
- версии 1 остаётся запись `purge` с числом стёртых версий. История системных классов не стирается. Удалить аккаунт —
872
- `db.auth.purgeAccount` ([раздел 10](#10-пользователи-вход-и-права)).
955
+ версии 1 остаётся запись `purge` с числом стёртых версий. История системных классов не стирается. Пока проектор,
956
+ чьи источники `from` включают класс объекта ([«Проекторы»](#проекторы)), не прочёл удаление, `purge` отказывает
957
+ (`projector_pending`): стёртое надгробие проектор не увидел бы, и вклад объекта остался бы в итогах. Удаление и
958
+ `purge` одной транзакцией в таком классе поэтому не проходят. Удалить аккаунт — `db.auth.purgeAccount`
959
+ ([раздел 10](#10-пользователи-вход-и-права)).
873
960
 
874
961
  ## 8. Транзакции, батчи и деньги
875
962
 
@@ -1011,6 +1098,65 @@ try {
1011
1098
  - Перевод — две операции в одной `db.begin()`, кошельки правятся в порядке id: тогда взаимных блокировок нет.
1012
1099
  - Нехватку средств отличают от других ошибок данных по `e.issues`: поле `balance`, правило `minimum`.
1013
1100
 
1101
+ ### Рецепт: баланс агрегатом
1102
+
1103
+ Тот же кошелёк, только баланс держит сама база: `balance` — агрегат, сумма `amount` живых операций
1104
+ ([«Агрегаты в базе»](#агрегаты-в-базе)). Операция — одна запись `Movement`, приращения `inc` в коде нет, и забыть его
1105
+ нельзя. Баланс остаётся верным, даже когда операции пишет сырой SQL или загрузчик, а удаляет каскад.
1106
+
1107
+ ```ts
1108
+ const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0) }, {
1109
+ ends: { Customer }, key: ['number'],
1110
+ aggregate: { balance: { sum: 'amount', of: 'Movement', via: 'Wallet' } }, // баланс — сумма живых операций
1111
+ })
1112
+ const Movement = link('Movement', { opId: z.string(), amount: z.number().int() },
1113
+ { ends: { Wallet }, key: ['Wallet', 'opId'] }) // ключ операции — идемпотентность
1114
+
1115
+ /** Операция: amount больше нуля — пополнение, меньше — списание. База пересчитает баланс в той же транзакции. */
1116
+ async function operate(db: Db, wallet: string, opId: string, amount: number) {
1117
+ try {
1118
+ await db.Wallet(wallet).Movement().create({ opId, amount }).rows()
1119
+ return 'done'
1120
+ } catch (e) {
1121
+ if (e instanceof LetopisError && e.code === 'exists') return 'repeat' // повтор после потери ответа
1122
+ throw e // invalid_data — баланс ушёл бы в минус (min(0))
1123
+ }
1124
+ }
1125
+
1126
+ /** Корректировка «баланс = target»: операция на разницу, кошелёк заблокирован до commit. */
1127
+ async function adjust(db: Db, wallet: string, opId: string, target: number) {
1128
+ const tx = await db.begin()
1129
+ try {
1130
+ const w = (await tx.Wallet(wallet).forUpdate().first())!
1131
+ await tx.Wallet(wallet).Movement().create({ opId, amount: target - (w.data.balance as number) }).rows()
1132
+ await tx.commit()
1133
+ return 'done'
1134
+ } catch (e) {
1135
+ await tx.rollback()
1136
+ if (e instanceof LetopisError && e.code === 'exists') return 'repeat' // корректировка с этим opId уже записана
1137
+ throw e
1138
+ }
1139
+ }
1140
+
1141
+ const [wallet] = await db.Customer(olga).Wallet().create({ number: 'W-1' }).rows() // balance — 0: его ставит база
1142
+ await operate(db, wallet.id, 'op-1', 10_000) // баланс 10 000
1143
+ await operate(db, wallet.id, 'op-2', -3_000) // баланс 7 000
1144
+ await adjust(db, wallet.id, 'op-3', 5_000) // операция на −2 000, баланс 5 000
1145
+ ```
1146
+
1147
+ - Модель здесь своя, и блок в программу примера не входит: там баланс растёт через `inc`, а агрегатное поле так не
1148
+ меняется.
1149
+ - `balance` не передают ни в `create`, ни в `update` (`aggregate_field`), поэтому корректировка — тоже операция, а не
1150
+ `update` под `{ rev }`. Чтобы записать операцию, право на запись в кошелёк не нужно: хватает права на запись в
1151
+ `Movement`.
1152
+ - Нехватка средств — `invalid_data`, как и с `inc`: в `e.issues` поле `balance` и правило `minimum`. Повтор сверяйте,
1153
+ как `same()` в рецепте выше: `exists` говорит только о том, что операция с этим `opId` уже есть.
1154
+ - Правка и удаление операции тоже меняют баланс: база вычтет прежнее значение. Отменять операцию всё равно лучше
1155
+ встречной операцией, тогда история кошелька остаётся полной.
1156
+ - Каждая операция блокирует строку кошелька и пишет его версию, поэтому операции одного кошелька идут друг за другом,
1157
+ как и с `inc`: на машине замеров — около 150 в секунду ([раздел 17](#17-производительность)). Если один объект
1158
+ получает больше, итог лучше считать проектором ([«Рецепт: дневная выручка»](#рецепт-дневная-выручка)).
1159
+
1014
1160
  ## 9. Массовая загрузка
1015
1161
 
1016
1162
  ### Когда нужен загрузчик
@@ -1113,7 +1259,8 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
1113
1259
  `issues`), `total` — сколько нарушений найдено, `truncated` — список обрезан, `limit` — предел списка (100),
1114
1260
  `complete` — проверки, нашедшие нарушения, прошли все строки. Коды строк: коды проверок базы, а также `duplicate`
1115
1261
  (повтор id), `exists` (объект уже есть), `deleted` (объект с этим id жил раньше) и `class_not_loadable` (строки
1116
- `Class` загрузчик не принимает: классы задаёт `syncModels`).
1262
+ `Class` загрузчик не принимает: классы задаёт `syncModels`). Строки производных классов и ссылки на них загрузчик
1263
+ отклоняет с кодом `derived_class`: такие строки пишет только проектор.
1117
1264
 
1118
1265
  Нарушения ищутся поэтапно: разбор строк (классы, id, повторы), права на классы (`load({ as })`), данные и концы
1119
1266
  пачками, в конце порции — цели вне загрузки, владельцы и арендатор. Загрузка отменяется после первого этапа,
@@ -1268,6 +1415,24 @@ await anna.auth.switch(SHOP) // арендатор сессии
1268
1415
  арендатора действуют на его строки, правила System — везде. Права действуют и на сырой SQL: строки без права не
1269
1416
  видны, правка — пусто или `acl_denied`.
1270
1417
 
1418
+ Маска класса в объекте `READ`, `WRITE` или `DELETE` совпадает и с потомками класса — по именам предков и их алиасам.
1419
+ Поэтому правка класса, которая меняет `parent`, `alias` или `derivedBy`, требует права `READ`, `WRITE` и `DELETE` без
1420
+ условий на все строки класса и каждого его потомка, иначе `acl_denied`: одного права `WRITE` на `Class` мало, чтобы
1421
+ подставить строки класса под чужие правила. Алиас не совпадает с именем другого класса (в том числе `Class` и
1422
+ системных) и с чужим алиасом арендатора и System, а имя нового класса — с чужим алиасом: иначе правило, написанное
1423
+ для одного класса, действовало бы и на строки другого. Нарушение — `invalid_class`.
1424
+
1425
+ По тем же именам правила находят и старые версии строк в журнале. Поэтому класс удаляется, только когда у него нет
1426
+ строк, потомков и истории строк: историю удалённых строк сначала стирает `purge()`. Класс, удалённый с историей
1427
+ раньше, создаётся заново только с прежними `parent`, `alias` и `derivedBy` — иначе `invalid_class`: старые версии его
1428
+ строк попали бы под чужие правила. Если журнал хранит строки класса с этим именем, но не его надгробие (например,
1429
+ строки класса System, удалённого в 1.1), класс с этим именем не создаётся, пока историю не сотрёт `purge()`.
1430
+
1431
+ Производный класс (`derivedBy`) заводит, меняет и удаляет только администратор: администраторское подключение или
1432
+ аккаунт с правом System `letopis.projector` (у системного администратора оно есть по маске `letopis.*`). Проектор
1433
+ пишет в такой класс итоги по всем строкам источников арендатора, без прав чтения, поэтому пользователь, заведя его
1434
+ сам, читал бы итоги по строкам, которые ему читать запрещено.
1435
+
1271
1436
  ### Выдать право
1272
1437
 
1273
1438
  Пример: Олег — сотрудник магазина с ролью `staff`, и сотрудникам нельзя удалять товары.
@@ -1336,6 +1501,10 @@ const followed = await db.Product(mug2).versions({ follow: true }) // и чер
1336
1501
  `delete({ confirm, reason })` и других), для всех операций блока — `db.begin({ reason })` и
1337
1502
  `db.batch(имя, { reason })`; сырой SQL — настройка `letopis.reason` транзакции. Версия удаления — с `$deleted`.
1338
1503
 
1504
+ Без `sort()` версии идут по id и номеру версии (с `follow` — по времени). `sort()` задаёт порядок и версиям, а
1505
+ `limit()` ограничивает их в самом запросе: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` — пять
1506
+ последних версий, новые первыми.
1507
+
1339
1508
  ### Срез на дату
1340
1509
 
1341
1510
  <!-- run: guide -->
@@ -1389,14 +1558,53 @@ const [b] = await db.Order(id).blame() // каждое по
1389
1558
  await db.Order(id).blame({ at: '2026-10-01', limit: 200 }) // версия на момент; просмотреть 200 версий
1390
1559
  ```
1391
1560
 
1392
- - `diff()` — по объекту шага: `{ id, class, from, to, changes }`, где изменение — `{ path, kind, before, after }`,
1393
- `kind` — `added`, `removed` или `changed`, а путь начинается с раздела строки: `data`, `links`, `tags`, `owner`,
1394
- `class`. Объекты данных сравниваются по ключам, массивы — целиком; множественный конец и теги — по элементам.
1395
- Если на момент `from` объекта не было, все пути — `added`; если на момент `to` он удалён — `to.deleted: true`.
1396
- - `blame()` — `{ id, class, rev, complete, fields }`: у каждого листа — версия, которая последней его изменила,
1397
- `author`, `agent`, `op`, `reason` и `exact`. `exact: false` — между текущей версией и этой есть пропуски (история
1398
- прорежена, обрезана или часть версий не видна): лист мог менять и выпавшая версия. `complete: true` — история
1399
- просмотрена целиком и без пропусков; по умолчанию просматривается не больше 1000 последних версий.
1561
+ Оба терминала отдают массив — по элементу на объект шага. Так выглядят ответы для чайника, которому снизили цену с
1562
+ причиной:
1563
+
1564
+ ```ts
1565
+ import type { BlameResult, DiffResult } from 'letopis'
1566
+
1567
+ // db.Product(kettle).blame(): цену изменила версия 2, остальные листья — с создания (версия 1)
1568
+ const blamed: BlameResult = {
1569
+ id: '6f1c…', class: 'Product', rev: 2, complete: true,
1570
+ fields: [ // по порядку пути
1571
+ { path: ['class'], rev: 1, at: '2026-10-27T09:15:02.118204+00:00', author: '<id Анны>', agent: '<id сервиса>',
1572
+ op: 'create', exact: true },
1573
+ { path: ['data', 'price'], rev: 2, at: '2026-10-27T09:20:41.502617+00:00', author: '<id Анны>', agent: '<id сервиса>',
1574
+ op: 'update', reason: 'скидка к акции', exact: true },
1575
+ // ['data', 'sku'], ['data', 'title'], ['links', 'Shop'], ['owner'] — тоже версия 1, как ['class']
1576
+ ],
1577
+ }
1578
+ // db.Product(kettle).diff(): предыдущая версия → последняя
1579
+ const diffed: DiffResult = {
1580
+ id: '6f1c…', class: 'Product',
1581
+ from: { rev: 1, at: '2026-10-27T09:15:02.118204+00:00' },
1582
+ to: { rev: 2, at: '2026-10-27T09:20:41.502617+00:00' },
1583
+ changes: [{ path: ['data', 'price'], kind: 'changed', before: 2500, after: 2000 }],
1584
+ }
1585
+ ```
1586
+
1587
+ `agent` здесь — сервис, который пишет от имени Анны (`db = await svc.as(…)`); у записи без имперсонации его нет.
1588
+
1589
+ - `diff()` — массив `DiffResult`: `{ id, class, from, to, changes }`. `from` и `to` — сравниваемые версии
1590
+ `{ rev, at }`. В них есть `id`, если версия принадлежит другому id (`follow: true` через `reclass` или `rekey`), а у
1591
+ `to` — `deleted: true`, если на этот момент объект удалён; тогда `changes` пуст. `from` равен `null`, если объекта
1592
+ тогда ещё не было: все пути — `added`. Если версии нет вовсе, `class` и `to` тоже `null`.
1593
+ - Изменение `DiffChange` — `{ path, kind, before, after }`. `path` — массив сегментов, а не строка с точками; первый
1594
+ сегмент — раздел строки: `data`, `links`, `tags`, `owner`, `class`. `kind` — `added` (значения не было), `removed`
1595
+ (не стало) или `changed`. Пустое значение опускается: у `added` нет `before`, у `removed` — `after`. Объекты данных
1596
+ сравниваются по ключам, массивы — целиком. Множественный конец и теги — множества: по изменению на каждый id или
1597
+ тег, путь — `['links', роль]` или `['tags']`, а сам id или тег — в `before` или `after`.
1598
+ - `blame()` — массив `BlameResult`: `{ id, class, rev, complete, fields }` и `deleted: true`, если эта версия —
1599
+ надгробие. Объект без версий на этот момент элемента не даёт. `fields` — массив `BlameField`, по элементу на лист
1600
+ версии, упорядоченный по пути: `{ path, rev, at, author, op, exact }` и, если есть, `agent`, `reason`, `id`. Лист —
1601
+ скаляр или массив в данных, пустой объект, роль одиночного конца, каждый id множественного конца
1602
+ (`['links', роль, id]`), каждый тег (`['tags', тег]`), `owner` и `class`. `rev`, `at`, `author`, `agent`, `op` и
1603
+ `reason` — от версии, которая последней изменила лист. Пустые `agent` и `reason` опускаются; `id` есть, только если
1604
+ это версия прежнего id (история прошла через `reclass` или `rekey`).
1605
+ - `exact: false` — между текущей версией и найденной есть пропуски (история прорежена, обрезана или часть версий не
1606
+ видна): лист мог менять и выпавшая версия. `complete: true` — история просмотрена целиком и без пропусков; по
1607
+ умолчанию просматривается не больше 1000 последних версий.
1400
1608
  - Считает база с правами вызывающего: сырой SQL получает то же — `select "v2.shop".blame('<id>')`,
1401
1609
  `select "v2.shop".diff_versions('<id>')`.
1402
1610
  - Колонка журнала `changed` (пути изменения версии, по ним `blame` работает быстро) приложению не выдана: пути
@@ -1444,7 +1652,10 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
1444
1652
 
1445
1653
  - `from` — `'now'` (по умолчанию: только изменения после подписки), `'start'` (весь журнал) или сохранённый курсор.
1446
1654
  `db.watch()` возвращается сразу, а момент «now» подписка берёт первым запросом к базе: запись, сделанная сразу
1447
- после `db.watch()`, может зафиксироваться раньше и в поток не попасть. Поэтому пример выше читает с `'start'`.
1655
+ после `db.watch()`, может зафиксироваться раньше и в поток не попасть. `await sub.ready()` дожидается этого момента:
1656
+ запись после него в поток придёт. Пример выше читает с `'start'`.
1657
+ - Вызовы `sub.next()` идут по очереди: параллельные получают разные события. Вызов, брошенный по таймауту
1658
+ (`Promise.race`), всё равно получит своё событие — держите его обещание и ждите его снова.
1448
1659
  - `classes` — только эти классы с потомками; `pollMs` — запасной опрос (1000 мс).
1449
1660
  - События идут без потерь и повторов, под правами читателя, в порядке номеров транзакций, а внутри транзакции — в
1450
1661
  порядке записи. Это не порядок фиксации: версии одного объекта сверяйте по `rev`.
@@ -1455,6 +1666,198 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
1455
1666
  - Подписка без потерь — это сохранённый курсор: храните `ev.cursor` последнего обработанного события и продолжайте
1456
1667
  с него после перезапуска.
1457
1668
 
1669
+ ### Проекторы
1670
+
1671
+ Проектор строит строки производных классов из журнала: сводки, денормализованные карточки и всё, что можно считать
1672
+ с отставанием от записи. Классы-источники (`from`) пишет приложение, а строки классов `into` — только проектор.
1673
+ Каждый класс `into` помечен опцией `derivedBy` с именем проектора ([«Настройки класса»](#настройки-класса)).
1674
+ Производные классы заводит только администратор: синхронизируйте их администраторским подключением с сессией
1675
+ арендатора — `adm` из раздела 3 (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или `as()` сервиса
1676
+ на таком подключении, в CLI — `letopis sync` с `--dsn` роли администратора. У сессии приложения это `acl_denied`. В примере ниже проектор
1677
+ считает продажи товаров по строкам заказов `line`.
1678
+
1679
+ ```ts
1680
+ const ProductSales = hub('ProductSales', { qty: z.number().int(), revenue: z.number().int() }, {
1681
+ ends: { Product: one(Product).onDelete('cascade') }, key: ['Product'], derivedBy: 'product-sales', history: false,
1682
+ }) // производный класс — в модели; syncModels — администраторским подключением
1683
+
1684
+ const sales = adm.projector('product-sales', { // adm — администраторское подключение
1685
+ from: ['line'], // классы-источники с потомками; у них должен быть журнал
1686
+ into: ['ProductSales'], // производные классы этого проектора
1687
+ version: 1, // логика handle изменилась — новая версия и перестройка
1688
+ withBefore: true, // c.before — прошлая версия объекта или null
1689
+ handle(changes, out) {
1690
+ for (const c of changes) {
1691
+ if (c.op === 'purge' || c.op === 'trim' || c.op === 'reset') continue // служебные изменения, не версии
1692
+ const was = c.before // прошлая версия строки заказа — вычесть
1693
+ const now = c.row.$deleted ? null : c.row // новая версия — прибавить; у удаления её нет
1694
+ if (was) out.inc('ProductSales', { Product: was.links.Product },
1695
+ { qty: -Number(was.data.qty), revenue: -Number(was.data.qty) * Number(was.data.price) }, { tenant: c.tenant })
1696
+ if (now) out.inc('ProductSales', { Product: now.links.Product },
1697
+ { qty: Number(now.data.qty), revenue: Number(now.data.qty) * Number(now.data.price) }, { tenant: c.tenant })
1698
+ }
1699
+ },
1700
+ })
1701
+ const report = await sales.run({ once: true }) // догнать журнал: { batches, changes, created, updated, … }
1702
+ const state = await sales.status() // { name, state, version, cursor, at, lag, failed, rebuildingTenants }
1703
+ await sales.rebuild() // перестроить с текущего состояния; { chunked: true } — пачками
1704
+ await sales.rebuild({ tenant: tenantId }) // перестроить одного арендатора — например, выбывшего (failed)
1705
+ await sales.rebuild({ tenant: tenantId, chunked: true }) // его же пачками: хвост журнала тем временем идёт для всех
1706
+ sales.run().catch((err) => console.error('проектор остановлен', err)) // следовать за журналом до stop()
1707
+ sales.close() // stop() после текущей пачки и отписка от сигналов
1708
+ ```
1709
+
1710
+ - Проектору нужно администраторское подключение: `connect({ allowBypassRls: true })` роли, которая может стать
1711
+ владельцем схемы. Иначе — `admin_required`, внутри `db.begin()` — `invalid_query`. Проектор читает журнал всех
1712
+ арендаторов.
1713
+ - Первый запуск `run()` перестраивает проекцию с текущего состояния: журнал прорежен политикой хранения и обрезан
1714
+ `purge`, поэтому собрать проекцию с начала журнала нельзя.
1715
+ - Каждая пачка — одна транзакция: изменения журнала, `handle`, запись результатов и сдвиг курсора фиксируются
1716
+ вместе. Обрыв посреди пачки откатывает её целиком, а повтор начинается с того же курсора, поэтому каждое
1717
+ изменение применяется ровно один раз. Писатель у проектора один: второй процесс того же проектора пропускает пачку
1718
+ (счётчик `skipped` в отчёте), а `rebuild()`, начатая, пока другой процесс пишет пачку, — `projector_busy`.
1719
+ - Пачка делится по арендаторам: `handle` получает изменения одного арендатора, и запись его результатов идёт под
1720
+ своей точкой сохранения. Если база отклоняет запись результатов арендатора (данные не проходят описание его
1721
+ производного класса, цель конца не найдена и т. п.) или `out` отклоняет строку или приращение, построенные по его
1722
+ данным (приращение — не число), откатываются только его изменения: арендатор выбывает, а остальные записываются в
1723
+ той же пачке. Выбывший попадает в `status().failed` — `{ [id арендатора]: { cursor, code, message } }`, `message`
1724
+ называет арендатора и id строки, — и его изменения пропускаются, пока его не перестроят. Производные строки
1725
+ выбывшего больше не меняются: выбывший в `run()` сохраняет их такими, какими они были до пачки с ошибкой, а выбывший
1726
+ посреди перестройки остаётся без них — частично построенные строки удаляются. Исключение из `handle`, нарушение
1727
+ границ `out` и сбой окружения (взаимная блокировка, отмена запроса, обрыв связи) откатывают пачку целиком, а
1728
+ `run()` отклоняется. Поэтому `run()` без `once` ловите (`catch`): после ошибки он остановлен.
1729
+ - Изменение (`ProjectorChange`) — это `cursor`, `id`, `class`, `tenant`, `op`, `rev`, `at`, `author`, `agent`,
1730
+ `reason`, `moved` и `row` — версия из журнала (у удаления — надгробие с `$deleted`). При перестройке изменения —
1731
+ строки текущего состояния: `rebuild: true`, `op` — `create`, `cursor` пустой.
1732
+ - Смена класса без смены id (у классов без ключа: сырой `UPDATE … SET class` и `reclass()`) журнал пишет версией уже
1733
+ нового класса. Если она уводит строку из семейства `from`, проектор получает удаление: `op` — `delete`, `row` —
1734
+ последняя версия строки в семействе с `$deleted`, `rev`, `at` и автор — смены класса. Строка, вошедшая так в
1735
+ семейство, приходит изменением `reclass` с `before` = `null`.
1736
+ - С `withBefore` у изменения есть `before` — прошлая версия того же id. `before` равен `null`, если прошлой версии нет
1737
+ (создание и первая версия нового id после `reclass` и `rekey`), если прошлая версия — удаление (так бывает после
1738
+ `restore`) или вне семейства `from`, и у всех изменений перестройки. При `reclass` и `rekey` прежние данные
1739
+ вычитает надгробие прежнего id: оно приходит изменением с `$deleted`, его `before` — последняя версия прежнего id,
1740
+ а `moved` обоих изменений указывает на другой id. Поэтому свёртка из примера после `rekey` даёт ту же сумму. У
1741
+ служебных изменений `purge`, `trim` и `reset` поля `before` нет совсем. Поэтому вычитайте `before`, только проверив
1742
+ его на `null`, как в примере выше.
1743
+ - Служебные записи журнала `purge`, `trim` и `reset` тоже приходят изменениями, и у них `rev` равен 1. Поэтому
1744
+ сначала разберите `op`, а уже потом сравнивайте `rev`.
1745
+ - `out.upsert(класс, строка, { tenant })` создаёт строку или заменяет её целиком по ключу класса.
1746
+ `out.inc(класс, строка, приращения, { tenant })` создаёт строку или дополняет её и прибавляет числа, не читая
1747
+ строку (поля, которых нет, считаются от 0). `out.delete(класс, ключ или id, { tenant })` удаляет строку, а если её
1748
+ нет, ничего не делает. `await out.get(класс, ключ или id, { tenant })` читает текущую строку вместе с записями
1749
+ пачки. `out` пишет только в классы `into` и только в арендатора изменений, переданных в `handle`; иначе —
1750
+ `derived_class` или `tenant_denied`, и пачка откатывается.
1751
+ - Приращения строки, заданной одними полями ключа (или одним `$id`), `out` складывает до отправки в базу, пока между
1752
+ ними нет `upsert`, `delete` или `get`: на строку — одна правка за пачку, а правило класса проверяется над итогом её
1753
+ приращений. Строку с другими полями (данными, `$tags`, `$owner`) `inc` не складывает: её поля правят строку, и
1754
+ порядок таких правок сохраняется.
1755
+ - Строка для `out` — в формате загрузчика: поля и роли концов на верхнем уровне, служебные `$tags`, `$owner` и
1756
+ `$id` (`$id` нужен только классу без ключа). Значение роли — id цели. Правка, которая ничего не меняет, версии не
1757
+ даёт. Версии производных строк пишутся от System с агентом `letopis projector <имя>`; журнал ведётся по
1758
+ `history` класса, а сигнал об изменении уходит всегда.
1759
+ - `rebuild()` перестраивает проекцию атомарно, одной транзакцией: до её фиксации читатели видят прежние строки, но
1760
+ горизонт подписок стоит. `rebuild({ chunked: true })` идёт пачками: производные строки удаляются сразу, и до
1761
+ конца перестройки данные неполные, а `status().state` — `'rebuilding'`. Прерванную перестройку пачками
1762
+ продолжает следующий запуск. Сам `run()` — первый запуск новой проекции и `onExpired: 'rebuild'` — перестраивает
1763
+ пачками: горизонт подписок кластера стоит не дольше пачки. Второй процесс того же проектора тем временем ждёт или
1764
+ продолжает ту же перестройку, а не начинает её заново. Перестройка удаляет строки только участников и только
1765
+ классов их семейств `into` и начинает всех участников заново: отметки выбывших снимаются, а арендатор, чьи данные
1766
+ снова не проходят, выбывает снова.
1767
+ - `rebuild({ tenant: id })` перестраивает одного арендатора одной транзакцией: удаляет его производные строки, строит
1768
+ их из его текущих строк источников и снимает отметку в `status().failed`; изменения, уже учтённые перестройкой,
1769
+ проектор потом не применяет второй раз. Исправьте данные или описание класса арендатора, затем перестройте его. Если
1770
+ база снова отклоняет запись, перестройка откатывается с этой ошибкой, и арендатор остаётся выбывшим. Проекции ещё
1771
+ нет или идёт перестройка пачками — `rebuild_required`; арендатор не участник — `tenant_denied`. С `chunked: true` —
1772
+ пачками, каждая своей транзакцией со своим снимком, пока хвост журнала идёт для всех: изменения арендатора выше
1773
+ последней пачки хвост пропускает (их прочтёт пачка), в пройденной — если транзакция видна в её снимке. Производные
1774
+ строки арендатора удаляются сразу и до конца перестройки неполны (`status().rebuildingTenants`); блокировку,
1775
+ занятую `run()` другого процесса, перестройка пережидает.
1776
+ - `status().state` описывает проекцию в базе, а не процесс: `'new'` — проекции ещё нет, её построит первый `run()`
1777
+ или `rebuild()`; `'running'` — проекция построена и ведётся по журналу (это не значит, что какой-то процесс сейчас
1778
+ выполняет `run()`); `'rebuilding'` — идёт или прервана перестройка пачками.
1779
+ - `version` — версия логики `handle`. Если проекция построена другой версией, `run()` бросает `rebuild_required`, и
1780
+ нужна перестройка `rebuild()`.
1781
+ - Если курсор старше яруса `all` политики хранения источников и после него есть изменения, часть изменений могла
1782
+ проредиться. Ярус у каждого арендатора свой — это описание его классов. Курсор старше яруса у всех участников —
1783
+ `run()` бросает `cursor_expired`, а с `onExpired: 'rebuild'` проектор перестраивается целиком, пачками. Старше
1784
+ яруса только у части — выбывают они (`status().failed`, код `cursor_expired`), остальные идут дальше, а с
1785
+ `onExpired: 'rebuild'` выбывшие перестраиваются сами, пачками: состояние перестройки лежит в чекпоинте, и её
1786
+ продолжает `run()` любого процесса; блокировка, занятая другим процессом, — только повтор на следующем проходе, а
1787
+ ошибка данных выводит арендатора с её кодом, без повторов. Ярус перестроенного арендатора отсчитывается от начала
1788
+ его перестройки, пока курсор её не догнал: отстающий проектор не выводит его снова. Проектор без новых изменений не
1789
+ истекает. `status().lag` — возраст самого старого необработанного изменения в мс.
1790
+ - Проектор работает с арендаторами, у которых каждый класс `into` помечен его `derivedBy`; изменения остальных
1791
+ арендаторов он пропускает, поэтому одноимённый класс без пометки у другого арендатора ему не мешает. Нет ни одного
1792
+ такого арендатора — `derived_class`. Пометить классы арендатора может только администратор, поэтому участников
1793
+ выбирает он: пользователь арендатора сам к проектору не подключится. Опция
1794
+ `tenants: [id, …]` — явный список участников: проектор работает только с этими арендаторами (их классы `into` тоже
1795
+ должны быть помечены), а пометку у остальных не учитывает. Источники — семейства `from` у этих арендаторов; класс
1796
+ не может быть одновременно источником и результатом. Изменений класса с `history: false` проектор не видит:
1797
+ журнала у него нет. Поэтому арендатор, у которого такой класс среди источников, выбывает с кодом `invalid_class`, и
1798
+ его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
1799
+ - `batch` — сколько изменений в пачке (500), `pollMs` — запасной опрос (1000 мс): сигналы источников будят
1800
+ проектор раньше.
1801
+ - Каждая правка, которую `out` отправляет в базу, — отдельная правка строки, и скорость проектора задаёт число
1802
+ правок, а не число изменений. Свёртка `inc` сводит правки строки к одной за пачку, поэтому handle из примера выше
1803
+ (`out.inc` на каждое изменение) на машине замеров догонял 6 тысяч изменений в секунду при `batch` 500 и 33 тысячи
1804
+ при `batch` 2000 ([раздел 17](#17-производительность)): чем больше пачка, тем больше приращений одной строки
1805
+ складывается в одну правку.
1806
+
1807
+ ### Рецепт: дневная выручка
1808
+
1809
+ Агрегат в базе и проектор решают похожие задачи по-разному:
1810
+
1811
+ | | Агрегат в базе | Проектор |
1812
+ |---|---|---|
1813
+ | когда считается | в той же транзакции, что и запись источника | после фиксации, с отставанием (`status().lag`) |
1814
+ | что считает | сумму поля или число строк источника для одной строки-цели | что угодно: сводки по дням и группам, денормализованные карточки |
1815
+ | совпадает с источником | всегда, в том числе для сырого SQL | когда проектор догнал журнал |
1816
+ | цена записи | каждая операция блокирует строку цели и пишет её версию, поэтому писатели одной цели идут по очереди | писатели не ждут друг друга: проектор применяет изменения пачками после фиксации |
1817
+ | правило над итогом | `min(0)` отклоняет операцию, которая увела бы итог за правило | итог не мешает записи: ошибка данных выводит арендатора из проекции |
1818
+ | что запустить | ничего: поле держит база | процесс на администраторском подключении |
1819
+
1820
+ Баланс кошелька, который не должен уйти в минус, — агрегат ([«Рецепт: баланс агрегатом»](#рецепт-баланс-агрегатом)).
1821
+ Выручка магазина по дням — итог по многим продажам, которому можно немного отставать, — работа для проектора:
1822
+
1823
+ ```ts
1824
+ const Sale = hub('Sale', { day: z.iso.date(), amount: z.number().int().min(0) }, { ends: { Shop } }) // продажа: день и сумма
1825
+ const DayRevenue = hub('DayRevenue', { day: z.iso.date(), revenue: z.number().int(), sales: z.number().int() }, {
1826
+ ends: { Shop: one(Shop).onDelete('cascade') }, key: ['Shop', 'day'], derivedBy: 'day-revenue', history: false,
1827
+ })
1828
+ await syncModels(db, [Sale], { apply: true })
1829
+ await syncModels(adm, [DayRevenue], { apply: true }) // производный класс заводит администратор: adm — с сессией арендатора
1830
+
1831
+ const revenue = adm.projector('day-revenue', {
1832
+ from: ['Sale'], into: ['DayRevenue'], version: 1, withBefore: true,
1833
+ handle(changes, out) {
1834
+ for (const c of changes) {
1835
+ if (c.op === 'purge' || c.op === 'trim' || c.op === 'reset') continue // служебные изменения
1836
+ const was = c.before // прошлая версия продажи — вычесть из её дня
1837
+ const now = c.row.$deleted ? null : c.row // новая — прибавить к её дню; у удаления её нет
1838
+ if (was) out.inc('DayRevenue', { Shop: was.links.Shop, day: was.data.day },
1839
+ { revenue: -Number(was.data.amount), sales: -1 }, { tenant: c.tenant })
1840
+ if (now) out.inc('DayRevenue', { Shop: now.links.Shop, day: now.data.day },
1841
+ { revenue: Number(now.data.amount), sales: 1 }, { tenant: c.tenant })
1842
+ }
1843
+ },
1844
+ })
1845
+ revenue.run().catch((err) => console.error('проектор выручки остановлен', err)) // следует за журналом до stop()
1846
+ const week = await db.Shop(shop).DayRevenue().sort('data.day', 'desc').limit(7).rows() // семь последних дней
1847
+ ```
1848
+
1849
+ - День — поле продажи, а не время версии `at`: со временем версии правка, `restore` или `rekey` переносили бы сумму
1850
+ продажи в свой день.
1851
+ - `out.inc` прибавляет, не читая строку, поэтому итог не зависит от порядка изменений: журнал отдаёт транзакции в
1852
+ порядке первой записи, а не фиксации. Правка продажи вычитает прежнюю сумму из прежнего дня и прибавляет новую к
1853
+ новому, удаление только вычитает. День, в котором продаж не осталось, остаётся строкой с нулями. Приращения одного
1854
+ дня в пачке `out` складывает в одну правку.
1855
+ - У `DayRevenue` — `history: false`: производные строки не удваивают журнал, а перестройка соберёт их и без истории.
1856
+ У `Sale` журнал нужен: изменений класса с `history: false` проектор не видит.
1857
+ - Строки `DayRevenue` приложение читает, как любой класс, а пишет их только проектор: `create` или `update` такой
1858
+ строки — `derived_class`. Итог отстаёт от продаж до следующей пачки: проектор будят сигналы записи, а без них —
1859
+ опрос раз в `pollMs`.
1860
+
1458
1861
  ### Кэш результатов
1459
1862
 
1460
1863
  <!-- run: guide -->
@@ -1497,11 +1900,11 @@ const report = await adm.maintain() // { sessions, codes, thinned, batches,
1497
1900
  очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Вызывать может системный администратор,
1498
1901
  роль с правом `letopis.maintain` или администраторское подключение. Запускает его внешний планировщик
1499
1902
  (`letopis maintain`) или сама библиотека: `connect({ maintain: '1h' })`. Из нескольких процессов работает один:
1500
- каждая пачка — своя транзакция, и первым делом она берёт транзакционную advisory-блокировку
1501
- (`pg_try_advisory_xact_lock`), которая снимается с фиксацией пачки. Если блокировку держит другой процесс, вызов до
1502
- первой пачки отвечает `skipped: true`, а между пачками останавливается (продолжит тот процесс) и отчитывается о
1503
- сделанном с `more: true`. Сеансовой блокировки нет, поэтому `maintain()` работает за любым пулом, в том числе за
1504
- PgBouncer в режиме транзакций.
1903
+ каждая пачка — своя транзакция, и первым делом она берёт блокировку обслуживания — строку таблицы `secret`, которую
1904
+ ни прикладная, ни посторонняя роль базы не займёт; блокировка снимается с фиксацией пачки. Если блокировку держит
1905
+ другой процесс, вызов до первой пачки отвечает `skipped: true`, а между пачками останавливается (продолжит тот
1906
+ процесс) и отчитывается о сделанном с `more: true`. Сеансовой блокировки нет, поэтому `maintain()` работает за любым
1907
+ пулом, в том числе за PgBouncer в режиме транзакций.
1505
1908
 
1506
1909
  ### Сброс: `reset()`
1507
1910
 
@@ -1525,8 +1928,150 @@ const modelText = describeText(model) // то же текстом — для llm
1525
1928
  ```
1526
1929
 
1527
1930
  `db.describe()` отдаёт описание классов, доступных сессии: поля, концы, ключи, политики и метаданные, а также
1528
- условия по нескольким полям `anyOf` и `oneOf` с учётом наследования, если они заданы. Из командной строки —
1529
- `letopis describe --format text --out llms.txt`.
1931
+ условия по нескольким полям `anyOf` и `oneOf` с учётом наследования, признак производного класса `derivedBy` и
1932
+ агрегаты `aggregate`, если они заданы. Из командной строки — `letopis describe --format text --out llms.txt`.
1933
+
1934
+ ### Агенты и MCP
1935
+
1936
+ Агент — Claude, Cursor или свой — работает с данными через инструменты MCP: узнаёт модель, читает цепочкой, смотрит
1937
+ историю, а если разрешено — пишет. Сервер MCP действует от имени одного пользователя и только с его правами: каждый
1938
+ вызов — цепочка под его сессией, права и арендаторов проверяет база. Сырого SQL у агента нет.
1939
+
1940
+ | Инструмент | Что делает |
1941
+ |---|---|
1942
+ | `letopis_model` | модель: классы, поля (JSON Schema), условия `anyOf` и `oneOf`, концы, ключи, алиасы, метаданные; с `class` — подробно один класс |
1943
+ | `letopis_query` | чтение JSON-цепочкой: старт `class` и `filter`, `id` или `ids`; `steps` — переходы по ролям и классам; `asOf`, `withDeleted`, `sort`, `limit`, `cursor`; терминал `rows`, `first`, `ids`, `count`, `sum`, `avg`, `min`, `max`, `countBy` |
1944
+ | `letopis_history` | `versions`, `diff` или `blame` одного объекта |
1945
+ | `letopis_write` | `create`, `update` (только с `rev`), `upsert`, `delete` — только при `write: true` (`letopis mcp --write`) |
1946
+
1947
+ Запрос `letopis_query` — та же цепочка, что `db.Product({ price: lt(1000) }).Shop().sort('data.name').rows()`, только записанная JSON:
1948
+
1949
+ ```json
1950
+ { "class": "Product", "filter": { "price": { "$lt": 1000 } }, "steps": [{ "step": "Shop" }], "sort": { "field": "data.name" } }
1951
+ ```
1952
+
1953
+ Значение фильтра — равенство, вложенный объект — поля вложенных объектов, `{ "Роль": id }` — конец. Операторы:
1954
+ `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$like`, `$ilike`, `$starts`, `$ends`, `$has`, `$hasAny`,
1955
+ `$hasAll`, `$exists`, `$isNull`, `$not`; ИЛИ — `{ "$or": [фильтр, …] }`. Неизвестный класс, роль, параметр или
1956
+ оператор — ответ `isError` с подсказкой похожих имён. Шагов `steps` — не больше 16, условий в `$or` одного фильтра —
1957
+ не больше 64; иначе `invalid_query`. Время запроса эти пределы не ограничивают: задайте `statement_timeout` роли базы,
1958
+ под которой подключается сервер MCP (`alter role … set statement_timeout = '5s'`).
1959
+
1960
+ Ответ — `structuredContent` и его текстовый дубль. `structuredContent` — `{ notice, data }`: `notice` — постоянный
1961
+ текст пакета о том, что в `data` данные базы, а не инструкции, `data` — сам ответ. Форма `data` у `letopis_query`
1962
+ зависит от терминала:
1963
+
1964
+ | Терминал | `structuredContent.data` |
1965
+ |---|---|
1966
+ | `rows` (по умолчанию) | `{ rows, count, more, cursor? }` |
1967
+ | `ids` | `{ ids, count, more, cursor? }` |
1968
+ | `first` | `{ row }` — строка или `null` |
1969
+ | `count` | `{ count }` |
1970
+ | `countBy` | `{ counts, truncated? }` |
1971
+ | `sum`, `avg`, `min`, `max` | `{ value }` |
1972
+
1973
+ У `rows` и `ids` `count` — число строк на этой странице, а не всего. Страница — не больше `limit`, `maxRows` строк
1974
+ (100) и `maxBytes` байт JSON строк (50 000); строка, которая сама больше `maxBytes`, приходит на странице одна.
1975
+ `more: true` — есть ещё строки, и тогда `cursor` с тем же запросом отдаёт следующую страницу. `counts` —
1976
+ `{ значение: число }`, самые частые значения; если группы не поместились в пределы, приходит `truncated: true`. Из
1977
+ базы `countBy` читает не больше `maxRows` + 1 групп. Поле агрегата и `countBy` —
1978
+ `field: 'data.<путь>'`. Сортировка — `sort: { field, dir }`: `field` — `at`, `rev`, `id` или `data.<путь>`, `dir` —
1979
+ `'asc'` (по умолчанию) или `'desc'`; без `sort` строки идут по `id`. `sort`, `limit` и `cursor` — только у `rows`,
1980
+ `first` и `ids`.
1981
+
1982
+ `letopis_model` без `class` отвечает (в `data`) `{ schema, tenant, classes }` — классы те же, что в `db.describe()`. Если такая
1983
+ модель больше `maxBytes`, приходит краткая: `brief: true`, а у классов — только имена полей и концов; подробно каждый
1984
+ класс отдаёт `letopis_model` с `class`. `letopis_history` принимает `class`, `id` и `kind`. `versions` отвечает
1985
+ `{ versions, more }` — последние версии в пределах ответа, от старой к новой; `diff` с `from` и `to` (номер версии или
1986
+ момент) — `{ diff }`; `blame` с `at` и `limit` — `{ blame }`. Если изменения или листья не поместились, ответ
1987
+ обрезан, и в нём есть `truncated: true` и `total`. `follow` работает у `versions` и `diff`.
1988
+
1989
+ Отказ инструмента — `isError: true` и `structuredContent` вида `{ notice, error }`, где `error` — `{ code, message }`, а также `similar` (похожие
1990
+ имена), `detail` (подробности) и `sqlstate` (код PostgreSQL у ошибки не из letopis), если они есть. Параметр не по
1991
+ входной схеме инструмента (не тот тип, значение не из перечня, нет обязательного) отклоняет SDK ещё до letopis: тогда
1992
+ `isError: true` и только текст, без `structuredContent`.
1993
+
1994
+ В тексте ответа всё, что пришло из базы, — строки, имена и описания классов, тексты ошибок, подсказки — стоит внутри
1995
+ `<untrusted-data-…>` со случайным суффиксом и пояснением, что это данные, а не инструкции. Описания инструментов и
1996
+ `instructions` сервера — постоянный текст пакета: `meta` классов пишут арендаторы, поэтому она попадает только в
1997
+ результаты.
1998
+
1999
+ Запись. Без `write` инструмента `letopis_write` нет в списке инструментов, а его вызов — ошибка протокола SDK
2000
+ (`Tool letopis_write not found`), а не ответ инструмента с `isError`. Концы записи задаёт
2001
+ `links: { Роль: id | [id] | null }`, а `create`, `update` и `upsert` отвечают `{ rows }`. `update` принимает только
2002
+ `rev` прочитанной версии: чужая версия — `conflict`, потерянной правки нет. `delete` без `confirm` возвращает превью
2003
+ каскада и код операции: `{ preview, operation }`. С `confirm: true` удаление подтверждает человек в клиенте через
2004
+ elicitation (в ревизии протокола 2025 — запросом клиенту, в 2026-07-28 — ответом `input_required`), подтверждение
2005
+ привязано к хэшу операции, а ответ — `{ deleted, operation }`. В ревизии 2026-07-28 подпись `requestState`, с которой
2006
+ клиент повторяет вызов, привязана ещё к пользователю сервера и действует 10 минут: подпись сервера другого
2007
+ пользователя и устаревшая — отказ `confirmation_required`. Окно подтверждения показывает каноническое имя класса и
2008
+ id целей (первые 10, дальше — «и ещё K»); алиас и `meta` класса пишет арендатор, поэтому в окно они не попадают. Код
2009
+ операции, присланный агентом, подтверждением не считается; клиент без elicitation получает отказ
2010
+ `confirmation_required`. Причина каждой записи — `mcp: letopis_write`, её показывает `blame()`. Классы прав — `rule`,
2011
+ `Resource`, `member`, `Account`, `Class` и их потомков (правила, членства, описания классов) — `letopis_write` по
2012
+ умолчанию не пишет, не правит и не удаляет: отказ `acl_denied`. Агент под инъекцией в данных выдал бы постороннему
2013
+ членство или правило без подтверждения человека. Разрешает это только опция `allowAclWrite: true`.
2014
+
2015
+ **stdio.** `letopis mcp` — сервер по stdio для клиентов MCP. Нужен пакет SDK: `npm i @modelcontextprotocol/server`
2016
+ (необязательная peer-зависимость). Строку подключения, схему и токен клиент передаёт флагами или переменными
2017
+ `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`; `--write` включает запись. Принимается только токен сессии
2018
+ пользователя — его выпускает приложение: `svc.auth.sessionFor(пользователь, { ttl })`, а рабочий арендатор сессии
2019
+ задаёт `auth.switch(арендатор)`. Ключ сервиса (`--api-key`) команда отклоняет: он даёт все права `auth.*` и лежал бы на
2020
+ машине агента. Без токена и с `--api-key` команда завершается с кодом 2 и пишет причину в stderr. Токен сессии в
2021
+ арендаторе System (так работает системный администратор) команда тоже отклоняет: опций `allowSystemAdmin` и
2022
+ `allowAclWrite` у неё нет. Конфигурация клиента (Claude Desktop, Claude Code, Cursor):
2023
+
2024
+ ```json
2025
+ {
2026
+ "mcpServers": {
2027
+ "letopis": {
2028
+ "command": "npx",
2029
+ "args": ["letopis", "mcp"],
2030
+ "env": {
2031
+ "LETOPIS_DSN": "postgres://shop_app:app-secret@localhost:5432/shop",
2032
+ "LETOPIS_SCHEMA": "v2.shop",
2033
+ "LETOPIS_TOKEN": "<токен сессии пользователя>"
2034
+ }
2035
+ }
2036
+ }
2037
+ }
2038
+ ```
2039
+
2040
+ **HTTP.** Встроенного HTTP-сервера нет. `createMcpServer(db, { write, maxRows, maxBytes })` из `letopis/mcp` отдаёт
2041
+ сервер MCP, а приложение подключает его к своему обработчику `createMcpHandler` из SDK со своей авторизацией
2042
+ OAuth 2.1: токен запроса проверяет приложение и находит по нему пользователя letopis. Подключение — сессия
2043
+ пользователя (`connect({ token })`) или фасад `svc.as(пользователь)`: ключ сервиса остаётся в процессе приложения.
2044
+ Подключение в обход RLS получает отказ `bypass_rls`, корень сервиса без `as()` и подключение без сессии —
2045
+ `user_session_required`, фасад явной транзакции `db.begin()` — `invalid_query`. Сессию в арендаторе System — так
2046
+ работает системный администратор: его роль `admin` даёт права на аккаунты, членства и правила установки — сервер тоже
2047
+ отклоняет с `user_session_required`, если не передана опция `allowSystemAdmin: true`.
2048
+
2049
+ <!-- run: mcp -->
2050
+ ```ts
2051
+ import { up, connect } from 'letopis'
2052
+ import { createMcpServer } from 'letopis/mcp'
2053
+ import { createMcpHandler } from '@modelcontextprotocol/server'
2054
+
2055
+ const ADMIN = 'postgres://postgres:secret@localhost:5432/shop'
2056
+ const APP = 'postgres://shop_app:app-secret@localhost:5432/shop'
2057
+ const r = await up({ dsn: ADMIN, schema: 'shop', quiet: true,
2058
+ tenant: { name: 'Ромашка', user: { name: 'Анна', login: 'anna@shop.ru', password: 'Ромашка-2026!' } } })
2059
+ const svc = await connect({ dsn: APP, schema: r.schema, apiKey: r.serviceKey! })
2060
+
2061
+ // authInfo — итог проверки токена запроса приложением; в extra оно кладёт id аккаунта letopis
2062
+ const mcp = createMcpHandler(async ({ authInfo }) =>
2063
+ createMcpServer(await svc.as(String(authInfo?.extra?.account), { tenant: r.tenant!.tenant })))
2064
+ // в HTTP-обработчике приложения, после проверки токена: return mcp.fetch(request, { authInfo })
2065
+ ```
2066
+
2067
+ Фабрику обработчик вызывает на каждый запрос HTTP, поэтому `createMcpServer` проверяет подключение каждый раз (один
2068
+ запрос к базе).
2069
+
2070
+ Проверить обработчик можно без сети: клиент SDK из пакета `@modelcontextprotocol/client` ходит в него через `fetch`
2071
+ приложения — `new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { fetch: (url, init) =>
2072
+ mcp.fetch(new Request(url, init), { authInfo }) })`. Так пример выше проверяет тест `docs` (сценарий `mcp` в
2073
+ `lib/test/docs.test.ts` репозитория): без `write` клиент видит три инструмента, а `letopis_model` отвечает от имени
2074
+ Анны в магазине.
1530
2075
 
1531
2076
  ### Сырой SQL
1532
2077
 
@@ -1552,6 +2097,8 @@ letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен>
1552
2097
  letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
1553
2098
  letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
1554
2099
  [--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
2100
+ letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
2101
+ (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
1555
2102
  ```
1556
2103
 
1557
2104
  ### Наблюдение
@@ -1619,9 +2166,10 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1619
2166
 
1620
2167
  | Точка входа | Что экспортирует |
1621
2168
  |---|---|
1622
- | `letopis` | `connect`, `up`, `createTenant`; операторы `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `inList`, `like`, `ilike`, `starts`, `ends`, `has`, `hasAny`, `hasAll`, `exists`, `isNull`, `not`, `or`, `isOp`; `inc`, `isInc`, `cursorOf`; `idOf`, `uuidv5`, `v5Name`, `v5Id`; `LetopisError`, `ValidationError`, `fromDbError`; `syncModels`, `generateTypes`, `schemaToTs`; `compileValidator`, `applyDefaults`, `Registry`; `totpCode`, `hashPassword`, `verifyHash`; `describeText`; константы `DDL_REVISION`, `RESERVED_CLASS_NAMES`, `CHAIN_METHODS`, `DB_METHODS`; типы `Db`, `DbTx`, `TypedDb`, `Chain`, `Row`, `Filter`, `Cursor`, `ConnectOptions`, `UpOptions`, `LoadOptions`, `LoadReport`, `WatchOptions`, `WatchEvent`, `AuthApi`, `AclApi` и другие |
1623
- | `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta`, `HistoryPolicy`; `normalizeSchema`, `translatePattern`, тип `JsonSchema` |
1624
- | CLI `letopis` | `sync`, `types`, `load`, `verify`, `maintain`, `describe`, `import` ([раздел 13](#13-эксплуатация)) |
2169
+ | `letopis` | `connect`, `up`, `createTenant`; операторы `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `inList`, `like`, `ilike`, `starts`, `ends`, `has`, `hasAny`, `hasAll`, `exists`, `isNull`, `not`, `or`, `isOp`; `inc`, `isInc`, `cursorOf`; `idOf`, `uuidv5`, `v5Name`, `v5Id`; `LetopisError`, `ValidationError`, `fromDbError`; `syncModels`, `generateTypes`, `schemaToTs`; `compileValidator`, `applyDefaults`, `Registry`; `totpCode`, `hashPassword`, `verifyHash`; `describeText`; константы `DDL_REVISION`, `RESERVED_CLASS_NAMES`, `CHAIN_METHODS`, `DB_METHODS`; типы `Db`, `DbTx`, `TypedDb`, `Chain`, `Row`, `Filter`, `Cursor`, `ConnectOptions`, `UpOptions`, `LoadOptions`, `LoadReport`, `WatchOptions`, `WatchEvent`, `AuthApi`, `AclApi`, `DiffResult`, `DiffChange`, `BlameResult`, `BlameField`, `Projector`, `ProjectorOptions`, `ProjectorChange`, `ProjectorOut`, `ProjectorRow`, `ProjectorReport`, `ProjectorStatus`, `ProjectorFailure` ([«Проектор»](#проектор)), `AggregateInfo` и другие |
2170
+ | `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta`, `HistoryPolicy`, `AggregateDef`; `normalizeSchema`, `translatePattern`, тип `JsonSchema` |
2171
+ | `letopis/mcp` | `createMcpServer(db, опции)` — сервер MCP SDK с инструментами letopis; тип `McpOptions`: `write` — инструмент `letopis_write` (по умолчанию его нет), `maxRows` — строк в ответе (100, от 1 до 10 000), `maxBytes` — байт в ответе (50 000, от 1 000 до 50 000 000); `allowAclWrite` — `letopis_write` пишет и классы прав (по умолчанию — `acl_denied`); `allowSystemAdmin` — сессия в арендаторе System (по умолчанию — `user_session_required`); значение вне пределов — `invalid_data`. Нужен пакет `@modelcontextprotocol/server` ([раздел 13](#агенты-и-mcp)) |
2172
+ | CLI `letopis` | `sync`, `types`, `load`, `verify`, `maintain`, `describe`, `import`, `mcp` ([раздел 13](#13-эксплуатация)); у `mcp` — `--dsn`, `--schema`, `--token` (или `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`) и `--write` |
1625
2173
 
1626
2174
  Снятые имена 0.21 (`uuidv7`, `LETOPIS_NS` и методы, перечисленные ниже) бросают `removed` с заменой.
1627
2175
 
@@ -1636,6 +2184,8 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1636
2184
  | `begin()`, `commit(tr)`, `rollback(tr)`, `lock(…ключи)` | явная транзакция ([раздел 8](#8-транзакции-батчи-и-деньги)) |
1637
2185
  | `batch(имя)` | очередь планов: `run()`, `discard()`, `size()` |
1638
2186
  | `load(вход, опции)` | загрузчик ([раздел 9](#9-массовая-загрузка)) |
2187
+ | `projector(имя, опции)` | проектор из журнала: `run()`, `rebuild({ chunked, tenant })`, `status()`, `stop()`, `close()` ([«Проекторы»](#проекторы)) |
2188
+ | `rebuildAggregates(класс)` | ремонт агрегатов класса и его потомков во всех арендаторах: поле заново из живых источников, версия — только у исправленных; → их число ([«Агрегаты в базе»](#агрегаты-в-базе)) |
1639
2189
  | `watch(опции)`, `cache` | подписка; кэш результатов (`clear()`, `stats()`) |
1640
2190
  | `` sql`…` ``, `transaction(fn)` | сырой SQL под сессией и RLS |
1641
2191
  | `idOf(класс, ключ)` | id объекта класса с ключом в арендаторе сессии |
@@ -1653,7 +2203,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1653
2203
  | модификаторы | `limit(n)`, `offset(n)`, `sort(поле, 'asc' \| 'desc')`, `after(курсор)`, `asOf(момент)`, `withDeleted()`, `deep(max)`, `exact()`, `alias(имя)`, `tags(…)`, `owner(…)`, `entity(…)`, `forUpdate()`, `cache({ ttl })` |
1654
2204
  | глаголы | `create(данные)`, `update(патч, { rev })`, `upsert(данные)`, `delete({ confirm })`, `anonymize(поля)`, `reclass(класс, данные)`, `rekey(патч)`, `restore()`, `purge({ confirm })` |
1655
2205
  | слоты | после глагола: `.Роль.set(значение)`, `.Роль.unset()`, `.Роль.add(значение)`, `.Роль.remove(значение)` |
1656
- | терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions({ follow })`, `sum(поле)`, `avg(поле)`, `min(поле)`, `max(поле)`, `countBy(поле)` |
2206
+ | терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions({ follow })`, `diff({ from, to, follow })`, `blame({ at, limit })`, `sum(поле)`, `avg(поле)`, `min(поле)`, `max(поле)`, `countBy(поле)` |
1657
2207
 
1658
2208
  ### `db.auth`
1659
2209
 
@@ -1680,6 +2230,23 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1680
2230
  Вид креда `kind` — `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`, `TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`,
1681
2231
  `OTP`, `TOTP` и `KEY` заняты.
1682
2232
 
2233
+ ### Проектор
2234
+
2235
+ `db.projector(имя, опции)` работает только на администраторском подключении ([«Проекторы»](#проекторы)). Имя —
2236
+ строчные латинские буквы, цифры и `_.:-`, до 64 символов; оно же — `derivedBy` производных классов. Неверные опции —
2237
+ `invalid_data`.
2238
+
2239
+ | Член | Что делает |
2240
+ |---|---|
2241
+ | опции `ProjectorOptions` | `from`, `into` — классы с потомками; `version` — целое от 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` — пачками); `batch` — изменений в пачке (500, от 1 до 10 000); `pollMs` — запасной опрос (1000 мс); `tenants` — id арендаторов-участников |
2242
+ | `run({ once })` | догнать журнал (`once`) или следовать за ним до `stop()` → `ProjectorReport` |
2243
+ | `rebuild({ chunked, tenant })` | перестроить с текущего состояния: атомарно, пачками или одного арендатора → `ProjectorReport` |
2244
+ | `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` — `{ [id арендатора]: ProjectorFailure }`, где `ProjectorFailure` — `{ cursor, code, message }` |
2245
+ | `stop()`, `close()` | остановить `run()` после текущей пачки; то же и отписаться от сигналов |
2246
+ | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds }` |
2247
+ | `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` и, если есть, `agent`, `reason`, `moved`, `before`, `rebuild` |
2248
+ | `ProjectorOut` | `upsert(класс, строка, { tenant })`, `inc(класс, строка, приращения, { tenant })`, `delete(класс, ключ или id, { tenant })`, `get(класс, ключ или id, { tenant })` → `Row \| null`; строка — `ProjectorRow` в формате загрузчика |
2249
+
1683
2250
  ### Строка ответа
1684
2251
 
1685
2252
  | Поле | Что значит |
@@ -1704,8 +2271,8 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1704
2271
  `owner`, `forUpdate`, `cache`, `rekey`, `diff`, `blame`; охранники снесённого API — `run`, `execute`, `account`;
1705
2272
  - корень `db`: `as`, `begin`, `commit`, `rollback`, `lock`, `batch`, `watch`, `close`, `registry`, `sql`, `load`,
1706
2273
  `verify`, `maintain`, `reset`, `describe`, `auth`, `acl`, `idOf`, `refresh`, `transaction`, `flushSignals`,
1707
- `schema`, `token`, `tenant`, `trimHistory`, `rekeyClass`, `cache`; охранники — `reloadSchema`, `accounts`,
1708
- `credentials`, `resources`, `rules`, `compact`;
2274
+ `schema`, `token`, `tenant`, `trimHistory`, `rekeyClass`, `cache`, `projector`, `rebuildAggregates`; охранники — `reloadSchema`,
2275
+ `accounts`, `credentials`, `resources`, `rules`, `compact`;
1709
2276
  - батч: `discard`, `size`.
1710
2277
 
1711
2278
  ## 16. Ошибки
@@ -1725,16 +2292,19 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1725
2292
  | `acl_denied` | LT006 | отказ по правилам доступа |
1726
2293
  | `id_mismatch`, `id_from_db` | LT007, LT008 | id не равен v5 от ключа; id класса без ключа выдаёт база |
1727
2294
  | `immutable_key` | LT009 | ключевое поле, id, арендатор и владелец не меняются (новый ключ — `rekey`) |
1728
- | `invalid_class` | LT010 | описание класса отклонено, класс не найден или абстрактный |
2295
+ | `invalid_class` | LT010 | описание класса отклонено, класс не найден или абстрактный; класс со строками, потомками или историей строк не удаляется |
1729
2296
  | `use_reclass`, `reclass_denied` | LT011, LT012 | смена класса через `update`, когда у старого или нового класса есть ключ: id меняется, нужен `reclass()`; роли ссылающихся строк не принимают новый класс |
1730
2297
  | `tightening_conflict` | LT013 | ужесточению описания мешают строки |
1731
2298
  | `isolation_level` | LT014 | удаление, смена класса и ключа — только в `read committed` |
1732
2299
  | `recreated_in_statement` | LT015 | id удалён и создан заново одним оператором |
1733
- | `cursor_expired` | LT016 | курсор подписки старше яруса `all` политики хранения |
2300
+ | `cursor_expired` | LT016 | курсор подписки или проектора старше яруса `all` политики хранения |
1734
2301
  | `delete_restricted` | LT017 | удалению мешают ссылающиеся строки (`onDelete: 'restrict'`) |
1735
2302
  | `not_deleted` | LT018 | `restore` и `purge` — только для удалённых |
1736
2303
  | `reset_disabled` | LT019 | сброс выключен в установке (`allowReset`) |
1737
2304
  | `confirm_mismatch` | LT020 | подтверждение сброса не совпадает с именем схемы |
2305
+ | `derived_class` | LT021 | строку производного класса (`derivedBy`) пишет, правит или удаляет не его проектор; конец ведёт на такую строку или на производный класс; проектор пишет в класс вне своих `into` или не нашёл ни одного арендатора, у которого все классы `into` помечены его `derivedBy` |
2306
+ | `aggregate_field` | LT022 | приложение передало или изменило агрегатное поле (`aggregate`): его считает база |
2307
+ | `projector_pending` | LT023 | `purge` удалённого объекта, чьё удаление ещё не прочёл проектор его класса: сначала `run()` проектора |
1738
2308
 
1739
2309
  Коды библиотеки:
1740
2310
 
@@ -1751,10 +2321,14 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1751
2321
  | `no_key` | `upsert`, `rekey` или `idOf` у класса без ключа |
1752
2322
  | `no_path` | между классами соседних шагов нет перехода |
1753
2323
  | `invalid_query` | цепочку или API используют неверно (модификатор шага `deep`, `exact`, `alias`, `tags` или `owner` после глагола, слот не после глагола, курсор `after()` со значением не того типа, что поле `sort`, без `v` или с `id` не uuid и т. п.) |
1754
- | `admin_required` | загрузчику нужно администраторское подключение |
2324
+ | `admin_required` | загрузчику, проектору и `rebuildAggregates` нужно администраторское подключение |
2325
+ | `rebuild_required` | проекция построена другой версией проектора (`version`): нужна перестройка `rebuild()`; `rebuild({ tenant })`, пока проекции нет или идёт перестройка пачками |
2326
+ | `projector_busy` | перестройку не начать: проектор сейчас работает в другом процессе |
1755
2327
  | `not_empty` | первая заливка (`initial`) — только в пустую схему |
1756
2328
  | `class_not_loadable` | строки `Class` загрузчик не принимает |
1757
- | `bypass_rls` | `connect()` с ролью, которая обходит RLS, без `allowBypassRls` |
2329
+ | `bypass_rls` | `connect()` с ролью, которая обходит RLS, без `allowBypassRls`; `createMcpServer` с подключением в обход RLS |
2330
+ | `user_session_required` | `createMcpServer` и `letopis mcp`: нужна сессия пользователя — у подключения нет сессии, это корень сервиса без `as()`, сессия в арендаторе System без `allowSystemAdmin`, в командной строке ключ сервиса или нет токена |
2331
+ | `confirmation_required` | `letopis_write` (MCP): удаление без подтверждения человека — клиент без elicitation, человек отказал, ответ не к этой операции или данные изменились после превью |
1758
2332
  | `removed` | снесённое API 0.21; в тексте — замена |
1759
2333
 
1760
2334
  Отказ PostgreSQL в праве (`42501`) приходит, например, при `upsert` поверх чужой строки, которую нельзя править.
@@ -1795,9 +2369,26 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1795
2369
  в конце — около 5 с. Подготовка строк в самом загрузчике (проверки, концы, id) идёт одновременно с `COPY` предыдущей
1796
2370
  пачки и теперь задаёт скорость: около 60 с из 77.
1797
2371
 
2372
+ Производные данные 1.2 (`cd lib && npx tsx bench/derived.bench.mjs`, 2026-10-27, PostgreSQL 18.6, одна машина; у
2373
+ агрегатов — медиана трёх кругов; строки проектора — повтор серий 2 и 3 после свёртки `inc` в `out`,
2374
+ `BENCH_SERIES=2,3`):
2375
+
2376
+ | Замер | 1.2 | Для сравнения |
2377
+ |---|---|---|
2378
+ | операция с агрегатом, один писатель | 94 операции/с, p50 10,7 мс | без агрегата — 137 и 6,9 мс; рецепт с `inc` — 69 |
2379
+ | операции одного кошелька с агрегатом, 20 писателей | 152 операции/с, p95 0,47 с | без агрегата — 1 987; рецепт с `inc` — 190 |
2380
+ | операции 20 кошельков с агрегатом, 20 писателей | 1 379 операций/с | без агрегата — 2 023 |
2381
+ | догонка проектора, `out.inc` на каждое изменение | 6 076 и 33 395 изменений/с (`batch` 500 и 2000) | до свёртки `inc` в `out` — 992 и 1 203 |
2382
+ | перестройка 75 тыс. строк | атомарная — 13,3–13,5 с, пачками — 13,0–13,2 с | до свёртки `inc` в `out` — 101 с и 58–63 с |
2383
+ | задержка события подписки во время перестройки | атомарной — до 13,4 с, пачками — до 0,21 с | без перестройки — до 0,12 с |
2384
+
2385
+ Триггеры агрегатов срабатывают на каждом операторе записи, но у классов без агрегатов их цены замер не видит: разница
2386
+ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101.bench.mjs`, четыре прогона) регрессий записи и чтения
2387
+ против 1.1 не показал: два выброса не повторились в отдельных проверках.
2388
+
1798
2389
  ## 18. Ограничения
1799
2390
 
1800
- Здесь собрано то, что letopis 1.1 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
2391
+ Здесь собрано то, что letopis 1.2 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
1801
2392
 
1802
2393
  **Платформа**
1803
2394
 
@@ -1877,11 +2468,19 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1877
2468
  - Счётчики попыток OTP и TOTP откатываются вместе с транзакцией вызывающего: частоту попыток ограничивает сервис.
1878
2469
  - Самостоятельная привязка занятого идентификатора отвечает `acl_denied` — пользователь с недавним входом так
1879
2470
  узнаёт, что идентификатор занят. Изменения прав во всех арендаторах упорядочены одной строкой счётчика.
1880
- - Число невидимых строк, мешающих удалению, и существование аккаунта по id (как цели ссылки) не скрываются.
2471
+ - Число невидимых строк, мешающих удалению или смене класса (`reclass_denied`), и существование аккаунта по id (как
2472
+ цели ссылки) не скрываются.
2473
+ - Ужесточение описания класса и `rekeyClass` проверяют все строки семейства, поэтому требуют права `READ` без условий
2474
+ на все строки класса и его потомков: иначе пробное ужесточение (`maximum`, `enum`, `pattern`) отвечало бы числом
2475
+ невидимых строк, которые ему не соответствуют. Системный администратор, ужесточая класс System, получает число
2476
+ нарушающих строк других арендаторов (`others`): это уровень установки.
1881
2477
  - Метаданные классов (`meta`) видны всем, у кого есть сессия: строки `Class` читает любой. Секретам там не место.
1882
- - Прикладная роль может замедлить установку для всех: `LOCK TABLE entity`, advisory-блокировки с предсказуемыми
1883
- ключами, поддельные сигналы `NOTIFY` (лишь сбрасывают кэш), частая смена прав (одна строка счётчика на
1884
- установку). По глобальному номеру журнала видна общая активность записи.
2478
+ - Прикладная роль может замедлить установку для всех: `LOCK TABLE entity`, advisory-блокировки с ключами `lock()`
2479
+ приложения, поддельные сигналы `NOTIFY` (лишь сбрасывают кэш), частая смена прав (одна строка счётчика на
2480
+ установку). По глобальному номеру журнала видна общая активность записи. Внутренние блокировки — семейства
2481
+ классов, проектора и обслуживания — строки таблицы `secret`, а не advisory: ни прикладная, ни посторонняя роль базы
2482
+ их не займёт. Писатель семейства проходит шлюз ужесточения в подтранзакции — одной на первую запись в семейство за
2483
+ транзакцию.
1885
2484
 
1886
2485
  **История, сигналы, кэш**
1887
2486
 
@@ -1906,6 +2505,12 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1906
2505
  - Очередь `NOTIFY` (8 ГБ) общая для кластера: зависший слушатель её переполняет, и тогда транзакции с `NOTIFY`
1907
2506
  падают при фиксации. Библиотека читает сигналы сразу, `maintain()` предупреждает о заполнении больше чем
1908
2507
  наполовину.
2508
+ - Транзакции с `NOTIFY` PostgreSQL фиксирует по одной: общую для кластера блокировку очереди сигналов фиксация держит
2509
+ вместе с записью на диск. Запись сырым SQL на своём соединении шлёт сигнал из триггера в той же транзакции, поэтому
2510
+ параллельные писатели сырым SQL фиксируются по очереди: на машине замеров 20 таких писателей делали около 520
2511
+ вставок в секунду. Транзакции библиотеки, в том числе `db.sql` и `db.transaction()`, фиксируют данные без `NOTIFY` и
2512
+ отправляют сигналы потом, отдельно, поэтому их писатели этой очереди не ждут: 20 писателей библиотеки делали около
2513
+ 2 000 операций в секунду.
1909
2514
  - Горизонт подписки общий для кластера: долгая пишущая транзакция задерживает события всех подписчиков
1910
2515
  (`sub.lag()`); длинные операции лучше вести пачками.
1911
2516
  - Кэш результатов между процессами устаревает на интервал отправки сигналов (100 мс) плюс доставку и на время
@@ -1914,6 +2519,63 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1914
2519
  - Курсор подписки «с этого момента» несёт снимок момента подписки, пока не пройдёт его границу: такой
1915
2520
  курсор длиннее обычного.
1916
2521
 
2522
+ **Проекторы**
2523
+
2524
+ - Проектор — процесс администратора: он работает только на администраторском подключении, читает журнал всех
2525
+ арендаторов в обход RLS и пишет результаты от роли владельца. Держите его в отдельном процессе, как загрузчик.
2526
+ Участник проектора — арендатор, у которого все классы `into` помечены его `derivedBy`; пометить класс может только
2527
+ администратор (право `letopis.projector`).
2528
+ - Итоги производного класса видит каждый, кому правила арендатора дают `READ` на него, — даже тот, кто не читает
2529
+ строки источников: решайте, кому его читать, правилами на сам производный класс. С `history: true` его журнал
2530
+ показывает изменение каждой пачки, то есть и вклад отдельных строк источников.
2531
+ - Строки производных классов пишет только их проектор: приложение, сырой SQL и загрузчик получают `derived_class`, а
2532
+ ссылаться на такие строки концами нельзя.
2533
+ - Атомарная перестройка — одна долгая пишущая транзакция: пока она идёт, горизонт подписок стоит, и события
2534
+ задерживаются у подписчиков и проекторов всех процессов. На машине замеров перестройка 75 тысяч строк шла 13 с, и
2535
+ до её конца подписчик не получил ни одного события. Поэтому сам `run()` перестраивает пачками, а атомарную
2536
+ перестройку запускает только явный `rebuild()`.
2537
+ - Перестройка пачками держит горизонт только на время пачки (в тех же замерах события задерживались до 0,2 с), но
2538
+ до её конца производные данные неполные: строки удалены сразу и появляются по мере перестройки.
2539
+ - Проектор видит только изменения из журнала, поэтому класс с `history: false` источником быть не может: арендатор
2540
+ с таким источником выбывает из проектора (`invalid_class`), пока журнал класса не включат и арендатора не
2541
+ перестроят.
2542
+ - Прореживание журнала старше яруса `all` ведёт к перестройке: проектор, отставший больше чем на этот ярус,
2543
+ получает `cursor_expired` или, с `onExpired: 'rebuild'`, перестраивается сам. Ярус — по арендатору: истёк только у
2544
+ части участников — выбывают и перестраиваются только они.
2545
+ - Проектор работает только на схеме той ревизии движка, которую ждёт библиотека; на схеме другой ревизии он
2546
+ отвечает `invalid_data`.
2547
+ - `purge()` ждёт проекторов: пока проектор, чьи источники включают класс объекта, а у арендатора объекта есть его
2548
+ производный класс, не прочёл удаление, `purge` отвечает `projector_pending`. Проектор, который больше не запускают,
2549
+ держит `purge` своих источников, пока у арендатора есть его производные классы: запустите его или удалите их.
2550
+
2551
+ **Агрегаты в базе**
2552
+
2553
+ - Агрегат выстраивает писателей одного объекта в очередь: каждая операция блокирует строку цели и пишет её версию.
2554
+ На машине замеров 20 писателей одного кошелька с агрегатом делали 152 операции в секунду (p95 — 0,47 с), без
2555
+ агрегата — 1 987. Для горячих счётчиков берите проектор со свёрткой пачкой.
2556
+ - `reclass` и `rekey` копят вклады до конца переноса, а каскад удаления и загрузчик применяют их по операторам:
2557
+ правило класса проверяется на итоге каждого оператора каскада и каждой пачки загрузчика. Поэтому загрузка, где
2558
+ списания идут пачкой раньше пополнений, получит `invalid_data`, хотя итог всей загрузки допустим. Каждая пачка,
2559
+ задевшая цель, пишет её версию.
2560
+ - Агрегат арендатора не считает строки класса System, а поле строки System считает только источники System.
2561
+
2562
+ **Агенты и MCP**
2563
+
2564
+ - Обёртка `<untrusted-data-…>` — смягчение, а не защита: модель может последовать тексту внутри неё. Защищают права
2565
+ базы: агент читает и пишет только то, что разрешено его пользователю. В `structuredContent` данные базы лежат в
2566
+ `data` и `error` рядом с пояснением `notice`, без меток: клиент, который отдаёт модели его, а не текст, передаёт
2567
+ данные с пояснением, но без случайной метки.
2568
+ - По умолчанию сервер только читает; запись — `write: true` (`--write`); удаление — только с подтверждением человека.
2569
+ Классы прав сервер пишет только с `allowAclWrite`, сессию в арендаторе System принимает только с
2570
+ `allowSystemAdmin`; `create`, `update` и `upsert` остальных классов подтверждения человека не требуют.
2571
+ - Обработчик HTTP в ревизии 2025 работает без состояния: экземпляр на запрос не знает возможностей клиента, поэтому
2572
+ удаление по нему отклоняется (`confirmation_required`). Удаляйте по stdio или в ревизии 2026-07-28.
2573
+ - Подтверждение в ревизии 2026-07-28 подписано ключом процесса: повтор вызова, попавший в другой процесс приложения
2574
+ (несколько экземпляров за балансировщиком), получает отказ `confirmation_required`.
2575
+ - `versions` отдаёт последние версии в пределах `maxRows` и `maxBytes` (из базы — не больше `maxRows` + 1 версий) и
2576
+ `more`; более ранние — `diff` и `blame` с `from`, `to`, `at`. Режим, в котором агент пишет код против API (code
2577
+ mode), в пакет не входит.
2578
+
1917
2579
  **Импорт из 0.21**
1918
2580
 
1919
2581
  - 1.0 — новая major-версия, а не продолжение 0.21: `count()`, `paths()`, `create`, роли, множественные концы
@@ -1931,7 +2593,8 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1931
2593
  # connect — подключение под сессией, отказ ролям в обход RLS, реестр, отложенный сигнал, перевод ошибок (этап 2)
1932
2594
  # context — сессии, вход сервиса по ключу, политики RLS этапа 1 (этап 1)
1933
2595
  # access-rest — оставшиеся строки этапа 5: загрузка от имени пользователя, баланс №20, 22, 29, upsert и { rev } под ACL, встраивание acl_ok (этап 5)
1934
- # acl — права ACL: перенос набора 0.21, условия на строки, вес и запрет по умолчанию, каскад, запрет на потомке, deep, невидимая цель, сырой SQL против библиотеки (этап 5)
2596
+ # aggregate — агрегаты в базе: 20 писателей одного кошелька, сырой SQL, каскад, reclass, restore, загрузчик, удаление аккаунта, restore и rekey цели, смена описания под записью и гонка №18, aggregate_field, ремонт и verify, строка System (план 1.1, этап 4б)
2597
+ # acl — права ACL: перенос набора 0.21, условия на строки, вес и запрет по умолчанию, каскад, запрет на потомке, deep, невидимая цель, сырой SQL против библиотеки; смена parent и alias класса — только с правами на все его строки, алиас — не имя другого класса, имя — не чужой алиас (этап 5; ревью безопасности №2)
1935
2598
  # acl-parity — 1000 случайных наборов правил: план прав и решения по эндпоинтам базы против decide 0.21 (этап 5)
1936
2599
  # auth — креды (пароль, ключ, ключ + секрет, OTP, TOTP, identity), выравнивание времени, регистр, гашение сессий, недавний вход (этап 5)
1937
2600
  # balance — пополнение и списание: идемпотентность по opId, гонки, взаимная блокировка, обрыв связи (TCP-прокси), корректировка под { rev } и forUpdate (этап 4)
@@ -1941,16 +2604,16 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1941
2604
  # chain-write — запись цепочками: create, update, upsert, delete с превью, anonymize, reclass, слоты, inc, { rev }, fan-out, откат плана (этап 4)
1942
2605
  # demo — демо-домен booking на Zod: сид из моделей, запись данных, id по ключу, согласие Zod и базы (этап 2)
1943
2606
  # docs — примеры документации исполняются: помеченные блоки README и шпаргалки (русские и английские) — сценариями против PostgreSQL 18 (этап 8)
1944
- # engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными исходами; ключ сигналов без права, заливка поддельными сигналами, в том числе Class (этапы 1, 3; план 1.1, этап 2 и точка А)
1945
- # engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение, метаданные (этап 1)
2607
+ # engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными исходами; ключ сигналов без права, заливка поддельными сигналами, в том числе Class; запись в производный класс и ссылка на него, чекпоинт проектора через функции кредов; агент через MCP без --write и без подтверждения человека; смена parent, alias и derivedBy класса с одним правом WRITE на Class, удаление и пересоздание класса с историей строк, пробное ужесточение как оракул, reclass без DELETE и перевод ссылок глубже первого уровня, политика хранения истории с одним правом WRITE на Class; агент через MCP выдаёт членство или правило, сессия системного администратора; пользователь арендатора сам заводит производный класс, `rekeyClass` предка производного класса; перенос строки, который переписал бы ссылки строк производного класса; прикладная роль держит advisory-блокировки с прежними ключами внутренних блокировок, а строки-замки ей недоступны; подделанная справка о взятых блокировках семейства (этапы 1, 3; план 1.1, этапы 2, 4а, 5, точка А, ревью безопасности №2, точка Б и доработки после неё, ревью безопасности №3)
2608
+ # engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение, метаданные; класс с историей строк удаляется только после purge() (этап 1; доработки после точки Б)
1946
2609
  # engine-write — конвейер записи сырым SQL: id v5, умолчания, merge, inc, удаление, воскрешение, журнал, сигналы (этап 1)
1947
- # engine-races — гонки на двух соединениях в обоих порядках без пауз по времени (этап 1)
1948
- # explain — индексы цепочек от роли приложения: снаружи первичный ключ и (class, tenant), внутри find_*() — GIN (этап 3)
1949
- # find-ids-parity — 30 цепочек через find_*() и прямым чтением под RLS дают одинаковые ответы (этап 3)
2610
+ # engine-races — гонки на двух соединениях в обоих порядках без пауз по времени; ужесточение во время непрерывной записи семейства не голодает; блокировка семейств, чьи корни отличаются только регистром, и подложенная строка вида LOCK (этап 1; ревью безопасности №3)
2611
+ # explain — индексы цепочек от роли приложения: снаружи первичный ключ и (class, tenant), внутри find_*() — GIN; срез asOf по классу не читает журнал другого арендатора (этап 3; доработки после точки Б)
2612
+ # find-ids-parity — 30 цепочек через find_*() и прямым чтением под RLS дают одинаковые ответы; свои членства в других арендаторах и строки Class System — сейчас, asOf и withDeleted (этап 3; доработки после точки Б)
1950
2613
  # hash — цепочка хэшей версий, подмена и разрыв журнала, verify() (этап 1)
1951
2614
  # harness — тестовая обвязка: подъём базы, схема на файл, две строки подключения (этап 0)
1952
2615
  # jsonschema — компиляция JSON Schema в jsonpath и проверка данных в базе (этап 1)
1953
- # impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor (этап 5)
2616
+ # impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б)
1954
2617
  # install — установщик и роли: повторная установка, две установки в базе, три таблицы (этап 1)
1955
2618
  # member — членство: работа в арендаторе по ролям, auth.switch, выход с переходом владения, системные строки, роли арендатора без прав System (этап 5)
1956
2619
  # model — модели на Zod, нормализатор JSON Schema, перевод шаблонов, совпадение с метасхемой базы (этап 2)
@@ -1978,7 +2641,9 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1978
2641
  # telemetry — onQuery и slowMs, describe() и метаданные классов с наследованием, describeText (этап 6)
1979
2642
  # history — diff и blame по журналу, причина изменения у глаголов, db.begin() и батча; версия, скрытая условием ACL, колонка changed, пропуск версии, row_changes на 20 тыс. целей (план 1.1, этап 1 и точка А)
1980
2643
  # signals — режимы сигналов: нагрузка у посторонней роли, точность кэша до арендатора, System, классы прав, процесс без ключа, загрузчик, подписка, ключ, none, склейка, версия сервера, смена класса, повтор отправки, режим после up() и reset({ all }) (план 1.1, этап 2)
1981
- # initial-load — первая заливка: равенство с обычным загрузчиком, структура и режимы триггеров, системные строки, непустая схема, вставка во время запуска, откат, MULTI_INSERT в WAL, commitEvery, import --initial (план 1.1, этап 3)
2644
+ # initial-load — первая заливка: равенство с обычным загрузчиком, структура и режимы триггеров, системные строки, непустая схема, вставка во время запуска, откат, MULTI_INSERT в WAL, commitEvery, import --initial, агрегаты (agg_fill) (план 1.1, этапы 3 и 4б)
2645
+ # projector — проекторы из журнала: догонка, ровно один раз при обрыве, один писатель, долгая транзакция, транзакция на границе снимка перестройки, запрет записи в производные классы и ссылок на них, границы out, перестройка атомарная и пачками во время записи, purge источника, отставание и cursor_expired, функции кредов; версия, имя, администраторское подключение; гонка двух процессов; ошибка данных одного арендатора — выбывает только он (failed, rebuild({ tenant }), опция tenants), чекпоинт — только строка System, перестройка — только строки участников и семейств into, before после rekey, ярус all и журнал источников — по арендатору; свёртка out.inc по ключу в пачке, перестройка пачками в автоматических путях run() и второй процесс во время неё; перестройка одного арендатора пачками, пока идёт хвост; onExpired пачками в другом процессе, ошибка данных без повторов, ярус от начала перестройки; пустой последний проход перестройки пачками, выбывший посреди своей перестройки — без частичных строк, смена класса без смены id из семейства источников и в него, purge непрочитанного надгробия (план 1.1, этап 4а; ревью безопасности №2; точка Б и доработки после неё; ревью безопасности №3)
2646
+ # mcp — инструменты MCP: модель и meta только в результатах, права арендатора, JSON-цепочка и подсказки похожих имён, предел строк и байт с курсором, обёртка недоверенных данных, запись только с write, update только с rev, удаление с подтверждением человека (elicitation в ревизиях 2025 и 2026-07-28), причина в blame, учётные данные, схемы инструментов, letopis без SDK; letopis mcp по stdio, обработчик HTTP; окно подтверждения без алиаса и meta, countBy и versions с пределом в самом запросе, предел шагов и условий $or; классы прав — только с allowAclWrite, сессия в арендаторе System — только с allowSystemAdmin; structuredContent с пояснением, подпись requestState пользователя сервера со сроком (план 1.1, этап 5; ревью безопасности №2; точка Б и доработки после неё)
1982
2647
  # import — импорт из 0.21 по фикстуре настоящей 0.21: нарушение ключа, --keyless, счётчики по классам, verify, самоссылка, повтор, id без ключа, висячая ссылка, $$strict и поля на $, update, креды, CLI (этап 7)
1983
2648
  # security-final — итоговое ревью безопасности (этап 8, §7.10 №8.4): блокировка семейства по арендатору корня, account_purge без права и restore/purge чужого id не дают ни утечки, ни блокировки; функции этапа 6, log_refs_at, срезы find_* по журналу и watch_read не выходят за свой арендатор; NOTIFY несёт только имена или токены режима; ключ кэша различает токен, актора и арендатора (этап 8)
1984
2649
  ```