letopis 1.4.2 → 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 (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
@@ -1,469 +1,587 @@
1
- # letopis 1.4 — шпаргалка для агента
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; английская версия — [AGENT-CHEATSHEET.en.md](AGENT-CHEATSHEET.en.md). Переход с 0.21 — [MIGRATION.md](MIGRATION.md);
5
- машинный каталог API и ошибок — `docs/api-contract.json` в репозитории. Примеры ниже идут по порядку и образуют одну
6
- программу; тест `docs` исполняет её против PostgreSQL 18, так что код сверен с пакетом.
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
+ концу**. Пример — иллюстрация, классы свои:
58
+
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)` (без аргумента погасил бы сессию сервиса).
202
+
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
+ - Ключ сервиса → файл с правами только владельца или хранилище секретов, не в репозиторий и не клиенту.
218
+
219
+ ### 6. Ошибки → текст для человека
220
+
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).
229
+
230
+ <!-- run -->
231
+ ```ts
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
+ }
252
+ ```
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`), не маскируется.
7
286
 
8
287
  ## Модель за минуту
9
288
 
10
- - PostgreSQL 18+, Node 22+. Три таблицы схемы установки `v2.<имя>`: `entity` (текущая версия каждой сущности),
11
- `log` (журнал всех версий), `secret` (креды и сессии). Классы, аккаунты, права — тоже строки `entity`.
12
- - Проверки делает **база**: триггеры (данные по JSON Schema, ссылки, ключи, журнал) и политики RLS (арендатор и
13
- права). Сырой SQL от прикладной роли ведёт себя так же, как библиотека.
14
- - Хаб (`hub`) — сущность, связь (`link`) — сущность с концами. Концы лежат в `row.links`, поля — в `row.data`.
15
- - id: класс с ключом — v5 от (класс, значения ключа) в арендаторе, его можно вычислить заранее (`db.idOf`);
16
- класс без ключа — id выдаёт база. Уникальность — только ключ.
17
- - Арендатор = аккаунт. Строки принадлежат арендатору **сессии**; чужие не видны. Права включены всегда и по
18
- умолчанию **запрещают**.
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)[имя]()`.
19
300
 
20
301
  ## Рабочий цикл
21
302
 
22
- ### 1. Установка: `up()` и роль приложения
303
+ ### 1. Установка: `up()`, роль приложения, ключ сервиса
23
304
 
24
305
  <!-- run -->
25
306
  ```ts
26
- import { up, connect, syncModels, inc, gt, or, cursorOf } from 'letopis'
27
-
28
- const ADMIN = 'postgres://postgres:pass@localhost:5432/app' // суперпользователь или роль с CREATEROLE
29
- const r = await up({ dsn: ADMIN, schema: 'myapp', // схема 'v2.myapp'
30
- tenant: { name: 'Салон', user: { name: 'Ольга', login: 'olga@salon.ru', password: 'Сирень-2026!' } } })
31
- // r: { schema, created, upgraded, revision, serviceKey, migrations, analyzed, tenant, … }
32
- // r.serviceKey ('lts_…') — ключ сервиса, выдаётся при первой установке (снова — только если в установке не осталось
33
- // ключей API, кроме отозванных). Сохраните его.
34
- // r.tenant: { tenant, user, token, created } — арендатор «Салон» и его первый пользователь Ольга
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!
35
325
  ```
36
- - `up()` ждёт базу, создаёт её, проверяет PostgreSQL 18+ и UTF8, ставит роли `letopis_owner` и `letopis_app`.
37
- Опции: `fresh` (пересоздать схему), `upgrade` (накатить миграции), `seeds` (имя сида пакета, например
38
- `'booking'` — демо-домен, или путь к своему `.sql`), `history` (политика хранения), `allowReset`, `serviceKey`,
39
- `quiet`, `pglite: true` (база для разработки без PostgreSQL; затем `connect({ dsn: r.pglite.dsn, listen: false,
40
- max: 1, allowBypassRls: true, … })` — подключение суперпользователя, RLS на нём не действует).
41
- - `signals` — что видно в сигналах `NOTIFY` любой роли, способной подключиться к базе. Канал — полное имя схемы
42
- (`LISTEN "v2.myapp"`), нагрузка сигнала записи: `'class'` (по умолчанию) — `Класс`; `'tenant'` —
43
- `Класс@токен арендатора` (кэш сбрасывается только в арендаторе записи); `'opaque'` — `токен класса@токен
44
- арендатора`; `'none'` — сигналов нет (кэш — только `ttlOnly`, подписка опрашивает журнал). Служебные сигналы:
45
- `session:<16 hex>` — погашенная сессия, `session:*` и `*` — сбросы (`reset`). Режим хранится в схеме: `up()` без
46
- опции его не меняет; после смены — перезапуск процессов. Ключ сигналов (точный сброс кэша) получают сервисы — право
47
- `letopis.signals`. Результат `up()`: ещё `signals` и `warnings` (PostgreSQL ниже 18.2 — обновить).
48
- - После обновления пакета вызывайте `up()`: функции схемы накатывает только он. `connect()` предупреждает, если штамп
49
- движка в комментарии схемы (версия пакета и хэш его SQL) не совпадает с пакетом: схема старее — `up()`, новее —
50
- обновить пакет.
51
- - `tenant` — первый арендатор и пользователь без своего сида: арендатор получает правило «аутентифицированным можно
52
- всё» (`grantAll`, по умолчанию да, вес 10), пользователь — членство с ролями `user.roles` (по умолчанию `['owner']`),
53
- пароль для входа по логину `login` и сессию в арендаторе. Повтор с тем же именем и логином ничего не дублирует и роли
54
- существующего членства не меняет (id — v5 от «tenant:<имя>» и «user:<логин>»).
55
- Ещё арендатор — `createTenant(postgres(ADMIN), 'v2.myapp', { name, user })` (нужно администраторское подключение).
56
- - Приложение подключается **прикладной LOGIN-ролью**, которая состоит только в `letopis_app`:
57
- `create role myapp login password '…'; grant letopis_app to myapp;`. Подключение суперпользователем —
58
- ошибка `bypass_rls` (RLS не действовал бы); администраторское подключение — только явно `allowBypassRls: true`.
59
- - Аккаунты живут в арендаторе System; системный администратор (член System с ролью `admin`; назначает его свой сид
60
- установки или загрузчик на администраторском подключении) создаёт их цепочкой `admin.Account().create({ name, categories })`. Свой сид (`seeds: ['./my.sql']`, функции владельца `sys_create`,
61
- `sys_grant_all`, маркеры `@SCHEMA@`, `@SYSTEM@`) остаётся для особых случаев.
62
-
63
- ### 2. Вход: сервис, пароль, сессия
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. Вход пользователя
64
340
 
65
341
  <!-- run -->
66
342
  ```ts
