letopis 0.13.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +203 -3
- package/README.md +825 -602
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.d.ts +44 -16
- package/dist/chain.js +309 -26
- package/dist/index.d.ts +7 -0
- package/dist/index.js +38 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +169 -4
- package/dist/sessions.js +66 -0
- package/dist/sql.d.ts +23 -3
- package/dist/sql.js +161 -25
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.d.ts +40 -4
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +68 -0
- package/dist/write.d.ts +12 -5
- package/dist/write.js +359 -95
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +1 -1
- package/sql/ddl.sql +124 -12
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +64 -17
package/README.md
CHANGED
|
@@ -9,11 +9,11 @@ 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)
|
|
16
|
+
await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Содержание:
|
|
@@ -52,8 +52,8 @@ const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v
|
|
|
52
52
|
// [letopis.up] schema "v1.booking" applied (3 files)
|
|
53
53
|
// [letopis.up] connected (schema "v1.booking")
|
|
54
54
|
|
|
55
|
-
const [org] = await db.Организация().
|
|
56
|
-
const [вася] = await db.Организация(org)
|
|
55
|
+
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
56
|
+
const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
|
|
57
57
|
await db.close()
|
|
58
58
|
```
|
|
59
59
|
|
|
@@ -108,7 +108,7 @@ const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz
|
|
|
108
108
|
|
|
109
109
|
| SQL | Триггер | Поведение |
|
|
110
110
|
|---|---|---|
|
|
111
|
-
| `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id;
|
|
111
|
+
| `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id; концы LINK по Schema.links v2 (жадный матчинг, союзы, optional, лишние связи — ошибка; legacy-строки — по-старому). Tombstone-вставки не проверяются |
|
|
112
112
|
| `UPDATE` | `entity_update` | физического апдейта нет: вставляется **новая версия** (`updated = GREATEST(clock, prev+1µs)`); не-latest строки игнорируются — история неизменна |
|
|
113
113
|
| `DELETE` | `entity_delete` | вставляется **tombstone** + **рекурсивный каскад**: DELETE живых зависимых (`links ⊃ {класс: id}`) повторяет триггер по дереву; advisory-lock; история/tombstone неприкосновенны (повторный DELETE — no-op) |
|
|
114
114
|
|
|
@@ -117,7 +117,7 @@ const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz
|
|
|
117
117
|
UPDATE "v1.booking"."Entity" SET data = data || '{"duration":45}'
|
|
118
118
|
WHERE partition='entity' AND class='Service' AND id='…'; -- → новая версия
|
|
119
119
|
DELETE FROM "v1.booking"."Entity"
|
|
120
|
-
WHERE partition='entity' AND class='
|
|
120
|
+
WHERE partition='entity' AND class='booking' AND id='…'; -- → tombstone + каскад
|
|
121
121
|
```
|
|
122
122
|
|
|
123
123
|
Управление объёмом истории — только политики Timescale: `db/policies.mjs` (§ 10.8).
|
|
@@ -143,18 +143,94 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
143
143
|
|
|
144
144
|
| Поле | Смысл |
|
|
145
145
|
|---|---|
|
|
146
|
-
| `id` / `alias` | англ. id (`Staff`) и русский алиас (
|
|
146
|
+
| `id` / `alias` | англ. id (`Staff`) и русский алиас (`Мастер`) — равноправны в API |
|
|
147
147
|
| `category` | `HUB` (сущность) \| `LINK` (связь с атрибутами) |
|
|
148
148
|
| `ancestor` | прямой родитель |
|
|
149
149
|
| `ancestors` | `[self, parent, …, root]` — **считает триггер `schema_lineage`** |
|
|
150
150
|
| `descendants` | все потомки транзитивно — **тот же триггер** |
|
|
151
|
-
| `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; вложенные `{type:'object', props:{…}}` любой глубины; строгая
|
|
152
|
-
| `links` |
|
|
151
|
+
| `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; вложенные `{type:'object', props:{…}}` любой глубины; строгая валидация; собираются по иерархии (§ 3.3); правило `id` — генерация (§ 3.2) |
|
|
152
|
+
| `links` | концы связей класса, формат v2 — массив объектов (см. ниже); legacy-строка `'Org'` = `{class:'Org'}`, `'Entity'` = старый полиморф |
|
|
153
153
|
| `meta` | `{ abstract?, description? }` |
|
|
154
154
|
|
|
155
|
+
### 3.1 Концы связей — Schema.links v2
|
|
156
|
+
|
|
157
|
+
Каждый конец — объект (`Schema.links jsonb` — массив объектов):
|
|
158
|
+
|
|
159
|
+
```jsonc
|
|
160
|
+
"links": [
|
|
161
|
+
{ "class": "Staff", "cardinality": 1 }, // один класс
|
|
162
|
+
{ "classes": ["Service", "Product", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
|
|
163
|
+
{ "class": "Customer", "optional": true, "cardinality": 1 } // конец может отсутствовать
|
|
164
|
+
]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `Entity` в концах не используется — классы называются явно («максимально точная идентификация связи»)
|
|
168
|
+
- **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет записи — `{Услуга|Товар|Комплекс}` (бронь услуги ИЛИ продажа товара ИЛИ комплекс — ровно один из)
|
|
169
|
+
- Обязательный конец без ключа → `requires end "Service|Product|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
|
|
170
|
+
- `cardinality` — зарезервировано (0 — безлимит, N — точное число), пока не проверяется: связь класса в строке одна (`{Класс: id}`), множественность выражается строками-связками
|
|
171
|
+
- Демо-домен (0.17, позитивная доступность): `адрес = [Мастер, Локация]`, `окно = [Мастер, Локация, Расписание]` (смена — интервал доступности), `запись = [Мастер, Локация, Расписание, {Услуга|Товар|Комплекс}, Клиент?]` — **наследник окна**, `содержимое = [Папка, {Услуга|Товар|Комплекс}]`, `состав = [Комплекс, {Услуга|Товар}]`, `навык = [Мастер, Услуга]`, `цена = [{Услуга|Товар|Комплекс}]` (варианты цены: `note` + `amounts` record<валюта,число>)
|
|
172
|
+
- Источник правды демо-домена — сид `lib/sql/seed.booking.sql` (редактируется руками, идемпотентен)
|
|
173
|
+
|
|
155
174
|
`schema_lineage` (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
|
|
156
175
|
пересчитывает `ancestors`/`descendants` при любом изменении Schema.
|
|
157
|
-
Из либы: `db.registry.resolve('связь').descendants` → `['
|
|
176
|
+
Из либы: `db.registry.resolve('связь').descendants` → `['address','booking','compo','content','price','skill','slot']`.
|
|
177
|
+
|
|
178
|
+
### 3.2 id считает схема: `attributes.id`
|
|
179
|
+
|
|
180
|
+
Правило рождения id объявляется в `attributes.id` класса (наследуется — § 3.3):
|
|
181
|
+
|
|
182
|
+
| Правило | Версия | Смысл |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `"uuid"` / `{"type":"uuid"}` | **v4** | random — дефолт (как раньше) |
|
|
185
|
+
| `{"type":"uuid","generate":7}` | **v7** | unix-время в старших битах — вставки ложатся в хвост btree-индекса |
|
|
186
|
+
| `{"type":"uuid","generate":5,"from":[…]}` | **v5** | детерминированный: id вычисляется из данных |
|
|
187
|
+
|
|
188
|
+
Формула v5 открыта: `uuidv5("pgSchema:partition:класс:значения from")`, namespace letopis —
|
|
189
|
+
`c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90`. `from` — имена ОБЯЗАТЕЛЬНЫХ концов `Schema.links`
|
|
190
|
+
(класс или полное имя союза `'Service|Complex'`; значение — id конца) и/или скалярных полей
|
|
191
|
+
`data`. Экспорт: `import { uuidv5, uuidv7, LETOPIS_NS } from 'letopis'`.
|
|
192
|
+
|
|
193
|
+
Два свойства v5, ради которых всё:
|
|
194
|
+
|
|
195
|
+
- **create идемпотентен**: та же комбинация → та же сущность (повтор — новая версия, не
|
|
196
|
+
дубль) — даже в гонке двух процессов оба вычислят один id; двойная бронь мертва на
|
|
197
|
+
уровне схемы;
|
|
198
|
+
- **id известен ДО создания**: «занято ли окно» — точечный `first()` по вычисленному id,
|
|
199
|
+
без создания чего-либо.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// запись: id считается сам — uuidv5(мастер, старт); правило унаследовано от окна
|
|
203
|
+
const [б] = await db.Мастер(м).запись().create({ start_datetime: t, end_datetime: e })
|
|
204
|
+
.Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
|
|
205
|
+
б.id === uuidv5(`v1.booking:entity:booking:${м.id}:${t}`) // → true
|
|
206
|
+
// «занято?» — ДО создания чего-либо:
|
|
207
|
+
await db.запись(uuidv5(`v1.booking:entity:booking:${м.id}:${t}`)).first() // Row | null
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Явный id у v5-класса запрещён (`computes id` — его всегда считает схема); в батче
|
|
211
|
+
v5-классы не склеиваются в multi-VALUES (id нужны концы) — исполняются поштучно (§ 8).
|
|
212
|
+
|
|
213
|
+
Демо-схема booking: корни `Entity`/`link` объявляют дефолт `{generate: 7}` один раз;
|
|
214
|
+
v7-классы (Org/Staff/Customer/Location/Schedule/Folder) наследуют его без собственного
|
|
215
|
+
правила; v5 объявляются по одному разу и наследуются дальше:
|
|
216
|
+
`Element ← v5(Org, data.name)` — наследуют Услуга/Товар/Комплекс (имя уникально в
|
|
217
|
+
организации, classId различает классы); `окно (slot) ← v5(Staff, data.start_datetime)` —
|
|
218
|
+
наследует `запись (booking)`: двойная бронь мертва самим id записи, а окно и запись
|
|
219
|
+
на одно время сосуществуют (classId в формуле разный); `адрес ← v5(Staff, Location)`,
|
|
220
|
+
`навык ← v5(Staff, Service)`, `состав ← v5(Complex, Service|Product)`,
|
|
221
|
+
`содержимое ← v5(Folder, Service|Product|Complex)` — полные имена союзов в from;
|
|
222
|
+
`цена ← v5(Service|Product|Complex, note)` — вариант цены уникален по (элемент, note).
|
|
223
|
+
|
|
224
|
+
`from`-поле может быть **необязательным**: если его нет в `create`, id берёт его `default`
|
|
225
|
+
из Schema (напр. `цена` без `note` → `note:'базовая'`, id считается стабильно). Так
|
|
226
|
+
необязательное поле участвует в детерминированном id, не ломая генерацию.
|
|
227
|
+
|
|
228
|
+
### 3.3 Наследование attributes
|
|
229
|
+
|
|
230
|
+
При загрузке реестра attributes класса собираются по цепочке `ancestor`: **потомок ПОВЕРХ
|
|
231
|
+
предка**, переопределение поля — замена правила ЦЕЛИКОМ (не слияние). Правило `id` (§ 3.2)
|
|
232
|
+
наследуется так же — дефолт объявляется один раз на корне иерархии; `links` НЕ наследуются:
|
|
233
|
+
концы объявляет каждый класс сам. Валидатор и типы полей компилируются из слитых attributes.
|
|
158
234
|
|
|
159
235
|
---
|
|
160
236
|
|
|
@@ -169,12 +245,32 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
169
245
|
| HUB → HUB (разные) | forward, если цель ∈ `Schema.links` текущего; иначе reverse; иначе «no path» |
|
|
170
246
|
| HUB → HUB (тот же класс) | reverse = **дети** (`db.Папка(id).Папка()`); родитель — `row.links.Folder` |
|
|
171
247
|
| LINK → LINK | ошибка |
|
|
248
|
+
| повтор LINK-класса | **pivot**: возврат к тому же узлу (ветвление к другому концу + дофильтр AND) |
|
|
249
|
+
|
|
250
|
+
Путь подчиняется переходам **всегда** — и в чтении, и в записи; недопустимый переход —
|
|
251
|
+
ошибка **синхронно при построении цепочки** (не в терминале). К концу связки, до которого
|
|
252
|
+
прямого HUB→HUB пути нет, идут через саму связку и **pivot** (повтор её имени):
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
// «записи клиента к, где услуга у и мастер вася» — путь через запись, pivot-возврат:
|
|
256
|
+
db.Клиент(к).запись().Услуга(у).запись().Мастер(вася).запись().rows()
|
|
257
|
+
// «умеет ли Ирина стрижку» — навык, дофильтр предметом, count путей:
|
|
258
|
+
db.Мастер(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Узел-переменная: `entity()`
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
const p = db.запись() // ленивый узел-паттерн
|
|
265
|
+
await db.Клиент(к).entity(p).Услуга(у).entity(p).Мастер().run() // та же p = тот же узел (явный pivot)
|
|
266
|
+
await db.entity(row).Услуга().first() // старт пути с готового Row
|
|
267
|
+
```
|
|
172
268
|
|
|
173
269
|
### Терминалы
|
|
174
270
|
|
|
175
271
|
```ts
|
|
176
|
-
const пути = await db
|
|
177
|
-
// [{
|
|
272
|
+
const пути = await db.Мастер({ name:'Вася' }).alias('Исполнитель').навык().Услуга().run()
|
|
273
|
+
// [{ Исполнитель: Row, навык: Row, Услуга: Row }, …]
|
|
178
274
|
```
|
|
179
275
|
|
|
180
276
|
| Вызов | Возврат |
|
|
@@ -200,9 +296,9 @@ const пути = await db.Сотрудник({ name:'Вася' }).alias('Мас
|
|
|
200
296
|
db.Услуга('uuid') // по id
|
|
201
297
|
db.Услуга(rowИлиAccount) // объект с id — возьмётся .id
|
|
202
298
|
db.Услуга(['id1','id2']) // по списку ([] → пусто)
|
|
203
|
-
db.Услуга({ name: 'Стрижка',
|
|
299
|
+
db.Услуга({ name: 'Стрижка', duration: 60 }) // eq полей data → GIN-containment
|
|
204
300
|
db.Услуга({ id: 'uuid', duration: gte(30) }) // ключ id — тоже id-фильтр
|
|
205
|
-
db
|
|
301
|
+
db.Локация({ coordinates: { lat: gte(55) } }) // вложенные пути ЛЮБОЙ глубины (object) + каст по листу
|
|
206
302
|
```
|
|
207
303
|
|
|
208
304
|
### Операторы (18: + `not`, `or`)
|
|
@@ -219,140 +315,165 @@ import { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends,
|
|
|
219
315
|
| `gt/gte/lt/lte(v)`, `between(a,b)` | number, date, string | `(data->>'f')::cast ⋛ $` |
|
|
220
316
|
| `inList([…])` | любые | `IN (…)`; `[]` → FALSE |
|
|
221
317
|
| `like/ilike/starts/ends(s)` | string | `[I]LIKE` |
|
|
222
|
-
| `has(v)/hasAll([…])/hasAny([…])` | массивы data
|
|
318
|
+
| `has(v)/hasAll([…])/hasAny([…])` | массивы data и колонка `tags` | `@>` / `?\|` |
|
|
223
319
|
| `exists(true/false)` | любые | ключ есть/нет |
|
|
224
320
|
| `isNull()` | любые | null или отсутствует |
|
|
225
321
|
| `not(op \| скаляр)` | по внутреннему | `NOT (…)`; `not(скаляр)` = `ne` |
|
|
226
|
-
| `or(f1, f2, …)` | **фильтр целиком** | `db
|
|
322
|
+
| `or(f1, f2, …)` | **фильтр целиком** | `db.Услуга(or({name:'Стрижка'}, {duration: lt(40)}))` — дизъюнкция под-фильтров |
|
|
227
323
|
|
|
228
324
|
Касты по `Schema.attributes`: number→`::numeric`, date→`::timestamptz`, boolean→`::boolean`.
|
|
229
325
|
Поле вне схемы фильтруется как text (записать его нельзя — строгая валидация).
|
|
230
|
-
Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`)
|
|
231
|
-
|
|
326
|
+
Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`); сравнения — операторами (`between(t, t)`
|
|
327
|
+
для «равно моменту»: скаляр-eq по дате — строковый jsonb-containment, он про
|
|
328
|
+
нормализованное значение). В демо-схеме массивов в data нет — `has*` живут на `tags`.
|
|
232
329
|
|
|
233
330
|
### Модификаторы цепочки
|
|
234
331
|
|
|
235
332
|
```ts
|
|
236
|
-
db
|
|
237
|
-
db
|
|
333
|
+
db.окно().sort('data.start_datetime').limit(10).offset(20).rows() // выборка
|
|
334
|
+
db.запись().sort('updated', 'desc').limit(50).rows()
|
|
238
335
|
|
|
239
336
|
db.Клиент().tags('vip') // фильтр: tags ⊇ ['vip']
|
|
240
337
|
db.Клиент().tags(['vip','telegram']) // все перечисленные
|
|
241
338
|
db.Клиент().tags(hasAny(['vip','b2b'])) // хотя бы один
|
|
242
|
-
db
|
|
339
|
+
db.запись().account(accId) // фильтр по колонке account (uuid | Row)
|
|
243
340
|
db.Организация().owner(acc) // фильтр по owner
|
|
244
|
-
db
|
|
341
|
+
db.Мастер({…}).alias('Исполнитель') // ключ шага в путях
|
|
245
342
|
```
|
|
246
343
|
|
|
247
|
-
| Модификатор | Область | В чтении | В
|
|
344
|
+
| Модификатор | Область | В чтении | В записи |
|
|
248
345
|
|---|---|---|---|
|
|
249
|
-
| `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей
|
|
346
|
+
| `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей `update()` |
|
|
250
347
|
| `asOf(t)` | вся цепочка | «как было на T» (§ 10.1) | — |
|
|
251
348
|
| `after(cursor)` | вся цепочка | keyset-пагинация, требует `sort` (§ 10.2) | — |
|
|
252
349
|
| `deep(max?)` | текущий шаг (self-hop) | рекурсивные дети, `$depth` (§ 10.4) | — |
|
|
253
|
-
| `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при
|
|
254
|
-
| `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при
|
|
255
|
-
| `
|
|
350
|
+
| `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при `create()` (string \| string[]) |
|
|
351
|
+
| `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при `create()` |
|
|
352
|
+
| `.Класс.set(x)` / `.Класс.unset()` | слот связи — после глагола записи | — | записать / снять конец связи БЕЗ участия в фильтре целей (§ 6.1) |
|
|
256
353
|
| `alias(name)` | текущий шаг | ключ в путях | — |
|
|
257
354
|
|
|
258
355
|
---
|
|
259
356
|
|
|
260
357
|
## 6. Запись: операции — звенья плана
|
|
261
358
|
|
|
262
|
-
`.
|
|
263
|
-
применяется к шагу, к которому приклеена точкой, и возвращает цепочку
|
|
264
|
-
Сама по себе ничего не пишет — **исполняет терминал**
|
|
265
|
-
весь план **одной транзакцией**: отказ любого сегмента
|
|
359
|
+
`.create(data?)` / `.update(data?)` / `.delete(opts?)` / `.anonymize(fields)` — **звенья
|
|
360
|
+
цепочки**: операция применяется к шагу, к которому приклеена точкой, и возвращает цепочку
|
|
361
|
+
для продолжения. Сама по себе ничего не пишет — **исполняет терминал**
|
|
362
|
+
(`rows()/first()/count()/run()/…`), весь план **одной транзакцией**: отказ любого сегмента
|
|
363
|
+
(валидация, ACL) откатывает всё.
|
|
364
|
+
|
|
365
|
+
Глаголы не перепутать: `create` **вставляет** (или версионирует по известному id),
|
|
366
|
+
`update` **правит найденное путём** и никогда не создаёт. Старый `set()` разбит на них
|
|
367
|
+
в 0.16.0 (бросает подсказку) — мина «`Класс()` → INSERT, `Класс({})` → UPDATE всех» мертва,
|
|
368
|
+
намерение всегда явное.
|
|
266
369
|
|
|
267
370
|
```ts
|
|
268
371
|
// одиночная запись: операция + терминал
|
|
269
|
-
const [вася] = await db.Организация(org)
|
|
372
|
+
const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
|
|
270
373
|
|
|
271
374
|
// несколько операций в одной цепочке: продолжение — ОТ РЕЗУЛЬТАТА предыдущей
|
|
272
|
-
await db
|
|
273
|
-
|
|
274
|
-
.rows()
|
|
375
|
+
await db.Организация().tags('сеть').update({ active: true }) // новая версия всех сетевых орг
|
|
376
|
+
.Услуга().create({ name: 'Акция месяца', duration: 30 }) // INSERT услуги КАЖДОЙ (fan-out)
|
|
377
|
+
.rows() // → акционные услуги
|
|
275
378
|
|
|
276
379
|
// операция сразу после операции — к тем же сущностям (две версии подряд)
|
|
277
|
-
await db
|
|
380
|
+
await db.запись(id).update({ notes: 'подтверждена' }).update({ notes: 'оплачена' }).rows()
|
|
278
381
|
```
|
|
279
382
|
|
|
280
383
|
### Анатомия
|
|
281
384
|
|
|
282
385
|
```
|
|
283
|
-
db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР )
|
|
284
|
-
└───── контекст ─────┘ └─────┘
|
|
285
|
-
каждый шаг = связь
|
|
386
|
+
db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хвост()… .rows()
|
|
387
|
+
└───── контекст ─────┘ └─────┘ └────┘ └─ продолжение ─┘ └─ терминал: исполняет план
|
|
388
|
+
каждый шаг = связь кого трогаем create|update|delete от записанных
|
|
389
|
+
(только update)
|
|
286
390
|
```
|
|
287
391
|
|
|
288
392
|
- **Контекст-шаги** (шаги до операции): каждый резолвится в **ровно одну** сущность —
|
|
289
393
|
id-фильтром (без запроса) или уникальным фильтром (0 или >1 → ошибка). Дают: `links`
|
|
290
|
-
при
|
|
291
|
-
-
|
|
394
|
+
при `create` и containment-фильтр целей при `update`/`delete`.
|
|
395
|
+
- **Режим выбирает глагол** (фильтр-объект допустим только перед `update`):
|
|
292
396
|
|
|
293
|
-
|
|
|
397
|
+
| Глагол | Шаг операции | Действие |
|
|
294
398
|
|---|---|---|
|
|
295
|
-
| `Класс()` — без
|
|
296
|
-
| `Класс()`
|
|
297
|
-
| `Класс(
|
|
298
|
-
|
|
|
299
|
-
| `Класс({})`
|
|
399
|
+
| `create(data?)` | `Класс()` — без фильтра | **INSERT**: id по правилу схемы (§ 3.2), links = контекст + слоты |
|
|
400
|
+
| | `Класс(id)` / `data.id` / вычисленный v5-id **уже существует** | **новая версия** (идемпотентный create, REST-PUT семантика); не существует → INSERT с этим id |
|
|
401
|
+
| | `Класс({фильтр})` | ошибка ПОСТРОЕНИЯ `create() takes no filter`; create на pivot-шаге — тоже ошибка |
|
|
402
|
+
| `update(data?)` | `Класс()` ≡ `Класс({})` | новая версия **всех** в границах контекста |
|
|
403
|
+
| | `Класс(id)` / `Класс({поля})` / pivot | новая версия **каждого** найденного путём; не найдено → `[]` — update НИКОГДА не создаёт |
|
|
300
404
|
|
|
301
|
-
- **Продолжение после операции** — от её результата: класс-шаг = переход (
|
|
405
|
+
- **Продолжение после операции** — от её результата: класс-шаг = переход (create-цель
|
|
302
406
|
следующего сегмента получает связь на записанное; чтение — обычный hop). При
|
|
303
407
|
множественном результате следующий сегмент исполняется **для каждой строки** (fan-out).
|
|
304
408
|
После `delete` продолжение идёт от строк класса цели.
|
|
305
|
-
- **DATA** — поля сущности/связи; `id`
|
|
409
|
+
- **DATA** — поля сущности/связи; явный `id` — здесь (`{ id, … }`) или шагом `Класс(id)`;
|
|
410
|
+
у v5-класса явный id запрещён — его считает схема (§ 3.2).
|
|
306
411
|
Валидация **строгая, всегда**: поле вне `Schema.attributes` → `ValidationError`;
|
|
307
412
|
default-ы схемы подставляются; id в `data` не хранится (он — колонка).
|
|
308
|
-
- **Deep-merge при
|
|
309
|
-
`
|
|
310
|
-
Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
|
|
311
|
-
- **Концы LINK**:
|
|
413
|
+
- **Deep-merge при `update`** (и у create-версии по известному id): меняются только
|
|
414
|
+
указанные листья — `update({ coordinates: { lat: 55.8 } })` сохранит `lng` и остальные
|
|
415
|
+
поля. Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
|
|
416
|
+
- **Концы LINK**: по `Schema.links` v2 (§3.1) — обязательные требуются, союз занимает один ключ, лишние связи — ошибка; значения — id, Row или вложенная цепочка (§6.1).
|
|
312
417
|
- **account/owner NOT NULL**: `.account()/.owner()` → `connect()` → System-аккаунт.
|
|
313
418
|
- Версии монотонны (`GREATEST(clock, prev+1µs)`), коллизия 23505 ретраится.
|
|
314
419
|
- Цепочка переиспользуема: повторный терминал = повторное исполнение плана.
|
|
315
420
|
|
|
316
|
-
### Связи:
|
|
421
|
+
### 6.1 Связи: путь и слоты `.Класс.set()` / `.Класс.unset()`
|
|
422
|
+
|
|
423
|
+
Один закон: **владелец создаваемой связки берётся из валидного пути; прочие концы —
|
|
424
|
+
слотами** `.Класс.set(значение)` после глагола записи. Слот — свойство-класс БЕЗ вызова
|
|
425
|
+
(`.Клиент.set(x)`, не `.Клиент(x)`); валиден только для конца из `Schema.links` и
|
|
426
|
+
требует операцию записи ПЕРЕД собой: `…create(…).Класс.set(x)` / `…update(…).Класс.unset()`;
|
|
427
|
+
слот без глагола — ошибка `link slot needs a write`.
|
|
317
428
|
|
|
318
429
|
```ts
|
|
319
|
-
//
|
|
320
|
-
await db
|
|
430
|
+
// СОЗДАНИЕ: владелец (Мастер) — из пути, остальные концы — слотами; id считает схема (§ 3.2)
|
|
431
|
+
await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
|
|
432
|
+
.Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
|
|
433
|
+
|
|
434
|
+
// навык: владелец Мастер из пути, предмет — слотом
|
|
435
|
+
await db.Мастер(s).навык().create({ level: 'expert' }).Услуга.set(у).rows()
|
|
321
436
|
|
|
322
|
-
//
|
|
323
|
-
await
|
|
324
|
-
.set({ kind: 'booking' }).rows()
|
|
437
|
+
// ПЕРЕВЕС связи (update): цель ищется путём/pivot, слот пишет новое значение БЕЗ фильтра
|
|
438
|
+
await tr.Клиент(к).запись().Услуга(старая).запись().update().Услуга.set(новая).rows()
|
|
325
439
|
|
|
326
|
-
//
|
|
327
|
-
await
|
|
328
|
-
|
|
440
|
+
// СОЮЗ-конец [Услуга|Товар|Комплекс]: слот замещает конец целиком (соседний класс снимается)
|
|
441
|
+
await db.запись(b).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
|
|
442
|
+
|
|
443
|
+
// СНЯТИЕ optional-конца: ручная бронь — клиент отвязан, имя в notes
|
|
444
|
+
await db.запись(b).update({ notes: 'по телефону: Аня' }).Клиент.unset().rows()
|
|
445
|
+
|
|
446
|
+
// вложенная цепочка как значение слота — та же транзакция, ровно одна сущность:
|
|
447
|
+
await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
|
|
448
|
+
.Локация.set(л).Расписание.set(р)
|
|
449
|
+
.Услуга.set(db.Организация(org).Услуга().create({ name: 'Новинка', duration: 45 })).rows() // создать И привязать
|
|
329
450
|
```
|
|
330
451
|
|
|
331
452
|
### `.delete({ confirm })` — превью и серверное удаление
|
|
332
453
|
|
|
333
454
|
```ts
|
|
334
|
-
const превью = await db
|
|
335
|
-
// кандидаты (цель +
|
|
336
|
-
// [{class:'
|
|
455
|
+
const превью = await db.Клиент(cid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
|
|
456
|
+
// кандидаты (цель + каскад: записи клиента), живые, БД не тронута:
|
|
457
|
+
// [{class:'Customer'}, {class:'booking'}]
|
|
337
458
|
|
|
338
|
-
const удалено = await db
|
|
459
|
+
const удалено = await db.Клиент(cid).delete({ confirm: true }).rows()
|
|
339
460
|
// удалённое дерево, каждый Row с $deleted: true
|
|
340
461
|
|
|
341
|
-
await db
|
|
462
|
+
await db.Мастер(m).окно().delete({ confirm: true }).rows() // контекст: только окна мастера
|
|
342
463
|
```
|
|
343
464
|
|
|
344
465
|
Цели = фильтр шага + контекст-связи. Замыкание (цели + все живые зависимые) собирается
|
|
345
466
|
одним CTE; `confirm: true` шлёт один SQL `DELETE` — триггер БД тумбстоунит дерево
|
|
346
|
-
(+advisory-lock). История неприкосновенна; повторный delete → `[]`; `
|
|
467
|
+
(+advisory-lock). История неприкосновенна; повторный delete → `[]`; `create()` с тем же
|
|
347
468
|
id — воскрешение.
|
|
348
469
|
|
|
349
470
|
### Откат плана
|
|
350
471
|
|
|
351
472
|
```ts
|
|
352
|
-
await db
|
|
353
|
-
|
|
473
|
+
await db.Организация(org).update({ phone: '+7 495 …' }) // валидный сегмент…
|
|
474
|
+
.Услуга().create({ name: 'X', чепуха: 1 }) // …невалидный: ValidationError
|
|
354
475
|
.rows()
|
|
355
|
-
// ← ОТКАТ ВСЕГО:
|
|
476
|
+
// ← ОТКАТ ВСЕГО: телефон не изменился, версий не прибавилось
|
|
356
477
|
```
|
|
357
478
|
|
|
358
479
|
---
|
|
@@ -360,35 +481,41 @@ await db.Клиент(id).set({ name: 'Новое' }) // валидный
|
|
|
360
481
|
## 7. Транзакции
|
|
361
482
|
|
|
362
483
|
```ts
|
|
484
|
+
const bookingId = uuidv5(`v1.booking:entity:booking:${staffId}:${start}`) // id известен ДО создания (§ 3.2)
|
|
485
|
+
|
|
363
486
|
const tr = await db.begin() // тот же API на выделенном соединении
|
|
364
|
-
await tr.lock('
|
|
365
|
-
const занято = await tr
|
|
366
|
-
if (!занято) await tr
|
|
487
|
+
await tr.lock('booking', staffId, start) // advisory-xact-lock до конца транзакции
|
|
488
|
+
const занято = await tr.запись(bookingId).first()
|
|
489
|
+
if (!занято) await tr.Мастер(s).запись().create({ start_datetime: start, end_datetime: end })
|
|
490
|
+
.Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
|
|
367
491
|
await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()
|
|
368
492
|
```
|
|
369
493
|
|
|
370
494
|
Повторный commit/rollback — no-op. `lock()` вне транзакции — ошибка. Держите транзакции
|
|
371
|
-
короткими. **Рецепт двойной брони**:
|
|
372
|
-
|
|
373
|
-
(
|
|
495
|
+
короткими. **Рецепт двойной брони**: id записи детерминирован схемой (v5 — § 3.2), так
|
|
496
|
+
что дубль невозможен в принципе (второй `create` стал бы версией той же записи);
|
|
497
|
+
`lock(мастер, старт)` + перечитка `first()` под локом нужны, чтобы сопернику честно
|
|
498
|
+
ОТКАЗАТЬ, а не молча версионировать чужую бронь (ровно одна успешна — покрыто тестом-гонкой).
|
|
374
499
|
|
|
375
500
|
---
|
|
376
501
|
|
|
377
502
|
## 8. Батчи
|
|
378
503
|
|
|
379
504
|
```ts
|
|
380
|
-
db.batch('
|
|
381
|
-
|
|
382
|
-
db.batch('
|
|
383
|
-
|
|
384
|
-
db.batch('
|
|
505
|
+
db.batch('смены').Мастер(m).окно().create({ start_datetime, end_datetime }) // план встал в очередь
|
|
506
|
+
.Локация.set(loc).Расписание.set(sch)
|
|
507
|
+
db.batch('смены').Мастер(m2).окно().create({ … }).Локация.set(loc).Расписание.set(sch)
|
|
508
|
+
db.batch('смены').size() // 2
|
|
509
|
+
const res = await db.batch('смены').run() // одна транзакция; Row[][] по порядку
|
|
510
|
+
db.batch('смены').discard() // отменить
|
|
385
511
|
```
|
|
386
512
|
|
|
387
513
|
- `run()` атомарен: любая ошибка откатывает всё.
|
|
388
514
|
- Операции в батч-цепочке кладут ПЛАН в очередь (многосегментные планы — одним элементом);
|
|
389
515
|
терминал на такой цепочке — ошибка `plan is queued in the batch — call batch.run()`.
|
|
390
|
-
- Подряд идущие чистые
|
|
391
|
-
склеиваются в один multi-VALUES
|
|
516
|
+
- Подряд идущие чистые `create()` одного класса (план из одного шага, без `data.id` и
|
|
517
|
+
слотов) склеиваются в один multi-VALUES; **v5-классы — поштучно** (id считается из
|
|
518
|
+
концов/полей — § 3.2), семантика та же.
|
|
392
519
|
- Read-вызовы на батч-фасаде исполняются сразу, мимо очереди.
|
|
393
520
|
|
|
394
521
|
---
|
|
@@ -506,7 +633,7 @@ lockout после N неудач. Legacy-bcrypt-хэши (`$2b$…` из дам
|
|
|
506
633
|
|---|---|---|
|
|
507
634
|
| `ACCOUNT` | аккаунта | `{"categories": "{Staff}"}`; `"!{Anonymous,Shadow}"` — нет ни одной; NULL — все |
|
|
508
635
|
| `API` | адреса эндпоинта | `{"endpoint": "v2.auth.apikey.*"}` — маска: `.`-сегменты, `{a,b}`, `*` — хвост |
|
|
509
|
-
| `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "
|
|
636
|
+
| `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "booking", "owner": "$account"}` |
|
|
510
637
|
|
|
511
638
|
Pattern операций — реальные колонки Entity (`class`, `owner`, `account`, `tags`, `data`,
|
|
512
639
|
`links`…), значения — литералы или `"$account"` (id субъекта, подставляется в запрос);
|
|
@@ -523,7 +650,7 @@ await db.acl.check(anon, 'v2.auth.password.signup')
|
|
|
523
650
|
await db.acl.check(anon, 'v2.auth.apikey.create')
|
|
524
651
|
// → { allow: false, code: 403, message: 'Access denied - default …' } — победило дно-правило deny 0
|
|
525
652
|
|
|
526
|
-
await db.acl.checkData(user, '
|
|
653
|
+
await db.acl.checkData(user, 'booking', 'READ') // ресурс {"class":"booking","owner":"$account"}
|
|
527
654
|
// → { allow: true, filter: { owner: '24c49a43-…' }, rule: {…weight: 60} } [1.7 ms]
|
|
528
655
|
await db.acl.checkData(user, 'SportsCar', 'READ') // право дал предок Vehicle (lineage)
|
|
529
656
|
// → { allow: true } — безусловный, без предиката
|
|
@@ -533,35 +660,36 @@ db.acl.reload() // сброс кэша Resou
|
|
|
533
660
|
```
|
|
534
661
|
|
|
535
662
|
**`connect({ account, enforceAcl: true })`** — те же решения в цепочках: каждый шаг —
|
|
536
|
-
`READ`, `
|
|
663
|
+
`READ`, `create()`/`update()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
|
|
537
664
|
(deny в замыкании откатывает транзакцию); `watch()` отдаёт события только безусловных
|
|
538
665
|
allow-классов (payload нечем проверить предикат). Правила фиксируются на connect.
|
|
539
666
|
|
|
540
667
|
```ts
|
|
541
668
|
const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
|
|
542
|
-
await u
|
|
543
|
-
await u
|
|
669
|
+
await u.запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
|
|
670
|
+
await u.запись().count() // честный count по суженному множеству
|
|
544
671
|
await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
|
|
545
|
-
const [z] = await u
|
|
546
|
-
await u
|
|
547
|
-
await u
|
|
548
|
-
//
|
|
672
|
+
const [z] = await u.Мастер(m).запись().create({…}).rows() // owner пришпилен правилом → user.id
|
|
673
|
+
await u.запись().owner(other).update({…}).rows() // Error: acl pins booking writes to owner …
|
|
674
|
+
await u.запись(чужаяId).update({ notes: '…' }).rows() // → [] — цель вне предиката не находится
|
|
675
|
+
// create той же v5-пары (id чужой записи) тоже НЕ перехватывает: → [] вместо новой версии
|
|
549
676
|
```
|
|
550
677
|
|
|
551
|
-
Оверхед (
|
|
678
|
+
Оверхед (полигон `v1.salondemo` ~980k, booking 440k×2; p50 из 20; `bench/acl.bench.mjs`):
|
|
552
679
|
|
|
553
680
|
| Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
|
|
554
681
|
|---|---|---|---|
|
|
555
|
-
| точечный `first(id)` | 2.
|
|
556
|
-
| фильтр `rows` limit 100 |
|
|
557
|
-
| keyset-страница всего класса
|
|
558
|
-
| `count()` класса
|
|
559
|
-
| цепочка
|
|
560
|
-
|
|
561
|
-
Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
682
|
+
| точечный `first(id)` | 2.9 ms | 4.2 ms | 6.1 ms |
|
|
683
|
+
| фильтр `rows` limit 100 | 1394 ms | 1454 ms | 1362 ms |
|
|
684
|
+
| keyset-страница всего класса 440k | 5495 ms | 5576 ms | 5364 ms |
|
|
685
|
+
| `count()` класса 440k | 3196 ms | 3049 ms | 2874 ms |
|
|
686
|
+
| цепочка `запись(id).Услуга()` | 5.5 ms | 6.6 ms | 5.1 ms |
|
|
687
|
+
|
|
688
|
+
Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат здесь на
|
|
689
|
+
селективности 100% (все строки booking — System-аккаунт, предикат никого не отсекает) —
|
|
690
|
+
на тяжёлых сценах ACL в пределах шума с базой; в реальности предикат СУЖАЕТ выборку,
|
|
691
|
+
и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
|
|
692
|
+
`db.acl.checkData` — справочный (1.0 ms: SQL за категориями аккаунта на каждый вызов);
|
|
565
693
|
горячий путь цепочек использует резолвер, скомпилированный на connect (микросекунды, memo).
|
|
566
694
|
|
|
567
695
|
`enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
|
|
@@ -572,8 +700,8 @@ worst-case (пропускает 100% строк — чистый оверхед
|
|
|
572
700
|
|
|
573
701
|
```ts
|
|
574
702
|
await db.Услуга().rows() // срез класса (актуальное, живое)
|
|
575
|
-
await db
|
|
576
|
-
await db.Клиент(cid)
|
|
703
|
+
await db.запись().sort('updated','desc').limit(50).rows()
|
|
704
|
+
await db.Клиент(cid).запись().Услуга().run() // связный подграф
|
|
577
705
|
```
|
|
578
706
|
|
|
579
707
|
```sql
|
|
@@ -583,7 +711,7 @@ SELECT * FROM (SELECT DISTINCT ON (class, id) * FROM "v1.booking"."Entity"
|
|
|
583
711
|
|
|
584
712
|
-- история сущности (все версии, включая tombstone)
|
|
585
713
|
SELECT updated, deleted, data, links FROM "v1.booking"."Entity"
|
|
586
|
-
WHERE partition='entity' AND class='
|
|
714
|
+
WHERE partition='entity' AND class='booking' AND id=$1 ORDER BY updated;
|
|
587
715
|
```
|
|
588
716
|
|
|
589
717
|
### 10.1 Время-путешествия: `.asOf()` / `.versions()`
|
|
@@ -591,11 +719,11 @@ WHERE partition='entity' AND class='Booking' AND id=$1 ORDER BY updated;
|
|
|
591
719
|
```ts
|
|
592
720
|
// «какая цена была на момент брони» — версии позже T невидимы, tombstone до T = «удалён»
|
|
593
721
|
const тогда = await db.Услуга(id).asOf('2026-07-01T12:00:00Z').first()
|
|
594
|
-
const срезДня = await db
|
|
722
|
+
const срезДня = await db.запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
|
|
595
723
|
|
|
596
724
|
// вся история сущности без сырого SQL (tombstone-версии приходят с $deleted: true)
|
|
597
|
-
const история = await db
|
|
598
|
-
// [{data:{
|
|
725
|
+
const история = await db.запись(id).versions()
|
|
726
|
+
// [{data:{…}}, {data:{…, notes:'подтверждена'}}, {…, $deleted:true}]
|
|
599
727
|
```
|
|
600
728
|
|
|
601
729
|
`asOf` применяется к каждому шагу цепочки — подграф целиком «как был». `versions()`
|
|
@@ -609,19 +737,19 @@ Keyset — закладка: курсор = значение поля сорти
|
|
|
609
737
|
порядок тотальным, дубли значений не теряются и не повторяются).
|
|
610
738
|
|
|
611
739
|
```ts
|
|
612
|
-
const стр1 = await db
|
|
740
|
+
const стр1 = await db.запись().sort('updated', 'desc').limit(50).rows()
|
|
613
741
|
const кур = cursorOf(стр1.at(-1)!) // { v: '<updated>', id: '…' } — просто объект,
|
|
614
|
-
const стр2 = await db
|
|
742
|
+
const стр2 = await db.запись().sort('updated', 'desc') // можно хранить в URL/state
|
|
615
743
|
.after(кур).limit(50).rows()
|
|
616
744
|
|
|
617
745
|
// по data-пути ЛЮБОЙ глубины — field тот же, что в sort
|
|
618
|
-
const дальше = await db
|
|
619
|
-
.after(cursorOf(окно, 'data.
|
|
746
|
+
const дальше = await db.окно().sort('data.start_datetime')
|
|
747
|
+
.after(cursorOf(окно, 'data.start_datetime')).limit(20).rows()
|
|
620
748
|
|
|
621
749
|
// бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
|
|
622
750
|
let cursor
|
|
623
751
|
do {
|
|
624
|
-
const page = await db
|
|
752
|
+
const page = await db.Мастер(m).запись().sort('updated', 'desc')
|
|
625
753
|
.after(cursor).limit(50).rows()
|
|
626
754
|
render(page)
|
|
627
755
|
cursor = page.length ? cursorOf(page.at(-1)!) : undefined
|
|
@@ -638,14 +766,14 @@ do {
|
|
|
638
766
|
### 10.3 Агрегации: считает БД
|
|
639
767
|
|
|
640
768
|
```ts
|
|
641
|
-
await db
|
|
642
|
-
await db.Услуга().avg('data.duration')
|
|
643
|
-
await db
|
|
644
|
-
await db
|
|
769
|
+
await db.цена().sum('data.amounts.RUB') // сумма всех прайсов (record-лист): number | null
|
|
770
|
+
await db.Услуга().avg('data.duration') // среднее
|
|
771
|
+
await db.Услуга().countBy('data.duration') // { '30': 2, '60': 1 } ({} на пустом)
|
|
772
|
+
await db.окно().min('data.start_datetime') // min/max — каст по типу поля из Schema
|
|
645
773
|
```
|
|
646
774
|
|
|
647
|
-
Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или
|
|
648
|
-
`'data.
|
|
775
|
+
Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или вложенный лист
|
|
776
|
+
(`'data.coordinates.lat'`). `sum`/`avg` кастуются в numeric; пустое множество → `null`.
|
|
649
777
|
|
|
650
778
|
### 10.4 Деревья: `.deep()`
|
|
651
779
|
|
|
@@ -661,7 +789,7 @@ reverse = дети); на другом переходе — ошибка. Раб
|
|
|
661
789
|
### 10.5 Realtime: `db.watch()`
|
|
662
790
|
|
|
663
791
|
```ts
|
|
664
|
-
const stop = await db.watch('
|
|
792
|
+
const stop = await db.watch('запись', (e) => {
|
|
665
793
|
// e = { partition, class, id, updated, deleted } — факт версии (insert/update/tombstone)
|
|
666
794
|
обновитьКалендарь(e.id)
|
|
667
795
|
})
|
|
@@ -677,7 +805,7 @@ await stop() // отписка
|
|
|
677
805
|
повторяет LISTEN), но `NOTIFY` за время разрыва потеряны — для этого `onReconnect`:
|
|
678
806
|
|
|
679
807
|
```ts
|
|
680
|
-
const stop = await db.watch('
|
|
808
|
+
const stop = await db.watch('запись', onEvent, {
|
|
681
809
|
onReconnect: () => дочитатьПропущенное(), // напр. перечитать всё с последнего e.updated
|
|
682
810
|
})
|
|
683
811
|
```
|
|
@@ -686,9 +814,9 @@ const stop = await db.watch('Запись', onEvent, {
|
|
|
686
814
|
|
|
687
815
|
```ts
|
|
688
816
|
const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
|
|
689
|
-
await db
|
|
690
|
-
await db.Клиент().
|
|
691
|
-
db
|
|
817
|
+
await db.запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
|
|
818
|
+
await db.Клиент().create({ name: 'X', phone: '+7…' }) // запись пришпилена к account
|
|
819
|
+
db.запись().account(чужой).rows() // ошибка: reads are pinned to account …
|
|
692
820
|
```
|
|
693
821
|
|
|
694
822
|
Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
|
|
@@ -698,7 +826,7 @@ db.Запись().account(чужой).rows() // ошибка: reads are p
|
|
|
698
826
|
### 10.7 Анонимизация (GDPR): `.anonymize()`
|
|
699
827
|
|
|
700
828
|
```ts
|
|
701
|
-
await db.Клиент(id).anonymize(['name', '
|
|
829
|
+
await db.Клиент(id).anonymize(['name', 'phone']).rows()
|
|
702
830
|
// новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
|
|
703
831
|
```
|
|
704
832
|
|
|
@@ -725,7 +853,7 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
|
|
|
725
853
|
### 10.9 Миграции классов: `scripts/schema-sync.mjs`
|
|
726
854
|
|
|
727
855
|
```bash
|
|
728
|
-
node scripts/schema-sync.mjs --file
|
|
856
|
+
node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
|
|
729
857
|
# schema-sync: … ↔ booking.Schema (partition entity)
|
|
730
858
|
# + Coupon (HUB · Купон) — новый класс
|
|
731
859
|
# ~ Service — изменены: attributes
|
|
@@ -763,7 +891,7 @@ npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d
|
|
|
763
891
|
```ts
|
|
764
892
|
import type { TypedDb } from './entity-types'
|
|
765
893
|
const t = db as unknown as TypedDb
|
|
766
|
-
const [svc] = await t.Услуга({
|
|
894
|
+
const [svc] = await t.Услуга({ duration: 60 }).rows() // svc.data.duration: number
|
|
767
895
|
```
|
|
768
896
|
|
|
769
897
|
Интерфейсы data-полей всех классов (enum → union-литералы, record → `Partial<Record<…>>`)
|
|
@@ -800,15 +928,16 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
|
|
|
800
928
|
|
|
801
929
|
Каждый метод описан по одной схеме: **сигнатура → параметры → назначение и алгоритм →
|
|
802
930
|
примеры (под каждым вызовом реальный ответ и время) → полный кейс**. Все ответы и тайминги —
|
|
803
|
-
**живой прогон** на
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
931
|
+
**живой прогон** на едином полигоне `v1.salondemo` ~980 000 строк Entity (год окон-смен и
|
|
932
|
+
записей: 440 000 записей-наследников окон ×2 версии, каталог из 600 услуг, 360 мастеров,
|
|
933
|
+
40 000 клиентов, 1026 цен, 1260 навыков); воспроизводитель — `bench/api-reference-demo.mjs` (только
|
|
934
|
+
читает полигон salon-seed, мутирует лишь свои сущности). Тот же полигон — под статьёй SALON.md.
|
|
935
|
+
id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; повторяющиеся
|
|
936
|
+
`account`/`owner`/`partition` в ответах опущены.
|
|
808
937
|
|
|
809
938
|
Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
|
|
810
939
|
[11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
|
|
811
|
-
[11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-
|
|
940
|
+
[11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-create--update--delete--anonymize) ·
|
|
812
941
|
[11.6 Транзакции](#116-транзакции-entitytx) · [11.7 Батчи](#117-batch) ·
|
|
813
942
|
[11.8 Таблицы](#118-таблицы-accounts--credentials--resources--rules) ·
|
|
814
943
|
[11.9 db.auth](#119-dbauth--вход-и-сессии) · [11.10 db.acl](#1110-dbacl) ·
|
|
@@ -842,23 +971,23 @@ NOT NULL `Entity.account`. При `enforceAcl: true` дополнительно
|
|
|
842
971
|
**Примеры**
|
|
843
972
|
|
|
844
973
|
```ts
|
|
845
|
-
const db = await connect({ dsn, schema: 'v1.
|
|
846
|
-
await db.Услуга(
|
|
847
|
-
// событие onQuery: {"mode":"rows","classes":["Service"],"ms":
|
|
974
|
+
const db = await connect({ dsn, schema: 'v1.salondemo', onQuery: (e) => log(e) }) // [33.6 ms]
|
|
975
|
+
await db.Услуга().first()
|
|
976
|
+
// первое событие onQuery: {"mode":"rows","classes":["Service"],"ms":15.7,"rows":1,"slow":false}
|
|
848
977
|
|
|
849
|
-
const dbSlow = await connect({ dsn, schema: 'v1.
|
|
850
|
-
await dbSlow
|
|
851
|
-
// {"mode":"count","classes":["
|
|
978
|
+
const dbSlow = await connect({ dsn, schema: 'v1.salondemo', slowMs: 200, onQuery: … }) // [35.8 ms]
|
|
979
|
+
await dbSlow.запись().count() // ~440k сущностей — дольше порога:
|
|
980
|
+
// {"mode":"count","classes":["booking"],"ms":3191.0,"rows":1,"slow":true}
|
|
852
981
|
```
|
|
853
982
|
|
|
854
983
|
**Кейс: три подключения — обычное, изолированное, под ACL**
|
|
855
984
|
|
|
856
985
|
```ts
|
|
857
|
-
const db = await connect({ dsn, schema }) // [
|
|
858
|
-
const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [
|
|
859
|
-
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [
|
|
860
|
-
await db.Организация().count() // →
|
|
861
|
-
await iso.Организация().count() // [
|
|
986
|
+
const db = await connect({ dsn, schema }) // [33.6 ms]
|
|
987
|
+
const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [40.2 ms]
|
|
988
|
+
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [54.7 ms]
|
|
989
|
+
await db.Организация().count() // → 30 — видит всех
|
|
990
|
+
await iso.Организация().count() // [5.1 ms] → 1 — только свой арендатор
|
|
862
991
|
await uc.Организация().rows()
|
|
863
992
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default)
|
|
864
993
|
await iso.close(); await uc.close()
|
|
@@ -931,14 +1060,14 @@ await up({ dsn, schema: 'v1.booking', version: 1 })
|
|
|
931
1060
|
|
|
932
1061
|
```ts
|
|
933
1062
|
const db = await up({ schema: 'booking', version: 1, dsn }) // [7.4 s] образ+контейнер+схема
|
|
934
|
-
await db
|
|
935
|
-
|
|
1063
|
+
await db.Организация().count() // → 0 — свежая схема
|
|
1064
|
+
await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
936
1065
|
await db.close()
|
|
937
1066
|
|
|
938
1067
|
// … docker stop letopis-timescale (ребут, уборка, что угодно) …
|
|
939
1068
|
|
|
940
1069
|
const db2 = await up({ schema: 'booking', version: 1, dsn }) // [1.2 s] start + reuse
|
|
941
|
-
await db2
|
|
1070
|
+
await db2.Организация().count() // → 1 — volume letopis-pgdata: всё на месте
|
|
942
1071
|
await db2.close()
|
|
943
1072
|
```
|
|
944
1073
|
|
|
@@ -947,6 +1076,7 @@ await db2.close()
|
|
|
947
1076
|
```ts
|
|
948
1077
|
import {
|
|
949
1078
|
connect, up, cursorOf, totpCode, // функции
|
|
1079
|
+
uuidv5, uuidv7, LETOPIS_NS, // генерация id (§ 3.2): формула v5 открыта
|
|
950
1080
|
ne, gt, gte, lt, lte, between, inList, like, ilike, // операторы (18)
|
|
951
1081
|
starts, ends, has, hasAny, hasAll, exists, isNull, not, or,
|
|
952
1082
|
ValidationError, Registry, // классы
|
|
@@ -971,21 +1101,21 @@ import type {
|
|
|
971
1101
|
|
|
972
1102
|
| Параметр | Тип | Описание |
|
|
973
1103
|
|---|---|---|
|
|
974
|
-
| `Класс` | имя свойства | id или алиас класса из `Schema` (`db.
|
|
1104
|
+
| `Класс` | имя свойства | id или алиас класса из `Schema` (`db.booking` ≡ `db.запись`); неизвестное имя — ошибка со списком классов |
|
|
975
1105
|
| `filter` | `Filter?` | без аргумента — весь класс; `string` — по id; `string[]` — по списку id; `Row`/объект с `.id` — как id; объект — поля `data` (равенство, операторы § 11.4, record-пути) + ключ `id` |
|
|
976
1106
|
|
|
977
1107
|
**Назначение и алгоритм.** Старт цепочки чтения/записи (§ 4–6). Ничего не выполняет —
|
|
978
1108
|
только копит шаги; SQL строится и уходит в БД одним запросом на терминале
|
|
979
|
-
(`rows/
|
|
1109
|
+
(`rows/run/count/…` — § 11.3). Формы `filter` нормализуются сразу: объект с `.id`
|
|
980
1110
|
сворачивается в строку-id.
|
|
981
1111
|
|
|
982
1112
|
**Примеры**
|
|
983
1113
|
|
|
984
1114
|
```ts
|
|
985
|
-
await db.Услуга('…
|
|
986
|
-
await db.Услуга(['…
|
|
987
|
-
await db.Организация(org).first() // [2.
|
|
988
|
-
await db
|
|
1115
|
+
await db.Услуга('…0000').first() // [3.5 ms] по id → Row {name: 'Стрижка 0', …}
|
|
1116
|
+
await db.Услуга(['…0000', '…0006']).rows() // [2.6 ms] по списку → ['Стрижка 0', 'Бритьё 6']
|
|
1117
|
+
await db.Организация(org).first() // [2.2 ms] Row-объект ≡ его id → 'Салон «Стрижка» №0'
|
|
1118
|
+
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [36.0 ms] → 630 (фильтр по цене)
|
|
989
1119
|
await db.НетТакогоКласса().rows()
|
|
990
1120
|
// Error: letopis: unknown class "НетТакогоКласса". Known: Entity·Сущность, Org·Организация, …
|
|
991
1121
|
```
|
|
@@ -993,10 +1123,10 @@ await db.НетТакогоКласса().rows()
|
|
|
993
1123
|
**Кейс: одна сущность тремя формами фильтра**
|
|
994
1124
|
|
|
995
1125
|
```ts
|
|
996
|
-
const поId
|
|
997
|
-
const поПолю
|
|
998
|
-
const поОбъекту = await db.Услуга(поId).first()
|
|
999
|
-
// все три → id '…
|
|
1126
|
+
const поId = await db.Услуга('…0000').first() // [3.5 ms] → data.name = 'Стрижка 0'
|
|
1127
|
+
const поПолю = await db.Услуга({ name: 'Стрижка 0' }).first() // тот же Row
|
|
1128
|
+
const поОбъекту = await db.Услуга(поId).first() // Row как фильтр ≡ его id
|
|
1129
|
+
// все три → id '…0000', data.name = 'Стрижка 0' (цена — отдельным LINK «цена», § 11.9)
|
|
1000
1130
|
```
|
|
1001
1131
|
|
|
1002
1132
|
#### `db.begin(): Promise<EntityTx>`
|
|
@@ -1015,19 +1145,20 @@ deadlock/serialization ошибка приходит сразу с подска
|
|
|
1015
1145
|
|
|
1016
1146
|
```ts
|
|
1017
1147
|
const tr = await db.begin() // [0.8 ms]
|
|
1018
|
-
await tr.Организация('…0901').Услуга().
|
|
1019
|
-
// [
|
|
1020
|
-
await tr.commit() // [
|
|
1148
|
+
await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15 }).rows()
|
|
1149
|
+
// [9.0 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
|
|
1150
|
+
await tr.commit() // [3.7 ms] — теперь видно всем
|
|
1021
1151
|
```
|
|
1022
1152
|
|
|
1023
1153
|
**Кейс: атомарный перенос с откатом при провале** — § 11.6 (`tr.lock`), плюс rollback:
|
|
1024
1154
|
|
|
1025
1155
|
```ts
|
|
1156
|
+
const [цУкл] = await db.Услуга({ name: 'Укладка' }).цена().create({ amounts: { RUB: 700 } }).rows() // базовая цена
|
|
1026
1157
|
const tr = await db.begin()
|
|
1027
|
-
await tr
|
|
1028
|
-
await tr
|
|
1029
|
-
await tr.rollback() // [
|
|
1030
|
-
await db
|
|
1158
|
+
await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows()
|
|
1159
|
+
await tr.цена(цУкл).first() // внутри tx → amounts.RUB = 9900
|
|
1160
|
+
await tr.rollback() // [1.1 ms]
|
|
1161
|
+
await db.цена(цУкл).first() // снаружи → amounts.RUB = 700 — изменение исчезло
|
|
1031
1162
|
```
|
|
1032
1163
|
|
|
1033
1164
|
#### `db.commit(tr): Promise<void>` / `db.rollback(tr): Promise<void>`
|
|
@@ -1042,7 +1173,7 @@ await db.Услуга('…0922').first() // снаружи → price.RUB = 700
|
|
|
1042
1173
|
**Примеры**
|
|
1043
1174
|
|
|
1044
1175
|
```ts
|
|
1045
|
-
await db.commit(tr3) // [
|
|
1176
|
+
await db.commit(tr3) // [4.0 ms] — то же, что tr3.commit()
|
|
1046
1177
|
await db.commit()
|
|
1047
1178
|
// Error: letopis: commit() needs a transaction: db.commit(tr) or tr.commit() [0.1 ms]
|
|
1048
1179
|
```
|
|
@@ -1068,14 +1199,14 @@ Tombstone-версия приходит с `deleted: true`.
|
|
|
1068
1199
|
**Примеры**
|
|
1069
1200
|
|
|
1070
1201
|
```ts
|
|
1071
|
-
const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [
|
|
1072
|
-
await db.Организация('…0901').Клиент().
|
|
1073
|
-
await db.Клиент('…0932').
|
|
1202
|
+
const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [17.9 ms]
|
|
1203
|
+
await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр §11', phone: '+7 900 …' }).rows()
|
|
1204
|
+
await db.Клиент('…0932').update({ preferred_contact: 'email' }).rows()
|
|
1074
1205
|
await db.Клиент('…0932').delete({ confirm: true }).rows()
|
|
1075
1206
|
// пойманные (3 события: insert → update → tombstone):
|
|
1076
|
-
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…
|
|
1077
|
-
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…
|
|
1078
|
-
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…
|
|
1207
|
+
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.912278+00:00', deleted: false }
|
|
1208
|
+
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.921929+00:00', deleted: false }
|
|
1209
|
+
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…56.944767+00:00', deleted: true }
|
|
1079
1210
|
await stop()
|
|
1080
1211
|
```
|
|
1081
1212
|
|
|
@@ -1087,8 +1218,8 @@ const stop = await db.watch('Клиент', (e) => события.push(e), {
|
|
|
1087
1218
|
})
|
|
1088
1219
|
// авария: pg_terminate_backend по LISTEN-соединению …
|
|
1089
1220
|
// onReconnect сработал через 0.1 s после обрыва (реальный прогон)
|
|
1090
|
-
await db
|
|
1091
|
-
// событие после reconnect: { class: 'Customer', id: '…0932', updated: '…
|
|
1221
|
+
await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр после обрыва', phone: '+7 900 …' }).rows() // create по id — воскрешение
|
|
1222
|
+
// событие после reconnect: { class: 'Customer', id: '…0932', updated: '…43.273398+00:00', deleted: false }
|
|
1092
1223
|
await stop()
|
|
1093
1224
|
```
|
|
1094
1225
|
|
|
@@ -1098,7 +1229,7 @@ await stop()
|
|
|
1098
1229
|
падают ошибкой postgres.js.
|
|
1099
1230
|
|
|
1100
1231
|
```ts
|
|
1101
|
-
await db.close() // [1.
|
|
1232
|
+
await db.close() // [1.3 ms]
|
|
1102
1233
|
```
|
|
1103
1234
|
|
|
1104
1235
|
**Кейс** — завершение процесса: `close()` в `finally`/`SIGTERM`-хендлере после `stop()`
|
|
@@ -1110,7 +1241,7 @@ await db.close() // [1.7 ms]
|
|
|
1110
1241
|
и поле `all` — § 11.11.
|
|
1111
1242
|
|
|
1112
1243
|
```ts
|
|
1113
|
-
db.registry.resolve('
|
|
1244
|
+
db.registry.resolve('запись') // [99 µs] → ClassDef {id: 'booking', alias: 'запись', …}
|
|
1114
1245
|
```
|
|
1115
1246
|
|
|
1116
1247
|
#### `db.sql`
|
|
@@ -1119,8 +1250,8 @@ db.registry.resolve('Запись') // [90 µs] → ClassDef {id: 'Booking', a
|
|
|
1119
1250
|
Ответственность за SQL — на вызывающем (движок цепочек его не проверяет).
|
|
1120
1251
|
|
|
1121
1252
|
```ts
|
|
1122
|
-
await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.
|
|
1123
|
-
// [
|
|
1253
|
+
await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.salondemo"."Entity"')
|
|
1254
|
+
// [37.3 ms] → [{ n: 980216 }]
|
|
1124
1255
|
```
|
|
1125
1256
|
|
|
1126
1257
|
**Кейс** — снятие плана тяжёлого запроса: `db.sql.unsafe('EXPLAIN (ANALYZE) …')` для
|
|
@@ -1146,14 +1277,14 @@ await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.article"."Entity"')
|
|
|
1146
1277
|
|
|
1147
1278
|
**Назначение и алгоритм.** Продолжение пути по связям: прямой переход (`e.id =
|
|
1148
1279
|
prev.links->>'Класс'`) кладётся точным равенством, обратный — containment
|
|
1149
|
-
`links @> {prevClass: prevId}` в кандидаты + перепроверку. В
|
|
1150
|
-
|
|
1280
|
+
`links @> {prevClass: prevId}` в кандидаты + перепроверку. В цепочке записи шаги до
|
|
1281
|
+
операции — **контекст** (§ 11.5).
|
|
1151
1282
|
|
|
1152
1283
|
**Примеры**
|
|
1153
1284
|
|
|
1154
1285
|
```ts
|
|
1155
|
-
await db.Организация('…
|
|
1156
|
-
await db.навык().Услуга().count()
|
|
1286
|
+
await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12 (обратный hop)
|
|
1287
|
+
await db.навык().Услуга().count() // [65.2 ms] → 1260 (LINK → HUB, прямой)
|
|
1157
1288
|
```
|
|
1158
1289
|
|
|
1159
1290
|
**Кейс: маршрут «мастер → его навыки → услуги»** — см. `.run()` ниже (тот же прогон).
|
|
@@ -1170,20 +1301,21 @@ await db.навык().Услуга().count() // [57.2 ms] →
|
|
|
1170
1301
|
**Примеры**
|
|
1171
1302
|
|
|
1172
1303
|
```ts
|
|
1173
|
-
await db
|
|
1174
|
-
// →
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
// }
|
|
1304
|
+
await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
|
|
1305
|
+
// → 2 пути (у Ольги 0.0 два навыка на разные услуги); первый:
|
|
1306
|
+
// [{
|
|
1307
|
+
// Мастер: { id: '00000003-…-000', class: 'Staff', data: { name: 'Ольга 0.0', phone: '+7 921 0000000', specialization: 'парикмахер' }, links: { Org: '00000001-…-000' }, … },
|
|
1308
|
+
// навык: { id: '00000012-…-000', class: 'skill', data: { level: 'basic' }, links: { Staff: '00000003-…-000', Service: '00000004-…-000' }, … },
|
|
1309
|
+
// Услуга: { id: '00000004-…-000', class: 'Service', data: { name: 'Стрижка 0', duration: 30, description: 'популярное' }, links: { Org: '00000001-…-000' }, … }
|
|
1310
|
+
// }, …] — по объекту на путь, полные узлы каждого шага
|
|
1179
1311
|
```
|
|
1180
1312
|
|
|
1181
1313
|
**Кейс: отчёт «кто что умеет» одним запросом**
|
|
1182
1314
|
|
|
1183
1315
|
```ts
|
|
1184
|
-
const пути = await db
|
|
1185
|
-
пути.map((p) => `${p
|
|
1186
|
-
// → ['
|
|
1316
|
+
const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [18.8 ms]
|
|
1317
|
+
пути.map((p) => `${p.Мастер.data.name} → ${p.Услуга.data.name}`)
|
|
1318
|
+
// → ['Ольга 0.0 → Стрижка 0', 'Ольга 0.0 → Массаж 7']
|
|
1187
1319
|
```
|
|
1188
1320
|
|
|
1189
1321
|
#### `.rows(): Promise<Row[]>`
|
|
@@ -1196,17 +1328,18 @@ const пути = await db.Сотрудник({ name: 'Вася' }).навык().
|
|
|
1196
1328
|
**Примеры**
|
|
1197
1329
|
|
|
1198
1330
|
```ts
|
|
1199
|
-
const услуги = await db.Услуга().rows() // [
|
|
1200
|
-
// [0] = { id: '
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
1331
|
+
const услуги = await db.Услуга().rows() // [11.1 ms] → 600 Row
|
|
1332
|
+
// [0] = { id: '00000004-0000-4000-8000-000000000000', class: 'Service',
|
|
1333
|
+
// data: { name: 'Стрижка 0', duration: 30, description: 'популярное' },
|
|
1334
|
+
// links: { Org: '00000001-0000-4000-8000-000000000000' }, tags: [],
|
|
1335
|
+
// updated: '2025-08-21T10:18:02.707+00:00' }
|
|
1203
1336
|
```
|
|
1204
1337
|
|
|
1205
1338
|
**Кейс: пути vs уникальные сущности**
|
|
1206
1339
|
|
|
1207
1340
|
```ts
|
|
1208
|
-
await db.навык().Услуга().count() // [
|
|
1209
|
-
(await db.навык().Услуга().rows()).length // [
|
|
1341
|
+
await db.навык().Услуга().count() // [65.2 ms] → 1260 путей (навык → услуга)
|
|
1342
|
+
(await db.навык().Услуга().rows()).length // [83.6 ms] → 600 уникальных услуг
|
|
1210
1343
|
```
|
|
1211
1344
|
|
|
1212
1345
|
#### `.first(): Promise<Row | null>`
|
|
@@ -1214,10 +1347,10 @@ await db.навык().Услуга().count() // [57.2 ms] → 1001 пу
|
|
|
1214
1347
|
Параметров нет. То же, что `rows()` с `LIMIT 1`: первая строка или `null`.
|
|
1215
1348
|
|
|
1216
1349
|
```ts
|
|
1217
|
-
await db
|
|
1218
|
-
// → { id: '…
|
|
1219
|
-
// links: { Org: '…
|
|
1220
|
-
await db
|
|
1350
|
+
await db.Мастер({ name: 'Олег 0.7' }).first() // [5.6 ms]
|
|
1351
|
+
// → { id: '…0007', class: 'Staff', data: { name: 'Олег 0.7', phone: '+7 921 0000007', specialization: 'колорист' },
|
|
1352
|
+
// links: { Org: '…0000' }, tags: [], updated: '2025-08-01T00:00:00+00:00' }
|
|
1353
|
+
await db.Мастер({ name: 'Гэндальф' }).first() // [5.7 ms] → null
|
|
1221
1354
|
```
|
|
1222
1355
|
|
|
1223
1356
|
**Кейс: проверка «занято ли окно» перед бронью** — § 11.6 (перечитка под локом).
|
|
@@ -1228,8 +1361,8 @@ await db.Сотрудник({ name: 'Гэндальф' }).first() // [3.9 ms]
|
|
|
1228
1361
|
(дешевле по трафику).
|
|
1229
1362
|
|
|
1230
1363
|
```ts
|
|
1231
|
-
await db
|
|
1232
|
-
// ['
|
|
1364
|
+
await db.Мастер().ids() // [4.3 ms] → 360 id
|
|
1365
|
+
// ['00000003-0000-4000-8000-000000000000', '…0001', '00000003-0000-4000-8000-000000000002', …]
|
|
1233
1366
|
```
|
|
1234
1367
|
|
|
1235
1368
|
**Кейс: набор id для батч-обработки** — собрать `ids()`, скормить очереди задач; полные
|
|
@@ -1240,21 +1373,21 @@ await db.Сотрудник().ids() // [3.8 ms] → 302 id
|
|
|
1240
1373
|
Параметров нет.
|
|
1241
1374
|
|
|
1242
1375
|
**Назначение и алгоритм.** `SELECT count(*)` поверх соединения шагов — считает **пути**
|
|
1243
|
-
(как `
|
|
1376
|
+
(как `run()`), не уникальные сущности: для цепочки из одного класса это одно и то же,
|
|
1244
1377
|
для многошаговой — нет (см. кейс `.rows()`).
|
|
1245
1378
|
|
|
1246
1379
|
```ts
|
|
1247
|
-
await db.Организация('…
|
|
1248
|
-
await db
|
|
1249
|
-
await db
|
|
1250
|
-
// [
|
|
1380
|
+
await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12
|
|
1381
|
+
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [37.4 ms] → 630
|
|
1382
|
+
await db.Локация({ coordinates: { lat: gte(55.5) } }).count()
|
|
1383
|
+
// [4.4 ms] → 26 — оператор на листе record-пути (глубина 2), каст numeric по Schema
|
|
1251
1384
|
```
|
|
1252
1385
|
|
|
1253
1386
|
**Кейс: витрина каталога** — счётчики к фильтрам без выборки строк:
|
|
1254
1387
|
|
|
1255
1388
|
```ts
|
|
1256
|
-
await db.Услуга({ duration: lte(45) }).count() // [3.
|
|
1257
|
-
await db
|
|
1389
|
+
await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300 «быстрые»
|
|
1390
|
+
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [37.4 ms] → 630 «премиум»
|
|
1258
1391
|
```
|
|
1259
1392
|
|
|
1260
1393
|
#### `.limit(n): Chain` / `.offset(n): Chain`
|
|
@@ -1270,10 +1403,10 @@ await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 3
|
|
|
1270
1403
|
**Примеры**
|
|
1271
1404
|
|
|
1272
1405
|
```ts
|
|
1273
|
-
await db
|
|
1274
|
-
// → [{
|
|
1275
|
-
await db
|
|
1276
|
-
// → [{ RUB:
|
|
1406
|
+
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms]
|
|
1407
|
+
// → [{ note: 'базовая', RUB: 8100 }, { note: 'базовая', RUB: 8000 }, { note: 'базовая', RUB: 7900 }]
|
|
1408
|
+
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows() // [19.5 ms]
|
|
1409
|
+
// → [{ RUB: 7800 }, { RUB: 7700 }, { RUB: 7600 }] — вторая страница
|
|
1277
1410
|
```
|
|
1278
1411
|
|
|
1279
1412
|
**Кейс: классическая пагинация страницы каталога** — `limit(3)` + `offset(3·N)`; при выходе
|
|
@@ -1283,21 +1416,21 @@ await db.Услуга().sort('data.price.RUB', 'desc').limit(3).offset(3).rows()
|
|
|
1283
1416
|
|
|
1284
1417
|
| Параметр | Тип | Описание |
|
|
1285
1418
|
|---|---|---|
|
|
1286
|
-
| `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.
|
|
1419
|
+
| `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.coordinates.lat`); SQL-каст по типу листа из Schema |
|
|
1287
1420
|
| `dir` | `'asc'` \| `'desc'` \| `boolean?` | default `asc`; `true` ≡ `'desc'` |
|
|
1288
1421
|
|
|
1289
1422
|
**Назначение и алгоритм.** `ORDER BY` по колонке `updated` или по выражению
|
|
1290
|
-
`data->'
|
|
1423
|
+
`data->'coordinates'->>'lat'` с кастом (numeric/text/timestamptz — из типа листа в Schema).
|
|
1291
1424
|
Обязателен для `.after()`.
|
|
1292
1425
|
|
|
1293
1426
|
**Примеры**
|
|
1294
1427
|
|
|
1295
1428
|
```ts
|
|
1296
|
-
await db
|
|
1297
|
-
await db.Услуга().sort('data.duration').limit(2).rows()
|
|
1298
|
-
// → [{ name: '
|
|
1299
|
-
await db
|
|
1300
|
-
// ЧЕСТНО: DISTINCT ON всех
|
|
1429
|
+
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [31.1 ms] → 8100, 8000, 7900
|
|
1430
|
+
await db.Услуга().sort('data.duration').limit(2).rows() // [7.5 ms] asc по умолчанию
|
|
1431
|
+
// → [{ name: 'Стрижка 0', duration: 30 }, { name: 'Педикюр 4', duration: 30 }]
|
|
1432
|
+
await db.запись().sort('updated', 'desc').limit(2).rows() // [6232.3 ms]
|
|
1433
|
+
// ЧЕСТНО: DISTINCT ON всех ~440k сущностей класса без фильтра — см. § 14
|
|
1301
1434
|
```
|
|
1302
1435
|
|
|
1303
1436
|
**Кейс: топ прайса** — первый пример; правило объёма: сортировка **всего** большого класса
|
|
@@ -1317,15 +1450,15 @@ await db.Запись().sort('updated', 'desc').limit(2).rows() // [161
|
|
|
1317
1450
|
**Примеры**
|
|
1318
1451
|
|
|
1319
1452
|
```ts
|
|
1320
|
-
const истор = await db
|
|
1321
|
-
await db
|
|
1322
|
-
// → data.
|
|
1323
|
-
await db
|
|
1324
|
-
// → data.
|
|
1453
|
+
const истор = await db.цена('…0009').versions() // [7.7 ms] (для t1 ниже)
|
|
1454
|
+
await db.цена('…0009').asOf(истор[0].updated).first() // [3.0 ms]
|
|
1455
|
+
// → data.amounts.RUB = 2550 — цена ТОГДА
|
|
1456
|
+
await db.цена('…0009').first()
|
|
1457
|
+
// → data.amounts.RUB = 2650 — цена сейчас
|
|
1325
1458
|
```
|
|
1326
1459
|
|
|
1327
1460
|
**Кейс: спор по чеку** — «сколько стоила стрижка в момент оформления записи»:
|
|
1328
|
-
`db
|
|
1461
|
+
`db.цена(id).asOf(запись.updated).first()` → исторический прайс без отдельных таблиц аудита.
|
|
1329
1462
|
|
|
1330
1463
|
#### `.versions(): Promise<Row[]>`
|
|
1331
1464
|
|
|
@@ -1339,14 +1472,12 @@ await db.Услуга('…0021').first()
|
|
|
1339
1472
|
**Примеры**
|
|
1340
1473
|
|
|
1341
1474
|
```ts
|
|
1342
|
-
await db
|
|
1343
|
-
// → [{
|
|
1344
|
-
//
|
|
1345
|
-
|
|
1346
|
-
//
|
|
1347
|
-
// {
|
|
1348
|
-
// { status: 'confirmed', updated: '…33.652394', $deleted: true }, ← tombstone
|
|
1349
|
-
// { status: 'created', updated: '…33.822616' }] ← воскрешение
|
|
1475
|
+
await db.цена('…0009').versions() // [7.7 ms]
|
|
1476
|
+
// → [{ RUB: 2550, updated: '2025-08-23T…' }, { RUB: 2650, updated: '2025-11-21T…' }]
|
|
1477
|
+
await db.Клиент('…0931').versions() // жизнь с удалением и воскрешением:
|
|
1478
|
+
// → [{ name: 'Злата', updated: '…54.159646' },
|
|
1479
|
+
// { name: 'Злата', updated: '…55.627712', $deleted: true }, ← tombstone
|
|
1480
|
+
// { name: 'Злата', updated: '…55.678666' }] ← воскрешение (create по тому же id)
|
|
1350
1481
|
```
|
|
1351
1482
|
|
|
1352
1483
|
**Кейс: аудит «кто когда менял»** — `versions()` + `owner` каждой версии = полный
|
|
@@ -1368,18 +1499,19 @@ await db.Запись('…0061').versions() // [6.7 ms] — жизнь с уд
|
|
|
1368
1499
|
**Примеры**
|
|
1369
1500
|
|
|
1370
1501
|
```ts
|
|
1371
|
-
const p1 = await db
|
|
1372
|
-
const кур = cursorOf(p1.at(-1)) // [
|
|
1373
|
-
// → { v: '2026-07-
|
|
1374
|
-
const p2 = await db
|
|
1502
|
+
const p1 = await db.запись().sort('updated', 'desc').limit(3).rows() // [5632.0 ms] — ~440k
|
|
1503
|
+
const кур = cursorOf(p1.at(-1)) // [82 µs]
|
|
1504
|
+
// → { v: '2026-07-25T18:00:00+00:00', id: '00000009-0000-4000-8000-000000431198' }
|
|
1505
|
+
const p2 = await db.запись().sort('updated', 'desc').after(кур).limit(3).rows() // [5680.4 ms]
|
|
1375
1506
|
// p2 — следующие 3, пересечение страниц: 0
|
|
1507
|
+
// (у тысяч записей последнего дня updated совпадает — id вторым ключом ORDER BY держит границу)
|
|
1376
1508
|
|
|
1377
|
-
const курЦены = cursorOf(топ3.at(-1), 'data.
|
|
1378
|
-
await db
|
|
1379
|
-
// →
|
|
1509
|
+
const курЦены = cursorOf(топ3.at(-1), 'data.amounts.RUB') // [41 µs] → { v: 7900, id: '…0951' }
|
|
1510
|
+
await db.цена().sort('data.amounts.RUB', 'desc').after(курЦены).limit(3).rows() // [20.5 ms]
|
|
1511
|
+
// → 7800, 7700, 7600
|
|
1380
1512
|
|
|
1381
|
-
await db
|
|
1382
|
-
// Error: letopis: .after(cursor) requires .sort(field) [0.
|
|
1513
|
+
await db.запись().after(кур).rows()
|
|
1514
|
+
// Error: letopis: .after(cursor) requires .sort(field) [0.3 ms]
|
|
1383
1515
|
```
|
|
1384
1516
|
|
|
1385
1517
|
**Кейс: бесконечная лента записей**
|
|
@@ -1387,7 +1519,7 @@ await db.Запись().after(кур).rows()
|
|
|
1387
1519
|
```ts
|
|
1388
1520
|
let кур
|
|
1389
1521
|
for (;;) {
|
|
1390
|
-
let q = db
|
|
1522
|
+
let q = db.запись().sort('updated', 'desc').limit(100)
|
|
1391
1523
|
if (кур) q = q.after(кур)
|
|
1392
1524
|
const стр = await q.rows()
|
|
1393
1525
|
if (!стр.length) break
|
|
@@ -1410,19 +1542,22 @@ for (;;) {
|
|
|
1410
1542
|
**Примеры**
|
|
1411
1543
|
|
|
1412
1544
|
```ts
|
|
1413
|
-
await db.Папка('…
|
|
1414
|
-
// → [{ name: 'Мужской зал', $depth: 1 }, { name: '
|
|
1415
|
-
|
|
1416
|
-
//
|
|
1545
|
+
await db.Папка('…0000').Папка().deep().rows() // [9.2 ms]
|
|
1546
|
+
// → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 },
|
|
1547
|
+
// { name: 'Борода и усы', $depth: 2 }, { name: 'Уход', $depth: 3 }]
|
|
1548
|
+
await db.Папка('…0000').Папка().deep(1).rows() // [7.4 ms] только прямые дети
|
|
1549
|
+
// → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 }]
|
|
1417
1550
|
```
|
|
1418
1551
|
|
|
1419
1552
|
**Кейс: хлебные крошки каталога** — дерево одним запросом, глубина из `$depth`:
|
|
1420
1553
|
|
|
1421
1554
|
```ts
|
|
1422
|
-
const дерево = await db.Папка(корень).Папка().deep().rows() // [
|
|
1555
|
+
const дерево = await db.Папка(корень).Папка().deep().rows() // [9.2 ms]
|
|
1423
1556
|
дерево.map((p) => `${' '.repeat(p.$depth)}${p.data.name}`)
|
|
1424
1557
|
// → Мужской зал
|
|
1558
|
+
// Женский зал
|
|
1425
1559
|
// Борода и усы
|
|
1560
|
+
// Уход
|
|
1426
1561
|
```
|
|
1427
1562
|
|
|
1428
1563
|
#### `.sum(field)` / `.avg(field): Promise<number | null>`
|
|
@@ -1438,14 +1573,14 @@ const дерево = await db.Папка(корень).Папка().deep().rows(
|
|
|
1438
1573
|
**Примеры**
|
|
1439
1574
|
|
|
1440
1575
|
```ts
|
|
1441
|
-
await db
|
|
1442
|
-
await db.Услуга().avg('data.duration')
|
|
1443
|
-
await db
|
|
1444
|
-
await db.Услуга({ name: 'НетТакой' }).sum('data.duration')
|
|
1576
|
+
await db.цена().sum('data.amounts.RUB') // [13.2 ms] → 2277000
|
|
1577
|
+
await db.Услуга().avg('data.duration') // [3.0 ms] → 52.5
|
|
1578
|
+
await db.Локация({}).sum('data.coordinates.lat') // [3.9 ms] лист record-пути (глубина 2) → 3327.925547539955
|
|
1579
|
+
await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [5.0 ms] → null (пусто)
|
|
1445
1580
|
```
|
|
1446
1581
|
|
|
1447
|
-
**Кейс:
|
|
1448
|
-
за
|
|
1582
|
+
**Кейс: итог по каталогу без выгрузки строк** — `sum('data.amounts.RUB')` по 405 ценам за 4 ms;
|
|
1583
|
+
на полигоне salondemo — 1026 цен на 2 277 000 за ≈13 ms (§ 14). Строки в приложение не едут.
|
|
1449
1584
|
|
|
1450
1585
|
#### `.min(field)` / `.max(field): Promise<unknown>`
|
|
1451
1586
|
|
|
@@ -1453,8 +1588,8 @@ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') /
|
|
|
1453
1588
|
**числом**, string — строкой.
|
|
1454
1589
|
|
|
1455
1590
|
```ts
|
|
1456
|
-
await db
|
|
1457
|
-
await db
|
|
1591
|
+
await db.цена().min('data.amounts.RUB') // [15.0 ms] → 300 (число, не '300')
|
|
1592
|
+
await db.цена().max('data.amounts.RUB') // [14.8 ms] → 8100
|
|
1458
1593
|
```
|
|
1459
1594
|
|
|
1460
1595
|
**Кейс: границы ценового слайдера** — `min` + `max` двумя запросами по 3–4 ms.
|
|
@@ -1469,24 +1604,25 @@ await db.Услуга().max('data.price.RUB') // [4.2 ms] → 1800
|
|
|
1469
1604
|
объекта = значения поля, значения = счётчики (по путям).
|
|
1470
1605
|
|
|
1471
1606
|
```ts
|
|
1472
|
-
await db
|
|
1473
|
-
// → {
|
|
1607
|
+
await db.запись().countBy('data.notes') // [3266.3 ms] — ~440k сущностей
|
|
1608
|
+
// → { 'подтверждена': 400000, 'по телефону: Вера': 3334, 'по телефону: Ольга': 3334, …,
|
|
1609
|
+
// 'по телефону: Марина': 3333 } — 12 имён «по телефону» по ~3333 (ручные брони ~9%)
|
|
1474
1610
|
```
|
|
1475
1611
|
|
|
1476
|
-
**Кейс: дашборд
|
|
1612
|
+
**Кейс: дашборд по заметкам записей** — один запрос вместо N `count()`; ~440k строк агрегирует БД.
|
|
1477
1613
|
|
|
1478
1614
|
#### `.alias(name): Chain`
|
|
1479
1615
|
|
|
1480
1616
|
| Параметр | Тип | Описание |
|
|
1481
1617
|
|---|---|---|
|
|
1482
|
-
| `name` | `string` | ключ ТЕКУЩЕГО шага в объектах-путях `
|
|
1618
|
+
| `name` | `string` | ключ ТЕКУЩЕГО шага в объектах-путях `run()` |
|
|
1483
1619
|
|
|
1484
1620
|
**Назначение.** Переименование ключа шага в выводе (данные и SQL не меняются) — удобно,
|
|
1485
1621
|
когда один класс встречается в пути дважды.
|
|
1486
1622
|
|
|
1487
1623
|
```ts
|
|
1488
|
-
await db.Организация(org).alias('салон')
|
|
1489
|
-
// [6.
|
|
1624
|
+
await db.Организация(org).alias('салон').Мастер({ name: 'Ольга 0.0' }).alias('мастер').run()
|
|
1625
|
+
// [6.7 ms] → ключи пути: ['салон', 'мастер']
|
|
1490
1626
|
```
|
|
1491
1627
|
|
|
1492
1628
|
**Кейс: self-join читаемо** — `db.Папка(a).alias('родитель').Папка().alias('дочка').run()`.
|
|
@@ -1495,14 +1631,14 @@ await db.Организация(org).alias('салон').Сотрудник({ na
|
|
|
1495
1631
|
|
|
1496
1632
|
| Параметр | Тип | Описание |
|
|
1497
1633
|
|---|---|---|
|
|
1498
|
-
| `v` | `string \| string[] \| has/hasAny/hasAll` | чтение: фильтр колонки `tags` (строка ≡ содержит; массив ≡ содержит все); в
|
|
1634
|
+
| `v` | `string \| string[] \| has/hasAny/hasAll` | чтение: фильтр колонки `tags` (строка ≡ содержит; массив ≡ содержит все); в цепочке записи — **значение** тегов `create()`-INSERT |
|
|
1499
1635
|
|
|
1500
1636
|
**Назначение и алгоритм.** Фильтр по массивной колонке `tags` (`@>` — GIN-индекс,
|
|
1501
1637
|
кандидаты + перепроверка). В записи — модификатор значения.
|
|
1502
1638
|
|
|
1503
1639
|
```ts
|
|
1504
|
-
await db.Клиент().tags('vip').count()
|
|
1505
|
-
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [
|
|
1640
|
+
await db.Клиент().tags('vip').count() // [24.7 ms] → 400 (vip-клиенты)
|
|
1641
|
+
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
|
|
1506
1642
|
```
|
|
1507
1643
|
|
|
1508
1644
|
**Кейс: пометить и найти** — § 5 (вставка с `.tags(['vip','telegram'])`, поиск `tags('vip')`);
|
|
@@ -1512,15 +1648,15 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms]
|
|
|
1512
1648
|
|
|
1513
1649
|
| Параметр | Тип | Описание |
|
|
1514
1650
|
|---|---|---|
|
|
1515
|
-
| `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`;
|
|
1651
|
+
| `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`; при `create()` — значение колонки |
|
|
1516
1652
|
|
|
1517
1653
|
**Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree). Под `enforceAccount`
|
|
1518
1654
|
чужой `.account()` — ошибка; под `enforceAcl` конфликт с пришпиленной правилом колонкой —
|
|
1519
1655
|
ошибка `acl pins` (§ 11.10).
|
|
1520
1656
|
|
|
1521
1657
|
```ts
|
|
1522
|
-
await db.Организация().account(SYS).count() // [
|
|
1523
|
-
await db.Организация().owner(SYS).count()
|
|
1658
|
+
await db.Организация().account(SYS).count() // [5.1 ms] → 30
|
|
1659
|
+
await db.Организация().owner(SYS).count() // [3.9 ms] → 30
|
|
1524
1660
|
```
|
|
1525
1661
|
|
|
1526
1662
|
**Кейс: чей это салон** — профиль владельца строки: `db.accounts.get(row.owner)`; выборка
|
|
@@ -1529,17 +1665,17 @@ await db.Организация().owner(SYS).count() // [3.4 ms] → 21
|
|
|
1529
1665
|
### 11.4 Операторы фильтров
|
|
1530
1666
|
|
|
1531
1667
|
18 функций-операторов: каждая возвращает объект-условие `Op` для значения поля в фильтре
|
|
1532
|
-
шага (`{ duration: gte(60) }`), включая record-пути (`{
|
|
1668
|
+
шага (`{ duration: gte(60) }`), включая record-пути (`{ coordinates: { lat: gte(55.5) } }` —
|
|
1533
1669
|
условие на листе любой глубины, SQL-каст по типу листа из Schema). Компилируются в
|
|
1534
1670
|
выражение на актуальной строке (`(data->>'duration')::numeric >= 60`); containment-части
|
|
1535
|
-
дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (
|
|
1671
|
+
дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (600 сущностей).
|
|
1536
1672
|
|
|
1537
1673
|
#### `ne(v): Op`
|
|
1538
1674
|
|
|
1539
1675
|
`v: string | number | boolean | null` — «не равно» (`IS DISTINCT FROM` — null-безопасно).
|
|
1540
1676
|
|
|
1541
1677
|
```ts
|
|
1542
|
-
await db.Услуга({ name: ne('Стрижка') }).count() // [
|
|
1678
|
+
await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms] → 570
|
|
1543
1679
|
```
|
|
1544
1680
|
|
|
1545
1681
|
**Кейс:** всё, кроме выбранного, — «другие услуги» под карточкой текущей.
|
|
@@ -1549,30 +1685,30 @@ await db.Услуга({ name: ne('Стрижка') }).count() // [3.4 ms] →
|
|
|
1549
1685
|
`v: number | string` — строго больше / больше-или-равно (числа и сравнимые строки-даты).
|
|
1550
1686
|
|
|
1551
1687
|
```ts
|
|
1552
|
-
await db.Услуга({ duration: gt(60) }).count() // [
|
|
1553
|
-
await db.Услуга({ duration: gte(60) }).count() // [4.
|
|
1688
|
+
await db.Услуга({ duration: gt(60) }).count() // [6.4 ms] → 150
|
|
1689
|
+
await db.Услуга({ duration: gte(60) }).count() // [4.3 ms] → 300
|
|
1554
1690
|
```
|
|
1555
1691
|
|
|
1556
1692
|
**Кейс:** граница включительно или нет — «от часа» это `gte(60)`; `gt(60)` потеряет
|
|
1557
|
-
ровно-часовые (
|
|
1693
|
+
ровно-часовые (300 vs 150).
|
|
1558
1694
|
|
|
1559
1695
|
#### `lt(v): Op` / `lte(v): Op`
|
|
1560
1696
|
|
|
1561
1697
|
`v: number | string` — строго меньше / меньше-или-равно.
|
|
1562
1698
|
|
|
1563
1699
|
```ts
|
|
1564
|
-
await db.Услуга({ duration: lt(45) }).count() // [
|
|
1565
|
-
await db.Услуга({ duration: lte(45) }).count() // [3.
|
|
1700
|
+
await db.Услуга({ duration: lt(45) }).count() // [5.1 ms] → 150
|
|
1701
|
+
await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300
|
|
1566
1702
|
```
|
|
1567
1703
|
|
|
1568
|
-
**Кейс:** «экспресс до 45 минут включительно» = `lte(45)` →
|
|
1704
|
+
**Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 300 услуг.
|
|
1569
1705
|
|
|
1570
1706
|
#### `between(a, b): Op`
|
|
1571
1707
|
|
|
1572
1708
|
`a, b: number | string` — диапазон включительно (`a ≤ x ≤ b`).
|
|
1573
1709
|
|
|
1574
1710
|
```ts
|
|
1575
|
-
await db.Услуга({ duration: between(40, 65) }).count() // [
|
|
1711
|
+
await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 300
|
|
1576
1712
|
```
|
|
1577
1713
|
|
|
1578
1714
|
**Кейс:** слайдер длительности «40–65 минут» одной функцией вместо пары gte+lte.
|
|
@@ -1582,7 +1718,7 @@ await db.Услуга({ duration: between(40, 65) }).count() // [3.6 ms] → 2
|
|
|
1582
1718
|
`vs: (string | number)[]` — значение из списка (`IN`).
|
|
1583
1719
|
|
|
1584
1720
|
```ts
|
|
1585
|
-
await db.Услуга({ name: inList(['Стрижка', '
|
|
1721
|
+
await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [4.4 ms] → 60
|
|
1586
1722
|
```
|
|
1587
1723
|
|
|
1588
1724
|
**Кейс:** сравнение выбранных чекбоксами услуг: имена из UI → один запрос.
|
|
@@ -1592,72 +1728,73 @@ await db.Услуга({ name: inList(['Стрижка', 'Услуга 7']) }).co
|
|
|
1592
1728
|
`s: string` — SQL-шаблон (`%` — любое, `_` — один символ); `ilike` — без учёта регистра.
|
|
1593
1729
|
|
|
1594
1730
|
```ts
|
|
1595
|
-
await db.Услуга({ name: like('Стри%') }).count() // [3
|
|
1596
|
-
await db.Услуга({ name: ilike('
|
|
1731
|
+
await db.Услуга({ name: like('Стри%') }).count() // [4.3 ms] → 60
|
|
1732
|
+
await db.Услуга({ name: ilike('%массаж%') }).count() // [4.3 ms] → 60
|
|
1597
1733
|
```
|
|
1598
1734
|
|
|
1599
1735
|
**Кейс:** живой поиск в админке — `ilike('%' + ввод + '%')` прощает регистр
|
|
1600
|
-
(
|
|
1736
|
+
(«массаж» находит «Массаж 7», «Массаж 17»).
|
|
1601
1737
|
|
|
1602
1738
|
#### `starts(s): Op` / `ends(s): Op`
|
|
1603
1739
|
|
|
1604
1740
|
`s: string` — начинается с / заканчивается на (сахар над `like(s+'%')` / `like('%'+s)`).
|
|
1605
1741
|
|
|
1606
1742
|
```ts
|
|
1607
|
-
await db.Услуга({ name: starts('
|
|
1608
|
-
await db.Услуга({ name: ends('
|
|
1743
|
+
await db.Услуга({ name: starts('Массаж') }).count() // [3.7 ms] → 60
|
|
1744
|
+
await db.Услуга({ name: ends('7') }).count() // [2.9 ms] → 60
|
|
1609
1745
|
```
|
|
1610
1746
|
|
|
1611
|
-
**Кейс:** префиксная навигация по
|
|
1747
|
+
**Кейс:** префиксная навигация по названию: `starts('Массаж')` → все «Массаж N» (60 в каталоге).
|
|
1612
1748
|
|
|
1613
1749
|
#### `has(v): Op` / `hasAny(vs): Op` / `hasAll(vs): Op`
|
|
1614
1750
|
|
|
1615
|
-
`v: скаляр`, `vs: скаляр[]` —
|
|
1616
|
-
|
|
1751
|
+
`v: скаляр`, `vs: скаляр[]` — массив содержит значение / хотя бы одно / все (containment `@>` —
|
|
1752
|
+
идёт и в GIN-кандидаты). В демо-схеме массивов в `data` нет — операторы показаны на колонке `tags`.
|
|
1617
1753
|
|
|
1618
1754
|
```ts
|
|
1619
|
-
await db
|
|
1620
|
-
await db
|
|
1621
|
-
await db
|
|
1755
|
+
await db.Клиент().tags(has('vip')).count() // [54.5 ms] → 400
|
|
1756
|
+
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms] → 800
|
|
1757
|
+
await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [11.0 ms] → 100
|
|
1622
1758
|
```
|
|
1623
1759
|
|
|
1624
|
-
**Кейс:**
|
|
1625
|
-
|
|
1760
|
+
**Кейс:** сегменты по меткам: «vip ИЛИ из телеграма» = `tags(hasAny(['vip','telegram']))` → 800;
|
|
1761
|
+
«vip И телеграм разом» = `tags(hasAll(['vip','telegram']))` → 100.
|
|
1626
1762
|
|
|
1627
1763
|
#### `exists(yes = true): Op`
|
|
1628
1764
|
|
|
1629
1765
|
`yes: boolean?` — поле присутствует (`true`, default) / отсутствует (`false`) в `data`.
|
|
1630
1766
|
|
|
1631
1767
|
```ts
|
|
1632
|
-
await db.Услуга({ description: exists() }).count() // [3.
|
|
1633
|
-
await db.Услуга({ description: exists(false) }).count() // [2
|
|
1768
|
+
await db.Услуга({ description: exists() }).count() // [3.7 ms] → 390
|
|
1769
|
+
await db.Услуга({ description: exists(false) }).count() // [3.2 ms] → 210
|
|
1634
1770
|
```
|
|
1635
1771
|
|
|
1636
|
-
**Кейс:** контроль заполненности каталога — «услуги без
|
|
1637
|
-
на доработку
|
|
1772
|
+
**Кейс:** контроль заполненности каталога — «услуги без ключа `description`» = `exists(false)` → 210
|
|
1773
|
+
на доработку контенту (не путать с `isNull()` ниже — тот ещё и явные `null` ловит).
|
|
1638
1774
|
|
|
1639
1775
|
#### `isNull(): Op`
|
|
1640
1776
|
|
|
1641
1777
|
Без параметров — поле `NULL` **или** отсутствует.
|
|
1642
1778
|
|
|
1643
1779
|
```ts
|
|
1644
|
-
await db.Услуга({ description: isNull() }).count() // [3.1 ms] →
|
|
1780
|
+
await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
|
|
1645
1781
|
```
|
|
1646
1782
|
|
|
1647
|
-
**Кейс:** отличие от `exists(false)
|
|
1648
|
-
|
|
1783
|
+
**Кейс:** отличие от `exists(false)` — на полигоне видно числом: `isNull()` → 390 (ловит и явный
|
|
1784
|
+
`null` в data, и отсутствие ключа), `exists(false)` → 210 (только отсутствие); расхождение 180 —
|
|
1785
|
+
это услуги с явным `description: null`.
|
|
1649
1786
|
|
|
1650
1787
|
#### `not(v): Op`
|
|
1651
1788
|
|
|
1652
1789
|
`v: скаляр | Op` — отрицание; скаляр ≡ «не равно» (как `ne`).
|
|
1653
1790
|
|
|
1654
1791
|
```ts
|
|
1655
|
-
await db.Услуга({ name: not(starts('
|
|
1656
|
-
await db.Услуга({ name: not('Стрижка') }).count() // [
|
|
1792
|
+
await db.Услуга({ name: not(starts('Стрижка')) }).count() // [3.7 ms] → 540
|
|
1793
|
+
await db.Услуга({ name: not('Стрижка 0') }).count() // [3.5 ms] → 570
|
|
1657
1794
|
```
|
|
1658
1795
|
|
|
1659
|
-
**Кейс:** инверсия готового условия без переписывания: «всё, что НЕ
|
|
1660
|
-
`not(starts('
|
|
1796
|
+
**Кейс:** инверсия готового условия без переписывания: «всё, что НЕ стрижки» =
|
|
1797
|
+
`not(starts('Стрижка'))` → 540 (из 600 услуг 60 — «Стрижка N»).
|
|
1661
1798
|
|
|
1662
1799
|
#### `or(...filters): Op`
|
|
1663
1800
|
|
|
@@ -1665,124 +1802,157 @@ await db.Услуга({ name: not('Стрижка') }).count() // [6.1
|
|
|
1665
1802
|
обычное И).
|
|
1666
1803
|
|
|
1667
1804
|
```ts
|
|
1668
|
-
await db.Услуга(or({ name: 'Стрижка' }, { duration: lt(45) })).count() // [4.0 ms] →
|
|
1805
|
+
await db.Услуга(or({ name: 'Стрижка 0' }, { duration: lt(45) })).count() // [4.0 ms] → 150
|
|
1669
1806
|
```
|
|
1670
1807
|
|
|
1671
|
-
**Кейс:**
|
|
1808
|
+
**Кейс:** «Стрижка 0 или что-нибудь быстрое» — один запрос: Стрижка 0 (duration 30) уже среди
|
|
1809
|
+
быстрых `lt(45)` → объединение = 150, отдельного плюса не даёт.
|
|
1672
1810
|
|
|
1673
|
-
### 11.5 Запись:
|
|
1811
|
+
### 11.5 Запись: create / update / delete / anonymize
|
|
1674
1812
|
|
|
1675
1813
|
Операции — **звенья плана** (§ 6): каждая применяется к шагу, к которому приклеена точкой,
|
|
1676
1814
|
и возвращает цепочку. Исполняет **терминал** — весь план одной транзакцией (внутренней,
|
|
1677
1815
|
с ретраем transient; внутри `db.begin()` — транзакцией пользователя); отказ любого сегмента
|
|
1678
|
-
откатывает всё. Продолжение цепочки — от результата операции.
|
|
1816
|
+
откатывает всё. Продолжение цепочки — от результата операции. Старый `set()` разбит на
|
|
1817
|
+
`create()`/`update()` в 0.16.0 — бросает подсказку.
|
|
1679
1818
|
|
|
1680
|
-
#### `.
|
|
1819
|
+
#### `.create(data?): Chain`
|
|
1681
1820
|
|
|
1682
1821
|
| Параметр | Тип | Описание |
|
|
1683
1822
|
|---|---|---|
|
|
1684
|
-
| `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация: лишний ключ — ошибка) + опциональный `id` —
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
(
|
|
1697
|
-
`owner`/`account` и фильтрует цели); (2) контекст-шаги резолвятся в связи — каждый обязан
|
|
1698
|
-
дать **ровно одну** сущность; (3) поиск целей: INSERT-форма не ищет, id/фильтр — ищут в
|
|
1699
|
-
границах контекста; (4) на каждую цель: **deep-merge** листьев `data`, слияние links
|
|
1700
|
-
(контекст + `.link()`), строгая валидация, **один INSERT новой версии** с
|
|
1701
|
-
`updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) UPSERT по id при пустом поиске —
|
|
1702
|
-
INSERT (воскрешает удалённую; под ACL существующая-но-недоступная **не перехватывается**);
|
|
1703
|
-
(6) конфликт `23505`/transient ретраится транзакцией плана. Сегмент после операции
|
|
1704
|
-
исполняется **для каждой строки её результата** (fan-out); `set` сразу после `set` — новая
|
|
1705
|
-
версия тех же сущностей (self).
|
|
1823
|
+
| `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация: лишний ключ — ошибка) + опциональный `id` (у v5-класса запрещён — id считает схема, § 3.2) |
|
|
1824
|
+
|
|
1825
|
+
**Назначение и алгоритм.** «Чтобы сущность существовала». На терминале: (1) под
|
|
1826
|
+
`enforceAcl` — WRITE-решение по классу (deny — откат; предикат правила пришпиливает
|
|
1827
|
+
`owner`/`account`); (2) владелец создаваемой связки — из валидного пути (каждый
|
|
1828
|
+
контекст-шаг обязан дать **ровно одну** сущность), прочие концы — слотами; (3) id: явный
|
|
1829
|
+
(шаг `Класс(id)` или `data.id`) либо по правилу схемы — v4/v7 генерируются, v5 вычисляется
|
|
1830
|
+
из концов/полей (§ 3.2); (4) id известен и **уже существует** (в границах пути) → **новая
|
|
1831
|
+
версия** с deep-merge — идемпотентный create, REST-PUT семантика (удалённую — воскрешает;
|
|
1832
|
+
под ACL существующая-но-недоступная **не перехватывается** → `[]`); не существует → **один
|
|
1833
|
+
INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конфликт
|
|
1834
|
+
`23505`/transient ретраится транзакцией плана. Фильтр-объект перед create — ошибка
|
|
1835
|
+
ПОСТРОЕНИЯ (`create() takes no filter`); create на pivot-шаге — ошибка.
|
|
1706
1836
|
|
|
1707
1837
|
**Примеры**
|
|
1708
1838
|
|
|
1709
1839
|
```ts
|
|
1710
|
-
await db.Организация().
|
|
1711
|
-
// → [{ id: '
|
|
1840
|
+
await db.Организация().create({ name: 'Пилигрим' }).rows() // [11.1 ms] INSERT + defaults из Schema
|
|
1841
|
+
// → [{ id: '019f5a53-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, links: {}, … }]
|
|
1712
1842
|
|
|
1713
|
-
await db.Организация().
|
|
1843
|
+
await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
|
|
1844
|
+
// [11.4 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
|
|
1714
1845
|
|
|
1715
|
-
await db.Организация('…0901')
|
|
1716
|
-
// [
|
|
1846
|
+
await db.Организация('…0901').Мастер().create({ id: '…0911', name: 'Мия', phone: '+7 909 000-09-11', specialization: 'массажист' }).rows()
|
|
1847
|
+
// [15.2 ms] контекст → links: { Org: '…0901' }
|
|
1717
1848
|
|
|
1718
|
-
await db
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
// deep-merge тронул только листья price; duration/description целы
|
|
1849
|
+
await db.Организация('…0901').Услуга().create({ name: 'Массаж головы', duration: 30, description: 'релакс' }).rows()
|
|
1850
|
+
// [16.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
|
|
1851
|
+
// повторный create той же пары (салон, имя) → новая ВЕРСИЯ (deep-merge листьев), не дубль
|
|
1722
1852
|
|
|
1723
|
-
|
|
1724
|
-
//
|
|
1853
|
+
db.Услуга({ name: 'Массаж головы' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
|
|
1854
|
+
// Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
|
|
1725
1855
|
|
|
1726
|
-
await db
|
|
1727
|
-
// → [{ id: '…0962', status: 'completed' }, { id: '…0963', status: 'completed' }]
|
|
1728
|
-
|
|
1729
|
-
await db.Услуга().set({ name: 'X', чепуха: 1 }).rows() // [1.4 ms] — ошибка НА ТЕРМИНАЛЕ:
|
|
1856
|
+
await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [9.9 ms] — ошибка НА ТЕРМИНАЛЕ:
|
|
1730
1857
|
// ValidationError: letopis: validation failed for "Service":
|
|
1731
|
-
//
|
|
1858
|
+
// The object '' contains forbidden keys: 'чепуха'.
|
|
1859
|
+
|
|
1860
|
+
db.Локация('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
|
|
1861
|
+
// Error: letopis: no path Location → Customer: neither embeds the other (Schema)
|
|
1862
|
+
```
|
|
1863
|
+
|
|
1864
|
+
#### `.update(data?): Chain`
|
|
1865
|
+
|
|
1866
|
+
| Параметр | Тип | Описание |
|
|
1867
|
+
|---|---|---|
|
|
1868
|
+
| `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация); без аргумента — версия без изменения полей (например, ради слотов) |
|
|
1869
|
+
|
|
1870
|
+
**Назначение и алгоритм.** Новая версия **каждого** найденного путём. Цели ищутся как при
|
|
1871
|
+
чтении — id, фильтр, pivot; `Класс()` ≡ `Класс({})` — «все в границах контекста». На каждую
|
|
1872
|
+
цель: **deep-merge** листьев `data` (`update({ coordinates: { lat: 55.8 } })` сохранит `lng` и
|
|
1873
|
+
остальные поля; массивы/скаляры — целиком), слияние links (слоты `.Класс.set()`/`.unset()`),
|
|
1874
|
+
строгая валидация, один INSERT новой версии. Не найдено → `[]` — update **НИКОГДА не
|
|
1875
|
+
создаёт**. Сегмент после операции исполняется **для каждой строки её результата** (fan-out);
|
|
1876
|
+
`update` сразу после `update` — новая версия тех же сущностей (self).
|
|
1877
|
+
|
|
1878
|
+
**Примеры**
|
|
1879
|
+
|
|
1880
|
+
```ts
|
|
1881
|
+
await db.Клиент('…0931').запись({ start_datetime: between(t, t) }).update({ notes: 'подтверждена' }).rows() // [645.4 ms]
|
|
1882
|
+
// → [{ id: '4907d8cd-…', notes: 'подтверждена' }]
|
|
1883
|
+
// момент фильтруется between(t, t): скаляр-eq по date-полю = строковый containment, потому диапазон
|
|
1884
|
+
// (сотни мс: поиск целей идёт по всему классу записей ~440k без btree по data->>'start_datetime' — § 14)
|
|
1885
|
+
|
|
1886
|
+
await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [600.4 ms] — ВСЕ записи в контексте
|
|
1887
|
+
// → [{ id: '4907d8cd-…', notes: 'день закрыт' }, { id: 'e7f16a08-…', notes: 'день закрыт' }]
|
|
1732
1888
|
|
|
1733
|
-
db
|
|
1734
|
-
// Error: letopis: step modifier after set() — add a class step first
|
|
1889
|
+
await db.запись({ notes: 'нет-такого' }).update({ notes: 'x' }).rows() // → [] — update НИКОГДА не создаёт
|
|
1735
1890
|
```
|
|
1736
1891
|
|
|
1737
1892
|
**Кейс: план из нескольких операций — реальный прогон**
|
|
1738
1893
|
|
|
1739
1894
|
```ts
|
|
1740
|
-
// обновить клиента → вставить ему запись (продолжение от записанного, одна транзакция)
|
|
1741
|
-
await db.Клиент('…0931').
|
|
1742
|
-
|
|
1743
|
-
.rows() // [
|
|
1744
|
-
// → [{ id: '…
|
|
1895
|
+
// обновить клиента → вставить ему запись со слотами (продолжение от записанного, одна транзакция)
|
|
1896
|
+
await db.Клиент('…0931').update({ preferred_contact: 'messenger' })
|
|
1897
|
+
.запись().create({ start_datetime: '2026-08-04T07:00:00Z', end_datetime: '2026-08-04T07:30:00Z' })
|
|
1898
|
+
.Мастер.set(мия).Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows() // [20.0 ms]
|
|
1899
|
+
// → [{ id: '…d091', class: 'booking', links: { Staff, Service, Customer: '…0931', Location, Schedule },
|
|
1900
|
+
// data: { start_datetime: '2026-08-04T07:00:00.000Z', end_datetime: '2026-08-04T07:30:00.000Z' } }]
|
|
1745
1901
|
|
|
1746
|
-
// self-
|
|
1747
|
-
await db
|
|
1748
|
-
// → ['
|
|
1902
|
+
// self-update: две версии подряд
|
|
1903
|
+
await db.запись('…d091').update({ notes: 'подтверждена' }).update({ notes: 'выполнена' }).rows() // [19.8 ms]
|
|
1904
|
+
// → ['выполнена']; versions: [null, 'подтверждена', 'выполнена']
|
|
1749
1905
|
|
|
1750
1906
|
// хвост-чтение после операции — в той же транзакции
|
|
1751
|
-
await db.Клиент('…0931').
|
|
1907
|
+
await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [16.7 ms] → 1
|
|
1752
1908
|
|
|
1753
|
-
// ОТКАТ: невалидный
|
|
1754
|
-
await db.Клиент('…0931').
|
|
1755
|
-
// Error: letopis: validation failed for "
|
|
1909
|
+
// ОТКАТ: валидный update + невалидный create — весь план назад
|
|
1910
|
+
await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().create({ чепуха: 1 }).rows()
|
|
1911
|
+
// Error: letopis: validation failed for "booking" … forbidden keys: 'чепуха' [15.5 ms]; клиент не изменился
|
|
1756
1912
|
|
|
1757
1913
|
// fan-out: обновить клиента → снести ВСЕ его записи
|
|
1758
|
-
await db.Клиент('…0931').
|
|
1759
|
-
// [
|
|
1914
|
+
await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().delete({ confirm: true }).rows()
|
|
1915
|
+
// [29.0 ms] → снесено 1 запись, с $deleted: true
|
|
1760
1916
|
```
|
|
1761
1917
|
|
|
1762
|
-
####
|
|
1918
|
+
#### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
|
|
1763
1919
|
|
|
1764
|
-
|
|
|
1765
|
-
|
|
1766
|
-
| `
|
|
1767
|
-
| `
|
|
1920
|
+
| Форма | Описание |
|
|
1921
|
+
|---|---|
|
|
1922
|
+
| `.Класс.set(target)` | значение конца связи новой версии; `target` = id \| Row \| вложенная цепочка |
|
|
1923
|
+
| `.Класс.unset()` | снять optional-конец (0.16.0: переименован из `.Класс.delete()` — старое имя бросает подсказку) |
|
|
1768
1924
|
|
|
1769
|
-
**Назначение и алгоритм.**
|
|
1770
|
-
|
|
1771
|
-
|
|
1925
|
+
**Назначение и алгоритм.** Слот — свойство-класс БЕЗ вызова, идёт ПОСЛЕ глагола записи
|
|
1926
|
+
(`create`/`update`); слот без операции — ошибка `link slot needs a write`. Пишет конец в
|
|
1927
|
+
links **БЕЗ участия в фильтре целей** (в отличие от шага пути, который фильтрует). Валиден
|
|
1928
|
+
только для конца из `Schema.links` владельца; союз-конец `[A|B]` замещается целиком
|
|
1929
|
+
(соседний класс снимается); дубль одного слота — ошибка. `target`-цепочка
|
|
1930
|
+
исполняется в той же транзакции и обязана дать ровно одну сущность класса конца.
|
|
1772
1931
|
|
|
1773
1932
|
**Примеры**
|
|
1774
1933
|
|
|
1775
1934
|
```ts
|
|
1776
|
-
await db.навык().
|
|
1777
|
-
// → { id: '
|
|
1935
|
+
await db.Мастер(мия).навык().create({ level: 'expert' }).Услуга.set(усл).rows() // [16.9 ms] ОДИН INSERT
|
|
1936
|
+
// → { id: '5e260ee8-…', class: 'skill', data: { level: 'expert' }, links: { Staff: '…0911', Service: '…' } }
|
|
1937
|
+
// навык — v5: id вычислен из (Staff, Service) — второй раз тот же навык не завести
|
|
1938
|
+
|
|
1939
|
+
// союз-конец [Услуга|Товар|Комплекс]: слот замещает целиком (соседний класс снят)
|
|
1940
|
+
await db.запись('…d091').update().Товар.set(воск).rows() // [10.3 ms] Service снят, Product встал
|
|
1941
|
+
|
|
1942
|
+
// снять optional-конец: ручная бронь без клиента, имя в notes
|
|
1943
|
+
await db.запись('…d091').update({ notes: 'бронь по телефону: Злата' }).Клиент.unset().rows() // [14.3 ms]
|
|
1944
|
+
|
|
1945
|
+
// required-конец снять нельзя:
|
|
1946
|
+
await db.запись('…d091').update().Локация.unset() // [0.3 ms]
|
|
1947
|
+
// Error: letopis: link end "Location" of "booking" is required — cannot unset
|
|
1778
1948
|
```
|
|
1779
1949
|
|
|
1780
|
-
**Кейс:
|
|
1950
|
+
**Кейс: заменить предмет на всех записях клиента**
|
|
1781
1951
|
|
|
1782
1952
|
```ts
|
|
1783
|
-
await tr
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1953
|
+
await tr.Клиент(к).запись().update().Комплекс.set(комплекс).rows()
|
|
1954
|
+
// цель ищется путём (все записи клиента); слот пишет новый предмет — союз [Услуга|Товар|Комплекс]
|
|
1955
|
+
// замещается целиком, БЕЗ фильтра по старому концу
|
|
1786
1956
|
```
|
|
1787
1957
|
|
|
1788
1958
|
#### `.delete(opts?): Chain`
|
|
@@ -1797,30 +1967,30 @@ await tr.Запись(b).позиция({}).link('Сотрудник', новы
|
|
|
1797
1967
|
под `enforceAcl` DELETE-решение проверяется на класс целей **и каждый класс замыкания**
|
|
1798
1968
|
(deny откатывает план); `DELETE` по целям будит серверный триггер: advisory-lock →
|
|
1799
1969
|
tombstone-версия → рекурсивное удаление зависимых. Возврат — замыкание с `$deleted: true`.
|
|
1800
|
-
История остаётся; `
|
|
1970
|
+
История остаётся; `create()` с тем же id — воскрешение. Продолжение цепочки — от строк
|
|
1801
1971
|
класса цели.
|
|
1802
1972
|
|
|
1803
1973
|
**Примеры**
|
|
1804
1974
|
|
|
1805
1975
|
```ts
|
|
1806
|
-
await db
|
|
1807
|
-
// → [{ id: '…
|
|
1808
|
-
//
|
|
1976
|
+
await db.Клиент('…0931').delete().rows() // [14.7 ms] ПРЕВЬЮ — кандидаты живы:
|
|
1977
|
+
// → [{ id: '…0931', class: 'Customer' }, { class: 'booking' }, { class: 'booking' }] — клиент + его записи (каскад)
|
|
1978
|
+
// после превью клиент жив: true
|
|
1809
1979
|
|
|
1810
|
-
await db
|
|
1980
|
+
await db.Клиент('…0931').delete({ confirm: true }).rows() // [35.8 ms] — сервер нашёл зависимых:
|
|
1811
1981
|
// → те же три, каждый с $deleted: true
|
|
1812
|
-
await db
|
|
1982
|
+
await db.Клиент('…0931').delete({ confirm: true }).rows() // [6.8 ms] повторно → []
|
|
1813
1983
|
```
|
|
1814
1984
|
|
|
1815
|
-
**Кейс: отмена
|
|
1985
|
+
**Кейс: отмена и воскрешение**
|
|
1816
1986
|
|
|
1817
1987
|
```ts
|
|
1818
|
-
const последствия = await db
|
|
1988
|
+
const последствия = await db.Клиент(cid).delete().rows() // [14.7 ms] оператору: клиент + N его записей
|
|
1819
1989
|
if (операторПодтвердил) {
|
|
1820
|
-
await db
|
|
1990
|
+
await db.Клиент(cid).delete({ confirm: true }).rows() // [35.8 ms] клиент и записи — tombstone (каскад)
|
|
1821
1991
|
}
|
|
1822
|
-
// клиент
|
|
1823
|
-
await db
|
|
1992
|
+
// клиент вернулся: воскрешение тем же id — create по (Org, id)
|
|
1993
|
+
await db.Организация('…0901').Клиент().create({ id: cid, name: 'Злата', phone: '+7 …' }).rows() // [16.3 ms]
|
|
1824
1994
|
```
|
|
1825
1995
|
|
|
1826
1996
|
#### `.anonymize(fields): Chain`
|
|
@@ -1837,17 +2007,17 @@ await db.Клиент('…0931').Запись('…0961').set({ status: 'created'
|
|
|
1837
2007
|
**Примеры**
|
|
1838
2008
|
|
|
1839
2009
|
```ts
|
|
1840
|
-
await db.Клиент('…0931').anonymize(['name', '
|
|
1841
|
-
// → [{ data: { name: '[erased]',
|
|
2010
|
+
await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
|
|
2011
|
+
// → [{ data: { name: '[erased]', phone: '[erased]', preferred_contact: 'phone' },
|
|
1842
2012
|
// tags: ['anonymized'] }]
|
|
1843
2013
|
```
|
|
1844
2014
|
|
|
1845
2015
|
**Кейс: запрос на забвение**
|
|
1846
2016
|
|
|
1847
2017
|
```ts
|
|
1848
|
-
await db.Клиент('…0931').anonymize(['name', '
|
|
1849
|
-
;(await db.Клиент('…0931').versions()).map((r) => r.data.name)
|
|
1850
|
-
await db.Клиент().tags('anonymized').count()
|
|
2018
|
+
await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
|
|
2019
|
+
;(await db.Клиент('…0931').versions()).map((r) => r.data.name) // → ['Злата', 'Злата', 'Злата', '[erased]']
|
|
2020
|
+
await db.Клиент().tags('anonymized').count() // все стёртые — под контролем
|
|
1851
2021
|
```
|
|
1852
2022
|
|
|
1853
2023
|
### 11.6 Транзакции: EntityTx
|
|
@@ -1866,11 +2036,11 @@ await db.Клиент().tags('anonymized').count() // в
|
|
|
1866
2036
|
**Примеры**
|
|
1867
2037
|
|
|
1868
2038
|
```ts
|
|
1869
|
-
const tr = await db.begin() // [
|
|
1870
|
-
await tr
|
|
1871
|
-
await tr
|
|
2039
|
+
const tr = await db.begin() // [1.1 ms]
|
|
2040
|
+
await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows() // цУкл — базовая цена «Укладки»
|
|
2041
|
+
await tr.цена(цУкл).first() // внутри → amounts.RUB = 9900
|
|
1872
2042
|
await tr.rollback() // [0.8 ms]
|
|
1873
|
-
await db
|
|
2043
|
+
await db.цена(цУкл).first() // снаружи → amounts.RUB = 700, изменения нет
|
|
1874
2044
|
```
|
|
1875
2045
|
|
|
1876
2046
|
**Кейс: commit** — § 11.2 `db.begin()`; ниже — главный сценарий `lock`.
|
|
@@ -1891,28 +2061,34 @@ await db.Услуга('…0922').first() // снаружи → 700
|
|
|
1891
2061
|
**Примеры**
|
|
1892
2062
|
|
|
1893
2063
|
```ts
|
|
1894
|
-
await trA.lock('
|
|
2064
|
+
await trA.lock('booking', staffId, start) // [3.1 ms]
|
|
1895
2065
|
await db.lock('x')
|
|
1896
|
-
// Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.
|
|
2066
|
+
// Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.2 ms]
|
|
1897
2067
|
```
|
|
1898
2068
|
|
|
1899
2069
|
**Кейс: гонка двойной брони — реальный прогон двух транзакций**
|
|
1900
2070
|
|
|
1901
2071
|
```ts
|
|
1902
|
-
// два администратора жмут «забронировать» на одно
|
|
2072
|
+
// два администратора жмут «забронировать» на одно время одновременно;
|
|
2073
|
+
// id записи детерминирован (v5, § 3.2) — известен ДО создания:
|
|
2074
|
+
const bId = uuidv5(`v1.salondemo:entity:booking:${мастер.id}:${start}`)
|
|
1903
2075
|
const trA = await db.begin(), trB = await db.begin()
|
|
1904
|
-
await trA.lock('
|
|
2076
|
+
await trA.lock('booking', мастер.id, start) // [3.1 ms] A первый
|
|
1905
2077
|
const гонкаB = (async () => {
|
|
1906
|
-
await trB.lock('
|
|
1907
|
-
const занято = await trB
|
|
1908
|
-
if (занято) { await trB.rollback(); return 'ОТКАЗ:
|
|
1909
|
-
await trB
|
|
2078
|
+
await trB.lock('booking', мастер.id, start) // B ВИСИТ до конца trA
|
|
2079
|
+
const занято = await trB.запись(bId).first() // перечитка под локом
|
|
2080
|
+
if (занято) { await trB.rollback(); return 'ОТКАЗ: время уже занято' }
|
|
2081
|
+
await trB.Мастер(мастер).запись().create({ start_datetime: start, end_datetime: end })
|
|
2082
|
+
.Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows()
|
|
1910
2083
|
await trB.commit(); return 'бронь моя'
|
|
1911
2084
|
})()
|
|
1912
|
-
await trA
|
|
1913
|
-
await trA
|
|
2085
|
+
await trA.запись(bId).first() // → null — свободно
|
|
2086
|
+
await trA.Мастер(мастер).запись().create({ start_datetime: start, end_datetime: end })
|
|
2087
|
+
.Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows()
|
|
1914
2088
|
await trA.commit()
|
|
1915
|
-
await гонкаB // → 'ОТКАЗ:
|
|
2089
|
+
await гонкаB // → 'ОТКАЗ: время уже занято' — B увидел бронь A, дубля нет
|
|
2090
|
+
// дубль невозможен и без лока (оба create вычислят ОДИН id — второй стал бы версией);
|
|
2091
|
+
// лок нужен, чтобы B получил честный отказ, а не молча версионировал чужую бронь
|
|
1916
2092
|
|
|
1917
2093
|
// а если два лока взять в разном порядке — деадлок, жертва получает:
|
|
1918
2094
|
// Error: deadlock detected — letopis: transaction is aborted, retry the whole db.begin() block
|
|
@@ -1931,7 +2107,7 @@ await гонкаB // → 'ОТКАЗ: окно уже занято' — B ув
|
|
|
1931
2107
|
цепочке — ошибка `plan is queued in the batch`. Исполняет `run()`.
|
|
1932
2108
|
|
|
1933
2109
|
```ts
|
|
1934
|
-
const b = db.batch('
|
|
2110
|
+
const b = db.batch('смены-августа') // [28 µs]
|
|
1935
2111
|
```
|
|
1936
2112
|
|
|
1937
2113
|
#### `batch.run(): Promise<Row[][]>`
|
|
@@ -1940,26 +2116,28 @@ const b = db.batch('слоты-августа') // [16 µs]
|
|
|
1940
2116
|
|
|
1941
2117
|
**Назначение и алгоритм.** Вся очередь — **одна транзакция** (бывший `execute()`);
|
|
1942
2118
|
результаты по порядку планов (`Row[]` на план; у многосегментного — результат последнего
|
|
1943
|
-
сегмента). Подряд идущие
|
|
1944
|
-
и
|
|
1945
|
-
пришпиливание — на каждый элемент склейки. Transient-ошибка
|
|
1946
|
-
Очередь очищается.
|
|
2119
|
+
сегмента). Подряд идущие чистые `create()` одного класса (план из одного шага, без `data.id`
|
|
2120
|
+
и слотов; id класса не v5 — § 3.2) склеиваются в **один multi-VALUES INSERT**; под
|
|
2121
|
+
`enforceAcl` WRITE-проверка и пришпиливание — на каждый элемент склейки. Transient-ошибка
|
|
2122
|
+
ретраит всю транзакцию целиком. Очередь очищается.
|
|
1947
2123
|
|
|
1948
2124
|
**Примеры**
|
|
1949
2125
|
|
|
1950
2126
|
```ts
|
|
1951
|
-
const b = db.batch('
|
|
1952
|
-
b
|
|
1953
|
-
b
|
|
1954
|
-
b
|
|
1955
|
-
b.size() // [
|
|
1956
|
-
await b.run() // [
|
|
1957
|
-
// → [[{ id: '
|
|
2127
|
+
const b = db.batch('смены-августа')
|
|
2128
|
+
b.Мастер(m).окно().create({ start_datetime: '2026-08-05T07:00:00Z', end_datetime: '…09:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
2129
|
+
b.Мастер(m).окно().create({ start_datetime: '2026-08-05T09:00:00Z', end_datetime: '…11:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
2130
|
+
b.Мастер(m).окно().create({ start_datetime: '2026-08-05T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
2131
|
+
b.size() // [35 µs] → 3
|
|
2132
|
+
await b.run() // [43.8 ms] — одна транзакция; окно — v5-класс → 3 честных INSERT, не склейка
|
|
2133
|
+
// → [[{ id: '06f67c88-…', start_datetime: '2026-08-05T07:00:00.000Z' }], [{ …09:00 }], [{ …11:00 }]]
|
|
2134
|
+
// id каждого окна вычислен схемой: uuidv5(Staff, start_datetime) — § 3.2
|
|
1958
2135
|
b.size() // → 0
|
|
1959
2136
|
```
|
|
1960
2137
|
|
|
1961
|
-
**Кейс: генерация расписания на день** — 3 окна одной транзакцией
|
|
1962
|
-
|
|
2138
|
+
**Кейс: генерация расписания на день** — 3 окна одной транзакцией (выше); упавшая
|
|
2139
|
+
валидация любого окна откатывает все; v7-классы без `data.id` и слотов склеились бы в
|
|
2140
|
+
один multi-VALUES INSERT. Терминал на плане в батче:
|
|
1963
2141
|
`план.rows()` → `Error: letopis: plan is queued in the batch — call batch.run()`.
|
|
1964
2142
|
|
|
1965
2143
|
#### `batch.discard(): void` / `batch.size(): number`
|
|
@@ -1968,14 +2146,14 @@ b.size() // → 0
|
|
|
1968
2146
|
|
|
1969
2147
|
```ts
|
|
1970
2148
|
const b2 = db.batch('отмена')
|
|
1971
|
-
b2
|
|
1972
|
-
b2.discard() // [
|
|
2149
|
+
b2.Мастер(m).окно().create({ start_datetime: '2026-08-06T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
2150
|
+
b2.discard() // [87 µs]
|
|
1973
2151
|
b2.size() // → 0
|
|
1974
|
-
await db
|
|
2152
|
+
await db.Мастер(m).окно({ start_datetime: between('2026-08-06T11:00:00Z', '2026-08-06T11:00:00Z') }).first() // → null — не исполнилось
|
|
1975
2153
|
```
|
|
1976
2154
|
|
|
1977
2155
|
**Кейс: черновик импорта** — копим операции по мере парсинга файла; ошибка парсера →
|
|
1978
|
-
`discard()`, полный успех → `
|
|
2156
|
+
`discard()`, полный успех → `run()`.
|
|
1979
2157
|
|
|
1980
2158
|
### 11.8 Таблицы: accounts / credentials / resources / rules
|
|
1981
2159
|
|
|
@@ -1994,7 +2172,7 @@ await db.Окно('…0955').first() // → null — ничего не исп
|
|
|
1994
2172
|
`category` → `= ANY(categories)`.
|
|
1995
2173
|
|
|
1996
2174
|
```ts
|
|
1997
|
-
await db.accounts.find({ category: 'Client', enabled: true }) // [2.
|
|
2175
|
+
await db.accounts.find({ category: 'Client', enabled: true }) // [2.4 ms] → 1 аккаунт
|
|
1998
2176
|
```
|
|
1999
2177
|
|
|
2000
2178
|
**Кейс:** список арендаторов для биллинга: `find({ enabled: true })`, отключённые не в счёте.
|
|
@@ -2004,7 +2182,7 @@ await db.accounts.find({ category: 'Client', enabled: true }) // [2.2 ms] →
|
|
|
2004
2182
|
`id: string` — точечный SELECT по PK.
|
|
2005
2183
|
|
|
2006
2184
|
```ts
|
|
2007
|
-
await db.accounts.get(acc.id) // [1.
|
|
2185
|
+
await db.accounts.get(acc.id) // [1.9 ms] → Account | null
|
|
2008
2186
|
```
|
|
2009
2187
|
|
|
2010
2188
|
**Кейс:** профиль владельца строки Entity: `db.accounts.get(row.owner)`.
|
|
@@ -2024,9 +2202,9 @@ await db.accounts.get(acc.id) // [1.7 ms] → Account | null
|
|
|
2024
2202
|
|
|
2025
2203
|
```ts
|
|
2026
2204
|
const acc = await db.accounts.set({ categories: ['Client'], data: { название: 'ИП Ромашка' } })
|
|
2027
|
-
// [
|
|
2028
|
-
// meta: {}, avatar: 'https://i.pravatar.cc/128?img=
|
|
2029
|
-
await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [
|
|
2205
|
+
// [5.4 ms] → { id: '06d3bbfe-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
|
|
2206
|
+
// meta: {}, avatar: 'https://i.pravatar.cc/128?img=33', enabled: true, created: …, updated: … }
|
|
2207
|
+
await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [5.7 ms] update
|
|
2030
2208
|
```
|
|
2031
2209
|
|
|
2032
2210
|
**Кейс:** бан аккаунта одним полем: `set({ id, enabled: false })` — все `verify*` § 11.9
|
|
@@ -2038,8 +2216,8 @@ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) //
|
|
|
2038
2216
|
в Entity (`account`/`owner` FK RESTRICT) не удалить — намеренно: история неприкосновенна.
|
|
2039
2217
|
|
|
2040
2218
|
```ts
|
|
2041
|
-
await db.accounts.delete(времId) // [
|
|
2042
|
-
await db.accounts.delete(SYS) // [
|
|
2219
|
+
await db.accounts.delete(времId) // [16.4 ms] → true (пустой аккаунт)
|
|
2220
|
+
await db.accounts.delete(SYS) // [6.0 ms]
|
|
2043
2221
|
// Error: update or delete on table "Account" violates foreign key constraint "entity_account_fk"
|
|
2044
2222
|
```
|
|
2045
2223
|
|
|
@@ -2055,7 +2233,7 @@ await db.accounts.delete(SYS) // [4.9 ms]
|
|
|
2055
2233
|
| `f.withDeleted` | `boolean?` | включить мягко-удалённые (default — только живые `deleted IS NULL`) |
|
|
2056
2234
|
|
|
2057
2235
|
```ts
|
|
2058
|
-
await db.credentials.find({ account: acc.id }) // [2
|
|
2236
|
+
await db.credentials.find({ account: acc.id }) // [3.2 ms] → 1 живой
|
|
2059
2237
|
await db.credentials.find({ account: acc.id, withDeleted: true }) // → 1 (после delete: 0 и 1)
|
|
2060
2238
|
```
|
|
2061
2239
|
|
|
@@ -2077,9 +2255,9 @@ telegram списком.
|
|
|
2077
2255
|
|
|
2078
2256
|
```ts
|
|
2079
2257
|
const кред = await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
|
|
2080
|
-
// [3
|
|
2258
|
+
// [5.3 ms] → { id: '5f49dbfa-…', confirmed: true, deleted: null, … }
|
|
2081
2259
|
await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
|
|
2082
|
-
// [
|
|
2260
|
+
// [7.7 ms] после delete → тот же id, deleted = null — воскрешение
|
|
2083
2261
|
```
|
|
2084
2262
|
|
|
2085
2263
|
**Кейс:** смена номера телефона: `delete(старый)` + `set(новый)`; передумали — повторный
|
|
@@ -2091,7 +2269,7 @@ await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 9
|
|
|
2091
2269
|
освобождается для других аккаунтов (§ 11.9).
|
|
2092
2270
|
|
|
2093
2271
|
```ts
|
|
2094
|
-
await db.credentials.delete(кред.id) // [
|
|
2272
|
+
await db.credentials.delete(кред.id) // [4.6 ms] → true; find() больше не видит
|
|
2095
2273
|
```
|
|
2096
2274
|
|
|
2097
2275
|
**Кейс:** отзыв api-ключа: `delete(credential.id)` → `verifyApiKey` мгновенно null
|
|
@@ -2112,10 +2290,10 @@ await db.credentials.delete(кред.id) // [3.7 ms] → true; find() боль
|
|
|
2112
2290
|
|
|
2113
2291
|
```ts
|
|
2114
2292
|
await db.resources.set({ alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' } })
|
|
2115
|
-
// [
|
|
2116
|
-
await db.resources.get('apiref.demo:API') // [
|
|
2117
|
-
await db.resources.find({ category: 'API' }) // [
|
|
2118
|
-
await db.resources.delete('apiref.demo:API') // [
|
|
2293
|
+
// [4.8 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
|
|
2294
|
+
await db.resources.get('apiref.demo:API') // [2.6 ms] → тот же Resource
|
|
2295
|
+
await db.resources.find({ category: 'API' }) // [1.1 ms] → 14 ресурсов
|
|
2296
|
+
await db.resources.delete('apiref.demo:API') // [5.2 ms] → true
|
|
2119
2297
|
```
|
|
2120
2298
|
|
|
2121
2299
|
**Кейс:** полный словарь для нового тарифа — § 11.10 (6 ресурсов + 5 правил одним блоком).
|
|
@@ -2134,9 +2312,9 @@ await db.resources.delete('apiref.demo:API') // [3.0 ms] → true
|
|
|
2134
2312
|
|
|
2135
2313
|
```ts
|
|
2136
2314
|
await db.rules.set({ account: 'apiref.demo:API', resource: 'apiref.demo:API', permission: 'allow', weight: 90 })
|
|
2137
|
-
// [3
|
|
2138
|
-
await db.rules.find({ resource: 'apiref.demo:API' }) // [2.
|
|
2139
|
-
await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [
|
|
2315
|
+
// [5.3 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
|
|
2316
|
+
await db.rules.find({ resource: 'apiref.demo:API' }) // [2.7 ms] → 1
|
|
2317
|
+
await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [4.3 ms] → true
|
|
2140
2318
|
```
|
|
2141
2319
|
|
|
2142
2320
|
**Кейс:** временный бан группы: `set({ account: группа, resource: цель, permission: 'deny',
|
|
@@ -2168,8 +2346,8 @@ uuid-строку или объект с `.id`.
|
|
|
2168
2346
|
|
|
2169
2347
|
```ts
|
|
2170
2348
|
await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2171
|
-
// [
|
|
2172
|
-
// meta.password = "scrypt$32768$8$1
|
|
2349
|
+
// [87.5 ms] → Credential; в БД вместо пароля:
|
|
2350
|
+
// meta.password = "scrypt$32768$8$1$+yEMcUTJIO7xEu/oDUAMXA==$b6…"
|
|
2173
2351
|
```
|
|
2174
2352
|
|
|
2175
2353
|
**Кейс** — регистрация + вход + сессия: см. `sessions()` ниже (полный флоу).
|
|
@@ -2191,11 +2369,11 @@ await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', passw
|
|
|
2191
2369
|
**Примеры**
|
|
2192
2370
|
|
|
2193
2371
|
```ts
|
|
2194
|
-
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [
|
|
2195
|
-
// → { account: { id: '
|
|
2372
|
+
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [78.8 ms]
|
|
2373
|
+
// → { account: { id: '06d3bbfe-…', categories: ['Client'], enabled: true, … },
|
|
2196
2374
|
// credential: { category: 'PASSWORD', identifier: 'romashka@salon.io', … } }
|
|
2197
|
-
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [
|
|
2198
|
-
await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [
|
|
2375
|
+
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [71.1 ms] → null
|
|
2376
|
+
await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [64.4 ms] → null
|
|
2199
2377
|
// незнакомый identifier — то же время (dummy-verify)
|
|
2200
2378
|
```
|
|
2201
2379
|
|
|
@@ -2205,11 +2383,11 @@ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' })
|
|
|
2205
2383
|
await db.auth.setPassword({ account: acc, identifier: 'noconfirm@salon.io', password: 'пароль-77',
|
|
2206
2384
|
category: 'EMAIL', confirmed: false })
|
|
2207
2385
|
await db.auth.verifyPassword({ identifier: 'noconfirm@salon.io', password: 'пароль-77', category: 'EMAIL' })
|
|
2208
|
-
// [
|
|
2209
|
-
await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [
|
|
2386
|
+
// [71.1 ms] → null — кред не подтверждён
|
|
2387
|
+
await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [72.2 ms] → { account, credential }
|
|
2210
2388
|
await db.accounts.set({ id: acc.id, enabled: false })
|
|
2211
2389
|
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2212
|
-
// [
|
|
2390
|
+
// [59.5 ms] → null — аккаунт выключен, пароль уже не важен
|
|
2213
2391
|
```
|
|
2214
2392
|
|
|
2215
2393
|
#### `db.auth.issueApiKey(a): Promise<{ key, credential }>`
|
|
@@ -2224,9 +2402,9 @@ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'ле
|
|
|
2224
2402
|
Сам ключ возвращается **один раз**; утечка БД ключи не раскрывает.
|
|
2225
2403
|
|
|
2226
2404
|
```ts
|
|
2227
|
-
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [
|
|
2228
|
-
// key = '
|
|
2229
|
-
// в БД: identifier = '
|
|
2405
|
+
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
|
|
2406
|
+
// key = 'lts_ca41bf2a3614c3f228be2551a2ba998db5285f1a2bc3b859' ← показать и забыть
|
|
2407
|
+
// в БД: identifier = '624b00355aba026f…' (sha256), meta = { name: 'касса-1', prefix: 'lts_ca41bf2a' }
|
|
2230
2408
|
```
|
|
2231
2409
|
|
|
2232
2410
|
**Кейс** — см. `verifyApiKey` (выпуск → проверка → отзыв).
|
|
@@ -2242,16 +2420,16 @@ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'к
|
|
|
2242
2420
|
Быстрый (без scrypt): ключ высокоэнтропийный, подбор бессмыслен.
|
|
2243
2421
|
|
|
2244
2422
|
```ts
|
|
2245
|
-
await db.auth.verifyApiKey(key) // [
|
|
2423
|
+
await db.auth.verifyApiKey(key) // [4.5 ms] → { account: 06d3bbfe…, credential }
|
|
2246
2424
|
```
|
|
2247
2425
|
|
|
2248
2426
|
**Кейс: полный жизненный цикл ключа**
|
|
2249
2427
|
|
|
2250
2428
|
```ts
|
|
2251
|
-
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [
|
|
2252
|
-
await db.auth.verifyApiKey(key) // [
|
|
2429
|
+
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [5.5 ms]
|
|
2430
|
+
await db.auth.verifyApiKey(key) // [4.5 ms] → { account, credential } — касса работает
|
|
2253
2431
|
await db.credentials.delete(credential.id) // отзыв (мягкий)
|
|
2254
|
-
await db.auth.verifyApiKey(key) // [1
|
|
2432
|
+
await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновенно недействителен
|
|
2255
2433
|
```
|
|
2256
2434
|
|
|
2257
2435
|
#### `db.auth.issueKeySecret(a): Promise<{ key, secret, credential }>`
|
|
@@ -2263,8 +2441,8 @@ await db.auth.verifyApiKey(key) // [1.7 ms] → null — мгновен
|
|
|
2263
2441
|
возвращается один раз.
|
|
2264
2442
|
|
|
2265
2443
|
```ts
|
|
2266
|
-
const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [
|
|
2267
|
-
// key = '
|
|
2444
|
+
const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [4.8 ms]
|
|
2445
|
+
// key = 'f8ab4007e4570809'; secret = 'c25b8b906f69…' (48 hex, показан один раз)
|
|
2268
2446
|
```
|
|
2269
2447
|
|
|
2270
2448
|
#### `db.auth.verifyKeySecret(key, secret, opts?): Promise<AuthResult | null>`
|
|
@@ -2278,8 +2456,8 @@ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'ин
|
|
|
2278
2456
|
**Алгоритм.** Кред по identifier = key, `timingSafeEqual(sha256(secret), meta.secret)`, ворота.
|
|
2279
2457
|
|
|
2280
2458
|
```ts
|
|
2281
|
-
await db.auth.verifyKeySecret(key, secret) // [
|
|
2282
|
-
await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [
|
|
2459
|
+
await db.auth.verifyKeySecret(key, secret) // [4.6 ms] → { account, credential }
|
|
2460
|
+
await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [2.2 ms] → null
|
|
2283
2461
|
```
|
|
2284
2462
|
|
|
2285
2463
|
**Кейс:** серверная интеграция (1С, платёжка): key хранится в конфиге открыто и светится
|
|
@@ -2301,8 +2479,8 @@ base32(20 случайных байт), `uri` — готовая строка `o
|
|
|
2301
2479
|
|
|
2302
2480
|
```ts
|
|
2303
2481
|
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: 'romashka@salon.io' })
|
|
2304
|
-
// [
|
|
2305
|
-
// uri = 'otpauth://totp/romashka%40salon.io?secret=
|
|
2482
|
+
// [4.8 ms] secret = 'G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS'
|
|
2483
|
+
// uri = 'otpauth://totp/romashka%40salon.io?secret=G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS&issuer=clockz&algorithm=SHA1&digits=6&period=30'
|
|
2306
2484
|
```
|
|
2307
2485
|
|
|
2308
2486
|
#### `db.auth.verifyTotp(a): Promise<boolean>`
|
|
@@ -2320,9 +2498,9 @@ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz
|
|
|
2320
2498
|
**Примеры**
|
|
2321
2499
|
|
|
2322
2500
|
```ts
|
|
2323
|
-
const код = totpCode(secret) // [
|
|
2324
|
-
await db.auth.verifyTotp({ account: acc, code: код }) // [
|
|
2325
|
-
await db.auth.verifyTotp({ account: acc, code: код }) // [
|
|
2501
|
+
const код = totpCode(secret) // [659 µs] → '564517' (как в приложении)
|
|
2502
|
+
await db.auth.verifyTotp({ account: acc, code: код }) // [8.1 ms] → true — фактор активирован
|
|
2503
|
+
await db.auth.verifyTotp({ account: acc, code: код }) // [2.6 ms] → false — replay отбит
|
|
2326
2504
|
const прошлый = totpCode(secret, Date.now() - 30_000) // код прошлого шага (окно ±1)
|
|
2327
2505
|
await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false — шаг ≤ lastStep
|
|
2328
2506
|
```
|
|
@@ -2330,10 +2508,10 @@ await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false
|
|
|
2330
2508
|
**Кейс: включение 2FA в кабинете**
|
|
2331
2509
|
|
|
2332
2510
|
```ts
|
|
2333
|
-
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [
|
|
2511
|
+
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [4.8 ms]
|
|
2334
2512
|
await db.auth.totpEnabled(acc) // → false — QR показан, ждём подтверждения
|
|
2335
|
-
await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [
|
|
2336
|
-
await db.auth.totpEnabled(acc) // [
|
|
2513
|
+
await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [8.1 ms] → true
|
|
2514
|
+
await db.auth.totpEnabled(acc) // [2.2 ms] → true — теперь требуем код при входе
|
|
2337
2515
|
```
|
|
2338
2516
|
|
|
2339
2517
|
#### `db.auth.totpEnabled(account): Promise<boolean>`
|
|
@@ -2342,7 +2520,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true — те
|
|
|
2342
2520
|
проверкой (`confirmed`). Приложение по нему решает, спрашивать ли второй фактор.
|
|
2343
2521
|
|
|
2344
2522
|
```ts
|
|
2345
|
-
await db.auth.totpEnabled(acc) // [
|
|
2523
|
+
await db.auth.totpEnabled(acc) // [2.2 ms] → true
|
|
2346
2524
|
```
|
|
2347
2525
|
|
|
2348
2526
|
#### `totpCode(secretBase32, atMs = Date.now()): string` — экспорт модуля
|
|
@@ -2357,7 +2535,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true
|
|
|
2357
2535
|
Для тестов и серверной генерации кодов.
|
|
2358
2536
|
|
|
2359
2537
|
```ts
|
|
2360
|
-
totpCode('
|
|
2538
|
+
totpCode('G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS') // [659 µs] → '564517'
|
|
2361
2539
|
```
|
|
2362
2540
|
|
|
2363
2541
|
**Кейс** — автотест 2FA без телефона: сгенерировать код из секрета и скормить `verifyTotp`
|
|
@@ -2379,7 +2557,7 @@ totpCode('FAZKFC57B3FPIOF2735ZYW47CZA5O6MW') // [335 µs] → '370916'
|
|
|
2379
2557
|
|
|
2380
2558
|
```ts
|
|
2381
2559
|
const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@salon.io', ttlSec: 600 })
|
|
2382
|
-
// [
|
|
2560
|
+
// [6.1 ms] code = '255243'; в БД: meta = { code: '7566c91d8a5e…' (sha256), expires: '2026-07-13T07:24:44.435Z', attempts: 0 }
|
|
2383
2561
|
```
|
|
2384
2562
|
|
|
2385
2563
|
#### `db.auth.verifyOtp(a): Promise<AuthResult | null>`
|
|
@@ -2398,19 +2576,19 @@ const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@sa
|
|
|
2398
2576
|
**Примеры**
|
|
2399
2577
|
|
|
2400
2578
|
```ts
|
|
2401
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [4
|
|
2402
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [
|
|
2403
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [
|
|
2579
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [8.4 ms] → null (+1 попытка)
|
|
2580
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11.0 ms] → { account, credential }
|
|
2581
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [2.4 ms] → null — сожжён
|
|
2404
2582
|
```
|
|
2405
2583
|
|
|
2406
2584
|
**Кейс: сброс пароля**
|
|
2407
2585
|
|
|
2408
2586
|
```ts
|
|
2409
|
-
const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [
|
|
2587
|
+
const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [6.1 ms]
|
|
2410
2588
|
отправитьПисьмо(почта, code) // доставка — на приложении
|
|
2411
|
-
const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [
|
|
2589
|
+
const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11.0 ms]
|
|
2412
2590
|
if (кто) await db.auth.setPassword({ account: кто.account, identifier: почта, password: новый })
|
|
2413
|
-
// протухший код (ttl 1 s в прогоне): verifyOtp → null [
|
|
2591
|
+
// протухший код (ttl 1 s в прогоне): verifyOtp → null [9.3 ms]
|
|
2414
2592
|
```
|
|
2415
2593
|
|
|
2416
2594
|
#### `db.auth.link(a): Promise<Credential>`
|
|
@@ -2431,7 +2609,7 @@ if (кто) await db.auth.setPassword({ account: кто.account, identifier: п
|
|
|
2431
2609
|
|
|
2432
2610
|
```ts
|
|
2433
2611
|
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' } })
|
|
2434
|
-
// [
|
|
2612
|
+
// [4.9 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
|
|
2435
2613
|
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915' }) // id занят ДРУГИМ аккаунтом:
|
|
2436
2614
|
// Error: duplicate key value violates unique constraint "credential_identity_udx" [3.9 ms]
|
|
2437
2615
|
```
|
|
@@ -2452,14 +2630,14 @@ await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915
|
|
|
2452
2630
|
|
|
2453
2631
|
```ts
|
|
2454
2632
|
await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' })
|
|
2455
|
-
// [
|
|
2633
|
+
// [5.2 ms] → { account: 06d3bbfe…, credential }
|
|
2456
2634
|
```
|
|
2457
2635
|
|
|
2458
2636
|
**Кейс: вход через telegram-бота**
|
|
2459
2637
|
|
|
2460
2638
|
```ts
|
|
2461
2639
|
// платформа подтвердила пользователя 777000111 (initData бота проверило приложение)
|
|
2462
|
-
const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [
|
|
2640
|
+
const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [5.2 ms]
|
|
2463
2641
|
if (!кто) { /* первая встреча: создать аккаунт + db.auth.link(…) */ }
|
|
2464
2642
|
const token = await sess.start(кто.account) // дальше обычная сессия
|
|
2465
2643
|
```
|
|
@@ -2475,7 +2653,7 @@ const token = await sess.start(кто.account) // дальше обычная
|
|
|
2475
2653
|
|
|
2476
2654
|
```ts
|
|
2477
2655
|
import Redis from 'ioredis'
|
|
2478
|
-
const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [
|
|
2656
|
+
const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [148 µs]
|
|
2479
2657
|
```
|
|
2480
2658
|
|
|
2481
2659
|
#### `sessions.start(account, opts?): Promise<string>`
|
|
@@ -2491,9 +2669,9 @@ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [147 µ
|
|
|
2491
2669
|
`sess:acc:<accountId>` для `revokeAll`. Дамп Redis действующих токенов не раскрывает.
|
|
2492
2670
|
|
|
2493
2671
|
```ts
|
|
2494
|
-
const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [
|
|
2495
|
-
// token = '
|
|
2496
|
-
// в Redis: 'sess:
|
|
2672
|
+
const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [8.0 ms]
|
|
2673
|
+
// token = 'fe0a7e83db6fcd1aa2c5002988da2b353602837db3bc0a813eddac863b9a1944'
|
|
2674
|
+
// в Redis: 'sess:8198bf2cd8d2ef2376d…' и 'sess:acc:06d3bbfe-d9a2-4…'
|
|
2497
2675
|
```
|
|
2498
2676
|
|
|
2499
2677
|
#### `sessions.check(token): Promise<Session | null>`
|
|
@@ -2502,9 +2680,9 @@ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }
|
|
|
2502
2680
|
(нет / истекла / отозвана). Суб-миллисекундный — на каждый HTTP-запрос.
|
|
2503
2681
|
|
|
2504
2682
|
```ts
|
|
2505
|
-
await sess.check(token) // [0.
|
|
2506
|
-
// → { account: '
|
|
2507
|
-
await sess.check(протухший) // [
|
|
2683
|
+
await sess.check(token) // [0.7 ms]
|
|
2684
|
+
// → { account: '06d3bbfe-…', meta: { device: 'iphone' }, created: '2026-07-13T07:14:45.810Z' }
|
|
2685
|
+
await sess.check(протухший) // [0.8 ms] → null (ttl 1 s истёк — Redis сам удалил)
|
|
2508
2686
|
```
|
|
2509
2687
|
|
|
2510
2688
|
#### `sessions.revoke(token): Promise<boolean>`
|
|
@@ -2512,8 +2690,8 @@ await sess.check(протухший) // [1.2 ms] → null (ttl 1 s истёк
|
|
|
2512
2690
|
`token: string` — `DEL` ключа + `SREM` из индекса; `false`, если сессии уже нет.
|
|
2513
2691
|
|
|
2514
2692
|
```ts
|
|
2515
|
-
await sess.revoke(token) // [
|
|
2516
|
-
await sess.revoke(token) // [0.
|
|
2693
|
+
await sess.revoke(token) // [1.9 ms] → true
|
|
2694
|
+
await sess.revoke(token) // [0.7 ms] → false — повторно
|
|
2517
2695
|
```
|
|
2518
2696
|
|
|
2519
2697
|
#### `sessions.revokeAll(account): Promise<number>`
|
|
@@ -2522,19 +2700,19 @@ await sess.revoke(token) // [0.5 ms] → false — повторно
|
|
|
2522
2700
|
сколько погашено.
|
|
2523
2701
|
|
|
2524
2702
|
```ts
|
|
2525
|
-
await sess.revokeAll(acc) // [1.
|
|
2703
|
+
await sess.revokeAll(acc) // [1.2 ms] → 2 — обе сессии (ipad + macbook) погасли
|
|
2526
2704
|
```
|
|
2527
2705
|
|
|
2528
2706
|
**Кейс: полный вход — пароль → сессия → запрос → выход (реальный прогон)**
|
|
2529
2707
|
|
|
2530
2708
|
```ts
|
|
2531
2709
|
const визит = await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2532
|
-
// [
|
|
2533
|
-
const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.
|
|
2710
|
+
// [71.3 ms] → { account, credential }
|
|
2711
|
+
const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.2 ms]
|
|
2534
2712
|
// … каждый запрос в middleware:
|
|
2535
|
-
const кто = await sess.check(token) // [0.
|
|
2713
|
+
const кто = await sess.check(token) // [0.7 ms] → { account: '06d3bbfe…', meta: { ip: '10.0.0.7' }, … }
|
|
2536
2714
|
// logout:
|
|
2537
|
-
await sess.revoke(token) // [
|
|
2715
|
+
await sess.revoke(token) // [1.3 ms] → true
|
|
2538
2716
|
// «выйти со всех устройств» после смены пароля: await sess.revokeAll(визит.account)
|
|
2539
2717
|
```
|
|
2540
2718
|
|
|
@@ -2543,7 +2721,7 @@ await sess.revoke(token) // [2.2 ms] → true
|
|
|
2543
2721
|
Решения по словарю Resource/Rule (§ 9.2). Прогоны ниже — словарь из § 11.8-кейса: группа
|
|
2544
2722
|
`apiref.client:ACCOUNT {categories: '{Client}'}`, эндпоинты `apiref.api.booking:API
|
|
2545
2723
|
{endpoint: 'v2.booking.*'}`, данные `apiref.booking.own:READ/WRITE/DELETE
|
|
2546
|
-
{class: '
|
|
2724
|
+
{class: 'booking', owner: '$account'}` и `apiref.service:READ {class: 'Service'}`,
|
|
2547
2725
|
5 правил `allow weight 60` от группы Client.
|
|
2548
2726
|
|
|
2549
2727
|
#### `db.acl.check(account, endpoint): Promise<AclDecision>`
|
|
@@ -2563,10 +2741,10 @@ NULL — все); объекты — `API`-ресурсы, чья маска п
|
|
|
2563
2741
|
**Примеры**
|
|
2564
2742
|
|
|
2565
2743
|
```ts
|
|
2566
|
-
await db.acl.check(acc, 'v2.booking.create') // [
|
|
2744
|
+
await db.acl.check(acc, 'v2.booking.create') // [5.0 ms]
|
|
2567
2745
|
// → { allow: true, rule: { account: 'apiref.client:ACCOUNT', resource: 'apiref.api.booking:API',
|
|
2568
2746
|
// permission: 'allow', weight: 60, enabled: true } }
|
|
2569
|
-
await db.acl.check(acc, 'v2.admin.stats') // [1.
|
|
2747
|
+
await db.acl.check(acc, 'v2.admin.stats') // [1.8 ms] — покрыло только дно-правило сида:
|
|
2570
2748
|
// → { allow: false, rule: { account: 'any:ACCOUNT', resource: 'any:API', permission: 'deny', weight: 0, … },
|
|
2571
2749
|
// code: 403, message: 'Access denied - default for any ACCOUNT to any API' }
|
|
2572
2750
|
```
|
|
@@ -2591,7 +2769,7 @@ app.use(async (req, res, next) => {
|
|
|
2591
2769
|
|
|
2592
2770
|
**Назначение и алгоритм.** Решение по данным: объекты — ресурсы категории `op`, чья
|
|
2593
2771
|
`pattern.class`-маска совпала с именем класса **или любого предка** (lineage: право на
|
|
2594
|
-
`
|
|
2772
|
+
`booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
|
|
2595
2773
|
ключи pattern (реальные колонки Entity, `"$account"` → id субъекта) возвращаются как
|
|
2596
2774
|
`filter` — готовый предикат строк. Справочный метод (SQL за категориями аккаунта на каждый
|
|
2597
2775
|
вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный на connect
|
|
@@ -2600,35 +2778,37 @@ app.use(async (req, res, next) => {
|
|
|
2600
2778
|
**Примеры**
|
|
2601
2779
|
|
|
2602
2780
|
```ts
|
|
2603
|
-
await db.acl.checkData(acc, '
|
|
2781
|
+
await db.acl.checkData(acc, 'booking', 'READ') // [2.7 ms]
|
|
2604
2782
|
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', weight: 60, … },
|
|
2605
|
-
// filter: { owner: '
|
|
2606
|
-
await db.acl.checkData(acc, 'Service', 'READ') // [1.
|
|
2783
|
+
// filter: { owner: '06d3bbfe-d9a2-4c1d-be44-04c40cb01108' } } ← $account подставлен
|
|
2784
|
+
await db.acl.checkData(acc, 'Service', 'READ') // [1.9 ms]
|
|
2607
2785
|
// → { allow: true, rule: { resource: 'apiref.service:READ', … } } ← безусловный (без filter)
|
|
2608
|
-
await db.acl.checkData(acc, 'Org', 'READ') // [
|
|
2786
|
+
await db.acl.checkData(acc, 'Org', 'READ') // [2.0 ms]
|
|
2609
2787
|
// → { allow: false, message: 'no matching rule (deny by default)' }
|
|
2610
|
-
await db.acl.checkData(acc, 'VipBooking', 'READ') // [
|
|
2611
|
-
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: '
|
|
2612
|
-
// класса нет в словаре — право дал предок
|
|
2788
|
+
await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий connect]
|
|
2789
|
+
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: '06d3bbfe-…' } }
|
|
2790
|
+
// класса нет в словаре — право дал предок booking (lineage)
|
|
2613
2791
|
```
|
|
2614
2792
|
|
|
2615
2793
|
**Кейс: enforceAcl — те же решения в SQL цепочек (реальный прогон)**
|
|
2616
2794
|
|
|
2617
2795
|
```ts
|
|
2618
|
-
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [
|
|
2619
|
-
await uc
|
|
2620
|
-
await uc
|
|
2621
|
-
await uc.Услуга().count() // [15.
|
|
2796
|
+
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [66.9 ms] правила фиксируются
|
|
2797
|
+
await uc.запись().rows() // [17.2 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
|
|
2798
|
+
await uc.запись().count() // [17.6 ms] → 3 — честный count по суженному множеству
|
|
2799
|
+
await uc.Услуга().count() // [15.1 ms] → 602 — безусловный allow, класс целиком
|
|
2622
2800
|
await uc.Организация().rows()
|
|
2623
2801
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
|
|
2624
|
-
const [z] = await uc
|
|
2802
|
+
const [z] = await uc.запись().create({ start_datetime: t, end_datetime: e })
|
|
2803
|
+
.Мастер.set(м).Локация.set(л).Расписание.set(р).Услуга.set(у).rows() // [20.9 ms]
|
|
2625
2804
|
z.owner === acc.id // → true — owner пришпилен правилом
|
|
2626
|
-
await uc
|
|
2627
|
-
// Error: letopis: acl pins
|
|
2628
|
-
await uc
|
|
2629
|
-
|
|
2805
|
+
await uc.запись().owner(SYS).create({ … }).rows()
|
|
2806
|
+
// Error: letopis: acl pins booking writes to owner 06d3bbfe-… — на терминале [2.6 ms]
|
|
2807
|
+
await uc.запись('чужой-id').create({ … }).rows() // [2.0 ms] — перехват чужого id мёртв: v5-класс id не принимает
|
|
2808
|
+
// Error: letopis: class "booking" computes id (uuid v5 from Staff, start_datetime) — remove the explicit id
|
|
2809
|
+
await uc.запись(свойId).delete({ confirm: true }).rows() // [30.3 ms] → [{ id: 'ee65244b-…', $deleted: true }]
|
|
2630
2810
|
// watch: события только безусловных allow-классов —
|
|
2631
|
-
// uc.watch(cb) поймал ['Service'];
|
|
2811
|
+
// uc.watch(cb) поймал ['Service']; booking скрыт (предикат не проверить по payload)
|
|
2632
2812
|
```
|
|
2633
2813
|
|
|
2634
2814
|
#### `db.acl.reload(): void`
|
|
@@ -2639,7 +2819,7 @@ connect; подхватить новые правила = новый `connect()`
|
|
|
2639
2819
|
connect — новый класс в lineage-проверках увидит только новое подключение.
|
|
2640
2820
|
|
|
2641
2821
|
```ts
|
|
2642
|
-
db.acl.reload() // [
|
|
2822
|
+
db.acl.reload() // [187 µs]
|
|
2643
2823
|
```
|
|
2644
2824
|
|
|
2645
2825
|
**Кейс:** админка сохранила правило → `reload()` в том же процессе, чтобы `check` следующего
|
|
@@ -2659,9 +2839,9 @@ db.acl.reload() // [120 µs]
|
|
|
2659
2839
|
компилированный валидатор.
|
|
2660
2840
|
|
|
2661
2841
|
```ts
|
|
2662
|
-
db.registry.resolve('
|
|
2663
|
-
// → { id: '
|
|
2664
|
-
// links: ['Customer'],
|
|
2842
|
+
db.registry.resolve('запись') // [103 µs]
|
|
2843
|
+
// → { id: 'booking', alias: 'запись', category: 'LINK', ancestors: ['booking', 'slot', 'link'],
|
|
2844
|
+
// links: [{ classes: ['Staff'], … }, …, { classes: ['Customer'], optional: true, … }], abstract: false }
|
|
2665
2845
|
db.registry.resolve('Дракон')
|
|
2666
2846
|
// Error: letopis: unknown class "Дракон". Known: Entity·Сущность, Org·Организация, …
|
|
2667
2847
|
```
|
|
@@ -2675,10 +2855,10 @@ db.registry.resolve('Дракон')
|
|
|
2675
2855
|
`all` — все классы партиции.
|
|
2676
2856
|
|
|
2677
2857
|
```ts
|
|
2678
|
-
db.registry.has('
|
|
2858
|
+
db.registry.has('booking') // → true
|
|
2679
2859
|
db.registry.has('Дракон') // → false
|
|
2680
2860
|
db.registry.find('Дракон') // → undefined
|
|
2681
|
-
db.registry.all.length // →
|
|
2861
|
+
db.registry.all.length // → 20 (+1: LINK-класс price·«цена»)
|
|
2682
2862
|
```
|
|
2683
2863
|
|
|
2684
2864
|
**Кейс:** роутер `GET /:класс` — `has()` до цепочки, чтобы отвечать 404, а не 500.
|
|
@@ -2690,18 +2870,18 @@ db.registry.all.length // → 15
|
|
|
2690
2870
|
| `message` | `string` | `letopis: validation failed for "<Класс>": …` |
|
|
2691
2871
|
| `issues` | `{ field, type, message, … }[]` | отчёт fastest-validator по каждому полю |
|
|
2692
2872
|
|
|
2693
|
-
**Назначение.** Бросается из ТЕРМИНАЛА плана (`.
|
|
2694
|
-
не проходит строгую схему класса (недостающее обязательное, лишний ключ,
|
|
2695
|
-
весь план откатывается. Другие ошибки записи (abstract-класс, недостающий
|
|
2696
|
-
обычный `Error` там же.
|
|
2873
|
+
**Назначение.** Бросается из ТЕРМИНАЛА плана (`.create()`/`.update()`/`.anonymize()`/батчи),
|
|
2874
|
+
когда `data` не проходит строгую схему класса (недостающее обязательное, лишний ключ,
|
|
2875
|
+
неверный тип) — весь план откатывается. Другие ошибки записи (abstract-класс, недостающий
|
|
2876
|
+
конец LINK) — обычный `Error` там же.
|
|
2697
2877
|
|
|
2698
2878
|
```ts
|
|
2699
|
-
try { await db.Услуга().
|
|
2879
|
+
try { await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [9.9 ms]
|
|
2700
2880
|
catch (e) {
|
|
2701
2881
|
e instanceof ValidationError // → true
|
|
2702
2882
|
e.issues
|
|
2703
|
-
// → [{ type: '
|
|
2704
|
-
//
|
|
2883
|
+
// → [{ type: 'objectStrict', message: "The object '' contains forbidden keys: 'чепуха'.",
|
|
2884
|
+
// expected: 'id, name, description, duration', actual: 'чепуха' }]
|
|
2705
2885
|
}
|
|
2706
2886
|
```
|
|
2707
2887
|
|
|
@@ -2744,13 +2924,24 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
|
|
|
2744
2924
|
| `unknown class "X". Known: …` | класс вне Schema (со списком) |
|
|
2745
2925
|
| `ValidationError` (`.issues`) | строгая валидация: мусор или лишние поля |
|
|
2746
2926
|
| `class "X" is abstract` | запись в abstract |
|
|
2747
|
-
| `link "X" requires end "Y"` / `
|
|
2748
|
-
| `context step "X" must resolve to exactly one entity` |
|
|
2749
|
-
| `
|
|
2750
|
-
| `
|
|
2927
|
+
| `link "X" requires end "Y|Z"` / `has stray link(s)` | не хватает обязательного конца LINK / связь вне объявленных концов (v2); legacy: `polymorphic end(s)` |
|
|
2928
|
+
| `context step "X" must resolve to exactly one entity` | шаг-владелец пути дал 0 или >1 |
|
|
2929
|
+
| `create() takes no filter — searching is update()` | фильтр-объект перед `create()` (синхронно, при построении) |
|
|
2930
|
+
| `create() on a pivot step` | pivot возвращает к существующему узлу — это `update()` |
|
|
2931
|
+
| `class "X" computes id (uuid v5 from …)` | явный id у v5-класса (§ 3.2) — id всегда считает схема |
|
|
2932
|
+
| `id (uuid v5) of "X" needs end "A\|B"` / `needs scalar data field "f"` | v5-классу не хватает конца (путь/слот) или скалярного поля из `from` |
|
|
2933
|
+
| `step modifier after create()/update()` | модификатор шага (alias/tags/…) сразу после операции |
|
|
2934
|
+
| `"X" is not a link end of "Y"` | слот `.X.set()` — не конец Y по Schema.links |
|
|
2935
|
+
| `slot "X" needs an owner step` | слот на корне (`db.X.set()`) без шага-владельца |
|
|
2936
|
+
| `link slot "X" needs a write` | слот без глагола записи — добавить `.create(…)`/`.update(…)` перед ним |
|
|
2937
|
+
| `link end "X" of "Y" is required — cannot unset` | `.unset()` обязательного конца |
|
|
2938
|
+
| `duplicate link slot "X"` | один конец задан слотом дважды в одной записи |
|
|
2939
|
+
| `set() split into create()/update() (0.16.0)` | старый глагол записи — create() вставляет, update() версионирует найденное |
|
|
2940
|
+
| `slot .delete() renamed to .unset() (0.16.0)` | старое имя слот-снятия |
|
|
2941
|
+
| `.link() removed (0.15.0)` | снесённый `.link()` — теперь слот `.Класс.set()` |
|
|
2751
2942
|
| `execute() renamed to run() (0.11.0)` | старое имя терминала путей (и `batch.execute()`) |
|
|
2752
2943
|
| `plan is queued in the batch — call batch.run()` | терминал на батч-цепочке с операциями |
|
|
2753
|
-
| `no path X → Y` / `LINK → LINK …` | недопустимый переход
|
|
2944
|
+
| `no path X → Y` / `LINK → LINK …` | недопустимый переход (синхронно при построении цепочки) |
|
|
2754
2945
|
| `Entity.account is NOT NULL…` | нет account и System-аккаунта |
|
|
2755
2946
|
| `violates foreign key constraint "entity_*_fk"` | несуществующий класс/аккаунт; удаление класса с данными |
|
|
2756
2947
|
| `lock() works only inside db.begin()` | лок вне транзакции |
|
|
@@ -2763,75 +2954,103 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
|
|
|
2763
2954
|
|
|
2764
2955
|
## 13. Что контролирует приложение
|
|
2765
2956
|
|
|
2766
|
-
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
-
|
|
2957
|
+
- Пересечения интервалов записей внутри окна-смены мастера (наложение броней; EXCLUDE на
|
|
2958
|
+
hypertable невозможен). Двойная бронь одного времени мертва самим id записи —
|
|
2959
|
+
`v5(Мастер, старт)` (§ 3.2); частичное наложение разных интервалов проверяет приложение.
|
|
2960
|
+
- Бизнес-проверки брони: у мастера есть навык на услугу (`Мастер→навык→Услуга`); интервал
|
|
2961
|
+
записи попадает в окно-смену того же мастера; длительность услуги укладывается в окно.
|
|
2962
|
+
- Итог по записи = цена предмета (`Услуга`/`Товар`/`Комплекс`), у комплекса — по составу:
|
|
2963
|
+
сами записи денег не хранят.
|
|
2964
|
+
- Генерация окон-смен из шаблонов/повторов; месячное `Расписание {name, year, month}`.
|
|
2770
2965
|
|
|
2771
2966
|
## 14. Производительность
|
|
2772
2967
|
|
|
2773
|
-
|
|
2968
|
+
Тайминги — живые прогоны демо на ЕДИНОМ полигоне `v1.salondemo` (`bench/salon-seed.mjs`,
|
|
2969
|
+
~980 000 строк: 440 000 записей ×2 версии, 600 услуг, 1026 цен, 1260 навыков). Статья
|
|
2970
|
+
(`bench/salon-article-demo.mjs`), API Reference (`bench/api-reference-demo.mjs`) и бенчи
|
|
2971
|
+
читают один и тот же полигон, мутируя лишь свои демо-сущности:
|
|
2774
2972
|
|
|
2775
|
-
| Операция |
|
|
2776
|
-
|
|
2777
|
-
|
|
|
2778
|
-
| `
|
|
2779
|
-
|
|
|
2780
|
-
| `count()`
|
|
2781
|
-
| `
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
(
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2973
|
+
| Операция | Время |
|
|
2974
|
+
|---|---|
|
|
2975
|
+
| фильтр/`count()` по каталогу (`ne`/`gt`/`between`/`like`) | 5–8 ms |
|
|
2976
|
+
| агрегация каталога `avg`/`min`/`max` | 5–15 ms |
|
|
2977
|
+
| `sum('data.amounts.RUB')` (класс цена, 1026 вариантов) | ≈13 ms |
|
|
2978
|
+
| `count()`/`rows()` каталога услуг (600) | 7–13 ms |
|
|
2979
|
+
| цена `sort('data.amounts.RUB')` + keyset-страница `after(cursor)` | 19–31 ms |
|
|
2980
|
+
| цепочка `навык→Услуга`, `count()` путей (1260) | ≈65 ms |
|
|
2981
|
+
| `countBy('data.notes')` по 440k записям | ≈3.3 s |
|
|
2982
|
+
| `sort('updated','desc').limit` по всему классу записей БЕЗ фильтра | ≈6 s |
|
|
2983
|
+
| `count()` всех 440 000 записей БЕЗ фильтра | ≈3.4 s |
|
|
2984
|
+
| обход `запись→Мастер` по всему классу записей | ≈6 s |
|
|
2985
|
+
| запись новой версии (`create`/`update`), в т.ч. со слотами | 8–21 ms |
|
|
2986
|
+
|
|
2987
|
+
Слабое место — выборка/обход **всего класса записей без фильтра** (`DISTINCT ON` по всем
|
|
2988
|
+
440 000 сущностям: секунды); лечится селективным фильтром, контекст-шагом или курсором
|
|
2989
|
+
(keyset — миллисекунды даже на 440k). Каталог, агрегации и цепочки с фильтром — единицы—десятки ms.
|
|
2990
|
+
TOAST-порог (data > 2KB): 0 строк.
|
|
2790
2991
|
|
|
2791
2992
|
- Containment и обход графа — GIN; операторы — на уже суженном наборе.
|
|
2792
|
-
- Начинайте цепочку с самого селективного
|
|
2793
|
-
- `count()`
|
|
2794
|
-
- Каскадное удаление — серверное: один DELETE на всё
|
|
2795
|
-
- Один сегмент плана пишет одним INSERT на цель независимо от числа связей (
|
|
2796
|
-
|
|
2993
|
+
- Начинайте цепочку с самого селективного шага (Организация/Мастер/Клиент, не `запись()`).
|
|
2994
|
+
- `count()` считает пути; число сущностей дешевле берётся `ids().length`.
|
|
2995
|
+
- Каскадное удаление — серверное: один DELETE на всё дерево (клиент → его записи).
|
|
2996
|
+
- Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты);
|
|
2997
|
+
окно/запись/навык — v5-классы, в батче идут поштучно (id из концов), не multi-VALUES.
|
|
2998
|
+
- Микро-бенч `npm run bench` (105k строк) + EXPLAIN-тесты (индексы обязаны быть в плане; ноль
|
|
2999
|
+
seq scan); партиционирование — `bench/dimensions.bench.mjs`, масштаб —
|
|
3000
|
+
`node bench/history.bench.mjs --entities=10000 --versions=100`, оверхед ACL —
|
|
3001
|
+
`npx tsx bench/acl.bench.mjs` (таблица в § 9.2).
|
|
2797
3002
|
|
|
2798
3003
|
---
|
|
2799
3004
|
|
|
2800
3005
|
## 15. E2E-пример: барбершоп
|
|
2801
3006
|
|
|
2802
|
-
Полный исполняемый сценарий — `test/integration.test.ts`.
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
const [
|
|
2808
|
-
const [
|
|
2809
|
-
const [
|
|
2810
|
-
const [
|
|
2811
|
-
await db
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
const [
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
await db
|
|
2819
|
-
await db
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
await db
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
//
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
const
|
|
2834
|
-
//
|
|
3007
|
+
Полный исполняемый сценарий — `test/integration.test.ts`. Скелет (модель 0.17,
|
|
3008
|
+
позитивная доступность):
|
|
3009
|
+
|
|
3010
|
+
```ts
|
|
3011
|
+
// организация, локация, штат (phone обязателен) — связи контекст-шагами; терминал .rows() исполняет план
|
|
3012
|
+
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
3013
|
+
const [loc] = await db.Организация(org).Локация().create({ address: 'Тверская, 7', coordinates: { lat: 55.76, lng: 37.61 } }).rows()
|
|
3014
|
+
const [иван] = await db.Организация(org).Мастер().create({ name: 'Иван', phone: '+7 900 111', specialization: 'барбер' }).rows()
|
|
3015
|
+
const [олег] = await db.Организация(org).Мастер().create({ name: 'Олег', phone: '+7 900 222' }).rows()
|
|
3016
|
+
const [пётр] = await db.Организация(org).Клиент().create({ name: 'Пётр', phone: '+7 905 000' }).rows()
|
|
3017
|
+
|
|
3018
|
+
// каталог: id услуги/комплекса вычислила схема uuidv5(Org, name) — дубль имени в салоне невозможен (§ 3.2)
|
|
3019
|
+
const [стрижка] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60 }).rows()
|
|
3020
|
+
const [комплекс] = await db.Организация(org).Комплекс().create({ name: 'Стрижка+борода', duration: 90, cost: 2000 }).rows()
|
|
3021
|
+
// цена — отдельный LINK «цена»: варианты (note) + мультивалюта (amounts); note не задан → «базовая»
|
|
3022
|
+
await db.Услуга(стрижка).цена().create({ amounts: { RUB: 1500 } }).rows()
|
|
3023
|
+
await db.Услуга(стрижка).цена().create({ note: 'с дизайном', amounts: { RUB: 2000 } }).rows()
|
|
3024
|
+
await db.Комплекс(комплекс).состав().create({ quantity: 1 }).Услуга.set(стрижка).rows() // состав комплекса
|
|
3025
|
+
await db.Мастер(иван).навык().create({ level: 'expert' }).Услуга.set(стрижка).rows() // умение
|
|
3026
|
+
await db.Мастер(иван).адрес().create({ default: true }).Локация.set(loc).rows() // мастер работает в локации
|
|
3027
|
+
|
|
3028
|
+
// месячное расписание → окна-смены батчем (окно = мастер доступен в интервале)
|
|
3029
|
+
const [sch] = await db.Организация(org).Расписание().create({ name: 'Август', year: 2026, month: 8 }).rows()
|
|
3030
|
+
db.batch('смены').Мастер(иван).окно().create({ start_datetime: '2026-08-01T07:00:00Z', end_datetime: '2026-08-01T15:00:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
3031
|
+
db.batch('смены').Мастер(олег).окно().create({ start_datetime: '2026-08-01T07:00:00Z', end_datetime: '2026-08-01T15:00:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
3032
|
+
await db.batch('смены').run() // окно — v5-класс: id = uuidv5(Мастер, старт) → поштучно
|
|
3033
|
+
await db.Расписание(sch).окно().Мастер().rows() // ростер смены: [Иван, Олег]
|
|
3034
|
+
|
|
3035
|
+
// бронь = наследник окна; id записи = v5(Мастер, старт), известен ДО создания — двойная бронь мертва
|
|
3036
|
+
const start = '2026-08-01T07:00:00Z', end = '2026-08-01T08:00:00Z'
|
|
3037
|
+
const bId = uuidv5(`v1.booking:entity:booking:${иван.id}:${start}`)
|
|
3038
|
+
const tr = await db.begin()
|
|
3039
|
+
await tr.lock('booking', иван.id, start) // сериализуем соперников на этом слоте
|
|
3040
|
+
if (!(await tr.запись(bId).first())) { // «занято?» — точечный first() по вычисленному id
|
|
3041
|
+
await tr.Мастер(иван).запись().create({ start_datetime: start, end_datetime: end })
|
|
3042
|
+
.Локация.set(loc).Расписание.set(sch).Услуга.set(стрижка).Клиент.set(пётр).rows()
|
|
3043
|
+
}
|
|
3044
|
+
await tr.commit()
|
|
3045
|
+
|
|
3046
|
+
// жизненный цикл, чтения, отмена/перенос
|
|
3047
|
+
await db.запись(bId).update({ notes: 'подтверждена' }).rows() // новая версия
|
|
3048
|
+
await db.Мастер(иван).запись().Услуга().rows() // что забронировано у Ивана
|
|
3049
|
+
await db.Локация(loc).запись().Клиент().rows() // кто записан в локацию
|
|
3050
|
+
await db.запись(bId).delete().rows() // ПРЕВЬЮ: что удалится
|
|
3051
|
+
await db.запись(bId).delete({ confirm: true }).rows() // отмена = tombstone (история цела)
|
|
3052
|
+
// перенос 07:00 → 09:00 = пересоздание (delete старой + create новой в одной транзакции):
|
|
3053
|
+
// id держит (Мастер, старт), поэтому смена времени = новая запись, старая — tombstone
|
|
2835
3054
|
```
|
|
2836
3055
|
|
|
2837
3056
|
---
|
|
@@ -2839,24 +3058,28 @@ const удалено = await db.Запись(bkg.id).delete({ confirm: true }).r
|
|
|
2839
3058
|
## 16. Тесты
|
|
2840
3059
|
|
|
2841
3060
|
```bash
|
|
2842
|
-
npm test #
|
|
3061
|
+
cd lib && npm test # 167/167 тестов против живого docker (timescale + redis), по файлам:
|
|
2843
3062
|
# acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
|
|
2844
3063
|
# с $account, enforceAcl (предикаты в SQL, каскад, watch)
|
|
2845
3064
|
# api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
|
|
2846
3065
|
# auth — db.auth: пароль/api-key/key-secret/TOTP/OTP/link+lookup,
|
|
2847
3066
|
# глобальная идентичность, сессии на живом Redis (expire/revoke)
|
|
2848
|
-
# plan — план-модель: несколько операций, fan-out, self-
|
|
2849
|
-
# превью/confirm delete, ОТКАТ плана,
|
|
3067
|
+
# plan — план-модель: несколько операций, fan-out, self-update,
|
|
3068
|
+
# превью/confirm delete, ОТКАТ плана, слот-перевес, батч-гард
|
|
2850
3069
|
# resilience — ретраи 40P01/40001, реальный deadlock двух транзакций,
|
|
2851
3070
|
# watch переживает обрыв LISTEN (pg_terminate_backend)
|
|
2852
3071
|
# core — триггеры (check/версии/каскад/lineage), обходы, операторы,
|
|
2853
|
-
# модификаторы,
|
|
3072
|
+
# модификаторы, create/update-формы, delete, гонка, батчи, EXPLAIN
|
|
2854
3073
|
# depth — наследование 3+ уровней, data-пути любой глубины
|
|
3074
|
+
# idgen — генерация id по Schema (§ 3.2): v4/v7/v5 из концов и полей,
|
|
3075
|
+
# гонка без локов, наследование attributes и правила id, гарды
|
|
2855
3076
|
# integration — E2E-барбершоп (8 сцен)
|
|
2856
3077
|
# real-life — 16 сцен «дня салона»: 4 руки, гонки ×3, переносы, no-show
|
|
2857
3078
|
# tables — auth/ACL-таблицы
|
|
2858
3079
|
# up — up(): docker-argv, probe-ветка, идемпотентность, fresh,
|
|
2859
3080
|
# свои сиды/seeds:false, автосоздание базы, валидация
|
|
3081
|
+
# salon — ТЕСТ-ПЛАН: имитация салона, ВСЕ 145 публичных API
|
|
3082
|
+
# (21 акт + матрица покрытия); слоты/pivot/entity
|
|
2860
3083
|
# wave2 — asOf/versions, keyset-курсор, gen-types, enforceAccount, anonymize
|
|
2861
3084
|
# wave3 — or/not, агрегации, deep, watch
|
|
2862
3085
|
# wave4 — compression-политики (чтение сжатого чанка), schema-sync, onQuery
|