letopis 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ const пути = await db.Сотрудник({ name: 'Вася' }).навык().
13
13
  // [ { Сотрудник: Row, навык: Row, Услуга: Row }, … ]
14
14
 
15
15
  // запись: операции — звенья, исполняет терминал
16
- await db.Организация(org).Сотрудник().set({ name: 'Вася', roles: ['master'] }).rows()
16
+ await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
17
17
  ```
18
18
 
19
19
  Содержание:
@@ -52,8 +52,8 @@ const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v
52
52
  // [letopis.up] schema "v1.booking" applied (3 files)
53
53
  // [letopis.up] connected (schema "v1.booking")
54
54
 
55
- const [org] = await db.Организация().set({ name: 'BarberPro' }).rows()
56
- const [вася] = await db.Организация(org).Сотрудник().set({ name: 'Вася', roles: ['master'] }).rows()
55
+ const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
56
+ const [вася] = await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
57
57
  await db.close()
58
58
  ```
59
59
 
@@ -108,7 +108,7 @@ const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz
108
108
 
109
109
  | SQL | Триггер | Поведение |
110
110
  |---|---|---|
111
- | `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id; у LINK есть обязательные концы (`'Entity'`-концы полиморфны — закрывает либа). Tombstone-вставки не проверяются |
111
+ | `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id; концы LINK по Schema.links v2 (жадный матчинг, союзы, optional, лишние связи — ошибка; legacy-строки — по-старому). Tombstone-вставки не проверяются |
112
112
  | `UPDATE` | `entity_update` | физического апдейта нет: вставляется **новая версия** (`updated = GREATEST(clock, prev+1µs)`); не-latest строки игнорируются — история неизменна |
113
113
  | `DELETE` | `entity_delete` | вставляется **tombstone** + **рекурсивный каскад**: DELETE живых зависимых (`links ⊃ {класс: id}`) повторяет триггер по дереву; advisory-lock; история/tombstone неприкосновенны (повторный DELETE — no-op) |
114
114
 
@@ -148,13 +148,79 @@ GIN-кандидатам, затем перепроверка условий н
148
148
  | `ancestor` | прямой родитель |
149
149
  | `ancestors` | `[self, parent, …, root]` — **считает триггер `schema_lineage`** |
150
150
  | `descendants` | все потомки транзитивно — **тот же триггер** |
151
- | `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; вложенные `{type:'object', props:{…}}` любой глубины; строгая валидация |
152
- | `links` | HUB: встраиваемые цели (forward-обход); LINK: обязательные концы, `'Entity'` = полиморфный |
151
+ | `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; вложенные `{type:'object', props:{…}}` любой глубины; строгая валидация; собираются по иерархии (§ 3.3); правило `id` — генерация (§ 3.2) |
152
+ | `links` | концы связей класса, формат v2 — массив объектов (см. ниже); legacy-строка `'Org'` = `{class:'Org'}`, `'Entity'` = старый полиморф |
153
153
  | `meta` | `{ abstract?, description? }` |
154
154
 