67
- const APP = 'postgres://myapp:…@localhost:5432/app'
68
- const svc = await connect({ dsn: APP, schema: 'v2.myapp', apiKey: r.serviceKey! }) // сервис: ключ → сессия
69
- const SALON = r.tenant!.tenant, OLGA = r.tenant!.user!
70
- await svc.auth.setPassword({ account: SALON, identifier: 'salon@example.com', password: 'Сирень-2026!', confirmed: true })
71
- const login = await svc.auth.verifyPassword({ identifier: 'salon@example.com', password: 'Сирень-2026!' })
72
- // login: { account, token } | null (неверный пароль или кред не подтверждён — null, без ошибки)
73
- const db = await connect({ dsn: APP, schema: 'v2.myapp', token: login!.token })
74
- db.tenant // арендатор сессии: по умолчанию собственный арендатор аккаунта (здесь SALON)
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
75
347
  ```
76
- - `connect({ dsn | sql, schema: 'v2.имя', token | apiKey, max, listen, cache, onQuery, slowMs, maintain,
77
- allowBypassRls, models })`. `schema` — **полное** имя с `v2.`.
78
- - `db.auth`: `setPassword`, `verifyPassword`, `issueApiKey`, `verifyApiKey`, `issueKeySecret`, `verifyKeySecret`,
79
- `enrollTotp`, `verifyTotp`, `totpEnabled`, `issueOtp`, `verifyOtp`, `link`, `lookup`, `credentials`, `revokeCredential`,
80
- `setFlags`, `sessionFor(account, { ttl })`, `session(token)`, `refresh`, `revoke`, `revokeAll`, `switch(tenant)`,
81
- `tenants()`, `purgeAccount`. Проверку паролей, OTP и TOTP, выпуск сессий, сведения о сессии по токену и
82
- подтверждение кредов вызывает **сервис** (права `auth.*`); обычному пользователю — `acl_denied`. Сигнатуры:
83
- `issueApiKey({ account, name?, ttl? })` → `{ key, id }`; `verifyApiKey(key)` → `{ token, account } | null`;
84
- `sessionFor(account, { ttl })` → строка-токен — дальше `connect({ dsn, schema, token })`, а для работы в чужом
85
- арендаторе по членству — `db.auth.switch(арендатор)` (без него сессия — в собственном арендаторе аккаунта). Токен для
86
- CLI (`--token`) — тот же `token` из `verifyPassword`/`verifyApiKey`/`sessionFor`. Администраторское подключение
87
- (`allowBypassRls`) печатает предупреждение «RLS не действует» — так и должно быть.
88
- - Второй фактор: `verifyPassword({ identifier, password, totp })` и `lookup({ kind, identifier, totp })` после первой
89
- проверки сверяют код TOTP (право `auth.totp`, счётчик неудач и блокировка — общие с `verifyTotp`) → сессия со
90
- способами `[вид, 'TOTP']`, по ней пользователь с TOTP меняет свои креды сам. Неверный или повторный код и код у
91
- аккаунта без TOTP — `null`; без `totp` код не проверяется.
92
- - Ключи сервиса: по сессии своего ключа сервис сам выпускает и отзывает свои ключи (`issueApiKey`,
93
- `issueKeySecret` с `account` — id сервиса; `revokeCredential(id)`). Отзыв ключа гасит только сессии этого ключа —
94
- ротация без простоя: новый ключ → процессы на него → отзыв старого. Последний действующий ключ сервиса не
95
- отзывается (`acl_denied`); отключить сервис — `enabled: false` у его аккаунта.
96
- - Сессия по токену: `svc.auth.session(token)` → `{ account, tenant, member, expiresAt, loginAt, methods } | null`.
97
- Сервис (право `auth.session`) узнаёт, чья сессия, в каком арендаторе она работает и цело ли там членство, не
98
- подключаясь по токену и не продлевая сессию. Неверный, погашенный или истёкший токен и отключённый аккаунт —
99
- `null`. Так веб-сервис проверяет сессию из cookie в начале каждого запроса: чтение по погашенной сессии ошибки не
100
- даёт, ответы просто пустые.
101
- - Работа в чужом арендаторе по членству: `await user.auth.tenants()` → `[{ tenant, roles }]`, затем
102
- `await user.auth.switch(tenant)` (только от корня подключения). Имперсонация сервисом:
103
- `const u = await svc.as(account, { tenant, owner })` — проверяет база, в журнале `author` — пользователь,
104
- `agent` — сервис. `db.as()` **асинхронный**.
105
-
106
- ### 3. Модель на Zod и `letopis sync`
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`.
353
+
354
+ ### 3. Модель на Zod
107
355
 
108
356
  <!-- run -->
109
357
  ```ts
110
- // models.ts
358
+ // models.ts (здесь же — Obj, Peer, Hold из правила 1)
111
359
  import { z } from 'zod'
112
- import { hub, link, one, many } from 'letopis/model'
113
- export const Shop = hub('Shop', { name: z.string(), city: z.string().optional() }, { key: ['name'] })
114
- export const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0).default(0) },
115
- { key: ['number'], ends: { Shop } }) // голая модель в ends = one(Shop), обязательный конец
116
- export const Payment = link('Payment', { opId: z.string(), amount: z.number().int() },
117
- { key: ['Wallet', 'opId'], ends: { Wallet } }) // ключ из конца и поля: идемпотентность по opId
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
118
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 }
119
369
  ```
