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/CHANGELOG.md +312 -0
- package/LICENSE +21 -0
- package/README.md +633 -245
- package/dist/acl.d.ts +7 -2
- package/dist/acl.js +3 -2
- package/dist/chain.d.ts +25 -0
- package/dist/chain.js +28 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.js +105 -27
- package/dist/schema.js +48 -6
- package/dist/sql.d.ts +18 -1
- package/dist/sql.js +111 -36
- package/dist/tables.js +9 -1
- package/dist/types.d.ts +50 -7
- package/dist/types.js +65 -3
- package/dist/up.d.ts +8 -0
- package/dist/up.js +36 -10
- package/dist/write.js +8 -5
- package/package.json +9 -3
- package/scripts/check-docs.mjs +375 -0
- package/scripts/gen-api-contract.mjs +226 -0
- package/scripts/gen-types.mjs +238 -42
- package/sql/ddl.sql +15 -1
- package/sql/seed.booking.sql +5 -4
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
|
|
13
|
+
const пути = await t.Мастер({ name: 'Вася' }).навык().Услуга().run()
|
|
13
14
|
// [ { Мастер: Row, навык: Row, Услуга: Row }, … ]
|
|
14
15
|
|
|
15
16
|
// запись: операции — звенья, исполняет терминал
|
|
16
|
-
await
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
await db.
|
|
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. Данные — работают
|
|
213
|
+
**3. Данные — работают сразу.** Цепочки идут от имени аккаунта (`db.as`, §10.6); System-аккаунт
|
|
214
|
+
из сида — валидное «имя» для дев-скриптов и он же дефолт колонок `account`/`owner`:
|
|
188
215
|
|
|
189
216
|
```ts
|
|
190
|
-
const [
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
const
|
|
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
|
-
|
|
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; в БД НЕ пароль, а слоёный хэш: [
|
|
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 [
|
|
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" [
|
|
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 } [
|
|
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 } [
|
|
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 } [
|
|
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 [
|
|
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 [
|
|
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({
|
|
1090
|
+
**`connect({ enforceAcl: true })` + `db.as(субъект)`** — те же решения в цепочках: каждый шаг —
|
|
1000
1091
|
`READ`, `create()`/`update()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
|
|
1001
1092
|
(deny в замыкании откатывает транзакцию); `watch()` отдаёт события только безусловных
|
|
1002
|
-
allow-классов (payload нечем проверить предикат).
|
|
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,
|
|
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)` |
|
|
1020
|
-
| фильтр `rows` limit 100 |
|
|
1021
|
-
| keyset-страница всего класса 440k |
|
|
1022
|
-
| `count()` класса 440k |
|
|
1023
|
-
| цепочка `запись(id).Услуга()` |
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
|
|
1029
|
-
`db.acl.checkData` — справочный (1.
|
|
1030
|
-
горячий путь цепочек использует резолвер, скомпилированный
|
|
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
|
|
1154
|
-
await db
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
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, мутирует лишь свои сущности).
|
|
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?` |
|
|
1304
|
-
| `opts.enforceAcl` | `boolean?` | `false` | ACL по Resource/Rule (§ 9.2): READ на каждый шаг, WRITE/DELETE на записи, предикаты строк в SQL заранее;
|
|
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`.
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
-
**Кейс: три
|
|
1518
|
+
**Кейс: три хендла — админский, изолированный, под ACL**
|
|
1329
1519
|
|
|
1330
1520
|
```ts
|
|
1331
|
-
const db = await connect({ dsn, schema })
|
|
1332
|
-
const iso = await connect({ dsn, schema
|
|
1333
|
-
const uc = await connect({ dsn, schema,
|
|
1334
|
-
await db.Организация().count() // → 30 — видит всех
|
|
1335
|
-
await iso.Организация().count() // [
|
|
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` | строка
|
|
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`/`
|
|
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() // [
|
|
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() // [
|
|
1463
|
-
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [
|
|
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() // [
|
|
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
|
|
1750
|
+
const tr = await db.begin() // [1.0 ms]
|
|
1493
1751
|
await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15 }).rows()
|
|
1494
|
-
// [
|
|
1495
|
-
await tr.commit() // [3.
|
|
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
|
|
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() // [
|
|
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
|
-
// [
|
|
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() // [
|
|
1632
|
-
await db.навык().Услуга().count() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
1726
|
-
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [
|
|
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
|
-
// [
|
|
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() // [
|
|
1735
|
-
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [
|
|
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() // [
|
|
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
|
|
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() // [
|
|
1775
|
-
await db.Услуга().sort('data.duration').limit(2).rows() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
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') // [
|
|
1922
|
-
await db.Услуга().avg('data.duration') // [
|
|
1923
|
-
await db.Локация({}).sum('data.coordinates.lat') // [
|
|
1924
|
-
await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [
|
|
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') // [
|
|
1937
|
-
await db.цена().max('data.amounts.RUB') // [
|
|
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') // [
|
|
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
|
-
// [
|
|
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() // [
|
|
1986
|
-
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [
|
|
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).
|
|
1999
|
-
|
|
2000
|
-
|
|
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() // [
|
|
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.
|
|
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() // [
|
|
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
|
|
2046
|
-
await db.Услуга({ duration: lte(45) }).count() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
2077
|
-
await db.Услуга({ name: ilike('%массаж%') }).count() // [4.
|
|
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() // [
|
|
2089
|
-
await db.Услуга({ name: ends('7') }).count() // [
|
|
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() // [
|
|
2101
|
-
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [
|
|
2102
|
-
await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [
|
|
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() // [
|
|
2114
|
-
await db.Услуга({ description: exists(false) }).count() // [
|
|
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() // [
|
|
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() // [
|
|
2138
|
-
await db.Услуга({ name: not('Стрижка 0') }).count() // [3
|
|
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() // [
|
|
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() // [
|
|
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
|
-
// [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
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() // [
|
|
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
|
-
// [
|
|
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() // [
|
|
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() // [
|
|
2612
|
+
await db.Клиент('…0931').delete({ confirm: true }).rows() // [49 ms] — сервер нашёл зависимых:
|
|
2326
2613
|
// → те же три, каждый с $deleted: true
|
|
2327
|
-
await db.Клиент('…0931').delete({ confirm: true }).rows() // [
|
|
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() // [
|
|
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() // [
|
|
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.
|
|
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() // [
|
|
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) // [
|
|
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) // [
|
|
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() // [
|
|
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 }) // [
|
|
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) // [
|
|
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
|
-
// [
|
|
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) // [
|
|
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'
|
|
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 }) // [
|
|
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.
|
|
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
|
-
// [
|
|
2673
|
-
await db.resources.get('apiref.demo:API') // [2.
|
|
2674
|
-
await db.resources.find({ category: 'API' }) // [
|
|
2675
|
-
await db.resources.delete('apiref.demo:API') // [
|
|
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.
|
|
2695
|
-
await db.rules.find({ resource: 'apiref.demo:API' }) // [2.
|
|
2696
|
-
await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [
|
|
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
|
-
// [
|
|
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!' }) // [
|
|
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: 'зима' }) // [
|
|
2755
|
-
await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [
|
|
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
|
-
// [
|
|
2766
|
-
await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [
|
|
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
|
-
// [
|
|
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' }) // [
|
|
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) // [
|
|
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' }) // [
|
|
2809
|
-
await db.auth.verifyApiKey(key) // [
|
|
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) // [
|
|
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.
|
|
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.
|
|
2839
|
-
await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [
|
|
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: код }) // [
|
|
2882
|
-
await db.auth.verifyTotp({ account: acc, code: код }) // [2
|
|
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: изПриложения }) // [
|
|
2893
|
-
await db.auth.totpEnabled(acc) // [2.
|
|
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.
|
|
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
|
-
// [
|
|
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' }) // [
|
|
2959
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11
|
|
2960
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [
|
|
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 }) // [
|
|
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
|
|
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
|
-
// [
|
|
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.
|
|
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.
|
|
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' } }) // [
|
|
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) // [
|
|
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) // [
|
|
3073
|
-
await sess.revoke(token) // [
|
|
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) // [
|
|
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
|
-
// [
|
|
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) // [
|
|
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) // [
|
|
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') // [
|
|
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') // [
|
|
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); горячий путь цепочек использует резолвер, скомпилированный
|
|
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.
|
|
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
|
|
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') // [
|
|
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
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
await uc
|
|
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() // [
|
|
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-… — на терминале [
|
|
3186
|
-
await uc.запись('чужой-id').create({ … }).rows() // [
|
|
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() // [
|
|
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
|
|
3196
|
-
перечитает словарь). На **enforceAcl-цепочки не влияет** —
|
|
3197
|
-
|
|
3198
|
-
|
|
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()
|
|
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
|
-
| `
|
|
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
|
-
|
|
3349
|
-
(`bench/salon-article-demo.mjs`), API Reference
|
|
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`) |
|
|
3355
|
-
| агрегация каталога `avg`/`min`/`max` |
|
|
3356
|
-
| `sum('data.amounts.RUB')` (класс цена, 1026 вариантов) | ≈
|
|
3357
|
-
| `count()`/`rows()` каталога услуг (600) |
|
|
3358
|
-
| цена `sort('data.amounts.RUB')` + keyset-страница `after(cursor)` |
|
|
3359
|
-
|
|
|
3360
|
-
| `
|
|
3361
|
-
| `
|
|
3362
|
-
| `count()` всех 440 000 записей БЕЗ фильтра | ≈
|
|
3363
|
-
|
|
|
3364
|
-
|
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
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 #
|
|
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-курсор,
|
|
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
|
|