letopis 0.16.0 → 0.18.1

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,12 +9,56 @@ import { connect } from 'letopis'
9
9
  const db = await connect({ dsn: 'postgres://…', schema: 'v1.booking' })
10
10
 
11
11
  // чтение: пути по графу
12
- const пути = await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run()
13
- // [ { Сотрудник: Row, навык: Row, Услуга: Row }, … ]
12
+ const пути = await db.Мастер({ name: 'Вася' }).навык().Услуга().run()
13
+ // [ { Мастер: Row, навык: Row, Услуга: Row }, … ]
14
14
 
15
15
  // запись: операции — звенья, исполняет терминал
16
- await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
17
- ```
16
+ await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
17
+ ```
18
+
19
+ ## Возможности
20
+
21
+ **Хранилище — темпоральное, append-only**
22
+ - Каждое изменение — новая версия-строка; история — first-class (§2, §10).
23
+ - Единая `Entity`-hypertable на TimescaleDB, партиции по времени `updated` (§2.1).
24
+ - Весь CRUD на уровне БД триггерами: `UPDATE` = новая версия, `DELETE` = tombstone + рекурсивный каскад (§2.2).
25
+ - FK-целостность с RESTRICT на append-only (§2.3); срез актуального через `DISTINCT ON` (§2.4).
26
+
27
+ **Схема и классы**
28
+ - Реестр классов из таблицы `Schema`: `HUB` / `LINK`, наследование `attributes` по иерархии (§3, §3.3).
29
+ - Строгая валидация данных — fastest-validator DSL, вложенные объекты любой глубины (§3, §6).
30
+ - Связи v2: союзы ролей (`{A|B|C}`), optional-концы, жадный матчинг (§3.1).
31
+ - Правило id в схеме: uuid v4 / v7 (time-ordered) / v5 (детерминированный → идемпотентный create) (§3.2).
32
+
33
+ **Чтение — dot-цепочки (graph-query)**
34
+ - Пути по графу HUB↔LINK, pivot-возврат, узел-переменная `entity()` (§4).
35
+ - Терминалы `run` / `rows` / `first` / `ids` / `count` — ленивый план, SQL уходит на терминале (§4).
36
+ - 18 операторов фильтров (`ne` `gt` `between` `inList` `like` `has` `exists` `not` `or`…), вложенные пути, касты по схеме (§5).
37
+ - Модификаторы: `limit` / `offset` / `sort`, `asOf`, `after` (keyset), `deep`, `tags` / `account` / `owner`, `alias` (§5).
38
+
39
+ **Запись**
40
+ - `create` / `update` / `delete` / `anonymize` — звенья плана; весь план одной транзакцией с откатом (§6).
41
+ - Слоты связей `.Класс.set()` / `.unset()`, вложенная цепочка как значение слота (§6.1).
42
+ - Deep-merge при `update`; идемпотентный `create` по вычисленному v5-id (§6).
43
+ - Транзакции `db.begin()` + advisory-lock (рецепт двойной брони, §7); батчи с multi-VALUES-склейкой (§8).
44
+
45
+ **История и время**
46
+ - `asOf()` («как было на T») + `versions()` (§10.1); keyset-пагинация `after()` / `cursorOf()` (§10.2).
47
+ - Агрегации в БД `sum` / `avg` / `min` / `max` / `countBy` (§10.3); деревья `deep()` recursive-CTE (§10.4).
48
+ - Realtime `watch()` через `pg_notify` + `onReconnect` (§10.5).
49
+ - Анонимизация (GDPR) `anonymize()` (§10.7); политики сжатия / retention Timescale (§10.8).
50
+
51
+ **Auth · ACL · сессии — встроенные**
52
+ - Пароли (scrypt), api-ключи, key-secret, external identity (oauth/sso/telegram), TOTP, одноразовые коды (§9.1).
53
+ - Сессии во внешнем Redis (клиент инжектируется, §9.1).
54
+ - ACL: `Resource` / `Rule`, `enforceAcl` — предикаты строк вливаются в SQL до сортировки/лимита; наследование прав по классам (§9.2).
55
+ - Изоляция арендатора `enforceAccount` (§10.6).
56
+
57
+ **Эксплуатация**
58
+ - `up()` — dev-bootstrap одной функцией: Docker (TimescaleDB pg17 + Redis) + схема + сиды + connect (§1, §1.1).
59
+ - Миграции классов `schema-sync` с отчётом совместимости (§10.9); TS-типы из схемы `gen-types` (§10.11).
60
+ - Наблюдаемость `onQuery` / `slowMs` (§10.10); ретраи deadlock/serialization, самопереподключение LISTEN (§10.12).
61
+ - Лёгкие зависимости: только `postgres` + `fastest-validator` (Redis-клиент внешний, `ioredis` опционален).
18
62
 
19
63
  Содержание:
20
64
  [1. Быстрый старт](#1-быстрый-старт) ·
@@ -53,7 +97,7 @@ const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v
53
97
  // [letopis.up] connected (schema "v1.booking")
54
98
 
55
99
  const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
56
- const [вася] = await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
100
+ const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
57
101
  await db.close()
58
102
  ```
59
103
 
@@ -61,7 +105,7 @@ await db.close()
61
105
  Данные PG живут в named volume `letopis-pgdata` (кроссплатформенно, переживает пересоздание
62
106
  контейнера); `dataDir: '/path'` — bind mount папки хоста. Полный контракт — [§11.1a](#111a-upopts).
63
107
 
64
- Вручную (то же самое по шагам):
108
+ Вручную (то же самое по шагам) — **из клона репозитория** (`db/apply.mjs` — корневой скрипт репо, в npm-пакет не входит; в установленном пакете накат делает `up()` выше, а Docker-образ он собирает из вложенного `docker/`):
65
109
 
66
110
  ```bash
67
111
  docker build -t letopis-db lib/docker
@@ -76,6 +120,88 @@ node db/apply.mjs --dsn=postgres://postgres:test@localhost:15432/clockz --schema
76
120
  const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz', schema: 'v1.booking' })
77
121
  ```
78
122
 
123
+ ### 1.1 Своя схема с нуля + всё в контейнере одной командой
124
+
125
+ Три шага: **(1)** сид своей схемы → **(2)** `up()` поднимает контейнер и накатывает → **(3)** пишешь данные. Таблицы (`Schema`, `Account`, `Entity`, …) создаёт `ddl.sql` — своему сиду нужны только INSERT-ы; маркер `<SCHEMA-NAME>` подставит `up()`.
126
+
127
+ **1. Сид `myapp.sql`** — классы в таблицу `Schema` + System-аккаунт:
128
+
129
+ ```sql
130
+ -- myapp.sql — своя схема letopis
131
+
132
+ -- System-аккаунт: Entity.account/owner — NOT NULL, дефолт либа берёт ОТСЮДА
133
+ -- (connect ищет Account с категорией 'System'; без него первый create бросит ошибку)
134
+ INSERT INTO "<SCHEMA-NAME>"."Account" (id, categories, data)
135
+ VALUES ('00000000-0000-4000-8000-000000000001', '{System}', '{"name":"System"}')
136
+ ON CONFLICT (id) DO NOTHING;
137
+
138
+ -- Классы домена (нужен ≥1, иначе connect: "has no classes")
139
+ INSERT INTO "<SCHEMA-NAME>"."Schema"
140
+ (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors)
141
+ VALUES
142
+ -- корень: дефолт id v7 наследуется; abstract — напрямую не создаётся
143
+ ('entity','Entity','Сущность','HUB', NULL,
144
+ '{"id":{"type":"uuid","generate":7}}','{"abstract":true}','[]',100,'{Entity}'),
145
+ -- HUB: заметка
146
+ ('entity','Note','Заметка','HUB','Entity',
147
+ '{"title":"string","body":"string|optional","done":{"type":"boolean","default":false}}',
148
+ '{}','[]',200,'{Note,Entity}'),
149
+ -- HUB: тег
150
+ ('entity','Tag','Тег','HUB','Entity',
151
+ '{"name":"string"}','{}','[]',300,'{Tag,Entity}'),
152
+ -- LINK: заметка ↔ тег
153
+ ('entity','tagged','помечена','LINK', NULL,
154
+ '{"id":{"type":"uuid","generate":7}}','{}',
155
+ '[{"class":"Note","cardinality":1},{"class":"Tag","cardinality":1}]',400,'{tagged}')
156
+ ON CONFLICT (partition, id) DO UPDATE SET
157
+ alias=EXCLUDED.alias, ancestor=EXCLUDED.ancestor, attributes=EXCLUDED.attributes,
158
+ meta=EXCLUDED.meta, links=EXCLUDED.links, "order"=EXCLUDED."order";
159
+ ```
160
+
161
+ **2. `up()` — контейнер + база + схема + connect одной командой:**
162
+
163
+ ```ts
164
+ import { up } from 'letopis'
165
+
166
+ const db = await up({ schema: 'myapp', version: 1, seeds: ['./myapp.sql'] })
167
+ // [letopis.up] building image letopis-db … (первый раз тянет TimescaleDB-базу — минуты)
168
+ // [letopis.up] container letopis-timescale created (data: volume letopis-pgdata)
169
+ // [letopis.up] postgres ready in 5.0 s / redis ready on 16379
170
+ // [letopis.up] schema "v1.myapp" applied (2 files) ← ddl.sql + myapp.sql
171
+ // [letopis.up] connected (schema "v1.myapp")
172
+ ```
173
+
174
+ Изолированное окружение (если на хосте уже крутится дефолтный контейнер или нужен отдельный):
175
+
176
+ ```ts
177
+ const db = await up({
178
+ schema: 'myapp', version: 1, seeds: ['./myapp.sql'],
179
+ container: 'myapp-db', // иначе дефолтный 'letopis-timescale'
180
+ image: 'myapp-timescale', // соберётся из пакетного docker/ (TimescaleDB pg17 + Redis)
181
+ dsn: 'postgres://postgres:secret@localhost:25432/myapp',
182
+ dataDir: 'myapp-pgdata', // named volume (или путь хоста = bind mount)
183
+ redisPort: 26379,
184
+ })
185
+ ```
186
+
187
+ **3. Данные — работают сразу** (System-аккаунт из сида закрывает `account`/`owner`):
188
+
189
+ ```ts
190
+ const [n] = await db.Заметка().create({ title: 'Купить хлеб' }).rows()
191
+ const [t] = await db.Тег().create({ name: 'дом' }).rows()
192
+ await db.Заметка(n).помечена().create().Тег.set(t).rows() // связь Note ↔ Tag (§6.1)
193
+ const активные = await db.Заметка({ done: false }).rows()
194
+ await db.close()
195
+ ```
196
+
197
+ **Гейты и рецепты**
198
+ - **≥1 класс** в `Schema` обязателен — иначе `up()` падает на connect: `has no classes` (§3).
199
+ - **System-аккаунт обязателен** (или передать `up({ …, account: '<uuid существующей Account>' })`) — иначе первый `create` бросит `Entity.account is NOT NULL …`.
200
+ - **Готовый auth из коробки** (пароли/ACL): добавь пакетный сид вторым — `seeds: ['./myapp.sql', 'node_modules/letopis/sql/seed.auth.sql']` (System + 16 Resource + 12 Rule); тогда свою `Account`-строку не пиши.
201
+ - **Идемпотентно**: контейнер жив → reuse; схема есть → накат пропущен. **Заново с нуля**: `fresh: true` (дроп схемы — данные теряются) либо снести контейнер: `docker rm -f myapp-db && docker volume rm myapp-pgdata`.
202
+ - **Prod / managed PG**: `dsn` на живой postgres → docker пропускается целиком (только база + схема + connect); либо накатить `sql/ddl.sql` + свой сид своим мигратором и `connect({ dsn, schema: 'v1.myapp' })`. Пакетный `docker/`-образ (Redis без пароля/персиста) — dev-удобство, не для прода.
203
+ - Локальный путь требует установленного и запущенного **Docker**; managed PG — без него.
204
+
79
205
  ---
80
206
 
81
207
  ## 2. Хранилище
@@ -117,7 +243,7 @@ const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz
117
243
  UPDATE "v1.booking"."Entity" SET data = data || '{"duration":45}'
118
244
  WHERE partition='entity' AND class='Service' AND id='…'; -- → новая версия
119
245
  DELETE FROM "v1.booking"."Entity"
120
- WHERE partition='entity' AND class='Booking' AND id='…'; -- → tombstone + каскад
246
+ WHERE partition='entity' AND class='booking' AND id='…'; -- → tombstone + каскад
121
247
  ```
122
248
 
123
249
  Управление объёмом истории — только политики Timescale: `db/policies.mjs` (§ 10.8).
@@ -143,7 +269,7 @@ GIN-кандидатам, затем перепроверка условий н
143
269
 
144
270
  | Поле | Смысл |
145
271
  |---|---|
146
- | `id` / `alias` | англ. id (`Staff`) и русский алиас (`Сотрудник`) — равноправны в API |
272
+ | `id` / `alias` | англ. id (`Staff`) и русский алиас (`Мастер`) — равноправны в API |
147
273
  | `category` | `HUB` (сущность) \| `LINK` (связь с атрибутами) |
148
274
  | `ancestor` | прямой родитель |
149
275
  | `ancestors` | `[self, parent, …, root]` — **считает триггер `schema_lineage`** |
@@ -158,22 +284,22 @@ GIN-кандидатам, затем перепроверка условий н
158
284
 
159
285
  ```jsonc
160
286
  "links": [
161
- { "class": "Booking", "cardinality": 1 }, // один класс
162
- { "classes": ["Service", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
163
- { "class": "Staff", "optional": true, "cardinality": 1 } // конец может отсутствовать
287
+ { "class": "Staff", "cardinality": 1 }, // один класс
288
+ { "classes": ["Service", "Product", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
289
+ { "class": "Customer", "optional": true, "cardinality": 1 } // конец может отсутствовать
164
290
  ]
165
291
  ```
166
292
 
167
293
  - `Entity` в концах не используется — классы называются явно («максимально точная идентификация связи»)
168
- - **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет позиции — `{Услуга|Комплекс}` (заказ услуги ИЛИ заказ комплекса — ровно один из)
169
- - Обязательный конец без ключа → `requires end "Service|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
294
+ - **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет записи — `{Услуга|Товар|Комплекс}` (бронь услуги ИЛИ продажа товара ИЛИ комплекс — ровно один из)
295
+ - Обязательный конец без ключа → `requires end "Service|Product|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
170
296
  - `cardinality` — зарезервировано (0 — безлимит, N — точное число), пока не проверяется: связь класса в строке одна (`{Класс: id}`), множественность выражается строками-связками
171
- - Демо-домен: `позиция = [Запись, {Услуга|Комплекс}, Сотрудник?]`, `состав = [Комплекс, Услуга]` (состав комплекса — отдельный LINK), `навык = [Сотрудник, {Услуга|Комплекс}]`, `занятость = [Сотрудник, Окно, Запись?]`, `смена = [Расписание, Сотрудник]`, `Запись = [Клиент?]`
172
- - Источник правды — `schema.booking.v2.json`; сид генерится: `node lib/scripts/gen-seed.mjs`
297
+ - Демо-домен (0.17, позитивная доступность): `адрес = [Мастер, Локация]`, `окно = [Мастер, Локация, Расписание]` (смена — интервал доступности), `запись = [Мастер, Локация, Расписание, {Услуга|Товар|Комплекс}, Клиент?]` — **наследник окна**, `содержимое = [Папка, {Услуга|Товар|Комплекс}]`, `состав = [Комплекс, {Услуга|Товар}]`, `навык = [Мастер, Услуга]`, `цена = [{Услуга|Товар|Комплекс}]` (варианты цены: `note` + `amounts` record<валюта,число>)
298
+ - Источник правды демо-домена — сид `lib/sql/seed.booking.sql` (редактируется руками, идемпотентен)
173
299
 
174
300
  `schema_lineage` (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
175
301
  пересчитывает `ancestors`/`descendants` при любом изменении Schema.
176
- Из либы: `db.registry.resolve('связь').descendants` → `['busy','compo','item','shift','skill']`.
302
+ Из либы: `db.registry.resolve('связь').descendants` → `['address','booking','compo','content','price','skill','slot']`.
177
303
 
178
304
  ### 3.2 id считает схема: `attributes.id`
179
305
 
@@ -199,21 +325,31 @@ GIN-кандидатам, затем перепроверка условий н
199
325
  без создания чего-либо.
200
326
 
201
327
  ```ts
202
- // занятость: id считается сам — uuidv5(окно, мастер)
203
- const [б] = await db.Сотрудник(м).занятость().create({ kind: 'booking' }).Окно.set(о).rows()
204
- б.id === uuidv5(`v1.booking:entity:busy:${о.id}:${м.id}`) // → true
328
+ // запись: id считается сам — uuidv5(мастер, старт); правило унаследовано от окна
329
+ const [б] = await db.Мастер(м).запись().create({ start_datetime: t, end_datetime: e })
330
+ .Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
331
+ б.id === uuidv5(`v1.booking:entity:booking:${м.id}:${t}`) // → true
205
332
  // «занято?» — ДО создания чего-либо:
206
- await db.занятость(uuidv5(`v1.booking:entity:busy:${о.id}:${м.id}`)).first() // Row | null
333
+ await db.запись(uuidv5(`v1.booking:entity:booking:${м.id}:${t}`)).first() // Row | null
207
334
  ```
208
335
 
209
336
  Явный id у v5-класса запрещён (`computes id` — его всегда считает схема); в батче
210
337
  v5-классы не склеиваются в multi-VALUES (id нужны концы) — исполняются поштучно (§ 8).
211
338
 
212
339
  Демо-схема booking: корни `Entity`/`link` объявляют дефолт `{generate: 7}` один раз;
213
- v7-классы (Org/Staff/Folder/Customer/Schedule/Booking/item) наследуют его без собственного
214
- правила; v5 переопределяют: `busy ← v5(Slot, Staff)`, `skill ← v5(Staff, Service|Complex)`,
215
- `shift ← v5(Schedule, Staff)`, `compo ← v5(Complex, Service)`, `Slot ← v5(Schedule,
216
- data.start)`, `Service`/`Complex` ← `v5(Org, data.name)` — имя уникально в организации.
340
+ v7-классы (Org/Staff/Customer/Location/Schedule/Folder) наследуют его без собственного
341
+ правила; v5 объявляются по одному разу и наследуются дальше:
342
+ `Element ← v5(Org, data.name)` — наследуют Услуга/Товар/Комплекс (имя уникально в
343
+ организации, classId различает классы); `окно (slot) ← v5(Staff, data.start_datetime)` —
344
+ наследует `запись (booking)`: двойная бронь мертва самим id записи, а окно и запись
345
+ на одно время сосуществуют (classId в формуле разный); `адрес ← v5(Staff, Location)`,
346
+ `навык ← v5(Staff, Service)`, `состав ← v5(Complex, Service|Product)`,
347
+ `содержимое ← v5(Folder, Service|Product|Complex)` — полные имена союзов в from;
348
+ `цена ← v5(Service|Product|Complex, note)` — вариант цены уникален по (элемент, note).
349
+
350
+ `from`-поле может быть **необязательным**: если его нет в `create`, id берёт его `default`
351
+ из Schema (напр. `цена` без `note` → `note:'базовая'`, id считается стабильно). Так
352
+ необязательное поле участвует в детерминированном id, не ломая генерацию.
217
353
 
218
354
  ### 3.3 Наследование attributes
219
355
 
@@ -222,6 +358,194 @@ data.start)`, `Service`/`Complex` ← `v5(Org, data.name)` — имя уника
222
358
  наследуется так же — дефолт объявляется один раз на корне иерархии; `links` НЕ наследуются:
223
359
  концы объявляет каждый класс сам. Валидатор и типы полей компилируются из слитых attributes.
224
360
 
361
+ ### 3.4 Примеры схем из таблицы `Schema`
362
+
363
+ Демо-домен booking — **20 классов** в `lib/sql/seed.booking.sql` (**источник правды**:
364
+ правится руками, идемпотентен — по строке-INSERT на класс). Файл входит в npm-пакет;
365
+ `up({ schema: 'booking' })` накатывает его дефолтом. Свой домен — тем же форматом:
366
+
367
+ ```ts
368
+ await up({ schema: 'salon', version: 1, seeds: ['./salon.sql'] }) // свой сид вместо демо
369
+ // seed.auth.sql грузится отдельно и в Schema НЕ пишет — только Account/Resource/Rule (§9)
370
+ ```
371
+
372
+ Таблица `Schema` (`lib/sql/ddl.sql`) — по строке на класс:
373
+
374
+ ```sql
375
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
376
+ partition text NOT NULL DEFAULT 'entity',
377
+ id text NOT NULL, -- англ. id класса: 'Staff', 'skill'
378
+ alias text NOT NULL, -- рус. алиас: 'Мастер', 'навык'
379
+ category text NOT NULL CHECK (category IN ('HUB', 'LINK')),
380
+ ancestor text, -- прямой родитель (наследование)
381
+ attributes jsonb NOT NULL DEFAULT '{}', -- fastest-validator DSL (валидирует либа)
382
+ links jsonb NOT NULL DEFAULT '[]', -- массив объектов-концов (§3.1)
383
+ meta jsonb NOT NULL DEFAULT '{}', -- {abstract?, description?, appearance?}
384
+ "order" int NOT NULL DEFAULT 0,
385
+ ancestors text[] NOT NULL DEFAULT '{}', -- [self, parent, …, root] — считает триггер schema_lineage
386
+ descendants text[] NOT NULL DEFAULT '{}', -- все потомки (транзитивно) — считает триггер schema_lineage
387
+ PRIMARY KEY (partition, id)
388
+ );
389
+ ```
390
+
391
+ Формат строки (один на все 20; `<SCHEMA-NAME>` подставляет `up()` реальным именем PG-схемы):
392
+
393
+ ```sql
394
+ INSERT INTO "<SCHEMA-NAME>"."Schema"
395
+ (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors)
396
+ VALUES ('entity', 'Service', 'Услуга', 'HUB', 'Element',
397
+ '{"duration":"number|min:0|optional"}', -- attributes: DSL fastest-validator (§3)
398
+ '{"abstract":false,"description":"…"}', -- meta
399
+ '[{"class":"Org","cardinality":1}]', -- links: концы (§3.1); НЕ наследуются
400
+ 800, '{Service,Element,Entity}') -- order + ancestors
401
+ ON CONFLICT (partition, id) DO UPDATE SET …; -- повторный накат = правка класса
402
+ ```
403
+
404
+ - `id` + `alias` — англ. и рус. имя, **равноправны** в API: `db.Service(…)` ≡ `db.Услуга(…)`.
405
+ - `descendants` не пишем — считает триггер `schema_lineage`; `ancestors` он тоже пересчитает.
406
+ - `attributes.id` — правило рождения id (§3.2), наследуется по `ancestor` (§3.3).
407
+
408
+ Ниже — 4 характерные строки verbatim из сида и **как ими пользоваться**.
409
+
410
+ #### HUB · вложенный object — `Org` (Организация)
411
+
412
+ ```jsonc
413
+ // attributes:
414
+ {
415
+ "name": "string",
416
+ "timezone": { "type": "string", "default": "Europe/Moscow" },
417
+ "address": "string|optional",
418
+ "phone": "string|optional",
419
+ "active": { "type": "boolean", "default": true },
420
+ "settings": { "type": "object", "optional": true, "strict": true, "props": {
421
+ "booking": { "type": "object", "optional": true, "strict": true, "props": {
422
+ "deposit": { "type": "object", "optional": true, "strict": true, "props": {
423
+ "amount": "number|integer|min:0|default:0",
424
+ "currency": { "type": "enum", "values": ["RUB","USD","EUR","AED"], "default": "RUB" }
425
+ }},
426
+ "autoconfirm": "boolean|default:false"
427
+ }}
428
+ }}
429
+ }
430
+ // links: [] ← корневой HUB, арендатор сам себе владелец (концов нет)
431
+ ```
432
+
433
+ ```ts
434
+ // create: defaults подставит схема (timezone, active); вложенный object любой глубины
435
+ const [org] = await db.Организация().create({
436
+ name: 'BarberPro',
437
+ settings: { booking: { deposit: { amount: 500, currency: 'RUB' }, autoconfirm: true } },
438
+ }).rows()
439
+
440
+ // правка одного листа — deep-merge, соседи целы (§6): autoconfirm и currency сохранятся
441
+ await db.Организация(org).update({ settings: { booking: { deposit: { amount: 1000 } } } }).rows()
442
+
443
+ // фильтр по вложенному пути любой глубины (§5):
444
+ await db.Организация({ settings: { booking: { autoconfirm: true } } }).rows()
445
+ ```
446
+
447
+ Поле вне `attributes` → `ValidationError` (валидация строгая, `$$strict`).
448
+
449
+ #### HUB · наследуемый v5-id — `Element → Service` (Услуга)
450
+
451
+ `Element` объявляет правило id **один раз**; `Service`/`Product`/`Complex` наследуют (§3.3):
452
+
453
+ ```jsonc
454
+ // Element (abstract): attributes
455
+ { "id": { "type": "uuid", "generate": 5, "from": ["Org", "name"] }, // id = uuidv5(Org, name)
456
+ "name": "string", "description": "string|optional" }
457
+ // links: [{ "class": "Org", "cardinality": 1 }]
458
+
459
+ // Service (ancestor: Element): attributes — добавляет только своё поле
460
+ { "duration": "number|min:0|optional" } // name и правило id унаследованы от Element
461
+ // links: [{ "class": "Org", "cardinality": 1 }] ← links НЕ наследуются, объявлены заново
462
+ ```
463
+
464
+ ```ts
465
+ // create: id НЕ передаём — схема считает uuidv5(Org, name); повтор той же пары = та же услуга
466
+ const [svc] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60 }).rows()
467
+ svc.id === uuidv5(`v1.booking:entity:Service:${org.id}:Стрижка`) // → true (§3.2)
468
+ // имя уникально в организации; classId в формуле различает Услугу и Товар с тем же именем
469
+ ```
470
+
471
+ #### LINK · наследование + союз ролей + optional — `slot → booking` (запись)
472
+
473
+ `booking` наследует `slot` (интервал + правило id), добавляет предмет записи и клиента:
474
+
475
+ ```jsonc
476
+ // slot (окно): attributes
477
+ { "id": { "type": "uuid", "generate": 5, "from": ["Staff", "start_datetime"] },
478
+ "start_datetime": { "type": "date", "convert": true },
479
+ "end_datetime": { "type": "date", "convert": true } }
480
+ // links: [{class:"Staff"}, {class:"Location"}, {class:"Schedule"}] (все cardinality:1)
481
+
482
+ // booking (ancestor: slot): attributes — только своё; start/end и правило id унаследованы
483
+ { "notes": "string|optional" }
484
+ // links:
485
+ [ { "class": "Staff", "cardinality": 1 },
486
+ { "class": "Location", "cardinality": 1 },
487
+ { "class": "Schedule", "cardinality": 1 },
488
+ { "classes": ["Service","Product","Complex"], "cardinality": 1 }, // союз: РОВНО один из
489
+ { "class": "Customer", "optional": true, "cardinality": 1 } ] // конец может отсутствовать
490
+ ```
491
+
492
+ ```ts
493
+ // владелец (Мастер) — из пути; прочие концы — слотами (§6.1); id считает схема из (Staff, start)
494
+ await db.Мастер(m).запись().create({ start_datetime: t, end_datetime: e })
495
+ .Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
496
+ // союз занимает ОДИН ключ: услуга ИЛИ товар ИЛИ комплекс; лишняя связь → ошибка
497
+ // Клиент optional → ручная бронь без него, имя в notes:
498
+ await db.Мастер(m).запись().create({ start_datetime: t, end_datetime: e, notes: 'по телефону: Аня' })
499
+ .Локация.set(л).Расписание.set(р).Товар.set(тов).rows() // Клиент опущен — ok
500
+ ```
501
+
502
+ #### LINK · record / мультивалюта — `price` (цена)
503
+
504
+ ```jsonc
505
+ // attributes:
506
+ { "id": { "type": "uuid", "generate": 5, "from": ["Service|Product|Complex", "note"] },
507
+ "note": { "type": "string", "default": "базовая" }, // необязателен → дефолт входит в id (§3.2)
508
+ "amounts": { "type": "record",
509
+ "key": { "type": "enum", "values": ["RUB","USD","EUR","AED"] },
510
+ "value": "number|integer|min:0" } }
511
+ // links: [{ "classes": ["Service","Product","Complex"], "cardinality": 1 }] ← владелец — элемент
512
+ ```
513
+
514
+ ```ts
515
+ // у элемента несколько вариантов цены; note различает (id = uuidv5(элемент, note))
516
+ await db.Услуга(svc).цена().create({ amounts: { RUB: 1500, AED: 60 } }).rows() // note → 'базовая'
517
+ await db.Услуга(svc).цена().create({ note: 'с дизайном', amounts: { RUB: 2500 } }).rows()
518
+ await db.Услуга(svc).цена().sum('data.amounts.RUB') // агрегат по record-листу (§10.3): 4000
519
+ ```
520
+
521
+ #### Все 20 классов и типовой вызов
522
+
523
+ | Класс (id · alias) | cat | Что это | Типовой вызов |
524
+ |---|---|---|---|
525
+ | `Entity` · Сущность | HUB | abstract-корень, дефолт id v7 | — не создаётся |
526
+ | `Org` · Организация | HUB | арендатор, владелец каталога | `db.Организация().create({ name })` |
527
+ | `Person` · Контрагент | HUB | abstract: name + phone | — |
528
+ | `Staff` · Мастер | HUB | бронируемый исполнитель | `db.Организация(o).Мастер().create({ name, phone })` |
529
+ | `Customer` · Клиент | HUB | клиент | `db.Организация(o).Клиент().create({ name, phone })` |
530
+ | `Location` · Локация | HUB | точка обслуживания | `db.Организация(o).Локация().create({ address })` |
531
+ | `Element` · Элемент | HUB | abstract каталог, id v5(Org, name) | — |
532
+ | `Service` · Услуга | HUB | услуга (+ duration) | `db.Организация(o).Услуга().create({ name, duration })` |
533
+ | `Product` · Товар | HUB | товар (+ sku, unit) | `db.Организация(o).Товар().create({ name, sku })` |
534
+ | `Complex` · Комплекс | HUB | комплекс (+ cost, duration) | `db.Организация(o).Комплекс().create({ name, cost, duration })` |
535
+ | `Schedule` · Расписание | HUB | месячное расписание | `db.Организация(o).Расписание().create({ name, year, month })` |
536
+ | `Folder` · Папка | HUB | категория (вложенная) | `db.Организация(o).Папка().create({ name })` · дети: `db.Папка(f).Папка().create({ name })` |
537
+ | `link` · связь | LINK | abstract-корень связей, id v7 | — |
538
+ | `address` · адрес | LINK | Мастер работает в Локации | `db.Мастер(m).адрес().create({ default: true }).Локация.set(l)` |
539
+ | `slot` · окно | LINK | доступность Мастер × Локация × Расписание | `db.Мастер(m).окно().create({ start_datetime, end_datetime }).Локация.set(l).Расписание.set(s)` |
540
+ | `booking` · запись | LINK | бронь (наследник окна) | `db.Мастер(m).запись().create({…}).Локация.set(l).Расписание.set(s).Услуга.set(u).Клиент.set(c)` |
541
+ | `content` · содержимое | LINK | элемент в папке | `db.Папка(f).содержимое().create({ order: 1 }).Услуга.set(u)` |
542
+ | `compo` · состав | LINK | элемент в комплексе | `db.Комплекс(k).состав().create({ quantity: 2 }).Услуга.set(u)` |
543
+ | `skill` · навык | LINK | Мастер умеет Услугу | `db.Мастер(m).навык().create({ level: 'expert' }).Услуга.set(u)` |
544
+ | `price` · цена | LINK | вариант цены элемента | `db.Услуга(u).цена().create({ amounts: { RUB: 1500 } })` |
545
+
546
+ Полные `attributes` / `links` / `meta` каждого класса — в `lib/sql/seed.booking.sql`
547
+ (по одному INSERT-у на класс, в порядке `order`).
548
+
225
549
  ---
226
550
 
227
551
  ## 4. Чтение: цепочки
@@ -242,25 +566,25 @@ data.start)`, `Service`/`Complex` ← `v5(Org, data.name)` — имя уника
242
566
  прямого HUB→HUB пути нет, идут через саму связку и **pivot** (повтор её имени):
