letopis 0.16.0 → 0.18.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
@@ -9,11 +9,11 @@ import { connect } from 'letopis'
9
9
  const db = await connect({ dsn: 'postgres://…', schema: 'v1.booking' })
10
10
 
11
11
  // чтение: пути по графу
12
- const пути = await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run()
13
- // [ { Сотрудник: Row, навык: Row, Услуга: Row }, … ]
12
+ const пути = await db.Мастер({ name: 'Вася' }).навык().Услуга().run()
13
+ // [ { Мастер: Row, навык: Row, Услуга: Row }, … ]
14
14
 
15
15
  // запись: операции — звенья, исполняет терминал
16
- await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
16
+ await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
17
17
  ```
18
18
 
19
19
  Содержание:
@@ -53,7 +53,7 @@ const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v
53
53
  // [letopis.up] connected (schema "v1.booking")
54
54
 
55
55
  const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
56
- const [вася] = await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
56
+ const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
57
57
  await db.close()
58
58
  ```
59
59
 
@@ -117,7 +117,7 @@ const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz
117
117
  UPDATE "v1.booking"."Entity" SET data = data || '{"duration":45}'
118
118
  WHERE partition='entity' AND class='Service' AND id='…'; -- → новая версия
119
119
  DELETE FROM "v1.booking"."Entity"
120
- WHERE partition='entity' AND class='Booking' AND id='…'; -- → tombstone + каскад
120
+ WHERE partition='entity' AND class='booking' AND id='…'; -- → tombstone + каскад
121
121
  ```
122
122
 
123
123
  Управление объёмом истории — только политики Timescale: `db/policies.mjs` (§ 10.8).
@@ -143,7 +143,7 @@ GIN-кандидатам, затем перепроверка условий н
143
143
 
144
144
  | Поле | Смысл |
145
145
  |---|---|
146
- | `id` / `alias` | англ. id (`Staff`) и русский алиас (`Сотрудник`) — равноправны в API |
146
+ | `id` / `alias` | англ. id (`Staff`) и русский алиас (`Мастер`) — равноправны в API |
147
147
  | `category` | `HUB` (сущность) \| `LINK` (связь с атрибутами) |
148
148
  | `ancestor` | прямой родитель |
149
149
  | `ancestors` | `[self, parent, …, root]` — **считает триггер `schema_lineage`** |
@@ -158,22 +158,22 @@ GIN-кандидатам, затем перепроверка условий н
158
158
 
159
159
  ```jsonc
