letopis 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/AGENT-CHEATSHEET.md +484 -414
  2. package/CHANGELOG.md +321 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +786 -183
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +3 -0
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +2 -1
  10. package/dist/admin.js +6 -4
  11. package/dist/auth.d.ts +18 -3
  12. package/dist/auth.js +75 -6
  13. package/dist/chain.d.ts +38 -17
  14. package/dist/chain.js +434 -63
  15. package/dist/cli.js +28 -6
  16. package/dist/errors.d.ts +19 -1
  17. package/dist/errors.js +46 -6
  18. package/dist/index.d.ts +10 -4
  19. package/dist/index.js +136 -46
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +9 -5
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +633 -0
  25. package/dist/mcp.js +26 -11
  26. package/dist/messages/api.d.ts +66 -0
  27. package/dist/messages/api.js +90 -1
  28. package/dist/messages/boot.js +4 -4
  29. package/dist/messages/codes.js +4 -4
  30. package/dist/messages/index.d.ts +5 -2
  31. package/dist/messages/index.js +5 -2
  32. package/dist/messages/log.d.ts +229 -0
  33. package/dist/messages/log.js +102 -0
  34. package/dist/messages/model.d.ts +24 -1
  35. package/dist/messages/model.js +34 -3
  36. package/dist/messages/query.d.ts +10 -0
  37. package/dist/messages/query.js +13 -0
  38. package/dist/messages/sql-core.d.ts +15 -1
  39. package/dist/messages/sql-core.js +20 -3
  40. package/dist/messages/sql-time.d.ts +15 -0
  41. package/dist/messages/sql-time.js +23 -0
  42. package/dist/messages/write.d.ts +47 -0
  43. package/dist/messages/write.js +63 -0
  44. package/dist/model.d.ts +4 -0
  45. package/dist/model.js +23 -5
  46. package/dist/sql.d.ts +2 -0
  47. package/dist/sql.js +77 -25
  48. package/dist/sync.d.ts +1 -1
  49. package/dist/sync.js +50 -0
  50. package/dist/tx.d.ts +10 -1
  51. package/dist/tx.js +44 -2
  52. package/dist/typed.d.ts +14 -10
  53. package/dist/types.d.ts +82 -4
  54. package/dist/types.js +6 -1
  55. package/dist/up.d.ts +3 -3
  56. package/dist/up.js +57 -17
  57. package/dist/uuid.d.ts +8 -0
  58. package/dist/uuid.js +9 -1
  59. package/dist/validate.js +24 -7
  60. package/dist/watch.d.ts +5 -0
  61. package/dist/watch.js +13 -0
  62. package/dist/write.d.ts +78 -1
  63. package/dist/write.js +357 -39
  64. package/llms.txt +37 -17
  65. package/package.json +12 -6
  66. package/sql/20-context.sql +11 -3
  67. package/sql/30-validate.sql +51 -10
  68. package/sql/40-class.sql +21 -1
  69. package/sql/80-auth.sql +88 -17
  70. package/sql/90-time.sql +34 -6
  71. package/sql/99-revision.sql +5 -1
  72. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  73. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  74. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  75. package/sql/migrate/README.md +5 -1
