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.
- package/AGENT-CHEATSHEET.md +484 -414
- package/CHANGELOG.md +321 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +786 -183
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +3 -0
- package/dist/acl.js +19 -2
- package/dist/admin.d.ts +2 -1
- package/dist/admin.js +6 -4
- package/dist/auth.d.ts +18 -3
- package/dist/auth.js +75 -6
- package/dist/chain.d.ts +38 -17
- package/dist/chain.js +434 -63
- package/dist/cli.js +28 -6
- package/dist/errors.d.ts +19 -1
- package/dist/errors.js +46 -6
- package/dist/index.d.ts +10 -4
- package/dist/index.js +136 -46
- package/dist/internal.d.ts +2 -0
- package/dist/load.d.ts +9 -5
- package/dist/load.js +30 -11
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.js +26 -11
- package/dist/messages/api.d.ts +66 -0
- package/dist/messages/api.js +90 -1
- package/dist/messages/boot.js +4 -4
- package/dist/messages/codes.js +4 -4
- package/dist/messages/index.d.ts +5 -2
- package/dist/messages/index.js +5 -2
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/model.d.ts +24 -1
- package/dist/messages/model.js +34 -3
- package/dist/messages/query.d.ts +10 -0
- package/dist/messages/query.js +13 -0
- package/dist/messages/sql-core.d.ts +15 -1
- package/dist/messages/sql-core.js +20 -3
- package/dist/messages/sql-time.d.ts +15 -0
- package/dist/messages/sql-time.js +23 -0
- package/dist/messages/write.d.ts +47 -0
- package/dist/messages/write.js +63 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +23 -5
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +77 -25
- package/dist/sync.d.ts +1 -1
- package/dist/sync.js +50 -0
- package/dist/tx.d.ts +10 -1
- package/dist/tx.js +44 -2
- package/dist/typed.d.ts +14 -10
- package/dist/types.d.ts +82 -4
- package/dist/types.js +6 -1
- package/dist/up.d.ts +3 -3
- package/dist/up.js +57 -17
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +9 -1
- package/dist/validate.js +24 -7
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +13 -0
- package/dist/write.d.ts +78 -1
- package/dist/write.js +357 -39
- package/llms.txt +37 -17
- package/package.json +12 -6
- package/sql/20-context.sql +11 -3
- package/sql/30-validate.sql +51 -10
- package/sql/40-class.sql +21 -1
- package/sql/80-auth.sql +88 -17
- package/sql/90-time.sql +34 -6
- package/sql/99-revision.sql +5 -1
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +5 -1
package/AGENT-CHEATSHEET.md
CHANGED
|
@@ -1,246 +1,432 @@
|
|
|
1
|
-
# letopis
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
- `
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
- `
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
- `
|
|
107
|
-
`
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
191
|
-
- Весь план → одна транзакция.
|
|
380
|
+
Глагол — звено плана; исполняет весь план одной транзакцией **терминал** (`rows`, `first`, `ids`, `count` …).
|
|
192
381
|
|
|
193
382
|
<!-- run -->
|
|
194
383
|
```ts
|
|
195
|
-
const [
|
|
196
|
-
const [w] = await db.
|
|
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.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
await db.
|
|
203
|
-
await db.
|
|
204
|
-
const
|
|
205
|
-
await db.
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
await
|
|
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
|
-
-
|
|
214
|
-
|
|
215
|
-
- `update()`
|
|
216
|
-
|
|
217
|
-
-
|
|
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.
|
|
225
|
-
const rich = await db.
|
|
226
|
-
const paid = await db.
|
|
227
|
-
await db.
|
|
228
|
-
await db.
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
await db.
|
|
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, строка ответа, объект
|
|
235
|
-
|
|
236
|
-
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
await db.
|
|
278
|
-
await db.
|
|
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:
|
|
476
|
+
const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token!, allowBypassRls: true })
|
|
290
477
|
const rep = await adm.load({
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}, { existing: 'skip' })
|
|
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
|
-
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
326
|
-
await db.acl.
|
|
327
|
-
await db.acl.
|
|
328
|
-
await
|
|
329
|
-
//
|
|
330
|
-
const
|
|
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 |
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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({
|
|
366
|
-
|
|
367
|
-
await db.
|
|
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 //
|
|
371
|
-
if (ev.id === w.id
|
|
513
|
+
cursor = ev.cursor // продолжить с места: db.watch({ from: cursor })
|
|
514
|
+
if (ev.id === w.id) break // break или sub.close() — остановить
|
|
372
515
|
}
|
|
373
|
-
const
|
|
374
|
-
db.cache.stats(); db.cache.clear()
|
|
516
|
+
const teams = await db.Team().cache({ ttl: 300 }).rows() // кэш результатов; сброс по своим записям и сигналам
|
|
375
517
|
```
|
|
376
|
-
- События
|
|
377
|
-
|
|
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.
|
|
384
|
-
await db.
|
|
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
|
-
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
-
|
|
391
|
-
|
|
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
|
|
399
|
-
// stdio: letopis mcp --dsn … --schema v2.
|
|
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
|
-
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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.
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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`.
|