letopis 1.4.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.md +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- 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 +18 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
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,25 +10,24 @@
|
|
|
6
10
|
RLS. Правила одинаково действуют и для библиотеки, и для сырого SQL. Данные читают и пишут цепочками:
|
|
7
11
|
`db.Shop(shop).Product({ price: lt(1000) }).rows()`.
|
|
8
12
|
|
|
9
|
-
[
|
|
10
|
-
[
|
|
13
|
+
[Памятка для ИИ-агента](AGENT-CHEATSHEET.md) · [Изменения по версиям](CHANGELOG.md) · [llms.txt](llms.txt) ·
|
|
14
|
+
[Лицензия](#19-лицензия): некоммерческое использование бесплатно, коммерческое — по разрешению
|
|
11
15
|
|
|
12
16
|
Что умеет letopis:
|
|
13
17
|
|
|
14
18
|
- **Модели на Zod** хранятся в базе как классы: хабы (объекты) и связи (отношения со своими данными) — [раздел 4](#4-модель-данных).
|
|
15
|
-
- **Запись цепочками**: `create`, `update` со слиянием, `upsert`, удаление
|
|
16
|
-
|
|
19
|
+
- **Запись цепочками**: `create`, `update` со слиянием, `upsert`, удаление только по показанному превью и с каскадом,
|
|
20
|
+
приращение `inc` для счётчиков — [разделы 5](#5-запись), [7](#7-удаление-и-восстановление) и [8](#8-транзакции-и-батчи).
|
|
17
21
|
- **Чтение цепочками**: переходы по связям, фильтры, деревья, агрегаты, постраничный вывод — [раздел 6](#6-чтение).
|
|
18
|
-
- **История**:
|
|
19
|
-
хранения, проверка цепочки хэшей — [раздел 11](#11-история-и-срезы-на-дату).
|
|
22
|
+
- **История**: версии с автором и причиной, срез на дату, восстановление удалённого — [раздел 11](#11-история-и-срезы-на-дату).
|
|
20
23
|
- **Арендаторы, вход и права** — в самой базе: пароли, ключи API, одноразовые коды, TOTP, сессии, имперсонация,
|
|
21
24
|
правила доступа — [раздел 10](#10-пользователи-вход-и-права).
|
|
22
25
|
- **Подписка на изменения** по курсору без потерь и **кэш результатов** — [раздел 12](#12-подписка-на-изменения-и-кэш).
|
|
23
|
-
- **Производные данные**: агрегаты в базе — суммы и счётчики, которые держит сама база, — и проекторы, которые строят
|
|
24
|
-
сводки из журнала, — [разделы 4](#агрегаты-в-базе) и [12](#проекторы).
|
|
25
26
|
- **Агенты и MCP**: сервер MCP даёт ИИ-агенту модель, чтение, историю и, если разрешено, запись — с правами одного
|
|
26
27
|
пользователя — [раздел 13](#агенты-и-mcp).
|
|
27
|
-
- **Массовая загрузка** миллионов строк
|
|
28
|
+
- **Массовая загрузка** миллионов строк — [раздел 9](#9-массовая-загрузка).
|
|
29
|
+
- **Журнал событий** приложения: удаления, восстановления, ошибки, вход, запросы — с маскировкой секретов —
|
|
30
|
+
[«Логирование»](#логирование).
|
|
28
31
|
|
|
29
32
|
Требования:
|
|
30
33
|
|
|
@@ -44,38 +47,42 @@ RLS. Правила одинаково действуют и для библио
|
|
|
44
47
|
5. [Запись](#5-запись)
|
|
45
48
|
6. [Чтение](#6-чтение)
|
|
46
49
|
7. [Удаление и восстановление](#7-удаление-и-восстановление)
|
|
47
|
-
8. [
|
|
50
|
+
8. [Транзакции и батчи](#8-транзакции-и-батчи)
|
|
48
51
|
9. [Массовая загрузка](#9-массовая-загрузка)
|
|
49
52
|
10. [Пользователи, вход и права](#10-пользователи-вход-и-права)
|
|
50
53
|
11. [История и срезы на дату](#11-история-и-срезы-на-дату)
|
|
51
54
|
12. [Подписка на изменения и кэш](#12-подписка-на-изменения-и-кэш)
|
|
52
|
-
13. [Эксплуатация](#13-эксплуатация)
|
|
53
|
-
14. [
|
|
54
|
-
15. [
|
|
55
|
-
16. [
|
|
56
|
-
17. [
|
|
57
|
-
18. [
|
|
58
|
-
19. [
|
|
55
|
+
13. [Эксплуатация](#13-эксплуатация): обслуживание, MCP, [логирование](#логирование), командная строка
|
|
56
|
+
14. [Справочник API](#14-справочник-api)
|
|
57
|
+
15. [Ошибки](#15-ошибки)
|
|
58
|
+
16. [Производительность](#16-производительность)
|
|
59
|
+
17. [Ограничения](#17-ограничения)
|
|
60
|
+
18. [Тесты](#18-тесты)
|
|
61
|
+
19. [Лицензия](#19-лицензия)
|
|
59
62
|
|
|
60
63
|
Разделы 3–13 построены на одном примере — магазине: установка, модель, запись, чтение и так далее. Блоки кода этих
|
|
61
|
-
разделов идут по порядку и вместе образуют одну программу.
|
|
62
|
-
|
|
64
|
+
разделов идут по порядку и вместе образуют одну программу. Автоматически её сейчас не сверяет ни один тест: тест
|
|
65
|
+
`docs` отстал от текста. Если пример разошёлся с поведением пакета, верен код.
|
|
63
66
|
|
|
64
67
|
## 1. Быстрый старт
|
|
65
68
|
|
|
66
|
-
Нужен PostgreSQL 18 и роль администратора: суперпользователь или роль с правом `
|
|
69
|
+
Нужен PostgreSQL 18 и роль администратора: суперпользователь или роль с `CREATEROLE`, правом `CREATE` в базе (схема
|
|
70
|
+
и расширение pgcrypto) и `CREATEDB`, если базы ещё нет. Установите пакет:
|
|
67
71
|
|
|
68
72
|
```sh
|
|
69
|
-
npm
|
|
73
|
+
npm pkg set type=module # код приложения — ESM: "type": "module" или файлы .mjs
|
|
74
|
+
npm install letopis zod postgres@3 # postgres@3 — для createTenant и своих SQL-запросов
|
|
75
|
+
npm install -D tsx # запуск примеров на TypeScript: npx tsx app.ts
|
|
70
76
|
```
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
`postgres` — зависимость letopis версии 3. Ставьте ту же мажорную версию: `npm i postgres` без версии поставит 4.x,
|
|
79
|
+
и код будет работать, но `createTenant(admin, …)` не пройдёт проверку типов — типы двух копий `postgres` несовместимы.
|
|
74
80
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
Пакет — модуль ES, и примеры написаны на `import`: без `"type": "module"` в `package.json` приложения (или без
|
|
82
|
+
расширения `.mjs`) Node ответит `SyntaxError: Cannot use import statement outside a module`.
|
|
83
|
+
|
|
84
|
+
Приложение подключается к базе отдельной ролью, которая состоит в `letopis_app`. Её заводит сама установка: опция
|
|
85
|
+
`login` у `up()` создаёт роль с паролем (повтор только обновит пароль) и выдаёт ей `letopis_app`.
|
|
79
86
|
|
|
80
87
|
Пример ставит схему, заводит магазин с первым пользователем, описывает два класса и работает с ними:
|
|
81
88
|
|
|
@@ -88,8 +95,8 @@ import { z } from 'zod'
|
|
|
88
95
|
const ADMIN = 'postgres://postgres:secret@localhost:5432/shop' // администратор: ставит схему
|
|
89
96
|
const APP = 'postgres://shop_app:app-secret@localhost:5432/shop' // приложение: член letopis_app
|
|
90
97
|
|
|
91
|
-
// 1. Установка схемы v2.shop. tenant — первый арендатор (магазин) и его пользователь Анна.
|
|
92
|
-
const r = await up({ dsn: ADMIN, schema: 'shop',
|
|
98
|
+
// 1. Установка схемы v2.shop. login — роль приложения shop_app, tenant — первый арендатор (магазин) и его пользователь Анна.
|
|
99
|
+
const r = await up({ dsn: ADMIN, schema: 'shop', login: { user: 'shop_app', password: 'app-secret' },
|
|
93
100
|
tenant: { name: 'Ромашка', user: { name: 'Анна', login: 'anna@shop.ru', password: 'Ромашка-2026!' } } })
|
|
94
101
|
// r.serviceKey — ключ сервиса приложения: выдаётся при первой установке, сохраните его
|
|
95
102
|
|
|
@@ -121,7 +128,10 @@ await svc.close()
|
|
|
121
128
|
`owner` в нём.
|
|
122
129
|
- `connect({ apiKey })` обменял ключ сервиса на сессию. `verifyPassword` вернул `{ account, token }` (или `null`,
|
|
123
130
|
если пароль неверный). `svc.as(account, { tenant })` — тот же пул соединений, но от имени Анны в магазине;
|
|
124
|
-
право сервиса на это проверяет база.
|
|
131
|
+
право сервиса на это проверяет база. Сессия входа и `svc.as(account)` без `tenant` работают в **личном**
|
|
132
|
+
арендаторе Анны (id её аккаунта): классов магазина там нет, и шаг `db.Product()` дал бы `invalid_class`. Своё
|
|
133
|
+
подключение по токену (`connect({ token })`) переходит в магазин через `auth.switch()`
|
|
134
|
+
([«Работа в другом арендаторе»](#работа-в-другом-арендаторе)).
|
|
125
135
|
- `hub('Product', поля, опции)` описал класс-объект. `ends: { Shop }` — обязательная ссылка на магазин.
|
|
126
136
|
`key: ['Shop', 'sku']` — ключ: id товара вычисляется из магазина и артикула, а второй товар с тем же ключом база
|
|
127
137
|
не примет.
|
|
@@ -129,7 +139,9 @@ await svc.close()
|
|
|
129
139
|
уменьшил цену внутри оператора `UPDATE`, без предварительного чтения. `versions()` прочитал историю из журнала.
|
|
130
140
|
|
|
131
141
|
Повторный вызов `up()` безопасен: он повторно накатывает ту же ревизию и данные не трогает, а ключ сервиса снова не
|
|
132
|
-
выдаёт, пока в установке есть хоть один ключ API, кроме отозванных.
|
|
142
|
+
выдаёт, пока в установке есть хоть один ключ API, кроме отозванных. Поэтому код, который запускают не один раз,
|
|
143
|
+
передаёт свой ключ — `up({ …, serviceKey: process.env.SERVICE_KEY })` — и подключается с ним
|
|
144
|
+
(`apiKey: r.serviceKey ?? process.env.SERVICE_KEY`); повторный `create()` того же магазина даст `exists`. Дальше раздел 2 объясняет устройство, а разделы 3–13 разбирают задачи по порядку.
|
|
133
145
|
|
|
134
146
|
## 2. Как устроен letopis
|
|
135
147
|
|
|
@@ -148,21 +160,20 @@ await svc.close()
|
|
|
148
160
|
| арендатор | аккаунт, в чьих данных идёт работа. Данные разных арендаторов изолированы |
|
|
149
161
|
| членство | связь `member` «пользователь — арендатор» с ролями; даёт работать в чужом арендаторе |
|
|
150
162
|
| сессия | токен, по которому база узнаёт аккаунт и арендатора. Сервис получает её по ключу API, пользователь — входом |
|
|
151
|
-
| версия, журнал | каждое изменение строки — версия в журнале `log` с
|
|
163
|
+
| версия, журнал | каждое изменение строки — версия в журнале `log` с автором и временем |
|
|
152
164
|
| надгробие | версия «удалено» с последним снимком объекта |
|
|
153
165
|
| цепочка | запрос вида `db.Shop(shop).Product().rows()`: шаги, модификаторы, глаголы записи и терминал |
|
|
154
|
-
| терминал | метод, который исполняет цепочку: `rows`, `first`, `ids`, `count`, `paths`, агрегаты |
|
|
166
|
+
| терминал | метод, который исполняет цепочку: `rows`, `first`, `ids`, `count`, `paths`, `versions`, агрегаты |
|
|
155
167
|
|
|
156
168
|
### Что хранится в базе
|
|
157
169
|
|
|
158
170
|
Таблиц три. `entity` — текущее состояние: одна строка на каждую живую сущность, включая строки системных классов
|
|
159
171
|
(`Class`, `Account`, `Resource`, `member`, `rule`). `log` — журнал: полный снимок каждой версии с автором,
|
|
160
|
-
временем, видом операции и
|
|
161
|
-
одной строки, с ней работают только функции входа и
|
|
172
|
+
временем, видом операции и причиной. `secret` — креды, сессии и служебные строки-замки: приложение не видит в ней ни
|
|
173
|
+
одной строки, с ней работают только функции входа и блокировок движка.
|
|
162
174
|
|
|
163
175
|
Классы — тоже сущности. Описание класса лежит в строке `Class`; там же база хранит развёрнутое описание с учётом
|
|
164
|
-
предков и скомпилированную проверку данных.
|
|
165
|
-
снимка строки. Поэтому подмену версии выдаёт следующая версия, а у последней — якорь вне базы ([раздел 11](#11-история-и-срезы-на-дату)).
|
|
176
|
+
предков и скомпилированную проверку данных.
|
|
166
177
|
|
|
167
178
|
### Кто проверяет данные
|
|
168
179
|
|
|
@@ -185,6 +196,28 @@ LOGIN-роль состоит только в ней. Всё, что выпол
|
|
|
185
196
|
которая обходит RLS (суперпользователь, `BYPASSRLS`, член `letopis_owner`), если не указано
|
|
186
197
|
`allowBypassRls: true`.
|
|
187
198
|
|
|
199
|
+
### Безопасность по умолчанию
|
|
200
|
+
|
|
201
|
+
Опасное действие нельзя совершить случайно — без сверки с тем, что видел человек, оно отклоняется до записи:
|
|
202
|
+
|
|
203
|
+
- **Удаление — только по превью.** `delete()` — превью; удаляет `delete({ confirm: true, expect: превью })`, ровно
|
|
204
|
+
показанное. `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`).
|
|
205
|
+
- **Восстановление — только по надгробиям.** `restore({ expect })` с ответом `delete()` или строками `withDeleted()`;
|
|
206
|
+
`restore()` без `expect`, в том числе по явному id, — `invalid_query` (`write.restore_without_expect`).
|
|
207
|
+
- **Массовое — только явно.** Правка и удаление без фильтра — `invalid_query`, пока нет `{ all: true }`; удаление и
|
|
208
|
+
восстановление без сверки — тоже только с `{ all: true }`.
|
|
209
|
+
- **Арендатор и права — в базе.** Права по умолчанию запрещают, строки чужого арендатора не видны; роль в обход RLS
|
|
210
|
+
`connect()` отклоняет (`bypass_rls`), пока не передано `allowBypassRls: true`. Арендатора берут только из серверной
|
|
211
|
+
сессии ([«Веб-сервис: одно подключение и безопасность»](#веб-сервис-одно-подключение-и-безопасность)).
|
|
212
|
+
- **Администраторский пул — явно.** `createTenant(admin, …)` принимает только пул `postgres()` (`api.create_tenant_admin`).
|
|
213
|
+
- **Секреты в журнале событий маскируются**: поля с паролями, токенами, ключами и кредами, учётные данные в строке
|
|
214
|
+
подключения; значения параметров SQL и тексты ошибок PostgreSQL не пишутся вовсе. Значения фильтров чтения журнал
|
|
215
|
+
пишет как есть — персональные поля добавьте в `redact` ([«Логирование»](#логирование)).
|
|
216
|
+
|
|
217
|
+
Общие правила для любой задачи на letopis — одна открытая запись на объект через ключ связи, удаление и возврат по
|
|
218
|
+
превью, гонки, перевод ошибок в текст для человека — собраны в [памятке](AGENT-CHEATSHEET.md), «Правила для любой
|
|
219
|
+
задачи».
|
|
220
|
+
|
|
188
221
|
### Как идёт чтение
|
|
189
222
|
|
|
190
223
|
Под RLS индексы по `data` и по обратным ссылкам политике напрямую недоступны. Поэтому id для шагов цепочки
|
|
@@ -208,9 +241,8 @@ LOGIN-роль состоит только в ней. Всё, что выпол
|
|
|
208
241
|
| `none` | сигналов нет | ничего |
|
|
209
242
|
|
|
210
243
|
Токен — 24 шестнадцатеричных знака ключевого HMAC: сопоставить псевдоним с именем может только процесс с ключом
|
|
211
|
-
сигналов, а ключ выдаётся сервисам (право `letopis.signals`). Кроме сигналов записи, есть
|
|
212
|
-
сессия шлёт `session:<16 hex>` (первые 16 шестнадцатеричных знаков sha256 её токена)
|
|
213
|
-
сброс арендатора или всей установки — `*` (сброс всей установки шлёт ещё и `session:*`). В режиме `none` нет и их.
|
|
244
|
+
сигналов, а ключ выдаётся сервисам (право `letopis.signals`). Кроме сигналов записи, есть служебный: погашенная
|
|
245
|
+
сессия шлёт `session:<16 hex>` (первые 16 шестнадцатеричных знаков sha256 её токена). В режиме `none` нет и его.
|
|
214
246
|
Ни id, ни данных в сигналах нет. По сигналам перечитывается реестр классов, сбрасывается кэш результатов и будится
|
|
215
247
|
подписка; что меняет режим для кэша и подписки — в [разделе 12](#12-подписка-на-изменения-и-кэш).
|
|
216
248
|
|
|
@@ -228,21 +260,28 @@ LOGIN-роль состоит только в ней. Всё, что выпол
|
|
|
228
260
|
| `schema` | имя установки: `'shop'` даёт схему `v2.shop`; можно сразу `'v2.shop'` |
|
|
229
261
|
| `tenant` | первый арендатор и его пользователь (см. ниже) |
|
|
230
262
|
| `seeds` | сиды после ядра: `'booking'` — демо-домен салона, или пути к своим файлам `.sql` |
|
|
231
|
-
| `
|
|
232
|
-
| `serviceKey` | свой ключ сервиса вместо случайного: `lts_` и 32–128 шестнадцатеричных знаков |
|
|
233
|
-
| `allowReset` | разрешить `db.reset()` ([раздел 13](#13-эксплуатация)); по умолчанию выключено |
|
|
263
|
+
| `serviceKey` | свой ключ сервиса вместо случайного: `lts_` и 32–128 шестнадцатеричных знаков в нижнем регистре (`/^lts_[0-9a-f]{32,128}$/`), иначе `invalid_data` ([«Сервис и ключи API»](#сервис-и-ключи-api)) |
|
|
234
264
|
| `signals` | режим сигналов: `'class'` (по умолчанию), `'tenant'`, `'opaque'` или `'none'` ([«Сигналы»](#сигналы)); режим хранится в схеме — `up()` без опции его не меняет |
|
|
235
265
|
| `fresh` | удалить схему и поставить заново — **данные схемы теряются** |
|
|
236
|
-
| `upgrade` | обновить схему
|
|
266
|
+
| `upgrade` | обновить схему отставшей ревизии: миграции и повторный накат ([«Обновление пакета и схемы»](#обновление-пакета-и-схемы)) |
|
|
237
267
|
| `pglite` | база для разработки без PostgreSQL (см. ниже) |
|
|
238
268
|
| `quiet`, `waitTimeoutMs` | без сообщений в консоль; сколько ждать базу (по умолчанию 60 000 мс) |
|
|
239
269
|
| `owner`, `app` | имена ролей владельца и приложения (по умолчанию `letopis_owner`, `letopis_app`) |
|
|
270
|
+
| `login` | роль приложения `{ user, password }`: заводится или получает новый пароль и членство в `letopis_app`; строка подключения — `r.login.dsn` ([«Роль приложения»](#роль-приложения)) |
|
|
271
|
+
| `migrationsDir` | свой каталог миграций для `upgrade` (по умолчанию `sql/migrate/` пакета) |
|
|
272
|
+
| `log`, `logLevel`, `redact` | журнал событий установки (`up`, `migrate`), как у `connect()` ([«Логирование»](#логирование)) |
|
|
240
273
|
|
|
241
274
|
Результат: `schema` (полное имя), `serviceKey` (при первой установке и при накате, когда в установке не осталось ни
|
|
242
275
|
одного ключа API, кроме отозванных: истёкший или выключенный ключ тоже в счёт; иначе `null`), `created`,
|
|
243
|
-
`upgraded`, `revision`, `migrations`, `analyzed
|
|
244
|
-
|
|
245
|
-
|
|
276
|
+
`upgraded`, `revision`, `migrations`, `analyzed` (таблицы без статистики, у которых её собрал `ANALYZE` от роли
|
|
277
|
+
владельца; оставшиеся без неё — в `warnings`), `owner`, `app`, `signals` (режим сигналов установки), `warnings`
|
|
278
|
+
(предупреждения, без `quiet` они печатаются), а также `tenant`, `pglite` и `login` (`{ user, created, dsn }`), если
|
|
279
|
+
их просили. На PostgreSQL ниже 18.2 `up()` предупреждает: такой сервер мог усечь CLOG, который ещё нужен непрочитанным сигналам, — обновите его.
|
|
280
|
+
|
|
281
|
+
Ключ сервиса храните сами: повторный `up()` его не отдаёт (`serviceKey: null`). `up({ fresh: true })` ставит схему
|
|
282
|
+
заново — вместе с данными пропадают сессии и ключи, и выдаётся новый ключ сервиса. Процесс со старым ключом получит
|
|
283
|
+
`no_session: неверный ключ`: перезапустите его с новым. Чтобы ключ не менялся между установками (разработка,
|
|
284
|
+
стенды), передавайте свой: `up({ …, serviceKey: process.env.SERVICE_KEY })` — он регистрируется и в новой схеме.
|
|
246
285
|
|
|
247
286
|
Несколько экземпляров сервиса могут звать `up()` одной схемы одновременно: накат идёт под блокировкой схемы. Первый
|
|
248
287
|
`up()` ставит или обновляет её, остальные ждут и делают повторный накат по готовой (`created: false`,
|
|
@@ -271,45 +310,56 @@ const ANNA = r.tenant!.user! // его первый пользовател
|
|
|
271
310
|
|
|
272
311
|
### Роль приложения
|
|
273
312
|
|
|
274
|
-
Приложение работает от LOGIN-роли, которая состоит только в `letopis_app`:
|
|
275
|
-
|
|
313
|
+
Приложение работает от LOGIN-роли, которая состоит только в `letopis_app`. Её заводит `up({ login: { user, password } })`:
|
|
314
|
+
имя и пароль квотирует база, повтор обновляет пароль, а привилегированную роль (суперпользователя, с `CREATEROLE`,
|
|
315
|
+
`CREATEDB`, `REPLICATION` или `BYPASSRLS`, роль-владельца или её члена) `up()` не трогает — `invalid_data`; строка подключения роли — `r.login.dsn`.
|
|
316
|
+
Вручную — `create role shop_app login password '…'; grant letopis_app to shop_app;`. На роль приложения действуют RLS и
|
|
276
317
|
триггеры, как и на любой сырой SQL от этой роли. Роль, которая обходит RLS, `connect()` отклоняет с ошибкой
|
|
277
|
-
`bypass_rls`. Администраторское подключение (`allowBypassRls: true`) нужно только загрузчику,
|
|
278
|
-
|
|
318
|
+
`bypass_rls`. Администраторское подключение (`allowBypassRls: true`) нужно только загрузчику, обслуживанию
|
|
319
|
+
и первой настройке прав арендатора ([«Права только по ролям»](#права-только-по-ролям)); держите его в отдельном процессе, а не в веб-приложении.
|
|
320
|
+
Малому приложению хватит одного процесса — [ниже](#один-процесс).
|
|
279
321
|
|
|
280
322
|
### Первый арендатор и пользователь
|
|
281
323
|
|
|
282
324
|
Права в letopis включены всегда и по умолчанию запрещают всё. Поэтому новому арендатору нужны правила. Опция
|
|
283
|
-
`up({ tenant })` и функция `createTenant(
|
|
325
|
+
`up({ tenant })` и функция `createTenant(admin, schema, opts)` делают это одной транзакцией:
|
|
284
326
|
|
|
285
327
|
- арендатор — аккаунт в System с id из имени («tenant:<имя>»);
|
|
286
|
-
- правило «аутентифицированным можно всё» в этом арендаторе
|
|
328
|
+
- правило «аутентифицированным можно всё» в этом арендаторе — чтение, запись и удаление строк всех классов, но не
|
|
329
|
+
эндпоинты `api` (`grantAll`, по умолчанию да, `grantAll: false` — без него; вес 10; права только по
|
|
330
|
+
ролям — [ниже](#права-только-по-ролям));
|
|
287
331
|
- пользователь (id из логина) с членством в арендаторе с ролями `user.roles` (по умолчанию `['owner']`), паролем и
|
|
288
332
|
сессией в арендаторе.
|
|
289
333
|
|
|
290
|
-
Повтор с теми же именем и логином ничего не дублирует и пароль не меняет, только выдаёт новую сессию.
|
|
291
|
-
|
|
292
|
-
одном регистре. Поле `created` в ответе — создан ли арендатор этим вызовом: для существующего арендатора с новым
|
|
334
|
+
Повтор с теми же именем и логином ничего не дублирует и пароль не меняет, только выдаёт новую сессию. Крайние пробелы
|
|
335
|
+
логина отбрасываются (` anna@shop.ru ` и `anna@shop.ru` — один пользователь), а регистр входит в id как есть:
|
|
336
|
+
`Anna@shop.ru` и `anna@shop.ru` дали бы два аккаунта, и у второго не было бы пароля, — пишите логин в одном регистре. Поле `created` в ответе — создан ли арендатор этим вызовом: для существующего арендатора с новым
|
|
293
337
|
пользователем оно `false`. Роли задаёт `user.roles` — непустой список имён (не масок), иначе `invalid_data`. Вид
|
|
294
338
|
пароля `user.kind` (по умолчанию `PASSWORD`) подчиняется правилу вида креда, как у `auth.setPassword`
|
|
295
339
|
([«Справочник API»](#dbauth)), иначе — тоже `invalid_data`. Роли
|
|
296
340
|
уже существующего членства повтор не меняет: их меняет правка членства ([«Выдать право»](#выдать-право)).
|
|
297
341
|
Удалённое повтор не воскрешает: если арендатор, аккаунт пользователя или членство удалены (строки нет, а история в
|
|
298
342
|
журнале есть), `createTenant` отвечает `invalid_data` и не записывает ничего. Текст ошибки называет удалённое и
|
|
299
|
-
советует вернуть его `restore()
|
|
300
|
-
|
|
343
|
+
советует вернуть его `restore({ expect })` с надгробием из `withDeleted().rows()`: аккаунт —
|
|
344
|
+
`db.Account(id).restore({ expect: await db.Account(id).withDeleted().rows() })` от имени системного администратора,
|
|
345
|
+
членство — так же `db.member(id)` в этом арендаторе; другой выход — другое имя или логин. Сессия
|
|
301
346
|
из `createTenant` — без способа входа, поэтому свой пароль по ней не сменить: для этого пользователь входит по
|
|
302
|
-
паролю.
|
|
347
|
+
паролю.
|
|
348
|
+
|
|
349
|
+
`admin` — **пул `postgres()`** администраторской роли (функция с методами `begin` и `unsafe`), а не строка
|
|
350
|
+
подключения и не подключение `connect()` letopis: другое значение `createTenant` отклоняет до запросов — `invalid_query`
|
|
351
|
+
(`api.create_tenant_admin`, текст называет, что передали). `schema` — как у `connect()` и `up()`: `'shop'` даёт
|
|
352
|
+
`v2.shop`, можно сразу `'v2.shop'` или `r.schema`. Пул закрывает вызывающий:
|
|
303
353
|
|
|
304
354
|
<!-- run: guide -->
|
|
305
355
|
```ts
|
|
306
356
|
import postgres from 'postgres'
|
|
307
357
|
import { createTenant } from 'letopis'
|
|
308
358
|
|
|
309
|
-
const
|
|
310
|
-
const lutik = await createTenant(
|
|
359
|
+
const admin = postgres(ADMIN) // пул postgres(), не строка DSN
|
|
360
|
+
const lutik = await createTenant(admin, r.schema, {
|
|
311
361
|
name: 'Лютик', user: { name: 'Борис', login: 'boris@lutik.ru', password: 'Лютик-2026!' } })
|
|
312
|
-
await
|
|
362
|
+
await admin.end()
|
|
313
363
|
const BORIS = lutik.user! // lutik: { tenant, user, token, created: true }
|
|
314
364
|
```
|
|
315
365
|
|
|
@@ -332,7 +382,7 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
|
|
|
332
382
|
| Опция `connect()` | Что делает |
|
|
333
383
|
|---|---|
|
|
334
384
|
| `dsn` или `sql` | строка подключения или готовый пул postgres.js |
|
|
335
|
-
| `schema` |
|
|
385
|
+
| `schema` | имя установки: `'v2.shop'` (удобно брать `r.schema`) или, как у `up()`, `'shop'` — то же `v2.shop` |
|
|
336
386
|
| `token`, `apiKey` | токен сессии или ключ API (`service` — синоним `apiKey`) |
|
|
337
387
|
| `models` | модели `letopis/model` — только для типов цепочек ([раздел 4](#4-модель-данных)) |
|
|
338
388
|
| `max` | размер пула (по умолчанию 10) |
|
|
@@ -340,18 +390,23 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
|
|
|
340
390
|
| `cache` | кэш результатов: `{ ttl, max, ttlOnly }` или `false` ([раздел 12](#12-подписка-на-изменения-и-кэш)) |
|
|
341
391
|
| `maintain` | запускать `maintain()` по расписанию: `'1h'`, `'15m'` или миллисекунды |
|
|
342
392
|
| `onQuery`, `slowMs` | хук на каждый запрос цепочки и порог медленного запроса |
|
|
393
|
+
| `log`, `logLevel`, `redact` | журнал событий: функция или объект-логгер (`console`, pino), порог (`'info'` по умолчанию) и ещё имена полей для маскировки ([«Логирование»](#логирование)) |
|
|
343
394
|
| `refreshSession` | продлевать сессию раз в минуту работы (по умолчанию да) |
|
|
344
395
|
| `notifyIntervalMs` | интервал отправки отложенных сигналов (100 мс) |
|
|
345
396
|
| `allowBypassRls`, `owner` | разрешить роль в обход RLS (администраторское подключение); имя роли-владельца |
|
|
397
|
+
| `admin` | строка подключения администратора или пул postgres.js: второй пул для `load`, `maintain` и `syncModels` с той же сессией ([«Один процесс»](#один-процесс)) |
|
|
398
|
+
|
|
399
|
+
Неизвестная опция — ошибка `invalid_data` до подключения: похожие имена — в тексте и в `err.params.similar`. Так же
|
|
400
|
+
проверяют опции `up()`, `db.load()`, `db.watch()` и `createMcpServer()` ([«Строгий режим»](#строгий-режим)).
|
|
346
401
|
|
|
347
402
|
У подключения есть `db.schema`, `db.token`, `db.tenant` (арендатор сессии) и `db.close()`. Подключения,
|
|
348
|
-
полученные через `as()`, делят пул с исходным: закрывать нужно
|
|
403
|
+
полученные через `as()`, делят пул с исходным: закрывать нужно исходное, один раз — оно закрывает пул, подписки и все
|
|
404
|
+
фасады. `close()` у подключения из `as()` ничего не делает: пул общий, и фасад после такого вызова работает дальше.
|
|
349
405
|
|
|
350
406
|
### Администраторское подключение
|
|
351
407
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
`maintain()` работают и на нём, и у системного администратора по обычному подключению. Администраторское
|
|
408
|
+
Загрузчику нужно подключение роли, которая может стать владельцем, с `allowBypassRls: true`. `maintain()` работает и
|
|
409
|
+
на нём, и у системного администратора по обычному подключению. Администраторское
|
|
355
410
|
подключение печатает предупреждение «RLS не действует» — так и должно быть. Сессия задаёт арендатора, с которым
|
|
356
411
|
оно работает. Отдельному процессу загрузки удобнее ключ сервиса: `connect({ dsn: ADMIN, schema, apiKey,
|
|
357
412
|
allowBypassRls: true })`, а арендатора строк задаёт опция `load(…, { tenant })`. В примере — сессия из `up()`:
|
|
@@ -361,54 +416,61 @@ allowBypassRls: true })`, а арендатора строк задаёт опц
|
|
|
361
416
|
const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token!, allowBypassRls: true })
|
|
362
417
|
```
|
|
363
418
|
|
|
419
|
+
### Один процесс
|
|
420
|
+
|
|
421
|
+
Малому приложению не обязательно держать два процесса. `up({ login })` заводит роль приложения и отдаёт её строку
|
|
422
|
+
подключения (`r.login.dsn`), а `connect({ dsn, admin, token })` при первом администраторском вызове открывает второй
|
|
423
|
+
пул — не больше двух соединений — строкой администратора. Через него идут только `load`, `maintain` и `syncModels`,
|
|
424
|
+
с той же сессией; цепочки и `db.sql` остаются в пуле приложения под RLS. Роль строки `admin` —
|
|
425
|
+
суперпользователь или роль с `SET` на роль-владельца, иначе `admin_required`.
|
|
426
|
+
Пароль администратора в процессе приложения — только для малых приложений: взломанный процесс получит и его.
|
|
427
|
+
|
|
428
|
+
<!-- run: guide -->
|
|
429
|
+
```ts
|
|
430
|
+
const soloUp = await up({ dsn: ADMIN, schema: r.schema, login: { user: 'shop_app', password: 'app-secret' } })
|
|
431
|
+
const solo = await connect({ dsn: soloUp.login!.dsn!, admin: ADMIN, schema: r.schema, token: r.tenant!.token! })
|
|
432
|
+
const soloCheck = await solo.maintain() // администраторским пулом, с сессией Анны: { skipped: false, … }
|
|
433
|
+
const soloMembers = await solo.member().count() // пулом приложения, под RLS: только своего арендатора
|
|
434
|
+
await solo.close() // закрывает оба пула
|
|
435
|
+
```
|
|
436
|
+
|
|
364
437
|
### Обновление пакета и схемы
|
|
365
438
|
|
|
366
|
-
У схемы есть ревизия движка (сейчас
|
|
439
|
+
У схемы есть ревизия движка (сейчас 10). Если новая версия пакета принесёт новую ревизию, `connect()` только
|
|
367
440
|
предупредит и назовёт команду. Отказывает `up()`: на отставшую схему без `upgrade: true` и на схему новее пакета.
|
|
368
441
|
К схеме новее пакета (её уже обновил более новый пакет, а старые экземпляры приложения ещё работают) `connect()`
|
|
369
|
-
подключается с предупреждением
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
бы прежние функции. Новая версия пакета без правок SQL штамп не меняет, и предупреждения нет.
|
|
381
|
-
|
|
382
|
-
Первый `up()` после перехода на 1.2 строит индекс журнала `log_class_tenant_at (class, tenant, at)` вместо
|
|
383
|
-
`log_class_at`: срез `asOf` и `withDeleted` по классу больше не читает журнал других арендаторов с тем же именем
|
|
384
|
-
класса. Пока индекс строится, запись в журнал ждёт. На большой установке постройте его заранее, не останавливая
|
|
385
|
-
записи: `create index concurrently log_class_tenant_at on "v2.<схема>".log (class, tenant, at)` от роли владельца, —
|
|
386
|
-
тогда `up()` только снимет прежний.
|
|
387
|
-
|
|
388
|
-
Обновление схем 1.4.1, 1.1.0 и 1.0.1 проверяет тест `upgrade`; каждый выпуск проверяет обновление с предыдущего
|
|
389
|
-
выпуска. Схему ставит прежний пакет, данные пишет прежний движок, затем
|
|
390
|
-
вызывается `up()` текущей версии. После него версии и хэши прежнего движка на месте, `verify({ data: true })` чист, а
|
|
391
|
-
каталог схемы совпадает с чистой установкой. Схема 1.4.1 той же ревизии 7 обновляется обычным `up()`, без миграций;
|
|
392
|
-
1.1.0 (ревизия 6) и 1.0.1 (ревизия 5) — через `up({ upgrade: true })`: миграцией `0007` и миграциями `0006` и `0007`.
|
|
442
|
+
подключается с предупреждением
|
|
443
|
+
«движок схемы … ревизии N новее библиотеки (она знает M) — обновите пакет letopis». Поэтому скрипт запуска, который
|
|
444
|
+
вызывает `up()` при каждом старте, после такого обновления пакета остановится, пока не получит `upgrade: true`.
|
|
445
|
+
`up({ dsn, schema, upgrade: true })` применяет миграции из `sql/migrate/` и повторно накатывает функции; данные
|
|
446
|
+
сохраняются. Пока схема не обновлена, `connect()` предупреждает, а функции новой ревизии в ней не работают.
|
|
447
|
+
|
|
448
|
+
Ревизия меняется не с каждой версией: у версий одной ревизии функции могут быть разные. Поэтому `up()` пишет в
|
|
449
|
+
комментарий схемы ещё и штамп движка: версию пакета и хэш его файлов SQL. Если штамп не совпадает с файлами пакета,
|
|
450
|
+
`connect()` предупреждает. Когда схему накатила более старая версия, он советует `up({ schema })`. Когда схему уже
|
|
451
|
+
обновила более новая версия, он советует обновить пакет: `up()` старой версией вернул бы прежние функции. Новая версия
|
|
452
|
+
пакета без правок SQL штамп не меняет, и предупреждения нет.
|
|
393
453
|
|
|
394
454
|
### Версии и совместимость
|
|
395
455
|
|
|
396
456
|
Версии letopis следуют semver. Что обещает каждый шаг версии:
|
|
397
457
|
|
|
398
|
-
- **Патч** (
|
|
458
|
+
- **Патч** (3.0.0 → 3.0.1) — только исправления. Ревизия движка та же: исправленные функции накатывает обычный
|
|
399
459
|
`up({ schema })`, миграций нет; до наката `connect()` предупреждает о расхождении штампа. Работающий код не получает
|
|
400
460
|
новых отказов, методы, опции, экспорты и коды ошибок не исчезают.
|
|
401
|
-
- **Минор** (
|
|
461
|
+
- **Минор** (3.0 → 3.1) — новые возможности: методы, опции, коды ошибок; изменения поведения, которые не ломают
|
|
402
462
|
работающий код; новая ревизия движка. Новую ревизию накатывает `up({ schema, upgrade: true })`. Удалённого и
|
|
403
463
|
переименованного нет.
|
|
404
|
-
- **Мажор** (
|
|
405
|
-
новые отказы для всех. Что
|
|
464
|
+
- **Мажор** (3.x → 4.0) — удаление и переименование методов, опций, экспортов и кодов ошибок, смена сигнатур и типов,
|
|
465
|
+
новые отказы для всех. Что снято и чем его заменить, перечисляет запись версии в [CHANGELOG.md](CHANGELOG.md). Схему
|
|
466
|
+
прошлого мажора новый мажор может не обновлять.
|
|
406
467
|
|
|
407
468
|
Текст сообщения об ошибке в API не входит: в патче он может стать точнее. Полагайтесь на `err.code`.
|
|
408
469
|
|
|
409
|
-
Шаг версии перед каждым выпуском сверяет сценарий `check:semver`: он сравнивает контракт API (`
|
|
410
|
-
и ревизию движка с прошлым выпуском и требует шаг не меньше нужного. Каждый выпуск проверяет
|
|
411
|
-
предыдущего
|
|
470
|
+
Шаг версии перед каждым выпуском сверяет сценарий `check:semver`: он сравнивает контракт API (`lib/api-contract.json`)
|
|
471
|
+
и ревизию движка с прошлым выпуском и требует шаг не меньше нужного. Каждый выпуск проверяет
|
|
472
|
+
обновление схемы с предыдущего выпуска. Что изменилось при переходе на 3.0 и чем заменить снятое — запись
|
|
473
|
+
версии в [CHANGELOG.md](CHANGELOG.md).
|
|
412
474
|
|
|
413
475
|
### Разработка без PostgreSQL: PGlite
|
|
414
476
|
|
|
@@ -469,13 +531,8 @@ const line = link('line', { qty: z.number().int().min(1), price: z.number().int(
|
|
|
469
531
|
ends: { Order: one(Order).onDelete('cascade'), Product },
|
|
470
532
|
key: ['Order', 'Product'],
|
|
471
533
|
})
|
|
472
|
-
const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0).default(0) },
|
|
473
|
-
{ ends: { Customer }, key: ['number'] })
|
|
474
|
-
const Movement = link('Movement', {
|
|
475
|
-
opId: z.string(), kind: z.enum(['topup', 'debit', 'adjust']), amount: z.number().int(), target: z.number().int().optional(),
|
|
476
|
-
}, { ends: { Wallet }, key: ['Wallet', 'opId'] })
|
|
477
534
|
|
|
478
|
-
const models = [Shop, Category, Product, Customer, Vip, Order, line
|
|
535
|
+
const models = [Shop, Category, Product, Customer, Vip, Order, line]
|
|
479
536
|
const plan = await syncModels(db, models) // сухой прогон: что будет создано и изменено
|
|
480
537
|
const applied = await syncModels(db, models, { apply: true }) // { applied: true, created: ['Shop', …], … }
|
|
481
538
|
```
|
|
@@ -487,10 +544,18 @@ const applied = await syncModels(db, models, { apply: true }) // { applied: true
|
|
|
487
544
|
поле необязательным, `.default(…)` — задаёт умолчание, которое база подставит при вставке. Пометки `.meta()` у поля
|
|
488
545
|
(`title`, `description`, свои ключи) база хранит, но на проверку данных они не влияют.
|
|
489
546
|
|
|
490
|
-
Правила `refine
|
|
491
|
-
|
|
547
|
+
Правила `refine` и `superRefine` в JSON Schema не выражаются, и база их не исполняет. `syncModels` возвращает их в
|
|
548
|
+
`lost`: проверяйте такие правила в приложении. Преобразования Zod — `.trim()`, `.toLowerCase()`, `.toUpperCase()`,
|
|
549
|
+
`.normalize()`, `.transform()`, `z.preprocess()`, `.pipe()` — меняют значение, а не проверяют его: ни база, ни
|
|
550
|
+
валидатор их не исполняют, и данные записались бы как есть. Поэтому модель с ними `syncModels` (и `letopis sync`)
|
|
551
|
+
отклоняет до записи, в том числе в сухом прогоне: `invalid_class` (`model.transform_unsupported`, пути и виды — в
|
|
552
|
+
`err.detail.rules`). Значение приводят в коде сервиса до записи (`value.trim()`), а в модели оставляют проверку:
|
|
553
|
+
`.regex()`, `.min()`, `.refine()`. Описание с ключевым словом JSON Schema, которого
|
|
492
554
|
база не знает, отклоняется — ни одно правило не пропадёт молча. Шаблоны `pattern` — ограниченное подмножество
|
|
493
|
-
регулярных выражений, общее для Zod, валидатора и базы.
|
|
555
|
+
регулярных выражений, общее для Zod, валидатора и базы. Исполняет шаблон на данных только база: шаблон задаёт
|
|
556
|
+
арендатор, и шаблон с катастрофическим перебором повесил бы процесс приложения. Поэтому валидатор на TypeScript
|
|
557
|
+
(`compileValidator`, проверка до записи) `pattern` и `anyOf`/`oneOf` с ним в ветке лишь разбирает, а нарушение
|
|
558
|
+
`pattern` приходит от базы при записи — тот же `ValidationError`.
|
|
494
559
|
|
|
495
560
|
### Концы
|
|
496
561
|
|
|
@@ -516,10 +581,13 @@ const applied = await syncModels(db, models, { apply: true }) // { applied: true
|
|
|
516
581
|
Нужный формат ключевого поля задайте в модели, и база будет его проверять.
|
|
517
582
|
|
|
518
583
|
- id не передают: база вычисляет его сама. Чужой id — `id_mismatch`, id для класса без ключа — `id_from_db`.
|
|
519
|
-
- Заранее id вычисляет `db.idOf(класс, ключ)`, например `db.idOf('Shop', { name: 'Ромашка' })`.
|
|
584
|
+
- Заранее id вычисляет `db.idOf(класс, ключ)`, например `db.idOf('Shop', { name: 'Ромашка' })`. Часть ключа — конец
|
|
585
|
+
связи: имя роли и id цели строкой, `db.idOf('Product', { Shop: shop.id, sku: 'K-1' })` (не строка ответа). Часть
|
|
586
|
+
ключа не строкой — `invalid_data` (`model.key_part_not_string`), конец не uuid — `invalid_data`
|
|
587
|
+
(`model.key_end_not_uuid`).
|
|
520
588
|
- Класс без ключа (`Customer` выше) получает id от случайной части; узнать его можно из ответа записи.
|
|
521
|
-
-
|
|
522
|
-
([раздел 5](#5-запись)).
|
|
589
|
+
- Ключ и класс объекта неизменны: правка ключевого поля или класса — `immutable_key`. Объект с другим ключом или
|
|
590
|
+
классом — новый объект ([раздел 5](#5-запись), «Ключ и класс неизменны»).
|
|
523
591
|
|
|
524
592
|
Если уникальным должно быть что-то кроме ключа, сделайте это отдельным классом. Например, email — ключ хаба
|
|
525
593
|
`Email`, а связь человека с ним — ключ `['Email']`. Персональные данные лучше не делать ключом самого человека:
|
|
@@ -534,78 +602,15 @@ id из email подбирается перебором и остаётся в
|
|
|
534
602
|
|
|
535
603
|
### Настройки класса
|
|
536
604
|
|
|
537
|
-
- `history: false` — журнал для класса не ведётся: нет версий, среза на дату, восстановления и событий
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
605
|
+
- `history: false` — журнал для класса не ведётся: нет версий, среза на дату, восстановления и событий подписки (по
|
|
606
|
+
умолчанию `true` — журнал хранит каждую версию). Сменить `history` у существующего класса может только тот, у кого
|
|
607
|
+
есть право `READ` и `DELETE` без условий на все строки класса и его потомков: `false` выключает журнал — аудит этих
|
|
608
|
+
строк.
|
|
541
609
|
- `ownerDefault: 'actor'` — владелец новых строк — сам пользователь, а не арендатор (по умолчанию `'tenant'`).
|
|
542
610
|
Пригодится для правила «каждый видит свои записи».
|
|
543
611
|
- `cache: { ttl }` — чтения только этого класса кэшируются без `.cache()` ([раздел 12](#12-подписка-на-изменения-и-кэш)).
|
|
544
612
|
- `meta` — свободные метаданные для приложения: название, иконка, цвет, порядок в меню. До 64 КБ, наследуются по
|
|
545
613
|
ключам (`null` убирает унаследованное значение), видны всем, у кого есть сессия. У конца — `.meta()`.
|
|
546
|
-
- `derivedBy: 'имя-проектора'` — производный класс: его строки пишет только этот проектор ([«Проекторы»](#проекторы)).
|
|
547
|
-
Имя — строчные латинские буквы, цифры и `_.:-` (первый знак — буква или цифра), до 64 символов. Опция
|
|
548
|
-
наследуется, потомок её не меняет. Концы производного класса — только `onDelete: 'cascade'` или `'unset'`:
|
|
549
|
-
производная строка не мешает удалять цель. На строки производного класса не ссылаются. Приложение, сырой SQL и
|
|
550
|
-
загрузчик в такой класс не пишут: запись, правка и удаление его строк — `derived_class`. Сам производный класс
|
|
551
|
-
(свой или унаследованный) заводит, меняет и удаляет только администратор — администраторское подключение или
|
|
552
|
-
аккаунт с правом System `letopis.projector`; у сессии приложения это `acl_denied` ([«Проекторы»](#проекторы)).
|
|
553
|
-
- `aggregate: { поле: { sum | count, of, via, where } }` — агрегат в базе: сумму или счётчик строк источника держит
|
|
554
|
-
сама база ([«Агрегаты в базе»](#агрегаты-в-базе)).
|
|
555
|
-
|
|
556
|
-
### Агрегаты в базе
|
|
557
|
-
|
|
558
|
-
Агрегат — поле, которое держит сама база: сумма поля или число живых строк класса-источника, чей конец ведёт на
|
|
559
|
-
строку. Он нужен там, где итог обязан совпадать с источником и для сырого SQL: баланс кошелька, число позиций заказа.
|
|
560
|
-
|
|
561
|
-
```ts
|
|
562
|
-
const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0), deposits: z.number().int().optional() }, {
|
|
563
|
-
key: ['number'],
|
|
564
|
-
aggregate: {
|
|
565
|
-
balance: { sum: 'amount', of: 'Movement', via: 'Wallet' }, // сумма операций
|
|
566
|
-
deposits: { count: true, of: 'Movement', via: 'Wallet', where: { properties: { amount: { minimum: 1 } } } }, // число пополнений
|
|
567
|
-
},
|
|
568
|
-
})
|
|
569
|
-
const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet } })
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
- `sum: 'поле'` — сумма числового поля источника, `count: true` — число строк. `of` — класс-источник (имя или
|
|
573
|
-
модель) вместе с потомками, `via` — его одиночный конец, который ведёт на строку-цель. `where` — JSON Schema над
|
|
574
|
-
данными источника: строки, которые её не проходят, в агрегат не входят.
|
|
575
|
-
- Считаются только живые строки того же арендатора, что и цель. Ссылка на строку System в агрегат не входит: общий
|
|
576
|
-
справочник не считает данные всех арендаторов. Агрегат класса арендатора, в том числе унаследованный от класса
|
|
577
|
-
System, считает только классы своего арендатора: класс System с именем `of` в него не входит, а свой потомок класса
|
|
578
|
-
System входит. Агрегат класса System считает классы System и классы арендатора строки-цели.
|
|
579
|
-
- Поле пересчитывает триггер после каждого оператора: запись библиотеки, сырой SQL, каскад удаления, `restore`,
|
|
580
|
-
`reclass`, `rekey`, загрузчик, удаление аккаунта. Операции одного оператора складываются в одно приращение цели, а
|
|
581
|
-
правило класса (`min(0)`) проверяется над итогом: операция, которая увела бы баланс в минус, отклоняется с
|
|
582
|
-
`invalid_data`. Каждое изменение цели — её новая версия с автором и причиной операции.
|
|
583
|
-
- Поле пишет только база. Значение в `create`, `update`, `upsert`, `rekey`, `reclass` или в сыром SQL — ошибка
|
|
584
|
-
`aggregate_field`. Новая строка получает итог уже существующих источников, обычно 0. Замена данных целиком без
|
|
585
|
-
поля сохраняет прежнее значение. Загрузчик переданное значение не пишет. Права на запись в цель не нужно:
|
|
586
|
-
достаточно права на запись в источник.
|
|
587
|
-
- Описание проверяет база (`invalid_class`): поле есть в схеме, оно числовое и не входит в ключ; конец `via` —
|
|
588
|
-
одиночный и принимает класс цели; поле `sum` — число; агрегаты не образуют цикл; цель и источник — не системные и
|
|
589
|
-
не производные классы. Класс-источник описывают раньше агрегата: агрегат над классом, которого ещё нет, отклоняется.
|
|
590
|
-
У класса арендатора источник ищут среди классов арендатора. `syncModels` сам описывает источники раньше целей, а
|
|
591
|
-
модели по кругу (агрегат над собственным потомком, цикл агрегатов) отклоняет до записи (`invalid_class` с кругом).
|
|
592
|
-
Класс `of` не удаляется, пока его называет агрегат класса того же арендатора: сначала агрегат убирают из описания цели.
|
|
593
|
-
- Строки попадают в агрегат только с ведома того, кто читает их все: поле считает и строки, которые автору не видны.
|
|
594
|
-
Описать или изменить агрегат может только тот, кто без условий читает все строки источника и его потомков. Сделать
|
|
595
|
-
класс источником (новый потомок источника, смена `parent`) может только тот, кто без условий читает все
|
|
596
|
-
строки этого класса и его потомков — и до правки, и после. Иначе `acl_denied`.
|
|
597
|
-
- Новый источник проверяется и по тому, кто описал агрегат: он тоже должен без условий читать все строки класса.
|
|
598
|
-
Описавший — автор версии строки `Class` цели, с которой описание агрегата стало нынешним. Если он их не читает, класс
|
|
599
|
-
источником не станет (`acl_denied`), пока агрегат не опишет заново тот, кто читает. У агрегата класса System
|
|
600
|
-
описавший проверяется по классам System, как и при описании. Класс арендатора под агрегатом System проверяется
|
|
601
|
-
только у того, кто его заводит.
|
|
602
|
-
- Опция наследуется, потомок заменяет её целиком. Добавить или изменить агрегат — ужесточение: запись в цель и в
|
|
603
|
-
источники ждёт, пока поле пересчитывается у всех строк.
|
|
604
|
-
- `db.rebuildAggregates(класс)` пересчитывает поле из текущих строк — для ремонта. Версия пишется только там, где
|
|
605
|
-
значение изменилось. Расхождения показывает `db.verify({ data: true })`. Нужно администраторское подключение.
|
|
606
|
-
- Сырой `INSERT … ON CONFLICT DO UPDATE SET data = excluded.data` для цели отклоняется: поле в `excluded.data` уже
|
|
607
|
-
поставил триггер вставки. Пишите `excluded.data - array['balance']`, и поле останется прежним; `upsert` библиотеки
|
|
608
|
-
делает так сам.
|
|
609
614
|
|
|
610
615
|
### Записать модель в базу
|
|
611
616
|
|
|
@@ -614,7 +619,21 @@ const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet
|
|
|
614
619
|
`syncModels` трогает только переданные классы и ничего не удаляет. Безопасные изменения — новое необязательное поле или
|
|
615
620
|
конец, метаданные, алиас — применяются сразу. Всё остальное, в том числе любая правка условий `anyOf` и `oneOf`, —
|
|
616
621
|
ужесточение: оно проверяет все строки семейства, и если им мешают строки, приходит `tightening_conflict` со списком. Классы пишутся в арендатора сессии, и другие арендаторы их не видят.
|
|
617
|
-
Классы, общие для всех, лежат в System: их ставит сид
|
|
622
|
+
Классы, общие для всех, лежат в System: их ставит сид установки. Модель с преобразованиями Zod `syncModels`
|
|
623
|
+
отклоняет ([«Поля»](#поля)).
|
|
624
|
+
|
|
625
|
+
Одна модель в нескольких арендаторах — `syncModels` в сессии каждого: сервис действует от имени пользователя, которому
|
|
626
|
+
в этом арендаторе можно писать классы (по правилу «можно всё» — его члену):
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
for (const [user, tenant] of [[ANNA, SHOP], [BORIS, lutik.tenant]]) {
|
|
630
|
+
await syncModels(await svc.as(user, { tenant }), models, { apply: true }) // классы — в каждом арендаторе свои
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
Из командной строки — тот же `letopis sync` с токеном сессии каждого арендатора. Повтор по арендатору, где модель уже
|
|
635
|
+
записана, ничего не меняет (`unchanged`). Администраторское подключение для этого не нужно: через фасад `svc.as()`
|
|
636
|
+
классы пишет сессия пользователя в пуле приложения, под RLS.
|
|
618
637
|
|
|
619
638
|
То же из командной строки:
|
|
620
639
|
|
|
@@ -624,25 +643,88 @@ npx letopis sync ./models.js --dsn "$APP" --schema v2.shop --token "$TOKEN" --ap
|
|
|
624
643
|
npx letopis types --dsn "$APP" --schema v2.shop --token "$TOKEN" --out types.ts # типы классов из базы
|
|
625
644
|
```
|
|
626
645
|
|
|
627
|
-
###
|
|
646
|
+
### Строгий режим
|
|
647
|
+
|
|
648
|
+
Строгий режим ловит ошибки раньше записи: опечатку в имени класса, роли или поля — при компиляции, а без строгих
|
|
649
|
+
типов — до обращения к базе; правку и удаление всех строк без условия, неизвестную опцию и курсор у счёта — тоже до
|
|
650
|
+
обращения к базе.
|
|
651
|
+
|
|
652
|
+
**Строгие типы.** `connect({ …, models })` с моделями `letopis/model` или `connect<Models>({ … })` с объявлениями
|
|
653
|
+
`letopis types` дают подключение со строгими типами (`TypedDb`). Описания классов библиотека всё равно берёт из базы:
|
|
654
|
+
модели и объявления нужны только TypeScript.
|
|
655
|
+
|
|
656
|
+
- Шаг от корня — только класс или алиас из моделей и системные классы `Class`, `Account`, `member`, `Resource`,
|
|
657
|
+
`rule` (встроенный набор `SystemModels`; своя модель с тем же именем заменяет встроенную).
|
|
658
|
+
- Переход — только по концам: от класса — его роли и классы, связанные с ним концом в любую сторону.
|
|
659
|
+
- Слот — только роль записываемого класса: `set(id | строка | цепочка)`, `add()` и `remove()` — у множественного
|
|
660
|
+
конца, `unset()` — у необязательного.
|
|
661
|
+
- Поля фильтра и `or()` — поля и роли класса шага; поле `sum()` и `avg()` — числовое `'data.<путь>'` класса, `min()`,
|
|
662
|
+
`max()` и `countBy()` — любое его поле.
|
|
663
|
+
- Строки ответа типизированы данными и концами класса: тип данных — `Infer<typeof Product>`, концов —
|
|
664
|
+
`InferLinks<typeof Product>`. Своё описание `meta` типизируется расширением интерфейса `ClassMeta`.
|
|
628
665
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
666
|
+
```ts
|
|
667
|
+
import { connect, gte, loose } from 'letopis'
|
|
668
|
+
|
|
669
|
+
const typed = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!, models })
|
|
670
|
+
const pricey = await typed.Shop({ name: 'Ромашка' }).Product({ price: gte(1000) }).rows() // data.price — number
|
|
671
|
+
// @ts-expect-error — класса Prodcut нет
|
|
672
|
+
typed.Prodcut()
|
|
673
|
+
// @ts-expect-error — Shop и Category не связаны концом
|
|
674
|
+
typed.Shop({ name: 'Ромашка' }).Category()
|
|
675
|
+
const name: string = 'Product' // имя, известное только во время выполнения
|
|
676
|
+
const n = await loose(typed)[name]().count()
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
`letopis types` выдаёт для каждого класса `<Класс>Data`, `<Класс>Links` и `<Класс>Model` — имя, алиас, концы с
|
|
680
|
+
классами целей и предков, — а в конце список `Models`: строгие типы работают и без моделей Zod. Имя, известное только
|
|
681
|
+
во время выполнения, — `loose(db)[имя]()`: то же подключение без строгих типов, проверки во время выполнения те же.
|
|
682
|
+
Модели без литеральных имён (список типа `AnyModel[]`) строгих типов не дают — тогда подключение без них (`Db`).
|
|
683
|
+
Чего строгие типы не знают: возврат к узлу связи от класса, с которым она не связана концом, и роль чужого класса
|
|
684
|
+
шагом; `db.as()`, `db.begin()`, `db.batch()` и `entity()` отдают цепочки без строгих типов.
|
|
685
|
+
|
|
686
|
+
**Отказы во время выполнения** — с моделями и без них:
|
|
687
|
+
|
|
688
|
+
| Что | Ответ |
|
|
689
|
+
|---|---|
|
|
690
|
+
| `delete({ confirm: true })` без `expect` (и без `all: true`) | `invalid_query` (`write.confirm_without_expect`) до записи, в батче — при `run()`: удаление не сверено с превью, которое видел пользователь ([раздел 7](#превью-и-удаление)) |
|
|
691
|
+
| `restore()` без `expect` (и без `all: true`), в том числе по явному id | `invalid_query` (`write.restore_without_expect`) до записи ([«Восстановить: `restore`»](#восстановить-restore)) |
|
|
692
|
+
| `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](#превью-и-удаление)) |
|
|
693
|
+
| неизвестная опция `connect()`, `up()`, `db.load()`, `db.watch()`, `createMcpServer()` | `invalid_data` с похожими именами |
|
|
694
|
+
| шаг — не класс, не алиас и не роль реестра | `invalid_class` с похожими именами (системные классы в похожие не идут) — при вызове шага; подсказка — классы арендатора видны только в его сессии. В реестре сессии нет ни одного класса арендатора — свой ключ `api.unknown_step_no_classes`: сессия, похоже, в личном арендаторе ([«Работа в другом арендаторе»](#работа-в-другом-арендаторе)) |
|
|
695
|
+
| у цепочки спросили свойство массива — `.length`, `.map()`, `.filter()`, `.forEach()`, `[0]` и т. п. — или перебрали её (`for…of`, `[...цепочка]`) | `invalid_query` (`api.chain_not_rows`): цепочка — не массив, строки даёт терминал — `await ….rows()` |
|
|
696
|
+
| id шага, фильтра `{ id }` или значения конца — не uuid, `null` вместо id | `invalid_query` (`query.bad_uuid`) до запроса; id в любом виде, который принимает PostgreSQL (без дефисов, в фигурных скобках), проходит |
|
|
697
|
+
| модель с преобразованием Zod (`.trim()`, `.transform()` …) | `invalid_class` (`model.transform_unsupported`) от `syncModels` до записи ([«Поля»](#поля)) |
|
|
698
|
+
| `db.begin(аргумент)` — функция, строка, число, массив вместо опций `{ reason }` | `invalid_query` (`api.begin_arg`): `begin()` функцию не вызывает; неизвестная опция `begin()` — `invalid_data` с похожими |
|
|
699
|
+
| `restore({ partial: true })` без `expect`, в том числе с `all: true` | `invalid_query` (`write.partial_without_expect`) |
|
|
700
|
+
| `createTenant(admin, …)`, где `admin` — не пул `postgres()`: строка DSN, подключение `connect()`, `undefined` | `invalid_query` (`api.create_tenant_admin`) до запросов ([«Первый арендатор и пользователь»](#первый-арендатор-и-пользователь)) |
|
|
701
|
+
| метод цепочки у результата `versions()`: `versions().rows()`, `.sort()`, `.limit()` | `invalid_query` (`api.versions_terminal`): `versions()` — терминал, модификаторы — до него |
|
|
702
|
+
| `forUpdate()` с тем, что блокировать нельзя: `count()`, `paths()`, `versions()`, агрегат, `asOf()`, `withDeleted()`, `deep()`, глагол | `lock_unsupported` (`api.for_update_unsupported`): текст называет помеху |
|
|
703
|
+
|
|
704
|
+
**Предупреждения** — не отказы: цепочка исполняется, а в консоль один раз на место (`console.warn`, текст начинается с
|
|
705
|
+
`letopis:`) выводится строка:
|
|
706
|
+
|
|
707
|
+
| Что | Ключ | Почему |
|
|
708
|
+
|---|---|---|
|
|
709
|
+
| шаг с аргументом `undefined`: `db.Product(undefined)` | `api.step_undefined_filter` | фильтра нет — шаг находит все строки, как `db.Product()`; чаще всего это пустая переменная с id |
|
|
710
|
+
| шаг после `sort()`: `db.Shop(shop).sort('data.name').Product()` | `api.sort_not_last` | `sort()` относится к последнему шагу цепочки — ставьте его в конец |
|
|
711
|
+
| `withDeleted()` не у последнего шага, а у последнего его нет: `db.Order(o).withDeleted().line()` | `api.with_deleted_step` | `withDeleted()` — модификатор своего шага: здесь удалённые заказы и живые строки; удалённые строки — `.line().withDeleted()` ([«Удалённые»](#удалённые)) |
|
|
712
|
+
| поле фильтра (и внутри `or()`), `sort('data.…')` или агрегата, которого нет в описании ни одного класса шага (с потомками, если шаг не `exact()`; у роли — её целей); в фильтре годится и роль конца | `invalid_query` с похожими именами до запроса, у плана записи — до записи. Сверяется только первый ключ пути: путь внутрь поля-объекта не проверяется. У строк `Class` поля — свойства описания класса (`kind`, `alias`, `parent` и другие) |
|
|
713
|
+
| `after()` перед `count()`, `sum()`, `avg()`, `min()`, `max()` или `countBy()` | `invalid_query`: курсор листает строки, а счёт идёт по всем объектам шага |
|
|
714
|
+
| класс с зарезервированным именем | `invalid_class`: хаб — от `hub()`, `syncModels` и базы, связь — от базы ([«Имена классов»](#имена-классов)) |
|
|
632
715
|
|
|
633
716
|
### Имена классов
|
|
634
717
|
|
|
635
718
|
Шаг цепочки называется именем класса, его алиасом или именем роли. Имена методов цепочки и корня (`count`, `sort`,
|
|
636
|
-
`set`, `sql`, `auth` и другие) там, где такой метод есть, библиотека разбирает раньше
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
[«Зарезервированные имена»](#зарезервированные-имена).
|
|
719
|
+
`set`, `sql`, `auth` и другие) там, где такой метод есть, библиотека разбирает раньше классов, поэтому класс с таким
|
|
720
|
+
именем не создать: хаб отклоняют `hub()`, `syncModels` и база, связь — база (`invalid_class`). Хаб, заведённый мимо
|
|
721
|
+
проверки базы (например, от роли владельца), `up()` оставляет и называет в предупреждении, правку его описания база
|
|
722
|
+
отклоняет. Полный список имён — в разделе [«Зарезервированные имена»](#зарезервированные-имена).
|
|
640
723
|
|
|
641
724
|
## 5. Запись
|
|
642
725
|
|
|
643
|
-
Глаголы записи — `create`, `update`, `upsert`, `delete`, `
|
|
644
|
-
|
|
645
|
-
план — одна транзакция.
|
|
726
|
+
Глаголы записи — `create`, `update`, `upsert`, `delete`, `restore` — звенья плана. Сам по себе глагол ничего не
|
|
727
|
+
делает: план исполняет любой терминал (`rows`, `first`, `ids`, `count` и другие), и весь план — одна транзакция.
|
|
646
728
|
|
|
647
729
|
### Создать: `create`
|
|
648
730
|
|
|
@@ -656,7 +738,9 @@ const [green] = await db.Category().create({ name: 'Зелёный чай' }).Pa
|
|
|
656
738
|
const [kettle] = await db.Shop(shop).Product().create({ sku: 'K-1', title: 'Чайник', price: 2500 }).rows()
|
|
657
739
|
const [sencha] = await db.Shop(shop).Product()
|
|
658
740
|
.create({ sku: 'T-7', title: 'Сенча', price: 900 }).Category.add(green).rows()
|
|
741
|
+
await db.Shop(shop).Product().create({ sku: 'M-1', title: 'Кружка', price: 400 }).rows()
|
|
659
742
|
const [olga] = await db.Customer().create({ name: 'Ольга', email: 'olga@mail.ru' }).rows()
|
|
743
|
+
await db.Vip().create({ name: 'Вера', email: 'vera@mail.ru', discount: 10 }).rows() // Vip — потомок Customer
|
|
660
744
|
|
|
661
745
|
try {
|
|
662
746
|
await db.Shop().create({ name: 'Ромашка' }).rows()
|
|
@@ -670,8 +754,19 @@ try {
|
|
|
670
754
|
защищён: повтор создаст второй объект.
|
|
671
755
|
- Теги новой строки задаёт `.tags([…])` перед глаголом, владельца — `.owner(id)`; явного владельца должны разрешать
|
|
672
756
|
права.
|
|
673
|
-
- Удалённый объект можно создать снова тем же ключом:
|
|
674
|
-
|
|
757
|
+
- Удалённый объект можно создать снова тем же ключом: новый объект **занимает id удалённого** (id — v5 от ключа), и
|
|
758
|
+
история id продолжится. Восстановить прежний после этого нельзя: `restore({ expect })` по его надгробию — `not_deleted`
|
|
759
|
+
(`write.restore_id_taken`). Если удалённое ещё могут вернуть, перед `create()` проверьте
|
|
760
|
+
ключ вместе с удалёнными: `db.Shop({ name: 'Ромашка' }).withDeleted().first()` — строка с `$deleted: true` значит,
|
|
761
|
+
что ключ принадлежит удалённому объекту, и решать, восстановить его или занять ключ, должно приложение.
|
|
762
|
+
- Ответ — строки созданных объектов со всеми колонками ([раздел 14](#14-справочник-api), «Строка ответа»).
|
|
763
|
+
- Ключи со значением `undefined` (`{ phone: undefined }`) `create`, `upsert` и `update` отбрасывают. У `update` поле
|
|
764
|
+
поэтому не меняется, а `upsert` заменяет данные целиком — поле с `undefined` у него **пропадает**, как не
|
|
765
|
+
переданное. `null` — значение: оно проходит, только если схема поля его допускает (`.nullable()`).
|
|
766
|
+
- Концы — не поля данных: ключ с именем роли (`{ Shop: shop.id }`) в данных `create` — ошибка `invalid_data` с ключом
|
|
767
|
+
`write.link_in_data` до записи: текст называет ключ и класс («… — связь, а не поле данных») и сразу форму слота —
|
|
768
|
+
`.Shop.set(id)`; конец берётся из пути или задаётся слотом ([«Концы: из пути и слоты»](#концы-из-пути-и-слоты)). То
|
|
769
|
+
же у `upsert` и `update`.
|
|
675
770
|
|
|
676
771
|
### Изменить: `update`
|
|
677
772
|
|
|
@@ -685,28 +780,41 @@ await db.Shop(shop).Product({ price: gte(2000) }).update({ title: 'Чайник
|
|
|
685
780
|
|
|
686
781
|
`update` правит каждую цель шага и никогда не создаёт. Патч сливается с данными внутри оператора: вложенные
|
|
687
782
|
объекты — рекурсивно, а массивы, скаляры и `null` заменяются. Поэтому параллельные правки разных полей не теряются.
|
|
688
|
-
Если у шага нет своего фильтра, цель берётся из `data.id`
|
|
689
|
-
`
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
783
|
+
Если у шага нет своего фильтра, цель берётся из `data.id` патча. Цели сужают фильтр, id, `tags()`, `owner()` или
|
|
784
|
+
`limit()` у шага глагола или шагов до него (или `create()` и `upsert()` раньше в цепочке; пустой `tags([])` не
|
|
785
|
+
сужает); без них — `invalid_query` до записи
|
|
786
|
+
([«Строгий режим»](#строгий-режим)). Править все строки шага — явно: `db.Product().update({ views: 0 }, { all: true })`
|
|
787
|
+
правит все товары арендатора. Шаг ничего не нашёл — ответ пустой и ошибки нет, поэтому проверяйте длину ответа.
|
|
788
|
+
|
|
789
|
+
Фильтр, который сужает цели не только по id (по данным, концам, `tags()`, `owner()`), `update` перепроверяет: цели
|
|
790
|
+
блокируются (`for no key update`, по порядку id) и читаются заново тем же запросом, а правятся только строки, которые
|
|
791
|
+
и после ожидания блокировки подходят. Так `db.Product({ price: gte(2000) }).update(…)` не тронет товар, который
|
|
792
|
+
параллельная транзакция успела удешевить, а из двух одновременных `db.Order({ id, status: 'new' }).update({ status:
|
|
793
|
+
'paid' })` проходит один — второй отвечает `[]`. Фильтр родительского шага перепроверяется так же. Без гонки цели те
|
|
794
|
+
же. Фильтр только по id перепроверять нечего: две одновременные правки одного id пройдут обе, вторая поверх первой. Перепроверка защищает только условие самого фильтра:
|
|
795
|
+
новое значение, вычисленное из прочитанного, защищают `{ rev }` и `forUpdate()` в `db.begin()`, а правило «не больше
|
|
796
|
+
одной открытой записи на объект» — ключ связи ([«Восстановить: `restore`»](#восстановить-restore)).
|
|
797
|
+
Если `update` затронул меньше строк, чем нашёл шаг, весь план откатывается: строку успели удалить —
|
|
798
|
+
`target_not_found`, строка видна, но править её нельзя — `acl_denied`. Модификатор `.tags(…)` перед `update`
|
|
799
|
+
отбирает строки; строка или список ещё и заменяют их теги целиком, а оператор (`has`, `hasAny`, `hasAll`) только
|
|
800
|
+
отбирает — теги не меняются.
|
|
694
801
|
|
|
695
802
|
### Создать или заменить: `upsert`
|
|
696
803
|
|
|
697
804
|
<!-- run: guide -->
|
|
698
805
|
```ts
|
|
699
806
|
const [kept] = await db.Shop().upsert({ name: 'Ромашка', city: 'Казань' }).rows() // kept.$upsert: 'unchanged'
|
|
700
|
-
const [
|
|
807
|
+
const [replaced] = await db.Shop().upsert({ name: 'Ромашка', city: 'Уфа' }).rows() // replaced.$upsert: 'updated'
|
|
701
808
|
```
|
|
702
809
|
|
|
703
810
|
`upsert` создаёт объект, а если объект с этим ключом уже есть — заменяет его данные, концы и теги целиком. Это
|
|
704
811
|
один оператор `INSERT … ON CONFLICT`, поэтому гонки «проверил, что нет, — создал» не бывает. Правила:
|
|
705
812
|
|
|
706
813
|
- только класс с ключом, иначе `no_key` без обращения к базе;
|
|
707
|
-
- объект передаётся целиком: поля, которых нет в переданном
|
|
814
|
+
- объект передаётся целиком: поля, которых нет в переданном объекте или которые равны `undefined`, пропадают;
|
|
815
|
+
сохранить прежнее значение поля — `update`, а не `upsert`;
|
|
708
816
|
- ответ несёт `$upsert`: `created`, `updated` или `unchanged`;
|
|
709
|
-
- приращать `upsert` не умеет и затирает параллельные приращения — для
|
|
817
|
+
- приращать `upsert` не умеет и затирает параллельные приращения — для счётчиков он не годится;
|
|
710
818
|
- для синхронизации больших справочников дешевле загрузчик с `existing: 'update'` ([раздел 9](#9-массовая-загрузка)).
|
|
711
819
|
|
|
712
820
|
### Концы: из пути и слоты
|
|
@@ -725,6 +833,16 @@ await db.Category(green).update({}).Parent.unset().rows() // катег
|
|
|
725
833
|
await db.Category(green).update({}).Parent.set(tea).rows() // и снова внутри «Чая»
|
|
726
834
|
```
|
|
727
835
|
|
|
836
|
+
У связи с двумя концами один конец обычно приходит из пути, второй — слотом; можно и оба слотом. Строка заказа
|
|
837
|
+
`line` с концами `Order` и `Product` (пример целиком — в [«Как исполняется план»](#как-исполняется-план)):
|
|
838
|
+
|
|
839
|
+
```ts
|
|
840
|
+
await db.Order(order).line().create({ qty: 1, price: 900 }).Product.set(sencha).rows() // Order — из пути
|
|
841
|
+
// или так — оба конца слотом (вместо строки выше: ключ [Order, Product] второй такой строки не даст)
|
|
842
|
+
await db.line().create({ qty: 1, price: 900 }).Order.set(order).Product.set(sencha).rows()
|
|
843
|
+
// db.line().create({ qty: 1, price: 900, Order: order, Product: sencha.id }) — invalid_data: концы не поля данных
|
|
844
|
+
```
|
|
845
|
+
|
|
728
846
|
### Числа и счётчики: `inc`
|
|
729
847
|
|
|
730
848
|
<!-- run: guide -->
|
|
@@ -743,14 +861,10 @@ await db.Product(kettle).update({ views: inc(1, { start: 0 }) }).rows() // п
|
|
|
743
861
|
- поле должно уже быть числом; если его нет — `invalid_data` или `start`. `null` и нечисло — ошибка даже со `start`;
|
|
744
862
|
- путь идёт только через существующие объекты;
|
|
745
863
|
- маркер `inc` нельзя получить из JSON: `{ "$inc": 5 }`, пришедший из сети, остаётся данными;
|
|
746
|
-
-
|
|
864
|
+
- суммы храните целым числом мелких единиц (копеек) не больше 2^53 − 1.
|
|
747
865
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
одна: операции одного объекта идут друг за другом под блокировкой его строки, и каждая пишет его версию. На машине
|
|
751
|
-
замеров один объект так принимал 150–190 операций в секунду, сколько бы писателей его ни ждало
|
|
752
|
-
([раздел 17](#17-производительность)). Для более горячих счётчиков берите проектор со свёрткой пачкой
|
|
753
|
-
([«Проекторы»](#проекторы)): итог отстаёт от источника, зато писатели не ждут друг друга.
|
|
866
|
+
Операции `inc` одного объекта идут друг за другом под блокировкой его строки, и каждая пишет его версию: писатели
|
|
867
|
+
одного горячего счётчика ждут друг друга.
|
|
754
868
|
|
|
755
869
|
### Защита от параллельной правки: `{ rev }`
|
|
756
870
|
|
|
@@ -765,44 +879,16 @@ try {
|
|
|
765
879
|
}
|
|
766
880
|
```
|
|
767
881
|
|
|
768
|
-
|
|
769
|
-
`conflict`, и ничего не записывается.
|
|
882
|
+
Правка с `{ rev }` нужна, когда новое значение вычислено из прочитанного: если строку успели изменить, приходит
|
|
883
|
+
`conflict`, и ничего не записывается. `{ rev }` работает при одной цели шага (иначе `rev_ambiguous`). `conflict`
|
|
770
884
|
библиотека сама не повторяет: что делать с изменившимися данными, решает приложение. Другой способ — чтение с
|
|
771
|
-
блокировкой `forUpdate()` ([раздел 8](#8
|
|
772
|
-
|
|
773
|
-
### Сменить ключ или класс
|
|
774
|
-
|
|
775
|
-
<!-- run: guide -->
|
|
776
|
-
```ts
|
|
777
|
-
const [mug] = await db.Shop(shop).Product().create({ sku: 'M-1', title: 'Кружка', price: 400 }).rows()
|
|
778
|
-
const [mug2] = await db.Product(mug).rekey({ sku: 'M-100' }).rows() // новый ключ — новый id, ссылки переведены
|
|
779
|
-
const [vip] = await db.Customer(olga).reclass('Vip', { name: 'Ольга', email: 'olga@mail.ru', discount: 10 }).rows()
|
|
780
|
-
```
|
|
885
|
+
блокировкой `forUpdate()` ([раздел 8](#8-транзакции-и-батчи)).
|
|
781
886
|
|
|
782
|
-
|
|
783
|
-
ссылающаяся строка включает этот конец в свой ключ, её id тоже меняется, и перевод идёт дальше. Журнал связывает
|
|
784
|
-
старый и новый id (`moved`).
|
|
785
|
-
- `db.rekeyClass(класс, ключ)` — новый ключ для всех строк семейства, одной транзакцией. У производного класса и
|
|
786
|
-
у класса с производным потомком — `derived_class`: строки производного класса пишет только проектор.
|
|
787
|
-
- `reclass(класс, данные)` — смена класса; данные необязательны, а если переданы — задаются целиком. Строка уходит из
|
|
788
|
-
прежнего класса, поэтому нужно право `DELETE` на неё, `WRITE` — на новый класс и на каждую строку, чьи ссылки
|
|
789
|
-
переписывает перевод, на любой глубине. Если у нового класса нет ключа, id сохраняется (как у `vip` выше); если
|
|
790
|
-
есть — id вычисляется заново, а ссылки переводятся.
|
|
791
|
-
Роли ссылающихся строк должны принимать новый класс, иначе `reclass_denied`. Сырой `UPDATE … SET class`
|
|
792
|
-
проходит, только если ключа нет ни у старого, ни у нового класса (иначе `use_reclass`).
|
|
793
|
-
- Удаление, смена класса и смена ключа работают только в `read committed`: транзакции библиотеки всегда открываются
|
|
794
|
-
на этом уровне.
|
|
887
|
+
### Ключ и класс неизменны
|
|
795
888
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
```ts
|
|
800
|
-
await db.Customer(olga).anonymize(['email']).rows() // email → '[erased]', тег anonymized
|
|
801
|
-
```
|
|
802
|
-
|
|
803
|
-
`anonymize(поля)` затирает строковые поля новой версией и помечает строку тегом `anonymized`. Прежние версии
|
|
804
|
-
остаются в журнале. Для полного забвения дальше идут обрезка истории ([раздел 11](#11-история-и-срезы-на-дату)),
|
|
805
|
-
затем удаление и `purge` ([раздел 7](#7-удаление-и-восстановление)).
|
|
889
|
+
Ключевые поля, класс, id, арендатор и владелец строки правкой не меняются — `immutable_key`, в том числе у сырого
|
|
890
|
+
`UPDATE … SET class`. Объект с другим ключом или классом — новый объект: создайте его (`create`), переведите на него
|
|
891
|
+
ссылки правкой ссылающихся строк и удалите прежний.
|
|
806
892
|
|
|
807
893
|
### Как исполняется план
|
|
808
894
|
|
|
@@ -823,10 +909,11 @@ await db.Order(order).line().create({ qty: 1, price: 900 }).Product.set(sencha).
|
|
|
823
909
|
(`40001`) и конфликте журнала библиотека повторяет план целиком, до трёх раз.
|
|
824
910
|
- Ошибка любого звена откатывает весь план. `exists` и `conflict` не повторяются: это ответы для приложения.
|
|
825
911
|
- Модификаторы до глагола (`limit`, `offset`, `sort`) сужают его цели.
|
|
826
|
-
- Модификатор после глагола относится к тому, что идёт за ним: к шагу класса, к `versions()
|
|
827
|
-
|
|
828
|
-
сужать уже нечего — глагол прошёл по всем целям шага, — поэтому
|
|
829
|
-
`invalid_query` и ничего не пишет. В батче так же
|
|
912
|
+
- Модификатор после глагола относится к тому, что идёт за ним: к шагу класса, к `versions()` и агрегатам или к
|
|
913
|
+
целям следующего глагола. Если за ним сразу терминал `rows`, `first`, `ids`, `count` или `paths`,
|
|
914
|
+
сужать уже нечего — глагол прошёл по всем целям шага, — поэтому
|
|
915
|
+
`db.Shop(shop).Product().update(…).limit(1).rows()` даёт `invalid_query` и ничего не пишет. В батче так же
|
|
916
|
+
отказывает `run()`.
|
|
830
917
|
- Глагол сразу после глагола пишет в те же сущности.
|
|
831
918
|
- Строки ответа глагола идут в порядке его целей: по `sort()` до глагола, без него — по id; `first()` после глагола —
|
|
832
919
|
первая по этому порядку.
|
|
@@ -856,6 +943,13 @@ const onlyCustomer = await db.Customer().exact().rows() // только
|
|
|
856
943
|
ценой `"900"`. Список как значение поля — «массив содержит все элементы». Операторы приводят своё значение к типу
|
|
857
944
|
поля из JSON Schema. Роль конца в фильтре — id цели или `null`.
|
|
858
945
|
|
|
946
|
+
Тип поля для сравнения — по классу шага. Шаг по родителю отдаёт и потомков, и в таком семействе поле может быть
|
|
947
|
+
описано разными типами (потомок переопределил тип, в одном из классов поле не описано). Тогда значение, которое к
|
|
948
|
+
типу класса шага не приводится, в операторах сравнения, `sort()`, `after()` и агрегатах `min`, `max`, `sum`, `avg`
|
|
949
|
+
считается `null`: при сортировке по возрастанию оно идёт после прочих (по убыванию — первым), ни одному оператору
|
|
950
|
+
сравнения не отвечает, агрегат его пропускает, а курсор такой строки — как курсор `null`. Значение, которое
|
|
951
|
+
приводится (`'7'` у числового поля), сравнивается как число. В семействе с одним типом поля всё как обычно.
|
|
952
|
+
|
|
859
953
|
<!-- run: guide -->
|
|
860
954
|
```ts
|
|
861
955
|
import { lt, ilike, between, or, not } from 'letopis'
|
|
@@ -868,18 +962,45 @@ const ofShop = await db.Product({ Shop: shop.id }).rows()
|
|
|
868
962
|
const roots = await db.Category({ Parent: null }).rows() // корневые категории
|
|
869
963
|
```
|
|
870
964
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
|
875
|
-
|
|
876
|
-
| `
|
|
877
|
-
| `
|
|
878
|
-
| `
|
|
879
|
-
| `
|
|
965
|
+
Операторы и их ключи в JSON-фильтре MCP (инструмент `letopis_query`, [раздел 13](#агенты-и-mcp)):
|
|
966
|
+
|
|
967
|
+
<!-- ops -->
|
|
968
|
+
| TypeScript | MCP | Смысл |
|
|
969
|
+
|---|---|---|
|
|
970
|
+
| `ne(v)` | `{ "$ne": v }` | Не равно. Подходят и строки без поля или с `null`; `ne(null)` — строки, где у поля есть значение |
|
|
971
|
+
| `gt(v)` | `{ "$gt": v }` | Больше. Сравнение — по типу поля из описания класса (числа — как числа, даты — как даты), поле без описания — как текст; строки без поля не подходят |
|
|
972
|
+
| `gte(v)` | `{ "$gte": v }` | Больше или равно; сравнение — как у `gt` |
|
|
973
|
+
| `lt(v)` | `{ "$lt": v }` | Меньше; сравнение — как у `gt` |
|
|
974
|
+
| `lte(v)` | `{ "$lte": v }` | Меньше или равно; сравнение — как у `gt` |
|
|
975
|
+
| `between(a, b)` | `{ "$between": [a, b] }` | От `a` до `b` включительно; сравнение — как у `gt`. Если `a` больше `b`, не подходит ничего |
|
|
976
|
+
| `inList([v, …])` | `{ "$in": [v, …] }` | Одно из значений списка. Пустой список не находит ничего; `null` в списке не находит строк без значения — для них `isNull` |
|
|
977
|
+
| `like(s)` | `{ "$like": "s" }` | Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один; буквальные `%` и `_` — с обратной косой чертой |
|
|
978
|
+
| `ilike(s)` | `{ "$ilike": "s" }` | Как `like`, но без учёта регистра |
|
|
979
|
+
| `starts(s)` | `{ "$starts": "s" }` | Текст начинается с `s`, с учётом регистра. Это `like(s + '%')`: `%` и `_` внутри `s` не экранируются и работают как в шаблоне |
|
|
980
|
+
| `ends(s)` | `{ "$ends": "s" }` | Текст заканчивается на `s`, с учётом регистра. Это `like('%' + s)`: `%` и `_` внутри `s` не экранируются и работают как в шаблоне |
|
|
981
|
+
| `has(v)` | `{ "$has": v }` | Поле-массив содержит элемент `v`. Тип важен: `has(5)` найдёт число 5, но не строку `'5'` |
|
|
982
|
+
| `hasAny([v, …])` | `{ "$hasAny": [v, …] }` | Поле-массив содержит хотя бы один элемент списка. Находит только строковые элементы; пустой список не находит ничего |
|
|
983
|
+
| `hasAll([v, …])` | `{ "$hasAll": [v, …] }` | Поле-массив содержит все элементы списка, в любом порядке. Тип важен, как у `has`; пустой список подходит любой строке, даже без поля |
|
|
984
|
+
| `exists(b = true)` | `{ "$exists": b }` | Поле есть в данных (`true`, по умолчанию) или его нет (`false`); значение не важно — поле с `null` тоже есть |
|
|
985
|
+
| `isNull()` | `{ "$isNull": true }` | В поле `null` или поля нет совсем; отличить одно от другого помогает `exists` |
|
|
986
|
+
| `not(x)` | `{ "$not": x }` | Отрицание: `x` — оператор (`not(gte(1000))` — меньше 1000 или поля нет) или простое значение — тогда это «не равно», как `ne` |
|
|
987
|
+
| `or(f1, f2, …)` | `{ "$or": [f1, f2, …] }` | Хотя бы один из фильтров. Ставится вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и» |
|
|
988
|
+
<!-- /ops -->
|
|
880
989
|
|
|
881
990
|
Теги и владелец строки — модификаторы `.tags(…)` и `.owner(…)`.
|
|
882
991
|
|
|
992
|
+
Пустые значения в фильтре:
|
|
993
|
+
|
|
994
|
+
- `{ поле: null }` — вхождение в `data`: находит только строки, где в поле явно записан `null`. Строки, где поля нет
|
|
995
|
+
совсем, находят `isNull()` (и `null`, и нет поля) и `exists(false)` (только нет поля).
|
|
996
|
+
- Фильтр `undefined` — шаг без фильтра: `db.Product(undefined).first()` вернёт любой товар и выведет предупреждение
|
|
997
|
+
`api.step_undefined_filter` ([«Строгий режим»](#строгий-режим)). Ключ со значением `undefined` в объекте фильтра
|
|
998
|
+
(`{ id: undefined }`, `{ Shop: undefined }`) отбрасывается. `update()` без сужения откажет сам (`invalid_query`), а
|
|
999
|
+
чтение — нет: обязательные id из запроса проверяйте до цепочки.
|
|
1000
|
+
- id в фильтре — uuid. Строка другого вида (`db.Product('42')`), а также `null` вместо id — `invalid_query`
|
|
1001
|
+
(`query.bad_uuid`) до запроса: `Product(…): "42" — не uuid`. Так же проверяются `{ id }` и значение конца
|
|
1002
|
+
(`{ Shop: '42' }`). Подходит любой вид, который принимает PostgreSQL: с дефисами, без них, в фигурных скобках.
|
|
1003
|
+
|
|
883
1004
|
### Терминалы и строка ответа
|
|
884
1005
|
|
|
885
1006
|
| Терминал | Что отдаёт |
|
|
@@ -893,8 +1014,8 @@ const roots = await db.Category({ Parent: null }).rows() // корневы
|
|
|
893
1014
|
| `sum`, `avg`, `min`, `max`, `countBy` | агрегаты по полю `'data.<путь>'`; на пустом множестве — `null`, у `countBy` — `{}` |
|
|
894
1015
|
|
|
895
1016
|
Строка ответа: `id`, `class`, `rev` (номер версии), `tenant`, `owner`, `links` (концы), `data` (поля), `tags`,
|
|
896
|
-
`at` (время версии), `author`, `op` (вид операции) и, если есть, `agent`, `reason
|
|
897
|
-
|
|
1017
|
+
`at` (время версии), `author`, `op` (вид операции) и, если есть, `agent`, `reason`. Служебные поля начинаются с
|
|
1018
|
+
`$`: `$deleted`, `$depth`, `$upsert`, `$action`.
|
|
898
1019
|
|
|
899
1020
|
### Сортировка и постраничный вывод
|
|
900
1021
|
|
|
@@ -907,6 +1028,13 @@ const page2 = await db.Product().sort('data.price', 'desc')
|
|
|
907
1028
|
.after(cursorOf(page1.at(-1)!, 'data.price')).limit(2).rows() // строго после последней строки страницы
|
|
908
1029
|
```
|
|
909
1030
|
|
|
1031
|
+
Модификаторы выборки не привязаны к шагу, после которого стоят. `sort`, `limit`, `offset`, `after` и `forUpdate`
|
|
1032
|
+
относятся к последнему шагу цепочки (а перед глаголом — к его целям), `asOf` — ко всей цепочке:
|
|
1033
|
+
`db.Shop(shop).sort('data.price').Product().rows()` сортирует товары, а поле `sort()` сверяется с классом последнего
|
|
1034
|
+
шага — иначе `invalid_query` с именем этого класса. Шаг после `sort()` выводит предупреждение `api.sort_not_last`:
|
|
1035
|
+
ставьте `sort()` в конец цепочки. `withDeleted()` — модификатор своего шага, как `exact()`
|
|
1036
|
+
([«Удалённые»](#удалённые)).
|
|
1037
|
+
|
|
910
1038
|
`sort(поле, 'asc' | 'desc')` — по `'at'`, `'rev'`, `'id'` или `'data.<путь>'`. `after(cursorOf(строка, поле))` —
|
|
911
1039
|
постраничный вывод «после такой-то строки»; он требует `sort()`. Такой вывод устойчив к вставкам и удалениям между
|
|
912
1040
|
страницами, но строка, у которой изменилось поле сортировки, может выпасть или повториться. Курсор — объект
|
|
@@ -920,7 +1048,8 @@ const page2 = await db.Product().sort('data.price', 'desc')
|
|
|
920
1048
|
`after()` листает и `versions()`: курсор версии несёт её `rev`, поэтому версии одного объекта с равным полем
|
|
921
1049
|
сортировки не теряются. В `paths()` курсор даёт `cursorOf(путь, поле)`: поле и id — последнего узла пути, а в `path`
|
|
922
1050
|
— id всех узлов, поэтому не теряются пути к одному узлу. Курсор `cursorOf(путь.Узел, поле)` с одного узла пропустит
|
|
923
|
-
остальные пути к этому узлу. `
|
|
1051
|
+
остальные пути к этому узлу. `after()` перед `count()` и агрегатами — `invalid_query`: они считают по всем объектам
|
|
1052
|
+
шага, страницы у них нет.
|
|
924
1053
|
|
|
925
1054
|
Поле данных бывает `null` или отсутствует. Такие строки `sort` ставит, как `order by` в PostgreSQL: при
|
|
926
1055
|
возрастании — в конце, при убывании — в начале, а `after()` считает `null` значением после всех прочих (по
|
|
@@ -960,7 +1089,8 @@ const orderPaths = await db.Order(order).line().Product().paths() // [{ Order:
|
|
|
960
1089
|
```
|
|
961
1090
|
|
|
962
1091
|
`paths()` отдаёт узлы каждого варианта пути; ключ узла — имя шага или имя из `.alias(имя)`. Ключи идут в порядке
|
|
963
|
-
шагов; если цепочка кончается возвратом к прежнему узлу, этот узел — последний.
|
|
1092
|
+
шагов; если цепочка кончается возвратом к прежнему узлу, этот узел — последний. Совпавшие имена получают первый
|
|
1093
|
+
свободный ключ: `x`, `x_2`, `x_3` и так далее, ни один узел не теряется. Повтор связи в
|
|
964
1094
|
цепочке возвращает к её узлу: так из одной связи можно пройти в несколько сторон. `db.entity(строка | цепочка)`
|
|
965
1095
|
вставляет готовый узел в путь.
|
|
966
1096
|
|
|
@@ -971,22 +1101,35 @@ const orderPaths = await db.Order(order).line().Product().paths() // [{ Order:
|
|
|
971
1101
|
<!-- run: guide -->
|
|
972
1102
|
```ts
|
|
973
1103
|
const preview = await db.Order(order).delete().rows() // ничего не удаляет: заказ и его строки, $action каждой
|
|
974
|
-
await db.Order(order).delete({ confirm: true }).rows()
|
|
1104
|
+
const goneOrder = await db.Order(order).delete({ confirm: true, expect: preview }).rows() // надгробия, $deleted: true
|
|
975
1105
|
|
|
976
|
-
const
|
|
1106
|
+
const shopPreview = await db.Shop(shop).delete().rows()
|
|
1107
|
+
const blockers = shopPreview.filter((x) => x.$action === 'restrict')
|
|
977
1108
|
try {
|
|
978
|
-
await db.Shop(shop).delete({ confirm: true }).rows() // магазину мешают товары:
|
|
1109
|
+
await db.Shop(shop).delete({ confirm: true, expect: shopPreview }).rows() // магазину мешают товары: restrict
|
|
979
1110
|
} catch (e) {
|
|
980
1111
|
if (!(e instanceof LetopisError) || e.code !== 'delete_restricted') throw e
|
|
981
1112
|
}
|
|
982
1113
|
```
|
|
983
1114
|
|
|
984
|
-
`delete()`
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1115
|
+
Удаляют в два шага: `delete()` — превью, его показывают человеку; `delete({ confirm: true, expect: превью })` —
|
|
1116
|
+
удаление ровно показанного. `delete()` без `{ confirm: true }` ничего не удаляет и отдаёт всё замыкание каскада. В
|
|
1117
|
+
каждой строке превью `$action`: `delete`, `unset` или `restrict`, и `$depth` — уровень каскада (0 — сама цель; у
|
|
1118
|
+
строк `unset` и `restrict` его нет). С подтверждением удаляются все цели шага одним оператором, а каскад применяет
|
|
1119
|
+
правила концов. Итог — надгробия по уровням каскада, внутри уровня по id (в таком порядке они и в журнале); цели
|
|
1120
|
+
классов без журнала (`history: false`) — в конце. Превью не учитывает права: строка с `$action: 'delete'` может не
|
|
1121
|
+
удалиться у пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить `acl.checkData(класс, 'DELETE')`.
|
|
1122
|
+
|
|
1123
|
+
- `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`) до записи, ничего не
|
|
1124
|
+
удалено: удаление не сверено с тем, что видел пользователь. Подсказка ошибки — та же форма в два шага.
|
|
1125
|
+
- Цели, как у `update`, сужают фильтр, id, `tags()`, `owner()` или `limit()` у шага глагола или шагов до него (или
|
|
1126
|
+
`create()` и `upsert()` раньше в цепочке), иначе `invalid_query` (`write.no_filter`) до записи; превью — без условий.
|
|
1127
|
+
- Осознанно удалить текущий состав целей без сверки с превью — `delete({ confirm: true, all: true })`: с фильтром —
|
|
1128
|
+
текущие цели фильтра, без фильтра — все строки класса. `all` вместе с `expect` снимает только требование фильтра:
|
|
1129
|
+
превью сверяется. Без сверки — только для скриптов обслуживания, не в ответ на действие пользователя.
|
|
1130
|
+
- Чужой, невидимый или несуществующий id — превью пустое, и удаление с `expect: []` отвечает пустым ответом без
|
|
1131
|
+
ошибки, как `update`: проверяйте длину ответа.
|
|
1132
|
+
|
|
990
1133
|
Правила концов:
|
|
991
1134
|
|
|
992
1135
|
- `restrict` — удаление запрещено, пока на цель ссылаются (`delete_restricted` со списком видимых строк и числом
|
|
@@ -997,36 +1140,172 @@ try {
|
|
|
997
1140
|
Каскад требует права на удаление каждой удаляемой строки, снятие ссылки — права на запись. Сырой
|
|
998
1141
|
`DELETE … WHERE id IN (…)` любого набора связанных строк работает так же.
|
|
999
1142
|
|
|
1143
|
+
**Сверка с превью.** Между превью и подтверждением данные могут измениться: к заказу добавили строку, строку
|
|
1144
|
+
поправили. С `expect: превью` удаление в своей транзакции строит превью заново и сравнивает его с показанным — по `id`,
|
|
1145
|
+
`rev` и `$action` (строки без `$action` сравниваются по `id` и `rev`). Совпало — удаляет. Не совпало — `conflict` с
|
|
1146
|
+
ключом `write.preview_changed`, ничего не удалено, новое превью — в `err.detail.preview`: его показывают пользователю и
|
|
1147
|
+
удаляют уже с ним. `expect` без `confirm: true` — `invalid_query` (`write.expect_without_confirm`), `expect` не
|
|
1148
|
+
списком строк `{ id, rev }` — `invalid_query` (`write.bad_expect`).
|
|
1149
|
+
|
|
1150
|
+
`expect` — строки превью как есть (тип `Expected`: `{ id, rev, $action? }`), порядок не важен. `err.detail.preview` —
|
|
1151
|
+
`Row[]` той же формы, что превью: строки с `$action` и, у строк `delete`, `$depth`. Сверка атомарна и под гонкой: всё
|
|
1152
|
+
замыкание каскада (строки превью с `$action: 'delete'`) блокируется в порядке id, и превью строится ещё раз уже под
|
|
1153
|
+
блокировкой. Связь к удаляемой строке, добавленная до этого, попадает в новое превью (`preview_changed`); добавленная
|
|
1154
|
+
позже ждёт удаления и получает `target_not_found` (`sql-write.end_target_missing`) — непоказанное каскадом не уходит.
|
|
1155
|
+
Взаимную блокировку двух таких удалений операция из пула повторяет сама (`40P01`), в `db.begin()` — повторяет
|
|
1156
|
+
вызывающий. Пустые цели: `expect: []` и целей нет — пустой ответ; `expect` непуст, а целей уже нет (удалены, не
|
|
1157
|
+
видны) — `conflict` с пустым `err.detail.preview`.
|
|
1158
|
+
|
|
1159
|
+
```ts
|
|
1160
|
+
const shown = await db.Order(id).delete().rows() // превью — пользователю
|
|
1161
|
+
// … пользователь подтвердил
|
|
1162
|
+
try {
|
|
1163
|
+
const gone = await db.Order(id).delete({ confirm: true, expect: shown }).rows() // удалено ровно показанное
|
|
1164
|
+
// gone — надгробия: сохраните, по ним возвращают ровно ушедшее
|
|
1165
|
+
} catch (e) {
|
|
1166
|
+
if (!(e instanceof LetopisError) || e.code !== 'conflict') throw e
|
|
1167
|
+
const fresh = (e.detail as { preview: Row[] }).preview // данные изменились: показать заново
|
|
1168
|
+
}
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1000
1171
|
### Восстановить: `restore`
|
|
1001
1172
|
|
|
1002
1173
|
<!-- run: guide -->
|
|
1003
1174
|
```ts
|
|
1004
|
-
await db.
|
|
1005
|
-
|
|
1175
|
+
const back = await db.begin({ reason: 'возврат A-1' }) // заказ и его строки — одной транзакцией
|
|
1176
|
+
try {
|
|
1177
|
+
await back.Order(order).restore({ expect: goneOrder }).rows() // заказ — тем снимком, что ушёл
|
|
1178
|
+
await back.Order(order).line().withDeleted().restore({ expect: goneOrder }).rows() // и строки этого каскада
|
|
1179
|
+
await back.commit()
|
|
1180
|
+
} catch (e) {
|
|
1181
|
+
await back.rollback() // ошибка второго шага не оставит заказ без строк
|
|
1182
|
+
throw e
|
|
1183
|
+
}
|
|
1006
1184
|
```
|
|
1007
1185
|
|
|
1008
|
-
`restore()` возвращает
|
|
1009
|
-
|
|
1186
|
+
`restore({ expect })` возвращает удалённый объект его последним снимком новой версией и перепроверяет его по текущему
|
|
1187
|
+
описанию класса; id прежний. `expect` — надгробия `{ id, rev }`: ответ `delete({ confirm: true, expect })` или строки
|
|
1188
|
+
`withDeleted()` с `$deleted: true` (`db.Order(id).withDeleted().rows()`). Восстанавливаются только цели шага, чьи id
|
|
1189
|
+
есть в `expect`, а не весь каскад. Живой объект — `not_deleted`.
|
|
1190
|
+
|
|
1191
|
+
- `restore()` без `expect` — `invalid_query` (`write.restore_without_expect`) до записи, и по явному id тоже: без
|
|
1192
|
+
`expect` поднялось бы **последнее** надгробие id, а id удалённого может достаться другому — `create()` по ключу
|
|
1193
|
+
удалённого занимает его id ([«Создать: `create`»](#создать-create)), а новый объект потом тоже удаляют.
|
|
1194
|
+
- Осознанно вернуть все удалённые цели шага без сверки версий — `restore({ all: true })`: с фильтром — удалённые цели
|
|
1195
|
+
фильтра, без фильтра — всё удалённое класса; `all` вместе с `expect` — сверка по `expect`.
|
|
1196
|
+
- Цели — удалённые объекты последнего шага: шаг глагола `restore()` сам читает с удалёнными, `withDeleted()` перед
|
|
1197
|
+
ним не обязателен. Чужой, невидимый или несуществующий id, id не из `expect` — пустой ответ без ошибки, как у
|
|
1198
|
+
`update`: проверяйте длину ответа.
|
|
1199
|
+
- Шаги до глагола идут по живым строкам, если у них нет своего `withDeleted()`. Поэтому каскад возвращают после цели —
|
|
1200
|
+
`db.Order(order).line().restore({ expect })` находит строки восстановленного заказа, — а строки ещё удалённого
|
|
1201
|
+
заказа находит `db.Order(order).withDeleted().line().restore({ expect })`.
|
|
1202
|
+
|
|
1203
|
+
**Сверка с надгробиями.** Восстанавливаются только цели, у которых последняя версия — это самое надгробие из
|
|
1204
|
+
`expect`. Иначе ничего не восстановлено, а отказ называет причину по записи журнала, которая идёт за надгробием цели:
|
|
1205
|
+
|
|
1206
|
+
| Что с id после удаления | `why` | Ключ | Код |
|
|
1207
|
+
|---|---|---|---|
|
|
1208
|
+
| объект уже восстановлен (другим запросом) | `restored` | `write.restore_already` | `not_deleted` |
|
|
1209
|
+
| id занял новый живой объект с тем же ключом (`create()` по ключу удалённого) | `reissued` | `write.restore_id_taken` | `not_deleted` |
|
|
1210
|
+
| прочее: id пересоздан и удалён снова, объект удалён снова с другим номером версии, гонка | `changed` | `write.restore_stale` | `conflict` |
|
|
1211
|
+
|
|
1212
|
+
У всех трёх `err.detail.ids` — id этого вида, а `err.detail.stale` — все цели, которые не восстановить:
|
|
1213
|
+
`[{ id, rev, expected, after, deleted, live, why }]`. `rev` — номер последней версии сейчас, `expected` — из
|
|
1214
|
+
`expect`, `after` — вид записи журнала сразу за `expected` (`create`, `update`, `delete`, `restore`; `null` — её нет),
|
|
1215
|
+
`deleted` — последняя версия — надгробие, `live` — объект живёт, `why` — из таблицы. Цели разного вида в одном вызове
|
|
1216
|
+
дают `restore_stale`, если среди них есть `changed`, иначе `restore_id_taken`, если есть `reissued`. Номер версии база
|
|
1217
|
+
сверяет ещё раз под блокировкой записи журнала и перечитывает последнюю запись после ожидания, поэтому гонку
|
|
1218
|
+
«прочитали надгробие — другой процесс пересоздал id — восстановили» ловит она, а проигравший гонку двух `restore()`
|
|
1219
|
+
получает `write.restore_already`. id из `expect`, которых нет среди целей шага, пропускаются: весь ответ `delete()` можно
|
|
1220
|
+
передавать каждому шагу. Поэтому шаг глагола задавайте id из `expect`: объект, который по пути уже не найти (связь
|
|
1221
|
+
пересоздана с другим концом), по пути молча пропустится, а по id даст `conflict`. Цель и её каскад возвращают одной
|
|
1222
|
+
`db.begin()`, цель конца — первой: отказ на любом шаге откатывает всё.
|
|
1010
1223
|
|
|
1011
|
-
|
|
1224
|
+
```ts
|
|
1225
|
+
const gone = await db.Order(order).delete({ confirm: true, expect: shown, reason: 'отмена A-1 #7f3a' }).rows()
|
|
1226
|
+
const ids = (cls: string) => gone.filter((x) => x.class === cls).map((x) => x.id)
|
|
1227
|
+
// … позже: вернуть ровно это — заказ и строки этого каскада, одной транзакцией
|
|
1228
|
+
const tr = await db.begin()
|
|
1229
|
+
try {
|
|
1230
|
+
await tr.Order(ids('Order')).restore({ expect: gone }).rows() // заказ — если это всё ещё его надгробие
|
|
1231
|
+
await tr.line(ids('line')).restore({ expect: gone }).rows() // строки этого каскада, удалённые раньше — нет
|
|
1232
|
+
await tr.commit()
|
|
1233
|
+
} catch (e) {
|
|
1234
|
+
await tr.rollback()
|
|
1235
|
+
if (!(e instanceof LetopisError) || (e.code !== 'conflict' && e.code !== 'not_deleted')) throw e
|
|
1236
|
+
// e.key — write.restore_stale, write.restore_id_taken или write.restore_already; причины по целям —
|
|
1237
|
+
// e.detail.stale[].why — пользователю: «id занят новой записью», «после удаления меняли», «уже вернули»
|
|
1238
|
+
}
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
**Вернуть, что можно: `restore({ expect, partial: true })`.** Без `partial` отказ одной цели отменяет всё. С
|
|
1242
|
+
`partial` восстанавливаются цели, у которых совпал номер версии, а остальные не роняют операцию и приходят в том же
|
|
1243
|
+
ответе строками-пропусками: последняя строка цели (живая или надгробие с `$deleted`) с `$action: 'skip'` и `$why` —
|
|
1244
|
+
`'restored'`, `'reissued'` или `'changed'`, как `why` в таблице выше. Ответ — в порядке целей (`sort()` до глагола,
|
|
1245
|
+
иначе по id). Каждая цель восстанавливается под своей точкой сохранения, поэтому проигранная гонка с другим
|
|
1246
|
+
`restore()` тоже становится пропуском. Ошибки другого рода — `target_not_found`, `invalid_data`, права — роняют
|
|
1247
|
+
операцию, как без `partial`. `partial` без `expect` — `invalid_query` (`write.partial_without_expect`), и с
|
|
1248
|
+
`all: true` тоже. Продолжение цепочки после такого `restore()` идёт только от восстановленных; `count()` и `ids()`
|
|
1249
|
+
сразу после него считают и пропуски — это строки ответа глагола.
|
|
1250
|
+
|
|
1251
|
+
```ts
|
|
1252
|
+
const tr = await db.begin()
|
|
1253
|
+
try {
|
|
1254
|
+
await tr.Order(ids('Order')).restore({ expect: gone }).rows() // заказ — целиком или отказ
|
|
1255
|
+
const lines = await tr.line(ids('line')).restore({ expect: gone, partial: true }).rows()
|
|
1256
|
+
await tr.commit()
|
|
1257
|
+
const skipped = lines.filter((x) => x.$action === 'skip').map((x) => ({ id: x.id, why: x.$why }))
|
|
1258
|
+
// skipped — пользователю: что не вернулось и почему
|
|
1259
|
+
} catch (e) {
|
|
1260
|
+
await tr.rollback()
|
|
1261
|
+
throw e
|
|
1262
|
+
}
|
|
1263
|
+
```
|
|
1264
|
+
|
|
1265
|
+
Ответ `delete()` не сохранили — надгробия по id: `db.line(id).withDeleted().rows()` (строки с `$deleted: true`) — и
|
|
1266
|
+
их в `expect`. Ни ответа, ни id — надгробия по пути и причине удаления: каскад получает `reason` глагола. Причину
|
|
1267
|
+
делайте **своей у каждого удаления** (с номером операции, как `#7f3a` выше): у пересозданного и снова удалённого
|
|
1268
|
+
объекта последнее надгробие несёт причину того, второго удаления, и под ваш отбор не попадёт. Путь теряет и другое:
|
|
1269
|
+
объект, чей id занял новый живой объект, по пути от прежнего конца уже не находится (последняя версия ведёт к другому),
|
|
1270
|
+
и пропуска `reissued` по нему не будет — человек не узнает, что он не вернулся. Срез `asOf()` на момент до удаления
|
|
1271
|
+
показывает, что было живо, но его строки — живые версии: их `rev` для `expect` не годится.
|
|
1012
1272
|
|
|
1013
|
-
<!-- run: guide -->
|
|
1014
1273
|
```ts
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
const before = await db.Customer(temp).purge().rows() // превью: $versions — сколько версий в журнале
|
|
1018
|
-
await db.Customer(temp).purge({ confirm: true }).rows() // история стёрта; в журнале — запись purge
|
|
1274
|
+
const ours = (await db.Order(order).withDeleted().line().withDeleted().rows())
|
|
1275
|
+
.filter((x) => x.$deleted && x.reason === 'отмена A-1 #7f3a') // строки удалённого заказа с причиной этого удаления
|
|
1019
1276
|
```
|
|
1020
1277
|
|
|
1021
|
-
|
|
1022
|
-
версии 1 остаётся запись `purge` с числом стёртых версий. История системных классов не стирается. Пока проектор,
|
|
1023
|
-
чьи источники `from` включают класс объекта ([«Проекторы»](#проекторы)), не прочёл удаление, `purge` отказывает
|
|
1024
|
-
(`projector_pending`): стёртое надгробие проектор не увидел бы, и вклад объекта остался бы в итогах. Удаление и
|
|
1025
|
-
`purge` одной транзакцией в таком классе поэтому не проходят. Так же `db.trimHistory` отказывает, пока проектор не
|
|
1026
|
-
прочёл первую оставшуюся версию: прошлую версию для `before` он бы уже не нашёл. Удалить аккаунт — `db.auth.purgeAccount`
|
|
1027
|
-
([раздел 10](#10-пользователи-вход-и-права)).
|
|
1278
|
+
Штатные ответы `restore()` — их показывают пользователю, а не превращают в 500:
|
|
1028
1279
|
|
|
1029
|
-
|
|
1280
|
+
| Ответ | Когда | Пользователю |
|
|
1281
|
+
|---|---|---|
|
|
1282
|
+
| пустой | id чужой, невидимый, не существует или не среди целей шага | «не найдено» |
|
|
1283
|
+
| `invalid_query`, ключ `write.restore_without_expect` | нет `expect` (и нет `all: true`) | — (ошибка в коде) |
|
|
1284
|
+
| `not_deleted`, ключ `write.not_deleted` | объект жив и не удалялся | «не удалён» |
|
|
1285
|
+
| `not_deleted`, ключ `write.restore_already` | объект уже восстановлен (в том числе проигранная гонка двух `restore()`); id — `err.detail.ids` | «уже восстановлено» |
|
|
1286
|
+
| `not_deleted`, ключ `write.restore_id_taken` | id занял новый живой объект с тем же ключом (`create()` по ключу удалённого); id — `err.detail.ids` | «место занято новой записью» |
|
|
1287
|
+
| `conflict`, ключ `write.restore_stale` | после этого удаления id меняли — пересоздан и удалён снова, удалён снова; причины — `err.detail.stale` | «после удаления запись меняли» |
|
|
1288
|
+
| строки `$action: 'skip'` | с `partial: true` вместо трёх отказов выше; причина — `$why` | что не вернулось и почему |
|
|
1289
|
+
| `target_not_found` | цель конца снимка удалена или не видна | сначала восстановить цель |
|
|
1290
|
+
| `invalid_data` | снимок не проходит нынешнее описание класса | — |
|
|
1291
|
+
|
|
1292
|
+
- `restore()` перепроверяет описание класса, концы (цель конца жива и видна, иначе `target_not_found`), ключ и права,
|
|
1293
|
+
но не бизнес-правила приложения: восстановленная связь вернёт занятость прежнему концу, даже если объект уже занят
|
|
1294
|
+
другим. Гарантию даёт только ключ связи: занятость держит ключ, а не поле и не проверка. Проверка в коде перед
|
|
1295
|
+
`restore()` держит правило, лишь если **все** пути создания и возврата таких связей берут в `db.begin()` одну и ту же
|
|
1296
|
+
блокировку (`forUpdate()` объекта или `tr.lock(…)`) и делают ту же проверку: один путь без неё — обычный `create()`
|
|
1297
|
+
связи — гарантию ломает, потому что `create()` и `restore()` берут на объекте совместимую блокировку и друг друга не
|
|
1298
|
+
ждут. Связь с ключом только из одного конца (`key: ['Obj']`) база второй для того же
|
|
1299
|
+
объекта не создаст (`exists`). Удалённую каскадом такую связь новая на тот же объект создаёт заново с тем же id
|
|
1300
|
+
(v5 от ключа), и восстановить прежнюю уже нельзя: пока новая связь жива, `restore({ expect })` —
|
|
1301
|
+
`not_deleted` (`write.restore_id_taken`); когда удалена и она — `conflict` (`write.restore_stale`); с
|
|
1302
|
+
`partial: true` вместо отказа — строка-пропуск. Общие правила — «одна открытая запись на объект» и возврат по ответу
|
|
1303
|
+
`delete()` — в [памятке](AGENT-CHEATSHEET.md), «Правила для любой задачи».
|
|
1304
|
+
|
|
1305
|
+
Историю удалённого объекта журнал хранит: стереть версии одного объекта нельзя. Удаление аккаунта
|
|
1306
|
+
(`db.auth.purgeAccount`, [раздел 10](#10-пользователи-вход-и-права)) стирает данные и журнал его арендатора.
|
|
1307
|
+
|
|
1308
|
+
## 8. Транзакции и батчи
|
|
1030
1309
|
|
|
1031
1310
|
### Каждая операция — транзакция
|
|
1032
1311
|
|
|
@@ -1057,6 +1336,9 @@ try {
|
|
|
1057
1336
|
библиотека о ней не знает, PostgreSQL молча отвечает `ROLLBACK` на `COMMIT`, а библиотека превращает это в ошибку
|
|
1058
1337
|
`tx_rolled_back`.
|
|
1059
1338
|
- Повторов внутри `db.begin()` нет: на `40P01` повторите весь блок.
|
|
1339
|
+
- `db.begin()` принимает только опции `{ reason }`. Функцию она не принимает и не вызывает: `db.begin(async (tr) => …)`,
|
|
1340
|
+
строка, число или массив — отклонённый промис `invalid_query` (`api.begin_arg`) с подсказкой — формой выше;
|
|
1341
|
+
неизвестная опция — `invalid_data` с похожими именами.
|
|
1060
1342
|
- `lock(...ключи)` — advisory-блокировка до конца транзакции. Она защищает, только если её берут все, кто правит
|
|
1061
1343
|
эти данные.
|
|
1062
1344
|
|
|
@@ -1077,74 +1359,23 @@ const results = await b.run() // Row[][] — по массиву стро
|
|
|
1077
1359
|
который поставил его в очередь (актор, арендатор, владелец, транзакция), и чужих планов `run()` не исполняет.
|
|
1078
1360
|
Копите планы через один и тот же объект фасада.
|
|
1079
1361
|
|
|
1080
|
-
###
|
|
1081
|
-
|
|
1082
|
-
Баланс — поле `balance` хаба `Wallet` (целые копейки, `min(0)`), операции — связь `Movement` с ключом
|
|
1083
|
-
`[Wallet, opId]` ([раздел 4](#4-модель-данных)). Операцию не правят и не удаляют, а отменяют встречной операцией с
|
|
1084
|
-
новым `opId`. Ключ делает операцию идемпотентной: повтор после потери ответа получает `exists`, и рецепт сверяет,
|
|
1085
|
-
тот ли это запрос.
|
|
1086
|
-
|
|
1087
|
-
<!-- run: guide -->
|
|
1088
|
-
```ts
|
|
1089
|
-
import { inc, type Db } from 'letopis'
|
|
1090
|
-
|
|
1091
|
-
type Kind = 'topup' | 'debit' | 'adjust'
|
|
1092
|
-
|
|
1093
|
-
/** Операция: запись Movement и приращение баланса — один план, одна транзакция. */
|
|
1094
|
-
async function operate(db: Db, wallet: string, opId: string, kind: 'topup' | 'debit', amount: number) {
|
|
1095
|
-
try {
|
|
1096
|
-
await db.Wallet(wallet).Movement().create({ opId, kind, amount })
|
|
1097
|
-
.Wallet().update({ balance: inc(kind === 'topup' ? amount : -amount) }).rows()
|
|
1098
|
-
return 'done'
|
|
1099
|
-
} catch (e) {
|
|
1100
|
-
if (e instanceof LetopisError && e.code === 'exists') return same(db, wallet, opId, kind, amount) // повтор
|
|
1101
|
-
throw e // invalid_data — не хватает средств (min(0))
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
/** Отпечаток повтора: тот ли запрос уже записан под этим opId. */
|
|
1106
|
-
async function same(db: Db, wallet: string, opId: string, kind: Kind, value: number) {
|
|
1107
|
-
const m = await db.Movement(db.idOf('Movement', { Wallet: wallet, opId })).first()
|
|
1108
|
-
if (!m || m.data.kind !== kind || (kind === 'adjust' ? m.data.target : m.data.amount) !== value)
|
|
1109
|
-
throw new Error(`ключ операции ${opId} уже использован другим запросом`)
|
|
1110
|
-
return 'repeat'
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
/** Корректировка «баланс = target»: операция adjust на разницу, итог — под { rev }. */
|
|
1114
|
-
async function adjust(db: Db, wallet: string, opId: string, target: number) {
|
|
1115
|
-
for (;;) {
|
|
1116
|
-
if (await db.Movement(db.idOf('Movement', { Wallet: wallet, opId })).first()) return same(db, wallet, opId, 'adjust', target)
|
|
1117
|
-
const cur = (await db.Wallet(wallet).first())!
|
|
1118
|
-
try {
|
|
1119
|
-
await db.Wallet(wallet).Movement().create({ opId, kind: 'adjust', amount: target - (cur.data.balance as number), target })
|
|
1120
|
-
.Wallet().update({ balance: target }, { rev: cur.rev }).rows()
|
|
1121
|
-
return 'done'
|
|
1122
|
-
} catch (e) {
|
|
1123
|
-
if (e instanceof LetopisError && e.code === 'conflict') continue // кошелёк изменился — перечитать
|
|
1124
|
-
if (e instanceof LetopisError && e.code === 'exists') return same(db, wallet, opId, 'adjust', target)
|
|
1125
|
-
throw e
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1362
|
+
### Значение из прочитанного и повтор операции
|
|
1129
1363
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
await operate(db, wallet.id, 'op-1', 'topup', 10_000) // 'repeat': повтор после потери ответа, баланс тот же
|
|
1133
|
-
await operate(db, wallet.id, 'op-2', 'debit', 3_000) // баланс 7 000
|
|
1134
|
-
await adjust(db, wallet.id, 'op-3', 5_000) // корректировка: adjust на −2 000, баланс 5 000
|
|
1135
|
-
```
|
|
1364
|
+
Новое значение, вычисленное из прочитанного, теряет параллельные правки, если его записать просто `update()`.
|
|
1365
|
+
Способов защиты три, и для одного объекта выбирают один:
|
|
1136
1366
|
|
|
1137
|
-
|
|
1367
|
+
- **Приращение** — `update({ поле: inc(n) })`: база прибавляет внутри оператора, чтение не нужно, а `min(0)` в модели
|
|
1368
|
+
отклоняет уход в минус (`invalid_data`, по `e.issues` — поле и правило `minimum`).
|
|
1369
|
+
- **Сверка версии** — `update(патч, { rev })`: строку успели изменить — `conflict`, перечитать и решить заново
|
|
1370
|
+
([«Защита от параллельной правки»](#защита-от-параллельной-правки--rev-)).
|
|
1371
|
+
- **Чтение с блокировкой** — `forUpdate()` внутри `db.begin()`: строка заблокирована до `commit()`, повторов нет.
|
|
1138
1372
|
|
|
1139
1373
|
<!-- run: guide -->
|
|
1140
1374
|
```ts
|
|
1141
|
-
const [wallet2] = await db.Customer(olga).Wallet().create({ number: 'W-2' }).rows()
|
|
1142
1375
|
const tx = await db.begin()
|
|
1143
1376
|
try {
|
|
1144
|
-
const
|
|
1145
|
-
await tx.
|
|
1146
|
-
.create({ opId: 'op-1', kind: 'adjust', amount: 6_000 - (w.data.balance as number), target: 6_000 })
|
|
1147
|
-
.Wallet().update({ balance: 6_000 }).rows()
|
|
1377
|
+
const locked = (await tx.Product(kettle).forUpdate().first())! // строка заблокирована до commit
|
|
1378
|
+
await tx.Product(kettle).update({ views: (locked.data.views as number) + 1 }).rows()
|
|
1148
1379
|
await tx.commit()
|
|
1149
1380
|
} catch (e) {
|
|
1150
1381
|
await tx.rollback()
|
|
@@ -1152,87 +1383,30 @@ try {
|
|
|
1152
1383
|
}
|
|
1153
1384
|
```
|
|
1154
1385
|
|
|
1155
|
-
- `inc` прибавляет внутри оператора над заблокированной строкой: параллельные списания не теряются, а `min(0)`
|
|
1156
|
-
отклоняет списание сверх баланса.
|
|
1157
|
-
- Обычный `update({ balance: 700 })` записывает значение, а не прибавляет. Итог, вычисленный из прочитанного,
|
|
1158
|
-
без `{ rev }` или `forUpdate()` теряет параллельные списания. Advisory `lock()` не спасает, если его берут не все
|
|
1159
|
-
писатели. `upsert` затирает приращения.
|
|
1160
1386
|
- `forUpdate()` — только внутри `db.begin()` (иначе `tx_required`) и только с `rows`, `first`, `ids` (иначе
|
|
1161
1387
|
`lock_unsupported`). Строки блокируются в порядке id; строка, которая перестала подходить под условия шага, в ответ
|
|
1162
1388
|
не попадает.
|
|
1163
|
-
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
-
|
|
1167
|
-
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
нельзя. Баланс остаётся верным, даже когда операции пишет сырой SQL или загрузчик, а удаляет каскад.
|
|
1174
|
-
|
|
1175
|
-
```ts
|
|
1176
|
-
const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0) }, {
|
|
1177
|
-
ends: { Customer }, key: ['number'],
|
|
1178
|
-
aggregate: { balance: { sum: 'amount', of: 'Movement', via: 'Wallet' } }, // баланс — сумма живых операций
|
|
1179
|
-
})
|
|
1180
|
-
const Movement = link('Movement', { opId: z.string(), amount: z.number().int() },
|
|
1181
|
-
{ ends: { Wallet }, key: ['Wallet', 'opId'] }) // ключ операции — идемпотентность
|
|
1182
|
-
|
|
1183
|
-
/** Операция: amount больше нуля — пополнение, меньше — списание. База пересчитает баланс в той же транзакции. */
|
|
1184
|
-
async function operate(db: Db, wallet: string, opId: string, amount: number) {
|
|
1185
|
-
try {
|
|
1186
|
-
await db.Wallet(wallet).Movement().create({ opId, amount }).rows()
|
|
1187
|
-
return 'done'
|
|
1188
|
-
} catch (e) {
|
|
1189
|
-
if (e instanceof LetopisError && e.code === 'exists') return 'repeat' // повтор после потери ответа
|
|
1190
|
-
throw e // invalid_data — баланс ушёл бы в минус (min(0))
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
/** Корректировка «баланс = target»: операция на разницу, кошелёк заблокирован до commit. */
|
|
1195
|
-
async function adjust(db: Db, wallet: string, opId: string, target: number) {
|
|
1196
|
-
const tx = await db.begin()
|
|
1197
|
-
try {
|
|
1198
|
-
const w = (await tx.Wallet(wallet).forUpdate().first())!
|
|
1199
|
-
await tx.Wallet(wallet).Movement().create({ opId, amount: target - (w.data.balance as number) }).rows()
|
|
1200
|
-
await tx.commit()
|
|
1201
|
-
return 'done'
|
|
1202
|
-
} catch (e) {
|
|
1203
|
-
await tx.rollback()
|
|
1204
|
-
if (e instanceof LetopisError && e.code === 'exists') return 'repeat' // корректировка с этим opId уже записана
|
|
1205
|
-
throw e
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
const [wallet] = await db.Customer(olga).Wallet().create({ number: 'W-1' }).rows() // balance — 0: его ставит база
|
|
1210
|
-
await operate(db, wallet.id, 'op-1', 10_000) // баланс 10 000
|
|
1211
|
-
await operate(db, wallet.id, 'op-2', -3_000) // баланс 7 000
|
|
1212
|
-
await adjust(db, wallet.id, 'op-3', 5_000) // операция на −2 000, баланс 5 000
|
|
1213
|
-
```
|
|
1214
|
-
|
|
1215
|
-
- Модель здесь своя, и блок в программу примера не входит: там баланс растёт через `inc`, а агрегатное поле так не
|
|
1216
|
-
меняется.
|
|
1217
|
-
- `balance` не передают ни в `create`, ни в `update` (`aggregate_field`), поэтому корректировка — тоже операция, а не
|
|
1218
|
-
`update` под `{ rev }`. Чтобы записать операцию, право на запись в кошелёк не нужно: хватает права на запись в
|
|
1219
|
-
`Movement`.
|
|
1220
|
-
- Нехватка средств — `invalid_data`, как и с `inc`: в `e.issues` поле `balance` и правило `minimum`. Повтор сверяйте,
|
|
1221
|
-
как `same()` в рецепте выше: `exists` говорит только о том, что операция с этим `opId` уже есть.
|
|
1222
|
-
- Правка и удаление операции тоже меняют баланс: база вычтет прежнее значение. Отменять операцию всё равно лучше
|
|
1223
|
-
встречной операцией, тогда история кошелька остаётся полной.
|
|
1224
|
-
- Каждая операция блокирует строку кошелька и пишет его версию, поэтому операции одного кошелька идут друг за другом,
|
|
1225
|
-
как и с `inc`: на машине замеров — около 150 в секунду ([раздел 17](#17-производительность)). Если один объект
|
|
1226
|
-
получает больше, итог лучше считать проектором ([«Рецепт: дневная выручка»](#рецепт-дневная-выручка)).
|
|
1389
|
+
- Под `{ rev }` и под `forUpdate()` объект и ключи блокируются в разном порядке: смешение двух путей в параллельных
|
|
1390
|
+
запросах одного объекта даёт `40P01`. Внутри блока с `forUpdate()` не ждите внешних вызовов: строку ждут все
|
|
1391
|
+
писатели (помогут `lock_timeout` и `idle_in_transaction_session_timeout`).
|
|
1392
|
+
- Несколько объектов в одной `db.begin()` правьте в порядке id: тогда взаимных блокировок нет.
|
|
1393
|
+
- Обычный `update({ поле: значение })` записывает значение, а не прибавляет; `upsert` затирает приращения; advisory
|
|
1394
|
+
`lock()` не спасает, если его берут не все писатели.
|
|
1395
|
+
- **Повтор операции.** Операцию, которую нельзя выполнить дважды, записывайте связью с ключом из объекта и номера
|
|
1396
|
+
операции (`key: ['Объект', 'opId']`) вместе с приращением одним планом: повтор после потери ответа (`commit_unknown`,
|
|
1397
|
+
обрыв связи) получает `exists` и ничего не меняет. Отменяют такую операцию встречной операцией с новым номером, а не
|
|
1398
|
+
правкой и не удалением.
|
|
1227
1399
|
|
|
1228
1400
|
## 9. Массовая загрузка
|
|
1229
1401
|
|
|
1230
1402
|
### Когда нужен загрузчик
|
|
1231
1403
|
|
|
1232
1404
|
Загрузчик — путь для импорта, засевки и синхронизации больших справочников. Он проверяет строки в коде библиотеки
|
|
1233
|
-
тем же валидатором, что и
|
|
1234
|
-
|
|
1235
|
-
|
|
1405
|
+
тем же валидатором, что и база (шаблоны `pattern` — запросом к базе на пачку), и пишет их командой `COPY` от роли
|
|
1406
|
+
владельца, без триггеров и RLS. На замере `COPY` без триггеров писал около 14 000 строк в секунду, а
|
|
1407
|
+
построчная вставка через триггеры — около 170 ([раздел 16](#16-производительность)); проверки строк в коде библиотеки
|
|
1408
|
+
к этому добавляются. Работает только на администраторском подключении (`allowBypassRls` и право стать владельцем),
|
|
1409
|
+
иначе — `admin_required`.
|
|
1236
1410
|
|
|
1237
1411
|
### `db.load()`
|
|
1238
1412
|
|
|
@@ -1269,15 +1443,14 @@ const changed = await adm.load({ Shop: [{ name: 'Василёк', city: 'Сам
|
|
|
1269
1443
|
| Опция | Значения |
|
|
1270
1444
|
|---|---|
|
|
1271
1445
|
| `duplicates` — повтор id внутри загрузки | `'error'` (по умолчанию) — отменить; `'first'` или `'last'` — оставить первую или последнюю строку |
|
|
1272
|
-
| `existing` — объект уже есть в базе | `'error'` (по умолчанию) — отменить; `'skip'` — пропустить, удалённые не воскрешать; `'update'` — новая версия, если содержимое отличается (неизменённые считаются в `skipped`), удалённые — создать заново; объект другого класса с тем же id
|
|
1446
|
+
| `existing` — объект уже есть в базе | `'error'` (по умолчанию) — отменить; `'skip'` — пропустить, удалённые не воскрешать; `'update'` — новая версия, если содержимое отличается (неизменённые считаются в `skipped`), удалённые — создать заново; объект другого класса с тем же id — `exists` |
|
|
1273
1447
|
|
|
1274
|
-
###
|
|
1448
|
+
### Порции и история
|
|
1275
1449
|
|
|
1276
1450
|
- `commitEvery: N` — фиксировать порциями примерно по N строк. Порции идут по классам в порядке зависимостей,
|
|
1277
1451
|
классы из одного цикла — в одной порции. Ошибка отменяет только текущую порцию, а повтор с `existing: 'skip'`
|
|
1278
1452
|
дописывает остальное.
|
|
1279
|
-
- `withHistory: true` — строки — это версии с `$rev`, `$at` и `$op`; так
|
|
1280
|
-
- `verify: true` — в конце перепроверить загруженные строки проверкой базы.
|
|
1453
|
+
- `withHistory: true` — строки — это версии с `$rev`, `$at` и `$op`; так переносят историю из другой системы.
|
|
1281
1454
|
- `batch` (строк в пачке `COPY`, 10 000), `class` (класс строк без `$class`), `tenant`, `agent` (агент версий).
|
|
1282
1455
|
|
|
1283
1456
|
### Загрузка от имени пользователя
|
|
@@ -1299,20 +1472,18 @@ const r = await adm.load(rows, { initial: true }) // пустая схема:
|
|
|
1299
1472
|
```
|
|
1300
1473
|
|
|
1301
1474
|
`db.load(rows, { initial: true })` (`letopis load --initial`) заливает данные в пустую схему быстрее обычного
|
|
1302
|
-
загрузчика — на миллионе объектов примерно в 2,2
|
|
1475
|
+
загрузчика — на миллионе объектов примерно в 2,2 раза (замер `lib/bench/initial-load.mjs`): всё идёт одной транзакцией, на время загрузки таблицы закрыты
|
|
1303
1476
|
для чтения и записи, индексы, ограничения и триггеры сняты и строятся заново в конце, строки пишутся `COPY … FREEZE`.
|
|
1304
|
-
Пустая — значит, кроме строк системных классов (описания классов, аккаунты, членства, ресурсы, правила)
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
`letopis import --initial` проводит так весь импорт: одной транзакцией, без `--update` и `--commit-every`. WAL пишется
|
|
1308
|
-
и здесь (без него — только при `wal_level = minimal`), но индексы строятся один раз, и его почти втрое меньше; после
|
|
1477
|
+
Пустая — значит, кроме строк системных классов (описания классов, аккаунты, членства, ресурсы, правила), в схеме
|
|
1478
|
+
ничего нет; иначе — `not_empty` до любых изменений. Режим несовместим с `commitEvery`, `existing` (кроме `'error'`) и
|
|
1479
|
+
`{ as }`; ошибка любой строки откатывает всё, вместе с индексами и триггерами. WAL пишется и здесь (без него — только при `wal_level = minimal`), но индексы строятся один раз, и его почти втрое меньше; после
|
|
1309
1480
|
`FREEZE` первое чтение и первая очистка таблицу не переписывают.
|
|
1310
1481
|
|
|
1311
1482
|
### Командная строка
|
|
1312
1483
|
|
|
1313
1484
|
```sh
|
|
1314
1485
|
npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --token "$TOKEN" \
|
|
1315
|
-
[--class Product] [--duplicates first] [--existing skip] [--commit-every 100000]
|
|
1486
|
+
[--class Product] [--duplicates first] [--existing skip] [--commit-every 100000]
|
|
1316
1487
|
# shops.ndjson: {"$class":"Shop","name":"Юг"}
|
|
1317
1488
|
# products.ndjson: {"$class":"Product","sku":"Y-1","title":"Ваза","price":800,"Shop":{"$key":["Юг"]}}
|
|
1318
1489
|
```
|
|
@@ -1327,8 +1498,7 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
|
|
|
1327
1498
|
`issues`), `total` — сколько нарушений найдено, `truncated` — список обрезан, `limit` — предел списка (100),
|
|
1328
1499
|
`complete` — проверки, нашедшие нарушения, прошли все строки. Коды строк: коды проверок базы, а также `duplicate`
|
|
1329
1500
|
(повтор id), `exists` (объект уже есть), `deleted` (объект с этим id жил раньше) и `class_not_loadable` (строки
|
|
1330
|
-
`Class` загрузчик не принимает: классы задаёт `syncModels`).
|
|
1331
|
-
отклоняет с кодом `derived_class`: такие строки пишет только проектор.
|
|
1501
|
+
`Class` загрузчик не принимает: классы задаёт `syncModels`).
|
|
1332
1502
|
|
|
1333
1503
|
Нарушения ищутся поэтапно: разбор строк (классы, id, повторы), права на классы (`load({ as })`), данные и концы
|
|
1334
1504
|
пачками, в конце порции — цели вне загрузки, владельцы и арендатор. Загрузка отменяется после первого этапа,
|
|
@@ -1336,7 +1506,7 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
|
|
|
1336
1506
|
первая, что на неё ссылается). «(и ещё N)» в тексте ошибки — точное число остальных нарушений. Если проверены не все
|
|
1337
1507
|
строки, `complete` — `false`, а текст говорит «(и ещё не меньше N: причины)» или, если найдено одно нарушение,
|
|
1338
1508
|
«(возможно, не единственная: причины)». Причины: следующие порции `commitEvery` не проверялись; условия ACL
|
|
1339
|
-
следующих пачек не проверялись (`load({ as })`); проверка базы (
|
|
1509
|
+
следующих пачек не проверялись (`load({ as })`); проверка базы (шаблоны `pattern`) называет не больше 100 строк.
|
|
1340
1510
|
|
|
1341
1511
|
## 10. Пользователи, вход и права
|
|
1342
1512
|
|
|
@@ -1359,6 +1529,15 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
|
|
|
1359
1529
|
ключи других сервисов, System и системных администраторов выпускает и отзывает только системный администратор;
|
|
1360
1530
|
новый ключ сервиса регистрирует и `up({ serviceKey })`.
|
|
1361
1531
|
|
|
1532
|
+
Ключ установки `up()` выпускает сам: `lts_` и 64 шестнадцатеричных знака в нижнем регистре. Отдаёт он его в
|
|
1533
|
+
`r.serviceKey` один раз — при первой установке или на накате, когда в схеме не осталось ни одного неотозванного ключа
|
|
1534
|
+
API; иначе `r.serviceKey` — `null`. База хранит только sha256 ключа, поэтому показать его снова нельзя: сохраните ключ
|
|
1535
|
+
в хранилище секретов. Чтобы ключ был известен заранее (например, из переменной окружения), передайте свой:
|
|
1536
|
+
`up({ serviceKey: process.env.LETOPIS_SERVICE_KEY })`. Формат — `lts_` и 32–128 шестнадцатеричных знаков в нижнем
|
|
1537
|
+
регистре, `/^lts_[0-9a-f]{32,128}$/`, иначе `invalid_data`; подходит `'lts_' + randomBytes(32).toString('hex')` из
|
|
1538
|
+
`node:crypto`. Если такого неотозванного ключа ещё нет, `up()` регистрирует его за сервисом установки и возвращает в
|
|
1539
|
+
`r.serviceKey`, иначе там `null`. Сервис входит по ключу: `connect({ dsn: APP, schema, apiKey })`.
|
|
1540
|
+
|
|
1362
1541
|
Ключей может быть несколько. Сессия, открытая ключом, помнит его, и отзыв ключа гасит только его сессии: подключения
|
|
1363
1542
|
по другим ключам работают дальше. Поэтому ротация идёт без простоя: сервис выпускает себе новый ключ
|
|
1364
1543
|
(`issueApiKey({ account: <id сервиса> })`), переводит процессы на него и отзывает старый (`revokeCredential(id)`).
|
|
@@ -1385,7 +1564,7 @@ const failed = await svc.auth.verifyPassword({ identifier: 'boris@lutik.ru', pas
|
|
|
1385
1564
|
|
|
1386
1565
|
- Пароль хранится хэшем scrypt; проверяет его сервис, время ответа для несуществующего логина выровнено.
|
|
1387
1566
|
- Идентификатор сравнивается без учёта регистра: `Anna@Shop.ru` и `anna@shop.ru` — один вход.
|
|
1388
|
-
- Смена пароля гасит остальные сессии
|
|
1567
|
+
- Смена пароля гасит остальные сессии аккаунта; текущая остаётся, `keepCurrent: false` гасит и её.
|
|
1389
1568
|
- По умолчанию вход — только по подтверждённому креду, иначе `null`; `requireConfirmed: false` пускает и по
|
|
1390
1569
|
неподтверждённому. Подтверждает кред сервис: `confirmed: true` в `setPassword` или `setFlags`.
|
|
1391
1570
|
- Второй фактор — опция `totp`: `verifyPassword({ identifier, password, totp })` после верного пароля проверяет код
|
|
@@ -1430,7 +1609,7 @@ const viaTelegram = await svc.auth.lookup({ kind: 'TELEGRAM', identifier: '10050
|
|
|
1430
1609
|
сессия от сервиса не в счёт) и не подтверждает их сам. При включённом TOTP база требует сессию с пройденным TOTP:
|
|
1431
1610
|
её выдают `verifyPassword` и `lookup` с опцией `totp`. Если идентификатор занят, привязка отвечает тем же
|
|
1432
1611
|
`acl_denied`, что и без недавнего входа. Но пользователь с недавним входом по такому отказу поймёт, что
|
|
1433
|
-
идентификатор занят ([раздел
|
|
1612
|
+
идентификатор занят ([раздел 17](#17-ограничения)).
|
|
1434
1613
|
|
|
1435
1614
|
### Сессии
|
|
1436
1615
|
|
|
@@ -1447,12 +1626,36 @@ await boris.close()
|
|
|
1447
1626
|
Токен — 256 случайных бит; база хранит только его sha256. Срок по умолчанию — 7 суток. `refresh()` продлевает
|
|
1448
1627
|
текущую сессию: новый срок отсчитывается от текущего момента, но не больше срока, выданного при входе; возвращает
|
|
1449
1628
|
он новое время истечения. `connect()` продлевает сессию сам раз в минуту работы (`refreshSession`), поэтому сессия,
|
|
1450
|
-
которой пользуются, не истекает: срок — это время простоя. `revoke()` —
|
|
1629
|
+
которой пользуются, не истекает: срок — это время простоя. `revoke()` — выход: гасит сессию этого подключения;
|
|
1630
|
+
`revoke(token)` — выход пользователя по токену его сессии (ниже); `revokeAll(account?)` — выход везде, то есть все
|
|
1631
|
+
сессии **одного** аккаунта. Общего вызова «погасить все сессии установки» нет: при утечке или после восстановления
|
|
1632
|
+
копии на чужом стенде — остановить процессы приложения и от суперпользователя или роли-владельца выполнить
|
|
1633
|
+
`update "v2.имя".secret set deleted_at = clock_timestamp() where kind = 'SESSION' and deleted_at is null` (ключи API —
|
|
1634
|
+
`kind in ('APIKEY', 'KEYSECRET')` или `revokeCredential(id)`); сигнала этот запрос не шлёт, поэтому процессы
|
|
1635
|
+
перезапускают.
|
|
1451
1636
|
Сессии гаснут при отключении аккаунта, смене пароля, удалении креда и удалении аккаунта. Удаление креда гасит
|
|
1452
1637
|
остальные сессии аккаунта, а отзыв ключа API или пары «ключ + секрет» — только сессии, открытые этим ключом (и
|
|
1453
1638
|
старые сессии того же вида входа без отметки ключа); текущая сессия остаётся, если не передать
|
|
1454
1639
|
`keepCurrent: false`.
|
|
1455
1640
|
|
|
1641
|
+
`apiKey`, `service` или `token`, переданные со значением `null`, пустой строкой или не строкой, `connect()` отклоняет
|
|
1642
|
+
сразу — `invalid_data` (`api.connect_empty_credential`); `undefined` значит «опции нет» — подключение без сессии, и
|
|
1643
|
+
отказ `no_session` придёт на первом вызове. Поэтому ключ из переменной окружения проверяйте до `connect()`.
|
|
1644
|
+
|
|
1645
|
+
`connect({ token })` с токеном, который уже не даёт сессии (погашен `revoke`, истёк, неверен), подключения не создаёт:
|
|
1646
|
+
его промис отклоняется с `no_session` (`api.token_not_active`, «нет действующей сессии: токен погашен, истёк или
|
|
1647
|
+
неверен»), пул закрывается. Пользователь входит заново.
|
|
1648
|
+
|
|
1649
|
+
Подключение с постоянным токеном (`connect({ token })`), которым не пользовались дольше срока сессии, остаётся без
|
|
1650
|
+
сессии: истёкшую сессию продление не возвращает, а новый токен подключение само не берёт. После простоя дольше минуты
|
|
1651
|
+
подключение до операции продлевает сессию и узнаёт, жива ли она: истёкшая — `no_session` на любую операцию, и
|
|
1652
|
+
чтение не идёт как анонимное с пустым ответом (с `refreshSession: false` проверки нет). Токен выдают заново —
|
|
1653
|
+
повтором `createTenant()` (для существующих арендатора и пользователя повтор только выдаёт новую сессию) или
|
|
1654
|
+
`svc.auth.sessionFor()` — и открывают с ним новое подключение. Подключение по ключу (`connect({ apiKey })`, ключ
|
|
1655
|
+
сервиса) так не застревает: истёкшую сессию оно заменяет само — обменивает ключ заново, с тем же рабочим арендатором,
|
|
1656
|
+
что выбрал `auth.switch()`, и повторяет операцию. Процессу, которому сессия пользователя нужна для редких операций,
|
|
1657
|
+
проще брать её на одну операцию: `sessionFor()`, подключение с её токеном, работа, `revoke()` и `close()`.
|
|
1658
|
+
|
|
1456
1659
|
Сервис, у которого есть токен пользователя (например, в cookie), узнаёт о сессии через `svc.auth.session(token)`:
|
|
1457
1660
|
подключение по этому токену не открывается, срок сессии не продлевается. Ответ — чья сессия (`account`), в каком
|
|
1458
1661
|
арендаторе она работает (`tenant`: выбранный `auth.switch()` или собственный арендатор аккаунта), цело ли там членство
|
|
@@ -1462,6 +1665,18 @@ await boris.close()
|
|
|
1462
1665
|
чтение по погашенной сессии ошибки не даёт, ответы просто пустые. Вызывает метод сервис или системный администратор
|
|
1463
1666
|
(право `auth.session`); пользователю отвечают `acl_denied`.
|
|
1464
1667
|
|
|
1668
|
+
Выход в веб-сервисе — `svc.auth.revoke(token)` с токеном из cookie: сервис гасит сессию пользователя, не подключаясь
|
|
1669
|
+
по ней. Ответ — `true`, если сессия погашена, и `false`, если токен неверный, сессия уже погашена или истекла (причина
|
|
1670
|
+
не различается). Право то же — `auth.session`, без него `acl_denied`. Погашенная сессия шлёт сигнал `session:…`, и
|
|
1671
|
+
процессы сбрасывают кэш её ответов. `svc.auth.revoke()` без аргумента погасил бы сессию самого сервиса:
|
|
1672
|
+
|
|
1673
|
+
```ts
|
|
1674
|
+
// POST /logout — токен пользователя в cookie
|
|
1675
|
+
const token = req.cookies.session
|
|
1676
|
+
if (token) await svc.auth.revoke(token) // true — вышел; false — сессии уже не было
|
|
1677
|
+
res.clearCookie('session')
|
|
1678
|
+
```
|
|
1679
|
+
|
|
1465
1680
|
### Работа в другом арендаторе
|
|
1466
1681
|
|
|
1467
1682
|
Сервис действует от имени пользователя через `svc.as(account, { tenant, owner })`. База проверяет право сервиса
|
|
@@ -1474,10 +1689,69 @@ await boris.close()
|
|
|
1474
1689
|
|
|
1475
1690
|
<!-- run: guide -->
|
|
1476
1691
|
```ts
|
|
1477
|
-
const mine = await anna.auth.tenants() // [{ tenant, roles }]: собственный арендатор и магазин
|
|
1692
|
+
const mine = await anna.auth.tenants() // [{ tenant, roles, name, personal }]: собственный арендатор и магазин
|
|
1478
1693
|
await anna.auth.switch(SHOP) // арендатор сессии — магазин
|
|
1479
1694
|
```
|
|
1480
1695
|
|
|
1696
|
+
`auth.tenants()` отдаёт арендаторов аккаунта сессии: `{ tenant, roles, name, personal }`. Первым идёт собственный
|
|
1697
|
+
арендатор пользователя — `tenant` равен id его аккаунта, `roles: ['owner']`, `personal: true`, `name` — имя
|
|
1698
|
+
пользователя. За ним — арендаторы по членству (`personal: false`) с ролями членства, по id. `name` — имя аккаунта
|
|
1699
|
+
арендатора: то, что передали в `createTenant({ name })` или `up({ tenant: { name } })`; у аккаунта без имени — `null`.
|
|
1700
|
+
Нужны только арендаторы по членству — `filter((t) => !t.personal)`.
|
|
1701
|
+
|
|
1702
|
+
Сервис узнаёт арендаторов пользователя, не открывая подключения по его токену: от имени пользователя без `tenant`
|
|
1703
|
+
(`svc.as(account)` работает в его личном арендаторе) — тот же `auth.tenants()`. Так после входа выбирают, куда
|
|
1704
|
+
пустить пользователя:
|
|
1705
|
+
|
|
1706
|
+
```ts
|
|
1707
|
+
const ok = await svc.auth.verifyPassword({ identifier, password })
|
|
1708
|
+
if (!ok) return 'неверный логин или пароль'
|
|
1709
|
+
const shops = (await (await svc.as(ok.account)).auth.tenants()).filter((t) => !t.personal)
|
|
1710
|
+
if (!shops.length) return 'вы не состоите ни в одном магазине'
|
|
1711
|
+
// несколько — список shops.map((t) => t.name) пользователю на выбор; выбранный — в его сессии:
|
|
1712
|
+
const work = await svc.as(ok.account, { tenant: shops[0].tenant })
|
|
1713
|
+
```
|
|
1714
|
+
|
|
1715
|
+
Сессия, открытая входом (`verifyPassword`, `sessionFor`), `svc.as(account)` без `tenant` и
|
|
1716
|
+
`connect({ token })` до `auth.switch()` работают в **собственном** арендаторе пользователя. Классы, которые
|
|
1717
|
+
`syncModels` записал в арендатор магазина, там не видны: шаг по такому классу — `invalid_class: класс или роль …
|
|
1718
|
+
не найдены`. Поэтому член арендатора работает так: сервис — `svc.as(login.account, { tenant: SHOP })`, своё
|
|
1719
|
+
подключение по токену — `auth.switch(SHOP)`; `db.tenant` показывает, где сессия. `auth.switch()` меняет арендатора
|
|
1720
|
+
самой сессии: токен и дальше работает в выбранном арендаторе. Нет членства — `tenant_denied`.
|
|
1721
|
+
|
|
1722
|
+
Название арендатора пользователь получает из `auth.tenants()` (`name`); саму строку `Account` арендатора он не
|
|
1723
|
+
видит — видна только своя (`db.Account(SHOP).first()` у члена магазина — `null`). Название, которое меняется в
|
|
1724
|
+
интерфейсе приложения, храните в данных арендатора — например, хабом `Shop` с полем `name`.
|
|
1725
|
+
|
|
1726
|
+
### Веб-сервис: одно подключение и безопасность
|
|
1727
|
+
|
|
1728
|
+
- **Одно подключение сервиса на процесс.** Сервис держит одно подключение по ключу (`connect({ apiKey })`) и на каждый
|
|
1729
|
+
запрос берёт фасад `svc.as(account, { tenant })` — тот же пул. `connect({ token })` на каждый вход открывает
|
|
1730
|
+
отдельный пул (`max`, по умолчанию 10 соединений) на пользователя: несколько десятков входов исчерпают соединения
|
|
1731
|
+
PostgreSQL. `close()` у фасада `as()` ничего не делает; закрывают `svc` при остановке процесса.
|
|
1732
|
+
- **Арендатор — только из серверной сессии.** В начале запроса — `svc.auth.session(token)` по токену из cookie
|
|
1733
|
+
(`null` — не вошёл: 401, никаких данных); арендатор — из конфигурации сервера или из выбора, сохранённого на сервере
|
|
1734
|
+
при входе, и никогда из параметра запроса (`?club=`, тело, заголовок). Нет членства — `svc.as()` отвечает
|
|
1735
|
+
`tenant_denied` (403).
|
|
1736
|
+
- **Экранирование.** Данные из базы, вставленные в HTML, экранируют все пять символов `& < > " '` (или пишут через
|
|
1737
|
+
`textContent`), а обработчики событий (`onclick="…"`) из данных не строят: id — в атрибуте `data-*`, обработчик —
|
|
1738
|
+
`addEventListener`.
|
|
1739
|
+
- **Ключ сервиса** выдаётся один раз ([«Сервис и ключи API»](#сервис-и-ключи-api)): храните его в файле с правами
|
|
1740
|
+
только владельца (`chmod 600`) или в хранилище секретов, файл — в `.gitignore`; в репозиторий и клиенту он не
|
|
1741
|
+
попадает. Свой ключ заранее — `up({ serviceKey })`.
|
|
1742
|
+
|
|
1743
|
+
```ts
|
|
1744
|
+
const svc = await connect({ dsn: APP, schema: 'v2.shop',
|
|
1745
|
+
apiKey: readFileSync('/etc/shop/service.key', 'utf8').trim() }) // один раз на процесс
|
|
1746
|
+
async function userDb(token: string | undefined) { // в начале каждого запроса
|
|
1747
|
+
const s = token ? await svc.auth.session(token) : null
|
|
1748
|
+
if (!s) return null // 401
|
|
1749
|
+
return svc.as(s.account, { tenant: SHOP }) // арендатор — с сервера; нет членства — tenant_denied
|
|
1750
|
+
}
|
|
1751
|
+
const esc = (v: string) => v.replace(/[&<>"']/g, (c) =>
|
|
1752
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
|
|
1753
|
+
```
|
|
1754
|
+
|
|
1481
1755
|
### Права
|
|
1482
1756
|
|
|
1483
1757
|
Права включены всегда и по умолчанию запрещают. Правило связывает группу с объектом:
|
|
@@ -1496,28 +1770,21 @@ await anna.auth.switch(SHOP) // арендатор сессии
|
|
|
1496
1770
|
видны, правка — пусто или `acl_denied`.
|
|
1497
1771
|
|
|
1498
1772
|
Маска класса в объекте `READ`, `WRITE` или `DELETE` совпадает и с потомками класса — по именам предков и их алиасам.
|
|
1499
|
-
Поэтому правка класса, которая меняет `parent
|
|
1773
|
+
Поэтому правка класса, которая меняет `parent` или `alias`, требует права `READ`, `WRITE` и `DELETE` без
|
|
1500
1774
|
условий на все строки класса и каждого его потомка, иначе `acl_denied`: одного права `WRITE` на `Class` мало, чтобы
|
|
1501
1775
|
подставить строки класса под чужие правила. Алиас не совпадает с именем другого класса (в том числе `Class` и
|
|
1502
1776
|
системных) и с чужим алиасом арендатора и System, а имя нового класса — с чужим алиасом: иначе правило, написанное
|
|
1503
1777
|
для одного класса, действовало бы и на строки другого. Нарушение — `invalid_class`.
|
|
1504
1778
|
|
|
1505
1779
|
По тем же именам правила находят и старые версии строк в журнале. Поэтому класс удаляется, только когда у него нет
|
|
1506
|
-
строк, потомков и истории
|
|
1507
|
-
строки, потомки и история арендатора, у которого есть свой класс с тем
|
|
1508
|
-
удалённый с историей раньше, создаётся заново только с прежними
|
|
1509
|
-
`invalid_class`: старые версии его строк попали бы под чужие
|
|
1510
|
-
версии и по алиасам предков. Поэтому, пока удалённые потомки класса хранят историю строк, класс не
|
|
1511
|
-
(`invalid_class`), а удалённым создаётся заново только с прежним описанием. Если журнал хранит строки
|
|
1512
|
-
именем, но не его надгробие (например,
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
Производный класс (`derivedBy`) заводит, меняет и удаляет только администратор: администраторское подключение или
|
|
1516
|
-
аккаунт с правом System `letopis.projector` (у системного администратора оно есть по маске `letopis.*`). Проектор
|
|
1517
|
-
пишет в такой класс итоги по всем строкам источников арендатора, без прав чтения, поэтому пользователь, заведя его
|
|
1518
|
-
сам, читал бы итоги по строкам, которые ему читать запрещено. Класс, у которого есть производные потомки, тоже
|
|
1519
|
-
правит только администратор: правка предка меняет описание потомка (поля, ключ, `history`). Пометку `aggregatedBy`
|
|
1520
|
-
(класс стал источником агрегата) база ставит и производному классу сама: полей и ключа она не меняет.
|
|
1780
|
+
строк, потомков и истории строк; историю строк не стереть, и класс, который придётся удалять, заводят с
|
|
1781
|
+
`history: false`. Для класса System не в счёт строки, потомки и история арендатора, у которого есть свой класс с тем
|
|
1782
|
+
же именем: они относятся к его классу. Класс, удалённый с историей раньше, создаётся заново только с прежними
|
|
1783
|
+
`parent`, `alias` и `history` — иначе `invalid_class`: старые версии его строк попали бы под чужие правила. Правила
|
|
1784
|
+
находят старые версии и по алиасам предков. Поэтому, пока удалённые потомки класса хранят историю строк, класс не
|
|
1785
|
+
меняет алиас (`invalid_class`), а удалённым создаётся заново только с прежним описанием. Если журнал хранит строки
|
|
1786
|
+
класса с этим именем, но не его надгробие (например, строку класса удалили мимо триггеров от роли владельца), класс с этим именем не
|
|
1787
|
+
создаётся.
|
|
1521
1788
|
|
|
1522
1789
|
### Выдать право
|
|
1523
1790
|
|
|
@@ -1525,17 +1792,14 @@ await anna.auth.switch(SHOP) // арендатор сессии
|
|
|
1525
1792
|
|
|
1526
1793
|
<!-- run: guide -->
|
|
1527
1794
|
```ts
|
|
1528
|
-
const
|
|
1529
|
-
const OLEG = (await createTenant(
|
|
1795
|
+
const admin2 = postgres(ADMIN)
|
|
1796
|
+
const OLEG = (await createTenant(admin2, r.schema, { // тот же магазин: создаётся только пользователь
|
|
1530
1797
|
name: 'Ромашка', user: { name: 'Олег', login: 'oleg@shop.ru', password: 'Олег-2026!' } })).user!
|
|
1531
|
-
await
|
|
1532
|
-
await db.
|
|
1798
|
+
await admin2.end()
|
|
1799
|
+
await db.acl.roles(OLEG, ['staff']) // роли Олега в текущем арендаторе (строка member)
|
|
1533
1800
|
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
await db.rule().create({ permission: 'deny', weight: 50 }).Group.set(staff).Object.set(delProduct).rows() // сильнее «можно всё» (10)
|
|
1537
|
-
const [reports] = await db.Resource().create({ alias: 'reports:API', category: 'API', pattern: { endpoint: 'shop.reports.*' } }).rows()
|
|
1538
|
-
await db.rule().create({ permission: 'allow', weight: 50 }).Group.set(staff).Object.set(reports).rows()
|
|
1801
|
+
await db.acl.deny({ role: 'staff', delete: 'Product' }) // вес 50 — сильнее «можно всё» (10)
|
|
1802
|
+
await db.acl.grant({ role: 'staff', api: 'shop.reports.*' })
|
|
1539
1803
|
|
|
1540
1804
|
const asOleg = await svc.as(OLEG, { tenant: SHOP })
|
|
1541
1805
|
const canDelete = await asOleg.acl.checkData('Product', 'DELETE') // { allow: false }
|
|
@@ -1543,10 +1807,28 @@ const canRead = await asOleg.acl.checkData('Product', 'READ') // { allow:
|
|
|
1543
1807
|
const canReport = await asOleg.acl.check('shop.reports.daily') // { allow: true, rule: { weight: 50, … } }
|
|
1544
1808
|
```
|
|
1545
1809
|
|
|
1810
|
+
- `db.acl.grant` и `db.acl.deny` принимают, кому правило, — роль (`role`) или все аккаунты арендатора, кроме анонимных
|
|
1811
|
+
и теневых (`everyone: true`; ровно одно из двух), — и цели: `read`, `write`, `delete` — класс (с потомками), `'*'` —
|
|
1812
|
+
все классы или класс с условием на строки (`{ class: 'Ticket', owner: '$actor' }`); `api` — маска эндпоинта;
|
|
1813
|
+
списком — несколько. На каждую операцию и цель — одно правило с весом `weight` (по умолчанию 50, от 1 до 999). Под
|
|
1814
|
+
капотом — те же строки `Resource` (`role:staff:ACCOUNT`, `everyone:ACCOUNT`, `class:Product:DELETE`,
|
|
1815
|
+
`api:shop.reports.*:API`) и `rule` текущего
|
|
1816
|
+
арендатора, ключ правила — пара группа и объект: повтор ничего не меняет, а `grant` после `deny` той же роли и цели
|
|
1817
|
+
заменяет запрет разрешением. Внутри `db.begin()` — в её транзакции. Писать правила может тот, у кого есть `WRITE` на
|
|
1818
|
+
`Resource` и `rule` (после `createTenant` — любой участник по правилу «можно всё»; закрыть — `deny` с
|
|
1819
|
+
`write: ['Resource', 'rule', 'member']`). Неверный вызов — `invalid_data` до записи.
|
|
1820
|
+
- `db.acl.revoke({ role, … })` и `db.acl.revoke({ everyone: true, … })` снимают правила роли или всех аккаунтов —
|
|
1821
|
+
разрешения и запреты — для этих целей и отвечают числом снятых; для всех аккаунтов — и «можно всё» от
|
|
1822
|
+
`createTenant` ([Права только по ролям](#права-только-по-ролям)).
|
|
1823
|
+
`db.acl.roles(аккаунт)` — роли в текущем арендаторе (`null` — не член), `db.acl.roles(аккаунт, ['staff'])` — заменить
|
|
1824
|
+
их (членство заводится), `db.acl.roles(аккаунт, null)` — снять членство. `db.acl.list()` — правила арендатора
|
|
1825
|
+
`{ id, role, everyone, group, op, target, condition, permission, weight, enabled }`, свои и «можно всё» от
|
|
1826
|
+
`createTenant` (`everyone: true`, `role: null`); `enabled: false` — правило выключено полем `enabled` строки `rule`,
|
|
1827
|
+
база его не применяет. На администраторском подключении — тоже только правила арендатора сессии.
|
|
1546
1828
|
- `acl.check(эндпоинт)` → `{ allow, rule, code, message }`: свой отказ эндпоинта задают поля `code` и `message`
|
|
1547
1829
|
запрещающего правила.
|
|
1548
1830
|
- `acl.checkData(класс, 'READ' | 'WRITE' | 'DELETE')` → `{ allow, filter }`: `filter` — условие на строки.
|
|
1549
|
-
- Права пересчитываются базой сами.
|
|
1831
|
+
- Права пересчитываются базой сами. Правила можно писать и цепочками `Resource` и `rule` напрямую — поля ниже.
|
|
1550
1832
|
- Библиотека один раз берёт у базы подписанный план прав актора — пропуск прав — и передаёт его в каждой
|
|
1551
1833
|
транзакции, поэтому проверка прав стоит десятки микросекунд. Сырой SQL без пропуска платит 0,3–1,3 мс на запрос.
|
|
1552
1834
|
- На вопрос «у кого был доступ на дату» отвечает срез `asOf` по классам `rule`, `member` и `Resource`.
|
|
@@ -1558,14 +1840,57 @@ const canReport = await asOleg.acl.check('shop.reports.daily') // { allow:
|
|
|
1558
1840
|
`READ`, `WRITE`, `DELETE`) и `pattern` — маска. У группы маска `categories` или `roles` — только строка: `'{staff}'`,
|
|
1559
1841
|
`'{a,b}'`, отрицание — `'!{a}'`. У объекта — `endpoint` или `class` (тоже только строки) и условия на колонки.
|
|
1560
1842
|
**Маску не строкой (например, массив `['staff']`, число или `null`) база отклоняет** с `invalid_data` — при записи
|
|
1561
|
-
библиотекой, сырым SQL и
|
|
1562
|
-
|
|
1563
|
-
классы (маска `endpoint` не
|
|
1564
|
-
описание класса `Resource`: оно приходит со свежей установкой и после `reset({ all })`; в существующей схеме
|
|
1565
|
-
исправленное сопоставление действует после повторного `up()`;
|
|
1843
|
+
библиотекой, сырым SQL и загрузчиком; проверку несёт описание класса `Resource`. Строка с такой маской, записанная
|
|
1844
|
+
в обход проверки (от роли владельца), разрешений не даёт, а запрет по ней действует на всех: по группе — на все
|
|
1845
|
+
аккаунты, по маске `class` — на все классы (маска `endpoint` не строкой не совпадает ни с одним эндпоинтом);
|
|
1566
1846
|
- `rule` — концы `Group` и `Object` (строки `Resource`), поля `permission` (`allow` или `deny`), `weight`, а для
|
|
1567
1847
|
своего отказа эндпоинта — `code` и `message`.
|
|
1568
1848
|
|
|
1849
|
+
### Права только по ролям
|
|
1850
|
+
|
|
1851
|
+
`createTenant` и `up({ tenant })` дают арендатору правило «аутентифицированным можно всё» (вес 10). Запреты ролей с
|
|
1852
|
+
весом больше сужают права, но всё, что запретами не закрыто, остаётся разрешено любому участнику. Чтобы права решали
|
|
1853
|
+
только роли, снимите это правило после настройки или не заводите его вовсе.
|
|
1854
|
+
|
|
1855
|
+
Снять после настройки. «Можно всё» — три правила `READ`, `WRITE` и `DELETE` для всех аккаунтов арендатора (группа
|
|
1856
|
+
System `authenticated:ACCOUNT`) на все классы; `db.acl.list()` показывает их с `everyone: true`, `role: null` и
|
|
1857
|
+
`target: '*'`, а снимает их `db.acl.revoke({ everyone: true, read: '*', write: '*', delete: '*' })`. Права ролям
|
|
1858
|
+
выдайте раньше, пока правило действует: после снятия у участника остаются только права его ролей. Если права и
|
|
1859
|
+
дальше правит приложение, дайте какой-то роли права и на строки прав (`Resource`, `rule`, `member`), иначе их запишет
|
|
1860
|
+
только администраторское подключение.
|
|
1861
|
+
|
|
1862
|
+
```ts
|
|
1863
|
+
await db.acl.grant({ role: 'manager', read: 'Product', write: 'Product', delete: 'Product' })
|
|
1864
|
+
await db.acl.grant({ role: 'staff', read: 'Product' })
|
|
1865
|
+
await db.acl.grant({ role: 'manager', read: ['Resource', 'rule', 'member'], // правит права и дальше
|
|
1866
|
+
write: ['Resource', 'rule', 'member'], delete: ['rule', 'member'] })
|
|
1867
|
+
await db.acl.revoke({ everyone: true, read: '*', write: '*', delete: '*' }) // «можно всё» снято: 3
|
|
1868
|
+
```
|
|
1869
|
+
|
|
1870
|
+
Вернуть его — `db.acl.grant({ everyone: true, read: '*', write: '*', delete: '*', weight: 10 })`: правила для всех
|
|
1871
|
+
аккаунтов обёртка пишет своей группой `everyone:ACCOUNT`. Так же — право всем аккаунтам на отдельный класс:
|
|
1872
|
+
`db.acl.grant({ everyone: true, read: 'Catalog' })`.
|
|
1873
|
+
|
|
1874
|
+
Не заводить. `createTenant(admin, schema, { name, grantAll: false, user })` и `up({ tenant: { name, grantAll: false, user
|
|
1875
|
+
} })` заводят арендатора без правил. У его участников, в том числе у первого пользователя, прав нет — ни на данные, ни
|
|
1876
|
+
на запись правил. Классы и правила такого арендатора пишет администраторское подключение с сессией его пользователя:
|
|
1877
|
+
RLS на нём не действует, а строки идут в арендатор сессии.
|
|
1878
|
+
|
|
1879
|
+
```ts
|
|
1880
|
+
const admin = postgres(ADMIN)
|
|
1881
|
+
const t = await createTenant(admin, r.schema, { name: 'Склад', grantAll: false,
|
|
1882
|
+
user: { name: 'Ирина', login: 'irina@stock.ru', password: 'Склад-2026!', roles: ['manager'] } })
|
|
1883
|
+
await admin.end()
|
|
1884
|
+
const adm = await connect({ dsn: ADMIN, schema: r.schema, token: t.token, allowBypassRls: true })
|
|
1885
|
+
await syncModels(adm, [Product], { apply: true })
|
|
1886
|
+
await adm.acl.grant({ role: 'manager', read: 'Product', write: 'Product', delete: 'Product' })
|
|
1887
|
+
await adm.close()
|
|
1888
|
+
```
|
|
1889
|
+
|
|
1890
|
+
Читать администраторское подключение может строки всех арендаторов, но `adm.acl.list()` и там показывает правила
|
|
1891
|
+
арендатора сессии. Видимость целей правила актору сессии база на таком подключении не проверяет (существование,
|
|
1892
|
+
арендатор и класс цели — проверяет), поэтому правило пишется и тогда, когда прав у актора ещё нет.
|
|
1893
|
+
|
|
1569
1894
|
### Удалить аккаунт
|
|
1570
1895
|
|
|
1571
1896
|
`db.auth.purgeAccount(аккаунт)` удаляет аккаунт и данные его арендатора. Вызывать может системный администратор или
|
|
@@ -1573,10 +1898,10 @@ const canReport = await asOleg.acl.check('shop.reports.daily') // { allow:
|
|
|
1573
1898
|
арендаторах переходит этим арендаторам, сессии гаснут, креды удаляются. Владелец может стереть и арендатора, в
|
|
1574
1899
|
котором сейчас работает: его сессия вернётся в личный арендатор.
|
|
1575
1900
|
|
|
1576
|
-
Системный администратор может удалить и саму строку аккаунта — `db.Account(id).delete({ confirm: true
|
|
1577
|
-
арендаторе System; данные арендатора аккаунта при этом остаются. Любой путь сразу стирает строки аккаунта в `secret`:
|
|
1578
|
-
сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (внешний ключ `secret.account` с
|
|
1579
|
-
|
|
1901
|
+
Системный администратор может удалить и саму строку аккаунта — по превью, `db.Account(id).delete({ confirm: true,
|
|
1902
|
+
expect: превью })`, в арендаторе System; данные арендатора аккаунта при этом остаются. Любой путь сразу стирает строки аккаунта в `secret`:
|
|
1903
|
+
сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (внешний ключ `secret.account` с каскадом).
|
|
1904
|
+
Сессии гаснут до этого с сигналом, и процессы сбрасывают кэш ответов этих сессий. Аккаунт System
|
|
1580
1905
|
не удаляется (`acl_denied`): ему принадлежат служебные строки установки в `secret`.
|
|
1581
1906
|
|
|
1582
1907
|
## 11. История и срезы на дату
|
|
@@ -1586,22 +1911,32 @@ const canReport = await asOleg.acl.check('shop.reports.daily') // { allow:
|
|
|
1586
1911
|
<!-- run: guide -->
|
|
1587
1912
|
```ts
|
|
1588
1913
|
const versions = await db.Product(kettle).versions() // все версии: op, rev, author, at, data
|
|
1589
|
-
const followed = await db.Product(mug2).versions({ follow: true }) // и через смену id (rekey, reclass)
|
|
1590
1914
|
```
|
|
1591
1915
|
|
|
1592
|
-
У каждой версии — автор, агент, время, номер, вид операции (`create`, `update`, `delete`, `
|
|
1593
|
-
|
|
1916
|
+
У каждой версии — автор, агент, время, номер, вид операции (`create`, `update`, `delete`, `restore`) и причина
|
|
1917
|
+
(`reason`). Причину задаёт опция `reason` глагола (`update(patch, { reason })`, `create(data, { reason })`,
|
|
1594
1918
|
`delete({ confirm, reason })` и других), для всех операций блока — `db.begin({ reason })` и
|
|
1595
1919
|
`db.batch(имя, { reason })`; сырой SQL — настройка `letopis.reason` транзакции. Версия удаления — с `$deleted`.
|
|
1596
1920
|
|
|
1597
|
-
|
|
1598
|
-
`
|
|
1921
|
+
Версия — строка той же формы, что строка ответа ([«Строка ответа»](#строка-ответа)), но со снимком на свою версию:
|
|
1922
|
+
`rev` — номер версии, `op` — вид операции, `links` и `data` — концы и поля после этой операции (у версии удаления —
|
|
1923
|
+
последний снимок объекта), `at`, `author`, а если есть — `agent` и `reason`; у версии удаления ещё `$deleted: true`.
|
|
1924
|
+
Без `sort()` версии идут по id и номеру версии (по возрастанию: первой — `create`). `sort()` задаёт порядок и версиям,
|
|
1925
|
+
а `limit()` ограничивает их в самом запросе: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` — пять
|
|
1599
1926
|
последних версий, новые первыми.
|
|
1600
1927
|
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1928
|
+
Шаг с явными id находит и удалённые объекты: `db.Product(id).versions()` отдаёт историю удалённого товара вместе с
|
|
1929
|
+
версией удаления, `withDeleted()` для этого не нужен. Шаг без id (`db.Product().versions()`, переход по пути) отдаёт
|
|
1930
|
+
версии **только живых** объектов, с удалёнными — `withDeleted()` этого шага. Так, строки удалённого заказа ушли
|
|
1931
|
+
каскадом, и `db.Product(p).line().versions()` их не покажет, а `db.Product(p).line().withDeleted().versions()` или
|
|
1932
|
+
`versions()` по id строки — покажут: концы — `links` версии, удаление — версия удаления, причина — `reason`.
|
|
1933
|
+
|
|
1934
|
+
`versions()` — терминал: отдаёт промис строк версий. Модификаторы ставят до него
|
|
1935
|
+
(`db.Product(kettle).sort('at', 'desc').limit(5).versions()`), а метод цепочки у его результата —
|
|
1936
|
+
`versions().rows()`, `.sort()`, `.limit()` — `invalid_query` (`api.versions_terminal`).
|
|
1937
|
+
|
|
1938
|
+
Журнал хранит каждую версию: прореживания и обрезки истории нет. Классу, чья история не нужна, — `history: false`
|
|
1939
|
+
([«Настройки класса»](#настройки-класса)).
|
|
1605
1940
|
|
|
1606
1941
|
### Срез на дату
|
|
1607
1942
|
|
|
@@ -1624,115 +1959,27 @@ const assortment = await db.Shop(shop).Product().asOf(createdAt).rows()
|
|
|
1624
1959
|
const withGone = await db.Customer().withDeleted().rows() // живые и удалённые; у удалённых $deleted: true
|
|
1625
1960
|
```
|
|
1626
1961
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
```ts
|
|
1632
|
-
history: { all: '1 day', daily: '1 week', weekly: '1 month', monthly: '1 year', yearly: 'forever', tz: 'Europe/Moscow' }
|
|
1633
|
-
```
|
|
1634
|
-
|
|
1635
|
-
Это значит: все версии за последние сутки; за неделю — по одной на день; за месяц — по одной на неделю; за год —
|
|
1636
|
-
по одной на месяц; старше — по одной на год, бессрочно. От периода остаётся его последняя версия. Неделя
|
|
1637
|
-
начинается с понедельника, границы периодов — в поясе `tz` (по умолчанию UTC).
|
|
1638
|
-
|
|
1639
|
-
- Политику задают у класса (потомки наследуют) или для всех классов без своей — `up({ history })`.
|
|
1640
|
-
- Если у последнего яруса задан срок, а не `'forever'`, история старше срока удаляется: `{ all: '30 days' }`.
|
|
1641
|
-
- Пока история объекта жива, не удаляются текущая версия, версия 1 и версии особых видов (`create`, `delete`,
|
|
1642
|
-
`reclass`, `rekey`, `restore`, `purge`, `reset`, `trim`). Если надгробие удалённого объекта старше последнего яруса
|
|
1643
|
-
со сроком, его история стирается целиком. История системных классов не прореживается.
|
|
1644
|
-
- Прореживает `maintain()` пачками по 10 000 версий ([раздел 13](#13-эксплуатация)).
|
|
1645
|
-
- В прореженной части срез показывает последнюю сохранённую версию до момента, то есть его точность равна
|
|
1646
|
-
периоду яруса. Подписчик, отставший дольше яруса `all`, получает `cursor_expired`.
|
|
1647
|
-
|
|
1648
|
-
### Что изменилось и кто: `diff` и `blame`
|
|
1649
|
-
|
|
1650
|
-
```ts
|
|
1651
|
-
const [d] = await db.Order(id).diff() // предыдущая версия → последняя
|
|
1652
|
-
await db.Order(id).diff({ from: 3, to: 7 }) // по номерам версий
|
|
1653
|
-
await db.Order(id).diff({ from: '2026-10-01' }) // состояние на момент → последнее
|
|
1654
|
-
await db.Order(id).diff({ follow: true }) // через смену id (reclass, rekey)
|
|
1655
|
-
const [b] = await db.Order(id).blame() // каждое поле последней версии
|
|
1656
|
-
await db.Order(id).blame({ at: '2026-10-01', limit: 200 }) // версия на момент; просмотреть 200 версий
|
|
1657
|
-
```
|
|
1658
|
-
|
|
1659
|
-
Оба терминала отдают массив — по элементу на объект шага. Так выглядят ответы для чайника, которому снизили цену с
|
|
1660
|
-
причиной:
|
|
1661
|
-
|
|
1662
|
-
```ts
|
|
1663
|
-
import type { BlameResult, DiffResult } from 'letopis'
|
|
1664
|
-
|
|
1665
|
-
// db.Product(kettle).blame(): цену изменила версия 2, остальные листья — с создания (версия 1)
|
|
1666
|
-
const blamed: BlameResult = {
|
|
1667
|
-
id: '6f1c…', class: 'Product', rev: 2, complete: true,
|
|
1668
|
-
fields: [ // по порядку пути
|
|
1669
|
-
{ path: ['class'], rev: 1, at: '2026-10-27T09:15:02.118204+00:00', author: '<id Анны>', agent: '<id сервиса>',
|
|
1670
|
-
op: 'create', exact: true },
|
|
1671
|
-
{ path: ['data', 'price'], rev: 2, at: '2026-10-27T09:20:41.502617+00:00', author: '<id Анны>', agent: '<id сервиса>',
|
|
1672
|
-
op: 'update', reason: 'скидка к акции', exact: true },
|
|
1673
|
-
// ['data', 'sku'], ['data', 'title'], ['links', 'Shop'], ['owner'] — тоже версия 1, как ['class']
|
|
1674
|
-
],
|
|
1675
|
-
}
|
|
1676
|
-
// db.Product(kettle).diff(): предыдущая версия → последняя
|
|
1677
|
-
const diffed: DiffResult = {
|
|
1678
|
-
id: '6f1c…', class: 'Product',
|
|
1679
|
-
from: { rev: 1, at: '2026-10-27T09:15:02.118204+00:00' },
|
|
1680
|
-
to: { rev: 2, at: '2026-10-27T09:20:41.502617+00:00' },
|
|
1681
|
-
changes: [{ path: ['data', 'price'], kind: 'changed', before: 2500, after: 2000 }],
|
|
1682
|
-
}
|
|
1683
|
-
```
|
|
1684
|
-
|
|
1685
|
-
`agent` здесь — сервис, который пишет от имени Анны (`db = await svc.as(…)`); у записи без имперсонации его нет.
|
|
1686
|
-
|
|
1687
|
-
- `diff()` — массив `DiffResult`: `{ id, class, from, to, changes }`. `from` и `to` — сравниваемые версии
|
|
1688
|
-
`{ rev, at }`. В них есть `id`, если версия принадлежит другому id (`follow: true` через `reclass` или `rekey`), а у
|
|
1689
|
-
`to` — `deleted: true`, если на этот момент объект удалён; тогда `changes` пуст. `from` равен `null`, если объекта
|
|
1690
|
-
тогда ещё не было: все пути — `added`. Если версии нет вовсе, `class` и `to` тоже `null`.
|
|
1691
|
-
- Изменение `DiffChange` — `{ path, kind, before, after }`. `path` — массив сегментов, а не строка с точками; первый
|
|
1692
|
-
сегмент — раздел строки: `data`, `links`, `tags`, `owner`, `class`. `kind` — `added` (значения не было), `removed`
|
|
1693
|
-
(не стало) или `changed`. Пустое значение опускается: у `added` нет `before`, у `removed` — `after`. Объекты данных
|
|
1694
|
-
сравниваются по ключам, массивы — целиком. Множественный конец и теги — множества: по изменению на каждый id или
|
|
1695
|
-
тег, путь — `['links', роль]` или `['tags']`, а сам id или тег — в `before` или `after`.
|
|
1696
|
-
- `blame()` — массив `BlameResult`: `{ id, class, rev, complete, fields }` и `deleted: true`, если эта версия —
|
|
1697
|
-
надгробие. Объект без версий на этот момент элемента не даёт. `fields` — массив `BlameField`, по элементу на лист
|
|
1698
|
-
версии, упорядоченный по пути: `{ path, rev, at, author, op, exact }` и, если есть, `agent`, `reason`, `id`. Лист —
|
|
1699
|
-
скаляр или массив в данных, пустой объект, роль одиночного конца, каждый id множественного конца
|
|
1700
|
-
(`['links', роль, id]`), каждый тег (`['tags', тег]`), `owner` и `class`. `rev`, `at`, `author`, `agent`, `op` и
|
|
1701
|
-
`reason` — от версии, которая последней изменила лист. Пустые `agent` и `reason` опускаются; `id` есть, только если
|
|
1702
|
-
это версия прежнего id (история прошла через `reclass` или `rekey`). Версии другого объекта на том же id — того,
|
|
1703
|
-
что жил там до переноса или заведён после, — `blame()` не смотрит.
|
|
1704
|
-
- `exact: false` — между текущей версией и найденной есть пропуски (история прорежена, обрезана или часть версий не
|
|
1705
|
-
видна): лист мог менять и выпавшая версия. `complete: true` — история просмотрена целиком и без пропусков; по
|
|
1706
|
-
умолчанию просматривается не больше 1000 последних версий.
|
|
1707
|
-
- Считает база с правами вызывающего: сырой SQL получает то же — `select "v2.shop".blame('<id>')`,
|
|
1708
|
-
`select "v2.shop".diff_versions('<id>')`.
|
|
1709
|
-
- Колонка журнала `changed` (пути изменения версии, по ним `blame` работает быстро) приложению не выдана: пути
|
|
1710
|
-
описывают разницу с предыдущей версией, а её читатель может не видеть. Поэтому `select * from log` от приложения
|
|
1711
|
-
не работает — колонки перечисляются явно; `blame` берёт пути функцией базы, только когда видны обе версии.
|
|
1712
|
-
|
|
1713
|
-
### Обрезка истории
|
|
1714
|
-
|
|
1715
|
-
<!-- run: guide -->
|
|
1716
|
-
```ts
|
|
1717
|
-
const trimmed = await db.trimHistory(kettle, new Date()) // стереть версии чайника до этого момента
|
|
1718
|
-
```
|
|
1719
|
-
|
|
1720
|
-
`db.trimHistory(цель, момент)` сразу стирает историю до момента: класса с потомками (по имени), id, списка id или
|
|
1721
|
-
строк. Это средний шаг «права на забвение»: `anonymize` → обрезка истории → удаление и `purge`. На месте версии 1
|
|
1722
|
-
остаётся запись `trim` с её временем, поэтому срез знает, когда объект появился.
|
|
1962
|
+
`withDeleted()` — модификатор своего шага, как `exact()`: удалённые находит шаг, после которого он стоит, а шаги без
|
|
1963
|
+
него идут по живым строкам. Удалённый объект приходит надгробием — своей последней версией, версией удаления, — с
|
|
1964
|
+
`$deleted: true`.
|
|
1723
1965
|
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1966
|
+
| Цепочка | Что находит |
|
|
1967
|
+
|---|---|
|
|
1968
|
+
| `db.Order(o).line().withDeleted()` | живые и удалённые строки живого заказа |
|
|
1969
|
+
| `db.Order(o).withDeleted().line().withDeleted()` | живые и удалённые строки заказа, даже если сам заказ удалён |
|
|
1970
|
+
| `db.Order(o).withDeleted().line()` | только **живые** строки (заказ — с удалёнными); выводит предупреждение `api.with_deleted_step` |
|
|
1971
|
+
| `db.Order(o).line().Product().withDeleted()` | товары, в том числе удалённые, живых строк живого заказа |
|
|
1972
|
+
|
|
1973
|
+
Путь через удалённый узел читают одной цепочкой — `withDeleted()` у каждого шага, который может быть удалён:
|
|
1974
|
+
`db.Customer(c).Order().withDeleted().line().withDeleted()` — строки всех заказов клиента, в том числе удалённых. В
|
|
1975
|
+
цепочке записи `withDeleted()` — только перед `restore()`: у других глаголов — `invalid_query`. В инструменте MCP
|
|
1976
|
+
`letopis_query` опция `withDeleted` относится к последнему шагу.
|
|
1977
|
+
|
|
1978
|
+
`withDeleted()` отдаёт по каждому id его последнюю версию. Если id надгробия занял новый объект (`create()` по ключу
|
|
1979
|
+
удалённого), шаг находит живой новый объект без `$deleted`, а прежнего надгробия среди строк нет; путь от прежнего
|
|
1980
|
+
владельца (`db.Customer(c).withDeleted().Order().withDeleted()`) такой объект не находит, если его последняя версия
|
|
1981
|
+
ведёт к другому. Прежнее надгробие видно только в версиях — `db.Order(id).versions()`; вернуть его нельзя: пока
|
|
1982
|
+
новый объект жив, `restore({ expect })` — `not_deleted` (`write.restore_id_taken`) ([«Восстановить: `restore`»](#восстановить-restore)).
|
|
1736
1983
|
|
|
1737
1984
|
## 12. Подписка на изменения и кэш
|
|
1738
1985
|
|
|
@@ -1755,228 +2002,15 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
|
|
|
1755
2002
|
запись после него в поток придёт. Пример выше читает с `'start'`.
|
|
1756
2003
|
- Вызовы `sub.next()` идут по очереди: параллельные получают разные события. Вызов, брошенный по таймауту
|
|
1757
2004
|
(`Promise.race`), всё равно получит своё событие — держите его обещание и ждите его снова.
|
|
1758
|
-
- `classes` — только эти классы с потомками; `
|
|
1759
|
-
|
|
1760
|
-
`$deleted`), даже если новый класс подписчику не виден. Событие приходит, если прежнюю версию подписчик видел.
|
|
2005
|
+
- `classes` — только эти классы с потомками; `batch` — событий за один запрос к журналу (500); `pollMs` — запасной
|
|
2006
|
+
опрос (1000 мс).
|
|
1761
2007
|
- События идут без потерь и повторов, под правами читателя, в порядке номеров транзакций, а внутри транзакции — в
|
|
1762
2008
|
порядке записи. Это не порядок фиксации: версии одного объекта сверяйте по `rev`.
|
|
1763
2009
|
- Долгая пишущая транзакция в любой базе кластера задерживает поток: `await sub.lag()` показывает отставание в мс.
|
|
1764
|
-
- Курсор старше яруса `all` политики хранения — `cursor_expired`: перечитайте текущее состояние и подпишитесь
|
|
1765
|
-
с `'now'`. Время в курсоре подписки `sub.cursor` — время последнего события, а когда подписка догнала журнал, —
|
|
1766
|
-
время опроса, поэтому подписка, к которой сутки не было событий, не устаревает. Курсор события `ev.cursor` хранит
|
|
1767
|
-
время самого события.
|
|
1768
2010
|
- Без `LISTEN` (`connect({ listen: false })`) подписка опрашивает журнал.
|
|
1769
2011
|
- Подписка без потерь — это сохранённый курсор: обработав событие, храните `sub.cursor` (позиция после последнего
|
|
1770
|
-
выданного события) и продолжайте с него после перезапуска. С `ev.cursor` поток тоже
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
### Проекторы
|
|
1774
|
-
|
|
1775
|
-
Проектор строит строки производных классов из журнала: сводки, денормализованные карточки и всё, что можно считать
|
|
1776
|
-
с отставанием от записи. Классы-источники (`from`) пишет приложение, а строки классов `into` — только проектор.
|
|
1777
|
-
Каждый класс `into` помечен опцией `derivedBy` с именем проектора ([«Настройки класса»](#настройки-класса)).
|
|
1778
|
-
Производные классы заводит только администратор: синхронизируйте их администраторским подключением с сессией
|
|
1779
|
-
арендатора — `adm` из раздела 3 (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или `as()` сервиса
|
|
1780
|
-
на таком подключении, в CLI — `letopis sync` с `--dsn` роли администратора. У сессии приложения это `acl_denied`. В примере ниже проектор
|
|
1781
|
-
считает продажи товаров по строкам заказов `line`.
|
|
1782
|
-
|
|
1783
|
-
```ts
|
|
1784
|
-
const ProductSales = hub('ProductSales', { qty: z.number().int(), revenue: z.number().int() }, {
|
|
1785
|
-
ends: { Product: one(Product).onDelete('cascade') }, key: ['Product'], derivedBy: 'product-sales', history: false,
|
|
1786
|
-
}) // производный класс — в модели; syncModels — администраторским подключением
|
|
1787
|
-
|
|
1788
|
-
const sales = adm.projector('product-sales', { // adm — администраторское подключение
|
|
1789
|
-
from: ['line'], // классы-источники с потомками; у них должен быть журнал
|
|
1790
|
-
into: ['ProductSales'], // производные классы этого проектора
|
|
1791
|
-
version: 1, // логика handle изменилась — новая версия и перестройка
|
|
1792
|
-
withBefore: true, // c.before — прошлая версия объекта или null
|
|
1793
|
-
handle(changes, out) {
|
|
1794
|
-
for (const c of changes) {
|
|
1795
|
-
if (c.op === 'purge' || c.op === 'trim' || c.op === 'reset') continue // служебные изменения, не версии
|
|
1796
|
-
const was = c.before // прошлая версия строки заказа — вычесть
|
|
1797
|
-
const now = c.row.$deleted ? null : c.row // новая версия — прибавить; у удаления её нет
|
|
1798
|
-
if (was) out.inc('ProductSales', { Product: was.links.Product },
|
|
1799
|
-
{ qty: -Number(was.data.qty), revenue: -Number(was.data.qty) * Number(was.data.price) }, { tenant: c.tenant })
|
|
1800
|
-
if (now) out.inc('ProductSales', { Product: now.links.Product },
|
|
1801
|
-
{ qty: Number(now.data.qty), revenue: Number(now.data.qty) * Number(now.data.price) }, { tenant: c.tenant })
|
|
1802
|
-
}
|
|
1803
|
-
},
|
|
1804
|
-
})
|
|
1805
|
-
const report = await sales.run({ once: true }) // догнать журнал: { batches, changes, created, updated, … }
|
|
1806
|
-
const state = await sales.status() // { name, state, version, cursor, at, lag, failed, rebuildingTenants }
|
|
1807
|
-
await sales.rebuild() // перестроить с текущего состояния; { chunked: true } — пачками
|
|
1808
|
-
await sales.rebuild({ tenant: tenantId }) // перестроить одного арендатора — например, выбывшего (failed)
|
|
1809
|
-
await sales.rebuild({ tenant: tenantId, chunked: true }) // его же пачками: хвост журнала тем временем идёт для всех
|
|
1810
|
-
sales.run().catch((err) => console.error('проектор остановлен', err)) // следовать за журналом до stop()
|
|
1811
|
-
sales.close() // stop() после текущей пачки и отписка от сигналов
|
|
1812
|
-
```
|
|
1813
|
-
|
|
1814
|
-
- Проектору нужно администраторское подключение: `connect({ allowBypassRls: true })` роли, которая может стать
|
|
1815
|
-
владельцем схемы. Иначе — `admin_required`, внутри `db.begin()` — `invalid_query`. Проектор читает журнал всех
|
|
1816
|
-
арендаторов.
|
|
1817
|
-
- Первый запуск `run()` перестраивает проекцию с текущего состояния: журнал прорежен политикой хранения и обрезан
|
|
1818
|
-
`purge`, поэтому собрать проекцию с начала журнала нельзя.
|
|
1819
|
-
- Каждая пачка — одна транзакция: изменения журнала, `handle`, запись результатов и сдвиг курсора фиксируются
|
|
1820
|
-
вместе. Обрыв посреди пачки откатывает её целиком, а повтор начинается с того же курсора, поэтому каждое
|
|
1821
|
-
изменение применяется ровно один раз. Писатель у проектора один: второй процесс того же проектора пропускает пачку
|
|
1822
|
-
(счётчик `skipped` в отчёте), а `rebuild()`, начатая, пока другой процесс пишет пачку, — `projector_busy`.
|
|
1823
|
-
- Пачка делится по арендаторам: `handle` получает изменения одного арендатора, и запись его результатов идёт под
|
|
1824
|
-
своей точкой сохранения. Если база отклоняет запись результатов арендатора (данные не проходят описание его
|
|
1825
|
-
производного класса, цель конца не найдена и т. п.) или `out` отклоняет строку или приращение, построенные по его
|
|
1826
|
-
данным (приращение — не число), откатываются только его изменения: арендатор выбывает, а остальные записываются в
|
|
1827
|
-
той же пачке. Выбывший попадает в `status().failed` — `{ [id арендатора]: { cursor, code, message } }`, `message`
|
|
1828
|
-
называет арендатора и id строки, — и его изменения пропускаются, пока его не перестроят. Производные строки
|
|
1829
|
-
выбывшего больше не меняются: выбывший в `run()` сохраняет их такими, какими они были до пачки с ошибкой, а выбывший
|
|
1830
|
-
посреди перестройки остаётся без них — частично построенные строки удаляются. Исключение из `handle`, нарушение
|
|
1831
|
-
границ `out` и сбой окружения (взаимная блокировка, отмена запроса, обрыв связи) откатывают пачку целиком, а
|
|
1832
|
-
`run()` отклоняется. Поэтому `run()` без `once` ловите (`catch`): после ошибки он остановлен.
|
|
1833
|
-
- Изменение (`ProjectorChange`) — это `cursor`, `id`, `class`, `tenant`, `op`, `rev`, `at`, `author`, `agent`,
|
|
1834
|
-
`reason`, `moved` и `row` — версия из журнала (у удаления — надгробие с `$deleted`). При перестройке изменения —
|
|
1835
|
-
строки текущего состояния: `rebuild: true`, `op` — `create`, `cursor` пустой.
|
|
1836
|
-
- Смена класса без смены id (у классов без ключа: сырой `UPDATE … SET class` и `reclass()`) журнал пишет версией уже
|
|
1837
|
-
нового класса. Если она уводит строку из семейства `from`, проектор получает удаление: `op` — `delete`, `row` —
|
|
1838
|
-
последняя версия строки в семействе с `$deleted`, `rev`, `at` и автор — смены класса. Строка, вошедшая так в
|
|
1839
|
-
семейство, приходит изменением `reclass` с `before` = `null`.
|
|
1840
|
-
- С `withBefore` у изменения есть `before` — прошлая версия того же id. `before` равен `null`, если прошлой версии нет
|
|
1841
|
-
(создание и первая версия нового id после `reclass` и `rekey`), если прошлая версия — удаление (так бывает после
|
|
1842
|
-
`restore`) или вне семейства `from`, и у всех изменений перестройки. При `reclass` и `rekey` прежние данные
|
|
1843
|
-
вычитает надгробие прежнего id: оно приходит изменением с `$deleted`, его `before` — последняя версия прежнего id,
|
|
1844
|
-
а `moved` обоих изменений указывает на другой id. Поэтому свёртка из примера после `rekey` даёт ту же сумму. У
|
|
1845
|
-
служебных изменений `purge`, `trim` и `reset` поля `before` нет совсем. Поэтому вычитайте `before`, только проверив
|
|
1846
|
-
его на `null`, как в примере выше.
|
|
1847
|
-
- Служебные записи журнала `purge`, `trim` и `reset` тоже приходят изменениями, и у них `rev` равен 1. Поэтому
|
|
1848
|
-
сначала разберите `op`, а уже потом сравнивайте `rev`.
|
|
1849
|
-
- `out.upsert(класс, строка, { tenant })` создаёт строку или заменяет её целиком по ключу класса.
|
|
1850
|
-
`out.inc(класс, строка, приращения, { tenant })` создаёт строку или дополняет её и прибавляет числа, не читая
|
|
1851
|
-
строку (поля, которых нет, считаются от 0). `out.delete(класс, ключ или id, { tenant })` удаляет строку, а если её
|
|
1852
|
-
нет, ничего не делает. `await out.get(класс, ключ или id, { tenant })` читает текущую строку вместе с записями
|
|
1853
|
-
пачки. Строку класса с ключом `delete` и `get` находят по полям ключа (`{ day: '2026-10-29' }`), а по id (строкой
|
|
1854
|
-
или `{ $id }`) — только строку класса без ключа; один id у класса с ключом — `invalid_data`, и пачка откатывается.
|
|
1855
|
-
`out` пишет только в классы `into` и только в арендатора изменений, переданных в `handle`; иначе —
|
|
1856
|
-
`derived_class` или `tenant_denied`, и пачка откатывается.
|
|
1857
|
-
- Приращения строки, заданной одними полями ключа (или одним `$id`), `out` складывает до отправки в базу, пока между
|
|
1858
|
-
ними нет `upsert`, `delete` или `get`: на строку — одна правка за пачку, а правило класса проверяется над итогом её
|
|
1859
|
-
приращений. Строку с другими полями (данными, `$tags`, `$owner`) `inc` не складывает: её поля правят строку, и
|
|
1860
|
-
порядок таких правок сохраняется.
|
|
1861
|
-
- Строка для `out` — в формате загрузчика: поля и роли концов на верхнем уровне, служебные `$tags`, `$owner` и
|
|
1862
|
-
`$id` (`$id` нужен только классу без ключа). Значение роли — id цели. Правка, которая ничего не меняет, версии не
|
|
1863
|
-
даёт. Версии производных строк пишутся от System с агентом `letopis projector <имя>`; журнал ведётся по
|
|
1864
|
-
`history` класса, а сигнал об изменении уходит всегда.
|
|
1865
|
-
- `rebuild()` перестраивает проекцию атомарно, одной транзакцией: до её фиксации читатели видят прежние строки, но
|
|
1866
|
-
горизонт подписок стоит. `rebuild({ chunked: true })` идёт пачками: производные строки удаляются сразу, и до
|
|
1867
|
-
конца перестройки данные неполные, а `status().state` — `'rebuilding'`. Прерванную перестройку пачками
|
|
1868
|
-
продолжает следующий запуск. Сам `run()` — первый запуск новой проекции и `onExpired: 'rebuild'` — перестраивает
|
|
1869
|
-
пачками: горизонт подписок кластера стоит не дольше пачки. Второй процесс того же проектора тем временем ждёт или
|
|
1870
|
-
продолжает ту же перестройку, а не начинает её заново. Перестройка удаляет строки только участников и только
|
|
1871
|
-
классов их семейств `into` и начинает всех участников заново: отметки выбывших снимаются, а арендатор, чьи данные
|
|
1872
|
-
снова не проходят, выбывает снова.
|
|
1873
|
-
- `rebuild({ tenant: id })` перестраивает одного арендатора одной транзакцией: удаляет его производные строки, строит
|
|
1874
|
-
их из его текущих строк источников и снимает отметку в `status().failed`; изменения, уже учтённые перестройкой,
|
|
1875
|
-
проектор потом не применяет второй раз. Исправьте данные или описание класса арендатора, затем перестройте его. Если
|
|
1876
|
-
база снова отклоняет запись, перестройка откатывается с этой ошибкой, и арендатор остаётся выбывшим. Проекции ещё
|
|
1877
|
-
нет или идёт перестройка пачками — `rebuild_required`; арендатор не участник — `tenant_denied`. С `chunked: true` —
|
|
1878
|
-
пачками, каждая своей транзакцией со своим снимком, пока хвост журнала идёт для всех: изменения арендатора выше
|
|
1879
|
-
последней пачки хвост пропускает (их прочтёт пачка), в пройденной — если транзакция видна в её снимке. Производные
|
|
1880
|
-
строки арендатора удаляются сразу и до конца перестройки неполны (`status().rebuildingTenants`); блокировку,
|
|
1881
|
-
занятую `run()` другого процесса, перестройка пережидает. Прерванную перестройку (процесс остановился) продолжает
|
|
1882
|
-
`run()` любого процесса — с `onExpired` и без него.
|
|
1883
|
-
- `status().state` описывает проекцию в базе, а не процесс: `'new'` — проекции ещё нет, её построит первый `run()`
|
|
1884
|
-
или `rebuild()`; `'running'` — проекция построена и ведётся по журналу (это не значит, что какой-то процесс сейчас
|
|
1885
|
-
выполняет `run()`); `'rebuilding'` — идёт или прервана перестройка пачками.
|
|
1886
|
-
- `version` — версия логики `handle`. Если проекция построена другой версией, `run()` бросает `rebuild_required`, и
|
|
1887
|
-
нужна перестройка `rebuild()`.
|
|
1888
|
-
- Если курсор старше яруса `all` политики хранения источников и после него есть изменения, часть изменений могла
|
|
1889
|
-
проредиться. Ярус у каждого арендатора свой — это описание его классов. Курсор старше яруса у всех участников —
|
|
1890
|
-
`run()` бросает `cursor_expired`, а с `onExpired: 'rebuild'` проектор перестраивается целиком, пачками. Старше
|
|
1891
|
-
яруса только у части — выбывают они (`status().failed`, код `cursor_expired`), остальные идут дальше, а с
|
|
1892
|
-
`onExpired: 'rebuild'` выбывшие перестраиваются сами, пачками: состояние перестройки лежит в чекпоинте, и её
|
|
1893
|
-
продолжает `run()` любого процесса; блокировка, занятая другим процессом, — только повтор на следующем проходе, а
|
|
1894
|
-
ошибка данных выводит арендатора с её кодом, без повторов. Ярус перестроенного арендатора отсчитывается от начала
|
|
1895
|
-
его перестройки, пока курсор её не догнал: отстающий проектор не выводит его снова. Проектор без новых изменений не
|
|
1896
|
-
истекает. `status().lag` — возраст самого старого необработанного изменения в мс.
|
|
1897
|
-
- Проектор работает с арендаторами, у которых каждый класс `into` помечен его `derivedBy`; изменения остальных
|
|
1898
|
-
арендаторов он пропускает, поэтому одноимённый класс без пометки у другого арендатора ему не мешает. Нет ни одного
|
|
1899
|
-
такого арендатора — `derived_class`. Пометить классы арендатора может только администратор, поэтому участников
|
|
1900
|
-
выбирает он: пользователь арендатора сам к проектору не подключится. Опция
|
|
1901
|
-
`tenants: [id, …]` — явный список участников: проектор работает только с этими арендаторами (их классы `into` тоже
|
|
1902
|
-
должны быть помечены), а пометку у остальных не учитывает. Источники — семейства `from` у этих арендаторов; класс
|
|
1903
|
-
из `into` источником не бывает, даже если попадает в `from`: свои результаты проектор не читает. Изменений класса
|
|
1904
|
-
с `history: false` проектор не видит: журнала у него нет. Поэтому арендатор, у которого такой класс среди
|
|
1905
|
-
источников, выбывает с кодом `invalid_class`, и его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
|
|
1906
|
-
- Арендатор, который стал участником после первой проекции (администратор пометил его классы позже), получает
|
|
1907
|
-
начальную перестройку пачками: в проекцию попадают и строки, заведённые до вступления. Состав участников проектор
|
|
1908
|
-
перечитывает по сигналу классов, а на подключении без LISTEN (`listen: false`) — в начале `run()` и не реже раза
|
|
1909
|
-
в 5 секунд.
|
|
1910
|
-
- `batch` — сколько изменений в пачке (500), `pollMs` — запасной опрос (1000 мс): сигналы источников будят
|
|
1911
|
-
проектор раньше.
|
|
1912
|
-
- `lockTimeout` — сколько транзакция проектора ждёт чужую блокировку строки (5000 мс; 0 — без предела). Транзакция
|
|
1913
|
-
любого арендатора, которая держит строку итога или цель конца, иначе держала бы пачку всех арендаторов. Дольше —
|
|
1914
|
-
пачка откатывается, курсор остаётся, `run()` не отклоняется, а пропускает проход (`skipped`) и повторяет его. Отчёт
|
|
1915
|
-
называет арендатора, на чьей строке встала пачка (`locked`). `rebuild({ tenant, chunked: true })` такую блокировку
|
|
1916
|
-
пережидает.
|
|
1917
|
-
- `lockRetries` — сколько проходов подряд запись одного арендатора может упираться в `lockTimeout`, прежде чем он
|
|
1918
|
-
выбудет (по умолчанию 0 — не выбывает). Выбывший получает код `55P03`, и пачка идёт для остальных; его изменения
|
|
1919
|
-
пропускаются до `rebuild({ tenant })`. Счёт ведёт процесс, удачная запись арендатора его сбрасывает. Учтите: долгая
|
|
1920
|
-
исключительная блокировка семейства класса System (ужесточение описания) упрётся в каждого арендатора по очереди.
|
|
1921
|
-
- Каждая правка, которую `out` отправляет в базу, — отдельная правка строки, и скорость проектора задаёт число
|
|
1922
|
-
правок, а не число изменений. Свёртка `inc` сводит правки строки к одной за пачку, поэтому handle из примера выше
|
|
1923
|
-
(`out.inc` на каждое изменение) на машине замеров догонял 6 тысяч изменений в секунду при `batch` 500 и 33 тысячи
|
|
1924
|
-
при `batch` 2000 ([раздел 17](#17-производительность)): чем больше пачка, тем больше приращений одной строки
|
|
1925
|
-
складывается в одну правку.
|
|
1926
|
-
|
|
1927
|
-
### Рецепт: дневная выручка
|
|
1928
|
-
|
|
1929
|
-
Агрегат в базе и проектор решают похожие задачи по-разному:
|
|
1930
|
-
|
|
1931
|
-
| | Агрегат в базе | Проектор |
|
|
1932
|
-
|---|---|---|
|
|
1933
|
-
| когда считается | в той же транзакции, что и запись источника | после фиксации, с отставанием (`status().lag`) |
|
|
1934
|
-
| что считает | сумму поля или число строк источника для одной строки-цели | что угодно: сводки по дням и группам, денормализованные карточки |
|
|
1935
|
-
| совпадает с источником | всегда, в том числе для сырого SQL | когда проектор догнал журнал |
|
|
1936
|
-
| цена записи | каждая операция блокирует строку цели и пишет её версию, поэтому писатели одной цели идут по очереди | писатели не ждут друг друга: проектор применяет изменения пачками после фиксации |
|
|
1937
|
-
| правило над итогом | `min(0)` отклоняет операцию, которая увела бы итог за правило | итог не мешает записи: ошибка данных выводит арендатора из проекции |
|
|
1938
|
-
| что запустить | ничего: поле держит база | процесс на администраторском подключении |
|
|
1939
|
-
|
|
1940
|
-
Баланс кошелька, который не должен уйти в минус, — агрегат ([«Рецепт: баланс агрегатом»](#рецепт-баланс-агрегатом)).
|
|
1941
|
-
Выручка магазина по дням — итог по многим продажам, которому можно немного отставать, — работа для проектора:
|
|
1942
|
-
|
|
1943
|
-
```ts
|
|
1944
|
-
const Sale = hub('Sale', { day: z.iso.date(), amount: z.number().int().min(0) }, { ends: { Shop } }) // продажа: день и сумма
|
|
1945
|
-
const DayRevenue = hub('DayRevenue', { day: z.iso.date(), revenue: z.number().int(), sales: z.number().int() }, {
|
|
1946
|
-
ends: { Shop: one(Shop).onDelete('cascade') }, key: ['Shop', 'day'], derivedBy: 'day-revenue', history: false,
|
|
1947
|
-
})
|
|
1948
|
-
await syncModels(db, [Sale], { apply: true })
|
|
1949
|
-
await syncModels(adm, [DayRevenue], { apply: true }) // производный класс заводит администратор: adm — с сессией арендатора
|
|
1950
|
-
|
|
1951
|
-
const revenue = adm.projector('day-revenue', {
|
|
1952
|
-
from: ['Sale'], into: ['DayRevenue'], version: 1, withBefore: true,
|
|
1953
|
-
handle(changes, out) {
|
|
1954
|
-
for (const c of changes) {
|
|
1955
|
-
if (c.op === 'purge' || c.op === 'trim' || c.op === 'reset') continue // служебные изменения
|
|
1956
|
-
const was = c.before // прошлая версия продажи — вычесть из её дня
|
|
1957
|
-
const now = c.row.$deleted ? null : c.row // новая — прибавить к её дню; у удаления её нет
|
|
1958
|
-
if (was) out.inc('DayRevenue', { Shop: was.links.Shop, day: was.data.day },
|
|
1959
|
-
{ revenue: -Number(was.data.amount), sales: -1 }, { tenant: c.tenant })
|
|
1960
|
-
if (now) out.inc('DayRevenue', { Shop: now.links.Shop, day: now.data.day },
|
|
1961
|
-
{ revenue: Number(now.data.amount), sales: 1 }, { tenant: c.tenant })
|
|
1962
|
-
}
|
|
1963
|
-
},
|
|
1964
|
-
})
|
|
1965
|
-
revenue.run().catch((err) => console.error('проектор выручки остановлен', err)) // следует за журналом до stop()
|
|
1966
|
-
const week = await db.Shop(shop).DayRevenue().sort('data.day', 'desc').limit(7).rows() // семь последних дней
|
|
1967
|
-
```
|
|
1968
|
-
|
|
1969
|
-
- День — поле продажи, а не время версии `at`: со временем версии правка, `restore` или `rekey` переносили бы сумму
|
|
1970
|
-
продажи в свой день.
|
|
1971
|
-
- `out.inc` прибавляет, не читая строку, поэтому итог не зависит от порядка изменений: журнал отдаёт транзакции в
|
|
1972
|
-
порядке первой записи, а не фиксации. Правка продажи вычитает прежнюю сумму из прежнего дня и прибавляет новую к
|
|
1973
|
-
новому, удаление только вычитает. День, в котором продаж не осталось, остаётся строкой с нулями. Приращения одного
|
|
1974
|
-
дня в пачке `out` складывает в одну правку.
|
|
1975
|
-
- У `DayRevenue` — `history: false`: производные строки не удваивают журнал, а перестройка соберёт их и без истории.
|
|
1976
|
-
У `Sale` журнал нужен: изменений класса с `history: false` проектор не видит.
|
|
1977
|
-
- Строки `DayRevenue` приложение читает, как любой класс, а пишет их только проектор: `create` или `update` такой
|
|
1978
|
-
строки — `derived_class`. Итог отстаёт от продаж до следующей пачки: проектор будят сигналы записи, а без них —
|
|
1979
|
-
опрос раз в `pollMs`.
|
|
2012
|
+
выданного события) и продолжайте с него после перезапуска. С `ev.cursor` поток тоже продолжится — после этого
|
|
2013
|
+
события.
|
|
1980
2014
|
|
|
1981
2015
|
### Кэш результатов
|
|
1982
2016
|
|
|
@@ -1998,10 +2032,10 @@ const stats = db.cache.stats() // { size, hits,
|
|
|
1998
2032
|
- срок по умолчанию — 60 секунд; внутри `db.begin()` кэш не работает;
|
|
1999
2033
|
- без `LISTEN` и в режиме `none` кэш выключен, а `connect({ cache: { ttlOnly: true } })` включает его: ответ живёт до
|
|
2000
2034
|
конца срока, если его не сбросит запись этого подключения;
|
|
2001
|
-
- `db.load()
|
|
2035
|
+
- `db.load()` сбрасывает кэш, даже этого подключения, только сигналом базы.
|
|
2002
2036
|
|
|
2003
2037
|
Реестр классов процесс перечитывает по сигналу об изменении классов сразу (одно перечитывание за раз), а реестр и
|
|
2004
|
-
ключ сигналов
|
|
2038
|
+
ключ сигналов после переподключения слушателя, по незнакомому токену и по нагрузке не своего режима (а без ключа в режиме `opaque` — по любому
|
|
2005
2039
|
сигналу) — не чаще раза в 5 секунд: поток сигналов, в том числе поддельных, не превращается в поток запросов. В режиме `none` реестр раз в 5 секунд сверяет отпечаток классов, а подписка
|
|
2006
2040
|
опрашивает журнал. Подписка просыпается только от своих классов и своего арендатора, а без ключа — от любого
|
|
2007
2041
|
сигнала; события она в любом случае читает из журнала, поэтому ничего не теряет.
|
|
@@ -2015,50 +2049,23 @@ const stats = db.cache.stats() // { size, hits,
|
|
|
2015
2049
|
|
|
2016
2050
|
<!-- run: guide -->
|
|
2017
2051
|
```ts
|
|
2018
|
-
const report = await adm.maintain() // { sessions, codes,
|
|
2052
|
+
const report = await adm.maintain() // { sessions, codes, queue, skipped }
|
|
2019
2053
|
```
|
|
2020
2054
|
|
|
2021
2055
|
`maintain()` удаляет из таблицы `secret` сессии и одноразовые коды всех видов (`OTP` и свои `OTP_<ИМЯ>` из
|
|
2022
2056
|
`issueOtp({ kind })`) — истёкшие, а также погашенные сессии и коды, которые уже использованы, заменены новым или
|
|
2023
2057
|
исчерпали попытки, — и строки-замки удалённых классов (по две на класс; строку, которую держит транзакция, — следующим
|
|
2024
|
-
вызовом),
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
процесс) и отчитывается о сделанном с `more: true`. Сеансовой блокировки нет, поэтому `maintain()` работает за любым
|
|
2036
|
-
пулом, в том числе за PgBouncer в режиме транзакций.
|
|
2037
|
-
|
|
2038
|
-
### Сброс: `reset()`
|
|
2039
|
-
|
|
2040
|
-
```ts
|
|
2041
|
-
await adm.reset({ level: 'tenant', confirm: 'v2.shop', tenant: SHOP }) // sessions | tenant | all
|
|
2042
|
-
```
|
|
2043
|
-
|
|
2044
|
-
Сброс нужен тестам и стендам. Он работает, только если установка поставлена с `up({ allowReset: true })` (иначе
|
|
2045
|
-
`reset_disabled` даже для System), у вызывающего есть право `letopis.reset`, а `confirm` совпадает с именем схемы
|
|
2046
|
-
(`confirm_mismatch`). Уровни: `sessions` — все сессии, кроме текущей; `tenant` — данные арендатора; `all` — всё, с заново
|
|
2047
|
-
положенным системным сидом и новым ключом сервиса. Первая запись журнала после сброса — `reset`.
|
|
2048
|
-
|
|
2049
|
-
### Описание модели для людей и ИИ
|
|
2050
|
-
|
|
2051
|
-
<!-- run: guide -->
|
|
2052
|
-
```ts
|
|
2053
|
-
import { describeText } from 'letopis'
|
|
2054
|
-
|
|
2055
|
-
const model = db.describe() // { schema, tenant, classes: [...], methods }
|
|
2056
|
-
const modelText = describeText(model) // то же текстом — для llms.txt приложения или подсказки ИИ-агенту
|
|
2057
|
-
```
|
|
2058
|
-
|
|
2059
|
-
`db.describe()` отдаёт описание классов, доступных сессии: поля, концы, ключи, политики и метаданные, а также
|
|
2060
|
-
условия по нескольким полям `anyOf` и `oneOf` с учётом наследования, признак производного класса `derivedBy` и
|
|
2061
|
-
агрегаты `aggregate`, если они заданы. Из командной строки — `letopis describe --format text --out llms.txt`.
|
|
2058
|
+
вызовом), а ещё проверяет очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Журнал
|
|
2059
|
+
`maintain()` не трогает. Строки удалённого аккаунта ждать не нужно: их стирает само удаление ([«Удалить
|
|
2060
|
+
аккаунт»](#удалить-аккаунт)). Вызывать может системный администратор, роль с правом `letopis.maintain` или
|
|
2061
|
+
администраторское подключение; остальным — `acl_denied` (`sql-time.maintain_denied`), в том числе сервису по ключу. Запускает его внешний планировщик (`letopis maintain`) или сама библиотека:
|
|
2062
|
+
`connect({ maintain: '1h' })`. Интервал — число миллисекунд или строка (`'500ms'`, `'30s'`, `'15m'`, `'1h'`, `'1d'`)
|
|
2063
|
+
от 1 мс до 2³¹−1 мс (около 24 суток): другое значение, в том числе ноль, отрицательное или `NaN`, `connect()`
|
|
2064
|
+
отклоняет с `invalid_data`, не открывая соединений. `close()` ждёт идущий по расписанию запуск. Из нескольких
|
|
2065
|
+
процессов работает один: обслуживание — одна транзакция, и первым делом она берёт блокировку обслуживания — строку
|
|
2066
|
+
таблицы `secret`, которую ни прикладная, ни посторонняя роль базы не займёт; блокировка снимается с фиксацией. Если
|
|
2067
|
+
блокировку держит другой процесс, вызов отвечает `skipped: true`. Сеансовой блокировки нет, поэтому `maintain()`
|
|
2068
|
+
работает за любым пулом, в том числе за PgBouncer в режиме транзакций.
|
|
2062
2069
|
|
|
2063
2070
|
### Агенты и MCP
|
|
2064
2071
|
|
|
@@ -2068,9 +2075,9 @@ const modelText = describeText(model) // то же текстом — для llm
|
|
|
2068
2075
|
|
|
2069
2076
|
| Инструмент | Что делает |
|
|
2070
2077
|
|---|---|
|
|
2071
|
-
| `letopis_model` | модель: классы, поля (JSON Schema), условия `anyOf` и `oneOf`, концы, ключи, алиасы,
|
|
2078
|
+
| `letopis_model` | модель: классы, поля (JSON Schema), условия `anyOf` и `oneOf`, концы, ключи, алиасы, метаданные и рабочие цепочки `examples`; с `class` — подробно один класс |
|
|
2072
2079
|
| `letopis_query` | чтение JSON-цепочкой: старт `class` и `filter`, `id` или `ids`; `steps` — переходы по ролям и классам; `asOf`, `withDeleted`, `sort`, `limit`, `cursor`; терминал `rows`, `first`, `ids`, `count`, `sum`, `avg`, `min`, `max`, `countBy` |
|
|
2073
|
-
| `letopis_history` |
|
|
2080
|
+
| `letopis_history` | версии одного объекта |
|
|
2074
2081
|
| `letopis_write` | `create`, `update` (только с `rev`), `upsert`, `delete` — только при `write: true` (`letopis mcp --write`) |
|
|
2075
2082
|
|
|
2076
2083
|
Запрос `letopis_query` — та же цепочка, что `db.Product({ price: lt(1000) }).Shop().sort('data.name').rows()`, только записанная JSON:
|
|
@@ -2079,9 +2086,9 @@ const modelText = describeText(model) // то же текстом — для llm
|
|
|
2079
2086
|
{ "class": "Product", "filter": { "price": { "$lt": 1000 } }, "steps": [{ "step": "Shop" }], "sort": { "field": "data.name" } }
|
|
2080
2087
|
```
|
|
2081
2088
|
|
|
2082
|
-
Значение фильтра — равенство, вложенный объект — поля вложенных объектов, `{ "Роль": id }` — конец.
|
|
2083
|
-
|
|
2084
|
-
|
|
2089
|
+
Значение фильтра — равенство, вложенный объект — поля вложенных объектов, `{ "Роль": id }` — конец. Операторы — ключи
|
|
2090
|
+
колонки MCP в таблице раздела [«Фильтры»](#фильтры): `{ "price": { "$gt": 5 } }`; ИЛИ — `{ "$or": [фильтр, …] }`
|
|
2091
|
+
вместо всего фильтра. Неизвестный класс, роль, поле, параметр или
|
|
2085
2092
|
оператор — ответ `isError` с подсказкой похожих имён. Шагов `steps` — не больше 16, условий в `$or` одного фильтра —
|
|
2086
2093
|
не больше 64; иначе `invalid_query`. Время запроса эти пределы не ограничивают: задайте `statement_timeout` роли базы,
|
|
2087
2094
|
под которой подключается сервер MCP (`alter role … set statement_timeout = '5s'`).
|
|
@@ -2108,15 +2115,25 @@ const modelText = describeText(model) // то же текстом — для llm
|
|
|
2108
2115
|
`'asc'` (по умолчанию) или `'desc'`; без `sort` строки идут по `id`. `sort`, `limit` и `cursor` — только у `rows`,
|
|
2109
2116
|
`first` и `ids`.
|
|
2110
2117
|
|
|
2111
|
-
`letopis_model` без `class` отвечает (в `data`) `{ schema, tenant, classes }` —
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
`
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2118
|
+
`letopis_model` без `class` отвечает (в `data`) `{ schema, tenant, classes }` — классы, доступные сессии: свои классы
|
|
2119
|
+
арендатора и общие классы System с полями, концами, ключами, настройками (`history`, `ownerDefault`, `cache`) и метаданными, а также условия по нескольким
|
|
2120
|
+
полям `anyOf` и `oneOf` с учётом наследования, если они заданы. У каждого класса есть `examples` — рабочие цепочки, собранные из реестра: `create` — создать, `find` — найти,
|
|
2121
|
+
`neighbors` — перейти к соседям. `create` передаёт поля ключа и обязательные поля без умолчания, а обязательные концы и
|
|
2122
|
+
концы ключа ставит слотами; у абстрактного и системного класса его нет. Образец значения — `examples`
|
|
2123
|
+
из JSON Schema поля (в Zod — `.meta({ examples: […] })`), иначе `const`, `default`, первое из `enum`, иначе по типу и
|
|
2124
|
+
формату. Поле с `pattern` без `examples` может получить образец, который проверку не пройдёт. `find` ищет по ключу
|
|
2125
|
+
(`first()`), а без ключа — по обязательному полю (`rows()`). `neighbors` идёт по каждому концу класса и к классам, чьи
|
|
2126
|
+
концы ведут на него. `'<id Org>'` — место для id объекта класса или его потомка; `'<id Service|Product>'` — любого из
|
|
2127
|
+
классов; у множественного конца с `min` — `'<id Класс #1>'`, `'<id Класс #2>'`. Если модель больше `maxBytes`, она
|
|
2128
|
+
приходит сначала без `examples`, затем краткой: `brief: true`, а у классов — только имена полей и концов; подробно
|
|
2129
|
+
каждый класс отдаёт `letopis_model` с `class`.
|
|
2130
|
+
|
|
2131
|
+
`letopis_history` принимает `class`, `id` и необязательный `kind` (только `versions`). Ответ — `{ versions, more }`:
|
|
2132
|
+
последние версии в пределах ответа, от старой к новой; `more: true` — есть и более ранние.
|
|
2133
|
+
|
|
2134
|
+
Отказ инструмента — `isError: true` и `structuredContent` вида `{ notice, error }`, где `error` — `{ code, message }`, а также `hint` (что
|
|
2135
|
+
делать), `similar` (похожие имена классов, ролей, полей, параметров или операторов), `detail` (подробности) и `sqlstate` (код
|
|
2136
|
+
PostgreSQL у ошибки не из letopis), если они есть. Параметр не по
|
|
2120
2137
|
входной схеме инструмента (не тот тип, значение не из перечня, нет обязательного) отклоняет SDK ещё до letopis: тогда
|
|
2121
2138
|
`isError: true` и только текст, без `structuredContent`.
|
|
2122
2139
|
|
|
@@ -2131,12 +2148,13 @@ const modelText = describeText(model) // то же текстом — для llm
|
|
|
2131
2148
|
`rev` прочитанной версии: чужая версия — `conflict`, потерянной правки нет. `delete` без `confirm` возвращает превью
|
|
2132
2149
|
каскада и код операции: `{ preview, operation }`. С `confirm: true` удаление подтверждает человек в клиенте через
|
|
2133
2150
|
elicitation (в ревизии протокола 2025 — запросом клиенту, в 2026-07-28 — ответом `input_required`), подтверждение
|
|
2134
|
-
привязано к хэшу операции, а ответ — `{ deleted, operation }`.
|
|
2151
|
+
привязано к хэшу операции, а ответ — `{ deleted, operation }`. Удаляет инструмент ровно показанное: превью, построенное
|
|
2152
|
+
до подтверждения, идёт в `expect`, и если данные изменились — `conflict` (`write.preview_changed`), ничего не удалено. В ревизии 2026-07-28 подпись `requestState`, с которой
|
|
2135
2153
|
клиент повторяет вызов, привязана ещё к пользователю сервера и действует 10 минут: подпись сервера другого
|
|
2136
2154
|
пользователя и устаревшая — отказ `confirmation_required`. Окно подтверждения показывает каноническое имя класса и
|
|
2137
2155
|
id целей (первые 10, дальше — «и ещё K»); алиас и `meta` класса пишет арендатор, поэтому в окно они не попадают. Код
|
|
2138
2156
|
операции, присланный агентом, подтверждением не считается; клиент без elicitation получает отказ
|
|
2139
|
-
`confirmation_required`. Причина каждой записи — `mcp: letopis_write`, её
|
|
2157
|
+
`confirmation_required`. Причина каждой записи — `mcp: letopis_write`, её показывают версии. Классы прав — `rule`,
|
|
2140
2158
|
`Resource`, `member`, `Account`, `Class` и их потомков (правила, членства, описания классов) — `letopis_write` по
|
|
2141
2159
|
умолчанию не пишет, не правит и не удаляет: отказ `acl_denied`. Агент под инъекцией в данных выдал бы постороннему
|
|
2142
2160
|
членство или правило без подтверждения человека. Разрешает это только опция `allowAclWrite: true`.
|
|
@@ -2169,7 +2187,9 @@ id целей (первые 10, дальше — «и ещё K»); алиас и
|
|
|
2169
2187
|
**HTTP.** Встроенного HTTP-сервера нет. `createMcpServer(db, { write, maxRows, maxBytes })` из `letopis/mcp` отдаёт
|
|
2170
2188
|
сервер MCP, а приложение подключает его к своему обработчику `createMcpHandler` из SDK со своей авторизацией
|
|
2171
2189
|
OAuth 2.1: токен запроса проверяет приложение и находит по нему пользователя letopis. Подключение — сессия
|
|
2172
|
-
пользователя (`connect({ token })`
|
|
2190
|
+
пользователя в арендаторе приложения (`connect({ token })` после `auth.switch(tenant)`) или фасад
|
|
2191
|
+
`svc.as(пользователь, { tenant })`: ключ сервиса остаётся в процессе приложения. Без `{ tenant }` фасад работает в
|
|
2192
|
+
личном арендаторе пользователя, и классов приложения агент не увидит.
|
|
2173
2193
|
Подключение в обход RLS получает отказ `bypass_rls`, корень сервиса без `as()` и подключение без сессии —
|
|
2174
2194
|
`user_session_required`, фасад явной транзакции `db.begin()` — `invalid_query`. Сессию в арендаторе System — так
|
|
2175
2195
|
работает системный администратор: его роль `admin` даёт права на аккаунты, членства и правила установки — сервер тоже
|
|
@@ -2214,39 +2234,157 @@ const [{ n }] = await db.sql`select count(*)::int as n from "v2.shop".entity whe
|
|
|
2214
2234
|
сериализации запрос и `fn` повторяются до трёх раз, поэтому `fn` должна выдерживать повтор. Писать сырым SQL тоже
|
|
2215
2235
|
можно — те же триггеры проверят данные, а функции базы `merge` и `inc` доступны приложению.
|
|
2216
2236
|
|
|
2237
|
+
Ошибки базы `db.sql`, `db.transaction(fn)` и `tr.sql` в `db.begin()` приходят так же, как у цепочек: с кодом letopis
|
|
2238
|
+
(`LT…` — по таблице [«Ошибки»](#15-ошибки)), а прочие — как ошибка postgres.js с SQLSTATE в `err.code`. Исключение —
|
|
2239
|
+
значение не того типа, SQLSTATE `22P02` (`'42'::uuid`, `'abc'::int`): оно приходит `LetopisError` с кодом
|
|
2240
|
+
`invalid_query` (ключ `api.db_bad_value`), текст называет значение — `letopis: invalid_query: значение не того типа:
|
|
2241
|
+
invalid input syntax for type uuid: "42"`, а исходная ошибка PostgreSQL — в `err.cause` (`err.cause.code === '22P02'`).
|
|
2242
|
+
Ловите её по `err.code === 'invalid_query'`, а не по SQLSTATE:
|
|
2243
|
+
|
|
2244
|
+
```ts
|
|
2245
|
+
try {
|
|
2246
|
+
await db.sql`select * from "v2.shop".entity where id = ${input}::uuid`
|
|
2247
|
+
} catch (e) {
|
|
2248
|
+
if (!(e instanceof LetopisError) || e.code !== 'invalid_query') throw e
|
|
2249
|
+
// e.key === 'api.db_bad_value'; (e.cause as { code: string }).code === '22P02'
|
|
2250
|
+
}
|
|
2251
|
+
```
|
|
2252
|
+
|
|
2217
2253
|
### Командная строка
|
|
2218
2254
|
|
|
2219
2255
|
```sh
|
|
2220
|
-
letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply]
|
|
2221
|
-
letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>]
|
|
2256
|
+
letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply | --check]
|
|
2257
|
+
letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>] [--system]
|
|
2222
2258
|
letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
|
|
2223
|
-
[--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--
|
|
2224
|
-
letopis
|
|
2225
|
-
letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--batches <N>]
|
|
2226
|
-
letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
|
|
2227
|
-
letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
|
|
2228
|
-
[--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
|
|
2259
|
+
[--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--initial]
|
|
2260
|
+
letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>]
|
|
2229
2261
|
letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
|
|
2230
2262
|
(или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
|
|
2263
|
+
# у любой команды: --log[=debug|info|warn|error] — события JSON-строками в stderr (без флага — LETOPIS_LOG)
|
|
2231
2264
|
```
|
|
2232
2265
|
|
|
2233
|
-
`letopis types`
|
|
2234
|
-
|
|
2266
|
+
`letopis types` строит результат по классам сессии, поэтому без действующей сессии (токен истёк, погашен, неверен или
|
|
2267
|
+
не задан, ключ API неверен) выходит с кодом 1 и ошибкой `no_session`, а файл `--out` не трогает.
|
|
2268
|
+
Классы арендатора System — системные и классы сидов пакета (например, `booking`) — `letopis types` выдаёт только с
|
|
2269
|
+
`--system`.
|
|
2270
|
+
|
|
2271
|
+
`letopis sync --check` — тот же сухой прогон, но с кодом 1, если модели расходятся с базой (есть что создать или
|
|
2272
|
+
изменить): шаг CI перед `--apply`. Вместе с `--apply` — справка и код 2.
|
|
2235
2273
|
|
|
2236
2274
|
### Наблюдение
|
|
2237
2275
|
|
|
2276
|
+
- Журнал событий — `connect({ log })` или `LETOPIS_LOG` ([«Логирование»](#логирование)).
|
|
2238
2277
|
- `connect({ onQuery, slowMs })`: хук получает на каждый запрос цепочки режим, классы, длительность, число строк и
|
|
2239
2278
|
признак медленного запроса; без хука медленный запрос печатает предупреждение.
|
|
2240
2279
|
- `sub.lag()` — отставание подписки; отчёт `maintain()` — заполнение очереди сигналов.
|
|
2241
2280
|
- Горячие цели ссылок (организация, на которую ссылается почти всё) копят блокировки `for key share` и MultiXact,
|
|
2242
2281
|
как при обычных внешних ключах. Это стоит мониторить.
|
|
2243
2282
|
|
|
2283
|
+
### Логирование
|
|
2284
|
+
|
|
2285
|
+
letopis пишет журнал событий приложения: что прочитано и записано, что удалено и восстановлено, какие были ошибки,
|
|
2286
|
+
кто вошёл. По нему видно, что удаление прошло по показанному превью, а восстановление вернуло то, что ушло. Журнал
|
|
2287
|
+
выключен, пока его не включили опцией или переменной окружения.
|
|
2288
|
+
|
|
2289
|
+
**Опции.** `connect()` и `up()` принимают одни и те же:
|
|
2290
|
+
|
|
2291
|
+
| Опция | Что делает |
|
|
2292
|
+
|---|---|
|
|
2293
|
+
| `log` | куда писать: функция `(event) => void` или объект с методами `debug`, `info`, `warn`, `error` (`console`, pino и подобные) — вызывается `logger[level](event, event.event)`; нет метода уровня — `logger.log`, нет и его — событие пропускается |
|
|
2294
|
+
| `logLevel` | порог: `'debug'`, `'info'` (по умолчанию), `'warn'`, `'error'` или `'off'`; событие ниже порога не собирается |
|
|
2295
|
+
| `redact` | ещё имена полей (`string[]`, без учёта регистра), значения которых заменяются на `'***'` в любом месте `detail` |
|
|
2296
|
+
|
|
2297
|
+
```ts
|
|
2298
|
+
import pino from 'pino'
|
|
2299
|
+
const svc = await connect({ dsn: APP, schema: 'v2.shop', apiKey: SERVICE_KEY,
|
|
2300
|
+
log: pino(), logLevel: 'info', redact: ['phone', 'email'] })
|
|
2301
|
+
const solo = await connect({ dsn: APP, schema: 'v2.shop', token, log: (e) => audit.push(e) }) // своя функция
|
|
2302
|
+
```
|
|
2303
|
+
|
|
2304
|
+
Подключения из `db.as()` и `svc.as()`, `db.begin()`, `db.transaction()`, батч и `auth.switch()` пишут тем же журналом
|
|
2305
|
+
с тем же порогом и `redact`; в их событиях свои `tenant`, `account` и `tx`. Исключение внутри логгера проглатывается:
|
|
2306
|
+
операция из-за журнала не ломается. Хук `onQuery` работает как прежде, а каждый запрос SQL — ещё и событие `sql`.
|
|
2307
|
+
|
|
2308
|
+
**Переменная окружения и командная строка.** `LETOPIS_LOG=<уровень>` (`debug`, `info`, `warn`, `error`, `off`;
|
|
2309
|
+
`1` и `true` — то же, что `info`): если `log` не передан, события пишутся в stderr JSON-строками, одна строка — одно
|
|
2310
|
+
событие. Если `log` передан без `logLevel`, порог берётся из `LETOPIS_LOG`, а без неё — `info`. Без `log` и без
|
|
2311
|
+
`LETOPIS_LOG` журнала нет. Команды CLI принимают `--log[=уровень]` (`--log` без значения — `info`), события — в stderr;
|
|
2312
|
+
без флага действует `LETOPIS_LOG`.
|
|
2313
|
+
|
|
2314
|
+
```sh
|
|
2315
|
+
LETOPIS_LOG=info node app.js 2>> letopis.log
|
|
2316
|
+
npx letopis maintain --dsn "$ADMIN" --schema v2.shop --api-key "$KEY" --log=debug
|
|
2317
|
+
```
|
|
2318
|
+
|
|
2319
|
+
**Событие** — объект:
|
|
2320
|
+
|
|
2321
|
+
| Поле | Что значит |
|
|
2322
|
+
|---|---|
|
|
2323
|
+
| `ts`, `level`, `event` | время ISO, уровень (`debug`, `info`, `warn`, `error`), имя события |
|
|
2324
|
+
| `op`, `ms` | операция (`rows`, `first`, `create`, `delete`, `login` …) и длительность в миллисекундах |
|
|
2325
|
+
| `tenant`, `account`, `tx` | арендатор и аккаунт сессии; метка транзакции, если операция в `db.begin()`, `transaction()` или батче |
|
|
2326
|
+
| `chain`, `rows` | цепочка в читаемом виде (`Shop({"name":"Ромашка"}).Product.rows()`) и число строк результата или затронутых |
|
|
2327
|
+
| `code`, `key`, `msg` | код и ключ каталога ошибки или предупреждения, текст |
|
|
2328
|
+
| `detail` | подробности события (секреты замаскированы) |
|
|
2329
|
+
| `cause` | у события `error` — исходная ошибка: `{ name, message, code }` |
|
|
2330
|
+
|
|
2331
|
+
**Маскировка.** Без настройки заменяются на `'***'` на любой глубине `detail`: значения полей с паролями, токенами,
|
|
2332
|
+
ключами, секретами и кредами — в том числе `password`, `pass`, `passwd`, `pwd`, `passphrase`, `secret`, `token`,
|
|
2333
|
+
`serviceKey`, `apiKey`, `api_key`, `credential`, `authorization`, `cookie`, `hash`, `material`, `totp`, `otp`;
|
|
2334
|
+
учётные данные в строке подключения (`postgres://***@host/db` — скрыт и пользователь); пароль в тексте SQL
|
|
2335
|
+
(`password '…'`). `redact` добавляет свои имена полей (персональные данные и т. п.).
|
|
2336
|
+
|
|
2337
|
+
Не пишутся никогда: значения параметров SQL (у событий `sql` и `raw` — только их число); текст, `DETAIL`, `WHERE`,
|
|
2338
|
+
`HINT` и внутренний запрос ошибки PostgreSQL — в событии только SQLSTATE и имена ограничения, таблицы, колонки, схемы,
|
|
2339
|
+
типа и функции, а вместо текста — фраза «ошибка PostgreSQL <SQLSTATE>»; поле `data` строк в `detail` ошибки (превью,
|
|
2340
|
+
снимки отказа `restore`) — `'***'`; значение аргумента в тексте ошибки letopis (`query.bad_uuid`, `api.db_bad_value`
|
|
2341
|
+
и подобные) — `'***'`.
|
|
2342
|
+
|
|
2343
|
+
Пишутся как есть — учитывайте это: значения фильтров в `chain` (`Shop({"name":"Ромашка"})`; у записи данные —
|
|
2344
|
+
только именами полей), поэтому фильтр по персональным полям — `redact: ['email', …]`; тексты исключений кода
|
|
2345
|
+
приложения (с маскировкой паролей). Объект ошибки, который получает приложение (`err.message`, `err.cause`), журнал
|
|
2346
|
+
не меняет: что из него логировать самому, решает приложение.
|
|
2347
|
+
|
|
2348
|
+
**Транзакции и фиксация.** Событие записи вне `db.begin()` пишется после фиксации своей транзакции и само доказывает
|
|
2349
|
+
её. Внутри `db.begin()` события несут `tx` и пишутся, когда оператор выполнен, до `COMMIT`: `begin → delete →
|
|
2350
|
+
rollback` оставит событие `delete`, а строки — на месте. Фиксацию такой записи доказывает только `tx.commit` с тем же
|
|
2351
|
+
`tx`; `tx.rollback` с ним — запись отменена; `commit_unknown` — исход неизвестен (повтор с тем же ключом).
|
|
2352
|
+
|
|
2353
|
+
**Основные события.** Полный каталог с уровнями и описаниями — `lib/src/messages/log.ts` (`LOG_EVENTS`).
|
|
2354
|
+
|
|
2355
|
+
| Событие | Уровень | Когда | Главное в событии |
|
|
2356
|
+
|---|---|---|---|
|
|
2357
|
+
| `connect`, `close` | info | подключение готово, закрыто | `ms`; `detail.schema` |
|
|
2358
|
+
| `up`, `migrate` | info | `up()` завершён; `up()` обновил ревизию схемы | `detail`: `schema`, `created`, `revision`; `from`, `to` |
|
|
2359
|
+
| `sync` | info | `syncModels()` | итог: созданные и изменённые классы |
|
|
2360
|
+
| `read` | debug | терминал чтения | `op`, `chain`, `rows`, `ms`, `tx` |
|
|
2361
|
+
| `cache.hit`, `cache.miss` | debug | ответ из кэша результатов; промах | `chain`, `rows` |
|
|
2362
|
+
| `write` | info | глагол записи, план батча, загрузка | `op`, `chain`, `rows`, `ms`, `tx` |
|
|
2363
|
+
| `delete.preview` | debug | `delete()` без `confirm` | `rows`; `detail`: сколько `delete`, `unset`, `restrict` |
|
|
2364
|
+
| `delete` | info | удаление выполнено | `chain`, `rows`; `detail.expect` — сколько строк превью сверено (`false` — без сверки, `all: true`) |
|
|
2365
|
+
| `restore` | info | восстановление выполнено | `chain`, `rows`; `detail`: `expect`, `partial`, `skipped: [{ id, why }]` |
|
|
2366
|
+
| `tx.begin` | debug | открыта транзакция | `tx` |
|
|
2367
|
+
| `tx.commit` | info, если в транзакции была запись, иначе debug | зафиксирована транзакция | `tx`, `ms`; `detail.writes` — число операций записи |
|
|
2368
|
+
| `tx.rollback` | info | откат | `detail`: `reason` (`explicit` или `error`), `writes` |
|
|
2369
|
+
| `tx.retry` | warn | повтор после `40001`, `40P01` или конфликта журнала | `detail`: `attempt`, `code` |
|
|
2370
|
+
| `auth.login`, `auth.logout`, `auth.revoke`, `auth.switch` | info | вход, выход, отзыв сессии, смена арендатора | `account`, `tenant` |
|
|
2371
|
+
| `watch.start`, `watch.stop` | info | подписка открыта, закрыта | `detail.classes` |
|
|
2372
|
+
| `signal` | debug | получен сигнал базы | `detail.channel` |
|
|
2373
|
+
| `maintain` | info | `maintain()` | итог обслуживания |
|
|
2374
|
+
| `warning` | warn | любое предупреждение letopis (`api.step_undefined_filter` и другие) | `key`, `msg` |
|
|
2375
|
+
| `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` |
|
|
2376
|
+
| `sql` | debug | каждый запрос SQL | `ms`, `rows`, `tx`; `detail`: текст (до 2000 знаков), число параметров |
|
|
2377
|
+
|
|
2378
|
+
Ещё есть, среди прочих: `auth.as` (debug, `as()`), `auth.fail` (warn, неудачный вход), `acl.change` (правка прав
|
|
2379
|
+
`db.acl`), `tenant.create`, `account.purge`, `load` (итог загрузчика), `lock` (debug, `db.lock()`), `mcp.call` (вызов
|
|
2380
|
+
инструмента MCP).
|
|
2381
|
+
|
|
2244
2382
|
### Пулы соединений и PgBouncer
|
|
2245
2383
|
|
|
2246
2384
|
С PgBouncer в режиме транзакций `LISTEN` недоступен: `connect({ listen: false })`. Тогда реестр перечитывается через
|
|
2247
2385
|
`db.refresh()`, подписка опрашивает журнал, а кэш результатов выключен или работает по сроку. Подготовленные
|
|
2248
2386
|
операторы работают с PgBouncer 1.21 и новее; со старыми передайте свой пул postgres.js с `prepare: false` в
|
|
2249
|
-
`connect({ sql })`. `maintain()` берёт транзакционную блокировку
|
|
2387
|
+
`connect({ sql })`. `maintain()` берёт транзакционную блокировку и работает за таким пулом.
|
|
2250
2388
|
|
|
2251
2389
|
<!-- run: guide -->
|
|
2252
2390
|
```ts
|
|
@@ -2255,55 +2393,18 @@ await anna.close()
|
|
|
2255
2393
|
await svc.close() // и подключения, полученные через svc.as()
|
|
2256
2394
|
```
|
|
2257
2395
|
|
|
2258
|
-
## 14.
|
|
2396
|
+
## 14. Справочник API
|
|
2259
2397
|
|
|
2260
|
-
|
|
2261
|
-
данные переносит команда `letopis import`, а код переводится по таблице замен из [MIGRATION.md](MIGRATION.md).
|
|
2262
|
-
Главные отличия в поведении: `create` не перезаписывает (занятый ключ — `exists`), `count()` считает сущности,
|
|
2263
|
-
пути — `paths()`, права и изоляция арендаторов включены всегда.
|
|
2264
|
-
|
|
2265
|
-
```sh
|
|
2266
|
-
npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgres://…/new --schema v2.salon \
|
|
2267
|
-
--tenant salon [--keyless booking] [--rename link=link_] [--update] [--commit-every 100000]
|
|
2268
|
-
```
|
|
2269
|
-
|
|
2270
|
-
- Схема 1.0 ставится заранее (`up()`), оба подключения — администраторские. Источник — таблицы 0.21 (`Schema`,
|
|
2271
|
-
`Entity`, `Account`, `Credential`, `Resource`, `Rule`) любой базы: PostgreSQL 17 с TimescaleDB или дамп обычных
|
|
2272
|
-
таблиц. Пишут загрузчик (режим истории, `existing: 'skip'`, агент `letopis import`) и функции владельца.
|
|
2273
|
-
- Классы становятся строками `Class` в System. Правила fastest-validator переводятся в JSON Schema (`optional`
|
|
2274
|
-
пропускает `null`, `$$strict` → `additionalProperties: false`, `record` → `propertyNames`). Концы — роли по
|
|
2275
|
-
имени класса цели, союз всех потомков класса — полиморфный конец. Корни `Entity` и `link` опускаются;
|
|
2276
|
-
`description`, `appearance`, `order` переходят в `meta`. Имена, занятые методами 1.0, — `--rename` (по умолчанию
|
|
2277
|
-
имя с `_`).
|
|
2278
|
-
- id: у класса с ключом (правило v5 из 0.21) — v5 от ключа, у класса без ключа — v5 от старого id. Данные System
|
|
2279
|
-
переходят в арендатора `--tenant`, аккаунты — в строки `Account`, креды — в `secret` как есть (scrypt, ключи);
|
|
2280
|
-
одноразовые коды не переносятся. Ресурсы и правила — в System.
|
|
2281
|
-
- До первой записи импорт останавливается, если два объекта 0.21 дают один id 1.0 (данные нарушают ключ —
|
|
2282
|
-
`--keyless <Класс>`), если ключ менялся между версиями или ссылается на отсутствующий объект. Висячая ссылка
|
|
2283
|
-
живой строки — `target_not_found` с объектом 0.21.
|
|
2284
|
-
- Если загрузчик отклонил данные, ошибка начинается с `import: арендатор …`, а её `detail` — как у загрузчика
|
|
2285
|
-
([«Отчёт и ошибки»](#отчёт-и-ошибки)), только строки названы объектами 0.21: `rows` — нарушения с `class`, `id`
|
|
2286
|
-
и `rev` объекта 0.21, `code` и `message`; `total`, `truncated`, `limit` и `complete` — как в отчёте загрузчика;
|
|
2287
|
-
`notes` — непереведённые правила.
|
|
2288
|
-
- Повтор дописывает недостающее без дублей, `--update` — ещё и новые версии изменившихся объектов. Отчёт — JSON в
|
|
2289
|
-
конце вывода: объекты и версии по классам в 0.21 и в 1.0, `verify()`, непереведённые правила, классы без ключа и
|
|
2290
|
-
uuid в `data`, совпадающие со старыми id (их импорт не переписывает). Код выхода 1, если `verify()` нашёл
|
|
2291
|
-
расхождения.
|
|
2292
|
-
|
|
2293
|
-
## 15. Справочник API
|
|
2294
|
-
|
|
2295
|
-
Полный машинный список экспортов, методов и текстов ошибок — `docs/api-contract.json` в репозитории.
|
|
2398
|
+
Полный машинный список экспортов, методов и текстов ошибок — `lib/api-contract.json` в репозитории.
|
|
2296
2399
|
|
|
2297
2400
|
### Экспорты пакета
|
|
2298
2401
|
|
|
2299
2402
|
| Точка входа | Что экспортирует |
|
|
2300
2403
|
|---|---|
|
|
2301
|
-
| `letopis` | `connect`, `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`;
|
|
2302
|
-
| `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta
|
|
2404
|
+
| `letopis` | `connect`, `loose`, `up`, `createTenant`; операторы `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `inList`, `like`, `ilike`, `starts`, `ends`, `has`, `hasAny`, `hasAll`, `exists`, `isNull`, `not`, `or`, `isOp`; `inc`, `isInc`, `cursorOf`; `idOf`, `uuidv5`, `v5Name`, `v5Id`; `LetopisError`, `ValidationError`, `fromDbError`; `syncModels`, `generateTypes`, `schemaToTs`; `compileValidator`, `applyDefaults`, `Registry`; `totpCode`, `hashPassword`, `verifyHash`; константы `DDL_REVISION`, `RESERVED_CLASS_NAMES`, `CHAIN_METHODS`, `DB_METHODS`; типы `Db`, `DbTx`, `TypedDb`, `SystemModels`, `Chain`, `Row`, `Expected` (строка `expect`: `{ id, rev, $action? }`), `DeleteOptions`, `RestoreOptions`, `Filter`, `Cursor`, `ConnectOptions`, `UpOptions`, `LoadOptions`, `LoadReport`, `WatchOptions`, `WatchEvent`, `AuthApi`, `AclApi`, `MaintainReport` и другие |
|
|
2405
|
+
| `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta`; `normalizeSchema`, `translatePattern`, тип `JsonSchema` |
|
|
2303
2406
|
| `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)) |
|
|
2304
|
-
| CLI `letopis` | `sync`, `types`, `load`, `
|
|
2305
|
-
|
|
2306
|
-
Снятые имена 0.21 (`uuidv7`, `LETOPIS_NS` и методы, перечисленные ниже) бросают `removed` с заменой.
|
|
2407
|
+
| CLI `letopis` | `sync`, `types`, `load`, `maintain`, `mcp` ([раздел 13](#13-эксплуатация)); у всех — `--log[=уровень]` ([«Логирование»](#логирование)); у `mcp` — `--dsn`, `--schema`, `--token` (или `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`) и `--write` |
|
|
2307
2408
|
|
|
2308
2409
|
### Корень `db`
|
|
2309
2410
|
|
|
@@ -2313,29 +2414,25 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2313
2414
|
| `schema`, `token`, `tenant`, `registry` | имя установки, токен, арендатор сессии, реестр классов (`resolve`, `find`, `has`, `all`, `family`) |
|
|
2314
2415
|
| `auth`, `acl` | вход, креды и сессии; решения прав ([раздел 10](#10-пользователи-вход-и-права)) |
|
|
2315
2416
|
| `as(аккаунт, { tenant, owner })` | тот же пул от имени аккаунта (асинхронный) |
|
|
2316
|
-
| `begin()`, `commit(tr)`, `rollback(tr)`, `lock(…ключи)` | явная транзакция ([раздел 8](#8
|
|
2417
|
+
| `begin()`, `commit(tr)`, `rollback(tr)`, `lock(…ключи)` | явная транзакция ([раздел 8](#8-транзакции-и-батчи)) |
|
|
2317
2418
|
| `batch(имя)` | очередь планов: `run()`, `discard()`, `size()` |
|
|
2318
2419
|
| `load(вход, опции)` | загрузчик ([раздел 9](#9-массовая-загрузка)) |
|
|
2319
|
-
| `projector(имя, опции)` | проектор из журнала: `run()`, `rebuild({ chunked, tenant })`, `status()`, `stop()`, `close()` ([«Проекторы»](#проекторы)) |
|
|
2320
|
-
| `rebuildAggregates(класс)` | ремонт агрегатов класса и его потомков во всех арендаторах: поле заново из живых источников, версия — только у исправленных; → их число ([«Агрегаты в базе»](#агрегаты-в-базе)) |
|
|
2321
2420
|
| `watch(опции)`, `cache` | подписка; кэш результатов (`clear()`, `stats()`) |
|
|
2322
2421
|
| `` sql`…` ``, `transaction(fn)` | сырой SQL под сессией и RLS |
|
|
2323
2422
|
| `idOf(класс, ключ)` | id объекта класса с ключом в арендаторе сессии |
|
|
2324
2423
|
| `entity(строка \| цепочка)` | начать путь с готового узла |
|
|
2325
|
-
| `
|
|
2326
|
-
| `
|
|
2327
|
-
| `trimHistory(цель, момент)`, `rekeyClass(класс, ключ)` | обрезка истории; новый ключ класса |
|
|
2328
|
-
| `refresh()`, `flushSignals()`, `close()` | перечитать классы; отправить сигналы сейчас; закрыть |
|
|
2424
|
+
| `maintain()` | обслуживание ([раздел 13](#13-эксплуатация)) |
|
|
2425
|
+
| `refresh()`, `flushSignals()`, `close()` | перечитать классы; отправить сигналы сейчас; закрыть пул и подписки (у фасада `as()` — ничего не делает, в `db.begin()` — откат) |
|
|
2329
2426
|
|
|
2330
2427
|
### Цепочка
|
|
2331
2428
|
|
|
2332
2429
|
| Группа | Методы |
|
|
2333
2430
|
|---|---|
|
|
2334
2431
|
| шаги | `.Класс(фильтр)`, `.алиас(фильтр)`, `.Роль(фильтр)` |
|
|
2335
|
-
| модификаторы | `limit(n)`, `offset(n)`, `sort(поле, 'asc' \| 'desc')`, `after(курсор)`, `asOf(момент)`, `withDeleted()`, `deep(max)`, `exact()`, `alias(имя)`, `tags(…)`, `owner(…)`, `entity(…)`, `forUpdate()`, `cache({ ttl })` |
|
|
2336
|
-
| глаголы | `create(данные)`, `update(патч, { rev })`, `upsert(данные)`, `delete({ confirm })`, `
|
|
2432
|
+
| модификаторы | `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` — своему шагу |
|
|
2433
|
+
| глаголы | `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 }` |
|
|
2337
2434
|
| слоты | после глагола: `.Роль.set(значение)`, `.Роль.unset()`, `.Роль.add(значение)`, `.Роль.remove(значение)` |
|
|
2338
|
-
| терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions(
|
|
2435
|
+
| терминалы | `rows()`, `first()`, `ids()`, `count({ paths })`, `paths()`, `versions()`, `sum(поле)`, `avg(поле)`, `min(поле)`, `max(поле)`, `countBy(поле)` |
|
|
2339
2436
|
|
|
2340
2437
|
### `db.auth`
|
|
2341
2438
|
|
|
@@ -2349,9 +2446,9 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2349
2446
|
| `issueOtp`, `verifyOtp` | `{ account, identifier, kind?, ttl?, digits?, maxAttempts? }` → `{ code }`; `{ identifier, code, kind? }` → вход или `null` |
|
|
2350
2447
|
| `link`, `lookup` | `{ account, kind, identifier, meta?, confirmed? }` → id; `{ kind, identifier, requireConfirmed?, ttl?, totp? }` → вход или `null` |
|
|
2351
2448
|
| `credentials`, `revokeCredential`, `setFlags` | `(account)` → креды без секретов; `(id, { keepCurrent? })`; `(id, { confirmed?, enabled? })` |
|
|
2352
|
-
| `sessionFor`, `refresh`, `revoke`, `revokeAll` | `(account, { ttl? })` → токен; `({ ttl? })` → новое время истечения (токен прежний); `()`; `(account?)` → число сессий |
|
|
2449
|
+
| `sessionFor`, `refresh`, `revoke`, `revokeAll` | `(account, { ttl? })` → токен; `({ ttl? })` → новое время истечения (токен прежний); `()` — выход подключения, `(token)` — выход пользователя по токену его сессии (право `auth.session`) → `boolean`; `(account?)` → число сессий |
|
|
2353
2450
|
| `session` | `(token)` → `{ account, tenant, member, expiresAt, loginAt, methods }` или `null` |
|
|
2354
|
-
| `tenants`, `switch` | `()` → `[{ tenant, roles }]
|
|
2451
|
+
| `tenants`, `switch` | `()` → `[{ tenant, roles, name, personal }]` (первым — собственный, `personal: true`); `(tenant)` |
|
|
2355
2452
|
| `purgeAccount` | `(account)` → `{ account, rows, versions, memberships, owned }` |
|
|
2356
2453
|
|
|
2357
2454
|
Проверку паролей, кодов и TOTP, выпуск сессий, сведения о сессии по токену и подтверждение кредов вызывает сервис
|
|
@@ -2361,26 +2458,9 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2361
2458
|
креды — нет; последний действующий ключ сервиса не отзывается никем (`acl_denied`). С `totp` вход `verifyPassword` и
|
|
2362
2459
|
`lookup` проверяет ещё и код TOTP (право `auth.totp`) и выдаёт сессию со способом `TOTP`.
|
|
2363
2460
|
Вид креда `kind` — `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`, `TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`,
|
|
2364
|
-
`OTP`, `TOTP`, `KEY
|
|
2461
|
+
`OTP`, `TOTP`, `KEY` и `LOCK` заняты. То же правило — у вида пароля `user.kind` в `createTenant` и
|
|
2365
2462
|
`up({ tenant })`.
|
|
2366
2463
|
|
|
2367
|
-
### Проектор
|
|
2368
|
-
|
|
2369
|
-
`db.projector(имя, опции)` работает только на администраторском подключении ([«Проекторы»](#проекторы)). Имя —
|
|
2370
|
-
строчные латинские буквы, цифры и `_.:-`, до 64 символов; оно же — `derivedBy` производных классов. Неверные опции —
|
|
2371
|
-
`invalid_data`.
|
|
2372
|
-
|
|
2373
|
-
| Член | Что делает |
|
|
2374
|
-
|---|---|
|
|
2375
|
-
| опции `ProjectorOptions` | `from`, `into` — классы с потомками; `version` — целое от 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` — пачками); `batch` — изменений в пачке (500, от 1 до 10 000); `pollMs` — запасной опрос (1000 мс); `lockTimeout` — ожидание чужой блокировки строки (5000 мс, 0 — без предела); `lockRetries` — проходов подряд до выбывания арендатора с чужой блокировкой (0 — не выбывает); `tenants` — id арендаторов-участников |
|
|
2376
|
-
| `run({ once })` | догнать журнал (`once`) или следовать за ним до `stop()` → `ProjectorReport` |
|
|
2377
|
-
| `rebuild({ chunked, tenant })` | перестроить с текущего состояния: атомарно, пачками или одного арендатора → `ProjectorReport` |
|
|
2378
|
-
| `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` — `{ [id арендатора]: ProjectorFailure }`, где `ProjectorFailure` — `{ cursor, code, message }` |
|
|
2379
|
-
| `stop()`, `close()` | остановить `run()` после текущей пачки; то же и отписаться от сигналов |
|
|
2380
|
-
| `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds, locked }` |
|
|
2381
|
-
| `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` и, если есть, `agent`, `reason`, `moved`, `before`, `rebuild` |
|
|
2382
|
-
| `ProjectorOut` | `upsert(класс, строка, { tenant })`, `inc(класс, строка, приращения, { tenant })`, `delete(класс, ключ или id, { tenant })`, `get(класс, ключ или id, { tenant })` → `Row \| null`; строка — `ProjectorRow` в формате загрузчика; у класса с ключом `delete` и `get` — по ключу, id — только у класса без ключа |
|
|
2383
|
-
|
|
2384
2464
|
### Строка ответа
|
|
2385
2465
|
|
|
2386
2466
|
| Поле | Что значит |
|
|
@@ -2389,142 +2469,211 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2389
2469
|
| `tenant`, `owner` | арендатор и владелец строки |
|
|
2390
2470
|
| `links`, `data`, `tags` | концы, поля, теги |
|
|
2391
2471
|
| `at`, `author`, `agent`, `op`, `reason` | время версии (ISO, UTC, микросекунды), автор, агент (сервис при имперсонации), вид операции, причина |
|
|
2392
|
-
| `moved` | связь со старым или новым id после `reclass` и `rekey` |
|
|
2393
2472
|
| `$deleted` | надгробие: удалённая сущность или версия удаления |
|
|
2394
2473
|
| `$depth` | глубина узла в `deep()`; уровень каскада в превью удаления |
|
|
2395
|
-
| `$upsert`, `$action
|
|
2474
|
+
| `$upsert`, `$action` | что сделал `upsert`; что сделает удаление (`delete`, `unset`, `restrict`); `skip` — цель `restore({ expect, partial: true })`, которую не восстановить |
|
|
2475
|
+
| `$why` | у строки `$action: 'skip'` — почему: `restored`, `reissued`, `changed` ([«Восстановить: `restore`»](#восстановить-restore)) |
|
|
2396
2476
|
|
|
2397
2477
|
### Зарезервированные имена
|
|
2398
2478
|
|
|
2399
|
-
Имя метода закрывает класс
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2479
|
+
Имя метода закрывает класс там, где этот метод есть: после шага — методы цепочки, у корня `db` — его методы, у
|
|
2480
|
+
батча — его. Поэтому класс с любым из этих имён не создать: хаб отклоняют `hub()`, `syncModels` и база, связь — база
|
|
2481
|
+
(`invalid_class`; `reserved_names()`, список подставляет установщик из `RESERVED_CLASS_NAMES`). Хаб, заведённый мимо
|
|
2482
|
+
проверки базы, `up()` оставляет и называет в предупреждении: с именем метода только цепочки (например, `count`) он
|
|
2483
|
+
доступен первым шагом `db.count(…)`, а после другого шага недостижим.
|
|
2403
2484
|
|
|
2404
2485
|
- методы цепочки: `then`, `entity`, `paths`, `rows`, `first`, `ids`, `count`, `limit`, `offset`, `sort`, `asOf`,
|
|
2405
2486
|
`withDeleted`, `deep`, `exact`, `sum`, `avg`, `min`, `max`, `countBy`, `after`, `versions`, `create`, `update`,
|
|
2406
|
-
`upsert`, `set`, `unset`, `add`, `remove`, `delete`, `
|
|
2407
|
-
`owner`, `forUpdate`, `cache`, `rekey`, `diff`, `blame`; охранники снесённого API — `run`, `execute`, `account`;
|
|
2487
|
+
`upsert`, `set`, `unset`, `add`, `remove`, `delete`, `restore`, `alias`, `tags`, `owner`, `forUpdate`, `cache`;
|
|
2408
2488
|
- корень `db`: `as`, `begin`, `commit`, `rollback`, `lock`, `batch`, `watch`, `close`, `registry`, `sql`, `load`,
|
|
2409
|
-
`
|
|
2410
|
-
|
|
2411
|
-
`accounts`, `credentials`, `resources`, `rules`, `compact`;
|
|
2412
|
-
- батч: `discard`, `size`.
|
|
2489
|
+
`maintain`, `auth`, `acl`, `idOf`, `refresh`, `transaction`, `flushSignals`, `schema`, `token`, `tenant`, `cache`;
|
|
2490
|
+
- батч: `run`, `discard`, `size`.
|
|
2413
2491
|
|
|
2414
|
-
|
|
2492
|
+
Имя роли конца одно на арендатора — это имя шага цепочки: во всех классах арендатора и System роль с одним именем
|
|
2493
|
+
ведёт на одни и те же классы. Поэтому заняты роли концов системных классов: `Group` и `Object` (класс `rule`) ведут на
|
|
2494
|
+
`Resource`, `User` и `Tenant` (класс `member`) — на `Account`. Конец с таким именем, который ведёт на другой класс,
|
|
2495
|
+
`syncModels` отклоняет до записи — `invalid_class` (`model.end_role_system`; роль другого своего класса —
|
|
2496
|
+
`model.end_role_taken`), а класс, заведённый мимо него, — база (`sql-write.end_other_class`). Имена классов `Group` и
|
|
2497
|
+
другие не заняты — заняты имена ролей: называйте роли иначе (`Team`, `Org`, `Holder`).
|
|
2498
|
+
|
|
2499
|
+
## 15. Ошибки
|
|
2415
2500
|
|
|
2416
2501
|
Текст ошибки — `letopis: <код>: <сообщение>`. Код — в `err.code`, подробности — в `err.detail`. Ошибки — объекты
|
|
2417
|
-
`LetopisError`, у ошибок данных — `ValidationError` с `err.issues: [{ path, keyword, message }]`.
|
|
2502
|
+
`LetopisError`, у ошибок данных — `ValidationError` с `err.issues: [{ path, keyword, message }]`. Ещё у ошибки есть:
|
|
2503
|
+
|
|
2504
|
+
- `err.hint` — что делать: подсказка из каталога сообщений библиотеки, своя у места или общая у кода;
|
|
2505
|
+
- `err.docs` — ссылка на строку кода в этом разделе README на GitHub, по тегу версии пакета;
|
|
2506
|
+
- `err.key` и `err.params` — ключ каталога сообщений и параметры текста. Они есть у ошибок, собранных по ключу каталога:
|
|
2507
|
+
у мест библиотеки и у ошибок базы, которая прислала ключ в `DETAIL`.
|
|
2508
|
+
|
|
2509
|
+
Тексты ошибок — только на русском. Ошибку базы с ключом библиотека отдаёт с тем же текстом, что получает и сырой SQL
|
|
2510
|
+
мимо библиотеки, а ключ и параметры из `DETAIL` переносит в `err.key` и `err.params`. У ошибки данных (`invalid_data`)
|
|
2511
|
+
текст ещё называет первое нарушение и их число: `letopis: invalid_data: данные не проходят описание класса Order:
|
|
2512
|
+
status — значение не из перечисления: допустимо "new", "paid", "cancelled" (всего нарушений: 2, все — err.issues)`.
|
|
2513
|
+
Сообщение нарушения перечисления называет допустимые значения, лишнего ключа — сам ключ (`лишний ключ colour`).
|
|
2514
|
+
|
|
2515
|
+
```ts
|
|
2516
|
+
const db = await connect({ dsn, schema: 'v2.shop', token })
|
|
2517
|
+
try {
|
|
2518
|
+
await db.Shpo().rows()
|
|
2519
|
+
} catch (e) {
|
|
2520
|
+
// e.code — 'invalid_class', e.params.similar — ['Shop']
|
|
2521
|
+
// e.message — 'letopis: invalid_class: класс или роль Shpo не найдены — похожие: Shop'
|
|
2522
|
+
// e.hint — что делать; e.docs — https://github.com/alepri51/letopis/blob/v<версия>/lib/README.md#invalid_class
|
|
2523
|
+
}
|
|
2524
|
+
```
|
|
2525
|
+
|
|
2526
|
+
Похожие имена. Неизвестный класс или роль в цепочке — ошибка `invalid_class`, неизвестное поле в фильтре, `sort()` или
|
|
2527
|
+
агрегате — ошибка `invalid_query`, неизвестная опция `connect()`, `up()`, `db.load()`, `db.watch()` и
|
|
2528
|
+
`createMcpServer()` — ошибка `invalid_data`: похожие имена — в тексте и в `err.params.similar`.
|
|
2529
|
+
|
|
2530
|
+
```ts
|
|
2531
|
+
try {
|
|
2532
|
+
await db.Shop({ ctiy: 'Юг' }).rows()
|
|
2533
|
+
} catch (e) {
|
|
2534
|
+
// e.code — 'invalid_query', e.key — 'query.unknown_field', e.params.similar — ['city']
|
|
2535
|
+
// e.message — 'letopis: invalid_query: Shop({ ctiy }): поле ctiy не описано в классе Shop — похожие: city'
|
|
2536
|
+
}
|
|
2537
|
+
```
|
|
2418
2538
|
|
|
2419
2539
|
Коды базы. Их получает и сырой SQL: SQLSTATE класса `LT`, подробности — JSON в `DETAIL`.
|
|
2420
2540
|
|
|
2421
2541
|
| Код | SQLSTATE | Когда |
|
|
2422
2542
|
|---|---|---|
|
|
2423
|
-
| `no_session
|
|
2424
|
-
| `target_not_found
|
|
2425
|
-
| `invalid_data
|
|
2426
|
-
| `undeclared_end
|
|
2427
|
-
| `tenant_denied
|
|
2428
|
-
| `acl_denied
|
|
2429
|
-
| `id_mismatch`, `id_from_db
|
|
2430
|
-
| `immutable_key
|
|
2431
|
-
| `invalid_class
|
|
2432
|
-
| `
|
|
2433
|
-
| `
|
|
2434
|
-
| `
|
|
2435
|
-
| `
|
|
2436
|
-
| `
|
|
2437
|
-
| `delete_restricted` | LT017 | удалению мешают ссылающиеся строки (`onDelete: 'restrict'`) |
|
|
2438
|
-
| `not_deleted` | LT018 | `restore` и `purge` — только для удалённых |
|
|
2439
|
-
| `reset_disabled` | LT019 | сброс выключен в установке (`allowReset`) |
|
|
2440
|
-
| `confirm_mismatch` | LT020 | подтверждение сброса не совпадает с именем схемы |
|
|
2441
|
-
| `derived_class` | LT021 | строку производного класса (`derivedBy`) пишет, правит или удаляет не его проектор; конец ведёт на такую строку или на производный класс; проектор пишет в класс вне своих `into` или не нашёл ни одного арендатора, у которого все классы `into` помечены его `derivedBy` |
|
|
2442
|
-
| `aggregate_field` | LT022 | приложение передало или изменило агрегатное поле (`aggregate`): его считает база |
|
|
2443
|
-
| `projector_pending` | LT023 | `purge` удалённого объекта, чьё удаление ещё не прочёл проектор его класса, или `trimHistory`, чью первую оставшуюся версию он ещё не прочёл: сначала `run()` проектора |
|
|
2543
|
+
| `no_session`<a id="no_session"></a> | LT001 | нет действующей сессии. У библиотеки ещё — `connect({ token })` с погашенным, истёкшим или неверным токеном: отклонённый промис, подключение не создаётся (`api.token_not_active`); то же — промисом терминала у шага, не найденного в реестре подключения по токену без арендатора сессии |
|
|
2544
|
+
| `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`) |
|
|
2545
|
+
| `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`) и неверное значение опции |
|
|
2546
|
+
| `undeclared_end`<a id="undeclared_end"></a> | LT004 | конец не объявлен в классе |
|
|
2547
|
+
| `tenant_denied`<a id="tenant_denied"></a> | LT005 | чужой арендатор или нет членства |
|
|
2548
|
+
| `acl_denied`<a id="acl_denied"></a> | LT006 | отказ по правилам доступа |
|
|
2549
|
+
| `id_mismatch`, `id_from_db`<a id="id_mismatch"></a><a id="id_from_db"></a> | LT007, LT008 | id не равен v5 от ключа; id класса без ключа выдаёт база |
|
|
2550
|
+
| `immutable_key`<a id="immutable_key"></a> | LT009 | ключевое поле, класс, id, арендатор и владелец не меняются: другой ключ или класс — новый объект |
|
|
2551
|
+
| `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`, [«Поля»](#поля)) |
|
|
2552
|
+
| `tightening_conflict`<a id="tightening_conflict"></a> | LT013 | ужесточению описания мешают строки |
|
|
2553
|
+
| `isolation_level`<a id="isolation_level"></a> | LT014 | удаление — только в `read committed` |
|
|
2554
|
+
| `recreated_in_statement`<a id="recreated_in_statement"></a> | LT015 | id удалён и создан заново одним оператором |
|
|
2555
|
+
| `delete_restricted`<a id="delete_restricted"></a> | LT017 | удалению мешают ссылающиеся строки (`onDelete: 'restrict'`) |
|
|
2556
|
+
| `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)) |
|
|
2444
2557
|
|
|
2445
2558
|
Коды библиотеки:
|
|
2446
2559
|
|
|
2447
2560
|
| Код | Когда |
|
|
2448
2561
|
|---|---|
|
|
2449
|
-
| `exists
|
|
2450
|
-
| `duplicate`, `deleted
|
|
2451
|
-
| `conflict
|
|
2452
|
-
| `rev_ambiguous
|
|
2453
|
-
| `tx_required
|
|
2454
|
-
| `lock_unsupported
|
|
2455
|
-
| `commit_unknown
|
|
2456
|
-
| `tx_rolled_back
|
|
2457
|
-
| `no_key
|
|
2458
|
-
| `no_path
|
|
2459
|
-
| `invalid_query
|
|
2460
|
-
| `admin_required
|
|
2461
|
-
| `
|
|
2462
|
-
| `
|
|
2463
|
-
| `
|
|
2464
|
-
| `
|
|
2465
|
-
| `
|
|
2466
|
-
| `
|
|
2467
|
-
| `confirmation_required` | `letopis_write` (MCP): удаление без подтверждения человека — клиент без elicitation, человек отказал, ответ не к этой операции или данные изменились после превью |
|
|
2468
|
-
| `removed` | снесённое API 0.21; в тексте — замена |
|
|
2562
|
+
| `exists`<a id="exists"></a> | объект уже существует: `create` (`detail.id` — его id) или загрузчик |
|
|
2563
|
+
| `duplicate`, `deleted`<a id="duplicate"></a><a id="deleted"></a> | загрузчик: повтор id в загрузке; объект с этим id жил раньше. Загрузчик бросает код первой проблемной строки, первые 100 — в `detail.rows`, всего — `detail.total` |
|
|
2564
|
+
| `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: []`), ничего не удалено |
|
|
2565
|
+
| `rev_ambiguous`<a id="rev_ambiguous"></a> | `{ rev }` у шага, у которого не одна цель |
|
|
2566
|
+
| `tx_required`<a id="tx_required"></a> | `forUpdate()`, `lock()`, `commit()` или `rollback()` вне действующей `db.begin()` |
|
|
2567
|
+
| `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()` отдельной цепочкой» |
|
|
2568
|
+
| `commit_unknown`<a id="commit_unknown"></a> | связь оборвалась во время `COMMIT`: исход неизвестен |
|
|
2569
|
+
| `tx_rolled_back`<a id="tx_rolled_back"></a> | PostgreSQL ответил `ROLLBACK` на `COMMIT`: ошибку перехватили внутри `tr.transaction(fn)` |
|
|
2570
|
+
| `no_key`<a id="no_key"></a> | `upsert` или `idOf` у класса без ключа |
|
|
2571
|
+
| `no_path`<a id="no_path"></a> | между классами соседних шагов нет перехода |
|
|
2572
|
+
| `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`) и т. п.) |
|
|
2573
|
+
| `admin_required`<a id="admin_required"></a> | загрузчику нужно администраторское подключение; роль строки `connect({ admin })` не может стать владельцем схемы; `up()` от роли без ADMIN OPTION на уже существующую роль владельца |
|
|
2574
|
+
| `not_empty`<a id="not_empty"></a> | первая заливка (`initial`) — только в пустую схему |
|
|
2575
|
+
| `class_not_loadable`<a id="class_not_loadable"></a> | строки `Class` загрузчик не принимает |
|
|
2576
|
+
| `bypass_rls`<a id="bypass_rls"></a> | `connect()` с ролью, которая обходит RLS, без `allowBypassRls`; `createMcpServer` с подключением в обход RLS |
|
|
2577
|
+
| `user_session_required`<a id="user_session_required"></a> | `createMcpServer` и `letopis mcp`: нужна сессия пользователя — у подключения нет сессии, это корень сервиса без `as()`, сессия в арендаторе System без `allowSystemAdmin`, в командной строке ключ сервиса или нет токена |
|
|
2578
|
+
| `confirmation_required`<a id="confirmation_required"></a> | `letopis_write` (MCP): удаление без подтверждения человека — клиент без elicitation, человек отказал, ответ не к этой операции или данные изменились после превью |
|
|
2579
|
+
| `internal`<a id="internal"></a> | внутренняя ошибка letopis: база не создала или не нашла свою строку-замок (из базы — SQLSTATE `XX000`, ключ в `DETAIL`); сообщите о ней с текстом, кодом и версией пакета |
|
|
2469
2580
|
|
|
2470
2581
|
Отказ PostgreSQL в праве (`42501`) приходит, например, при `upsert` поверх чужой строки, которую нельзя править.
|
|
2471
2582
|
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
|
2480
|
-
|
|
2481
|
-
|
|
|
2482
|
-
|
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
|
2488
|
-
|
|
2489
|
-
|
|
|
2490
|
-
|
|
|
2491
|
-
|
|
|
2492
|
-
|
|
|
2493
|
-
|
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2583
|
+
**Отказы безопасности по умолчанию и неверного использования.** Ключи, которые приходят до записи и до запроса — их
|
|
2584
|
+
исправляют в коде, а не показывают пользователю:
|
|
2585
|
+
|
|
2586
|
+
| Ключ | Код | Когда | Что делать |
|
|
2587
|
+
|---|---|---|---|
|
|
2588
|
+
| `write.confirm_without_expect` | `invalid_query` | `delete({ confirm: true })` без `expect` и без `all: true` | показать превью `delete()` и удалить с ним: `delete({ confirm: true, expect: превью })`; без сверки — осознанно `{ confirm: true, all: true }` |
|
|
2589
|
+
| `write.restore_without_expect` | `invalid_query` | `restore()` без `expect` и без `all: true`, в том числе по явному id | `restore({ expect })` с ответом `delete()` или строками `withDeleted().rows()`; что можно — `restore({ expect, partial: true })`; всё без сверки — осознанно `restore({ all: true })` |
|
|
2590
|
+
| `api.create_tenant_admin` | `invalid_query` | `createTenant(admin, …)`: `admin` — строка DSN, подключение `connect()`, `undefined` или другой не пул | `const admin = postgres(ADMIN); await createTenant(admin, schema, opts); await admin.end()` |
|
|
2591
|
+
| `api.versions_terminal` | `invalid_query` | метод цепочки у результата `versions()`: `versions().rows()`, `.sort()`, `.limit()` | `await db.X(id).versions()`; модификаторы — до `versions()` |
|
|
2592
|
+
| `write.link_in_data` | `invalid_data` | имя конца (связи) в данных `create()`, `upsert()`, `update()` | конец — из пути или слотом после глагола: `.Роль.set(id)`, у списка — `.add(id)` / `.remove(id)`, снять — `.unset()` |
|
|
2593
|
+
| `sql-core.impersonation_no_account` | `target_not_found` | `svc.as(id)`: аккаунта нет — не заводили или удалён | id аккаунта — из входа, `createTenant()` или `auth.tenants()` |
|
|
2594
|
+
|
|
2595
|
+
**Текст для человека.** Штатные отказы — ответ пользователю, а не 500. Фразу выбирают по `err.key`, если он есть, иначе
|
|
2596
|
+
по `err.code`; `err.message` и `err.hint` — для разработчика и журнала:
|
|
2597
|
+
|
|
2598
|
+
| Код (ключ) | Пользователю |
|
|
2599
|
+
|---|---|
|
|
2600
|
+
| `exists` | «уже существует» / «уже занято» |
|
|
2601
|
+
| `conflict` | «данные изменил другой — обновите и повторите»; `write.preview_changed` — показать новое превью; `write.restore_stale` — «после удаления запись меняли» |
|
|
2602
|
+
| `not_deleted` | `write.restore_already` — «уже возвращено»; `write.restore_id_taken` — «место занято новой записью»; `write.not_deleted` — «не удалено» |
|
|
2603
|
+
| `target_not_found` | «не найдено: удалено или недоступно» |
|
|
2604
|
+
| `delete_restricted` | «нельзя удалить: на запись ссылаются» (строки — превью с `$action: 'restrict'`) |
|
|
2605
|
+
| `acl_denied`, `tenant_denied` | «недостаточно прав», «нет доступа к организации» |
|
|
2606
|
+
| `no_session` | «сессия истекла — войдите снова» |
|
|
2607
|
+
| `invalid_data` | по `err.issues`: своё название поля по `path`, фраза по `keyword` (ниже) |
|
|
2608
|
+
|
|
2609
|
+
**Значение не того типа (SQLSTATE `22P02`).** Неверный id в цепочке библиотека ловит сама, до запроса:
|
|
2610
|
+
`invalid_query`, ключ `query.bad_uuid`. Что дошло до базы — `'42'::uuid` в `db.sql`, `db.transaction(fn)` или
|
|
2611
|
+
`tr.sql`, — приходит `LetopisError` с кодом `invalid_query` и ключом `api.db_bad_value`; текст называет, какое
|
|
2612
|
+
значение не подошло, исходная ошибка PostgreSQL — в `err.cause`:
|
|
2613
|
+
|
|
2614
|
+
```ts
|
|
2615
|
+
try {
|
|
2616
|
+
await db.sql`select ${'42'}::uuid`
|
|
2617
|
+
} catch (e) {
|
|
2618
|
+
// e instanceof LetopisError, e.code === 'invalid_query', e.key === 'api.db_bad_value'
|
|
2619
|
+
// e.message — 'letopis: invalid_query: значение не того типа: invalid input syntax for type uuid: "42"'
|
|
2620
|
+
// (e.cause as { code: string }).code === '22P02' — исходная ошибка PostgreSQL
|
|
2621
|
+
}
|
|
2622
|
+
```
|
|
2623
|
+
|
|
2624
|
+
SQLSTATE, если он нужен, берут из `err.cause.code`.
|
|
2625
|
+
|
|
2626
|
+
**Текст для пользователя из `err.issues`.** У `ValidationError` каждое нарушение — `{ path, keyword, message }`:
|
|
2627
|
+
`path` — путь к полю (`[]` — данные целиком), `keyword` — правило JSON Schema (`type`, `required`, `minimum`, `enum`,
|
|
2628
|
+
`pattern`, `additionalProperties` …), `message` — русский текст для разработчика: у перечисления он называет
|
|
2629
|
+
допустимые значения, у лишнего ключа — сам ключ, у `minLength` и `maxLength` — предел («длина строки меньше 3», «длина
|
|
2630
|
+
строки больше 40»), у прочих правил бывает общим («нарушено правило minimum»). Пользователю
|
|
2631
|
+
показывают свои названия полей и свои фразы по `keyword`, а `message` — запасной текст:
|
|
2632
|
+
|
|
2633
|
+
```ts
|
|
2634
|
+
import { ValidationError } from 'letopis'
|
|
2635
|
+
|
|
2636
|
+
const LABELS: Record<string, string> = { price: 'Цена', title: 'Название', sku: 'Артикул' }
|
|
2637
|
+
const RULES: Record<string, string> = { required: 'обязательно', minimum: 'слишком мало', type: 'неверный формат' }
|
|
2638
|
+
function explain(e: ValidationError): string[] {
|
|
2639
|
+
return e.issues.map((i) => {
|
|
2640
|
+
const field = i.path.length ? (LABELS[String(i.path[0])] ?? i.path.join('.')) : 'Данные'
|
|
2641
|
+
return `${field}: ${RULES[i.keyword] ?? i.message}` // «Цена: слишком мало»
|
|
2642
|
+
})
|
|
2643
|
+
}
|
|
2644
|
+
```
|
|
2645
|
+
|
|
2646
|
+
**Предупреждения.** Часть ошибок использования — не отказ, а предупреждение в консоли: шаг с `undefined`, шаг после
|
|
2647
|
+
`sort()`, `withDeleted()` не у того шага ([«Строгий режим»](#строгий-режим)). При включённом журнале каждое
|
|
2648
|
+
предупреждение — ещё и событие `warning`, а каждая ошибка — событие `error` с кодом, ключом и цепочкой
|
|
2649
|
+
([«Логирование»](#логирование)).
|
|
2650
|
+
|
|
2651
|
+
## 16. Производительность
|
|
2652
|
+
|
|
2653
|
+
Замеры — сценарии `lib/bench/` (как запускать, написано в шапке каждого файла), итоги — `lib/bench/results/`. Ниже —
|
|
2654
|
+
серии `core-*` (замеры 8 октября 2026 года: PostgreSQL 18.6 в Docker, `shared_buffers` 128 МБ, Node.js 24) и
|
|
2655
|
+
чтение цепочкой `chain-read` на тех же данных, что у `core-read`. Цифры зависят от машины; важно соотношение.
|
|
2656
|
+
|
|
2657
|
+
| Что меряли | Серия | Итог |
|
|
2513
2658
|
|---|---|---|
|
|
2514
|
-
|
|
|
2515
|
-
|
|
|
2516
|
-
|
|
|
2517
|
-
|
|
|
2518
|
-
|
|
|
2519
|
-
|
|
|
2659
|
+
| Вставка через триггеры по строке, транзакция на строку | `core-write`, 10 000 строк | около 170 строк в секунду; без ожидания записи на диск при фиксации (`synchronous_commit = off`) — около 470 |
|
|
2660
|
+
| Та же вставка пачками по 1 000 строк в одном операторе (так склеивает подряд идущие `create` батч) | `core-write`, 10 000 строк | около 1 350 строк в секунду: 731 мкс на строку, из них проверка концов — около 330, журнал — 150, сигнал — 90, проверка данных — 45, RLS — меньше 10; та же вставка без триггеров — 99 мкс |
|
|
2661
|
+
| `COPY` от роли владельца и проверка концов одним запросом в конце (прототип загрузчика) | `core-write`, `core-load` | 10 000 строк — около 14 000 в секунду; миллион — за 92 с (около 10 800 в секунду) против 75 с у `COPY` в таблицы без триггеров; запрос по целям в конце — 0,1 с при 1 000 целей и 1,2 с при 100 000 |
|
|
2662
|
+
| Горячая строка: операция и `inc` итога одной транзакцией | `core-concurrency`, замер 11 | одно соединение — около 150 операций в секунду, 5 — 325, 20 — 230 (95-й процентиль — 290 мс), 50 — 154 (около 1 с); итог через `{ rev }` с повторами при 20 соединениях — 72 (23 тыс. повторов), через блокировку строки — 144 |
|
|
2663
|
+
| Двадцать писателей строк со ссылкой на одну цель | `core-concurrency`, замер 2; `core-notify` | с `NOTIFY` в транзакции записи — около 580 строк в секунду, без него — 2 900: фиксации с `NOTIFY` идут по одной. Сигналы отдельной транзакцией (так их шлёт библиотека, [«Сигналы»](#сигналы)) — 2 800 |
|
|
2664
|
+
| Чтение цепочкой из трёх шагов с фильтром по `data` под RLS: два арендатора, у каждого 1 000 организаций, 100 тыс. мастеров и миллион смен | `chain-read` | узкий запрос (143 строки) — 24 мс, владелец без RLS — 19 мс; широкий (14 285 строк) — 3,1 с против 2,9 с |
|
|
2665
|
+
| Срез `asOf` по журналу | `core-read`, замер 7 | 51 мс под RLS, 50 мс у владельца |
|
|
2666
|
+
| Каскадное удаление организации с мастерами одним `delete` | `core-delete` | 100 тыс. строк — 23 с, миллион — 247 с (около 240 мкс на строку); временные файлы — 80 и 800 МБ |
|
|
2667
|
+
| id v5 против последовательных | `core-ids`, 10 млн строк | v5 ложатся в индекс вразброс: вставка в 3,4 раза медленнее (135 тыс. строк в секунду против 465 тыс.), индекс на 27 % и WAL на 39 % больше; вычисление v5 в базе — 11 мкс |
|
|
2668
|
+
| Проверка данных в базе (jsonpath) | `core-validate`, 100 тыс. строк | 35 мкс на строку у плоского описания из пяти полей, 113 мкс — у вложенного глубины 4; у модели, где регулярных выражений больше 32, — 1,6 мс (PostgreSQL держит в памяти процесса 32 скомпилированных выражения) |
|
|
2520
2669
|
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2670
|
+
Построчная запись дороже простой вставки: основное время — проверки в базе и фиксация с записью на диск. Поэтому
|
|
2671
|
+
подряд идущие `create` склеивает батч ([раздел 8](#батч)), массовую запись ведёт загрузчик, а первая заливка в пустую
|
|
2672
|
+
схему строит индексы, ограничения и триггеры разом в конце ([раздел 9](#9-массовая-загрузка)).
|
|
2524
2673
|
|
|
2525
|
-
##
|
|
2674
|
+
## 17. Ограничения
|
|
2526
2675
|
|
|
2527
|
-
Здесь собрано то, что letopis
|
|
2676
|
+
Здесь собрано то, что letopis 3.0 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
|
|
2528
2677
|
|
|
2529
2678
|
**Платформа**
|
|
2530
2679
|
|
|
@@ -2545,50 +2694,60 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2545
2694
|
|
|
2546
2695
|
**Запись и целостность**
|
|
2547
2696
|
|
|
2548
|
-
- Построчная запись дороже простой вставки: каждую строку проверяют триггеры (данные, концы, id, журнал,
|
|
2549
|
-
|
|
2550
|
-
загрузчик.
|
|
2551
|
-
-
|
|
2552
|
-
|
|
2697
|
+
- Построчная запись дороже простой вставки: каждую строку проверяют триггеры (данные, концы, id, журнал, RLS).
|
|
2698
|
+
Построчная вставка через триггеры, транзакция на строку, — около 170 строк в секунду
|
|
2699
|
+
([раздел 16](#16-производительность)); подряд идущие `create` склеивает батч, для массовой записи есть загрузчик.
|
|
2700
|
+
- Удаление работает только в `read committed` (`isolation_level`); транзакции библиотеки всегда открываются на этом
|
|
2701
|
+
уровне.
|
|
2553
2702
|
- `COPY` в таблицы под RLS PostgreSQL не выполняет; массовая загрузка — только загрузчиком на администраторском
|
|
2554
2703
|
подключении.
|
|
2555
2704
|
- Первая заливка закрывает таблицы схемы на всё время загрузки — и для записи, и для чтения: она для пустой схемы
|
|
2556
|
-
(засевка,
|
|
2705
|
+
(засевка, перенос данных), а не для работающей системы. Выигрыш у неё — в базе (индексы и ограничения строятся
|
|
2557
2706
|
один раз); подготовку строк в загрузчике она не ускоряет, а только ведёт одновременно с `COPY`, поэтому на миллионе
|
|
2558
2707
|
объектов она быстрее обычной загрузки примерно в 2,2 раза.
|
|
2559
2708
|
- Загрузчику база доверяет: данные, концы и id загруженных строк проверяет код библиотеки (валидатор на TypeScript),
|
|
2560
|
-
а не триггеры; база сверяет только
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
веб-приложение: SQL-инъекция в нём получила бы права владельца.
|
|
2564
|
-
- Владелец схемы и суперпользователь могут отключить триггеры и изменить что
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
- В базе проверяется подмножество JSON Schema: `refine
|
|
2568
|
-
|
|
2709
|
+
а не триггеры; база сверяет только шаблоны `pattern` — запросом на пачку до записи — и цели вне загрузки,
|
|
2710
|
+
арендаторов и владельцев одним запросом в конце.
|
|
2711
|
+
Защита — загрузчик только у администратора и дифференциальные тесты валидатора против базы. Администраторское
|
|
2712
|
+
подключение держит отдельный процесс загрузки, а не веб-приложение: SQL-инъекция в нём получила бы права владельца.
|
|
2713
|
+
- Владелец схемы и суперпользователь могут отключить триггеры и изменить что угодно, в том числе журнал: letopis
|
|
2714
|
+
этого не предотвращает и не обнаруживает. Журналу можно верить, пока роль владельца и суперпользователь — вне
|
|
2715
|
+
приложения.
|
|
2716
|
+
- В базе проверяется подмножество JSON Schema: `refine` и `superRefine` Zod база не исполняет (они в `lost` у
|
|
2717
|
+
`syncModels`), преобразования Zod (`.trim()`, `.transform()` …) `syncModels` отклоняет, описание с неизвестным
|
|
2718
|
+
ключевым словом база не примет; `pattern` — ограниченное подмножество
|
|
2569
2719
|
регулярных выражений, общее для Zod, валидатора и базы. Вывод `z.toJSONSchema()` меняется между версиями Zod:
|
|
2570
2720
|
в базе хранится уже нормализованная схема, а сухой прогон `syncModels` показывает разницу до применения.
|
|
2571
2721
|
- Ключ — единственный способ уникальности: id вычисляется из значений ключа без нормализации, поэтому одно
|
|
2572
2722
|
значение в разной записи (email в разном регистре, время с миллисекундами и без) даёт разные id. Формат
|
|
2573
2723
|
ключевого поля задаёт модель, и база его проверяет.
|
|
2574
|
-
-
|
|
2575
|
-
в отдельный хаб со связью: id из email подбирается перебором и остаётся в ссылках и
|
|
2576
|
-
|
|
2724
|
+
- Ключ и класс объекта неизменны: смена значения ключа (email, телефон) — новый объект. Изменчивые и персональные
|
|
2725
|
+
значения лучше выносить в отдельный хаб со связью: id из email подбирается перебором и остаётся в ссылках и
|
|
2726
|
+
журнале, а версии журнала не стираются (кроме удаления аккаунта вместе с его арендатором).
|
|
2727
|
+
- `create()` по ключу удалённого объекта занимает его id: прежний объект после этого не восстановить
|
|
2728
|
+
(`restore({ expect })` — `not_deleted`, `write.restore_id_taken`, пока новый объект жив; когда удалён и он, —
|
|
2729
|
+
`conflict`, `write.restore_stale`). Что ключ принадлежит удалённому, показывает `withDeleted()` перед `create()`.
|
|
2730
|
+
- `restore()` не проверяет бизнес-правила приложения, только описание класса, концы, ключ и права.
|
|
2577
2731
|
- `create` занятого ключа отвечает `exists` с id, `upsert` без права на существующую строку — отказом `42501`:
|
|
2578
2732
|
так раскрывается, что ключ занят, но только внутри своего арендатора — у других арендаторов те же ключи дают
|
|
2579
2733
|
другие id.
|
|
2580
|
-
-
|
|
2581
|
-
поэтому её цена пропорциональна числу ссылок. Ужесточение описания класса и `rekeyClass` на время проверки или
|
|
2582
|
-
переноса приостанавливают запись в семейство.
|
|
2583
|
-
- `rekeyClass` переносит строки одной транзакцией под исключительной блокировкой семейства, а не пачками:
|
|
2584
|
-
между пачками строки со старыми id нарушали бы правило «id — v5 от ключа».
|
|
2734
|
+
- Ужесточение описания класса на время проверки строк приостанавливает запись в семейство.
|
|
2585
2735
|
- id v5 ложатся в индекс первичного ключа вразброс; на очень больших таблицах с интенсивной вставкой это заметнее,
|
|
2586
2736
|
чем с последовательными id.
|
|
2587
2737
|
- Горячие цели ссылок (организация, на которую ссылается почти всё) копят блокировки `for key share` и MultiXact,
|
|
2588
|
-
как при обычных внешних ключах; это нужно мониторить.
|
|
2589
|
-
блокировкой его строки ([раздел 8](#8
|
|
2590
|
-
-
|
|
2591
|
-
|
|
2738
|
+
как при обычных внешних ключах; это нужно мониторить. Приращения и правки одного объекта идут друг за другом под
|
|
2739
|
+
блокировкой его строки ([раздел 8](#8-транзакции-и-батчи), «Значение из прочитанного и повтор операции»).
|
|
2740
|
+
- Класс не может называться зарезервированным именем ([«Зарезервированные имена»](#зарезервированные-имена)): хаб
|
|
2741
|
+
отклоняют `hub()`, `syncModels` и база, связь — база. Хаб, заведённый мимо проверки базы, доступен не везде:
|
|
2742
|
+
например, `count` — только первым шагом.
|
|
2743
|
+
- Строгие типы есть только у подключения с моделями (`TypedDb`): `db.as()`, `db.begin()`, `db.batch()`, `entity()` и
|
|
2744
|
+
`loose(db)` отдают цепочки без них. Неизвестное поле в фильтре, `sort()` или агрегате у них ловит проверка во время
|
|
2745
|
+
выполнения — `invalid_query` до запроса ([«Строгий режим»](#строгий-режим)), но только по первому ключу пути: опечатку
|
|
2746
|
+
внутри поля-объекта (`{ address: { ctiy } }`) не ловит ничто, условие просто ничего не найдёт.
|
|
2747
|
+
- Поля сверяются с описаниями классов в реестре подключения. Поле, которое другой процесс только что добавил в
|
|
2748
|
+
описание, до обновления реестра (сигнал изменения класса или `db.refresh()`) — тоже `invalid_query`, как и новый
|
|
2749
|
+
класс. Срезы `asOf()`, `withDeleted()` и `versions()` сверяются с нынешним описанием: поле, которое из описания
|
|
2750
|
+
убрали, в фильтре старых версий — отказ.
|
|
2592
2751
|
|
|
2593
2752
|
**Доступ**
|
|
2594
2753
|
|
|
@@ -2605,9 +2764,9 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2605
2764
|
- Самостоятельная привязка занятого идентификатора отвечает `acl_denied` — пользователь с недавним входом так
|
|
2606
2765
|
узнаёт, что идентификатор занят. Изменения прав во всех арендаторах упорядочены одной строкой счётчика.
|
|
2607
2766
|
- Тексты отказов называют класс, роль и id ссылающейся строки, только если актор её видит: каскад удаления, снятие
|
|
2608
|
-
конца,
|
|
2609
|
-
|
|
2610
|
-
- Ужесточение описания класса
|
|
2767
|
+
конца, конфликт смены `parent` (`tightening_conflict`). Число невидимых строк, мешающих удалению, и существование
|
|
2768
|
+
аккаунта по id (как цели ссылки) не скрываются.
|
|
2769
|
+
- Ужесточение описания класса проверяет все строки семейства, поэтому требует права `READ` без условий
|
|
2611
2770
|
на все строки класса и его потомков: иначе пробное ужесточение (`maximum`, `enum`, `pattern`) отвечало бы числом
|
|
2612
2771
|
невидимых строк, которые ему не соответствуют. Системный администратор, ужесточая класс System, получает число
|
|
2613
2772
|
нарушающих строк других арендаторов (`others`): это уровень установки.
|
|
@@ -2615,26 +2774,24 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2615
2774
|
- Прикладная роль может замедлить установку для всех: `LOCK TABLE entity`, advisory-блокировки с ключами `lock()`
|
|
2616
2775
|
приложения, поддельные сигналы `NOTIFY` (лишь сбрасывают кэш), частая смена прав (одна строка счётчика на
|
|
2617
2776
|
установку). По глобальному номеру журнала видна общая активность записи. Внутренние блокировки — семейства
|
|
2618
|
-
|
|
2777
|
+
классов и обслуживания — строки таблицы `secret`, а не advisory: ни прикладная, ни посторонняя роль базы
|
|
2619
2778
|
их не займёт. Писатель семейства проходит шлюз ужесточения в подтранзакции — одной на первую запись в семейство за
|
|
2620
2779
|
транзакцию.
|
|
2621
2780
|
|
|
2622
2781
|
**История, сигналы, кэш**
|
|
2623
2782
|
|
|
2624
|
-
- Срез `asOf` восстанавливается из журнала; дёшево только текущее состояние.
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
- Журнал без партиций: прореживание удаляет версии пачками, файл таблицы сам не уменьшается (место занимают новые
|
|
2630
|
-
версии после autovacuum); вернуть место системе — `VACUUM FULL` или `pg_repack` в окно обслуживания.
|
|
2783
|
+
- Срез `asOf` восстанавливается из журнала; дёшево только текущее состояние.
|
|
2784
|
+
- Журнал без партиций и хранит каждую версию: прореживания, обрезки и стирания истории объекта нет, версии стирает
|
|
2785
|
+
только удаление аккаунта вместе с его арендатором. Классу, чья история не нужна, — `history: false`.
|
|
2786
|
+
Место удалённых версий файл таблицы сам не отдаёт (его занимают новые версии после autovacuum); вернуть место
|
|
2787
|
+
системе — `VACUUM FULL` или `pg_repack` в окно обслуживания.
|
|
2631
2788
|
- Сигналы раскрывают активность: слушать канал PostgreSQL разрешает любой роли. В режиме `class` видно, когда и в
|
|
2632
2789
|
какие классы пишут; `tenant` добавляет псевдонимы арендаторов; `opaque` прячет, что записано, но не когда: время и
|
|
2633
2790
|
частоту записи по псевдониму видно, а детерминированный токен — основа частотных атак. Время записи видно и без
|
|
2634
2791
|
сигналов: `pg_stat_all_tables` и `pg_locks` открыты для PUBLIC. Режим `none` убирает сигналы, но тогда отзыв
|
|
2635
2792
|
сессии действует в кэше других процессов только по сроку, а новый класс появляется в их реестре в течение
|
|
2636
2793
|
5 секунд. Настоящая защита от посторонних ролей — отдельная база. Поддельный сигнал лишь сбросит кэш лишний раз:
|
|
2637
|
-
по
|
|
2794
|
+
по незнакомым токенам и нагрузке не своего режима реестр перечитывается не чаще раза в 5 секунд, а поддельный сигнал изменения классов
|
|
2638
2795
|
перечитывает его, как настоящий, но не больше одного перечитывания одновременно.
|
|
2639
2796
|
- Режим сигналов меняется `up({ signals })`, и после этого нужно перезапустить процессы: режим и ключ сигналов
|
|
2640
2797
|
процесс читает при подключении (нагрузку другого режима он считает чужой и сбрасывает весь кэш).
|
|
@@ -2656,47 +2813,6 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2656
2813
|
- Курсор подписки «с этого момента» несёт снимок момента подписки, пока не пройдёт его границу: такой
|
|
2657
2814
|
курсор длиннее обычного.
|
|
2658
2815
|
|
|
2659
|
-
**Проекторы**
|
|
2660
|
-
|
|
2661
|
-
- Проектор — процесс администратора: он работает только на администраторском подключении, читает журнал всех
|
|
2662
|
-
арендаторов в обход RLS и пишет результаты от роли владельца. Держите его в отдельном процессе, как загрузчик.
|
|
2663
|
-
Участник проектора — арендатор, у которого все классы `into` помечены его `derivedBy`; пометить класс может только
|
|
2664
|
-
администратор (право `letopis.projector`).
|
|
2665
|
-
- Итоги производного класса видит каждый, кому правила арендатора дают `READ` на него, — даже тот, кто не читает
|
|
2666
|
-
строки источников: решайте, кому его читать, правилами на сам производный класс. С `history: true` его журнал
|
|
2667
|
-
показывает изменение каждой пачки, то есть и вклад отдельных строк источников.
|
|
2668
|
-
- Строки производных классов пишет только их проектор: приложение, сырой SQL и загрузчик получают `derived_class`, а
|
|
2669
|
-
ссылаться на такие строки концами нельзя.
|
|
2670
|
-
- Атомарная перестройка — одна долгая пишущая транзакция: пока она идёт, горизонт подписок стоит, и события
|
|
2671
|
-
задерживаются у подписчиков и проекторов всех процессов. На машине замеров перестройка 75 тысяч строк шла 13 с, и
|
|
2672
|
-
до её конца подписчик не получил ни одного события. Поэтому сам `run()` перестраивает пачками, а атомарную
|
|
2673
|
-
перестройку запускает только явный `rebuild()`.
|
|
2674
|
-
- Перестройка пачками держит горизонт только на время пачки (в тех же замерах события задерживались до 0,2 с), но
|
|
2675
|
-
до её конца производные данные неполные: строки удалены сразу и появляются по мере перестройки.
|
|
2676
|
-
- Проектор видит только изменения из журнала, поэтому класс с `history: false` источником быть не может: арендатор
|
|
2677
|
-
с таким источником выбывает из проектора (`invalid_class`), пока журнал класса не включат и арендатора не
|
|
2678
|
-
перестроят.
|
|
2679
|
-
- Прореживание журнала старше яруса `all` ведёт к перестройке: проектор, отставший больше чем на этот ярус,
|
|
2680
|
-
получает `cursor_expired` или, с `onExpired: 'rebuild'`, перестраивается сам. Ярус — по арендатору: истёк только у
|
|
2681
|
-
части участников — выбывают и перестраиваются только они.
|
|
2682
|
-
- Проектор работает только на схеме той ревизии движка, которую ждёт библиотека; на схеме другой ревизии он
|
|
2683
|
-
отвечает `invalid_data`.
|
|
2684
|
-
- `purge()` ждёт проекторов: пока проектор, чьи источники включают класс объекта, а у арендатора объекта есть его
|
|
2685
|
-
производный класс, не прочёл удаление, `purge` отвечает `projector_pending`. Проектор, который больше не запускают,
|
|
2686
|
-
держит `purge` своих источников, пока у арендатора есть его производные классы: запустите его или удалите их.
|
|
2687
|
-
Так же ждёт `trimHistory`: пока проектор не прочёл первую оставшуюся версию, обрезка отвечает `projector_pending`.
|
|
2688
|
-
|
|
2689
|
-
**Агрегаты в базе**
|
|
2690
|
-
|
|
2691
|
-
- Агрегат выстраивает писателей одного объекта в очередь: каждая операция блокирует строку цели и пишет её версию.
|
|
2692
|
-
На машине замеров 20 писателей одного кошелька с агрегатом делали 152 операции в секунду (p95 — 0,47 с), без
|
|
2693
|
-
агрегата — 1 987. Для горячих счётчиков берите проектор со свёрткой пачкой.
|
|
2694
|
-
- `reclass` и `rekey` копят вклады до конца переноса, а каскад удаления и загрузчик применяют их по операторам:
|
|
2695
|
-
правило класса проверяется на итоге каждого оператора каскада и каждой пачки загрузчика. Поэтому загрузка, где
|
|
2696
|
-
списания идут пачкой раньше пополнений, получит `invalid_data`, хотя итог всей загрузки допустим. Каждая пачка,
|
|
2697
|
-
задевшая цель, пишет её версию.
|
|
2698
|
-
- Агрегат арендатора не считает строки класса System, а поле строки System считает только источники System.
|
|
2699
|
-
|
|
2700
2816
|
**Агенты и MCP**
|
|
2701
2817
|
|
|
2702
2818
|
- Обёртка `<untrusted-data-…>` — смягчение, а не защита: модель может последовать тексту внутри неё. Защищают права
|
|
@@ -2710,19 +2826,11 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2710
2826
|
удаление по нему отклоняется (`confirmation_required`). Удаляйте по stdio или в ревизии 2026-07-28.
|
|
2711
2827
|
- Подтверждение в ревизии 2026-07-28 подписано ключом процесса: повтор вызова, попавший в другой процесс приложения
|
|
2712
2828
|
(несколько экземпляров за балансировщиком), получает отказ `confirmation_required`.
|
|
2713
|
-
- `
|
|
2714
|
-
`more`; более
|
|
2715
|
-
mode), в пакет не входит.
|
|
2716
|
-
|
|
2717
|
-
**Импорт из 0.21**
|
|
2829
|
+
- `letopis_history` отдаёт последние версии в пределах `maxRows` и `maxBytes` (из базы — не больше `maxRows` + 1
|
|
2830
|
+
версий) и `more`; состояние на более ранний момент — `letopis_query` с `asOf`. Режим, в котором агент пишет код
|
|
2831
|
+
против API (code mode), в пакет не входит.
|
|
2718
2832
|
|
|
2719
|
-
|
|
2720
|
-
и `reclass` ведут себя по-новому, данные переносятся импортом ([MIGRATION.md](MIGRATION.md)).
|
|
2721
|
-
- Импорт: шаги (классы, аккаунты, креды, права, данные каждого арендатора) — отдельные транзакции, источник
|
|
2722
|
-
читается в память целиком; владелец строки, не найденный среди аккаунтов, становится арендатором; правила доступа
|
|
2723
|
-
0.21 переносятся в System и действуют во всех арендаторах; `onDelete` концов — `cascade`, как удаляла 0.21.
|
|
2724
|
-
|
|
2725
|
-
## 19. Тесты
|
|
2833
|
+
## 18. Тесты
|
|
2726
2834
|
|
|
2727
2835
|
Раздел — для тех, кто развивает сам letopis. Запуск: `cd lib && npm test`. Обвязка сама поднимает контейнер `letopis-pg18` (`postgres:18`,
|
|
2728
2836
|
порт 15433), если база не отвечает; каждый тестовый файл работает в своей схеме `v2.t_<имя>`.
|
|
@@ -2734,67 +2842,81 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2734
2842
|
исправления называется пунктом тест-плана («план 2.0, 7.3 №1») и без исправления падает.
|
|
2735
2843
|
|
|
2736
2844
|
```
|
|
2737
|
-
# connect — подключение под сессией, отказ ролям в обход RLS, реестр, отложенный сигнал, перевод ошибок (этап 2)
|
|
2845
|
+
# connect — подключение под сессией, отказ ролям в обход RLS, реестр, отложенный сигнал, перевод ошибок (этап 2); сессия, истёкшая по простою: по ключу — обмен заново с прежним арендатором, по токену — no_session вместо пустого чтения (ревью сервисов)
|
|
2738
2846
|
# context — сессии, вход сервиса по ключу, политики RLS этапа 1 (этап 1)
|
|
2739
2847
|
# access-rest — оставшиеся строки этапа 5: загрузка от имени пользователя, баланс №20, 22, 29, upsert и { rev } под ACL, встраивание acl_ok (этап 5)
|
|
2740
|
-
#
|
|
2741
|
-
# acl —
|
|
2742
|
-
# acl-parity — 1000 случайных наборов правил: план прав и решения по эндпоинтам базы против decide
|
|
2848
|
+
# acl — права ACL: решение одним правилом, условия на строки, вес и запрет по умолчанию, каскад, запрет на потомке, deep, невидимая цель, сырой SQL против библиотеки; смена parent и alias класса — только с правами на все его строки, алиас — не имя другого класса, имя — не чужой алиас (этап 5; ревью безопасности №2)
|
|
2849
|
+
# acl-grant — обёртка прав db.acl: grant, deny, revoke, roles и list идемпотентны, их соблюдает база поверх «можно всё», при равном весе запрет сильнее, условие $actor, api, транзакция db.begin(), invalid_data до записи (план 2.0, 7.9 №1); everyone — правила для всех аккаунтов, revoke({ everyone: true, … }) снимает «можно всё»; list() на администраторском подключении — только арендатор сессии, enabled; администраторское подключение пишет правила арендатора без «можно всё» — без target_not_found (агентский прогон, этап 3, п. 9); check() — отказ правила сида «запрет по умолчанию»
|
|
2850
|
+
# acl-parity — 1000 случайных наборов правил: план прав и решения по эндпоинтам базы против эталона decide из lib/test/oracle (этап 5)
|
|
2743
2851
|
# auth — креды (пароль, ключ, ключ + секрет, OTP, TOTP, identity), выравнивание времени, регистр, гашение сессий, недавний вход (этап 5)
|
|
2744
2852
|
# balance — пополнение и списание: идемпотентность по opId, гонки, взаимная блокировка, обрыв связи (TCP-прокси), корректировка под { rev } и forUpdate (этап 4)
|
|
2745
|
-
# chain-api —
|
|
2853
|
+
# chain-api — зарезервированные имена, ошибки построения, кэш плана, onQuery (этап 3)
|
|
2746
2854
|
# chain-read — цепочки чтения на демо-домене: переходы, роли, множественные концы, 18 операторов, keyset, агрегаты, deep, asOf, withDeleted, pivot (этап 3)
|
|
2747
|
-
# chain-types — типы
|
|
2748
|
-
# chain-write — запись цепочками: create, update, upsert, delete с превью,
|
|
2855
|
+
# chain-types — строгие типы цепочек по моделям: connect({ models }) и connect<Models>() (этапы 3, 4); шаг — только класс или алиас моделей и системных классов, переходы по концам, слоты ролей класса, поля фильтров, or() и агрегатов, loose(db) (план 2.0, 7.10 №1); строгие типы из файла letopis types (7.10 №6)
|
|
2856
|
+
# chain-write — запись цепочками: create, update, upsert, delete с превью, слоты, inc, { rev }, fan-out, откат плана (этап 4); update() и delete({ confirm: true }) без фильтра — invalid_query до записи, с { all: true }, фильтром, id, tags(), owner() или limit() — проходят: цепочка, батч, db.begin() (план 2.0, 7.10 №2)
|
|
2749
2857
|
# demo — демо-домен booking на Zod: сид из моделей, запись данных, id по ключу, согласие Zod и базы (этап 2)
|
|
2750
|
-
# docs — примеры документации исполняются: помеченные блоки README
|
|
2751
|
-
# engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными исходами; ключ сигналов без права, заливка поддельными сигналами, в том числе Class;
|
|
2752
|
-
# engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение, метаданные; класс с историей строк
|
|
2753
|
-
# engine-write — конвейер записи сырым SQL: id v5, умолчания, merge, inc, удаление, воскрешение,
|
|
2858
|
+
# docs — примеры документации исполняются: помеченные блоки README, шпаргалки и корневого README — сценариями против PostgreSQL 18 (этап 8)
|
|
2859
|
+
# engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными исходами; ключ сигналов без права, заливка поддельными сигналами, в том числе Class; агент через MCP без --write и без подтверждения человека; смена parent и alias класса с одним правом WRITE на Class, удаление и пересоздание класса с историей строк, пробное ужесточение как оракул, выключение журнала класса (history) с одним правом WRITE на Class; смена класса — immutable_key; агент через MCP выдаёт членство или правило, сессия системного администратора; прикладная роль держит advisory-блокировки с прежними ключами внутренних блокировок, а строки-замки ей недоступны; подделанная справка о взятых блокировках семейства (этапы 1, 3; план 1.1, этапы 2, 5, точка А, ревью безопасности №2, точка Б и доработки после неё, ревью безопасности №3)
|
|
2860
|
+
# engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение, метаданные; класс с историей строк не удаляется, класс без журнала — удаляется (этап 1; доработки после точки Б)
|
|
2861
|
+
# engine-write — конвейер записи сырым SQL: id v5, умолчания, merge, inc, удаление, воскрешение, журнал без пропусков номеров версий, сигналы; класс строки неизменен — immutable_key (этап 1; план 2.0)
|
|
2754
2862
|
# engine-races — гонки на двух соединениях в обоих порядках без пауз по времени; ужесточение во время непрерывной записи семейства не голодает; блокировка семейств, чьи корни отличаются только регистром, и подложенная строка вида LOCK (этап 1; ревью безопасности №3)
|
|
2755
2863
|
# explain — индексы цепочек от роли приложения: снаружи первичный ключ и (class, tenant), внутри find_*() — GIN; срез asOf по классу не читает журнал другого арендатора (этап 3; доработки после точки Б)
|
|
2756
2864
|
# find-ids-parity — 30 цепочек через find_*() и прямым чтением под RLS дают одинаковые ответы; свои членства в других арендаторах и строки Class System — сейчас, asOf и withDeleted (этап 3; доработки после точки Б)
|
|
2757
|
-
# hash — цепочка хэшей версий, подмена и разрыв журнала, verify() (этап 1)
|
|
2758
2865
|
# harness — тестовая обвязка: подъём базы, схема на файл, две строки подключения (этап 0)
|
|
2759
2866
|
# jsonschema — компиляция JSON Schema в jsonpath и проверка данных в базе (этап 1)
|
|
2760
|
-
# impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б); реестр фасада не общий с корнем, пустой реестр не кэшируется (хвост ревью безопасности №3)
|
|
2867
|
+
# impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б); реестр фасада не общий с корнем, пустой реестр не кэшируется (хвост ревью безопасности №3); auth.switch при имперсонации — LetopisError acl_denied из каталога, а не сырая ошибка LT000 (план 2.0, 7.5 №3)
|
|
2761
2868
|
# install — установщик и роли: повторная установка, две установки в базе, три таблицы (этап 1)
|
|
2762
2869
|
# member — членство: работа в арендаторе по ролям, auth.switch, выход с переходом владения, системные строки, роли арендатора без прав System (этап 5)
|
|
2763
|
-
# model — модели на Zod, нормализатор JSON Schema, перевод шаблонов, совпадение с метасхемой базы (этап 2)
|
|
2870
|
+
# model — модели на Zod, нормализатор JSON Schema, перевод шаблонов, совпадение с метасхемой базы (этап 2); format email длиннее 254 символов — нарушение без шаблона (ревью)
|
|
2764
2871
|
# load — загрузчик: несколько классов, циклы, $key и $ext, повторы, existing, порции, режим истории, запрос по целям вне загрузки, CLI (этап 4)
|
|
2765
|
-
# roles — роли и права: установка без суперпользователя, функции владельца, триггеры по каталогу, журнал
|
|
2872
|
+
# roles — роли и права: установка без суперпользователя, функции владельца, триггеры по каталогу, журнал на чтение всеми колонками; up() от такого установщика — ANALYZE и fresh от роли владельца (этап 1; план 1.1, точка А; ревью)
|
|
2766
2873
|
# write-races — гонки записи: 20 параллельных правок разных полей без потерь и строгий rev, параллельные add/remove множественного конца, загрузчик против ужесточения класса и против параллельной правки (existing: update) в обоих порядках (этап 4)
|
|
2767
|
-
# validator-parity — валидатор на TypeScript против проверки в базе: официальный набор, 10 000 случайных документов, умолчания (этап 2)
|
|
2768
|
-
# scenario-barbershop — сквозной сценарий барбершопа: 9 сцен
|
|
2769
|
-
# scenario-real-life — «реальная жизнь» салона: 16 сцен
|
|
2770
|
-
# system-classes — системные
|
|
2771
|
-
# sync — letopis sync (сухой прогон, применение, ужесточение, потерянные refine) и letopis types (этап 2)
|
|
2874
|
+
# validator-parity — валидатор на TypeScript против проверки в базе: официальный набор, 10 000 случайных документов, умолчания (этап 2); нарушение базы — ключ каталога и параметры, шаблон с ними — текст базы (план 2.0, этап 3, п. 3)
|
|
2875
|
+
# scenario-barbershop — сквозной сценарий барбершопа: 9 сцен на цепочках — каталог, окна батчем, брони под lock, мульти-слот и мульти-мастер, версии, отмена и бронь заново на тот же id, закрытие месяца при restrict (этап 4)
|
|
2876
|
+
# scenario-real-life — «реальная жизнь» салона: 16 сцен — сервис записи поверх цепочек, гонки двойной брони, переноса и отмены, walk-in, отказы, отчёты и инварианты дня (этап 4)
|
|
2877
|
+
# system-classes — системные классы: сид System, аккаунты, креды, ресурсы и правила, транзакции (этап 5)
|
|
2878
|
+
# sync — letopis sync (сухой прогон, применение, ужесточение, потерянные refine) и letopis types (этап 2); зарезервированное имя хаба — отказ hub(), syncModels и базы (план 2.0, 7.10 №3)
|
|
2879
|
+
# cli — команды CLI (план 2.0, 7.9 №10, ред. 2): коды выхода letopis sync --check; справка, отчёт sync и letopis types, letopis types --system
|
|
2772
2880
|
# tx — явные транзакции db.begin(): сломанная транзакция, lock(), 40P01; forUpdate(); батч и его склейка, очередь батча — своя у фасада; db.as(…, { owner }) (этап 4)
|
|
2773
2881
|
# v5 — идентификаторы v5 на стороне SQL против векторов независимой реализации (этап 1)
|
|
2774
2882
|
# zod-output — снимок вывода z.toJSONSchema() по видам полей демо-домена против эталона (этап 0)
|
|
2775
|
-
# time — история: versions
|
|
2776
|
-
# rekey — новое значение ключа объекта и новый ключ класса: новые id, перевод ссылок, рекурсия по связям с концом в ключе (этап 6)
|
|
2777
|
-
# reset — сброс sessions, tenant, all: флаг allowReset, право letopis.reset, подтверждение именем схемы, запись reset, новый ключ сервиса (этап 6)
|
|
2778
|
-
# verify — законные разрывы и подмены, verify({ data: true }): данные, концы, арендаторы, id по ключу; права и CLI (этап 6)
|
|
2779
|
-
# retention — политика хранения: ярусы, конечный срок, особые версии, удалённые, asOf, cursor_expired, часовой пояс, догоняющий запуск, наследование и умолчание — против эталона на TypeScript (этап 6)
|
|
2883
|
+
# time — история: versions, withDeleted, restore, удаление аккаунта (этап 6)
|
|
2780
2884
|
# watch — подписка по курсору: порядок фиксации, долгая транзакция, возобновление, обрыв слушателя, два арендатора (этап 6)
|
|
2781
|
-
# cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков
|
|
2885
|
+
# cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков сигналов, гашения чужих сессий не растят счётчики (этап 6; план 1.1, точка А; план 2.0, 7.7)
|
|
2782
2886
|
# maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6), строки-замки удалённых классов
|
|
2783
|
-
# up — полный установщик: свежая установка, повторный накат, свои сиды, автосоздание базы, UTF8, версия 17,
|
|
2784
|
-
#
|
|
2785
|
-
#
|
|
2786
|
-
#
|
|
2787
|
-
#
|
|
2788
|
-
#
|
|
2789
|
-
#
|
|
2790
|
-
#
|
|
2791
|
-
# mcp — инструменты MCP: модель и meta только в результатах, права арендатора, JSON-цепочка и подсказки похожих имён, предел строк и байт с курсором, обёртка недоверенных данных, запись только с write, update только с rev, удаление с подтверждением человека (elicitation в ревизиях 2025 и 2026-07-28), причина в blame, учётные данные, схемы инструментов, letopis без SDK; letopis mcp по stdio, обработчик HTTP; окно подтверждения без алиаса и meta, countBy и versions с пределом в самом запросе, предел шагов и условий $or; классы прав — только с allowAclWrite, сессия в арендаторе System — только с allowSystemAdmin; structuredContent с пояснением, подпись requestState пользователя сервера со сроком (план 1.1, этап 5; ревью безопасности №2; точка Б и доработки после неё)
|
|
2792
|
-
# import — импорт из 0.21 по фикстуре настоящей 0.21: нарушение ключа, --keyless, счётчики по классам, verify, самоссылка, повтор, id без ключа, висячая ссылка, $$strict и поля на $, update, креды, CLI (этап 7)
|
|
2793
|
-
# security-final — итоговое ревью безопасности (этап 8, §7.10 №8.4): блокировка семейства по арендатору корня, account_purge без права и restore/purge чужого id не дают ни утечки, ни блокировки; функции этапа 6, log_refs_at, срезы find_* по журналу и watch_read не выходят за свой арендатор; NOTIFY несёт только имена или токены режима; ключ кэша различает токен, актора и арендатора (этап 8)
|
|
2887
|
+
# up — полный установщик: свежая установка, повторный накат, свои сиды, автосоздание базы, UTF8, версия 17, накат миграций, PGlite, первый арендатор и пользователь (up({ tenant }), createTenant); ошибки, ход установки и предупреждение штампа — тексты каталога (раздел boot); установка без ADMIN OPTION на готовую роль владельца — admin_required (7.5 №3); схема ревизии ниже 8 или без штампа — отказ up() и connect() до любых изменений с подсказкой up({ fresh: true }), fresh проходит (план 2.0, 7.10 №5); хаб с зарезервированным именем, заведённый мимо проверки базы, переживает up() с предупреждением, правку его описания база отклоняет (7.10 №3) (этапы 6, 8)
|
|
2888
|
+
# scenario-salon — сквозной сценарий салона: 20 актов с матрицей покрытия API (этап 6)
|
|
2889
|
+
# telemetry — onQuery и slowMs (этап 6)
|
|
2890
|
+
# history — причина изменения в версиях журнала: у глаголов, db.begin() и батча, причина батча — с фасада, поставившего глагол (план 1.1, этап 1)
|
|
2891
|
+
# signals — режимы сигналов: нагрузка у посторонней роли, точность кэша до арендатора, System, классы прав, процесс без ключа, загрузчик, подписка, ключ, none, склейка, версия сервера, повтор отправки, режим после повторного up() (план 1.1, этап 2)
|
|
2892
|
+
# initial-load — первая заливка: равенство с обычным загрузчиком, структура и режимы триггеров, системные строки, непустая схема, вставка во время запуска, откат, MULTI_INSERT в WAL, commitEvery (план 1.1, этап 3)
|
|
2893
|
+
# mcp — инструменты MCP: модель и meta только в результатах, права арендатора, JSON-цепочка и подсказки похожих имён, предел строк и байт с курсором, обёртка недоверенных данных, запись только с write, update только с rev, удаление с подтверждением человека (elicitation в ревизиях 2025 и 2026-07-28), причина в версиях, учётные данные, схемы инструментов, letopis без SDK; letopis mcp по stdio, обработчик HTTP; окно подтверждения без алиаса и meta, countBy и versions с пределом в самом запросе, предел шагов и условий $or; классы прав — только с allowAclWrite, сессия в арендаторе System — только с allowSystemAdmin; structuredContent с пояснением, подпись requestState пользователя сервера со сроком; описания инструментов, их параметров и instructions — у каждого есть, по-русски (план 1.1, этап 5; ревью безопасности №2; точка Б и доработки после неё)
|
|
2894
|
+
# security-final — итоговое ревью безопасности (этап 8, §7.10 №8.4): блокировка семейства по арендатору корня, account_purge без права и restore чужого id не дают ни утечки, ни блокировки; функции этапа 6, log_refs_at, срезы find_* по журналу и watch_read не выходят за свой арендатор; NOTIFY несёт только имена или токены режима; ключ кэша различает токен, актора и арендатора (этап 8)
|
|
2794
2895
|
# semver — правила версий: check:semver требует шаг версии не меньше нужного — мажор за исчезнувший метод или код ошибки, минор за новый метод и новую ревизию движка, патча хватает на тексты (план 2.0, 7.8 №1)
|
|
2795
|
-
#
|
|
2796
|
-
#
|
|
2797
|
-
#
|
|
2896
|
+
# one-process — один процесс: up({ login }) квотирует имя и пароль роли приложения, повтор обновляет пароль, привилегированную роль не трогает; connect({ admin }) — второй пул при первой надобности для load, syncModels, maintain с той же сессией, цепочки под RLS, без admin — admin_required (план 2.0, 7.9 №3, №4)
|
|
2897
|
+
# prerelease — проверка перед выпуском: шаги идут строго по очереди, первый красный останавливает прогон, следующие не запускаются; тест-файл, процесс которого упал до первого теста, перезапускается один раз, красный тест — нет; при --only и --skip таблица итогов копится — строки прочих шагов остаются из прежних прогонов (план 2.0, 7.8 №3)
|
|
2898
|
+
# write-contract — обещания README о записи (план 2.0, 7.2): повтор ключа в батче — exists и откат, порядок строк ответа restore, цепочка с глаголом в значении слота, загрузчик с existing: update не правит строку другого класса, ответ из кэша не портится правкой прошлого ответа
|
|
2899
|
+
# watch-contract — обещания README о подписке и подключении (план 2.0, 7.4): молчащая подписка опрашивает журнал раз в pollMs, close() подключения закрывает живые подписки, подписка на класс получает события потомка, заведённого после watch(), реестров db.as() не больше предела
|
|
2798
2900
|
# model-contract — обещания README о моделях, типах, CLI и ошибках (план 2.0, 7.5): letopis types — верный TypeScript для кириллицы, класса без полей и anyOf, полная форма hub() с z.object, понятные ошибки вместо сырых, коды выхода CLI
|
|
2799
|
-
# read-contract — обещания README о чтении и истории (план 2.0, 7.1):
|
|
2800
|
-
|
|
2901
|
+
# read-contract — обещания README о чтении и истории (план 2.0, 7.1): курсор after() в versions() и paths(), имя поля с @S@
|
|
2902
|
+
# messages — каталог сообщений (план 2.0, этап 3, п. 3; 7.9 №6): у каждого кода заголовок и подсказка, hint и docs у каждой ошибки, docs ведёт на якорь кода в README; места библиотеки и ошибки базы — по ключу каталога, key и params, текст как у сырого SQL; нарушения данных из базы (запись, ужесточение класса, загрузчик), внутренняя ошибка internal, повторяемый конфликт с ключом; похожие имена для неизвестного класса, роли, поля и опции; неизвестная опция connect(), up(), load(), watch() и createMcpServer() — invalid_data с похожими (7.10 №4)
|
|
2903
|
+
# mcp-model — описание модели в letopis_model: каждая цепочка examples («создать», «найти», «перейти к соседям») исполняется на демо-схеме booking, letopis_model { class } отдаёт те же примеры, большая модель сначала теряет только их (план 2.0, 7.9 №9); метаданные и наследование, условия anyOf и oneOf (этап 6; план 1.1)
|
|
2904
|
+
# llms — один словарь фильтров (план 2.0, 7.9 №8): реестр операторов совпадает с фабриками пакета, с ключами letopis_query (тот же ответ, что у цепочки) и с таблицами README, шпаргалки и главы «Чтение» сайта
|
|
2905
|
+
# disconnect — обрыв соединения (план 2.0, 7.4 №7): загрузчик и первая заливка, открытие db.begin() — обрыв TCP до и после COMMIT и на открытии транзакции, pg_terminate_backend процесса, ждущего блокировку: промис отклонён ошибкой связи, необработанных ошибок в процессе нет, следующая операция пула проходит
|
|
2906
|
+
```
|
|
2907
|
+
|
|
2908
|
+
## 19. Лицензия
|
|
2909
|
+
|
|
2910
|
+
letopis распространяется под лицензией **PolyForm Noncommercial License 1.0.0** (SPDX `PolyForm-Noncommercial-1.0.0`):
|
|
2911
|
+
исходный код открыт, некоммерческое использование бесплатно, коммерческое — только по отдельной лицензии от
|
|
2912
|
+
владельца. Это не лицензия open source в смысле OSI. Полный текст — [LICENSE](LICENSE) (юридическую силу имеет он).
|
|
2913
|
+
|
|
2914
|
+
- **Без разрешения** — любые некоммерческие цели: личное использование для исследований, экспериментов, учёбы, хобби
|
|
2915
|
+
без коммерческого применения; использование благотворительными и некоммерческими организациями, учебными
|
|
2916
|
+
заведениями, государственными исследовательскими организациями и учреждениями. Код можно менять и распространять —
|
|
2917
|
+
с текстом лицензии и строкой `Required Notice: Copyright 2026 alepri51 (https://github.com/alepri51)`.
|
|
2918
|
+
- **По разрешению владельца** — коммерческое использование: платный продукт или сервис, внутренние системы
|
|
2919
|
+
коммерческой компании, разработка на заказ, встраивание в коммерческий продукт.
|
|
2920
|
+
- **Как получить разрешение** — issue в репозитории <https://github.com/alepri51/letopis/issues> с заголовком
|
|
2921
|
+
«Коммерческая лицензия»; владелец — <https://github.com/alepri51>. Подробнее — [LICENSE-COMMERCIAL.md](LICENSE-COMMERCIAL.md).
|
|
2922
|
+
- Версии до 2.0.0 включительно выпущены под MIT и остаются под ней; PolyForm Noncommercial действует с 2.1.0.
|