@@ -1,246 +1,432 @@
1
- # letopis 2.0 — шпаргалка для агента
1
+ # letopis 3.0 — памятка для агента
2
+
3
+ - Для ИИ-агента, который пишет код поверх пакета; лежит в пакете рядом с [README.md](README.md). Здесь — общие правила
4
+ для любой задачи и рабочий цикл; подробности и справочник — README.
5
+ - Примеры — на абстрактных классах `Team`, `Item`, `Op`, `Note`, `Obj`, `Peer`, `Hold`: подставьте классы своей модели.
6
+ Имена ролей концов `Group`, `Object`, `User`, `Tenant` заняты системными классами — [Грабли 9](#грабли).
7
+ - Блоки с пометкой `<!-- run -->` по порядку — одна программа ([«Запуск»](#запуск)). Перед выпуском 3.0.0 её прогнали
8
+ вручную на чистой базе PostgreSQL 18, дважды подряд; автоматически памятку сейчас не сверяет ни один тест. Разошлась с
9
+ поведением пакета — верен код, затем README.
10
+ - Машинный каталог API и ошибок — `api-contract.json` в пакете (`node_modules/letopis/api-contract.json`).
11
+ - Правила записаны строками «условие → поведение»; каждое правило — в одном месте, остальные места на него ссылаются.
12
+ - Лицензия — PolyForm Noncommercial 1.0.0 ([LICENSE](LICENSE)): некоммерческое использование бесплатно; код для
13
+ коммерческого продукта, сервиса или компании → нужна коммерческая лицензия владельца
14
+ ([LICENSE-COMMERCIAL.md](LICENSE-COMMERCIAL.md), запрос — issue на https://github.com/alepri51/letopis/issues).
15
+ Пишете код для пользователя — скажите ему об этом.
16
+
17
+ ## Запуск
2
18
 
3
- - Одна страница вместо [README.md](README.md) — для ИИ-агента, который пишет код поверх пакета; лежит в пакете рядом
4
- с README.
5
- - Машинный каталог API и ошибок — `docs/api-contract.json` в репозитории.
6
- - Примеры ниже идут по порядку и образуют одну программу; тест `docs` исполняет её против PostgreSQL 18, так что код
7
- сверен с пакетом.
8
- - Правила записаны строками «условие → поведение».
19
+ ```sh
20
+ npm init -y && npm pkg set type=module # код приложения — ESM, иначе import → SyntaxError
21
+ npm i letopis zod postgres@3 # postgres@3 — та же мажорная версия, что у letopis (пул для createTenant)
22
+ npm i -D tsx
23
+ ADMIN_DSN='postgres://postgres:…@localhost:5432/app' SERVICE_KEY=lts_… APP_PASSWORD=… USER_PASSWORD=… npx tsx app.ts
24
+ ```
25
+ - PostgreSQL 18+ (расширение pgcrypto из стандартной поставки ставит `up()`), база в кодировке UTF8 (нет базы —
26
+ `up()` создаст её), Node 22+.
27
+ - `ADMIN_DSN` — суперпользователь; без него — роль с `CREATEROLE`, правом `CREATE` в базе (схема, pgcrypto) и
28
+ `CREATEDB`, если базы ещё нет.
29
+ - `SERVICE_KEY` — свой ключ сервиса: `node -e "console.log('lts_' + crypto.randomBytes(16).toString('hex'))"`. С ним
30
+ программа повторяема: повторный `up()` ключ не выдаёт, а тот же ключ уже зарегистрирован.
31
+ - `postgres` без версии ставит 4.x: код работает, но `createTenant(admin, …)` не проходит `tsc` — типы двух копий
32
+ `postgres` несовместимы.
33
+
34
+ ## Правила для любой задачи
35
+
36
+ ### 0. Безопасность по умолчанию
37
+
38
+ - `delete({ confirm: true })` без `expect` → `invalid_query` (`write.confirm_without_expect`); `restore()` без `expect`,
39
+ и по явному id тоже, → `invalid_query` (`write.restore_without_expect`). Ничего не изменено; типы `DeleteOptions` и
40
+ `RestoreOptions` ловят то же при компиляции.
41
+ - `update()` и `delete({ confirm: true, … })` без фильтра, id, `tags()`, `owner()` и `limit()` у шагов →
42
+ `invalid_query` (`write.no_filter`).
43
+ - `{ all: true }` (`update`, `delete({ confirm: true, all: true })`, `restore({ all: true })`) → все цели без сверки
44
+ с тем, что видел человек: только в осознанных скриптах (очистка, обслуживание), не в ответ на действие пользователя.
45
+ `all` вместе с `expect` → сверка есть.
46
+ - Права по умолчанию **запрещают**; строки чужого арендатора не видны. Подключение ролью в обход RLS → `bypass_rls`,
47
+ пока не передано `allowBypassRls: true`.
48
+ - Арендатор → только из серверной сессии (правило 5). Что журнал маскирует, а что нет, — правило 8.
49
+
50
+ ### 1. Уникальность и инварианты держит ключ, а не проверка в коде
51
+
52
+ - Класс с ключом → второй объект с тем же ключом база не заведёт: `create()` → `exists` (`err.detail.id`), и под
53
+ гонкой тоже. «Прочитать, нет ли такого, потом создать» гонку не держит.
54
+ - id класса с ключом — UUID v5 от ключа в арендаторе: `db.idOf('Team', { name: 'g1' })`; часть ключа — конец →
55
+ имя роли и id цели строкой: `db.idOf('Hold', { Obj: obj.id })`.
56
+ - Инвариант «у объекта не больше одной открытой записи» (занятость, назначение, текущий статус) → связь с **ключом по
57
+ концу**. Пример — иллюстрация, классы свои:
9
58
 
10
- ## Модель за минуту
59
+ <!-- run -->
60
+ ```ts
61
+ import { z } from 'zod'
62
+ import { hub, link, one } from 'letopis/model'
63
+ import { LetopisError, ValidationError, type Db, type Row } from 'letopis'
64
+ export const Obj = hub('Obj', { code: z.string() }, { key: ['code'] })
65
+ export const Peer = hub('Peer', { name: z.string() }, { key: ['name'] })
66
+ // ключ — только конец Obj: у объекта одна открытая Hold; удалили Peer — его Hold уходят каскадом
67
+ export const Hold = link('Hold', { since: z.string() },
68
+ { key: ['Obj'], ends: { Obj, Peer: one(Peer).onDelete('cascade') } })
69
+
70
+ async function open(db: Db, obj: string, peer: string): Promise<Row | null> {
71
+ try {
72
+ return await db.Obj(obj).Hold().create({ since: new Date().toISOString() }).Peer.set(peer).first()
73
+ } catch (e) {
74
+ if (e instanceof LetopisError && e.code === 'exists') return null // «объект уже занят» — ответ, а не 500
75
+ throw e
76
+ }
77
+ }
78
+ ```
79
+ - Закрыть запись → удалить связь по превью (правило 2); следующая запись того же объекта займёт тот же id, история
80
+ объекта — версии одного id.
81
+ - `create()` по ключу удалённого объекта → **занимает его id**: прежний уже не вернуть (`write.restore_id_taken`).
82
+ Удалённое могут вернуть → перед `create()` проверьте `db.Класс({ ключ }).withDeleted().first()` (`$deleted: true`).
83
+ - `restore()` бизнес-правил не проверяет (только описание класса, концы, ключ и права), поэтому инвариант между
84
+ объектами при возврате держит тоже только ключ: вернуть прежнюю `Hold` к объекту, у которого уже новая, база не даст
85
+ (`not_deleted`, `write.restore_id_taken`). Инвариант без ключа → во **всех** путях создания и возврата в `db.begin()`
86
+ одна и та же блокировка (`forUpdate()` объекта или `tr.lock(…)`), затем та же проверка. Один путь без неё (обычный
87
+ `create()` связи) ломает гарантию: `create()` и `restore()` берут на объекте совместимую блокировку и друг друга не
88
+ ждут.
89
+
90
+ ### 2. Удаление — только по превью
91
+
92
+ `delete()` → показать человеку → `delete({ confirm: true, expect: превью })`. Удаляется **ровно показанное**, атомарно:
93
+ превью пересчитывается под блокировкой всего каскада; расхождение → `conflict` (`write.preview_changed`), ничего не
94
+ удалено, новое превью — `err.detail.preview`.
95
+
96
+ <!-- run -->
97
+ ```ts
98
+ async function remove(db: Db, peer: string, shown: Row[]) {
99
+ try {
100
+ return { gone: await db.Peer(peer).delete({ confirm: true, expect: shown, reason: `удаление ${peer}` }).rows() }
101
+ } catch (e) {
102
+ if (!(e instanceof LetopisError) || e.code !== 'conflict') throw e
103
+ return { again: (e.detail as { preview: Row[] }).preview } // [] — уже удалено; иначе показать новое превью
104
+ }
105
+ }
106
+ ```
107
+ - Превью — строки ответа и `$action`: `'delete'` (уйдёт; `$depth` — уровень каскада, 0 — цель), `'unset'` (конец
108
+ снимется), `'restrict'` (мешает → удаление даст `delete_restricted`). Человеку — `shown.filter((x) => x.$action ===
109
+ 'delete')` по `class`.
110
+ - Ответ — надгробия **всего каскада** (`$deleted: true`): **сохраните** его — по нему возвращают (правило 3).
111
+ - Целей нет (удалены, чужие, не видны, id не существует):
112
+ - `expect` непуст → `conflict` (`write.preview_changed`) с `err.detail.preview: []` — «уже удалено или не найдено»;
113
+ - `expect: []` → пустой ответ без ошибки.
114
+ - `expect` — строки превью как есть (`{ id, rev, $action? }`, порядок не важен); `expect` без `confirm` →
115
+ `write.expect_without_confirm`, не список `{ id, rev }` → `write.bad_expect` (оба `invalid_query`).
116
+ - Связь к удаляемой строке, добавленная во время удаления, ждёт его и получает `target_not_found`
117
+ (`sql-write.end_target_missing`).
118
+
119
+ ### 3. Возврат — по ответу `delete()`, одной транзакцией
120
+
121
+ `restore({ expect: ответ delete() })` → всё или отказ; `restore({ expect, partial: true })` → что можно, остальное —
122
+ строки-пропуски. `restore()` возвращает только **цели шага**, не каскад: цель конца — первой, каскад — следующим шагом,
123
+ всё — в одной `db.begin()` с `commit()`/`rollback()`, чтобы ошибка второго шага не оставила половину.
124
+
125
+ <!-- run -->
126
+ ```ts
127
+ const WHY = { reissued: 'id занят новой записью', changed: 'после удаления запись меняли', restored: 'уже возвращено' } as const
128
+ async function bringBack(db: Db, gone: Row[]) {
129
+ const ids = (cls: string) => gone.filter((x) => x.class === cls).map((x) => x.id)
130
+ const tr = await db.begin({ reason: 'возврат' })
131
+ try {
132
+ await tr.Peer(ids('Peer')).restore({ expect: gone }).rows() // цель конца — целиком или отказ
133
+ const holds = ids('Hold').length ? await tr.Hold(ids('Hold')).restore({ expect: gone, partial: true }).rows() : []
134
+ await tr.commit()
135
+ return {
136
+ back: holds.filter((r) => r.$action !== 'skip').map((r) => r.id),
137
+ skipped: holds.filter((r) => r.$action === 'skip').map((r) => ({ id: r.id, why: WHY[r.$why!] })), // человеку
138
+ }
139
+ } catch (e) {
140
+ await tr.rollback()
141
+ throw e
142
+ }
143
+ }
144
+ ```
145
+ - Шаг глагола — по id из ответа (`ids('Hold')`); id из `expect`, которых нет среди целей шага, пропускаются — весь
146
+ ответ можно передавать каждому шагу. Путь по связям (`db.Peer(p).withDeleted().Hold().withDeleted()`) теряет живые
147
+ пропуски: запись, чей id занят новой, от прежнего конца уже не находится.
148
+ - Ответ `delete()` не сохранили → надгробия по id: `db.Hold(id).withDeleted().rows()` → в `expect`.
149
+ - Что стало с id после удаления → пропуск `$why` (с `partial`) или отказ без него:
150
+
151
+ | После удаления | `$why` | Отказ (`err.key`) | Код |
152
+ |---|---|---|---|
153
+ | уже восстановлен | `'restored'` | `write.restore_already` | `not_deleted` |
154
+ | занят новым живым объектом с тем же ключом | `'reissued'` | `write.restore_id_taken` | `not_deleted` |
155
+ | пересоздан и снова удалён, удалён снова, гонка | `'changed'` | `write.restore_stale` | `conflict` |
156
+
157
+ Подробности отказа — `err.detail.stale: [{ id, rev, expected, after, deleted, live, why }]`. Живой объект →
158
+ `not_deleted` (`write.not_deleted`); цель конца удалена → `target_not_found` (верните сначала её); чужой или
159
+ несуществующий id → пустой ответ.
160
+ - Пропуск — последняя строка цели (`$action: 'skip'`): по ней не восстанавливают; покажите человеку, что не вернулось
161
+ и почему. Ошибки другого рода (`target_not_found`, `invalid_data`, `acl_denied`) роняют и `partial`.
162
+
163
+ ### 4. Гонка за прочитанное → `forUpdate()`, `{ rev }`, `inc()` или ключ
164
+
165
+ - Новое значение из прочитанного → `forUpdate()` в `db.begin()` (только с `rows()`, `first()`, `ids()`) и правка
166
+ отдельной цепочкой; без транзакции → `update(патч, { rev: прочитанная.rev })`: чужая правка → `conflict`, перечитать и
167
+ решить заново.
168
+ - Приращение → `update({ n: inc(k) })`: база прибавляет внутри оператора, без чтения; уход ниже `minimum` схемы →
169
+ `invalid_data`. Идемпотентность операции → ключ (`[Item, opId]`: повтор → `exists`).
170
+ - Фильтр `update()` по данным, концам (и фильтру родительского шага), `tags()` или `owner()` → перепроверяется под
171
+ блокировкой строки: строку, которую параллельная транзакция вывела из-под фильтра, `update()` молча не правит. Два
172
+ одновременных `db.X({ id, status: 'new' }).update({ status: 'paid' })` → проходит один, второй получает `[]`.
173
+ - Фильтр только по id → перепроверять нечего: обе правки пройдут, вторая поверх первой. Значение, вычисленное из
174
+ прочитанного, защищают только `{ rev }` или `forUpdate()`.
175
+ - Затронуто меньше строк, чем нашёл шаг, → откат всего плана: `target_not_found` (строку удалили), `conflict` (`rev`
176
+ разошёлся) или `acl_denied`.
177
+ - Что получает проигравший и что сказать человеку:
178
+
179
+ | Проигравший получает | Когда | Человеку |
180
+ |---|---|---|
181
+ | `exists` | `create()` по занятому ключу | «уже занято / уже создано» |
182
+ | `conflict` (`write.rev_conflict`) | `update(…, { rev })`: строку успели изменить | «данные изменил другой — обновите и повторите» |
183
+ | `conflict` (`write.preview_changed`) | `delete({ confirm: true, expect })`: состав изменился | показать новое превью или «уже удалено» |
184
+ | `target_not_found` | строку удалили между чтением и правкой | «запись удалена другим — обновите страницу» |
185
+ | `not_deleted` (`write.restore_already`) | два `restore()` одного надгробия | «уже возвращено» |
186
+ | `40P01` в `db.begin()` | взаимная блокировка | повторить весь блок (операции из пула повторяет библиотека) |
187
+ | `commit_unknown` | связь оборвалась во время `COMMIT` | повторить с тем же ключом: `exists` = «уже записано» |
188
+
189
+ ### 5. Арендатор и пользователь — только из серверной сессии
190
+
191
+ - Одно подключение сервиса на процесс (`connect({ apiKey })`) и `svc.as(account, { tenant })` на запрос — тот же пул;
192
+ в журнале `author` — пользователь, `agent` — сервис. `connect({ token })` на каждый вход исчерпает соединения:
193
+ у каждого `connect()` свой пул (`max` 10).
194
+ - Арендатор → из конфигурации сервера или выбора, сохранённого на сервере при входе; **никогда** из параметра клиента
195
+ (`?tenant=`, тело, заголовок).
196
+ - В начале каждого запроса → `svc.auth.session(token)` (токен из cookie): `null` → 401. По погашенной сессии чтение
197
+ ошибки не даёт, а отдаёт пустые ответы; запись → `no_session`.
198
+ - Без `{ tenant }` у `as()` (и без `auth.switch()` у подключения по токену) → сессия в **личном** арендаторе
199
+ пользователя: классов там нет, `db.Item()` → `invalid_class`. Проверка — `db.tenant`.
200
+ - Нет членства → `tenant_denied` (403); `svc.as()` несуществующего аккаунта → `target_not_found`
201
+ (`sql-core.impersonation_no_account`). Выход → `svc.auth.revoke(token)` (без аргумента погасил бы сессию сервиса).
11
202
 
12
- - PostgreSQL 18+, Node 22+. Три таблицы схемы установки `v2.<имя>`: `entity` (текущая версия каждой сущности),
13
- `log` (журнал всех версий), `secret` (креды и сессии). Классы, аккаунты, права — тоже строки `entity`.
14
- - Проверки делает **база**: триггеры (данные по JSON Schema, ссылки, ключи, журнал) и политики RLS (арендатор и
15
- права). Сырой SQL от прикладной роли → ведёт себя так же, как библиотека.
16
- - Хаб (`hub`) — сущность, связь (`link`) — сущность с концами. Концы лежат в `row.links`, поля — в `row.data`.
17
- - id:
18
- - класс с ключом → v5 от (класс, значения ключа) в арендаторе, его можно вычислить заранее (`db.idOf`);
19
- - класс без ключа → id выдаёт база;
20
- - уникальность — только ключ.
21
- - Арендатор = аккаунт. Строки принадлежат арендатору **сессии**; чужие → не видны.
22
- - Права включены всегда и по умолчанию **запрещают**.
23
- - Строгий режим 2.0: строгие типы по моделям (`connect({ models })`, имена во время выполнения — `loose(db)`);
24
- `update()` и `delete({ confirm: true })` без сужения целей и `after()` перед `count()` и агрегатами → `invalid_query`;
25
- неизвестное поле фильтра, `sort()` или агрегата → `invalid_query` с похожими именами; неизвестный класс или роль →
26
- `invalid_class` с похожими; неизвестная опция → `invalid_data` с похожими именами.
203
+ ```ts
204
+ import { readFileSync } from 'node:fs'
205
+ const svc = await connect({ dsn: APP, schema: 'v2.myapp',
206
+ apiKey: readFileSync('/etc/myapp/service.key', 'utf8').trim() }) // один раз на процесс
207
+ const HOME = process.env.APP_TENANT! // арендатор — с сервера, не из запроса
208
+ async function userDb(token: string | undefined) { // в начале каждого запроса
209
+ const s = token ? await svc.auth.session(token) : null
210
+ if (!s) return null // 401: войти
211
+ return svc.as(s.account, { tenant: HOME }) // tenant_denied → 403
212
+ }
213
+ const esc = (s: string) => s.replace(/[&<>"']/g, (c) =>
214
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
215
+ // `<li data-id="${esc(row.id)}">${esc(String(row.data.code))}</li>`; обработчик — на список, не onclick из данных
216
+ ```
217
+ - Ключ сервиса → файл с правами только владельца или хранилище секретов, не в репозиторий и не клиенту.
27
218
 
28
- ## Рабочий цикл
219
+ ### 6. Ошибки → текст для человека
29
220
 
30
- ### 1. Установка: `up()` и роль приложения
221
+ - Ошибка — `LetopisError`: `err.code` (строчный код), `err.key` (ключ каталога, точнее кода), `err.params`,
222
+ `err.detail` (`preview`, `stale`, `ids`, `id`), `err.hint` и `err.message` — разработчику и в журнал. Ошибка данных —
223
+ `ValidationError` с `err.issues: [{ path, keyword, message }]`.
224
+ - Штатные отказы — ответ пользователю, а не 500: `exists`, `conflict`, `not_deleted`, `target_not_found`,
225
+ `delete_restricted`, `tenant_denied`, `acl_denied`, `no_session`, `invalid_data` с `err.issues`. Ошибки кода
226
+ (`invalid_query`, `invalid_class`, `no_key`, `write.link_in_data` и другие) → 500, журнал и общая фраза.
227
+ - Пустой ответ без ошибки → «не найдено»: `update()` по несуществующему, чужому или невидимому id, `restore({ expect })`
228
+ по такому id, `delete({ confirm: true, expect: [] })` без целей (непустой `expect` — правило 2).
31
229
 
32
230
  <!-- run -->
33
231
  ```ts
34
- import { up, connect, syncModels, inc, gt, or, cursorOf } from 'letopis'
35
-
36
- const ADMIN = 'postgres://postgres:pass@localhost:5432/app' // суперпользователь или роль с CREATEROLE
37
- const r = await up({ dsn: ADMIN, schema: 'myapp', // схема 'v2.myapp'
38
- tenant: { name: 'Салон', user: { name: 'Ольга', login: 'olga@salon.ru', password: 'Сирень-2026!' } } })
39
- // r: { schema, created, upgraded, revision, serviceKey, migrations, analyzed, tenant, … }
40
- // r.serviceKey ('lts_…') — ключ сервиса, выдаётся при первой установке (снова — только если в установке не осталось
41
- // ключей API, кроме отозванных). Сохраните его.
42
- // r.tenant: { tenant, user, token, created } — арендатор «Салон» и его первый пользователь Ольга
232
+ const BY_KEY: Record<string, string> = {
233
+ 'write.preview_changed': 'Данные изменились — проверьте список ещё раз',
234
+ 'write.restore_already': 'Уже возвращено',
235
+ 'write.restore_id_taken': 'Не вернуть: место занято новой записью',
236
+ 'write.restore_stale': 'Не вернуть: после удаления запись меняли',
237
+ }
238
+ const BY_CODE: Record<string, string> = {
239
+ exists: 'Уже существует', conflict: 'Данные изменил другой пользователь — обновите и повторите',
240
+ target_not_found: 'Не найдено: запись удалена или недоступна', not_deleted: 'Запись не удалена',
241
+ delete_restricted: 'Нельзя удалить: на запись ссылаются другие', acl_denied: 'Недостаточно прав',
242
+ tenant_denied: 'Нет доступа к этой организации', no_session: 'Сессия истекла — войдите снова',
243
+ }
244
+ const FIELDS: Record<string, string> = { code: 'Код', n: 'Количество' }
245
+ const RULES: Record<string, string> = { required: 'обязательно', minimum: 'слишком мало', type: 'неверный формат' }
246
+ function toHuman(e: unknown): string[] {
247
+ if (e instanceof ValidationError)
248
+ return e.issues.map((i) => `${FIELDS[String(i.path[0])] ?? (i.path.join('.') || 'Данные')}: ${RULES[i.keyword] ?? i.message}`)
249
+ if (e instanceof LetopisError) return [BY_KEY[e.key ?? ''] ?? BY_CODE[e.code] ?? 'Не удалось выполнить действие']
250
+ throw e // не ошибка letopis — 500
251
+ }
43
252
  ```
44
- - Свой ключ сервиса → `up({ serviceKey })`: `lts_` и 32–128 шестнадцатеричных знаков в нижнем регистре
45
- (`/^lts_[0-9a-f]{32,128}$/`), иначе `invalid_data`; ключ зарегистрирован этим вызовом → он в `r.serviceKey`, уже
46
- был → `null`.
47
- - `up()` → ждёт базу, создаёт её, проверяет PostgreSQL 18+ и UTF8, ставит роли `letopis_owner` и `letopis_app`.
48
- - Опции `up()`:
49
- - `fresh` → пересоздать схему;
50
- - `upgrade` → обновить схему отставшей ревизии (миграции и повторный накат);
51
- - `seeds` → имя сида пакета (например, `'booking'` — демо-домен) или путь к своему `.sql`;
52
- - ещё `serviceKey`, `quiet`, `waitTimeoutMs`, `owner`, `app`, `migrationsDir`, `sql` (готовый пул вместо `dsn`);
53
- - `pglite: true` → база для разработки без PostgreSQL; затем `connect({ dsn: r.pglite.dsn, listen: false, max: 1,
54
- allowBypassRls: true, … })` — подключение суперпользователя, RLS на нём не действует.
55
- - `signals` — что видно в сигналах `NOTIFY` любой роли, способной подключиться к базе. Канал — полное имя схемы
56
- (`LISTEN "v2.myapp"`). Нагрузка сигнала записи:
57
- - `'class'` (по умолчанию) → `Класс`;
58
- - `'tenant'` → `Класс@токен арендатора` (кэш сбрасывается только в арендаторе записи);
59
- - `'opaque'` → `токен класса@токен арендатора`;
60
- - `'none'` → сигналов нет (кэш — только `ttlOnly`, подписка опрашивает журнал);
61
- - служебный сигнал: `session:<16 hex>` → погашенная сессия;
62
- - режим хранится в схеме: `up()` без опции → его не меняет; после смены → перезапуск процессов;
63
- - ключ сигналов (точный сброс кэша) получают сервисы — право `letopis.signals`;
64
- - результат `up()`: ещё `signals` и `warnings` (PostgreSQL ниже 18.2 → обновить).
65
- - Пакет обновили → вызывайте `up()`: функции схемы накатывает только он; новую ревизию — `up({ upgrade: true })`.
66
- - Штамп движка в комментарии схемы (версия пакета и хэш его SQL) не совпадает с пакетом → `connect()` предупреждает:
67
- схема старее → `up()`, новее → обновить пакет.
68
- - `tenant` — первый арендатор и пользователь без своего сида:
69
- - арендатор → получает правило «аутентифицированным можно всё» (`grantAll`, по умолчанию да, вес 10);
70
- - пользователь → получает членство с ролями `user.roles` (по умолчанию `['owner']`), пароль для входа по логину
71
- `login` и сессию в арендаторе;
72
- - повтор с тем же именем и логином → ничего не дублирует и роли существующего членства не меняет (id — v5 от
73
- «tenant:<имя>» и «user:<логин>»);
74
- - ещё арендатор → `createTenant(postgres(ADMIN), 'v2.myapp', { name, user })` (нужно администраторское подключение).
75
- - Приложение подключается **прикладной LOGIN-ролью**, которая состоит только в `letopis_app`:
76
- - завести её → `up({ login: { user, password } })` (строка подключения — `r.login.dsn`; привилегированная роль →
77
- отказ `invalid_data`) или вручную: `create role myapp login password '…'; grant letopis_app to myapp;`;
78
- - подключение суперпользователем → ошибка `bypass_rls` (RLS не действовал бы);
79
- - администраторское подключение → только явно, `allowBypassRls: true`;
80
- - один процесс: `connect({ dsn: r.login.dsn, admin: ADMIN, token })` → `load`, `maintain`, `syncModels`
81
- идут вторым пулом с той же сессией, цепочки — под RLS.
82
- - Аккаунты живут в арендаторе System:
83
- - создаёт их системный администратор (член System с ролью `admin`; назначает его свой сид установки или загрузчик
84
- на администраторском подключении) цепочкой `admin.Account().create({ name, categories })`;
85
- - особые случаи → свой сид (`seeds: ['./my.sql']`, функции владельца `sys_create`, `sys_grant_all`, маркеры
86
- `@SCHEMA@`, `@SYSTEM@`).
87
-
88
- ### 2. Вход: сервис, пароль, сессия
253
+
254
+ ### 7. `undefined` и `null`
255
+
256
+ - `update()`: ключ со значением `undefined` отбрасывается → поле **не меняется**; очистить поле → `null` (только у поля
257
+ `.nullable()`, иначе `invalid_data`).
258
+ - `upsert()` заменяет данные **целиком**: поле с `undefined` (как и не переданное) после него **исчезнет**. Сохранить
259
+ прежнее значение → `update()`, а не `upsert()`.
260
+ - Из формы и JSON пустое значение приводите к `null` сами: `{ note: body.note ?? null }`.
261
+ - Фильтр шага `undefined` → шаг **без фильтра** (`db.Item(undefined).first()` — любая строка) и предупреждение
262
+ `api.step_undefined_filter`; ключ `undefined` в объекте фильтра отбрасывается. Обязательные id из запроса проверяйте
263
+ до цепочки. id не uuid или `null` → `invalid_query` (`query.bad_uuid`) до запроса.
264
+
265
+ ### 8. Журнал: что он доказывает и что скрывает
266
+
267
+ - Включить → `connect({ log, logLevel, redact })` и `up({ … })`: `log` — функция `(event) => void` или логгер
268
+ (`console`, pino); `logLevel` — `'debug' | 'info' | 'warn' | 'error' | 'off'` (по умолчанию `'info'`). Без кода →
269
+ `LETOPIS_LOG=info` (JSON-строки в stderr); CLI → `--log[=уровень]`. Список событий и полей — README, «Логирование».
270
+ - Событие `delete` (`detail.expect` — сколько строк сверено, `false` — без сверки; `rows`) и `restore` (`expect`,
271
+ `partial`, `skipped: [{ id, why }]`) — что удалено и что не вернулось.
272
+ - Вне `db.begin()` событие записи пишется после фиксации. Внутри `db.begin()` у события есть `tx`, и оно значит
273
+ «выполнено в транзакции», а не «зафиксировано»: `begin → delete → rollback` оставит событие `delete`, а строки —
274
+ на месте. Фиксацию доказывает `tx.commit` с тем же `tx` (уровень info, `detail.writes` — сколько было записей);
275
+ `tx.rollback` с ним — запись отменена; `commit_unknown` — исход неизвестен.
276
+ - Штатные отказы (правило 6, в том числе `invalid_data` с `err.issues`) пишутся событием `error` уровня `warn`;
277
+ уровень `error` — у ошибок кода, базы и связи. Тревогу ставьте на уровень, разбор — по `code` и `key`.
278
+ - Не пишутся никогда: значения параметров SQL; текст, `DETAIL`, `WHERE`, `HINT` ошибки PostgreSQL (в событии — только
279
+ SQLSTATE и имена ограничения, таблицы, колонки); значения полей `data` строк в `detail` ошибки (`***`); значение
280
+ аргумента в тексте ошибки letopis (`***`).
281
+ - Маскируются (`***`): поля с именами паролей, токенов, ключей, секретов, кредов, `cookie` и поля из `redact` — на
282
+ любой глубине; учётные данные в строке подключения (`postgres://***@host/db`); пароль в тексте SQL.
283
+ - Пишутся как есть: значения **фильтров** в `chain` (`Item({"code":"i1"})`; данные записи — только именами полей),
284
+ тексты исключений вашего кода. Фильтр по персональным или секретным полям с другим именем → имя поля в `redact`.
285
+ Объект ошибки, который получает приложение (`err.message`, `err.cause`), не маскируется.
286
+
287
+ ## Модель за минуту
288
+
289
+ - Три таблицы схемы `v2.<имя>`: `entity` (текущая версия), `log` (все версии), `secret` (креды и сессии). Классы,
290
+ аккаунты, права — тоже строки `entity`.
291
+ - Проверяет **база**: триггеры (данные по JSON Schema, ссылки, ключи, журнал) и политики RLS (арендатор и права).
292
+ Сырой SQL прикладной роли подчиняется тем же правилам.
293
+ - Хаб (`hub`) — объект, связь (`link`) — объект с концами. Поля — в `row.data`, концы — в `row.links`.
294
+ - Строка ответа (`Row`): `{ id, class, rev, tenant, owner, links, data, tags, at, author, agent?, op, reason? }`;
295
+ надгробие — ещё `$deleted: true`.
296
+ - Арендатор = аккаунт; строки принадлежат арендатору **сессии**.
297
+ - Строгий режим: неизвестный класс или роль → `invalid_class`, поле фильтра, `sort()` или агрегата → `invalid_query`,
298
+ опция → `invalid_data` — с похожими именами; строгие типы — `connect({ models })`, имя во время выполнения —
299
+ `loose(db)[имя]()`.
300
+
301
+ ## Рабочий цикл
302
+
303
+ ### 1. Установка: `up()`, роль приложения, ключ сервиса
89
304
 
90
305
  <!-- run -->
91
306
  ```ts
92
- const APP = 'postgres://myapp:…@localhost:5432/app'
93
- const svc = await connect({ dsn: APP, schema: 'v2.myapp', apiKey: r.serviceKey! }) // сервис: ключ → сессия
94
- const SALON = r.tenant!.tenant, OLGA = r.tenant!.user!
95
- await svc.auth.setPassword({ account: SALON, identifier: 'salon@example.com', password: 'Сирень-2026!', confirmed: true })
96
- const login = await svc.auth.verifyPassword({ identifier: 'salon@example.com', password: 'Сирень-2026!' })
97
- // login: { account, token } | null (неверный пароль или кред не подтверждён — null, без ошибки)
98
- const db = await connect({ dsn: APP, schema: 'v2.myapp', token: login!.token })
99
- db.tenant // арендатор сессии: по умолчанию собственный арендатор аккаунта (здесь SALON)
307
+ import postgres from 'postgres'
308
+ import { up, connect, createTenant, syncModels, inc, gt, or, cursorOf } from 'letopis'
309
+
310
+ const ADMIN = process.env.ADMIN_DSN! // администратор: ставит схему и роль приложения
311
+ const r = await up({ dsn: ADMIN, schema: 'myapp', // схема v2.myapp; повторный up() данные не трогает
312
+ serviceKey: process.env.SERVICE_KEY, // свой ключ сервиса — один и тот же при каждом запуске
313
+ login: { user: 'myapp', password: process.env.APP_PASSWORD! }, // роль приложения: LOGIN, член letopis_app
314
+ tenant: { name: 'Арендатор А', user: { name: 'u1', login: 'u1@example.com', password: process.env.USER_PASSWORD! } } })
315
+ const APP = r.login!.dsn! // строка подключения роли приложения
316
+ const KEY = r.serviceKey ?? process.env.SERVICE_KEY // выдан этим up() — сохраните; повторный up() отдаёт null
317
+ if (!KEY) throw new Error('нет ключа сервиса: задайте SERVICE_KEY')
318
+ const TENANT = r.tenant!.tenant, U1 = r.tenant!.user! // r.tenant: { tenant, user, token, created }
319
+ // ещё пользователь: то же name → тот же арендатор (новый арендатор — другое name)
320
+ const admin = postgres(ADMIN) // пул postgres(), не строка DSN и не connect()
321
+ const t2 = await createTenant(admin, r.schema, { name: 'Арендатор А',
322
+ user: { name: 'u2', login: 'u2@example.com', password: process.env.USER_PASSWORD!, roles: ['staff'] } })
323
+ await admin.end()
324
+ const U2 = t2.user!
100
325
  ```
101
- - `connect({ dsn | sql, schema: 'v2.имя', token | apiKey | service, admin, max, listen, cache, onQuery, slowMs,
102
- maintain, refreshSession, allowBypassRls, owner, notifyIntervalMs, models })`. `schema` — **полное** имя с `v2.`.
103
- - неизвестная опция (и у `up`, `db.load`, `db.watch`, `createMcpServer`) → `invalid_data` с похожими именами.
104
- - администраторское подключение (`allowBypassRls`) → печатает предупреждение «RLS не действует» — так и должно
105
- быть.
106
- - `db.auth`: `setPassword`, `verifyPassword`, `issueApiKey`, `verifyApiKey`, `issueKeySecret`, `verifyKeySecret`,
107
- `enrollTotp`, `verifyTotp`, `totpEnabled`, `issueOtp`, `verifyOtp`, `link`, `lookup`, `credentials`, `revokeCredential`,
108
- `setFlags`, `sessionFor(account, { ttl })`, `session(token)`, `refresh`, `revoke`, `revokeAll`, `switch(tenant)`,
109
- `tenants()`, `purgeAccount`:
110
- - проверка паролей, OTP и TOTP, выпуск сессий, сведения о сессии по токену и подтверждение кредов → вызывает
111
- **сервис** (права `auth.*`); обычный пользователь → `acl_denied`;
112
- - `issueApiKey({ account, name?, ttl? })` → `{ key, id }`;
113
- - `verifyApiKey(key)` → `{ token, account } | null`;
114
- - `sessionFor(account, { ttl })` → строка-токен, дальше `connect({ dsn, schema, token })`;
115
- - работа в чужом арендаторе по членству → `db.auth.switch(арендатор)`; без него сессия — в собственном арендаторе
116
- аккаунта;
117
- - токен для CLI (`--token`) — тот же `token` из `verifyPassword`/`verifyApiKey`/`sessionFor`.
118
- - Второй фактор — `verifyPassword({ identifier, password, totp })` и `lookup({ kind, identifier, totp })`:
119
- - после первой проверки сверяют код TOTP (право `auth.totp`, счётчик неудач и блокировка — общие с `verifyTotp`);
120
- - код верен → сессия со способами `[вид, 'TOTP']`, по ней пользователь с TOTP меняет свои креды сам;
121
- - неверный или повторный код, код у аккаунта без TOTP → `null`;
122
- - без `totp` → код не проверяется.
123
- - Ключи сервиса:
124
- - по сессии своего ключа → сервис сам выпускает и отзывает свои ключи (`issueApiKey`, `issueKeySecret` с `account` —
125
- id сервиса; `revokeCredential(id)`);
126
- - отзыв ключа → гасит только сессии этого ключа; ротация без простоя: новый ключ → процессы на него → отзыв старого;
127
- - последний действующий ключ сервиса → не отзывается (`acl_denied`);
128
- - отключить сервис → `enabled: false` у его аккаунта.
129
- - Сессия по токену — `svc.auth.session(token)` → `{ account, tenant, member, expiresAt, loginAt, methods } | null`:
130
- - сервис (право `auth.session`) узнаёт, чья сессия, в каком арендаторе она работает и цело ли там членство, не
131
- подключаясь по токену и не продлевая сессию;
132
- - неверный, погашенный или истёкший токен, отключённый аккаунт → `null`;
133
- - так веб-сервис проверяет сессию из cookie в начале каждого запроса: чтение по погашенной сессии → ошибки нет,
134
- ответы просто пустые.
135
- - Работа в чужом арендаторе по членству: `await user.auth.tenants()` → `[{ tenant, roles }]`, затем
136
- `await user.auth.switch(tenant)` (только от корня подключения). Первым в списке — собственный арендатор
137
- пользователя: `tenant` — id его аккаунта, `roles: ['owner']`.
138
- - Имперсонация сервисом: `const u = await svc.as(account, { tenant, owner })` — проверяет база, в журнале `author` —
139
- пользователь, `agent` — сервис. `db.as()` **асинхронный**.
140
-
141
- ### 3. Модель на Zod и `letopis sync`
326
+ - `up()` создаёт базу, если её нет, проверяет PostgreSQL 18+ и UTF8, ставит роли `letopis_owner` и `letopis_app`,
327
+ pgcrypto и схему; новый арендатор (`tenant`, `createTenant`) получает правило «вошедшим можно всё» (`grantAll`, вес
328
+ 10), пользователь — членство с ролями `user.roles` (по умолчанию `['owner']`) и пароль. Повтор ничего не дублирует.
329
+ - Ключ сервиса выдаётся один раз: повторный `up()` → `r.serviceKey === null`. `up({ fresh: true })` пересоздаёт схему
330
+ — данные, сессии и ключи пропали, выдан новый ключ (свой `serviceKey` регистрируется и в новой схеме).
331
+ - `connect({ apiKey })` или `{ token }` со значением `null`, `''` → сразу `invalid_data`
332
+ (`api.connect_empty_credential`); но `undefined` — «опции нет»: подключение без сессии, отказ `no_session` придёт на
333
+ первом вызове. Поэтому `apiKey: process.env.SERVICE_KEY` без проверки опасен — проверяйте ключ, как выше.
334
+ - `createTenant(admin, …)`: `admin` не пул `postgres()` → `invalid_query` (`api.create_tenant_admin`); пул закрывает
335
+ вызывающий.
336
+ - Пакет обновили → `up()` (функции схемы накатывает он); новая ревизия → `up({ upgrade: true })`. Остальные опции,
337
+ `pglite`, `signals`, свои сиды — README, «Установка и подключение».
338
+
339
+ ### 2. Вход пользователя
142
340
 
143
341
  <!-- run -->
144
342
  ```ts
145
- // models.ts
146
- import { z } from 'zod'
147
- import { hub, link, one, many } from 'letopis/model'
148
- export const Shop = hub('Shop', { name: z.string(), city: z.string().optional() }, { key: ['name'] })
149
- export const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0).default(0) },
150
- { key: ['number'], ends: { Shop } }) // голая модель в ends = one(Shop), обязательный конец
151
- export const Payment = link('Payment', { opId: z.string(), amount: z.number().int() },
152
- { key: ['Wallet', 'opId'], ends: { Wallet } }) // ключ из конца и поля: идемпотентность по opId
153
- export const Note = hub('Note', { text: z.string() }, {}) // без ключа: id выдаёт база
343
+ const svc = await connect({ dsn: APP, schema: r.schema, apiKey: KEY }) // сервис: одно подключение на процесс
344
+ const me = await svc.auth.verifyPassword({ identifier: 'u1@example.com', password: process.env.USER_PASSWORD! })
345
+ if (!me) throw new Error('неверный логин или пароль') // null, а не ошибка; me: { account, token } — token в cookie
346
+ const db = await svc.as(me.account, { tenant: TENANT }) // от имени u1 в арендаторе: db.tenant === TENANT
154
347
  ```
155
- - Полная форма: `hub(name, { alias, extends, abstract, ends, fields: z.object(…) | { поля }, key, onDelete, history,
156
- ownerDefault: 'tenant' | 'actor', cache: { ttl }, meta })`; `link` — так же:
157
- - `extends` → работает и в короткой форме, но типы данных потомка TypeScript выводит только в полной.
158
- - Концы:
159
- - `one(A)` → обязательный; `one(A, B)` → союз; `one('Имя')` → цель по имени (ссылка класса на себя);
160
- - `.optional()`, `.onDelete('restrict' | 'cascade' | 'unset')` (по умолчанию `restrict`; `unset` — только для
161
- необязательного);
162
- - `many(A, { min, max, onDelete })`.
163
- - Ключ — поля (обязательные строки) и одиночные обязательные концы; ключ наследуется от `extends`.
164
-
165
- Модели в базу:
348
+ - Дальше на каждый запрос — `userDb(token)` из правила 5. Список арендаторов пользователя →
349
+ `(await svc.as(me.account)).auth.tenants()`: `[{ tenant, roles, name, personal }]`, первым — личный.
350
+ - Своё подключение по токену (CLI, отдельный процесс) → `connect({ dsn, schema, token })` и `auth.switch(tenant)`;
351
+ погашенный или истёкший токен → `no_session` (`api.token_not_active`) промисом `connect()`.
352
+ - `db.auth` (пароли, ключи API, OTP, TOTP, сессии, ротация ключей сервиса) вызывает сервис; методы — README, `db.auth`.
166
353
 
167
- - классы → пишутся в арендатора сессии;
168
- - ужесточение, которому мешают строки, → `tightening_conflict`.
354
+ ### 3. Модель на Zod
169
355
 
170
356
  <!-- run -->
171
357
  ```ts
172
- const synced = await syncModels(db, [Shop, Wallet, Payment, Note], { apply: true })
173
- // synced: { applied, created, updated, unchanged, skipped, lost } (lost — refine и прочее, чего JSON Schema не выражает)
174
- ```
175
- ```sh
176
- npx letopis sync ./models.js --dsn "$APP" --schema v2.myapp --token "$TOKEN" # сухой прогон (.ts — Node 22.18+)
177
- npx letopis sync ./models.js --dsn "$APP" --schema v2.myapp --token "$TOKEN" --apply # применить
178
- npx letopis types --dsn "$APP" --schema v2.myapp --token "$TOKEN" --out types.ts # типы классов из базы
358
+ // models.ts (здесь же — Obj, Peer, Hold из правила 1)
359
+ import { z } from 'zod'
360
+ import { hub, link } from 'letopis/model'
361
+ export const Team = hub('Team', { name: z.string(), zone: z.string().optional() }, { key: ['name'] })
362
+ export const Item = hub('Item', { code: z.string(), n: z.number().int().min(0).default(0) },
363
+ { key: ['code'], ends: { Team } }) // голая модель в ends = one(Team), обязательный конец
364
+ export const Op = link('Op', { opId: z.string(), amount: z.number().int() },
365
+ { key: ['Item', 'opId'], ends: { Item } }) // ключ из конца и поля: повтор opId → exists
366
+ export const Note = hub('Note', { text: z.string() }, {}) // без ключа: id выдаёт база
367
+ const synced = await syncModels(db, [Team, Item, Op, Note, Obj, Peer, Hold], { apply: true })
368
+ // synced: { applied, created, updated, unchanged, skipped, lost }
179
369
  ```
180
- - Строгие типы: `connect({ …, models: [Shop, Wallet, Payment, Note] })` или `connect<Models>({ … })` с файлом
181
- `letopis types` — модели и объявления только для TypeScript:
182
- - шаг от корня → только класс или алиас моделей и системные `Class`, `Account`, `member`, `Resource`, `rule`;
183
- - переход → только по концам; слот → только роль класса (`add`/`remove` — у `many`, `unset` — у необязательного);
184
- - поля фильтра, `or()`, `sum`/`avg` (числовые), `min`/`max`/`countBy` → поля класса;
185
- - имя, известное только во время выполнения, → `loose(db)[имя]()`; `db.as()`, `db.begin()`, `db.batch()` и
186
- `entity()` → цепочки без строгих типов.
370
+ - Классы пишутся в арендатора сессии; одна модель в нескольких арендаторах → `syncModels` в сессии каждого.
371
+ - Концы: `one(A)` — обязательный, `.optional()`, `.onDelete('restrict' | 'cascade' | 'unset')` (по умолчанию
372
+ `restrict`), `many(A, { min, max })`. Ключ — обязательные строковые поля и одиночные обязательные концы.
373
+ - Преобразования Zod (`.trim()`, `.transform()`, `z.preprocess()`, `.pipe()`) → `invalid_class`
374
+ (`model.transform_unsupported`): база их не исполняет, приводите значение в коде.
375
+ - CLI: `npx letopis sync ./models.js --dsn … --schema v2.myapp --token … [--apply]`, `npx letopis types … --out
376
+ types.ts`.
187
377
 
188
378
  ### 4. Запись цепочками
189
379
 
190
- - Глагол → звено плана; исполняет план **терминал** (`rows`, `first`, `ids`, `count` …).
191
- - Весь план → одна транзакция.
380
+ Глагол — звено плана; исполняет весь план одной транзакцией **терминал** (`rows`, `first`, `ids`, `count` …).
192
381
 
193
382
  <!-- run -->
194
383
  ```ts
195
- const [shop] = await db.Shop().create({ name: 'Центр', city: 'Казань' }).rows()
196
- const [w] = await db.Shop(shop).Wallet().create({ number: 'W-1' }).rows() // конец Shop — из пути
384
+ const [grp] = await db.Team().create({ name: 'g1', zone: 'A' }).rows()
385
+ const [w] = await db.Team(grp).Item().create({ code: 'i1' }).rows() // конец Team — из пути
197
386
  const [n] = await db.Note().create({ text: 'привет' }).rows()
198
387
  await db.Note(n.id).update({ text: 'пока' }).rows() // слияние патча, новая версия
199
- const [up1] = await db.Shop().upsert({ name: 'Центр', city: 'Уфа' }).rows() // замена целиком; up1.$upsert:
200
- // 'created' | 'updated' | 'unchanged'
201
- // деньги: приращение в базе, без чтения; Payment с ключом [Wallet, opId] — повтор даёт exists
202
- await db.Wallet(w).Payment().create({ opId: 'op-1', amount: 500 }).Wallet().update({ balance: inc(500) }).rows()
203
- await db.Wallet(w).update({ balance: inc(-300) }).first() // баланс 200; итог ниже minimum (0) — invalid_data
204
- const cur = (await db.Wallet(w).first())!
205
- await db.Wallet(w).update({ balance: 0 }, { rev: cur.rev }).first() // { rev }: чужая правка — conflict
206
- // слоты концов после create / update / upsert: .Роль.set(x) .unset() и для many — .add(x) .remove(x)
207
- const [west] = await db.Shop().create({ name: 'Запад' }).rows()
208
- await db.Wallet(w).update({}).Shop.set(west).rows()
209
- await db.Shop(shop).Wallet().create({ number: 'W-2' }).rows()
210
- const b = db.batch('init'); b.Shop().create({ name: 'А' }); b.Shop().create({ name: 'Б' })
211
- const batchRows = await b.run() // Row[][] одной транзакцией; b.size(), b.discard()
388
+ const [up1] = await db.Team().upsert({ name: 'g1', zone: 'B' }).rows() // замена целиком; up1.$upsert: 'updated'
389
+ await db.Item(w).Op().create({ opId: 'op-1', amount: 500 }).Item().update({ n: inc(500) }).rows() // один план
390
+ await db.Item(w).update({ n: inc(-300) }).rows() // 200
391
+ const cur = (await db.Item(w).first())!
392
+ await db.Item(w).update({ n: 0 }, { rev: cur.rev }).rows() // чужая правка после чтения → conflict
393
+ const [grp2] = await db.Team().create({ name: 'g2' }).rows()
394
+ await db.Item(w).update({}).Team.set(grp2).rows() // слот конца; .unset(), у many — .add/.remove
395
+ await db.Team(grp).Item().create({ code: 'i2' }).rows()
396
+ const b = db.batch('init'); b.Team().create({ name: 'g3' }); b.Team().create({ name: 'g4' })
397
+ const batchRows = await b.run() // Row[][] одной транзакцией
212
398
  ```
213
- - `inc(n, { start })` → только в `update`; поля нет → ошибка (или `start`); маркер не получить из JSON.
214
- - `{ rev }` → только при одной цели шага; целей больше → `rev_ambiguous`.
215
- - `update()` без фильтра, id, `tags()`, `owner()` и `limit()` ни у одного шага → `invalid_query` до записи; все строки
216
- → `update(патч, { all: true })`.
217
- - Затронуто меньше строк, чем нашёл шаг, → откат плана с `target_not_found` (не видна), `conflict` (`rev` разошёлся)
218
- или `acl_denied`.
399
+ - **Концы — не поля данных**: из пути или слотом `.Роль.set(id)`; `{ Team: id }` в данных → `invalid_data`
400
+ (`write.link_in_data`). Связь двух концов — один из пути, второй слотом.
401
+ - `update()` никогда не создаёт; `upsert()` — только у класса с ключом (`no_key`); `{ rev }` — при одной цели шага
402
+ (`rev_ambiguous`); `inc()` — только в `update()`.
403
+ - Терминал отдаёт строки **последнего** шага: `…Op().create(…).Item().update(…).rows()` → строки `Item`.
219
404
 
220
405
  ### 5. Чтение цепочкой
221
406
 
222
407
  <!-- run -->
223
408
  ```ts
224
- await db.Shop({ city: 'Уфа' }).Wallet().rows() // Row[]: { id, class, rev, tenant, owner, links, data, tags, at, author, op, … }
225
- const rich = await db.Wallet({ balance: gt(100) }).count() // число СУЩНОСТЕЙ; пути — count({ paths: true })
226
- const paid = await db.Wallet(w).Payment().sum('data.amount') // sum | avg | min | max (null на пустом), countBy
227
- await db.Shop(west).Wallet().Payment().paths() // [{ Shop: Row, Wallet: Row, Payment: Row }]
228
- await db.Wallet(w).first() // Row | null
229
- const page = await db.Wallet().sort('data.number', 'desc').limit(20).rows()
230
- const next = await db.Wallet().sort('data.number', 'desc').after(cursorOf(page.at(-1)!, 'data.number')).limit(20).rows() // keyset
231
- await db.Note().withDeleted().rows() // с удалёнными ($deleted: true)
232
- await db.Wallet(or({ number: 'W-1' }, { number: 'W-2' })).ids()
409
+ await db.Team({ zone: 'B' }).Item().rows() // Row[]
410
+ const rich = await db.Item({ n: gt(100) }).count() // число СУЩНОСТЕЙ; пути — count({ paths: true })
411
+ const paid = await db.Item(w).Op().sum('data.amount') // 500; sum | avg | min | max (null на пустом), countBy
412
+ await db.Team(grp2).Item().Op().paths() // [{ Team: Row, Item: Row, Op: Row }]
413
+ const page = await db.Item().sort('data.code', 'desc').limit(20).rows()
414
+ const next = await db.Item().sort('data.code', 'desc').after(cursorOf(page.at(-1)!, 'data.code')).limit(20).rows()
415
+ await db.Note().withDeleted().rows() // с удалёнными ($deleted: true)
416
+ await db.Item(or({ code: 'i1' }, { code: 'i2' })).ids()
233
417
  ```
234
- - Фильтр шага: id, список id, строка ответа, объект полей любой глубины, `or(…)`, операторы (таблица ниже), роли
235
- концов `{ Shop: id }`, `{ Shop: null }`. Поля — в `row.data`, не в корне строки.
236
- - Шаг — имя класса, алиас или роль конца:
237
- - шаг по родителю → отдаёт и потомков; `.exact()` → только свой класс;
238
- - `.deep(max)` → дерево (`$depth`);
239
- - ещё `.tags()`, `.owner()`, `.alias(имя)`, `db.entity(row)`.
240
- - `after(cursorOf(строка, поле))`:
241
- - требует `sort()` по тому же полю;
242
- - листает `rows()`, `ids()`, `versions()` и `paths()` (курсор пути — `cursorOf(путь, поле)`, ключи пути — в порядке шагов);
243
- - перед `count()` и агрегатами → `invalid_query` (они считают по всем объектам шага).
418
+ - Фильтр шага: id, список id, строка ответа, объект полей, `or(…)`, операторы (таблица), роль конца `{ Team: id }`,
419
+ `{ Team: null }` — без конца. `{ поле: null }` — только явный `null`; нет поля → `isNull()` или `exists(false)`.
420
+ - Модификаторы `sort`, `limit`, `offset`, `after`, `forUpdate` → действуют на **последний** шаг, где бы ни стояли
421
+ (`sort()` не в конце — предупреждение `api.sort_not_last`); `asOf` — на всю цепочку.
422
+ - `withDeleted()` → только к **своему** шагу: `X().withDeleted().Y()` — удалённые `X`, живые `Y` (предупреждение
423
+ `api.with_deleted_step`); удалённые `Y` → `X().Y().withDeleted()`. В записи — только перед `restore()`.
424
+ - Тип поля в сравнении — по классу шага; в семействе с разными типами поля непреобразуемое значение в сравнении,
425
+ `sort()`, `after()` и агрегатах считается `null` (в конце сортировки, ни с чем не совпадает, агрегат пропускает).
426
+ - Поле `sort()`, агрегата и курсора — полным именем: `'data.code'`, `'at'`, `'rev'`, `'id'`; `sort('code')` →
427
+ `invalid_query` (`query.sort_field_data`: «может быть, data.code?»).
428
+ - `after(cursorOf(строка, поле))` требует `sort()` по тому же полю; перед `count()` и агрегатами → `invalid_query`.
429
+ - Цепочка — не массив: `.length`, `.map()`, `[0]` → `invalid_query` (`api.chain_not_rows`); строки — `await ….rows()`.
244
430
 
245
431
  Операторы фильтра и их ключи в JSON-фильтре MCP (инструмент `letopis_query`, п. 11):
246
432
 
@@ -267,251 +453,135 @@ await db.Wallet(or({ number: 'W-1' }, { number: 'W-2' })).ids()
267
453
  | `or(f1, f2, …)` | `{ "$or": [f1, f2, …] }` | Хотя бы один из фильтров. Ставится вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и» |
268
454
  <!-- /ops -->
269
455
 
270
- ### 6. Удаление с превью
456
+ ### 6. Удаление и возврат
457
+
458
+ Правила 1–3 в деле: занятость объекта, удаление по превью, возврат каскада одной транзакцией.
271
459
 
272
460
  <!-- run -->
273
461
  ```ts
274
- const preview = await db.Shop(shop).delete().rows() // ничего не удаляет: всё замыкание каскада,
275
- // в каждой строке $action: 'delete' | 'unset' | 'restrict' и $depth (0 — цель; у unset и restrict его нет). Wallet ссылается на Shop
276
- // с onDelete по умолчанию 'restrict' — в превью он с $action 'restrict', а delete({ confirm: true }) даст delete_restricted
277
- await db.Note(n.id).delete({ confirm: true }).rows() // надгробия (row.$deleted); каскад — по onDelete концов
278
- await db.Note(n.id).restore().rows() // вернуть удалённое (id прежний); живое — not_deleted
462
+ const [obj] = await db.Obj().create({ code: 'o1' }).rows()
463
+ const [peer] = await db.Peer().create({ name: 'p1' }).rows()
464
+ const hold = await open(db, obj.id, peer.id) // Hold; id === db.idOf('Hold', { Obj: obj.id })
465
+ const busy = await open(db, obj.id, peer.id) // null: у объекта уже есть открытая Hold
466
+ const shown = await db.Peer(peer).delete().rows() // превью: Peer ($depth 0) и его Hold (каскад) — человеку
467
+ const res = await remove(db, peer.id, shown) // { gone: надгробия } | { again: новое превью }
468
+ const back = res.gone ? await bringBack(db, res.gone) : null // { back: [id Hold], skipped: [] }
469
+ const blocked = await db.Team(grp2).delete().rows() // Item ссылается на Team (restrict) → $action 'restrict'
279
470
  ```
280
- - `delete({ confirm: true })` без фильтра, id, `tags()`, `owner()` и `limit()` у шагов → `invalid_query` до записи;
281
- все строки → `delete({ confirm: true, all: true })`; превью → без условий.
282
471
 
283
472
  ### 7. Загрузчик
284
473
 
285
- - Нужно **администраторское** подключение (`allowBypassRls` и право стать владельцем); иначе → `admin_required`.
286
-
287
474
  <!-- run -->
288
475
  ```ts
289
- const adm = await connect({ dsn: ADMIN, schema: 'v2.myapp', token: login!.token, allowBypassRls: true })
476
+ const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token!, allowBypassRls: true })
290
477
  const rep = await adm.load({
291
- Shop: [{ name: 'Север' }],
292
- Wallet: [{ number: 'W-9', Shop: { $key: ['Север'] } }], // ссылка: id | { $key: [...] } | { $ext }
293
- }, { existing: 'skip' }) // duplicates 'error'|'first'|'last', existing 'error'|'skip'|'update', batch, commitEvery, withHistory, class, tenant, agent, as, initial
478
+ Team: [{ name: 'g5' }],
479
+ Item: [{ code: 'i9', Team: { $key: ['g5'] } }], // ссылка: id | { $key: [...] } | { $ext }
480
+ }, { existing: 'skip' })
294
481
  // rep: { rows, loaded, updated, skipped, duplicates, byClass, external, portions, ms }
295
482
  ```
296
- - Первая заливка — `adm.load(rows, { initial: true })`, `letopis load --initial`:
297
- - только в пустую схему, иначе → `not_empty`;
298
- - одной транзакцией; на миллионе объектов — примерно в 2,2 раза быстрее обычной загрузки;
299
- - без `commitEvery`, `existing` и `{ as }`.
483
+ - Нужно **администраторское** подключение (`allowBypassRls: true`, роль может стать владельцем), иначе
484
+ `admin_required`; внутри `db.begin()` → `invalid_query`. Арендатор строк — арендатор сессии или `load(…, { tenant })`.
485
+ - Первая заливка в пустую схему — `load(rows, { initial: true })`; файлы NDJSON — `letopis load`; ошибки —
486
+ `err.detail.rows`. Подробно — README, «Массовая загрузка».
300
487
 
301
- Файлы NDJSON (объект на строку, все файлы — одна загрузка):
302
- ```sh
303
- npx letopis load shops.ndjson wallets.ndjson --dsn "$ADMIN" --schema v2.myapp --token "$TOKEN" [--class Wallet] \
304
- [--duplicates first] [--existing skip] [--commit-every 100000] [--initial]
305
- # {"$class":"Shop","name":"Юг"}
306
- # {"$class":"Wallet","number":"W-10","Shop":{"$key":["Юг"]}}
307
- # {"$class":"Note","text":"x","$ext":"n1"} ← $ext — внешний ключ строки для ссылок { "$ext": "n1" }
308
- ```
309
- - Ошибка → код первой проблемы; `err.detail.rows` — первые 100 нарушений (номер строки, код, подробности),
310
- `err.detail.total` — сколько всего, `err.detail.truncated` — список обрезан.
311
- - Полиморфная ссылка → нужен `$class`.
312
- - `$tags` — теги строки, `$owner` — владелец.
313
-
314
- ### 8. Права: выдать право
315
-
316
- - Правило = группа (`Resource` категории `ACCOUNT`) → объект (`API` — эндпоинт, `READ`/`WRITE`/`DELETE` — строки
317
- класса с условием) с `allow`/`deny` и весом.
318
- - Решает одно правило — с наибольшим весом; при равном весе → запрет.
319
- - Вес правила арендатора больше 999 → урезается до 999.
320
- - `db.acl.grant` и `db.acl.deny` → пишут такие строки сами (вес по умолчанию 50); писать их может тот, у кого есть
321
- `WRITE` на них (здесь — сам арендатор по правилу «можно всё»).
488
+ ### 8. Права
322
489
 
323
490
  <!-- run -->
324
491
  ```ts
325
- // членство Ольги в салоне создал up({ tenant }) с ролью owner; делаем её сотрудником с ролью staff
326
- await db.acl.roles(OLGA, ['staff'])
327
- await db.acl.grant({ role: 'staff', api: 'shop.reports.*' })
328
- await db.acl.deny({ role: 'staff', delete: 'Wallet' }) // вес 50 — сильнее «можно всё» (10)
329
- // проверка от имени Ольги (сервис действует от её имени в салоне)
330
- const olga = await svc.as(OLGA, { tenant: SALON })
331
- const canReport = await olga.acl.check('shop.reports.view') // { allow: true, rule: { weight: 50, … }, code?, message? }
332
- const canDelete = await olga.acl.checkData('Wallet', 'DELETE') // { allow: false } | { allow: true, filter? }
492
+ // u2 — член арендатора с ролью staff (createTenant выше)
493
+ await db.acl.grant({ role: 'staff', api: 'app.reports.*' })
494
+ await db.acl.deny({ role: 'staff', delete: 'Item' }) // вес 50 — сильнее «можно всё» (10)
495
+ const u2 = await svc.as(U2, { tenant: TENANT })
496
+ const canReport = await u2.acl.check('app.reports.view') // { allow: true, rule: { weight: 50, … } }
497
+ const canDelete = await u2.acl.checkData('Item', 'DELETE') // { allow: false }
333
498
  ```
334
- - `grant`/`deny({ role | everyone: true, read | write | delete, api, weight })`:
335
- - кому → `role` (роль членства) или `everyone: true` (все аккаунты арендатора); ровно одно, иначе `invalid_data`;
336
- - цель — класс (с потомками), `'*'` — все классы, класс с условием `{ class: 'Wallet', owner: '$actor' }` или
337
- `{ class: 'Wallet', data: { … } }`; `api` — маска эндпоинта; списком — несколько;
338
- - повтор → ничего не меняет; `grant` после `deny` той же роли и цели → заменяет запрет;
339
- - внутри `db.begin()` → в её транзакции;
340
- - неверный вызов → `invalid_data` до записи.
341
- - `db.acl.revoke({ role | everyone: true, … })` → снять (ответ — число снятых).
342
- - `db.acl.roles(аккаунт)` → роли в текущем арендаторе (`null` — не член).
343
- - `db.acl.roles(аккаунт, ['staff'])` → заменить (членство — строка `member`, ключ `[User, Tenant]`, заводится);
344
- `db.acl.roles(аккаунт, null)` → снять членство.
345
- - `db.acl.list()` → правила арендатора `{ role, everyone, group, op, target, condition, permission, weight, enabled }`,
346
- свои и «можно всё» (`everyone: true`); на администраторском подключении — тоже только арендатора сессии.
347
- - Права только по ролям:
348
- - сначала права ролям, в том числе на `Resource`, `rule`, `member`, если права правит приложение;
349
- - затем снять «можно всё» → `db.acl.revoke({ everyone: true, read: '*', write: '*', delete: '*' })` (→ 3);
350
- - или арендатор без него: `createTenant(…, { grantAll: false })` → классы и правила пишет администраторское
351
- подключение с сессией арендатора (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`).
352
- - Напрямую строками `Resource` и `rule`:
353
- - группа → `{ categories: '{Staff}' }` (категории аккаунта) или `{ roles: '{staff}' }` (роли членства);
354
- - маска (`categories`, `roles`, `endpoint`, `class`) — **только строка**: массив (`['staff']`), число или `null` →
355
- база отклоняет с `invalid_data`;
356
- - условие объекта: `{ class: 'Wallet', owner: '$actor' }`, `{ class: 'Wallet', data: { … } }`; маска класса — с
357
- потомками, `'{rule,Resource,member}'` — несколько классов;
358
- - `deny` с `{ code, message }` → свой отказ эндпоинта.
359
- - Права пересчитываются базой сами.
499
+ - Решает одно правило — с наибольшим весом; при равном весе → запрет. `grant`/`deny({ role | everyone: true, read |
500
+ write | delete, api, weight })`, повтор ничего не меняет; `revoke`, `roles(аккаунт[, роли])`, `list()`.
501
+ - Права только по ролям → сначала права ролям, затем `db.acl.revoke({ everyone: true, read: '*', write: '*', delete:
502
+ '*' })` или `createTenant(…, { grantAll: false })`. Подробно — README, «Права».
360
503
 
361
504
  ### 9. Подписка и кэш
362
505
 
363
506
  <!-- run -->
364
507
  ```ts
365
- const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (по умолчанию) | 'start' | курсор; pollMs; batch
366
- const lagMs = await sub.lag() // отставание, мс
367
- await db.Wallet(w).update({ balance: inc(1) }).rows()
508
+ const sub = db.watch({ classes: ['Item'] }) // from: 'now' (по умолчанию) | 'start' | курсор
509
+ await sub.ready() // «now» зафиксирован: запись ниже попадёт в поток
510
+ await db.Item(w).update({ n: inc(1) }).rows()
368
511
  let cursor = ''
369
512
  for await (const ev of sub) { // ev: { cursor, row, id, class, op, rev, at }
370
- cursor = ev.cursor // продолжить: db.watch({ from: cursor })
371
- if (ev.id === w.id && ev.row.data.balance === 1) break // break или sub.close() — остановить
513
+ cursor = ev.cursor // продолжить с места: db.watch({ from: cursor })
514
+ if (ev.id === w.id) break // break или sub.close() — остановить
372
515
  }
373
- const shops = await db.Shop().cache({ ttl: 300 }).rows() // кэш результатов; сброс по своим записям и сигналам
374
- db.cache.stats(); db.cache.clear()
516
+ const teams = await db.Team().cache({ ttl: 300 }).rows() // кэш результатов; сброс по своим записям и сигналам
375
517
  ```
376
- - События → без потерь и повторов, под RLS.
377
- - `from: 'now'` → подписка видит только то, что зафиксировано после её начала.
518
+ - События — без потерь и повторов, под RLS. Без `await sub.ready()` запись сразу после `watch()` может в поток не
519
+ попасть.
378
520
 
379
- ### 10. Срез на дату и история
521
+ ### 10. История и срез на дату
380
522
 
381
523
  <!-- run -->
382
524
  ```ts
383
- const was = await db.Wallet(w).asOf('2026-10-01T00:00:00Z').first() // как было на момент (строка ISO или Date)
384
- await db.Shop(shop).Wallet().asOf(new Date('2026-10-01')).rows()
385
- const history = await db.Wallet(w).versions() // все версии; версия удаления — $deleted
525
+ const was = await db.Item(w).asOf('2026-10-01T00:00:00Z').first() // как было на момент
526
+ const history = await db.Item(w).versions() // все версии; версия удаления — $deleted
386
527
  ```
387
- - Версия → `{ rev, op, at, author, agent?, reason? }`: причина — опция `reason` глагола, `db.begin({ reason })`,
388
- `db.batch(имя, { reason })`; пустые `agent` и `reason` → опускаются.
389
- - Журнал хранит каждую версию: прореживания, обрезки и стирания истории объекта нет.
390
- - Класс с `history: false` → не даёт `versions`, `asOf`, `restore` и событий подписки.
391
- - Класс с историей строк не удаляется (`invalid_class`): класс, который придётся удалять, заводят с `history: false`.
528
+ - `versions()` — терминал: модификаторы — до него; `versions().rows()` → `invalid_query` (`api.versions_terminal`).
529
+ - `versions()` по пути или по классу без id → только **живые** объекты; с удалёнными → `withDeleted()` на этом шаге
530
+ (`db.Obj(o).Hold().withDeleted().versions()`) или явный id (`db.Hold(id).versions()`).
531
+ - Причина версии — опция `reason` глагола, `db.begin({ reason })`, `db.batch(имя, { reason })`. Журнал хранит каждую
532
+ версию; класс с `history: false` не даёт `versions`, `asOf`, `restore`, событий подписки.
392
533
 
393
534
  ### 11. Агенты и MCP
394
535
 
395
- Агенту — инструменты MCP под сессией пользователя (нужен `npm i @modelcontextprotocol/server`):
396
536
  ```ts
397
- import { createMcpServer } from 'letopis/mcp'
398
- const server = await createMcpServer(await svc.as(userId)) // letopis_model, letopis_query, letopis_history; { write: true } — letopis_write
399
- // stdio: letopis mcp --dsn … --schema v2.x --token <токен пользователя> [--write]; HTTP — createMcpHandler(() => createMcpServer(…)) из SDK
537
+ import { createMcpServer } from 'letopis/mcp' // npm i @modelcontextprotocol/server
538
+ const server = await createMcpServer(await svc.as(userId, { tenant: TENANT }), { write: false })
539
+ // stdio: letopis mcp --dsn … --schema v2.myapp --token <токен пользователя в арендаторе> [--write]
400
540
  ```
401
- - `letopis_model` → классы, поля, концы, ключи и рабочие цепочки `examples` (`create`, `find`, `neighbors`;
402
- `'<id Класс>'` — место для id); модель больше `maxBytes` → сначала без `examples`, затем кратко (`brief`), а
403
- `{ class }` — один класс целиком.
404
- - Только сессия пользователя или `svc.as(пользователь)`:
405
- - подключение в обход RLS → `bypass_rls`;
406
- - корень сервиса без `as()`, сессия в арендаторе System (без `allowSystemAdmin`) → `user_session_required`.
407
- - Классы прав → `letopis_write` не пишет без `allowAclWrite` (`acl_denied`).
408
- - Командная строка: `--api-key` → не принимает; `--dsn`, `--schema` и `--token` → берёт и из `LETOPIS_DSN`,
409
- `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`.
410
- - Чтение → JSON-цепочкой (`class`, `filter` с `$gt`, `$in`, `$or` и т. п., `steps`,
411
- `sort: { field, dir: 'asc' | 'desc' }`, `cursor`), без SQL.
412
- - Ответ → до `maxRows` строк (100) и `maxBytes` байт (50 000) из опций `createMcpServer`; дальше → `cursor`.
413
- - `structuredContent` — `{ notice, data }` (`notice` — постоянное пояснение, что это данные, а не инструкции); `data`
414
- по терминалам:
415
- - `rows` → `{ rows, count, more, cursor? }`, `ids` → `{ ids, count, more, cursor? }` (`count` — строк на странице);
416
- - `first` → `{ row }`;
417
- - `count` → `{ count }`;
418
- - `countBy` → `{ counts, truncated? }`;
419
- - `sum`, `avg`, `min`, `max` → `{ value }`.
420
- - Отказ → `isError: true` и `structuredContent` вида `{ notice, error }`
421
- (`error` — `{ code, message, detail?, sqlstate?, similar? }`).
422
- - Запись — только с `write: true`; без него → `letopis_write` нет в списке, а вызов — ошибка протокола SDK:
423
- - `update` → только с `rev`;
424
- - `delete` без `confirm` → превью; с `confirm` → подтверждение человека (elicitation); без подтверждения →
425
- `confirmation_required`.
426
- - Данные из базы в ответе → внутри `<untrusted-data-…>`.
427
-
428
- ### Эксплуатация
429
-
430
- - `db.maintain()` (без опций) и `connect({ maintain: '1h' })` → одной транзакцией: истёкшие и погашенные сессии,
431
- одноразовые коды, строки-замки удалённых классов, проверка очереди сигналов; отчёт `{ skipped, sessions, codes,
432
- queue }` (`skipped` — обслуживает другой процесс); журнал не трогает.
433
- - Закрыть подключения — по одному разу; подключения из `svc.as()` делят пул с `svc`:
541
+ - Только сессия пользователя в арендаторе приложения: без `{ tenant }` — личный арендатор, классов там нет; корень
542
+ сервиса без `as()` → `user_session_required`; подключение в обход RLS → `bypass_rls`.
543
+ - `letopis_model`, `letopis_query`, `letopis_history`; с `write: true` — `letopis_write`: `update` только с `rev`,
544
+ удаление — превью и подтверждение человека. Форматы ответов — README, «Агенты и MCP».
545
+
546
+ ### 12. Эксплуатация и завершение
434
547
 
435
548
  <!-- run -->
436
549
  ```ts
437
- await adm.close(); await db.close(); await svc.close()
550
+ const report = await adm.maintain() // { skipped, sessions, codes, queue }
551
+ // учебные данные — убрать, чтобы программа повторялась (удаляет ВСЕ строки этих классов арендатора: только в учебной схеме)
552
+ for (const cls of ['Op', 'Hold', 'Item', 'Peer', 'Obj', 'Team', 'Note'] as const)
553
+ await db[cls]().delete({ confirm: true, all: true }).rows()
554
+ await adm.close(); await svc.close() // svc закрывает пул и все фасады as()
438
555
  ```
439
-
440
- ## Грабли 2.0
441
-
442
- 1. **`create` занятого ключа — ошибка `exists`** (`err.detail.id`), а не перезапись.
443
- - заменить целиком → `upsert`, и только у класса с ключом (иначе `no_key`);
444
- - частичная правка → `update`.
445
- 2. **`update` никогда не создаёт.**
446
- - шаг ничего не нашёл → пустой ответ без ошибки, проверяйте длину;
447
- - без фильтра, id, `tags()`, `owner()`, `limit()` и `data.id` → `invalid_query` до записи; все строки → `{ all: true }`;
448
- - `target_not_found` → при `{ rev }` или если строку удалили между поиском и правкой.
449
- 3. **Глаголы возвращают цепочку.**
450
- - без терминала (`.rows()`, `.first()` …) → ничего не исполнится;
451
- - `delete()` без `{ confirm: true }` → только превью;
452
- - `restore` → только для удалённых (иначе `not_deleted`).
453
- 4. **Деньги и счётчики** — `update({ balance: inc(n) })`: база прибавляет внутри оператора, без чтения и потерянных
454
- обновлений.
455
- - уход в минус → ловит проверка `minimum` в схеме;
456
- - итог, вычисленный из прочитанного, → `{ rev }` (перечитать и повторить при `conflict`) или `forUpdate()` в
457
- `db.begin()`;
458
- - идемпотентность → ключом операции (`[Wallet, opId]`): повтор даёт `exists`.
459
- 5. **`db.begin()` — `read committed`.** Два чтения в транзакции могут увидеть разные данные.
460
- - заблокировать прочитанное → `await tr.Wallet(w).forUpdate().first()`;
461
- - `forUpdate()` вне `db.begin()` → `tx_required`; с терминалом, кроме `rows`, `first`, `ids`, → `lock_unsupported`;
462
- - `tr.lock(...ключи)` → advisory-блокировка до конца транзакции.
463
- 6. **Ошибка ломает явную транзакцию.**
464
- - после любой ошибки (в том числе `exists`, `conflict`) → каждый вызов и `commit()` бросают исходную ошибку,
465
- `commit()` при этом откатывает; `rollback()` проходит;
466
- - шаблон: `const tr = await db.begin(); try { …; await tr.commit() } catch (e) { await tr.rollback(); throw e }`;
467
- - `40P01` в `db.begin()` → повторов нет: повторите весь блок;
468
- - операции из пула → сами повторяют `40001`/`40P01`.
469
- 7. **`commit_unknown`** — связь оборвалась во время `COMMIT`: транзакция могла зафиксироваться.
470
- - не повторяйте вслепую → повторите ту же операцию с тем же ключом (`exists` значит «уже записано»).
471
- 8. **Загрузчику нужно администраторское подключение** (`allowBypassRls: true`, роль-владелец).
472
- - иначе → `admin_required`;
473
- - внутри `db.begin()` → `invalid_query`;
474
- - обычное приложение → прикладной ролью, без обхода RLS.
475
- 9. **Данные под RLS.**
476
- - запись без сессии (`token`/`apiKey`) → `no_session`;
477
- - чужая или невидимая строка → неотличима от несуществующей: шаг её не находит, а ссылка на неё — `target_not_found`;
478
- - без права → пусто или `acl_denied`;
479
- - `` db.sql`…` `` и сырой SQL → тоже под сессией и RLS.
480
- 10. **Ключ и класс неизменны.**
481
- - правка ключевого поля, класса, id, арендатора, владельца → `immutable_key` (и у сырого `UPDATE … SET class`);
482
- - другой ключ или класс → новый объект: `create` нового, ссылки — правкой, прежний — `delete`;
483
- - удаление → только в `read committed` (иначе `isolation_level`).
484
- 11. **Классы — в арендаторе сессии.**
485
- - `letopis sync` с токеном арендатора → пишет классы ему; другие арендаторы их не видят;
486
- - общие для всех классы → в System (сид установки).
487
- 12. **id класса с ключом не передают** — он вычисляется (`db.idOf('Shop', { name: 'Центр' })`).
488
- - чужой id → `id_mismatch`;
489
- - id для класса без ключа → `id_from_db`.
490
- 13. **Имена методов зарезервированы**:
491
- - хаб с именем `count`, `sort`, `set`, `run`, `sql`, `auth` … → `invalid_class` от `hub()`, `syncModels` и базы;
492
- связь → от базы (список — `RESERVED_CLASS_NAMES`).
493
- 14. **Права запрещают по умолчанию.**
494
- - новый арендатор без правил → ничего не видит;
495
- - быстрый старт → `up({ tenant })` и `createTenant` (`grantAll`);
496
- - права `auth.*` (пароли, сессии, имперсонация) → есть у сервиса и системного администратора.
497
- 15. **Ошибки:**
498
- - `err.code` — строчный код (`exists`, `conflict`, `invalid_data` …), текст — `letopis: <код>: …`;
499
- - ошибка данных (`ValidationError`) → `err.issues: [{ path, keyword, message }]`;
500
- - `err.hint` — что делать, `err.docs` — ссылка на код в разделе «Ошибки» README;
501
- - неизвестный класс или роль → `invalid_class`, похожие имена в тексте и в `err.params.similar`;
502
- - неизвестная опция `connect()`, `up()`, `db.load()`, `db.watch()`, `createMcpServer()` → `invalid_data` с
503
- похожими;
504
- - опечатка в поле фильтра, `sort()` или агрегате → `invalid_query` с похожими до запроса (у записи — до записи);
505
- сверяется первый ключ пути, путь внутрь поля-объекта — нет.
506
- 16. **Подписка с `'now'` — с момента ответа базы.**
507
- - `db.watch()` → возвращается сразу, а момент «now» подписка берёт первым запросом к базе;
508
- - запись сразу после `db.watch()` → может зафиксироваться раньше и в поток не попасть;
509
- - нужна гарантия → `await sub.ready()` перед записью, сохранённый курсор или `'start'`;
510
- - параллельные `next()` → идут по очереди и получают разные события.
511
- 17. **Терминал отдаёт строки последнего шага.**
512
- - `…Payment().create(…).Wallet().update(…).rows()` → вернёт строки `Wallet`, а не созданной операции;
513
- - нужна операция → заканчивайте цепочку на ней.
514
- 18. **Очередь батча — своя у фасада.**
515
- - `db.batch('x')` у корня, у каждого `await db.as(…)` и у каждой `db.begin()` → разные очереди;
516
- - план → исполняется от имени того, кто его поставил;
517
- - копите планы через один объект фасада.
556
+ - `maintain()` и `connect({ maintain: '1h' })` (истёкшие сессии, коды, замки, очередь сигналов; журнал не трогает) →
557
+ администраторское подключение (`allowBypassRls` или корень с `connect({ admin })`), системный администратор или
558
+ право `letopis.maintain`; иначе `acl_denied` (`sql-time.maintain_denied`), в том числе сервису по ключу. По
559
+ расписанию отказ — предупреждение `time.maintain_failed`, а не исключение.
560
+ - `close()` у фасада `svc.as()` ничего не делает — закрывайте исходное подключение, один раз; скрипт не завершается →
561
+ какое-то `connect()` не закрыто.
562
+
563
+ ## Грабли
564
+
565
+ 1. Глаголы без терминала (`.rows()`, `.first()` …) ничего не исполняют.
566
+ 2. `create()` занятого ключа → `exists`, а не перезапись; заменить — `upsert()`, поправить — `update()` (правила 1, 7).
567
+ 3. Пустой ответ — не ошибка, а «не найдено» (правило 6); `delete` с непустым `expect` без целей → `conflict` (правило 2).
568
+ 4. `db.begin()` — `read committed`, принимает только `{ reason }`: `db.begin(fn)` → `invalid_query` (`api.begin_arg`).
569
+ После ошибки транзакция бросает исходную ошибку на каждом вызове и `commit()`; шаблон — правило 3.
570
+ 5. `forUpdate()` вне `db.begin()` → `tx_required`; с `count()`, `paths()`, `versions()`, агрегатом, `asOf()`,
571
+ `withDeleted()`, `deep()` или глаголом → `lock_unsupported` (правило 4).
572
+ 6. Вошедший пользователь по умолчанию — в **личном** арендаторе: `invalid_class` на только что записанный класс →
573
+ проверьте `db.tenant` (правило 5).
574
+ 7. Ключ, класс, id, арендатор и владелец строки неизменны → `immutable_key`; другой ключ — новый объект.
575
+ 8. Имена методов (`count`, `sort`, `set`, `run`, `sql`, `auth` …) — не имена классов: `invalid_class`
576
+ (`RESERVED_CLASS_NAMES`).
577
+ 9. Имя роли конца одно на арендатора (это имя шага цепочки): во всех классах роль с одним именем ведёт на одни и те же
578
+ классы. Роли `Group` и `Object` (системный класс `rule`, ведут на `Resource`), `User` и `Tenant` (класс `member`,
579
+ ведут на `Account`) заняты системой: конец с таким именем на другой класс → `invalid_class` от `syncModels`
580
+ (`model.end_role_system`; роль своего класса — `model.end_role_taken`) и от базы (`sql-write.end_other_class`).
581
+ Имена классов (`Group`) не заняты — заняты имена ролей: `ends: { Team }`, а не `ends: { Group }`.
582
+ 10. Загрузчику и `maintain()` нужно администраторское подключение; обычному коду — роль приложения без обхода RLS.
583
+ 11. Класс с историей строк не удаляется (`invalid_class`): класс, который придётся удалять, заводят с `history: false`.
584
+ 12. Предупреждения `letopis: …` в консоли (фильтр `undefined`, `sort()` не в конце, `withDeleted()` не у своего шага) —
585
+ почти всегда ошибка в коде.
586
+ 13. SQLSTATE `22P02` (значение не того типа, и в `db.sql`) → `invalid_query` (`api.db_bad_value`), исходная —
587
+ `err.cause`.