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