155
+ ### 3.1 Концы связей — Schema.links v2
156
+
157
+ Каждый конец — объект (`Schema.links jsonb` — массив объектов):
158
+
159
+ ```jsonc
160
+ "links": [
161
+ { "class": "Booking", "cardinality": 1 }, // один класс
162
+ { "classes": ["Service", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
163
+ { "class": "Staff", "optional": true, "cardinality": 1 } // конец может отсутствовать
164
+ ]
165
+ ```
166
+
167
+ - `Entity` в концах не используется — классы называются явно («максимально точная идентификация связи»)
168
+ - **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет позиции — `{Услуга|Комплекс}` (заказ услуги ИЛИ заказ комплекса — ровно один из)
169
+ - Обязательный конец без ключа → `requires end "Service|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
170
+ - `cardinality` — зарезервировано (0 — безлимит, N — точное число), пока не проверяется: связь класса в строке одна (`{Класс: id}`), множественность выражается строками-связками
171
+ - Демо-домен: `позиция = [Запись, {Услуга|Комплекс}, Сотрудник?]`, `состав = [Комплекс, Услуга]` (состав комплекса — отдельный LINK), `навык = [Сотрудник, {Услуга|Комплекс}]`, `занятость = [Сотрудник, Окно, Запись?]`, `смена = [Расписание, Сотрудник]`, `Запись = [Клиент?]`
172
+ - Источник правды — `schema.booking.v2.json`; сид генерится: `node lib/scripts/gen-seed.mjs`
173
+
155
174
  `schema_lineage` (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
156
175
  пересчитывает `ancestors`/`descendants` при любом изменении Schema.
157
- Из либы: `db.registry.resolve('связь').descendants` → `['busy','item','shift','skill']`.
176
+ Из либы: `db.registry.resolve('связь').descendants` → `['busy','compo','item','shift','skill']`.
177
+
178
+ ### 3.2 id считает схема: `attributes.id`
179
+
180
+ Правило рождения id объявляется в `attributes.id` класса (наследуется — § 3.3):
181
+
182
+ | Правило | Версия | Смысл |
183
+ |---|---|---|
184
+ | `"uuid"` / `{"type":"uuid"}` | **v4** | random — дефолт (как раньше) |
185
+ | `{"type":"uuid","generate":7}` | **v7** | unix-время в старших битах — вставки ложатся в хвост btree-индекса |
186
+ | `{"type":"uuid","generate":5,"from":[…]}` | **v5** | детерминированный: id вычисляется из данных |
187
+
188
+ Формула v5 открыта: `uuidv5("pgSchema:partition:класс:значения from")`, namespace letopis —
189
+ `c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90`. `from` — имена ОБЯЗАТЕЛЬНЫХ концов `Schema.links`
190
+ (класс или полное имя союза `'Service|Complex'`; значение — id конца) и/или скалярных полей
191
+ `data`. Экспорт: `import { uuidv5, uuidv7, LETOPIS_NS } from 'letopis'`.
192
+
193
+ Два свойства v5, ради которых всё:
194
+
195
+ - **create идемпотентен**: та же комбинация → та же сущность (повтор — новая версия, не
196
+ дубль) — даже в гонке двух процессов оба вычислят один id; двойная бронь мертва на
197
+ уровне схемы;
198
+ - **id известен ДО создания**: «занято ли окно» — точечный `first()` по вычисленному id,
199
+ без создания чего-либо.
200
+
201
+ ```ts
202
+ // занятость: id считается сам — uuidv5(окно, мастер)
203
+ const [б] = await db.Сотрудник(м).занятость().create({ kind: 'booking' }).Окно.set(о).rows()
204
+ б.id === uuidv5(`v1.booking:entity:busy:${о.id}:${м.id}`) // → true
205
+ // «занято?» — ДО создания чего-либо:
206
+ await db.занятость(uuidv5(`v1.booking:entity:busy:${о.id}:${м.id}`)).first() // Row | null
207
+ ```
208
+
209
+ Явный id у v5-класса запрещён (`computes id` — его всегда считает схема); в батче
210
+ v5-классы не склеиваются в multi-VALUES (id нужны концы) — исполняются поштучно (§ 8).
211
+
212
+ Демо-схема booking: корни `Entity`/`link` объявляют дефолт `{generate: 7}` один раз;
213
+ v7-классы (Org/Staff/Folder/Customer/Schedule/Booking/item) наследуют его без собственного
214
+ правила; v5 переопределяют: `busy ← v5(Slot, Staff)`, `skill ← v5(Staff, Service|Complex)`,
215
+ `shift ← v5(Schedule, Staff)`, `compo ← v5(Complex, Service)`, `Slot ← v5(Schedule,
216
+ data.start)`, `Service`/`Complex` ← `v5(Org, data.name)` — имя уникально в организации.
217
+
218
+ ### 3.3 Наследование attributes
219
+
220
+ При загрузке реестра attributes класса собираются по цепочке `ancestor`: **потомок ПОВЕРХ
221
+ предка**, переопределение поля — замена правила ЦЕЛИКОМ (не слияние). Правило `id` (§ 3.2)
222
+ наследуется так же — дефолт объявляется один раз на корне иерархии; `links` НЕ наследуются:
223
+ концы объявляет каждый класс сам. Валидатор и типы полей компилируются из слитых attributes.
158
224
 
159
225
  ---
160
226
 
@@ -169,6 +235,26 @@ GIN-кандидатам, затем перепроверка условий н
169
235
  | HUB → HUB (разные) | forward, если цель ∈ `Schema.links` текущего; иначе reverse; иначе «no path» |
170
236
  | HUB → HUB (тот же класс) | reverse = **дети** (`db.Папка(id).Папка()`); родитель — `row.links.Folder` |
171
237
  | LINK → LINK | ошибка |
238
+ | повтор LINK-класса | **pivot**: возврат к тому же узлу (ветвление к другому концу + дофильтр AND) |
239
+
240
+ Путь подчиняется переходам **всегда** — и в чтении, и в записи; недопустимый переход —
241
+ ошибка **синхронно при построении цепочки** (не в терминале). К концу связки, до которого
242
+ прямого HUB→HUB пути нет, идут через саму связку и **pivot** (повтор её имени):
243
+
244
+ ```ts
245
+ // «позиции записи з, где услуга у и мастер вася» — путь через позицию, pivot-возврат:
246
+ db.Запись(з).позиция().Услуга(у).позиция().Сотрудник(вася).позиция().rows()
247
+ // «умеет ли Ирина стрижку» — навык, дофильтр предметом, count путей:
248
+ db.Сотрудник(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
249
+ ```
250
+
251
+ ### Узел-переменная: `entity()`
252
+
253
+ ```ts
254
+ const p = db.позиция() // ленивый узел-паттерн
255
+ await db.Запись(з).entity(p).Услуга(у).entity(p).Сотрудник().run() // та же p = тот же узел (явный pivot)
256
+ await db.entity(row).Услуга().first() // старт пути с готового Row
257
+ ```
172
258
 
173
259
  ### Терминалы
174
260
 
@@ -244,88 +330,112 @@ db.Организация().owner(acc) // фильтр по owne
244
330
  db.Сотрудник({…}).alias('Мастер') // ключ шага в путях
245
331
  ```
246
332
 
247
- | Модификатор | Область | В чтении | В `set()` |
333
+ | Модификатор | Область | В чтении | В записи |
248
334
  |---|---|---|---|
249
- | `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей UPDATE |
335
+ | `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей `update()` |
250
336
  | `asOf(t)` | вся цепочка | «как было на T» (§ 10.1) | — |
251
337
  | `after(cursor)` | вся цепочка | keyset-пагинация, требует `sort` (§ 10.2) | — |
252
338
  | `deep(max?)` | текущий шаг (self-hop) | рекурсивные дети, `$depth` (§ 10.4) | — |
253
- | `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при INSERT (string \| string[]) |
254
- | `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при INSERT |
255
- | `link(Класс, target)` | текущий шаг | — | **значение** связи БЕЗ участия в фильтре целей (§ 6) |
339
+ | `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при `create()` (string \| string[]) |
340
+ | `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при `create()` |
341
+ | `.Класс.set(x)` / `.Класс.unset()` | слот связи — после глагола записи | — | записать / снять конец связи БЕЗ участия в фильтре целей (§ 6.1) |
256
342
  | `alias(name)` | текущий шаг | ключ в путях | — |
257
343
 
258
344
  ---
259
345
 
260
346
  ## 6. Запись: операции — звенья плана
261
347
 
262
- `.set(data)` / `.delete(opts)` / `.anonymize(fields)` — **звенья цепочки**: операция
263
- применяется к шагу, к которому приклеена точкой, и возвращает цепочку для продолжения.
264
- Сама по себе ничего не пишет — **исполняет терминал** (`rows()/first()/count()/run()/…`),
265
- весь план **одной транзакцией**: отказ любого сегмента (валидация, ACL) откатывает всё.
348
+ `.create(data?)` / `.update(data?)` / `.delete(opts?)` / `.anonymize(fields)` — **звенья
349
+ цепочки**: операция применяется к шагу, к которому приклеена точкой, и возвращает цепочку
350
+ для продолжения. Сама по себе ничего не пишет — **исполняет терминал**
351
+ (`rows()/first()/count()/run()/…`), весь план **одной транзакцией**: отказ любого сегмента
352
+ (валидация, ACL) откатывает всё.
353
+
354
+ Глаголы не перепутать: `create` **вставляет** (или версионирует по известному id),
355
+ `update` **правит найденное путём** и никогда не создаёт. Старый `set()` разбит на них
356
+ в 0.16.0 (бросает подсказку) — мина «`Класс()` → INSERT, `Класс({})` → UPDATE всех» мертва,
357
+ намерение всегда явное.
266
358
 
267
359
  ```ts
268
360
  // одиночная запись: операция + терминал
269
- const [вася] = await db.Организация(org).Сотрудник().set({ name: 'Вася', roles: ['master'] }).rows()
361
+ const [вася] = await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
270
362
 
271
363
  // несколько операций в одной цепочке: продолжение — ОТ РЕЗУЛЬТАТА предыдущей
272
- await db.Клиент({ vip: true }).set({ bonus: 500 }) // UPDATE всех vip
273
- .Запись().set({ status: 'gift' }) // INSERT записи КАЖДОМУ (fan-out)
364
+ await db.Клиент({ vip: true }).update({ bonus: 500 }) // новая версия всех vip
365
+ .Запись().create({ status: 'gift' }) // INSERT записи КАЖДОМУ (fan-out)
274
366
  .rows() // → подарочные записи
275
367
 
276
368
  // операция сразу после операции — к тем же сущностям (две версии подряд)
277
- await db.Запись(id).set({ status: 'confirmed' }).set({ paid: true }).rows()
369
+ await db.Запись(id).update({ status: 'confirmed' }).update({ paid: true }).rows()
278
370
  ```
279
371
 
280
372
  ### Анатомия
281
373
 
282
374
  ```
283
- db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).set( DATA ).Хвост()… .rows()
284
- └───── контекст ─────┘ └─────┘ └─ продолжение ─┘ └─ терминал: исполняет план
285
- каждый шаг = связь кого трогаем от записанных
375
+ db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хвост()… .rows()
376
+ └───── контекст ─────┘ └─────┘ └────┘ └─ продолжение ─┘ └─ терминал: исполняет план
377
+ каждый шаг = связь кого трогаем create|update|delete от записанных
378
+ (только update)
286
379
  ```
287
380
 
288
381
  - **Контекст-шаги** (шаги до операции): каждый резолвится в **ровно одну** сущность —
289
382
  id-фильтром (без запроса) или уникальным фильтром (0 или >1 → ошибка). Дают: `links`
290
- при INSERT и containment-фильтр целей при UPDATE/DELETE.
291
- - **ФИЛЬТР шага операции** — режим:
383
+ при `create` и containment-фильтр целей при `update`/`delete`.
384
+ - **Режим выбирает глагол** (фильтр-объект допустим только перед `update`):
292
385
 
293
- | Фильтр шага операции | data.id | Действие |
386
+ | Глагол | Шаг операции | Действие |
294
387
  |---|---|---|
295
- | `Класс()` — без аргумента | нет | **INSERT** (id = uuid), links = контекст |
296
- | `Класс()` — без аргумента | есть | **UPSERT** по `data.id` |
297
- | `Класс('id')` — строка | — | **UPSERT** по этому id |
298
- | `Класс({...поля})` — объект | — | **UPDATE**: новая версия каждого найденного (в границах контекста); пусто → `[]` |
299
- | `Класс({})` — **пустой объект** | — | **UPDATE всех** в границах контекста |
388
+ | `create(data?)` | `Класс()` — без фильтра | **INSERT**: id по правилу схемы (§ 3.2), links = контекст + слоты |
389
+ | | `Класс(id)` / `data.id` / вычисленный v5-id **уже существует** | **новая версия** (идемпотентный create, REST-PUT семантика); не существует → INSERT с этим id |
390
+ | | `Класс({фильтр})` | ошибка ПОСТРОЕНИЯ `create() takes no filter`; create на pivot-шаге — тоже ошибка |
391
+ | `update(data?)` | `Класс()` ≡ `Класс({})` | новая версия **всех** в границах контекста |
392
+ | | `Класс(id)` / `Класс({поля})` / pivot | новая версия **каждого** найденного путём; не найдено → `[]` — update НИКОГДА не создаёт |
300
393
 
301
- - **Продолжение после операции** — от её результата: класс-шаг = переход (INSERT-цель
394
+ - **Продолжение после операции** — от её результата: класс-шаг = переход (create-цель
302
395
  следующего сегмента получает связь на записанное; чтение — обычный hop). При
303
396
  множественном результате следующий сегмент исполняется **для каждой строки** (fan-out).
304
397
  После `delete` продолжение идёт от строк класса цели.
305
- - **DATA** — поля сущности/связи; `id` передаётся здесь (`{ id: busyId, … }`).
398
+ - **DATA** — поля сущности/связи; явный `id` — здесь (`{ id, … }`) или шагом `Класс(id)`;
399
+ у v5-класса явный id запрещён — его считает схема (§ 3.2).
306
400
  Валидация **строгая, всегда**: поле вне `Schema.attributes` → `ValidationError`;
307
401
  default-ы схемы подставляются; id в `data` не хранится (он — колонка).
308
- - **Deep-merge при UPDATE**: меняются только указанные листья —
309
- `set({ price: { RUB: 1100 } })` сохранит `USD/EUR` и остальные поля.
310
- Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
311
- - **Концы LINK**: объявленные в `Schema.links` обязательны; значения — id или Row.
402
+ - **Deep-merge при `update`** (и у create-версии по известному id): меняются только
403
+ указанные листья — `update({ price: { RUB: 1100 } })` сохранит `USD/EUR` и остальные
404
+ поля. Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
405
+ - **Концы LINK**: по `Schema.links` v2 (§3.1) — обязательные требуются, союз занимает один ключ, лишние связи — ошибка; значения — id, Row или вложенная цепочка (§6.1).
312
406
  - **account/owner NOT NULL**: `.account()/.owner()` → `connect()` → System-аккаунт.
313
407
  - Версии монотонны (`GREATEST(clock, prev+1µs)`), коллизия 23505 ретраится.
314
408
  - Цепочка переиспользуема: повторный терминал = повторное исполнение плана.
315
409
 
316
- ### Связи: контекст и `.link()`
410
+ ### 6.1 Связи: путь и слоты `.Класс.set()` / `.Класс.unset()`
411
+
412
+ Один закон: **владелец создаваемой связки берётся из валидного пути; прочие концы —
413
+ слотами** `.Класс.set(значение)` после глагола записи. Слот — свойство-класс БЕЗ вызова
414
+ (`.Сотрудник.set(x)`, не `.Сотрудник(x)`); валиден только для конца из `Schema.links` и
415
+ требует операцию записи ПЕРЕД собой: `…create(…).Класс.set(x)` / `…update(…).Класс.unset()`;
416
+ слот без глагола — ошибка `link slot needs a write`.
317
417
 
318
418
  ```ts
319
- // 1) контекст ДО операции: шаги-связи, затем класс-цель (значение + фильтр целей)
320
- await db.Сотрудник(s).Окно(w).Запись(b).занятость().set({ id: busyId, kind: 'booking' }).rows()
419
+ // СОЗДАНИЕ: владелец Запись — из пути, предмет и исполнитель — слотами
420
+ await db.Запись(b).позиция().create({ qty: 1, price: { RUB: 1500 } })
421
+ .Услуга.set(u).Сотрудник.set(s).rows()
321
422
 
322
- // 2) .link(Класс, target) — ТОЛЬКО значение связи, в фильтр целей не входит:
323
- await db.занятость(busyId).link('Сотрудник', s).link('Окно', w).link('Запись', b)
324
- .set({ kind: 'booking' }).rows()
423
+ // занятость: владелец Сотрудник из пути, окно и запись — слотами; id считает схема (§ 3.2)
424
+ await db.Сотрудник(s).занятость().create({ kind: 'booking' }).Окно.set(w).Запись.set(b).rows()
325
425
 
326
- // разница видна на UPDATE: сменить исполнителя ВСЕХ позиций записи —
327
- await tr.Запись(b).позиция({}).link('Сотрудник', новый).set({}).rows()
328
- // контекст-шаг Сотрудник(новый) тут не годится: он ФИЛЬТРОВАЛ бы позиции по старой связи
426
+ // ПЕРЕВЕС связи (update): цель ищется путём/pivot, слот пишет новое значение БЕЗ фильтра
427
+ await tr.Запись(b).позиция().Сотрудник(старый).позиция().update().Сотрудник.set(новый).rows()
428
+
429
+ // СОЮЗ-конец [Услуга|Комплекс]: слот замещает конец целиком (соседний класс снимается)
430
+ await db.позиция(p).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
431
+
432
+ // СНЯТИЕ optional-конца
433
+ await db.занятость(bz).update({ kind: 'hold' }).Запись.unset().rows()
434
+
435
+ // вложенная цепочка как значение слота — та же транзакция, ровно одна сущность:
436
+ await db.Запись(b).позиция().create({ qty: 1 })
437
+ .Услуга.set(u)
438
+ .Сотрудник.set(db.Сотрудник().create({ name: 'Новичок' }).Org.set(org)).rows() // создать И привязать
329
439
  ```
330
440
 
331
441
  ### `.delete({ confirm })` — превью и серверное удаление
@@ -343,14 +453,14 @@ await db.Запись(b).занятость().delete({ confirm: true }).rows() /
343
453
 
344
454
  Цели = фильтр шага + контекст-связи. Замыкание (цели + все живые зависимые) собирается
345
455
  одним CTE; `confirm: true` шлёт один SQL `DELETE` — триггер БД тумбстоунит дерево
346
- (+advisory-lock). История неприкосновенна; повторный delete → `[]`; `set()` по тому же
456
+ (+advisory-lock). История неприкосновенна; повторный delete → `[]`; `create()` с тем же
347
457
  id — воскрешение.
348
458
 
349
459
  ### Откат плана
350
460
 
351
461
  ```ts
352
- await db.Клиент(id).set({ name: 'Новое' }) // валидный сегмент…
353
- .Запись().set({ чепуха: 1 }) // …невалидный: ValidationError
462
+ await db.Клиент(id).update({ name: 'Новое' }) // валидный сегмент…
463
+ .Запись().create({ чепуха: 1 }) // …невалидный: ValidationError
354
464
  .rows()
355
465
  // ← ОТКАТ ВСЕГО: имя клиента не изменилось, версий не прибавилось
356
466
  ```
@@ -360,25 +470,28 @@ await db.Клиент(id).set({ name: 'Новое' }) // валидный
360
470
  ## 7. Транзакции
361
471
 
362
472
  ```ts
473
+ const busyId = uuidv5(`v1.booking:entity:busy:${slotId}:${staffId}`) // id известен ДО создания (§ 3.2)
474
+
363
475
  const tr = await db.begin() // тот же API на выделенном соединении
364
476
  await tr.lock('busy', staffId, slotId) // advisory-xact-lock до конца транзакции
365
477
  const занято = await tr.занятость(busyId).first()
366
- if (!занято) await tr.Сотрудник(s).Окно(w).занятость(busyId).set({ kind: 'booking' }).rows()
478
+ if (!занято) await tr.Сотрудник(s).занятость().create({ kind: 'booking' }).Окно.set(w).rows()
367
479
  await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()
368
480
  ```
369
481
 
370
482
  Повторный commit/rollback — no-op. `lock()` вне транзакции — ошибка. Держите транзакции
371
- короткими. **Рецепт двойной брони**: `lock(мастер, окно)` + детерминированный id занятости +
372
- перечитать `first()` под локом → параллельная транзакция видит бронь и отказывает
373
- (ровно одна успешна — покрыто тестом-гонкой).
483
+ короткими. **Рецепт двойной брони**: id занятости детерминирован схемой (v5 — § 3.2), так
484
+ что дубль невозможен в принципе (второй `create` стал бы версией той же занятости);
485
+ `lock(мастер, окно)` + перечитка `first()` под локом нужны, чтобы сопернику честно
486
+ ОТКАЗАТЬ, а не молча версионировать чужую бронь (ровно одна успешна — покрыто тестом-гонкой).
374
487
 
375
488
  ---
376
489
 
377
490
  ## 8. Батчи
378
491
 
379
492
  ```ts
380
- db.batch('окна').Расписание(sch).Окно().set({ start, end }) // план встал в очередь
381
- db.batch('окна').Расписание(sch).Окно().set({ … })
493
+ db.batch('окна').Расписание(sch).Окно().create({ start, end }) // план встал в очередь
494
+ db.batch('окна').Расписание(sch).Окно().create({ … })
382
495
  db.batch('окна').size() // 2
383
496
  const res = await db.batch('окна').run() // одна транзакция; Row[][] по порядку
384
497
  db.batch('окна').discard() // отменить
@@ -387,8 +500,9 @@ db.batch('окна').discard() // отменить
387
500
  - `run()` атомарен: любая ошибка откатывает всё.
388
501
  - Операции в батч-цепочке кладут ПЛАН в очередь (многосегментные планы — одним элементом);
389
502
  терминал на такой цепочке — ошибка `plan is queued in the batch — call batch.run()`.
390
- - Подряд идущие чистые INSERT одного класса (один шаг, пустой фильтр, без data.id и `.link`)
391
- склеиваются в один multi-VALUES.
503
+ - Подряд идущие чистые `create()` одного класса (план из одного шага, без `data.id` и
504
+ слотов) склеиваются в один multi-VALUES; **v5-классы — поштучно** (id считается из
505
+ концов/полей — § 3.2), семантика та же.
392
506
  - Read-вызовы на батч-фасаде исполняются сразу, мимо очереди.
393
507
 
394
508
  ---
@@ -533,7 +647,7 @@ db.acl.reload() // сброс кэша Resou
533
647
  ```
534
648
 
535
649
  **`connect({ account, enforceAcl: true })`** — те же решения в цепочках: каждый шаг —
536
- `READ`, `set()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
650
+ `READ`, `create()`/`update()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
537
651
  (deny в замыкании откатывает транзакцию); `watch()` отдаёт события только безусловных
538
652
  allow-классов (payload нечем проверить предикат). Правила фиксируются на connect.
539
653
 
@@ -542,10 +656,10 @@ const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
542
656
  await u.Запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
543
657
  await u.Запись().count() // честный count по суженному множеству
544
658
  await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
545
- const [z] = await u.Запись().set({}) // owner пришпилен правилом → user.id
546
- await u.Запись().owner(other).set({}) // Error: acl pins Booking writes to owner …
547
- await u.Запись(чужаяId).set({ status: '…' }) // → [] — цель вне предиката не находится
548
- // и не перехватывается: «не нашёл среди своих» при существующей чужой НЕ превращается в INSERT
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 тоже НЕ перехватывает: → [] вместо новой версии
549
663
  ```
550
664
 
551
665
  Оверхед (article-полигон 1.1M, p50 из 20; `bench/acl.bench.mjs`):
@@ -687,7 +801,7 @@ const stop = await db.watch('Запись', onEvent, {
687
801
  ```ts
688
802
  const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
689
803
  await db.Запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
690
- await db.Клиент().set({ name: 'X' }) // запись пришпилена к account
804
+ await db.Клиент().create({ name: 'X' }) // запись пришпилена к account
691
805
  db.Запись().account(чужой).rows() // ошибка: reads are pinned to account …
692
806
  ```
693
807
 
@@ -808,7 +922,7 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
808
922
 
809
923
  Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
810
924
  [11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
811
- [11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-set--delete--anonymize) ·
925
+ [11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-create--update--delete--anonymize) ·
812
926
  [11.6 Транзакции](#116-транзакции-entitytx) · [11.7 Батчи](#117-batch) ·
813
927
  [11.8 Таблицы](#118-таблицы-accounts--credentials--resources--rules) ·
814
928
  [11.9 db.auth](#119-dbauth--вход-и-сессии) · [11.10 db.acl](#1110-dbacl) ·
@@ -932,7 +1046,7 @@ await up({ dsn, schema: 'v1.booking', version: 1 })
932
1046
  ```ts
933
1047
  const db = await up({ schema: 'booking', version: 1, dsn }) // [7.4 s] образ+контейнер+схема
934
1048
  await db.Клиент().count() // → 0 — свежая схема
935
- const [аня] = await db.Клиент().set({ name: 'Аня', contact: '+7 900' }).rows()
1049
+ const [аня] = await db.Клиент().create({ name: 'Аня', contact: '+7 900' }).rows()
936
1050
  await db.close()
937
1051
 
938
1052
  // … docker stop letopis-timescale (ребут, уборка, что угодно) …
@@ -947,6 +1061,7 @@ await db2.close()
947
1061
  ```ts
948
1062
  import {
949
1063
  connect, up, cursorOf, totpCode, // функции
1064
+ uuidv5, uuidv7, LETOPIS_NS, // генерация id (§ 3.2): формула v5 открыта
950
1065
  ne, gt, gte, lt, lte, between, inList, like, ilike, // операторы (18)
951
1066
  starts, ends, has, hasAny, hasAll, exists, isNull, not, or,
952
1067
  ValidationError, Registry, // классы
@@ -976,7 +1091,7 @@ import type {
976
1091
 
977
1092
  **Назначение и алгоритм.** Старт цепочки чтения/записи (§ 4–6). Ничего не выполняет —
978
1093
  только копит шаги; SQL строится и уходит в БД одним запросом на терминале
979
- (`rows/execute/count/…` — § 11.3). Формы `filter` нормализуются сразу: объект с `.id`
1094
+ (`rows/run/count/…` — § 11.3). Формы `filter` нормализуются сразу: объект с `.id`
980
1095
  сворачивается в строку-id.
981
1096
 
982
1097
  **Примеры**
@@ -1015,8 +1130,8 @@ deadlock/serialization ошибка приходит сразу с подска
1015
1130
 
1016
1131
  ```ts
1017
1132
  const tr = await db.begin() // [0.8 ms]
1018
- await tr.Организация('…0901').Услуга().set({ id: '…0922', name: 'Укладка', duration: 15, price: { RUB: 700 } }).rows()
1019
- // [7.0 ms] → [Row] — видно ТОЛЬКО внутри tr
1133
+ await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15, price: { RUB: 700 } }).rows()
1134
+ // [7.0 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
1020
1135
  await tr.commit() // [2.7 ms] — теперь видно всем
