letopis 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.en.md +117 -10
- package/AGENT-CHEATSHEET.md +110 -8
- package/CHANGELOG.md +739 -0
- package/README.en.md +873 -68
- package/README.md +830 -66
- package/dist/admin.d.ts +24 -4
- package/dist/admin.js +39 -9
- package/dist/cache.d.ts +33 -9
- package/dist/cache.js +81 -32
- package/dist/chain.d.ts +29 -7
- package/dist/chain.js +61 -12
- package/dist/cli.js +59 -7
- package/dist/errors.js +4 -2
- package/dist/import.d.ts +6 -0
- package/dist/import.js +196 -175
- package/dist/index.d.ts +27 -5
- package/dist/index.js +191 -29
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.d.ts +63 -0
- package/dist/load.js +331 -44
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +810 -0
- package/dist/model.d.ts +34 -0
- package/dist/model.js +17 -0
- package/dist/project.d.ts +309 -0
- package/dist/project.js +1097 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/signals.d.ts +73 -0
- package/dist/signals.js +169 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +26 -7
- package/dist/tx.d.ts +9 -7
- package/dist/tx.js +15 -5
- package/dist/typed.d.ts +21 -6
- package/dist/types.d.ts +77 -4
- package/dist/types.js +8 -3
- package/dist/up.d.ts +16 -2
- package/dist/up.js +34 -4
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +23 -2
- package/dist/watch.js +44 -7
- package/dist/write.d.ts +11 -2
- package/dist/write.js +57 -10
- package/llms.txt +9 -2
- package/package.json +13 -2
- package/sql/10-core.sql +13 -2
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +611 -82
- package/sql/70-read.sql +33 -8
- package/sql/75-history.sql +358 -0
- package/sql/80-auth.sql +7 -7
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +134 -21
- package/sql/95-seed.system.sql +24 -6
- package/sql/99-revision.sql +1 -1
- package/sql/README.md +5 -1
- package/sql/migrate/0006-changed-paths.sql +3 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# letopis 1.
|
|
1
|
+
# letopis 1.2
|
|
2
2
|
|
|
3
3
|
**letopis** — библиотека для Node.js, хранилище версионированных сущностей на PostgreSQL 18. Каждое изменение
|
|
4
4
|
сохраняется версией в журнале. Поэтому у любого объекта есть история, состояние на любую дату и подписка на
|
|
@@ -15,11 +15,15 @@ RLS. Правила одинаково действуют и для библио
|
|
|
15
15
|
- **Запись цепочками**: `create`, `update` со слиянием, `upsert`, удаление с превью и каскадом, приращение `inc`
|
|
16
16
|
для денег и счётчиков — [разделы 5](#5-запись), [7](#7-удаление-и-восстановление) и [8](#8-транзакции-батчи-и-деньги).
|
|
17
17
|
- **Чтение цепочками**: переходы по связям, фильтры, деревья, агрегаты, постраничный вывод — [раздел 6](#6-чтение).
|
|
18
|
-
- **История**: версии, срез на дату,
|
|
19
|
-
[раздел 11](#11-история-и-срезы-на-дату).
|
|
18
|
+
- **История**: версии, срез на дату, что изменилось и кто (`diff`, `blame`), восстановление удалённого, политика
|
|
19
|
+
хранения, проверка цепочки хэшей — [раздел 11](#11-история-и-срезы-на-дату).
|
|
20
20
|
- **Арендаторы, вход и права** — в самой базе: пароли, ключи API, одноразовые коды, TOTP, сессии, имперсонация,
|
|
21
21
|
правила доступа — [раздел 10](#10-пользователи-вход-и-права).
|
|
22
22
|
- **Подписка на изменения** по курсору без потерь и **кэш результатов** — [раздел 12](#12-подписка-на-изменения-и-кэш).
|
|
23
|
+
- **Производные данные**: агрегаты в базе — суммы и счётчики, которые держит сама база, — и проекторы, которые строят
|
|
24
|
+
сводки из журнала, — [разделы 4](#агрегаты-в-базе) и [12](#проекторы).
|
|
25
|
+
- **Агенты и MCP**: сервер MCP даёт ИИ-агенту модель, чтение, историю и, если разрешено, запись — с правами одного
|
|
26
|
+
пользователя — [раздел 13](#агенты-и-mcp).
|
|
23
27
|
- **Массовая загрузка** миллионов строк и **импорт из 0.21** — [разделы 9](#9-массовая-загрузка) и [14](#14-переход-с-021).
|
|
24
28
|
|
|
25
29
|
Требования:
|
|
@@ -28,7 +32,8 @@ RLS. Правила одинаково действуют и для библио
|
|
|
28
32
|
- Node.js 22 или новее; пакет — модуль ES.
|
|
29
33
|
- Рантайм-зависимость одна — `postgres`. Zod 4 — необязательная peer-зависимость: он нужен только подпути
|
|
30
34
|
`letopis/model`, а `import { connect } from 'letopis'` работает и без него. Для разработки без PostgreSQL —
|
|
31
|
-
необязательные `@electric-sql/pglite` и `@electric-sql/pglite-socket`.
|
|
35
|
+
необязательные `@electric-sql/pglite` и `@electric-sql/pglite-socket`. Серверу MCP (`letopis/mcp` и
|
|
36
|
+
`letopis mcp`) нужна необязательная `@modelcontextprotocol/server` версии 2.4 или новее в линейке 2.
|
|
32
37
|
|
|
33
38
|
## Оглавление
|
|
34
39
|
|
|
@@ -152,8 +157,8 @@ await svc.close()
|
|
|
152
157
|
|
|
153
158
|
Таблиц три. `entity` — текущее состояние: одна строка на каждую живую сущность, включая строки системных классов
|
|
154
159
|
(`Class`, `Account`, `Resource`, `member`, `rule`). `log` — журнал: полный снимок каждой версии с автором,
|
|
155
|
-
временем, видом операции и хэшем. `secret` —
|
|
156
|
-
работают только функции
|
|
160
|
+
временем, видом операции и хэшем. `secret` — креды, сессии и чекпоинты проекторов: приложение не видит в ней ни
|
|
161
|
+
одной строки, с ней работают только функции входа и проекторов.
|
|
157
162
|
|
|
158
163
|
Классы — тоже сущности. Описание класса лежит в строке `Class`; там же база хранит развёрнутое описание с учётом
|
|
159
164
|
предков и скомпилированную проверку данных. Хэш версии — генерируемая колонка: sha256 от хэша прошлой версии и
|
|
@@ -188,10 +193,26 @@ LOGIN-роль состоит только в ней. Всё, что выпол
|
|
|
188
193
|
|
|
189
194
|
### Сигналы
|
|
190
195
|
|
|
191
|
-
Запись сообщает об
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
Запись сообщает об изменениях через `NOTIFY`. Канал — полное имя схемы установки вместе с версией, например
|
|
197
|
+
`v2.shop`; из-за точки в SQL его имя берут в двойные кавычки: `LISTEN "v2.shop"`. Запись сырым SQL на своём
|
|
198
|
+
соединении шлёт сигнал из триггера, и PostgreSQL доставляет его при фиксации. Транзакции библиотеки, в том числе
|
|
199
|
+
`db.sql` и `db.transaction()`, шлют сигналы отложенно: нотификатор процесса отправляет их раз в 100 мс
|
|
200
|
+
(`notifyIntervalMs`), а неотправленные из-за ошибки — повторяет. Что несёт сигнал записи (его нагрузка, payload),
|
|
201
|
+
задаёт режим установки `up({ signals })`:
|
|
202
|
+
|
|
203
|
+
| Режим | Нагрузка сигнала записи | Что видит любая роль, которая может подключиться к базе |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| `class` (по умолчанию) | имя класса: `Booking` | имена классов и время записи |
|
|
206
|
+
| `tenant` | `Класс@токен арендатора`: `Booking@3f9c…` | ещё и псевдонимы арендаторов |
|
|
207
|
+
| `opaque` | `токен класса@токен арендатора`: `9a1e…@3f9c…` | только время и частоту записи по псевдонимам |
|
|
208
|
+
| `none` | сигналов нет | ничего |
|
|
209
|
+
|
|
210
|
+
Токен — 24 шестнадцатеричных знака ключевого HMAC: сопоставить псевдоним с именем может только процесс с ключом
|
|
211
|
+
сигналов, а ключ выдаётся сервисам (право `letopis.signals`). Кроме сигналов записи, есть служебные: погашенная
|
|
212
|
+
сессия шлёт `session:<16 hex>` (первые 16 шестнадцатеричных знаков sha256 её токена), сброс сессий — `session:*`,
|
|
213
|
+
сброс арендатора или всей установки — `*` (сброс всей установки шлёт ещё и `session:*`). В режиме `none` нет и их.
|
|
214
|
+
Ни id, ни данных в сигналах нет. По сигналам перечитывается реестр классов, сбрасывается кэш результатов и будится
|
|
215
|
+
подписка; что меняет режим для кэша и подписки — в [разделе 12](#12-подписка-на-изменения-и-кэш).
|
|
195
216
|
|
|
196
217
|
## 3. Установка и подключение
|
|
197
218
|
|
|
@@ -210,6 +231,7 @@ LOGIN-роль состоит только в ней. Всё, что выпол
|
|
|
210
231
|
| `history` | политика хранения истории для классов без своей ([раздел 11](#11-история-и-срезы-на-дату)) |
|
|
211
232
|
| `serviceKey` | свой ключ сервиса вместо случайного: `lts_` и 32–128 шестнадцатеричных знаков |
|
|
212
233
|
| `allowReset` | разрешить `db.reset()` ([раздел 13](#13-эксплуатация)); по умолчанию выключено |
|
|
234
|
+
| `signals` | режим сигналов: `'class'` (по умолчанию), `'tenant'`, `'opaque'` или `'none'` ([«Сигналы»](#сигналы)); режим хранится в схеме — `up()` без опции его не меняет |
|
|
213
235
|
| `fresh` | удалить схему и поставить заново — **данные схемы теряются** |
|
|
214
236
|
| `upgrade` | обновить схему старой ревизии: миграции и повторный накат |
|
|
215
237
|
| `pglite` | база для разработки без PostgreSQL (см. ниже) |
|
|
@@ -218,7 +240,16 @@ LOGIN-роль состоит только в ней. Всё, что выпол
|
|
|
218
240
|
|
|
219
241
|
Результат: `schema` (полное имя), `serviceKey` (при первой установке и при накате, когда в установке не осталось ни
|
|
220
242
|
одного действующего ключа API; иначе `null`), `created`,
|
|
221
|
-
`upgraded`, `revision`, `migrations`, `analyzed`, `owner`, `app`,
|
|
243
|
+
`upgraded`, `revision`, `migrations`, `analyzed`, `owner`, `app`, `signals` (режим сигналов установки), `warnings`
|
|
244
|
+
(предупреждения, без `quiet` они печатаются), а также `tenant` и `pglite`, если их просили. На PostgreSQL ниже 18.2
|
|
245
|
+
`up()` предупреждает: такой сервер мог усечь CLOG, который ещё нужен непрочитанным сигналам, — обновите его.
|
|
246
|
+
|
|
247
|
+
Скрыть из сигналов имена классов — режим `opaque` (или `tenant`, если имена не секрет, а нужна точность кэша по
|
|
248
|
+
арендатору); после смены режима перезапустите процессы приложения — режим читается при подключении:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
await up({ dsn: ADMIN, schema: 'shop', signals: 'opaque' }) // дальше up({ dsn, schema }) режим не меняет
|
|
252
|
+
```
|
|
222
253
|
|
|
223
254
|
<!-- run: guide -->
|
|
224
255
|
```ts
|
|
@@ -238,8 +269,8 @@ const ANNA = r.tenant!.user! // его первый пользовател
|
|
|
238
269
|
Приложение работает от LOGIN-роли, которая состоит только в `letopis_app`:
|
|
239
270
|
`create role shop_app login password '…'; grant letopis_app to shop_app;`. Тогда на него действуют RLS и
|
|
240
271
|
триггеры, как и на любой сырой SQL от этой роли. Роль, которая обходит RLS, `connect()` отклоняет с ошибкой
|
|
241
|
-
`bypass_rls`. Администраторское подключение (`allowBypassRls: true`) нужно только загрузчику,
|
|
242
|
-
обслуживанию; держите его в отдельном процессе, а не в веб-приложении.
|
|
272
|
+
`bypass_rls`. Администраторское подключение (`allowBypassRls: true`) нужно только загрузчику, проекторам, ремонту
|
|
273
|
+
агрегатов (`rebuildAggregates`), проверке и обслуживанию; держите его в отдельном процессе, а не в веб-приложении.
|
|
243
274
|
|
|
244
275
|
### Первый арендатор и пользователь
|
|
245
276
|
|
|
@@ -311,7 +342,8 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
|
|
|
311
342
|
|
|
312
343
|
### Администраторское подключение
|
|
313
344
|
|
|
314
|
-
|
|
345
|
+
Загрузчику, проекторам и `rebuildAggregates` нужно подключение роли, которая может стать владельцем, с
|
|
346
|
+
`allowBypassRls: true`. `verify()` и
|
|
315
347
|
`maintain()` работают и на нём, и у системного администратора по обычному подключению. Администраторское
|
|
316
348
|
подключение печатает предупреждение «RLS не действует» — так и должно быть. Сессия задаёт арендатора, с которым
|
|
317
349
|
оно работает. Отдельному процессу загрузки удобнее ключ сервиса: `connect({ dsn: ADMIN, schema, apiKey,
|
|
@@ -324,12 +356,18 @@ const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token
|
|
|
324
356
|
|
|
325
357
|
### Обновление пакета и схемы
|
|
326
358
|
|
|
327
|
-
У схемы есть ревизия движка (сейчас
|
|
359
|
+
У схемы есть ревизия движка (сейчас 6). Если новая версия пакета принесла новую ревизию, `connect()` только
|
|
328
360
|
предупредит и назовёт команду. Отказывает `up()`: на отставшую схему без `upgrade: true` и на схему новее пакета.
|
|
329
361
|
Поэтому скрипт запуска, который вызывает `up()` при каждом старте, после обновления пакета остановится, пока не
|
|
330
362
|
получит `upgrade: true`. `up({ dsn, schema, upgrade: true })` применяет миграции из `sql/migrate/` и повторно
|
|
331
363
|
накатывает функции; данные сохраняются.
|
|
332
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
|
+
|
|
333
371
|
### Разработка без PostgreSQL: PGlite
|
|
334
372
|
|
|
335
373
|
`up({ pglite: true })` поднимает PGlite (PostgreSQL 18 на WebAssembly) с pgcrypto и сокетом на свободном порту.
|
|
@@ -456,11 +494,68 @@ id из email подбирается перебором и остаётся в
|
|
|
456
494
|
|
|
457
495
|
- `history: false` — журнал для класса не ведётся: нет версий, среза на дату, восстановления и событий подписки.
|
|
458
496
|
Политика хранения — `history: { all: '1 day', daily: '3 months', … }` ([раздел 11](#11-история-и-срезы-на-дату)).
|
|
497
|
+
Сменить `history` у существующего класса может только тот, у кого есть право `READ` и `DELETE` без условий на все
|
|
498
|
+
строки класса и его потомков: по политике `maintain()` прореживает журнал, а `false` его выключает.
|
|
459
499
|
- `ownerDefault: 'actor'` — владелец новых строк — сам пользователь, а не арендатор (по умолчанию `'tenant'`).
|
|
460
500
|
Пригодится для правила «каждый видит свои записи».
|
|
461
501
|
- `cache: { ttl }` — чтения только этого класса кэшируются без `.cache()` ([раздел 12](#12-подписка-на-изменения-и-кэш)).
|
|
462
502
|
- `meta` — свободные метаданные для приложения: название, иконка, цвет, порядок в меню. До 64 КБ, наследуются по
|
|
463
503
|
ключам (`null` убирает унаследованное значение), видны всем, у кого есть сессия. У конца — `.meta()`.
|
|
504
|
+
- `derivedBy: 'имя-проектора'` — производный класс: его строки пишет только этот проектор ([«Проекторы»](#проекторы)).
|
|
505
|
+
Имя — строчные латинские буквы, цифры и `_.:-` (первый знак — буква или цифра), до 64 символов. Опция
|
|
506
|
+
наследуется, потомок её не меняет. Концы производного класса — только `onDelete: 'cascade'` или `'unset'`:
|
|
507
|
+
производная строка не мешает удалять цель. На строки производного класса не ссылаются. Приложение, сырой SQL и
|
|
508
|
+
загрузчик в такой класс не пишут: запись, правка и удаление его строк — `derived_class`. Сам производный класс
|
|
509
|
+
(свой или унаследованный) заводит, меняет и удаляет только администратор — администраторское подключение или
|
|
510
|
+
аккаунт с правом System `letopis.projector`; у сессии приложения это `acl_denied` ([«Проекторы»](#проекторы)).
|
|
511
|
+
- `aggregate: { поле: { sum | count, of, via, where } }` — агрегат в базе: сумму или счётчик строк источника держит
|
|
512
|
+
сама база ([«Агрегаты в базе»](#агрегаты-в-базе)).
|
|
513
|
+
|
|
514
|
+
### Агрегаты в базе
|
|
515
|
+
|
|
516
|
+
Агрегат — поле, которое держит сама база: сумма поля или число живых строк класса-источника, чей конец ведёт на
|
|
517
|
+
строку. Он нужен там, где итог обязан совпадать с источником и для сырого SQL: баланс кошелька, число позиций заказа.
|
|
518
|
+
|
|
519
|
+
```ts
|
|
520
|
+
const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0), deposits: z.number().int().optional() }, {
|
|
521
|
+
key: ['number'],
|
|
522
|
+
aggregate: {
|
|
523
|
+
balance: { sum: 'amount', of: 'Movement', via: 'Wallet' }, // сумма операций
|
|
524
|
+
deposits: { count: true, of: 'Movement', via: 'Wallet', where: { properties: { amount: { minimum: 1 } } } }, // число пополнений
|
|
525
|
+
},
|
|
526
|
+
})
|
|
527
|
+
const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet } })
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
- `sum: 'поле'` — сумма числового поля источника, `count: true` — число строк. `of` — класс-источник (имя или
|
|
531
|
+
модель) вместе с потомками, `via` — его одиночный конец, который ведёт на строку-цель. `where` — JSON Schema над
|
|
532
|
+
данными источника: строки, которые её не проходят, в агрегат не входят.
|
|
533
|
+
- Считаются только живые строки того же арендатора, что и цель. Ссылка на строку System в агрегат не входит: общий
|
|
534
|
+
справочник не считает данные всех арендаторов. Агрегат класса арендатора, в том числе унаследованный от класса
|
|
535
|
+
System, считает только классы своего арендатора: класс System с именем `of` в него не входит, а свой потомок класса
|
|
536
|
+
System входит. Агрегат класса System считает классы System и классы арендатора строки-цели.
|
|
537
|
+
- Поле пересчитывает триггер после каждого оператора: запись библиотеки, сырой SQL, каскад удаления, `restore`,
|
|
538
|
+
`reclass`, `rekey`, загрузчик, удаление аккаунта. Операции одного оператора складываются в одно приращение цели, а
|
|
539
|
+
правило класса (`min(0)`) проверяется над итогом: операция, которая увела бы баланс в минус, отклоняется с
|
|
540
|
+
`invalid_data`. Каждое изменение цели — её новая версия с автором и причиной операции.
|
|
541
|
+
- Поле пишет только база. Значение в `create`, `update`, `upsert`, `rekey`, `reclass` или в сыром SQL — ошибка
|
|
542
|
+
`aggregate_field`. Новая строка получает итог уже существующих источников, обычно 0. Замена данных целиком без
|
|
543
|
+
поля сохраняет прежнее значение. Загрузчик переданное значение не пишет. Права на запись в цель не нужно:
|
|
544
|
+
достаточно права на запись в источник.
|
|
545
|
+
- Описание проверяет база (`invalid_class`): поле есть в схеме, оно числовое и не входит в ключ; конец `via` —
|
|
546
|
+
одиночный и принимает класс цели; поле `sum` — число; агрегаты не образуют цикл; цель и источник — не системные и
|
|
547
|
+
не производные классы. Источник, описанный позже цели, сверяется при появлении.
|
|
548
|
+
- Строки попадают в агрегат только с ведома того, кто читает их все: поле считает и строки, которые автору не видны.
|
|
549
|
+
Описать или изменить агрегат может только тот, кто без условий читает все строки источника и его потомков. Сделать
|
|
550
|
+
класс источником (новый класс, смена `parent`, источник после цели) может только тот, кто без условий читает все
|
|
551
|
+
строки этого класса и его потомков — и до правки, и после. Иначе `acl_denied`.
|
|
552
|
+
- Опция наследуется, потомок заменяет её целиком. Добавить или изменить агрегат — ужесточение: запись в цель и в
|
|
553
|
+
источники ждёт, пока поле пересчитывается у всех строк.
|
|
554
|
+
- `db.rebuildAggregates(класс)` пересчитывает поле из текущих строк — для ремонта. Версия пишется только там, где
|
|
555
|
+
значение изменилось. Расхождения показывает `db.verify({ data: true })`. Нужно администраторское подключение.
|
|
556
|
+
- Сырой `INSERT … ON CONFLICT DO UPDATE SET data = excluded.data` для цели отклоняется: поле в `excluded.data` уже
|
|
557
|
+
поставил триггер вставки. Пишите `excluded.data - array['balance']`, и поле останется прежним; `upsert` библиотеки
|
|
558
|
+
делает так сам.
|
|
464
559
|
|
|
465
560
|
### Записать модель в базу
|
|
466
561
|
|
|
@@ -598,6 +693,13 @@ await db.Product(kettle).update({ views: inc(1, { start: 0 }) }).rows() // п
|
|
|
598
693
|
- маркер `inc` нельзя получить из JSON: `{ "$inc": 5 }`, пришедший из сети, остаётся данными;
|
|
599
694
|
- деньги храните целым числом мелких единиц (копеек) не больше 2^53 − 1.
|
|
600
695
|
|
|
696
|
+
Счётчик, который обязан совпадать с источником и для сырого SQL, удобнее держать агрегатом
|
|
697
|
+
([«Агрегаты в базе»](#агрегаты-в-базе)): его пересчитывает сама база, и забыть `inc` нельзя. Цена у агрегата и `inc`
|
|
698
|
+
одна: операции одного объекта идут друг за другом под блокировкой его строки, и каждая пишет его версию. На машине
|
|
699
|
+
замеров один объект так принимал 150–190 операций в секунду, сколько бы писателей его ни ждало
|
|
700
|
+
([раздел 17](#17-производительность)). Для более горячих счётчиков берите проектор со свёрткой пачкой
|
|
701
|
+
([«Проекторы»](#проекторы)): итог отстаёт от источника, зато писатели не ждут друг друга.
|
|
702
|
+
|
|
601
703
|
### Защита от параллельной правки: `{ rev }`
|
|
602
704
|
|
|
603
705
|
<!-- run: guide -->
|
|
@@ -628,9 +730,12 @@ const [vip] = await db.Customer(olga).reclass('Vip', { name: 'Ольга', email
|
|
|
628
730
|
- `rekey(патч)` — новое значение ключа: строка с новым id, все ссылки переведены, старая строка удалена. Если
|
|
629
731
|
ссылающаяся строка включает этот конец в свой ключ, её id тоже меняется, и перевод идёт дальше. Журнал связывает
|
|
630
732
|
старый и новый id (`moved`).
|
|
631
|
-
- `db.rekeyClass(класс, ключ)` — новый ключ для всех строк семейства, одной транзакцией.
|
|
632
|
-
|
|
633
|
-
|
|
733
|
+
- `db.rekeyClass(класс, ключ)` — новый ключ для всех строк семейства, одной транзакцией. У производного класса и
|
|
734
|
+
у класса с производным потомком — `derived_class`: строки производного класса пишет только проектор.
|
|
735
|
+
- `reclass(класс, данные)` — смена класса; данные необязательны, а если переданы — задаются целиком. Строка уходит из
|
|
736
|
+
прежнего класса, поэтому нужно право `DELETE` на неё, `WRITE` — на новый класс и на каждую строку, чьи ссылки
|
|
737
|
+
переписывает перевод, на любой глубине. Если у нового класса нет ключа, id сохраняется (как у `vip` выше); если
|
|
738
|
+
есть — id вычисляется заново, а ссылки переводятся.
|
|
634
739
|
Роли ссылающихся строк должны принимать новый класс, иначе `reclass_denied`. Сырой `UPDATE … SET class`
|
|
635
740
|
проходит, только если ключа нет ни у старого, ни у нового класса (иначе `use_reclass`).
|
|
636
741
|
- Удаление, смена класса и смена ключа работают только в `read committed`: транзакции библиотеки всегда открываются
|
|
@@ -779,7 +884,8 @@ const avgPrice = await db.Shop(shop).Product().avg('data.price')
|
|
|
779
884
|
const byStatus = await db.Order().countBy('data.status') // { new: 1 }
|
|
780
885
|
```
|
|
781
886
|
|
|
782
|
-
Агрегаты считает база, по сущностям последнего шага.
|
|
887
|
+
Агрегаты считает база, по сущностям последнего шага. `limit(n)` и `offset(n)` перед `countBy()` относятся к группам:
|
|
888
|
+
`db.Order().limit(3).countBy('data.status')` — три самых частых значения.
|
|
783
889
|
|
|
784
890
|
### Пути
|
|
785
891
|
|
|
@@ -846,8 +952,11 @@ await db.Customer(temp).purge({ confirm: true }).rows() // история
|
|
|
846
952
|
```
|
|
847
953
|
|
|
848
954
|
`purge` работает только для удалённых объектов (живой — `not_deleted`) и стирает их историю целиком; на месте
|
|
849
|
-
версии 1 остаётся запись `purge` с числом стёртых версий. История системных классов не стирается.
|
|
850
|
-
`
|
|
955
|
+
версии 1 остаётся запись `purge` с числом стёртых версий. История системных классов не стирается. Пока проектор,
|
|
956
|
+
чьи источники `from` включают класс объекта ([«Проекторы»](#проекторы)), не прочёл удаление, `purge` отказывает
|
|
957
|
+
(`projector_pending`): стёртое надгробие проектор не увидел бы, и вклад объекта остался бы в итогах. Удаление и
|
|
958
|
+
`purge` одной транзакцией в таком классе поэтому не проходят. Удалить аккаунт — `db.auth.purgeAccount`
|
|
959
|
+
([раздел 10](#10-пользователи-вход-и-права)).
|
|
851
960
|
|
|
852
961
|
## 8. Транзакции, батчи и деньги
|
|
853
962
|
|
|
@@ -989,6 +1098,65 @@ try {
|
|
|
989
1098
|
- Перевод — две операции в одной `db.begin()`, кошельки правятся в порядке id: тогда взаимных блокировок нет.
|
|
990
1099
|
- Нехватку средств отличают от других ошибок данных по `e.issues`: поле `balance`, правило `minimum`.
|
|
991
1100
|
|
|
1101
|
+
### Рецепт: баланс агрегатом
|
|
1102
|
+
|
|
1103
|
+
Тот же кошелёк, только баланс держит сама база: `balance` — агрегат, сумма `amount` живых операций
|
|
1104
|
+
([«Агрегаты в базе»](#агрегаты-в-базе)). Операция — одна запись `Movement`, приращения `inc` в коде нет, и забыть его
|
|
1105
|
+
нельзя. Баланс остаётся верным, даже когда операции пишет сырой SQL или загрузчик, а удаляет каскад.
|
|
1106
|
+
|
|
1107
|
+
```ts
|
|
1108
|
+
const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0) }, {
|
|
1109
|
+
ends: { Customer }, key: ['number'],
|
|
1110
|
+
aggregate: { balance: { sum: 'amount', of: 'Movement', via: 'Wallet' } }, // баланс — сумма живых операций
|
|
1111
|
+
})
|
|
1112
|
+
const Movement = link('Movement', { opId: z.string(), amount: z.number().int() },
|
|
1113
|
+
{ ends: { Wallet }, key: ['Wallet', 'opId'] }) // ключ операции — идемпотентность
|
|
1114
|
+
|
|
1115
|
+
/** Операция: amount больше нуля — пополнение, меньше — списание. База пересчитает баланс в той же транзакции. */
|
|
1116
|
+
async function operate(db: Db, wallet: string, opId: string, amount: number) {
|
|
1117
|
+
try {
|
|
1118
|
+
await db.Wallet(wallet).Movement().create({ opId, amount }).rows()
|
|
1119
|
+
return 'done'
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
if (e instanceof LetopisError && e.code === 'exists') return 'repeat' // повтор после потери ответа
|
|
1122
|
+
throw e // invalid_data — баланс ушёл бы в минус (min(0))
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** Корректировка «баланс = target»: операция на разницу, кошелёк заблокирован до commit. */
|
|
1127
|
+
async function adjust(db: Db, wallet: string, opId: string, target: number) {
|
|
1128
|
+
const tx = await db.begin()
|
|
1129
|
+
try {
|
|
1130
|
+
const w = (await tx.Wallet(wallet).forUpdate().first())!
|
|
1131
|
+
await tx.Wallet(wallet).Movement().create({ opId, amount: target - (w.data.balance as number) }).rows()
|
|
1132
|
+
await tx.commit()
|
|
1133
|
+
return 'done'
|
|
1134
|
+
} catch (e) {
|
|
1135
|
+
await tx.rollback()
|
|
1136
|
+
if (e instanceof LetopisError && e.code === 'exists') return 'repeat' // корректировка с этим opId уже записана
|
|
1137
|
+
throw e
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const [wallet] = await db.Customer(olga).Wallet().create({ number: 'W-1' }).rows() // balance — 0: его ставит база
|
|
1142
|
+
await operate(db, wallet.id, 'op-1', 10_000) // баланс 10 000
|
|
1143
|
+
await operate(db, wallet.id, 'op-2', -3_000) // баланс 7 000
|
|
1144
|
+
await adjust(db, wallet.id, 'op-3', 5_000) // операция на −2 000, баланс 5 000
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
- Модель здесь своя, и блок в программу примера не входит: там баланс растёт через `inc`, а агрегатное поле так не
|
|
1148
|
+
меняется.
|
|
1149
|
+
- `balance` не передают ни в `create`, ни в `update` (`aggregate_field`), поэтому корректировка — тоже операция, а не
|
|
1150
|
+
`update` под `{ rev }`. Чтобы записать операцию, право на запись в кошелёк не нужно: хватает права на запись в
|
|
1151
|
+
`Movement`.
|
|
1152
|
+
- Нехватка средств — `invalid_data`, как и с `inc`: в `e.issues` поле `balance` и правило `minimum`. Повтор сверяйте,
|
|
1153
|
+
как `same()` в рецепте выше: `exists` говорит только о том, что операция с этим `opId` уже есть.
|
|
1154
|
+
- Правка и удаление операции тоже меняют баланс: база вычтет прежнее значение. Отменять операцию всё равно лучше
|
|
1155
|
+
встречной операцией, тогда история кошелька остаётся полной.
|
|
1156
|
+
- Каждая операция блокирует строку кошелька и пишет его версию, поэтому операции одного кошелька идут друг за другом,
|
|
1157
|
+
как и с `inc`: на машине замеров — около 150 в секунду ([раздел 17](#17-производительность)). Если один объект
|
|
1158
|
+
получает больше, итог лучше считать проектором ([«Рецепт: дневная выручка»](#рецепт-дневная-выручка)).
|
|
1159
|
+
|
|
992
1160
|
## 9. Массовая загрузка
|
|
993
1161
|
|
|
994
1162
|
### Когда нужен загрузчик
|
|
@@ -1056,6 +1224,22 @@ await adm.load({ Category: [{ name: 'Посуда' }] }, { as: ANNA, tenant: SHO
|
|
|
1056
1224
|
`ownerDefault`. Без `tenant` строки идут в собственного арендатора пользователя. Так процесс загрузки принимает
|
|
1057
1225
|
файлы, присланные пользователями.
|
|
1058
1226
|
|
|
1227
|
+
### Первая заливка
|
|
1228
|
+
|
|
1229
|
+
```ts
|
|
1230
|
+
const r = await adm.load(rows, { initial: true }) // пустая схема: одна транзакция, индексы и триггеры — в конце
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
`db.load(rows, { initial: true })` (`letopis load --initial`) заливает данные в пустую схему быстрее обычного
|
|
1234
|
+
загрузчика — на миллионе объектов примерно в 2,2 раза: всё идёт одной транзакцией, на время загрузки таблицы закрыты
|
|
1235
|
+
для чтения и записи, индексы, ограничения и триггеры сняты и строятся заново в конце, строки пишутся `COPY … FREEZE`.
|
|
1236
|
+
Пустая — значит, кроме строк системных классов (описания классов, аккаунты, членства, ресурсы, правила) и записей
|
|
1237
|
+
`reset`, в схеме ничего нет; иначе — `not_empty` до любых изменений. Режим несовместим с `commitEvery`, `existing`
|
|
1238
|
+
(кроме `'error'`) и `{ as }`; ошибка любой строки откатывает всё, вместе с индексами и триггерами.
|
|
1239
|
+
`letopis import --initial` проводит так весь импорт: одной транзакцией, без `--update` и `--commit-every`. WAL пишется
|
|
1240
|
+
и здесь (без него — только при `wal_level = minimal`), но индексы строятся один раз, и его почти втрое меньше; после
|
|
1241
|
+
`FREEZE` первое чтение и первая очистка таблицу не переписывают.
|
|
1242
|
+
|
|
1059
1243
|
### Командная строка
|
|
1060
1244
|
|
|
1061
1245
|
```sh
|
|
@@ -1075,7 +1259,8 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
|
|
|
1075
1259
|
`issues`), `total` — сколько нарушений найдено, `truncated` — список обрезан, `limit` — предел списка (100),
|
|
1076
1260
|
`complete` — проверки, нашедшие нарушения, прошли все строки. Коды строк: коды проверок базы, а также `duplicate`
|
|
1077
1261
|
(повтор id), `exists` (объект уже есть), `deleted` (объект с этим id жил раньше) и `class_not_loadable` (строки
|
|
1078
|
-
`Class` загрузчик не принимает: классы задаёт `syncModels`).
|
|
1262
|
+
`Class` загрузчик не принимает: классы задаёт `syncModels`). Строки производных классов и ссылки на них загрузчик
|
|
1263
|
+
отклоняет с кодом `derived_class`: такие строки пишет только проектор.
|
|
1079
1264
|
|
|
1080
1265
|
Нарушения ищутся поэтапно: разбор строк (классы, id, повторы), права на классы (`load({ as })`), данные и концы
|
|
1081
1266
|
пачками, в конце порции — цели вне загрузки, владельцы и арендатор. Загрузка отменяется после первого этапа,
|
|
@@ -1230,6 +1415,24 @@ await anna.auth.switch(SHOP) // арендатор сессии
|
|
|
1230
1415
|
арендатора действуют на его строки, правила System — везде. Права действуют и на сырой SQL: строки без права не
|
|
1231
1416
|
видны, правка — пусто или `acl_denied`.
|
|
1232
1417
|
|
|
1418
|
+
Маска класса в объекте `READ`, `WRITE` или `DELETE` совпадает и с потомками класса — по именам предков и их алиасам.
|
|
1419
|
+
Поэтому правка класса, которая меняет `parent`, `alias` или `derivedBy`, требует права `READ`, `WRITE` и `DELETE` без
|
|
1420
|
+
условий на все строки класса и каждого его потомка, иначе `acl_denied`: одного права `WRITE` на `Class` мало, чтобы
|
|
1421
|
+
подставить строки класса под чужие правила. Алиас не совпадает с именем другого класса (в том числе `Class` и
|
|
1422
|
+
системных) и с чужим алиасом арендатора и System, а имя нового класса — с чужим алиасом: иначе правило, написанное
|
|
1423
|
+
для одного класса, действовало бы и на строки другого. Нарушение — `invalid_class`.
|
|
1424
|
+
|
|
1425
|
+
По тем же именам правила находят и старые версии строк в журнале. Поэтому класс удаляется, только когда у него нет
|
|
1426
|
+
строк, потомков и истории строк: историю удалённых строк сначала стирает `purge()`. Класс, удалённый с историей
|
|
1427
|
+
раньше, создаётся заново только с прежними `parent`, `alias` и `derivedBy` — иначе `invalid_class`: старые версии его
|
|
1428
|
+
строк попали бы под чужие правила. Если журнал хранит строки класса с этим именем, но не его надгробие (например,
|
|
1429
|
+
строки класса System, удалённого в 1.1), класс с этим именем не создаётся, пока историю не сотрёт `purge()`.
|
|
1430
|
+
|
|
1431
|
+
Производный класс (`derivedBy`) заводит, меняет и удаляет только администратор: администраторское подключение или
|
|
1432
|
+
аккаунт с правом System `letopis.projector` (у системного администратора оно есть по маске `letopis.*`). Проектор
|
|
1433
|
+
пишет в такой класс итоги по всем строкам источников арендатора, без прав чтения, поэтому пользователь, заведя его
|
|
1434
|
+
сам, читал бы итоги по строкам, которые ему читать запрещено.
|
|
1435
|
+
|
|
1233
1436
|
### Выдать право
|
|
1234
1437
|
|
|
1235
1438
|
Пример: Олег — сотрудник магазина с ролью `staff`, и сотрудникам нельзя удалять товары.
|
|
@@ -1294,8 +1497,13 @@ const followed = await db.Product(mug2).versions({ follow: true }) // и чер
|
|
|
1294
1497
|
```
|
|
1295
1498
|
|
|
1296
1499
|
У каждой версии — автор, агент, время, номер, вид операции (`create`, `update`, `delete`, `reclass`, `rekey`,
|
|
1297
|
-
`restore`, `purge`, `trim`, `reset`) и причина (`reason`)
|
|
1298
|
-
|
|
1500
|
+
`restore`, `purge`, `trim`, `reset`) и причина (`reason`). Причину задаёт опция `reason` глагола (`update(patch, { reason })`, `create(data, { reason })`,
|
|
1501
|
+
`delete({ confirm, reason })` и других), для всех операций блока — `db.begin({ reason })` и
|
|
1502
|
+
`db.batch(имя, { reason })`; сырой SQL — настройка `letopis.reason` транзакции. Версия удаления — с `$deleted`.
|
|
1503
|
+
|
|
1504
|
+
Без `sort()` версии идут по id и номеру версии (с `follow` — по времени). `sort()` задаёт порядок и версиям, а
|
|
1505
|
+
`limit()` ограничивает их в самом запросе: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` — пять
|
|
1506
|
+
последних версий, новые первыми.
|
|
1299
1507
|
|
|
1300
1508
|
### Срез на дату
|
|
1301
1509
|
|
|
@@ -1339,6 +1547,70 @@ history: { all: '1 day', daily: '1 week', weekly: '1 month', monthly: '1 year',
|
|
|
1339
1547
|
- В прореженной части срез показывает последнюю сохранённую версию до момента, то есть его точность равна
|
|
1340
1548
|
периоду яруса. Подписчик, отставший дольше яруса `all`, получает `cursor_expired`.
|
|
1341
1549
|
|
|
1550
|
+
### Что изменилось и кто: `diff` и `blame`
|
|
1551
|
+
|
|
1552
|
+
```ts
|
|
1553
|
+
const [d] = await db.Order(id).diff() // предыдущая версия → последняя
|
|
1554
|
+
await db.Order(id).diff({ from: 3, to: 7 }) // по номерам версий
|
|
1555
|
+
await db.Order(id).diff({ from: '2026-10-01' }) // состояние на момент → последнее
|
|
1556
|
+
await db.Order(id).diff({ follow: true }) // через смену id (reclass, rekey)
|
|
1557
|
+
const [b] = await db.Order(id).blame() // каждое поле последней версии
|
|
1558
|
+
await db.Order(id).blame({ at: '2026-10-01', limit: 200 }) // версия на момент; просмотреть 200 версий
|
|
1559
|
+
```
|
|
1560
|
+
|
|
1561
|
+
Оба терминала отдают массив — по элементу на объект шага. Так выглядят ответы для чайника, которому снизили цену с
|
|
1562
|
+
причиной:
|
|
1563
|
+
|
|
1564
|
+
```ts
|
|
1565
|
+
import type { BlameResult, DiffResult } from 'letopis'
|
|
1566
|
+
|
|
1567
|
+
// db.Product(kettle).blame(): цену изменила версия 2, остальные листья — с создания (версия 1)
|
|
1568
|
+
const blamed: BlameResult = {
|
|
1569
|
+
id: '6f1c…', class: 'Product', rev: 2, complete: true,
|
|
1570
|
+
fields: [ // по порядку пути
|
|
1571
|
+
{ path: ['class'], rev: 1, at: '2026-10-27T09:15:02.118204+00:00', author: '<id Анны>', agent: '<id сервиса>',
|
|
1572
|
+
op: 'create', exact: true },
|
|
1573
|
+
{ path: ['data', 'price'], rev: 2, at: '2026-10-27T09:20:41.502617+00:00', author: '<id Анны>', agent: '<id сервиса>',
|
|
1574
|
+
op: 'update', reason: 'скидка к акции', exact: true },
|
|
1575
|
+
// ['data', 'sku'], ['data', 'title'], ['links', 'Shop'], ['owner'] — тоже версия 1, как ['class']
|
|
1576
|
+
],
|
|
1577
|
+
}
|
|
1578
|
+
// db.Product(kettle).diff(): предыдущая версия → последняя
|
|
1579
|
+
const diffed: DiffResult = {
|
|
1580
|
+
id: '6f1c…', class: 'Product',
|
|
1581
|
+
from: { rev: 1, at: '2026-10-27T09:15:02.118204+00:00' },
|
|
1582
|
+
to: { rev: 2, at: '2026-10-27T09:20:41.502617+00:00' },
|
|
1583
|
+
changes: [{ path: ['data', 'price'], kind: 'changed', before: 2500, after: 2000 }],
|
|
1584
|
+
}
|
|
1585
|
+
```
|
|
1586
|
+
|
|
1587
|
+
`agent` здесь — сервис, который пишет от имени Анны (`db = await svc.as(…)`); у записи без имперсонации его нет.
|
|
1588
|
+
|
|
1589
|
+
- `diff()` — массив `DiffResult`: `{ id, class, from, to, changes }`. `from` и `to` — сравниваемые версии
|
|
1590
|
+
`{ rev, at }`. В них есть `id`, если версия принадлежит другому id (`follow: true` через `reclass` или `rekey`), а у
|
|
1591
|
+
`to` — `deleted: true`, если на этот момент объект удалён; тогда `changes` пуст. `from` равен `null`, если объекта
|
|
1592
|
+
тогда ещё не было: все пути — `added`. Если версии нет вовсе, `class` и `to` тоже `null`.
|
|
1593
|
+
- Изменение `DiffChange` — `{ path, kind, before, after }`. `path` — массив сегментов, а не строка с точками; первый
|
|
1594
|
+
сегмент — раздел строки: `data`, `links`, `tags`, `owner`, `class`. `kind` — `added` (значения не было), `removed`
|
|
1595
|
+
(не стало) или `changed`. Пустое значение опускается: у `added` нет `before`, у `removed` — `after`. Объекты данных
|
|
1596
|
+
сравниваются по ключам, массивы — целиком. Множественный конец и теги — множества: по изменению на каждый id или
|
|
1597
|
+
тег, путь — `['links', роль]` или `['tags']`, а сам id или тег — в `before` или `after`.
|
|
1598
|
+
- `blame()` — массив `BlameResult`: `{ id, class, rev, complete, fields }` и `deleted: true`, если эта версия —
|
|
1599
|
+
надгробие. Объект без версий на этот момент элемента не даёт. `fields` — массив `BlameField`, по элементу на лист
|
|
1600
|
+
версии, упорядоченный по пути: `{ path, rev, at, author, op, exact }` и, если есть, `agent`, `reason`, `id`. Лист —
|
|
1601
|
+
скаляр или массив в данных, пустой объект, роль одиночного конца, каждый id множественного конца
|
|
1602
|
+
(`['links', роль, id]`), каждый тег (`['tags', тег]`), `owner` и `class`. `rev`, `at`, `author`, `agent`, `op` и
|
|
1603
|
+
`reason` — от версии, которая последней изменила лист. Пустые `agent` и `reason` опускаются; `id` есть, только если
|
|
1604
|
+
это версия прежнего id (история прошла через `reclass` или `rekey`).
|
|
1605
|
+
- `exact: false` — между текущей версией и найденной есть пропуски (история прорежена, обрезана или часть версий не
|
|
1606
|
+
видна): лист мог менять и выпавшая версия. `complete: true` — история просмотрена целиком и без пропусков; по
|
|
1607
|
+
умолчанию просматривается не больше 1000 последних версий.
|
|
1608
|
+
- Считает база с правами вызывающего: сырой SQL получает то же — `select "v2.shop".blame('<id>')`,
|
|
1609
|
+
`select "v2.shop".diff_versions('<id>')`.
|
|
1610
|
+
- Колонка журнала `changed` (пути изменения версии, по ним `blame` работает быстро) приложению не выдана: пути
|
|
1611
|
+
описывают разницу с предыдущей версией, а её читатель может не видеть. Поэтому `select * from log` от приложения
|
|
1612
|
+
не работает — колонки перечисляются явно; `blame` берёт пути функцией базы, только когда видны обе версии.
|
|
1613
|
+
|
|
1342
1614
|
### Обрезка истории
|
|
1343
1615
|
|
|
1344
1616
|
<!-- run: guide -->
|
|
@@ -1380,7 +1652,10 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
|
|
|
1380
1652
|
|
|
1381
1653
|
- `from` — `'now'` (по умолчанию: только изменения после подписки), `'start'` (весь журнал) или сохранённый курсор.
|
|
1382
1654
|
`db.watch()` возвращается сразу, а момент «now» подписка берёт первым запросом к базе: запись, сделанная сразу
|
|
1383
|
-
после `db.watch()`, может зафиксироваться раньше и в поток не попасть.
|
|
1655
|
+
после `db.watch()`, может зафиксироваться раньше и в поток не попасть. `await sub.ready()` дожидается этого момента:
|
|
1656
|
+
запись после него в поток придёт. Пример выше читает с `'start'`.
|
|
1657
|
+
- Вызовы `sub.next()` идут по очереди: параллельные получают разные события. Вызов, брошенный по таймауту
|
|
1658
|
+
(`Promise.race`), всё равно получит своё событие — держите его обещание и ждите его снова.
|
|
1384
1659
|
- `classes` — только эти классы с потомками; `pollMs` — запасной опрос (1000 мс).
|
|
1385
1660
|
- События идут без потерь и повторов, под правами читателя, в порядке номеров транзакций, а внутри транзакции — в
|
|
1386
1661
|
порядке записи. Это не порядок фиксации: версии одного объекта сверяйте по `rev`.
|
|
@@ -1391,6 +1666,198 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
|
|
|
1391
1666
|
- Подписка без потерь — это сохранённый курсор: храните `ev.cursor` последнего обработанного события и продолжайте
|
|
1392
1667
|
с него после перезапуска.
|
|
1393
1668
|
|
|
1669
|
+
### Проекторы
|
|
1670
|
+
|
|
1671
|
+
Проектор строит строки производных классов из журнала: сводки, денормализованные карточки и всё, что можно считать
|
|
1672
|
+
с отставанием от записи. Классы-источники (`from`) пишет приложение, а строки классов `into` — только проектор.
|
|
1673
|
+
Каждый класс `into` помечен опцией `derivedBy` с именем проектора ([«Настройки класса»](#настройки-класса)).
|
|
1674
|
+
Производные классы заводит только администратор: синхронизируйте их администраторским подключением с сессией
|
|
1675
|
+
арендатора — `adm` из раздела 3 (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или `as()` сервиса
|
|
1676
|
+
на таком подключении, в CLI — `letopis sync` с `--dsn` роли администратора. У сессии приложения это `acl_denied`. В примере ниже проектор
|
|
1677
|
+
считает продажи товаров по строкам заказов `line`.
|
|
1678
|
+
|
|
1679
|
+
```ts
|
|
1680
|
+
const ProductSales = hub('ProductSales', { qty: z.number().int(), revenue: z.number().int() }, {
|
|
1681
|
+
ends: { Product: one(Product).onDelete('cascade') }, key: ['Product'], derivedBy: 'product-sales', history: false,
|
|
1682
|
+
}) // производный класс — в модели; syncModels — администраторским подключением
|
|
1683
|
+
|
|
1684
|
+
const sales = adm.projector('product-sales', { // adm — администраторское подключение
|
|
1685
|
+
from: ['line'], // классы-источники с потомками; у них должен быть журнал
|
|
1686
|
+
into: ['ProductSales'], // производные классы этого проектора
|
|
1687
|
+
version: 1, // логика handle изменилась — новая версия и перестройка
|
|
1688
|
+
withBefore: true, // c.before — прошлая версия объекта или null
|
|
1689
|
+
handle(changes, out) {
|
|
1690
|
+
for (const c of changes) {
|
|
1691
|
+
if (c.op === 'purge' || c.op === 'trim' || c.op === 'reset') continue // служебные изменения, не версии
|
|
1692
|
+
const was = c.before // прошлая версия строки заказа — вычесть
|
|
1693
|
+
const now = c.row.$deleted ? null : c.row // новая версия — прибавить; у удаления её нет
|
|
1694
|
+
if (was) out.inc('ProductSales', { Product: was.links.Product },
|
|
1695
|
+
{ qty: -Number(was.data.qty), revenue: -Number(was.data.qty) * Number(was.data.price) }, { tenant: c.tenant })
|
|
1696
|
+
if (now) out.inc('ProductSales', { Product: now.links.Product },
|
|
1697
|
+
{ qty: Number(now.data.qty), revenue: Number(now.data.qty) * Number(now.data.price) }, { tenant: c.tenant })
|
|
1698
|
+
}
|
|
1699
|
+
},
|
|
1700
|
+
})
|
|
1701
|
+
const report = await sales.run({ once: true }) // догнать журнал: { batches, changes, created, updated, … }
|
|
1702
|
+
const state = await sales.status() // { name, state, version, cursor, at, lag, failed, rebuildingTenants }
|
|
1703
|
+
await sales.rebuild() // перестроить с текущего состояния; { chunked: true } — пачками
|
|
1704
|
+
await sales.rebuild({ tenant: tenantId }) // перестроить одного арендатора — например, выбывшего (failed)
|
|
1705
|
+
await sales.rebuild({ tenant: tenantId, chunked: true }) // его же пачками: хвост журнала тем временем идёт для всех
|
|
1706
|
+
sales.run().catch((err) => console.error('проектор остановлен', err)) // следовать за журналом до stop()
|
|
1707
|
+
sales.close() // stop() после текущей пачки и отписка от сигналов
|
|
1708
|
+
```
|
|
1709
|
+
|
|
1710
|
+
- Проектору нужно администраторское подключение: `connect({ allowBypassRls: true })` роли, которая может стать
|
|
1711
|
+
владельцем схемы. Иначе — `admin_required`, внутри `db.begin()` — `invalid_query`. Проектор читает журнал всех
|
|
1712
|
+
арендаторов.
|
|
1713
|
+
- Первый запуск `run()` перестраивает проекцию с текущего состояния: журнал прорежен политикой хранения и обрезан
|
|
1714
|
+
`purge`, поэтому собрать проекцию с начала журнала нельзя.
|
|
1715
|
+
- Каждая пачка — одна транзакция: изменения журнала, `handle`, запись результатов и сдвиг курсора фиксируются
|
|
1716
|
+
вместе. Обрыв посреди пачки откатывает её целиком, а повтор начинается с того же курсора, поэтому каждое
|
|
1717
|
+
изменение применяется ровно один раз. Писатель у проектора один: второй процесс того же проектора пропускает пачку
|
|
1718
|
+
(счётчик `skipped` в отчёте), а `rebuild()`, начатая, пока другой процесс пишет пачку, — `projector_busy`.
|
|
1719
|
+
- Пачка делится по арендаторам: `handle` получает изменения одного арендатора, и запись его результатов идёт под
|
|
1720
|
+
своей точкой сохранения. Если база отклоняет запись результатов арендатора (данные не проходят описание его
|
|
1721
|
+
производного класса, цель конца не найдена и т. п.) или `out` отклоняет строку или приращение, построенные по его
|
|
1722
|
+
данным (приращение — не число), откатываются только его изменения: арендатор выбывает, а остальные записываются в
|
|
1723
|
+
той же пачке. Выбывший попадает в `status().failed` — `{ [id арендатора]: { cursor, code, message } }`, `message`
|
|
1724
|
+
называет арендатора и id строки, — и его изменения пропускаются, пока его не перестроят. Производные строки
|
|
1725
|
+
выбывшего больше не меняются: выбывший в `run()` сохраняет их такими, какими они были до пачки с ошибкой, а выбывший
|
|
1726
|
+
посреди перестройки остаётся без них — частично построенные строки удаляются. Исключение из `handle`, нарушение
|
|
1727
|
+
границ `out` и сбой окружения (взаимная блокировка, отмена запроса, обрыв связи) откатывают пачку целиком, а
|
|
1728
|
+
`run()` отклоняется. Поэтому `run()` без `once` ловите (`catch`): после ошибки он остановлен.
|
|
1729
|
+
- Изменение (`ProjectorChange`) — это `cursor`, `id`, `class`, `tenant`, `op`, `rev`, `at`, `author`, `agent`,
|
|
1730
|
+
`reason`, `moved` и `row` — версия из журнала (у удаления — надгробие с `$deleted`). При перестройке изменения —
|
|
1731
|
+
строки текущего состояния: `rebuild: true`, `op` — `create`, `cursor` пустой.
|
|
1732
|
+
- Смена класса без смены id (у классов без ключа: сырой `UPDATE … SET class` и `reclass()`) журнал пишет версией уже
|
|
1733
|
+
нового класса. Если она уводит строку из семейства `from`, проектор получает удаление: `op` — `delete`, `row` —
|
|
1734
|
+
последняя версия строки в семействе с `$deleted`, `rev`, `at` и автор — смены класса. Строка, вошедшая так в
|
|
1735
|
+
семейство, приходит изменением `reclass` с `before` = `null`.
|
|
1736
|
+
- С `withBefore` у изменения есть `before` — прошлая версия того же id. `before` равен `null`, если прошлой версии нет
|
|
1737
|
+
(создание и первая версия нового id после `reclass` и `rekey`), если прошлая версия — удаление (так бывает после
|
|
1738
|
+
`restore`) или вне семейства `from`, и у всех изменений перестройки. При `reclass` и `rekey` прежние данные
|
|
1739
|
+
вычитает надгробие прежнего id: оно приходит изменением с `$deleted`, его `before` — последняя версия прежнего id,
|
|
1740
|
+
а `moved` обоих изменений указывает на другой id. Поэтому свёртка из примера после `rekey` даёт ту же сумму. У
|
|
1741
|
+
служебных изменений `purge`, `trim` и `reset` поля `before` нет совсем. Поэтому вычитайте `before`, только проверив
|
|
1742
|
+
его на `null`, как в примере выше.
|
|
1743
|
+
- Служебные записи журнала `purge`, `trim` и `reset` тоже приходят изменениями, и у них `rev` равен 1. Поэтому
|
|
1744
|
+
сначала разберите `op`, а уже потом сравнивайте `rev`.
|
|
1745
|
+
- `out.upsert(класс, строка, { tenant })` создаёт строку или заменяет её целиком по ключу класса.
|
|
1746
|
+
`out.inc(класс, строка, приращения, { tenant })` создаёт строку или дополняет её и прибавляет числа, не читая
|
|
1747
|
+
строку (поля, которых нет, считаются от 0). `out.delete(класс, ключ или id, { tenant })` удаляет строку, а если её
|
|
1748
|
+
нет, ничего не делает. `await out.get(класс, ключ или id, { tenant })` читает текущую строку вместе с записями
|
|
1749
|
+
пачки. `out` пишет только в классы `into` и только в арендатора изменений, переданных в `handle`; иначе —
|
|
1750
|
+
`derived_class` или `tenant_denied`, и пачка откатывается.
|
|
1751
|
+
- Приращения строки, заданной одними полями ключа (или одним `$id`), `out` складывает до отправки в базу, пока между
|
|
1752
|
+
ними нет `upsert`, `delete` или `get`: на строку — одна правка за пачку, а правило класса проверяется над итогом её
|
|
1753
|
+
приращений. Строку с другими полями (данными, `$tags`, `$owner`) `inc` не складывает: её поля правят строку, и
|
|
1754
|
+
порядок таких правок сохраняется.
|
|
1755
|
+
- Строка для `out` — в формате загрузчика: поля и роли концов на верхнем уровне, служебные `$tags`, `$owner` и
|
|
1756
|
+
`$id` (`$id` нужен только классу без ключа). Значение роли — id цели. Правка, которая ничего не меняет, версии не
|
|
1757
|
+
даёт. Версии производных строк пишутся от System с агентом `letopis projector <имя>`; журнал ведётся по
|
|
1758
|
+
`history` класса, а сигнал об изменении уходит всегда.
|
|
1759
|
+
- `rebuild()` перестраивает проекцию атомарно, одной транзакцией: до её фиксации читатели видят прежние строки, но
|
|
1760
|
+
горизонт подписок стоит. `rebuild({ chunked: true })` идёт пачками: производные строки удаляются сразу, и до
|
|
1761
|
+
конца перестройки данные неполные, а `status().state` — `'rebuilding'`. Прерванную перестройку пачками
|
|
1762
|
+
продолжает следующий запуск. Сам `run()` — первый запуск новой проекции и `onExpired: 'rebuild'` — перестраивает
|
|
1763
|
+
пачками: горизонт подписок кластера стоит не дольше пачки. Второй процесс того же проектора тем временем ждёт или
|
|
1764
|
+
продолжает ту же перестройку, а не начинает её заново. Перестройка удаляет строки только участников и только
|
|
1765
|
+
классов их семейств `into` и начинает всех участников заново: отметки выбывших снимаются, а арендатор, чьи данные
|
|
1766
|
+
снова не проходят, выбывает снова.
|
|
1767
|
+
- `rebuild({ tenant: id })` перестраивает одного арендатора одной транзакцией: удаляет его производные строки, строит
|
|
1768
|
+
их из его текущих строк источников и снимает отметку в `status().failed`; изменения, уже учтённые перестройкой,
|
|
1769
|
+
проектор потом не применяет второй раз. Исправьте данные или описание класса арендатора, затем перестройте его. Если
|
|
1770
|
+
база снова отклоняет запись, перестройка откатывается с этой ошибкой, и арендатор остаётся выбывшим. Проекции ещё
|
|
1771
|
+
нет или идёт перестройка пачками — `rebuild_required`; арендатор не участник — `tenant_denied`. С `chunked: true` —
|
|
1772
|
+
пачками, каждая своей транзакцией со своим снимком, пока хвост журнала идёт для всех: изменения арендатора выше
|
|
1773
|
+
последней пачки хвост пропускает (их прочтёт пачка), в пройденной — если транзакция видна в её снимке. Производные
|
|
1774
|
+
строки арендатора удаляются сразу и до конца перестройки неполны (`status().rebuildingTenants`); блокировку,
|
|
1775
|
+
занятую `run()` другого процесса, перестройка пережидает.
|
|
1776
|
+
- `status().state` описывает проекцию в базе, а не процесс: `'new'` — проекции ещё нет, её построит первый `run()`
|
|
1777
|
+
или `rebuild()`; `'running'` — проекция построена и ведётся по журналу (это не значит, что какой-то процесс сейчас
|
|
1778
|
+
выполняет `run()`); `'rebuilding'` — идёт или прервана перестройка пачками.
|
|
1779
|
+
- `version` — версия логики `handle`. Если проекция построена другой версией, `run()` бросает `rebuild_required`, и
|
|
1780
|
+
нужна перестройка `rebuild()`.
|
|
1781
|
+
- Если курсор старше яруса `all` политики хранения источников и после него есть изменения, часть изменений могла
|
|
1782
|
+
проредиться. Ярус у каждого арендатора свой — это описание его классов. Курсор старше яруса у всех участников —
|
|
1783
|
+
`run()` бросает `cursor_expired`, а с `onExpired: 'rebuild'` проектор перестраивается целиком, пачками. Старше
|
|
1784
|
+
яруса только у части — выбывают они (`status().failed`, код `cursor_expired`), остальные идут дальше, а с
|
|
1785
|
+
`onExpired: 'rebuild'` выбывшие перестраиваются сами, пачками: состояние перестройки лежит в чекпоинте, и её
|
|
1786
|
+
продолжает `run()` любого процесса; блокировка, занятая другим процессом, — только повтор на следующем проходе, а
|
|
1787
|
+
ошибка данных выводит арендатора с её кодом, без повторов. Ярус перестроенного арендатора отсчитывается от начала
|
|
1788
|
+
его перестройки, пока курсор её не догнал: отстающий проектор не выводит его снова. Проектор без новых изменений не
|
|
1789
|
+
истекает. `status().lag` — возраст самого старого необработанного изменения в мс.
|
|
1790
|
+
- Проектор работает с арендаторами, у которых каждый класс `into` помечен его `derivedBy`; изменения остальных
|
|
1791
|
+
арендаторов он пропускает, поэтому одноимённый класс без пометки у другого арендатора ему не мешает. Нет ни одного
|
|
1792
|
+
такого арендатора — `derived_class`. Пометить классы арендатора может только администратор, поэтому участников
|
|
1793
|
+
выбирает он: пользователь арендатора сам к проектору не подключится. Опция
|
|
1794
|
+
`tenants: [id, …]` — явный список участников: проектор работает только с этими арендаторами (их классы `into` тоже
|
|
1795
|
+
должны быть помечены), а пометку у остальных не учитывает. Источники — семейства `from` у этих арендаторов; класс
|
|
1796
|
+
не может быть одновременно источником и результатом. Изменений класса с `history: false` проектор не видит:
|
|
1797
|
+
журнала у него нет. Поэтому арендатор, у которого такой класс среди источников, выбывает с кодом `invalid_class`, и
|
|
1798
|
+
его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
|
|
1799
|
+
- `batch` — сколько изменений в пачке (500), `pollMs` — запасной опрос (1000 мс): сигналы источников будят
|
|
1800
|
+
проектор раньше.
|
|
1801
|
+
- Каждая правка, которую `out` отправляет в базу, — отдельная правка строки, и скорость проектора задаёт число
|
|
1802
|
+
правок, а не число изменений. Свёртка `inc` сводит правки строки к одной за пачку, поэтому handle из примера выше
|
|
1803
|
+
(`out.inc` на каждое изменение) на машине замеров догонял 6 тысяч изменений в секунду при `batch` 500 и 33 тысячи
|
|
1804
|
+
при `batch` 2000 ([раздел 17](#17-производительность)): чем больше пачка, тем больше приращений одной строки
|
|
1805
|
+
складывается в одну правку.
|
|
1806
|
+
|
|
1807
|
+
### Рецепт: дневная выручка
|
|
1808
|
+
|
|
1809
|
+
Агрегат в базе и проектор решают похожие задачи по-разному:
|
|
1810
|
+
|
|
1811
|
+
| | Агрегат в базе | Проектор |
|
|
1812
|
+
|---|---|---|
|
|
1813
|
+
| когда считается | в той же транзакции, что и запись источника | после фиксации, с отставанием (`status().lag`) |
|
|
1814
|
+
| что считает | сумму поля или число строк источника для одной строки-цели | что угодно: сводки по дням и группам, денормализованные карточки |
|
|
1815
|
+
| совпадает с источником | всегда, в том числе для сырого SQL | когда проектор догнал журнал |
|
|
1816
|
+
| цена записи | каждая операция блокирует строку цели и пишет её версию, поэтому писатели одной цели идут по очереди | писатели не ждут друг друга: проектор применяет изменения пачками после фиксации |
|
|
1817
|
+
| правило над итогом | `min(0)` отклоняет операцию, которая увела бы итог за правило | итог не мешает записи: ошибка данных выводит арендатора из проекции |
|
|
1818
|
+
| что запустить | ничего: поле держит база | процесс на администраторском подключении |
|
|
1819
|
+
|
|
1820
|
+
Баланс кошелька, который не должен уйти в минус, — агрегат ([«Рецепт: баланс агрегатом»](#рецепт-баланс-агрегатом)).
|
|
1821
|
+
Выручка магазина по дням — итог по многим продажам, которому можно немного отставать, — работа для проектора:
|
|
1822
|
+
|
|
1823
|
+
```ts
|
|
1824
|
+
const Sale = hub('Sale', { day: z.iso.date(), amount: z.number().int().min(0) }, { ends: { Shop } }) // продажа: день и сумма
|
|
1825
|
+
const DayRevenue = hub('DayRevenue', { day: z.iso.date(), revenue: z.number().int(), sales: z.number().int() }, {
|
|
1826
|
+
ends: { Shop: one(Shop).onDelete('cascade') }, key: ['Shop', 'day'], derivedBy: 'day-revenue', history: false,
|
|
1827
|
+
})
|
|
1828
|
+
await syncModels(db, [Sale], { apply: true })
|
|
1829
|
+
await syncModels(adm, [DayRevenue], { apply: true }) // производный класс заводит администратор: adm — с сессией арендатора
|
|
1830
|
+
|
|
1831
|
+
const revenue = adm.projector('day-revenue', {
|
|
1832
|
+
from: ['Sale'], into: ['DayRevenue'], version: 1, withBefore: true,
|
|
1833
|
+
handle(changes, out) {
|
|
1834
|
+
for (const c of changes) {
|
|
1835
|
+
if (c.op === 'purge' || c.op === 'trim' || c.op === 'reset') continue // служебные изменения
|
|
1836
|
+
const was = c.before // прошлая версия продажи — вычесть из её дня
|
|
1837
|
+
const now = c.row.$deleted ? null : c.row // новая — прибавить к её дню; у удаления её нет
|
|
1838
|
+
if (was) out.inc('DayRevenue', { Shop: was.links.Shop, day: was.data.day },
|
|
1839
|
+
{ revenue: -Number(was.data.amount), sales: -1 }, { tenant: c.tenant })
|
|
1840
|
+
if (now) out.inc('DayRevenue', { Shop: now.links.Shop, day: now.data.day },
|
|
1841
|
+
{ revenue: Number(now.data.amount), sales: 1 }, { tenant: c.tenant })
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
})
|
|
1845
|
+
revenue.run().catch((err) => console.error('проектор выручки остановлен', err)) // следует за журналом до stop()
|
|
1846
|
+
const week = await db.Shop(shop).DayRevenue().sort('data.day', 'desc').limit(7).rows() // семь последних дней
|
|
1847
|
+
```
|
|
1848
|
+
|
|
1849
|
+
- День — поле продажи, а не время версии `at`: со временем версии правка, `restore` или `rekey` переносили бы сумму
|
|
1850
|
+
продажи в свой день.
|
|
1851
|
+
- `out.inc` прибавляет, не читая строку, поэтому итог не зависит от порядка изменений: журнал отдаёт транзакции в
|
|
1852
|
+
порядке первой записи, а не фиксации. Правка продажи вычитает прежнюю сумму из прежнего дня и прибавляет новую к
|
|
1853
|
+
новому, удаление только вычитает. День, в котором продаж не осталось, остаётся строкой с нулями. Приращения одного
|
|
1854
|
+
дня в пачке `out` складывает в одну правку.
|
|
1855
|
+
- У `DayRevenue` — `history: false`: производные строки не удваивают журнал, а перестройка соберёт их и без истории.
|
|
1856
|
+
У `Sale` журнал нужен: изменений класса с `history: false` проектор не видит.
|
|
1857
|
+
- Строки `DayRevenue` приложение читает, как любой класс, а пишет их только проектор: `create` или `update` такой
|
|
1858
|
+
строки — `derived_class`. Итог отстаёт от продаж до следующей пачки: проектор будят сигналы записи, а без них —
|
|
1859
|
+
опрос раз в `pollMs`.
|
|
1860
|
+
|
|
1394
1861
|
### Кэш результатов
|
|
1395
1862
|
|
|
1396
1863
|
<!-- run: guide -->
|
|
@@ -1403,11 +1870,19 @@ const stats = db.cache.stats() // { size, hits,
|
|
|
1403
1870
|
|
|
1404
1871
|
- в ключе ответа — запрос, параметры, сессия, актор и арендатор, поэтому один пользователь не получит ответ другого;
|
|
1405
1872
|
- свои записи сбрасывают ответы изменённых классов сразу после фиксации (с классами каскада), чужие — по сигналу
|
|
1406
|
-
|
|
1873
|
+
базы. В режимах `tenant` и `opaque` процесс с ключом сигналов сбрасывает класс только в арендаторе записи (строки
|
|
1874
|
+
System — во всех); процесс без ключа — во всех арендаторах, а в режиме `opaque` любой сигнал сбрасывает у него
|
|
1875
|
+
весь кэш;
|
|
1407
1876
|
- изменение прав и классов, запись через `db.sql`, погашенная сессия и переподключение слушателя сбрасывают весь
|
|
1408
1877
|
кэш или ответы сессии;
|
|
1409
1878
|
- срок по умолчанию — 60 секунд; внутри `db.begin()` кэш не работает;
|
|
1410
|
-
- без `LISTEN` кэш выключен, а `connect({ cache: { ttlOnly: true } })` включает его только по сроку.
|
|
1879
|
+
- без `LISTEN` и в режиме `none` кэш выключен, а `connect({ cache: { ttlOnly: true } })` включает его только по сроку.
|
|
1880
|
+
|
|
1881
|
+
Реестр классов процесс перечитывает по сигналу об изменении классов сразу (одно перечитывание за раз), а реестр и
|
|
1882
|
+
ключ сигналов по `*`, по незнакомому токену и по нагрузке не своего режима (а без ключа в режиме `opaque` — по любому
|
|
1883
|
+
сигналу) — не чаще раза в 5 секунд: поток сигналов, в том числе поддельных, не превращается в поток запросов. В режиме `none` реестр раз в 5 секунд сверяет отпечаток классов, а подписка
|
|
1884
|
+
опрашивает журнал. Подписка просыпается только от своих классов и своего арендатора, а без ключа — от любого
|
|
1885
|
+
сигнала; события она в любом случае читает из журнала, поэтому ничего не теряет.
|
|
1411
1886
|
|
|
1412
1887
|
Между процессами кэш устаревает на интервал отправки сигналов (`notifyIntervalMs`, 100 мс) плюс доставку;
|
|
1413
1888
|
`db.flushSignals()` отправляет накопленные сигналы сразу. `db.cache.clear()` очищает кэш.
|
|
@@ -1425,11 +1900,11 @@ const report = await adm.maintain() // { sessions, codes, thinned, batches,
|
|
|
1425
1900
|
очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Вызывать может системный администратор,
|
|
1426
1901
|
роль с правом `letopis.maintain` или администраторское подключение. Запускает его внешний планировщик
|
|
1427
1902
|
(`letopis maintain`) или сама библиотека: `connect({ maintain: '1h' })`. Из нескольких процессов работает один:
|
|
1428
|
-
каждая пачка — своя транзакция, и первым делом она берёт
|
|
1429
|
-
|
|
1430
|
-
первой пачки отвечает `skipped: true`, а между пачками останавливается (продолжит тот
|
|
1431
|
-
сделанном с `more: true`. Сеансовой блокировки нет, поэтому `maintain()` работает за любым
|
|
1432
|
-
PgBouncer в режиме транзакций.
|
|
1903
|
+
каждая пачка — своя транзакция, и первым делом она берёт блокировку обслуживания — строку таблицы `secret`, которую
|
|
1904
|
+
ни прикладная, ни посторонняя роль базы не займёт; блокировка снимается с фиксацией пачки. Если блокировку держит
|
|
1905
|
+
другой процесс, вызов до первой пачки отвечает `skipped: true`, а между пачками останавливается (продолжит тот
|
|
1906
|
+
процесс) и отчитывается о сделанном с `more: true`. Сеансовой блокировки нет, поэтому `maintain()` работает за любым
|
|
1907
|
+
пулом, в том числе за PgBouncer в режиме транзакций.
|
|
1433
1908
|
|
|
1434
1909
|
### Сброс: `reset()`
|
|
1435
1910
|
|
|
@@ -1452,8 +1927,151 @@ const model = db.describe() // { schema, tenant, classes: [...], metho
|
|
|
1452
1927
|
const modelText = describeText(model) // то же текстом — для llms.txt приложения или подсказки ИИ-агенту
|
|
1453
1928
|
```
|
|
1454
1929
|
|
|
1455
|
-
`db.describe()` отдаёт описание классов, доступных сессии: поля, концы, ключи, политики и
|
|
1456
|
-
|
|
1930
|
+
`db.describe()` отдаёт описание классов, доступных сессии: поля, концы, ключи, политики и метаданные, а также
|
|
1931
|
+
условия по нескольким полям `anyOf` и `oneOf` с учётом наследования, признак производного класса `derivedBy` и
|
|
1932
|
+
агрегаты `aggregate`, если они заданы. Из командной строки — `letopis describe --format text --out llms.txt`.
|
|
1933
|
+
|
|
1934
|
+
### Агенты и MCP
|
|
1935
|
+
|
|
1936
|
+
Агент — Claude, Cursor или свой — работает с данными через инструменты MCP: узнаёт модель, читает цепочкой, смотрит
|
|
1937
|
+
историю, а если разрешено — пишет. Сервер MCP действует от имени одного пользователя и только с его правами: каждый
|
|
1938
|
+
вызов — цепочка под его сессией, права и арендаторов проверяет база. Сырого SQL у агента нет.
|
|
1939
|
+
|
|
1940
|
+
| Инструмент | Что делает |
|
|
1941
|
+
|---|---|
|
|
1942
|
+
| `letopis_model` | модель: классы, поля (JSON Schema), условия `anyOf` и `oneOf`, концы, ключи, алиасы, метаданные; с `class` — подробно один класс |
|
|
1943
|
+
| `letopis_query` | чтение JSON-цепочкой: старт `class` и `filter`, `id` или `ids`; `steps` — переходы по ролям и классам; `asOf`, `withDeleted`, `sort`, `limit`, `cursor`; терминал `rows`, `first`, `ids`, `count`, `sum`, `avg`, `min`, `max`, `countBy` |
|
|
1944
|
+
| `letopis_history` | `versions`, `diff` или `blame` одного объекта |
|
|
1945
|
+
| `letopis_write` | `create`, `update` (только с `rev`), `upsert`, `delete` — только при `write: true` (`letopis mcp --write`) |
|
|
1946
|
+
|
|
1947
|
+
Запрос `letopis_query` — та же цепочка, что `db.Product({ price: lt(1000) }).Shop().sort('data.name').rows()`, только записанная JSON:
|
|
1948
|
+
|
|
1949
|
+
```json
|
|
1950
|
+
{ "class": "Product", "filter": { "price": { "$lt": 1000 } }, "steps": [{ "step": "Shop" }], "sort": { "field": "data.name" } }
|
|
1951
|
+
```
|
|
1952
|
+
|
|
1953
|
+
Значение фильтра — равенство, вложенный объект — поля вложенных объектов, `{ "Роль": id }` — конец. Операторы:
|
|
1954
|
+
`$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$like`, `$ilike`, `$starts`, `$ends`, `$has`, `$hasAny`,
|
|
1955
|
+
`$hasAll`, `$exists`, `$isNull`, `$not`; ИЛИ — `{ "$or": [фильтр, …] }`. Неизвестный класс, роль, параметр или
|
|
1956
|
+
оператор — ответ `isError` с подсказкой похожих имён. Шагов `steps` — не больше 16, условий в `$or` одного фильтра —
|
|
1957
|
+
не больше 64; иначе `invalid_query`. Время запроса эти пределы не ограничивают: задайте `statement_timeout` роли базы,
|
|
1958
|
+
под которой подключается сервер MCP (`alter role … set statement_timeout = '5s'`).
|
|
1959
|
+
|
|
1960
|
+
Ответ — `structuredContent` и его текстовый дубль. `structuredContent` — `{ notice, data }`: `notice` — постоянный
|
|
1961
|
+
текст пакета о том, что в `data` данные базы, а не инструкции, `data` — сам ответ. Форма `data` у `letopis_query`
|
|
1962
|
+
зависит от терминала:
|
|
1963
|
+
|
|
1964
|
+
| Терминал | `structuredContent.data` |
|
|
1965
|
+
|---|---|
|
|
1966
|
+
| `rows` (по умолчанию) | `{ rows, count, more, cursor? }` |
|
|
1967
|
+
| `ids` | `{ ids, count, more, cursor? }` |
|
|
1968
|
+
| `first` | `{ row }` — строка или `null` |
|
|
1969
|
+
| `count` | `{ count }` |
|
|
1970
|
+
| `countBy` | `{ counts, truncated? }` |
|
|
1971
|
+
| `sum`, `avg`, `min`, `max` | `{ value }` |
|
|
1972
|
+
|
|
1973
|
+
У `rows` и `ids` `count` — число строк на этой странице, а не всего. Страница — не больше `limit`, `maxRows` строк
|
|
1974
|
+
(100) и `maxBytes` байт JSON строк (50 000); строка, которая сама больше `maxBytes`, приходит на странице одна.
|
|
1975
|
+
`more: true` — есть ещё строки, и тогда `cursor` с тем же запросом отдаёт следующую страницу. `counts` —
|
|
1976
|
+
`{ значение: число }`, самые частые значения; если группы не поместились в пределы, приходит `truncated: true`. Из
|
|
1977
|
+
базы `countBy` читает не больше `maxRows` + 1 групп. Поле агрегата и `countBy` —
|
|
1978
|
+
`field: 'data.<путь>'`. Сортировка — `sort: { field, dir }`: `field` — `at`, `rev`, `id` или `data.<путь>`, `dir` —
|
|
1979
|
+
`'asc'` (по умолчанию) или `'desc'`; без `sort` строки идут по `id`. `sort`, `limit` и `cursor` — только у `rows`,
|
|
1980
|
+
`first` и `ids`.
|
|
1981
|
+
|
|
1982
|
+
`letopis_model` без `class` отвечает (в `data`) `{ schema, tenant, classes }` — классы те же, что в `db.describe()`. Если такая
|
|
1983
|
+
модель больше `maxBytes`, приходит краткая: `brief: true`, а у классов — только имена полей и концов; подробно каждый
|
|
1984
|
+
класс отдаёт `letopis_model` с `class`. `letopis_history` принимает `class`, `id` и `kind`. `versions` отвечает
|
|
1985
|
+
`{ versions, more }` — последние версии в пределах ответа, от старой к новой; `diff` с `from` и `to` (номер версии или
|
|
1986
|
+
момент) — `{ diff }`; `blame` с `at` и `limit` — `{ blame }`. Если изменения или листья не поместились, ответ
|
|
1987
|
+
обрезан, и в нём есть `truncated: true` и `total`. `follow` работает у `versions` и `diff`.
|
|
1988
|
+
|
|
1989
|
+
Отказ инструмента — `isError: true` и `structuredContent` вида `{ notice, error }`, где `error` — `{ code, message }`, а также `similar` (похожие
|
|
1990
|
+
имена), `detail` (подробности) и `sqlstate` (код PostgreSQL у ошибки не из letopis), если они есть. Параметр не по
|
|
1991
|
+
входной схеме инструмента (не тот тип, значение не из перечня, нет обязательного) отклоняет SDK ещё до letopis: тогда
|
|
1992
|
+
`isError: true` и только текст, без `structuredContent`.
|
|
1993
|
+
|
|
1994
|
+
В тексте ответа всё, что пришло из базы, — строки, имена и описания классов, тексты ошибок, подсказки — стоит внутри
|
|
1995
|
+
`<untrusted-data-…>` со случайным суффиксом и пояснением, что это данные, а не инструкции. Описания инструментов и
|
|
1996
|
+
`instructions` сервера — постоянный текст пакета: `meta` классов пишут арендаторы, поэтому она попадает только в
|
|
1997
|
+
результаты.
|
|
1998
|
+
|
|
1999
|
+
Запись. Без `write` инструмента `letopis_write` нет в списке инструментов, а его вызов — ошибка протокола SDK
|
|
2000
|
+
(`Tool letopis_write not found`), а не ответ инструмента с `isError`. Концы записи задаёт
|
|
2001
|
+
`links: { Роль: id | [id] | null }`, а `create`, `update` и `upsert` отвечают `{ rows }`. `update` принимает только
|
|
2002
|
+
`rev` прочитанной версии: чужая версия — `conflict`, потерянной правки нет. `delete` без `confirm` возвращает превью
|
|
2003
|
+
каскада и код операции: `{ preview, operation }`. С `confirm: true` удаление подтверждает человек в клиенте через
|
|
2004
|
+
elicitation (в ревизии протокола 2025 — запросом клиенту, в 2026-07-28 — ответом `input_required`), подтверждение
|
|
2005
|
+
привязано к хэшу операции, а ответ — `{ deleted, operation }`. В ревизии 2026-07-28 подпись `requestState`, с которой
|
|
2006
|
+
клиент повторяет вызов, привязана ещё к пользователю сервера и действует 10 минут: подпись сервера другого
|
|
2007
|
+
пользователя и устаревшая — отказ `confirmation_required`. Окно подтверждения показывает каноническое имя класса и
|
|
2008
|
+
id целей (первые 10, дальше — «и ещё K»); алиас и `meta` класса пишет арендатор, поэтому в окно они не попадают. Код
|
|
2009
|
+
операции, присланный агентом, подтверждением не считается; клиент без elicitation получает отказ
|
|
2010
|
+
`confirmation_required`. Причина каждой записи — `mcp: letopis_write`, её показывает `blame()`. Классы прав — `rule`,
|
|
2011
|
+
`Resource`, `member`, `Account`, `Class` и их потомков (правила, членства, описания классов) — `letopis_write` по
|
|
2012
|
+
умолчанию не пишет, не правит и не удаляет: отказ `acl_denied`. Агент под инъекцией в данных выдал бы постороннему
|
|
2013
|
+
членство или правило без подтверждения человека. Разрешает это только опция `allowAclWrite: true`.
|
|
2014
|
+
|
|
2015
|
+
**stdio.** `letopis mcp` — сервер по stdio для клиентов MCP. Нужен пакет SDK: `npm i @modelcontextprotocol/server`
|
|
2016
|
+
(необязательная peer-зависимость). Строку подключения, схему и токен клиент передаёт флагами или переменными
|
|
2017
|
+
`LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`; `--write` включает запись. Принимается только токен сессии
|
|
2018
|
+
пользователя — его выпускает приложение: `svc.auth.sessionFor(пользователь, { ttl })`, а рабочий арендатор сессии
|
|
2019
|
+
задаёт `auth.switch(арендатор)`. Ключ сервиса (`--api-key`) команда отклоняет: он даёт все права `auth.*` и лежал бы на
|
|
2020
|
+
машине агента. Без токена и с `--api-key` команда завершается с кодом 2 и пишет причину в stderr. Токен сессии в
|
|
2021
|
+
арендаторе System (так работает системный администратор) команда тоже отклоняет: опций `allowSystemAdmin` и
|
|
2022
|
+
`allowAclWrite` у неё нет. Конфигурация клиента (Claude Desktop, Claude Code, Cursor):
|
|
2023
|
+
|
|
2024
|
+
```json
|
|
2025
|
+
{
|
|
2026
|
+
"mcpServers": {
|
|
2027
|
+
"letopis": {
|
|
2028
|
+
"command": "npx",
|
|
2029
|
+
"args": ["letopis", "mcp"],
|
|
2030
|
+
"env": {
|
|
2031
|
+
"LETOPIS_DSN": "postgres://shop_app:app-secret@localhost:5432/shop",
|
|
2032
|
+
"LETOPIS_SCHEMA": "v2.shop",
|
|
2033
|
+
"LETOPIS_TOKEN": "<токен сессии пользователя>"
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
```
|
|
2039
|
+
|
|
2040
|
+
**HTTP.** Встроенного HTTP-сервера нет. `createMcpServer(db, { write, maxRows, maxBytes })` из `letopis/mcp` отдаёт
|
|
2041
|
+
сервер MCP, а приложение подключает его к своему обработчику `createMcpHandler` из SDK со своей авторизацией
|
|
2042
|
+
OAuth 2.1: токен запроса проверяет приложение и находит по нему пользователя letopis. Подключение — сессия
|
|
2043
|
+
пользователя (`connect({ token })`) или фасад `svc.as(пользователь)`: ключ сервиса остаётся в процессе приложения.
|
|
2044
|
+
Подключение в обход RLS получает отказ `bypass_rls`, корень сервиса без `as()` и подключение без сессии —
|
|
2045
|
+
`user_session_required`, фасад явной транзакции `db.begin()` — `invalid_query`. Сессию в арендаторе System — так
|
|
2046
|
+
работает системный администратор: его роль `admin` даёт права на аккаунты, членства и правила установки — сервер тоже
|
|
2047
|
+
отклоняет с `user_session_required`, если не передана опция `allowSystemAdmin: true`.
|
|
2048
|
+
|
|
2049
|
+
<!-- run: mcp -->
|
|
2050
|
+
```ts
|
|
2051
|
+
import { up, connect } from 'letopis'
|
|
2052
|
+
import { createMcpServer } from 'letopis/mcp'
|
|
2053
|
+
import { createMcpHandler } from '@modelcontextprotocol/server'
|
|
2054
|
+
|
|
2055
|
+
const ADMIN = 'postgres://postgres:secret@localhost:5432/shop'
|
|
2056
|
+
const APP = 'postgres://shop_app:app-secret@localhost:5432/shop'
|
|
2057
|
+
const r = await up({ dsn: ADMIN, schema: 'shop', quiet: true,
|
|
2058
|
+
tenant: { name: 'Ромашка', user: { name: 'Анна', login: 'anna@shop.ru', password: 'Ромашка-2026!' } } })
|
|
2059
|
+
const svc = await connect({ dsn: APP, schema: r.schema, apiKey: r.serviceKey! })
|
|
2060
|
+
|
|
2061
|
+
// authInfo — итог проверки токена запроса приложением; в extra оно кладёт id аккаунта letopis
|
|
2062
|
+
const mcp = createMcpHandler(async ({ authInfo }) =>
|
|
2063
|
+
createMcpServer(await svc.as(String(authInfo?.extra?.account), { tenant: r.tenant!.tenant })))
|
|
2064
|
+
// в HTTP-обработчике приложения, после проверки токена: return mcp.fetch(request, { authInfo })
|
|
2065
|
+
```
|
|
2066
|
+
|
|
2067
|
+
Фабрику обработчик вызывает на каждый запрос HTTP, поэтому `createMcpServer` проверяет подключение каждый раз (один
|
|
2068
|
+
запрос к базе).
|
|
2069
|
+
|
|
2070
|
+
Проверить обработчик можно без сети: клиент SDK из пакета `@modelcontextprotocol/client` ходит в него через `fetch`
|
|
2071
|
+
приложения — `new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { fetch: (url, init) =>
|
|
2072
|
+
mcp.fetch(new Request(url, init), { authInfo }) })`. Так пример выше проверяет тест `docs` (сценарий `mcp` в
|
|
2073
|
+
`lib/test/docs.test.ts` репозитория): без `write` клиент видит три инструмента, а `letopis_model` отвечает от имени
|
|
2074
|
+
Анны в магазине.
|
|
1457
2075
|
|
|
1458
2076
|
### Сырой SQL
|
|
1459
2077
|
|
|
@@ -1473,12 +2091,14 @@ const [{ n }] = await db.sql`select count(*)::int as n from "v2.shop".entity whe
|
|
|
1473
2091
|
letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply]
|
|
1474
2092
|
letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>]
|
|
1475
2093
|
letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
|
|
1476
|
-
[--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify]
|
|
2094
|
+
[--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify] [--initial]
|
|
1477
2095
|
letopis verify --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--data]
|
|
1478
2096
|
letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--batches <N>]
|
|
1479
2097
|
letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
|
|
1480
2098
|
letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
|
|
1481
|
-
[--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>]
|
|
2099
|
+
[--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
|
|
2100
|
+
letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
|
|
2101
|
+
(или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
|
|
1482
2102
|
```
|
|
1483
2103
|
|
|
1484
2104
|
### Наблюдение
|
|
@@ -1546,9 +2166,10 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1546
2166
|
|
|
1547
2167
|
| Точка входа | Что экспортирует |
|
|
1548
2168
|
|---|---|
|
|
1549
|
-
| `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` и другие |
|
|
1550
|
-
| `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta`, `HistoryPolicy`; `normalizeSchema`, `translatePattern`, тип `JsonSchema` |
|
|
1551
|
-
|
|
|
2169
|
+
| `letopis` | `connect`, `up`, `createTenant`; операторы `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `inList`, `like`, `ilike`, `starts`, `ends`, `has`, `hasAny`, `hasAll`, `exists`, `isNull`, `not`, `or`, `isOp`; `inc`, `isInc`, `cursorOf`; `idOf`, `uuidv5`, `v5Name`, `v5Id`; `LetopisError`, `ValidationError`, `fromDbError`; `syncModels`, `generateTypes`, `schemaToTs`; `compileValidator`, `applyDefaults`, `Registry`; `totpCode`, `hashPassword`, `verifyHash`; `describeText`; константы `DDL_REVISION`, `RESERVED_CLASS_NAMES`, `CHAIN_METHODS`, `DB_METHODS`; типы `Db`, `DbTx`, `TypedDb`, `Chain`, `Row`, `Filter`, `Cursor`, `ConnectOptions`, `UpOptions`, `LoadOptions`, `LoadReport`, `WatchOptions`, `WatchEvent`, `AuthApi`, `AclApi`, `DiffResult`, `DiffChange`, `BlameResult`, `BlameField`, `Projector`, `ProjectorOptions`, `ProjectorChange`, `ProjectorOut`, `ProjectorRow`, `ProjectorReport`, `ProjectorStatus`, `ProjectorFailure` ([«Проектор»](#проектор)), `AggregateInfo` и другие |
|
|
2170
|
+
| `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta`, `HistoryPolicy`, `AggregateDef`; `normalizeSchema`, `translatePattern`, тип `JsonSchema` |
|
|
2171
|
+
| `letopis/mcp` | `createMcpServer(db, опции)` — сервер MCP SDK с инструментами letopis; тип `McpOptions`: `write` — инструмент `letopis_write` (по умолчанию его нет), `maxRows` — строк в ответе (100, от 1 до 10 000), `maxBytes` — байт в ответе (50 000, от 1 000 до 50 000 000); `allowAclWrite` — `letopis_write` пишет и классы прав (по умолчанию — `acl_denied`); `allowSystemAdmin` — сессия в арендаторе System (по умолчанию — `user_session_required`); значение вне пределов — `invalid_data`. Нужен пакет `@modelcontextprotocol/server` ([раздел 13](#агенты-и-mcp)) |
|
|
2172
|
+
| CLI `letopis` | `sync`, `types`, `load`, `verify`, `maintain`, `describe`, `import`, `mcp` ([раздел 13](#13-эксплуатация)); у `mcp` — `--dsn`, `--schema`, `--token` (или `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`) и `--write` |
|
|
1552
2173
|
|
|
1553
2174
|
Снятые имена 0.21 (`uuidv7`, `LETOPIS_NS` и методы, перечисленные ниже) бросают `removed` с заменой.
|
|
1554
2175
|
|
|
@@ -1563,6 +2184,8 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1563
2184
|
| `begin()`, `commit(tr)`, `rollback(tr)`, `lock(…ключи)` | явная транзакция ([раздел 8](#8-транзакции-батчи-и-деньги)) |
|
|
1564
2185
|
| `batch(имя)` | очередь планов: `run()`, `discard()`, `size()` |
|
|
1565
2186
|
| `load(вход, опции)` | загрузчик ([раздел 9](#9-массовая-загрузка)) |
|
|
2187
|
+
| `projector(имя, опции)` | проектор из журнала: `run()`, `rebuild({ chunked, tenant })`, `status()`, `stop()`, `close()` ([«Проекторы»](#проекторы)) |
|
|
2188
|
+
| `rebuildAggregates(класс)` | ремонт агрегатов класса и его потомков во всех арендаторах: поле заново из живых источников, версия — только у исправленных; → их число ([«Агрегаты в базе»](#агрегаты-в-базе)) |
|
|
1566
2189
|
| `watch(опции)`, `cache` | подписка; кэш результатов (`clear()`, `stats()`) |
|
|
1567
2190
|
| `` sql`…` ``, `transaction(fn)` | сырой SQL под сессией и RLS |
|
|
1568
2191
|
| `idOf(класс, ключ)` | id объекта класса с ключом в арендаторе сессии |
|
|
@@ -1580,7 +2203,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1580
2203
|
| модификаторы | `limit(n)`, `offset(n)`, `sort(поле, 'asc' \| 'desc')`, `after(курсор)`, `asOf(момент)`, `withDeleted()`, `deep(max)`, `exact()`, `alias(имя)`, `tags(…)`, `owner(…)`, `entity(…)`, `forUpdate()`, `cache({ ttl })` |
|
|
1581
2204
|
| глаголы | `create(данные)`, `update(патч, { rev })`, `upsert(данные)`, `delete({ confirm })`, `anonymize(поля)`, `reclass(класс, данные)`, `rekey(патч)`, `restore()`, `purge({ confirm })` |
|
|
1582
2205
|
| слоты | после глагола: `.Роль.set(значение)`, `.Роль.unset()`, `.Роль.add(значение)`, `.Роль.remove(значение)` |
|
|
1583
|
-
| терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions({ follow })`, `sum(поле)`, `avg(поле)`, `min(поле)`, `max(поле)`, `countBy(поле)` |
|
|
2206
|
+
| терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions({ follow })`, `diff({ from, to, follow })`, `blame({ at, limit })`, `sum(поле)`, `avg(поле)`, `min(поле)`, `max(поле)`, `countBy(поле)` |
|
|
1584
2207
|
|
|
1585
2208
|
### `db.auth`
|
|
1586
2209
|
|
|
@@ -1607,6 +2230,23 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1607
2230
|
Вид креда `kind` — `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`, `TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`,
|
|
1608
2231
|
`OTP`, `TOTP` и `KEY` заняты.
|
|
1609
2232
|
|
|
2233
|
+
### Проектор
|
|
2234
|
+
|
|
2235
|
+
`db.projector(имя, опции)` работает только на администраторском подключении ([«Проекторы»](#проекторы)). Имя —
|
|
2236
|
+
строчные латинские буквы, цифры и `_.:-`, до 64 символов; оно же — `derivedBy` производных классов. Неверные опции —
|
|
2237
|
+
`invalid_data`.
|
|
2238
|
+
|
|
2239
|
+
| Член | Что делает |
|
|
2240
|
+
|---|---|
|
|
2241
|
+
| опции `ProjectorOptions` | `from`, `into` — классы с потомками; `version` — целое от 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` — пачками); `batch` — изменений в пачке (500, от 1 до 10 000); `pollMs` — запасной опрос (1000 мс); `tenants` — id арендаторов-участников |
|
|
2242
|
+
| `run({ once })` | догнать журнал (`once`) или следовать за ним до `stop()` → `ProjectorReport` |
|
|
2243
|
+
| `rebuild({ chunked, tenant })` | перестроить с текущего состояния: атомарно, пачками или одного арендатора → `ProjectorReport` |
|
|
2244
|
+
| `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` — `{ [id арендатора]: ProjectorFailure }`, где `ProjectorFailure` — `{ cursor, code, message }` |
|
|
2245
|
+
| `stop()`, `close()` | остановить `run()` после текущей пачки; то же и отписаться от сигналов |
|
|
2246
|
+
| `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds }` |
|
|
2247
|
+
| `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` и, если есть, `agent`, `reason`, `moved`, `before`, `rebuild` |
|
|
2248
|
+
| `ProjectorOut` | `upsert(класс, строка, { tenant })`, `inc(класс, строка, приращения, { tenant })`, `delete(класс, ключ или id, { tenant })`, `get(класс, ключ или id, { tenant })` → `Row \| null`; строка — `ProjectorRow` в формате загрузчика |
|
|
2249
|
+
|
|
1610
2250
|
### Строка ответа
|
|
1611
2251
|
|
|
1612
2252
|
| Поле | Что значит |
|
|
@@ -1628,11 +2268,11 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1628
2268
|
- методы цепочки: `then`, `entity`, `paths`, `rows`, `first`, `ids`, `count`, `limit`, `offset`, `sort`, `asOf`,
|
|
1629
2269
|
`withDeleted`, `deep`, `exact`, `sum`, `avg`, `min`, `max`, `countBy`, `after`, `versions`, `create`, `update`,
|
|
1630
2270
|
`upsert`, `set`, `unset`, `add`, `remove`, `delete`, `anonymize`, `reclass`, `restore`, `purge`, `alias`, `tags`,
|
|
1631
|
-
`owner`, `forUpdate`, `cache`, `rekey`; охранники снесённого API — `run`, `execute`, `account`;
|
|
2271
|
+
`owner`, `forUpdate`, `cache`, `rekey`, `diff`, `blame`; охранники снесённого API — `run`, `execute`, `account`;
|
|
1632
2272
|
- корень `db`: `as`, `begin`, `commit`, `rollback`, `lock`, `batch`, `watch`, `close`, `registry`, `sql`, `load`,
|
|
1633
2273
|
`verify`, `maintain`, `reset`, `describe`, `auth`, `acl`, `idOf`, `refresh`, `transaction`, `flushSignals`,
|
|
1634
|
-
`schema`, `token`, `tenant`, `trimHistory`, `rekeyClass`, `cache`; охранники — `reloadSchema`,
|
|
1635
|
-
`credentials`, `resources`, `rules`, `compact`;
|
|
2274
|
+
`schema`, `token`, `tenant`, `trimHistory`, `rekeyClass`, `cache`, `projector`, `rebuildAggregates`; охранники — `reloadSchema`,
|
|
2275
|
+
`accounts`, `credentials`, `resources`, `rules`, `compact`;
|
|
1636
2276
|
- батч: `discard`, `size`.
|
|
1637
2277
|
|
|
1638
2278
|
## 16. Ошибки
|
|
@@ -1652,16 +2292,19 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1652
2292
|
| `acl_denied` | LT006 | отказ по правилам доступа |
|
|
1653
2293
|
| `id_mismatch`, `id_from_db` | LT007, LT008 | id не равен v5 от ключа; id класса без ключа выдаёт база |
|
|
1654
2294
|
| `immutable_key` | LT009 | ключевое поле, id, арендатор и владелец не меняются (новый ключ — `rekey`) |
|
|
1655
|
-
| `invalid_class` | LT010 | описание класса отклонено, класс не найден или
|
|
2295
|
+
| `invalid_class` | LT010 | описание класса отклонено, класс не найден или абстрактный; класс со строками, потомками или историей строк не удаляется |
|
|
1656
2296
|
| `use_reclass`, `reclass_denied` | LT011, LT012 | смена класса через `update`, когда у старого или нового класса есть ключ: id меняется, нужен `reclass()`; роли ссылающихся строк не принимают новый класс |
|
|
1657
2297
|
| `tightening_conflict` | LT013 | ужесточению описания мешают строки |
|
|
1658
2298
|
| `isolation_level` | LT014 | удаление, смена класса и ключа — только в `read committed` |
|
|
1659
2299
|
| `recreated_in_statement` | LT015 | id удалён и создан заново одним оператором |
|
|
1660
|
-
| `cursor_expired` | LT016 | курсор подписки старше яруса `all` политики хранения |
|
|
2300
|
+
| `cursor_expired` | LT016 | курсор подписки или проектора старше яруса `all` политики хранения |
|
|
1661
2301
|
| `delete_restricted` | LT017 | удалению мешают ссылающиеся строки (`onDelete: 'restrict'`) |
|
|
1662
2302
|
| `not_deleted` | LT018 | `restore` и `purge` — только для удалённых |
|
|
1663
2303
|
| `reset_disabled` | LT019 | сброс выключен в установке (`allowReset`) |
|
|
1664
2304
|
| `confirm_mismatch` | LT020 | подтверждение сброса не совпадает с именем схемы |
|
|
2305
|
+
| `derived_class` | LT021 | строку производного класса (`derivedBy`) пишет, правит или удаляет не его проектор; конец ведёт на такую строку или на производный класс; проектор пишет в класс вне своих `into` или не нашёл ни одного арендатора, у которого все классы `into` помечены его `derivedBy` |
|
|
2306
|
+
| `aggregate_field` | LT022 | приложение передало или изменило агрегатное поле (`aggregate`): его считает база |
|
|
2307
|
+
| `projector_pending` | LT023 | `purge` удалённого объекта, чьё удаление ещё не прочёл проектор его класса: сначала `run()` проектора |
|
|
1665
2308
|
|
|
1666
2309
|
Коды библиотеки:
|
|
1667
2310
|
|
|
@@ -1678,9 +2321,14 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1678
2321
|
| `no_key` | `upsert`, `rekey` или `idOf` у класса без ключа |
|
|
1679
2322
|
| `no_path` | между классами соседних шагов нет перехода |
|
|
1680
2323
|
| `invalid_query` | цепочку или API используют неверно (модификатор шага `deep`, `exact`, `alias`, `tags` или `owner` после глагола, слот не после глагола, курсор `after()` со значением не того типа, что поле `sort`, без `v` или с `id` не uuid и т. п.) |
|
|
1681
|
-
| `admin_required` |
|
|
2324
|
+
| `admin_required` | загрузчику, проектору и `rebuildAggregates` нужно администраторское подключение |
|
|
2325
|
+
| `rebuild_required` | проекция построена другой версией проектора (`version`): нужна перестройка `rebuild()`; `rebuild({ tenant })`, пока проекции нет или идёт перестройка пачками |
|
|
2326
|
+
| `projector_busy` | перестройку не начать: проектор сейчас работает в другом процессе |
|
|
2327
|
+
| `not_empty` | первая заливка (`initial`) — только в пустую схему |
|
|
1682
2328
|
| `class_not_loadable` | строки `Class` загрузчик не принимает |
|
|
1683
|
-
| `bypass_rls` | `connect()` с ролью, которая обходит RLS, без `allowBypassRls` |
|
|
2329
|
+
| `bypass_rls` | `connect()` с ролью, которая обходит RLS, без `allowBypassRls`; `createMcpServer` с подключением в обход RLS |
|
|
2330
|
+
| `user_session_required` | `createMcpServer` и `letopis mcp`: нужна сессия пользователя — у подключения нет сессии, это корень сервиса без `as()`, сессия в арендаторе System без `allowSystemAdmin`, в командной строке ключ сервиса или нет токена |
|
|
2331
|
+
| `confirmation_required` | `letopis_write` (MCP): удаление без подтверждения человека — клиент без elicitation, человек отказал, ответ не к этой операции или данные изменились после превью |
|
|
1684
2332
|
| `removed` | снесённое API 0.21; в тексте — замена |
|
|
1685
2333
|
|
|
1686
2334
|
Отказ PostgreSQL в праве (`42501`) приходит, например, при `upsert` поверх чужой строки, которую нельзя править.
|
|
@@ -1715,9 +2363,32 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1715
2363
|
запись: 119 → 128–147 строк/с в разных прогонах). Построчный `create` остаётся медленнее 0.21 на 12–18 % (замеры
|
|
1716
2364
|
колеблются): основное время — триггеры проверок в базе и фиксация с записью на диск.
|
|
1717
2365
|
|
|
2366
|
+
Первая заливка (`cd lib && npx tsx bench/initial-load.mjs`, 2026-10-11, медиана трёх кругов): 1,07 млн объектов
|
|
2367
|
+
полигона salon без истории обычный загрузчик пишет за 169 с (6 351 строка/с, WAL 4,0 ГБ), первая заливка — за 77 с
|
|
2368
|
+
(13 902 строки/с, WAL 1,4 ГБ). Главную разницу дают индексы GIN: поддерживать их во время загрузки стоит 36 с, построить
|
|
2369
|
+
в конце — около 5 с. Подготовка строк в самом загрузчике (проверки, концы, id) идёт одновременно с `COPY` предыдущей
|
|
2370
|
+
пачки и теперь задаёт скорость: около 60 с из 77.
|
|
2371
|
+
|
|
2372
|
+
Производные данные 1.2 (`cd lib && npx tsx bench/derived.bench.mjs`, 2026-10-27, PostgreSQL 18.6, одна машина; у
|
|
2373
|
+
агрегатов — медиана трёх кругов; строки проектора — повтор серий 2 и 3 после свёртки `inc` в `out`,
|
|
2374
|
+
`BENCH_SERIES=2,3`):
|
|
2375
|
+
|
|
2376
|
+
| Замер | 1.2 | Для сравнения |
|
|
2377
|
+
|---|---|---|
|
|
2378
|
+
| операция с агрегатом, один писатель | 94 операции/с, p50 10,7 мс | без агрегата — 137 и 6,9 мс; рецепт с `inc` — 69 |
|
|
2379
|
+
| операции одного кошелька с агрегатом, 20 писателей | 152 операции/с, p95 0,47 с | без агрегата — 1 987; рецепт с `inc` — 190 |
|
|
2380
|
+
| операции 20 кошельков с агрегатом, 20 писателей | 1 379 операций/с | без агрегата — 2 023 |
|
|
2381
|
+
| догонка проектора, `out.inc` на каждое изменение | 6 076 и 33 395 изменений/с (`batch` 500 и 2000) | до свёртки `inc` в `out` — 992 и 1 203 |
|
|
2382
|
+
| перестройка 75 тыс. строк | атомарная — 13,3–13,5 с, пачками — 13,0–13,2 с | до свёртки `inc` в `out` — 101 с и 58–63 с |
|
|
2383
|
+
| задержка события подписки во время перестройки | атомарной — до 13,4 с, пачками — до 0,21 с | без перестройки — до 0,12 с |
|
|
2384
|
+
|
|
2385
|
+
Триггеры агрегатов срабатывают на каждом операторе записи, но у классов без агрегатов их цены замер не видит: разница
|
|
2386
|
+
p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101.bench.mjs`, четыре прогона) регрессий записи и чтения
|
|
2387
|
+
против 1.1 не показал: два выброса не повторились в отдельных проверках.
|
|
2388
|
+
|
|
1718
2389
|
## 18. Ограничения
|
|
1719
2390
|
|
|
1720
|
-
Здесь собрано то, что letopis 1.
|
|
2391
|
+
Здесь собрано то, что letopis 1.2 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
|
|
1721
2392
|
|
|
1722
2393
|
**Платформа**
|
|
1723
2394
|
|
|
@@ -1732,6 +2403,9 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1732
2403
|
с `prepare: false` в `connect({ sql })`.
|
|
1733
2404
|
- Символ NUL (`\u0000`) в строках данных невозможен: PostgreSQL не хранит его в `jsonb`; такие данные
|
|
1734
2405
|
отклоняются ещё при разборе JSON (официальный набор JSON-Schema-Test-Suite, группы «nul characters», пропущены).
|
|
2406
|
+
- На PostgreSQL до 18.6 параллельная сборка индекса GIN могла записать в статистику таблицы (`pg_class.reltuples`)
|
|
2407
|
+
неверное число строк, после чего автоочистка переставала её обрабатывать. Первая заливка поэтому всегда
|
|
2408
|
+
заканчивается `ANALYZE`, но сервер лучше обновить до 18.6 или новее.
|
|
1735
2409
|
|
|
1736
2410
|
**Запись и целостность**
|
|
1737
2411
|
|
|
@@ -1742,6 +2416,10 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1742
2416
|
библиотеки всегда открываются на этом уровне.
|
|
1743
2417
|
- `COPY` в таблицы под RLS PostgreSQL не выполняет; массовая загрузка — только загрузчиком на администраторском
|
|
1744
2418
|
подключении.
|
|
2419
|
+
- Первая заливка закрывает таблицы схемы на всё время загрузки — и для записи, и для чтения: она для пустой схемы
|
|
2420
|
+
(засевка, перенос, импорт), а не для работающей системы. Выигрыш у неё — в базе (индексы и ограничения строятся
|
|
2421
|
+
один раз); подготовку строк в загрузчике она не ускоряет, а только ведёт одновременно с `COPY`, поэтому на миллионе
|
|
2422
|
+
объектов она быстрее обычной загрузки примерно в 2,2 раза.
|
|
1745
2423
|
- Загрузчику база доверяет: данные, концы и id загруженных строк проверяет код библиотеки (валидатор на TypeScript),
|
|
1746
2424
|
а не триггеры; база сверяет только цели вне загрузки, арендаторов и владельцев одним запросом в конце.
|
|
1747
2425
|
Защита — загрузчик только у администратора, дифференциальные тесты валидатора против базы, `load({ verify: true })`
|
|
@@ -1790,11 +2468,19 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1790
2468
|
- Счётчики попыток OTP и TOTP откатываются вместе с транзакцией вызывающего: частоту попыток ограничивает сервис.
|
|
1791
2469
|
- Самостоятельная привязка занятого идентификатора отвечает `acl_denied` — пользователь с недавним входом так
|
|
1792
2470
|
узнаёт, что идентификатор занят. Изменения прав во всех арендаторах упорядочены одной строкой счётчика.
|
|
1793
|
-
- Число невидимых строк, мешающих
|
|
2471
|
+
- Число невидимых строк, мешающих удалению или смене класса (`reclass_denied`), и существование аккаунта по id (как
|
|
2472
|
+
цели ссылки) не скрываются.
|
|
2473
|
+
- Ужесточение описания класса и `rekeyClass` проверяют все строки семейства, поэтому требуют права `READ` без условий
|
|
2474
|
+
на все строки класса и его потомков: иначе пробное ужесточение (`maximum`, `enum`, `pattern`) отвечало бы числом
|
|
2475
|
+
невидимых строк, которые ему не соответствуют. Системный администратор, ужесточая класс System, получает число
|
|
2476
|
+
нарушающих строк других арендаторов (`others`): это уровень установки.
|
|
1794
2477
|
- Метаданные классов (`meta`) видны всем, у кого есть сессия: строки `Class` читает любой. Секретам там не место.
|
|
1795
|
-
- Прикладная роль может замедлить установку для всех: `LOCK TABLE entity`, advisory-блокировки с
|
|
1796
|
-
|
|
1797
|
-
установку). По глобальному номеру журнала видна общая активность записи.
|
|
2478
|
+
- Прикладная роль может замедлить установку для всех: `LOCK TABLE entity`, advisory-блокировки с ключами `lock()`
|
|
2479
|
+
приложения, поддельные сигналы `NOTIFY` (лишь сбрасывают кэш), частая смена прав (одна строка счётчика на
|
|
2480
|
+
установку). По глобальному номеру журнала видна общая активность записи. Внутренние блокировки — семейства
|
|
2481
|
+
классов, проектора и обслуживания — строки таблицы `secret`, а не advisory: ни прикладная, ни посторонняя роль базы
|
|
2482
|
+
их не займёт. Писатель семейства проходит шлюз ужесточения в подтранзакции — одной на первую запись в семейство за
|
|
2483
|
+
транзакцию.
|
|
1798
2484
|
|
|
1799
2485
|
**История, сигналы, кэш**
|
|
1800
2486
|
|
|
@@ -1805,19 +2491,91 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1805
2491
|
старше яруса `all` у класса с политикой `verify()` не отличит от прореживания.
|
|
1806
2492
|
- Журнал без партиций: прореживание удаляет версии пачками, файл таблицы сам не уменьшается (место занимают новые
|
|
1807
2493
|
версии после autovacuum); вернуть место системе — `VACUUM FULL` или `pg_repack` в окно обслуживания.
|
|
1808
|
-
- Сигналы раскрывают активность: слушать канал PostgreSQL разрешает любой
|
|
1809
|
-
|
|
1810
|
-
|
|
2494
|
+
- Сигналы раскрывают активность: слушать канал PostgreSQL разрешает любой роли. В режиме `class` видно, когда и в
|
|
2495
|
+
какие классы пишут; `tenant` добавляет псевдонимы арендаторов; `opaque` прячет, что записано, но не когда: время и
|
|
2496
|
+
частоту записи по псевдониму видно, а детерминированный токен — основа частотных атак. Время записи видно и без
|
|
2497
|
+
сигналов: `pg_stat_all_tables` и `pg_locks` открыты для PUBLIC. Режим `none` убирает сигналы, но тогда отзыв
|
|
2498
|
+
сессии действует в кэше других процессов только по сроку, а новый класс появляется в их реестре в течение
|
|
2499
|
+
5 секунд. Настоящая защита от посторонних ролей — отдельная база. Поддельный сигнал лишь сбросит кэш лишний раз:
|
|
2500
|
+
по `*` и незнакомым токенам реестр перечитывается не чаще раза в 5 секунд, а поддельный сигнал изменения классов
|
|
2501
|
+
перечитывает его, как настоящий, но не больше одного перечитывания одновременно.
|
|
2502
|
+
- Режим сигналов меняется `up({ signals })`, и после этого нужно перезапустить процессы: режим и ключ сигналов
|
|
2503
|
+
процесс читает при подключении (нагрузку другого режима он считает чужой и сбрасывает весь кэш).
|
|
2504
|
+
- PostgreSQL ниже 18.2 мог усечь CLOG, который ещё нужен непрочитанным сигналам: `up()` предупреждает об этом.
|
|
1811
2505
|
- Очередь `NOTIFY` (8 ГБ) общая для кластера: зависший слушатель её переполняет, и тогда транзакции с `NOTIFY`
|
|
1812
2506
|
падают при фиксации. Библиотека читает сигналы сразу, `maintain()` предупреждает о заполнении больше чем
|
|
1813
2507
|
наполовину.
|
|
2508
|
+
- Транзакции с `NOTIFY` PostgreSQL фиксирует по одной: общую для кластера блокировку очереди сигналов фиксация держит
|
|
2509
|
+
вместе с записью на диск. Запись сырым SQL на своём соединении шлёт сигнал из триггера в той же транзакции, поэтому
|
|
2510
|
+
параллельные писатели сырым SQL фиксируются по очереди: на машине замеров 20 таких писателей делали около 520
|
|
2511
|
+
вставок в секунду. Транзакции библиотеки, в том числе `db.sql` и `db.transaction()`, фиксируют данные без `NOTIFY` и
|
|
2512
|
+
отправляют сигналы потом, отдельно, поэтому их писатели этой очереди не ждут: 20 писателей библиотеки делали около
|
|
2513
|
+
2 000 операций в секунду.
|
|
1814
2514
|
- Горизонт подписки общий для кластера: долгая пишущая транзакция задерживает события всех подписчиков
|
|
1815
2515
|
(`sub.lag()`); длинные операции лучше вести пачками.
|
|
1816
2516
|
- Кэш результатов между процессами устаревает на интервал отправки сигналов (100 мс) плюс доставку и на время
|
|
1817
|
-
переподключения
|
|
2517
|
+
переподключения слушателя: обрыв соединения слушателя postgres.js не сообщает, поэтому до переподключения кэш
|
|
2518
|
+
работает, а после — сбрасывается целиком. Срок `ttl` — предохранитель.
|
|
1818
2519
|
- Курсор подписки «с этого момента» несёт снимок момента подписки, пока не пройдёт его границу: такой
|
|
1819
2520
|
курсор длиннее обычного.
|
|
1820
2521
|
|
|
2522
|
+
**Проекторы**
|
|
2523
|
+
|
|
2524
|
+
- Проектор — процесс администратора: он работает только на администраторском подключении, читает журнал всех
|
|
2525
|
+
арендаторов в обход RLS и пишет результаты от роли владельца. Держите его в отдельном процессе, как загрузчик.
|
|
2526
|
+
Участник проектора — арендатор, у которого все классы `into` помечены его `derivedBy`; пометить класс может только
|
|
2527
|
+
администратор (право `letopis.projector`).
|
|
2528
|
+
- Итоги производного класса видит каждый, кому правила арендатора дают `READ` на него, — даже тот, кто не читает
|
|
2529
|
+
строки источников: решайте, кому его читать, правилами на сам производный класс. С `history: true` его журнал
|
|
2530
|
+
показывает изменение каждой пачки, то есть и вклад отдельных строк источников.
|
|
2531
|
+
- Строки производных классов пишет только их проектор: приложение, сырой SQL и загрузчик получают `derived_class`, а
|
|
2532
|
+
ссылаться на такие строки концами нельзя.
|
|
2533
|
+
- Атомарная перестройка — одна долгая пишущая транзакция: пока она идёт, горизонт подписок стоит, и события
|
|
2534
|
+
задерживаются у подписчиков и проекторов всех процессов. На машине замеров перестройка 75 тысяч строк шла 13 с, и
|
|
2535
|
+
до её конца подписчик не получил ни одного события. Поэтому сам `run()` перестраивает пачками, а атомарную
|
|
2536
|
+
перестройку запускает только явный `rebuild()`.
|
|
2537
|
+
- Перестройка пачками держит горизонт только на время пачки (в тех же замерах события задерживались до 0,2 с), но
|
|
2538
|
+
до её конца производные данные неполные: строки удалены сразу и появляются по мере перестройки.
|
|
2539
|
+
- Проектор видит только изменения из журнала, поэтому класс с `history: false` источником быть не может: арендатор
|
|
2540
|
+
с таким источником выбывает из проектора (`invalid_class`), пока журнал класса не включат и арендатора не
|
|
2541
|
+
перестроят.
|
|
2542
|
+
- Прореживание журнала старше яруса `all` ведёт к перестройке: проектор, отставший больше чем на этот ярус,
|
|
2543
|
+
получает `cursor_expired` или, с `onExpired: 'rebuild'`, перестраивается сам. Ярус — по арендатору: истёк только у
|
|
2544
|
+
части участников — выбывают и перестраиваются только они.
|
|
2545
|
+
- Проектор работает только на схеме той ревизии движка, которую ждёт библиотека; на схеме другой ревизии он
|
|
2546
|
+
отвечает `invalid_data`.
|
|
2547
|
+
- `purge()` ждёт проекторов: пока проектор, чьи источники включают класс объекта, а у арендатора объекта есть его
|
|
2548
|
+
производный класс, не прочёл удаление, `purge` отвечает `projector_pending`. Проектор, который больше не запускают,
|
|
2549
|
+
держит `purge` своих источников, пока у арендатора есть его производные классы: запустите его или удалите их.
|
|
2550
|
+
|
|
2551
|
+
**Агрегаты в базе**
|
|
2552
|
+
|
|
2553
|
+
- Агрегат выстраивает писателей одного объекта в очередь: каждая операция блокирует строку цели и пишет её версию.
|
|
2554
|
+
На машине замеров 20 писателей одного кошелька с агрегатом делали 152 операции в секунду (p95 — 0,47 с), без
|
|
2555
|
+
агрегата — 1 987. Для горячих счётчиков берите проектор со свёрткой пачкой.
|
|
2556
|
+
- `reclass` и `rekey` копят вклады до конца переноса, а каскад удаления и загрузчик применяют их по операторам:
|
|
2557
|
+
правило класса проверяется на итоге каждого оператора каскада и каждой пачки загрузчика. Поэтому загрузка, где
|
|
2558
|
+
списания идут пачкой раньше пополнений, получит `invalid_data`, хотя итог всей загрузки допустим. Каждая пачка,
|
|
2559
|
+
задевшая цель, пишет её версию.
|
|
2560
|
+
- Агрегат арендатора не считает строки класса System, а поле строки System считает только источники System.
|
|
2561
|
+
|
|
2562
|
+
**Агенты и MCP**
|
|
2563
|
+
|
|
2564
|
+
- Обёртка `<untrusted-data-…>` — смягчение, а не защита: модель может последовать тексту внутри неё. Защищают права
|
|
2565
|
+
базы: агент читает и пишет только то, что разрешено его пользователю. В `structuredContent` данные базы лежат в
|
|
2566
|
+
`data` и `error` рядом с пояснением `notice`, без меток: клиент, который отдаёт модели его, а не текст, передаёт
|
|
2567
|
+
данные с пояснением, но без случайной метки.
|
|
2568
|
+
- По умолчанию сервер только читает; запись — `write: true` (`--write`); удаление — только с подтверждением человека.
|
|
2569
|
+
Классы прав сервер пишет только с `allowAclWrite`, сессию в арендаторе System принимает только с
|
|
2570
|
+
`allowSystemAdmin`; `create`, `update` и `upsert` остальных классов подтверждения человека не требуют.
|
|
2571
|
+
- Обработчик HTTP в ревизии 2025 работает без состояния: экземпляр на запрос не знает возможностей клиента, поэтому
|
|
2572
|
+
удаление по нему отклоняется (`confirmation_required`). Удаляйте по stdio или в ревизии 2026-07-28.
|
|
2573
|
+
- Подтверждение в ревизии 2026-07-28 подписано ключом процесса: повтор вызова, попавший в другой процесс приложения
|
|
2574
|
+
(несколько экземпляров за балансировщиком), получает отказ `confirmation_required`.
|
|
2575
|
+
- `versions` отдаёт последние версии в пределах `maxRows` и `maxBytes` (из базы — не больше `maxRows` + 1 версий) и
|
|
2576
|
+
`more`; более ранние — `diff` и `blame` с `from`, `to`, `at`. Режим, в котором агент пишет код против API (code
|
|
2577
|
+
mode), в пакет не входит.
|
|
2578
|
+
|
|
1821
2579
|
**Импорт из 0.21**
|
|
1822
2580
|
|
|
1823
2581
|
- 1.0 — новая major-версия, а не продолжение 0.21: `count()`, `paths()`, `create`, роли, множественные концы
|
|
@@ -1835,7 +2593,8 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1835
2593
|
# connect — подключение под сессией, отказ ролям в обход RLS, реестр, отложенный сигнал, перевод ошибок (этап 2)
|
|
1836
2594
|
# context — сессии, вход сервиса по ключу, политики RLS этапа 1 (этап 1)
|
|
1837
2595
|
# access-rest — оставшиеся строки этапа 5: загрузка от имени пользователя, баланс №20, 22, 29, upsert и { rev } под ACL, встраивание acl_ok (этап 5)
|
|
1838
|
-
#
|
|
2596
|
+
# aggregate — агрегаты в базе: 20 писателей одного кошелька, сырой SQL, каскад, reclass, restore, загрузчик, удаление аккаунта, restore и rekey цели, смена описания под записью и гонка №18, aggregate_field, ремонт и verify, строка System (план 1.1, этап 4б)
|
|
2597
|
+
# acl — права ACL: перенос набора 0.21, условия на строки, вес и запрет по умолчанию, каскад, запрет на потомке, deep, невидимая цель, сырой SQL против библиотеки; смена parent и alias класса — только с правами на все его строки, алиас — не имя другого класса, имя — не чужой алиас (этап 5; ревью безопасности №2)
|
|
1839
2598
|
# acl-parity — 1000 случайных наборов правил: план прав и решения по эндпоинтам базы против decide 0.21 (этап 5)
|
|
1840
2599
|
# auth — креды (пароль, ключ, ключ + секрет, OTP, TOTP, identity), выравнивание времени, регистр, гашение сессий, недавний вход (этап 5)
|
|
1841
2600
|
# balance — пополнение и списание: идемпотентность по opId, гонки, взаимная блокировка, обрыв связи (TCP-прокси), корректировка под { rev } и forUpdate (этап 4)
|
|
@@ -1845,21 +2604,21 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1845
2604
|
# chain-write — запись цепочками: create, update, upsert, delete с превью, anonymize, reclass, слоты, inc, { rev }, fan-out, откат плана (этап 4)
|
|
1846
2605
|
# demo — демо-домен booking на Zod: сид из моделей, запись данных, id по ключу, согласие Zod и базы (этап 2)
|
|
1847
2606
|
# docs — примеры документации исполняются: помеченные блоки README и шпаргалки (русские и английские) — сценариями против PostgreSQL 18 (этап 8)
|
|
1848
|
-
# engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными
|
|
1849
|
-
# engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение,
|
|
2607
|
+
# engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными исходами; ключ сигналов без права, заливка поддельными сигналами, в том числе Class; запись в производный класс и ссылка на него, чекпоинт проектора через функции кредов; агент через MCP без --write и без подтверждения человека; смена parent, alias и derivedBy класса с одним правом WRITE на Class, удаление и пересоздание класса с историей строк, пробное ужесточение как оракул, reclass без DELETE и перевод ссылок глубже первого уровня, политика хранения истории с одним правом WRITE на Class; агент через MCP выдаёт членство или правило, сессия системного администратора; пользователь арендатора сам заводит производный класс, `rekeyClass` предка производного класса; перенос строки, который переписал бы ссылки строк производного класса; прикладная роль держит advisory-блокировки с прежними ключами внутренних блокировок, а строки-замки ей недоступны; подделанная справка о взятых блокировках семейства (этапы 1, 3; план 1.1, этапы 2, 4а, 5, точка А, ревью безопасности №2, точка Б и доработки после неё, ревью безопасности №3)
|
|
2608
|
+
# engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение, метаданные; класс с историей строк удаляется только после purge() (этап 1; доработки после точки Б)
|
|
1850
2609
|
# engine-write — конвейер записи сырым SQL: id v5, умолчания, merge, inc, удаление, воскрешение, журнал, сигналы (этап 1)
|
|
1851
|
-
# engine-races — гонки на двух соединениях в обоих порядках без пауз по
|
|
1852
|
-
# explain — индексы цепочек от роли приложения: снаружи первичный ключ и (class, tenant), внутри find_*() — GIN (этап 3)
|
|
1853
|
-
# find-ids-parity — 30 цепочек через find_*() и прямым чтением под RLS дают одинаковые
|
|
2610
|
+
# engine-races — гонки на двух соединениях в обоих порядках без пауз по времени; ужесточение во время непрерывной записи семейства не голодает; блокировка семейств, чьи корни отличаются только регистром, и подложенная строка вида LOCK (этап 1; ревью безопасности №3)
|
|
2611
|
+
# explain — индексы цепочек от роли приложения: снаружи первичный ключ и (class, tenant), внутри find_*() — GIN; срез asOf по классу не читает журнал другого арендатора (этап 3; доработки после точки Б)
|
|
2612
|
+
# find-ids-parity — 30 цепочек через find_*() и прямым чтением под RLS дают одинаковые ответы; свои членства в других арендаторах и строки Class System — сейчас, asOf и withDeleted (этап 3; доработки после точки Б)
|
|
1854
2613
|
# hash — цепочка хэшей версий, подмена и разрыв журнала, verify() (этап 1)
|
|
1855
2614
|
# harness — тестовая обвязка: подъём базы, схема на файл, две строки подключения (этап 0)
|
|
1856
2615
|
# jsonschema — компиляция JSON Schema в jsonpath и проверка данных в базе (этап 1)
|
|
1857
|
-
# impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor (этап 5)
|
|
2616
|
+
# impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б)
|
|
1858
2617
|
# install — установщик и роли: повторная установка, две установки в базе, три таблицы (этап 1)
|
|
1859
2618
|
# member — членство: работа в арендаторе по ролям, auth.switch, выход с переходом владения, системные строки, роли арендатора без прав System (этап 5)
|
|
1860
2619
|
# model — модели на Zod, нормализатор JSON Schema, перевод шаблонов, совпадение с метасхемой базы (этап 2)
|
|
1861
2620
|
# load — загрузчик: несколько классов, циклы, $key и $ext, повторы, existing, порции, режим истории, запрос по целям вне загрузки, CLI (этап 4)
|
|
1862
|
-
# roles — роли и права: установка без суперпользователя, функции владельца, триггеры по
|
|
2621
|
+
# roles — роли и права: установка без суперпользователя, функции владельца, триггеры по каталогу, журнал без колонки changed (этап 1; план 1.1, точка А)
|
|
1863
2622
|
# write-races — гонки записи: 20 параллельных правок разных полей без потерь и строгий rev, параллельные add/remove множественного конца, загрузчик против ужесточения класса и против параллельной правки (existing: update) в обоих порядках (этап 4)
|
|
1864
2623
|
# validator-parity — валидатор на TypeScript против проверки в базе: официальный набор, 10 000 случайных документов, умолчания (этап 2)
|
|
1865
2624
|
# scenario-barbershop — сквозной сценарий барбершопа: 9 сцен 0.21 (integration) на цепочках 1.0 — каталог, окна батчем, брони под lock, мульти-слот и мульти-мастер, версии, отмена и бронь заново на тот же id, закрытие месяца при restrict (этап 4)
|
|
@@ -1875,11 +2634,16 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
1875
2634
|
# verify — законные разрывы и подмены, verify({ data: true }): данные, концы, арендаторы, id по ключу; права и CLI (этап 6)
|
|
1876
2635
|
# retention — политика хранения: ярусы, конечный срок, особые версии, удалённые, asOf, cursor_expired, часовой пояс, догоняющий запуск, наследование и умолчание — против эталона на TypeScript (этап 6)
|
|
1877
2636
|
# watch — подписка по курсору: порядок фиксации, долгая транзакция, возобновление, обрыв слушателя, два арендатора (этап 6)
|
|
1878
|
-
# cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql (этап 6)
|
|
2637
|
+
# cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков сигналов (этап 6; план 1.1, точка А)
|
|
1879
2638
|
# maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6)
|
|
1880
2639
|
# up — полный установщик: свежая установка, повторный накат, свои сиды, автосоздание базы, UTF8, версия 17, upgrade с миграциями, PGlite, первый арендатор и пользователь (up({ tenant }), createTenant) (этапы 6, 8)
|
|
1881
2640
|
# scenario-salon — сквозной сценарий салона: 21 акт 0.21 (salon) с матрицей покрытия API 1.0 (этап 6)
|
|
1882
2641
|
# telemetry — onQuery и slowMs, describe() и метаданные классов с наследованием, describeText (этап 6)
|
|
2642
|
+
# history — diff и blame по журналу, причина изменения у глаголов, db.begin() и батча; версия, скрытая условием ACL, колонка changed, пропуск версии, row_changes на 20 тыс. целей (план 1.1, этап 1 и точка А)
|
|
2643
|
+
# signals — режимы сигналов: нагрузка у посторонней роли, точность кэша до арендатора, System, классы прав, процесс без ключа, загрузчик, подписка, ключ, none, склейка, версия сервера, смена класса, повтор отправки, режим после up() и reset({ all }) (план 1.1, этап 2)
|
|
2644
|
+
# initial-load — первая заливка: равенство с обычным загрузчиком, структура и режимы триггеров, системные строки, непустая схема, вставка во время запуска, откат, MULTI_INSERT в WAL, commitEvery, import --initial, агрегаты (agg_fill) (план 1.1, этапы 3 и 4б)
|
|
2645
|
+
# projector — проекторы из журнала: догонка, ровно один раз при обрыве, один писатель, долгая транзакция, транзакция на границе снимка перестройки, запрет записи в производные классы и ссылок на них, границы out, перестройка атомарная и пачками во время записи, purge источника, отставание и cursor_expired, функции кредов; версия, имя, администраторское подключение; гонка двух процессов; ошибка данных одного арендатора — выбывает только он (failed, rebuild({ tenant }), опция tenants), чекпоинт — только строка System, перестройка — только строки участников и семейств into, before после rekey, ярус all и журнал источников — по арендатору; свёртка out.inc по ключу в пачке, перестройка пачками в автоматических путях run() и второй процесс во время неё; перестройка одного арендатора пачками, пока идёт хвост; onExpired пачками в другом процессе, ошибка данных без повторов, ярус от начала перестройки; пустой последний проход перестройки пачками, выбывший посреди своей перестройки — без частичных строк, смена класса без смены id из семейства источников и в него, purge непрочитанного надгробия (план 1.1, этап 4а; ревью безопасности №2; точка Б и доработки после неё; ревью безопасности №3)
|
|
2646
|
+
# mcp — инструменты MCP: модель и meta только в результатах, права арендатора, JSON-цепочка и подсказки похожих имён, предел строк и байт с курсором, обёртка недоверенных данных, запись только с write, update только с rev, удаление с подтверждением человека (elicitation в ревизиях 2025 и 2026-07-28), причина в blame, учётные данные, схемы инструментов, letopis без SDK; letopis mcp по stdio, обработчик HTTP; окно подтверждения без алиаса и meta, countBy и versions с пределом в самом запросе, предел шагов и условий $or; классы прав — только с allowAclWrite, сессия в арендаторе System — только с allowSystemAdmin; structuredContent с пояснением, подпись requestState пользователя сервера со сроком (план 1.1, этап 5; ревью безопасности №2; точка Б и доработки после неё)
|
|
1883
2647
|
# import — импорт из 0.21 по фикстуре настоящей 0.21: нарушение ключа, --keyless, счётчики по классам, verify, самоссылка, повтор, id без ключа, висячая ссылка, $$strict и поля на $, update, креды, CLI (этап 7)
|
|
1884
|
-
# security-final — итоговое ревью безопасности (этап 8, §7.10 №8.4): блокировка семейства по арендатору корня, account_purge без права и restore/purge чужого id не дают ни утечки, ни блокировки; функции этапа 6, log_refs_at, срезы find_* по журналу и watch_read не выходят за свой арендатор; NOTIFY несёт только
|
|
2648
|
+
# security-final — итоговое ревью безопасности (этап 8, §7.10 №8.4): блокировка семейства по арендатору корня, account_purge без права и restore/purge чужого id не дают ни утечки, ни блокировки; функции этапа 6, log_refs_at, срезы find_* по журналу и watch_read не выходят за свой арендатор; NOTIFY несёт только имена или токены режима; ключ кэша различает токен, актора и арендатора (этап 8)
|
|
1885
2649
|
```
|