letopis 0.16.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -5
- package/README.md +924 -544
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.js +112 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +36 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +73 -0
- package/dist/sessions.js +66 -0
- package/dist/sql.js +90 -1
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.js +36 -0
- package/dist/write.js +148 -2
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +2 -1
- package/scripts/gen-types.mjs +154 -0
- package/scripts/schema-sync.mjs +185 -0
- package/sql/ddl.sql +71 -1
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +60 -18
package/README.md
CHANGED
|
@@ -9,12 +9,56 @@ import { connect } from 'letopis'
|
|
|
9
9
|
const db = await connect({ dsn: 'postgres://…', schema: 'v1.booking' })
|
|
10
10
|
|
|
11
11
|
// чтение: пути по графу
|
|
12
|
-
const пути = await db
|
|
13
|
-
// [ {
|
|
12
|
+
const пути = await db.Мастер({ name: 'Вася' }).навык().Услуга().run()
|
|
13
|
+
// [ { Мастер: Row, навык: Row, Услуга: Row }, … ]
|
|
14
14
|
|
|
15
15
|
// запись: операции — звенья, исполняет терминал
|
|
16
|
-
await db.Организация(org)
|
|
17
|
-
```
|
|
16
|
+
await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Возможности
|
|
20
|
+
|
|
21
|
+
**Хранилище — темпоральное, append-only**
|
|
22
|
+
- Каждое изменение — новая версия-строка; история — first-class (§2, §10).
|
|
23
|
+
- Единая `Entity`-hypertable на TimescaleDB, партиции по времени `updated` (§2.1).
|
|
24
|
+
- Весь CRUD на уровне БД триггерами: `UPDATE` = новая версия, `DELETE` = tombstone + рекурсивный каскад (§2.2).
|
|
25
|
+
- FK-целостность с RESTRICT на append-only (§2.3); срез актуального через `DISTINCT ON` (§2.4).
|
|
26
|
+
|
|
27
|
+
**Схема и классы**
|
|
28
|
+
- Реестр классов из таблицы `Schema`: `HUB` / `LINK`, наследование `attributes` по иерархии (§3, §3.3).
|
|
29
|
+
- Строгая валидация данных — fastest-validator DSL, вложенные объекты любой глубины (§3, §6).
|
|
30
|
+
- Связи v2: союзы ролей (`{A|B|C}`), optional-концы, жадный матчинг (§3.1).
|
|
31
|
+
- Правило id в схеме: uuid v4 / v7 (time-ordered) / v5 (детерминированный → идемпотентный create) (§3.2).
|
|
32
|
+
|
|
33
|
+
**Чтение — dot-цепочки (graph-query)**
|
|
34
|
+
- Пути по графу HUB↔LINK, pivot-возврат, узел-переменная `entity()` (§4).
|
|
35
|
+
- Терминалы `run` / `rows` / `first` / `ids` / `count` — ленивый план, SQL уходит на терминале (§4).
|
|
36
|
+
- 18 операторов фильтров (`ne` `gt` `between` `inList` `like` `has` `exists` `not` `or`…), вложенные пути, касты по схеме (§5).
|
|
37
|
+
- Модификаторы: `limit` / `offset` / `sort`, `asOf`, `after` (keyset), `deep`, `tags` / `account` / `owner`, `alias` (§5).
|
|
38
|
+
|
|
39
|
+
**Запись**
|
|
40
|
+
- `create` / `update` / `delete` / `anonymize` — звенья плана; весь план одной транзакцией с откатом (§6).
|
|
41
|
+
- Слоты связей `.Класс.set()` / `.unset()`, вложенная цепочка как значение слота (§6.1).
|
|
42
|
+
- Deep-merge при `update`; идемпотентный `create` по вычисленному v5-id (§6).
|
|
43
|
+
- Транзакции `db.begin()` + advisory-lock (рецепт двойной брони, §7); батчи с multi-VALUES-склейкой (§8).
|
|
44
|
+
|
|
45
|
+
**История и время**
|
|
46
|
+
- `asOf()` («как было на T») + `versions()` (§10.1); keyset-пагинация `after()` / `cursorOf()` (§10.2).
|
|
47
|
+
- Агрегации в БД `sum` / `avg` / `min` / `max` / `countBy` (§10.3); деревья `deep()` recursive-CTE (§10.4).
|
|
48
|
+
- Realtime `watch()` через `pg_notify` + `onReconnect` (§10.5).
|
|
49
|
+
- Анонимизация (GDPR) `anonymize()` (§10.7); политики сжатия / retention Timescale (§10.8).
|
|
50
|
+
|
|
51
|
+
**Auth · ACL · сессии — встроенные**
|
|
52
|
+
- Пароли (scrypt), api-ключи, key-secret, external identity (oauth/sso/telegram), TOTP, одноразовые коды (§9.1).
|
|
53
|
+
- Сессии во внешнем Redis (клиент инжектируется, §9.1).
|
|
54
|
+
- ACL: `Resource` / `Rule`, `enforceAcl` — предикаты строк вливаются в SQL до сортировки/лимита; наследование прав по классам (§9.2).
|
|
55
|
+
- Изоляция арендатора `enforceAccount` (§10.6).
|
|
56
|
+
|
|
57
|
+
**Эксплуатация**
|
|
58
|
+
- `up()` — dev-bootstrap одной функцией: Docker (TimescaleDB pg17 + Redis) + схема + сиды + connect (§1, §1.1).
|
|
59
|
+
- Миграции классов `schema-sync` с отчётом совместимости (§10.9); TS-типы из схемы `gen-types` (§10.11).
|
|
60
|
+
- Наблюдаемость `onQuery` / `slowMs` (§10.10); ретраи deadlock/serialization, самопереподключение LISTEN (§10.12).
|
|
61
|
+
- Лёгкие зависимости: только `postgres` + `fastest-validator` (Redis-клиент внешний, `ioredis` опционален).
|
|
18
62
|
|
|
19
63
|
Содержание:
|
|
20
64
|
[1. Быстрый старт](#1-быстрый-старт) ·
|
|
@@ -53,7 +97,7 @@ const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v
|
|
|
53
97
|
// [letopis.up] connected (schema "v1.booking")
|
|
54
98
|
|
|
55
99
|
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
56
|
-
const [вася] = await db.Организация(org)
|
|
100
|
+
const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
|
|
57
101
|
await db.close()
|
|
58
102
|
```
|
|
59
103
|
|
|
@@ -61,7 +105,7 @@ await db.close()
|
|
|
61
105
|
Данные PG живут в named volume `letopis-pgdata` (кроссплатформенно, переживает пересоздание
|
|
62
106
|
контейнера); `dataDir: '/path'` — bind mount папки хоста. Полный контракт — [§11.1a](#111a-upopts).
|
|
63
107
|
|
|
64
|
-
Вручную (то же самое по шагам):
|
|
108
|
+
Вручную (то же самое по шагам) — **из клона репозитория** (`db/apply.mjs` — корневой скрипт репо, в npm-пакет не входит; в установленном пакете накат делает `up()` выше, а Docker-образ он собирает из вложенного `docker/`):
|
|
65
109
|
|
|
66
110
|
```bash
|
|
67
111
|
docker build -t letopis-db lib/docker
|
|
@@ -76,6 +120,88 @@ node db/apply.mjs --dsn=postgres://postgres:test@localhost:15432/clockz --schema
|
|
|
76
120
|
const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz', schema: 'v1.booking' })
|
|
77
121
|
```
|
|
78
122
|
|
|
123
|
+
### 1.1 Своя схема с нуля + всё в контейнере одной командой
|
|
124
|
+
|
|
125
|
+
Три шага: **(1)** сид своей схемы → **(2)** `up()` поднимает контейнер и накатывает → **(3)** пишешь данные. Таблицы (`Schema`, `Account`, `Entity`, …) создаёт `ddl.sql` — своему сиду нужны только INSERT-ы; маркер `<SCHEMA-NAME>` подставит `up()`.
|
|
126
|
+
|
|
127
|
+
**1. Сид `myapp.sql`** — классы в таблицу `Schema` + System-аккаунт:
|
|
128
|
+
|
|
129
|
+
```sql
|
|
130
|
+
-- myapp.sql — своя схема letopis
|
|
131
|
+
|
|
132
|
+
-- System-аккаунт: Entity.account/owner — NOT NULL, дефолт либа берёт ОТСЮДА
|
|
133
|
+
-- (connect ищет Account с категорией 'System'; без него первый create бросит ошибку)
|
|
134
|
+
INSERT INTO "<SCHEMA-NAME>"."Account" (id, categories, data)
|
|
135
|
+
VALUES ('00000000-0000-4000-8000-000000000001', '{System}', '{"name":"System"}')
|
|
136
|
+
ON CONFLICT (id) DO NOTHING;
|
|
137
|
+
|
|
138
|
+
-- Классы домена (нужен ≥1, иначе connect: "has no classes")
|
|
139
|
+
INSERT INTO "<SCHEMA-NAME>"."Schema"
|
|
140
|
+
(partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors)
|
|
141
|
+
VALUES
|
|
142
|
+
-- корень: дефолт id v7 наследуется; abstract — напрямую не создаётся
|
|
143
|
+
('entity','Entity','Сущность','HUB', NULL,
|
|
144
|
+
'{"id":{"type":"uuid","generate":7}}','{"abstract":true}','[]',100,'{Entity}'),
|
|
145
|
+
-- HUB: заметка
|
|
146
|
+
('entity','Note','Заметка','HUB','Entity',
|
|
147
|
+
'{"title":"string","body":"string|optional","done":{"type":"boolean","default":false}}',
|
|
148
|
+
'{}','[]',200,'{Note,Entity}'),
|
|
149
|
+
-- HUB: тег
|
|
150
|
+
('entity','Tag','Тег','HUB','Entity',
|
|
151
|
+
'{"name":"string"}','{}','[]',300,'{Tag,Entity}'),
|
|
152
|
+
-- LINK: заметка ↔ тег
|
|
153
|
+
('entity','tagged','помечена','LINK', NULL,
|
|
154
|
+
'{"id":{"type":"uuid","generate":7}}','{}',
|
|
155
|
+
'[{"class":"Note","cardinality":1},{"class":"Tag","cardinality":1}]',400,'{tagged}')
|
|
156
|
+
ON CONFLICT (partition, id) DO UPDATE SET
|
|
157
|
+
alias=EXCLUDED.alias, ancestor=EXCLUDED.ancestor, attributes=EXCLUDED.attributes,
|
|
158
|
+
meta=EXCLUDED.meta, links=EXCLUDED.links, "order"=EXCLUDED."order";
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**2. `up()` — контейнер + база + схема + connect одной командой:**
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { up } from 'letopis'
|
|
165
|
+
|
|
166
|
+
const db = await up({ schema: 'myapp', version: 1, seeds: ['./myapp.sql'] })
|
|
167
|
+
// [letopis.up] building image letopis-db … (первый раз тянет TimescaleDB-базу — минуты)
|
|
168
|
+
// [letopis.up] container letopis-timescale created (data: volume letopis-pgdata)
|
|
169
|
+
// [letopis.up] postgres ready in 5.0 s / redis ready on 16379
|
|
170
|
+
// [letopis.up] schema "v1.myapp" applied (2 files) ← ddl.sql + myapp.sql
|
|
171
|
+
// [letopis.up] connected (schema "v1.myapp")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Изолированное окружение (если на хосте уже крутится дефолтный контейнер или нужен отдельный):
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const db = await up({
|
|
178
|
+
schema: 'myapp', version: 1, seeds: ['./myapp.sql'],
|
|
179
|
+
container: 'myapp-db', // иначе дефолтный 'letopis-timescale'
|
|
180
|
+
image: 'myapp-timescale', // соберётся из пакетного docker/ (TimescaleDB pg17 + Redis)
|
|
181
|
+
dsn: 'postgres://postgres:secret@localhost:25432/myapp',
|
|
182
|
+
dataDir: 'myapp-pgdata', // named volume (или путь хоста = bind mount)
|
|
183
|
+
redisPort: 26379,
|
|
184
|
+
})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**3. Данные — работают сразу** (System-аккаунт из сида закрывает `account`/`owner`):
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const [n] = await db.Заметка().create({ title: 'Купить хлеб' }).rows()
|
|
191
|
+
const [t] = await db.Тег().create({ name: 'дом' }).rows()
|
|
192
|
+
await db.Заметка(n).помечена().create().Тег.set(t).rows() // связь Note ↔ Tag (§6.1)
|
|
193
|
+
const активные = await db.Заметка({ done: false }).rows()
|
|
194
|
+
await db.close()
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Гейты и рецепты**
|
|
198
|
+
- **≥1 класс** в `Schema` обязателен — иначе `up()` падает на connect: `has no classes` (§3).
|
|
199
|
+
- **System-аккаунт обязателен** (или передать `up({ …, account: '<uuid существующей Account>' })`) — иначе первый `create` бросит `Entity.account is NOT NULL …`.
|
|
200
|
+
- **Готовый auth из коробки** (пароли/ACL): добавь пакетный сид вторым — `seeds: ['./myapp.sql', 'node_modules/letopis/sql/seed.auth.sql']` (System + 16 Resource + 12 Rule); тогда свою `Account`-строку не пиши.
|
|
201
|
+
- **Идемпотентно**: контейнер жив → reuse; схема есть → накат пропущен. **Заново с нуля**: `fresh: true` (дроп схемы — данные теряются) либо снести контейнер: `docker rm -f myapp-db && docker volume rm myapp-pgdata`.
|
|
202
|
+
- **Prod / managed PG**: `dsn` на живой postgres → docker пропускается целиком (только база + схема + connect); либо накатить `sql/ddl.sql` + свой сид своим мигратором и `connect({ dsn, schema: 'v1.myapp' })`. Пакетный `docker/`-образ (Redis без пароля/персиста) — dev-удобство, не для прода.
|
|
203
|
+
- Локальный путь требует установленного и запущенного **Docker**; managed PG — без него.
|
|
204
|
+
|
|
79
205
|
---
|
|
80
206
|
|
|
81
207
|
## 2. Хранилище
|
|
@@ -117,7 +243,7 @@ const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz
|
|
|
117
243
|
UPDATE "v1.booking"."Entity" SET data = data || '{"duration":45}'
|
|
118
244
|
WHERE partition='entity' AND class='Service' AND id='…'; -- → новая версия
|
|
119
245
|
DELETE FROM "v1.booking"."Entity"
|
|
120
|
-
WHERE partition='entity' AND class='
|
|
246
|
+
WHERE partition='entity' AND class='booking' AND id='…'; -- → tombstone + каскад
|
|
121
247
|
```
|
|
122
248
|
|
|
123
249
|
Управление объёмом истории — только политики Timescale: `db/policies.mjs` (§ 10.8).
|
|
@@ -143,7 +269,7 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
143
269
|
|
|
144
270
|
| Поле | Смысл |
|
|
145
271
|
|---|---|
|
|
146
|
-
| `id` / `alias` | англ. id (`Staff`) и русский алиас (
|
|
272
|
+
| `id` / `alias` | англ. id (`Staff`) и русский алиас (`Мастер`) — равноправны в API |
|
|
147
273
|
| `category` | `HUB` (сущность) \| `LINK` (связь с атрибутами) |
|
|
148
274
|
| `ancestor` | прямой родитель |
|
|
149
275
|
| `ancestors` | `[self, parent, …, root]` — **считает триггер `schema_lineage`** |
|
|
@@ -158,22 +284,22 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
158
284
|
|
|
159
285
|
```jsonc
|
|
160
286
|
"links": [
|
|
161
|
-
{ "class": "
|
|
162
|
-
{ "classes": ["Service", "Complex"], "cardinality": 1 },
|
|
163
|
-
{ "class": "
|
|
287
|
+
{ "class": "Staff", "cardinality": 1 }, // один класс
|
|
288
|
+
{ "classes": ["Service", "Product", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
|
|
289
|
+
{ "class": "Customer", "optional": true, "cardinality": 1 } // конец может отсутствовать
|
|
164
290
|
]
|
|
165
291
|
```
|
|
166
292
|
|
|
167
293
|
- `Entity` в концах не используется — классы называются явно («максимально точная идентификация связи»)
|
|
168
|
-
- **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет
|
|
169
|
-
- Обязательный конец без ключа → `requires end "Service|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
|
|
294
|
+
- **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет записи — `{Услуга|Товар|Комплекс}` (бронь услуги ИЛИ продажа товара ИЛИ комплекс — ровно один из)
|
|
295
|
+
- Обязательный конец без ключа → `requires end "Service|Product|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
|
|
170
296
|
- `cardinality` — зарезервировано (0 — безлимит, N — точное число), пока не проверяется: связь класса в строке одна (`{Класс: id}`), множественность выражается строками-связками
|
|
171
|
-
-
|
|
172
|
-
- Источник правды —
|
|
297
|
+
- Демо-домен (0.17, позитивная доступность): `адрес = [Мастер, Локация]`, `окно = [Мастер, Локация, Расписание]` (смена — интервал доступности), `запись = [Мастер, Локация, Расписание, {Услуга|Товар|Комплекс}, Клиент?]` — **наследник окна**, `содержимое = [Папка, {Услуга|Товар|Комплекс}]`, `состав = [Комплекс, {Услуга|Товар}]`, `навык = [Мастер, Услуга]`, `цена = [{Услуга|Товар|Комплекс}]` (варианты цены: `note` + `amounts` record<валюта,число>)
|
|
298
|
+
- Источник правды демо-домена — сид `lib/sql/seed.booking.sql` (редактируется руками, идемпотентен)
|
|
173
299
|
|
|
174
300
|
`schema_lineage` (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
|
|
175
301
|
пересчитывает `ancestors`/`descendants` при любом изменении Schema.
|
|
176
|
-
Из либы: `db.registry.resolve('связь').descendants` → `['
|
|
302
|
+
Из либы: `db.registry.resolve('связь').descendants` → `['address','booking','compo','content','price','skill','slot']`.
|
|
177
303
|
|
|
178
304
|
### 3.2 id считает схема: `attributes.id`
|
|
179
305
|
|
|
@@ -199,21 +325,31 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
199
325
|
без создания чего-либо.
|
|
200
326
|
|
|
201
327
|
```ts
|
|
202
|
-
//
|
|
203
|
-
const [б] = await db
|
|
204
|
-
|
|
328
|
+
// запись: id считается сам — uuidv5(мастер, старт); правило унаследовано от окна
|
|
329
|
+
const [б] = await db.Мастер(м).запись().create({ start_datetime: t, end_datetime: e })
|
|
330
|
+
.Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
|
|
331
|
+
б.id === uuidv5(`v1.booking:entity:booking:${м.id}:${t}`) // → true
|
|
205
332
|
// «занято?» — ДО создания чего-либо:
|
|
206
|
-
await db
|
|
333
|
+
await db.запись(uuidv5(`v1.booking:entity:booking:${м.id}:${t}`)).first() // Row | null
|
|
207
334
|
```
|
|
208
335
|
|
|
209
336
|
Явный id у v5-класса запрещён (`computes id` — его всегда считает схема); в батче
|
|
210
337
|
v5-классы не склеиваются в multi-VALUES (id нужны концы) — исполняются поштучно (§ 8).
|
|
211
338
|
|
|
212
339
|
Демо-схема booking: корни `Entity`/`link` объявляют дефолт `{generate: 7}` один раз;
|
|
213
|
-
v7-классы (Org/Staff/
|
|
214
|
-
правила; v5
|
|
215
|
-
`
|
|
216
|
-
|
|
340
|
+
v7-классы (Org/Staff/Customer/Location/Schedule/Folder) наследуют его без собственного
|
|
341
|
+
правила; v5 объявляются по одному разу и наследуются дальше:
|
|
342
|
+
`Element ← v5(Org, data.name)` — наследуют Услуга/Товар/Комплекс (имя уникально в
|
|
343
|
+
организации, classId различает классы); `окно (slot) ← v5(Staff, data.start_datetime)` —
|
|
344
|
+
наследует `запись (booking)`: двойная бронь мертва самим id записи, а окно и запись
|
|
345
|
+
на одно время сосуществуют (classId в формуле разный); `адрес ← v5(Staff, Location)`,
|
|
346
|
+
`навык ← v5(Staff, Service)`, `состав ← v5(Complex, Service|Product)`,
|
|
347
|
+
`содержимое ← v5(Folder, Service|Product|Complex)` — полные имена союзов в from;
|
|
348
|
+
`цена ← v5(Service|Product|Complex, note)` — вариант цены уникален по (элемент, note).
|
|
349
|
+
|
|
350
|
+
`from`-поле может быть **необязательным**: если его нет в `create`, id берёт его `default`
|
|
351
|
+
из Schema (напр. `цена` без `note` → `note:'базовая'`, id считается стабильно). Так
|
|
352
|
+
необязательное поле участвует в детерминированном id, не ломая генерацию.
|
|
217
353
|
|
|
218
354
|
### 3.3 Наследование attributes
|
|
219
355
|
|
|
@@ -222,6 +358,194 @@ data.start)`, `Service`/`Complex` ← `v5(Org, data.name)` — имя уника
|
|
|
222
358
|
наследуется так же — дефолт объявляется один раз на корне иерархии; `links` НЕ наследуются:
|
|
223
359
|
концы объявляет каждый класс сам. Валидатор и типы полей компилируются из слитых attributes.
|
|
224
360
|
|
|
361
|
+
### 3.4 Примеры схем из таблицы `Schema`
|
|
362
|
+
|
|
363
|
+
Демо-домен booking — **20 классов** в `lib/sql/seed.booking.sql` (**источник правды**:
|
|
364
|
+
правится руками, идемпотентен — по строке-INSERT на класс). Файл входит в npm-пакет;
|
|
365
|
+
`up({ schema: 'booking' })` накатывает его дефолтом. Свой домен — тем же форматом:
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
await up({ schema: 'salon', version: 1, seeds: ['./salon.sql'] }) // свой сид вместо демо
|
|
369
|
+
// seed.auth.sql грузится отдельно и в Schema НЕ пишет — только Account/Resource/Rule (§9)
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Таблица `Schema` (`lib/sql/ddl.sql`) — по строке на класс:
|
|
373
|
+
|
|
374
|
+
```sql
|
|
375
|
+
CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
|
|
376
|
+
partition text NOT NULL DEFAULT 'entity',
|
|
377
|
+
id text NOT NULL, -- англ. id класса: 'Staff', 'skill'
|
|
378
|
+
alias text NOT NULL, -- рус. алиас: 'Мастер', 'навык'
|
|
379
|
+
category text NOT NULL CHECK (category IN ('HUB', 'LINK')),
|
|
380
|
+
ancestor text, -- прямой родитель (наследование)
|
|
381
|
+
attributes jsonb NOT NULL DEFAULT '{}', -- fastest-validator DSL (валидирует либа)
|
|
382
|
+
links jsonb NOT NULL DEFAULT '[]', -- массив объектов-концов (§3.1)
|
|
383
|
+
meta jsonb NOT NULL DEFAULT '{}', -- {abstract?, description?, appearance?}
|
|
384
|
+
"order" int NOT NULL DEFAULT 0,
|
|
385
|
+
ancestors text[] NOT NULL DEFAULT '{}', -- [self, parent, …, root] — считает триггер schema_lineage
|
|
386
|
+
descendants text[] NOT NULL DEFAULT '{}', -- все потомки (транзитивно) — считает триггер schema_lineage
|
|
387
|
+
PRIMARY KEY (partition, id)
|
|
388
|
+
);
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Формат строки (один на все 20; `<SCHEMA-NAME>` подставляет `up()` реальным именем PG-схемы):
|
|
392
|
+
|
|
393
|
+
```sql
|
|
394
|
+
INSERT INTO "<SCHEMA-NAME>"."Schema"
|
|
395
|
+
(partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors)
|
|
396
|
+
VALUES ('entity', 'Service', 'Услуга', 'HUB', 'Element',
|
|
397
|
+
'{"duration":"number|min:0|optional"}', -- attributes: DSL fastest-validator (§3)
|
|
398
|
+
'{"abstract":false,"description":"…"}', -- meta
|
|
399
|
+
'[{"class":"Org","cardinality":1}]', -- links: концы (§3.1); НЕ наследуются
|
|
400
|
+
800, '{Service,Element,Entity}') -- order + ancestors
|
|
401
|
+
ON CONFLICT (partition, id) DO UPDATE SET …; -- повторный накат = правка класса
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
- `id` + `alias` — англ. и рус. имя, **равноправны** в API: `db.Service(…)` ≡ `db.Услуга(…)`.
|
|
405
|
+
- `descendants` не пишем — считает триггер `schema_lineage`; `ancestors` он тоже пересчитает.
|
|
406
|
+
- `attributes.id` — правило рождения id (§3.2), наследуется по `ancestor` (§3.3).
|
|
407
|
+
|
|
408
|
+
Ниже — 4 характерные строки verbatim из сида и **как ими пользоваться**.
|
|
409
|
+
|
|
410
|
+
#### HUB · вложенный object — `Org` (Организация)
|
|
411
|
+
|
|
412
|
+
```jsonc
|
|
413
|
+
// attributes:
|
|
414
|
+
{
|
|
415
|
+
"name": "string",
|
|
416
|
+
"timezone": { "type": "string", "default": "Europe/Moscow" },
|
|
417
|
+
"address": "string|optional",
|
|
418
|
+
"phone": "string|optional",
|
|
419
|
+
"active": { "type": "boolean", "default": true },
|
|
420
|
+
"settings": { "type": "object", "optional": true, "strict": true, "props": {
|
|
421
|
+
"booking": { "type": "object", "optional": true, "strict": true, "props": {
|
|
422
|
+
"deposit": { "type": "object", "optional": true, "strict": true, "props": {
|
|
423
|
+
"amount": "number|integer|min:0|default:0",
|
|
424
|
+
"currency": { "type": "enum", "values": ["RUB","USD","EUR","AED"], "default": "RUB" }
|
|
425
|
+
}},
|
|
426
|
+
"autoconfirm": "boolean|default:false"
|
|
427
|
+
}}
|
|
428
|
+
}}
|
|
429
|
+
}
|
|
430
|
+
// links: [] ← корневой HUB, арендатор сам себе владелец (концов нет)
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
// create: defaults подставит схема (timezone, active); вложенный object любой глубины
|
|
435
|
+
const [org] = await db.Организация().create({
|
|
436
|
+
name: 'BarberPro',
|
|
437
|
+
settings: { booking: { deposit: { amount: 500, currency: 'RUB' }, autoconfirm: true } },
|
|
438
|
+
}).rows()
|
|
439
|
+
|
|
440
|
+
// правка одного листа — deep-merge, соседи целы (§6): autoconfirm и currency сохранятся
|
|
441
|
+
await db.Организация(org).update({ settings: { booking: { deposit: { amount: 1000 } } } }).rows()
|
|
442
|
+
|
|
443
|
+
// фильтр по вложенному пути любой глубины (§5):
|
|
444
|
+
await db.Организация({ settings: { booking: { autoconfirm: true } } }).rows()
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Поле вне `attributes` → `ValidationError` (валидация строгая, `$$strict`).
|
|
448
|
+
|
|
449
|
+
#### HUB · наследуемый v5-id — `Element → Service` (Услуга)
|
|
450
|
+
|
|
451
|
+
`Element` объявляет правило id **один раз**; `Service`/`Product`/`Complex` наследуют (§3.3):
|
|
452
|
+
|
|
453
|
+
```jsonc
|
|
454
|
+
// Element (abstract): attributes
|
|
455
|
+
{ "id": { "type": "uuid", "generate": 5, "from": ["Org", "name"] }, // id = uuidv5(Org, name)
|
|
456
|
+
"name": "string", "description": "string|optional" }
|
|
457
|
+
// links: [{ "class": "Org", "cardinality": 1 }]
|
|
458
|
+
|
|
459
|
+
// Service (ancestor: Element): attributes — добавляет только своё поле
|
|
460
|
+
{ "duration": "number|min:0|optional" } // name и правило id унаследованы от Element
|
|
461
|
+
// links: [{ "class": "Org", "cardinality": 1 }] ← links НЕ наследуются, объявлены заново
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
// create: id НЕ передаём — схема считает uuidv5(Org, name); повтор той же пары = та же услуга
|
|
466
|
+
const [svc] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60 }).rows()
|
|
467
|
+
svc.id === uuidv5(`v1.booking:entity:Service:${org.id}:Стрижка`) // → true (§3.2)
|
|
468
|
+
// имя уникально в организации; classId в формуле различает Услугу и Товар с тем же именем
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
#### LINK · наследование + союз ролей + optional — `slot → booking` (запись)
|
|
472
|
+
|
|
473
|
+
`booking` наследует `slot` (интервал + правило id), добавляет предмет записи и клиента:
|
|
474
|
+
|
|
475
|
+
```jsonc
|
|
476
|
+
// slot (окно): attributes
|
|
477
|
+
{ "id": { "type": "uuid", "generate": 5, "from": ["Staff", "start_datetime"] },
|
|
478
|
+
"start_datetime": { "type": "date", "convert": true },
|
|
479
|
+
"end_datetime": { "type": "date", "convert": true } }
|
|
480
|
+
// links: [{class:"Staff"}, {class:"Location"}, {class:"Schedule"}] (все cardinality:1)
|
|
481
|
+
|
|
482
|
+
// booking (ancestor: slot): attributes — только своё; start/end и правило id унаследованы
|
|
483
|
+
{ "notes": "string|optional" }
|
|
484
|
+
// links:
|
|
485
|
+
[ { "class": "Staff", "cardinality": 1 },
|
|
486
|
+
{ "class": "Location", "cardinality": 1 },
|
|
487
|
+
{ "class": "Schedule", "cardinality": 1 },
|
|
488
|
+
{ "classes": ["Service","Product","Complex"], "cardinality": 1 }, // союз: РОВНО один из
|
|
489
|
+
{ "class": "Customer", "optional": true, "cardinality": 1 } ] // конец может отсутствовать
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
// владелец (Мастер) — из пути; прочие концы — слотами (§6.1); id считает схема из (Staff, start)
|
|
494
|
+
await db.Мастер(m).запись().create({ start_datetime: t, end_datetime: e })
|
|
495
|
+
.Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
|
|
496
|
+
// союз занимает ОДИН ключ: услуга ИЛИ товар ИЛИ комплекс; лишняя связь → ошибка
|
|
497
|
+
// Клиент optional → ручная бронь без него, имя в notes:
|
|
498
|
+
await db.Мастер(m).запись().create({ start_datetime: t, end_datetime: e, notes: 'по телефону: Аня' })
|
|
499
|
+
.Локация.set(л).Расписание.set(р).Товар.set(тов).rows() // Клиент опущен — ok
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
#### LINK · record / мультивалюта — `price` (цена)
|
|
503
|
+
|
|
504
|
+
```jsonc
|
|
505
|
+
// attributes:
|
|
506
|
+
{ "id": { "type": "uuid", "generate": 5, "from": ["Service|Product|Complex", "note"] },
|
|
507
|
+
"note": { "type": "string", "default": "базовая" }, // необязателен → дефолт входит в id (§3.2)
|
|
508
|
+
"amounts": { "type": "record",
|
|
509
|
+
"key": { "type": "enum", "values": ["RUB","USD","EUR","AED"] },
|
|
510
|
+
"value": "number|integer|min:0" } }
|
|
511
|
+
// links: [{ "classes": ["Service","Product","Complex"], "cardinality": 1 }] ← владелец — элемент
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
// у элемента несколько вариантов цены; note различает (id = uuidv5(элемент, note))
|
|
516
|
+
await db.Услуга(svc).цена().create({ amounts: { RUB: 1500, AED: 60 } }).rows() // note → 'базовая'
|
|
517
|
+
await db.Услуга(svc).цена().create({ note: 'с дизайном', amounts: { RUB: 2500 } }).rows()
|
|
518
|
+
await db.Услуга(svc).цена().sum('data.amounts.RUB') // агрегат по record-листу (§10.3): 4000
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
#### Все 20 классов и типовой вызов
|
|
522
|
+
|
|
523
|
+
| Класс (id · alias) | cat | Что это | Типовой вызов |
|
|
524
|
+
|---|---|---|---|
|
|
525
|
+
| `Entity` · Сущность | HUB | abstract-корень, дефолт id v7 | — не создаётся |
|
|
526
|
+
| `Org` · Организация | HUB | арендатор, владелец каталога | `db.Организация().create({ name })` |
|
|
527
|
+
| `Person` · Контрагент | HUB | abstract: name + phone | — |
|
|
528
|
+
| `Staff` · Мастер | HUB | бронируемый исполнитель | `db.Организация(o).Мастер().create({ name, phone })` |
|
|
529
|
+
| `Customer` · Клиент | HUB | клиент | `db.Организация(o).Клиент().create({ name, phone })` |
|
|
530
|
+
| `Location` · Локация | HUB | точка обслуживания | `db.Организация(o).Локация().create({ address })` |
|
|
531
|
+
| `Element` · Элемент | HUB | abstract каталог, id v5(Org, name) | — |
|
|
532
|
+
| `Service` · Услуга | HUB | услуга (+ duration) | `db.Организация(o).Услуга().create({ name, duration })` |
|
|
533
|
+
| `Product` · Товар | HUB | товар (+ sku, unit) | `db.Организация(o).Товар().create({ name, sku })` |
|
|
534
|
+
| `Complex` · Комплекс | HUB | комплекс (+ cost, duration) | `db.Организация(o).Комплекс().create({ name, cost, duration })` |
|
|
535
|
+
| `Schedule` · Расписание | HUB | месячное расписание | `db.Организация(o).Расписание().create({ name, year, month })` |
|
|
536
|
+
| `Folder` · Папка | HUB | категория (вложенная) | `db.Организация(o).Папка().create({ name })` · дети: `db.Папка(f).Папка().create({ name })` |
|
|
537
|
+
| `link` · связь | LINK | abstract-корень связей, id v7 | — |
|
|
538
|
+
| `address` · адрес | LINK | Мастер работает в Локации | `db.Мастер(m).адрес().create({ default: true }).Локация.set(l)` |
|
|
539
|
+
| `slot` · окно | LINK | доступность Мастер × Локация × Расписание | `db.Мастер(m).окно().create({ start_datetime, end_datetime }).Локация.set(l).Расписание.set(s)` |
|
|
540
|
+
| `booking` · запись | LINK | бронь (наследник окна) | `db.Мастер(m).запись().create({…}).Локация.set(l).Расписание.set(s).Услуга.set(u).Клиент.set(c)` |
|
|
541
|
+
| `content` · содержимое | LINK | элемент в папке | `db.Папка(f).содержимое().create({ order: 1 }).Услуга.set(u)` |
|
|
542
|
+
| `compo` · состав | LINK | элемент в комплексе | `db.Комплекс(k).состав().create({ quantity: 2 }).Услуга.set(u)` |
|
|
543
|
+
| `skill` · навык | LINK | Мастер умеет Услугу | `db.Мастер(m).навык().create({ level: 'expert' }).Услуга.set(u)` |
|
|
544
|
+
| `price` · цена | LINK | вариант цены элемента | `db.Услуга(u).цена().create({ amounts: { RUB: 1500 } })` |
|
|
545
|
+
|
|
546
|
+
Полные `attributes` / `links` / `meta` каждого класса — в `lib/sql/seed.booking.sql`
|
|
547
|
+
(по одному INSERT-у на класс, в порядке `order`).
|
|
548
|
+
|
|
225
549
|
---
|
|
226
550
|
|
|
227
551
|
## 4. Чтение: цепочки
|
|
@@ -242,25 +566,25 @@ data.start)`, `Service`/`Complex` ← `v5(Org, data.name)` — имя уника
|
|
|
242
566
|
прямого HUB→HUB пути нет, идут через саму связку и **pivot** (повтор её имени):
|
|
243
567
|
|
|
244
568
|
```ts
|
|
245
|
-
//
|
|
246
|
-
db
|
|
569
|
+
// «записи клиента к, где услуга у и мастер вася» — путь через запись, pivot-возврат:
|
|
570
|
+
db.Клиент(к).запись().Услуга(у).запись().Мастер(вася).запись().rows()
|
|
247
571
|
// «умеет ли Ирина стрижку» — навык, дофильтр предметом, count путей:
|
|
248
|
-
db
|
|
572
|
+
db.Мастер(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
|
|
249
573
|
```
|
|
250
574
|
|
|
251
575
|
### Узел-переменная: `entity()`
|
|
252
576
|
|
|
253
577
|
```ts
|
|
254
|
-
const p = db
|
|
255
|
-
await db
|
|
578
|
+
const p = db.запись() // ленивый узел-паттерн
|
|
579
|
+
await db.Клиент(к).entity(p).Услуга(у).entity(p).Мастер().run() // та же p = тот же узел (явный pivot)
|
|
256
580
|
await db.entity(row).Услуга().first() // старт пути с готового Row
|
|
257
581
|
```
|
|
258
582
|
|
|
259
583
|
### Терминалы
|
|
260
584
|
|
|
261
585
|
```ts
|
|
262
|
-
const пути = await db
|
|
263
|
-
// [{
|
|
586
|
+
const пути = await db.Мастер({ name:'Вася' }).alias('Исполнитель').навык().Услуга().run()
|
|
587
|
+
// [{ Исполнитель: Row, навык: Row, Услуга: Row }, …]
|
|
264
588
|
```
|
|
265
589
|
|
|
266
590
|
| Вызов | Возврат |
|
|
@@ -286,9 +610,9 @@ const пути = await db.Сотрудник({ name:'Вася' }).alias('Мас
|
|
|
286
610
|
db.Услуга('uuid') // по id
|
|
287
611
|
db.Услуга(rowИлиAccount) // объект с id — возьмётся .id
|
|
288
612
|
db.Услуга(['id1','id2']) // по списку ([] → пусто)
|
|
289
|
-
db.Услуга({ name: 'Стрижка',
|
|
613
|
+
db.Услуга({ name: 'Стрижка', duration: 60 }) // eq полей data → GIN-containment
|
|
290
614
|
db.Услуга({ id: 'uuid', duration: gte(30) }) // ключ id — тоже id-фильтр
|
|
291
|
-
db
|
|
615
|
+
db.Локация({ coordinates: { lat: gte(55) } }) // вложенные пути ЛЮБОЙ глубины (object) + каст по листу
|
|
292
616
|
```
|
|
293
617
|
|
|
294
618
|
### Операторы (18: + `not`, `or`)
|
|
@@ -305,29 +629,30 @@ import { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends,
|
|
|
305
629
|
| `gt/gte/lt/lte(v)`, `between(a,b)` | number, date, string | `(data->>'f')::cast ⋛ $` |
|
|
306
630
|
| `inList([…])` | любые | `IN (…)`; `[]` → FALSE |
|
|
307
631
|
| `like/ilike/starts/ends(s)` | string | `[I]LIKE` |
|
|
308
|
-
| `has(v)/hasAll([…])/hasAny([…])` | массивы data
|
|
632
|
+
| `has(v)/hasAll([…])/hasAny([…])` | массивы data и колонка `tags` | `@>` / `?\|` |
|
|
309
633
|
| `exists(true/false)` | любые | ключ есть/нет |
|
|
310
634
|
| `isNull()` | любые | null или отсутствует |
|
|
311
635
|
| `not(op \| скаляр)` | по внутреннему | `NOT (…)`; `not(скаляр)` = `ne` |
|
|
312
|
-
| `or(f1, f2, …)` | **фильтр целиком** | `db
|
|
636
|
+
| `or(f1, f2, …)` | **фильтр целиком** | `db.Услуга(or({name:'Стрижка'}, {duration: lt(40)}))` — дизъюнкция под-фильтров |
|
|
313
637
|
|
|
314
638
|
Касты по `Schema.attributes`: number→`::numeric`, date→`::timestamptz`, boolean→`::boolean`.
|
|
315
639
|
Поле вне схемы фильтруется как text (записать его нельзя — строгая валидация).
|
|
316
|
-
Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`)
|
|
317
|
-
|
|
640
|
+
Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`); сравнения — операторами (`between(t, t)`
|
|
641
|
+
для «равно моменту»: скаляр-eq по дате — строковый jsonb-containment, он про
|
|
642
|
+
нормализованное значение). В демо-схеме массивов в data нет — `has*` живут на `tags`.
|
|
318
643
|
|
|
319
644
|
### Модификаторы цепочки
|
|
320
645
|
|
|
321
646
|
```ts
|
|
322
|
-
db
|
|
323
|
-
db
|
|
647
|
+
db.окно().sort('data.start_datetime').limit(10).offset(20).rows() // выборка
|
|
648
|
+
db.запись().sort('updated', 'desc').limit(50).rows()
|
|
324
649
|
|
|
325
650
|
db.Клиент().tags('vip') // фильтр: tags ⊇ ['vip']
|
|
326
651
|
db.Клиент().tags(['vip','telegram']) // все перечисленные
|
|
327
652
|
db.Клиент().tags(hasAny(['vip','b2b'])) // хотя бы один
|
|
328
|
-
db
|
|
653
|
+
db.запись().account(accId) // фильтр по колонке account (uuid | Row)
|
|
329
654
|
db.Организация().owner(acc) // фильтр по owner
|
|
330
|
-
db
|
|
655
|
+
db.Мастер({…}).alias('Исполнитель') // ключ шага в путях
|
|
331
656
|
```
|
|
332
657
|
|
|
333
658
|
| Модификатор | Область | В чтении | В записи |
|
|
@@ -358,15 +683,15 @@ db.Сотрудник({…}).alias('Мастер') // ключ шаг
|
|
|
358
683
|
|
|
359
684
|
```ts
|
|
360
685
|
// одиночная запись: операция + терминал
|
|
361
|
-
const [вася] = await db.Организация(org)
|
|
686
|
+
const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
|
|
362
687
|
|
|
363
688
|
// несколько операций в одной цепочке: продолжение — ОТ РЕЗУЛЬТАТА предыдущей
|
|
364
|
-
await db
|
|
365
|
-
|
|
366
|
-
.rows()
|
|
689
|
+
await db.Организация().tags('сеть').update({ active: true }) // новая версия всех сетевых орг
|
|
690
|
+
.Услуга().create({ name: 'Акция месяца', duration: 30 }) // INSERT услуги КАЖДОЙ (fan-out)
|
|
691
|
+
.rows() // → акционные услуги
|
|
367
692
|
|
|
368
693
|
// операция сразу после операции — к тем же сущностям (две версии подряд)
|
|
369
|
-
await db
|
|
694
|
+
await db.запись(id).update({ notes: 'подтверждена' }).update({ notes: 'оплачена' }).rows()
|
|
370
695
|
```
|
|
371
696
|
|
|
372
697
|
### Анатомия
|
|
@@ -400,7 +725,7 @@ db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хво
|
|
|
400
725
|
Валидация **строгая, всегда**: поле вне `Schema.attributes` → `ValidationError`;
|
|
401
726
|
default-ы схемы подставляются; id в `data` не хранится (он — колонка).
|
|
402
727
|
- **Deep-merge при `update`** (и у create-версии по известному id): меняются только
|
|
403
|
-
указанные листья — `update({
|
|
728
|
+
указанные листья — `update({ coordinates: { lat: 55.8 } })` сохранит `lng` и остальные
|
|
404
729
|
поля. Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
|
|
405
730
|
- **Концы LINK**: по `Schema.links` v2 (§3.1) — обязательные требуются, союз занимает один ключ, лишние связи — ошибка; значения — id, Row или вложенная цепочка (§6.1).
|
|
406
731
|
- **account/owner NOT NULL**: `.account()/.owner()` → `connect()` → System-аккаунт.
|
|
@@ -411,44 +736,44 @@ db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хво
|
|
|
411
736
|
|
|
412
737
|
Один закон: **владелец создаваемой связки берётся из валидного пути; прочие концы —
|
|
413
738
|
слотами** `.Класс.set(значение)` после глагола записи. Слот — свойство-класс БЕЗ вызова
|
|
414
|
-
(
|
|
739
|
+
(`.Клиент.set(x)`, не `.Клиент(x)`); валиден только для конца из `Schema.links` и
|
|
415
740
|
требует операцию записи ПЕРЕД собой: `…create(…).Класс.set(x)` / `…update(…).Класс.unset()`;
|
|
416
741
|
слот без глагола — ошибка `link slot needs a write`.
|
|
417
742
|
|
|
418
743
|
```ts
|
|
419
|
-
// СОЗДАНИЕ: владелец
|
|
420
|
-
await db
|
|
421
|
-
.Услуга.set(
|
|
744
|
+
// СОЗДАНИЕ: владелец (Мастер) — из пути, остальные концы — слотами; id считает схема (§ 3.2)
|
|
745
|
+
await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
|
|
746
|
+
.Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
|
|
422
747
|
|
|
423
|
-
//
|
|
424
|
-
await db
|
|
748
|
+
// навык: владелец Мастер из пути, предмет — слотом
|
|
749
|
+
await db.Мастер(s).навык().create({ level: 'expert' }).Услуга.set(у).rows()
|
|
425
750
|
|
|
426
751
|
// ПЕРЕВЕС связи (update): цель ищется путём/pivot, слот пишет новое значение БЕЗ фильтра
|
|
427
|
-
await tr
|
|
752
|
+
await tr.Клиент(к).запись().Услуга(старая).запись().update().Услуга.set(новая).rows()
|
|
428
753
|
|
|
429
|
-
// СОЮЗ-конец [
|
|
430
|
-
await db
|
|
754
|
+
// СОЮЗ-конец [Услуга|Товар|Комплекс]: слот замещает конец целиком (соседний класс снимается)
|
|
755
|
+
await db.запись(b).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
|
|
431
756
|
|
|
432
|
-
// СНЯТИЕ optional
|
|
433
|
-
await db
|
|
757
|
+
// СНЯТИЕ optional-конца: ручная бронь — клиент отвязан, имя в notes
|
|
758
|
+
await db.запись(b).update({ notes: 'по телефону: Аня' }).Клиент.unset().rows()
|
|
434
759
|
|
|
435
760
|
// вложенная цепочка как значение слота — та же транзакция, ровно одна сущность:
|
|
436
|
-
await db
|
|
437
|
-
|
|
438
|
-
|
|
761
|
+
await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
|
|
762
|
+
.Локация.set(л).Расписание.set(р)
|
|
763
|
+
.Услуга.set(db.Организация(org).Услуга().create({ name: 'Новинка', duration: 45 })).rows() // создать И привязать
|
|
439
764
|
```
|
|
440
765
|
|
|
441
766
|
### `.delete({ confirm })` — превью и серверное удаление
|
|
442
767
|
|
|
443
768
|
```ts
|
|
444
|
-
const превью = await db
|
|
445
|
-
// кандидаты (цель +
|
|
446
|
-
// [{class:'
|
|
769
|
+
const превью = await db.Клиент(cid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
|
|
770
|
+
// кандидаты (цель + каскад: записи клиента), живые, БД не тронута:
|
|
771
|
+
// [{class:'Customer'}, {class:'booking'}]
|
|
447
772
|
|
|
448
|
-
const удалено = await db
|
|
773
|
+
const удалено = await db.Клиент(cid).delete({ confirm: true }).rows()
|
|
449
774
|
// удалённое дерево, каждый Row с $deleted: true
|
|
450
775
|
|
|
451
|
-
await db
|
|
776
|
+
await db.Мастер(m).окно().delete({ confirm: true }).rows() // контекст: только окна мастера
|
|
452
777
|
```
|
|
453
778
|
|
|
454
779
|
Цели = фильтр шага + контекст-связи. Замыкание (цели + все живые зависимые) собирается
|
|
@@ -459,10 +784,10 @@ id — воскрешение.
|
|
|
459
784
|
### Откат плана
|
|
460
785
|
|
|
461
786
|
```ts
|
|
462
|
-
await db
|
|
463
|
-
|
|
787
|
+
await db.Организация(org).update({ phone: '+7 495 …' }) // валидный сегмент…
|
|
788
|
+
.Услуга().create({ name: 'X', чепуха: 1 }) // …невалидный: ValidationError
|
|
464
789
|
.rows()
|
|
465
|
-
// ← ОТКАТ ВСЕГО:
|
|
790
|
+
// ← ОТКАТ ВСЕГО: телефон не изменился, версий не прибавилось
|
|
466
791
|
```
|
|
467
792
|
|
|
468
793
|
---
|
|
@@ -470,19 +795,20 @@ await db.Клиент(id).update({ name: 'Новое' }) // валидный
|
|
|
470
795
|
## 7. Транзакции
|
|
471
796
|
|
|
472
797
|
```ts
|
|
473
|
-
const
|
|
798
|
+
const bookingId = uuidv5(`v1.booking:entity:booking:${staffId}:${start}`) // id известен ДО создания (§ 3.2)
|
|
474
799
|
|
|
475
800
|
const tr = await db.begin() // тот же API на выделенном соединении
|
|
476
|
-
await tr.lock('
|
|
477
|
-
const занято = await tr
|
|
478
|
-
if (!занято) await tr
|
|
801
|
+
await tr.lock('booking', staffId, start) // advisory-xact-lock до конца транзакции
|
|
802
|
+
const занято = await tr.запись(bookingId).first()
|
|
803
|
+
if (!занято) await tr.Мастер(s).запись().create({ start_datetime: start, end_datetime: end })
|
|
804
|
+
.Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
|
|
479
805
|
await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()
|
|
480
806
|
```
|
|
481
807
|
|
|
482
808
|
Повторный commit/rollback — no-op. `lock()` вне транзакции — ошибка. Держите транзакции
|
|
483
|
-
короткими. **Рецепт двойной брони**: id
|
|
484
|
-
что дубль невозможен в принципе (второй `create` стал бы версией той же
|
|
485
|
-
`lock(мастер,
|
|
809
|
+
короткими. **Рецепт двойной брони**: id записи детерминирован схемой (v5 — § 3.2), так
|
|
810
|
+
что дубль невозможен в принципе (второй `create` стал бы версией той же записи);
|
|
811
|
+
`lock(мастер, старт)` + перечитка `first()` под локом нужны, чтобы сопернику честно
|
|
486
812
|
ОТКАЗАТЬ, а не молча версионировать чужую бронь (ровно одна успешна — покрыто тестом-гонкой).
|
|
487
813
|
|
|
488
814
|
---
|
|
@@ -490,11 +816,12 @@ await db.commit(tr) // или db.rollback(tr) / tr.comm
|
|
|
490
816
|
## 8. Батчи
|
|
491
817
|
|
|
492
818
|
```ts
|
|
493
|
-
db.batch('
|
|
494
|
-
|
|
495
|
-
db.batch('
|
|
496
|
-
|
|
497
|
-
db.batch('
|
|
819
|
+
db.batch('смены').Мастер(m).окно().create({ start_datetime, end_datetime }) // план встал в очередь
|
|
820
|
+
.Локация.set(loc).Расписание.set(sch)
|
|
821
|
+
db.batch('смены').Мастер(m2).окно().create({ … }).Локация.set(loc).Расписание.set(sch)
|
|
822
|
+
db.batch('смены').size() // 2
|
|
823
|
+
const res = await db.batch('смены').run() // одна транзакция; Row[][] по порядку
|
|
824
|
+
db.batch('смены').discard() // отменить
|
|
498
825
|
```
|
|
499
826
|
|
|
500
827
|
- `run()` атомарен: любая ошибка откатывает всё.
|
|
@@ -620,7 +947,7 @@ lockout после N неудач. Legacy-bcrypt-хэши (`$2b$…` из дам
|
|
|
620
947
|
|---|---|---|
|
|
621
948
|
| `ACCOUNT` | аккаунта | `{"categories": "{Staff}"}`; `"!{Anonymous,Shadow}"` — нет ни одной; NULL — все |
|
|
622
949
|
| `API` | адреса эндпоинта | `{"endpoint": "v2.auth.apikey.*"}` — маска: `.`-сегменты, `{a,b}`, `*` — хвост |
|
|
623
|
-
| `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "
|
|
950
|
+
| `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "booking", "owner": "$account"}` |
|
|
624
951
|
|
|
625
952
|
Pattern операций — реальные колонки Entity (`class`, `owner`, `account`, `tags`, `data`,
|
|
626
953
|
`links`…), значения — литералы или `"$account"` (id субъекта, подставляется в запрос);
|
|
@@ -637,7 +964,7 @@ await db.acl.check(anon, 'v2.auth.password.signup')
|
|
|
637
964
|
await db.acl.check(anon, 'v2.auth.apikey.create')
|
|
638
965
|
// → { allow: false, code: 403, message: 'Access denied - default …' } — победило дно-правило deny 0
|
|
639
966
|
|
|
640
|
-
await db.acl.checkData(user, '
|
|
967
|
+
await db.acl.checkData(user, 'booking', 'READ') // ресурс {"class":"booking","owner":"$account"}
|
|
641
968
|
// → { allow: true, filter: { owner: '24c49a43-…' }, rule: {…weight: 60} } [1.7 ms]
|
|
642
969
|
await db.acl.checkData(user, 'SportsCar', 'READ') // право дал предок Vehicle (lineage)
|
|
643
970
|
// → { allow: true } — безусловный, без предиката
|
|
@@ -653,29 +980,30 @@ allow-классов (payload нечем проверить предикат).
|
|
|
653
980
|
|
|
654
981
|
```ts
|
|
655
982
|
const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
|
|
656
|
-
await u
|
|
657
|
-
await u
|
|
983
|
+
await u.запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
|
|
984
|
+
await u.запись().count() // честный count по суженному множеству
|
|
658
985
|
await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
|
|
659
|
-
const [z] = await u
|
|
660
|
-
await u
|
|
661
|
-
await u
|
|
662
|
-
// create
|
|
986
|
+
const [z] = await u.Мастер(m).запись().create({…}).rows() // owner пришпилен правилом → user.id
|
|
987
|
+
await u.запись().owner(other).update({…}).rows() // Error: acl pins booking writes to owner …
|
|
988
|
+
await u.запись(чужаяId).update({ notes: '…' }).rows() // → [] — цель вне предиката не находится
|
|
989
|
+
// create той же v5-пары (id чужой записи) тоже НЕ перехватывает: → [] вместо новой версии
|
|
663
990
|
```
|
|
664
991
|
|
|
665
|
-
Оверхед (
|
|
992
|
+
Оверхед (полигон `v1.salondemo` ~980k, booking 440k×2; p50 из 20; `bench/acl.bench.mjs`):
|
|
666
993
|
|
|
667
994
|
| Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
|
|
668
995
|
|---|---|---|---|
|
|
669
|
-
| точечный `first(id)` | 2.
|
|
670
|
-
| фильтр `rows` limit 100 |
|
|
671
|
-
| keyset-страница всего класса
|
|
672
|
-
| `count()` класса
|
|
673
|
-
| цепочка
|
|
674
|
-
|
|
675
|
-
Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
996
|
+
| точечный `first(id)` | 2.9 ms | 4.2 ms | 6.1 ms |
|
|
997
|
+
| фильтр `rows` limit 100 | 1394 ms | 1454 ms | 1362 ms |
|
|
998
|
+
| keyset-страница всего класса 440k | 5495 ms | 5576 ms | 5364 ms |
|
|
999
|
+
| `count()` класса 440k | 3196 ms | 3049 ms | 2874 ms |
|
|
1000
|
+
| цепочка `запись(id).Услуга()` | 5.5 ms | 6.6 ms | 5.1 ms |
|
|
1001
|
+
|
|
1002
|
+
Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат здесь на
|
|
1003
|
+
селективности 100% (все строки booking — System-аккаунт, предикат никого не отсекает) —
|
|
1004
|
+
на тяжёлых сценах ACL в пределах шума с базой; в реальности предикат СУЖАЕТ выборку,
|
|
1005
|
+
и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
|
|
1006
|
+
`db.acl.checkData` — справочный (1.0 ms: SQL за категориями аккаунта на каждый вызов);
|
|
679
1007
|
горячий путь цепочек использует резолвер, скомпилированный на connect (микросекунды, memo).
|
|
680
1008
|
|
|
681
1009
|
`enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
|
|
@@ -686,8 +1014,8 @@ worst-case (пропускает 100% строк — чистый оверхед
|
|
|
686
1014
|
|
|
687
1015
|
```ts
|
|
688
1016
|
await db.Услуга().rows() // срез класса (актуальное, живое)
|
|
689
|
-
await db
|
|
690
|
-
await db.Клиент(cid)
|
|
1017
|
+
await db.запись().sort('updated','desc').limit(50).rows()
|
|
1018
|
+
await db.Клиент(cid).запись().Услуга().run() // связный подграф
|
|
691
1019
|
```
|
|
692
1020
|
|
|
693
1021
|
```sql
|
|
@@ -697,7 +1025,7 @@ SELECT * FROM (SELECT DISTINCT ON (class, id) * FROM "v1.booking"."Entity"
|
|
|
697
1025
|
|
|
698
1026
|
-- история сущности (все версии, включая tombstone)
|
|
699
1027
|
SELECT updated, deleted, data, links FROM "v1.booking"."Entity"
|
|
700
|
-
WHERE partition='entity' AND class='
|
|
1028
|
+
WHERE partition='entity' AND class='booking' AND id=$1 ORDER BY updated;
|
|
701
1029
|
```
|
|
702
1030
|
|
|
703
1031
|
### 10.1 Время-путешествия: `.asOf()` / `.versions()`
|
|
@@ -705,11 +1033,11 @@ WHERE partition='entity' AND class='Booking' AND id=$1 ORDER BY updated;
|
|
|
705
1033
|
```ts
|
|
706
1034
|
// «какая цена была на момент брони» — версии позже T невидимы, tombstone до T = «удалён»
|
|
707
1035
|
const тогда = await db.Услуга(id).asOf('2026-07-01T12:00:00Z').first()
|
|
708
|
-
const срезДня = await db
|
|
1036
|
+
const срезДня = await db.запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
|
|
709
1037
|
|
|
710
1038
|
// вся история сущности без сырого SQL (tombstone-версии приходят с $deleted: true)
|
|
711
|
-
const история = await db
|
|
712
|
-
// [{data:{
|
|
1039
|
+
const история = await db.запись(id).versions()
|
|
1040
|
+
// [{data:{…}}, {data:{…, notes:'подтверждена'}}, {…, $deleted:true}]
|
|
713
1041
|
```
|
|
714
1042
|
|
|
715
1043
|
`asOf` применяется к каждому шагу цепочки — подграф целиком «как был». `versions()`
|
|
@@ -723,19 +1051,19 @@ Keyset — закладка: курсор = значение поля сорти
|
|
|
723
1051
|
порядок тотальным, дубли значений не теряются и не повторяются).
|
|
724
1052
|
|
|
725
1053
|
```ts
|
|
726
|
-
const стр1 = await db
|
|
1054
|
+
const стр1 = await db.запись().sort('updated', 'desc').limit(50).rows()
|
|
727
1055
|
const кур = cursorOf(стр1.at(-1)!) // { v: '<updated>', id: '…' } — просто объект,
|
|
728
|
-
const стр2 = await db
|
|
1056
|
+
const стр2 = await db.запись().sort('updated', 'desc') // можно хранить в URL/state
|
|
729
1057
|
.after(кур).limit(50).rows()
|
|
730
1058
|
|
|
731
1059
|
// по data-пути ЛЮБОЙ глубины — field тот же, что в sort
|
|
732
|
-
const дальше = await db
|
|
733
|
-
.after(cursorOf(окно, 'data.
|
|
1060
|
+
const дальше = await db.окно().sort('data.start_datetime')
|
|
1061
|
+
.after(cursorOf(окно, 'data.start_datetime')).limit(20).rows()
|
|
734
1062
|
|
|
735
1063
|
// бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
|
|
736
1064
|
let cursor
|
|
737
1065
|
do {
|
|
738
|
-
const page = await db
|
|
1066
|
+
const page = await db.Мастер(m).запись().sort('updated', 'desc')
|
|
739
1067
|
.after(cursor).limit(50).rows()
|
|
740
1068
|
render(page)
|
|
741
1069
|
cursor = page.length ? cursorOf(page.at(-1)!) : undefined
|
|
@@ -752,14 +1080,14 @@ do {
|
|
|
752
1080
|
### 10.3 Агрегации: считает БД
|
|
753
1081
|
|
|
754
1082
|
```ts
|
|
755
|
-
await db
|
|
756
|
-
await db.Услуга().avg('data.duration')
|
|
757
|
-
await db
|
|
758
|
-
await db
|
|
1083
|
+
await db.цена().sum('data.amounts.RUB') // сумма всех прайсов (record-лист): number | null
|
|
1084
|
+
await db.Услуга().avg('data.duration') // среднее
|
|
1085
|
+
await db.Услуга().countBy('data.duration') // { '30': 2, '60': 1 } ({} на пустом)
|
|
1086
|
+
await db.окно().min('data.start_datetime') // min/max — каст по типу поля из Schema
|
|
759
1087
|
```
|
|
760
1088
|
|
|
761
|
-
Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или
|
|
762
|
-
`'data.
|
|
1089
|
+
Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или вложенный лист
|
|
1090
|
+
(`'data.coordinates.lat'`). `sum`/`avg` кастуются в numeric; пустое множество → `null`.
|
|
763
1091
|
|
|
764
1092
|
### 10.4 Деревья: `.deep()`
|
|
765
1093
|
|
|
@@ -775,7 +1103,7 @@ reverse = дети); на другом переходе — ошибка. Раб
|
|
|
775
1103
|
### 10.5 Realtime: `db.watch()`
|
|
776
1104
|
|
|
777
1105
|
```ts
|
|
778
|
-
const stop = await db.watch('
|
|
1106
|
+
const stop = await db.watch('запись', (e) => {
|
|
779
1107
|
// e = { partition, class, id, updated, deleted } — факт версии (insert/update/tombstone)
|
|
780
1108
|
обновитьКалендарь(e.id)
|
|
781
1109
|
})
|
|
@@ -791,7 +1119,7 @@ await stop() // отписка
|
|
|
791
1119
|
повторяет LISTEN), но `NOTIFY` за время разрыва потеряны — для этого `onReconnect`:
|
|
792
1120
|
|
|
793
1121
|
```ts
|
|
794
|
-
const stop = await db.watch('
|
|
1122
|
+
const stop = await db.watch('запись', onEvent, {
|
|
795
1123
|
onReconnect: () => дочитатьПропущенное(), // напр. перечитать всё с последнего e.updated
|
|
796
1124
|
})
|
|
797
1125
|
```
|
|
@@ -800,9 +1128,9 @@ const stop = await db.watch('Запись', onEvent, {
|
|
|
800
1128
|
|
|
801
1129
|
```ts
|
|
802
1130
|
const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
|
|
803
|
-
await db
|
|
804
|
-
await db.Клиент().create({ name: 'X' }) // запись пришпилена к account
|
|
805
|
-
db
|
|
1131
|
+
await db.запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
|
|
1132
|
+
await db.Клиент().create({ name: 'X', phone: '+7…' }) // запись пришпилена к account
|
|
1133
|
+
db.запись().account(чужой).rows() // ошибка: reads are pinned to account …
|
|
806
1134
|
```
|
|
807
1135
|
|
|
808
1136
|
Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
|
|
@@ -812,7 +1140,7 @@ db.Запись().account(чужой).rows() // ошибка: reads are p
|
|
|
812
1140
|
### 10.7 Анонимизация (GDPR): `.anonymize()`
|
|
813
1141
|
|
|
814
1142
|
```ts
|
|
815
|
-
await db.Клиент(id).anonymize(['name', '
|
|
1143
|
+
await db.Клиент(id).anonymize(['name', 'phone']).rows()
|
|
816
1144
|
// новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
|
|
817
1145
|
```
|
|
818
1146
|
|
|
@@ -822,6 +1150,8 @@ await db.Клиент(id).anonymize(['name', 'contact']).rows()
|
|
|
822
1150
|
|
|
823
1151
|
### 10.8 Политики хранения: `db/policies.mjs`
|
|
824
1152
|
|
|
1153
|
+
> `db/policies.mjs` живёт в репозитории (корневой `db/`), **в npm-пакет НЕ входит** — запускать из клона. Логика — чистый Timescale SQL (`add_compression_policy` / `add_retention_policy`), при желании вызывается напрямую тем же DSN.
|
|
1154
|
+
|
|
825
1155
|
```bash
|
|
826
1156
|
node db/policies.mjs --dsn=… --schema=v1.booking --compress-after=30d # сжатие (история цела)
|
|
827
1157
|
node db/policies.mjs --dsn=… --schema=v1.booking --retain=2y # + retention (drop навсегда!)
|
|
@@ -838,8 +1168,10 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
|
|
|
838
1168
|
|
|
839
1169
|
### 10.9 Миграции классов: `scripts/schema-sync.mjs`
|
|
840
1170
|
|
|
1171
|
+
> Входит в npm-пакет (`files: ["scripts"]`); в установке путь — `node_modules/letopis/scripts/schema-sync.mjs`. Зависимости — `postgres` и `fastest-validator` (обе — deps пакета), `tsx` не нужен.
|
|
1172
|
+
|
|
841
1173
|
```bash
|
|
842
|
-
node scripts/schema-sync.mjs --file
|
|
1174
|
+
node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
|
|
843
1175
|
# schema-sync: … ↔ booking.Schema (partition entity)
|
|
844
1176
|
# + Coupon (HUB · Купон) — новый класс
|
|
845
1177
|
# ~ Service — изменены: attributes
|
|
@@ -870,6 +1202,8 @@ const db = await connect({
|
|
|
870
1202
|
|
|
871
1203
|
### 10.11 TS-типы из Schema: `scripts/gen-types.mjs`
|
|
872
1204
|
|
|
1205
|
+
> Входит в npm-пакет; чистый Node-ESM (только `postgres`), `tsx` не нужен — в установке `node node_modules/letopis/scripts/gen-types.mjs …`.
|
|
1206
|
+
|
|
873
1207
|
```bash
|
|
874
1208
|
npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d.ts
|
|
875
1209
|
```
|
|
@@ -877,7 +1211,7 @@ npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d
|
|
|
877
1211
|
```ts
|
|
878
1212
|
import type { TypedDb } from './entity-types'
|
|
879
1213
|
const t = db as unknown as TypedDb
|
|
880
|
-
const [svc] = await t.Услуга({
|
|
1214
|
+
const [svc] = await t.Услуга({ duration: 60 }).rows() // svc.data.duration: number
|
|
881
1215
|
```
|
|
882
1216
|
|
|
883
1217
|
Интерфейсы data-полей всех классов (enum → union-литералы, record → `Partial<Record<…>>`)
|
|
@@ -914,11 +1248,12 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
|
|
|
914
1248
|
|
|
915
1249
|
Каждый метод описан по одной схеме: **сигнатура → параметры → назначение и алгоритм →
|
|
916
1250
|
примеры (под каждым вызовом реальный ответ и время) → полный кейс**. Все ответы и тайминги —
|
|
917
|
-
**живой прогон** на
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1251
|
+
**живой прогон** на едином полигоне `v1.salondemo` ~980 000 строк Entity (год окон-смен и
|
|
1252
|
+
записей: 440 000 записей-наследников окон ×2 версии, каталог из 600 услуг, 360 мастеров,
|
|
1253
|
+
40 000 клиентов, 1026 цен, 1260 навыков); воспроизводитель — `bench/api-reference-demo.mjs` (только
|
|
1254
|
+
читает полигон salon-seed, мутирует лишь свои сущности). Тот же полигон — под статьёй SALON.md.
|
|
1255
|
+
id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; повторяющиеся
|
|
1256
|
+
`account`/`owner`/`partition` в ответах опущены.
|
|
922
1257
|
|
|
923
1258
|
Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
|
|
924
1259
|
[11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
|
|
@@ -956,23 +1291,23 @@ NOT NULL `Entity.account`. При `enforceAcl: true` дополнительно
|
|
|
956
1291
|
**Примеры**
|
|
957
1292
|
|
|
958
1293
|
```ts
|
|
959
|
-
const db = await connect({ dsn, schema: 'v1.
|
|
960
|
-
await db.Услуга(
|
|
961
|
-
// событие onQuery: {"mode":"rows","classes":["Service"],"ms":
|
|
1294
|
+
const db = await connect({ dsn, schema: 'v1.salondemo', onQuery: (e) => log(e) }) // [33.6 ms]
|
|
1295
|
+
await db.Услуга().first()
|
|
1296
|
+
// первое событие onQuery: {"mode":"rows","classes":["Service"],"ms":15.7,"rows":1,"slow":false}
|
|
962
1297
|
|
|
963
|
-
const dbSlow = await connect({ dsn, schema: 'v1.
|
|
964
|
-
await dbSlow
|
|
965
|
-
// {"mode":"count","classes":["
|
|
1298
|
+
const dbSlow = await connect({ dsn, schema: 'v1.salondemo', slowMs: 200, onQuery: … }) // [35.8 ms]
|
|
1299
|
+
await dbSlow.запись().count() // ~440k сущностей — дольше порога:
|
|
1300
|
+
// {"mode":"count","classes":["booking"],"ms":3191.0,"rows":1,"slow":true}
|
|
966
1301
|
```
|
|
967
1302
|
|
|
968
1303
|
**Кейс: три подключения — обычное, изолированное, под ACL**
|
|
969
1304
|
|
|
970
1305
|
```ts
|
|
971
|
-
const db = await connect({ dsn, schema }) // [
|
|
972
|
-
const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [
|
|
973
|
-
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [
|
|
974
|
-
await db.Организация().count() // →
|
|
975
|
-
await iso.Организация().count() // [
|
|
1306
|
+
const db = await connect({ dsn, schema }) // [33.6 ms]
|
|
1307
|
+
const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [40.2 ms]
|
|
1308
|
+
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [54.7 ms]
|
|
1309
|
+
await db.Организация().count() // → 30 — видит всех
|
|
1310
|
+
await iso.Организация().count() // [5.1 ms] → 1 — только свой арендатор
|
|
976
1311
|
await uc.Организация().rows()
|
|
977
1312
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default)
|
|
978
1313
|
await iso.close(); await uc.close()
|
|
@@ -1045,14 +1380,14 @@ await up({ dsn, schema: 'v1.booking', version: 1 })
|
|
|
1045
1380
|
|
|
1046
1381
|
```ts
|
|
1047
1382
|
const db = await up({ schema: 'booking', version: 1, dsn }) // [7.4 s] образ+контейнер+схема
|
|
1048
|
-
await db
|
|
1049
|
-
|
|
1383
|
+
await db.Организация().count() // → 0 — свежая схема
|
|
1384
|
+
await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
1050
1385
|
await db.close()
|
|
1051
1386
|
|
|
1052
1387
|
// … docker stop letopis-timescale (ребут, уборка, что угодно) …
|
|
1053
1388
|
|
|
1054
1389
|
const db2 = await up({ schema: 'booking', version: 1, dsn }) // [1.2 s] start + reuse
|
|
1055
|
-
await db2
|
|
1390
|
+
await db2.Организация().count() // → 1 — volume letopis-pgdata: всё на месте
|
|
1056
1391
|
await db2.close()
|
|
1057
1392
|
```
|
|
1058
1393
|
|
|
@@ -1086,7 +1421,7 @@ import type {
|
|
|
1086
1421
|
|
|
1087
1422
|
| Параметр | Тип | Описание |
|
|
1088
1423
|
|---|---|---|
|
|
1089
|
-
| `Класс` | имя свойства | id или алиас класса из `Schema` (`db.
|
|
1424
|
+
| `Класс` | имя свойства | id или алиас класса из `Schema` (`db.booking` ≡ `db.запись`); неизвестное имя — ошибка со списком классов |
|
|
1090
1425
|
| `filter` | `Filter?` | без аргумента — весь класс; `string` — по id; `string[]` — по списку id; `Row`/объект с `.id` — как id; объект — поля `data` (равенство, операторы § 11.4, record-пути) + ключ `id` |
|
|
1091
1426
|
|
|
1092
1427
|
**Назначение и алгоритм.** Старт цепочки чтения/записи (§ 4–6). Ничего не выполняет —
|
|
@@ -1097,10 +1432,10 @@ import type {
|
|
|
1097
1432
|
**Примеры**
|
|
1098
1433
|
|
|
1099
1434
|
```ts
|
|
1100
|
-
await db.Услуга('…
|
|
1101
|
-
await db.Услуга(['…
|
|
1102
|
-
await db.Организация(org).first() // [2.
|
|
1103
|
-
await db
|
|
1435
|
+
await db.Услуга('…0000').first() // [3.5 ms] по id → Row {name: 'Стрижка 0', …}
|
|
1436
|
+
await db.Услуга(['…0000', '…0006']).rows() // [2.6 ms] по списку → ['Стрижка 0', 'Бритьё 6']
|
|
1437
|
+
await db.Организация(org).first() // [2.2 ms] Row-объект ≡ его id → 'Салон «Стрижка» №0'
|
|
1438
|
+
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [36.0 ms] → 630 (фильтр по цене)
|
|
1104
1439
|
await db.НетТакогоКласса().rows()
|
|
1105
1440
|
// Error: letopis: unknown class "НетТакогоКласса". Known: Entity·Сущность, Org·Организация, …
|
|
1106
1441
|
```
|
|
@@ -1108,10 +1443,10 @@ await db.НетТакогоКласса().rows()
|
|
|
1108
1443
|
**Кейс: одна сущность тремя формами фильтра**
|
|
1109
1444
|
|
|
1110
1445
|
```ts
|
|
1111
|
-
const поId
|
|
1112
|
-
const поПолю
|
|
1113
|
-
const поОбъекту = await db.Услуга(поId).first()
|
|
1114
|
-
// все три → id '…
|
|
1446
|
+
const поId = await db.Услуга('…0000').first() // [3.5 ms] → data.name = 'Стрижка 0'
|
|
1447
|
+
const поПолю = await db.Услуга({ name: 'Стрижка 0' }).first() // тот же Row
|
|
1448
|
+
const поОбъекту = await db.Услуга(поId).first() // Row как фильтр ≡ его id
|
|
1449
|
+
// все три → id '…0000', data.name = 'Стрижка 0' (цена — отдельным LINK «цена», § 11.9)
|
|
1115
1450
|
```
|
|
1116
1451
|
|
|
1117
1452
|
#### `db.begin(): Promise<EntityTx>`
|
|
@@ -1130,19 +1465,20 @@ deadlock/serialization ошибка приходит сразу с подска
|
|
|
1130
1465
|
|
|
1131
1466
|
```ts
|
|
1132
1467
|
const tr = await db.begin() // [0.8 ms]
|
|
1133
|
-
await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15
|
|
1134
|
-
// [
|
|
1135
|
-
await tr.commit() // [
|
|
1468
|
+
await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15 }).rows()
|
|
1469
|
+
// [9.0 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
|
|
1470
|
+
await tr.commit() // [3.7 ms] — теперь видно всем
|
|
1136
1471
|
```
|
|
1137
1472
|
|
|
1138
1473
|
**Кейс: атомарный перенос с откатом при провале** — § 11.6 (`tr.lock`), плюс rollback:
|
|
1139
1474
|
|
|
1140
1475
|
```ts
|
|
1476
|
+
const [цУкл] = await db.Услуга({ name: 'Укладка' }).цена().create({ amounts: { RUB: 700 } }).rows() // базовая цена
|
|
1141
1477
|
const tr = await db.begin()
|
|
1142
|
-
await tr
|
|
1143
|
-
await tr
|
|
1144
|
-
await tr.rollback() // [
|
|
1145
|
-
await db
|
|
1478
|
+
await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows()
|
|
1479
|
+
await tr.цена(цУкл).first() // внутри tx → amounts.RUB = 9900
|
|
1480
|
+
await tr.rollback() // [1.1 ms]
|
|
1481
|
+
await db.цена(цУкл).first() // снаружи → amounts.RUB = 700 — изменение исчезло
|
|
1146
1482
|
```
|
|
1147
1483
|
|
|
1148
1484
|
#### `db.commit(tr): Promise<void>` / `db.rollback(tr): Promise<void>`
|
|
@@ -1157,7 +1493,7 @@ await db.Услуга({ name: 'Укладка' }).first() // снаружи
|
|
|
1157
1493
|
**Примеры**
|
|
1158
1494
|
|
|
1159
1495
|
```ts
|
|
1160
|
-
await db.commit(tr3) // [
|
|
1496
|
+
await db.commit(tr3) // [4.0 ms] — то же, что tr3.commit()
|
|
1161
1497
|
await db.commit()
|
|
1162
1498
|
// Error: letopis: commit() needs a transaction: db.commit(tr) or tr.commit() [0.1 ms]
|
|
1163
1499
|
```
|
|
@@ -1183,14 +1519,14 @@ Tombstone-версия приходит с `deleted: true`.
|
|
|
1183
1519
|
**Примеры**
|
|
1184
1520
|
|
|
1185
1521
|
```ts
|
|
1186
|
-
const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [
|
|
1187
|
-
await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр §11' }).rows()
|
|
1188
|
-
await db.Клиент('…0932').update({
|
|
1522
|
+
const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [17.9 ms]
|
|
1523
|
+
await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр §11', phone: '+7 900 …' }).rows()
|
|
1524
|
+
await db.Клиент('…0932').update({ preferred_contact: 'email' }).rows()
|
|
1189
1525
|
await db.Клиент('…0932').delete({ confirm: true }).rows()
|
|
1190
1526
|
// пойманные (3 события: insert → update → tombstone):
|
|
1191
|
-
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…
|
|
1192
|
-
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…
|
|
1193
|
-
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…
|
|
1527
|
+
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.912278+00:00', deleted: false }
|
|
1528
|
+
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.921929+00:00', deleted: false }
|
|
1529
|
+
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.944767+00:00', deleted: true }
|
|
1194
1530
|
await stop()
|
|
1195
1531
|
```
|
|
1196
1532
|
|
|
@@ -1202,8 +1538,8 @@ const stop = await db.watch('Клиент', (e) => события.push(e), {
|
|
|
1202
1538
|
})
|
|
1203
1539
|
// авария: pg_terminate_backend по LISTEN-соединению …
|
|
1204
1540
|
// onReconnect сработал через 0.1 s после обрыва (реальный прогон)
|
|
1205
|
-
await db
|
|
1206
|
-
// событие после reconnect: { class: 'Customer', id: '…0932', updated: '…
|
|
1541
|
+
await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр после обрыва', phone: '+7 900 …' }).rows() // create по id — воскрешение
|
|
1542
|
+
// событие после reconnect: { class: 'Customer', id: '…0932', updated: '…43.273398+00:00', deleted: false }
|
|
1207
1543
|
await stop()
|
|
1208
1544
|
```
|
|
1209
1545
|
|
|
@@ -1213,7 +1549,7 @@ await stop()
|
|
|
1213
1549
|
падают ошибкой postgres.js.
|
|
1214
1550
|
|
|
1215
1551
|
```ts
|
|
1216
|
-
await db.close() // [1.
|
|
1552
|
+
await db.close() // [1.3 ms]
|
|
1217
1553
|
```
|
|
1218
1554
|
|
|
1219
1555
|
**Кейс** — завершение процесса: `close()` в `finally`/`SIGTERM`-хендлере после `stop()`
|
|
@@ -1225,7 +1561,7 @@ await db.close() // [1.7 ms]
|
|
|
1225
1561
|
и поле `all` — § 11.11.
|
|
1226
1562
|
|
|
1227
1563
|
```ts
|
|
1228
|
-
db.registry.resolve('
|
|
1564
|
+
db.registry.resolve('запись') // [99 µs] → ClassDef {id: 'booking', alias: 'запись', …}
|
|
1229
1565
|
```
|
|
1230
1566
|
|
|
1231
1567
|
#### `db.sql`
|
|
@@ -1234,8 +1570,8 @@ db.registry.resolve('Запись') // [90 µs] → ClassDef {id: 'Booking', a
|
|
|
1234
1570
|
Ответственность за SQL — на вызывающем (движок цепочек его не проверяет).
|
|
1235
1571
|
|
|
1236
1572
|
```ts
|
|
1237
|
-
await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.
|
|
1238
|
-
// [
|
|
1573
|
+
await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.salondemo"."Entity"')
|
|
1574
|
+
// [37.3 ms] → [{ n: 980216 }]
|
|
1239
1575
|
```
|
|
1240
1576
|
|
|
1241
1577
|
**Кейс** — снятие плана тяжёлого запроса: `db.sql.unsafe('EXPLAIN (ANALYZE) …')` для
|
|
@@ -1267,8 +1603,8 @@ prev.links->>'Класс'`) кладётся точным равенством,
|
|
|
1267
1603
|
**Примеры**
|
|
1268
1604
|
|
|
1269
1605
|
```ts
|
|
1270
|
-
await db.Организация('…
|
|
1271
|
-
await db.навык().Услуга().count()
|
|
1606
|
+
await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12 (обратный hop)
|
|
1607
|
+
await db.навык().Услуга().count() // [65.2 ms] → 1260 (LINK → HUB, прямой)
|
|
1272
1608
|
```
|
|
1273
1609
|
|
|
1274
1610
|
**Кейс: маршрут «мастер → его навыки → услуги»** — см. `.run()` ниже (тот же прогон).
|
|
@@ -1285,20 +1621,21 @@ await db.навык().Услуга().count() // [57.2 ms] →
|
|
|
1285
1621
|
**Примеры**
|
|
1286
1622
|
|
|
1287
1623
|
```ts
|
|
1288
|
-
await db
|
|
1289
|
-
// →
|
|
1290
|
-
//
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
// }
|
|
1624
|
+
await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
|
|
1625
|
+
// → 2 пути (у Ольги 0.0 два навыка на разные услуги); первый:
|
|
1626
|
+
// [{
|
|
1627
|
+
// Мастер: { id: '00000003-…-000', class: 'Staff', data: { name: 'Ольга 0.0', phone: '+7 921 0000000', specialization: 'парикмахер' }, links: { Org: '00000001-…-000' }, … },
|
|
1628
|
+
// навык: { id: '00000012-…-000', class: 'skill', data: { level: 'basic' }, links: { Staff: '00000003-…-000', Service: '00000004-…-000' }, … },
|
|
1629
|
+
// Услуга: { id: '00000004-…-000', class: 'Service', data: { name: 'Стрижка 0', duration: 30, description: 'популярное' }, links: { Org: '00000001-…-000' }, … }
|
|
1630
|
+
// }, …] — по объекту на путь, полные узлы каждого шага
|
|
1294
1631
|
```
|
|
1295
1632
|
|
|
1296
1633
|
**Кейс: отчёт «кто что умеет» одним запросом**
|
|
1297
1634
|
|
|
1298
1635
|
```ts
|
|
1299
|
-
const пути = await db
|
|
1300
|
-
пути.map((p) => `${p
|
|
1301
|
-
// → ['
|
|
1636
|
+
const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
|
|
1637
|
+
пути.map((p) => `${p.Мастер.data.name} → ${p.Услуга.data.name}`)
|
|
1638
|
+
// → ['Ольга 0.0 → Стрижка 0', 'Ольга 0.0 → Массаж 7']
|
|
1302
1639
|
```
|
|
1303
1640
|
|
|
1304
1641
|
#### `.rows(): Promise<Row[]>`
|
|
@@ -1311,17 +1648,18 @@ const пути = await db.Сотрудник({ name: 'Вася' }).навык().
|
|
|
1311
1648
|
**Примеры**
|
|
1312
1649
|
|
|
1313
1650
|
```ts
|
|
1314
|
-
const услуги = await db.Услуга().rows() // [
|
|
1315
|
-
// [0] = { id: '
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1651
|
+
const услуги = await db.Услуга().rows() // [11.1 ms] → 600 Row
|
|
1652
|
+
// [0] = { id: '00000004-0000-4000-8000-000000000000', class: 'Service',
|
|
1653
|
+
// data: { name: 'Стрижка 0', duration: 30, description: 'популярное' },
|
|
1654
|
+
// links: { Org: '00000001-0000-4000-8000-000000000000' }, tags: [],
|
|
1655
|
+
// updated: '2025-08-21T10:18:02.707+00:00' }
|
|
1318
1656
|
```
|
|
1319
1657
|
|
|
1320
1658
|
**Кейс: пути vs уникальные сущности**
|
|
1321
1659
|
|
|
1322
1660
|
```ts
|
|
1323
|
-
await db.навык().Услуга().count() // [
|
|
1324
|
-
(await db.навык().Услуга().rows()).length // [
|
|
1661
|
+
await db.навык().Услуга().count() // [65.2 ms] → 1260 путей (навык → услуга)
|
|
1662
|
+
(await db.навык().Услуга().rows()).length // [83.6 ms] → 600 уникальных услуг
|
|
1325
1663
|
```
|
|
1326
1664
|
|
|
1327
1665
|
#### `.first(): Promise<Row | null>`
|
|
@@ -1329,10 +1667,10 @@ await db.навык().Услуга().count() // [57.2 ms] → 1001 пу
|
|
|
1329
1667
|
Параметров нет. То же, что `rows()` с `LIMIT 1`: первая строка или `null`.
|
|
1330
1668
|
|
|
1331
1669
|
```ts
|
|
1332
|
-
await db
|
|
1333
|
-
// → { id: '…
|
|
1334
|
-
// links: { Org: '…
|
|
1335
|
-
await db
|
|
1670
|
+
await db.Мастер({ name: 'Олег 0.7' }).first() // [5.6 ms]
|
|
1671
|
+
// → { id: '…0007', class: 'Staff', data: { name: 'Олег 0.7', phone: '+7 921 0000007', specialization: 'колорист' },
|
|
1672
|
+
// links: { Org: '…0000' }, tags: [], updated: '2025-08-01T00:00:00+00:00' }
|
|
1673
|
+
await db.Мастер({ name: 'Гэндальф' }).first() // [5.7 ms] → null
|
|
1336
1674
|
```
|
|
1337
1675
|
|
|
1338
1676
|
**Кейс: проверка «занято ли окно» перед бронью** — § 11.6 (перечитка под локом).
|
|
@@ -1343,8 +1681,8 @@ await db.Сотрудник({ name: 'Гэндальф' }).first() // [3.9 ms]
|
|
|
1343
1681
|
(дешевле по трафику).
|
|
1344
1682
|
|
|
1345
1683
|
```ts
|
|
1346
|
-
await db
|
|
1347
|
-
// ['
|
|
1684
|
+
await db.Мастер().ids() // [4.3 ms] → 360 id
|
|
1685
|
+
// ['00000003-0000-4000-8000-000000000000', '…0001', '00000003-0000-4000-8000-000000000002', …]
|
|
1348
1686
|
```
|
|
1349
1687
|
|
|
1350
1688
|
**Кейс: набор id для батч-обработки** — собрать `ids()`, скормить очереди задач; полные
|
|
@@ -1359,17 +1697,17 @@ await db.Сотрудник().ids() // [3.8 ms] → 302 id
|
|
|
1359
1697
|
для многошаговой — нет (см. кейс `.rows()`).
|
|
1360
1698
|
|
|
1361
1699
|
```ts
|
|
1362
|
-
await db.Организация('…
|
|
1363
|
-
await db
|
|
1364
|
-
await db
|
|
1365
|
-
// [
|
|
1700
|
+
await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12
|
|
1701
|
+
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [37.4 ms] → 630
|
|
1702
|
+
await db.Локация({ coordinates: { lat: gte(55.5) } }).count()
|
|
1703
|
+
// [4.4 ms] → 26 — оператор на листе record-пути (глубина 2), каст numeric по Schema
|
|
1366
1704
|
```
|
|
1367
1705
|
|
|
1368
1706
|
**Кейс: витрина каталога** — счётчики к фильтрам без выборки строк:
|
|
1369
1707
|
|
|
1370
1708
|
```ts
|
|
1371
|
-
await db.Услуга({ duration: lte(45) }).count() // [3.
|
|
1372
|
-
await db
|
|
1709
|
+
await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300 «быстрые»
|
|
1710
|
+
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [37.4 ms] → 630 «премиум»
|
|
1373
1711
|
```
|
|
1374
1712
|
|
|
1375
1713
|
#### `.limit(n): Chain` / `.offset(n): Chain`
|
|
@@ -1385,10 +1723,10 @@ await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 3
|
|
|
1385
1723
|
**Примеры**
|
|
1386
1724
|
|
|
1387
1725
|
```ts
|
|
1388
|
-
await db
|
|
1389
|
-
// → [{
|
|
1390
|
-
await db
|
|
1391
|
-
// → [{ RUB:
|
|
1726
|
+
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms]
|
|
1727
|
+
// → [{ note: 'базовая', RUB: 8100 }, { note: 'базовая', RUB: 8000 }, { note: 'базовая', RUB: 7900 }]
|
|
1728
|
+
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows() // [19.5 ms]
|
|
1729
|
+
// → [{ RUB: 7800 }, { RUB: 7700 }, { RUB: 7600 }] — вторая страница
|
|
1392
1730
|
```
|
|
1393
1731
|
|
|
1394
1732
|
**Кейс: классическая пагинация страницы каталога** — `limit(3)` + `offset(3·N)`; при выходе
|
|
@@ -1398,21 +1736,21 @@ await db.Услуга().sort('data.price.RUB', 'desc').limit(3).offset(3).rows()
|
|
|
1398
1736
|
|
|
1399
1737
|
| Параметр | Тип | Описание |
|
|
1400
1738
|
|---|---|---|
|
|
1401
|
-
| `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.
|
|
1739
|
+
| `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.coordinates.lat`); SQL-каст по типу листа из Schema |
|
|
1402
1740
|
| `dir` | `'asc'` \| `'desc'` \| `boolean?` | default `asc`; `true` ≡ `'desc'` |
|
|
1403
1741
|
|
|
1404
1742
|
**Назначение и алгоритм.** `ORDER BY` по колонке `updated` или по выражению
|
|
1405
|
-
`data->'
|
|
1743
|
+
`data->'coordinates'->>'lat'` с кастом (numeric/text/timestamptz — из типа листа в Schema).
|
|
1406
1744
|
Обязателен для `.after()`.
|
|
1407
1745
|
|
|
1408
1746
|
**Примеры**
|
|
1409
1747
|
|
|
1410
1748
|
```ts
|
|
1411
|
-
await db
|
|
1412
|
-
await db.Услуга().sort('data.duration').limit(2).rows()
|
|
1413
|
-
// → [{ name: '
|
|
1414
|
-
await db
|
|
1415
|
-
// ЧЕСТНО: DISTINCT ON всех
|
|
1749
|
+
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms] → 8100, 8000, 7900
|
|
1750
|
+
await db.Услуга().sort('data.duration').limit(2).rows() // [7.5 ms] asc по умолчанию
|
|
1751
|
+
// → [{ name: 'Стрижка 0', duration: 30 }, { name: 'Педикюр 4', duration: 30 }]
|
|
1752
|
+
await db.запись().sort('updated', 'desc').limit(2).rows() // [6232.3 ms]
|
|
1753
|
+
// ЧЕСТНО: DISTINCT ON всех ~440k сущностей класса без фильтра — см. § 14
|
|
1416
1754
|
```
|
|
1417
1755
|
|
|
1418
1756
|
**Кейс: топ прайса** — первый пример; правило объёма: сортировка **всего** большого класса
|
|
@@ -1432,15 +1770,15 @@ await db.Запись().sort('updated', 'desc').limit(2).rows() // [161
|
|
|
1432
1770
|
**Примеры**
|
|
1433
1771
|
|
|
1434
1772
|
```ts
|
|
1435
|
-
const истор = await db
|
|
1436
|
-
await db
|
|
1437
|
-
// → data.
|
|
1438
|
-
await db
|
|
1439
|
-
// → data.
|
|
1773
|
+
const истор = await db.цена('…0009').versions() // [7.7 ms] (для t1 ниже)
|
|
1774
|
+
await db.цена('…0009').asOf(истор[0].updated).first() // [3.0 ms]
|
|
1775
|
+
// → data.amounts.RUB = 2550 — цена ТОГДА
|
|
1776
|
+
await db.цена('…0009').first()
|
|
1777
|
+
// → data.amounts.RUB = 2650 — цена сейчас
|
|
1440
1778
|
```
|
|
1441
1779
|
|
|
1442
1780
|
**Кейс: спор по чеку** — «сколько стоила стрижка в момент оформления записи»:
|
|
1443
|
-
`db
|
|
1781
|
+
`db.цена(id).asOf(запись.updated).first()` → исторический прайс без отдельных таблиц аудита.
|
|
1444
1782
|
|
|
1445
1783
|
#### `.versions(): Promise<Row[]>`
|
|
1446
1784
|
|
|
@@ -1454,14 +1792,12 @@ await db.Услуга('…0021').first()
|
|
|
1454
1792
|
**Примеры**
|
|
1455
1793
|
|
|
1456
1794
|
```ts
|
|
1457
|
-
await db
|
|
1458
|
-
// → [{
|
|
1459
|
-
//
|
|
1460
|
-
|
|
1461
|
-
//
|
|
1462
|
-
// {
|
|
1463
|
-
// { status: 'confirmed', updated: '…33.652394', $deleted: true }, ← tombstone
|
|
1464
|
-
// { status: 'created', updated: '…33.822616' }] ← воскрешение
|
|
1795
|
+
await db.цена('…0009').versions() // [7.7 ms]
|
|
1796
|
+
// → [{ RUB: 2550, updated: '2025-08-23T…' }, { RUB: 2650, updated: '2025-11-21T…' }]
|
|
1797
|
+
await db.Клиент('…0931').versions() // жизнь с удалением и воскрешением:
|
|
1798
|
+
// → [{ name: 'Злата', updated: '…54.159646' },
|
|
1799
|
+
// { name: 'Злата', updated: '…55.627712', $deleted: true }, ← tombstone
|
|
1800
|
+
// { name: 'Злата', updated: '…55.678666' }] ← воскрешение (create по тому же id)
|
|
1465
1801
|
```
|
|
1466
1802
|
|
|
1467
1803
|
**Кейс: аудит «кто когда менял»** — `versions()` + `owner` каждой версии = полный
|
|
@@ -1483,18 +1819,19 @@ await db.Запись('…0061').versions() // [6.7 ms] — жизнь с уд
|
|
|
1483
1819
|
**Примеры**
|
|
1484
1820
|
|
|
1485
1821
|
```ts
|
|
1486
|
-
const p1 = await db
|
|
1487
|
-
const кур = cursorOf(p1.at(-1)) // [
|
|
1488
|
-
// → { v: '2026-07-
|
|
1489
|
-
const p2 = await db
|
|
1822
|
+
const p1 = await db.запись().sort('updated', 'desc').limit(3).rows() // [5632.0 ms] — ~440k
|
|
1823
|
+
const кур = cursorOf(p1.at(-1)) // [82 µs]
|
|
1824
|
+
// → { v: '2026-07-25T18:00:00+00:00', id: '00000009-0000-4000-8000-000000431198' }
|
|
1825
|
+
const p2 = await db.запись().sort('updated', 'desc').after(кур).limit(3).rows() // [5680.4 ms]
|
|
1490
1826
|
// p2 — следующие 3, пересечение страниц: 0
|
|
1827
|
+
// (у тысяч записей последнего дня updated совпадает — id вторым ключом ORDER BY держит границу)
|
|
1491
1828
|
|
|
1492
|
-
const курЦены = cursorOf(топ3.at(-1), 'data.
|
|
1493
|
-
await db
|
|
1494
|
-
// →
|
|
1829
|
+
const курЦены = cursorOf(топ3.at(-1), 'data.amounts.RUB') // [41 µs] → { v: 7900, id: '…0951' }
|
|
1830
|
+
await db.цена().sort('data.amounts.RUB', 'desc').after(курЦены).limit(3).rows() // [20.5 ms]
|
|
1831
|
+
// → 7800, 7700, 7600
|
|
1495
1832
|
|
|
1496
|
-
await db
|
|
1497
|
-
// Error: letopis: .after(cursor) requires .sort(field) [0.
|
|
1833
|
+
await db.запись().after(кур).rows()
|
|
1834
|
+
// Error: letopis: .after(cursor) requires .sort(field) [0.3 ms]
|
|
1498
1835
|
```
|
|
1499
1836
|
|
|
1500
1837
|
**Кейс: бесконечная лента записей**
|
|
@@ -1502,7 +1839,7 @@ await db.Запись().after(кур).rows()
|
|
|
1502
1839
|
```ts
|
|
1503
1840
|
let кур
|
|
1504
1841
|
for (;;) {
|
|
1505
|
-
let q = db
|
|
1842
|
+
let q = db.запись().sort('updated', 'desc').limit(100)
|
|
1506
1843
|
if (кур) q = q.after(кур)
|
|
1507
1844
|
const стр = await q.rows()
|
|
1508
1845
|
if (!стр.length) break
|
|
@@ -1525,19 +1862,22 @@ for (;;) {
|
|
|
1525
1862
|
**Примеры**
|
|
1526
1863
|
|
|
1527
1864
|
```ts
|
|
1528
|
-
await db.Папка('…
|
|
1529
|
-
// → [{ name: 'Мужской зал', $depth: 1 }, { name: '
|
|
1530
|
-
|
|
1531
|
-
//
|
|
1865
|
+
await db.Папка('…0000').Папка().deep().rows() // [9.2 ms]
|
|
1866
|
+
// → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 },
|
|
1867
|
+
// { name: 'Борода и усы', $depth: 2 }, { name: 'Уход', $depth: 3 }]
|
|
1868
|
+
await db.Папка('…0000').Папка().deep(1).rows() // [7.4 ms] только прямые дети
|
|
1869
|
+
// → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 }]
|
|
1532
1870
|
```
|
|
1533
1871
|
|
|
1534
1872
|
**Кейс: хлебные крошки каталога** — дерево одним запросом, глубина из `$depth`:
|
|
1535
1873
|
|
|
1536
1874
|
```ts
|
|
1537
|
-
const дерево = await db.Папка(корень).Папка().deep().rows() // [
|
|
1875
|
+
const дерево = await db.Папка(корень).Папка().deep().rows() // [9.2 ms]
|
|
1538
1876
|
дерево.map((p) => `${' '.repeat(p.$depth)}${p.data.name}`)
|
|
1539
1877
|
// → Мужской зал
|
|
1878
|
+
// Женский зал
|
|
1540
1879
|
// Борода и усы
|
|
1880
|
+
// Уход
|
|
1541
1881
|
```
|
|
1542
1882
|
|
|
1543
1883
|
#### `.sum(field)` / `.avg(field): Promise<number | null>`
|
|
@@ -1553,14 +1893,14 @@ const дерево = await db.Папка(корень).Папка().deep().rows(
|
|
|
1553
1893
|
**Примеры**
|
|
1554
1894
|
|
|
1555
1895
|
```ts
|
|
1556
|
-
await db
|
|
1557
|
-
await db.Услуга().avg('data.duration')
|
|
1558
|
-
await db
|
|
1559
|
-
await db.Услуга({ name: 'НетТакой' }).sum('data.duration')
|
|
1896
|
+
await db.цена().sum('data.amounts.RUB') // [13.2 ms] → 2277000
|
|
1897
|
+
await db.Услуга().avg('data.duration') // [3.0 ms] → 52.5
|
|
1898
|
+
await db.Локация({}).sum('data.coordinates.lat') // [3.9 ms] лист record-пути (глубина 2) → 3327.925547539955
|
|
1899
|
+
await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [5.0 ms] → null (пусто)
|
|
1560
1900
|
```
|
|
1561
1901
|
|
|
1562
|
-
**Кейс:
|
|
1563
|
-
за
|
|
1902
|
+
**Кейс: итог по каталогу без выгрузки строк** — `sum('data.amounts.RUB')` по 405 ценам за 4 ms;
|
|
1903
|
+
на полигоне salondemo — 1026 цен на 2 277 000 за ≈13 ms (§ 14). Строки в приложение не едут.
|
|
1564
1904
|
|
|
1565
1905
|
#### `.min(field)` / `.max(field): Promise<unknown>`
|
|
1566
1906
|
|
|
@@ -1568,8 +1908,8 @@ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') /
|
|
|
1568
1908
|
**числом**, string — строкой.
|
|
1569
1909
|
|
|
1570
1910
|
```ts
|
|
1571
|
-
await db
|
|
1572
|
-
await db
|
|
1911
|
+
await db.цена().min('data.amounts.RUB') // [15.0 ms] → 300 (число, не '300')
|
|
1912
|
+
await db.цена().max('data.amounts.RUB') // [14.8 ms] → 8100
|
|
1573
1913
|
```
|
|
1574
1914
|
|
|
1575
1915
|
**Кейс: границы ценового слайдера** — `min` + `max` двумя запросами по 3–4 ms.
|
|
@@ -1584,11 +1924,12 @@ await db.Услуга().max('data.price.RUB') // [4.2 ms] → 1800
|
|
|
1584
1924
|
объекта = значения поля, значения = счётчики (по путям).
|
|
1585
1925
|
|
|
1586
1926
|
```ts
|
|
1587
|
-
await db
|
|
1588
|
-
// → {
|
|
1927
|
+
await db.запись().countBy('data.notes') // [3266.3 ms] — ~440k сущностей
|
|
1928
|
+
// → { 'подтверждена': 400000, 'по телефону: Вера': 3334, 'по телефону: Ольга': 3334, …,
|
|
1929
|
+
// 'по телефону: Марина': 3333 } — 12 имён «по телефону» по ~3333 (ручные брони ~9%)
|
|
1589
1930
|
```
|
|
1590
1931
|
|
|
1591
|
-
**Кейс: дашборд
|
|
1932
|
+
**Кейс: дашборд по заметкам записей** — один запрос вместо N `count()`; ~440k строк агрегирует БД.
|
|
1592
1933
|
|
|
1593
1934
|
#### `.alias(name): Chain`
|
|
1594
1935
|
|
|
@@ -1600,8 +1941,8 @@ await db.Запись().countBy('data.status') // [575.8 ms] — 250k сущн
|
|
|
1600
1941
|
когда один класс встречается в пути дважды.
|
|
1601
1942
|
|
|
1602
1943
|
```ts
|
|
1603
|
-
await db.Организация(org).alias('салон')
|
|
1604
|
-
// [6.
|
|
1944
|
+
await db.Организация(org).alias('салон').Мастер({ name: 'Ольга 0.0' }).alias('мастер').run()
|
|
1945
|
+
// [6.7 ms] → ключи пути: ['салон', 'мастер']
|
|
1605
1946
|
```
|
|
1606
1947
|
|
|
1607
1948
|
**Кейс: self-join читаемо** — `db.Папка(a).alias('родитель').Папка().alias('дочка').run()`.
|
|
@@ -1616,8 +1957,8 @@ await db.Организация(org).alias('салон').Сотрудник({ na
|
|
|
1616
1957
|
кандидаты + перепроверка). В записи — модификатор значения.
|
|
1617
1958
|
|
|
1618
1959
|
```ts
|
|
1619
|
-
await db.Клиент().tags('vip').count()
|
|
1620
|
-
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [
|
|
1960
|
+
await db.Клиент().tags('vip').count() // [24.7 ms] → 400 (vip-клиенты)
|
|
1961
|
+
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
|
|
1621
1962
|
```
|
|
1622
1963
|
|
|
1623
1964
|
**Кейс: пометить и найти** — § 5 (вставка с `.tags(['vip','telegram'])`, поиск `tags('vip')`);
|
|
@@ -1634,8 +1975,8 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms]
|
|
|
1634
1975
|
ошибка `acl pins` (§ 11.10).
|
|
1635
1976
|
|
|
1636
1977
|
```ts
|
|
1637
|
-
await db.Организация().account(SYS).count() // [
|
|
1638
|
-
await db.Организация().owner(SYS).count()
|
|
1978
|
+
await db.Организация().account(SYS).count() // [5.1 ms] → 30
|
|
1979
|
+
await db.Организация().owner(SYS).count() // [3.9 ms] → 30
|
|
1639
1980
|
```
|
|
1640
1981
|
|
|
1641
1982
|
**Кейс: чей это салон** — профиль владельца строки: `db.accounts.get(row.owner)`; выборка
|
|
@@ -1644,17 +1985,17 @@ await db.Организация().owner(SYS).count() // [3.4 ms] → 21
|
|
|
1644
1985
|
### 11.4 Операторы фильтров
|
|
1645
1986
|
|
|
1646
1987
|
18 функций-операторов: каждая возвращает объект-условие `Op` для значения поля в фильтре
|
|
1647
|
-
шага (`{ duration: gte(60) }`), включая record-пути (`{
|
|
1988
|
+
шага (`{ duration: gte(60) }`), включая record-пути (`{ coordinates: { lat: gte(55.5) } }` —
|
|
1648
1989
|
условие на листе любой глубины, SQL-каст по типу листа из Schema). Компилируются в
|
|
1649
1990
|
выражение на актуальной строке (`(data->>'duration')::numeric >= 60`); containment-части
|
|
1650
|
-
дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (
|
|
1991
|
+
дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (600 сущностей).
|
|
1651
1992
|
|
|
1652
1993
|
#### `ne(v): Op`
|
|
1653
1994
|
|
|
1654
1995
|
`v: string | number | boolean | null` — «не равно» (`IS DISTINCT FROM` — null-безопасно).
|
|
1655
1996
|
|
|
1656
1997
|
```ts
|
|
1657
|
-
await db.Услуга({ name: ne('Стрижка') }).count() // [
|
|
1998
|
+
await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms] → 570
|
|
1658
1999
|
```
|
|
1659
2000
|
|
|
1660
2001
|
**Кейс:** всё, кроме выбранного, — «другие услуги» под карточкой текущей.
|
|
@@ -1664,30 +2005,30 @@ await db.Услуга({ name: ne('Стрижка') }).count() // [3.4 ms] →
|
|
|
1664
2005
|
`v: number | string` — строго больше / больше-или-равно (числа и сравнимые строки-даты).
|
|
1665
2006
|
|
|
1666
2007
|
```ts
|
|
1667
|
-
await db.Услуга({ duration: gt(60) }).count() // [
|
|
1668
|
-
await db.Услуга({ duration: gte(60) }).count() // [4.
|
|
2008
|
+
await db.Услуга({ duration: gt(60) }).count() // [6.4 ms] → 150
|
|
2009
|
+
await db.Услуга({ duration: gte(60) }).count() // [4.3 ms] → 300
|
|
1669
2010
|
```
|
|
1670
2011
|
|
|
1671
2012
|
**Кейс:** граница включительно или нет — «от часа» это `gte(60)`; `gt(60)` потеряет
|
|
1672
|
-
ровно-часовые (
|
|
2013
|
+
ровно-часовые (300 vs 150).
|
|
1673
2014
|
|
|
1674
2015
|
#### `lt(v): Op` / `lte(v): Op`
|
|
1675
2016
|
|
|
1676
2017
|
`v: number | string` — строго меньше / меньше-или-равно.
|
|
1677
2018
|
|
|
1678
2019
|
```ts
|
|
1679
|
-
await db.Услуга({ duration: lt(45) }).count() // [
|
|
1680
|
-
await db.Услуга({ duration: lte(45) }).count() // [3.
|
|
2020
|
+
await db.Услуга({ duration: lt(45) }).count() // [5.1 ms] → 150
|
|
2021
|
+
await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300
|
|
1681
2022
|
```
|
|
1682
2023
|
|
|
1683
|
-
**Кейс:** «экспресс до 45 минут включительно» = `lte(45)` →
|
|
2024
|
+
**Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 300 услуг.
|
|
1684
2025
|
|
|
1685
2026
|
#### `between(a, b): Op`
|
|
1686
2027
|
|
|
1687
2028
|
`a, b: number | string` — диапазон включительно (`a ≤ x ≤ b`).
|
|
1688
2029
|
|
|
1689
2030
|
```ts
|
|
1690
|
-
await db.Услуга({ duration: between(40, 65) }).count() // [
|
|
2031
|
+
await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 300
|
|
1691
2032
|
```
|
|
1692
2033
|
|
|
1693
2034
|
**Кейс:** слайдер длительности «40–65 минут» одной функцией вместо пары gte+lte.
|
|
@@ -1697,7 +2038,7 @@ await db.Услуга({ duration: between(40, 65) }).count() // [3.6 ms] → 2
|
|
|
1697
2038
|
`vs: (string | number)[]` — значение из списка (`IN`).
|
|
1698
2039
|
|
|
1699
2040
|
```ts
|
|
1700
|
-
await db.Услуга({ name: inList(['Стрижка', '
|
|
2041
|
+
await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [4.4 ms] → 60
|
|
1701
2042
|
```
|
|
1702
2043
|
|
|
1703
2044
|
**Кейс:** сравнение выбранных чекбоксами услуг: имена из UI → один запрос.
|
|
@@ -1707,72 +2048,73 @@ await db.Услуга({ name: inList(['Стрижка', 'Услуга 7']) }).co
|
|
|
1707
2048
|
`s: string` — SQL-шаблон (`%` — любое, `_` — один символ); `ilike` — без учёта регистра.
|
|
1708
2049
|
|
|
1709
2050
|
```ts
|
|
1710
|
-
await db.Услуга({ name: like('Стри%') }).count() // [3
|
|
1711
|
-
await db.Услуга({ name: ilike('
|
|
2051
|
+
await db.Услуга({ name: like('Стри%') }).count() // [4.3 ms] → 60
|
|
2052
|
+
await db.Услуга({ name: ilike('%массаж%') }).count() // [4.3 ms] → 60
|
|
1712
2053
|
```
|
|
1713
2054
|
|
|
1714
2055
|
**Кейс:** живой поиск в админке — `ilike('%' + ввод + '%')` прощает регистр
|
|
1715
|
-
(
|
|
2056
|
+
(«массаж» находит «Массаж 7», «Массаж 17»).
|
|
1716
2057
|
|
|
1717
2058
|
#### `starts(s): Op` / `ends(s): Op`
|
|
1718
2059
|
|
|
1719
2060
|
`s: string` — начинается с / заканчивается на (сахар над `like(s+'%')` / `like('%'+s)`).
|
|
1720
2061
|
|
|
1721
2062
|
```ts
|
|
1722
|
-
await db.Услуга({ name: starts('
|
|
1723
|
-
await db.Услуга({ name: ends('
|
|
2063
|
+
await db.Услуга({ name: starts('Массаж') }).count() // [3.7 ms] → 60
|
|
2064
|
+
await db.Услуга({ name: ends('7') }).count() // [2.9 ms] → 60
|
|
1724
2065
|
```
|
|
1725
2066
|
|
|
1726
|
-
**Кейс:** префиксная навигация по
|
|
2067
|
+
**Кейс:** префиксная навигация по названию: `starts('Массаж')` → все «Массаж N» (60 в каталоге).
|
|
1727
2068
|
|
|
1728
2069
|
#### `has(v): Op` / `hasAny(vs): Op` / `hasAll(vs): Op`
|
|
1729
2070
|
|
|
1730
|
-
`v: скаляр`, `vs: скаляр[]` —
|
|
1731
|
-
|
|
2071
|
+
`v: скаляр`, `vs: скаляр[]` — массив содержит значение / хотя бы одно / все (containment `@>` —
|
|
2072
|
+
идёт и в GIN-кандидаты). В демо-схеме массивов в `data` нет — операторы показаны на колонке `tags`.
|
|
1732
2073
|
|
|
1733
2074
|
```ts
|
|
1734
|
-
await db
|
|
1735
|
-
await db
|
|
1736
|
-
await db
|
|
2075
|
+
await db.Клиент().tags(has('vip')).count() // [54.5 ms] → 400
|
|
2076
|
+
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
|
|
2077
|
+
await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [11.0 ms] → 100
|
|
1737
2078
|
```
|
|
1738
2079
|
|
|
1739
|
-
**Кейс:**
|
|
1740
|
-
|
|
2080
|
+
**Кейс:** сегменты по меткам: «vip ИЛИ из телеграма» = `tags(hasAny(['vip','telegram']))` → 800;
|
|
2081
|
+
«vip И телеграм разом» = `tags(hasAll(['vip','telegram']))` → 100.
|
|
1741
2082
|
|
|
1742
2083
|
#### `exists(yes = true): Op`
|
|
1743
2084
|
|
|
1744
2085
|
`yes: boolean?` — поле присутствует (`true`, default) / отсутствует (`false`) в `data`.
|
|
1745
2086
|
|
|
1746
2087
|
```ts
|
|
1747
|
-
await db.Услуга({ description: exists() }).count() // [3.
|
|
1748
|
-
await db.Услуга({ description: exists(false) }).count() // [2
|
|
2088
|
+
await db.Услуга({ description: exists() }).count() // [3.7 ms] → 390
|
|
2089
|
+
await db.Услуга({ description: exists(false) }).count() // [3.2 ms] → 210
|
|
1749
2090
|
```
|
|
1750
2091
|
|
|
1751
|
-
**Кейс:** контроль заполненности каталога — «услуги без
|
|
1752
|
-
на доработку
|
|
2092
|
+
**Кейс:** контроль заполненности каталога — «услуги без ключа `description`» = `exists(false)` → 210
|
|
2093
|
+
на доработку контенту (не путать с `isNull()` ниже — тот ещё и явные `null` ловит).
|
|
1753
2094
|
|
|
1754
2095
|
#### `isNull(): Op`
|
|
1755
2096
|
|
|
1756
2097
|
Без параметров — поле `NULL` **или** отсутствует.
|
|
1757
2098
|
|
|
1758
2099
|
```ts
|
|
1759
|
-
await db.Услуга({ description: isNull() }).count() // [3.1 ms] →
|
|
2100
|
+
await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
|
|
1760
2101
|
```
|
|
1761
2102
|
|
|
1762
|
-
**Кейс:** отличие от `exists(false)
|
|
1763
|
-
|
|
2103
|
+
**Кейс:** отличие от `exists(false)` — на полигоне видно числом: `isNull()` → 390 (ловит и явный
|
|
2104
|
+
`null` в data, и отсутствие ключа), `exists(false)` → 210 (только отсутствие); расхождение 180 —
|
|
2105
|
+
это услуги с явным `description: null`.
|
|
1764
2106
|
|
|
1765
2107
|
#### `not(v): Op`
|
|
1766
2108
|
|
|
1767
2109
|
`v: скаляр | Op` — отрицание; скаляр ≡ «не равно» (как `ne`).
|
|
1768
2110
|
|
|
1769
2111
|
```ts
|
|
1770
|
-
await db.Услуга({ name: not(starts('
|
|
1771
|
-
await db.Услуга({ name: not('Стрижка') }).count() // [
|
|
2112
|
+
await db.Услуга({ name: not(starts('Стрижка')) }).count() // [3.7 ms] → 540
|
|
2113
|
+
await db.Услуга({ name: not('Стрижка 0') }).count() // [3.5 ms] → 570
|
|
1772
2114
|
```
|
|
1773
2115
|
|
|
1774
|
-
**Кейс:** инверсия готового условия без переписывания: «всё, что НЕ
|
|
1775
|
-
`not(starts('
|
|
2116
|
+
**Кейс:** инверсия готового условия без переписывания: «всё, что НЕ стрижки» =
|
|
2117
|
+
`not(starts('Стрижка'))` → 540 (из 600 услуг 60 — «Стрижка N»).
|
|
1776
2118
|
|
|
1777
2119
|
#### `or(...filters): Op`
|
|
1778
2120
|
|
|
@@ -1780,10 +2122,11 @@ await db.Услуга({ name: not('Стрижка') }).count() // [6.1
|
|
|
1780
2122
|
обычное И).
|
|
1781
2123
|
|
|
1782
2124
|
```ts
|
|
1783
|
-
await db.Услуга(or({ name: 'Стрижка' }, { duration: lt(45) })).count() // [4.0 ms] →
|
|
2125
|
+
await db.Услуга(or({ name: 'Стрижка 0' }, { duration: lt(45) })).count() // [4.0 ms] → 150
|
|
1784
2126
|
```
|
|
1785
2127
|
|
|
1786
|
-
**Кейс:**
|
|
2128
|
+
**Кейс:** «Стрижка 0 или что-нибудь быстрое» — один запрос: Стрижка 0 (duration 30) уже среди
|
|
2129
|
+
быстрых `lt(45)` → объединение = 150, отдельного плюса не даёт.
|
|
1787
2130
|
|
|
1788
2131
|
### 11.5 Запись: create / update / delete / anonymize
|
|
1789
2132
|
|
|
@@ -1814,28 +2157,28 @@ INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конф
|
|
|
1814
2157
|
**Примеры**
|
|
1815
2158
|
|
|
1816
2159
|
```ts
|
|
1817
|
-
await db.Организация().create({ name: 'Пилигрим' }).rows() // [
|
|
1818
|
-
// → [{ id: '
|
|
2160
|
+
await db.Организация().create({ name: 'Пилигрим' }).rows() // [11.1 ms] INSERT + defaults из Schema
|
|
2161
|
+
// → [{ id: '019f5a53-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, links: {}, … }]
|
|
1819
2162
|
|
|
1820
2163
|
await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
|
|
1821
|
-
// [
|
|
2164
|
+
// [11.4 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
|
|
1822
2165
|
|
|
1823
|
-
await db.Организация('…0901')
|
|
1824
|
-
// [
|
|
2166
|
+
await db.Организация('…0901').Мастер().create({ id: '…0911', name: 'Мия', phone: '+7 909 000-09-11', specialization: 'массажист' }).rows()
|
|
2167
|
+
// [15.2 ms] контекст → links: { Org: '…0901' }
|
|
1825
2168
|
|
|
1826
|
-
await db.Организация('…0901').Услуга().create({ name: '
|
|
1827
|
-
// [
|
|
1828
|
-
// повторный create той же пары (салон, имя) → новая ВЕРСИЯ
|
|
2169
|
+
await db.Организация('…0901').Услуга().create({ name: 'Массаж головы', duration: 30, description: 'релакс' }).rows()
|
|
2170
|
+
// [16.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
|
|
2171
|
+
// повторный create той же пары (салон, имя) → новая ВЕРСИЯ (deep-merge листьев), не дубль
|
|
1829
2172
|
|
|
1830
|
-
db.Услуга({ name: '
|
|
2173
|
+
db.Услуга({ name: 'Массаж головы' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
|
|
1831
2174
|
// Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
|
|
1832
2175
|
|
|
1833
|
-
await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() // [
|
|
2176
|
+
await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [9.9 ms] — ошибка НА ТЕРМИНАЛЕ:
|
|
1834
2177
|
// ValidationError: letopis: validation failed for "Service":
|
|
1835
|
-
//
|
|
2178
|
+
// The object '' contains forbidden keys: 'чепуха'.
|
|
1836
2179
|
|
|
1837
|
-
db
|
|
1838
|
-
// Error: letopis: no path
|
|
2180
|
+
db.Локация('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
|
|
2181
|
+
// Error: letopis: no path Location → Customer: neither embeds the other (Schema)
|
|
1839
2182
|
```
|
|
1840
2183
|
|
|
1841
2184
|
#### `.update(data?): Chain`
|
|
@@ -1846,7 +2189,7 @@ db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый
|
|
|
1846
2189
|
|
|
1847
2190
|
**Назначение и алгоритм.** Новая версия **каждого** найденного путём. Цели ищутся как при
|
|
1848
2191
|
чтении — id, фильтр, pivot; `Класс()` ≡ `Класс({})` — «все в границах контекста». На каждую
|
|
1849
|
-
цель: **deep-merge** листьев `data` (`update({
|
|
2192
|
+
цель: **deep-merge** листьев `data` (`update({ coordinates: { lat: 55.8 } })` сохранит `lng` и
|
|
1850
2193
|
остальные поля; массивы/скаляры — целиком), слияние links (слоты `.Класс.set()`/`.unset()`),
|
|
1851
2194
|
строгая валидация, один INSERT новой версии. Не найдено → `[]` — update **НИКОГДА не
|
|
1852
2195
|
создаёт**. Сегмент после операции исполняется **для каждой строки её результата** (fan-out);
|
|
@@ -1855,43 +2198,41 @@ db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый
|
|
|
1855
2198
|
**Примеры**
|
|
1856
2199
|
|
|
1857
2200
|
```ts
|
|
1858
|
-
await db
|
|
1859
|
-
//
|
|
1860
|
-
//
|
|
1861
|
-
//
|
|
1862
|
-
|
|
1863
|
-
await db.Клиент('…0931').Запись({ status: 'created' }).update({ status: 'confirmed' }).rows() // [12.1 ms]
|
|
1864
|
-
// → [{ id: '…0962', status: 'confirmed' }, { id: '…0963', status: 'confirmed' }] — только записи Златы
|
|
2201
|
+
await db.Клиент('…0931').запись({ start_datetime: between(t, t) }).update({ notes: 'подтверждена' }).rows() // [645.4 ms]
|
|
2202
|
+
// → [{ id: '4907d8cd-…', notes: 'подтверждена' }]
|
|
2203
|
+
// момент фильтруется between(t, t): скаляр-eq по date-полю = строковый containment, потому диапазон
|
|
2204
|
+
// (сотни мс: поиск целей идёт по всему классу записей ~440k без btree по data->>'start_datetime' — § 14)
|
|
1865
2205
|
|
|
1866
|
-
await db.Клиент('…0931')
|
|
1867
|
-
// → [{ id: '
|
|
2206
|
+
await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [600.4 ms] — ВСЕ записи в контексте
|
|
2207
|
+
// → [{ id: '4907d8cd-…', notes: 'день закрыт' }, { id: 'e7f16a08-…', notes: 'день закрыт' }]
|
|
1868
2208
|
|
|
1869
|
-
await db
|
|
2209
|
+
await db.запись({ notes: 'нет-такого' }).update({ notes: 'x' }).rows() // → [] — update НИКОГДА не создаёт
|
|
1870
2210
|
```
|
|
1871
2211
|
|
|
1872
2212
|
**Кейс: план из нескольких операций — реальный прогон**
|
|
1873
2213
|
|
|
1874
2214
|
```ts
|
|
1875
|
-
// обновить клиента → вставить ему запись (продолжение от записанного, одна транзакция)
|
|
1876
|
-
await db.Клиент('…0931').update({
|
|
1877
|
-
|
|
1878
|
-
.rows() // [
|
|
1879
|
-
// → [{ id: '…
|
|
2215
|
+
// обновить клиента → вставить ему запись со слотами (продолжение от записанного, одна транзакция)
|
|
2216
|
+
await db.Клиент('…0931').update({ preferred_contact: 'messenger' })
|
|
2217
|
+
.запись().create({ start_datetime: '2026-08-04T07:00:00Z', end_datetime: '2026-08-04T07:30:00Z' })
|
|
2218
|
+
.Мастер.set(мия).Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows() // [20.0 ms]
|
|
2219
|
+
// → [{ id: '…d091', class: 'booking', links: { Staff, Service, Customer: '…0931', Location, Schedule },
|
|
2220
|
+
// data: { start_datetime: '2026-08-04T07:00:00.000Z', end_datetime: '2026-08-04T07:30:00.000Z' } }]
|
|
1880
2221
|
|
|
1881
2222
|
// self-update: две версии подряд
|
|
1882
|
-
await db
|
|
1883
|
-
// → ['
|
|
2223
|
+
await db.запись('…d091').update({ notes: 'подтверждена' }).update({ notes: 'выполнена' }).rows() // [19.8 ms]
|
|
2224
|
+
// → ['выполнена']; versions: [null, 'подтверждена', 'выполнена']
|
|
1884
2225
|
|
|
1885
2226
|
// хвост-чтение после операции — в той же транзакции
|
|
1886
|
-
await db.Клиент('…0931').update({
|
|
2227
|
+
await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [16.7 ms] → 1
|
|
1887
2228
|
|
|
1888
|
-
// ОТКАТ: невалидный
|
|
1889
|
-
await db.Клиент('…0931').update({
|
|
1890
|
-
// Error: letopis: validation failed for "
|
|
2229
|
+
// ОТКАТ: валидный update + невалидный create — весь план назад
|
|
2230
|
+
await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().create({ чепуха: 1 }).rows()
|
|
2231
|
+
// Error: letopis: validation failed for "booking" … forbidden keys: 'чепуха' [15.5 ms]; клиент не изменился
|
|
1891
2232
|
|
|
1892
2233
|
// fan-out: обновить клиента → снести ВСЕ его записи
|
|
1893
|
-
await db.Клиент('…0931').update({
|
|
1894
|
-
// [
|
|
2234
|
+
await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().delete({ confirm: true }).rows()
|
|
2235
|
+
// [29.0 ms] → снесено 1 запись, с $deleted: true
|
|
1895
2236
|
```
|
|
1896
2237
|
|
|
1897
2238
|
#### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
|
|
@@ -1911,19 +2252,27 @@ links **БЕЗ участия в фильтре целей** (в отличие
|
|
|
1911
2252
|
**Примеры**
|
|
1912
2253
|
|
|
1913
2254
|
```ts
|
|
1914
|
-
await db
|
|
1915
|
-
// → { id: '
|
|
1916
|
-
// навык — v5: id вычислен из (Staff, Service
|
|
2255
|
+
await db.Мастер(мия).навык().create({ level: 'expert' }).Услуга.set(усл).rows() // [16.9 ms] ОДИН INSERT
|
|
2256
|
+
// → { id: '5e260ee8-…', class: 'skill', data: { level: 'expert' }, links: { Staff: '…0911', Service: '…' } }
|
|
2257
|
+
// навык — v5: id вычислен из (Staff, Service) — второй раз тот же навык не завести
|
|
2258
|
+
|
|
2259
|
+
// союз-конец [Услуга|Товар|Комплекс]: слот замещает целиком (соседний класс снят)
|
|
2260
|
+
await db.запись('…d091').update().Товар.set(воск).rows() // [10.3 ms] Service снят, Product встал
|
|
2261
|
+
|
|
2262
|
+
// снять optional-конец: ручная бронь без клиента, имя в notes
|
|
2263
|
+
await db.запись('…d091').update({ notes: 'бронь по телефону: Злата' }).Клиент.unset().rows() // [14.3 ms]
|
|
1917
2264
|
|
|
1918
|
-
//
|
|
1919
|
-
await db
|
|
2265
|
+
// required-конец снять нельзя:
|
|
2266
|
+
await db.запись('…d091').update().Локация.unset() // [0.3 ms]
|
|
2267
|
+
// Error: letopis: link end "Location" of "booking" is required — cannot unset
|
|
1920
2268
|
```
|
|
1921
2269
|
|
|
1922
|
-
**Кейс:
|
|
2270
|
+
**Кейс: заменить предмет на всех записях клиента**
|
|
1923
2271
|
|
|
1924
2272
|
```ts
|
|
1925
|
-
await tr
|
|
1926
|
-
// цель ищется путём (все
|
|
2273
|
+
await tr.Клиент(к).запись().update().Комплекс.set(комплекс).rows()
|
|
2274
|
+
// цель ищется путём (все записи клиента); слот пишет новый предмет — союз [Услуга|Товар|Комплекс]
|
|
2275
|
+
// замещается целиком, БЕЗ фильтра по старому концу
|
|
1927
2276
|
```
|
|
1928
2277
|
|
|
1929
2278
|
#### `.delete(opts?): Chain`
|
|
@@ -1944,24 +2293,24 @@ tombstone-версия → рекурсивное удаление зависи
|
|
|
1944
2293
|
**Примеры**
|
|
1945
2294
|
|
|
1946
2295
|
```ts
|
|
1947
|
-
await db
|
|
1948
|
-
// → [{ id: '…
|
|
1949
|
-
//
|
|
2296
|
+
await db.Клиент('…0931').delete().rows() // [14.7 ms] ПРЕВЬЮ — кандидаты живы:
|
|
2297
|
+
// → [{ id: '…0931', class: 'Customer' }, { class: 'booking' }, { class: 'booking' }] — клиент + его записи (каскад)
|
|
2298
|
+
// после превью клиент жив: true
|
|
1950
2299
|
|
|
1951
|
-
await db
|
|
2300
|
+
await db.Клиент('…0931').delete({ confirm: true }).rows() // [35.8 ms] — сервер нашёл зависимых:
|
|
1952
2301
|
// → те же три, каждый с $deleted: true
|
|
1953
|
-
await db
|
|
2302
|
+
await db.Клиент('…0931').delete({ confirm: true }).rows() // [6.8 ms] повторно → []
|
|
1954
2303
|
```
|
|
1955
2304
|
|
|
1956
|
-
**Кейс: отмена
|
|
2305
|
+
**Кейс: отмена и воскрешение**
|
|
1957
2306
|
|
|
1958
2307
|
```ts
|
|
1959
|
-
const последствия = await db
|
|
2308
|
+
const последствия = await db.Клиент(cid).delete().rows() // [14.7 ms] оператору: клиент + N его записей
|
|
1960
2309
|
if (операторПодтвердил) {
|
|
1961
|
-
await db
|
|
2310
|
+
await db.Клиент(cid).delete({ confirm: true }).rows() // [35.8 ms] клиент и записи — tombstone (каскад)
|
|
1962
2311
|
}
|
|
1963
|
-
// клиент
|
|
1964
|
-
await db
|
|
2312
|
+
// клиент вернулся: воскрешение тем же id — create по (Org, id)
|
|
2313
|
+
await db.Организация('…0901').Клиент().create({ id: cid, name: 'Злата', phone: '+7 …' }).rows() // [16.3 ms]
|
|
1965
2314
|
```
|
|
1966
2315
|
|
|
1967
2316
|
#### `.anonymize(fields): Chain`
|
|
@@ -1978,17 +2327,17 @@ await db.Клиент('…0931').Запись('…0961').create({ status: 'creat
|
|
|
1978
2327
|
**Примеры**
|
|
1979
2328
|
|
|
1980
2329
|
```ts
|
|
1981
|
-
await db.Клиент('…0931').anonymize(['name', '
|
|
1982
|
-
// → [{ data: { name: '[erased]',
|
|
2330
|
+
await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
|
|
2331
|
+
// → [{ data: { name: '[erased]', phone: '[erased]', preferred_contact: 'phone' },
|
|
1983
2332
|
// tags: ['anonymized'] }]
|
|
1984
2333
|
```
|
|
1985
2334
|
|
|
1986
2335
|
**Кейс: запрос на забвение**
|
|
1987
2336
|
|
|
1988
2337
|
```ts
|
|
1989
|
-
await db.Клиент('…0931').anonymize(['name', '
|
|
1990
|
-
;(await db.Клиент('…0931').versions()).map((r) => r.data.name)
|
|
1991
|
-
await db.Клиент().tags('anonymized').count()
|
|
2338
|
+
await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
|
|
2339
|
+
;(await db.Клиент('…0931').versions()).map((r) => r.data.name) // → ['Злата', 'Злата', 'Злата', '[erased]']
|
|
2340
|
+
await db.Клиент().tags('anonymized').count() // все стёртые — под контролем
|
|
1992
2341
|
```
|
|
1993
2342
|
|
|
1994
2343
|
### 11.6 Транзакции: EntityTx
|
|
@@ -2007,11 +2356,11 @@ await db.Клиент().tags('anonymized').count() // в
|
|
|
2007
2356
|
**Примеры**
|
|
2008
2357
|
|
|
2009
2358
|
```ts
|
|
2010
|
-
const tr = await db.begin() // [
|
|
2011
|
-
await tr
|
|
2012
|
-
await tr
|
|
2359
|
+
const tr = await db.begin() // [1.1 ms]
|
|
2360
|
+
await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows() // цУкл — базовая цена «Укладки»
|
|
2361
|
+
await tr.цена(цУкл).first() // внутри → amounts.RUB = 9900
|
|
2013
2362
|
await tr.rollback() // [0.8 ms]
|
|
2014
|
-
await db
|
|
2363
|
+
await db.цена(цУкл).first() // снаружи → amounts.RUB = 700, изменения нет
|
|
2015
2364
|
```
|
|
2016
2365
|
|
|
2017
2366
|
**Кейс: commit** — § 11.2 `db.begin()`; ниже — главный сценарий `lock`.
|
|
@@ -2032,30 +2381,32 @@ await db.Услуга({ name: 'Укладка' }).first() // снаружи
|
|
|
2032
2381
|
**Примеры**
|
|
2033
2382
|
|
|
2034
2383
|
```ts
|
|
2035
|
-
await trA.lock('
|
|
2384
|
+
await trA.lock('booking', staffId, start) // [3.1 ms]
|
|
2036
2385
|
await db.lock('x')
|
|
2037
|
-
// Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.
|
|
2386
|
+
// Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.2 ms]
|
|
2038
2387
|
```
|
|
2039
2388
|
|
|
2040
2389
|
**Кейс: гонка двойной брони — реальный прогон двух транзакций**
|
|
2041
2390
|
|
|
2042
2391
|
```ts
|
|
2043
|
-
// два администратора жмут «забронировать» на одно
|
|
2044
|
-
// id
|
|
2045
|
-
const
|
|
2392
|
+
// два администратора жмут «забронировать» на одно время одновременно;
|
|
2393
|
+
// id записи детерминирован (v5, § 3.2) — известен ДО создания:
|
|
2394
|
+
const bId = uuidv5(`v1.salondemo:entity:booking:${мастер.id}:${start}`)
|
|
2046
2395
|
const trA = await db.begin(), trB = await db.begin()
|
|
2047
|
-
await trA.lock('
|
|
2396
|
+
await trA.lock('booking', мастер.id, start) // [3.1 ms] A первый
|
|
2048
2397
|
const гонкаB = (async () => {
|
|
2049
|
-
await trB.lock('
|
|
2050
|
-
const занято = await trB
|
|
2051
|
-
if (занято) { await trB.rollback(); return 'ОТКАЗ:
|
|
2052
|
-
await trB
|
|
2398
|
+
await trB.lock('booking', мастер.id, start) // B ВИСИТ до конца trA
|
|
2399
|
+
const занято = await trB.запись(bId).first() // перечитка под локом
|
|
2400
|
+
if (занято) { await trB.rollback(); return 'ОТКАЗ: время уже занято' }
|
|
2401
|
+
await trB.Мастер(мастер).запись().create({ start_datetime: start, end_datetime: end })
|
|
2402
|
+
.Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows()
|
|
2053
2403
|
await trB.commit(); return 'бронь моя'
|
|
2054
2404
|
})()
|
|
2055
|
-
await trA
|
|
2056
|
-
await trA
|
|
2405
|
+
await trA.запись(bId).first() // → null — свободно
|
|
2406
|
+
await trA.Мастер(мастер).запись().create({ start_datetime: start, end_datetime: end })
|
|
2407
|
+
.Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows()
|
|
2057
2408
|
await trA.commit()
|
|
2058
|
-
await гонкаB // → 'ОТКАЗ:
|
|
2409
|
+
await гонкаB // → 'ОТКАЗ: время уже занято' — B увидел бронь A, дубля нет
|
|
2059
2410
|
// дубль невозможен и без лока (оба create вычислят ОДИН id — второй стал бы версией);
|
|
2060
2411
|
// лок нужен, чтобы B получил честный отказ, а не молча версионировал чужую бронь
|
|
2061
2412
|
|
|
@@ -2076,7 +2427,7 @@ await гонкаB // → 'ОТКАЗ: окно уже занято' — B ув
|
|
|
2076
2427
|
цепочке — ошибка `plan is queued in the batch`. Исполняет `run()`.
|
|
2077
2428
|
|
|
2078
2429
|
```ts
|
|
2079
|
-
const b = db.batch('
|
|
2430
|
+
const b = db.batch('смены-августа') // [28 µs]
|
|
2080
2431
|
```
|
|
2081
2432
|
|
|
2082
2433
|
#### `batch.run(): Promise<Row[][]>`
|
|
@@ -2093,14 +2444,14 @@ const b = db.batch('слоты-августа') // [16 µs]
|
|
|
2093
2444
|
**Примеры**
|
|
2094
2445
|
|
|
2095
2446
|
```ts
|
|
2096
|
-
const b = db.batch('
|
|
2097
|
-
b
|
|
2098
|
-
b
|
|
2099
|
-
b
|
|
2100
|
-
b.size() // [
|
|
2101
|
-
await b.run() // [
|
|
2102
|
-
// → [[{
|
|
2103
|
-
// id каждого окна вычислен схемой: uuidv5(
|
|
2447
|
+
const b = db.batch('смены-августа')
|
|
2448
|
+
b.Мастер(m).окно().create({ start_datetime: '2026-08-05T07:00:00Z', end_datetime: '…09:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
2449
|
+
b.Мастер(m).окно().create({ start_datetime: '2026-08-05T09:00:00Z', end_datetime: '…11:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
2450
|
+
b.Мастер(m).окно().create({ start_datetime: '2026-08-05T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
2451
|
+
b.size() // [35 µs] → 3
|
|
2452
|
+
await b.run() // [43.8 ms] — одна транзакция; окно — v5-класс → 3 честных INSERT, не склейка
|
|
2453
|
+
// → [[{ id: '06f67c88-…', start_datetime: '2026-08-05T07:00:00.000Z' }], [{ …09:00 }], [{ …11:00 }]]
|
|
2454
|
+
// id каждого окна вычислен схемой: uuidv5(Staff, start_datetime) — § 3.2
|
|
2104
2455
|
b.size() // → 0
|
|
2105
2456
|
```
|
|
2106
2457
|
|
|
@@ -2115,10 +2466,10 @@ b.size() // → 0
|
|
|
2115
2466
|
|
|
2116
2467
|
```ts
|
|
2117
2468
|
const b2 = db.batch('отмена')
|
|
2118
|
-
b2
|
|
2119
|
-
b2.discard() // [
|
|
2469
|
+
b2.Мастер(m).окно().create({ start_datetime: '2026-08-06T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
2470
|
+
b2.discard() // [87 µs]
|
|
2120
2471
|
b2.size() // → 0
|
|
2121
|
-
await db
|
|
2472
|
+
await db.Мастер(m).окно({ start_datetime: between('2026-08-06T11:00:00Z', '2026-08-06T11:00:00Z') }).first() // → null — не исполнилось
|
|
2122
2473
|
```
|
|
2123
2474
|
|
|
2124
2475
|
**Кейс: черновик импорта** — копим операции по мере парсинга файла; ошибка парсера →
|
|
@@ -2141,7 +2492,7 @@ await db.Расписание(sch).Окно({ start: '2026-08-02T11:00:00Z' }).f
|
|
|
2141
2492
|
`category` → `= ANY(categories)`.
|
|
2142
2493
|
|
|
2143
2494
|
```ts
|
|
2144
|
-
await db.accounts.find({ category: 'Client', enabled: true }) // [2.
|
|
2495
|
+
await db.accounts.find({ category: 'Client', enabled: true }) // [2.4 ms] → 1 аккаунт
|
|
2145
2496
|
```
|
|
2146
2497
|
|
|
2147
2498
|
**Кейс:** список арендаторов для биллинга: `find({ enabled: true })`, отключённые не в счёте.
|
|
@@ -2151,7 +2502,7 @@ await db.accounts.find({ category: 'Client', enabled: true }) // [2.2 ms] →
|
|
|
2151
2502
|
`id: string` — точечный SELECT по PK.
|
|
2152
2503
|
|
|
2153
2504
|
```ts
|
|
2154
|
-
await db.accounts.get(acc.id) // [1.
|
|
2505
|
+
await db.accounts.get(acc.id) // [1.9 ms] → Account | null
|
|
2155
2506
|
```
|
|
2156
2507
|
|
|
2157
2508
|
**Кейс:** профиль владельца строки Entity: `db.accounts.get(row.owner)`.
|
|
@@ -2171,9 +2522,9 @@ await db.accounts.get(acc.id) // [1.7 ms] → Account | null
|
|
|
2171
2522
|
|
|
2172
2523
|
```ts
|
|
2173
2524
|
const acc = await db.accounts.set({ categories: ['Client'], data: { название: 'ИП Ромашка' } })
|
|
2174
|
-
// [
|
|
2175
|
-
// meta: {}, avatar: 'https://i.pravatar.cc/128?img=
|
|
2176
|
-
await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [
|
|
2525
|
+
// [5.4 ms] → { id: '06d3bbfe-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
|
|
2526
|
+
// meta: {}, avatar: 'https://i.pravatar.cc/128?img=33', enabled: true, created: …, updated: … }
|
|
2527
|
+
await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [5.7 ms] update
|
|
2177
2528
|
```
|
|
2178
2529
|
|
|
2179
2530
|
**Кейс:** бан аккаунта одним полем: `set({ id, enabled: false })` — все `verify*` § 11.9
|
|
@@ -2185,8 +2536,8 @@ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) //
|
|
|
2185
2536
|
в Entity (`account`/`owner` FK RESTRICT) не удалить — намеренно: история неприкосновенна.
|
|
2186
2537
|
|
|
2187
2538
|
```ts
|
|
2188
|
-
await db.accounts.delete(времId) // [
|
|
2189
|
-
await db.accounts.delete(SYS) // [
|
|
2539
|
+
await db.accounts.delete(времId) // [16.4 ms] → true (пустой аккаунт)
|
|
2540
|
+
await db.accounts.delete(SYS) // [6.0 ms]
|
|
2190
2541
|
// Error: update or delete on table "Account" violates foreign key constraint "entity_account_fk"
|
|
2191
2542
|
```
|
|
2192
2543
|
|
|
@@ -2202,7 +2553,7 @@ await db.accounts.delete(SYS) // [4.9 ms]
|
|
|
2202
2553
|
| `f.withDeleted` | `boolean?` | включить мягко-удалённые (default — только живые `deleted IS NULL`) |
|
|
2203
2554
|
|
|
2204
2555
|
```ts
|
|
2205
|
-
await db.credentials.find({ account: acc.id }) // [2
|
|
2556
|
+
await db.credentials.find({ account: acc.id }) // [3.2 ms] → 1 живой
|
|
2206
2557
|
await db.credentials.find({ account: acc.id, withDeleted: true }) // → 1 (после delete: 0 и 1)
|
|
2207
2558
|
```
|
|
2208
2559
|
|
|
@@ -2224,9 +2575,9 @@ telegram списком.
|
|
|
2224
2575
|
|
|
2225
2576
|
```ts
|
|
2226
2577
|
const кред = await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
|
|
2227
|
-
// [3
|
|
2578
|
+
// [5.3 ms] → { id: '5f49dbfa-…', confirmed: true, deleted: null, … }
|
|
2228
2579
|
await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
|
|
2229
|
-
// [
|
|
2580
|
+
// [7.7 ms] после delete → тот же id, deleted = null — воскрешение
|
|
2230
2581
|
```
|
|
2231
2582
|
|
|
2232
2583
|
**Кейс:** смена номера телефона: `delete(старый)` + `set(новый)`; передумали — повторный
|
|
@@ -2238,7 +2589,7 @@ await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 9
|
|
|
2238
2589
|
освобождается для других аккаунтов (§ 11.9).
|
|
2239
2590
|
|
|
2240
2591
|
```ts
|
|
2241
|
-
await db.credentials.delete(кред.id) // [
|
|
2592
|
+
await db.credentials.delete(кред.id) // [4.6 ms] → true; find() больше не видит
|
|
2242
2593
|
```
|
|
2243
2594
|
|
|
2244
2595
|
**Кейс:** отзыв api-ключа: `delete(credential.id)` → `verifyApiKey` мгновенно null
|
|
@@ -2259,10 +2610,10 @@ await db.credentials.delete(кред.id) // [3.7 ms] → true; find() боль
|
|
|
2259
2610
|
|
|
2260
2611
|
```ts
|
|
2261
2612
|
await db.resources.set({ alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' } })
|
|
2262
|
-
// [
|
|
2263
|
-
await db.resources.get('apiref.demo:API') // [
|
|
2264
|
-
await db.resources.find({ category: 'API' }) // [
|
|
2265
|
-
await db.resources.delete('apiref.demo:API') // [
|
|
2613
|
+
// [4.8 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
|
|
2614
|
+
await db.resources.get('apiref.demo:API') // [2.6 ms] → тот же Resource
|
|
2615
|
+
await db.resources.find({ category: 'API' }) // [1.1 ms] → 14 ресурсов
|
|
2616
|
+
await db.resources.delete('apiref.demo:API') // [5.2 ms] → true
|
|
2266
2617
|
```
|
|
2267
2618
|
|
|
2268
2619
|
**Кейс:** полный словарь для нового тарифа — § 11.10 (6 ресурсов + 5 правил одним блоком).
|
|
@@ -2281,9 +2632,9 @@ await db.resources.delete('apiref.demo:API') // [3.0 ms] → true
|
|
|
2281
2632
|
|
|
2282
2633
|
```ts
|
|
2283
2634
|
await db.rules.set({ account: 'apiref.demo:API', resource: 'apiref.demo:API', permission: 'allow', weight: 90 })
|
|
2284
|
-
// [3
|
|
2285
|
-
await db.rules.find({ resource: 'apiref.demo:API' }) // [2.
|
|
2286
|
-
await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [
|
|
2635
|
+
// [5.3 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
|
|
2636
|
+
await db.rules.find({ resource: 'apiref.demo:API' }) // [2.7 ms] → 1
|
|
2637
|
+
await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [4.3 ms] → true
|
|
2287
2638
|
```
|
|
2288
2639
|
|
|
2289
2640
|
**Кейс:** временный бан группы: `set({ account: группа, resource: цель, permission: 'deny',
|
|
@@ -2315,8 +2666,8 @@ uuid-строку или объект с `.id`.
|
|
|
2315
2666
|
|
|
2316
2667
|
```ts
|
|
2317
2668
|
await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2318
|
-
// [
|
|
2319
|
-
// meta.password = "scrypt$32768$8$1
|
|
2669
|
+
// [87.5 ms] → Credential; в БД вместо пароля:
|
|
2670
|
+
// meta.password = "scrypt$32768$8$1$+yEMcUTJIO7xEu/oDUAMXA==$b6…"
|
|
2320
2671
|
```
|
|
2321
2672
|
|
|
2322
2673
|
**Кейс** — регистрация + вход + сессия: см. `sessions()` ниже (полный флоу).
|
|
@@ -2338,11 +2689,11 @@ await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', passw
|
|
|
2338
2689
|
**Примеры**
|
|
2339
2690
|
|
|
2340
2691
|
```ts
|
|
2341
|
-
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [
|
|
2342
|
-
// → { account: { id: '
|
|
2692
|
+
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [78.8 ms]
|
|
2693
|
+
// → { account: { id: '06d3bbfe-…', categories: ['Client'], enabled: true, … },
|
|
2343
2694
|
// credential: { category: 'PASSWORD', identifier: 'romashka@salon.io', … } }
|
|
2344
|
-
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [
|
|
2345
|
-
await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [
|
|
2695
|
+
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [71.1 ms] → null
|
|
2696
|
+
await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [64.4 ms] → null
|
|
2346
2697
|
// незнакомый identifier — то же время (dummy-verify)
|
|
2347
2698
|
```
|
|
2348
2699
|
|
|
@@ -2352,11 +2703,11 @@ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' })
|
|
|
2352
2703
|
await db.auth.setPassword({ account: acc, identifier: 'noconfirm@salon.io', password: 'пароль-77',
|
|
2353
2704
|
category: 'EMAIL', confirmed: false })
|
|
2354
2705
|
await db.auth.verifyPassword({ identifier: 'noconfirm@salon.io', password: 'пароль-77', category: 'EMAIL' })
|
|
2355
|
-
// [
|
|
2356
|
-
await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [
|
|
2706
|
+
// [71.1 ms] → null — кред не подтверждён
|
|
2707
|
+
await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [72.2 ms] → { account, credential }
|
|
2357
2708
|
await db.accounts.set({ id: acc.id, enabled: false })
|
|
2358
2709
|
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2359
|
-
// [
|
|
2710
|
+
// [59.5 ms] → null — аккаунт выключен, пароль уже не важен
|
|
2360
2711
|
```
|
|
2361
2712
|
|
|
2362
2713
|
#### `db.auth.issueApiKey(a): Promise<{ key, credential }>`
|
|
@@ -2371,9 +2722,9 @@ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'ле
|
|
|
2371
2722
|
Сам ключ возвращается **один раз**; утечка БД ключи не раскрывает.
|
|
2372
2723
|
|
|
2373
2724
|
```ts
|
|
2374
|
-
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [
|
|
2375
|
-
// key = '
|
|
2376
|
-
// в БД: identifier = '
|
|
2725
|
+
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
|
|
2726
|
+
// key = 'lts_ca41bf2a3614c3f228be2551a2ba998db5285f1a2bc3b859' ← показать и забыть
|
|
2727
|
+
// в БД: identifier = '624b00355aba026f…' (sha256), meta = { name: 'касса-1', prefix: 'lts_ca41bf2a' }
|
|
2377
2728
|
```
|
|
2378
2729
|
|
|
2379
2730
|
**Кейс** — см. `verifyApiKey` (выпуск → проверка → отзыв).
|
|
@@ -2389,16 +2740,16 @@ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'к
|
|
|
2389
2740
|
Быстрый (без scrypt): ключ высокоэнтропийный, подбор бессмыслен.
|
|
2390
2741
|
|
|
2391
2742
|
```ts
|
|
2392
|
-
await db.auth.verifyApiKey(key) // [
|
|
2743
|
+
await db.auth.verifyApiKey(key) // [4.5 ms] → { account: 06d3bbfe…, credential }
|
|
2393
2744
|
```
|
|
2394
2745
|
|
|
2395
2746
|
**Кейс: полный жизненный цикл ключа**
|
|
2396
2747
|
|
|
2397
2748
|
```ts
|
|
2398
|
-
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [
|
|
2399
|
-
await db.auth.verifyApiKey(key) // [
|
|
2749
|
+
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
|
|
2750
|
+
await db.auth.verifyApiKey(key) // [4.5 ms] → { account, credential } — касса работает
|
|
2400
2751
|
await db.credentials.delete(credential.id) // отзыв (мягкий)
|
|
2401
|
-
await db.auth.verifyApiKey(key) // [1
|
|
2752
|
+
await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновенно недействителен
|
|
2402
2753
|
```
|
|
2403
2754
|
|
|
2404
2755
|
#### `db.auth.issueKeySecret(a): Promise<{ key, secret, credential }>`
|
|
@@ -2410,8 +2761,8 @@ await db.auth.verifyApiKey(key) // [1.7 ms] → null — мгновен
|
|
|
2410
2761
|
возвращается один раз.
|
|
2411
2762
|
|
|
2412
2763
|
```ts
|
|
2413
|
-
const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [
|
|
2414
|
-
// key = '
|
|
2764
|
+
const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [4.8 ms]
|
|
2765
|
+
// key = 'f8ab4007e4570809'; secret = 'c25b8b906f69…' (48 hex, показан один раз)
|
|
2415
2766
|
```
|
|
2416
2767
|
|
|
2417
2768
|
#### `db.auth.verifyKeySecret(key, secret, opts?): Promise<AuthResult | null>`
|
|
@@ -2425,8 +2776,8 @@ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'ин
|
|
|
2425
2776
|
**Алгоритм.** Кред по identifier = key, `timingSafeEqual(sha256(secret), meta.secret)`, ворота.
|
|
2426
2777
|
|
|
2427
2778
|
```ts
|
|
2428
|
-
await db.auth.verifyKeySecret(key, secret) // [
|
|
2429
|
-
await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [
|
|
2779
|
+
await db.auth.verifyKeySecret(key, secret) // [4.6 ms] → { account, credential }
|
|
2780
|
+
await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [2.2 ms] → null
|
|
2430
2781
|
```
|
|
2431
2782
|
|
|
2432
2783
|
**Кейс:** серверная интеграция (1С, платёжка): key хранится в конфиге открыто и светится
|
|
@@ -2448,8 +2799,8 @@ base32(20 случайных байт), `uri` — готовая строка `o
|
|
|
2448
2799
|
|
|
2449
2800
|
```ts
|
|
2450
2801
|
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: 'romashka@salon.io' })
|
|
2451
|
-
// [
|
|
2452
|
-
// uri = 'otpauth://totp/romashka%40salon.io?secret=
|
|
2802
|
+
// [4.8 ms] secret = 'G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS'
|
|
2803
|
+
// uri = 'otpauth://totp/romashka%40salon.io?secret=G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS&issuer=clockz&algorithm=SHA1&digits=6&period=30'
|
|
2453
2804
|
```
|
|
2454
2805
|
|
|
2455
2806
|
#### `db.auth.verifyTotp(a): Promise<boolean>`
|
|
@@ -2467,9 +2818,9 @@ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz
|
|
|
2467
2818
|
**Примеры**
|
|
2468
2819
|
|
|
2469
2820
|
```ts
|
|
2470
|
-
const код = totpCode(secret) // [
|
|
2471
|
-
await db.auth.verifyTotp({ account: acc, code: код }) // [
|
|
2472
|
-
await db.auth.verifyTotp({ account: acc, code: код }) // [
|
|
2821
|
+
const код = totpCode(secret) // [659 µs] → '564517' (как в приложении)
|
|
2822
|
+
await db.auth.verifyTotp({ account: acc, code: код }) // [8.1 ms] → true — фактор активирован
|
|
2823
|
+
await db.auth.verifyTotp({ account: acc, code: код }) // [2.6 ms] → false — replay отбит
|
|
2473
2824
|
const прошлый = totpCode(secret, Date.now() - 30_000) // код прошлого шага (окно ±1)
|
|
2474
2825
|
await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false — шаг ≤ lastStep
|
|
2475
2826
|
```
|
|
@@ -2477,10 +2828,10 @@ await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false
|
|
|
2477
2828
|
**Кейс: включение 2FA в кабинете**
|
|
2478
2829
|
|
|
2479
2830
|
```ts
|
|
2480
|
-
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [
|
|
2831
|
+
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [4.8 ms]
|
|
2481
2832
|
await db.auth.totpEnabled(acc) // → false — QR показан, ждём подтверждения
|
|
2482
|
-
await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [
|
|
2483
|
-
await db.auth.totpEnabled(acc) // [
|
|
2833
|
+
await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [8.1 ms] → true
|
|
2834
|
+
await db.auth.totpEnabled(acc) // [2.2 ms] → true — теперь требуем код при входе
|
|
2484
2835
|
```
|
|
2485
2836
|
|
|
2486
2837
|
#### `db.auth.totpEnabled(account): Promise<boolean>`
|
|
@@ -2489,7 +2840,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true — те
|
|
|
2489
2840
|
проверкой (`confirmed`). Приложение по нему решает, спрашивать ли второй фактор.
|
|
2490
2841
|
|
|
2491
2842
|
```ts
|
|
2492
|
-
await db.auth.totpEnabled(acc) // [
|
|
2843
|
+
await db.auth.totpEnabled(acc) // [2.2 ms] → true
|
|
2493
2844
|
```
|
|
2494
2845
|
|
|
2495
2846
|
#### `totpCode(secretBase32, atMs = Date.now()): string` — экспорт модуля
|
|
@@ -2504,7 +2855,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true
|
|
|
2504
2855
|
Для тестов и серверной генерации кодов.
|
|
2505
2856
|
|
|
2506
2857
|
```ts
|
|
2507
|
-
totpCode('
|
|
2858
|
+
totpCode('G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS') // [659 µs] → '564517'
|
|
2508
2859
|
```
|
|
2509
2860
|
|
|
2510
2861
|
**Кейс** — автотест 2FA без телефона: сгенерировать код из секрета и скормить `verifyTotp`
|
|
@@ -2526,7 +2877,7 @@ totpCode('FAZKFC57B3FPIOF2735ZYW47CZA5O6MW') // [335 µs] → '370916'
|
|
|
2526
2877
|
|
|
2527
2878
|
```ts
|
|
2528
2879
|
const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@salon.io', ttlSec: 600 })
|
|
2529
|
-
// [
|
|
2880
|
+
// [6.1 ms] code = '255243'; в БД: meta = { code: '7566c91d8a5e…' (sha256), expires: '2026-07-13T07:24:44.435Z', attempts: 0 }
|
|
2530
2881
|
```
|
|
2531
2882
|
|
|
2532
2883
|
#### `db.auth.verifyOtp(a): Promise<AuthResult | null>`
|
|
@@ -2545,19 +2896,19 @@ const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@sa
|
|
|
2545
2896
|
**Примеры**
|
|
2546
2897
|
|
|
2547
2898
|
```ts
|
|
2548
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [4
|
|
2549
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [
|
|
2550
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [
|
|
2899
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [8.4 ms] → null (+1 попытка)
|
|
2900
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11.0 ms] → { account, credential }
|
|
2901
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [2.4 ms] → null — сожжён
|
|
2551
2902
|
```
|
|
2552
2903
|
|
|
2553
2904
|
**Кейс: сброс пароля**
|
|
2554
2905
|
|
|
2555
2906
|
```ts
|
|
2556
|
-
const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [
|
|
2907
|
+
const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [6.1 ms]
|
|
2557
2908
|
отправитьПисьмо(почта, code) // доставка — на приложении
|
|
2558
|
-
const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [
|
|
2909
|
+
const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11.0 ms]
|
|
2559
2910
|
if (кто) await db.auth.setPassword({ account: кто.account, identifier: почта, password: новый })
|
|
2560
|
-
// протухший код (ttl 1 s в прогоне): verifyOtp → null [
|
|
2911
|
+
// протухший код (ttl 1 s в прогоне): verifyOtp → null [9.3 ms]
|
|
2561
2912
|
```
|
|
2562
2913
|
|
|
2563
2914
|
#### `db.auth.link(a): Promise<Credential>`
|
|
@@ -2578,7 +2929,7 @@ if (кто) await db.auth.setPassword({ account: кто.account, identifier: п
|
|
|
2578
2929
|
|
|
2579
2930
|
```ts
|
|
2580
2931
|
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' } })
|
|
2581
|
-
// [
|
|
2932
|
+
// [4.9 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
|
|
2582
2933
|
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915' }) // id занят ДРУГИМ аккаунтом:
|
|
2583
2934
|
// Error: duplicate key value violates unique constraint "credential_identity_udx" [3.9 ms]
|
|
2584
2935
|
```
|
|
@@ -2599,14 +2950,14 @@ await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915
|
|
|
2599
2950
|
|
|
2600
2951
|
```ts
|
|
2601
2952
|
await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' })
|
|
2602
|
-
// [
|
|
2953
|
+
// [5.2 ms] → { account: 06d3bbfe…, credential }
|
|
2603
2954
|
```
|
|
2604
2955
|
|
|
2605
2956
|
**Кейс: вход через telegram-бота**
|
|
2606
2957
|
|
|
2607
2958
|
```ts
|
|
2608
2959
|
// платформа подтвердила пользователя 777000111 (initData бота проверило приложение)
|
|
2609
|
-
const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [
|
|
2960
|
+
const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [5.2 ms]
|
|
2610
2961
|
if (!кто) { /* первая встреча: создать аккаунт + db.auth.link(…) */ }
|
|
2611
2962
|
const token = await sess.start(кто.account) // дальше обычная сессия
|
|
2612
2963
|
```
|
|
@@ -2622,7 +2973,7 @@ const token = await sess.start(кто.account) // дальше обычная
|
|
|
2622
2973
|
|
|
2623
2974
|
```ts
|
|
2624
2975
|
import Redis from 'ioredis'
|
|
2625
|
-
const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [
|
|
2976
|
+
const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [148 µs]
|
|
2626
2977
|
```
|
|
2627
2978
|
|
|
2628
2979
|
#### `sessions.start(account, opts?): Promise<string>`
|
|
@@ -2638,9 +2989,9 @@ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [147 µ
|
|
|
2638
2989
|
`sess:acc:<accountId>` для `revokeAll`. Дамп Redis действующих токенов не раскрывает.
|
|
2639
2990
|
|
|
2640
2991
|
```ts
|
|
2641
|
-
const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [
|
|
2642
|
-
// token = '
|
|
2643
|
-
// в Redis: 'sess:
|
|
2992
|
+
const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [8.0 ms]
|
|
2993
|
+
// token = 'fe0a7e83db6fcd1aa2c5002988da2b353602837db3bc0a813eddac863b9a1944'
|
|
2994
|
+
// в Redis: 'sess:8198bf2cd8d2ef2376d…' и 'sess:acc:06d3bbfe-d9a2-4…'
|
|
2644
2995
|
```
|
|
2645
2996
|
|
|
2646
2997
|
#### `sessions.check(token): Promise<Session | null>`
|
|
@@ -2649,9 +3000,9 @@ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }
|
|
|
2649
3000
|
(нет / истекла / отозвана). Суб-миллисекундный — на каждый HTTP-запрос.
|
|
2650
3001
|
|
|
2651
3002
|
```ts
|
|
2652
|
-
await sess.check(token) // [0.
|
|
2653
|
-
// → { account: '
|
|
2654
|
-
await sess.check(протухший) // [
|
|
3003
|
+
await sess.check(token) // [0.7 ms]
|
|
3004
|
+
// → { account: '06d3bbfe-…', meta: { device: 'iphone' }, created: '2026-07-13T07:14:45.810Z' }
|
|
3005
|
+
await sess.check(протухший) // [0.8 ms] → null (ttl 1 s истёк — Redis сам удалил)
|
|
2655
3006
|
```
|
|
2656
3007
|
|
|
2657
3008
|
#### `sessions.revoke(token): Promise<boolean>`
|
|
@@ -2659,8 +3010,8 @@ await sess.check(протухший) // [1.2 ms] → null (ttl 1 s истёк
|
|
|
2659
3010
|
`token: string` — `DEL` ключа + `SREM` из индекса; `false`, если сессии уже нет.
|
|
2660
3011
|
|
|
2661
3012
|
```ts
|
|
2662
|
-
await sess.revoke(token) // [
|
|
2663
|
-
await sess.revoke(token) // [0.
|
|
3013
|
+
await sess.revoke(token) // [1.9 ms] → true
|
|
3014
|
+
await sess.revoke(token) // [0.7 ms] → false — повторно
|
|
2664
3015
|
```
|
|
2665
3016
|
|
|
2666
3017
|
#### `sessions.revokeAll(account): Promise<number>`
|
|
@@ -2669,19 +3020,19 @@ await sess.revoke(token) // [0.5 ms] → false — повторно
|
|
|
2669
3020
|
сколько погашено.
|
|
2670
3021
|
|
|
2671
3022
|
```ts
|
|
2672
|
-
await sess.revokeAll(acc) // [1.
|
|
3023
|
+
await sess.revokeAll(acc) // [1.2 ms] → 2 — обе сессии (ipad + macbook) погасли
|
|
2673
3024
|
```
|
|
2674
3025
|
|
|
2675
3026
|
**Кейс: полный вход — пароль → сессия → запрос → выход (реальный прогон)**
|
|
2676
3027
|
|
|
2677
3028
|
```ts
|
|
2678
3029
|
const визит = await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2679
|
-
// [
|
|
2680
|
-
const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.
|
|
3030
|
+
// [71.3 ms] → { account, credential }
|
|
3031
|
+
const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.2 ms]
|
|
2681
3032
|
// … каждый запрос в middleware:
|
|
2682
|
-
const кто = await sess.check(token) // [0.
|
|
3033
|
+
const кто = await sess.check(token) // [0.7 ms] → { account: '06d3bbfe…', meta: { ip: '10.0.0.7' }, … }
|
|
2683
3034
|
// logout:
|
|
2684
|
-
await sess.revoke(token) // [
|
|
3035
|
+
await sess.revoke(token) // [1.3 ms] → true
|
|
2685
3036
|
// «выйти со всех устройств» после смены пароля: await sess.revokeAll(визит.account)
|
|
2686
3037
|
```
|
|
2687
3038
|
|
|
@@ -2690,7 +3041,7 @@ await sess.revoke(token) // [2.2 ms] → true
|
|
|
2690
3041
|
Решения по словарю Resource/Rule (§ 9.2). Прогоны ниже — словарь из § 11.8-кейса: группа
|
|
2691
3042
|
`apiref.client:ACCOUNT {categories: '{Client}'}`, эндпоинты `apiref.api.booking:API
|
|
2692
3043
|
{endpoint: 'v2.booking.*'}`, данные `apiref.booking.own:READ/WRITE/DELETE
|
|
2693
|
-
{class: '
|
|
3044
|
+
{class: 'booking', owner: '$account'}` и `apiref.service:READ {class: 'Service'}`,
|
|
2694
3045
|
5 правил `allow weight 60` от группы Client.
|
|
2695
3046
|
|
|
2696
3047
|
#### `db.acl.check(account, endpoint): Promise<AclDecision>`
|
|
@@ -2710,10 +3061,10 @@ NULL — все); объекты — `API`-ресурсы, чья маска п
|
|
|
2710
3061
|
**Примеры**
|
|
2711
3062
|
|
|
2712
3063
|
```ts
|
|
2713
|
-
await db.acl.check(acc, 'v2.booking.create') // [
|
|
3064
|
+
await db.acl.check(acc, 'v2.booking.create') // [5.0 ms]
|
|
2714
3065
|
// → { allow: true, rule: { account: 'apiref.client:ACCOUNT', resource: 'apiref.api.booking:API',
|
|
2715
3066
|
// permission: 'allow', weight: 60, enabled: true } }
|
|
2716
|
-
await db.acl.check(acc, 'v2.admin.stats') // [1.
|
|
3067
|
+
await db.acl.check(acc, 'v2.admin.stats') // [1.8 ms] — покрыло только дно-правило сида:
|
|
2717
3068
|
// → { allow: false, rule: { account: 'any:ACCOUNT', resource: 'any:API', permission: 'deny', weight: 0, … },
|
|
2718
3069
|
// code: 403, message: 'Access denied - default for any ACCOUNT to any API' }
|
|
2719
3070
|
```
|
|
@@ -2738,7 +3089,7 @@ app.use(async (req, res, next) => {
|
|
|
2738
3089
|
|
|
2739
3090
|
**Назначение и алгоритм.** Решение по данным: объекты — ресурсы категории `op`, чья
|
|
2740
3091
|
`pattern.class`-маска совпала с именем класса **или любого предка** (lineage: право на
|
|
2741
|
-
`
|
|
3092
|
+
`booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
|
|
2742
3093
|
ключи pattern (реальные колонки Entity, `"$account"` → id субъекта) возвращаются как
|
|
2743
3094
|
`filter` — готовый предикат строк. Справочный метод (SQL за категориями аккаунта на каждый
|
|
2744
3095
|
вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный на connect
|
|
@@ -2747,35 +3098,37 @@ app.use(async (req, res, next) => {
|
|
|
2747
3098
|
**Примеры**
|
|
2748
3099
|
|
|
2749
3100
|
```ts
|
|
2750
|
-
await db.acl.checkData(acc, '
|
|
3101
|
+
await db.acl.checkData(acc, 'booking', 'READ') // [2.7 ms]
|
|
2751
3102
|
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', weight: 60, … },
|
|
2752
|
-
// filter: { owner: '
|
|
2753
|
-
await db.acl.checkData(acc, 'Service', 'READ') // [1.
|
|
3103
|
+
// filter: { owner: '06d3bbfe-d9a2-4c1d-be44-04c40cb01108' } } ← $account подставлен
|
|
3104
|
+
await db.acl.checkData(acc, 'Service', 'READ') // [1.9 ms]
|
|
2754
3105
|
// → { allow: true, rule: { resource: 'apiref.service:READ', … } } ← безусловный (без filter)
|
|
2755
|
-
await db.acl.checkData(acc, 'Org', 'READ') // [
|
|
3106
|
+
await db.acl.checkData(acc, 'Org', 'READ') // [2.0 ms]
|
|
2756
3107
|
// → { allow: false, message: 'no matching rule (deny by default)' }
|
|
2757
|
-
await db.acl.checkData(acc, 'VipBooking', 'READ') // [
|
|
2758
|
-
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: '
|
|
2759
|
-
// класса нет в словаре — право дал предок
|
|
3108
|
+
await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий connect]
|
|
3109
|
+
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: '06d3bbfe-…' } }
|
|
3110
|
+
// класса нет в словаре — право дал предок booking (lineage)
|
|
2760
3111
|
```
|
|
2761
3112
|
|
|
2762
3113
|
**Кейс: enforceAcl — те же решения в SQL цепочек (реальный прогон)**
|
|
2763
3114
|
|
|
2764
3115
|
```ts
|
|
2765
|
-
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [
|
|
2766
|
-
await uc
|
|
2767
|
-
await uc
|
|
2768
|
-
await uc.Услуга().count() // [15.
|
|
3116
|
+
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [66.9 ms] правила фиксируются
|
|
3117
|
+
await uc.запись().rows() // [17.2 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
|
|
3118
|
+
await uc.запись().count() // [17.6 ms] → 3 — честный count по суженному множеству
|
|
3119
|
+
await uc.Услуга().count() // [15.1 ms] → 602 — безусловный allow, класс целиком
|
|
2769
3120
|
await uc.Организация().rows()
|
|
2770
3121
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
|
|
2771
|
-
const [z] = await uc
|
|
3122
|
+
const [z] = await uc.запись().create({ start_datetime: t, end_datetime: e })
|
|
3123
|
+
.Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [20.9 ms]
|
|
2772
3124
|
z.owner === acc.id // → true — owner пришпилен правилом
|
|
2773
|
-
await uc
|
|
2774
|
-
// Error: letopis: acl pins
|
|
2775
|
-
await uc
|
|
2776
|
-
|
|
3125
|
+
await uc.запись().owner(SYS).create({ … }).rows()
|
|
3126
|
+
// Error: letopis: acl pins booking writes to owner 06d3bbfe-… — на терминале [2.6 ms]
|
|
3127
|
+
await uc.запись('чужой-id').create({ … }).rows() // [2.0 ms] — перехват чужого id мёртв: v5-класс id не принимает
|
|
3128
|
+
// Error: letopis: class "booking" computes id (uuid v5 from Staff, start_datetime) — remove the explicit id
|
|
3129
|
+
await uc.запись(свойId).delete({ confirm: true }).rows() // [30.3 ms] → [{ id: 'ee65244b-…', $deleted: true }]
|
|
2777
3130
|
// watch: события только безусловных allow-классов —
|
|
2778
|
-
// uc.watch(cb) поймал ['Service'];
|
|
3131
|
+
// uc.watch(cb) поймал ['Service']; booking скрыт (предикат не проверить по payload)
|
|
2779
3132
|
```
|
|
2780
3133
|
|
|
2781
3134
|
#### `db.acl.reload(): void`
|
|
@@ -2786,7 +3139,7 @@ connect; подхватить новые правила = новый `connect()`
|
|
|
2786
3139
|
connect — новый класс в lineage-проверках увидит только новое подключение.
|
|
2787
3140
|
|
|
2788
3141
|
```ts
|
|
2789
|
-
db.acl.reload() // [
|
|
3142
|
+
db.acl.reload() // [187 µs]
|
|
2790
3143
|
```
|
|
2791
3144
|
|
|
2792
3145
|
**Кейс:** админка сохранила правило → `reload()` в том же процессе, чтобы `check` следующего
|
|
@@ -2806,9 +3159,9 @@ db.acl.reload() // [120 µs]
|
|
|
2806
3159
|
компилированный валидатор.
|
|
2807
3160
|
|
|
2808
3161
|
```ts
|
|
2809
|
-
db.registry.resolve('
|
|
2810
|
-
// → { id: '
|
|
2811
|
-
// links: ['Customer'],
|
|
3162
|
+
db.registry.resolve('запись') // [103 µs]
|
|
3163
|
+
// → { id: 'booking', alias: 'запись', category: 'LINK', ancestors: ['booking', 'slot', 'link'],
|
|
3164
|
+
// links: [{ classes: ['Staff'], … }, …, { classes: ['Customer'], optional: true, … }], abstract: false }
|
|
2812
3165
|
db.registry.resolve('Дракон')
|
|
2813
3166
|
// Error: letopis: unknown class "Дракон". Known: Entity·Сущность, Org·Организация, …
|
|
2814
3167
|
```
|
|
@@ -2822,10 +3175,10 @@ db.registry.resolve('Дракон')
|
|
|
2822
3175
|
`all` — все классы партиции.
|
|
2823
3176
|
|
|
2824
3177
|
```ts
|
|
2825
|
-
db.registry.has('
|
|
3178
|
+
db.registry.has('booking') // → true
|
|
2826
3179
|
db.registry.has('Дракон') // → false
|
|
2827
3180
|
db.registry.find('Дракон') // → undefined
|
|
2828
|
-
db.registry.all.length // →
|
|
3181
|
+
db.registry.all.length // → 20 (+1: LINK-класс price·«цена»)
|
|
2829
3182
|
```
|
|
2830
3183
|
|
|
2831
3184
|
**Кейс:** роутер `GET /:класс` — `has()` до цепочки, чтобы отвечать 404, а не 500.
|
|
@@ -2843,12 +3196,12 @@ db.registry.all.length // → 15
|
|
|
2843
3196
|
конец LINK) — обычный `Error` там же.
|
|
2844
3197
|
|
|
2845
3198
|
```ts
|
|
2846
|
-
try { await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [
|
|
3199
|
+
try { await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [9.9 ms]
|
|
2847
3200
|
catch (e) {
|
|
2848
3201
|
e instanceof ValidationError // → true
|
|
2849
3202
|
e.issues
|
|
2850
|
-
// → [{ type: '
|
|
2851
|
-
//
|
|
3203
|
+
// → [{ type: 'objectStrict', message: "The object '' contains forbidden keys: 'чепуха'.",
|
|
3204
|
+
// expected: 'id, name, description, duration', actual: 'чепуха' }]
|
|
2852
3205
|
}
|
|
2853
3206
|
```
|
|
2854
3207
|
|
|
@@ -2921,76 +3274,103 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
|
|
|
2921
3274
|
|
|
2922
3275
|
## 13. Что контролирует приложение
|
|
2923
3276
|
|
|
2924
|
-
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
-
|
|
3277
|
+
- Пересечения интервалов записей внутри окна-смены мастера (наложение броней; EXCLUDE на
|
|
3278
|
+
hypertable невозможен). Двойная бронь одного времени мертва самим id записи —
|
|
3279
|
+
`v5(Мастер, старт)` (§ 3.2); частичное наложение разных интервалов проверяет приложение.
|
|
3280
|
+
- Бизнес-проверки брони: у мастера есть навык на услугу (`Мастер→навык→Услуга`); интервал
|
|
3281
|
+
записи попадает в окно-смену того же мастера; длительность услуги укладывается в окно.
|
|
3282
|
+
- Итог по записи = цена предмета (`Услуга`/`Товар`/`Комплекс`), у комплекса — по составу:
|
|
3283
|
+
сами записи денег не хранят.
|
|
3284
|
+
- Генерация окон-смен из шаблонов/повторов; месячное `Расписание {name, year, month}`.
|
|
2928
3285
|
|
|
2929
3286
|
## 14. Производительность
|
|
2930
3287
|
|
|
2931
|
-
|
|
3288
|
+
Тайминги — живые прогоны демо на ЕДИНОМ полигоне `v1.salondemo` (`bench/salon-seed.mjs`,
|
|
3289
|
+
~980 000 строк: 440 000 записей ×2 версии, 600 услуг, 1026 цен, 1260 навыков). Статья
|
|
3290
|
+
(`bench/salon-article-demo.mjs`), API Reference (`bench/api-reference-demo.mjs`) и бенчи
|
|
3291
|
+
читают один и тот же полигон, мутируя лишь свои демо-сущности:
|
|
2932
3292
|
|
|
2933
|
-
| Операция |
|
|
2934
|
-
|
|
2935
|
-
|
|
|
2936
|
-
| `
|
|
2937
|
-
|
|
|
2938
|
-
| `count()`
|
|
2939
|
-
|
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
(
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
3293
|
+
| Операция | Время |
|
|
3294
|
+
|---|---|
|
|
3295
|
+
| фильтр/`count()` по каталогу (`ne`/`gt`/`between`/`like`) | 5–8 ms |
|
|
3296
|
+
| агрегация каталога `avg`/`min`/`max` | 5–15 ms |
|
|
3297
|
+
| `sum('data.amounts.RUB')` (класс цена, 1026 вариантов) | ≈13 ms |
|
|
3298
|
+
| `count()`/`rows()` каталога услуг (600) | 7–13 ms |
|
|
3299
|
+
| цена `sort('data.amounts.RUB')` + keyset-страница `after(cursor)` | 19–31 ms |
|
|
3300
|
+
| цепочка `навык→Услуга`, `count()` путей (1260) | ≈65 ms |
|
|
3301
|
+
| `countBy('data.notes')` по 440k записям | ≈3.3 s |
|
|
3302
|
+
| `sort('updated','desc').limit` по всему классу записей БЕЗ фильтра | ≈6 s |
|
|
3303
|
+
| `count()` всех 440 000 записей БЕЗ фильтра | ≈3.4 s |
|
|
3304
|
+
| обход `запись→Мастер` по всему классу записей | ≈6 s |
|
|
3305
|
+
| запись новой версии (`create`/`update`), в т.ч. со слотами | 8–21 ms |
|
|
3306
|
+
|
|
3307
|
+
Слабое место — выборка/обход **всего класса записей без фильтра** (`DISTINCT ON` по всем
|
|
3308
|
+
440 000 сущностям: секунды); лечится селективным фильтром, контекст-шагом или курсором
|
|
3309
|
+
(keyset — миллисекунды даже на 440k). Каталог, агрегации и цепочки с фильтром — единицы—десятки ms.
|
|
3310
|
+
TOAST-порог (data > 2KB): 0 строк.
|
|
2948
3311
|
|
|
2949
3312
|
- Containment и обход графа — GIN; операторы — на уже суженном наборе.
|
|
2950
|
-
- Начинайте цепочку с самого селективного
|
|
2951
|
-
- `count()`
|
|
2952
|
-
- Каскадное удаление — серверное: один DELETE на всё
|
|
2953
|
-
- Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты)
|
|
2954
|
-
|
|
3313
|
+
- Начинайте цепочку с самого селективного шага (Организация/Мастер/Клиент, не `запись()`).
|
|
3314
|
+
- `count()` считает пути; число сущностей дешевле берётся `ids().length`.
|
|
3315
|
+
- Каскадное удаление — серверное: один DELETE на всё дерево (клиент → его записи).
|
|
3316
|
+
- Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты);
|
|
3317
|
+
окно/запись/навык — v5-классы, в батче идут поштучно (id из концов), не multi-VALUES.
|
|
3318
|
+
- Микро-бенч `npm run bench` (105k строк) + EXPLAIN-тесты (индексы обязаны быть в плане; ноль
|
|
3319
|
+
seq scan); партиционирование — `bench/dimensions.bench.mjs`, масштаб —
|
|
3320
|
+
`node bench/history.bench.mjs --entities=10000 --versions=100`, оверхед ACL —
|
|
3321
|
+
`npx tsx bench/acl.bench.mjs` (таблица в § 9.2).
|
|
2955
3322
|
|
|
2956
3323
|
---
|
|
2957
3324
|
|
|
2958
3325
|
## 15. E2E-пример: барбершоп
|
|
2959
3326
|
|
|
2960
|
-
Полный исполняемый сценарий — `test/integration.test.ts`.
|
|
3327
|
+
Полный исполняемый сценарий — `test/integration.test.ts`. Скелет (модель 0.17,
|
|
3328
|
+
позитивная доступность):
|
|
2961
3329
|
|
|
2962
3330
|
```ts
|
|
2963
|
-
// штат
|
|
3331
|
+
// организация, локация, штат (phone обязателен) — связи контекст-шагами; терминал .rows() исполняет план
|
|
2964
3332
|
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
2965
|
-
const [
|
|
2966
|
-
const [
|
|
2967
|
-
const [
|
|
2968
|
-
const [
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
await db
|
|
2972
|
-
|
|
2973
|
-
//
|
|
2974
|
-
|
|
2975
|
-
db
|
|
2976
|
-
|
|
2977
|
-
await db
|
|
2978
|
-
await db
|
|
2979
|
-
|
|
2980
|
-
//
|
|
2981
|
-
const [
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
await db
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
//
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
await
|
|
2992
|
-
|
|
2993
|
-
|
|
3333
|
+
const [loc] = await db.Организация(org).Локация().create({ address: 'Тверская, 7', coordinates: { lat: 55.76, lng: 37.61 } }).rows()
|
|
3334
|
+
const [иван] = await db.Организация(org).Мастер().create({ name: 'Иван', phone: '+7 900 111', specialization: 'барбер' }).rows()
|
|
3335
|
+
const [олег] = await db.Организация(org).Мастер().create({ name: 'Олег', phone: '+7 900 222' }).rows()
|
|
3336
|
+
const [пётр] = await db.Организация(org).Клиент().create({ name: 'Пётр', phone: '+7 905 000' }).rows()
|
|
3337
|
+
|
|
3338
|
+
// каталог: id услуги/комплекса вычислила схема uuidv5(Org, name) — дубль имени в салоне невозможен (§ 3.2)
|
|
3339
|
+
const [стрижка] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60 }).rows()
|
|
3340
|
+
const [комплекс] = await db.Организация(org).Комплекс().create({ name: 'Стрижка+борода', duration: 90, cost: 2000 }).rows()
|
|
3341
|
+
// цена — отдельный LINK «цена»: варианты (note) + мультивалюта (amounts); note не задан → «базовая»
|
|
3342
|
+
await db.Услуга(стрижка).цена().create({ amounts: { RUB: 1500 } }).rows()
|
|
3343
|
+
await db.Услуга(стрижка).цена().create({ note: 'с дизайном', amounts: { RUB: 2000 } }).rows()
|
|
3344
|
+
await db.Комплекс(комплекс).состав().create({ quantity: 1 }).Услуга.set(стрижка).rows() // состав комплекса
|
|
3345
|
+
await db.Мастер(иван).навык().create({ level: 'expert' }).Услуга.set(стрижка).rows() // умение
|
|
3346
|
+
await db.Мастер(иван).адрес().create({ default: true }).Локация.set(loc).rows() // мастер работает в локации
|
|
3347
|
+
|
|
3348
|
+
// месячное расписание → окна-смены батчем (окно = мастер доступен в интервале)
|
|
3349
|
+
const [sch] = await db.Организация(org).Расписание().create({ name: 'Август', year: 2026, month: 8 }).rows()
|
|
3350
|
+
db.batch('смены').Мастер(иван).окно().create({ start_datetime: '2026-08-01T07:00:00Z', end_datetime: '2026-08-01T15:00:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
3351
|
+
db.batch('смены').Мастер(олег).окно().create({ start_datetime: '2026-08-01T07:00:00Z', end_datetime: '2026-08-01T15:00:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
3352
|
+
await db.batch('смены').run() // окно — v5-класс: id = uuidv5(Мастер, старт) → поштучно
|
|
3353
|
+
await db.Расписание(sch).окно().Мастер().rows() // ростер смены: [Иван, Олег]
|
|
3354
|
+
|
|
3355
|
+
// бронь = наследник окна; id записи = v5(Мастер, старт), известен ДО создания — двойная бронь мертва
|
|
3356
|
+
const start = '2026-08-01T07:00:00Z', end = '2026-08-01T08:00:00Z'
|
|
3357
|
+
const bId = uuidv5(`v1.booking:entity:booking:${иван.id}:${start}`)
|
|
3358
|
+
const tr = await db.begin()
|
|
3359
|
+
await tr.lock('booking', иван.id, start) // сериализуем соперников на этом слоте
|
|
3360
|
+
if (!(await tr.запись(bId).first())) { // «занято?» — точечный first() по вычисленному id
|
|
3361
|
+
await tr.Мастер(иван).запись().create({ start_datetime: start, end_datetime: end })
|
|
3362
|
+
.Локация.set(loc).Расписание.set(sch).Услуга.set(стрижка).Клиент.set(пётр).rows()
|
|
3363
|
+
}
|
|
3364
|
+
await tr.commit()
|
|
3365
|
+
|
|
3366
|
+
// жизненный цикл, чтения, отмена/перенос
|
|
3367
|
+
await db.запись(bId).update({ notes: 'подтверждена' }).rows() // новая версия
|
|
3368
|
+
await db.Мастер(иван).запись().Услуга().rows() // что забронировано у Ивана
|
|
3369
|
+
await db.Локация(loc).запись().Клиент().rows() // кто записан в локацию
|
|
3370
|
+
await db.запись(bId).delete().rows() // ПРЕВЬЮ: что удалится
|
|
3371
|
+
await db.запись(bId).delete({ confirm: true }).rows() // отмена = tombstone (история цела)
|
|
3372
|
+
// перенос 07:00 → 09:00 = пересоздание (delete старой + create новой в одной транзакции):
|
|
3373
|
+
// id держит (Мастер, старт), поэтому смена времени = новая запись, старая — tombstone
|
|
2994
3374
|
```
|
|
2995
3375
|
|
|
2996
3376
|
---
|
|
@@ -2998,7 +3378,7 @@ const удалено = await db.Запись(bkg.id).delete({ confirm: true }).r
|
|
|
2998
3378
|
## 16. Тесты
|
|
2999
3379
|
|
|
3000
3380
|
```bash
|
|
3001
|
-
npm test #
|
|
3381
|
+
cd lib && npm test # 167/167 тестов против живого docker (timescale + redis), по файлам:
|
|
3002
3382
|
# acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
|
|
3003
3383
|
# с $account, enforceAcl (предикаты в SQL, каскад, watch)
|
|
3004
3384
|
# api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
|