letopis 0.13.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).Сотрудник().set({ name: 'Вася', roles: ['master'] }).rows()
16
+ await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
17
17
  ```
18
18
 
19
19
  Содержание:
@@ -52,8 +52,8 @@ const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v
52
52
  // [letopis.up] schema "v1.booking" applied (3 files)
53
53
  // [letopis.up] connected (schema "v1.booking")
54
54
 
55
- const [org] = await db.Организация().set({ name: 'BarberPro' }).rows()
56
- const [вася] = await db.Организация(org).Сотрудник().set({ name: 'Вася', roles: ['master'] }).rows()
55
+ const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
56
+ const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
57
57
  await db.close()
58
58
  ```
59
59
 
@@ -108,7 +108,7 @@ const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz
108
108
 
109
109
  | SQL | Триггер | Поведение |
110
110
  |---|---|---|
111
- | `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id; у LINK есть обязательные концы (`'Entity'`-концы полиморфны — закрывает либа). Tombstone-вставки не проверяются |
111
+ | `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id; концы LINK по Schema.links v2 (жадный матчинг, союзы, optional, лишние связи — ошибка; legacy-строки — по-старому). Tombstone-вставки не проверяются |
112
112
  | `UPDATE` | `entity_update` | физического апдейта нет: вставляется **новая версия** (`updated = GREATEST(clock, prev+1µs)`); не-latest строки игнорируются — история неизменна |
113
113
  | `DELETE` | `entity_delete` | вставляется **tombstone** + **рекурсивный каскад**: DELETE живых зависимых (`links ⊃ {класс: id}`) повторяет триггер по дереву; advisory-lock; история/tombstone неприкосновенны (повторный DELETE — no-op) |
114
114
 
