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