1021
1136
  ```
1022
1137
 
@@ -1024,10 +1139,10 @@ await tr.commit() // [2.7 ms] — т
1024
1139
 
1025
1140
  ```ts
1026
1141
  const tr = await db.begin()
1027
- await tr.Услуга('…0922').set({ price: { RUB: 9900 } }).rows()
1028
- await tr.Услуга('…0922').first() // внутри tx → price.RUB = 9900
1142
+ await tr.Услуга({ name: 'Укладка' }).update({ price: { RUB: 9900 } }).rows()
1143
+ await tr.Услуга({ name: 'Укладка' }).first() // внутри tx → price.RUB = 9900
1029
1144
  await tr.rollback() // [0.8 ms]
1030
- await db.Услуга('…0922').first() // снаружи → price.RUB = 700 — изменение исчезло
1145
+ await db.Услуга({ name: 'Укладка' }).first() // снаружи → price.RUB = 700 — изменение исчезло
1031
1146
  ```
1032
1147
 
1033
1148
  #### `db.commit(tr): Promise<void>` / `db.rollback(tr): Promise<void>`
@@ -1069,8 +1184,8 @@ Tombstone-версия приходит с `deleted: true`.
1069
1184
 
1070
1185
  ```ts
1071
1186
  const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [34.8 ms]
