letopis 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
 
@@ -781,6 +867,33 @@ await db.Мастер(m).окно().delete({ confirm: true }).rows() // кон
781
867
  (+advisory-lock). История неприкосновенна; повторный delete → `[]`; `create()` с тем же
782
868
  id — воскрешение.
783
869
 
870
+ ### `.purge({ confirm })` — ФИЗИЧЕСКИЙ hard-erase (необратимо)
871
+
872
+ ```ts
873
+ await db.Клиент(cid).delete({ confirm: true }).rows() // 1) мягко: tombstone (обратимо)
874
+ await db.Клиент(cid).purge().rows() // превью замыкания (что сотрётся), БД цела
875
+ await db.Клиент(cid).purge({ confirm: true }).rows() // 2) физически: tombstone + всё поддерево (все версии)
876
+ ```
877
+
878
+ > **⚠ На живой сущности `.purge()` возвращает `[]` — БЕЗ ошибки.** Гейт двухфазный: пустой
879
+ > результат значит «нечего стирать, сначала `.delete({confirm:true})`», а НЕ «стёрто».
880
+ > Не считайте `[]` подтверждением: проверяйте `.withDeleted().first()` → `null`.
881
+
882
+ **Двухфазно:** `.purge()` работает ТОЛЬКО по уже логически удалённому (tombstone) — живую сущность
883
+ не трогает (сначала `.delete()`). Историю, в отличие от `.delete()`, НЕ сохраняет. Внутри — серверная
884
+ `purge(partition,class,id)`: `SET LOCAL letopis.purge='on'` отключает append-only-триггер (через `WHEN`)
885
+ → плоский физический `DELETE` замыкания (детерминированно, без вложенного DML). Голый SQL:
886
+ `SELECT * FROM "v1.notify".purge('entity','Клиент','…')` (или `…,true)` — dry-превью).
887
+
888
+ ### `.withDeleted()` — включить удалённые (tombstone) в выдачу
889
+
890
+ ```ts
891
+ await db.Клиент(cid).rows() // только живые
892
+ await db.Клиент(cid).withDeleted().rows() // + tombstone (последний шаг)
893
+ ```
894
+
895
+ Снимает **только** фильтр `deleted IS NULL`; изоляция арендатора (`enforceAccount`) и ACL (`enforceAcl`) действуют.
896
+
784
897
  ### Откат плана
785
898
 
786
899
  ```ts
@@ -855,7 +968,8 @@ await db.rules.set({ account: 'authenticated:ACCOUNT', resource: 'shop:API', per
855
968
  ```
856
969
 
857
970
  Связка с Entity: `account`/`owner` NOT NULL, default — System-аккаунт; фильтры и значения —
858
- модификаторы `.account()/.owner()`; профиль владельца — `db.accounts.get(row.owner)`.
971
+ хендл `db.as(account, { owner })` (§ 10.6) либо точечные модификаторы `.account()/.owner()`;
972
+ профиль владельца — `db.accounts.get(row.owner)`.
859
973
  Сид `lib/sql/seed.auth.sql`: System + 16 Resource + 12 Rule.
860
974
 
861
975
  ### 9.1 Вход: `db.auth` — много кредов на аккаунт
@@ -973,13 +1087,15 @@ await db.acl.checkData(user, 'Org', 'READ')
973
1087
  db.acl.reload() // сброс кэша Resource/Rule фасада
974
1088
  ```
975
1089
 
976
- **`connect({ account, enforceAcl: true })`** — те же решения в цепочках: каждый шаг —
1090
+ **`connect({ enforceAcl: true })` + `db.as(субъект)`** — те же решения в цепочках: каждый шаг —
977
1091
  `READ`, `create()`/`update()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
978
1092
  (deny в замыкании откатывает транзакцию); `watch()` отдаёт события только безусловных
979
- allow-классов (payload нечем проверить предикат). Правила фиксируются на connect.
1093
+ allow-классов (payload нечем проверить предикат). Субъекта даёт `db.as()`: правила снимаются
1094
+ и компилятся в memo-резолвер **на каждый scope** (у своего аккаунта — свой резолвер, ключ memo
1095
+ субъекта не содержит); правки `Resource`/`Rule` подхватывает `db.reloadSchema()` (§ 11.10).
980
1096
 
981
1097
  ```ts
982
- const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
1098
+ const u = await (await connect({ dsn, schema, enforceAcl: true })).as(user.id)
983
1099
  await u.запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
984
1100
  await u.запись().count() // честный count по суженному множеству
985
1101
  await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
@@ -989,22 +1105,27 @@ await u.запись(чужаяId).update({ notes: '…' }).rows() // → []
989
1105
  // create той же v5-пары (id чужой записи) тоже НЕ перехватывает: → [] вместо новой версии
990
1106
  ```
991
1107
 
992
- Оверхед (полигон `v1.salondemo` ~980k, booking 440k×2; p50 из 20; `bench/acl.bench.mjs`):
1108
+ Оверхед (полигон `v1.salondemo` ~980k, booking 440k×2; p50 из 20; `bench/acl.bench.mjs`;
1109
+ все три хендла с `enforceAccount: false` — мерится цена **авторизации**, изоляция арендатора
1110
+ считается отдельно в § 10.6; полигон **проанализирован**, см. § 14.1):
993
1111
 
994
1112
  | Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
995
1113
  |---|---|---|---|
996
- | точечный `first(id)` | 2.9 ms | 4.2 ms | 6.1 ms |
997
- | фильтр `rows` limit 100 | 1394 ms | 1454 ms | 1362 ms |
998
- | keyset-страница всего класса 440k | 5495 ms | 5576 ms | 5364 ms |
999
- | `count()` класса 440k | 3196 ms | 3049 ms | 2874 ms |
1000
- | цепочка `запись(id).Услуга()` | 5.5 ms | 6.6 ms | 5.1 ms |
1001
-
1002
- Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат здесь на
1003
- селективности 100% (все строки booking — System-аккаунт, предикат никого не отсекает) —
1004
- на тяжёлых сценах ACL в пределах шума с базой; в реальности предикат СУЖАЕТ выборку,
1005
- и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
1006
- `db.acl.checkData` — справочный (1.0 ms: SQL за категориями аккаунта на каждый вызов);
1007
- горячий путь цепочек использует резолвер, скомпилированный на connect (микросекунды, memo).
1114
+ | точечный `first(id)` | 4.5 ms | 4.7 ms | 4.6 ms |
1115
+ | фильтр `rows` limit 100 | 10.9 ms | 11.1 ms | 11.4 ms |
1116
+ | keyset-страница всего класса 440k | 5425 ms | 5146 ms | 5305 ms |
1117
+ | `count()` класса 440k | 1373 ms | 1262 ms | 1392 ms |
1118
+ | цепочка `запись(id).Услуга()` | 6.0 ms | 6.4 ms | 5.8 ms |
1119
+
1120
+ **ACL бесплатен на всех сценах** — отличия в пределах шума измерения (местами колонка с ACL
1121
+ даже «быстрее» базы). Решение берётся из memo, SQL безусловного правила совпадает с базовым.
1122
+ *Предикат здесь на селективности 100% (все строки booking — System-аккаунт, предикат никого
1123
+ не отсекает); в реальности он СУЖАЕТ выборку, и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
1124
+ `db.acl.checkData` — справочный (1.7 ms: SQL за категориями аккаунта на каждый вызов);
1125
+ горячий путь цепочек использует резолвер, скомпилированный при `db.as()` (микросекунды, memo).
1126
+ Цена самого `db.as()` под `enforceAcl` — один параллельный запрос за аккаунт + Resource/Rule
1127
+ (≈ те же 50 ms, что раньше платил `connect`), поэтому scope стоит держать на запрос, а не на
1128
+ цепочку. Без `enforceAcl` `db.as()` бесплатен: клон контекста, без SQL и без нового соединения.
1008
1129
 