@@ -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,18 +143,94 @@ 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`** |
150
150
  | `descendants` | все потомки транзитивно — **тот же триггер** |
151
- | `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; вложенные `{type:'object', props:{…}}` любой глубины; строгая валидация |
152
- | `links` | HUB: встраиваемые цели (forward-обход); LINK: обязательные концы, `'Entity'` = полиморфный |
151
+ | `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; вложенные `{type:'object', props:{…}}` любой глубины; строгая валидация; собираются по иерархии (§ 3.3); правило `id` — генерация (§ 3.2) |
152
+ | `links` | концы связей класса, формат v2 — массив объектов (см. ниже); legacy-строка `'Org'` = `{class:'Org'}`, `'Entity'` = старый полиморф |
153
153
  | `meta` | `{ abstract?, description? }` |
154
154
 
155
+ ### 3.1 Концы связей — Schema.links v2
156
+
157
+ Каждый конец — объект (`Schema.links jsonb` — массив объектов):
158
+
159
+ ```jsonc
160
+ "links": [
161
+ { "class": "Staff", "cardinality": 1 }, // один класс
162
+ { "classes": ["Service", "Product", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
163
+ { "class": "Customer", "optional": true, "cardinality": 1 } // конец может отсутствовать
164
+ ]
165
+ ```
166
+
167
+ - `Entity` в концах не используется — классы называются явно («максимально точная идентификация связи»)
168
+ - **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет записи — `{Услуга|Товар|Комплекс}` (бронь услуги ИЛИ продажа товара ИЛИ комплекс — ровно один из)
169
+ - Обязательный конец без ключа → `requires end "Service|Product|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
170
+ - `cardinality` — зарезервировано (0 — безлимит, N — точное число), пока не проверяется: связь класса в строке одна (`{Класс: id}`), множественность выражается строками-связками
171
+ - Демо-домен (0.17, позитивная доступность): `адрес = [Мастер, Локация]`, `окно = [Мастер, Локация, Расписание]` (смена — интервал доступности), `запись = [Мастер, Локация, Расписание, {Услуга|Товар|Комплекс}, Клиент?]` — **наследник окна**, `содержимое = [Папка, {Услуга|Товар|Комплекс}]`, `состав = [Комплекс, {Услуга|Товар}]`, `навык = [Мастер, Услуга]`, `цена = [{Услуга|Товар|Комплекс}]` (варианты цены: `note` + `amounts` record<валюта,число>)
172
+ - Источник правды демо-домена — сид `lib/sql/seed.booking.sql` (редактируется руками, идемпотентен)
173
+
155
174
  `schema_lineage` (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
156
175
  пересчитывает `ancestors`/`descendants` при любом изменении Schema.
157
- Из либы: `db.registry.resolve('связь').descendants` → `['busy','item','shift','skill']`.
176
+ Из либы: `db.registry.resolve('связь').descendants` → `['address','booking','compo','content','price','skill','slot']`.
177
+
178
+ ### 3.2 id считает схема: `attributes.id`
179
+
180
+ Правило рождения id объявляется в `attributes.id` класса (наследуется — § 3.3):
181
+
182
+ | Правило | Версия | Смысл |
183
+ |---|---|---|
184
+ | `"uuid"` / `{"type":"uuid"}` | **v4** | random — дефолт (как раньше) |
185
+ | `{"type":"uuid","generate":7}` | **v7** | unix-время в старших битах — вставки ложатся в хвост btree-индекса |
186
+ | `{"type":"uuid","generate":5,"from":[…]}` | **v5** | детерминированный: id вычисляется из данных |
187
+
188
+ Формула v5 открыта: `uuidv5("pgSchema:partition:класс:значения from")`, namespace letopis —
189
+ `c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90`. `from` — имена ОБЯЗАТЕЛЬНЫХ концов `Schema.links`
190
+ (класс или полное имя союза `'Service|Complex'`; значение — id конца) и/или скалярных полей
191
+ `data`. Экспорт: `import { uuidv5, uuidv7, LETOPIS_NS } from 'letopis'`.
192
+
193
+ Два свойства v5, ради которых всё:
194
+
195
+ - **create идемпотентен**: та же комбинация → та же сущность (повтор — новая версия, не
196
+ дубль) — даже в гонке двух процессов оба вычислят один id; двойная бронь мертва на
197
+ уровне схемы;
198
+ - **id известен ДО создания**: «занято ли окно» — точечный `first()` по вычисленному id,
199
+ без создания чего-либо.
200
+
201
+ ```ts
202
+ // запись: id считается сам — uuidv5(мастер, старт); правило унаследовано от окна
203
+ const [б] = await db.Мастер(м).запись().create({ start_datetime: t, end_datetime: e })
204
+ .Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
205
+ б.id === uuidv5(`v1.booking:entity:booking:${м.id}:${t}`) // → true
206
+ // «занято?» — ДО создания чего-либо:
207
+ await db.запись(uuidv5(`v1.booking:entity:booking:${м.id}:${t}`)).first() // Row | null
208
+ ```
209
+
210
+ Явный id у v5-класса запрещён (`computes id` — его всегда считает схема); в батче
211
+ v5-классы не склеиваются в multi-VALUES (id нужны концы) — исполняются поштучно (§ 8).
212
+
213
+ Демо-схема booking: корни `Entity`/`link` объявляют дефолт `{generate: 7}` один раз;
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, не ломая генерацию.
227
+
228
+ ### 3.3 Наследование attributes
229
+
230
+ При загрузке реестра attributes класса собираются по цепочке `ancestor`: **потомок ПОВЕРХ
231
+ предка**, переопределение поля — замена правила ЦЕЛИКОМ (не слияние). Правило `id` (§ 3.2)
232
+ наследуется так же — дефолт объявляется один раз на корне иерархии; `links` НЕ наследуются:
233
+ концы объявляет каждый класс сам. Валидатор и типы полей компилируются из слитых attributes.
158
234
 
159
235
  ---
160
236
 
@@ -169,12 +245,32 @@ GIN-кандидатам, затем перепроверка условий н
169
245
  | HUB → HUB (разные) | forward, если цель ∈ `Schema.links` текущего; иначе reverse; иначе «no path» |
170
246
  | HUB → HUB (тот же класс) | reverse = **дети** (`db.Папка(id).Папка()`); родитель — `row.links.Folder` |
171
247
  | LINK → LINK | ошибка |
248
+ | повтор LINK-класса | **pivot**: возврат к тому же узлу (ветвление к другому концу + дофильтр AND) |
249
+
250
+ Путь подчиняется переходам **всегда** — и в чтении, и в записи; недопустимый переход —
251
+ ошибка **синхронно при построении цепочки** (не в терминале). К концу связки, до которого
252
+ прямого HUB→HUB пути нет, идут через саму связку и **pivot** (повтор её имени):
253
+
254
+ ```ts
255
+ // «записи клиента к, где услуга у и мастер вася» — путь через запись, pivot-возврат:
256
+ db.Клиент(к).запись().Услуга(у).запись().Мастер(вася).запись().rows()
257
+ // «умеет ли Ирина стрижку» — навык, дофильтр предметом, count путей:
258
+ db.Мастер(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
259
+ ```
260
+
261
+ ### Узел-переменная: `entity()`
262
+
263
+ ```ts
264
+ const p = db.запись() // ленивый узел-паттерн
265
+ await db.Клиент(к).entity(p).Услуга(у).entity(p).Мастер().run() // та же p = тот же узел (явный pivot)
266
+ await db.entity(row).Услуга().first() // старт пути с готового Row
267
+ ```
172
268
 
173
269
  ### Терминалы
174
270
 
175
271
  ```ts
176
- const пути = await db.Сотрудник({ name:'Вася' }).alias('Мастер').навык().Услуга().run()
177
- // [{ Мастер: Row, навык: Row, Услуга: Row }, …]
272
+ const пути = await db.Мастер({ name:'Вася' }).alias('Исполнитель').навык().Услуга().run()
273
+ // [{ Исполнитель: Row, навык: Row, Услуга: Row }, …]
178
274
  ```
179
275
 
180
276
  | Вызов | Возврат |
@@ -200,9 +296,9 @@ const пути = await db.Сотрудник({ name:'Вася' }).alias('Мас
200
296
  db.Услуга('uuid') // по id
201
297
  db.Услуга(rowИлиAccount) // объект с id — возьмётся .id
202
298
  db.Услуга(['id1','id2']) // по списку ([] → пусто)
203
- db.Услуга({ name: 'Стрижка', active: true }) // eq полей data → GIN-containment
299
+ db.Услуга({ name: 'Стрижка', duration: 60 }) // eq полей data → GIN-containment
204
300
  db.Услуга({ id: 'uuid', duration: gte(30) }) // ключ id — тоже id-фильтр
205
- db.Услуга({ price: { RUB: lte(2000) } }) // вложенные пути ЛЮБОЙ глубины (record/object) + каст по листу
301
+ db.Локация({ coordinates: { lat: gte(55) } }) // вложенные пути ЛЮБОЙ глубины (object) + каст по листу
206
302
  ```
207
303
 
208
304
  ### Операторы (18: + `not`, `or`)
@@ -219,140 +315,165 @@ import { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends,
219
315
  | `gt/gte/lt/lte(v)`, `between(a,b)` | number, date, string | `(data->>'f')::cast ⋛ $` |
220
316
  | `inList([…])` | любые | `IN (…)`; `[]` → FALSE |
221
317
  | `like/ilike/starts/ends(s)` | string | `[I]LIKE` |
222
- | `has(v)/hasAll([…])/hasAny([…])` | массивы data (`roles`) | `@>` / `?\|` |
318
+ | `has(v)/hasAll([…])/hasAny([…])` | массивы data и колонка `tags` | `@>` / `?\|` |
223
319
  | `exists(true/false)` | любые | ключ есть/нет |
224
320
  | `isNull()` | любые | null или отсутствует |
225
321
  | `not(op \| скаляр)` | по внутреннему | `NOT (…)`; `not(скаляр)` = `ne` |
226
- | `or(f1, f2, …)` | **фильтр целиком** | `db.Запись(or({status:'created'}, {status:'confirmed'}))` — дизъюнкция под-фильтров |
322
+ | `or(f1, f2, …)` | **фильтр целиком** | `db.Услуга(or({name:'Стрижка'}, {duration: lt(40)}))` — дизъюнкция под-фильтров |
227
323
 
228
324
  Касты по `Schema.attributes`: number→`::numeric`, date→`::timestamptz`, boolean→`::boolean`.
229
325
  Поле вне схемы фильтруется как text (записать его нельзя — строгая валидация).
230
- Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`), сравнения корректны.
231
- `{ roles: ['a','b'] }` = containment «содержит оба», не «равно массиву».
326
+ Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`); сравнения — операторами (`between(t, t)`
327
+ для «равно моменту»: скаляр-eq по дате — строковый jsonb-containment, он про
328
+ нормализованное значение). В демо-схеме массивов в data нет — `has*` живут на `tags`.
232
329
 
233
330
  ### Модификаторы цепочки
234
331
 
235
332
  ```ts
236
- db.Окно().sort('data.start').limit(10).offset(20).rows() // выборка
237
- 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()
238
335
 
239
336
  db.Клиент().tags('vip') // фильтр: tags ⊇ ['vip']
240
337
  db.Клиент().tags(['vip','telegram']) // все перечисленные
241
338
  db.Клиент().tags(hasAny(['vip','b2b'])) // хотя бы один
242
- db.Запись().account(accId) // фильтр по колонке account (uuid | Row)
339
+ db.запись().account(accId) // фильтр по колонке account (uuid | Row)
243
340
  db.Организация().owner(acc) // фильтр по owner
244
- db.Сотрудник({…}).alias('Мастер') // ключ шага в путях
341
+ db.Мастер({…}).alias('Исполнитель') // ключ шага в путях
245
342
  ```
246
343
 
247
- | Модификатор | Область | В чтении | В `set()` |
344
+ | Модификатор | Область | В чтении | В записи |
248
345
  |---|---|---|---|
249
- | `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей UPDATE |
346
+ | `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей `update()` |
250
347
  | `asOf(t)` | вся цепочка | «как было на T» (§ 10.1) | — |
251
348
  | `after(cursor)` | вся цепочка | keyset-пагинация, требует `sort` (§ 10.2) | — |
252
349
  | `deep(max?)` | текущий шаг (self-hop) | рекурсивные дети, `$depth` (§ 10.4) | — |
253
- | `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при INSERT (string \| string[]) |
254
- | `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при INSERT |
255
- | `link(Класс, target)` | текущий шаг | — | **значение** связи БЕЗ участия в фильтре целей (§ 6) |
350
+ | `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при `create()` (string \| string[]) |
351
+ | `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при `create()` |
352
+ | `.Класс.set(x)` / `.Класс.unset()` | слот связи — после глагола записи | — | записать / снять конец связи БЕЗ участия в фильтре целей (§ 6.1) |
256
353
  | `alias(name)` | текущий шаг | ключ в путях | — |
257
354
 
258
355
  ---
259
356
 
260
357
  ## 6. Запись: операции — звенья плана
261
358
 
262
- `.set(data)` / `.delete(opts)` / `.anonymize(fields)` — **звенья цепочки**: операция
263
- применяется к шагу, к которому приклеена точкой, и возвращает цепочку для продолжения.
264
- Сама по себе ничего не пишет — **исполняет терминал** (`rows()/first()/count()/run()/…`),
265
- весь план **одной транзакцией**: отказ любого сегмента (валидация, ACL) откатывает всё.
359
+ `.create(data?)` / `.update(data?)` / `.delete(opts?)` / `.anonymize(fields)` — **звенья
360
+ цепочки**: операция применяется к шагу, к которому приклеена точкой, и возвращает цепочку
361
+ для продолжения. Сама по себе ничего не пишет — **исполняет терминал**
362
+ (`rows()/first()/count()/run()/…`), весь план **одной транзакцией**: отказ любого сегмента
363
+ (валидация, ACL) откатывает всё.
364
+
365
+ Глаголы не перепутать: `create` **вставляет** (или версионирует по известному id),
366
+ `update` **правит найденное путём** и никогда не создаёт. Старый `set()` разбит на них
367
+ в 0.16.0 (бросает подсказку) — мина «`Класс()` → INSERT, `Класс({})` → UPDATE всех» мертва,
368
+ намерение всегда явное.
266
369
 
267
370
  ```ts
268
371
  // одиночная запись: операция + терминал
269
- const [вася] = await db.Организация(org).Сотрудник().set({ name: 'Вася', roles: ['master'] }).rows()
372
+ const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
270
373
 
271
374
  // несколько операций в одной цепочке: продолжение — ОТ РЕЗУЛЬТАТА предыдущей
272
- await db.Клиент({ vip: true }).set({ bonus: 500 }) // UPDATE всех vip
273
- .Запись().set({ status: 'gift' }) // INSERT записи КАЖДОМУ (fan-out)
274
- .rows() // → подарочные записи
375
+ await db.Организация().tags('сеть').update({ active: true }) // новая версия всех сетевых орг
376
+ .Услуга().create({ name: 'Акция месяца', duration: 30 }) // INSERT услуги КАЖДОЙ (fan-out)
377
+ .rows() // → акционные услуги
275
378
 
276
379
  // операция сразу после операции — к тем же сущностям (две версии подряд)
277
- await db.Запись(id).set({ status: 'confirmed' }).set({ paid: true }).rows()
380
+ await db.запись(id).update({ notes: 'подтверждена' }).update({ notes: 'оплачена' }).rows()
278
381
  ```
279
382
 
280
383
  ### Анатомия
281
384
 
282
385
  ```
283
- db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).set( DATA ).Хвост()… .rows()
284
- └───── контекст ─────┘ └─────┘ └─ продолжение ─┘ └─ терминал: исполняет план
285
- каждый шаг = связь кого трогаем от записанных
386
+ db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хвост()… .rows()
387
+ └───── контекст ─────┘ └─────┘ └────┘ └─ продолжение ─┘ └─ терминал: исполняет план
388
+ каждый шаг = связь кого трогаем create|update|delete от записанных
389
+ (только update)
286
390
  ```
287
391
 
288
392
  - **Контекст-шаги** (шаги до операции): каждый резолвится в **ровно одну** сущность —
289
393
  id-фильтром (без запроса) или уникальным фильтром (0 или >1 → ошибка). Дают: `links`
290
- при INSERT и containment-фильтр целей при UPDATE/DELETE.
291
- - **ФИЛЬТР шага операции** — режим:
394
+ при `create` и containment-фильтр целей при `update`/`delete`.
395
+ - **Режим выбирает глагол** (фильтр-объект допустим только перед `update`):
292
396
 
293
- | Фильтр шага операции | data.id | Действие |
397
+ | Глагол | Шаг операции | Действие |
294
398
  |---|---|---|
295
- | `Класс()` — без аргумента | нет | **INSERT** (id = uuid), links = контекст |
296
- | `Класс()` — без аргумента | есть | **UPSERT** по `data.id` |
297
- | `Класс('id')` — строка | — | **UPSERT** по этому id |
298
- | `Класс({...поля})` — объект | — | **UPDATE**: новая версия каждого найденного (в границах контекста); пусто → `[]` |
299
- | `Класс({})` — **пустой объект** | — | **UPDATE всех** в границах контекста |
399
+ | `create(data?)` | `Класс()` — без фильтра | **INSERT**: id по правилу схемы (§ 3.2), links = контекст + слоты |
400
+ | | `Класс(id)` / `data.id` / вычисленный v5-id **уже существует** | **новая версия** (идемпотентный create, REST-PUT семантика); не существует → INSERT с этим id |
401
+ | | `Класс({фильтр})` | ошибка ПОСТРОЕНИЯ `create() takes no filter`; create на pivot-шаге — тоже ошибка |
402
+ | `update(data?)` | `Класс()` ≡ `Класс({})` | новая версия **всех** в границах контекста |
403
+ | | `Класс(id)` / `Класс({поля})` / pivot | новая версия **каждого** найденного путём; не найдено → `[]` — update НИКОГДА не создаёт |
300
404
 
301
- - **Продолжение после операции** — от её результата: класс-шаг = переход (INSERT-цель
405
+ - **Продолжение после операции** — от её результата: класс-шаг = переход (create-цель
302
406
  следующего сегмента получает связь на записанное; чтение — обычный hop). При
303
407
  множественном результате следующий сегмент исполняется **для каждой строки** (fan-out).
304
408
  После `delete` продолжение идёт от строк класса цели.
305
- - **DATA** — поля сущности/связи; `id` передаётся здесь (`{ id: busyId, … }`).
409
+ - **DATA** — поля сущности/связи; явный `id` — здесь (`{ id, … }`) или шагом `Класс(id)`;
410
+ у v5-класса явный id запрещён — его считает схема (§ 3.2).
306
411
  Валидация **строгая, всегда**: поле вне `Schema.attributes` → `ValidationError`;
307
412
  default-ы схемы подставляются; id в `data` не хранится (он — колонка).
308
- - **Deep-merge при UPDATE**: меняются только указанные листья —
309
- `set({ price: { RUB: 1100 } })` сохранит `USD/EUR` и остальные поля.
310
- Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
311
- - **Концы LINK**: объявленные в `Schema.links` обязательны; значения — id или Row.
413
+ - **Deep-merge при `update`** (и у create-версии по известному id): меняются только
414
+ указанные листья — `update({ coordinates: { lat: 55.8 } })` сохранит `lng` и остальные
415
+ поля. Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
416
+ - **Концы LINK**: по `Schema.links` v2 (§3.1) — обязательные требуются, союз занимает один ключ, лишние связи — ошибка; значения — id, Row или вложенная цепочка (§6.1).
312
417
  - **account/owner NOT NULL**: `.account()/.owner()` → `connect()` → System-аккаунт.
313
418
  - Версии монотонны (`GREATEST(clock, prev+1µs)`), коллизия 23505 ретраится.
314
419
  - Цепочка переиспользуема: повторный терминал = повторное исполнение плана.
315
420
 
316
- ### Связи: контекст и `.link()`
421
+ ### 6.1 Связи: путь и слоты `.Класс.set()` / `.Класс.unset()`
422
+
423
+ Один закон: **владелец создаваемой связки берётся из валидного пути; прочие концы —
424
+ слотами** `.Класс.set(значение)` после глагола записи. Слот — свойство-класс БЕЗ вызова
425
+ (`.Клиент.set(x)`, не `.Клиент(x)`); валиден только для конца из `Schema.links` и
426
+ требует операцию записи ПЕРЕД собой: `…create(…).Класс.set(x)` / `…update(…).Класс.unset()`;
427
+ слот без глагола — ошибка `link slot needs a write`.
317
428
 
318
429
  ```ts
319
- // 1) контекст ДО операции: шаги-связи, затем класс-цель (значение + фильтр целей)
320
- await db.Сотрудник(s).Окно(w).Запись(b).занятость().set({ id: busyId, kind: 'booking' }).rows()
430
+ // СОЗДАНИЕ: владелец (Мастер) — из пути, остальные концы — слотами; id считает схема (§ 3.2)
431
+ await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
432
+ .Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
433
+
434
+ // навык: владелец Мастер из пути, предмет — слотом
435
+ await db.Мастер(s).навык().create({ level: 'expert' }).Услуга.set(у).rows()
321
436
 
322
- // 2) .link(Класс, target) — ТОЛЬКО значение связи, в фильтр целей не входит:
323
- await db.занятость(busyId).link('Сотрудник', s).link('Окно', w).link('Запись', b)
324
- .set({ kind: 'booking' }).rows()
437
+ // ПЕРЕВЕС связи (update): цель ищется путём/pivot, слот пишет новое значение БЕЗ фильтра
438
+ await tr.Клиент(к).запись().Услуга(старая).запись().update().Услуга.set(новая).rows()
325
439
 
326
- // разница видна на UPDATE: сменить исполнителя ВСЕХ позиций записи —
327
- await tr.Запись(b).позиция({}).link('Сотрудник', новый).set({}).rows()
328
- // контекст-шаг Сотрудник(новый) тут не годится: он ФИЛЬТРОВАЛ бы позиции по старой связи
440
+ // СОЮЗ-конец [Услуга|Товар|Комплекс]: слот замещает конец целиком (соседний класс снимается)
441
+ await db.запись(b).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
442
+
443
+ // СНЯТИЕ optional-конца: ручная бронь — клиент отвязан, имя в notes
444
+ await db.запись(b).update({ notes: 'по телефону: Аня' }).Клиент.unset().rows()
445
+
446
+ // вложенная цепочка как значение слота — та же транзакция, ровно одна сущность:
447
+ await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
448
+ .Локация.set(л).Расписание.set(р)
449
+ .Услуга.set(db.Организация(org).Услуга().create({ name: 'Новинка', duration: 45 })).rows() // создать И привязать
329
450
  ```
330
451
 
331
452
  ### `.delete({ confirm })` — превью и серверное удаление
332
453
 
333
454
  ```ts
334
- const превью = await db.Запись(bid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
335
- // кандидаты (цель + каскад), живые, БД не тронута:
336
- // [{class:'Booking'}, {class:'busy'}, {class:'item'}]
455
+ const превью = await db.Клиент(cid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
456
+ // кандидаты (цель + каскад: записи клиента), живые, БД не тронута:
457
+ // [{class:'Customer'}, {class:'booking'}]
337
458
 
338
- const удалено = await db.Запись(bid).delete({ confirm: true }).rows()
459
+ const удалено = await db.Клиент(cid).delete({ confirm: true }).rows()
339
460
  // удалённое дерево, каждый Row с $deleted: true
340
461
 
341
- await db.Запись(b).занятость().delete({ confirm: true }).rows() // контекст: только занятости записи
462
+ await db.Мастер(m).окно().delete({ confirm: true }).rows() // контекст: только окна мастера
342
463
  ```
343
464
 
344
465
  Цели = фильтр шага + контекст-связи. Замыкание (цели + все живые зависимые) собирается
345
466
  одним CTE; `confirm: true` шлёт один SQL `DELETE` — триггер БД тумбстоунит дерево
346
- (+advisory-lock). История неприкосновенна; повторный delete → `[]`; `set()` по тому же
467
+ (+advisory-lock). История неприкосновенна; повторный delete → `[]`; `create()` с тем же
347
468
  id — воскрешение.
348
469
 
349
470
  ### Откат плана
350
471
 
351
472
  ```ts
352
- await db.Клиент(id).set({ name: 'Новое' }) // валидный сегмент…
353
- .Запись().set({ чепуха: 1 }) // …невалидный: ValidationError
473
+ await db.Организация(org).update({ phone: '+7 495 …' }) // валидный сегмент…
474
+ .Услуга().create({ name: 'X', чепуха: 1 }) // …невалидный: ValidationError
354
475
  .rows()
355
- // ← ОТКАТ ВСЕГО: имя клиента не изменилось, версий не прибавилось
476
+ // ← ОТКАТ ВСЕГО: телефон не изменился, версий не прибавилось
356
477
  ```
357
478
 
358
479
  ---
@@ -360,35 +481,41 @@ await db.Клиент(id).set({ name: 'Новое' }) // валидный
360
481
  ## 7. Транзакции
361
482
 
362
483
  ```ts
484
+ const bookingId = uuidv5(`v1.booking:entity:booking:${staffId}:${start}`) // id известен ДО создания (§ 3.2)
485
+
363
486
  const tr = await db.begin() // тот же API на выделенном соединении
364
- await tr.lock('busy', staffId, slotId) // advisory-xact-lock до конца транзакции
365
- const занято = await tr.занятость(busyId).first()
366
- if (!занято) await tr.Сотрудник(s).Окно(w).занятость(busyId).set({ kind: 'booking' }).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()
367
491
  await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()
368
492
  ```
369
493
 
370
494
  Повторный commit/rollback — no-op. `lock()` вне транзакции — ошибка. Держите транзакции
371
- короткими. **Рецепт двойной брони**: `lock(мастер, окно)` + детерминированный id занятости +
372
- перечитать `first()` под локом → параллельная транзакция видит бронь и отказывает
373
- (ровно одна успешна — покрыто тестом-гонкой).
495
+ короткими. **Рецепт двойной брони**: id записи детерминирован схемой (v5 — § 3.2), так
496
+ что дубль невозможен в принципе (второй `create` стал бы версией той же записи);
497
+ `lock(мастер, старт)` + перечитка `first()` под локом нужны, чтобы сопернику честно
498
+ ОТКАЗАТЬ, а не молча версионировать чужую бронь (ровно одна успешна — покрыто тестом-гонкой).
374
499
 
375
500
  ---
376
501
 
377
502
  ## 8. Батчи
378
503
 
379
504
  ```ts
380
- db.batch('окна').Расписание(sch).Окно().set({ start, end }) // план встал в очередь
381
- db.batch('окна').Расписание(sch).Окно().set({ … })
382
- db.batch('окна').size() // 2
383
- const res = await db.batch('окна').run() // одна транзакция; Row[][] по порядку
384
- 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() // отменить
385
511
  ```
386
512
 
387
513
  - `run()` атомарен: любая ошибка откатывает всё.
388
514
  - Операции в батч-цепочке кладут ПЛАН в очередь (многосегментные планы — одним элементом);
389
515
  терминал на такой цепочке — ошибка `plan is queued in the batch — call batch.run()`.
390
- - Подряд идущие чистые INSERT одного класса (один шаг, пустой фильтр, без data.id и `.link`)
391
- склеиваются в один multi-VALUES.
516
+ - Подряд идущие чистые `create()` одного класса (план из одного шага, без `data.id` и
517
+ слотов) склеиваются в один multi-VALUES; **v5-классы — поштучно** (id считается из
518
+ концов/полей — § 3.2), семантика та же.
392
519
  - Read-вызовы на батч-фасаде исполняются сразу, мимо очереди.
393
520
 
394
521
  ---
@@ -506,7 +633,7 @@ lockout после N неудач. Legacy-bcrypt-хэши (`$2b$…` из дам
506
633
  |---|---|---|
507
634
  | `ACCOUNT` | аккаунта | `{"categories": "{Staff}"}`; `"!{Anonymous,Shadow}"` — нет ни одной; NULL — все |
508
635
  | `API` | адреса эндпоинта | `{"endpoint": "v2.auth.apikey.*"}` — маска: `.`-сегменты, `{a,b}`, `*` — хвост |
509
- | `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "Booking", "owner": "$account"}` |
636
+ | `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "booking", "owner": "$account"}` |
510
637
 
511
638
  Pattern операций — реальные колонки Entity (`class`, `owner`, `account`, `tags`, `data`,
512
639
  `links`…), значения — литералы или `"$account"` (id субъекта, подставляется в запрос);
@@ -523,7 +650,7 @@ await db.acl.check(anon, 'v2.auth.password.signup')
523
650
  await db.acl.check(anon, 'v2.auth.apikey.create')
524
651
  // → { allow: false, code: 403, message: 'Access denied - default …' } — победило дно-правило deny 0
525
652
 
526
- await db.acl.checkData(user, 'Booking', 'READ') // ресурс {"class":"Booking","owner":"$account"}
653
+ await db.acl.checkData(user, 'booking', 'READ') // ресурс {"class":"booking","owner":"$account"}
527
654
  // → { allow: true, filter: { owner: '24c49a43-…' }, rule: {…weight: 60} } [1.7 ms]
528
655
  await db.acl.checkData(user, 'SportsCar', 'READ') // право дал предок Vehicle (lineage)
529
656
  // → { allow: true } — безусловный, без предиката
@@ -533,35 +660,36 @@ db.acl.reload() // сброс кэша Resou
533
660
  ```
534
661
 
535
662
  **`connect({ account, enforceAcl: true })`** — те же решения в цепочках: каждый шаг —
536
- `READ`, `set()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
663
+ `READ`, `create()`/`update()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
537
664
  (deny в замыкании откатывает транзакцию); `watch()` отдаёт события только безусловных
538
665
  allow-классов (payload нечем проверить предикат). Правила фиксируются на connect.
539
666
 
540
667
  ```ts
541
668
  const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
542
- await u.Запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
543
- await u.Запись().count() // честный count по суженному множеству
669
+ await u.запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
670
+ await u.запись().count() // честный count по суженному множеству
544
671
  await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
545
- const [z] = await u.Запись().set({}) // owner пришпилен правилом → user.id
546
- await u.Запись().owner(other).set({}) // Error: acl pins Booking writes to owner …
547
- await u.Запись(чужаяId).set({ status: '…' }) // → [] — цель вне предиката не находится
548
- // и не перехватывается: «не нашёл среди своих» при существующей чужой НЕ превращается в INSERT
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 чужой записи) тоже НЕ перехватывает: → [] вместо новой версии
549
676
  ```
550
677
 
551
- Оверхед (article-полигон 1.1M, p50 из 20; `bench/acl.bench.mjs`):
678
+ Оверхед (полигон `v1.salondemo` ~980k, booking 440k×2; p50 из 20; `bench/acl.bench.mjs`):
552
679
 
553
680
  | Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
554
681
  |---|---|---|---|
555
- | точечный `first(id)` | 2.2 ms | 3.1 ms | 3.5 ms |
556
- | фильтр `rows` limit 100 | 119.8 ms | 121.6 ms | 119.9 ms |
557
- | keyset-страница всего класса 250k | 1619 ms | 1652 ms | 2155 ms |
558
- | `count()` класса 250k | 440 ms | 453 ms | 875 ms |
559
- | цепочка 2 шага | 5.5 ms | 6.6 ms | 7.0 ms |
560
-
561
- Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат замерен в
562
- worst-case (пропускает 100% строк — чистый оверхед доп. условия на full-scan);
563
- в реальности предикат СУЖАЕТ выборку, и тяжёлые сцены становятся ДЕШЕВЛЕ, чем без ACL.
564
- `db.acl.checkData` — справочный (1.3 ms: SQL за категориями аккаунта на каждый вызов);
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 за категориями аккаунта на каждый вызов);
565
693
  горячий путь цепочек использует резолвер, скомпилированный на connect (микросекунды, memo).
566
694
 
567
695
  `enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
@@ -572,8 +700,8 @@ worst-case (пропускает 100% строк — чистый оверхед
572
700
 
573
701
  ```ts
574
702
  await db.Услуга().rows() // срез класса (актуальное, живое)
575
- await db.Запись().sort('updated','desc').limit(50).rows()
576
- await db.Клиент(cid).Запись().позиция().run() // связный подграф
703
+ await db.запись().sort('updated','desc').limit(50).rows()
704
+ await db.Клиент(cid).запись().Услуга().run() // связный подграф
577
705
  ```
578
706
 
579
707
  ```sql
@@ -583,7 +711,7 @@ SELECT * FROM (SELECT DISTINCT ON (class, id) * FROM "v1.booking"."Entity"
583
711
 
584
712
  -- история сущности (все версии, включая tombstone)
585
713
  SELECT updated, deleted, data, links FROM "v1.booking"."Entity"
586
- 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;
587
715
  ```
588
716
 
589
717
  ### 10.1 Время-путешествия: `.asOf()` / `.versions()`
@@ -591,11 +719,11 @@ WHERE partition='entity' AND class='Booking' AND id=$1 ORDER BY updated;
591
719
  ```ts
592
720
  // «какая цена была на момент брони» — версии позже T невидимы, tombstone до T = «удалён»
593
721
  const тогда = await db.Услуга(id).asOf('2026-07-01T12:00:00Z').first()
594
- const срезДня = await db.Запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
722
+ const срезДня = await db.запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
595
723
 
596
724
  // вся история сущности без сырого SQL (tombstone-версии приходят с $deleted: true)
597
- const история = await db.Запись(id).versions()
598
- // [{data:{status:'created'}}, {data:{status:'confirmed'}}, {…, $deleted:true}]
725
+ const история = await db.запись(id).versions()
726
+ // [{data:{…}}, {data:{…, notes:'подтверждена'}}, {…, $deleted:true}]
599
727
  ```
600
728
 
601
729
  `asOf` применяется к каждому шагу цепочки — подграф целиком «как был». `versions()`
@@ -609,19 +737,19 @@ Keyset — закладка: курсор = значение поля сорти
609
737
  порядок тотальным, дубли значений не теряются и не повторяются).
610
738
 
611
739
  ```ts
612
- const стр1 = await db.Запись().sort('updated', 'desc').limit(50).rows()
740
+ const стр1 = await db.запись().sort('updated', 'desc').limit(50).rows()
613
741
  const кур = cursorOf(стр1.at(-1)!) // { v: '<updated>', id: '…' } — просто объект,
614
- const стр2 = await db.Запись().sort('updated', 'desc') // можно хранить в URL/state
742
+ const стр2 = await db.запись().sort('updated', 'desc') // можно хранить в URL/state
615
743
  .after(кур).limit(50).rows()
616
744
 
617
745
  // по data-пути ЛЮБОЙ глубины — field тот же, что в sort
618
- const дальше = await db.Окно().sort('data.start')
619
- .after(cursorOf(окно, 'data.start')).limit(20).rows()
746
+ const дальше = await db.окно().sort('data.start_datetime')
747
+ .after(cursorOf(окно, 'data.start_datetime')).limit(20).rows()
620
748
 
621
749
  // бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
622
750
  let cursor
623
751
  do {
624
- const page = await db.Запись({ status: 'confirmed' }).sort('updated', 'desc')
752
+ const page = await db.Мастер(m).запись().sort('updated', 'desc')
625
753
  .after(cursor).limit(50).rows()
626
754
  render(page)
627
755
  cursor = page.length ? cursorOf(page.at(-1)!) : undefined
@@ -638,14 +766,14 @@ do {
638
766
  ### 10.3 Агрегации: считает БД
639
767
 
640
768
  ```ts
641
- await db.Запись({ status: 'completed' }).sum('data.total.RUB') // выручка: number | null
642
- await db.Услуга().avg('data.duration') // среднее
643
- await db.Запись().countBy('data.status') // { confirmed: 12, cancelled: 3 } ({} на пустом)
644
- 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
645
773
  ```
646
774
 
647
- Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или record-лист
648
- `'data.total.RUB'`. `sum`/`avg` кастуются в numeric; пустое множество → `null`.
775
+ Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или вложенный лист
776
+ (`'data.coordinates.lat'`). `sum`/`avg` кастуются в numeric; пустое множество → `null`.
649
777
 
650
778
  ### 10.4 Деревья: `.deep()`
651
779
 
@@ -661,7 +789,7 @@ reverse = дети); на другом переходе — ошибка. Раб
661
789
  ### 10.5 Realtime: `db.watch()`
662
790
 
663
791
  ```ts
664
- const stop = await db.watch('Запись', (e) => {
792
+ const stop = await db.watch('запись', (e) => {
665
793
  // e = { partition, class, id, updated, deleted } — факт версии (insert/update/tombstone)
666
794
  обновитьКалендарь(e.id)
667
795
  })
@@ -677,7 +805,7 @@ await stop() // отписка
677
805
  повторяет LISTEN), но `NOTIFY` за время разрыва потеряны — для этого `onReconnect`:
678
806
 
679
807
  ```ts
680
- const stop = await db.watch('Запись', onEvent, {
808
+ const stop = await db.watch('запись', onEvent, {
681
809
  onReconnect: () => дочитатьПропущенное(), // напр. перечитать всё с последнего e.updated
682
810
  })
683
811
  ```
@@ -686,9 +814,9 @@ const stop = await db.watch('Запись', onEvent, {
686
814
 
687
815
  ```ts
688
816
  const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
689
- await db.Запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
690
- await db.Клиент().set({ name: 'X' }) // запись пришпилена к account
691
- 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 …
692
820
  ```
693
821
 
694
822
  Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
@@ -698,7 +826,7 @@ db.Запись().account(чужой).rows() // ошибка: reads are p
698
826
  ### 10.7 Анонимизация (GDPR): `.anonymize()`
699
827
 
700
828
  ```ts
701
- await db.Клиент(id).anonymize(['name', 'contact']).rows()
829
+ await db.Клиент(id).anonymize(['name', 'phone']).rows()
702
830
  // новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
703
831
  ```
704
832
 
@@ -725,7 +853,7 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
725
853
  ### 10.9 Миграции классов: `scripts/schema-sync.mjs`
726
854
 
727
855
  ```bash
728
- 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
729
857
  # schema-sync: … ↔ booking.Schema (partition entity)
730
858
  # + Coupon (HUB · Купон) — новый класс
731
859
  # ~ Service — изменены: attributes
@@ -763,7 +891,7 @@ npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d
763
891
  ```ts
764
892
  import type { TypedDb } from './entity-types'
765
893
  const t = db as unknown as TypedDb
766
- const [svc] = await t.Услуга({ active: true }).rows() // svc.data.duration: number
894
+ const [svc] = await t.Услуга({ duration: 60 }).rows() // svc.data.duration: number
767
895
  ```
768
896
 
769
897
  Интерфейсы data-полей всех классов (enum → union-литералы, record → `Partial<Record<…>>`)
@@ -800,15 +928,16 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
800
928
 
801
929
  Каждый метод описан по одной схеме: **сигнатура → параметры → назначение и алгоритм →
802
930
  примеры (под каждым вызовом реальный ответ и время) → полный кейс**. Все ответы и тайминги —
803
- **живой прогон** на article-полигоне 1.11M строк (год истории: 250k записей ×2 версии,
804
- 250k занятостей, 250k позиций); воспроизводитель — `bench/api-reference-demo.mjs`
805
- (мутирует только свои сущности, полигон не пересоздаёт). id сокращены:
806
- `…0021` = `00000000-0000-4000-8000-000000000021`; повторяющиеся `account`/`owner`/`partition`
807
- в ответах опущены.
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` в ответах опущены.
808
937
 
809
938
  Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
810
939
  [11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
811
- [11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-set--delete--anonymize) ·
940
+ [11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-create--update--delete--anonymize) ·
812
941
  [11.6 Транзакции](#116-транзакции-entitytx) · [11.7 Батчи](#117-batch) ·
813
942
  [11.8 Таблицы](#118-таблицы-accounts--credentials--resources--rules) ·
814
943
  [11.9 db.auth](#119-dbauth--вход-и-сессии) · [11.10 db.acl](#1110-dbacl) ·
@@ -842,23 +971,23 @@ NOT NULL `Entity.account`. При `enforceAcl: true` дополнительно
842
971
  **Примеры**
843
972
 
844
973
  ```ts
845
- const db = await connect({ dsn, schema: 'v1.article', onQuery: (e) => log(e) }) // [34.4 ms]
846
- await db.Услуга('…0021').first()
847
- // событие onQuery: {"mode":"rows","classes":["Service"],"ms":13.1,"rows":1,"slow":false}
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}
848
977
 
849
- const dbSlow = await connect({ dsn, schema: 'v1.article', slowMs: 200, onQuery: … }) // [32.8 ms]
850
- await dbSlow.Запись().count() // 250k сущностей — дольше порога:
851
- // {"mode":"count","classes":["Booking"],"ms":491.9,"rows":1,"slow":true}
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}
852
981
  ```
853
982
 
854
983
  **Кейс: три подключения — обычное, изолированное, под ACL**
855
984
 
856
985
  ```ts
857
- const db = await connect({ dsn, schema }) // [34.4 ms]
858
- const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [49.7 ms]
859
- const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [72.3 ms]
860
- await db.Организация().count() // → 21 — видит всех
861
- await iso.Организация().count() // [4.3 ms] → 1 — только свой арендатор
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 — только свой арендатор
862
991
  await uc.Организация().rows()
863
992
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default)
864
993
  await iso.close(); await uc.close()
@@ -931,14 +1060,14 @@ await up({ dsn, schema: 'v1.booking', version: 1 })
931
1060
 
932
1061
  ```ts
933
1062
  const db = await up({ schema: 'booking', version: 1, dsn }) // [7.4 s] образ+контейнер+схема
934
- await db.Клиент().count() // → 0 — свежая схема
935
- const [аня] = await db.Клиент().set({ name: 'Аня', contact: '+7 900' }).rows()
1063
+ await db.Организация().count() // → 0 — свежая схема
1064
+ await db.Организация().create({ name: 'BarberPro' }).rows()
936
1065
  await db.close()
937
1066
 
938
1067
  // … docker stop letopis-timescale (ребут, уборка, что угодно) …
939
1068
 
940
1069
  const db2 = await up({ schema: 'booking', version: 1, dsn }) // [1.2 s] start + reuse
941
- await db2.Клиент().count() // → 2 — volume letopis-pgdata: всё на месте
1070
+ await db2.Организация().count() // → 1 — volume letopis-pgdata: всё на месте
942
1071
  await db2.close()
943
1072
  ```
944
1073
 
@@ -947,6 +1076,7 @@ await db2.close()
947
1076
  ```ts
948
1077
  import {
949
1078
  connect, up, cursorOf, totpCode, // функции
1079
+ uuidv5, uuidv7, LETOPIS_NS, // генерация id (§ 3.2): формула v5 открыта
950
1080
  ne, gt, gte, lt, lte, between, inList, like, ilike, // операторы (18)
951
1081
  starts, ends, has, hasAny, hasAll, exists, isNull, not, or,
952
1082
  ValidationError, Registry, // классы
@@ -971,21 +1101,21 @@ import type {
971
1101
 
972
1102
  | Параметр | Тип | Описание |
973
1103
  |---|---|---|
974
- | `Класс` | имя свойства | id или алиас класса из `Schema` (`db.Booking` ≡ `db.Запись`); неизвестное имя — ошибка со списком классов |
1104
+ | `Класс` | имя свойства | id или алиас класса из `Schema` (`db.booking` ≡ `db.запись`); неизвестное имя — ошибка со списком классов |
975
1105
  | `filter` | `Filter?` | без аргумента — весь класс; `string` — по id; `string[]` — по списку id; `Row`/объект с `.id` — как id; объект — поля `data` (равенство, операторы § 11.4, record-пути) + ключ `id` |
976
1106
 
977
1107
  **Назначение и алгоритм.** Старт цепочки чтения/записи (§ 4–6). Ничего не выполняет —
978
1108
  только копит шаги; SQL строится и уходит в БД одним запросом на терминале
979
- (`rows/execute/count/…` — § 11.3). Формы `filter` нормализуются сразу: объект с `.id`
1109
+ (`rows/run/count/…` — § 11.3). Формы `filter` нормализуются сразу: объект с `.id`
980
1110
  сворачивается в строку-id.
981
1111
 
982
1112
  **Примеры**
983
1113
 
984
1114
  ```ts
985
- await db.Услуга('…0021').first() // [2.6 ms] по id → Row {name: 'Стрижка', …}
986
- await db.Услуга(['…0021', '…0022']).rows() // [2.6 ms] по списку → 2 Row
987
- await db.Организация(org).first() // [2.5 ms] Row-объект ≡ его id → 'BarberPro'
988
- await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 301
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 (фильтр по цене)
989
1119
  await db.НетТакогоКласса().rows()
990
1120
  // Error: letopis: unknown class "НетТакогоКласса". Known: Entity·Сущность, Org·Организация, …
991
1121
  ```
@@ -993,10 +1123,10 @@ await db.НетТакогоКласса().rows()
993
1123
  **Кейс: одна сущность тремя формами фильтра**
994
1124
 
995
1125
  ```ts
996
- const поId = await db.Услуга('…0021').first() // [2.6 ms] → data.name = 'Стрижка'
997
- const поПолю = await db.Услуга({ name: 'Стрижка' }).first() // тот же Row
998
- const поОбъекту = await db.Услуга(поId).first() // Row как фильтр ≡ его id
999
- // все три → id '…0021', цена {RUB: 1800, USD: 20}
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)
1000
1130
  ```
1001
1131
 
1002
1132
  #### `db.begin(): Promise<EntityTx>`
@@ -1015,19 +1145,20 @@ deadlock/serialization ошибка приходит сразу с подска
1015
1145
 
1016
1146
  ```ts
1017
1147
  const tr = await db.begin() // [0.8 ms]
1018
- await tr.Организация('…0901').Услуга().set({ id: '…0922', name: 'Укладка', duration: 15, price: { RUB: 700 } }).rows()
1019
- // [7.0 ms] → [Row] — видно ТОЛЬКО внутри tr
1020
- await tr.commit() // [2.7 ms] — теперь видно всем
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] — теперь видно всем
1021
1151
  ```
1022
1152
 
1023
1153
  **Кейс: атомарный перенос с откатом при провале** — § 11.6 (`tr.lock`), плюс rollback:
1024
1154
 
1025
1155
  ```ts