243
567
 
244
568
  ```ts
245
- // «позиции записи з, где услуга у и мастер вася» — путь через позицию, pivot-возврат:
246
- db.Запись(з).позиция().Услуга(у).позиция().Сотрудник(вася).позиция().rows()
569
+ // «записи клиента к, где услуга у и мастер вася» — путь через запись, pivot-возврат:
570
+ db.Клиент(к).запись().Услуга(у).запись().Мастер(вася).запись().rows()
247
571
  // «умеет ли Ирина стрижку» — навык, дофильтр предметом, count путей:
248
- db.Сотрудник(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
572
+ db.Мастер(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
249
573
  ```
250
574
 
251
575
  ### Узел-переменная: `entity()`
252
576
 
253
577
  ```ts
254
- const p = db.позиция() // ленивый узел-паттерн
255
- await db.Запись(з).entity(p).Услуга(у).entity(p).Сотрудник().run() // та же p = тот же узел (явный pivot)
578
+ const p = db.запись() // ленивый узел-паттерн
579
+ await db.Клиент(к).entity(p).Услуга(у).entity(p).Мастер().run() // та же p = тот же узел (явный pivot)
256
580
  await db.entity(row).Услуга().first() // старт пути с готового Row
257
581
  ```
258
582
 
259
583
  ### Терминалы
260
584
 
261
585
  ```ts
262
- const пути = await db.Сотрудник({ name:'Вася' }).alias('Мастер').навык().Услуга().run()
263
- // [{ Мастер: Row, навык: Row, Услуга: Row }, …]
586
+ const пути = await db.Мастер({ name:'Вася' }).alias('Исполнитель').навык().Услуга().run()
587
+ // [{ Исполнитель: Row, навык: Row, Услуга: Row }, …]
264
588
  ```
265
589
 
266
590
  | Вызов | Возврат |
@@ -286,9 +610,9 @@ const пути = await db.Сотрудник({ name:'Вася' }).alias('Мас
286
610
  db.Услуга('uuid') // по id
287
611
  db.Услуга(rowИлиAccount) // объект с id — возьмётся .id
288
612
  db.Услуга(['id1','id2']) // по списку ([] → пусто)
289
- db.Услуга({ name: 'Стрижка', active: true }) // eq полей data → GIN-containment
613
+ db.Услуга({ name: 'Стрижка', duration: 60 }) // eq полей data → GIN-containment
290
614
  db.Услуга({ id: 'uuid', duration: gte(30) }) // ключ id — тоже id-фильтр
291
- db.Услуга({ price: { RUB: lte(2000) } }) // вложенные пути ЛЮБОЙ глубины (record/object) + каст по листу
615
+ db.Локация({ coordinates: { lat: gte(55) } }) // вложенные пути ЛЮБОЙ глубины (object) + каст по листу
292
616
  ```
293
617
 
294
618
  ### Операторы (18: + `not`, `or`)
@@ -305,29 +629,30 @@ import { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends,
305
629
  | `gt/gte/lt/lte(v)`, `between(a,b)` | number, date, string | `(data->>'f')::cast ⋛ $` |
306
630
  | `inList([…])` | любые | `IN (…)`; `[]` → FALSE |
307
631
  | `like/ilike/starts/ends(s)` | string | `[I]LIKE` |
308
- | `has(v)/hasAll([…])/hasAny([…])` | массивы data (`roles`) | `@>` / `?\|` |
632
+ | `has(v)/hasAll([…])/hasAny([…])` | массивы data и колонка `tags` | `@>` / `?\|` |
309
633
  | `exists(true/false)` | любые | ключ есть/нет |
310
634
  | `isNull()` | любые | null или отсутствует |
311
635
  | `not(op \| скаляр)` | по внутреннему | `NOT (…)`; `not(скаляр)` = `ne` |
312
- | `or(f1, f2, …)` | **фильтр целиком** | `db.Запись(or({status:'created'}, {status:'confirmed'}))` — дизъюнкция под-фильтров |
636
+ | `or(f1, f2, …)` | **фильтр целиком** | `db.Услуга(or({name:'Стрижка'}, {duration: lt(40)}))` — дизъюнкция под-фильтров |
313
637
 
314
638
  Касты по `Schema.attributes`: number→`::numeric`, date→`::timestamptz`, boolean→`::boolean`.
315
639
  Поле вне схемы фильтруется как text (записать его нельзя — строгая валидация).
316
- Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`), сравнения корректны.
317
- `{ roles: ['a','b'] }` = containment «содержит оба», не «равно массиву».
640
+ Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`); сравнения — операторами (`between(t, t)`
641
+ для «равно моменту»: скаляр-eq по дате — строковый jsonb-containment, он про
642
+ нормализованное значение). В демо-схеме массивов в data нет — `has*` живут на `tags`.
318
643
 
319
644
  ### Модификаторы цепочки
320
645
 
321
646
  ```ts
322
- db.Окно().sort('data.start').limit(10).offset(20).rows() // выборка
323
- db.Запись().sort('updated', 'desc').limit(50).rows()
647
+ db.окно().sort('data.start_datetime').limit(10).offset(20).rows() // выборка
648
+ db.запись().sort('updated', 'desc').limit(50).rows()
324
649
 
325
650
  db.Клиент().tags('vip') // фильтр: tags ⊇ ['vip']
326
651
  db.Клиент().tags(['vip','telegram']) // все перечисленные
327
652
  db.Клиент().tags(hasAny(['vip','b2b'])) // хотя бы один
328
- db.Запись().account(accId) // фильтр по колонке account (uuid | Row)
653
+ db.запись().account(accId) // фильтр по колонке account (uuid | Row)
329
654
  db.Организация().owner(acc) // фильтр по owner
330
- db.Сотрудник({…}).alias('Мастер') // ключ шага в путях
655
+ db.Мастер({…}).alias('Исполнитель') // ключ шага в путях
331
656
  ```
332
657
 
333
658
  | Модификатор | Область | В чтении | В записи |
@@ -358,15 +683,15 @@ db.Сотрудник({…}).alias('Мастер') // ключ шаг
358
683
 
359
684
  ```ts
360
685
  // одиночная запись: операция + терминал
361
- const [вася] = await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
686
+ const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
362
687
 
363
688
  // несколько операций в одной цепочке: продолжение — ОТ РЕЗУЛЬТАТА предыдущей
364
- await db.Клиент({ vip: true }).update({ bonus: 500 }) // новая версия всех vip
365
- .Запись().create({ status: 'gift' }) // INSERT записи КАЖДОМУ (fan-out)
366
- .rows() // → подарочные записи
689
+ await db.Организация().tags('сеть').update({ active: true }) // новая версия всех сетевых орг
690
+ .Услуга().create({ name: 'Акция месяца', duration: 30 }) // INSERT услуги КАЖДОЙ (fan-out)
691
+ .rows() // → акционные услуги
367
692
 
368
693
  // операция сразу после операции — к тем же сущностям (две версии подряд)
369
- await db.Запись(id).update({ status: 'confirmed' }).update({ paid: true }).rows()
694
+ await db.запись(id).update({ notes: 'подтверждена' }).update({ notes: 'оплачена' }).rows()
370
695
  ```
371
696
 
372
697
  ### Анатомия
@@ -400,7 +725,7 @@ db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хво
400
725
  Валидация **строгая, всегда**: поле вне `Schema.attributes` → `ValidationError`;
401
726
  default-ы схемы подставляются; id в `data` не хранится (он — колонка).
402
727
  - **Deep-merge при `update`** (и у create-версии по известному id): меняются только
403
- указанные листья — `update({ price: { RUB: 1100 } })` сохранит `USD/EUR` и остальные
728
+ указанные листья — `update({ coordinates: { lat: 55.8 } })` сохранит `lng` и остальные
404
729
  поля. Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
405
730
  - **Концы LINK**: по `Schema.links` v2 (§3.1) — обязательные требуются, союз занимает один ключ, лишние связи — ошибка; значения — id, Row или вложенная цепочка (§6.1).
406
731
  - **account/owner NOT NULL**: `.account()/.owner()` → `connect()` → System-аккаунт.
@@ -411,44 +736,44 @@ db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хво
411
736
 
412
737
  Один закон: **владелец создаваемой связки берётся из валидного пути; прочие концы —
413
738
  слотами** `.Класс.set(значение)` после глагола записи. Слот — свойство-класс БЕЗ вызова
414
- (`.Сотрудник.set(x)`, не `.Сотрудник(x)`); валиден только для конца из `Schema.links` и
739
+ (`.Клиент.set(x)`, не `.Клиент(x)`); валиден только для конца из `Schema.links` и
415
740
  требует операцию записи ПЕРЕД собой: `…create(…).Класс.set(x)` / `…update(…).Класс.unset()`;
416
741
  слот без глагола — ошибка `link slot needs a write`.
417
742
 
418
743
  ```ts
419
- // СОЗДАНИЕ: владелец Запись — из пути, предмет и исполнитель — слотами
420
- await db.Запись(b).позиция().create({ qty: 1, price: { RUB: 1500 } })
421
- .Услуга.set(u).Сотрудник.set(s).rows()
744
+ // СОЗДАНИЕ: владелец (Мастер) — из пути, остальные концы — слотами; id считает схема (§ 3.2)
745
+ await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
746
+ .Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
422
747
 
423
- // занятость: владелец Сотрудник из пути, окно и запись — слотами; id считает схема (§ 3.2)
424
- await db.Сотрудник(s).занятость().create({ kind: 'booking' }).Окно.set(w).Запись.set(b).rows()
748
+ // навык: владелец Мастер из пути, предмет — слотом
749
+ await db.Мастер(s).навык().create({ level: 'expert' }).Услуга.set(у).rows()
425
750
 
426
751
  // ПЕРЕВЕС связи (update): цель ищется путём/pivot, слот пишет новое значение БЕЗ фильтра
427
- await tr.Запись(b).позиция().Сотрудник(старый).позиция().update().Сотрудник.set(новый).rows()
752
+ await tr.Клиент(к).запись().Услуга(старая).запись().update().Услуга.set(новая).rows()
428
753
 
429
- // СОЮЗ-конец [Услуга|Комплекс]: слот замещает конец целиком (соседний класс снимается)
430
- await db.позиция(p).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
754
+ // СОЮЗ-конец [Услуга|Товар|Комплекс]: слот замещает конец целиком (соседний класс снимается)
755
+ await db.запись(b).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
431
756
 
432
- // СНЯТИЕ optional-конца
433
- await db.занятость(bz).update({ kind: 'hold' }).Запись.unset().rows()
757
+ // СНЯТИЕ optional-конца: ручная бронь — клиент отвязан, имя в notes
758
+ await db.запись(b).update({ notes: 'по телефону: Аня' }).Клиент.unset().rows()
434
759
 
435
760
  // вложенная цепочка как значение слота — та же транзакция, ровно одна сущность:
436
- await db.Запись(b).позиция().create({ qty: 1 })
437
- .Услуга.set(u)
438
- .Сотрудник.set(db.Сотрудник().create({ name: 'Новичок' }).Org.set(org)).rows() // создать И привязать
761
+ await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
762
+ .Локация.set(л).Расписание.set(р)
763
+ .Услуга.set(db.Организация(org).Услуга().create({ name: 'Новинка', duration: 45 })).rows() // создать И привязать
439
764
  ```
440
765
 
441
766
  ### `.delete({ confirm })` — превью и серверное удаление
442
767
 
443
768
  ```ts