120
- - Полная форма: `hub(name, { alias, extends, abstract, ends, fields: z.object(…) | { поля }, key, onDelete, history,
121
- ownerDefault: 'tenant' | 'actor', cache: { ttl }, meta, derivedBy, aggregate })`; `link` — так же. `derivedBy` и
122
- `aggregate` — разделы 12 и 13 ниже. `extends` работает и в короткой форме, но типы данных потомка TypeScript
123
- выводит только в полной.
124
- - Концы: `one(A)` (обязательный), `one(A, B)` (союз), `one('Имя')` (цель по имени — ссылка класса на себя),
125
- `.optional()`, `.onDelete('restrict' | 'cascade' | 'unset')` (по умолчанию `restrict`; `unset` — только для
126
- необязательного), `many(A, { min, max, onDelete })`.
127
- - Ключ — поля (обязательные строки) и одиночные обязательные концы. Ключ наследуется от `extends`.
128
-
129
- Модели в базу (классы пишутся в арендатора сессии; ужесточение, которому мешают строки, — `tightening_conflict`):
130
- <!-- run -->
131
- ```ts
132
- const synced = await syncModels(db, [Shop, Wallet, Payment, Note], { apply: true })
133
- // synced: { applied, created, updated, unchanged, skipped, lost } (lost — refine и прочее, чего JSON Schema не выражает)
134
- ```
135
- ```sh
136
- npx letopis sync ./models.js --dsn "$APP" --schema v2.myapp --token "$TOKEN" # сухой прогон (.ts — Node 22.18+)
137
- npx letopis sync ./models.js --dsn "$APP" --schema v2.myapp --token "$TOKEN" --apply # применить
138
- npx letopis types --dsn "$APP" --schema v2.myapp --token "$TOKEN" --out types.ts # типы классов из базы
139
- ```
140
- Типы цепочек: `connect({ …, models: [Shop, Wallet, Payment, Note] })` — модели только для типов.
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`.
141
377
 
142
378
  ### 4. Запись цепочками
143
379
 
144
- Глагол — звено плана; исполняет **терминал** (`rows`, `first`, `ids`, `count` …). Весь план — одна транзакция.
380
+ Глагол — звено плана; исполняет весь план одной транзакцией **терминал** (`rows`, `first`, `ids`, `count` …).
381
+
145
382
  <!-- run -->
146
383
  ```ts
147
- const [shop] = await db.Shop().create({ name: 'Центр', city: 'Казань' }).rows()
148
- 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 — из пути
149
386
  const [n] = await db.Note().create({ text: 'привет' }).rows()
150
387
  await db.Note(n.id).update({ text: 'пока' }).rows() // слияние патча, новая версия
151
- const [up1] = await db.Shop().upsert({ name: 'Центр', city: 'Уфа' }).rows() // замена целиком; up1.$upsert:
152
- // 'created' | 'updated' | 'unchanged'
153
- // деньги: приращение в базе, без чтения; Payment с ключом [Wallet, opId] — повтор даёт exists
154
- await db.Wallet(w).Payment().create({ opId: 'op-1', amount: 500 }).Wallet().update({ balance: inc(500) }).rows()
155
- await db.Wallet(w).update({ balance: inc(-300) }).first() // баланс 200; итог ниже minimum (0) — invalid_data
156
- const cur = (await db.Wallet(w).first())!
157
- await db.Wallet(w).update({ balance: 0 }, { rev: cur.rev }).first() // строгий режим: чужая правка — conflict
158
- // слоты концов после create / update / upsert: .Роль.set(x) .unset() и для many — .add(x) .remove(x)
159
- const [west] = await db.Shop().create({ name: 'Запад' }).rows()
160
- await db.Wallet(w).update({}).Shop.set(west).rows()
161
- const [w2] = await db.Shop(shop).Wallet().create({ number: 'W-2' }).rows()
162
- const [w200] = await db.Wallet(w2).rekey({ number: 'W-200' }).rows() // новый ключ → новый id, ссылки переведены
163
- await db.Note(n.id).anonymize(['text']).rows()
164
- const b = db.batch('init'); b.Shop().create({ name: 'А' }); b.Shop().create({ name: 'Б' })
165
- 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[][] одной транзакцией
166
398
  ```
167
- - `inc(n, { start })` — только в `update`; поля нет — ошибка (или `start`). Маркер не получить из JSON.
168
- - `{ rev }` — только при одной цели шага (`rev_ambiguous`). Затронуто меньше строк, чем нашёл шаг, — откат плана с
169
- `target_not_found` (не видна), `conflict` (`rev` разошёлся) или `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`.
170
404
 
171
405
  ### 5. Чтение цепочкой
172
406
 
173
407
  <!-- run -->
174
408
  ```ts