1072
- await db.Организация('…0901').Клиент().set({ id: '…0932', name: 'Пётр §11' }).rows()
1073
- await db.Клиент('…0932').set({ name: 'Пётр Второй' }).rows()
1187
+ await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр §11' }).rows()
1188
+ await db.Клиент('…0932').update({ name: 'Пётр Второй' }).rows()
1074
1189
  await db.Клиент('…0932').delete({ confirm: true }).rows()
1075
1190
  // пойманные (3 события: insert → update → tombstone):
1076
1191
  // { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.775349+00:00', deleted: false }
@@ -1087,7 +1202,7 @@ const stop = await db.watch('Клиент', (e) => события.push(e), {
1087
1202
  })
1088
1203
  // авария: pg_terminate_backend по LISTEN-соединению …
1089
1204
  // onReconnect сработал через 0.1 s после обрыва (реальный прогон)
1090
- await db.Клиент('…0932').set({ name: 'Пётр после обрыва' }).rows()
1205
+ await db.Клиент('…0932').create({ name: 'Пётр после обрыва' }).rows() // create по id — воскрешение
1091
1206
  // событие после reconnect: { class: 'Customer', id: '…0932', updated: '…35.40884+00:00', deleted: false }
1092
1207
  await stop()
1093
1208
  ```
@@ -1146,8 +1261,8 @@ await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.article"."Entity"')
1146
1261
 
1147
1262
  **Назначение и алгоритм.** Продолжение пути по связям: прямой переход (`e.id =
1148
1263
  prev.links->>'Класс'`) кладётся точным равенством, обратный — containment
1149
- `links @> {prevClass: prevId}` в кандидаты + перепроверку. В `set()`-цепочке шаги до
1150
- последнего — **контекст** (§ 11.5).
1264
+ `links @> {prevClass: prevId}` в кандидаты + перепроверку. В цепочке записи шаги до
1265
+ операции — **контекст** (§ 11.5).
1151
1266
 
1152
1267
  **Примеры**
1153
1268
 
@@ -1240,7 +1355,7 @@ await db.Сотрудник().ids() // [3.8 ms] → 302 id
1240
1355
  Параметров нет.
1241
1356
 
1242
1357
  **Назначение и алгоритм.** `SELECT count(*)` поверх соединения шагов — считает **пути**
1243
- (как `execute()`), не уникальные сущности: для цепочки из одного класса это одно и то же,
1358
+ (как `run()`), не уникальные сущности: для цепочки из одного класса это одно и то же,
1244
1359
  для многошаговой — нет (см. кейс `.rows()`).
1245
1360
 
1246
1361
  ```ts
@@ -1479,7 +1594,7 @@ await db.Запись().countBy('data.status') // [575.8 ms] — 250k сущн
1479
1594
 
1480
1595
  | Параметр | Тип | Описание |
1481
1596
  |---|---|---|
1482
- | `name` | `string` | ключ ТЕКУЩЕГО шага в объектах-путях `execute()` |
1597
+ | `name` | `string` | ключ ТЕКУЩЕГО шага в объектах-путях `run()` |
1483
1598
 
1484
1599
  **Назначение.** Переименование ключа шага в выводе (данные и SQL не меняются) — удобно,
1485
1600
  когда один класс встречается в пути дважды.
@@ -1495,7 +1610,7 @@ await db.Организация(org).alias('салон').Сотрудник({ na
1495
1610
 
1496
1611
  | Параметр | Тип | Описание |
1497
1612
  |---|---|---|
1498
- | `v` | `string \| string[] \| has/hasAny/hasAll` | чтение: фильтр колонки `tags` (строка ≡ содержит; массив ≡ содержит все); в `set()`-цепочке — **значение** тегов INSERT |
1613
+ | `v` | `string \| string[] \| has/hasAny/hasAll` | чтение: фильтр колонки `tags` (строка ≡ содержит; массив ≡ содержит все); в цепочке записи — **значение** тегов `create()`-INSERT |
1499
1614
 
1500
1615
  **Назначение и алгоритм.** Фильтр по массивной колонке `tags` (`@>` — GIN-индекс,
1501
1616
  кандидаты + перепроверка). В записи — модификатор значения.
@@ -1512,7 +1627,7 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms]
1512
1627
 
1513
1628
  | Параметр | Тип | Описание |
1514
1629
  |---|---|---|
1515
- | `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`; в `set()`-INSERT — значение колонки |
1630
+ | `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`; при `create()` — значение колонки |
1516
1631
 
1517
1632
  **Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree). Под `enforceAccount`
1518
1633
  чужой `.account()` — ошибка; под `enforceAcl` конфликт с пришпиленной правилом колонкой —
@@ -1670,119 +1785,145 @@ await db.Услуга(or({ name: 'Стрижка' }, { duration: lt(45) })).coun
1670
1785
 
1671
1786
  **Кейс:** «стрижка или что-нибудь быстрое» — один запрос: 1 (Стрижка) + 101 (быстрые) = 102.
1672
1787
 
1673
- ### 11.5 Запись: set / delete / anonymize
1788
+ ### 11.5 Запись: create / update / delete / anonymize
1674
1789
 
1675
1790
  Операции — **звенья плана** (§ 6): каждая применяется к шагу, к которому приклеена точкой,
1676
1791
  и возвращает цепочку. Исполняет **терминал** — весь план одной транзакцией (внутренней,
1677
1792
  с ретраем transient; внутри `db.begin()` — транзакцией пользователя); отказ любого сегмента
1678
- откатывает всё. Продолжение цепочки — от результата операции.
1793
+ откатывает всё. Продолжение цепочки — от результата операции. Старый `set()` разбит на
1794
+ `create()`/`update()` в 0.16.0 — бросает подсказку.
1679
1795
 
1680
- #### `.set(data?): Chain`
1796
+ #### `.create(data?): Chain`
1681
1797
 
1682
1798
  | Параметр | Тип | Описание |
1683
1799
  |---|---|---|
