letopis 0.19.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
@@ -6,14 +6,15 @@ Dot-цепочки над append-only Entity-хранилищем (TimescaleDB).
6
6
  ```ts
7
7
  import { connect } from 'letopis'
8
8
 
9
- const db = await connect({ dsn: 'postgres://…', schema: 'v1.booking' })
9
+ const db = await connect({ dsn: 'postgres://…', schema: 'v1.booking' }) // пул, безличный
10
+ const t = await db.as(accountId) // арендатор ВЫЗОВА: кто именно делает эти запросы (§10.6)
10
11
 
11
12
  // чтение: пути по графу
12
- const пути = await db.Мастер({ name: 'Вася' }).навык().Услуга().run()
13
+ const пути = await t.Мастер({ name: 'Вася' }).навык().Услуга().run()
13
14
  // [ { Мастер: Row, навык: Row, Услуга: Row }, … ]
14
15
 
15
16
  // запись: операции — звенья, исполняет терминал
16
- await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
17
+ await t.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
17
18
  ```
18
19
 
19
20
  ## Возможности
@@ -52,7 +53,7 @@ await db.Организация(org).Мастер().create({ name: 'Вася', p
52
53
  - Пароли (scrypt), api-ключи, key-secret, external identity (oauth/sso/telegram), TOTP, одноразовые коды (§9.1).
53
54
  - Сессии во внешнем Redis (клиент инжектируется, §9.1).
54
55
  - ACL: `Resource` / `Rule`, `enforceAcl` — предикаты строк вливаются в SQL до сортировки/лимита; наследование прав по классам (§9.2).
55
- - Изоляция арендатора `enforceAccount` (§10.6).
56
+ - Изоляция арендатора `enforceAccount` **включена по умолчанию**; арендатора называет `db.as(account)` — свойство ВЫЗОВА, не подключения (§10.6).
56
57
 
57
58
  **Эксплуатация**
58
59
  - `up()` — dev-bootstrap одной функцией: Docker (TimescaleDB pg17 + Redis) + схема + сиды + connect (§1, §1.1).
@@ -82,6 +83,19 @@ await db.Организация(org).Мастер().create({ name: 'Вася', p
82
83
 
83
84
  ## 1. Быстрый старт
84
85
 
86
+ ```bash
87
+ npm install letopis # ESM-only, Node 20+; рантайм-зависимости: postgres + fastest-validator
88
+ ```
89
+
90
+ Нужен PostgreSQL 17 с расширением TimescaleDB (`Entity` — hypertable). Локально его
91
+ поднимает сам `up()` через Docker; Redis нужен только для сессий (`db.auth.sessions`),
92
+ клиент инжектируется.
93
+
94
+ > **Две разные «версии», не путать.** Semver пакета живёт в `package.json` /
95
+ > `CHANGELOG.md`. Везде в этом руководстве `version` / `opts.version` — **версия
96
+ > DDL-схемы**: целое ≥ 1, из него строится имя PG-схемы `v<version>.<schema>`,
97
+ > бампается руками при breaking-изменении `ddl.sql`.
98
+
85
99
  Одна функция — весь путь от нуля: dev-контейнер (TimescaleDB + Redis), готовность,
86
100
  версионная схема с сидами, подключение:
87
101
 
@@ -96,9 +110,14 @@ const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v
96
110
  // [letopis.up] schema "v1.booking" applied (3 files)
97
111
  // [letopis.up] connected (schema "v1.booking")
98
112
 
99
- const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
100
- const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
101
- await db.close()
113
+ // db — безличный пул: изоляция арендатора включена по умолчанию, поэтому цепочки идут
114
+ // от ИМЕНИ аккаунта (§10.6). На старте единственный аккаунт — System из сида.
115
+ const [sys] = await db.accounts.find({ category: 'System' })
116
+ const t = await db.as(sys.id)
117
+
118
+ const [org] = await t.Организация().create({ name: 'BarberPro' }).rows()
119
+ const [вася] = await t.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
120
+ await db.close() // scope'ы делят пул с корнем — закрывать один раз
102
121
  ```
103
122
 
104
123
  Идемпотентно: живой postgres на `dsn` → docker пропускается; существующая схема не трогается.
@@ -122,6 +141,13 @@ const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz
122
141
 
123
142
  ### 1.1 Своя схема с нуля + всё в контейнере одной командой
124
143
 
144
+ > **⚠ `seeds` ОБЯЗАТЕЛЕН для своего домена.** Без него (`seeds` не передан) `up()` заливает
145
+ > **демо-домен booking** — в любую схему, как бы она ни называлась: `up({schema:'myapp', version:1})`
146
+ > даст `v1.myapp` с классами `Организация`/`Мастер`/`запись`, а не с вашими.
147
+ > Передавайте `seeds: ['./myapp.sql']`, либо `seeds: false` — голая структура без классов
148
+ > (тогда классы добавляйте через `db.schema.define()`, иначе `connect` упадёт `has no classes`).
149
+ > Схема применяется ОДИН раз: если `v1.myapp` уже есть, накат пропускается (`fresh: true` — дропнуть и перелить).
150
+
125
151
  Три шага: **(1)** сид своей схемы → **(2)** `up()` поднимает контейнер и накатывает → **(3)** пишешь данные. Таблицы (`Schema`, `Account`, `Entity`, …) создаёт `ddl.sql` — своему сиду нужны только INSERT-ы; маркер `<SCHEMA-NAME>` подставит `up()`.
126
152
 
127
153
  **1. Сид `myapp.sql`** — классы в таблицу `Schema` + System-аккаунт:
@@ -184,13 +210,17 @@ const db = await up({
184
210
  })
185
211
  ```
186
212
 
187
- **3. Данные — работают сразу** (System-аккаунт из сида закрывает `account`/`owner`):
213
+ **3. Данные — работают сразу.** Цепочки идут от имени аккаунта (`db.as`, §10.6); System-аккаунт
214
+ из сида — валидное «имя» для дев-скриптов и он же дефолт колонок `account`/`owner`:
188
215
 
189
216
  ```ts
190
- const [n] = await db.Заметка().create({ title: 'Купить хлеб' }).rows()
191
- const [t] = await db.Тег().create({ name: 'дом' }).rows()
192
- await db.Заметка(n).помечена().create().Тег.set(t).rows() // связь Note ↔ Tag (§6.1)
193
- const активные = await db.Заметка({ done: false }).rows()
217
+ const [sys] = await db.accounts.find({ category: 'System' })
218
+ const s = await db.as(sys.id)
219
+
220
+ const [n] = await s.Заметка().create({ title: 'Купить хлеб' }).rows()
221
+ const [t] = await s.Тег().create({ name: 'дом' }).rows()
222
+ await s.Заметка(n).помечена().create().Тег.set(t).rows() // связь Note ↔ Tag (§6.1)
223
+ const активные = await s.Заметка({ done: false }).rows()
194
224
  await db.close()
195
225
  ```
196
226
 
@@ -278,6 +308,35 @@ GIN-кандидатам, затем перепроверка условий н
278
308
  | `links` | концы связей класса, формат v2 — массив объектов (см. ниже); legacy-строка `'Org'` = `{class:'Org'}`, `'Entity'` = старый полиморф |
279
309
  | `meta` | `{ abstract?, description? }` |
280
310
 
311
+ #### Зарезервированные имена классов
312
+
313
+ Шаг цепочки — это свойство Proxy, а терминалы/модификаторы/глаголы записи разбираются
314
+ **до** резолва класса. Значит класс, чей `id` или `alias` совпал с таким именем, как шаг
315
+ под этим именем **недостижим**: например класс с id `count` не получится пройти как
316
+ `db.X(id).count()` — вызовется терминал подсчёта.
317
+
318
+ | Уровень | Занятые имена |
319
+ |---|---|
320
+ | цепочка | `account` `after` `alias` `anonymize` `asOf` `avg` `count` `countBy` `create` `deep` `delete` `entity` `exact` `execute` `first` `ids` `limit` `max` `min` `offset` `owner` `purge` `rows` `run` `set` `sort` `sum` `tags` `then` `update` `versions` `withDeleted` |
321
+ | батч `db.batch(n)` | `discard` `size` (+ `run` / `execute` из строки выше) |
322
+ | корень `db` | `accounts` `acl` `as` `auth` `batch` `begin` `close` `commit` `credentials` `lock` `registry` `reloadSchema` `resources` `rollback` `rules` `schema` `sql` `watch` |
323
+
324
+ Всего 52 имени. `then` занят во всех трёх Proxy — иначе цепочка выглядела бы thenable и
325
+ ломала `await`. Фасады `accounts`/`credentials`/`resources`/`rules`/`schema`/`auth`/`acl`
326
+ разбираются на корне `db` до резолва класса.
327
+
328
+ - Гварды: `db.schema.define()` **отказывает** на таком имени; `connect()`/`up()` печатают
329
+ предупреждение (один раз на процесс) вида
330
+ `letopis: class name "count" is reserved by the chain API — use "Счётчик" for chain steps`.
331
+ - Хватает **одного** свободного имени из пары `id`/`alias`: если занят `id`, класс ходится
332
+ по алиасу, и наоборот. Если заняты оба — класс недостижим как шаг, `gen-types` его в типы
333
+ не выдаст, а предупреждение скажет «rename it».
334
+ - В демо-домене конфликтов нет: охранник на `.link()` снят в 0.20.0, поэтому класс
335
+ `link`/`связь` (абстрактный корень связок) ходится под любым из двух имён.
336
+ - Список — константа `RESERVED_CLASS_NAMES`; её совпадение с реальными точками перехвата
337
+ в `chain.ts` (и `case`-метки, и ранние `if (prop === …)`) проверяет
338
+ `scripts/check-docs.mjs`, а `gen-types` не выдаёт таких шагов в типы.
339
+
281
340
  ### 3.1 Концы связей — Schema.links v2
282
341
 
283
342
  Каждый конец — объект (`Schema.links jsonb` — массив объектов):
@@ -358,6 +417,32 @@ v7-классы (Org/Staff/Customer/Location/Schedule/Folder) наследуют
358
417
  наследуется так же — дефолт объявляется один раз на корне иерархии; `links` НЕ наследуются:
359
418
  концы объявляет каждый класс сам. Валидатор и типы полей компилируются из слитых attributes.
360
419
 
420
+ **Чтение ПОЛИМОРФНО: шаг по родителю отдаёт объединение с потомками любой глубины** —
421
+ как и права ACL наследуются по иерархии (§ 9.2). Абстрактные классы благодаря этому
422
+ перестают быть пустыми: `db.Контрагент()` → `Мастер` + `Клиент`, `db.связь()` → все
423
+ классы-связки, `db.Сущность()` → всё дерево. Уникальность сущности в таких выборках —
424
+ пара `(class, id)`; `Row.class` показывает конкретный класс строки.
425
+
426
+ **`.exact()` — только свой класс, без потомков.** Нужен, когда наследование в домене
427
+ использовано для переиспользования `attributes`, а не как «is-a» для выборки: в демо
428
+ `запись` наследует `окно` (интервал и правило v5-id), но смена ≠ бронь, поэтому
429
+ «смены мастера» — `db.Мастер(id).окно().exact()`, иначе в выдачу попадут и записи.
430
+
431
+ ```ts
432
+ await db.Контрагент().count() // Мастера + Клиенты
433
+ await db.Контрагент().exact().count() // 0 — abstract-класс своих строк не имеет
434
+ await db.Мастер(id).окно().exact().rows() // именно смены, без броней
435
+ ```
436
+
437
+ **Запись полиморфной НЕ бывает**: `create()` пишет строго в свой класс, в abstract —
438
+ ошибка `class "X" is abstract`. `update()`/`delete()`/`purge()` версионируют/сносят
439
+ то, что нашёл путь, — каждую строку в её собственном классе.
440
+
441
+ Права ACL проверяются **по конкретному классу каждой строки**: запрещённый потомок
442
+ выпадает из выборки по родителю (deny на `Customer` не обходится шагом `db.Контрагент()`),
443
+ у каждого разрешённого класса действует его собственный row-предикат. Если запрещено
444
+ всё семейство — отказ шага целиком.
445
+
361
446
  ### 3.4 Примеры схем из таблицы `Schema`
362
447
 
363
448
  Демо-домен booking — **20 классов** в `lib/sql/seed.booking.sql` (**источник правды**:
@@ -644,7 +729,7 @@ import { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends,
644
729
  ### Модификаторы цепочки
645
730
 
646
731
  ```ts
647
- db.окно().sort('data.start_datetime').limit(10).offset(20).rows() // выборка
732
+ db.окно().exact().sort('data.start_datetime').limit(10).offset(20).rows() // выборка (exact: смены без броней)
648
733
  db.запись().sort('updated', 'desc').limit(50).rows()
649
734
 
650
735
  db.Клиент().tags('vip') // фильтр: tags ⊇ ['vip']
@@ -661,8 +746,9 @@ db.Мастер({…}).alias('Исполнитель') // ключ ша
661
746
  | `asOf(t)` | вся цепочка | «как было на T» (§ 10.1) | — |
662
747
  | `after(cursor)` | вся цепочка | keyset-пагинация, требует `sort` (§ 10.2) | — |
663
748
  | `deep(max?)` | текущий шаг (self-hop) | рекурсивные дети, `$depth` (§ 10.4) | — |
749
+ | `exact()` | текущий шаг | снять полиморфизм: только свой класс, без потомков (§ 3.3) | — (запись и так строго по своему классу) |
664
750
  | `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при `create()` (string \| string[]) |
665
- | `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при `create()` |
751
+ | `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при `create()`; арендатор всей цепочки — не здесь, а `db.as()` (§ 10.6) |
666
752
  | `.Класс.set(x)` / `.Класс.unset()` | слот связи — после глагола записи | — | записать / снять конец связи БЕЗ участия в фильтре целей (§ 6.1) |
667
753
  | `alias(name)` | текущий шаг | ключ в путях | — |
668
754
 
@@ -789,6 +875,10 @@ await db.Клиент(cid).purge().rows() // превью з
789
875
  await db.Клиент(cid).purge({ confirm: true }).rows() // 2) физически: tombstone + всё поддерево (все версии)
790
876
  ```
791
877
 
878
+ > **⚠ На живой сущности `.purge()` возвращает `[]` — БЕЗ ошибки.** Гейт двухфазный: пустой
879
+ > результат значит «нечего стирать, сначала `.delete({confirm:true})`», а НЕ «стёрто».
880
+ > Не считайте `[]` подтверждением: проверяйте `.withDeleted().first()` → `null`.
881
+
792
882
  **Двухфазно:** `.purge()` работает ТОЛЬКО по уже логически удалённому (tombstone) — живую сущность
793
883
  не трогает (сначала `.delete()`). Историю, в отличие от `.delete()`, НЕ сохраняет. Внутри — серверная
794
884
  `purge(partition,class,id)`: `SET LOCAL letopis.purge='on'` отключает append-only-триггер (через `WHEN`)
@@ -878,7 +968,8 @@ await db.rules.set({ account: 'authenticated:ACCOUNT', resource: 'shop:API', per
878
968
  ```
879
969
 
880
970
  Связка с Entity: `account`/`owner` NOT NULL, default — System-аккаунт; фильтры и значения —
881
- модификаторы `.account()/.owner()`; профиль владельца — `db.accounts.get(row.owner)`.
971
+ хендл `db.as(account, { owner })` (§ 10.6) либо точечные модификаторы `.account()/.owner()`;
972
+ профиль владельца — `db.accounts.get(row.owner)`.
882
973
  Сид `lib/sql/seed.auth.sql`: System + 16 Resource + 12 Rule.
883
974
 
884
975
  ### 9.1 Вход: `db.auth` — много кредов на аккаунт
@@ -896,41 +987,41 @@ const acc = await db.accounts.set({ categories: ['User'], data: { name: 'Вас
896
987
 
897
988
  // ПАРОЛЬ (почта/пароль и логин/пароль — одна механика, различает category)
898
989
  await db.auth.setPassword({ account: acc, identifier: 'vasya@salon.io', password: 'корень-огня-77' })
899
- // → Credential; в БД НЕ пароль, а слоёный хэш: [64.8 ms]
990
+ // → Credential; в БД НЕ пароль, а слоёный хэш: [105 ms]
900
991
  // meta.password = "scrypt$32768$8$1$EmyOQlletdEahwgoXendhw==$vGQB9LrwcDSIB0+…"
901
992
  await db.auth.verifyPassword({ identifier: 'vasya@salon.io', password: 'корень-огня-77' })
902
993
  // → { account: {id: '7277…', data: {name: 'Вася'}, enabled: true, …},
903
994
  // credential: {category: 'PASSWORD', identifier: 'vasya@salon.io', …} } [71.8 ms]
904
- 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]
905
996
  // цена задана scrypt-ом (~70 ms) и выровнена: «нет такого identifier» не быстрее «пароль неверен»
906
997
 
907
998
  // API-КЛЮЧ: показывается ОДИН раз, в БД — только sha256
908
999
  const { key } = await db.auth.issueApiKey({ account: acc, name: 'CI' })
909
- // key = "lts_a61118c4af31868640529131927eb578211730bcd092856c" [6.3 ms]
1000
+ // key = "lts_a61118c4af31868640529131927eb578211730bcd092856c" [9.0 ms]
910
1001
  // в БД: identifier = "ae9002e2cd4c…" (sha256), meta = {name: 'CI', prefix: 'lts_a61118c4'}
911
- await db.auth.verifyApiKey(key) // → { account, credential } [5.6 ms]
1002
+ await db.auth.verifyApiKey(key) // → { account, credential } [6.9 ms]
912
1003
 
913
1004
  // КЛЮЧ-СЕКРЕТ: key — открытый id пары, secret — только sha256
914
1005
  const { key: k, secret } = await db.auth.issueKeySecret({ account: acc, name: 'integration' })
915
1006
  // k = "96d327b741421f74", secret = "38f69922c9748aa8…" (48 hex) [4.7 ms]
916
- await db.auth.verifyKeySecret(k, secret) // → { account, credential } [3.4 ms]
1007
+ await db.auth.verifyKeySecret(k, secret) // → { account, credential } [4.4 ms]
917
1008
 
918
1009
  // ВНЕШНЯЯ IDENTITY (oauth/sso/telegram): токен проверяет приложение, тут — связка
919
1010
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915', meta: { username: 'roboteza' } })
920
1011
  await db.auth.lookup({ category: 'TELEGRAM', identifier: '1635246915' })
921
- // → { account, credential } [2.6 ms]
1012
+ // → { account, credential } [5.3 ms]
922
1013
 
923
1014
  // TOTP (authenticator, RFC 6238): секрет в QR, активен после первой проверки
924
1015
  const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: 'anna@salon.io' })
925
1016
  // secret = "RWVB3WWQ62LEED55AMU6L425C6KIT5EJ" (base32, 20 байт) [5.6 ms]
926
1017
  // uri = "otpauth://totp/anna%40salon.io?secret=…&issuer=clockz&algorithm=SHA1&digits=6&period=30"
927
- 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]
928
1019
  await db.auth.verifyTotp({ account: acc, code: '139999' }) // → false — replay того же шага отбит
929
1020
  await db.auth.totpEnabled(acc) // → true (после первой проверки)
930
1021
 
931
1022
  // ОДНОРАЗОВЫЙ КОД (email/SMS/reset — доставка на приложении)
932
1023
  const { code } = await db.auth.issueOtp({ account: acc, identifier: 'anna@salon.io', ttlSec: 600 })
933
- // code = "273746"; в БД sha256 + expires + attempts [3.3 ms]
1024
+ // code = "273746"; в БД sha256 + expires + attempts [4.8 ms]
934
1025
  await db.auth.verifyOtp({ identifier: 'anna@salon.io', code })
935
1026
  // → { account, credential }; код СОЖЖЁН (одноразовость) [6.0 ms]; повторно → null
936
1027
  // 5 неверных попыток тоже сжигают; повторный issue перезаписывает код (валиден последний)
@@ -996,13 +1087,16 @@ await db.acl.checkData(user, 'Org', 'READ')
996
1087
  db.acl.reload() // сброс кэша Resource/Rule фасада
997
1088
  ```
998
1089
 
999
- **`connect({ account, enforceAcl: true })`** — те же решения в цепочках: каждый шаг —
1090
+ **`connect({ enforceAcl: true })` + `db.as(субъект)`** — те же решения в цепочках: каждый шаг —
1000
1091
  `READ`, `create()`/`update()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
1001
1092
  (deny в замыкании откатывает транзакцию); `watch()` отдаёт события только безусловных
1002
- allow-классов (payload нечем проверить предикат). Правила фиксируются на connect.
1093
+ allow-классов (payload нечем проверить предикат). Субъекта даёт `db.as()`: у каждого scope
1094
+ **свой** memo-резолвер (ключ memo субъекта не содержит, поэтому один энфорсер = один субъект),
1095
+ а сам словарь `Resource`/`Rule` — общий снимок на подключение; правки словаря подхватывает
1096
+ `db.reloadSchema()` (§ 11.10).
1003
1097
 
1004
1098
  ```ts
1005
- const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
1099
+ const u = await (await connect({ dsn, schema, enforceAcl: true })).as(user.id)
1006
1100
  await u.запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
1007
1101
  await u.запись().count() // честный count по суженному множеству
1008
1102
  await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
@@ -1012,22 +1106,28 @@ await u.запись(чужаяId).update({ notes: '…' }).rows() // → []
1012
1106
  // create той же v5-пары (id чужой записи) тоже НЕ перехватывает: → [] вместо новой версии
1013
1107
  ```
1014
1108
 
1015
- Оверхед (полигон `v1.salondemo` ~980k, booking 440k×2; p50 из 20; `bench/acl.bench.mjs`):
1109
+ Оверхед (полигон `v1.salondemo` ~980k, booking 440k×2; p50 из 20; `bench/acl.bench.mjs`;
1110
+ все три хендла с `enforceAccount: false` — мерится цена **авторизации**, изоляция арендатора
1111
+ считается отдельно в § 10.6; полигон **проанализирован**, см. § 14.1):
1016
1112
 
1017
1113
  | Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
1018
1114
  |---|---|---|---|
1019
- | точечный `first(id)` | 2.9 ms | 4.2 ms | 6.1 ms |
1020
- | фильтр `rows` limit 100 | 1394 ms | 1454 ms | 1362 ms |
1021
- | keyset-страница всего класса 440k | 5495 ms | 5576 ms | 5364 ms |
1022
- | `count()` класса 440k | 3196 ms | 3049 ms | 2874 ms |
1023
- | цепочка `запись(id).Услуга()` | 5.5 ms | 6.6 ms | 5.1 ms |
1024
-
1025
- Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат здесь на
1026
- селективности 100% (все строки booking — System-аккаунт, предикат никого не отсекает) —
1027
- на тяжёлых сценах ACL в пределах шума с базой; в реальности предикат СУЖАЕТ выборку,
1028
- и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
1029
- `db.acl.checkData` — справочный (1.0 ms: SQL за категориями аккаунта на каждый вызов);
1030
- горячий путь цепочек использует резолвер, скомпилированный на connect (микросекунды, memo).
1115
+ | точечный `first(id)` | 4.5 ms | 4.7 ms | 4.6 ms |
1116
+ | фильтр `rows` limit 100 | 10.9 ms | 11.1 ms | 11.4 ms |
1117
+ | keyset-страница всего класса 440k | 5425 ms | 5146 ms | 5305 ms |
1118
+ | `count()` класса 440k | 1373 ms | 1262 ms | 1392 ms |
1119
+ | цепочка `запись(id).Услуга()` | 6.0 ms | 6.4 ms | 5.8 ms |
1120
+
1121
+ **ACL бесплатен на всех сценах** — отличия в пределах шума измерения (местами колонка с ACL
1122
+ даже «быстрее» базы). Решение берётся из memo, SQL безусловного правила совпадает с базовым.
1123
+ *Предикат здесь на селективности 100% (все строки booking — System-аккаунт, предикат никого
1124
+ не отсекает); в реальности он СУЖАЕТ выборку, и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
1125
+ `db.acl.checkData` — справочный (1.7 ms: SQL за категориями аккаунта на каждый вызов);
1126
+ горячий путь цепочек использует резолвер, скомпилированный при `db.as()` (микросекунды, memo).
1127
+ Цена самого `db.as()` под `enforceAcl` — **один** SELECT за свой аккаунт: словарь
1128
+ `Resource`/`Rule` снимается один раз на подключение и переиспользуется всеми scope (сбрасывает
1129
+ `db.reloadSchema()`), поэтому scope-per-request не платит за словарь на каждый HTTP-запрос.
1130
+ Без `enforceAcl` `db.as()` бесплатен вовсе: клон контекста, без SQL и без нового соединения.
1031
1131
 
1032
1132
  `enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
1033
1133
 
@@ -1080,7 +1180,7 @@ const стр2 = await db.запись().sort('updated', 'desc') // можно
1080
1180
  .after(кур).limit(50).rows()
1081
1181
 
1082
1182
  // по data-пути ЛЮБОЙ глубины — field тот же, что в sort
1083
- const дальше = await db.окно().sort('data.start_datetime')
1183
+ const дальше = await db.окно().exact().sort('data.start_datetime')
1084
1184
  .after(cursorOf(окно, 'data.start_datetime')).limit(20).rows()
1085
1185
 
1086
1186
  // бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
@@ -1106,7 +1206,7 @@ do {
1106
1206
  await db.цена().sum('data.amounts.RUB') // сумма всех прайсов (record-лист): number | null
1107
1207
  await db.Услуга().avg('data.duration') // среднее
1108
1208
  await db.Услуга().countBy('data.duration') // { '30': 2, '60': 1 } ({} на пустом)
1109
- await db.окно().min('data.start_datetime') // min/max — каст по типу поля из Schema
1209
+ await db.окно().exact().min('data.start_datetime') // min/max — каст по типу поля из Schema
1110
1210
  ```
1111
1211
 
1112
1212
  Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или вложенный лист
@@ -1147,18 +1247,56 @@ const stop = await db.watch('запись', onEvent, {
1147
1247
  })
1148
1248
  ```
1149
1249
 
1150
- ### 10.6 Изоляция арендатора: `enforceAccount`
1250
+ ### 10.6 Изоляция арендатора: `db.as()` + `enforceAccount`
1251
+
1252
+ **Арендатор — свойство ВЫЗОВА, а не подключения.** Один пул обслуживает разных арендаторов,
1253
+ поэтому в `connect()` опции `account` нет: подключение безличное, а имя даёт `db.as(account)` —
1254
+ хендл того же пула, работающий от имени этого аккаунта.
1151
1255
 
1152
1256
  ```ts
1153
- const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
1154
- await db.запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
1155
- await db.Клиент().create({ name: 'X', phone: '+7…' }) // запись пришпилена к account
1156
- db.запись().account(чужой).rows() // ошибка: reads are pinned to account …
1257
+ const db = await connect({ dsn, schema }) // пул; enforceAccount по умолчанию TRUE
1258
+ const t = await db.as(tenantId) // идентичность вызова (в вебе — на запрос)
1259
+
1260
+ await t.запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
1261
+ await t.Клиент().create({ name: 'X', phone: '+7…' }) // запись пришпилена к account
1262
+ await t.запись().account(чужой).rows() // ошибка: reads are pinned to account …
1263
+
1264
+ await db.запись().rows() // ошибка: enforceAccount is on — call db.as(account) …
1157
1265
  ```
1158
1266
 
1159
1267
  Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
1160
- больше не утечка данных соседнего салона. Это простой флаг «всё по одному арендатору»;
1161
- гранулярные права (по классам, операциям, со срезами строк правилами) — ACL § 9.2.
1268
+ больше не утечка данных соседнего салона, а забытый `db.as()` — не тихая выдача всего, а
1269
+ внятная ошибка. Это простой флаг «всё по одному арендатору»; гранулярные права (по классам,
1270
+ операциям, со срезами строк правилами) — ACL § 9.2 (субъекта ему даёт тот же `db.as()`).
1271
+
1272
+ `db.as()` дешёв: клон контекста на том же пуле, без нового соединения и без SQL (под
1273
+ `enforceAcl` — плюс компиляция правил под этого субъекта, § 9.2). Хендлы можно держать
1274
+ сколько нужно, `close()` зовётся один раз на пул. Второй аргумент задаёт `owner` новых
1275
+ строк: `db.as(account, { owner })` (по умолчанию `owner` = `account`).
1276
+
1277
+ **Цена изоляции — отрицательная в реальной многоарендаторной базе.** Предикат `account`
1278
+ попадает не только в перепроверку, но и в подзапрос кандидатов, то есть **сужает выборку по
1279
+ индексу до основного скана**: изоляция не добавляет фильтр к полному проходу по классу, а
1280
+ заменяет его. Замерено на полигоне из 10 арендаторов (1 млн версий класса `Customer`, `count()`,
1281
+ p50 из 5; воспроизводится `node bench/tenants-seed.mjs && npx tsx bench/isolation.bench.mjs`):
1282
+
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 с.
1294
+ Вывод практический: чем больше у вас арендаторов, тем выгоднее держать изоляцию включённой.
1295
+ Все замеры — на проанализированной базе; без `ANALYZE` эта же сцена давала 54 с (§ 14.1).
1296
+
1297
+ Административный доступ (миграции, дев-скрипты, отчёты по всем арендаторам) — явным
1298
+ `connect({ …, enforceAccount: false })`: там `db` читает и пишет без scope, а колонку
1299
+ `account` закрывает System-аккаунт схемы.
1162
1300
 
1163
1301
  ### 10.7 Анонимизация (GDPR): `.anonymize()`
1164
1302
 
@@ -1191,6 +1329,49 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
1191
1329
  - Интервалы: `30d`, `2y`, `12h`, `6mon` или сырой PG (`'90 days'`). Повторный запуск
1192
1330
  переустанавливает политику.
1193
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
+
1194
1375
  ### 10.9 Миграции классов: `scripts/schema-sync.mjs`
1195
1376
 
1196
1377
  > Входит в npm-пакет (`files: ["scripts"]`); в установке путь — `node_modules/letopis/scripts/schema-sync.mjs`. Зависимости — `postgres` и `fastest-validator` (обе — deps пакета), `tsx` не нужен.
@@ -1276,10 +1457,17 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
1276
1457
  **живой прогон** на едином полигоне `v1.salondemo` ~980 000 строк Entity (год окон-смен и
1277
1458
  записей: 440 000 записей-наследников окон ×2 версии, каталог из 600 услуг, 360 мастеров,
1278
1459
  40 000 клиентов, 1026 цен, 1260 навыков); воспроизводитель — `bench/api-reference-demo.mjs` (только
1279
- читает полигон salon-seed, мутирует лишь свои сущности). Тот же полигон — под статьёй SALON.md.
1460
+ читает полигон salon-seed, мутирует лишь свои сущности).
1280
1461
  id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; повторяющиеся
1281
1462
  `account`/`owner`/`partition` в ответах опущены.
1282
1463
 
1464
+ > **Как читать тайминги.** Значения перенесены из прогона демо **точным сопоставлением по
1465
+ > коду вызова**, полигон при этом проанализирован (`ANALYZE`, § 14.1 — без него те же запросы
1466
+ > медленнее на порядок). Часть примеров иллюстративна и в демо в такой форме не исполняется
1467
+ > (плейсхолдеры вроде `connect({ dsn, schema })`, переименованные переменные, разбитые на
1468
+ > строки цепочки) — у них цифра осталась от более раннего прогона и может быть пессимистичной.
1469
+ > Сводные, всегда свежие цифры — таблицы § 9.2 (ACL), § 10.6 (изоляция) и § 14 (перф).
1470
+
1283
1471
  Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
1284
1472
  [11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
1285
1473
  [11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-create--update--delete--anonymize) ·
@@ -1297,21 +1485,23 @@ id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; по
1297
1485
  | `opts.dsn` | `string` | — | строка подключения `postgres://user:pass@host:port/db` |
1298
1486
  | `opts.schema` | `string` | — | ПОЛНОЕ имя PG-схемы с версией движка: `'v1.booking'` (создаёт `up({schema: 'booking', version: 1})` либо `db/apply.mjs --schema=booking --version=1`; либа префикс не достраивает) |
1299
1487
  | `opts.partition` | `string?` | `'entity'` | партиция данных: все чтения/записи этого подключения живут в ней |
1300
- | `opts.account` | `uuid?` | System-аккаунт схемы | default-`account` (арендатор) новых строк |
1301
- | `opts.owner` | `uuid?` | = `account` | default-`owner` (владелец) новых строк |
1302
1488
  | `opts.max` | `number?` | `10` | размер пула соединений postgres.js |
1303
- | `opts.enforceAccount` | `boolean?` | `false` | жёсткая изоляция арендатора (§ 10.6): каждый шаг чтения фильтруется `account = opts.account`, записи пришпилены; явный чужой `.account()` — ошибка |
1304
- | `opts.enforceAcl` | `boolean?` | `false` | ACL по Resource/Rule (§ 9.2): READ на каждый шаг, WRITE/DELETE на записи, предикаты строк в SQL заранее; **требует `account`**; deny-by-default |
1489
+ | `opts.enforceAccount` | `boolean?` | **`true`** | жёсткая изоляция арендатора (§ 10.6): каждый шаг чтения фильтруется по `account` scope-хендла, записи пришпилены; явный чужой `.account()` — ошибка; чтение/запись **с безличного корня — ошибка** с подсказкой на `db.as()` |
1490
+ | `opts.enforceAcl` | `boolean?` | `false` | ACL по Resource/Rule (§ 9.2): READ на каждый шаг, WRITE/DELETE на записи, предикаты строк в SQL заранее; субъект — аккаунт `db.as()`; deny-by-default. Выключенный печатает предупреждение один раз на процесс |
1305
1491
  | `opts.onQuery` | `((e: QueryEvent) => void)?` | — | хук на каждый запрос цепочки: `{mode, classes, ms, rows, slow}` (§ 10.10) |
1306
1492
  | `opts.slowMs` | `number?` | — | порог медленного запроса: `ms > slowMs` → `e.slow = true`; если `onQuery` не задан — `console.warn` |
1307
1493
 
1308
1494
  **Назначение и алгоритм.** Открывает пул postgres.js (timestamptz парсится **строкой**,
1309
1495
  чтобы не терять микросекунды в `asOf`/курсорах), одним SELECT загружает реестр классов из
1310
1496
  `Schema` (компилируя fastest-validator на класс), резолвит System-аккаунт как fallback для
1311
- NOT NULL `Entity.account`. При `enforceAcl: true` дополнительно параллельно читает аккаунт,
1312
- все Resource и включённые Rule и **компилирует синхронный резолвер решений** (класс,
1313
- операция) → `AclDecision` с memo — правила фиксируются на весь срок жизни подключения.
1314
- Реестр классов тоже фиксируется: новый класс в `Schema` увидит только новый `connect()`.
1497
+ NOT NULL `Entity.account`. Подключение **безличное**: арендатора и субъекта ACL называет
1498
+ `db.as(account)` (§ 10.6) — опций `account`/`owner` в `ConnectOpts` нет. При `enforceAcl: true`
1499
+ каждый `db.as()` параллельно читает свой аккаунт, все Resource и включённые Rule и
1500
+ **компилирует синхронный резолвер решений** (класс, операция) → `AclDecision` с memo.
1501
+ Реестр классов снимается один раз на `connect()` — но НЕ до конца жизни подключения: правки
1502
+ в `Schema` / `Resource` / `Rule` подхватываются `db.reloadSchema()` без реконнекта (со
1503
+ scope-хендла пересобирает и реестр, и его ACL-резолвер — [§11.2](#112-entitydb--корень)).
1504
+ Реконнект нужен только для смены `dsn`/`schema`/`partition` и флагов `enforce*`.
1315
1505
 
1316
1506
  **Примеры**
1317
1507
 
@@ -1325,26 +1515,62 @@ await dbSlow.запись().count() // ~440k сущностей — дольш
1325
1515
  // {"mode":"count","classes":["booking"],"ms":3191.0,"rows":1,"slow":true}
1326
1516
  ```
1327
1517
 
1328
- **Кейс: три подключения — обычное, изолированное, под ACL**
1518
+ **Кейс: три хендла — админский, изолированный, под ACL**
1329
1519
 
1330
1520
  ```ts
1331
- const db = await connect({ dsn, schema }) // [33.6 ms]
1332
- const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [40.2 ms]
1333
- const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [54.7 ms]
1334
- await db.Организация().count() // → 30 — видит всех
1335
- await iso.Организация().count() // [5.1 ms] → 1 — только свой арендатор
1521
+ const db = await connect({ dsn, schema, enforceAccount: false }) // [32.6 ms]
1522
+ const iso = await (await connect({ dsn, schema })).as(acc.id) // [55.3 ms]
1523
+ const uc = await (await connect({ dsn, schema, enforceAcl: true })).as(acc.id) // [58.1 ms]
1524
+ await db.Организация().count() // → 30 — админский видит всех
1525
+ await iso.Организация().count() // [9.0 ms] → 1 — только свой арендатор
1336
1526
  await uc.Организация().rows()
1337
1527
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default)
1338
1528
  await iso.close(); await uc.close()
1339
1529
  ```
1340
1530
 
1531
+ #### `uuidv5(name: string, ns = LETOPIS_NS): string` · `uuidv7(): string` · `LETOPIS_NS`
1532
+
1533
+ | Экспорт | Тип | Описание |
1534
+ |---|---|---|
1535
+ | `uuidv5` | `(name: string, ns?: string) => string` | детерминированный (sha1) id из имени в пространстве `ns` |
1536
+ | `uuidv7` | `() => string` | time-ordered: старшие биты — метка времени (мс), дальше random |
1537
+ | `LETOPIS_NS` | `string` | пространство имён либы: `c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90` |
1538
+
1539
+ **Назначение и алгоритм.** Это не хелперы «на всякий случай», а **публичный контракт**:
1540
+ для класса с `attributes.id = {type:'uuid', generate:5, from:[…]}` id считает схема, и
1541
+ формула открыта — тот же id можно получить на клиенте **до** записи (идемпотентный
1542
+ `create()`, проверка «занято ли» точечным `first()`, advisory-lock по будущему id).
1543
+ Строка-имя собирается так (`from` — в порядке объявления; конец связи → id цели,
1544
+ скалярное поле → его значение):
1545
+
1546
+ ```
1547
+ uuidv5(`${pgSchema}:${partition}:${class}:${from₁}:${from₂}:…`, LETOPIS_NS)
1548
+ ```
1549
+
1550
+ **Любое** изменение имени PG-схемы, партиции, id класса или ПОРЯДКА `from` даёт другой id —
1551
+ это часть контракта данных, менять как breaking-change (бамп `version` схемы).
1552
+
1553
+ **Примеры**
1554
+
1555
+ ```ts
1556
+ import { uuidv5, uuidv7, LETOPIS_NS } from 'letopis'
1557
+
1558
+ // id записи известен ДО создания: v5(Мастер, старт) — двойная бронь мертва самим id (§ 3.2)
1559
+ const bId = uuidv5(`v1.booking:entity:booking:${иван.id}:2026-08-01T07:00:00Z`)
1560
+ await db.запись(bId).first() // «занято?» — точечно, без обхода
1561
+ uuidv5('x') === uuidv5('x', LETOPIS_NS) // → true: ns по умолчанию
1562
+ uuidv7() // → '0199f3c1-…' — сортируемый по времени
1563
+ ```
1564
+
1565
+ Полный разбор правил генерации (v4 / v7 / v5, наследование правила) — [§ 3.2](#32-id-считает-схема-attributesid).
1566
+
1341
1567
  ### 11.1a up(opts)
1342
1568
 
1343
1569
  #### `up(opts: UpOpts): Promise<EntityDb>`
1344
1570
 
1345
1571
  | Параметр | Тип | Default | Описание |
1346
1572
  |---|---|---|---|
1347
- | `opts.dsn` | `string?` | `postgres://postgres:test@localhost:15432/letopis` | строка подключения; хост не `localhost` → docker-шаги пропускаются (только ожидание готовности) |
1573
+ | `opts.dsn` | `string?` | `postgres://postgres:test@localhost:15432/letopis` | строка подключения. Docker-шаги решает **живая проба**, не имя хоста: postgres по `dsn` уже отвечает → контейнер не поднимается (`postgres is up … — docker skipped`), базы нет → создаётся. Контейнер создаётся только если проба не прошла И хост локальный; для нелокального хоста — только ожидание готовности |
1348
1574
  | `opts.schema` | `string` | — | **базовое** имя схемы БЕЗ версии и точек (`'booking'`) |
1349
1575
  | `opts.version` | `number` | — | версия движка, целое ≥ 1: итоговая PG-схема `"v<N>.<schema>"`; бамп руками при breaking-изменении DDL |
1350
1576
  | `opts.container` | `string?` | `'letopis-timescale'` | имя dev-контейнера |
@@ -1355,7 +1581,7 @@ await iso.close(); await uc.close()
1355
1581
  | `opts.fresh` | `boolean?` | `false` | дропнуть схему и накатить заново — **данные схемы теряются** |
1356
1582
  | `opts.quiet` | `boolean?` | `false` | без `[letopis.up]`-прогресса в консоли |
1357
1583
  | `opts.waitTimeoutMs` | `number?` | `120 000` | максимум ожидания готовности (первый запуск: pull образа + initdb) |
1358
- | …остальные | `ConnectOpts` | — | `partition`/`account`/`enforceAcl`/`onQuery` и все опции `connect()` прокидываются насквозь |
1584
+ | …остальные | `ConnectOpts` | — | `partition`/`enforceAccount`/`enforceAcl`/`onQuery` и все опции `connect()` прокидываются насквозь; возвращённый `db` — такой же безличный корень (арендатора даёт `db.as()`, § 10.6) |
1359
1585
 
1360
1586
  **Назначение и алгоритм.** Одна точка входа: от пустой машины до готового `db`.
1361
1587
  (1) **Probe**: одна попытка `SELECT 1` по `dsn` — живой postgres (свой контейнер, CI-сервис,
@@ -1439,9 +1665,41 @@ import type {
1439
1665
  ### 11.2 EntityDb — корень
1440
1666
 
1441
1667
  `EntityDb` — Proxy: любое имя класса из `Schema` (id или алиас) — метод старта цепочки;
1442
- плюс фиксированные члены `begin/commit/rollback/batch/watch/close/registry/sql` и фасады
1668
+ плюс фиксированные члены `as/begin/commit/rollback/batch/watch/close/registry/sql` и фасады
1443
1669
  `accounts/credentials/resources/rules` (§ 11.8), `auth` (§ 11.9), `acl` (§ 11.10).
1444
1670
 
1671
+ #### `db.as(account: uuid | { id }, opts?: { owner? }): Promise<EntityDb>`
1672
+
1673
+ | Параметр | Тип | Default | Описание |
1674
+ |---|---|---|---|
1675
+ | `account` | `uuid \| { id }` | — | арендатор/субъект ЭТОГО вызова; пустое значение — ошибка `db.as(account) requires an account id` |
1676
+ | `opts.owner` | `uuid?` | = `account` | значение колонки `owner` для новых строк этого scope |
1677
+
1678
+ **Назначение и алгоритм.** Возвращает хендл **того же пула**, работающий от имени `account`:
1679
+ арендатор — свойство вызова, не подключения (в `connect()` опции `account` нет). Клонирует
1680
+ контекст (как `db.begin()`), нового соединения не открывает; под `enforceAcl` дополнительно
1681
+ читает **свой аккаунт** (один SELECT) и компилирует резолвер **под этого субъекта** — у каждого
1682
+ scope свой, решения между арендаторами не переиспользуются. Словарь `Resource`/`Rule` при этом
1683
+ общий: снимается один раз на подключение, сбрасывается `db.reloadSchema()`. Батчи с корня не наследуются: очередь
1684
+ планов, общая для разных арендаторов, была бы утечкой записи. `close()` закрывает пул, поэтому
1685
+ зовётся один раз — и с корня, и с любого scope (это одно и то же соединение).
1686
+
1687
+ Под `enforceAccount` (default `true`) чтение/запись возможны **только** с такого хендла:
1688
+ с безличного корня — ошибка с подсказкой (§ 10.6). В вебе scope живёт на запрос:
1689
+ `const t = await db.as(req.accountId)`.
1690
+
1691
+ **Примеры**
1692
+
1693
+ ```ts
1694
+ const db = await connect({ dsn, schema })
1695
+ const t1 = await db.as(salon1)
1696
+ const t2 = await db.as(salon2, { owner: managerId })
1697
+ await t1.Клиент().count() // только клиенты salon1
1698
+ await t2.Клиент().create({ name: 'X', phone: '+7…' }).rows() // account = salon2, owner = managerId
1699
+ await db.Клиент().count() // Error: enforceAccount is on — call db.as(account) …
1700
+ await db.close() // один раз на пул
1701
+ ```
1702
+
1445
1703
  #### `db.<Класс>(filter?: Filter): Chain`
1446
1704
 
1447
1705
  | Параметр | Тип | Описание |
@@ -1457,10 +1715,10 @@ import type {
1457
1715
  **Примеры**
1458
1716
 
1459
1717
  ```ts
1460
- await db.Услуга('…0000').first() // [3.5 ms] по id → Row {name: 'Стрижка 0', …}
1718
+ await db.Услуга('…0000').first() // [4.8 ms] по id → Row {name: 'Стрижка 0', …}
1461
1719
  await db.Услуга(['…0000', '…0006']).rows() // [2.6 ms] по списку → ['Стрижка 0', 'Бритьё 6']
1462
- await db.Организация(org).first() // [2.2 ms] Row-объект ≡ его id → 'Салон «Стрижка» №0'
1463
- 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 (фильтр по цене)
1464
1722
  await db.НетТакогоКласса().rows()
1465
1723
  // Error: letopis: unknown class "НетТакогоКласса". Known: Entity·Сущность, Org·Организация, …
1466
1724
  ```
@@ -1468,7 +1726,7 @@ await db.НетТакогоКласса().rows()
1468
1726
  **Кейс: одна сущность тремя формами фильтра**
1469
1727
 
1470
1728
  ```ts
1471
- 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'
1472
1730
  const поПолю = await db.Услуга({ name: 'Стрижка 0' }).first() // тот же Row
1473
1731
  const поОбъекту = await db.Услуга(поId).first() // Row как фильтр ≡ его id
1474
1732
  // все три → id '…0000', data.name = 'Стрижка 0' (цена — отдельным LINK «цена», § 11.9)
@@ -1489,10 +1747,10 @@ deadlock/serialization ошибка приходит сразу с подска
1489
1747
  **Примеры**
1490
1748
 
1491
1749
  ```ts
1492
- const tr = await db.begin() // [0.8 ms]
1750
+ const tr = await db.begin() // [1.0 ms]
1493
1751
  await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15 }).rows()
1494
- // [9.0 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
1495
- await tr.commit() // [3.7 ms] — теперь видно всем
1752
+ // [10 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
1753
+ await tr.commit() // [3.2 ms] — теперь видно всем
1496
1754
  ```
1497
1755
 
1498
1756
  **Кейс: атомарный перенос с откатом при провале** — § 11.6 (`tr.lock`), плюс rollback:
@@ -1518,7 +1776,7 @@ await db.цена(цУкл).first() // снаружи → amounts.RUB = 7
1518
1776
  **Примеры**
1519
1777
 
1520
1778
  ```ts
1521
- await db.commit(tr3) // [4.0 ms] — то же, что tr3.commit()
1779
+ await db.commit(tr3) // [3.4 ms] — то же, что tr3.commit()
1522
1780
  await db.commit()
1523
1781
  // Error: letopis: commit() needs a transaction: db.commit(tr) or tr.commit() [0.1 ms]
1524
1782
  ```
@@ -1574,7 +1832,7 @@ await stop()
1574
1832
  падают ошибкой postgres.js.
1575
1833
 
1576
1834
  ```ts
1577
- await db.close() // [1.3 ms]
1835
+ await db.close() // [2.0 ms]
1578
1836
  ```
1579
1837
 
1580
1838
  **Кейс** — завершение процесса: `close()` в `finally`/`SIGTERM`-хендлере после `stop()`
@@ -1596,7 +1854,7 @@ db.registry.resolve('запись') // [99 µs] → ClassDef {id: 'booking', a
1596
1854
 
1597
1855
  ```ts
1598
1856
  await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.salondemo"."Entity"')
1599
- // [37.3 ms] → [{ n: 980216 }]
1857
+ // [51 ms] → [{ n: 980216 }]
1600
1858
  ```
1601
1859
 
1602
1860
  **Кейс** — снятие плана тяжёлого запроса: `db.sql.unsafe('EXPLAIN (ANALYZE) …')` для
@@ -1628,8 +1886,8 @@ prev.links->>'Класс'`) кладётся точным равенством,
1628
1886
  **Примеры**
1629
1887
 
1630
1888
  ```ts
1631
- await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12 (обратный hop)
1632
- 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, прямой)
1633
1891
  ```
1634
1892
 
1635
1893
  **Кейс: маршрут «мастер → его навыки → услуги»** — см. `.run()` ниже (тот же прогон).
@@ -1646,7 +1904,7 @@ await db.навык().Услуга().count() // [65.2 ms] → 126
1646
1904
  **Примеры**
1647
1905
 
1648
1906
  ```ts
1649
- await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
1907
+ await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [23 ms]
1650
1908
  // → 2 пути (у Ольги 0.0 два навыка на разные услуги); первый:
1651
1909
  // [{
1652
1910
  // Мастер: { id: '00000003-…-000', class: 'Staff', data: { name: 'Ольга 0.0', phone: '+7 921 0000000', specialization: 'парикмахер' }, links: { Org: '00000001-…-000' }, … },
@@ -1658,7 +1916,7 @@ await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().ru
1658
1916
  **Кейс: отчёт «кто что умеет» одним запросом**
1659
1917
 
1660
1918
  ```ts
1661
- const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
1919
+ const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [23 ms]
1662
1920
  пути.map((p) => `${p.Мастер.data.name} → ${p.Услуга.data.name}`)
1663
1921
  // → ['Ольга 0.0 → Стрижка 0', 'Ольга 0.0 → Массаж 7']
1664
1922
  ```
@@ -1673,7 +1931,7 @@ const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().
1673
1931
  **Примеры**
1674
1932
 
1675
1933
  ```ts
1676
- const услуги = await db.Услуга().rows() // [11.1 ms] → 600 Row
1934
+ const услуги = await db.Услуга().rows() // [21 ms] → 600 Row
1677
1935
  // [0] = { id: '00000004-0000-4000-8000-000000000000', class: 'Service',
1678
1936
  // data: { name: 'Стрижка 0', duration: 30, description: 'популярное' },
1679
1937
  // links: { Org: '00000001-0000-4000-8000-000000000000' }, tags: [],
@@ -1683,7 +1941,7 @@ const услуги = await db.Услуга().rows() // [11.1 ms] → 600 Row
1683
1941
  **Кейс: пути vs уникальные сущности**
1684
1942
 
1685
1943
  ```ts
1686
- await db.навык().Услуга().count() // [65.2 ms] → 1260 путей (навык → услуга)
1944
+ await db.навык().Услуга().count() // [93 ms] → 1260 путей (навык → услуга)
1687
1945
  (await db.навык().Услуга().rows()).length // [83.6 ms] → 600 уникальных услуг
1688
1946
  ```
1689
1947
 
@@ -1692,10 +1950,10 @@ await db.навык().Услуга().count() // [65.2 ms] → 1260 пу
1692
1950
  Параметров нет. То же, что `rows()` с `LIMIT 1`: первая строка или `null`.
1693
1951
 
1694
1952
  ```ts
1695
- await db.Мастер({ name: 'Олег 0.7' }).first() // [5.6 ms]
1953
+ await db.Мастер({ name: 'Олег 0.7' }).first() // [8.6 ms]
1696
1954
  // → { id: '…0007', class: 'Staff', data: { name: 'Олег 0.7', phone: '+7 921 0000007', specialization: 'колорист' },
1697
1955
  // links: { Org: '…0000' }, tags: [], updated: '2025-08-01T00:00:00+00:00' }
1698
- await db.Мастер({ name: 'Гэндальф' }).first() // [5.7 ms] → null
1956
+ await db.Мастер({ name: 'Гэндальф' }).first() // [7.2 ms] → null
1699
1957
  ```
1700
1958
 
1701
1959
  **Кейс: проверка «занято ли окно» перед бронью** — § 11.6 (перечитка под локом).
@@ -1706,7 +1964,7 @@ await db.Мастер({ name: 'Гэндальф' }).first() // [5.7 ms] → nul
1706
1964
  (дешевле по трафику).
1707
1965
 
1708
1966
  ```ts
1709
- await db.Мастер().ids() // [4.3 ms] → 360 id
1967
+ await db.Мастер().ids() // [6.7 ms] → 360 id
1710
1968
  // ['00000003-0000-4000-8000-000000000000', '…0001', '00000003-0000-4000-8000-000000000002', …]
1711
1969
  ```
1712
1970
 
@@ -1722,17 +1980,17 @@ await db.Мастер().ids() // [4.3 ms] → 360 id
1722
1980
  для многошаговой — нет (см. кейс `.rows()`).
1723
1981
 
1724
1982
  ```ts
1725
- await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12
1726
- 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
1727
1985
  await db.Локация({ coordinates: { lat: gte(55.5) } }).count()
1728
- // [4.4 ms] → 26 — оператор на листе record-пути (глубина 2), каст numeric по Schema
1986
+ // [5.7 ms] → 26 — оператор на листе record-пути (глубина 2), каст numeric по Schema
1729
1987
  ```
1730
1988
 
1731
1989
  **Кейс: витрина каталога** — счётчики к фильтрам без выборки строк:
1732
1990
 
1733
1991
  ```ts
1734
- await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300 «быстрые»
1735
- 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 «премиум»
1736
1994
  ```
1737
1995
 
1738
1996
  #### `.limit(n): Chain` / `.offset(n): Chain`
@@ -1748,9 +2006,9 @@ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() //
1748
2006
  **Примеры**
1749
2007
 
1750
2008
  ```ts
1751
- 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]
1752
2010
  // → [{ note: 'базовая', RUB: 8100 }, { note: 'базовая', RUB: 8000 }, { note: 'базовая', RUB: 7900 }]
1753
- 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]
1754
2012
  // → [{ RUB: 7800 }, { RUB: 7700 }, { RUB: 7600 }] — вторая страница
1755
2013
  ```
1756
2014
 
@@ -1771,10 +2029,10 @@ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows()
1771
2029
  **Примеры**
1772
2030
 
1773
2031
  ```ts
1774
- await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms] → 8100, 8000, 7900
1775
- 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 по умолчанию
1776
2034
  // → [{ name: 'Стрижка 0', duration: 30 }, { name: 'Педикюр 4', duration: 30 }]
1777
- await db.запись().sort('updated', 'desc').limit(2).rows() // [6232.3 ms]
2035
+ await db.запись().sort('updated', 'desc').limit(2).rows() // [5726 ms]
1778
2036
  // ЧЕСТНО: DISTINCT ON всех ~440k сущностей класса без фильтра — см. § 14
1779
2037
  ```
1780
2038
 
@@ -1795,7 +2053,7 @@ await db.запись().sort('updated', 'desc').limit(2).rows() // [6232.3
1795
2053
  **Примеры**
1796
2054
 
1797
2055
  ```ts
1798
- const истор = await db.цена('…0009').versions() // [7.7 ms] (для t1 ниже)
2056
+ const истор = await db.цена('…0009').versions() // [9.1 ms] (для t1 ниже)
1799
2057
  await db.цена('…0009').asOf(истор[0].updated).first() // [3.0 ms]
1800
2058
  // → data.amounts.RUB = 2550 — цена ТОГДА
1801
2059
  await db.цена('…0009').first()
@@ -1817,7 +2075,7 @@ await db.цена('…0009').first()
1817
2075
  **Примеры**
1818
2076
 
1819
2077
  ```ts
1820
- await db.цена('…0009').versions() // [7.7 ms]
2078
+ await db.цена('…0009').versions() // [9.1 ms]
1821
2079
  // → [{ RUB: 2550, updated: '2025-08-23T…' }, { RUB: 2650, updated: '2025-11-21T…' }]
1822
2080
  await db.Клиент('…0931').versions() // жизнь с удалением и воскрешением:
1823
2081
  // → [{ name: 'Злата', updated: '…54.159646' },
@@ -1873,6 +2131,33 @@ for (;;) {
1873
2131
  }
1874
2132
  ```
1875
2133
 
2134
+ #### `.exact(): Chain`
2135
+
2136
+ Параметров нет.
2137
+
2138
+ **Назначение и алгоритм.** Снимает полиморфизм ТЕКУЩЕГО шага: в SQL уходит
2139
+ `class = '<свой>'` вместо `class = ANY('<свой>' + descendants)`. Нужен, когда наследование
2140
+ в домене — переиспользование `attributes`, а не «is-a» для выборки (§ 3.3). На классе без
2141
+ потомков — no-op. На запись не влияет: `create()` всегда пишет строго в свой класс.
2142
+
2143
+ **Примеры**
2144
+
2145
+ ```ts
2146
+ await db.Контрагент().count() // → 40367 Мастера + Клиенты (полиморфно)
2147
+ await db.Контрагент().exact().count() // → 0 abstract-класс своих строк не имеет
2148
+ await db.окно().count() // → 494008 смены + записи-наследники
2149
+ await db.окно().exact().count() // → 54005 именно смены
2150
+ await db.Мастер(и).окно().exact().rows() // смены мастера, без его броней
2151
+ ```
2152
+
2153
+ **Кейс: «занят ли мастер»** — смены и брони живут в одной иерархии, поэтому проверка
2154
+ доступности всегда берёт `.exact()` на окне и отдельный шаг на записи:
2155
+
2156
+ ```ts
2157
+ const смены = await db.Мастер(и).окно().exact().rows() // интервалы доступности
2158
+ const брони = await db.Мастер(и).запись().rows() // что уже занято
2159
+ ```
2160
+
1876
2161
  #### `.deep(max = 32): Chain`
1877
2162
 
1878
2163
  | Параметр | Тип | Описание |
@@ -1887,10 +2172,10 @@ for (;;) {
1887
2172
  **Примеры**
1888
2173
 
1889
2174
  ```ts
1890
- await db.Папка('…0000').Папка().deep().rows() // [9.2 ms]
2175
+ await db.Папка('…0000').Папка().deep().rows() // [22 ms]
1891
2176
  // → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 },
1892
2177
  // { name: 'Борода и усы', $depth: 2 }, { name: 'Уход', $depth: 3 }]
1893
- await db.Папка('…0000').Папка().deep(1).rows() // [7.4 ms] только прямые дети
2178
+ await db.Папка('…0000').Папка().deep(1).rows() // [15 ms] только прямые дети
1894
2179
  // → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 }]
1895
2180
  ```
1896
2181
 
@@ -1918,10 +2203,10 @@ const дерево = await db.Папка(корень).Папка().deep().rows(
1918
2203
  **Примеры**
1919
2204
 
1920
2205
  ```ts
1921
- await db.цена().sum('data.amounts.RUB') // [13.2 ms] → 2277000
1922
- await db.Услуга().avg('data.duration') // [3.0 ms] → 52.5
1923
- await db.Локация({}).sum('data.coordinates.lat') // [3.9 ms] лист record-пути (глубина 2) → 3327.925547539955
1924
- 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 (пусто)
1925
2210
  ```
1926
2211
 
1927
2212
  **Кейс: итог по каталогу без выгрузки строк** — `sum('data.amounts.RUB')` по 405 ценам за 4 ms;
@@ -1933,8 +2218,8 @@ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [5
1933
2218
  **числом**, string — строкой.
1934
2219
 
1935
2220
  ```ts
1936
- await db.цена().min('data.amounts.RUB') // [15.0 ms] → 300 (число, не '300')
1937
- 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
1938
2223
  ```
1939
2224
 
1940
2225
  **Кейс: границы ценового слайдера** — `min` + `max` двумя запросами по 3–4 ms.
@@ -1949,7 +2234,7 @@ await db.цена().max('data.amounts.RUB') // [14.8 ms] → 8100
1949
2234
  объекта = значения поля, значения = счётчики (по путям).
1950
2235
 
1951
2236
  ```ts
1952
- await db.запись().countBy('data.notes') // [3266.3 ms] — ~440k сущностей
2237
+ await db.запись().countBy('data.notes') // [1853 ms] — ~440k сущностей
1953
2238
  // → { 'подтверждена': 400000, 'по телефону: Вера': 3334, 'по телефону: Ольга': 3334, …,
1954
2239
  // 'по телефону: Марина': 3333 } — 12 имён «по телефону» по ~3333 (ручные брони ~9%)
1955
2240
  ```
@@ -1967,7 +2252,7 @@ await db.запись().countBy('data.notes') // [3266.3 ms] — ~440k сущ
1967
2252
 
1968
2253
  ```ts
1969
2254
  await db.Организация(org).alias('салон').Мастер({ name: 'Ольга 0.0' }).alias('мастер').run()
1970
- // [6.7 ms] → ключи пути: ['салон', 'мастер']
2255
+ // [13 ms] → ключи пути: ['салон', 'мастер']
1971
2256
  ```
1972
2257
 
1973
2258
  **Кейс: self-join читаемо** — `db.Папка(a).alias('родитель').Папка().alias('дочка').run()`.
@@ -1982,8 +2267,8 @@ await db.Организация(org).alias('салон').Мастер({ name: '
1982
2267
  кандидаты + перепроверка). В записи — модификатор значения.
1983
2268
 
1984
2269
  ```ts
1985
- await db.Клиент().tags('vip').count() // [24.7 ms] → 400 (vip-клиенты)
1986
- 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
1987
2272
  ```
1988
2273
 
1989
2274
  **Кейс: пометить и найти** — § 5 (вставка с `.tags(['vip','telegram'])`, поиск `tags('vip')`);
@@ -1995,13 +2280,15 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms]
1995
2280
  |---|---|---|
1996
2281
  | `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`; при `create()` — значение колонки |
1997
2282
 
1998
- **Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree). Под `enforceAccount`
1999
- чужой `.account()` — ошибка; под `enforceAcl` конфликт с пришпиленной правилом колонкой —
2000
- ошибка `acl pins` (§ 11.10).
2283
+ **Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree). Модификатор патчит
2284
+ **только свой шаг** — арендатор всей цепочки задаётся не им, а хендлом `db.as()` (§ 10.6):
2285
+ под `enforceAccount` он фильтрует каждый шаг сам, а чужой `.account()` — ошибка `pinned`.
2286
+ Под `enforceAcl` конфликт с пришпиленной правилом колонкой — ошибка `acl pins` (§ 11.10).
2287
+ Смысл `.account()` остаётся прежним: точечный фильтр/значение на админском хендле.
2001
2288
 
2002
2289
  ```ts
2003
2290
  await db.Организация().account(SYS).count() // [5.1 ms] → 30
2004
- await db.Организация().owner(SYS).count() // [3.9 ms] → 30
2291
+ await db.Организация().owner(SYS).count() // [5.5 ms] → 30
2005
2292
  ```
2006
2293
 
2007
2294
  **Кейс: чей это салон** — профиль владельца строки: `db.accounts.get(row.owner)`; выборка
@@ -2020,7 +2307,7 @@ await db.Организация().owner(SYS).count() // [3.9 ms] → 30
2020
2307
  `v: string | number | boolean | null` — «не равно» (`IS DISTINCT FROM` — null-безопасно).
2021
2308
 
2022
2309
  ```ts
2023
- await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms] → 570
2310
+ await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.3 ms] → 570
2024
2311
  ```
2025
2312
 
2026
2313
  **Кейс:** всё, кроме выбранного, — «другие услуги» под карточкой текущей.
@@ -2030,7 +2317,7 @@ await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms]
2030
2317
  `v: number | string` — строго больше / больше-или-равно (числа и сравнимые строки-даты).
2031
2318
 
2032
2319
  ```ts
2033
- await db.Услуга({ duration: gt(60) }).count() // [6.4 ms] → 150
2320
+ await db.Услуга({ duration: gt(60) }).count() // [4.4 ms] → 150
2034
2321
  await db.Услуга({ duration: gte(60) }).count() // [4.3 ms] → 300
2035
2322
  ```
2036
2323
 
@@ -2042,8 +2329,8 @@ await db.Услуга({ duration: gte(60) }).count() // [4.3 ms] → 300
2042
2329
  `v: number | string` — строго меньше / меньше-или-равно.
2043
2330
 
2044
2331
  ```ts
2045
- await db.Услуга({ duration: lt(45) }).count() // [5.1 ms] → 150
2046
- 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
2047
2334
  ```
2048
2335
 
2049
2336
  **Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 300 услуг.
@@ -2053,7 +2340,7 @@ await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300
2053
2340
  `a, b: number | string` — диапазон включительно (`a ≤ x ≤ b`).
2054
2341
 
2055
2342
  ```ts
2056
- await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 300
2343
+ await db.Услуга({ duration: between(40, 65) }).count() // [4.7 ms] → 300
2057
2344
  ```
2058
2345
 
2059
2346
  **Кейс:** слайдер длительности «40–65 минут» одной функцией вместо пары gte+lte.
@@ -2063,7 +2350,7 @@ await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 3
2063
2350
  `vs: (string | number)[]` — значение из списка (`IN`).
2064
2351
 
2065
2352
  ```ts
2066
- await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [4.4 ms] → 60
2353
+ await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [5.3 ms] → 60
2067
2354
  ```
2068
2355
 
2069
2356
  **Кейс:** сравнение выбранных чекбоксами услуг: имена из UI → один запрос.
@@ -2073,8 +2360,8 @@ await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).
2073
2360
  `s: string` — SQL-шаблон (`%` — любое, `_` — один символ); `ilike` — без учёта регистра.
2074
2361
 
2075
2362
  ```ts
2076
- await db.Услуга({ name: like('Стри%') }).count() // [4.3 ms] → 60
2077
- 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
2078
2365
  ```
2079
2366
 
2080
2367
  **Кейс:** живой поиск в админке — `ilike('%' + ввод + '%')` прощает регистр
@@ -2085,8 +2372,8 @@ await db.Услуга({ name: ilike('%массаж%') }).count() // [4.3 m
2085
2372
  `s: string` — начинается с / заканчивается на (сахар над `like(s+'%')` / `like('%'+s)`).
2086
2373
 
2087
2374
  ```ts
2088
- await db.Услуга({ name: starts('Массаж') }).count() // [3.7 ms] → 60
2089
- 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
2090
2377
  ```
2091
2378
 
2092
2379
  **Кейс:** префиксная навигация по названию: `starts('Массаж')` → все «Массаж N» (60 в каталоге).
@@ -2097,9 +2384,9 @@ await db.Услуга({ name: ends('7') }).count() // [2.9 ms] → 60
2097
2384
  идёт и в GIN-кандидаты). В демо-схеме массивов в `data` нет — операторы показаны на колонке `tags`.
2098
2385
 
2099
2386
  ```ts
2100
- await db.Клиент().tags(has('vip')).count() // [54.5 ms] → 400
2101
- await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
2102
- 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
2103
2390
  ```
2104
2391
 
2105
2392
  **Кейс:** сегменты по меткам: «vip ИЛИ из телеграма» = `tags(hasAny(['vip','telegram']))` → 800;
@@ -2110,8 +2397,8 @@ await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [11.0 ms
2110
2397
  `yes: boolean?` — поле присутствует (`true`, default) / отсутствует (`false`) в `data`.
2111
2398
 
2112
2399
  ```ts
2113
- await db.Услуга({ description: exists() }).count() // [3.7 ms] → 390
2114
- 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
2115
2402
  ```
2116
2403
 
2117
2404
  **Кейс:** контроль заполненности каталога — «услуги без ключа `description`» = `exists(false)` → 210
@@ -2122,7 +2409,7 @@ await db.Услуга({ description: exists(false) }).count() // [3.2 ms] →
2122
2409
  Без параметров — поле `NULL` **или** отсутствует.
2123
2410
 
2124
2411
  ```ts
2125
- await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
2412
+ await db.Услуга({ description: isNull() }).count() // [6.1 ms] → 390
2126
2413
  ```
2127
2414
 
2128
2415
  **Кейс:** отличие от `exists(false)` — на полигоне видно числом: `isNull()` → 390 (ловит и явный
@@ -2134,8 +2421,8 @@ await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
2134
2421
  `v: скаляр | Op` — отрицание; скаляр ≡ «не равно» (как `ne`).
2135
2422
 
2136
2423
  ```ts
2137
- await db.Услуга({ name: not(starts('Стрижка')) }).count() // [3.7 ms] → 540
2138
- 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
2139
2426
  ```
2140
2427
 
2141
2428
  **Кейс:** инверсия готового условия без переписывания: «всё, что НЕ стрижки» =
@@ -2147,7 +2434,7 @@ await db.Услуга({ name: not('Стрижка 0') }).count() // [3.
2147
2434
  обычное И).
2148
2435
 
2149
2436
  ```ts
2150
- 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
2151
2438
  ```
2152
2439
 
2153
2440
  **Кейс:** «Стрижка 0 или что-нибудь быстрое» — один запрос: Стрижка 0 (duration 30) уже среди
@@ -2182,11 +2469,11 @@ INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конф
2182
2469
  **Примеры**
2183
2470
 
2184
2471
  ```ts
2185
- await db.Организация().create({ name: 'Пилигрим' }).rows() // [11.1 ms] INSERT + defaults из Schema
2472
+ await db.Организация().create({ name: 'Пилигрим' }).rows() // [45 ms] INSERT + defaults из Schema
2186
2473
  // → [{ id: '019f5a53-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, links: {}, … }]
2187
2474
 
2188
2475
  await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
2189
- // [11.4 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
2476
+ // [14 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
2190
2477
 
2191
2478
  await db.Организация('…0901').Мастер().create({ id: '…0911', name: 'Мия', phone: '+7 909 000-09-11', specialization: 'массажист' }).rows()
2192
2479
  // [15.2 ms] контекст → links: { Org: '…0901' }
@@ -2198,7 +2485,7 @@ await db.Организация('…0901').Услуга().create({ name: 'Мас
2198
2485
  db.Услуга({ name: 'Массаж головы' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
2199
2486
  // Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
2200
2487
 
2201
- await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [9.9 ms] — ошибка НА ТЕРМИНАЛЕ:
2488
+ await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [11 ms] — ошибка НА ТЕРМИНАЛЕ:
2202
2489
  // ValidationError: letopis: validation failed for "Service":
2203
2490
  // The object '' contains forbidden keys: 'чепуха'.
2204
2491
 
@@ -2223,12 +2510,12 @@ db.Локация('…0921').Клиент() // [0.1 ms] недопустимы
2223
2510
  **Примеры**
2224
2511
 
2225
2512
  ```ts
2226
- 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]
2227
2514
  // → [{ id: '4907d8cd-…', notes: 'подтверждена' }]
2228
2515
  // момент фильтруется between(t, t): скаляр-eq по date-полю = строковый containment, потому диапазон
2229
2516
  // (сотни мс: поиск целей идёт по всему классу записей ~440k без btree по data->>'start_datetime' — § 14)
2230
2517
 
2231
- await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [600.4 ms] — ВСЕ записи в контексте
2518
+ await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [25 ms] — ВСЕ записи в контексте
2232
2519
  // → [{ id: '4907d8cd-…', notes: 'день закрыт' }, { id: 'e7f16a08-…', notes: 'день закрыт' }]
2233
2520
 
2234
2521
  await db.запись({ notes: 'нет-такого' }).update({ notes: 'x' }).rows() // → [] — update НИКОГДА не создаёт
@@ -2249,7 +2536,7 @@ await db.запись('…d091').update({ notes: 'подтверждена' }).u
2249
2536
  // → ['выполнена']; versions: [null, 'подтверждена', 'выполнена']
2250
2537
 
2251
2538
  // хвост-чтение после операции — в той же транзакции
2252
- await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [16.7 ms] → 1
2539
+ await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [23 ms] → 1
2253
2540
 
2254
2541
  // ОТКАТ: валидный update + невалидный create — весь план назад
2255
2542
  await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().create({ чепуха: 1 }).rows()
@@ -2257,7 +2544,7 @@ await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запис
2257
2544
 
2258
2545
  // fan-out: обновить клиента → снести ВСЕ его записи
2259
2546
  await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().delete({ confirm: true }).rows()
2260
- // [29.0 ms] → снесено 1 запись, с $deleted: true
2547
+ // [40 ms] → снесено 1 запись, с $deleted: true
2261
2548
  ```
2262
2549
 
2263
2550
  #### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
@@ -2318,13 +2605,13 @@ tombstone-версия → рекурсивное удаление зависи
2318
2605
  **Примеры**
2319
2606
 
2320
2607
  ```ts
2321
- await db.Клиент('…0931').delete().rows() // [14.7 ms] ПРЕВЬЮ — кандидаты живы:
2608
+ await db.Клиент('…0931').delete().rows() // [23 ms] ПРЕВЬЮ — кандидаты живы:
2322
2609
  // → [{ id: '…0931', class: 'Customer' }, { class: 'booking' }, { class: 'booking' }] — клиент + его записи (каскад)
2323
2610
  // после превью клиент жив: true
2324
2611
 
2325
- await db.Клиент('…0931').delete({ confirm: true }).rows() // [35.8 ms] — сервер нашёл зависимых:
2612
+ await db.Клиент('…0931').delete({ confirm: true }).rows() // [49 ms] — сервер нашёл зависимых:
2326
2613
  // → те же три, каждый с $deleted: true
2327
- await db.Клиент('…0931').delete({ confirm: true }).rows() // [6.8 ms] повторно → []
2614
+ await db.Клиент('…0931').delete({ confirm: true }).rows() // [49 ms] повторно → []
2328
2615
  ```
2329
2616
 
2330
2617
  **Кейс: отмена и воскрешение**
@@ -2352,7 +2639,7 @@ await db.Организация('…0901').Клиент().create({ id: cid, name
2352
2639
  **Примеры**
2353
2640
 
2354
2641
  ```ts
2355
- await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
2642
+ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [13 ms]
2356
2643
  // → [{ data: { name: '[erased]', phone: '[erased]', preferred_contact: 'phone' },
2357
2644
  // tags: ['anonymized'] }]
2358
2645
  ```
@@ -2360,7 +2647,7 @@ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7
2360
2647
  **Кейс: запрос на забвение**
2361
2648
 
2362
2649
  ```ts
2363
- await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
2650
+ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [13 ms]
2364
2651
  ;(await db.Клиент('…0931').versions()).map((r) => r.data.name) // → ['Злата', 'Злата', 'Злата', '[erased]']
2365
2652
  await db.Клиент().tags('anonymized').count() // все стёртые — под контролем
2366
2653
  ```
@@ -2381,10 +2668,10 @@ await db.Клиент().tags('anonymized').count() // вс
2381
2668
  **Примеры**
2382
2669
 
2383
2670
  ```ts
2384
- const tr = await db.begin() // [1.1 ms]
2671
+ const tr = await db.begin() // [1.0 ms]
2385
2672
  await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows() // цУкл — базовая цена «Укладки»
2386
2673
  await tr.цена(цУкл).first() // внутри → amounts.RUB = 9900
2387
- await tr.rollback() // [0.8 ms]
2674
+ await tr.rollback() // [1.1 ms]
2388
2675
  await db.цена(цУкл).first() // снаружи → amounts.RUB = 700, изменения нет
2389
2676
  ```
2390
2677
 
@@ -2406,7 +2693,7 @@ await db.цена(цУкл).first() // снаружи → amo
2406
2693
  **Примеры**
2407
2694
 
2408
2695
  ```ts
2409
- await trA.lock('booking', staffId, start) // [3.1 ms]
2696
+ await trA.lock('booking', staffId, start) // [1.7 ms]
2410
2697
  await db.lock('x')
2411
2698
  // Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.2 ms]
2412
2699
  ```
@@ -2418,7 +2705,7 @@ await db.lock('x')
2418
2705
  // id записи детерминирован (v5, § 3.2) — известен ДО создания:
2419
2706
  const bId = uuidv5(`v1.salondemo:entity:booking:${мастер.id}:${start}`)
2420
2707
  const trA = await db.begin(), trB = await db.begin()
2421
- await trA.lock('booking', мастер.id, start) // [3.1 ms] A первый
2708
+ await trA.lock('booking', мастер.id, start) // [1.7 ms] A первый
2422
2709
  const гонкаB = (async () => {
2423
2710
  await trB.lock('booking', мастер.id, start) // B ВИСИТ до конца trA
2424
2711
  const занято = await trB.запись(bId).first() // перечитка под локом
@@ -2474,7 +2761,7 @@ b.Мастер(m).окно().create({ start_datetime: '2026-08-05T07:00:00Z', en
2474
2761
  b.Мастер(m).окно().create({ start_datetime: '2026-08-05T09:00:00Z', end_datetime: '…11:00Z' }).Локация.set(loc).Расписание.set(sch)
2475
2762
  b.Мастер(m).окно().create({ start_datetime: '2026-08-05T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
2476
2763
  b.size() // [35 µs] → 3
2477
- await b.run() // [43.8 ms] — одна транзакция; окно — v5-класс → 3 честных INSERT, не склейка
2764
+ await b.run() // [63 ms] — одна транзакция; окно — v5-класс → 3 честных INSERT, не склейка
2478
2765
  // → [[{ id: '06f67c88-…', start_datetime: '2026-08-05T07:00:00.000Z' }], [{ …09:00 }], [{ …11:00 }]]
2479
2766
  // id каждого окна вычислен схемой: uuidv5(Staff, start_datetime) — § 3.2
2480
2767
  b.size() // → 0
@@ -2517,7 +2804,7 @@ await db.Мастер(m).окно({ start_datetime: between('2026-08-06T11:00:00
2517
2804
  `category` → `= ANY(categories)`.
2518
2805
 
2519
2806
  ```ts
2520
- 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 аккаунт
2521
2808
  ```
2522
2809
 
2523
2810
  **Кейс:** список арендаторов для биллинга: `find({ enabled: true })`, отключённые не в счёте.
@@ -2527,7 +2814,7 @@ await db.accounts.find({ category: 'Client', enabled: true }) // [2.4 ms] →
2527
2814
  `id: string` — точечный SELECT по PK.
2528
2815
 
2529
2816
  ```ts
2530
- await db.accounts.get(acc.id) // [1.9 ms] → Account | null
2817
+ await db.accounts.get(acc.id) // [3.2 ms] → Account | null
2531
2818
  ```
2532
2819
 
2533
2820
  **Кейс:** профиль владельца строки Entity: `db.accounts.get(row.owner)`.
@@ -2547,7 +2834,7 @@ await db.accounts.get(acc.id) // [1.9 ms] → Account | null
2547
2834
 
2548
2835
  ```ts
2549
2836
  const acc = await db.accounts.set({ categories: ['Client'], data: { название: 'ИП Ромашка' } })
2550
- // [5.4 ms] → { id: '06d3bbfe-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
2837
+ // [9.7 ms] → { id: '06d3bbfe-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
2551
2838
  // meta: {}, avatar: 'https://i.pravatar.cc/128?img=33', enabled: true, created: …, updated: … }
2552
2839
  await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [5.7 ms] update
2553
2840
  ```
@@ -2562,7 +2849,7 @@ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) //
2562
2849
 
2563
2850
  ```ts
2564
2851
  await db.accounts.delete(времId) // [16.4 ms] → true (пустой аккаунт)
2565
- await db.accounts.delete(SYS) // [6.0 ms]
2852
+ await db.accounts.delete(SYS) // [5.5 ms]
2566
2853
  // Error: update or delete on table "Account" violates foreign key constraint "entity_account_fk"
2567
2854
  ```
2568
2855
 
@@ -2577,9 +2864,13 @@ await db.accounts.delete(SYS) // [6.0 ms]
2577
2864
  Предохранители (до сноса): вызывать может лишь **Owner/System**; нельзя снести **последний enabled Owner**
2578
2865
  (лок-аут тенанта) и **свой** аккаунт сессии.
2579
2866
 
2867
+ Вызывать нужно **со scoped-хендла** — предохранители смотрят, кто именно зовёт:
2868
+
2580
2869
  ```ts
2581
- const dbSys = await connect({ dsn, schema: 'v1.notify', account: SYS })
2870
+ const dbSys = await (await connect({ dsn, schema: 'v1.notify' })).as(SYS)
2582
2871
  await dbSys.accounts.purge(tenantId) // → true: Entity + Account + Credential снесены, место освобождено
2872
+ // с безличного корня: Error: accounts.purge requires an authenticated caller — call it from
2873
+ // a scoped handle: (await db.as(caller)).accounts.purge(id)
2583
2874
  ```
2584
2875
 
2585
2876
  #### `db.schema.define(def)` / `db.reloadSchema()` — живой подхват правок схемы
@@ -2612,7 +2903,7 @@ await db.schema.define({ id: 'Coupon', alias: 'Купон', category: 'HUB',
2612
2903
  | `f.withDeleted` | `boolean?` | включить мягко-удалённые (default — только живые `deleted IS NULL`) |
2613
2904
 
2614
2905
  ```ts
2615
- await db.credentials.find({ account: acc.id }) // [3.2 ms] → 1 живой
2906
+ await db.credentials.find({ account: acc.id }) // [2.9 ms] → 1 живой
2616
2907
  await db.credentials.find({ account: acc.id, withDeleted: true }) // → 1 (после delete: 0 и 1)
2617
2908
  ```
2618
2909
 
@@ -2648,7 +2939,7 @@ await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 9
2648
2939
  освобождается для других аккаунтов (§ 11.9).
2649
2940
 
2650
2941
  ```ts
2651
- await db.credentials.delete(кред.id) // [4.6 ms] → true; find() больше не видит
2942
+ await db.credentials.delete(кред.id) // [4.0 ms] → true; find() больше не видит
2652
2943
  ```
2653
2944
 
2654
2945
  **Кейс:** отзыв api-ключа: `delete(credential.id)` → `verifyApiKey` мгновенно null
@@ -2669,10 +2960,10 @@ await db.credentials.delete(кред.id) // [4.6 ms] → true; find() боль
2669
2960
 
2670
2961
  ```ts
2671
2962
  await db.resources.set({ alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' } })
2672
- // [4.8 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
2673
- await db.resources.get('apiref.demo:API') // [2.6 ms] → тот же Resource
2674
- await db.resources.find({ category: 'API' }) // [1.1 ms] → 14 ресурсов
2675
- 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
2676
2967
  ```
2677
2968
 
2678
2969
  **Кейс:** полный словарь для нового тарифа — § 11.10 (6 ресурсов + 5 правил одним блоком).
@@ -2691,9 +2982,9 @@ await db.resources.delete('apiref.demo:API') // [5.2 ms] → true
2691
2982
 
2692
2983
  ```ts
2693
2984
  await db.rules.set({ account: 'apiref.demo:API', resource: 'apiref.demo:API', permission: 'allow', weight: 90 })
2694
- // [5.3 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
2695
- await db.rules.find({ resource: 'apiref.demo:API' }) // [2.7 ms] → 1
2696
- 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
2697
2988
  ```
2698
2989
 
2699
2990
  **Кейс:** временный бан группы: `set({ account: группа, resource: цель, permission: 'deny',
@@ -2725,7 +3016,7 @@ uuid-строку или объект с `.id`.
2725
3016
 
2726
3017
  ```ts
2727
3018
  await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', password: 'лето-2026!' })
2728
- // [87.5 ms] → Credential; в БД вместо пароля:
3019
+ // [105 ms] → Credential; в БД вместо пароля:
2729
3020
  // meta.password = "scrypt$32768$8$1$+yEMcUTJIO7xEu/oDUAMXA==$b6…"
2730
3021
  ```
2731
3022
 
@@ -2748,11 +3039,11 @@ await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', passw
2748
3039
  **Примеры**
2749
3040
 
2750
3041
  ```ts
2751
- 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]
2752
3043
  // → { account: { id: '06d3bbfe-…', categories: ['Client'], enabled: true, … },
2753
3044
  // credential: { category: 'PASSWORD', identifier: 'romashka@salon.io', … } }
2754
- await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [71.1 ms] → null
2755
- 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
2756
3047
  // незнакомый identifier — то же время (dummy-verify)
2757
3048
  ```
2758
3049
 
@@ -2762,11 +3053,11 @@ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' })
2762
3053
  await db.auth.setPassword({ account: acc, identifier: 'noconfirm@salon.io', password: 'пароль-77',
2763
3054
  category: 'EMAIL', confirmed: false })
2764
3055
  await db.auth.verifyPassword({ identifier: 'noconfirm@salon.io', password: 'пароль-77', category: 'EMAIL' })
2765
- // [71.1 ms] → null — кред не подтверждён
2766
- 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 }
2767
3058
  await db.accounts.set({ id: acc.id, enabled: false })
2768
3059
  await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
2769
- // [59.5 ms] → null — аккаунт выключен, пароль уже не важен
3060
+ // [109 ms] → null — аккаунт выключен, пароль уже не важен
2770
3061
  ```
2771
3062
 
2772
3063
  #### `db.auth.issueApiKey(a): Promise<{ key, credential }>`
@@ -2781,7 +3072,7 @@ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'ле
2781
3072
  Сам ключ возвращается **один раз**; утечка БД ключи не раскрывает.
2782
3073
 
2783
3074
  ```ts
2784
- 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]
2785
3076
  // key = 'lts_ca41bf2a3614c3f228be2551a2ba998db5285f1a2bc3b859' ← показать и забыть
2786
3077
  // в БД: identifier = '624b00355aba026f…' (sha256), meta = { name: 'касса-1', prefix: 'lts_ca41bf2a' }
2787
3078
  ```
@@ -2799,16 +3090,16 @@ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'к
2799
3090
  Быстрый (без scrypt): ключ высокоэнтропийный, подбор бессмыслен.
2800
3091
 
2801
3092
  ```ts
2802
- await db.auth.verifyApiKey(key) // [4.5 ms] → { account: 06d3bbfe…, credential }
3093
+ await db.auth.verifyApiKey(key) // [6.9 ms] → { account: 06d3bbfe…, credential }
2803
3094
  ```
2804
3095
 
2805
3096
  **Кейс: полный жизненный цикл ключа**
2806
3097
 
2807
3098
  ```ts
2808
- const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
2809
- 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 } — касса работает
2810
3101
  await db.credentials.delete(credential.id) // отзыв (мягкий)
2811
- await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновенно недействителен
3102
+ await db.auth.verifyApiKey(key) // [6.9 ms] → null — мгновенно недействителен
2812
3103
  ```
2813
3104
 
2814
3105
  #### `db.auth.issueKeySecret(a): Promise<{ key, secret, credential }>`
@@ -2820,7 +3111,7 @@ await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновен
2820
3111
  возвращается один раз.
2821
3112
 
2822
3113
  ```ts
2823
- 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]
2824
3115
  // key = 'f8ab4007e4570809'; secret = 'c25b8b906f69…' (48 hex, показан один раз)
2825
3116
  ```
2826
3117
 
@@ -2835,8 +3126,8 @@ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'ин
2835
3126
  **Алгоритм.** Кред по identifier = key, `timingSafeEqual(sha256(secret), meta.secret)`, ворота.
2836
3127
 
2837
3128
  ```ts
2838
- await db.auth.verifyKeySecret(key, secret) // [4.6 ms] → { account, credential }
2839
- 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
2840
3131
  ```
2841
3132
 
2842
3133
  **Кейс:** серверная интеграция (1С, платёжка): key хранится в конфиге открыто и светится
@@ -2878,8 +3169,8 @@ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz
2878
3169
 
2879
3170
  ```ts
2880
3171
  const код = totpCode(secret) // [659 µs] → '564517' (как в приложении)
2881
- await db.auth.verifyTotp({ account: acc, code: код }) // [8.1 ms] → true — фактор активирован
2882
- 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 отбит
2883
3174
  const прошлый = totpCode(secret, Date.now() - 30_000) // код прошлого шага (окно ±1)
2884
3175
  await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false — шаг ≤ lastStep
2885
3176
  ```
@@ -2889,8 +3180,8 @@ await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false
2889
3180
  ```ts
2890
3181
  const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [4.8 ms]
2891
3182
  await db.auth.totpEnabled(acc) // → false — QR показан, ждём подтверждения
2892
- await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [8.1 ms] → true
2893
- 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 — теперь требуем код при входе
2894
3185
  ```
2895
3186
 
2896
3187
  #### `db.auth.totpEnabled(account): Promise<boolean>`
@@ -2899,7 +3190,7 @@ await db.auth.totpEnabled(acc) // [2.2 ms] → true — те
2899
3190
  проверкой (`confirmed`). Приложение по нему решает, спрашивать ли второй фактор.
2900
3191
 
2901
3192
  ```ts
2902
- await db.auth.totpEnabled(acc) // [2.2 ms] → true
3193
+ await db.auth.totpEnabled(acc) // [2.0 ms] → true
2903
3194
  ```
2904
3195
 
2905
3196
  #### `totpCode(secretBase32, atMs = Date.now()): string` — экспорт модуля
@@ -2936,7 +3227,7 @@ totpCode('G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS') // [659 µs] → '564517'
2936
3227
 
2937
3228
  ```ts
2938
3229
  const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@salon.io', ttlSec: 600 })
2939
- // [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 }
2940
3231
  ```
2941
3232
 
2942
3233
  #### `db.auth.verifyOtp(a): Promise<AuthResult | null>`
@@ -2955,17 +3246,17 @@ const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@sa
2955
3246
  **Примеры**
2956
3247
 
2957
3248
  ```ts
2958
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [8.4 ms] → null (+1 попытка)
2959
- await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11.0 ms] → { account, credential }
2960
- 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 — сожжён
2961
3252
  ```
2962
3253
 
2963
3254
  **Кейс: сброс пароля**
2964
3255
 
2965
3256
  ```ts
2966
- 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]
2967
3258
  отправитьПисьмо(почта, code) // доставка — на приложении
2968
- const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11.0 ms]
3259
+ const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11 ms]
2969
3260
  if (кто) await db.auth.setPassword({ account: кто.account, identifier: почта, password: новый })
2970
3261
  // протухший код (ttl 1 s в прогоне): verifyOtp → null [9.3 ms]
2971
3262
  ```
@@ -2988,7 +3279,7 @@ if (кто) await db.auth.setPassword({ account: кто.account, identifier: п
2988
3279
 
2989
3280
  ```ts
2990
3281
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' } })
2991
- // [4.9 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
3282
+ // [12 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
2992
3283
  await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915' }) // id занят ДРУГИМ аккаунтом:
2993
3284
  // Error: duplicate key value violates unique constraint "credential_identity_udx" [3.9 ms]
2994
3285
  ```
@@ -3009,14 +3300,14 @@ await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915
3009
3300
 
3010
3301
  ```ts
3011
3302
  await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' })
3012
- // [5.2 ms] → { account: 06d3bbfe…, credential }
3303
+ // [5.3 ms] → { account: 06d3bbfe…, credential }
3013
3304
  ```
3014
3305
 
3015
3306
  **Кейс: вход через telegram-бота**
3016
3307
 
3017
3308
  ```ts
3018
3309
  // платформа подтвердила пользователя 777000111 (initData бота проверило приложение)
3019
- 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]
3020
3311
  if (!кто) { /* первая встреча: создать аккаунт + db.auth.link(…) */ }
3021
3312
  const token = await sess.start(кто.account) // дальше обычная сессия
3022
3313
  ```
@@ -3048,7 +3339,7 @@ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [148 µ
3048
3339
  `sess:acc:<accountId>` для `revokeAll`. Дамп Redis действующих токенов не раскрывает.
3049
3340
 
3050
3341
  ```ts
3051
- 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]
3052
3343
  // token = 'fe0a7e83db6fcd1aa2c5002988da2b353602837db3bc0a813eddac863b9a1944'
3053
3344
  // в Redis: 'sess:8198bf2cd8d2ef2376d…' и 'sess:acc:06d3bbfe-d9a2-4…'
3054
3345
  ```
@@ -3059,7 +3350,7 @@ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }
3059
3350
  (нет / истекла / отозвана). Суб-миллисекундный — на каждый HTTP-запрос.
3060
3351
 
3061
3352
  ```ts
3062
- await sess.check(token) // [0.7 ms]
3353
+ await sess.check(token) // [1.7 ms]
3063
3354
  // → { account: '06d3bbfe-…', meta: { device: 'iphone' }, created: '2026-07-13T07:14:45.810Z' }
3064
3355
  await sess.check(протухший) // [0.8 ms] → null (ttl 1 s истёк — Redis сам удалил)
3065
3356
  ```
@@ -3069,8 +3360,8 @@ await sess.check(протухший) // [0.8 ms] → null (ttl 1 s истёк
3069
3360
  `token: string` — `DEL` ключа + `SREM` из индекса; `false`, если сессии уже нет.
3070
3361
 
3071
3362
  ```ts
3072
- await sess.revoke(token) // [1.9 ms] → true
3073
- 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 — повторно
3074
3365
  ```
3075
3366
 
3076
3367
  #### `sessions.revokeAll(account): Promise<number>`
@@ -3079,19 +3370,19 @@ await sess.revoke(token) // [0.7 ms] → false — повторно
3079
3370
  сколько погашено.
3080
3371
 
3081
3372
  ```ts
3082
- await sess.revokeAll(acc) // [1.2 ms] → 2 — обе сессии (ipad + macbook) погасли
3373
+ await sess.revokeAll(acc) // [2.5 ms] → 2 — обе сессии (ipad + macbook) погасли
3083
3374
  ```
3084
3375
 
3085
3376
  **Кейс: полный вход — пароль → сессия → запрос → выход (реальный прогон)**
3086
3377
 
3087
3378
  ```ts
3088
3379
  const визит = await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
3089
- // [71.3 ms] → { account, credential }
3380
+ // [109 ms] → { account, credential }
3090
3381
  const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.2 ms]
3091
3382
  // … каждый запрос в middleware:
3092
- 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' }, … }
3093
3384
  // logout:
3094
- await sess.revoke(token) // [1.3 ms] → true
3385
+ await sess.revoke(token) // [3.9 ms] → true
3095
3386
  // «выйти со всех устройств» после смены пароля: await sess.revokeAll(визит.account)
3096
3387
  ```
3097
3388
 
@@ -3120,10 +3411,10 @@ NULL — все); объекты — `API`-ресурсы, чья маска п
3120
3411
  **Примеры**
3121
3412
 
3122
3413
  ```ts
3123
- await db.acl.check(acc, 'v2.booking.create') // [5.0 ms]
3414
+ await db.acl.check(acc, 'v2.booking.create') // [6.1 ms]
3124
3415
  // → { allow: true, rule: { account: 'apiref.client:ACCOUNT', resource: 'apiref.api.booking:API',
3125
3416
  // permission: 'allow', weight: 60, enabled: true } }
3126
- await db.acl.check(acc, 'v2.admin.stats') // [1.8 ms] — покрыло только дно-правило сида:
3417
+ await db.acl.check(acc, 'v2.admin.stats') // [2.5 ms] — покрыло только дно-правило сида:
3127
3418
  // → { allow: false, rule: { account: 'any:ACCOUNT', resource: 'any:API', permission: 'deny', weight: 0, … },
3128
3419
  // code: 403, message: 'Access denied - default for any ACCOUNT to any API' }
3129
3420
  ```
@@ -3151,18 +3442,18 @@ app.use(async (req, res, next) => {
3151
3442
  `booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
3152
3443
  ключи pattern (реальные колонки Entity, `"$account"` → id субъекта) возвращаются как
3153
3444
  `filter` — готовый предикат строк. Справочный метод (SQL за категориями аккаунта на каждый
3154
- вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный на connect
3445
+ вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный при `db.as()`
3155
3446
  (микросекунды, memo).
3156
3447
 
3157
3448
  **Примеры**
3158
3449
 
3159
3450
  ```ts
3160
- await db.acl.checkData(acc, 'booking', 'READ') // [2.7 ms]
3451
+ await db.acl.checkData(acc, 'booking', 'READ') // [2.3 ms]
3161
3452
  // → { allow: true, rule: { resource: 'apiref.booking.own:READ', weight: 60, … },
3162
3453
  // filter: { owner: '06d3bbfe-d9a2-4c1d-be44-04c40cb01108' } } ← $account подставлен
3163
- await db.acl.checkData(acc, 'Service', 'READ') // [1.9 ms]
3454
+ await db.acl.checkData(acc, 'Service', 'READ') // [2.1 ms]
3164
3455
  // → { allow: true, rule: { resource: 'apiref.service:READ', … } } ← безусловный (без filter)
3165
- await db.acl.checkData(acc, 'Org', 'READ') // [2.0 ms]
3456
+ await db.acl.checkData(acc, 'Org', 'READ') // [1.9 ms]
3166
3457
  // → { allow: false, message: 'no matching rule (deny by default)' }
3167
3458
  await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий connect]
3168
3459
  // → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: '06d3bbfe-…' } }
@@ -3172,33 +3463,44 @@ await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий
3172
3463
  **Кейс: enforceAcl — те же решения в SQL цепочек (реальный прогон)**
3173
3464
 
3174
3465
  ```ts
3175
- const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [66.9 ms] правила фиксируются
3176
- await uc.запись().rows() // [17.2 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
3177
- await uc.запись().count() // [17.6 ms] → 3 — честный count по суженному множеству
3178
- await uc.Услуга().count() // [15.1 ms] → 602 — безусловный allow, класс целиком
3466
+ // enforceAccount: false — показаны ACL-предикаты, а не изоляция арендатора (§ 10.6)
3467
+ const uc = await (await connect({ dsn, schema, enforceAcl: true, enforceAccount: false })).as(acc.id)
3468
+ // [58.1 ms] снимок правил под ЭТОГО субъекта (обновить — reloadSchema)
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, класс целиком
3179
3472
  await uc.Организация().rows()
3180
3473
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
3181
3474
  const [z] = await uc.запись().create({ start_datetime: t, end_datetime: e })
3182
- .Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [20.9 ms]
3475
+ .Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [16.2 ms]
3183
3476
  z.owner === acc.id // → true — owner пришпилен правилом
3184
3477
  await uc.запись().owner(SYS).create({ … }).rows()
3185
- // Error: letopis: acl pins booking writes to owner 06d3bbfe-… — на терминале [2.6 ms]
3186
- await uc.запись('чужой-id').create({ … }).rows() // [2.0 ms] — перехват чужого id мёртв: v5-класс id не принимает
3478
+ // Error: letopis: acl pins booking writes to owner 06d3bbfe-… — на терминале [1.8 ms]
3479
+ await uc.запись('чужой-id').create({ … }).rows() // [1.6 ms] — перехват чужого id мёртв: v5-класс id не принимает
3187
3480
  // Error: letopis: class "booking" computes id (uuid v5 from Staff, start_datetime) — remove the explicit id
3188
- await uc.запись(свойId).delete({ confirm: true }).rows() // [30.3 ms] → [{ id: 'ee65244b-…', $deleted: true }]
3481
+ await uc.запись(свойId).delete({ confirm: true }).rows() // [27.8 ms] → [{ id: 'ee65244b-…', $deleted: true }]
3189
3482
  // watch: события только безусловных allow-классов —
3190
3483
  // uc.watch(cb) поймал ['Service']; booking скрыт (предикат не проверить по payload)
3191
3484
  ```
3192
3485
 
3193
3486
  #### `db.acl.reload(): void`
3194
3487
 
3195
- Параметров нет. Сбрасывает кэш Resource/Rule фасада `db.acl` (следующий `check`/`checkData`
3196
- перечитает словарь). На **enforceAcl-цепочки не влияет** — их резолвер скомпилирован на
3197
- connect; подхватить новые правила = новый `connect()`. Реестр классов тоже фиксирован на
3198
- connect — новый класс в lineage-проверках увидит только новое подключение.
3488
+ Параметров нет. Сбрасывает кэш Resource/Rule **фасада `db.acl`** (следующий `check`/`checkData`
3489
+ перечитает словарь). На **enforceAcl-цепочки не влияет** — это отдельная подсистема.
3490
+
3491
+ **Два «reload», разные подсистемы — не путать:**
3492
+
3493
+ | Вызов | Что пересобирает | На что НЕ влияет |
3494
+ |---|---|---|
3495
+ | `db.acl.reload()` | кэш Resource/Rule фасада `db.acl` (`check`/`checkData`) | энфорсер `enforceAcl`-цепочек, реестр классов |
3496
+ | `await db.reloadSchema()` | реестр классов из `Schema` **и** снимок `Resource`/`Rule` подключения (следующий `db.as()` получит свежий словарь); со scope-хендла — **и** его энфорсер `enforceAcl` (§ 11.2) | кэш фасада `db.acl` (сбрасывать отдельно); уже созданные энфорсеры ДРУГИХ scope |
3497
+
3498
+ Реконнект нужен только для смены `dsn`/`schema`/`partition` и самих флагов `enforce*`
3499
+ (сменить арендатора реконнект НЕ требует — это новый `db.as()`).
3199
3500
 
3200
3501
  ```ts
3201
- db.acl.reload() // [187 µs]
3502
+ db.acl.reload() // [187 µs] — словарь фасада
3503
+ await db.reloadSchema() // классы + энфорсер цепочек, без реконнекта
3202
3504
  ```
3203
3505
 
3204
3506
  **Кейс:** админка сохранила правило → `reload()` в том же процессе, чтобы `check` следующего
@@ -3317,14 +3619,19 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
3317
3619
  | `duplicate link slot "X"` | один конец задан слотом дважды в одной записи |
3318
3620
  | `set() split into create()/update() (0.16.0)` | старый глагол записи — create() вставляет, update() версионирует найденное |
3319
3621
  | `slot .delete() renamed to .unset() (0.16.0)` | старое имя слот-снятия |
3320
- | `.link() removed (0.15.0)` | снесённый `.link()` — теперь слот `.Класс.set()` |
3321
3622
  | `execute() renamed to run() (0.11.0)` | старое имя терминала путей (и `batch.execute()`) |
3322
3623
  | `plan is queued in the batch — call batch.run()` | терминал на батч-цепочке с операциями |
3323
3624
  | `no path X → Y` / `LINK → LINK …` | недопустимый переход (синхронно при построении цепочки) |
3324
- | `Entity.account is NOT NULL…` | нет account и System-аккаунта |
3625
+ | `enforceAccount is on — call db.as(account) to name the tenant of this call…` | чтение/запись с безличного корня при включённой изоляции: назвать арендатора вызова `db.as()` либо взять админский `connect({ enforceAccount: false })` (§ 10.6) |
3626
+ | `enforceAccount is on — reads are pinned to account X` / `writes are pinned to…` | `.account(чужой)` на scope-хендле — подменить арендатора нельзя (§ 10.6) |
3627
+ | `db.as(account) requires an account id (uuid or { id })` | `db.as()` без аккаунта (пустая строка / объект без `id`) |
3628
+ | `enforceAcl is on — call db.as(account) to name the subject` | цепочка под `enforceAcl` без субъекта (§ 9.2) |
3629
+ | `Entity.account is NOT NULL…` | нет ни `.account()`, ни scope `db.as()`, ни System-аккаунта в схеме |
3325
3630
  | `violates foreign key constraint "entity_*_fk"` | несуществующий класс/аккаунт; удаление класса с данными |
3326
3631
  | `lock() works only inside db.begin()` | лок вне транзакции |
3327
3632
  | `commit() needs a transaction` | commit на корневом db |
3633
+ | `schema "X" has no "Schema" table …` | `connect()` получил БАЗОВОЕ имя (`'booking'`) вместо полного `'v1.booking'` — либа префикс не достраивает; в тексте ошибки перечислены схемы letopis этой БД |
3634
+ | `schema "X" has no classes for partition "Y"` | схема есть, но таблица `Schema` пуста для партиции — нужен сид хотя бы одного класса (§ 1.1) |
3328
3635
  | `letopis.up: bad schema name "X"` / `bad version` | `up()`: имя с точкой/версией либо version не целое ≥ 1 |
3329
3636
  | `letopis.up: docker CLI not found…` | `up()`: docker не установлен, а postgres на dsn не отвечает |
3330
3637
  | `letopis.up: postgres not ready in N s…` / `redis not ready` | `up()`: контейнер не поднялся за `waitTimeoutMs` (подсказка: `docker logs`) |
@@ -3345,29 +3652,63 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
3345
3652
  ## 14. Производительность
3346
3653
 
3347
3654
  Тайминги — живые прогоны демо на ЕДИНОМ полигоне `v1.salondemo` (`bench/salon-seed.mjs`,
3348
- ~980 000 строк: 440 000 записей ×2 версии, 600 услуг, 1026 цен, 1260 навыков). Статья
3349
- (`bench/salon-article-demo.mjs`), API Reference (`bench/api-reference-demo.mjs`) и бенчи
3350
- читают один и тот же полигон, мутируя лишь свои демо-сущности:
3655
+ 980 328 строк: 440 006 записей ×2 версии, 600 услуг, 1026 цен, 1260 навыков; 13 чанков
3656
+ гипертаблицы, диапазон `updated` — год). Статья (`bench/salon-article-demo.mjs`), API Reference
3657
+ (`bench/api-reference-demo.mjs`) и бенчи читают один и тот же полигон, мутируя лишь свои
3658
+ демо-сущности.
3659
+
3660
+ > **Цифры привязаны к размеру полигона — при его смене строки надо перемерять.** Тяжёлые сцены
3661
+ > линейны по числу строк, а обход графа — ещё и по числу чанков (см. ниже). Пример дрейфа:
3662
+ > строка про обход `запись→Мастер` показывала ≈6 с с версии 0.17.0, когда полигон был
3663
+ > 250 000 записей / 613 тыс. строк, и переезд на 440 006 записей / 980 328 строк её не обновил —
3664
+ > настоящее значение оказалось ≈30 с. Полигон обязан быть проанализирован (§ 14.1).
3351
3665
 
3352
3666
  | Операция | Время |
3353
3667
  |---|---|
3354
- | фильтр/`count()` по каталогу (`ne`/`gt`/`between`/`like`) | 5–8 ms |
3355
- | агрегация каталога `avg`/`min`/`max` | 5–15 ms |
3356
- | `sum('data.amounts.RUB')` (класс цена, 1026 вариантов) | ≈13 ms |
3357
- | `count()`/`rows()` каталога услуг (600) | 7–13 ms |
3358
- | цена `sort('data.amounts.RUB')` + keyset-страница `after(cursor)` | 19–31 ms |
3359
- | цепочка `навык→Услуга`, `count()` путей (1260) | ≈65 ms |
3360
- | `countBy('data.notes')` по 440k записям | ≈3.3 s |
3361
- | `sort('updated','desc').limit` по всему классу записей БЕЗ фильтра | ≈6 s |
3362
- | `count()` всех 440 000 записей БЕЗ фильтра | ≈3.4 s |
3363
- | обход `запись→Мастер` по всему классу записей | ≈6 s |
3364
- | запись новой версии (`create`/`update`), в т.ч. со слотами | 8–21 ms |
3365
-
3366
- Слабое место — выборка/обход **всего класса записей без фильтра** (`DISTINCT ON` по всем
3367
- 440 000 сущностям: секунды); лечится селективным фильтром, контекст-шагом или курсором
3368
- (keyset — миллисекунды даже на 440k). Каталог, агрегации и цепочки с фильтром — единицы—десятки ms.
3369
- TOAST-порог (data > 2KB): 0 строк.
3370
-
3668
+ | фильтр/`count()` по каталогу (`ne`/`gt`/`between`/`like`) | 4–7 ms |
3669
+ | агрегация каталога `avg`/`min`/`max` | 4–8 ms |
3670
+ | `sum('data.amounts.RUB')` (класс цена, 1026 вариантов) | ≈9 ms |
3671
+ | `count()`/`rows()` каталога услуг (600) | 6–21 ms |
3672
+ | цена `sort('data.amounts.RUB')` + keyset-страница `after(cursor)` | ≈17 ms |
3673
+ | фильтр `rows` limit 100 по 440k записям (`{notes: …}`) | ≈11 ms |
3674
+ | цепочка `навык→Услуга`, `count()` путей (1260) | ≈93 ms |
3675
+ | `countBy('data.notes')` по 440k записям | ≈1.9 s |
3676
+ | `count()` всех 440 000 записей БЕЗ фильтра | ≈1.4 s |
3677
+ | `sort('updated','desc').limit` по всему классу записей БЕЗ фильтра | ≈5.4 s |
3678
+ | обход `запись→Мастер` по всему классу записей | ≈30 s |
3679
+ | запись новой версии (`create`/`update`), в т.ч. со слотами | 8–25 ms |
3680
+
3681
+ Слабое место — выборка/обход **всего класса записей без фильтра**; лечится селективным
3682
+ фильтром, контекст-шагом или курсором. Причины у двух худших строк разные:
3683
+
3684
+ - `sort(…).limit` по классу (≈5.4 с) — `DISTINCT ON` обязан посчитать актуальную версию для
3685
+ всех 440 000 сущностей прежде, чем сортировать. Упирается в объём, планировщику тут нечего
3686
+ улучшать.
3687
+ - обход `запись→Мастер` (≈30 с) — forward-hop идёт коррелированным `JOIN LATERAL`: **одна
3688
+ индексная проба на каждую строку внешнего шага**, и каждая проба обходит ВСЕ чанки
3689
+ гипертаблицы (исключение по времени невозможно — ищем по `id`, не по `updated`). Отсюда
3690
+ модель стоимости: `строки внешнего шага × чанки`. Замерено: одна проба обоих шагов — 0.924 мс
3691
+ и 80 буферов (13 чанков × ~3 страницы × 2 шага), 440k проб → 17.7 млн обращений к буферам.
3692
+
3693
+ Форма запроса при этом **оптимальна**, а не «недоделана» — проверено замерами:
3694
+ PostgreSQL не может её ни раскоррелировать, ни мемоизировать (корреляция сидит внутри
3695
+ подзапроса с `DISTINCT ON`), и дело не в оценке кардинальности: подстановка класса литералом
3696
+ вместо параметра и `SET enable_nestloop = off` план не меняют — `Nested Loop` без `Memoize`,
3697
+ те же 17.7 млн буферов. Некоррелированный join обеих сторон по ключу измерен и оказался
3698
+ **хуже в 18 раз** (561 с, 48.1 млн буферов).
3699
+
3700
+ Лечится не переписыванием запроса, а стороной, с которой начата цепочка:
3701
+ `db.запись(id).Мастер()` — **7.5 мс**, `db.Мастер(id).запись()` — **11.7 мс** против 30 с
3702
+ у `db.запись().Мастер()`. (В самой БД проба ещё дешевле, 0.9 мс; остальное — сборка SQL и
3703
+ разбор ответа в клиенте.)
3704
+
3705
+ Каталог, агрегации и цепочки с фильтром — единицы—десятки ms. TOAST-порог (data > 2KB): 0 строк.
3706
+
3707
+ - **Цена полиморфизма.** Шаг по родителю сканирует всё семейство, поэтому дороже листа:
3708
+ на том же полигоне `db.окно().exact().count()` ≈ 82 мс (54 008), `db.окно().count()` ≈ 1.6 с
3709
+ (494 018), `db.Контрагент().count()` ≈ 0.23 с (40 371). Класс без потомков идёт быстрым
3710
+ путём (равенство вместо `ANY`) и не дорожает вовсе: `db.Услуга().count()` ≈ 6 мс.
3711
+ Тайминги в таблице выше сняты на классах-листьях и полиморфизмом не затронуты.
3371
3712
  - Containment и обход графа — GIN; операторы — на уже суженном наборе.
3372
3713
  - Начинайте цепочку с самого селективного шага (Организация/Мастер/Клиент, не `запись()`).
3373
3714
  - `count()` считает пути; число сущностей дешевле берётся `ids().length`.
@@ -3377,7 +3718,43 @@ TOAST-порог (data > 2KB): 0 строк.
3377
3718
  - Микро-бенч `npm run bench` (105k строк) + EXPLAIN-тесты (индексы обязаны быть в плане; ноль
3378
3719
  seq scan); партиционирование — `bench/dimensions.bench.mjs`, масштаб —
3379
3720
  `node bench/history.bench.mjs --entities=10000 --versions=100`, оверхед ACL —
3380
- `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 владеет всем классом).
3725
+
3726
+ ### 14.1 `ANALYZE` обязателен после массовой заливки
3727
+
3728
+ **Все тайминги выше верны только на проанализированной базе.** Без статистики те же запросы
3729
+ медленнее на порядок, и это самая дорогая ошибка эксплуатации из всех, что есть в этом
3730
+ руководстве.
3731
+
3732
+ `Entity` — гипертаблица: строки живут в чанках, у родительской таблицы своих строк нет. Пока
3733
+ `ANALYZE` по ней не прошёл, планировщик подставляет дефолт «200 уникальных `id`». Ядро всех
3734
+ чтений либы — semi-join с подзапросом кандидатов (`e.id IN (SELECT c.id FROM Entity c WHERE …)`),
3735
+ и на оценке 200 вместо сотен тысяч он выбирает `Nested Loop` там, где нужен `Merge Semi Join`.
3736
+
3737
+ Замерено на `v1.salondemo` (980k строк), один и тот же запрос:
3738
+
3739
+ | Сцена | без статистики | после `ANALYZE` | |
3740
+ |---|---|---|---|
3741
+ | `count()` класса под изоляцией арендатора | 57.6 s | **3.2 s** | ×18 |
3742
+ | фильтр `rows` limit 100 по 440k | 1371 ms | **11 ms** | ×126 |
3743
+ | `count()` класса без фильтра | 2981 ms | **1373 ms** | ×2.2 |
3744
+ | keyset-страница по классу | 5610 ms | 5425 ms | без изменений |
3745
+
3746
+ Выигрывают запросы **с предикатом** — там, где строится подзапрос кандидатов. Полные проходы
3747
+ по классу не меняются: они упираются в объём, а не в план.
3748
+
3749
+ Статистику `n_distinct` PostgreSQL считает корректно сам (на этом полигоне `-0.35`, то есть
3750
+ уникальных `id` ≈ 35% строк — сущность живёт в среднем в трёх версиях). Никаких подсказок
3751
+ (`ALTER COLUMN … SET (n_distinct = …)`) добавлять не нужно — нужен сам факт запуска.
3752
+
3753
+ - `up()` и `db/apply.mjs` делают `ANALYZE` при накате схемы сами.
3754
+ - **Своя массовая вставка — на вас**: после `COPY`, миграции или генератора данных вызовите
3755
+ `ANALYZE "v1.myapp"."Entity"` (на 1 млн строк ≈ 9 с). Автовакуум доберётся сам, но не сразу,
3756
+ и до этого приложение будет работать в разы медленнее без видимой причины.
3757
+ - Полезно и после массового `delete`/`purge`: доля живых строк меняется скачком.
3381
3758
 
3382
3759
  ---
3383
3760
 
@@ -3437,12 +3814,20 @@ await db.запись(bId).delete({ confirm: true }).rows() // отм
3437
3814
  ## 16. Тесты
3438
3815
 
3439
3816
  ```bash
3440
- cd lib && npm test # 167/167 тестов против живого docker (timescale + redis), по файлам:
3817
+ cd lib && npm test # весь набор против живого docker (timescale + redis); счёт кейсов — в CHANGELOG
3818
+ # актуального релиза. По файлам:
3441
3819
  # acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
3442
- # с $account, enforceAcl (предикаты в SQL, каскад, watch)
3820
+ # с $account, enforceAcl (предикаты в SQL, каскад, watch);
3821
+ # полиморфное чтение по иерархии + .exact(); deny на потомке
3822
+ # не обходится шагом по родителю (проверка на утечку)
3443
3823
  # api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
3444
3824
  # auth — db.auth: пароль/api-key/key-secret/TOTP/OTP/link+lookup,
3445
3825
  # глобальная идентичность, сессии на живом Redis (expire/revoke)
3826
+ # parity — паритет клиент ↔ триггер БД: один и тот же кейс исполняется
3827
+ # через либу и голым INSERT, оба обязаны решить одинаково
3828
+ # (abstract, неизвестный класс, обязательные/optional/союзные
3829
+ # концы, посторонняя связь) + закреплена известная асимметрия:
3830
+ # валидацию data делает только либа
3446
3831
  # plan — план-модель: несколько операций, fan-out, self-update,
3447
3832
  # превью/confirm delete, ОТКАТ плана, слот-перевес, батч-гард
3448
3833
  # resilience — ретраи 40P01/40001, реальный deadlock двух транзакций,
@@ -3459,9 +3844,12 @@ cd lib && npm test # 167/167 тестов против живого docker (t
3459
3844
  # свои сиды/seeds:false, автосоздание базы, валидация
3460
3845
  # salon — ТЕСТ-ПЛАН: имитация салона, ВСЕ 145 публичных API
3461
3846
  # (21 акт + матрица покрытия); слоты/pivot/entity
3462
- # wave2 — asOf/versions, keyset-курсор, gen-types, enforceAccount, anonymize
3847
+ # wave2 — asOf/versions, keyset-курсор, enforceAccount, anonymize; gen-types:
3848
+ # форма фасада, слоты, отсутствие снесённых имён + compile-гейт (tsc)
3463
3849
  # wave3 — or/not, агрегации, deep, watch
3464
3850
  # wave4 — compression-политики (чтение сжатого чанка), schema-sync, onQuery
3851
+ # wave5 — физический purge (двухфазность, замыкание, accounts.purge),
3852
+ # withDeleted, живой подхват схемы (schema.define/reloadSchema)
3465
3853
  npm run bench # производительность на 105k строк (§ 14)
3466
3854
  ```
3467
3855