175
- await db.Shop({ city: 'Уфа' }).Wallet().rows() // Row[]: { id, class, rev, tenant, owner, links, data, tags, at, author, op, … }
176
- const rich = await db.Wallet({ balance: gt(100) }).count() // число СУЩНОСТЕЙ; пути — count({ paths: true })
177
- const paid = await db.Wallet(w).Payment().sum('data.amount') // sum | avg | min | max (null на пустом), countBy
178
- await db.Shop(west).Wallet().Payment().paths() // [{ Shop: Row, Wallet: Row, Payment: Row }]
179
- await db.Wallet(w).first() // Row | null
180
- const page = await db.Wallet().sort('data.number', 'desc').limit(20).rows()
181
- const next = await db.Wallet().sort('data.number', 'desc').after(cursorOf(page.at(-1)!, 'data.number')).limit(20).rows() // keyset
182
- await db.Note().withDeleted().rows() // с удалёнными ($deleted: true)
183
- await db.Wallet(or({ number: 'W-1' }, { number: 'W-200' })).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()
184
417
  ```
185
- - Фильтр шага: id, список id, строка ответа, объект полей любой глубины, `or(…)`, операторы `ne gt gte lt lte
186
- between inList like ilike starts ends has hasAny hasAll exists isNull not`, роли концов `{ Shop: id }`,
187
- `{ Shop: null }`. Поля — в `row.data`, не в корне строки.
188
- - Шаг — имя класса, алиас или роль конца; шаг по родителю отдаёт и потомков (`.exact()` — только свой класс);
189
- `.deep(max)` — дерево (`$depth`); `.tags()`, `.owner()`, `.alias(имя)`, `db.entity(row)`.
190
- - `after(cursorOf(строка, поле))` требует `sort()` по тому же полю; листает `rows()`, `versions()` и `paths()` (курсор
191
- пути — `cursorOf(путь, поле)`, ключи пути — в порядке шагов). `count()`, агрегаты, `diff()`, `blame()` курсор не
192
- учитывают.
193
-
194
- ### 6. Удаление с превью
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()`.
430
+
431
+ Операторы фильтра и их ключи в JSON-фильтре MCP (инструмент `letopis_query`, п. 11):
432
+
433
+ <!-- ops -->
434
+ | TypeScript | MCP | Смысл |
435
+ |---|---|---|
436
+ | `ne(v)` | `{ "$ne": v }` | Не равно. Подходят и строки без поля или с `null`; `ne(null)` — строки, где у поля есть значение |
437
+ | `gt(v)` | `{ "$gt": v }` | Больше. Сравнение — по типу поля из описания класса (числа — как числа, даты — как даты), поле без описания — как текст; строки без поля не подходят |
438
+ | `gte(v)` | `{ "$gte": v }` | Больше или равно; сравнение — как у `gt` |
439
+ | `lt(v)` | `{ "$lt": v }` | Меньше; сравнение — как у `gt` |
440
+ | `lte(v)` | `{ "$lte": v }` | Меньше или равно; сравнение — как у `gt` |
441
+ | `between(a, b)` | `{ "$between": [a, b] }` | От `a` до `b` включительно; сравнение — как у `gt`. Если `a` больше `b`, не подходит ничего |
442
+ | `inList([v, …])` | `{ "$in": [v, …] }` | Одно из значений списка. Пустой список не находит ничего; `null` в списке не находит строк без значения — для них `isNull` |
443
+ | `like(s)` | `{ "$like": "s" }` | Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один; буквальные `%` и `_` — с обратной косой чертой |
444
+ | `ilike(s)` | `{ "$ilike": "s" }` | Как `like`, но без учёта регистра |
445
+ | `starts(s)` | `{ "$starts": "s" }` | Текст начинается с `s`, с учётом регистра. Это `like(s + '%')`: `%` и `_` внутри `s` не экранируются и работают как в шаблоне |
446
+ | `ends(s)` | `{ "$ends": "s" }` | Текст заканчивается на `s`, с учётом регистра. Это `like('%' + s)`: `%` и `_` внутри `s` не экранируются и работают как в шаблоне |
447
+ | `has(v)` | `{ "$has": v }` | Поле-массив содержит элемент `v`. Тип важен: `has(5)` найдёт число 5, но не строку `'5'` |
448
+ | `hasAny([v, …])` | `{ "$hasAny": [v, …] }` | Поле-массив содержит хотя бы один элемент списка. Находит только строковые элементы; пустой список не находит ничего |
449
+ | `hasAll([v, …])` | `{ "$hasAll": [v, …] }` | Поле-массив содержит все элементы списка, в любом порядке. Тип важен, как у `has`; пустой список подходит любой строке, даже без поля |
450
+ | `exists(b = true)` | `{ "$exists": b }` | Поле есть в данных (`true`, по умолчанию) или его нет (`false`); значение не важно — поле с `null` тоже есть |
451
+ | `isNull()` | `{ "$isNull": true }` | В поле `null` или поля нет совсем; отличить одно от другого помогает `exists` |
452
+ | `not(x)` | `{ "$not": x }` | Отрицание: `x` — оператор (`not(gte(1000))` — меньше 1000 или поля нет) или простое значение — тогда это «не равно», как `ne` |
453
+ | `or(f1, f2, …)` | `{ "$or": [f1, f2, …] }` | Хотя бы один из фильтров. Ставится вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и» |
454
+ <!-- /ops -->
455
+
456
+ ### 6. Удаление и возврат
457
+
458
+ Правила 1–3 в деле: занятость объекта, удаление по превью, возврат каскада одной транзакцией.
195
459
 
196
460
  <!-- run -->
197
461
  ```ts
198
- const preview = await db.Shop(shop).delete().rows() // ничего не удаляет: всё замыкание каскада,
199
- // в каждой строке $action: 'delete' | 'unset' | 'restrict' и $depth (0 — цель; у unset и restrict его нет). Wallet ссылается на Shop
200
- // с onDelete по умолчанию 'restrict' — в превью он с $action 'restrict', а delete({ confirm: true }) даст delete_restricted
201
- await db.Note(n.id).delete({ confirm: true }).rows() // надгробия (row.$deleted); каскад — по onDelete концов
202
- await db.Note(n.id).restore().rows() // вернуть удалённое (id прежний); живое — not_deleted
203
- await db.Note(n.id).delete({ confirm: true }).rows()
204
- await db.Note(n.id).purge({ confirm: true }).rows() // стереть историю удалённого; без confirm — превью
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'
205
470
  ```
206
471
 
207
472
  ### 7. Загрузчик
208
473
 
209
- Только **администраторское** подключение (`allowBypassRls` и право стать владельцем), иначе `admin_required`:
210
474
  <!-- run -->
211
475
  ```ts
212
- 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 })
213
477
  const rep = await adm.load({
214
- Shop: [{ name: 'Север' }],
215
- Wallet: [{ number: 'W-9', Shop: { $key: ['Север'] } }], // ссылка: id | { $key: [...] } | { $ext }
216
- }, { existing: 'skip' }) // duplicates 'error'|'first'|'last', existing 'error'|'skip'|'update', commitEvery, withHistory, verify, tenant, as
478
+ Team: [{ name: 'g5' }],
479
+ Item: [{ code: 'i9', Team: { $key: ['g5'] } }], // ссылка: id | { $key: [...] } | { $ext }
480
+ }, { existing: 'skip' })
217
481
  // rep: { rows, loaded, updated, skipped, duplicates, byClass, external, portions, ms }
218
482
  ```
