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