letopis 1.1.0 → 1.3.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.3
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,23 @@ 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
+
371
+ Обновление схем 1.1.0 и 1.0.1 проверяет тест `upgrade`. Схему ставит прежний пакет, данные пишет прежний движок, затем
372
+ вызывается `up()` текущей версии. После него версии и хэши прежнего движка на месте, `verify({ data: true })` чист, а
373
+ каталог схемы совпадает с чистой установкой. Схема 1.1.0 обновляется простым `up()`: ревизия у неё та же. Схема 1.0.1
374
+ обновляется через `up({ upgrade: true })`.
375
+
355
376
  ### Разработка без PostgreSQL: PGlite
356
377
 
357
378
  `up({ pglite: true })` поднимает PGlite (PostgreSQL 18 на WebAssembly) с pgcrypto и сокетом на свободном порту.
@@ -478,11 +499,70 @@ id из email подбирается перебором и остаётся в
478
499
 
479
500
  - `history: false` — журнал для класса не ведётся: нет версий, среза на дату, восстановления и событий подписки.
480
501
  Политика хранения — `history: { all: '1 day', daily: '3 months', … }` ([раздел 11](#11-история-и-срезы-на-дату)).
502
+ Сменить `history` у существующего класса может только тот, у кого есть право `READ` и `DELETE` без условий на все
503
+ строки класса и его потомков: по политике `maintain()` прореживает журнал, а `false` его выключает.
481
504
  - `ownerDefault: 'actor'` — владелец новых строк — сам пользователь, а не арендатор (по умолчанию `'tenant'`).
482
505
  Пригодится для правила «каждый видит свои записи».
483
506
  - `cache: { ttl }` — чтения только этого класса кэшируются без `.cache()` ([раздел 12](#12-подписка-на-изменения-и-кэш)).
484
507
  - `meta` — свободные метаданные для приложения: название, иконка, цвет, порядок в меню. До 64 КБ, наследуются по
485
508
  ключам (`null` убирает унаследованное значение), видны всем, у кого есть сессия. У конца — `.meta()`.
509
+ - `derivedBy: 'имя-проектора'` — производный класс: его строки пишет только этот проектор ([«Проекторы»](#проекторы)).
510
+ Имя — строчные латинские буквы, цифры и `_.:-` (первый знак — буква или цифра), до 64 символов. Опция
511
+ наследуется, потомок её не меняет. Концы производного класса — только `onDelete: 'cascade'` или `'unset'`:
512
+ производная строка не мешает удалять цель. На строки производного класса не ссылаются. Приложение, сырой SQL и
513
+ загрузчик в такой класс не пишут: запись, правка и удаление его строк — `derived_class`. Сам производный класс
514
+ (свой или унаследованный) заводит, меняет и удаляет только администратор — администраторское подключение или
515
+ аккаунт с правом System `letopis.projector`; у сессии приложения это `acl_denied` ([«Проекторы»](#проекторы)).
516
+ - `aggregate: { поле: { sum | count, of, via, where } }` — агрегат в базе: сумму или счётчик строк источника держит
517
+ сама база ([«Агрегаты в базе»](#агрегаты-в-базе)).
518
+
519
+ ### Агрегаты в базе
520
+
521
+ Агрегат — поле, которое держит сама база: сумма поля или число живых строк класса-источника, чей конец ведёт на
522
+ строку. Он нужен там, где итог обязан совпадать с источником и для сырого SQL: баланс кошелька, число позиций заказа.
523
+
524
+ ```ts
525
+ const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0), deposits: z.number().int().optional() }, {
526
+ key: ['number'],
527
+ aggregate: {
528
+ balance: { sum: 'amount', of: 'Movement', via: 'Wallet' }, // сумма операций
529
+ deposits: { count: true, of: 'Movement', via: 'Wallet', where: { properties: { amount: { minimum: 1 } } } }, // число пополнений
530
+ },
531
+ })
532
+ const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet } })
533
+ ```
534
+
535
+ - `sum: 'поле'` — сумма числового поля источника, `count: true` — число строк. `of` — класс-источник (имя или
536
+ модель) вместе с потомками, `via` — его одиночный конец, который ведёт на строку-цель. `where` — JSON Schema над
537
+ данными источника: строки, которые её не проходят, в агрегат не входят.
538
+ - Считаются только живые строки того же арендатора, что и цель. Ссылка на строку System в агрегат не входит: общий
539
+ справочник не считает данные всех арендаторов. Агрегат класса арендатора, в том числе унаследованный от класса
540
+ System, считает только классы своего арендатора: класс System с именем `of` в него не входит, а свой потомок класса
541
+ System входит. Агрегат класса System считает классы System и классы арендатора строки-цели.
542
+ - Поле пересчитывает триггер после каждого оператора: запись библиотеки, сырой SQL, каскад удаления, `restore`,
543
+ `reclass`, `rekey`, загрузчик, удаление аккаунта. Операции одного оператора складываются в одно приращение цели, а
544
+ правило класса (`min(0)`) проверяется над итогом: операция, которая увела бы баланс в минус, отклоняется с
545
+ `invalid_data`. Каждое изменение цели — её новая версия с автором и причиной операции.
546
+ - Поле пишет только база. Значение в `create`, `update`, `upsert`, `rekey`, `reclass` или в сыром SQL — ошибка
547
+ `aggregate_field`. Новая строка получает итог уже существующих источников, обычно 0. Замена данных целиком без
548
+ поля сохраняет прежнее значение. Загрузчик переданное значение не пишет. Права на запись в цель не нужно:
549
+ достаточно права на запись в источник.
550
+ - Описание проверяет база (`invalid_class`): поле есть в схеме, оно числовое и не входит в ключ; конец `via` —
551
+ одиночный и принимает класс цели; поле `sum` — число; агрегаты не образуют цикл; цель и источник — не системные и
552
+ не производные классы. Класс-источник описывают раньше агрегата: агрегат над классом, которого ещё нет, отклоняется.
553
+ У класса арендатора источник ищут среди классов арендатора. `syncModels` сам описывает источники раньше целей.
554
+ Класс `of` не удаляется, пока его называет агрегат класса того же арендатора: сначала агрегат убирают из описания цели.
555
+ - Строки попадают в агрегат только с ведома того, кто читает их все: поле считает и строки, которые автору не видны.
556
+ Описать или изменить агрегат может только тот, кто без условий читает все строки источника и его потомков. Сделать
557
+ класс источником (новый потомок источника, смена `parent`) может только тот, кто без условий читает все
558
+ строки этого класса и его потомков — и до правки, и после. Иначе `acl_denied`.
559
+ - Опция наследуется, потомок заменяет её целиком. Добавить или изменить агрегат — ужесточение: запись в цель и в
560
+ источники ждёт, пока поле пересчитывается у всех строк.
561
+ - `db.rebuildAggregates(класс)` пересчитывает поле из текущих строк — для ремонта. Версия пишется только там, где
562
+ значение изменилось. Расхождения показывает `db.verify({ data: true })`. Нужно администраторское подключение.
563
+ - Сырой `INSERT … ON CONFLICT DO UPDATE SET data = excluded.data` для цели отклоняется: поле в `excluded.data` уже
564
+ поставил триггер вставки. Пишите `excluded.data - array['balance']`, и поле останется прежним; `upsert` библиотеки
565
+ делает так сам.
486
566
 
487
567
  ### Записать модель в базу
488
568
 
@@ -620,6 +700,13 @@ await db.Product(kettle).update({ views: inc(1, { start: 0 }) }).rows() // п
620
700
  - маркер `inc` нельзя получить из JSON: `{ "$inc": 5 }`, пришедший из сети, остаётся данными;
621
701
  - деньги храните целым числом мелких единиц (копеек) не больше 2^53 − 1.
622
702
 
703
+ Счётчик, который обязан совпадать с источником и для сырого SQL, удобнее держать агрегатом
704
+ ([«Агрегаты в базе»](#агрегаты-в-базе)): его пересчитывает сама база, и забыть `inc` нельзя. Цена у агрегата и `inc`
705
+ одна: операции одного объекта идут друг за другом под блокировкой его строки, и каждая пишет его версию. На машине
706
+ замеров один объект так принимал 150–190 операций в секунду, сколько бы писателей его ни ждало
707
+ ([раздел 17](#17-производительность)). Для более горячих счётчиков берите проектор со свёрткой пачкой
708
+ ([«Проекторы»](#проекторы)): итог отстаёт от источника, зато писатели не ждут друг друга.
709
+
623
710
  ### Защита от параллельной правки: `{ rev }`
624
711
 
625
712
  <!-- run: guide -->
@@ -650,9 +737,12 @@ const [vip] = await db.Customer(olga).reclass('Vip', { name: 'Ольга', email
650
737
  - `rekey(патч)` — новое значение ключа: строка с новым id, все ссылки переведены, старая строка удалена. Если
651
738
  ссылающаяся строка включает этот конец в свой ключ, её id тоже меняется, и перевод идёт дальше. Журнал связывает
652
739
  старый и новый id (`moved`).
653
- - `db.rekeyClass(класс, ключ)` — новый ключ для всех строк семейства, одной транзакцией.
654
- - `reclass(класс, данные)` — смена класса; данные необязательны, а если переданы — задаются целиком. Если у нового
655
- класса нет ключа, id сохраняется (как у `vip` выше); если есть — id вычисляется заново, а ссылки переводятся.
740
+ - `db.rekeyClass(класс, ключ)` — новый ключ для всех строк семейства, одной транзакцией. У производного класса и
741
+ у класса с производным потомком — `derived_class`: строки производного класса пишет только проектор.
742
+ - `reclass(класс, данные)` — смена класса; данные необязательны, а если переданы — задаются целиком. Строка уходит из
743
+ прежнего класса, поэтому нужно право `DELETE` на неё, `WRITE` — на новый класс и на каждую строку, чьи ссылки
744
+ переписывает перевод, на любой глубине. Если у нового класса нет ключа, id сохраняется (как у `vip` выше); если
745
+ есть — id вычисляется заново, а ссылки переводятся.
656
746
  Роли ссылающихся строк должны принимать новый класс, иначе `reclass_denied`. Сырой `UPDATE … SET class`
657
747
  проходит, только если ключа нет ни у старого, ни у нового класса (иначе `use_reclass`).
658
748
  - Удаление, смена класса и смена ключа работают только в `read committed`: транзакции библиотеки всегда открываются
@@ -801,7 +891,8 @@ const avgPrice = await db.Shop(shop).Product().avg('data.price')
801
891
  const byStatus = await db.Order().countBy('data.status') // { new: 1 }
802
892
  ```
803
893
 
804
- Агрегаты считает база, по сущностям последнего шага.
894
+ Агрегаты считает база, по сущностям последнего шага. `limit(n)` и `offset(n)` перед `countBy()` относятся к группам:
895
+ `db.Order().limit(3).countBy('data.status')` — три самых частых значения.
805
896
 
806
897
  ### Пути
807
898
 
@@ -868,8 +959,12 @@ await db.Customer(temp).purge({ confirm: true }).rows() // история
868
959
  ```
869
960
 
870
961
  `purge` работает только для удалённых объектов (живой — `not_deleted`) и стирает их историю целиком; на месте
871
- версии 1 остаётся запись `purge` с числом стёртых версий. История системных классов не стирается. Удалить аккаунт —
872
- `db.auth.purgeAccount` ([раздел 10](#10-пользователи-вход-и-права)).
962
+ версии 1 остаётся запись `purge` с числом стёртых версий. История системных классов не стирается. Пока проектор,
963
+ чьи источники `from` включают класс объекта ([«Проекторы»](#проекторы)), не прочёл удаление, `purge` отказывает
964
+ (`projector_pending`): стёртое надгробие проектор не увидел бы, и вклад объекта остался бы в итогах. Удаление и
965
+ `purge` одной транзакцией в таком классе поэтому не проходят. Так же `db.trimHistory` отказывает, пока проектор не
966
+ прочёл первую оставшуюся версию: прошлую версию для `before` он бы уже не нашёл. Удалить аккаунт — `db.auth.purgeAccount`
967
+ ([раздел 10](#10-пользователи-вход-и-права)).
873
968
 
874
969
  ## 8. Транзакции, батчи и деньги
875
970
 
@@ -1011,6 +1106,65 @@ try {
1011
1106
  - Перевод — две операции в одной `db.begin()`, кошельки правятся в порядке id: тогда взаимных блокировок нет.
1012
1107
  - Нехватку средств отличают от других ошибок данных по `e.issues`: поле `balance`, правило `minimum`.
1013
1108
 
1109
+ ### Рецепт: баланс агрегатом
1110
+
1111
+ Тот же кошелёк, только баланс держит сама база: `balance` — агрегат, сумма `amount` живых операций
1112
+ ([«Агрегаты в базе»](#агрегаты-в-базе)). Операция — одна запись `Movement`, приращения `inc` в коде нет, и забыть его
1113
+ нельзя. Баланс остаётся верным, даже когда операции пишет сырой SQL или загрузчик, а удаляет каскад.
1114
+
1115
+ ```ts
1116
+ const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0) }, {
1117
+ ends: { Customer }, key: ['number'],
1118
+ aggregate: { balance: { sum: 'amount', of: 'Movement', via: 'Wallet' } }, // баланс — сумма живых операций
1119
+ })
1120
+ const Movement = link('Movement', { opId: z.string(), amount: z.number().int() },
1121
+ { ends: { Wallet }, key: ['Wallet', 'opId'] }) // ключ операции — идемпотентность
1122
+
1123
+ /** Операция: amount больше нуля — пополнение, меньше — списание. База пересчитает баланс в той же транзакции. */
1124
+ async function operate(db: Db, wallet: string, opId: string, amount: number) {
1125
+ try {
1126
+ await db.Wallet(wallet).Movement().create({ opId, amount }).rows()
1127
+ return 'done'
1128
+ } catch (e) {
1129
+ if (e instanceof LetopisError && e.code === 'exists') return 'repeat' // повтор после потери ответа
1130
+ throw e // invalid_data — баланс ушёл бы в минус (min(0))
1131
+ }
1132
+ }
1133
+
1134
+ /** Корректировка «баланс = target»: операция на разницу, кошелёк заблокирован до commit. */
1135
+ async function adjust(db: Db, wallet: string, opId: string, target: number) {
1136
+ const tx = await db.begin()
1137
+ try {
1138
+ const w = (await tx.Wallet(wallet).forUpdate().first())!
1139
+ await tx.Wallet(wallet).Movement().create({ opId, amount: target - (w.data.balance as number) }).rows()
1140
+ await tx.commit()
1141
+ return 'done'
1142
+ } catch (e) {
1143
+ await tx.rollback()
1144
+ if (e instanceof LetopisError && e.code === 'exists') return 'repeat' // корректировка с этим opId уже записана
1145
+ throw e
1146
+ }
1147
+ }
1148
+
1149
+ const [wallet] = await db.Customer(olga).Wallet().create({ number: 'W-1' }).rows() // balance — 0: его ставит база
1150
+ await operate(db, wallet.id, 'op-1', 10_000) // баланс 10 000
1151
+ await operate(db, wallet.id, 'op-2', -3_000) // баланс 7 000
1152
+ await adjust(db, wallet.id, 'op-3', 5_000) // операция на −2 000, баланс 5 000
1153
+ ```
1154
+
1155
+ - Модель здесь своя, и блок в программу примера не входит: там баланс растёт через `inc`, а агрегатное поле так не
1156
+ меняется.
1157
+ - `balance` не передают ни в `create`, ни в `update` (`aggregate_field`), поэтому корректировка — тоже операция, а не
1158
+ `update` под `{ rev }`. Чтобы записать операцию, право на запись в кошелёк не нужно: хватает права на запись в
1159
+ `Movement`.
1160
+ - Нехватка средств — `invalid_data`, как и с `inc`: в `e.issues` поле `balance` и правило `minimum`. Повтор сверяйте,
1161
+ как `same()` в рецепте выше: `exists` говорит только о том, что операция с этим `opId` уже есть.
1162
+ - Правка и удаление операции тоже меняют баланс: база вычтет прежнее значение. Отменять операцию всё равно лучше
1163
+ встречной операцией, тогда история кошелька остаётся полной.
1164
+ - Каждая операция блокирует строку кошелька и пишет его версию, поэтому операции одного кошелька идут друг за другом,
1165
+ как и с `inc`: на машине замеров — около 150 в секунду ([раздел 17](#17-производительность)). Если один объект
1166
+ получает больше, итог лучше считать проектором ([«Рецепт: дневная выручка»](#рецепт-дневная-выручка)).
1167
+
1014
1168
  ## 9. Массовая загрузка
1015
1169
 
1016
1170
  ### Когда нужен загрузчик
@@ -1113,7 +1267,8 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
1113
1267
  `issues`), `total` — сколько нарушений найдено, `truncated` — список обрезан, `limit` — предел списка (100),
1114
1268
  `complete` — проверки, нашедшие нарушения, прошли все строки. Коды строк: коды проверок базы, а также `duplicate`
1115
1269
  (повтор id), `exists` (объект уже есть), `deleted` (объект с этим id жил раньше) и `class_not_loadable` (строки
1116
- `Class` загрузчик не принимает: классы задаёт `syncModels`).
1270
+ `Class` загрузчик не принимает: классы задаёт `syncModels`). Строки производных классов и ссылки на них загрузчик
1271
+ отклоняет с кодом `derived_class`: такие строки пишет только проектор.
1117
1272
 
1118
1273
  Нарушения ищутся поэтапно: разбор строк (классы, id, повторы), права на классы (`load({ as })`), данные и концы
1119
1274
  пачками, в конце порции — цели вне загрузки, владельцы и арендатор. Загрузка отменяется после первого этапа,
@@ -1268,6 +1423,30 @@ await anna.auth.switch(SHOP) // арендатор сессии
1268
1423
  арендатора действуют на его строки, правила System — везде. Права действуют и на сырой SQL: строки без права не
1269
1424
  видны, правка — пусто или `acl_denied`.
1270
1425
 
1426
+ Маска класса в объекте `READ`, `WRITE` или `DELETE` совпадает и с потомками класса — по именам предков и их алиасам.
1427
+ Поэтому правка класса, которая меняет `parent`, `alias` или `derivedBy`, требует права `READ`, `WRITE` и `DELETE` без
1428
+ условий на все строки класса и каждого его потомка, иначе `acl_denied`: одного права `WRITE` на `Class` мало, чтобы
1429
+ подставить строки класса под чужие правила. Алиас не совпадает с именем другого класса (в том числе `Class` и
1430
+ системных) и с чужим алиасом арендатора и System, а имя нового класса — с чужим алиасом: иначе правило, написанное
1431
+ для одного класса, действовало бы и на строки другого. Нарушение — `invalid_class`.
1432
+
1433
+ По тем же именам правила находят и старые версии строк в журнале. Поэтому класс удаляется, только когда у него нет
1434
+ строк, потомков и истории строк: историю удалённых строк сначала стирает `purge()`. Для класса System не в счёт
1435
+ строки, потомки и история арендатора, у которого есть свой класс с тем же именем: они относятся к его классу. Класс,
1436
+ удалённый с историей раньше, создаётся заново только с прежними `parent`, `alias`, `derivedBy` и `history` — иначе
1437
+ `invalid_class`: старые версии его строк попали бы под чужие правила или другое прореживание. Правила находят старые
1438
+ версии и по алиасам предков. Поэтому, пока удалённые потомки класса хранят историю строк, класс не меняет алиас
1439
+ (`invalid_class`), а удалённым создаётся заново только с прежним описанием. Если журнал хранит строки класса с этим
1440
+ именем, но не его надгробие (например, строки класса System, удалённого в 1.1), класс с этим именем не создаётся, пока
1441
+ историю не сотрёт `purge()`.
1442
+
1443
+ Производный класс (`derivedBy`) заводит, меняет и удаляет только администратор: администраторское подключение или
1444
+ аккаунт с правом System `letopis.projector` (у системного администратора оно есть по маске `letopis.*`). Проектор
1445
+ пишет в такой класс итоги по всем строкам источников арендатора, без прав чтения, поэтому пользователь, заведя его
1446
+ сам, читал бы итоги по строкам, которые ему читать запрещено. Класс, у которого есть производные потомки, тоже
1447
+ правит только администратор: правка предка меняет описание потомка (поля, ключ, `history`). Пометку `aggregatedBy`
1448
+ (класс стал источником агрегата) база ставит и производному классу сама: полей и ключа она не меняет.
1449
+
1271
1450
  ### Выдать право
1272
1451
 
1273
1452
  Пример: Олег — сотрудник магазина с ролью `staff`, и сотрудникам нельзя удалять товары.
@@ -1336,6 +1515,10 @@ const followed = await db.Product(mug2).versions({ follow: true }) // и чер
1336
1515
  `delete({ confirm, reason })` и других), для всех операций блока — `db.begin({ reason })` и
1337
1516
  `db.batch(имя, { reason })`; сырой SQL — настройка `letopis.reason` транзакции. Версия удаления — с `$deleted`.
1338
1517
 
1518
+ Без `sort()` версии идут по id и номеру версии (с `follow` — по времени). `sort()` задаёт порядок и версиям, а
1519
+ `limit()` ограничивает их в самом запросе: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` — пять
1520
+ последних версий, новые первыми.
1521
+
1339
1522
  ### Срез на дату
1340
1523
 
1341
1524
  <!-- run: guide -->
@@ -1389,14 +1572,53 @@ const [b] = await db.Order(id).blame() // каждое по
1389
1572
  await db.Order(id).blame({ at: '2026-10-01', limit: 200 }) // версия на момент; просмотреть 200 версий
1390
1573
  ```
1391
1574
 
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 последних версий.
1575
+ Оба терминала отдают массив — по элементу на объект шага. Так выглядят ответы для чайника, которому снизили цену с
1576
+ причиной:
1577
+
1578
+ ```ts
1579
+ import type { BlameResult, DiffResult } from 'letopis'
1580
+
1581
+ // db.Product(kettle).blame(): цену изменила версия 2, остальные листья — с создания (версия 1)
1582
+ const blamed: BlameResult = {
1583
+ id: '6f1c…', class: 'Product', rev: 2, complete: true,
1584
+ fields: [ // по порядку пути
1585
+ { path: ['class'], rev: 1, at: '2026-10-27T09:15:02.118204+00:00', author: '<id Анны>', agent: '<id сервиса>',
1586
+ op: 'create', exact: true },
1587
+ { path: ['data', 'price'], rev: 2, at: '2026-10-27T09:20:41.502617+00:00', author: '<id Анны>', agent: '<id сервиса>',
1588
+ op: 'update', reason: 'скидка к акции', exact: true },
1589
+ // ['data', 'sku'], ['data', 'title'], ['links', 'Shop'], ['owner'] — тоже версия 1, как ['class']
1590
+ ],
1591
+ }
1592
+ // db.Product(kettle).diff(): предыдущая версия → последняя
1593
+ const diffed: DiffResult = {
1594
+ id: '6f1c…', class: 'Product',
1595
+ from: { rev: 1, at: '2026-10-27T09:15:02.118204+00:00' },
1596
+ to: { rev: 2, at: '2026-10-27T09:20:41.502617+00:00' },
1597
+ changes: [{ path: ['data', 'price'], kind: 'changed', before: 2500, after: 2000 }],
1598
+ }
1599
+ ```
1600
+
1601
+ `agent` здесь — сервис, который пишет от имени Анны (`db = await svc.as(…)`); у записи без имперсонации его нет.
1602
+
1603
+ - `diff()` — массив `DiffResult`: `{ id, class, from, to, changes }`. `from` и `to` — сравниваемые версии
1604
+ `{ rev, at }`. В них есть `id`, если версия принадлежит другому id (`follow: true` через `reclass` или `rekey`), а у
1605
+ `to` — `deleted: true`, если на этот момент объект удалён; тогда `changes` пуст. `from` равен `null`, если объекта
1606
+ тогда ещё не было: все пути — `added`. Если версии нет вовсе, `class` и `to` тоже `null`.
1607
+ - Изменение `DiffChange` — `{ path, kind, before, after }`. `path` — массив сегментов, а не строка с точками; первый
1608
+ сегмент — раздел строки: `data`, `links`, `tags`, `owner`, `class`. `kind` — `added` (значения не было), `removed`
1609
+ (не стало) или `changed`. Пустое значение опускается: у `added` нет `before`, у `removed` — `after`. Объекты данных
1610
+ сравниваются по ключам, массивы — целиком. Множественный конец и теги — множества: по изменению на каждый id или
1611
+ тег, путь — `['links', роль]` или `['tags']`, а сам id или тег — в `before` или `after`.
1612
+ - `blame()` — массив `BlameResult`: `{ id, class, rev, complete, fields }` и `deleted: true`, если эта версия —
1613
+ надгробие. Объект без версий на этот момент элемента не даёт. `fields` — массив `BlameField`, по элементу на лист
1614
+ версии, упорядоченный по пути: `{ path, rev, at, author, op, exact }` и, если есть, `agent`, `reason`, `id`. Лист —
1615
+ скаляр или массив в данных, пустой объект, роль одиночного конца, каждый id множественного конца
1616
+ (`['links', роль, id]`), каждый тег (`['tags', тег]`), `owner` и `class`. `rev`, `at`, `author`, `agent`, `op` и
1617
+ `reason` — от версии, которая последней изменила лист. Пустые `agent` и `reason` опускаются; `id` есть, только если
1618
+ это версия прежнего id (история прошла через `reclass` или `rekey`).
1619
+ - `exact: false` — между текущей версией и найденной есть пропуски (история прорежена, обрезана или часть версий не
1620
+ видна): лист мог менять и выпавшая версия. `complete: true` — история просмотрена целиком и без пропусков; по
1621
+ умолчанию просматривается не больше 1000 последних версий.
1400
1622
  - Считает база с правами вызывающего: сырой SQL получает то же — `select "v2.shop".blame('<id>')`,
1401
1623
  `select "v2.shop".diff_versions('<id>')`.
1402
1624
  - Колонка журнала `changed` (пути изменения версии, по ним `blame` работает быстро) приложению не выдана: пути
@@ -1444,8 +1666,13 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
1444
1666
 
1445
1667
  - `from` — `'now'` (по умолчанию: только изменения после подписки), `'start'` (весь журнал) или сохранённый курсор.
1446
1668
  `db.watch()` возвращается сразу, а момент «now» подписка берёт первым запросом к базе: запись, сделанная сразу
1447
- после `db.watch()`, может зафиксироваться раньше и в поток не попасть. Поэтому пример выше читает с `'start'`.
1448
- - `classes` — только эти классы с потомками; `pollMs` — запасной опрос (1000 мс).
1669
+ после `db.watch()`, может зафиксироваться раньше и в поток не попасть. `await sub.ready()` дожидается этого момента:
1670
+ запись после него в поток придёт. Пример выше читает с `'start'`.
1671
+ - Вызовы `sub.next()` идут по очереди: параллельные получают разные события. Вызов, брошенный по таймауту
1672
+ (`Promise.race`), всё равно получит своё событие — держите его обещание и ждите его снова.
1673
+ - `classes` — только эти классы с потомками; `pollMs` — запасной опрос (1000 мс). Строка, ушедшая из этих классов
1674
+ сменой класса без смены id, приходит событием удаления в прежнем классе (`op: 'delete'`, `row` — прежняя версия с
1675
+ `$deleted`), даже если новый класс подписчику не виден. Событие приходит, если прежнюю версию подписчик видел.
1449
1676
  - События идут без потерь и повторов, под правами читателя, в порядке номеров транзакций, а внутри транзакции — в
1450
1677
  порядке записи. Это не порядок фиксации: версии одного объекта сверяйте по `rev`.
1451
1678
  - Долгая пишущая транзакция в любой базе кластера задерживает поток: `await sub.lag()` показывает отставание в мс.
@@ -1455,6 +1682,202 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
1455
1682
  - Подписка без потерь — это сохранённый курсор: храните `ev.cursor` последнего обработанного события и продолжайте
1456
1683
  с него после перезапуска.
1457
1684
 
1685
+ ### Проекторы
1686
+
1687
+ Проектор строит строки производных классов из журнала: сводки, денормализованные карточки и всё, что можно считать
1688
+ с отставанием от записи. Классы-источники (`from`) пишет приложение, а строки классов `into` — только проектор.
1689
+ Каждый класс `into` помечен опцией `derivedBy` с именем проектора ([«Настройки класса»](#настройки-класса)).
1690
+ Производные классы заводит только администратор: синхронизируйте их администраторским подключением с сессией
1691
+ арендатора — `adm` из раздела 3 (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или `as()` сервиса
1692
+ на таком подключении, в CLI — `letopis sync` с `--dsn` роли администратора. У сессии приложения это `acl_denied`. В примере ниже проектор
1693
+ считает продажи товаров по строкам заказов `line`.
1694
+
1695
+ ```ts
1696
+ const ProductSales = hub('ProductSales', { qty: z.number().int(), revenue: z.number().int() }, {
1697
+ ends: { Product: one(Product).onDelete('cascade') }, key: ['Product'], derivedBy: 'product-sales', history: false,
1698
+ }) // производный класс — в модели; syncModels — администраторским подключением
1699
+
1700
+ const sales = adm.projector('product-sales', { // adm — администраторское подключение
1701
+ from: ['line'], // классы-источники с потомками; у них должен быть журнал
1702
+ into: ['ProductSales'], // производные классы этого проектора
1703
+ version: 1, // логика handle изменилась — новая версия и перестройка
1704
+ withBefore: true, // c.before — прошлая версия объекта или null
1705
+ handle(changes, out) {
1706
+ for (const c of changes) {
1707
+ if (c.op === 'purge' || c.op === 'trim' || c.op === 'reset') continue // служебные изменения, не версии
1708
+ const was = c.before // прошлая версия строки заказа — вычесть
1709
+ const now = c.row.$deleted ? null : c.row // новая версия — прибавить; у удаления её нет
1710
+ if (was) out.inc('ProductSales', { Product: was.links.Product },
1711
+ { qty: -Number(was.data.qty), revenue: -Number(was.data.qty) * Number(was.data.price) }, { tenant: c.tenant })
1712
+ if (now) out.inc('ProductSales', { Product: now.links.Product },
1713
+ { qty: Number(now.data.qty), revenue: Number(now.data.qty) * Number(now.data.price) }, { tenant: c.tenant })
1714
+ }
1715
+ },
1716
+ })
1717
+ const report = await sales.run({ once: true }) // догнать журнал: { batches, changes, created, updated, … }
1718
+ const state = await sales.status() // { name, state, version, cursor, at, lag, failed, rebuildingTenants }
1719
+ await sales.rebuild() // перестроить с текущего состояния; { chunked: true } — пачками
1720
+ await sales.rebuild({ tenant: tenantId }) // перестроить одного арендатора — например, выбывшего (failed)
1721
+ await sales.rebuild({ tenant: tenantId, chunked: true }) // его же пачками: хвост журнала тем временем идёт для всех
1722
+ sales.run().catch((err) => console.error('проектор остановлен', err)) // следовать за журналом до stop()
1723
+ sales.close() // stop() после текущей пачки и отписка от сигналов
1724
+ ```
1725
+
1726
+ - Проектору нужно администраторское подключение: `connect({ allowBypassRls: true })` роли, которая может стать
1727
+ владельцем схемы. Иначе — `admin_required`, внутри `db.begin()` — `invalid_query`. Проектор читает журнал всех
1728
+ арендаторов.
1729
+ - Первый запуск `run()` перестраивает проекцию с текущего состояния: журнал прорежен политикой хранения и обрезан
1730
+ `purge`, поэтому собрать проекцию с начала журнала нельзя.
1731
+ - Каждая пачка — одна транзакция: изменения журнала, `handle`, запись результатов и сдвиг курсора фиксируются
1732
+ вместе. Обрыв посреди пачки откатывает её целиком, а повтор начинается с того же курсора, поэтому каждое
1733
+ изменение применяется ровно один раз. Писатель у проектора один: второй процесс того же проектора пропускает пачку
1734
+ (счётчик `skipped` в отчёте), а `rebuild()`, начатая, пока другой процесс пишет пачку, — `projector_busy`.
1735
+ - Пачка делится по арендаторам: `handle` получает изменения одного арендатора, и запись его результатов идёт под
1736
+ своей точкой сохранения. Если база отклоняет запись результатов арендатора (данные не проходят описание его
1737
+ производного класса, цель конца не найдена и т. п.) или `out` отклоняет строку или приращение, построенные по его
1738
+ данным (приращение — не число), откатываются только его изменения: арендатор выбывает, а остальные записываются в
1739
+ той же пачке. Выбывший попадает в `status().failed` — `{ [id арендатора]: { cursor, code, message } }`, `message`
1740
+ называет арендатора и id строки, — и его изменения пропускаются, пока его не перестроят. Производные строки
1741
+ выбывшего больше не меняются: выбывший в `run()` сохраняет их такими, какими они были до пачки с ошибкой, а выбывший
1742
+ посреди перестройки остаётся без них — частично построенные строки удаляются. Исключение из `handle`, нарушение
1743
+ границ `out` и сбой окружения (взаимная блокировка, отмена запроса, обрыв связи) откатывают пачку целиком, а
1744
+ `run()` отклоняется. Поэтому `run()` без `once` ловите (`catch`): после ошибки он остановлен.
1745
+ - Изменение (`ProjectorChange`) — это `cursor`, `id`, `class`, `tenant`, `op`, `rev`, `at`, `author`, `agent`,
1746
+ `reason`, `moved` и `row` — версия из журнала (у удаления — надгробие с `$deleted`). При перестройке изменения —
1747
+ строки текущего состояния: `rebuild: true`, `op` — `create`, `cursor` пустой.
1748
+ - Смена класса без смены id (у классов без ключа: сырой `UPDATE … SET class` и `reclass()`) журнал пишет версией уже
1749
+ нового класса. Если она уводит строку из семейства `from`, проектор получает удаление: `op` — `delete`, `row` —
1750
+ последняя версия строки в семействе с `$deleted`, `rev`, `at` и автор — смены класса. Строка, вошедшая так в
1751
+ семейство, приходит изменением `reclass` с `before` = `null`.
1752
+ - С `withBefore` у изменения есть `before` — прошлая версия того же id. `before` равен `null`, если прошлой версии нет
1753
+ (создание и первая версия нового id после `reclass` и `rekey`), если прошлая версия — удаление (так бывает после
1754
+ `restore`) или вне семейства `from`, и у всех изменений перестройки. При `reclass` и `rekey` прежние данные
1755
+ вычитает надгробие прежнего id: оно приходит изменением с `$deleted`, его `before` — последняя версия прежнего id,
1756
+ а `moved` обоих изменений указывает на другой id. Поэтому свёртка из примера после `rekey` даёт ту же сумму. У
1757
+ служебных изменений `purge`, `trim` и `reset` поля `before` нет совсем. Поэтому вычитайте `before`, только проверив
1758
+ его на `null`, как в примере выше.
1759
+ - Служебные записи журнала `purge`, `trim` и `reset` тоже приходят изменениями, и у них `rev` равен 1. Поэтому
1760
+ сначала разберите `op`, а уже потом сравнивайте `rev`.
1761
+ - `out.upsert(класс, строка, { tenant })` создаёт строку или заменяет её целиком по ключу класса.
1762
+ `out.inc(класс, строка, приращения, { tenant })` создаёт строку или дополняет её и прибавляет числа, не читая
1763
+ строку (поля, которых нет, считаются от 0). `out.delete(класс, ключ или id, { tenant })` удаляет строку, а если её
1764
+ нет, ничего не делает. `await out.get(класс, ключ или id, { tenant })` читает текущую строку вместе с записями
1765
+ пачки. `out` пишет только в классы `into` и только в арендатора изменений, переданных в `handle`; иначе —
1766
+ `derived_class` или `tenant_denied`, и пачка откатывается.
1767
+ - Приращения строки, заданной одними полями ключа (или одним `$id`), `out` складывает до отправки в базу, пока между
1768
+ ними нет `upsert`, `delete` или `get`: на строку — одна правка за пачку, а правило класса проверяется над итогом её
1769
+ приращений. Строку с другими полями (данными, `$tags`, `$owner`) `inc` не складывает: её поля правят строку, и
1770
+ порядок таких правок сохраняется.
1771
+ - Строка для `out` — в формате загрузчика: поля и роли концов на верхнем уровне, служебные `$tags`, `$owner` и
1772
+ `$id` (`$id` нужен только классу без ключа). Значение роли — id цели. Правка, которая ничего не меняет, версии не
1773
+ даёт. Версии производных строк пишутся от System с агентом `letopis projector <имя>`; журнал ведётся по
1774
+ `history` класса, а сигнал об изменении уходит всегда.
1775
+ - `rebuild()` перестраивает проекцию атомарно, одной транзакцией: до её фиксации читатели видят прежние строки, но
1776
+ горизонт подписок стоит. `rebuild({ chunked: true })` идёт пачками: производные строки удаляются сразу, и до
1777
+ конца перестройки данные неполные, а `status().state` — `'rebuilding'`. Прерванную перестройку пачками
1778
+ продолжает следующий запуск. Сам `run()` — первый запуск новой проекции и `onExpired: 'rebuild'` — перестраивает
1779
+ пачками: горизонт подписок кластера стоит не дольше пачки. Второй процесс того же проектора тем временем ждёт или
1780
+ продолжает ту же перестройку, а не начинает её заново. Перестройка удаляет строки только участников и только
1781
+ классов их семейств `into` и начинает всех участников заново: отметки выбывших снимаются, а арендатор, чьи данные
1782
+ снова не проходят, выбывает снова.
1783
+ - `rebuild({ tenant: id })` перестраивает одного арендатора одной транзакцией: удаляет его производные строки, строит
1784
+ их из его текущих строк источников и снимает отметку в `status().failed`; изменения, уже учтённые перестройкой,
1785
+ проектор потом не применяет второй раз. Исправьте данные или описание класса арендатора, затем перестройте его. Если
1786
+ база снова отклоняет запись, перестройка откатывается с этой ошибкой, и арендатор остаётся выбывшим. Проекции ещё
1787
+ нет или идёт перестройка пачками — `rebuild_required`; арендатор не участник — `tenant_denied`. С `chunked: true` —
1788
+ пачками, каждая своей транзакцией со своим снимком, пока хвост журнала идёт для всех: изменения арендатора выше
1789
+ последней пачки хвост пропускает (их прочтёт пачка), в пройденной — если транзакция видна в её снимке. Производные
1790
+ строки арендатора удаляются сразу и до конца перестройки неполны (`status().rebuildingTenants`); блокировку,
1791
+ занятую `run()` другого процесса, перестройка пережидает. Прерванную перестройку (процесс остановился) продолжает
1792
+ `run()` любого процесса — с `onExpired` и без него.
1793
+ - `status().state` описывает проекцию в базе, а не процесс: `'new'` — проекции ещё нет, её построит первый `run()`
1794
+ или `rebuild()`; `'running'` — проекция построена и ведётся по журналу (это не значит, что какой-то процесс сейчас
1795
+ выполняет `run()`); `'rebuilding'` — идёт или прервана перестройка пачками.
1796
+ - `version` — версия логики `handle`. Если проекция построена другой версией, `run()` бросает `rebuild_required`, и
1797
+ нужна перестройка `rebuild()`.
1798
+ - Если курсор старше яруса `all` политики хранения источников и после него есть изменения, часть изменений могла
1799
+ проредиться. Ярус у каждого арендатора свой — это описание его классов. Курсор старше яруса у всех участников —
1800
+ `run()` бросает `cursor_expired`, а с `onExpired: 'rebuild'` проектор перестраивается целиком, пачками. Старше
1801
+ яруса только у части — выбывают они (`status().failed`, код `cursor_expired`), остальные идут дальше, а с
1802
+ `onExpired: 'rebuild'` выбывшие перестраиваются сами, пачками: состояние перестройки лежит в чекпоинте, и её
1803
+ продолжает `run()` любого процесса; блокировка, занятая другим процессом, — только повтор на следующем проходе, а
1804
+ ошибка данных выводит арендатора с её кодом, без повторов. Ярус перестроенного арендатора отсчитывается от начала
1805
+ его перестройки, пока курсор её не догнал: отстающий проектор не выводит его снова. Проектор без новых изменений не
1806
+ истекает. `status().lag` — возраст самого старого необработанного изменения в мс.
1807
+ - Проектор работает с арендаторами, у которых каждый класс `into` помечен его `derivedBy`; изменения остальных
1808
+ арендаторов он пропускает, поэтому одноимённый класс без пометки у другого арендатора ему не мешает. Нет ни одного
1809
+ такого арендатора — `derived_class`. Пометить классы арендатора может только администратор, поэтому участников
1810
+ выбирает он: пользователь арендатора сам к проектору не подключится. Опция
1811
+ `tenants: [id, …]` — явный список участников: проектор работает только с этими арендаторами (их классы `into` тоже
1812
+ должны быть помечены), а пометку у остальных не учитывает. Источники — семейства `from` у этих арендаторов; класс
1813
+ не может быть одновременно источником и результатом. Изменений класса с `history: false` проектор не видит:
1814
+ журнала у него нет. Поэтому арендатор, у которого такой класс среди источников, выбывает с кодом `invalid_class`, и
1815
+ его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
1816
+ - `batch` — сколько изменений в пачке (500), `pollMs` — запасной опрос (1000 мс): сигналы источников будят
1817
+ проектор раньше.
1818
+ - `lockTimeout` — сколько транзакция проектора ждёт чужую блокировку строки (5000 мс; 0 — без предела). Транзакция
1819
+ любого арендатора, которая держит строку итога или цель конца, иначе держала бы пачку всех арендаторов. Дольше —
1820
+ пачка откатывается, курсор остаётся, `run()` не отклоняется, а пропускает проход (`skipped`) и повторяет его.
1821
+ - Каждая правка, которую `out` отправляет в базу, — отдельная правка строки, и скорость проектора задаёт число
1822
+ правок, а не число изменений. Свёртка `inc` сводит правки строки к одной за пачку, поэтому handle из примера выше
1823
+ (`out.inc` на каждое изменение) на машине замеров догонял 6 тысяч изменений в секунду при `batch` 500 и 33 тысячи
1824
+ при `batch` 2000 ([раздел 17](#17-производительность)): чем больше пачка, тем больше приращений одной строки
1825
+ складывается в одну правку.
1826
+
1827
+ ### Рецепт: дневная выручка
1828
+
1829
+ Агрегат в базе и проектор решают похожие задачи по-разному:
1830
+
1831
+ | | Агрегат в базе | Проектор |
1832
+ |---|---|---|
1833
+ | когда считается | в той же транзакции, что и запись источника | после фиксации, с отставанием (`status().lag`) |
1834
+ | что считает | сумму поля или число строк источника для одной строки-цели | что угодно: сводки по дням и группам, денормализованные карточки |
1835
+ | совпадает с источником | всегда, в том числе для сырого SQL | когда проектор догнал журнал |
1836
+ | цена записи | каждая операция блокирует строку цели и пишет её версию, поэтому писатели одной цели идут по очереди | писатели не ждут друг друга: проектор применяет изменения пачками после фиксации |
1837
+ | правило над итогом | `min(0)` отклоняет операцию, которая увела бы итог за правило | итог не мешает записи: ошибка данных выводит арендатора из проекции |
1838
+ | что запустить | ничего: поле держит база | процесс на администраторском подключении |
1839
+
1840
+ Баланс кошелька, который не должен уйти в минус, — агрегат ([«Рецепт: баланс агрегатом»](#рецепт-баланс-агрегатом)).
1841
+ Выручка магазина по дням — итог по многим продажам, которому можно немного отставать, — работа для проектора:
1842
+
1843
+ ```ts
1844
+ const Sale = hub('Sale', { day: z.iso.date(), amount: z.number().int().min(0) }, { ends: { Shop } }) // продажа: день и сумма
1845
+ const DayRevenue = hub('DayRevenue', { day: z.iso.date(), revenue: z.number().int(), sales: z.number().int() }, {
1846
+ ends: { Shop: one(Shop).onDelete('cascade') }, key: ['Shop', 'day'], derivedBy: 'day-revenue', history: false,
1847
+ })
1848
+ await syncModels(db, [Sale], { apply: true })
1849
+ await syncModels(adm, [DayRevenue], { apply: true }) // производный класс заводит администратор: adm — с сессией арендатора
1850
+
1851
+ const revenue = adm.projector('day-revenue', {
1852
+ from: ['Sale'], into: ['DayRevenue'], version: 1, withBefore: true,
1853
+ handle(changes, out) {
1854
+ for (const c of changes) {
1855
+ if (c.op === 'purge' || c.op === 'trim' || c.op === 'reset') continue // служебные изменения
1856
+ const was = c.before // прошлая версия продажи — вычесть из её дня
1857
+ const now = c.row.$deleted ? null : c.row // новая — прибавить к её дню; у удаления её нет
1858
+ if (was) out.inc('DayRevenue', { Shop: was.links.Shop, day: was.data.day },
1859
+ { revenue: -Number(was.data.amount), sales: -1 }, { tenant: c.tenant })
1860
+ if (now) out.inc('DayRevenue', { Shop: now.links.Shop, day: now.data.day },
1861
+ { revenue: Number(now.data.amount), sales: 1 }, { tenant: c.tenant })
1862
+ }
1863
+ },
1864
+ })
1865
+ revenue.run().catch((err) => console.error('проектор выручки остановлен', err)) // следует за журналом до stop()
1866
+ const week = await db.Shop(shop).DayRevenue().sort('data.day', 'desc').limit(7).rows() // семь последних дней
1867
+ ```
1868
+
1869
+ - День — поле продажи, а не время версии `at`: со временем версии правка, `restore` или `rekey` переносили бы сумму
1870
+ продажи в свой день.
1871
+ - `out.inc` прибавляет, не читая строку, поэтому итог не зависит от порядка изменений: журнал отдаёт транзакции в
1872
+ порядке первой записи, а не фиксации. Правка продажи вычитает прежнюю сумму из прежнего дня и прибавляет новую к
1873
+ новому, удаление только вычитает. День, в котором продаж не осталось, остаётся строкой с нулями. Приращения одного
1874
+ дня в пачке `out` складывает в одну правку.
1875
+ - У `DayRevenue` — `history: false`: производные строки не удваивают журнал, а перестройка соберёт их и без истории.
1876
+ У `Sale` журнал нужен: изменений класса с `history: false` проектор не видит.
1877
+ - Строки `DayRevenue` приложение читает, как любой класс, а пишет их только проектор: `create` или `update` такой
1878
+ строки — `derived_class`. Итог отстаёт от продаж до следующей пачки: проектор будят сигналы записи, а без них —
1879
+ опрос раз в `pollMs`.
1880
+
1458
1881
  ### Кэш результатов
1459
1882
 
1460
1883
  <!-- run: guide -->
@@ -1497,11 +1920,11 @@ const report = await adm.maintain() // { sessions, codes, thinned, batches,
1497
1920
  очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Вызывать может системный администратор,
1498
1921
  роль с правом `letopis.maintain` или администраторское подключение. Запускает его внешний планировщик
1499
1922
  (`letopis maintain`) или сама библиотека: `connect({ maintain: '1h' })`. Из нескольких процессов работает один:
1500
- каждая пачка — своя транзакция, и первым делом она берёт транзакционную advisory-блокировку
1501
- (`pg_try_advisory_xact_lock`), которая снимается с фиксацией пачки. Если блокировку держит другой процесс, вызов до
1502
- первой пачки отвечает `skipped: true`, а между пачками останавливается (продолжит тот процесс) и отчитывается о
1503
- сделанном с `more: true`. Сеансовой блокировки нет, поэтому `maintain()` работает за любым пулом, в том числе за
1504
- PgBouncer в режиме транзакций.
1923
+ каждая пачка — своя транзакция, и первым делом она берёт блокировку обслуживания — строку таблицы `secret`, которую
1924
+ ни прикладная, ни посторонняя роль базы не займёт; блокировка снимается с фиксацией пачки. Если блокировку держит
1925
+ другой процесс, вызов до первой пачки отвечает `skipped: true`, а между пачками останавливается (продолжит тот
1926
+ процесс) и отчитывается о сделанном с `more: true`. Сеансовой блокировки нет, поэтому `maintain()` работает за любым
1927
+ пулом, в том числе за PgBouncer в режиме транзакций.
1505
1928
 
1506
1929
  ### Сброс: `reset()`
1507
1930
 
@@ -1525,8 +1948,150 @@ const modelText = describeText(model) // то же текстом — для llm
1525
1948
  ```
1526
1949
 
1527
1950
  `db.describe()` отдаёт описание классов, доступных сессии: поля, концы, ключи, политики и метаданные, а также
1528
- условия по нескольким полям `anyOf` и `oneOf` с учётом наследования, если они заданы. Из командной строки —
1529
- `letopis describe --format text --out llms.txt`.
1951
+ условия по нескольким полям `anyOf` и `oneOf` с учётом наследования, признак производного класса `derivedBy` и
1952
+ агрегаты `aggregate`, если они заданы. Из командной строки — `letopis describe --format text --out llms.txt`.
1953
+
1954
+ ### Агенты и MCP
1955
+
1956
+ Агент — Claude, Cursor или свой — работает с данными через инструменты MCP: узнаёт модель, читает цепочкой, смотрит
1957
+ историю, а если разрешено — пишет. Сервер MCP действует от имени одного пользователя и только с его правами: каждый
1958
+ вызов — цепочка под его сессией, права и арендаторов проверяет база. Сырого SQL у агента нет.
1959
+
1960
+ | Инструмент | Что делает |
1961
+ |---|---|
1962
+ | `letopis_model` | модель: классы, поля (JSON Schema), условия `anyOf` и `oneOf`, концы, ключи, алиасы, метаданные; с `class` — подробно один класс |
1963
+ | `letopis_query` | чтение JSON-цепочкой: старт `class` и `filter`, `id` или `ids`; `steps` — переходы по ролям и классам; `asOf`, `withDeleted`, `sort`, `limit`, `cursor`; терминал `rows`, `first`, `ids`, `count`, `sum`, `avg`, `min`, `max`, `countBy` |
1964
+ | `letopis_history` | `versions`, `diff` или `blame` одного объекта |
1965
+ | `letopis_write` | `create`, `update` (только с `rev`), `upsert`, `delete` — только при `write: true` (`letopis mcp --write`) |
1966
+
1967
+ Запрос `letopis_query` — та же цепочка, что `db.Product({ price: lt(1000) }).Shop().sort('data.name').rows()`, только записанная JSON:
1968
+
1969
+ ```json
1970
+ { "class": "Product", "filter": { "price": { "$lt": 1000 } }, "steps": [{ "step": "Shop" }], "sort": { "field": "data.name" } }
1971
+ ```
1972
+
1973
+ Значение фильтра — равенство, вложенный объект — поля вложенных объектов, `{ "Роль": id }` — конец. Операторы:
1974
+ `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$like`, `$ilike`, `$starts`, `$ends`, `$has`, `$hasAny`,
1975
+ `$hasAll`, `$exists`, `$isNull`, `$not`; ИЛИ — `{ "$or": [фильтр, …] }`. Неизвестный класс, роль, параметр или
1976
+ оператор — ответ `isError` с подсказкой похожих имён. Шагов `steps` — не больше 16, условий в `$or` одного фильтра —
1977
+ не больше 64; иначе `invalid_query`. Время запроса эти пределы не ограничивают: задайте `statement_timeout` роли базы,
1978
+ под которой подключается сервер MCP (`alter role … set statement_timeout = '5s'`).
1979
+
1980
+ Ответ — `structuredContent` и его текстовый дубль. `structuredContent` — `{ notice, data }`: `notice` — постоянный
1981
+ текст пакета о том, что в `data` данные базы, а не инструкции, `data` — сам ответ. Форма `data` у `letopis_query`
1982
+ зависит от терминала:
1983
+
1984
+ | Терминал | `structuredContent.data` |
1985
+ |---|---|
1986
+ | `rows` (по умолчанию) | `{ rows, count, more, cursor? }` |
1987
+ | `ids` | `{ ids, count, more, cursor? }` |
1988
+ | `first` | `{ row }` — строка или `null` |
1989
+ | `count` | `{ count }` |
1990
+ | `countBy` | `{ counts, truncated? }` |
1991
+ | `sum`, `avg`, `min`, `max` | `{ value }` |
1992
+
1993
+ У `rows` и `ids` `count` — число строк на этой странице, а не всего. Страница — не больше `limit`, `maxRows` строк
1994
+ (100) и `maxBytes` байт JSON строк (50 000); строка, которая сама больше `maxBytes`, приходит на странице одна.
1995
+ `more: true` — есть ещё строки, и тогда `cursor` с тем же запросом отдаёт следующую страницу. `counts` —
1996
+ `{ значение: число }`, самые частые значения; если группы не поместились в пределы, приходит `truncated: true`. Из
1997
+ базы `countBy` читает не больше `maxRows` + 1 групп. Поле агрегата и `countBy` —
1998
+ `field: 'data.<путь>'`. Сортировка — `sort: { field, dir }`: `field` — `at`, `rev`, `id` или `data.<путь>`, `dir` —
1999
+ `'asc'` (по умолчанию) или `'desc'`; без `sort` строки идут по `id`. `sort`, `limit` и `cursor` — только у `rows`,
2000
+ `first` и `ids`.
2001
+
2002
+ `letopis_model` без `class` отвечает (в `data`) `{ schema, tenant, classes }` — классы те же, что в `db.describe()`. Если такая
2003
+ модель больше `maxBytes`, приходит краткая: `brief: true`, а у классов — только имена полей и концов; подробно каждый
2004
+ класс отдаёт `letopis_model` с `class`. `letopis_history` принимает `class`, `id` и `kind`. `versions` отвечает
2005
+ `{ versions, more }` — последние версии в пределах ответа, от старой к новой; `diff` с `from` и `to` (номер версии или
2006
+ момент) — `{ diff }`; `blame` с `at` и `limit` — `{ blame }`. Если изменения или листья не поместились, ответ
2007
+ обрезан, и в нём есть `truncated: true` и `total`. `follow` работает у `versions` и `diff`.
2008
+
2009
+ Отказ инструмента — `isError: true` и `structuredContent` вида `{ notice, error }`, где `error` — `{ code, message }`, а также `similar` (похожие
2010
+ имена), `detail` (подробности) и `sqlstate` (код PostgreSQL у ошибки не из letopis), если они есть. Параметр не по
2011
+ входной схеме инструмента (не тот тип, значение не из перечня, нет обязательного) отклоняет SDK ещё до letopis: тогда
2012
+ `isError: true` и только текст, без `structuredContent`.
2013
+
2014
+ В тексте ответа всё, что пришло из базы, — строки, имена и описания классов, тексты ошибок, подсказки — стоит внутри
2015
+ `<untrusted-data-…>` со случайным суффиксом и пояснением, что это данные, а не инструкции. Описания инструментов и
2016
+ `instructions` сервера — постоянный текст пакета: `meta` классов пишут арендаторы, поэтому она попадает только в
2017
+ результаты.
2018
+
2019
+ Запись. Без `write` инструмента `letopis_write` нет в списке инструментов, а его вызов — ошибка протокола SDK
2020
+ (`Tool letopis_write not found`), а не ответ инструмента с `isError`. Концы записи задаёт
2021
+ `links: { Роль: id | [id] | null }`, а `create`, `update` и `upsert` отвечают `{ rows }`. `update` принимает только
2022
+ `rev` прочитанной версии: чужая версия — `conflict`, потерянной правки нет. `delete` без `confirm` возвращает превью
2023
+ каскада и код операции: `{ preview, operation }`. С `confirm: true` удаление подтверждает человек в клиенте через
2024
+ elicitation (в ревизии протокола 2025 — запросом клиенту, в 2026-07-28 — ответом `input_required`), подтверждение
2025
+ привязано к хэшу операции, а ответ — `{ deleted, operation }`. В ревизии 2026-07-28 подпись `requestState`, с которой
2026
+ клиент повторяет вызов, привязана ещё к пользователю сервера и действует 10 минут: подпись сервера другого
2027
+ пользователя и устаревшая — отказ `confirmation_required`. Окно подтверждения показывает каноническое имя класса и
2028
+ id целей (первые 10, дальше — «и ещё K»); алиас и `meta` класса пишет арендатор, поэтому в окно они не попадают. Код
2029
+ операции, присланный агентом, подтверждением не считается; клиент без elicitation получает отказ
2030
+ `confirmation_required`. Причина каждой записи — `mcp: letopis_write`, её показывает `blame()`. Классы прав — `rule`,
2031
+ `Resource`, `member`, `Account`, `Class` и их потомков (правила, членства, описания классов) — `letopis_write` по
2032
+ умолчанию не пишет, не правит и не удаляет: отказ `acl_denied`. Агент под инъекцией в данных выдал бы постороннему
2033
+ членство или правило без подтверждения человека. Разрешает это только опция `allowAclWrite: true`.
2034
+
2035
+ **stdio.** `letopis mcp` — сервер по stdio для клиентов MCP. Нужен пакет SDK: `npm i @modelcontextprotocol/server`
2036
+ (необязательная peer-зависимость). Строку подключения, схему и токен клиент передаёт флагами или переменными
2037
+ `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`; `--write` включает запись. Принимается только токен сессии
2038
+ пользователя — его выпускает приложение: `svc.auth.sessionFor(пользователь, { ttl })`, а рабочий арендатор сессии
2039
+ задаёт `auth.switch(арендатор)`. Ключ сервиса (`--api-key`) команда отклоняет: он даёт все права `auth.*` и лежал бы на
2040
+ машине агента. Без токена и с `--api-key` команда завершается с кодом 2 и пишет причину в stderr. Токен сессии в
2041
+ арендаторе System (так работает системный администратор) команда тоже отклоняет: опций `allowSystemAdmin` и
2042
+ `allowAclWrite` у неё нет. Конфигурация клиента (Claude Desktop, Claude Code, Cursor):
2043
+
2044
+ ```json
2045
+ {
2046
+ "mcpServers": {
2047
+ "letopis": {
2048
+ "command": "npx",
2049
+ "args": ["letopis", "mcp"],
2050
+ "env": {
2051
+ "LETOPIS_DSN": "postgres://shop_app:app-secret@localhost:5432/shop",
2052
+ "LETOPIS_SCHEMA": "v2.shop",
2053
+ "LETOPIS_TOKEN": "<токен сессии пользователя>"
2054
+ }
2055
+ }
2056
+ }
2057
+ }
2058
+ ```
2059
+
2060
+ **HTTP.** Встроенного HTTP-сервера нет. `createMcpServer(db, { write, maxRows, maxBytes })` из `letopis/mcp` отдаёт
2061
+ сервер MCP, а приложение подключает его к своему обработчику `createMcpHandler` из SDK со своей авторизацией
2062
+ OAuth 2.1: токен запроса проверяет приложение и находит по нему пользователя letopis. Подключение — сессия
2063
+ пользователя (`connect({ token })`) или фасад `svc.as(пользователь)`: ключ сервиса остаётся в процессе приложения.
2064
+ Подключение в обход RLS получает отказ `bypass_rls`, корень сервиса без `as()` и подключение без сессии —
2065
+ `user_session_required`, фасад явной транзакции `db.begin()` — `invalid_query`. Сессию в арендаторе System — так
2066
+ работает системный администратор: его роль `admin` даёт права на аккаунты, членства и правила установки — сервер тоже
2067
+ отклоняет с `user_session_required`, если не передана опция `allowSystemAdmin: true`.
2068
+
2069
+ <!-- run: mcp -->
2070
+ ```ts
2071
+ import { up, connect } from 'letopis'
2072
+ import { createMcpServer } from 'letopis/mcp'
2073
+ import { createMcpHandler } from '@modelcontextprotocol/server'
2074
+
2075
+ const ADMIN = 'postgres://postgres:secret@localhost:5432/shop'
2076
+ const APP = 'postgres://shop_app:app-secret@localhost:5432/shop'
2077
+ const r = await up({ dsn: ADMIN, schema: 'shop', quiet: true,
2078
+ tenant: { name: 'Ромашка', user: { name: 'Анна', login: 'anna@shop.ru', password: 'Ромашка-2026!' } } })
2079
+ const svc = await connect({ dsn: APP, schema: r.schema, apiKey: r.serviceKey! })
2080
+
2081
+ // authInfo — итог проверки токена запроса приложением; в extra оно кладёт id аккаунта letopis
2082
+ const mcp = createMcpHandler(async ({ authInfo }) =>
2083
+ createMcpServer(await svc.as(String(authInfo?.extra?.account), { tenant: r.tenant!.tenant })))
2084
+ // в HTTP-обработчике приложения, после проверки токена: return mcp.fetch(request, { authInfo })
2085
+ ```
2086
+
2087
+ Фабрику обработчик вызывает на каждый запрос HTTP, поэтому `createMcpServer` проверяет подключение каждый раз (один
2088
+ запрос к базе).
2089
+
2090
+ Проверить обработчик можно без сети: клиент SDK из пакета `@modelcontextprotocol/client` ходит в него через `fetch`
2091
+ приложения — `new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { fetch: (url, init) =>
2092
+ mcp.fetch(new Request(url, init), { authInfo }) })`. Так пример выше проверяет тест `docs` (сценарий `mcp` в
2093
+ `lib/test/docs.test.ts` репозитория): без `write` клиент видит три инструмента, а `letopis_model` отвечает от имени
2094
+ Анны в магазине.
1530
2095
 
1531
2096
  ### Сырой SQL
1532
2097
 
@@ -1552,6 +2117,8 @@ letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен>
1552
2117
  letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
1553
2118
  letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
1554
2119
  [--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
2120
+ letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
2121
+ (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
1555
2122
  ```
1556
2123
 
1557
2124
  ### Наблюдение
@@ -1619,9 +2186,10 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1619
2186
 
1620
2187
  | Точка входа | Что экспортирует |
1621
2188
  |---|---|
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-эксплуатация)) |
2189
+ | `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` и другие |
2190
+ | `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta`, `HistoryPolicy`, `AggregateDef`; `normalizeSchema`, `translatePattern`, тип `JsonSchema` |
2191
+ | `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)) |
2192
+ | CLI `letopis` | `sync`, `types`, `load`, `verify`, `maintain`, `describe`, `import`, `mcp` ([раздел 13](#13-эксплуатация)); у `mcp` — `--dsn`, `--schema`, `--token` (или `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`) и `--write` |
1625
2193
 
1626
2194
  Снятые имена 0.21 (`uuidv7`, `LETOPIS_NS` и методы, перечисленные ниже) бросают `removed` с заменой.
1627
2195
 
@@ -1636,6 +2204,8 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1636
2204
  | `begin()`, `commit(tr)`, `rollback(tr)`, `lock(…ключи)` | явная транзакция ([раздел 8](#8-транзакции-батчи-и-деньги)) |
1637
2205
  | `batch(имя)` | очередь планов: `run()`, `discard()`, `size()` |
1638
2206
  | `load(вход, опции)` | загрузчик ([раздел 9](#9-массовая-загрузка)) |
2207
+ | `projector(имя, опции)` | проектор из журнала: `run()`, `rebuild({ chunked, tenant })`, `status()`, `stop()`, `close()` ([«Проекторы»](#проекторы)) |
2208
+ | `rebuildAggregates(класс)` | ремонт агрегатов класса и его потомков во всех арендаторах: поле заново из живых источников, версия — только у исправленных; → их число ([«Агрегаты в базе»](#агрегаты-в-базе)) |
1639
2209
  | `watch(опции)`, `cache` | подписка; кэш результатов (`clear()`, `stats()`) |
1640
2210
  | `` sql`…` ``, `transaction(fn)` | сырой SQL под сессией и RLS |
1641
2211
  | `idOf(класс, ключ)` | id объекта класса с ключом в арендаторе сессии |
@@ -1653,7 +2223,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1653
2223
  | модификаторы | `limit(n)`, `offset(n)`, `sort(поле, 'asc' \| 'desc')`, `after(курсор)`, `asOf(момент)`, `withDeleted()`, `deep(max)`, `exact()`, `alias(имя)`, `tags(…)`, `owner(…)`, `entity(…)`, `forUpdate()`, `cache({ ttl })` |
1654
2224
  | глаголы | `create(данные)`, `update(патч, { rev })`, `upsert(данные)`, `delete({ confirm })`, `anonymize(поля)`, `reclass(класс, данные)`, `rekey(патч)`, `restore()`, `purge({ confirm })` |
1655
2225
  | слоты | после глагола: `.Роль.set(значение)`, `.Роль.unset()`, `.Роль.add(значение)`, `.Роль.remove(значение)` |
1656
- | терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions({ follow })`, `sum(поле)`, `avg(поле)`, `min(поле)`, `max(поле)`, `countBy(поле)` |
2226
+ | терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions({ follow })`, `diff({ from, to, follow })`, `blame({ at, limit })`, `sum(поле)`, `avg(поле)`, `min(поле)`, `max(поле)`, `countBy(поле)` |
1657
2227
 
1658
2228
  ### `db.auth`
1659
2229
 
@@ -1680,6 +2250,23 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1680
2250
  Вид креда `kind` — `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`, `TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`,
1681
2251
  `OTP`, `TOTP` и `KEY` заняты.
1682
2252
 
2253
+ ### Проектор
2254
+
2255
+ `db.projector(имя, опции)` работает только на администраторском подключении ([«Проекторы»](#проекторы)). Имя —
2256
+ строчные латинские буквы, цифры и `_.:-`, до 64 символов; оно же — `derivedBy` производных классов. Неверные опции —
2257
+ `invalid_data`.
2258
+
2259
+ | Член | Что делает |
2260
+ |---|---|
2261
+ | опции `ProjectorOptions` | `from`, `into` — классы с потомками; `version` — целое от 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` — пачками); `batch` — изменений в пачке (500, от 1 до 10 000); `pollMs` — запасной опрос (1000 мс); `lockTimeout` — ожидание чужой блокировки строки (5000 мс, 0 — без предела); `tenants` — id арендаторов-участников |
2262
+ | `run({ once })` | догнать журнал (`once`) или следовать за ним до `stop()` → `ProjectorReport` |
2263
+ | `rebuild({ chunked, tenant })` | перестроить с текущего состояния: атомарно, пачками или одного арендатора → `ProjectorReport` |
2264
+ | `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` — `{ [id арендатора]: ProjectorFailure }`, где `ProjectorFailure` — `{ cursor, code, message }` |
2265
+ | `stop()`, `close()` | остановить `run()` после текущей пачки; то же и отписаться от сигналов |
2266
+ | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds }` |
2267
+ | `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` и, если есть, `agent`, `reason`, `moved`, `before`, `rebuild` |
2268
+ | `ProjectorOut` | `upsert(класс, строка, { tenant })`, `inc(класс, строка, приращения, { tenant })`, `delete(класс, ключ или id, { tenant })`, `get(класс, ключ или id, { tenant })` → `Row \| null`; строка — `ProjectorRow` в формате загрузчика |
2269
+
1683
2270
  ### Строка ответа
1684
2271
 
1685
2272
  | Поле | Что значит |
@@ -1704,8 +2291,8 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1704
2291
  `owner`, `forUpdate`, `cache`, `rekey`, `diff`, `blame`; охранники снесённого API — `run`, `execute`, `account`;
1705
2292
  - корень `db`: `as`, `begin`, `commit`, `rollback`, `lock`, `batch`, `watch`, `close`, `registry`, `sql`, `load`,
1706
2293
  `verify`, `maintain`, `reset`, `describe`, `auth`, `acl`, `idOf`, `refresh`, `transaction`, `flushSignals`,
1707
- `schema`, `token`, `tenant`, `trimHistory`, `rekeyClass`, `cache`; охранники — `reloadSchema`, `accounts`,
1708
- `credentials`, `resources`, `rules`, `compact`;
2294
+ `schema`, `token`, `tenant`, `trimHistory`, `rekeyClass`, `cache`, `projector`, `rebuildAggregates`; охранники — `reloadSchema`,
2295
+ `accounts`, `credentials`, `resources`, `rules`, `compact`;
1709
2296
  - батч: `discard`, `size`.
1710
2297
 
1711
2298
  ## 16. Ошибки
@@ -1725,16 +2312,19 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1725
2312
  | `acl_denied` | LT006 | отказ по правилам доступа |
1726
2313
  | `id_mismatch`, `id_from_db` | LT007, LT008 | id не равен v5 от ключа; id класса без ключа выдаёт база |
1727
2314
  | `immutable_key` | LT009 | ключевое поле, id, арендатор и владелец не меняются (новый ключ — `rekey`) |
1728
- | `invalid_class` | LT010 | описание класса отклонено, класс не найден или абстрактный |
2315
+ | `invalid_class` | LT010 | описание класса отклонено, класс не найден или абстрактный; класс со строками, потомками или историей строк не удаляется |
1729
2316
  | `use_reclass`, `reclass_denied` | LT011, LT012 | смена класса через `update`, когда у старого или нового класса есть ключ: id меняется, нужен `reclass()`; роли ссылающихся строк не принимают новый класс |
1730
2317
  | `tightening_conflict` | LT013 | ужесточению описания мешают строки |
1731
2318
  | `isolation_level` | LT014 | удаление, смена класса и ключа — только в `read committed` |
1732
2319
  | `recreated_in_statement` | LT015 | id удалён и создан заново одним оператором |
1733
- | `cursor_expired` | LT016 | курсор подписки старше яруса `all` политики хранения |
2320
+ | `cursor_expired` | LT016 | курсор подписки или проектора старше яруса `all` политики хранения |
1734
2321
  | `delete_restricted` | LT017 | удалению мешают ссылающиеся строки (`onDelete: 'restrict'`) |
1735
2322
  | `not_deleted` | LT018 | `restore` и `purge` — только для удалённых |
1736
2323
  | `reset_disabled` | LT019 | сброс выключен в установке (`allowReset`) |
1737
2324
  | `confirm_mismatch` | LT020 | подтверждение сброса не совпадает с именем схемы |
2325
+ | `derived_class` | LT021 | строку производного класса (`derivedBy`) пишет, правит или удаляет не его проектор; конец ведёт на такую строку или на производный класс; проектор пишет в класс вне своих `into` или не нашёл ни одного арендатора, у которого все классы `into` помечены его `derivedBy` |
2326
+ | `aggregate_field` | LT022 | приложение передало или изменило агрегатное поле (`aggregate`): его считает база |
2327
+ | `projector_pending` | LT023 | `purge` удалённого объекта, чьё удаление ещё не прочёл проектор его класса, или `trimHistory`, чью первую оставшуюся версию он ещё не прочёл: сначала `run()` проектора |
1738
2328
 
1739
2329
  Коды библиотеки:
1740
2330
 
@@ -1751,10 +2341,14 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1751
2341
  | `no_key` | `upsert`, `rekey` или `idOf` у класса без ключа |
1752
2342
  | `no_path` | между классами соседних шагов нет перехода |
1753
2343
  | `invalid_query` | цепочку или API используют неверно (модификатор шага `deep`, `exact`, `alias`, `tags` или `owner` после глагола, слот не после глагола, курсор `after()` со значением не того типа, что поле `sort`, без `v` или с `id` не uuid и т. п.) |
1754
- | `admin_required` | загрузчику нужно администраторское подключение |
2344
+ | `admin_required` | загрузчику, проектору и `rebuildAggregates` нужно администраторское подключение |
2345
+ | `rebuild_required` | проекция построена другой версией проектора (`version`): нужна перестройка `rebuild()`; `rebuild({ tenant })`, пока проекции нет или идёт перестройка пачками |
2346
+ | `projector_busy` | перестройку не начать: проектор сейчас работает в другом процессе |
1755
2347
  | `not_empty` | первая заливка (`initial`) — только в пустую схему |
1756
2348
  | `class_not_loadable` | строки `Class` загрузчик не принимает |
1757
- | `bypass_rls` | `connect()` с ролью, которая обходит RLS, без `allowBypassRls` |
2349
+ | `bypass_rls` | `connect()` с ролью, которая обходит RLS, без `allowBypassRls`; `createMcpServer` с подключением в обход RLS |
2350
+ | `user_session_required` | `createMcpServer` и `letopis mcp`: нужна сессия пользователя — у подключения нет сессии, это корень сервиса без `as()`, сессия в арендаторе System без `allowSystemAdmin`, в командной строке ключ сервиса или нет токена |
2351
+ | `confirmation_required` | `letopis_write` (MCP): удаление без подтверждения человека — клиент без elicitation, человек отказал, ответ не к этой операции или данные изменились после превью |
1758
2352
  | `removed` | снесённое API 0.21; в тексте — замена |
1759
2353
 
1760
2354
  Отказ PostgreSQL в праве (`42501`) приходит, например, при `upsert` поверх чужой строки, которую нельзя править.
@@ -1795,9 +2389,26 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1795
2389
  в конце — около 5 с. Подготовка строк в самом загрузчике (проверки, концы, id) идёт одновременно с `COPY` предыдущей
1796
2390
  пачки и теперь задаёт скорость: около 60 с из 77.
1797
2391
 
2392
+ Производные данные 1.2 (`cd lib && npx tsx bench/derived.bench.mjs`, 2026-10-27, PostgreSQL 18.6, одна машина; у
2393
+ агрегатов — медиана трёх кругов; строки проектора — повтор серий 2 и 3 после свёртки `inc` в `out`,
2394
+ `BENCH_SERIES=2,3`):
2395
+
2396
+ | Замер | 1.2 | Для сравнения |
2397
+ |---|---|---|
2398
+ | операция с агрегатом, один писатель | 94 операции/с, p50 10,7 мс | без агрегата — 137 и 6,9 мс; рецепт с `inc` — 69 |
2399
+ | операции одного кошелька с агрегатом, 20 писателей | 152 операции/с, p95 0,47 с | без агрегата — 1 987; рецепт с `inc` — 190 |
2400
+ | операции 20 кошельков с агрегатом, 20 писателей | 1 379 операций/с | без агрегата — 2 023 |
2401
+ | догонка проектора, `out.inc` на каждое изменение | 6 076 и 33 395 изменений/с (`batch` 500 и 2000) | до свёртки `inc` в `out` — 992 и 1 203 |
2402
+ | перестройка 75 тыс. строк | атомарная — 13,3–13,5 с, пачками — 13,0–13,2 с | до свёртки `inc` в `out` — 101 с и 58–63 с |
2403
+ | задержка события подписки во время перестройки | атомарной — до 13,4 с, пачками — до 0,21 с | без перестройки — до 0,12 с |
2404
+
2405
+ Триггеры агрегатов срабатывают на каждом операторе записи, но у классов без агрегатов их цены замер не видит: разница
2406
+ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101.bench.mjs`, четыре прогона) регрессий записи и чтения
2407
+ против 1.1 не показал: два выброса не повторились в отдельных проверках.
2408
+
1798
2409
  ## 18. Ограничения
1799
2410
 
1800
- Здесь собрано то, что letopis 1.1 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
2411
+ Здесь собрано то, что letopis 1.3 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
1801
2412
 
1802
2413
  **Платформа**
1803
2414
 
@@ -1877,11 +2488,20 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1877
2488
  - Счётчики попыток OTP и TOTP откатываются вместе с транзакцией вызывающего: частоту попыток ограничивает сервис.
1878
2489
  - Самостоятельная привязка занятого идентификатора отвечает `acl_denied` — пользователь с недавним входом так
1879
2490
  узнаёт, что идентификатор занят. Изменения прав во всех арендаторах упорядочены одной строкой счётчика.
1880
- - Число невидимых строк, мешающих удалению, и существование аккаунта по id (как цели ссылки) не скрываются.
2491
+ - Тексты отказов называют класс, роль и id ссылающейся строки, только если актор её видит: каскад удаления, снятие
2492
+ конца, перевод ссылок при смене класса и ключа, конфликт смены `parent` (`tightening_conflict`). Число невидимых строк, мешающих удалению или смене класса
2493
+ (`reclass_denied`), и существование аккаунта по id (как цели ссылки) не скрываются.
2494
+ - Ужесточение описания класса и `rekeyClass` проверяют все строки семейства, поэтому требуют права `READ` без условий
2495
+ на все строки класса и его потомков: иначе пробное ужесточение (`maximum`, `enum`, `pattern`) отвечало бы числом
2496
+ невидимых строк, которые ему не соответствуют. Системный администратор, ужесточая класс System, получает число
2497
+ нарушающих строк других арендаторов (`others`): это уровень установки.
1881
2498
  - Метаданные классов (`meta`) видны всем, у кого есть сессия: строки `Class` читает любой. Секретам там не место.
1882
- - Прикладная роль может замедлить установку для всех: `LOCK TABLE entity`, advisory-блокировки с предсказуемыми
1883
- ключами, поддельные сигналы `NOTIFY` (лишь сбрасывают кэш), частая смена прав (одна строка счётчика на
1884
- установку). По глобальному номеру журнала видна общая активность записи.
2499
+ - Прикладная роль может замедлить установку для всех: `LOCK TABLE entity`, advisory-блокировки с ключами `lock()`
2500
+ приложения, поддельные сигналы `NOTIFY` (лишь сбрасывают кэш), частая смена прав (одна строка счётчика на
2501
+ установку). По глобальному номеру журнала видна общая активность записи. Внутренние блокировки — семейства
2502
+ классов, проектора и обслуживания — строки таблицы `secret`, а не advisory: ни прикладная, ни посторонняя роль базы
2503
+ их не займёт. Писатель семейства проходит шлюз ужесточения в подтранзакции — одной на первую запись в семейство за
2504
+ транзакцию.
1885
2505
 
1886
2506
  **История, сигналы, кэш**
1887
2507
 
@@ -1906,6 +2526,12 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1906
2526
  - Очередь `NOTIFY` (8 ГБ) общая для кластера: зависший слушатель её переполняет, и тогда транзакции с `NOTIFY`
1907
2527
  падают при фиксации. Библиотека читает сигналы сразу, `maintain()` предупреждает о заполнении больше чем
1908
2528
  наполовину.
2529
+ - Транзакции с `NOTIFY` PostgreSQL фиксирует по одной: общую для кластера блокировку очереди сигналов фиксация держит
2530
+ вместе с записью на диск. Запись сырым SQL на своём соединении шлёт сигнал из триггера в той же транзакции, поэтому
2531
+ параллельные писатели сырым SQL фиксируются по очереди: на машине замеров 20 таких писателей делали около 520
2532
+ вставок в секунду. Транзакции библиотеки, в том числе `db.sql` и `db.transaction()`, фиксируют данные без `NOTIFY` и
2533
+ отправляют сигналы потом, отдельно, поэтому их писатели этой очереди не ждут: 20 писателей библиотеки делали около
2534
+ 2 000 операций в секунду.
1909
2535
  - Горизонт подписки общий для кластера: долгая пишущая транзакция задерживает события всех подписчиков
1910
2536
  (`sub.lag()`); длинные операции лучше вести пачками.
1911
2537
  - Кэш результатов между процессами устаревает на интервал отправки сигналов (100 мс) плюс доставку и на время
@@ -1914,6 +2540,64 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1914
2540
  - Курсор подписки «с этого момента» несёт снимок момента подписки, пока не пройдёт его границу: такой
1915
2541
  курсор длиннее обычного.
1916
2542
 
2543
+ **Проекторы**
2544
+
2545
+ - Проектор — процесс администратора: он работает только на администраторском подключении, читает журнал всех
2546
+ арендаторов в обход RLS и пишет результаты от роли владельца. Держите его в отдельном процессе, как загрузчик.
2547
+ Участник проектора — арендатор, у которого все классы `into` помечены его `derivedBy`; пометить класс может только
2548
+ администратор (право `letopis.projector`).
2549
+ - Итоги производного класса видит каждый, кому правила арендатора дают `READ` на него, — даже тот, кто не читает
2550
+ строки источников: решайте, кому его читать, правилами на сам производный класс. С `history: true` его журнал
2551
+ показывает изменение каждой пачки, то есть и вклад отдельных строк источников.
2552
+ - Строки производных классов пишет только их проектор: приложение, сырой SQL и загрузчик получают `derived_class`, а
2553
+ ссылаться на такие строки концами нельзя.
2554
+ - Атомарная перестройка — одна долгая пишущая транзакция: пока она идёт, горизонт подписок стоит, и события
2555
+ задерживаются у подписчиков и проекторов всех процессов. На машине замеров перестройка 75 тысяч строк шла 13 с, и
2556
+ до её конца подписчик не получил ни одного события. Поэтому сам `run()` перестраивает пачками, а атомарную
2557
+ перестройку запускает только явный `rebuild()`.
2558
+ - Перестройка пачками держит горизонт только на время пачки (в тех же замерах события задерживались до 0,2 с), но
2559
+ до её конца производные данные неполные: строки удалены сразу и появляются по мере перестройки.
2560
+ - Проектор видит только изменения из журнала, поэтому класс с `history: false` источником быть не может: арендатор
2561
+ с таким источником выбывает из проектора (`invalid_class`), пока журнал класса не включат и арендатора не
2562
+ перестроят.
2563
+ - Прореживание журнала старше яруса `all` ведёт к перестройке: проектор, отставший больше чем на этот ярус,
2564
+ получает `cursor_expired` или, с `onExpired: 'rebuild'`, перестраивается сам. Ярус — по арендатору: истёк только у
2565
+ части участников — выбывают и перестраиваются только они.
2566
+ - Проектор работает только на схеме той ревизии движка, которую ждёт библиотека; на схеме другой ревизии он
2567
+ отвечает `invalid_data`.
2568
+ - `purge()` ждёт проекторов: пока проектор, чьи источники включают класс объекта, а у арендатора объекта есть его
2569
+ производный класс, не прочёл удаление, `purge` отвечает `projector_pending`. Проектор, который больше не запускают,
2570
+ держит `purge` своих источников, пока у арендатора есть его производные классы: запустите его или удалите их.
2571
+ Так же ждёт `trimHistory`: пока проектор не прочёл первую оставшуюся версию, обрезка отвечает `projector_pending`.
2572
+
2573
+ **Агрегаты в базе**
2574
+
2575
+ - Агрегат выстраивает писателей одного объекта в очередь: каждая операция блокирует строку цели и пишет её версию.
2576
+ На машине замеров 20 писателей одного кошелька с агрегатом делали 152 операции в секунду (p95 — 0,47 с), без
2577
+ агрегата — 1 987. Для горячих счётчиков берите проектор со свёрткой пачкой.
2578
+ - `reclass` и `rekey` копят вклады до конца переноса, а каскад удаления и загрузчик применяют их по операторам:
2579
+ правило класса проверяется на итоге каждого оператора каскада и каждой пачки загрузчика. Поэтому загрузка, где
2580
+ списания идут пачкой раньше пополнений, получит `invalid_data`, хотя итог всей загрузки допустим. Каждая пачка,
2581
+ задевшая цель, пишет её версию.
2582
+ - Агрегат арендатора не считает строки класса System, а поле строки System считает только источники System.
2583
+
2584
+ **Агенты и MCP**
2585
+
2586
+ - Обёртка `<untrusted-data-…>` — смягчение, а не защита: модель может последовать тексту внутри неё. Защищают права
2587
+ базы: агент читает и пишет только то, что разрешено его пользователю. В `structuredContent` данные базы лежат в
2588
+ `data` и `error` рядом с пояснением `notice`, без меток: клиент, который отдаёт модели его, а не текст, передаёт
2589
+ данные с пояснением, но без случайной метки.
2590
+ - По умолчанию сервер только читает; запись — `write: true` (`--write`); удаление — только с подтверждением человека.
2591
+ Классы прав сервер пишет только с `allowAclWrite`, сессию в арендаторе System принимает только с
2592
+ `allowSystemAdmin`; `create`, `update` и `upsert` остальных классов подтверждения человека не требуют.
2593
+ - Обработчик HTTP в ревизии 2025 работает без состояния: экземпляр на запрос не знает возможностей клиента, поэтому
2594
+ удаление по нему отклоняется (`confirmation_required`). Удаляйте по stdio или в ревизии 2026-07-28.
2595
+ - Подтверждение в ревизии 2026-07-28 подписано ключом процесса: повтор вызова, попавший в другой процесс приложения
2596
+ (несколько экземпляров за балансировщиком), получает отказ `confirmation_required`.
2597
+ - `versions` отдаёт последние версии в пределах `maxRows` и `maxBytes` (из базы — не больше `maxRows` + 1 версий) и
2598
+ `more`; более ранние — `diff` и `blame` с `from`, `to`, `at`. Режим, в котором агент пишет код против API (code
2599
+ mode), в пакет не входит.
2600
+
1917
2601
  **Импорт из 0.21**
1918
2602
 
1919
2603
  - 1.0 — новая major-версия, а не продолжение 0.21: `count()`, `paths()`, `create`, роли, множественные концы
@@ -1931,7 +2615,8 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1931
2615
  # connect — подключение под сессией, отказ ролям в обход RLS, реестр, отложенный сигнал, перевод ошибок (этап 2)
1932
2616
  # context — сессии, вход сервиса по ключу, политики RLS этапа 1 (этап 1)
1933
2617
  # access-rest — оставшиеся строки этапа 5: загрузка от имени пользователя, баланс №20, 22, 29, upsert и { rev } под ACL, встраивание acl_ok (этап 5)
1934
- # acl — права ACL: перенос набора 0.21, условия на строки, вес и запрет по умолчанию, каскад, запрет на потомке, deep, невидимая цель, сырой SQL против библиотеки (этап 5)
2618
+ # aggregate — агрегаты в базе: 20 писателей одного кошелька, сырой SQL, каскад, reclass, restore, загрузчик, удаление аккаунта, restore и rekey цели, смена описания под записью и гонка №18, aggregate_field, ремонт и verify, строка System (план 1.1, этап 4б)
2619
+ # acl — права ACL: перенос набора 0.21, условия на строки, вес и запрет по умолчанию, каскад, запрет на потомке, deep, невидимая цель, сырой SQL против библиотеки; смена parent и alias класса — только с правами на все его строки, алиас — не имя другого класса, имя — не чужой алиас (этап 5; ревью безопасности №2)
1935
2620
  # acl-parity — 1000 случайных наборов правил: план прав и решения по эндпоинтам базы против decide 0.21 (этап 5)
1936
2621
  # auth — креды (пароль, ключ, ключ + секрет, OTP, TOTP, identity), выравнивание времени, регистр, гашение сессий, недавний вход (этап 5)
1937
2622
  # balance — пополнение и списание: идемпотентность по opId, гонки, взаимная блокировка, обрыв связи (TCP-прокси), корректировка под { rev } и forUpdate (этап 4)
@@ -1941,16 +2626,16 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1941
2626
  # chain-write — запись цепочками: create, update, upsert, delete с превью, anonymize, reclass, слоты, inc, { rev }, fan-out, откат плана (этап 4)
1942
2627
  # demo — демо-домен booking на Zod: сид из моделей, запись данных, id по ключу, согласие Zod и базы (этап 2)
1943
2628
  # docs — примеры документации исполняются: помеченные блоки README и шпаргалки (русские и английские) — сценариями против PostgreSQL 18 (этап 8)
1944
- # engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными исходами; ключ сигналов без права, заливка поддельными сигналами, в том числе Class (этапы 1, 3; план 1.1, этап 2 и точка А)
1945
- # engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение, метаданные (этап 1)
2629
+ # 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)
2630
+ # engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение, метаданные; класс с историей строк удаляется только после purge() (этап 1; доработки после точки Б)
1946
2631
  # 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)
2632
+ # engine-races — гонки на двух соединениях в обоих порядках без пауз по времени; ужесточение во время непрерывной записи семейства не голодает; блокировка семейств, чьи корни отличаются только регистром, и подложенная строка вида LOCK (этап 1; ревью безопасности №3)
2633
+ # explain — индексы цепочек от роли приложения: снаружи первичный ключ и (class, tenant), внутри find_*() — GIN; срез asOf по классу не читает журнал другого арендатора (этап 3; доработки после точки Б)
2634
+ # find-ids-parity — 30 цепочек через find_*() и прямым чтением под RLS дают одинаковые ответы; свои членства в других арендаторах и строки Class System — сейчас, asOf и withDeleted (этап 3; доработки после точки Б)
1950
2635
  # hash — цепочка хэшей версий, подмена и разрыв журнала, verify() (этап 1)
1951
2636
  # harness — тестовая обвязка: подъём базы, схема на файл, две строки подключения (этап 0)
1952
2637
  # jsonschema — компиляция JSON Schema в jsonpath и проверка данных в базе (этап 1)
1953
- # impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor (этап 5)
2638
+ # impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б); реестр фасада не общий с корнем, пустой реестр не кэшируется (хвост ревью безопасности №3)
1954
2639
  # install — установщик и роли: повторная установка, две установки в базе, три таблицы (этап 1)
1955
2640
  # member — членство: работа в арендаторе по ролям, auth.switch, выход с переходом владения, системные строки, роли арендатора без прав System (этап 5)
1956
2641
  # model — модели на Zod, нормализатор JSON Schema, перевод шаблонов, совпадение с метасхемой базы (этап 2)
@@ -1974,11 +2659,14 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1974
2659
  # cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков сигналов (этап 6; план 1.1, точка А)
1975
2660
  # maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6)
1976
2661
  # up — полный установщик: свежая установка, повторный накат, свои сиды, автосоздание базы, UTF8, версия 17, upgrade с миграциями, PGlite, первый арендатор и пользователь (up({ tenant }), createTenant) (этапы 6, 8)
2662
+ # upgrade — обновление схемы с 1.1.0 (повторный накат) и с 1.0.1 (upgrade: true, миграция 0006) пакетами letopis-11 и letopis-1: версии и хэши прежнего движка, verify(), каталог против чистой установки, строки-замки, приложение, подписка и проектор по старым данным, повторный up() (доработки после 1.2.0, этап 1)
1977
2663
  # scenario-salon — сквозной сценарий салона: 21 акт 0.21 (salon) с матрицей покрытия API 1.0 (этап 6)
1978
2664
  # telemetry — onQuery и slowMs, describe() и метаданные классов с наследованием, describeText (этап 6)
1979
2665
  # history — diff и blame по журналу, причина изменения у глаголов, db.begin() и батча; версия, скрытая условием ACL, колонка changed, пропуск версии, row_changes на 20 тыс. целей (план 1.1, этап 1 и точка А)
1980
2666
  # signals — режимы сигналов: нагрузка у посторонней роли, точность кэша до арендатора, System, классы прав, процесс без ключа, загрузчик, подписка, ключ, none, склейка, версия сервера, смена класса, повтор отправки, режим после up() и reset({ all }) (план 1.1, этап 2)
1981
- # initial-load — первая заливка: равенство с обычным загрузчиком, структура и режимы триггеров, системные строки, непустая схема, вставка во время запуска, откат, MULTI_INSERT в WAL, commitEvery, import --initial (план 1.1, этап 3)
2667
+ # initial-load — первая заливка: равенство с обычным загрузчиком, структура и режимы триггеров, системные строки, непустая схема, вставка во время запуска, откат, MULTI_INSERT в WAL, commitEvery, import --initial, агрегаты (agg_fill) (план 1.1, этапы 3 и 4б)
2668
+ # 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)
2669
+ # 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
2670
  # import — импорт из 0.21 по фикстуре настоящей 0.21: нарушение ключа, --keyless, счётчики по классам, verify, самоссылка, повтор, id без ключа, висячая ссылка, $$strict и поля на $, update, креды, CLI (этап 7)
1983
2671
  # security-final — итоговое ревью безопасности (этап 8, §7.10 №8.4): блокировка семейства по арендатору корня, account_purge без права и restore/purge чужого id не дают ни утечки, ни блокировки; функции этапа 6, log_refs_at, срезы find_* по журналу и watch_read не выходят за свой арендатор; NOTIFY несёт только имена или токены режима; ключ кэша различает токен, актора и арендатора (этап 8)
1984
2672
  ```