1156
+ const [цУкл] = await db.Услуга({ name: 'Укладка' }).цена().create({ amounts: { RUB: 700 } }).rows() // базовая цена
1026
1157
  const tr = await db.begin()
1027
- await tr.Услуга('…0922').set({ price: { RUB: 9900 } }).rows()
1028
- await tr.Услуга('…0922').first() // внутри tx → price.RUB = 9900
1029
- await tr.rollback() // [0.8 ms]
1030
- await db.Услуга('…0922').first() // снаружи → price.RUB = 700 — изменение исчезло
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 — изменение исчезло
1031
1162
  ```
1032
1163
 
1033
1164
  #### `db.commit(tr): Promise<void>` / `db.rollback(tr): Promise<void>`
@@ -1042,7 +1173,7 @@ await db.Услуга('…0922').first() // снаружи → price.RUB = 700
1042
1173
  **Примеры**
1043
1174
 
1044
1175
  ```ts
1045
- await db.commit(tr3) // [2.9 ms] — то же, что tr3.commit()
1176
+ await db.commit(tr3) // [4.0 ms] — то же, что tr3.commit()
1046
1177
  await db.commit()
1047
1178
  // Error: letopis: commit() needs a transaction: db.commit(tr) or tr.commit() [0.1 ms]
1048
1179
  ```
@@ -1068,14 +1199,14 @@ Tombstone-версия приходит с `deleted: true`.
1068
1199
  **Примеры**
1069
1200
 
1070
1201
  ```ts
1071
- const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [34.8 ms]
1072
- await db.Организация('…0901').Клиент().set({ id: '…0932', name: 'Пётр §11' }).rows()
1073
- await db.Клиент('…0932').set({ name: 'Пётр Второй' }).rows()
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()
1074
1205
  await db.Клиент('…0932').delete({ confirm: true }).rows()
1075
1206
  // пойманные (3 события: insert → update → tombstone):
1076
- // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.775349+00:00', deleted: false }
1077
- // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.799888+00:00', deleted: false }
1078
- // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.823072+00:00', deleted: true }
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 }
1079
1210
  await stop()
1080
1211
  ```
1081
1212
 
@@ -1087,8 +1218,8 @@ const stop = await db.watch('Клиент', (e) => события.push(e), {
1087
1218
  })
1088
1219
  // авария: pg_terminate_backend по LISTEN-соединению …
1089
1220
  // onReconnect сработал через 0.1 s после обрыва (реальный прогон)
1090
- await db.Клиент('…0932').set({ name: 'Пётр после обрыва' }).rows()
1091
- // событие после 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 }
1092
1223
  await stop()
1093
1224
  ```
1094
1225
 
@@ -1098,7 +1229,7 @@ await stop()
1098
1229
  падают ошибкой postgres.js.
1099
1230
 
1100
1231
  ```ts
1101
- await db.close() // [1.7 ms]
1232
+ await db.close() // [1.3 ms]
1102
1233
  ```
1103
1234
 
1104
1235
  **Кейс** — завершение процесса: `close()` в `finally`/`SIGTERM`-хендлере после `stop()`
@@ -1110,7 +1241,7 @@ await db.close() // [1.7 ms]
1110
1241
  и поле `all` — § 11.11.
1111
1242
 
1112
1243
  ```ts
1113
- db.registry.resolve('Запись') // [90 µs] → ClassDef {id: 'Booking', alias: 'Запись', …}
1244
+ db.registry.resolve('запись') // [99 µs] → ClassDef {id: 'booking', alias: 'запись', …}
1114
1245
  ```
1115
1246
 
1116
1247
  #### `db.sql`
@@ -1119,8 +1250,8 @@ db.registry.resolve('Запись') // [90 µs] → ClassDef {id: 'Booking', a
1119
1250
  Ответственность за SQL — на вызывающем (движок цепочек его не проверяет).
1120
1251
 
1121
1252
  ```ts
1122
- await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.article"."Entity"')
1123
- // [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 }]
1124
1255
  ```
1125
1256
 
1126
1257
  **Кейс** — снятие плана тяжёлого запроса: `db.sql.unsafe('EXPLAIN (ANALYZE) …')` для
@@ -1146,14 +1277,14 @@ await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.article"."Entity"')
1146
1277
 
1147
1278
  **Назначение и алгоритм.** Продолжение пути по связям: прямой переход (`e.id =
1148
1279
  prev.links->>'Класс'`) кладётся точным равенством, обратный — containment
1149
- `links @> {prevClass: prevId}` в кандидаты + перепроверку. В `set()`-цепочке шаги до
1150
- последнего — **контекст** (§ 11.5).
1280
+ `links @> {prevClass: prevId}` в кандидаты + перепроверку. В цепочке записи шаги до
1281
+ операции — **контекст** (§ 11.5).
1151
1282
 
1152
1283
  **Примеры**
1153
1284
 
1154
1285
  ```ts
1155
- await db.Организация('…0001').Сотрудник().count() // [5.4 ms] → 2 (обратный hop)
1156
- 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, прямой)
1157
1288
  ```
1158
1289
 
1159
1290
  **Кейс: маршрут «мастер → его навыки → услуги»** — см. `.run()` ниже (тот же прогон).
@@ -1170,20 +1301,21 @@ await db.навык().Услуга().count() // [57.2 ms] →
1170
1301
  **Примеры**
1171
1302
 
1172
1303
  ```ts
1173
- await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run() // [18.9 ms]
1174
- // → [{
1175
- // Сотрудник: { id: '…0011', class: 'Staff', data: { name: 'Вася', roles: ['owner','master'], active: true }, links: { Org: '…0001' }, … },
1176
- // навык: { id: '17773ae2-…', class: 'skill', data: {}, links: { Staff: '…0011', Service: '…0021' }, … },
1177
- // Услуга: { id: '…0021', class: 'Service', data: { name: 'Стрижка', price: { RUB: 1800, USD: 20 }, duration: 60, … }, … }
1178
- // }] — 1 путь, полные узлы каждого шага
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
+ // }, …] — по объекту на путь, полные узлы каждого шага
1179
1311
  ```
1180
1312
 
1181
1313
  **Кейс: отчёт «кто что умеет» одним запросом**
1182
1314
 
1183
1315
  ```ts
1184
- const пути = await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run() // [18.9 ms]
1185
- пути.map((p) => `${p.Сотрудник.data.name} → ${p.Услуга.data.name}`)
1186
- // → ['Вася → Стрижка']
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']
1187
1319
  ```
1188
1320
 
1189
1321
  #### `.rows(): Promise<Row[]>`
@@ -1196,17 +1328,18 @@ const пути = await db.Сотрудник({ name: 'Вася' }).навык().
1196
1328
  **Примеры**
1197
1329
 
1198
1330
  ```ts
1199
- const услуги = await db.Услуга().rows() // [8.8 ms] → 402 Row
1200
- // [0] = { id: '…0021', class: 'Service', data: { name: 'Стрижка', price: { RUB: 1800, USD: 20 },
1201
- // active: true, duration: 60, description: 'классика' }, links: { Org: '…0001' }, tags: [],
1202
- // updated: '2026-07-10T18:38:27.40772+00:00' }
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' }
1203
1336
  ```
1204
1337
 
1205
1338
  **Кейс: пути vs уникальные сущности**
1206
1339
 
1207
1340
  ```ts
1208
- await db.навык().Услуга().count() // [57.2 ms] → 1001 путей (навыков на услуги)
1209
- (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 уникальных услуг
1210
1343
  ```
