letopis 0.20.0 → 0.20.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
@@ -987,41 +987,41 @@ const acc = await db.accounts.set({ categories: ['User'], data: { name: 'Вас
987
987
 
988
988
  // ПАРОЛЬ (почта/пароль и логин/пароль — одна механика, различает category)
989
989
  await db.auth.setPassword({ account: acc, identifier: 'vasya@salon.io', password: 'корень-огня-77' })
990
- // → Credential; в БД НЕ пароль, а слоёный хэш: [64.8 ms]
990
+ // → Credential; в БД НЕ пароль, а слоёный хэш: [105 ms]
991
991
  // meta.password = "scrypt$32768$8$1$EmyOQlletdEahwgoXendhw==$vGQB9LrwcDSIB0+…"
992
992
  await db.auth.verifyPassword({ identifier: 'vasya@salon.io', password: 'корень-огня-77' })
993
993
  // → { account: {id: '7277…', data: {name: 'Вася'}, enabled: true, …},
994
994
  // credential: {category: 'PASSWORD', identifier: 'vasya@salon.io', …} } [71.8 ms]
995
- await db.auth.verifyPassword({ identifier: 'vasya@salon.io', password: 'хм' }) // → null [82.8 ms]
995
+ await db.auth.verifyPassword({ identifier: 'vasya@salon.io', password: 'хм' }) // → null [109 ms]
996
996
  // цена задана scrypt-ом (~70 ms) и выровнена: «нет такого identifier» не быстрее «пароль неверен»
997
997
 
998
998
  // API-КЛЮЧ: показывается ОДИН раз, в БД — только sha256
999
999
  const { key } = await db.auth.issueApiKey({ account: acc, name: 'CI' })
1000
- // key = "lts_a61118c4af31868640529131927eb578211730bcd092856c" [6.3 ms]
1000
+ // key = "lts_a61118c4af31868640529131927eb578211730bcd092856c" [9.0 ms]
1001
1001
  // в БД: identifier = "ae9002e2cd4c…" (sha256), meta = {name: 'CI', prefix: 'lts_a61118c4'}
1002
- await db.auth.verifyApiKey(key) // → { account, credential } [5.6 ms]
1002
+ await db.auth.verifyApiKey(key) // → { account, credential } [6.9 ms]
1003
1003
 
1004
1004
  // КЛЮЧ-СЕКРЕТ: key — открытый id пары, secret — только sha256
1005
1005
  const { key: k, secret } = await db.auth.issueKeySecret({ account: acc, name: 'integration' })
1006
1006
  // k = "96d327b741421f74", secret = "38f69922c9748aa8…" (48 hex) [4.7 ms]
1007
- await db.auth.verifyKeySecret(k, secret) // → { account, credential } [3.4 ms]
1007
+ await db.auth.verifyKeySecret(k, secret) // → { account, credential } [4.4 ms]
1008
1008
 
1009
1009
  // ВНЕШНЯЯ IDENTITY (oauth/sso/telegram): токен проверяет приложение, тут — связка
1010
1010
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915', meta: { username: 'roboteza' } })
1011
1011
  await db.auth.lookup({ category: 'TELEGRAM', identifier: '1635246915' })
1012
- // → { account, credential } [2.6 ms]
1012
+ // → { account, credential } [5.3 ms]
1013
1013
 
1014
1014
  // TOTP (authenticator, RFC 6238): секрет в QR, активен после первой проверки
1015
1015
  const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: 'anna@salon.io' })
1016
1016
  // secret = "RWVB3WWQ62LEED55AMU6L425C6KIT5EJ" (base32, 20 байт) [5.6 ms]
1017
1017
  // uri = "otpauth://totp/anna%40salon.io?secret=…&issuer=clockz&algorithm=SHA1&digits=6&period=30"
1018
- await db.auth.verifyTotp({ account: acc, code: '139999' }) // → true [4.9 ms]
1018
+ await db.auth.verifyTotp({ account: acc, code: '139999' }) // → true [7.2 ms]
1019
1019
  await db.auth.verifyTotp({ account: acc, code: '139999' }) // → false — replay того же шага отбит
1020
1020
  await db.auth.totpEnabled(acc) // → true (после первой проверки)
1021
1021
 
1022
1022
  // ОДНОРАЗОВЫЙ КОД (email/SMS/reset — доставка на приложении)
1023
1023
  const { code } = await db.auth.issueOtp({ account: acc, identifier: 'anna@salon.io', ttlSec: 600 })
1024
- // code = "273746"; в БД sha256 + expires + attempts [3.3 ms]
1024
+ // code = "273746"; в БД sha256 + expires + attempts [4.8 ms]
1025
1025
  await db.auth.verifyOtp({ identifier: 'anna@salon.io', code })
1026
1026
  // → { account, credential }; код СОЖЖЁН (одноразовость) [6.0 ms]; повторно → null
1027
1027
  // 5 неверных попыток тоже сжигают; повторный issue перезаписывает код (валиден последний)
@@ -1090,9 +1090,10 @@ db.acl.reload() // сброс кэша Resou
1090
1090
  **`connect({ enforceAcl: true })` + `db.as(субъект)`** — те же решения в цепочках: каждый шаг —
1091
1091
  `READ`, `create()`/`update()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
1092
1092
  (deny в замыкании откатывает транзакцию); `watch()` отдаёт события только безусловных
1093
- allow-классов (payload нечем проверить предикат). Субъекта даёт `db.as()`: правила снимаются
1094
- и компилятся в memo-резолвер **на каждый scope** (у своего аккаунта — свой резолвер, ключ memo
1095
- субъекта не содержит); правки `Resource`/`Rule` подхватывает `db.reloadSchema()` (§ 11.10).
1093
+ allow-классов (payload нечем проверить предикат). Субъекта даёт `db.as()`: у каждого scope
1094
+ **свой** memo-резолвер (ключ memo субъекта не содержит, поэтому один энфорсер = один субъект),
1095
+ а сам словарь `Resource`/`Rule` — общий снимок на подключение; правки словаря подхватывает
1096
+ `db.reloadSchema()` (§ 11.10).
1096
1097
 
1097
1098
  ```ts
1098
1099
  const u = await (await connect({ dsn, schema, enforceAcl: true })).as(user.id)
@@ -1123,9 +1124,10 @@ await u.запись(чужаяId).update({ notes: '…' }).rows() // → []
1123
1124
  не отсекает); в реальности он СУЖАЕТ выборку, и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
1124
1125
  `db.acl.checkData` — справочный (1.7 ms: SQL за категориями аккаунта на каждый вызов);
1125
1126
  горячий путь цепочек использует резолвер, скомпилированный при `db.as()` (микросекунды, memo).
1126
- Цена самого `db.as()` под `enforceAcl` — один параллельный запрос за аккаунт + Resource/Rule
1127
- (≈ те же 50 ms, что раньше платил `connect`), поэтому scope стоит держать на запрос, а не на
1128
- цепочку. Без `enforceAcl` `db.as()` бесплатен: клон контекста, без SQL и без нового соединения.
1127
+ Цена самого `db.as()` под `enforceAcl` — **один** SELECT за свой аккаунт: словарь
1128
+ `Resource`/`Rule` снимается один раз на подключение и переиспользуется всеми scope (сбрасывает
1129
+ `db.reloadSchema()`), поэтому scope-per-request не платит за словарь на каждый HTTP-запрос.
1130
+ Без `enforceAcl` `db.as()` бесплатен вовсе: клон контекста, без SQL и без нового соединения.
1129
1131
 