444
- const превью = await db.Запись(bid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
445
- // кандидаты (цель + каскад), живые, БД не тронута:
446
- // [{class:'Booking'}, {class:'busy'}, {class:'item'}]
769
+ const превью = await db.Клиент(cid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
770
+ // кандидаты (цель + каскад: записи клиента), живые, БД не тронута:
771
+ // [{class:'Customer'}, {class:'booking'}]
447
772
 
448
- const удалено = await db.Запись(bid).delete({ confirm: true }).rows()
773
+ const удалено = await db.Клиент(cid).delete({ confirm: true }).rows()
449
774
  // удалённое дерево, каждый Row с $deleted: true
450
775
 
451
- await db.Запись(b).занятость().delete({ confirm: true }).rows() // контекст: только занятости записи
776
+ await db.Мастер(m).окно().delete({ confirm: true }).rows() // контекст: только окна мастера
452
777
  ```
453
778
 
454
779
  Цели = фильтр шага + контекст-связи. Замыкание (цели + все живые зависимые) собирается
@@ -459,10 +784,10 @@ id — воскрешение.
459
784
  ### Откат плана
460
785
 
461
786
  ```ts
462
- await db.Клиент(id).update({ name: 'Новое' }) // валидный сегмент…
463
- .Запись().create({ чепуха: 1 }) // …невалидный: ValidationError
787
+ await db.Организация(org).update({ phone: '+7 495 …' }) // валидный сегмент…
788
+ .Услуга().create({ name: 'X', чепуха: 1 }) // …невалидный: ValidationError
464
789
  .rows()
465
- // ← ОТКАТ ВСЕГО: имя клиента не изменилось, версий не прибавилось
790
+ // ← ОТКАТ ВСЕГО: телефон не изменился, версий не прибавилось
466
791
  ```
467
792
 
468
793
  ---
@@ -470,19 +795,20 @@ await db.Клиент(id).update({ name: 'Новое' }) // валидный
470
795
  ## 7. Транзакции
471
796
 
472
797
  ```ts
473
- const busyId = uuidv5(`v1.booking:entity:busy:${slotId}:${staffId}`) // id известен ДО создания (§ 3.2)
798
+ const bookingId = uuidv5(`v1.booking:entity:booking:${staffId}:${start}`) // id известен ДО создания (§ 3.2)
474
799
 
475
800
  const tr = await db.begin() // тот же API на выделенном соединении
476
- await tr.lock('busy', staffId, slotId) // advisory-xact-lock до конца транзакции
477
- const занято = await tr.занятость(busyId).first()
478
- if (!занято) await tr.Сотрудник(s).занятость().create({ kind: 'booking' }).Окно.set(w).rows()
801
+ await tr.lock('booking', staffId, start) // advisory-xact-lock до конца транзакции
802
+ const занято = await tr.запись(bookingId).first()
803
+ if (!занято) await tr.Мастер(s).запись().create({ start_datetime: start, end_datetime: end })
804
+ .Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
479
805
  await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()
480
806
  ```
481
807
 
482
808
  Повторный commit/rollback — no-op. `lock()` вне транзакции — ошибка. Держите транзакции
483
- короткими. **Рецепт двойной брони**: id занятости детерминирован схемой (v5 — § 3.2), так
484
- что дубль невозможен в принципе (второй `create` стал бы версией той же занятости);
485
- `lock(мастер, окно)` + перечитка `first()` под локом нужны, чтобы сопернику честно
809
+ короткими. **Рецепт двойной брони**: id записи детерминирован схемой (v5 — § 3.2), так
810
+ что дубль невозможен в принципе (второй `create` стал бы версией той же записи);
811
+ `lock(мастер, старт)` + перечитка `first()` под локом нужны, чтобы сопернику честно
486
812
  ОТКАЗАТЬ, а не молча версионировать чужую бронь (ровно одна успешна — покрыто тестом-гонкой).
487
813
 
488
814
  ---
@@ -490,11 +816,12 @@ await db.commit(tr) // или db.rollback(tr) / tr.comm
490
816
  ## 8. Батчи
491
817
 
492
818
  ```ts
493
- db.batch('окна').Расписание(sch).Окно().create({ start, end }) // план встал в очередь
494
- db.batch('окна').Расписание(sch).Окно().create({ … })
495
- db.batch('окна').size() // 2
496
- const res = await db.batch('окна').run() // одна транзакция; Row[][] по порядку
497
- db.batch('окна').discard() // отменить
819
+ db.batch('смены').Мастер(m).окно().create({ start_datetime, end_datetime }) // план встал в очередь
820
+ .Локация.set(loc).Расписание.set(sch)
821
+ db.batch('смены').Мастер(m2).окно().create({ … }).Локация.set(loc).Расписание.set(sch)
822
+ db.batch('смены').size() // 2
823
+ const res = await db.batch('смены').run() // одна транзакция; Row[][] по порядку
824
+ db.batch('смены').discard() // отменить
498
825
  ```
499
826
 
500
827
  - `run()` атомарен: любая ошибка откатывает всё.
@@ -620,7 +947,7 @@ lockout после N неудач. Legacy-bcrypt-хэши (`$2b$…` из дам
620
947
  |---|---|---|
621
948
  | `ACCOUNT` | аккаунта | `{"categories": "{Staff}"}`; `"!{Anonymous,Shadow}"` — нет ни одной; NULL — все |
622
949
  | `API` | адреса эндпоинта | `{"endpoint": "v2.auth.apikey.*"}` — маска: `.`-сегменты, `{a,b}`, `*` — хвост |
623
- | `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "Booking", "owner": "$account"}` |
950
+ | `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "booking", "owner": "$account"}` |
624
951
 
625
952
  Pattern операций — реальные колонки Entity (`class`, `owner`, `account`, `tags`, `data`,
626
953
  `links`…), значения — литералы или `"$account"` (id субъекта, подставляется в запрос);
@@ -637,7 +964,7 @@ await db.acl.check(anon, 'v2.auth.password.signup')
637
964
  await db.acl.check(anon, 'v2.auth.apikey.create')
638
965
  // → { allow: false, code: 403, message: 'Access denied - default …' } — победило дно-правило deny 0
639
966
 
640
- await db.acl.checkData(user, 'Booking', 'READ') // ресурс {"class":"Booking","owner":"$account"}
967
+ await db.acl.checkData(user, 'booking', 'READ') // ресурс {"class":"booking","owner":"$account"}
641
968
  // → { allow: true, filter: { owner: '24c49a43-…' }, rule: {…weight: 60} } [1.7 ms]
642
969
  await db.acl.checkData(user, 'SportsCar', 'READ') // право дал предок Vehicle (lineage)
643
970
  // → { allow: true } — безусловный, без предиката
@@ -653,29 +980,30 @@ allow-классов (payload нечем проверить предикат).
653
980
 
654
981
  ```ts
655
982
  const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
656
- await u.Запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
657
- await u.Запись().count() // честный count по суженному множеству
983
+ await u.запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
984
+ await u.запись().count() // честный count по суженному множеству
658
985
  await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
659
- const [z] = await u.Запись().create().rows() // owner пришпилен правилом → user.id
660
- await u.Запись().owner(other).create().rows() // Error: acl pins Booking writes to owner …
661
- await u.Запись(чужаяId).update({ status: '…' }).rows() // → [] — цель вне предиката не находится
662
- // create по известному чужому id тоже НЕ перехватывает: → [] вместо новой версии
986
+ const [z] = await u.Мастер(m).запись().create({…}).rows() // owner пришпилен правилом → user.id
987
+ await u.запись().owner(other).update({…}).rows() // Error: acl pins booking writes to owner …
988
+ await u.запись(чужаяId).update({ notes: '…' }).rows() // → [] — цель вне предиката не находится
989
+ // create той же v5-пары (id чужой записи) тоже НЕ перехватывает: → [] вместо новой версии
663
990
  ```
664
991
 
665
- Оверхед (article-полигон 1.1M, p50 из 20; `bench/acl.bench.mjs`):
992
+ Оверхед (полигон `v1.salondemo` ~980k, booking 440k×2; p50 из 20; `bench/acl.bench.mjs`):
666
993
 
667
994
  | Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
668
995
  |---|---|---|---|
669
- | точечный `first(id)` | 2.2 ms | 3.1 ms | 3.5 ms |
670
- | фильтр `rows` limit 100 | 119.8 ms | 121.6 ms | 119.9 ms |
671
- | keyset-страница всего класса 250k | 1619 ms | 1652 ms | 2155 ms |
672
- | `count()` класса 250k | 440 ms | 453 ms | 875 ms |
673
- | цепочка 2 шага | 5.5 ms | 6.6 ms | 7.0 ms |
674
-
675
- Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат замерен в
676
- worst-case (пропускает 100% строк — чистый оверхед доп. условия на full-scan);
677
- в реальности предикат СУЖАЕТ выборку, и тяжёлые сцены становятся ДЕШЕВЛЕ, чем без ACL.
678
- `db.acl.checkData` — справочный (1.3 ms: SQL за категориями аккаунта на каждый вызов);
996
+ | точечный `first(id)` | 2.9 ms | 4.2 ms | 6.1 ms |
997
+ | фильтр `rows` limit 100 | 1394 ms | 1454 ms | 1362 ms |
998
+ | keyset-страница всего класса 440k | 5495 ms | 5576 ms | 5364 ms |
999
+ | `count()` класса 440k | 3196 ms | 3049 ms | 2874 ms |
1000
+ | цепочка `запись(id).Услуга()` | 5.5 ms | 6.6 ms | 5.1 ms |
1001
+
1002
+ Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат здесь на
1003
+ селективности 100% (все строки booking — System-аккаунт, предикат никого не отсекает) —
1004
+ на тяжёлых сценах ACL в пределах шума с базой; в реальности предикат СУЖАЕТ выборку,
1005
+ и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
1006
+ `db.acl.checkData` — справочный (1.0 ms: SQL за категориями аккаунта на каждый вызов);
679
1007
  горячий путь цепочек использует резолвер, скомпилированный на connect (микросекунды, memo).
680
1008
 
681
1009
  `enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
@@ -686,8 +1014,8 @@ worst-case (пропускает 100% строк — чистый оверхед
686
1014
 
687
1015
  ```ts
688
1016
  await db.Услуга().rows() // срез класса (актуальное, живое)
689
- await db.Запись().sort('updated','desc').limit(50).rows()
690
- await db.Клиент(cid).Запись().позиция().run() // связный подграф
1017
+ await db.запись().sort('updated','desc').limit(50).rows()
1018
+ await db.Клиент(cid).запись().Услуга().run() // связный подграф
691
1019
  ```
692
1020
 
693
1021
  ```sql
@@ -697,7 +1025,7 @@ SELECT * FROM (SELECT DISTINCT ON (class, id) * FROM "v1.booking"."Entity"
697
1025
 
698
1026
  -- история сущности (все версии, включая tombstone)
699
1027
  SELECT updated, deleted, data, links FROM "v1.booking"."Entity"
700
- WHERE partition='entity' AND class='Booking' AND id=$1 ORDER BY updated;
1028
+ WHERE partition='entity' AND class='booking' AND id=$1 ORDER BY updated;
701
1029
  ```
702
1030
 
703
1031
  ### 10.1 Время-путешествия: `.asOf()` / `.versions()`
@@ -705,11 +1033,11 @@ WHERE partition='entity' AND class='Booking' AND id=$1 ORDER BY updated;
705
1033
  ```ts
706
1034
  // «какая цена была на момент брони» — версии позже T невидимы, tombstone до T = «удалён»
707
1035
  const тогда = await db.Услуга(id).asOf('2026-07-01T12:00:00Z').first()
708
- const срезДня = await db.Запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
1036
+ const срезДня = await db.запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
709
1037
 
710
1038
  // вся история сущности без сырого SQL (tombstone-версии приходят с $deleted: true)
711
- const история = await db.Запись(id).versions()
712
- // [{data:{status:'created'}}, {data:{status:'confirmed'}}, {…, $deleted:true}]
1039
+ const история = await db.запись(id).versions()
1040
+ // [{data:{…}}, {data:{…, notes:'подтверждена'}}, {…, $deleted:true}]
713
1041
  ```
714
1042
 
715
1043
  `asOf` применяется к каждому шагу цепочки — подграф целиком «как был». `versions()`
@@ -723,19 +1051,19 @@ Keyset — закладка: курсор = значение поля сорти
723
1051
  порядок тотальным, дубли значений не теряются и не повторяются).
724
1052
 
725
1053
  ```ts
726
- const стр1 = await db.Запись().sort('updated', 'desc').limit(50).rows()
1054
+ const стр1 = await db.запись().sort('updated', 'desc').limit(50).rows()
727
1055
  const кур = cursorOf(стр1.at(-1)!) // { v: '<updated>', id: '…' } — просто объект,
728
- const стр2 = await db.Запись().sort('updated', 'desc') // можно хранить в URL/state
1056
+ const стр2 = await db.запись().sort('updated', 'desc') // можно хранить в URL/state
729
1057
  .after(кур).limit(50).rows()
730
1058
 
731
1059
  // по data-пути ЛЮБОЙ глубины — field тот же, что в sort
732
- const дальше = await db.Окно().sort('data.start')
733
- .after(cursorOf(окно, 'data.start')).limit(20).rows()
1060
+ const дальше = await db.окно().sort('data.start_datetime')
1061
+ .after(cursorOf(окно, 'data.start_datetime')).limit(20).rows()
734
1062
 
735
1063
  // бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
736
1064
  let cursor
737
1065
  do {
738
- const page = await db.Запись({ status: 'confirmed' }).sort('updated', 'desc')
1066
+ const page = await db.Мастер(m).запись().sort('updated', 'desc')
739
1067
  .after(cursor).limit(50).rows()
740
1068
  render(page)
741
1069
  cursor = page.length ? cursorOf(page.at(-1)!) : undefined
@@ -752,14 +1080,14 @@ do {
752
1080
  ### 10.3 Агрегации: считает БД
753
1081
 
754
1082
  ```ts
755
- await db.Запись({ status: 'completed' }).sum('data.total.RUB') // выручка: number | null
756
- await db.Услуга().avg('data.duration') // среднее
757
- await db.Запись().countBy('data.status') // { confirmed: 12, cancelled: 3 } ({} на пустом)
758
- await db.Окно().min('data.start') // min/max — каст по типу поля из Schema
1083
+ await db.цена().sum('data.amounts.RUB') // сумма всех прайсов (record-лист): number | null
1084
+ await db.Услуга().avg('data.duration') // среднее
1085
+ await db.Услуга().countBy('data.duration') // { '30': 2, '60': 1 } ({} на пустом)
1086
+ await db.окно().min('data.start_datetime') // min/max — каст по типу поля из Schema
759
1087
  ```
760
1088
 
761
- Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или record-лист
762
- `'data.total.RUB'`. `sum`/`avg` кастуются в numeric; пустое множество → `null`.
1089
+ Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или вложенный лист
1090
+ (`'data.coordinates.lat'`). `sum`/`avg` кастуются в numeric; пустое множество → `null`.
763
1091
 
764
1092
  ### 10.4 Деревья: `.deep()`
765
1093
 
@@ -775,7 +1103,7 @@ reverse = дети); на другом переходе — ошибка. Раб
775
1103
  ### 10.5 Realtime: `db.watch()`
776
1104
 
777
1105
  ```ts