219
- Первая заливка: `adm.load(rows, { initial: true })`, `letopis load --initial`, `letopis import --initial` — только в пустую
220
- схему (иначе `not_empty`), одной транзакцией; на миллионе объектов — примерно в 2,2 раза быстрее обычной загрузки;
221
- без `commitEvery`, `existing` и `{ as }`.
222
-
223
- Файлы NDJSON (объект на строку, все файлы — одна загрузка):
224
- ```sh
225
- npx letopis load shops.ndjson wallets.ndjson --dsn "$ADMIN" --schema v2.myapp --token "$TOKEN" [--class Wallet] \
226
- [--duplicates first] [--existing skip] [--commit-every 100000] [--verify] [--initial]
227
- # {"$class":"Shop","name":"Юг"}
228
- # {"$class":"Wallet","number":"W-10","Shop":{"$key":["Юг"]}}
229
- # {"$class":"Note","text":"x","$ext":"n1"} ← $ext — внешний ключ строки для ссылок { "$ext": "n1" }
230
- ```
231
- Ошибка — код первой проблемы; `err.detail.rows` — первые 100 нарушений (номер строки, код, подробности),
232
- `err.detail.total` — сколько всего, `err.detail.truncated` — список обрезан. Полиморфной ссылке нужен `$class`.
233
- `$tags` — теги строки, `$owner` — владелец.
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, «Массовая загрузка».
234
487
 
235
- ### 8. Права: выдать право
488
+ ### 8. Права
236
489
 
237
- Правило = группа (`Resource` категории `ACCOUNT`) → объект (`API` — эндпоинт, `READ`/`WRITE`/`DELETE` — строки
238
- класса с условием) с `allow`/`deny` и весом. Решает одно правило: наибольший вес, при равном — запрет.
239
- Вес правила арендатора больше 999 урезается до 999. Писать строки прав в арендаторе может тот, у кого есть `WRITE` на них
240
- (здесь — сам арендатор по правилу «можно всё»).
241
490
  <!-- run -->
242
491
  ```ts
243
- // членство Ольги в салоне создал up({ tenant }) с ролью owner; делаем её сотрудником с ролью staff
244
- await db.member(db.idOf('member', { User: OLGA, Tenant: SALON })).update({ roles: ['staff'] }).rows()
245
- const [staff] = await db.Resource().create({ alias: 'staff:ACCOUNT', category: 'ACCOUNT', pattern: { roles: '{staff}' } }).rows()
246
- const [api] = await db.Resource().create({ alias: 'reports:API', category: 'API', pattern: { endpoint: 'shop.reports.*' } }).rows()
247
- await db.rule().create({ permission: 'allow', weight: 50 }).Group.set(staff).Object.set(api).rows()
248
- const [del] = await db.Resource().create({ alias: 'wallet:DELETE', category: 'DELETE', pattern: { class: 'Wallet' } }).rows()
249
- await db.rule().create({ permission: 'deny', weight: 50 }).Group.set(staff).Object.set(del).rows() // сильнее «можно всё» (10)
250
- // проверка от имени Ольги (сервис действует от её имени в салоне)
251
- const olga = await svc.as(OLGA, { tenant: SALON })
252
- const canReport = await olga.acl.check('shop.reports.view') // { allow: true, rule: { weight: 50, … }, code?, message? }
253
- 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 }
254
498
  ```
255
- - Членство — строка `member` с концами `User` и `Tenant`, ключ `[User, Tenant]`: новое членство —
256
- `db.member().create({ roles }).User.set(аккаунт).Tenant.set(арендатор)`, если его ещё нет (иначе `exists`).
257
- - Группа: `{ categories: '{Staff}' }` (категории аккаунта) или `{ roles: '{staff}' }` (роли членства). Маска
258
- (`categories`, `roles`, `endpoint`, `class`) — **только строка**: массив (`['staff']`), число или `null` база
259
- отклоняет с `invalid_data`.
260
- Условие объекта: `{ class: 'Wallet', owner: '$actor' }`, `{ class: 'Wallet', data: { … } }`; маска класса —
261
- с потомками, `'{rule,Resource,member}'` — несколько классов. `deny` с `{ code, message }` — свой отказ эндпоинта.
262
- - Права пересчитываются базой сами; убрать право — `db.rule(id).delete({ confirm: true }).rows()`.
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, «Права».
263
503
 
264
504
  ### 9. Подписка и кэш
265
505
 
266
506
  <!-- run -->
267
507
  ```ts
268
- const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (по умолчанию) | 'start' | курсор; pollMs; уход из classes — op 'delete'
269
- const lagMs = await sub.lag() // отставание, мс
270
- 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()
271
511
  let cursor = ''
272
512
  for await (const ev of sub) { // ev: { cursor, row, id, class, op, rev, at }
273
- cursor = ev.cursor // продолжить: db.watch({ from: cursor })
274
- 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() — остановить
275
515
  }
276
- const shops = await db.Shop().cache({ ttl: 300 }).rows() // кэш результатов; сброс по своим записям и сигналам
277
- db.cache.stats(); db.cache.clear()
516
+ const teams = await db.Team().cache({ ttl: 300 }).rows() // кэш результатов; сброс по своим записям и сигналам
278
517
  ```
279
- События — без потерь и повторов, под RLS; курсор старше яруса `all` политики хранения — `cursor_expired`.
280
- С `from: 'now'` подписка видит только то, что зафиксировано после её начала.
518
+ - События — без потерь и повторов, под RLS. Без `await sub.ready()` запись сразу после `watch()` может в поток не
519
+ попасть.
281
520
 
282
- ### 10. Срез на дату и история
521
+ ### 10. История и срез на дату
283
522
 
284
523
  <!-- run -->