160
160
  "links": [
161
- { "class": "Booking", "cardinality": 1 }, // один класс
162
- { "classes": ["Service", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
163
- { "class": "Staff", "optional": true, "cardinality": 1 } // конец может отсутствовать
161
+ { "class": "Staff", "cardinality": 1 }, // один класс
162
+ { "classes": ["Service", "Product", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
163
+ { "class": "Customer", "optional": true, "cardinality": 1 } // конец может отсутствовать
164
164
  ]
165
165
  ```
166
166
 
167
167
  - `Entity` в концах не используется — классы называются явно («максимально точная идентификация связи»)
168
- - **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет позиции — `{Услуга|Комплекс}` (заказ услуги ИЛИ заказ комплекса — ровно один из)
169
- - Обязательный конец без ключа → `requires end "Service|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
168
+ - **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет записи — `{Услуга|Товар|Комплекс}` (бронь услуги ИЛИ продажа товара ИЛИ комплекс — ровно один из)
169
+ - Обязательный конец без ключа → `requires end "Service|Product|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
170
170
  - `cardinality` — зарезервировано (0 — безлимит, N — точное число), пока не проверяется: связь класса в строке одна (`{Класс: id}`), множественность выражается строками-связками
171
- - Демо-домен: `позиция = [Запись, {Услуга|Комплекс}, Сотрудник?]`, `состав = [Комплекс, Услуга]` (состав комплекса — отдельный LINK), `навык = [Сотрудник, {Услуга|Комплекс}]`, `занятость = [Сотрудник, Окно, Запись?]`, `смена = [Расписание, Сотрудник]`, `Запись = [Клиент?]`
172
- - Источник правды — `schema.booking.v2.json`; сид генерится: `node lib/scripts/gen-seed.mjs`
171
+ - Демо-домен (0.17, позитивная доступность): `адрес = [Мастер, Локация]`, `окно = [Мастер, Локация, Расписание]` (смена — интервал доступности), `запись = [Мастер, Локация, Расписание, {Услуга|Товар|Комплекс}, Клиент?]` — **наследник окна**, `содержимое = [Папка, {Услуга|Товар|Комплекс}]`, `состав = [Комплекс, {Услуга|Товар}]`, `навык = [Мастер, Услуга]`, `цена = [{Услуга|Товар|Комплекс}]` (варианты цены: `note` + `amounts` record<валюта,число>)
172
+ - Источник правды демо-домена — сид `lib/sql/seed.booking.sql` (редактируется руками, идемпотентен)
173
173
 
174
174
  `schema_lineage` (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
175
175
  пересчитывает `ancestors`/`descendants` при любом изменении Schema.
176
- Из либы: `db.registry.resolve('связь').descendants` → `['busy','compo','item','shift','skill']`.
176
+ Из либы: `db.registry.resolve('связь').descendants` → `['address','booking','compo','content','price','skill','slot']`.
177
177
 
178
178
  ### 3.2 id считает схема: `attributes.id`
179
179
 
@@ -199,21 +199,31 @@ GIN-кандидатам, затем перепроверка условий н
199
199
  без создания чего-либо.
200
200
 
201
201
  ```ts
202
- // занятость: id считается сам — uuidv5(окно, мастер)
203
- const [б] = await db.Сотрудник(м).занятость().create({ kind: 'booking' }).Окно.set(о).rows()
204
- б.id === uuidv5(`v1.booking:entity:busy:${о.id}:${м.id}`) // → true
202
+ // запись: id считается сам — uuidv5(мастер, старт); правило унаследовано от окна
203
+ const [б] = await db.Мастер(м).запись().create({ start_datetime: t, end_datetime: e })
204
+ .Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
205
+ б.id === uuidv5(`v1.booking:entity:booking:${м.id}:${t}`) // → true
205
206
  // «занято?» — ДО создания чего-либо:
206
- await db.занятость(uuidv5(`v1.booking:entity:busy:${о.id}:${м.id}`)).first() // Row | null
207
+ await db.запись(uuidv5(`v1.booking:entity:booking:${м.id}:${t}`)).first() // Row | null
207
208
  ```
208
209
 
209
210
  Явный id у v5-класса запрещён (`computes id` — его всегда считает схема); в батче
210
211
  v5-классы не склеиваются в multi-VALUES (id нужны концы) — исполняются поштучно (§ 8).
211
212
 
212
213
  Демо-схема 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)` — имя уникально в организации.
214
+ v7-классы (Org/Staff/Customer/Location/Schedule/Folder) наследуют его без собственного
215
+ правила; v5 объявляются по одному разу и наследуются дальше:
216
+ `Element ← v5(Org, data.name)` — наследуют Услуга/Товар/Комплекс (имя уникально в
217
+ организации, classId различает классы); `окно (slot) ← v5(Staff, data.start_datetime)` —
218
+ наследует `запись (booking)`: двойная бронь мертва самим id записи, а окно и запись
219
+ на одно время сосуществуют (classId в формуле разный); `адрес ← v5(Staff, Location)`,
220
+ `навык ← v5(Staff, Service)`, `состав ← v5(Complex, Service|Product)`,
221
+ `содержимое ← v5(Folder, Service|Product|Complex)` — полные имена союзов в from;
222
+ `цена ← v5(Service|Product|Complex, note)` — вариант цены уникален по (элемент, note).
223
+
224
+ `from`-поле может быть **необязательным**: если его нет в `create`, id берёт его `default`
225
+ из Schema (напр. `цена` без `note` → `note:'базовая'`, id считается стабильно). Так
226
+ необязательное поле участвует в детерминированном id, не ломая генерацию.
217
227
 
218
228
  ### 3.3 Наследование attributes
219
229
 
@@ -242,25 +252,25 @@ data.start)`, `Service`/`Complex` ← `v5(Org, data.name)` — имя уника
242
252
  прямого HUB→HUB пути нет, идут через саму связку и **pivot** (повтор её имени):
243
253
 
244
254
  ```ts
245
- // «позиции записи з, где услуга у и мастер вася» — путь через позицию, pivot-возврат:
246
- db.Запись(з).позиция().Услуга(у).позиция().Сотрудник(вася).позиция().rows()
255
+ // «записи клиента к, где услуга у и мастер вася» — путь через запись, pivot-возврат:
256
+ db.Клиент(к).запись().Услуга(у).запись().Мастер(вася).запись().rows()
247
257
  // «умеет ли Ирина стрижку» — навык, дофильтр предметом, count путей:
248
- db.Сотрудник(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
258
+ db.Мастер(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
249
259
  ```
250
260
 
251
261
  ### Узел-переменная: `entity()`
252
262
 
253
263
  ```ts
254
- const p = db.позиция() // ленивый узел-паттерн
255
- await db.Запись(з).entity(p).Услуга(у).entity(p).Сотрудник().run() // та же p = тот же узел (явный pivot)
264
+ const p = db.запись() // ленивый узел-паттерн
265
+ await db.Клиент(к).entity(p).Услуга(у).entity(p).Мастер().run() // та же p = тот же узел (явный pivot)
256
266
  await db.entity(row).Услуга().first() // старт пути с готового Row
257
267
  ```
258
268
 
259
269
  ### Терминалы
260
270
 
261
271
  ```ts
262
- const пути = await db.Сотрудник({ name:'Вася' }).alias('Мастер').навык().Услуга().run()
263
- // [{ Мастер: Row, навык: Row, Услуга: Row }, …]
272
+ const пути = await db.Мастер({ name:'Вася' }).alias('Исполнитель').навык().Услуга().run()
273
+ // [{ Исполнитель: Row, навык: Row, Услуга: Row }, …]
264
274
  ```
265
275
 
266
276
  | Вызов | Возврат |
@@ -286,9 +296,9 @@ const пути = await db.Сотрудник({ name:'Вася' }).alias('Мас
286
296
  db.Услуга('uuid') // по id
287
297
  db.Услуга(rowИлиAccount) // объект с id — возьмётся .id
288
298
  db.Услуга(['id1','id2']) // по списку ([] → пусто)
289
- db.Услуга({ name: 'Стрижка', active: true }) // eq полей data → GIN-containment
299
+ db.Услуга({ name: 'Стрижка', duration: 60 }) // eq полей data → GIN-containment
290
300
  db.Услуга({ id: 'uuid', duration: gte(30) }) // ключ id — тоже id-фильтр
291
- db.Услуга({ price: { RUB: lte(2000) } }) // вложенные пути ЛЮБОЙ глубины (record/object) + каст по листу
301
+ db.Локация({ coordinates: { lat: gte(55) } }) // вложенные пути ЛЮБОЙ глубины (object) + каст по листу
292
302
  ```
293
303
 
294
304
  ### Операторы (18: + `not`, `or`)
@@ -305,29 +315,30 @@ import { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends,
305
315
  | `gt/gte/lt/lte(v)`, `between(a,b)` | number, date, string | `(data->>'f')::cast ⋛ $` |
306
316
  | `inList([…])` | любые | `IN (…)`; `[]` → FALSE |
307
317
  | `like/ilike/starts/ends(s)` | string | `[I]LIKE` |
308
- | `has(v)/hasAll([…])/hasAny([…])` | массивы data (`roles`) | `@>` / `?\|` |
318
+ | `has(v)/hasAll([…])/hasAny([…])` | массивы data и колонка `tags` | `@>` / `?\|` |
309
319
  | `exists(true/false)` | любые | ключ есть/нет |
310
320
  | `isNull()` | любые | null или отсутствует |
311
321
  | `not(op \| скаляр)` | по внутреннему | `NOT (…)`; `not(скаляр)` = `ne` |
312
- | `or(f1, f2, …)` | **фильтр целиком** | `db.Запись(or({status:'created'}, {status:'confirmed'}))` — дизъюнкция под-фильтров |
322
+ | `or(f1, f2, …)` | **фильтр целиком** | `db.Услуга(or({name:'Стрижка'}, {duration: lt(40)}))` — дизъюнкция под-фильтров |
313
323
 
314
324
  Касты по `Schema.attributes`: number→`::numeric`, date→`::timestamptz`, boolean→`::boolean`.
315
325
  Поле вне схемы фильтруется как text (записать его нельзя — строгая валидация).
316
- Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`), сравнения корректны.
317
- `{ roles: ['a','b'] }` = containment «содержит оба», не «равно массиву».
326
+ Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`); сравнения — операторами (`between(t, t)`
327
+ для «равно моменту»: скаляр-eq по дате — строковый jsonb-containment, он про
328
+ нормализованное значение). В демо-схеме массивов в data нет — `has*` живут на `tags`.
318
329
 
319
330
  ### Модификаторы цепочки
320
331
 
321
332
  ```ts
322
- db.Окно().sort('data.start').limit(10).offset(20).rows() // выборка
323
- db.Запись().sort('updated', 'desc').limit(50).rows()
333
+ db.окно().sort('data.start_datetime').limit(10).offset(20).rows() // выборка
334
+ db.запись().sort('updated', 'desc').limit(50).rows()
324
335
 
325
336
  db.Клиент().tags('vip') // фильтр: tags ⊇ ['vip']
326
337
  db.Клиент().tags(['vip','telegram']) // все перечисленные
327
338
  db.Клиент().tags(hasAny(['vip','b2b'])) // хотя бы один
328
- db.Запись().account(accId) // фильтр по колонке account (uuid | Row)
339
+ db.запись().account(accId) // фильтр по колонке account (uuid | Row)
329
340
  db.Организация().owner(acc) // фильтр по owner
330
- db.Сотрудник({…}).alias('Мастер') // ключ шага в путях
341
+ db.Мастер({…}).alias('Исполнитель') // ключ шага в путях
331
342
  ```
332
343
 
333
344
  | Модификатор | Область | В чтении | В записи |
@@ -358,15 +369,15 @@ db.Сотрудник({…}).alias('Мастер') // ключ шаг
358
369
 
359
370
  ```ts
360
371
  // одиночная запись: операция + терминал
361
- const [вася] = await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
372
+ const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
362
373
 
363
374
  // несколько операций в одной цепочке: продолжение — ОТ РЕЗУЛЬТАТА предыдущей
364
- await db.Клиент({ vip: true }).update({ bonus: 500 }) // новая версия всех vip
365
- .Запись().create({ status: 'gift' }) // INSERT записи КАЖДОМУ (fan-out)
366
- .rows() // → подарочные записи
375
+ await db.Организация().tags('сеть').update({ active: true }) // новая версия всех сетевых орг
376
+ .Услуга().create({ name: 'Акция месяца', duration: 30 }) // INSERT услуги КАЖДОЙ (fan-out)
377
+ .rows() // → акционные услуги
367
378
 
368
379
  // операция сразу после операции — к тем же сущностям (две версии подряд)
369
- await db.Запись(id).update({ status: 'confirmed' }).update({ paid: true }).rows()
380
+ await db.запись(id).update({ notes: 'подтверждена' }).update({ notes: 'оплачена' }).rows()
370
381
  ```
371
382
 
372
383
  ### Анатомия
@@ -400,7 +411,7 @@ db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хво
400
411
  Валидация **строгая, всегда**: поле вне `Schema.attributes` → `ValidationError`;
401
412
  default-ы схемы подставляются; id в `data` не хранится (он — колонка).
402
413
  - **Deep-merge при `update`** (и у create-версии по известному id): меняются только
403
- указанные листья — `update({ price: { RUB: 1100 } })` сохранит `USD/EUR` и остальные
414
+ указанные листья — `update({ coordinates: { lat: 55.8 } })` сохранит `lng` и остальные
404
415
  поля. Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
405
416
  - **Концы LINK**: по `Schema.links` v2 (§3.1) — обязательные требуются, союз занимает один ключ, лишние связи — ошибка; значения — id, Row или вложенная цепочка (§6.1).
406
417
  - **account/owner NOT NULL**: `.account()/.owner()` → `connect()` → System-аккаунт.
@@ -411,44 +422,44 @@ db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хво
411
422
 
412
423
  Один закон: **владелец создаваемой связки берётся из валидного пути; прочие концы —
413
424
  слотами** `.Класс.set(значение)` после глагола записи. Слот — свойство-класс БЕЗ вызова
414
- (`.Сотрудник.set(x)`, не `.Сотрудник(x)`); валиден только для конца из `Schema.links` и
425
+ (`.Клиент.set(x)`, не `.Клиент(x)`); валиден только для конца из `Schema.links` и
415
426
  требует операцию записи ПЕРЕД собой: `…create(…).Класс.set(x)` / `…update(…).Класс.unset()`;
416
427
  слот без глагола — ошибка `link slot needs a write`.
417
428
 
418
429
  ```ts
419
- // СОЗДАНИЕ: владелец Запись — из пути, предмет и исполнитель — слотами
420
- await db.Запись(b).позиция().create({ qty: 1, price: { RUB: 1500 } })
421
- .Услуга.set(u).Сотрудник.set(s).rows()
430
+ // СОЗДАНИЕ: владелец (Мастер) — из пути, остальные концы — слотами; id считает схема (§ 3.2)
431
+ await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
432
+ .Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
422
433
 
423
- // занятость: владелец Сотрудник из пути, окно и запись — слотами; id считает схема (§ 3.2)
424
- await db.Сотрудник(s).занятость().create({ kind: 'booking' }).Окно.set(w).Запись.set(b).rows()
434
+ // навык: владелец Мастер из пути, предмет — слотом
435
+ await db.Мастер(s).навык().create({ level: 'expert' }).Услуга.set(у).rows()
425
436
 
426
437
  // ПЕРЕВЕС связи (update): цель ищется путём/pivot, слот пишет новое значение БЕЗ фильтра
427
- await tr.Запись(b).позиция().Сотрудник(старый).позиция().update().Сотрудник.set(новый).rows()
438
+ await tr.Клиент(к).запись().Услуга(старая).запись().update().Услуга.set(новая).rows()
428
439
 
429
- // СОЮЗ-конец [Услуга|Комплекс]: слот замещает конец целиком (соседний класс снимается)
430
- await db.позиция(p).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
440
+ // СОЮЗ-конец [Услуга|Товар|Комплекс]: слот замещает конец целиком (соседний класс снимается)
441
+ await db.запись(b).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
431
442
 
432
- // СНЯТИЕ optional-конца
433
- await db.занятость(bz).update({ kind: 'hold' }).Запись.unset().rows()
443
+ // СНЯТИЕ optional-конца: ручная бронь — клиент отвязан, имя в notes
444
+ await db.запись(b).update({ notes: 'по телефону: Аня' }).Клиент.unset().rows()
434
445
 
435
446
  // вложенная цепочка как значение слота — та же транзакция, ровно одна сущность:
436
- await db.Запись(b).позиция().create({ qty: 1 })
437
- .Услуга.set(u)
438
- .Сотрудник.set(db.Сотрудник().create({ name: 'Новичок' }).Org.set(org)).rows() // создать И привязать
447
+ await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
448
+ .Локация.set(л).Расписание.set(р)
449
+ .Услуга.set(db.Организация(org).Услуга().create({ name: 'Новинка', duration: 45 })).rows() // создать И привязать
439
450
  ```
440
451
 
441
452
  ### `.delete({ confirm })` — превью и серверное удаление
442
453
 
443
454
  ```ts
444
- const превью = await db.Запись(bid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
445
- // кандидаты (цель + каскад), живые, БД не тронута:
446
- // [{class:'Booking'}, {class:'busy'}, {class:'item'}]
455
+ const превью = await db.Клиент(cid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
456
+ // кандидаты (цель + каскад: записи клиента), живые, БД не тронута:
457
+ // [{class:'Customer'}, {class:'booking'}]
447
458
 
448
- const удалено = await db.Запись(bid).delete({ confirm: true }).rows()
459
+ const удалено = await db.Клиент(cid).delete({ confirm: true }).rows()
449
460
  // удалённое дерево, каждый Row с $deleted: true
450
461
 
451
- await db.Запись(b).занятость().delete({ confirm: true }).rows() // контекст: только занятости записи
462
+ await db.Мастер(m).окно().delete({ confirm: true }).rows() // контекст: только окна мастера
452
463
  ```
453
464
 
454
465
  Цели = фильтр шага + контекст-связи. Замыкание (цели + все живые зависимые) собирается
@@ -459,10 +470,10 @@ id — воскрешение.
459
470
  ### Откат плана
460
471
 
461
472
  ```ts
462
- await db.Клиент(id).update({ name: 'Новое' }) // валидный сегмент…
463
- .Запись().create({ чепуха: 1 }) // …невалидный: ValidationError
473
+ await db.Организация(org).update({ phone: '+7 495 …' }) // валидный сегмент…
474
+ .Услуга().create({ name: 'X', чепуха: 1 }) // …невалидный: ValidationError
464
475
  .rows()
465
- // ← ОТКАТ ВСЕГО: имя клиента не изменилось, версий не прибавилось
476
+ // ← ОТКАТ ВСЕГО: телефон не изменился, версий не прибавилось
466
477
  ```
467
478
 
468
479
  ---
@@ -470,19 +481,20 @@ await db.Клиент(id).update({ name: 'Новое' }) // валидный
470
481
  ## 7. Транзакции
471
482
 
472
483
  ```ts
473
- const busyId = uuidv5(`v1.booking:entity:busy:${slotId}:${staffId}`) // id известен ДО создания (§ 3.2)
484
+ const bookingId = uuidv5(`v1.booking:entity:booking:${staffId}:${start}`) // id известен ДО создания (§ 3.2)
474
485
 
475
486
  const tr = await db.begin() // тот же API на выделенном соединении
476
- await tr.lock('busy', staffId, slotId) // advisory-xact-lock до конца транзакции
477
- const занято = await tr.занятость(busyId).first()
478
- if (!занято) await tr.Сотрудник(s).занятость().create({ kind: 'booking' }).Окно.set(w).rows()
487
+ await tr.lock('booking', staffId, start) // advisory-xact-lock до конца транзакции
488
+ const занято = await tr.запись(bookingId).first()
489
+ if (!занято) await tr.Мастер(s).запись().create({ start_datetime: start, end_datetime: end })
490
+ .Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
479
491
  await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()
480
492
  ```
481
493
 
482
494
  Повторный commit/rollback — no-op. `lock()` вне транзакции — ошибка. Держите транзакции
483
- короткими. **Рецепт двойной брони**: id занятости детерминирован схемой (v5 — § 3.2), так
484
- что дубль невозможен в принципе (второй `create` стал бы версией той же занятости);
485
- `lock(мастер, окно)` + перечитка `first()` под локом нужны, чтобы сопернику честно
495
+ короткими. **Рецепт двойной брони**: id записи детерминирован схемой (v5 — § 3.2), так
496
+ что дубль невозможен в принципе (второй `create` стал бы версией той же записи);
497
+ `lock(мастер, старт)` + перечитка `first()` под локом нужны, чтобы сопернику честно
486
498
  ОТКАЗАТЬ, а не молча версионировать чужую бронь (ровно одна успешна — покрыто тестом-гонкой).
487
499
 
488
500
  ---
@@ -490,11 +502,12 @@ await db.commit(tr) // или db.rollback(tr) / tr.comm
490
502
  ## 8. Батчи
491
503
 
492
504
  ```ts
493
- db.batch('окна').Расписание(sch).Окно().create({ start, end }) // план встал в очередь
494
- db.batch('окна').Расписание(sch).Окно().create({ … })
495
- db.batch('окна').size() // 2
496
- const res = await db.batch('окна').run() // одна транзакция; Row[][] по порядку
497
- db.batch('окна').discard() // отменить
505
+ db.batch('смены').Мастер(m).окно().create({ start_datetime, end_datetime }) // план встал в очередь
506
+ .Локация.set(loc).Расписание.set(sch)
507
+ db.batch('смены').Мастер(m2).окно().create({ … }).Локация.set(loc).Расписание.set(sch)
508
+ db.batch('смены').size() // 2
509
+ const res = await db.batch('смены').run() // одна транзакция; Row[][] по порядку
510
+ db.batch('смены').discard() // отменить
498
511
  ```
499
512
 
500
513
  - `run()` атомарен: любая ошибка откатывает всё.
@@ -620,7 +633,7 @@ lockout после N неудач. Legacy-bcrypt-хэши (`$2b$…` из дам
620
633
  |---|---|---|
621
634
  | `ACCOUNT` | аккаунта | `{"categories": "{Staff}"}`; `"!{Anonymous,Shadow}"` — нет ни одной; NULL — все |
622
635
  | `API` | адреса эндпоинта | `{"endpoint": "v2.auth.apikey.*"}` — маска: `.`-сегменты, `{a,b}`, `*` — хвост |
623
- | `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "Booking", "owner": "$account"}` |
636
+ | `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "booking", "owner": "$account"}` |
624
637
 
625
638
  Pattern операций — реальные колонки Entity (`class`, `owner`, `account`, `tags`, `data`,
626
639
  `links`…), значения — литералы или `"$account"` (id субъекта, подставляется в запрос);
@@ -637,7 +650,7 @@ await db.acl.check(anon, 'v2.auth.password.signup')
637
650
  await db.acl.check(anon, 'v2.auth.apikey.create')
638
651
  // → { allow: false, code: 403, message: 'Access denied - default …' } — победило дно-правило deny 0
639
652
 
640
- await db.acl.checkData(user, 'Booking', 'READ') // ресурс {"class":"Booking","owner":"$account"}
653
+ await db.acl.checkData(user, 'booking', 'READ') // ресурс {"class":"booking","owner":"$account"}
641
654
  // → { allow: true, filter: { owner: '24c49a43-…' }, rule: {…weight: 60} } [1.7 ms]
642
655
  await db.acl.checkData(user, 'SportsCar', 'READ') // право дал предок Vehicle (lineage)
643
656
  // → { allow: true } — безусловный, без предиката
@@ -653,29 +666,30 @@ allow-классов (payload нечем проверить предикат).
653
666
 
654
667
  ```ts
655
668
  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 по суженному множеству
669
+ await u.запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
670
+ await u.запись().count() // честный count по суженному множеству
658
671
  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 тоже НЕ перехватывает: → [] вместо новой версии
672
+ const [z] = await u.Мастер(m).запись().create({…}).rows() // owner пришпилен правилом → user.id
673
+ await u.запись().owner(other).update({…}).rows() // Error: acl pins booking writes to owner …
674
+ await u.запись(чужаяId).update({ notes: '…' }).rows() // → [] — цель вне предиката не находится
675
+ // create той же v5-пары (id чужой записи) тоже НЕ перехватывает: → [] вместо новой версии
663
676
  ```
664
677
 
665
- Оверхед (article-полигон 1.1M, p50 из 20; `bench/acl.bench.mjs`):
678
+ Оверхед (полигон `v1.salondemo` ~980k, booking 440k×2; p50 из 20; `bench/acl.bench.mjs`):
666
679
 
667
680
  | Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
668
681
  |---|---|---|---|
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 за категориями аккаунта на каждый вызов);
682
+ | точечный `first(id)` | 2.9 ms | 4.2 ms | 6.1 ms |
683
+ | фильтр `rows` limit 100 | 1394 ms | 1454 ms | 1362 ms |
684
+ | keyset-страница всего класса 440k | 5495 ms | 5576 ms | 5364 ms |
685
+ | `count()` класса 440k | 3196 ms | 3049 ms | 2874 ms |
686
+ | цепочка `запись(id).Услуга()` | 5.5 ms | 6.6 ms | 5.1 ms |
687
+
688
+ Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат здесь на
689
+ селективности 100% (все строки booking — System-аккаунт, предикат никого не отсекает) —
690
+ на тяжёлых сценах ACL в пределах шума с базой; в реальности предикат СУЖАЕТ выборку,
691
+ и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
692
+ `db.acl.checkData` — справочный (1.0 ms: SQL за категориями аккаунта на каждый вызов);
679
693
  горячий путь цепочек использует резолвер, скомпилированный на connect (микросекунды, memo).
680
694
 
681
695
  `enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
@@ -686,8 +700,8 @@ worst-case (пропускает 100% строк — чистый оверхед
686
700
 
687
701
  ```ts
688
702
  await db.Услуга().rows() // срез класса (актуальное, живое)
689
- await db.Запись().sort('updated','desc').limit(50).rows()
690
- await db.Клиент(cid).Запись().позиция().run() // связный подграф
703
+ await db.запись().sort('updated','desc').limit(50).rows()
704
+ await db.Клиент(cid).запись().Услуга().run() // связный подграф
691
705
  ```
692
706
 
693
707
  ```sql
@@ -697,7 +711,7 @@ SELECT * FROM (SELECT DISTINCT ON (class, id) * FROM "v1.booking"."Entity"
697
711
 
698
712
  -- история сущности (все версии, включая tombstone)
699
713
  SELECT updated, deleted, data, links FROM "v1.booking"."Entity"
700
- WHERE partition='entity' AND class='Booking' AND id=$1 ORDER BY updated;
714
+ WHERE partition='entity' AND class='booking' AND id=$1 ORDER BY updated;
701
715
  ```
702
716
 
703
717
  ### 10.1 Время-путешествия: `.asOf()` / `.versions()`
@@ -705,11 +719,11 @@ WHERE partition='entity' AND class='Booking' AND id=$1 ORDER BY updated;
705
719
  ```ts
706
720
  // «какая цена была на момент брони» — версии позже T невидимы, tombstone до T = «удалён»
707
721
  const тогда = await db.Услуга(id).asOf('2026-07-01T12:00:00Z').first()
708
- const срезДня = await db.Запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
722
+ const срезДня = await db.запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
709
723
 
710
724
  // вся история сущности без сырого SQL (tombstone-версии приходят с $deleted: true)
711
- const история = await db.Запись(id).versions()
712
- // [{data:{status:'created'}}, {data:{status:'confirmed'}}, {…, $deleted:true}]
725
+ const история = await db.запись(id).versions()
726
+ // [{data:{…}}, {data:{…, notes:'подтверждена'}}, {…, $deleted:true}]
713
727
  ```
714
728
 
715
729
  `asOf` применяется к каждому шагу цепочки — подграф целиком «как был». `versions()`
@@ -723,19 +737,19 @@ Keyset — закладка: курсор = значение поля сорти
723
737
  порядок тотальным, дубли значений не теряются и не повторяются).
724
738
 
725
739
  ```ts
726
- const стр1 = await db.Запись().sort('updated', 'desc').limit(50).rows()
740
+ const стр1 = await db.запись().sort('updated', 'desc').limit(50).rows()
727
741
  const кур = cursorOf(стр1.at(-1)!) // { v: '<updated>', id: '…' } — просто объект,
728
- const стр2 = await db.Запись().sort('updated', 'desc') // можно хранить в URL/state
742
+ const стр2 = await db.запись().sort('updated', 'desc') // можно хранить в URL/state
729
743
  .after(кур).limit(50).rows()
730
744
 
731
745
  // по data-пути ЛЮБОЙ глубины — field тот же, что в sort
732
- const дальше = await db.Окно().sort('data.start')
733
- .after(cursorOf(окно, 'data.start')).limit(20).rows()
746
+ const дальше = await db.окно().sort('data.start_datetime')
747
+ .after(cursorOf(окно, 'data.start_datetime')).limit(20).rows()
734
748
 
735
749
  // бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
736
750
  let cursor
737
751
  do {
738
- const page = await db.Запись({ status: 'confirmed' }).sort('updated', 'desc')
752
+ const page = await db.Мастер(m).запись().sort('updated', 'desc')
739
753
  .after(cursor).limit(50).rows()
740
754
  render(page)
741
755
  cursor = page.length ? cursorOf(page.at(-1)!) : undefined
@@ -752,14 +766,14 @@ do {
752
766
  ### 10.3 Агрегации: считает БД
753
767
 
754
768
  ```ts
755
- await db.Запись({ status: 'completed' }).sum('data.total.RUB') // выручка: number | null
756
- await db.Услуга().avg('data.duration') // среднее
757
- await db.Запись().countBy('data.status') // { confirmed: 12, cancelled: 3 } ({} на пустом)
758
- await db.Окно().min('data.start') // min/max — каст по типу поля из Schema
769
+ await db.цена().sum('data.amounts.RUB') // сумма всех прайсов (record-лист): number | null
770
+ await db.Услуга().avg('data.duration') // среднее
771
+ await db.Услуга().countBy('data.duration') // { '30': 2, '60': 1 } ({} на пустом)
772
+ await db.окно().min('data.start_datetime') // min/max — каст по типу поля из Schema
759
773
  ```
760
774
 
761
- Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или record-лист
762
- `'data.total.RUB'`. `sum`/`avg` кастуются в numeric; пустое множество → `null`.
775
+ Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или вложенный лист
776
+ (`'data.coordinates.lat'`). `sum`/`avg` кастуются в numeric; пустое множество → `null`.
763
777
 
764
778
  ### 10.4 Деревья: `.deep()`
765
779
 
@@ -775,7 +789,7 @@ reverse = дети); на другом переходе — ошибка. Раб
775
789
  ### 10.5 Realtime: `db.watch()`
776
790
 
777
791
  ```ts
778
- const stop = await db.watch('Запись', (e) => {
792
+ const stop = await db.watch('запись', (e) => {
779
793
  // e = { partition, class, id, updated, deleted } — факт версии (insert/update/tombstone)
780
794
  обновитьКалендарь(e.id)
781
795
  })
@@ -791,7 +805,7 @@ await stop() // отписка
791
805
  повторяет LISTEN), но `NOTIFY` за время разрыва потеряны — для этого `onReconnect`:
792
806
 
793
807
  ```ts
794
- const stop = await db.watch('Запись', onEvent, {
808
+ const stop = await db.watch('запись', onEvent, {
795
809
  onReconnect: () => дочитатьПропущенное(), // напр. перечитать всё с последнего e.updated
796
810
  })
797
811
  ```
@@ -800,9 +814,9 @@ const stop = await db.watch('Запись', onEvent, {
800
814
 
801
815
  ```ts
802
816
  const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
803
- await db.Запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
804
- await db.Клиент().create({ name: 'X' }) // запись пришпилена к account
805
- db.Запись().account(чужой).rows() // ошибка: reads are pinned to account …
817
+ await db.запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
818
+ await db.Клиент().create({ name: 'X', phone: '+7…' }) // запись пришпилена к account
819
+ db.запись().account(чужой).rows() // ошибка: reads are pinned to account …
806
820
  ```
807
821
 
808
822
  Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
@@ -812,7 +826,7 @@ db.Запись().account(чужой).rows() // ошибка: reads are p
812
826
  ### 10.7 Анонимизация (GDPR): `.anonymize()`
813
827
 
814
828
  ```ts
815
- await db.Клиент(id).anonymize(['name', 'contact']).rows()
829
+ await db.Клиент(id).anonymize(['name', 'phone']).rows()
816
830
  // новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
817
831
  ```
818
832
 
@@ -839,7 +853,7 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
839
853
  ### 10.9 Миграции классов: `scripts/schema-sync.mjs`
840
854
 
841
855
  ```bash
842
- node scripts/schema-sync.mjs --file=../schema.booking.v2.json --dsn=… --schema=v1.booking
856
+ node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
843
857
  # schema-sync: … ↔ booking.Schema (partition entity)
844
858
  # + Coupon (HUB · Купон) — новый класс
845
859
  # ~ Service — изменены: attributes
@@ -877,7 +891,7 @@ npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d
877
891
  ```ts
878
892
  import type { TypedDb } from './entity-types'
879
893
  const t = db as unknown as TypedDb
880
- const [svc] = await t.Услуга({ active: true }).rows() // svc.data.duration: number
894
+ const [svc] = await t.Услуга({ duration: 60 }).rows() // svc.data.duration: number
881
895
  ```
882
896
 
883
897
  Интерфейсы data-полей всех классов (enum → union-литералы, record → `Partial<Record<…>>`)
@@ -914,11 +928,12 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
914
928
 
915
929
  Каждый метод описан по одной схеме: **сигнатура → параметры → назначение и алгоритм →
916
930
  примеры (под каждым вызовом реальный ответ и время) → полный кейс**. Все ответы и тайминги —
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
- в ответах опущены.
931
+ **живой прогон** на едином полигоне `v1.salondemo` ~980 000 строк Entity (год окон-смен и
932
+ записей: 440 000 записей-наследников окон ×2 версии, каталог из 600 услуг, 360 мастеров,
933
+ 40 000 клиентов, 1026 цен, 1260 навыков); воспроизводитель — `bench/api-reference-demo.mjs` (только
934
+ читает полигон salon-seed, мутирует лишь свои сущности). Тот же полигон — под статьёй SALON.md.
935
+ id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; повторяющиеся
936
+ `account`/`owner`/`partition` в ответах опущены.
922
937
 
923
938
  Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
924
939
  [11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
@@ -956,23 +971,23 @@ NOT NULL `Entity.account`. При `enforceAcl: true` дополнительно
956
971
  **Примеры**
957
972
 
958
973
  ```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}
974
+ const db = await connect({ dsn, schema: 'v1.salondemo', onQuery: (e) => log(e) }) // [33.6 ms]
975
+ await db.Услуга().first()
976
+ // первое событие onQuery: {"mode":"rows","classes":["Service"],"ms":15.7,"rows":1,"slow":false}
962
977
 
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}
978
+ const dbSlow = await connect({ dsn, schema: 'v1.salondemo', slowMs: 200, onQuery: … }) // [35.8 ms]
979
+ await dbSlow.запись().count() // ~440k сущностей — дольше порога:
980
+ // {"mode":"count","classes":["booking"],"ms":3191.0,"rows":1,"slow":true}
966
981
  ```
967
982
 
968
983
  **Кейс: три подключения — обычное, изолированное, под ACL**
969
984
 
970
985
  ```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 — только свой арендатор
986
+ const db = await connect({ dsn, schema }) // [33.6 ms]
987
+ const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [40.2 ms]
988
+ const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [54.7 ms]
989
+ await db.Организация().count() // → 30 — видит всех
990
+ await iso.Организация().count() // [5.1 ms] → 1 — только свой арендатор
976
991
  await uc.Организация().rows()
977
992
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default)
978
993
  await iso.close(); await uc.close()
@@ -1045,14 +1060,14 @@ await up({ dsn, schema: 'v1.booking', version: 1 })
1045
1060
 
1046
1061
  ```ts
1047
1062
  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()
1063
+ await db.Организация().count() // → 0 — свежая схема
1064
+ await db.Организация().create({ name: 'BarberPro' }).rows()
1050
1065
  await db.close()
1051
1066
 
1052
1067
  // … docker stop letopis-timescale (ребут, уборка, что угодно) …
1053
1068
 
1054
1069
  const db2 = await up({ schema: 'booking', version: 1, dsn }) // [1.2 s] start + reuse
1055
- await db2.Клиент().count() // → 2 — volume letopis-pgdata: всё на месте
1070
+ await db2.Организация().count() // → 1 — volume letopis-pgdata: всё на месте
1056
1071
  await db2.close()
1057
1072
  ```
1058
1073
 
@@ -1086,7 +1101,7 @@ import type {
1086
1101
 
1087
1102
  | Параметр | Тип | Описание |
1088
1103
  |---|---|---|
1089
- | `Класс` | имя свойства | id или алиас класса из `Schema` (`db.Booking` ≡ `db.Запись`); неизвестное имя — ошибка со списком классов |
1104
+ | `Класс` | имя свойства | id или алиас класса из `Schema` (`db.booking` ≡ `db.запись`); неизвестное имя — ошибка со списком классов |
1090
1105
  | `filter` | `Filter?` | без аргумента — весь класс; `string` — по id; `string[]` — по списку id; `Row`/объект с `.id` — как id; объект — поля `data` (равенство, операторы § 11.4, record-пути) + ключ `id` |
1091
1106
 
1092
1107
  **Назначение и алгоритм.** Старт цепочки чтения/записи (§ 4–6). Ничего не выполняет —
@@ -1097,10 +1112,10 @@ import type {
1097
1112
  **Примеры**
1098
1113
 
1099
1114
  ```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
1115
+ await db.Услуга('…0000').first() // [3.5 ms] по id → Row {name: 'Стрижка 0', …}
1116
+ await db.Услуга(['…0000', '…0006']).rows() // [2.6 ms] по списку → ['Стрижка 0', 'Бритьё 6']
1117
+ await db.Организация(org).first() // [2.2 ms] Row-объект ≡ его id → 'Салон «Стрижка» №0'
1118
+ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [36.0 ms] → 630 (фильтр по цене)
1104
1119
  await db.НетТакогоКласса().rows()
1105
1120
  // Error: letopis: unknown class "НетТакогоКласса". Known: Entity·Сущность, Org·Организация, …
1106
1121
  ```
@@ -1108,10 +1123,10 @@ await db.НетТакогоКласса().rows()
1108
1123
  **Кейс: одна сущность тремя формами фильтра**
1109
1124
 
1110
1125
  ```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}
1126
+ const поId = await db.Услуга('…0000').first() // [3.5 ms] → data.name = 'Стрижка 0'
1127
+ const поПолю = await db.Услуга({ name: 'Стрижка 0' }).first() // тот же Row
1128
+ const поОбъекту = await db.Услуга(поId).first() // Row как фильтр ≡ его id
1129
+ // все три → id '…0000', data.name = 'Стрижка 0' (цена — отдельным LINK «цена», § 11.9)
1115
1130
  ```
1116
1131
 
1117
1132
  #### `db.begin(): Promise<EntityTx>`
@@ -1130,19 +1145,20 @@ deadlock/serialization ошибка приходит сразу с подска
1130
1145
 
1131
1146
  ```ts
1132
1147
  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] — теперь видно всем
1148
+ await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15 }).rows()
1149
+ // [9.0 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
1150
+ await tr.commit() // [3.7 ms] — теперь видно всем
1136
1151
  ```
1137
1152
 
1138
1153
  **Кейс: атомарный перенос с откатом при провале** — § 11.6 (`tr.lock`), плюс rollback:
1139
1154
 
1140
1155
  ```ts
1156
+ const [цУкл] = await db.Услуга({ name: 'Укладка' }).цена().create({ amounts: { RUB: 700 } }).rows() // базовая цена
1141
1157
  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 — изменение исчезло
1158
+ await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows()
1159
+ await tr.цена(цУкл).first() // внутри tx → amounts.RUB = 9900
1160
+ await tr.rollback() // [1.1 ms]
1161
+ await db.цена(цУкл).first() // снаружи → amounts.RUB = 700 — изменение исчезло
1146
1162
  ```
1147
1163
 
1148
1164
  #### `db.commit(tr): Promise<void>` / `db.rollback(tr): Promise<void>`
@@ -1157,7 +1173,7 @@ await db.Услуга({ name: 'Укладка' }).first() // снаружи
1157
1173
  **Примеры**
1158
1174
 
1159
1175
  ```ts
1160
- await db.commit(tr3) // [2.9 ms] — то же, что tr3.commit()
1176
+ await db.commit(tr3) // [4.0 ms] — то же, что tr3.commit()
1161
1177
  await db.commit()
1162
1178
  // Error: letopis: commit() needs a transaction: db.commit(tr) or tr.commit() [0.1 ms]
1163
1179
  ```
@@ -1183,14 +1199,14 @@ Tombstone-версия приходит с `deleted: true`.
1183
1199
  **Примеры**
1184
1200
 
1185
1201
  ```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()
1202
+ const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [17.9 ms]
1203
+ await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр §11', phone: '+7 900 …' }).rows()
1204
+ await db.Клиент('…0932').update({ preferred_contact: 'email' }).rows()
1189
1205
  await db.Клиент('…0932').delete({ confirm: true }).rows()
1190
1206
  // пойманные (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 }
1207
+ // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.912278+00:00', deleted: false }
1208
+ // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.921929+00:00', deleted: false }
1209
+ // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.944767+00:00', deleted: true }
1194
1210
  await stop()
1195
1211
  ```
1196
1212
 
@@ -1202,8 +1218,8 @@ const stop = await db.watch('Клиент', (e) => события.push(e), {
1202
1218
  })
1203
1219
  // авария: pg_terminate_backend по LISTEN-соединению …
1204
1220
  // 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 }
1221
+ await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр после обрыва', phone: '+7 900 …' }).rows() // create по id — воскрешение
1222
+ // событие после reconnect: { class: 'Customer', id: '…0932', updated: '…43.273398+00:00', deleted: false }
1207
1223
  await stop()
1208
1224
  ```
1209
1225
 
@@ -1213,7 +1229,7 @@ await stop()
1213
1229
  падают ошибкой postgres.js.
1214
1230
 
1215
1231
  ```ts
1216
- await db.close() // [1.7 ms]
1232
+ await db.close() // [1.3 ms]
1217
1233
  ```
1218
1234
 
1219
1235
  **Кейс** — завершение процесса: `close()` в `finally`/`SIGTERM`-хендлере после `stop()`
@@ -1225,7 +1241,7 @@ await db.close() // [1.7 ms]
1225
1241
  и поле `all` — § 11.11.
1226
1242
 
1227
1243
  ```ts
1228
- db.registry.resolve('Запись') // [90 µs] → ClassDef {id: 'Booking', alias: 'Запись', …}
1244
+ db.registry.resolve('запись') // [99 µs] → ClassDef {id: 'booking', alias: 'запись', …}
1229
1245
  ```
1230
1246
 
1231
1247
  #### `db.sql`
@@ -1234,8 +1250,8 @@ db.registry.resolve('Запись') // [90 µs] → ClassDef {id: 'Booking', a
1234
1250
  Ответственность за SQL — на вызывающем (движок цепочек его не проверяет).
1235
1251
 
1236
1252
  ```ts
1237
- await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.article"."Entity"')
1238
- // [32.3 ms] → [{ n: 1112925 }]
1253
+ await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.salondemo"."Entity"')
1254
+ // [37.3 ms] → [{ n: 980216 }]
1239
1255
  ```
1240
1256
 
1241
1257
  **Кейс** — снятие плана тяжёлого запроса: `db.sql.unsafe('EXPLAIN (ANALYZE) …')` для
@@ -1267,8 +1283,8 @@ prev.links->>'Класс'`) кладётся точным равенством,
1267
1283
  **Примеры**
1268
1284
 
1269
1285
  ```ts
1270
- await db.Организация('…0001').Сотрудник().count() // [5.4 ms] → 2 (обратный hop)
1271
- await db.навык().Услуга().count() // [57.2 ms] → 1001 (LINK → HUB, прямой)
1286
+ await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12 (обратный hop)
1287
+ await db.навык().Услуга().count() // [65.2 ms] → 1260 (LINK → HUB, прямой)
1272
1288
  ```
1273
1289
 
1274
1290
  **Кейс: маршрут «мастер → его навыки → услуги»** — см. `.run()` ниже (тот же прогон).
@@ -1285,20 +1301,21 @@ await db.навык().Услуга().count() // [57.2 ms] →
1285
1301
  **Примеры**
1286
1302
 
1287
1303
  ```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 путь, полные узлы каждого шага
1304
+ await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
1305
+ // → 2 пути (у Ольги 0.0 два навыка на разные услуги); первый:
1306
+ // [{
1307
+ // Мастер: { id: '00000003-…-000', class: 'Staff', data: { name: 'Ольга 0.0', phone: '+7 921 0000000', specialization: 'парикмахер' }, links: { Org: '00000001-…-000' }, … },
1308
+ // навык: { id: '00000012-…-000', class: 'skill', data: { level: 'basic' }, links: { Staff: '00000003-…-000', Service: '00000004-…-000' }, … },
1309
+ // Услуга: { id: '00000004-…-000', class: 'Service', data: { name: 'Стрижка 0', duration: 30, description: 'популярное' }, links: { Org: '00000001-…-000' }, … }
1310
+ // }, …] — по объекту на путь, полные узлы каждого шага
1294
1311
  ```
1295
1312
 
1296
1313
  **Кейс: отчёт «кто что умеет» одним запросом**
1297
1314
 
1298
1315
  ```ts
1299
- const пути = await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run() // [18.9 ms]
1300
- пути.map((p) => `${p.Сотрудник.data.name} → ${p.Услуга.data.name}`)
1301
- // → ['Вася → Стрижка']
1316
+ const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
1317
+ пути.map((p) => `${p.Мастер.data.name} → ${p.Услуга.data.name}`)
1318
+ // → ['Ольга 0.0 → Стрижка 0', 'Ольга 0.0 → Массаж 7']
1302
1319
  ```
1303
1320
 
1304
1321
  #### `.rows(): Promise<Row[]>`
@@ -1311,17 +1328,18 @@ const пути = await db.Сотрудник({ name: 'Вася' }).навык().
1311
1328
  **Примеры**
1312
1329
 
1313
1330
  ```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' }
1331
+ const услуги = await db.Услуга().rows() // [11.1 ms] → 600 Row
1332
+ // [0] = { id: '00000004-0000-4000-8000-000000000000', class: 'Service',
1333
+ // data: { name: 'Стрижка 0', duration: 30, description: 'популярное' },
1334
+ // links: { Org: '00000001-0000-4000-8000-000000000000' }, tags: [],
1335
+ // updated: '2025-08-21T10:18:02.707+00:00' }
1318
1336
  ```
1319
1337
 
1320
1338
  **Кейс: пути vs уникальные сущности**
1321
1339
 
1322
1340
  ```ts
1323
- await db.навык().Услуга().count() // [57.2 ms] → 1001 путей (навыков на услуги)
1324
- (await db.навык().Услуга().rows()).length // [60.8 ms] → 401 уникальная услуга
1341
+ await db.навык().Услуга().count() // [65.2 ms] → 1260 путей (навык → услуга)
1342
+ (await db.навык().Услуга().rows()).length // [83.6 ms] → 600 уникальных услуг
1325
1343
  ```
1326
1344
 
1327
1345
  #### `.first(): Promise<Row | null>`
@@ -1329,10 +1347,10 @@ await db.навык().Услуга().count() // [57.2 ms] → 1001 пу
1329
1347
  Параметров нет. То же, что `rows()` с `LIMIT 1`: первая строка или `null`.
1330
1348
 
1331
1349
  ```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
1350
+ await db.Мастер({ name: 'Олег 0.7' }).first() // [5.6 ms]
1351
+ // → { id: '…0007', class: 'Staff', data: { name: 'Олег 0.7', phone: '+7 921 0000007', specialization: 'колорист' },
1352
+ // links: { Org: '…0000' }, tags: [], updated: '2025-08-01T00:00:00+00:00' }
1353
+ await db.Мастер({ name: 'Гэндальф' }).first() // [5.7 ms] → null
1336
1354
  ```
1337
1355
 
1338
1356
  **Кейс: проверка «занято ли окно» перед бронью** — § 11.6 (перечитка под локом).
@@ -1343,8 +1361,8 @@ await db.Сотрудник({ name: 'Гэндальф' }).first() // [3.9 ms]
1343
1361
  (дешевле по трафику).
1344
1362
 
1345
1363
  ```ts
1346
- await db.Сотрудник().ids() // [3.8 ms] → 302 id
1347
- // ['00000000-0000-4000-8000-000000000011', '…0012', '00000003-0000-4000-8000-000000000000', …]
1364
+ await db.Мастер().ids() // [4.3 ms] → 360 id
1365
+ // ['00000003-0000-4000-8000-000000000000', '…0001', '00000003-0000-4000-8000-000000000002', …]
1348
1366
  ```
1349
1367
 
1350
1368
  **Кейс: набор id для батч-обработки** — собрать `ids()`, скормить очереди задач; полные
@@ -1359,17 +1377,17 @@ await db.Сотрудник().ids() // [3.8 ms] → 302 id
1359
1377
  для многошаговой — нет (см. кейс `.rows()`).
1360
1378
 
1361
1379
  ```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
1380
+ await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12
1381
+ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [37.4 ms] → 630
1382
+ await db.Локация({ coordinates: { lat: gte(55.5) } }).count()
1383
+ // [4.4 ms] → 26 — оператор на листе record-пути (глубина 2), каст numeric по Schema
1366
1384
  ```
1367
1385
 
1368
1386
  **Кейс: витрина каталога** — счётчики к фильтрам без выборки строк:
1369
1387
 
1370
1388
  ```ts
1371
- await db.Услуга({ duration: lte(45) }).count() // [3.0 ms] → 201 «быстрые»
1372
- await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 301 «премиум»
1389
+ await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300 «быстрые»
1390
+ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [37.4 ms] → 630 «премиум»
1373
1391
  ```
1374
1392
 
1375
1393
  #### `.limit(n): Chain` / `.offset(n): Chain`
@@ -1385,10 +1403,10 @@ await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 3
1385
1403
  **Примеры**
1386
1404
 
1387
1405
  ```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 }] — вторая страница
1406
+ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms]
1407
+ // → [{ note: 'базовая', RUB: 8100 }, { note: 'базовая', RUB: 8000 }, { note: 'базовая', RUB: 7900 }]
1408
+ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows() // [19.5 ms]
1409
+ // → [{ RUB: 7800 }, { RUB: 7700 }, { RUB: 7600 }] — вторая страница
1392
1410
  ```
1393
1411
 
1394
1412
  **Кейс: классическая пагинация страницы каталога** — `limit(3)` + `offset(3·N)`; при выходе
@@ -1398,21 +1416,21 @@ await db.Услуга().sort('data.price.RUB', 'desc').limit(3).offset(3).rows()
1398
1416
 
1399
1417
  | Параметр | Тип | Описание |
1400
1418
  |---|---|---|
1401
- | `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.price.RUB`); SQL-каст по типу листа из Schema |
1419
+ | `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.coordinates.lat`); SQL-каст по типу листа из Schema |
1402
1420
  | `dir` | `'asc'` \| `'desc'` \| `boolean?` | default `asc`; `true` ≡ `'desc'` |
1403
1421
 
1404
1422
  **Назначение и алгоритм.** `ORDER BY` по колонке `updated` или по выражению
1405
- `data->'price'->>'RUB'` с кастом (numeric/text/timestamptz — из типа листа в Schema).
1423
+ `data->'coordinates'->>'lat'` с кастом (numeric/text/timestamptz — из типа листа в Schema).
1406
1424
  Обязателен для `.after()`.
1407
1425
 
1408
1426
  **Примеры**
1409
1427
 
1410
1428
  ```ts
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
1429
+ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms] → 8100, 8000, 7900
1430
+ await db.Услуга().sort('data.duration').limit(2).rows() // [7.5 ms] asc по умолчанию
1431
+ // → [{ name: 'Стрижка 0', duration: 30 }, { name: 'Педикюр 4', duration: 30 }]
1432
+ await db.запись().sort('updated', 'desc').limit(2).rows() // [6232.3 ms]
1433
+ // ЧЕСТНО: DISTINCT ON всех ~440k сущностей класса без фильтра — см. § 14
1416
1434
  ```
1417
1435
 
1418
1436
  **Кейс: топ прайса** — первый пример; правило объёма: сортировка **всего** большого класса
@@ -1432,15 +1450,15 @@ await db.Запись().sort('updated', 'desc').limit(2).rows() // [161
1432
1450
  **Примеры**
1433
1451
 
1434
1452
  ```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 } — цена сейчас
1453
+ const истор = await db.цена('…0009').versions() // [7.7 ms] (для t1 ниже)
1454
+ await db.цена('…0009').asOf(истор[0].updated).first() // [3.0 ms]
1455
+ // → data.amounts.RUB = 2550 — цена ТОГДА
1456
+ await db.цена('…0009').first()
1457
+ // → data.amounts.RUB = 2650 — цена сейчас
1440
1458
  ```
1441
1459
 
1442
1460
  **Кейс: спор по чеку** — «сколько стоила стрижка в момент оформления записи»:
1443
- `db.Услуга(id).asOf(запись.updated).first()` → исторический прайс без отдельных таблиц аудита.
1461
+ `db.цена(id).asOf(запись.updated).first()` → исторический прайс без отдельных таблиц аудита.
1444
1462
 
1445
1463
  #### `.versions(): Promise<Row[]>`
1446
1464
 
@@ -1454,14 +1472,12 @@ await db.Услуга('…0021').first()
1454
1472
  **Примеры**
1455
1473
 
1456
1474
  ```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' }] ← воскрешение
1475
+ await db.цена('…0009').versions() // [7.7 ms]
1476
+ // → [{ RUB: 2550, updated: '2025-08-23T…' }, { RUB: 2650, updated: '2025-11-21T…' }]
1477
+ await db.Клиент('…0931').versions() // жизнь с удалением и воскрешением:
1478
+ // → [{ name: 'Злата', updated: '…54.159646' },
1479
+ // { name: 'Злата', updated: '…55.627712', $deleted: true }, ← tombstone
1480
+ // { name: 'Злата', updated: '…55.678666' }] ← воскрешение (create по тому же id)
1465
1481
  ```
1466
1482
 
1467
1483
  **Кейс: аудит «кто когда менял»** — `versions()` + `owner` каждой версии = полный
@@ -1483,18 +1499,19 @@ await db.Запись('…0061').versions() // [6.7 ms] — жизнь с уд
1483
1499
  **Примеры**
1484
1500
 
1485
1501
  ```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]