1130
1132
  `enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
1131
1133
 
@@ -1274,21 +1276,23 @@ await db.запись().rows() // ошибка: enforceAccount i
1274
1276
 
1275
1277
  **Цена изоляции — отрицательная в реальной многоарендаторной базе.** Предикат `account`
1276
1278
  попадает не только в перепроверку, но и в подзапрос кандидатов, то есть **сужает выборку по
1277
- индексу до основного скана**. Замерено на полигоне из 10 арендаторов (1 млн версий, класс
1278
- `Customer`, `count()` — с сужением против без него):
1279
+ индексу до основного скана**: изоляция не добавляет фильтр к полному проходу по классу, а
1280
+ заменяет его. Замерено на полигоне из 10 арендаторов (1 млн версий класса `Customer`, `count()`,
1281
+ p50 из 5; воспроизводится `node bench/tenants-seed.mjs && npx tsx bench/isolation.bench.mjs`):
1279
1282
 
1280
- | Доля арендатора в классе | с сужением | без сужения |
1281
- |---|---|---|
1282
- | 50 % | 936 ms | 800 ms |
1283
- | 10 % | 314 ms | 785 ms |
1284
- | 1 % | 87 ms | 800 ms |
1285
- | 0.3 % | 28 ms | 773 ms |
1286
-
1287
- Чем мельче арендатор, тем больше выигрыш: на 0.3 % класса изоляция ускоряет чтение в **27 раз**.
1288
- Проигрывает она только в вырожденном случае, когда один арендатор владеет почти всем классом
1289
- (на `v1.salondemo` System владеет 100 %, и там `count()` под изоляцией — 2.6 с против 1.3 с).
1283
+ | Доля арендатора в классе | `db.as(арендатор)` | явный `.account()` | против всего класса (835 ms) |
1284
+ |---|---|---|---|
1285
+ | 50 % (250 000 сущн.) | 954 ms | 945 ms | равно |
1286
+ | 10 % (50 000) | 316 ms | 314 ms | быстрее ×2.6 |
1287
+ | 1 % (5 000) | 96 ms | 95 ms | быстрее ×8.7 |
1288
+ | 0.3 % (1 500) | 32 ms | 27 ms | **быстрее ×26.5** |
1289
+
1290
+ Чем мельче арендатор, тем дешевле чтение. Колонки `db.as()` и `.account()` совпадают: SQL у них
1291
+ один — изоляция не берёт ничего сверх явного фильтра, она лишь ставит его на каждый шаг сама.
1292
+ Расплата только в вырожденном случае, когда один арендатор владеет почти всем классом: на
1293
+ `v1.salondemo` System владеет 100 %, и там `count()` под изоляцией — 2.6 с против 1.3 с.
1290
1294
  Вывод практический: чем больше у вас арендаторов, тем выгоднее держать изоляцию включённой.
1291
- Оба замера сняты на проанализированной базе — без `ANALYZE` эта же сцена давала 54 с (§ 14.1).
1295
+ Все замеры — на проанализированной базе; без `ANALYZE` эта же сцена давала 54 с (§ 14.1).
1292
1296
 
1293
1297
  Административный доступ (миграции, дев-скрипты, отчёты по всем арендаторам) — явным
1294
1298
  `connect({ …, enforceAccount: false })`: там `db` читает и пишет без scope, а колонку
@@ -1325,6 +1329,49 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
1325
1329
  - Интервалы: `30d`, `2y`, `12h`, `6mon` или сырой PG (`'90 days'`). Повторный запуск
1326
1330
  переустанавливает политику.
1327
1331
 
1332
+ ### 10.8a Обновление движка: ревизия DDL и `upgrade`
1333
+
1334
+ Классы живут в таблице `Schema` и правятся на ходу (§ 10.9), а **движок** — это `ddl.sql`:
1335
+ таблицы, индексы, триггеры и серверные функции. Он тоже растёт: в 0.19.0, например, появились
1336
+ `purge`/`purge_closure`/`purge_account`. Схема, накатанная более старой версией либы, этих
1337
+ функций **не получает** — приложение обновляет пакет и падает сырым
1338
+
1339
+ ```
1340
+ PostgresError: function "v1.booking".purge(unknown, unknown, unknown, boolean) does not exist
1341
+ ```
1342
+
1343
+ Поэтому последняя строка `ddl.sql` штампует в схему метку ревизии
1344
+ (`COMMENT ON SCHEMA … IS 'letopis ddl_revision=N'`), а `connect()` сверяет её с константой
1345
+ `DDL_REVISION` либы и **один раз на схему за процесс** предупреждает:
1346
+
1347
+ ```
1348
+ letopis: движок схемы "v1.booking" отстал — ревизия не помечена (схема накатана либой до
1349
+ появления метки). Схема продолжит работать, но новых функций/триггеров в ней нет
1350
+ (например purge/purge_account из 0.19.0) … up({ …, upgrade: true }) либо
1351
+ node db/apply.mjs --dsn=… --schema=<имя> --version=<N> --upgrade
1352
+ ```
1353
+
1354
+ Предупреждение, а не отказ: правки аддитивны — то, что работало, продолжает работать.
1355
+
1356
+ **Апгрейд** перекатывает **только** `ddl.sql`, сиды не трогает:
1357
+
1358
+ ```ts
1359
+ await up({ dsn, schema: 'booking', version: 1, upgrade: true }) // данные целы
1360
+ ```
1361
+ ```bash
1362
+ node db/apply.mjs --dsn=… --schema=booking --version=1 --upgrade
1363
+ ```
1364
+
1365
+ Это безопасно, потому что `ddl.sql` идемпотентен: функции — `CREATE OR REPLACE`, триггеры —
1366
+ `DROP IF EXISTS` + `CREATE`, таблицы и индексы — `IF NOT EXISTS`. Данные append-only не
1367
+ перезаписываются.
1368
+
1369
+ **Ревизия ≠ версия схемы.** Ревизия — про аддитивные правки внутри одной версии движка, они
1370
+ доезжают апгрейдом. Несовместимая правка структуры таблиц — это смена `version` в имени схемы
1371
+ (`v1.booking` → `v2.booking`), то есть новая схема и перенос данных силами приложения.
1372
+ Совпадение маркера в `ddl.sql`, штампа `COMMENT` и константы `DDL_REVISION` проверяет
1373
+ `npm run check:docs`.
1374
+
1328
1375
  ### 10.9 Миграции классов: `scripts/schema-sync.mjs`
1329
1376
 
1330
1377
  > Входит в npm-пакет (`files: ["scripts"]`); в установке путь — `node_modules/letopis/scripts/schema-sync.mjs`. Зависимости — `postgres` и `fastest-validator` (обе — deps пакета), `tsx` не нужен.
@@ -1414,6 +1461,13 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
1414
1461
  id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; повторяющиеся
1415
1462
  `account`/`owner`/`partition` в ответах опущены.
1416
1463
 
1464
+ > **Как читать тайминги.** Значения перенесены из прогона демо **точным сопоставлением по
1465
+ > коду вызова**, полигон при этом проанализирован (`ANALYZE`, § 14.1 — без него те же запросы
1466
+ > медленнее на порядок). Часть примеров иллюстративна и в демо в такой форме не исполняется
1467
+ > (плейсхолдеры вроде `connect({ dsn, schema })`, переименованные переменные, разбитые на
1468
+ > строки цепочки) — у них цифра осталась от более раннего прогона и может быть пессимистичной.
1469
+ > Сводные, всегда свежие цифры — таблицы § 9.2 (ACL), § 10.6 (изоляция) и § 14 (перф).
1470
+
1417
1471
  Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
1418
1472
  [11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
1419
1473
  [11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-create--update--delete--anonymize) ·
@@ -1468,7 +1522,7 @@ const db = await connect({ dsn, schema, enforceAccount: false })
1468
1522
  const iso = await (await connect({ dsn, schema })).as(acc.id) // [55.3 ms]
1469
1523
  const uc = await (await connect({ dsn, schema, enforceAcl: true })).as(acc.id) // [58.1 ms]
1470
1524
  await db.Организация().count() // → 30 — админский видит всех
1471
- await iso.Организация().count() // [5.1 ms] → 1 — только свой арендатор
1525
+ await iso.Организация().count() // [9.0 ms] → 1 — только свой арендатор
1472
1526
  await uc.Организация().rows()
1473
1527
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default)
1474
1528
  await iso.close(); await uc.close()
@@ -1624,8 +1678,9 @@ import type {
1624
1678
  **Назначение и алгоритм.** Возвращает хендл **того же пула**, работающий от имени `account`:
1625
1679
  арендатор — свойство вызова, не подключения (в `connect()` опции `account` нет). Клонирует
1626
1680
  контекст (как `db.begin()`), нового соединения не открывает; под `enforceAcl` дополнительно
1627
- читает аккаунт + Resource/Rule и компилирует резолвер **под этого субъекта** — у каждого scope
1628
- свой, решения между арендаторами не переиспользуются. Батчи со корня не наследуются: очередь
1681
+ читает **свой аккаунт** (один SELECT) и компилирует резолвер **под этого субъекта** — у каждого
1682
+ scope свой, решения между арендаторами не переиспользуются. Словарь `Resource`/`Rule` при этом
1683
+ общий: снимается один раз на подключение, сбрасывается `db.reloadSchema()`. Батчи с корня не наследуются: очередь
1629
1684
  планов, общая для разных арендаторов, была бы утечкой записи. `close()` закрывает пул, поэтому
1630
1685
  зовётся один раз — и с корня, и с любого scope (это одно и то же соединение).
1631
1686
 
@@ -1660,10 +1715,10 @@ await db.close() // один раз на пул
1660
1715
  **Примеры**
1661
1716
 
1662
1717
  ```ts
1663
- await db.Услуга('…0000').first() // [3.5 ms] по id → Row {name: 'Стрижка 0', …}
1718
+ await db.Услуга('…0000').first() // [4.8 ms] по id → Row {name: 'Стрижка 0', …}
1664
1719
  await db.Услуга(['…0000', '…0006']).rows() // [2.6 ms] по списку → ['Стрижка 0', 'Бритьё 6']
1665
- await db.Организация(org).first() // [2.2 ms] Row-объект ≡ его id → 'Салон «Стрижка» №0'
1666
- await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [36.0 ms] → 630 (фильтр по цене)
1720
+ await db.Организация(org).first() // [3.9 ms] Row-объект ≡ его id → 'Салон «Стрижка» №0'
1721
+ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [56 ms] → 630 (фильтр по цене)
1667
1722
  await db.НетТакогоКласса().rows()
1668
1723
  // Error: letopis: unknown class "НетТакогоКласса". Known: Entity·Сущность, Org·Организация, …
1669
1724
  ```
@@ -1671,7 +1726,7 @@ await db.НетТакогоКласса().rows()
1671
1726
  **Кейс: одна сущность тремя формами фильтра**
1672
1727
 
1673
1728
  ```ts
1674
- const поId = await db.Услуга('…0000').first() // [3.5 ms] → data.name = 'Стрижка 0'
1729
+ const поId = await db.Услуга('…0000').first() // [4.8 ms] → data.name = 'Стрижка 0'
1675
1730
  const поПолю = await db.Услуга({ name: 'Стрижка 0' }).first() // тот же Row
1676
1731
  const поОбъекту = await db.Услуга(поId).first() // Row как фильтр ≡ его id
1677
1732
  // все три → id '…0000', data.name = 'Стрижка 0' (цена — отдельным LINK «цена», § 11.9)
@@ -1692,10 +1747,10 @@ deadlock/serialization ошибка приходит сразу с подска
1692
1747
  **Примеры**
1693
1748
 