1009
1130
  `enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
1010
1131
 
@@ -1057,7 +1178,7 @@ const стр2 = await db.запись().sort('updated', 'desc') // можно
1057
1178
  .after(кур).limit(50).rows()
1058
1179
 
1059
1180
  // по data-пути ЛЮБОЙ глубины — field тот же, что в sort
1060
- const дальше = await db.окно().sort('data.start_datetime')
1181
+ const дальше = await db.окно().exact().sort('data.start_datetime')
1061
1182
  .after(cursorOf(окно, 'data.start_datetime')).limit(20).rows()
1062
1183
 
1063
1184
  // бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
@@ -1083,7 +1204,7 @@ do {
1083
1204
  await db.цена().sum('data.amounts.RUB') // сумма всех прайсов (record-лист): number | null
1084
1205
  await db.Услуга().avg('data.duration') // среднее
1085
1206
  await db.Услуга().countBy('data.duration') // { '30': 2, '60': 1 } ({} на пустом)
1086
- await db.окно().min('data.start_datetime') // min/max — каст по типу поля из Schema
1207
+ await db.окно().exact().min('data.start_datetime') // min/max — каст по типу поля из Schema
1087
1208
  ```
1088
1209
 
1089
1210
  Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или вложенный лист
@@ -1124,18 +1245,54 @@ const stop = await db.watch('запись', onEvent, {
1124
1245
  })
1125
1246
  ```
1126
1247
 
1127
- ### 10.6 Изоляция арендатора: `enforceAccount`
1248
+ ### 10.6 Изоляция арендатора: `db.as()` + `enforceAccount`
1249
+
1250
+ **Арендатор — свойство ВЫЗОВА, а не подключения.** Один пул обслуживает разных арендаторов,
1251
+ поэтому в `connect()` опции `account` нет: подключение безличное, а имя даёт `db.as(account)` —
1252
+ хендл того же пула, работающий от имени этого аккаунта.
1128
1253
 
1129
1254
  ```ts
1130
- const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
1131
- await db.запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
1132
- await db.Клиент().create({ name: 'X', phone: '+7…' }) // запись пришпилена к account
1133
- db.запись().account(чужой).rows() // ошибка: reads are pinned to account …
1255
+ const db = await connect({ dsn, schema }) // пул; enforceAccount по умолчанию TRUE
1256
+ const t = await db.as(tenantId) // идентичность вызова (в вебе — на запрос)
1257
+
1258
+ await t.запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
1259
+ await t.Клиент().create({ name: 'X', phone: '+7…' }) // запись пришпилена к account
1260
+ await t.запись().account(чужой).rows() // ошибка: reads are pinned to account …
1261
+
1262
+ await db.запись().rows() // ошибка: enforceAccount is on — call db.as(account) …
1134
1263
  ```
1135
1264
 
1136
1265
  Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
1137
- больше не утечка данных соседнего салона. Это простой флаг «всё по одному арендатору»;
1138
- гранулярные права (по классам, операциям, со срезами строк правилами) — ACL § 9.2.
1266
+ больше не утечка данных соседнего салона, а забытый `db.as()` — не тихая выдача всего, а
1267
+ внятная ошибка. Это простой флаг «всё по одному арендатору»; гранулярные права (по классам,
1268
+ операциям, со срезами строк правилами) — ACL § 9.2 (субъекта ему даёт тот же `db.as()`).
1269
+
1270
+ `db.as()` дешёв: клон контекста на том же пуле, без нового соединения и без SQL (под
1271
+ `enforceAcl` — плюс компиляция правил под этого субъекта, § 9.2). Хендлы можно держать
1272
+ сколько нужно, `close()` зовётся один раз на пул. Второй аргумент задаёт `owner` новых
1273
+ строк: `db.as(account, { owner })` (по умолчанию `owner` = `account`).
1274
+
1275
+ **Цена изоляции — отрицательная в реальной многоарендаторной базе.** Предикат `account`
1276
+ попадает не только в перепроверку, но и в подзапрос кандидатов, то есть **сужает выборку по
1277
+ индексу до основного скана**. Замерено на полигоне из 10 арендаторов (1 млн версий, класс
1278
+ `Customer`, `count()` — с сужением против без него):
1279
+
1280
+ | Доля арендатора в классе | с сужением | без сужения |
1281
+ |---|---|---|
1282
+ | 50 % | 936 ms | 800 ms |
1283
+ | 10 % | 314 ms | 785 ms |
1284
+ | 1 % | 87 ms | 800 ms |
1285
+ | 0.3 % | 28 ms | 773 ms |
1286
+
1287
+ Чем мельче арендатор, тем больше выигрыш: на 0.3 % класса изоляция ускоряет чтение в **27 раз**.
1288
+ Проигрывает она только в вырожденном случае, когда один арендатор владеет почти всем классом
1289
+ (на `v1.salondemo` System владеет 100 %, и там `count()` под изоляцией — 2.6 с против 1.3 с).
1290
+ Вывод практический: чем больше у вас арендаторов, тем выгоднее держать изоляцию включённой.
1291
+ Оба замера сняты на проанализированной базе — без `ANALYZE` эта же сцена давала 54 с (§ 14.1).
1292
+
1293
+ Административный доступ (миграции, дев-скрипты, отчёты по всем арендаторам) — явным
1294
+ `connect({ …, enforceAccount: false })`: там `db` читает и пишет без scope, а колонку
1295
+ `account` закрывает System-аккаунт схемы.
1139
1296
 
1140
1297
  ### 10.7 Анонимизация (GDPR): `.anonymize()`
1141
1298
 
@@ -1144,9 +1301,11 @@ await db.Клиент(id).anonymize(['name', 'phone']).rows()
1144
1301
  // новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
1145
1302
  ```
1146
1303
 
1147
- Физического стирания НЕТ — история священна: старые версии хранят PII до retention-политики
1148
- (§ 10.8). Полное «право на забвение» = `anonymize()` сейчас + настроенный retention потом.
1149
- Не-string поле в списке — ошибка (типы сверяются по Schema).
1304
+ Физического стирания `anonymize()` НЕ делает — история священна: старые версии хранят PII до
1305
+ retention-политики (§ 10.8). Три уровня «права на забвение»: `anonymize()` (затереть PII, запись
1306
+ живёт) → retention (снос по времени) → **`.purge({ confirm })`** — немедленный физический hard-erase
1307
+ удалённого (tombstone) + всего поддерева, а `db.accounts.purge(id)` — целого тенанта (см. § 11).
1308
+ Не-string поле в списке `anonymize` — ошибка (типы сверяются по Schema).
1150
1309
 