285
524
  ```ts
286
- const was = await db.Wallet(w).asOf('2026-10-01T00:00:00Z').first() // как было на момент (строка ISO или Date)
287
- await db.Shop(shop).Wallet().asOf(new Date('2026-10-01')).rows()
288
- const history = await db.Wallet(w).versions() // все версии; версия удаления — $deleted
289
- await db.Wallet(w200).versions({ follow: true }) // и через смену id (reclass, rekey)
290
- await db.Wallet(w).diff() // что изменилось: предыдущая версия → последняя
291
- await db.Wallet(w).blame() // кто, когда и почему (опция reason глагола) менял поля
292
- ```
293
- `diff()` — массив `DiffResult`: `{ id, class, from, to, changes }`; `from` и `to` — `{ rev, at, id? }`, у `to` ещё
294
- `deleted?` (тогда `changes` пуст), `from: null` — объекта тогда не было. Изменение `DiffChange` —
295
- `{ path, kind: 'added' | 'removed' | 'changed', before?, after? }`. `blame()` — массив `BlameResult`:
296
- `{ id, class, rev, complete, fields }`; `fields` — листья `BlameField` по порядку пути:
297
- `{ path, rev, at, author, op, exact, agent?, reason?, id? }`. `path` — массив сегментов: `['data', 'price']`; пустые
298
- `agent`, `reason`, `before`, `after` опускаются. Типы — из `letopis`.
299
- Класс с `history: false` не даёт `versions`, `asOf`, `restore` и событий подписки.
300
-
301
- ### 11. Импорт из 0.21
302
-
303
- Схема 1.0 ставится заранее (`up()` без демо-сида), подключения — администраторские. Источник — таблицы 0.21
304
- (`Schema`, `Entity`, `Account`, `Credential`, `Resource`, `Rule`) в схеме `v1.<имя>` любой базы.
305
- В репозитории есть фикстура `lib/test/fixtures/v021.sql` — дамп таблиц с маркером `<SCHEMA>`: замените его и
306
- выполните файл.
307
- ```ts
308
- import postgres from 'postgres'; import { readFile } from 'node:fs/promises'
309
- const sql = postgres(ADMIN)
310
- await sql.unsafe((await readFile('lib/test/fixtures/v021.sql', 'utf8')).replaceAll('<SCHEMA>', 'v1.fixture'))
311
- await up({ dsn: ADMIN, schema: 'imported' }) // v2.imported
312
- ```
313
- ```sh
314
- npx letopis import --from "$ADMIN" --from-schema v1.fixture --dsn "$ADMIN" --schema v2.imported \
315
- --tenant Импорт --keyless Service
316
- ```
317
- - `--tenant <имя>` — арендатор для данных System 0.21; `--keyless <Класс>` — класс, чьи данные нарушают ключ
318
- (без него импорт фикстуры останавливается: два объекта 0.21 дают один id 1.0); `--rename Старое=Новое` — имена,
319
- занятые методами 1.0; `--update` — дописать новые версии; `--commit-every N` — фиксировать порциями;
320
- `--partition <имя>` — партиция источника 0.21 (по умолчанию `entity`).
321
- - Отчёт — JSON в конце вывода (перед ним — строки хода `letopis.import: …`): `classes`, `notes`, `source`/`target`
322
- (объекты, версии и живые по классам в 0.21 и 1.0), `tenants`, `accounts`, `credentials`, `resources`, `rules`,
323
- `objects`, `uuidFields`, `droppedFields`, `ownerLost`, `verify`, `ms`; код выхода 1, если `verify` не прошёл.
324
- Повтор импорта дописывает недостающее без дублей.
325
-
326
- ### 12. Проекторы
327
-
328
- Проектор строит строки производных классов из журнала: сводки, карточки, всё, что можно считать с отставанием.
329
- Производный класс помечен `derivedBy: 'имя-проектора'`, и его строки пишет только этот проектор. Сам производный
330
- класс (свой или унаследованный) и его предков заводит, меняет и удаляет только администратор: `syncModels`
331
- администраторским подключением с сессией арендатора (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или аккаунт с
332
- правом System `letopis.projector`; у сессии приложения — `acl_denied`. Итоги производного класса видит каждый, кому
333
- правила дают `READ` на него, даже без прав на строки источников.
334
- ```ts
335
- const p = adm.projector('daily', { from: ['Payment'], into: ['DayTotal'], version: 1, withBefore: true,
336
- handle(changes, out) { /* out.inc, out.upsert, out.delete, await out.get — классы into, { tenant: c.tenant } */ } })
337
- await p.run({ once: true }) // догнать журнал; run() — следовать за ним до stop(); rebuild(), status(), close()
338
- p.run().catch(report) // run() без once — с catch: после ошибки проектора он остановлен
525
+ const was = await db.Item(w).asOf('2026-10-01T00:00:00Z').first() // как было на момент
526
+ const history = await db.Item(w).versions() // все версии; версия удаления — $deleted
339
527
  ```
340
- Только администраторское подключение (иначе `admin_required`). Пачка — одна транзакция, каждое изменение
341
- применяется ровно один раз, писатель один (`rebuild()`, пока другой процесс пишет пачку, — `projector_busy`).
342
- Первый запуск перестраивает проекцию с текущего состояния, пачками. `purge`, `trim` и `reset` приходят изменениями с `rev` 1 — сначала
343
- разберите `op`. Другая `version` — `rebuild_required`; курсор старше яруса `all` — `cursor_expired`
344
- (`onExpired: 'rebuild'` перестраивает сам, пачками); ярус — по арендатору: истёк у части участников — выбывают только они.
345
- Арендатор с источником `history: false` выбывает (`invalid_class`). `c.before` (с `withBefore`) —
346
- прошлая версия того же id или `null`: `null` при создании, у первой версии нового id после `reclass` и `rekey`
347
- (прежние данные вычитает надгробие прежнего id), при перестройке и когда прошлая версия — удаление (после `restore`)
348
- или вне семейства `from`; у `purge`, `trim` и `reset` поля нет (`undefined`) — вычитайте `before`, только проверив его истинность: `if (c.before)`.
349
- Смена класса без смены id (класс без ключа), уводящая строку из семейства `from`, приходит удалением (`op` `delete`,
350
- `row` — последняя версия в семействе с `$deleted`). `handle` получает изменения
351
- одного арендатора. Ошибку его данных (база отклонила его результаты или `out` — его строку или приращение)
352
- откатывает только он: арендатор выбывает (`status().failed`: `{ id: { cursor, code, message } }`) до
353
- `rebuild({ tenant: id })` (`{ tenant, chunked: true }` — пачками, пока хвост идёт для всех, прерванную продолжает любой `run()`; `status().rebuildingTenants`),
354
- остальные идут дальше; с `onExpired: 'rebuild'` выбывший по ярусу перестраивается пачками сам, в любом процессе; `tenants: [id, …]` — явный список участников. `status().state`: `'new'` —
355
- проекции ещё нет, `'running'` — проекция построена и ведётся (а не «процесс сейчас работает»), `'rebuilding'` — идёт
356
- или прервана перестройка пачками.
357
- Дневной итог — свёртка: прошлую версию (`c.before`) вычесть, новую (`c.row`, если не `$deleted`) прибавить через
358
- `out.inc('DayTotal', { day }, { total: ±amount }, { tenant: c.tenant })`; у `DayTotal` ключ `['day']`,
359
- `derivedBy: 'daily'` и `history: false`. День берите из данных строки, а не из `at`: время версии меняет каждая правка.
360
- Приращения одной строки по ключу `out` складывает сам в одну правку за пачку (пока между ними нет `upsert`, `delete`
361
- или `get`), а правило класса проверяет над их итогом. Полный рецепт —
362
- [README, «Рецепт: дневная выручка»](README.md#рецепт-дневная-выручка).
363
-
364
- ### 13. Агрегаты в базе
365
-
366
- Сумму или счётчик строк источника держит сама база — для итогов, которые обязаны совпадать и для сырого SQL:
367
- ```ts
368
- const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0) }, {
369
- key: ['number'], aggregate: { balance: { sum: 'amount', of: 'Payment', via: 'Wallet' } } }) // или count: true; where — JSON Schema
370
- await db.Wallet(w).Payment().create({ opId: 'op-9', amount: 500 }).rows() // баланс вырос сам, без inc
371
- ```
372
- Поле пересчитывает триггер после каждого оператора (библиотека, сырой SQL, каскад, `restore`, `reclass`, `rekey`,
373
- загрузчик); уход ниже `minimum` — `invalid_data`, каждое изменение — версия цели. Считаются живые строки того же
374
- арендатора (строка System — нет). Ремонт — `adm.rebuildAggregates('Wallet')` на администраторском подключении,
375
- расхождения — `verify({ data: true })`. Описать или изменить агрегат может только тот, кто без условий читает все строки
376
- источника и его потомков, иначе `acl_denied`. Источник описывают раньше агрегата: над классом, которого ещё нет,
377
- агрегат не описывается (`invalid_class`), а класс `of` не удаляется, пока агрегат его называет; `syncModels` сам ставит
378
- источники раньше целей. Горячий счётчик (больше ~150 записей в секунду на объект) —
379
- проектор со свёрткой пачкой, а не агрегат.
380
-
381
- ### 14. Агенты и MCP
382
-
383
- Агенту — инструменты MCP под сессией пользователя (нужен `npm i @modelcontextprotocol/server`):
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`, событий подписки.
533
+
534
+ ### 11. Агенты и MCP
535
+
384
536
  ```ts
385
- import { createMcpServer } from 'letopis/mcp'
386
- const server = await createMcpServer(await svc.as(userId)) // letopis_model, letopis_query, letopis_history; { write: true } — letopis_write
387
- // 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]
388
540
  ```
389
- Только сессия пользователя или `svc.as(пользователь)`: подключение в обход RLS — `bypass_rls`, корень сервиса без `as()`
390
- и сессия в арендаторе System (без `allowSystemAdmin`) — `user_session_required`; классы прав `letopis_write` не пишет без
391
- `allowAclWrite` (`acl_denied`); `--api-key` командная строка не принимает, а `--dsn`, `--schema` и `--token` берёт и из
392
- `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`. Чтение — JSON-цепочкой (`class`, `filter` с `$gt`, `$in`, `$or` и
393
- т. п., `steps`, `sort: { field, dir: 'asc' | 'desc' }`, `cursor`), без SQL; ответ — до `maxRows` строк (100) и
394
- `maxBytes` байт (50 000) из опций `createMcpServer`, дальше `cursor`. `structuredContent` — `{ notice, data }`
395
- (`notice` — постоянное пояснение, что это данные, а не инструкции); `data` по терминалам: `rows` —
396
- `{ rows, count, more, cursor? }`, `ids` — `{ ids, count, more, cursor? }` (`count` — строк на странице), `first` —
397
- `{ row }`, `count` — `{ count }`, `countBy` — `{ counts, truncated? }`, `sum`, `avg`, `min`, `max` — `{ value }`.
398
- Отказ — `isError: true` и `structuredContent` вида `{ notice, error }` (`error` — `{ code, message, detail?, sqlstate?, similar? }`). Запись — только `write: true`
399
- (без него `letopis_write` нет в списке, а вызов — ошибка протокола SDK): `update` с `rev`; `delete` без `confirm` —
400
- превью, с `confirm` — подтверждение человека (elicitation), без него — `confirmation_required`. Данные из базы в
401
- ответе — внутри `<untrusted-data-…>`.
402
-
403
- ### Эксплуатация
404
-
405
- `db.verify({ data: true })` и `letopis verify` — цепочки хэшей, якорь; `db.maintain()` и `connect({ maintain: '1h' })` —
406
- сессии, коды, прореживание журнала; `db.trimHistory(цель, момент)`; `db.reset({ level, confirm, tenant })` — при
407
- `up({ allowReset: true })`; `db.describe()` / `letopis describe --format text`. Закрыть подключения — по одному разу;
408
- подключения из `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. Эксплуатация и завершение
547
+
409
548
  <!-- run -->
410
549
  ```ts
411
- 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()
412
555
  ```
413
-
414
- ## Грабли 1.0
415
-
416
- 1. **`create` занятого ключа — ошибка `exists`** (`err.detail.id`), а не перезапись. Заменить целиком — `upsert`,
417
- и только у класса с ключом (иначе `no_key`). Частичная правка — `update`.
418
- 2. **`update` никогда не создаёт.** Шаг ничего не нашёл — пустой ответ без ошибки, проверяйте длину; без фильтра и
419
- без `data.id` правятся все строки шага. `target_not_found` — при `{ rev }` или если строку удалили между поиском
420
- и правкой.
421
- 3. **Глаголы возвращают цепочку.** Без терминала (`.rows()`, `.first()` …) ничего не исполнится. `delete()` без
422
- `{ confirm: true }` — только превью; `purge` — только для удалённых (`not_deleted`) и после того, как проектор
423
- класса прочёл удаление (`projector_pending`); `trimHistory` — после того, как проектор прочёл версии.
424
- 4. **Деньги и счётчики** — `update({ balance: inc(n) })`: база прибавляет внутри оператора, без чтения и потерянных
425
- обновлений; проверка `minimum` в схеме ловит уход в минус. Итог, вычисленный из прочитанного, — `{ rev }`
426
- (перечитать и повторить при `conflict`) или `forUpdate()` в `db.begin()`. Идемпотентность — ключом операции
427
- (`[Wallet, opId]`): повтор даёт `exists`.
428
- 5. **`db.begin()` — `read committed`.** Два чтения в транзакции могут увидеть разные данные. Заблокировать
429
- прочитанное — `await tr.Wallet(w).forUpdate().first()` (только в `db.begin()`, иначе `tx_required`; только с
430
- `rows`, `first`, `ids`, иначе `lock_unsupported`). `tr.lock(...ключи)` — advisory до конца транзакции.
431
- 6. **Ошибка ломает явную транзакцию.** После любой ошибки (в том числе `exists`, `conflict`) каждый вызов и
432
- `commit()` бросают исходную ошибку, `commit()` при этом откатывает; `rollback()` проходит. Шаблон:
433
- `const tr = await db.begin(); try { …; await tr.commit() } catch (e) { await tr.rollback(); throw e }`.
434
- В `db.begin()` повторов нет: на `40P01` повторите весь блок. Операции из пула сами повторяют `40001`/`40P01`.
435
- 7. **`commit_unknown`** — связь оборвалась во время `COMMIT`: транзакция могла зафиксироваться. Не повторяйте
436
- вслепую — повторите ту же операцию с тем же ключом (`exists` значит «уже записано»).
437
- 8. **Загрузчику, проектору и `rebuildAggregates` нужно администраторское подключение** (`allowBypassRls: true`,
438
- роль-владелец), иначе `admin_required`; внутри `db.begin()` — `invalid_query`. Обычное приложение — прикладной
439
- ролью, без обхода RLS.
440
- 9. **Данные под RLS.** Без сессии (`token`/`apiKey`) запись — `no_session`; чужая или невидимая строка неотличима от
441
- несуществующей: шаг её не находит, а ссылка на неё — `target_not_found`; без права — пусто или `acl_denied`. `` db.sql`…` `` и сырой SQL — тоже под
442
- сессией и RLS.
443
- 10. **Ключ неизменен.** Правка ключевого поля, id, арендатора, владельца — `immutable_key`; новый ключ —
444
- `rekey(patch)` (или `db.rekeyClass(класс, ключ)`), это новый id. Смена класса — `reclass(класс, данные)`, не
445
- `update` (`use_reclass`). Удаление, `reclass` и `rekey` — только в `read committed` (`isolation_level`).
446
- 11. **Классы — в арендаторе сессии.** `letopis sync` с токеном арендатора пишет классы ему; другие арендаторы их
447
- не видят. Общие для всех классы — в System (сид установки или импорт).
448
- 12. **id класса с ключом не передают** — он вычисляется (`db.idOf('Shop', { name: 'Центр' })`); чужой id —
449
- `id_mismatch`, id для класса без ключа — `id_from_db`.
450
- 13. **Имена методов зарезервированы**: класс с именем `count`, `sort`, `set`, `run`, `account`, `sql`, `auth` … как
451
- шаг недостижим (список — `RESERVED_CLASS_NAMES`). Снятое API 0.21 (`run()`, `account()`, `watch(cb)`,
452
- `reloadSchema()`, фасады `db.accounts` …) бросает `removed` с заменой — см. [MIGRATION.md](MIGRATION.md).
453
- 14. **Права запрещают по умолчанию.** Новый арендатор без правил ничего не видит; `up({ tenant })` и `createTenant`
454
- (`grantAll`) — быстрый старт. Права `auth.*` (пароли, сессии, имперсонация) есть у сервиса и системного
455
- администратора.
456
- 15. **Ошибки:** `err.code` — строчный код (`exists`, `conflict`, `invalid_data` …), текст — `letopis: <код>: …`;
457
- у ошибок данных (`ValidationError`) — `err.issues: [{ path, keyword, message }]`.
458
- 16. **Подписка с `'now'` — с момента ответа базы.** `db.watch()` возвращается сразу, а момент «now» подписка берёт
459
- первым запросом к базе. Запись, сделанная сразу после `db.watch()`, может зафиксироваться раньше и в поток не
460
- попасть. Нужна гарантия — `await sub.ready()` перед записью, сохранённый курсор или `'start'`. Параллельные
461
- `next()` идут по очереди и получают разные события.
462
- 17. **Терминал отдаёт строки последнего шага.** `…Payment().create(…).Wallet().update(…).rows()` вернёт строки
463
- `Wallet`, а не созданной операции; нужна операция — заканчивайте цепочку на ней.
464
- 18. **Очередь батча — своя у фасада.** `db.batch('x')` у корня, у каждого `await db.as(…)` и у каждой `db.begin()` —
465
- разные очереди; план исполняется от имени того, кто его поставил. Копите планы через один объект фасада.
466
- 19. **Строки производного класса пишет только его проектор.** Приложение, сырой SQL и загрузчик получат
467
- `derived_class`; ссылаться на такие строки концами тоже нельзя. Пересчитать их — `rebuild()` проектора.
468
- 20. **Агрегатное поле пишет только база.** Значение в `create`, `update`, `upsert`, `rekey`, `reclass` и сыром SQL —
469
- `aggregate_field`; не передавайте его. Сырой upsert цели — `set data = excluded.data - array['balance']`.
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`.