1694
1749
  ```ts
1695
- const tr = await db.begin() // [0.8 ms]
1750
+ const tr = await db.begin() // [1.0 ms]
1696
1751
  await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15 }).rows()
1697
- // [9.0 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
1698
- await tr.commit() // [3.7 ms] — теперь видно всем
1752
+ // [10 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
1753
+ await tr.commit() // [3.2 ms] — теперь видно всем
1699
1754
  ```
1700
1755
 
1701
1756
  **Кейс: атомарный перенос с откатом при провале** — § 11.6 (`tr.lock`), плюс rollback:
@@ -1721,7 +1776,7 @@ await db.цена(цУкл).first() // снаружи → amounts.RUB = 7
1721
1776
  **Примеры**
1722
1777
 
1723
1778
  ```ts
1724
- await db.commit(tr3) // [4.0 ms] — то же, что tr3.commit()
1779
+ await db.commit(tr3) // [3.4 ms] — то же, что tr3.commit()
1725
1780
  await db.commit()
1726
1781
  // Error: letopis: commit() needs a transaction: db.commit(tr) or tr.commit() [0.1 ms]
1727
1782
  ```
@@ -1777,7 +1832,7 @@ await stop()
1777
1832
  падают ошибкой postgres.js.
1778
1833
 
1779
1834
  ```ts
1780
- await db.close() // [1.3 ms]
1835
+ await db.close() // [2.0 ms]
1781
1836
  ```
1782
1837
 
1783
1838
  **Кейс** — завершение процесса: `close()` в `finally`/`SIGTERM`-хендлере после `stop()`
@@ -1799,7 +1854,7 @@ db.registry.resolve('запись') // [99 µs] → ClassDef {id: 'booking', a
1799
1854
 
1800
1855
  ```ts
1801
1856
  await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.salondemo"."Entity"')
1802
- // [37.3 ms] → [{ n: 980216 }]
1857
+ // [51 ms] → [{ n: 980216 }]
1803
1858
  ```
1804
1859
 
1805
1860
  **Кейс** — снятие плана тяжёлого запроса: `db.sql.unsafe('EXPLAIN (ANALYZE) …')` для
@@ -1831,8 +1886,8 @@ prev.links->>'Класс'`) кладётся точным равенством,
1831
1886
  **Примеры**
1832
1887
 
1833
1888
  ```ts
1834
- await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12 (обратный hop)
1835
- await db.навык().Услуга().count() // [65.2 ms] → 1260 (LINK → HUB, прямой)
1889
+ await db.Организация('…0000').Мастер().count() // [12 ms] → 12 (обратный hop)
1890
+ await db.навык().Услуга().count() // [93 ms] → 1260 (LINK → HUB, прямой)
1836
1891
  ```
1837
1892
 
1838
1893
  **Кейс: маршрут «мастер → его навыки → услуги»** — см. `.run()` ниже (тот же прогон).
@@ -1849,7 +1904,7 @@ await db.навык().Услуга().count() // [65.2 ms] → 126
1849
1904
  **Примеры**
1850
1905
 
1851
1906
  ```ts
1852
- await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
1907
+ await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [23 ms]
1853
1908
  // → 2 пути (у Ольги 0.0 два навыка на разные услуги); первый:
1854
1909
  // [{
1855
1910
  // Мастер: { id: '00000003-…-000', class: 'Staff', data: { name: 'Ольга 0.0', phone: '+7 921 0000000', specialization: 'парикмахер' }, links: { Org: '00000001-…-000' }, … },
@@ -1861,7 +1916,7 @@ await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().ru
1861
1916
  **Кейс: отчёт «кто что умеет» одним запросом**
1862
1917
 
1863
1918
  ```ts
1864
- const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
1919
+ const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [23 ms]
1865
1920
  пути.map((p) => `${p.Мастер.data.name} → ${p.Услуга.data.name}`)
1866
1921
  // → ['Ольга 0.0 → Стрижка 0', 'Ольга 0.0 → Массаж 7']
1867
1922
  ```
@@ -1876,7 +1931,7 @@ const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().
1876
1931
  **Примеры**
1877
1932
 
1878
1933
  ```ts
1879
- const услуги = await db.Услуга().rows() // [11.1 ms] → 600 Row
1934
+ const услуги = await db.Услуга().rows() // [21 ms] → 600 Row
1880
1935
  // [0] = { id: '00000004-0000-4000-8000-000000000000', class: 'Service',
1881
1936
  // data: { name: 'Стрижка 0', duration: 30, description: 'популярное' },
1882
1937
  // links: { Org: '00000001-0000-4000-8000-000000000000' }, tags: [],
@@ -1886,7 +1941,7 @@ const услуги = await db.Услуга().rows() // [11.1 ms] → 600 Row
1886
1941
  **Кейс: пути vs уникальные сущности**
1887
1942
 
1888
1943
  ```ts
1889
- await db.навык().Услуга().count() // [65.2 ms] → 1260 путей (навык → услуга)
1944
+ await db.навык().Услуга().count() // [93 ms] → 1260 путей (навык → услуга)
1890
1945
  (await db.навык().Услуга().rows()).length // [83.6 ms] → 600 уникальных услуг
1891
1946
  ```
1892
1947
 
@@ -1895,10 +1950,10 @@ await db.навык().Услуга().count() // [65.2 ms] → 1260 пу
1895
1950
  Параметров нет. То же, что `rows()` с `LIMIT 1`: первая строка или `null`.
1896
1951
 
1897
1952
  ```ts
1898
- await db.Мастер({ name: 'Олег 0.7' }).first() // [5.6 ms]
1953
+ await db.Мастер({ name: 'Олег 0.7' }).first() // [8.6 ms]
1899
1954
  // → { id: '…0007', class: 'Staff', data: { name: 'Олег 0.7', phone: '+7 921 0000007', specialization: 'колорист' },
1900
1955
  // links: { Org: '…0000' }, tags: [], updated: '2025-08-01T00:00:00+00:00' }
1901
- await db.Мастер({ name: 'Гэндальф' }).first() // [5.7 ms] → null
1956
+ await db.Мастер({ name: 'Гэндальф' }).first() // [7.2 ms] → null
1902
1957
  ```
1903
1958
 
1904
1959
  **Кейс: проверка «занято ли окно» перед бронью** — § 11.6 (перечитка под локом).
@@ -1909,7 +1964,7 @@ await db.Мастер({ name: 'Гэндальф' }).first() // [5.7 ms] → nul
1909
1964
  (дешевле по трафику).
1910
1965
 
1911
1966
  ```ts
1912
- await db.Мастер().ids() // [4.3 ms] → 360 id
1967
+ await db.Мастер().ids() // [6.7 ms] → 360 id
1913
1968
  // ['00000003-0000-4000-8000-000000000000', '…0001', '00000003-0000-4000-8000-000000000002', …]
1914
1969
  ```
1915
1970
 
@@ -1925,17 +1980,17 @@ await db.Мастер().ids() // [4.3 ms] → 360 id
1925
1980
  для многошаговой — нет (см. кейс `.rows()`).
1926
1981
 
1927
1982
  ```ts
1928
- await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12
1929
- await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [37.4 ms] → 630
1983
+ await db.Организация('…0000').Мастер().count() // [12 ms] → 12
1984
+ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [56 ms] → 630
1930
1985
  await db.Локация({ coordinates: { lat: gte(55.5) } }).count()
1931
- // [4.4 ms] → 26 — оператор на листе record-пути (глубина 2), каст numeric по Schema
1986
+ // [5.7 ms] → 26 — оператор на листе record-пути (глубина 2), каст numeric по Schema
1932
1987
  ```
1933
1988
 
1934
1989
  **Кейс: витрина каталога** — счётчики к фильтрам без выборки строк:
1935
1990
 
1936
1991
  ```ts
1937
- await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300 «быстрые»
1938
- await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [37.4 ms] → 630 «премиум»
1992
+ await db.Услуга({ duration: lte(45) }).count() // [4.6 ms] → 300 «быстрые»
1993
+ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [56 ms] → 630 «премиум»
1939
1994
  ```
1940
1995
 
1941
1996
  #### `.limit(n): Chain` / `.offset(n): Chain`
@@ -1951,9 +2006,9 @@ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() //
1951
2006
  **Примеры**
1952
2007
 
1953
2008
  ```ts
1954
- await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms]
2009
+ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [17 ms]
1955
2010
  // → [{ note: 'базовая', RUB: 8100 }, { note: 'базовая', RUB: 8000 }, { note: 'базовая', RUB: 7900 }]
1956
- await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows() // [19.5 ms]
2011
+ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows() // [19 ms]
1957
2012
  // → [{ RUB: 7800 }, { RUB: 7700 }, { RUB: 7600 }] — вторая страница
1958
2013
  ```
1959
2014
 
@@ -1974,10 +2029,10 @@ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows()
1974
2029
  **Примеры**
1975
2030
 
1976
2031
  ```ts
1977
- await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms] → 8100, 8000, 7900
1978
- await db.Услуга().sort('data.duration').limit(2).rows() // [7.5 ms] asc по умолчанию
2032
+ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [17 ms] → 8100, 8000, 7900
2033
+ await db.Услуга().sort('data.duration').limit(2).rows() // [11 ms] asc по умолчанию
1979
2034
  // → [{ name: 'Стрижка 0', duration: 30 }, { name: 'Педикюр 4', duration: 30 }]
1980
- await db.запись().sort('updated', 'desc').limit(2).rows() // [6232.3 ms]
2035
+ await db.запись().sort('updated', 'desc').limit(2).rows() // [5726 ms]
1981
2036
  // ЧЕСТНО: DISTINCT ON всех ~440k сущностей класса без фильтра — см. § 14