1151
1310
  ### 10.8 Политики хранения: `db/policies.mjs`
1152
1311
 
@@ -1251,7 +1410,7 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
1251
1410
  **живой прогон** на едином полигоне `v1.salondemo` ~980 000 строк Entity (год окон-смен и
1252
1411
  записей: 440 000 записей-наследников окон ×2 версии, каталог из 600 услуг, 360 мастеров,
1253
1412
  40 000 клиентов, 1026 цен, 1260 навыков); воспроизводитель — `bench/api-reference-demo.mjs` (только
1254
- читает полигон salon-seed, мутирует лишь свои сущности). Тот же полигон — под статьёй SALON.md.
1413
+ читает полигон salon-seed, мутирует лишь свои сущности).
1255
1414
  id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; повторяющиеся
1256
1415
  `account`/`owner`/`partition` в ответах опущены.
1257
1416
 
@@ -1272,21 +1431,23 @@ id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; по
1272
1431
  | `opts.dsn` | `string` | — | строка подключения `postgres://user:pass@host:port/db` |
1273
1432
  | `opts.schema` | `string` | — | ПОЛНОЕ имя PG-схемы с версией движка: `'v1.booking'` (создаёт `up({schema: 'booking', version: 1})` либо `db/apply.mjs --schema=booking --version=1`; либа префикс не достраивает) |
1274
1433
  | `opts.partition` | `string?` | `'entity'` | партиция данных: все чтения/записи этого подключения живут в ней |
1275
- | `opts.account` | `uuid?` | System-аккаунт схемы | default-`account` (арендатор) новых строк |
1276
- | `opts.owner` | `uuid?` | = `account` | default-`owner` (владелец) новых строк |
1277
1434
  | `opts.max` | `number?` | `10` | размер пула соединений postgres.js |
1278
- | `opts.enforceAccount` | `boolean?` | `false` | жёсткая изоляция арендатора (§ 10.6): каждый шаг чтения фильтруется `account = opts.account`, записи пришпилены; явный чужой `.account()` — ошибка |
1279
- | `opts.enforceAcl` | `boolean?` | `false` | ACL по Resource/Rule (§ 9.2): READ на каждый шаг, WRITE/DELETE на записи, предикаты строк в SQL заранее; **требует `account`**; deny-by-default |
1435
+ | `opts.enforceAccount` | `boolean?` | **`true`** | жёсткая изоляция арендатора (§ 10.6): каждый шаг чтения фильтруется по `account` scope-хендла, записи пришпилены; явный чужой `.account()` — ошибка; чтение/запись **с безличного корня — ошибка** с подсказкой на `db.as()` |
1436
+ | `opts.enforceAcl` | `boolean?` | `false` | ACL по Resource/Rule (§ 9.2): READ на каждый шаг, WRITE/DELETE на записи, предикаты строк в SQL заранее; субъект — аккаунт `db.as()`; deny-by-default. Выключенный печатает предупреждение один раз на процесс |
1280
1437
  | `opts.onQuery` | `((e: QueryEvent) => void)?` | — | хук на каждый запрос цепочки: `{mode, classes, ms, rows, slow}` (§ 10.10) |
1281
1438
  | `opts.slowMs` | `number?` | — | порог медленного запроса: `ms > slowMs` → `e.slow = true`; если `onQuery` не задан — `console.warn` |
1282
1439
 
1283
1440
  **Назначение и алгоритм.** Открывает пул postgres.js (timestamptz парсится **строкой**,
1284
1441
  чтобы не терять микросекунды в `asOf`/курсорах), одним SELECT загружает реестр классов из
1285
1442
  `Schema` (компилируя fastest-validator на класс), резолвит System-аккаунт как fallback для