1684
- | `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация: лишний ключ — ошибка) + опциональный `id` — явный id строки |
1685
-
1686
- Роль фильтра шага операции:
1687
-
1688
- | Форма | Пример | Смысл |
1689
- |---|---|---|
1690
- | — | `db.Услуга().set({…})` | **INSERT** новой сущности |
1691
- | `{}` пустой объект | `db.позиция({}).set({…})` | **UPDATE всех** в границах контекста |
1692
- | `string` / `Row` | `db.Услуга('id').set({…})` | **UPSERT** по id: есть — новая версия, нет — INSERT с этим id |
1693
- | объект | `db.Запись({ status: 'created' }).set({…})` | UPDATE каждого найденного (в контексте) |
1694
-
1695
- **Назначение и алгоритм.** Единственный способ записи. На терминале для каждого сегмента:
1696
- (1) под `enforceAcl` — WRITE-решение по классу (deny — откат; предикат правила пришпиливает
1697
- `owner`/`account` и фильтрует цели); (2) контекст-шаги резолвятся в связи — каждый обязан
1698
- дать **ровно одну** сущность; (3) поиск целей: INSERT-форма не ищет, id/фильтр — ищут в
1699
- границах контекста; (4) на каждую цель: **deep-merge** листьев `data`, слияние links
1700
- (контекст + `.link()`), строгая валидация, **один INSERT новой версии** с
1701
- `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) UPSERT по id при пустом поиске —
1702
- INSERT (воскрешает удалённую; под ACL существующая-но-недоступная **не перехватывается**);
1703
- (6) конфликт `23505`/transient ретраится транзакцией плана. Сегмент после операции
1704
- исполняется **для каждой строки её результата** (fan-out); `set` сразу после `set` — новая
1705
- версия тех же сущностей (self).
1800
+ | `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация: лишний ключ — ошибка) + опциональный `id` (у v5-класса запрещён — id считает схема, § 3.2) |
1801
+
1802
+ **Назначение и алгоритм.** «Чтобы сущность существовала». На терминале: (1) под
1803
+ `enforceAcl` — WRITE-решение по классу (deny — откат; предикат правила пришпиливает
1804
+ `owner`/`account`); (2) владелец создаваемой связки — из валидного пути (каждый
1805
+ контекст-шаг обязан дать **ровно одну** сущность), прочие концы — слотами; (3) id: явный
1806
+ (шаг `Класс(id)` или `data.id`) либо по правилу схемы — v4/v7 генерируются, v5 вычисляется
1807
+ из концов/полей (§ 3.2); (4) id известен и **уже существует** (в границах пути) → **новая
1808
+ версия** с deep-merge — идемпотентный create, REST-PUT семантика (удалённую — воскрешает;
1809
+ под ACL существующая-но-недоступная **не перехватывается** → `[]`); не существует → **один
1810
+ INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конфликт
1811
+ `23505`/transient ретраится транзакцией плана. Фильтр-объект перед create — ошибка
1812
+ ПОСТРОЕНИЯ (`create() takes no filter`); create на pivot-шаге — ошибка.
1706
1813
 
1707
1814
  **Примеры**
1708
1815
 
1709
1816
  ```ts
1710
- await db.Организация().set({ name: 'Пилигрим' }).rows() // [9.0 ms] INSERT + defaults из Schema
1817
+ await db.Организация().create({ name: 'Пилигрим' }).rows() // [9.0 ms] INSERT + defaults из Schema
1711
1818
  // → [{ id: '39aba8a5-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, … }]
1712
1819
 
1713
- await db.Организация().set({ id: '…0901', name: 'Демо-салон §11' }).rows() // [7.0 ms] INSERT с явным id
1820
+ await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
1821
+ // [7.0 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
1714
1822
 
1715
- await db.Организация('…0901').Сотрудник().set({ id: '…0911', name: 'Мия', roles: ['master'] }).rows()
1823
+ await db.Организация('…0901').Сотрудник().create({ id: '…0911', name: 'Мия', roles: ['master'] }).rows()
1716
1824
  // [14.5 ms] контекст → links: { Org: '…0901' }
1717
1825
 