1982
2037
  ```
1983
2038
 
@@ -1998,7 +2053,7 @@ await db.запись().sort('updated', 'desc').limit(2).rows() // [6232.3
1998
2053
  **Примеры**
1999
2054
 
2000
2055
  ```ts
2001
- const истор = await db.цена('…0009').versions() // [7.7 ms] (для t1 ниже)
2056
+ const истор = await db.цена('…0009').versions() // [9.1 ms] (для t1 ниже)
2002
2057
  await db.цена('…0009').asOf(истор[0].updated).first() // [3.0 ms]
2003
2058
  // → data.amounts.RUB = 2550 — цена ТОГДА
2004
2059
  await db.цена('…0009').first()
@@ -2020,7 +2075,7 @@ await db.цена('…0009').first()
2020
2075
  **Примеры**
2021
2076
 
2022
2077
  ```ts
2023
- await db.цена('…0009').versions() // [7.7 ms]
2078
+ await db.цена('…0009').versions() // [9.1 ms]
2024
2079
  // → [{ RUB: 2550, updated: '2025-08-23T…' }, { RUB: 2650, updated: '2025-11-21T…' }]
2025
2080
  await db.Клиент('…0931').versions() // жизнь с удалением и воскрешением:
2026
2081
  // → [{ name: 'Злата', updated: '…54.159646' },
@@ -2117,10 +2172,10 @@ const брони = await db.Мастер(и).запись().rows() // ч
2117
2172
  **Примеры**
2118
2173
 
2119
2174
  ```ts
2120
- await db.Папка('…0000').Папка().deep().rows() // [9.2 ms]
2175
+ await db.Папка('…0000').Папка().deep().rows() // [22 ms]
2121
2176
  // → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 },
2122
2177
  // { name: 'Борода и усы', $depth: 2 }, { name: 'Уход', $depth: 3 }]
2123
- await db.Папка('…0000').Папка().deep(1).rows() // [7.4 ms] только прямые дети
2178
+ await db.Папка('…0000').Папка().deep(1).rows() // [15 ms] только прямые дети
2124
2179
  // → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 }]
2125
2180
  ```
2126
2181
 
@@ -2148,10 +2203,10 @@ const дерево = await db.Папка(корень).Папка().deep().rows(
2148
2203
  **Примеры**
2149
2204
 
2150
2205
  ```ts
2151
- await db.цена().sum('data.amounts.RUB') // [13.2 ms] → 2277000
2152
- await db.Услуга().avg('data.duration') // [3.0 ms] → 52.5
2153
- await db.Локация({}).sum('data.coordinates.lat') // [3.9 ms] лист record-пути (глубина 2) → 3327.925547539955
2154
- await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [5.0 ms] → null (пусто)
2206
+ await db.цена().sum('data.amounts.RUB') // [9.0 ms] → 2277000
2207
+ await db.Услуга().avg('data.duration') // [7.4 ms] → 52.5
2208
+ await db.Локация({}).sum('data.coordinates.lat') // [4.0 ms] лист record-пути (глубина 2) → 3327.925547539955
2209
+ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [6.6 ms] → null (пусто)
2155
2210
  ```
2156
2211
 
2157
2212
  **Кейс: итог по каталогу без выгрузки строк** — `sum('data.amounts.RUB')` по 405 ценам за 4 ms;
@@ -2163,8 +2218,8 @@ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [5
2163
2218
  **числом**, string — строкой.
2164
2219
 
2165
2220
  ```ts
2166
- await db.цена().min('data.amounts.RUB') // [15.0 ms] → 300 (число, не '300')
2167
- await db.цена().max('data.amounts.RUB') // [14.8 ms] → 8100
2221
+ await db.цена().min('data.amounts.RUB') // [7.1 ms] → 300 (число, не '300')
2222
+ await db.цена().max('data.amounts.RUB') // [6.2 ms] → 8100
2168
2223
  ```
2169
2224
 
2170
2225
  **Кейс: границы ценового слайдера** — `min` + `max` двумя запросами по 3–4 ms.
@@ -2179,7 +2234,7 @@ await db.цена().max('data.amounts.RUB') // [14.8 ms] → 8100
2179
2234
  объекта = значения поля, значения = счётчики (по путям).
2180
2235
 
2181
2236
  ```ts
2182
- await db.запись().countBy('data.notes') // [3266.3 ms] — ~440k сущностей
2237
+ await db.запись().countBy('data.notes') // [1853 ms] — ~440k сущностей
2183
2238
  // → { 'подтверждена': 400000, 'по телефону: Вера': 3334, 'по телефону: Ольга': 3334, …,
2184
2239
  // 'по телефону: Марина': 3333 } — 12 имён «по телефону» по ~3333 (ручные брони ~9%)
2185
2240
  ```
@@ -2197,7 +2252,7 @@ await db.запись().countBy('data.notes') // [3266.3 ms] — ~440k сущ
2197
2252
 
2198
2253
  ```ts
2199
2254
  await db.Организация(org).alias('салон').Мастер({ name: 'Ольга 0.0' }).alias('мастер').run()
2200
- // [6.7 ms] → ключи пути: ['салон', 'мастер']
2255
+ // [13 ms] → ключи пути: ['салон', 'мастер']
2201
2256
  ```
2202
2257
 
2203
2258
  **Кейс: self-join читаемо** — `db.Папка(a).alias('родитель').Папка().alias('дочка').run()`.
@@ -2212,8 +2267,8 @@ await db.Организация(org).alias('салон').Мастер({ name: '
2212
2267
  кандидаты + перепроверка). В записи — модификатор значения.
2213
2268
 
2214
2269
  ```ts
2215
- await db.Клиент().tags('vip').count() // [24.7 ms] → 400 (vip-клиенты)
2216
- await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
2270
+ await db.Клиент().tags('vip').count() // [52 ms] → 400 (vip-клиенты)
2271
+ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [78 ms] → 800
2217
2272
  ```
2218
2273
 
2219
2274
  **Кейс: пометить и найти** — § 5 (вставка с `.tags(['vip','telegram'])`, поиск `tags('vip')`);
@@ -2233,7 +2288,7 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms]
2233
2288
 
2234
2289
  ```ts
2235
2290
  await db.Организация().account(SYS).count() // [5.1 ms] → 30
2236
- await db.Организация().owner(SYS).count() // [3.9 ms] → 30
2291
+ await db.Организация().owner(SYS).count() // [5.5 ms] → 30
2237
2292
  ```
2238
2293
 
2239
2294
  **Кейс: чей это салон** — профиль владельца строки: `db.accounts.get(row.owner)`; выборка
@@ -2252,7 +2307,7 @@ await db.Организация().owner(SYS).count() // [3.9 ms] → 30
2252
2307
  `v: string | number | boolean | null` — «не равно» (`IS DISTINCT FROM` — null-безопасно).
2253
2308
 
2254
2309
  ```ts
2255
- await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms] → 570
2310
+ await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.3 ms] → 570
2256
2311
  ```
2257
2312
 
2258
2313
  **Кейс:** всё, кроме выбранного, — «другие услуги» под карточкой текущей.
@@ -2262,7 +2317,7 @@ await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms]
2262
2317
  `v: number | string` — строго больше / больше-или-равно (числа и сравнимые строки-даты).
2263
2318
 
2264
2319
  ```ts
2265
- await db.Услуга({ duration: gt(60) }).count() // [6.4 ms] → 150
2320
+ await db.Услуга({ duration: gt(60) }).count() // [4.4 ms] → 150
2266
2321
  await db.Услуга({ duration: gte(60) }).count() // [4.3 ms] → 300
2267
2322
  ```
2268
2323
 
@@ -2274,8 +2329,8 @@ await db.Услуга({ duration: gte(60) }).count() // [4.3 ms] → 300
2274
2329
  `v: number | string` — строго меньше / меньше-или-равно.
2275
2330
 
2276
2331
  ```ts
2277
- await db.Услуга({ duration: lt(45) }).count() // [5.1 ms] → 150
2278
- await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300
2332
+ await db.Услуга({ duration: lt(45) }).count() // [6.5 ms] → 150
2333
+ await db.Услуга({ duration: lte(45) }).count() // [4.6 ms] → 300
2279
2334
  ```
2280
2335
 
2281
2336
  **Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 300 услуг.
@@ -2285,7 +2340,7 @@ await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300
2285
2340
  `a, b: number | string` — диапазон включительно (`a ≤ x ≤ b`).
2286
2341
 
2287
2342
  ```ts
2288
- await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 300
2343
+ await db.Услуга({ duration: between(40, 65) }).count() // [4.7 ms] → 300
2289
2344
  ```
2290
2345
 
2291
2346
  **Кейс:** слайдер длительности «40–65 минут» одной функцией вместо пары gte+lte.
@@ -2295,7 +2350,7 @@ await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 3
2295
2350
  `vs: (string | number)[]` — значение из списка (`IN`).
2296
2351
 
2297
2352
  ```ts
2298
- await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [4.4 ms] → 60
2353
+ await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [5.3 ms] → 60
2299
2354
  ```
2300
2355
 
2301
2356
  **Кейс:** сравнение выбранных чекбоксами услуг: имена из UI → один запрос.
@@ -2305,8 +2360,8 @@ await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).
2305
2360
  `s: string` — SQL-шаблон (`%` — любое, `_` — один символ); `ilike` — без учёта регистра.
2306
2361
 
2307
2362
  ```ts
