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