1718
- await db.Услуга('…0921').set({ price: { RUB: 1400, USD: 15 } }).rows() // [7.0 ms] UPSERT: новая версия
1826
+ await db.Организация('…0901').Услуга().create({ name: 'Укладка экспресс', duration: 15, price: { RUB: 700 } }).rows()
1827
+ // [7.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
1828
+ // повторный create той же пары (салон, имя) → новая ВЕРСИЯ той же услуги, не дубль
1829
+
1830
+ db.Услуга({ name: 'Укладка экспресс' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
1831
+ // Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
1832
+
1833
+ await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() // [1.4 ms] — ошибка НА ТЕРМИНАЛЕ:
1834
+ // ValidationError: letopis: validation failed for "Service":
1835
+ // duration — The 'duration' field is required.; … forbidden keys: 'чепуха'
1836
+
1837
+ db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
1838
+ // Error: letopis: no path Service → Customer
1839
+ ```
1840
+
1841
+ #### `.update(data?): Chain`
1842
+
1843
+ | Параметр | Тип | Описание |
1844
+ |---|---|---|
1845
+ | `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация); без аргумента — версия без изменения полей (например, ради слотов) |
1846
+
1847
+ **Назначение и алгоритм.** Новая версия **каждого** найденного путём. Цели ищутся как при
1848
+ чтении — id, фильтр, pivot; `Класс()` ≡ `Класс({})` — «все в границах контекста». На каждую
1849
+ цель: **deep-merge** листьев `data` (`update({ price: { RUB: 1100 } })` сохранит `USD` и
1850
+ остальные поля; массивы/скаляры — целиком), слияние links (слоты `.Класс.set()`/`.unset()`),
1851
+ строгая валидация, один INSERT новой версии. Не найдено → `[]` — update **НИКОГДА не
1852
+ создаёт**. Сегмент после операции исполняется **для каждой строки её результата** (fan-out);
1853
+ `update` сразу после `update` — новая версия тех же сущностей (self).
1854
+
1855
+ **Примеры**
1856
+
1857
+ ```ts
1858
+ await db.Услуга('…0921').update({ price: { RUB: 1400, USD: 15 } }).rows() // [7.0 ms] новая версия по id
1719
1859
  // было: { name: 'Массаж головы', price: { RUB: 1200 }, duration: 30, description: 'релакс', … }
1720
1860
  // стало: { name: 'Массаж головы', price: { RUB: 1400, USD: 15 }, duration: 30, description: 'релакс', … }
1721
1861
  // deep-merge тронул только листья price; duration/description целы
1722
1862
 
1723
- await db.Клиент('…0931').Запись({ status: 'created' }).set({ status: 'confirmed' }).rows() // [12.1 ms]
1863
+ await db.Клиент('…0931').Запись({ status: 'created' }).update({ status: 'confirmed' }).rows() // [12.1 ms]
1724
1864
  // → [{ id: '…0962', status: 'confirmed' }, { id: '…0963', status: 'confirmed' }] — только записи Златы
1725
1865
 
1726
- await db.Клиент('…0931').Запись({}).set({ status: 'completed' }).rows() // [10.7 ms] ВСЕ в контексте
1866
+ await db.Клиент('…0931').Запись().update({ status: 'completed' }).rows() // [10.7 ms] ВСЕ в контексте
1727
1867
  // → [{ id: '…0962', status: 'completed' }, { id: '…0963', status: 'completed' }]
1728
1868
 
1729
- await db.Услуга().set({ name: 'X', чепуха: 1 }).rows() // [1.4 ms] — ошибка НА ТЕРМИНАЛЕ:
1730
- // ValidationError: letopis: validation failed for "Service":
1731
- // duration — The 'duration' field is required.; … forbidden keys: 'чепуха'
1732
-
1733
- db.Услуга('…0921').set({ duration: 30 }).link('Сотрудник', м) // [0.1 ms]
1734
- // Error: letopis: step modifier after set() — add a class step first
1869
+ await db.Запись({ status: 'нет-такого' }).update({ status: 'x' }).rows() // → [] — НИЧЕГО не создано
1735
1870
  ```
1736
1871
 
1737
1872
  **Кейс: план из нескольких операций — реальный прогон**
1738
1873
 
1739
1874
  ```ts
1740
1875
  // обновить клиента → вставить ему запись (продолжение от записанного, одна транзакция)
1741
- await db.Клиент('…0931').set({ language: 'en' })
1742
- .Запись().set({ id: '…0964', status: 'created', total: { RUB: 990 } })
1876
+ await db.Клиент('…0931').update({ language: 'en' })
1877
+ .Запись().create({ id: '…0964', status: 'created', total: { RUB: 990 } })
1743
1878
  .rows() // [15.5 ms]
1744
1879
  // → [{ id: '…0964', class: 'Booking', links: { Customer: '…0931' }, status: 'created' }]
1745
1880
 
1746
- // self-set: две версии подряд
1747
- await db.Запись('…0964').set({ status: 'confirmed' }).set({ status: 'completed' }).rows() // [13.6 ms]
1881
+ // self-update: две версии подряд
1882
+ await db.Запись('…0964').update({ status: 'confirmed' }).update({ status: 'completed' }).rows() // [13.6 ms]
1748
1883
  // → ['completed']; versions: ['created', 'confirmed', 'completed']
1749
1884
 
1750
1885
  // хвост-чтение после операции — в той же транзакции
1751
- await db.Клиент('…0931').set({ language: 'ru' }).Запись().count() // [201.6 ms] → 7
1886
+ await db.Клиент('…0931').update({ language: 'ru' }).Запись().count() // [201.6 ms] → 7
1752
1887
 
1753
1888
  // ОТКАТ: невалидный второй сегмент откатывает и первый
1754
- await db.Клиент('…0931').set({ name: 'Не запишется' }).Запись().set({ чепуха: 1 }).rows()
1889
+ await db.Клиент('…0931').update({ name: 'Не запишется' }).Запись().create({ чепуха: 1 }).rows()
1755
1890
  // Error: letopis: validation failed for "Booking" … [6.1 ms]; имя клиента не изменилось
1756
1891
 
1757
1892
  // fan-out: обновить клиента → снести ВСЕ его записи
1758
- await db.Клиент('…0931').set({ active: true }).Запись().delete({ confirm: true }).rows()
1893
+ await db.Клиент('…0931').update({ active: true }).Запись().delete({ confirm: true }).rows()
1759
1894
  // [229.4 ms] → 7 записей, все с $deleted: true
1760
1895
  ```
1761
1896
 
1762
- #### `.link(className, target): Chain`
1897
+ #### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
1763
1898
 
1764
- | Параметр | Тип | Описание |
1765
- |---|---|---|
1766
- | `className` | `string` | класс связи (id или алиас) |
1767
- | `target` | `string \| { id }` | конец связи |
1899
+ | Форма | Описание |
1900
+ |---|---|
1901
+ | `.Класс.set(target)` | значение конца связи новой версии; `target` = id \| Row \| вложенная цепочка |
1902
+ | `.Класс.unset()` | снять optional-конец (0.16.0: переименован из `.Класс.delete()` — старое имя бросает подсказку) |
1768
1903
 
1769
- **Назначение и алгоритм.** Значение связи для записи **БЕЗ участия в фильтре целей** —
1770
- в отличие от контекст-шага, который и фильтрует, и пишет. Кладётся в links новой версии
1771
- (поверх контекста). Только до операции; после — ошибка `step modifier after set()`.
1904
+ **Назначение и алгоритм.** Слот — свойство-класс БЕЗ вызова, идёт ПОСЛЕ глагола записи
1905
+ (`create`/`update`); слот без операции — ошибка `link slot needs a write`. Пишет конец в
1906
+ links **БЕЗ участия в фильтре целей** (в отличие от шага пути, который фильтрует). Валиден
1907
+ только для конца из `Schema.links` владельца; союз-конец `[A|B]` замещается целиком
1908
+ (соседний класс снимается); дубль одного слота — ошибка. `target`-цепочка
1909
+ исполняется в той же транзакции и обязана дать ровно одну сущность класса конца.
1772
1910
 
1773
1911
  **Примеры**
1774
1912
 
1775
1913
  ```ts
1776
- await db.навык().link('Сотрудник', м9).link('Услуга', у9).set({}).rows() // [4.1 ms] ОДИН INSERT
1914
+ await db.Сотрудник(м9).навык().create().Услуга.set(у9).rows() // [4.1 ms] ОДИН INSERT
1777
1915
  // → { id: '7b374ae4-…', class: 'skill', links: { Staff: '…0911', Service: '…0921' } }
1916
+ // навык — v5: id вычислен из (Staff, Service|Complex) — второй раз тот же навык не завести
1917
+
1918
+ // вложенная цепочка — создать И привязать в одной транзакции:
1919
+ await db.Запись(b).позиция().create({ qty: 1 }).Услуга.set(db.Услуга(у).update({ active: true })).rows()
1778
1920
  ```
1779
1921
 
1780
1922
  **Кейс: перевесить исполнителя на всех позициях записи**
1781
1923
 
1782
1924
  ```ts
1783
- await tr.Запись(b).позиция({}).link('Сотрудник', новый).set({}).rows()
1784
- // контекст-шаг Сотрудник(новый) тут НЕ годится: он фильтровал бы позиции по старой связи —
1785
- // а .link только записывает значение
1925
+ await tr.Запись(b).позиция().update().Сотрудник.set(новый).rows()
1926
+ // цель ищется путём (все позиции записи); слот пишет нового исполнителя БЕЗ фильтра по старому
1786
1927
  ```
1787
1928
 
1788
1929
  #### `.delete(opts?): Chain`
@@ -1797,14 +1938,14 @@ await tr.Запись(b).позиция({}).link('Сотрудник', новы
1797
1938
  под `enforceAcl` DELETE-решение проверяется на класс целей **и каждый класс замыкания**
1798
1939
  (deny откатывает план); `DELETE` по целям будит серверный триггер: advisory-lock →
1799
1940
  tombstone-версия → рекурсивное удаление зависимых. Возврат — замыкание с `$deleted: true`.
1800
- История остаётся; `set()` по тому же id — воскрешение. Продолжение цепочки — от строк
1941
+ История остаётся; `create()` с тем же id — воскрешение. Продолжение цепочки — от строк
1801
1942
  класса цели.
1802
1943
 
1803
1944
  **Примеры**
1804
1945
 
1805
1946
  ```ts
1806
1947
  await db.Запись('…0961').delete().rows() // [192.7 ms] ПРЕВЬЮ — кандидаты живы:
1807
- // → [{ id: '…0961', class: 'Booking' }, { id: 'busy-0961', class: 'busy' }, { id: '20790dc3-…', class: 'item' }]
1948
+ // → [{ id: '…0961', class: 'Booking' }, { class: 'busy', … }, { class: 'item', … }] — бронь + занятость + позиция
1808
1949
  // запись жива: true
1809
1950
 
1810
1951
  await db.Запись('…0961').delete({ confirm: true }).rows() // [209.2 ms] — сервер нашёл зависимых:
@@ -1819,8 +1960,8 @@ const последствия = await db.Запись(bid).delete().rows()
1819
1960
  if (операторПодтвердил) {
1820
1961
  await db.Запись(bid).delete({ confirm: true }).rows() // [209.2 ms] бронь + занятость + позиция
1821
1962
  }
1822
- // клиент передумал: воскрешение тем же id (зависимые пересоздать явно)
1823
- await db.Клиент('…0931').Запись('…0961').set({ status: 'created', total: { RUB: 500 } }).rows() // [11.3 ms]
1963
+ // клиент передумал: воскрешение тем же id — create (зависимые пересоздать явно)
1964
+ await db.Клиент('…0931').Запись('…0961').create({ status: 'created', total: { RUB: 500 } }).rows() // [11.3 ms]
1824
1965
  ```
1825
1966
 
1826
1967
  #### `.anonymize(fields): Chain`
@@ -1867,10 +2008,10 @@ await db.Клиент().tags('anonymized').count() // в
1867
2008
 
1868
2009
  ```ts
1869
2010
  const tr = await db.begin() // [0.8 ms]
1870
- await tr.Услуга('…0922').set({ price: { RUB: 9900 } }).rows()
1871
- await tr.Услуга('…0922').first() // внутри → 9900
2011
+ await tr.Услуга({ name: 'Укладка' }).update({ price: { RUB: 9900 } }).rows()
2012
+ await tr.Услуга({ name: 'Укладка' }).first() // внутри → 9900
1872
2013
  await tr.rollback() // [0.8 ms]
1873
- await db.Услуга('…0922').first() // снаружи → 700, изменения нет
2014
+ await db.Услуга({ name: 'Укладка' }).first() // снаружи → 700, изменения нет
1874
2015
  ```
1875
2016
 
1876
2017
  **Кейс: commit** — § 11.2 `db.begin()`; ниже — главный сценарий `lock`.
@@ -1899,20 +2040,24 @@ await db.lock('x')
1899
2040
  **Кейс: гонка двойной брони — реальный прогон двух транзакций**
1900
2041
 
1901
2042
  ```ts
1902
- // два администратора жмут «забронировать» на одно окно одновременно
2043
+ // два администратора жмут «забронировать» на одно окно одновременно;
2044
+ // id занятости детерминирован (v5, § 3.2) — известен ДО создания:
2045
+ const busyId = uuidv5(`v1.article:entity:busy:${окно.id}:${мастер.id}`)
1903
2046
  const trA = await db.begin(), trB = await db.begin()
1904
- await trA.lock('busy', '…0911', '…0951') // [1.4 ms] A первый
2047
+ await trA.lock('busy', мастер.id, окно.id) // [1.4 ms] A первый
1905
2048
  const гонкаB = (async () => {
1906
- await trB.lock('busy', '…0911', '…0951') // B ВИСИТ до конца trA
2049
+ await trB.lock('busy', мастер.id, окно.id) // B ВИСИТ до конца trA
1907
2050
  const занято = await trB.занятость(busyId).first() // перечитка под локом
1908
2051
  if (занято) { await trB.rollback(); return 'ОТКАЗ: окно уже занято' }
1909
- await trB.Сотрудник('…0911').Окно('…0951').занятость(busyId).set({ kind: 'booking' }).rows()
2052
+ await trB.Сотрудник(мастер).занятость().create({ kind: 'booking' }).Окно.set(окно).rows()
1910
2053
  await trB.commit(); return 'бронь моя'
1911
2054
  })()
1912
2055
  await trA.занятость(busyId).first() // → null — свободно
1913
- await trA.Сотрудник('…0911').Окно('…0951').занятость(busyId).set({ kind: 'booking' }).rows()
2056
+ await trA.Сотрудник(мастер).занятость().create({ kind: 'booking' }).Окно.set(окно).rows()
1914
2057
  await trA.commit()
1915
2058
  await гонкаB // → 'ОТКАЗ: окно уже занято' — B увидел бронь A, дубля нет
2059
+ // дубль невозможен и без лока (оба create вычислят ОДИН id — второй стал бы версией);
2060
+ // лок нужен, чтобы B получил честный отказ, а не молча версионировал чужую бронь
1916
2061
 
1917
2062
  // а если два лока взять в разном порядке — деадлок, жертва получает:
1918
2063
  // Error: deadlock detected — letopis: transaction is aborted, retry the whole db.begin() block
@@ -1940,26 +2085,28 @@ const b = db.batch('слоты-августа') // [16 µs]
1940
2085
 
1941
2086
  **Назначение и алгоритм.** Вся очередь — **одна транзакция** (бывший `execute()`);
1942
2087
  результаты по порядку планов (`Row[]` на план; у многосегментного — результат последнего
1943
- сегмента). Подряд идущие «чистые INSERT» одного класса (один шаг, без фильтра, без data.id
1944
- и `.link`) склеиваются в **один multi-VALUES INSERT**; под `enforceAcl` WRITE-проверка и
1945
- пришпиливание — на каждый элемент склейки. Transient-ошибка ретраит всю транзакцию целиком.
1946
- Очередь очищается.
2088
+ сегмента). Подряд идущие чистые `create()` одного класса (план из одного шага, без `data.id`
2089
+ и слотов; id класса не v5 — § 3.2) склеиваются в **один multi-VALUES INSERT**; под
2090
+ `enforceAcl` WRITE-проверка и пришпиливание — на каждый элемент склейки. Transient-ошибка
2091
+ ретраит всю транзакцию целиком. Очередь очищается.
1947
2092
 
1948
2093
  **Примеры**
1949
2094
 
1950
2095
  ```ts
1951
2096
  const b = db.batch('слоты-августа')
1952
- b.Окно().set({ id: '…0952', start: '2026-08-01T11:00:00Z', end: '…12:00Z' })
1953
- b.Окно().set({ id: '…0953', start: '2026-08-01T12:00:00Z', end: '…13:00Z' })
1954
- b.Окно().set({ id: '…0954', start: '2026-08-01T13:00:00Z', end: '…14:00Z' })
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' })
1955
2100
  b.size() // [29 µs] → 3
1956
- await b.run() // [32.1 ms] — 3 INSERT склеены в один multi-VALUES
1957
- // → [[{ id: '…0952', start: '2026-08-01T11:00:00.000Z' }], [{ id: '…0953', … }], [{ id: '…0954', … }]]
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
1958
2104
  b.size() // → 0
1959
2105
  ```
1960
2106
 
1961
- **Кейс: генерация расписания на день** — 3 окна одной транзакцией и одним INSERT (выше);
1962
- упавшая валидация любого окна откатывает все. Терминал на плане в батче:
2107
+ **Кейс: генерация расписания на день** — 3 окна одной транзакцией (выше); упавшая
2108
+ валидация любого окна откатывает все; v7-классы без `data.id` и слотов склеились бы в
2109
+ один multi-VALUES INSERT. Терминал на плане в батче:
1963
2110
  `план.rows()` → `Error: letopis: plan is queued in the batch — call batch.run()`.
1964
2111
 
1965
2112
  #### `batch.discard(): void` / `batch.size(): number`
@@ -1968,14 +2115,14 @@ b.size() // → 0
1968
2115
 
1969
2116
  ```ts
1970
2117
  const b2 = db.batch('отмена')
1971
- b2.Окно().set({ id: '…0955', start: '…', end: '…' })
2118
+ b2.Расписание(sch).Окно().create({ start: '2026-08-02T11:00:00Z', end: '…12:00Z' })
1972
2119
  b2.discard() // [53 µs]
1973
2120
  b2.size() // → 0
1974
- await db.Окно('…0955').first() // → null — ничего не исполнилось
2121
+ await db.Расписание(sch).Окно({ start: '2026-08-02T11:00:00Z' }).first() // → null — не исполнилось
1975
2122
  ```
1976
2123
 
1977
2124
  **Кейс: черновик импорта** — копим операции по мере парсинга файла; ошибка парсера →
1978
- `discard()`, полный успех → `execute()`.
2125
+ `discard()`, полный успех → `run()`.
1979
2126
 
1980
2127
  ### 11.8 Таблицы: accounts / credentials / resources / rules
1981
2128
 
@@ -2621,11 +2768,11 @@ await uc.Запись().count() // [20.3 ms] → 3 — честный count п
2621
2768
  await uc.Услуга().count() // [15.9 ms] → 404 — безусловный allow, класс целиком
2622
2769
  await uc.Организация().rows()
2623
2770
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
2624
- const [z] = await uc.Запись().set({ status: 'created', total: { RUB: 300 } }).rows() // [11.7 ms]
2771
+ const [z] = await uc.Запись().create({ status: 'created', total: { RUB: 300 } }).rows() // [11.7 ms]
2625
2772
  z.owner === acc.id // → true — owner пришпилен правилом
2626
- await uc.Запись().owner(SYS).set({ … }).rows()
2773
+ await uc.Запись().owner(SYS).create({ … }).rows()
2627
2774
  // Error: letopis: acl pins Booking writes to owner … — на терминале [1.8 ms]
2628
- await uc.Запись(чужаяId).set({ status: 'hacked' }).rows() // [10.9 ms] → [] — чужая жива и НЕ перехвачена
2775
+ await uc.Запись(чужаяId).update({ status: 'hacked' }).rows() // [10.9 ms] → [] — чужая жива и НЕ перехвачена
2629
2776
  await uc.Запись('…0965').delete({ confirm: true }).rows() // [27.7 ms] → [{ id: '…0965', $deleted: true }]
2630
2777
  // watch: события только безусловных allow-классов —
2631
2778
  // uc.watch(cb) поймал ['Service']; Booking скрыт (предикат не проверить по payload)
@@ -2690,13 +2837,13 @@ db.registry.all.length // → 15
2690
2837
  | `message` | `string` | `letopis: validation failed for "<Класс>": …` |
2691
2838
  | `issues` | `{ field, type, message, … }[]` | отчёт fastest-validator по каждому полю |
2692
2839
 
2693
- **Назначение.** Бросается из ТЕРМИНАЛА плана (`.set()`/`.anonymize()`/батчи), когда `data`
2694
- не проходит строгую схему класса (недостающее обязательное, лишний ключ, неверный тип) —
2695
- весь план откатывается. Другие ошибки записи (abstract-класс, недостающий конец LINK) —
2696
- обычный `Error` там же.
2840
+ **Назначение.** Бросается из ТЕРМИНАЛА плана (`.create()`/`.update()`/`.anonymize()`/батчи),
2841
+ когда `data` не проходит строгую схему класса (недостающее обязательное, лишний ключ,
2842
+ неверный тип) — весь план откатывается. Другие ошибки записи (abstract-класс, недостающий
2843
+ конец LINK) — обычный `Error` там же.
2697
2844
 
2698
2845
  ```ts
2699
- try { await db.Услуга().set({ name: 'X', чепуха: 1 }).rows() } // [1.4 ms]
2846
+ try { await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [1.4 ms]
2700
2847
  catch (e) {
2701
2848
  e instanceof ValidationError // → true
2702
2849
  e.issues
@@ -2744,13 +2891,24 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
2744
2891
  | `unknown class "X". Known: …` | класс вне Schema (со списком) |
2745
2892
  | `ValidationError` (`.issues`) | строгая валидация: мусор или лишние поля |
2746
2893
  | `class "X" is abstract` | запись в abstract |
2747
- | `link "X" requires end "Y"` / `polymorphic end(s)` | не хватает концов LINK |
2748
- | `context step "X" must resolve to exactly one entity` | контекст-шаг дал 0 или >1 |
2749
- | `context step "X" needs an id or a unique filter` | контекст-шаг без фильтра |
2750
- | `step modifier after set()` | модификатор шага (alias/tags/link/…) сразу после операции |
2894
+ | `link "X" requires end "Y|Z"` / `has stray link(s)` | не хватает обязательного конца LINK / связь вне объявленных концов (v2); legacy: `polymorphic end(s)` |
2895
+ | `context step "X" must resolve to exactly one entity` | шаг-владелец пути дал 0 или >1 |
2896
+ | `create() takes no filter — searching is update()` | фильтр-объект перед `create()` (синхронно, при построении) |
2897
+ | `create() on a pivot step` | pivot возвращает к существующему узлу — это `update()` |
2898
+ | `class "X" computes id (uuid v5 from …)` | явный id у v5-класса (§ 3.2) — id всегда считает схема |
2899
+ | `id (uuid v5) of "X" needs end "A\|B"` / `needs scalar data field "f"` | v5-классу не хватает конца (путь/слот) или скалярного поля из `from` |
2900
+ | `step modifier after create()/update()` | модификатор шага (alias/tags/…) сразу после операции |
2901
+ | `"X" is not a link end of "Y"` | слот `.X.set()` — не конец Y по Schema.links |
2902
+ | `slot "X" needs an owner step` | слот на корне (`db.X.set()`) без шага-владельца |
2903
+ | `link slot "X" needs a write` | слот без глагола записи — добавить `.create(…)`/`.update(…)` перед ним |
2904
+ | `link end "X" of "Y" is required — cannot unset` | `.unset()` обязательного конца |
2905
+ | `duplicate link slot "X"` | один конец задан слотом дважды в одной записи |
2906
+ | `set() split into create()/update() (0.16.0)` | старый глагол записи — create() вставляет, update() версионирует найденное |
2907
+ | `slot .delete() renamed to .unset() (0.16.0)` | старое имя слот-снятия |
2908
+ | `.link() removed (0.15.0)` | снесённый `.link()` — теперь слот `.Класс.set()` |
2751
2909
  | `execute() renamed to run() (0.11.0)` | старое имя терминала путей (и `batch.execute()`) |
2752
2910
  | `plan is queued in the batch — call batch.run()` | терминал на батч-цепочке с операциями |
2753
- | `no path X → Y` / `LINK → LINK …` | недопустимый переход чтения |
2911
+ | `no path X → Y` / `LINK → LINK …` | недопустимый переход (синхронно при построении цепочки) |
2754
2912
  | `Entity.account is NOT NULL…` | нет account и System-аккаунта |
2755
2913
  | `violates foreign key constraint "entity_*_fk"` | несуществующий класс/аккаунт; удаление класса с данными |
2756
2914
  | `lock() works only inside db.begin()` | лок вне транзакции |
@@ -2778,7 +2936,7 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
2778
2936
  | `rows()` весь класс, sort+limit 100 | 63 ms | 74 ms |
2779
2937
  | цепочка 3 хопа (пути) | 15 ms | 23 ms |
2780
2938
  | `count()` путей | 15 ms | 20 ms |
2781
- | `set()` новой версии | 8 ms | 12 ms |
2939
+ | запись новой версии (`create`/`update`) | 8 ms | 12 ms |
2782
2940
 
2783
2941
  TOAST-порог (data > 2KB): 0 строк. Слабое место — выборка «весь класс с сортировкой»
2784
2942
  (DISTINCT ON всех сущностей класса); лечится селективным фильтром или курсором.
@@ -2792,7 +2950,7 @@ API Reference (§ 11) — `npx tsx bench/api-reference-demo.mjs` (живой art
2792
2950
  - Начинайте цепочку с самого селективного шага.
2793
2951
  - `count()` — пути; количество сущностей дешевле `ids().length`.
2794
2952
  - Каскадное удаление — серверное: один DELETE на всё дерево.
2795
- - Один сегмент плана пишет одним INSERT на цель независимо от числа связей (`.link()`/контекст).
2953
+ - Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты).
2796
2954
  - EXPLAIN-паттерн — тест `EXPLAIN` (индексы обязаны быть в плане; ноль seq scan).
2797
2955
 
2798
2956
  ---
@@ -2803,30 +2961,31 @@ API Reference (§ 11) — `npx tsx bench/api-reference-demo.mjs` (живой art
2803
2961
 
2804
2962
  ```ts
2805
2963
  // штат и каталог — связи контекст-шагами; терминал .rows() исполняет план
2806
- const [org] = await db.Организация().set({ name: 'BarberPro' }).rows()
2807
- const [ivan] = await db.Организация(org).Сотрудник().set({ name: 'Иван', roles: ['owner','master'] }).rows()
2808
- const [oleg] = await db.Организация(org).Сотрудник().set({ name: 'Олег', roles: ['master'] }).rows()
2809
- const [стрижка] = await db.Организация(org).Услуга().set({ name: 'Стрижка', duration: 60, price: { RUB: 1500 } }).rows()
2810
- const [комплекс] = await db.Организация(org).Комплекс().set({ name: 'Стрижка+борода', duration: 90, price: { RUB: 2500 } }).rows()
2811
- await db.Комплекс(комплекс).Услуга(стрижка).позиция().set({ qty: 1 }).rows() // состав
2812
- await db.Сотрудник(ivan).Комплекс(комплекс).навык().set({}).rows() // умение
2964
+ const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
2965
+ const [ivan] = await db.Организация(org).Сотрудник().create({ name: 'Иван', roles: ['owner','master'] }).rows()
2966
+ const [oleg] = await db.Организация(org).Сотрудник().create({ name: 'Олег', roles: ['master'] }).rows()
2967
+ const [стрижка] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60, price: { RUB: 1500 } }).rows()
2968
+ const [комплекс] = await db.Организация(org).Комплекс().create({ name: 'Стрижка+борода', duration: 90, price: { RUB: 2500 } }).rows()
2969
+ // id услуги/комплекса вычислила схема: uuidv5(Org, name) — дубль имени в салоне невозможен (§ 3.2)
2970
+ await db.Комплекс(комплекс).состав().create({ qty: 1 }).Услуга.set(стрижка).rows() // состав комплекса
2971
+ await db.Сотрудник(ivan).навык().create().Комплекс.set(комплекс).rows() // умение
2813
2972
 
2814
2973
  // календарь: период → окна батчем → ростер → исключение
2815
- const [sch] = await db.Организация(org).Расписание().set({ start: '2026-07-10T10:00:00+03:00', end: '…13:00' }).rows()
2816
- db.batch('о').Расписание(sch).Окно().set({ start: '…10:00', end: '…11:00' }) // ×3 — планы в очередь
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 — планы в очередь
2817
2976
  const [[w1],[w2],[w3]] = await db.batch('о').run()
2818
- await db.Расписание(sch).Сотрудник(ivan).смена().set({}).rows()
2819
- await db.Сотрудник(oleg).Окно(w3).занятость().set({ id: `off-${oleg.id}-${w3.id}`, kind: 'off' }).rows()
2977
+ await db.Расписание(sch).смена().create().Сотрудник.set(ivan).rows()
2978
+ await db.Сотрудник(oleg).занятость().create({ kind: 'off' }).Окно.set(w3).rows() // id = uuidv5(w3, oleg)
2820
2979
 
2821
- // бронь мульти-слот (90м > 60м → два окна): контекст-форма и .link-форма
2822
- const [bkg] = await db.Клиент(пётр).Запись().set({ status: 'created', total: { RUB: 2500 } }).rows()
2823
- await db.Запись(bkg).Комплекс(комплекс).Сотрудник(ivan).позиция().set({ qty: 1, price: { RUB: 2500 } }).rows()
2824
- await db.занятость().link('Сотрудник', ivan).link('Окно', w2).link('Запись', bkg)
2825
- .set({ id: `busy-${ivan.id}-${w2.id}`, kind: 'booking' }).rows()
2826
- await db.Сотрудник(ivan).Окно(w3).Запись(bkg).занятость(`busy-${ivan.id}-${w3.id}`).set({ kind: 'booking' }).rows()
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(окно, мастер): двойная бронь мертва на уровне схемы
2827
2986
 
2828
2987
  // жизненный цикл, чтения, отмена
2829
- await db.Запись(bkg.id).set({ status: 'confirmed' }).rows()
2988
+ await db.Запись(bkg.id).update({ status: 'confirmed' }).rows()
2830
2989
  await db.Запись(bkg.id).занятость().Окно().sort('data.start').rows() // окна брони
2831
2990
  await db.Окно(w2.id).занятость({ kind: 'booking' }).Сотрудник().rows() // кто занят
2832
2991
  await db.Запись(bkg.id).delete().rows() // ПРЕВЬЮ: что удалится
@@ -2839,24 +2998,28 @@ const удалено = await db.Запись(bkg.id).delete({ confirm: true }).r
2839
2998
  ## 16. Тесты
2840
2999
 
2841
3000
  ```bash
2842
- npm test # 132 теста против живого docker (timescale + redis), по файлам:
3001
+ npm test # 165/165 тестов против живого docker (timescale + redis), по файлам:
2843
3002
  # acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
2844
3003
  # с $account, enforceAcl (предикаты в SQL, каскад, watch)
2845
3004
  # api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
2846
3005
  # auth — db.auth: пароль/api-key/key-secret/TOTP/OTP/link+lookup,
2847
3006
  # глобальная идентичность, сессии на живом Redis (expire/revoke)
2848
- # plan — план-модель: несколько операций, fan-out, self-set,
2849
- # превью/confirm delete, ОТКАТ плана, .link(), батч-гард
3007
+ # plan — план-модель: несколько операций, fan-out, self-update,
3008
+ # превью/confirm delete, ОТКАТ плана, слот-перевес, батч-гард
2850
3009
  # resilience — ретраи 40P01/40001, реальный deadlock двух транзакций,
2851
3010
  # watch переживает обрыв LISTEN (pg_terminate_backend)
2852
3011
  # core — триггеры (check/версии/каскад/lineage), обходы, операторы,
2853
- # модификаторы, set-формы, delete, гонка, батчи, EXPLAIN
3012
+ # модификаторы, create/update-формы, delete, гонка, батчи, EXPLAIN
2854
3013
  # depth — наследование 3+ уровней, data-пути любой глубины
3014
+ # idgen — генерация id по Schema (§ 3.2): v4/v7/v5 из концов и полей,
3015
+ # гонка без локов, наследование attributes и правила id, гарды
2855
3016
  # integration — E2E-барбершоп (8 сцен)
2856
3017
  # real-life — 16 сцен «дня салона»: 4 руки, гонки ×3, переносы, no-show
2857
3018
  # tables — auth/ACL-таблицы
2858
3019
  # up — up(): docker-argv, probe-ветка, идемпотентность, fresh,
2859
3020
  # свои сиды/seeds:false, автосоздание базы, валидация
3021
+ # salon — ТЕСТ-ПЛАН: имитация салона, ВСЕ 145 публичных API
3022
+ # (21 акт + матрица покрытия); слоты/pivot/entity
2860
3023
  # wave2 — asOf/versions, keyset-курсор, gen-types, enforceAccount, anonymize
2861
3024
  # wave3 — or/not, агрегации, deep, watch
2862
3025
  # wave4 — compression-политики (чтение сжатого чанка), schema-sync, onQuery