1286
- NOT NULL `Entity.account`. При `enforceAcl: true` дополнительно параллельно читает аккаунт,
1287
- все Resource и включённые Rule и **компилирует синхронный резолвер решений** (класс,
1288
- операция) → `AclDecision` с memo — правила фиксируются на весь срок жизни подключения.
1289
- Реестр классов тоже фиксируется: новый класс в `Schema` увидит только новый `connect()`.
1443
+ NOT NULL `Entity.account`. Подключение **безличное**: арендатора и субъекта ACL называет
1444
+ `db.as(account)` (§ 10.6) — опций `account`/`owner` в `ConnectOpts` нет. При `enforceAcl: true`
1445
+ каждый `db.as()` параллельно читает свой аккаунт, все Resource и включённые Rule и
1446
+ **компилирует синхронный резолвер решений** (класс, операция) → `AclDecision` с memo.
1447
+ Реестр классов снимается один раз на `connect()` — но НЕ до конца жизни подключения: правки
1448
+ в `Schema` / `Resource` / `Rule` подхватываются `db.reloadSchema()` без реконнекта (со
1449
+ scope-хендла пересобирает и реестр, и его ACL-резолвер — [§11.2](#112-entitydb--корень)).
1450
+ Реконнект нужен только для смены `dsn`/`schema`/`partition` и флагов `enforce*`.
1290
1451
 
1291
1452
  **Примеры**
1292
1453
 
@@ -1300,26 +1461,62 @@ await dbSlow.запись().count() // ~440k сущностей — дольш
1300
1461
  // {"mode":"count","classes":["booking"],"ms":3191.0,"rows":1,"slow":true}
1301
1462
  ```
1302
1463
 
1303
- **Кейс: три подключения — обычное, изолированное, под ACL**
1464
+ **Кейс: три хендла — админский, изолированный, под ACL**
1304
1465
 
1305
1466
  ```ts
1306
- const db = await connect({ dsn, schema }) // [33.6 ms]
1307
- const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [40.2 ms]
1308
- const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [54.7 ms]
1309
- await db.Организация().count() // → 30 — видит всех
1467
+ const db = await connect({ dsn, schema, enforceAccount: false }) // [32.6 ms]
1468
+ const iso = await (await connect({ dsn, schema })).as(acc.id) // [55.3 ms]
1469
+ const uc = await (await connect({ dsn, schema, enforceAcl: true })).as(acc.id) // [58.1 ms]
1470
+ await db.Организация().count() // → 30 — админский видит всех
1310
1471
  await iso.Организация().count() // [5.1 ms] → 1 — только свой арендатор
1311
1472
  await uc.Организация().rows()
1312
1473
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default)
1313
1474
  await iso.close(); await uc.close()
1314
1475
  ```
1315
1476
 
1477
+ #### `uuidv5(name: string, ns = LETOPIS_NS): string` · `uuidv7(): string` · `LETOPIS_NS`
1478
+
1479
+ | Экспорт | Тип | Описание |
1480
+ |---|---|---|
1481
+ | `uuidv5` | `(name: string, ns?: string) => string` | детерминированный (sha1) id из имени в пространстве `ns` |
1482
+ | `uuidv7` | `() => string` | time-ordered: старшие биты — метка времени (мс), дальше random |
1483
+ | `LETOPIS_NS` | `string` | пространство имён либы: `c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90` |
1484
+
1485
+ **Назначение и алгоритм.** Это не хелперы «на всякий случай», а **публичный контракт**:
1486
+ для класса с `attributes.id = {type:'uuid', generate:5, from:[…]}` id считает схема, и
1487
+ формула открыта — тот же id можно получить на клиенте **до** записи (идемпотентный
1488
+ `create()`, проверка «занято ли» точечным `first()`, advisory-lock по будущему id).
1489
+ Строка-имя собирается так (`from` — в порядке объявления; конец связи → id цели,
1490
+ скалярное поле → его значение):
1491
+
1492
+ ```
1493
+ uuidv5(`${pgSchema}:${partition}:${class}:${from₁}:${from₂}:…`, LETOPIS_NS)
1494
+ ```
1495
+
1496
+ **Любое** изменение имени PG-схемы, партиции, id класса или ПОРЯДКА `from` даёт другой id —
1497
+ это часть контракта данных, менять как breaking-change (бамп `version` схемы).
1498
+
1499
+ **Примеры**
1500
+
1501
+ ```ts
1502
+ import { uuidv5, uuidv7, LETOPIS_NS } from 'letopis'
1503
+
1504
+ // id записи известен ДО создания: v5(Мастер, старт) — двойная бронь мертва самим id (§ 3.2)
1505
+ const bId = uuidv5(`v1.booking:entity:booking:${иван.id}:2026-08-01T07:00:00Z`)
1506
+ await db.запись(bId).first() // «занято?» — точечно, без обхода
1507
+ uuidv5('x') === uuidv5('x', LETOPIS_NS) // → true: ns по умолчанию
1508
+ uuidv7() // → '0199f3c1-…' — сортируемый по времени
1509
+ ```
1510
+
1511
+ Полный разбор правил генерации (v4 / v7 / v5, наследование правила) — [§ 3.2](#32-id-считает-схема-attributesid).
1512
+
1316
1513
  ### 11.1a up(opts)
1317
1514
 
1318
1515
  #### `up(opts: UpOpts): Promise<EntityDb>`
1319
1516
 
1320
1517
  | Параметр | Тип | Default | Описание |
1321
1518
  |---|---|---|---|
1322
- | `opts.dsn` | `string?` | `postgres://postgres:test@localhost:15432/letopis` | строка подключения; хост не `localhost` → docker-шаги пропускаются (только ожидание готовности) |
1519
+ | `opts.dsn` | `string?` | `postgres://postgres:test@localhost:15432/letopis` | строка подключения. Docker-шаги решает **живая проба**, не имя хоста: postgres по `dsn` уже отвечает → контейнер не поднимается (`postgres is up … — docker skipped`), базы нет → создаётся. Контейнер создаётся только если проба не прошла И хост локальный; для нелокального хоста — только ожидание готовности |
1323
1520
  | `opts.schema` | `string` | — | **базовое** имя схемы БЕЗ версии и точек (`'booking'`) |
1324
1521
  | `opts.version` | `number` | — | версия движка, целое ≥ 1: итоговая PG-схема `"v<N>.<schema>"`; бамп руками при breaking-изменении DDL |
1325
1522
  | `opts.container` | `string?` | `'letopis-timescale'` | имя dev-контейнера |
@@ -1330,7 +1527,7 @@ await iso.close(); await uc.close()
1330
1527
  | `opts.fresh` | `boolean?` | `false` | дропнуть схему и накатить заново — **данные схемы теряются** |
1331
1528
  | `opts.quiet` | `boolean?` | `false` | без `[letopis.up]`-прогресса в консоли |
1332
1529
  | `opts.waitTimeoutMs` | `number?` | `120 000` | максимум ожидания готовности (первый запуск: pull образа + initdb) |
1333
- | …остальные | `ConnectOpts` | — | `partition`/`account`/`enforceAcl`/`onQuery` и все опции `connect()` прокидываются насквозь |
1530
+ | …остальные | `ConnectOpts` | — | `partition`/`enforceAccount`/`enforceAcl`/`onQuery` и все опции `connect()` прокидываются насквозь; возвращённый `db` — такой же безличный корень (арендатора даёт `db.as()`, § 10.6) |
1334
1531
 
1335
1532
  **Назначение и алгоритм.** Одна точка входа: от пустой машины до готового `db`.
1336
1533
  (1) **Probe**: одна попытка `SELECT 1` по `dsn` — живой postgres (свой контейнер, CI-сервис,
@@ -1414,9 +1611,40 @@ import type {
1414
1611
  ### 11.2 EntityDb — корень
1415
1612
 
1416
1613
  `EntityDb` — Proxy: любое имя класса из `Schema` (id или алиас) — метод старта цепочки;
1417
- плюс фиксированные члены `begin/commit/rollback/batch/watch/close/registry/sql` и фасады
1614
+ плюс фиксированные члены `as/begin/commit/rollback/batch/watch/close/registry/sql` и фасады
1418
1615
  `accounts/credentials/resources/rules` (§ 11.8), `auth` (§ 11.9), `acl` (§ 11.10).
1419
1616
 
1617
+ #### `db.as(account: uuid | { id }, opts?: { owner? }): Promise<EntityDb>`
1618
+
1619
+ | Параметр | Тип | Default | Описание |
1620
+ |---|---|---|---|
1621
+ | `account` | `uuid \| { id }` | — | арендатор/субъект ЭТОГО вызова; пустое значение — ошибка `db.as(account) requires an account id` |
1622
+ | `opts.owner` | `uuid?` | = `account` | значение колонки `owner` для новых строк этого scope |
1623
+
1624
+ **Назначение и алгоритм.** Возвращает хендл **того же пула**, работающий от имени `account`:
1625
+ арендатор — свойство вызова, не подключения (в `connect()` опции `account` нет). Клонирует
1626
+ контекст (как `db.begin()`), нового соединения не открывает; под `enforceAcl` дополнительно
1627
+ читает аккаунт + Resource/Rule и компилирует резолвер **под этого субъекта** — у каждого scope
1628
+ свой, решения между арендаторами не переиспользуются. Батчи со корня не наследуются: очередь
1629
+ планов, общая для разных арендаторов, была бы утечкой записи. `close()` закрывает пул, поэтому
1630
+ зовётся один раз — и с корня, и с любого scope (это одно и то же соединение).
1631
+
1632
+ Под `enforceAccount` (default `true`) чтение/запись возможны **только** с такого хендла:
1633
+ с безличного корня — ошибка с подсказкой (§ 10.6). В вебе scope живёт на запрос:
1634
+ `const t = await db.as(req.accountId)`.
1635
+
1636
+ **Примеры**
1637
+
1638
+ ```ts
1639
+ const db = await connect({ dsn, schema })
1640
+ const t1 = await db.as(salon1)
1641
+ const t2 = await db.as(salon2, { owner: managerId })
1642
+ await t1.Клиент().count() // только клиенты salon1
1643
+ await t2.Клиент().create({ name: 'X', phone: '+7…' }).rows() // account = salon2, owner = managerId
1644
+ await db.Клиент().count() // Error: enforceAccount is on — call db.as(account) …
1645
+ await db.close() // один раз на пул
1646
+ ```
1647
+
1420
1648
  #### `db.<Класс>(filter?: Filter): Chain`
1421
1649
 
1422
1650
  | Параметр | Тип | Описание |
@@ -1848,6 +2076,33 @@ for (;;) {
1848
2076
  }
1849
2077
  ```
1850
2078
 
2079
+ #### `.exact(): Chain`
2080
+
2081
+ Параметров нет.
2082
+
2083
+ **Назначение и алгоритм.** Снимает полиморфизм ТЕКУЩЕГО шага: в SQL уходит
2084
+ `class = '<свой>'` вместо `class = ANY('<свой>' + descendants)`. Нужен, когда наследование
2085
+ в домене — переиспользование `attributes`, а не «is-a» для выборки (§ 3.3). На классе без
2086
+ потомков — no-op. На запись не влияет: `create()` всегда пишет строго в свой класс.
2087
+
2088
+ **Примеры**
2089
+
2090
+ ```ts
2091
+ await db.Контрагент().count() // → 40367 Мастера + Клиенты (полиморфно)
2092
+ await db.Контрагент().exact().count() // → 0 abstract-класс своих строк не имеет
2093
+ await db.окно().count() // → 494008 смены + записи-наследники
2094
+ await db.окно().exact().count() // → 54005 именно смены
2095
+ await db.Мастер(и).окно().exact().rows() // смены мастера, без его броней
2096
+ ```
2097
+
2098
+ **Кейс: «занят ли мастер»** — смены и брони живут в одной иерархии, поэтому проверка
2099
+ доступности всегда берёт `.exact()` на окне и отдельный шаг на записи:
2100
+
2101
+ ```ts
2102
+ const смены = await db.Мастер(и).окно().exact().rows() // интервалы доступности
2103
+ const брони = await db.Мастер(и).запись().rows() // что уже занято
2104
+ ```
2105
+
1851
2106
  #### `.deep(max = 32): Chain`
1852
2107
 
1853
2108
  | Параметр | Тип | Описание |
@@ -1970,9 +2225,11 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms]
1970
2225
  |---|---|---|
1971
2226
  | `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`; при `create()` — значение колонки |
1972
2227
 
1973
- **Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree). Под `enforceAccount`
1974
- чужой `.account()` — ошибка; под `enforceAcl` конфликт с пришпиленной правилом колонкой —
1975
- ошибка `acl pins` (§ 11.10).
2228
+ **Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree). Модификатор патчит
2229
+ **только свой шаг** — арендатор всей цепочки задаётся не им, а хендлом `db.as()` (§ 10.6):
2230
+ под `enforceAccount` он фильтрует каждый шаг сам, а чужой `.account()` — ошибка `pinned`.
2231
+ Под `enforceAcl` конфликт с пришпиленной правилом колонкой — ошибка `acl pins` (§ 11.10).
2232
+ Смысл `.account()` остаётся прежним: точечный фильтр/значение на админском хендле.
1976
2233
 
1977
2234
  ```ts
1978
2235
  await db.Организация().account(SYS).count() // [5.1 ms] → 30
@@ -2542,7 +2799,45 @@ await db.accounts.delete(SYS) // [6.0 ms]
2542
2799
  ```
2543
2800
 
2544
2801
  **Кейс:** чистка мусорной регистрации — удалять можно только то, что не оставило следов;
2545
- след есть → `enabled: false` вместо удаления.
2802
+ след есть → `enabled: false` вместо удаления, либо `db.accounts.purge(id)` — полный офбординг ниже.
2803
+
2804
+ #### `db.accounts.purge(id): Promise<boolean>`
2805
+
2806
+ Полный физический **офбординг тенанта** (необратимо): все `Entity` с `account = id` ИЛИ `owner = id`
2807
+ + сам `Account` (`Credential` — FK-каскад). Освобождает `entity_account_fk`/`entity_owner_fk` (RESTRICT),
2808
+ которые блокируют обычный `delete`. Внутри — серверная `purge_account()` (флаг отключает append-only-триггер).
2809
+ Предохранители (до сноса): вызывать может лишь **Owner/System**; нельзя снести **последний enabled Owner**
2810
+ (лок-аут тенанта) и **свой** аккаунт сессии.
2811
+
2812
+ Вызывать нужно **со scoped-хендла** — предохранители смотрят, кто именно зовёт:
2813
+
2814
+ ```ts
2815
+ const dbSys = await (await connect({ dsn, schema: 'v1.notify' })).as(SYS)
2816
+ await dbSys.accounts.purge(tenantId) // → true: Entity + Account + Credential снесены, место освобождено
2817
+ // с безличного корня: Error: accounts.purge requires an authenticated caller — call it from
2818
+ // a scoped handle: (await db.as(caller)).accounts.purge(id)
2819
+ ```
2820
+
2821
+ #### `db.schema.define(def)` / `db.reloadSchema()` — живой подхват правок схемы
2822
+
2823
+ Определения классов живут в таблице `Schema`. Меняешь их **простым SQL** (или `db.schema.define(...)`)
2824
+ → `db.reloadSchema()` пересобирает реестр (и ACL-резолвер) **без реконнекта**.
2825
+
2826
+ ```ts
2827
+ // правка простым SQL + перечитывание (напр. новое значение enum)
2828
+ await db.sql.unsafe(`UPDATE "v1.notify"."Schema"
2829
+ SET attributes = jsonb_set(attributes, '{status,values}', '["queued","sent","paused"]')
2830
+ WHERE id = 'Delivery'`)
2831
+ await db.reloadSchema() // запись со status:'paused' теперь проходит валидацию
2832
+
2833
+ // или спец-метод (сам перечитывает)
2834
+ await db.schema.define({ id: 'Coupon', alias: 'Купон', category: 'HUB',
2835
+ attributes: { id: { type: 'uuid', generate: 7 }, code: { type: 'string' } } })
2836
+ ```
2837
+
2838
+ `up()` при существующей схеме seed **не** перезаливает → правка в `Schema` переживает рестарт (клоббер только
2839
+ при явном re-run старого seed через `db/apply.mjs`/`fresh`). Бамп версии (`vN.*`) — **новый пустой** namespace
2840
+ с переливом данных, НЕ инструмент для аддитивной правки определения (новое значение enum/поле/класс).
2546
2841
 
2547
2842
  #### `db.credentials.find(f?): Promise<Credential[]>`
2548
2843
 
@@ -3092,7 +3387,7 @@ app.use(async (req, res, next) => {
3092
3387
  `booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
3093
3388
  ключи pattern (реальные колонки Entity, `"$account"` → id субъекта) возвращаются как
3094
3389
  `filter` — готовый предикат строк. Справочный метод (SQL за категориями аккаунта на каждый
3095
- вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный на connect
3390
+ вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный при `db.as()`
3096
3391
  (микросекунды, memo).
3097
3392
 
3098
3393
  **Примеры**
@@ -3113,33 +3408,44 @@ await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий
3113
3408
  **Кейс: enforceAcl — те же решения в SQL цепочек (реальный прогон)**
3114
3409
 
3115
3410
  ```ts
3116
- const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [66.9 ms] правила фиксируются
3117
- await uc.запись().rows() // [17.2 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
3118
- await uc.запись().count() // [17.6 ms] → 3 — честный count по суженному множеству
3119
- await uc.Услуга().count() // [15.1 ms] → 602 — безусловный allow, класс целиком
3411
+ // enforceAccount: false — показаны ACL-предикаты, а не изоляция арендатора (§ 10.6)
3412
+ const uc = await (await connect({ dsn, schema, enforceAcl: true, enforceAccount: false })).as(acc.id)
3413
+ // [58.1 ms] снимок правил под ЭТОГО субъекта (обновить — reloadSchema)
3414
+ await uc.запись().rows() // [12.6 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
3415
+ await uc.запись().count() // [12.5 ms] → 3 — честный count по суженному множеству
3416
+ await uc.Услуга().count() // [11.4 ms] → 605 — безусловный allow, класс целиком
3120
3417
  await uc.Организация().rows()
3121
3418
  // Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
3122
3419
  const [z] = await uc.запись().create({ start_datetime: t, end_datetime: e })
3123
- .Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [20.9 ms]
3420
+ .Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [16.2 ms]
3124
3421
  z.owner === acc.id // → true — owner пришпилен правилом
3125
3422
  await uc.запись().owner(SYS).create({ … }).rows()
3126
- // Error: letopis: acl pins booking writes to owner 06d3bbfe-… — на терминале [2.6 ms]
3127
- await uc.запись('чужой-id').create({ … }).rows() // [2.0 ms] — перехват чужого id мёртв: v5-класс id не принимает
3423
+ // Error: letopis: acl pins booking writes to owner 06d3bbfe-… — на терминале [1.8 ms]
3424
+ await uc.запись('чужой-id').create({ … }).rows() // [1.6 ms] — перехват чужого id мёртв: v5-класс id не принимает
3128
3425
  // Error: letopis: class "booking" computes id (uuid v5 from Staff, start_datetime) — remove the explicit id
3129
- await uc.запись(свойId).delete({ confirm: true }).rows() // [30.3 ms] → [{ id: 'ee65244b-…', $deleted: true }]
3426
+ await uc.запись(свойId).delete({ confirm: true }).rows() // [27.8 ms] → [{ id: 'ee65244b-…', $deleted: true }]
3130
3427
  // watch: события только безусловных allow-классов —
3131
3428
  // uc.watch(cb) поймал ['Service']; booking скрыт (предикат не проверить по payload)
3132
3429
  ```
3133
3430
 
3134
3431
  #### `db.acl.reload(): void`
3135
3432
 
3136
- Параметров нет. Сбрасывает кэш Resource/Rule фасада `db.acl` (следующий `check`/`checkData`
3137
- перечитает словарь). На **enforceAcl-цепочки не влияет** — их резолвер скомпилирован на
3138
- connect; подхватить новые правила = новый `connect()`. Реестр классов тоже фиксирован на
3139
- connect — новый класс в lineage-проверках увидит только новое подключение.
3433
+ Параметров нет. Сбрасывает кэш Resource/Rule **фасада `db.acl`** (следующий `check`/`checkData`
3434
+ перечитает словарь). На **enforceAcl-цепочки не влияет** — это отдельная подсистема.
3435
+
3436
+ **Два «reload», разные подсистемы — не путать:**
3437
+
3438
+ | Вызов | Что пересобирает | На что НЕ влияет |
3439
+ |---|---|---|
3440
+ | `db.acl.reload()` | кэш Resource/Rule фасада `db.acl` (`check`/`checkData`) | энфорсер `enforceAcl`-цепочек, реестр классов |
3441
+ | `await db.reloadSchema()` | реестр классов из `Schema`; со scope-хендла (`db.as`) — **и** его энфорсер `enforceAcl` (§ 11.2) | кэш фасада `db.acl` (сбрасывать отдельно); энфорсеры ДРУГИХ scope |
3442
+
3443
+ Реконнект нужен только для смены `dsn`/`schema`/`partition` и самих флагов `enforce*`
3444
+ (сменить арендатора реконнект НЕ требует — это новый `db.as()`).
3140
3445
 
3141
3446
  ```ts
3142
- db.acl.reload() // [187 µs]
3447
+ db.acl.reload() // [187 µs] — словарь фасада
3448
+ await db.reloadSchema() // классы + энфорсер цепочек, без реконнекта
3143
3449
  ```
3144
3450
 
3145
3451
  **Кейс:** админка сохранила правило → `reload()` в том же процессе, чтобы `check` следующего
@@ -3258,14 +3564,19 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
3258
3564
  | `duplicate link slot "X"` | один конец задан слотом дважды в одной записи |
3259
3565
  | `set() split into create()/update() (0.16.0)` | старый глагол записи — create() вставляет, update() версионирует найденное |
3260
3566
  | `slot .delete() renamed to .unset() (0.16.0)` | старое имя слот-снятия |
3261
- | `.link() removed (0.15.0)` | снесённый `.link()` — теперь слот `.Класс.set()` |
3262
3567
  | `execute() renamed to run() (0.11.0)` | старое имя терминала путей (и `batch.execute()`) |
3263
3568
  | `plan is queued in the batch — call batch.run()` | терминал на батч-цепочке с операциями |
3264
3569
  | `no path X → Y` / `LINK → LINK …` | недопустимый переход (синхронно при построении цепочки) |
3265
- | `Entity.account is NOT NULL…` | нет account и System-аккаунта |
3570
+ | `enforceAccount is on — call db.as(account) to name the tenant of this call…` | чтение/запись с безличного корня при включённой изоляции: назвать арендатора вызова `db.as()` либо взять админский `connect({ enforceAccount: false })` (§ 10.6) |
3571
+ | `enforceAccount is on — reads are pinned to account X` / `writes are pinned to…` | `.account(чужой)` на scope-хендле — подменить арендатора нельзя (§ 10.6) |
3572
+ | `db.as(account) requires an account id (uuid or { id })` | `db.as()` без аккаунта (пустая строка / объект без `id`) |
3573
+ | `enforceAcl is on — call db.as(account) to name the subject` | цепочка под `enforceAcl` без субъекта (§ 9.2) |
3574
+ | `Entity.account is NOT NULL…` | нет ни `.account()`, ни scope `db.as()`, ни System-аккаунта в схеме |
3266
3575
  | `violates foreign key constraint "entity_*_fk"` | несуществующий класс/аккаунт; удаление класса с данными |
3267
3576
  | `lock() works only inside db.begin()` | лок вне транзакции |
3268
3577
  | `commit() needs a transaction` | commit на корневом db |
3578
+ | `schema "X" has no "Schema" table …` | `connect()` получил БАЗОВОЕ имя (`'booking'`) вместо полного `'v1.booking'` — либа префикс не достраивает; в тексте ошибки перечислены схемы letopis этой БД |
3579
+ | `schema "X" has no classes for partition "Y"` | схема есть, но таблица `Schema` пуста для партиции — нужен сид хотя бы одного класса (§ 1.1) |
3269
3580
  | `letopis.up: bad schema name "X"` / `bad version` | `up()`: имя с точкой/версией либо version не целое ≥ 1 |
3270
3581
  | `letopis.up: docker CLI not found…` | `up()`: docker не установлен, а postgres на dsn не отвечает |
3271
3582
  | `letopis.up: postgres not ready in N s…` / `redis not ready` | `up()`: контейнер не поднялся за `waitTimeoutMs` (подсказка: `docker logs`) |
@@ -3286,29 +3597,63 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
3286
3597
  ## 14. Производительность
3287
3598
 
3288
3599
  Тайминги — живые прогоны демо на ЕДИНОМ полигоне `v1.salondemo` (`bench/salon-seed.mjs`,
3289
- ~980 000 строк: 440 000 записей ×2 версии, 600 услуг, 1026 цен, 1260 навыков). Статья
3290
- (`bench/salon-article-demo.mjs`), API Reference (`bench/api-reference-demo.mjs`) и бенчи
3291
- читают один и тот же полигон, мутируя лишь свои демо-сущности:
3600
+ 980 328 строк: 440 006 записей ×2 версии, 600 услуг, 1026 цен, 1260 навыков; 13 чанков
3601
+ гипертаблицы, диапазон `updated` — год). Статья (`bench/salon-article-demo.mjs`), API Reference
3602
+ (`bench/api-reference-demo.mjs`) и бенчи читают один и тот же полигон, мутируя лишь свои
3603
+ демо-сущности.
3604
+
3605
+ > **Цифры привязаны к размеру полигона — при его смене строки надо перемерять.** Тяжёлые сцены
3606
+ > линейны по числу строк, а обход графа — ещё и по числу чанков (см. ниже). Пример дрейфа:
3607
+ > строка про обход `запись→Мастер` показывала ≈6 с с версии 0.17.0, когда полигон был
3608
+ > 250 000 записей / 613 тыс. строк, и переезд на 440 006 записей / 980 328 строк её не обновил —
3609
+ > настоящее значение оказалось ≈30 с. Полигон обязан быть проанализирован (§ 14.1).
3292
3610
 
3293
3611
  | Операция | Время |
3294
3612
  |---|---|
3295
- | фильтр/`count()` по каталогу (`ne`/`gt`/`between`/`like`) | 5–8 ms |
3296
- | агрегация каталога `avg`/`min`/`max` | 5–15 ms |
3297
- | `sum('data.amounts.RUB')` (класс цена, 1026 вариантов) | ≈13 ms |
3298
- | `count()`/`rows()` каталога услуг (600) | 7–13 ms |
3299
- | цена `sort('data.amounts.RUB')` + keyset-страница `after(cursor)` | 19–31 ms |
3300
- | цепочка `навык→Услуга`, `count()` путей (1260) | ≈65 ms |
3301
- | `countBy('data.notes')` по 440k записям | ≈3.3 s |
3302
- | `sort('updated','desc').limit` по всему классу записей БЕЗ фильтра | ≈6 s |
3303
- | `count()` всех 440 000 записей БЕЗ фильтра | ≈3.4 s |
3304
- | обход `запись→Мастер` по всему классу записей | ≈6 s |
3305
- | запись новой версии (`create`/`update`), в т.ч. со слотами | 8–21 ms |
3306
-
3307
- Слабое место — выборка/обход **всего класса записей без фильтра** (`DISTINCT ON` по всем
3308
- 440 000 сущностям: секунды); лечится селективным фильтром, контекст-шагом или курсором
3309
- (keyset — миллисекунды даже на 440k). Каталог, агрегации и цепочки с фильтром — единицы—десятки ms.
3310
- TOAST-порог (data > 2KB): 0 строк.
3311
-
3613
+ | фильтр/`count()` по каталогу (`ne`/`gt`/`between`/`like`) | 4–7 ms |
3614
+ | агрегация каталога `avg`/`min`/`max` | 4–8 ms |
3615
+ | `sum('data.amounts.RUB')` (класс цена, 1026 вариантов) | ≈9 ms |
3616
+ | `count()`/`rows()` каталога услуг (600) | 6–21 ms |
3617
+ | цена `sort('data.amounts.RUB')` + keyset-страница `after(cursor)` | ≈17 ms |
3618
+ | фильтр `rows` limit 100 по 440k записям (`{notes: …}`) | ≈11 ms |
3619
+ | цепочка `навык→Услуга`, `count()` путей (1260) | ≈93 ms |
3620
+ | `countBy('data.notes')` по 440k записям | ≈1.9 s |
3621
+ | `count()` всех 440 000 записей БЕЗ фильтра | ≈1.4 s |
3622
+ | `sort('updated','desc').limit` по всему классу записей БЕЗ фильтра | ≈5.4 s |
3623
+ | обход `запись→Мастер` по всему классу записей | ≈30 s |
3624
+ | запись новой версии (`create`/`update`), в т.ч. со слотами | 8–25 ms |
3625
+
3626
+ Слабое место — выборка/обход **всего класса записей без фильтра**; лечится селективным
3627
+ фильтром, контекст-шагом или курсором. Причины у двух худших строк разные:
3628
+
3629
+ - `sort(…).limit` по классу (≈5.4 с) — `DISTINCT ON` обязан посчитать актуальную версию для
3630
+ всех 440 000 сущностей прежде, чем сортировать. Упирается в объём, планировщику тут нечего
3631
+ улучшать.
3632
+ - обход `запись→Мастер` (≈30 с) — forward-hop идёт коррелированным `JOIN LATERAL`: **одна
3633
+ индексная проба на каждую строку внешнего шага**, и каждая проба обходит ВСЕ чанки
3634
+ гипертаблицы (исключение по времени невозможно — ищем по `id`, не по `updated`). Отсюда
3635
+ модель стоимости: `строки внешнего шага × чанки`. Замерено: одна проба обоих шагов — 0.924 мс
3636
+ и 80 буферов (13 чанков × ~3 страницы × 2 шага), 440k проб → 17.7 млн обращений к буферам.
3637
+
3638
+ Форма запроса при этом **оптимальна**, а не «недоделана» — проверено замерами:
3639
+ PostgreSQL не может её ни раскоррелировать, ни мемоизировать (корреляция сидит внутри
3640
+ подзапроса с `DISTINCT ON`), и дело не в оценке кардинальности: подстановка класса литералом
3641
+ вместо параметра и `SET enable_nestloop = off` план не меняют — `Nested Loop` без `Memoize`,
3642
+ те же 17.7 млн буферов. Некоррелированный join обеих сторон по ключу измерен и оказался
3643
+ **хуже в 18 раз** (561 с, 48.1 млн буферов).
3644
+
3645
+ Лечится не переписыванием запроса, а стороной, с которой начата цепочка:
3646
+ `db.запись(id).Мастер()` — **7.5 мс**, `db.Мастер(id).запись()` — **11.7 мс** против 30 с
3647
+ у `db.запись().Мастер()`. (В самой БД проба ещё дешевле, 0.9 мс; остальное — сборка SQL и
3648
+ разбор ответа в клиенте.)
3649
+
3650
+ Каталог, агрегации и цепочки с фильтром — единицы—десятки ms. TOAST-порог (data > 2KB): 0 строк.
3651
+
3652
+ - **Цена полиморфизма.** Шаг по родителю сканирует всё семейство, поэтому дороже листа:
3653
+ на том же полигоне `db.окно().exact().count()` ≈ 82 мс (54 008), `db.окно().count()` ≈ 1.6 с
3654
+ (494 018), `db.Контрагент().count()` ≈ 0.23 с (40 371). Класс без потомков идёт быстрым
3655
+ путём (равенство вместо `ANY`) и не дорожает вовсе: `db.Услуга().count()` ≈ 6 мс.
3656
+ Тайминги в таблице выше сняты на классах-листьях и полиморфизмом не затронуты.
3312
3657
  - Containment и обход графа — GIN; операторы — на уже суженном наборе.
3313
3658
  - Начинайте цепочку с самого селективного шага (Организация/Мастер/Клиент, не `запись()`).
3314
3659
  - `count()` считает пути; число сущностей дешевле берётся `ids().length`.
@@ -3320,6 +3665,39 @@ TOAST-порог (data > 2KB): 0 строк.
3320
3665
  `node bench/history.bench.mjs --entities=10000 --versions=100`, оверхед ACL —
3321
3666
  `npx tsx bench/acl.bench.mjs` (таблица в § 9.2).
3322
3667
 
3668
+ ### 14.1 `ANALYZE` обязателен после массовой заливки
3669
+
3670
+ **Все тайминги выше верны только на проанализированной базе.** Без статистики те же запросы
3671
+ медленнее на порядок, и это самая дорогая ошибка эксплуатации из всех, что есть в этом
3672
+ руководстве.
3673
+
3674
+ `Entity` — гипертаблица: строки живут в чанках, у родительской таблицы своих строк нет. Пока
3675
+ `ANALYZE` по ней не прошёл, планировщик подставляет дефолт «200 уникальных `id`». Ядро всех
3676
+ чтений либы — semi-join с подзапросом кандидатов (`e.id IN (SELECT c.id FROM Entity c WHERE …)`),
3677
+ и на оценке 200 вместо сотен тысяч он выбирает `Nested Loop` там, где нужен `Merge Semi Join`.
3678
+
3679
+ Замерено на `v1.salondemo` (980k строк), один и тот же запрос:
3680
+
3681
+ | Сцена | без статистики | после `ANALYZE` | |
3682
+ |---|---|---|---|
3683
+ | `count()` класса под изоляцией арендатора | 57.6 s | **3.2 s** | ×18 |
3684
+ | фильтр `rows` limit 100 по 440k | 1371 ms | **11 ms** | ×126 |
3685
+ | `count()` класса без фильтра | 2981 ms | **1373 ms** | ×2.2 |
3686
+ | keyset-страница по классу | 5610 ms | 5425 ms | без изменений |
3687
+
3688
+ Выигрывают запросы **с предикатом** — там, где строится подзапрос кандидатов. Полные проходы
3689
+ по классу не меняются: они упираются в объём, а не в план.
3690
+
3691
+ Статистику `n_distinct` PostgreSQL считает корректно сам (на этом полигоне `-0.35`, то есть
3692
+ уникальных `id` ≈ 35% строк — сущность живёт в среднем в трёх версиях). Никаких подсказок
3693
+ (`ALTER COLUMN … SET (n_distinct = …)`) добавлять не нужно — нужен сам факт запуска.
3694
+
3695
+ - `up()` и `db/apply.mjs` делают `ANALYZE` при накате схемы сами.
3696
+ - **Своя массовая вставка — на вас**: после `COPY`, миграции или генератора данных вызовите
3697
+ `ANALYZE "v1.myapp"."Entity"` (на 1 млн строк ≈ 9 с). Автовакуум доберётся сам, но не сразу,
3698
+ и до этого приложение будет работать в разы медленнее без видимой причины.
3699
+ - Полезно и после массового `delete`/`purge`: доля живых строк меняется скачком.
3700
+
3323
3701
  ---
3324
3702
 
3325
3703
  ## 15. E2E-пример: барбершоп
@@ -3378,12 +3756,20 @@ await db.запись(bId).delete({ confirm: true }).rows() // отм
3378
3756
  ## 16. Тесты
3379
3757
 
3380
3758
  ```bash
3381
- cd lib && npm test # 167/167 тестов против живого docker (timescale + redis), по файлам:
3759
+ cd lib && npm test # весь набор против живого docker (timescale + redis); счёт кейсов — в CHANGELOG
3760
+ # актуального релиза. По файлам:
3382
3761
  # acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
3383
- # с $account, enforceAcl (предикаты в SQL, каскад, watch)
3762
+ # с $account, enforceAcl (предикаты в SQL, каскад, watch);
3763
+ # полиморфное чтение по иерархии + .exact(); deny на потомке
3764
+ # не обходится шагом по родителю (проверка на утечку)
3384
3765
  # api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
3385
3766
  # auth — db.auth: пароль/api-key/key-secret/TOTP/OTP/link+lookup,
3386
3767
  # глобальная идентичность, сессии на живом Redis (expire/revoke)
3768
+ # parity — паритет клиент ↔ триггер БД: один и тот же кейс исполняется
3769
+ # через либу и голым INSERT, оба обязаны решить одинаково
3770
+ # (abstract, неизвестный класс, обязательные/optional/союзные
3771
+ # концы, посторонняя связь) + закреплена известная асимметрия:
3772
+ # валидацию data делает только либа
3387
3773
  # plan — план-модель: несколько операций, fan-out, self-update,
3388
3774
  # превью/confirm delete, ОТКАТ плана, слот-перевес, батч-гард
3389
3775
  # resilience — ретраи 40P01/40001, реальный deadlock двух транзакций,
@@ -3400,9 +3786,12 @@ cd lib && npm test # 167/167 тестов против живого docker (t
3400
3786
  # свои сиды/seeds:false, автосоздание базы, валидация
3401
3787
  # salon — ТЕСТ-ПЛАН: имитация салона, ВСЕ 145 публичных API
3402
3788
  # (21 акт + матрица покрытия); слоты/pivot/entity
3403
- # wave2 — asOf/versions, keyset-курсор, gen-types, enforceAccount, anonymize
3789
+ # wave2 — asOf/versions, keyset-курсор, enforceAccount, anonymize; gen-types:
3790
+ # форма фасада, слоты, отсутствие снесённых имён + compile-гейт (tsc)
3404
3791
  # wave3 — or/not, агрегации, deep, watch
3405
3792
  # wave4 — compression-политики (чтение сжатого чанка), schema-sync, onQuery
3793
+ # wave5 — физический purge (двухфазность, замыкание, accounts.purge),
3794
+ # withDeleted, живой подхват схемы (schema.define/reloadSchema)
3406
3795
  npm run bench # производительность на 105k строк (§ 14)
3407
3796
  ```
3408
3797