1502
+ const p1 = await db.запись().sort('updated', 'desc').limit(3).rows() // [5632.0 ms] — ~440k
1503
+ const кур = cursorOf(p1.at(-1)) // [82 µs]
1504
+ // → { v: '2026-07-25T18:00:00+00:00', id: '00000009-0000-4000-8000-000000431198' }
1505
+ const p2 = await db.запись().sort('updated', 'desc').after(кур).limit(3).rows() // [5680.4 ms]
1490
1506
  // p2 — следующие 3, пересечение страниц: 0
1507
+ // (у тысяч записей последнего дня updated совпадает — id вторым ключом ORDER BY держит границу)
1491
1508
 
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
1509
+ const курЦены = cursorOf(топ3.at(-1), 'data.amounts.RUB') // [41 µs] → { v: 7900, id: '…0951' }
1510
+ await db.цена().sort('data.amounts.RUB', 'desc').after(курЦены).limit(3).rows() // [20.5 ms]
1511
+ // → 7800, 7700, 7600
1495
1512
 
1496
- await db.Запись().after(кур).rows()
1497
- // Error: letopis: .after(cursor) requires .sort(field) [0.1 ms]
1513
+ await db.запись().after(кур).rows()
1514
+ // Error: letopis: .after(cursor) requires .sort(field) [0.3 ms]
1498
1515
  ```
1499
1516
 
1500
1517
  **Кейс: бесконечная лента записей**
@@ -1502,7 +1519,7 @@ await db.Запись().after(кур).rows()
1502
1519
  ```ts
