letopis 2.0.0 → 3.0.1

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 (83) hide show
  1. package/AGENT-CHEATSHEET.md +491 -414
  2. package/CHANGELOG.md +361 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +969 -210
  6. package/api-contract.json +385 -0
  7. package/dist/acl.d.ts +7 -2
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +7 -1
  10. package/dist/admin.js +16 -9
  11. package/dist/auth.d.ts +47 -11
  12. package/dist/auth.js +129 -15
  13. package/dist/chain.d.ts +52 -25
  14. package/dist/chain.js +445 -64
  15. package/dist/cli.js +72 -20
  16. package/dist/errors.d.ts +23 -6
  17. package/dist/errors.js +52 -13
  18. package/dist/index.d.ts +32 -15
  19. package/dist/index.js +209 -68
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +10 -6
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +654 -0
  25. package/dist/mcp.d.ts +1 -1
  26. package/dist/mcp.js +34 -16
  27. package/dist/messages/access.d.ts +10 -0
  28. package/dist/messages/access.js +13 -0
  29. package/dist/messages/api.d.ts +76 -0
  30. package/dist/messages/api.js +109 -7
  31. package/dist/messages/boot.d.ts +5 -0
  32. package/dist/messages/boot.js +11 -4
  33. package/dist/messages/codes.js +4 -4
  34. package/dist/messages/index.d.ts +5 -2
  35. package/dist/messages/index.js +5 -2
  36. package/dist/messages/log.d.ts +232 -0
  37. package/dist/messages/log.js +105 -0
  38. package/dist/messages/mcp.js +7 -7
  39. package/dist/messages/model.d.ts +40 -1
  40. package/dist/messages/model.js +52 -3
  41. package/dist/messages/query.d.ts +10 -0
  42. package/dist/messages/query.js +13 -0
  43. package/dist/messages/sql-core.d.ts +15 -1
  44. package/dist/messages/sql-core.js +20 -3
  45. package/dist/messages/sql-time.d.ts +15 -0
  46. package/dist/messages/sql-time.js +23 -0
  47. package/dist/messages/write.d.ts +52 -0
  48. package/dist/messages/write.js +77 -6
  49. package/dist/model.d.ts +10 -2
  50. package/dist/model.js +28 -6
  51. package/dist/registry.d.ts +6 -0
  52. package/dist/registry.js +6 -0
  53. package/dist/sql.d.ts +2 -0
  54. package/dist/sql.js +77 -25
  55. package/dist/sync.d.ts +4 -5
  56. package/dist/sync.js +53 -4
  57. package/dist/tx.d.ts +10 -1
  58. package/dist/tx.js +44 -2
  59. package/dist/typed.d.ts +18 -14
  60. package/dist/types.d.ts +91 -8
  61. package/dist/types.js +8 -3
  62. package/dist/up.d.ts +8 -5
  63. package/dist/up.js +71 -23
  64. package/dist/uuid.d.ts +8 -0
  65. package/dist/uuid.js +9 -1
  66. package/dist/validate.js +24 -7
  67. package/dist/watch.d.ts +5 -0
  68. package/dist/watch.js +13 -0
  69. package/dist/write.d.ts +84 -3
  70. package/dist/write.js +427 -40
  71. package/llms.txt +38 -17
  72. package/package.json +12 -6
  73. package/sql/20-context.sql +11 -3
  74. package/sql/30-validate.sql +51 -10
  75. package/sql/40-class.sql +21 -1
  76. package/sql/60-write.sql +18 -6
  77. package/sql/80-auth.sql +88 -17
  78. package/sql/90-time.sql +34 -6
  79. package/sql/99-revision.sql +5 -1
  80. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  81. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  82. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  83. 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,44 @@ 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 i letopis@3 zod postgres@3 # postgres@3 — для createTenant и своих SQL-запросов
75
+ npm i -D tsx # запуск примеров на TypeScript: npx tsx app.ts
65
76
  ```
66
77
 
78
+ `letopis@3` — мажорная линия 3, о которой это руководство: внутри неё обновления не ломают работающий код
79
+ ([«Версии и совместимость»](#версии-и-совместимость)), а `npm i letopis` без версии поставит последний выпуск, и после
80
+ выхода 4.0 им окажется он. `postgres` — зависимость letopis версии 3. Ставьте ту же мажорную версию: `npm i postgres`
81
+ без версии поставит 4.x, и код будет работать, но `createTenant(admin, …)` не пройдёт проверку типов — типы двух копий
82
+ `postgres` несовместимы. Для MCP добавьте `@modelcontextprotocol/server` (и `@modelcontextprotocol/client` — проверять
83
+ сервер своим клиентом), для PGlite — `@electric-sql/pglite @electric-sql/pglite-socket`.
84
+
85
+ Пакет — модуль ES, и примеры написаны на `import`: без `"type": "module"` в `package.json` приложения (или без
86
+ расширения `.mjs`) Node ответит `SyntaxError: Cannot use import statement outside a module`.
87
+
67
88
  Приложение подключается к базе отдельной ролью, которая состоит в `letopis_app`. Её заводит сама установка: опция
68
89
  `login` у `up()` создаёт роль с паролем (повтор только обновит пароль) и выдаёт ей `letopis_app`.
69
90
 
@@ -111,7 +132,10 @@ await svc.close()
111
132
  `owner` в нём.
112
133
  - `connect({ apiKey })` обменял ключ сервиса на сессию. `verifyPassword` вернул `{ account, token }` (или `null`,
113
134
  если пароль неверный). `svc.as(account, { tenant })` — тот же пул соединений, но от имени Анны в магазине;
114
- право сервиса на это проверяет база.
135
+ право сервиса на это проверяет база. Сессия входа и `svc.as(account)` без `tenant` работают в **личном**
136
+ арендаторе Анны (id её аккаунта): классов магазина там нет, и шаг `db.Product()` дал бы `invalid_class`. Своё
137
+ подключение по токену (`connect({ token })`) переходит в магазин через `auth.switch()`
138
+ ([«Работа в другом арендаторе»](#работа-в-другом-арендаторе)).
115
139
  - `hub('Product', поля, опции)` описал класс-объект. `ends: { Shop }` — обязательная ссылка на магазин.
116
140
  `key: ['Shop', 'sku']` — ключ: id товара вычисляется из магазина и артикула, а второй товар с тем же ключом база
117
141
  не примет.
@@ -119,7 +143,9 @@ await svc.close()
119
143
  уменьшил цену внутри оператора `UPDATE`, без предварительного чтения. `versions()` прочитал историю из журнала.
120
144
 
121
145
  Повторный вызов `up()` безопасен: он повторно накатывает ту же ревизию и данные не трогает, а ключ сервиса снова не
122
- выдаёт, пока в установке есть хоть один ключ API, кроме отозванных. Дальше раздел 2 объясняет устройство, а разделы 3–13 разбирают задачи по порядку.
146
+ выдаёт, пока в установке есть хоть один ключ API, кроме отозванных. Поэтому код, который запускают не один раз,
147
+ передаёт свой ключ — `up({ …, serviceKey: process.env.SERVICE_KEY })` — и подключается с ним
148
+ (`apiKey: r.serviceKey ?? process.env.SERVICE_KEY`); повторный `create()` того же магазина даст `exists`. Дальше раздел 2 объясняет устройство, а разделы 3–13 разбирают задачи по порядку.
123
149
 
124
150
  ## 2. Как устроен letopis
125
151
 
@@ -174,6 +200,28 @@ LOGIN-роль состоит только в ней. Всё, что выпол
174
200
  которая обходит RLS (суперпользователь, `BYPASSRLS`, член `letopis_owner`), если не указано
175
201
  `allowBypassRls: true`.
176
202
 
203
+ ### Безопасность по умолчанию
204
+
205
+ Опасное действие нельзя совершить случайно — без сверки с тем, что видел человек, оно отклоняется до записи:
206
+
207
+ - **Удаление — только по превью.** `delete()` — превью; удаляет `delete({ confirm: true, expect: превью })`, ровно
208
+ показанное. `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`).
209
+ - **Восстановление — только по надгробиям.** `restore({ expect })` с ответом `delete()` или строками `withDeleted()`;
210
+ `restore()` без `expect`, в том числе по явному id, — `invalid_query` (`write.restore_without_expect`).
211
+ - **Массовое — только явно.** Правка и удаление без фильтра — `invalid_query`, пока нет `{ all: true }`; удаление и
212
+ восстановление без сверки — тоже только с `{ all: true }`.
213
+ - **Арендатор и права — в базе.** Права по умолчанию запрещают, строки чужого арендатора не видны; роль в обход RLS
214
+ `connect()` отклоняет (`bypass_rls`), пока не передано `allowBypassRls: true`. Арендатора берут только из серверной
215
+ сессии ([«Веб-сервис: одно подключение и безопасность»](#веб-сервис-одно-подключение-и-безопасность)).
216
+ - **Администраторский пул — явно.** `createTenant(admin, …)` принимает только пул `postgres()` (`api.create_tenant_admin`).
217
+ - **Секреты в журнале событий маскируются**: поля с паролями, токенами, ключами и кредами, учётные данные в строке
218
+ подключения; значения параметров SQL и тексты ошибок PostgreSQL не пишутся вовсе. Значения фильтров чтения журнал
219
+ пишет как есть — персональные поля добавьте в `redact` ([«Логирование»](#логирование)).
220
+
221
+ Общие правила для любой задачи на letopis — одна открытая запись на объект через ключ связи, удаление и возврат по
222
+ превью, гонки, перевод ошибок в текст для человека — собраны в [памятке](AGENT-CHEATSHEET.md), «Правила для любой
223
+ задачи».
224
+
177
225
  ### Как идёт чтение
178
226
 
179
227
  Под RLS индексы по `data` и по обратным ссылкам политике напрямую недоступны. Поэтому id для шагов цепочки
@@ -225,6 +273,7 @@ LOGIN-роль состоит только в ней. Всё, что выпол
225
273
  | `owner`, `app` | имена ролей владельца и приложения (по умолчанию `letopis_owner`, `letopis_app`) |
226
274
  | `login` | роль приложения `{ user, password }`: заводится или получает новый пароль и членство в `letopis_app`; строка подключения — `r.login.dsn` ([«Роль приложения»](#роль-приложения)) |
227
275
  | `migrationsDir` | свой каталог миграций для `upgrade` (по умолчанию `sql/migrate/` пакета) |
276
+ | `log`, `logLevel`, `redact` | журнал событий установки (`up`, `migrate`), как у `connect()` ([«Логирование»](#логирование)) |
228
277
 
229
278
  Результат: `schema` (полное имя), `serviceKey` (при первой установке и при накате, когда в установке не осталось ни
230
279
  одного ключа API, кроме отозванных: истёкший или выключенный ключ тоже в счёт; иначе `null`), `created`,
@@ -233,6 +282,11 @@ LOGIN-роль состоит только в ней. Всё, что выпол
233
282
  (предупреждения, без `quiet` они печатаются), а также `tenant`, `pglite` и `login` (`{ user, created, dsn }`), если
234
283
  их просили. На PostgreSQL ниже 18.2 `up()` предупреждает: такой сервер мог усечь CLOG, который ещё нужен непрочитанным сигналам, — обновите его.
235
284
 
285
+ Ключ сервиса храните сами: повторный `up()` его не отдаёт (`serviceKey: null`). `up({ fresh: true })` ставит схему
286
+ заново — вместе с данными пропадают сессии и ключи, и выдаётся новый ключ сервиса. Процесс со старым ключом получит
287
+ `no_session: неверный ключ`: перезапустите его с новым. Чтобы ключ не менялся между установками (разработка,
288
+ стенды), передавайте свой: `up({ …, serviceKey: process.env.SERVICE_KEY })` — он регистрируется и в новой схеме.
289
+
236
290
  Несколько экземпляров сервиса могут звать `up()` одной схемы одновременно: накат идёт под блокировкой схемы. Первый
237
291
  `up()` ставит или обновляет её, остальные ждут и делают повторный накат по готовой (`created: false`,
238
292
  `upgraded: false`); первого арендатора `tenant` заводит один из них, остальные находят его по имени. `up()` разных
@@ -250,10 +304,10 @@ await up({ dsn: ADMIN, schema: 'shop', signals: 'opaque' }) // дальше up
250
304
  import { up, connect } from 'letopis'
251
305
 
252
306
  const ADMIN = 'postgres://postgres:secret@localhost:5432/shop'
253
- const APP = 'postgres://shop_app:app-secret@localhost:5432/shop'
254
307
 
255
- const r = await up({ dsn: ADMIN, schema: 'shop',
308
+ const r = await up({ dsn: ADMIN, schema: 'shop', login: { user: 'shop_app', password: 'app-secret' },
256
309
  tenant: { name: 'Ромашка', user: { name: 'Анна', login: 'anna@shop.ru', password: 'Ромашка-2026!' } } })
310
+ const APP = r.login!.dsn! // роль приложения shop_app: postgres://shop_app:app-secret@localhost:5432/shop
257
311
  const SHOP = r.tenant!.tenant // арендатор — магазин «Ромашка»
258
312
  const ANNA = r.tenant!.user! // его первый пользователь
259
313
  ```
@@ -272,37 +326,44 @@ const ANNA = r.tenant!.user! // его первый пользовател
272
326
  ### Первый арендатор и пользователь
273
327
 
274
328
  Права в letopis включены всегда и по умолчанию запрещают всё. Поэтому новому арендатору нужны правила. Опция
275
- `up({ tenant })` и функция `createTenant(sql, schema, opts)` делают это одной транзакцией:
329
+ `up({ tenant })` и функция `createTenant(admin, schema, opts)` делают это одной транзакцией:
276
330
 
277
331
  - арендатор — аккаунт в System с id из имени («tenant:<имя>»);
