letopis 0.5.0 → 0.16.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/CHANGELOG.md +269 -0
- package/README.md +2472 -240
- package/dist/acl.d.ts +48 -0
- package/dist/acl.js +208 -0
- package/dist/auth.d.ts +116 -0
- package/dist/auth.js +263 -0
- package/dist/chain.d.ts +81 -28
- package/dist/chain.js +312 -72
- package/dist/index.d.ts +10 -3
- package/dist/index.js +26 -3
- package/dist/schema.js +107 -4
- package/dist/sessions.d.ts +32 -0
- package/dist/sessions.js +48 -0
- package/dist/sql.d.ts +43 -1
- package/dist/sql.js +238 -79
- package/dist/tx.d.ts +1 -1
- package/dist/tx.js +16 -1
- package/dist/types.d.ts +81 -2
- package/dist/up.d.ts +48 -0
- package/dist/up.js +209 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +32 -0
- package/dist/write.d.ts +29 -16
- package/dist/write.js +427 -98
- package/docker/Dockerfile +17 -0
- package/docker/start.sh +4 -0
- package/package.json +16 -2
- package/sql/ddl.sql +404 -0
- package/sql/seed.auth.sql +46 -0
- package/sql/seed.booking.sql +69 -0
package/README.md
CHANGED
|
@@ -6,14 +6,14 @@ Dot-цепочки над append-only Entity-хранилищем (TimescaleDB).
|
|
|
6
6
|
```ts
|
|
7
7
|
import { connect } from 'letopis'
|
|
8
8
|
|
|
9
|
-
const db = await connect({ dsn: 'postgres://…', schema: 'booking' })
|
|
9
|
+
const db = await connect({ dsn: 'postgres://…', schema: 'v1.booking' })
|
|
10
10
|
|
|
11
11
|
// чтение: пути по графу
|
|
12
|
-
const пути = await db.Сотрудник({ name: 'Вася' }).навык().Услуга().
|
|
12
|
+
const пути = await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run()
|
|
13
13
|
// [ { Сотрудник: Row, навык: Row, Услуга: Row }, … ]
|
|
14
14
|
|
|
15
|
-
// запись:
|
|
16
|
-
await db.Организация(org).Сотрудник().
|
|
15
|
+
// запись: операции — звенья, исполняет терминал
|
|
16
|
+
await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Содержание:
|
|
@@ -22,7 +22,7 @@ await db.Организация(org).Сотрудник().set({ name: 'Вася'
|
|
|
22
22
|
[3. Schema](#3-schema-классы-hub--link) ·
|
|
23
23
|
[4. Чтение](#4-чтение-цепочки) ·
|
|
24
24
|
[5. Фильтры и модификаторы](#5-фильтры-и-модификаторы) ·
|
|
25
|
-
[6. Запись](#6
|
|
25
|
+
[6. Запись](#6-запись-операции--звенья-плана) ·
|
|
26
26
|
[7. Транзакции](#7-транзакции) ·
|
|
27
27
|
[8. Батчи](#8-батчи) ·
|
|
28
28
|
[9. Auth-таблицы](#9-служебные-таблицы-authacl) ·
|
|
@@ -38,25 +38,49 @@ await db.Организация(org).Сотрудник().set({ name: 'Вася'
|
|
|
38
38
|
|
|
39
39
|
## 1. Быстрый старт
|
|
40
40
|
|
|
41
|
+
Одна функция — весь путь от нуля: dev-контейнер (TimescaleDB + Redis), готовность,
|
|
42
|
+
версионная схема с сидами, подключение:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { up } from 'letopis'
|
|
46
|
+
|
|
47
|
+
const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v1.booking"
|
|
48
|
+
// [letopis.up] building image letopis-db (first time pulls the timescaledb base — may take minutes)…
|
|
49
|
+
// [letopis.up] container letopis-timescale created (data: volume letopis-pgdata)
|
|
50
|
+
// [letopis.up] postgres ready in 5.0 s
|
|
51
|
+
// [letopis.up] redis ready on 16379
|
|
52
|
+
// [letopis.up] schema "v1.booking" applied (3 files)
|
|
53
|
+
// [letopis.up] connected (schema "v1.booking")
|
|
54
|
+
|
|
55
|
+
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
56
|
+
const [вася] = await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
|
|
57
|
+
await db.close()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Идемпотентно: живой postgres на `dsn` → docker пропускается; существующая схема не трогается.
|
|
61
|
+
Данные PG живут в named volume `letopis-pgdata` (кроссплатформенно, переживает пересоздание
|
|
62
|
+
контейнера); `dataDir: '/path'` — bind mount папки хоста. Полный контракт — [§11.1a](#111a-upopts).
|
|
63
|
+
|
|
64
|
+
Вручную (то же самое по шагам):
|
|
65
|
+
|
|
41
66
|
```bash
|
|
42
|
-
docker
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
67
|
+
docker build -t letopis-db lib/docker
|
|
68
|
+
docker run -d --name letopis-timescale -e POSTGRES_PASSWORD=test -e POSTGRES_DB=clockz \
|
|
69
|
+
-p 15432:5432 -p 16379:6379 -v letopis-pgdata:/var/lib/postgresql/data letopis-db
|
|
70
|
+
node db/apply.mjs --dsn=postgres://postgres:test@localhost:15432/clockz --schema=booking --version=1
|
|
71
|
+
# SQL-шаблоны (lib/sql/) держат маркер <SCHEMA-NAME>; итоговая PG-схема — "v1.booking"
|
|
72
|
+
# = ddl.sql + seed.booking.sql + seed.auth.sql в указанную PG-схему (--no-seed — только ddl)
|
|
46
73
|
```
|
|
47
74
|
|
|
48
75
|
```ts
|
|
49
|
-
const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz', schema: 'booking' })
|
|
50
|
-
const [org] = await db.Организация().set({ name: 'BarberPro' })
|
|
51
|
-
const [вася] = await db.Организация(org).Сотрудник().set({ name: 'Вася', roles: ['master'] })
|
|
52
|
-
await db.close()
|
|
76
|
+
const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz', schema: 'v1.booking' })
|
|
53
77
|
```
|
|
54
78
|
|
|
55
79
|
---
|
|
56
80
|
|
|
57
81
|
## 2. Хранилище
|
|
58
82
|
|
|
59
|
-
`
|
|
83
|
+
`lib/sql/ddl.sql`: `Entity`, `Schema`, `Account`, `Credential`, `Resource`, `Rule` + триггеры.
|
|
60
84
|
**Весь CRUD работает на уровне БД** — голый SQL равносилен либе; либа добавляет строгую
|
|
61
85
|
валидацию данных, цепочки и удобства.
|
|
62
86
|
|
|
@@ -84,15 +108,15 @@ await db.close()
|
|
|
84
108
|
|
|
85
109
|
| SQL | Триггер | Поведение |
|
|
86
110
|
|---|---|---|
|
|
87
|
-
| `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id;
|
|
111
|
+
| `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id; концы LINK по Schema.links v2 (жадный матчинг, союзы, optional, лишние связи — ошибка; legacy-строки — по-старому). Tombstone-вставки не проверяются |
|
|
88
112
|
| `UPDATE` | `entity_update` | физического апдейта нет: вставляется **новая версия** (`updated = GREATEST(clock, prev+1µs)`); не-latest строки игнорируются — история неизменна |
|
|
89
113
|
| `DELETE` | `entity_delete` | вставляется **tombstone** + **рекурсивный каскад**: DELETE живых зависимых (`links ⊃ {класс: id}`) повторяет триггер по дереву; advisory-lock; история/tombstone неприкосновенны (повторный DELETE — no-op) |
|
|
90
114
|
|
|
91
115
|
```sql
|
|
92
116
|
-- голый SQL работает как либа:
|
|
93
|
-
UPDATE "booking"."Entity" SET data = data || '{"duration":45}'
|
|
117
|
+
UPDATE "v1.booking"."Entity" SET data = data || '{"duration":45}'
|
|
94
118
|
WHERE partition='entity' AND class='Service' AND id='…'; -- → новая версия
|
|
95
|
-
DELETE FROM "booking"."Entity"
|
|
119
|
+
DELETE FROM "v1.booking"."Entity"
|
|
96
120
|
WHERE partition='entity' AND class='Booking' AND id='…'; -- → tombstone + каскад
|
|
97
121
|
```
|
|
98
122
|
|
|
@@ -124,13 +148,79 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
124
148
|
| `ancestor` | прямой родитель |
|
|
125
149
|
| `ancestors` | `[self, parent, …, root]` — **считает триггер `schema_lineage`** |
|
|
126
150
|
| `descendants` | все потомки транзитивно — **тот же триггер** |
|
|
127
|
-
| `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; строгая
|
|
128
|
-
| `links` |
|
|
151
|
+
| `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; вложенные `{type:'object', props:{…}}` любой глубины; строгая валидация; собираются по иерархии (§ 3.3); правило `id` — генерация (§ 3.2) |
|
|
152
|
+
| `links` | концы связей класса, формат v2 — массив объектов (см. ниже); legacy-строка `'Org'` = `{class:'Org'}`, `'Entity'` = старый полиморф |
|
|
129
153
|
| `meta` | `{ abstract?, description? }` |
|
|
130
154
|
|
|
155
|
+
### 3.1 Концы связей — Schema.links v2
|
|
156
|
+
|
|
157
|
+
Каждый конец — объект (`Schema.links jsonb` — массив объектов):
|
|
158
|
+
|
|
159
|
+
```jsonc
|
|
160
|
+
"links": [
|
|
161
|
+
{ "class": "Booking", "cardinality": 1 }, // один класс
|
|
162
|
+
{ "classes": ["Service", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
|
|
163
|
+
{ "class": "Staff", "optional": true, "cardinality": 1 } // конец может отсутствовать
|
|
164
|
+
]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `Entity` в концах не используется — классы называются явно («максимально точная идентификация связи»)
|
|
168
|
+
- **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет позиции — `{Услуга|Комплекс}` (заказ услуги ИЛИ заказ комплекса — ровно один из)
|
|
169
|
+
- Обязательный конец без ключа → `requires end "Service|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
|
|
170
|
+
- `cardinality` — зарезервировано (0 — безлимит, N — точное число), пока не проверяется: связь класса в строке одна (`{Класс: id}`), множественность выражается строками-связками
|
|
171
|
+
- Демо-домен: `позиция = [Запись, {Услуга|Комплекс}, Сотрудник?]`, `состав = [Комплекс, Услуга]` (состав комплекса — отдельный LINK), `навык = [Сотрудник, {Услуга|Комплекс}]`, `занятость = [Сотрудник, Окно, Запись?]`, `смена = [Расписание, Сотрудник]`, `Запись = [Клиент?]`
|
|
172
|
+
- Источник правды — `schema.booking.v2.json`; сид генерится: `node lib/scripts/gen-seed.mjs`
|
|
173
|
+
|
|
131
174
|
`schema_lineage` (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
|
|
132
175
|
пересчитывает `ancestors`/`descendants` при любом изменении Schema.
|
|
133
|
-
Из либы: `db.registry.resolve('связь').descendants` → `['busy','item','shift','skill']`.
|
|
176
|
+
Из либы: `db.registry.resolve('связь').descendants` → `['busy','compo','item','shift','skill']`.
|
|
177
|
+
|
|
178
|
+
### 3.2 id считает схема: `attributes.id`
|
|
179
|
+
|
|
180
|
+
Правило рождения id объявляется в `attributes.id` класса (наследуется — § 3.3):
|
|
181
|
+
|
|
182
|
+
| Правило | Версия | Смысл |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `"uuid"` / `{"type":"uuid"}` | **v4** | random — дефолт (как раньше) |
|
|
185
|
+
| `{"type":"uuid","generate":7}` | **v7** | unix-время в старших битах — вставки ложатся в хвост btree-индекса |
|
|
186
|
+
| `{"type":"uuid","generate":5,"from":[…]}` | **v5** | детерминированный: id вычисляется из данных |
|
|
187
|
+
|
|
188
|
+
Формула v5 открыта: `uuidv5("pgSchema:partition:класс:значения from")`, namespace letopis —
|
|
189
|
+
`c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90`. `from` — имена ОБЯЗАТЕЛЬНЫХ концов `Schema.links`
|
|
190
|
+
(класс или полное имя союза `'Service|Complex'`; значение — id конца) и/или скалярных полей
|
|
191
|
+
`data`. Экспорт: `import { uuidv5, uuidv7, LETOPIS_NS } from 'letopis'`.
|
|
192
|
+
|
|
193
|
+
Два свойства v5, ради которых всё:
|
|
194
|
+
|
|
195
|
+
- **create идемпотентен**: та же комбинация → та же сущность (повтор — новая версия, не
|
|
196
|
+
дубль) — даже в гонке двух процессов оба вычислят один id; двойная бронь мертва на
|
|
197
|
+
уровне схемы;
|
|
198
|
+
- **id известен ДО создания**: «занято ли окно» — точечный `first()` по вычисленному id,
|
|
199
|
+
без создания чего-либо.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// занятость: id считается сам — uuidv5(окно, мастер)
|
|
203
|
+
const [б] = await db.Сотрудник(м).занятость().create({ kind: 'booking' }).Окно.set(о).rows()
|
|
204
|
+
б.id === uuidv5(`v1.booking:entity:busy:${о.id}:${м.id}`) // → true
|
|
205
|
+
// «занято?» — ДО создания чего-либо:
|
|
206
|
+
await db.занятость(uuidv5(`v1.booking:entity:busy:${о.id}:${м.id}`)).first() // Row | null
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Явный id у v5-класса запрещён (`computes id` — его всегда считает схема); в батче
|
|
210
|
+
v5-классы не склеиваются в multi-VALUES (id нужны концы) — исполняются поштучно (§ 8).
|
|
211
|
+
|
|
212
|
+
Демо-схема booking: корни `Entity`/`link` объявляют дефолт `{generate: 7}` один раз;
|
|
213
|
+
v7-классы (Org/Staff/Folder/Customer/Schedule/Booking/item) наследуют его без собственного
|
|
214
|
+
правила; v5 переопределяют: `busy ← v5(Slot, Staff)`, `skill ← v5(Staff, Service|Complex)`,
|
|
215
|
+
`shift ← v5(Schedule, Staff)`, `compo ← v5(Complex, Service)`, `Slot ← v5(Schedule,
|
|
216
|
+
data.start)`, `Service`/`Complex` ← `v5(Org, data.name)` — имя уникально в организации.
|
|
217
|
+
|
|
218
|
+
### 3.3 Наследование attributes
|
|
219
|
+
|
|
220
|
+
При загрузке реестра attributes класса собираются по цепочке `ancestor`: **потомок ПОВЕРХ
|
|
221
|
+
предка**, переопределение поля — замена правила ЦЕЛИКОМ (не слияние). Правило `id` (§ 3.2)
|
|
222
|
+
наследуется так же — дефолт объявляется один раз на корне иерархии; `links` НЕ наследуются:
|
|
223
|
+
концы объявляет каждый класс сам. Валидатор и типы полей компилируются из слитых attributes.
|
|
134
224
|
|
|
135
225
|
---
|
|
136
226
|
|
|
@@ -145,23 +235,45 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
145
235
|
| HUB → HUB (разные) | forward, если цель ∈ `Schema.links` текущего; иначе reverse; иначе «no path» |
|
|
146
236
|
| HUB → HUB (тот же класс) | reverse = **дети** (`db.Папка(id).Папка()`); родитель — `row.links.Folder` |
|
|
147
237
|
| LINK → LINK | ошибка |
|
|
238
|
+
| повтор LINK-класса | **pivot**: возврат к тому же узлу (ветвление к другому концу + дофильтр AND) |
|
|
239
|
+
|
|
240
|
+
Путь подчиняется переходам **всегда** — и в чтении, и в записи; недопустимый переход —
|
|
241
|
+
ошибка **синхронно при построении цепочки** (не в терминале). К концу связки, до которого
|
|
242
|
+
прямого HUB→HUB пути нет, идут через саму связку и **pivot** (повтор её имени):
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// «позиции записи з, где услуга у и мастер вася» — путь через позицию, pivot-возврат:
|
|
246
|
+
db.Запись(з).позиция().Услуга(у).позиция().Сотрудник(вася).позиция().rows()
|
|
247
|
+
// «умеет ли Ирина стрижку» — навык, дофильтр предметом, count путей:
|
|
248
|
+
db.Сотрудник(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Узел-переменная: `entity()`
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
const p = db.позиция() // ленивый узел-паттерн
|
|
255
|
+
await db.Запись(з).entity(p).Услуга(у).entity(p).Сотрудник().run() // та же p = тот же узел (явный pivot)
|
|
256
|
+
await db.entity(row).Услуга().first() // старт пути с готового Row
|
|
257
|
+
```
|
|
148
258
|
|
|
149
259
|
### Терминалы
|
|
150
260
|
|
|
151
261
|
```ts
|
|
152
|
-
const пути = await db.Сотрудник({ name:'Вася' }).alias('Мастер').навык().Услуга().
|
|
262
|
+
const пути = await db.Сотрудник({ name:'Вася' }).alias('Мастер').навык().Услуга().run()
|
|
153
263
|
// [{ Мастер: Row, навык: Row, Услуга: Row }, …]
|
|
154
264
|
```
|
|
155
265
|
|
|
156
266
|
| Вызов | Возврат |
|
|
157
267
|
|---|---|
|
|
158
|
-
| `
|
|
268
|
+
| `run()` | `Path[]` — все узлы каждого варианта пути; ключ = имя шага / `.alias()`; повтор → `имя_2` |
|
|
159
269
|
| `rows()` | `Row[]` — уникальные сущности последнего шага |
|
|
160
270
|
| `first()` | `Row \| null` |
|
|
161
271
|
| `ids()` | `string[]` |
|
|
162
272
|
| `count()` | число **путей** |
|
|
163
273
|
|
|
164
|
-
|
|
274
|
+
Цепочка — ленивый ПЛАН: SQL уходит только на терминале. Терминалы без аргументов —
|
|
275
|
+
выборка настраивается модификаторами (§ 5). Если в цепочке есть операции записи (§ 6),
|
|
276
|
+
терминал исполняет весь план одной транзакцией.
|
|
165
277
|
`Row = { id, class, data, links, tags, account, owner, updated, $deleted? }`.
|
|
166
278
|
|
|
167
279
|
---
|
|
@@ -176,7 +288,7 @@ db.Услуга(rowИлиAccount) // объект с id —
|
|
|
176
288
|
db.Услуга(['id1','id2']) // по списку ([] → пусто)
|
|
177
289
|
db.Услуга({ name: 'Стрижка', active: true }) // eq полей data → GIN-containment
|
|
178
290
|
db.Услуга({ id: 'uuid', duration: gte(30) }) // ключ id — тоже id-фильтр
|
|
179
|
-
db.Услуга({ price: { RUB: lte(2000) } }) //
|
|
291
|
+
db.Услуга({ price: { RUB: lte(2000) } }) // вложенные пути ЛЮБОЙ глубины (record/object) + каст по листу
|
|
180
292
|
```
|
|
181
293
|
|
|
182
294
|
### Операторы (18: + `not`, `or`)
|
|
@@ -218,135 +330,179 @@ db.Организация().owner(acc) // фильтр по owne
|
|
|
218
330
|
db.Сотрудник({…}).alias('Мастер') // ключ шага в путях
|
|
219
331
|
```
|
|
220
332
|
|
|
221
|
-
| Модификатор | Область | В чтении | В
|
|
333
|
+
| Модификатор | Область | В чтении | В записи |
|
|
222
334
|
|---|---|---|---|
|
|
223
|
-
| `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей
|
|
335
|
+
| `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей `update()` |
|
|
224
336
|
| `asOf(t)` | вся цепочка | «как было на T» (§ 10.1) | — |
|
|
225
337
|
| `after(cursor)` | вся цепочка | keyset-пагинация, требует `sort` (§ 10.2) | — |
|
|
226
338
|
| `deep(max?)` | текущий шаг (self-hop) | рекурсивные дети, `$depth` (§ 10.4) | — |
|
|
227
|
-
| `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при
|
|
228
|
-
| `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при
|
|
339
|
+
| `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при `create()` (string \| string[]) |
|
|
340
|
+
| `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при `create()` |
|
|
341
|
+
| `.Класс.set(x)` / `.Класс.unset()` | слот связи — после глагола записи | — | записать / снять конец связи БЕЗ участия в фильтре целей (§ 6.1) |
|
|
229
342
|
| `alias(name)` | текущий шаг | ключ в путях | — |
|
|
230
343
|
|
|
231
344
|
---
|
|
232
345
|
|
|
233
|
-
## 6. Запись:
|
|
346
|
+
## 6. Запись: операции — звенья плана
|
|
347
|
+
|
|
348
|
+
`.create(data?)` / `.update(data?)` / `.delete(opts?)` / `.anonymize(fields)` — **звенья
|
|
349
|
+
цепочки**: операция применяется к шагу, к которому приклеена точкой, и возвращает цепочку
|
|
350
|
+
для продолжения. Сама по себе ничего не пишет — **исполняет терминал**
|
|
351
|
+
(`rows()/first()/count()/run()/…`), весь план **одной транзакцией**: отказ любого сегмента
|
|
352
|
+
(валидация, ACL) откатывает всё.
|
|
234
353
|
|
|
235
|
-
|
|
354
|
+
Глаголы не перепутать: `create` **вставляет** (или версионирует по известному id),
|
|
355
|
+
`update` **правит найденное путём** и никогда не создаёт. Старый `set()` разбит на них
|
|
356
|
+
в 0.16.0 (бросает подсказку) — мина «`Класс()` → INSERT, `Класс({})` → UPDATE всех» мертва,
|
|
357
|
+
намерение всегда явное.
|
|
236
358
|
|
|
237
359
|
```ts
|
|
238
|
-
//
|
|
239
|
-
await db
|
|
360
|
+
// одиночная запись: операция + терминал
|
|
361
|
+
const [вася] = await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
|
|
240
362
|
|
|
241
|
-
//
|
|
242
|
-
await db
|
|
363
|
+
// несколько операций в одной цепочке: продолжение — ОТ РЕЗУЛЬТАТА предыдущей
|
|
364
|
+
await db.Клиент({ vip: true }).update({ bonus: 500 }) // новая версия всех vip
|
|
365
|
+
.Запись().create({ status: 'gift' }) // INSERT записи КАЖДОМУ (fan-out)
|
|
366
|
+
.rows() // → подарочные записи
|
|
243
367
|
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
z.Сотрудник(s)
|
|
247
|
-
z.Окно(w)
|
|
248
|
-
z.Запись(b)
|
|
249
|
-
await z
|
|
368
|
+
// операция сразу после операции — к тем же сущностям (две версии подряд)
|
|
369
|
+
await db.Запись(id).update({ status: 'confirmed' }).update({ paid: true }).rows()
|
|
250
370
|
```
|
|
251
371
|
|
|
252
|
-
`set()` возвращает **ленивый билдер** (thenable): класс-вызовы довешивают связи,
|
|
253
|
-
первый `await` исполняет ОДИН INSERT со всеми связями; промис кешируется
|
|
254
|
-
(повторный `await` не создаёт версий; довесить связь после исполнения — ошибка).
|
|
255
|
-
|
|
256
372
|
### Анатомия
|
|
257
373
|
|
|
258
374
|
```
|
|
259
|
-
db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР )
|
|
260
|
-
└───── контекст ─────┘ └─────┘
|
|
261
|
-
каждый шаг = связь
|
|
375
|
+
db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хвост()… .rows()
|
|
376
|
+
└───── контекст ─────┘ └─────┘ └────┘ └─ продолжение ─┘ └─ терминал: исполняет план
|
|
377
|
+
каждый шаг = связь кого трогаем create|update|delete от записанных
|
|
378
|
+
(только update)
|
|
262
379
|
```
|
|
263
380
|
|
|
264
|
-
- **Контекст-шаги** (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
-
|
|
381
|
+
- **Контекст-шаги** (шаги до операции): каждый резолвится в **ровно одну** сущность —
|
|
382
|
+
id-фильтром (без запроса) или уникальным фильтром (0 или >1 → ошибка). Дают: `links`
|
|
383
|
+
при `create` и containment-фильтр целей при `update`/`delete`.
|
|
384
|
+
- **Режим выбирает глагол** (фильтр-объект допустим только перед `update`):
|
|
268
385
|
|
|
269
|
-
|
|
|
386
|
+
| Глагол | Шаг операции | Действие |
|
|
270
387
|
|---|---|---|
|
|
271
|
-
| `Класс()` — без
|
|
272
|
-
| `Класс()`
|
|
273
|
-
| `Класс(
|
|
274
|
-
|
|
|
275
|
-
| `Класс({})`
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
388
|
+
| `create(data?)` | `Класс()` — без фильтра | **INSERT**: id по правилу схемы (§ 3.2), links = контекст + слоты |
|
|
389
|
+
| | `Класс(id)` / `data.id` / вычисленный v5-id **уже существует** | **новая версия** (идемпотентный create, REST-PUT семантика); не существует → INSERT с этим id |
|
|
390
|
+
| | `Класс({фильтр})` | ошибка ПОСТРОЕНИЯ `create() takes no filter`; create на pivot-шаге — тоже ошибка |
|
|
391
|
+
| `update(data?)` | `Класс()` ≡ `Класс({})` | новая версия **всех** в границах контекста |
|
|
392
|
+
| | `Класс(id)` / `Класс({поля})` / pivot | новая версия **каждого** найденного путём; не найдено → `[]` — update НИКОГДА не создаёт |
|
|
393
|
+
|
|
394
|
+
- **Продолжение после операции** — от её результата: класс-шаг = переход (create-цель
|
|
395
|
+
следующего сегмента получает связь на записанное; чтение — обычный hop). При
|
|
396
|
+
множественном результате следующий сегмент исполняется **для каждой строки** (fan-out).
|
|
397
|
+
После `delete` продолжение идёт от строк класса цели.
|
|
398
|
+
- **DATA** — поля сущности/связи; явный `id` — здесь (`{ id, … }`) или шагом `Класс(id)`;
|
|
399
|
+
у v5-класса явный id запрещён — его считает схема (§ 3.2).
|
|
281
400
|
Валидация **строгая, всегда**: поле вне `Schema.attributes` → `ValidationError`;
|
|
282
401
|
default-ы схемы подставляются; id в `data` не хранится (он — колонка).
|
|
283
|
-
- **Deep-merge при
|
|
284
|
-
`
|
|
285
|
-
Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
|
|
286
|
-
- **Концы LINK**:
|
|
402
|
+
- **Deep-merge при `update`** (и у create-версии по известному id): меняются только
|
|
403
|
+
указанные листья — `update({ price: { RUB: 1100 } })` сохранит `USD/EUR` и остальные
|
|
404
|
+
поля. Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
|
|
405
|
+
- **Концы LINK**: по `Schema.links` v2 (§3.1) — обязательные требуются, союз занимает один ключ, лишние связи — ошибка; значения — id, Row или вложенная цепочка (§6.1).
|
|
287
406
|
- **account/owner NOT NULL**: `.account()/.owner()` → `connect()` → System-аккаунт.
|
|
288
407
|
- Версии монотонны (`GREATEST(clock, prev+1µs)`), коллизия 23505 ретраится.
|
|
408
|
+
- Цепочка переиспользуема: повторный терминал = повторное исполнение плана.
|
|
409
|
+
|
|
410
|
+
### 6.1 Связи: путь и слоты `.Класс.set()` / `.Класс.unset()`
|
|
411
|
+
|
|
412
|
+
Один закон: **владелец создаваемой связки берётся из валидного пути; прочие концы —
|
|
413
|
+
слотами** `.Класс.set(значение)` после глагола записи. Слот — свойство-класс БЕЗ вызова
|
|
414
|
+
(`.Сотрудник.set(x)`, не `.Сотрудник(x)`); валиден только для конца из `Schema.links` и
|
|
415
|
+
требует операцию записи ПЕРЕД собой: `…create(…).Класс.set(x)` / `…update(…).Класс.unset()`;
|
|
416
|
+
слот без глагола — ошибка `link slot needs a write`.
|
|
289
417
|
|
|
290
418
|
```ts
|
|
291
|
-
//
|
|
292
|
-
|
|
419
|
+
// СОЗДАНИЕ: владелец Запись — из пути, предмет и исполнитель — слотами
|
|
420
|
+
await db.Запись(b).позиция().create({ qty: 1, price: { RUB: 1500 } })
|
|
421
|
+
.Услуга.set(u).Сотрудник.set(s).rows()
|
|
422
|
+
|
|
423
|
+
// занятость: владелец Сотрудник из пути, окно и запись — слотами; id считает схема (§ 3.2)
|
|
424
|
+
await db.Сотрудник(s).занятость().create({ kind: 'booking' }).Окно.set(w).Запись.set(b).rows()
|
|
425
|
+
|
|
426
|
+
// ПЕРЕВЕС связи (update): цель ищется путём/pivot, слот пишет новое значение БЕЗ фильтра
|
|
427
|
+
await tr.Запись(b).позиция().Сотрудник(старый).позиция().update().Сотрудник.set(новый).rows()
|
|
428
|
+
|
|
429
|
+
// СОЮЗ-конец [Услуга|Комплекс]: слот замещает конец целиком (соседний класс снимается)
|
|
430
|
+
await db.позиция(p).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
|
|
293
431
|
|
|
294
|
-
//
|
|
295
|
-
await db
|
|
432
|
+
// СНЯТИЕ optional-конца
|
|
433
|
+
await db.занятость(bz).update({ kind: 'hold' }).Запись.unset().rows()
|
|
296
434
|
|
|
297
|
-
//
|
|
298
|
-
await db
|
|
435
|
+
// вложенная цепочка как значение слота — та же транзакция, ровно одна сущность:
|
|
436
|
+
await db.Запись(b).позиция().create({ qty: 1 })
|
|
437
|
+
.Услуга.set(u)
|
|
438
|
+
.Сотрудник.set(db.Сотрудник().create({ name: 'Новичок' }).Org.set(org)).rows() // создать И привязать
|
|
299
439
|
```
|
|
300
440
|
|
|
301
|
-
### `.delete()` — серверное удаление
|
|
441
|
+
### `.delete({ confirm })` — превью и серверное удаление
|
|
302
442
|
|
|
303
443
|
```ts
|
|
304
|
-
const
|
|
305
|
-
//
|
|
306
|
-
// [{class:'Booking'
|
|
444
|
+
const превью = await db.Запись(bid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
|
|
445
|
+
// кандидаты (цель + каскад), живые, БД не тронута:
|
|
446
|
+
// [{class:'Booking'}, {class:'busy'}, {class:'item'}]
|
|
307
447
|
|
|
308
|
-
await db.Запись(
|
|
448
|
+
const удалено = await db.Запись(bid).delete({ confirm: true }).rows()
|
|
449
|
+
// удалённое дерево, каждый Row с $deleted: true
|
|
450
|
+
|
|
451
|
+
await db.Запись(b).занятость().delete({ confirm: true }).rows() // контекст: только занятости записи
|
|
309
452
|
```
|
|
310
453
|
|
|
311
|
-
Цели = фильтр
|
|
312
|
-
|
|
313
|
-
(+advisory-lock). История неприкосновенна; повторный delete → `[]`; `
|
|
314
|
-
воскрешение.
|
|
454
|
+
Цели = фильтр шага + контекст-связи. Замыкание (цели + все живые зависимые) собирается
|
|
455
|
+
одним CTE; `confirm: true` шлёт один SQL `DELETE` — триггер БД тумбстоунит дерево
|
|
456
|
+
(+advisory-lock). История неприкосновенна; повторный delete → `[]`; `create()` с тем же
|
|
457
|
+
id — воскрешение.
|
|
458
|
+
|
|
459
|
+
### Откат плана
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
await db.Клиент(id).update({ name: 'Новое' }) // валидный сегмент…
|
|
463
|
+
.Запись().create({ чепуха: 1 }) // …невалидный: ValidationError
|
|
464
|
+
.rows()
|
|
465
|
+
// ← ОТКАТ ВСЕГО: имя клиента не изменилось, версий не прибавилось
|
|
466
|
+
```
|
|
315
467
|
|
|
316
468
|
---
|
|
317
469
|
|
|
318
470
|
## 7. Транзакции
|
|
319
471
|
|
|
320
472
|
```ts
|
|
473
|
+
const busyId = uuidv5(`v1.booking:entity:busy:${slotId}:${staffId}`) // id известен ДО создания (§ 3.2)
|
|
474
|
+
|
|
321
475
|
const tr = await db.begin() // тот же API на выделенном соединении
|
|
322
476
|
await tr.lock('busy', staffId, slotId) // advisory-xact-lock до конца транзакции
|
|
323
477
|
const занято = await tr.занятость(busyId).first()
|
|
324
|
-
if (!занято) await tr.Сотрудник(s)
|
|
478
|
+
if (!занято) await tr.Сотрудник(s).занятость().create({ kind: 'booking' }).Окно.set(w).rows()
|
|
325
479
|
await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()
|
|
326
480
|
```
|
|
327
481
|
|
|
328
482
|
Повторный commit/rollback — no-op. `lock()` вне транзакции — ошибка. Держите транзакции
|
|
329
|
-
короткими. **Рецепт двойной брони**:
|
|
330
|
-
|
|
331
|
-
(
|
|
483
|
+
короткими. **Рецепт двойной брони**: id занятости детерминирован схемой (v5 — § 3.2), так
|
|
484
|
+
что дубль невозможен в принципе (второй `create` стал бы версией той же занятости);
|
|
485
|
+
`lock(мастер, окно)` + перечитка `first()` под локом нужны, чтобы сопернику честно
|
|
486
|
+
ОТКАЗАТЬ, а не молча версионировать чужую бронь (ровно одна успешна — покрыто тестом-гонкой).
|
|
332
487
|
|
|
333
488
|
---
|
|
334
489
|
|
|
335
490
|
## 8. Батчи
|
|
336
491
|
|
|
337
492
|
```ts
|
|
338
|
-
db.batch('окна').Расписание(sch).Окно().
|
|
339
|
-
db.batch('окна').Расписание(sch).Окно().
|
|
493
|
+
db.batch('окна').Расписание(sch).Окно().create({ start, end }) // план встал в очередь
|
|
494
|
+
db.batch('окна').Расписание(sch).Окно().create({ … })
|
|
340
495
|
db.batch('окна').size() // 2
|
|
341
|
-
const res = await db.batch('окна').
|
|
496
|
+
const res = await db.batch('окна').run() // одна транзакция; Row[][] по порядку
|
|
342
497
|
db.batch('окна').discard() // отменить
|
|
343
498
|
```
|
|
344
499
|
|
|
345
|
-
- `
|
|
346
|
-
-
|
|
347
|
-
|
|
348
|
-
- Подряд идущие чистые
|
|
349
|
-
склеиваются в один multi-VALUES
|
|
500
|
+
- `run()` атомарен: любая ошибка откатывает всё.
|
|
501
|
+
- Операции в батч-цепочке кладут ПЛАН в очередь (многосегментные планы — одним элементом);
|
|
502
|
+
терминал на такой цепочке — ошибка `plan is queued in the batch — call batch.run()`.
|
|
503
|
+
- Подряд идущие чистые `create()` одного класса (план из одного шага, без `data.id` и
|
|
504
|
+
слотов) склеиваются в один multi-VALUES; **v5-классы — поштучно** (id считается из
|
|
505
|
+
концов/полей — § 3.2), семантика та же.
|
|
350
506
|
- Read-вызовы на батч-фасаде исполняются сразу, мимо очереди.
|
|
351
507
|
|
|
352
508
|
---
|
|
@@ -373,7 +529,156 @@ await db.rules.set({ account: 'authenticated:ACCOUNT', resource: 'shop:API', per
|
|
|
373
529
|
|
|
374
530
|
Связка с Entity: `account`/`owner` NOT NULL, default — System-аккаунт; фильтры и значения —
|
|
375
531
|
модификаторы `.account()/.owner()`; профиль владельца — `db.accounts.get(row.owner)`.
|
|
376
|
-
Сид `
|
|
532
|
+
Сид `lib/sql/seed.auth.sql`: System + 16 Resource + 12 Rule.
|
|
533
|
+
|
|
534
|
+
### 9.1 Вход: `db.auth` — много кредов на аккаунт
|
|
535
|
+
|
|
536
|
+
Слой над `db.credentials`: пароль (логин или почта), api-ключ, ключ-секрет, внешние
|
|
537
|
+
identity (oauth/sso/telegram). Однозначность входа гарантирует БД:
|
|
538
|
+
`credential_identity_udx` — один **живой** кред на `(category, identifier)` во всей схеме
|
|
539
|
+
(мягко удалённый identifier освобождается). Пароли — scrypt (node:crypto, зависимостей нет),
|
|
540
|
+
ключи и секреты хранятся **только sha256-хэшем**. Все `verify*` возвращают
|
|
541
|
+
`{ account, credential } | null` и проверяют три ворот: кред жив (`deleted IS NULL`),
|
|
542
|
+
подтверждён (`confirmed`, отключаемо `requireConfirmed: false`), аккаунт `enabled`.
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
const acc = await db.accounts.set({ categories: ['User'], data: { name: 'Вася' } })
|
|
546
|
+
|
|
547
|
+
// ПАРОЛЬ (почта/пароль и логин/пароль — одна механика, различает category)
|
|
548
|
+
await db.auth.setPassword({ account: acc, identifier: 'vasya@salon.io', password: 'корень-огня-77' })
|
|
549
|
+
// → Credential; в БД НЕ пароль, а слоёный хэш: [64.8 ms]
|
|
550
|
+
// meta.password = "scrypt$32768$8$1$EmyOQlletdEahwgoXendhw==$vGQB9LrwcDSIB0+…"
|
|
551
|
+
await db.auth.verifyPassword({ identifier: 'vasya@salon.io', password: 'корень-огня-77' })
|
|
552
|
+
// → { account: {id: '7277…', data: {name: 'Вася'}, enabled: true, …},
|
|
553
|
+
// credential: {category: 'PASSWORD', identifier: 'vasya@salon.io', …} } [71.8 ms]
|
|
554
|
+
await db.auth.verifyPassword({ identifier: 'vasya@salon.io', password: 'хм' }) // → null [82.8 ms]
|
|
555
|
+
// цена задана scrypt-ом (~70 ms) и выровнена: «нет такого identifier» не быстрее «пароль неверен»
|
|
556
|
+
|
|
557
|
+
// API-КЛЮЧ: показывается ОДИН раз, в БД — только sha256
|
|
558
|
+
const { key } = await db.auth.issueApiKey({ account: acc, name: 'CI' })
|
|
559
|
+
// key = "lts_a61118c4af31868640529131927eb578211730bcd092856c" [6.3 ms]
|
|
560
|
+
// в БД: identifier = "ae9002e2cd4c…" (sha256), meta = {name: 'CI', prefix: 'lts_a61118c4'}
|
|
561
|
+
await db.auth.verifyApiKey(key) // → { account, credential } [5.6 ms]
|
|
562
|
+
|
|
563
|
+
// КЛЮЧ-СЕКРЕТ: key — открытый id пары, secret — только sha256
|
|
564
|
+
const { key: k, secret } = await db.auth.issueKeySecret({ account: acc, name: 'integration' })
|
|
565
|
+
// k = "96d327b741421f74", secret = "38f69922c9748aa8…" (48 hex) [4.7 ms]
|
|
566
|
+
await db.auth.verifyKeySecret(k, secret) // → { account, credential } [3.4 ms]
|
|
567
|
+
|
|
568
|
+
// ВНЕШНЯЯ IDENTITY (oauth/sso/telegram): токен проверяет приложение, тут — связка
|
|
569
|
+
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915', meta: { username: 'roboteza' } })
|
|
570
|
+
await db.auth.lookup({ category: 'TELEGRAM', identifier: '1635246915' })
|
|
571
|
+
// → { account, credential } [2.6 ms]
|
|
572
|
+
|
|
573
|
+
// TOTP (authenticator, RFC 6238): секрет в QR, активен после первой проверки
|
|
574
|
+
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: 'anna@salon.io' })
|
|
575
|
+
// secret = "RWVB3WWQ62LEED55AMU6L425C6KIT5EJ" (base32, 20 байт) [5.6 ms]
|
|
576
|
+
// uri = "otpauth://totp/anna%40salon.io?secret=…&issuer=clockz&algorithm=SHA1&digits=6&period=30"
|
|
577
|
+
await db.auth.verifyTotp({ account: acc, code: '139999' }) // → true [4.9 ms]
|
|
578
|
+
await db.auth.verifyTotp({ account: acc, code: '139999' }) // → false — replay того же шага отбит
|
|
579
|
+
await db.auth.totpEnabled(acc) // → true (после первой проверки)
|
|
580
|
+
|
|
581
|
+
// ОДНОРАЗОВЫЙ КОД (email/SMS/reset — доставка на приложении)
|
|
582
|
+
const { code } = await db.auth.issueOtp({ account: acc, identifier: 'anna@salon.io', ttlSec: 600 })
|
|
583
|
+
// code = "273746"; в БД sha256 + expires + attempts [3.3 ms]
|
|
584
|
+
await db.auth.verifyOtp({ identifier: 'anna@salon.io', code })
|
|
585
|
+
// → { account, credential }; код СОЖЖЁН (одноразовость) [6.0 ms]; повторно → null
|
|
586
|
+
// 5 неверных попыток тоже сжигают; повторный issue перезаписывает код (валиден последний)
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Хелпер `totpCode(secret, atMs?)` (экспорт) — код authenticator-а для секрета: тесты,
|
|
590
|
+
серверная генерация.
|
|
591
|
+
|
|
592
|
+
Сессии — во внешнем Redis (клиент инжектируется, в зависимости не входит; ioredis
|
|
593
|
+
подходит как есть — интерфейс `SessionStore`):
|
|
594
|
+
|
|
595
|
+
```ts
|
|
596
|
+
import Redis from 'ioredis'
|
|
597
|
+
const s = db.auth.sessions(new Redis('redis://localhost:16379'))
|
|
598
|
+
|
|
599
|
+
const token = await s.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } })
|
|
600
|
+
// → "843a351714b3a9e43447ad0cfa2a88f8d2d166cd1642b49472335e7a13291585" [2.4 ms]
|
|
601
|
+
await s.check(token)
|
|
602
|
+
// → { account: '7277…', meta: {device: 'iphone'}, created: '2026-07-10T18:43:12.815Z' } [0.4 ms]
|
|
603
|
+
// в Redis лежит sha256(token), не сам токен (+ set-индекс аккаунта для revokeAll):
|
|
604
|
+
// sess:d3fa766175d67e10… sess:acc:72778374-3857-…
|
|
605
|
+
await s.revoke(token) // → true; повторно → false
|
|
606
|
+
await s.revokeAll(acc) // → сколько сессий погасло
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
Границы (осознанно на приложении): OAuth-танец/проверка внешних токенов, rate-limit,
|
|
610
|
+
lockout после N неудач. Legacy-bcrypt-хэши (`$2b$…` из дампа) библиотека не верифицирует —
|
|
611
|
+
пароль задаётся заново через `setPassword`.
|
|
612
|
+
|
|
613
|
+
### 9.2 ACL: `db.acl` + `enforceAcl` — Resource/Rule в работе
|
|
614
|
+
|
|
615
|
+
**Resource** — именованное множество, pattern — шаблон «своей» сущности. **Rule** — стрелка
|
|
616
|
+
«группа → группа» (`allow|deny` + `weight`); правила никогда не указывают на конкретный
|
|
617
|
+
аккаунт — принадлежность вычисляется из `Account.categories` на лету.
|
|
618
|
+
|
|
619
|
+
| category | pattern — шаблон чего | пример |
|
|
620
|
+
|---|---|---|
|
|
621
|
+
| `ACCOUNT` | аккаунта | `{"categories": "{Staff}"}`; `"!{Anonymous,Shadow}"` — нет ни одной; NULL — все |
|
|
622
|
+
| `API` | адреса эндпоинта | `{"endpoint": "v2.auth.apikey.*"}` — маска: `.`-сегменты, `{a,b}`, `*` — хвост |
|
|
623
|
+
| `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "Booking", "owner": "$account"}` |
|
|
624
|
+
|
|
625
|
+
Pattern операций — реальные колонки Entity (`class`, `owner`, `account`, `tags`, `data`,
|
|
626
|
+
`links`…), значения — литералы или `"$account"` (id субъекта, подставляется в запрос);
|
|
627
|
+
`class` — маска с наследованием (право на `Vehicle` действует на `SportsCar`).
|
|
628
|
+
|
|
629
|
+
Резолюция: субъекты аккаунта × объекты цели → правила → побеждает **ровно одно**
|
|
630
|
+
(max weight; при равенстве deny); **нет правил — deny**. Остальные ключи победившего
|
|
631
|
+
allow — предикат строк, **вливается в SQL до сортировки и лимита**: пагинация, `count()`,
|
|
632
|
+
keyset честные, постфильтрации нет.
|
|
633
|
+
|
|
634
|
+
```ts
|
|
635
|
+
await db.acl.check(anon, 'v2.auth.password.signup')
|
|
636
|
+
// → { allow: true, rule: {account: 'any:ACCOUNT', resource: 'auth.anonymous:API', weight: 105} } [2.0 ms]
|
|
637
|
+
await db.acl.check(anon, 'v2.auth.apikey.create')
|
|
638
|
+
// → { allow: false, code: 403, message: 'Access denied - default …' } — победило дно-правило deny 0
|
|
639
|
+
|
|
640
|
+
await db.acl.checkData(user, 'Booking', 'READ') // ресурс {"class":"Booking","owner":"$account"}
|
|
641
|
+
// → { allow: true, filter: { owner: '24c49a43-…' }, rule: {…weight: 60} } [1.7 ms]
|
|
642
|
+
await db.acl.checkData(user, 'SportsCar', 'READ') // право дал предок Vehicle (lineage)
|
|
643
|
+
// → { allow: true } — безусловный, без предиката
|
|
644
|
+
await db.acl.checkData(user, 'Org', 'READ')
|
|
645
|
+
// → { allow: false, message: 'no matching rule (deny by default)' }
|
|
646
|
+
db.acl.reload() // сброс кэша Resource/Rule фасада
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
**`connect({ account, enforceAcl: true })`** — те же решения в цепочках: каждый шаг —
|
|
650
|
+
`READ`, `create()`/`update()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
|
|
651
|
+
(deny в замыкании откатывает транзакцию); `watch()` отдаёт события только безусловных
|
|
652
|
+
allow-классов (payload нечем проверить предикат). Правила фиксируются на connect.
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
|
|
656
|
+
await u.Запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
|
|
657
|
+
await u.Запись().count() // честный count по суженному множеству
|
|
658
|
+
await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
|
|
659
|
+
const [z] = await u.Запись().create().rows() // owner пришпилен правилом → user.id
|
|
660
|
+
await u.Запись().owner(other).create().rows() // Error: acl pins Booking writes to owner …
|
|
661
|
+
await u.Запись(чужаяId).update({ status: '…' }).rows() // → [] — цель вне предиката не находится
|
|
662
|
+
// create по известному чужому id тоже НЕ перехватывает: → [] вместо новой версии
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
Оверхед (article-полигон 1.1M, p50 из 20; `bench/acl.bench.mjs`):
|
|
666
|
+
|
|
667
|
+
| Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
|
|
668
|
+
|---|---|---|---|
|
|
669
|
+
| точечный `first(id)` | 2.2 ms | 3.1 ms | 3.5 ms |
|
|
670
|
+
| фильтр `rows` limit 100 | 119.8 ms | 121.6 ms | 119.9 ms |
|
|
671
|
+
| keyset-страница всего класса 250k | 1619 ms | 1652 ms | 2155 ms |
|
|
672
|
+
| `count()` класса 250k | 440 ms | 453 ms | 875 ms |
|
|
673
|
+
| цепочка 2 шага | 5.5 ms | 6.6 ms | 7.0 ms |
|
|
674
|
+
|
|
675
|
+
Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат замерен в
|
|
676
|
+
worst-case (пропускает 100% строк — чистый оверхед доп. условия на full-scan);
|
|
677
|
+
в реальности предикат СУЖАЕТ выборку, и тяжёлые сцены становятся ДЕШЕВЛЕ, чем без ACL.
|
|
678
|
+
`db.acl.checkData` — справочный (1.3 ms: SQL за категориями аккаунта на каждый вызов);
|
|
679
|
+
горячий путь цепочек использует резолвер, скомпилированный на connect (микросекунды, memo).
|
|
680
|
+
|
|
681
|
+
`enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
|
|
377
682
|
|
|
378
683
|
---
|
|
379
684
|
|
|
@@ -382,16 +687,16 @@ await db.rules.set({ account: 'authenticated:ACCOUNT', resource: 'shop:API', per
|
|
|
382
687
|
```ts
|
|
383
688
|
await db.Услуга().rows() // срез класса (актуальное, живое)
|
|
384
689
|
await db.Запись().sort('updated','desc').limit(50).rows()
|
|
385
|
-
await db.Клиент(cid).Запись().позиция().
|
|
690
|
+
await db.Клиент(cid).Запись().позиция().run() // связный подграф
|
|
386
691
|
```
|
|
387
692
|
|
|
388
693
|
```sql
|
|
389
694
|
-- вся партиция одним SQL
|
|
390
|
-
SELECT * FROM (SELECT DISTINCT ON (class, id) * FROM "booking"."Entity"
|
|
695
|
+
SELECT * FROM (SELECT DISTINCT ON (class, id) * FROM "v1.booking"."Entity"
|
|
391
696
|
WHERE partition='entity' ORDER BY class, id, updated DESC) t WHERE t.deleted IS NULL;
|
|
392
697
|
|
|
393
698
|
-- история сущности (все версии, включая tombstone)
|
|
394
|
-
SELECT updated, deleted, data, links FROM "booking"."Entity"
|
|
699
|
+
SELECT updated, deleted, data, links FROM "v1.booking"."Entity"
|
|
395
700
|
WHERE partition='entity' AND class='Booking' AND id=$1 ORDER BY updated;
|
|
396
701
|
```
|
|
397
702
|
|
|
@@ -412,20 +717,37 @@ const история = await db.Запись(id).versions()
|
|
|
412
717
|
|
|
413
718
|
### 10.2 Пагинация курсором: `.after()` + `cursorOf()`
|
|
414
719
|
|
|
415
|
-
Offset
|
|
720
|
+
Offset заставляет БД построить и выбросить всё пропущенное (и «съезжает» при вставках).
|
|
721
|
+
Keyset — закладка: курсор = значение поля сортировки **+ id** последней строки страницы;
|
|
722
|
+
следующая страница — «строго после закладки» (`WHERE (поле, id) > (v, id)` — пара делает
|
|
723
|
+
порядок тотальным, дубли значений не теряются и не повторяются).
|
|
416
724
|
|
|
417
725
|
```ts
|
|
418
726
|
const стр1 = await db.Запись().sort('updated', 'desc').limit(50).rows()
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
.limit(50).rows()
|
|
727
|
+
const кур = cursorOf(стр1.at(-1)!) // { v: '<updated>', id: '…' } — просто объект,
|
|
728
|
+
const стр2 = await db.Запись().sort('updated', 'desc') // можно хранить в URL/state
|
|
729
|
+
.after(кур).limit(50).rows()
|
|
422
730
|
|
|
423
|
-
// по data
|
|
731
|
+
// по data-пути ЛЮБОЙ глубины — field тот же, что в sort
|
|
424
732
|
const дальше = await db.Окно().sort('data.start')
|
|
425
733
|
.after(cursorOf(окно, 'data.start')).limit(20).rows()
|
|
734
|
+
|
|
735
|
+
// бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
|
|
736
|
+
let cursor
|
|
737
|
+
do {
|
|
738
|
+
const page = await db.Запись({ status: 'confirmed' }).sort('updated', 'desc')
|
|
739
|
+
.after(cursor).limit(50).rows()
|
|
740
|
+
render(page)
|
|
741
|
+
cursor = page.length ? cursorOf(page.at(-1)!) : undefined
|
|
742
|
+
} while (cursor)
|
|
426
743
|
```
|
|
427
744
|
|
|
428
|
-
`.after()` требует `.sort()` (иначе ошибка);
|
|
745
|
+
`.after()` требует `.sort()` (иначе ошибка); поле курсора — то же, что в sort.
|
|
746
|
+
|
|
747
|
+
Произвольная страница № N (курсор знает только «после X»): гибрид — прыжок `offset`-ом один
|
|
748
|
+
раз, дальше курсором; или прыжок **по значению** — курсор собирается руками:
|
|
749
|
+
`.after({ v: 1300, id: '' })` → «к ценам ниже 1300», `.after({ v: '2026-03-01', id: '' })` →
|
|
750
|
+
«к записям до марта» — без чтения промежуточных страниц.
|
|
429
751
|
|
|
430
752
|
### 10.3 Агрегации: считает БД
|
|
431
753
|
|
|
@@ -465,22 +787,32 @@ await stop() // отписка
|
|
|
465
787
|
вставленную версию; данные подписчик дочитывает обычной цепочкой. Основа живых
|
|
466
788
|
интерфейсов без поллинга.
|
|
467
789
|
|
|
790
|
+
Обрывы LISTEN-соединение переживает само (postgres.js переподключается с backoff и
|
|
791
|
+
повторяет LISTEN), но `NOTIFY` за время разрыва потеряны — для этого `onReconnect`:
|
|
792
|
+
|
|
793
|
+
```ts
|
|
794
|
+
const stop = await db.watch('Запись', onEvent, {
|
|
795
|
+
onReconnect: () => дочитатьПропущенное(), // напр. перечитать всё с последнего e.updated
|
|
796
|
+
})
|
|
797
|
+
```
|
|
798
|
+
|
|
468
799
|
### 10.6 Изоляция арендатора: `enforceAccount`
|
|
469
800
|
|
|
470
801
|
```ts
|
|
471
802
|
const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
|
|
472
803
|
await db.Запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
|
|
473
|
-
await db.Клиент().
|
|
804
|
+
await db.Клиент().create({ name: 'X' }) // запись пришпилена к account
|
|
474
805
|
db.Запись().account(чужой).rows() // ошибка: reads are pinned to account …
|
|
475
806
|
```
|
|
476
807
|
|
|
477
808
|
Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
|
|
478
|
-
больше не утечка данных соседнего салона.
|
|
809
|
+
больше не утечка данных соседнего салона. Это простой флаг «всё по одному арендатору»;
|
|
810
|
+
гранулярные права (по классам, операциям, со срезами строк правилами) — ACL § 9.2.
|
|
479
811
|
|
|
480
812
|
### 10.7 Анонимизация (GDPR): `.anonymize()`
|
|
481
813
|
|
|
482
814
|
```ts
|
|
483
|
-
await db.Клиент(id).anonymize(['name', 'contact'])
|
|
815
|
+
await db.Клиент(id).anonymize(['name', 'contact']).rows()
|
|
484
816
|
// новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
|
|
485
817
|
```
|
|
486
818
|
|
|
@@ -491,9 +823,9 @@ await db.Клиент(id).anonymize(['name', 'contact'])
|
|
|
491
823
|
### 10.8 Политики хранения: `db/policies.mjs`
|
|
492
824
|
|
|
493
825
|
```bash
|
|
494
|
-
node db/policies.mjs --dsn=… --schema=booking --compress-after=30d # сжатие (история цела)
|
|
495
|
-
node db/policies.mjs --dsn=… --schema=booking --retain=2y # + retention (drop навсегда!)
|
|
496
|
-
node db/policies.mjs --dsn=… --schema=booking
|
|
826
|
+
node db/policies.mjs --dsn=… --schema=v1.booking --compress-after=30d # сжатие (история цела)
|
|
827
|
+
node db/policies.mjs --dsn=… --schema=v1.booking --retain=2y # + retention (drop навсегда!)
|
|
828
|
+
node db/policies.mjs --dsn=… --schema=v1.booking # текущее состояние
|
|
497
829
|
```
|
|
498
830
|
|
|
499
831
|
- **Сжатие** — чанки старше N: `segmentby (partition, class, id)` кладёт версии сущности рядом
|
|
@@ -507,14 +839,14 @@ node db/policies.mjs --dsn=… --schema=booking # тек
|
|
|
507
839
|
### 10.9 Миграции классов: `scripts/schema-sync.mjs`
|
|
508
840
|
|
|
509
841
|
```bash
|
|
510
|
-
node scripts/schema-sync.mjs --file=../schema.booking.v2.json --dsn=… --schema=booking
|
|
842
|
+
node scripts/schema-sync.mjs --file=../schema.booking.v2.json --dsn=… --schema=v1.booking
|
|
511
843
|
# schema-sync: … ↔ booking.Schema (partition entity)
|
|
512
844
|
# + Coupon (HUB · Купон) — новый класс
|
|
513
845
|
# ~ Service — изменены: attributes
|
|
514
846
|
# ! Service: 3/50 живых строк НЕ пройдут новую валидацию:
|
|
515
847
|
# id=… → brand — The 'brand' field is required.
|
|
516
848
|
# ИТОГ: ломающие изменения (3 строк) … exit 1
|
|
517
|
-
node scripts/schema-sync.mjs --file=… --dsn=… --schema=booking --apply # применить
|
|
849
|
+
node scripts/schema-sync.mjs --file=… --dsn=… --schema=v1.booking --apply # применить
|
|
518
850
|
```
|
|
519
851
|
|
|
520
852
|
Diff файла и таблицы Schema (новые/изменённые/отсутствующие классы) + отчёт совместимости:
|
|
@@ -539,7 +871,7 @@ const db = await connect({
|
|
|
539
871
|
### 10.11 TS-типы из Schema: `scripts/gen-types.mjs`
|
|
540
872
|
|
|
541
873
|
```bash
|
|
542
|
-
npx tsx scripts/gen-types.mjs --dsn=… --schema=booking --out=entity-types.d.ts
|
|
874
|
+
npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d.ts
|
|
543
875
|
```
|
|
544
876
|
|
|
545
877
|
```ts
|
|
@@ -551,162 +883,2039 @@ const [svc] = await t.Услуга({ active: true }).rows() // svc.data.durati
|
|
|
551
883
|
Интерфейсы data-полей всех классов (enum → union-литералы, record → `Partial<Record<…>>`)
|
|
552
884
|
+ фасад `TypedDb`. Ядро остаётся динамическим — типы это надстройка.
|
|
553
885
|
|
|
886
|
+
### 10.12 Устойчивость к сбоям
|
|
887
|
+
|
|
888
|
+
Транзиентные ошибки PG — deadlock (`40P01`) и serialization failure (`40001`) —
|
|
889
|
+
библиотека гасит сама там, где повтор безопасен:
|
|
890
|
+
|
|
891
|
+
- **чтения в автокоммите** — до 3 попыток с backoff (40–160 ms);
|
|
892
|
+
- **`insertOne`** — ретрай гонки `updated` (23505, мгновенно) и transient (с backoff);
|
|
893
|
+
- **внутренние транзакции** (`.delete()`-каскад, батчи) — повтор ВСЕЙ транзакции:
|
|
894
|
+
откат полный, побочек нет, повтор честен.
|
|
895
|
+
|
|
896
|
+
Внутри `db.begin()` повтор невозможен семантикой PG (вся транзакция aborted) — ошибка
|
|
897
|
+
уходит наружу немедленно, с дописанной подсказкой:
|
|
898
|
+
|
|
899
|
+
```
|
|
900
|
+
deadlock detected — letopis: transaction is aborted, retry the whole db.begin() block
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
Ретраить весь `db.begin()`-блок — ответственность приложения (только оно знает,
|
|
904
|
+
можно ли повторять его побочные эффекты). Встречный порядок `tr.lock()` двух транзакций —
|
|
905
|
+
классический способ поймать 40P01; фиксируйте порядок ключей блокировок.
|
|
906
|
+
|
|
907
|
+
Обрывы соединений: пул postgres.js лениво переустанавливает коннекты (упавший в полёте
|
|
908
|
+
запрос завершается ошибкой, следующий получает новое соединение); LISTEN-канал `watch()`
|
|
909
|
+
переподключается сам, пропущенные NOTIFY дочитываются в `onReconnect` (§ 10.5).
|
|
910
|
+
|
|
554
911
|
---
|
|
555
912
|
|
|
556
913
|
## 11. API Reference
|
|
557
914
|
|
|
558
|
-
|
|
915
|
+
Каждый метод описан по одной схеме: **сигнатура → параметры → назначение и алгоритм →
|
|
916
|
+
примеры (под каждым вызовом реальный ответ и время) → полный кейс**. Все ответы и тайминги —
|
|
917
|
+
**живой прогон** на article-полигоне 1.11M строк (год истории: 250k записей ×2 версии,
|
|
918
|
+
250k занятостей, 250k позиций); воспроизводитель — `bench/api-reference-demo.mjs`
|
|
919
|
+
(мутирует только свои сущности, полигон не пересоздаёт). id сокращены:
|
|
920
|
+
`…0021` = `00000000-0000-4000-8000-000000000021`; повторяющиеся `account`/`owner`/`partition`
|
|
921
|
+
в ответах опущены.
|
|
922
|
+
|
|
923
|
+
Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
|
|
924
|
+
[11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
|
|
925
|
+
[11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-create--update--delete--anonymize) ·
|
|
926
|
+
[11.6 Транзакции](#116-транзакции-entitytx) · [11.7 Батчи](#117-batch) ·
|
|
927
|
+
[11.8 Таблицы](#118-таблицы-accounts--credentials--resources--rules) ·
|
|
928
|
+
[11.9 db.auth](#119-dbauth--вход-и-сессии) · [11.10 db.acl](#1110-dbacl) ·
|
|
929
|
+
[11.11 Registry и ошибки](#1111-registry--validationerror) · [11.12 Типы](#1112-типы)
|
|
930
|
+
|
|
931
|
+
### 11.1 Модуль: connect и экспорты
|
|
932
|
+
|
|
933
|
+
#### `connect(opts: ConnectOpts): Promise<EntityDb>`
|
|
934
|
+
|
|
935
|
+
| Параметр | Тип | Default | Описание |
|
|
936
|
+
|---|---|---|---|
|
|
937
|
+
| `opts.dsn` | `string` | — | строка подключения `postgres://user:pass@host:port/db` |
|
|
938
|
+
| `opts.schema` | `string` | — | ПОЛНОЕ имя PG-схемы с версией движка: `'v1.booking'` (создаёт `up({schema: 'booking', version: 1})` либо `db/apply.mjs --schema=booking --version=1`; либа префикс не достраивает) |
|
|
939
|
+
| `opts.partition` | `string?` | `'entity'` | партиция данных: все чтения/записи этого подключения живут в ней |
|
|
940
|
+
| `opts.account` | `uuid?` | System-аккаунт схемы | default-`account` (арендатор) новых строк |
|
|
941
|
+
| `opts.owner` | `uuid?` | = `account` | default-`owner` (владелец) новых строк |
|
|
942
|
+
| `opts.max` | `number?` | `10` | размер пула соединений postgres.js |
|
|
943
|
+
| `opts.enforceAccount` | `boolean?` | `false` | жёсткая изоляция арендатора (§ 10.6): каждый шаг чтения фильтруется `account = opts.account`, записи пришпилены; явный чужой `.account()` — ошибка |
|
|
944
|
+
| `opts.enforceAcl` | `boolean?` | `false` | ACL по Resource/Rule (§ 9.2): READ на каждый шаг, WRITE/DELETE на записи, предикаты строк в SQL заранее; **требует `account`**; deny-by-default |
|
|
945
|
+
| `opts.onQuery` | `((e: QueryEvent) => void)?` | — | хук на каждый запрос цепочки: `{mode, classes, ms, rows, slow}` (§ 10.10) |
|
|
946
|
+
| `opts.slowMs` | `number?` | — | порог медленного запроса: `ms > slowMs` → `e.slow = true`; если `onQuery` не задан — `console.warn` |
|
|
947
|
+
|
|
948
|
+
**Назначение и алгоритм.** Открывает пул postgres.js (timestamptz парсится **строкой**,
|
|
949
|
+
чтобы не терять микросекунды в `asOf`/курсорах), одним SELECT загружает реестр классов из
|
|
950
|
+
`Schema` (компилируя fastest-validator на класс), резолвит System-аккаунт как fallback для
|
|
951
|
+
NOT NULL `Entity.account`. При `enforceAcl: true` дополнительно параллельно читает аккаунт,
|
|
952
|
+
все Resource и включённые Rule и **компилирует синхронный резолвер решений** (класс,
|
|
953
|
+
операция) → `AclDecision` с memo — правила фиксируются на весь срок жизни подключения.
|
|
954
|
+
Реестр классов тоже фиксируется: новый класс в `Schema` увидит только новый `connect()`.
|
|
955
|
+
|
|
956
|
+
**Примеры**
|
|
957
|
+
|
|
958
|
+
```ts
|
|
959
|
+
const db = await connect({ dsn, schema: 'v1.article', onQuery: (e) => log(e) }) // [34.4 ms]
|
|
960
|
+
await db.Услуга('…0021').first()
|
|
961
|
+
// событие onQuery: {"mode":"rows","classes":["Service"],"ms":13.1,"rows":1,"slow":false}
|
|
962
|
+
|
|
963
|
+
const dbSlow = await connect({ dsn, schema: 'v1.article', slowMs: 200, onQuery: … }) // [32.8 ms]
|
|
964
|
+
await dbSlow.Запись().count() // 250k сущностей — дольше порога:
|
|
965
|
+
// {"mode":"count","classes":["Booking"],"ms":491.9,"rows":1,"slow":true}
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
**Кейс: три подключения — обычное, изолированное, под ACL**
|
|
969
|
+
|
|
970
|
+
```ts
|
|
971
|
+
const db = await connect({ dsn, schema }) // [34.4 ms]
|
|
972
|
+
const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [49.7 ms]
|
|
973
|
+
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [72.3 ms]
|
|
974
|
+
await db.Организация().count() // → 21 — видит всех
|
|
975
|
+
await iso.Организация().count() // [4.3 ms] → 1 — только свой арендатор
|
|
976
|
+
await uc.Организация().rows()
|
|
977
|
+
// Error: letopis: acl denies READ on Org — no matching rule (deny by default)
|
|
978
|
+
await iso.close(); await uc.close()
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
### 11.1a up(opts)
|
|
982
|
+
|
|
983
|
+
#### `up(opts: UpOpts): Promise<EntityDb>`
|
|
559
984
|
|
|
560
985
|
| Параметр | Тип | Default | Описание |
|
|
561
986
|
|---|---|---|---|
|
|
562
|
-
| `dsn` | string |
|
|
563
|
-
| `schema` | string | — |
|
|
564
|
-
| `
|
|
565
|
-
| `
|
|
566
|
-
| `
|
|
567
|
-
| `
|
|
568
|
-
| `
|
|
569
|
-
| `
|
|
570
|
-
| `
|
|
987
|
+
| `opts.dsn` | `string?` | `postgres://postgres:test@localhost:15432/letopis` | строка подключения; хост не `localhost` → docker-шаги пропускаются (только ожидание готовности) |
|
|
988
|
+
| `opts.schema` | `string` | — | **базовое** имя схемы БЕЗ версии и точек (`'booking'`) |
|
|
989
|
+
| `opts.version` | `number` | — | версия движка, целое ≥ 1: итоговая PG-схема `"v<N>.<schema>"`; бамп руками при breaking-изменении DDL |
|
|
990
|
+
| `opts.container` | `string?` | `'letopis-timescale'` | имя dev-контейнера |
|
|
991
|
+
| `opts.image` | `string?` | `'letopis-db'` | имя образа; при отсутствии собирается из пакованного `docker/Dockerfile` (TimescaleDB + Redis) |
|
|
992
|
+
| `opts.dataDir` | `string?` | volume `letopis-pgdata` | путь на хосте (bind mount; на Windows/NTFS — на свой риск) либо имя docker-volume для данных PG |
|
|
993
|
+
| `opts.redisPort` | `number?` | `16379` | хост-порт Redis контейнера |
|
|
994
|
+
| `opts.seeds` | `string[] \| false?` | демо booking + auth | свои сид-файлы (маркер `"<SCHEMA-NAME>"` поддержан; нужен хотя бы один класс в `Schema`, иначе connect упадёт `has no classes`); `false` — голая структура без сидов |
|
|
995
|
+
| `opts.fresh` | `boolean?` | `false` | дропнуть схему и накатить заново — **данные схемы теряются** |
|
|
996
|
+
| `opts.quiet` | `boolean?` | `false` | без `[letopis.up]`-прогресса в консоли |
|
|
997
|
+
| `opts.waitTimeoutMs` | `number?` | `120 000` | максимум ожидания готовности (первый запуск: pull образа + initdb) |
|
|
998
|
+
| …остальные | `ConnectOpts` | — | `partition`/`account`/`enforceAcl`/`onQuery` и все опции `connect()` прокидываются насквозь |
|
|
999
|
+
|
|
1000
|
+
**Назначение и алгоритм.** Одна точка входа: от пустой машины до готового `db`.
|
|
1001
|
+
(1) **Probe**: одна попытка `SELECT 1` по `dsn` — живой postgres (свой контейнер, CI-сервис,
|
|
1002
|
+
внешняя БД) означает «docker не нужен»; ответ «базы нет» (3D000) — база создаётся через
|
|
1003
|
+
служебное подключение к `postgres`. (2) **Docker** (только localhost-`dsn` и только если probe
|
|
1004
|
+
не ответил): `container inspect` → бежит — reuse; остановлен — `docker start`; нет — `image
|
|
1005
|
+
inspect`, при отсутствии `docker build` из пакованного Dockerfile, затем `docker run` с
|
|
1006
|
+
портами/паролем/базой из `dsn` и данными в `letopis-pgdata` (или `dataDir`). (3) **Ожидание**:
|
|
1007
|
+
ретраи ping каждые 500 мс до `waitTimeoutMs` (ошибка аутентификации — фатально сразу), затем
|
|
1008
|
+
TCP-проверка Redis-порта. (4) **Схема**: `fresh` → `DROP SCHEMA … CASCADE`; схема есть →
|
|
1009
|
+
apply пропущен; нет → `ddl.sql` + сиды из пакета (`lib/sql/`) с тотальной заменой маркера
|
|
1010
|
+
`"<SCHEMA-NAME>"` → `"v<N>.<schema>"`. (5) `connect()` со сквозными опциями. Повторный вызов
|
|
1011
|
+
идемпотентен на каждом шаге.
|
|
1012
|
+
|
|
1013
|
+
**Примеры**
|
|
1014
|
+
|
|
1015
|
+
```ts
|
|
1016
|
+
const db = await up({ dsn, schema: 'booking', version: 1 }) // [7.4 s] — с нуля
|
|
1017
|
+
// [letopis.up] building image letopis-db (first time pulls the timescaledb base — may take minutes)…
|
|
1018
|
+
// [letopis.up] image letopis-db built
|
|
1019
|
+
// [letopis.up] container letopis-timescale created (data: volume letopis-pgdata)
|
|
1020
|
+
// [letopis.up] postgres ready in 5.0 s
|
|
1021
|
+
// [letopis.up] redis ready on 16379
|
|
1022
|
+
// [letopis.up] schema "v1.booking" applied (3 files)
|
|
1023
|
+
// [letopis.up] connected (schema "v1.booking")
|
|
1024
|
+
|
|
1025
|
+
const db2 = await up({ dsn, schema: 'booking', version: 1 }) // [110 ms] — всё уже есть
|
|
1026
|
+
// [letopis.up] postgres is up at localhost:15432 — docker skipped
|
|
1027
|
+
// [letopis.up] schema "v1.booking" already exists — apply skipped
|
|
1028
|
+
// [letopis.up] connected (schema "v1.booking")
|
|
1029
|
+
|
|
1030
|
+
const db3 = await up({ dsn, schema: 'booking', version: 1 }) // [1.2 s] — после docker stop
|
|
1031
|
+
// [letopis.up] container letopis-timescale started
|
|
1032
|
+
// [letopis.up] postgres ready in 0.7 s
|
|
1033
|
+
// [letopis.up] redis ready on 16379
|
|
1034
|
+
// [letopis.up] schema "v1.booking" already exists — apply skipped
|
|
1035
|
+
// [letopis.up] connected (schema "v1.booking")
|
|
1036
|
+
|
|
1037
|
+
const scratch = await up({ dsn, schema: 'scratch', version: 1, fresh: true, quiet: true })
|
|
1038
|
+
// [263 ms] — схема снесена и накачена заново, данные схемы потеряны
|
|
1039
|
+
|
|
1040
|
+
await up({ dsn, schema: 'v1.booking', version: 1 })
|
|
1041
|
+
// Error: letopis.up: bad schema name "v1.booking" (базовое имя без версии и точек; версию задаёт version)
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
**Кейс: жизненный цикл dev-машины — данные переживают контейнер**
|
|
1045
|
+
|
|
1046
|
+
```ts
|
|
1047
|
+
const db = await up({ schema: 'booking', version: 1, dsn }) // [7.4 s] образ+контейнер+схема
|
|
1048
|
+
await db.Клиент().count() // → 0 — свежая схема
|
|
1049
|
+
const [аня] = await db.Клиент().create({ name: 'Аня', contact: '+7 900' }).rows()
|
|
1050
|
+
await db.close()
|
|
571
1051
|
|
|
572
|
-
|
|
1052
|
+
// … docker stop letopis-timescale (ребут, уборка, что угодно) …
|
|
1053
|
+
|
|
1054
|
+
const db2 = await up({ schema: 'booking', version: 1, dsn }) // [1.2 s] start + reuse
|
|
1055
|
+
await db2.Клиент().count() // → 2 — volume letopis-pgdata: всё на месте
|
|
1056
|
+
await db2.close()
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
#### Экспорты модуля
|
|
573
1060
|
|
|
574
1061
|
```ts
|
|
575
1062
|
import {
|
|
576
|
-
connect, cursorOf,
|
|
1063
|
+
connect, up, cursorOf, totpCode, // функции
|
|
1064
|
+
uuidv5, uuidv7, LETOPIS_NS, // генерация id (§ 3.2): формула v5 открыта
|
|
577
1065
|
ne, gt, gte, lt, lte, between, inList, like, ilike, // операторы (18)
|
|
578
1066
|
starts, ends, has, hasAny, hasAll, exists, isNull, not, or,
|
|
579
1067
|
ValidationError, Registry, // классы
|
|
580
1068
|
} from 'letopis'
|
|
581
1069
|
import type {
|
|
582
|
-
Row, Path, Filter, Cursor, ChainMods, ConnectOpts, QueryEvent,
|
|
1070
|
+
Row, Path, Filter, Cursor, ChainMods, ConnectOpts, UpOpts, QueryEvent,
|
|
583
1071
|
EntityDb, EntityTx, Chain, Batch, Tables,
|
|
584
1072
|
Account, Credential, Resource, Rule,
|
|
1073
|
+
AccountsApi, CredentialsApi, ResourcesApi, RulesApi,
|
|
1074
|
+
AuthApi, AuthResult, SessionStore, Sessions, Session,
|
|
1075
|
+
WatchEvent, WatchOpts, AclApi, AclOp, AclDecision,
|
|
585
1076
|
} from 'letopis'
|
|
586
1077
|
```
|
|
587
1078
|
|
|
588
|
-
###
|
|
1079
|
+
### 11.2 EntityDb — корень
|
|
1080
|
+
|
|
1081
|
+
`EntityDb` — Proxy: любое имя класса из `Schema` (id или алиас) — метод старта цепочки;
|
|
1082
|
+
плюс фиксированные члены `begin/commit/rollback/batch/watch/close/registry/sql` и фасады
|
|
1083
|
+
`accounts/credentials/resources/rules` (§ 11.8), `auth` (§ 11.9), `acl` (§ 11.10).
|
|
1084
|
+
|
|
1085
|
+
#### `db.<Класс>(filter?: Filter): Chain`
|
|
1086
|
+
|
|
1087
|
+
| Параметр | Тип | Описание |
|
|
1088
|
+
|---|---|---|
|
|
1089
|
+
| `Класс` | имя свойства | id или алиас класса из `Schema` (`db.Booking` ≡ `db.Запись`); неизвестное имя — ошибка со списком классов |
|
|
1090
|
+
| `filter` | `Filter?` | без аргумента — весь класс; `string` — по id; `string[]` — по списку id; `Row`/объект с `.id` — как id; объект — поля `data` (равенство, операторы § 11.4, record-пути) + ключ `id` |
|
|
1091
|
+
|
|
1092
|
+
**Назначение и алгоритм.** Старт цепочки чтения/записи (§ 4–6). Ничего не выполняет —
|
|
1093
|
+
только копит шаги; SQL строится и уходит в БД одним запросом на терминале
|
|
1094
|
+
(`rows/run/count/…` — § 11.3). Формы `filter` нормализуются сразу: объект с `.id`
|
|
1095
|
+
сворачивается в строку-id.
|
|
1096
|
+
|
|
1097
|
+
**Примеры**
|
|
1098
|
+
|
|
1099
|
+
```ts
|
|
1100
|
+
await db.Услуга('…0021').first() // [2.6 ms] по id → Row {name: 'Стрижка', …}
|
|
1101
|
+
await db.Услуга(['…0021', '…0022']).rows() // [2.6 ms] по списку → 2 Row
|
|
1102
|
+
await db.Организация(org).first() // [2.5 ms] Row-объект ≡ его id → 'BarberPro'
|
|
1103
|
+
await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 301
|
|
1104
|
+
await db.НетТакогоКласса().rows()
|
|
1105
|
+
// Error: letopis: unknown class "НетТакогоКласса". Known: Entity·Сущность, Org·Организация, …
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
**Кейс: одна сущность тремя формами фильтра**
|
|
1109
|
+
|
|
1110
|
+
```ts
|
|
1111
|
+
const поId = await db.Услуга('…0021').first() // [2.6 ms] → data.name = 'Стрижка'
|
|
1112
|
+
const поПолю = await db.Услуга({ name: 'Стрижка' }).first() // тот же Row
|
|
1113
|
+
const поОбъекту = await db.Услуга(поId).first() // Row как фильтр ≡ его id
|
|
1114
|
+
// все три → id '…0021', цена {RUB: 1800, USD: 20}
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
#### `db.begin(): Promise<EntityTx>`
|
|
1118
|
+
|
|
1119
|
+
Параметров нет.
|
|
1120
|
+
|
|
1121
|
+
**Назначение и алгоритм.** Открывает транзакцию: резервирует выделенное соединение пула
|
|
1122
|
+
(`sql.reserve()`), шлёт `BEGIN`, возвращает `EntityTx` — **тот же полный API** (цепочки,
|
|
1123
|
+
таблицы, auth, acl) плюс `commit()/rollback()/lock()`; соединение освобождается в
|
|
1124
|
+
`commit`/`rollback` (повторный вызов — no-op). Планы с операциями исполняются в транзакции
|
|
1125
|
+
пользователя (своя не открывается). Внутри транзакции transient-ретраи выключены: при
|
|
1126
|
+
deadlock/serialization ошибка приходит сразу с подсказкой
|
|
1127
|
+
`retry the whole db.begin() block` (§ 10.12) — повторить нужно весь блок.
|
|
1128
|
+
|
|
1129
|
+
**Примеры**
|
|
1130
|
+
|
|
1131
|
+
```ts
|
|
1132
|
+
const tr = await db.begin() // [0.8 ms]
|
|
1133
|
+
await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15, price: { RUB: 700 } }).rows()
|
|
1134
|
+
// [7.0 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
|
|
1135
|
+
await tr.commit() // [2.7 ms] — теперь видно всем
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
**Кейс: атомарный перенос с откатом при провале** — § 11.6 (`tr.lock`), плюс rollback:
|
|
589
1139
|
|
|
590
|
-
|
|
591
|
-
|
|
1140
|
+
```ts
|
|
1141
|
+
const tr = await db.begin()
|
|
1142
|
+
await tr.Услуга({ name: 'Укладка' }).update({ price: { RUB: 9900 } }).rows()
|
|
1143
|
+
await tr.Услуга({ name: 'Укладка' }).first() // внутри tx → price.RUB = 9900
|
|
1144
|
+
await tr.rollback() // [0.8 ms]
|
|
1145
|
+
await db.Услуга({ name: 'Укладка' }).first() // снаружи → price.RUB = 700 — изменение исчезло
|
|
1146
|
+
```
|
|
592
1147
|
|
|
593
|
-
|
|
1148
|
+
#### `db.commit(tr): Promise<void>` / `db.rollback(tr): Promise<void>`
|
|
594
1149
|
|
|
595
|
-
|
|
|
1150
|
+
| Параметр | Тип | Описание |
|
|
596
1151
|
|---|---|---|
|
|
597
|
-
| `
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1152
|
+
| `tr` | `EntityTx` | транзакция из `db.begin()`; без аргумента на корневом `db` — ошибка |
|
|
1153
|
+
|
|
1154
|
+
**Назначение и алгоритм.** Эквивалент `tr.commit()`/`tr.rollback()` (§ 11.6) — обе формы
|
|
1155
|
+
зовут один и тот же финализатор: `COMMIT`/`ROLLBACK` + освобождение соединения, идемпотентно.
|
|
1156
|
+
|
|
1157
|
+
**Примеры**
|
|
1158
|
+
|
|
1159
|
+
```ts
|
|
1160
|
+
await db.commit(tr3) // [2.9 ms] — то же, что tr3.commit()
|
|
1161
|
+
await db.commit()
|
|
1162
|
+
// Error: letopis: commit() needs a transaction: db.commit(tr) or tr.commit() [0.1 ms]
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
**Кейс** — § 11.6 (транзакции целиком).
|
|
607
1166
|
|
|
608
|
-
|
|
1167
|
+
#### `db.watch(cb, opts?)` / `db.watch(cls, cb, opts?): Promise<() => void>`
|
|
609
1168
|
|
|
610
|
-
|
|
|
1169
|
+
| Параметр | Тип | Описание |
|
|
611
1170
|
|---|---|---|
|
|
612
|
-
|
|
|
613
|
-
|
|
|
614
|
-
|
|
|
615
|
-
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1171
|
+
| `cls` | `string?` | класс (id или алиас): события только его; без него — все классы партиции |
|
|
1172
|
+
| `cb` | `(e: WatchEvent) => void` | колбэк на каждую вставленную версию: `{partition, class, id, updated, deleted}` — данных в payload НЕТ (дочитываются запросом при надобности) |
|
|
1173
|
+
| `opts.onReconnect` | `(() => void)?` | зовётся после каждого **восстановления** LISTEN-соединения (не на первом подключении): NOTIFY за время разрыва потеряны — точка дочитать пропущенное |
|
|
1174
|
+
| возврат | `Promise<() => void>` | stop-функция: снять подписку |
|
|
1175
|
+
|
|
1176
|
+
**Назначение и алгоритм.** Realtime-события версий: AFTER INSERT-триггер `entity_notify`
|
|
1177
|
+
шлёт `pg_notify` в канал с именем PG-схемы; watch держит выделенное LISTEN-соединение
|
|
1178
|
+
(postgres.js сам переподключается и повторяет LISTEN после обрыва). Слушатель отфильтровывает
|
|
1179
|
+
чужую партицию и, при `cls`, чужие классы. Под `enforceAcl` события отдаются **только для
|
|
1180
|
+
классов с безусловным allow** — предикатное правило по payload не проверить (§ 11.10).
|
|
1181
|
+
Tombstone-версия приходит с `deleted: true`.
|
|
1182
|
+
|
|
1183
|
+
**Примеры**
|
|
1184
|
+
|
|
1185
|
+
```ts
|
|
1186
|
+
const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [34.8 ms]
|
|
1187
|
+
await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр §11' }).rows()
|
|
1188
|
+
await db.Клиент('…0932').update({ name: 'Пётр Второй' }).rows()
|
|
1189
|
+
await db.Клиент('…0932').delete({ confirm: true }).rows()
|
|
1190
|
+
// пойманные (3 события: insert → update → tombstone):
|
|
1191
|
+
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.775349+00:00', deleted: false }
|
|
1192
|
+
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.799888+00:00', deleted: false }
|
|
1193
|
+
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.823072+00:00', deleted: true }
|
|
1194
|
+
await stop()
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
**Кейс: пережить обрыв соединения без потери хвоста**
|
|
1198
|
+
|
|
1199
|
+
```ts
|
|
1200
|
+
const stop = await db.watch('Клиент', (e) => события.push(e), {
|
|
1201
|
+
onReconnect: () => дочитатьПропущенное(), // напр. .sort('updated').after(последний курсор)
|
|
1202
|
+
})
|
|
1203
|
+
// авария: pg_terminate_backend по LISTEN-соединению …
|
|
1204
|
+
// onReconnect сработал через 0.1 s после обрыва (реальный прогон)
|
|
1205
|
+
await db.Клиент('…0932').create({ name: 'Пётр после обрыва' }).rows() // create по id — воскрешение
|
|
1206
|
+
// событие после reconnect: { class: 'Customer', id: '…0932', updated: '…35.40884+00:00', deleted: false }
|
|
1207
|
+
await stop()
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
#### `db.close(): Promise<void>`
|
|
1211
|
+
|
|
1212
|
+
Параметров нет. Закрывает пул соединений (включая LISTEN, если был). Вызовы после закрытия
|
|
1213
|
+
падают ошибкой postgres.js.
|
|
1214
|
+
|
|
1215
|
+
```ts
|
|
1216
|
+
await db.close() // [1.7 ms]
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
**Кейс** — завершение процесса: `close()` в `finally`/`SIGTERM`-хендлере после `stop()`
|
|
1220
|
+
всех watch-подписок.
|
|
1221
|
+
|
|
1222
|
+
#### `db.registry: Registry`
|
|
1223
|
+
|
|
1224
|
+
Свойство (не метод): реестр классов, загруженный на `connect`. Методы `resolve/find/has`
|
|
1225
|
+
и поле `all` — § 11.11.
|
|
1226
|
+
|
|
1227
|
+
```ts
|
|
1228
|
+
db.registry.resolve('Запись') // [90 µs] → ClassDef {id: 'Booking', alias: 'Запись', …}
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
#### `db.sql`
|
|
1232
|
+
|
|
1233
|
+
Свойство: голый клиент postgres.js того же пула — EXPLAIN, служебные запросы, тесты.
|
|
1234
|
+
Ответственность за SQL — на вызывающем (движок цепочек его не проверяет).
|
|
1235
|
+
|
|
1236
|
+
```ts
|
|
1237
|
+
await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.article"."Entity"')
|
|
1238
|
+
// [32.3 ms] → [{ n: 1112925 }]
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
**Кейс** — снятие плана тяжёлого запроса: `db.sql.unsafe('EXPLAIN (ANALYZE) …')` для
|
|
1242
|
+
запроса, который показал `slow: true` в `onQuery`.
|
|
1243
|
+
|
|
1244
|
+
### 11.3 Chain — чтение
|
|
630
1245
|
|
|
631
|
-
|
|
1246
|
+
Общая механика (одинакова для всех терминалов): каждый шаг цепочки — подзапрос
|
|
1247
|
+
«актуальная версия»: `DISTINCT ON (id) … ORDER BY id, updated DESC`, затем `deleted IS NULL`;
|
|
1248
|
+
шаги соединяются `JOIN LATERAL` по направлению связи (§ 4). Containment-фильтры
|
|
1249
|
+
(`data @>`, `links @>`, `tags @>`) сначала сужают кандидатов по GIN-индексам, потом
|
|
1250
|
+
**перепроверяются на актуальной строке** (GIN видит все версии). Терминал определяет
|
|
1251
|
+
финальную форму SQL. Чтения в автокоммите ретраятся при deadlock/serialization
|
|
1252
|
+
(до 3 попыток, backoff 40–160 ms, § 10.12). Если в цепочке есть операции записи (§ 11.5),
|
|
1253
|
+
любой терминал исполняет весь план одной транзакцией.
|
|
632
1254
|
|
|
633
|
-
|
|
1255
|
+
#### `.<Класс>(filter?: Filter): Chain` — следующий шаг
|
|
1256
|
+
|
|
1257
|
+
| Параметр | Тип | Описание |
|
|
634
1258
|
|---|---|---|
|
|
635
|
-
|
|
|
636
|
-
| `
|
|
1259
|
+
| `Класс` | имя свойства | класс следующего шага; допустимые переходы: HUB→LINK (обратный), LINK→HUB (прямой), HUB→HUB self (дети); недопустимый — ошибка `no path X → Y` |
|
|
1260
|
+
| `filter` | `Filter?` | как у `db.<Класс>` (§ 11.2) |
|
|
1261
|
+
|
|
1262
|
+
**Назначение и алгоритм.** Продолжение пути по связям: прямой переход (`e.id =
|
|
1263
|
+
prev.links->>'Класс'`) кладётся точным равенством, обратный — containment
|
|
1264
|
+
`links @> {prevClass: prevId}` в кандидаты + перепроверку. В цепочке записи шаги до
|
|
1265
|
+
операции — **контекст** (§ 11.5).
|
|
1266
|
+
|
|
1267
|
+
**Примеры**
|
|
1268
|
+
|
|
1269
|
+
```ts
|
|
1270
|
+
await db.Организация('…0001').Сотрудник().count() // [5.4 ms] → 2 (обратный hop)
|
|
1271
|
+
await db.навык().Услуга().count() // [57.2 ms] → 1001 (LINK → HUB, прямой)
|
|
1272
|
+
```
|
|
1273
|
+
|
|
1274
|
+
**Кейс: маршрут «мастер → его навыки → услуги»** — см. `.run()` ниже (тот же прогон).
|
|
1275
|
+
|
|
1276
|
+
#### `.run(): Promise<Path[]>`
|
|
1277
|
+
|
|
1278
|
+
Параметров нет.
|
|
1279
|
+
|
|
1280
|
+
**Назначение и алгоритм.** Терминал «пути» (бывший `execute()` — старое имя бросает
|
|
1281
|
+
подсказку): возвращает **все варианты пути** — по объекту на вариант, ключ = имя шага
|
|
1282
|
+
(или `.alias()`), значение = полный Row узла. Без дедупликации: сколько путей в данных,
|
|
1283
|
+
столько элементов. На плане с операциями — пути от результата последней операции.
|
|
1284
|
+
|
|
1285
|
+
**Примеры**
|
|
1286
|
+
|
|
1287
|
+
```ts
|
|
1288
|
+
await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run() // [18.9 ms]
|
|
1289
|
+
// → [{
|
|
1290
|
+
// Сотрудник: { id: '…0011', class: 'Staff', data: { name: 'Вася', roles: ['owner','master'], active: true }, links: { Org: '…0001' }, … },
|
|
1291
|
+
// навык: { id: '17773ae2-…', class: 'skill', data: {}, links: { Staff: '…0011', Service: '…0021' }, … },
|
|
1292
|
+
// Услуга: { id: '…0021', class: 'Service', data: { name: 'Стрижка', price: { RUB: 1800, USD: 20 }, duration: 60, … }, … }
|
|
1293
|
+
// }] — 1 путь, полные узлы каждого шага
|
|
1294
|
+
```
|
|
1295
|
+
|
|
1296
|
+
**Кейс: отчёт «кто что умеет» одним запросом**
|
|
1297
|
+
|
|
1298
|
+
```ts
|
|
1299
|
+
const пути = await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run() // [18.9 ms]
|
|
1300
|
+
пути.map((p) => `${p.Сотрудник.data.name} → ${p.Услуга.data.name}`)
|
|
1301
|
+
// → ['Вася → Стрижка']
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
#### `.rows(): Promise<Row[]>`
|
|
1305
|
+
|
|
1306
|
+
Параметров нет.
|
|
1307
|
+
|
|
1308
|
+
**Назначение и алгоритм.** Уникальные сущности **последнего** шага: поверх путей — внешний
|
|
1309
|
+
`DISTINCT ON (id последнего шага)`. Сравни с `count()`, который считает пути.
|
|
1310
|
+
|
|
1311
|
+
**Примеры**
|
|
1312
|
+
|
|
1313
|
+
```ts
|
|
1314
|
+
const услуги = await db.Услуга().rows() // [8.8 ms] → 402 Row
|
|
1315
|
+
// [0] = { id: '…0021', class: 'Service', data: { name: 'Стрижка', price: { RUB: 1800, USD: 20 },
|
|
1316
|
+
// active: true, duration: 60, description: 'классика' }, links: { Org: '…0001' }, tags: [],
|
|
1317
|
+
// updated: '2026-07-10T18:38:27.40772+00:00' }
|
|
1318
|
+
```
|
|
1319
|
+
|
|
1320
|
+
**Кейс: пути vs уникальные сущности**
|
|
1321
|
+
|
|
1322
|
+
```ts
|
|
1323
|
+
await db.навык().Услуга().count() // [57.2 ms] → 1001 путей (навыков на услуги)
|
|
1324
|
+
(await db.навык().Услуга().rows()).length // [60.8 ms] → 401 уникальная услуга
|
|
1325
|
+
```
|
|
1326
|
+
|
|
1327
|
+
#### `.first(): Promise<Row | null>`
|
|
1328
|
+
|
|
1329
|
+
Параметров нет. То же, что `rows()` с `LIMIT 1`: первая строка или `null`.
|
|
1330
|
+
|
|
1331
|
+
```ts
|
|
1332
|
+
await db.Сотрудник({ name: 'Олег' }).first() // [4.9 ms]
|
|
1333
|
+
// → { id: '…0012', class: 'Staff', data: { name: 'Олег', roles: ['master'], active: true },
|
|
1334
|
+
// links: { Org: '…0001' }, tags: [], updated: '2026-07-10T18:38:27.275231+00:00' }
|
|
1335
|
+
await db.Сотрудник({ name: 'Гэндальф' }).first() // [3.9 ms] → null
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
**Кейс: проверка «занято ли окно» перед бронью** — § 11.6 (перечитка под локом).
|
|
1339
|
+
|
|
1340
|
+
#### `.ids(): Promise<string[]>`
|
|
1341
|
+
|
|
1342
|
+
Параметров нет. id уникальных сущностей последнего шага — когда полные Row не нужны
|
|
1343
|
+
(дешевле по трафику).
|
|
1344
|
+
|
|
1345
|
+
```ts
|
|
1346
|
+
await db.Сотрудник().ids() // [3.8 ms] → 302 id
|
|
1347
|
+
// ['00000000-0000-4000-8000-000000000011', '…0012', '00000003-0000-4000-8000-000000000000', …]
|
|
1348
|
+
```
|
|
1349
|
+
|
|
1350
|
+
**Кейс: набор id для батч-обработки** — собрать `ids()`, скормить очереди задач; полные
|
|
1351
|
+
данные каждая задача дочитает точечным `first()` (2–3 ms).
|
|
1352
|
+
|
|
1353
|
+
#### `.count(): Promise<number>`
|
|
1354
|
+
|
|
1355
|
+
Параметров нет.
|
|
1356
|
+
|
|
1357
|
+
**Назначение и алгоритм.** `SELECT count(*)` поверх соединения шагов — считает **пути**
|
|
1358
|
+
(как `run()`), не уникальные сущности: для цепочки из одного класса это одно и то же,
|
|
1359
|
+
для многошаговой — нет (см. кейс `.rows()`).
|
|
637
1360
|
|
|
638
|
-
|
|
1361
|
+
```ts
|
|
1362
|
+
await db.Организация('…0001').Сотрудник().count() // [5.4 ms] → 2
|
|
1363
|
+
await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 301
|
|
1364
|
+
await db.Организация({ settings: { booking: { deposit: { amount: gte(900) } } } }).count()
|
|
1365
|
+
// [2.2 ms] → 0 — оператор на листе глубины 4, каст numeric по Schema
|
|
1366
|
+
```
|
|
1367
|
+
|
|
1368
|
+
**Кейс: витрина каталога** — счётчики к фильтрам без выборки строк:
|
|
1369
|
+
|
|
1370
|
+
```ts
|
|
1371
|
+
await db.Услуга({ duration: lte(45) }).count() // [3.0 ms] → 201 «быстрые»
|
|
1372
|
+
await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 301 «премиум»
|
|
1373
|
+
```
|
|
639
1374
|
|
|
640
|
-
|
|
1375
|
+
#### `.limit(n): Chain` / `.offset(n): Chain`
|
|
1376
|
+
|
|
1377
|
+
| Параметр | Тип | Описание |
|
|
641
1378
|
|---|---|---|
|
|
642
|
-
|
|
|
643
|
-
| `{}` пустой объект | `db.позиция({})` | чтение: весь класс; **set: UPDATE всех** (в границах контекста) |
|
|
644
|
-
| `string` | `db.Услуга('uuid')` | по id; set: upsert; контекст: значение связи (без запроса) |
|
|
645
|
-
| `Row`/объект с `.id` | `db.Организация(org)` | то же, что id |
|
|
646
|
-
| `string[]` | `db.Услуга(['a','b'])` | по списку id |
|
|
647
|
-
| объект | `{ name: 'X', duration: gte(30), price: { RUB: lte(2000) }, id: 'u1' }` | поля data (eq/операторы/record-пути) + ключ `id` |
|
|
1379
|
+
| `n` | `number` | максимум строк/путей (`LIMIT n`) · пропустить первые n (`OFFSET n`) |
|
|
648
1380
|
|
|
649
|
-
|
|
1381
|
+
**Назначение и алгоритм.** Прозрачные `LIMIT`/`OFFSET` в конце SQL — **после** ACL-предикатов
|
|
1382
|
+
и фильтров, поэтому страницы честные. Для глубокой пагинации offset дорожает линейно —
|
|
1383
|
+
на объёме использовать `.after()` (keyset).
|
|
650
1384
|
|
|
651
|
-
|
|
1385
|
+
**Примеры**
|
|
652
1386
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1387
|
+
```ts
|
|
1388
|
+
await db.Услуга().sort('data.price.RUB', 'desc').limit(3).rows() // [5.5 ms]
|
|
1389
|
+
// → [{ name: 'Стрижка', RUB: 1800 }, { name: 'Услуга 399', RUB: 1599 }, { name: 'Услуга 398', RUB: 1598 }]
|
|
1390
|
+
await db.Услуга().sort('data.price.RUB', 'desc').limit(3).offset(3).rows() // [5.3 ms]
|
|
1391
|
+
// → [{ RUB: 1597 }, { RUB: 1596 }, { RUB: 1595 }] — вторая страница
|
|
1392
|
+
```
|
|
658
1393
|
|
|
659
|
-
|
|
1394
|
+
**Кейс: классическая пагинация страницы каталога** — `limit(3)` + `offset(3·N)`; при выходе
|
|
1395
|
+
на сотни страниц перейти на `.after()` (ниже).
|
|
660
1396
|
|
|
661
|
-
|
|
1397
|
+
#### `.sort(field, dir?): Chain`
|
|
1398
|
+
|
|
1399
|
+
| Параметр | Тип | Описание |
|
|
662
1400
|
|---|---|---|
|
|
663
|
-
| `
|
|
664
|
-
| `
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
| `credentials.delete` | `(id) → boolean` | мягкое |
|
|
670
|
-
| `resources.find/get/set/delete` | по `alias` | set — upsert; delete каскадит Rule |
|
|
671
|
-
| `rules.find` | `({ account?, resource?, permission?, enabled? }?) → Rule[]` | weight DESC |
|
|
672
|
-
| `rules.set` | `({ account, resource, permission, weight?, meta?, enabled? }) → Rule` | upsert по PK |
|
|
673
|
-
| `rules.delete` | `(account, resource) → boolean` | |
|
|
1401
|
+
| `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.price.RUB`); SQL-каст по типу листа из Schema |
|
|
1402
|
+
| `dir` | `'asc'` \| `'desc'` \| `boolean?` | default `asc`; `true` ≡ `'desc'` |
|
|
1403
|
+
|
|
1404
|
+
**Назначение и алгоритм.** `ORDER BY` по колонке `updated` или по выражению
|
|
1405
|
+
`data->'price'->>'RUB'` с кастом (numeric/text/timestamptz — из типа листа в Schema).
|
|
1406
|
+
Обязателен для `.after()`.
|
|
674
1407
|
|
|
675
|
-
|
|
1408
|
+
**Примеры**
|
|
676
1409
|
|
|
677
1410
|
```ts
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
QueryEvent = { mode: 'paths'|'rows'|'ids'|'count'|'versions'|'agg'|'insert'|'delete',
|
|
684
|
-
classes: string[], ms: number, rows: number, slow: boolean }
|
|
685
|
-
ChainMods = { limit?, offset?, order?, desc?, asOf?, after?, aggFn?, aggField? }
|
|
686
|
-
Account = { id, categories: string[], data, meta, avatar, enabled, created, updated }
|
|
687
|
-
Credential = { id, account, category, identifier, meta, confirmed, created, updated, deleted }
|
|
688
|
-
Resource = { alias, category, pattern, meta }
|
|
689
|
-
Rule = { account, resource, permission, weight, meta, enabled }
|
|
1411
|
+
await db.Услуга().sort('data.price.RUB', 'desc').limit(3).rows() // [5.5 ms] → 1800, 1599, 1598
|
|
1412
|
+
await db.Услуга().sort('data.duration').limit(2).rows() // [6.3 ms] asc по умолчанию
|
|
1413
|
+
// → [{ name: 'Королевское бритьё', duration: 30 }, { name: 'Услуга 0', duration: 30 }]
|
|
1414
|
+
await db.Запись().sort('updated', 'desc').limit(2).rows() // [1613.1 ms]
|
|
1415
|
+
// ЧЕСТНО: DISTINCT ON всех 250k сущностей класса без фильтра — см. § 14
|
|
690
1416
|
```
|
|
691
1417
|
|
|
692
|
-
|
|
1418
|
+
**Кейс: топ прайса** — первый пример; правило объёма: сортировка **всего** большого класса
|
|
1419
|
+
без фильтра — секунды, с фильтром/контекстом — миллисекунды.
|
|
693
1420
|
|
|
694
|
-
|
|
1421
|
+
#### `.asOf(t): Chain`
|
|
1422
|
+
|
|
1423
|
+
| Параметр | Тип | Описание |
|
|
1424
|
+
|---|---|---|
|
|
1425
|
+
| `t` | `string \| Date` | момент времени (ISO-строка или Date; Date конвертируется в ISO) |
|
|
1426
|
+
|
|
1427
|
+
**Назначение и алгоритм.** Время-путешествие: внутрь каждого шага добавляется
|
|
1428
|
+
`updated <= t`, так что «актуальной версией» становится последняя **на момент t** — все
|
|
1429
|
+
терминалы (`rows/count/execute/…`) видят мир «как было тогда». Версии позже t и сущности,
|
|
1430
|
+
созданные позже, невидимы.
|
|
1431
|
+
|
|
1432
|
+
**Примеры**
|
|
1433
|
+
|
|
1434
|
+
```ts
|
|
1435
|
+
const истор = await db.Услуга('…0021').versions() // [5.1 ms] (для t1 ниже)
|
|
1436
|
+
await db.Услуга('…0021').asOf(истор[0].updated).first() // [4.8 ms]
|
|
1437
|
+
// → data.price = { RUB: 1500 } — цена ТОГДА
|
|
1438
|
+
await db.Услуга('…0021').first()
|
|
1439
|
+
// → data.price = { RUB: 1800, USD: 20 } — цена сейчас
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
**Кейс: спор по чеку** — «сколько стоила стрижка в момент оформления записи»:
|
|
1443
|
+
`db.Услуга(id).asOf(запись.updated).first()` → исторический прайс без отдельных таблиц аудита.
|
|
1444
|
+
|
|
1445
|
+
#### `.versions(): Promise<Row[]>`
|
|
1446
|
+
|
|
1447
|
+
Параметров нет.
|
|
1448
|
+
|
|
1449
|
+
**Назначение и алгоритм.** Вся история сущностей последнего шага: id находятся обычным
|
|
1450
|
+
путём (актуальные), затем джойнятся со **всеми** строками той же `(partition, class, id)` —
|
|
1451
|
+
включая tombstone (`$deleted: true`) — по возрастанию `updated`. На последнем шаге фильтр
|
|
1452
|
+
`deleted IS NULL` не применяется, чтобы историю было видно и у удалённой сущности.
|
|
1453
|
+
|
|
1454
|
+
**Примеры**
|
|
1455
|
+
|
|
1456
|
+
```ts
|
|
1457
|
+
await db.Услуга('…0021').versions() // [5.1 ms]
|
|
1458
|
+
// → [{ price: { RUB: 1500 }, updated: '…27.284125' },
|
|
1459
|
+
// { price: { RUB: 1800, USD: 20 }, updated: '…27.40772' }]
|
|
1460
|
+
await db.Запись('…0061').versions() // [6.7 ms] — жизнь с удалением и воскрешением:
|
|
1461
|
+
// → [{ status: 'created', updated: '…27.438002' },
|
|
1462
|
+
// { status: 'confirmed', updated: '…27.521769' },
|
|
1463
|
+
// { status: 'confirmed', updated: '…33.652394', $deleted: true }, ← tombstone
|
|
1464
|
+
// { status: 'created', updated: '…33.822616' }] ← воскрешение
|
|
1465
|
+
```
|
|
1466
|
+
|
|
1467
|
+
**Кейс: аудит «кто когда менял»** — `versions()` + `owner` каждой версии = полный
|
|
1468
|
+
журнал изменений сущности бесплатно (append-only хранит всё).
|
|
1469
|
+
|
|
1470
|
+
#### `.after(cursor): Chain` и `cursorOf(row, field = 'updated'): Cursor`
|
|
1471
|
+
|
|
1472
|
+
| Параметр | Тип | Описание |
|
|
1473
|
+
|---|---|---|
|
|
1474
|
+
| `cursor` | `Cursor {v, id}` | позиция «строго после»; получать `cursorOf()` |
|
|
1475
|
+
| `row` | `Row` | последняя строка текущей страницы |
|
|
1476
|
+
| `field` | `string?` | тот же field, что в `.sort()` (`'updated'` или data-путь); default `'updated'` |
|
|
1477
|
+
|
|
1478
|
+
**Назначение и алгоритм.** Keyset-пагинация: `cursorOf` снимает `{v: значение поля, id}`
|
|
1479
|
+
со строки; `.after()` строит **строгое кортежное сравнение** `(sortExpr, id) < / > (v, id)`
|
|
1480
|
+
(знак по направлению сортировки, каст как в `.sort()`). Требует `.sort()` — иначе ошибка.
|
|
1481
|
+
Стабильна при дублях значений (id — tiebreaker) и не дорожает с глубиной, в отличие от offset.
|
|
1482
|
+
|
|
1483
|
+
**Примеры**
|
|
1484
|
+
|
|
1485
|
+
```ts
|
|
1486
|
+
const p1 = await db.Запись().sort('updated', 'desc').limit(3).rows() // [1568.3 ms] — 250k
|
|
1487
|
+
const кур = cursorOf(p1.at(-1)) // [95 µs]
|
|
1488
|
+
// → { v: '2026-07-06T15:46:14.577+00:00', id: '00000008-0000-4000-8000-000000130266' }
|
|
1489
|
+
const p2 = await db.Запись().sort('updated', 'desc').after(кур).limit(3).rows() // [1587.8 ms]
|
|
1490
|
+
// p2 — следующие 3, пересечение страниц: 0
|
|
1491
|
+
|
|
1492
|
+
const курЦены = cursorOf(топ3.at(-1), 'data.price.RUB') // [45 µs] → { v: 1598, id: '…0398' }
|
|
1493
|
+
await db.Услуга().sort('data.price.RUB', 'desc').after(курЦены).limit(3).rows() // [8.9 ms]
|
|
1494
|
+
// → 1597, 1596, 1595
|
|
1495
|
+
|
|
1496
|
+
await db.Запись().after(кур).rows()
|
|
1497
|
+
// Error: letopis: .after(cursor) requires .sort(field) [0.1 ms]
|
|
1498
|
+
```
|
|
1499
|
+
|
|
1500
|
+
**Кейс: бесконечная лента записей**
|
|
1501
|
+
|
|
1502
|
+
```ts
|
|
1503
|
+
let кур
|
|
1504
|
+
for (;;) {
|
|
1505
|
+
let q = db.Запись().sort('updated', 'desc').limit(100)
|
|
1506
|
+
if (кур) q = q.after(кур)
|
|
1507
|
+
const стр = await q.rows()
|
|
1508
|
+
if (!стр.length) break
|
|
1509
|
+
обработать(стр)
|
|
1510
|
+
кур = cursorOf(стр.at(-1)) // курсор можно сериализовать в URL — { v, id }
|
|
1511
|
+
}
|
|
1512
|
+
```
|
|
1513
|
+
|
|
1514
|
+
#### `.deep(max = 32): Chain`
|
|
1515
|
+
|
|
1516
|
+
| Параметр | Тип | Описание |
|
|
1517
|
+
|---|---|---|
|
|
1518
|
+
| `max` | `number?` | максимальная глубина рекурсии; default 32 |
|
|
1519
|
+
|
|
1520
|
+
**Назначение и алгоритм.** Рекурсивный обход детей **того же класса** (self-hop:
|
|
1521
|
+
`Папка → Папка`; иной переход — ошибка): `WITH RECURSIVE` от родителя вниз по
|
|
1522
|
+
`links @> {Класс: id}`, каждая ступень — latest-паттерн. Глубина попадает в `Row.$depth`
|
|
1523
|
+
(1 = прямой ребёнок). Один SQL-запрос на всё дерево.
|
|
1524
|
+
|
|
1525
|
+
**Примеры**
|
|
1526
|
+
|
|
1527
|
+
```ts
|
|
1528
|
+
await db.Папка('…0071').Папка().deep().rows() // [13.1 ms]
|
|
1529
|
+
// → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Борода и усы', $depth: 2 }]
|
|
1530
|
+
await db.Папка('…0071').Папка().deep(1).rows() // [8.3 ms] только прямые дети
|
|
1531
|
+
// → [{ name: 'Мужской зал', $depth: 1 }]
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
**Кейс: хлебные крошки каталога** — дерево одним запросом, глубина из `$depth`:
|
|
1535
|
+
|
|
1536
|
+
```ts
|
|
1537
|
+
const дерево = await db.Папка(корень).Папка().deep().rows() // [13.1 ms]
|
|
1538
|
+
дерево.map((p) => `${' '.repeat(p.$depth)}${p.data.name}`)
|
|
1539
|
+
// → Мужской зал
|
|
1540
|
+
// Борода и усы
|
|
1541
|
+
```
|
|
1542
|
+
|
|
1543
|
+
#### `.sum(field)` / `.avg(field): Promise<number | null>`
|
|
1544
|
+
|
|
1545
|
+
| Параметр | Тип | Описание |
|
|
1546
|
+
|---|---|---|
|
|
1547
|
+
| `field` | `'data.<путь>'` | числовой лист любой глубины |
|
|
1548
|
+
|
|
1549
|
+
**Назначение и алгоритм.** Агрегат считает БД: `SELECT sum/avg((data->…->>лист)::numeric)`
|
|
1550
|
+
поверх соединения шагов (по путям, как `count()`). Пустое множество → `null`. Ответ — число
|
|
1551
|
+
(numeric приводится).
|
|
1552
|
+
|
|
1553
|
+
**Примеры**
|
|
1554
|
+
|
|
1555
|
+
```ts
|
|
1556
|
+
await db.Запись({ status: 'completed' }).sum('data.total.RUB') // [236.9 ms] → 177500000
|
|
1557
|
+
await db.Услуга().avg('data.duration') // [4.3 ms] → 52.46268656716418
|
|
1558
|
+
await db.Организация({}).sum('data.settings.booking.deposit.amount') // [2.9 ms] лист глубины 4
|
|
1559
|
+
await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [4.0 ms] → null (пусто)
|
|
1560
|
+
```
|
|
1561
|
+
|
|
1562
|
+
**Кейс: выручка за период без выгрузки строк** — `sum` по 50 000 завершённых записей
|
|
1563
|
+
за 237 ms; строки в приложение не едут.
|
|
1564
|
+
|
|
1565
|
+
#### `.min(field)` / `.max(field): Promise<unknown>`
|
|
1566
|
+
|
|
1567
|
+
Параметры — как у `.sum`. Каст по типу листа из Schema: number-поля возвращаются
|
|
1568
|
+
**числом**, string — строкой.
|
|
1569
|
+
|
|
1570
|
+
```ts
|
|
1571
|
+
await db.Услуга().min('data.price.RUB') // [3.6 ms] → 800 (число, не '800')
|
|
1572
|
+
await db.Услуга().max('data.price.RUB') // [4.2 ms] → 1800
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
**Кейс: границы ценового слайдера** — `min` + `max` двумя запросами по 3–4 ms.
|
|
1576
|
+
|
|
1577
|
+
#### `.countBy(field): Promise<Record<string, number>>`
|
|
1578
|
+
|
|
1579
|
+
| Параметр | Тип | Описание |
|
|
1580
|
+
|---|---|---|
|
|
1581
|
+
| `field` | `'data.<путь>'` | поле группировки |
|
|
1582
|
+
|
|
1583
|
+
**Назначение и алгоритм.** `SELECT значение, count(*) … GROUP BY 1 ORDER BY 2 DESC` — ключи
|
|
1584
|
+
объекта = значения поля, значения = счётчики (по путям).
|
|
1585
|
+
|
|
1586
|
+
```ts
|
|
1587
|
+
await db.Запись().countBy('data.status') // [575.8 ms] — 250k сущностей
|
|
1588
|
+
// → { confirmed: 150000, cancelled: 50000, completed: 50000, created: 1 }
|
|
1589
|
+
```
|
|
1590
|
+
|
|
1591
|
+
**Кейс: дашборд статусов** — один запрос вместо N `count()`; 250k строк агрегирует БД.
|
|
1592
|
+
|
|
1593
|
+
#### `.alias(name): Chain`
|
|
1594
|
+
|
|
1595
|
+
| Параметр | Тип | Описание |
|
|
1596
|
+
|---|---|---|
|
|
1597
|
+
| `name` | `string` | ключ ТЕКУЩЕГО шага в объектах-путях `run()` |
|
|
1598
|
+
|
|
1599
|
+
**Назначение.** Переименование ключа шага в выводе (данные и SQL не меняются) — удобно,
|
|
1600
|
+
когда один класс встречается в пути дважды.
|
|
1601
|
+
|
|
1602
|
+
```ts
|
|
1603
|
+
await db.Организация(org).alias('салон').Сотрудник({ name: 'Вася' }).alias('мастер').run()
|
|
1604
|
+
// [6.4 ms] → ключи пути: ['салон', 'мастер']
|
|
1605
|
+
```
|
|
1606
|
+
|
|
1607
|
+
**Кейс: self-join читаемо** — `db.Папка(a).alias('родитель').Папка().alias('дочка').run()`.
|
|
1608
|
+
|
|
1609
|
+
#### `.tags(v): Chain`
|
|
1610
|
+
|
|
1611
|
+
| Параметр | Тип | Описание |
|
|
1612
|
+
|---|---|---|
|
|
1613
|
+
| `v` | `string \| string[] \| has/hasAny/hasAll` | чтение: фильтр колонки `tags` (строка ≡ содержит; массив ≡ содержит все); в цепочке записи — **значение** тегов `create()`-INSERT |
|
|
1614
|
+
|
|
1615
|
+
**Назначение и алгоритм.** Фильтр по массивной колонке `tags` (`@>` — GIN-индекс,
|
|
1616
|
+
кандидаты + перепроверка). В записи — модификатор значения.
|
|
1617
|
+
|
|
1618
|
+
```ts
|
|
1619
|
+
await db.Клиент().tags('vip').count() // [3.9 ms] → 1 (среди 30k клиентов)
|
|
1620
|
+
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms] → 1
|
|
1621
|
+
```
|
|
1622
|
+
|
|
1623
|
+
**Кейс: пометить и найти** — § 5 (вставка с `.tags(['vip','telegram'])`, поиск `tags('vip')`);
|
|
1624
|
+
`.anonymize()` сам добавляет тег `anonymized` — выборка «стёртых» = `tags('anonymized')`.
|
|
1625
|
+
|
|
1626
|
+
#### `.account(v)` / `.owner(v): Chain`
|
|
1627
|
+
|
|
1628
|
+
| Параметр | Тип | Описание |
|
|
1629
|
+
|---|---|---|
|
|
1630
|
+
| `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`; при `create()` — значение колонки |
|
|
1631
|
+
|
|
1632
|
+
**Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree). Под `enforceAccount`
|
|
1633
|
+
чужой `.account()` — ошибка; под `enforceAcl` конфликт с пришпиленной правилом колонкой —
|
|
1634
|
+
ошибка `acl pins` (§ 11.10).
|
|
1635
|
+
|
|
1636
|
+
```ts
|
|
1637
|
+
await db.Организация().account(SYS).count() // [3.4 ms] → 21
|
|
1638
|
+
await db.Организация().owner(SYS).count() // [3.4 ms] → 21
|
|
1639
|
+
```
|
|
1640
|
+
|
|
1641
|
+
**Кейс: чей это салон** — профиль владельца строки: `db.accounts.get(row.owner)`; выборка
|
|
1642
|
+
всех сущностей арендатора: `.account(tenantId)`.
|
|
1643
|
+
|
|
1644
|
+
### 11.4 Операторы фильтров
|
|
1645
|
+
|
|
1646
|
+
18 функций-операторов: каждая возвращает объект-условие `Op` для значения поля в фильтре
|
|
1647
|
+
шага (`{ duration: gte(60) }`), включая record-пути (`{ price: { RUB: gte(1300) } }` —
|
|
1648
|
+
условие на листе любой глубины, SQL-каст по типу листа из Schema). Компилируются в
|
|
1649
|
+
выражение на актуальной строке (`(data->>'duration')::numeric >= 60`); containment-части
|
|
1650
|
+
дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (402 сущности).
|
|
1651
|
+
|
|
1652
|
+
#### `ne(v): Op`
|
|
1653
|
+
|
|
1654
|
+
`v: string | number | boolean | null` — «не равно» (`IS DISTINCT FROM` — null-безопасно).
|
|
1655
|
+
|
|
1656
|
+
```ts
|
|
1657
|
+
await db.Услуга({ name: ne('Стрижка') }).count() // [3.4 ms] → 401
|
|
1658
|
+
```
|
|
1659
|
+
|
|
1660
|
+
**Кейс:** всё, кроме выбранного, — «другие услуги» под карточкой текущей.
|
|
1661
|
+
|
|
1662
|
+
#### `gt(v): Op` / `gte(v): Op`
|
|
1663
|
+
|
|
1664
|
+
`v: number | string` — строго больше / больше-или-равно (числа и сравнимые строки-даты).
|
|
1665
|
+
|
|
1666
|
+
```ts
|
|
1667
|
+
await db.Услуга({ duration: gt(60) }).count() // [3.2 ms] → 100
|
|
1668
|
+
await db.Услуга({ duration: gte(60) }).count() // [4.7 ms] → 201
|
|
1669
|
+
```
|
|
1670
|
+
|
|
1671
|
+
**Кейс:** граница включительно или нет — «от часа» это `gte(60)`; `gt(60)` потеряет
|
|
1672
|
+
ровно-часовые (201 vs 100).
|
|
1673
|
+
|
|
1674
|
+
#### `lt(v): Op` / `lte(v): Op`
|
|
1675
|
+
|
|
1676
|
+
`v: number | string` — строго меньше / меньше-или-равно.
|
|
1677
|
+
|
|
1678
|
+
```ts
|
|
1679
|
+
await db.Услуга({ duration: lt(45) }).count() // [3.3 ms] → 101
|
|
1680
|
+
await db.Услуга({ duration: lte(45) }).count() // [3.0 ms] → 201
|
|
1681
|
+
```
|
|
1682
|
+
|
|
1683
|
+
**Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 201 услуга.
|
|
1684
|
+
|
|
1685
|
+
#### `between(a, b): Op`
|
|
1686
|
+
|
|
1687
|
+
`a, b: number | string` — диапазон включительно (`a ≤ x ≤ b`).
|
|
1688
|
+
|
|
1689
|
+
```ts
|
|
1690
|
+
await db.Услуга({ duration: between(40, 65) }).count() // [3.6 ms] → 201
|
|
1691
|
+
```
|
|
1692
|
+
|
|
1693
|
+
**Кейс:** слайдер длительности «40–65 минут» одной функцией вместо пары gte+lte.
|
|
1694
|
+
|
|
1695
|
+
#### `inList(vs): Op`
|
|
1696
|
+
|
|
1697
|
+
`vs: (string | number)[]` — значение из списка (`IN`).
|
|
1698
|
+
|
|
1699
|
+
```ts
|
|
1700
|
+
await db.Услуга({ name: inList(['Стрижка', 'Услуга 7']) }).count() // [2.8 ms] → 2
|
|
1701
|
+
```
|
|
1702
|
+
|
|
1703
|
+
**Кейс:** сравнение выбранных чекбоксами услуг: имена из UI → один запрос.
|
|
1704
|
+
|
|
1705
|
+
#### `like(s): Op` / `ilike(s): Op`
|
|
1706
|
+
|
|
1707
|
+
`s: string` — SQL-шаблон (`%` — любое, `_` — один символ); `ilike` — без учёта регистра.
|
|
1708
|
+
|
|
1709
|
+
```ts
|
|
1710
|
+
await db.Услуга({ name: like('Стри%') }).count() // [3.2 ms] → 1
|
|
1711
|
+
await db.Услуга({ name: ilike('%королевское%') }).count() // [3.0 ms] → 1
|
|
1712
|
+
```
|
|
1713
|
+
|
|
1714
|
+
**Кейс:** живой поиск в админке — `ilike('%' + ввод + '%')` прощает регистр
|
|
1715
|
+
(«королевское» находит «Королевское бритьё»).
|
|
1716
|
+
|
|
1717
|
+
#### `starts(s): Op` / `ends(s): Op`
|
|
1718
|
+
|
|
1719
|
+
`s: string` — начинается с / заканчивается на (сахар над `like(s+'%')` / `like('%'+s)`).
|
|
1720
|
+
|
|
1721
|
+
```ts
|
|
1722
|
+
await db.Услуга({ name: starts('Услуга 39') }).count() // [3.2 ms] → 11
|
|
1723
|
+
await db.Услуга({ name: ends('бритьё') }).count() // [3.2 ms] → 1
|
|
1724
|
+
```
|
|
1725
|
+
|
|
1726
|
+
**Кейс:** префиксная навигация по артикулам: `starts('Услуга 39')` → 39, 390…399.
|
|
1727
|
+
|
|
1728
|
+
#### `has(v): Op` / `hasAny(vs): Op` / `hasAll(vs): Op`
|
|
1729
|
+
|
|
1730
|
+
`v: скаляр`, `vs: скаляр[]` — массив-поле содержит значение / хотя бы одно / все
|
|
1731
|
+
(containment `@>` — идёт и в GIN-кандидаты).
|
|
1732
|
+
|
|
1733
|
+
```ts
|
|
1734
|
+
await db.Сотрудник({ roles: has('owner') }).count() // [2.7 ms] → 1
|
|
1735
|
+
await db.Сотрудник({ roles: hasAny(['owner', 'admin']) }).count() // [2.6 ms] → 1
|
|
1736
|
+
await db.Сотрудник({ roles: hasAll(['owner', 'master']) }).count() // [3.3 ms] → 1
|
|
1737
|
+
```
|
|
1738
|
+
|
|
1739
|
+
**Кейс:** права из массива ролей: «может закрывать смену» = `hasAny(['owner','admin'])`;
|
|
1740
|
+
«владелец, который сам стрижёт» = `hasAll(['owner','master'])`.
|
|
1741
|
+
|
|
1742
|
+
#### `exists(yes = true): Op`
|
|
1743
|
+
|
|
1744
|
+
`yes: boolean?` — поле присутствует (`true`, default) / отсутствует (`false`) в `data`.
|
|
1745
|
+
|
|
1746
|
+
```ts
|
|
1747
|
+
await db.Услуга({ description: exists() }).count() // [3.4 ms] → 1
|
|
1748
|
+
await db.Услуга({ description: exists(false) }).count() // [2.8 ms] → 401
|
|
1749
|
+
```
|
|
1750
|
+
|
|
1751
|
+
**Кейс:** контроль заполненности каталога — «услуги без описания» = `exists(false)` → 401
|
|
1752
|
+
на доработку контенту.
|
|
1753
|
+
|
|
1754
|
+
#### `isNull(): Op`
|
|
1755
|
+
|
|
1756
|
+
Без параметров — поле `NULL` **или** отсутствует.
|
|
1757
|
+
|
|
1758
|
+
```ts
|
|
1759
|
+
await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 401
|
|
1760
|
+
```
|
|
1761
|
+
|
|
1762
|
+
**Кейс:** отличие от `exists(false)`: `isNull()` ловит и явный `null` в data, и отсутствие
|
|
1763
|
+
ключа; `exists(false)` — только отсутствие.
|
|
1764
|
+
|
|
1765
|
+
#### `not(v): Op`
|
|
1766
|
+
|
|
1767
|
+
`v: скаляр | Op` — отрицание; скаляр ≡ «не равно» (как `ne`).
|
|
1768
|
+
|
|
1769
|
+
```ts
|
|
1770
|
+
await db.Услуга({ name: not(starts('Услуга')) }).count() // [3.4 ms] → 2
|
|
1771
|
+
await db.Услуга({ name: not('Стрижка') }).count() // [6.1 ms] → 401
|
|
1772
|
+
```
|
|
1773
|
+
|
|
1774
|
+
**Кейс:** инверсия готового условия без переписывания: «всё, что НЕ сид-генерация» =
|
|
1775
|
+
`not(starts('Услуга'))` → Стрижка и Королевское бритьё.
|
|
1776
|
+
|
|
1777
|
+
#### `or(...filters): Op`
|
|
1778
|
+
|
|
1779
|
+
`filters: Record<string, unknown>[]` — ИЛИ между объектами-фильтрами шага (внутри каждого —
|
|
1780
|
+
обычное И).
|
|
1781
|
+
|
|
1782
|
+
```ts
|
|
1783
|
+
await db.Услуга(or({ name: 'Стрижка' }, { duration: lt(45) })).count() // [4.0 ms] → 102
|
|
1784
|
+
```
|
|
1785
|
+
|
|
1786
|
+
**Кейс:** «стрижка или что-нибудь быстрое» — один запрос: 1 (Стрижка) + 101 (быстрые) = 102.
|
|
1787
|
+
|
|
1788
|
+
### 11.5 Запись: create / update / delete / anonymize
|
|
1789
|
+
|
|
1790
|
+
Операции — **звенья плана** (§ 6): каждая применяется к шагу, к которому приклеена точкой,
|
|
1791
|
+
и возвращает цепочку. Исполняет **терминал** — весь план одной транзакцией (внутренней,
|
|
1792
|
+
с ретраем transient; внутри `db.begin()` — транзакцией пользователя); отказ любого сегмента
|
|
1793
|
+
откатывает всё. Продолжение цепочки — от результата операции. Старый `set()` разбит на
|
|
1794
|
+
`create()`/`update()` в 0.16.0 — бросает подсказку.
|
|
1795
|
+
|
|
1796
|
+
#### `.create(data?): Chain`
|
|
1797
|
+
|
|
1798
|
+
| Параметр | Тип | Описание |
|
|
1799
|
+
|---|---|---|
|
|
1800
|
+
| `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация: лишний ключ — ошибка) + опциональный `id` (у v5-класса запрещён — id считает схема, § 3.2) |
|
|
1801
|
+
|
|
1802
|
+
**Назначение и алгоритм.** «Чтобы сущность существовала». На терминале: (1) под
|
|
1803
|
+
`enforceAcl` — WRITE-решение по классу (deny — откат; предикат правила пришпиливает
|
|
1804
|
+
`owner`/`account`); (2) владелец создаваемой связки — из валидного пути (каждый
|
|
1805
|
+
контекст-шаг обязан дать **ровно одну** сущность), прочие концы — слотами; (3) id: явный
|
|
1806
|
+
(шаг `Класс(id)` или `data.id`) либо по правилу схемы — v4/v7 генерируются, v5 вычисляется
|
|
1807
|
+
из концов/полей (§ 3.2); (4) id известен и **уже существует** (в границах пути) → **новая
|
|
1808
|
+
версия** с deep-merge — идемпотентный create, REST-PUT семантика (удалённую — воскрешает;
|
|
1809
|
+
под ACL существующая-но-недоступная **не перехватывается** → `[]`); не существует → **один
|
|
1810
|
+
INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конфликт
|
|
1811
|
+
`23505`/transient ретраится транзакцией плана. Фильтр-объект перед create — ошибка
|
|
1812
|
+
ПОСТРОЕНИЯ (`create() takes no filter`); create на pivot-шаге — ошибка.
|
|
1813
|
+
|
|
1814
|
+
**Примеры**
|
|
1815
|
+
|
|
1816
|
+
```ts
|
|
1817
|
+
await db.Организация().create({ name: 'Пилигрим' }).rows() // [9.0 ms] INSERT + defaults из Schema
|
|
1818
|
+
// → [{ id: '39aba8a5-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, … }]
|
|
1819
|
+
|
|
1820
|
+
await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
|
|
1821
|
+
// [7.0 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
|
|
1822
|
+
|
|
1823
|
+
await db.Организация('…0901').Сотрудник().create({ id: '…0911', name: 'Мия', roles: ['master'] }).rows()
|
|
1824
|
+
// [14.5 ms] контекст → links: { Org: '…0901' }
|
|
1825
|
+
|
|
1826
|
+
await db.Организация('…0901').Услуга().create({ name: 'Укладка экспресс', duration: 15, price: { RUB: 700 } }).rows()
|
|
1827
|
+
// [7.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
|
|
1828
|
+
// повторный create той же пары (салон, имя) → новая ВЕРСИЯ той же услуги, не дубль
|
|
1829
|
+
|
|
1830
|
+
db.Услуга({ name: 'Укладка экспресс' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
|
|
1831
|
+
// Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
|
|
1832
|
+
|
|
1833
|
+
await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() // [1.4 ms] — ошибка НА ТЕРМИНАЛЕ:
|
|
1834
|
+
// ValidationError: letopis: validation failed for "Service":
|
|
1835
|
+
// duration — The 'duration' field is required.; … forbidden keys: 'чепуха'
|
|
1836
|
+
|
|
1837
|
+
db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
|
|
1838
|
+
// Error: letopis: no path Service → Customer
|
|
1839
|
+
```
|
|
1840
|
+
|
|
1841
|
+
#### `.update(data?): Chain`
|
|
1842
|
+
|
|
1843
|
+
| Параметр | Тип | Описание |
|
|
1844
|
+
|---|---|---|
|
|
1845
|
+
| `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация); без аргумента — версия без изменения полей (например, ради слотов) |
|
|
1846
|
+
|
|
1847
|
+
**Назначение и алгоритм.** Новая версия **каждого** найденного путём. Цели ищутся как при
|
|
1848
|
+
чтении — id, фильтр, pivot; `Класс()` ≡ `Класс({})` — «все в границах контекста». На каждую
|
|
1849
|
+
цель: **deep-merge** листьев `data` (`update({ price: { RUB: 1100 } })` сохранит `USD` и
|
|
1850
|
+
остальные поля; массивы/скаляры — целиком), слияние links (слоты `.Класс.set()`/`.unset()`),
|
|
1851
|
+
строгая валидация, один INSERT новой версии. Не найдено → `[]` — update **НИКОГДА не
|
|
1852
|
+
создаёт**. Сегмент после операции исполняется **для каждой строки её результата** (fan-out);
|
|
1853
|
+
`update` сразу после `update` — новая версия тех же сущностей (self).
|
|
1854
|
+
|
|
1855
|
+
**Примеры**
|
|
1856
|
+
|
|
1857
|
+
```ts
|
|
1858
|
+
await db.Услуга('…0921').update({ price: { RUB: 1400, USD: 15 } }).rows() // [7.0 ms] новая версия по id
|
|
1859
|
+
// было: { name: 'Массаж головы', price: { RUB: 1200 }, duration: 30, description: 'релакс', … }
|
|
1860
|
+
// стало: { name: 'Массаж головы', price: { RUB: 1400, USD: 15 }, duration: 30, description: 'релакс', … }
|
|
1861
|
+
// deep-merge тронул только листья price; duration/description целы
|
|
1862
|
+
|
|
1863
|
+
await db.Клиент('…0931').Запись({ status: 'created' }).update({ status: 'confirmed' }).rows() // [12.1 ms]
|
|
1864
|
+
// → [{ id: '…0962', status: 'confirmed' }, { id: '…0963', status: 'confirmed' }] — только записи Златы
|
|
1865
|
+
|
|
1866
|
+
await db.Клиент('…0931').Запись().update({ status: 'completed' }).rows() // [10.7 ms] ВСЕ в контексте
|
|
1867
|
+
// → [{ id: '…0962', status: 'completed' }, { id: '…0963', status: 'completed' }]
|
|
1868
|
+
|
|
1869
|
+
await db.Запись({ status: 'нет-такого' }).update({ status: 'x' }).rows() // → [] — НИЧЕГО не создано
|
|
1870
|
+
```
|
|
1871
|
+
|
|
1872
|
+
**Кейс: план из нескольких операций — реальный прогон**
|
|
1873
|
+
|
|
1874
|
+
```ts
|
|
1875
|
+
// обновить клиента → вставить ему запись (продолжение от записанного, одна транзакция)
|
|
1876
|
+
await db.Клиент('…0931').update({ language: 'en' })
|
|
1877
|
+
.Запись().create({ id: '…0964', status: 'created', total: { RUB: 990 } })
|
|
1878
|
+
.rows() // [15.5 ms]
|
|
1879
|
+
// → [{ id: '…0964', class: 'Booking', links: { Customer: '…0931' }, status: 'created' }]
|
|
1880
|
+
|
|
1881
|
+
// self-update: две версии подряд
|
|
1882
|
+
await db.Запись('…0964').update({ status: 'confirmed' }).update({ status: 'completed' }).rows() // [13.6 ms]
|
|
1883
|
+
// → ['completed']; versions: ['created', 'confirmed', 'completed']
|
|
1884
|
+
|
|
1885
|
+
// хвост-чтение после операции — в той же транзакции
|
|
1886
|
+
await db.Клиент('…0931').update({ language: 'ru' }).Запись().count() // [201.6 ms] → 7
|
|
1887
|
+
|
|
1888
|
+
// ОТКАТ: невалидный второй сегмент откатывает и первый
|
|
1889
|
+
await db.Клиент('…0931').update({ name: 'Не запишется' }).Запись().create({ чепуха: 1 }).rows()
|
|
1890
|
+
// Error: letopis: validation failed for "Booking" … [6.1 ms]; имя клиента не изменилось
|
|
1891
|
+
|
|
1892
|
+
// fan-out: обновить клиента → снести ВСЕ его записи
|
|
1893
|
+
await db.Клиент('…0931').update({ active: true }).Запись().delete({ confirm: true }).rows()
|
|
1894
|
+
// [229.4 ms] → 7 записей, все с $deleted: true
|
|
1895
|
+
```
|
|
1896
|
+
|
|
1897
|
+
#### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
|
|
1898
|
+
|
|
1899
|
+
| Форма | Описание |
|
|
1900
|
+
|---|---|
|
|
1901
|
+
| `.Класс.set(target)` | значение конца связи новой версии; `target` = id \| Row \| вложенная цепочка |
|
|
1902
|
+
| `.Класс.unset()` | снять optional-конец (0.16.0: переименован из `.Класс.delete()` — старое имя бросает подсказку) |
|
|
1903
|
+
|
|
1904
|
+
**Назначение и алгоритм.** Слот — свойство-класс БЕЗ вызова, идёт ПОСЛЕ глагола записи
|
|
1905
|
+
(`create`/`update`); слот без операции — ошибка `link slot needs a write`. Пишет конец в
|
|
1906
|
+
links **БЕЗ участия в фильтре целей** (в отличие от шага пути, который фильтрует). Валиден
|
|
1907
|
+
только для конца из `Schema.links` владельца; союз-конец `[A|B]` замещается целиком
|
|
1908
|
+
(соседний класс снимается); дубль одного слота — ошибка. `target`-цепочка
|
|
1909
|
+
исполняется в той же транзакции и обязана дать ровно одну сущность класса конца.
|
|
1910
|
+
|
|
1911
|
+
**Примеры**
|
|
1912
|
+
|
|
1913
|
+
```ts
|
|
1914
|
+
await db.Сотрудник(м9).навык().create().Услуга.set(у9).rows() // [4.1 ms] ОДИН INSERT
|
|
1915
|
+
// → { id: '7b374ae4-…', class: 'skill', links: { Staff: '…0911', Service: '…0921' } }
|
|
1916
|
+
// навык — v5: id вычислен из (Staff, Service|Complex) — второй раз тот же навык не завести
|
|
1917
|
+
|
|
1918
|
+
// вложенная цепочка — создать И привязать в одной транзакции:
|
|
1919
|
+
await db.Запись(b).позиция().create({ qty: 1 }).Услуга.set(db.Услуга(у).update({ active: true })).rows()
|
|
1920
|
+
```
|
|
1921
|
+
|
|
1922
|
+
**Кейс: перевесить исполнителя на всех позициях записи**
|
|
1923
|
+
|
|
1924
|
+
```ts
|
|
1925
|
+
await tr.Запись(b).позиция().update().Сотрудник.set(новый).rows()
|
|
1926
|
+
// цель ищется путём (все позиции записи); слот пишет нового исполнителя БЕЗ фильтра по старому
|
|
1927
|
+
```
|
|
1928
|
+
|
|
1929
|
+
#### `.delete(opts?): Chain`
|
|
1930
|
+
|
|
1931
|
+
| Параметр | Тип | Описание |
|
|
1932
|
+
|---|---|---|
|
|
1933
|
+
| `opts.confirm` | `boolean?` | `true` — удалить; **без confirm — превью** (кандидаты, БД не тронута) |
|
|
1934
|
+
|
|
1935
|
+
**Назначение и алгоритм.** Удаление-звено: цели ищутся как при чтении (id/фильтр/контекст);
|
|
1936
|
+
рекурсивный CTE считает **полное замыкание** — цели плюс все зависимые по `links` любой
|
|
1937
|
+
глубины. Без `confirm` терминал возвращает замыкание живым (превью). С `confirm: true`:
|
|
1938
|
+
под `enforceAcl` DELETE-решение проверяется на класс целей **и каждый класс замыкания**
|
|
1939
|
+
(deny откатывает план); `DELETE` по целям будит серверный триггер: advisory-lock →
|
|
1940
|
+
tombstone-версия → рекурсивное удаление зависимых. Возврат — замыкание с `$deleted: true`.
|
|
1941
|
+
История остаётся; `create()` с тем же id — воскрешение. Продолжение цепочки — от строк
|
|
1942
|
+
класса цели.
|
|
1943
|
+
|
|
1944
|
+
**Примеры**
|
|
1945
|
+
|
|
1946
|
+
```ts
|
|
1947
|
+
await db.Запись('…0961').delete().rows() // [192.7 ms] ПРЕВЬЮ — кандидаты живы:
|
|
1948
|
+
// → [{ id: '…0961', class: 'Booking' }, { class: 'busy', … }, { class: 'item', … }] — бронь + занятость + позиция
|
|
1949
|
+
// запись жива: true
|
|
1950
|
+
|
|
1951
|
+
await db.Запись('…0961').delete({ confirm: true }).rows() // [209.2 ms] — сервер нашёл зависимых:
|
|
1952
|
+
// → те же три, каждый с $deleted: true
|
|
1953
|
+
await db.Запись('…0961').delete({ confirm: true }).rows() // [5.1 ms] повторно → []
|
|
1954
|
+
```
|
|
1955
|
+
|
|
1956
|
+
**Кейс: отмена брони с показом последствий**
|
|
1957
|
+
|
|
1958
|
+
```ts
|
|
1959
|
+
const последствия = await db.Запись(bid).delete().rows() // [192.7 ms] показать оператору
|
|
1960
|
+
if (операторПодтвердил) {
|
|
1961
|
+
await db.Запись(bid).delete({ confirm: true }).rows() // [209.2 ms] бронь + занятость + позиция
|
|
1962
|
+
}
|
|
1963
|
+
// клиент передумал: воскрешение тем же id — create (зависимые пересоздать явно)
|
|
1964
|
+
await db.Клиент('…0931').Запись('…0961').create({ status: 'created', total: { RUB: 500 } }).rows() // [11.3 ms]
|
|
1965
|
+
```
|
|
1966
|
+
|
|
1967
|
+
#### `.anonymize(fields): Chain`
|
|
1968
|
+
|
|
1969
|
+
| Параметр | Тип | Описание |
|
|
1970
|
+
|---|---|---|
|
|
1971
|
+
| `fields` | `string[]` | имена string-полей по Schema (иной тип — ошибка) |
|
|
1972
|
+
|
|
1973
|
+
**Назначение и алгоритм.** GDPR-звено: для каждой найденной сущности — новая версия, где
|
|
1974
|
+
перечисленные поля = `'[erased]'` (только присутствующие в data), плюс тег `anonymized`.
|
|
1975
|
+
Прошлые версии остаются (физическое стирание истории — политика хранения § 10.8).
|
|
1976
|
+
Под `enforceAcl` — WRITE. Продолжение — от затёртых версий.
|
|
1977
|
+
|
|
1978
|
+
**Примеры**
|
|
1979
|
+
|
|
1980
|
+
```ts
|
|
1981
|
+
await db.Клиент('…0931').anonymize(['name', 'contact']).rows() // [7.8 ms]
|
|
1982
|
+
// → [{ data: { name: '[erased]', contact: '[erased]', active: true, language: 'ru' },
|
|
1983
|
+
// tags: ['anonymized'] }]
|
|
1984
|
+
```
|
|
1985
|
+
|
|
1986
|
+
**Кейс: запрос на забвение**
|
|
1987
|
+
|
|
1988
|
+
```ts
|
|
1989
|
+
await db.Клиент('…0931').anonymize(['name', 'contact']).rows() // [7.8 ms]
|
|
1990
|
+
;(await db.Клиент('…0931').versions()).map((r) => r.data.name) // → ['Злата', '[erased]']
|
|
1991
|
+
await db.Клиент().tags('anonymized').count() // все стёртые — под контролем
|
|
1992
|
+
```
|
|
1993
|
+
|
|
1994
|
+
### 11.6 Транзакции: EntityTx
|
|
1995
|
+
|
|
1996
|
+
`EntityTx = EntityDb + { commit, rollback, lock }` — весь API (цепочки, таблицы, auth, acl)
|
|
1997
|
+
на выделенном соединении внутри `BEGIN … COMMIT`.
|
|
1998
|
+
|
|
1999
|
+
#### `tr.commit(): Promise<void>` / `tr.rollback(): Promise<void>`
|
|
2000
|
+
|
|
2001
|
+
Параметров нет.
|
|
2002
|
+
|
|
2003
|
+
**Назначение и алгоритм.** `COMMIT`/`ROLLBACK` + освобождение зарезервированного соединения;
|
|
2004
|
+
идемпотентно (повторный вызов — no-op). Эквивалентные формы: `db.commit(tr)` /
|
|
2005
|
+
`db.rollback(tr)` (§ 11.2).
|
|
2006
|
+
|
|
2007
|
+
**Примеры**
|
|
2008
|
+
|
|
2009
|
+
```ts
|
|
2010
|
+
const tr = await db.begin() // [0.8 ms]
|
|
2011
|
+
await tr.Услуга({ name: 'Укладка' }).update({ price: { RUB: 9900 } }).rows()
|
|
2012
|
+
await tr.Услуга({ name: 'Укладка' }).first() // внутри → 9900
|
|
2013
|
+
await tr.rollback() // [0.8 ms]
|
|
2014
|
+
await db.Услуга({ name: 'Укладка' }).first() // снаружи → 700, изменения нет
|
|
2015
|
+
```
|
|
2016
|
+
|
|
2017
|
+
**Кейс: commit** — § 11.2 `db.begin()`; ниже — главный сценарий `lock`.
|
|
2018
|
+
|
|
2019
|
+
#### `tr.lock(...keys): Promise<void>`
|
|
2020
|
+
|
|
2021
|
+
| Параметр | Тип | Описание |
|
|
2022
|
+
|---|---|---|
|
|
2023
|
+
| `keys` | `(string \| number)[]` | составной ключ ресурса; склеивается через `\|` и хэшируется |
|
|
2024
|
+
|
|
2025
|
+
**Назначение и алгоритм.** Advisory-lock на составной ключ:
|
|
2026
|
+
`pg_advisory_xact_lock(hashtextextended(keys.join('|'), 0))` — сериализует соперников на
|
|
2027
|
+
одном ключе (второй **висит** до конца транзакции первого), отпускается автоматически на
|
|
2028
|
+
`commit`/`rollback`. Только внутри `db.begin()` — на корневом `db` ошибка. Взаимная блокировка
|
|
2029
|
+
двух транзакций → PG убивает одну (deadlock 40P01); ошибка приходит с подсказкой ретраить
|
|
2030
|
+
весь блок.
|
|
2031
|
+
|
|
2032
|
+
**Примеры**
|
|
2033
|
+
|
|
2034
|
+
```ts
|
|
2035
|
+
await trA.lock('busy', staffId, slotId) // [1.4 ms]
|
|
2036
|
+
await db.lock('x')
|
|
2037
|
+
// Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.1 ms]
|
|
2038
|
+
```
|
|
2039
|
+
|
|
2040
|
+
**Кейс: гонка двойной брони — реальный прогон двух транзакций**
|
|
2041
|
+
|
|
2042
|
+
```ts
|
|
2043
|
+
// два администратора жмут «забронировать» на одно окно одновременно;
|
|
2044
|
+
// id занятости детерминирован (v5, § 3.2) — известен ДО создания:
|
|
2045
|
+
const busyId = uuidv5(`v1.article:entity:busy:${окно.id}:${мастер.id}`)
|
|
2046
|
+
const trA = await db.begin(), trB = await db.begin()
|
|
2047
|
+
await trA.lock('busy', мастер.id, окно.id) // [1.4 ms] A первый
|
|
2048
|
+
const гонкаB = (async () => {
|
|
2049
|
+
await trB.lock('busy', мастер.id, окно.id) // B ВИСИТ до конца trA
|
|
2050
|
+
const занято = await trB.занятость(busyId).first() // перечитка под локом
|
|
2051
|
+
if (занято) { await trB.rollback(); return 'ОТКАЗ: окно уже занято' }
|
|
2052
|
+
await trB.Сотрудник(мастер).занятость().create({ kind: 'booking' }).Окно.set(окно).rows()
|
|
2053
|
+
await trB.commit(); return 'бронь моя'
|
|
2054
|
+
})()
|
|
2055
|
+
await trA.занятость(busyId).first() // → null — свободно
|
|
2056
|
+
await trA.Сотрудник(мастер).занятость().create({ kind: 'booking' }).Окно.set(окно).rows()
|
|
2057
|
+
await trA.commit()
|
|
2058
|
+
await гонкаB // → 'ОТКАЗ: окно уже занято' — B увидел бронь A, дубля нет
|
|
2059
|
+
// дубль невозможен и без лока (оба create вычислят ОДИН id — второй стал бы версией);
|
|
2060
|
+
// лок нужен, чтобы B получил честный отказ, а не молча версионировал чужую бронь
|
|
2061
|
+
|
|
2062
|
+
// а если два лока взять в разном порядке — деадлок, жертва получает:
|
|
2063
|
+
// Error: deadlock detected — letopis: transaction is aborted, retry the whole db.begin() block
|
|
2064
|
+
```
|
|
2065
|
+
|
|
2066
|
+
### 11.7 Batch
|
|
2067
|
+
|
|
2068
|
+
#### `db.batch(name): Batch`
|
|
2069
|
+
|
|
2070
|
+
| Параметр | Тип | Описание |
|
|
2071
|
+
|---|---|---|
|
|
2072
|
+
| `name` | `string` | имя очереди: `db.batch('x')` с тем же именем возвращает ту же очередь |
|
|
2073
|
+
|
|
2074
|
+
**Назначение и алгоритм.** Именованная очередь ПЛАНОВ записи: цепочки те же, операции
|
|
2075
|
+
кладут план в очередь (многосегментная цепочка — один элемент очереди); терминал на такой
|
|
2076
|
+
цепочке — ошибка `plan is queued in the batch`. Исполняет `run()`.
|
|
2077
|
+
|
|
2078
|
+
```ts
|
|
2079
|
+
const b = db.batch('слоты-августа') // [16 µs]
|
|
2080
|
+
```
|
|
2081
|
+
|
|
2082
|
+
#### `batch.run(): Promise<Row[][]>`
|
|
2083
|
+
|
|
2084
|
+
Параметров нет.
|
|
2085
|
+
|
|
2086
|
+
**Назначение и алгоритм.** Вся очередь — **одна транзакция** (бывший `execute()`);
|
|
2087
|
+
результаты по порядку планов (`Row[]` на план; у многосегментного — результат последнего
|
|
2088
|
+
сегмента). Подряд идущие чистые `create()` одного класса (план из одного шага, без `data.id`
|
|
2089
|
+
и слотов; id класса не v5 — § 3.2) склеиваются в **один multi-VALUES INSERT**; под
|
|
2090
|
+
`enforceAcl` WRITE-проверка и пришпиливание — на каждый элемент склейки. Transient-ошибка
|
|
2091
|
+
ретраит всю транзакцию целиком. Очередь очищается.
|
|
2092
|
+
|
|
2093
|
+
**Примеры**
|
|
2094
|
+
|
|
2095
|
+
```ts
|
|
2096
|
+
const b = db.batch('слоты-августа')
|
|
2097
|
+
b.Расписание(sch).Окно().create({ start: '2026-08-01T11:00:00Z', end: '…12:00Z' })
|
|
2098
|
+
b.Расписание(sch).Окно().create({ start: '2026-08-01T12:00:00Z', end: '…13:00Z' })
|
|
2099
|
+
b.Расписание(sch).Окно().create({ start: '2026-08-01T13:00:00Z', end: '…14:00Z' })
|
|
2100
|
+
b.size() // [29 µs] → 3
|
|
2101
|
+
await b.run() // [32.1 ms] — одна транзакция; Окно — v5-класс → 3 честных INSERT, не склейка
|
|
2102
|
+
// → [[{ start: '2026-08-01T11:00:00.000Z', … }], [{ …12:00 }], [{ …13:00 }]]
|
|
2103
|
+
// id каждого окна вычислен схемой: uuidv5(Schedule, start) — § 3.2
|
|
2104
|
+
b.size() // → 0
|
|
2105
|
+
```
|
|
2106
|
+
|
|
2107
|
+
**Кейс: генерация расписания на день** — 3 окна одной транзакцией (выше); упавшая
|
|
2108
|
+
валидация любого окна откатывает все; v7-классы без `data.id` и слотов склеились бы в
|
|
2109
|
+
один multi-VALUES INSERT. Терминал на плане в батче:
|
|
2110
|
+
`план.rows()` → `Error: letopis: plan is queued in the batch — call batch.run()`.
|
|
2111
|
+
|
|
2112
|
+
#### `batch.discard(): void` / `batch.size(): number`
|
|
2113
|
+
|
|
2114
|
+
Параметров нет. `discard` очищает очередь без исполнения; `size` — число накопленных операций.
|
|
2115
|
+
|
|
2116
|
+
```ts
|
|
2117
|
+
const b2 = db.batch('отмена')
|
|
2118
|
+
b2.Расписание(sch).Окно().create({ start: '2026-08-02T11:00:00Z', end: '…12:00Z' })
|
|
2119
|
+
b2.discard() // [53 µs]
|
|
2120
|
+
b2.size() // → 0
|
|
2121
|
+
await db.Расписание(sch).Окно({ start: '2026-08-02T11:00:00Z' }).first() // → null — не исполнилось
|
|
2122
|
+
```
|
|
2123
|
+
|
|
2124
|
+
**Кейс: черновик импорта** — копим операции по мере парсинга файла; ошибка парсера →
|
|
2125
|
+
`discard()`, полный успех → `run()`.
|
|
2126
|
+
|
|
2127
|
+
### 11.8 Таблицы: accounts / credentials / resources / rules
|
|
2128
|
+
|
|
2129
|
+
Обычные таблицы (UPDATE/DELETE стандартные, без версионирования Entity), доступны на `db`
|
|
2130
|
+
и `tr`. jsonb-поля здесь заменяются **целиком** (не deep-merge).
|
|
2131
|
+
|
|
2132
|
+
#### `db.accounts.find(f?): Promise<Account[]>`
|
|
2133
|
+
|
|
2134
|
+
| Параметр | Тип | Описание |
|
|
2135
|
+
|---|---|---|
|
|
2136
|
+
| `f.id` | `string?` | по id |
|
|
2137
|
+
| `f.enabled` | `boolean?` | по флагу |
|
|
2138
|
+
| `f.category` | `string?` | **вхождение** в массив `categories` |
|
|
2139
|
+
|
|
2140
|
+
**Назначение и алгоритм.** SELECT с составным WHERE из переданных фильтров;
|
|
2141
|
+
`category` → `= ANY(categories)`.
|
|
2142
|
+
|
|
2143
|
+
```ts
|
|
2144
|
+
await db.accounts.find({ category: 'Client', enabled: true }) // [2.2 ms] → 3 аккаунта
|
|
2145
|
+
```
|
|
2146
|
+
|
|
2147
|
+
**Кейс:** список арендаторов для биллинга: `find({ enabled: true })`, отключённые не в счёте.
|
|
2148
|
+
|
|
2149
|
+
#### `db.accounts.get(id): Promise<Account | null>`
|
|
2150
|
+
|
|
2151
|
+
`id: string` — точечный SELECT по PK.
|
|
2152
|
+
|
|
2153
|
+
```ts
|
|
2154
|
+
await db.accounts.get(acc.id) // [1.7 ms] → Account | null
|
|
2155
|
+
```
|
|
2156
|
+
|
|
2157
|
+
**Кейс:** профиль владельца строки Entity: `db.accounts.get(row.owner)`.
|
|
2158
|
+
|
|
2159
|
+
#### `db.accounts.set(a): Promise<Account>`
|
|
2160
|
+
|
|
2161
|
+
| Параметр | Тип | Описание |
|
|
2162
|
+
|---|---|---|
|
|
2163
|
+
| `a.id` | `string?` | есть — UPDATE по id (0 строк — ошибка); нет — INSERT |
|
|
2164
|
+
| `a.categories` | `string[]?` | группы (субъекты ACL § 9.2) |
|
|
2165
|
+
| `a.data` / `a.meta` | `object?` | произвольные jsonb (заменяются целиком) |
|
|
2166
|
+
| `a.avatar` | `string?` | URL |
|
|
2167
|
+
| `a.enabled` | `boolean?` | выключенный аккаунт не проходит verify-ворота § 11.9 |
|
|
2168
|
+
|
|
2169
|
+
**Назначение и алгоритм.** INSERT переданных колонок (без `ON CONFLICT`) либо UPDATE
|
|
2170
|
+
только переданных полей + `updated = now()`.
|
|
2171
|
+
|
|
2172
|
+
```ts
|
|
2173
|
+
const acc = await db.accounts.set({ categories: ['Client'], data: { название: 'ИП Ромашка' } })
|
|
2174
|
+
// [10.6 ms] → { id: 'c66766f7-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
|
|
2175
|
+
// meta: {}, avatar: 'https://i.pravatar.cc/128?img=37', enabled: true, created: …, updated: … }
|
|
2176
|
+
await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [3.0 ms] update
|
|
2177
|
+
```
|
|
2178
|
+
|
|
2179
|
+
**Кейс:** бан аккаунта одним полем: `set({ id, enabled: false })` — все `verify*` § 11.9
|
|
2180
|
+
мгновенно дают null (прогон в § 11.9 verifyPassword).
|
|
2181
|
+
|
|
2182
|
+
#### `db.accounts.delete(id): Promise<boolean>`
|
|
2183
|
+
|
|
2184
|
+
`id: string` — **физический** DELETE (`Credential` уйдут FK-каскадом). Аккаунт с историей
|
|
2185
|
+
в Entity (`account`/`owner` FK RESTRICT) не удалить — намеренно: история неприкосновенна.
|
|
2186
|
+
|
|
2187
|
+
```ts
|
|
2188
|
+
await db.accounts.delete(времId) // [9.9 ms] → true (пустой аккаунт)
|
|
2189
|
+
await db.accounts.delete(SYS) // [4.9 ms]
|
|
2190
|
+
// Error: update or delete on table "Account" violates foreign key constraint "entity_account_fk"
|
|
2191
|
+
```
|
|
2192
|
+
|
|
2193
|
+
**Кейс:** чистка мусорной регистрации — удалять можно только то, что не оставило следов;
|
|
2194
|
+
след есть → `enabled: false` вместо удаления.
|
|
2195
|
+
|
|
2196
|
+
#### `db.credentials.find(f?): Promise<Credential[]>`
|
|
2197
|
+
|
|
2198
|
+
| Параметр | Тип | Описание |
|
|
2199
|
+
|---|---|---|
|
|
2200
|
+
| `f.id` / `f.account` / `f.category` / `f.identifier` | `string?` | равенство |
|
|
2201
|
+
| `f.confirmed` | `boolean?` | по флагу |
|
|
2202
|
+
| `f.withDeleted` | `boolean?` | включить мягко-удалённые (default — только живые `deleted IS NULL`) |
|
|
2203
|
+
|
|
2204
|
+
```ts
|
|
2205
|
+
await db.credentials.find({ account: acc.id }) // [2.5 ms] → 1 живой
|
|
2206
|
+
await db.credentials.find({ account: acc.id, withDeleted: true }) // → 1 (после delete: 0 и 1)
|
|
2207
|
+
```
|
|
2208
|
+
|
|
2209
|
+
**Кейс:** экран «способы входа» в личном кабинете: `find({ account })` — пароль, ключи,
|
|
2210
|
+
telegram списком.
|
|
2211
|
+
|
|
2212
|
+
#### `db.credentials.set(c): Promise<Credential>`
|
|
2213
|
+
|
|
2214
|
+
| Параметр | Тип | Описание |
|
|
2215
|
+
|---|---|---|
|
|
2216
|
+
| `c.account` | `string` | владелец |
|
|
2217
|
+
| `c.category` | `string` | тип креда (`PASSWORD`, `APIKEY`, `TELEGRAM`, свой) |
|
|
2218
|
+
| `c.identifier` | `string` | логин/почта/id — уникален среди живых во всей схеме (`credential_identity_udx`) |
|
|
2219
|
+
| `c.meta` | `object?` | полезная нагрузка (хэши — § 11.9) |
|
|
2220
|
+
| `c.confirmed` | `boolean?` | default true |
|
|
2221
|
+
|
|
2222
|
+
**Назначение и алгоритм.** UPSERT `ON CONFLICT (account, category, identifier) DO UPDATE
|
|
2223
|
+
… deleted = NULL` — повторный set **воскрешает** мягко-удалённый кред (тот же id).
|
|
2224
|
+
|
|
2225
|
+
```ts
|
|
2226
|
+
const кред = await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
|
|
2227
|
+
// [3.8 ms] → { id: '0d1e98b7-…', confirmed: true, deleted: null, … }
|
|
2228
|
+
await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
|
|
2229
|
+
// [3.0 ms] после delete → тот же id, deleted = null — воскрешение
|
|
2230
|
+
```
|
|
2231
|
+
|
|
2232
|
+
**Кейс:** смена номера телефона: `delete(старый)` + `set(новый)`; передумали — повторный
|
|
2233
|
+
`set(старый)` вернёт кред без потери id.
|
|
2234
|
+
|
|
2235
|
+
#### `db.credentials.delete(id): Promise<boolean>`
|
|
2236
|
+
|
|
2237
|
+
`id: string` — **мягкое** удаление: `deleted = now()` (только живого). Identifier
|
|
2238
|
+
освобождается для других аккаунтов (§ 11.9).
|
|
2239
|
+
|
|
2240
|
+
```ts
|
|
2241
|
+
await db.credentials.delete(кред.id) // [3.7 ms] → true; find() больше не видит
|
|
2242
|
+
```
|
|
2243
|
+
|
|
2244
|
+
**Кейс:** отзыв api-ключа: `delete(credential.id)` → `verifyApiKey` мгновенно null
|
|
2245
|
+
(прогон § 11.9).
|
|
2246
|
+
|
|
2247
|
+
#### `db.resources.find(f?)` / `get(alias)` / `set(r)` / `delete(alias)`
|
|
2248
|
+
|
|
2249
|
+
| Метод | Параметры | Возврат |
|
|
2250
|
+
|---|---|---|
|
|
2251
|
+
| `find` | `f.category?: string` | `Resource[]` |
|
|
2252
|
+
| `get` | `alias: string` | `Resource \| null` |
|
|
2253
|
+
| `set` | `{ alias, category, pattern?, meta? }` | `Resource` (upsert по alias) |
|
|
2254
|
+
| `delete` | `alias: string` | `boolean` — физический; Rule на ресурс уходят FK-каскадом |
|
|
2255
|
+
|
|
2256
|
+
**Назначение и алгоритм.** CRUD словаря ACL (§ 9.2): `category` — роль ресурса
|
|
2257
|
+
(`ACCOUNT`/`API`/`READ`/`WRITE`/`DELETE`), `pattern` — шаблон (маска эндпоинта, группа
|
|
2258
|
+
категорий, шаблон строки Entity). `set` — `INSERT … ON CONFLICT (alias) DO UPDATE`.
|
|
2259
|
+
|
|
2260
|
+
```ts
|
|
2261
|
+
await db.resources.set({ alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' } })
|
|
2262
|
+
// [3.0 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
|
|
2263
|
+
await db.resources.get('apiref.demo:API') // [1.8 ms] → тот же Resource
|
|
2264
|
+
await db.resources.find({ category: 'API' }) // [2.2 ms] → 15 ресурсов
|
|
2265
|
+
await db.resources.delete('apiref.demo:API') // [3.0 ms] → true
|
|
2266
|
+
```
|
|
2267
|
+
|
|
2268
|
+
**Кейс:** полный словарь для нового тарифа — § 11.10 (6 ресурсов + 5 правил одним блоком).
|
|
2269
|
+
|
|
2270
|
+
#### `db.rules.find(f?)` / `set(r)` / `delete(account, resource)`
|
|
2271
|
+
|
|
2272
|
+
| Метод | Параметры | Возврат |
|
|
2273
|
+
|---|---|---|
|
|
2274
|
+
| `find` | `f: { account?, resource?, permission?, enabled? }?` | `Rule[]` (weight DESC) |
|
|
2275
|
+
| `set` | `{ account, resource, permission, weight?, meta?, enabled? }` | `Rule` (upsert по PK `(account, resource)`) |
|
|
2276
|
+
| `delete` | `account: string, resource: string` | `boolean` |
|
|
2277
|
+
|
|
2278
|
+
**Назначение и алгоритм.** Стрелки «группа → группа»: оба конца — **alias ресурсов**
|
|
2279
|
+
(субъект — ресурс `ACCOUNT`-категории), `permission: 'allow' | 'deny'`, `weight` решает
|
|
2280
|
+
победителя (§ 11.10). `enabled: false` выключает правило без удаления.
|
|
2281
|
+
|
|
2282
|
+
```ts
|
|
2283
|
+
await db.rules.set({ account: 'apiref.demo:API', resource: 'apiref.demo:API', permission: 'allow', weight: 90 })
|
|
2284
|
+
// [3.6 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
|
|
2285
|
+
await db.rules.find({ resource: 'apiref.demo:API' }) // [2.3 ms] → 1
|
|
2286
|
+
await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [2.7 ms] → true
|
|
2287
|
+
```
|
|
2288
|
+
|
|
2289
|
+
**Кейс:** временный бан группы: `set({ account: группа, resource: цель, permission: 'deny',
|
|
2290
|
+
weight: 1000 })` перебивает любые allow (§ 9.2); снять — `delete` или `enabled: false`.
|
|
2291
|
+
|
|
2292
|
+
### 11.9 db.auth — вход и сессии
|
|
2293
|
+
|
|
2294
|
+
Слой над `db.credentials` (§ 9.1). Общее для всех `verify*`/`lookup`: возврат
|
|
2295
|
+
`AuthResult { account, credential } | null`; **три ворот по порядку** — кред жив
|
|
2296
|
+
(`deleted IS NULL`), кред подтверждён (`confirmed`; отключаемо `requireConfirmed: false`),
|
|
2297
|
+
аккаунт `enabled`. Однозначность identifier гарантирует БД: `credential_identity_udx` —
|
|
2298
|
+
один живой кред на `(category, identifier)` во всей схеме. `account` всюду принимает
|
|
2299
|
+
uuid-строку или объект с `.id`.
|
|
2300
|
+
|
|
2301
|
+
#### `db.auth.setPassword(a): Promise<Credential>`
|
|
2302
|
+
|
|
2303
|
+
| Параметр | Тип | Описание |
|
|
2304
|
+
|---|---|---|
|
|
2305
|
+
| `a.account` | `string \| { id }` | владелец |
|
|
2306
|
+
| `a.identifier` | `string` | логин или почта |
|
|
2307
|
+
| `a.password` | `string` | пароль открытым текстом (в БД не попадает) |
|
|
2308
|
+
| `a.category` | `string?` | default `'PASSWORD'`; почта/пароль и логин/пароль различаются категорией |
|
|
2309
|
+
| `a.confirmed` | `boolean?` | default true; false — до подтверждения почты |
|
|
2310
|
+
|
|
2311
|
+
**Назначение и алгоритм.** Upsert креда с хэшем в `meta.password`: scrypt из node:crypto
|
|
2312
|
+
(`N=32768, r=8, p=1`, keylen 32, соль 16 байт) в формате `scrypt$N$r$p$salt$hash` — параметры
|
|
2313
|
+
в строке, апгрейд стоимости не ломает старые хэши. Повторный вызов — ротация пароля
|
|
2314
|
+
(тот же кред). Стоимость scrypt задаёт время (~60–80 ms) — намеренная цена подбора.
|
|
2315
|
+
|
|
2316
|
+
```ts
|
|
2317
|
+
await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2318
|
+
// [78.0 ms] → Credential; в БД вместо пароля:
|
|
2319
|
+
// meta.password = "scrypt$32768$8$1$PSHhWqCsjR+Yne0tUwbVPQ==$ls…"
|
|
2320
|
+
```
|
|
2321
|
+
|
|
2322
|
+
**Кейс** — регистрация + вход + сессия: см. `sessions()` ниже (полный флоу).
|
|
2323
|
+
|
|
2324
|
+
#### `db.auth.verifyPassword(a): Promise<AuthResult | null>`
|
|
2325
|
+
|
|
2326
|
+
| Параметр | Тип | Описание |
|
|
2327
|
+
|---|---|---|
|
|
2328
|
+
| `a.identifier` | `string` | логин/почта |
|
|
2329
|
+
| `a.password` | `string` | проверяемый пароль |
|
|
2330
|
+
| `a.category` | `string?` | default `'PASSWORD'` |
|
|
2331
|
+
| `a.requireConfirmed` | `boolean?` | `false` — пускать и неподтверждённые креды |
|
|
2332
|
+
|
|
2333
|
+
**Назначение и алгоритм.** Ищет живой кред по `(category, identifier)`, сверяет scrypt-хэш
|
|
2334
|
+
`timingSafeEqual`-ом, прогоняет три ворот. Если кред не найден — **dummy-verify** (scrypt на
|
|
2335
|
+
фиктивной соли): время ответа «нет такого пользователя» неотличимо от «пароль неверен» —
|
|
2336
|
+
перечисление identifier по таймингу не работает.
|
|
2337
|
+
|
|
2338
|
+
**Примеры**
|
|
2339
|
+
|
|
2340
|
+
```ts
|
|
2341
|
+
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [65.9 ms]
|
|
2342
|
+
// → { account: { id: 'c66766f7-…', categories: ['Client'], enabled: true, … },
|
|
2343
|
+
// credential: { category: 'PASSWORD', identifier: 'romashka@salon.io', … } }
|
|
2344
|
+
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [63.1 ms] → null
|
|
2345
|
+
await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [65.2 ms] → null
|
|
2346
|
+
// незнакомый identifier — то же время (dummy-verify)
|
|
2347
|
+
```
|
|
2348
|
+
|
|
2349
|
+
**Кейс: ворота confirmed и enabled на живом прогоне**
|
|
2350
|
+
|
|
2351
|
+
```ts
|
|
2352
|
+
await db.auth.setPassword({ account: acc, identifier: 'noconfirm@salon.io', password: 'пароль-77',
|
|
2353
|
+
category: 'EMAIL', confirmed: false })
|
|
2354
|
+
await db.auth.verifyPassword({ identifier: 'noconfirm@salon.io', password: 'пароль-77', category: 'EMAIL' })
|
|
2355
|
+
// [74.2 ms] → null — кред не подтверждён
|
|
2356
|
+
await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [65.7 ms] → { account, credential }
|
|
2357
|
+
await db.accounts.set({ id: acc.id, enabled: false })
|
|
2358
|
+
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2359
|
+
// [81.9 ms] → null — аккаунт выключен, пароль уже не важен
|
|
2360
|
+
```
|
|
2361
|
+
|
|
2362
|
+
#### `db.auth.issueApiKey(a): Promise<{ key, credential }>`
|
|
2363
|
+
|
|
2364
|
+
| Параметр | Тип | Описание |
|
|
2365
|
+
|---|---|---|
|
|
2366
|
+
| `a.account` | `string \| { id }` | владелец |
|
|
2367
|
+
| `a.name` | `string?` | человекочитаемая метка (в `meta.name`) |
|
|
2368
|
+
|
|
2369
|
+
**Назначение и алгоритм.** Генерирует ключ `lts_<48 hex>` (24 случайных байта); в БД —
|
|
2370
|
+
**только sha256(key)** как identifier + `prefix` (первые символы — для UI «какой это ключ»).
|
|
2371
|
+
Сам ключ возвращается **один раз**; утечка БД ключи не раскрывает.
|
|
2372
|
+
|
|
2373
|
+
```ts
|
|
2374
|
+
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [3.6 ms]
|
|
2375
|
+
// key = 'lts_2e14a34f5895a8892c6238d2f2a0620e1b9c3145e491c6d3' ← показать и забыть
|
|
2376
|
+
// в БД: identifier = '4d0b27202e76f6fb…' (sha256), meta = { name: 'касса-1', prefix: 'lts_2e14a34f' }
|
|
2377
|
+
```
|
|
2378
|
+
|
|
2379
|
+
**Кейс** — см. `verifyApiKey` (выпуск → проверка → отзыв).
|
|
2380
|
+
|
|
2381
|
+
#### `db.auth.verifyApiKey(key, opts?): Promise<AuthResult | null>`
|
|
2382
|
+
|
|
2383
|
+
| Параметр | Тип | Описание |
|
|
2384
|
+
|---|---|---|
|
|
2385
|
+
| `key` | `string` | ключ как есть (`lts_…`) |
|
|
2386
|
+
| `opts.requireConfirmed` | `boolean?` | как в verifyPassword |
|
|
2387
|
+
|
|
2388
|
+
**Назначение и алгоритм.** `sha256(key)` → точечный поиск креда по identifier → ворота.
|
|
2389
|
+
Быстрый (без scrypt): ключ высокоэнтропийный, подбор бессмыслен.
|
|
2390
|
+
|
|
2391
|
+
```ts
|
|
2392
|
+
await db.auth.verifyApiKey(key) // [3.5 ms] → { account: c66766f7…, credential }
|
|
2393
|
+
```
|
|
2394
|
+
|
|
2395
|
+
**Кейс: полный жизненный цикл ключа**
|
|
2396
|
+
|
|
2397
|
+
```ts
|
|
2398
|
+
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [3.6 ms]
|
|
2399
|
+
await db.auth.verifyApiKey(key) // [3.5 ms] → { account, credential } — касса работает
|
|
2400
|
+
await db.credentials.delete(credential.id) // отзыв (мягкий)
|
|
2401
|
+
await db.auth.verifyApiKey(key) // [1.7 ms] → null — мгновенно недействителен
|
|
2402
|
+
```
|
|
2403
|
+
|
|
2404
|
+
#### `db.auth.issueKeySecret(a): Promise<{ key, secret, credential }>`
|
|
2405
|
+
|
|
2406
|
+
Параметры — как у `issueApiKey`.
|
|
2407
|
+
|
|
2408
|
+
**Назначение и алгоритм.** Пара для интеграций: `key` (8 байт hex) — **открытый id пары**
|
|
2409
|
+
(identifier как есть), `secret` (24 байта hex) — в БД только sha256 в `meta.secret`,
|
|
2410
|
+
возвращается один раз.
|
|
2411
|
+
|
|
2412
|
+
```ts
|
|
2413
|
+
const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [3.3 ms]
|
|
2414
|
+
// key = '05aaaf12e20fb957'; secret = '88f0f27c98ff…' (48 hex, показан один раз)
|
|
2415
|
+
```
|
|
2416
|
+
|
|
2417
|
+
#### `db.auth.verifyKeySecret(key, secret, opts?): Promise<AuthResult | null>`
|
|
2418
|
+
|
|
2419
|
+
| Параметр | Тип | Описание |
|
|
2420
|
+
|---|---|---|
|
|
2421
|
+
| `key` | `string` | открытый id пары |
|
|
2422
|
+
| `secret` | `string` | секрет |
|
|
2423
|
+
| `opts.requireConfirmed` | `boolean?` | — |
|
|
2424
|
+
|
|
2425
|
+
**Алгоритм.** Кред по identifier = key, `timingSafeEqual(sha256(secret), meta.secret)`, ворота.
|
|
2426
|
+
|
|
2427
|
+
```ts
|
|
2428
|
+
await db.auth.verifyKeySecret(key, secret) // [3.7 ms] → { account, credential }
|
|
2429
|
+
await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [1.5 ms] → null
|
|
2430
|
+
```
|
|
2431
|
+
|
|
2432
|
+
**Кейс:** серверная интеграция (1С, платёжка): key хранится в конфиге открыто и светится
|
|
2433
|
+
в логах безопасно, secret — в секрет-хранилище; ротация = повторный `issueKeySecret`.
|
|
2434
|
+
|
|
2435
|
+
#### `db.auth.enrollTotp(a): Promise<{ secret, uri, credential }>`
|
|
2436
|
+
|
|
2437
|
+
| Параметр | Тип | Описание |
|
|
2438
|
+
|---|---|---|
|
|
2439
|
+
| `a.account` | `string \| { id }` | владелец (один TOTP-фактор на аккаунт) |
|
|
2440
|
+
| `a.issuer` | `string?` | имя сервиса в приложении-аутентификаторе |
|
|
2441
|
+
| `a.label` | `string?` | подпись аккаунта (обычно почта) |
|
|
2442
|
+
|
|
2443
|
+
**Назначение и алгоритм.** Заводит TOTP-фактор (RFC 6238: SHA1, 6 цифр, шаг 30 s): секрет —
|
|
2444
|
+
base32(20 случайных байт), `uri` — готовая строка `otpauth://totp/…` для QR-кода. Кред
|
|
2445
|
+
создаётся с `confirmed: false` — фактор **активируется первой успешной проверкой**
|
|
2446
|
+
(пользователь доказал, что добавил секрет в приложение). Повторный enroll перезаписывает
|
|
2447
|
+
секрет и сбрасывает активацию.
|
|
2448
|
+
|
|
2449
|
+
```ts
|
|
2450
|
+
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: 'romashka@salon.io' })
|
|
2451
|
+
// [3.3 ms] secret = 'FAZKFC57B3FPIOF2735ZYW47CZA5O6MW'
|
|
2452
|
+
// uri = 'otpauth://totp/romashka%40salon.io?secret=FAZKFC57B3FPIOF2735ZYW47CZA5O6MW&issuer=clockz&algorithm=SHA1&digits=6&period=30'
|
|
2453
|
+
```
|
|
2454
|
+
|
|
2455
|
+
#### `db.auth.verifyTotp(a): Promise<boolean>`
|
|
2456
|
+
|
|
2457
|
+
| Параметр | Тип | Описание |
|
|
2458
|
+
|---|---|---|
|
|
2459
|
+
| `a.account` | `string \| { id }` | чей фактор |
|
|
2460
|
+
| `a.code` | `string` | 6 цифр из приложения |
|
|
2461
|
+
| `a.window` | `number?` | допуск в шагах; default 1 → принимаются коды текущего шага ±1 (рассинхрон часов до 30 s) |
|
|
2462
|
+
|
|
2463
|
+
**Назначение и алгоритм.** Перебирает шаги окна, сверяет HOTP-код (`timingSafeEqual`);
|
|
2464
|
+
шаги `<= meta.lastStep` пропускаются — **принятый код нельзя повторить** (replay), даже пока
|
|
2465
|
+
он «ещё валиден» по времени. Успех записывает `lastStep` и ставит `confirmed: true`.
|
|
2466
|
+
|
|
2467
|
+
**Примеры**
|
|
2468
|
+
|
|
2469
|
+
```ts
|
|
2470
|
+
const код = totpCode(secret) // [335 µs] → '370916' (как в приложении)
|
|
2471
|
+
await db.auth.verifyTotp({ account: acc, code: код }) // [4.8 ms] → true — фактор активирован
|
|
2472
|
+
await db.auth.verifyTotp({ account: acc, code: код }) // [1.7 ms] → false — replay отбит
|
|
2473
|
+
const прошлый = totpCode(secret, Date.now() - 30_000) // код прошлого шага (окно ±1)
|
|
2474
|
+
await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false — шаг ≤ lastStep
|
|
2475
|
+
```
|
|
2476
|
+
|
|
2477
|
+
**Кейс: включение 2FA в кабинете**
|
|
2478
|
+
|
|
2479
|
+
```ts
|
|
2480
|
+
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [3.3 ms]
|
|
2481
|
+
await db.auth.totpEnabled(acc) // → false — QR показан, ждём подтверждения
|
|
2482
|
+
await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [4.8 ms] → true
|
|
2483
|
+
await db.auth.totpEnabled(acc) // [1.7 ms] → true — теперь требуем код при входе
|
|
2484
|
+
```
|
|
2485
|
+
|
|
2486
|
+
#### `db.auth.totpEnabled(account): Promise<boolean>`
|
|
2487
|
+
|
|
2488
|
+
`account: string | { id }` — true, если TOTP-фактор заведён **и** активирован первой
|
|
2489
|
+
проверкой (`confirmed`). Приложение по нему решает, спрашивать ли второй фактор.
|
|
2490
|
+
|
|
2491
|
+
```ts
|
|
2492
|
+
await db.auth.totpEnabled(acc) // [1.7 ms] → true
|
|
2493
|
+
```
|
|
2494
|
+
|
|
2495
|
+
#### `totpCode(secretBase32, atMs = Date.now()): string` — экспорт модуля
|
|
2496
|
+
|
|
2497
|
+
| Параметр | Тип | Описание |
|
|
2498
|
+
|---|---|---|
|
|
2499
|
+
| `secretBase32` | `string` | секрет из `enrollTotp` |
|
|
2500
|
+
| `atMs` | `number?` | момент времени; default сейчас |
|
|
2501
|
+
|
|
2502
|
+
**Назначение и алгоритм.** Чистая функция «что сейчас показывает приложение»: HOTP
|
|
2503
|
+
(RFC 4226 — HMAC-SHA1, динамическое усечение, mod 10⁶) от счётчика `floor(atMs/1000/30)`.
|
|
2504
|
+
Для тестов и серверной генерации кодов.
|
|
2505
|
+
|
|
2506
|
+
```ts
|
|
2507
|
+
totpCode('FAZKFC57B3FPIOF2735ZYW47CZA5O6MW') // [335 µs] → '370916'
|
|
2508
|
+
```
|
|
2509
|
+
|
|
2510
|
+
**Кейс** — автотест 2FA без телефона: сгенерировать код из секрета и скормить `verifyTotp`
|
|
2511
|
+
(ровно так работает `test/auth.test.ts`).
|
|
2512
|
+
|
|
2513
|
+
#### `db.auth.issueOtp(a): Promise<{ code, credential }>`
|
|
2514
|
+
|
|
2515
|
+
| Параметр | Тип | Описание |
|
|
2516
|
+
|---|---|---|
|
|
2517
|
+
| `a.account` | `string \| { id }` | владелец |
|
|
2518
|
+
| `a.identifier` | `string` | куда доставляется код (почта/телефон) — доставка на приложении |
|
|
2519
|
+
| `a.category` | `string?` | default `'OTP'` |
|
|
2520
|
+
| `a.ttlSec` | `number?` | срок жизни; default 600 |
|
|
2521
|
+
| `a.digits` | `number?` | длина кода; default 6 |
|
|
2522
|
+
|
|
2523
|
+
**Назначение и алгоритм.** Одноразовый код (подтверждение почты, сброс пароля, вход по SMS):
|
|
2524
|
+
в БД — `sha256(code)` + `expires` + `attempts: 0`; повторный выпуск **затирает** прежний код
|
|
2525
|
+
(валиден только последний).
|
|
2526
|
+
|
|
2527
|
+
```ts
|
|
2528
|
+
const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@salon.io', ttlSec: 600 })
|
|
2529
|
+
// [3.8 ms] code = '838474'; в БД: meta = { code: 'ccc3142253e2…' (sha256), expires: '2026-07-11T13:32:36.545Z', attempts: 0 }
|
|
2530
|
+
```
|
|
2531
|
+
|
|
2532
|
+
#### `db.auth.verifyOtp(a): Promise<AuthResult | null>`
|
|
2533
|
+
|
|
2534
|
+
| Параметр | Тип | Описание |
|
|
2535
|
+
|---|---|---|
|
|
2536
|
+
| `a.identifier` | `string` | тот же адрес |
|
|
2537
|
+
| `a.code` | `string` | введённый код |
|
|
2538
|
+
| `a.category` | `string?` | default `'OTP'` |
|
|
2539
|
+
| `a.maxAttempts` | `number?` | лимит неудач; default 5 |
|
|
2540
|
+
|
|
2541
|
+
**Назначение и алгоритм.** Код одноразовый в обе стороны: **успех сжигает** (кред мягко
|
|
2542
|
+
удаляется), `maxAttempts` неудач сжигают, истёкший `expires` сжигает при первой же проверке.
|
|
2543
|
+
Неудача инкрементирует `attempts`.
|
|
2544
|
+
|
|
2545
|
+
**Примеры**
|
|
2546
|
+
|
|
2547
|
+
```ts
|
|
2548
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [4.3 ms] → null (+1 попытка)
|
|
2549
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [7.4 ms] → { account, credential }
|
|
2550
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [1.8 ms] → null — сожжён
|
|
2551
|
+
```
|
|
2552
|
+
|
|
2553
|
+
**Кейс: сброс пароля**
|
|
2554
|
+
|
|
2555
|
+
```ts
|
|
2556
|
+
const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [3.8 ms]
|
|
2557
|
+
отправитьПисьмо(почта, code) // доставка — на приложении
|
|
2558
|
+
const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [7.4 ms]
|
|
2559
|
+
if (кто) await db.auth.setPassword({ account: кто.account, identifier: почта, password: новый })
|
|
2560
|
+
// протухший код (ttl 1 s в прогоне): verifyOtp → null [12.6 ms]
|
|
2561
|
+
```
|
|
2562
|
+
|
|
2563
|
+
#### `db.auth.link(a): Promise<Credential>`
|
|
2564
|
+
|
|
2565
|
+
| Параметр | Тип | Описание |
|
|
2566
|
+
|---|---|---|
|
|
2567
|
+
| `a.account` | `string \| { id }` | кому привязываем |
|
|
2568
|
+
| `a.category` | `string` | `'TELEGRAM'`, `'GOOGLE'`, `'SSO:OKTA'` — любая строка |
|
|
2569
|
+
| `a.identifier` | `string` | внешний id (telegram user id, sub из OIDC…) |
|
|
2570
|
+
| `a.meta` | `object?` | полезное (username и т.п.) |
|
|
2571
|
+
| `a.confirmed` | `boolean?` | default true |
|
|
2572
|
+
|
|
2573
|
+
**Назначение и алгоритм.** Связка аккаунта с внешней identity: OAuth-танец и проверку
|
|
2574
|
+
внешнего токена делает **приложение**, библиотека хранит соответствие. Upsert; identifier
|
|
2575
|
+
глобально однозначен среди живых (`credential_identity_udx`).
|
|
2576
|
+
|
|
2577
|
+
**Примеры**
|
|
2578
|
+
|
|
2579
|
+
```ts
|
|
2580
|
+
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' } })
|
|
2581
|
+
// [6.6 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
|
|
2582
|
+
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915' }) // id занят ДРУГИМ аккаунтом:
|
|
2583
|
+
// Error: duplicate key value violates unique constraint "credential_identity_udx" [3.9 ms]
|
|
2584
|
+
```
|
|
2585
|
+
|
|
2586
|
+
**Кейс** — см. `lookup` (вход через telegram-бота).
|
|
2587
|
+
|
|
2588
|
+
#### `db.auth.lookup(a): Promise<AuthResult | null>`
|
|
2589
|
+
|
|
2590
|
+
| Параметр | Тип | Описание |
|
|
2591
|
+
|---|---|---|
|
|
2592
|
+
| `a.category` | `string` | тип identity |
|
|
2593
|
+
| `a.identifier` | `string` | внешний id |
|
|
2594
|
+
| `a.requireConfirmed` | `boolean?` | — |
|
|
2595
|
+
|
|
2596
|
+
**Назначение и алгоритм.** Обратный поиск «чья это identity»: живой кред по
|
|
2597
|
+
`(category, identifier)` + ворота. Секрета нет — доверие внешнему провайдеру уже
|
|
2598
|
+
установлено приложением.
|
|
2599
|
+
|
|
2600
|
+
```ts
|
|
2601
|
+
await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' })
|
|
2602
|
+
// [7.2 ms] → { account: c66766f7…, credential }
|
|
2603
|
+
```
|
|
2604
|
+
|
|
2605
|
+
**Кейс: вход через telegram-бота**
|
|
2606
|
+
|
|
2607
|
+
```ts
|
|
2608
|
+
// платформа подтвердила пользователя 777000111 (initData бота проверило приложение)
|
|
2609
|
+
const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [7.2 ms]
|
|
2610
|
+
if (!кто) { /* первая встреча: создать аккаунт + db.auth.link(…) */ }
|
|
2611
|
+
const token = await sess.start(кто.account) // дальше обычная сессия
|
|
2612
|
+
```
|
|
2613
|
+
|
|
2614
|
+
#### `db.auth.sessions(store): Sessions`
|
|
2615
|
+
|
|
2616
|
+
| Параметр | Тип | Описание |
|
|
2617
|
+
|---|---|---|
|
|
2618
|
+
| `store` | `SessionStore` | KV-клиент: `set/get/del/sadd/srem/smembers/expire` — ioredis подходит как есть; в prod-зависимости letopis не входит |
|
|
2619
|
+
|
|
2620
|
+
**Назначение и алгоритм.** Фабрика сессий поверх внешнего Redis (§ 9.1): токены — вне БД,
|
|
2621
|
+
протухают сами по TTL. Синхронная (только замыкает store).
|
|
2622
|
+
|
|
2623
|
+
```ts
|
|
2624
|
+
import Redis from 'ioredis'
|
|
2625
|
+
const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [147 µs]
|
|
2626
|
+
```
|
|
2627
|
+
|
|
2628
|
+
#### `sessions.start(account, opts?): Promise<string>`
|
|
2629
|
+
|
|
2630
|
+
| Параметр | Тип | Описание |
|
|
2631
|
+
|---|---|---|
|
|
2632
|
+
| `account` | `string \| { id }` | чья сессия |
|
|
2633
|
+
| `opts.ttlSec` | `number?` | срок жизни; default 7 суток |
|
|
2634
|
+
| `opts.meta` | `object?` | полезное (device, ip…) — вернётся из `check` |
|
|
2635
|
+
|
|
2636
|
+
**Назначение и алгоритм.** Токен = 32 случайных байта hex, отдаётся **один раз**; в Redis —
|
|
2637
|
+
ключ `sess:<sha256(token)>` (JSON `{account, meta, created}`, `EX ttl`) + set-индекс
|
|
2638
|
+
`sess:acc:<accountId>` для `revokeAll`. Дамп Redis действующих токенов не раскрывает.
|
|
2639
|
+
|
|
2640
|
+
```ts
|
|
2641
|
+
const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [9.1 ms]
|
|
2642
|
+
// token = 'fe6093998681fceaf5a902ed5fb3f72be3da5ce943cd4096f9c0d6349fcc231d'
|
|
2643
|
+
// в Redis: 'sess:eb473191b2a470d130e…' и 'sess:acc:c66766f7-0704-4…'
|
|
2644
|
+
```
|
|
2645
|
+
|
|
2646
|
+
#### `sessions.check(token): Promise<Session | null>`
|
|
2647
|
+
|
|
2648
|
+
`token: string` — `GET sess:<sha256(token)>`: `{ account, meta, created }` или `null`
|
|
2649
|
+
(нет / истекла / отозвана). Суб-миллисекундный — на каждый HTTP-запрос.
|
|
2650
|
+
|
|
2651
|
+
```ts
|
|
2652
|
+
await sess.check(token) // [0.9 ms]
|
|
2653
|
+
// → { account: 'c66766f7-…', meta: { device: 'iphone' }, created: '2026-07-11T13:22:37.913Z' }
|
|
2654
|
+
await sess.check(протухший) // [1.2 ms] → null (ttl 1 s истёк — Redis сам удалил)
|
|
2655
|
+
```
|
|
2656
|
+
|
|
2657
|
+
#### `sessions.revoke(token): Promise<boolean>`
|
|
2658
|
+
|
|
2659
|
+
`token: string` — `DEL` ключа + `SREM` из индекса; `false`, если сессии уже нет.
|
|
2660
|
+
|
|
2661
|
+
```ts
|
|
2662
|
+
await sess.revoke(token) // [2.4 ms] → true
|
|
2663
|
+
await sess.revoke(token) // [0.5 ms] → false — повторно
|
|
2664
|
+
```
|
|
2665
|
+
|
|
2666
|
+
#### `sessions.revokeAll(account): Promise<number>`
|
|
2667
|
+
|
|
2668
|
+
`account: string | { id }` — `SMEMBERS` индекса → `DEL` всех сессий + индекса; возвращает
|
|
2669
|
+
сколько погашено.
|
|
2670
|
+
|
|
2671
|
+
```ts
|
|
2672
|
+
await sess.revokeAll(acc) // [1.1 ms] → 2 — обе сессии (ipad + macbook) погасли
|
|
2673
|
+
```
|
|
2674
|
+
|
|
2675
|
+
**Кейс: полный вход — пароль → сессия → запрос → выход (реальный прогон)**
|
|
2676
|
+
|
|
2677
|
+
```ts
|
|
2678
|
+
const визит = await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2679
|
+
// [80.4 ms] → { account, credential }
|
|
2680
|
+
const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.9 ms]
|
|
2681
|
+
// … каждый запрос в middleware:
|
|
2682
|
+
const кто = await sess.check(token) // [0.9 ms] → { account: 'c66766f7…', meta: { ip: '10.0.0.7' }, … }
|
|
2683
|
+
// logout:
|
|
2684
|
+
await sess.revoke(token) // [2.2 ms] → true
|
|
2685
|
+
// «выйти со всех устройств» после смены пароля: await sess.revokeAll(визит.account)
|
|
2686
|
+
```
|
|
2687
|
+
|
|
2688
|
+
### 11.10 db.acl
|
|
2689
|
+
|
|
2690
|
+
Решения по словарю Resource/Rule (§ 9.2). Прогоны ниже — словарь из § 11.8-кейса: группа
|
|
2691
|
+
`apiref.client:ACCOUNT {categories: '{Client}'}`, эндпоинты `apiref.api.booking:API
|
|
2692
|
+
{endpoint: 'v2.booking.*'}`, данные `apiref.booking.own:READ/WRITE/DELETE
|
|
2693
|
+
{class: 'Booking', owner: '$account'}` и `apiref.service:READ {class: 'Service'}`,
|
|
2694
|
+
5 правил `allow weight 60` от группы Client.
|
|
2695
|
+
|
|
2696
|
+
#### `db.acl.check(account, endpoint): Promise<AclDecision>`
|
|
2697
|
+
|
|
2698
|
+
| Параметр | Тип | Описание |
|
|
2699
|
+
|---|---|---|
|
|
2700
|
+
| `account` | `string \| { id }` | субъект |
|
|
2701
|
+
| `endpoint` | `string` | адрес: `.`-сегменты (`v2.booking.create`) |
|
|
2702
|
+
|
|
2703
|
+
**Назначение и алгоритм.** Может ли аккаунт дёрнуть эндпоинт: субъект-группы — `ACCOUNT`-ресурсы,
|
|
2704
|
+
чей pattern categories совпал с `Account.categories` (`{A,B}` — любая из, `!{A,B}` — ни одной,
|
|
2705
|
+
NULL — все); объекты — `API`-ресурсы, чья маска покрыла endpoint (сегменты-литералы,
|
|
2706
|
+
`{a,b}` — альтернативы, `*` — хвост из 1+ сегментов); из правил субъекты×объекты побеждает
|
|
2707
|
+
**ровно одно**: max weight, при равенстве deny, без правил — deny. Ответ несёт победившее
|
|
2708
|
+
правило и `code`/`message` из его meta.
|
|
2709
|
+
|
|
2710
|
+
**Примеры**
|
|
2711
|
+
|
|
2712
|
+
```ts
|
|
2713
|
+
await db.acl.check(acc, 'v2.booking.create') // [3.2 ms]
|
|
2714
|
+
// → { allow: true, rule: { account: 'apiref.client:ACCOUNT', resource: 'apiref.api.booking:API',
|
|
2715
|
+
// permission: 'allow', weight: 60, enabled: true } }
|
|
2716
|
+
await db.acl.check(acc, 'v2.admin.stats') // [1.5 ms] — покрыло только дно-правило сида:
|
|
2717
|
+
// → { allow: false, rule: { account: 'any:ACCOUNT', resource: 'any:API', permission: 'deny', weight: 0, … },
|
|
2718
|
+
// code: 403, message: 'Access denied - default for any ACCOUNT to any API' }
|
|
2719
|
+
```
|
|
2720
|
+
|
|
2721
|
+
**Кейс: гейт HTTP-роутера**
|
|
2722
|
+
|
|
2723
|
+
```ts
|
|
2724
|
+
app.use(async (req, res, next) => {
|
|
2725
|
+
const реш = await db.acl.check(req.account, req.route) // 1.5–3.2 ms
|
|
2726
|
+
if (!реш.allow) return res.status(реш.code ?? 403).json({ error: реш.message })
|
|
2727
|
+
next()
|
|
2728
|
+
})
|
|
2729
|
+
```
|
|
2730
|
+
|
|
2731
|
+
#### `db.acl.checkData(account, className, op): Promise<AclDecision>`
|
|
2732
|
+
|
|
2733
|
+
| Параметр | Тип | Описание |
|
|
2734
|
+
|---|---|---|
|
|
2735
|
+
| `account` | `string \| { id }` | субъект |
|
|
2736
|
+
| `className` | `string` | класс (id или алиас) |
|
|
2737
|
+
| `op` | `'READ' \| 'WRITE' \| 'DELETE'` | операция = категория ресурса |
|
|
2738
|
+
|
|
2739
|
+
**Назначение и алгоритм.** Решение по данным: объекты — ресурсы категории `op`, чья
|
|
2740
|
+
`pattern.class`-маска совпала с именем класса **или любого предка** (lineage: право на
|
|
2741
|
+
`Booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
|
|
2742
|
+
ключи pattern (реальные колонки Entity, `"$account"` → id субъекта) возвращаются как
|
|
2743
|
+
`filter` — готовый предикат строк. Справочный метод (SQL за категориями аккаунта на каждый
|
|
2744
|
+
вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный на connect
|
|
2745
|
+
(микросекунды, memo).
|
|
2746
|
+
|
|
2747
|
+
**Примеры**
|
|
2748
|
+
|
|
2749
|
+
```ts
|
|
2750
|
+
await db.acl.checkData(acc, 'Booking', 'READ') // [1.6 ms]
|
|
2751
|
+
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', weight: 60, … },
|
|
2752
|
+
// filter: { owner: 'c66766f7-0704-4cb3-b6c9-18620ebfdc2e' } } ← $account подставлен
|
|
2753
|
+
await db.acl.checkData(acc, 'Service', 'READ') // [1.2 ms]
|
|
2754
|
+
// → { allow: true, rule: { resource: 'apiref.service:READ', … } } ← безусловный (без filter)
|
|
2755
|
+
await db.acl.checkData(acc, 'Org', 'READ') // [1.0 ms]
|
|
2756
|
+
// → { allow: false, message: 'no matching rule (deny by default)' }
|
|
2757
|
+
await db.acl.checkData(acc, 'VipBooking', 'READ') // [20.7 ms — свежий connect]
|
|
2758
|
+
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: 'c66766f7-…' } }
|
|
2759
|
+
// класса нет в словаре — право дал предок Booking (lineage)
|
|
2760
|
+
```
|
|
2761
|
+
|
|
2762
|
+
**Кейс: enforceAcl — те же решения в SQL цепочек (реальный прогон)**
|
|
2763
|
+
|
|
2764
|
+
```ts
|
|
2765
|
+
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [72.3 ms] правила фиксируются
|
|
2766
|
+
await uc.Запись().rows() // [16.4 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
|
|
2767
|
+
await uc.Запись().count() // [20.3 ms] → 3 — честный count по суженному множеству
|
|
2768
|
+
await uc.Услуга().count() // [15.9 ms] → 404 — безусловный allow, класс целиком
|
|
2769
|
+
await uc.Организация().rows()
|
|
2770
|
+
// Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
|
|
2771
|
+
const [z] = await uc.Запись().create({ status: 'created', total: { RUB: 300 } }).rows() // [11.7 ms]
|
|
2772
|
+
z.owner === acc.id // → true — owner пришпилен правилом
|
|
2773
|
+
await uc.Запись().owner(SYS).create({ … }).rows()
|
|
2774
|
+
// Error: letopis: acl pins Booking writes to owner … — на терминале [1.8 ms]
|
|
2775
|
+
await uc.Запись(чужаяId).update({ status: 'hacked' }).rows() // [10.9 ms] → [] — чужая жива и НЕ перехвачена
|
|
2776
|
+
await uc.Запись('…0965').delete({ confirm: true }).rows() // [27.7 ms] → [{ id: '…0965', $deleted: true }]
|
|
2777
|
+
// watch: события только безусловных allow-классов —
|
|
2778
|
+
// uc.watch(cb) поймал ['Service']; Booking скрыт (предикат не проверить по payload)
|
|
2779
|
+
```
|
|
2780
|
+
|
|
2781
|
+
#### `db.acl.reload(): void`
|
|
2782
|
+
|
|
2783
|
+
Параметров нет. Сбрасывает кэш Resource/Rule фасада `db.acl` (следующий `check`/`checkData`
|
|
2784
|
+
перечитает словарь). На **enforceAcl-цепочки не влияет** — их резолвер скомпилирован на
|
|
2785
|
+
connect; подхватить новые правила = новый `connect()`. Реестр классов тоже фиксирован на
|
|
2786
|
+
connect — новый класс в lineage-проверках увидит только новое подключение.
|
|
2787
|
+
|
|
2788
|
+
```ts
|
|
2789
|
+
db.acl.reload() // [120 µs]
|
|
2790
|
+
```
|
|
2791
|
+
|
|
2792
|
+
**Кейс:** админка сохранила правило → `reload()` в том же процессе, чтобы `check` следующего
|
|
2793
|
+
запроса увидел его без переподключения.
|
|
2794
|
+
|
|
2795
|
+
### 11.11 Registry / ValidationError
|
|
2796
|
+
|
|
2797
|
+
#### `db.registry.resolve(name): ClassDef`
|
|
2798
|
+
|
|
2799
|
+
| Параметр | Тип | Описание |
|
|
2800
|
+
|---|---|---|
|
|
2801
|
+
| `name` | `string` | id или алиас класса |
|
|
2802
|
+
|
|
2803
|
+
**Назначение и алгоритм.** Класс из реестра (Map индексирует и id, и алиас); неизвестное имя —
|
|
2804
|
+
ошибка со списком всех классов. `ClassDef`: `id`, `alias`, `category` (HUB/LINK), `ancestors`
|
|
2805
|
+
(lineage — считает БД-триггер), `descendants`, `links` (концы LINK), `attributes`, `abstract`,
|
|
2806
|
+
компилированный валидатор.
|
|
2807
|
+
|
|
2808
|
+
```ts
|
|
2809
|
+
db.registry.resolve('Запись') // [90 µs]
|
|
2810
|
+
// → { id: 'Booking', alias: 'Запись', category: 'HUB', ancestors: ['Booking', 'Entity'],
|
|
2811
|
+
// links: ['Customer'], abstract: false, … }
|
|
2812
|
+
db.registry.resolve('Дракон')
|
|
2813
|
+
// Error: letopis: unknown class "Дракон". Known: Entity·Сущность, Org·Организация, …
|
|
2814
|
+
```
|
|
2815
|
+
|
|
2816
|
+
**Кейс:** генерация форм по схеме: `resolve(cls).attributes` → поля UI; `ancestors` —
|
|
2817
|
+
наследование пресетов.
|
|
2818
|
+
|
|
2819
|
+
#### `db.registry.find(name): ClassDef | undefined` / `has(name): boolean` / `all: ClassDef[]`
|
|
2820
|
+
|
|
2821
|
+
`find` — как `resolve`, но `undefined` вместо ошибки; `has` — проверка существования;
|
|
2822
|
+
`all` — все классы партиции.
|
|
2823
|
+
|
|
2824
|
+
```ts
|
|
2825
|
+
db.registry.has('Booking') // → true
|
|
2826
|
+
db.registry.has('Дракон') // → false
|
|
2827
|
+
db.registry.find('Дракон') // → undefined
|
|
2828
|
+
db.registry.all.length // → 15
|
|
2829
|
+
```
|
|
2830
|
+
|
|
2831
|
+
**Кейс:** роутер `GET /:класс` — `has()` до цепочки, чтобы отвечать 404, а не 500.
|
|
2832
|
+
|
|
2833
|
+
#### `ValidationError` — класс ошибки
|
|
2834
|
+
|
|
2835
|
+
| Поле | Тип | Описание |
|
|
2836
|
+
|---|---|---|
|
|
2837
|
+
| `message` | `string` | `letopis: validation failed for "<Класс>": …` |
|
|
2838
|
+
| `issues` | `{ field, type, message, … }[]` | отчёт fastest-validator по каждому полю |
|
|
2839
|
+
|
|
2840
|
+
**Назначение.** Бросается из ТЕРМИНАЛА плана (`.create()`/`.update()`/`.anonymize()`/батчи),
|
|
2841
|
+
когда `data` не проходит строгую схему класса (недостающее обязательное, лишний ключ,
|
|
2842
|
+
неверный тип) — весь план откатывается. Другие ошибки записи (abstract-класс, недостающий
|
|
2843
|
+
конец LINK) — обычный `Error` там же.
|
|
2844
|
+
|
|
2845
|
+
```ts
|
|
2846
|
+
try { await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [1.4 ms]
|
|
2847
|
+
catch (e) {
|
|
2848
|
+
e instanceof ValidationError // → true
|
|
2849
|
+
e.issues
|
|
2850
|
+
// → [{ type: 'required', field: 'duration', message: "The 'duration' field is required." },
|
|
2851
|
+
// { type: 'objectStrict', expected: 'id, name, price, active, duration, description', actual: 'чепуха', … }]
|
|
2852
|
+
}
|
|
2853
|
+
```
|
|
2854
|
+
|
|
2855
|
+
**Кейс:** формы: `issues` маппится в подсветку полей — `field` → инпут, `message` → подпись.
|
|
2856
|
+
|
|
2857
|
+
### 11.12 Типы
|
|
2858
|
+
|
|
2859
|
+
```ts
|
|
2860
|
+
Row = { id, class, data, links, tags, account, owner, updated,
|
|
2861
|
+
$deleted?: true, // у .delete()-результатов и tombstone в .versions()
|
|
2862
|
+
$depth?: number } // у .deep()-строк: 1 = прямой ребёнок
|
|
2863
|
+
Path = Record<string, Row> // вариант пути: ключ шага → узел
|
|
2864
|
+
Filter = string | string[] | Op | { [field]: значение | Op | вложенный объект }
|
|
2865
|
+
Cursor = { v: string | number, id: string } // .after() / cursorOf()
|
|
2866
|
+
QueryEvent = { mode: 'paths'|'rows'|'ids'|'count'|'versions'|'agg'|'insert'|'delete',
|
|
2867
|
+
classes: string[], ms: number, rows: number, slow: boolean }
|
|
2868
|
+
ChainMods = { limit?, offset?, order?, desc?, asOf?, after?, aggFn?, aggField? }
|
|
2869
|
+
Account = { id, categories: string[], data, meta, avatar, enabled, created, updated }
|
|
2870
|
+
Credential = { id, account, category, identifier, meta, confirmed, created, updated, deleted }
|
|
2871
|
+
Resource = { alias, category, pattern, meta }
|
|
2872
|
+
Rule = { account, resource, permission, weight, meta, enabled }
|
|
2873
|
+
AuthResult = { account: Account, credential: Credential } // все verify*/lookup
|
|
2874
|
+
Session = { account: string, meta, created } // sessions.check()
|
|
2875
|
+
SessionStore = { set, get, del, sadd, srem, smembers, expire } // ioredis-совместимый срез
|
|
2876
|
+
WatchEvent = { partition, class, id, updated, deleted: boolean } // db.watch()
|
|
2877
|
+
WatchOpts = { onReconnect?: () => void } // после re-listen (§ 10.5)
|
|
2878
|
+
AclOp = 'READ' | 'WRITE' | 'DELETE' // операция = category ресурса
|
|
2879
|
+
AclDecision = { allow, rule?, filter?, code?, message? } // filter — предикат строк (§ 9.2)
|
|
2880
|
+
```
|
|
2881
|
+
|
|
2882
|
+
---
|
|
2883
|
+
|
|
2884
|
+
## 12. Ошибки
|
|
2885
|
+
|
|
2886
|
+
Ошибки записи (валидация, ACL, контекст) бросаются из ТЕРМИНАЛА (async) — план до
|
|
2887
|
+
терминала не исполняется и не проверяется.
|
|
695
2888
|
|
|
696
2889
|
| Ошибка | Когда |
|
|
697
2890
|
|---|---|
|
|
698
2891
|
| `unknown class "X". Known: …` | класс вне Schema (со списком) |
|
|
699
2892
|
| `ValidationError` (`.issues`) | строгая валидация: мусор или лишние поля |
|
|
700
2893
|
| `class "X" is abstract` | запись в abstract |
|
|
701
|
-
| `link "X" requires end "Y"` / `
|
|
702
|
-
| `context step "X" must resolve to exactly one entity` |
|
|
703
|
-
| `
|
|
704
|
-
| `
|
|
705
|
-
| `
|
|
2894
|
+
| `link "X" requires end "Y|Z"` / `has stray link(s)` | не хватает обязательного конца LINK / связь вне объявленных концов (v2); legacy: `polymorphic end(s)` |
|
|
2895
|
+
| `context step "X" must resolve to exactly one entity` | шаг-владелец пути дал 0 или >1 |
|
|
2896
|
+
| `create() takes no filter — searching is update()` | фильтр-объект перед `create()` (синхронно, при построении) |
|
|
2897
|
+
| `create() on a pivot step` | pivot возвращает к существующему узлу — это `update()` |
|
|
2898
|
+
| `class "X" computes id (uuid v5 from …)` | явный id у v5-класса (§ 3.2) — id всегда считает схема |
|
|
2899
|
+
| `id (uuid v5) of "X" needs end "A\|B"` / `needs scalar data field "f"` | v5-классу не хватает конца (путь/слот) или скалярного поля из `from` |
|
|
2900
|
+
| `step modifier after create()/update()` | модификатор шага (alias/tags/…) сразу после операции |
|
|
2901
|
+
| `"X" is not a link end of "Y"` | слот `.X.set()` — не конец Y по Schema.links |
|
|
2902
|
+
| `slot "X" needs an owner step` | слот на корне (`db.X.set()`) без шага-владельца |
|
|
2903
|
+
| `link slot "X" needs a write` | слот без глагола записи — добавить `.create(…)`/`.update(…)` перед ним |
|
|
2904
|
+
| `link end "X" of "Y" is required — cannot unset` | `.unset()` обязательного конца |
|
|
2905
|
+
| `duplicate link slot "X"` | один конец задан слотом дважды в одной записи |
|
|
2906
|
+
| `set() split into create()/update() (0.16.0)` | старый глагол записи — create() вставляет, update() версионирует найденное |
|
|
2907
|
+
| `slot .delete() renamed to .unset() (0.16.0)` | старое имя слот-снятия |
|
|
2908
|
+
| `.link() removed (0.15.0)` | снесённый `.link()` — теперь слот `.Класс.set()` |
|
|
2909
|
+
| `execute() renamed to run() (0.11.0)` | старое имя терминала путей (и `batch.execute()`) |
|
|
2910
|
+
| `plan is queued in the batch — call batch.run()` | терминал на батч-цепочке с операциями |
|
|
2911
|
+
| `no path X → Y` / `LINK → LINK …` | недопустимый переход (синхронно при построении цепочки) |
|
|
706
2912
|
| `Entity.account is NOT NULL…` | нет account и System-аккаунта |
|
|
707
2913
|
| `violates foreign key constraint "entity_*_fk"` | несуществующий класс/аккаунт; удаление класса с данными |
|
|
708
2914
|
| `lock() works only inside db.begin()` | лок вне транзакции |
|
|
709
2915
|
| `commit() needs a transaction` | commit на корневом db |
|
|
2916
|
+
| `letopis.up: bad schema name "X"` / `bad version` | `up()`: имя с точкой/версией либо version не целое ≥ 1 |
|
|
2917
|
+
| `letopis.up: docker CLI not found…` | `up()`: docker не установлен, а postgres на dsn не отвечает |
|
|
2918
|
+
| `letopis.up: postgres not ready in N s…` / `redis not ready` | `up()`: контейнер не поднялся за `waitTimeoutMs` (подсказка: `docker logs`) |
|
|
710
2919
|
|
|
711
2920
|
---
|
|
712
2921
|
|
|
@@ -727,17 +2936,21 @@ Rule = { account, resource, permission, weight, meta, enabled }
|
|
|
727
2936
|
| `rows()` весь класс, sort+limit 100 | 63 ms | 74 ms |
|
|
728
2937
|
| цепочка 3 хопа (пути) | 15 ms | 23 ms |
|
|
729
2938
|
| `count()` путей | 15 ms | 20 ms |
|
|
730
|
-
|
|
|
2939
|
+
| запись новой версии (`create`/`update`) | 8 ms | 12 ms |
|
|
731
2940
|
|
|
732
2941
|
TOAST-порог (data > 2KB): 0 строк. Слабое место — выборка «весь класс с сортировкой»
|
|
733
2942
|
(DISTINCT ON всех сущностей класса); лечится селективным фильтром или курсором.
|
|
734
2943
|
Масштаб побольше: `node bench/history.bench.mjs --entities=10000 --versions=100` (1M строк).
|
|
2944
|
+
Оверхед ACL: `npx tsx bench/acl.bench.mjs` на article-полигоне (таблица в § 9.2);
|
|
2945
|
+
сравнение вариантов партиционирования — `bench/dimensions.bench.mjs`; все ответы/тайминги
|
|
2946
|
+
API Reference (§ 11) — `npx tsx bench/api-reference-demo.mjs` (живой article-полигон,
|
|
2947
|
+
мутирует только свои сущности).
|
|
735
2948
|
|
|
736
2949
|
- Containment и обход графа — GIN; операторы — на уже суженном наборе.
|
|
737
2950
|
- Начинайте цепочку с самого селективного шага.
|
|
738
2951
|
- `count()` — пути; количество сущностей дешевле `ids().length`.
|
|
739
2952
|
- Каскадное удаление — серверное: один DELETE на всё дерево.
|
|
740
|
-
-
|
|
2953
|
+
- Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты).
|
|
741
2954
|
- EXPLAIN-паттерн — тест `EXPLAIN` (индексы обязаны быть в плане; ноль seq scan).
|
|
742
2955
|
|
|
743
2956
|
---
|
|
@@ -747,34 +2960,37 @@ TOAST-порог (data > 2KB): 0 строк. Слабое место — выб
|
|
|
747
2960
|
Полный исполняемый сценарий — `test/integration.test.ts`. Скелет:
|
|
748
2961
|
|
|
749
2962
|
```ts
|
|
750
|
-
// штат и каталог — связи
|
|
751
|
-
const [org] = await db.Организация().
|
|
752
|
-
const [ivan] = await db.Организация(org).Сотрудник().
|
|
753
|
-
const [oleg] = await db.Организация(org).Сотрудник().
|
|
754
|
-
const [стрижка] = await db.Организация(org).Услуга().
|
|
755
|
-
const [комплекс] = await db.Организация(org).Комплекс().
|
|
756
|
-
|
|
757
|
-
await db
|
|
2963
|
+
// штат и каталог — связи контекст-шагами; терминал .rows() исполняет план
|
|
2964
|
+
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
2965
|
+
const [ivan] = await db.Организация(org).Сотрудник().create({ name: 'Иван', roles: ['owner','master'] }).rows()
|
|
2966
|
+
const [oleg] = await db.Организация(org).Сотрудник().create({ name: 'Олег', roles: ['master'] }).rows()
|
|
2967
|
+
const [стрижка] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60, price: { RUB: 1500 } }).rows()
|
|
2968
|
+
const [комплекс] = await db.Организация(org).Комплекс().create({ name: 'Стрижка+борода', duration: 90, price: { RUB: 2500 } }).rows()
|
|
2969
|
+
// id услуги/комплекса вычислила схема: uuidv5(Org, name) — дубль имени в салоне невозможен (§ 3.2)
|
|
2970
|
+
await db.Комплекс(комплекс).состав().create({ qty: 1 }).Услуга.set(стрижка).rows() // состав комплекса
|
|
2971
|
+
await db.Сотрудник(ivan).навык().create().Комплекс.set(комплекс).rows() // умение
|
|
758
2972
|
|
|
759
2973
|
// календарь: период → окна батчем → ростер → исключение
|
|
760
|
-
const [sch] = await db.Организация(org).Расписание().
|
|
761
|
-
db.batch('о').Расписание(sch).Окно().
|
|
762
|
-
const [[w1],[w2],[w3]] = await db.batch('о').
|
|
763
|
-
await db.Расписание(sch)
|
|
764
|
-
await db.Сотрудник(oleg)
|
|
765
|
-
|
|
766
|
-
// бронь мульти-слот (90м > 60м → два окна)
|
|
767
|
-
const [bkg] = await db.Клиент(пётр).Запись().
|
|
768
|
-
await db.Запись(bkg)
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
2974
|
+
const [sch] = await db.Организация(org).Расписание().create({ start: '2026-07-10T10:00:00+03:00', end: '…13:00' }).rows()
|
|
2975
|
+
db.batch('о').Расписание(sch).Окно().create({ start: '…10:00', end: '…11:00' }) // ×3 — планы в очередь
|
|
2976
|
+
const [[w1],[w2],[w3]] = await db.batch('о').run()
|
|
2977
|
+
await db.Расписание(sch).смена().create().Сотрудник.set(ivan).rows()
|
|
2978
|
+
await db.Сотрудник(oleg).занятость().create({ kind: 'off' }).Окно.set(w3).rows() // id = uuidv5(w3, oleg)
|
|
2979
|
+
|
|
2980
|
+
// бронь мульти-слот (90м > 60м → два окна): владелец из пути, концы слотами
|
|
2981
|
+
const [bkg] = await db.Клиент(пётр).Запись().create({ status: 'created', total: { RUB: 2500 } }).rows()
|
|
2982
|
+
await db.Запись(bkg).позиция().create({ qty: 1, price: { RUB: 2500 } }).Комплекс.set(комплекс).Сотрудник.set(ivan).rows()
|
|
2983
|
+
await db.Сотрудник(ivan).занятость().create({ kind: 'booking' }).Окно.set(w2).Запись.set(bkg).rows()
|
|
2984
|
+
await db.Сотрудник(ivan).занятость().create({ kind: 'booking' }).Окно.set(w3).Запись.set(bkg).rows()
|
|
2985
|
+
// id занятостей детерминированы — uuidv5(окно, мастер): двойная бронь мертва на уровне схемы
|
|
772
2986
|
|
|
773
2987
|
// жизненный цикл, чтения, отмена
|
|
774
|
-
await db.Запись(bkg.id).
|
|
2988
|
+
await db.Запись(bkg.id).update({ status: 'confirmed' }).rows()
|
|
775
2989
|
await db.Запись(bkg.id).занятость().Окно().sort('data.start').rows() // окна брони
|
|
776
2990
|
await db.Окно(w2.id).занятость({ kind: 'booking' }).Сотрудник().rows() // кто занят
|
|
777
|
-
|
|
2991
|
+
await db.Запись(bkg.id).delete().rows() // ПРЕВЬЮ: что удалится
|
|
2992
|
+
const удалено = await db.Запись(bkg.id).delete({ confirm: true }).rows()
|
|
2993
|
+
// всё с $deleted (Booking+busy×2+item)
|
|
778
2994
|
```
|
|
779
2995
|
|
|
780
2996
|
---
|
|
@@ -782,18 +2998,34 @@ const удалено = await db.Запись(bkg.id).delete() // всё с $de
|
|
|
782
2998
|
## 16. Тесты
|
|
783
2999
|
|
|
784
3000
|
```bash
|
|
785
|
-
npm test #
|
|
3001
|
+
npm test # 165/165 тестов против живого docker (timescale + redis), по файлам:
|
|
3002
|
+
# acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
|
|
3003
|
+
# с $account, enforceAcl (предикаты в SQL, каскад, watch)
|
|
786
3004
|
# api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
|
|
3005
|
+
# auth — db.auth: пароль/api-key/key-secret/TOTP/OTP/link+lookup,
|
|
3006
|
+
# глобальная идентичность, сессии на живом Redis (expire/revoke)
|
|
3007
|
+
# plan — план-модель: несколько операций, fan-out, self-update,
|
|
3008
|
+
# превью/confirm delete, ОТКАТ плана, слот-перевес, батч-гард
|
|
3009
|
+
# resilience — ретраи 40P01/40001, реальный deadlock двух транзакций,
|
|
3010
|
+
# watch переживает обрыв LISTEN (pg_terminate_backend)
|
|
787
3011
|
# core — триггеры (check/версии/каскад/lineage), обходы, операторы,
|
|
788
|
-
# модификаторы,
|
|
3012
|
+
# модификаторы, create/update-формы, delete, гонка, батчи, EXPLAIN
|
|
3013
|
+
# depth — наследование 3+ уровней, data-пути любой глубины
|
|
3014
|
+
# idgen — генерация id по Schema (§ 3.2): v4/v7/v5 из концов и полей,
|
|
3015
|
+
# гонка без локов, наследование attributes и правила id, гарды
|
|
789
3016
|
# integration — E2E-барбершоп (8 сцен)
|
|
790
3017
|
# real-life — 16 сцен «дня салона»: 4 руки, гонки ×3, переносы, no-show
|
|
791
3018
|
# tables — auth/ACL-таблицы
|
|
3019
|
+
# up — up(): docker-argv, probe-ветка, идемпотентность, fresh,
|
|
3020
|
+
# свои сиды/seeds:false, автосоздание базы, валидация
|
|
3021
|
+
# salon — ТЕСТ-ПЛАН: имитация салона, ВСЕ 145 публичных API
|
|
3022
|
+
# (21 акт + матрица покрытия); слоты/pivot/entity
|
|
792
3023
|
# wave2 — asOf/versions, keyset-курсор, gen-types, enforceAccount, anonymize
|
|
793
3024
|
# wave3 — or/not, агрегации, deep, watch
|
|
794
3025
|
# wave4 — compression-политики (чтение сжатого чанка), schema-sync, onQuery
|
|
795
3026
|
npm run bench # производительность на 105k строк (§ 14)
|
|
796
3027
|
```
|
|
797
3028
|
|
|
798
|
-
`ENTITY_DSN` переопределяет DSN (default `postgres://postgres:test@localhost:15432/clockz`)
|
|
799
|
-
|
|
3029
|
+
`ENTITY_DSN` переопределяет DSN (default `postgres://postgres:test@localhost:15432/clockz`),
|
|
3030
|
+
`ENTITY_REDIS` — Redis для auth-теста (default `redis://localhost:16379`).
|
|
3031
|
+
Тесты полностью автономны: `wave4`/`depth` сами создают себе PG-схемы через `db/apply.mjs`.
|