778
- const stop = await db.watch('Запись', (e) => {
1106
+ const stop = await db.watch('запись', (e) => {
779
1107
  // e = { partition, class, id, updated, deleted } — факт версии (insert/update/tombstone)
780
1108
  обновитьКалендарь(e.id)
781
1109
  })
@@ -791,7 +1119,7 @@ await stop() // отписка
791
1119
  повторяет LISTEN), но `NOTIFY` за время разрыва потеряны — для этого `onReconnect`:
792
1120
 
793
1121
  ```ts
794
- const stop = await db.watch('Запись', onEvent, {
1122
+ const stop = await db.watch('запись', onEvent, {
795
1123
  onReconnect: () => дочитатьПропущенное(), // напр. перечитать всё с последнего e.updated
796
1124
  })
797
1125
  ```
@@ -800,9 +1128,9 @@ const stop = await db.watch('Запись', onEvent, {
800
1128
 
801
1129
  ```ts
802
1130
  const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
803
- await db.Запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
804
- await db.Клиент().create({ name: 'X' }) // запись пришпилена к account
805
- db.Запись().account(чужой).rows() // ошибка: reads are pinned to account …
1131
+ await db.запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
1132
+ await db.Клиент().create({ name: 'X', phone: '+7…' }) // запись пришпилена к account
1133
+ db.запись().account(чужой).rows() // ошибка: reads are pinned to account …
806
1134
  ```
807
1135
 
808
1136
  Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
@@ -812,7 +1140,7 @@ db.Запись().account(чужой).rows() // ошибка: reads are p
812
1140
  ### 10.7 Анонимизация (GDPR): `.anonymize()`
813
1141
 
814
1142
  ```ts
815
- await db.Клиент(id).anonymize(['name', 'contact']).rows()
1143
+ await db.Клиент(id).anonymize(['name', 'phone']).rows()
816
1144
  // новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
817
1145
  ```
818
1146
 
@@ -822,6 +1150,8 @@ await db.Клиент(id).anonymize(['name', 'contact']).rows()
822
1150
 
823
1151
  ### 10.8 Политики хранения: `db/policies.mjs`
824
1152
 
1153
+ > `db/policies.mjs` живёт в репозитории (корневой `db/`), **в npm-пакет НЕ входит** — запускать из клона. Логика — чистый Timescale SQL (`add_compression_policy` / `add_retention_policy`), при желании вызывается напрямую тем же DSN.
1154
+
825
1155
  ```bash
826
1156
  node db/policies.mjs --dsn=… --schema=v1.booking --compress-after=30d # сжатие (история цела)
827
1157
  node db/policies.mjs --dsn=… --schema=v1.booking --retain=2y # + retention (drop навсегда!)
@@ -838,8 +1168,10 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
838
1168
 
839
1169
  ### 10.9 Миграции классов: `scripts/schema-sync.mjs`
840
1170
 
1171
+ > Входит в npm-пакет (`files: ["scripts"]`); в установке путь — `node_modules/letopis/scripts/schema-sync.mjs`. Зависимости — `postgres` и `fastest-validator` (обе — deps пакета), `tsx` не нужен.
1172
+
841
1173
  ```bash
842
- node scripts/schema-sync.mjs --file=../schema.booking.v2.json --dsn=… --schema=v1.booking
1174
+ node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
843
1175
  # schema-sync: … ↔ booking.Schema (partition entity)
844
1176
  # + Coupon (HUB · Купон) — новый класс
845
1177
  # ~ Service — изменены: attributes
@@ -870,6 +1202,8 @@ const db = await connect({
870
1202
 
871
1203
  ### 10.11 TS-типы из Schema: `scripts/gen-types.mjs`
872
1204
 
1205
+ > Входит в npm-пакет; чистый Node-ESM (только `postgres`), `tsx` не нужен — в установке `node node_modules/letopis/scripts/gen-types.mjs …`.
1206
+
873
1207
  ```bash
874
1208
  npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d.ts
875
1209
  ```
@@ -877,7 +1211,7 @@ npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d
877
1211
  ```ts
878
1212
  import type { TypedDb } from './entity-types'
879
1213
  const t = db as unknown as TypedDb
880
- const [svc] = await t.Услуга({ active: true }).rows() // svc.data.duration: number
1214
+ const [svc] = await t.Услуга({ duration: 60 }).rows() // svc.data.duration: number
881
1215
  ```
882
1216
 
883
1217
  Интерфейсы data-полей всех классов (enum → union-литералы, record → `Partial<Record<…>>`)
@@ -914,11 +1248,12 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
914
1248
 
915
1249
  Каждый метод описан по одной схеме: **сигнатура → параметры → назначение и алгоритм →
916
1250
  примеры (под каждым вызовом реальный ответ и время) → полный кейс**. Все ответы и тайминги —
917
- **живой прогон** на article-полигоне 1.11M строк (год истории: 250k записей ×2 версии,
918
- 250k занятостей, 250k позиций); воспроизводитель — `bench/api-reference-demo.mjs`
919
- (мутирует только свои сущности, полигон не пересоздаёт). id сокращены:
920
- `…0021` = `00000000-0000-4000-8000-000000000021`; повторяющиеся `account`/`owner`/`partition`
921
- в ответах опущены.
1251
+ **живой прогон** на едином полигоне `v1.salondemo` ~980 000 строк Entity (год окон-смен и
1252
+ записей: 440 000 записей-наследников окон ×2 версии, каталог из 600 услуг, 360 мастеров,
1253
+ 40 000 клиентов, 1026 цен, 1260 навыков); воспроизводитель — `bench/api-reference-demo.mjs` (только
1254
+ читает полигон salon-seed, мутирует лишь свои сущности). Тот же полигон — под статьёй SALON.md.
1255
+ id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; повторяющиеся
1256
+ `account`/`owner`/`partition` в ответах опущены.
922
1257
 
923
1258
  Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
924
1259
  [11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
@@ -956,23 +1291,23 @@ NOT NULL `Entity.account`. При `enforceAcl: true` дополнительно
956
1291
  **Примеры**
957
1292
 
958
1293
  ```ts
959
- const db = await connect({ dsn, schema: 'v1.article', onQuery: (e) => log(e) }) // [34.4 ms]
960
- await db.Услуга('…0021').first()
961
- // событие onQuery: {"mode":"rows","classes":["Service"],"ms":13.1,"rows":1,"slow":false}
1294
+ const db = await connect({ dsn, schema: 'v1.salondemo', onQuery: (e) => log(e) }) // [33.6 ms]
1295
+ await db.Услуга().first()
1296
+ // первое событие onQuery: {"mode":"rows","classes":["Service"],"ms":15.7,"rows":1,"slow":false}
962
1297
 
963
- const dbSlow = await connect({ dsn, schema: 'v1.article', slowMs: 200, onQuery: … }) // [32.8 ms]
964
- await dbSlow.Запись().count() // 250k сущностей — дольше порога:
965
- // {"mode":"count","classes":["Booking"],"ms":491.9,"rows":1,"slow":true}
1298
+ const dbSlow = await connect({ dsn, schema: 'v1.salondemo', slowMs: 200, onQuery: … }) // [35.8 ms]
1299
+ await dbSlow.запись().count() // ~440k сущностей — дольше порога:
1300
+ // {"mode":"count","classes":["booking"],"ms":3191.0,"rows":1,"slow":true}
966
1301
  ```
967
1302
 
968
1303
  **Кейс: три подключения — обычное, изолированное, под ACL**
969
1304
 
970
1305
  ```ts
971
- const db = await connect({ dsn, schema }) // [34.4 ms]
972
- const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [49.7 ms]
973
- const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [72.3 ms]
974
- await db.Организация().count() // → 21 — видит всех
975
- await iso.Организация().count() // [4.3 ms] → 1 — только свой арендатор
1306
+ const db = await connect({ dsn, schema }) // [33.6 ms]
1307
+ const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [40.2 ms]
1308
+ const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [54.7 ms]
1309
+ await db.Организация().count() // → 30 — видит всех
1310
+ await iso.Организация().count() // [5.1 ms] → 1 — только свой арендатор
976
1311
  await uc.Организация().rows()
977
1312
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default)
978
1313
  await iso.close(); await uc.close()
@@ -1045,14 +1380,14 @@ await up({ dsn, schema: 'v1.booking', version: 1 })
1045
1380
 
1046
1381
  ```ts
1047
1382
  const db = await up({ schema: 'booking', version: 1, dsn }) // [7.4 s] образ+контейнер+схема
1048
- await db.Клиент().count() // → 0 — свежая схема
1049
- const [аня] = await db.Клиент().create({ name: 'Аня', contact: '+7 900' }).rows()
1383
+ await db.Организация().count() // → 0 — свежая схема
1384
+ await db.Организация().create({ name: 'BarberPro' }).rows()
1050
1385
  await db.close()
1051
1386
 
1052
1387
  // … docker stop letopis-timescale (ребут, уборка, что угодно) …
1053
1388
 
1054
1389
  const db2 = await up({ schema: 'booking', version: 1, dsn }) // [1.2 s] start + reuse
1055
- await db2.Клиент().count() // → 2 — volume letopis-pgdata: всё на месте
1390
+ await db2.Организация().count() // → 1 — volume letopis-pgdata: всё на месте
1056
1391
  await db2.close()
1057
1392
  ```
1058
1393
 
@@ -1086,7 +1421,7 @@ import type {
1086
1421
 
1087
1422
  | Параметр | Тип | Описание |
1088
1423
  |---|---|---|
1089
- | `Класс` | имя свойства | id или алиас класса из `Schema` (`db.Booking` ≡ `db.Запись`); неизвестное имя — ошибка со списком классов |
1424
+ | `Класс` | имя свойства | id или алиас класса из `Schema` (`db.booking` ≡ `db.запись`); неизвестное имя — ошибка со списком классов |
1090
1425
  | `filter` | `Filter?` | без аргумента — весь класс; `string` — по id; `string[]` — по списку id; `Row`/объект с `.id` — как id; объект — поля `data` (равенство, операторы § 11.4, record-пути) + ключ `id` |
1091
1426
 
1092
1427
  **Назначение и алгоритм.** Старт цепочки чтения/записи (§ 4–6). Ничего не выполняет —
@@ -1097,10 +1432,10 @@ import type {
1097
1432
  **Примеры**
1098
1433
 
1099
1434
  ```ts
1100
- await db.Услуга('…0021').first() // [2.6 ms] по id → Row {name: 'Стрижка', …}
1101
- await db.Услуга(['…0021', '…0022']).rows() // [2.6 ms] по списку → 2 Row
1102
- await db.Организация(org).first() // [2.5 ms] Row-объект ≡ его id → 'BarberPro'
1103
- await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 301
1435
+ await db.Услуга('…0000').first() // [3.5 ms] по id → Row {name: 'Стрижка 0', …}
1436
+ await db.Услуга(['…0000', '…0006']).rows() // [2.6 ms] по списку → ['Стрижка 0', 'Бритьё 6']
1437
+ await db.Организация(org).first() // [2.2 ms] Row-объект ≡ его id → 'Салон «Стрижка» №0'
1438
+ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [36.0 ms] → 630 (фильтр по цене)
1104
1439
  await db.НетТакогоКласса().rows()
1105
1440
  // Error: letopis: unknown class "НетТакогоКласса". Known: Entity·Сущность, Org·Организация, …
1106
1441
  ```
@@ -1108,10 +1443,10 @@ await db.НетТакогоКласса().rows()
1108
1443
  **Кейс: одна сущность тремя формами фильтра**
1109
1444
 
1110
1445
  ```ts
1111
- const поId = await db.Услуга('…0021').first() // [2.6 ms] → data.name = 'Стрижка'
1112
- const поПолю = await db.Услуга({ name: 'Стрижка' }).first() // тот же Row
1113
- const поОбъекту = await db.Услуга(поId).first() // Row как фильтр ≡ его id
1114
- // все три → id '…0021', цена {RUB: 1800, USD: 20}
1446
+ const поId = await db.Услуга('…0000').first() // [3.5 ms] → data.name = 'Стрижка 0'
1447
+ const поПолю = await db.Услуга({ name: 'Стрижка 0' }).first() // тот же Row
1448
+ const поОбъекту = await db.Услуга(поId).first() // Row как фильтр ≡ его id
1449
+ // все три → id '…0000', data.name = 'Стрижка 0' (цена — отдельным LINK «цена», § 11.9)
1115
1450
  ```
1116
1451
 
1117
1452
  #### `db.begin(): Promise<EntityTx>`
@@ -1130,19 +1465,20 @@ deadlock/serialization ошибка приходит сразу с подска
1130
1465
 
1131
1466
  ```ts
1132
1467
  const tr = await db.begin() // [0.8 ms]
1133
- await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15, price: { RUB: 700 } }).rows()
1134
- // [7.0 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
1135
- await tr.commit() // [2.7 ms] — теперь видно всем
1468
+ await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15 }).rows()
1469
+ // [9.0 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
1470
+ await tr.commit() // [3.7 ms] — теперь видно всем
1136
1471
  ```
1137
1472
 
1138
1473
  **Кейс: атомарный перенос с откатом при провале** — § 11.6 (`tr.lock`), плюс rollback:
1139
1474
 
1140
1475
  ```ts
1476
+ const [цУкл] = await db.Услуга({ name: 'Укладка' }).цена().create({ amounts: { RUB: 700 } }).rows() // базовая цена
1141
1477
  const tr = await db.begin()
1142
- await tr.Услуга({ name: 'Укладка' }).update({ price: { RUB: 9900 } }).rows()
1143
- await tr.Услуга({ name: 'Укладка' }).first() // внутри tx → price.RUB = 9900
1144
- await tr.rollback() // [0.8 ms]
1145
- await db.Услуга({ name: 'Укладка' }).first() // снаружи → price.RUB = 700 — изменение исчезло
1478
+ await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows()
1479
+ await tr.цена(цУкл).first() // внутри tx → amounts.RUB = 9900
1480
+ await tr.rollback() // [1.1 ms]
1481
+ await db.цена(цУкл).first() // снаружи → amounts.RUB = 700 — изменение исчезло
1146
1482
  ```
1147
1483
 
1148
1484
  #### `db.commit(tr): Promise<void>` / `db.rollback(tr): Promise<void>`
@@ -1157,7 +1493,7 @@ await db.Услуга({ name: 'Укладка' }).first() // снаружи
1157
1493
  **Примеры**
1158
1494
 
1159
1495
  ```ts
1160
- await db.commit(tr3) // [2.9 ms] — то же, что tr3.commit()
1496
+ await db.commit(tr3) // [4.0 ms] — то же, что tr3.commit()
1161
1497
  await db.commit()
1162
1498
  // Error: letopis: commit() needs a transaction: db.commit(tr) or tr.commit() [0.1 ms]
1163
1499
  ```
@@ -1183,14 +1519,14 @@ Tombstone-версия приходит с `deleted: true`.
1183
1519
  **Примеры**
1184
1520
 
1185
1521
  ```ts
1186
- const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [34.8 ms]
1187
- await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр §11' }).rows()
1188
- await db.Клиент('…0932').update({ name: 'Пётр Второй' }).rows()
1522
+ const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [17.9 ms]
1523
+ await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр §11', phone: '+7 900 …' }).rows()
1524
+ await db.Клиент('…0932').update({ preferred_contact: 'email' }).rows()
1189
1525
  await db.Клиент('…0932').delete({ confirm: true }).rows()
1190
1526
  // пойманные (3 события: insert → update → tombstone):
1191
- // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.775349+00:00', deleted: false }
1192
- // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.799888+00:00', deleted: false }
1193
- // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.823072+00:00', deleted: true }
1527
+ // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.912278+00:00', deleted: false }
1528
+ // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.921929+00:00', deleted: false }
1529
+ // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.944767+00:00', deleted: true }
1194
1530
  await stop()
1195
1531
  ```
1196
1532
 
@@ -1202,8 +1538,8 @@ const stop = await db.watch('Клиент', (e) => события.push(e), {
1202
1538
  })
1203
1539
  // авария: pg_terminate_backend по LISTEN-соединению …
1204
1540
  // onReconnect сработал через 0.1 s после обрыва (реальный прогон)
1205
- await db.Клиент('…0932').create({ name: 'Пётр после обрыва' }).rows() // create по id — воскрешение
1206
- // событие после reconnect: { class: 'Customer', id: '…0932', updated: '…35.40884+00:00', deleted: false }
1541
+ await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр после обрыва', phone: '+7 900 …' }).rows() // create по id — воскрешение
1542
+ // событие после reconnect: { class: 'Customer', id: '…0932', updated: '…43.273398+00:00', deleted: false }
1207
1543
  await stop()
1208
1544
  ```
1209
1545
 
@@ -1213,7 +1549,7 @@ await stop()
1213
1549
  падают ошибкой postgres.js.
1214
1550
 
1215
1551
  ```ts
1216
- await db.close() // [1.7 ms]
1552
+ await db.close() // [1.3 ms]
1217
1553
  ```
1218
1554
 
1219
1555
  **Кейс** — завершение процесса: `close()` в `finally`/`SIGTERM`-хендлере после `stop()`
@@ -1225,7 +1561,7 @@ await db.close() // [1.7 ms]
1225
1561
  и поле `all` — § 11.11.
1226
1562
 
1227
1563
  ```ts
1228
- db.registry.resolve('Запись') // [90 µs] → ClassDef {id: 'Booking', alias: 'Запись', …}
1564
+ db.registry.resolve('запись') // [99 µs] → ClassDef {id: 'booking', alias: 'запись', …}
1229
1565
  ```
1230
1566
 
1231
1567
  #### `db.sql`
@@ -1234,8 +1570,8 @@ db.registry.resolve('Запись') // [90 µs] → ClassDef {id: 'Booking', a
1234
1570
  Ответственность за SQL — на вызывающем (движок цепочек его не проверяет).
1235
1571
 
1236
1572
  ```ts
1237
- await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.article"."Entity"')
1238
- // [32.3 ms] → [{ n: 1112925 }]
1573
+ await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.salondemo"."Entity"')
1574
+ // [37.3 ms] → [{ n: 980216 }]
1239
1575
  ```
1240
1576
 
1241
1577
  **Кейс** — снятие плана тяжёлого запроса: `db.sql.unsafe('EXPLAIN (ANALYZE) …')` для
@@ -1267,8 +1603,8 @@ prev.links->>'Класс'`) кладётся точным равенством,
1267
1603
  **Примеры**
1268
1604
 
1269
1605
  ```ts
1270
- await db.Организация('…0001').Сотрудник().count() // [5.4 ms] → 2 (обратный hop)
1271
- await db.навык().Услуга().count() // [57.2 ms] → 1001 (LINK → HUB, прямой)
1606
+ await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12 (обратный hop)
1607
+ await db.навык().Услуга().count() // [65.2 ms] → 1260 (LINK → HUB, прямой)
1272
1608
  ```
1273
1609
 
1274
1610
  **Кейс: маршрут «мастер → его навыки → услуги»** — см. `.run()` ниже (тот же прогон).
@@ -1285,20 +1621,21 @@ await db.навык().Услуга().count() // [57.2 ms] →
1285
1621
  **Примеры**
1286
1622
 
1287
1623
  ```ts
1288
- await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run() // [18.9 ms]
1289
- // → [{
1290
- // Сотрудник: { id: '…0011', class: 'Staff', data: { name: 'Вася', roles: ['owner','master'], active: true }, links: { Org: '…0001' }, … },
1291
- // навык: { id: '17773ae2-…', class: 'skill', data: {}, links: { Staff: '…0011', Service: '…0021' }, … },
1292
- // Услуга: { id: '…0021', class: 'Service', data: { name: 'Стрижка', price: { RUB: 1800, USD: 20 }, duration: 60, … }, … }
1293
- // }] — 1 путь, полные узлы каждого шага
1624
+ await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
1625
+ // → 2 пути (у Ольги 0.0 два навыка на разные услуги); первый:
1626
+ // [{
1627
+ // Мастер: { id: '00000003-…-000', class: 'Staff', data: { name: 'Ольга 0.0', phone: '+7 921 0000000', specialization: 'парикмахер' }, links: { Org: '00000001-…-000' }, … },
1628
+ // навык: { id: '00000012-…-000', class: 'skill', data: { level: 'basic' }, links: { Staff: '00000003-…-000', Service: '00000004-…-000' }, … },
1629
+ // Услуга: { id: '00000004-…-000', class: 'Service', data: { name: 'Стрижка 0', duration: 30, description: 'популярное' }, links: { Org: '00000001-…-000' }, … }
1630
+ // }, …] — по объекту на путь, полные узлы каждого шага
1294
1631
  ```
1295
1632
 
1296
1633
  **Кейс: отчёт «кто что умеет» одним запросом**
1297
1634
 
1298
1635
  ```ts
1299
- const пути = await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run() // [18.9 ms]
1300
- пути.map((p) => `${p.Сотрудник.data.name} → ${p.Услуга.data.name}`)
1301
- // → ['Вася → Стрижка']
1636
+ const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
1637
+ пути.map((p) => `${p.Мастер.data.name} → ${p.Услуга.data.name}`)
1638
+ // → ['Ольга 0.0 → Стрижка 0', 'Ольга 0.0 → Массаж 7']
1302
1639
  ```
1303
1640
 
1304
1641
  #### `.rows(): Promise<Row[]>`
@@ -1311,17 +1648,18 @@ const пути = await db.Сотрудник({ name: 'Вася' }).навык().
1311
1648
  **Примеры**
1312
1649
 
1313
1650
  ```ts
1314
- const услуги = await db.Услуга().rows() // [8.8 ms] → 402 Row
1315
- // [0] = { id: '…0021', class: 'Service', data: { name: 'Стрижка', price: { RUB: 1800, USD: 20 },
1316
- // active: true, duration: 60, description: 'классика' }, links: { Org: '…0001' }, tags: [],
1317
- // updated: '2026-07-10T18:38:27.40772+00:00' }
1651
+ const услуги = await db.Услуга().rows() // [11.1 ms] → 600 Row
1652
+ // [0] = { id: '00000004-0000-4000-8000-000000000000', class: 'Service',
1653
+ // data: { name: 'Стрижка 0', duration: 30, description: 'популярное' },
1654
+ // links: { Org: '00000001-0000-4000-8000-000000000000' }, tags: [],
1655
+ // updated: '2025-08-21T10:18:02.707+00:00' }
1318
1656
  ```
1319
1657
 
1320
1658
  **Кейс: пути vs уникальные сущности**
1321
1659
 
1322
1660
  ```ts
1323
- await db.навык().Услуга().count() // [57.2 ms] → 1001 путей (навыков на услуги)
1324
- (await db.навык().Услуга().rows()).length // [60.8 ms] → 401 уникальная услуга
1661
+ await db.навык().Услуга().count() // [65.2 ms] → 1260 путей (навык → услуга)
1662
+ (await db.навык().Услуга().rows()).length // [83.6 ms] → 600 уникальных услуг
1325
1663
  ```
1326
1664
 
1327
1665
  #### `.first(): Promise<Row | null>`
@@ -1329,10 +1667,10 @@ await db.навык().Услуга().count() // [57.2 ms] → 1001 пу
1329
1667
  Параметров нет. То же, что `rows()` с `LIMIT 1`: первая строка или `null`.
1330
1668
 
1331
1669
  ```ts
1332
- await db.Сотрудник({ name: 'Олег' }).first() // [4.9 ms]
1333
- // → { id: '…0012', class: 'Staff', data: { name: 'Олег', roles: ['master'], active: true },
1334
- // links: { Org: '…0001' }, tags: [], updated: '2026-07-10T18:38:27.275231+00:00' }
1335
- await db.Сотрудник({ name: 'Гэндальф' }).first() // [3.9 ms] → null
1670
+ await db.Мастер({ name: 'Олег 0.7' }).first() // [5.6 ms]
1671
+ // → { id: '…0007', class: 'Staff', data: { name: 'Олег 0.7', phone: '+7 921 0000007', specialization: 'колорист' },
1672
+ // links: { Org: '…0000' }, tags: [], updated: '2025-08-01T00:00:00+00:00' }
1673
+ await db.Мастер({ name: 'Гэндальф' }).first() // [5.7 ms] → null
1336
1674
  ```
1337
1675
 
1338
1676
  **Кейс: проверка «занято ли окно» перед бронью** — § 11.6 (перечитка под локом).
@@ -1343,8 +1681,8 @@ await db.Сотрудник({ name: 'Гэндальф' }).first() // [3.9 ms]
1343
1681
  (дешевле по трафику).
1344
1682
 
1345
1683
  ```ts
1346
- await db.Сотрудник().ids() // [3.8 ms] → 302 id
1347
- // ['00000000-0000-4000-8000-000000000011', '…0012', '00000003-0000-4000-8000-000000000000', …]
1684
+ await db.Мастер().ids() // [4.3 ms] → 360 id
1685
+ // ['00000003-0000-4000-8000-000000000000', '…0001', '00000003-0000-4000-8000-000000000002', …]
1348
1686
  ```
1349
1687
 
1350
1688
  **Кейс: набор id для батч-обработки** — собрать `ids()`, скормить очереди задач; полные
@@ -1359,17 +1697,17 @@ await db.Сотрудник().ids() // [3.8 ms] → 302 id
1359
1697
  для многошаговой — нет (см. кейс `.rows()`).
1360
1698
 
1361
1699
  ```ts
1362
- await db.Организация('…0001').Сотрудник().count() // [5.4 ms] → 2
1363
- await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 301
1364
- await db.Организация({ settings: { booking: { deposit: { amount: gte(900) } } } }).count()
1365
- // [2.2 ms] → 0 — оператор на листе глубины 4, каст numeric по Schema
1700
+ await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12
1701
+ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [37.4 ms] → 630
1702
+ await db.Локация({ coordinates: { lat: gte(55.5) } }).count()
1703
+ // [4.4 ms] → 26 — оператор на листе record-пути (глубина 2), каст numeric по Schema
1366
1704
  ```
1367
1705
 
1368
1706
  **Кейс: витрина каталога** — счётчики к фильтрам без выборки строк:
1369
1707
 
1370
1708
  ```ts
1371
- await db.Услуга({ duration: lte(45) }).count() // [3.0 ms] → 201 «быстрые»
1372
- await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 301 «премиум»
1709
+ await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300 «быстрые»
1710
+ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [37.4 ms] → 630 «премиум»
1373
1711
  ```
1374
1712
 
1375
1713
  #### `.limit(n): Chain` / `.offset(n): Chain`
@@ -1385,10 +1723,10 @@ await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 3
1385
1723
  **Примеры**
1386
1724
 
1387
1725
  ```ts
1388
- await db.Услуга().sort('data.price.RUB', 'desc').limit(3).rows() // [5.5 ms]
1389
- // → [{ name: 'Стрижка', RUB: 1800 }, { name: 'Услуга 399', RUB: 1599 }, { name: 'Услуга 398', RUB: 1598 }]
1390
- await db.Услуга().sort('data.price.RUB', 'desc').limit(3).offset(3).rows() // [5.3 ms]
1391
- // → [{ RUB: 1597 }, { RUB: 1596 }, { RUB: 1595 }] — вторая страница
1726
+ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms]
1727
+ // → [{ note: 'базовая', RUB: 8100 }, { note: 'базовая', RUB: 8000 }, { note: 'базовая', RUB: 7900 }]
1728
+ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows() // [19.5 ms]
1729
+ // → [{ RUB: 7800 }, { RUB: 7700 }, { RUB: 7600 }] — вторая страница
1392
1730
  ```
1393
1731
 
1394
1732
  **Кейс: классическая пагинация страницы каталога** — `limit(3)` + `offset(3·N)`; при выходе
@@ -1398,21 +1736,21 @@ await db.Услуга().sort('data.price.RUB', 'desc').limit(3).offset(3).rows()
1398
1736
 
1399
1737
  | Параметр | Тип | Описание |
1400
1738
  |---|---|---|
1401
- | `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.price.RUB`); SQL-каст по типу листа из Schema |
1739
+ | `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.coordinates.lat`); SQL-каст по типу листа из Schema |
1402
1740
  | `dir` | `'asc'` \| `'desc'` \| `boolean?` | default `asc`; `true` ≡ `'desc'` |
1403
1741
 
1404
1742
  **Назначение и алгоритм.** `ORDER BY` по колонке `updated` или по выражению
1405
- `data->'price'->>'RUB'` с кастом (numeric/text/timestamptz — из типа листа в Schema).
1743
+ `data->'coordinates'->>'lat'` с кастом (numeric/text/timestamptz — из типа листа в Schema).
1406
1744
  Обязателен для `.after()`.
1407
1745
 
1408
1746
  **Примеры**
1409
1747
 
1410
1748
  ```ts
1411
- await db.Услуга().sort('data.price.RUB', 'desc').limit(3).rows() // [5.5 ms] → 1800, 1599, 1598
1412
- await db.Услуга().sort('data.duration').limit(2).rows() // [6.3 ms] asc по умолчанию
1413
- // → [{ name: 'Королевское бритьё', duration: 30 }, { name: 'Услуга 0', duration: 30 }]
1414
- await db.Запись().sort('updated', 'desc').limit(2).rows() // [1613.1 ms]
1415
- // ЧЕСТНО: DISTINCT ON всех 250k сущностей класса без фильтра — см. § 14
1749
+ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms] → 8100, 8000, 7900
1750
+ await db.Услуга().sort('data.duration').limit(2).rows() // [7.5 ms] asc по умолчанию
1751
+ // → [{ name: 'Стрижка 0', duration: 30 }, { name: 'Педикюр 4', duration: 30 }]
1752
+ await db.запись().sort('updated', 'desc').limit(2).rows() // [6232.3 ms]
1753
+ // ЧЕСТНО: DISTINCT ON всех ~440k сущностей класса без фильтра — см. § 14
1416
1754
  ```
1417
1755
 
1418
1756
  **Кейс: топ прайса** — первый пример; правило объёма: сортировка **всего** большого класса
@@ -1432,15 +1770,15 @@ await db.Запись().sort('updated', 'desc').limit(2).rows() // [161
1432
1770
  **Примеры**
1433
1771
 
1434
1772
  ```ts
1435
- const истор = await db.Услуга('…0021').versions() // [5.1 ms] (для t1 ниже)
1436
- await db.Услуга('…0021').asOf(истор[0].updated).first() // [4.8 ms]
1437
- // → data.price = { RUB: 1500 } — цена ТОГДА
1438
- await db.Услуга('…0021').first()
1439
- // → data.price = { RUB: 1800, USD: 20 } — цена сейчас
1773
+ const истор = await db.цена('…0009').versions() // [7.7 ms] (для t1 ниже)
1774
+ await db.цена('…0009').asOf(истор[0].updated).first() // [3.0 ms]
1775
+ // → data.amounts.RUB = 2550 — цена ТОГДА
1776
+ await db.цена('…0009').first()
1777
+ // → data.amounts.RUB = 2650 — цена сейчас
1440
1778
  ```
1441
1779
 
1442
1780
  **Кейс: спор по чеку** — «сколько стоила стрижка в момент оформления записи»:
1443
- `db.Услуга(id).asOf(запись.updated).first()` → исторический прайс без отдельных таблиц аудита.
1781
+ `db.цена(id).asOf(запись.updated).first()` → исторический прайс без отдельных таблиц аудита.
1444
1782
 
1445
1783
  #### `.versions(): Promise<Row[]>`
1446
1784
 
@@ -1454,14 +1792,12 @@ await db.Услуга('…0021').first()
1454
1792
  **Примеры**
1455
1793
 
1456
1794
  ```ts
1457
- await db.Услуга('…0021').versions() // [5.1 ms]
1458
- // → [{ price: { RUB: 1500 }, updated: '…27.284125' },
1459
- // { price: { RUB: 1800, USD: 20 }, updated: '…27.40772' }]
1460
- await db.Запись('…0061').versions() // [6.7 ms] — жизнь с удалением и воскрешением:
1461
- // → [{ status: 'created', updated: '…27.438002' },
1462
- // { status: 'confirmed', updated: '…27.521769' },
1463
- // { status: 'confirmed', updated: '…33.652394', $deleted: true }, ← tombstone
1464
- // { status: 'created', updated: '…33.822616' }] ← воскрешение
1795
+ await db.цена('…0009').versions() // [7.7 ms]
1796
+ // → [{ RUB: 2550, updated: '2025-08-23T…' }, { RUB: 2650, updated: '2025-11-21T…' }]
1797
+ await db.Клиент('…0931').versions() // жизнь с удалением и воскрешением:
1798
+ // → [{ name: 'Злата', updated: '…54.159646' },
1799
+ // { name: 'Злата', updated: '…55.627712', $deleted: true }, ← tombstone
1800
+ // { name: 'Злата', updated: '…55.678666' }] ← воскрешение (create по тому же id)
1465
1801
  ```
1466
1802
 
1467
1803
  **Кейс: аудит «кто когда менял»** — `versions()` + `owner` каждой версии = полный
@@ -1483,18 +1819,19 @@ await db.Запись('…0061').versions() // [6.7 ms] — жизнь с уд
1483
1819
  **Примеры**
1484
1820
 
1485
1821
  ```ts
