letopis 1.4.1 → 2.0.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.md +329 -278
- package/CHANGELOG.md +558 -1
- package/README.md +606 -1037
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -175
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +33 -10
- package/dist/chain.d.ts +19 -41
- package/dist/chain.js +169 -201
- package/dist/cli.js +51 -88
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -71
- package/dist/index.js +245 -130
- package/dist/internal.d.ts +12 -0
- package/dist/internal.js +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +225 -186
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +171 -215
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +26 -29
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +5 -9
- package/dist/signals.js +5 -9
- package/dist/sql.d.ts +13 -6
- package/dist/sql.js +176 -83
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +51 -21
- package/dist/tx.d.ts +26 -1
- package/dist/tx.js +44 -14
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +23 -91
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +184 -73
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +11 -10
- package/dist/watch.js +33 -70
- package/dist/write.d.ts +9 -16
- package/dist/write.js +118 -187
- package/llms.txt +22 -25
- package/package.json +8 -11
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -667
- package/sql/70-read.sql +20 -16
- package/sql/80-auth.sql +24 -27
- package/sql/90-time.sql +70 -859
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -0
- package/AGENT-CHEATSHEET.en.md +0 -485
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2859
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -350
- package/dist/project.js +0 -1224
- package/sql/75-history.sql +0 -358
- package/sql/88-derived.sql +0 -1235
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# letopis
|
|
1
|
+
# letopis 2.0
|
|
2
2
|
|
|
3
3
|
**letopis** — библиотека для Node.js, хранилище версионированных сущностей на PostgreSQL 18. Каждое изменение
|
|
4
4
|
сохраняется версией в журнале. Поэтому у любого объекта есть история, состояние на любую дату и подписка на
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
RLS. Правила одинаково действуют и для библиотеки, и для сырого SQL. Данные читают и пишут цепочками:
|
|
7
7
|
`db.Shop(shop).Product({ price: lt(1000) }).rows()`.
|
|
8
8
|
|
|
9
|
-
[
|
|
10
|
-
[Изменения по версиям](CHANGELOG.md) · [llms.txt](llms.txt)
|
|
9
|
+
[Шпаргалка для ИИ-агента](AGENT-CHEATSHEET.md) · [Изменения по версиям](CHANGELOG.md) · [llms.txt](llms.txt)
|
|
11
10
|
|
|
12
11
|
Что умеет letopis:
|
|
13
12
|
|
|
@@ -15,16 +14,13 @@ RLS. Правила одинаково действуют и для библио
|
|
|
15
14
|
- **Запись цепочками**: `create`, `update` со слиянием, `upsert`, удаление с превью и каскадом, приращение `inc`
|
|
16
15
|
для денег и счётчиков — [разделы 5](#5-запись), [7](#7-удаление-и-восстановление) и [8](#8-транзакции-батчи-и-деньги).
|
|
17
16
|
- **Чтение цепочками**: переходы по связям, фильтры, деревья, агрегаты, постраничный вывод — [раздел 6](#6-чтение).
|
|
18
|
-
- **История**:
|
|
19
|
-
хранения, проверка цепочки хэшей — [раздел 11](#11-история-и-срезы-на-дату).
|
|
17
|
+
- **История**: версии с автором и причиной, срез на дату, восстановление удалённого — [раздел 11](#11-история-и-срезы-на-дату).
|
|
20
18
|
- **Арендаторы, вход и права** — в самой базе: пароли, ключи API, одноразовые коды, TOTP, сессии, имперсонация,
|
|
21
19
|
правила доступа — [раздел 10](#10-пользователи-вход-и-права).
|
|
22
20
|
- **Подписка на изменения** по курсору без потерь и **кэш результатов** — [раздел 12](#12-подписка-на-изменения-и-кэш).
|
|
23
|
-
- **Производные данные**: агрегаты в базе — суммы и счётчики, которые держит сама база, — и проекторы, которые строят
|
|
24
|
-
сводки из журнала, — [разделы 4](#агрегаты-в-базе) и [12](#проекторы).
|
|
25
21
|
- **Агенты и MCP**: сервер MCP даёт ИИ-агенту модель, чтение, историю и, если разрешено, запись — с правами одного
|
|
26
22
|
пользователя — [раздел 13](#агенты-и-mcp).
|
|
27
|
-
- **Массовая загрузка** миллионов строк
|
|
23
|
+
- **Массовая загрузка** миллионов строк — [раздел 9](#9-массовая-загрузка).
|
|
28
24
|
|
|
29
25
|
Требования:
|
|
30
26
|
|
|
@@ -50,12 +46,11 @@ RLS. Правила одинаково действуют и для библио
|
|
|
50
46
|
11. [История и срезы на дату](#11-история-и-срезы-на-дату)
|
|
51
47
|
12. [Подписка на изменения и кэш](#12-подписка-на-изменения-и-кэш)
|
|
52
48
|
13. [Эксплуатация](#13-эксплуатация)
|
|
53
|
-
14. [
|
|
54
|
-
15. [
|
|
55
|
-
16. [
|
|
56
|
-
17. [
|
|
57
|
-
18. [
|
|
58
|
-
19. [Тесты](#19-тесты)
|
|
49
|
+
14. [Справочник API](#14-справочник-api)
|
|
50
|
+
15. [Ошибки](#15-ошибки)
|
|
51
|
+
16. [Производительность](#16-производительность)
|
|
52
|
+
17. [Ограничения](#17-ограничения)
|
|
53
|
+
18. [Тесты](#18-тесты)
|
|
59
54
|
|
|
60
55
|
Разделы 3–13 построены на одном примере — магазине: установка, модель, запись, чтение и так далее. Блоки кода этих
|
|
61
56
|
разделов идут по порядку и вместе образуют одну программу. Её, как и пример быстрого старта, тест `docs` исполняет
|
|
@@ -69,13 +64,8 @@ RLS. Правила одинаково действуют и для библио
|
|
|
69
64
|
npm install letopis zod postgres # postgres — для createTenant и своих SQL-запросов
|
|
70
65
|
```
|
|
71
66
|
|
|
72
|
-
Приложение подключается к базе отдельной ролью, которая состоит в `letopis_app`.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
```sql
|
|
76
|
-
create role shop_app login password 'app-secret';
|
|
77
|
-
grant letopis_app to shop_app;
|
|
78
|
-
```
|
|
67
|
+
Приложение подключается к базе отдельной ролью, которая состоит в `letopis_app`. Её заводит сама установка: опция
|
|
68
|
+
`login` у `up()` создаёт роль с паролем (повтор только обновит пароль) и выдаёт ей `letopis_app`.
|
|
79
69
|
|
|
80
70
|
Пример ставит схему, заводит магазин с первым пользователем, описывает два класса и работает с ними:
|
|
81
71
|
|
|
@@ -88,8 +78,8 @@ import { z } from 'zod'
|
|
|
88
78
|
const ADMIN = 'postgres://postgres:secret@localhost:5432/shop' // администратор: ставит схему
|
|
89
79
|
const APP = 'postgres://shop_app:app-secret@localhost:5432/shop' // приложение: член letopis_app
|
|
90
80
|
|
|
91
|
-
// 1. Установка схемы v2.shop. tenant — первый арендатор (магазин) и его пользователь Анна.
|
|
92
|
-
const r = await up({ dsn: ADMIN, schema: 'shop',
|
|
81
|
+
// 1. Установка схемы v2.shop. login — роль приложения shop_app, tenant — первый арендатор (магазин) и его пользователь Анна.
|
|
82
|
+
const r = await up({ dsn: ADMIN, schema: 'shop', login: { user: 'shop_app', password: 'app-secret' },
|
|
93
83
|
tenant: { name: 'Ромашка', user: { name: 'Анна', login: 'anna@shop.ru', password: 'Ромашка-2026!' } } })
|
|
94
84
|
// r.serviceKey — ключ сервиса приложения: выдаётся при первой установке, сохраните его
|
|
95
85
|
|
|
@@ -148,21 +138,20 @@ await svc.close()
|
|
|
148
138
|
| арендатор | аккаунт, в чьих данных идёт работа. Данные разных арендаторов изолированы |
|
|
149
139
|
| членство | связь `member` «пользователь — арендатор» с ролями; даёт работать в чужом арендаторе |
|
|
150
140
|
| сессия | токен, по которому база узнаёт аккаунт и арендатора. Сервис получает её по ключу API, пользователь — входом |
|
|
151
|
-
| версия, журнал | каждое изменение строки — версия в журнале `log` с
|
|
141
|
+
| версия, журнал | каждое изменение строки — версия в журнале `log` с автором и временем |
|
|
152
142
|
| надгробие | версия «удалено» с последним снимком объекта |
|
|
153
143
|
| цепочка | запрос вида `db.Shop(shop).Product().rows()`: шаги, модификаторы, глаголы записи и терминал |
|
|
154
|
-
| терминал | метод, который исполняет цепочку: `rows`, `first`, `ids`, `count`, `paths`, агрегаты |
|
|
144
|
+
| терминал | метод, который исполняет цепочку: `rows`, `first`, `ids`, `count`, `paths`, `versions`, агрегаты |
|
|
155
145
|
|
|
156
146
|
### Что хранится в базе
|
|
157
147
|
|
|
158
148
|
Таблиц три. `entity` — текущее состояние: одна строка на каждую живую сущность, включая строки системных классов
|
|
159
149
|
(`Class`, `Account`, `Resource`, `member`, `rule`). `log` — журнал: полный снимок каждой версии с автором,
|
|
160
|
-
временем, видом операции и
|
|
161
|
-
одной строки, с ней работают только функции входа и
|
|
150
|
+
временем, видом операции и причиной. `secret` — креды, сессии и служебные строки-замки: приложение не видит в ней ни
|
|
151
|
+
одной строки, с ней работают только функции входа и блокировок движка.
|
|
162
152
|
|
|
163
153
|
Классы — тоже сущности. Описание класса лежит в строке `Class`; там же база хранит развёрнутое описание с учётом
|
|
164
|
-
предков и скомпилированную проверку данных.
|
|
165
|
-
снимка строки. Поэтому подмену версии выдаёт следующая версия, а у последней — якорь вне базы ([раздел 11](#11-история-и-срезы-на-дату)).
|
|
154
|
+
предков и скомпилированную проверку данных.
|
|
166
155
|
|
|
167
156
|
### Кто проверяет данные
|
|
168
157
|
|
|
@@ -208,9 +197,8 @@ LOGIN-роль состоит только в ней. Всё, что выпол
|
|
|
208
197
|
| `none` | сигналов нет | ничего |
|
|
209
198
|
|
|
210
199
|
Токен — 24 шестнадцатеричных знака ключевого HMAC: сопоставить псевдоним с именем может только процесс с ключом
|
|
211
|
-
сигналов, а ключ выдаётся сервисам (право `letopis.signals`). Кроме сигналов записи, есть
|
|
212
|
-
сессия шлёт `session:<16 hex>` (первые 16 шестнадцатеричных знаков sha256 её токена)
|
|
213
|
-
сброс арендатора или всей установки — `*` (сброс всей установки шлёт ещё и `session:*`). В режиме `none` нет и их.
|
|
200
|
+
сигналов, а ключ выдаётся сервисам (право `letopis.signals`). Кроме сигналов записи, есть служебный: погашенная
|
|
201
|
+
сессия шлёт `session:<16 hex>` (первые 16 шестнадцатеричных знаков sha256 её токена). В режиме `none` нет и его.
|
|
214
202
|
Ни id, ни данных в сигналах нет. По сигналам перечитывается реестр классов, сбрасывается кэш результатов и будится
|
|
215
203
|
подписка; что меняет режим для кэша и подписки — в [разделе 12](#12-подписка-на-изменения-и-кэш).
|
|
216
204
|
|
|
@@ -228,21 +216,22 @@ LOGIN-роль состоит только в ней. Всё, что выпол
|
|
|
228
216
|
| `schema` | имя установки: `'shop'` даёт схему `v2.shop`; можно сразу `'v2.shop'` |
|
|
229
217
|
| `tenant` | первый арендатор и его пользователь (см. ниже) |
|
|
230
218
|
| `seeds` | сиды после ядра: `'booking'` — демо-домен салона, или пути к своим файлам `.sql` |
|
|
231
|
-
| `
|
|
232
|
-
| `serviceKey` | свой ключ сервиса вместо случайного: `lts_` и 32–128 шестнадцатеричных знаков |
|
|
233
|
-
| `allowReset` | разрешить `db.reset()` ([раздел 13](#13-эксплуатация)); по умолчанию выключено |
|
|
219
|
+
| `serviceKey` | свой ключ сервиса вместо случайного: `lts_` и 32–128 шестнадцатеричных знаков в нижнем регистре (`/^lts_[0-9a-f]{32,128}$/`), иначе `invalid_data` ([«Сервис и ключи API»](#сервис-и-ключи-api)) |
|
|
234
220
|
| `signals` | режим сигналов: `'class'` (по умолчанию), `'tenant'`, `'opaque'` или `'none'` ([«Сигналы»](#сигналы)); режим хранится в схеме — `up()` без опции его не меняет |
|
|
235
221
|
| `fresh` | удалить схему и поставить заново — **данные схемы теряются** |
|
|
236
|
-
| `upgrade` | обновить схему
|
|
222
|
+
| `upgrade` | обновить схему отставшей ревизии: миграции и повторный накат ([«Обновление пакета и схемы»](#обновление-пакета-и-схемы)) |
|
|
237
223
|
| `pglite` | база для разработки без PostgreSQL (см. ниже) |
|
|
238
224
|
| `quiet`, `waitTimeoutMs` | без сообщений в консоль; сколько ждать базу (по умолчанию 60 000 мс) |
|
|
239
225
|
| `owner`, `app` | имена ролей владельца и приложения (по умолчанию `letopis_owner`, `letopis_app`) |
|
|
226
|
+
| `login` | роль приложения `{ user, password }`: заводится или получает новый пароль и членство в `letopis_app`; строка подключения — `r.login.dsn` ([«Роль приложения»](#роль-приложения)) |
|
|
227
|
+
| `migrationsDir` | свой каталог миграций для `upgrade` (по умолчанию `sql/migrate/` пакета) |
|
|
240
228
|
|
|
241
229
|
Результат: `schema` (полное имя), `serviceKey` (при первой установке и при накате, когда в установке не осталось ни
|
|
242
230
|
одного ключа API, кроме отозванных: истёкший или выключенный ключ тоже в счёт; иначе `null`), `created`,
|
|
243
|
-
`upgraded`, `revision`, `migrations`, `analyzed
|
|
244
|
-
|
|
245
|
-
|
|
231
|
+
`upgraded`, `revision`, `migrations`, `analyzed` (таблицы без статистики, у которых её собрал `ANALYZE` от роли
|
|
232
|
+
владельца; оставшиеся без неё — в `warnings`), `owner`, `app`, `signals` (режим сигналов установки), `warnings`
|
|
233
|
+
(предупреждения, без `quiet` они печатаются), а также `tenant`, `pglite` и `login` (`{ user, created, dsn }`), если
|
|
234
|
+
их просили. На PostgreSQL ниже 18.2 `up()` предупреждает: такой сервер мог усечь CLOG, который ещё нужен непрочитанным сигналам, — обновите его.
|
|
246
235
|
|
|
247
236
|
Несколько экземпляров сервиса могут звать `up()` одной схемы одновременно: накат идёт под блокировкой схемы. Первый
|
|
248
237
|
`up()` ставит или обновляет её, остальные ждут и делают повторный накат по готовой (`created: false`,
|
|
@@ -271,11 +260,14 @@ const ANNA = r.tenant!.user! // его первый пользовател
|
|
|
271
260
|
|
|
272
261
|
### Роль приложения
|
|
273
262
|
|
|
274
|
-
Приложение работает от LOGIN-роли, которая состоит только в `letopis_app`:
|
|
275
|
-
|
|
263
|
+
Приложение работает от LOGIN-роли, которая состоит только в `letopis_app`. Её заводит `up({ login: { user, password } })`:
|
|
264
|
+
имя и пароль квотирует база, повтор обновляет пароль, а привилегированную роль (суперпользователя, с `CREATEROLE`,
|
|
265
|
+
`CREATEDB`, `REPLICATION` или `BYPASSRLS`, роль-владельца или её члена) `up()` не трогает — `invalid_data`; строка подключения роли — `r.login.dsn`.
|
|
266
|
+
Вручную — `create role shop_app login password '…'; grant letopis_app to shop_app;`. На роль приложения действуют RLS и
|
|
276
267
|
триггеры, как и на любой сырой SQL от этой роли. Роль, которая обходит RLS, `connect()` отклоняет с ошибкой
|
|
277
|
-
`bypass_rls`. Администраторское подключение (`allowBypassRls: true`) нужно только загрузчику,
|
|
278
|
-
|
|
268
|
+
`bypass_rls`. Администраторское подключение (`allowBypassRls: true`) нужно только загрузчику, обслуживанию
|
|
269
|
+
и первой настройке прав арендатора ([«Права только по ролям»](#права-только-по-ролям)); держите его в отдельном процессе, а не в веб-приложении.
|
|
270
|
+
Малому приложению хватит одного процесса — [ниже](#один-процесс).
|
|
279
271
|
|
|
280
272
|
### Первый арендатор и пользователь
|
|
281
273
|
|
|
@@ -283,7 +275,8 @@ const ANNA = r.tenant!.user! // его первый пользовател
|
|
|
283
275
|
`up({ tenant })` и функция `createTenant(sql, schema, opts)` делают это одной транзакцией:
|
|
284
276
|
|
|
285
277
|
- арендатор — аккаунт в System с id из имени («tenant:<имя>»);
|
|
286
|
-
- правило «аутентифицированным можно всё» в этом арендаторе (`grantAll`, по умолчанию да, вес 10
|
|
278
|
+
- правило «аутентифицированным можно всё» в этом арендаторе (`grantAll`, по умолчанию да, вес 10; права только по
|
|
279
|
+
ролям — [ниже](#права-только-по-ролям));
|
|
287
280
|
- пользователь (id из логина) с членством в арендаторе с ролями `user.roles` (по умолчанию `['owner']`), паролем и
|
|
288
281
|
сессией в арендаторе.
|
|
289
282
|
|
|
@@ -343,15 +336,18 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
|
|
|
343
336
|
| `refreshSession` | продлевать сессию раз в минуту работы (по умолчанию да) |
|
|
344
337
|
| `notifyIntervalMs` | интервал отправки отложенных сигналов (100 мс) |
|
|
345
338
|
| `allowBypassRls`, `owner` | разрешить роль в обход RLS (администраторское подключение); имя роли-владельца |
|
|
339
|
+
| `admin` | строка подключения администратора или пул postgres.js: второй пул для `load`, `maintain` и `syncModels` с той же сессией ([«Один процесс»](#один-процесс)) |
|
|
340
|
+
|
|
341
|
+
Неизвестная опция — ошибка `invalid_data` до подключения: похожие имена — в тексте и в `err.params.similar`. Так же
|
|
342
|
+
проверяют опции `up()`, `db.load()`, `db.watch()` и `createMcpServer()` ([«Строгий режим»](#строгий-режим)).
|
|
346
343
|
|
|
347
344
|
У подключения есть `db.schema`, `db.token`, `db.tenant` (арендатор сессии) и `db.close()`. Подключения,
|
|
348
345
|
полученные через `as()`, делят пул с исходным: закрывать нужно исходное.
|
|
349
346
|
|
|
350
347
|
### Администраторское подключение
|
|
351
348
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
`maintain()` работают и на нём, и у системного администратора по обычному подключению. Администраторское
|
|
349
|
+
Загрузчику нужно подключение роли, которая может стать владельцем, с `allowBypassRls: true`. `maintain()` работает и
|
|
350
|
+
на нём, и у системного администратора по обычному подключению. Администраторское
|
|
355
351
|
подключение печатает предупреждение «RLS не действует» — так и должно быть. Сессия задаёт арендатора, с которым
|
|
356
352
|
оно работает. Отдельному процессу загрузки удобнее ключ сервиса: `connect({ dsn: ADMIN, schema, apiKey,
|
|
357
353
|
allowBypassRls: true })`, а арендатора строк задаёт опция `load(…, { tenant })`. В примере — сессия из `up()`:
|
|
@@ -361,34 +357,60 @@ allowBypassRls: true })`, а арендатора строк задаёт опц
|
|
|
361
357
|
const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token!, allowBypassRls: true })
|
|
362
358
|
```
|
|
363
359
|
|
|
360
|
+
### Один процесс
|
|
361
|
+
|
|
362
|
+
Малому приложению не обязательно держать два процесса. `up({ login })` заводит роль приложения и отдаёт её строку
|
|
363
|
+
подключения (`r.login.dsn`), а `connect({ dsn, admin, token })` при первом администраторском вызове открывает второй
|
|
364
|
+
пул — не больше двух соединений — строкой администратора. Через него идут только `load`, `maintain` и `syncModels`,
|
|
365
|
+
с той же сессией; цепочки и `db.sql` остаются в пуле приложения под RLS. Роль строки `admin` —
|
|
366
|
+
суперпользователь или роль с `SET` на роль-владельца, иначе `admin_required`.
|
|
367
|
+
Пароль администратора в процессе приложения — только для малых приложений: взломанный процесс получит и его.
|
|
368
|
+
|
|
369
|
+
<!-- run: guide -->
|
|
370
|
+
```ts
|
|
371
|
+
const soloUp = await up({ dsn: ADMIN, schema: r.schema, login: { user: 'shop_app', password: 'app-secret' } })
|
|
372
|
+
const solo = await connect({ dsn: soloUp.login!.dsn!, admin: ADMIN, schema: r.schema, token: r.tenant!.token! })
|
|
373
|
+
const soloCheck = await solo.maintain() // администраторским пулом, с сессией Анны: { skipped: false, … }
|
|
374
|
+
const soloMembers = await solo.member().count() // пулом приложения, под RLS: только своего арендатора
|
|
375
|
+
await solo.close() // закрывает оба пула
|
|
376
|
+
```
|
|
377
|
+
|
|
364
378
|
### Обновление пакета и схемы
|
|
365
379
|
|
|
366
|
-
У схемы есть ревизия движка (сейчас
|
|
380
|
+
У схемы есть ревизия движка (сейчас 8). Если новая версия пакета принесёт новую ревизию, `connect()` только
|
|
367
381
|
предупредит и назовёт команду. Отказывает `up()`: на отставшую схему без `upgrade: true` и на схему новее пакета.
|
|
368
382
|
К схеме новее пакета (её уже обновил более новый пакет, а старые экземпляры приложения ещё работают) `connect()`
|
|
369
|
-
подключается с предупреждением
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
383
|
+
подключается с предупреждением
|
|
384
|
+
«движок схемы … ревизии N новее библиотеки (она знает M) — обновите пакет letopis». Поэтому скрипт запуска, который
|
|
385
|
+
вызывает `up()` при каждом старте, после такого обновления пакета остановится, пока не получит `upgrade: true`.
|
|
386
|
+
`up({ dsn, schema, upgrade: true })` применяет миграции из `sql/migrate/` и повторно накатывает функции; данные
|
|
387
|
+
сохраняются. У 2.0 миграций нет.
|
|
388
|
+
|
|
389
|
+
Ревизия меняется не с каждой версией: у версий одной ревизии функции могут быть разные. Поэтому `up()` пишет в
|
|
390
|
+
комментарий схемы ещё и штамп движка: версию пакета и хэш его файлов SQL. Если штамп не совпадает с файлами пакета,
|
|
391
|
+
`connect()` предупреждает. Когда схему накатила более старая версия, он советует `up({ schema })`. Когда схему уже
|
|
392
|
+
обновила более новая версия, он советует обновить пакет: `up()` старой версией вернул бы прежние функции. Новая версия
|
|
393
|
+
пакета без правок SQL штамп не меняет, и предупреждения нет.
|
|
394
|
+
|
|
395
|
+
### Версии и совместимость
|
|
396
|
+
|
|
397
|
+
Версии letopis следуют semver. Что обещает каждый шаг версии:
|
|
398
|
+
|
|
399
|
+
- **Патч** (2.0.0 → 2.0.1) — только исправления. Ревизия движка та же: исправленные функции накатывает обычный
|
|
400
|
+
`up({ schema })`, миграций нет; до наката `connect()` предупреждает о расхождении штампа. Работающий код не получает
|
|
401
|
+
новых отказов, методы, опции, экспорты и коды ошибок не исчезают.
|
|
402
|
+
- **Минор** (2.0 → 2.1) — новые возможности: методы, опции, коды ошибок; изменения поведения, которые не ломают
|
|
403
|
+
работающий код; новая ревизия движка. Новую ревизию накатывает `up({ schema, upgrade: true })`. Удалённого и
|
|
404
|
+
переименованного нет.
|
|
405
|
+
- **Мажор** (2.x → 3.0) — удаление и переименование методов, опций, экспортов и кодов ошибок, смена сигнатур и типов,
|
|
406
|
+
новые отказы для всех. Что снято и чем его заменить, перечисляет запись версии в [CHANGELOG.md](CHANGELOG.md). Схему
|
|
407
|
+
прошлого мажора новый мажор может не обновлять.
|
|
408
|
+
|
|
409
|
+
Текст сообщения об ошибке в API не входит: в патче он может стать точнее. Полагайтесь на `err.code`.
|
|
410
|
+
|
|
411
|
+
Шаг версии перед каждым выпуском сверяет сценарий `check:semver`: он сравнивает контракт API (`docs/api-contract.json`)
|
|
412
|
+
и ревизию движка с прошлым выпуском и требует шаг не меньше нужного. Каждый следующий выпуск 2.x проверяет
|
|
413
|
+
обновление схемы с предыдущего выпуска.
|
|
392
414
|
|
|
393
415
|
### Разработка без PostgreSQL: PGlite
|
|
394
416
|
|
|
@@ -470,7 +492,10 @@ const applied = await syncModels(db, models, { apply: true }) // { applied: true
|
|
|
470
492
|
Правила `refine`, `superRefine` и `transform` в JSON Schema не выражаются, и база их не исполняет. `syncModels`
|
|
471
493
|
возвращает их в `lost`: проверяйте такие правила в приложении. Описание с ключевым словом JSON Schema, которого
|
|
472
494
|
база не знает, отклоняется — ни одно правило не пропадёт молча. Шаблоны `pattern` — ограниченное подмножество
|
|
473
|
-
регулярных выражений, общее для Zod, валидатора и базы.
|
|
495
|
+
регулярных выражений, общее для Zod, валидатора и базы. Исполняет шаблон на данных только база: шаблон задаёт
|
|
496
|
+
арендатор, и шаблон с катастрофическим перебором повесил бы процесс приложения. Поэтому валидатор на TypeScript
|
|
497
|
+
(`compileValidator`, проверка до записи) `pattern` и `anyOf`/`oneOf` с ним в ветке лишь разбирает, а нарушение
|
|
498
|
+
`pattern` приходит от базы при записи — тот же `ValidationError`.
|
|
474
499
|
|
|
475
500
|
### Концы
|
|
476
501
|
|
|
@@ -498,8 +523,8 @@ const applied = await syncModels(db, models, { apply: true }) // { applied: true
|
|
|
498
523
|
- id не передают: база вычисляет его сама. Чужой id — `id_mismatch`, id для класса без ключа — `id_from_db`.
|
|
499
524
|
- Заранее id вычисляет `db.idOf(класс, ключ)`, например `db.idOf('Shop', { name: 'Ромашка' })`.
|
|
500
525
|
- Класс без ключа (`Customer` выше) получает id от случайной части; узнать его можно из ответа записи.
|
|
501
|
-
-
|
|
502
|
-
([раздел 5](#5-запись)).
|
|
526
|
+
- Ключ и класс объекта неизменны: правка ключевого поля или класса — `immutable_key`. Объект с другим ключом или
|
|
527
|
+
классом — новый объект ([раздел 5](#5-запись), «Ключ и класс неизменны»).
|
|
503
528
|
|
|
504
529
|
Если уникальным должно быть что-то кроме ключа, сделайте это отдельным классом. Например, email — ключ хаба
|
|
505
530
|
`Email`, а связь человека с ним — ключ `['Email']`. Персональные данные лучше не делать ключом самого человека:
|
|
@@ -514,78 +539,15 @@ id из email подбирается перебором и остаётся в
|
|
|
514
539
|
|
|
515
540
|
### Настройки класса
|
|
516
541
|
|
|
517
|
-
- `history: false` — журнал для класса не ведётся: нет версий, среза на дату, восстановления и событий
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
542
|
+
- `history: false` — журнал для класса не ведётся: нет версий, среза на дату, восстановления и событий подписки (по
|
|
543
|
+
умолчанию `true` — журнал хранит каждую версию). Сменить `history` у существующего класса может только тот, у кого
|
|
544
|
+
есть право `READ` и `DELETE` без условий на все строки класса и его потомков: `false` выключает журнал — аудит этих
|
|
545
|
+
строк.
|
|
521
546
|
- `ownerDefault: 'actor'` — владелец новых строк — сам пользователь, а не арендатор (по умолчанию `'tenant'`).
|
|
522
547
|
Пригодится для правила «каждый видит свои записи».
|
|
523
548
|
- `cache: { ttl }` — чтения только этого класса кэшируются без `.cache()` ([раздел 12](#12-подписка-на-изменения-и-кэш)).
|
|
524
549
|
- `meta` — свободные метаданные для приложения: название, иконка, цвет, порядок в меню. До 64 КБ, наследуются по
|
|
525
550
|
ключам (`null` убирает унаследованное значение), видны всем, у кого есть сессия. У конца — `.meta()`.
|
|
526
|
-
- `derivedBy: 'имя-проектора'` — производный класс: его строки пишет только этот проектор ([«Проекторы»](#проекторы)).
|
|
527
|
-
Имя — строчные латинские буквы, цифры и `_.:-` (первый знак — буква или цифра), до 64 символов. Опция
|
|
528
|
-
наследуется, потомок её не меняет. Концы производного класса — только `onDelete: 'cascade'` или `'unset'`:
|
|
529
|
-
производная строка не мешает удалять цель. На строки производного класса не ссылаются. Приложение, сырой SQL и
|
|
530
|
-
загрузчик в такой класс не пишут: запись, правка и удаление его строк — `derived_class`. Сам производный класс
|
|
531
|
-
(свой или унаследованный) заводит, меняет и удаляет только администратор — администраторское подключение или
|
|
532
|
-
аккаунт с правом System `letopis.projector`; у сессии приложения это `acl_denied` ([«Проекторы»](#проекторы)).
|
|
533
|
-
- `aggregate: { поле: { sum | count, of, via, where } }` — агрегат в базе: сумму или счётчик строк источника держит
|
|
534
|
-
сама база ([«Агрегаты в базе»](#агрегаты-в-базе)).
|
|
535
|
-
|
|
536
|
-
### Агрегаты в базе
|
|
537
|
-
|
|
538
|
-
Агрегат — поле, которое держит сама база: сумма поля или число живых строк класса-источника, чей конец ведёт на
|
|
539
|
-
строку. Он нужен там, где итог обязан совпадать с источником и для сырого SQL: баланс кошелька, число позиций заказа.
|
|
540
|
-
|
|
541
|
-
```ts
|
|
542
|
-
const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0), deposits: z.number().int().optional() }, {
|
|
543
|
-
key: ['number'],
|
|
544
|
-
aggregate: {
|
|
545
|
-
balance: { sum: 'amount', of: 'Movement', via: 'Wallet' }, // сумма операций
|
|
546
|
-
deposits: { count: true, of: 'Movement', via: 'Wallet', where: { properties: { amount: { minimum: 1 } } } }, // число пополнений
|
|
547
|
-
},
|
|
548
|
-
})
|
|
549
|
-
const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet } })
|
|
550
|
-
```
|
|
551
|
-
|
|
552
|
-
- `sum: 'поле'` — сумма числового поля источника, `count: true` — число строк. `of` — класс-источник (имя или
|
|
553
|
-
модель) вместе с потомками, `via` — его одиночный конец, который ведёт на строку-цель. `where` — JSON Schema над
|
|
554
|
-
данными источника: строки, которые её не проходят, в агрегат не входят.
|
|
555
|
-
- Считаются только живые строки того же арендатора, что и цель. Ссылка на строку System в агрегат не входит: общий
|
|
556
|
-
справочник не считает данные всех арендаторов. Агрегат класса арендатора, в том числе унаследованный от класса
|
|
557
|
-
System, считает только классы своего арендатора: класс System с именем `of` в него не входит, а свой потомок класса
|
|
558
|
-
System входит. Агрегат класса System считает классы System и классы арендатора строки-цели.
|
|
559
|
-
- Поле пересчитывает триггер после каждого оператора: запись библиотеки, сырой SQL, каскад удаления, `restore`,
|
|
560
|
-
`reclass`, `rekey`, загрузчик, удаление аккаунта. Операции одного оператора складываются в одно приращение цели, а
|
|
561
|
-
правило класса (`min(0)`) проверяется над итогом: операция, которая увела бы баланс в минус, отклоняется с
|
|
562
|
-
`invalid_data`. Каждое изменение цели — её новая версия с автором и причиной операции.
|
|
563
|
-
- Поле пишет только база. Значение в `create`, `update`, `upsert`, `rekey`, `reclass` или в сыром SQL — ошибка
|
|
564
|
-
`aggregate_field`. Новая строка получает итог уже существующих источников, обычно 0. Замена данных целиком без
|
|
565
|
-
поля сохраняет прежнее значение. Загрузчик переданное значение не пишет. Права на запись в цель не нужно:
|
|
566
|
-
достаточно права на запись в источник.
|
|
567
|
-
- Описание проверяет база (`invalid_class`): поле есть в схеме, оно числовое и не входит в ключ; конец `via` —
|
|
568
|
-
одиночный и принимает класс цели; поле `sum` — число; агрегаты не образуют цикл; цель и источник — не системные и
|
|
569
|
-
не производные классы. Класс-источник описывают раньше агрегата: агрегат над классом, которого ещё нет, отклоняется.
|
|
570
|
-
У класса арендатора источник ищут среди классов арендатора. `syncModels` сам описывает источники раньше целей, а
|
|
571
|
-
модели по кругу (агрегат над собственным потомком, цикл агрегатов) отклоняет до записи (`invalid_class` с кругом).
|
|
572
|
-
Класс `of` не удаляется, пока его называет агрегат класса того же арендатора: сначала агрегат убирают из описания цели.
|
|
573
|
-
- Строки попадают в агрегат только с ведома того, кто читает их все: поле считает и строки, которые автору не видны.
|
|
574
|
-
Описать или изменить агрегат может только тот, кто без условий читает все строки источника и его потомков. Сделать
|
|
575
|
-
класс источником (новый потомок источника, смена `parent`) может только тот, кто без условий читает все
|
|
576
|
-
строки этого класса и его потомков — и до правки, и после. Иначе `acl_denied`.
|
|
577
|
-
- Новый источник проверяется и по тому, кто описал агрегат: он тоже должен без условий читать все строки класса.
|
|
578
|
-
Описавший — автор версии строки `Class` цели, с которой описание агрегата стало нынешним. Если он их не читает, класс
|
|
579
|
-
источником не станет (`acl_denied`), пока агрегат не опишет заново тот, кто читает. У агрегата класса System
|
|
580
|
-
описавший проверяется по классам System, как и при описании. Класс арендатора под агрегатом System проверяется
|
|
581
|
-
только у того, кто его заводит.
|
|
582
|
-
- Опция наследуется, потомок заменяет её целиком. Добавить или изменить агрегат — ужесточение: запись в цель и в
|
|
583
|
-
источники ждёт, пока поле пересчитывается у всех строк.
|
|
584
|
-
- `db.rebuildAggregates(класс)` пересчитывает поле из текущих строк — для ремонта. Версия пишется только там, где
|
|
585
|
-
значение изменилось. Расхождения показывает `db.verify({ data: true })`. Нужно администраторское подключение.
|
|
586
|
-
- Сырой `INSERT … ON CONFLICT DO UPDATE SET data = excluded.data` для цели отклоняется: поле в `excluded.data` уже
|
|
587
|
-
поставил триггер вставки. Пишите `excluded.data - array['balance']`, и поле останется прежним; `upsert` библиотеки
|
|
588
|
-
делает так сам.
|
|
589
551
|
|
|
590
552
|
### Записать модель в базу
|
|
591
553
|
|
|
@@ -594,7 +556,7 @@ const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet
|
|
|
594
556
|
`syncModels` трогает только переданные классы и ничего не удаляет. Безопасные изменения — новое необязательное поле или
|
|
595
557
|
конец, метаданные, алиас — применяются сразу. Всё остальное, в том числе любая правка условий `anyOf` и `oneOf`, —
|
|
596
558
|
ужесточение: оно проверяет все строки семейства, и если им мешают строки, приходит `tightening_conflict` со списком. Классы пишутся в арендатора сессии, и другие арендаторы их не видят.
|
|
597
|
-
Классы, общие для всех, лежат в System: их ставит сид
|
|
559
|
+
Классы, общие для всех, лежат в System: их ставит сид установки.
|
|
598
560
|
|
|
599
561
|
То же из командной строки:
|
|
600
562
|
|
|
@@ -604,25 +566,69 @@ npx letopis sync ./models.js --dsn "$APP" --schema v2.shop --token "$TOKEN" --ap
|
|
|
604
566
|
npx letopis types --dsn "$APP" --schema v2.shop --token "$TOKEN" --out types.ts # типы классов из базы
|
|
605
567
|
```
|
|
606
568
|
|
|
607
|
-
###
|
|
569
|
+
### Строгий режим
|
|
570
|
+
|
|
571
|
+
Строгий режим ловит ошибки раньше записи: опечатку в имени класса, роли или поля — при компиляции, а без строгих
|
|
572
|
+
типов — до обращения к базе; правку и удаление всех строк без условия, неизвестную опцию и курсор у счёта — тоже до
|
|
573
|
+
обращения к базе.
|
|
608
574
|
|
|
609
|
-
`connect({ …, models })`
|
|
610
|
-
|
|
611
|
-
|
|
575
|
+
**Строгие типы.** `connect({ …, models })` с моделями `letopis/model` или `connect<Models>({ … })` с объявлениями
|
|
576
|
+
`letopis types` дают подключение со строгими типами (`TypedDb`). Описания классов библиотека всё равно берёт из базы:
|
|
577
|
+
модели и объявления нужны только TypeScript.
|
|
578
|
+
|
|
579
|
+
- Шаг от корня — только класс или алиас из моделей и системные классы `Class`, `Account`, `member`, `Resource`,
|
|
580
|
+
`rule` (встроенный набор `SystemModels`; своя модель с тем же именем заменяет встроенную).
|
|
581
|
+
- Переход — только по концам: от класса — его роли и классы, связанные с ним концом в любую сторону.
|
|
582
|
+
- Слот — только роль записываемого класса: `set(id | строка | цепочка)`, `add()` и `remove()` — у множественного
|
|
583
|
+
конца, `unset()` — у необязательного.
|
|
584
|
+
- Поля фильтра и `or()` — поля и роли класса шага; поле `sum()` и `avg()` — числовое `'data.<путь>'` класса, `min()`,
|
|
585
|
+
`max()` и `countBy()` — любое его поле.
|
|
586
|
+
- Строки ответа типизированы данными и концами класса: тип данных — `Infer<typeof Product>`, концов —
|
|
587
|
+
`InferLinks<typeof Product>`. Своё описание `meta` типизируется расширением интерфейса `ClassMeta`.
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
import { connect, gte, loose } from 'letopis'
|
|
591
|
+
|
|
592
|
+
const typed = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!, models })
|
|
593
|
+
const pricey = await typed.Shop({ name: 'Ромашка' }).Product({ price: gte(1000) }).rows() // data.price — number
|
|
594
|
+
// @ts-expect-error — класса Prodcut нет
|
|
595
|
+
typed.Prodcut()
|
|
596
|
+
// @ts-expect-error — Shop и Category не связаны концом
|
|
597
|
+
typed.Shop({ name: 'Ромашка' }).Category()
|
|
598
|
+
const name: string = 'Product' // имя, известное только во время выполнения
|
|
599
|
+
const n = await loose(typed)[name]().count()
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
`letopis types` выдаёт для каждого класса `<Класс>Data`, `<Класс>Links` и `<Класс>Model` — имя, алиас, концы с
|
|
603
|
+
классами целей и предков, — а в конце список `Models`: строгие типы работают и без моделей Zod. Имя, известное только
|
|
604
|
+
во время выполнения, — `loose(db)[имя]()`: то же подключение без строгих типов, проверки во время выполнения те же.
|
|
605
|
+
Модели без литеральных имён (список типа `AnyModel[]`) строгих типов не дают — тогда подключение без них (`Db`).
|
|
606
|
+
Чего строгие типы не знают: возврат к узлу связи от класса, с которым она не связана концом, и роль чужого класса
|
|
607
|
+
шагом; `db.as()`, `db.begin()`, `db.batch()` и `entity()` отдают цепочки без строгих типов.
|
|
608
|
+
|
|
609
|
+
**Отказы во время выполнения** — с моделями и без них:
|
|
610
|
+
|
|
611
|
+
| Что | Ответ |
|
|
612
|
+
|---|---|
|
|
613
|
+
| `update()` или `delete({ confirm: true })`, а ни у шага глагола, ни у шагов до него нет фильтра, id, непустого `tags()`, `owner()` или `limit()` и раньше в цепочке нет `create()` или `upsert()` (цель `update()` задаёт и `data.id` патча) | `invalid_query` до записи, в батче — при `run()`; все строки шага — `{ all: true }` ([раздел 5](#изменить-update), [раздел 7](#превью-и-удаление)) |
|
|
614
|
+
| неизвестная опция `connect()`, `up()`, `db.load()`, `db.watch()`, `createMcpServer()` | `invalid_data` с похожими именами |
|
|
615
|
+
| шаг — не класс, не алиас и не роль реестра | `invalid_class` с похожими именами — при вызове шага |
|
|
616
|
+
| поле фильтра (и внутри `or()`), `sort('data.…')` или агрегата, которого нет в описании ни одного класса шага (с потомками, если шаг не `exact()`; у роли — её целей); в фильтре годится и роль конца | `invalid_query` с похожими именами до запроса, у плана записи — до записи. Сверяется только первый ключ пути: путь внутрь поля-объекта не проверяется. У строк `Class` поля — свойства описания класса (`kind`, `alias`, `parent` и другие) |
|
|
617
|
+
| `after()` перед `count()`, `sum()`, `avg()`, `min()`, `max()` или `countBy()` | `invalid_query`: курсор листает строки, а счёт идёт по всем объектам шага |
|
|
618
|
+
| класс с зарезервированным именем | `invalid_class`: хаб — от `hub()`, `syncModels` и базы, связь — от базы ([«Имена классов»](#имена-классов)) |
|
|
612
619
|
|
|
613
620
|
### Имена классов
|
|
614
621
|
|
|
615
622
|
Шаг цепочки называется именем класса, его алиасом или именем роли. Имена методов цепочки и корня (`count`, `sort`,
|
|
616
|
-
`set`, `sql`, `auth` и другие) там, где такой метод есть, библиотека разбирает раньше
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
[«Зарезервированные имена»](#зарезервированные-имена).
|
|
623
|
+
`set`, `sql`, `auth` и другие) там, где такой метод есть, библиотека разбирает раньше классов, поэтому класс с таким
|
|
624
|
+
именем не создать: хаб отклоняют `hub()`, `syncModels` и база, связь — база (`invalid_class`). Хаб, заведённый мимо
|
|
625
|
+
проверки базы (например, от роли владельца), `up()` оставляет и называет в предупреждении, правку его описания база
|
|
626
|
+
отклоняет. Полный список имён — в разделе [«Зарезервированные имена»](#зарезервированные-имена).
|
|
620
627
|
|
|
621
628
|
## 5. Запись
|
|
622
629
|
|
|
623
|
-
Глаголы записи — `create`, `update`, `upsert`, `delete`, `
|
|
624
|
-
|
|
625
|
-
план — одна транзакция.
|
|
630
|
+
Глаголы записи — `create`, `update`, `upsert`, `delete`, `restore` — звенья плана. Сам по себе глагол ничего не
|
|
631
|
+
делает: план исполняет любой терминал (`rows`, `first`, `ids`, `count` и другие), и весь план — одна транзакция.
|
|
626
632
|
|
|
627
633
|
### Создать: `create`
|
|
628
634
|
|
|
@@ -636,7 +642,9 @@ const [green] = await db.Category().create({ name: 'Зелёный чай' }).Pa
|
|
|
636
642
|
const [kettle] = await db.Shop(shop).Product().create({ sku: 'K-1', title: 'Чайник', price: 2500 }).rows()
|
|
637
643
|
const [sencha] = await db.Shop(shop).Product()
|
|
638
644
|
.create({ sku: 'T-7', title: 'Сенча', price: 900 }).Category.add(green).rows()
|
|
645
|
+
await db.Shop(shop).Product().create({ sku: 'M-1', title: 'Кружка', price: 400 }).rows()
|
|
639
646
|
const [olga] = await db.Customer().create({ name: 'Ольга', email: 'olga@mail.ru' }).rows()
|
|
647
|
+
await db.Vip().create({ name: 'Вера', email: 'vera@mail.ru', discount: 10 }).rows() // Vip — потомок Customer
|
|
640
648
|
|
|
641
649
|
try {
|
|
642
650
|
await db.Shop().create({ name: 'Ромашка' }).rows()
|
|
@@ -651,7 +659,7 @@ try {
|
|
|
651
659
|
- Теги новой строки задаёт `.tags([…])` перед глаголом, владельца — `.owner(id)`; явного владельца должны разрешать
|
|
652
660
|
права.
|
|
653
661
|
- Удалённый объект можно создать снова тем же ключом: история продолжится.
|
|
654
|
-
- Ответ — строки созданных объектов со всеми колонками ([раздел
|
|
662
|
+
- Ответ — строки созданных объектов со всеми колонками ([раздел 14](#14-справочник-api), «Строка ответа»).
|
|
655
663
|
|
|
656
664
|
### Изменить: `update`
|
|
657
665
|
|
|
@@ -665,19 +673,22 @@ await db.Shop(shop).Product({ price: gte(2000) }).update({ title: 'Чайник
|
|
|
665
673
|
|
|
666
674
|
`update` правит каждую цель шага и никогда не создаёт. Патч сливается с данными внутри оператора: вложенные
|
|
667
675
|
объекты — рекурсивно, а массивы, скаляры и `null` заменяются. Поэтому параллельные правки разных полей не теряются.
|
|
668
|
-
Если у шага нет своего фильтра, цель берётся из `data.id`
|
|
669
|
-
`
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
676
|
+
Если у шага нет своего фильтра, цель берётся из `data.id` патча. Цели сужают фильтр, id, `tags()`, `owner()` или
|
|
677
|
+
`limit()` у шага глагола или шагов до него (или `create()` и `upsert()` раньше в цепочке; пустой `tags([])` не
|
|
678
|
+
сужает); без них — `invalid_query` до записи
|
|
679
|
+
([«Строгий режим»](#строгий-режим)). Править все строки шага — явно: `db.Product().update({ views: 0 }, { all: true })`
|
|
680
|
+
правит все товары арендатора. Шаг ничего не нашёл — ответ пустой и ошибки нет, поэтому проверяйте длину ответа.
|
|
681
|
+
Если `update` затронул меньше строк, чем нашёл шаг, весь план откатывается: строку успели удалить —
|
|
682
|
+
`target_not_found`, строка видна, но править её нельзя — `acl_denied`. Модификатор `.tags(…)` перед `update`
|
|
683
|
+
отбирает строки; строка или список ещё и заменяют их теги целиком, а оператор (`has`, `hasAny`, `hasAll`) только
|
|
684
|
+
отбирает — теги не меняются.
|
|
674
685
|
|
|
675
686
|
### Создать или заменить: `upsert`
|
|
676
687
|
|
|
677
688
|
<!-- run: guide -->
|
|
678
689
|
```ts
|
|
679
690
|
const [kept] = await db.Shop().upsert({ name: 'Ромашка', city: 'Казань' }).rows() // kept.$upsert: 'unchanged'
|
|
680
|
-
const [
|
|
691
|
+
const [replaced] = await db.Shop().upsert({ name: 'Ромашка', city: 'Уфа' }).rows() // replaced.$upsert: 'updated'
|
|
681
692
|
```
|
|
682
693
|
|
|
683
694
|
`upsert` создаёт объект, а если объект с этим ключом уже есть — заменяет его данные, концы и теги целиком. Это
|
|
@@ -725,12 +736,8 @@ await db.Product(kettle).update({ views: inc(1, { start: 0 }) }).rows() // п
|
|
|
725
736
|
- маркер `inc` нельзя получить из JSON: `{ "$inc": 5 }`, пришедший из сети, остаётся данными;
|
|
726
737
|
- деньги храните целым числом мелких единиц (копеек) не больше 2^53 − 1.
|
|
727
738
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
одна: операции одного объекта идут друг за другом под блокировкой его строки, и каждая пишет его версию. На машине
|
|
731
|
-
замеров один объект так принимал 150–190 операций в секунду, сколько бы писателей его ни ждало
|
|
732
|
-
([раздел 17](#17-производительность)). Для более горячих счётчиков берите проектор со свёрткой пачкой
|
|
733
|
-
([«Проекторы»](#проекторы)): итог отстаёт от источника, зато писатели не ждут друг друга.
|
|
739
|
+
Операции `inc` одного объекта идут друг за другом под блокировкой его строки, и каждая пишет его версию: писатели
|
|
740
|
+
одного горячего счётчика ждут друг друга.
|
|
734
741
|
|
|
735
742
|
### Защита от параллельной правки: `{ rev }`
|
|
736
743
|
|
|
@@ -745,44 +752,16 @@ try {
|
|
|
745
752
|
}
|
|
746
753
|
```
|
|
747
754
|
|
|
748
|
-
|
|
749
|
-
`conflict`, и ничего не записывается.
|
|
755
|
+
Правка с `{ rev }` нужна, когда новое значение вычислено из прочитанного: если строку успели изменить, приходит
|
|
756
|
+
`conflict`, и ничего не записывается. `{ rev }` работает при одной цели шага (иначе `rev_ambiguous`). `conflict`
|
|
750
757
|
библиотека сама не повторяет: что делать с изменившимися данными, решает приложение. Другой способ — чтение с
|
|
751
758
|
блокировкой `forUpdate()` ([раздел 8](#8-транзакции-батчи-и-деньги)).
|
|
752
759
|
|
|
753
|
-
###
|
|
754
|
-
|
|
755
|
-
<!-- run: guide -->
|
|
756
|
-
```ts
|
|
757
|
-
const [mug] = await db.Shop(shop).Product().create({ sku: 'M-1', title: 'Кружка', price: 400 }).rows()
|
|
758
|
-
const [mug2] = await db.Product(mug).rekey({ sku: 'M-100' }).rows() // новый ключ — новый id, ссылки переведены
|
|
759
|
-
const [vip] = await db.Customer(olga).reclass('Vip', { name: 'Ольга', email: 'olga@mail.ru', discount: 10 }).rows()
|
|
760
|
-
```
|
|
761
|
-
|
|
762
|
-
- `rekey(патч)` — новое значение ключа: строка с новым id, все ссылки переведены, старая строка удалена. Если
|
|
763
|
-
ссылающаяся строка включает этот конец в свой ключ, её id тоже меняется, и перевод идёт дальше. Журнал связывает
|
|
764
|
-
старый и новый id (`moved`).
|
|
765
|
-
- `db.rekeyClass(класс, ключ)` — новый ключ для всех строк семейства, одной транзакцией. У производного класса и
|
|
766
|
-
у класса с производным потомком — `derived_class`: строки производного класса пишет только проектор.
|
|
767
|
-
- `reclass(класс, данные)` — смена класса; данные необязательны, а если переданы — задаются целиком. Строка уходит из
|
|
768
|
-
прежнего класса, поэтому нужно право `DELETE` на неё, `WRITE` — на новый класс и на каждую строку, чьи ссылки
|
|
769
|
-
переписывает перевод, на любой глубине. Если у нового класса нет ключа, id сохраняется (как у `vip` выше); если
|
|
770
|
-
есть — id вычисляется заново, а ссылки переводятся.
|
|
771
|
-
Роли ссылающихся строк должны принимать новый класс, иначе `reclass_denied`. Сырой `UPDATE … SET class`
|
|
772
|
-
проходит, только если ключа нет ни у старого, ни у нового класса (иначе `use_reclass`).
|
|
773
|
-
- Удаление, смена класса и смена ключа работают только в `read committed`: транзакции библиотеки всегда открываются
|
|
774
|
-
на этом уровне.
|
|
775
|
-
|
|
776
|
-
### Обезличить: `anonymize`
|
|
777
|
-
|
|
778
|
-
<!-- run: guide -->
|
|
779
|
-
```ts
|
|
780
|
-
await db.Customer(olga).anonymize(['email']).rows() // email → '[erased]', тег anonymized
|
|
781
|
-
```
|
|
760
|
+
### Ключ и класс неизменны
|
|
782
761
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
762
|
+
Ключевые поля, класс, id, арендатор и владелец строки правкой не меняются — `immutable_key`, в том числе у сырого
|
|
763
|
+
`UPDATE … SET class`. Объект с другим ключом или классом — новый объект: создайте его (`create`), переведите на него
|
|
764
|
+
ссылки правкой ссылающихся строк и удалите прежний.
|
|
786
765
|
|
|
787
766
|
### Как исполняется план
|
|
788
767
|
|
|
@@ -803,10 +782,11 @@ await db.Order(order).line().create({ qty: 1, price: 900 }).Product.set(sencha).
|
|
|
803
782
|
(`40001`) и конфликте журнала библиотека повторяет план целиком, до трёх раз.
|
|
804
783
|
- Ошибка любого звена откатывает весь план. `exists` и `conflict` не повторяются: это ответы для приложения.
|
|
805
784
|
- Модификаторы до глагола (`limit`, `offset`, `sort`) сужают его цели.
|
|
806
|
-
- Модификатор после глагола относится к тому, что идёт за ним: к шагу класса, к `versions()
|
|
807
|
-
|
|
808
|
-
сужать уже нечего — глагол прошёл по всем целям шага, — поэтому
|
|
809
|
-
`invalid_query` и ничего не пишет. В батче так же
|
|
785
|
+
- Модификатор после глагола относится к тому, что идёт за ним: к шагу класса, к `versions()` и агрегатам или к
|
|
786
|
+
целям следующего глагола. Если за ним сразу терминал `rows`, `first`, `ids`, `count` или `paths`,
|
|
787
|
+
сужать уже нечего — глагол прошёл по всем целям шага, — поэтому
|
|
788
|
+
`db.Shop(shop).Product().update(…).limit(1).rows()` даёт `invalid_query` и ничего не пишет. В батче так же
|
|
789
|
+
отказывает `run()`.
|
|
810
790
|
- Глагол сразу после глагола пишет в те же сущности.
|
|
811
791
|
- Строки ответа глагола идут в порядке его целей: по `sort()` до глагола, без него — по id; `first()` после глагола —
|
|
812
792
|
первая по этому порядку.
|
|
@@ -848,15 +828,30 @@ const ofShop = await db.Product({ Shop: shop.id }).rows()
|
|
|
848
828
|
const roots = await db.Category({ Parent: null }).rows() // корневые категории
|
|
849
829
|
```
|
|
850
830
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
|
855
|
-
|
|
856
|
-
| `
|
|
857
|
-
| `
|
|
858
|
-
| `
|
|
859
|
-
| `
|
|
831
|
+
Операторы и их ключи в JSON-фильтре MCP (инструмент `letopis_query`, [раздел 13](#агенты-и-mcp)):
|
|
832
|
+
|
|
833
|
+
<!-- ops -->
|
|
834
|
+
| TypeScript | MCP | Смысл |
|
|
835
|
+
|---|---|---|
|
|
836
|
+
| `ne(v)` | `{ "$ne": v }` | Не равно. Подходят и строки без поля или с `null`; `ne(null)` — строки, где у поля есть значение |
|
|
837
|
+
| `gt(v)` | `{ "$gt": v }` | Больше. Сравнение — по типу поля из описания класса (числа — как числа, даты — как даты), поле без описания — как текст; строки без поля не подходят |
|
|
838
|
+
| `gte(v)` | `{ "$gte": v }` | Больше или равно; сравнение — как у `gt` |
|
|
839
|
+
| `lt(v)` | `{ "$lt": v }` | Меньше; сравнение — как у `gt` |
|
|
840
|
+
| `lte(v)` | `{ "$lte": v }` | Меньше или равно; сравнение — как у `gt` |
|
|
841
|
+
| `between(a, b)` | `{ "$between": [a, b] }` | От `a` до `b` включительно; сравнение — как у `gt`. Если `a` больше `b`, не подходит ничего |
|
|
842
|
+
| `inList([v, …])` | `{ "$in": [v, …] }` | Одно из значений списка. Пустой список не находит ничего; `null` в списке не находит строк без значения — для них `isNull` |
|
|
843
|
+
| `like(s)` | `{ "$like": "s" }` | Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один; буквальные `%` и `_` — с обратной косой чертой |
|
|
844
|
+
| `ilike(s)` | `{ "$ilike": "s" }` | Как `like`, но без учёта регистра |
|
|
845
|
+
| `starts(s)` | `{ "$starts": "s" }` | Текст начинается с `s`, с учётом регистра. Это `like(s + '%')`: `%` и `_` внутри `s` не экранируются и работают как в шаблоне |
|
|
846
|
+
| `ends(s)` | `{ "$ends": "s" }` | Текст заканчивается на `s`, с учётом регистра. Это `like('%' + s)`: `%` и `_` внутри `s` не экранируются и работают как в шаблоне |
|
|
847
|
+
| `has(v)` | `{ "$has": v }` | Поле-массив содержит элемент `v`. Тип важен: `has(5)` найдёт число 5, но не строку `'5'` |
|
|
848
|
+
| `hasAny([v, …])` | `{ "$hasAny": [v, …] }` | Поле-массив содержит хотя бы один элемент списка. Находит только строковые элементы; пустой список не находит ничего |
|
|
849
|
+
| `hasAll([v, …])` | `{ "$hasAll": [v, …] }` | Поле-массив содержит все элементы списка, в любом порядке. Тип важен, как у `has`; пустой список подходит любой строке, даже без поля |
|
|
850
|
+
| `exists(b = true)` | `{ "$exists": b }` | Поле есть в данных (`true`, по умолчанию) или его нет (`false`); значение не важно — поле с `null` тоже есть |
|
|
851
|
+
| `isNull()` | `{ "$isNull": true }` | В поле `null` или поля нет совсем; отличить одно от другого помогает `exists` |
|
|
852
|
+
| `not(x)` | `{ "$not": x }` | Отрицание: `x` — оператор (`not(gte(1000))` — меньше 1000 или поля нет) или простое значение — тогда это «не равно», как `ne` |
|
|
853
|
+
| `or(f1, f2, …)` | `{ "$or": [f1, f2, …] }` | Хотя бы один из фильтров. Ставится вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и» |
|
|
854
|
+
<!-- /ops -->
|
|
860
855
|
|
|
861
856
|
Теги и владелец строки — модификаторы `.tags(…)` и `.owner(…)`.
|
|
862
857
|
|
|
@@ -873,8 +868,8 @@ const roots = await db.Category({ Parent: null }).rows() // корневы
|
|
|
873
868
|
| `sum`, `avg`, `min`, `max`, `countBy` | агрегаты по полю `'data.<путь>'`; на пустом множестве — `null`, у `countBy` — `{}` |
|
|
874
869
|
|
|
875
870
|
Строка ответа: `id`, `class`, `rev` (номер версии), `tenant`, `owner`, `links` (концы), `data` (поля), `tags`,
|
|
876
|
-
`at` (время версии), `author`, `op` (вид операции) и, если есть, `agent`, `reason
|
|
877
|
-
|
|
871
|
+
`at` (время версии), `author`, `op` (вид операции) и, если есть, `agent`, `reason`. Служебные поля начинаются с
|
|
872
|
+
`$`: `$deleted`, `$depth`, `$upsert`, `$action`.
|
|
878
873
|
|
|
879
874
|
### Сортировка и постраничный вывод
|
|
880
875
|
|
|
@@ -890,13 +885,19 @@ const page2 = await db.Product().sort('data.price', 'desc')
|
|
|
890
885
|
`sort(поле, 'asc' | 'desc')` — по `'at'`, `'rev'`, `'id'` или `'data.<путь>'`. `after(cursorOf(строка, поле))` —
|
|
891
886
|
постраничный вывод «после такой-то строки»; он требует `sort()`. Такой вывод устойчив к вставкам и удалениям между
|
|
892
887
|
страницами, но строка, у которой изменилось поле сортировки, может выпасть или повториться. Курсор — объект
|
|
893
|
-
`{ v, id }` (значение
|
|
888
|
+
`{ v, id, rev }` (значение поля, id и номер версии строки): он переживает JSON, и его можно отдать клиенту. Поле в `cursorOf` должно
|
|
894
889
|
совпадать с полем `sort`. Значение курсора проверяется по типу поля `sort` до запроса, неверное — `invalid_query`:
|
|
895
890
|
числовому полю нужно конечное число, `rev` — целое, логическому — `true` или `false`, текстовому — строка, uuid и
|
|
896
891
|
`id` — uuid, времени и `at` — строка ISO с поясом, дате — `ГГГГ-ММ-ДД`, полю без описания — строка, число или
|
|
897
892
|
`true`/`false`. `null` подходит только полям данных, `id` курсора — всегда uuid. Курсор того же типа, снятый
|
|
898
893
|
с другого поля, проверка не отличит. `limit` и `offset` тоже есть.
|
|
899
894
|
|
|
895
|
+
`after()` листает и `versions()`: курсор версии несёт её `rev`, поэтому версии одного объекта с равным полем
|
|
896
|
+
сортировки не теряются. В `paths()` курсор даёт `cursorOf(путь, поле)`: поле и id — последнего узла пути, а в `path`
|
|
897
|
+
— id всех узлов, поэтому не теряются пути к одному узлу. Курсор `cursorOf(путь.Узел, поле)` с одного узла пропустит
|
|
898
|
+
остальные пути к этому узлу. `after()` перед `count()` и агрегатами — `invalid_query`: они считают по всем объектам
|
|
899
|
+
шага, страницы у них нет.
|
|
900
|
+
|
|
900
901
|
Поле данных бывает `null` или отсутствует. Такие строки `sort` ставит, как `order by` в PostgreSQL: при
|
|
901
902
|
возрастании — в конце, при убывании — в начале, а `after()` считает `null` значением после всех прочих (по
|
|
902
903
|
возрастанию) или перед ними (по убыванию). Поэтому страницы проходят такие строки без пропусков и повторов, а
|
|
@@ -934,7 +935,8 @@ const byStatus = await db.Order().countBy('data.status') // { new: 1 }
|
|
|
934
935
|
const orderPaths = await db.Order(order).line().Product().paths() // [{ Order: Row, line: Row, Product: Row }, …]
|
|
935
936
|
```
|
|
936
937
|
|
|
937
|
-
`paths()` отдаёт узлы каждого варианта пути; ключ узла — имя шага или имя из `.alias(имя)`.
|
|
938
|
+
`paths()` отдаёт узлы каждого варианта пути; ключ узла — имя шага или имя из `.alias(имя)`. Ключи идут в порядке
|
|
939
|
+
шагов; если цепочка кончается возвратом к прежнему узлу, этот узел — последний. Повтор связи в
|
|
938
940
|
цепочке возвращает к её узлу: так из одной связи можно пройти в несколько сторон. `db.entity(строка | цепочка)`
|
|
939
941
|
вставляет готовый узел в путь.
|
|
940
942
|
|
|
@@ -957,7 +959,10 @@ try {
|
|
|
957
959
|
|
|
958
960
|
`delete()` без `{ confirm: true }` — только превью: всё замыкание каскада. В каждой строке превью `$action`:
|
|
959
961
|
`delete`, `unset` или `restrict`, и `$depth` — уровень каскада (0 — сама цель; у строк `unset` и `restrict` его
|
|
960
|
-
нет). С подтверждением удаляются все цели шага одним оператором, а каскад применяет правила концов.
|
|
962
|
+
нет). С подтверждением удаляются все цели шага одним оператором, а каскад применяет правила концов. Цели, как у
|
|
963
|
+
`update`, сужают фильтр, id, `tags()`, `owner()` или `limit()` у шага глагола или шагов до него (или `create()` и
|
|
964
|
+
`upsert()` раньше в цепочке), иначе `invalid_query` до записи;
|
|
965
|
+
удалить все строки шага — `delete({ confirm: true, all: true })`, превью — без условий. Итог —
|
|
961
966
|
надгробия по уровням каскада, внутри уровня по id (в таком порядке они и в журнале); цели классов без журнала
|
|
962
967
|
(`history: false`) — в конце. Превью не учитывает права: строка с `$action: 'delete'` может не удалиться у
|
|
963
968
|
пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить `acl.checkData(класс, 'DELETE')`.
|
|
@@ -982,23 +987,8 @@ await db.Order(order).line().withDeleted().restore().rows() // и его стр
|
|
|
982
987
|
`restore()` возвращает последний снимок удалённого объекта новой версией и перепроверяет его по текущему описанию
|
|
983
988
|
класса. Восстанавливаются только цели шага, а не весь каскад. Живой объект — `not_deleted`.
|
|
984
989
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
<!-- run: guide -->
|
|
988
|
-
```ts
|
|
989
|
-
const [temp] = await db.Customer().create({ name: 'Тест', email: 'test@example.com' }).rows()
|
|
990
|
-
await db.Customer(temp).delete({ confirm: true }).rows()
|
|
991
|
-
const before = await db.Customer(temp).purge().rows() // превью: $versions — сколько версий в журнале
|
|
992
|
-
await db.Customer(temp).purge({ confirm: true }).rows() // история стёрта; в журнале — запись purge
|
|
993
|
-
```
|
|
994
|
-
|
|
995
|
-
`purge` работает только для удалённых объектов (живой — `not_deleted`) и стирает их историю целиком; на месте
|
|
996
|
-
версии 1 остаётся запись `purge` с числом стёртых версий. История системных классов не стирается. Пока проектор,
|
|
997
|
-
чьи источники `from` включают класс объекта ([«Проекторы»](#проекторы)), не прочёл удаление, `purge` отказывает
|
|
998
|
-
(`projector_pending`): стёртое надгробие проектор не увидел бы, и вклад объекта остался бы в итогах. Удаление и
|
|
999
|
-
`purge` одной транзакцией в таком классе поэтому не проходят. Так же `db.trimHistory` отказывает, пока проектор не
|
|
1000
|
-
прочёл первую оставшуюся версию: прошлую версию для `before` он бы уже не нашёл. Удалить аккаунт — `db.auth.purgeAccount`
|
|
1001
|
-
([раздел 10](#10-пользователи-вход-и-права)).
|
|
990
|
+
Историю удалённого объекта журнал хранит: стереть версии одного объекта нельзя. Удаление аккаунта
|
|
991
|
+
(`db.auth.purgeAccount`, [раздел 10](#10-пользователи-вход-и-права)) стирает данные и журнал его арендатора.
|
|
1002
992
|
|
|
1003
993
|
## 8. Транзакции, батчи и деньги
|
|
1004
994
|
|
|
@@ -1140,73 +1130,16 @@ try {
|
|
|
1140
1130
|
- Перевод — две операции в одной `db.begin()`, кошельки правятся в порядке id: тогда взаимных блокировок нет.
|
|
1141
1131
|
- Нехватку средств отличают от других ошибок данных по `e.issues`: поле `balance`, правило `minimum`.
|
|
1142
1132
|
|
|
1143
|
-
### Рецепт: баланс агрегатом
|
|
1144
|
-
|
|
1145
|
-
Тот же кошелёк, только баланс держит сама база: `balance` — агрегат, сумма `amount` живых операций
|
|
1146
|
-
([«Агрегаты в базе»](#агрегаты-в-базе)). Операция — одна запись `Movement`, приращения `inc` в коде нет, и забыть его
|
|
1147
|
-
нельзя. Баланс остаётся верным, даже когда операции пишет сырой SQL или загрузчик, а удаляет каскад.
|
|
1148
|
-
|
|
1149
|
-
```ts
|
|
1150
|
-
const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0) }, {
|
|
1151
|
-
ends: { Customer }, key: ['number'],
|
|
1152
|
-
aggregate: { balance: { sum: 'amount', of: 'Movement', via: 'Wallet' } }, // баланс — сумма живых операций
|
|
1153
|
-
})
|
|
1154
|
-
const Movement = link('Movement', { opId: z.string(), amount: z.number().int() },
|
|
1155
|
-
{ ends: { Wallet }, key: ['Wallet', 'opId'] }) // ключ операции — идемпотентность
|
|
1156
|
-
|
|
1157
|
-
/** Операция: amount больше нуля — пополнение, меньше — списание. База пересчитает баланс в той же транзакции. */
|
|
1158
|
-
async function operate(db: Db, wallet: string, opId: string, amount: number) {
|
|
1159
|
-
try {
|
|
1160
|
-
await db.Wallet(wallet).Movement().create({ opId, amount }).rows()
|
|
1161
|
-
return 'done'
|
|
1162
|
-
} catch (e) {
|
|
1163
|
-
if (e instanceof LetopisError && e.code === 'exists') return 'repeat' // повтор после потери ответа
|
|
1164
|
-
throw e // invalid_data — баланс ушёл бы в минус (min(0))
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
/** Корректировка «баланс = target»: операция на разницу, кошелёк заблокирован до commit. */
|
|
1169
|
-
async function adjust(db: Db, wallet: string, opId: string, target: number) {
|
|
1170
|
-
const tx = await db.begin()
|
|
1171
|
-
try {
|
|
1172
|
-
const w = (await tx.Wallet(wallet).forUpdate().first())!
|
|
1173
|
-
await tx.Wallet(wallet).Movement().create({ opId, amount: target - (w.data.balance as number) }).rows()
|
|
1174
|
-
await tx.commit()
|
|
1175
|
-
return 'done'
|
|
1176
|
-
} catch (e) {
|
|
1177
|
-
await tx.rollback()
|
|
1178
|
-
if (e instanceof LetopisError && e.code === 'exists') return 'repeat' // корректировка с этим opId уже записана
|
|
1179
|
-
throw e
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
const [wallet] = await db.Customer(olga).Wallet().create({ number: 'W-1' }).rows() // balance — 0: его ставит база
|
|
1184
|
-
await operate(db, wallet.id, 'op-1', 10_000) // баланс 10 000
|
|
1185
|
-
await operate(db, wallet.id, 'op-2', -3_000) // баланс 7 000
|
|
1186
|
-
await adjust(db, wallet.id, 'op-3', 5_000) // операция на −2 000, баланс 5 000
|
|
1187
|
-
```
|
|
1188
|
-
|
|
1189
|
-
- Модель здесь своя, и блок в программу примера не входит: там баланс растёт через `inc`, а агрегатное поле так не
|
|
1190
|
-
меняется.
|
|
1191
|
-
- `balance` не передают ни в `create`, ни в `update` (`aggregate_field`), поэтому корректировка — тоже операция, а не
|
|
1192
|
-
`update` под `{ rev }`. Чтобы записать операцию, право на запись в кошелёк не нужно: хватает права на запись в
|
|
1193
|
-
`Movement`.
|
|
1194
|
-
- Нехватка средств — `invalid_data`, как и с `inc`: в `e.issues` поле `balance` и правило `minimum`. Повтор сверяйте,
|
|
1195
|
-
как `same()` в рецепте выше: `exists` говорит только о том, что операция с этим `opId` уже есть.
|
|
1196
|
-
- Правка и удаление операции тоже меняют баланс: база вычтет прежнее значение. Отменять операцию всё равно лучше
|
|
1197
|
-
встречной операцией, тогда история кошелька остаётся полной.
|
|
1198
|
-
- Каждая операция блокирует строку кошелька и пишет его версию, поэтому операции одного кошелька идут друг за другом,
|
|
1199
|
-
как и с `inc`: на машине замеров — около 150 в секунду ([раздел 17](#17-производительность)). Если один объект
|
|
1200
|
-
получает больше, итог лучше считать проектором ([«Рецепт: дневная выручка»](#рецепт-дневная-выручка)).
|
|
1201
|
-
|
|
1202
1133
|
## 9. Массовая загрузка
|
|
1203
1134
|
|
|
1204
1135
|
### Когда нужен загрузчик
|
|
1205
1136
|
|
|
1206
1137
|
Загрузчик — путь для импорта, засевки и синхронизации больших справочников. Он проверяет строки в коде библиотеки
|
|
1207
|
-
тем же валидатором, что и
|
|
1208
|
-
|
|
1209
|
-
|
|
1138
|
+
тем же валидатором, что и база (шаблоны `pattern` — запросом к базе на пачку), и пишет их командой `COPY` от роли
|
|
1139
|
+
владельца, без триггеров и RLS. На замере `COPY` без триггеров писал около 14 000 строк в секунду, а
|
|
1140
|
+
построчная вставка через триггеры — около 170 ([раздел 16](#16-производительность)); проверки строк в коде библиотеки
|
|
1141
|
+
к этому добавляются. Работает только на администраторском подключении (`allowBypassRls` и право стать владельцем),
|
|
1142
|
+
иначе — `admin_required`.
|
|
1210
1143
|
|
|
1211
1144
|
### `db.load()`
|
|
1212
1145
|
|
|
@@ -1243,15 +1176,14 @@ const changed = await adm.load({ Shop: [{ name: 'Василёк', city: 'Сам
|
|
|
1243
1176
|
| Опция | Значения |
|
|
1244
1177
|
|---|---|
|
|
1245
1178
|
| `duplicates` — повтор id внутри загрузки | `'error'` (по умолчанию) — отменить; `'first'` или `'last'` — оставить первую или последнюю строку |
|
|
1246
|
-
| `existing` — объект уже есть в базе | `'error'` (по умолчанию) — отменить; `'skip'` — пропустить, удалённые не воскрешать; `'update'` — новая версия, если содержимое отличается (неизменённые считаются в `skipped`), удалённые — создать
|
|
1179
|
+
| `existing` — объект уже есть в базе | `'error'` (по умолчанию) — отменить; `'skip'` — пропустить, удалённые не воскрешать; `'update'` — новая версия, если содержимое отличается (неизменённые считаются в `skipped`), удалённые — создать заново; объект другого класса с тем же id — `exists` |
|
|
1247
1180
|
|
|
1248
|
-
###
|
|
1181
|
+
### Порции и история
|
|
1249
1182
|
|
|
1250
1183
|
- `commitEvery: N` — фиксировать порциями примерно по N строк. Порции идут по классам в порядке зависимостей,
|
|
1251
1184
|
классы из одного цикла — в одной порции. Ошибка отменяет только текущую порцию, а повтор с `existing: 'skip'`
|
|
1252
1185
|
дописывает остальное.
|
|
1253
|
-
- `withHistory: true` — строки — это версии с `$rev`, `$at` и `$op`; так
|
|
1254
|
-
- `verify: true` — в конце перепроверить загруженные строки проверкой базы.
|
|
1186
|
+
- `withHistory: true` — строки — это версии с `$rev`, `$at` и `$op`; так переносят историю из другой системы.
|
|
1255
1187
|
- `batch` (строк в пачке `COPY`, 10 000), `class` (класс строк без `$class`), `tenant`, `agent` (агент версий).
|
|
1256
1188
|
|
|
1257
1189
|
### Загрузка от имени пользователя
|
|
@@ -1273,20 +1205,18 @@ const r = await adm.load(rows, { initial: true }) // пустая схема:
|
|
|
1273
1205
|
```
|
|
1274
1206
|
|
|
1275
1207
|
`db.load(rows, { initial: true })` (`letopis load --initial`) заливает данные в пустую схему быстрее обычного
|
|
1276
|
-
загрузчика — на миллионе объектов примерно в 2,2
|
|
1208
|
+
загрузчика — на миллионе объектов примерно в 2,2 раза (замер `lib/bench/initial-load.mjs`): всё идёт одной транзакцией, на время загрузки таблицы закрыты
|
|
1277
1209
|
для чтения и записи, индексы, ограничения и триггеры сняты и строятся заново в конце, строки пишутся `COPY … FREEZE`.
|
|
1278
|
-
Пустая — значит, кроме строк системных классов (описания классов, аккаунты, членства, ресурсы, правила)
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
`letopis import --initial` проводит так весь импорт: одной транзакцией, без `--update` и `--commit-every`. WAL пишется
|
|
1282
|
-
и здесь (без него — только при `wal_level = minimal`), но индексы строятся один раз, и его почти втрое меньше; после
|
|
1210
|
+
Пустая — значит, кроме строк системных классов (описания классов, аккаунты, членства, ресурсы, правила), в схеме
|
|
1211
|
+
ничего нет; иначе — `not_empty` до любых изменений. Режим несовместим с `commitEvery`, `existing` (кроме `'error'`) и
|
|
1212
|
+
`{ as }`; ошибка любой строки откатывает всё, вместе с индексами и триггерами. WAL пишется и здесь (без него — только при `wal_level = minimal`), но индексы строятся один раз, и его почти втрое меньше; после
|
|
1283
1213
|
`FREEZE` первое чтение и первая очистка таблицу не переписывают.
|
|
1284
1214
|
|
|
1285
1215
|
### Командная строка
|
|
1286
1216
|
|
|
1287
1217
|
```sh
|
|
1288
1218
|
npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --token "$TOKEN" \
|
|
1289
|
-
[--class Product] [--duplicates first] [--existing skip] [--commit-every 100000]
|
|
1219
|
+
[--class Product] [--duplicates first] [--existing skip] [--commit-every 100000]
|
|
1290
1220
|
# shops.ndjson: {"$class":"Shop","name":"Юг"}
|
|
1291
1221
|
# products.ndjson: {"$class":"Product","sku":"Y-1","title":"Ваза","price":800,"Shop":{"$key":["Юг"]}}
|
|
1292
1222
|
```
|
|
@@ -1301,8 +1231,7 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
|
|
|
1301
1231
|
`issues`), `total` — сколько нарушений найдено, `truncated` — список обрезан, `limit` — предел списка (100),
|
|
1302
1232
|
`complete` — проверки, нашедшие нарушения, прошли все строки. Коды строк: коды проверок базы, а также `duplicate`
|
|
1303
1233
|
(повтор id), `exists` (объект уже есть), `deleted` (объект с этим id жил раньше) и `class_not_loadable` (строки
|
|
1304
|
-
`Class` загрузчик не принимает: классы задаёт `syncModels`).
|
|
1305
|
-
отклоняет с кодом `derived_class`: такие строки пишет только проектор.
|
|
1234
|
+
`Class` загрузчик не принимает: классы задаёт `syncModels`).
|
|
1306
1235
|
|
|
1307
1236
|
Нарушения ищутся поэтапно: разбор строк (классы, id, повторы), права на классы (`load({ as })`), данные и концы
|
|
1308
1237
|
пачками, в конце порции — цели вне загрузки, владельцы и арендатор. Загрузка отменяется после первого этапа,
|
|
@@ -1310,7 +1239,7 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
|
|
|
1310
1239
|
первая, что на неё ссылается). «(и ещё N)» в тексте ошибки — точное число остальных нарушений. Если проверены не все
|
|
1311
1240
|
строки, `complete` — `false`, а текст говорит «(и ещё не меньше N: причины)» или, если найдено одно нарушение,
|
|
1312
1241
|
«(возможно, не единственная: причины)». Причины: следующие порции `commitEvery` не проверялись; условия ACL
|
|
1313
|
-
следующих пачек не проверялись (`load({ as })`); проверка базы (
|
|
1242
|
+
следующих пачек не проверялись (`load({ as })`); проверка базы (шаблоны `pattern`) называет не больше 100 строк.
|
|
1314
1243
|
|
|
1315
1244
|
## 10. Пользователи, вход и права
|
|
1316
1245
|
|
|
@@ -1333,6 +1262,15 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
|
|
|
1333
1262
|
ключи других сервисов, System и системных администраторов выпускает и отзывает только системный администратор;
|
|
1334
1263
|
новый ключ сервиса регистрирует и `up({ serviceKey })`.
|
|
1335
1264
|
|
|
1265
|
+
Ключ установки `up()` выпускает сам: `lts_` и 64 шестнадцатеричных знака в нижнем регистре. Отдаёт он его в
|
|
1266
|
+
`r.serviceKey` один раз — при первой установке или на накате, когда в схеме не осталось ни одного неотозванного ключа
|
|
1267
|
+
API; иначе `r.serviceKey` — `null`. База хранит только sha256 ключа, поэтому показать его снова нельзя: сохраните ключ
|
|
1268
|
+
в хранилище секретов. Чтобы ключ был известен заранее (например, из переменной окружения), передайте свой:
|
|
1269
|
+
`up({ serviceKey: process.env.LETOPIS_SERVICE_KEY })`. Формат — `lts_` и 32–128 шестнадцатеричных знаков в нижнем
|
|
1270
|
+
регистре, `/^lts_[0-9a-f]{32,128}$/`, иначе `invalid_data`; подходит `'lts_' + randomBytes(32).toString('hex')` из
|
|
1271
|
+
`node:crypto`. Если такого неотозванного ключа ещё нет, `up()` регистрирует его за сервисом установки и возвращает в
|
|
1272
|
+
`r.serviceKey`, иначе там `null`. Сервис входит по ключу: `connect({ dsn: APP, schema, apiKey })`.
|
|
1273
|
+
|
|
1336
1274
|
Ключей может быть несколько. Сессия, открытая ключом, помнит его, и отзыв ключа гасит только его сессии: подключения
|
|
1337
1275
|
по другим ключам работают дальше. Поэтому ротация идёт без простоя: сервис выпускает себе новый ключ
|
|
1338
1276
|
(`issueApiKey({ account: <id сервиса> })`), переводит процессы на него и отзывает старый (`revokeCredential(id)`).
|
|
@@ -1404,7 +1342,7 @@ const viaTelegram = await svc.auth.lookup({ kind: 'TELEGRAM', identifier: '10050
|
|
|
1404
1342
|
сессия от сервиса не в счёт) и не подтверждает их сам. При включённом TOTP база требует сессию с пройденным TOTP:
|
|
1405
1343
|
её выдают `verifyPassword` и `lookup` с опцией `totp`. Если идентификатор занят, привязка отвечает тем же
|
|
1406
1344
|
`acl_denied`, что и без недавнего входа. Но пользователь с недавним входом по такому отказу поймёт, что
|
|
1407
|
-
идентификатор занят ([раздел
|
|
1345
|
+
идентификатор занят ([раздел 17](#17-ограничения)).
|
|
1408
1346
|
|
|
1409
1347
|
### Сессии
|
|
1410
1348
|
|
|
@@ -1427,6 +1365,16 @@ await boris.close()
|
|
|
1427
1365
|
старые сессии того же вида входа без отметки ключа); текущая сессия остаётся, если не передать
|
|
1428
1366
|
`keepCurrent: false`.
|
|
1429
1367
|
|
|
1368
|
+
Подключение с постоянным токеном (`connect({ token })`), которым не пользовались дольше срока сессии, остаётся без
|
|
1369
|
+
сессии: истёкшую сессию продление не возвращает, а новый токен подключение само не берёт. После простоя дольше минуты
|
|
1370
|
+
подключение до операции продлевает сессию и узнаёт, жива ли она: истёкшая — `no_session` на любую операцию, и
|
|
1371
|
+
чтение не идёт как анонимное с пустым ответом (с `refreshSession: false` проверки нет). Токен выдают заново —
|
|
1372
|
+
повтором `createTenant()` (для существующих арендатора и пользователя повтор только выдаёт новую сессию) или
|
|
1373
|
+
`svc.auth.sessionFor()` — и открывают с ним новое подключение. Подключение по ключу (`connect({ apiKey })`, ключ
|
|
1374
|
+
сервиса) так не застревает: истёкшую сессию оно заменяет само — обменивает ключ заново, с тем же рабочим арендатором,
|
|
1375
|
+
что выбрал `auth.switch()`, и повторяет операцию. Процессу, которому сессия пользователя нужна для редких операций,
|
|
1376
|
+
проще брать её на одну операцию: `sessionFor()`, подключение с её токеном, работа, `revoke()` и `close()`.
|
|
1377
|
+
|
|
1430
1378
|
Сервис, у которого есть токен пользователя (например, в cookie), узнаёт о сессии через `svc.auth.session(token)`:
|
|
1431
1379
|
подключение по этому токену не открывается, срок сессии не продлевается. Ответ — чья сессия (`account`), в каком
|
|
1432
1380
|
арендаторе она работает (`tenant`: выбранный `auth.switch()` или собственный арендатор аккаунта), цело ли там членство
|
|
@@ -1442,7 +1390,8 @@ await boris.close()
|
|
|
1442
1390
|
`auth.impersonate` (по правилам System), что пользователь существует и включён и что арендатор ему доступен —
|
|
1443
1391
|
собственный или по членству. От имени System, сервисов, системных администраторов и отключённых аккаунтов
|
|
1444
1392
|
действовать нельзя. Видимость пользователя по правилам доступа не проверяется. В журнале `author` — пользователь,
|
|
1445
|
-
`agent` — сервис.
|
|
1393
|
+
`agent` — сервис. Реестр классов `db.as()` — один на арендатора, подключение держит их не больше 1000: давно не
|
|
1394
|
+
нужный вытесняется, а фасад, чей реестр вытеснен, продолжает работать — реестр перечитывается. Подключение
|
|
1446
1395
|
пользователя по своему токену переключается между арендаторами так:
|
|
1447
1396
|
|
|
1448
1397
|
<!-- run: guide -->
|
|
@@ -1451,6 +1400,10 @@ const mine = await anna.auth.tenants() // [{ tenant, roles }]: собств
|
|
|
1451
1400
|
await anna.auth.switch(SHOP) // арендатор сессии — магазин
|
|
1452
1401
|
```
|
|
1453
1402
|
|
|
1403
|
+
`auth.tenants()` отдаёт и собственный арендатор пользователя: первым элементом, с `tenant`, равным id его аккаунта, и
|
|
1404
|
+
`roles: ['owner']`. За ним идут арендаторы по членству с ролями членства. Нужны только арендаторы по членству —
|
|
1405
|
+
отбросьте элемент с id аккаунта (`login.account` у `verifyPassword`, `user` у `createTenant`).
|
|
1406
|
+
|
|
1454
1407
|
### Права
|
|
1455
1408
|
|
|
1456
1409
|
Права включены всегда и по умолчанию запрещают. Правило связывает группу с объектом:
|
|
@@ -1469,28 +1422,21 @@ await anna.auth.switch(SHOP) // арендатор сессии
|
|
|
1469
1422
|
видны, правка — пусто или `acl_denied`.
|
|
1470
1423
|
|
|
1471
1424
|
Маска класса в объекте `READ`, `WRITE` или `DELETE` совпадает и с потомками класса — по именам предков и их алиасам.
|
|
1472
|
-
Поэтому правка класса, которая меняет `parent
|
|
1425
|
+
Поэтому правка класса, которая меняет `parent` или `alias`, требует права `READ`, `WRITE` и `DELETE` без
|
|
1473
1426
|
условий на все строки класса и каждого его потомка, иначе `acl_denied`: одного права `WRITE` на `Class` мало, чтобы
|
|
1474
1427
|
подставить строки класса под чужие правила. Алиас не совпадает с именем другого класса (в том числе `Class` и
|
|
1475
1428
|
системных) и с чужим алиасом арендатора и System, а имя нового класса — с чужим алиасом: иначе правило, написанное
|
|
1476
1429
|
для одного класса, действовало бы и на строки другого. Нарушение — `invalid_class`.
|
|
1477
1430
|
|
|
1478
1431
|
По тем же именам правила находят и старые версии строк в журнале. Поэтому класс удаляется, только когда у него нет
|
|
1479
|
-
строк, потомков и истории
|
|
1480
|
-
строки, потомки и история арендатора, у которого есть свой класс с тем
|
|
1481
|
-
удалённый с историей раньше, создаётся заново только с прежними
|
|
1482
|
-
`invalid_class`: старые версии его строк попали бы под чужие
|
|
1483
|
-
версии и по алиасам предков. Поэтому, пока удалённые потомки класса хранят историю строк, класс не
|
|
1484
|
-
(`invalid_class`), а удалённым создаётся заново только с прежним описанием. Если журнал хранит строки
|
|
1485
|
-
именем, но не его надгробие (например,
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
Производный класс (`derivedBy`) заводит, меняет и удаляет только администратор: администраторское подключение или
|
|
1489
|
-
аккаунт с правом System `letopis.projector` (у системного администратора оно есть по маске `letopis.*`). Проектор
|
|
1490
|
-
пишет в такой класс итоги по всем строкам источников арендатора, без прав чтения, поэтому пользователь, заведя его
|
|
1491
|
-
сам, читал бы итоги по строкам, которые ему читать запрещено. Класс, у которого есть производные потомки, тоже
|
|
1492
|
-
правит только администратор: правка предка меняет описание потомка (поля, ключ, `history`). Пометку `aggregatedBy`
|
|
1493
|
-
(класс стал источником агрегата) база ставит и производному классу сама: полей и ключа она не меняет.
|
|
1432
|
+
строк, потомков и истории строк; историю строк не стереть, и класс, который придётся удалять, заводят с
|
|
1433
|
+
`history: false`. Для класса System не в счёт строки, потомки и история арендатора, у которого есть свой класс с тем
|
|
1434
|
+
же именем: они относятся к его классу. Класс, удалённый с историей раньше, создаётся заново только с прежними
|
|
1435
|
+
`parent`, `alias` и `history` — иначе `invalid_class`: старые версии его строк попали бы под чужие правила. Правила
|
|
1436
|
+
находят старые версии и по алиасам предков. Поэтому, пока удалённые потомки класса хранят историю строк, класс не
|
|
1437
|
+
меняет алиас (`invalid_class`), а удалённым создаётся заново только с прежним описанием. Если журнал хранит строки
|
|
1438
|
+
класса с этим именем, но не его надгробие (например, строку класса удалили мимо триггеров от роли владельца), класс с этим именем не
|
|
1439
|
+
создаётся.
|
|
1494
1440
|
|
|
1495
1441
|
### Выдать право
|
|
1496
1442
|
|
|
@@ -1502,13 +1448,10 @@ const pg2 = postgres(ADMIN)
|
|
|
1502
1448
|
const OLEG = (await createTenant(pg2, r.schema, { // тот же магазин: создаётся только пользователь
|
|
1503
1449
|
name: 'Ромашка', user: { name: 'Олег', login: 'oleg@shop.ru', password: 'Олег-2026!' } })).user!
|
|
1504
1450
|
await pg2.end()
|
|
1505
|
-
await db.
|
|
1451
|
+
await db.acl.roles(OLEG, ['staff']) // роли Олега в текущем арендаторе (строка member)
|
|
1506
1452
|
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
await db.rule().create({ permission: 'deny', weight: 50 }).Group.set(staff).Object.set(delProduct).rows() // сильнее «можно всё» (10)
|
|
1510
|
-
const [reports] = await db.Resource().create({ alias: 'reports:API', category: 'API', pattern: { endpoint: 'shop.reports.*' } }).rows()
|
|
1511
|
-
await db.rule().create({ permission: 'allow', weight: 50 }).Group.set(staff).Object.set(reports).rows()
|
|
1453
|
+
await db.acl.deny({ role: 'staff', delete: 'Product' }) // вес 50 — сильнее «можно всё» (10)
|
|
1454
|
+
await db.acl.grant({ role: 'staff', api: 'shop.reports.*' })
|
|
1512
1455
|
|
|
1513
1456
|
const asOleg = await svc.as(OLEG, { tenant: SHOP })
|
|
1514
1457
|
const canDelete = await asOleg.acl.checkData('Product', 'DELETE') // { allow: false }
|
|
@@ -1516,10 +1459,28 @@ const canRead = await asOleg.acl.checkData('Product', 'READ') // { allow:
|
|
|
1516
1459
|
const canReport = await asOleg.acl.check('shop.reports.daily') // { allow: true, rule: { weight: 50, … } }
|
|
1517
1460
|
```
|
|
1518
1461
|
|
|
1462
|
+
- `db.acl.grant` и `db.acl.deny` принимают, кому правило, — роль (`role`) или все аккаунты арендатора, кроме анонимных
|
|
1463
|
+
и теневых (`everyone: true`; ровно одно из двух), — и цели: `read`, `write`, `delete` — класс (с потомками), `'*'` —
|
|
1464
|
+
все классы или класс с условием на строки (`{ class: 'Ticket', owner: '$actor' }`); `api` — маска эндпоинта;
|
|
1465
|
+
списком — несколько. На каждую операцию и цель — одно правило с весом `weight` (по умолчанию 50, от 1 до 999). Под
|
|
1466
|
+
капотом — те же строки `Resource` (`role:staff:ACCOUNT`, `everyone:ACCOUNT`, `class:Product:DELETE`,
|
|
1467
|
+
`api:shop.reports.*:API`) и `rule` текущего
|
|
1468
|
+
арендатора, ключ правила — пара группа и объект: повтор ничего не меняет, а `grant` после `deny` той же роли и цели
|
|
1469
|
+
заменяет запрет разрешением. Внутри `db.begin()` — в её транзакции. Писать правила может тот, у кого есть `WRITE` на
|
|
1470
|
+
`Resource` и `rule` (после `createTenant` — любой участник по правилу «можно всё»; закрыть — `deny` с
|
|
1471
|
+
`write: ['Resource', 'rule', 'member']`). Неверный вызов — `invalid_data` до записи.
|
|
1472
|
+
- `db.acl.revoke({ role, … })` и `db.acl.revoke({ everyone: true, … })` снимают правила роли или всех аккаунтов —
|
|
1473
|
+
разрешения и запреты — для этих целей и отвечают числом снятых; для всех аккаунтов — и «можно всё» от
|
|
1474
|
+
`createTenant` ([Права только по ролям](#права-только-по-ролям)).
|
|
1475
|
+
`db.acl.roles(аккаунт)` — роли в текущем арендаторе (`null` — не член), `db.acl.roles(аккаунт, ['staff'])` — заменить
|
|
1476
|
+
их (членство заводится), `db.acl.roles(аккаунт, null)` — снять членство. `db.acl.list()` — правила арендатора
|
|
1477
|
+
`{ id, role, everyone, group, op, target, condition, permission, weight, enabled }`, свои и «можно всё» от
|
|
1478
|
+
`createTenant` (`everyone: true`, `role: null`); `enabled: false` — правило выключено полем `enabled` строки `rule`,
|
|
1479
|
+
база его не применяет. На администраторском подключении — тоже только правила арендатора сессии.
|
|
1519
1480
|
- `acl.check(эндпоинт)` → `{ allow, rule, code, message }`: свой отказ эндпоинта задают поля `code` и `message`
|
|
1520
1481
|
запрещающего правила.
|
|
1521
1482
|
- `acl.checkData(класс, 'READ' | 'WRITE' | 'DELETE')` → `{ allow, filter }`: `filter` — условие на строки.
|
|
1522
|
-
- Права пересчитываются базой сами.
|
|
1483
|
+
- Права пересчитываются базой сами. Правила можно писать и цепочками `Resource` и `rule` напрямую — поля ниже.
|
|
1523
1484
|
- Библиотека один раз берёт у базы подписанный план прав актора — пропуск прав — и передаёт его в каждой
|
|
1524
1485
|
транзакции, поэтому проверка прав стоит десятки микросекунд. Сырой SQL без пропуска платит 0,3–1,3 мс на запрос.
|
|
1525
1486
|
- На вопрос «у кого был доступ на дату» отвечает срез `asOf` по классам `rule`, `member` и `Resource`.
|
|
@@ -1531,14 +1492,57 @@ const canReport = await asOleg.acl.check('shop.reports.daily') // { allow:
|
|
|
1531
1492
|
`READ`, `WRITE`, `DELETE`) и `pattern` — маска. У группы маска `categories` или `roles` — только строка: `'{staff}'`,
|
|
1532
1493
|
`'{a,b}'`, отрицание — `'!{a}'`. У объекта — `endpoint` или `class` (тоже только строки) и условия на колонки.
|
|
1533
1494
|
**Маску не строкой (например, массив `['staff']`, число или `null`) база отклоняет** с `invalid_data` — при записи
|
|
1534
|
-
библиотекой, сырым SQL и
|
|
1535
|
-
|
|
1536
|
-
классы (маска `endpoint` не
|
|
1537
|
-
описание класса `Resource`: оно приходит со свежей установкой и после `reset({ all })`; в существующей схеме
|
|
1538
|
-
исправленное сопоставление действует после повторного `up()`;
|
|
1495
|
+
библиотекой, сырым SQL и загрузчиком; проверку несёт описание класса `Resource`. Строка с такой маской, записанная
|
|
1496
|
+
в обход проверки (от роли владельца), разрешений не даёт, а запрет по ней действует на всех: по группе — на все
|
|
1497
|
+
аккаунты, по маске `class` — на все классы (маска `endpoint` не строкой не совпадает ни с одним эндпоинтом);
|
|
1539
1498
|
- `rule` — концы `Group` и `Object` (строки `Resource`), поля `permission` (`allow` или `deny`), `weight`, а для
|
|
1540
1499
|
своего отказа эндпоинта — `code` и `message`.
|
|
1541
1500
|
|
|
1501
|
+
### Права только по ролям
|
|
1502
|
+
|
|
1503
|
+
`createTenant` и `up({ tenant })` дают арендатору правило «аутентифицированным можно всё» (вес 10). Запреты ролей с
|
|
1504
|
+
весом больше сужают права, но всё, что запретами не закрыто, остаётся разрешено любому участнику. Чтобы права решали
|
|
1505
|
+
только роли, снимите это правило после настройки или не заводите его вовсе.
|
|
1506
|
+
|
|
1507
|
+
Снять после настройки. «Можно всё» — три правила `READ`, `WRITE` и `DELETE` для всех аккаунтов арендатора (группа
|
|
1508
|
+
System `authenticated:ACCOUNT`) на все классы; `db.acl.list()` показывает их с `everyone: true`, `role: null` и
|
|
1509
|
+
`target: '*'`, а снимает их `db.acl.revoke({ everyone: true, read: '*', write: '*', delete: '*' })`. Права ролям
|
|
1510
|
+
выдайте раньше, пока правило действует: после снятия у участника остаются только права его ролей. Если права и
|
|
1511
|
+
дальше правит приложение, дайте какой-то роли права и на строки прав (`Resource`, `rule`, `member`), иначе их запишет
|
|
1512
|
+
только администраторское подключение.
|
|
1513
|
+
|
|
1514
|
+
```ts
|
|
1515
|
+
await db.acl.grant({ role: 'manager', read: 'Product', write: 'Product', delete: 'Product' })
|
|
1516
|
+
await db.acl.grant({ role: 'staff', read: 'Product' })
|
|
1517
|
+
await db.acl.grant({ role: 'manager', read: ['Resource', 'rule', 'member'], // правит права и дальше
|
|
1518
|
+
write: ['Resource', 'rule', 'member'], delete: ['rule', 'member'] })
|
|
1519
|
+
await db.acl.revoke({ everyone: true, read: '*', write: '*', delete: '*' }) // «можно всё» снято: 3
|
|
1520
|
+
```
|
|
1521
|
+
|
|
1522
|
+
Вернуть его — `db.acl.grant({ everyone: true, read: '*', write: '*', delete: '*', weight: 10 })`: правила для всех
|
|
1523
|
+
аккаунтов обёртка пишет своей группой `everyone:ACCOUNT`. Так же — право всем аккаунтам на отдельный класс:
|
|
1524
|
+
`db.acl.grant({ everyone: true, read: 'Catalog' })`.
|
|
1525
|
+
|
|
1526
|
+
Не заводить. `createTenant(sql, schema, { name, grantAll: false, user })` и `up({ tenant: { name, grantAll: false, user
|
|
1527
|
+
} })` заводят арендатора без правил. У его участников, в том числе у первого пользователя, прав нет — ни на данные, ни
|
|
1528
|
+
на запись правил. Классы и правила такого арендатора пишет администраторское подключение с сессией его пользователя:
|
|
1529
|
+
RLS на нём не действует, а строки идут в арендатор сессии.
|
|
1530
|
+
|
|
1531
|
+
```ts
|
|
1532
|
+
const pg = postgres(ADMIN)
|
|
1533
|
+
const t = await createTenant(pg, r.schema, { name: 'Склад', grantAll: false,
|
|
1534
|
+
user: { name: 'Ирина', login: 'irina@stock.ru', password: 'Склад-2026!', roles: ['manager'] } })
|
|
1535
|
+
await pg.end()
|
|
1536
|
+
const adm = await connect({ dsn: ADMIN, schema: r.schema, token: t.token, allowBypassRls: true })
|
|
1537
|
+
await syncModels(adm, [Product], { apply: true })
|
|
1538
|
+
await adm.acl.grant({ role: 'manager', read: 'Product', write: 'Product', delete: 'Product' })
|
|
1539
|
+
await adm.close()
|
|
1540
|
+
```
|
|
1541
|
+
|
|
1542
|
+
Читать администраторское подключение может строки всех арендаторов, но `adm.acl.list()` и там показывает правила
|
|
1543
|
+
арендатора сессии. Видимость целей правила актору сессии база на таком подключении не проверяет (существование,
|
|
1544
|
+
арендатор и класс цели — проверяет), поэтому правило пишется и тогда, когда прав у актора ещё нет.
|
|
1545
|
+
|
|
1542
1546
|
### Удалить аккаунт
|
|
1543
1547
|
|
|
1544
1548
|
`db.auth.purgeAccount(аккаунт)` удаляет аккаунт и данные его арендатора. Вызывать может системный администратор или
|
|
@@ -1548,8 +1552,8 @@ const canReport = await asOleg.acl.check('shop.reports.daily') // { allow:
|
|
|
1548
1552
|
|
|
1549
1553
|
Системный администратор может удалить и саму строку аккаунта — `db.Account(id).delete({ confirm: true })` в
|
|
1550
1554
|
арендаторе System; данные арендатора аккаунта при этом остаются. Любой путь сразу стирает строки аккаунта в `secret`:
|
|
1551
|
-
сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (внешний ключ `secret.account` с
|
|
1552
|
-
|
|
1555
|
+
сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (внешний ключ `secret.account` с каскадом).
|
|
1556
|
+
Сессии гаснут до этого с сигналом, и процессы сбрасывают кэш ответов этих сессий. Аккаунт System
|
|
1553
1557
|
не удаляется (`acl_denied`): ему принадлежат служебные строки установки в `secret`.
|
|
1554
1558
|
|
|
1555
1559
|
## 11. История и срезы на дату
|
|
@@ -1559,18 +1563,20 @@ const canReport = await asOleg.acl.check('shop.reports.daily') // { allow:
|
|
|
1559
1563
|
<!-- run: guide -->
|
|
1560
1564
|
```ts
|
|
1561
1565
|
const versions = await db.Product(kettle).versions() // все версии: op, rev, author, at, data
|
|
1562
|
-
const followed = await db.Product(mug2).versions({ follow: true }) // и через смену id (rekey, reclass)
|
|
1563
1566
|
```
|
|
1564
1567
|
|
|
1565
|
-
У каждой версии — автор, агент, время, номер, вид операции (`create`, `update`, `delete`, `
|
|
1566
|
-
|
|
1568
|
+
У каждой версии — автор, агент, время, номер, вид операции (`create`, `update`, `delete`, `restore`) и причина
|
|
1569
|
+
(`reason`). Причину задаёт опция `reason` глагола (`update(patch, { reason })`, `create(data, { reason })`,
|
|
1567
1570
|
`delete({ confirm, reason })` и других), для всех операций блока — `db.begin({ reason })` и
|
|
1568
1571
|
`db.batch(имя, { reason })`; сырой SQL — настройка `letopis.reason` транзакции. Версия удаления — с `$deleted`.
|
|
1569
1572
|
|
|
1570
|
-
Без `sort()` версии идут по id и номеру
|
|
1573
|
+
Без `sort()` версии идут по id и номеру версии. `sort()` задаёт порядок и версиям, а
|
|
1571
1574
|
`limit()` ограничивает их в самом запросе: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` — пять
|
|
1572
1575
|
последних версий, новые первыми.
|
|
1573
1576
|
|
|
1577
|
+
Журнал хранит каждую версию: прореживания и обрезки истории нет. Классу, чья история не нужна, — `history: false`
|
|
1578
|
+
([«Настройки класса»](#настройки-класса)).
|
|
1579
|
+
|
|
1574
1580
|
### Срез на дату
|
|
1575
1581
|
|
|
1576
1582
|
<!-- run: guide -->
|
|
@@ -1592,115 +1598,6 @@ const assortment = await db.Shop(shop).Product().asOf(createdAt).rows()
|
|
|
1592
1598
|
const withGone = await db.Customer().withDeleted().rows() // живые и удалённые; у удалённых $deleted: true
|
|
1593
1599
|
```
|
|
1594
1600
|
|
|
1595
|
-
### Политика хранения
|
|
1596
|
-
|
|
1597
|
-
Без политики журнал хранит каждую версию. Политика задаёт, сколько хранить старые версии и с какой подробностью:
|
|
1598
|
-
|
|
1599
|
-
```ts
|
|
1600
|
-
history: { all: '1 day', daily: '1 week', weekly: '1 month', monthly: '1 year', yearly: 'forever', tz: 'Europe/Moscow' }
|
|
1601
|
-
```
|
|
1602
|
-
|
|
1603
|
-
Это значит: все версии за последние сутки; за неделю — по одной на день; за месяц — по одной на неделю; за год —
|
|
1604
|
-
по одной на месяц; старше — по одной на год, бессрочно. От периода остаётся его последняя версия. Неделя
|
|
1605
|
-
начинается с понедельника, границы периодов — в поясе `tz` (по умолчанию UTC).
|
|
1606
|
-
|
|
1607
|
-
- Политику задают у класса (потомки наследуют) или для всех классов без своей — `up({ history })`.
|
|
1608
|
-
- Если у последнего яруса задан срок, а не `'forever'`, история старше срока удаляется: `{ all: '30 days' }`.
|
|
1609
|
-
- Пока история объекта жива, не удаляются текущая версия, версия 1 и версии особых видов (`create`, `delete`,
|
|
1610
|
-
`reclass`, `rekey`, `restore`, `purge`, `reset`, `trim`). Если надгробие удалённого объекта старше последнего яруса
|
|
1611
|
-
со сроком, его история стирается целиком. История системных классов не прореживается.
|
|
1612
|
-
- Прореживает `maintain()` пачками по 10 000 версий ([раздел 13](#13-эксплуатация)).
|
|
1613
|
-
- В прореженной части срез показывает последнюю сохранённую версию до момента, то есть его точность равна
|
|
1614
|
-
периоду яруса. Подписчик, отставший дольше яруса `all`, получает `cursor_expired`.
|
|
1615
|
-
|
|
1616
|
-
### Что изменилось и кто: `diff` и `blame`
|
|
1617
|
-
|
|
1618
|
-
```ts
|
|
1619
|
-
const [d] = await db.Order(id).diff() // предыдущая версия → последняя
|
|
1620
|
-
await db.Order(id).diff({ from: 3, to: 7 }) // по номерам версий
|
|
1621
|
-
await db.Order(id).diff({ from: '2026-10-01' }) // состояние на момент → последнее
|
|
1622
|
-
await db.Order(id).diff({ follow: true }) // через смену id (reclass, rekey)
|
|
1623
|
-
const [b] = await db.Order(id).blame() // каждое поле последней версии
|
|
1624
|
-
await db.Order(id).blame({ at: '2026-10-01', limit: 200 }) // версия на момент; просмотреть 200 версий
|
|
1625
|
-
```
|
|
1626
|
-
|
|
1627
|
-
Оба терминала отдают массив — по элементу на объект шага. Так выглядят ответы для чайника, которому снизили цену с
|
|
1628
|
-
причиной:
|
|
1629
|
-
|
|
1630
|
-
```ts
|
|
1631
|
-
import type { BlameResult, DiffResult } from 'letopis'
|
|
1632
|
-
|
|
1633
|
-
// db.Product(kettle).blame(): цену изменила версия 2, остальные листья — с создания (версия 1)
|
|
1634
|
-
const blamed: BlameResult = {
|
|
1635
|
-
id: '6f1c…', class: 'Product', rev: 2, complete: true,
|
|
1636
|
-
fields: [ // по порядку пути
|
|
1637
|
-
{ path: ['class'], rev: 1, at: '2026-10-27T09:15:02.118204+00:00', author: '<id Анны>', agent: '<id сервиса>',
|
|
1638
|
-
op: 'create', exact: true },
|
|
1639
|
-
{ path: ['data', 'price'], rev: 2, at: '2026-10-27T09:20:41.502617+00:00', author: '<id Анны>', agent: '<id сервиса>',
|
|
1640
|
-
op: 'update', reason: 'скидка к акции', exact: true },
|
|
1641
|
-
// ['data', 'sku'], ['data', 'title'], ['links', 'Shop'], ['owner'] — тоже версия 1, как ['class']
|
|
1642
|
-
],
|
|
1643
|
-
}
|
|
1644
|
-
// db.Product(kettle).diff(): предыдущая версия → последняя
|
|
1645
|
-
const diffed: DiffResult = {
|
|
1646
|
-
id: '6f1c…', class: 'Product',
|
|
1647
|
-
from: { rev: 1, at: '2026-10-27T09:15:02.118204+00:00' },
|
|
1648
|
-
to: { rev: 2, at: '2026-10-27T09:20:41.502617+00:00' },
|
|
1649
|
-
changes: [{ path: ['data', 'price'], kind: 'changed', before: 2500, after: 2000 }],
|
|
1650
|
-
}
|
|
1651
|
-
```
|
|
1652
|
-
|
|
1653
|
-
`agent` здесь — сервис, который пишет от имени Анны (`db = await svc.as(…)`); у записи без имперсонации его нет.
|
|
1654
|
-
|
|
1655
|
-
- `diff()` — массив `DiffResult`: `{ id, class, from, to, changes }`. `from` и `to` — сравниваемые версии
|
|
1656
|
-
`{ rev, at }`. В них есть `id`, если версия принадлежит другому id (`follow: true` через `reclass` или `rekey`), а у
|
|
1657
|
-
`to` — `deleted: true`, если на этот момент объект удалён; тогда `changes` пуст. `from` равен `null`, если объекта
|
|
1658
|
-
тогда ещё не было: все пути — `added`. Если версии нет вовсе, `class` и `to` тоже `null`.
|
|
1659
|
-
- Изменение `DiffChange` — `{ path, kind, before, after }`. `path` — массив сегментов, а не строка с точками; первый
|
|
1660
|
-
сегмент — раздел строки: `data`, `links`, `tags`, `owner`, `class`. `kind` — `added` (значения не было), `removed`
|
|
1661
|
-
(не стало) или `changed`. Пустое значение опускается: у `added` нет `before`, у `removed` — `after`. Объекты данных
|
|
1662
|
-
сравниваются по ключам, массивы — целиком. Множественный конец и теги — множества: по изменению на каждый id или
|
|
1663
|
-
тег, путь — `['links', роль]` или `['tags']`, а сам id или тег — в `before` или `after`.
|
|
1664
|
-
- `blame()` — массив `BlameResult`: `{ id, class, rev, complete, fields }` и `deleted: true`, если эта версия —
|
|
1665
|
-
надгробие. Объект без версий на этот момент элемента не даёт. `fields` — массив `BlameField`, по элементу на лист
|
|
1666
|
-
версии, упорядоченный по пути: `{ path, rev, at, author, op, exact }` и, если есть, `agent`, `reason`, `id`. Лист —
|
|
1667
|
-
скаляр или массив в данных, пустой объект, роль одиночного конца, каждый id множественного конца
|
|
1668
|
-
(`['links', роль, id]`), каждый тег (`['tags', тег]`), `owner` и `class`. `rev`, `at`, `author`, `agent`, `op` и
|
|
1669
|
-
`reason` — от версии, которая последней изменила лист. Пустые `agent` и `reason` опускаются; `id` есть, только если
|
|
1670
|
-
это версия прежнего id (история прошла через `reclass` или `rekey`).
|
|
1671
|
-
- `exact: false` — между текущей версией и найденной есть пропуски (история прорежена, обрезана или часть версий не
|
|
1672
|
-
видна): лист мог менять и выпавшая версия. `complete: true` — история просмотрена целиком и без пропусков; по
|
|
1673
|
-
умолчанию просматривается не больше 1000 последних версий.
|
|
1674
|
-
- Считает база с правами вызывающего: сырой SQL получает то же — `select "v2.shop".blame('<id>')`,
|
|
1675
|
-
`select "v2.shop".diff_versions('<id>')`.
|
|
1676
|
-
- Колонка журнала `changed` (пути изменения версии, по ним `blame` работает быстро) приложению не выдана: пути
|
|
1677
|
-
описывают разницу с предыдущей версией, а её читатель может не видеть. Поэтому `select * from log` от приложения
|
|
1678
|
-
не работает — колонки перечисляются явно; `blame` берёт пути функцией базы, только когда видны обе версии.
|
|
1679
|
-
|
|
1680
|
-
### Обрезка истории
|
|
1681
|
-
|
|
1682
|
-
<!-- run: guide -->
|
|
1683
|
-
```ts
|
|
1684
|
-
const trimmed = await db.trimHistory(kettle, new Date()) // стереть версии чайника до этого момента
|
|
1685
|
-
```
|
|
1686
|
-
|
|
1687
|
-
`db.trimHistory(цель, момент)` сразу стирает историю до момента: класса с потомками (по имени), id, списка id или
|
|
1688
|
-
строк. Это средний шаг «права на забвение»: `anonymize` → обрезка истории → удаление и `purge`. На месте версии 1
|
|
1689
|
-
остаётся запись `trim` с её временем, поэтому срез знает, когда объект появился.
|
|
1690
|
-
|
|
1691
|
-
### Проверка целостности
|
|
1692
|
-
|
|
1693
|
-
<!-- run: guide -->
|
|
1694
|
-
```ts
|
|
1695
|
-
const audit = await adm.verify({ data: true }) // { ok, issues, anchor }
|
|
1696
|
-
```
|
|
1697
|
-
|
|
1698
|
-
`verify()` пересчитывает цепочки хэшей всех версий и возвращает якорь — хэш всех голов цепочек. Храните якорь вне
|
|
1699
|
-
базы: подмену последних версий выдаст его расхождение. `{ data: true }` вдобавок перепроверяет данные, концы,
|
|
1700
|
-
арендаторов, владельцев и id всех строк. Вызывать может системный администратор или администраторское подключение;
|
|
1701
|
-
из командной строки — `letopis verify [--data]` (код выхода 1 при находках). Законные разрывы — записи `trim` и
|
|
1702
|
-
`purge`, прореживание и `reset` — ошибкой не считаются.
|
|
1703
|
-
|
|
1704
1601
|
## 12. Подписка на изменения и кэш
|
|
1705
1602
|
|
|
1706
1603
|
### Подписка
|
|
@@ -1722,224 +1619,15 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
|
|
|
1722
1619
|
запись после него в поток придёт. Пример выше читает с `'start'`.
|
|
1723
1620
|
- Вызовы `sub.next()` идут по очереди: параллельные получают разные события. Вызов, брошенный по таймауту
|
|
1724
1621
|
(`Promise.race`), всё равно получит своё событие — держите его обещание и ждите его снова.
|
|
1725
|
-
- `classes` — только эти классы с потомками; `
|
|
1726
|
-
|
|
1727
|
-
`$deleted`), даже если новый класс подписчику не виден. Событие приходит, если прежнюю версию подписчик видел.
|
|
1622
|
+
- `classes` — только эти классы с потомками; `batch` — событий за один запрос к журналу (500); `pollMs` — запасной
|
|
1623
|
+
опрос (1000 мс).
|
|
1728
1624
|
- События идут без потерь и повторов, под правами читателя, в порядке номеров транзакций, а внутри транзакции — в
|
|
1729
1625
|
порядке записи. Это не порядок фиксации: версии одного объекта сверяйте по `rev`.
|
|
1730
1626
|
- Долгая пишущая транзакция в любой базе кластера задерживает поток: `await sub.lag()` показывает отставание в мс.
|
|
1731
|
-
- Курсор старше яруса `all` политики хранения — `cursor_expired`: перечитайте текущее состояние и подпишитесь
|
|
1732
|
-
с `'now'`. Время в курсоре подписки `sub.cursor` — время последнего события, а когда подписка догнала журнал, —
|
|
1733
|
-
время опроса, поэтому подписка, к которой сутки не было событий, не устаревает. Курсор события `ev.cursor` хранит
|
|
1734
|
-
время самого события.
|
|
1735
1627
|
- Без `LISTEN` (`connect({ listen: false })`) подписка опрашивает журнал.
|
|
1736
1628
|
- Подписка без потерь — это сохранённый курсор: обработав событие, храните `sub.cursor` (позиция после последнего
|
|
1737
|
-
выданного события) и продолжайте с него после перезапуска. С `ev.cursor` поток тоже
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
### Проекторы
|
|
1741
|
-
|
|
1742
|
-
Проектор строит строки производных классов из журнала: сводки, денормализованные карточки и всё, что можно считать
|
|
1743
|
-
с отставанием от записи. Классы-источники (`from`) пишет приложение, а строки классов `into` — только проектор.
|
|
1744
|
-
Каждый класс `into` помечен опцией `derivedBy` с именем проектора ([«Настройки класса»](#настройки-класса)).
|
|
1745
|
-
Производные классы заводит только администратор: синхронизируйте их администраторским подключением с сессией
|
|
1746
|
-
арендатора — `adm` из раздела 3 (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или `as()` сервиса
|
|
1747
|
-
на таком подключении, в CLI — `letopis sync` с `--dsn` роли администратора. У сессии приложения это `acl_denied`. В примере ниже проектор
|
|
1748
|
-
считает продажи товаров по строкам заказов `line`.
|
|
1749
|
-
|
|
1750
|
-
```ts
|
|
1751
|
-
const ProductSales = hub('ProductSales', { qty: z.number().int(), revenue: z.number().int() }, {
|
|
1752
|
-
ends: { Product: one(Product).onDelete('cascade') }, key: ['Product'], derivedBy: 'product-sales', history: false,
|
|
1753
|
-
}) // производный класс — в модели; syncModels — администраторским подключением
|
|
1754
|
-
|
|
1755
|
-
const sales = adm.projector('product-sales', { // adm — администраторское подключение
|
|
1756
|
-
from: ['line'], // классы-источники с потомками; у них должен быть журнал
|
|
1757
|
-
into: ['ProductSales'], // производные классы этого проектора
|
|
1758
|
-
version: 1, // логика handle изменилась — новая версия и перестройка
|
|
1759
|
-
withBefore: true, // c.before — прошлая версия объекта или null
|
|
1760
|
-
handle(changes, out) {
|
|
1761
|
-
for (const c of changes) {
|
|
1762
|
-
if (c.op === 'purge' || c.op === 'trim' || c.op === 'reset') continue // служебные изменения, не версии
|
|
1763
|
-
const was = c.before // прошлая версия строки заказа — вычесть
|
|
1764
|
-
const now = c.row.$deleted ? null : c.row // новая версия — прибавить; у удаления её нет
|
|
1765
|
-
if (was) out.inc('ProductSales', { Product: was.links.Product },
|
|
1766
|
-
{ qty: -Number(was.data.qty), revenue: -Number(was.data.qty) * Number(was.data.price) }, { tenant: c.tenant })
|
|
1767
|
-
if (now) out.inc('ProductSales', { Product: now.links.Product },
|
|
1768
|
-
{ qty: Number(now.data.qty), revenue: Number(now.data.qty) * Number(now.data.price) }, { tenant: c.tenant })
|
|
1769
|
-
}
|
|
1770
|
-
},
|
|
1771
|
-
})
|
|
1772
|
-
const report = await sales.run({ once: true }) // догнать журнал: { batches, changes, created, updated, … }
|
|
1773
|
-
const state = await sales.status() // { name, state, version, cursor, at, lag, failed, rebuildingTenants }
|
|
1774
|
-
await sales.rebuild() // перестроить с текущего состояния; { chunked: true } — пачками
|
|
1775
|
-
await sales.rebuild({ tenant: tenantId }) // перестроить одного арендатора — например, выбывшего (failed)
|
|
1776
|
-
await sales.rebuild({ tenant: tenantId, chunked: true }) // его же пачками: хвост журнала тем временем идёт для всех
|
|
1777
|
-
sales.run().catch((err) => console.error('проектор остановлен', err)) // следовать за журналом до stop()
|
|
1778
|
-
sales.close() // stop() после текущей пачки и отписка от сигналов
|
|
1779
|
-
```
|
|
1780
|
-
|
|
1781
|
-
- Проектору нужно администраторское подключение: `connect({ allowBypassRls: true })` роли, которая может стать
|
|
1782
|
-
владельцем схемы. Иначе — `admin_required`, внутри `db.begin()` — `invalid_query`. Проектор читает журнал всех
|
|
1783
|
-
арендаторов.
|
|
1784
|
-
- Первый запуск `run()` перестраивает проекцию с текущего состояния: журнал прорежен политикой хранения и обрезан
|
|
1785
|
-
`purge`, поэтому собрать проекцию с начала журнала нельзя.
|
|
1786
|
-
- Каждая пачка — одна транзакция: изменения журнала, `handle`, запись результатов и сдвиг курсора фиксируются
|
|
1787
|
-
вместе. Обрыв посреди пачки откатывает её целиком, а повтор начинается с того же курсора, поэтому каждое
|
|
1788
|
-
изменение применяется ровно один раз. Писатель у проектора один: второй процесс того же проектора пропускает пачку
|
|
1789
|
-
(счётчик `skipped` в отчёте), а `rebuild()`, начатая, пока другой процесс пишет пачку, — `projector_busy`.
|
|
1790
|
-
- Пачка делится по арендаторам: `handle` получает изменения одного арендатора, и запись его результатов идёт под
|
|
1791
|
-
своей точкой сохранения. Если база отклоняет запись результатов арендатора (данные не проходят описание его
|
|
1792
|
-
производного класса, цель конца не найдена и т. п.) или `out` отклоняет строку или приращение, построенные по его
|
|
1793
|
-
данным (приращение — не число), откатываются только его изменения: арендатор выбывает, а остальные записываются в
|
|
1794
|
-
той же пачке. Выбывший попадает в `status().failed` — `{ [id арендатора]: { cursor, code, message } }`, `message`
|
|
1795
|
-
называет арендатора и id строки, — и его изменения пропускаются, пока его не перестроят. Производные строки
|
|
1796
|
-
выбывшего больше не меняются: выбывший в `run()` сохраняет их такими, какими они были до пачки с ошибкой, а выбывший
|
|
1797
|
-
посреди перестройки остаётся без них — частично построенные строки удаляются. Исключение из `handle`, нарушение
|
|
1798
|
-
границ `out` и сбой окружения (взаимная блокировка, отмена запроса, обрыв связи) откатывают пачку целиком, а
|
|
1799
|
-
`run()` отклоняется. Поэтому `run()` без `once` ловите (`catch`): после ошибки он остановлен.
|
|
1800
|
-
- Изменение (`ProjectorChange`) — это `cursor`, `id`, `class`, `tenant`, `op`, `rev`, `at`, `author`, `agent`,
|
|
1801
|
-
`reason`, `moved` и `row` — версия из журнала (у удаления — надгробие с `$deleted`). При перестройке изменения —
|
|
1802
|
-
строки текущего состояния: `rebuild: true`, `op` — `create`, `cursor` пустой.
|
|
1803
|
-
- Смена класса без смены id (у классов без ключа: сырой `UPDATE … SET class` и `reclass()`) журнал пишет версией уже
|
|
1804
|
-
нового класса. Если она уводит строку из семейства `from`, проектор получает удаление: `op` — `delete`, `row` —
|
|
1805
|
-
последняя версия строки в семействе с `$deleted`, `rev`, `at` и автор — смены класса. Строка, вошедшая так в
|
|
1806
|
-
семейство, приходит изменением `reclass` с `before` = `null`.
|
|
1807
|
-
- С `withBefore` у изменения есть `before` — прошлая версия того же id. `before` равен `null`, если прошлой версии нет
|
|
1808
|
-
(создание и первая версия нового id после `reclass` и `rekey`), если прошлая версия — удаление (так бывает после
|
|
1809
|
-
`restore`) или вне семейства `from`, и у всех изменений перестройки. При `reclass` и `rekey` прежние данные
|
|
1810
|
-
вычитает надгробие прежнего id: оно приходит изменением с `$deleted`, его `before` — последняя версия прежнего id,
|
|
1811
|
-
а `moved` обоих изменений указывает на другой id. Поэтому свёртка из примера после `rekey` даёт ту же сумму. У
|
|
1812
|
-
служебных изменений `purge`, `trim` и `reset` поля `before` нет совсем. Поэтому вычитайте `before`, только проверив
|
|
1813
|
-
его на `null`, как в примере выше.
|
|
1814
|
-
- Служебные записи журнала `purge`, `trim` и `reset` тоже приходят изменениями, и у них `rev` равен 1. Поэтому
|
|
1815
|
-
сначала разберите `op`, а уже потом сравнивайте `rev`.
|
|
1816
|
-
- `out.upsert(класс, строка, { tenant })` создаёт строку или заменяет её целиком по ключу класса.
|
|
1817
|
-
`out.inc(класс, строка, приращения, { tenant })` создаёт строку или дополняет её и прибавляет числа, не читая
|
|
1818
|
-
строку (поля, которых нет, считаются от 0). `out.delete(класс, ключ или id, { tenant })` удаляет строку, а если её
|
|
1819
|
-
нет, ничего не делает. `await out.get(класс, ключ или id, { tenant })` читает текущую строку вместе с записями
|
|
1820
|
-
пачки. Строку класса с ключом `delete` и `get` находят по полям ключа (`{ day: '2026-10-29' }`), а по id (строкой
|
|
1821
|
-
или `{ $id }`) — только строку класса без ключа; один id у класса с ключом — `invalid_data`, и пачка откатывается.
|
|
1822
|
-
`out` пишет только в классы `into` и только в арендатора изменений, переданных в `handle`; иначе —
|
|
1823
|
-
`derived_class` или `tenant_denied`, и пачка откатывается.
|
|
1824
|
-
- Приращения строки, заданной одними полями ключа (или одним `$id`), `out` складывает до отправки в базу, пока между
|
|
1825
|
-
ними нет `upsert`, `delete` или `get`: на строку — одна правка за пачку, а правило класса проверяется над итогом её
|
|
1826
|
-
приращений. Строку с другими полями (данными, `$tags`, `$owner`) `inc` не складывает: её поля правят строку, и
|
|
1827
|
-
порядок таких правок сохраняется.
|
|
1828
|
-
- Строка для `out` — в формате загрузчика: поля и роли концов на верхнем уровне, служебные `$tags`, `$owner` и
|
|
1829
|
-
`$id` (`$id` нужен только классу без ключа). Значение роли — id цели. Правка, которая ничего не меняет, версии не
|
|
1830
|
-
даёт. Версии производных строк пишутся от System с агентом `letopis projector <имя>`; журнал ведётся по
|
|
1831
|
-
`history` класса, а сигнал об изменении уходит всегда.
|
|
1832
|
-
- `rebuild()` перестраивает проекцию атомарно, одной транзакцией: до её фиксации читатели видят прежние строки, но
|
|
1833
|
-
горизонт подписок стоит. `rebuild({ chunked: true })` идёт пачками: производные строки удаляются сразу, и до
|
|
1834
|
-
конца перестройки данные неполные, а `status().state` — `'rebuilding'`. Прерванную перестройку пачками
|
|
1835
|
-
продолжает следующий запуск. Сам `run()` — первый запуск новой проекции и `onExpired: 'rebuild'` — перестраивает
|
|
1836
|
-
пачками: горизонт подписок кластера стоит не дольше пачки. Второй процесс того же проектора тем временем ждёт или
|
|
1837
|
-
продолжает ту же перестройку, а не начинает её заново. Перестройка удаляет строки только участников и только
|
|
1838
|
-
классов их семейств `into` и начинает всех участников заново: отметки выбывших снимаются, а арендатор, чьи данные
|
|
1839
|
-
снова не проходят, выбывает снова.
|
|
1840
|
-
- `rebuild({ tenant: id })` перестраивает одного арендатора одной транзакцией: удаляет его производные строки, строит
|
|
1841
|
-
их из его текущих строк источников и снимает отметку в `status().failed`; изменения, уже учтённые перестройкой,
|
|
1842
|
-
проектор потом не применяет второй раз. Исправьте данные или описание класса арендатора, затем перестройте его. Если
|
|
1843
|
-
база снова отклоняет запись, перестройка откатывается с этой ошибкой, и арендатор остаётся выбывшим. Проекции ещё
|
|
1844
|
-
нет или идёт перестройка пачками — `rebuild_required`; арендатор не участник — `tenant_denied`. С `chunked: true` —
|
|
1845
|
-
пачками, каждая своей транзакцией со своим снимком, пока хвост журнала идёт для всех: изменения арендатора выше
|
|
1846
|
-
последней пачки хвост пропускает (их прочтёт пачка), в пройденной — если транзакция видна в её снимке. Производные
|
|
1847
|
-
строки арендатора удаляются сразу и до конца перестройки неполны (`status().rebuildingTenants`); блокировку,
|
|
1848
|
-
занятую `run()` другого процесса, перестройка пережидает. Прерванную перестройку (процесс остановился) продолжает
|
|
1849
|
-
`run()` любого процесса — с `onExpired` и без него.
|
|
1850
|
-
- `status().state` описывает проекцию в базе, а не процесс: `'new'` — проекции ещё нет, её построит первый `run()`
|
|
1851
|
-
или `rebuild()`; `'running'` — проекция построена и ведётся по журналу (это не значит, что какой-то процесс сейчас
|
|
1852
|
-
выполняет `run()`); `'rebuilding'` — идёт или прервана перестройка пачками.
|
|
1853
|
-
- `version` — версия логики `handle`. Если проекция построена другой версией, `run()` бросает `rebuild_required`, и
|
|
1854
|
-
нужна перестройка `rebuild()`.
|
|
1855
|
-
- Если курсор старше яруса `all` политики хранения источников и после него есть изменения, часть изменений могла
|
|
1856
|
-
проредиться. Ярус у каждого арендатора свой — это описание его классов. Курсор старше яруса у всех участников —
|
|
1857
|
-
`run()` бросает `cursor_expired`, а с `onExpired: 'rebuild'` проектор перестраивается целиком, пачками. Старше
|
|
1858
|
-
яруса только у части — выбывают они (`status().failed`, код `cursor_expired`), остальные идут дальше, а с
|
|
1859
|
-
`onExpired: 'rebuild'` выбывшие перестраиваются сами, пачками: состояние перестройки лежит в чекпоинте, и её
|
|
1860
|
-
продолжает `run()` любого процесса; блокировка, занятая другим процессом, — только повтор на следующем проходе, а
|
|
1861
|
-
ошибка данных выводит арендатора с её кодом, без повторов. Ярус перестроенного арендатора отсчитывается от начала
|
|
1862
|
-
его перестройки, пока курсор её не догнал: отстающий проектор не выводит его снова. Проектор без новых изменений не
|
|
1863
|
-
истекает. `status().lag` — возраст самого старого необработанного изменения в мс.
|
|
1864
|
-
- Проектор работает с арендаторами, у которых каждый класс `into` помечен его `derivedBy`; изменения остальных
|
|
1865
|
-
арендаторов он пропускает, поэтому одноимённый класс без пометки у другого арендатора ему не мешает. Нет ни одного
|
|
1866
|
-
такого арендатора — `derived_class`. Пометить классы арендатора может только администратор, поэтому участников
|
|
1867
|
-
выбирает он: пользователь арендатора сам к проектору не подключится. Опция
|
|
1868
|
-
`tenants: [id, …]` — явный список участников: проектор работает только с этими арендаторами (их классы `into` тоже
|
|
1869
|
-
должны быть помечены), а пометку у остальных не учитывает. Источники — семейства `from` у этих арендаторов; класс
|
|
1870
|
-
из `into` источником не бывает, даже если попадает в `from`: свои результаты проектор не читает. Изменений класса
|
|
1871
|
-
с `history: false` проектор не видит: журнала у него нет. Поэтому арендатор, у которого такой класс среди
|
|
1872
|
-
источников, выбывает с кодом `invalid_class`, и его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
|
|
1873
|
-
- `batch` — сколько изменений в пачке (500), `pollMs` — запасной опрос (1000 мс): сигналы источников будят
|
|
1874
|
-
проектор раньше.
|
|
1875
|
-
- `lockTimeout` — сколько транзакция проектора ждёт чужую блокировку строки (5000 мс; 0 — без предела). Транзакция
|
|
1876
|
-
любого арендатора, которая держит строку итога или цель конца, иначе держала бы пачку всех арендаторов. Дольше —
|
|
1877
|
-
пачка откатывается, курсор остаётся, `run()` не отклоняется, а пропускает проход (`skipped`) и повторяет его. Отчёт
|
|
1878
|
-
называет арендатора, на чьей строке встала пачка (`locked`). `rebuild({ tenant, chunked: true })` такую блокировку
|
|
1879
|
-
пережидает.
|
|
1880
|
-
- `lockRetries` — сколько проходов подряд запись одного арендатора может упираться в `lockTimeout`, прежде чем он
|
|
1881
|
-
выбудет (по умолчанию 0 — не выбывает). Выбывший получает код `55P03`, и пачка идёт для остальных; его изменения
|
|
1882
|
-
пропускаются до `rebuild({ tenant })`. Счёт ведёт процесс, удачная запись арендатора его сбрасывает. Учтите: долгая
|
|
1883
|
-
исключительная блокировка семейства класса System (ужесточение описания) упрётся в каждого арендатора по очереди.
|
|
1884
|
-
- Каждая правка, которую `out` отправляет в базу, — отдельная правка строки, и скорость проектора задаёт число
|
|
1885
|
-
правок, а не число изменений. Свёртка `inc` сводит правки строки к одной за пачку, поэтому handle из примера выше
|
|
1886
|
-
(`out.inc` на каждое изменение) на машине замеров догонял 6 тысяч изменений в секунду при `batch` 500 и 33 тысячи
|
|
1887
|
-
при `batch` 2000 ([раздел 17](#17-производительность)): чем больше пачка, тем больше приращений одной строки
|
|
1888
|
-
складывается в одну правку.
|
|
1889
|
-
|
|
1890
|
-
### Рецепт: дневная выручка
|
|
1891
|
-
|
|
1892
|
-
Агрегат в базе и проектор решают похожие задачи по-разному:
|
|
1893
|
-
|
|
1894
|
-
| | Агрегат в базе | Проектор |
|
|
1895
|
-
|---|---|---|
|
|
1896
|
-
| когда считается | в той же транзакции, что и запись источника | после фиксации, с отставанием (`status().lag`) |
|
|
1897
|
-
| что считает | сумму поля или число строк источника для одной строки-цели | что угодно: сводки по дням и группам, денормализованные карточки |
|
|
1898
|
-
| совпадает с источником | всегда, в том числе для сырого SQL | когда проектор догнал журнал |
|
|
1899
|
-
| цена записи | каждая операция блокирует строку цели и пишет её версию, поэтому писатели одной цели идут по очереди | писатели не ждут друг друга: проектор применяет изменения пачками после фиксации |
|
|
1900
|
-
| правило над итогом | `min(0)` отклоняет операцию, которая увела бы итог за правило | итог не мешает записи: ошибка данных выводит арендатора из проекции |
|
|
1901
|
-
| что запустить | ничего: поле держит база | процесс на администраторском подключении |
|
|
1902
|
-
|
|
1903
|
-
Баланс кошелька, который не должен уйти в минус, — агрегат ([«Рецепт: баланс агрегатом»](#рецепт-баланс-агрегатом)).
|
|
1904
|
-
Выручка магазина по дням — итог по многим продажам, которому можно немного отставать, — работа для проектора:
|
|
1905
|
-
|
|
1906
|
-
```ts
|
|
1907
|
-
const Sale = hub('Sale', { day: z.iso.date(), amount: z.number().int().min(0) }, { ends: { Shop } }) // продажа: день и сумма
|
|
1908
|
-
const DayRevenue = hub('DayRevenue', { day: z.iso.date(), revenue: z.number().int(), sales: z.number().int() }, {
|
|
1909
|
-
ends: { Shop: one(Shop).onDelete('cascade') }, key: ['Shop', 'day'], derivedBy: 'day-revenue', history: false,
|
|
1910
|
-
})
|
|
1911
|
-
await syncModels(db, [Sale], { apply: true })
|
|
1912
|
-
await syncModels(adm, [DayRevenue], { apply: true }) // производный класс заводит администратор: adm — с сессией арендатора
|
|
1913
|
-
|
|
1914
|
-
const revenue = adm.projector('day-revenue', {
|
|
1915
|
-
from: ['Sale'], into: ['DayRevenue'], version: 1, withBefore: true,
|
|
1916
|
-
handle(changes, out) {
|
|
1917
|
-
for (const c of changes) {
|
|
1918
|
-
if (c.op === 'purge' || c.op === 'trim' || c.op === 'reset') continue // служебные изменения
|
|
1919
|
-
const was = c.before // прошлая версия продажи — вычесть из её дня
|
|
1920
|
-
const now = c.row.$deleted ? null : c.row // новая — прибавить к её дню; у удаления её нет
|
|
1921
|
-
if (was) out.inc('DayRevenue', { Shop: was.links.Shop, day: was.data.day },
|
|
1922
|
-
{ revenue: -Number(was.data.amount), sales: -1 }, { tenant: c.tenant })
|
|
1923
|
-
if (now) out.inc('DayRevenue', { Shop: now.links.Shop, day: now.data.day },
|
|
1924
|
-
{ revenue: Number(now.data.amount), sales: 1 }, { tenant: c.tenant })
|
|
1925
|
-
}
|
|
1926
|
-
},
|
|
1927
|
-
})
|
|
1928
|
-
revenue.run().catch((err) => console.error('проектор выручки остановлен', err)) // следует за журналом до stop()
|
|
1929
|
-
const week = await db.Shop(shop).DayRevenue().sort('data.day', 'desc').limit(7).rows() // семь последних дней
|
|
1930
|
-
```
|
|
1931
|
-
|
|
1932
|
-
- День — поле продажи, а не время версии `at`: со временем версии правка, `restore` или `rekey` переносили бы сумму
|
|
1933
|
-
продажи в свой день.
|
|
1934
|
-
- `out.inc` прибавляет, не читая строку, поэтому итог не зависит от порядка изменений: журнал отдаёт транзакции в
|
|
1935
|
-
порядке первой записи, а не фиксации. Правка продажи вычитает прежнюю сумму из прежнего дня и прибавляет новую к
|
|
1936
|
-
новому, удаление только вычитает. День, в котором продаж не осталось, остаётся строкой с нулями. Приращения одного
|
|
1937
|
-
дня в пачке `out` складывает в одну правку.
|
|
1938
|
-
- У `DayRevenue` — `history: false`: производные строки не удваивают журнал, а перестройка соберёт их и без истории.
|
|
1939
|
-
У `Sale` журнал нужен: изменений класса с `history: false` проектор не видит.
|
|
1940
|
-
- Строки `DayRevenue` приложение читает, как любой класс, а пишет их только проектор: `create` или `update` такой
|
|
1941
|
-
строки — `derived_class`. Итог отстаёт от продаж до следующей пачки: проектор будят сигналы записи, а без них —
|
|
1942
|
-
опрос раз в `pollMs`.
|
|
1629
|
+
выданного события) и продолжайте с него после перезапуска. С `ev.cursor` поток тоже продолжится — после этого
|
|
1630
|
+
события.
|
|
1943
1631
|
|
|
1944
1632
|
### Кэш результатов
|
|
1945
1633
|
|
|
@@ -1961,10 +1649,10 @@ const stats = db.cache.stats() // { size, hits,
|
|
|
1961
1649
|
- срок по умолчанию — 60 секунд; внутри `db.begin()` кэш не работает;
|
|
1962
1650
|
- без `LISTEN` и в режиме `none` кэш выключен, а `connect({ cache: { ttlOnly: true } })` включает его: ответ живёт до
|
|
1963
1651
|
конца срока, если его не сбросит запись этого подключения;
|
|
1964
|
-
- `db.load()
|
|
1652
|
+
- `db.load()` сбрасывает кэш, даже этого подключения, только сигналом базы.
|
|
1965
1653
|
|
|
1966
1654
|
Реестр классов процесс перечитывает по сигналу об изменении классов сразу (одно перечитывание за раз), а реестр и
|
|
1967
|
-
ключ сигналов
|
|
1655
|
+
ключ сигналов после переподключения слушателя, по незнакомому токену и по нагрузке не своего режима (а без ключа в режиме `opaque` — по любому
|
|
1968
1656
|
сигналу) — не чаще раза в 5 секунд: поток сигналов, в том числе поддельных, не превращается в поток запросов. В режиме `none` реестр раз в 5 секунд сверяет отпечаток классов, а подписка
|
|
1969
1657
|
опрашивает журнал. Подписка просыпается только от своих классов и своего арендатора, а без ключа — от любого
|
|
1970
1658
|
сигнала; события она в любом случае читает из журнала, поэтому ничего не теряет.
|
|
@@ -1978,49 +1666,23 @@ const stats = db.cache.stats() // { size, hits,
|
|
|
1978
1666
|
|
|
1979
1667
|
<!-- run: guide -->
|
|
1980
1668
|
```ts
|
|
1981
|
-
const report = await adm.maintain() // { sessions, codes,
|
|
1669
|
+
const report = await adm.maintain() // { sessions, codes, queue, skipped }
|
|
1982
1670
|
```
|
|
1983
1671
|
|
|
1984
1672
|
`maintain()` удаляет из таблицы `secret` сессии и одноразовые коды всех видов (`OTP` и свои `OTP_<ИМЯ>` из
|
|
1985
1673
|
`issueOtp({ kind })`) — истёкшие, а также погашенные сессии и коды, которые уже использованы, заменены новым или
|
|
1986
|
-
исчерпали попытки, —
|
|
1987
|
-
|
|
1988
|
-
`maintain()` ждать не нужно: их стирает само удаление ([«Удалить
|
|
1989
|
-
администратор, роль с правом `letopis.maintain` или
|
|
1990
|
-
планировщик (`letopis maintain`) или сама библиотека:
|
|
1991
|
-
строка (`'500ms'`, `'30s'`, `'15m'`, `'1h'`, `'1d'`)
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
ни прикладная, ни посторонняя роль базы не займёт; блокировка снимается с
|
|
1996
|
-
другой процесс, вызов
|
|
1997
|
-
|
|
1998
|
-
пулом, в том числе за PgBouncer в режиме транзакций.
|
|
1999
|
-
|
|
2000
|
-
### Сброс: `reset()`
|
|
2001
|
-
|
|
2002
|
-
```ts
|
|
2003
|
-
await adm.reset({ level: 'tenant', confirm: 'v2.shop', tenant: SHOP }) // sessions | tenant | all
|
|
2004
|
-
```
|
|
2005
|
-
|
|
2006
|
-
Сброс нужен тестам и стендам. Он работает, только если установка поставлена с `up({ allowReset: true })` (иначе
|
|
2007
|
-
`reset_disabled` даже для System), у вызывающего есть право `letopis.reset`, а `confirm` совпадает с именем схемы
|
|
2008
|
-
(`confirm_mismatch`). Уровни: `sessions` — все сессии, кроме текущей; `tenant` — данные арендатора; `all` — всё, с заново
|
|
2009
|
-
положенным системным сидом и новым ключом сервиса. Первая запись журнала после сброса — `reset`.
|
|
2010
|
-
|
|
2011
|
-
### Описание модели для людей и ИИ
|
|
2012
|
-
|
|
2013
|
-
<!-- run: guide -->
|
|
2014
|
-
```ts
|
|
2015
|
-
import { describeText } from 'letopis'
|
|
2016
|
-
|
|
2017
|
-
const model = db.describe() // { schema, tenant, classes: [...], methods }
|
|
2018
|
-
const modelText = describeText(model) // то же текстом — для llms.txt приложения или подсказки ИИ-агенту
|
|
2019
|
-
```
|
|
2020
|
-
|
|
2021
|
-
`db.describe()` отдаёт описание классов, доступных сессии: поля, концы, ключи, политики и метаданные, а также
|
|
2022
|
-
условия по нескольким полям `anyOf` и `oneOf` с учётом наследования, признак производного класса `derivedBy` и
|
|
2023
|
-
агрегаты `aggregate`, если они заданы. Из командной строки — `letopis describe --format text --out llms.txt`.
|
|
1674
|
+
исчерпали попытки, — и строки-замки удалённых классов (по две на класс; строку, которую держит транзакция, — следующим
|
|
1675
|
+
вызовом), а ещё проверяет очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Журнал
|
|
1676
|
+
`maintain()` не трогает. Строки удалённого аккаунта ждать не нужно: их стирает само удаление ([«Удалить
|
|
1677
|
+
аккаунт»](#удалить-аккаунт)). Вызывать может системный администратор, роль с правом `letopis.maintain` или
|
|
1678
|
+
администраторское подключение. Запускает его внешний планировщик (`letopis maintain`) или сама библиотека:
|
|
1679
|
+
`connect({ maintain: '1h' })`. Интервал — число миллисекунд или строка (`'500ms'`, `'30s'`, `'15m'`, `'1h'`, `'1d'`)
|
|
1680
|
+
от 1 мс до 2³¹−1 мс (около 24 суток): другое значение, в том числе ноль, отрицательное или `NaN`, `connect()`
|
|
1681
|
+
отклоняет с `invalid_data`, не открывая соединений. `close()` ждёт идущий по расписанию запуск. Из нескольких
|
|
1682
|
+
процессов работает один: обслуживание — одна транзакция, и первым делом она берёт блокировку обслуживания — строку
|
|
1683
|
+
таблицы `secret`, которую ни прикладная, ни посторонняя роль базы не займёт; блокировка снимается с фиксацией. Если
|
|
1684
|
+
блокировку держит другой процесс, вызов отвечает `skipped: true`. Сеансовой блокировки нет, поэтому `maintain()`
|
|
1685
|
+
работает за любым пулом, в том числе за PgBouncer в режиме транзакций.
|
|
2024
1686
|
|
|
2025
1687
|
### Агенты и MCP
|
|
2026
1688
|
|
|
@@ -2030,9 +1692,9 @@ const modelText = describeText(model) // то же текстом — для llm
|
|
|
2030
1692
|
|
|
2031
1693
|
| Инструмент | Что делает |
|
|
2032
1694
|
|---|---|
|
|
2033
|
-
| `letopis_model` | модель: классы, поля (JSON Schema), условия `anyOf` и `oneOf`, концы, ключи, алиасы,
|
|
1695
|
+
| `letopis_model` | модель: классы, поля (JSON Schema), условия `anyOf` и `oneOf`, концы, ключи, алиасы, метаданные и рабочие цепочки `examples`; с `class` — подробно один класс |
|
|
2034
1696
|
| `letopis_query` | чтение JSON-цепочкой: старт `class` и `filter`, `id` или `ids`; `steps` — переходы по ролям и классам; `asOf`, `withDeleted`, `sort`, `limit`, `cursor`; терминал `rows`, `first`, `ids`, `count`, `sum`, `avg`, `min`, `max`, `countBy` |
|
|
2035
|
-
| `letopis_history` |
|
|
1697
|
+
| `letopis_history` | версии одного объекта |
|
|
2036
1698
|
| `letopis_write` | `create`, `update` (только с `rev`), `upsert`, `delete` — только при `write: true` (`letopis mcp --write`) |
|
|
2037
1699
|
|
|
2038
1700
|
Запрос `letopis_query` — та же цепочка, что `db.Product({ price: lt(1000) }).Shop().sort('data.name').rows()`, только записанная JSON:
|
|
@@ -2041,9 +1703,9 @@ const modelText = describeText(model) // то же текстом — для llm
|
|
|
2041
1703
|
{ "class": "Product", "filter": { "price": { "$lt": 1000 } }, "steps": [{ "step": "Shop" }], "sort": { "field": "data.name" } }
|
|
2042
1704
|
```
|
|
2043
1705
|
|
|
2044
|
-
Значение фильтра — равенство, вложенный объект — поля вложенных объектов, `{ "Роль": id }` — конец.
|
|
2045
|
-
|
|
2046
|
-
|
|
1706
|
+
Значение фильтра — равенство, вложенный объект — поля вложенных объектов, `{ "Роль": id }` — конец. Операторы — ключи
|
|
1707
|
+
колонки MCP в таблице раздела [«Фильтры»](#фильтры): `{ "price": { "$gt": 5 } }`; ИЛИ — `{ "$or": [фильтр, …] }`
|
|
1708
|
+
вместо всего фильтра. Неизвестный класс, роль, поле, параметр или
|
|
2047
1709
|
оператор — ответ `isError` с подсказкой похожих имён. Шагов `steps` — не больше 16, условий в `$or` одного фильтра —
|
|
2048
1710
|
не больше 64; иначе `invalid_query`. Время запроса эти пределы не ограничивают: задайте `statement_timeout` роли базы,
|
|
2049
1711
|
под которой подключается сервер MCP (`alter role … set statement_timeout = '5s'`).
|
|
@@ -2070,15 +1732,25 @@ const modelText = describeText(model) // то же текстом — для llm
|
|
|
2070
1732
|
`'asc'` (по умолчанию) или `'desc'`; без `sort` строки идут по `id`. `sort`, `limit` и `cursor` — только у `rows`,
|
|
2071
1733
|
`first` и `ids`.
|
|
2072
1734
|
|
|
2073
|
-
`letopis_model` без `class` отвечает (в `data`) `{ schema, tenant, classes }` —
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
`
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
1735
|
+
`letopis_model` без `class` отвечает (в `data`) `{ schema, tenant, classes }` — классы, доступные сессии: свои классы
|
|
1736
|
+
арендатора и общие классы System с полями, концами, ключами, настройками (`history`, `ownerDefault`, `cache`) и метаданными, а также условия по нескольким
|
|
1737
|
+
полям `anyOf` и `oneOf` с учётом наследования, если они заданы. У каждого класса есть `examples` — рабочие цепочки, собранные из реестра: `create` — создать, `find` — найти,
|
|
1738
|
+
`neighbors` — перейти к соседям. `create` передаёт поля ключа и обязательные поля без умолчания, а обязательные концы и
|
|
1739
|
+
концы ключа ставит слотами; у абстрактного и системного класса его нет. Образец значения — `examples`
|
|
1740
|
+
из JSON Schema поля (в Zod — `.meta({ examples: […] })`), иначе `const`, `default`, первое из `enum`, иначе по типу и
|
|
1741
|
+
формату. Поле с `pattern` без `examples` может получить образец, который проверку не пройдёт. `find` ищет по ключу
|
|
1742
|
+
(`first()`), а без ключа — по обязательному полю (`rows()`). `neighbors` идёт по каждому концу класса и к классам, чьи
|
|
1743
|
+
концы ведут на него. `'<id Org>'` — место для id объекта класса или его потомка; `'<id Service|Product>'` — любого из
|
|
1744
|
+
классов; у множественного конца с `min` — `'<id Класс #1>'`, `'<id Класс #2>'`. Если модель больше `maxBytes`, она
|
|
1745
|
+
приходит сначала без `examples`, затем краткой: `brief: true`, а у классов — только имена полей и концов; подробно
|
|
1746
|
+
каждый класс отдаёт `letopis_model` с `class`.
|
|
1747
|
+
|
|
1748
|
+
`letopis_history` принимает `class`, `id` и необязательный `kind` (только `versions`). Ответ — `{ versions, more }`:
|
|
1749
|
+
последние версии в пределах ответа, от старой к новой; `more: true` — есть и более ранние.
|
|
1750
|
+
|
|
1751
|
+
Отказ инструмента — `isError: true` и `structuredContent` вида `{ notice, error }`, где `error` — `{ code, message }`, а также `hint` (что
|
|
1752
|
+
делать), `similar` (похожие имена классов, ролей, полей, параметров или операторов), `detail` (подробности) и `sqlstate` (код
|
|
1753
|
+
PostgreSQL у ошибки не из letopis), если они есть. Параметр не по
|
|
2082
1754
|
входной схеме инструмента (не тот тип, значение не из перечня, нет обязательного) отклоняет SDK ещё до letopis: тогда
|
|
2083
1755
|
`isError: true` и только текст, без `structuredContent`.
|
|
2084
1756
|
|
|
@@ -2098,7 +1770,7 @@ elicitation (в ревизии протокола 2025 — запросом кл
|
|
|
2098
1770
|
пользователя и устаревшая — отказ `confirmation_required`. Окно подтверждения показывает каноническое имя класса и
|
|
2099
1771
|
id целей (первые 10, дальше — «и ещё K»); алиас и `meta` класса пишет арендатор, поэтому в окно они не попадают. Код
|
|
2100
1772
|
операции, присланный агентом, подтверждением не считается; клиент без elicitation получает отказ
|
|
2101
|
-
`confirmation_required`. Причина каждой записи — `mcp: letopis_write`, её
|
|
1773
|
+
`confirmation_required`. Причина каждой записи — `mcp: letopis_write`, её показывают версии. Классы прав — `rule`,
|
|
2102
1774
|
`Resource`, `member`, `Account`, `Class` и их потомков (правила, членства, описания классов) — `letopis_write` по
|
|
2103
1775
|
умолчанию не пишет, не правит и не удаляет: отказ `acl_denied`. Агент под инъекцией в данных выдал бы постороннему
|
|
2104
1776
|
членство или правило без подтверждения человека. Разрешает это только опция `allowAclWrite: true`.
|
|
@@ -2179,21 +1851,22 @@ const [{ n }] = await db.sql`select count(*)::int as n from "v2.shop".entity whe
|
|
|
2179
1851
|
### Командная строка
|
|
2180
1852
|
|
|
2181
1853
|
```sh
|
|
2182
|
-
letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply]
|
|
2183
|
-
letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>]
|
|
1854
|
+
letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply | --check]
|
|
1855
|
+
letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>] [--system]
|
|
2184
1856
|
letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
|
|
2185
|
-
[--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--
|
|
2186
|
-
letopis
|
|
2187
|
-
letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--batches <N>]
|
|
2188
|
-
letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
|
|
2189
|
-
letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
|
|
2190
|
-
[--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
|
|
1857
|
+
[--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--initial]
|
|
1858
|
+
letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>]
|
|
2191
1859
|
letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
|
|
2192
1860
|
(или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
|
|
2193
1861
|
```
|
|
2194
1862
|
|
|
2195
|
-
`letopis types`
|
|
2196
|
-
|
|
1863
|
+
`letopis types` строит результат по классам сессии, поэтому без действующей сессии (токен истёк, погашен, неверен или
|
|
1864
|
+
не задан, ключ API неверен) выходит с кодом 1 и ошибкой `no_session`, а файл `--out` не трогает.
|
|
1865
|
+
Классы арендатора System — системные и классы сидов пакета (например, `booking`) — `letopis types` выдаёт только с
|
|
1866
|
+
`--system`.
|
|
1867
|
+
|
|
1868
|
+
`letopis sync --check` — тот же сухой прогон, но с кодом 1, если модели расходятся с базой (есть что создать или
|
|
1869
|
+
изменить): шаг CI перед `--apply`. Вместе с `--apply` — справка и код 2.
|
|
2197
1870
|
|
|
2198
1871
|
### Наблюдение
|
|
2199
1872
|
|
|
@@ -2208,7 +1881,7 @@ letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пол
|
|
|
2208
1881
|
С PgBouncer в режиме транзакций `LISTEN` недоступен: `connect({ listen: false })`. Тогда реестр перечитывается через
|
|
2209
1882
|
`db.refresh()`, подписка опрашивает журнал, а кэш результатов выключен или работает по сроку. Подготовленные
|
|
2210
1883
|
операторы работают с PgBouncer 1.21 и новее; со старыми передайте свой пул postgres.js с `prepare: false` в
|
|
2211
|
-
`connect({ sql })`. `maintain()` берёт транзакционную блокировку
|
|
1884
|
+
`connect({ sql })`. `maintain()` берёт транзакционную блокировку и работает за таким пулом.
|
|
2212
1885
|
|
|
2213
1886
|
<!-- run: guide -->
|
|
2214
1887
|
```ts
|
|
@@ -2217,42 +1890,7 @@ await anna.close()
|
|
|
2217
1890
|
await svc.close() // и подключения, полученные через svc.as()
|
|
2218
1891
|
```
|
|
2219
1892
|
|
|
2220
|
-
## 14.
|
|
2221
|
-
|
|
2222
|
-
1.0 — новая реализация, а не продолжение 0.21: схема 0.21 не обновляется на месте. 1.0 ставится в новую схему,
|
|
2223
|
-
данные переносит команда `letopis import`, а код переводится по таблице замен из [MIGRATION.md](MIGRATION.md).
|
|
2224
|
-
Главные отличия в поведении: `create` не перезаписывает (занятый ключ — `exists`), `count()` считает сущности,
|
|
2225
|
-
пути — `paths()`, права и изоляция арендаторов включены всегда.
|
|
2226
|
-
|
|
2227
|
-
```sh
|
|
2228
|
-
npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgres://…/new --schema v2.salon \
|
|
2229
|
-
--tenant salon [--keyless booking] [--rename link=link_] [--update] [--commit-every 100000]
|
|
2230
|
-
```
|
|
2231
|
-
|
|
2232
|
-
- Схема 1.0 ставится заранее (`up()`), оба подключения — администраторские. Источник — таблицы 0.21 (`Schema`,
|
|
2233
|
-
`Entity`, `Account`, `Credential`, `Resource`, `Rule`) любой базы: PostgreSQL 17 с TimescaleDB или дамп обычных
|
|
2234
|
-
таблиц. Пишут загрузчик (режим истории, `existing: 'skip'`, агент `letopis import`) и функции владельца.
|
|
2235
|
-
- Классы становятся строками `Class` в System. Правила fastest-validator переводятся в JSON Schema (`optional`
|
|
2236
|
-
пропускает `null`, `$$strict` → `additionalProperties: false`, `record` → `propertyNames`). Концы — роли по
|
|
2237
|
-
имени класса цели, союз всех потомков класса — полиморфный конец. Корни `Entity` и `link` опускаются;
|
|
2238
|
-
`description`, `appearance`, `order` переходят в `meta`. Имена, занятые методами 1.0, — `--rename` (по умолчанию
|
|
2239
|
-
имя с `_`).
|
|
2240
|
-
- id: у класса с ключом (правило v5 из 0.21) — v5 от ключа, у класса без ключа — v5 от старого id. Данные System
|
|
2241
|
-
переходят в арендатора `--tenant`, аккаунты — в строки `Account`, креды — в `secret` как есть (scrypt, ключи);
|
|
2242
|
-
одноразовые коды не переносятся. Ресурсы и правила — в System.
|
|
2243
|
-
- До первой записи импорт останавливается, если два объекта 0.21 дают один id 1.0 (данные нарушают ключ —
|
|
2244
|
-
`--keyless <Класс>`), если ключ менялся между версиями или ссылается на отсутствующий объект. Висячая ссылка
|
|
2245
|
-
живой строки — `target_not_found` с объектом 0.21.
|
|
2246
|
-
- Если загрузчик отклонил данные, ошибка начинается с `import: арендатор …`, а её `detail` — как у загрузчика
|
|
2247
|
-
([«Отчёт и ошибки»](#отчёт-и-ошибки)), только строки названы объектами 0.21: `rows` — нарушения с `class`, `id`
|
|
2248
|
-
и `rev` объекта 0.21, `code` и `message`; `total`, `truncated`, `limit` и `complete` — как в отчёте загрузчика;
|
|
2249
|
-
`notes` — непереведённые правила.
|
|
2250
|
-
- Повтор дописывает недостающее без дублей, `--update` — ещё и новые версии изменившихся объектов. Отчёт — JSON в
|
|
2251
|
-
конце вывода: объекты и версии по классам в 0.21 и в 1.0, `verify()`, непереведённые правила, классы без ключа и
|
|
2252
|
-
uuid в `data`, совпадающие со старыми id (их импорт не переписывает). Код выхода 1, если `verify()` нашёл
|
|
2253
|
-
расхождения.
|
|
2254
|
-
|
|
2255
|
-
## 15. Справочник API
|
|
1893
|
+
## 14. Справочник API
|
|
2256
1894
|
|
|
2257
1895
|
Полный машинный список экспортов, методов и текстов ошибок — `docs/api-contract.json` в репозитории.
|
|
2258
1896
|
|
|
@@ -2260,12 +1898,10 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2260
1898
|
|
|
2261
1899
|
| Точка входа | Что экспортирует |
|
|
2262
1900
|
|---|---|
|
|
2263
|
-
| `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`;
|
|
2264
|
-
| `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta
|
|
1901
|
+
| `letopis` | `connect`, `loose`, `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`; константы `DDL_REVISION`, `RESERVED_CLASS_NAMES`, `CHAIN_METHODS`, `DB_METHODS`; типы `Db`, `DbTx`, `TypedDb`, `SystemModels`, `Chain`, `Row`, `Filter`, `Cursor`, `ConnectOptions`, `UpOptions`, `LoadOptions`, `LoadReport`, `WatchOptions`, `WatchEvent`, `AuthApi`, `AclApi`, `MaintainReport` и другие |
|
|
1902
|
+
| `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta`; `normalizeSchema`, `translatePattern`, тип `JsonSchema` |
|
|
2265
1903
|
| `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)) |
|
|
2266
|
-
| CLI `letopis` | `sync`, `types`, `load`, `
|
|
2267
|
-
|
|
2268
|
-
Снятые имена 0.21 (`uuidv7`, `LETOPIS_NS` и методы, перечисленные ниже) бросают `removed` с заменой.
|
|
1904
|
+
| CLI `letopis` | `sync`, `types`, `load`, `maintain`, `mcp` ([раздел 13](#13-эксплуатация)); у `mcp` — `--dsn`, `--schema`, `--token` (или `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`) и `--write` |
|
|
2269
1905
|
|
|
2270
1906
|
### Корень `db`
|
|
2271
1907
|
|
|
@@ -2278,15 +1914,11 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2278
1914
|
| `begin()`, `commit(tr)`, `rollback(tr)`, `lock(…ключи)` | явная транзакция ([раздел 8](#8-транзакции-батчи-и-деньги)) |
|
|
2279
1915
|
| `batch(имя)` | очередь планов: `run()`, `discard()`, `size()` |
|
|
2280
1916
|
| `load(вход, опции)` | загрузчик ([раздел 9](#9-массовая-загрузка)) |
|
|
2281
|
-
| `projector(имя, опции)` | проектор из журнала: `run()`, `rebuild({ chunked, tenant })`, `status()`, `stop()`, `close()` ([«Проекторы»](#проекторы)) |
|
|
2282
|
-
| `rebuildAggregates(класс)` | ремонт агрегатов класса и его потомков во всех арендаторах: поле заново из живых источников, версия — только у исправленных; → их число ([«Агрегаты в базе»](#агрегаты-в-базе)) |
|
|
2283
1917
|
| `watch(опции)`, `cache` | подписка; кэш результатов (`clear()`, `stats()`) |
|
|
2284
1918
|
| `` sql`…` ``, `transaction(fn)` | сырой SQL под сессией и RLS |
|
|
2285
1919
|
| `idOf(класс, ключ)` | id объекта класса с ключом в арендаторе сессии |
|
|
2286
1920
|
| `entity(строка \| цепочка)` | начать путь с готового узла |
|
|
2287
|
-
| `
|
|
2288
|
-
| `verify(опции)`, `maintain(опции)`, `reset(опции)` | проверка, обслуживание, сброс ([разделы 11](#11-история-и-срезы-на-дату) и [13](#13-эксплуатация)) |
|
|
2289
|
-
| `trimHistory(цель, момент)`, `rekeyClass(класс, ключ)` | обрезка истории; новый ключ класса |
|
|
1921
|
+
| `maintain()` | обслуживание ([раздел 13](#13-эксплуатация)) |
|
|
2290
1922
|
| `refresh()`, `flushSignals()`, `close()` | перечитать классы; отправить сигналы сейчас; закрыть |
|
|
2291
1923
|
|
|
2292
1924
|
### Цепочка
|
|
@@ -2295,9 +1927,9 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2295
1927
|
|---|---|
|
|
2296
1928
|
| шаги | `.Класс(фильтр)`, `.алиас(фильтр)`, `.Роль(фильтр)` |
|
|
2297
1929
|
| модификаторы | `limit(n)`, `offset(n)`, `sort(поле, 'asc' \| 'desc')`, `after(курсор)`, `asOf(момент)`, `withDeleted()`, `deep(max)`, `exact()`, `alias(имя)`, `tags(…)`, `owner(…)`, `entity(…)`, `forUpdate()`, `cache({ ttl })` |
|
|
2298
|
-
| глаголы | `create(данные)`, `update(патч, { rev })`, `upsert(данные)`, `delete({ confirm })`, `
|
|
1930
|
+
| глаголы | `create(данные)`, `update(патч, { rev, all })`, `upsert(данные)`, `delete({ confirm, all })`, `restore()`; `update` и `delete({ confirm: true })` без фильтра, id, `tags()`, `owner()` и `limit()` у шага глагола и шагов до него (и без `create()` или `upsert()` раньше в цепочке) — только с `{ all: true }` |
|
|
2299
1931
|
| слоты | после глагола: `.Роль.set(значение)`, `.Роль.unset()`, `.Роль.add(значение)`, `.Роль.remove(значение)` |
|
|
2300
|
-
| терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions(
|
|
1932
|
+
| терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions()`, `sum(поле)`, `avg(поле)`, `min(поле)`, `max(поле)`, `countBy(поле)` |
|
|
2301
1933
|
|
|
2302
1934
|
### `db.auth`
|
|
2303
1935
|
|
|
@@ -2323,26 +1955,9 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2323
1955
|
креды — нет; последний действующий ключ сервиса не отзывается никем (`acl_denied`). С `totp` вход `verifyPassword` и
|
|
2324
1956
|
`lookup` проверяет ещё и код TOTP (право `auth.totp`) и выдаёт сессию со способом `TOTP`.
|
|
2325
1957
|
Вид креда `kind` — `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`, `TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`,
|
|
2326
|
-
`OTP`, `TOTP`, `KEY
|
|
1958
|
+
`OTP`, `TOTP`, `KEY` и `LOCK` заняты. То же правило — у вида пароля `user.kind` в `createTenant` и
|
|
2327
1959
|
`up({ tenant })`.
|
|
2328
1960
|
|
|
2329
|
-
### Проектор
|
|
2330
|
-
|
|
2331
|
-
`db.projector(имя, опции)` работает только на администраторском подключении ([«Проекторы»](#проекторы)). Имя —
|
|
2332
|
-
строчные латинские буквы, цифры и `_.:-`, до 64 символов; оно же — `derivedBy` производных классов. Неверные опции —
|
|
2333
|
-
`invalid_data`.
|
|
2334
|
-
|
|
2335
|
-
| Член | Что делает |
|
|
2336
|
-
|---|---|
|
|
2337
|
-
| опции `ProjectorOptions` | `from`, `into` — классы с потомками; `version` — целое от 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` — пачками); `batch` — изменений в пачке (500, от 1 до 10 000); `pollMs` — запасной опрос (1000 мс); `lockTimeout` — ожидание чужой блокировки строки (5000 мс, 0 — без предела); `lockRetries` — проходов подряд до выбывания арендатора с чужой блокировкой (0 — не выбывает); `tenants` — id арендаторов-участников |
|
|
2338
|
-
| `run({ once })` | догнать журнал (`once`) или следовать за ним до `stop()` → `ProjectorReport` |
|
|
2339
|
-
| `rebuild({ chunked, tenant })` | перестроить с текущего состояния: атомарно, пачками или одного арендатора → `ProjectorReport` |
|
|
2340
|
-
| `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` — `{ [id арендатора]: ProjectorFailure }`, где `ProjectorFailure` — `{ cursor, code, message }` |
|
|
2341
|
-
| `stop()`, `close()` | остановить `run()` после текущей пачки; то же и отписаться от сигналов |
|
|
2342
|
-
| `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds, locked }` |
|
|
2343
|
-
| `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` и, если есть, `agent`, `reason`, `moved`, `before`, `rebuild` |
|
|
2344
|
-
| `ProjectorOut` | `upsert(класс, строка, { tenant })`, `inc(класс, строка, приращения, { tenant })`, `delete(класс, ключ или id, { tenant })`, `get(класс, ключ или id, { tenant })` → `Row \| null`; строка — `ProjectorRow` в формате загрузчика; у класса с ключом `delete` и `get` — по ключу, id — только у класса без ключа |
|
|
2345
|
-
|
|
2346
1961
|
### Строка ответа
|
|
2347
1962
|
|
|
2348
1963
|
| Поле | Что значит |
|
|
@@ -2351,147 +1966,137 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2351
1966
|
| `tenant`, `owner` | арендатор и владелец строки |
|
|
2352
1967
|
| `links`, `data`, `tags` | концы, поля, теги |
|
|
2353
1968
|
| `at`, `author`, `agent`, `op`, `reason` | время версии (ISO, UTC, микросекунды), автор, агент (сервис при имперсонации), вид операции, причина |
|
|
2354
|
-
| `moved` | связь со старым или новым id после `reclass` и `rekey` |
|
|
2355
1969
|
| `$deleted` | надгробие: удалённая сущность или версия удаления |
|
|
2356
1970
|
| `$depth` | глубина узла в `deep()`; уровень каскада в превью удаления |
|
|
2357
|
-
| `$upsert`, `$action
|
|
1971
|
+
| `$upsert`, `$action` | что сделал `upsert`; что сделает удаление |
|
|
2358
1972
|
|
|
2359
1973
|
### Зарезервированные имена
|
|
2360
1974
|
|
|
2361
|
-
Имя метода закрывает класс
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
1975
|
+
Имя метода закрывает класс там, где этот метод есть: после шага — методы цепочки, у корня `db` — его методы, у
|
|
1976
|
+
батча — его. Поэтому класс с любым из этих имён не создать: хаб отклоняют `hub()`, `syncModels` и база, связь — база
|
|
1977
|
+
(`invalid_class`; `reserved_names()`, список подставляет установщик из `RESERVED_CLASS_NAMES`). Хаб, заведённый мимо
|
|
1978
|
+
проверки базы, `up()` оставляет и называет в предупреждении: с именем метода только цепочки (например, `count`) он
|
|
1979
|
+
доступен первым шагом `db.count(…)`, а после другого шага недостижим.
|
|
2365
1980
|
|
|
2366
1981
|
- методы цепочки: `then`, `entity`, `paths`, `rows`, `first`, `ids`, `count`, `limit`, `offset`, `sort`, `asOf`,
|
|
2367
1982
|
`withDeleted`, `deep`, `exact`, `sum`, `avg`, `min`, `max`, `countBy`, `after`, `versions`, `create`, `update`,
|
|
2368
|
-
`upsert`, `set`, `unset`, `add`, `remove`, `delete`, `
|
|
2369
|
-
`owner`, `forUpdate`, `cache`, `rekey`, `diff`, `blame`; охранники снесённого API — `run`, `execute`, `account`;
|
|
1983
|
+
`upsert`, `set`, `unset`, `add`, `remove`, `delete`, `restore`, `alias`, `tags`, `owner`, `forUpdate`, `cache`;
|
|
2370
1984
|
- корень `db`: `as`, `begin`, `commit`, `rollback`, `lock`, `batch`, `watch`, `close`, `registry`, `sql`, `load`,
|
|
2371
|
-
`
|
|
2372
|
-
|
|
2373
|
-
`accounts`, `credentials`, `resources`, `rules`, `compact`;
|
|
2374
|
-
- батч: `discard`, `size`.
|
|
1985
|
+
`maintain`, `auth`, `acl`, `idOf`, `refresh`, `transaction`, `flushSignals`, `schema`, `token`, `tenant`, `cache`;
|
|
1986
|
+
- батч: `run`, `discard`, `size`.
|
|
2375
1987
|
|
|
2376
|
-
##
|
|
1988
|
+
## 15. Ошибки
|
|
2377
1989
|
|
|
2378
1990
|
Текст ошибки — `letopis: <код>: <сообщение>`. Код — в `err.code`, подробности — в `err.detail`. Ошибки — объекты
|
|
2379
|
-
`LetopisError`, у ошибок данных — `ValidationError` с `err.issues: [{ path, keyword, message }]`.
|
|
1991
|
+
`LetopisError`, у ошибок данных — `ValidationError` с `err.issues: [{ path, keyword, message }]`. Ещё у ошибки есть:
|
|
1992
|
+
|
|
1993
|
+
- `err.hint` — что делать: подсказка из каталога сообщений библиотеки, своя у места или общая у кода;
|
|
1994
|
+
- `err.docs` — ссылка на строку кода в этом разделе README на GitHub, по тегу версии пакета;
|
|
1995
|
+
- `err.key` и `err.params` — ключ каталога сообщений и параметры текста. Они есть у ошибок, собранных по ключу каталога:
|
|
1996
|
+
у мест библиотеки и у ошибок базы, которая прислала ключ в `DETAIL`.
|
|
1997
|
+
|
|
1998
|
+
Тексты ошибок — только на русском. Ошибку базы с ключом библиотека отдаёт с тем же текстом, что получает и сырой SQL
|
|
1999
|
+
мимо библиотеки, а ключ и параметры из `DETAIL` переносит в `err.key` и `err.params`.
|
|
2000
|
+
|
|
2001
|
+
```ts
|
|
2002
|
+
const db = await connect({ dsn, schema: 'v2.shop', token })
|
|
2003
|
+
try {
|
|
2004
|
+
await db.Shpo().rows()
|
|
2005
|
+
} catch (e) {
|
|
2006
|
+
// e.code — 'invalid_class', e.params.similar — ['Shop']
|
|
2007
|
+
// e.message — 'letopis: invalid_class: класс или роль Shpo не найдены — похожие: Shop'
|
|
2008
|
+
// e.hint — что делать; e.docs — https://github.com/alepri51/letopis/blob/v<версия>/lib/README.md#invalid_class
|
|
2009
|
+
}
|
|
2010
|
+
```
|
|
2011
|
+
|
|
2012
|
+
Похожие имена. Неизвестный класс или роль в цепочке — ошибка `invalid_class`, неизвестное поле в фильтре, `sort()` или
|
|
2013
|
+
агрегате — ошибка `invalid_query`, неизвестная опция `connect()`, `up()`, `db.load()`, `db.watch()` и
|
|
2014
|
+
`createMcpServer()` — ошибка `invalid_data`: похожие имена — в тексте и в `err.params.similar`.
|
|
2015
|
+
|
|
2016
|
+
```ts
|
|
2017
|
+
try {
|
|
2018
|
+
await db.Shop({ ctiy: 'Юг' }).rows()
|
|
2019
|
+
} catch (e) {
|
|
2020
|
+
// e.code — 'invalid_query', e.key — 'query.unknown_field', e.params.similar — ['city']
|
|
2021
|
+
// e.message — 'letopis: invalid_query: Shop({ ctiy }): поле ctiy не описано в классе Shop — похожие: city'
|
|
2022
|
+
}
|
|
2023
|
+
```
|
|
2380
2024
|
|
|
2381
2025
|
Коды базы. Их получает и сырой SQL: SQLSTATE класса `LT`, подробности — JSON в `DETAIL`.
|
|
2382
2026
|
|
|
2383
2027
|
| Код | SQLSTATE | Когда |
|
|
2384
2028
|
|---|---|---|
|
|
2385
|
-
| `no_session
|
|
2386
|
-
| `target_not_found
|
|
2387
|
-
| `invalid_data
|
|
2388
|
-
| `undeclared_end
|
|
2389
|
-
| `tenant_denied
|
|
2390
|
-
| `acl_denied
|
|
2391
|
-
| `id_mismatch`, `id_from_db
|
|
2392
|
-
| `immutable_key
|
|
2393
|
-
| `invalid_class
|
|
2394
|
-
| `
|
|
2395
|
-
| `
|
|
2396
|
-
| `
|
|
2397
|
-
| `
|
|
2398
|
-
| `
|
|
2399
|
-
| `delete_restricted` | LT017 | удалению мешают ссылающиеся строки (`onDelete: 'restrict'`) |
|
|
2400
|
-
| `not_deleted` | LT018 | `restore` и `purge` — только для удалённых |
|
|
2401
|
-
| `reset_disabled` | LT019 | сброс выключен в установке (`allowReset`) |
|
|
2402
|
-
| `confirm_mismatch` | LT020 | подтверждение сброса не совпадает с именем схемы |
|
|
2403
|
-
| `derived_class` | LT021 | строку производного класса (`derivedBy`) пишет, правит или удаляет не его проектор; конец ведёт на такую строку или на производный класс; проектор пишет в класс вне своих `into` или не нашёл ни одного арендатора, у которого все классы `into` помечены его `derivedBy` |
|
|
2404
|
-
| `aggregate_field` | LT022 | приложение передало или изменило агрегатное поле (`aggregate`): его считает база |
|
|
2405
|
-
| `projector_pending` | LT023 | `purge` удалённого объекта, чьё удаление ещё не прочёл проектор его класса, или `trimHistory`, чью первую оставшуюся версию он ещё не прочёл: сначала `run()` проектора |
|
|
2029
|
+
| `no_session`<a id="no_session"></a> | LT001 | нет действующей сессии |
|
|
2030
|
+
| `target_not_found`<a id="target_not_found"></a> | LT002 | цель не найдена — одинаково для несуществующей, чужой и невидимой |
|
|
2031
|
+
| `invalid_data`<a id="invalid_data"></a> | LT003 | данные не проходят описание класса; `err.issues` — путь, ключевое слово, сообщение. У библиотеки ещё — неизвестная опция `connect()`, `up()`, `db.load()`, `db.watch()` и `createMcpServer()` (похожие — в `err.params.similar`) и неверное значение опции |
|
|
2032
|
+
| `undeclared_end`<a id="undeclared_end"></a> | LT004 | конец не объявлен в классе |
|
|
2033
|
+
| `tenant_denied`<a id="tenant_denied"></a> | LT005 | чужой арендатор или нет членства |
|
|
2034
|
+
| `acl_denied`<a id="acl_denied"></a> | LT006 | отказ по правилам доступа |
|
|
2035
|
+
| `id_mismatch`, `id_from_db`<a id="id_mismatch"></a><a id="id_from_db"></a> | LT007, LT008 | id не равен v5 от ключа; id класса без ключа выдаёт база |
|
|
2036
|
+
| `immutable_key`<a id="immutable_key"></a> | LT009 | ключевое поле, класс, id, арендатор и владелец не меняются: другой ключ или класс — новый объект |
|
|
2037
|
+
| `invalid_class`<a id="invalid_class"></a> | LT010 | описание класса отклонено, класс не найден или абстрактный; класс со строками, потомками или историей строк не удаляется; хаб или связь с зарезервированным именем (хаб отклоняют ещё `hub()` и `syncModels`) |
|
|
2038
|
+
| `tightening_conflict`<a id="tightening_conflict"></a> | LT013 | ужесточению описания мешают строки |
|
|
2039
|
+
| `isolation_level`<a id="isolation_level"></a> | LT014 | удаление — только в `read committed` |
|
|
2040
|
+
| `recreated_in_statement`<a id="recreated_in_statement"></a> | LT015 | id удалён и создан заново одним оператором |
|
|
2041
|
+
| `delete_restricted`<a id="delete_restricted"></a> | LT017 | удалению мешают ссылающиеся строки (`onDelete: 'restrict'`) |
|
|
2042
|
+
| `not_deleted`<a id="not_deleted"></a> | LT018 | `restore` — только для удалённых |
|
|
2406
2043
|
|
|
2407
2044
|
Коды библиотеки:
|
|
2408
2045
|
|
|
2409
2046
|
| Код | Когда |
|
|
2410
2047
|
|---|---|
|
|
2411
|
-
| `exists
|
|
2412
|
-
| `duplicate`, `deleted
|
|
2413
|
-
| `conflict
|
|
2414
|
-
| `rev_ambiguous
|
|
2415
|
-
| `tx_required
|
|
2416
|
-
| `lock_unsupported
|
|
2417
|
-
| `commit_unknown
|
|
2418
|
-
| `tx_rolled_back
|
|
2419
|
-
| `no_key
|
|
2420
|
-
| `no_path
|
|
2421
|
-
| `invalid_query
|
|
2422
|
-
| `admin_required
|
|
2423
|
-
| `
|
|
2424
|
-
| `
|
|
2425
|
-
| `
|
|
2426
|
-
| `
|
|
2427
|
-
| `
|
|
2428
|
-
| `
|
|
2429
|
-
| `confirmation_required` | `letopis_write` (MCP): удаление без подтверждения человека — клиент без elicitation, человек отказал, ответ не к этой операции или данные изменились после превью |
|
|
2430
|
-
| `removed` | снесённое API 0.21; в тексте — замена |
|
|
2048
|
+
| `exists`<a id="exists"></a> | объект уже существует: `create` (`detail.id` — его id) или загрузчик |
|
|
2049
|
+
| `duplicate`, `deleted`<a id="duplicate"></a><a id="deleted"></a> | загрузчик: повтор id в загрузке; объект с этим id жил раньше. Загрузчик бросает код первой проблемной строки, первые 100 — в `detail.rows`, всего — `detail.total` |
|
|
2050
|
+
| `conflict`<a id="conflict"></a> | `update(патч, { rev })`: строку успели изменить (`detail`: `id`, `rev`, `expected`) |
|
|
2051
|
+
| `rev_ambiguous`<a id="rev_ambiguous"></a> | `{ rev }` у шага, у которого не одна цель |
|
|
2052
|
+
| `tx_required`<a id="tx_required"></a> | `forUpdate()`, `lock()`, `commit()` или `rollback()` вне действующей `db.begin()` |
|
|
2053
|
+
| `lock_unsupported`<a id="lock_unsupported"></a> | `forUpdate()` с агрегатом, `asOf`, `versions`, `withDeleted`, `deep` или глаголом |
|
|
2054
|
+
| `commit_unknown`<a id="commit_unknown"></a> | связь оборвалась во время `COMMIT`: исход неизвестен |
|
|
2055
|
+
| `tx_rolled_back`<a id="tx_rolled_back"></a> | PostgreSQL ответил `ROLLBACK` на `COMMIT`: ошибку перехватили внутри `tr.transaction(fn)` |
|
|
2056
|
+
| `no_key`<a id="no_key"></a> | `upsert` или `idOf` у класса без ключа |
|
|
2057
|
+
| `no_path`<a id="no_path"></a> | между классами соседних шагов нет перехода |
|
|
2058
|
+
| `invalid_query`<a id="invalid_query"></a> | цепочку или API используют неверно (поле фильтра, `sort()` или агрегата не описано ни в одном классе шага — похожие в `err.params.similar`; `update()` или `delete({ confirm: true })` без фильтра, id, `tags()`, `owner()` и `limit()` у шагов и без `{ all: true }`, `after()` перед `count()` и агрегатами, модификатор шага `deep`, `exact`, `alias`, `tags` или `owner` после глагола, модификатор выборки между глаголом и терминалом `rows`, `first`, `ids`, `count` или `paths`, слот не после глагола, курсор `after()` со значением не того типа, что поле `sort`, без `v` или с `id` не uuid и т. п.) |
|
|
2059
|
+
| `admin_required`<a id="admin_required"></a> | загрузчику нужно администраторское подключение; роль строки `connect({ admin })` не может стать владельцем схемы; `up()` от роли без ADMIN OPTION на уже существующую роль владельца |
|
|
2060
|
+
| `not_empty`<a id="not_empty"></a> | первая заливка (`initial`) — только в пустую схему |
|
|
2061
|
+
| `class_not_loadable`<a id="class_not_loadable"></a> | строки `Class` загрузчик не принимает |
|
|
2062
|
+
| `bypass_rls`<a id="bypass_rls"></a> | `connect()` с ролью, которая обходит RLS, без `allowBypassRls`; `createMcpServer` с подключением в обход RLS |
|
|
2063
|
+
| `user_session_required`<a id="user_session_required"></a> | `createMcpServer` и `letopis mcp`: нужна сессия пользователя — у подключения нет сессии, это корень сервиса без `as()`, сессия в арендаторе System без `allowSystemAdmin`, в командной строке ключ сервиса или нет токена |
|
|
2064
|
+
| `confirmation_required`<a id="confirmation_required"></a> | `letopis_write` (MCP): удаление без подтверждения человека — клиент без elicitation, человек отказал, ответ не к этой операции или данные изменились после превью |
|
|
2065
|
+
| `internal`<a id="internal"></a> | внутренняя ошибка letopis: база не создала или не нашла свою строку-замок (из базы — SQLSTATE `XX000`, ключ в `DETAIL`); сообщите о ней с текстом, кодом и версией пакета |
|
|
2431
2066
|
|
|
2432
2067
|
Отказ PostgreSQL в праве (`42501`) приходит, например, при `upsert` поверх чужой строки, которую нельзя править.
|
|
2433
2068
|
|
|
2434
|
-
##
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
| Замер | 1.0 | 0.21 | Единица | Примечание |
|
|
2442
|
-
|---|---|---|---|---|
|
|
2443
|
-
| загрузка полигона salon | 6359 | 3609 | строк/с | 1.0 — загрузчик в режиме истории с проверками; 0.21 — сырые INSERT в Entity |
|
|
2444
|
-
| загрузка полигона tenants | 8566 | 35186 | строк/с | 0.21 — INSERT … SELECT с выключенными триггерами |
|
|
2445
|
-
| построчный create | 128 | 156 | строк/с | |
|
|
2446
|
-
| чтение по ключу | 2.2 | 3.81 | мс (p50) | |
|
|
2447
|
-
| цепочка из трёх шагов (count) | 222.79 | 1233.83 | мс (p50) | организация → мастера → записи |
|
|
2448
|
-
| запрос на дату (asOf) | 202.77 | 327.98 | мс (p50) | записи мастера на середину года |
|
|
2449
|
-
| 20 параллельных писателей | 982 | 522 | строк/с | |
|
|
2450
|
-
| смена класса (reclass) | 9.5 | — | мс (p50) | в 0.21 глагола нет |
|
|
2451
|
-
| count() записей: арендатор под RLS и правами | 272.25 | 890.73 | мс (p50) | 0.21 — без изоляции и прав (enforceAccount: false); 1.0 без RLS (администратор): 18.59 мс |
|
|
2452
|
-
| count() клиентов арендатора 1 (50.0%) | 114.09 | 667.19 | мс (p50) | |
|
|
2453
|
-
| count() клиентов арендатора 3 (10.0%) | 23.8 | 229.94 | мс (p50) | |
|
|
2454
|
-
| count() клиентов арендатора 8 (1.0%) | 5.3 | 88.02 | мс (p50) | |
|
|
2455
|
-
| count() клиентов арендатора 10 (0.3%) | 3.54 | 26.08 | мс (p50) | |
|
|
2456
|
-
|
|
2457
|
-
Полный импорт полигона salon из 0.21 (`npm run test:import-salon`): 539 580 объектов и 980 180 версий за 193 с
|
|
2458
|
-
(5 084 строки/с), счётчики по классам совпали, `verify()` чист. Доводка точки Б (ревизия 5): индекс журнала
|
|
2459
|
-
по целям ссылок `log_ends` ускорил срез `asOf` по переходу в 16 раз (3,37 с → 0,20 с) и стоит загрузчику
|
|
2460
|
-
15–17 % скорости; чтение `letopis.changed` и `COMMIT` одним конвейером сняло круг до базы с каждой записи (построчная
|
|
2461
|
-
запись: 119 → 128–147 строк/с в разных прогонах). Построчный `create` остаётся медленнее 0.21 на 12–18 % (замеры
|
|
2462
|
-
колеблются): основное время — триггеры проверок в базе и фиксация с записью на диск.
|
|
2463
|
-
|
|
2464
|
-
Первая заливка (`cd lib && npx tsx bench/initial-load.mjs`, 2026-10-11, медиана трёх кругов): 1,07 млн объектов
|
|
2465
|
-
полигона salon без истории обычный загрузчик пишет за 169 с (6 351 строка/с, WAL 4,0 ГБ), первая заливка — за 77 с
|
|
2466
|
-
(13 902 строки/с, WAL 1,4 ГБ). Главную разницу дают индексы GIN: поддерживать их во время загрузки стоит 36 с, построить
|
|
2467
|
-
в конце — около 5 с. Подготовка строк в самом загрузчике (проверки, концы, id) идёт одновременно с `COPY` предыдущей
|
|
2468
|
-
пачки и теперь задаёт скорость: около 60 с из 77.
|
|
2469
|
-
|
|
2470
|
-
Производные данные 1.2 (`cd lib && npx tsx bench/derived.bench.mjs`, 2026-10-27, PostgreSQL 18.6, одна машина; у
|
|
2471
|
-
агрегатов — медиана трёх кругов; строки проектора — повтор серий 2 и 3 после свёртки `inc` в `out`,
|
|
2472
|
-
`BENCH_SERIES=2,3`):
|
|
2473
|
-
|
|
2474
|
-
| Замер | 1.2 | Для сравнения |
|
|
2069
|
+
## 16. Производительность
|
|
2070
|
+
|
|
2071
|
+
Замеры — сценарии `lib/bench/` (как запускать, написано в шапке каждого файла), итоги — `lib/bench/results/`. Ниже —
|
|
2072
|
+
серии `core-*` (замеры 8 октября 2026 года: PostgreSQL 18.6 в Docker, `shared_buffers` 128 МБ, Node.js 24) и
|
|
2073
|
+
чтение цепочкой `chain-read` на тех же данных, что у `core-read`. Цифры зависят от машины; важно соотношение.
|
|
2074
|
+
|
|
2075
|
+
| Что меряли | Серия | Итог |
|
|
2475
2076
|
|---|---|---|
|
|
2476
|
-
|
|
|
2477
|
-
|
|
|
2478
|
-
|
|
|
2479
|
-
|
|
|
2480
|
-
|
|
|
2481
|
-
|
|
|
2077
|
+
| Вставка через триггеры по строке, транзакция на строку | `core-write`, 10 000 строк | около 170 строк в секунду; без ожидания записи на диск при фиксации (`synchronous_commit = off`) — около 470 |
|
|
2078
|
+
| Та же вставка пачками по 1 000 строк в одном операторе (так склеивает подряд идущие `create` батч) | `core-write`, 10 000 строк | около 1 350 строк в секунду: 731 мкс на строку, из них проверка концов — около 330, журнал — 150, сигнал — 90, проверка данных — 45, RLS — меньше 10; та же вставка без триггеров — 99 мкс |
|
|
2079
|
+
| `COPY` от роли владельца и проверка концов одним запросом в конце (прототип загрузчика) | `core-write`, `core-load` | 10 000 строк — около 14 000 в секунду; миллион — за 92 с (около 10 800 в секунду) против 75 с у `COPY` в таблицы без триггеров; запрос по целям в конце — 0,1 с при 1 000 целей и 1,2 с при 100 000 |
|
|
2080
|
+
| Горячая строка: операция и `inc` итога одной транзакцией | `core-concurrency`, замер 11 | одно соединение — около 150 операций в секунду, 5 — 325, 20 — 230 (95-й процентиль — 290 мс), 50 — 154 (около 1 с); итог через `{ rev }` с повторами при 20 соединениях — 72 (23 тыс. повторов), через блокировку строки — 144 |
|
|
2081
|
+
| Двадцать писателей строк со ссылкой на одну цель | `core-concurrency`, замер 2; `core-notify` | с `NOTIFY` в транзакции записи — около 580 строк в секунду, без него — 2 900: фиксации с `NOTIFY` идут по одной. Сигналы отдельной транзакцией (так их шлёт библиотека, [«Сигналы»](#сигналы)) — 2 800 |
|
|
2082
|
+
| Чтение цепочкой из трёх шагов с фильтром по `data` под RLS: два арендатора, у каждого 1 000 организаций, 100 тыс. мастеров и миллион смен | `chain-read` | узкий запрос (143 строки) — 24 мс, владелец без RLS — 19 мс; широкий (14 285 строк) — 3,1 с против 2,9 с |
|
|
2083
|
+
| Срез `asOf` по журналу | `core-read`, замер 7 | 51 мс под RLS, 50 мс у владельца |
|
|
2084
|
+
| Каскадное удаление организации с мастерами одним `delete` | `core-delete` | 100 тыс. строк — 23 с, миллион — 247 с (около 240 мкс на строку); временные файлы — 80 и 800 МБ |
|
|
2085
|
+
| id v5 против последовательных | `core-ids`, 10 млн строк | v5 ложатся в индекс вразброс: вставка в 3,4 раза медленнее (135 тыс. строк в секунду против 465 тыс.), индекс на 27 % и WAL на 39 % больше; вычисление v5 в базе — 11 мкс |
|
|
2086
|
+
| Проверка данных в базе (jsonpath) | `core-validate`, 100 тыс. строк | 35 мкс на строку у плоского описания из пяти полей, 113 мкс — у вложенного глубины 4; у модели, где регулярных выражений больше 32, — 1,6 мс (PostgreSQL держит в памяти процесса 32 скомпилированных выражения) |
|
|
2482
2087
|
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2088
|
+
Построчная запись дороже простой вставки: основное время — проверки в базе и фиксация с записью на диск. Поэтому
|
|
2089
|
+
подряд идущие `create` склеивает батч ([раздел 8](#батч)), массовую запись ведёт загрузчик, а первая заливка в пустую
|
|
2090
|
+
схему строит индексы, ограничения и триггеры разом в конце ([раздел 9](#9-массовая-загрузка)).
|
|
2486
2091
|
|
|
2487
|
-
##
|
|
2092
|
+
## 17. Ограничения
|
|
2488
2093
|
|
|
2489
|
-
Здесь собрано то, что letopis
|
|
2094
|
+
Здесь собрано то, что letopis 2.0 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
|
|
2490
2095
|
|
|
2491
2096
|
**Платформа**
|
|
2492
2097
|
|
|
2493
2098
|
- Нужен PostgreSQL 18 или новее; обходных путей для 15–17 нет. AWS RDS, Google Cloud SQL, Azure Flexible Server
|
|
2494
|
-
и Neon поддерживают 18, а Supabase на октябрь 2026 года — только 15 и 17, поэтому там
|
|
2099
|
+
и Neon поддерживают 18, а Supabase на октябрь 2026 года — только 15 и 17, поэтому там letopis не заработает.
|
|
2495
2100
|
- База — в кодировке UTF8 (`casefold()` и коллация `pg_unicode_fast`). Если новая основная версия PostgreSQL
|
|
2496
2101
|
обновит таблицы Unicode, уникальный индекс идентификаторов кредов перестраивается командой `reindex`.
|
|
2497
2102
|
- PGlite — одно соединение суперпользователя: без параллельных писателей и `LISTEN`, RLS не действует
|
|
@@ -2507,25 +2112,25 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2507
2112
|
|
|
2508
2113
|
**Запись и целостность**
|
|
2509
2114
|
|
|
2510
|
-
- Построчная запись дороже простой вставки: каждую строку проверяют триггеры (данные, концы, id, журнал,
|
|
2511
|
-
|
|
2512
|
-
загрузчик.
|
|
2513
|
-
-
|
|
2514
|
-
|
|
2115
|
+
- Построчная запись дороже простой вставки: каждую строку проверяют триггеры (данные, концы, id, журнал, RLS).
|
|
2116
|
+
Построчная вставка через триггеры, транзакция на строку, — около 170 строк в секунду
|
|
2117
|
+
([раздел 16](#16-производительность)); подряд идущие `create` склеивает батч, для массовой записи есть загрузчик.
|
|
2118
|
+
- Удаление работает только в `read committed` (`isolation_level`); транзакции библиотеки всегда открываются на этом
|
|
2119
|
+
уровне.
|
|
2515
2120
|
- `COPY` в таблицы под RLS PostgreSQL не выполняет; массовая загрузка — только загрузчиком на администраторском
|
|
2516
2121
|
подключении.
|
|
2517
2122
|
- Первая заливка закрывает таблицы схемы на всё время загрузки — и для записи, и для чтения: она для пустой схемы
|
|
2518
|
-
(засевка,
|
|
2123
|
+
(засевка, перенос данных), а не для работающей системы. Выигрыш у неё — в базе (индексы и ограничения строятся
|
|
2519
2124
|
один раз); подготовку строк в загрузчике она не ускоряет, а только ведёт одновременно с `COPY`, поэтому на миллионе
|
|
2520
2125
|
объектов она быстрее обычной загрузки примерно в 2,2 раза.
|
|
2521
2126
|
- Загрузчику база доверяет: данные, концы и id загруженных строк проверяет код библиотеки (валидатор на TypeScript),
|
|
2522
|
-
а не триггеры; база сверяет только
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
веб-приложение: SQL-инъекция в нём получила бы права владельца.
|
|
2526
|
-
- Владелец схемы и суперпользователь могут отключить триггеры и изменить что
|
|
2527
|
-
|
|
2528
|
-
|
|
2127
|
+
а не триггеры; база сверяет только шаблоны `pattern` — запросом на пачку до записи — и цели вне загрузки,
|
|
2128
|
+
арендаторов и владельцев одним запросом в конце.
|
|
2129
|
+
Защита — загрузчик только у администратора и дифференциальные тесты валидатора против базы. Администраторское
|
|
2130
|
+
подключение держит отдельный процесс загрузки, а не веб-приложение: SQL-инъекция в нём получила бы права владельца.
|
|
2131
|
+
- Владелец схемы и суперпользователь могут отключить триггеры и изменить что угодно, в том числе журнал: letopis
|
|
2132
|
+
этого не предотвращает и не обнаруживает. Журналу можно верить, пока роль владельца и суперпользователь — вне
|
|
2133
|
+
приложения.
|
|
2529
2134
|
- В базе проверяется подмножество JSON Schema: `refine`, `superRefine` и `transform` Zod работают только
|
|
2530
2135
|
в библиотеке, описание с неизвестным ключевым словом база не примет; `pattern` — ограниченное подмножество
|
|
2531
2136
|
регулярных выражений, общее для Zod, валидатора и базы. Вывод `z.toJSONSchema()` меняется между версиями Zod:
|
|
@@ -2533,24 +2138,29 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2533
2138
|
- Ключ — единственный способ уникальности: id вычисляется из значений ключа без нормализации, поэтому одно
|
|
2534
2139
|
значение в разной записи (email в разном регистре, время с миллисекундами и без) даёт разные id. Формат
|
|
2535
2140
|
ключевого поля задаёт модель, и база его проверяет.
|
|
2536
|
-
-
|
|
2537
|
-
в отдельный хаб со связью: id из email подбирается перебором и остаётся в ссылках и
|
|
2538
|
-
|
|
2141
|
+
- Ключ и класс объекта неизменны: смена значения ключа (email, телефон) — новый объект. Изменчивые и персональные
|
|
2142
|
+
значения лучше выносить в отдельный хаб со связью: id из email подбирается перебором и остаётся в ссылках и
|
|
2143
|
+
журнале, а версии журнала не стираются (кроме удаления аккаунта вместе с его арендатором).
|
|
2539
2144
|
- `create` занятого ключа отвечает `exists` с id, `upsert` без права на существующую строку — отказом `42501`:
|
|
2540
2145
|
так раскрывается, что ключ занят, но только внутри своего арендатора — у других арендаторов те же ключи дают
|
|
2541
2146
|
другие id.
|
|
2542
|
-
-
|
|
2543
|
-
поэтому её цена пропорциональна числу ссылок. Ужесточение описания класса и `rekeyClass` на время проверки или
|
|
2544
|
-
переноса приостанавливают запись в семейство.
|
|
2545
|
-
- `rekeyClass` переносит строки одной транзакцией под исключительной блокировкой семейства, а не пачками:
|
|
2546
|
-
между пачками строки со старыми id нарушали бы правило «id — v5 от ключа».
|
|
2147
|
+
- Ужесточение описания класса на время проверки строк приостанавливает запись в семейство.
|
|
2547
2148
|
- id v5 ложатся в индекс первичного ключа вразброс; на очень больших таблицах с интенсивной вставкой это заметнее,
|
|
2548
2149
|
чем с последовательными id.
|
|
2549
2150
|
- Горячие цели ссылок (организация, на которую ссылается почти всё) копят блокировки `for key share` и MultiXact,
|
|
2550
2151
|
как при обычных внешних ключах; это нужно мониторить. Операции одного кошелька идут друг за другом под
|
|
2551
2152
|
блокировкой его строки ([раздел 8](#8-транзакции-батчи-и-деньги), «Рецепт: кошелёк»).
|
|
2552
|
-
-
|
|
2553
|
-
|
|
2153
|
+
- Класс не может называться зарезервированным именем ([«Зарезервированные имена»](#зарезервированные-имена)): хаб
|
|
2154
|
+
отклоняют `hub()`, `syncModels` и база, связь — база. Хаб, заведённый мимо проверки базы, доступен не везде:
|
|
2155
|
+
например, `count` — только первым шагом.
|
|
2156
|
+
- Строгие типы есть только у подключения с моделями (`TypedDb`): `db.as()`, `db.begin()`, `db.batch()`, `entity()` и
|
|
2157
|
+
`loose(db)` отдают цепочки без них. Неизвестное поле в фильтре, `sort()` или агрегате у них ловит проверка во время
|
|
2158
|
+
выполнения — `invalid_query` до запроса ([«Строгий режим»](#строгий-режим)), но только по первому ключу пути: опечатку
|
|
2159
|
+
внутри поля-объекта (`{ address: { ctiy } }`) не ловит ничто, условие просто ничего не найдёт.
|
|
2160
|
+
- Поля сверяются с описаниями классов в реестре подключения. Поле, которое другой процесс только что добавил в
|
|
2161
|
+
описание, до обновления реестра (сигнал изменения класса или `db.refresh()`) — тоже `invalid_query`, как и новый
|
|
2162
|
+
класс. Срезы `asOf()`, `withDeleted()` и `versions()` сверяются с нынешним описанием: поле, которое из описания
|
|
2163
|
+
убрали, в фильтре старых версий — отказ.
|
|
2554
2164
|
|
|
2555
2165
|
**Доступ**
|
|
2556
2166
|
|
|
@@ -2567,9 +2177,9 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2567
2177
|
- Самостоятельная привязка занятого идентификатора отвечает `acl_denied` — пользователь с недавним входом так
|
|
2568
2178
|
узнаёт, что идентификатор занят. Изменения прав во всех арендаторах упорядочены одной строкой счётчика.
|
|
2569
2179
|
- Тексты отказов называют класс, роль и id ссылающейся строки, только если актор её видит: каскад удаления, снятие
|
|
2570
|
-
конца,
|
|
2571
|
-
|
|
2572
|
-
- Ужесточение описания класса
|
|
2180
|
+
конца, конфликт смены `parent` (`tightening_conflict`). Число невидимых строк, мешающих удалению, и существование
|
|
2181
|
+
аккаунта по id (как цели ссылки) не скрываются.
|
|
2182
|
+
- Ужесточение описания класса проверяет все строки семейства, поэтому требует права `READ` без условий
|
|
2573
2183
|
на все строки класса и его потомков: иначе пробное ужесточение (`maximum`, `enum`, `pattern`) отвечало бы числом
|
|
2574
2184
|
невидимых строк, которые ему не соответствуют. Системный администратор, ужесточая класс System, получает число
|
|
2575
2185
|
нарушающих строк других арендаторов (`others`): это уровень установки.
|
|
@@ -2577,26 +2187,24 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2577
2187
|
- Прикладная роль может замедлить установку для всех: `LOCK TABLE entity`, advisory-блокировки с ключами `lock()`
|
|
2578
2188
|
приложения, поддельные сигналы `NOTIFY` (лишь сбрасывают кэш), частая смена прав (одна строка счётчика на
|
|
2579
2189
|
установку). По глобальному номеру журнала видна общая активность записи. Внутренние блокировки — семейства
|
|
2580
|
-
|
|
2190
|
+
классов и обслуживания — строки таблицы `secret`, а не advisory: ни прикладная, ни посторонняя роль базы
|
|
2581
2191
|
их не займёт. Писатель семейства проходит шлюз ужесточения в подтранзакции — одной на первую запись в семейство за
|
|
2582
2192
|
транзакцию.
|
|
2583
2193
|
|
|
2584
2194
|
**История, сигналы, кэш**
|
|
2585
2195
|
|
|
2586
|
-
- Срез `asOf` восстанавливается из журнала; дёшево только текущее состояние.
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
- Журнал без партиций: прореживание удаляет версии пачками, файл таблицы сам не уменьшается (место занимают новые
|
|
2592
|
-
версии после autovacuum); вернуть место системе — `VACUUM FULL` или `pg_repack` в окно обслуживания.
|
|
2196
|
+
- Срез `asOf` восстанавливается из журнала; дёшево только текущее состояние.
|
|
2197
|
+
- Журнал без партиций и хранит каждую версию: прореживания, обрезки и стирания истории объекта нет, версии стирает
|
|
2198
|
+
только удаление аккаунта вместе с его арендатором. Классу, чья история не нужна, — `history: false`.
|
|
2199
|
+
Место удалённых версий файл таблицы сам не отдаёт (его занимают новые версии после autovacuum); вернуть место
|
|
2200
|
+
системе — `VACUUM FULL` или `pg_repack` в окно обслуживания.
|
|
2593
2201
|
- Сигналы раскрывают активность: слушать канал PostgreSQL разрешает любой роли. В режиме `class` видно, когда и в
|
|
2594
2202
|
какие классы пишут; `tenant` добавляет псевдонимы арендаторов; `opaque` прячет, что записано, но не когда: время и
|
|
2595
2203
|
частоту записи по псевдониму видно, а детерминированный токен — основа частотных атак. Время записи видно и без
|
|
2596
2204
|
сигналов: `pg_stat_all_tables` и `pg_locks` открыты для PUBLIC. Режим `none` убирает сигналы, но тогда отзыв
|
|
2597
2205
|
сессии действует в кэше других процессов только по сроку, а новый класс появляется в их реестре в течение
|
|
2598
2206
|
5 секунд. Настоящая защита от посторонних ролей — отдельная база. Поддельный сигнал лишь сбросит кэш лишний раз:
|
|
2599
|
-
по
|
|
2207
|
+
по незнакомым токенам и нагрузке не своего режима реестр перечитывается не чаще раза в 5 секунд, а поддельный сигнал изменения классов
|
|
2600
2208
|
перечитывает его, как настоящий, но не больше одного перечитывания одновременно.
|
|
2601
2209
|
- Режим сигналов меняется `up({ signals })`, и после этого нужно перезапустить процессы: режим и ключ сигналов
|
|
2602
2210
|
процесс читает при подключении (нагрузку другого режима он считает чужой и сбрасывает весь кэш).
|
|
@@ -2618,47 +2226,6 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2618
2226
|
- Курсор подписки «с этого момента» несёт снимок момента подписки, пока не пройдёт его границу: такой
|
|
2619
2227
|
курсор длиннее обычного.
|
|
2620
2228
|
|
|
2621
|
-
**Проекторы**
|
|
2622
|
-
|
|
2623
|
-
- Проектор — процесс администратора: он работает только на администраторском подключении, читает журнал всех
|
|
2624
|
-
арендаторов в обход RLS и пишет результаты от роли владельца. Держите его в отдельном процессе, как загрузчик.
|
|
2625
|
-
Участник проектора — арендатор, у которого все классы `into` помечены его `derivedBy`; пометить класс может только
|
|
2626
|
-
администратор (право `letopis.projector`).
|
|
2627
|
-
- Итоги производного класса видит каждый, кому правила арендатора дают `READ` на него, — даже тот, кто не читает
|
|
2628
|
-
строки источников: решайте, кому его читать, правилами на сам производный класс. С `history: true` его журнал
|
|
2629
|
-
показывает изменение каждой пачки, то есть и вклад отдельных строк источников.
|
|
2630
|
-
- Строки производных классов пишет только их проектор: приложение, сырой SQL и загрузчик получают `derived_class`, а
|
|
2631
|
-
ссылаться на такие строки концами нельзя.
|
|
2632
|
-
- Атомарная перестройка — одна долгая пишущая транзакция: пока она идёт, горизонт подписок стоит, и события
|
|
2633
|
-
задерживаются у подписчиков и проекторов всех процессов. На машине замеров перестройка 75 тысяч строк шла 13 с, и
|
|
2634
|
-
до её конца подписчик не получил ни одного события. Поэтому сам `run()` перестраивает пачками, а атомарную
|
|
2635
|
-
перестройку запускает только явный `rebuild()`.
|
|
2636
|
-
- Перестройка пачками держит горизонт только на время пачки (в тех же замерах события задерживались до 0,2 с), но
|
|
2637
|
-
до её конца производные данные неполные: строки удалены сразу и появляются по мере перестройки.
|
|
2638
|
-
- Проектор видит только изменения из журнала, поэтому класс с `history: false` источником быть не может: арендатор
|
|
2639
|
-
с таким источником выбывает из проектора (`invalid_class`), пока журнал класса не включат и арендатора не
|
|
2640
|
-
перестроят.
|
|
2641
|
-
- Прореживание журнала старше яруса `all` ведёт к перестройке: проектор, отставший больше чем на этот ярус,
|
|
2642
|
-
получает `cursor_expired` или, с `onExpired: 'rebuild'`, перестраивается сам. Ярус — по арендатору: истёк только у
|
|
2643
|
-
части участников — выбывают и перестраиваются только они.
|
|
2644
|
-
- Проектор работает только на схеме той ревизии движка, которую ждёт библиотека; на схеме другой ревизии он
|
|
2645
|
-
отвечает `invalid_data`.
|
|
2646
|
-
- `purge()` ждёт проекторов: пока проектор, чьи источники включают класс объекта, а у арендатора объекта есть его
|
|
2647
|
-
производный класс, не прочёл удаление, `purge` отвечает `projector_pending`. Проектор, который больше не запускают,
|
|
2648
|
-
держит `purge` своих источников, пока у арендатора есть его производные классы: запустите его или удалите их.
|
|
2649
|
-
Так же ждёт `trimHistory`: пока проектор не прочёл первую оставшуюся версию, обрезка отвечает `projector_pending`.
|
|
2650
|
-
|
|
2651
|
-
**Агрегаты в базе**
|
|
2652
|
-
|
|
2653
|
-
- Агрегат выстраивает писателей одного объекта в очередь: каждая операция блокирует строку цели и пишет её версию.
|
|
2654
|
-
На машине замеров 20 писателей одного кошелька с агрегатом делали 152 операции в секунду (p95 — 0,47 с), без
|
|
2655
|
-
агрегата — 1 987. Для горячих счётчиков берите проектор со свёрткой пачкой.
|
|
2656
|
-
- `reclass` и `rekey` копят вклады до конца переноса, а каскад удаления и загрузчик применяют их по операторам:
|
|
2657
|
-
правило класса проверяется на итоге каждого оператора каскада и каждой пачки загрузчика. Поэтому загрузка, где
|
|
2658
|
-
списания идут пачкой раньше пополнений, получит `invalid_data`, хотя итог всей загрузки допустим. Каждая пачка,
|
|
2659
|
-
задевшая цель, пишет её версию.
|
|
2660
|
-
- Агрегат арендатора не считает строки класса System, а поле строки System считает только источники System.
|
|
2661
|
-
|
|
2662
2229
|
**Агенты и MCP**
|
|
2663
2230
|
|
|
2664
2231
|
- Обёртка `<untrusted-data-…>` — смягчение, а не защита: модель может последовать тексту внутри неё. Защищают права
|
|
@@ -2672,79 +2239,81 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2672
2239
|
удаление по нему отклоняется (`confirmation_required`). Удаляйте по stdio или в ревизии 2026-07-28.
|
|
2673
2240
|
- Подтверждение в ревизии 2026-07-28 подписано ключом процесса: повтор вызова, попавший в другой процесс приложения
|
|
2674
2241
|
(несколько экземпляров за балансировщиком), получает отказ `confirmation_required`.
|
|
2675
|
-
- `
|
|
2676
|
-
`more`; более
|
|
2677
|
-
mode), в пакет не входит.
|
|
2678
|
-
|
|
2679
|
-
**Импорт из 0.21**
|
|
2242
|
+
- `letopis_history` отдаёт последние версии в пределах `maxRows` и `maxBytes` (из базы — не больше `maxRows` + 1
|
|
2243
|
+
версий) и `more`; состояние на более ранний момент — `letopis_query` с `asOf`. Режим, в котором агент пишет код
|
|
2244
|
+
против API (code mode), в пакет не входит.
|
|
2680
2245
|
|
|
2681
|
-
|
|
2682
|
-
и `reclass` ведут себя по-новому, данные переносятся импортом ([MIGRATION.md](MIGRATION.md)).
|
|
2683
|
-
- Импорт: шаги (классы, аккаунты, креды, права, данные каждого арендатора) — отдельные транзакции, источник
|
|
2684
|
-
читается в память целиком; владелец строки, не найденный среди аккаунтов, становится арендатором; правила доступа
|
|
2685
|
-
0.21 переносятся в System и действуют во всех арендаторах; `onDelete` концов — `cascade`, как удаляла 0.21.
|
|
2686
|
-
|
|
2687
|
-
## 19. Тесты
|
|
2246
|
+
## 18. Тесты
|
|
2688
2247
|
|
|
2689
2248
|
Раздел — для тех, кто развивает сам letopis. Запуск: `cd lib && npm test`. Обвязка сама поднимает контейнер `letopis-pg18` (`postgres:18`,
|
|
2690
2249
|
порт 15433), если база не отвечает; каждый тестовый файл работает в своей схеме `v2.t_<имя>`.
|
|
2691
2250
|
|
|
2251
|
+
В CI тестов нет: перед пушем и выпуском всё, что проверяет поведение, идёт одной командой `cd lib && npm run prerelease` —
|
|
2252
|
+
по очереди typecheck, сверка документов, все тесты, примеры сайта со снимками, песочница, сценарии сервисов, быстрый
|
|
2253
|
+
долгий прогон (15 минут смешанной нагрузки) и проверка пакета с живым прогоном; первый красный шаг останавливает
|
|
2254
|
+
прогон. Та же нагрузка на сутки — `cd lib && npm run soak` (длительность — `SOAK_HOURS`, по умолчанию 24 часа). Тест
|
|
2255
|
+
исправления называется пунктом тест-плана («план 2.0, 7.3 №1») и без исправления падает.
|
|
2256
|
+
|
|
2692
2257
|
```
|
|
2693
|
-
# connect — подключение под сессией, отказ ролям в обход RLS, реестр, отложенный сигнал, перевод ошибок (этап 2)
|
|
2258
|
+
# connect — подключение под сессией, отказ ролям в обход RLS, реестр, отложенный сигнал, перевод ошибок (этап 2); сессия, истёкшая по простою: по ключу — обмен заново с прежним арендатором, по токену — no_session вместо пустого чтения (ревью сервисов)
|
|
2694
2259
|
# context — сессии, вход сервиса по ключу, политики RLS этапа 1 (этап 1)
|
|
2695
2260
|
# access-rest — оставшиеся строки этапа 5: загрузка от имени пользователя, баланс №20, 22, 29, upsert и { rev } под ACL, встраивание acl_ok (этап 5)
|
|
2696
|
-
#
|
|
2697
|
-
# acl —
|
|
2698
|
-
# acl-parity — 1000 случайных наборов правил: план прав и решения по эндпоинтам базы против decide
|
|
2261
|
+
# acl — права ACL: решение одним правилом, условия на строки, вес и запрет по умолчанию, каскад, запрет на потомке, deep, невидимая цель, сырой SQL против библиотеки; смена parent и alias класса — только с правами на все его строки, алиас — не имя другого класса, имя — не чужой алиас (этап 5; ревью безопасности №2)
|
|
2262
|
+
# acl-grant — обёртка прав db.acl: grant, deny, revoke, roles и list идемпотентны, их соблюдает база поверх «можно всё», при равном весе запрет сильнее, условие $actor, api, транзакция db.begin(), invalid_data до записи (план 2.0, 7.9 №1); everyone — правила для всех аккаунтов, revoke({ everyone: true, … }) снимает «можно всё»; list() на администраторском подключении — только арендатор сессии, enabled; администраторское подключение пишет правила арендатора без «можно всё» — без target_not_found (агентский прогон, этап 3, п. 9); check() — отказ правила сида «запрет по умолчанию»
|
|
2263
|
+
# acl-parity — 1000 случайных наборов правил: план прав и решения по эндпоинтам базы против эталона decide из lib/test/oracle (этап 5)
|
|
2699
2264
|
# auth — креды (пароль, ключ, ключ + секрет, OTP, TOTP, identity), выравнивание времени, регистр, гашение сессий, недавний вход (этап 5)
|
|
2700
2265
|
# balance — пополнение и списание: идемпотентность по opId, гонки, взаимная блокировка, обрыв связи (TCP-прокси), корректировка под { rev } и forUpdate (этап 4)
|
|
2701
|
-
# chain-api —
|
|
2266
|
+
# chain-api — зарезервированные имена, ошибки построения, кэш плана, onQuery (этап 3)
|
|
2702
2267
|
# chain-read — цепочки чтения на демо-домене: переходы, роли, множественные концы, 18 операторов, keyset, агрегаты, deep, asOf, withDeleted, pivot (этап 3)
|
|
2703
|
-
# chain-types — типы
|
|
2704
|
-
# chain-write — запись цепочками: create, update, upsert, delete с превью,
|
|
2268
|
+
# chain-types — строгие типы цепочек по моделям: connect({ models }) и connect<Models>() (этапы 3, 4); шаг — только класс или алиас моделей и системных классов, переходы по концам, слоты ролей класса, поля фильтров, or() и агрегатов, loose(db) (план 2.0, 7.10 №1); строгие типы из файла letopis types (7.10 №6)
|
|
2269
|
+
# chain-write — запись цепочками: create, update, upsert, delete с превью, слоты, inc, { rev }, fan-out, откат плана (этап 4); update() и delete({ confirm: true }) без фильтра — invalid_query до записи, с { all: true }, фильтром, id, tags(), owner() или limit() — проходят: цепочка, батч, db.begin() (план 2.0, 7.10 №2)
|
|
2705
2270
|
# demo — демо-домен booking на Zod: сид из моделей, запись данных, id по ключу, согласие Zod и базы (этап 2)
|
|
2706
|
-
# docs — примеры документации исполняются: помеченные блоки README
|
|
2707
|
-
# engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными исходами; ключ сигналов без права, заливка поддельными сигналами, в том числе Class;
|
|
2708
|
-
# engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение, метаданные; класс с историей строк
|
|
2709
|
-
# engine-write — конвейер записи сырым SQL: id v5, умолчания, merge, inc, удаление, воскрешение,
|
|
2271
|
+
# docs — примеры документации исполняются: помеченные блоки README, шпаргалки и корневого README — сценариями против PostgreSQL 18 (этап 8)
|
|
2272
|
+
# engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными исходами; ключ сигналов без права, заливка поддельными сигналами, в том числе Class; агент через MCP без --write и без подтверждения человека; смена parent и alias класса с одним правом WRITE на Class, удаление и пересоздание класса с историей строк, пробное ужесточение как оракул, выключение журнала класса (history) с одним правом WRITE на Class; смена класса — immutable_key; агент через MCP выдаёт членство или правило, сессия системного администратора; прикладная роль держит advisory-блокировки с прежними ключами внутренних блокировок, а строки-замки ей недоступны; подделанная справка о взятых блокировках семейства (этапы 1, 3; план 1.1, этапы 2, 5, точка А, ревью безопасности №2, точка Б и доработки после неё, ревью безопасности №3)
|
|
2273
|
+
# engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение, метаданные; класс с историей строк не удаляется, класс без журнала — удаляется (этап 1; доработки после точки Б)
|
|
2274
|
+
# engine-write — конвейер записи сырым SQL: id v5, умолчания, merge, inc, удаление, воскрешение, журнал без пропусков номеров версий, сигналы; класс строки неизменен — immutable_key (этап 1; план 2.0)
|
|
2710
2275
|
# engine-races — гонки на двух соединениях в обоих порядках без пауз по времени; ужесточение во время непрерывной записи семейства не голодает; блокировка семейств, чьи корни отличаются только регистром, и подложенная строка вида LOCK (этап 1; ревью безопасности №3)
|
|
2711
2276
|
# explain — индексы цепочек от роли приложения: снаружи первичный ключ и (class, tenant), внутри find_*() — GIN; срез asOf по классу не читает журнал другого арендатора (этап 3; доработки после точки Б)
|
|
2712
2277
|
# find-ids-parity — 30 цепочек через find_*() и прямым чтением под RLS дают одинаковые ответы; свои членства в других арендаторах и строки Class System — сейчас, asOf и withDeleted (этап 3; доработки после точки Б)
|
|
2713
|
-
# hash — цепочка хэшей версий, подмена и разрыв журнала, verify() (этап 1)
|
|
2714
2278
|
# harness — тестовая обвязка: подъём базы, схема на файл, две строки подключения (этап 0)
|
|
2715
2279
|
# jsonschema — компиляция JSON Schema в jsonpath и проверка данных в базе (этап 1)
|
|
2716
|
-
# impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б); реестр фасада не общий с корнем, пустой реестр не кэшируется (хвост ревью безопасности №3)
|
|
2280
|
+
# impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б); реестр фасада не общий с корнем, пустой реестр не кэшируется (хвост ревью безопасности №3); auth.switch при имперсонации — LetopisError acl_denied из каталога, а не сырая ошибка LT000 (план 2.0, 7.5 №3)
|
|
2717
2281
|
# install — установщик и роли: повторная установка, две установки в базе, три таблицы (этап 1)
|
|
2718
2282
|
# member — членство: работа в арендаторе по ролям, auth.switch, выход с переходом владения, системные строки, роли арендатора без прав System (этап 5)
|
|
2719
|
-
# model — модели на Zod, нормализатор JSON Schema, перевод шаблонов, совпадение с метасхемой базы (этап 2)
|
|
2283
|
+
# model — модели на Zod, нормализатор JSON Schema, перевод шаблонов, совпадение с метасхемой базы (этап 2); format email длиннее 254 символов — нарушение без шаблона (ревью)
|
|
2720
2284
|
# load — загрузчик: несколько классов, циклы, $key и $ext, повторы, existing, порции, режим истории, запрос по целям вне загрузки, CLI (этап 4)
|
|
2721
|
-
# roles — роли и права: установка без суперпользователя, функции владельца, триггеры по каталогу, журнал
|
|
2285
|
+
# roles — роли и права: установка без суперпользователя, функции владельца, триггеры по каталогу, журнал на чтение всеми колонками; up() от такого установщика — ANALYZE и fresh от роли владельца (этап 1; план 1.1, точка А; ревью)
|
|
2722
2286
|
# write-races — гонки записи: 20 параллельных правок разных полей без потерь и строгий rev, параллельные add/remove множественного конца, загрузчик против ужесточения класса и против параллельной правки (existing: update) в обоих порядках (этап 4)
|
|
2723
|
-
# validator-parity — валидатор на TypeScript против проверки в базе: официальный набор, 10 000 случайных документов, умолчания (этап 2)
|
|
2724
|
-
# scenario-barbershop — сквозной сценарий барбершопа: 9 сцен
|
|
2725
|
-
# scenario-real-life — «реальная жизнь» салона: 16 сцен
|
|
2726
|
-
# system-classes — системные
|
|
2727
|
-
# sync — letopis sync (сухой прогон, применение, ужесточение, потерянные refine) и letopis types (этап 2)
|
|
2287
|
+
# validator-parity — валидатор на TypeScript против проверки в базе: официальный набор, 10 000 случайных документов, умолчания (этап 2); нарушение базы — ключ каталога и параметры, шаблон с ними — текст базы (план 2.0, этап 3, п. 3)
|
|
2288
|
+
# scenario-barbershop — сквозной сценарий барбершопа: 9 сцен на цепочках — каталог, окна батчем, брони под lock, мульти-слот и мульти-мастер, версии, отмена и бронь заново на тот же id, закрытие месяца при restrict (этап 4)
|
|
2289
|
+
# scenario-real-life — «реальная жизнь» салона: 16 сцен — сервис записи поверх цепочек, гонки двойной брони, переноса и отмены, walk-in, отказы, отчёты и инварианты дня (этап 4)
|
|
2290
|
+
# system-classes — системные классы: сид System, аккаунты, креды, ресурсы и правила, транзакции (этап 5)
|
|
2291
|
+
# sync — letopis sync (сухой прогон, применение, ужесточение, потерянные refine) и letopis types (этап 2); зарезервированное имя хаба — отказ hub(), syncModels и базы (план 2.0, 7.10 №3)
|
|
2292
|
+
# cli — команды CLI (план 2.0, 7.9 №10, ред. 2): коды выхода letopis sync --check; справка, отчёт sync и letopis types, letopis types --system
|
|
2728
2293
|
# tx — явные транзакции db.begin(): сломанная транзакция, lock(), 40P01; forUpdate(); батч и его склейка, очередь батча — своя у фасада; db.as(…, { owner }) (этап 4)
|
|
2729
2294
|
# v5 — идентификаторы v5 на стороне SQL против векторов независимой реализации (этап 1)
|
|
2730
2295
|
# zod-output — снимок вывода z.toJSONSchema() по видам полей демо-домена против эталона (этап 0)
|
|
2731
|
-
# time — история: versions
|
|
2732
|
-
# rekey — новое значение ключа объекта и новый ключ класса: новые id, перевод ссылок, рекурсия по связям с концом в ключе (этап 6)
|
|
2733
|
-
# reset — сброс sessions, tenant, all: флаг allowReset, право letopis.reset, подтверждение именем схемы, запись reset, новый ключ сервиса (этап 6)
|
|
2734
|
-
# verify — законные разрывы и подмены, verify({ data: true }): данные, концы, арендаторы, id по ключу; права и CLI (этап 6)
|
|
2735
|
-
# retention — политика хранения: ярусы, конечный срок, особые версии, удалённые, asOf, cursor_expired, часовой пояс, догоняющий запуск, наследование и умолчание — против эталона на TypeScript (этап 6)
|
|
2296
|
+
# time — история: versions, withDeleted, restore, удаление аккаунта (этап 6)
|
|
2736
2297
|
# watch — подписка по курсору: порядок фиксации, долгая транзакция, возобновление, обрыв слушателя, два арендатора (этап 6)
|
|
2737
|
-
# cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков
|
|
2738
|
-
# maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6)
|
|
2739
|
-
# up — полный установщик: свежая установка, повторный накат, свои сиды, автосоздание базы, UTF8, версия 17,
|
|
2740
|
-
#
|
|
2741
|
-
#
|
|
2742
|
-
#
|
|
2743
|
-
#
|
|
2744
|
-
#
|
|
2745
|
-
#
|
|
2746
|
-
#
|
|
2747
|
-
#
|
|
2748
|
-
#
|
|
2749
|
-
#
|
|
2298
|
+
# cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков сигналов, гашения чужих сессий не растят счётчики (этап 6; план 1.1, точка А; план 2.0, 7.7)
|
|
2299
|
+
# maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6), строки-замки удалённых классов
|
|
2300
|
+
# up — полный установщик: свежая установка, повторный накат, свои сиды, автосоздание базы, UTF8, версия 17, накат миграций, PGlite, первый арендатор и пользователь (up({ tenant }), createTenant); ошибки, ход установки и предупреждение штампа — тексты каталога (раздел boot); установка без ADMIN OPTION на готовую роль владельца — admin_required (7.5 №3); схема ревизии ниже 8 или без штампа — отказ up() и connect() до любых изменений с подсказкой up({ fresh: true }), fresh проходит (план 2.0, 7.10 №5); хаб с зарезервированным именем, заведённый мимо проверки базы, переживает up() с предупреждением, правку его описания база отклоняет (7.10 №3) (этапы 6, 8)
|
|
2301
|
+
# scenario-salon — сквозной сценарий салона: 20 актов с матрицей покрытия API (этап 6)
|
|
2302
|
+
# telemetry — onQuery и slowMs (этап 6)
|
|
2303
|
+
# history — причина изменения в версиях журнала: у глаголов, db.begin() и батча, причина батча — с фасада, поставившего глагол (план 1.1, этап 1)
|
|
2304
|
+
# signals — режимы сигналов: нагрузка у посторонней роли, точность кэша до арендатора, System, классы прав, процесс без ключа, загрузчик, подписка, ключ, none, склейка, версия сервера, повтор отправки, режим после повторного up() (план 1.1, этап 2)
|
|
2305
|
+
# initial-load — первая заливка: равенство с обычным загрузчиком, структура и режимы триггеров, системные строки, непустая схема, вставка во время запуска, откат, MULTI_INSERT в WAL, commitEvery (план 1.1, этап 3)
|
|
2306
|
+
# mcp — инструменты MCP: модель и meta только в результатах, права арендатора, JSON-цепочка и подсказки похожих имён, предел строк и байт с курсором, обёртка недоверенных данных, запись только с write, update только с rev, удаление с подтверждением человека (elicitation в ревизиях 2025 и 2026-07-28), причина в версиях, учётные данные, схемы инструментов, letopis без SDK; letopis mcp по stdio, обработчик HTTP; окно подтверждения без алиаса и meta, countBy и versions с пределом в самом запросе, предел шагов и условий $or; классы прав — только с allowAclWrite, сессия в арендаторе System — только с allowSystemAdmin; structuredContent с пояснением, подпись requestState пользователя сервера со сроком; описания инструментов, их параметров и instructions — у каждого есть, по-русски (план 1.1, этап 5; ревью безопасности №2; точка Б и доработки после неё)
|
|
2307
|
+
# security-final — итоговое ревью безопасности (этап 8, §7.10 №8.4): блокировка семейства по арендатору корня, account_purge без права и restore чужого id не дают ни утечки, ни блокировки; функции этапа 6, log_refs_at, срезы find_* по журналу и watch_read не выходят за свой арендатор; NOTIFY несёт только имена или токены режима; ключ кэша различает токен, актора и арендатора (этап 8)
|
|
2308
|
+
# semver — правила версий: check:semver требует шаг версии не меньше нужного — мажор за исчезнувший метод или код ошибки, минор за новый метод и новую ревизию движка, патча хватает на тексты (план 2.0, 7.8 №1)
|
|
2309
|
+
# one-process — один процесс: up({ login }) квотирует имя и пароль роли приложения, повтор обновляет пароль, привилегированную роль не трогает; connect({ admin }) — второй пул при первой надобности для load, syncModels, maintain с той же сессией, цепочки под RLS, без admin — admin_required (план 2.0, 7.9 №3, №4)
|
|
2310
|
+
# prerelease — проверка перед выпуском: шаги идут строго по очереди, первый красный останавливает прогон, следующие не запускаются; тест-файл, процесс которого упал до первого теста, перезапускается один раз, красный тест — нет; при --only и --skip таблица итогов копится — строки прочих шагов остаются из прежних прогонов (план 2.0, 7.8 №3)
|
|
2311
|
+
# write-contract — обещания README о записи (план 2.0, 7.2): повтор ключа в батче — exists и откат, порядок строк ответа restore, цепочка с глаголом в значении слота, загрузчик с existing: update не правит строку другого класса, ответ из кэша не портится правкой прошлого ответа
|
|
2312
|
+
# watch-contract — обещания README о подписке и подключении (план 2.0, 7.4): молчащая подписка опрашивает журнал раз в pollMs, close() подключения закрывает живые подписки, подписка на класс получает события потомка, заведённого после watch(), реестров db.as() не больше предела
|
|
2313
|
+
# model-contract — обещания README о моделях, типах, CLI и ошибках (план 2.0, 7.5): letopis types — верный TypeScript для кириллицы, класса без полей и anyOf, полная форма hub() с z.object, понятные ошибки вместо сырых, коды выхода CLI
|
|
2314
|
+
# read-contract — обещания README о чтении и истории (план 2.0, 7.1): курсор after() в versions() и paths(), имя поля с @S@
|
|
2315
|
+
# messages — каталог сообщений (план 2.0, этап 3, п. 3; 7.9 №6): у каждого кода заголовок и подсказка, hint и docs у каждой ошибки, docs ведёт на якорь кода в README; места библиотеки и ошибки базы — по ключу каталога, key и params, текст как у сырого SQL; нарушения данных из базы (запись, ужесточение класса, загрузчик), внутренняя ошибка internal, повторяемый конфликт с ключом; похожие имена для неизвестного класса, роли, поля и опции; неизвестная опция connect(), up(), load(), watch() и createMcpServer() — invalid_data с похожими (7.10 №4)
|
|
2316
|
+
# mcp-model — описание модели в letopis_model: каждая цепочка examples («создать», «найти», «перейти к соседям») исполняется на демо-схеме booking, letopis_model { class } отдаёт те же примеры, большая модель сначала теряет только их (план 2.0, 7.9 №9); метаданные и наследование, условия anyOf и oneOf (этап 6; план 1.1)
|
|
2317
|
+
# llms — один словарь фильтров (план 2.0, 7.9 №8): реестр операторов совпадает с фабриками пакета, с ключами letopis_query (тот же ответ, что у цепочки) и с таблицами README, шпаргалки и главы «Чтение» сайта
|
|
2318
|
+
# disconnect — обрыв соединения (план 2.0, 7.4 №7): загрузчик и первая заливка, открытие db.begin() — обрыв TCP до и после COMMIT и на открытии транзакции, pg_terminate_backend процесса, ждущего блокировку: промис отклонён ошибкой связи, необработанных ошибок в процессе нет, следующая операция пула проходит
|
|
2750
2319
|
```
|