2308
- await db.Услуга({ name: like('Стри%') }).count() // [4.3 ms] → 60
2309
- await db.Услуга({ name: ilike('%массаж%') }).count() // [4.3 ms] → 60
2363
+ await db.Услуга({ name: like('Стри%') }).count() // [6.6 ms] → 60
2364
+ await db.Услуга({ name: ilike('%массаж%') }).count() // [4.8 ms] → 60
2310
2365
  ```
2311
2366
 
2312
2367
  **Кейс:** живой поиск в админке — `ilike('%' + ввод + '%')` прощает регистр
@@ -2317,8 +2372,8 @@ await db.Услуга({ name: ilike('%массаж%') }).count() // [4.3 m
2317
2372
  `s: string` — начинается с / заканчивается на (сахар над `like(s+'%')` / `like('%'+s)`).
2318
2373
 
2319
2374
  ```ts
2320
- await db.Услуга({ name: starts('Массаж') }).count() // [3.7 ms] → 60
2321
- await db.Услуга({ name: ends('7') }).count() // [2.9 ms] → 60
2375
+ await db.Услуга({ name: starts('Массаж') }).count() // [5.7 ms] → 60
2376
+ await db.Услуга({ name: ends('7') }).count() // [5.0 ms] → 60
2322
2377
  ```
2323
2378
 
2324
2379
  **Кейс:** префиксная навигация по названию: `starts('Массаж')` → все «Массаж N» (60 в каталоге).
@@ -2329,9 +2384,9 @@ await db.Услуга({ name: ends('7') }).count() // [2.9 ms] → 60
2329
2384
  идёт и в GIN-кандидаты). В демо-схеме массивов в `data` нет — операторы показаны на колонке `tags`.
2330
2385
 
2331
2386
  ```ts
2332
- await db.Клиент().tags(has('vip')).count() // [54.5 ms] → 400
2333
- await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
2334
- await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [11.0 ms] → 100
2387
+ await db.Клиент().tags(has('vip')).count() // [85 ms] → 400
2388
+ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [78 ms] → 800
2389
+ await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [19 ms] → 100
2335
2390
  ```
2336
2391
 
2337
2392
  **Кейс:** сегменты по меткам: «vip ИЛИ из телеграма» = `tags(hasAny(['vip','telegram']))` → 800;
@@ -2342,8 +2397,8 @@ await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [11.0 ms
2342
2397
  `yes: boolean?` — поле присутствует (`true`, default) / отсутствует (`false`) в `data`.
2343
2398
 
2344
2399
  ```ts
2345
- await db.Услуга({ description: exists() }).count() // [3.7 ms] → 390
2346
- await db.Услуга({ description: exists(false) }).count() // [3.2 ms] → 210
2400
+ await db.Услуга({ description: exists() }).count() // [7.9 ms] → 390
2401
+ await db.Услуга({ description: exists(false) }).count() // [6.2 ms] → 210
2347
2402
  ```
2348
2403
 
2349
2404
  **Кейс:** контроль заполненности каталога — «услуги без ключа `description`» = `exists(false)` → 210
@@ -2354,7 +2409,7 @@ await db.Услуга({ description: exists(false) }).count() // [3.2 ms] →
2354
2409
  Без параметров — поле `NULL` **или** отсутствует.
2355
2410
 
2356
2411
  ```ts
2357
- await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
2412
+ await db.Услуга({ description: isNull() }).count() // [6.1 ms] → 390
2358
2413
  ```
2359
2414
 
2360
2415
  **Кейс:** отличие от `exists(false)` — на полигоне видно числом: `isNull()` → 390 (ловит и явный
@@ -2366,8 +2421,8 @@ await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
2366
2421
  `v: скаляр | Op` — отрицание; скаляр ≡ «не равно» (как `ne`).
2367
2422
 
2368
2423
  ```ts
2369
- await db.Услуга({ name: not(starts('Стрижка')) }).count() // [3.7 ms] → 540
2370
- await db.Услуга({ name: not('Стрижка 0') }).count() // [3.5 ms] → 570
2424
+ await db.Услуга({ name: not(starts('Стрижка')) }).count() // [5.2 ms] → 540
2425
+ await db.Услуга({ name: not('Стрижка 0') }).count() // [4.3 ms] → 570
2371
2426
  ```
2372
2427
 
2373
2428
  **Кейс:** инверсия готового условия без переписывания: «всё, что НЕ стрижки» =
@@ -2379,7 +2434,7 @@ await db.Услуга({ name: not('Стрижка 0') }).count() // [3.
2379
2434
  обычное И).
2380
2435
 
2381
2436
  ```ts
2382
- await db.Услуга(or({ name: 'Стрижка 0' }, { duration: lt(45) })).count() // [4.0 ms] → 150
2437
+ await db.Услуга(or({ name: 'Стрижка 0' }, { duration: lt(45) })).count() // [5.7 ms] → 150
2383
2438
  ```
2384
2439
 
2385
2440
  **Кейс:** «Стрижка 0 или что-нибудь быстрое» — один запрос: Стрижка 0 (duration 30) уже среди
@@ -2414,11 +2469,11 @@ INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конф
2414
2469
  **Примеры**
2415
2470
 
2416
2471
  ```ts
2417
- await db.Организация().create({ name: 'Пилигрим' }).rows() // [11.1 ms] INSERT + defaults из Schema
2472
+ await db.Организация().create({ name: 'Пилигрим' }).rows() // [45 ms] INSERT + defaults из Schema
2418
2473
  // → [{ id: '019f5a53-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, links: {}, … }]
2419
2474
 
2420
2475
  await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
2421
- // [11.4 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
2476
+ // [14 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
2422
2477
 
2423
2478
  await db.Организация('…0901').Мастер().create({ id: '…0911', name: 'Мия', phone: '+7 909 000-09-11', specialization: 'массажист' }).rows()
2424
2479
  // [15.2 ms] контекст → links: { Org: '…0901' }
