letopis 2.0.0 → 3.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.
Files changed (75) hide show
  1. package/AGENT-CHEATSHEET.md +484 -414
  2. package/CHANGELOG.md +321 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +786 -183
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +3 -0
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +2 -1
  10. package/dist/admin.js +6 -4
  11. package/dist/auth.d.ts +18 -3
  12. package/dist/auth.js +75 -6
  13. package/dist/chain.d.ts +38 -17
  14. package/dist/chain.js +434 -63
  15. package/dist/cli.js +28 -6
  16. package/dist/errors.d.ts +19 -1
  17. package/dist/errors.js +46 -6
  18. package/dist/index.d.ts +10 -4
  19. package/dist/index.js +136 -46
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +9 -5
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +633 -0
  25. package/dist/mcp.js +26 -11
  26. package/dist/messages/api.d.ts +66 -0
  27. package/dist/messages/api.js +90 -1
  28. package/dist/messages/boot.js +4 -4
  29. package/dist/messages/codes.js +4 -4
  30. package/dist/messages/index.d.ts +5 -2
  31. package/dist/messages/index.js +5 -2
  32. package/dist/messages/log.d.ts +229 -0
  33. package/dist/messages/log.js +102 -0
  34. package/dist/messages/model.d.ts +24 -1
  35. package/dist/messages/model.js +34 -3
  36. package/dist/messages/query.d.ts +10 -0
  37. package/dist/messages/query.js +13 -0
  38. package/dist/messages/sql-core.d.ts +15 -1
  39. package/dist/messages/sql-core.js +20 -3
  40. package/dist/messages/sql-time.d.ts +15 -0
  41. package/dist/messages/sql-time.js +23 -0
  42. package/dist/messages/write.d.ts +47 -0
  43. package/dist/messages/write.js +63 -0
  44. package/dist/model.d.ts +4 -0
  45. package/dist/model.js +23 -5
  46. package/dist/sql.d.ts +2 -0
  47. package/dist/sql.js +77 -25
  48. package/dist/sync.d.ts +1 -1
  49. package/dist/sync.js +50 -0
  50. package/dist/tx.d.ts +10 -1
  51. package/dist/tx.js +44 -2
  52. package/dist/typed.d.ts +14 -10
  53. package/dist/types.d.ts +82 -4
  54. package/dist/types.js +6 -1
  55. package/dist/up.d.ts +3 -3
  56. package/dist/up.js +57 -17
  57. package/dist/uuid.d.ts +8 -0
  58. package/dist/uuid.js +9 -1
  59. package/dist/validate.js +24 -7
  60. package/dist/watch.d.ts +5 -0
  61. package/dist/watch.js +13 -0
  62. package/dist/write.d.ts +78 -1
  63. package/dist/write.js +357 -39
  64. package/llms.txt +37 -17
  65. package/package.json +12 -6
  66. package/sql/20-context.sql +11 -3
  67. package/sql/30-validate.sql +51 -10
  68. package/sql/40-class.sql +21 -1
  69. package/sql/80-auth.sql +88 -17
  70. package/sql/90-time.sql +34 -6
  71. package/sql/99-revision.sql +5 -1
  72. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  73. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  74. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  75. package/sql/migrate/README.md +5 -1
package/README.md CHANGED
@@ -1,4 +1,8 @@
1
- # letopis 2.0
1
+ # letopis 3.0
2
+
3
+ > ИИ-агенту — сначала [AGENT-CHEATSHEET.md](AGENT-CHEATSHEET.md): памятка лежит в пакете рядом с этим файлом
4
+ > (`node_modules/letopis/AGENT-CHEATSHEET.md`), в ней общие правила для любой задачи, рабочий цикл и грабли на одной
5
+ > странице.
2
6
 
3
7
  **letopis** — библиотека для Node.js, хранилище версионированных сущностей на PostgreSQL 18. Каждое изменение
4
8
  сохраняется версией в журнале. Поэтому у любого объекта есть история, состояние на любую дату и подписка на
@@ -6,13 +10,14 @@
6
10
  RLS. Правила одинаково действуют и для библиотеки, и для сырого SQL. Данные читают и пишут цепочками:
7
11
  `db.Shop(shop).Product({ price: lt(1000) }).rows()`.
8
12
 
