letopis 0.19.0 → 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 +226 -0
- package/LICENSE +21 -0
- package/README.md +430 -100
- 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 +45 -22
- 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 +30 -7
- package/dist/types.js +40 -3
- package/dist/up.js +15 -4
- package/dist/write.js +8 -5
- 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/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` — много кредов на аккаунт
|
|
@@ -996,13 +1087,15 @@ 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()`: правила снимаются
|
|
1094
|
+
и компилятся в memo-резолвер **на каждый scope** (у своего аккаунта — свой резолвер, ключ memo
|
|
1095
|
+
субъекта не содержит); правки `Resource`/`Rule` подхватывает `db.reloadSchema()` (§ 11.10).
|
|
1003
1096
|
|
|
1004
1097
|
```ts
|
|
1005
|
-
const u = await connect({ dsn, schema,
|
|
1098
|
+
const u = await (await connect({ dsn, schema, enforceAcl: true })).as(user.id)
|
|
1006
1099
|
await u.запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
|
|
1007
1100
|
await u.запись().count() // честный count по суженному множеству
|
|
1008
1101
|
await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
|
|
@@ -1012,22 +1105,27 @@ await u.запись(чужаяId).update({ notes: '…' }).rows() // → []
|
|
|
1012
1105
|
// create той же v5-пары (id чужой записи) тоже НЕ перехватывает: → [] вместо новой версии
|
|
1013
1106
|
```
|
|
1014
1107
|
|
|
1015
|
-
Оверхед (полигон `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):
|
|
1016
1111
|
|
|
1017
1112
|
| Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
|
|
1018
1113
|
|---|---|---|---|
|
|
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
|
-
горячий путь цепочек использует резолвер, скомпилированный
|
|
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 и без нового соединения.
|
|
1031
1129
|
|
|
1032
1130
|
`enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
|
|
1033
1131
|
|
|
@@ -1080,7 +1178,7 @@ const стр2 = await db.запись().sort('updated', 'desc') // можно
|
|
|
1080
1178
|
.after(кур).limit(50).rows()
|
|
1081
1179
|
|
|
1082
1180
|
// по data-пути ЛЮБОЙ глубины — field тот же, что в sort
|
|
1083
|
-
const дальше = await db.окно().sort('data.start_datetime')
|
|
1181
|
+
const дальше = await db.окно().exact().sort('data.start_datetime')
|
|
1084
1182
|
.after(cursorOf(окно, 'data.start_datetime')).limit(20).rows()
|
|
1085
1183
|
|
|
1086
1184
|
// бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
|
|
@@ -1106,7 +1204,7 @@ do {
|
|
|
1106
1204
|
await db.цена().sum('data.amounts.RUB') // сумма всех прайсов (record-лист): number | null
|
|
1107
1205
|
await db.Услуга().avg('data.duration') // среднее
|
|
1108
1206
|
await db.Услуга().countBy('data.duration') // { '30': 2, '60': 1 } ({} на пустом)
|
|
1109
|
-
await db.окно().min('data.start_datetime') // min/max — каст по типу поля из Schema
|
|
1207
|
+
await db.окно().exact().min('data.start_datetime') // min/max — каст по типу поля из Schema
|
|
1110
1208
|
```
|
|
1111
1209
|
|
|
1112
1210
|
Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или вложенный лист
|
|
@@ -1147,18 +1245,54 @@ const stop = await db.watch('запись', onEvent, {
|
|
|
1147
1245
|
})
|
|
1148
1246
|
```
|
|
1149
1247
|
|
|
1150
|
-
### 10.6 Изоляция арендатора: `enforceAccount`
|
|
1248
|
+
### 10.6 Изоляция арендатора: `db.as()` + `enforceAccount`
|
|
1249
|
+
|
|
1250
|
+
**Арендатор — свойство ВЫЗОВА, а не подключения.** Один пул обслуживает разных арендаторов,
|
|
1251
|
+
поэтому в `connect()` опции `account` нет: подключение безличное, а имя даёт `db.as(account)` —
|
|
1252
|
+
хендл того же пула, работающий от имени этого аккаунта.
|
|
1151
1253
|
|
|
1152
1254
|
```ts
|
|
1153
|
-
const db = await connect({ dsn, schema
|
|
1154
|
-
await db
|
|
1155
|
-
|
|
1156
|
-
|
|
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) …
|
|
1157
1263
|
```
|
|
1158
1264
|
|
|
1159
1265
|
Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
|
|
1160
|
-
больше не утечка данных соседнего
|
|
1161
|
-
|
|
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-аккаунт схемы.
|
|
1162
1296
|
|
|
1163
1297
|
### 10.7 Анонимизация (GDPR): `.anonymize()`
|
|
1164
1298
|
|
|
@@ -1276,7 +1410,7 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
|
|
|
1276
1410
|
**живой прогон** на едином полигоне `v1.salondemo` ~980 000 строк Entity (год окон-смен и
|
|
1277
1411
|
записей: 440 000 записей-наследников окон ×2 версии, каталог из 600 услуг, 360 мастеров,
|
|
1278
1412
|
40 000 клиентов, 1026 цен, 1260 навыков); воспроизводитель — `bench/api-reference-demo.mjs` (только
|
|
1279
|
-
читает полигон salon-seed, мутирует лишь свои сущности).
|
|
1413
|
+
читает полигон salon-seed, мутирует лишь свои сущности).
|
|
1280
1414
|
id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; повторяющиеся
|
|
1281
1415
|
`account`/`owner`/`partition` в ответах опущены.
|
|
1282
1416
|
|
|
@@ -1297,21 +1431,23 @@ id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; по
|
|
|
1297
1431
|
| `opts.dsn` | `string` | — | строка подключения `postgres://user:pass@host:port/db` |
|
|
1298
1432
|
| `opts.schema` | `string` | — | ПОЛНОЕ имя PG-схемы с версией движка: `'v1.booking'` (создаёт `up({schema: 'booking', version: 1})` либо `db/apply.mjs --schema=booking --version=1`; либа префикс не достраивает) |
|
|
1299
1433
|
| `opts.partition` | `string?` | `'entity'` | партиция данных: все чтения/записи этого подключения живут в ней |
|
|
1300
|
-
| `opts.account` | `uuid?` | System-аккаунт схемы | default-`account` (арендатор) новых строк |
|
|
1301
|
-
| `opts.owner` | `uuid?` | = `account` | default-`owner` (владелец) новых строк |
|
|
1302
1434
|
| `opts.max` | `number?` | `10` | размер пула соединений postgres.js |
|
|
1303
|
-
| `opts.enforceAccount` | `boolean?` |
|
|
1304
|
-
| `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. Выключенный печатает предупреждение один раз на процесс |
|
|
1305
1437
|
| `opts.onQuery` | `((e: QueryEvent) => void)?` | — | хук на каждый запрос цепочки: `{mode, classes, ms, rows, slow}` (§ 10.10) |
|
|
1306
1438
|
| `opts.slowMs` | `number?` | — | порог медленного запроса: `ms > slowMs` → `e.slow = true`; если `onQuery` не задан — `console.warn` |
|
|
1307
1439
|
|
|
1308
1440
|
**Назначение и алгоритм.** Открывает пул postgres.js (timestamptz парсится **строкой**,
|
|
1309
1441
|
чтобы не терять микросекунды в `asOf`/курсорах), одним SELECT загружает реестр классов из
|
|
1310
1442
|
`Schema` (компилируя fastest-validator на класс), резолвит System-аккаунт как fallback для
|
|
1311
|
-
NOT NULL `Entity.account`.
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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*`.
|
|
1315
1451
|
|
|
1316
1452
|
**Примеры**
|
|
1317
1453
|
|
|
@@ -1325,26 +1461,62 @@ await dbSlow.запись().count() // ~440k сущностей — дольш
|
|
|
1325
1461
|
// {"mode":"count","classes":["booking"],"ms":3191.0,"rows":1,"slow":true}
|
|
1326
1462
|
```
|
|
1327
1463
|
|
|
1328
|
-
**Кейс: три
|
|
1464
|
+
**Кейс: три хендла — админский, изолированный, под ACL**
|
|
1329
1465
|
|
|
1330
1466
|
```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 — видит всех
|
|
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 — админский видит всех
|
|
1335
1471
|
await iso.Организация().count() // [5.1 ms] → 1 — только свой арендатор
|
|
1336
1472
|
await uc.Организация().rows()
|
|
1337
1473
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default)
|
|
1338
1474
|
await iso.close(); await uc.close()
|
|
1339
1475
|
```
|
|
1340
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
|
+
|
|
1341
1513
|
### 11.1a up(opts)
|
|
1342
1514
|
|
|
1343
1515
|
#### `up(opts: UpOpts): Promise<EntityDb>`
|
|
1344
1516
|
|
|
1345
1517
|
| Параметр | Тип | Default | Описание |
|
|
1346
1518
|
|---|---|---|---|
|
|
1347
|
-
| `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`), базы нет → создаётся. Контейнер создаётся только если проба не прошла И хост локальный; для нелокального хоста — только ожидание готовности |
|
|
1348
1520
|
| `opts.schema` | `string` | — | **базовое** имя схемы БЕЗ версии и точек (`'booking'`) |
|
|
1349
1521
|
| `opts.version` | `number` | — | версия движка, целое ≥ 1: итоговая PG-схема `"v<N>.<schema>"`; бамп руками при breaking-изменении DDL |
|
|
1350
1522
|
| `opts.container` | `string?` | `'letopis-timescale'` | имя dev-контейнера |
|
|
@@ -1355,7 +1527,7 @@ await iso.close(); await uc.close()
|
|
|
1355
1527
|
| `opts.fresh` | `boolean?` | `false` | дропнуть схему и накатить заново — **данные схемы теряются** |
|
|
1356
1528
|
| `opts.quiet` | `boolean?` | `false` | без `[letopis.up]`-прогресса в консоли |
|
|
1357
1529
|
| `opts.waitTimeoutMs` | `number?` | `120 000` | максимум ожидания готовности (первый запуск: pull образа + initdb) |
|
|
1358
|
-
| …остальные | `ConnectOpts` | — | `partition`/`
|
|
1530
|
+
| …остальные | `ConnectOpts` | — | `partition`/`enforceAccount`/`enforceAcl`/`onQuery` и все опции `connect()` прокидываются насквозь; возвращённый `db` — такой же безличный корень (арендатора даёт `db.as()`, § 10.6) |
|
|
1359
1531
|
|
|
1360
1532
|
**Назначение и алгоритм.** Одна точка входа: от пустой машины до готового `db`.
|
|
1361
1533
|
(1) **Probe**: одна попытка `SELECT 1` по `dsn` — живой postgres (свой контейнер, CI-сервис,
|
|
@@ -1439,9 +1611,40 @@ import type {
|
|
|
1439
1611
|
### 11.2 EntityDb — корень
|
|
1440
1612
|
|
|
1441
1613
|
`EntityDb` — Proxy: любое имя класса из `Schema` (id или алиас) — метод старта цепочки;
|
|
1442
|
-
плюс фиксированные члены `begin/commit/rollback/batch/watch/close/registry/sql` и фасады
|
|
1614
|
+
плюс фиксированные члены `as/begin/commit/rollback/batch/watch/close/registry/sql` и фасады
|
|
1443
1615
|
`accounts/credentials/resources/rules` (§ 11.8), `auth` (§ 11.9), `acl` (§ 11.10).
|
|
1444
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
|
+
|
|
1445
1648
|
#### `db.<Класс>(filter?: Filter): Chain`
|
|
1446
1649
|
|
|
1447
1650
|
| Параметр | Тип | Описание |
|
|
@@ -1873,6 +2076,33 @@ for (;;) {
|
|
|
1873
2076
|
}
|
|
1874
2077
|
```
|
|
1875
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
|
+
|
|
1876
2106
|
#### `.deep(max = 32): Chain`
|
|
1877
2107
|
|
|
1878
2108
|
| Параметр | Тип | Описание |
|
|
@@ -1995,9 +2225,11 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms]
|
|
|
1995
2225
|
|---|---|---|
|
|
1996
2226
|
| `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`; при `create()` — значение колонки |
|
|
1997
2227
|
|
|
1998
|
-
**Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree).
|
|
1999
|
-
|
|
2000
|
-
|
|
2228
|
+
**Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree). Модификатор патчит
|
|
2229
|
+
**только свой шаг** — арендатор всей цепочки задаётся не им, а хендлом `db.as()` (§ 10.6):
|
|
2230
|
+
под `enforceAccount` он фильтрует каждый шаг сам, а чужой `.account()` — ошибка `pinned`.
|
|
2231
|
+
Под `enforceAcl` конфликт с пришпиленной правилом колонкой — ошибка `acl pins` (§ 11.10).
|
|
2232
|
+
Смысл `.account()` остаётся прежним: точечный фильтр/значение на админском хендле.
|
|
2001
2233
|
|
|
2002
2234
|
```ts
|
|
2003
2235
|
await db.Организация().account(SYS).count() // [5.1 ms] → 30
|
|
@@ -2577,9 +2809,13 @@ await db.accounts.delete(SYS) // [6.0 ms]
|
|
|
2577
2809
|
Предохранители (до сноса): вызывать может лишь **Owner/System**; нельзя снести **последний enabled Owner**
|
|
2578
2810
|
(лок-аут тенанта) и **свой** аккаунт сессии.
|
|
2579
2811
|
|
|
2812
|
+
Вызывать нужно **со scoped-хендла** — предохранители смотрят, кто именно зовёт:
|
|
2813
|
+
|
|
2580
2814
|
```ts
|
|
2581
|
-
const dbSys = await connect({ dsn, schema: 'v1.notify'
|
|
2815
|
+
const dbSys = await (await connect({ dsn, schema: 'v1.notify' })).as(SYS)
|
|
2582
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)
|
|
2583
2819
|
```
|
|
2584
2820
|
|
|
2585
2821
|
#### `db.schema.define(def)` / `db.reloadSchema()` — живой подхват правок схемы
|
|
@@ -3151,7 +3387,7 @@ app.use(async (req, res, next) => {
|
|
|
3151
3387
|
`booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
|
|
3152
3388
|
ключи pattern (реальные колонки Entity, `"$account"` → id субъекта) возвращаются как
|
|
3153
3389
|
`filter` — готовый предикат строк. Справочный метод (SQL за категориями аккаунта на каждый
|
|
3154
|
-
вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный
|
|
3390
|
+
вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный при `db.as()`
|
|
3155
3391
|
(микросекунды, memo).
|
|
3156
3392
|
|
|
3157
3393
|
**Примеры**
|
|
@@ -3172,33 +3408,44 @@ await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий
|
|
|
3172
3408
|
**Кейс: enforceAcl — те же решения в SQL цепочек (реальный прогон)**
|
|
3173
3409
|
|
|
3174
3410
|
```ts
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
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, класс целиком
|
|
3179
3417
|
await uc.Организация().rows()
|
|
3180
3418
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
|
|
3181
3419
|
const [z] = await uc.запись().create({ start_datetime: t, end_datetime: e })
|
|
3182
|
-
.Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [
|
|
3420
|
+
.Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [16.2 ms]
|
|
3183
3421
|
z.owner === acc.id // → true — owner пришпилен правилом
|
|
3184
3422
|
await uc.запись().owner(SYS).create({ … }).rows()
|
|
3185
|
-
// Error: letopis: acl pins booking writes to owner 06d3bbfe-… — на терминале [
|
|
3186
|
-
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 не принимает
|
|
3187
3425
|
// Error: letopis: class "booking" computes id (uuid v5 from Staff, start_datetime) — remove the explicit id
|
|
3188
|
-
await uc.запись(свойId).delete({ confirm: true }).rows() // [
|
|
3426
|
+
await uc.запись(свойId).delete({ confirm: true }).rows() // [27.8 ms] → [{ id: 'ee65244b-…', $deleted: true }]
|
|
3189
3427
|
// watch: события только безусловных allow-классов —
|
|
3190
3428
|
// uc.watch(cb) поймал ['Service']; booking скрыт (предикат не проверить по payload)
|
|
3191
3429
|
```
|
|
3192
3430
|
|
|
3193
3431
|
#### `db.acl.reload(): void`
|
|
3194
3432
|
|
|
3195
|
-
Параметров нет. Сбрасывает кэш Resource/Rule
|
|
3196
|
-
перечитает словарь). На **enforceAcl-цепочки не влияет** —
|
|
3197
|
-
|
|
3198
|
-
|
|
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()`).
|
|
3199
3445
|
|
|
3200
3446
|
```ts
|
|
3201
|
-
db.acl.reload()
|
|
3447
|
+
db.acl.reload() // [187 µs] — словарь фасада
|
|
3448
|
+
await db.reloadSchema() // классы + энфорсер цепочек, без реконнекта
|
|
3202
3449
|
```
|
|
3203
3450
|
|
|
3204
3451
|
**Кейс:** админка сохранила правило → `reload()` в том же процессе, чтобы `check` следующего
|
|
@@ -3317,14 +3564,19 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
|
|
|
3317
3564
|
| `duplicate link slot "X"` | один конец задан слотом дважды в одной записи |
|
|
3318
3565
|
| `set() split into create()/update() (0.16.0)` | старый глагол записи — create() вставляет, update() версионирует найденное |
|
|
3319
3566
|
| `slot .delete() renamed to .unset() (0.16.0)` | старое имя слот-снятия |
|
|
3320
|
-
| `.link() removed (0.15.0)` | снесённый `.link()` — теперь слот `.Класс.set()` |
|
|
3321
3567
|
| `execute() renamed to run() (0.11.0)` | старое имя терминала путей (и `batch.execute()`) |
|
|
3322
3568
|
| `plan is queued in the batch — call batch.run()` | терминал на батч-цепочке с операциями |
|
|
3323
3569
|
| `no path X → Y` / `LINK → LINK …` | недопустимый переход (синхронно при построении цепочки) |
|
|
3324
|
-
| `
|
|
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-аккаунта в схеме |
|
|
3325
3575
|
| `violates foreign key constraint "entity_*_fk"` | несуществующий класс/аккаунт; удаление класса с данными |
|
|
3326
3576
|
| `lock() works only inside db.begin()` | лок вне транзакции |
|
|
3327
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) |
|
|
3328
3580
|
| `letopis.up: bad schema name "X"` / `bad version` | `up()`: имя с точкой/версией либо version не целое ≥ 1 |
|
|
3329
3581
|
| `letopis.up: docker CLI not found…` | `up()`: docker не установлен, а postgres на dsn не отвечает |
|
|
3330
3582
|
| `letopis.up: postgres not ready in N s…` / `redis not ready` | `up()`: контейнер не поднялся за `waitTimeoutMs` (подсказка: `docker logs`) |
|
|
@@ -3345,29 +3597,63 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
|
|
|
3345
3597
|
## 14. Производительность
|
|
3346
3598
|
|
|
3347
3599
|
Тайминги — живые прогоны демо на ЕДИНОМ полигоне `v1.salondemo` (`bench/salon-seed.mjs`,
|
|
3348
|
-
|
|
3349
|
-
(`bench/salon-article-demo.mjs`), API Reference
|
|
3350
|
-
читают один и тот же полигон, мутируя лишь свои
|
|
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).
|
|
3351
3610
|
|
|
3352
3611
|
| Операция | Время |
|
|
3353
3612
|
|---|---|
|
|
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
|
-
|
|
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
|
+
Тайминги в таблице выше сняты на классах-листьях и полиморфизмом не затронуты.
|
|
3371
3657
|
- Containment и обход графа — GIN; операторы — на уже суженном наборе.
|
|
3372
3658
|
- Начинайте цепочку с самого селективного шага (Организация/Мастер/Клиент, не `запись()`).
|
|
3373
3659
|
- `count()` считает пути; число сущностей дешевле берётся `ids().length`.
|
|
@@ -3379,6 +3665,39 @@ TOAST-порог (data > 2KB): 0 строк.
|
|
|
3379
3665
|
`node bench/history.bench.mjs --entities=10000 --versions=100`, оверхед ACL —
|
|
3380
3666
|
`npx tsx bench/acl.bench.mjs` (таблица в § 9.2).
|
|
3381
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
|
+
|
|
3382
3701
|
---
|
|
3383
3702
|
|
|
3384
3703
|
## 15. E2E-пример: барбершоп
|
|
@@ -3437,12 +3756,20 @@ await db.запись(bId).delete({ confirm: true }).rows() // отм
|
|
|
3437
3756
|
## 16. Тесты
|
|
3438
3757
|
|
|
3439
3758
|
```bash
|
|
3440
|
-
cd lib && npm test #
|
|
3759
|
+
cd lib && npm test # весь набор против живого docker (timescale + redis); счёт кейсов — в CHANGELOG
|
|
3760
|
+
# актуального релиза. По файлам:
|
|
3441
3761
|
# acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
|
|
3442
|
-
# с $account, enforceAcl (предикаты в SQL, каскад, watch)
|
|
3762
|
+
# с $account, enforceAcl (предикаты в SQL, каскад, watch);
|
|
3763
|
+
# полиморфное чтение по иерархии + .exact(); deny на потомке
|
|
3764
|
+
# не обходится шагом по родителю (проверка на утечку)
|
|
3443
3765
|
# api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
|
|
3444
3766
|
# auth — db.auth: пароль/api-key/key-secret/TOTP/OTP/link+lookup,
|
|
3445
3767
|
# глобальная идентичность, сессии на живом Redis (expire/revoke)
|
|
3768
|
+
# parity — паритет клиент ↔ триггер БД: один и тот же кейс исполняется
|
|
3769
|
+
# через либу и голым INSERT, оба обязаны решить одинаково
|
|
3770
|
+
# (abstract, неизвестный класс, обязательные/optional/союзные
|
|
3771
|
+
# концы, посторонняя связь) + закреплена известная асимметрия:
|
|
3772
|
+
# валидацию data делает только либа
|
|
3446
3773
|
# plan — план-модель: несколько операций, fan-out, self-update,
|
|
3447
3774
|
# превью/confirm delete, ОТКАТ плана, слот-перевес, батч-гард
|
|
3448
3775
|
# resilience — ретраи 40P01/40001, реальный deadlock двух транзакций,
|
|
@@ -3459,9 +3786,12 @@ cd lib && npm test # 167/167 тестов против живого docker (t
|
|
|
3459
3786
|
# свои сиды/seeds:false, автосоздание базы, валидация
|
|
3460
3787
|
# salon — ТЕСТ-ПЛАН: имитация салона, ВСЕ 145 публичных API
|
|
3461
3788
|
# (21 акт + матрица покрытия); слоты/pivot/entity
|
|
3462
|
-
# wave2 — asOf/versions, keyset-курсор,
|
|
3789
|
+
# wave2 — asOf/versions, keyset-курсор, enforceAccount, anonymize; gen-types:
|
|
3790
|
+
# форма фасада, слоты, отсутствие снесённых имён + compile-гейт (tsc)
|
|
3463
3791
|
# wave3 — or/not, агрегации, deep, watch
|
|
3464
3792
|
# wave4 — compression-политики (чтение сжатого чанка), schema-sync, onQuery
|
|
3793
|
+
# wave5 — физический purge (двухфазность, замыкание, accounts.purge),
|
|
3794
|
+
# withDeleted, живой подхват схемы (schema.define/reloadSchema)
|
|
3465
3795
|
npm run bench # производительность на 105k строк (§ 14)
|
|
3466
3796
|
```
|
|
3467
3797
|
|