@@ -2430,7 +2485,7 @@ await db.Организация('…0901').Услуга().create({ name: 'Мас
2430
2485
  db.Услуга({ name: 'Массаж головы' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
2431
2486
  // Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
2432
2487
 
2433
- await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [9.9 ms] — ошибка НА ТЕРМИНАЛЕ:
2488
+ await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [11 ms] — ошибка НА ТЕРМИНАЛЕ:
2434
2489
  // ValidationError: letopis: validation failed for "Service":
2435
2490
  // The object '' contains forbidden keys: 'чепуха'.
2436
2491
 
@@ -2455,12 +2510,12 @@ db.Локация('…0921').Клиент() // [0.1 ms] недопустимы
2455
2510
  **Примеры**
2456
2511
 
2457
2512
  ```ts
2458
- await db.Клиент('…0931').запись({ start_datetime: between(t, t) }).update({ notes: 'подтверждена' }).rows() // [645.4 ms]
2513
+ await db.Клиент('…0931').запись({ start_datetime: between(t, t) }).update({ notes: 'подтверждена' }).rows() // [22 ms]
2459
2514
  // → [{ id: '4907d8cd-…', notes: 'подтверждена' }]
2460
2515
  // момент фильтруется between(t, t): скаляр-eq по date-полю = строковый containment, потому диапазон
2461
2516
  // (сотни мс: поиск целей идёт по всему классу записей ~440k без btree по data->>'start_datetime' — § 14)
2462
2517
 
2463
- await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [600.4 ms] — ВСЕ записи в контексте
2518
+ await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [25 ms] — ВСЕ записи в контексте
2464
2519
  // → [{ id: '4907d8cd-…', notes: 'день закрыт' }, { id: 'e7f16a08-…', notes: 'день закрыт' }]
2465
2520
 
2466
2521
  await db.запись({ notes: 'нет-такого' }).update({ notes: 'x' }).rows() // → [] — update НИКОГДА не создаёт
@@ -2481,7 +2536,7 @@ await db.запись('…d091').update({ notes: 'подтверждена' }).u
2481
2536
  // → ['выполнена']; versions: [null, 'подтверждена', 'выполнена']
2482
2537
 
2483
2538
  // хвост-чтение после операции — в той же транзакции
2484
- await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [16.7 ms] → 1
2539
+ await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [23 ms] → 1
2485
2540
 
2486
2541
  // ОТКАТ: валидный update + невалидный create — весь план назад
2487
2542
  await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().create({ чепуха: 1 }).rows()
@@ -2489,7 +2544,7 @@ await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запис
2489
2544
 
2490
2545
  // fan-out: обновить клиента → снести ВСЕ его записи
2491
2546
  await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().delete({ confirm: true }).rows()
2492
- // [29.0 ms] → снесено 1 запись, с $deleted: true
2547
+ // [40 ms] → снесено 1 запись, с $deleted: true
2493
2548
  ```
2494
2549
 
2495
2550
  #### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
@@ -2550,13 +2605,13 @@ tombstone-версия → рекурсивное удаление зависи
2550
2605
  **Примеры**
2551
2606
 
2552
2607
  ```ts
2553
- await db.Клиент('…0931').delete().rows() // [14.7 ms] ПРЕВЬЮ — кандидаты живы:
2608
+ await db.Клиент('…0931').delete().rows() // [23 ms] ПРЕВЬЮ — кандидаты живы:
2554
2609
  // → [{ id: '…0931', class: 'Customer' }, { class: 'booking' }, { class: 'booking' }] — клиент + его записи (каскад)
2555
2610
  // после превью клиент жив: true
2556
2611
 
2557
- await db.Клиент('…0931').delete({ confirm: true }).rows() // [35.8 ms] — сервер нашёл зависимых:
2612
+ await db.Клиент('…0931').delete({ confirm: true }).rows() // [49 ms] — сервер нашёл зависимых:
2558
2613
  // → те же три, каждый с $deleted: true
2559
- await db.Клиент('…0931').delete({ confirm: true }).rows() // [6.8 ms] повторно → []
2614
+ await db.Клиент('…0931').delete({ confirm: true }).rows() // [49 ms] повторно → []
2560
2615
  ```
2561
2616
 
2562
2617
  **Кейс: отмена и воскрешение**
@@ -2584,7 +2639,7 @@ await db.Организация('…0901').Клиент().create({ id: cid, name
2584
2639
  **Примеры**
2585
2640
 
2586
2641
  ```ts
2587
- await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
2642
+ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [13 ms]
2588
2643
  // → [{ data: { name: '[erased]', phone: '[erased]', preferred_contact: 'phone' },
2589
2644
  // tags: ['anonymized'] }]
2590
2645
  ```
@@ -2592,7 +2647,7 @@ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7
2592
2647
  **Кейс: запрос на забвение**
2593
2648
 
2594
2649
  ```ts
2595
- await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
2650
+ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [13 ms]
2596
2651
  ;(await db.Клиент('…0931').versions()).map((r) => r.data.name) // → ['Злата', 'Злата', 'Злата', '[erased]']
2597
2652
  await db.Клиент().tags('anonymized').count() // все стёртые — под контролем
2598
2653
  ```
@@ -2613,10 +2668,10 @@ await db.Клиент().tags('anonymized').count() // вс
2613
2668
  **Примеры**
2614
2669
 
2615
2670
  ```ts
2616
- const tr = await db.begin() // [1.1 ms]
2671
+ const tr = await db.begin() // [1.0 ms]
2617
2672
  await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows() // цУкл — базовая цена «Укладки»
2618
2673
  await tr.цена(цУкл).first() // внутри → amounts.RUB = 9900
2619
- await tr.rollback() // [0.8 ms]
2674
+ await tr.rollback() // [1.1 ms]
2620
2675
  await db.цена(цУкл).first() // снаружи → amounts.RUB = 700, изменения нет
2621
2676
  ```
2622
2677
 
@@ -2638,7 +2693,7 @@ await db.цена(цУкл).first() // снаружи → amo
2638
2693
  **Примеры**
2639
2694
 
2640
2695
  ```ts
2641
- await trA.lock('booking', staffId, start) // [3.1 ms]
2696
+ await trA.lock('booking', staffId, start) // [1.7 ms]
2642
2697
  await db.lock('x')
2643
2698
  // Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.2 ms]
2644
2699
  ```
@@ -2650,7 +2705,7 @@ await db.lock('x')
2650
2705
  // id записи детерминирован (v5, § 3.2) — известен ДО создания:
2651
2706
  const bId = uuidv5(`v1.salondemo:entity:booking:${мастер.id}:${start}`)
2652
2707
  const trA = await db.begin(), trB = await db.begin()
2653
- await trA.lock('booking', мастер.id, start) // [3.1 ms] A первый
2708
+ await trA.lock('booking', мастер.id, start) // [1.7 ms] A первый
2654
2709
  const гонкаB = (async () => {
2655
2710
  await trB.lock('booking', мастер.id, start) // B ВИСИТ до конца trA
2656
2711
  const занято = await trB.запись(bId).first() // перечитка под локом
@@ -2706,7 +2761,7 @@ b.Мастер(m).окно().create({ start_datetime: '2026-08-05T07:00:00Z', en
2706
2761
  b.Мастер(m).окно().create({ start_datetime: '2026-08-05T09:00:00Z', end_datetime: '…11:00Z' }).Локация.set(loc).Расписание.set(sch)
2707
2762
  b.Мастер(m).окно().create({ start_datetime: '2026-08-05T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
2708
2763
  b.size() // [35 µs] → 3
2709
- await b.run() // [43.8 ms] — одна транзакция; окно — v5-класс → 3 честных INSERT, не склейка
2764
+ await b.run() // [63 ms] — одна транзакция; окно — v5-класс → 3 честных INSERT, не склейка
2710
2765
  // → [[{ id: '06f67c88-…', start_datetime: '2026-08-05T07:00:00.000Z' }], [{ …09:00 }], [{ …11:00 }]]
2711
2766
  // id каждого окна вычислен схемой: uuidv5(Staff, start_datetime) — § 3.2
2712
2767
  b.size() // → 0
@@ -2749,7 +2804,7 @@ await db.Мастер(m).окно({ start_datetime: between('2026-08-06T11:00:00
2749
2804
  `category` → `= ANY(categories)`.
2750
2805
 
2751
2806
  ```ts
2752
- await db.accounts.find({ category: 'Client', enabled: true }) // [2.4 ms] → 1 аккаунт
2807
+ await db.accounts.find({ category: 'Client', enabled: true }) // [4.1 ms] → 1 аккаунт
2753
2808
  ```
2754
2809
 
2755
2810
  **Кейс:** список арендаторов для биллинга: `find({ enabled: true })`, отключённые не в счёте.
@@ -2759,7 +2814,7 @@ await db.accounts.find({ category: 'Client', enabled: true }) // [2.4 ms] →
2759
2814
  `id: string` — точечный SELECT по PK.
2760
2815
 
2761
2816
  ```ts
2762
- await db.accounts.get(acc.id) // [1.9 ms] → Account | null
2817
+ await db.accounts.get(acc.id) // [3.2 ms] → Account | null
2763
2818
  ```
2764
2819
 
2765
2820
  **Кейс:** профиль владельца строки Entity: `db.accounts.get(row.owner)`.
@@ -2779,7 +2834,7 @@ await db.accounts.get(acc.id) // [1.9 ms] → Account | null
2779
2834
 
2780
2835
  ```ts
2781
2836
  const acc = await db.accounts.set({ categories: ['Client'], data: { название: 'ИП Ромашка' } })
2782
- // [5.4 ms] → { id: '06d3bbfe-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
2837
+ // [9.7 ms] → { id: '06d3bbfe-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
2783
2838
  // meta: {}, avatar: 'https://i.pravatar.cc/128?img=33', enabled: true, created: …, updated: … }
2784
2839
  await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [5.7 ms] update
2785
2840
  ```
@@ -2794,7 +2849,7 @@ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) //
2794
2849
 
2795
2850
  ```ts
2796
2851
  await db.accounts.delete(времId) // [16.4 ms] → true (пустой аккаунт)
2797
- await db.accounts.delete(SYS) // [6.0 ms]
2852
+ await db.accounts.delete(SYS) // [5.5 ms]
2798
2853
  // Error: update or delete on table "Account" violates foreign key constraint "entity_account_fk"
2799
2854
  ```
2800
2855
 
@@ -2848,7 +2903,7 @@ await db.schema.define({ id: 'Coupon', alias: 'Купон', category: 'HUB',
2848
2903
  | `f.withDeleted` | `boolean?` | включить мягко-удалённые (default — только живые `deleted IS NULL`) |
2849
2904
 
2850
2905
  ```ts
2851
- await db.credentials.find({ account: acc.id }) // [3.2 ms] → 1 живой
2906
+ await db.credentials.find({ account: acc.id }) // [2.9 ms] → 1 живой
2852
2907
  await db.credentials.find({ account: acc.id, withDeleted: true }) // → 1 (после delete: 0 и 1)
2853
2908
  ```
2854
2909
 
@@ -2884,7 +2939,7 @@ await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 9
2884
2939
  освобождается для других аккаунтов (§ 11.9).
2885
2940
 
2886
2941
  ```ts
2887
- await db.credentials.delete(кред.id) // [4.6 ms] → true; find() больше не видит
2942
+ await db.credentials.delete(кред.id) // [4.0 ms] → true; find() больше не видит
2888
2943
  ```
2889
2944
 
2890
2945
  **Кейс:** отзыв api-ключа: `delete(credential.id)` → `verifyApiKey` мгновенно null
@@ -2905,10 +2960,10 @@ await db.credentials.delete(кред.id) // [4.6 ms] → true; find() боль
2905
2960
 
2906
2961
  ```ts
2907
2962
  await db.resources.set({ alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' } })
2908
- // [4.8 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
2909
- await db.resources.get('apiref.demo:API') // [2.6 ms] → тот же Resource
2910
- await db.resources.find({ category: 'API' }) // [1.1 ms] → 14 ресурсов
2911
- await db.resources.delete('apiref.demo:API') // [5.2 ms] → true
2963
+ // [5.1 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
2964
+ await db.resources.get('apiref.demo:API') // [2.5 ms] → тот же Resource
2965
+ await db.resources.find({ category: 'API' }) // [2.5 ms] → 14 ресурсов
2966
+ await db.resources.delete('apiref.demo:API') // [4.9 ms] → true
2912
2967
  ```
2913
2968
 
2914
2969
  **Кейс:** полный словарь для нового тарифа — § 11.10 (6 ресурсов + 5 правил одним блоком).
@@ -2927,9 +2982,9 @@ await db.resources.delete('apiref.demo:API') // [5.2 ms] → true
2927
2982
 
2928
2983
  ```ts
2929
2984
  await db.rules.set({ account: 'apiref.demo:API', resource: 'apiref.demo:API', permission: 'allow', weight: 90 })
2930
- // [5.3 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
2931
- await db.rules.find({ resource: 'apiref.demo:API' }) // [2.7 ms] → 1
2932
- await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [4.3 ms] → true
2985
+ // [5.9 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
2986
+ await db.rules.find({ resource: 'apiref.demo:API' }) // [2.8 ms] → 1
2987
+ await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [3.7 ms] → true
2933
2988
  ```
2934
2989
 
2935
2990
  **Кейс:** временный бан группы: `set({ account: группа, resource: цель, permission: 'deny',
@@ -2961,7 +3016,7 @@ uuid-строку или объект с `.id`.
2961
3016
 
2962
3017
  ```ts
2963
3018
  await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', password: 'лето-2026!' })
2964
- // [87.5 ms] → Credential; в БД вместо пароля:
3019
+ // [105 ms] → Credential; в БД вместо пароля:
2965
3020
  // meta.password = "scrypt$32768$8$1$+yEMcUTJIO7xEu/oDUAMXA==$b6…"
2966
3021
  ```
2967
3022
 
@@ -2984,11 +3039,11 @@ await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', passw
2984
3039
  **Примеры**
2985
3040
 
2986
3041
  ```ts
2987
- await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [78.8 ms]
3042
+ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [109 ms]
2988
3043
  // → { account: { id: '06d3bbfe-…', categories: ['Client'], enabled: true, … },
2989
3044
  // credential: { category: 'PASSWORD', identifier: 'romashka@salon.io', … } }
2990
- await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [71.1 ms] → null
2991
- await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [64.4 ms] → null
3045
+ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [109 ms] → null
3046
+ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [109 ms] → null
2992
3047
  // незнакомый identifier — то же время (dummy-verify)
2993
3048
  ```
2994
3049
 
@@ -2998,11 +3053,11 @@ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' })
2998
3053
  await db.auth.setPassword({ account: acc, identifier: 'noconfirm@salon.io', password: 'пароль-77',
2999
3054
  category: 'EMAIL', confirmed: false })
3000
3055
  await db.auth.verifyPassword({ identifier: 'noconfirm@salon.io', password: 'пароль-77', category: 'EMAIL' })
3001
- // [71.1 ms] → null — кред не подтверждён
3002
- await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [72.2 ms] → { account, credential }
3056
+ // [109 ms] → null — кред не подтверждён
3057
+ await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [109 ms] → { account, credential }
3003
3058
  await db.accounts.set({ id: acc.id, enabled: false })
3004
3059
  await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
3005
- // [59.5 ms] → null — аккаунт выключен, пароль уже не важен
3060
+ // [109 ms] → null — аккаунт выключен, пароль уже не важен
3006
3061
  ```
3007
3062
 
3008
3063
  #### `db.auth.issueApiKey(a): Promise<{ key, credential }>`
@@ -3017,7 +3072,7 @@ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'ле
3017
3072
  Сам ключ возвращается **один раз**; утечка БД ключи не раскрывает.
3018
3073
 
3019
3074
  ```ts
3020
- const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
3075
+ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [9.0 ms]
3021
3076
  // key = 'lts_ca41bf2a3614c3f228be2551a2ba998db5285f1a2bc3b859' ← показать и забыть
3022
3077
  // в БД: identifier = '624b00355aba026f…' (sha256), meta = { name: 'касса-1', prefix: 'lts_ca41bf2a' }
3023
3078
  ```
@@ -3035,16 +3090,16 @@ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'к
3035
3090
  Быстрый (без scrypt): ключ высокоэнтропийный, подбор бессмыслен.
3036
3091
 
3037
3092
  ```ts
3038
- await db.auth.verifyApiKey(key) // [4.5 ms] → { account: 06d3bbfe…, credential }
3093
+ await db.auth.verifyApiKey(key) // [6.9 ms] → { account: 06d3bbfe…, credential }
3039
3094
  ```
3040
3095
 
3041
3096
  **Кейс: полный жизненный цикл ключа**
3042
3097
 
3043
3098
  ```ts
3044
- const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
3045
- await db.auth.verifyApiKey(key) // [4.5 ms] → { account, credential } — касса работает
3099
+ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [9.0 ms]
3100
+ await db.auth.verifyApiKey(key) // [6.9 ms] → { account, credential } — касса работает
3046
3101
  await db.credentials.delete(credential.id) // отзыв (мягкий)
3047
- await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновенно недействителен
3102
+ await db.auth.verifyApiKey(key) // [6.9 ms] → null — мгновенно недействителен
3048
3103
  ```
3049
3104
 
3050
3105
  #### `db.auth.issueKeySecret(a): Promise<{ key, secret, credential }>`
@@ -3056,7 +3111,7 @@ await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновен
3056
3111
  возвращается один раз.
3057
3112
 
3058
3113
  ```ts
3059
- const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [4.8 ms]
3114
+ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [4.5 ms]
3060
3115
  // key = 'f8ab4007e4570809'; secret = 'c25b8b906f69…' (48 hex, показан один раз)
3061
3116
  ```
3062
3117
 
@@ -3071,8 +3126,8 @@ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'ин
3071
3126
  **Алгоритм.** Кред по identifier = key, `timingSafeEqual(sha256(secret), meta.secret)`, ворота.
3072
3127
 
3073
3128
  ```ts
3074
- await db.auth.verifyKeySecret(key, secret) // [4.6 ms] → { account, credential }
3075
- await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [2.2 ms] → null
3129
+ await db.auth.verifyKeySecret(key, secret) // [4.4 ms] → { account, credential }
3130
+ await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [4.4 ms] → null
3076
3131
  ```
3077
3132
 
3078
3133
  **Кейс:** серверная интеграция (1С, платёжка): key хранится в конфиге открыто и светится
@@ -3114,8 +3169,8 @@ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz
3114
3169
 
3115
3170
  ```ts
3116
3171
  const код = totpCode(secret) // [659 µs] → '564517' (как в приложении)
3117
- await db.auth.verifyTotp({ account: acc, code: код }) // [8.1 ms] → true — фактор активирован
3118
- await db.auth.verifyTotp({ account: acc, code: код }) // [2.6 ms] → false — replay отбит
3172
+ await db.auth.verifyTotp({ account: acc, code: код }) // [7.2 ms] → true — фактор активирован
3173
+ await db.auth.verifyTotp({ account: acc, code: код }) // [7.2 ms] → false — replay отбит
3119
3174
  const прошлый = totpCode(secret, Date.now() - 30_000) // код прошлого шага (окно ±1)
3120
3175
  await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false — шаг ≤ lastStep
3121
3176
  ```
@@ -3125,8 +3180,8 @@ await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false
3125
3180
  ```ts
3126
3181
  const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [4.8 ms]
3127
3182
  await db.auth.totpEnabled(acc) // → false — QR показан, ждём подтверждения
3128
- await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [8.1 ms] → true
3129
- await db.auth.totpEnabled(acc) // [2.2 ms] → true — теперь требуем код при входе
3183
+ await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [7.2 ms] → true
3184
+ await db.auth.totpEnabled(acc) // [2.0 ms] → true — теперь требуем код при входе
3130
3185
  ```
3131
3186
 
3132
3187
  #### `db.auth.totpEnabled(account): Promise<boolean>`
@@ -3135,7 +3190,7 @@ await db.auth.totpEnabled(acc) // [2.2 ms] → true — те
3135
3190
  проверкой (`confirmed`). Приложение по нему решает, спрашивать ли второй фактор.
3136
3191
 
3137
3192
  ```ts
3138
- await db.auth.totpEnabled(acc) // [2.2 ms] → true
3193
+ await db.auth.totpEnabled(acc) // [2.0 ms] → true
3139
3194
  ```
3140
3195
 
3141
3196
  #### `totpCode(secretBase32, atMs = Date.now()): string` — экспорт модуля
@@ -3172,7 +3227,7 @@ totpCode('G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS') // [659 µs] → '564517'
3172
3227
 
3173
3228
  ```ts
3174
3229
  const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@salon.io', ttlSec: 600 })
3175
- // [6.1 ms] code = '255243'; в БД: meta = { code: '7566c91d8a5e…' (sha256), expires: '2026-07-13T07:24:44.435Z', attempts: 0 }
3230
+ // [4.8 ms] code = '255243'; в БД: meta = { code: '7566c91d8a5e…' (sha256), expires: '2026-07-13T07:24:44.435Z', attempts: 0 }
3176
3231
  ```
3177
3232
 
3178
3233
  #### `db.auth.verifyOtp(a): Promise<AuthResult | null>`
@@ -3191,17 +3246,17 @@ const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@sa
3191
3246
  **Примеры**
3192
3247
 
3193
3248
  ```ts
3194
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [8.4 ms] → null (+1 попытка)
3195
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11.0 ms] → { account, credential }
3196
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [2.4 ms] → null — сожжён
3249
+ await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [11 ms] → null (+1 попытка)
3250
+ await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11 ms] → { account, credential }
3251
+ await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11 ms] → null — сожжён
3197
3252
  ```
3198
3253
 
3199
3254
  **Кейс: сброс пароля**
3200
3255
 
3201
3256
  ```ts
3202
- const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [6.1 ms]
3257
+ const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [4.8 ms]
3203
3258
  отправитьПисьмо(почта, code) // доставка — на приложении
3204
- const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11.0 ms]
3259
+ const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11 ms]
3205
3260
  if (кто) await db.auth.setPassword({ account: кто.account, identifier: почта, password: новый })
3206
3261
  // протухший код (ttl 1 s в прогоне): verifyOtp → null [9.3 ms]
3207
3262
  ```
@@ -3224,7 +3279,7 @@ if (кто) await db.auth.setPassword({ account: кто.account, identifier: п
3224
3279
 
3225
3280
  ```ts
3226
3281
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' } })
3227
- // [4.9 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
3282
+ // [12 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
3228
3283
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915' }) // id занят ДРУГИМ аккаунтом:
3229
3284
  // Error: duplicate key value violates unique constraint "credential_identity_udx" [3.9 ms]
3230
3285
  ```
@@ -3245,14 +3300,14 @@ await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915
3245
3300
 
3246
3301
  ```ts
3247
3302
  await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' })
3248
- // [5.2 ms] → { account: 06d3bbfe…, credential }
3303
+ // [5.3 ms] → { account: 06d3bbfe…, credential }
3249
3304
  ```
3250
3305
 
3251
3306
  **Кейс: вход через telegram-бота**
3252
3307
 
3253
3308
  ```ts
3254
3309
  // платформа подтвердила пользователя 777000111 (initData бота проверило приложение)
3255
- const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [5.2 ms]
3310
+ const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [5.3 ms]
3256
3311
  if (!кто) { /* первая встреча: создать аккаунт + db.auth.link(…) */ }
3257
3312
  const token = await sess.start(кто.account) // дальше обычная сессия
3258
3313
  ```
@@ -3284,7 +3339,7 @@ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [148 µ
3284
3339
  `sess:acc:<accountId>` для `revokeAll`. Дамп Redis действующих токенов не раскрывает.
3285
3340
 
3286
3341
  ```ts
3287
- const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [8.0 ms]
3342
+ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [18 ms]
3288
3343
  // token = 'fe0a7e83db6fcd1aa2c5002988da2b353602837db3bc0a813eddac863b9a1944'
3289
3344
  // в Redis: 'sess:8198bf2cd8d2ef2376d…' и 'sess:acc:06d3bbfe-d9a2-4…'
3290
3345
  ```
@@ -3295,7 +3350,7 @@ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }
3295
3350
  (нет / истекла / отозвана). Суб-миллисекундный — на каждый HTTP-запрос.
3296
3351
 
3297
3352
  ```ts
3298
- await sess.check(token) // [0.7 ms]
3353
+ await sess.check(token) // [1.7 ms]
3299
3354
  // → { account: '06d3bbfe-…', meta: { device: 'iphone' }, created: '2026-07-13T07:14:45.810Z' }
3300
3355
  await sess.check(протухший) // [0.8 ms] → null (ttl 1 s истёк — Redis сам удалил)
3301
3356
  ```
@@ -3305,8 +3360,8 @@ await sess.check(протухший) // [0.8 ms] → null (ttl 1 s истёк
3305
3360
  `token: string` — `DEL` ключа + `SREM` из индекса; `false`, если сессии уже нет.
3306
3361
 
3307
3362
  ```ts
3308
- await sess.revoke(token) // [1.9 ms] → true
3309
- await sess.revoke(token) // [0.7 ms] → false — повторно
3363
+ await sess.revoke(token) // [3.9 ms] → true
3364
+ await sess.revoke(token) // [3.9 ms] → false — повторно
3310
3365
  ```
3311
3366
 
3312
3367
  #### `sessions.revokeAll(account): Promise<number>`
@@ -3315,19 +3370,19 @@ await sess.revoke(token) // [0.7 ms] → false — повторно
3315
3370
  сколько погашено.
3316
3371
 
3317
3372
  ```ts
3318
- await sess.revokeAll(acc) // [1.2 ms] → 2 — обе сессии (ipad + macbook) погасли
3373
+ await sess.revokeAll(acc) // [2.5 ms] → 2 — обе сессии (ipad + macbook) погасли
3319
3374
  ```
3320
3375
 
3321
3376
  **Кейс: полный вход — пароль → сессия → запрос → выход (реальный прогон)**
3322
3377
 
3323
3378
  ```ts
3324
3379
  const визит = await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
3325
- // [71.3 ms] → { account, credential }
3380
+ // [109 ms] → { account, credential }
3326
3381
  const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.2 ms]
3327
3382
  // … каждый запрос в middleware:
3328
- const кто = await sess.check(token) // [0.7 ms] → { account: '06d3bbfe…', meta: { ip: '10.0.0.7' }, … }
3383
+ const кто = await sess.check(token) // [1.7 ms] → { account: '06d3bbfe…', meta: { ip: '10.0.0.7' }, … }
3329
3384
  // logout:
3330
- await sess.revoke(token) // [1.3 ms] → true
3385
+ await sess.revoke(token) // [3.9 ms] → true
3331
3386
  // «выйти со всех устройств» после смены пароля: await sess.revokeAll(визит.account)
3332
3387
  ```
3333
3388
 
@@ -3356,10 +3411,10 @@ NULL — все); объекты — `API`-ресурсы, чья маска п
3356
3411
  **Примеры**
3357
3412
 
3358
3413
  ```ts
3359
- await db.acl.check(acc, 'v2.booking.create') // [5.0 ms]
3414
+ await db.acl.check(acc, 'v2.booking.create') // [6.1 ms]
3360
3415
  // → { allow: true, rule: { account: 'apiref.client:ACCOUNT', resource: 'apiref.api.booking:API',
3361
3416
  // permission: 'allow', weight: 60, enabled: true } }
3362
- await db.acl.check(acc, 'v2.admin.stats') // [1.8 ms] — покрыло только дно-правило сида:
3417
+ await db.acl.check(acc, 'v2.admin.stats') // [2.5 ms] — покрыло только дно-правило сида:
3363
3418
  // → { allow: false, rule: { account: 'any:ACCOUNT', resource: 'any:API', permission: 'deny', weight: 0, … },
3364
3419
  // code: 403, message: 'Access denied - default for any ACCOUNT to any API' }
3365
3420
  ```
@@ -3393,12 +3448,12 @@ app.use(async (req, res, next) => {
3393
3448
  **Примеры**
3394
3449
 
3395
3450
  ```ts
3396
- await db.acl.checkData(acc, 'booking', 'READ') // [2.7 ms]
3451
+ await db.acl.checkData(acc, 'booking', 'READ') // [2.3 ms]
3397
3452
  // → { allow: true, rule: { resource: 'apiref.booking.own:READ', weight: 60, … },
3398
3453
  // filter: { owner: '06d3bbfe-d9a2-4c1d-be44-04c40cb01108' } } ← $account подставлен
3399
- await db.acl.checkData(acc, 'Service', 'READ') // [1.9 ms]
3454
+ await db.acl.checkData(acc, 'Service', 'READ') // [2.1 ms]
3400
3455
  // → { allow: true, rule: { resource: 'apiref.service:READ', … } } ← безусловный (без filter)
3401
- await db.acl.checkData(acc, 'Org', 'READ') // [2.0 ms]
3456
+ await db.acl.checkData(acc, 'Org', 'READ') // [1.9 ms]
3402
3457
  // → { allow: false, message: 'no matching rule (deny by default)' }
3403
3458
  await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий connect]
3404
3459
  // → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: '06d3bbfe-…' } }
@@ -3411,9 +3466,9 @@ await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий
3411
3466
  // enforceAccount: false — показаны ACL-предикаты, а не изоляция арендатора (§ 10.6)
3412
3467
  const uc = await (await connect({ dsn, schema, enforceAcl: true, enforceAccount: false })).as(acc.id)
3413
3468
  // [58.1 ms] снимок правил под ЭТОГО субъекта (обновить — reloadSchema)
3414
- await uc.запись().rows() // [12.6 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
3415
- await uc.запись().count() // [12.5 ms] → 3 — честный count по суженному множеству
3416
- await uc.Услуга().count() // [11.4 ms] → 605 — безусловный allow, класс целиком
3469
+ await uc.запись().rows() // [26 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
3470
+ await uc.запись().count() // [26 ms] → 3 — честный count по суженному множеству
3471
+ await uc.Услуга().count() // [30 ms] → 605 — безусловный allow, класс целиком
3417
3472
  await uc.Организация().rows()
3418
3473
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
3419
3474
  const [z] = await uc.запись().create({ start_datetime: t, end_datetime: e })
@@ -3438,7 +3493,7 @@ await uc.запись(свойId).delete({ confirm: true }).rows() // [27.8 ms
3438
3493
  | Вызов | Что пересобирает | На что НЕ влияет |
3439
3494
  |---|---|---|
3440
3495
  | `db.acl.reload()` | кэш Resource/Rule фасада `db.acl` (`check`/`checkData`) | энфорсер `enforceAcl`-цепочек, реестр классов |
3441
- | `await db.reloadSchema()` | реестр классов из `Schema`; со scope-хендла (`db.as`) — **и** его энфорсер `enforceAcl` (§ 11.2) | кэш фасада `db.acl` (сбрасывать отдельно); энфорсеры ДРУГИХ scope |
3496
+ | `await db.reloadSchema()` | реестр классов из `Schema` **и** снимок `Resource`/`Rule` подключения (следующий `db.as()` получит свежий словарь); со scope-хендла — **и** его энфорсер `enforceAcl` (§ 11.2) | кэш фасада `db.acl` (сбрасывать отдельно); уже созданные энфорсеры ДРУГИХ scope |
3442
3497
 
3443
3498
  Реконнект нужен только для смены `dsn`/`schema`/`partition` и самих флагов `enforce*`
3444
3499
  (сменить арендатора реконнект НЕ требует — это новый `db.as()`).
@@ -3663,7 +3718,10 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
3663
3718
  - Микро-бенч `npm run bench` (105k строк) + EXPLAIN-тесты (индексы обязаны быть в плане; ноль
3664
3719
  seq scan); партиционирование — `bench/dimensions.bench.mjs`, масштаб —
3665
3720
  `node bench/history.bench.mjs --entities=10000 --versions=100`, оверхед ACL —
3666
- `npx tsx bench/acl.bench.mjs` (таблица в § 9.2).
3721
+ `npx tsx bench/acl.bench.mjs` (таблица в § 9.2), цена изоляции арендатора —
3722
+ `node bench/tenants-seed.mjs && npx tsx bench/isolation.bench.mjs` (таблица в § 10.6;
3723
+ свой полигон `v1.tenants` — 10 арендаторов с перекосом 50 %…0.3 %, единый `v1.salondemo`
3724
+ этот вопрос не измеряет, там System владеет всем классом).
3667
3725
 
3668
3726
  ### 14.1 `ANALYZE` обязателен после массовой заливки
3669
3727