1486
- const p1 = await db.Запись().sort('updated', 'desc').limit(3).rows() // [1568.3 ms] — 250k
1487
- const кур = cursorOf(p1.at(-1)) // [95 µs]
1488
- // → { v: '2026-07-06T15:46:14.577+00:00', id: '00000008-0000-4000-8000-000000130266' }
1489
- const p2 = await db.Запись().sort('updated', 'desc').after(кур).limit(3).rows() // [1587.8 ms]
1822
+ const p1 = await db.запись().sort('updated', 'desc').limit(3).rows() // [5632.0 ms] — ~440k
1823
+ const кур = cursorOf(p1.at(-1)) // [82 µs]
1824
+ // → { v: '2026-07-25T18:00:00+00:00', id: '00000009-0000-4000-8000-000000431198' }
1825
+ const p2 = await db.запись().sort('updated', 'desc').after(кур).limit(3).rows() // [5680.4 ms]
1490
1826
  // p2 — следующие 3, пересечение страниц: 0
1827
+ // (у тысяч записей последнего дня updated совпадает — id вторым ключом ORDER BY держит границу)
1491
1828
 
1492
- const курЦены = cursorOf(топ3.at(-1), 'data.price.RUB') // [45 µs] → { v: 1598, id: '…0398' }
1493
- await db.Услуга().sort('data.price.RUB', 'desc').after(курЦены).limit(3).rows() // [8.9 ms]
1494
- // → 1597, 1596, 1595
1829
+ const курЦены = cursorOf(топ3.at(-1), 'data.amounts.RUB') // [41 µs] → { v: 7900, id: '…0951' }
1830
+ await db.цена().sort('data.amounts.RUB', 'desc').after(курЦены).limit(3).rows() // [20.5 ms]
1831
+ // → 7800, 7700, 7600
1495
1832
 
1496
- await db.Запись().after(кур).rows()
1497
- // Error: letopis: .after(cursor) requires .sort(field) [0.1 ms]
1833
+ await db.запись().after(кур).rows()
1834
+ // Error: letopis: .after(cursor) requires .sort(field) [0.3 ms]
1498
1835
  ```
1499
1836
 
1500
1837
  **Кейс: бесконечная лента записей**
@@ -1502,7 +1839,7 @@ await db.Запись().after(кур).rows()
1502
1839
  ```ts
1503
1840
  let кур