1211
1344
 
1212
1345
  #### `.first(): Promise<Row | null>`
@@ -1214,10 +1347,10 @@ await db.навык().Услуга().count() // [57.2 ms] → 1001 пу
1214
1347
  Параметров нет. То же, что `rows()` с `LIMIT 1`: первая строка или `null`.
1215
1348
 
1216
1349
  ```ts
1217
- await db.Сотрудник({ name: 'Олег' }).first() // [4.9 ms]
1218
- // → { id: '…0012', class: 'Staff', data: { name: 'Олег', roles: ['master'], active: true },
1219
- // links: { Org: '…0001' }, tags: [], updated: '2026-07-10T18:38:27.275231+00:00' }
1220
- await db.Сотрудник({ name: 'Гэндальф' }).first() // [3.9 ms] → null
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
1221
1354
  ```
1222
1355
 
1223
1356
  **Кейс: проверка «занято ли окно» перед бронью** — § 11.6 (перечитка под локом).
@@ -1228,8 +1361,8 @@ await db.Сотрудник({ name: 'Гэндальф' }).first() // [3.9 ms]
1228
1361
  (дешевле по трафику).
1229
1362
 
1230
1363
  ```ts
1231
- await db.Сотрудник().ids() // [3.8 ms] → 302 id
1232
- // ['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', …]
1233
1366
  ```
1234
1367
 
1235
1368
  **Кейс: набор id для батч-обработки** — собрать `ids()`, скормить очереди задач; полные
@@ -1240,21 +1373,21 @@ await db.Сотрудник().ids() // [3.8 ms] → 302 id
1240
1373
  Параметров нет.
1241
1374
 
1242
1375
  **Назначение и алгоритм.** `SELECT count(*)` поверх соединения шагов — считает **пути**
1243
- (как `execute()`), не уникальные сущности: для цепочки из одного класса это одно и то же,
1376
+ (как `run()`), не уникальные сущности: для цепочки из одного класса это одно и то же,
1244
1377
  для многошаговой — нет (см. кейс `.rows()`).
1245
1378
 
1246
1379
  ```ts
1247
- await db.Организация('…0001').Сотрудник().count() // [5.4 ms] → 2
1248
- await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 301
1249
- await db.Организация({ settings: { booking: { deposit: { amount: gte(900) } } } }).count()
1250
- // [2.2 ms] → 0 — оператор на листе глубины 4, каст numeric по Schema
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
1251
1384
  ```
1252
1385
 
1253
1386
  **Кейс: витрина каталога** — счётчики к фильтрам без выборки строк:
1254
1387
 
1255
1388
  ```ts
1256
- await db.Услуга({ duration: lte(45) }).count() // [3.0 ms] → 201 «быстрые»
1257
- 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 «премиум»
1258
1391
  ```
1259
1392
 
1260
1393
  #### `.limit(n): Chain` / `.offset(n): Chain`
@@ -1270,10 +1403,10 @@ await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 3
1270
1403
  **Примеры**
1271
1404
 
1272
1405
  ```ts
1273
- await db.Услуга().sort('data.price.RUB', 'desc').limit(3).rows() // [5.5 ms]
1274
- // → [{ name: 'Стрижка', RUB: 1800 }, { name: 'Услуга 399', RUB: 1599 }, { name: 'Услуга 398', RUB: 1598 }]
1275
- await db.Услуга().sort('data.price.RUB', 'desc').limit(3).offset(3).rows() // [5.3 ms]
1276
- // → [{ RUB: 1597 }, { RUB: 1596 }, { RUB: 1595 }] — вторая страница
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 }] — вторая страница
1277
1410
  ```
1278
1411
 
1279
1412
  **Кейс: классическая пагинация страницы каталога** — `limit(3)` + `offset(3·N)`; при выходе
@@ -1283,21 +1416,21 @@ await db.Услуга().sort('data.price.RUB', 'desc').limit(3).offset(3).rows()
1283
1416
 
1284
1417
  | Параметр | Тип | Описание |
1285
1418
  |---|---|---|
1286
- | `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.price.RUB`); SQL-каст по типу листа из Schema |
1419
+ | `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.coordinates.lat`); SQL-каст по типу листа из Schema |
1287
1420
  | `dir` | `'asc'` \| `'desc'` \| `boolean?` | default `asc`; `true` ≡ `'desc'` |
1288
1421
 
1289
1422
  **Назначение и алгоритм.** `ORDER BY` по колонке `updated` или по выражению
1290
- `data->'price'->>'RUB'` с кастом (numeric/text/timestamptz — из типа листа в Schema).
1423
+ `data->'coordinates'->>'lat'` с кастом (numeric/text/timestamptz — из типа листа в Schema).
1291
1424
  Обязателен для `.after()`.
1292
1425
 
1293
1426
  **Примеры**
1294
1427
 
1295
1428
  ```ts
1296
- await db.Услуга().sort('data.price.RUB', 'desc').limit(3).rows() // [5.5 ms] → 1800, 1599, 1598
1297
- await db.Услуга().sort('data.duration').limit(2).rows() // [6.3 ms] asc по умолчанию
1298
- // → [{ name: 'Королевское бритьё', duration: 30 }, { name: 'Услуга 0', duration: 30 }]
1299
- await db.Запись().sort('updated', 'desc').limit(2).rows() // [1613.1 ms]
1300
- // ЧЕСТНО: DISTINCT ON всех 250k сущностей класса без фильтра — см. § 14
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
1301
1434
  ```
1302
1435
 
1303
1436
  **Кейс: топ прайса** — первый пример; правило объёма: сортировка **всего** большого класса
@@ -1317,15 +1450,15 @@ await db.Запись().sort('updated', 'desc').limit(2).rows() // [161
1317
1450
  **Примеры**
1318
1451
 
1319
1452
  ```ts
1320
- const истор = await db.Услуга('…0021').versions() // [5.1 ms] (для t1 ниже)
1321
- await db.Услуга('…0021').asOf(истор[0].updated).first() // [4.8 ms]
1322
- // → data.price = { RUB: 1500 } — цена ТОГДА
1323
- await db.Услуга('…0021').first()
1324
- // → data.price = { RUB: 1800, USD: 20 } — цена сейчас
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 — цена сейчас
1325
1458
  ```
1326
1459
 
1327
1460
  **Кейс: спор по чеку** — «сколько стоила стрижка в момент оформления записи»:
1328
- `db.Услуга(id).asOf(запись.updated).first()` → исторический прайс без отдельных таблиц аудита.
1461
+ `db.цена(id).asOf(запись.updated).first()` → исторический прайс без отдельных таблиц аудита.
1329
1462
 
1330
1463
  #### `.versions(): Promise<Row[]>`
1331
1464
 
@@ -1339,14 +1472,12 @@ await db.Услуга('…0021').first()
1339
1472
  **Примеры**
1340
1473
 
1341
1474
  ```ts
1342
- await db.Услуга('…0021').versions() // [5.1 ms]
1343
- // → [{ price: { RUB: 1500 }, updated: '…27.284125' },
1344
- // { price: { RUB: 1800, USD: 20 }, updated: '…27.40772' }]
1345
- await db.Запись('…0061').versions() // [6.7 ms] — жизнь с удалением и воскрешением:
1346
- // → [{ status: 'created', updated: '…27.438002' },
1347
- // { status: 'confirmed', updated: '…27.521769' },
1348
- // { status: 'confirmed', updated: '…33.652394', $deleted: true }, ← tombstone
1349
- // { status: 'created', updated: '…33.822616' }] ← воскрешение
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)
1350
1481
  ```
1351
1482
 
1352
1483
  **Кейс: аудит «кто когда менял»** — `versions()` + `owner` каждой версии = полный
@@ -1368,18 +1499,19 @@ await db.Запись('…0061').versions() // [6.7 ms] — жизнь с уд
1368
1499
  **Примеры**
1369
1500
 
1370
1501
  ```ts
1371
- const p1 = await db.Запись().sort('updated', 'desc').limit(3).rows() // [1568.3 ms] — 250k
1372
- const кур = cursorOf(p1.at(-1)) // [95 µs]
1373
- // → { v: '2026-07-06T15:46:14.577+00:00', id: '00000008-0000-4000-8000-000000130266' }
1374
- const p2 = await db.Запись().sort('updated', 'desc').after(кур).limit(3).rows() // [1587.8 ms]
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]
1375
1506
  // p2 — следующие 3, пересечение страниц: 0
1507
+ // (у тысяч записей последнего дня updated совпадает — id вторым ключом ORDER BY держит границу)
1376
1508
 
1377
- const курЦены = cursorOf(топ3.at(-1), 'data.price.RUB') // [45 µs] → { v: 1598, id: '…0398' }
1378
- await db.Услуга().sort('data.price.RUB', 'desc').after(курЦены).limit(3).rows() // [8.9 ms]
1379
- // → 1597, 1596, 1595
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
1380
1512
 
1381
- await db.Запись().after(кур).rows()
1382
- // 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]
1383
1515
  ```
1384
1516
 
1385
1517
  **Кейс: бесконечная лента записей**
@@ -1387,7 +1519,7 @@ await db.Запись().after(кур).rows()
1387
1519
  ```ts
1388
1520
  let кур
1389
1521
  for (;;) {
1390
- let q = db.Запись().sort('updated', 'desc').limit(100)
1522
+ let q = db.запись().sort('updated', 'desc').limit(100)
1391
1523
  if (кур) q = q.after(кур)
1392
1524
  const стр = await q.rows()
1393
1525
  if (!стр.length) break
@@ -1410,19 +1542,22 @@ for (;;) {
1410
1542
  **Примеры**
1411
1543
 
1412
1544
  ```ts
1413
- await db.Папка('…0071').Папка().deep().rows() // [13.1 ms]
1414
- // → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Борода и усы', $depth: 2 }]
1415
- await db.Папка('…0071').Папка().deep(1).rows() // [8.3 ms] только прямые дети
1416
- // → [{ name: 'Мужской зал', $depth: 1 }]
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 }]
1417
1550
  ```
1418
1551
 
1419
1552
  **Кейс: хлебные крошки каталога** — дерево одним запросом, глубина из `$depth`:
1420
1553
 
1421
1554
  ```ts
1422
- const дерево = await db.Папка(корень).Папка().deep().rows() // [13.1 ms]
1555
+ const дерево = await db.Папка(корень).Папка().deep().rows() // [9.2 ms]
1423
1556
  дерево.map((p) => `${' '.repeat(p.$depth)}${p.data.name}`)
1424
1557
  // → Мужской зал
1558
+ // Женский зал
1425
1559
  // Борода и усы
1560
+ // Уход
1426
1561
  ```
1427
1562
 
1428
1563
  #### `.sum(field)` / `.avg(field): Promise<number | null>`
@@ -1438,14 +1573,14 @@ const дерево = await db.Папка(корень).Папка().deep().rows(
1438
1573
  **Примеры**
1439
1574
 
1440
1575
  ```ts
1441
- await db.Запись({ status: 'completed' }).sum('data.total.RUB') // [236.9 ms] → 177500000
1442
- await db.Услуга().avg('data.duration') // [4.3 ms] → 52.46268656716418
1443
- await db.Организация({}).sum('data.settings.booking.deposit.amount') // [2.9 ms] лист глубины 4
1444
- await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [4.0 ms] → null (пусто)
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 (пусто)
1445
1580
  ```
1446
1581
 
1447
- **Кейс: выручка за период без выгрузки строк** — `sum` по 50 000 завершённых записей
1448
- за 237 ms; строки в приложение не едут.
1582
+ **Кейс: итог по каталогу без выгрузки строк** — `sum('data.amounts.RUB')` по 405 ценам за 4 ms;
1583
+ на полигоне salondemo — 1026 цен на 2 277 000 за ≈13 ms (§ 14). Строки в приложение не едут.
1449
1584
 
1450
1585
  #### `.min(field)` / `.max(field): Promise<unknown>`
1451
1586
 
@@ -1453,8 +1588,8 @@ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') /
1453
1588
  **числом**, string — строкой.
1454
1589
 
1455
1590
  ```ts
1456
- await db.Услуга().min('data.price.RUB') // [3.6 ms] → 800 (число, не '800')
1457
- 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
1458
1593
  ```
1459
1594
 
1460
1595
  **Кейс: границы ценового слайдера** — `min` + `max` двумя запросами по 3–4 ms.
@@ -1469,24 +1604,25 @@ await db.Услуга().max('data.price.RUB') // [4.2 ms] → 1800
1469
1604
  объекта = значения поля, значения = счётчики (по путям).
1470
1605
 
1471
1606
  ```ts
1472
- await db.Запись().countBy('data.status') // [575.8 ms] — 250k сущностей
1473
- // → { 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%)
1474
1610
  ```
1475
1611
 
1476
- **Кейс: дашборд статусов** — один запрос вместо N `count()`; 250k строк агрегирует БД.
1612
+ **Кейс: дашборд по заметкам записей** — один запрос вместо N `count()`; ~440k строк агрегирует БД.
1477
1613
 
1478
1614
  #### `.alias(name): Chain`
1479
1615
 
1480
1616
  | Параметр | Тип | Описание |
1481
1617
  |---|---|---|
1482
- | `name` | `string` | ключ ТЕКУЩЕГО шага в объектах-путях `execute()` |
1618
+ | `name` | `string` | ключ ТЕКУЩЕГО шага в объектах-путях `run()` |
1483
1619
 
1484
1620
  **Назначение.** Переименование ключа шага в выводе (данные и SQL не меняются) — удобно,
1485
1621
  когда один класс встречается в пути дважды.
1486
1622
 
1487
1623
  ```ts
1488
- await db.Организация(org).alias('салон').Сотрудник({ name: 'Вася' }).alias('мастер').run()
1489
- // [6.4 ms] → ключи пути: ['салон', 'мастер']
1624
+ await db.Организация(org).alias('салон').Мастер({ name: 'Ольга 0.0' }).alias('мастер').run()
1625
+ // [6.7 ms] → ключи пути: ['салон', 'мастер']
1490
1626
  ```
1491
1627
 
1492
1628
  **Кейс: self-join читаемо** — `db.Папка(a).alias('родитель').Папка().alias('дочка').run()`.
@@ -1495,14 +1631,14 @@ await db.Организация(org).alias('салон').Сотрудник({ na
1495
1631
 
1496
1632
  | Параметр | Тип | Описание |
1497
1633
  |---|---|---|
1498
- | `v` | `string \| string[] \| has/hasAny/hasAll` | чтение: фильтр колонки `tags` (строка ≡ содержит; массив ≡ содержит все); в `set()`-цепочке — **значение** тегов INSERT |
1634
+ | `v` | `string \| string[] \| has/hasAny/hasAll` | чтение: фильтр колонки `tags` (строка ≡ содержит; массив ≡ содержит все); в цепочке записи — **значение** тегов `create()`-INSERT |
1499
1635
 
1500
1636
  **Назначение и алгоритм.** Фильтр по массивной колонке `tags` (`@>` — GIN-индекс,
1501
1637
  кандидаты + перепроверка). В записи — модификатор значения.
1502
1638
 
1503
1639
  ```ts
1504
- await db.Клиент().tags('vip').count() // [3.9 ms] → 1 (среди 30k клиентов)
1505
- 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
1506
1642
  ```
1507
1643
 
1508
1644
  **Кейс: пометить и найти** — § 5 (вставка с `.tags(['vip','telegram'])`, поиск `tags('vip')`);
@@ -1512,15 +1648,15 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms]
1512
1648
 
1513
1649
  | Параметр | Тип | Описание |
1514
1650
  |---|---|---|
1515
- | `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`; в `set()`-INSERT — значение колонки |
1651
+ | `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`; при `create()` — значение колонки |
1516
1652
 
1517
1653
  **Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree). Под `enforceAccount`
1518
1654
  чужой `.account()` — ошибка; под `enforceAcl` конфликт с пришпиленной правилом колонкой —
1519
1655
  ошибка `acl pins` (§ 11.10).
1520
1656
 
1521
1657
  ```ts
1522
- await db.Организация().account(SYS).count() // [3.4 ms] → 21
1523
- 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
1524
1660
  ```
1525
1661
 
1526
1662
  **Кейс: чей это салон** — профиль владельца строки: `db.accounts.get(row.owner)`; выборка
@@ -1529,17 +1665,17 @@ await db.Организация().owner(SYS).count() // [3.4 ms] → 21
1529
1665
  ### 11.4 Операторы фильтров
1530
1666
 
1531
1667
  18 функций-операторов: каждая возвращает объект-условие `Op` для значения поля в фильтре
1532
- шага (`{ duration: gte(60) }`), включая record-пути (`{ price: { RUB: gte(1300) } }` —
1668
+ шага (`{ duration: gte(60) }`), включая record-пути (`{ coordinates: { lat: gte(55.5) } }` —
1533
1669
  условие на листе любой глубины, SQL-каст по типу листа из Schema). Компилируются в
1534
1670
  выражение на актуальной строке (`(data->>'duration')::numeric >= 60`); containment-части
1535
- дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (402 сущности).
1671
+ дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (600 сущностей).
1536
1672
 
1537
1673
  #### `ne(v): Op`
1538
1674
 
1539
1675
  `v: string | number | boolean | null` — «не равно» (`IS DISTINCT FROM` — null-безопасно).
1540
1676
 
1541
1677
  ```ts
1542
- await db.Услуга({ name: ne('Стрижка') }).count() // [3.4 ms] → 401
1678
+ await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms] → 570
1543
1679
  ```
1544
1680
 
1545
1681
  **Кейс:** всё, кроме выбранного, — «другие услуги» под карточкой текущей.
@@ -1549,30 +1685,30 @@ await db.Услуга({ name: ne('Стрижка') }).count() // [3.4 ms] →
1549
1685
  `v: number | string` — строго больше / больше-или-равно (числа и сравнимые строки-даты).
1550
1686
 
1551
1687
  ```ts
1552
- await db.Услуга({ duration: gt(60) }).count() // [3.2 ms] → 100
1553
- 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
1554
1690
  ```
1555
1691
 
1556
1692
  **Кейс:** граница включительно или нет — «от часа» это `gte(60)`; `gt(60)` потеряет
1557
- ровно-часовые (201 vs 100).
1693
+ ровно-часовые (300 vs 150).
1558
1694
 
1559
1695
  #### `lt(v): Op` / `lte(v): Op`
1560
1696
 
1561
1697
  `v: number | string` — строго меньше / меньше-или-равно.
1562
1698
 
1563
1699
  ```ts