1503
1520
  let кур
1504
1521
  for (;;) {
1505
- let q = db.Запись().sort('updated', 'desc').limit(100)
1522
+ let q = db.запись().sort('updated', 'desc').limit(100)
1506
1523
  if (кур) q = q.after(кур)
1507
1524
  const стр = await q.rows()
1508
1525
  if (!стр.length) break
@@ -1525,19 +1542,22 @@ for (;;) {
1525
1542
  **Примеры**
1526
1543
 
1527
1544
  ```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 }]
1545
+ await db.Папка('…0000').Папка().deep().rows() // [9.2 ms]
1546
+ // → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 },
1547
+ // { name: 'Борода и усы', $depth: 2 }, { name: 'Уход', $depth: 3 }]
1548
+ await db.Папка('…0000').Папка().deep(1).rows() // [7.4 ms] только прямые дети
1549
+ // → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 }]
1532
1550
  ```
1533
1551
 
1534
1552
  **Кейс: хлебные крошки каталога** — дерево одним запросом, глубина из `$depth`:
1535
1553
 
1536
1554
  ```ts
1537
- const дерево = await db.Папка(корень).Папка().deep().rows() // [13.1 ms]
1555
+ const дерево = await db.Папка(корень).Папка().deep().rows() // [9.2 ms]
1538
1556
  дерево.map((p) => `${' '.repeat(p.$depth)}${p.data.name}`)
1539
1557
  // → Мужской зал
1558
+ // Женский зал
1540
1559
  // Борода и усы
1560
+ // Уход
1541
1561
  ```
1542
1562
 
1543
1563
  #### `.sum(field)` / `.avg(field): Promise<number | null>`
@@ -1553,14 +1573,14 @@ const дерево = await db.Папка(корень).Папка().deep().rows(
1553
1573
  **Примеры**
1554
1574
 
1555
1575
  ```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 (пусто)
1576
+ await db.цена().sum('data.amounts.RUB') // [13.2 ms] → 2277000
1577
+ await db.Услуга().avg('data.duration') // [3.0 ms] → 52.5
1578
+ await db.Локация({}).sum('data.coordinates.lat') // [3.9 ms] лист record-пути (глубина 2) → 3327.925547539955
1579
+ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [5.0 ms] → null (пусто)
1560
1580
  ```
1561
1581
 
1562
- **Кейс: выручка за период без выгрузки строк** — `sum` по 50 000 завершённых записей
1563
- за 237 ms; строки в приложение не едут.
1582
+ **Кейс: итог по каталогу без выгрузки строк** — `sum('data.amounts.RUB')` по 405 ценам за 4 ms;
1583
+ на полигоне salondemo — 1026 цен на 2 277 000 за ≈13 ms (§ 14). Строки в приложение не едут.
1564
1584
 
1565
1585
  #### `.min(field)` / `.max(field): Promise<unknown>`
1566
1586
 
@@ -1568,8 +1588,8 @@ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') /
1568
1588
  **числом**, string — строкой.
1569
1589
 
1570
1590
  ```ts
1571
- await db.Услуга().min('data.price.RUB') // [3.6 ms] → 800 (число, не '800')
1572
- await db.Услуга().max('data.price.RUB') // [4.2 ms] → 1800
1591
+ await db.цена().min('data.amounts.RUB') // [15.0 ms] → 300 (число, не '300')
1592
+ await db.цена().max('data.amounts.RUB') // [14.8 ms] → 8100
1573
1593
  ```
1574
1594
 
1575
1595
  **Кейс: границы ценового слайдера** — `min` + `max` двумя запросами по 3–4 ms.
@@ -1584,11 +1604,12 @@ await db.Услуга().max('data.price.RUB') // [4.2 ms] → 1800
1584
1604
  объекта = значения поля, значения = счётчики (по путям).
1585
1605
 
1586
1606
  ```ts
1587
- await db.Запись().countBy('data.status') // [575.8 ms] — 250k сущностей
1588
- // → { confirmed: 150000, cancelled: 50000, completed: 50000, created: 1 }
1607
+ await db.запись().countBy('data.notes') // [3266.3 ms] — ~440k сущностей
1608
+ // → { 'подтверждена': 400000, 'по телефону: Вера': 3334, 'по телефону: Ольга': 3334, …,
1609
+ // 'по телефону: Марина': 3333 } — 12 имён «по телефону» по ~3333 (ручные брони ~9%)
1589
1610
  ```
1590
1611
 
1591
- **Кейс: дашборд статусов** — один запрос вместо N `count()`; 250k строк агрегирует БД.
1612
+ **Кейс: дашборд по заметкам записей** — один запрос вместо N `count()`; ~440k строк агрегирует БД.
1592
1613
 
1593
1614
  #### `.alias(name): Chain`
1594
1615
 
@@ -1600,8 +1621,8 @@ await db.Запись().countBy('data.status') // [575.8 ms] — 250k сущн
1600
1621
  когда один класс встречается в пути дважды.
1601
1622
 
1602
1623
  ```ts
1603
- await db.Организация(org).alias('салон').Сотрудник({ name: 'Вася' }).alias('мастер').run()
1604
- // [6.4 ms] → ключи пути: ['салон', 'мастер']
1624
+ await db.Организация(org).alias('салон').Мастер({ name: 'Ольга 0.0' }).alias('мастер').run()
1625
+ // [6.7 ms] → ключи пути: ['салон', 'мастер']
1605
1626
  ```
1606
1627
 
1607
1628
  **Кейс: self-join читаемо** — `db.Папка(a).alias('родитель').Папка().alias('дочка').run()`.
@@ -1616,8 +1637,8 @@ await db.Организация(org).alias('салон').Сотрудник({ na
1616
1637
  кандидаты + перепроверка). В записи — модификатор значения.
1617
1638
 
1618
1639
  ```ts
1619
- await db.Клиент().tags('vip').count() // [3.9 ms] → 1 (среди 30k клиентов)
1620
- await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms] → 1
1640
+ await db.Клиент().tags('vip').count() // [24.7 ms] → 400 (vip-клиенты)
1641
+ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
1621
1642
  ```
1622
1643
 
1623
1644
  **Кейс: пометить и найти** — § 5 (вставка с `.tags(['vip','telegram'])`, поиск `tags('vip')`);
@@ -1634,8 +1655,8 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms]
1634
1655
  ошибка `acl pins` (§ 11.10).
1635
1656
 
1636
1657
  ```ts
1637
- await db.Организация().account(SYS).count() // [3.4 ms] → 21
1638
- await db.Организация().owner(SYS).count() // [3.4 ms] → 21
1658
+ await db.Организация().account(SYS).count() // [5.1 ms] → 30
1659
+ await db.Организация().owner(SYS).count() // [3.9 ms] → 30
1639
1660
  ```
1640
1661
 
1641
1662
  **Кейс: чей это салон** — профиль владельца строки: `db.accounts.get(row.owner)`; выборка
@@ -1644,17 +1665,17 @@ await db.Организация().owner(SYS).count() // [3.4 ms] → 21
1644
1665
  ### 11.4 Операторы фильтров
1645
1666
 
1646
1667
  18 функций-операторов: каждая возвращает объект-условие `Op` для значения поля в фильтре
1647
- шага (`{ duration: gte(60) }`), включая record-пути (`{ price: { RUB: gte(1300) } }` —
1668
+ шага (`{ duration: gte(60) }`), включая record-пути (`{ coordinates: { lat: gte(55.5) } }` —
1648
1669
  условие на листе любой глубины, SQL-каст по типу листа из Schema). Компилируются в
1649
1670
  выражение на актуальной строке (`(data->>'duration')::numeric >= 60`); containment-части
1650
- дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (402 сущности).
1671
+ дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (600 сущностей).
1651
1672
 
1652
1673
  #### `ne(v): Op`
1653
1674
 
1654
1675
  `v: string | number | boolean | null` — «не равно» (`IS DISTINCT FROM` — null-безопасно).
1655
1676
 
1656
1677
  ```ts
1657
- await db.Услуга({ name: ne('Стрижка') }).count() // [3.4 ms] → 401
1678
+ await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms] → 570
1658
1679
  ```
1659
1680
 
1660
1681
  **Кейс:** всё, кроме выбранного, — «другие услуги» под карточкой текущей.
@@ -1664,30 +1685,30 @@ await db.Услуга({ name: ne('Стрижка') }).count() // [3.4 ms] →
1664
1685
  `v: number | string` — строго больше / больше-или-равно (числа и сравнимые строки-даты).
1665
1686
 
1666
1687
  ```ts
1667
- await db.Услуга({ duration: gt(60) }).count() // [3.2 ms] → 100
1668
- await db.Услуга({ duration: gte(60) }).count() // [4.7 ms] → 201
1688
+ await db.Услуга({ duration: gt(60) }).count() // [6.4 ms] → 150
1689
+ await db.Услуга({ duration: gte(60) }).count() // [4.3 ms] → 300
1669
1690
  ```
1670
1691
 
1671
1692
  **Кейс:** граница включительно или нет — «от часа» это `gte(60)`; `gt(60)` потеряет
1672
- ровно-часовые (201 vs 100).
1693
+ ровно-часовые (300 vs 150).
1673
1694
 
1674
1695
  #### `lt(v): Op` / `lte(v): Op`
1675
1696
 
1676
1697
  `v: number | string` — строго меньше / меньше-или-равно.
1677
1698
 
1678
1699
  ```ts
1679
- await db.Услуга({ duration: lt(45) }).count() // [3.3 ms] → 101
1680
- await db.Услуга({ duration: lte(45) }).count() // [3.0 ms] → 201
1700
+ await db.Услуга({ duration: lt(45) }).count() // [5.1 ms] → 150
1701
+ await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300
1681
1702
  ```
1682
1703
 
1683
- **Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 201 услуга.
1704
+ **Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 300 услуг.
1684
1705
 
1685
1706
  #### `between(a, b): Op`
1686
1707
 
1687
1708
  `a, b: number | string` — диапазон включительно (`a ≤ x ≤ b`).
1688
1709
 
1689
1710
  ```ts
1690
- await db.Услуга({ duration: between(40, 65) }).count() // [3.6 ms] → 201
1711
+ await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 300
1691
1712
  ```
1692
1713
 
1693
1714
  **Кейс:** слайдер длительности «40–65 минут» одной функцией вместо пары gte+lte.
@@ -1697,7 +1718,7 @@ await db.Услуга({ duration: between(40, 65) }).count() // [3.6 ms] → 2
1697
1718
  `vs: (string | number)[]` — значение из списка (`IN`).
1698
1719
 
1699
1720
  ```ts
1700
- await db.Услуга({ name: inList(['Стрижка', 'Услуга 7']) }).count() // [2.8 ms] → 2
1721
+ await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [4.4 ms] → 60
1701
1722
  ```
1702
1723
 
1703
1724
  **Кейс:** сравнение выбранных чекбоксами услуг: имена из UI → один запрос.
@@ -1707,72 +1728,73 @@ await db.Услуга({ name: inList(['Стрижка', 'Услуга 7']) }).co
1707
1728
  `s: string` — SQL-шаблон (`%` — любое, `_` — один символ); `ilike` — без учёта регистра.
1708
1729
 
1709
1730
  ```ts
1710
- await db.Услуга({ name: like('Стри%') }).count() // [3.2 ms] → 1
1711
- await db.Услуга({ name: ilike('%королевское%') }).count() // [3.0 ms] → 1
1731
+ await db.Услуга({ name: like('Стри%') }).count() // [4.3 ms] → 60
1732
+ await db.Услуга({ name: ilike('%массаж%') }).count() // [4.3 ms] → 60
1712
1733
  ```
1713
1734
 
1714
1735
  **Кейс:** живой поиск в админке — `ilike('%' + ввод + '%')` прощает регистр
1715
- («королевское» находит «Королевское бритьё»).
1736
+ («массаж» находит «Массаж 7», «Массаж 17»).
1716
1737
 
1717
1738
  #### `starts(s): Op` / `ends(s): Op`
1718
1739
 
1719
1740
  `s: string` — начинается с / заканчивается на (сахар над `like(s+'%')` / `like('%'+s)`).
1720
1741
 
1721
1742
  ```ts
1722
- await db.Услуга({ name: starts('Услуга 39') }).count() // [3.2 ms] → 11
1723
- await db.Услуга({ name: ends('бритьё') }).count() // [3.2 ms] → 1
1743
+ await db.Услуга({ name: starts('Массаж') }).count() // [3.7 ms] → 60
1744
+ await db.Услуга({ name: ends('7') }).count() // [2.9 ms] → 60
1724
1745
  ```
1725
1746
 
1726
- **Кейс:** префиксная навигация по артикулам: `starts('Услуга 39')` → 39, 390…399.
1747
+ **Кейс:** префиксная навигация по названию: `starts('Массаж')` → все «Массаж N» (60 в каталоге).
1727
1748
 
1728
1749
  #### `has(v): Op` / `hasAny(vs): Op` / `hasAll(vs): Op`
1729
1750
 
1730
- `v: скаляр`, `vs: скаляр[]` — массив-поле содержит значение / хотя бы одно / все
1731
- (containment `@>` — идёт и в GIN-кандидаты).
1751
+ `v: скаляр`, `vs: скаляр[]` — массив содержит значение / хотя бы одно / все (containment `@>` —
1752
+ идёт и в GIN-кандидаты). В демо-схеме массивов в `data` нет — операторы показаны на колонке `tags`.
1732
1753
 
1733
1754
  ```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
1755
+ await db.Клиент().tags(has('vip')).count() // [54.5 ms] → 400
1756
+ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
1757
+ await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [11.0 ms] → 100
1737
1758
  ```
1738
1759
 
1739
- **Кейс:** права из массива ролей: «может закрывать смену» = `hasAny(['owner','admin'])`;
1740
- «владелец, который сам стрижёт» = `hasAll(['owner','master'])`.
1760
+ **Кейс:** сегменты по меткам: «vip ИЛИ из телеграма» = `tags(hasAny(['vip','telegram']))` → 800;
1761
+ «vip И телеграм разом» = `tags(hasAll(['vip','telegram']))` → 100.
1741
1762
 
1742
1763
  #### `exists(yes = true): Op`
1743
1764
 
1744
1765
  `yes: boolean?` — поле присутствует (`true`, default) / отсутствует (`false`) в `data`.
1745
1766
 
1746
1767
  ```ts
1747
- await db.Услуга({ description: exists() }).count() // [3.4 ms] → 1
1748
- await db.Услуга({ description: exists(false) }).count() // [2.8 ms] → 401
1768
+ await db.Услуга({ description: exists() }).count() // [3.7 ms] → 390
1769
+ await db.Услуга({ description: exists(false) }).count() // [3.2 ms] → 210
1749
1770
  ```
1750
1771
 
1751
- **Кейс:** контроль заполненности каталога — «услуги без описания» = `exists(false)` → 401
1752
- на доработку контенту.
1772
+ **Кейс:** контроль заполненности каталога — «услуги без ключа `description`» = `exists(false)` → 210
1773
+ на доработку контенту (не путать с `isNull()` ниже — тот ещё и явные `null` ловит).
1753
1774
 
1754
1775
  #### `isNull(): Op`
1755
1776
 
1756
1777
  Без параметров — поле `NULL` **или** отсутствует.
1757
1778
 
1758
1779
  ```ts
1759
- await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 401
1780
+ await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
1760
1781
  ```
1761
1782
 
1762
- **Кейс:** отличие от `exists(false)`: `isNull()` ловит и явный `null` в data, и отсутствие
1763
- ключа; `exists(false)` — только отсутствие.
1783
+ **Кейс:** отличие от `exists(false)` — на полигоне видно числом: `isNull()` → 390 (ловит и явный
1784
+ `null` в data, и отсутствие ключа), `exists(false)` → 210 (только отсутствие); расхождение 180 —
1785
+ это услуги с явным `description: null`.
1764
1786
 
1765
1787
  #### `not(v): Op`
1766
1788
 
1767
1789
  `v: скаляр | Op` — отрицание; скаляр ≡ «не равно» (как `ne`).
1768
1790
 
1769
1791
  ```ts
1770
- await db.Услуга({ name: not(starts('Услуга')) }).count() // [3.4 ms] → 2
1771
- await db.Услуга({ name: not('Стрижка') }).count() // [6.1 ms] → 401
1792
+ await db.Услуга({ name: not(starts('Стрижка')) }).count() // [3.7 ms] → 540
1793
+ await db.Услуга({ name: not('Стрижка 0') }).count() // [3.5 ms] → 570
1772
1794
  ```
1773
1795
 
1774
- **Кейс:** инверсия готового условия без переписывания: «всё, что НЕ сид-генерация» =
1775
- `not(starts('Услуга'))` → Стрижка и Королевское бритьё.
1796
+ **Кейс:** инверсия готового условия без переписывания: «всё, что НЕ стрижки» =
1797
+ `not(starts('Стрижка'))` → 540 (из 600 услуг 60 — «Стрижка N»).
1776
1798
 
1777
1799
  #### `or(...filters): Op`
1778
1800
 
@@ -1780,10 +1802,11 @@ await db.Услуга({ name: not('Стрижка') }).count() // [6.1
1780
1802
  обычное И).
1781
1803
 
1782
1804
  ```ts
1783
- await db.Услуга(or({ name: 'Стрижка' }, { duration: lt(45) })).count() // [4.0 ms] → 102
1805
+ await db.Услуга(or({ name: 'Стрижка 0' }, { duration: lt(45) })).count() // [4.0 ms] → 150
1784
1806
  ```
1785
1807
 
1786
- **Кейс:** «стрижка или что-нибудь быстрое» — один запрос: 1 (Стрижка) + 101 (быстрые) = 102.
1808
+ **Кейс:** «Стрижка 0 или что-нибудь быстрое» — один запрос: Стрижка 0 (duration 30) уже среди
1809
+ быстрых `lt(45)` → объединение = 150, отдельного плюса не даёт.
1787
1810
 
1788
1811
  ### 11.5 Запись: create / update / delete / anonymize
1789
1812
 
@@ -1814,28 +1837,28 @@ INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конф
1814
1837
  **Примеры**
1815
1838
 
1816
1839
  ```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' }, … }]
1840
+ await db.Организация().create({ name: 'Пилигрим' }).rows() // [11.1 ms] INSERT + defaults из Schema
1841
+ // → [{ id: '019f5a53-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, links: {}, … }]
1819
1842
 
1820
1843
  await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
1821
- // [7.0 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
1844
+ // [11.4 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
1822
1845
 
1823
- await db.Организация('…0901').Сотрудник().create({ id: '…0911', name: 'Мия', roles: ['master'] }).rows()
1824
- // [14.5 ms] контекст → links: { Org: '…0901' }
1846
+ await db.Организация('…0901').Мастер().create({ id: '…0911', name: 'Мия', phone: '+7 909 000-09-11', specialization: 'массажист' }).rows()
1847
+ // [15.2 ms] контекст → links: { Org: '…0901' }
1825
1848
 
1826
- await db.Организация('…0901').Услуга().create({ name: 'Укладка экспресс', duration: 15, price: { RUB: 700 } }).rows()
1827
- // [7.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
1828
- // повторный create той же пары (салон, имя) → новая ВЕРСИЯ той же услуги, не дубль
1849
+ await db.Организация('…0901').Услуга().create({ name: 'Массаж головы', duration: 30, description: 'релакс' }).rows()
1850
+ // [16.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
1851
+ // повторный create той же пары (салон, имя) → новая ВЕРСИЯ (deep-merge листьев), не дубль
1829
1852
 
1830
- db.Услуга({ name: 'Укладка экспресс' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
1853
+ db.Услуга({ name: 'Массаж головы' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
1831
1854
  // Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
1832
1855
 
1833
- await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() // [1.4 ms] — ошибка НА ТЕРМИНАЛЕ:
1856
+ await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [9.9 ms] — ошибка НА ТЕРМИНАЛЕ:
1834
1857
  // ValidationError: letopis: validation failed for "Service":
1835
- // duration — The 'duration' field is required.; … forbidden keys: 'чепуха'
1858
+ // The object '' contains forbidden keys: 'чепуха'.
1836
1859
 
1837
- db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
1838
- // Error: letopis: no path Service → Customer
1860
+ db.Локация('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
1861
+ // Error: letopis: no path Location → Customer: neither embeds the other (Schema)
1839
1862
  ```
1840
1863
 
1841
1864
  #### `.update(data?): Chain`
@@ -1846,7 +1869,7 @@ db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый
1846
1869
 
1847
1870
  **Назначение и алгоритм.** Новая версия **каждого** найденного путём. Цели ищутся как при
1848
1871
  чтении — id, фильтр, pivot; `Класс()` ≡ `Класс({})` — «все в границах контекста». На каждую
1849
- цель: **deep-merge** листьев `data` (`update({ price: { RUB: 1100 } })` сохранит `USD` и
1872
+ цель: **deep-merge** листьев `data` (`update({ coordinates: { lat: 55.8 } })` сохранит `lng` и
1850
1873
  остальные поля; массивы/скаляры — целиком), слияние links (слоты `.Класс.set()`/`.unset()`),
1851
1874
  строгая валидация, один INSERT новой версии. Не найдено → `[]` — update **НИКОГДА не
1852
1875
  создаёт**. Сегмент после операции исполняется **для каждой строки её результата** (fan-out);
@@ -1855,43 +1878,41 @@ db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый
1855
1878
  **Примеры**
1856
1879
 
1857
1880
  ```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 целы
1881
+ await db.Клиент('…0931').запись({ start_datetime: between(t, t) }).update({ notes: 'подтверждена' }).rows() // [645.4 ms]
1882
+ // → [{ id: '4907d8cd-…', notes: 'подтверждена' }]
1883
+ // момент фильтруется between(t, t): скаляр-eq по date-полю = строковый containment, потому диапазон
1884
+ // (сотни мс: поиск целей идёт по всему классу записей ~440k без btree по data->>'start_datetime' — § 14)
1862
1885
 
1863
- await db.Клиент('…0931').Запись({ status: 'created' }).update({ status: 'confirmed' }).rows() // [12.1 ms]
1864
- // → [{ id: '…0962', status: 'confirmed' }, { id: '…0963', status: 'confirmed' }] — только записи Златы
1886
+ await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [600.4 ms] — ВСЕ записи в контексте
1887
+ // → [{ id: '4907d8cd-…', notes: 'день закрыт' }, { id: 'e7f16a08-…', notes: 'день закрыт' }]
1865
1888
 
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() // → [] — НИЧЕГО не создано
1889
+ await db.запись({ notes: 'нет-такого' }).update({ notes: 'x' }).rows() // → [] — update НИКОГДА не создаёт
1870
1890
  ```
1871
1891
 
1872
1892
  **Кейс: план из нескольких операций — реальный прогон**
1873
1893
 
1874
1894
  ```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' }]
1895
+ // обновить клиента → вставить ему запись со слотами (продолжение от записанного, одна транзакция)
1896
+ await db.Клиент('…0931').update({ preferred_contact: 'messenger' })
1897
+ .запись().create({ start_datetime: '2026-08-04T07:00:00Z', end_datetime: '2026-08-04T07:30:00Z' })
1898
+ .Мастер.set(мия).Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows() // [20.0 ms]
1899
+ // → [{ id: '…d091', class: 'booking', links: { Staff, Service, Customer: '…0931', Location, Schedule },
1900
+ // data: { start_datetime: '2026-08-04T07:00:00.000Z', end_datetime: '2026-08-04T07:30:00.000Z' } }]
1880
1901
 
1881
1902
  // self-update: две версии подряд
1882
- await db.Запись('…0964').update({ status: 'confirmed' }).update({ status: 'completed' }).rows() // [13.6 ms]
1883
- // → ['completed']; versions: ['created', 'confirmed', 'completed']
1903
+ await db.запись('…d091').update({ notes: 'подтверждена' }).update({ notes: 'выполнена' }).rows() // [19.8 ms]
1904
+ // → ['выполнена']; versions: [null, 'подтверждена', 'выполнена']
1884
1905
 
1885
1906
  // хвост-чтение после операции — в той же транзакции
1886
- await db.Клиент('…0931').update({ language: 'ru' }).Запись().count() // [201.6 ms] → 7
1907
+ await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [16.7 ms] → 1
1887
1908
 
1888
- // ОТКАТ: невалидный второй сегмент откатывает и первый
1889
- await db.Клиент('…0931').update({ name: 'Не запишется' }).Запись().create({ чепуха: 1 }).rows()
1890
- // Error: letopis: validation failed for "Booking" … [6.1 ms]; имя клиента не изменилось
1909
+ // ОТКАТ: валидный update + невалидный create — весь план назад
1910
+ await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().create({ чепуха: 1 }).rows()
1911
+ // Error: letopis: validation failed for "booking" … forbidden keys: 'чепуха' [15.5 ms]; клиент не изменился
1891
1912
 
1892
1913
  // fan-out: обновить клиента → снести ВСЕ его записи
1893
- await db.Клиент('…0931').update({ active: true }).Запись().delete({ confirm: true }).rows()
1894
- // [229.4 ms] → 7 записей, все с $deleted: true
1914
+ await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().delete({ confirm: true }).rows()
1915
+ // [29.0 ms] → снесено 1 запись, с $deleted: true
1895
1916
  ```
1896
1917
 
1897
1918
  #### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
@@ -1911,19 +1932,27 @@ links **БЕЗ участия в фильтре целей** (в отличие
1911
1932
  **Примеры**
1912
1933
 
1913
1934
  ```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) — второй раз тот же навык не завести
1935
+ await db.Мастер(мия).навык().create({ level: 'expert' }).Услуга.set(усл).rows() // [16.9 ms] ОДИН INSERT
1936
+ // → { id: '5e260ee8-…', class: 'skill', data: { level: 'expert' }, links: { Staff: '…0911', Service: '…' } }
1937
+ // навык — v5: id вычислен из (Staff, Service) — второй раз тот же навык не завести
1938
+
1939
+ // союз-конец [Услуга|Товар|Комплекс]: слот замещает целиком (соседний класс снят)
1940
+ await db.запись('…d091').update().Товар.set(воск).rows() // [10.3 ms] Service снят, Product встал
1917
1941
 
1918
- // вложенная цепочка — создать И привязать в одной транзакции:
1919
- await db.Запись(b).позиция().create({ qty: 1 }).Услуга.set(db.Услуга(у).update({ active: true })).rows()
1942
+ // снять optional-конец: ручная бронь без клиента, имя в notes
1943
+ await db.запись('…d091').update({ notes: 'бронь по телефону: Злата' }).Клиент.unset().rows() // [14.3 ms]
1944
+
1945
+ // required-конец снять нельзя:
1946
+ await db.запись('…d091').update().Локация.unset() // [0.3 ms]
1947
+ // Error: letopis: link end "Location" of "booking" is required — cannot unset
1920
1948
  ```
1921
1949
 
1922
- **Кейс: перевесить исполнителя на всех позициях записи**
1950
+ **Кейс: заменить предмет на всех записях клиента**
1923
1951
 
1924
1952
  ```ts
1925
- await tr.Запись(b).позиция().update().Сотрудник.set(новый).rows()
1926
- // цель ищется путём (все позиции записи); слот пишет нового исполнителя БЕЗ фильтра по старому
1953
+ await tr.Клиент(к).запись().update().Комплекс.set(комплекс).rows()
1954
+ // цель ищется путём (все записи клиента); слот пишет новый предмет — союз [Услуга|Товар|Комплекс]
1955
+ // замещается целиком, БЕЗ фильтра по старому концу
1927
1956
  ```
1928
1957
 
1929
1958
  #### `.delete(opts?): Chain`
@@ -1944,24 +1973,24 @@ tombstone-версия → рекурсивное удаление зависи
1944
1973
  **Примеры**
1945
1974
 
1946
1975
  ```ts
1947
- await db.Запись('…0961').delete().rows() // [192.7 ms] ПРЕВЬЮ — кандидаты живы:
1948
- // → [{ id: '…0961', class: 'Booking' }, { class: 'busy', … }, { class: 'item', … }] — бронь + занятость + позиция
1949
- // запись жива: true
1976
+ await db.Клиент('…0931').delete().rows() // [14.7 ms] ПРЕВЬЮ — кандидаты живы:
1977
+ // → [{ id: '…0931', class: 'Customer' }, { class: 'booking' }, { class: 'booking' }] — клиент + его записи (каскад)
1978
+ // после превью клиент жив: true
1950
1979
 
1951
- await db.Запись('…0961').delete({ confirm: true }).rows() // [209.2 ms] — сервер нашёл зависимых:
1980
+ await db.Клиент('…0931').delete({ confirm: true }).rows() // [35.8 ms] — сервер нашёл зависимых:
1952
1981
  // → те же три, каждый с $deleted: true
1953
- await db.Запись('…0961').delete({ confirm: true }).rows() // [5.1 ms] повторно → []
1982
+ await db.Клиент('…0931').delete({ confirm: true }).rows() // [6.8 ms] повторно → []
1954
1983
  ```
1955
1984
 
1956
- **Кейс: отмена брони с показом последствий**
1985
+ **Кейс: отмена и воскрешение**
1957
1986
 
1958
1987
  ```ts
1959
- const последствия = await db.Запись(bid).delete().rows() // [192.7 ms] показать оператору
1988
+ const последствия = await db.Клиент(cid).delete().rows() // [14.7 ms] оператору: клиент + N его записей
1960
1989
  if (операторПодтвердил) {
1961
- await db.Запись(bid).delete({ confirm: true }).rows() // [209.2 ms] бронь + занятость + позиция
1990
+ await db.Клиент(cid).delete({ confirm: true }).rows() // [35.8 ms] клиент и записи — tombstone (каскад)
1962
1991
  }
1963
- // клиент передумал: воскрешение тем же id — create (зависимые пересоздать явно)
1964
- await db.Клиент('…0931').Запись('…0961').create({ status: 'created', total: { RUB: 500 } }).rows() // [11.3 ms]
1992
+ // клиент вернулся: воскрешение тем же id — create по (Org, id)
1993
+ await db.Организация('…0901').Клиент().create({ id: cid, name: 'Злата', phone: '+7 …' }).rows() // [16.3 ms]
1965
1994
  ```
1966
1995
 
1967
1996
  #### `.anonymize(fields): Chain`
@@ -1978,17 +2007,17 @@ await db.Клиент('…0931').Запись('…0961').create({ status: 'creat
1978
2007
  **Примеры**
1979
2008
 
1980
2009
  ```ts
1981
- await db.Клиент('…0931').anonymize(['name', 'contact']).rows() // [7.8 ms]
1982
- // → [{ data: { name: '[erased]', contact: '[erased]', active: true, language: 'ru' },
2010
+ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
2011
+ // → [{ data: { name: '[erased]', phone: '[erased]', preferred_contact: 'phone' },
1983
2012
  // tags: ['anonymized'] }]
1984
2013
  ```
1985
2014
 
1986
2015
  **Кейс: запрос на забвение**
1987
2016
 
1988
2017
  ```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() // все стёртые — под контролем
2018
+ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
2019
+ ;(await db.Клиент('…0931').versions()).map((r) => r.data.name) // → ['Злата', 'Злата', 'Злата', '[erased]']
2020
+ await db.Клиент().tags('anonymized').count() // все стёртые — под контролем
1992
2021
  ```
1993
2022
 
1994
2023
  ### 11.6 Транзакции: EntityTx
@@ -2007,11 +2036,11 @@ await db.Клиент().tags('anonymized').count() // в
2007
2036
  **Примеры**
2008
2037
 
2009
2038
  ```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
2039
+ const tr = await db.begin() // [1.1 ms]
2040
+ await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows() // цУкл — базовая цена «Укладки»
2041
+ await tr.цена(цУкл).first() // внутри → amounts.RUB = 9900
2013
2042
  await tr.rollback() // [0.8 ms]
2014
- await db.Услуга({ name: 'Укладка' }).first() // снаружи → 700, изменения нет
2043
+ await db.цена(цУкл).first() // снаружи → amounts.RUB = 700, изменения нет
2015
2044
  ```
2016
2045
 
2017
2046
  **Кейс: commit** — § 11.2 `db.begin()`; ниже — главный сценарий `lock`.
@@ -2032,30 +2061,32 @@ await db.Услуга({ name: 'Укладка' }).first() // снаружи
2032
2061
  **Примеры**
2033
2062
 
2034
2063
  ```ts
2035
- await trA.lock('busy', staffId, slotId) // [1.4 ms]
2064
+ await trA.lock('booking', staffId, start) // [3.1 ms]
2036
2065
  await db.lock('x')
2037
- // Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.1 ms]
2066
+ // Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.2 ms]
2038
2067
  ```
2039
2068
 
2040
2069
  **Кейс: гонка двойной брони — реальный прогон двух транзакций**
2041
2070
 
2042
2071
  ```ts
2043
- // два администратора жмут «забронировать» на одно окно одновременно;
2044
- // id занятости детерминирован (v5, § 3.2) — известен ДО создания:
2045
- const busyId = uuidv5(`v1.article:entity:busy:${окно.id}:${мастер.id}`)
2072
+ // два администратора жмут «забронировать» на одно время одновременно;
2073
+ // id записи детерминирован (v5, § 3.2) — известен ДО создания:
2074
+ const bId = uuidv5(`v1.salondemo:entity:booking:${мастер.id}:${start}`)
2046
2075
  const trA = await db.begin(), trB = await db.begin()
2047
- await trA.lock('busy', мастер.id, окно.id) // [1.4 ms] A первый
2076
+ await trA.lock('booking', мастер.id, start) // [3.1 ms] A первый
2048
2077
  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()
2078
+ await trB.lock('booking', мастер.id, start) // B ВИСИТ до конца trA
2079
+ const занято = await trB.запись(bId).first() // перечитка под локом
2080
+ if (занято) { await trB.rollback(); return 'ОТКАЗ: время уже занято' }
2081
+ await trB.Мастер(мастер).запись().create({ start_datetime: start, end_datetime: end })
2082
+ .Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows()
2053
2083
  await trB.commit(); return 'бронь моя'
2054
2084
  })()
2055
- await trA.занятость(busyId).first() // → null — свободно
2056
- await trA.Сотрудник(мастер).занятость().create({ kind: 'booking' }).Окно.set(окно).rows()
2085
+ await trA.запись(bId).first() // → null — свободно
2086
+ await trA.Мастер(мастер).запись().create({ start_datetime: start, end_datetime: end })
2087
+ .Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows()
2057
2088
  await trA.commit()
2058
- await гонкаB // → 'ОТКАЗ: окно уже занято' — B увидел бронь A, дубля нет
2089
+ await гонкаB // → 'ОТКАЗ: время уже занято' — B увидел бронь A, дубля нет
2059
2090
  // дубль невозможен и без лока (оба create вычислят ОДИН id — второй стал бы версией);
2060
2091
  // лок нужен, чтобы B получил честный отказ, а не молча версионировал чужую бронь
2061
2092
 
@@ -2076,7 +2107,7 @@ await гонкаB // → 'ОТКАЗ: окно уже занято' — B ув
2076
2107
  цепочке — ошибка `plan is queued in the batch`. Исполняет `run()`.
2077
2108
 
2078
2109
  ```ts
2079
- const b = db.batch('слоты-августа') // [16 µs]
2110
+ const b = db.batch('смены-августа') // [28 µs]
2080
2111
  ```
2081
2112
 
2082
2113
  #### `batch.run(): Promise<Row[][]>`
@@ -2093,14 +2124,14 @@ const b = db.batch('слоты-августа') // [16 µs]
2093
2124
  **Примеры**
2094
2125
 
2095
2126
  ```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
2127
+ const b = db.batch('смены-августа')
2128
+ b.Мастер(m).окно().create({ start_datetime: '2026-08-05T07:00:00Z', end_datetime: '…09:00Z' }).Локация.set(loc).Расписание.set(sch)
2129
+ b.Мастер(m).окно().create({ start_datetime: '2026-08-05T09:00:00Z', end_datetime: '…11:00Z' }).Локация.set(loc).Расписание.set(sch)
2130
+ b.Мастер(m).окно().create({ start_datetime: '2026-08-05T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
2131
+ b.size() // [35 µs] → 3
2132
+ await b.run() // [43.8 ms] — одна транзакция; окно — v5-класс → 3 честных INSERT, не склейка
2133
+ // → [[{ id: '06f67c88-…', start_datetime: '2026-08-05T07:00:00.000Z' }], [{ …09:00 }], [{ …11:00 }]]
2134
+ // id каждого окна вычислен схемой: uuidv5(Staff, start_datetime) — § 3.2
2104
2135
  b.size() // → 0
2105
2136
  ```
2106
2137
 
@@ -2115,10 +2146,10 @@ b.size() // → 0
2115
2146
 
2116
2147
  ```ts
2117
2148
  const b2 = db.batch('отмена')
2118
- b2.Расписание(sch).Окно().create({ start: '2026-08-02T11:00:00Z', end: '…12:00Z' })
2119
- b2.discard() // [53 µs]
2149
+ b2.Мастер(m).окно().create({ start_datetime: '2026-08-06T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
2150
+ b2.discard() // [87 µs]
2120
2151
  b2.size() // → 0
2121
- await db.Расписание(sch).Окно({ start: '2026-08-02T11:00:00Z' }).first() // → null — не исполнилось
2152
+ await db.Мастер(m).окно({ start_datetime: between('2026-08-06T11:00:00Z', '2026-08-06T11:00:00Z') }).first() // → null — не исполнилось
2122
2153
  ```
2123
2154
 
2124
2155
  **Кейс: черновик импорта** — копим операции по мере парсинга файла; ошибка парсера →
@@ -2141,7 +2172,7 @@ await db.Расписание(sch).Окно({ start: '2026-08-02T11:00:00Z' }).f
2141
2172
  `category` → `= ANY(categories)`.
2142
2173
 
2143
2174
  ```ts
2144
- await db.accounts.find({ category: 'Client', enabled: true }) // [2.2 ms] → 3 аккаунта
2175
+ await db.accounts.find({ category: 'Client', enabled: true }) // [2.4 ms] → 1 аккаунт
2145
2176
  ```
2146
2177
 
2147
2178
  **Кейс:** список арендаторов для биллинга: `find({ enabled: true })`, отключённые не в счёте.
@@ -2151,7 +2182,7 @@ await db.accounts.find({ category: 'Client', enabled: true }) // [2.2 ms] →
2151
2182
  `id: string` — точечный SELECT по PK.
2152
2183
 
2153
2184
  ```ts
2154
- await db.accounts.get(acc.id) // [1.7 ms] → Account | null
2185
+ await db.accounts.get(acc.id) // [1.9 ms] → Account | null
2155
2186
  ```
2156
2187
 
2157
2188
  **Кейс:** профиль владельца строки Entity: `db.accounts.get(row.owner)`.
@@ -2171,9 +2202,9 @@ await db.accounts.get(acc.id) // [1.7 ms] → Account | null
2171
2202
 
2172
2203
  ```ts
2173
2204
  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
2205
+ // [5.4 ms] → { id: '06d3bbfe-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
2206
+ // meta: {}, avatar: 'https://i.pravatar.cc/128?img=33', enabled: true, created: …, updated: … }
2207
+ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [5.7 ms] update
2177
2208
  ```
2178
2209
 
2179
2210
  **Кейс:** бан аккаунта одним полем: `set({ id, enabled: false })` — все `verify*` § 11.9
@@ -2185,8 +2216,8 @@ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) //
2185
2216
  в Entity (`account`/`owner` FK RESTRICT) не удалить — намеренно: история неприкосновенна.
2186
2217
 
2187
2218
  ```ts
2188
- await db.accounts.delete(времId) // [9.9 ms] → true (пустой аккаунт)
2189
- await db.accounts.delete(SYS) // [4.9 ms]
2219
+ await db.accounts.delete(времId) // [16.4 ms] → true (пустой аккаунт)
2220
+ await db.accounts.delete(SYS) // [6.0 ms]
2190
2221
  // Error: update or delete on table "Account" violates foreign key constraint "entity_account_fk"
2191
2222
  ```
2192
2223
 
@@ -2202,7 +2233,7 @@ await db.accounts.delete(SYS) // [4.9 ms]
2202
2233
  | `f.withDeleted` | `boolean?` | включить мягко-удалённые (default — только живые `deleted IS NULL`) |
2203
2234
 
2204
2235
  ```ts
2205
- await db.credentials.find({ account: acc.id }) // [2.5 ms] → 1 живой
2236
+ await db.credentials.find({ account: acc.id }) // [3.2 ms] → 1 живой
2206
2237
  await db.credentials.find({ account: acc.id, withDeleted: true }) // → 1 (после delete: 0 и 1)
2207
2238
  ```
2208
2239
 
@@ -2224,9 +2255,9 @@ telegram списком.
2224
2255
 
2225
2256
  ```ts
2226
2257
  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, … }
2258
+ // [5.3 ms] → { id: '5f49dbfa-…', confirmed: true, deleted: null, … }
2228
2259
  await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
2229
- // [3.0 ms] после delete → тот же id, deleted = null — воскрешение
2260
+ // [7.7 ms] после delete → тот же id, deleted = null — воскрешение
2230
2261
  ```
2231
2262
 
2232
2263
  **Кейс:** смена номера телефона: `delete(старый)` + `set(новый)`; передумали — повторный
@@ -2238,7 +2269,7 @@ await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 9
2238
2269
  освобождается для других аккаунтов (§ 11.9).
2239
2270
 
2240
2271
  ```ts
2241
- await db.credentials.delete(кред.id) // [3.7 ms] → true; find() больше не видит
2272
+ await db.credentials.delete(кред.id) // [4.6 ms] → true; find() больше не видит
2242
2273
  ```
2243
2274
 
2244
2275
  **Кейс:** отзыв api-ключа: `delete(credential.id)` → `verifyApiKey` мгновенно null
@@ -2259,10 +2290,10 @@ await db.credentials.delete(кред.id) // [3.7 ms] → true; find() боль
2259
2290
 
2260
2291
  ```ts
2261
2292
  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
2293
+ // [4.8 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
2294
+ await db.resources.get('apiref.demo:API') // [2.6 ms] → тот же Resource
2295
+ await db.resources.find({ category: 'API' }) // [1.1 ms] → 14 ресурсов
2296
+ await db.resources.delete('apiref.demo:API') // [5.2 ms] → true
2266
2297
  ```
2267
2298
 
2268
2299
  **Кейс:** полный словарь для нового тарифа — § 11.10 (6 ресурсов + 5 правил одним блоком).
@@ -2281,9 +2312,9 @@ await db.resources.delete('apiref.demo:API') // [3.0 ms] → true
2281
2312
 
2282
2313
  ```ts
2283
2314
  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
2315
+ // [5.3 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
2316
+ await db.rules.find({ resource: 'apiref.demo:API' }) // [2.7 ms] → 1
2317
+ await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [4.3 ms] → true
2287
2318
  ```
2288
2319
 
2289
2320
  **Кейс:** временный бан группы: `set({ account: группа, resource: цель, permission: 'deny',
@@ -2315,8 +2346,8 @@ uuid-строку или объект с `.id`.
2315
2346
 
2316
2347
  ```ts
2317
2348
  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…"
2349
+ // [87.5 ms] → Credential; в БД вместо пароля:
2350
+ // meta.password = "scrypt$32768$8$1$+yEMcUTJIO7xEu/oDUAMXA==$b6…"
2320
2351
  ```
2321
2352
 
2322
2353
  **Кейс** — регистрация + вход + сессия: см. `sessions()` ниже (полный флоу).
@@ -2338,11 +2369,11 @@ await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', passw
2338
2369
  **Примеры**
2339
2370
 
2340
2371
  ```ts
2341
- await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [65.9 ms]
2342
- // → { account: { id: 'c66766f7-…', categories: ['Client'], enabled: true, … },
2372
+ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [78.8 ms]
2373
+ // → { account: { id: '06d3bbfe-…', categories: ['Client'], enabled: true, … },
2343
2374
  // 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
2375
+ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [71.1 ms] → null
2376
+ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [64.4 ms] → null
2346
2377
  // незнакомый identifier — то же время (dummy-verify)
2347
2378
  ```
2348
2379
 
@@ -2352,11 +2383,11 @@ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' })
2352
2383
  await db.auth.setPassword({ account: acc, identifier: 'noconfirm@salon.io', password: 'пароль-77',
2353
2384
  category: 'EMAIL', confirmed: false })
2354
2385
  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 }
2386
+ // [71.1 ms] → null — кред не подтверждён
2387
+ await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [72.2 ms] → { account, credential }
2357
2388
  await db.accounts.set({ id: acc.id, enabled: false })
2358
2389
  await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
2359
- // [81.9 ms] → null — аккаунт выключен, пароль уже не важен
2390
+ // [59.5 ms] → null — аккаунт выключен, пароль уже не важен
2360
2391
  ```
2361
2392
 
2362
2393
  #### `db.auth.issueApiKey(a): Promise<{ key, credential }>`
@@ -2371,9 +2402,9 @@ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'ле
2371
2402
  Сам ключ возвращается **один раз**; утечка БД ключи не раскрывает.
2372
2403
 
2373
2404
  ```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' }
2405
+ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
2406
+ // key = 'lts_ca41bf2a3614c3f228be2551a2ba998db5285f1a2bc3b859' ← показать и забыть
2407
+ // в БД: identifier = '624b00355aba026f…' (sha256), meta = { name: 'касса-1', prefix: 'lts_ca41bf2a' }
2377
2408
  ```
2378
2409
 
2379
2410
  **Кейс** — см. `verifyApiKey` (выпуск → проверка → отзыв).
@@ -2389,16 +2420,16 @@ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'к
2389
2420
  Быстрый (без scrypt): ключ высокоэнтропийный, подбор бессмыслен.
2390
2421
 
2391
2422
  ```ts
2392
- await db.auth.verifyApiKey(key) // [3.5 ms] → { account: c66766f7…, credential }
2423
+ await db.auth.verifyApiKey(key) // [4.5 ms] → { account: 06d3bbfe…, credential }
2393
2424
  ```
2394
2425
 
2395
2426
  **Кейс: полный жизненный цикл ключа**
2396
2427
 
2397
2428
  ```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 } — касса работает
2429
+ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
2430
+ await db.auth.verifyApiKey(key) // [4.5 ms] → { account, credential } — касса работает
2400
2431
  await db.credentials.delete(credential.id) // отзыв (мягкий)
2401
- await db.auth.verifyApiKey(key) // [1.7 ms] → null — мгновенно недействителен
2432
+ await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновенно недействителен
2402
2433
  ```
2403
2434
 
2404
2435
  #### `db.auth.issueKeySecret(a): Promise<{ key, secret, credential }>`
@@ -2410,8 +2441,8 @@ await db.auth.verifyApiKey(key) // [1.7 ms] → null — мгновен
2410
2441
  возвращается один раз.
2411
2442
 
2412
2443
  ```ts
2413
- const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [3.3 ms]
2414
- // key = '05aaaf12e20fb957'; secret = '88f0f27c98ff…' (48 hex, показан один раз)
2444
+ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [4.8 ms]
2445
+ // key = 'f8ab4007e4570809'; secret = 'c25b8b906f69…' (48 hex, показан один раз)
2415
2446
  ```
2416
2447
 
2417
2448
  #### `db.auth.verifyKeySecret(key, secret, opts?): Promise<AuthResult | null>`
@@ -2425,8 +2456,8 @@ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'ин
2425
2456
  **Алгоритм.** Кред по identifier = key, `timingSafeEqual(sha256(secret), meta.secret)`, ворота.
2426
2457
 
2427
2458
  ```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
2459
+ await db.auth.verifyKeySecret(key, secret) // [4.6 ms] → { account, credential }
2460
+ await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [2.2 ms] → null
2430
2461
  ```
2431
2462
 
2432
2463
  **Кейс:** серверная интеграция (1С, платёжка): key хранится в конфиге открыто и светится
@@ -2448,8 +2479,8 @@ base32(20 случайных байт), `uri` — готовая строка `o
2448
2479
 
2449
2480
  ```ts
2450
2481
  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'
2482
+ // [4.8 ms] secret = 'G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS'
2483
+ // uri = 'otpauth://totp/romashka%40salon.io?secret=G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS&issuer=clockz&algorithm=SHA1&digits=6&period=30'
2453
2484
  ```
2454
2485
 
2455
2486
  #### `db.auth.verifyTotp(a): Promise<boolean>`
@@ -2467,9 +2498,9 @@ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz
2467
2498
  **Примеры**
2468
2499
 
2469
2500
  ```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 отбит
2501
+ const код = totpCode(secret) // [659 µs] → '564517' (как в приложении)
2502
+ await db.auth.verifyTotp({ account: acc, code: код }) // [8.1 ms] → true — фактор активирован
2503
+ await db.auth.verifyTotp({ account: acc, code: код }) // [2.6 ms] → false — replay отбит
2473
2504
  const прошлый = totpCode(secret, Date.now() - 30_000) // код прошлого шага (окно ±1)
2474
2505
  await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false — шаг ≤ lastStep
2475
2506
  ```
@@ -2477,10 +2508,10 @@ await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false
2477
2508
  **Кейс: включение 2FA в кабинете**
2478
2509
 
2479
2510
  ```ts
2480
- const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [3.3 ms]
2511
+ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [4.8 ms]
2481
2512
  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 — теперь требуем код при входе
2513
+ await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [8.1 ms] → true
2514
+ await db.auth.totpEnabled(acc) // [2.2 ms] → true — теперь требуем код при входе
2484
2515
  ```
2485
2516
 
2486
2517
  #### `db.auth.totpEnabled(account): Promise<boolean>`
@@ -2489,7 +2520,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true — те
2489
2520
  проверкой (`confirmed`). Приложение по нему решает, спрашивать ли второй фактор.
2490
2521
 
2491
2522
  ```ts
2492
- await db.auth.totpEnabled(acc) // [1.7 ms] → true
2523
+ await db.auth.totpEnabled(acc) // [2.2 ms] → true
2493
2524
  ```
2494
2525
 
2495
2526
  #### `totpCode(secretBase32, atMs = Date.now()): string` — экспорт модуля
@@ -2504,7 +2535,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true
2504
2535
  Для тестов и серверной генерации кодов.
2505
2536
 
2506
2537
  ```ts
2507
- totpCode('FAZKFC57B3FPIOF2735ZYW47CZA5O6MW') // [335 µs] → '370916'
2538
+ totpCode('G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS') // [659 µs] → '564517'
2508
2539
  ```
2509
2540
 
2510
2541
  **Кейс** — автотест 2FA без телефона: сгенерировать код из секрета и скормить `verifyTotp`
@@ -2526,7 +2557,7 @@ totpCode('FAZKFC57B3FPIOF2735ZYW47CZA5O6MW') // [335 µs] → '370916'
2526
2557
 
2527
2558
  ```ts
2528
2559
  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 }
2560
+ // [6.1 ms] code = '255243'; в БД: meta = { code: '7566c91d8a5e…' (sha256), expires: '2026-07-13T07:24:44.435Z', attempts: 0 }
2530
2561
  ```
2531
2562
 
2532
2563
  #### `db.auth.verifyOtp(a): Promise<AuthResult | null>`
@@ -2545,19 +2576,19 @@ const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@sa
2545
2576
  **Примеры**
2546
2577
 
2547
2578
  ```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 — сожжён
2579
+ await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [8.4 ms] → null (+1 попытка)
2580
+ await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11.0 ms] → { account, credential }
2581
+ await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [2.4 ms] → null — сожжён
2551
2582
  ```
2552
2583
 
2553
2584
  **Кейс: сброс пароля**
2554
2585
 
2555
2586
  ```ts
2556
- const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [3.8 ms]
2587
+ const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [6.1 ms]
2557
2588
  отправитьПисьмо(почта, code) // доставка — на приложении
2558
- const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [7.4 ms]
2589
+ const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11.0 ms]
2559
2590
  if (кто) await db.auth.setPassword({ account: кто.account, identifier: почта, password: новый })
2560
- // протухший код (ttl 1 s в прогоне): verifyOtp → null [12.6 ms]
2591
+ // протухший код (ttl 1 s в прогоне): verifyOtp → null [9.3 ms]
2561
2592
  ```
2562
2593
 
2563
2594
  #### `db.auth.link(a): Promise<Credential>`
@@ -2578,7 +2609,7 @@ if (кто) await db.auth.setPassword({ account: кто.account, identifier: п
2578
2609
 
2579
2610
  ```ts
2580
2611
  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 }
2612
+ // [4.9 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
2582
2613
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915' }) // id занят ДРУГИМ аккаунтом:
2583
2614
  // Error: duplicate key value violates unique constraint "credential_identity_udx" [3.9 ms]
2584
2615
  ```
@@ -2599,14 +2630,14 @@ await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915
2599
2630
 
2600
2631
  ```ts
2601
2632
  await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' })
2602
- // [7.2 ms] → { account: c66766f7…, credential }
2633
+ // [5.2 ms] → { account: 06d3bbfe…, credential }
2603
2634
  ```
2604
2635
 
2605
2636
  **Кейс: вход через telegram-бота**
2606
2637
 
2607
2638
  ```ts
2608
2639
  // платформа подтвердила пользователя 777000111 (initData бота проверило приложение)
2609
- const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [7.2 ms]
2640
+ const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [5.2 ms]
2610
2641
  if (!кто) { /* первая встреча: создать аккаунт + db.auth.link(…) */ }
2611
2642
  const token = await sess.start(кто.account) // дальше обычная сессия
2612
2643
  ```
@@ -2622,7 +2653,7 @@ const token = await sess.start(кто.account) // дальше обычная
2622
2653
 
2623
2654
  ```ts
2624
2655
  import Redis from 'ioredis'
2625
- const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [147 µs]
2656
+ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [148 µs]
2626
2657
  ```
2627
2658
 
2628
2659
  #### `sessions.start(account, opts?): Promise<string>`
@@ -2638,9 +2669,9 @@ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [147 µ
2638
2669
  `sess:acc:<accountId>` для `revokeAll`. Дамп Redis действующих токенов не раскрывает.
2639
2670
 
2640
2671
  ```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…'
2672
+ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [8.0 ms]
2673
+ // token = 'fe0a7e83db6fcd1aa2c5002988da2b353602837db3bc0a813eddac863b9a1944'
2674
+ // в Redis: 'sess:8198bf2cd8d2ef2376d…' и 'sess:acc:06d3bbfe-d9a2-4…'
2644
2675
  ```
2645
2676
 
2646
2677
  #### `sessions.check(token): Promise<Session | null>`
@@ -2649,9 +2680,9 @@ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }
2649
2680
  (нет / истекла / отозвана). Суб-миллисекундный — на каждый HTTP-запрос.
2650
2681
 
2651
2682
  ```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 сам удалил)
2683
+ await sess.check(token) // [0.7 ms]
2684
+ // → { account: '06d3bbfe-…', meta: { device: 'iphone' }, created: '2026-07-13T07:14:45.810Z' }
2685
+ await sess.check(протухший) // [0.8 ms] → null (ttl 1 s истёк — Redis сам удалил)
2655
2686
  ```
2656
2687
 
2657
2688
  #### `sessions.revoke(token): Promise<boolean>`
@@ -2659,8 +2690,8 @@ await sess.check(протухший) // [1.2 ms] → null (ttl 1 s истёк
2659
2690
  `token: string` — `DEL` ключа + `SREM` из индекса; `false`, если сессии уже нет.
2660
2691
 
2661
2692
  ```ts
2662
- await sess.revoke(token) // [2.4 ms] → true
2663
- await sess.revoke(token) // [0.5 ms] → false — повторно
2693
+ await sess.revoke(token) // [1.9 ms] → true
2694
+ await sess.revoke(token) // [0.7 ms] → false — повторно
2664
2695
  ```
2665
2696
 
2666
2697
  #### `sessions.revokeAll(account): Promise<number>`
@@ -2669,19 +2700,19 @@ await sess.revoke(token) // [0.5 ms] → false — повторно
2669
2700
  сколько погашено.
2670
2701
 
2671
2702
  ```ts
2672
- await sess.revokeAll(acc) // [1.1 ms] → 2 — обе сессии (ipad + macbook) погасли
2703
+ await sess.revokeAll(acc) // [1.2 ms] → 2 — обе сессии (ipad + macbook) погасли
2673
2704
  ```
2674
2705
 
2675
2706
  **Кейс: полный вход — пароль → сессия → запрос → выход (реальный прогон)**
2676
2707
 
2677
2708
  ```ts
2678
2709
  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]
2710
+ // [71.3 ms] → { account, credential }
2711
+ const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.2 ms]
2681
2712
  // … каждый запрос в middleware:
2682
- const кто = await sess.check(token) // [0.9 ms] → { account: 'c66766f7…', meta: { ip: '10.0.0.7' }, … }
2713
+ const кто = await sess.check(token) // [0.7 ms] → { account: '06d3bbfe…', meta: { ip: '10.0.0.7' }, … }
2683
2714
  // logout:
2684
- await sess.revoke(token) // [2.2 ms] → true
2715
+ await sess.revoke(token) // [1.3 ms] → true
2685
2716
  // «выйти со всех устройств» после смены пароля: await sess.revokeAll(визит.account)
2686
2717
  ```
2687
2718
 
@@ -2690,7 +2721,7 @@ await sess.revoke(token) // [2.2 ms] → true
2690
2721
  Решения по словарю Resource/Rule (§ 9.2). Прогоны ниже — словарь из § 11.8-кейса: группа
2691
2722
  `apiref.client:ACCOUNT {categories: '{Client}'}`, эндпоинты `apiref.api.booking:API
2692
2723
  {endpoint: 'v2.booking.*'}`, данные `apiref.booking.own:READ/WRITE/DELETE
2693
- {class: 'Booking', owner: '$account'}` и `apiref.service:READ {class: 'Service'}`,
2724
+ {class: 'booking', owner: '$account'}` и `apiref.service:READ {class: 'Service'}`,
2694
2725
  5 правил `allow weight 60` от группы Client.
2695
2726
 
2696
2727
  #### `db.acl.check(account, endpoint): Promise<AclDecision>`
@@ -2710,10 +2741,10 @@ NULL — все); объекты — `API`-ресурсы, чья маска п
2710
2741
  **Примеры**
2711
2742
 
2712
2743
  ```ts
2713
- await db.acl.check(acc, 'v2.booking.create') // [3.2 ms]
2744
+ await db.acl.check(acc, 'v2.booking.create') // [5.0 ms]
2714
2745
  // → { allow: true, rule: { account: 'apiref.client:ACCOUNT', resource: 'apiref.api.booking:API',
2715
2746
  // permission: 'allow', weight: 60, enabled: true } }
2716
- await db.acl.check(acc, 'v2.admin.stats') // [1.5 ms] — покрыло только дно-правило сида:
2747
+ await db.acl.check(acc, 'v2.admin.stats') // [1.8 ms] — покрыло только дно-правило сида:
2717
2748
  // → { allow: false, rule: { account: 'any:ACCOUNT', resource: 'any:API', permission: 'deny', weight: 0, … },
2718
2749
  // code: 403, message: 'Access denied - default for any ACCOUNT to any API' }
2719
2750
  ```
@@ -2738,7 +2769,7 @@ app.use(async (req, res, next) => {
2738
2769
 
2739
2770
  **Назначение и алгоритм.** Решение по данным: объекты — ресурсы категории `op`, чья
2740
2771
  `pattern.class`-маска совпала с именем класса **или любого предка** (lineage: право на
2741
- `Booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
2772
+ `booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
2742
2773
  ключи pattern (реальные колонки Entity, `"$account"` → id субъекта) возвращаются как
2743
2774
  `filter` — готовый предикат строк. Справочный метод (SQL за категориями аккаунта на каждый
2744
2775
  вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный на connect
@@ -2747,35 +2778,37 @@ app.use(async (req, res, next) => {
2747
2778
  **Примеры**
2748
2779
 
2749
2780
  ```ts
2750
- await db.acl.checkData(acc, 'Booking', 'READ') // [1.6 ms]
2781
+ await db.acl.checkData(acc, 'booking', 'READ') // [2.7 ms]
2751
2782
  // → { 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]
2783
+ // filter: { owner: '06d3bbfe-d9a2-4c1d-be44-04c40cb01108' } } ← $account подставлен
2784
+ await db.acl.checkData(acc, 'Service', 'READ') // [1.9 ms]
2754
2785
  // → { allow: true, rule: { resource: 'apiref.service:READ', … } } ← безусловный (без filter)
2755
- await db.acl.checkData(acc, 'Org', 'READ') // [1.0 ms]
2786
+ await db.acl.checkData(acc, 'Org', 'READ') // [2.0 ms]
2756
2787
  // → { 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)
2788
+ await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий connect]
2789
+ // → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: '06d3bbfe-…' } }
2790
+ // класса нет в словаре — право дал предок booking (lineage)
2760
2791
  ```
2761
2792
 
2762
2793
  **Кейс: enforceAcl — те же решения в SQL цепочек (реальный прогон)**
2763
2794
 
2764
2795
  ```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, класс целиком
2796
+ const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [66.9 ms] правила фиксируются
2797
+ await uc.запись().rows() // [17.2 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
2798
+ await uc.запись().count() // [17.6 ms] → 3 — честный count по суженному множеству
2799
+ await uc.Услуга().count() // [15.1 ms] → 602 — безусловный allow, класс целиком
2769
2800
  await uc.Организация().rows()
2770
2801
  // 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]
2802
+ const [z] = await uc.запись().create({ start_datetime: t, end_datetime: e })
2803
+ .Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [20.9 ms]
2772
2804
  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 }]
2805
+ await uc.запись().owner(SYS).create({ … }).rows()
2806
+ // Error: letopis: acl pins booking writes to owner 06d3bbfe-… — на терминале [2.6 ms]
2807
+ await uc.запись('чужой-id').create({ … }).rows() // [2.0 ms] — перехват чужого id мёртв: v5-класс id не принимает
2808
+ // Error: letopis: class "booking" computes id (uuid v5 from Staff, start_datetime) — remove the explicit id
2809
+ await uc.запись(свойId).delete({ confirm: true }).rows() // [30.3 ms] → [{ id: 'ee65244b-…', $deleted: true }]
2777
2810
  // watch: события только безусловных allow-классов —
2778
- // uc.watch(cb) поймал ['Service']; Booking скрыт (предикат не проверить по payload)
2811
+ // uc.watch(cb) поймал ['Service']; booking скрыт (предикат не проверить по payload)
2779
2812
  ```
2780
2813
 
2781
2814
  #### `db.acl.reload(): void`
@@ -2786,7 +2819,7 @@ connect; подхватить новые правила = новый `connect()`
2786
2819
  connect — новый класс в lineage-проверках увидит только новое подключение.
2787
2820
 
2788
2821
  ```ts
2789
- db.acl.reload() // [120 µs]
2822
+ db.acl.reload() // [187 µs]
2790
2823
  ```
2791
2824
 
2792
2825
  **Кейс:** админка сохранила правило → `reload()` в том же процессе, чтобы `check` следующего
@@ -2806,9 +2839,9 @@ db.acl.reload() // [120 µs]
2806
2839
  компилированный валидатор.
2807
2840
 
2808
2841
  ```ts
2809
- db.registry.resolve('Запись') // [90 µs]
2810
- // → { id: 'Booking', alias: 'Запись', category: 'HUB', ancestors: ['Booking', 'Entity'],
2811
- // links: ['Customer'], abstract: false, … }
2842
+ db.registry.resolve('запись') // [103 µs]
2843
+ // → { id: 'booking', alias: 'запись', category: 'LINK', ancestors: ['booking', 'slot', 'link'],
2844
+ // links: [{ classes: ['Staff'], … }, …, { classes: ['Customer'], optional: true, … }], abstract: false }
2812
2845
  db.registry.resolve('Дракон')
2813
2846
  // Error: letopis: unknown class "Дракон". Known: Entity·Сущность, Org·Организация, …
2814
2847
  ```
@@ -2822,10 +2855,10 @@ db.registry.resolve('Дракон')
2822
2855
  `all` — все классы партиции.
2823
2856
 
2824
2857
  ```ts
2825
- db.registry.has('Booking') // → true
2858
+ db.registry.has('booking') // → true
2826
2859
  db.registry.has('Дракон') // → false
2827
2860
  db.registry.find('Дракон') // → undefined
2828
- db.registry.all.length // → 15
2861
+ db.registry.all.length // → 20 (+1: LINK-класс price·«цена»)
2829
2862
  ```
2830
2863
 
2831
2864
  **Кейс:** роутер `GET /:класс` — `has()` до цепочки, чтобы отвечать 404, а не 500.
@@ -2843,12 +2876,12 @@ db.registry.all.length // → 15
2843
2876
  конец LINK) — обычный `Error` там же.
2844
2877
 
2845
2878
  ```ts
2846
- try { await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [1.4 ms]
2879
+ try { await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [9.9 ms]
2847
2880
  catch (e) {
2848
2881
  e instanceof ValidationError // → true
2849
2882
  e.issues
2850
- // → [{ type: 'required', field: 'duration', message: "The 'duration' field is required." },
2851
- // { type: 'objectStrict', expected: 'id, name, price, active, duration, description', actual: 'чепуха', … }]
2883
+ // → [{ type: 'objectStrict', message: "The object '' contains forbidden keys: 'чепуха'.",
2884
+ // expected: 'id, name, description, duration', actual: 'чепуха' }]
2852
2885
  }
2853
2886
  ```
2854
2887
 
@@ -2921,76 +2954,103 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
2921
2954
 
2922
2955
  ## 13. Что контролирует приложение
2923
2956
 
2924
- - Непересечение окон в расписании (EXCLUDE на hypertable невозможен).
2925
- - Бизнес-проверки брони: навык, смена, число подряд окон под `duration` (§ 7).
2926
- - `total` записи = Σ позиций.
2927
- - Генерация окон из шаблонов/повторов.
2957
+ - Пересечения интервалов записей внутри окна-смены мастера (наложение броней; EXCLUDE на
2958
+ hypertable невозможен). Двойная бронь одного времени мертва самим id записи —
2959
+ `v5(Мастер, старт)` (§ 3.2); частичное наложение разных интервалов проверяет приложение.
2960
+ - Бизнес-проверки брони: у мастера есть навык на услугу (`Мастер→навык→Услуга`); интервал
2961
+ записи попадает в окно-смену того же мастера; длительность услуги укладывается в окно.
2962
+ - Итог по записи = цена предмета (`Услуга`/`Товар`/`Комплекс`), у комплекса — по составу:
2963
+ сами записи денег не хранят.
2964
+ - Генерация окон-смен из шаблонов/повторов; месячное `Расписание {name, year, month}`.
2928
2965
 
2929
2966
  ## 14. Производительность
2930
2967
 
2931
- Замеры `npm run bench` (105 200 строк: 5 000 сущностей × 20 версий + связи; docker, локально):
2968
+ Тайминги — живые прогоны демо на ЕДИНОМ полигоне `v1.salondemo` (`bench/salon-seed.mjs`,
2969
+ ~980 000 строк: 440 000 записей ×2 версии, 600 услуг, 1026 цен, 1260 навыков). Статья
2970
+ (`bench/salon-article-demo.mjs`), API Reference (`bench/api-reference-demo.mjs`) и бенчи
2971
+ читают один и тот же полигон, мутируя лишь свои демо-сущности:
2932
2972
 
2933
- | Операция | p50 | p95 |
2934
- |---|---|---|
2935
- | `rows()` класс + containment-фильтр | 5 ms | 52 ms |
2936
- | `rows()` весь класс, sort+limit 100 | 63 ms | 74 ms |
2937
- | цепочка 3 хопа (пути) | 15 ms | 23 ms |
2938
- | `count()` путей | 15 ms | 20 ms |
2939
- | запись новой версии (`create`/`update`) | 8 ms | 12 ms |
2940
-
2941
- TOAST-порог (data > 2KB): 0 строк. Слабое место — выборка «весь класс с сортировкой»
2942
- (DISTINCT ON всех сущностей класса); лечится селективным фильтром или курсором.
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
- мутирует только свои сущности).
2973
+ | Операция | Время |
2974
+ |---|---|
2975
+ | фильтр/`count()` по каталогу (`ne`/`gt`/`between`/`like`) | 5–8 ms |
2976
+ | агрегация каталога `avg`/`min`/`max` | 5–15 ms |
2977
+ | `sum('data.amounts.RUB')` (класс цена, 1026 вариантов) | ≈13 ms |
2978
+ | `count()`/`rows()` каталога услуг (600) | 7–13 ms |
2979
+ | цена `sort('data.amounts.RUB')` + keyset-страница `after(cursor)` | 19–31 ms |
2980
+ | цепочка `навык→Услуга`, `count()` путей (1260) | ≈65 ms |
2981
+ | `countBy('data.notes')` по 440k записям | ≈3.3 s |
2982
+ | `sort('updated','desc').limit` по всему классу записей БЕЗ фильтра | ≈6 s |
2983
+ | `count()` всех 440 000 записей БЕЗ фильтра | ≈3.4 s |
2984
+ | обход `запись→Мастер` по всему классу записей | ≈6 s |
2985
+ | запись новой версии (`create`/`update`), в т.ч. со слотами | 8–21 ms |
2986
+
2987
+ Слабое место — выборка/обход **всего класса записей без фильтра** (`DISTINCT ON` по всем
2988
+ 440 000 сущностям: секунды); лечится селективным фильтром, контекст-шагом или курсором
2989
+ (keyset — миллисекунды даже на 440k). Каталог, агрегации и цепочки с фильтром — единицы—десятки ms.
2990
+ TOAST-порог (data > 2KB): 0 строк.
2948
2991
 
2949
2992
  - Containment и обход графа — GIN; операторы — на уже суженном наборе.
2950
- - Начинайте цепочку с самого селективного шага.
2951
- - `count()` — пути; количество сущностей дешевле `ids().length`.
2952
- - Каскадное удаление — серверное: один DELETE на всё дерево.
2953
- - Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты).
2954
- - EXPLAIN-паттерн — тест `EXPLAIN` (индексы обязаны быть в плане; ноль seq scan).
2993
+ - Начинайте цепочку с самого селективного шага (Организация/Мастер/Клиент, не `запись()`).
2994
+ - `count()` считает пути; число сущностей дешевле берётся `ids().length`.
2995
+ - Каскадное удаление — серверное: один DELETE на всё дерево (клиент → его записи).
2996
+ - Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты);
2997
+ окно/запись/навык — v5-классы, в батче идут поштучно (id из концов), не multi-VALUES.
2998
+ - Микро-бенч `npm run bench` (105k строк) + EXPLAIN-тесты (индексы обязаны быть в плане; ноль
2999
+ seq scan); партиционирование — `bench/dimensions.bench.mjs`, масштаб —
3000
+ `node bench/history.bench.mjs --entities=10000 --versions=100`, оверхед ACL —
3001
+ `npx tsx bench/acl.bench.mjs` (таблица в § 9.2).
2955
3002
 
2956
3003
  ---
2957
3004
 
2958
3005
  ## 15. E2E-пример: барбершоп
2959
3006
 
2960
- Полный исполняемый сценарий — `test/integration.test.ts`. Скелет:
3007
+ Полный исполняемый сценарий — `test/integration.test.ts`. Скелет (модель 0.17,
3008
+ позитивная доступность):
2961
3009
 
2962
3010
  ```ts
2963
- // штат и каталог — связи контекст-шагами; терминал .rows() исполняет план
3011
+ // организация, локация, штат (phone обязателен) — связи контекст-шагами; терминал .rows() исполняет план
2964
3012
  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() // умение
2972
-
2973
- // календарь: период → окна батчем → ростер → исключение
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(окно, мастер): двойная бронь мертва на уровне схемы
2986
-
2987
- // жизненный цикл, чтения, отмена
2988
- await db.Запись(bkg.id).update({ status: 'confirmed' }).rows()
2989
- await db.Запись(bkg.id).занятость().Окно().sort('data.start').rows() // окна брони
2990
- await db.Окно(w2.id).занятость({ kind: 'booking' }).Сотрудник().rows() // кто занят
2991
- await db.Запись(bkg.id).delete().rows() // ПРЕВЬЮ: что удалится
2992
- const удалено = await db.Запись(bkg.id).delete({ confirm: true }).rows()
2993
- // всё с $deleted (Booking+busy×2+item)
3013
+ const [loc] = await db.Организация(org).Локация().create({ address: 'Тверская, 7', coordinates: { lat: 55.76, lng: 37.61 } }).rows()
3014
+ const [иван] = await db.Организация(org).Мастер().create({ name: 'Иван', phone: '+7 900 111', specialization: 'барбер' }).rows()
3015
+ const [олег] = await db.Организация(org).Мастер().create({ name: 'Олег', phone: '+7 900 222' }).rows()
3016
+ const [пётр] = await db.Организация(org).Клиент().create({ name: 'Пётр', phone: '+7 905 000' }).rows()
3017
+
3018
+ // каталог: id услуги/комплекса вычислила схема uuidv5(Org, name) — дубль имени в салоне невозможен (§ 3.2)
3019
+ const [стрижка] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60 }).rows()
3020
+ const [комплекс] = await db.Организация(org).Комплекс().create({ name: 'Стрижка+борода', duration: 90, cost: 2000 }).rows()
3021
+ // цена — отдельный LINK «цена»: варианты (note) + мультивалюта (amounts); note не задан → «базовая»
3022
+ await db.Услуга(стрижка).цена().create({ amounts: { RUB: 1500 } }).rows()
3023
+ await db.Услуга(стрижка).цена().create({ note: 'с дизайном', amounts: { RUB: 2000 } }).rows()
3024
+ await db.Комплекс(комплекс).состав().create({ quantity: 1 }).Услуга.set(стрижка).rows() // состав комплекса
3025
+ await db.Мастер(иван).навык().create({ level: 'expert' }).Услуга.set(стрижка).rows() // умение
3026
+ await db.Мастер(иван).адрес().create({ default: true }).Локация.set(loc).rows() // мастер работает в локации
3027
+
3028
+ // месячное расписание → окна-смены батчем (окно = мастер доступен в интервале)
3029
+ const [sch] = await db.Организация(org).Расписание().create({ name: 'Август', year: 2026, month: 8 }).rows()
3030
+ db.batch('смены').Мастер(иван).окно().create({ start_datetime: '2026-08-01T07:00:00Z', end_datetime: '2026-08-01T15:00:00Z' }).Локация.set(loc).Расписание.set(sch)
3031
+ db.batch('смены').Мастер(олег).окно().create({ start_datetime: '2026-08-01T07:00:00Z', end_datetime: '2026-08-01T15:00:00Z' }).Локация.set(loc).Расписание.set(sch)
3032
+ await db.batch('смены').run() // окно — v5-класс: id = uuidv5(Мастер, старт) → поштучно
3033
+ await db.Расписание(sch).окно().Мастер().rows() // ростер смены: [Иван, Олег]
3034
+
3035
+ // бронь = наследник окна; id записи = v5(Мастер, старт), известен ДО создания — двойная бронь мертва
3036
+ const start = '2026-08-01T07:00:00Z', end = '2026-08-01T08:00:00Z'
3037
+ const bId = uuidv5(`v1.booking:entity:booking:${иван.id}:${start}`)
3038
+ const tr = await db.begin()
3039
+ await tr.lock('booking', иван.id, start) // сериализуем соперников на этом слоте
3040
+ if (!(await tr.запись(bId).first())) { // «занято?» — точечный first() по вычисленному id
3041
+ await tr.Мастер(иван).запись().create({ start_datetime: start, end_datetime: end })
3042
+ .Локация.set(loc).Расписание.set(sch).Услуга.set(стрижка).Клиент.set(пётр).rows()
3043
+ }
3044
+ await tr.commit()
3045
+
3046
+ // жизненный цикл, чтения, отмена/перенос
3047
+ await db.запись(bId).update({ notes: 'подтверждена' }).rows() // новая версия
3048
+ await db.Мастер(иван).запись().Услуга().rows() // что забронировано у Ивана
3049
+ await db.Локация(loc).запись().Клиент().rows() // кто записан в локацию
3050
+ await db.запись(bId).delete().rows() // ПРЕВЬЮ: что удалится
3051
+ await db.запись(bId).delete({ confirm: true }).rows() // отмена = tombstone (история цела)
3052
+ // перенос 07:00 → 09:00 = пересоздание (delete старой + create новой в одной транзакции):
3053
+ // id держит (Мастер, старт), поэтому смена времени = новая запись, старая — tombstone
2994
3054
  ```
2995
3055
 
2996
3056
  ---
@@ -2998,7 +3058,7 @@ const удалено = await db.Запись(bkg.id).delete({ confirm: true }).r
2998
3058
  ## 16. Тесты
2999
3059
 
3000
3060
  ```bash
3001
- npm test # 165/165 тестов против живого docker (timescale + redis), по файлам:
3061
+ cd lib && npm test # 167/167 тестов против живого docker (timescale + redis), по файлам:
3002
3062
  # acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
3003
3063
  # с $account, enforceAcl (предикаты в SQL, каскад, watch)
3004
3064
  # api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)