1504
1841
  for (;;) {
1505
- let q = db.Запись().sort('updated', 'desc').limit(100)
1842
+ let q = db.запись().sort('updated', 'desc').limit(100)
1506
1843
  if (кур) q = q.after(кур)
1507
1844
  const стр = await q.rows()
1508
1845
  if (!стр.length) break
@@ -1525,19 +1862,22 @@ for (;;) {
1525
1862
  **Примеры**
1526
1863
 
1527
1864
  ```ts
1528
- await db.Папка('…0071').Папка().deep().rows() // [13.1 ms]
1529
- // → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Борода и усы', $depth: 2 }]
1530
- await db.Папка('…0071').Папка().deep(1).rows() // [8.3 ms] только прямые дети
1531
- // → [{ name: 'Мужской зал', $depth: 1 }]
1865
+ await db.Папка('…0000').Папка().deep().rows() // [9.2 ms]
1866
+ // → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 },
1867
+ // { name: 'Борода и усы', $depth: 2 }, { name: 'Уход', $depth: 3 }]
1868
+ await db.Папка('…0000').Папка().deep(1).rows() // [7.4 ms] только прямые дети
1869
+ // → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 }]
1532
1870
  ```
1533
1871
 
1534
1872
  **Кейс: хлебные крошки каталога** — дерево одним запросом, глубина из `$depth`:
1535
1873
 
1536
1874
  ```ts
1537
- const дерево = await db.Папка(корень).Папка().deep().rows() // [13.1 ms]
1875
+ const дерево = await db.Папка(корень).Папка().deep().rows() // [9.2 ms]
1538
1876
  дерево.map((p) => `${' '.repeat(p.$depth)}${p.data.name}`)
1539
1877
  // → Мужской зал
1878
+ // Женский зал
1540
1879
  // Борода и усы
1880
+ // Уход
1541
1881
  ```
1542
1882
 
1543
1883
  #### `.sum(field)` / `.avg(field): Promise<number | null>`
@@ -1553,14 +1893,14 @@ const дерево = await db.Папка(корень).Папка().deep().rows(
1553
1893
  **Примеры**
1554
1894
 
1555
1895
  ```ts
1556
- await db.Запись({ status: 'completed' }).sum('data.total.RUB') // [236.9 ms] → 177500000
1557
- await db.Услуга().avg('data.duration') // [4.3 ms] → 52.46268656716418
1558
- await db.Организация({}).sum('data.settings.booking.deposit.amount') // [2.9 ms] лист глубины 4
1559
- await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [4.0 ms] → null (пусто)
1896
+ await db.цена().sum('data.amounts.RUB') // [13.2 ms] → 2277000
1897
+ await db.Услуга().avg('data.duration') // [3.0 ms] → 52.5
1898
+ await db.Локация({}).sum('data.coordinates.lat') // [3.9 ms] лист record-пути (глубина 2) → 3327.925547539955
1899
+ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [5.0 ms] → null (пусто)
1560
1900
  ```
1561
1901
 
1562
- **Кейс: выручка за период без выгрузки строк** — `sum` по 50 000 завершённых записей
1563
- за 237 ms; строки в приложение не едут.
1902
+ **Кейс: итог по каталогу без выгрузки строк** — `sum('data.amounts.RUB')` по 405 ценам за 4 ms;
1903
+ на полигоне salondemo — 1026 цен на 2 277 000 за ≈13 ms (§ 14). Строки в приложение не едут.
1564
1904
 
1565
1905
  #### `.min(field)` / `.max(field): Promise<unknown>`
1566
1906
 
@@ -1568,8 +1908,8 @@ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') /
1568
1908
  **числом**, string — строкой.
1569
1909
 
1570
1910
  ```ts
1571
- await db.Услуга().min('data.price.RUB') // [3.6 ms] → 800 (число, не '800')
1572
- await db.Услуга().max('data.price.RUB') // [4.2 ms] → 1800
1911
+ await db.цена().min('data.amounts.RUB') // [15.0 ms] → 300 (число, не '300')
1912
+ await db.цена().max('data.amounts.RUB') // [14.8 ms] → 8100
1573
1913
  ```
1574
1914
 
1575
1915
  **Кейс: границы ценового слайдера** — `min` + `max` двумя запросами по 3–4 ms.
@@ -1584,11 +1924,12 @@ await db.Услуга().max('data.price.RUB') // [4.2 ms] → 1800
1584
1924
  объекта = значения поля, значения = счётчики (по путям).
1585
1925
 
1586
1926
  ```ts
1587
- await db.Запись().countBy('data.status') // [575.8 ms] — 250k сущностей
1588
- // → { confirmed: 150000, cancelled: 50000, completed: 50000, created: 1 }
1927
+ await db.запись().countBy('data.notes') // [3266.3 ms] — ~440k сущностей
1928
+ // → { 'подтверждена': 400000, 'по телефону: Вера': 3334, 'по телефону: Ольга': 3334, …,
1929
+ // 'по телефону: Марина': 3333 } — 12 имён «по телефону» по ~3333 (ручные брони ~9%)
1589
1930
  ```
1590
1931
 
1591
- **Кейс: дашборд статусов** — один запрос вместо N `count()`; 250k строк агрегирует БД.
1932
+ **Кейс: дашборд по заметкам записей** — один запрос вместо N `count()`; ~440k строк агрегирует БД.
1592
1933
 
1593
1934
  #### `.alias(name): Chain`
1594
1935
 
@@ -1600,8 +1941,8 @@ await db.Запись().countBy('data.status') // [575.8 ms] — 250k сущн
1600
1941
  когда один класс встречается в пути дважды.
1601
1942
 
1602
1943
  ```ts
1603
- await db.Организация(org).alias('салон').Сотрудник({ name: 'Вася' }).alias('мастер').run()
1604
- // [6.4 ms] → ключи пути: ['салон', 'мастер']
1944
+ await db.Организация(org).alias('салон').Мастер({ name: 'Ольга 0.0' }).alias('мастер').run()
1945
+ // [6.7 ms] → ключи пути: ['салон', 'мастер']
1605
1946
  ```
1606
1947
 
1607
1948
  **Кейс: self-join читаемо** — `db.Папка(a).alias('родитель').Папка().alias('дочка').run()`.
@@ -1616,8 +1957,8 @@ await db.Организация(org).alias('салон').Сотрудник({ na
1616
1957
  кандидаты + перепроверка). В записи — модификатор значения.
1617
1958
 
1618
1959
  ```ts
1619
- await db.Клиент().tags('vip').count() // [3.9 ms] → 1 (среди 30k клиентов)
1620
- await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms] → 1
1960
+ await db.Клиент().tags('vip').count() // [24.7 ms] → 400 (vip-клиенты)
1961
+ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
1621
1962
  ```
1622
1963
 
1623
1964
  **Кейс: пометить и найти** — § 5 (вставка с `.tags(['vip','telegram'])`, поиск `tags('vip')`);
@@ -1634,8 +1975,8 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms]
1634
1975
  ошибка `acl pins` (§ 11.10).
1635
1976
 
1636
1977
  ```ts
1637
- await db.Организация().account(SYS).count() // [3.4 ms] → 21
1638
- await db.Организация().owner(SYS).count() // [3.4 ms] → 21
1978
+ await db.Организация().account(SYS).count() // [5.1 ms] → 30
1979
+ await db.Организация().owner(SYS).count() // [3.9 ms] → 30
1639
1980
  ```
1640
1981
 
1641
1982
  **Кейс: чей это салон** — профиль владельца строки: `db.accounts.get(row.owner)`; выборка
@@ -1644,17 +1985,17 @@ await db.Организация().owner(SYS).count() // [3.4 ms] → 21
1644
1985
  ### 11.4 Операторы фильтров
1645
1986
 
1646
1987
  18 функций-операторов: каждая возвращает объект-условие `Op` для значения поля в фильтре
1647
- шага (`{ duration: gte(60) }`), включая record-пути (`{ price: { RUB: gte(1300) } }` —
1988
+ шага (`{ duration: gte(60) }`), включая record-пути (`{ coordinates: { lat: gte(55.5) } }` —
1648
1989
  условие на листе любой глубины, SQL-каст по типу листа из Schema). Компилируются в
1649
1990
  выражение на актуальной строке (`(data->>'duration')::numeric >= 60`); containment-части
1650
- дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (402 сущности).
1991
+ дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (600 сущностей).
1651
1992
 
1652
1993
  #### `ne(v): Op`
1653
1994
 
1654
1995
  `v: string | number | boolean | null` — «не равно» (`IS DISTINCT FROM` — null-безопасно).
1655
1996
 
1656
1997
  ```ts
1657
- await db.Услуга({ name: ne('Стрижка') }).count() // [3.4 ms] → 401
1998
+ await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms] → 570
1658
1999
  ```
1659
2000
 
1660
2001
  **Кейс:** всё, кроме выбранного, — «другие услуги» под карточкой текущей.
@@ -1664,30 +2005,30 @@ await db.Услуга({ name: ne('Стрижка') }).count() // [3.4 ms] →
1664
2005
  `v: number | string` — строго больше / больше-или-равно (числа и сравнимые строки-даты).
1665
2006
 
1666
2007
  ```ts
1667
- await db.Услуга({ duration: gt(60) }).count() // [3.2 ms] → 100
1668
- await db.Услуга({ duration: gte(60) }).count() // [4.7 ms] → 201
2008
+ await db.Услуга({ duration: gt(60) }).count() // [6.4 ms] → 150
2009
+ await db.Услуга({ duration: gte(60) }).count() // [4.3 ms] → 300
1669
2010
  ```
1670
2011
 
1671
2012
  **Кейс:** граница включительно или нет — «от часа» это `gte(60)`; `gt(60)` потеряет
1672
- ровно-часовые (201 vs 100).
2013
+ ровно-часовые (300 vs 150).
1673
2014
 
1674
2015
  #### `lt(v): Op` / `lte(v): Op`
1675
2016
 
1676
2017
  `v: number | string` — строго меньше / меньше-или-равно.
1677
2018
 
1678
2019
  ```ts
1679
- await db.Услуга({ duration: lt(45) }).count() // [3.3 ms] → 101
1680
- await db.Услуга({ duration: lte(45) }).count() // [3.0 ms] → 201
2020
+ await db.Услуга({ duration: lt(45) }).count() // [5.1 ms] → 150
2021
+ await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300
1681
2022
  ```
1682
2023
 
1683
- **Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 201 услуга.
2024
+ **Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 300 услуг.
1684
2025
 
1685
2026
  #### `between(a, b): Op`
1686
2027
 
1687
2028
  `a, b: number | string` — диапазон включительно (`a ≤ x ≤ b`).
1688
2029
 
1689
2030
  ```ts
1690
- await db.Услуга({ duration: between(40, 65) }).count() // [3.6 ms] → 201
2031
+ await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 300
1691
2032
  ```
1692
2033
 
1693
2034
  **Кейс:** слайдер длительности «40–65 минут» одной функцией вместо пары gte+lte.
@@ -1697,7 +2038,7 @@ await db.Услуга({ duration: between(40, 65) }).count() // [3.6 ms] → 2
1697
2038
  `vs: (string | number)[]` — значение из списка (`IN`).
1698
2039
 
1699
2040
  ```ts
1700
- await db.Услуга({ name: inList(['Стрижка', 'Услуга 7']) }).count() // [2.8 ms] → 2
2041
+ await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [4.4 ms] → 60
1701
2042
  ```
1702
2043
 
1703
2044
  **Кейс:** сравнение выбранных чекбоксами услуг: имена из UI → один запрос.
@@ -1707,72 +2048,73 @@ await db.Услуга({ name: inList(['Стрижка', 'Услуга 7']) }).co
1707
2048
  `s: string` — SQL-шаблон (`%` — любое, `_` — один символ); `ilike` — без учёта регистра.
1708
2049
 
1709
2050
  ```ts
1710
- await db.Услуга({ name: like('Стри%') }).count() // [3.2 ms] → 1
1711
- await db.Услуга({ name: ilike('%королевское%') }).count() // [3.0 ms] → 1
2051
+ await db.Услуга({ name: like('Стри%') }).count() // [4.3 ms] → 60
2052
+ await db.Услуга({ name: ilike('%массаж%') }).count() // [4.3 ms] → 60
1712
2053
  ```
1713
2054
 
1714
2055
  **Кейс:** живой поиск в админке — `ilike('%' + ввод + '%')` прощает регистр
1715
- («королевское» находит «Королевское бритьё»).
2056
+ («массаж» находит «Массаж 7», «Массаж 17»).
1716
2057
 
1717
2058
  #### `starts(s): Op` / `ends(s): Op`
1718
2059
 
1719
2060
  `s: string` — начинается с / заканчивается на (сахар над `like(s+'%')` / `like('%'+s)`).
1720
2061
 
1721
2062
  ```ts
1722
- await db.Услуга({ name: starts('Услуга 39') }).count() // [3.2 ms] → 11
1723
- await db.Услуга({ name: ends('бритьё') }).count() // [3.2 ms] → 1
2063
+ await db.Услуга({ name: starts('Массаж') }).count() // [3.7 ms] → 60
2064
+ await db.Услуга({ name: ends('7') }).count() // [2.9 ms] → 60
1724
2065
  ```
1725
2066
 
1726
- **Кейс:** префиксная навигация по артикулам: `starts('Услуга 39')` → 39, 390…399.
2067
+ **Кейс:** префиксная навигация по названию: `starts('Массаж')` → все «Массаж N» (60 в каталоге).
1727
2068
 
1728
2069
  #### `has(v): Op` / `hasAny(vs): Op` / `hasAll(vs): Op`
1729
2070
 
1730
- `v: скаляр`, `vs: скаляр[]` — массив-поле содержит значение / хотя бы одно / все
1731
- (containment `@>` — идёт и в GIN-кандидаты).
2071
+ `v: скаляр`, `vs: скаляр[]` — массив содержит значение / хотя бы одно / все (containment `@>` —
2072
+ идёт и в GIN-кандидаты). В демо-схеме массивов в `data` нет — операторы показаны на колонке `tags`.
1732
2073
 
1733
2074
  ```ts
1734
- await db.Сотрудник({ roles: has('owner') }).count() // [2.7 ms] → 1
1735
- await db.Сотрудник({ roles: hasAny(['owner', 'admin']) }).count() // [2.6 ms] → 1
1736
- await db.Сотрудник({ roles: hasAll(['owner', 'master']) }).count() // [3.3 ms] → 1
2075
+ await db.Клиент().tags(has('vip')).count() // [54.5 ms] → 400
2076
+ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
2077
+ await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [11.0 ms] → 100
1737
2078
  ```
1738
2079
 
1739
- **Кейс:** права из массива ролей: «может закрывать смену» = `hasAny(['owner','admin'])`;
1740
- «владелец, который сам стрижёт» = `hasAll(['owner','master'])`.
2080
+ **Кейс:** сегменты по меткам: «vip ИЛИ из телеграма» = `tags(hasAny(['vip','telegram']))` → 800;
2081
+ «vip И телеграм разом» = `tags(hasAll(['vip','telegram']))` → 100.
1741
2082
 
1742
2083
  #### `exists(yes = true): Op`
1743
2084
 
1744
2085
  `yes: boolean?` — поле присутствует (`true`, default) / отсутствует (`false`) в `data`.
1745
2086
 
1746
2087
  ```ts
1747
- await db.Услуга({ description: exists() }).count() // [3.4 ms] → 1
1748
- await db.Услуга({ description: exists(false) }).count() // [2.8 ms] → 401
2088
+ await db.Услуга({ description: exists() }).count() // [3.7 ms] → 390
2089
+ await db.Услуга({ description: exists(false) }).count() // [3.2 ms] → 210
1749
2090
  ```
1750
2091
 
1751
- **Кейс:** контроль заполненности каталога — «услуги без описания» = `exists(false)` → 401
1752
- на доработку контенту.
2092
+ **Кейс:** контроль заполненности каталога — «услуги без ключа `description`» = `exists(false)` → 210
2093
+ на доработку контенту (не путать с `isNull()` ниже — тот ещё и явные `null` ловит).
1753
2094
 
1754
2095
  #### `isNull(): Op`
1755
2096
 
1756
2097
  Без параметров — поле `NULL` **или** отсутствует.
1757
2098
 
1758
2099
  ```ts
1759
- await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 401
2100
+ await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
1760
2101
  ```
1761
2102
 
1762
- **Кейс:** отличие от `exists(false)`: `isNull()` ловит и явный `null` в data, и отсутствие
1763
- ключа; `exists(false)` — только отсутствие.
2103
+ **Кейс:** отличие от `exists(false)` — на полигоне видно числом: `isNull()` → 390 (ловит и явный
2104
+ `null` в data, и отсутствие ключа), `exists(false)` → 210 (только отсутствие); расхождение 180 —
2105
+ это услуги с явным `description: null`.
1764
2106
 
1765
2107
  #### `not(v): Op`
1766
2108
 
1767
2109
  `v: скаляр | Op` — отрицание; скаляр ≡ «не равно» (как `ne`).
1768
2110
 
1769
2111
  ```ts
1770
- await db.Услуга({ name: not(starts('Услуга')) }).count() // [3.4 ms] → 2
1771
- await db.Услуга({ name: not('Стрижка') }).count() // [6.1 ms] → 401
2112
+ await db.Услуга({ name: not(starts('Стрижка')) }).count() // [3.7 ms] → 540
2113
+ await db.Услуга({ name: not('Стрижка 0') }).count() // [3.5 ms] → 570
1772
2114
  ```
1773
2115
 
1774
- **Кейс:** инверсия готового условия без переписывания: «всё, что НЕ сид-генерация» =
1775
- `not(starts('Услуга'))` → Стрижка и Королевское бритьё.
2116
+ **Кейс:** инверсия готового условия без переписывания: «всё, что НЕ стрижки» =
2117
+ `not(starts('Стрижка'))` → 540 (из 600 услуг 60 — «Стрижка N»).
1776
2118
 
1777
2119
  #### `or(...filters): Op`
1778
2120
 
@@ -1780,10 +2122,11 @@ await db.Услуга({ name: not('Стрижка') }).count() // [6.1
1780
2122
  обычное И).
1781
2123
 
1782
2124
  ```ts
1783
- await db.Услуга(or({ name: 'Стрижка' }, { duration: lt(45) })).count() // [4.0 ms] → 102
2125
+ await db.Услуга(or({ name: 'Стрижка 0' }, { duration: lt(45) })).count() // [4.0 ms] → 150
1784
2126
  ```
1785
2127
 
1786
- **Кейс:** «стрижка или что-нибудь быстрое» — один запрос: 1 (Стрижка) + 101 (быстрые) = 102.
2128
+ **Кейс:** «Стрижка 0 или что-нибудь быстрое» — один запрос: Стрижка 0 (duration 30) уже среди
2129
+ быстрых `lt(45)` → объединение = 150, отдельного плюса не даёт.
1787
2130
 
1788
2131
  ### 11.5 Запись: create / update / delete / anonymize
1789
2132
 
@@ -1814,28 +2157,28 @@ INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конф
1814
2157
  **Примеры**
1815
2158
 
1816
2159
  ```ts
1817
- await db.Организация().create({ name: 'Пилигрим' }).rows() // [9.0 ms] INSERT + defaults из Schema
1818
- // → [{ id: '39aba8a5-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, … }]
2160
+ await db.Организация().create({ name: 'Пилигрим' }).rows() // [11.1 ms] INSERT + defaults из Schema
2161
+ // → [{ id: '019f5a53-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, links: {}, … }]
1819
2162
 
1820
2163
  await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
1821
- // [7.0 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
2164
+ // [11.4 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
1822
2165
 
1823
- await db.Организация('…0901').Сотрудник().create({ id: '…0911', name: 'Мия', roles: ['master'] }).rows()
1824
- // [14.5 ms] контекст → links: { Org: '…0901' }
2166
+ await db.Организация('…0901').Мастер().create({ id: '…0911', name: 'Мия', phone: '+7 909 000-09-11', specialization: 'массажист' }).rows()
2167
+ // [15.2 ms] контекст → links: { Org: '…0901' }
1825
2168
 
1826
- await db.Организация('…0901').Услуга().create({ name: 'Укладка экспресс', duration: 15, price: { RUB: 700 } }).rows()
1827
- // [7.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
1828
- // повторный create той же пары (салон, имя) → новая ВЕРСИЯ той же услуги, не дубль
2169
+ await db.Организация('…0901').Услуга().create({ name: 'Массаж головы', duration: 30, description: 'релакс' }).rows()
2170
+ // [16.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
2171
+ // повторный create той же пары (салон, имя) → новая ВЕРСИЯ (deep-merge листьев), не дубль
1829
2172
 
1830
- db.Услуга({ name: 'Укладка экспресс' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
2173
+ db.Услуга({ name: 'Массаж головы' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
1831
2174
  // Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
1832
2175
 
1833
- await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() // [1.4 ms] — ошибка НА ТЕРМИНАЛЕ:
2176
+ await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [9.9 ms] — ошибка НА ТЕРМИНАЛЕ:
1834
2177
  // ValidationError: letopis: validation failed for "Service":
1835
- // duration — The 'duration' field is required.; … forbidden keys: 'чепуха'
2178
+ // The object '' contains forbidden keys: 'чепуха'.
1836
2179
 
1837
- db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
1838
- // Error: letopis: no path Service → Customer
2180
+ db.Локация('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
2181
+ // Error: letopis: no path Location → Customer: neither embeds the other (Schema)
1839
2182
  ```
1840
2183
 
1841
2184
  #### `.update(data?): Chain`
@@ -1846,7 +2189,7 @@ db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый
1846
2189
 
1847
2190
  **Назначение и алгоритм.** Новая версия **каждого** найденного путём. Цели ищутся как при
1848
2191
  чтении — id, фильтр, pivot; `Класс()` ≡ `Класс({})` — «все в границах контекста». На каждую
1849
- цель: **deep-merge** листьев `data` (`update({ price: { RUB: 1100 } })` сохранит `USD` и
2192
+ цель: **deep-merge** листьев `data` (`update({ coordinates: { lat: 55.8 } })` сохранит `lng` и
1850
2193
  остальные поля; массивы/скаляры — целиком), слияние links (слоты `.Класс.set()`/`.unset()`),
1851
2194
  строгая валидация, один INSERT новой версии. Не найдено → `[]` — update **НИКОГДА не
1852
2195
  создаёт**. Сегмент после операции исполняется **для каждой строки её результата** (fan-out);
@@ -1855,43 +2198,41 @@ db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый
1855
2198
  **Примеры**
1856
2199
 
1857
2200
  ```ts
1858
- await db.Услуга('…0921').update({ price: { RUB: 1400, USD: 15 } }).rows() // [7.0 ms] новая версия по id
1859
- // было: { name: 'Массаж головы', price: { RUB: 1200 }, duration: 30, description: 'релакс', … }
1860
- // стало: { name: 'Массаж головы', price: { RUB: 1400, USD: 15 }, duration: 30, description: 'релакс', … }
1861
- // deep-merge тронул только листья price; duration/description целы
1862
-
1863
- await db.Клиент('…0931').Запись({ status: 'created' }).update({ status: 'confirmed' }).rows() // [12.1 ms]
1864
- // → [{ id: '…0962', status: 'confirmed' }, { id: '…0963', status: 'confirmed' }] — только записи Златы
2201
+ await db.Клиент('…0931').запись({ start_datetime: between(t, t) }).update({ notes: 'подтверждена' }).rows() // [645.4 ms]
2202
+ // → [{ id: '4907d8cd-…', notes: 'подтверждена' }]
2203
+ // момент фильтруется between(t, t): скаляр-eq по date-полю = строковый containment, потому диапазон
2204
+ // (сотни мс: поиск целей идёт по всему классу записей ~440k без btree по data->>'start_datetime' — § 14)
1865
2205
 
1866
- await db.Клиент('…0931').Запись().update({ status: 'completed' }).rows() // [10.7 ms] ВСЕ в контексте
1867
- // → [{ id: '…0962', status: 'completed' }, { id: '…0963', status: 'completed' }]
2206
+ await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [600.4 ms] — ВСЕ записи в контексте
2207
+ // → [{ id: '4907d8cd-…', notes: 'день закрыт' }, { id: 'e7f16a08-…', notes: 'день закрыт' }]
1868
2208
 
1869
- await db.Запись({ status: 'нет-такого' }).update({ status: 'x' }).rows() // → [] — НИЧЕГО не создано
2209
+ await db.запись({ notes: 'нет-такого' }).update({ notes: 'x' }).rows() // → [] — update НИКОГДА не создаёт
1870
2210
  ```
1871
2211
 
1872
2212
  **Кейс: план из нескольких операций — реальный прогон**
1873
2213
 
1874
2214
  ```ts
1875
- // обновить клиента → вставить ему запись (продолжение от записанного, одна транзакция)
1876
- await db.Клиент('…0931').update({ language: 'en' })
1877
- .Запись().create({ id: '…0964', status: 'created', total: { RUB: 990 } })
1878
- .rows() // [15.5 ms]
1879
- // → [{ id: '…0964', class: 'Booking', links: { Customer: '…0931' }, status: 'created' }]
2215
+ // обновить клиента → вставить ему запись со слотами (продолжение от записанного, одна транзакция)
2216
+ await db.Клиент('…0931').update({ preferred_contact: 'messenger' })
2217
+ .запись().create({ start_datetime: '2026-08-04T07:00:00Z', end_datetime: '2026-08-04T07:30:00Z' })
2218
+ .Мастер.set(мия).Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows() // [20.0 ms]
2219
+ // → [{ id: '…d091', class: 'booking', links: { Staff, Service, Customer: '…0931', Location, Schedule },
2220
+ // data: { start_datetime: '2026-08-04T07:00:00.000Z', end_datetime: '2026-08-04T07:30:00.000Z' } }]
1880
2221
 
1881
2222
  // self-update: две версии подряд
1882
- await db.Запись('…0964').update({ status: 'confirmed' }).update({ status: 'completed' }).rows() // [13.6 ms]
1883
- // → ['completed']; versions: ['created', 'confirmed', 'completed']
2223
+ await db.запись('…d091').update({ notes: 'подтверждена' }).update({ notes: 'выполнена' }).rows() // [19.8 ms]
2224
+ // → ['выполнена']; versions: [null, 'подтверждена', 'выполнена']
1884
2225
 
1885
2226
  // хвост-чтение после операции — в той же транзакции
1886
- await db.Клиент('…0931').update({ language: 'ru' }).Запись().count() // [201.6 ms] → 7
2227
+ await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [16.7 ms] → 1
1887
2228
 
1888
- // ОТКАТ: невалидный второй сегмент откатывает и первый
1889
- await db.Клиент('…0931').update({ name: 'Не запишется' }).Запись().create({ чепуха: 1 }).rows()
1890
- // Error: letopis: validation failed for "Booking" … [6.1 ms]; имя клиента не изменилось
2229
+ // ОТКАТ: валидный update + невалидный create — весь план назад
2230
+ await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().create({ чепуха: 1 }).rows()
2231
+ // Error: letopis: validation failed for "booking" … forbidden keys: 'чепуха' [15.5 ms]; клиент не изменился
1891
2232
 
1892
2233
  // fan-out: обновить клиента → снести ВСЕ его записи
1893
- await db.Клиент('…0931').update({ active: true }).Запись().delete({ confirm: true }).rows()
1894
- // [229.4 ms] → 7 записей, все с $deleted: true
2234
+ await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().delete({ confirm: true }).rows()
2235
+ // [29.0 ms] → снесено 1 запись, с $deleted: true
1895
2236
  ```
1896
2237
 
1897
2238
  #### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
@@ -1911,19 +2252,27 @@ links **БЕЗ участия в фильтре целей** (в отличие
1911
2252
  **Примеры**
1912
2253
 
1913
2254
  ```ts
1914
- await db.Сотрудник(м9).навык().create().Услуга.set(у9).rows() // [4.1 ms] ОДИН INSERT
1915
- // → { id: '7b374ae4-…', class: 'skill', links: { Staff: '…0911', Service: '…0921' } }
1916
- // навык — v5: id вычислен из (Staff, Service|Complex) — второй раз тот же навык не завести
2255
+ await db.Мастер(мия).навык().create({ level: 'expert' }).Услуга.set(усл).rows() // [16.9 ms] ОДИН INSERT
2256
+ // → { id: '5e260ee8-…', class: 'skill', data: { level: 'expert' }, links: { Staff: '…0911', Service: '…' } }
2257
+ // навык — v5: id вычислен из (Staff, Service) — второй раз тот же навык не завести
2258
+
2259
+ // союз-конец [Услуга|Товар|Комплекс]: слот замещает целиком (соседний класс снят)
2260
+ await db.запись('…d091').update().Товар.set(воск).rows() // [10.3 ms] Service снят, Product встал
2261
+
2262
+ // снять optional-конец: ручная бронь без клиента, имя в notes
2263
+ await db.запись('…d091').update({ notes: 'бронь по телефону: Злата' }).Клиент.unset().rows() // [14.3 ms]
1917
2264
 
1918
- // вложенная цепочка — создать И привязать в одной транзакции:
1919
- await db.Запись(b).позиция().create({ qty: 1 }).Услуга.set(db.Услуга(у).update({ active: true })).rows()
2265
+ // required-конец снять нельзя:
2266
+ await db.запись('…d091').update().Локация.unset() // [0.3 ms]
2267
+ // Error: letopis: link end "Location" of "booking" is required — cannot unset
1920
2268
  ```
1921
2269
 
1922
- **Кейс: перевесить исполнителя на всех позициях записи**
2270
+ **Кейс: заменить предмет на всех записях клиента**
1923
2271
 
1924
2272
  ```ts
1925
- await tr.Запись(b).позиция().update().Сотрудник.set(новый).rows()
1926
- // цель ищется путём (все позиции записи); слот пишет нового исполнителя БЕЗ фильтра по старому
2273
+ await tr.Клиент(к).запись().update().Комплекс.set(комплекс).rows()
2274
+ // цель ищется путём (все записи клиента); слот пишет новый предмет — союз [Услуга|Товар|Комплекс]
2275
+ // замещается целиком, БЕЗ фильтра по старому концу
1927
2276
  ```
1928
2277
 
1929
2278
  #### `.delete(opts?): Chain`
@@ -1944,24 +2293,24 @@ tombstone-версия → рекурсивное удаление зависи
1944
2293
  **Примеры**
1945
2294
 
1946
2295
  ```ts
1947
- await db.Запись('…0961').delete().rows() // [192.7 ms] ПРЕВЬЮ — кандидаты живы:
1948
- // → [{ id: '…0961', class: 'Booking' }, { class: 'busy', … }, { class: 'item', … }] — бронь + занятость + позиция
1949
- // запись жива: true
2296
+ await db.Клиент('…0931').delete().rows() // [14.7 ms] ПРЕВЬЮ — кандидаты живы:
2297
+ // → [{ id: '…0931', class: 'Customer' }, { class: 'booking' }, { class: 'booking' }] — клиент + его записи (каскад)
2298
+ // после превью клиент жив: true
1950
2299
 
1951
- await db.Запись('…0961').delete({ confirm: true }).rows() // [209.2 ms] — сервер нашёл зависимых:
2300
+ await db.Клиент('…0931').delete({ confirm: true }).rows() // [35.8 ms] — сервер нашёл зависимых:
1952
2301
  // → те же три, каждый с $deleted: true
1953
- await db.Запись('…0961').delete({ confirm: true }).rows() // [5.1 ms] повторно → []
2302
+ await db.Клиент('…0931').delete({ confirm: true }).rows() // [6.8 ms] повторно → []
1954
2303
  ```
1955
2304
 
1956
- **Кейс: отмена брони с показом последствий**
2305
+ **Кейс: отмена и воскрешение**
1957
2306
 
1958
2307
  ```ts
1959
- const последствия = await db.Запись(bid).delete().rows() // [192.7 ms] показать оператору
2308
+ const последствия = await db.Клиент(cid).delete().rows() // [14.7 ms] оператору: клиент + N его записей
1960
2309
  if (операторПодтвердил) {
1961
- await db.Запись(bid).delete({ confirm: true }).rows() // [209.2 ms] бронь + занятость + позиция
2310
+ await db.Клиент(cid).delete({ confirm: true }).rows() // [35.8 ms] клиент и записи — tombstone (каскад)
1962
2311
  }
1963
- // клиент передумал: воскрешение тем же id — create (зависимые пересоздать явно)
1964
- await db.Клиент('…0931').Запись('…0961').create({ status: 'created', total: { RUB: 500 } }).rows() // [11.3 ms]
2312
+ // клиент вернулся: воскрешение тем же id — create по (Org, id)
2313
+ await db.Организация('…0901').Клиент().create({ id: cid, name: 'Злата', phone: '+7 …' }).rows() // [16.3 ms]
1965
2314
  ```
1966
2315
 
1967
2316
  #### `.anonymize(fields): Chain`
@@ -1978,17 +2327,17 @@ await db.Клиент('…0931').Запись('…0961').create({ status: 'creat
1978
2327
  **Примеры**
1979
2328
 
1980
2329
  ```ts
1981
- await db.Клиент('…0931').anonymize(['name', 'contact']).rows() // [7.8 ms]
1982
- // → [{ data: { name: '[erased]', contact: '[erased]', active: true, language: 'ru' },
2330
+ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
2331
+ // → [{ data: { name: '[erased]', phone: '[erased]', preferred_contact: 'phone' },
1983
2332
  // tags: ['anonymized'] }]
1984
2333
  ```
1985
2334
 
1986
2335
  **Кейс: запрос на забвение**
1987
2336
 
1988
2337
  ```ts
1989
- await db.Клиент('…0931').anonymize(['name', 'contact']).rows() // [7.8 ms]
1990
- ;(await db.Клиент('…0931').versions()).map((r) => r.data.name) // → ['Злата', '[erased]']
1991
- await db.Клиент().tags('anonymized').count() // все стёртые — под контролем
2338
+ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
2339
+ ;(await db.Клиент('…0931').versions()).map((r) => r.data.name) // → ['Злата', 'Злата', 'Злата', '[erased]']
2340
+ await db.Клиент().tags('anonymized').count() // все стёртые — под контролем
1992
2341
  ```
1993
2342
 
1994
2343
  ### 11.6 Транзакции: EntityTx
@@ -2007,11 +2356,11 @@ await db.Клиент().tags('anonymized').count() // в
2007
2356
  **Примеры**
2008
2357
 
2009
2358
  ```ts
2010
- const tr = await db.begin() // [0.8 ms]
2011
- await tr.Услуга({ name: 'Укладка' }).update({ price: { RUB: 9900 } }).rows()
2012
- await tr.Услуга({ name: 'Укладка' }).first() // внутри → 9900
2359
+ const tr = await db.begin() // [1.1 ms]
2360
+ await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows() // цУкл — базовая цена «Укладки»
2361
+ await tr.цена(цУкл).first() // внутри → amounts.RUB = 9900
2013
2362
  await tr.rollback() // [0.8 ms]
2014
- await db.Услуга({ name: 'Укладка' }).first() // снаружи → 700, изменения нет
2363
+ await db.цена(цУкл).first() // снаружи → amounts.RUB = 700, изменения нет
2015
2364
  ```
2016
2365
 
2017
2366
  **Кейс: commit** — § 11.2 `db.begin()`; ниже — главный сценарий `lock`.
@@ -2032,30 +2381,32 @@ await db.Услуга({ name: 'Укладка' }).first() // снаружи
2032
2381
  **Примеры**
2033
2382
 
2034
2383
  ```ts
2035
- await trA.lock('busy', staffId, slotId) // [1.4 ms]
2384
+ await trA.lock('booking', staffId, start) // [3.1 ms]
2036
2385
  await db.lock('x')
2037
- // Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.1 ms]
2386
+ // Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.2 ms]
2038
2387
  ```
2039
2388
 
2040
2389
  **Кейс: гонка двойной брони — реальный прогон двух транзакций**
2041
2390
 
2042
2391
  ```ts
2043
- // два администратора жмут «забронировать» на одно окно одновременно;
2044
- // id занятости детерминирован (v5, § 3.2) — известен ДО создания:
2045
- const busyId = uuidv5(`v1.article:entity:busy:${окно.id}:${мастер.id}`)
2392
+ // два администратора жмут «забронировать» на одно время одновременно;
2393
+ // id записи детерминирован (v5, § 3.2) — известен ДО создания:
2394
+ const bId = uuidv5(`v1.salondemo:entity:booking:${мастер.id}:${start}`)
2046
2395
  const trA = await db.begin(), trB = await db.begin()
2047
- await trA.lock('busy', мастер.id, окно.id) // [1.4 ms] A первый
2396
+ await trA.lock('booking', мастер.id, start) // [3.1 ms] A первый
2048
2397
  const гонкаB = (async () => {
2049
- await trB.lock('busy', мастер.id, окно.id) // B ВИСИТ до конца trA
2050
- const занято = await trB.занятость(busyId).first() // перечитка под локом
2051
- if (занято) { await trB.rollback(); return 'ОТКАЗ: окно уже занято' }
2052
- await trB.Сотрудник(мастер).занятость().create({ kind: 'booking' }).Окно.set(окно).rows()
2398
+ await trB.lock('booking', мастер.id, start) // B ВИСИТ до конца trA
2399
+ const занято = await trB.запись(bId).first() // перечитка под локом
2400
+ if (занято) { await trB.rollback(); return 'ОТКАЗ: время уже занято' }
2401
+ await trB.Мастер(мастер).запись().create({ start_datetime: start, end_datetime: end })
2402
+ .Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows()
2053
2403
  await trB.commit(); return 'бронь моя'
2054
2404
  })()
2055
- await trA.занятость(busyId).first() // → null — свободно
2056
- await trA.Сотрудник(мастер).занятость().create({ kind: 'booking' }).Окно.set(окно).rows()
2405
+ await trA.запись(bId).first() // → null — свободно
2406
+ await trA.Мастер(мастер).запись().create({ start_datetime: start, end_datetime: end })
2407
+ .Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows()
2057
2408
  await trA.commit()
2058
- await гонкаB // → 'ОТКАЗ: окно уже занято' — B увидел бронь A, дубля нет
2409
+ await гонкаB // → 'ОТКАЗ: время уже занято' — B увидел бронь A, дубля нет
2059
2410
  // дубль невозможен и без лока (оба create вычислят ОДИН id — второй стал бы версией);
2060
2411
  // лок нужен, чтобы B получил честный отказ, а не молча версионировал чужую бронь
2061
2412
 
@@ -2076,7 +2427,7 @@ await гонкаB // → 'ОТКАЗ: окно уже занято' — B ув
2076
2427
  цепочке — ошибка `plan is queued in the batch`. Исполняет `run()`.
2077
2428
 
2078
2429
  ```ts
2079
- const b = db.batch('слоты-августа') // [16 µs]
2430
+ const b = db.batch('смены-августа') // [28 µs]
2080
2431
  ```
2081
2432
 
2082
2433
  #### `batch.run(): Promise<Row[][]>`
@@ -2093,14 +2444,14 @@ const b = db.batch('слоты-августа') // [16 µs]
2093
2444
  **Примеры**
2094
2445
 
2095
2446
  ```ts
2096
- const b = db.batch('слоты-августа')
2097
- b.Расписание(sch).Окно().create({ start: '2026-08-01T11:00:00Z', end: '…12:00Z' })
2098
- b.Расписание(sch).Окно().create({ start: '2026-08-01T12:00:00Z', end: '…13:00Z' })
2099
- b.Расписание(sch).Окно().create({ start: '2026-08-01T13:00:00Z', end: '…14:00Z' })
2100
- b.size() // [29 µs] → 3
2101
- await b.run() // [32.1 ms] — одна транзакция; Окно — v5-класс → 3 честных INSERT, не склейка
2102
- // → [[{ start: '2026-08-01T11:00:00.000Z', … }], [{ …12:00 }], [{ …13:00 }]]
2103
- // id каждого окна вычислен схемой: uuidv5(Schedule, start) — § 3.2
2447
+ const b = db.batch('смены-августа')
2448
+ b.Мастер(m).окно().create({ start_datetime: '2026-08-05T07:00:00Z', end_datetime: '…09:00Z' }).Локация.set(loc).Расписание.set(sch)
2449
+ b.Мастер(m).окно().create({ start_datetime: '2026-08-05T09:00:00Z', end_datetime: '…11:00Z' }).Локация.set(loc).Расписание.set(sch)
2450
+ b.Мастер(m).окно().create({ start_datetime: '2026-08-05T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
2451
+ b.size() // [35 µs] → 3
2452
+ await b.run() // [43.8 ms] — одна транзакция; окно — v5-класс → 3 честных INSERT, не склейка
2453
+ // → [[{ id: '06f67c88-…', start_datetime: '2026-08-05T07:00:00.000Z' }], [{ …09:00 }], [{ …11:00 }]]
2454
+ // id каждого окна вычислен схемой: uuidv5(Staff, start_datetime) — § 3.2
2104
2455
  b.size() // → 0
2105
2456
  ```
2106
2457
 
@@ -2115,10 +2466,10 @@ b.size() // → 0
2115
2466
 
2116
2467
  ```ts
2117
2468
  const b2 = db.batch('отмена')
2118
- b2.Расписание(sch).Окно().create({ start: '2026-08-02T11:00:00Z', end: '…12:00Z' })
2119
- b2.discard() // [53 µs]
2469
+ b2.Мастер(m).окно().create({ start_datetime: '2026-08-06T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
2470
+ b2.discard() // [87 µs]
2120
2471
  b2.size() // → 0
2121
- await db.Расписание(sch).Окно({ start: '2026-08-02T11:00:00Z' }).first() // → null — не исполнилось
2472
+ await db.Мастер(m).окно({ start_datetime: between('2026-08-06T11:00:00Z', '2026-08-06T11:00:00Z') }).first() // → null — не исполнилось
2122
2473
  ```
2123
2474
 
2124
2475
  **Кейс: черновик импорта** — копим операции по мере парсинга файла; ошибка парсера →
@@ -2141,7 +2492,7 @@ await db.Расписание(sch).Окно({ start: '2026-08-02T11:00:00Z' }).f
2141
2492
  `category` → `= ANY(categories)`.
2142
2493
 
2143
2494
  ```ts
2144
- await db.accounts.find({ category: 'Client', enabled: true }) // [2.2 ms] → 3 аккаунта
2495
+ await db.accounts.find({ category: 'Client', enabled: true }) // [2.4 ms] → 1 аккаунт
2145
2496
  ```
2146
2497
 
2147
2498
  **Кейс:** список арендаторов для биллинга: `find({ enabled: true })`, отключённые не в счёте.
@@ -2151,7 +2502,7 @@ await db.accounts.find({ category: 'Client', enabled: true }) // [2.2 ms] →
2151
2502
  `id: string` — точечный SELECT по PK.
2152
2503
 
2153
2504
  ```ts
2154
- await db.accounts.get(acc.id) // [1.7 ms] → Account | null
2505
+ await db.accounts.get(acc.id) // [1.9 ms] → Account | null
2155
2506
  ```
2156
2507
 
2157
2508
  **Кейс:** профиль владельца строки Entity: `db.accounts.get(row.owner)`.
@@ -2171,9 +2522,9 @@ await db.accounts.get(acc.id) // [1.7 ms] → Account | null
2171
2522
 
2172
2523
  ```ts
2173
2524
  const acc = await db.accounts.set({ categories: ['Client'], data: { название: 'ИП Ромашка' } })
2174
- // [10.6 ms] → { id: 'c66766f7-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
2175
- // meta: {}, avatar: 'https://i.pravatar.cc/128?img=37', enabled: true, created: …, updated: … }
2176
- await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [3.0 ms] update
2525
+ // [5.4 ms] → { id: '06d3bbfe-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
2526
+ // meta: {}, avatar: 'https://i.pravatar.cc/128?img=33', enabled: true, created: …, updated: … }
2527
+ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [5.7 ms] update
2177
2528
  ```
2178
2529
 
2179
2530
  **Кейс:** бан аккаунта одним полем: `set({ id, enabled: false })` — все `verify*` § 11.9
@@ -2185,8 +2536,8 @@ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) //
2185
2536
  в Entity (`account`/`owner` FK RESTRICT) не удалить — намеренно: история неприкосновенна.
2186
2537
 
2187
2538
  ```ts
2188
- await db.accounts.delete(времId) // [9.9 ms] → true (пустой аккаунт)
2189
- await db.accounts.delete(SYS) // [4.9 ms]
2539
+ await db.accounts.delete(времId) // [16.4 ms] → true (пустой аккаунт)
2540
+ await db.accounts.delete(SYS) // [6.0 ms]
2190
2541
  // Error: update or delete on table "Account" violates foreign key constraint "entity_account_fk"
2191
2542
  ```
2192
2543
 
@@ -2202,7 +2553,7 @@ await db.accounts.delete(SYS) // [4.9 ms]
2202
2553
  | `f.withDeleted` | `boolean?` | включить мягко-удалённые (default — только живые `deleted IS NULL`) |
2203
2554
 
2204
2555
  ```ts
2205
- await db.credentials.find({ account: acc.id }) // [2.5 ms] → 1 живой
2556
+ await db.credentials.find({ account: acc.id }) // [3.2 ms] → 1 живой
2206
2557
  await db.credentials.find({ account: acc.id, withDeleted: true }) // → 1 (после delete: 0 и 1)
2207
2558
  ```
2208
2559
 
@@ -2224,9 +2575,9 @@ telegram списком.
2224
2575
 
2225
2576
  ```ts
2226
2577
  const кред = await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
2227
- // [3.8 ms] → { id: '0d1e98b7-…', confirmed: true, deleted: null, … }
2578
+ // [5.3 ms] → { id: '5f49dbfa-…', confirmed: true, deleted: null, … }
2228
2579
  await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
2229
- // [3.0 ms] после delete → тот же id, deleted = null — воскрешение
2580
+ // [7.7 ms] после delete → тот же id, deleted = null — воскрешение
2230
2581
  ```
2231
2582
 
2232
2583
  **Кейс:** смена номера телефона: `delete(старый)` + `set(новый)`; передумали — повторный
@@ -2238,7 +2589,7 @@ await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 9
2238
2589
  освобождается для других аккаунтов (§ 11.9).
2239
2590
 
2240
2591
  ```ts
2241
- await db.credentials.delete(кред.id) // [3.7 ms] → true; find() больше не видит
2592
+ await db.credentials.delete(кред.id) // [4.6 ms] → true; find() больше не видит
2242
2593
  ```
2243
2594
 
2244
2595
  **Кейс:** отзыв api-ключа: `delete(credential.id)` → `verifyApiKey` мгновенно null
@@ -2259,10 +2610,10 @@ await db.credentials.delete(кред.id) // [3.7 ms] → true; find() боль
2259
2610
 
2260
2611
  ```ts
2261
2612
  await db.resources.set({ alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' } })
2262
- // [3.0 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
2263
- await db.resources.get('apiref.demo:API') // [1.8 ms] → тот же Resource
2264
- await db.resources.find({ category: 'API' }) // [2.2 ms] → 15 ресурсов
2265
- await db.resources.delete('apiref.demo:API') // [3.0 ms] → true
2613
+ // [4.8 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
2614
+ await db.resources.get('apiref.demo:API') // [2.6 ms] → тот же Resource
2615
+ await db.resources.find({ category: 'API' }) // [1.1 ms] → 14 ресурсов
2616
+ await db.resources.delete('apiref.demo:API') // [5.2 ms] → true
2266
2617
  ```
2267
2618
 
2268
2619
  **Кейс:** полный словарь для нового тарифа — § 11.10 (6 ресурсов + 5 правил одним блоком).
@@ -2281,9 +2632,9 @@ await db.resources.delete('apiref.demo:API') // [3.0 ms] → true
2281
2632
 
2282
2633
  ```ts
2283
2634
  await db.rules.set({ account: 'apiref.demo:API', resource: 'apiref.demo:API', permission: 'allow', weight: 90 })
2284
- // [3.6 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
2285
- await db.rules.find({ resource: 'apiref.demo:API' }) // [2.3 ms] → 1
2286
- await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [2.7 ms] → true
2635
+ // [5.3 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
2636
+ await db.rules.find({ resource: 'apiref.demo:API' }) // [2.7 ms] → 1
2637
+ await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [4.3 ms] → true
2287
2638
  ```
2288
2639
 
2289
2640
  **Кейс:** временный бан группы: `set({ account: группа, resource: цель, permission: 'deny',
@@ -2315,8 +2666,8 @@ uuid-строку или объект с `.id`.
2315
2666
 
2316
2667
  ```ts
2317
2668
  await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', password: 'лето-2026!' })
2318
- // [78.0 ms] → Credential; в БД вместо пароля:
2319
- // meta.password = "scrypt$32768$8$1$PSHhWqCsjR+Yne0tUwbVPQ==$ls…"
2669
+ // [87.5 ms] → Credential; в БД вместо пароля:
2670
+ // meta.password = "scrypt$32768$8$1$+yEMcUTJIO7xEu/oDUAMXA==$b6…"
2320
2671
  ```
2321
2672
 
2322
2673
  **Кейс** — регистрация + вход + сессия: см. `sessions()` ниже (полный флоу).
@@ -2338,11 +2689,11 @@ await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', passw
2338
2689
  **Примеры**
2339
2690
 
2340
2691
  ```ts
2341
- await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [65.9 ms]
2342
- // → { account: { id: 'c66766f7-…', categories: ['Client'], enabled: true, … },
2692
+ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [78.8 ms]
2693
+ // → { account: { id: '06d3bbfe-…', categories: ['Client'], enabled: true, … },
2343
2694
  // credential: { category: 'PASSWORD', identifier: 'romashka@salon.io', … } }
2344
- await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [63.1 ms] → null
2345
- await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [65.2 ms] → null
2695
+ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [71.1 ms] → null
2696
+ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [64.4 ms] → null
2346
2697
  // незнакомый identifier — то же время (dummy-verify)
2347
2698
  ```
2348
2699
 
@@ -2352,11 +2703,11 @@ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' })
2352
2703
  await db.auth.setPassword({ account: acc, identifier: 'noconfirm@salon.io', password: 'пароль-77',
2353
2704
  category: 'EMAIL', confirmed: false })
2354
2705
  await db.auth.verifyPassword({ identifier: 'noconfirm@salon.io', password: 'пароль-77', category: 'EMAIL' })
2355
- // [74.2 ms] → null — кред не подтверждён
2356
- await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [65.7 ms] → { account, credential }
2706
+ // [71.1 ms] → null — кред не подтверждён
2707
+ await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [72.2 ms] → { account, credential }
2357
2708
  await db.accounts.set({ id: acc.id, enabled: false })
2358
2709
  await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
2359
- // [81.9 ms] → null — аккаунт выключен, пароль уже не важен
2710
+ // [59.5 ms] → null — аккаунт выключен, пароль уже не важен
2360
2711
  ```
2361
2712
 
2362
2713
  #### `db.auth.issueApiKey(a): Promise<{ key, credential }>`
@@ -2371,9 +2722,9 @@ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'ле
2371
2722
  Сам ключ возвращается **один раз**; утечка БД ключи не раскрывает.
2372
2723
 
2373
2724
  ```ts
2374
- const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [3.6 ms]
2375
- // key = 'lts_2e14a34f5895a8892c6238d2f2a0620e1b9c3145e491c6d3' ← показать и забыть
2376
- // в БД: identifier = '4d0b27202e76f6fb…' (sha256), meta = { name: 'касса-1', prefix: 'lts_2e14a34f' }
2725
+ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
2726
+ // key = 'lts_ca41bf2a3614c3f228be2551a2ba998db5285f1a2bc3b859' ← показать и забыть
2727
+ // в БД: identifier = '624b00355aba026f…' (sha256), meta = { name: 'касса-1', prefix: 'lts_ca41bf2a' }
2377
2728
  ```
2378
2729
 
2379
2730
  **Кейс** — см. `verifyApiKey` (выпуск → проверка → отзыв).
@@ -2389,16 +2740,16 @@ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'к
2389
2740
  Быстрый (без scrypt): ключ высокоэнтропийный, подбор бессмыслен.
2390
2741
 
2391
2742
  ```ts
2392
- await db.auth.verifyApiKey(key) // [3.5 ms] → { account: c66766f7…, credential }
2743
+ await db.auth.verifyApiKey(key) // [4.5 ms] → { account: 06d3bbfe…, credential }
2393
2744
  ```
2394
2745
 
2395
2746
  **Кейс: полный жизненный цикл ключа**
2396
2747
 
2397
2748
  ```ts
2398
- const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [3.6 ms]
2399
- await db.auth.verifyApiKey(key) // [3.5 ms] → { account, credential } — касса работает
2749
+ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
2750
+ await db.auth.verifyApiKey(key) // [4.5 ms] → { account, credential } — касса работает
2400
2751
  await db.credentials.delete(credential.id) // отзыв (мягкий)
2401
- await db.auth.verifyApiKey(key) // [1.7 ms] → null — мгновенно недействителен
2752
+ await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновенно недействителен
2402
2753
  ```
2403
2754
 
2404
2755
  #### `db.auth.issueKeySecret(a): Promise<{ key, secret, credential }>`
@@ -2410,8 +2761,8 @@ await db.auth.verifyApiKey(key) // [1.7 ms] → null — мгновен
2410
2761
  возвращается один раз.
2411
2762
 
2412
2763
  ```ts
2413
- const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [3.3 ms]
2414
- // key = '05aaaf12e20fb957'; secret = '88f0f27c98ff…' (48 hex, показан один раз)
2764
+ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [4.8 ms]
2765
+ // key = 'f8ab4007e4570809'; secret = 'c25b8b906f69…' (48 hex, показан один раз)
2415
2766
  ```
2416
2767
 
2417
2768
  #### `db.auth.verifyKeySecret(key, secret, opts?): Promise<AuthResult | null>`
@@ -2425,8 +2776,8 @@ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'ин
2425
2776
  **Алгоритм.** Кред по identifier = key, `timingSafeEqual(sha256(secret), meta.secret)`, ворота.
2426
2777
 
2427
2778
  ```ts
2428
- await db.auth.verifyKeySecret(key, secret) // [3.7 ms] → { account, credential }
2429
- await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [1.5 ms] → null
2779
+ await db.auth.verifyKeySecret(key, secret) // [4.6 ms] → { account, credential }
2780
+ await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [2.2 ms] → null
2430
2781
  ```
2431
2782
 
2432
2783
  **Кейс:** серверная интеграция (1С, платёжка): key хранится в конфиге открыто и светится
@@ -2448,8 +2799,8 @@ base32(20 случайных байт), `uri` — готовая строка `o
2448
2799
 
2449
2800
  ```ts
2450
2801
  const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: 'romashka@salon.io' })
2451
- // [3.3 ms] secret = 'FAZKFC57B3FPIOF2735ZYW47CZA5O6MW'
2452
- // uri = 'otpauth://totp/romashka%40salon.io?secret=FAZKFC57B3FPIOF2735ZYW47CZA5O6MW&issuer=clockz&algorithm=SHA1&digits=6&period=30'
2802
+ // [4.8 ms] secret = 'G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS'
2803
+ // uri = 'otpauth://totp/romashka%40salon.io?secret=G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS&issuer=clockz&algorithm=SHA1&digits=6&period=30'
2453
2804
  ```
2454
2805
 
2455
2806
  #### `db.auth.verifyTotp(a): Promise<boolean>`
@@ -2467,9 +2818,9 @@ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz
2467
2818
  **Примеры**
2468
2819
 
2469
2820
  ```ts
2470
- const код = totpCode(secret) // [335 µs] → '370916' (как в приложении)
2471
- await db.auth.verifyTotp({ account: acc, code: код }) // [4.8 ms] → true — фактор активирован
2472
- await db.auth.verifyTotp({ account: acc, code: код }) // [1.7 ms] → false — replay отбит
2821
+ const код = totpCode(secret) // [659 µs] → '564517' (как в приложении)
2822
+ await db.auth.verifyTotp({ account: acc, code: код }) // [8.1 ms] → true — фактор активирован
2823
+ await db.auth.verifyTotp({ account: acc, code: код }) // [2.6 ms] → false — replay отбит
2473
2824
  const прошлый = totpCode(secret, Date.now() - 30_000) // код прошлого шага (окно ±1)
2474
2825
  await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false — шаг ≤ lastStep
2475
2826
  ```
@@ -2477,10 +2828,10 @@ await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false
2477
2828
  **Кейс: включение 2FA в кабинете**
2478
2829
 
2479
2830
  ```ts
2480
- const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [3.3 ms]
2831
+ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [4.8 ms]
2481
2832
  await db.auth.totpEnabled(acc) // → false — QR показан, ждём подтверждения
2482
- await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [4.8 ms] → true
2483
- await db.auth.totpEnabled(acc) // [1.7 ms] → true — теперь требуем код при входе
2833
+ await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [8.1 ms] → true
2834
+ await db.auth.totpEnabled(acc) // [2.2 ms] → true — теперь требуем код при входе
2484
2835
  ```
2485
2836
 
2486
2837
  #### `db.auth.totpEnabled(account): Promise<boolean>`
@@ -2489,7 +2840,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true — те
2489
2840
  проверкой (`confirmed`). Приложение по нему решает, спрашивать ли второй фактор.
2490
2841
 
2491
2842
  ```ts
2492
- await db.auth.totpEnabled(acc) // [1.7 ms] → true
2843
+ await db.auth.totpEnabled(acc) // [2.2 ms] → true
2493
2844
  ```
2494
2845
 
2495
2846
  #### `totpCode(secretBase32, atMs = Date.now()): string` — экспорт модуля
@@ -2504,7 +2855,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true
2504
2855
  Для тестов и серверной генерации кодов.
2505
2856
 
2506
2857
  ```ts
2507
- totpCode('FAZKFC57B3FPIOF2735ZYW47CZA5O6MW') // [335 µs] → '370916'
2858
+ totpCode('G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS') // [659 µs] → '564517'
2508
2859
  ```
2509
2860
 
2510
2861
  **Кейс** — автотест 2FA без телефона: сгенерировать код из секрета и скормить `verifyTotp`
@@ -2526,7 +2877,7 @@ totpCode('FAZKFC57B3FPIOF2735ZYW47CZA5O6MW') // [335 µs] → '370916'
2526
2877
 
2527
2878
  ```ts
2528
2879
  const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@salon.io', ttlSec: 600 })
2529
- // [3.8 ms] code = '838474'; в БД: meta = { code: 'ccc3142253e2…' (sha256), expires: '2026-07-11T13:32:36.545Z', attempts: 0 }
2880
+ // [6.1 ms] code = '255243'; в БД: meta = { code: '7566c91d8a5e…' (sha256), expires: '2026-07-13T07:24:44.435Z', attempts: 0 }
2530
2881
  ```
2531
2882
 
2532
2883
  #### `db.auth.verifyOtp(a): Promise<AuthResult | null>`
@@ -2545,19 +2896,19 @@ const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@sa
2545
2896
  **Примеры**
2546
2897
 
2547
2898
  ```ts
2548
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [4.3 ms] → null (+1 попытка)
2549
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [7.4 ms] → { account, credential }
2550
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [1.8 ms] → null — сожжён
2899
+ await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [8.4 ms] → null (+1 попытка)
2900
+ await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11.0 ms] → { account, credential }
2901
+ await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [2.4 ms] → null — сожжён
2551
2902
  ```
2552
2903
 
2553
2904
  **Кейс: сброс пароля**
2554
2905
 
2555
2906
  ```ts
2556
- const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [3.8 ms]
2907
+ const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [6.1 ms]
2557
2908
  отправитьПисьмо(почта, code) // доставка — на приложении
2558
- const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [7.4 ms]
2909
+ const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11.0 ms]
2559
2910
  if (кто) await db.auth.setPassword({ account: кто.account, identifier: почта, password: новый })
2560
- // протухший код (ttl 1 s в прогоне): verifyOtp → null [12.6 ms]
2911
+ // протухший код (ttl 1 s в прогоне): verifyOtp → null [9.3 ms]
2561
2912
  ```
2562
2913
 
2563
2914
  #### `db.auth.link(a): Promise<Credential>`
@@ -2578,7 +2929,7 @@ if (кто) await db.auth.setPassword({ account: кто.account, identifier: п
2578
2929
 
2579
2930
  ```ts
2580
2931
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' } })
2581
- // [6.6 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
2932
+ // [4.9 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
2582
2933
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915' }) // id занят ДРУГИМ аккаунтом:
2583
2934
  // Error: duplicate key value violates unique constraint "credential_identity_udx" [3.9 ms]
2584
2935
  ```
@@ -2599,14 +2950,14 @@ await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915
2599
2950
 
2600
2951
  ```ts
2601
2952
  await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' })
2602
- // [7.2 ms] → { account: c66766f7…, credential }
2953
+ // [5.2 ms] → { account: 06d3bbfe…, credential }
2603
2954
  ```
2604
2955
 
2605
2956
  **Кейс: вход через telegram-бота**
2606
2957
 
2607
2958
  ```ts
2608
2959
  // платформа подтвердила пользователя 777000111 (initData бота проверило приложение)
2609
- const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [7.2 ms]
2960
+ const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [5.2 ms]
2610
2961
  if (!кто) { /* первая встреча: создать аккаунт + db.auth.link(…) */ }
2611
2962
  const token = await sess.start(кто.account) // дальше обычная сессия
2612
2963
  ```
@@ -2622,7 +2973,7 @@ const token = await sess.start(кто.account) // дальше обычная
2622
2973
 
2623
2974
  ```ts
2624
2975
  import Redis from 'ioredis'
2625
- const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [147 µs]
2976
+ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [148 µs]
2626
2977
  ```
2627
2978
 
2628
2979
  #### `sessions.start(account, opts?): Promise<string>`
@@ -2638,9 +2989,9 @@ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [147 µ
2638
2989
  `sess:acc:<accountId>` для `revokeAll`. Дамп Redis действующих токенов не раскрывает.
2639
2990
 
2640
2991
  ```ts
2641
- const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [9.1 ms]
2642
- // token = 'fe6093998681fceaf5a902ed5fb3f72be3da5ce943cd4096f9c0d6349fcc231d'
2643
- // в Redis: 'sess:eb473191b2a470d130e…' и 'sess:acc:c66766f7-0704-4…'
2992
+ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [8.0 ms]
2993
+ // token = 'fe0a7e83db6fcd1aa2c5002988da2b353602837db3bc0a813eddac863b9a1944'
2994
+ // в Redis: 'sess:8198bf2cd8d2ef2376d…' и 'sess:acc:06d3bbfe-d9a2-4…'
2644
2995
  ```
2645
2996
 
2646
2997
  #### `sessions.check(token): Promise<Session | null>`
@@ -2649,9 +3000,9 @@ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }
2649
3000
  (нет / истекла / отозвана). Суб-миллисекундный — на каждый HTTP-запрос.
2650
3001
 
2651
3002
  ```ts
2652
- await sess.check(token) // [0.9 ms]
2653
- // → { account: 'c66766f7-…', meta: { device: 'iphone' }, created: '2026-07-11T13:22:37.913Z' }
2654
- await sess.check(протухший) // [1.2 ms] → null (ttl 1 s истёк — Redis сам удалил)
3003
+ await sess.check(token) // [0.7 ms]
3004
+ // → { account: '06d3bbfe-…', meta: { device: 'iphone' }, created: '2026-07-13T07:14:45.810Z' }
3005
+ await sess.check(протухший) // [0.8 ms] → null (ttl 1 s истёк — Redis сам удалил)
2655
3006
  ```
2656
3007
 
2657
3008
  #### `sessions.revoke(token): Promise<boolean>`
@@ -2659,8 +3010,8 @@ await sess.check(протухший) // [1.2 ms] → null (ttl 1 s истёк
2659
3010
  `token: string` — `DEL` ключа + `SREM` из индекса; `false`, если сессии уже нет.
2660
3011
 
2661
3012
  ```ts
2662
- await sess.revoke(token) // [2.4 ms] → true
2663
- await sess.revoke(token) // [0.5 ms] → false — повторно
3013
+ await sess.revoke(token) // [1.9 ms] → true
3014
+ await sess.revoke(token) // [0.7 ms] → false — повторно
2664
3015
  ```
2665
3016
 
2666
3017
  #### `sessions.revokeAll(account): Promise<number>`
@@ -2669,19 +3020,19 @@ await sess.revoke(token) // [0.5 ms] → false — повторно
2669
3020
  сколько погашено.
2670
3021
 
2671
3022
  ```ts
2672
- await sess.revokeAll(acc) // [1.1 ms] → 2 — обе сессии (ipad + macbook) погасли
3023
+ await sess.revokeAll(acc) // [1.2 ms] → 2 — обе сессии (ipad + macbook) погасли
2673
3024
  ```
2674
3025
 
2675
3026
  **Кейс: полный вход — пароль → сессия → запрос → выход (реальный прогон)**
2676
3027
 
2677
3028
  ```ts
2678
3029
  const визит = await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
2679
- // [80.4 ms] → { account, credential }
2680
- const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.9 ms]
3030
+ // [71.3 ms] → { account, credential }
3031
+ const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.2 ms]
2681
3032
  // … каждый запрос в middleware:
2682
- const кто = await sess.check(token) // [0.9 ms] → { account: 'c66766f7…', meta: { ip: '10.0.0.7' }, … }
3033
+ const кто = await sess.check(token) // [0.7 ms] → { account: '06d3bbfe…', meta: { ip: '10.0.0.7' }, … }
2683
3034
  // logout:
2684
- await sess.revoke(token) // [2.2 ms] → true
3035
+ await sess.revoke(token) // [1.3 ms] → true
2685
3036
  // «выйти со всех устройств» после смены пароля: await sess.revokeAll(визит.account)
2686
3037
  ```
2687
3038
 
@@ -2690,7 +3041,7 @@ await sess.revoke(token) // [2.2 ms] → true
2690
3041
  Решения по словарю Resource/Rule (§ 9.2). Прогоны ниже — словарь из § 11.8-кейса: группа
2691
3042
  `apiref.client:ACCOUNT {categories: '{Client}'}`, эндпоинты `apiref.api.booking:API
2692
3043
  {endpoint: 'v2.booking.*'}`, данные `apiref.booking.own:READ/WRITE/DELETE
2693
- {class: 'Booking', owner: '$account'}` и `apiref.service:READ {class: 'Service'}`,
3044
+ {class: 'booking', owner: '$account'}` и `apiref.service:READ {class: 'Service'}`,
2694
3045
  5 правил `allow weight 60` от группы Client.
2695
3046
 
2696
3047
  #### `db.acl.check(account, endpoint): Promise<AclDecision>`
@@ -2710,10 +3061,10 @@ NULL — все); объекты — `API`-ресурсы, чья маска п
2710
3061
  **Примеры**
2711
3062
 
2712
3063
  ```ts
2713
- await db.acl.check(acc, 'v2.booking.create') // [3.2 ms]
3064
+ await db.acl.check(acc, 'v2.booking.create') // [5.0 ms]
2714
3065
  // → { allow: true, rule: { account: 'apiref.client:ACCOUNT', resource: 'apiref.api.booking:API',
2715
3066
  // permission: 'allow', weight: 60, enabled: true } }
2716
- await db.acl.check(acc, 'v2.admin.stats') // [1.5 ms] — покрыло только дно-правило сида:
3067
+ await db.acl.check(acc, 'v2.admin.stats') // [1.8 ms] — покрыло только дно-правило сида:
2717
3068
  // → { allow: false, rule: { account: 'any:ACCOUNT', resource: 'any:API', permission: 'deny', weight: 0, … },
2718
3069
  // code: 403, message: 'Access denied - default for any ACCOUNT to any API' }
2719
3070
  ```
@@ -2738,7 +3089,7 @@ app.use(async (req, res, next) => {
2738
3089
 
2739
3090
  **Назначение и алгоритм.** Решение по данным: объекты — ресурсы категории `op`, чья
2740
3091
  `pattern.class`-маска совпала с именем класса **или любого предка** (lineage: право на
2741
- `Booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
3092
+ `booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
2742
3093
  ключи pattern (реальные колонки Entity, `"$account"` → id субъекта) возвращаются как
2743
3094
  `filter` — готовый предикат строк. Справочный метод (SQL за категориями аккаунта на каждый
2744
3095
  вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный на connect
@@ -2747,35 +3098,37 @@ app.use(async (req, res, next) => {
2747
3098
  **Примеры**
2748
3099
 
2749
3100
  ```ts
2750
- await db.acl.checkData(acc, 'Booking', 'READ') // [1.6 ms]
3101
+ await db.acl.checkData(acc, 'booking', 'READ') // [2.7 ms]
2751
3102
  // → { allow: true, rule: { resource: 'apiref.booking.own:READ', weight: 60, … },
2752
- // filter: { owner: 'c66766f7-0704-4cb3-b6c9-18620ebfdc2e' } } ← $account подставлен
2753
- await db.acl.checkData(acc, 'Service', 'READ') // [1.2 ms]
3103
+ // filter: { owner: '06d3bbfe-d9a2-4c1d-be44-04c40cb01108' } } ← $account подставлен
3104
+ await db.acl.checkData(acc, 'Service', 'READ') // [1.9 ms]
2754
3105
  // → { allow: true, rule: { resource: 'apiref.service:READ', … } } ← безусловный (без filter)
2755
- await db.acl.checkData(acc, 'Org', 'READ') // [1.0 ms]
3106
+ await db.acl.checkData(acc, 'Org', 'READ') // [2.0 ms]
2756
3107
  // → { allow: false, message: 'no matching rule (deny by default)' }
2757
- await db.acl.checkData(acc, 'VipBooking', 'READ') // [20.7 ms — свежий connect]
2758
- // → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: 'c66766f7-…' } }
2759
- // класса нет в словаре — право дал предок Booking (lineage)
3108
+ await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий connect]
3109
+ // → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: '06d3bbfe-…' } }
3110
+ // класса нет в словаре — право дал предок booking (lineage)
2760
3111
  ```
2761
3112
 
2762
3113
  **Кейс: enforceAcl — те же решения в SQL цепочек (реальный прогон)**
2763
3114
 
2764
3115
  ```ts
2765
- const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [72.3 ms] правила фиксируются
2766
- await uc.Запись().rows() // [16.4 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
2767
- await uc.Запись().count() // [20.3 ms] → 3 — честный count по суженному множеству
2768
- await uc.Услуга().count() // [15.9 ms] → 404 — безусловный allow, класс целиком
3116
+ const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [66.9 ms] правила фиксируются
3117
+ await uc.запись().rows() // [17.2 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
3118
+ await uc.запись().count() // [17.6 ms] → 3 — честный count по суженному множеству
3119
+ await uc.Услуга().count() // [15.1 ms] → 602 — безусловный allow, класс целиком
2769
3120
  await uc.Организация().rows()
2770
3121
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
2771
- const [z] = await uc.Запись().create({ status: 'created', total: { RUB: 300 } }).rows() // [11.7 ms]
3122
+ const [z] = await uc.запись().create({ start_datetime: t, end_datetime: e })
3123
+ .Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [20.9 ms]
2772
3124
  z.owner === acc.id // → true — owner пришпилен правилом
2773
- await uc.Запись().owner(SYS).create({ … }).rows()
2774
- // Error: letopis: acl pins Booking writes to owner … — на терминале [1.8 ms]
2775
- await uc.Запись(чужаяId).update({ status: 'hacked' }).rows() // [10.9 ms] → [] — чужая жива и НЕ перехвачена
2776
- await uc.Запись('…0965').delete({ confirm: true }).rows() // [27.7 ms] → [{ id: '…0965', $deleted: true }]
3125
+ await uc.запись().owner(SYS).create({ … }).rows()
3126
+ // Error: letopis: acl pins booking writes to owner 06d3bbfe-… — на терминале [2.6 ms]
3127
+ await uc.запись('чужой-id').create({ … }).rows() // [2.0 ms] — перехват чужого id мёртв: v5-класс id не принимает
3128
+ // Error: letopis: class "booking" computes id (uuid v5 from Staff, start_datetime) — remove the explicit id
3129
+ await uc.запись(свойId).delete({ confirm: true }).rows() // [30.3 ms] → [{ id: 'ee65244b-…', $deleted: true }]
2777
3130
  // watch: события только безусловных allow-классов —
2778
- // uc.watch(cb) поймал ['Service']; Booking скрыт (предикат не проверить по payload)
3131
+ // uc.watch(cb) поймал ['Service']; booking скрыт (предикат не проверить по payload)
2779
3132
  ```
2780
3133
 
2781
3134
  #### `db.acl.reload(): void`
@@ -2786,7 +3139,7 @@ connect; подхватить новые правила = новый `connect()`
2786
3139
  connect — новый класс в lineage-проверках увидит только новое подключение.
2787
3140
 
2788
3141
  ```ts
2789
- db.acl.reload() // [120 µs]
3142
+ db.acl.reload() // [187 µs]
2790
3143
  ```
2791
3144
 
2792
3145
  **Кейс:** админка сохранила правило → `reload()` в том же процессе, чтобы `check` следующего
@@ -2806,9 +3159,9 @@ db.acl.reload() // [120 µs]
2806
3159
  компилированный валидатор.
2807
3160
 
2808
3161
  ```ts
2809
- db.registry.resolve('Запись') // [90 µs]
2810
- // → { id: 'Booking', alias: 'Запись', category: 'HUB', ancestors: ['Booking', 'Entity'],
2811
- // links: ['Customer'], abstract: false, … }
3162
+ db.registry.resolve('запись') // [103 µs]
3163
+ // → { id: 'booking', alias: 'запись', category: 'LINK', ancestors: ['booking', 'slot', 'link'],
3164
+ // links: [{ classes: ['Staff'], … }, …, { classes: ['Customer'], optional: true, … }], abstract: false }
2812
3165
  db.registry.resolve('Дракон')
2813
3166
  // Error: letopis: unknown class "Дракон". Known: Entity·Сущность, Org·Организация, …
2814
3167
  ```
@@ -2822,10 +3175,10 @@ db.registry.resolve('Дракон')
2822
3175
  `all` — все классы партиции.
2823
3176
 
2824
3177
  ```ts
2825
- db.registry.has('Booking') // → true
3178
+ db.registry.has('booking') // → true
2826
3179
  db.registry.has('Дракон') // → false
2827
3180
  db.registry.find('Дракон') // → undefined
2828
- db.registry.all.length // → 15
3181
+ db.registry.all.length // → 20 (+1: LINK-класс price·«цена»)
2829
3182
  ```
2830
3183
 
2831
3184
  **Кейс:** роутер `GET /:класс` — `has()` до цепочки, чтобы отвечать 404, а не 500.
@@ -2843,12 +3196,12 @@ db.registry.all.length // → 15
2843
3196
  конец LINK) — обычный `Error` там же.
2844
3197
 
2845
3198
  ```ts
2846
- try { await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [1.4 ms]
3199
+ try { await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [9.9 ms]
2847
3200
  catch (e) {
2848
3201
  e instanceof ValidationError // → true
2849
3202
  e.issues
2850
- // → [{ type: 'required', field: 'duration', message: "The 'duration' field is required." },
2851
- // { type: 'objectStrict', expected: 'id, name, price, active, duration, description', actual: 'чепуха', … }]
3203
+ // → [{ type: 'objectStrict', message: "The object '' contains forbidden keys: 'чепуха'.",
3204
+ // expected: 'id, name, description, duration', actual: 'чепуха' }]
2852
3205
  }
2853
3206
  ```
2854
3207
 
@@ -2921,76 +3274,103 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
2921
3274
 
2922
3275
  ## 13. Что контролирует приложение
2923
3276
 
2924
- - Непересечение окон в расписании (EXCLUDE на hypertable невозможен).
2925
- - Бизнес-проверки брони: навык, смена, число подряд окон под `duration` (§ 7).
2926
- - `total` записи = Σ позиций.
2927
- - Генерация окон из шаблонов/повторов.
3277
+ - Пересечения интервалов записей внутри окна-смены мастера (наложение броней; EXCLUDE на
3278
+ hypertable невозможен). Двойная бронь одного времени мертва самим id записи —
3279
+ `v5(Мастер, старт)` (§ 3.2); частичное наложение разных интервалов проверяет приложение.
3280
+ - Бизнес-проверки брони: у мастера есть навык на услугу (`Мастер→навык→Услуга`); интервал
3281
+ записи попадает в окно-смену того же мастера; длительность услуги укладывается в окно.
3282
+ - Итог по записи = цена предмета (`Услуга`/`Товар`/`Комплекс`), у комплекса — по составу:
3283
+ сами записи денег не хранят.
3284
+ - Генерация окон-смен из шаблонов/повторов; месячное `Расписание {name, year, month}`.
2928
3285
 
2929
3286
  ## 14. Производительность
2930
3287
 
2931
- Замеры `npm run bench` (105 200 строк: 5 000 сущностей × 20 версий + связи; docker, локально):
3288
+ Тайминги — живые прогоны демо на ЕДИНОМ полигоне `v1.salondemo` (`bench/salon-seed.mjs`,
3289
+ ~980 000 строк: 440 000 записей ×2 версии, 600 услуг, 1026 цен, 1260 навыков). Статья
3290
+ (`bench/salon-article-demo.mjs`), API Reference (`bench/api-reference-demo.mjs`) и бенчи
3291
+ читают один и тот же полигон, мутируя лишь свои демо-сущности:
2932
3292
 
2933
- | Операция | p50 | p95 |
2934
- |---|---|---|
2935
- | `rows()` класс + containment-фильтр | 5 ms | 52 ms |
2936
- | `rows()` весь класс, sort+limit 100 | 63 ms | 74 ms |
2937
- | цепочка 3 хопа (пути) | 15 ms | 23 ms |
2938
- | `count()` путей | 15 ms | 20 ms |
2939
- | запись новой версии (`create`/`update`) | 8 ms | 12 ms |
2940
-
2941
- TOAST-порог (data > 2KB): 0 строк. Слабое место — выборка «весь класс с сортировкой»
2942
- (DISTINCT ON всех сущностей класса); лечится селективным фильтром или курсором.
2943
- Масштаб побольше: `node bench/history.bench.mjs --entities=10000 --versions=100` (1M строк).
2944
- Оверхед ACL: `npx tsx bench/acl.bench.mjs` на article-полигоне (таблица в § 9.2);
2945
- сравнение вариантов партиционирования — `bench/dimensions.bench.mjs`; все ответы/тайминги
2946
- API Reference (§ 11) — `npx tsx bench/api-reference-demo.mjs` (живой article-полигон,
2947
- мутирует только свои сущности).
3293
+ | Операция | Время |
3294
+ |---|---|
3295
+ | фильтр/`count()` по каталогу (`ne`/`gt`/`between`/`like`) | 5–8 ms |
3296
+ | агрегация каталога `avg`/`min`/`max` | 5–15 ms |
3297
+ | `sum('data.amounts.RUB')` (класс цена, 1026 вариантов) | ≈13 ms |
3298
+ | `count()`/`rows()` каталога услуг (600) | 7–13 ms |
3299
+ | цена `sort('data.amounts.RUB')` + keyset-страница `after(cursor)` | 19–31 ms |
3300
+ | цепочка `навык→Услуга`, `count()` путей (1260) | ≈65 ms |
3301
+ | `countBy('data.notes')` по 440k записям | ≈3.3 s |
3302
+ | `sort('updated','desc').limit` по всему классу записей БЕЗ фильтра | ≈6 s |
3303
+ | `count()` всех 440 000 записей БЕЗ фильтра | ≈3.4 s |
3304
+ | обход `запись→Мастер` по всему классу записей | ≈6 s |
3305
+ | запись новой версии (`create`/`update`), в т.ч. со слотами | 8–21 ms |
3306
+
3307
+ Слабое место — выборка/обход **всего класса записей без фильтра** (`DISTINCT ON` по всем
3308
+ 440 000 сущностям: секунды); лечится селективным фильтром, контекст-шагом или курсором
3309
+ (keyset — миллисекунды даже на 440k). Каталог, агрегации и цепочки с фильтром — единицы—десятки ms.
3310
+ TOAST-порог (data > 2KB): 0 строк.
2948
3311
 
2949
3312
  - Containment и обход графа — GIN; операторы — на уже суженном наборе.
2950
- - Начинайте цепочку с самого селективного шага.
2951
- - `count()` — пути; количество сущностей дешевле `ids().length`.
2952
- - Каскадное удаление — серверное: один DELETE на всё дерево.
2953
- - Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты).
2954
- - EXPLAIN-паттерн — тест `EXPLAIN` (индексы обязаны быть в плане; ноль seq scan).
3313
+ - Начинайте цепочку с самого селективного шага (Организация/Мастер/Клиент, не `запись()`).
3314
+ - `count()` считает пути; число сущностей дешевле берётся `ids().length`.
3315
+ - Каскадное удаление — серверное: один DELETE на всё дерево (клиент → его записи).
3316
+ - Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты);
3317
+ окно/запись/навык — v5-классы, в батче идут поштучно (id из концов), не multi-VALUES.
3318
+ - Микро-бенч `npm run bench` (105k строк) + EXPLAIN-тесты (индексы обязаны быть в плане; ноль
3319
+ seq scan); партиционирование — `bench/dimensions.bench.mjs`, масштаб —
3320
+ `node bench/history.bench.mjs --entities=10000 --versions=100`, оверхед ACL —
3321
+ `npx tsx bench/acl.bench.mjs` (таблица в § 9.2).
2955
3322
 
2956
3323
  ---
2957
3324
 
2958
3325
  ## 15. E2E-пример: барбершоп
2959
3326
 
2960
- Полный исполняемый сценарий — `test/integration.test.ts`. Скелет:
3327
+ Полный исполняемый сценарий — `test/integration.test.ts`. Скелет (модель 0.17,
3328
+ позитивная доступность):
2961
3329
 
2962
3330
  ```ts
2963
- // штат и каталог — связи контекст-шагами; терминал .rows() исполняет план
3331
+ // организация, локация, штат (phone обязателен) — связи контекст-шагами; терминал .rows() исполняет план
2964
3332
  const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
2965
- const [ivan] = await db.Организация(org).Сотрудник().create({ name: 'Иван', roles: ['owner','master'] }).rows()
2966
- const [oleg] = await db.Организация(org).Сотрудник().create({ name: 'Олег', roles: ['master'] }).rows()
2967
- const [стрижка] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60, price: { RUB: 1500 } }).rows()
2968
- const [комплекс] = await db.Организация(org).Комплекс().create({ name: 'Стрижка+борода', duration: 90, price: { RUB: 2500 } }).rows()
2969
- // id услуги/комплекса вычислила схема: uuidv5(Org, name) — дубль имени в салоне невозможен (§ 3.2)
2970
- await db.Комплекс(комплекс).состав().create({ qty: 1 }).Услуга.set(стрижка).rows() // состав комплекса
2971
- await db.Сотрудник(ivan).навык().create().Комплекс.set(комплекс).rows() // умение
2972
-
2973
- // календарь: период → окна батчем → ростер → исключение
2974
- const [sch] = await db.Организация(org).Расписание().create({ start: '2026-07-10T10:00:00+03:00', end: '…13:00' }).rows()
2975
- db.batch('о').Расписание(sch).Окно().create({ start: '…10:00', end: '…11:00' }) // ×3 — планы в очередь
2976
- const [[w1],[w2],[w3]] = await db.batch('о').run()
2977
- await db.Расписание(sch).смена().create().Сотрудник.set(ivan).rows()
2978
- await db.Сотрудник(oleg).занятость().create({ kind: 'off' }).Окно.set(w3).rows() // id = uuidv5(w3, oleg)
2979
-
2980
- // бронь мульти-слот (90м > 60м → два окна): владелец из пути, концы слотами
2981
- const [bkg] = await db.Клиент(пётр).Запись().create({ status: 'created', total: { RUB: 2500 } }).rows()
2982
- await db.Запись(bkg).позиция().create({ qty: 1, price: { RUB: 2500 } }).Комплекс.set(комплекс).Сотрудник.set(ivan).rows()
2983
- await db.Сотрудник(ivan).занятость().create({ kind: 'booking' }).Окно.set(w2).Запись.set(bkg).rows()
2984
- await db.Сотрудник(ivan).занятость().create({ kind: 'booking' }).Окно.set(w3).Запись.set(bkg).rows()
2985
- // id занятостей детерминированы — uuidv5(окно, мастер): двойная бронь мертва на уровне схемы
2986
-
2987
- // жизненный цикл, чтения, отмена
2988
- await db.Запись(bkg.id).update({ status: 'confirmed' }).rows()
2989
- await db.Запись(bkg.id).занятость().Окно().sort('data.start').rows() // окна брони
2990
- await db.Окно(w2.id).занятость({ kind: 'booking' }).Сотрудник().rows() // кто занят
2991
- await db.Запись(bkg.id).delete().rows() // ПРЕВЬЮ: что удалится
2992
- const удалено = await db.Запись(bkg.id).delete({ confirm: true }).rows()
2993
- // всё с $deleted (Booking+busy×2+item)
3333
+ const [loc] = await db.Организация(org).Локация().create({ address: 'Тверская, 7', coordinates: { lat: 55.76, lng: 37.61 } }).rows()
3334
+ const [иван] = await db.Организация(org).Мастер().create({ name: 'Иван', phone: '+7 900 111', specialization: 'барбер' }).rows()
3335
+ const [олег] = await db.Организация(org).Мастер().create({ name: 'Олег', phone: '+7 900 222' }).rows()
3336
+ const [пётр] = await db.Организация(org).Клиент().create({ name: 'Пётр', phone: '+7 905 000' }).rows()
3337
+
3338
+ // каталог: id услуги/комплекса вычислила схема uuidv5(Org, name) — дубль имени в салоне невозможен (§ 3.2)
3339
+ const [стрижка] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60 }).rows()
3340
+ const [комплекс] = await db.Организация(org).Комплекс().create({ name: 'Стрижка+борода', duration: 90, cost: 2000 }).rows()
3341
+ // цена — отдельный LINK «цена»: варианты (note) + мультивалюта (amounts); note не задан → «базовая»
3342
+ await db.Услуга(стрижка).цена().create({ amounts: { RUB: 1500 } }).rows()
3343
+ await db.Услуга(стрижка).цена().create({ note: 'с дизайном', amounts: { RUB: 2000 } }).rows()
3344
+ await db.Комплекс(комплекс).состав().create({ quantity: 1 }).Услуга.set(стрижка).rows() // состав комплекса
3345
+ await db.Мастер(иван).навык().create({ level: 'expert' }).Услуга.set(стрижка).rows() // умение
3346
+ await db.Мастер(иван).адрес().create({ default: true }).Локация.set(loc).rows() // мастер работает в локации
3347
+
3348
+ // месячное расписание → окна-смены батчем (окно = мастер доступен в интервале)
3349
+ const [sch] = await db.Организация(org).Расписание().create({ name: 'Август', year: 2026, month: 8 }).rows()
3350
+ db.batch('смены').Мастер(иван).окно().create({ start_datetime: '2026-08-01T07:00:00Z', end_datetime: '2026-08-01T15:00:00Z' }).Локация.set(loc).Расписание.set(sch)
3351
+ db.batch('смены').Мастер(олег).окно().create({ start_datetime: '2026-08-01T07:00:00Z', end_datetime: '2026-08-01T15:00:00Z' }).Локация.set(loc).Расписание.set(sch)
3352
+ await db.batch('смены').run() // окно — v5-класс: id = uuidv5(Мастер, старт) → поштучно
3353
+ await db.Расписание(sch).окно().Мастер().rows() // ростер смены: [Иван, Олег]
3354
+
3355
+ // бронь = наследник окна; id записи = v5(Мастер, старт), известен ДО создания — двойная бронь мертва
3356
+ const start = '2026-08-01T07:00:00Z', end = '2026-08-01T08:00:00Z'
3357
+ const bId = uuidv5(`v1.booking:entity:booking:${иван.id}:${start}`)
3358
+ const tr = await db.begin()
3359
+ await tr.lock('booking', иван.id, start) // сериализуем соперников на этом слоте
3360
+ if (!(await tr.запись(bId).first())) { // «занято?» — точечный first() по вычисленному id
3361
+ await tr.Мастер(иван).запись().create({ start_datetime: start, end_datetime: end })
3362
+ .Локация.set(loc).Расписание.set(sch).Услуга.set(стрижка).Клиент.set(пётр).rows()
3363
+ }
3364
+ await tr.commit()
3365
+
3366
+ // жизненный цикл, чтения, отмена/перенос
3367
+ await db.запись(bId).update({ notes: 'подтверждена' }).rows() // новая версия
3368
+ await db.Мастер(иван).запись().Услуга().rows() // что забронировано у Ивана
3369
+ await db.Локация(loc).запись().Клиент().rows() // кто записан в локацию
3370
+ await db.запись(bId).delete().rows() // ПРЕВЬЮ: что удалится
3371
+ await db.запись(bId).delete({ confirm: true }).rows() // отмена = tombstone (история цела)
3372
+ // перенос 07:00 → 09:00 = пересоздание (delete старой + create новой в одной транзакции):
3373
+ // id держит (Мастер, старт), поэтому смена времени = новая запись, старая — tombstone
2994
3374
  ```
2995
3375
 
2996
3376
  ---
@@ -2998,7 +3378,7 @@ const удалено = await db.Запись(bkg.id).delete({ confirm: true }).r
2998
3378
  ## 16. Тесты
2999
3379
 
3000
3380
  ```bash
3001
- npm test # 165/165 тестов против живого docker (timescale + redis), по файлам:
3381
+ cd lib && npm test # 167/167 тестов против живого docker (timescale + redis), по файлам:
3002
3382
  # acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
3003
3383
  # с $account, enforceAcl (предикаты в SQL, каскад, watch)
3004
3384
  # api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)