1564
- await db.Услуга({ duration: lt(45) }).count() // [3.3 ms] → 101
1565
- 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
1566
1702
  ```
1567
1703
 
1568
- **Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 201 услуга.
1704
+ **Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 300 услуг.
1569
1705
 
1570
1706
  #### `between(a, b): Op`
1571
1707
 
1572
1708
  `a, b: number | string` — диапазон включительно (`a ≤ x ≤ b`).
1573
1709
 
1574
1710
  ```ts
1575
- await db.Услуга({ duration: between(40, 65) }).count() // [3.6 ms] → 201
1711
+ await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 300
1576
1712
  ```
1577
1713
 
1578
1714
  **Кейс:** слайдер длительности «40–65 минут» одной функцией вместо пары gte+lte.
@@ -1582,7 +1718,7 @@ await db.Услуга({ duration: between(40, 65) }).count() // [3.6 ms] → 2
1582
1718
  `vs: (string | number)[]` — значение из списка (`IN`).
1583
1719
 
1584
1720
  ```ts
1585
- await db.Услуга({ name: inList(['Стрижка', 'Услуга 7']) }).count() // [2.8 ms] → 2
1721
+ await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [4.4 ms] → 60
1586
1722
  ```
1587
1723
 
1588
1724
  **Кейс:** сравнение выбранных чекбоксами услуг: имена из UI → один запрос.
@@ -1592,72 +1728,73 @@ await db.Услуга({ name: inList(['Стрижка', 'Услуга 7']) }).co
1592
1728
  `s: string` — SQL-шаблон (`%` — любое, `_` — один символ); `ilike` — без учёта регистра.
1593
1729
 
1594
1730
  ```ts
1595
- await db.Услуга({ name: like('Стри%') }).count() // [3.2 ms] → 1
1596
- 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
1597
1733
  ```
1598
1734
 
1599
1735
  **Кейс:** живой поиск в админке — `ilike('%' + ввод + '%')` прощает регистр
1600
- («королевское» находит «Королевское бритьё»).
1736
+ («массаж» находит «Массаж 7», «Массаж 17»).
1601
1737
 
1602
1738
  #### `starts(s): Op` / `ends(s): Op`
1603
1739
 
1604
1740
  `s: string` — начинается с / заканчивается на (сахар над `like(s+'%')` / `like('%'+s)`).
1605
1741
 
1606
1742
  ```ts
1607
- await db.Услуга({ name: starts('Услуга 39') }).count() // [3.2 ms] → 11
1608
- 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
1609
1745
  ```
1610
1746
 
1611
- **Кейс:** префиксная навигация по артикулам: `starts('Услуга 39')` → 39, 390…399.
1747
+ **Кейс:** префиксная навигация по названию: `starts('Массаж')` → все «Массаж N» (60 в каталоге).
1612
1748
 
1613
1749
  #### `has(v): Op` / `hasAny(vs): Op` / `hasAll(vs): Op`
1614
1750
 
1615
- `v: скаляр`, `vs: скаляр[]` — массив-поле содержит значение / хотя бы одно / все
1616
- (containment `@>` — идёт и в GIN-кандидаты).
1751
+ `v: скаляр`, `vs: скаляр[]` — массив содержит значение / хотя бы одно / все (containment `@>` —
1752
+ идёт и в GIN-кандидаты). В демо-схеме массивов в `data` нет — операторы показаны на колонке `tags`.
1617
1753
 
1618
1754
  ```ts
1619
- await db.Сотрудник({ roles: has('owner') }).count() // [2.7 ms] → 1
1620
- await db.Сотрудник({ roles: hasAny(['owner', 'admin']) }).count() // [2.6 ms] → 1
1621
- await db.Сотрудник({ roles: hasAll(['owner', 'master']) }).count() // [3.3 ms] → 1
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
1622
1758
  ```
1623
1759
 
1624
- **Кейс:** права из массива ролей: «может закрывать смену» = `hasAny(['owner','admin'])`;
1625
- «владелец, который сам стрижёт» = `hasAll(['owner','master'])`.
1760
+ **Кейс:** сегменты по меткам: «vip ИЛИ из телеграма» = `tags(hasAny(['vip','telegram']))` → 800;
1761
+ «vip И телеграм разом» = `tags(hasAll(['vip','telegram']))` → 100.
1626
1762
 
1627
1763
  #### `exists(yes = true): Op`
1628
1764
 
1629
1765
  `yes: boolean?` — поле присутствует (`true`, default) / отсутствует (`false`) в `data`.
1630
1766
 
1631
1767
  ```ts
1632
- await db.Услуга({ description: exists() }).count() // [3.4 ms] → 1
1633
- 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
1634
1770
  ```
1635
1771
 
1636
- **Кейс:** контроль заполненности каталога — «услуги без описания» = `exists(false)` → 401
1637
- на доработку контенту.
1772
+ **Кейс:** контроль заполненности каталога — «услуги без ключа `description`» = `exists(false)` → 210
1773
+ на доработку контенту (не путать с `isNull()` ниже — тот ещё и явные `null` ловит).
1638
1774
 
1639
1775
  #### `isNull(): Op`
1640
1776
 
1641
1777
  Без параметров — поле `NULL` **или** отсутствует.
1642
1778
 
1643
1779
  ```ts
1644
- await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 401
1780
+ await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
1645
1781
  ```
1646
1782
 
1647
- **Кейс:** отличие от `exists(false)`: `isNull()` ловит и явный `null` в data, и отсутствие
1648
- ключа; `exists(false)` — только отсутствие.
1783
+ **Кейс:** отличие от `exists(false)` — на полигоне видно числом: `isNull()` → 390 (ловит и явный
1784
+ `null` в data, и отсутствие ключа), `exists(false)` → 210 (только отсутствие); расхождение 180 —
1785
+ это услуги с явным `description: null`.
1649
1786
 
1650
1787
  #### `not(v): Op`
1651
1788
 
1652
1789
  `v: скаляр | Op` — отрицание; скаляр ≡ «не равно» (как `ne`).
1653
1790
 
1654
1791
  ```ts
1655
- await db.Услуга({ name: not(starts('Услуга')) }).count() // [3.4 ms] → 2
1656
- 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
1657
1794
  ```
1658
1795
 
1659
- **Кейс:** инверсия готового условия без переписывания: «всё, что НЕ сид-генерация» =
1660
- `not(starts('Услуга'))` → Стрижка и Королевское бритьё.
1796
+ **Кейс:** инверсия готового условия без переписывания: «всё, что НЕ стрижки» =
1797
+ `not(starts('Стрижка'))` → 540 (из 600 услуг 60 — «Стрижка N»).
1661
1798
 
1662
1799
  #### `or(...filters): Op`
1663
1800
 
@@ -1665,124 +1802,157 @@ await db.Услуга({ name: not('Стрижка') }).count() // [6.1
1665
1802
  обычное И).
1666
1803
 
1667
1804
  ```ts
1668
- 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
1669
1806
  ```
1670
1807
 
1671
- **Кейс:** «стрижка или что-нибудь быстрое» — один запрос: 1 (Стрижка) + 101 (быстрые) = 102.
1808
+ **Кейс:** «Стрижка 0 или что-нибудь быстрое» — один запрос: Стрижка 0 (duration 30) уже среди
1809
+ быстрых `lt(45)` → объединение = 150, отдельного плюса не даёт.
1672
1810
 
1673
- ### 11.5 Запись: set / delete / anonymize
1811
+ ### 11.5 Запись: create / update / delete / anonymize
1674
1812
 
1675
1813
  Операции — **звенья плана** (§ 6): каждая применяется к шагу, к которому приклеена точкой,
1676
1814
  и возвращает цепочку. Исполняет **терминал** — весь план одной транзакцией (внутренней,
1677
1815
  с ретраем transient; внутри `db.begin()` — транзакцией пользователя); отказ любого сегмента
1678
- откатывает всё. Продолжение цепочки — от результата операции.
1816
+ откатывает всё. Продолжение цепочки — от результата операции. Старый `set()` разбит на
1817
+ `create()`/`update()` в 0.16.0 — бросает подсказку.
1679
1818
 
1680
- #### `.set(data?): Chain`
1819
+ #### `.create(data?): Chain`
1681
1820
 
1682
1821
  | Параметр | Тип | Описание |
1683
1822
  |---|---|---|
1684
- | `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация: лишний ключ — ошибка) + опциональный `id` — явный id строки |
1685
-
1686
- Роль фильтра шага операции:
1687
-
1688
- | Форма | Пример | Смысл |
1689
- |---|---|---|
1690
- | — | `db.Услуга().set({…})` | **INSERT** новой сущности |
1691
- | `{}` пустой объект | `db.позиция({}).set({…})` | **UPDATE всех** в границах контекста |
1692
- | `string` / `Row` | `db.Услуга('id').set({…})` | **UPSERT** по id: есть — новая версия, нет — INSERT с этим id |
1693
- | объект | `db.Запись({ status: 'created' }).set({…})` | UPDATE каждого найденного (в контексте) |
1694
-
1695
- **Назначение и алгоритм.** Единственный способ записи. На терминале для каждого сегмента:
1696
- (1) под `enforceAcl` — WRITE-решение по классу (deny — откат; предикат правила пришпиливает
1697
- `owner`/`account` и фильтрует цели); (2) контекст-шаги резолвятся в связи — каждый обязан
1698
- дать **ровно одну** сущность; (3) поиск целей: INSERT-форма не ищет, id/фильтр — ищут в
1699
- границах контекста; (4) на каждую цель: **deep-merge** листьев `data`, слияние links
1700
- (контекст + `.link()`), строгая валидация, **один INSERT новой версии** с
1701
- `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) UPSERT по id при пустом поиске —
1702
- INSERT (воскрешает удалённую; под ACL существующая-но-недоступная **не перехватывается**);
1703
- (6) конфликт `23505`/transient ретраится транзакцией плана. Сегмент после операции
1704
- исполняется **для каждой строки её результата** (fan-out); `set` сразу после `set` — новая
1705
- версия тех же сущностей (self).
1823
+ | `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация: лишний ключ — ошибка) + опциональный `id` (у v5-класса запрещён — id считает схема, § 3.2) |
1824
+
1825
+ **Назначение и алгоритм.** «Чтобы сущность существовала». На терминале: (1) под
1826
+ `enforceAcl` — WRITE-решение по классу (deny — откат; предикат правила пришпиливает
1827
+ `owner`/`account`); (2) владелец создаваемой связки — из валидного пути (каждый
1828
+ контекст-шаг обязан дать **ровно одну** сущность), прочие концы — слотами; (3) id: явный
1829
+ (шаг `Класс(id)` или `data.id`) либо по правилу схемы — v4/v7 генерируются, v5 вычисляется
1830
+ из концов/полей (§ 3.2); (4) id известен и **уже существует** (в границах пути) → **новая
1831
+ версия** с deep-merge — идемпотентный create, REST-PUT семантика (удалённую — воскрешает;
1832
+ под ACL существующая-но-недоступная **не перехватывается** → `[]`); не существует → **один
1833
+ INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конфликт
1834
+ `23505`/transient ретраится транзакцией плана. Фильтр-объект перед create — ошибка
1835
+ ПОСТРОЕНИЯ (`create() takes no filter`); create на pivot-шаге — ошибка.
1706
1836
 
1707
1837
  **Примеры**
1708
1838
 
1709
1839
  ```ts
1710
- await db.Организация().set({ name: 'Пилигрим' }).rows() // [9.0 ms] INSERT + defaults из Schema
1711
- // → [{ id: '39aba8a5-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, … }]
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: {}, … }]
1712
1842
 
1713
- await db.Организация().set({ id: '…0901', name: 'Демо-салон §11' }).rows() // [7.0 ms] INSERT с явным id
1843
+ await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
1844
+ // [11.4 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
1714
1845
 
1715
- await db.Организация('…0901').Сотрудник().set({ id: '…0911', name: 'Мия', roles: ['master'] }).rows()
1716
- // [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' }
1717
1848
 
1718
- await db.Услуга('…0921').set({ price: { RUB: 1400, USD: 15 } }).rows() // [7.0 ms] UPSERT: новая версия
1719
- // было: { name: 'Массаж головы', price: { RUB: 1200 }, duration: 30, description: 'релакс', … }
1720
- // стало: { name: 'Массаж головы', price: { RUB: 1400, USD: 15 }, duration: 30, description: 'релакс', … }
1721
- // deep-merge тронул только листья price; duration/description целы
1849
+ await db.Организация('…0901').Услуга().create({ name: 'Массаж головы', duration: 30, description: 'релакс' }).rows()
1850
+ // [16.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
1851
+ // повторный create той же пары (салон, имя) → новая ВЕРСИЯ (deep-merge листьев), не дубль
1722
1852
 
1723
- await db.Клиент('…0931').Запись({ status: 'created' }).set({ status: 'confirmed' }).rows() // [12.1 ms]
1724
- // → [{ id: '…0962', status: 'confirmed' }, { id: '…0963', status: 'confirmed' }] — только записи Златы
1853
+ db.Услуга({ name: 'Массаж головы' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
1854
+ // Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
1725
1855
 
1726
- await db.Клиент('…0931').Запись({}).set({ status: 'completed' }).rows() // [10.7 ms] ВСЕ в контексте
1727
- // → [{ id: '…0962', status: 'completed' }, { id: '…0963', status: 'completed' }]
1728
-
1729
- await db.Услуга().set({ name: 'X', чепуха: 1 }).rows() // [1.4 ms] — ошибка НА ТЕРМИНАЛЕ:
1856
+ await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [9.9 ms] — ошибка НА ТЕРМИНАЛЕ:
1730
1857
  // ValidationError: letopis: validation failed for "Service":
1731
- // duration — The 'duration' field is required.; … forbidden keys: 'чепуха'
1858
+ // The object '' contains forbidden keys: 'чепуха'.
1859
+
1860
+ db.Локация('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
1861
+ // Error: letopis: no path Location → Customer: neither embeds the other (Schema)
1862
+ ```
1863
+
1864
+ #### `.update(data?): Chain`
1865
+
1866
+ | Параметр | Тип | Описание |
1867
+ |---|---|---|
1868
+ | `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация); без аргумента — версия без изменения полей (например, ради слотов) |
1869
+
1870
+ **Назначение и алгоритм.** Новая версия **каждого** найденного путём. Цели ищутся как при
1871
+ чтении — id, фильтр, pivot; `Класс()` ≡ `Класс({})` — «все в границах контекста». На каждую
1872
+ цель: **deep-merge** листьев `data` (`update({ coordinates: { lat: 55.8 } })` сохранит `lng` и
1873
+ остальные поля; массивы/скаляры — целиком), слияние links (слоты `.Класс.set()`/`.unset()`),
1874
+ строгая валидация, один INSERT новой версии. Не найдено → `[]` — update **НИКОГДА не
1875
+ создаёт**. Сегмент после операции исполняется **для каждой строки её результата** (fan-out);
1876
+ `update` сразу после `update` — новая версия тех же сущностей (self).
1877
+
1878
+ **Примеры**
1879
+
1880
+ ```ts
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)
1885
+
1886
+ await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [600.4 ms] — ВСЕ записи в контексте
1887
+ // → [{ id: '4907d8cd-…', notes: 'день закрыт' }, { id: 'e7f16a08-…', notes: 'день закрыт' }]
1732
1888
 
1733
- db.Услуга('…0921').set({ duration: 30 }).link('Сотрудник', м) // [0.1 ms]
1734
- // Error: letopis: step modifier after set() — add a class step first
1889
+ await db.запись({ notes: 'нет-такого' }).update({ notes: 'x' }).rows() // → [] — update НИКОГДА не создаёт
1735
1890
  ```
1736
1891
 
1737
1892
  **Кейс: план из нескольких операций — реальный прогон**
1738
1893
 
1739
1894
  ```ts
1740
- // обновить клиента → вставить ему запись (продолжение от записанного, одна транзакция)
1741
- await db.Клиент('…0931').set({ language: 'en' })
1742
- .Запись().set({ id: '…0964', status: 'created', total: { RUB: 990 } })
1743
- .rows() // [15.5 ms]
1744
- // → [{ id: '…0964', class: 'Booking', links: { Customer: '…0931' }, status: 'created' }]
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' } }]
1745
1901
 
1746
- // self-set: две версии подряд
1747
- await db.Запись('…0964').set({ status: 'confirmed' }).set({ status: 'completed' }).rows() // [13.6 ms]
1748
- // → ['completed']; versions: ['created', 'confirmed', 'completed']
1902
+ // self-update: две версии подряд
1903
+ await db.запись('…d091').update({ notes: 'подтверждена' }).update({ notes: 'выполнена' }).rows() // [19.8 ms]
1904
+ // → ['выполнена']; versions: [null, 'подтверждена', 'выполнена']
1749
1905
 
1750
1906
  // хвост-чтение после операции — в той же транзакции
1751
- await db.Клиент('…0931').set({ language: 'ru' }).Запись().count() // [201.6 ms] → 7
1907
+ await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [16.7 ms] → 1
1752
1908
 
1753
- // ОТКАТ: невалидный второй сегмент откатывает и первый
1754
- await db.Клиент('…0931').set({ name: 'Не запишется' }).Запись().set({ чепуха: 1 }).rows()
1755
- // 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]; клиент не изменился
1756
1912
 
1757
1913
  // fan-out: обновить клиента → снести ВСЕ его записи
1758
- await db.Клиент('…0931').set({ active: true }).Запись().delete({ confirm: true }).rows()
1759
- // [229.4 ms] → 7 записей, все с $deleted: true
1914
+ await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().delete({ confirm: true }).rows()
1915
+ // [29.0 ms] → снесено 1 запись, с $deleted: true
1760
1916
  ```
1761
1917
 
1762
- #### `.link(className, target): Chain`
1918
+ #### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
1763
1919
 
1764
- | Параметр | Тип | Описание |
1765
- |---|---|---|
1766
- | `className` | `string` | класс связи (id или алиас) |
1767
- | `target` | `string \| { id }` | конец связи |
1920
+ | Форма | Описание |
1921
+ |---|---|
1922
+ | `.Класс.set(target)` | значение конца связи новой версии; `target` = id \| Row \| вложенная цепочка |
1923
+ | `.Класс.unset()` | снять optional-конец (0.16.0: переименован из `.Класс.delete()` — старое имя бросает подсказку) |
1768
1924
 
1769
- **Назначение и алгоритм.** Значение связи для записи **БЕЗ участия в фильтре целей** —
1770
- в отличие от контекст-шага, который и фильтрует, и пишет. Кладётся в links новой версии
1771
- (поверх контекста). Только до операции; после — ошибка `step modifier after set()`.
1925
+ **Назначение и алгоритм.** Слот — свойство-класс БЕЗ вызова, идёт ПОСЛЕ глагола записи
1926
+ (`create`/`update`); слот без операции — ошибка `link slot needs a write`. Пишет конец в
1927
+ links **БЕЗ участия в фильтре целей** (в отличие от шага пути, который фильтрует). Валиден
1928
+ только для конца из `Schema.links` владельца; союз-конец `[A|B]` замещается целиком
1929
+ (соседний класс снимается); дубль одного слота — ошибка. `target`-цепочка
1930
+ исполняется в той же транзакции и обязана дать ровно одну сущность класса конца.
1772
1931
 
1773
1932
  **Примеры**
1774
1933
 
1775
1934
  ```ts