9
- [Шпаргалка для ИИ-агента](AGENT-CHEATSHEET.md) · [Изменения по версиям](CHANGELOG.md) · [llms.txt](llms.txt)
13
+ [Памятка для ИИ-агента](AGENT-CHEATSHEET.md) · [Изменения по версиям](CHANGELOG.md) · [llms.txt](llms.txt) ·
14
+ [Лицензия](#19-лицензия): некоммерческое использование бесплатно, коммерческое — по разрешению
10
15
 
11
16
  Что умеет letopis:
12
17
 
13
18
  - **Модели на Zod** хранятся в базе как классы: хабы (объекты) и связи (отношения со своими данными) — [раздел 4](#4-модель-данных).
14
- - **Запись цепочками**: `create`, `update` со слиянием, `upsert`, удаление с превью и каскадом, приращение `inc`
15
- для денег и счётчиков — [разделы 5](#5-запись), [7](#7-удаление-и-восстановление) и [8](#8-транзакции-батчи-и-деньги).
19
+ - **Запись цепочками**: `create`, `update` со слиянием, `upsert`, удаление только по показанному превью и с каскадом,
20
+ приращение `inc` для счётчиков — [разделы 5](#5-запись), [7](#7-удаление-и-восстановление) и [8](#8-транзакции-и-батчи).
16
21
  - **Чтение цепочками**: переходы по связям, фильтры, деревья, агрегаты, постраничный вывод — [раздел 6](#6-чтение).
17
22
  - **История**: версии с автором и причиной, срез на дату, восстановление удалённого — [раздел 11](#11-история-и-срезы-на-дату).
18
23
  - **Арендаторы, вход и права** — в самой базе: пароли, ключи API, одноразовые коды, TOTP, сессии, имперсонация,
@@ -21,6 +26,8 @@ RLS. Правила одинаково действуют и для библио
21
26
  - **Агенты и MCP**: сервер MCP даёт ИИ-агенту модель, чтение, историю и, если разрешено, запись — с правами одного
22
27
  пользователя — [раздел 13](#агенты-и-mcp).
23
28
  - **Массовая загрузка** миллионов строк — [раздел 9](#9-массовая-загрузка).
29
+ - **Журнал событий** приложения: удаления, восстановления, ошибки, вход, запросы — с маскировкой секретов —
30
+ [«Логирование»](#логирование).
24
31
 
25
32
  Требования:
26
33
 
@@ -40,30 +47,40 @@ RLS. Правила одинаково действуют и для библио
40
47
  5. [Запись](#5-запись)
41
48
  6. [Чтение](#6-чтение)
42
49
  7. [Удаление и восстановление](#7-удаление-и-восстановление)
43
- 8. [Транзакции, батчи и деньги](#8-транзакции-батчи-и-деньги)
50
+ 8. [Транзакции и батчи](#8-транзакции-и-батчи)
44
51
  9. [Массовая загрузка](#9-массовая-загрузка)
45
52
  10. [Пользователи, вход и права](#10-пользователи-вход-и-права)
46
53
  11. [История и срезы на дату](#11-история-и-срезы-на-дату)
47
54
  12. [Подписка на изменения и кэш](#12-подписка-на-изменения-и-кэш)
48
- 13. [Эксплуатация](#13-эксплуатация)
55
+ 13. [Эксплуатация](#13-эксплуатация): обслуживание, MCP, [логирование](#логирование), командная строка
49
56
  14. [Справочник API](#14-справочник-api)
50
57
  15. [Ошибки](#15-ошибки)
51
58
  16. [Производительность](#16-производительность)
52
59
  17. [Ограничения](#17-ограничения)
53
60
  18. [Тесты](#18-тесты)
61
+ 19. [Лицензия](#19-лицензия)
54
62
 
55
63
  Разделы 3–13 построены на одном примере — магазине: установка, модель, запись, чтение и так далее. Блоки кода этих
56
- разделов идут по порядку и вместе образуют одну программу. Её, как и пример быстрого старта, тест `docs` исполняет
57
- против настоящей базы, поэтому примеры не расходятся с кодом.
64
+ разделов идут по порядку и вместе образуют одну программу. Автоматически её сейчас не сверяет ни один тест: тест
65
+ `docs` отстал от текста. Если пример разошёлся с поведением пакета, верен код.
58
66
 
59
67
  ## 1. Быстрый старт
60
68
 
61
- Нужен PostgreSQL 18 и роль администратора: суперпользователь или роль с правом `CREATEROLE`. Установите пакет:
69
+ Нужен PostgreSQL 18 и роль администратора: суперпользователь или роль с `CREATEROLE`, правом `CREATE` в базе (схема
70
+ и расширение pgcrypto) и `CREATEDB`, если базы ещё нет. Установите пакет:
62
71
 
63
72
  ```sh
64
- npm install letopis zod postgres # postgres — для createTenant и своих SQL-запросов
73
+ npm pkg set type=module # код приложения — ESM: "type": "module" или файлы .mjs
74
+ npm install letopis zod postgres@3 # postgres@3 — для createTenant и своих SQL-запросов
75
+ npm install -D tsx # запуск примеров на TypeScript: npx tsx app.ts
65
76
  ```
66
77
 
78
+ `postgres` — зависимость letopis версии 3. Ставьте ту же мажорную версию: `npm i postgres` без версии поставит 4.x,
79
+ и код будет работать, но `createTenant(admin, …)` не пройдёт проверку типов — типы двух копий `postgres` несовместимы.
80
+
81
+ Пакет — модуль ES, и примеры написаны на `import`: без `"type": "module"` в `package.json` приложения (или без
82
+ расширения `.mjs`) Node ответит `SyntaxError: Cannot use import statement outside a module`.
83
+
67
84
  Приложение подключается к базе отдельной ролью, которая состоит в `letopis_app`. Её заводит сама установка: опция
68
85
  `login` у `up()` создаёт роль с паролем (повтор только обновит пароль) и выдаёт ей `letopis_app`.
69
86
 
@@ -111,7 +128,10 @@ await svc.close()
111
128
  `owner` в нём.
112
129
  - `connect({ apiKey })` обменял ключ сервиса на сессию. `verifyPassword` вернул `{ account, token }` (или `null`,
113
130
  если пароль неверный). `svc.as(account, { tenant })` — тот же пул соединений, но от имени Анны в магазине;
114
- право сервиса на это проверяет база.
131
+ право сервиса на это проверяет база. Сессия входа и `svc.as(account)` без `tenant` работают в **личном**
132
+ арендаторе Анны (id её аккаунта): классов магазина там нет, и шаг `db.Product()` дал бы `invalid_class`. Своё
133
+ подключение по токену (`connect({ token })`) переходит в магазин через `auth.switch()`
134
+ ([«Работа в другом арендаторе»](#работа-в-другом-арендаторе)).
115
135
  - `hub('Product', поля, опции)` описал класс-объект. `ends: { Shop }` — обязательная ссылка на магазин.
116
136
  `key: ['Shop', 'sku']` — ключ: id товара вычисляется из магазина и артикула, а второй товар с тем же ключом база
117
137
  не примет.
@@ -119,7 +139,9 @@ await svc.close()
119
139
  уменьшил цену внутри оператора `UPDATE`, без предварительного чтения. `versions()` прочитал историю из журнала.
120
140
 
121
141
  Повторный вызов `up()` безопасен: он повторно накатывает ту же ревизию и данные не трогает, а ключ сервиса снова не
122
- выдаёт, пока в установке есть хоть один ключ API, кроме отозванных. Дальше раздел 2 объясняет устройство, а разделы 3–13 разбирают задачи по порядку.
142
+ выдаёт, пока в установке есть хоть один ключ API, кроме отозванных. Поэтому код, который запускают не один раз,
143
+ передаёт свой ключ — `up({ …, serviceKey: process.env.SERVICE_KEY })` — и подключается с ним
144
+ (`apiKey: r.serviceKey ?? process.env.SERVICE_KEY`); повторный `create()` того же магазина даст `exists`. Дальше раздел 2 объясняет устройство, а разделы 3–13 разбирают задачи по порядку.
123
145
 
124
146
  ## 2. Как устроен letopis
125
147
 
@@ -174,6 +196,28 @@ LOGIN-роль состоит только в ней. Всё, что выпол
174
196
  которая обходит RLS (суперпользователь, `BYPASSRLS`, член `letopis_owner`), если не указано
175
197
  `allowBypassRls: true`.
176
198
 
199
+ ### Безопасность по умолчанию
200
+
201
+ Опасное действие нельзя совершить случайно — без сверки с тем, что видел человек, оно отклоняется до записи:
202
+
203
+ - **Удаление — только по превью.** `delete()` — превью; удаляет `delete({ confirm: true, expect: превью })`, ровно
204
+ показанное. `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`).
205
+ - **Восстановление — только по надгробиям.** `restore({ expect })` с ответом `delete()` или строками `withDeleted()`;
206
+ `restore()` без `expect`, в том числе по явному id, — `invalid_query` (`write.restore_without_expect`).
207
+ - **Массовое — только явно.** Правка и удаление без фильтра — `invalid_query`, пока нет `{ all: true }`; удаление и
208
+ восстановление без сверки — тоже только с `{ all: true }`.
209
+ - **Арендатор и права — в базе.** Права по умолчанию запрещают, строки чужого арендатора не видны; роль в обход RLS
210
+ `connect()` отклоняет (`bypass_rls`), пока не передано `allowBypassRls: true`. Арендатора берут только из серверной
211
+ сессии ([«Веб-сервис: одно подключение и безопасность»](#веб-сервис-одно-подключение-и-безопасность)).
212
+ - **Администраторский пул — явно.** `createTenant(admin, …)` принимает только пул `postgres()` (`api.create_tenant_admin`).
213
+ - **Секреты в журнале событий маскируются**: поля с паролями, токенами, ключами и кредами, учётные данные в строке
214
+ подключения; значения параметров SQL и тексты ошибок PostgreSQL не пишутся вовсе. Значения фильтров чтения журнал
215
+ пишет как есть — персональные поля добавьте в `redact` ([«Логирование»](#логирование)).
216
+
217
+ Общие правила для любой задачи на letopis — одна открытая запись на объект через ключ связи, удаление и возврат по
218
+ превью, гонки, перевод ошибок в текст для человека — собраны в [памятке](AGENT-CHEATSHEET.md), «Правила для любой
219
+ задачи».
220
+
177
221
  ### Как идёт чтение
178
222
 
179
223
  Под RLS индексы по `data` и по обратным ссылкам политике напрямую недоступны. Поэтому id для шагов цепочки
@@ -225,6 +269,7 @@ LOGIN-роль состоит только в ней. Всё, что выпол
225
269
  | `owner`, `app` | имена ролей владельца и приложения (по умолчанию `letopis_owner`, `letopis_app`) |
226
270
  | `login` | роль приложения `{ user, password }`: заводится или получает новый пароль и членство в `letopis_app`; строка подключения — `r.login.dsn` ([«Роль приложения»](#роль-приложения)) |
227
271
  | `migrationsDir` | свой каталог миграций для `upgrade` (по умолчанию `sql/migrate/` пакета) |
272
+ | `log`, `logLevel`, `redact` | журнал событий установки (`up`, `migrate`), как у `connect()` ([«Логирование»](#логирование)) |
228
273
 
229
274
  Результат: `schema` (полное имя), `serviceKey` (при первой установке и при накате, когда в установке не осталось ни
230
275
  одного ключа API, кроме отозванных: истёкший или выключенный ключ тоже в счёт; иначе `null`), `created`,
@@ -233,6 +278,11 @@ LOGIN-роль состоит только в ней. Всё, что выпол
233
278
  (предупреждения, без `quiet` они печатаются), а также `tenant`, `pglite` и `login` (`{ user, created, dsn }`), если
234
279
  их просили. На PostgreSQL ниже 18.2 `up()` предупреждает: такой сервер мог усечь CLOG, который ещё нужен непрочитанным сигналам, — обновите его.
235
280
 
281
+ Ключ сервиса храните сами: повторный `up()` его не отдаёт (`serviceKey: null`). `up({ fresh: true })` ставит схему
282
+ заново — вместе с данными пропадают сессии и ключи, и выдаётся новый ключ сервиса. Процесс со старым ключом получит
283
+ `no_session: неверный ключ`: перезапустите его с новым. Чтобы ключ не менялся между установками (разработка,
284
+ стенды), передавайте свой: `up({ …, serviceKey: process.env.SERVICE_KEY })` — он регистрируется и в новой схеме.
285
+
236
286
  Несколько экземпляров сервиса могут звать `up()` одной схемы одновременно: накат идёт под блокировкой схемы. Первый
237
287
  `up()` ставит или обновляет её, остальные ждут и делают повторный накат по готовой (`created: false`,
238
288
  `upgraded: false`); первого арендатора `tenant` заводит один из них, остальные находят его по имени. `up()` разных
@@ -272,37 +322,44 @@ const ANNA = r.tenant!.user! // его первый пользовател
272
322
  ### Первый арендатор и пользователь
273
323
 
274
324
  Права в letopis включены всегда и по умолчанию запрещают всё. Поэтому новому арендатору нужны правила. Опция
275
- `up({ tenant })` и функция `createTenant(sql, schema, opts)` делают это одной транзакцией:
325
+ `up({ tenant })` и функция `createTenant(admin, schema, opts)` делают это одной транзакцией:
276
326
 
277
327
  - арендатор — аккаунт в System с id из имени («tenant:<имя>»);
278
- - правило «аутентифицированным можно всё» в этом арендаторе (`grantAll`, по умолчанию да, вес 10; права только по
328
+ - правило «аутентифицированным можно всё» в этом арендаторе — чтение, запись и удаление строк всех классов, но не
329
+ эндпоинты `api` (`grantAll`, по умолчанию да, `grantAll: false` — без него; вес 10; права только по
279
330
  ролям — [ниже](#права-только-по-ролям));
280
331
  - пользователь (id из логина) с членством в арендаторе с ролями `user.roles` (по умолчанию `['owner']`), паролем и
281
332
  сессией в арендаторе.
282
333
 
283
- Повтор с теми же именем и логином ничего не дублирует и пароль не меняет, только выдаёт новую сессию. Логин входит в
284
- id как есть: `Anna@shop.ru` и `anna@shop.ru` дали бы два аккаунта, и у второго не было бы пароля, — пишите логин в
285
- одном регистре. Поле `created` в ответе — создан ли арендатор этим вызовом: для существующего арендатора с новым
334
+ Повтор с теми же именем и логином ничего не дублирует и пароль не меняет, только выдаёт новую сессию. Крайние пробелы
335
+ логина отбрасываются (` anna@shop.ru ` и `anna@shop.ru` — один пользователь), а регистр входит в id как есть:
336
+ `Anna@shop.ru` и `anna@shop.ru` дали бы два аккаунта, и у второго не было бы пароля, — пишите логин в одном регистре. Поле `created` в ответе — создан ли арендатор этим вызовом: для существующего арендатора с новым
286
337
  пользователем оно `false`. Роли задаёт `user.roles` — непустой список имён (не масок), иначе `invalid_data`. Вид
287
338
  пароля `user.kind` (по умолчанию `PASSWORD`) подчиняется правилу вида креда, как у `auth.setPassword`
288
339
  ([«Справочник API»](#dbauth)), иначе — тоже `invalid_data`. Роли
289
340
  уже существующего членства повтор не меняет: их меняет правка членства ([«Выдать право»](#выдать-право)).
290
341
  Удалённое повтор не воскрешает: если арендатор, аккаунт пользователя или членство удалены (строки нет, а история в
291
342
  журнале есть), `createTenant` отвечает `invalid_data` и не записывает ничего. Текст ошибки называет удалённое и
292
- советует вернуть его `restore()`: аккаунт — `db.Account(id).restore()` от имени системного администратора,
293
- членство — `db.member(id).restore()` в этом арендаторе; другой выход — другое имя или логин. Сессия
343
+ советует вернуть его `restore({ expect })` с надгробием из `withDeleted().rows()`: аккаунт —
344
+ `db.Account(id).restore({ expect: await db.Account(id).withDeleted().rows() })` от имени системного администратора,
345
+ членство — так же `db.member(id)` в этом арендаторе; другой выход — другое имя или логин. Сессия
294
346
  из `createTenant` — без способа входа, поэтому свой пароль по ней не сменить: для этого пользователь входит по
295
- паролю. `createTenant` нужен администраторский пул postgres.js:
347
+ паролю.
348
+
349
+ `admin` — **пул `postgres()`** администраторской роли (функция с методами `begin` и `unsafe`), а не строка
350
+ подключения и не подключение `connect()` letopis: другое значение `createTenant` отклоняет до запросов — `invalid_query`
351
+ (`api.create_tenant_admin`, текст называет, что передали). `schema` — как у `connect()` и `up()`: `'shop'` даёт
352
+ `v2.shop`, можно сразу `'v2.shop'` или `r.schema`. Пул закрывает вызывающий:
296
353
 
297
354
  <!-- run: guide -->
298
355
  ```ts
299
356
  import postgres from 'postgres'
300
357
  import { createTenant } from 'letopis'
301
358
 
302
- const pg = postgres(ADMIN)
303
- const lutik = await createTenant(pg, r.schema, {
359
+ const admin = postgres(ADMIN) // пул postgres(), не строка DSN
360
+ const lutik = await createTenant(admin, r.schema, {
304
361
  name: 'Лютик', user: { name: 'Борис', login: 'boris@lutik.ru', password: 'Лютик-2026!' } })
305
- await pg.end()
362
+ await admin.end()
306
363
  const BORIS = lutik.user! // lutik: { tenant, user, token, created: true }
307
364
  ```
308
365
 
@@ -325,7 +382,7 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
325
382
  | Опция `connect()` | Что делает |
326
383
  |---|---|
327
384
  | `dsn` или `sql` | строка подключения или готовый пул postgres.js |
328
- | `schema` | полное имя установки: `'v2.shop'` (удобно брать `r.schema`) |
385
+ | `schema` | имя установки: `'v2.shop'` (удобно брать `r.schema`) или, как у `up()`, `'shop'` — то же `v2.shop` |
329
386
  | `token`, `apiKey` | токен сессии или ключ API (`service` — синоним `apiKey`) |
330
387
  | `models` | модели `letopis/model` — только для типов цепочек ([раздел 4](#4-модель-данных)) |
331
388
  | `max` | размер пула (по умолчанию 10) |
@@ -333,6 +390,7 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
333
390
  | `cache` | кэш результатов: `{ ttl, max, ttlOnly }` или `false` ([раздел 12](#12-подписка-на-изменения-и-кэш)) |
334
391
  | `maintain` | запускать `maintain()` по расписанию: `'1h'`, `'15m'` или миллисекунды |
335
392
  | `onQuery`, `slowMs` | хук на каждый запрос цепочки и порог медленного запроса |
393
+ | `log`, `logLevel`, `redact` | журнал событий: функция или объект-логгер (`console`, pino), порог (`'info'` по умолчанию) и ещё имена полей для маскировки ([«Логирование»](#логирование)) |
336
394
  | `refreshSession` | продлевать сессию раз в минуту работы (по умолчанию да) |
337
395
  | `notifyIntervalMs` | интервал отправки отложенных сигналов (100 мс) |
338
396
  | `allowBypassRls`, `owner` | разрешить роль в обход RLS (администраторское подключение); имя роли-владельца |
@@ -342,7 +400,8 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
342
400
  проверяют опции `up()`, `db.load()`, `db.watch()` и `createMcpServer()` ([«Строгий режим»](#строгий-режим)).
343
401
 
344
402
  У подключения есть `db.schema`, `db.token`, `db.tenant` (арендатор сессии) и `db.close()`. Подключения,
345
- полученные через `as()`, делят пул с исходным: закрывать нужно исходное.
403
+ полученные через `as()`, делят пул с исходным: закрывать нужно исходное, один раз — оно закрывает пул, подписки и все
404
+ фасады. `close()` у подключения из `as()` ничего не делает: пул общий, и фасад после такого вызова работает дальше.
346
405
 
347
406
  ### Администраторское подключение
348
407
 
@@ -377,14 +436,14 @@ await solo.close() // закрывает оба пул
377
436
 
378
437
  ### Обновление пакета и схемы
379
438
 
380
- У схемы есть ревизия движка (сейчас 8). Если новая версия пакета принесёт новую ревизию, `connect()` только
439
+ У схемы есть ревизия движка (сейчас 10). Если новая версия пакета принесёт новую ревизию, `connect()` только
381
440
  предупредит и назовёт команду. Отказывает `up()`: на отставшую схему без `upgrade: true` и на схему новее пакета.
382
441
  К схеме новее пакета (её уже обновил более новый пакет, а старые экземпляры приложения ещё работают) `connect()`
383
442
  подключается с предупреждением
384
443
  «движок схемы … ревизии N новее библиотеки (она знает M) — обновите пакет letopis». Поэтому скрипт запуска, который
385
444
  вызывает `up()` при каждом старте, после такого обновления пакета остановится, пока не получит `upgrade: true`.
386
445
  `up({ dsn, schema, upgrade: true })` применяет миграции из `sql/migrate/` и повторно накатывает функции; данные
387
- сохраняются. У 2.0 миграций нет.
446
+ сохраняются. Пока схема не обновлена, `connect()` предупреждает, а функции новой ревизии в ней не работают.
388
447
 
389
448
  Ревизия меняется не с каждой версией: у версий одной ревизии функции могут быть разные. Поэтому `up()` пишет в
390
449
  комментарий схемы ещё и штамп движка: версию пакета и хэш его файлов SQL. Если штамп не совпадает с файлами пакета,
@@ -396,21 +455,22 @@ await solo.close() // закрывает оба пул
396
455
 
397
456
  Версии letopis следуют semver. Что обещает каждый шаг версии:
398
457
 
399
- - **Патч** (2.0.0 → 2.0.1) — только исправления. Ревизия движка та же: исправленные функции накатывает обычный
458
+ - **Патч** (3.0.0 → 3.0.1) — только исправления. Ревизия движка та же: исправленные функции накатывает обычный
400
459
  `up({ schema })`, миграций нет; до наката `connect()` предупреждает о расхождении штампа. Работающий код не получает
401
460
  новых отказов, методы, опции, экспорты и коды ошибок не исчезают.
402
- - **Минор** (2.0 → 2.1) — новые возможности: методы, опции, коды ошибок; изменения поведения, которые не ломают
461
+ - **Минор** (3.0 → 3.1) — новые возможности: методы, опции, коды ошибок; изменения поведения, которые не ломают
403
462
  работающий код; новая ревизия движка. Новую ревизию накатывает `up({ schema, upgrade: true })`. Удалённого и
404
463
  переименованного нет.
405
- - **Мажор** (2.x → 3.0) — удаление и переименование методов, опций, экспортов и кодов ошибок, смена сигнатур и типов,
464
+ - **Мажор** (3.x → 4.0) — удаление и переименование методов, опций, экспортов и кодов ошибок, смена сигнатур и типов,
406
465
  новые отказы для всех. Что снято и чем его заменить, перечисляет запись версии в [CHANGELOG.md](CHANGELOG.md). Схему
407
466
  прошлого мажора новый мажор может не обновлять.
408
467
 
409
468
  Текст сообщения об ошибке в API не входит: в патче он может стать точнее. Полагайтесь на `err.code`.
410
469
 
411
- Шаг версии перед каждым выпуском сверяет сценарий `check:semver`: он сравнивает контракт API (`docs/api-contract.json`)
412
- и ревизию движка с прошлым выпуском и требует шаг не меньше нужного. Каждый следующий выпуск 2.x проверяет
413
- обновление схемы с предыдущего выпуска.
470
+ Шаг версии перед каждым выпуском сверяет сценарий `check:semver`: он сравнивает контракт API (`lib/api-contract.json`)
471
+ и ревизию движка с прошлым выпуском и требует шаг не меньше нужного. Каждый выпуск проверяет
472
+ обновление схемы с предыдущего выпуска. Что изменилось при переходе на 3.0 и чем заменить снятое — запись
473
+ версии в [CHANGELOG.md](CHANGELOG.md).
414
474
 
415
475
  ### Разработка без PostgreSQL: PGlite
416
476
 
@@ -471,13 +531,8 @@ const line = link('line', { qty: z.number().int().min(1), price: z.number().int(
471
531
  ends: { Order: one(Order).onDelete('cascade'), Product },
472
532
  key: ['Order', 'Product'],
473
533
  })
474
- const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0).default(0) },
475
- { ends: { Customer }, key: ['number'] })
476
- const Movement = link('Movement', {
477
- opId: z.string(), kind: z.enum(['topup', 'debit', 'adjust']), amount: z.number().int(), target: z.number().int().optional(),
478
- }, { ends: { Wallet }, key: ['Wallet', 'opId'] })
479
534
 
480
- const models = [Shop, Category, Product, Customer, Vip, Order, line, Wallet, Movement]
535
+ const models = [Shop, Category, Product, Customer, Vip, Order, line]
481
536
  const plan = await syncModels(db, models) // сухой прогон: что будет создано и изменено
482
537
  const applied = await syncModels(db, models, { apply: true }) // { applied: true, created: ['Shop', …], … }
483
538
  ```
@@ -489,8 +544,13 @@ const applied = await syncModels(db, models, { apply: true }) // { applied: true
489
544
  поле необязательным, `.default(…)` — задаёт умолчание, которое база подставит при вставке. Пометки `.meta()` у поля
490
545
  (`title`, `description`, свои ключи) база хранит, но на проверку данных они не влияют.
491
546
 
492
- Правила `refine`, `superRefine` и `transform` в JSON Schema не выражаются, и база их не исполняет. `syncModels`
493
- возвращает их в `lost`: проверяйте такие правила в приложении. Описание с ключевым словом JSON Schema, которого
547
+ Правила `refine` и `superRefine` в JSON Schema не выражаются, и база их не исполняет. `syncModels` возвращает их в
548
+ `lost`: проверяйте такие правила в приложении. Преобразования Zod — `.trim()`, `.toLowerCase()`, `.toUpperCase()`,
549
+ `.normalize()`, `.transform()`, `z.preprocess()`, `.pipe()` — меняют значение, а не проверяют его: ни база, ни
550
+ валидатор их не исполняют, и данные записались бы как есть. Поэтому модель с ними `syncModels` (и `letopis sync`)
551
+ отклоняет до записи, в том числе в сухом прогоне: `invalid_class` (`model.transform_unsupported`, пути и виды — в
552
+ `err.detail.rules`). Значение приводят в коде сервиса до записи (`value.trim()`), а в модели оставляют проверку:
553
+ `.regex()`, `.min()`, `.refine()`. Описание с ключевым словом JSON Schema, которого
494
554
  база не знает, отклоняется — ни одно правило не пропадёт молча. Шаблоны `pattern` — ограниченное подмножество
495
555
  регулярных выражений, общее для Zod, валидатора и базы. Исполняет шаблон на данных только база: шаблон задаёт
496
556
  арендатор, и шаблон с катастрофическим перебором повесил бы процесс приложения. Поэтому валидатор на TypeScript
@@ -521,7 +581,10 @@ const applied = await syncModels(db, models, { apply: true }) // { applied: true
521
581
  Нужный формат ключевого поля задайте в модели, и база будет его проверять.
522
582
 
523
583
  - id не передают: база вычисляет его сама. Чужой id — `id_mismatch`, id для класса без ключа — `id_from_db`.
524
- - Заранее id вычисляет `db.idOf(класс, ключ)`, например `db.idOf('Shop', { name: 'Ромашка' })`.
584
+ - Заранее id вычисляет `db.idOf(класс, ключ)`, например `db.idOf('Shop', { name: 'Ромашка' })`. Часть ключа — конец
585
+ связи: имя роли и id цели строкой, `db.idOf('Product', { Shop: shop.id, sku: 'K-1' })` (не строка ответа). Часть
586
+ ключа не строкой — `invalid_data` (`model.key_part_not_string`), конец не uuid — `invalid_data`
587
+ (`model.key_end_not_uuid`).
525
588
  - Класс без ключа (`Customer` выше) получает id от случайной части; узнать его можно из ответа записи.
526
589
  - Ключ и класс объекта неизменны: правка ключевого поля или класса — `immutable_key`. Объект с другим ключом или
527
590
  классом — новый объект ([раздел 5](#5-запись), «Ключ и класс неизменны»).
@@ -556,7 +619,21 @@ id из email подбирается перебором и остаётся в
556
619
  `syncModels` трогает только переданные классы и ничего не удаляет. Безопасные изменения — новое необязательное поле или
557
620
  конец, метаданные, алиас — применяются сразу. Всё остальное, в том числе любая правка условий `anyOf` и `oneOf`, —
558
621
  ужесточение: оно проверяет все строки семейства, и если им мешают строки, приходит `tightening_conflict` со списком. Классы пишутся в арендатора сессии, и другие арендаторы их не видят.
559
- Классы, общие для всех, лежат в System: их ставит сид установки.
622
+ Классы, общие для всех, лежат в System: их ставит сид установки. Модель с преобразованиями Zod `syncModels`
623
+ отклоняет ([«Поля»](#поля)).
624
+
625
+ Одна модель в нескольких арендаторах — `syncModels` в сессии каждого: сервис действует от имени пользователя, которому
626
+ в этом арендаторе можно писать классы (по правилу «можно всё» — его члену):
627
+
628
+ ```ts
629
+ for (const [user, tenant] of [[ANNA, SHOP], [BORIS, lutik.tenant]]) {
630
+ await syncModels(await svc.as(user, { tenant }), models, { apply: true }) // классы — в каждом арендаторе свои
631
+ }
632
+ ```
633
+
634
+ Из командной строки — тот же `letopis sync` с токеном сессии каждого арендатора. Повтор по арендатору, где модель уже
635
+ записана, ничего не меняет (`unchanged`). Администраторское подключение для этого не нужно: через фасад `svc.as()`
636
+ классы пишет сессия пользователя в пуле приложения, под RLS.
560
637
 
561
638
  То же из командной строки:
562
639
 
@@ -610,9 +687,28 @@ const n = await loose(typed)[name]().count()
610
687
 
611
688
  | Что | Ответ |
612
689
  |---|---|
613
- | `update()` или `delete({ confirm: true })`, а ни у шага глагола, ни у шагов до него нет фильтра, id, непустого `tags()`, `owner()` или `limit()` и раньше в цепочке нет `create()` или `upsert()` (цель `update()` задаёт и `data.id` патча) | `invalid_query` до записи, в батче — при `run()`; все строки шага — `{ all: true }` ([раздел 5](#изменить-update), [раздел 7](#превью-и-удаление)) |
690
+ | `delete({ confirm: true })` без `expect` (и без `all: true`) | `invalid_query` (`write.confirm_without_expect`) до записи, в батче — при `run()`: удаление не сверено с превью, которое видел пользователь ([раздел 7](#превью-и-удаление)) |
691
+ | `restore()` без `expect` (и без `all: true`), в том числе по явному id | `invalid_query` (`write.restore_without_expect`) до записи ([«Восстановить: `restore`»](#восстановить-restore)) |
692
+ | `update()` или `delete({ confirm: true, expect })`, а ни у шага глагола, ни у шагов до него нет фильтра, id, непустого `tags()`, `owner()` или `limit()` и раньше в цепочке нет `create()` или `upsert()` (цель `update()` задаёт и `data.id` патча) | `invalid_query` (`write.no_filter`) до записи, в батче — при `run()`; все строки шага — `{ all: true }` ([раздел 5](#изменить-update), [раздел 7](#превью-и-удаление)) |
614
693
  | неизвестная опция `connect()`, `up()`, `db.load()`, `db.watch()`, `createMcpServer()` | `invalid_data` с похожими именами |
615
- | шаг — не класс, не алиас и не роль реестра | `invalid_class` с похожими именами — при вызове шага |
694
+ | шаг — не класс, не алиас и не роль реестра | `invalid_class` с похожими именами (системные классы в похожие не идут) — при вызове шага; подсказка — классы арендатора видны только в его сессии. В реестре сессии нет ни одного класса арендатора — свой ключ `api.unknown_step_no_classes`: сессия, похоже, в личном арендаторе ([«Работа в другом арендаторе»](#работа-в-другом-арендаторе)) |
695
+ | у цепочки спросили свойство массива — `.length`, `.map()`, `.filter()`, `.forEach()`, `[0]` и т. п. — или перебрали её (`for…of`, `[...цепочка]`) | `invalid_query` (`api.chain_not_rows`): цепочка — не массив, строки даёт терминал — `await ….rows()` |
696
+ | id шага, фильтра `{ id }` или значения конца — не uuid, `null` вместо id | `invalid_query` (`query.bad_uuid`) до запроса; id в любом виде, который принимает PostgreSQL (без дефисов, в фигурных скобках), проходит |
697
+ | модель с преобразованием Zod (`.trim()`, `.transform()` …) | `invalid_class` (`model.transform_unsupported`) от `syncModels` до записи ([«Поля»](#поля)) |
698
+ | `db.begin(аргумент)` — функция, строка, число, массив вместо опций `{ reason }` | `invalid_query` (`api.begin_arg`): `begin()` функцию не вызывает; неизвестная опция `begin()` — `invalid_data` с похожими |
699
+ | `restore({ partial: true })` без `expect`, в том числе с `all: true` | `invalid_query` (`write.partial_without_expect`) |
700
+ | `createTenant(admin, …)`, где `admin` — не пул `postgres()`: строка DSN, подключение `connect()`, `undefined` | `invalid_query` (`api.create_tenant_admin`) до запросов ([«Первый арендатор и пользователь»](#первый-арендатор-и-пользователь)) |
701
+ | метод цепочки у результата `versions()`: `versions().rows()`, `.sort()`, `.limit()` | `invalid_query` (`api.versions_terminal`): `versions()` — терминал, модификаторы — до него |
702
+ | `forUpdate()` с тем, что блокировать нельзя: `count()`, `paths()`, `versions()`, агрегат, `asOf()`, `withDeleted()`, `deep()`, глагол | `lock_unsupported` (`api.for_update_unsupported`): текст называет помеху |
703
+
704
+ **Предупреждения** — не отказы: цепочка исполняется, а в консоль один раз на место (`console.warn`, текст начинается с
705
+ `letopis:`) выводится строка:
706
+
707
+ | Что | Ключ | Почему |
708
+ |---|---|---|
709
+ | шаг с аргументом `undefined`: `db.Product(undefined)` | `api.step_undefined_filter` | фильтра нет — шаг находит все строки, как `db.Product()`; чаще всего это пустая переменная с id |
710
+ | шаг после `sort()`: `db.Shop(shop).sort('data.name').Product()` | `api.sort_not_last` | `sort()` относится к последнему шагу цепочки — ставьте его в конец |
711
+ | `withDeleted()` не у последнего шага, а у последнего его нет: `db.Order(o).withDeleted().line()` | `api.with_deleted_step` | `withDeleted()` — модификатор своего шага: здесь удалённые заказы и живые строки; удалённые строки — `.line().withDeleted()` ([«Удалённые»](#удалённые)) |
616
712
  | поле фильтра (и внутри `or()`), `sort('data.…')` или агрегата, которого нет в описании ни одного класса шага (с потомками, если шаг не `exact()`; у роли — её целей); в фильтре годится и роль конца | `invalid_query` с похожими именами до запроса, у плана записи — до записи. Сверяется только первый ключ пути: путь внутрь поля-объекта не проверяется. У строк `Class` поля — свойства описания класса (`kind`, `alias`, `parent` и другие) |
617
713
  | `after()` перед `count()`, `sum()`, `avg()`, `min()`, `max()` или `countBy()` | `invalid_query`: курсор листает строки, а счёт идёт по всем объектам шага |
618
714
  | класс с зарезервированным именем | `invalid_class`: хаб — от `hub()`, `syncModels` и базы, связь — от базы ([«Имена классов»](#имена-классов)) |
@@ -658,8 +754,19 @@ try {
658
754
  защищён: повтор создаст второй объект.
659
755
  - Теги новой строки задаёт `.tags([…])` перед глаголом, владельца — `.owner(id)`; явного владельца должны разрешать
660
756
  права.
661
- - Удалённый объект можно создать снова тем же ключом: история продолжится.
757
+ - Удалённый объект можно создать снова тем же ключом: новый объект **занимает id удалённого** (id — v5 от ключа), и
758
+ история id продолжится. Восстановить прежний после этого нельзя: `restore({ expect })` по его надгробию — `not_deleted`
759
+ (`write.restore_id_taken`). Если удалённое ещё могут вернуть, перед `create()` проверьте
760
+ ключ вместе с удалёнными: `db.Shop({ name: 'Ромашка' }).withDeleted().first()` — строка с `$deleted: true` значит,
761
+ что ключ принадлежит удалённому объекту, и решать, восстановить его или занять ключ, должно приложение.
662
762
  - Ответ — строки созданных объектов со всеми колонками ([раздел 14](#14-справочник-api), «Строка ответа»).
763
+ - Ключи со значением `undefined` (`{ phone: undefined }`) `create`, `upsert` и `update` отбрасывают. У `update` поле
764
+ поэтому не меняется, а `upsert` заменяет данные целиком — поле с `undefined` у него **пропадает**, как не
765
+ переданное. `null` — значение: оно проходит, только если схема поля его допускает (`.nullable()`).
766
+ - Концы — не поля данных: ключ с именем роли (`{ Shop: shop.id }`) в данных `create` — ошибка `invalid_data` с ключом
767
+ `write.link_in_data` до записи: текст называет ключ и класс («… — связь, а не поле данных») и сразу форму слота —
768
+ `.Shop.set(id)`; конец берётся из пути или задаётся слотом ([«Концы: из пути и слоты»](#концы-из-пути-и-слоты)). То
769
+ же у `upsert` и `update`.
663
770
 
664
771
  ### Изменить: `update`
665
772
 
@@ -678,6 +785,15 @@ await db.Shop(shop).Product({ price: gte(2000) }).update({ title: 'Чайник
678
785
  сужает); без них — `invalid_query` до записи
679
786
  ([«Строгий режим»](#строгий-режим)). Править все строки шага — явно: `db.Product().update({ views: 0 }, { all: true })`
680
787
  правит все товары арендатора. Шаг ничего не нашёл — ответ пустой и ошибки нет, поэтому проверяйте длину ответа.
788
+
789
+ Фильтр, который сужает цели не только по id (по данным, концам, `tags()`, `owner()`), `update` перепроверяет: цели
790
+ блокируются (`for no key update`, по порядку id) и читаются заново тем же запросом, а правятся только строки, которые
791
+ и после ожидания блокировки подходят. Так `db.Product({ price: gte(2000) }).update(…)` не тронет товар, который
792
+ параллельная транзакция успела удешевить, а из двух одновременных `db.Order({ id, status: 'new' }).update({ status:
793
+ 'paid' })` проходит один — второй отвечает `[]`. Фильтр родительского шага перепроверяется так же. Без гонки цели те
794
+ же. Фильтр только по id перепроверять нечего: две одновременные правки одного id пройдут обе, вторая поверх первой. Перепроверка защищает только условие самого фильтра:
795
+ новое значение, вычисленное из прочитанного, защищают `{ rev }` и `forUpdate()` в `db.begin()`, а правило «не больше
796
+ одной открытой записи на объект» — ключ связи ([«Восстановить: `restore`»](#восстановить-restore)).
681
797
  Если `update` затронул меньше строк, чем нашёл шаг, весь план откатывается: строку успели удалить —
682
798
  `target_not_found`, строка видна, но править её нельзя — `acl_denied`. Модификатор `.tags(…)` перед `update`
683
799
  отбирает строки; строка или список ещё и заменяют их теги целиком, а оператор (`has`, `hasAny`, `hasAll`) только
@@ -695,9 +811,10 @@ const [replaced] = await db.Shop().upsert({ name: 'Ромашка', city: 'Уф
695
811
  один оператор `INSERT … ON CONFLICT`, поэтому гонки «проверил, что нет, — создал» не бывает. Правила:
696
812
 
697
813
  - только класс с ключом, иначе `no_key` без обращения к базе;
698
- - объект передаётся целиком: поля, которых нет в переданном объекте, пропадают;
814
+ - объект передаётся целиком: поля, которых нет в переданном объекте или которые равны `undefined`, пропадают;
815
+ сохранить прежнее значение поля — `update`, а не `upsert`;
699
816
  - ответ несёт `$upsert`: `created`, `updated` или `unchanged`;
700
- - приращать `upsert` не умеет и затирает параллельные приращения — для денег он не годится;
817
+ - приращать `upsert` не умеет и затирает параллельные приращения — для счётчиков он не годится;
701
818
  - для синхронизации больших справочников дешевле загрузчик с `existing: 'update'` ([раздел 9](#9-массовая-загрузка)).
702
819
 
703
820
  ### Концы: из пути и слоты
@@ -716,6 +833,16 @@ await db.Category(green).update({}).Parent.unset().rows() // катег
716
833
  await db.Category(green).update({}).Parent.set(tea).rows() // и снова внутри «Чая»
717
834
  ```
718
835
 
836
+ У связи с двумя концами один конец обычно приходит из пути, второй — слотом; можно и оба слотом. Строка заказа
837
+ `line` с концами `Order` и `Product` (пример целиком — в [«Как исполняется план»](#как-исполняется-план)):
838
+
839
+ ```ts
840
+ await db.Order(order).line().create({ qty: 1, price: 900 }).Product.set(sencha).rows() // Order — из пути
841
+ // или так — оба конца слотом (вместо строки выше: ключ [Order, Product] второй такой строки не даст)
842
+ await db.line().create({ qty: 1, price: 900 }).Order.set(order).Product.set(sencha).rows()
843
+ // db.line().create({ qty: 1, price: 900, Order: order, Product: sencha.id }) — invalid_data: концы не поля данных
844
+ ```
845
+
719
846
  ### Числа и счётчики: `inc`
720
847
 
721
848
  <!-- run: guide -->
@@ -734,7 +861,7 @@ await db.Product(kettle).update({ views: inc(1, { start: 0 }) }).rows() // п
734
861
  - поле должно уже быть числом; если его нет — `invalid_data` или `start`. `null` и нечисло — ошибка даже со `start`;
735
862
  - путь идёт только через существующие объекты;
736
863
  - маркер `inc` нельзя получить из JSON: `{ "$inc": 5 }`, пришедший из сети, остаётся данными;
737
- - деньги храните целым числом мелких единиц (копеек) не больше 2^53 − 1.
864
+ - суммы храните целым числом мелких единиц (копеек) не больше 2^53 − 1.
738
865
 
739
866
  Операции `inc` одного объекта идут друг за другом под блокировкой его строки, и каждая пишет его версию: писатели
740
867
  одного горячего счётчика ждут друг друга.
@@ -755,7 +882,7 @@ try {
755
882
  Правка с `{ rev }` нужна, когда новое значение вычислено из прочитанного: если строку успели изменить, приходит
756
883
  `conflict`, и ничего не записывается. `{ rev }` работает при одной цели шага (иначе `rev_ambiguous`). `conflict`
757
884
  библиотека сама не повторяет: что делать с изменившимися данными, решает приложение. Другой способ — чтение с
758
- блокировкой `forUpdate()` ([раздел 8](#8-транзакции-батчи-и-деньги)).
885
+ блокировкой `forUpdate()` ([раздел 8](#8-транзакции-и-батчи)).
759
886
 
760
887
  ### Ключ и класс неизменны
761
888
 
@@ -816,6 +943,13 @@ const onlyCustomer = await db.Customer().exact().rows() // только
816
943
  ценой `"900"`. Список как значение поля — «массив содержит все элементы». Операторы приводят своё значение к типу
817
944
  поля из JSON Schema. Роль конца в фильтре — id цели или `null`.
818
945
 
946
+ Тип поля для сравнения — по классу шага. Шаг по родителю отдаёт и потомков, и в таком семействе поле может быть
947
+ описано разными типами (потомок переопределил тип, в одном из классов поле не описано). Тогда значение, которое к
948
+ типу класса шага не приводится, в операторах сравнения, `sort()`, `after()` и агрегатах `min`, `max`, `sum`, `avg`
949
+ считается `null`: при сортировке по возрастанию оно идёт после прочих (по убыванию — первым), ни одному оператору
950
+ сравнения не отвечает, агрегат его пропускает, а курсор такой строки — как курсор `null`. Значение, которое
951
+ приводится (`'7'` у числового поля), сравнивается как число. В семействе с одним типом поля всё как обычно.
952
+
819
953
  <!-- run: guide -->
820
954
  ```ts
821
955
  import { lt, ilike, between, or, not } from 'letopis'
@@ -855,6 +989,18 @@ const roots = await db.Category({ Parent: null }).rows() // корневы
855
989
 
856
990
  Теги и владелец строки — модификаторы `.tags(…)` и `.owner(…)`.
857
991
 
992
+ Пустые значения в фильтре:
993
+
994
+ - `{ поле: null }` — вхождение в `data`: находит только строки, где в поле явно записан `null`. Строки, где поля нет
995
+ совсем, находят `isNull()` (и `null`, и нет поля) и `exists(false)` (только нет поля).
996
+ - Фильтр `undefined` — шаг без фильтра: `db.Product(undefined).first()` вернёт любой товар и выведет предупреждение
997
+ `api.step_undefined_filter` ([«Строгий режим»](#строгий-режим)). Ключ со значением `undefined` в объекте фильтра
998
+ (`{ id: undefined }`, `{ Shop: undefined }`) отбрасывается. `update()` без сужения откажет сам (`invalid_query`), а
999
+ чтение — нет: обязательные id из запроса проверяйте до цепочки.
1000
+ - id в фильтре — uuid. Строка другого вида (`db.Product('42')`), а также `null` вместо id — `invalid_query`
1001
+ (`query.bad_uuid`) до запроса: `Product(…): "42" — не uuid`. Так же проверяются `{ id }` и значение конца
1002
+ (`{ Shop: '42' }`). Подходит любой вид, который принимает PostgreSQL: с дефисами, без них, в фигурных скобках.
1003
+
858
1004
  ### Терминалы и строка ответа
859
1005
 
860
1006
  | Терминал | Что отдаёт |
@@ -882,6 +1028,13 @@ const page2 = await db.Product().sort('data.price', 'desc')
882
1028
  .after(cursorOf(page1.at(-1)!, 'data.price')).limit(2).rows() // строго после последней строки страницы
883
1029
  ```
884
1030
 
1031
+ Модификаторы выборки не привязаны к шагу, после которого стоят. `sort`, `limit`, `offset`, `after` и `forUpdate`
1032
+ относятся к последнему шагу цепочки (а перед глаголом — к его целям), `asOf` — ко всей цепочке:
1033
+ `db.Shop(shop).sort('data.price').Product().rows()` сортирует товары, а поле `sort()` сверяется с классом последнего
1034
+ шага — иначе `invalid_query` с именем этого класса. Шаг после `sort()` выводит предупреждение `api.sort_not_last`:
1035
+ ставьте `sort()` в конец цепочки. `withDeleted()` — модификатор своего шага, как `exact()`
1036
+ ([«Удалённые»](#удалённые)).
1037
+
885
1038
  `sort(поле, 'asc' | 'desc')` — по `'at'`, `'rev'`, `'id'` или `'data.<путь>'`. `after(cursorOf(строка, поле))` —
886
1039
  постраничный вывод «после такой-то строки»; он требует `sort()`. Такой вывод устойчив к вставкам и удалениям между
887
1040
  страницами, но строка, у которой изменилось поле сортировки, может выпасть или повториться. Курсор — объект
@@ -936,7 +1089,8 @@ const orderPaths = await db.Order(order).line().Product().paths() // [{ Order:
936
1089
  ```
937
1090
 
938
1091
  `paths()` отдаёт узлы каждого варианта пути; ключ узла — имя шага или имя из `.alias(имя)`. Ключи идут в порядке
939
- шагов; если цепочка кончается возвратом к прежнему узлу, этот узел — последний. Повтор связи в
1092
+ шагов; если цепочка кончается возвратом к прежнему узлу, этот узел — последний. Совпавшие имена получают первый
1093
+ свободный ключ: `x`, `x_2`, `x_3` и так далее, ни один узел не теряется. Повтор связи в
940
1094
  цепочке возвращает к её узлу: так из одной связи можно пройти в несколько сторон. `db.entity(строка | цепочка)`
941
1095
  вставляет готовый узел в путь.
942
1096
 
@@ -947,25 +1101,35 @@ const orderPaths = await db.Order(order).line().Product().paths() // [{ Order:
947
1101
  <!-- run: guide -->
948
1102
  ```ts
949
1103
  const preview = await db.Order(order).delete().rows() // ничего не удаляет: заказ и его строки, $action каждой
950
- await db.Order(order).delete({ confirm: true }).rows() // удалено: надгробия с $deleted: true
1104
+ const goneOrder = await db.Order(order).delete({ confirm: true, expect: preview }).rows() // надгробия, $deleted: true
951
1105
 
952
- const blockers = (await db.Shop(shop).delete().rows()).filter((x) => x.$action === 'restrict')
1106
+ const shopPreview = await db.Shop(shop).delete().rows()
1107
+ const blockers = shopPreview.filter((x) => x.$action === 'restrict')
953
1108
  try {
954
- await db.Shop(shop).delete({ confirm: true }).rows() // магазину мешают товары: конец Shop — restrict
1109
+ await db.Shop(shop).delete({ confirm: true, expect: shopPreview }).rows() // магазину мешают товары: restrict
955
1110
  } catch (e) {
956
1111
  if (!(e instanceof LetopisError) || e.code !== 'delete_restricted') throw e
957
1112
  }
958
1113
  ```
959
1114
 
960
- `delete()` без `{ confirm: true }` — только превью: всё замыкание каскада. В каждой строке превью `$action`:
961
- `delete`, `unset` или `restrict`, и `$depth` — уровень каскада (0 — сама цель; у строк `unset` и `restrict` его
962
- нет). С подтверждением удаляются все цели шага одним оператором, а каскад применяет правила концов. Цели, как у
963
- `update`, сужают фильтр, id, `tags()`, `owner()` или `limit()` у шага глагола или шагов до него (или `create()` и
964
- `upsert()` раньше в цепочке), иначе `invalid_query` до записи;
965
- удалить все строки шага — `delete({ confirm: true, all: true })`, превью — без условий. Итог —
966
- надгробия по уровням каскада, внутри уровня по id (в таком порядке они и в журнале); цели классов без журнала
967
- (`history: false`) — в конце. Превью не учитывает права: строка с `$action: 'delete'` может не удалиться у
968
- пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить `acl.checkData(класс, 'DELETE')`.
1115
+ Удаляют в два шага: `delete()` — превью, его показывают человеку; `delete({ confirm: true, expect: превью })` —
1116
+ удаление ровно показанного. `delete()` без `{ confirm: true }` ничего не удаляет и отдаёт всё замыкание каскада. В
1117
+ каждой строке превью `$action`: `delete`, `unset` или `restrict`, и `$depth` — уровень каскада (0 — сама цель; у
1118
+ строк `unset` и `restrict` его нет). С подтверждением удаляются все цели шага одним оператором, а каскад применяет
1119
+ правила концов. Итог — надгробия по уровням каскада, внутри уровня по id (в таком порядке они и в журнале); цели
1120
+ классов без журнала (`history: false`) — в конце. Превью не учитывает права: строка с `$action: 'delete'` может не
1121
+ удалиться у пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить `acl.checkData(класс, 'DELETE')`.
1122
+
1123
+ - `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`) до записи, ничего не
1124
+ удалено: удаление не сверено с тем, что видел пользователь. Подсказка ошибки — та же форма в два шага.
1125
+ - Цели, как у `update`, сужают фильтр, id, `tags()`, `owner()` или `limit()` у шага глагола или шагов до него (или
1126
+ `create()` и `upsert()` раньше в цепочке), иначе `invalid_query` (`write.no_filter`) до записи; превью — без условий.
1127
+ - Осознанно удалить текущий состав целей без сверки с превью — `delete({ confirm: true, all: true })`: с фильтром —
1128
+ текущие цели фильтра, без фильтра — все строки класса. `all` вместе с `expect` снимает только требование фильтра:
1129
+ превью сверяется. Без сверки — только для скриптов обслуживания, не в ответ на действие пользователя.
1130
+ - Чужой, невидимый или несуществующий id — превью пустое, и удаление с `expect: []` отвечает пустым ответом без
1131
+ ошибки, как `update`: проверяйте длину ответа.
1132
+
969
1133
  Правила концов:
970
1134
 
971
1135
  - `restrict` — удаление запрещено, пока на цель ссылаются (`delete_restricted` со списком видимых строк и числом
@@ -976,21 +1140,172 @@ try {
976
1140
  Каскад требует права на удаление каждой удаляемой строки, снятие ссылки — права на запись. Сырой
977
1141
  `DELETE … WHERE id IN (…)` любого набора связанных строк работает так же.
978
1142
 
1143
+ **Сверка с превью.** Между превью и подтверждением данные могут измениться: к заказу добавили строку, строку
1144
+ поправили. С `expect: превью` удаление в своей транзакции строит превью заново и сравнивает его с показанным — по `id`,
1145
+ `rev` и `$action` (строки без `$action` сравниваются по `id` и `rev`). Совпало — удаляет. Не совпало — `conflict` с
1146
+ ключом `write.preview_changed`, ничего не удалено, новое превью — в `err.detail.preview`: его показывают пользователю и
1147
+ удаляют уже с ним. `expect` без `confirm: true` — `invalid_query` (`write.expect_without_confirm`), `expect` не
1148
+ списком строк `{ id, rev }` — `invalid_query` (`write.bad_expect`).
1149
+
1150
+ `expect` — строки превью как есть (тип `Expected`: `{ id, rev, $action? }`), порядок не важен. `err.detail.preview` —
1151
+ `Row[]` той же формы, что превью: строки с `$action` и, у строк `delete`, `$depth`. Сверка атомарна и под гонкой: всё
1152
+ замыкание каскада (строки превью с `$action: 'delete'`) блокируется в порядке id, и превью строится ещё раз уже под
1153
+ блокировкой. Связь к удаляемой строке, добавленная до этого, попадает в новое превью (`preview_changed`); добавленная
1154
+ позже ждёт удаления и получает `target_not_found` (`sql-write.end_target_missing`) — непоказанное каскадом не уходит.
1155
+ Взаимную блокировку двух таких удалений операция из пула повторяет сама (`40P01`), в `db.begin()` — повторяет
1156
+ вызывающий. Пустые цели: `expect: []` и целей нет — пустой ответ; `expect` непуст, а целей уже нет (удалены, не
1157
+ видны) — `conflict` с пустым `err.detail.preview`.
1158
+
1159
+ ```ts
1160
+ const shown = await db.Order(id).delete().rows() // превью — пользователю
1161
+ // … пользователь подтвердил
1162
+ try {
1163
+ const gone = await db.Order(id).delete({ confirm: true, expect: shown }).rows() // удалено ровно показанное
1164
+ // gone — надгробия: сохраните, по ним возвращают ровно ушедшее
1165
+ } catch (e) {
1166
+ if (!(e instanceof LetopisError) || e.code !== 'conflict') throw e
1167
+ const fresh = (e.detail as { preview: Row[] }).preview // данные изменились: показать заново
1168
+ }
1169
+ ```
1170
+
979
1171
  ### Восстановить: `restore`
980
1172
 
981
1173
  <!-- run: guide -->
982
1174
  ```ts
983
- await db.Order(order).restore().rows() // заказ — последним снимком, id прежний
984
- await db.Order(order).line().withDeleted().restore().rows() // и его строки, удалённые каскадом
1175
+ const back = await db.begin({ reason: 'возврат A-1' }) // заказ и его строки — одной транзакцией
1176
+ try {
1177
+ await back.Order(order).restore({ expect: goneOrder }).rows() // заказ — тем снимком, что ушёл
1178
+ await back.Order(order).line().withDeleted().restore({ expect: goneOrder }).rows() // и строки этого каскада
1179
+ await back.commit()
1180
+ } catch (e) {
1181
+ await back.rollback() // ошибка второго шага не оставит заказ без строк
1182
+ throw e
1183
+ }
1184
+ ```
1185
+
1186
+ `restore({ expect })` возвращает удалённый объект его последним снимком новой версией и перепроверяет его по текущему
1187
+ описанию класса; id прежний. `expect` — надгробия `{ id, rev }`: ответ `delete({ confirm: true, expect })` или строки
1188
+ `withDeleted()` с `$deleted: true` (`db.Order(id).withDeleted().rows()`). Восстанавливаются только цели шага, чьи id
1189
+ есть в `expect`, а не весь каскад. Живой объект — `not_deleted`.
1190
+
1191
+ - `restore()` без `expect` — `invalid_query` (`write.restore_without_expect`) до записи, и по явному id тоже: без
1192
+ `expect` поднялось бы **последнее** надгробие id, а id удалённого может достаться другому — `create()` по ключу
1193
+ удалённого занимает его id ([«Создать: `create`»](#создать-create)), а новый объект потом тоже удаляют.
1194
+ - Осознанно вернуть все удалённые цели шага без сверки версий — `restore({ all: true })`: с фильтром — удалённые цели
1195
+ фильтра, без фильтра — всё удалённое класса; `all` вместе с `expect` — сверка по `expect`.
1196
+ - Цели — удалённые объекты последнего шага: шаг глагола `restore()` сам читает с удалёнными, `withDeleted()` перед
1197
+ ним не обязателен. Чужой, невидимый или несуществующий id, id не из `expect` — пустой ответ без ошибки, как у
1198
+ `update`: проверяйте длину ответа.
1199
+ - Шаги до глагола идут по живым строкам, если у них нет своего `withDeleted()`. Поэтому каскад возвращают после цели —
1200
+ `db.Order(order).line().restore({ expect })` находит строки восстановленного заказа, — а строки ещё удалённого
1201
+ заказа находит `db.Order(order).withDeleted().line().restore({ expect })`.
1202
+
1203
+ **Сверка с надгробиями.** Восстанавливаются только цели, у которых последняя версия — это самое надгробие из
1204
+ `expect`. Иначе ничего не восстановлено, а отказ называет причину по записи журнала, которая идёт за надгробием цели:
1205
+
1206
+ | Что с id после удаления | `why` | Ключ | Код |
1207
+ |---|---|---|---|
1208
+ | объект уже восстановлен (другим запросом) | `restored` | `write.restore_already` | `not_deleted` |
1209
+ | id занял новый живой объект с тем же ключом (`create()` по ключу удалённого) | `reissued` | `write.restore_id_taken` | `not_deleted` |
1210
+ | прочее: id пересоздан и удалён снова, объект удалён снова с другим номером версии, гонка | `changed` | `write.restore_stale` | `conflict` |
1211
+
1212
+ У всех трёх `err.detail.ids` — id этого вида, а `err.detail.stale` — все цели, которые не восстановить:
1213
+ `[{ id, rev, expected, after, deleted, live, why }]`. `rev` — номер последней версии сейчас, `expected` — из
1214
+ `expect`, `after` — вид записи журнала сразу за `expected` (`create`, `update`, `delete`, `restore`; `null` — её нет),
1215
+ `deleted` — последняя версия — надгробие, `live` — объект живёт, `why` — из таблицы. Цели разного вида в одном вызове
1216
+ дают `restore_stale`, если среди них есть `changed`, иначе `restore_id_taken`, если есть `reissued`. Номер версии база
1217
+ сверяет ещё раз под блокировкой записи журнала и перечитывает последнюю запись после ожидания, поэтому гонку
1218
+ «прочитали надгробие — другой процесс пересоздал id — восстановили» ловит она, а проигравший гонку двух `restore()`
1219
+ получает `write.restore_already`. id из `expect`, которых нет среди целей шага, пропускаются: весь ответ `delete()` можно
1220
+ передавать каждому шагу. Поэтому шаг глагола задавайте id из `expect`: объект, который по пути уже не найти (связь
1221
+ пересоздана с другим концом), по пути молча пропустится, а по id даст `conflict`. Цель и её каскад возвращают одной
1222
+ `db.begin()`, цель конца — первой: отказ на любом шаге откатывает всё.
1223
+
1224
+ ```ts
1225
+ const gone = await db.Order(order).delete({ confirm: true, expect: shown, reason: 'отмена A-1 #7f3a' }).rows()
1226
+ const ids = (cls: string) => gone.filter((x) => x.class === cls).map((x) => x.id)
1227
+ // … позже: вернуть ровно это — заказ и строки этого каскада, одной транзакцией
1228
+ const tr = await db.begin()
1229
+ try {
1230
+ await tr.Order(ids('Order')).restore({ expect: gone }).rows() // заказ — если это всё ещё его надгробие
1231
+ await tr.line(ids('line')).restore({ expect: gone }).rows() // строки этого каскада, удалённые раньше — нет
1232
+ await tr.commit()
1233
+ } catch (e) {
1234
+ await tr.rollback()
1235
+ if (!(e instanceof LetopisError) || (e.code !== 'conflict' && e.code !== 'not_deleted')) throw e
1236
+ // e.key — write.restore_stale, write.restore_id_taken или write.restore_already; причины по целям —
1237
+ // e.detail.stale[].why — пользователю: «id занят новой записью», «после удаления меняли», «уже вернули»
1238
+ }
1239
+ ```
1240
+
1241
+ **Вернуть, что можно: `restore({ expect, partial: true })`.** Без `partial` отказ одной цели отменяет всё. С
1242
+ `partial` восстанавливаются цели, у которых совпал номер версии, а остальные не роняют операцию и приходят в том же
1243
+ ответе строками-пропусками: последняя строка цели (живая или надгробие с `$deleted`) с `$action: 'skip'` и `$why` —
1244
+ `'restored'`, `'reissued'` или `'changed'`, как `why` в таблице выше. Ответ — в порядке целей (`sort()` до глагола,
1245
+ иначе по id). Каждая цель восстанавливается под своей точкой сохранения, поэтому проигранная гонка с другим
1246
+ `restore()` тоже становится пропуском. Ошибки другого рода — `target_not_found`, `invalid_data`, права — роняют
1247
+ операцию, как без `partial`. `partial` без `expect` — `invalid_query` (`write.partial_without_expect`), и с
1248
+ `all: true` тоже. Продолжение цепочки после такого `restore()` идёт только от восстановленных; `count()` и `ids()`
1249
+ сразу после него считают и пропуски — это строки ответа глагола.
1250
+
1251
+ ```ts
1252
+ const tr = await db.begin()
1253
+ try {
1254
+ await tr.Order(ids('Order')).restore({ expect: gone }).rows() // заказ — целиком или отказ
1255
+ const lines = await tr.line(ids('line')).restore({ expect: gone, partial: true }).rows()
1256
+ await tr.commit()
1257
+ const skipped = lines.filter((x) => x.$action === 'skip').map((x) => ({ id: x.id, why: x.$why }))
1258
+ // skipped — пользователю: что не вернулось и почему
1259
+ } catch (e) {
1260
+ await tr.rollback()
1261
+ throw e
1262
+ }
1263
+ ```
1264
+
1265
+ Ответ `delete()` не сохранили — надгробия по id: `db.line(id).withDeleted().rows()` (строки с `$deleted: true`) — и
1266
+ их в `expect`. Ни ответа, ни id — надгробия по пути и причине удаления: каскад получает `reason` глагола. Причину
1267
+ делайте **своей у каждого удаления** (с номером операции, как `#7f3a` выше): у пересозданного и снова удалённого
1268
+ объекта последнее надгробие несёт причину того, второго удаления, и под ваш отбор не попадёт. Путь теряет и другое:
1269
+ объект, чей id занял новый живой объект, по пути от прежнего конца уже не находится (последняя версия ведёт к другому),
1270
+ и пропуска `reissued` по нему не будет — человек не узнает, что он не вернулся. Срез `asOf()` на момент до удаления
1271
+ показывает, что было живо, но его строки — живые версии: их `rev` для `expect` не годится.
1272
+
1273
+ ```ts
1274
+ const ours = (await db.Order(order).withDeleted().line().withDeleted().rows())
1275
+ .filter((x) => x.$deleted && x.reason === 'отмена A-1 #7f3a') // строки удалённого заказа с причиной этого удаления
985
1276
  ```
986
1277
 
987
- `restore()` возвращает последний снимок удалённого объекта новой версией и перепроверяет его по текущему описанию
988
- класса. Восстанавливаются только цели шага, а не весь каскад. Живой объект — `not_deleted`.
1278
+ Штатные ответы `restore()` — их показывают пользователю, а не превращают в 500:
1279
+
1280
+ | Ответ | Когда | Пользователю |
1281
+ |---|---|---|
1282
+ | пустой | id чужой, невидимый, не существует или не среди целей шага | «не найдено» |
1283
+ | `invalid_query`, ключ `write.restore_without_expect` | нет `expect` (и нет `all: true`) | — (ошибка в коде) |
1284
+ | `not_deleted`, ключ `write.not_deleted` | объект жив и не удалялся | «не удалён» |
1285
+ | `not_deleted`, ключ `write.restore_already` | объект уже восстановлен (в том числе проигранная гонка двух `restore()`); id — `err.detail.ids` | «уже восстановлено» |
1286
+ | `not_deleted`, ключ `write.restore_id_taken` | id занял новый живой объект с тем же ключом (`create()` по ключу удалённого); id — `err.detail.ids` | «место занято новой записью» |
1287
+ | `conflict`, ключ `write.restore_stale` | после этого удаления id меняли — пересоздан и удалён снова, удалён снова; причины — `err.detail.stale` | «после удаления запись меняли» |
1288
+ | строки `$action: 'skip'` | с `partial: true` вместо трёх отказов выше; причина — `$why` | что не вернулось и почему |
1289
+ | `target_not_found` | цель конца снимка удалена или не видна | сначала восстановить цель |
1290
+ | `invalid_data` | снимок не проходит нынешнее описание класса | — |
1291
+
1292
+ - `restore()` перепроверяет описание класса, концы (цель конца жива и видна, иначе `target_not_found`), ключ и права,
1293
+ но не бизнес-правила приложения: восстановленная связь вернёт занятость прежнему концу, даже если объект уже занят
1294
+ другим. Гарантию даёт только ключ связи: занятость держит ключ, а не поле и не проверка. Проверка в коде перед
1295
+ `restore()` держит правило, лишь если **все** пути создания и возврата таких связей берут в `db.begin()` одну и ту же
1296
+ блокировку (`forUpdate()` объекта или `tr.lock(…)`) и делают ту же проверку: один путь без неё — обычный `create()`
1297
+ связи — гарантию ломает, потому что `create()` и `restore()` берут на объекте совместимую блокировку и друг друга не
1298
+ ждут. Связь с ключом только из одного конца (`key: ['Obj']`) база второй для того же
1299
+ объекта не создаст (`exists`). Удалённую каскадом такую связь новая на тот же объект создаёт заново с тем же id
1300
+ (v5 от ключа), и восстановить прежнюю уже нельзя: пока новая связь жива, `restore({ expect })` —
1301
+ `not_deleted` (`write.restore_id_taken`); когда удалена и она — `conflict` (`write.restore_stale`); с
1302
+ `partial: true` вместо отказа — строка-пропуск. Общие правила — «одна открытая запись на объект» и возврат по ответу
1303
+ `delete()` — в [памятке](AGENT-CHEATSHEET.md), «Правила для любой задачи».
989
1304
 
990
1305
  Историю удалённого объекта журнал хранит: стереть версии одного объекта нельзя. Удаление аккаунта
991
1306
  (`db.auth.purgeAccount`, [раздел 10](#10-пользователи-вход-и-права)) стирает данные и журнал его арендатора.
992
1307
 
993
- ## 8. Транзакции, батчи и деньги
1308
+ ## 8. Транзакции и батчи
994
1309
 
995
1310
  ### Каждая операция — транзакция
996
1311
 
@@ -1021,6 +1336,9 @@ try {
1021
1336
  библиотека о ней не знает, PostgreSQL молча отвечает `ROLLBACK` на `COMMIT`, а библиотека превращает это в ошибку
1022
1337
  `tx_rolled_back`.
1023
1338
  - Повторов внутри `db.begin()` нет: на `40P01` повторите весь блок.
1339
+ - `db.begin()` принимает только опции `{ reason }`. Функцию она не принимает и не вызывает: `db.begin(async (tr) => …)`,
1340
+ строка, число или массив — отклонённый промис `invalid_query` (`api.begin_arg`) с подсказкой — формой выше;
1341
+ неизвестная опция — `invalid_data` с похожими именами.
1024
1342
  - `lock(...ключи)` — advisory-блокировка до конца транзакции. Она защищает, только если её берут все, кто правит
1025
1343
  эти данные.
1026
1344
 
@@ -1041,74 +1359,23 @@ const results = await b.run() // Row[][] — по массиву стро
1041
1359
  который поставил его в очередь (актор, арендатор, владелец, транзакция), и чужих планов `run()` не исполняет.
1042
1360
  Копите планы через один и тот же объект фасада.
1043
1361
 
1044
- ### Рецепт: кошелёк
1045
-
1046
- Баланс — поле `balance` хаба `Wallet` (целые копейки, `min(0)`), операции — связь `Movement` с ключом
1047
- `[Wallet, opId]` ([раздел 4](#4-модель-данных)). Операцию не правят и не удаляют, а отменяют встречной операцией с
1048
- новым `opId`. Ключ делает операцию идемпотентной: повтор после потери ответа получает `exists`, и рецепт сверяет,
1049
- тот ли это запрос.
1050
-
1051
- <!-- run: guide -->
1052
- ```ts
1053
- import { inc, type Db } from 'letopis'
1054
-
1055
- type Kind = 'topup' | 'debit' | 'adjust'
1056
-
1057
- /** Операция: запись Movement и приращение баланса — один план, одна транзакция. */
1058
- async function operate(db: Db, wallet: string, opId: string, kind: 'topup' | 'debit', amount: number) {
1059
- try {
1060
- await db.Wallet(wallet).Movement().create({ opId, kind, amount })
1061
- .Wallet().update({ balance: inc(kind === 'topup' ? amount : -amount) }).rows()
1062
- return 'done'
1063
- } catch (e) {
1064
- if (e instanceof LetopisError && e.code === 'exists') return same(db, wallet, opId, kind, amount) // повтор
1065
- throw e // invalid_data — не хватает средств (min(0))
1066
- }
1067
- }
1068
-
1069
- /** Отпечаток повтора: тот ли запрос уже записан под этим opId. */
1070
- async function same(db: Db, wallet: string, opId: string, kind: Kind, value: number) {
1071
- const m = await db.Movement(db.idOf('Movement', { Wallet: wallet, opId })).first()
1072
- if (!m || m.data.kind !== kind || (kind === 'adjust' ? m.data.target : m.data.amount) !== value)
1073
- throw new Error(`ключ операции ${opId} уже использован другим запросом`)
1074
- return 'repeat'
1075
- }
1362
+ ### Значение из прочитанного и повтор операции
1076
1363
 
1077
- /** Корректировка «баланс = target»: операция adjust на разницу, итог — под { rev }. */
1078
- async function adjust(db: Db, wallet: string, opId: string, target: number) {
1079
- for (;;) {
1080
- if (await db.Movement(db.idOf('Movement', { Wallet: wallet, opId })).first()) return same(db, wallet, opId, 'adjust', target)
1081
- const cur = (await db.Wallet(wallet).first())!
1082
- try {
1083
- await db.Wallet(wallet).Movement().create({ opId, kind: 'adjust', amount: target - (cur.data.balance as number), target })
1084
- .Wallet().update({ balance: target }, { rev: cur.rev }).rows()
1085
- return 'done'
1086
- } catch (e) {
1087
- if (e instanceof LetopisError && e.code === 'conflict') continue // кошелёк изменился — перечитать
1088
- if (e instanceof LetopisError && e.code === 'exists') return same(db, wallet, opId, 'adjust', target)
1089
- throw e
1090
- }
1091
- }
1092
- }
1364
+ Новое значение, вычисленное из прочитанного, теряет параллельные правки, если его записать просто `update()`.
1365
+ Способов защиты три, и для одного объекта выбирают один:
1093
1366
 
1094
- const [wallet] = await db.Customer(olga).Wallet().create({ number: 'W-1' }).rows()
1095
- await operate(db, wallet.id, 'op-1', 'topup', 10_000) // 'done': баланс 10 000
1096
- await operate(db, wallet.id, 'op-1', 'topup', 10_000) // 'repeat': повтор после потери ответа, баланс тот же
1097
- await operate(db, wallet.id, 'op-2', 'debit', 3_000) // баланс 7 000
1098
- await adjust(db, wallet.id, 'op-3', 5_000) // корректировка: adjust на −2 000, баланс 5 000
1099
- ```
1100
-
1101
- Тот же итог можно записать под чтением с блокировкой — без повторов при `conflict`:
1367
+ - **Приращение** — `update({ поле: inc(n) })`: база прибавляет внутри оператора, чтение не нужно, а `min(0)` в модели
1368
+ отклоняет уход в минус (`invalid_data`, по `e.issues` — поле и правило `minimum`).
1369
+ - **Сверка версии** — `update(патч, { rev })`: строку успели изменить — `conflict`, перечитать и решить заново
1370
+ ([«Защита от параллельной правки»](#защита-от-параллельной-правки--rev-)).
1371
+ - **Чтение с блокировкой** — `forUpdate()` внутри `db.begin()`: строка заблокирована до `commit()`, повторов нет.
1102
1372
 
1103
1373
  <!-- run: guide -->
1104
1374
  ```ts
1105
- const [wallet2] = await db.Customer(olga).Wallet().create({ number: 'W-2' }).rows()
1106
1375
  const tx = await db.begin()
1107
1376
  try {
1108
- const w = (await tx.Wallet(wallet2).forUpdate().first())! // строка кошелька заблокирована до commit
1109
- await tx.Wallet(wallet2).Movement()
1110
- .create({ opId: 'op-1', kind: 'adjust', amount: 6_000 - (w.data.balance as number), target: 6_000 })
1111
- .Wallet().update({ balance: 6_000 }).rows()
1377
+ const locked = (await tx.Product(kettle).forUpdate().first())! // строка заблокирована до commit
1378
+ await tx.Product(kettle).update({ views: (locked.data.views as number) + 1 }).rows()
1112
1379
  await tx.commit()
1113
1380
  } catch (e) {
1114
1381
  await tx.rollback()
@@ -1116,19 +1383,19 @@ try {
1116
1383
  }
1117
1384
  ```
1118
1385
 
1119
- - `inc` прибавляет внутри оператора над заблокированной строкой: параллельные списания не теряются, а `min(0)`
1120
- отклоняет списание сверх баланса.
1121
- - Обычный `update({ balance: 700 })` записывает значение, а не прибавляет. Итог, вычисленный из прочитанного,
1122
- без `{ rev }` или `forUpdate()` теряет параллельные списания. Advisory `lock()` не спасает, если его берут не все
1123
- писатели. `upsert` затирает приращения.
1124
1386
  - `forUpdate()` — только внутри `db.begin()` (иначе `tx_required`) и только с `rows`, `first`, `ids` (иначе
1125
1387
  `lock_unsupported`). Строки блокируются в порядке id; строка, которая перестала подходить под условия шага, в ответ
1126
1388
  не попадает.
1127
- - Для одного кошелька выберите один путь: под `{ rev }` и под `forUpdate()` кошелёк и ключ операции блокируются в
1128
- разном порядке, и смешение в параллельных запросах даёт `40P01`. Внутри блока с `forUpdate()` не ждите внешних
1129
- вызовов: кошелёк ждут все писатели (помогут `lock_timeout` и `idle_in_transaction_session_timeout`).
1130
- - Перевод — две операции в одной `db.begin()`, кошельки правятся в порядке id: тогда взаимных блокировок нет.
1131
- - Нехватку средств отличают от других ошибок данных по `e.issues`: поле `balance`, правило `minimum`.
1389
+ - Под `{ rev }` и под `forUpdate()` объект и ключи блокируются в разном порядке: смешение двух путей в параллельных
1390
+ запросах одного объекта даёт `40P01`. Внутри блока с `forUpdate()` не ждите внешних вызовов: строку ждут все
1391
+ писатели (помогут `lock_timeout` и `idle_in_transaction_session_timeout`).
1392
+ - Несколько объектов в одной `db.begin()` правьте в порядке id: тогда взаимных блокировок нет.
1393
+ - Обычный `update({ поле: значение })` записывает значение, а не прибавляет; `upsert` затирает приращения; advisory
1394
+ `lock()` не спасает, если его берут не все писатели.
1395
+ - **Повтор операции.** Операцию, которую нельзя выполнить дважды, записывайте связью с ключом из объекта и номера
1396
+ операции (`key: ['Объект', 'opId']`) вместе с приращением одним планом: повтор после потери ответа (`commit_unknown`,
1397
+ обрыв связи) получает `exists` и ничего не меняет. Отменяют такую операцию встречной операцией с новым номером, а не
1398
+ правкой и не удалением.
1132
1399
 
1133
1400
  ## 9. Массовая загрузка
1134
1401
 
@@ -1297,7 +1564,7 @@ const failed = await svc.auth.verifyPassword({ identifier: 'boris@lutik.ru', pas
1297
1564
 
1298
1565
  - Пароль хранится хэшем scrypt; проверяет его сервис, время ответа для несуществующего логина выровнено.
1299
1566
  - Идентификатор сравнивается без учёта регистра: `Anna@Shop.ru` и `anna@shop.ru` — один вход.
1300
- - Смена пароля гасит остальные сессии аккаунта (`keepCurrent` — оставить текущую).
1567
+ - Смена пароля гасит остальные сессии аккаунта; текущая остаётся, `keepCurrent: false` гасит и её.
1301
1568
  - По умолчанию вход — только по подтверждённому креду, иначе `null`; `requireConfirmed: false` пускает и по
1302
1569
  неподтверждённому. Подтверждает кред сервис: `confirmed: true` в `setPassword` или `setFlags`.
1303
1570
  - Второй фактор — опция `totp`: `verifyPassword({ identifier, password, totp })` после верного пароля проверяет код
@@ -1359,12 +1626,26 @@ await boris.close()
1359
1626
  Токен — 256 случайных бит; база хранит только его sha256. Срок по умолчанию — 7 суток. `refresh()` продлевает
1360
1627
  текущую сессию: новый срок отсчитывается от текущего момента, но не больше срока, выданного при входе; возвращает
1361
1628
  он новое время истечения. `connect()` продлевает сессию сам раз в минуту работы (`refreshSession`), поэтому сессия,
1362
- которой пользуются, не истекает: срок — это время простоя. `revoke()` — выход, `revokeAll()` — выход везде.
1629
+ которой пользуются, не истекает: срок — это время простоя. `revoke()` — выход: гасит сессию этого подключения;
1630
+ `revoke(token)` — выход пользователя по токену его сессии (ниже); `revokeAll(account?)` — выход везде, то есть все
1631
+ сессии **одного** аккаунта. Общего вызова «погасить все сессии установки» нет: при утечке или после восстановления
1632
+ копии на чужом стенде — остановить процессы приложения и от суперпользователя или роли-владельца выполнить
1633
+ `update "v2.имя".secret set deleted_at = clock_timestamp() where kind = 'SESSION' and deleted_at is null` (ключи API —
1634
+ `kind in ('APIKEY', 'KEYSECRET')` или `revokeCredential(id)`); сигнала этот запрос не шлёт, поэтому процессы
1635
+ перезапускают.
1363
1636
  Сессии гаснут при отключении аккаунта, смене пароля, удалении креда и удалении аккаунта. Удаление креда гасит
1364
1637
  остальные сессии аккаунта, а отзыв ключа API или пары «ключ + секрет» — только сессии, открытые этим ключом (и
1365
1638
  старые сессии того же вида входа без отметки ключа); текущая сессия остаётся, если не передать
1366
1639
  `keepCurrent: false`.
1367
1640
 
1641
+ `apiKey`, `service` или `token`, переданные со значением `null`, пустой строкой или не строкой, `connect()` отклоняет
1642
+ сразу — `invalid_data` (`api.connect_empty_credential`); `undefined` значит «опции нет» — подключение без сессии, и
1643
+ отказ `no_session` придёт на первом вызове. Поэтому ключ из переменной окружения проверяйте до `connect()`.
1644
+
1645
+ `connect({ token })` с токеном, который уже не даёт сессии (погашен `revoke`, истёк, неверен), подключения не создаёт:
1646
+ его промис отклоняется с `no_session` (`api.token_not_active`, «нет действующей сессии: токен погашен, истёк или
1647
+ неверен»), пул закрывается. Пользователь входит заново.
1648
+
1368
1649
  Подключение с постоянным токеном (`connect({ token })`), которым не пользовались дольше срока сессии, остаётся без
1369
1650
  сессии: истёкшую сессию продление не возвращает, а новый токен подключение само не берёт. После простоя дольше минуты
1370
1651
  подключение до операции продлевает сессию и узнаёт, жива ли она: истёкшая — `no_session` на любую операцию, и
@@ -1384,6 +1665,18 @@ await boris.close()
1384
1665
  чтение по погашенной сессии ошибки не даёт, ответы просто пустые. Вызывает метод сервис или системный администратор
1385
1666
  (право `auth.session`); пользователю отвечают `acl_denied`.
1386
1667
 
1668
+ Выход в веб-сервисе — `svc.auth.revoke(token)` с токеном из cookie: сервис гасит сессию пользователя, не подключаясь
1669
+ по ней. Ответ — `true`, если сессия погашена, и `false`, если токен неверный, сессия уже погашена или истекла (причина
1670
+ не различается). Право то же — `auth.session`, без него `acl_denied`. Погашенная сессия шлёт сигнал `session:…`, и
1671
+ процессы сбрасывают кэш её ответов. `svc.auth.revoke()` без аргумента погасил бы сессию самого сервиса:
1672
+
1673
+ ```ts
1674
+ // POST /logout — токен пользователя в cookie
1675
+ const token = req.cookies.session
1676
+ if (token) await svc.auth.revoke(token) // true — вышел; false — сессии уже не было
1677
+ res.clearCookie('session')
1678
+ ```
1679
+
1387
1680
  ### Работа в другом арендаторе
1388
1681
 
1389
1682
  Сервис действует от имени пользователя через `svc.as(account, { tenant, owner })`. База проверяет право сервиса
@@ -1396,13 +1689,68 @@ await boris.close()
1396
1689
 
1397
1690
  <!-- run: guide -->
1398
1691
  ```ts
1399
- const mine = await anna.auth.tenants() // [{ tenant, roles }]: собственный арендатор и магазин по членству
1692
+ const mine = await anna.auth.tenants() // [{ tenant, roles, name, personal }]: собственный арендатор и магазин
1400
1693
  await anna.auth.switch(SHOP) // арендатор сессии — магазин
1401
1694
  ```
1402
1695
 
1403
- `auth.tenants()` отдаёт и собственный арендатор пользователя: первым элементом, с `tenant`, равным id его аккаунта, и
1404
- `roles: ['owner']`. За ним идут арендаторы по членству с ролями членства. Нужны только арендаторы по членству —
1405
- отбросьте элемент с id аккаунта (`login.account` у `verifyPassword`, `user` у `createTenant`).
1696
+ `auth.tenants()` отдаёт арендаторов аккаунта сессии: `{ tenant, roles, name, personal }`. Первым идёт собственный
1697
+ арендатор пользователя — `tenant` равен id его аккаунта, `roles: ['owner']`, `personal: true`, `name` — имя
1698
+ пользователя. За ним — арендаторы по членству (`personal: false`) с ролями членства, по id. `name` — имя аккаунта
1699
+ арендатора: то, что передали в `createTenant({ name })` или `up({ tenant: { name } })`; у аккаунта без имени — `null`.
1700
+ Нужны только арендаторы по членству — `filter((t) => !t.personal)`.
1701
+
1702
+ Сервис узнаёт арендаторов пользователя, не открывая подключения по его токену: от имени пользователя без `tenant`
1703
+ (`svc.as(account)` работает в его личном арендаторе) — тот же `auth.tenants()`. Так после входа выбирают, куда
1704
+ пустить пользователя:
1705
+
1706
+ ```ts
1707
+ const ok = await svc.auth.verifyPassword({ identifier, password })
1708
+ if (!ok) return 'неверный логин или пароль'
1709
+ const shops = (await (await svc.as(ok.account)).auth.tenants()).filter((t) => !t.personal)
1710
+ if (!shops.length) return 'вы не состоите ни в одном магазине'
1711
+ // несколько — список shops.map((t) => t.name) пользователю на выбор; выбранный — в его сессии:
1712
+ const work = await svc.as(ok.account, { tenant: shops[0].tenant })
1713
+ ```
1714
+
1715
+ Сессия, открытая входом (`verifyPassword`, `sessionFor`), `svc.as(account)` без `tenant` и
1716
+ `connect({ token })` до `auth.switch()` работают в **собственном** арендаторе пользователя. Классы, которые
1717
+ `syncModels` записал в арендатор магазина, там не видны: шаг по такому классу — `invalid_class: класс или роль …
1718
+ не найдены`. Поэтому член арендатора работает так: сервис — `svc.as(login.account, { tenant: SHOP })`, своё
1719
+ подключение по токену — `auth.switch(SHOP)`; `db.tenant` показывает, где сессия. `auth.switch()` меняет арендатора
1720
+ самой сессии: токен и дальше работает в выбранном арендаторе. Нет членства — `tenant_denied`.
1721
+
1722
+ Название арендатора пользователь получает из `auth.tenants()` (`name`); саму строку `Account` арендатора он не
1723
+ видит — видна только своя (`db.Account(SHOP).first()` у члена магазина — `null`). Название, которое меняется в
1724
+ интерфейсе приложения, храните в данных арендатора — например, хабом `Shop` с полем `name`.
1725
+
1726
+ ### Веб-сервис: одно подключение и безопасность
1727
+
1728
+ - **Одно подключение сервиса на процесс.** Сервис держит одно подключение по ключу (`connect({ apiKey })`) и на каждый
1729
+ запрос берёт фасад `svc.as(account, { tenant })` — тот же пул. `connect({ token })` на каждый вход открывает
1730
+ отдельный пул (`max`, по умолчанию 10 соединений) на пользователя: несколько десятков входов исчерпают соединения
1731
+ PostgreSQL. `close()` у фасада `as()` ничего не делает; закрывают `svc` при остановке процесса.
1732
+ - **Арендатор — только из серверной сессии.** В начале запроса — `svc.auth.session(token)` по токену из cookie
1733
+ (`null` — не вошёл: 401, никаких данных); арендатор — из конфигурации сервера или из выбора, сохранённого на сервере
1734
+ при входе, и никогда из параметра запроса (`?club=`, тело, заголовок). Нет членства — `svc.as()` отвечает
1735
+ `tenant_denied` (403).
1736
+ - **Экранирование.** Данные из базы, вставленные в HTML, экранируют все пять символов `& < > " '` (или пишут через
1737
+ `textContent`), а обработчики событий (`onclick="…"`) из данных не строят: id — в атрибуте `data-*`, обработчик —
1738
+ `addEventListener`.
1739
+ - **Ключ сервиса** выдаётся один раз ([«Сервис и ключи API»](#сервис-и-ключи-api)): храните его в файле с правами
1740
+ только владельца (`chmod 600`) или в хранилище секретов, файл — в `.gitignore`; в репозиторий и клиенту он не
1741
+ попадает. Свой ключ заранее — `up({ serviceKey })`.
1742
+
1743
+ ```ts
1744
+ const svc = await connect({ dsn: APP, schema: 'v2.shop',
1745
+ apiKey: readFileSync('/etc/shop/service.key', 'utf8').trim() }) // один раз на процесс
1746
+ async function userDb(token: string | undefined) { // в начале каждого запроса
1747
+ const s = token ? await svc.auth.session(token) : null
1748
+ if (!s) return null // 401
1749
+ return svc.as(s.account, { tenant: SHOP }) // арендатор — с сервера; нет членства — tenant_denied
1750
+ }
1751
+ const esc = (v: string) => v.replace(/[&<>"']/g, (c) =>
1752
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
1753
+ ```
1406
1754
 
1407
1755
  ### Права
1408
1756
 
@@ -1444,10 +1792,10 @@ await anna.auth.switch(SHOP) // арендатор сессии
1444
1792
 
1445
1793
  <!-- run: guide -->
1446
1794
  ```ts
1447
- const pg2 = postgres(ADMIN)
1448
- const OLEG = (await createTenant(pg2, r.schema, { // тот же магазин: создаётся только пользователь
1795
+ const admin2 = postgres(ADMIN)
1796
+ const OLEG = (await createTenant(admin2, r.schema, { // тот же магазин: создаётся только пользователь
1449
1797
  name: 'Ромашка', user: { name: 'Олег', login: 'oleg@shop.ru', password: 'Олег-2026!' } })).user!
1450
- await pg2.end()
1798
+ await admin2.end()
1451
1799
  await db.acl.roles(OLEG, ['staff']) // роли Олега в текущем арендаторе (строка member)
1452
1800
 
1453
1801
  await db.acl.deny({ role: 'staff', delete: 'Product' }) // вес 50 — сильнее «можно всё» (10)
@@ -1523,16 +1871,16 @@ await db.acl.revoke({ everyone: true, read: '*', write: '*', delete: '*' })
1523
1871
  аккаунтов обёртка пишет своей группой `everyone:ACCOUNT`. Так же — право всем аккаунтам на отдельный класс:
1524
1872
  `db.acl.grant({ everyone: true, read: 'Catalog' })`.
1525
1873
 
1526
- Не заводить. `createTenant(sql, schema, { name, grantAll: false, user })` и `up({ tenant: { name, grantAll: false, user
1874
+ Не заводить. `createTenant(admin, schema, { name, grantAll: false, user })` и `up({ tenant: { name, grantAll: false, user
1527
1875
  } })` заводят арендатора без правил. У его участников, в том числе у первого пользователя, прав нет — ни на данные, ни
1528
1876
  на запись правил. Классы и правила такого арендатора пишет администраторское подключение с сессией его пользователя:
1529
1877
  RLS на нём не действует, а строки идут в арендатор сессии.
1530
1878
 
1531
1879
  ```ts
1532
- const pg = postgres(ADMIN)
1533
- const t = await createTenant(pg, r.schema, { name: 'Склад', grantAll: false,
1880
+ const admin = postgres(ADMIN)
1881
+ const t = await createTenant(admin, r.schema, { name: 'Склад', grantAll: false,
1534
1882
  user: { name: 'Ирина', login: 'irina@stock.ru', password: 'Склад-2026!', roles: ['manager'] } })
1535
- await pg.end()
1883
+ await admin.end()
1536
1884
  const adm = await connect({ dsn: ADMIN, schema: r.schema, token: t.token, allowBypassRls: true })
1537
1885
  await syncModels(adm, [Product], { apply: true })
1538
1886
  await adm.acl.grant({ role: 'manager', read: 'Product', write: 'Product', delete: 'Product' })
@@ -1550,8 +1898,8 @@ await adm.close()
1550
1898
  арендаторах переходит этим арендаторам, сессии гаснут, креды удаляются. Владелец может стереть и арендатора, в
1551
1899
  котором сейчас работает: его сессия вернётся в личный арендатор.
1552
1900
 
1553
- Системный администратор может удалить и саму строку аккаунта — `db.Account(id).delete({ confirm: true })` в
1554
- арендаторе System; данные арендатора аккаунта при этом остаются. Любой путь сразу стирает строки аккаунта в `secret`:
1901
+ Системный администратор может удалить и саму строку аккаунта — по превью, `db.Account(id).delete({ confirm: true,
1902
+ expect: превью })`, в арендаторе System; данные арендатора аккаунта при этом остаются. Любой путь сразу стирает строки аккаунта в `secret`:
1555
1903
  сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (внешний ключ `secret.account` с каскадом).
1556
1904
  Сессии гаснут до этого с сигналом, и процессы сбрасывают кэш ответов этих сессий. Аккаунт System
1557
1905
  не удаляется (`acl_denied`): ему принадлежат служебные строки установки в `secret`.
@@ -1570,10 +1918,23 @@ const versions = await db.Product(kettle).versions() // все ве
1570
1918
  `delete({ confirm, reason })` и других), для всех операций блока — `db.begin({ reason })` и
1571
1919
  `db.batch(имя, { reason })`; сырой SQL — настройка `letopis.reason` транзакции. Версия удаления — с `$deleted`.
1572
1920
 
1573
- Без `sort()` версии идут по id и номеру версии. `sort()` задаёт порядок и версиям, а
1574
- `limit()` ограничивает их в самом запросе: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` — пять
1921
+ Версия — строка той же формы, что строка ответа ([«Строка ответа»](#строка-ответа)), но со снимком на свою версию:
1922
+ `rev` — номер версии, `op` — вид операции, `links` и `data` — концы и поля после этой операции (у версии удаления —
1923
+ последний снимок объекта), `at`, `author`, а если есть — `agent` и `reason`; у версии удаления ещё `$deleted: true`.
1924
+ Без `sort()` версии идут по id и номеру версии (по возрастанию: первой — `create`). `sort()` задаёт порядок и версиям,
1925
+ а `limit()` ограничивает их в самом запросе: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` — пять
1575
1926
  последних версий, новые первыми.
1576
1927
 
1928
+ Шаг с явными id находит и удалённые объекты: `db.Product(id).versions()` отдаёт историю удалённого товара вместе с
1929
+ версией удаления, `withDeleted()` для этого не нужен. Шаг без id (`db.Product().versions()`, переход по пути) отдаёт
1930
+ версии **только живых** объектов, с удалёнными — `withDeleted()` этого шага. Так, строки удалённого заказа ушли
1931
+ каскадом, и `db.Product(p).line().versions()` их не покажет, а `db.Product(p).line().withDeleted().versions()` или
1932
+ `versions()` по id строки — покажут: концы — `links` версии, удаление — версия удаления, причина — `reason`.
1933
+
1934
+ `versions()` — терминал: отдаёт промис строк версий. Модификаторы ставят до него
1935
+ (`db.Product(kettle).sort('at', 'desc').limit(5).versions()`), а метод цепочки у его результата —
1936
+ `versions().rows()`, `.sort()`, `.limit()` — `invalid_query` (`api.versions_terminal`).
1937
+
1577
1938
  Журнал хранит каждую версию: прореживания и обрезки истории нет. Классу, чья история не нужна, — `history: false`
1578
1939
  ([«Настройки класса»](#настройки-класса)).
1579
1940
 
@@ -1598,6 +1959,28 @@ const assortment = await db.Shop(shop).Product().asOf(createdAt).rows()
1598
1959
  const withGone = await db.Customer().withDeleted().rows() // живые и удалённые; у удалённых $deleted: true
1599
1960
  ```
1600
1961
 
1962
+ `withDeleted()` — модификатор своего шага, как `exact()`: удалённые находит шаг, после которого он стоит, а шаги без
1963
+ него идут по живым строкам. Удалённый объект приходит надгробием — своей последней версией, версией удаления, — с
1964
+ `$deleted: true`.
1965
+
1966
+ | Цепочка | Что находит |
1967
+ |---|---|
1968
+ | `db.Order(o).line().withDeleted()` | живые и удалённые строки живого заказа |
1969
+ | `db.Order(o).withDeleted().line().withDeleted()` | живые и удалённые строки заказа, даже если сам заказ удалён |
1970
+ | `db.Order(o).withDeleted().line()` | только **живые** строки (заказ — с удалёнными); выводит предупреждение `api.with_deleted_step` |
1971
+ | `db.Order(o).line().Product().withDeleted()` | товары, в том числе удалённые, живых строк живого заказа |
1972
+
1973
+ Путь через удалённый узел читают одной цепочкой — `withDeleted()` у каждого шага, который может быть удалён:
1974
+ `db.Customer(c).Order().withDeleted().line().withDeleted()` — строки всех заказов клиента, в том числе удалённых. В
1975
+ цепочке записи `withDeleted()` — только перед `restore()`: у других глаголов — `invalid_query`. В инструменте MCP
1976
+ `letopis_query` опция `withDeleted` относится к последнему шагу.
1977
+
1978
+ `withDeleted()` отдаёт по каждому id его последнюю версию. Если id надгробия занял новый объект (`create()` по ключу
1979
+ удалённого), шаг находит живой новый объект без `$deleted`, а прежнего надгробия среди строк нет; путь от прежнего
1980
+ владельца (`db.Customer(c).withDeleted().Order().withDeleted()`) такой объект не находит, если его последняя версия
1981
+ ведёт к другому. Прежнее надгробие видно только в версиях — `db.Order(id).versions()`; вернуть его нельзя: пока
1982
+ новый объект жив, `restore({ expect })` — `not_deleted` (`write.restore_id_taken`) ([«Восстановить: `restore`»](#восстановить-restore)).
1983
+
1601
1984
  ## 12. Подписка на изменения и кэш
1602
1985
 
1603
1986
  ### Подписка
@@ -1675,7 +2058,7 @@ const report = await adm.maintain() // { sessions, codes, queue, skipped }
1675
2058
  вызовом), а ещё проверяет очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Журнал
1676
2059
  `maintain()` не трогает. Строки удалённого аккаунта ждать не нужно: их стирает само удаление ([«Удалить
1677
2060
  аккаунт»](#удалить-аккаунт)). Вызывать может системный администратор, роль с правом `letopis.maintain` или
1678
- администраторское подключение. Запускает его внешний планировщик (`letopis maintain`) или сама библиотека:
2061
+ администраторское подключение; остальным — `acl_denied` (`sql-time.maintain_denied`), в том числе сервису по ключу. Запускает его внешний планировщик (`letopis maintain`) или сама библиотека:
1679
2062
  `connect({ maintain: '1h' })`. Интервал — число миллисекунд или строка (`'500ms'`, `'30s'`, `'15m'`, `'1h'`, `'1d'`)
1680
2063
  от 1 мс до 2³¹−1 мс (около 24 суток): другое значение, в том числе ноль, отрицательное или `NaN`, `connect()`
1681
2064
  отклоняет с `invalid_data`, не открывая соединений. `close()` ждёт идущий по расписанию запуск. Из нескольких
@@ -1765,7 +2148,8 @@ PostgreSQL у ошибки не из letopis), если они есть. Пар
1765
2148
  `rev` прочитанной версии: чужая версия — `conflict`, потерянной правки нет. `delete` без `confirm` возвращает превью
1766
2149
  каскада и код операции: `{ preview, operation }`. С `confirm: true` удаление подтверждает человек в клиенте через
1767
2150
  elicitation (в ревизии протокола 2025 — запросом клиенту, в 2026-07-28 — ответом `input_required`), подтверждение
1768
- привязано к хэшу операции, а ответ — `{ deleted, operation }`. В ревизии 2026-07-28 подпись `requestState`, с которой
2151
+ привязано к хэшу операции, а ответ — `{ deleted, operation }`. Удаляет инструмент ровно показанное: превью, построенное
2152
+ до подтверждения, идёт в `expect`, и если данные изменились — `conflict` (`write.preview_changed`), ничего не удалено. В ревизии 2026-07-28 подпись `requestState`, с которой
1769
2153
  клиент повторяет вызов, привязана ещё к пользователю сервера и действует 10 минут: подпись сервера другого
1770
2154
  пользователя и устаревшая — отказ `confirmation_required`. Окно подтверждения показывает каноническое имя класса и
1771
2155
  id целей (первые 10, дальше — «и ещё K»); алиас и `meta` класса пишет арендатор, поэтому в окно они не попадают. Код
@@ -1803,7 +2187,9 @@ id целей (первые 10, дальше — «и ещё K»); алиас и
1803
2187
  **HTTP.** Встроенного HTTP-сервера нет. `createMcpServer(db, { write, maxRows, maxBytes })` из `letopis/mcp` отдаёт
1804
2188
  сервер MCP, а приложение подключает его к своему обработчику `createMcpHandler` из SDK со своей авторизацией
1805
2189
  OAuth 2.1: токен запроса проверяет приложение и находит по нему пользователя letopis. Подключение — сессия
1806
- пользователя (`connect({ token })`) или фасад `svc.as(пользователь)`: ключ сервиса остаётся в процессе приложения.
2190
+ пользователя в арендаторе приложения (`connect({ token })` после `auth.switch(tenant)`) или фасад
2191
+ `svc.as(пользователь, { tenant })`: ключ сервиса остаётся в процессе приложения. Без `{ tenant }` фасад работает в
2192
+ личном арендаторе пользователя, и классов приложения агент не увидит.
1807
2193
  Подключение в обход RLS получает отказ `bypass_rls`, корень сервиса без `as()` и подключение без сессии —
1808
2194
  `user_session_required`, фасад явной транзакции `db.begin()` — `invalid_query`. Сессию в арендаторе System — так
1809
2195
  работает системный администратор: его роль `admin` даёт права на аккаунты, членства и правила установки — сервер тоже
@@ -1848,6 +2234,22 @@ const [{ n }] = await db.sql`select count(*)::int as n from "v2.shop".entity whe
1848
2234
  сериализации запрос и `fn` повторяются до трёх раз, поэтому `fn` должна выдерживать повтор. Писать сырым SQL тоже
1849
2235
  можно — те же триггеры проверят данные, а функции базы `merge` и `inc` доступны приложению.
1850
2236
 
2237
+ Ошибки базы `db.sql`, `db.transaction(fn)` и `tr.sql` в `db.begin()` приходят так же, как у цепочек: с кодом letopis
2238
+ (`LT…` — по таблице [«Ошибки»](#15-ошибки)), а прочие — как ошибка postgres.js с SQLSTATE в `err.code`. Исключение —
2239
+ значение не того типа, SQLSTATE `22P02` (`'42'::uuid`, `'abc'::int`): оно приходит `LetopisError` с кодом
2240
+ `invalid_query` (ключ `api.db_bad_value`), текст называет значение — `letopis: invalid_query: значение не того типа:
2241
+ invalid input syntax for type uuid: "42"`, а исходная ошибка PostgreSQL — в `err.cause` (`err.cause.code === '22P02'`).
2242
+ Ловите её по `err.code === 'invalid_query'`, а не по SQLSTATE:
2243
+
2244
+ ```ts
2245
+ try {
2246
+ await db.sql`select * from "v2.shop".entity where id = ${input}::uuid`
2247
+ } catch (e) {
2248
+ if (!(e instanceof LetopisError) || e.code !== 'invalid_query') throw e
2249
+ // e.key === 'api.db_bad_value'; (e.cause as { code: string }).code === '22P02'
2250
+ }
2251
+ ```
2252
+
1851
2253
  ### Командная строка
1852
2254
 
1853
2255
  ```sh
@@ -1858,6 +2260,7 @@ letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token
1858
2260
  letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>]
1859
2261
  letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
1860
2262
  (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
2263
+ # у любой команды: --log[=debug|info|warn|error] — события JSON-строками в stderr (без флага — LETOPIS_LOG)
1861
2264
  ```
1862
2265
 
1863
2266
  `letopis types` строит результат по классам сессии, поэтому без действующей сессии (токен истёк, погашен, неверен или
@@ -1870,12 +2273,112 @@ letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пол
1870
2273
 
1871
2274
  ### Наблюдение
1872
2275
 
2276
+ - Журнал событий — `connect({ log })` или `LETOPIS_LOG` ([«Логирование»](#логирование)).
1873
2277
  - `connect({ onQuery, slowMs })`: хук получает на каждый запрос цепочки режим, классы, длительность, число строк и
1874
2278
  признак медленного запроса; без хука медленный запрос печатает предупреждение.
1875
2279
  - `sub.lag()` — отставание подписки; отчёт `maintain()` — заполнение очереди сигналов.
1876
2280
  - Горячие цели ссылок (организация, на которую ссылается почти всё) копят блокировки `for key share` и MultiXact,
1877
2281
  как при обычных внешних ключах. Это стоит мониторить.
1878
2282
 
2283
+ ### Логирование
2284
+
2285
+ letopis пишет журнал событий приложения: что прочитано и записано, что удалено и восстановлено, какие были ошибки,
2286
+ кто вошёл. По нему видно, что удаление прошло по показанному превью, а восстановление вернуло то, что ушло. Журнал
2287
+ выключен, пока его не включили опцией или переменной окружения.
2288
+
2289
+ **Опции.** `connect()` и `up()` принимают одни и те же:
2290
+
2291
+ | Опция | Что делает |
2292
+ |---|---|
2293
+ | `log` | куда писать: функция `(event) => void` или объект с методами `debug`, `info`, `warn`, `error` (`console`, pino и подобные) — вызывается `logger[level](event, event.event)`; нет метода уровня — `logger.log`, нет и его — событие пропускается |
2294
+ | `logLevel` | порог: `'debug'`, `'info'` (по умолчанию), `'warn'`, `'error'` или `'off'`; событие ниже порога не собирается |
2295
+ | `redact` | ещё имена полей (`string[]`, без учёта регистра), значения которых заменяются на `'***'` в любом месте `detail` |
2296
+
2297
+ ```ts
2298
+ import pino from 'pino'
2299
+ const svc = await connect({ dsn: APP, schema: 'v2.shop', apiKey: SERVICE_KEY,
2300
+ log: pino(), logLevel: 'info', redact: ['phone', 'email'] })
2301
+ const solo = await connect({ dsn: APP, schema: 'v2.shop', token, log: (e) => audit.push(e) }) // своя функция
2302
+ ```
2303
+
2304
+ Подключения из `db.as()` и `svc.as()`, `db.begin()`, `db.transaction()`, батч и `auth.switch()` пишут тем же журналом
2305
+ с тем же порогом и `redact`; в их событиях свои `tenant`, `account` и `tx`. Исключение внутри логгера проглатывается:
2306
+ операция из-за журнала не ломается. Хук `onQuery` работает как прежде, а каждый запрос SQL — ещё и событие `sql`.
2307
+
2308
+ **Переменная окружения и командная строка.** `LETOPIS_LOG=<уровень>` (`debug`, `info`, `warn`, `error`, `off`;
2309
+ `1` и `true` — то же, что `info`): если `log` не передан, события пишутся в stderr JSON-строками, одна строка — одно
2310
+ событие. Если `log` передан без `logLevel`, порог берётся из `LETOPIS_LOG`, а без неё — `info`. Без `log` и без
2311
+ `LETOPIS_LOG` журнала нет. Команды CLI принимают `--log[=уровень]` (`--log` без значения — `info`), события — в stderr;
2312
+ без флага действует `LETOPIS_LOG`.
2313
+
2314
+ ```sh
2315
+ LETOPIS_LOG=info node app.js 2>> letopis.log
2316
+ npx letopis maintain --dsn "$ADMIN" --schema v2.shop --api-key "$KEY" --log=debug
2317
+ ```
2318
+
2319
+ **Событие** — объект:
2320
+
2321
+ | Поле | Что значит |
2322
+ |---|---|
2323
+ | `ts`, `level`, `event` | время ISO, уровень (`debug`, `info`, `warn`, `error`), имя события |
2324
+ | `op`, `ms` | операция (`rows`, `first`, `create`, `delete`, `login` …) и длительность в миллисекундах |
2325
+ | `tenant`, `account`, `tx` | арендатор и аккаунт сессии; метка транзакции, если операция в `db.begin()`, `transaction()` или батче |
2326
+ | `chain`, `rows` | цепочка в читаемом виде (`Shop({"name":"Ромашка"}).Product.rows()`) и число строк результата или затронутых |
2327
+ | `code`, `key`, `msg` | код и ключ каталога ошибки или предупреждения, текст |
2328
+ | `detail` | подробности события (секреты замаскированы) |
2329
+ | `cause` | у события `error` — исходная ошибка: `{ name, message, code }` |
2330
+
2331
+ **Маскировка.** Без настройки заменяются на `'***'` на любой глубине `detail`: значения полей с паролями, токенами,
2332
+ ключами, секретами и кредами — в том числе `password`, `pass`, `passwd`, `pwd`, `passphrase`, `secret`, `token`,
2333
+ `serviceKey`, `apiKey`, `api_key`, `credential`, `authorization`, `cookie`, `hash`, `material`, `totp`, `otp`;
2334
+ учётные данные в строке подключения (`postgres://***@host/db` — скрыт и пользователь); пароль в тексте SQL
2335
+ (`password '…'`). `redact` добавляет свои имена полей (персональные данные и т. п.).
2336
+
2337
+ Не пишутся никогда: значения параметров SQL (у событий `sql` и `raw` — только их число); текст, `DETAIL`, `WHERE`,
2338
+ `HINT` и внутренний запрос ошибки PostgreSQL — в событии только SQLSTATE и имена ограничения, таблицы, колонки, схемы,
2339
+ типа и функции, а вместо текста — фраза «ошибка PostgreSQL <SQLSTATE>»; поле `data` строк в `detail` ошибки (превью,
2340
+ снимки отказа `restore`) — `'***'`; значение аргумента в тексте ошибки letopis (`query.bad_uuid`, `api.db_bad_value`
2341
+ и подобные) — `'***'`.
2342
+
2343
+ Пишутся как есть — учитывайте это: значения фильтров в `chain` (`Shop({"name":"Ромашка"})`; у записи данные —
2344
+ только именами полей), поэтому фильтр по персональным полям — `redact: ['email', …]`; тексты исключений кода
2345
+ приложения (с маскировкой паролей). Объект ошибки, который получает приложение (`err.message`, `err.cause`), журнал
2346
+ не меняет: что из него логировать самому, решает приложение.
2347
+
2348
+ **Транзакции и фиксация.** Событие записи вне `db.begin()` пишется после фиксации своей транзакции и само доказывает
2349
+ её. Внутри `db.begin()` события несут `tx` и пишутся, когда оператор выполнен, до `COMMIT`: `begin → delete →
2350
+ rollback` оставит событие `delete`, а строки — на месте. Фиксацию такой записи доказывает только `tx.commit` с тем же
2351
+ `tx`; `tx.rollback` с ним — запись отменена; `commit_unknown` — исход неизвестен (повтор с тем же ключом).
2352
+
2353
+ **Основные события.** Полный каталог с уровнями и описаниями — `lib/src/messages/log.ts` (`LOG_EVENTS`).
2354
+
2355
+ | Событие | Уровень | Когда | Главное в событии |
2356
+ |---|---|---|---|
2357
+ | `connect`, `close` | info | подключение готово, закрыто | `ms`; `detail.schema` |
2358
+ | `up`, `migrate` | info | `up()` завершён; `up()` обновил ревизию схемы | `detail`: `schema`, `created`, `revision`; `from`, `to` |
2359
+ | `sync` | info | `syncModels()` | итог: созданные и изменённые классы |
2360
+ | `read` | debug | терминал чтения | `op`, `chain`, `rows`, `ms`, `tx` |
2361
+ | `cache.hit`, `cache.miss` | debug | ответ из кэша результатов; промах | `chain`, `rows` |
2362
+ | `write` | info | глагол записи, план батча, загрузка | `op`, `chain`, `rows`, `ms`, `tx` |
2363
+ | `delete.preview` | debug | `delete()` без `confirm` | `rows`; `detail`: сколько `delete`, `unset`, `restrict` |
2364
+ | `delete` | info | удаление выполнено | `chain`, `rows`; `detail.expect` — сколько строк превью сверено (`false` — без сверки, `all: true`) |
2365
+ | `restore` | info | восстановление выполнено | `chain`, `rows`; `detail`: `expect`, `partial`, `skipped: [{ id, why }]` |
2366
+ | `tx.begin` | debug | открыта транзакция | `tx` |
2367
+ | `tx.commit` | info, если в транзакции была запись, иначе debug | зафиксирована транзакция | `tx`, `ms`; `detail.writes` — число операций записи |
2368
+ | `tx.rollback` | info | откат | `detail`: `reason` (`explicit` или `error`), `writes` |
2369
+ | `tx.retry` | warn | повтор после `40001`, `40P01` или конфликта журнала | `detail`: `attempt`, `code` |
2370
+ | `auth.login`, `auth.logout`, `auth.revoke`, `auth.switch` | info | вход, выход, отзыв сессии, смена арендатора | `account`, `tenant` |
2371
+ | `watch.start`, `watch.stop` | info | подписка открыта, закрыта | `detail.classes` |
2372
+ | `signal` | debug | получен сигнал базы | `detail.channel` |
2373
+ | `maintain` | info | `maintain()` | итог обслуживания |
2374
+ | `warning` | warn | любое предупреждение letopis (`api.step_undefined_filter` и другие) | `key`, `msg` |
2375
+ | `error` | warn — штатный отказ (`exists`, `conflict`, `target_not_found`, `not_deleted`, `delete_restricted`, `acl_denied`, `tenant_denied`, `no_session`, `invalid_data` с `err.issues` и другие); error — прочее | каждая ошибка при выходе из операции | `op`, `chain`, `code`, `key`, `msg`, `detail`, `cause` |
2376
+ | `sql` | debug | каждый запрос SQL | `ms`, `rows`, `tx`; `detail`: текст (до 2000 знаков), число параметров |
2377
+
2378
+ Ещё есть, среди прочих: `auth.as` (debug, `as()`), `auth.fail` (warn, неудачный вход), `acl.change` (правка прав
2379
+ `db.acl`), `tenant.create`, `account.purge`, `load` (итог загрузчика), `lock` (debug, `db.lock()`), `mcp.call` (вызов
2380
+ инструмента MCP).
2381
+
1879
2382
  ### Пулы соединений и PgBouncer
1880
2383
 
1881
2384
  С PgBouncer в режиме транзакций `LISTEN` недоступен: `connect({ listen: false })`. Тогда реестр перечитывается через
@@ -1892,16 +2395,16 @@ await svc.close() // и подключения, полученные чер
1892
2395
 
1893
2396
  ## 14. Справочник API
1894
2397
 
1895
- Полный машинный список экспортов, методов и текстов ошибок — `docs/api-contract.json` в репозитории.
2398
+ Полный машинный список экспортов, методов и текстов ошибок — `lib/api-contract.json` в репозитории.
1896
2399
 
1897
2400
  ### Экспорты пакета
1898
2401
 
1899
2402
  | Точка входа | Что экспортирует |
1900
2403
  |---|---|
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` и другие |
2404
+ | `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`, `Expected` (строка `expect`: `{ id, rev, $action? }`), `DeleteOptions`, `RestoreOptions`, `Filter`, `Cursor`, `ConnectOptions`, `UpOptions`, `LoadOptions`, `LoadReport`, `WatchOptions`, `WatchEvent`, `AuthApi`, `AclApi`, `MaintainReport` и другие |
1902
2405
  | `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta`; `normalizeSchema`, `translatePattern`, тип `JsonSchema` |
1903
2406
  | `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)) |
1904
- | CLI `letopis` | `sync`, `types`, `load`, `maintain`, `mcp` ([раздел 13](#13-эксплуатация)); у `mcp` — `--dsn`, `--schema`, `--token` (или `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`) и `--write` |
2407
+ | CLI `letopis` | `sync`, `types`, `load`, `maintain`, `mcp` ([раздел 13](#13-эксплуатация)); у всех — `--log[=уровень]` ([«Логирование»](#логирование)); у `mcp` — `--dsn`, `--schema`, `--token` (или `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`) и `--write` |
1905
2408
 
1906
2409
  ### Корень `db`
1907
2410
 
@@ -1911,7 +2414,7 @@ await svc.close() // и подключения, полученные чер
1911
2414
  | `schema`, `token`, `tenant`, `registry` | имя установки, токен, арендатор сессии, реестр классов (`resolve`, `find`, `has`, `all`, `family`) |
1912
2415
  | `auth`, `acl` | вход, креды и сессии; решения прав ([раздел 10](#10-пользователи-вход-и-права)) |
1913
2416
  | `as(аккаунт, { tenant, owner })` | тот же пул от имени аккаунта (асинхронный) |
1914
- | `begin()`, `commit(tr)`, `rollback(tr)`, `lock(…ключи)` | явная транзакция ([раздел 8](#8-транзакции-батчи-и-деньги)) |
2417
+ | `begin()`, `commit(tr)`, `rollback(tr)`, `lock(…ключи)` | явная транзакция ([раздел 8](#8-транзакции-и-батчи)) |
1915
2418
  | `batch(имя)` | очередь планов: `run()`, `discard()`, `size()` |
1916
2419
  | `load(вход, опции)` | загрузчик ([раздел 9](#9-массовая-загрузка)) |
1917
2420
  | `watch(опции)`, `cache` | подписка; кэш результатов (`clear()`, `stats()`) |
@@ -1919,15 +2422,15 @@ await svc.close() // и подключения, полученные чер
1919
2422
  | `idOf(класс, ключ)` | id объекта класса с ключом в арендаторе сессии |
1920
2423
  | `entity(строка \| цепочка)` | начать путь с готового узла |
1921
2424
  | `maintain()` | обслуживание ([раздел 13](#13-эксплуатация)) |
1922
- | `refresh()`, `flushSignals()`, `close()` | перечитать классы; отправить сигналы сейчас; закрыть |
2425
+ | `refresh()`, `flushSignals()`, `close()` | перечитать классы; отправить сигналы сейчас; закрыть пул и подписки (у фасада `as()` — ничего не делает, в `db.begin()` — откат) |
1923
2426
 
1924
2427
  ### Цепочка
1925
2428
 
1926
2429
  | Группа | Методы |
1927
2430
  |---|---|
1928
2431
  | шаги | `.Класс(фильтр)`, `.алиас(фильтр)`, `.Роль(фильтр)` |
1929
- | модификаторы | `limit(n)`, `offset(n)`, `sort(поле, 'asc' \| 'desc')`, `after(курсор)`, `asOf(момент)`, `withDeleted()`, `deep(max)`, `exact()`, `alias(имя)`, `tags(…)`, `owner(…)`, `entity(…)`, `forUpdate()`, `cache({ ttl })` |
1930
- | глаголы | `create(данные)`, `update(патч, { rev, all })`, `upsert(данные)`, `delete({ confirm, all })`, `restore()`; `update` и `delete({ confirm: true })` без фильтра, id, `tags()`, `owner()` и `limit()` у шага глагола и шагов до него (и без `create()` или `upsert()` раньше в цепочке) — только с `{ all: true }` |
2432
+ | модификаторы | `limit(n)`, `offset(n)`, `sort(поле, 'asc' \| 'desc')`, `after(курсор)`, `asOf(момент)`, `withDeleted()`, `deep(max)`, `exact()`, `alias(имя)`, `tags(…)`, `owner(…)`, `entity(…)`, `forUpdate()`, `cache({ ttl })`; модификаторы выборки `limit`, `offset`, `sort`, `after`, `forUpdate` — последнему шагу цепочки (перед глаголом — его целям), где бы ни стояли; `asOf` и `cache` — всей цепочке; `withDeleted`, `deep`, `exact`, `alias`, `tags`, `owner` — своему шагу |
2433
+ | глаголы | `create(данные)`, `update(патч, { rev, all })`, `upsert(данные)`, `delete({ confirm, expect, all })`, `restore({ expect, partial, all })` (у всех — ещё `reason`; `expect` — строки `{ id, rev }` превью или надгробий, [раздел 7](#7-удаление-и-восстановление)); `delete({ confirm: true })` — только с `expect` или `all: true`, `restore()` — только с `expect` или `all: true` (тип `DeleteOptions` и `RestoreOptions` отклоняет иное ещё при компиляции); `update` и `delete({ confirm: true, expect })` без фильтра, id, `tags()`, `owner()` и `limit()` у шага глагола и шагов до него (и без `create()` или `upsert()` раньше в цепочке) — только с `{ all: true }` |
1931
2434
  | слоты | после глагола: `.Роль.set(значение)`, `.Роль.unset()`, `.Роль.add(значение)`, `.Роль.remove(значение)` |
1932
2435
  | терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions()`, `sum(поле)`, `avg(поле)`, `min(поле)`, `max(поле)`, `countBy(поле)` |
1933
2436
 
@@ -1943,9 +2446,9 @@ await svc.close() // и подключения, полученные чер
1943
2446
  | `issueOtp`, `verifyOtp` | `{ account, identifier, kind?, ttl?, digits?, maxAttempts? }` → `{ code }`; `{ identifier, code, kind? }` → вход или `null` |
1944
2447
  | `link`, `lookup` | `{ account, kind, identifier, meta?, confirmed? }` → id; `{ kind, identifier, requireConfirmed?, ttl?, totp? }` → вход или `null` |
1945
2448
  | `credentials`, `revokeCredential`, `setFlags` | `(account)` → креды без секретов; `(id, { keepCurrent? })`; `(id, { confirmed?, enabled? })` |
1946
- | `sessionFor`, `refresh`, `revoke`, `revokeAll` | `(account, { ttl? })` → токен; `({ ttl? })` → новое время истечения (токен прежний); `()`; `(account?)` → число сессий |
2449
+ | `sessionFor`, `refresh`, `revoke`, `revokeAll` | `(account, { ttl? })` → токен; `({ ttl? })` → новое время истечения (токен прежний); `()` — выход подключения, `(token)` — выход пользователя по токену его сессии (право `auth.session`) → `boolean`; `(account?)` → число сессий |
1947
2450
  | `session` | `(token)` → `{ account, tenant, member, expiresAt, loginAt, methods }` или `null` |
1948
- | `tenants`, `switch` | `()` → `[{ tenant, roles }]`; `(tenant)` |
2451
+ | `tenants`, `switch` | `()` → `[{ tenant, roles, name, personal }]` (первым — собственный, `personal: true`); `(tenant)` |
1949
2452
  | `purgeAccount` | `(account)` → `{ account, rows, versions, memberships, owned }` |
1950
2453
 
1951
2454
  Проверку паролей, кодов и TOTP, выпуск сессий, сведения о сессии по токену и подтверждение кредов вызывает сервис
@@ -1968,7 +2471,8 @@ await svc.close() // и подключения, полученные чер
1968
2471
  | `at`, `author`, `agent`, `op`, `reason` | время версии (ISO, UTC, микросекунды), автор, агент (сервис при имперсонации), вид операции, причина |
1969
2472
  | `$deleted` | надгробие: удалённая сущность или версия удаления |
1970
2473
  | `$depth` | глубина узла в `deep()`; уровень каскада в превью удаления |
1971
- | `$upsert`, `$action` | что сделал `upsert`; что сделает удаление |
2474
+ | `$upsert`, `$action` | что сделал `upsert`; что сделает удаление (`delete`, `unset`, `restrict`); `skip` — цель `restore({ expect, partial: true })`, которую не восстановить |
2475
+ | `$why` | у строки `$action: 'skip'` — почему: `restored`, `reissued`, `changed` ([«Восстановить: `restore`»](#восстановить-restore)) |
1972
2476
 
1973
2477
  ### Зарезервированные имена
1974
2478
 
@@ -1985,6 +2489,13 @@ await svc.close() // и подключения, полученные чер
1985
2489
  `maintain`, `auth`, `acl`, `idOf`, `refresh`, `transaction`, `flushSignals`, `schema`, `token`, `tenant`, `cache`;
1986
2490
  - батч: `run`, `discard`, `size`.
1987
2491
 
2492
+ Имя роли конца одно на арендатора — это имя шага цепочки: во всех классах арендатора и System роль с одним именем
2493
+ ведёт на одни и те же классы. Поэтому заняты роли концов системных классов: `Group` и `Object` (класс `rule`) ведут на
2494
+ `Resource`, `User` и `Tenant` (класс `member`) — на `Account`. Конец с таким именем, который ведёт на другой класс,
2495
+ `syncModels` отклоняет до записи — `invalid_class` (`model.end_role_system`; роль другого своего класса —
2496
+ `model.end_role_taken`), а класс, заведённый мимо него, — база (`sql-write.end_other_class`). Имена классов `Group` и
2497
+ другие не заняты — заняты имена ролей: называйте роли иначе (`Team`, `Org`, `Holder`).
2498
+
1988
2499
  ## 15. Ошибки
1989
2500
 
1990
2501
  Текст ошибки — `letopis: <код>: <сообщение>`. Код — в `err.code`, подробности — в `err.detail`. Ошибки — объекты
@@ -1996,7 +2507,10 @@ await svc.close() // и подключения, полученные чер
1996
2507
  у мест библиотеки и у ошибок базы, которая прислала ключ в `DETAIL`.
1997
2508
 
1998
2509
  Тексты ошибок — только на русском. Ошибку базы с ключом библиотека отдаёт с тем же текстом, что получает и сырой SQL
1999
- мимо библиотеки, а ключ и параметры из `DETAIL` переносит в `err.key` и `err.params`.
2510
+ мимо библиотеки, а ключ и параметры из `DETAIL` переносит в `err.key` и `err.params`. У ошибки данных (`invalid_data`)
2511
+ текст ещё называет первое нарушение и их число: `letopis: invalid_data: данные не проходят описание класса Order:
2512
+ status — значение не из перечисления: допустимо "new", "paid", "cancelled" (всего нарушений: 2, все — err.issues)`.
2513
+ Сообщение нарушения перечисления называет допустимые значения, лишнего ключа — сам ключ (`лишний ключ colour`).
2000
2514
 
2001
2515
  ```ts
2002
2516
  const db = await connect({ dsn, schema: 'v2.shop', token })
@@ -2026,20 +2540,20 @@ try {
2026
2540
 
2027
2541
  | Код | SQLSTATE | Когда |
2028
2542
  |---|---|---|
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`) и неверное значение опции |
2543
+ | `no_session`<a id="no_session"></a> | LT001 | нет действующей сессии. У библиотеки ещё — `connect({ token })` с погашенным, истёкшим или неверным токеном: отклонённый промис, подключение не создаётся (`api.token_not_active`); то же — промисом терминала у шага, не найденного в реестре подключения по токену без арендатора сессии |
2544
+ | `target_not_found`<a id="target_not_found"></a> | LT002 | цель не найдена — одинаково для несуществующей, чужой и невидимой. `svc.as(id)` с id аккаунта, которого нет (не заводили или удалён), — ключ `sql-core.impersonation_no_account`: «аккаунт … не найден»; аккаунт есть, но действовать от его имени нельзя (System, сервис, системный администратор, выключен) — `acl_denied` (`sql-core.impersonation_denied`) |
2545
+ | `invalid_data`<a id="invalid_data"></a> | LT003 | данные не проходят описание класса; текст называет первое нарушение и их число, `err.issues` — все: путь, ключевое слово, сообщение. Ключ с именем конца в данных `create`, `upsert`, `update` — `write.link_in_data`: текст называет ключ и форму слота `.Роль.set(id)`. У библиотеки ещё — неизвестная опция `connect()`, `up()`, `db.load()`, `db.watch()` и `createMcpServer()` (похожие — в `err.params.similar`) и неверное значение опции |
2032
2546
  | `undeclared_end`<a id="undeclared_end"></a> | LT004 | конец не объявлен в классе |
2033
2547
  | `tenant_denied`<a id="tenant_denied"></a> | LT005 | чужой арендатор или нет членства |
2034
2548
  | `acl_denied`<a id="acl_denied"></a> | LT006 | отказ по правилам доступа |
2035
2549
  | `id_mismatch`, `id_from_db`<a id="id_mismatch"></a><a id="id_from_db"></a> | LT007, LT008 | id не равен v5 от ключа; id класса без ключа выдаёт база |
2036
2550
  | `immutable_key`<a id="immutable_key"></a> | LT009 | ключевое поле, класс, id, арендатор и владелец не меняются: другой ключ или класс — новый объект |
2037
- | `invalid_class`<a id="invalid_class"></a> | LT010 | описание класса отклонено, класс не найден или абстрактный; класс со строками, потомками или историей строк не удаляется; хаб или связь с зарезервированным именем (хаб отклоняют ещё `hub()` и `syncModels`) |
2551
+ | `invalid_class`<a id="invalid_class"></a> | LT010 | описание класса отклонено, класс не найден или абстрактный; класс со строками, потомками или историей строк не удаляется; хаб или связь с зарезервированным именем (хаб отклоняют ещё `hub()` и `syncModels`). Класс, который точно записан `syncModels`, «не найден» — сессия в другом арендаторе, и классы арендатора ей не видны: чаще всего пользователь после входа остался в личном арендаторе — проверьте `db.tenant`, нужен `svc.as(account, { tenant })` или `auth.switch(tenant)` ([«Работа в другом арендаторе»](#работа-в-другом-арендаторе)). Если в реестре сессии нет ни одного класса арендатора — ключ `api.unknown_step_no_classes`. Системные классы в похожие имена не идут. Ещё — модель с преобразованием Zod у `syncModels` (`model.transform_unsupported`, [«Поля»](#поля)) |
2038
2552
  | `tightening_conflict`<a id="tightening_conflict"></a> | LT013 | ужесточению описания мешают строки |
2039
2553
  | `isolation_level`<a id="isolation_level"></a> | LT014 | удаление — только в `read committed` |
2040
2554
  | `recreated_in_statement`<a id="recreated_in_statement"></a> | LT015 | id удалён и создан заново одним оператором |
2041
2555
  | `delete_restricted`<a id="delete_restricted"></a> | LT017 | удалению мешают ссылающиеся строки (`onDelete: 'restrict'`) |
2042
- | `not_deleted`<a id="not_deleted"></a> | LT018 | `restore` — только для удалённых |
2556
+ | `not_deleted`<a id="not_deleted"></a> | LT018 | `restore` — только для удалённых: объект жив и не удалялся (`write.not_deleted`), уже восстановлен — в том числе проигравшим гонку двух `restore()` (`write.restore_already`), или его id занял новый живой объект с тем же ключом — `create()` по ключу удалённого (`write.restore_id_taken`): место занято новым объектом, восстанавливать нечего. id — `err.detail.ids`, с `expect` причины по целям — `err.detail.stale` ([«Восстановить: `restore`»](#восстановить-restore)) |
2043
2557
 
2044
2558
  Коды библиотеки:
2045
2559
 
@@ -2047,15 +2561,15 @@ try {
2047
2561
  |---|---|
2048
2562
  | `exists`<a id="exists"></a> | объект уже существует: `create` (`detail.id` — его id) или загрузчик |
2049
2563
  | `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`) |
2564
+ | `conflict`<a id="conflict"></a> | `update(патч, { rev })`: строку успели изменить (`detail`: `id`, `rev`, `expected`); `restore({ expect })`: после этого удаления id меняли — пересоздан и удалён снова, удалён снова (`write.restore_stale`, `detail`: `{ ids, stale: [{ id, rev, expected, after, deleted, live, why }] }`), ничего не восстановлено; `delete({ confirm: true, expect })`: превью изменилось (`write.preview_changed`, новое превью — `detail.preview`; цели уже удалены или не найдены при непустом `expect` — `detail.preview: []`), ничего не удалено |
2051
2565
  | `rev_ambiguous`<a id="rev_ambiguous"></a> | `{ rev }` у шага, у которого не одна цель |
2052
2566
  | `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` или глаголом |
2567
+ | `lock_unsupported`<a id="lock_unsupported"></a> | `forUpdate()` с агрегатом, `count()`, `paths()`, `versions()`, `asOf()`, `withDeleted()`, `deep()` или глаголом (`api.for_update_unsupported`): текст называет помеху — «forUpdate() нельзя с withDeleted(): блокировка — только у чтения rows(), first(), ids()», подсказка — допустимое и форма «прочитать `forUpdate().first()`, затем `update()` отдельной цепочкой» |
2054
2568
  | `commit_unknown`<a id="commit_unknown"></a> | связь оборвалась во время `COMMIT`: исход неизвестен |
2055
2569
  | `tx_rolled_back`<a id="tx_rolled_back"></a> | PostgreSQL ответил `ROLLBACK` на `COMMIT`: ошибку перехватили внутри `tr.transaction(fn)` |
2056
2570
  | `no_key`<a id="no_key"></a> | `upsert` или `idOf` у класса без ключа |
2057
2571
  | `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 и т. п.) |
2572
+ | `invalid_query`<a id="invalid_query"></a> | цепочку или API используют неверно (поле фильтра, `sort()` или агрегата не описано ни в одном классе шага — похожие в `err.params.similar`; `delete({ confirm: true })` без `expect` и без `all: true` (`write.confirm_without_expect`); `restore()` без `expect` и без `all: true`, в том числе по явному id (`write.restore_without_expect`); `update()` или `delete({ confirm: true, expect })` без фильтра, id, `tags()`, `owner()` и `limit()` у шагов и без `{ all: true }` (`write.no_filter`); `createTenant(admin, …)` с `admin` не пулом `postgres()` (`api.create_tenant_admin`); метод цепочки у результата `versions()` (`api.versions_terminal`), `after()` перед `count()` и агрегатами, модификатор шага `deep`, `exact`, `alias`, `tags` или `owner` после глагола, модификатор выборки между глаголом и терминалом `rows`, `first`, `ids`, `count` или `paths`, слот не после глагола, курсор `after()` со значением не того типа, что поле `sort`, без `v` или с `id` не uuid; id шага, `{ id }` или конца — не uuid (`query.bad_uuid`); свойство массива у цепочки — `.length`, `.map()`, `[0]`, перебор (`api.chain_not_rows`); `expect` без `confirm: true` или не списком строк `{ id, rev }`; `restore({ partial: true })` без `expect`, и с `all: true` тоже (`write.partial_without_expect`); `db.begin(аргумент)` — не объект опций `{ reason }`: функция, строка, число, массив (`api.begin_arg`); `withDeleted()` в цепочке записи, кроме `restore()`; значение не того типа в запросе — SQLSTATE `22P02` из базы, в том числе у `db.sql` (`api.db_bad_value`, исходная ошибка — `err.cause`) и т. п.) |
2059
2573
  | `admin_required`<a id="admin_required"></a> | загрузчику нужно администраторское подключение; роль строки `connect({ admin })` не может стать владельцем схемы; `up()` от роли без ADMIN OPTION на уже существующую роль владельца |
2060
2574
  | `not_empty`<a id="not_empty"></a> | первая заливка (`initial`) — только в пустую схему |
2061
2575
  | `class_not_loadable`<a id="class_not_loadable"></a> | строки `Class` загрузчик не принимает |
@@ -2066,6 +2580,74 @@ try {
2066
2580
 
2067
2581
  Отказ PostgreSQL в праве (`42501`) приходит, например, при `upsert` поверх чужой строки, которую нельзя править.
2068
2582
 
2583
+ **Отказы безопасности по умолчанию и неверного использования.** Ключи, которые приходят до записи и до запроса — их
2584
+ исправляют в коде, а не показывают пользователю:
2585
+
2586
+ | Ключ | Код | Когда | Что делать |
2587
+ |---|---|---|---|
2588
+ | `write.confirm_without_expect` | `invalid_query` | `delete({ confirm: true })` без `expect` и без `all: true` | показать превью `delete()` и удалить с ним: `delete({ confirm: true, expect: превью })`; без сверки — осознанно `{ confirm: true, all: true }` |
2589
+ | `write.restore_without_expect` | `invalid_query` | `restore()` без `expect` и без `all: true`, в том числе по явному id | `restore({ expect })` с ответом `delete()` или строками `withDeleted().rows()`; что можно — `restore({ expect, partial: true })`; всё без сверки — осознанно `restore({ all: true })` |
2590
+ | `api.create_tenant_admin` | `invalid_query` | `createTenant(admin, …)`: `admin` — строка DSN, подключение `connect()`, `undefined` или другой не пул | `const admin = postgres(ADMIN); await createTenant(admin, schema, opts); await admin.end()` |
2591
+ | `api.versions_terminal` | `invalid_query` | метод цепочки у результата `versions()`: `versions().rows()`, `.sort()`, `.limit()` | `await db.X(id).versions()`; модификаторы — до `versions()` |
2592
+ | `write.link_in_data` | `invalid_data` | имя конца (связи) в данных `create()`, `upsert()`, `update()` | конец — из пути или слотом после глагола: `.Роль.set(id)`, у списка — `.add(id)` / `.remove(id)`, снять — `.unset()` |
2593
+ | `sql-core.impersonation_no_account` | `target_not_found` | `svc.as(id)`: аккаунта нет — не заводили или удалён | id аккаунта — из входа, `createTenant()` или `auth.tenants()` |
2594
+
2595
+ **Текст для человека.** Штатные отказы — ответ пользователю, а не 500. Фразу выбирают по `err.key`, если он есть, иначе
2596
+ по `err.code`; `err.message` и `err.hint` — для разработчика и журнала:
2597
+
2598
+ | Код (ключ) | Пользователю |
2599
+ |---|---|
2600
+ | `exists` | «уже существует» / «уже занято» |
2601
+ | `conflict` | «данные изменил другой — обновите и повторите»; `write.preview_changed` — показать новое превью; `write.restore_stale` — «после удаления запись меняли» |
2602
+ | `not_deleted` | `write.restore_already` — «уже возвращено»; `write.restore_id_taken` — «место занято новой записью»; `write.not_deleted` — «не удалено» |
2603
+ | `target_not_found` | «не найдено: удалено или недоступно» |
2604
+ | `delete_restricted` | «нельзя удалить: на запись ссылаются» (строки — превью с `$action: 'restrict'`) |
2605
+ | `acl_denied`, `tenant_denied` | «недостаточно прав», «нет доступа к организации» |
2606
+ | `no_session` | «сессия истекла — войдите снова» |
2607
+ | `invalid_data` | по `err.issues`: своё название поля по `path`, фраза по `keyword` (ниже) |
2608
+
2609
+ **Значение не того типа (SQLSTATE `22P02`).** Неверный id в цепочке библиотека ловит сама, до запроса:
2610
+ `invalid_query`, ключ `query.bad_uuid`. Что дошло до базы — `'42'::uuid` в `db.sql`, `db.transaction(fn)` или
2611
+ `tr.sql`, — приходит `LetopisError` с кодом `invalid_query` и ключом `api.db_bad_value`; текст называет, какое
2612
+ значение не подошло, исходная ошибка PostgreSQL — в `err.cause`:
2613
+
2614
+ ```ts
2615
+ try {
2616
+ await db.sql`select ${'42'}::uuid`
2617
+ } catch (e) {
2618
+ // e instanceof LetopisError, e.code === 'invalid_query', e.key === 'api.db_bad_value'
2619
+ // e.message — 'letopis: invalid_query: значение не того типа: invalid input syntax for type uuid: "42"'
2620
+ // (e.cause as { code: string }).code === '22P02' — исходная ошибка PostgreSQL
2621
+ }
2622
+ ```
2623
+
2624
+ SQLSTATE, если он нужен, берут из `err.cause.code`.
2625
+
2626
+ **Текст для пользователя из `err.issues`.** У `ValidationError` каждое нарушение — `{ path, keyword, message }`:
2627
+ `path` — путь к полю (`[]` — данные целиком), `keyword` — правило JSON Schema (`type`, `required`, `minimum`, `enum`,
2628
+ `pattern`, `additionalProperties` …), `message` — русский текст для разработчика: у перечисления он называет
2629
+ допустимые значения, у лишнего ключа — сам ключ, у `minLength` и `maxLength` — предел («длина строки меньше 3», «длина
2630
+ строки больше 40»), у прочих правил бывает общим («нарушено правило minimum»). Пользователю
2631
+ показывают свои названия полей и свои фразы по `keyword`, а `message` — запасной текст:
2632
+
2633
+ ```ts
2634
+ import { ValidationError } from 'letopis'
2635
+
2636
+ const LABELS: Record<string, string> = { price: 'Цена', title: 'Название', sku: 'Артикул' }
2637
+ const RULES: Record<string, string> = { required: 'обязательно', minimum: 'слишком мало', type: 'неверный формат' }
2638
+ function explain(e: ValidationError): string[] {
2639
+ return e.issues.map((i) => {
2640
+ const field = i.path.length ? (LABELS[String(i.path[0])] ?? i.path.join('.')) : 'Данные'
2641
+ return `${field}: ${RULES[i.keyword] ?? i.message}` // «Цена: слишком мало»
2642
+ })
2643
+ }
2644
+ ```
2645
+
2646
+ **Предупреждения.** Часть ошибок использования — не отказ, а предупреждение в консоли: шаг с `undefined`, шаг после
2647
+ `sort()`, `withDeleted()` не у того шага ([«Строгий режим»](#строгий-режим)). При включённом журнале каждое
2648
+ предупреждение — ещё и событие `warning`, а каждая ошибка — событие `error` с кодом, ключом и цепочкой
2649
+ ([«Логирование»](#логирование)).
2650
+
2069
2651
  ## 16. Производительность
2070
2652
 
2071
2653
  Замеры — сценарии `lib/bench/` (как запускать, написано в шапке каждого файла), итоги — `lib/bench/results/`. Ниже —
@@ -2091,7 +2673,7 @@ try {
2091
2673
 
2092
2674
  ## 17. Ограничения
2093
2675
 
2094
- Здесь собрано то, что letopis 2.0 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
2676
+ Здесь собрано то, что letopis 3.0 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
2095
2677
 
2096
2678
  **Платформа**
2097
2679
 
@@ -2131,8 +2713,9 @@ try {
2131
2713
  - Владелец схемы и суперпользователь могут отключить триггеры и изменить что угодно, в том числе журнал: letopis
2132
2714
  этого не предотвращает и не обнаруживает. Журналу можно верить, пока роль владельца и суперпользователь — вне
2133
2715
  приложения.
2134
- - В базе проверяется подмножество JSON Schema: `refine`, `superRefine` и `transform` Zod работают только
2135
- в библиотеке, описание с неизвестным ключевым словом база не примет; `pattern` — ограниченное подмножество
2716
+ - В базе проверяется подмножество JSON Schema: `refine` и `superRefine` Zod база не исполняет (они в `lost` у
2717
+ `syncModels`), преобразования Zod (`.trim()`, `.transform()` …) `syncModels` отклоняет, описание с неизвестным
2718
+ ключевым словом база не примет; `pattern` — ограниченное подмножество
2136
2719
  регулярных выражений, общее для Zod, валидатора и базы. Вывод `z.toJSONSchema()` меняется между версиями Zod:
2137
2720
  в базе хранится уже нормализованная схема, а сухой прогон `syncModels` показывает разницу до применения.
2138
2721
  - Ключ — единственный способ уникальности: id вычисляется из значений ключа без нормализации, поэтому одно
@@ -2141,6 +2724,10 @@ try {
2141
2724
  - Ключ и класс объекта неизменны: смена значения ключа (email, телефон) — новый объект. Изменчивые и персональные
2142
2725
  значения лучше выносить в отдельный хаб со связью: id из email подбирается перебором и остаётся в ссылках и
2143
2726
  журнале, а версии журнала не стираются (кроме удаления аккаунта вместе с его арендатором).
2727
+ - `create()` по ключу удалённого объекта занимает его id: прежний объект после этого не восстановить
2728
+ (`restore({ expect })` — `not_deleted`, `write.restore_id_taken`, пока новый объект жив; когда удалён и он, —
2729
+ `conflict`, `write.restore_stale`). Что ключ принадлежит удалённому, показывает `withDeleted()` перед `create()`.
2730
+ - `restore()` не проверяет бизнес-правила приложения, только описание класса, концы, ключ и права.
2144
2731
  - `create` занятого ключа отвечает `exists` с id, `upsert` без права на существующую строку — отказом `42501`:
2145
2732
  так раскрывается, что ключ занят, но только внутри своего арендатора — у других арендаторов те же ключи дают
2146
2733
  другие id.
@@ -2148,8 +2735,8 @@ try {
2148
2735
  - id v5 ложатся в индекс первичного ключа вразброс; на очень больших таблицах с интенсивной вставкой это заметнее,
2149
2736
  чем с последовательными id.
2150
2737
  - Горячие цели ссылок (организация, на которую ссылается почти всё) копят блокировки `for key share` и MultiXact,
2151
- как при обычных внешних ключах; это нужно мониторить. Операции одного кошелька идут друг за другом под
2152
- блокировкой его строки ([раздел 8](#8-транзакции-батчи-и-деньги), «Рецепт: кошелёк»).
2738
+ как при обычных внешних ключах; это нужно мониторить. Приращения и правки одного объекта идут друг за другом под
2739
+ блокировкой его строки ([раздел 8](#8-транзакции-и-батчи), «Значение из прочитанного и повтор операции»).
2153
2740
  - Класс не может называться зарезервированным именем ([«Зарезервированные имена»](#зарезервированные-имена)): хаб
2154
2741
  отклоняют `hub()`, `syncModels` и база, связь — база. Хаб, заведённый мимо проверки базы, доступен не везде:
2155
2742
  например, `count` — только первым шагом.
@@ -2317,3 +2904,19 @@ try {
2317
2904
  # llms — один словарь фильтров (план 2.0, 7.9 №8): реестр операторов совпадает с фабриками пакета, с ключами letopis_query (тот же ответ, что у цепочки) и с таблицами README, шпаргалки и главы «Чтение» сайта
2318
2905
  # disconnect — обрыв соединения (план 2.0, 7.4 №7): загрузчик и первая заливка, открытие db.begin() — обрыв TCP до и после COMMIT и на открытии транзакции, pg_terminate_backend процесса, ждущего блокировку: промис отклонён ошибкой связи, необработанных ошибок в процессе нет, следующая операция пула проходит
2319
2906
  ```
2907
+
2908
+ ## 19. Лицензия
2909
+
2910
+ letopis распространяется под лицензией **PolyForm Noncommercial License 1.0.0** (SPDX `PolyForm-Noncommercial-1.0.0`):
2911
+ исходный код открыт, некоммерческое использование бесплатно, коммерческое — только по отдельной лицензии от
2912
+ владельца. Это не лицензия open source в смысле OSI. Полный текст — [LICENSE](LICENSE) (юридическую силу имеет он).
2913
+
2914
+ - **Без разрешения** — любые некоммерческие цели: личное использование для исследований, экспериментов, учёбы, хобби
2915
+ без коммерческого применения; использование благотворительными и некоммерческими организациями, учебными
2916
+ заведениями, государственными исследовательскими организациями и учреждениями. Код можно менять и распространять —
2917
+ с текстом лицензии и строкой `Required Notice: Copyright 2026 alepri51 (https://github.com/alepri51)`.
2918
+ - **По разрешению владельца** — коммерческое использование: платный продукт или сервис, внутренние системы
2919
+ коммерческой компании, разработка на заказ, встраивание в коммерческий продукт.
2920
+ - **Как получить разрешение** — issue в репозитории <https://github.com/alepri51/letopis/issues> с заголовком
2921
+ «Коммерческая лицензия»; владелец — <https://github.com/alepri51>. Подробнее — [LICENSE-COMMERCIAL.md](LICENSE-COMMERCIAL.md).
2922
+ - Версии до 2.0.0 включительно выпущены под MIT и остаются под ней; PolyForm Noncommercial действует с 2.1.0.