letopis 0.5.0 → 0.16.0

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