1776
- await db.навык().link('Сотрудник', м9).link('Услуга', у9).set({}).rows() // [4.1 ms] ОДИН INSERT
1777
- // → { id: '7b374ae4-…', class: 'skill', links: { Staff: '…0911', Service: '…0921' } }
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 встал
1941
+
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
1778
1948
  ```
1779
1949
 
1780
- **Кейс: перевесить исполнителя на всех позициях записи**
1950
+ **Кейс: заменить предмет на всех записях клиента**
1781
1951
 
1782
1952
  ```ts
1783
- await tr.Запись(b).позиция({}).link('Сотрудник', новый).set({}).rows()
1784
- // контекст-шаг Сотрудник(новый) тут НЕ годится: он фильтровал бы позиции по старой связи —
1785
- // а .link только записывает значение
1953
+ await tr.Клиент(к).запись().update().Комплекс.set(комплекс).rows()
1954
+ // цель ищется путём (все записи клиента); слот пишет новый предмет — союз [Услуга|Товар|Комплекс]
1955
+ // замещается целиком, БЕЗ фильтра по старому концу
1786
1956
  ```
1787
1957
 
1788
1958
  #### `.delete(opts?): Chain`
@@ -1797,30 +1967,30 @@ await tr.Запись(b).позиция({}).link('Сотрудник', новы
1797
1967
  под `enforceAcl` DELETE-решение проверяется на класс целей **и каждый класс замыкания**
1798
1968
  (deny откатывает план); `DELETE` по целям будит серверный триггер: advisory-lock →
1799
1969
  tombstone-версия → рекурсивное удаление зависимых. Возврат — замыкание с `$deleted: true`.
1800
- История остаётся; `set()` по тому же id — воскрешение. Продолжение цепочки — от строк
1970
+ История остаётся; `create()` с тем же id — воскрешение. Продолжение цепочки — от строк
1801
1971
  класса цели.
1802
1972
 
1803
1973
  **Примеры**
1804
1974
 
1805
1975
  ```ts
1806
- await db.Запись('…0961').delete().rows() // [192.7 ms] ПРЕВЬЮ — кандидаты живы:
1807
- // → [{ id: '…0961', class: 'Booking' }, { id: 'busy-0961', class: 'busy' }, { id: '20790dc3-…', class: 'item' }]
1808
- // запись жива: true
1976
+ await db.Клиент('…0931').delete().rows() // [14.7 ms] ПРЕВЬЮ — кандидаты живы:
1977
+ // → [{ id: '…0931', class: 'Customer' }, { class: 'booking' }, { class: 'booking' }] — клиент + его записи (каскад)
1978
+ // после превью клиент жив: true
1809
1979
 
1810
- await db.Запись('…0961').delete({ confirm: true }).rows() // [209.2 ms] — сервер нашёл зависимых:
1980
+ await db.Клиент('…0931').delete({ confirm: true }).rows() // [35.8 ms] — сервер нашёл зависимых:
1811
1981
  // → те же три, каждый с $deleted: true
1812
- await db.Запись('…0961').delete({ confirm: true }).rows() // [5.1 ms] повторно → []
1982
+ await db.Клиент('…0931').delete({ confirm: true }).rows() // [6.8 ms] повторно → []
1813
1983
  ```
1814
1984
 
1815
- **Кейс: отмена брони с показом последствий**
1985
+ **Кейс: отмена и воскрешение**
1816
1986
 
1817
1987
  ```ts
1818
- const последствия = await db.Запись(bid).delete().rows() // [192.7 ms] показать оператору
1988
+ const последствия = await db.Клиент(cid).delete().rows() // [14.7 ms] оператору: клиент + N его записей
1819
1989
  if (операторПодтвердил) {
1820
- await db.Запись(bid).delete({ confirm: true }).rows() // [209.2 ms] бронь + занятость + позиция
1990
+ await db.Клиент(cid).delete({ confirm: true }).rows() // [35.8 ms] клиент и записи — tombstone (каскад)
1821
1991
  }
1822
- // клиент передумал: воскрешение тем же id (зависимые пересоздать явно)
1823
- await db.Клиент('…0931').Запись('…0961').set({ 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]
1824
1994
  ```
1825
1995
 
1826
1996
  #### `.anonymize(fields): Chain`
@@ -1837,17 +2007,17 @@ await db.Клиент('…0931').Запись('…0961').set({ status: 'created'
1837
2007
  **Примеры**
1838
2008
 
1839
2009
  ```ts
1840
- await db.Клиент('…0931').anonymize(['name', 'contact']).rows() // [7.8 ms]
1841
- // → [{ 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' },
1842
2012
  // tags: ['anonymized'] }]
1843
2013
  ```
1844
2014
 
1845
2015
  **Кейс: запрос на забвение**
1846
2016
 
1847
2017
  ```ts
1848
- await db.Клиент('…0931').anonymize(['name', 'contact']).rows() // [7.8 ms]
1849
- ;(await db.Клиент('…0931').versions()).map((r) => r.data.name) // → ['Злата', '[erased]']
1850
- await db.Клиент().tags('anonymized').count() // все стёртые — под контролем
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() // все стёртые — под контролем
1851
2021
  ```
1852
2022
 
1853
2023
  ### 11.6 Транзакции: EntityTx
@@ -1866,11 +2036,11 @@ await db.Клиент().tags('anonymized').count() // в
1866
2036
  **Примеры**
1867
2037
 
1868
2038
  ```ts
1869
- const tr = await db.begin() // [0.8 ms]
1870
- await tr.Услуга('…0922').set({ price: { RUB: 9900 } }).rows()
1871
- await tr.Услуга('…0922').first() // внутри → 9900
2039
+ const tr = await db.begin() // [1.1 ms]
2040
+ await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows() // цУкл — базовая цена «Укладки»
2041
+ await tr.цена(цУкл).first() // внутри → amounts.RUB = 9900
1872
2042
  await tr.rollback() // [0.8 ms]
1873
- await db.Услуга('…0922').first() // снаружи → 700, изменения нет
2043
+ await db.цена(цУкл).first() // снаружи → amounts.RUB = 700, изменения нет
1874
2044
  ```
1875
2045
 
1876
2046
  **Кейс: commit** — § 11.2 `db.begin()`; ниже — главный сценарий `lock`.
@@ -1891,28 +2061,34 @@ await db.Услуга('…0922').first() // снаружи → 700
1891
2061
  **Примеры**
1892
2062
 
1893
2063
  ```ts
1894
- await trA.lock('busy', staffId, slotId) // [1.4 ms]
2064
+ await trA.lock('booking', staffId, start) // [3.1 ms]
1895
2065
  await db.lock('x')
1896
- // 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]
1897
2067
  ```
1898
2068
 
1899
2069
  **Кейс: гонка двойной брони — реальный прогон двух транзакций**
1900
2070
 
1901
2071
  ```ts
1902
- // два администратора жмут «забронировать» на одно окно одновременно
2072
+ // два администратора жмут «забронировать» на одно время одновременно;
2073
+ // id записи детерминирован (v5, § 3.2) — известен ДО создания:
2074
+ const bId = uuidv5(`v1.salondemo:entity:booking:${мастер.id}:${start}`)
1903
2075
  const trA = await db.begin(), trB = await db.begin()
1904
- await trA.lock('busy', '…0911', '…0951') // [1.4 ms] A первый
2076
+ await trA.lock('booking', мастер.id, start) // [3.1 ms] A первый
1905
2077
  const гонкаB = (async () => {
1906
- await trB.lock('busy', '…0911', '…0951') // B ВИСИТ до конца trA
1907
- const занято = await trB.занятость(busyId).first() // перечитка под локом
1908
- if (занято) { await trB.rollback(); return 'ОТКАЗ: окно уже занято' }
1909
- await trB.Сотрудник('…0911').Окно('…0951').занятость(busyId).set({ kind: 'booking' }).rows()
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()
1910
2083
  await trB.commit(); return 'бронь моя'
1911
2084
  })()
1912
- await trA.занятость(busyId).first() // → null — свободно
1913
- await trA.Сотрудник('…0911').Окно('…0951').занятость(busyId).set({ kind: 'booking' }).rows()
2085
+ await trA.запись(bId).first() // → null — свободно
2086
+ await trA.Мастер(мастер).запись().create({ start_datetime: start, end_datetime: end })
2087
+ .Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows()
1914
2088
  await trA.commit()
1915
- await гонкаB // → 'ОТКАЗ: окно уже занято' — B увидел бронь A, дубля нет
2089
+ await гонкаB // → 'ОТКАЗ: время уже занято' — B увидел бронь A, дубля нет
2090
+ // дубль невозможен и без лока (оба create вычислят ОДИН id — второй стал бы версией);
2091
+ // лок нужен, чтобы B получил честный отказ, а не молча версионировал чужую бронь
1916
2092
 
1917
2093
  // а если два лока взять в разном порядке — деадлок, жертва получает:
1918
2094
  // Error: deadlock detected — letopis: transaction is aborted, retry the whole db.begin() block
@@ -1931,7 +2107,7 @@ await гонкаB // → 'ОТКАЗ: окно уже занято' — B ув
1931
2107
  цепочке — ошибка `plan is queued in the batch`. Исполняет `run()`.
1932
2108
 
1933
2109
  ```ts
1934
- const b = db.batch('слоты-августа') // [16 µs]
2110
+ const b = db.batch('смены-августа') // [28 µs]
1935
2111
  ```
1936
2112
 
1937
2113
  #### `batch.run(): Promise<Row[][]>`
@@ -1940,26 +2116,28 @@ const b = db.batch('слоты-августа') // [16 µs]
1940
2116
 
1941
2117
  **Назначение и алгоритм.** Вся очередь — **одна транзакция** (бывший `execute()`);
1942
2118
  результаты по порядку планов (`Row[]` на план; у многосегментного — результат последнего
1943
- сегмента). Подряд идущие «чистые INSERT» одного класса (один шаг, без фильтра, без data.id
1944
- и `.link`) склеиваются в **один multi-VALUES INSERT**; под `enforceAcl` WRITE-проверка и
1945
- пришпиливание — на каждый элемент склейки. Transient-ошибка ретраит всю транзакцию целиком.
1946
- Очередь очищается.
2119
+ сегмента). Подряд идущие чистые `create()` одного класса (план из одного шага, без `data.id`
2120
+ и слотов; id класса не v5 — § 3.2) склеиваются в **один multi-VALUES INSERT**; под
2121
+ `enforceAcl` WRITE-проверка и пришпиливание — на каждый элемент склейки. Transient-ошибка
2122
+ ретраит всю транзакцию целиком. Очередь очищается.
1947
2123
 
1948
2124
  **Примеры**
1949
2125
 
1950
2126
  ```ts
1951
- const b = db.batch('слоты-августа')
1952
- b.Окно().set({ id: '…0952', start: '2026-08-01T11:00:00Z', end: '…12:00Z' })
1953
- b.Окно().set({ id: '…0953', start: '2026-08-01T12:00:00Z', end: '…13:00Z' })
1954
- b.Окно().set({ id: '…0954', start: '2026-08-01T13:00:00Z', end: '…14:00Z' })
1955
- b.size() // [29 µs] → 3
1956
- await b.run() // [32.1 ms] — 3 INSERT склеены в один multi-VALUES
1957
- // → [[{ id: '…0952', start: '2026-08-01T11:00:00.000Z' }], [{ id: '…0953', … }], [{ id: '…0954', … }]]
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
1958
2135
  b.size() // → 0
1959
2136
  ```
1960
2137
 
1961
- **Кейс: генерация расписания на день** — 3 окна одной транзакцией и одним INSERT (выше);
1962
- упавшая валидация любого окна откатывает все. Терминал на плане в батче:
2138
+ **Кейс: генерация расписания на день** — 3 окна одной транзакцией (выше); упавшая
2139
+ валидация любого окна откатывает все; v7-классы без `data.id` и слотов склеились бы в
2140
+ один multi-VALUES INSERT. Терминал на плане в батче:
1963
2141
  `план.rows()` → `Error: letopis: plan is queued in the batch — call batch.run()`.
1964
2142
 
1965
2143
  #### `batch.discard(): void` / `batch.size(): number`
@@ -1968,14 +2146,14 @@ b.size() // → 0
1968
2146
 
1969
2147
  ```ts
1970
2148
  const b2 = db.batch('отмена')
1971
- b2.Окно().set({ id: '…0955', start: '…', end: '…' })
1972
- 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]
1973
2151
  b2.size() // → 0
1974
- await db.Окно('…0955').first() // → null — ничего не исполнилось
2152
+ await db.Мастер(m).окно({ start_datetime: between('2026-08-06T11:00:00Z', '2026-08-06T11:00:00Z') }).first() // → null — не исполнилось
1975
2153
  ```
1976
2154
 
1977
2155
  **Кейс: черновик импорта** — копим операции по мере парсинга файла; ошибка парсера →
1978
- `discard()`, полный успех → `execute()`.
2156
+ `discard()`, полный успех → `run()`.
1979
2157
 
1980
2158
  ### 11.8 Таблицы: accounts / credentials / resources / rules
1981
2159
 
@@ -1994,7 +2172,7 @@ await db.Окно('…0955').first() // → null — ничего не исп
1994
2172
  `category` → `= ANY(categories)`.
1995
2173
 
1996
2174
  ```ts
1997
- 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 аккаунт
1998
2176
  ```
1999
2177
 
2000
2178
  **Кейс:** список арендаторов для биллинга: `find({ enabled: true })`, отключённые не в счёте.
@@ -2004,7 +2182,7 @@ await db.accounts.find({ category: 'Client', enabled: true }) // [2.2 ms] →
2004
2182
  `id: string` — точечный SELECT по PK.
2005
2183
 
2006
2184
  ```ts
2007
- await db.accounts.get(acc.id) // [1.7 ms] → Account | null
2185
+ await db.accounts.get(acc.id) // [1.9 ms] → Account | null
2008
2186
  ```
2009
2187
 
2010
2188
  **Кейс:** профиль владельца строки Entity: `db.accounts.get(row.owner)`.
@@ -2024,9 +2202,9 @@ await db.accounts.get(acc.id) // [1.7 ms] → Account | null
2024
2202
 
2025
2203
  ```ts
2026
2204
  const acc = await db.accounts.set({ categories: ['Client'], data: { название: 'ИП Ромашка' } })
2027
- // [10.6 ms] → { id: 'c66766f7-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
2028
- // meta: {}, avatar: 'https://i.pravatar.cc/128?img=37', enabled: true, created: …, updated: … }
2029
- await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [3.0 ms] update
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
2030
2208
  ```
2031
2209
 
2032
2210
  **Кейс:** бан аккаунта одним полем: `set({ id, enabled: false })` — все `verify*` § 11.9
@@ -2038,8 +2216,8 @@ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) //
2038
2216
  в Entity (`account`/`owner` FK RESTRICT) не удалить — намеренно: история неприкосновенна.
2039
2217
 
2040
2218
  ```ts
2041
- await db.accounts.delete(времId) // [9.9 ms] → true (пустой аккаунт)
2042
- 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]
2043
2221
  // Error: update or delete on table "Account" violates foreign key constraint "entity_account_fk"
2044
2222
  ```
2045
2223
 
@@ -2055,7 +2233,7 @@ await db.accounts.delete(SYS) // [4.9 ms]
2055
2233
  | `f.withDeleted` | `boolean?` | включить мягко-удалённые (default — только живые `deleted IS NULL`) |
2056
2234
 
2057
2235
  ```ts
2058
- await db.credentials.find({ account: acc.id }) // [2.5 ms] → 1 живой
2236
+ await db.credentials.find({ account: acc.id }) // [3.2 ms] → 1 живой
2059
2237
  await db.credentials.find({ account: acc.id, withDeleted: true }) // → 1 (после delete: 0 и 1)
2060
2238
  ```
2061
2239
 
@@ -2077,9 +2255,9 @@ telegram списком.
2077
2255
 
2078
2256
  ```ts
2079
2257
  const кред = await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
2080
- // [3.8 ms] → { id: '0d1e98b7-…', confirmed: true, deleted: null, … }
2258
+ // [5.3 ms] → { id: '5f49dbfa-…', confirmed: true, deleted: null, … }
2081
2259
  await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
2082
- // [3.0 ms] после delete → тот же id, deleted = null — воскрешение
2260
+ // [7.7 ms] после delete → тот же id, deleted = null — воскрешение
2083
2261
  ```
2084
2262
 
2085
2263
  **Кейс:** смена номера телефона: `delete(старый)` + `set(новый)`; передумали — повторный
@@ -2091,7 +2269,7 @@ await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 9
2091
2269
  освобождается для других аккаунтов (§ 11.9).
2092
2270
 
2093
2271
  ```ts
2094
- await db.credentials.delete(кред.id) // [3.7 ms] → true; find() больше не видит
2272
+ await db.credentials.delete(кред.id) // [4.6 ms] → true; find() больше не видит
2095
2273
  ```
2096
2274
 
2097
2275
  **Кейс:** отзыв api-ключа: `delete(credential.id)` → `verifyApiKey` мгновенно null
@@ -2112,10 +2290,10 @@ await db.credentials.delete(кред.id) // [3.7 ms] → true; find() боль
2112
2290
 
2113
2291
  ```ts
2114
2292
  await db.resources.set({ alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' } })
2115
- // [3.0 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
2116
- await db.resources.get('apiref.demo:API') // [1.8 ms] → тот же Resource
2117
- await db.resources.find({ category: 'API' }) // [2.2 ms] → 15 ресурсов
2118
- await db.resources.delete('apiref.demo:API') // [3.0 ms] → true
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
2119
2297
  ```
2120
2298
 
2121
2299
  **Кейс:** полный словарь для нового тарифа — § 11.10 (6 ресурсов + 5 правил одним блоком).