278
- - правило «аутентифицированным можно всё» в этом арендаторе (`grantAll`, по умолчанию да, вес 10; права только по
332
+ - правило «аутентифицированным можно всё» в этом арендаторе — чтение, запись и удаление строк всех классов, но не
333
+ эндпоинты `api` (`grantAll`, по умолчанию да, `grantAll: false` — без него; вес 10; права только по
279
334
  ролям — [ниже](#права-только-по-ролям));
280
335
  - пользователь (id из логина) с членством в арендаторе с ролями `user.roles` (по умолчанию `['owner']`), паролем и
281
336
  сессией в арендаторе.
282
337
 
283
- Повтор с теми же именем и логином ничего не дублирует и пароль не меняет, только выдаёт новую сессию. Логин входит в
284
- id как есть: `Anna@shop.ru` и `anna@shop.ru` дали бы два аккаунта, и у второго не было бы пароля, — пишите логин в
285
- одном регистре. Поле `created` в ответе — создан ли арендатор этим вызовом: для существующего арендатора с новым
338
+ Повтор с теми же именем и логином ничего не дублирует и пароль не меняет, только выдаёт новую сессию. Крайние пробелы
339
+ логина отбрасываются (` anna@shop.ru ` и `anna@shop.ru` — один пользователь), а регистр входит в id как есть:
340
+ `Anna@shop.ru` и `anna@shop.ru` дали бы два аккаунта, и у второго не было бы пароля, — пишите логин в одном регистре. Поле `created` в ответе — создан ли арендатор этим вызовом: для существующего арендатора с новым
286
341
  пользователем оно `false`. Роли задаёт `user.roles` — непустой список имён (не масок), иначе `invalid_data`. Вид
287
342
  пароля `user.kind` (по умолчанию `PASSWORD`) подчиняется правилу вида креда, как у `auth.setPassword`
288
343
  ([«Справочник API»](#dbauth)), иначе — тоже `invalid_data`. Роли
289
344
  уже существующего членства повтор не меняет: их меняет правка членства ([«Выдать право»](#выдать-право)).
290
345
  Удалённое повтор не воскрешает: если арендатор, аккаунт пользователя или членство удалены (строки нет, а история в
291
346
  журнале есть), `createTenant` отвечает `invalid_data` и не записывает ничего. Текст ошибки называет удалённое и
292
- советует вернуть его `restore()`: аккаунт — `db.Account(id).restore()` от имени системного администратора,
293
- членство — `db.member(id).restore()` в этом арендаторе; другой выход — другое имя или логин. Сессия
347
+ советует вернуть его `restore({ expect })` с надгробием из `withDeleted().rows()`: аккаунт —
348
+ `db.Account(id).restore({ expect: await db.Account(id).withDeleted().rows() })` от имени системного администратора,
349
+ членство — так же `db.member(id)` в этом арендаторе; другой выход — другое имя или логин. Сессия
294
350
  из `createTenant` — без способа входа, поэтому свой пароль по ней не сменить: для этого пользователь входит по
295
- паролю. `createTenant` нужен администраторский пул postgres.js:
351
+ паролю.
352
+
353
+ `admin` — **пул `postgres()`** администраторской роли (функция с методами `begin` и `unsafe`), а не строка
354
+ подключения и не подключение `connect()` letopis: другое значение `createTenant` отклоняет до запросов — `invalid_query`
355
+ (`api.create_tenant_admin`, текст называет, что передали). `schema` — как у `connect()` и `up()`: `'shop'` даёт
356
+ `v2.shop`, можно сразу `'v2.shop'` или `r.schema`. Пул закрывает вызывающий:
296
357
 
297
358
  <!-- run: guide -->
298
359
  ```ts
299
360
  import postgres from 'postgres'
300
361
  import { createTenant } from 'letopis'
301
362
 
302
- const pg = postgres(ADMIN)
303
- const lutik = await createTenant(pg, r.schema, {
363
+ const admin = postgres(ADMIN) // пул postgres(), не строка DSN
364
+ const lutik = await createTenant(admin, r.schema, {
304
365
  name: 'Лютик', user: { name: 'Борис', login: 'boris@lutik.ru', password: 'Лютик-2026!' } })
305
- await pg.end()
366
+ await admin.end()
306
367
  const BORIS = lutik.user! // lutik: { tenant, user, token, created: true }
307
368
  ```
308
369
 
@@ -325,7 +386,7 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
325
386
  | Опция `connect()` | Что делает |
326
387
  |---|---|
327
388
  | `dsn` или `sql` | строка подключения или готовый пул postgres.js |
328
- | `schema` | полное имя установки: `'v2.shop'` (удобно брать `r.schema`) |
389
+ | `schema` | имя установки: `'v2.shop'` (удобно брать `r.schema`) или, как у `up()`, `'shop'` — то же `v2.shop` |
329
390
  | `token`, `apiKey` | токен сессии или ключ API (`service` — синоним `apiKey`) |
330
391
  | `models` | модели `letopis/model` — только для типов цепочек ([раздел 4](#4-модель-данных)) |
331
392
  | `max` | размер пула (по умолчанию 10) |
@@ -333,6 +394,7 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
333
394
  | `cache` | кэш результатов: `{ ttl, max, ttlOnly }` или `false` ([раздел 12](#12-подписка-на-изменения-и-кэш)) |
334
395
  | `maintain` | запускать `maintain()` по расписанию: `'1h'`, `'15m'` или миллисекунды |
335
396
  | `onQuery`, `slowMs` | хук на каждый запрос цепочки и порог медленного запроса |
397
+ | `log`, `logLevel`, `redact` | журнал событий: функция или объект-логгер (`console`, pino), порог (`'info'` по умолчанию) и ещё имена полей для маскировки ([«Логирование»](#логирование)) |
336
398
  | `refreshSession` | продлевать сессию раз в минуту работы (по умолчанию да) |
337
399
  | `notifyIntervalMs` | интервал отправки отложенных сигналов (100 мс) |
338
400
  | `allowBypassRls`, `owner` | разрешить роль в обход RLS (администраторское подключение); имя роли-владельца |
@@ -342,7 +404,11 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
342
404
  проверяют опции `up()`, `db.load()`, `db.watch()` и `createMcpServer()` ([«Строгий режим»](#строгий-режим)).
343
405
 
344
406
  У подключения есть `db.schema`, `db.token`, `db.tenant` (арендатор сессии) и `db.close()`. Подключения,
345
- полученные через `as()`, делят пул с исходным: закрывать нужно исходное.
407
+ полученные через `as()`, делят пул с исходным: закрывать нужно исходное, один раз — оно закрывает пул, подписки и все
408
+ фасады. `close()` у подключения из `as()` ничего не делает: пул общий, и фасад после такого вызова работает дальше.
409
+ После `close()` исходного подключения любой вызов — цепочка, `begin()`, `transaction()`, `sql`, `auth.*` у него и у
410
+ его фасадов — `invalid_query` (`api.closed`, «подключение закрыто (db.close()) — откройте новое connect()»): пул заново
411
+ не открывается, и процесс может завершиться. Повторный `close()` ошибки не даёт.
346
412
 
347
413
  ### Администраторское подключение
348
414
 
@@ -377,14 +443,14 @@ await solo.close() // закрывает оба пул
377
443
 
378
444
  ### Обновление пакета и схемы
379
445
 
380
- У схемы есть ревизия движка (сейчас 8). Если новая версия пакета принесёт новую ревизию, `connect()` только
446
+ У схемы есть ревизия движка (сейчас 11, `r.revision` и константа `DDL_REVISION`). Если новая версия пакета принесёт новую ревизию, `connect()` только
381
447
  предупредит и назовёт команду. Отказывает `up()`: на отставшую схему без `upgrade: true` и на схему новее пакета.
382
448
  К схеме новее пакета (её уже обновил более новый пакет, а старые экземпляры приложения ещё работают) `connect()`
383
449
  подключается с предупреждением
384
450
  «движок схемы … ревизии N новее библиотеки (она знает M) — обновите пакет letopis». Поэтому скрипт запуска, который
385
451
  вызывает `up()` при каждом старте, после такого обновления пакета остановится, пока не получит `upgrade: true`.
386
452
  `up({ dsn, schema, upgrade: true })` применяет миграции из `sql/migrate/` и повторно накатывает функции; данные
387
- сохраняются. У 2.0 миграций нет.
453
+ сохраняются. Пока схема не обновлена, `connect()` предупреждает, а функции новой ревизии в ней не работают.
388
454
 
389
455
  Ревизия меняется не с каждой версией: у версий одной ревизии функции могут быть разные. Поэтому `up()` пишет в
390
456
  комментарий схемы ещё и штамп движка: версию пакета и хэш его файлов SQL. Если штамп не совпадает с файлами пакета,
@@ -396,21 +462,22 @@ await solo.close() // закрывает оба пул
396
462
 
397
463
  Версии letopis следуют semver. Что обещает каждый шаг версии:
398
464
 
399
- - **Патч** (2.0.0 → 2.0.1) — только исправления. Ревизия движка та же: исправленные функции накатывает обычный
465
+ - **Патч** (3.0.0 → 3.0.1) — только исправления. Ревизия движка та же: исправленные функции накатывает обычный
400
466
  `up({ schema })`, миграций нет; до наката `connect()` предупреждает о расхождении штампа. Работающий код не получает
401
467
  новых отказов, методы, опции, экспорты и коды ошибок не исчезают.
402
- - **Минор** (2.0 → 2.1) — новые возможности: методы, опции, коды ошибок; изменения поведения, которые не ломают
468
+ - **Минор** (3.0 → 3.1) — новые возможности: методы, опции, коды ошибок; изменения поведения, которые не ломают
403
469
  работающий код; новая ревизия движка. Новую ревизию накатывает `up({ schema, upgrade: true })`. Удалённого и
404
470
  переименованного нет.
405
- - **Мажор** (2.x → 3.0) — удаление и переименование методов, опций, экспортов и кодов ошибок, смена сигнатур и типов,
471
+ - **Мажор** (3.x → 4.0) — удаление и переименование методов, опций, экспортов и кодов ошибок, смена сигнатур и типов,
406
472
  новые отказы для всех. Что снято и чем его заменить, перечисляет запись версии в [CHANGELOG.md](CHANGELOG.md). Схему
407
473
  прошлого мажора новый мажор может не обновлять.
408
474
 
409
475
  Текст сообщения об ошибке в API не входит: в патче он может стать точнее. Полагайтесь на `err.code`.
410
476
 
411
- Шаг версии перед каждым выпуском сверяет сценарий `check:semver`: он сравнивает контракт API (`docs/api-contract.json`)
412
- и ревизию движка с прошлым выпуском и требует шаг не меньше нужного. Каждый следующий выпуск 2.x проверяет
413
- обновление схемы с предыдущего выпуска.
477
+ Шаг версии перед каждым выпуском сверяет сценарий `check:semver`: он сравнивает контракт API (`api-contract.json` в корне пакета)
478
+ и ревизию движка с прошлым выпуском и требует шаг не меньше нужного. Каждый выпуск проверяет
479
+ обновление схемы с предыдущего выпуска. Что изменилось при переходе на 3.0 и чем заменить снятое — запись
480
+ версии в [CHANGELOG.md](CHANGELOG.md).
414
481
 
415
482
  ### Разработка без PostgreSQL: PGlite
416
483
 
@@ -471,13 +538,8 @@ const line = link('line', { qty: z.number().int().min(1), price: z.number().int(
471
538
  ends: { Order: one(Order).onDelete('cascade'), Product },
472
539
  key: ['Order', 'Product'],
473
540
  })
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
541
 
480
- const models = [Shop, Category, Product, Customer, Vip, Order, line, Wallet, Movement]
542
+ const models = [Shop, Category, Product, Customer, Vip, Order, line]
481
543
  const plan = await syncModels(db, models) // сухой прогон: что будет создано и изменено
482
544
  const applied = await syncModels(db, models, { apply: true }) // { applied: true, created: ['Shop', …], … }
483
545
  ```
@@ -489,8 +551,13 @@ const applied = await syncModels(db, models, { apply: true }) // { applied: true
489
551
  поле необязательным, `.default(…)` — задаёт умолчание, которое база подставит при вставке. Пометки `.meta()` у поля
490
552
  (`title`, `description`, свои ключи) база хранит, но на проверку данных они не влияют.
491
553
 
492
- Правила `refine`, `superRefine` и `transform` в JSON Schema не выражаются, и база их не исполняет. `syncModels`
493
- возвращает их в `lost`: проверяйте такие правила в приложении. Описание с ключевым словом JSON Schema, которого
554
+ Правила `refine` и `superRefine` в JSON Schema не выражаются, и база их не исполняет. `syncModels` возвращает их в
555
+ `lost`: проверяйте такие правила в приложении. Преобразования Zod — `.trim()`, `.toLowerCase()`, `.toUpperCase()`,
556
+ `.normalize()`, `.transform()`, `z.preprocess()`, `.pipe()` — меняют значение, а не проверяют его: ни база, ни
557
+ валидатор их не исполняют, и данные записались бы как есть. Поэтому модель с ними `syncModels` (и `letopis sync`)
558
+ отклоняет до записи, в том числе в сухом прогоне: `invalid_class` (`model.transform_unsupported`, пути и виды — в
559
+ `err.detail.rules`). Значение приводят в коде сервиса до записи (`value.trim()`), а в модели оставляют проверку:
560
+ `.regex()`, `.min()`, `.refine()`. Описание с ключевым словом JSON Schema, которого
494
561
  база не знает, отклоняется — ни одно правило не пропадёт молча. Шаблоны `pattern` — ограниченное подмножество
495
562
  регулярных выражений, общее для Zod, валидатора и базы. Исполняет шаблон на данных только база: шаблон задаёт
496
563
  арендатор, и шаблон с катастрофическим перебором повесил бы процесс приложения. Поэтому валидатор на TypeScript
@@ -521,7 +588,10 @@ const applied = await syncModels(db, models, { apply: true }) // { applied: true
521
588
  Нужный формат ключевого поля задайте в модели, и база будет его проверять.
522
589
 
523
590
  - id не передают: база вычисляет его сама. Чужой id — `id_mismatch`, id для класса без ключа — `id_from_db`.
524
- - Заранее id вычисляет `db.idOf(класс, ключ)`, например `db.idOf('Shop', { name: 'Ромашка' })`.
591
+ - Заранее id вычисляет `db.idOf(класс, ключ)`, например `db.idOf('Shop', { name: 'Ромашка' })`. Часть ключа — конец
592
+ связи: имя роли и id цели строкой, `db.idOf('Product', { Shop: shop.id, sku: 'K-1' })` (не строка ответа). Часть
593
+ ключа не строкой — `invalid_data` (`model.key_part_not_string`), конец не uuid — `invalid_data`
594
+ (`model.key_end_not_uuid`).
525
595
  - Класс без ключа (`Customer` выше) получает id от случайной части; узнать его можно из ответа записи.
526
596
  - Ключ и класс объекта неизменны: правка ключевого поля или класса — `immutable_key`. Объект с другим ключом или
527
597
  классом — новый объект ([раздел 5](#5-запись), «Ключ и класс неизменны»).
@@ -533,14 +603,18 @@ id из email подбирается перебором и остаётся в
533
603
  ### Наследование и алиасы
534
604
 
535
605
  `extends` — потомок получает поля, концы и ключ предка. Своё поле с тем же именем заменяет поле предка целиком.
536
- `abstract: true` — у класса не бывает своих строк. Шаг цепочки по классу отдаёт и строки потомков: `db.Customer()`
606
+ `abstract: true` — у класса не бывает своих строк: `create()` абстрактного класса — `invalid_class`
607
+ (`sql-write.class_abstract`, «класс … абстрактный»), строки создают у потомков. Шаг цепочки по классу отдаёт и строки потомков: `db.Customer()`
537
608
  найдёт и `Vip`, а `.exact()` оставит только сам класс. `alias` — второе имя класса для шага цепочки, например
538
609
  по-русски.
539
610
 
540
611
  ### Настройки класса
541
612
 
542
613
  - `history: false` — журнал для класса не ведётся: нет версий, среза на дату, восстановления и событий подписки (по
543
- умолчанию `true` — журнал хранит каждую версию). Сменить `history` у существующего класса может только тот, у кого
614
+ умолчанию `true` — журнал хранит каждую версию). `versions()` такого класса — пустой список без ошибки, с предупреждением
615
+ `api.no_history` в консоль; правка
616
+ перезаписывает данные, а удаление стирает строку без следа в журнале — так хранят данные, которые потом стирают
617
+ ([«Удалить аккаунт»](#удалить-аккаунт)). Сменить `history` у существующего класса может только тот, у кого
544
618
  есть право `READ` и `DELETE` без условий на все строки класса и его потомков: `false` выключает журнал — аудит этих
545
619
  строк.
546
620
  - `ownerDefault: 'actor'` — владелец новых строк — сам пользователь, а не арендатор (по умолчанию `'tenant'`).
@@ -556,7 +630,21 @@ id из email подбирается перебором и остаётся в
556
630
  `syncModels` трогает только переданные классы и ничего не удаляет. Безопасные изменения — новое необязательное поле или
557
631
  конец, метаданные, алиас — применяются сразу. Всё остальное, в том числе любая правка условий `anyOf` и `oneOf`, —
558
632
  ужесточение: оно проверяет все строки семейства, и если им мешают строки, приходит `tightening_conflict` со списком. Классы пишутся в арендатора сессии, и другие арендаторы их не видят.
559
- Классы, общие для всех, лежат в System: их ставит сид установки.
633
+ Классы, общие для всех, лежат в System: их ставит сид установки. Модель с преобразованиями Zod `syncModels`
634
+ отклоняет ([«Поля»](#поля)).
635
+
636
+ Одна модель в нескольких арендаторах — `syncModels` в сессии каждого: сервис действует от имени пользователя, которому
637
+ в этом арендаторе можно писать классы (по правилу «можно всё» — его члену):
638
+
639
+ ```ts
640
+ for (const [user, tenant] of [[ANNA, SHOP], [BORIS, lutik.tenant]]) {
641
+ await syncModels(await svc.as(user, { tenant }), models, { apply: true }) // классы — в каждом арендаторе свои
642
+ }
643
+ ```
644
+
645
+ Из командной строки — тот же `letopis sync` с токеном сессии каждого арендатора. Повтор по арендатору, где модель уже
646
+ записана, ничего не меняет (`unchanged`). Администраторское подключение для этого не нужно: через фасад `svc.as()`
647
+ классы пишет сессия пользователя в пуле приложения, под RLS.
560
648
 
561
649
  То же из командной строки:
562
650
 
@@ -606,17 +694,54 @@ const n = await loose(typed)[name]().count()
606
694
  Чего строгие типы не знают: возврат к узлу связи от класса, с которым она не связана концом, и роль чужого класса
607
695
  шагом; `db.as()`, `db.begin()`, `db.batch()` и `entity()` отдают цепочки без строгих типов.
608
696
 
697
+ Веб-сервису строгие типы нужны у фасада `svc.as()`, а не у своего подключения по токену: свой пул на каждый вход
698
+ исчерпал бы соединения ([«Веб-сервис»](#веб-сервис-одно-подключение-и-безопасность)). Фасад и подключение по токену —
699
+ одни и те же цепочки во время выполнения, различаются только типы, поэтому фасад приводят к `TypedDb` одним
700
+ помощником:
701
+
702
+ ```ts
703
+ import type { Db, TypedDb } from 'letopis'
704
+
705
+ const typedAs = (d: Db) => d as unknown as TypedDb<typeof models> // только тип: проверки во время выполнения те же
706
+ const shopDb = typedAs(await svc.as(ANNA, { tenant: SHOP }))
707
+ await shopDb.Shop({ name: 'Ромашка' }).Product().rows() // строгие типы, как у connect({ models })
708
+ ```
709
+
710
+ Фильтр, собранный во время выполнения (из параметров запроса), — тип `FieldFilter` из `letopis`:
711
+ `const f: Record<string, FieldFilter> = { status: req.query.status }` и `loose(shopDb).Order(f)`; `Record<string,
712
+ unknown>` проверку типов не проходит.
713
+
609
714
  **Отказы во время выполнения** — с моделями и без них:
610
715
 
611
716
  | Что | Ответ |
612
717
  |---|---|
613
- | `update()` или `delete({ confirm: true })`, а ни у шага глагола, ни у шагов до него нет фильтра, id, непустого `tags()`, `owner()` или `limit()` и раньше в цепочке нет `create()` или `upsert()` (цель `update()` задаёт и `data.id` патча) | `invalid_query` до записи, в батче — при `run()`; все строки шага — `{ all: true }` ([раздел 5](#изменить-update), [раздел 7](#превью-и-удаление)) |
718
+ | `delete({ confirm: true })` без `expect` (и без `all: true`) | `invalid_query` (`write.confirm_without_expect`) до записи, в батче — при `run()`: удаление не сверено с превью, которое видел пользователь ([раздел 7](#превью-и-удаление)) |
719
+ | `restore()` без `expect` (и без `all: true`), в том числе по явному id | `invalid_query` (`write.restore_without_expect`) до записи ([«Восстановить: `restore`»](#восстановить-restore)) |
720
+ | `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
721
  | неизвестная опция `connect()`, `up()`, `db.load()`, `db.watch()`, `createMcpServer()` | `invalid_data` с похожими именами |
615
- | шаг — не класс, не алиас и не роль реестра | `invalid_class` с похожими именами — при вызове шага |
722
+ | шаг — не класс, не алиас и не роль реестра | `invalid_class` с похожими именами (системные классы в похожие не идут) — при вызове шага; подсказка — классы арендатора видны только в его сессии. В реестре сессии нет ни одного класса арендатора — свой ключ `api.unknown_step_no_classes`: сессия, похоже, в личном арендаторе ([«Работа в другом арендаторе»](#работа-в-другом-арендаторе)) |
723
+ | у цепочки спросили свойство массива — `.length`, `.map()`, `.filter()`, `.forEach()`, `[0]` и т. п. — или перебрали её (`for…of`, `[...цепочка]`) | `invalid_query` (`api.chain_not_rows`): цепочка — не массив, строки даёт терминал — `await ….rows()` |
724
+ | id шага, фильтра `{ id }` или значения конца — не uuid, `null` вместо id | `invalid_query` (`query.bad_uuid`) до запроса; id в любом виде, который принимает PostgreSQL (без дефисов, в фигурных скобках), проходит |
725
+ | модель с преобразованием Zod (`.trim()`, `.transform()` …) | `invalid_class` (`model.transform_unsupported`) от `syncModels` до записи ([«Поля»](#поля)) |
726
+ | `db.begin(аргумент)` — функция, строка, число, массив вместо опций `{ reason }` | `invalid_query` (`api.begin_arg`): `begin()` функцию не вызывает; неизвестная опция `begin()` — `invalid_data` с похожими |
727
+ | `restore({ partial: true })` без `expect`, в том числе с `all: true` | `invalid_query` (`write.partial_without_expect`) |
728
+ | `createTenant(admin, …)`, где `admin` — не пул `postgres()`: строка DSN, подключение `connect()`, `undefined` | `invalid_query` (`api.create_tenant_admin`) до запросов ([«Первый арендатор и пользователь»](#первый-арендатор-и-пользователь)) |
729
+ | метод цепочки у результата `versions()`: `versions().rows()`, `.sort()`, `.limit()` | `invalid_query` (`api.versions_terminal`): `versions()` — терминал, модификаторы — до него |
730
+ | `forUpdate()` с тем, что блокировать нельзя: `count()`, `paths()`, `versions()`, агрегат, `asOf()`, `withDeleted()`, `deep()`, глагол | `lock_unsupported` (`api.for_update_unsupported`): текст называет помеху |
616
731
  | поле фильтра (и внутри `or()`), `sort('data.…')` или агрегата, которого нет в описании ни одного класса шага (с потомками, если шаг не `exact()`; у роли — её целей); в фильтре годится и роль конца | `invalid_query` с похожими именами до запроса, у плана записи — до записи. Сверяется только первый ключ пути: путь внутрь поля-объекта не проверяется. У строк `Class` поля — свойства описания класса (`kind`, `alias`, `parent` и другие) |
617
732
  | `after()` перед `count()`, `sum()`, `avg()`, `min()`, `max()` или `countBy()` | `invalid_query`: курсор листает строки, а счёт идёт по всем объектам шага |
618
733
  | класс с зарезервированным именем | `invalid_class`: хаб — от `hub()`, `syncModels` и базы, связь — от базы ([«Имена классов»](#имена-классов)) |
619
734
 
735
+ **Предупреждения** — не отказы: цепочка исполняется, а в консоль один раз на место (`console.warn`, текст начинается с
736
+ `letopis:`) выводится строка:
737
+
738
+ | Что | Ключ | Почему |
739
+ |---|---|---|
740
+ | шаг с аргументом `undefined`: `db.Product(undefined)` | `api.step_undefined_filter` | фильтра нет — шаг находит все строки, как `db.Product()`; чаще всего это пустая переменная с id |
741
+ | шаг после `sort()`: `db.Shop(shop).sort('data.name').Product()` | `api.sort_not_last` | `sort()` относится к последнему шагу цепочки — ставьте его в конец |
742
+ | `withDeleted()` не у последнего шага, а у последнего его нет: `db.Order(o).withDeleted().line()` | `api.with_deleted_step` | `withDeleted()` — модификатор своего шага: здесь удалённые заказы и живые строки; удалённые строки — `.line().withDeleted()` ([«Удалённые»](#удалённые)) |
743
+ | `versions()` у класса с `history: false` | `api.no_history` | журнала у класса нет — ответ пустой ([«Настройки класса»](#настройки-класса)) |
744
+
620
745
  ### Имена классов
621
746
 
622
747
  Шаг цепочки называется именем класса, его алиасом или именем роли. Имена методов цепочки и корня (`count`, `sort`,
@@ -656,10 +781,22 @@ try {
656
781
  - `create` только создаёт. Занятый ключ — ошибка `exists` с `detail.id`: id библиотека вычисляет сама. Поэтому у
657
782
  класса с ключом повтор после сбоя сети сразу показывает, что первая попытка прошла. Класс без ключа так не
658
783
  защищён: повтор создаст второй объект.
659
- - Теги новой строки задаёт `.tags([…])` перед глаголом, владельца — `.owner(id)`; явного владельца должны разрешать
660
- права.
661
- - Удалённый объект можно создать снова тем же ключом: история продолжится.
784
+ - Теги новой строки задаёт `.tags([…])` после шага класса и перед глаголом —
785
+ `db.Product().tags(['новинка']).create({ … }).rows()`, владельца — так же `.owner(id)`; явного владельца должны
786
+ разрешать права. У корня таких методов нет: `db.tags([…])` ищет класс `tags` — `invalid_class`.
787
+ - Удалённый объект можно создать снова тем же ключом: новый объект **занимает id удалённого** (id — v5 от ключа), и
788
+ история id продолжится. Восстановить прежний после этого нельзя: `restore({ expect })` по его надгробию — `not_deleted`
789
+ (`write.restore_id_taken`). Если удалённое ещё могут вернуть, перед `create()` проверьте
790
+ ключ вместе с удалёнными: `db.Shop({ name: 'Ромашка' }).withDeleted().first()` — строка с `$deleted: true` значит,
791
+ что ключ принадлежит удалённому объекту, и решать, восстановить его или занять ключ, должно приложение.
662
792
  - Ответ — строки созданных объектов со всеми колонками ([раздел 14](#14-справочник-api), «Строка ответа»).
793
+ - Ключи со значением `undefined` (`{ phone: undefined }`) `create`, `upsert` и `update` отбрасывают. У `update` поле
794
+ поэтому не меняется, а `upsert` заменяет данные целиком — поле с `undefined` у него **пропадает**, как не
795
+ переданное. `null` — значение: оно проходит, только если схема поля его допускает (`.nullable()`).
796
+ - Концы — не поля данных: ключ с именем роли (`{ Shop: shop.id }`) в данных `create` — ошибка `invalid_data` с ключом
797
+ `write.link_in_data` до записи: текст называет ключ и класс («… — связь, а не поле данных») и сразу форму слота —
798
+ `.Shop.set(id)`; конец берётся из пути или задаётся слотом ([«Концы: из пути и слоты»](#концы-из-пути-и-слоты)). То
799
+ же у `upsert` и `update`.
663
800
 
664
801
  ### Изменить: `update`
665
802
 
@@ -678,6 +815,15 @@ await db.Shop(shop).Product({ price: gte(2000) }).update({ title: 'Чайник
678
815
  сужает); без них — `invalid_query` до записи
679
816
  ([«Строгий режим»](#строгий-режим)). Править все строки шага — явно: `db.Product().update({ views: 0 }, { all: true })`
680
817
  правит все товары арендатора. Шаг ничего не нашёл — ответ пустой и ошибки нет, поэтому проверяйте длину ответа.
818
+
819
+ Фильтр, который сужает цели не только по id (по данным, концам, `tags()`, `owner()`), `update` перепроверяет: цели
820
+ блокируются (`for no key update`, по порядку id) и читаются заново тем же запросом, а правятся только строки, которые
821
+ и после ожидания блокировки подходят. Так `db.Product({ price: gte(2000) }).update(…)` не тронет товар, который
822
+ параллельная транзакция успела удешевить, а из двух одновременных `db.Order({ id, status: 'new' }).update({ status:
823
+ 'paid' })` проходит один — второй отвечает `[]`. Фильтр родительского шага перепроверяется так же. Без гонки цели те
824
+ же. Фильтр только по id перепроверять нечего: две одновременные правки одного id пройдут обе, вторая поверх первой. Перепроверка защищает только условие самого фильтра:
825
+ новое значение, вычисленное из прочитанного, защищают `{ rev }` и `forUpdate()` в `db.begin()`, а правило «не больше
826
+ одной открытой записи на объект» — ключ связи ([«Восстановить: `restore`»](#восстановить-restore)).
681
827
  Если `update` затронул меньше строк, чем нашёл шаг, весь план откатывается: строку успели удалить —
682
828
  `target_not_found`, строка видна, но править её нельзя — `acl_denied`. Модификатор `.tags(…)` перед `update`
683
829
  отбирает строки; строка или список ещё и заменяют их теги целиком, а оператор (`has`, `hasAny`, `hasAll`) только
@@ -695,9 +841,10 @@ const [replaced] = await db.Shop().upsert({ name: 'Ромашка', city: 'Уф
695
841
  один оператор `INSERT … ON CONFLICT`, поэтому гонки «проверил, что нет, — создал» не бывает. Правила:
696
842
 
697
843
  - только класс с ключом, иначе `no_key` без обращения к базе;
698
- - объект передаётся целиком: поля, которых нет в переданном объекте, пропадают;
844
+ - объект передаётся целиком: поля, которых нет в переданном объекте или которые равны `undefined`, пропадают;
845
+ сохранить прежнее значение поля — `update`, а не `upsert`;
699
846
  - ответ несёт `$upsert`: `created`, `updated` или `unchanged`;
700
- - приращать `upsert` не умеет и затирает параллельные приращения — для денег он не годится;
847
+ - приращать `upsert` не умеет и затирает параллельные приращения — для счётчиков он не годится;
701
848
  - для синхронизации больших справочников дешевле загрузчик с `existing: 'update'` ([раздел 9](#9-массовая-загрузка)).
702
849
 
703
850
  ### Концы: из пути и слоты
@@ -716,6 +863,16 @@ await db.Category(green).update({}).Parent.unset().rows() // катег
716
863
  await db.Category(green).update({}).Parent.set(tea).rows() // и снова внутри «Чая»
717
864
  ```
718
865
 
866
+ У связи с двумя концами один конец обычно приходит из пути, второй — слотом; можно и оба слотом. Строка заказа
867
+ `line` с концами `Order` и `Product` (пример целиком — в [«Как исполняется план»](#как-исполняется-план)):
868
+
869
+ ```ts
870
+ await db.Order(order).line().create({ qty: 1, price: 900 }).Product.set(sencha).rows() // Order — из пути
871
+ // или так — оба конца слотом (вместо строки выше: ключ [Order, Product] второй такой строки не даст)
872
+ await db.line().create({ qty: 1, price: 900 }).Order.set(order).Product.set(sencha).rows()
873
+ // db.line().create({ qty: 1, price: 900, Order: order, Product: sencha.id }) — invalid_data: концы не поля данных
874
+ ```
875
+
719
876
  ### Числа и счётчики: `inc`
720
877
 
721
878
  <!-- run: guide -->
@@ -734,7 +891,7 @@ await db.Product(kettle).update({ views: inc(1, { start: 0 }) }).rows() // п
734
891
  - поле должно уже быть числом; если его нет — `invalid_data` или `start`. `null` и нечисло — ошибка даже со `start`;
735
892
  - путь идёт только через существующие объекты;
736
893
  - маркер `inc` нельзя получить из JSON: `{ "$inc": 5 }`, пришедший из сети, остаётся данными;
737
- - деньги храните целым числом мелких единиц (копеек) не больше 2^53 − 1.
894
+ - суммы храните целым числом мелких единиц (копеек) не больше 2^53 − 1.
738
895
 
739
896
  Операции `inc` одного объекта идут друг за другом под блокировкой его строки, и каждая пишет его версию: писатели
740
897
  одного горячего счётчика ждут друг друга.
@@ -755,7 +912,7 @@ try {
755
912
  Правка с `{ rev }` нужна, когда новое значение вычислено из прочитанного: если строку успели изменить, приходит
756
913
  `conflict`, и ничего не записывается. `{ rev }` работает при одной цели шага (иначе `rev_ambiguous`). `conflict`
757
914
  библиотека сама не повторяет: что делать с изменившимися данными, решает приложение. Другой способ — чтение с
758
- блокировкой `forUpdate()` ([раздел 8](#8-транзакции-батчи-и-деньги)).
915
+ блокировкой `forUpdate()` ([раздел 8](#8-транзакции-и-батчи)).
759
916
 
760
917
  ### Ключ и класс неизменны
761
918
 
@@ -816,6 +973,13 @@ const onlyCustomer = await db.Customer().exact().rows() // только
816
973
  ценой `"900"`. Список как значение поля — «массив содержит все элементы». Операторы приводят своё значение к типу
817
974
  поля из JSON Schema. Роль конца в фильтре — id цели или `null`.
818
975
 
976
+ Тип поля для сравнения — по классу шага. Шаг по родителю отдаёт и потомков, и в таком семействе поле может быть
977
+ описано разными типами (потомок переопределил тип, в одном из классов поле не описано). Тогда значение, которое к
978
+ типу класса шага не приводится, в операторах сравнения, `sort()`, `after()` и агрегатах `min`, `max`, `sum`, `avg`
979
+ считается `null`: при сортировке по возрастанию оно идёт после прочих (по убыванию — первым), ни одному оператору
980
+ сравнения не отвечает, агрегат его пропускает, а курсор такой строки — как курсор `null`. Значение, которое
981
+ приводится (`'7'` у числового поля), сравнивается как число. В семействе с одним типом поля всё как обычно.
982
+
819
983
  <!-- run: guide -->
820
984
  ```ts
821
985
  import { lt, ilike, between, or, not } from 'letopis'
@@ -855,6 +1019,18 @@ const roots = await db.Category({ Parent: null }).rows() // корневы
855
1019
 
856
1020
  Теги и владелец строки — модификаторы `.tags(…)` и `.owner(…)`.
857
1021
 
1022
+ Пустые значения в фильтре:
1023
+
1024
+ - `{ поле: null }` — вхождение в `data`: находит только строки, где в поле явно записан `null`. Строки, где поля нет
1025
+ совсем, находят `isNull()` (и `null`, и нет поля) и `exists(false)` (только нет поля).
1026
+ - Фильтр `undefined` — шаг без фильтра: `db.Product(undefined).first()` вернёт любой товар и выведет предупреждение
1027
+ `api.step_undefined_filter` ([«Строгий режим»](#строгий-режим)). Ключ со значением `undefined` в объекте фильтра
1028
+ (`{ id: undefined }`, `{ Shop: undefined }`) отбрасывается. `update()` без сужения откажет сам (`invalid_query`), а
1029
+ чтение — нет: обязательные id из запроса проверяйте до цепочки.
1030
+ - id в фильтре — uuid. Строка другого вида (`db.Product('42')`), а также `null` вместо id — `invalid_query`
1031
+ (`query.bad_uuid`) до запроса: `Product(…): "42" — не uuid`. Так же проверяются `{ id }` и значение конца
1032
+ (`{ Shop: '42' }`). Подходит любой вид, который принимает PostgreSQL: с дефисами, без них, в фигурных скобках.
1033
+
858
1034
  ### Терминалы и строка ответа
859
1035
 
860
1036
  | Терминал | Что отдаёт |
@@ -882,10 +1058,20 @@ const page2 = await db.Product().sort('data.price', 'desc')
882
1058
  .after(cursorOf(page1.at(-1)!, 'data.price')).limit(2).rows() // строго после последней строки страницы
883
1059
  ```
884
1060
 
1061
+ Модификаторы выборки не привязаны к шагу, после которого стоят. `sort`, `limit`, `offset`, `after` и `forUpdate`
1062
+ относятся к последнему шагу цепочки (а перед глаголом — к его целям), `asOf` — ко всей цепочке:
1063
+ `db.Shop(shop).sort('data.price').Product().rows()` сортирует товары, а поле `sort()` сверяется с классом последнего
1064
+ шага — иначе `invalid_query` с именем этого класса. Шаг после `sort()` выводит предупреждение `api.sort_not_last`:
1065
+ ставьте `sort()` в конец цепочки. `withDeleted()` — модификатор своего шага, как `exact()`
1066
+ ([«Удалённые»](#удалённые)).
1067
+
885
1068
  `sort(поле, 'asc' | 'desc')` — по `'at'`, `'rev'`, `'id'` или `'data.<путь>'`. `after(cursorOf(строка, поле))` —
886
1069
  постраничный вывод «после такой-то строки»; он требует `sort()`. Такой вывод устойчив к вставкам и удалениям между
887
1070
  страницами, но строка, у которой изменилось поле сортировки, может выпасть или повториться. Курсор — объект
888
- `{ v, id, rev }` (значение поля, id и номер версии строки): он переживает JSON, и его можно отдать клиенту. Поле в `cursorOf` должно
1071
+ `{ v, id, rev }` (значение поля, id и номер версии строки): он переживает JSON, и его можно отдать клиенту — например,
1072
+ строкой в ссылке «дальше»: `Buffer.from(JSON.stringify(cursorOf(last, 'data.price'))).toString('base64url')`, обратно —
1073
+ `JSON.parse(Buffer.from(s, 'base64url').toString())` в `after()`; курсор от клиента — недоверенные данные, неверный
1074
+ даёт `invalid_query` (ниже). Поле в `cursorOf` должно
889
1075
  совпадать с полем `sort`. Значение курсора проверяется по типу поля `sort` до запроса, неверное — `invalid_query`:
890
1076
  числовому полю нужно конечное число, `rev` — целое, логическому — `true` или `false`, текстовому — строка, uuid и
891
1077
  `id` — uuid, времени и `at` — строка ISO с поясом, дате — `ГГГГ-ММ-ДД`, полю без описания — строка, число или
@@ -910,7 +1096,10 @@ const page2 = await db.Product().sort('data.price', 'desc')
910
1096
  const subtree = await db.Category(tea).Category().deep().rows() // подкатегории любой глубины; $depth — уровень
911
1097
  ```
912
1098
 
913
- `deep(max)` повторяет переход к тому же классу до глубины `max` (по умолчанию 32 уровня). Фильтр, теги и владелец
1099
+ `deep(max)` повторяет переход к тому же классу до глубины `max` (по умолчанию 32 уровня). Нужен конец класса на
1100
+ самого себя (`Parent: one('Category')` в модели выше); обход идёт от узла к детям — к строкам, чей конец ведёт на
1101
+ узел. `deep()` на первом шаге — `invalid_query` (`query.deep_first_step`), на переходе к другому классу —
1102
+ `invalid_query` (`query.deep_hop`); переход без конца на тот же класс — `no_path`. Фильтр, теги и владелец
914
1103
  шага отбирают узлы любого уровня, но обход не обрывают. Обрывает его только узел, невидимый по правам: он скрывает и
915
1104
  своё поддерево.
916
1105
 
@@ -936,7 +1125,8 @@ const orderPaths = await db.Order(order).line().Product().paths() // [{ Order:
936
1125
  ```
937
1126
 
938
1127
  `paths()` отдаёт узлы каждого варианта пути; ключ узла — имя шага или имя из `.alias(имя)`. Ключи идут в порядке
939
- шагов; если цепочка кончается возвратом к прежнему узлу, этот узел — последний. Повтор связи в
1128
+ шагов; если цепочка кончается возвратом к прежнему узлу, этот узел — последний. Совпавшие имена получают первый
1129
+ свободный ключ: `x`, `x_2`, `x_3` и так далее, ни один узел не теряется. Повтор связи в
940
1130
  цепочке возвращает к её узлу: так из одной связи можно пройти в несколько сторон. `db.entity(строка | цепочка)`
941
1131
  вставляет готовый узел в путь.
942
1132
 
@@ -947,25 +1137,47 @@ const orderPaths = await db.Order(order).line().Product().paths() // [{ Order:
947
1137
  <!-- run: guide -->
948
1138
  ```ts
949
1139
  const preview = await db.Order(order).delete().rows() // ничего не удаляет: заказ и его строки, $action каждой
950
- await db.Order(order).delete({ confirm: true }).rows() // удалено: надгробия с $deleted: true
1140
+ const goneOrder = await db.Order(order).delete({ confirm: true, expect: preview }).rows() // надгробия, $deleted: true
951
1141
 
952
- const blockers = (await db.Shop(shop).delete().rows()).filter((x) => x.$action === 'restrict')
1142
+ const shopPreview = await db.Shop(shop).delete().rows()
1143
+ const blockers = shopPreview.filter((x) => x.$action === 'restrict')
953
1144
  try {
954
- await db.Shop(shop).delete({ confirm: true }).rows() // магазину мешают товары: конец Shop — restrict
1145
+ await db.Shop(shop).delete({ confirm: true, expect: shopPreview }).rows() // магазину мешают товары: restrict
955
1146
  } catch (e) {
956
1147
  if (!(e instanceof LetopisError) || e.code !== 'delete_restricted') throw e
957
1148
  }
958
1149
  ```
959
1150
 
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')`.
1151
+ Удаляют в два шага: `delete()` — превью, его показывают человеку; `delete({ confirm: true, expect: превью })` —
1152
+ удаление ровно показанного. `delete()` без `{ confirm: true }` ничего не удаляет и отдаёт всё замыкание каскада. В
1153
+ каждой строке превью `$action`: `delete`, `unset` или `restrict`, и `$depth` — уровень каскада (0 — сама цель; у
1154
+ строк `unset` и `restrict` его нет). С подтверждением удаляются все цели шага одним оператором, а каскад применяет
1155
+ правила концов. Итог — надгробия по уровням каскада, внутри уровня по id (в таком порядке они и в журнале); цели
1156
+ классов без журнала (`history: false`) — в конце. Права на удаление превью не проверяет: строка с
1157
+ `$action: 'delete'` может не удалиться у пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить
1158
+ `acl.checkData(класс, 'DELETE')`.
1159
+
1160
+ **Строки, скрытые правами.** Строки замыкания, которых пользователь не видит (например, оплаты заказа у мастера без
1161
+ права `READ` на них), в превью не раскрываются: в конце превью идёт по одной строке-сводке на действие —
1162
+ `{ id: 'hidden:restrict', $action: 'hidden', $hidden: 'restrict', $count: 2, … }` (порядок `delete`, `unset`,
1163
+ `restrict`, только при числе больше нуля). `$hidden` — что будет со скрытыми строками, `$count` — сколько их; класс,
1164
+ id и данные скрытых строк не показываются. Отличить сводку — `row.$action === 'hidden'`. Сводки входят в `expect` как
1165
+ есть и сверяются по `$hidden` и `$count`: изменилось число скрытых строк — `conflict` (`write.preview_changed`).
1166
+ Сводки `delete` и `unset` удалению не мешают, а превью со строками `restrict` — видимыми или скрытыми — подтверждение
1167
+ отклоняет заранее, до удаления: `delete_restricted` («удалению мешают ссылающиеся строки: видимых N, скрытых правами
1168
+ M»), `err.detail` — `{ rows: [{ id, class }], others: M }`. Пользователю — «ещё M скрытых записей мешают удалению —
1169
+ обратитесь к владельцу».
1170
+
1171
+ - `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`) до записи, ничего не
1172
+ удалено: удаление не сверено с тем, что видел пользователь. Подсказка ошибки — та же форма в два шага.
1173
+ - Цели, как у `update`, сужают фильтр, id, `tags()`, `owner()` или `limit()` у шага глагола или шагов до него (или
1174
+ `create()` и `upsert()` раньше в цепочке), иначе `invalid_query` (`write.no_filter`) до записи; превью — без условий.
1175
+ - Осознанно удалить текущий состав целей без сверки с превью — `delete({ confirm: true, all: true })`: с фильтром —
1176
+ текущие цели фильтра, без фильтра — все строки класса. `all` вместе с `expect` снимает только требование фильтра:
1177
+ превью сверяется. Без сверки — только для скриптов обслуживания, не в ответ на действие пользователя.
1178
+ - Чужой, невидимый или несуществующий id — превью пустое, и удаление с `expect: []` отвечает пустым ответом без
1179
+ ошибки, как `update`: проверяйте длину ответа.
1180
+
969
1181
  Правила концов:
970
1182
 
971
1183
  - `restrict` — удаление запрещено, пока на цель ссылаются (`delete_restricted` со списком видимых строк и числом
@@ -976,21 +1188,173 @@ try {
976
1188
  Каскад требует права на удаление каждой удаляемой строки, снятие ссылки — права на запись. Сырой
977
1189
  `DELETE … WHERE id IN (…)` любого набора связанных строк работает так же.
978
1190
 
1191
+ **Сверка с превью.** Между превью и подтверждением данные могут измениться: к заказу добавили строку, строку
1192
+ поправили. С `expect: превью` удаление в своей транзакции строит превью заново и сравнивает его с показанным — по `id`,
1193
+ `rev` и `$action` (строки без `$action` сравниваются по `id` и `rev`). Совпало — удаляет. Не совпало — `conflict` с
1194
+ ключом `write.preview_changed`, ничего не удалено, новое превью — в `err.detail.preview`: его показывают пользователю и
1195
+ удаляют уже с ним. `expect` без `confirm: true` — `invalid_query` (`write.expect_without_confirm`), `expect` не
1196
+ списком строк `{ id, rev }` — `invalid_query` (`write.bad_expect`).
1197
+
1198
+ `expect` — строки превью как есть (тип `Expected`: `{ id, rev, $action? }`), порядок не важен. `err.detail.preview` —
1199
+ `Row[]` той же формы, что превью: строки с `$action` и, у строк `delete`, `$depth`. Сверка атомарна и под гонкой: всё
1200
+ замыкание каскада (строки превью с `$action: 'delete'`) блокируется в порядке id, и превью строится ещё раз уже под
1201
+ блокировкой. Связь к удаляемой строке, добавленная до этого, попадает в новое превью (`preview_changed`); добавленная
1202
+ позже ждёт удаления и получает `target_not_found` (`sql-write.end_target_missing`) — непоказанное каскадом не уходит.
1203
+ Взаимную блокировку двух таких удалений операция из пула повторяет сама (`40P01`), в `db.begin()` — повторяет
1204
+ вызывающий. Пустые цели: `expect: []` и целей нет — пустой ответ; `expect` непуст, а целей уже нет (удалены, не
1205
+ видны) — `conflict` с пустым `err.detail.preview`.
1206
+
1207
+ ```ts
1208
+ const shown = await db.Order(id).delete().rows() // превью — пользователю
1209
+ // … пользователь подтвердил
1210
+ try {
1211
+ const gone = await db.Order(id).delete({ confirm: true, expect: shown }).rows() // удалено ровно показанное
1212
+ // gone — надгробия: сохраните, по ним возвращают ровно ушедшее
1213
+ } catch (e) {
1214
+ if (!(e instanceof LetopisError) || e.code !== 'conflict') throw e
1215
+ const fresh = (e.detail as { preview: Row[] }).preview // данные изменились: показать заново
1216
+ }
1217
+ ```
1218
+
979
1219
  ### Восстановить: `restore`
980
1220
 
981
1221
  <!-- run: guide -->
982
1222
  ```ts
983
- await db.Order(order).restore().rows() // заказ — последним снимком, id прежний
984
- await db.Order(order).line().withDeleted().restore().rows() // и его строки, удалённые каскадом
1223
+ const back = await db.begin({ reason: 'возврат A-1' }) // заказ и его строки — одной транзакцией
1224
+ try {
1225
+ await back.Order(order).restore({ expect: goneOrder }).rows() // заказ — тем снимком, что ушёл
1226
+ await back.Order(order).line().withDeleted().restore({ expect: goneOrder }).rows() // и строки этого каскада
1227
+ await back.commit()
1228
+ } catch (e) {
1229
+ await back.rollback() // ошибка второго шага не оставит заказ без строк
1230
+ throw e
1231
+ }
1232
+ ```
1233
+
1234
+ `restore({ expect })` возвращает удалённый объект его последним снимком новой версией и перепроверяет его по текущему
1235
+ описанию класса; id прежний. `expect` — надгробия `{ id, rev }`: ответ `delete({ confirm: true, expect })` или строки
1236
+ `withDeleted()` с `$deleted: true` (`db.Order(id).withDeleted().rows()`). Восстанавливаются только цели шага, чьи id
1237
+ есть в `expect`, а не весь каскад. Живой объект — `not_deleted`.
1238
+
1239
+ - `restore()` без `expect` — `invalid_query` (`write.restore_without_expect`) до записи, и по явному id тоже: без
1240
+ `expect` поднялось бы **последнее** надгробие id, а id удалённого может достаться другому — `create()` по ключу
1241
+ удалённого занимает его id ([«Создать: `create`»](#создать-create)), а новый объект потом тоже удаляют.
1242
+ - Осознанно вернуть все удалённые цели шага без сверки версий — `restore({ all: true })`: с фильтром — удалённые цели
1243
+ фильтра, без фильтра — всё удалённое класса; `all` вместе с `expect` — сверка по `expect`.
1244
+ - Цели — удалённые объекты последнего шага: шаг глагола `restore()` сам читает с удалёнными, `withDeleted()` перед
1245
+ ним не обязателен. Чужой, невидимый или несуществующий id, id не из `expect` — пустой ответ без ошибки, как у
1246
+ `update`: проверяйте длину ответа.
1247
+ - Шаги до глагола идут по живым строкам, если у них нет своего `withDeleted()`. Поэтому каскад возвращают после цели —
1248
+ `db.Order(order).line().restore({ expect })` находит строки восстановленного заказа, — а строки ещё удалённого
1249
+ заказа находит `db.Order(order).withDeleted().line().restore({ expect })`.
1250
+
1251
+ **Сверка с надгробиями.** Восстанавливаются только цели, у которых последняя версия — это самое надгробие из
1252
+ `expect`. Иначе ничего не восстановлено, а отказ называет причину по записи журнала, которая идёт за надгробием цели:
1253
+
1254
+ | Что с id после удаления | `why` | Ключ | Код |
1255
+ |---|---|---|---|
1256
+ | объект уже восстановлен (другим запросом) | `restored` | `write.restore_already` | `not_deleted` |
1257
+ | id занял новый живой объект с тем же ключом (`create()` по ключу удалённого) | `reissued` | `write.restore_id_taken` | `not_deleted` |
1258
+ | прочее: id пересоздан и удалён снова, объект удалён снова с другим номером версии, гонка | `changed` | `write.restore_stale` | `conflict` |
1259
+
1260
+ У всех трёх `err.detail.ids` — id этого вида, а `err.detail.stale` — все цели, которые не восстановить:
1261
+ `[{ id, rev, expected, after, deleted, live, why }]`. `rev` — номер последней версии сейчас, `expected` — из
1262
+ `expect`, `after` — вид записи журнала сразу за `expected` (`create`, `update`, `delete`, `restore`; `null` — её нет),
1263
+ `deleted` — последняя версия — надгробие, `live` — объект живёт, `why` — из таблицы. Цели разного вида в одном вызове
1264
+ дают `restore_stale`, если среди них есть `changed`, иначе `restore_id_taken`, если есть `reissued`. Номер версии база
1265
+ сверяет ещё раз под блокировкой записи журнала и перечитывает последнюю запись после ожидания, поэтому гонку
1266
+ «прочитали надгробие — другой процесс пересоздал id — восстановили» ловит она, а проигравший гонку двух `restore()`
1267
+ получает `write.restore_already`. id из `expect`, которых нет среди целей шага, пропускаются: весь ответ `delete()` можно
1268
+ передавать каждому шагу. Поэтому шаг глагола задавайте id из `expect`: объект, который по пути уже не найти (связь
1269
+ пересоздана с другим концом), по пути молча пропустится, а по id даст `conflict`. Цель и её каскад возвращают одной
1270
+ `db.begin()`, цель конца — первой: отказ на любом шаге откатывает всё.
1271
+
1272
+ ```ts
1273
+ const gone = await db.Order(order).delete({ confirm: true, expect: shown, reason: 'отмена A-1 #7f3a' }).rows()
1274
+ const ids = (cls: string) => gone.filter((x) => x.class === cls).map((x) => x.id)
1275
+ // … позже: вернуть ровно это — заказ и строки этого каскада, одной транзакцией
1276
+ const tr = await db.begin()
1277
+ try {
1278
+ await tr.Order(ids('Order')).restore({ expect: gone }).rows() // заказ — если это всё ещё его надгробие
1279
+ await tr.line(ids('line')).restore({ expect: gone }).rows() // строки этого каскада, удалённые раньше — нет
1280
+ await tr.commit()
1281
+ } catch (e) {
1282
+ await tr.rollback()
1283
+ if (!(e instanceof LetopisError) || (e.code !== 'conflict' && e.code !== 'not_deleted')) throw e
1284
+ // e.key — write.restore_stale, write.restore_id_taken или write.restore_already; причины по целям —
1285
+ // e.detail.stale[].why — пользователю: «id занят новой записью», «после удаления меняли», «уже вернули»
1286
+ }
1287
+ ```
1288
+
1289
+ **Вернуть, что можно: `restore({ expect, partial: true })`.** Без `partial` отказ одной цели отменяет всё. С
1290
+ `partial` восстанавливаются цели, у которых совпал номер версии, а остальные не роняют операцию и приходят в том же
1291
+ ответе строками-пропусками: последняя строка цели (живая или надгробие с `$deleted`) с `$action: 'skip'` и `$why` —
1292
+ `'restored'`, `'reissued'` или `'changed'`, как `why` в таблице выше. Ответ — в порядке целей (`sort()` до глагола,
1293
+ иначе по id). Каждая цель восстанавливается под своей точкой сохранения, поэтому проигранная гонка с другим
1294
+ `restore()` тоже становится пропуском. Ошибки другого рода — `target_not_found`, `invalid_data`, права — роняют
1295
+ операцию, как без `partial`. `partial` без `expect` — `invalid_query` (`write.partial_without_expect`), и с
1296
+ `all: true` тоже. Продолжение цепочки после такого `restore()` идёт только от восстановленных; `count()` и `ids()`
1297
+ сразу после него считают и пропуски — это строки ответа глагола.
1298
+
1299
+ ```ts
1300
+ const tr = await db.begin()
1301
+ try {
1302
+ await tr.Order(ids('Order')).restore({ expect: gone }).rows() // заказ — целиком или отказ
1303
+ const lines = await tr.line(ids('line')).restore({ expect: gone, partial: true }).rows()
1304
+ await tr.commit()
1305
+ const skipped = lines.filter((x) => x.$action === 'skip').map((x) => ({ id: x.id, why: x.$why }))
1306
+ // skipped — пользователю: что не вернулось и почему
1307
+ } catch (e) {
1308
+ await tr.rollback()
1309
+ throw e
1310
+ }
985
1311
  ```
986
1312
 
987
- `restore()` возвращает последний снимок удалённого объекта новой версией и перепроверяет его по текущему описанию
988
- класса. Восстанавливаются только цели шага, а не весь каскад. Живой объект — `not_deleted`.
1313
+ Ответ `delete()` не сохранили — надгробия по id: `db.line(id).withDeleted().rows()` (строки с `$deleted: true`) — и
1314
+ их в `expect`. Ни ответа, ни id — надгробия по пути и причине удаления: каскад получает `reason` глагола. Причину
1315
+ делайте **своей у каждого удаления** (с номером операции, как `#7f3a` выше): у пересозданного и снова удалённого
1316
+ объекта последнее надгробие несёт причину того, второго удаления, и под ваш отбор не попадёт. Путь теряет и другое:
1317
+ объект, чей id занял новый живой объект, по пути от прежнего конца уже не находится (последняя версия ведёт к другому),
1318
+ и пропуска `reissued` по нему не будет — человек не узнает, что он не вернулся. Срез `asOf()` на момент до удаления
1319
+ показывает, что было живо, но его строки — живые версии: их `rev` для `expect` не годится.
1320
+
1321
+ ```ts
1322
+ const ours = (await db.Order(order).withDeleted().line().withDeleted().rows())
1323
+ .filter((x) => x.$deleted && x.reason === 'отмена A-1 #7f3a') // строки удалённого заказа с причиной этого удаления
1324
+ ```
1325
+
1326
+ Штатные ответы `restore()` — их показывают пользователю, а не превращают в 500:
1327
+
1328
+ | Ответ | Когда | Пользователю |
1329
+ |---|---|---|
1330
+ | пустой | id чужой, невидимый, не существует или не среди целей шага | «не найдено» |
1331
+ | `invalid_query`, ключ `write.restore_without_expect` | нет `expect` (и нет `all: true`) | — (ошибка в коде) |
1332
+ | `not_deleted`, ключ `write.not_deleted` | объект жив и не удалялся | «не удалён» |
1333
+ | `not_deleted`, ключ `write.restore_already` | объект уже восстановлен (в том числе проигранная гонка двух `restore()`); id — `err.detail.ids` | «уже восстановлено» |
1334
+ | `not_deleted`, ключ `write.restore_id_taken` | id занял новый живой объект с тем же ключом (`create()` по ключу удалённого); id — `err.detail.ids` | «место занято новой записью» |
1335
+ | `conflict`, ключ `write.restore_stale` | после этого удаления id меняли — пересоздан и удалён снова, удалён снова; причины — `err.detail.stale` | «после удаления запись меняли» |
1336
+ | строки `$action: 'skip'` | с `partial: true` вместо трёх отказов выше; причина — `$why` | что не вернулось и почему |
1337
+ | `target_not_found` | цель конца снимка удалена или не видна | сначала восстановить цель |
1338
+ | `invalid_data` | снимок не проходит нынешнее описание класса | — |
1339
+
1340
+ - `restore()` перепроверяет описание класса, концы (цель конца жива и видна, иначе `target_not_found`), ключ и права,
1341
+ но не бизнес-правила приложения: восстановленная связь вернёт занятость прежнему концу, даже если объект уже занят
1342
+ другим. Гарантию даёт только ключ связи: занятость держит ключ, а не поле и не проверка. Проверка в коде перед
1343
+ `restore()` держит правило, лишь если **все** пути создания и возврата таких связей берут в `db.begin()` одну и ту же
1344
+ блокировку (`forUpdate()` объекта или `tr.lock(…)`) и делают ту же проверку: один путь без неё — обычный `create()`
1345
+ связи — гарантию ломает, потому что `create()` и `restore()` берут на объекте совместимую блокировку и друг друга не
1346
+ ждут. Связь с ключом только из одного конца (`key: ['Obj']`) база второй для того же
1347
+ объекта не создаст (`exists`). Удалённую каскадом такую связь новая на тот же объект создаёт заново с тем же id
1348
+ (v5 от ключа), и восстановить прежнюю уже нельзя: пока новая связь жива, `restore({ expect })` —
1349
+ `not_deleted` (`write.restore_id_taken`); когда удалена и она — `conflict` (`write.restore_stale`); с
1350
+ `partial: true` вместо отказа — строка-пропуск. Общие правила — «одна открытая запись на объект» и возврат по ответу
1351
+ `delete()` — в [памятке](AGENT-CHEATSHEET.md), «Правила для любой задачи».
989
1352
 
990
1353
  Историю удалённого объекта журнал хранит: стереть версии одного объекта нельзя. Удаление аккаунта
991
- (`db.auth.purgeAccount`, [раздел 10](#10-пользователи-вход-и-права)) стирает данные и журнал его арендатора.
1354
+ (`db.auth.purgeAccount`) стирает данные и журнал только его собственного арендатора; что делать с персональными
1355
+ данными в чужих арендаторах — [«Удалить аккаунт»](#удалить-аккаунт).
992
1356
 
993
- ## 8. Транзакции, батчи и деньги
1357
+ ## 8. Транзакции и батчи
994
1358
 
995
1359
  ### Каждая операция — транзакция
996
1360
 
@@ -1021,6 +1385,9 @@ try {
1021
1385
  библиотека о ней не знает, PostgreSQL молча отвечает `ROLLBACK` на `COMMIT`, а библиотека превращает это в ошибку
1022
1386
  `tx_rolled_back`.
1023
1387
  - Повторов внутри `db.begin()` нет: на `40P01` повторите весь блок.
1388
+ - `db.begin()` принимает только опции `{ reason }`. Функцию она не принимает и не вызывает: `db.begin(async (tr) => …)`,
1389
+ строка, число или массив — отклонённый промис `invalid_query` (`api.begin_arg`) с подсказкой — формой выше;
1390
+ неизвестная опция — `invalid_data` с похожими именами.
1024
1391
  - `lock(...ключи)` — advisory-блокировка до конца транзакции. Она защищает, только если её берут все, кто правит
1025
1392
  эти данные.
1026
1393
 
@@ -1041,74 +1408,23 @@ const results = await b.run() // Row[][] — по массиву стро
1041
1408
  который поставил его в очередь (актор, арендатор, владелец, транзакция), и чужих планов `run()` не исполняет.
1042
1409
  Копите планы через один и тот же объект фасада.
1043
1410
 
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'
1411
+ ### Значение из прочитанного и повтор операции
1056
1412
 
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
- }
1413
+ Новое значение, вычисленное из прочитанного, теряет параллельные правки, если его записать просто `update()`.
1414
+ Способов защиты три, и для одного объекта выбирают один:
1068
1415
 
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
- }
1076
-
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
- }
1093
-
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`:
1416
+ - **Приращение** — `update({ поле: inc(n) })`: база прибавляет внутри оператора, чтение не нужно, а `min(0)` в модели
1417
+ отклоняет уход в минус (`invalid_data`, по `e.issues` — поле и правило `minimum`).
1418
+ - **Сверка версии** — `update(патч, { rev })`: строку успели изменить — `conflict`, перечитать и решить заново
1419
+ ([«Защита от параллельной правки»](#защита-от-параллельной-правки--rev-)).
1420
+ - **Чтение с блокировкой** — `forUpdate()` внутри `db.begin()`: строка заблокирована до `commit()`, повторов нет.
1102
1421
 
1103
1422
  <!-- run: guide -->
1104
1423
  ```ts
1105
- const [wallet2] = await db.Customer(olga).Wallet().create({ number: 'W-2' }).rows()
1106
1424
  const tx = await db.begin()
1107
1425
  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()
1426
+ const locked = (await tx.Product(kettle).forUpdate().first())! // строка заблокирована до commit
1427
+ await tx.Product(kettle).update({ views: (locked.data.views as number) + 1 }).rows()
1112
1428
  await tx.commit()
1113
1429
  } catch (e) {
1114
1430
  await tx.rollback()
@@ -1116,19 +1432,20 @@ try {
1116
1432
  }
1117
1433
  ```
1118
1434
 
1119
- - `inc` прибавляет внутри оператора над заблокированной строкой: параллельные списания не теряются, а `min(0)`
1120
- отклоняет списание сверх баланса.
1121
- - Обычный `update({ balance: 700 })` записывает значение, а не прибавляет. Итог, вычисленный из прочитанного,
1122
- без `{ rev }` или `forUpdate()` теряет параллельные списания. Advisory `lock()` не спасает, если его берут не все
1123
- писатели. `upsert` затирает приращения.
1124
1435
  - `forUpdate()` — только внутри `db.begin()` (иначе `tx_required`) и только с `rows`, `first`, `ids` (иначе
1125
1436
  `lock_unsupported`). Строки блокируются в порядке id; строка, которая перестала подходить под условия шага, в ответ
1126
- не попадает.
1127
- - Для одного кошелька выберите один путь: под `{ rev }` и под `forUpdate()` кошелёк и ключ операции блокируются в
1128
- разном порядке, и смешение в параллельных запросах даёт `40P01`. Внутри блока с `forUpdate()` не ждите внешних
1129
- вызовов: кошелёк ждут все писатели (помогут `lock_timeout` и `idle_in_transaction_session_timeout`).
1130
- - Перевод — две операции в одной `db.begin()`, кошельки правятся в порядке id: тогда взаимных блокировок нет.
1131
- - Нехватку средств отличают от других ошибок данных по `e.issues`: поле `balance`, правило `minimum`.
1437
+ не попадает. Блокировка — это право записи: строки, которые пользователь видит, но править не может, `forUpdate()`
1438
+ не блокирует — `acl_denied` (`api.lock_denied`).
1439
+ - Под `{ rev }` и под `forUpdate()` объект и ключи блокируются в разном порядке: смешение двух путей в параллельных
1440
+ запросах одного объекта даёт `40P01`. Внутри блока с `forUpdate()` не ждите внешних вызовов: строку ждут все
1441
+ писатели (помогут `lock_timeout` и `idle_in_transaction_session_timeout`).
1442
+ - Несколько объектов в одной `db.begin()` правьте в порядке id: тогда взаимных блокировок нет.
1443
+ - Обычный `update({ поле: значение })` записывает значение, а не прибавляет; `upsert` затирает приращения; advisory
1444
+ `lock()` не спасает, если его берут не все писатели.
1445
+ - **Повтор операции.** Операцию, которую нельзя выполнить дважды, записывайте связью с ключом из объекта и номера
1446
+ операции (`key: ['Объект', 'opId']`) вместе с приращением одним планом: повтор после потери ответа (`commit_unknown`,
1447
+ обрыв связи) получает `exists` и ничего не меняет. Отменяют такую операцию встречной операцией с новым номером, а не
1448
+ правкой и не удалением.
1132
1449
 
1133
1450
  ## 9. Массовая загрузка
1134
1451
 
@@ -1247,12 +1564,43 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
1247
1564
 
1248
1565
  Аккаунт — строка класса `Account` в System: имя, категории (`User`, `Service` и свои), признак `enabled`. Каждый
1249
1566
  аккаунт — сам себе арендатор. Работать в чужом арендаторе позволяет членство `member` — связь пользователя
1250
- (`User`) с арендатором (`Tenant`) с ролями в данных. Аккаунты и их категории правит системный администратор —
1251
- член System с ролью `admin`. Отдельного метода, чтобы назначить его, нет: членство в System с ролью `admin` кладёт
1252
- свой сид установки или загрузчик на администраторском подключении (строка `member` с `tenant: <id System>`).
1253
- Новых пользователей арендатора удобно заводить `createTenant`
1567
+ (`User`) с арендатором (`Tenant`) с ролями в данных. Новых пользователей арендатора удобно заводить `createTenant`
1254
1568
  ([раздел 3](#3-установка-и-подключение)).
1255
1569
 
1570
+ **Системный администратор** — член System с ролью `admin`: правит аккаунты и их категории, удаляет аккаунты
1571
+ (`purgeAccount`), выдаёт креды System и сервисов. Его права действуют, только пока сессия работает в арендаторе
1572
+ System. Отдельного метода, чтобы назначить его, нет; путь — четыре шага: отдельный аккаунт, членство в System
1573
+ загрузчиком на администраторском подключении, первый вход по токену `createTenant`, постоянный вход — своим ключом API.
1574
+
1575
+ ```ts
1576
+ const SYSTEM = '2eba6d0a-1edd-4bb6-a85a-a703dab49035' // id арендатора System — один во всех установках
1577
+ // 1. отдельный аккаунт: свой арендатор без правил («Администрирование»), логин и пароль
1578
+ const rootPool = postgres(ADMIN)
1579
+ const root = await createTenant(rootPool, r.schema, { name: 'Администрирование', grantAll: false,
1580
+ user: { name: 'Root', login: 'root@shop.ru', password: 'Root-2026!' } })
1581
+ await rootPool.end()
1582
+ // 2. членство в System с ролью admin — загрузчиком на администраторском подключении (adm выше)
1583
+ await adm.load({ member: [{ User: root.user, Tenant: SYSTEM, roles: ['admin'] }] }, { tenant: SYSTEM })
1584
+ // 3. первый вход — токен из createTenant; права администратора — после перехода сессии в System
1585
+ const sa = await connect({ dsn: APP, schema: r.schema, token: root.token! })
1586
+ await sa.auth.switch(SYSTEM)
1587
+ // 4. постоянный вход — свой ключ API (отдаётся один раз: храните, как ключ сервиса)
1588
+ const { key: ROOT_KEY } = await sa.auth.issueApiKey({ account: root.user!, name: 'root' })
1589
+ await sa.close()
1590
+ // дальше: const sa = await connect({ dsn: APP, schema, apiKey: ROOT_KEY }); await sa.auth.switch(SYSTEM)
1591
+ ```
1592
+
1593
+ - Системный администратор — не обычный пользователь: сервис его не пускает. `verifyPassword`, `lookup` и
1594
+ `sessionFor` для него — `acl_denied` (`sql-access.no_sessions_for_account`, «сессии для этого аккаунта не
1595
+ выпускаются»), `svc.as()` от его имени — `acl_denied` (`sql-core.impersonation_denied`): иначе сервис получил бы его
1596
+ права. Поэтому назначайте администратором отдельный аккаунт: пользователь, которого сделали администратором,
1597
+ перестаёт входить через сервис.
1598
+ - Вход по ключу API (`connect({ apiKey })`) работает и у него; сессия по ключу начинается в личном
1599
+ арендаторе, поэтому после входа — `auth.switch(SYSTEM)`. Ключ потерян — новый токен даёт повтор `createTenant` с тем
1600
+ же `name` и логином (пароль он не меняет), затем шаги 3–4.
1601
+ - Снять роль — удалить строку `member` в System по превью от имени другого администратора или правкой её `roles`.
1602
+ - Сервер MCP с сессией в System — только с опцией `allowSystemAdmin: true` ([«Агенты и MCP»](#агенты-и-mcp)).
1603
+
1256
1604
  ### Сервис и ключи API
1257
1605
 
1258
1606
  Сервис — аккаунт категории `Service` с ключом API. Ключ установки выдаёт `up()`. Сервис проверяет пароли и коды,
@@ -1297,7 +1645,7 @@ const failed = await svc.auth.verifyPassword({ identifier: 'boris@lutik.ru', pas
1297
1645
 
1298
1646
  - Пароль хранится хэшем scrypt; проверяет его сервис, время ответа для несуществующего логина выровнено.
1299
1647
  - Идентификатор сравнивается без учёта регистра: `Anna@Shop.ru` и `anna@shop.ru` — один вход.
1300
- - Смена пароля гасит остальные сессии аккаунта (`keepCurrent` — оставить текущую).
1648
+ - Смена пароля гасит остальные сессии аккаунта; текущая остаётся, `keepCurrent: false` гасит и её.
1301
1649
  - По умолчанию вход — только по подтверждённому креду, иначе `null`; `requireConfirmed: false` пускает и по
1302
1650
  неподтверждённому. Подтверждает кред сервис: `confirmed: true` в `setPassword` или `setFlags`.
1303
1651
  - Второй фактор — опция `totp`: `verifyPassword({ identifier, password, totp })` после верного пароля проверяет код
@@ -1314,6 +1662,7 @@ const { code } = await svc.auth.issueOtp({ account: BORIS, identifier: 'boris@lu
1314
1662
  const byCode = await svc.auth.verifyOtp({ identifier: 'boris@lutik.ru', code }) // { account, token } | null
1315
1663
  const { secret, uri } = await svc.auth.enrollTotp({ account: BORIS, issuer: 'Лютик' }) // uri — для QR-кода
1316
1664
  const totpOk = await svc.auth.verifyTotp({ account: BORIS, code: totpCode(secret) }) // первая проверка включает фактор
1665
+ // тот же код второй раз (в том же 30-секундном шаге) отклоняется: вход с totp сразу после verifyTotp — null до следующего шага
1317
1666
  ```
1318
1667
 
1319
1668
  Код одноразовый: успешная проверка и перебор (по умолчанию 5 попыток) его сжигают. Счётчики попыток откатываются
@@ -1359,12 +1708,26 @@ await boris.close()
1359
1708
  Токен — 256 случайных бит; база хранит только его sha256. Срок по умолчанию — 7 суток. `refresh()` продлевает
1360
1709
  текущую сессию: новый срок отсчитывается от текущего момента, но не больше срока, выданного при входе; возвращает
1361
1710
  он новое время истечения. `connect()` продлевает сессию сам раз в минуту работы (`refreshSession`), поэтому сессия,
1362
- которой пользуются, не истекает: срок — это время простоя. `revoke()` — выход, `revokeAll()` — выход везде.
1711
+ которой пользуются, не истекает: срок — это время простоя. `revoke()` — выход: гасит сессию этого подключения;
1712
+ `revoke(token)` — выход пользователя по токену его сессии (ниже); `revokeAll(account?)` — выход везде, то есть все
1713
+ сессии **одного** аккаунта. Общего вызова «погасить все сессии установки» нет: при утечке или после восстановления
1714
+ копии на чужом стенде — остановить процессы приложения и от суперпользователя или роли-владельца выполнить
1715
+ `update "v2.имя".secret set deleted_at = clock_timestamp() where kind = 'SESSION' and deleted_at is null` (ключи API —
1716
+ `kind in ('APIKEY', 'KEYSECRET')` или `revokeCredential(id)`); сигнала этот запрос не шлёт, поэтому процессы
1717
+ перезапускают.
1363
1718
  Сессии гаснут при отключении аккаунта, смене пароля, удалении креда и удалении аккаунта. Удаление креда гасит
1364
1719
  остальные сессии аккаунта, а отзыв ключа API или пары «ключ + секрет» — только сессии, открытые этим ключом (и
1365
1720
  старые сессии того же вида входа без отметки ключа); текущая сессия остаётся, если не передать
1366
1721
  `keepCurrent: false`.
1367
1722
 
1723
+ `apiKey`, `service` или `token`, переданные со значением `null`, пустой строкой или не строкой, `connect()` отклоняет
1724
+ сразу — `invalid_data` (`api.connect_empty_credential`); `undefined` значит «опции нет» — подключение без сессии, и
1725
+ отказ `no_session` придёт на первом вызове. Поэтому ключ из переменной окружения проверяйте до `connect()`.
1726
+
1727
+ `connect({ token })` с токеном, который уже не даёт сессии (погашен `revoke`, истёк, неверен), подключения не создаёт:
1728
+ его промис отклоняется с `no_session` (`api.token_not_active`, «нет действующей сессии: токен погашен, истёк или
1729
+ неверен»), пул закрывается. Пользователь входит заново.
1730
+
1368
1731
  Подключение с постоянным токеном (`connect({ token })`), которым не пользовались дольше срока сессии, остаётся без
1369
1732
  сессии: истёкшую сессию продление не возвращает, а новый токен подключение само не берёт. После простоя дольше минуты
1370
1733
  подключение до операции продлевает сессию и узнаёт, жива ли она: истёкшая — `no_session` на любую операцию, и
@@ -1384,6 +1747,18 @@ await boris.close()
1384
1747
  чтение по погашенной сессии ошибки не даёт, ответы просто пустые. Вызывает метод сервис или системный администратор
1385
1748
  (право `auth.session`); пользователю отвечают `acl_denied`.
1386
1749
 
1750
+ Выход в веб-сервисе — `svc.auth.revoke(token)` с токеном из cookie: сервис гасит сессию пользователя, не подключаясь
1751
+ по ней. Ответ — `true`, если сессия погашена, и `false`, если токен неверный, сессия уже погашена или истекла (причина
1752
+ не различается). Право то же — `auth.session`, без него `acl_denied`. Погашенная сессия шлёт сигнал `session:…`, и
1753
+ процессы сбрасывают кэш её ответов. `svc.auth.revoke()` без аргумента погасил бы сессию самого сервиса:
1754
+
1755
+ ```ts
1756
+ // POST /logout — токен пользователя в cookie
1757
+ const token = req.cookies.session
1758
+ if (token) await svc.auth.revoke(token) // true — вышел; false — сессии уже не было
1759
+ res.clearCookie('session')
1760
+ ```
1761
+
1387
1762
  ### Работа в другом арендаторе
1388
1763
 
1389
1764
  Сервис действует от имени пользователя через `svc.as(account, { tenant, owner })`. База проверяет право сервиса
@@ -1396,13 +1771,68 @@ await boris.close()
1396
1771
 
1397
1772
  <!-- run: guide -->
1398
1773
  ```ts
1399
- const mine = await anna.auth.tenants() // [{ tenant, roles }]: собственный арендатор и магазин по членству
1774
+ const mine = await anna.auth.tenants() // [{ tenant, roles, name, personal }]: собственный арендатор и магазин
1400
1775
  await anna.auth.switch(SHOP) // арендатор сессии — магазин
1401
1776
  ```
1402
1777
 
1403
- `auth.tenants()` отдаёт и собственный арендатор пользователя: первым элементом, с `tenant`, равным id его аккаунта, и
1404
- `roles: ['owner']`. За ним идут арендаторы по членству с ролями членства. Нужны только арендаторы по членству —
1405
- отбросьте элемент с id аккаунта (`login.account` у `verifyPassword`, `user` у `createTenant`).
1778
+ `auth.tenants()` отдаёт арендаторов аккаунта сессии: `{ tenant, roles, name, personal }`. Первым идёт собственный
1779
+ арендатор пользователя — `tenant` равен id его аккаунта, `roles: ['owner']`, `personal: true`, `name` — имя
1780
+ пользователя. За ним — арендаторы по членству (`personal: false`) с ролями членства, по id. `name` — имя аккаунта
1781
+ арендатора: то, что передали в `createTenant({ name })` или `up({ tenant: { name } })`; у аккаунта без имени — `null`.
1782
+ Нужны только арендаторы по членству — `filter((t) => !t.personal)`.
1783
+
1784
+ Сервис узнаёт арендаторов пользователя, не открывая подключения по его токену: от имени пользователя без `tenant`
1785
+ (`svc.as(account)` работает в его личном арендаторе) — тот же `auth.tenants()`. Так после входа выбирают, куда
1786
+ пустить пользователя:
1787
+
1788
+ ```ts
1789
+ const ok = await svc.auth.verifyPassword({ identifier, password })
1790
+ if (!ok) return 'неверный логин или пароль'
1791
+ const shops = (await (await svc.as(ok.account)).auth.tenants()).filter((t) => !t.personal)
1792
+ if (!shops.length) return 'вы не состоите ни в одном магазине'
1793
+ // несколько — список shops.map((t) => t.name) пользователю на выбор; выбранный — в его сессии:
1794
+ const work = await svc.as(ok.account, { tenant: shops[0].tenant })
1795
+ ```
1796
+
1797
+ Сессия, открытая входом (`verifyPassword`, `sessionFor`), `svc.as(account)` без `tenant` и
1798
+ `connect({ token })` до `auth.switch()` работают в **собственном** арендаторе пользователя. Классы, которые
1799
+ `syncModels` записал в арендатор магазина, там не видны: шаг по такому классу — `invalid_class: класс или роль …
1800
+ не найдены`. Поэтому член арендатора работает так: сервис — `svc.as(login.account, { tenant: SHOP })`, своё
1801
+ подключение по токену — `auth.switch(SHOP)`; `db.tenant` показывает, где сессия. `auth.switch()` меняет арендатора
1802
+ самой сессии: токен и дальше работает в выбранном арендаторе. Нет членства — `tenant_denied`.
1803
+
1804
+ Название арендатора пользователь получает из `auth.tenants()` (`name`); саму строку `Account` арендатора он не
1805
+ видит — видна только своя (`db.Account(SHOP).first()` у члена магазина — `null`). Название, которое меняется в
1806
+ интерфейсе приложения, храните в данных арендатора — например, хабом `Shop` с полем `name`.
1807
+
1808
+ ### Веб-сервис: одно подключение и безопасность
1809
+
1810
+ - **Одно подключение сервиса на процесс.** Сервис держит одно подключение по ключу (`connect({ apiKey })`) и на каждый
1811
+ запрос берёт фасад `svc.as(account, { tenant })` — тот же пул. `connect({ token })` на каждый вход открывает
1812
+ отдельный пул (`max`, по умолчанию 10 соединений) на пользователя: несколько десятков входов исчерпают соединения
1813
+ PostgreSQL. `close()` у фасада `as()` ничего не делает; закрывают `svc` при остановке процесса.
1814
+ - **Арендатор — только из серверной сессии.** В начале запроса — `svc.auth.session(token)` по токену из cookie
1815
+ (`null` — не вошёл: 401, никаких данных); арендатор — из конфигурации сервера или из выбора, сохранённого на сервере
1816
+ при входе, и никогда из параметра запроса (`?club=`, тело, заголовок). Нет членства — `svc.as()` отвечает
1817
+ `tenant_denied` (403).
1818
+ - **Экранирование.** Данные из базы, вставленные в HTML, экранируют все пять символов `& < > " '` (или пишут через
1819
+ `textContent`), а обработчики событий (`onclick="…"`) из данных не строят: id — в атрибуте `data-*`, обработчик —
1820
+ `addEventListener`.
1821
+ - **Ключ сервиса** выдаётся один раз ([«Сервис и ключи API»](#сервис-и-ключи-api)): храните его в файле с правами
1822
+ только владельца (`chmod 600`) или в хранилище секретов, файл — в `.gitignore`; в репозиторий и клиенту он не
1823
+ попадает. Свой ключ заранее — `up({ serviceKey })`.
1824
+
1825
+ ```ts
1826
+ const svc = await connect({ dsn: APP, schema: 'v2.shop',
1827
+ apiKey: readFileSync('/etc/shop/service.key', 'utf8').trim() }) // один раз на процесс
1828
+ async function userDb(token: string | undefined) { // в начале каждого запроса
1829
+ const s = token ? await svc.auth.session(token) : null
1830
+ if (!s) return null // 401
1831
+ return svc.as(s.account, { tenant: SHOP }) // арендатор — с сервера; нет членства — tenant_denied
1832
+ }
1833
+ const esc = (v: string) => v.replace(/[&<>"']/g, (c) =>
1834
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
1835
+ ```
1406
1836
 
1407
1837
  ### Права
1408
1838
 
@@ -1444,10 +1874,10 @@ await anna.auth.switch(SHOP) // арендатор сессии
1444
1874
 
1445
1875
  <!-- run: guide -->
1446
1876
  ```ts
1447
- const pg2 = postgres(ADMIN)
1448
- const OLEG = (await createTenant(pg2, r.schema, { // тот же магазин: создаётся только пользователь
1877
+ const admin2 = postgres(ADMIN)
1878
+ const OLEG = (await createTenant(admin2, r.schema, { // тот же магазин: создаётся только пользователь
1449
1879
  name: 'Ромашка', user: { name: 'Олег', login: 'oleg@shop.ru', password: 'Олег-2026!' } })).user!
1450
- await pg2.end()
1880
+ await admin2.end()
1451
1881
  await db.acl.roles(OLEG, ['staff']) // роли Олега в текущем арендаторе (строка member)
1452
1882
 
1453
1883
  await db.acl.deny({ role: 'staff', delete: 'Product' }) // вес 50 — сильнее «можно всё» (10)
@@ -1523,16 +1953,16 @@ await db.acl.revoke({ everyone: true, read: '*', write: '*', delete: '*' })
1523
1953
  аккаунтов обёртка пишет своей группой `everyone:ACCOUNT`. Так же — право всем аккаунтам на отдельный класс:
1524
1954
  `db.acl.grant({ everyone: true, read: 'Catalog' })`.
1525
1955
 
1526
- Не заводить. `createTenant(sql, schema, { name, grantAll: false, user })` и `up({ tenant: { name, grantAll: false, user
1956
+ Не заводить. `createTenant(admin, schema, { name, grantAll: false, user })` и `up({ tenant: { name, grantAll: false, user
1527
1957
  } })` заводят арендатора без правил. У его участников, в том числе у первого пользователя, прав нет — ни на данные, ни
1528
1958
  на запись правил. Классы и правила такого арендатора пишет администраторское подключение с сессией его пользователя:
1529
1959
  RLS на нём не действует, а строки идут в арендатор сессии.
1530
1960
 
1531
1961
  ```ts
1532
- const pg = postgres(ADMIN)
1533
- const t = await createTenant(pg, r.schema, { name: 'Склад', grantAll: false,
1962
+ const admin = postgres(ADMIN)
1963
+ const t = await createTenant(admin, r.schema, { name: 'Склад', grantAll: false,
1534
1964
  user: { name: 'Ирина', login: 'irina@stock.ru', password: 'Склад-2026!', roles: ['manager'] } })
1535
- await pg.end()
1965
+ await admin.end()
1536
1966
  const adm = await connect({ dsn: ADMIN, schema: r.schema, token: t.token, allowBypassRls: true })
1537
1967
  await syncModels(adm, [Product], { apply: true })
1538
1968
  await adm.acl.grant({ role: 'manager', read: 'Product', write: 'Product', delete: 'Product' })
@@ -1545,13 +1975,42 @@ await adm.close()
1545
1975
 
1546
1976
  ### Удалить аккаунт
1547
1977
 
1548
- `db.auth.purgeAccount(аккаунт)` удаляет аккаунт и данные его арендатора. Вызывать может системный администратор или
1549
- владелец его арендатора; себя и последнего владельца арендатора удалить нельзя. Владение строками в чужих
1550
- арендаторах переходит этим арендаторам, сессии гаснут, креды удаляются. Владелец может стереть и арендатора, в
1551
- котором сейчас работает: его сессия вернётся в личный арендатор.
1978
+ `db.auth.purgeAccount(аккаунт)` удаляет аккаунт и его собственного арендатора. Вызывать может системный администратор
1979
+ ([«Аккаунты, арендаторы и членство»](#аккаунты-арендаторы-и-членство)) или владелец (роль `owner`) этого арендатора;
1980
+ себя и последнего владельца арендатора удалить нельзя. Сервису по ключу — `acl_denied`
1981
+ (`sql-time.account_purge_denied`). Ответ — `{ account, rows, versions, memberships, owned }`. Владелец может стереть
1982
+ и арендатора, в котором сейчас работает: его сессия вернётся в личный арендатор.
1983
+
1984
+ Что стирается и что остаётся:
1552
1985
 
1553
- Системный администратор может удалить и саму строку аккаунта — `db.Account(id).delete({ confirm: true })` в
1554
- арендаторе System; данные арендатора аккаунта при этом остаются. Любой путь сразу стирает строки аккаунта в `secret`:
1986
+ | Что | После `purgeAccount` |
1987
+ |---|---|
1988
+ | строки собственного арендатора аккаунта и их журнал | стёрты физически (`rows`, `versions`) |
1989
+ | креды, сессии, секреты TOTP, внешние учётные записи (`secret`) | стёрты; сессии гаснут с сигналом |
1990
+ | членства в чужих арендаторах | удалены, надгробие — в журнале (`memberships`) |
1991
+ | строка `Account` | удалена; надгробие с последним снимком (имя, категории) — в журнале System |
1992
+ | строки чужих арендаторов, которыми он владел | остаются, владелец — их арендатор (`owned`) |
1993
+ | строки чужих арендаторов, которые он создал или правил, и **все их версии** | остаются как есть; `author` версий — id удалённого аккаунта |
1994
+ | строки, которые ссылаются на аккаунт концом | мешают: `delete_restricted` (`sql-time.account_referenced`), ничего не удалено |
1995
+
1996
+ Персональные данные клиента в данных арендатора (телефон и почта в строке `Customer` магазина) `purgeAccount` не
1997
+ трогает, а журнал хранит каждую их версию: стереть версии одного объекта нельзя. Поэтому стираемые персональные
1998
+ данные держите в отдельном классе с `history: false` — у него нет версий, правка перезаписывает, а удаление стирает
1999
+ без следа, — и ссылайтесь на него из основного объекта. Стереть клиента — удалить его строку этого класса по превью:
2000
+
2001
+ ```ts
2002
+ const Contact = hub('Contact', { phone: z.string(), email: z.string() }, { history: false }) // без журнала
2003
+ const Client = hub('Client', { name: z.string() }, { ends: { Contact: one(Contact).optional().onDelete('unset') } })
2004
+ // … стереть персональные данные клиента c:
2005
+ const shown = await db.Client(c).Contact().delete().rows()
2006
+ await db.Client(c).Contact().delete({ confirm: true, expect: shown, reason: 'стирание по запросу' }).rows()
2007
+ ```
2008
+
2009
+ Данные, уже записанные в класс с журналом, остаются в его версиях; обезличить можно только текущую версию правкой.
2010
+ Удалить весь арендатор с журналом — `purgeAccount` арендатора.
2011
+
2012
+ Системный администратор может удалить и саму строку аккаунта — по превью, `db.Account(id).delete({ confirm: true,
2013
+ expect: превью })`, в арендаторе System; данные арендатора аккаунта при этом остаются. Любой путь сразу стирает строки аккаунта в `secret`:
1555
2014
  сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (внешний ключ `secret.account` с каскадом).
1556
2015
  Сессии гаснут до этого с сигналом, и процессы сбрасывают кэш ответов этих сессий. Аккаунт System
1557
2016
  не удаляется (`acl_denied`): ему принадлежат служебные строки установки в `secret`.
@@ -1570,10 +2029,23 @@ const versions = await db.Product(kettle).versions() // все ве
1570
2029
  `delete({ confirm, reason })` и других), для всех операций блока — `db.begin({ reason })` и
1571
2030
  `db.batch(имя, { reason })`; сырой SQL — настройка `letopis.reason` транзакции. Версия удаления — с `$deleted`.
1572
2031
 
1573
- Без `sort()` версии идут по id и номеру версии. `sort()` задаёт порядок и версиям, а
1574
- `limit()` ограничивает их в самом запросе: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` — пять
2032
+ Версия — строка той же формы, что строка ответа ([«Строка ответа»](#строка-ответа)), но со снимком на свою версию:
2033
+ `rev` — номер версии, `op` — вид операции, `links` и `data` — концы и поля после этой операции (у версии удаления —
2034
+ последний снимок объекта), `at`, `author`, а если есть — `agent` и `reason`; у версии удаления ещё `$deleted: true`.
2035
+ Без `sort()` версии идут по id и номеру версии (по возрастанию: первой — `create`). `sort()` задаёт порядок и версиям,
2036
+ а `limit()` ограничивает их в самом запросе: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` — пять
1575
2037
  последних версий, новые первыми.
1576
2038
 
2039
+ Шаг с явными id находит и удалённые объекты: `db.Product(id).versions()` отдаёт историю удалённого товара вместе с
2040
+ версией удаления, `withDeleted()` для этого не нужен. Шаг без id (`db.Product().versions()`, переход по пути) отдаёт
2041
+ версии **только живых** объектов, с удалёнными — `withDeleted()` этого шага. Так, строки удалённого заказа ушли
2042
+ каскадом, и `db.Product(p).line().versions()` их не покажет, а `db.Product(p).line().withDeleted().versions()` или
2043
+ `versions()` по id строки — покажут: концы — `links` версии, удаление — версия удаления, причина — `reason`.
2044
+
2045
+ `versions()` — терминал: отдаёт промис строк версий. Модификаторы ставят до него
2046
+ (`db.Product(kettle).sort('at', 'desc').limit(5).versions()`), а метод цепочки у его результата —
2047
+ `versions().rows()`, `.sort()`, `.limit()` — `invalid_query` (`api.versions_terminal`).
2048
+
1577
2049
  Журнал хранит каждую версию: прореживания и обрезки истории нет. Классу, чья история не нужна, — `history: false`
1578
2050
  ([«Настройки класса»](#настройки-класса)).
1579
2051
 
@@ -1598,6 +2070,28 @@ const assortment = await db.Shop(shop).Product().asOf(createdAt).rows()
1598
2070
  const withGone = await db.Customer().withDeleted().rows() // живые и удалённые; у удалённых $deleted: true
1599
2071
  ```
1600
2072
 
2073
+ `withDeleted()` — модификатор своего шага, как `exact()`: удалённые находит шаг, после которого он стоит, а шаги без
2074
+ него идут по живым строкам. Удалённый объект приходит надгробием — своей последней версией, версией удаления, — с
2075
+ `$deleted: true`.
2076
+
2077
+ | Цепочка | Что находит |
2078
+ |---|---|
2079
+ | `db.Order(o).line().withDeleted()` | живые и удалённые строки живого заказа |
2080
+ | `db.Order(o).withDeleted().line().withDeleted()` | живые и удалённые строки заказа, даже если сам заказ удалён |
2081
+ | `db.Order(o).withDeleted().line()` | только **живые** строки (заказ — с удалёнными); выводит предупреждение `api.with_deleted_step` |
2082
+ | `db.Order(o).line().Product().withDeleted()` | товары, в том числе удалённые, живых строк живого заказа |
2083
+
2084
+ Путь через удалённый узел читают одной цепочкой — `withDeleted()` у каждого шага, который может быть удалён:
2085
+ `db.Customer(c).Order().withDeleted().line().withDeleted()` — строки всех заказов клиента, в том числе удалённых. В
2086
+ цепочке записи `withDeleted()` — только перед `restore()`: у других глаголов — `invalid_query`. В инструменте MCP
2087
+ `letopis_query` опция `withDeleted` относится к последнему шагу.
2088
+
2089
+ `withDeleted()` отдаёт по каждому id его последнюю версию. Если id надгробия занял новый объект (`create()` по ключу
2090
+ удалённого), шаг находит живой новый объект без `$deleted`, а прежнего надгробия среди строк нет; путь от прежнего
2091
+ владельца (`db.Customer(c).withDeleted().Order().withDeleted()`) такой объект не находит, если его последняя версия
2092
+ ведёт к другому. Прежнее надгробие видно только в версиях — `db.Order(id).versions()`; вернуть его нельзя: пока
2093
+ новый объект жив, `restore({ expect })` — `not_deleted` (`write.restore_id_taken`) ([«Восстановить: `restore`»](#восстановить-restore)).
2094
+
1601
2095
  ## 12. Подписка на изменения и кэш
1602
2096
 
1603
2097
  ### Подписка
@@ -1675,7 +2169,7 @@ const report = await adm.maintain() // { sessions, codes, queue, skipped }
1675
2169
  вызовом), а ещё проверяет очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Журнал
1676
2170
  `maintain()` не трогает. Строки удалённого аккаунта ждать не нужно: их стирает само удаление ([«Удалить
1677
2171
  аккаунт»](#удалить-аккаунт)). Вызывать может системный администратор, роль с правом `letopis.maintain` или
1678
- администраторское подключение. Запускает его внешний планировщик (`letopis maintain`) или сама библиотека:
2172
+ администраторское подключение; остальным — `acl_denied` (`sql-time.maintain_denied`), в том числе сервису по ключу. Запускает его внешний планировщик (`letopis maintain`) или сама библиотека:
1679
2173
  `connect({ maintain: '1h' })`. Интервал — число миллисекунд или строка (`'500ms'`, `'30s'`, `'15m'`, `'1h'`, `'1d'`)
1680
2174
  от 1 мс до 2³¹−1 мс (около 24 суток): другое значение, в том числе ноль, отрицательное или `NaN`, `connect()`
1681
2175
  отклоняет с `invalid_data`, не открывая соединений. `close()` ждёт идущий по расписанию запуск. Из нескольких
@@ -1765,7 +2259,8 @@ PostgreSQL у ошибки не из letopis), если они есть. Пар
1765
2259
  `rev` прочитанной версии: чужая версия — `conflict`, потерянной правки нет. `delete` без `confirm` возвращает превью
1766
2260
  каскада и код операции: `{ preview, operation }`. С `confirm: true` удаление подтверждает человек в клиенте через
1767
2261
  elicitation (в ревизии протокола 2025 — запросом клиенту, в 2026-07-28 — ответом `input_required`), подтверждение
1768
- привязано к хэшу операции, а ответ — `{ deleted, operation }`. В ревизии 2026-07-28 подпись `requestState`, с которой
2262
+ привязано к хэшу операции, а ответ — `{ deleted, operation }`. Удаляет инструмент ровно показанное: превью, построенное
2263
+ до подтверждения, идёт в `expect`, и если данные изменились — `conflict` (`write.preview_changed`), ничего не удалено. В ревизии 2026-07-28 подпись `requestState`, с которой
1769
2264
  клиент повторяет вызов, привязана ещё к пользователю сервера и действует 10 минут: подпись сервера другого
1770
2265
  пользователя и устаревшая — отказ `confirmation_required`. Окно подтверждения показывает каноническое имя класса и
1771
2266
  id целей (первые 10, дальше — «и ещё K»); алиас и `meta` класса пишет арендатор, поэтому в окно они не попадают. Код
@@ -1803,7 +2298,9 @@ id целей (первые 10, дальше — «и ещё K»); алиас и
1803
2298
  **HTTP.** Встроенного HTTP-сервера нет. `createMcpServer(db, { write, maxRows, maxBytes })` из `letopis/mcp` отдаёт
1804
2299
  сервер MCP, а приложение подключает его к своему обработчику `createMcpHandler` из SDK со своей авторизацией
1805
2300
  OAuth 2.1: токен запроса проверяет приложение и находит по нему пользователя letopis. Подключение — сессия
1806
- пользователя (`connect({ token })`) или фасад `svc.as(пользователь)`: ключ сервиса остаётся в процессе приложения.
2301
+ пользователя в арендаторе приложения (`connect({ token })` после `auth.switch(tenant)`) или фасад
2302
+ `svc.as(пользователь, { tenant })`: ключ сервиса остаётся в процессе приложения. Без `{ tenant }` фасад работает в
2303
+ личном арендаторе пользователя, и классов приложения агент не увидит.
1807
2304
  Подключение в обход RLS получает отказ `bypass_rls`, корень сервиса без `as()` и подключение без сессии —
1808
2305
  `user_session_required`, фасад явной транзакции `db.begin()` — `invalid_query`. Сессию в арендаторе System — так
1809
2306
  работает системный администратор: его роль `admin` даёт права на аккаунты, членства и правила установки — сервер тоже
@@ -1816,10 +2313,10 @@ import { createMcpServer } from 'letopis/mcp'
1816
2313
  import { createMcpHandler } from '@modelcontextprotocol/server'
1817
2314
 
1818
2315
  const ADMIN = 'postgres://postgres:secret@localhost:5432/shop'
1819
- const APP = 'postgres://shop_app:app-secret@localhost:5432/shop'
1820
- const r = await up({ dsn: ADMIN, schema: 'shop', quiet: true,
2316
+ const r = await up({ dsn: ADMIN, schema: 'shop', quiet: true, login: { user: 'shop_app', password: 'app-secret' },
2317
+ serviceKey: process.env.SERVICE_KEY, // повторный up() ключ не выдаёт — для повторяемого запуска передайте свой
1821
2318
  tenant: { name: 'Ромашка', user: { name: 'Анна', login: 'anna@shop.ru', password: 'Ромашка-2026!' } } })
1822
- const svc = await connect({ dsn: APP, schema: r.schema, apiKey: r.serviceKey! })
2319
+ const svc = await connect({ dsn: r.login!.dsn!, schema: r.schema, apiKey: (r.serviceKey ?? process.env.SERVICE_KEY)! })
1823
2320
 
1824
2321
  // authInfo — итог проверки токена запроса приложением; в extra оно кладёт id аккаунта letopis
1825
2322
  const mcp = createMcpHandler(async ({ authInfo }) =>
@@ -1830,11 +2327,34 @@ const mcp = createMcpHandler(async ({ authInfo }) =>
1830
2327
  Фабрику обработчик вызывает на каждый запрос HTTP, поэтому `createMcpServer` проверяет подключение каждый раз (один
1831
2328
  запрос к базе).
1832
2329
 
1833
- Проверить обработчик можно без сети: клиент SDK из пакета `@modelcontextprotocol/client` ходит в него через `fetch`
1834
- приложения — `new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { fetch: (url, init) =>
1835
- mcp.fetch(new Request(url, init), { authInfo }) })`. Так пример выше проверяет тест `docs` (сценарий `mcp` в
1836
- `lib/test/docs.test.ts` репозитория): без `write` клиент видит три инструмента, а `letopis_model` отвечает от имени
1837
- Анны в магазине.
2330
+ Проверить обработчик можно без сети: клиент SDK из пакета `@modelcontextprotocol/client` (его ставят отдельно:
2331
+ `npm i -D @modelcontextprotocol/client`) ходит в него через `fetch` приложения — `new StreamableHTTPClientTransport(new
2332
+ URL('http://localhost/mcp'), { fetch: (url, init) => mcp.fetch(new Request(url, init), { authInfo }) })`.
2333
+
2334
+ **Клиент в том же процессе** — без HTTP, парой связанных транспортов `InMemoryTransport` (есть в обоих пакетах SDK):
2335
+
2336
+ ```ts
2337
+ import { Client, InMemoryTransport } from '@modelcontextprotocol/client'
2338
+
2339
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair()
2340
+ const server = await createMcpServer(await svc.as(r.tenant!.user!, { tenant: r.tenant!.tenant }), { write: true })
2341
+ await server.connect(serverSide)
2342
+ const client = new Client({ name: 'check', version: '1' }) // подтверждение удаления — { capabilities: { elicitation: {} } }
2343
+ await client.connect(clientSide)
2344
+ const res = await client.callTool({ name: 'letopis_query', arguments: { class: 'Shop', limit: 10 } })
2345
+ const rows = (res.structuredContent as { data: { rows: unknown[] } }).data.rows // { notice, data: { rows, count, more } }
2346
+ await client.callTool({ name: 'letopis_write', arguments: { op: 'create', class: 'Shop', data: { name: 'Лютик' } } })
2347
+ await client.close()
2348
+ ```
2349
+
2350
+ Аргументы инструментов (полные схемы с описаниями отдаёт `client.listTools()`):
2351
+
2352
+ | Инструмент | Аргументы |
2353
+ |---|---|
2354
+ | `letopis_model` | `class?` |
2355
+ | `letopis_query` | `class`; `id`, `ids` или `filter`; `steps: [{ step, id?, ids?, filter? }]`, `asOf`, `withDeleted`, `sort: { field, dir }`, `limit`, `cursor`, `terminal`, `field` (поле агрегата) |
2356
+ | `letopis_history` | `class`, `id`, `kind?` (`versions`) |
2357
+ | `letopis_write` | `op` (`create`, `update`, `upsert`, `delete`), `class`; `id` или `ids` — цели `update` и `delete`; `data` — поля; `links: { Роль: id \| [id] \| null }`; `rev` — обязателен у `update`; `confirm`, `operation` — подтверждение удаления. Причину версий задаёт сервер (`mcp: letopis_write`), своей нет |
1838
2358
 
1839
2359
  ### Сырой SQL
1840
2360
 
@@ -1844,10 +2364,40 @@ const [{ n }] = await db.sql`select count(*)::int as n from "v2.shop".entity whe
1844
2364
  ```
1845
2365
 
1846
2366
  `` db.sql`…` `` выполняет запрос одной транзакцией под сессией подключения и RLS: видны те же строки, что и
1847
- цепочкам. `db.transaction(fn)` даёт соединение postgres.js на всю транзакцию. При взаимной блокировке и ошибке
2367
+ цепочкам. Таблицы пишут с именем схемы (`"v2.shop".entity`): путь поиска роли схему letopis не содержит, и
2368
+ `from entity` даст `42P01`.
2369
+
2370
+ Имя схемы — **один** идентификатор с точкой внутри, поэтому его берут в двойные кавычки целиком. Идентификатор
2371
+ postgres.js `sql(r.schema)` делит имя по точке (`"v2"."shop"`) — и запрос падает с `0A000` (cross-database
2372
+ references). Имя из переменной вставляют так:
2373
+
2374
+ ```ts
2375
+ const S = `"${r.schema}"` // "v2.shop" — имя схемы целиком в кавычках
2376
+ const [{ n: products }] = await db.transaction((t) =>
2377
+ t`select count(*)::int as n from ${t.unsafe(S)}.entity where class = ${'Product'}`)
2378
+ ```
2379
+
2380
+ `db.sql` — только шаблонный вызов; соединение postgres.js со всеми его помощниками (`t.unsafe`, `t(…)`) даёт
2381
+ `db.transaction(fn)`. `db.transaction(fn)` даёт соединение postgres.js на всю транзакцию. При взаимной блокировке и ошибке
1848
2382
  сериализации запрос и `fn` повторяются до трёх раз, поэтому `fn` должна выдерживать повтор. Писать сырым SQL тоже
1849
2383
  можно — те же триггеры проверят данные, а функции базы `merge` и `inc` доступны приложению.
1850
2384
 
2385
+ Ошибки базы `db.sql`, `db.transaction(fn)` и `tr.sql` в `db.begin()` приходят так же, как у цепочек: с кодом letopis
2386
+ (`LT…` — по таблице [«Ошибки»](#15-ошибки)), а прочие — как ошибка postgres.js с SQLSTATE в `err.code`. Исключение —
2387
+ значение не того типа, SQLSTATE `22P02` (`'42'::uuid`, `'abc'::int`): оно приходит `LetopisError` с кодом
2388
+ `invalid_query` (ключ `api.db_bad_value`), текст называет значение — `letopis: invalid_query: значение не того типа:
2389
+ invalid input syntax for type uuid: "42"`, а исходная ошибка PostgreSQL — в `err.cause` (`err.cause.code === '22P02'`).
2390
+ Ловите её по `err.code === 'invalid_query'`, а не по SQLSTATE:
2391
+
2392
+ ```ts
2393
+ try {
2394
+ await db.sql`select * from "v2.shop".entity where id = ${input}::uuid`
2395
+ } catch (e) {
2396
+ if (!(e instanceof LetopisError) || e.code !== 'invalid_query') throw e
2397
+ // e.key === 'api.db_bad_value'; (e.cause as { code: string }).code === '22P02'
2398
+ }
2399
+ ```
2400
+
1851
2401
  ### Командная строка
1852
2402
 
1853
2403
  ```sh
@@ -1858,6 +2408,7 @@ letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token
1858
2408
  letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>]
1859
2409
  letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
1860
2410
  (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
2411
+ # у любой команды: --log[=debug|info|warn|error] — события JSON-строками в stderr (без флага — LETOPIS_LOG)
1861
2412
  ```
1862
2413
 
1863
2414
  `letopis types` строит результат по классам сессии, поэтому без действующей сессии (токен истёк, погашен, неверен или
@@ -1870,12 +2421,116 @@ letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пол
1870
2421
 
1871
2422
  ### Наблюдение
1872
2423
 
2424
+ - Журнал событий — `connect({ log })` или `LETOPIS_LOG` ([«Логирование»](#логирование)).
1873
2425
  - `connect({ onQuery, slowMs })`: хук получает на каждый запрос цепочки режим, классы, длительность, число строк и
1874
2426
  признак медленного запроса; без хука медленный запрос печатает предупреждение.
1875
2427
  - `sub.lag()` — отставание подписки; отчёт `maintain()` — заполнение очереди сигналов.
1876
2428
  - Горячие цели ссылок (организация, на которую ссылается почти всё) копят блокировки `for key share` и MultiXact,
1877
2429
  как при обычных внешних ключах. Это стоит мониторить.
1878
2430
 
2431
+ ### Логирование
2432
+
2433
+ letopis пишет журнал событий приложения: что прочитано и записано, что удалено и восстановлено, какие были ошибки,
2434
+ кто вошёл. По нему видно, что удаление прошло по показанному превью, а восстановление вернуло то, что ушло. Журнал
2435
+ выключен, пока его не включили опцией или переменной окружения.
2436
+
2437
+ **Опции.** `connect()` и `up()` принимают одни и те же:
2438
+
2439
+ | Опция | Что делает |
2440
+ |---|---|
2441
+ | `log` | куда писать: функция `(event) => void` или объект с методами `debug`, `info`, `warn`, `error` (`console`, pino и подобные) — вызывается `logger[level](event, event.event)`; нет метода уровня — `logger.log`, нет и его — событие пропускается |
2442
+ | `logLevel` | порог: `'debug'`, `'info'` (по умолчанию), `'warn'`, `'error'` или `'off'`; событие ниже порога не собирается |
2443
+ | `redact` | ещё имена полей (`string[]`, без учёта регистра), значения которых заменяются на `'***'` в любом месте `detail` |
2444
+
2445
+ ```ts
2446
+ import pino from 'pino'
2447
+ const svc = await connect({ dsn: APP, schema: 'v2.shop', apiKey: SERVICE_KEY,
2448
+ log: pino(), logLevel: 'info', redact: ['phone', 'email'] })
2449
+ const solo = await connect({ dsn: APP, schema: 'v2.shop', token, log: (e) => audit.push(e) }) // своя функция
2450
+ ```
2451
+
2452
+ Подключения из `db.as()` и `svc.as()`, `db.begin()`, `db.transaction()`, батч и `auth.switch()` пишут тем же журналом
2453
+ с тем же порогом и `redact`; в их событиях свои `tenant`, `account` и `tx`. Исключение внутри логгера проглатывается:
2454
+ операция из-за журнала не ломается. Хук `onQuery` работает как прежде, а каждый запрос SQL — ещё и событие `sql`.
2455
+
2456
+ **Переменная окружения и командная строка.** `LETOPIS_LOG=<уровень>` (`debug`, `info`, `warn`, `error`, `off`;
2457
+ `1` и `true` — то же, что `info`): если `log` не передан, события пишутся в stderr JSON-строками, одна строка — одно
2458
+ событие. Если `log` передан без `logLevel`, порог берётся из `LETOPIS_LOG`, а без неё — `info`. Без `log` и без
2459
+ `LETOPIS_LOG` журнала нет. Команды CLI принимают `--log[=уровень]` (`--log` без значения — `info`), события — в stderr;
2460
+ без флага действует `LETOPIS_LOG`.
2461
+
2462
+ ```sh
2463
+ LETOPIS_LOG=info node app.js 2>> letopis.log
2464
+ npx letopis maintain --dsn "$ADMIN" --schema v2.shop --api-key "$KEY" --log=debug
2465
+ ```
2466
+
2467
+ **Событие** — объект:
2468
+
2469
+ | Поле | Что значит |
2470
+ |---|---|
2471
+ | `ts`, `level`, `event` | время ISO, уровень (`debug`, `info`, `warn`, `error`), имя события |
2472
+ | `op`, `ms` | операция (`rows`, `first`, `create`, `delete`, `login` …) и длительность в миллисекундах |
2473
+ | `tenant`, `account`, `tx` | арендатор и аккаунт сессии; метка транзакции, если операция в `db.begin()`, `transaction()` или батче |
2474
+ | `chain`, `rows` | цепочка в читаемом виде (`Shop({"name":"Ромашка"}).Product.rows()`) и число строк результата или затронутых |
2475
+ | `code`, `key`, `msg` | код и ключ каталога ошибки или предупреждения, текст |
2476
+ | `detail` | подробности события (секреты замаскированы) |
2477
+ | `cause` | у события `error` — исходная ошибка: `{ name, message, code }` |
2478
+
2479
+ **Маскировка.** Без настройки заменяются на `'***'` на любой глубине `detail`: значения полей с паролями, токенами,
2480
+ ключами, секретами и кредами — по точному имени: `password`, `passwd`, `passphrase`, `pwd`, `secret`, `token`,
2481
+ `apiKey`, `serviceKey`, `authorization`, `cookie`, `credential`, `credentials`, `hash`, `material`, `totp`, `otp`, — и
2482
+ составных имён с ними (`dbPassword`, `refreshToken`, `clientSecret`); слова, где такие буквы лишь внутри (`bypass`),
2483
+ не маскируются. Строка подключения в поле `dsn` пишется без пользователя, пароля и параметров —
2484
+ `postgres://localhost:5432/shop`; в тексте событий учётные данные вида `postgres://user:pass@` заменяются на
2485
+ `postgres://***@`. Пароль в тексте SQL (`password '…'`) — `'***'`. `redact` добавляет свои имена полей (персональные
2486
+ данные и т. п.).
2487
+
2488
+ Не пишутся никогда: значения параметров SQL (у событий `sql` и `raw` — только их число); текст, `DETAIL`, `WHERE`,
2489
+ `HINT` и внутренний запрос ошибки PostgreSQL — в событии только SQLSTATE и имена ограничения, таблицы, колонки, схемы,
2490
+ типа и функции, а вместо текста — фраза «ошибка PostgreSQL <SQLSTATE>»; поле `data` строк в `detail` ошибки (превью,
2491
+ снимки отказа `restore`) — `'***'`; значение аргумента в тексте ошибки letopis (`query.bad_uuid`, `api.db_bad_value`
2492
+ и подобные) — `'***'`.
2493
+
2494
+ Пишутся как есть — учитывайте это: значения фильтров в `chain` (`Shop({"name":"Ромашка"})`; у записи данные —
2495
+ только именами полей), поэтому фильтр по персональным полям — `redact: ['email', …]`; тексты исключений кода
2496
+ приложения (с маскировкой паролей). Объект ошибки, который получает приложение (`err.message`, `err.cause`), журнал
2497
+ не меняет: что из него логировать самому, решает приложение.
2498
+
2499
+ **Транзакции и фиксация.** Событие записи вне `db.begin()` пишется после фиксации своей транзакции и само доказывает
2500
+ её. Внутри `db.begin()` события несут `tx` и пишутся, когда оператор выполнен, до `COMMIT`: `begin → delete →
2501
+ rollback` оставит событие `delete`, а строки — на месте. Фиксацию такой записи доказывает только `tx.commit` с тем же
2502
+ `tx`; `tx.rollback` с ним — запись отменена; `commit_unknown` — исход неизвестен (повтор с тем же ключом).
2503
+
2504
+ **Основные события.** Полный каталог — `import { LOG_EVENTS } from 'letopis'`: объект «имя события →
2505
+ `{ level, ru }`» (уровень и описание), тип события — `LogEvent`.
2506
+
2507
+ | Событие | Уровень | Когда | Главное в событии |
2508
+ |---|---|---|---|
2509
+ | `connect`, `close` | info | подключение готово, закрыто | `ms`; `detail.schema` |
2510
+ | `up`, `migrate` | info | `up()` завершён; `up()` обновил ревизию схемы | `detail`: `schema`, `created`, `revision`; `from`, `to` |
2511
+ | `sync` | info | `syncModels()` | итог: созданные и изменённые классы |
2512
+ | `read` | debug | терминал чтения | `op`, `chain`, `rows`, `ms`, `tx` |
2513
+ | `cache.hit`, `cache.miss` | debug | ответ из кэша результатов; промах | `chain`, `rows` |
2514
+ | `write` | info | глагол записи, план батча, загрузка | `op`, `chain`, `rows`, `ms`, `tx` |
2515
+ | `delete.preview` | debug | `delete()` без `confirm` | `rows`; `detail`: сколько `delete`, `unset`, `restrict` |
2516
+ | `delete` | info | удаление выполнено | `chain`, `rows`; `detail.expect` — сколько строк превью сверено (`false` — без сверки, `all: true`) |
2517
+ | `restore` | info | восстановление выполнено | `chain`, `rows`; `detail`: `expect`, `partial`, `skipped: [{ id, why }]` |
2518
+ | `tx.begin` | debug | открыта транзакция | `tx` |
2519
+ | `tx.commit` | info, если в транзакции была запись, иначе debug | зафиксирована транзакция | `tx`, `ms`; `detail.writes` — число операций записи |
2520
+ | `tx.rollback` | info | откат | `detail`: `reason` (`explicit` или `error`), `writes` |
2521
+ | `tx.retry` | warn | повтор после `40001`, `40P01` или конфликта журнала | `detail`: `attempt`, `code` |
2522
+ | `auth.login`, `auth.logout`, `auth.revoke`, `auth.switch` | info | вход, выход, отзыв сессии, смена арендатора | `account`, `tenant` |
2523
+ | `watch.start`, `watch.stop` | info | подписка открыта, закрыта | `detail.classes` |
2524
+ | `signal` | debug | получен сигнал базы | `detail.channel` |
2525
+ | `maintain` | info | `maintain()` | итог обслуживания |
2526
+ | `warning` | warn | любое предупреждение letopis (`api.step_undefined_filter` и другие) | `key`, `msg` |
2527
+ | `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` |
2528
+ | `sql` | debug | каждый запрос SQL | `ms`, `rows`, `tx`; `detail`: текст (до 2000 знаков), число параметров |
2529
+
2530
+ Ещё есть, среди прочих: `auth.as` (debug, `as()`), `auth.fail` (warn, неудачный вход), `acl.change` (правка прав
2531
+ `db.acl`), `tenant.create`, `account.purge`, `load` (итог загрузчика), `lock` (debug, `db.lock()`), `mcp.call` (вызов
2532
+ инструмента MCP).
2533
+
1879
2534
  ### Пулы соединений и PgBouncer
1880
2535
 
1881
2536
  С PgBouncer в режиме транзакций `LISTEN` недоступен: `connect({ listen: false })`. Тогда реестр перечитывается через
@@ -1892,16 +2547,17 @@ await svc.close() // и подключения, полученные чер
1892
2547
 
1893
2548
  ## 14. Справочник API
1894
2549
 
1895
- Полный машинный список экспортов, методов и текстов ошибок — `docs/api-contract.json` в репозитории.
2550
+ Полный машинный список экспортов, методов и текстов ошибок — `api-contract.json` в корне пакета
2551
+ (`node_modules/letopis/api-contract.json`).
1896
2552
 
1897
2553
  ### Экспорты пакета
1898
2554
 
1899
2555
  | Точка входа | Что экспортирует |
1900
2556
  |---|---|
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` и другие |
2557
+ | `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`, `LOG_EVENTS` (каталог событий журнала); типы `Db`, `DbTx`, `TypedDb`, `SystemModels`, `Chain`, `Row`, `Expected` (строка `expect`: `{ id, rev, $action? }`), `DeleteOptions`, `RestoreOptions`, `Filter`, `FieldFilter` (значение поля в фильтре: `Record<string, FieldFilter>` — фильтр, собранный во время выполнения), `Cursor`, `LogEvent`, `ConnectOptions`, `UpOptions`, `LoadOptions`, `LoadReport`, `WatchOptions`, `WatchEvent`, `AuthApi`, `AclApi`, `MaintainReport` и другие |
1902
2558
  | `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta`; `normalizeSchema`, `translatePattern`, тип `JsonSchema` |
1903
2559
  | `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` |
2560
+ | CLI `letopis` | `sync`, `types`, `load`, `maintain`, `mcp` ([раздел 13](#13-эксплуатация)); у всех — `--log[=уровень]` ([«Логирование»](#логирование)); у `mcp` — `--dsn`, `--schema`, `--token` (или `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`) и `--write` |
1905
2561
 
1906
2562
  ### Корень `db`
1907
2563
 
@@ -1911,7 +2567,7 @@ await svc.close() // и подключения, полученные чер
1911
2567
  | `schema`, `token`, `tenant`, `registry` | имя установки, токен, арендатор сессии, реестр классов (`resolve`, `find`, `has`, `all`, `family`) |
1912
2568
  | `auth`, `acl` | вход, креды и сессии; решения прав ([раздел 10](#10-пользователи-вход-и-права)) |
1913
2569
  | `as(аккаунт, { tenant, owner })` | тот же пул от имени аккаунта (асинхронный) |
1914
- | `begin()`, `commit(tr)`, `rollback(tr)`, `lock(…ключи)` | явная транзакция ([раздел 8](#8-транзакции-батчи-и-деньги)) |
2570
+ | `begin()`, `commit(tr)`, `rollback(tr)`, `lock(…ключи)` | явная транзакция ([раздел 8](#8-транзакции-и-батчи)) |
1915
2571
  | `batch(имя)` | очередь планов: `run()`, `discard()`, `size()` |
1916
2572
  | `load(вход, опции)` | загрузчик ([раздел 9](#9-массовая-загрузка)) |
1917
2573
  | `watch(опции)`, `cache` | подписка; кэш результатов (`clear()`, `stats()`) |
@@ -1919,15 +2575,15 @@ await svc.close() // и подключения, полученные чер
1919
2575
  | `idOf(класс, ключ)` | id объекта класса с ключом в арендаторе сессии |
1920
2576
  | `entity(строка \| цепочка)` | начать путь с готового узла |
1921
2577
  | `maintain()` | обслуживание ([раздел 13](#13-эксплуатация)) |
1922
- | `refresh()`, `flushSignals()`, `close()` | перечитать классы; отправить сигналы сейчас; закрыть |
2578
+ | `refresh()`, `flushSignals()`, `close()` | перечитать классы; отправить сигналы сейчас; закрыть пул и подписки (у фасада `as()` — ничего не делает, в `db.begin()` — откат); после `close()` любой вызов — `invalid_query` (`api.closed`) |
1923
2579
 
1924
2580
  ### Цепочка
1925
2581
 
1926
2582
  | Группа | Методы |
1927
2583
  |---|---|
1928
2584
  | шаги | `.Класс(фильтр)`, `.алиас(фильтр)`, `.Роль(фильтр)` |
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 }` |
2585
+ | модификаторы | `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` — своему шагу |
2586
+ | глаголы | `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
2587
  | слоты | после глагола: `.Роль.set(значение)`, `.Роль.unset()`, `.Роль.add(значение)`, `.Роль.remove(значение)` |
1932
2588
  | терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions()`, `sum(поле)`, `avg(поле)`, `min(поле)`, `max(поле)`, `countBy(поле)` |
1933
2589
 
@@ -1943,9 +2599,9 @@ await svc.close() // и подключения, полученные чер
1943
2599
  | `issueOtp`, `verifyOtp` | `{ account, identifier, kind?, ttl?, digits?, maxAttempts? }` → `{ code }`; `{ identifier, code, kind? }` → вход или `null` |
1944
2600
  | `link`, `lookup` | `{ account, kind, identifier, meta?, confirmed? }` → id; `{ kind, identifier, requireConfirmed?, ttl?, totp? }` → вход или `null` |
1945
2601
  | `credentials`, `revokeCredential`, `setFlags` | `(account)` → креды без секретов; `(id, { keepCurrent? })`; `(id, { confirmed?, enabled? })` |
1946
- | `sessionFor`, `refresh`, `revoke`, `revokeAll` | `(account, { ttl? })` → токен; `({ ttl? })` → новое время истечения (токен прежний); `()`; `(account?)` → число сессий |
2602
+ | `sessionFor`, `refresh`, `revoke`, `revokeAll` | `(account, { ttl? })` → токен; `({ ttl? })` → новое время истечения (токен прежний); `()` — выход подключения, `(token)` — выход пользователя по токену его сессии (право `auth.session`) → `boolean`; `(account?)` → число сессий |
1947
2603
  | `session` | `(token)` → `{ account, tenant, member, expiresAt, loginAt, methods }` или `null` |
1948
- | `tenants`, `switch` | `()` → `[{ tenant, roles }]`; `(tenant)` |
2604
+ | `tenants`, `switch` | `()` → `[{ tenant, roles, name, personal }]` (первым — собственный, `personal: true`); `(tenant)` |
1949
2605
  | `purgeAccount` | `(account)` → `{ account, rows, versions, memberships, owned }` |
1950
2606
 
1951
2607
  Проверку паролей, кодов и TOTP, выпуск сессий, сведения о сессии по токену и подтверждение кредов вызывает сервис
@@ -1954,6 +2610,9 @@ await svc.close() // и подключения, полученные чер
1954
2610
  сервиса: свои ключи API и пары «ключ + секрет» сервис выпускает и отзывает сам по сессии своего ключа, прочие свои
1955
2611
  креды — нет; последний действующий ключ сервиса не отзывается никем (`acl_denied`). С `totp` вход `verifyPassword` и
1956
2612
  `lookup` проверяет ещё и код TOTP (право `auth.totp`) и выдаёт сессию со способом `TOTP`.
2613
+ Срок `ttl` (`sessionFor`, `verifyPassword`, `issueApiKey`, `issueOtp` и другие) — число **секунд** больше нуля, короткая
2614
+ строка как у `connect({ maintain })` (`'15m'`, `'2h'`, `'30s'`, `'1d'`) или интервал PostgreSQL (`'15 minutes'`); иное —
2615
+ `invalid_data` (`access.bad_ttl`). Без `ttl` — 7 суток. `maxAttempts` у `issueOtp` — целое от 1 до 10, иначе `invalid_data`.
1957
2616
  Вид креда `kind` — `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`, `TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`,
1958
2617
  `OTP`, `TOTP`, `KEY` и `LOCK` заняты. То же правило — у вида пароля `user.kind` в `createTenant` и
1959
2618
  `up({ tenant })`.
@@ -1968,7 +2627,8 @@ await svc.close() // и подключения, полученные чер
1968
2627
  | `at`, `author`, `agent`, `op`, `reason` | время версии (ISO, UTC, микросекунды), автор, агент (сервис при имперсонации), вид операции, причина |
1969
2628
  | `$deleted` | надгробие: удалённая сущность или версия удаления |
1970
2629
  | `$depth` | глубина узла в `deep()`; уровень каскада в превью удаления |
1971
- | `$upsert`, `$action` | что сделал `upsert`; что сделает удаление |
2630
+ | `$upsert`, `$action` | что сделал `upsert`; что сделает удаление (`delete`, `unset`, `restrict`); `skip` — цель `restore({ expect, partial: true })`, которую не восстановить |
2631
+ | `$why` | у строки `$action: 'skip'` — почему: `restored`, `reissued`, `changed` ([«Восстановить: `restore`»](#восстановить-restore)) |
1972
2632
 
1973
2633
  ### Зарезервированные имена
1974
2634
 
@@ -1985,6 +2645,13 @@ await svc.close() // и подключения, полученные чер
1985
2645
  `maintain`, `auth`, `acl`, `idOf`, `refresh`, `transaction`, `flushSignals`, `schema`, `token`, `tenant`, `cache`;
1986
2646
  - батч: `run`, `discard`, `size`.
1987
2647
 
2648
+ Имя роли конца одно на арендатора — это имя шага цепочки: во всех классах арендатора и System роль с одним именем
2649
+ ведёт на одни и те же классы. Поэтому заняты роли концов системных классов: `Group` и `Object` (класс `rule`) ведут на
2650
+ `Resource`, `User` и `Tenant` (класс `member`) — на `Account`. Конец с таким именем, который ведёт на другой класс,
2651
+ `syncModels` отклоняет до записи — `invalid_class` (`model.end_role_system`; роль другого своего класса —
2652
+ `model.end_role_taken`), а класс, заведённый мимо него, — база (`sql-write.end_other_class`). Имена классов `Group` и
2653
+ другие не заняты — заняты имена ролей: называйте роли иначе (`Team`, `Org`, `Holder`).
2654
+
1988
2655
  ## 15. Ошибки
1989
2656
 
1990
2657
  Текст ошибки — `letopis: <код>: <сообщение>`. Код — в `err.code`, подробности — в `err.detail`. Ошибки — объекты
@@ -1996,7 +2663,10 @@ await svc.close() // и подключения, полученные чер
1996
2663
  у мест библиотеки и у ошибок базы, которая прислала ключ в `DETAIL`.
1997
2664
 
1998
2665
  Тексты ошибок — только на русском. Ошибку базы с ключом библиотека отдаёт с тем же текстом, что получает и сырой SQL
1999
- мимо библиотеки, а ключ и параметры из `DETAIL` переносит в `err.key` и `err.params`.
2666
+ мимо библиотеки, а ключ и параметры из `DETAIL` переносит в `err.key` и `err.params`. У ошибки данных (`invalid_data`)
2667
+ текст ещё называет первое нарушение и их число: `letopis: invalid_data: данные не проходят описание класса Order:
2668
+ status — значение не из перечисления: допустимо "new", "paid", "cancelled" (всего нарушений: 2, все — err.issues)`.
2669
+ Сообщение нарушения перечисления называет допустимые значения, лишнего ключа — сам ключ (`лишний ключ colour`).
2000
2670
 
2001
2671
  ```ts
2002
2672
  const db = await connect({ dsn, schema: 'v2.shop', token })
@@ -2026,20 +2696,20 @@ try {
2026
2696
 
2027
2697
  | Код | SQLSTATE | Когда |
2028
2698
  |---|---|---|
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`) и неверное значение опции |
2699
+ | `no_session`<a id="no_session"></a> | LT001 | нет действующей сессии. У библиотеки ещё — `connect({ token })` с погашенным, истёкшим или неверным токеном: отклонённый промис, подключение не создаётся (`api.token_not_active`); то же — промисом терминала у шага, не найденного в реестре подключения по токену без арендатора сессии |
2700
+ | `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`) |
2701
+ | `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
2702
  | `undeclared_end`<a id="undeclared_end"></a> | LT004 | конец не объявлен в классе |
2033
2703
  | `tenant_denied`<a id="tenant_denied"></a> | LT005 | чужой арендатор или нет членства |
2034
2704
  | `acl_denied`<a id="acl_denied"></a> | LT006 | отказ по правилам доступа |
2035
2705
  | `id_mismatch`, `id_from_db`<a id="id_mismatch"></a><a id="id_from_db"></a> | LT007, LT008 | id не равен v5 от ключа; id класса без ключа выдаёт база |
2036
2706
  | `immutable_key`<a id="immutable_key"></a> | LT009 | ключевое поле, класс, id, арендатор и владелец не меняются: другой ключ или класс — новый объект |
2037
- | `invalid_class`<a id="invalid_class"></a> | LT010 | описание класса отклонено, класс не найден или абстрактный; класс со строками, потомками или историей строк не удаляется; хаб или связь с зарезервированным именем (хаб отклоняют ещё `hub()` и `syncModels`) |
2707
+ | `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
2708
  | `tightening_conflict`<a id="tightening_conflict"></a> | LT013 | ужесточению описания мешают строки |
2039
2709
  | `isolation_level`<a id="isolation_level"></a> | LT014 | удаление — только в `read committed` |
2040
2710
  | `recreated_in_statement`<a id="recreated_in_statement"></a> | LT015 | id удалён и создан заново одним оператором |
2041
2711
  | `delete_restricted`<a id="delete_restricted"></a> | LT017 | удалению мешают ссылающиеся строки (`onDelete: 'restrict'`) |
2042
- | `not_deleted`<a id="not_deleted"></a> | LT018 | `restore` — только для удалённых |
2712
+ | `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
2713
 
2044
2714
  Коды библиотеки:
2045
2715
 
@@ -2047,15 +2717,15 @@ try {
2047
2717
  |---|---|
2048
2718
  | `exists`<a id="exists"></a> | объект уже существует: `create` (`detail.id` — его id) или загрузчик |
2049
2719
  | `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`) |
2720
+ | `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
2721
  | `rev_ambiguous`<a id="rev_ambiguous"></a> | `{ rev }` у шага, у которого не одна цель |
2052
2722
  | `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` или глаголом |
2723
+ | `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
2724
  | `commit_unknown`<a id="commit_unknown"></a> | связь оборвалась во время `COMMIT`: исход неизвестен |
2055
2725
  | `tx_rolled_back`<a id="tx_rolled_back"></a> | PostgreSQL ответил `ROLLBACK` на `COMMIT`: ошибку перехватили внутри `tr.transaction(fn)` |
2056
2726
  | `no_key`<a id="no_key"></a> | `upsert` или `idOf` у класса без ключа |
2057
2727
  | `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 и т. п.) |
2728
+ | `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
2729
  | `admin_required`<a id="admin_required"></a> | загрузчику нужно администраторское подключение; роль строки `connect({ admin })` не может стать владельцем схемы; `up()` от роли без ADMIN OPTION на уже существующую роль владельца |
2060
2730
  | `not_empty`<a id="not_empty"></a> | первая заливка (`initial`) — только в пустую схему |
2061
2731
  | `class_not_loadable`<a id="class_not_loadable"></a> | строки `Class` загрузчик не принимает |
@@ -2066,6 +2736,74 @@ try {
2066
2736
 
2067
2737
  Отказ PostgreSQL в праве (`42501`) приходит, например, при `upsert` поверх чужой строки, которую нельзя править.
2068
2738
 
2739
+ **Отказы безопасности по умолчанию и неверного использования.** Ключи, которые приходят до записи и до запроса — их
2740
+ исправляют в коде, а не показывают пользователю:
2741
+
2742
+ | Ключ | Код | Когда | Что делать |
2743
+ |---|---|---|---|
2744
+ | `write.confirm_without_expect` | `invalid_query` | `delete({ confirm: true })` без `expect` и без `all: true` | показать превью `delete()` и удалить с ним: `delete({ confirm: true, expect: превью })`; без сверки — осознанно `{ confirm: true, all: true }` |
2745
+ | `write.restore_without_expect` | `invalid_query` | `restore()` без `expect` и без `all: true`, в том числе по явному id | `restore({ expect })` с ответом `delete()` или строками `withDeleted().rows()`; что можно — `restore({ expect, partial: true })`; всё без сверки — осознанно `restore({ all: true })` |
2746
+ | `api.create_tenant_admin` | `invalid_query` | `createTenant(admin, …)`: `admin` — строка DSN, подключение `connect()`, `undefined` или другой не пул | `const admin = postgres(ADMIN); await createTenant(admin, schema, opts); await admin.end()` |
2747
+ | `api.versions_terminal` | `invalid_query` | метод цепочки у результата `versions()`: `versions().rows()`, `.sort()`, `.limit()` | `await db.X(id).versions()`; модификаторы — до `versions()` |
2748
+ | `write.link_in_data` | `invalid_data` | имя конца (связи) в данных `create()`, `upsert()`, `update()` | конец — из пути или слотом после глагола: `.Роль.set(id)`, у списка — `.add(id)` / `.remove(id)`, снять — `.unset()` |
2749
+ | `sql-core.impersonation_no_account` | `target_not_found` | `svc.as(id)`: аккаунта нет — не заводили или удалён | id аккаунта — из входа, `createTenant()` или `auth.tenants()` |
2750
+
2751
+ **Текст для человека.** Штатные отказы — ответ пользователю, а не 500. Фразу выбирают по `err.key`, если он есть, иначе
2752
+ по `err.code`; `err.message` и `err.hint` — для разработчика и журнала:
2753
+
2754
+ | Код (ключ) | Пользователю |
2755
+ |---|---|
2756
+ | `exists` | «уже существует» / «уже занято» |
2757
+ | `conflict` | «данные изменил другой — обновите и повторите»; `write.preview_changed` — показать новое превью; `write.restore_stale` — «после удаления запись меняли» |
2758
+ | `not_deleted` | `write.restore_already` — «уже возвращено»; `write.restore_id_taken` — «место занято новой записью»; `write.not_deleted` — «не удалено» |
2759
+ | `target_not_found` | «не найдено: удалено или недоступно» |
2760
+ | `delete_restricted` | «нельзя удалить: на запись ссылаются» (строки — превью с `$action: 'restrict'`) |
2761
+ | `acl_denied`, `tenant_denied` | «недостаточно прав», «нет доступа к организации» |
2762
+ | `no_session` | «сессия истекла — войдите снова» |
2763
+ | `invalid_data` | по `err.issues`: своё название поля по `path`, фраза по `keyword` (ниже) |
2764
+
2765
+ **Значение не того типа (SQLSTATE `22P02`).** Неверный id в цепочке библиотека ловит сама, до запроса:
2766
+ `invalid_query`, ключ `query.bad_uuid`. Что дошло до базы — `'42'::uuid` в `db.sql`, `db.transaction(fn)` или
2767
+ `tr.sql`, — приходит `LetopisError` с кодом `invalid_query` и ключом `api.db_bad_value`; текст называет, какое
2768
+ значение не подошло, исходная ошибка PostgreSQL — в `err.cause`:
2769
+
2770
+ ```ts
2771
+ try {
2772
+ await db.sql`select ${'42'}::uuid`
2773
+ } catch (e) {
2774
+ // e instanceof LetopisError, e.code === 'invalid_query', e.key === 'api.db_bad_value'
2775
+ // e.message — 'letopis: invalid_query: значение не того типа: invalid input syntax for type uuid: "42"'
2776
+ // (e.cause as { code: string }).code === '22P02' — исходная ошибка PostgreSQL
2777
+ }
2778
+ ```
2779
+
2780
+ SQLSTATE, если он нужен, берут из `err.cause.code`.
2781
+
2782
+ **Текст для пользователя из `err.issues`.** У `ValidationError` каждое нарушение — `{ path, keyword, message }`:
2783
+ `path` — путь к полю (`[]` — данные целиком), `keyword` — правило JSON Schema (`type`, `required`, `minimum`, `enum`,
2784
+ `pattern`, `additionalProperties` …), `message` — русский текст для разработчика: у перечисления он называет
2785
+ допустимые значения, у лишнего ключа — сам ключ, у `minLength` и `maxLength` — предел («длина строки меньше 3», «длина
2786
+ строки больше 40»), у прочих правил бывает общим («нарушено правило minimum»). Пользователю
2787
+ показывают свои названия полей и свои фразы по `keyword`, а `message` — запасной текст:
2788
+
2789
+ ```ts
2790
+ import { ValidationError } from 'letopis'
2791
+
2792
+ const LABELS: Record<string, string> = { price: 'Цена', title: 'Название', sku: 'Артикул' }
2793
+ const RULES: Record<string, string> = { required: 'обязательно', minimum: 'слишком мало', type: 'неверный формат' }
2794
+ function explain(e: ValidationError): string[] {
2795
+ return e.issues.map((i) => {
2796
+ const field = i.path.length ? (LABELS[String(i.path[0])] ?? i.path.join('.')) : 'Данные'
2797
+ return `${field}: ${RULES[i.keyword] ?? i.message}` // «Цена: слишком мало»
2798
+ })
2799
+ }
2800
+ ```
2801
+
2802
+ **Предупреждения.** Часть ошибок использования — не отказ, а предупреждение в консоли: шаг с `undefined`, шаг после
2803
+ `sort()`, `withDeleted()` не у того шага ([«Строгий режим»](#строгий-режим)). При включённом журнале каждое
2804
+ предупреждение — ещё и событие `warning`, а каждая ошибка — событие `error` с кодом, ключом и цепочкой
2805
+ ([«Логирование»](#логирование)).
2806
+
2069
2807
  ## 16. Производительность
2070
2808
 
2071
2809
  Замеры — сценарии `lib/bench/` (как запускать, написано в шапке каждого файла), итоги — `lib/bench/results/`. Ниже —
@@ -2091,7 +2829,7 @@ try {
2091
2829
 
2092
2830
  ## 17. Ограничения
2093
2831
 
2094
- Здесь собрано то, что letopis 2.0 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
2832
+ Здесь собрано то, что letopis 3.0 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
2095
2833
 
2096
2834
  **Платформа**
2097
2835
 
@@ -2131,8 +2869,9 @@ try {
2131
2869
  - Владелец схемы и суперпользователь могут отключить триггеры и изменить что угодно, в том числе журнал: letopis
2132
2870
  этого не предотвращает и не обнаруживает. Журналу можно верить, пока роль владельца и суперпользователь — вне
2133
2871
  приложения.
2134
- - В базе проверяется подмножество JSON Schema: `refine`, `superRefine` и `transform` Zod работают только
2135
- в библиотеке, описание с неизвестным ключевым словом база не примет; `pattern` — ограниченное подмножество
2872
+ - В базе проверяется подмножество JSON Schema: `refine` и `superRefine` Zod база не исполняет (они в `lost` у
2873
+ `syncModels`), преобразования Zod (`.trim()`, `.transform()` …) `syncModels` отклоняет, описание с неизвестным
2874
+ ключевым словом база не примет; `pattern` — ограниченное подмножество
2136
2875
  регулярных выражений, общее для Zod, валидатора и базы. Вывод `z.toJSONSchema()` меняется между версиями Zod:
2137
2876
  в базе хранится уже нормализованная схема, а сухой прогон `syncModels` показывает разницу до применения.
2138
2877
  - Ключ — единственный способ уникальности: id вычисляется из значений ключа без нормализации, поэтому одно
@@ -2141,6 +2880,10 @@ try {
2141
2880
  - Ключ и класс объекта неизменны: смена значения ключа (email, телефон) — новый объект. Изменчивые и персональные
2142
2881
  значения лучше выносить в отдельный хаб со связью: id из email подбирается перебором и остаётся в ссылках и
2143
2882
  журнале, а версии журнала не стираются (кроме удаления аккаунта вместе с его арендатором).
2883
+ - `create()` по ключу удалённого объекта занимает его id: прежний объект после этого не восстановить
2884
+ (`restore({ expect })` — `not_deleted`, `write.restore_id_taken`, пока новый объект жив; когда удалён и он, —
2885
+ `conflict`, `write.restore_stale`). Что ключ принадлежит удалённому, показывает `withDeleted()` перед `create()`.
2886
+ - `restore()` не проверяет бизнес-правила приложения, только описание класса, концы, ключ и права.
2144
2887
  - `create` занятого ключа отвечает `exists` с id, `upsert` без права на существующую строку — отказом `42501`:
2145
2888
  так раскрывается, что ключ занят, но только внутри своего арендатора — у других арендаторов те же ключи дают
2146
2889
  другие id.
@@ -2148,8 +2891,8 @@ try {
2148
2891
  - id v5 ложатся в индекс первичного ключа вразброс; на очень больших таблицах с интенсивной вставкой это заметнее,
2149
2892
  чем с последовательными id.
2150
2893
  - Горячие цели ссылок (организация, на которую ссылается почти всё) копят блокировки `for key share` и MultiXact,
2151
- как при обычных внешних ключах; это нужно мониторить. Операции одного кошелька идут друг за другом под
2152
- блокировкой его строки ([раздел 8](#8-транзакции-батчи-и-деньги), «Рецепт: кошелёк»).
2894
+ как при обычных внешних ключах; это нужно мониторить. Приращения и правки одного объекта идут друг за другом под
2895
+ блокировкой его строки ([раздел 8](#8-транзакции-и-батчи), «Значение из прочитанного и повтор операции»).
2153
2896
  - Класс не может называться зарезервированным именем ([«Зарезервированные имена»](#зарезервированные-имена)): хаб
2154
2897
  отклоняют `hub()`, `syncModels` и база, связь — база. Хаб, заведённый мимо проверки базы, доступен не везде:
2155
2898
  например, `count` — только первым шагом.
@@ -2317,3 +3060,19 @@ try {
2317
3060
  # llms — один словарь фильтров (план 2.0, 7.9 №8): реестр операторов совпадает с фабриками пакета, с ключами letopis_query (тот же ответ, что у цепочки) и с таблицами README, шпаргалки и главы «Чтение» сайта
2318
3061
  # disconnect — обрыв соединения (план 2.0, 7.4 №7): загрузчик и первая заливка, открытие db.begin() — обрыв TCP до и после COMMIT и на открытии транзакции, pg_terminate_backend процесса, ждущего блокировку: промис отклонён ошибкой связи, необработанных ошибок в процессе нет, следующая операция пула проходит
2319
3062
  ```
3063
+
3064
+ ## 19. Лицензия
3065
+
3066
+ letopis распространяется под лицензией **PolyForm Noncommercial License 1.0.0** (SPDX `PolyForm-Noncommercial-1.0.0`):
3067
+ исходный код открыт, некоммерческое использование бесплатно, коммерческое — только по отдельной лицензии от
3068
+ владельца. Это не лицензия open source в смысле OSI. Полный текст — [LICENSE](LICENSE) (юридическую силу имеет он).
3069
+
3070
+ - **Без разрешения** — любые некоммерческие цели: личное использование для исследований, экспериментов, учёбы, хобби
3071
+ без коммерческого применения; использование благотворительными и некоммерческими организациями, учебными
3072
+ заведениями, государственными исследовательскими организациями и учреждениями. Код можно менять и распространять —
3073
+ с текстом лицензии и строкой `Required Notice: Copyright 2026 alepri51 (https://github.com/alepri51)`.
3074
+ - **По разрешению владельца** — коммерческое использование: платный продукт или сервис, внутренние системы
3075
+ коммерческой компании, разработка на заказ, встраивание в коммерческий продукт.
3076
+ - **Как получить разрешение** — issue в репозитории <https://github.com/alepri51/letopis/issues> с заголовком
3077
+ «Коммерческая лицензия»; владелец — <https://github.com/alepri51>. Подробнее — [LICENSE-COMMERCIAL.md](LICENSE-COMMERCIAL.md).
3078
+ - Версии до 2.0.0 включительно выпущены под MIT и остаются под ней; PolyForm Noncommercial действует с 2.1.0.