@@ -2134,9 +2312,9 @@ await db.resources.delete('apiref.demo:API') // [3.0 ms] → true
2134
2312
 
2135
2313
  ```ts
2136
2314
  await db.rules.set({ account: 'apiref.demo:API', resource: 'apiref.demo:API', permission: 'allow', weight: 90 })
2137
- // [3.6 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
2138
- await db.rules.find({ resource: 'apiref.demo:API' }) // [2.3 ms] → 1
2139
- await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [2.7 ms] → true
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
2140
2318
  ```
2141
2319
 
2142
2320
  **Кейс:** временный бан группы: `set({ account: группа, resource: цель, permission: 'deny',
@@ -2168,8 +2346,8 @@ uuid-строку или объект с `.id`.
2168
2346
 
2169
2347
  ```ts
2170
2348
  await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', password: 'лето-2026!' })
2171
- // [78.0 ms] → Credential; в БД вместо пароля:
2172
- // meta.password = "scrypt$32768$8$1$PSHhWqCsjR+Yne0tUwbVPQ==$ls…"
2349
+ // [87.5 ms] → Credential; в БД вместо пароля:
2350
+ // meta.password = "scrypt$32768$8$1$+yEMcUTJIO7xEu/oDUAMXA==$b6…"
2173
2351
  ```
2174
2352
 
2175
2353
  **Кейс** — регистрация + вход + сессия: см. `sessions()` ниже (полный флоу).
@@ -2191,11 +2369,11 @@ await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', passw
2191
2369
  **Примеры**
2192
2370
 
2193
2371
  ```ts
2194
- await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [65.9 ms]
2195
- // → { 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, … },
2196
2374
  // credential: { category: 'PASSWORD', identifier: 'romashka@salon.io', … } }
2197
- await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [63.1 ms] → null
2198
- await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [65.2 ms] → null
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
2199
2377
  // незнакомый identifier — то же время (dummy-verify)
2200
2378
  ```
2201
2379
 
@@ -2205,11 +2383,11 @@ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' })
2205
2383
  await db.auth.setPassword({ account: acc, identifier: 'noconfirm@salon.io', password: 'пароль-77',
2206
2384
  category: 'EMAIL', confirmed: false })
2207
2385
  await db.auth.verifyPassword({ identifier: 'noconfirm@salon.io', password: 'пароль-77', category: 'EMAIL' })
2208
- // [74.2 ms] → null — кред не подтверждён
2209
- await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [65.7 ms] → { account, credential }
2386
+ // [71.1 ms] → null — кред не подтверждён
2387
+ await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [72.2 ms] → { account, credential }
2210
2388
  await db.accounts.set({ id: acc.id, enabled: false })
2211
2389
  await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
2212
- // [81.9 ms] → null — аккаунт выключен, пароль уже не важен
2390
+ // [59.5 ms] → null — аккаунт выключен, пароль уже не важен
2213
2391
  ```
2214
2392
 
2215
2393
  #### `db.auth.issueApiKey(a): Promise<{ key, credential }>`
@@ -2224,9 +2402,9 @@ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'ле
2224
2402
  Сам ключ возвращается **один раз**; утечка БД ключи не раскрывает.
2225
2403
 
2226
2404
  ```ts
2227
- const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [3.6 ms]
2228
- // key = 'lts_2e14a34f5895a8892c6238d2f2a0620e1b9c3145e491c6d3' ← показать и забыть
2229
- // в БД: identifier = '4d0b27202e76f6fb…' (sha256), meta = { name: 'касса-1', prefix: 'lts_2e14a34f' }
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' }
2230
2408
  ```
2231
2409
 
2232
2410
  **Кейс** — см. `verifyApiKey` (выпуск → проверка → отзыв).
@@ -2242,16 +2420,16 @@ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'к
2242
2420
  Быстрый (без scrypt): ключ высокоэнтропийный, подбор бессмыслен.
2243
2421
 
2244
2422
  ```ts
2245
- await db.auth.verifyApiKey(key) // [3.5 ms] → { account: c66766f7…, credential }
2423
+ await db.auth.verifyApiKey(key) // [4.5 ms] → { account: 06d3bbfe…, credential }
2246
2424
  ```
2247
2425
 
2248
2426
  **Кейс: полный жизненный цикл ключа**
2249
2427
 
2250
2428
  ```ts
2251
- const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [3.6 ms]
2252
- await db.auth.verifyApiKey(key) // [3.5 ms] → { account, credential } — касса работает
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 } — касса работает
2253
2431
  await db.credentials.delete(credential.id) // отзыв (мягкий)
2254
- await db.auth.verifyApiKey(key) // [1.7 ms] → null — мгновенно недействителен
2432
+ await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновенно недействителен
2255
2433
  ```
2256
2434
 
2257
2435
  #### `db.auth.issueKeySecret(a): Promise<{ key, secret, credential }>`
@@ -2263,8 +2441,8 @@ await db.auth.verifyApiKey(key) // [1.7 ms] → null — мгновен
2263
2441
  возвращается один раз.
2264
2442
 
2265
2443
  ```ts
2266
- const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [3.3 ms]
2267
- // 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, показан один раз)
2268
2446
  ```
2269
2447
 
2270
2448
  #### `db.auth.verifyKeySecret(key, secret, opts?): Promise<AuthResult | null>`
@@ -2278,8 +2456,8 @@ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'ин
2278
2456
  **Алгоритм.** Кред по identifier = key, `timingSafeEqual(sha256(secret), meta.secret)`, ворота.
2279
2457
 
2280
2458
  ```ts
2281
- await db.auth.verifyKeySecret(key, secret) // [3.7 ms] → { account, credential }
2282
- await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [1.5 ms] → null
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
2283
2461
  ```
2284
2462
 
2285
2463
  **Кейс:** серверная интеграция (1С, платёжка): key хранится в конфиге открыто и светится
@@ -2301,8 +2479,8 @@ base32(20 случайных байт), `uri` — готовая строка `o
2301
2479
 
2302
2480
  ```ts
2303
2481
  const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: 'romashka@salon.io' })
2304
- // [3.3 ms] secret = 'FAZKFC57B3FPIOF2735ZYW47CZA5O6MW'
2305
- // uri = 'otpauth://totp/romashka%40salon.io?secret=FAZKFC57B3FPIOF2735ZYW47CZA5O6MW&issuer=clockz&algorithm=SHA1&digits=6&period=30'
2482
+ // [4.8 ms] secret = 'G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS'
2483
+ // uri = 'otpauth://totp/romashka%40salon.io?secret=G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS&issuer=clockz&algorithm=SHA1&digits=6&period=30'
2306
2484
  ```
2307
2485
 
2308
2486
  #### `db.auth.verifyTotp(a): Promise<boolean>`
@@ -2320,9 +2498,9 @@ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz
2320
2498
  **Примеры**
2321
2499
 
2322
2500
  ```ts
2323
- const код = totpCode(secret) // [335 µs] → '370916' (как в приложении)
2324
- await db.auth.verifyTotp({ account: acc, code: код }) // [4.8 ms] → true — фактор активирован
2325
- await db.auth.verifyTotp({ account: acc, code: код }) // [1.7 ms] → false — replay отбит
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 отбит
2326
2504
  const прошлый = totpCode(secret, Date.now() - 30_000) // код прошлого шага (окно ±1)
2327
2505
  await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false — шаг ≤ lastStep
2328
2506
  ```
@@ -2330,10 +2508,10 @@ await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false
2330
2508
  **Кейс: включение 2FA в кабинете**
2331
2509
 
2332
2510
  ```ts
2333
- 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]
2334
2512
  await db.auth.totpEnabled(acc) // → false — QR показан, ждём подтверждения
2335
- await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [4.8 ms] → true
2336
- await db.auth.totpEnabled(acc) // [1.7 ms] → true — теперь требуем код при входе
2513
+ await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [8.1 ms] → true
2514
+ await db.auth.totpEnabled(acc) // [2.2 ms] → true — теперь требуем код при входе
2337
2515
  ```
2338
2516
 
2339
2517
  #### `db.auth.totpEnabled(account): Promise<boolean>`
@@ -2342,7 +2520,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true — те
2342
2520
  проверкой (`confirmed`). Приложение по нему решает, спрашивать ли второй фактор.
2343
2521
 
2344
2522
  ```ts
2345
- await db.auth.totpEnabled(acc) // [1.7 ms] → true
2523
+ await db.auth.totpEnabled(acc) // [2.2 ms] → true
2346
2524
  ```
2347
2525
 
2348
2526
  #### `totpCode(secretBase32, atMs = Date.now()): string` — экспорт модуля
@@ -2357,7 +2535,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true
2357
2535
  Для тестов и серверной генерации кодов.
2358
2536
 
2359
2537
  ```ts
2360
- totpCode('FAZKFC57B3FPIOF2735ZYW47CZA5O6MW') // [335 µs] → '370916'
2538
+ totpCode('G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS') // [659 µs] → '564517'
2361
2539
  ```
2362
2540
 
2363
2541
  **Кейс** — автотест 2FA без телефона: сгенерировать код из секрета и скормить `verifyTotp`
@@ -2379,7 +2557,7 @@ totpCode('FAZKFC57B3FPIOF2735ZYW47CZA5O6MW') // [335 µs] → '370916'
2379
2557
 
2380
2558
  ```ts
2381
2559
  const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@salon.io', ttlSec: 600 })
2382
- // [3.8 ms] code = '838474'; в БД: meta = { code: 'ccc3142253e2…' (sha256), expires: '2026-07-11T13:32:36.545Z', attempts: 0 }
2560
+ // [6.1 ms] code = '255243'; в БД: meta = { code: '7566c91d8a5e…' (sha256), expires: '2026-07-13T07:24:44.435Z', attempts: 0 }
2383
2561
  ```
2384
2562
 
2385
2563
  #### `db.auth.verifyOtp(a): Promise<AuthResult | null>`
@@ -2398,19 +2576,19 @@ const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@sa
2398
2576
  **Примеры**
2399
2577
 
2400
2578
  ```ts
2401
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [4.3 ms] → null (+1 попытка)
2402
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [7.4 ms] → { account, credential }
2403
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [1.8 ms] → null — сожжён
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 — сожжён
2404
2582
  ```
2405
2583
 
2406
2584
  **Кейс: сброс пароля**
2407
2585
 
2408
2586
  ```ts
2409
- 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]
2410
2588
  отправитьПисьмо(почта, code) // доставка — на приложении
2411
- const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [7.4 ms]
2589
+ const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11.0 ms]
2412
2590
  if (кто) await db.auth.setPassword({ account: кто.account, identifier: почта, password: новый })
2413
- // протухший код (ttl 1 s в прогоне): verifyOtp → null [12.6 ms]
2591
+ // протухший код (ttl 1 s в прогоне): verifyOtp → null [9.3 ms]
2414
2592
  ```
2415
2593
 
2416
2594
  #### `db.auth.link(a): Promise<Credential>`
@@ -2431,7 +2609,7 @@ if (кто) await db.auth.setPassword({ account: кто.account, identifier: п
2431
2609
 
2432
2610
  ```ts
2433
2611
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' } })
2434
- // [6.6 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
2612
+ // [4.9 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
2435
2613
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915' }) // id занят ДРУГИМ аккаунтом:
2436
2614
  // Error: duplicate key value violates unique constraint "credential_identity_udx" [3.9 ms]
2437
2615
  ```
@@ -2452,14 +2630,14 @@ await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915
2452
2630
 
2453
2631
  ```ts
2454
2632
  await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' })
2455
- // [7.2 ms] → { account: c66766f7…, credential }
2633
+ // [5.2 ms] → { account: 06d3bbfe…, credential }
2456
2634
  ```
2457
2635
 
2458
2636
  **Кейс: вход через telegram-бота**
2459
2637
 
2460
2638
  ```ts
2461
2639
  // платформа подтвердила пользователя 777000111 (initData бота проверило приложение)
2462
- 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]
2463
2641
  if (!кто) { /* первая встреча: создать аккаунт + db.auth.link(…) */ }
2464
2642
  const token = await sess.start(кто.account) // дальше обычная сессия
2465
2643
  ```
@@ -2475,7 +2653,7 @@ const token = await sess.start(кто.account) // дальше обычная
2475
2653
 
2476
2654
  ```ts
2477
2655
  import Redis from 'ioredis'
2478
- 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]
2479
2657
  ```
2480
2658
 
2481
2659
  #### `sessions.start(account, opts?): Promise<string>`
@@ -2491,9 +2669,9 @@ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [147 µ
2491
2669
  `sess:acc:<accountId>` для `revokeAll`. Дамп Redis действующих токенов не раскрывает.
2492
2670
 
2493
2671
  ```ts
2494
- const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [9.1 ms]
2495
- // token = 'fe6093998681fceaf5a902ed5fb3f72be3da5ce943cd4096f9c0d6349fcc231d'
2496
- // в Redis: 'sess:eb473191b2a470d130e…' и 'sess:acc:c66766f7-0704-4…'
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…'
2497
2675
  ```
2498
2676
 
2499
2677
  #### `sessions.check(token): Promise<Session | null>`
@@ -2502,9 +2680,9 @@ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }
2502
2680
  (нет / истекла / отозвана). Суб-миллисекундный — на каждый HTTP-запрос.
2503
2681
 
2504
2682
  ```ts
2505
- await sess.check(token) // [0.9 ms]
2506
- // → { account: 'c66766f7-…', meta: { device: 'iphone' }, created: '2026-07-11T13:22:37.913Z' }
2507
- await sess.check(протухший) // [1.2 ms] → null (ttl 1 s истёк — Redis сам удалил)
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 сам удалил)
2508
2686
  ```
2509
2687
 
2510
2688
  #### `sessions.revoke(token): Promise<boolean>`
@@ -2512,8 +2690,8 @@ await sess.check(протухший) // [1.2 ms] → null (ttl 1 s истёк
2512
2690
  `token: string` — `DEL` ключа + `SREM` из индекса; `false`, если сессии уже нет.
2513
2691
 
2514
2692
  ```ts
2515
- await sess.revoke(token) // [2.4 ms] → true
2516
- 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 — повторно
2517
2695
  ```
2518
2696
 
2519
2697
  #### `sessions.revokeAll(account): Promise<number>`
@@ -2522,19 +2700,19 @@ await sess.revoke(token) // [0.5 ms] → false — повторно
2522
2700
  сколько погашено.
2523
2701
 
2524
2702
  ```ts
2525
- await sess.revokeAll(acc) // [1.1 ms] → 2 — обе сессии (ipad + macbook) погасли
2703
+ await sess.revokeAll(acc) // [1.2 ms] → 2 — обе сессии (ipad + macbook) погасли
2526
2704
  ```
2527
2705
 
2528
2706
  **Кейс: полный вход — пароль → сессия → запрос → выход (реальный прогон)**
2529
2707
 
2530
2708
  ```ts
2531
2709
  const визит = await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
2532
- // [80.4 ms] → { account, credential }
2533
- const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.9 ms]
2710
+ // [71.3 ms] → { account, credential }
2711
+ const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.2 ms]
2534
2712
  // … каждый запрос в middleware:
2535
- 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' }, … }
2536
2714
  // logout:
2537
- await sess.revoke(token) // [2.2 ms] → true
2715
+ await sess.revoke(token) // [1.3 ms] → true
2538
2716
  // «выйти со всех устройств» после смены пароля: await sess.revokeAll(визит.account)
2539
2717
  ```
2540
2718
 
@@ -2543,7 +2721,7 @@ await sess.revoke(token) // [2.2 ms] → true
2543
2721
  Решения по словарю Resource/Rule (§ 9.2). Прогоны ниже — словарь из § 11.8-кейса: группа
2544
2722
  `apiref.client:ACCOUNT {categories: '{Client}'}`, эндпоинты `apiref.api.booking:API
2545
2723
  {endpoint: 'v2.booking.*'}`, данные `apiref.booking.own:READ/WRITE/DELETE
2546
- {class: 'Booking', owner: '$account'}` и `apiref.service:READ {class: 'Service'}`,
2724
+ {class: 'booking', owner: '$account'}` и `apiref.service:READ {class: 'Service'}`,
2547
2725
  5 правил `allow weight 60` от группы Client.
2548
2726
 
2549
2727
  #### `db.acl.check(account, endpoint): Promise<AclDecision>`
@@ -2563,10 +2741,10 @@ NULL — все); объекты — `API`-ресурсы, чья маска п
2563
2741
  **Примеры**
2564
2742
 
2565
2743
  ```ts
2566
- await db.acl.check(acc, 'v2.booking.create') // [3.2 ms]
2744
+ await db.acl.check(acc, 'v2.booking.create') // [5.0 ms]
2567
2745
  // → { allow: true, rule: { account: 'apiref.client:ACCOUNT', resource: 'apiref.api.booking:API',
2568
2746
  // permission: 'allow', weight: 60, enabled: true } }
2569
- await db.acl.check(acc, 'v2.admin.stats') // [1.5 ms] — покрыло только дно-правило сида:
2747
+ await db.acl.check(acc, 'v2.admin.stats') // [1.8 ms] — покрыло только дно-правило сида:
2570
2748
  // → { allow: false, rule: { account: 'any:ACCOUNT', resource: 'any:API', permission: 'deny', weight: 0, … },
2571
2749
  // code: 403, message: 'Access denied - default for any ACCOUNT to any API' }
2572
2750
  ```
@@ -2591,7 +2769,7 @@ app.use(async (req, res, next) => {
2591
2769
 
2592
2770
  **Назначение и алгоритм.** Решение по данным: объекты — ресурсы категории `op`, чья
2593
2771
  `pattern.class`-маска совпала с именем класса **или любого предка** (lineage: право на
2594
- `Booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
2772
+ `booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
2595
2773
  ключи pattern (реальные колонки Entity, `"$account"` → id субъекта) возвращаются как
2596
2774
  `filter` — готовый предикат строк. Справочный метод (SQL за категориями аккаунта на каждый
2597
2775
  вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный на connect
@@ -2600,35 +2778,37 @@ app.use(async (req, res, next) => {
2600
2778
  **Примеры**
2601
2779
 
2602
2780
  ```ts
2603
- await db.acl.checkData(acc, 'Booking', 'READ') // [1.6 ms]
2781
+ await db.acl.checkData(acc, 'booking', 'READ') // [2.7 ms]
2604
2782
  // → { allow: true, rule: { resource: 'apiref.booking.own:READ', weight: 60, … },
2605
- // filter: { owner: 'c66766f7-0704-4cb3-b6c9-18620ebfdc2e' } } ← $account подставлен
2606
- await db.acl.checkData(acc, 'Service', 'READ') // [1.2 ms]
2783
+ // filter: { owner: '06d3bbfe-d9a2-4c1d-be44-04c40cb01108' } } ← $account подставлен
2784
+ await db.acl.checkData(acc, 'Service', 'READ') // [1.9 ms]
2607
2785
  // → { allow: true, rule: { resource: 'apiref.service:READ', … } } ← безусловный (без filter)
2608
- await db.acl.checkData(acc, 'Org', 'READ') // [1.0 ms]
2786
+ await db.acl.checkData(acc, 'Org', 'READ') // [2.0 ms]
2609
2787
  // → { allow: false, message: 'no matching rule (deny by default)' }
2610
- await db.acl.checkData(acc, 'VipBooking', 'READ') // [20.7 ms — свежий connect]
2611
- // → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: 'c66766f7-…' } }
2612
- // класса нет в словаре — право дал предок Booking (lineage)
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)
2613
2791
  ```
2614
2792
 
2615
2793
  **Кейс: enforceAcl — те же решения в SQL цепочек (реальный прогон)**
2616
2794
 
2617
2795
  ```ts
2618
- const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [72.3 ms] правила фиксируются
2619
- await uc.Запись().rows() // [16.4 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
2620
- await uc.Запись().count() // [20.3 ms] → 3 — честный count по суженному множеству
2621
- await uc.Услуга().count() // [15.9 ms] → 404 — безусловный allow, класс целиком
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, класс целиком
2622
2800
  await uc.Организация().rows()
2623
2801
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
2624
- const [z] = await uc.Запись().set({ status: 'created', total: { RUB: 300 } }).rows() // [11.7 ms]
2802
+ const [z] = await uc.запись().create({ start_datetime: t, end_datetime: e })
2803
+ .Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [20.9 ms]
2625
2804
  z.owner === acc.id // → true — owner пришпилен правилом
2626
- await uc.Запись().owner(SYS).set({ … }).rows()
2627
- // Error: letopis: acl pins Booking writes to owner … — на терминале [1.8 ms]
2628
- await uc.Запись(чужаяId).set({ status: 'hacked' }).rows() // [10.9 ms] → [] — чужая жива и НЕ перехвачена
2629
- await uc.Запись('…0965').delete({ confirm: true }).rows() // [27.7 ms] → [{ id: '…0965', $deleted: true }]
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 }]
2630
2810
  // watch: события только безусловных allow-классов —
2631
- // uc.watch(cb) поймал ['Service']; Booking скрыт (предикат не проверить по payload)
2811
+ // uc.watch(cb) поймал ['Service']; booking скрыт (предикат не проверить по payload)
2632
2812
  ```
2633
2813
 
2634
2814
  #### `db.acl.reload(): void`
@@ -2639,7 +2819,7 @@ connect; подхватить новые правила = новый `connect()`
2639
2819
  connect — новый класс в lineage-проверках увидит только новое подключение.
2640
2820
 
2641
2821
  ```ts
2642
- db.acl.reload() // [120 µs]
2822
+ db.acl.reload() // [187 µs]
2643
2823
  ```
2644
2824
 
2645
2825
  **Кейс:** админка сохранила правило → `reload()` в том же процессе, чтобы `check` следующего
@@ -2659,9 +2839,9 @@ db.acl.reload() // [120 µs]
2659
2839
  компилированный валидатор.
2660
2840
 
2661
2841
  ```ts
2662
- db.registry.resolve('Запись') // [90 µs]
2663
- // → { id: 'Booking', alias: 'Запись', category: 'HUB', ancestors: ['Booking', 'Entity'],
2664
- // 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 }
2665
2845
  db.registry.resolve('Дракон')
2666
2846
  // Error: letopis: unknown class "Дракон". Known: Entity·Сущность, Org·Организация, …
2667
2847
  ```
@@ -2675,10 +2855,10 @@ db.registry.resolve('Дракон')
2675
2855
  `all` — все классы партиции.
2676
2856
 
2677
2857
  ```ts
2678
- db.registry.has('Booking') // → true
2858
+ db.registry.has('booking') // → true
2679
2859
  db.registry.has('Дракон') // → false
2680
2860
  db.registry.find('Дракон') // → undefined
2681
- db.registry.all.length // → 15
2861
+ db.registry.all.length // → 20 (+1: LINK-класс price·«цена»)
2682
2862
  ```
2683
2863
 
2684
2864
  **Кейс:** роутер `GET /:класс` — `has()` до цепочки, чтобы отвечать 404, а не 500.
@@ -2690,18 +2870,18 @@ db.registry.all.length // → 15
2690
2870
  | `message` | `string` | `letopis: validation failed for "<Класс>": …` |
2691
2871
  | `issues` | `{ field, type, message, … }[]` | отчёт fastest-validator по каждому полю |
2692
2872
 
2693
- **Назначение.** Бросается из ТЕРМИНАЛА плана (`.set()`/`.anonymize()`/батчи), когда `data`
2694
- не проходит строгую схему класса (недостающее обязательное, лишний ключ, неверный тип) —
2695
- весь план откатывается. Другие ошибки записи (abstract-класс, недостающий конец LINK) —
2696
- обычный `Error` там же.
2873
+ **Назначение.** Бросается из ТЕРМИНАЛА плана (`.create()`/`.update()`/`.anonymize()`/батчи),
2874
+ когда `data` не проходит строгую схему класса (недостающее обязательное, лишний ключ,
2875
+ неверный тип) — весь план откатывается. Другие ошибки записи (abstract-класс, недостающий
2876
+ конец LINK) — обычный `Error` там же.
2697
2877
 
2698
2878
  ```ts
2699
- try { await db.Услуга().set({ name: 'X', чепуха: 1 }).rows() } // [1.4 ms]
2879
+ try { await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [9.9 ms]
2700
2880
  catch (e) {
2701
2881
  e instanceof ValidationError // → true
2702
2882
  e.issues
2703
- // → [{ type: 'required', field: 'duration', message: "The 'duration' field is required." },
2704
- // { 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: 'чепуха' }]
2705
2885
  }
2706
2886
  ```
2707
2887
 
@@ -2744,13 +2924,24 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
2744
2924
  | `unknown class "X". Known: …` | класс вне Schema (со списком) |
2745
2925
  | `ValidationError` (`.issues`) | строгая валидация: мусор или лишние поля |
2746
2926
  | `class "X" is abstract` | запись в abstract |
2747
- | `link "X" requires end "Y"` / `polymorphic end(s)` | не хватает концов LINK |
2748
- | `context step "X" must resolve to exactly one entity` | контекст-шаг дал 0 или >1 |
2749
- | `context step "X" needs an id or a unique filter` | контекст-шаг без фильтра |
2750
- | `step modifier after set()` | модификатор шага (alias/tags/link/…) сразу после операции |
2927
+ | `link "X" requires end "Y|Z"` / `has stray link(s)` | не хватает обязательного конца LINK / связь вне объявленных концов (v2); legacy: `polymorphic end(s)` |
2928
+ | `context step "X" must resolve to exactly one entity` | шаг-владелец пути дал 0 или >1 |
2929
+ | `create() takes no filter — searching is update()` | фильтр-объект перед `create()` (синхронно, при построении) |
2930
+ | `create() on a pivot step` | pivot возвращает к существующему узлу — это `update()` |
2931
+ | `class "X" computes id (uuid v5 from …)` | явный id у v5-класса (§ 3.2) — id всегда считает схема |
2932
+ | `id (uuid v5) of "X" needs end "A\|B"` / `needs scalar data field "f"` | v5-классу не хватает конца (путь/слот) или скалярного поля из `from` |
2933
+ | `step modifier after create()/update()` | модификатор шага (alias/tags/…) сразу после операции |
2934
+ | `"X" is not a link end of "Y"` | слот `.X.set()` — не конец Y по Schema.links |
2935
+ | `slot "X" needs an owner step` | слот на корне (`db.X.set()`) без шага-владельца |
2936
+ | `link slot "X" needs a write` | слот без глагола записи — добавить `.create(…)`/`.update(…)` перед ним |
2937
+ | `link end "X" of "Y" is required — cannot unset` | `.unset()` обязательного конца |
2938
+ | `duplicate link slot "X"` | один конец задан слотом дважды в одной записи |
2939
+ | `set() split into create()/update() (0.16.0)` | старый глагол записи — create() вставляет, update() версионирует найденное |
2940
+ | `slot .delete() renamed to .unset() (0.16.0)` | старое имя слот-снятия |
2941
+ | `.link() removed (0.15.0)` | снесённый `.link()` — теперь слот `.Класс.set()` |
2751
2942
  | `execute() renamed to run() (0.11.0)` | старое имя терминала путей (и `batch.execute()`) |
2752
2943
  | `plan is queued in the batch — call batch.run()` | терминал на батч-цепочке с операциями |
2753
- | `no path X → Y` / `LINK → LINK …` | недопустимый переход чтения |
2944
+ | `no path X → Y` / `LINK → LINK …` | недопустимый переход (синхронно при построении цепочки) |
2754
2945
  | `Entity.account is NOT NULL…` | нет account и System-аккаунта |
2755
2946
  | `violates foreign key constraint "entity_*_fk"` | несуществующий класс/аккаунт; удаление класса с данными |
2756
2947
  | `lock() works only inside db.begin()` | лок вне транзакции |
@@ -2763,75 +2954,103 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
2763
2954
 
2764
2955
  ## 13. Что контролирует приложение
2765
2956
 
2766
- - Непересечение окон в расписании (EXCLUDE на hypertable невозможен).
2767
- - Бизнес-проверки брони: навык, смена, число подряд окон под `duration` (§ 7).
2768
- - `total` записи = Σ позиций.
2769
- - Генерация окон из шаблонов/повторов.
2957
+ - Пересечения интервалов записей внутри окна-смены мастера (наложение броней; EXCLUDE на
2958
+ hypertable невозможен). Двойная бронь одного времени мертва самим id записи —
2959
+ `v5(Мастер, старт)` (§ 3.2); частичное наложение разных интервалов проверяет приложение.
2960
+ - Бизнес-проверки брони: у мастера есть навык на услугу (`Мастер→навык→Услуга`); интервал
2961
+ записи попадает в окно-смену того же мастера; длительность услуги укладывается в окно.
2962
+ - Итог по записи = цена предмета (`Услуга`/`Товар`/`Комплекс`), у комплекса — по составу:
2963
+ сами записи денег не хранят.
2964
+ - Генерация окон-смен из шаблонов/повторов; месячное `Расписание {name, year, month}`.
2770
2965
 
2771
2966
  ## 14. Производительность
2772
2967
 
2773
- Замеры `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
+ читают один и тот же полигон, мутируя лишь свои демо-сущности:
2774
2972
 
2775
- | Операция | p50 | p95 |
2776
- |---|---|---|
2777
- | `rows()` класс + containment-фильтр | 5 ms | 52 ms |
2778
- | `rows()` весь класс, sort+limit 100 | 63 ms | 74 ms |
2779
- | цепочка 3 хопа (пути) | 15 ms | 23 ms |
2780
- | `count()` путей | 15 ms | 20 ms |
2781
- | `set()` новой версии | 8 ms | 12 ms |
2782
-
2783
- TOAST-порог (data > 2KB): 0 строк. Слабое место — выборка «весь класс с сортировкой»
2784
- (DISTINCT ON всех сущностей класса); лечится селективным фильтром или курсором.
2785
- Масштаб побольше: `node bench/history.bench.mjs --entities=10000 --versions=100` (1M строк).
2786
- Оверхед ACL: `npx tsx bench/acl.bench.mjs` на article-полигоне (таблица в § 9.2);
2787
- сравнение вариантов партиционирования — `bench/dimensions.bench.mjs`; все ответы/тайминги
2788
- API Reference (§ 11) — `npx tsx bench/api-reference-demo.mjs` (живой article-полигон,
2789
- мутирует только свои сущности).
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 строк.
2790
2991
 
2791
2992
  - Containment и обход графа — GIN; операторы — на уже суженном наборе.
2792
- - Начинайте цепочку с самого селективного шага.
2793
- - `count()` — пути; количество сущностей дешевле `ids().length`.
2794
- - Каскадное удаление — серверное: один DELETE на всё дерево.
2795
- - Один сегмент плана пишет одним INSERT на цель независимо от числа связей (`.link()`/контекст).
2796
- - 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).
2797
3002
 
2798
3003
  ---
2799
3004
 
2800
3005
  ## 15. E2E-пример: барбершоп
2801
3006
 
2802
- Полный исполняемый сценарий — `test/integration.test.ts`. Скелет:
2803
-
2804
- ```ts
2805
- // штат и каталог — связи контекст-шагами; терминал .rows() исполняет план
2806
- const [org] = await db.Организация().set({ name: 'BarberPro' }).rows()
2807
- const [ivan] = await db.Организация(org).Сотрудник().set({ name: 'Иван', roles: ['owner','master'] }).rows()
2808
- const [oleg] = await db.Организация(org).Сотрудник().set({ name: 'Олег', roles: ['master'] }).rows()
2809
- const [стрижка] = await db.Организация(org).Услуга().set({ name: 'Стрижка', duration: 60, price: { RUB: 1500 } }).rows()
2810
- const [комплекс] = await db.Организация(org).Комплекс().set({ name: 'Стрижка+борода', duration: 90, price: { RUB: 2500 } }).rows()
2811
- await db.Комплекс(комплекс).Услуга(стрижка).позиция().set({ qty: 1 }).rows() // состав
2812
- await db.Сотрудник(ivan).Комплекс(комплекс).навык().set({}).rows() // умение
2813
-
2814
- // календарь: период → окна батчем → ростер → исключение
2815
- const [sch] = await db.Организация(org).Расписание().set({ start: '2026-07-10T10:00:00+03:00', end: '…13:00' }).rows()
2816
- db.batch('о').Расписание(sch).Окно().set({ start: '…10:00', end: '…11:00' }) // ×3 — планы в очередь
2817
- const [[w1],[w2],[w3]] = await db.batch('о').run()
2818
- await db.Расписание(sch).Сотрудник(ivan).смена().set({}).rows()
2819
- await db.Сотрудник(oleg).Окно(w3).занятость().set({ id: `off-${oleg.id}-${w3.id}`, kind: 'off' }).rows()
2820
-
2821
- // бронь мульти-слот (90м > 60м → два окна): контекст-форма и .link-форма
2822
- const [bkg] = await db.Клиент(пётр).Запись().set({ status: 'created', total: { RUB: 2500 } }).rows()
2823
- await db.Запись(bkg).Комплекс(комплекс).Сотрудник(ivan).позиция().set({ qty: 1, price: { RUB: 2500 } }).rows()
2824
- await db.занятость().link('Сотрудник', ivan).link('Окно', w2).link('Запись', bkg)
2825
- .set({ id: `busy-${ivan.id}-${w2.id}`, kind: 'booking' }).rows()
2826
- await db.Сотрудник(ivan).Окно(w3).Запись(bkg).занятость(`busy-${ivan.id}-${w3.id}`).set({ kind: 'booking' }).rows()
2827
-
2828
- // жизненный цикл, чтения, отмена
2829
- await db.Запись(bkg.id).set({ status: 'confirmed' }).rows()
2830
- await db.Запись(bkg.id).занятость().Окно().sort('data.start').rows() // окна брони
2831
- await db.Окно(w2.id).занятость({ kind: 'booking' }).Сотрудник().rows() // кто занят
2832
- await db.Запись(bkg.id).delete().rows() // ПРЕВЬЮ: что удалится
2833
- const удалено = await db.Запись(bkg.id).delete({ confirm: true }).rows()
2834
- // всё с $deleted (Booking+busy×2+item)
3007
+ Полный исполняемый сценарий — `test/integration.test.ts`. Скелет (модель 0.17,
3008
+ позитивная доступность):
3009
+
3010
+ ```ts
3011
+ // организация, локация, штат (phone обязателен) — связи контекст-шагами; терминал .rows() исполняет план
3012
+ const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
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
2835
3054
  ```
2836
3055
 
2837
3056
  ---
@@ -2839,24 +3058,28 @@ const удалено = await db.Запись(bkg.id).delete({ confirm: true }).r
2839
3058
  ## 16. Тесты
2840
3059
 
2841
3060
  ```bash
2842
- npm test # 132 теста против живого docker (timescale + redis), по файлам:
3061
+ cd lib && npm test # 167/167 тестов против живого docker (timescale + redis), по файлам:
2843
3062
  # acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
2844
3063
  # с $account, enforceAcl (предикаты в SQL, каскад, watch)
2845
3064
  # api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
2846
3065
  # auth — db.auth: пароль/api-key/key-secret/TOTP/OTP/link+lookup,
2847
3066
  # глобальная идентичность, сессии на живом Redis (expire/revoke)
2848
- # plan — план-модель: несколько операций, fan-out, self-set,
2849
- # превью/confirm delete, ОТКАТ плана, .link(), батч-гард
3067
+ # plan — план-модель: несколько операций, fan-out, self-update,
3068
+ # превью/confirm delete, ОТКАТ плана, слот-перевес, батч-гард
2850
3069
  # resilience — ретраи 40P01/40001, реальный deadlock двух транзакций,
2851
3070
  # watch переживает обрыв LISTEN (pg_terminate_backend)
2852
3071
  # core — триггеры (check/версии/каскад/lineage), обходы, операторы,
2853
- # модификаторы, set-формы, delete, гонка, батчи, EXPLAIN
3072
+ # модификаторы, create/update-формы, delete, гонка, батчи, EXPLAIN
2854
3073
  # depth — наследование 3+ уровней, data-пути любой глубины
3074
+ # idgen — генерация id по Schema (§ 3.2): v4/v7/v5 из концов и полей,
3075
+ # гонка без локов, наследование attributes и правила id, гарды
2855
3076
  # integration — E2E-барбершоп (8 сцен)
2856
3077
  # real-life — 16 сцен «дня салона»: 4 руки, гонки ×3, переносы, no-show
2857
3078
  # tables — auth/ACL-таблицы
2858
3079
  # up — up(): docker-argv, probe-ветка, идемпотентность, fresh,
2859
3080
  # свои сиды/seeds:false, автосоздание базы, валидация
3081
+ # salon — ТЕСТ-ПЛАН: имитация салона, ВСЕ 145 публичных API
3082
+ # (21 акт + матрица покрытия); слоты/pivot/entity
2860
3083
  # wave2 — asOf/versions, keyset-курсор, gen-types, enforceAccount, anonymize
2861
3084
  # wave3 — or/not, агрегации, deep, watch
2862
3085
  # wave4 — compression-политики (чтение сжатого чанка), schema-sync, onQuery