letopis 0.16.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 +107 -5
- package/README.md +602 -542
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.js +112 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +36 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +73 -0
- package/dist/sessions.js +66 -0
- package/dist/sql.js +90 -1
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.js +36 -0
- package/dist/write.js +148 -2
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +1 -1
- package/sql/ddl.sql +71 -1
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +60 -18
package/README.md
CHANGED
|
@@ -9,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
|
Содержание:
|
|
@@ -53,7 +53,7 @@ const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v
|
|
|
53
53
|
// [letopis.up] connected (schema "v1.booking")
|
|
54
54
|
|
|
55
55
|
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
56
|
-
const [вася] = await db.Организация(org)
|
|
56
|
+
const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
|
|
57
57
|
await db.close()
|
|
58
58
|
```
|
|
59
59
|
|
|
@@ -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,7 +143,7 @@ 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`** |
|
|
@@ -158,22 +158,22 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
158
158
|
|
|
159
159
|
```jsonc
|
|
160
160
|
"links": [
|
|
161
|
-
{ "class": "
|
|
162
|
-
{ "classes": ["Service", "Complex"], "cardinality": 1 },
|
|
163
|
-
{ "class": "
|
|
161
|
+
{ "class": "Staff", "cardinality": 1 }, // один класс
|
|
162
|
+
{ "classes": ["Service", "Product", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
|
|
163
|
+
{ "class": "Customer", "optional": true, "cardinality": 1 } // конец может отсутствовать
|
|
164
164
|
]
|
|
165
165
|
```
|
|
166
166
|
|
|
167
167
|
- `Entity` в концах не используется — классы называются явно («максимально точная идентификация связи»)
|
|
168
|
-
- **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет
|
|
169
|
-
- Обязательный конец без ключа → `requires end "Service|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
|
|
168
|
+
- **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет записи — `{Услуга|Товар|Комплекс}` (бронь услуги ИЛИ продажа товара ИЛИ комплекс — ровно один из)
|
|
169
|
+
- Обязательный конец без ключа → `requires end "Service|Product|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
|
|
170
170
|
- `cardinality` — зарезервировано (0 — безлимит, N — точное число), пока не проверяется: связь класса в строке одна (`{Класс: id}`), множественность выражается строками-связками
|
|
171
|
-
-
|
|
172
|
-
- Источник правды —
|
|
171
|
+
- Демо-домен (0.17, позитивная доступность): `адрес = [Мастер, Локация]`, `окно = [Мастер, Локация, Расписание]` (смена — интервал доступности), `запись = [Мастер, Локация, Расписание, {Услуга|Товар|Комплекс}, Клиент?]` — **наследник окна**, `содержимое = [Папка, {Услуга|Товар|Комплекс}]`, `состав = [Комплекс, {Услуга|Товар}]`, `навык = [Мастер, Услуга]`, `цена = [{Услуга|Товар|Комплекс}]` (варианты цены: `note` + `amounts` record<валюта,число>)
|
|
172
|
+
- Источник правды демо-домена — сид `lib/sql/seed.booking.sql` (редактируется руками, идемпотентен)
|
|
173
173
|
|
|
174
174
|
`schema_lineage` (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
|
|
175
175
|
пересчитывает `ancestors`/`descendants` при любом изменении Schema.
|
|
176
|
-
Из либы: `db.registry.resolve('связь').descendants` → `['
|
|
176
|
+
Из либы: `db.registry.resolve('связь').descendants` → `['address','booking','compo','content','price','skill','slot']`.
|
|
177
177
|
|
|
178
178
|
### 3.2 id считает схема: `attributes.id`
|
|
179
179
|
|
|
@@ -199,21 +199,31 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
199
199
|
без создания чего-либо.
|
|
200
200
|
|
|
201
201
|
```ts
|
|
202
|
-
//
|
|
203
|
-
const [б] = await db
|
|
204
|
-
|
|
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
|
|
205
206
|
// «занято?» — ДО создания чего-либо:
|
|
206
|
-
await db
|
|
207
|
+
await db.запись(uuidv5(`v1.booking:entity:booking:${м.id}:${t}`)).first() // Row | null
|
|
207
208
|
```
|
|
208
209
|
|
|
209
210
|
Явный id у v5-класса запрещён (`computes id` — его всегда считает схема); в батче
|
|
210
211
|
v5-классы не склеиваются в multi-VALUES (id нужны концы) — исполняются поштучно (§ 8).
|
|
211
212
|
|
|
212
213
|
Демо-схема booking: корни `Entity`/`link` объявляют дефолт `{generate: 7}` один раз;
|
|
213
|
-
v7-классы (Org/Staff/
|
|
214
|
-
правила; v5
|
|
215
|
-
`
|
|
216
|
-
|
|
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, не ломая генерацию.
|
|
217
227
|
|
|
218
228
|
### 3.3 Наследование attributes
|
|
219
229
|
|
|
@@ -242,25 +252,25 @@ data.start)`, `Service`/`Complex` ← `v5(Org, data.name)` — имя уника
|
|
|
242
252
|
прямого HUB→HUB пути нет, идут через саму связку и **pivot** (повтор её имени):
|
|
243
253
|
|
|
244
254
|
```ts
|
|
245
|
-
//
|
|
246
|
-
db
|
|
255
|
+
// «записи клиента к, где услуга у и мастер вася» — путь через запись, pivot-возврат:
|
|
256
|
+
db.Клиент(к).запись().Услуга(у).запись().Мастер(вася).запись().rows()
|
|
247
257
|
// «умеет ли Ирина стрижку» — навык, дофильтр предметом, count путей:
|
|
248
|
-
db
|
|
258
|
+
db.Мастер(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
|
|
249
259
|
```
|
|
250
260
|
|
|
251
261
|
### Узел-переменная: `entity()`
|
|
252
262
|
|
|
253
263
|
```ts
|
|
254
|
-
const p = db
|
|
255
|
-
await db
|
|
264
|
+
const p = db.запись() // ленивый узел-паттерн
|
|
265
|
+
await db.Клиент(к).entity(p).Услуга(у).entity(p).Мастер().run() // та же p = тот же узел (явный pivot)
|
|
256
266
|
await db.entity(row).Услуга().first() // старт пути с готового Row
|
|
257
267
|
```
|
|
258
268
|
|
|
259
269
|
### Терминалы
|
|
260
270
|
|
|
261
271
|
```ts
|
|
262
|
-
const пути = await db
|
|
263
|
-
// [{
|
|
272
|
+
const пути = await db.Мастер({ name:'Вася' }).alias('Исполнитель').навык().Услуга().run()
|
|
273
|
+
// [{ Исполнитель: Row, навык: Row, Услуга: Row }, …]
|
|
264
274
|
```
|
|
265
275
|
|
|
266
276
|
| Вызов | Возврат |
|
|
@@ -286,9 +296,9 @@ const пути = await db.Сотрудник({ name:'Вася' }).alias('Мас
|
|
|
286
296
|
db.Услуга('uuid') // по id
|
|
287
297
|
db.Услуга(rowИлиAccount) // объект с id — возьмётся .id
|
|
288
298
|
db.Услуга(['id1','id2']) // по списку ([] → пусто)
|
|
289
|
-
db.Услуга({ name: 'Стрижка',
|
|
299
|
+
db.Услуга({ name: 'Стрижка', duration: 60 }) // eq полей data → GIN-containment
|
|
290
300
|
db.Услуга({ id: 'uuid', duration: gte(30) }) // ключ id — тоже id-фильтр
|
|
291
|
-
db
|
|
301
|
+
db.Локация({ coordinates: { lat: gte(55) } }) // вложенные пути ЛЮБОЙ глубины (object) + каст по листу
|
|
292
302
|
```
|
|
293
303
|
|
|
294
304
|
### Операторы (18: + `not`, `or`)
|
|
@@ -305,29 +315,30 @@ import { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends,
|
|
|
305
315
|
| `gt/gte/lt/lte(v)`, `between(a,b)` | number, date, string | `(data->>'f')::cast ⋛ $` |
|
|
306
316
|
| `inList([…])` | любые | `IN (…)`; `[]` → FALSE |
|
|
307
317
|
| `like/ilike/starts/ends(s)` | string | `[I]LIKE` |
|
|
308
|
-
| `has(v)/hasAll([…])/hasAny([…])` | массивы data
|
|
318
|
+
| `has(v)/hasAll([…])/hasAny([…])` | массивы data и колонка `tags` | `@>` / `?\|` |
|
|
309
319
|
| `exists(true/false)` | любые | ключ есть/нет |
|
|
310
320
|
| `isNull()` | любые | null или отсутствует |
|
|
311
321
|
| `not(op \| скаляр)` | по внутреннему | `NOT (…)`; `not(скаляр)` = `ne` |
|
|
312
|
-
| `or(f1, f2, …)` | **фильтр целиком** | `db
|
|
322
|
+
| `or(f1, f2, …)` | **фильтр целиком** | `db.Услуга(or({name:'Стрижка'}, {duration: lt(40)}))` — дизъюнкция под-фильтров |
|
|
313
323
|
|
|
314
324
|
Касты по `Schema.attributes`: number→`::numeric`, date→`::timestamptz`, boolean→`::boolean`.
|
|
315
325
|
Поле вне схемы фильтруется как text (записать его нельзя — строгая валидация).
|
|
316
|
-
Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`)
|
|
317
|
-
|
|
326
|
+
Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`); сравнения — операторами (`between(t, t)`
|
|
327
|
+
для «равно моменту»: скаляр-eq по дате — строковый jsonb-containment, он про
|
|
328
|
+
нормализованное значение). В демо-схеме массивов в data нет — `has*` живут на `tags`.
|
|
318
329
|
|
|
319
330
|
### Модификаторы цепочки
|
|
320
331
|
|
|
321
332
|
```ts
|
|
322
|
-
db
|
|
323
|
-
db
|
|
333
|
+
db.окно().sort('data.start_datetime').limit(10).offset(20).rows() // выборка
|
|
334
|
+
db.запись().sort('updated', 'desc').limit(50).rows()
|
|
324
335
|
|
|
325
336
|
db.Клиент().tags('vip') // фильтр: tags ⊇ ['vip']
|
|
326
337
|
db.Клиент().tags(['vip','telegram']) // все перечисленные
|
|
327
338
|
db.Клиент().tags(hasAny(['vip','b2b'])) // хотя бы один
|
|
328
|
-
db
|
|
339
|
+
db.запись().account(accId) // фильтр по колонке account (uuid | Row)
|
|
329
340
|
db.Организация().owner(acc) // фильтр по owner
|
|
330
|
-
db
|
|
341
|
+
db.Мастер({…}).alias('Исполнитель') // ключ шага в путях
|
|
331
342
|
```
|
|
332
343
|
|
|
333
344
|
| Модификатор | Область | В чтении | В записи |
|
|
@@ -358,15 +369,15 @@ db.Сотрудник({…}).alias('Мастер') // ключ шаг
|
|
|
358
369
|
|
|
359
370
|
```ts
|
|
360
371
|
// одиночная запись: операция + терминал
|
|
361
|
-
const [вася] = await db.Организация(org)
|
|
372
|
+
const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
|
|
362
373
|
|
|
363
374
|
// несколько операций в одной цепочке: продолжение — ОТ РЕЗУЛЬТАТА предыдущей
|
|
364
|
-
await db
|
|
365
|
-
|
|
366
|
-
.rows()
|
|
375
|
+
await db.Организация().tags('сеть').update({ active: true }) // новая версия всех сетевых орг
|
|
376
|
+
.Услуга().create({ name: 'Акция месяца', duration: 30 }) // INSERT услуги КАЖДОЙ (fan-out)
|
|
377
|
+
.rows() // → акционные услуги
|
|
367
378
|
|
|
368
379
|
// операция сразу после операции — к тем же сущностям (две версии подряд)
|
|
369
|
-
await db
|
|
380
|
+
await db.запись(id).update({ notes: 'подтверждена' }).update({ notes: 'оплачена' }).rows()
|
|
370
381
|
```
|
|
371
382
|
|
|
372
383
|
### Анатомия
|
|
@@ -400,7 +411,7 @@ db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хво
|
|
|
400
411
|
Валидация **строгая, всегда**: поле вне `Schema.attributes` → `ValidationError`;
|
|
401
412
|
default-ы схемы подставляются; id в `data` не хранится (он — колонка).
|
|
402
413
|
- **Deep-merge при `update`** (и у create-версии по известному id): меняются только
|
|
403
|
-
указанные листья — `update({
|
|
414
|
+
указанные листья — `update({ coordinates: { lat: 55.8 } })` сохранит `lng` и остальные
|
|
404
415
|
поля. Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
|
|
405
416
|
- **Концы LINK**: по `Schema.links` v2 (§3.1) — обязательные требуются, союз занимает один ключ, лишние связи — ошибка; значения — id, Row или вложенная цепочка (§6.1).
|
|
406
417
|
- **account/owner NOT NULL**: `.account()/.owner()` → `connect()` → System-аккаунт.
|
|
@@ -411,44 +422,44 @@ db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хво
|
|
|
411
422
|
|
|
412
423
|
Один закон: **владелец создаваемой связки берётся из валидного пути; прочие концы —
|
|
413
424
|
слотами** `.Класс.set(значение)` после глагола записи. Слот — свойство-класс БЕЗ вызова
|
|
414
|
-
(
|
|
425
|
+
(`.Клиент.set(x)`, не `.Клиент(x)`); валиден только для конца из `Schema.links` и
|
|
415
426
|
требует операцию записи ПЕРЕД собой: `…create(…).Класс.set(x)` / `…update(…).Класс.unset()`;
|
|
416
427
|
слот без глагола — ошибка `link slot needs a write`.
|
|
417
428
|
|
|
418
429
|
```ts
|
|
419
|
-
// СОЗДАНИЕ: владелец
|
|
420
|
-
await db
|
|
421
|
-
.Услуга.set(
|
|
430
|
+
// СОЗДАНИЕ: владелец (Мастер) — из пути, остальные концы — слотами; id считает схема (§ 3.2)
|
|
431
|
+
await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
|
|
432
|
+
.Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
|
|
422
433
|
|
|
423
|
-
//
|
|
424
|
-
await db
|
|
434
|
+
// навык: владелец Мастер из пути, предмет — слотом
|
|
435
|
+
await db.Мастер(s).навык().create({ level: 'expert' }).Услуга.set(у).rows()
|
|
425
436
|
|
|
426
437
|
// ПЕРЕВЕС связи (update): цель ищется путём/pivot, слот пишет новое значение БЕЗ фильтра
|
|
427
|
-
await tr
|
|
438
|
+
await tr.Клиент(к).запись().Услуга(старая).запись().update().Услуга.set(новая).rows()
|
|
428
439
|
|
|
429
|
-
// СОЮЗ-конец [
|
|
430
|
-
await db
|
|
440
|
+
// СОЮЗ-конец [Услуга|Товар|Комплекс]: слот замещает конец целиком (соседний класс снимается)
|
|
441
|
+
await db.запись(b).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
|
|
431
442
|
|
|
432
|
-
// СНЯТИЕ optional
|
|
433
|
-
await db
|
|
443
|
+
// СНЯТИЕ optional-конца: ручная бронь — клиент отвязан, имя в notes
|
|
444
|
+
await db.запись(b).update({ notes: 'по телефону: Аня' }).Клиент.unset().rows()
|
|
434
445
|
|
|
435
446
|
// вложенная цепочка как значение слота — та же транзакция, ровно одна сущность:
|
|
436
|
-
await db
|
|
437
|
-
|
|
438
|
-
|
|
447
|
+
await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
|
|
448
|
+
.Локация.set(л).Расписание.set(р)
|
|
449
|
+
.Услуга.set(db.Организация(org).Услуга().create({ name: 'Новинка', duration: 45 })).rows() // создать И привязать
|
|
439
450
|
```
|
|
440
451
|
|
|
441
452
|
### `.delete({ confirm })` — превью и серверное удаление
|
|
442
453
|
|
|
443
454
|
```ts
|
|
444
|
-
const превью = await db
|
|
445
|
-
// кандидаты (цель +
|
|
446
|
-
// [{class:'
|
|
455
|
+
const превью = await db.Клиент(cid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
|
|
456
|
+
// кандидаты (цель + каскад: записи клиента), живые, БД не тронута:
|
|
457
|
+
// [{class:'Customer'}, {class:'booking'}]
|
|
447
458
|
|
|
448
|
-
const удалено = await db
|
|
459
|
+
const удалено = await db.Клиент(cid).delete({ confirm: true }).rows()
|
|
449
460
|
// удалённое дерево, каждый Row с $deleted: true
|
|
450
461
|
|
|
451
|
-
await db
|
|
462
|
+
await db.Мастер(m).окно().delete({ confirm: true }).rows() // контекст: только окна мастера
|
|
452
463
|
```
|
|
453
464
|
|
|
454
465
|
Цели = фильтр шага + контекст-связи. Замыкание (цели + все живые зависимые) собирается
|
|
@@ -459,10 +470,10 @@ id — воскрешение.
|
|
|
459
470
|
### Откат плана
|
|
460
471
|
|
|
461
472
|
```ts
|
|
462
|
-
await db
|
|
463
|
-
|
|
473
|
+
await db.Организация(org).update({ phone: '+7 495 …' }) // валидный сегмент…
|
|
474
|
+
.Услуга().create({ name: 'X', чепуха: 1 }) // …невалидный: ValidationError
|
|
464
475
|
.rows()
|
|
465
|
-
// ← ОТКАТ ВСЕГО:
|
|
476
|
+
// ← ОТКАТ ВСЕГО: телефон не изменился, версий не прибавилось
|
|
466
477
|
```
|
|
467
478
|
|
|
468
479
|
---
|
|
@@ -470,19 +481,20 @@ await db.Клиент(id).update({ name: 'Новое' }) // валидный
|
|
|
470
481
|
## 7. Транзакции
|
|
471
482
|
|
|
472
483
|
```ts
|
|
473
|
-
const
|
|
484
|
+
const bookingId = uuidv5(`v1.booking:entity:booking:${staffId}:${start}`) // id известен ДО создания (§ 3.2)
|
|
474
485
|
|
|
475
486
|
const tr = await db.begin() // тот же API на выделенном соединении
|
|
476
|
-
await tr.lock('
|
|
477
|
-
const занято = await tr
|
|
478
|
-
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()
|
|
479
491
|
await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()
|
|
480
492
|
```
|
|
481
493
|
|
|
482
494
|
Повторный commit/rollback — no-op. `lock()` вне транзакции — ошибка. Держите транзакции
|
|
483
|
-
короткими. **Рецепт двойной брони**: id
|
|
484
|
-
что дубль невозможен в принципе (второй `create` стал бы версией той же
|
|
485
|
-
`lock(мастер,
|
|
495
|
+
короткими. **Рецепт двойной брони**: id записи детерминирован схемой (v5 — § 3.2), так
|
|
496
|
+
что дубль невозможен в принципе (второй `create` стал бы версией той же записи);
|
|
497
|
+
`lock(мастер, старт)` + перечитка `first()` под локом нужны, чтобы сопернику честно
|
|
486
498
|
ОТКАЗАТЬ, а не молча версионировать чужую бронь (ровно одна успешна — покрыто тестом-гонкой).
|
|
487
499
|
|
|
488
500
|
---
|
|
@@ -490,11 +502,12 @@ await db.commit(tr) // или db.rollback(tr) / tr.comm
|
|
|
490
502
|
## 8. Батчи
|
|
491
503
|
|
|
492
504
|
```ts
|
|
493
|
-
db.batch('
|
|
494
|
-
|
|
495
|
-
db.batch('
|
|
496
|
-
|
|
497
|
-
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() // отменить
|
|
498
511
|
```
|
|
499
512
|
|
|
500
513
|
- `run()` атомарен: любая ошибка откатывает всё.
|
|
@@ -620,7 +633,7 @@ lockout после N неудач. Legacy-bcrypt-хэши (`$2b$…` из дам
|
|
|
620
633
|
|---|---|---|
|
|
621
634
|
| `ACCOUNT` | аккаунта | `{"categories": "{Staff}"}`; `"!{Anonymous,Shadow}"` — нет ни одной; NULL — все |
|
|
622
635
|
| `API` | адреса эндпоинта | `{"endpoint": "v2.auth.apikey.*"}` — маска: `.`-сегменты, `{a,b}`, `*` — хвост |
|
|
623
|
-
| `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "
|
|
636
|
+
| `READ` / `WRITE` / `DELETE` | **строки Entity** (операция = категория) | `{"class": "booking", "owner": "$account"}` |
|
|
624
637
|
|
|
625
638
|
Pattern операций — реальные колонки Entity (`class`, `owner`, `account`, `tags`, `data`,
|
|
626
639
|
`links`…), значения — литералы или `"$account"` (id субъекта, подставляется в запрос);
|
|
@@ -637,7 +650,7 @@ await db.acl.check(anon, 'v2.auth.password.signup')
|
|
|
637
650
|
await db.acl.check(anon, 'v2.auth.apikey.create')
|
|
638
651
|
// → { allow: false, code: 403, message: 'Access denied - default …' } — победило дно-правило deny 0
|
|
639
652
|
|
|
640
|
-
await db.acl.checkData(user, '
|
|
653
|
+
await db.acl.checkData(user, 'booking', 'READ') // ресурс {"class":"booking","owner":"$account"}
|
|
641
654
|
// → { allow: true, filter: { owner: '24c49a43-…' }, rule: {…weight: 60} } [1.7 ms]
|
|
642
655
|
await db.acl.checkData(user, 'SportsCar', 'READ') // право дал предок Vehicle (lineage)
|
|
643
656
|
// → { allow: true } — безусловный, без предиката
|
|
@@ -653,29 +666,30 @@ allow-классов (payload нечем проверить предикат).
|
|
|
653
666
|
|
|
654
667
|
```ts
|
|
655
668
|
const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
|
|
656
|
-
await u
|
|
657
|
-
await u
|
|
669
|
+
await u.запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
|
|
670
|
+
await u.запись().count() // честный count по суженному множеству
|
|
658
671
|
await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
|
|
659
|
-
const [z] = await u
|
|
660
|
-
await u
|
|
661
|
-
await u
|
|
662
|
-
// create
|
|
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 чужой записи) тоже НЕ перехватывает: → [] вместо новой версии
|
|
663
676
|
```
|
|
664
677
|
|
|
665
|
-
Оверхед (
|
|
678
|
+
Оверхед (полигон `v1.salondemo` ~980k, booking 440k×2; p50 из 20; `bench/acl.bench.mjs`):
|
|
666
679
|
|
|
667
680
|
| Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
|
|
668
681
|
|---|---|---|---|
|
|
669
|
-
| точечный `first(id)` | 2.
|
|
670
|
-
| фильтр `rows` limit 100 |
|
|
671
|
-
| keyset-страница всего класса
|
|
672
|
-
| `count()` класса
|
|
673
|
-
| цепочка
|
|
674
|
-
|
|
675
|
-
Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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 за категориями аккаунта на каждый вызов);
|
|
679
693
|
горячий путь цепочек использует резолвер, скомпилированный на connect (микросекунды, memo).
|
|
680
694
|
|
|
681
695
|
`enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
|
|
@@ -686,8 +700,8 @@ worst-case (пропускает 100% строк — чистый оверхед
|
|
|
686
700
|
|
|
687
701
|
```ts
|
|
688
702
|
await db.Услуга().rows() // срез класса (актуальное, живое)
|
|
689
|
-
await db
|
|
690
|
-
await db.Клиент(cid)
|
|
703
|
+
await db.запись().sort('updated','desc').limit(50).rows()
|
|
704
|
+
await db.Клиент(cid).запись().Услуга().run() // связный подграф
|
|
691
705
|
```
|
|
692
706
|
|
|
693
707
|
```sql
|
|
@@ -697,7 +711,7 @@ SELECT * FROM (SELECT DISTINCT ON (class, id) * FROM "v1.booking"."Entity"
|
|
|
697
711
|
|
|
698
712
|
-- история сущности (все версии, включая tombstone)
|
|
699
713
|
SELECT updated, deleted, data, links FROM "v1.booking"."Entity"
|
|
700
|
-
WHERE partition='entity' AND class='
|
|
714
|
+
WHERE partition='entity' AND class='booking' AND id=$1 ORDER BY updated;
|
|
701
715
|
```
|
|
702
716
|
|
|
703
717
|
### 10.1 Время-путешествия: `.asOf()` / `.versions()`
|
|
@@ -705,11 +719,11 @@ WHERE partition='entity' AND class='Booking' AND id=$1 ORDER BY updated;
|
|
|
705
719
|
```ts
|
|
706
720
|
// «какая цена была на момент брони» — версии позже T невидимы, tombstone до T = «удалён»
|
|
707
721
|
const тогда = await db.Услуга(id).asOf('2026-07-01T12:00:00Z').first()
|
|
708
|
-
const срезДня = await db
|
|
722
|
+
const срезДня = await db.запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
|
|
709
723
|
|
|
710
724
|
// вся история сущности без сырого SQL (tombstone-версии приходят с $deleted: true)
|
|
711
|
-
const история = await db
|
|
712
|
-
// [{data:{
|
|
725
|
+
const история = await db.запись(id).versions()
|
|
726
|
+
// [{data:{…}}, {data:{…, notes:'подтверждена'}}, {…, $deleted:true}]
|
|
713
727
|
```
|
|
714
728
|
|
|
715
729
|
`asOf` применяется к каждому шагу цепочки — подграф целиком «как был». `versions()`
|
|
@@ -723,19 +737,19 @@ Keyset — закладка: курсор = значение поля сорти
|
|
|
723
737
|
порядок тотальным, дубли значений не теряются и не повторяются).
|
|
724
738
|
|
|
725
739
|
```ts
|
|
726
|
-
const стр1 = await db
|
|
740
|
+
const стр1 = await db.запись().sort('updated', 'desc').limit(50).rows()
|
|
727
741
|
const кур = cursorOf(стр1.at(-1)!) // { v: '<updated>', id: '…' } — просто объект,
|
|
728
|
-
const стр2 = await db
|
|
742
|
+
const стр2 = await db.запись().sort('updated', 'desc') // можно хранить в URL/state
|
|
729
743
|
.after(кур).limit(50).rows()
|
|
730
744
|
|
|
731
745
|
// по data-пути ЛЮБОЙ глубины — field тот же, что в sort
|
|
732
|
-
const дальше = await db
|
|
733
|
-
.after(cursorOf(окно, 'data.
|
|
746
|
+
const дальше = await db.окно().sort('data.start_datetime')
|
|
747
|
+
.after(cursorOf(окно, 'data.start_datetime')).limit(20).rows()
|
|
734
748
|
|
|
735
749
|
// бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
|
|
736
750
|
let cursor
|
|
737
751
|
do {
|
|
738
|
-
const page = await db
|
|
752
|
+
const page = await db.Мастер(m).запись().sort('updated', 'desc')
|
|
739
753
|
.after(cursor).limit(50).rows()
|
|
740
754
|
render(page)
|
|
741
755
|
cursor = page.length ? cursorOf(page.at(-1)!) : undefined
|
|
@@ -752,14 +766,14 @@ do {
|
|
|
752
766
|
### 10.3 Агрегации: считает БД
|
|
753
767
|
|
|
754
768
|
```ts
|
|
755
|
-
await db
|
|
756
|
-
await db.Услуга().avg('data.duration')
|
|
757
|
-
await db
|
|
758
|
-
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
|
|
759
773
|
```
|
|
760
774
|
|
|
761
|
-
Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или
|
|
762
|
-
`'data.
|
|
775
|
+
Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или вложенный лист
|
|
776
|
+
(`'data.coordinates.lat'`). `sum`/`avg` кастуются в numeric; пустое множество → `null`.
|
|
763
777
|
|
|
764
778
|
### 10.4 Деревья: `.deep()`
|
|
765
779
|
|
|
@@ -775,7 +789,7 @@ reverse = дети); на другом переходе — ошибка. Раб
|
|
|
775
789
|
### 10.5 Realtime: `db.watch()`
|
|
776
790
|
|
|
777
791
|
```ts
|
|
778
|
-
const stop = await db.watch('
|
|
792
|
+
const stop = await db.watch('запись', (e) => {
|
|
779
793
|
// e = { partition, class, id, updated, deleted } — факт версии (insert/update/tombstone)
|
|
780
794
|
обновитьКалендарь(e.id)
|
|
781
795
|
})
|
|
@@ -791,7 +805,7 @@ await stop() // отписка
|
|
|
791
805
|
повторяет LISTEN), но `NOTIFY` за время разрыва потеряны — для этого `onReconnect`:
|
|
792
806
|
|
|
793
807
|
```ts
|
|
794
|
-
const stop = await db.watch('
|
|
808
|
+
const stop = await db.watch('запись', onEvent, {
|
|
795
809
|
onReconnect: () => дочитатьПропущенное(), // напр. перечитать всё с последнего e.updated
|
|
796
810
|
})
|
|
797
811
|
```
|
|
@@ -800,9 +814,9 @@ const stop = await db.watch('Запись', onEvent, {
|
|
|
800
814
|
|
|
801
815
|
```ts
|
|
802
816
|
const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
|
|
803
|
-
await db
|
|
804
|
-
await db.Клиент().create({ name: 'X' }) // запись пришпилена к account
|
|
805
|
-
db
|
|
817
|
+
await db.запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
|
|
818
|
+
await db.Клиент().create({ name: 'X', phone: '+7…' }) // запись пришпилена к account
|
|
819
|
+
db.запись().account(чужой).rows() // ошибка: reads are pinned to account …
|
|
806
820
|
```
|
|
807
821
|
|
|
808
822
|
Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
|
|
@@ -812,7 +826,7 @@ db.Запись().account(чужой).rows() // ошибка: reads are p
|
|
|
812
826
|
### 10.7 Анонимизация (GDPR): `.anonymize()`
|
|
813
827
|
|
|
814
828
|
```ts
|
|
815
|
-
await db.Клиент(id).anonymize(['name', '
|
|
829
|
+
await db.Клиент(id).anonymize(['name', 'phone']).rows()
|
|
816
830
|
// новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
|
|
817
831
|
```
|
|
818
832
|
|
|
@@ -839,7 +853,7 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
|
|
|
839
853
|
### 10.9 Миграции классов: `scripts/schema-sync.mjs`
|
|
840
854
|
|
|
841
855
|
```bash
|
|
842
|
-
node scripts/schema-sync.mjs --file
|
|
856
|
+
node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
|
|
843
857
|
# schema-sync: … ↔ booking.Schema (partition entity)
|
|
844
858
|
# + Coupon (HUB · Купон) — новый класс
|
|
845
859
|
# ~ Service — изменены: attributes
|
|
@@ -877,7 +891,7 @@ npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d
|
|
|
877
891
|
```ts
|
|
878
892
|
import type { TypedDb } from './entity-types'
|
|
879
893
|
const t = db as unknown as TypedDb
|
|
880
|
-
const [svc] = await t.Услуга({
|
|
894
|
+
const [svc] = await t.Услуга({ duration: 60 }).rows() // svc.data.duration: number
|
|
881
895
|
```
|
|
882
896
|
|
|
883
897
|
Интерфейсы data-полей всех классов (enum → union-литералы, record → `Partial<Record<…>>`)
|
|
@@ -914,11 +928,12 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
|
|
|
914
928
|
|
|
915
929
|
Каждый метод описан по одной схеме: **сигнатура → параметры → назначение и алгоритм →
|
|
916
930
|
примеры (под каждым вызовом реальный ответ и время) → полный кейс**. Все ответы и тайминги —
|
|
917
|
-
**живой прогон** на
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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` в ответах опущены.
|
|
922
937
|
|
|
923
938
|
Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
|
|
924
939
|
[11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
|
|
@@ -956,23 +971,23 @@ NOT NULL `Entity.account`. При `enforceAcl: true` дополнительно
|
|
|
956
971
|
**Примеры**
|
|
957
972
|
|
|
958
973
|
```ts
|
|
959
|
-
const db = await connect({ dsn, schema: 'v1.
|
|
960
|
-
await db.Услуга(
|
|
961
|
-
// событие 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}
|
|
962
977
|
|
|
963
|
-
const dbSlow = await connect({ dsn, schema: 'v1.
|
|
964
|
-
await dbSlow
|
|
965
|
-
// {"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}
|
|
966
981
|
```
|
|
967
982
|
|
|
968
983
|
**Кейс: три подключения — обычное, изолированное, под ACL**
|
|
969
984
|
|
|
970
985
|
```ts
|
|
971
|
-
const db = await connect({ dsn, schema }) // [
|
|
972
|
-
const iso = await connect({ dsn, schema, account: acc.id, enforceAccount: true }) // [
|
|
973
|
-
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [
|
|
974
|
-
await db.Организация().count() // →
|
|
975
|
-
await iso.Организация().count() // [
|
|
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 — только свой арендатор
|
|
976
991
|
await uc.Организация().rows()
|
|
977
992
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default)
|
|
978
993
|
await iso.close(); await uc.close()
|
|
@@ -1045,14 +1060,14 @@ await up({ dsn, schema: 'v1.booking', version: 1 })
|
|
|
1045
1060
|
|
|
1046
1061
|
```ts
|
|
1047
1062
|
const db = await up({ schema: 'booking', version: 1, dsn }) // [7.4 s] образ+контейнер+схема
|
|
1048
|
-
await db
|
|
1049
|
-
|
|
1063
|
+
await db.Организация().count() // → 0 — свежая схема
|
|
1064
|
+
await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
1050
1065
|
await db.close()
|
|
1051
1066
|
|
|
1052
1067
|
// … docker stop letopis-timescale (ребут, уборка, что угодно) …
|
|
1053
1068
|
|
|
1054
1069
|
const db2 = await up({ schema: 'booking', version: 1, dsn }) // [1.2 s] start + reuse
|
|
1055
|
-
await db2
|
|
1070
|
+
await db2.Организация().count() // → 1 — volume letopis-pgdata: всё на месте
|
|
1056
1071
|
await db2.close()
|
|
1057
1072
|
```
|
|
1058
1073
|
|
|
@@ -1086,7 +1101,7 @@ import type {
|
|
|
1086
1101
|
|
|
1087
1102
|
| Параметр | Тип | Описание |
|
|
1088
1103
|
|---|---|---|
|
|
1089
|
-
| `Класс` | имя свойства | id или алиас класса из `Schema` (`db.
|
|
1104
|
+
| `Класс` | имя свойства | id или алиас класса из `Schema` (`db.booking` ≡ `db.запись`); неизвестное имя — ошибка со списком классов |
|
|
1090
1105
|
| `filter` | `Filter?` | без аргумента — весь класс; `string` — по id; `string[]` — по списку id; `Row`/объект с `.id` — как id; объект — поля `data` (равенство, операторы § 11.4, record-пути) + ключ `id` |
|
|
1091
1106
|
|
|
1092
1107
|
**Назначение и алгоритм.** Старт цепочки чтения/записи (§ 4–6). Ничего не выполняет —
|
|
@@ -1097,10 +1112,10 @@ import type {
|
|
|
1097
1112
|
**Примеры**
|
|
1098
1113
|
|
|
1099
1114
|
```ts
|
|
1100
|
-
await db.Услуга('…
|
|
1101
|
-
await db.Услуга(['…
|
|
1102
|
-
await db.Организация(org).first() // [2.
|
|
1103
|
-
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 (фильтр по цене)
|
|
1104
1119
|
await db.НетТакогоКласса().rows()
|
|
1105
1120
|
// Error: letopis: unknown class "НетТакогоКласса". Known: Entity·Сущность, Org·Организация, …
|
|
1106
1121
|
```
|
|
@@ -1108,10 +1123,10 @@ await db.НетТакогоКласса().rows()
|
|
|
1108
1123
|
**Кейс: одна сущность тремя формами фильтра**
|
|
1109
1124
|
|
|
1110
1125
|
```ts
|
|
1111
|
-
const поId
|
|
1112
|
-
const поПолю
|
|
1113
|
-
const поОбъекту = await db.Услуга(поId).first()
|
|
1114
|
-
// все три → 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)
|
|
1115
1130
|
```
|
|
1116
1131
|
|
|
1117
1132
|
#### `db.begin(): Promise<EntityTx>`
|
|
@@ -1130,19 +1145,20 @@ deadlock/serialization ошибка приходит сразу с подска
|
|
|
1130
1145
|
|
|
1131
1146
|
```ts
|
|
1132
1147
|
const tr = await db.begin() // [0.8 ms]
|
|
1133
|
-
await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15
|
|
1134
|
-
// [
|
|
1135
|
-
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] — теперь видно всем
|
|
1136
1151
|
```
|
|
1137
1152
|
|
|
1138
1153
|
**Кейс: атомарный перенос с откатом при провале** — § 11.6 (`tr.lock`), плюс rollback:
|
|
1139
1154
|
|
|
1140
1155
|
```ts
|
|
1156
|
+
const [цУкл] = await db.Услуга({ name: 'Укладка' }).цена().create({ amounts: { RUB: 700 } }).rows() // базовая цена
|
|
1141
1157
|
const tr = await db.begin()
|
|
1142
|
-
await tr
|
|
1143
|
-
await tr
|
|
1144
|
-
await tr.rollback() // [
|
|
1145
|
-
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 — изменение исчезло
|
|
1146
1162
|
```
|
|
1147
1163
|
|
|
1148
1164
|
#### `db.commit(tr): Promise<void>` / `db.rollback(tr): Promise<void>`
|
|
@@ -1157,7 +1173,7 @@ await db.Услуга({ name: 'Укладка' }).first() // снаружи
|
|
|
1157
1173
|
**Примеры**
|
|
1158
1174
|
|
|
1159
1175
|
```ts
|
|
1160
|
-
await db.commit(tr3) // [
|
|
1176
|
+
await db.commit(tr3) // [4.0 ms] — то же, что tr3.commit()
|
|
1161
1177
|
await db.commit()
|
|
1162
1178
|
// Error: letopis: commit() needs a transaction: db.commit(tr) or tr.commit() [0.1 ms]
|
|
1163
1179
|
```
|
|
@@ -1183,14 +1199,14 @@ Tombstone-версия приходит с `deleted: true`.
|
|
|
1183
1199
|
**Примеры**
|
|
1184
1200
|
|
|
1185
1201
|
```ts
|
|
1186
|
-
const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [
|
|
1187
|
-
await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр §11' }).rows()
|
|
1188
|
-
await db.Клиент('…0932').update({
|
|
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()
|
|
1189
1205
|
await db.Клиент('…0932').delete({ confirm: true }).rows()
|
|
1190
1206
|
// пойманные (3 события: insert → update → tombstone):
|
|
1191
|
-
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…
|
|
1192
|
-
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…
|
|
1193
|
-
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…
|
|
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 }
|
|
1194
1210
|
await stop()
|
|
1195
1211
|
```
|
|
1196
1212
|
|
|
@@ -1202,8 +1218,8 @@ const stop = await db.watch('Клиент', (e) => события.push(e), {
|
|
|
1202
1218
|
})
|
|
1203
1219
|
// авария: pg_terminate_backend по LISTEN-соединению …
|
|
1204
1220
|
// onReconnect сработал через 0.1 s после обрыва (реальный прогон)
|
|
1205
|
-
await db
|
|
1206
|
-
// событие после 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 }
|
|
1207
1223
|
await stop()
|
|
1208
1224
|
```
|
|
1209
1225
|
|
|
@@ -1213,7 +1229,7 @@ await stop()
|
|
|
1213
1229
|
падают ошибкой postgres.js.
|
|
1214
1230
|
|
|
1215
1231
|
```ts
|
|
1216
|
-
await db.close() // [1.
|
|
1232
|
+
await db.close() // [1.3 ms]
|
|
1217
1233
|
```
|
|
1218
1234
|
|
|
1219
1235
|
**Кейс** — завершение процесса: `close()` в `finally`/`SIGTERM`-хендлере после `stop()`
|
|
@@ -1225,7 +1241,7 @@ await db.close() // [1.7 ms]
|
|
|
1225
1241
|
и поле `all` — § 11.11.
|
|
1226
1242
|
|
|
1227
1243
|
```ts
|
|
1228
|
-
db.registry.resolve('
|
|
1244
|
+
db.registry.resolve('запись') // [99 µs] → ClassDef {id: 'booking', alias: 'запись', …}
|
|
1229
1245
|
```
|
|
1230
1246
|
|
|
1231
1247
|
#### `db.sql`
|
|
@@ -1234,8 +1250,8 @@ db.registry.resolve('Запись') // [90 µs] → ClassDef {id: 'Booking', a
|
|
|
1234
1250
|
Ответственность за SQL — на вызывающем (движок цепочек его не проверяет).
|
|
1235
1251
|
|
|
1236
1252
|
```ts
|
|
1237
|
-
await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.
|
|
1238
|
-
// [
|
|
1253
|
+
await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.salondemo"."Entity"')
|
|
1254
|
+
// [37.3 ms] → [{ n: 980216 }]
|
|
1239
1255
|
```
|
|
1240
1256
|
|
|
1241
1257
|
**Кейс** — снятие плана тяжёлого запроса: `db.sql.unsafe('EXPLAIN (ANALYZE) …')` для
|
|
@@ -1267,8 +1283,8 @@ prev.links->>'Класс'`) кладётся точным равенством,
|
|
|
1267
1283
|
**Примеры**
|
|
1268
1284
|
|
|
1269
1285
|
```ts
|
|
1270
|
-
await db.Организация('…
|
|
1271
|
-
await db.навык().Услуга().count()
|
|
1286
|
+
await db.Организация('…0000').Мастер().count() // [5.4 ms] → 12 (обратный hop)
|
|
1287
|
+
await db.навык().Услуга().count() // [65.2 ms] → 1260 (LINK → HUB, прямой)
|
|
1272
1288
|
```
|
|
1273
1289
|
|
|
1274
1290
|
**Кейс: маршрут «мастер → его навыки → услуги»** — см. `.run()` ниже (тот же прогон).
|
|
@@ -1285,20 +1301,21 @@ await db.навык().Услуга().count() // [57.2 ms] →
|
|
|
1285
1301
|
**Примеры**
|
|
1286
1302
|
|
|
1287
1303
|
```ts
|
|
1288
|
-
await db
|
|
1289
|
-
// →
|
|
1290
|
-
//
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
// }
|
|
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
|
+
// }, …] — по объекту на путь, полные узлы каждого шага
|
|
1294
1311
|
```
|
|
1295
1312
|
|
|
1296
1313
|
**Кейс: отчёт «кто что умеет» одним запросом**
|
|
1297
1314
|
|
|
1298
1315
|
```ts
|
|
1299
|
-
const пути = await db
|
|
1300
|
-
пути.map((p) => `${p
|
|
1301
|
-
// → ['
|
|
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']
|
|
1302
1319
|
```
|
|
1303
1320
|
|
|
1304
1321
|
#### `.rows(): Promise<Row[]>`
|
|
@@ -1311,17 +1328,18 @@ const пути = await db.Сотрудник({ name: 'Вася' }).навык().
|
|
|
1311
1328
|
**Примеры**
|
|
1312
1329
|
|
|
1313
1330
|
```ts
|
|
1314
|
-
const услуги = await db.Услуга().rows() // [
|
|
1315
|
-
// [0] = { id: '
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
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' }
|
|
1318
1336
|
```
|
|
1319
1337
|
|
|
1320
1338
|
**Кейс: пути vs уникальные сущности**
|
|
1321
1339
|
|
|
1322
1340
|
```ts
|
|
1323
|
-
await db.навык().Услуга().count() // [
|
|
1324
|
-
(await db.навык().Услуга().rows()).length // [
|
|
1341
|
+
await db.навык().Услуга().count() // [65.2 ms] → 1260 путей (навык → услуга)
|
|
1342
|
+
(await db.навык().Услуга().rows()).length // [83.6 ms] → 600 уникальных услуг
|
|
1325
1343
|
```
|
|
1326
1344
|
|
|
1327
1345
|
#### `.first(): Promise<Row | null>`
|
|
@@ -1329,10 +1347,10 @@ await db.навык().Услуга().count() // [57.2 ms] → 1001 пу
|
|
|
1329
1347
|
Параметров нет. То же, что `rows()` с `LIMIT 1`: первая строка или `null`.
|
|
1330
1348
|
|
|
1331
1349
|
```ts
|
|
1332
|
-
await db
|
|
1333
|
-
// → { id: '…
|
|
1334
|
-
// links: { Org: '…
|
|
1335
|
-
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
|
|
1336
1354
|
```
|
|
1337
1355
|
|
|
1338
1356
|
**Кейс: проверка «занято ли окно» перед бронью** — § 11.6 (перечитка под локом).
|
|
@@ -1343,8 +1361,8 @@ await db.Сотрудник({ name: 'Гэндальф' }).first() // [3.9 ms]
|
|
|
1343
1361
|
(дешевле по трафику).
|
|
1344
1362
|
|
|
1345
1363
|
```ts
|
|
1346
|
-
await db
|
|
1347
|
-
// ['
|
|
1364
|
+
await db.Мастер().ids() // [4.3 ms] → 360 id
|
|
1365
|
+
// ['00000003-0000-4000-8000-000000000000', '…0001', '00000003-0000-4000-8000-000000000002', …]
|
|
1348
1366
|
```
|
|
1349
1367
|
|
|
1350
1368
|
**Кейс: набор id для батч-обработки** — собрать `ids()`, скормить очереди задач; полные
|
|
@@ -1359,17 +1377,17 @@ await db.Сотрудник().ids() // [3.8 ms] → 302 id
|
|
|
1359
1377
|
для многошаговой — нет (см. кейс `.rows()`).
|
|
1360
1378
|
|
|
1361
1379
|
```ts
|
|
1362
|
-
await db.Организация('…
|
|
1363
|
-
await db
|
|
1364
|
-
await db
|
|
1365
|
-
// [
|
|
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
|
|
1366
1384
|
```
|
|
1367
1385
|
|
|
1368
1386
|
**Кейс: витрина каталога** — счётчики к фильтрам без выборки строк:
|
|
1369
1387
|
|
|
1370
1388
|
```ts
|
|
1371
|
-
await db.Услуга({ duration: lte(45) }).count() // [3.
|
|
1372
|
-
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 «премиум»
|
|
1373
1391
|
```
|
|
1374
1392
|
|
|
1375
1393
|
#### `.limit(n): Chain` / `.offset(n): Chain`
|
|
@@ -1385,10 +1403,10 @@ await db.Услуга({ price: { RUB: gte(1300) } }).count() // [3.4 ms] → 3
|
|
|
1385
1403
|
**Примеры**
|
|
1386
1404
|
|
|
1387
1405
|
```ts
|
|
1388
|
-
await db
|
|
1389
|
-
// → [{
|
|
1390
|
-
await db
|
|
1391
|
-
// → [{ 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 }] — вторая страница
|
|
1392
1410
|
```
|
|
1393
1411
|
|
|
1394
1412
|
**Кейс: классическая пагинация страницы каталога** — `limit(3)` + `offset(3·N)`; при выходе
|
|
@@ -1398,21 +1416,21 @@ await db.Услуга().sort('data.price.RUB', 'desc').limit(3).offset(3).rows()
|
|
|
1398
1416
|
|
|
1399
1417
|
| Параметр | Тип | Описание |
|
|
1400
1418
|
|---|---|---|
|
|
1401
|
-
| `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.
|
|
1419
|
+
| `field` | `'updated'` \| `'data.<путь>'` | путь любой глубины (`data.coordinates.lat`); SQL-каст по типу листа из Schema |
|
|
1402
1420
|
| `dir` | `'asc'` \| `'desc'` \| `boolean?` | default `asc`; `true` ≡ `'desc'` |
|
|
1403
1421
|
|
|
1404
1422
|
**Назначение и алгоритм.** `ORDER BY` по колонке `updated` или по выражению
|
|
1405
|
-
`data->'
|
|
1423
|
+
`data->'coordinates'->>'lat'` с кастом (numeric/text/timestamptz — из типа листа в Schema).
|
|
1406
1424
|
Обязателен для `.after()`.
|
|
1407
1425
|
|
|
1408
1426
|
**Примеры**
|
|
1409
1427
|
|
|
1410
1428
|
```ts
|
|
1411
|
-
await db
|
|
1412
|
-
await db.Услуга().sort('data.duration').limit(2).rows()
|
|
1413
|
-
// → [{ name: '
|
|
1414
|
-
await db
|
|
1415
|
-
// ЧЕСТНО: 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
|
|
1416
1434
|
```
|
|
1417
1435
|
|
|
1418
1436
|
**Кейс: топ прайса** — первый пример; правило объёма: сортировка **всего** большого класса
|
|
@@ -1432,15 +1450,15 @@ await db.Запись().sort('updated', 'desc').limit(2).rows() // [161
|
|
|
1432
1450
|
**Примеры**
|
|
1433
1451
|
|
|
1434
1452
|
```ts
|
|
1435
|
-
const истор = await db
|
|
1436
|
-
await db
|
|
1437
|
-
// → data.
|
|
1438
|
-
await db
|
|
1439
|
-
// → 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 — цена сейчас
|
|
1440
1458
|
```
|
|
1441
1459
|
|
|
1442
1460
|
**Кейс: спор по чеку** — «сколько стоила стрижка в момент оформления записи»:
|
|
1443
|
-
`db
|
|
1461
|
+
`db.цена(id).asOf(запись.updated).first()` → исторический прайс без отдельных таблиц аудита.
|
|
1444
1462
|
|
|
1445
1463
|
#### `.versions(): Promise<Row[]>`
|
|
1446
1464
|
|
|
@@ -1454,14 +1472,12 @@ await db.Услуга('…0021').first()
|
|
|
1454
1472
|
**Примеры**
|
|
1455
1473
|
|
|
1456
1474
|
```ts
|
|
1457
|
-
await db
|
|
1458
|
-
// → [{
|
|
1459
|
-
//
|
|
1460
|
-
|
|
1461
|
-
//
|
|
1462
|
-
// {
|
|
1463
|
-
// { status: 'confirmed', updated: '…33.652394', $deleted: true }, ← tombstone
|
|
1464
|
-
// { 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)
|
|
1465
1481
|
```
|
|
1466
1482
|
|
|
1467
1483
|
**Кейс: аудит «кто когда менял»** — `versions()` + `owner` каждой версии = полный
|
|
@@ -1483,18 +1499,19 @@ await db.Запись('…0061').versions() // [6.7 ms] — жизнь с уд
|
|
|
1483
1499
|
**Примеры**
|
|
1484
1500
|
|
|
1485
1501
|
```ts
|
|
1486
|
-
const p1 = await db
|
|
1487
|
-
const кур = cursorOf(p1.at(-1)) // [
|
|
1488
|
-
// → { v: '2026-07-
|
|
1489
|
-
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]
|
|
1490
1506
|
// p2 — следующие 3, пересечение страниц: 0
|
|
1507
|
+
// (у тысяч записей последнего дня updated совпадает — id вторым ключом ORDER BY держит границу)
|
|
1491
1508
|
|
|
1492
|
-
const курЦены = cursorOf(топ3.at(-1), 'data.
|
|
1493
|
-
await db
|
|
1494
|
-
// →
|
|
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
|
|
1495
1512
|
|
|
1496
|
-
await db
|
|
1497
|
-
// Error: letopis: .after(cursor) requires .sort(field) [0.
|
|
1513
|
+
await db.запись().after(кур).rows()
|
|
1514
|
+
// Error: letopis: .after(cursor) requires .sort(field) [0.3 ms]
|
|
1498
1515
|
```
|
|
1499
1516
|
|
|
1500
1517
|
**Кейс: бесконечная лента записей**
|
|
@@ -1502,7 +1519,7 @@ await db.Запись().after(кур).rows()
|
|
|
1502
1519
|
```ts
|
|
1503
1520
|
let кур
|
|
1504
1521
|
for (;;) {
|
|
1505
|
-
let q = db
|
|
1522
|
+
let q = db.запись().sort('updated', 'desc').limit(100)
|
|
1506
1523
|
if (кур) q = q.after(кур)
|
|
1507
1524
|
const стр = await q.rows()
|
|
1508
1525
|
if (!стр.length) break
|
|
@@ -1525,19 +1542,22 @@ for (;;) {
|
|
|
1525
1542
|
**Примеры**
|
|
1526
1543
|
|
|
1527
1544
|
```ts
|
|
1528
|
-
await db.Папка('…
|
|
1529
|
-
// → [{ name: 'Мужской зал', $depth: 1 }, { name: '
|
|
1530
|
-
|
|
1531
|
-
//
|
|
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 }]
|
|
1532
1550
|
```
|
|
1533
1551
|
|
|
1534
1552
|
**Кейс: хлебные крошки каталога** — дерево одним запросом, глубина из `$depth`:
|
|
1535
1553
|
|
|
1536
1554
|
```ts
|
|
1537
|
-
const дерево = await db.Папка(корень).Папка().deep().rows() // [
|
|
1555
|
+
const дерево = await db.Папка(корень).Папка().deep().rows() // [9.2 ms]
|
|
1538
1556
|
дерево.map((p) => `${' '.repeat(p.$depth)}${p.data.name}`)
|
|
1539
1557
|
// → Мужской зал
|
|
1558
|
+
// Женский зал
|
|
1540
1559
|
// Борода и усы
|
|
1560
|
+
// Уход
|
|
1541
1561
|
```
|
|
1542
1562
|
|
|
1543
1563
|
#### `.sum(field)` / `.avg(field): Promise<number | null>`
|
|
@@ -1553,14 +1573,14 @@ const дерево = await db.Папка(корень).Папка().deep().rows(
|
|
|
1553
1573
|
**Примеры**
|
|
1554
1574
|
|
|
1555
1575
|
```ts
|
|
1556
|
-
await db
|
|
1557
|
-
await db.Услуга().avg('data.duration')
|
|
1558
|
-
await db
|
|
1559
|
-
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 (пусто)
|
|
1560
1580
|
```
|
|
1561
1581
|
|
|
1562
|
-
**Кейс:
|
|
1563
|
-
за
|
|
1582
|
+
**Кейс: итог по каталогу без выгрузки строк** — `sum('data.amounts.RUB')` по 405 ценам за 4 ms;
|
|
1583
|
+
на полигоне salondemo — 1026 цен на 2 277 000 за ≈13 ms (§ 14). Строки в приложение не едут.
|
|
1564
1584
|
|
|
1565
1585
|
#### `.min(field)` / `.max(field): Promise<unknown>`
|
|
1566
1586
|
|
|
@@ -1568,8 +1588,8 @@ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') /
|
|
|
1568
1588
|
**числом**, string — строкой.
|
|
1569
1589
|
|
|
1570
1590
|
```ts
|
|
1571
|
-
await db
|
|
1572
|
-
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
|
|
1573
1593
|
```
|
|
1574
1594
|
|
|
1575
1595
|
**Кейс: границы ценового слайдера** — `min` + `max` двумя запросами по 3–4 ms.
|
|
@@ -1584,11 +1604,12 @@ await db.Услуга().max('data.price.RUB') // [4.2 ms] → 1800
|
|
|
1584
1604
|
объекта = значения поля, значения = счётчики (по путям).
|
|
1585
1605
|
|
|
1586
1606
|
```ts
|
|
1587
|
-
await db
|
|
1588
|
-
// → {
|
|
1607
|
+
await db.запись().countBy('data.notes') // [3266.3 ms] — ~440k сущностей
|
|
1608
|
+
// → { 'подтверждена': 400000, 'по телефону: Вера': 3334, 'по телефону: Ольга': 3334, …,
|
|
1609
|
+
// 'по телефону: Марина': 3333 } — 12 имён «по телефону» по ~3333 (ручные брони ~9%)
|
|
1589
1610
|
```
|
|
1590
1611
|
|
|
1591
|
-
**Кейс: дашборд
|
|
1612
|
+
**Кейс: дашборд по заметкам записей** — один запрос вместо N `count()`; ~440k строк агрегирует БД.
|
|
1592
1613
|
|
|
1593
1614
|
#### `.alias(name): Chain`
|
|
1594
1615
|
|
|
@@ -1600,8 +1621,8 @@ await db.Запись().countBy('data.status') // [575.8 ms] — 250k сущн
|
|
|
1600
1621
|
когда один класс встречается в пути дважды.
|
|
1601
1622
|
|
|
1602
1623
|
```ts
|
|
1603
|
-
await db.Организация(org).alias('салон')
|
|
1604
|
-
// [6.
|
|
1624
|
+
await db.Организация(org).alias('салон').Мастер({ name: 'Ольга 0.0' }).alias('мастер').run()
|
|
1625
|
+
// [6.7 ms] → ключи пути: ['салон', 'мастер']
|
|
1605
1626
|
```
|
|
1606
1627
|
|
|
1607
1628
|
**Кейс: self-join читаемо** — `db.Папка(a).alias('родитель').Папка().alias('дочка').run()`.
|
|
@@ -1616,8 +1637,8 @@ await db.Организация(org).alias('салон').Сотрудник({ na
|
|
|
1616
1637
|
кандидаты + перепроверка). В записи — модификатор значения.
|
|
1617
1638
|
|
|
1618
1639
|
```ts
|
|
1619
|
-
await db.Клиент().tags('vip').count()
|
|
1620
|
-
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
|
|
1621
1642
|
```
|
|
1622
1643
|
|
|
1623
1644
|
**Кейс: пометить и найти** — § 5 (вставка с `.tags(['vip','telegram'])`, поиск `tags('vip')`);
|
|
@@ -1634,8 +1655,8 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms]
|
|
|
1634
1655
|
ошибка `acl pins` (§ 11.10).
|
|
1635
1656
|
|
|
1636
1657
|
```ts
|
|
1637
|
-
await db.Организация().account(SYS).count() // [
|
|
1638
|
-
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
|
|
1639
1660
|
```
|
|
1640
1661
|
|
|
1641
1662
|
**Кейс: чей это салон** — профиль владельца строки: `db.accounts.get(row.owner)`; выборка
|
|
@@ -1644,17 +1665,17 @@ await db.Организация().owner(SYS).count() // [3.4 ms] → 21
|
|
|
1644
1665
|
### 11.4 Операторы фильтров
|
|
1645
1666
|
|
|
1646
1667
|
18 функций-операторов: каждая возвращает объект-условие `Op` для значения поля в фильтре
|
|
1647
|
-
шага (`{ duration: gte(60) }`), включая record-пути (`{
|
|
1668
|
+
шага (`{ duration: gte(60) }`), включая record-пути (`{ coordinates: { lat: gte(55.5) } }` —
|
|
1648
1669
|
условие на листе любой глубины, SQL-каст по типу листа из Schema). Компилируются в
|
|
1649
1670
|
выражение на актуальной строке (`(data->>'duration')::numeric >= 60`); containment-части
|
|
1650
|
-
дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (
|
|
1671
|
+
дополнительно сужают кандидатов по GIN. Прогоны — на классе Услуга (600 сущностей).
|
|
1651
1672
|
|
|
1652
1673
|
#### `ne(v): Op`
|
|
1653
1674
|
|
|
1654
1675
|
`v: string | number | boolean | null` — «не равно» (`IS DISTINCT FROM` — null-безопасно).
|
|
1655
1676
|
|
|
1656
1677
|
```ts
|
|
1657
|
-
await db.Услуга({ name: ne('Стрижка') }).count() // [
|
|
1678
|
+
await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms] → 570
|
|
1658
1679
|
```
|
|
1659
1680
|
|
|
1660
1681
|
**Кейс:** всё, кроме выбранного, — «другие услуги» под карточкой текущей.
|
|
@@ -1664,30 +1685,30 @@ await db.Услуга({ name: ne('Стрижка') }).count() // [3.4 ms] →
|
|
|
1664
1685
|
`v: number | string` — строго больше / больше-или-равно (числа и сравнимые строки-даты).
|
|
1665
1686
|
|
|
1666
1687
|
```ts
|
|
1667
|
-
await db.Услуга({ duration: gt(60) }).count() // [
|
|
1668
|
-
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
|
|
1669
1690
|
```
|
|
1670
1691
|
|
|
1671
1692
|
**Кейс:** граница включительно или нет — «от часа» это `gte(60)`; `gt(60)` потеряет
|
|
1672
|
-
ровно-часовые (
|
|
1693
|
+
ровно-часовые (300 vs 150).
|
|
1673
1694
|
|
|
1674
1695
|
#### `lt(v): Op` / `lte(v): Op`
|
|
1675
1696
|
|
|
1676
1697
|
`v: number | string` — строго меньше / меньше-или-равно.
|
|
1677
1698
|
|
|
1678
1699
|
```ts
|
|
1679
|
-
await db.Услуга({ duration: lt(45) }).count() // [
|
|
1680
|
-
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
|
|
1681
1702
|
```
|
|
1682
1703
|
|
|
1683
|
-
**Кейс:** «экспресс до 45 минут включительно» = `lte(45)` →
|
|
1704
|
+
**Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 300 услуг.
|
|
1684
1705
|
|
|
1685
1706
|
#### `between(a, b): Op`
|
|
1686
1707
|
|
|
1687
1708
|
`a, b: number | string` — диапазон включительно (`a ≤ x ≤ b`).
|
|
1688
1709
|
|
|
1689
1710
|
```ts
|
|
1690
|
-
await db.Услуга({ duration: between(40, 65) }).count() // [
|
|
1711
|
+
await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 300
|
|
1691
1712
|
```
|
|
1692
1713
|
|
|
1693
1714
|
**Кейс:** слайдер длительности «40–65 минут» одной функцией вместо пары gte+lte.
|
|
@@ -1697,7 +1718,7 @@ await db.Услуга({ duration: between(40, 65) }).count() // [3.6 ms] → 2
|
|
|
1697
1718
|
`vs: (string | number)[]` — значение из списка (`IN`).
|
|
1698
1719
|
|
|
1699
1720
|
```ts
|
|
1700
|
-
await db.Услуга({ name: inList(['Стрижка', '
|
|
1721
|
+
await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [4.4 ms] → 60
|
|
1701
1722
|
```
|
|
1702
1723
|
|
|
1703
1724
|
**Кейс:** сравнение выбранных чекбоксами услуг: имена из UI → один запрос.
|
|
@@ -1707,72 +1728,73 @@ await db.Услуга({ name: inList(['Стрижка', 'Услуга 7']) }).co
|
|
|
1707
1728
|
`s: string` — SQL-шаблон (`%` — любое, `_` — один символ); `ilike` — без учёта регистра.
|
|
1708
1729
|
|
|
1709
1730
|
```ts
|
|
1710
|
-
await db.Услуга({ name: like('Стри%') }).count() // [3
|
|
1711
|
-
await db.Услуга({ name: ilike('
|
|
1731
|
+
await db.Услуга({ name: like('Стри%') }).count() // [4.3 ms] → 60
|
|
1732
|
+
await db.Услуга({ name: ilike('%массаж%') }).count() // [4.3 ms] → 60
|
|
1712
1733
|
```
|
|
1713
1734
|
|
|
1714
1735
|
**Кейс:** живой поиск в админке — `ilike('%' + ввод + '%')` прощает регистр
|
|
1715
|
-
(
|
|
1736
|
+
(«массаж» находит «Массаж 7», «Массаж 17»).
|
|
1716
1737
|
|
|
1717
1738
|
#### `starts(s): Op` / `ends(s): Op`
|
|
1718
1739
|
|
|
1719
1740
|
`s: string` — начинается с / заканчивается на (сахар над `like(s+'%')` / `like('%'+s)`).
|
|
1720
1741
|
|
|
1721
1742
|
```ts
|
|
1722
|
-
await db.Услуга({ name: starts('
|
|
1723
|
-
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
|
|
1724
1745
|
```
|
|
1725
1746
|
|
|
1726
|
-
**Кейс:** префиксная навигация по
|
|
1747
|
+
**Кейс:** префиксная навигация по названию: `starts('Массаж')` → все «Массаж N» (60 в каталоге).
|
|
1727
1748
|
|
|
1728
1749
|
#### `has(v): Op` / `hasAny(vs): Op` / `hasAll(vs): Op`
|
|
1729
1750
|
|
|
1730
|
-
`v: скаляр`, `vs: скаляр[]` —
|
|
1731
|
-
|
|
1751
|
+
`v: скаляр`, `vs: скаляр[]` — массив содержит значение / хотя бы одно / все (containment `@>` —
|
|
1752
|
+
идёт и в GIN-кандидаты). В демо-схеме массивов в `data` нет — операторы показаны на колонке `tags`.
|
|
1732
1753
|
|
|
1733
1754
|
```ts
|
|
1734
|
-
await db
|
|
1735
|
-
await db
|
|
1736
|
-
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
|
|
1737
1758
|
```
|
|
1738
1759
|
|
|
1739
|
-
**Кейс:**
|
|
1740
|
-
|
|
1760
|
+
**Кейс:** сегменты по меткам: «vip ИЛИ из телеграма» = `tags(hasAny(['vip','telegram']))` → 800;
|
|
1761
|
+
«vip И телеграм разом» = `tags(hasAll(['vip','telegram']))` → 100.
|
|
1741
1762
|
|
|
1742
1763
|
#### `exists(yes = true): Op`
|
|
1743
1764
|
|
|
1744
1765
|
`yes: boolean?` — поле присутствует (`true`, default) / отсутствует (`false`) в `data`.
|
|
1745
1766
|
|
|
1746
1767
|
```ts
|
|
1747
|
-
await db.Услуга({ description: exists() }).count() // [3.
|
|
1748
|
-
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
|
|
1749
1770
|
```
|
|
1750
1771
|
|
|
1751
|
-
**Кейс:** контроль заполненности каталога — «услуги без
|
|
1752
|
-
на доработку
|
|
1772
|
+
**Кейс:** контроль заполненности каталога — «услуги без ключа `description`» = `exists(false)` → 210
|
|
1773
|
+
на доработку контенту (не путать с `isNull()` ниже — тот ещё и явные `null` ловит).
|
|
1753
1774
|
|
|
1754
1775
|
#### `isNull(): Op`
|
|
1755
1776
|
|
|
1756
1777
|
Без параметров — поле `NULL` **или** отсутствует.
|
|
1757
1778
|
|
|
1758
1779
|
```ts
|
|
1759
|
-
await db.Услуга({ description: isNull() }).count() // [3.1 ms] →
|
|
1780
|
+
await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
|
|
1760
1781
|
```
|
|
1761
1782
|
|
|
1762
|
-
**Кейс:** отличие от `exists(false)
|
|
1763
|
-
|
|
1783
|
+
**Кейс:** отличие от `exists(false)` — на полигоне видно числом: `isNull()` → 390 (ловит и явный
|
|
1784
|
+
`null` в data, и отсутствие ключа), `exists(false)` → 210 (только отсутствие); расхождение 180 —
|
|
1785
|
+
это услуги с явным `description: null`.
|
|
1764
1786
|
|
|
1765
1787
|
#### `not(v): Op`
|
|
1766
1788
|
|
|
1767
1789
|
`v: скаляр | Op` — отрицание; скаляр ≡ «не равно» (как `ne`).
|
|
1768
1790
|
|
|
1769
1791
|
```ts
|
|
1770
|
-
await db.Услуга({ name: not(starts('
|
|
1771
|
-
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
|
|
1772
1794
|
```
|
|
1773
1795
|
|
|
1774
|
-
**Кейс:** инверсия готового условия без переписывания: «всё, что НЕ
|
|
1775
|
-
`not(starts('
|
|
1796
|
+
**Кейс:** инверсия готового условия без переписывания: «всё, что НЕ стрижки» =
|
|
1797
|
+
`not(starts('Стрижка'))` → 540 (из 600 услуг 60 — «Стрижка N»).
|
|
1776
1798
|
|
|
1777
1799
|
#### `or(...filters): Op`
|
|
1778
1800
|
|
|
@@ -1780,10 +1802,11 @@ await db.Услуга({ name: not('Стрижка') }).count() // [6.1
|
|
|
1780
1802
|
обычное И).
|
|
1781
1803
|
|
|
1782
1804
|
```ts
|
|
1783
|
-
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
|
|
1784
1806
|
```
|
|
1785
1807
|
|
|
1786
|
-
**Кейс:**
|
|
1808
|
+
**Кейс:** «Стрижка 0 или что-нибудь быстрое» — один запрос: Стрижка 0 (duration 30) уже среди
|
|
1809
|
+
быстрых `lt(45)` → объединение = 150, отдельного плюса не даёт.
|
|
1787
1810
|
|
|
1788
1811
|
### 11.5 Запись: create / update / delete / anonymize
|
|
1789
1812
|
|
|
@@ -1814,28 +1837,28 @@ INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конф
|
|
|
1814
1837
|
**Примеры**
|
|
1815
1838
|
|
|
1816
1839
|
```ts
|
|
1817
|
-
await db.Организация().create({ name: 'Пилигрим' }).rows() // [
|
|
1818
|
-
// → [{ 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: {}, … }]
|
|
1819
1842
|
|
|
1820
1843
|
await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
|
|
1821
|
-
// [
|
|
1844
|
+
// [11.4 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
|
|
1822
1845
|
|
|
1823
|
-
await db.Организация('…0901')
|
|
1824
|
-
// [
|
|
1846
|
+
await db.Организация('…0901').Мастер().create({ id: '…0911', name: 'Мия', phone: '+7 909 000-09-11', specialization: 'массажист' }).rows()
|
|
1847
|
+
// [15.2 ms] контекст → links: { Org: '…0901' }
|
|
1825
1848
|
|
|
1826
|
-
await db.Организация('…0901').Услуга().create({ name: '
|
|
1827
|
-
// [
|
|
1828
|
-
// повторный create той же пары (салон, имя) → новая ВЕРСИЯ
|
|
1849
|
+
await db.Организация('…0901').Услуга().create({ name: 'Массаж головы', duration: 30, description: 'релакс' }).rows()
|
|
1850
|
+
// [16.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
|
|
1851
|
+
// повторный create той же пары (салон, имя) → новая ВЕРСИЯ (deep-merge листьев), не дубль
|
|
1829
1852
|
|
|
1830
|
-
db.Услуга({ name: '
|
|
1853
|
+
db.Услуга({ name: 'Массаж головы' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
|
|
1831
1854
|
// Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
|
|
1832
1855
|
|
|
1833
|
-
await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() // [
|
|
1856
|
+
await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [9.9 ms] — ошибка НА ТЕРМИНАЛЕ:
|
|
1834
1857
|
// ValidationError: letopis: validation failed for "Service":
|
|
1835
|
-
//
|
|
1858
|
+
// The object '' contains forbidden keys: 'чепуха'.
|
|
1836
1859
|
|
|
1837
|
-
db
|
|
1838
|
-
// Error: letopis: no path
|
|
1860
|
+
db.Локация('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
|
|
1861
|
+
// Error: letopis: no path Location → Customer: neither embeds the other (Schema)
|
|
1839
1862
|
```
|
|
1840
1863
|
|
|
1841
1864
|
#### `.update(data?): Chain`
|
|
@@ -1846,7 +1869,7 @@ db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый
|
|
|
1846
1869
|
|
|
1847
1870
|
**Назначение и алгоритм.** Новая версия **каждого** найденного путём. Цели ищутся как при
|
|
1848
1871
|
чтении — id, фильтр, pivot; `Класс()` ≡ `Класс({})` — «все в границах контекста». На каждую
|
|
1849
|
-
цель: **deep-merge** листьев `data` (`update({
|
|
1872
|
+
цель: **deep-merge** листьев `data` (`update({ coordinates: { lat: 55.8 } })` сохранит `lng` и
|
|
1850
1873
|
остальные поля; массивы/скаляры — целиком), слияние links (слоты `.Класс.set()`/`.unset()`),
|
|
1851
1874
|
строгая валидация, один INSERT новой версии. Не найдено → `[]` — update **НИКОГДА не
|
|
1852
1875
|
создаёт**. Сегмент после операции исполняется **для каждой строки её результата** (fan-out);
|
|
@@ -1855,43 +1878,41 @@ db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый
|
|
|
1855
1878
|
**Примеры**
|
|
1856
1879
|
|
|
1857
1880
|
```ts
|
|
1858
|
-
await db
|
|
1859
|
-
//
|
|
1860
|
-
//
|
|
1861
|
-
//
|
|
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)
|
|
1862
1885
|
|
|
1863
|
-
await db.Клиент('…0931')
|
|
1864
|
-
// → [{ id: '
|
|
1886
|
+
await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [600.4 ms] — ВСЕ записи в контексте
|
|
1887
|
+
// → [{ id: '4907d8cd-…', notes: 'день закрыт' }, { id: 'e7f16a08-…', notes: 'день закрыт' }]
|
|
1865
1888
|
|
|
1866
|
-
await db
|
|
1867
|
-
// → [{ id: '…0962', status: 'completed' }, { id: '…0963', status: 'completed' }]
|
|
1868
|
-
|
|
1869
|
-
await db.Запись({ status: 'нет-такого' }).update({ status: 'x' }).rows() // → [] — НИЧЕГО не создано
|
|
1889
|
+
await db.запись({ notes: 'нет-такого' }).update({ notes: 'x' }).rows() // → [] — update НИКОГДА не создаёт
|
|
1870
1890
|
```
|
|
1871
1891
|
|
|
1872
1892
|
**Кейс: план из нескольких операций — реальный прогон**
|
|
1873
1893
|
|
|
1874
1894
|
```ts
|
|
1875
|
-
// обновить клиента → вставить ему запись (продолжение от записанного, одна транзакция)
|
|
1876
|
-
await db.Клиент('…0931').update({
|
|
1877
|
-
|
|
1878
|
-
.rows() // [
|
|
1879
|
-
// → [{ 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' } }]
|
|
1880
1901
|
|
|
1881
1902
|
// self-update: две версии подряд
|
|
1882
|
-
await db
|
|
1883
|
-
// → ['
|
|
1903
|
+
await db.запись('…d091').update({ notes: 'подтверждена' }).update({ notes: 'выполнена' }).rows() // [19.8 ms]
|
|
1904
|
+
// → ['выполнена']; versions: [null, 'подтверждена', 'выполнена']
|
|
1884
1905
|
|
|
1885
1906
|
// хвост-чтение после операции — в той же транзакции
|
|
1886
|
-
await db.Клиент('…0931').update({
|
|
1907
|
+
await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [16.7 ms] → 1
|
|
1887
1908
|
|
|
1888
|
-
// ОТКАТ: невалидный
|
|
1889
|
-
await db.Клиент('…0931').update({
|
|
1890
|
-
// 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]; клиент не изменился
|
|
1891
1912
|
|
|
1892
1913
|
// fan-out: обновить клиента → снести ВСЕ его записи
|
|
1893
|
-
await db.Клиент('…0931').update({
|
|
1894
|
-
// [
|
|
1914
|
+
await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().delete({ confirm: true }).rows()
|
|
1915
|
+
// [29.0 ms] → снесено 1 запись, с $deleted: true
|
|
1895
1916
|
```
|
|
1896
1917
|
|
|
1897
1918
|
#### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
|
|
@@ -1911,19 +1932,27 @@ links **БЕЗ участия в фильтре целей** (в отличие
|
|
|
1911
1932
|
**Примеры**
|
|
1912
1933
|
|
|
1913
1934
|
```ts
|
|
1914
|
-
await db
|
|
1915
|
-
// → { id: '
|
|
1916
|
-
// навык — v5: id вычислен из (Staff, Service
|
|
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 встал
|
|
1917
1941
|
|
|
1918
|
-
//
|
|
1919
|
-
await db
|
|
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
|
|
1920
1948
|
```
|
|
1921
1949
|
|
|
1922
|
-
**Кейс:
|
|
1950
|
+
**Кейс: заменить предмет на всех записях клиента**
|
|
1923
1951
|
|
|
1924
1952
|
```ts
|
|
1925
|
-
await tr
|
|
1926
|
-
// цель ищется путём (все
|
|
1953
|
+
await tr.Клиент(к).запись().update().Комплекс.set(комплекс).rows()
|
|
1954
|
+
// цель ищется путём (все записи клиента); слот пишет новый предмет — союз [Услуга|Товар|Комплекс]
|
|
1955
|
+
// замещается целиком, БЕЗ фильтра по старому концу
|
|
1927
1956
|
```
|
|
1928
1957
|
|
|
1929
1958
|
#### `.delete(opts?): Chain`
|
|
@@ -1944,24 +1973,24 @@ tombstone-версия → рекурсивное удаление зависи
|
|
|
1944
1973
|
**Примеры**
|
|
1945
1974
|
|
|
1946
1975
|
```ts
|
|
1947
|
-
await db
|
|
1948
|
-
// → [{ id: '…
|
|
1949
|
-
//
|
|
1976
|
+
await db.Клиент('…0931').delete().rows() // [14.7 ms] ПРЕВЬЮ — кандидаты живы:
|
|
1977
|
+
// → [{ id: '…0931', class: 'Customer' }, { class: 'booking' }, { class: 'booking' }] — клиент + его записи (каскад)
|
|
1978
|
+
// после превью клиент жив: true
|
|
1950
1979
|
|
|
1951
|
-
await db
|
|
1980
|
+
await db.Клиент('…0931').delete({ confirm: true }).rows() // [35.8 ms] — сервер нашёл зависимых:
|
|
1952
1981
|
// → те же три, каждый с $deleted: true
|
|
1953
|
-
await db
|
|
1982
|
+
await db.Клиент('…0931').delete({ confirm: true }).rows() // [6.8 ms] повторно → []
|
|
1954
1983
|
```
|
|
1955
1984
|
|
|
1956
|
-
**Кейс: отмена
|
|
1985
|
+
**Кейс: отмена и воскрешение**
|
|
1957
1986
|
|
|
1958
1987
|
```ts
|
|
1959
|
-
const последствия = await db
|
|
1988
|
+
const последствия = await db.Клиент(cid).delete().rows() // [14.7 ms] оператору: клиент + N его записей
|
|
1960
1989
|
if (операторПодтвердил) {
|
|
1961
|
-
await db
|
|
1990
|
+
await db.Клиент(cid).delete({ confirm: true }).rows() // [35.8 ms] клиент и записи — tombstone (каскад)
|
|
1962
1991
|
}
|
|
1963
|
-
// клиент
|
|
1964
|
-
await db
|
|
1992
|
+
// клиент вернулся: воскрешение тем же id — create по (Org, id)
|
|
1993
|
+
await db.Организация('…0901').Клиент().create({ id: cid, name: 'Злата', phone: '+7 …' }).rows() // [16.3 ms]
|
|
1965
1994
|
```
|
|
1966
1995
|
|
|
1967
1996
|
#### `.anonymize(fields): Chain`
|
|
@@ -1978,17 +2007,17 @@ await db.Клиент('…0931').Запись('…0961').create({ status: 'creat
|
|
|
1978
2007
|
**Примеры**
|
|
1979
2008
|
|
|
1980
2009
|
```ts
|
|
1981
|
-
await db.Клиент('…0931').anonymize(['name', '
|
|
1982
|
-
// → [{ data: { name: '[erased]',
|
|
2010
|
+
await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7 ms]
|
|
2011
|
+
// → [{ data: { name: '[erased]', phone: '[erased]', preferred_contact: 'phone' },
|
|
1983
2012
|
// tags: ['anonymized'] }]
|
|
1984
2013
|
```
|
|
1985
2014
|
|
|
1986
2015
|
**Кейс: запрос на забвение**
|
|
1987
2016
|
|
|
1988
2017
|
```ts
|
|
1989
|
-
await db.Клиент('…0931').anonymize(['name', '
|
|
1990
|
-
;(await db.Клиент('…0931').versions()).map((r) => r.data.name)
|
|
1991
|
-
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() // все стёртые — под контролем
|
|
1992
2021
|
```
|
|
1993
2022
|
|
|
1994
2023
|
### 11.6 Транзакции: EntityTx
|
|
@@ -2007,11 +2036,11 @@ await db.Клиент().tags('anonymized').count() // в
|
|
|
2007
2036
|
**Примеры**
|
|
2008
2037
|
|
|
2009
2038
|
```ts
|
|
2010
|
-
const tr = await db.begin() // [
|
|
2011
|
-
await tr
|
|
2012
|
-
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
|
|
2013
2042
|
await tr.rollback() // [0.8 ms]
|
|
2014
|
-
await db
|
|
2043
|
+
await db.цена(цУкл).first() // снаружи → amounts.RUB = 700, изменения нет
|
|
2015
2044
|
```
|
|
2016
2045
|
|
|
2017
2046
|
**Кейс: commit** — § 11.2 `db.begin()`; ниже — главный сценарий `lock`.
|
|
@@ -2032,30 +2061,32 @@ await db.Услуга({ name: 'Укладка' }).first() // снаружи
|
|
|
2032
2061
|
**Примеры**
|
|
2033
2062
|
|
|
2034
2063
|
```ts
|
|
2035
|
-
await trA.lock('
|
|
2064
|
+
await trA.lock('booking', staffId, start) // [3.1 ms]
|
|
2036
2065
|
await db.lock('x')
|
|
2037
|
-
// 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]
|
|
2038
2067
|
```
|
|
2039
2068
|
|
|
2040
2069
|
**Кейс: гонка двойной брони — реальный прогон двух транзакций**
|
|
2041
2070
|
|
|
2042
2071
|
```ts
|
|
2043
|
-
// два администратора жмут «забронировать» на одно
|
|
2044
|
-
// id
|
|
2045
|
-
const
|
|
2072
|
+
// два администратора жмут «забронировать» на одно время одновременно;
|
|
2073
|
+
// id записи детерминирован (v5, § 3.2) — известен ДО создания:
|
|
2074
|
+
const bId = uuidv5(`v1.salondemo:entity:booking:${мастер.id}:${start}`)
|
|
2046
2075
|
const trA = await db.begin(), trB = await db.begin()
|
|
2047
|
-
await trA.lock('
|
|
2076
|
+
await trA.lock('booking', мастер.id, start) // [3.1 ms] A первый
|
|
2048
2077
|
const гонкаB = (async () => {
|
|
2049
|
-
await trB.lock('
|
|
2050
|
-
const занято = await trB
|
|
2051
|
-
if (занято) { await trB.rollback(); return 'ОТКАЗ:
|
|
2052
|
-
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()
|
|
2053
2083
|
await trB.commit(); return 'бронь моя'
|
|
2054
2084
|
})()
|
|
2055
|
-
await trA
|
|
2056
|
-
await trA
|
|
2085
|
+
await trA.запись(bId).first() // → null — свободно
|
|
2086
|
+
await trA.Мастер(мастер).запись().create({ start_datetime: start, end_datetime: end })
|
|
2087
|
+
.Локация.set(лок).Расписание.set(расп).Услуга.set(усл).rows()
|
|
2057
2088
|
await trA.commit()
|
|
2058
|
-
await гонкаB // → 'ОТКАЗ:
|
|
2089
|
+
await гонкаB // → 'ОТКАЗ: время уже занято' — B увидел бронь A, дубля нет
|
|
2059
2090
|
// дубль невозможен и без лока (оба create вычислят ОДИН id — второй стал бы версией);
|
|
2060
2091
|
// лок нужен, чтобы B получил честный отказ, а не молча версионировал чужую бронь
|
|
2061
2092
|
|
|
@@ -2076,7 +2107,7 @@ await гонкаB // → 'ОТКАЗ: окно уже занято' — B ув
|
|
|
2076
2107
|
цепочке — ошибка `plan is queued in the batch`. Исполняет `run()`.
|
|
2077
2108
|
|
|
2078
2109
|
```ts
|
|
2079
|
-
const b = db.batch('
|
|
2110
|
+
const b = db.batch('смены-августа') // [28 µs]
|
|
2080
2111
|
```
|
|
2081
2112
|
|
|
2082
2113
|
#### `batch.run(): Promise<Row[][]>`
|
|
@@ -2093,14 +2124,14 @@ const b = db.batch('слоты-августа') // [16 µs]
|
|
|
2093
2124
|
**Примеры**
|
|
2094
2125
|
|
|
2095
2126
|
```ts
|
|
2096
|
-
const b = db.batch('
|
|
2097
|
-
b
|
|
2098
|
-
b
|
|
2099
|
-
b
|
|
2100
|
-
b.size() // [
|
|
2101
|
-
await b.run() // [
|
|
2102
|
-
// → [[{
|
|
2103
|
-
// id каждого окна вычислен схемой: uuidv5(
|
|
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
|
|
2104
2135
|
b.size() // → 0
|
|
2105
2136
|
```
|
|
2106
2137
|
|
|
@@ -2115,10 +2146,10 @@ b.size() // → 0
|
|
|
2115
2146
|
|
|
2116
2147
|
```ts
|
|
2117
2148
|
const b2 = db.batch('отмена')
|
|
2118
|
-
b2
|
|
2119
|
-
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]
|
|
2120
2151
|
b2.size() // → 0
|
|
2121
|
-
await db
|
|
2152
|
+
await db.Мастер(m).окно({ start_datetime: between('2026-08-06T11:00:00Z', '2026-08-06T11:00:00Z') }).first() // → null — не исполнилось
|
|
2122
2153
|
```
|
|
2123
2154
|
|
|
2124
2155
|
**Кейс: черновик импорта** — копим операции по мере парсинга файла; ошибка парсера →
|
|
@@ -2141,7 +2172,7 @@ await db.Расписание(sch).Окно({ start: '2026-08-02T11:00:00Z' }).f
|
|
|
2141
2172
|
`category` → `= ANY(categories)`.
|
|
2142
2173
|
|
|
2143
2174
|
```ts
|
|
2144
|
-
await db.accounts.find({ category: 'Client', enabled: true }) // [2.
|
|
2175
|
+
await db.accounts.find({ category: 'Client', enabled: true }) // [2.4 ms] → 1 аккаунт
|
|
2145
2176
|
```
|
|
2146
2177
|
|
|
2147
2178
|
**Кейс:** список арендаторов для биллинга: `find({ enabled: true })`, отключённые не в счёте.
|
|
@@ -2151,7 +2182,7 @@ await db.accounts.find({ category: 'Client', enabled: true }) // [2.2 ms] →
|
|
|
2151
2182
|
`id: string` — точечный SELECT по PK.
|
|
2152
2183
|
|
|
2153
2184
|
```ts
|
|
2154
|
-
await db.accounts.get(acc.id) // [1.
|
|
2185
|
+
await db.accounts.get(acc.id) // [1.9 ms] → Account | null
|
|
2155
2186
|
```
|
|
2156
2187
|
|
|
2157
2188
|
**Кейс:** профиль владельца строки Entity: `db.accounts.get(row.owner)`.
|
|
@@ -2171,9 +2202,9 @@ await db.accounts.get(acc.id) // [1.7 ms] → Account | null
|
|
|
2171
2202
|
|
|
2172
2203
|
```ts
|
|
2173
2204
|
const acc = await db.accounts.set({ categories: ['Client'], data: { название: 'ИП Ромашка' } })
|
|
2174
|
-
// [
|
|
2175
|
-
// meta: {}, avatar: 'https://i.pravatar.cc/128?img=
|
|
2176
|
-
await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [
|
|
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
|
|
2177
2208
|
```
|
|
2178
2209
|
|
|
2179
2210
|
**Кейс:** бан аккаунта одним полем: `set({ id, enabled: false })` — все `verify*` § 11.9
|
|
@@ -2185,8 +2216,8 @@ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) //
|
|
|
2185
2216
|
в Entity (`account`/`owner` FK RESTRICT) не удалить — намеренно: история неприкосновенна.
|
|
2186
2217
|
|
|
2187
2218
|
```ts
|
|
2188
|
-
await db.accounts.delete(времId) // [
|
|
2189
|
-
await db.accounts.delete(SYS) // [
|
|
2219
|
+
await db.accounts.delete(времId) // [16.4 ms] → true (пустой аккаунт)
|
|
2220
|
+
await db.accounts.delete(SYS) // [6.0 ms]
|
|
2190
2221
|
// Error: update or delete on table "Account" violates foreign key constraint "entity_account_fk"
|
|
2191
2222
|
```
|
|
2192
2223
|
|
|
@@ -2202,7 +2233,7 @@ await db.accounts.delete(SYS) // [4.9 ms]
|
|
|
2202
2233
|
| `f.withDeleted` | `boolean?` | включить мягко-удалённые (default — только живые `deleted IS NULL`) |
|
|
2203
2234
|
|
|
2204
2235
|
```ts
|
|
2205
|
-
await db.credentials.find({ account: acc.id }) // [2
|
|
2236
|
+
await db.credentials.find({ account: acc.id }) // [3.2 ms] → 1 живой
|
|
2206
2237
|
await db.credentials.find({ account: acc.id, withDeleted: true }) // → 1 (после delete: 0 и 1)
|
|
2207
2238
|
```
|
|
2208
2239
|
|
|
@@ -2224,9 +2255,9 @@ telegram списком.
|
|
|
2224
2255
|
|
|
2225
2256
|
```ts
|
|
2226
2257
|
const кред = await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
|
|
2227
|
-
// [3
|
|
2258
|
+
// [5.3 ms] → { id: '5f49dbfa-…', confirmed: true, deleted: null, … }
|
|
2228
2259
|
await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 921 555-77-99' })
|
|
2229
|
-
// [
|
|
2260
|
+
// [7.7 ms] после delete → тот же id, deleted = null — воскрешение
|
|
2230
2261
|
```
|
|
2231
2262
|
|
|
2232
2263
|
**Кейс:** смена номера телефона: `delete(старый)` + `set(новый)`; передумали — повторный
|
|
@@ -2238,7 +2269,7 @@ await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 9
|
|
|
2238
2269
|
освобождается для других аккаунтов (§ 11.9).
|
|
2239
2270
|
|
|
2240
2271
|
```ts
|
|
2241
|
-
await db.credentials.delete(кред.id) // [
|
|
2272
|
+
await db.credentials.delete(кред.id) // [4.6 ms] → true; find() больше не видит
|
|
2242
2273
|
```
|
|
2243
2274
|
|
|
2244
2275
|
**Кейс:** отзыв api-ключа: `delete(credential.id)` → `verifyApiKey` мгновенно null
|
|
@@ -2259,10 +2290,10 @@ await db.credentials.delete(кред.id) // [3.7 ms] → true; find() боль
|
|
|
2259
2290
|
|
|
2260
2291
|
```ts
|
|
2261
2292
|
await db.resources.set({ alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' } })
|
|
2262
|
-
// [
|
|
2263
|
-
await db.resources.get('apiref.demo:API') // [
|
|
2264
|
-
await db.resources.find({ category: 'API' }) // [
|
|
2265
|
-
await db.resources.delete('apiref.demo:API') // [
|
|
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
|
|
2266
2297
|
```
|
|
2267
2298
|
|
|
2268
2299
|
**Кейс:** полный словарь для нового тарифа — § 11.10 (6 ресурсов + 5 правил одним блоком).
|
|
@@ -2281,9 +2312,9 @@ await db.resources.delete('apiref.demo:API') // [3.0 ms] → true
|
|
|
2281
2312
|
|
|
2282
2313
|
```ts
|
|
2283
2314
|
await db.rules.set({ account: 'apiref.demo:API', resource: 'apiref.demo:API', permission: 'allow', weight: 90 })
|
|
2284
|
-
// [3
|
|
2285
|
-
await db.rules.find({ resource: 'apiref.demo:API' }) // [2.
|
|
2286
|
-
await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [
|
|
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
|
|
2287
2318
|
```
|
|
2288
2319
|
|
|
2289
2320
|
**Кейс:** временный бан группы: `set({ account: группа, resource: цель, permission: 'deny',
|
|
@@ -2315,8 +2346,8 @@ uuid-строку или объект с `.id`.
|
|
|
2315
2346
|
|
|
2316
2347
|
```ts
|
|
2317
2348
|
await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2318
|
-
// [
|
|
2319
|
-
// meta.password = "scrypt$32768$8$1
|
|
2349
|
+
// [87.5 ms] → Credential; в БД вместо пароля:
|
|
2350
|
+
// meta.password = "scrypt$32768$8$1$+yEMcUTJIO7xEu/oDUAMXA==$b6…"
|
|
2320
2351
|
```
|
|
2321
2352
|
|
|
2322
2353
|
**Кейс** — регистрация + вход + сессия: см. `sessions()` ниже (полный флоу).
|
|
@@ -2338,11 +2369,11 @@ await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', passw
|
|
|
2338
2369
|
**Примеры**
|
|
2339
2370
|
|
|
2340
2371
|
```ts
|
|
2341
|
-
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [
|
|
2342
|
-
// → { account: { id: '
|
|
2372
|
+
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [78.8 ms]
|
|
2373
|
+
// → { account: { id: '06d3bbfe-…', categories: ['Client'], enabled: true, … },
|
|
2343
2374
|
// credential: { category: 'PASSWORD', identifier: 'romashka@salon.io', … } }
|
|
2344
|
-
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [
|
|
2345
|
-
await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [
|
|
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
|
|
2346
2377
|
// незнакомый identifier — то же время (dummy-verify)
|
|
2347
2378
|
```
|
|
2348
2379
|
|
|
@@ -2352,11 +2383,11 @@ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' })
|
|
|
2352
2383
|
await db.auth.setPassword({ account: acc, identifier: 'noconfirm@salon.io', password: 'пароль-77',
|
|
2353
2384
|
category: 'EMAIL', confirmed: false })
|
|
2354
2385
|
await db.auth.verifyPassword({ identifier: 'noconfirm@salon.io', password: 'пароль-77', category: 'EMAIL' })
|
|
2355
|
-
// [
|
|
2356
|
-
await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [
|
|
2386
|
+
// [71.1 ms] → null — кред не подтверждён
|
|
2387
|
+
await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [72.2 ms] → { account, credential }
|
|
2357
2388
|
await db.accounts.set({ id: acc.id, enabled: false })
|
|
2358
2389
|
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2359
|
-
// [
|
|
2390
|
+
// [59.5 ms] → null — аккаунт выключен, пароль уже не важен
|
|
2360
2391
|
```
|
|
2361
2392
|
|
|
2362
2393
|
#### `db.auth.issueApiKey(a): Promise<{ key, credential }>`
|
|
@@ -2371,9 +2402,9 @@ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'ле
|
|
|
2371
2402
|
Сам ключ возвращается **один раз**; утечка БД ключи не раскрывает.
|
|
2372
2403
|
|
|
2373
2404
|
```ts
|
|
2374
|
-
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [
|
|
2375
|
-
// key = '
|
|
2376
|
-
// в БД: 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' }
|
|
2377
2408
|
```
|
|
2378
2409
|
|
|
2379
2410
|
**Кейс** — см. `verifyApiKey` (выпуск → проверка → отзыв).
|
|
@@ -2389,16 +2420,16 @@ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'к
|
|
|
2389
2420
|
Быстрый (без scrypt): ключ высокоэнтропийный, подбор бессмыслен.
|
|
2390
2421
|
|
|
2391
2422
|
```ts
|
|
2392
|
-
await db.auth.verifyApiKey(key) // [
|
|
2423
|
+
await db.auth.verifyApiKey(key) // [4.5 ms] → { account: 06d3bbfe…, credential }
|
|
2393
2424
|
```
|
|
2394
2425
|
|
|
2395
2426
|
**Кейс: полный жизненный цикл ключа**
|
|
2396
2427
|
|
|
2397
2428
|
```ts
|
|
2398
|
-
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [
|
|
2399
|
-
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 } — касса работает
|
|
2400
2431
|
await db.credentials.delete(credential.id) // отзыв (мягкий)
|
|
2401
|
-
await db.auth.verifyApiKey(key) // [1
|
|
2432
|
+
await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновенно недействителен
|
|
2402
2433
|
```
|
|
2403
2434
|
|
|
2404
2435
|
#### `db.auth.issueKeySecret(a): Promise<{ key, secret, credential }>`
|
|
@@ -2410,8 +2441,8 @@ await db.auth.verifyApiKey(key) // [1.7 ms] → null — мгновен
|
|
|
2410
2441
|
возвращается один раз.
|
|
2411
2442
|
|
|
2412
2443
|
```ts
|
|
2413
|
-
const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [
|
|
2414
|
-
// key = '
|
|
2444
|
+
const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [4.8 ms]
|
|
2445
|
+
// key = 'f8ab4007e4570809'; secret = 'c25b8b906f69…' (48 hex, показан один раз)
|
|
2415
2446
|
```
|
|
2416
2447
|
|
|
2417
2448
|
#### `db.auth.verifyKeySecret(key, secret, opts?): Promise<AuthResult | null>`
|
|
@@ -2425,8 +2456,8 @@ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'ин
|
|
|
2425
2456
|
**Алгоритм.** Кред по identifier = key, `timingSafeEqual(sha256(secret), meta.secret)`, ворота.
|
|
2426
2457
|
|
|
2427
2458
|
```ts
|
|
2428
|
-
await db.auth.verifyKeySecret(key, secret) // [
|
|
2429
|
-
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
|
|
2430
2461
|
```
|
|
2431
2462
|
|
|
2432
2463
|
**Кейс:** серверная интеграция (1С, платёжка): key хранится в конфиге открыто и светится
|
|
@@ -2448,8 +2479,8 @@ base32(20 случайных байт), `uri` — готовая строка `o
|
|
|
2448
2479
|
|
|
2449
2480
|
```ts
|
|
2450
2481
|
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: 'romashka@salon.io' })
|
|
2451
|
-
// [
|
|
2452
|
-
// 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'
|
|
2453
2484
|
```
|
|
2454
2485
|
|
|
2455
2486
|
#### `db.auth.verifyTotp(a): Promise<boolean>`
|
|
@@ -2467,9 +2498,9 @@ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz
|
|
|
2467
2498
|
**Примеры**
|
|
2468
2499
|
|
|
2469
2500
|
```ts
|
|
2470
|
-
const код = totpCode(secret) // [
|
|
2471
|
-
await db.auth.verifyTotp({ account: acc, code: код }) // [
|
|
2472
|
-
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 отбит
|
|
2473
2504
|
const прошлый = totpCode(secret, Date.now() - 30_000) // код прошлого шага (окно ±1)
|
|
2474
2505
|
await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false — шаг ≤ lastStep
|
|
2475
2506
|
```
|
|
@@ -2477,10 +2508,10 @@ await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false
|
|
|
2477
2508
|
**Кейс: включение 2FA в кабинете**
|
|
2478
2509
|
|
|
2479
2510
|
```ts
|
|
2480
|
-
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]
|
|
2481
2512
|
await db.auth.totpEnabled(acc) // → false — QR показан, ждём подтверждения
|
|
2482
|
-
await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [
|
|
2483
|
-
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 — теперь требуем код при входе
|
|
2484
2515
|
```
|
|
2485
2516
|
|
|
2486
2517
|
#### `db.auth.totpEnabled(account): Promise<boolean>`
|
|
@@ -2489,7 +2520,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true — те
|
|
|
2489
2520
|
проверкой (`confirmed`). Приложение по нему решает, спрашивать ли второй фактор.
|
|
2490
2521
|
|
|
2491
2522
|
```ts
|
|
2492
|
-
await db.auth.totpEnabled(acc) // [
|
|
2523
|
+
await db.auth.totpEnabled(acc) // [2.2 ms] → true
|
|
2493
2524
|
```
|
|
2494
2525
|
|
|
2495
2526
|
#### `totpCode(secretBase32, atMs = Date.now()): string` — экспорт модуля
|
|
@@ -2504,7 +2535,7 @@ await db.auth.totpEnabled(acc) // [1.7 ms] → true
|
|
|
2504
2535
|
Для тестов и серверной генерации кодов.
|
|
2505
2536
|
|
|
2506
2537
|
```ts
|
|
2507
|
-
totpCode('
|
|
2538
|
+
totpCode('G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS') // [659 µs] → '564517'
|
|
2508
2539
|
```
|
|
2509
2540
|
|
|
2510
2541
|
**Кейс** — автотест 2FA без телефона: сгенерировать код из секрета и скормить `verifyTotp`
|
|
@@ -2526,7 +2557,7 @@ totpCode('FAZKFC57B3FPIOF2735ZYW47CZA5O6MW') // [335 µs] → '370916'
|
|
|
2526
2557
|
|
|
2527
2558
|
```ts
|
|
2528
2559
|
const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@salon.io', ttlSec: 600 })
|
|
2529
|
-
// [
|
|
2560
|
+
// [6.1 ms] code = '255243'; в БД: meta = { code: '7566c91d8a5e…' (sha256), expires: '2026-07-13T07:24:44.435Z', attempts: 0 }
|
|
2530
2561
|
```
|
|
2531
2562
|
|
|
2532
2563
|
#### `db.auth.verifyOtp(a): Promise<AuthResult | null>`
|
|
@@ -2545,19 +2576,19 @@ const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@sa
|
|
|
2545
2576
|
**Примеры**
|
|
2546
2577
|
|
|
2547
2578
|
```ts
|
|
2548
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [4
|
|
2549
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [
|
|
2550
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [
|
|
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 — сожжён
|
|
2551
2582
|
```
|
|
2552
2583
|
|
|
2553
2584
|
**Кейс: сброс пароля**
|
|
2554
2585
|
|
|
2555
2586
|
```ts
|
|
2556
|
-
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]
|
|
2557
2588
|
отправитьПисьмо(почта, code) // доставка — на приложении
|
|
2558
|
-
const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [
|
|
2589
|
+
const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11.0 ms]
|
|
2559
2590
|
if (кто) await db.auth.setPassword({ account: кто.account, identifier: почта, password: новый })
|
|
2560
|
-
// протухший код (ttl 1 s в прогоне): verifyOtp → null [
|
|
2591
|
+
// протухший код (ttl 1 s в прогоне): verifyOtp → null [9.3 ms]
|
|
2561
2592
|
```
|
|
2562
2593
|
|
|
2563
2594
|
#### `db.auth.link(a): Promise<Credential>`
|
|
@@ -2578,7 +2609,7 @@ if (кто) await db.auth.setPassword({ account: кто.account, identifier: п
|
|
|
2578
2609
|
|
|
2579
2610
|
```ts
|
|
2580
2611
|
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' } })
|
|
2581
|
-
// [
|
|
2612
|
+
// [4.9 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
|
|
2582
2613
|
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915' }) // id занят ДРУГИМ аккаунтом:
|
|
2583
2614
|
// Error: duplicate key value violates unique constraint "credential_identity_udx" [3.9 ms]
|
|
2584
2615
|
```
|
|
@@ -2599,14 +2630,14 @@ await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915
|
|
|
2599
2630
|
|
|
2600
2631
|
```ts
|
|
2601
2632
|
await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' })
|
|
2602
|
-
// [
|
|
2633
|
+
// [5.2 ms] → { account: 06d3bbfe…, credential }
|
|
2603
2634
|
```
|
|
2604
2635
|
|
|
2605
2636
|
**Кейс: вход через telegram-бота**
|
|
2606
2637
|
|
|
2607
2638
|
```ts
|
|
2608
2639
|
// платформа подтвердила пользователя 777000111 (initData бота проверило приложение)
|
|
2609
|
-
const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [
|
|
2640
|
+
const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [5.2 ms]
|
|
2610
2641
|
if (!кто) { /* первая встреча: создать аккаунт + db.auth.link(…) */ }
|
|
2611
2642
|
const token = await sess.start(кто.account) // дальше обычная сессия
|
|
2612
2643
|
```
|
|
@@ -2622,7 +2653,7 @@ const token = await sess.start(кто.account) // дальше обычная
|
|
|
2622
2653
|
|
|
2623
2654
|
```ts
|
|
2624
2655
|
import Redis from 'ioredis'
|
|
2625
|
-
const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [
|
|
2656
|
+
const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [148 µs]
|
|
2626
2657
|
```
|
|
2627
2658
|
|
|
2628
2659
|
#### `sessions.start(account, opts?): Promise<string>`
|
|
@@ -2638,9 +2669,9 @@ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [147 µ
|
|
|
2638
2669
|
`sess:acc:<accountId>` для `revokeAll`. Дамп Redis действующих токенов не раскрывает.
|
|
2639
2670
|
|
|
2640
2671
|
```ts
|
|
2641
|
-
const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [
|
|
2642
|
-
// token = '
|
|
2643
|
-
// в 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…'
|
|
2644
2675
|
```
|
|
2645
2676
|
|
|
2646
2677
|
#### `sessions.check(token): Promise<Session | null>`
|
|
@@ -2649,9 +2680,9 @@ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }
|
|
|
2649
2680
|
(нет / истекла / отозвана). Суб-миллисекундный — на каждый HTTP-запрос.
|
|
2650
2681
|
|
|
2651
2682
|
```ts
|
|
2652
|
-
await sess.check(token) // [0.
|
|
2653
|
-
// → { account: '
|
|
2654
|
-
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 сам удалил)
|
|
2655
2686
|
```
|
|
2656
2687
|
|
|
2657
2688
|
#### `sessions.revoke(token): Promise<boolean>`
|
|
@@ -2659,8 +2690,8 @@ await sess.check(протухший) // [1.2 ms] → null (ttl 1 s истёк
|
|
|
2659
2690
|
`token: string` — `DEL` ключа + `SREM` из индекса; `false`, если сессии уже нет.
|
|
2660
2691
|
|
|
2661
2692
|
```ts
|
|
2662
|
-
await sess.revoke(token) // [
|
|
2663
|
-
await sess.revoke(token) // [0.
|
|
2693
|
+
await sess.revoke(token) // [1.9 ms] → true
|
|
2694
|
+
await sess.revoke(token) // [0.7 ms] → false — повторно
|
|
2664
2695
|
```
|
|
2665
2696
|
|
|
2666
2697
|
#### `sessions.revokeAll(account): Promise<number>`
|
|
@@ -2669,19 +2700,19 @@ await sess.revoke(token) // [0.5 ms] → false — повторно
|
|
|
2669
2700
|
сколько погашено.
|
|
2670
2701
|
|
|
2671
2702
|
```ts
|
|
2672
|
-
await sess.revokeAll(acc) // [1.
|
|
2703
|
+
await sess.revokeAll(acc) // [1.2 ms] → 2 — обе сессии (ipad + macbook) погасли
|
|
2673
2704
|
```
|
|
2674
2705
|
|
|
2675
2706
|
**Кейс: полный вход — пароль → сессия → запрос → выход (реальный прогон)**
|
|
2676
2707
|
|
|
2677
2708
|
```ts
|
|
2678
2709
|
const визит = await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2679
|
-
// [
|
|
2680
|
-
const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.
|
|
2710
|
+
// [71.3 ms] → { account, credential }
|
|
2711
|
+
const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.2 ms]
|
|
2681
2712
|
// … каждый запрос в middleware:
|
|
2682
|
-
const кто = await sess.check(token) // [0.
|
|
2713
|
+
const кто = await sess.check(token) // [0.7 ms] → { account: '06d3bbfe…', meta: { ip: '10.0.0.7' }, … }
|
|
2683
2714
|
// logout:
|
|
2684
|
-
await sess.revoke(token) // [
|
|
2715
|
+
await sess.revoke(token) // [1.3 ms] → true
|
|
2685
2716
|
// «выйти со всех устройств» после смены пароля: await sess.revokeAll(визит.account)
|
|
2686
2717
|
```
|
|
2687
2718
|
|
|
@@ -2690,7 +2721,7 @@ await sess.revoke(token) // [2.2 ms] → true
|
|
|
2690
2721
|
Решения по словарю Resource/Rule (§ 9.2). Прогоны ниже — словарь из § 11.8-кейса: группа
|
|
2691
2722
|
`apiref.client:ACCOUNT {categories: '{Client}'}`, эндпоинты `apiref.api.booking:API
|
|
2692
2723
|
{endpoint: 'v2.booking.*'}`, данные `apiref.booking.own:READ/WRITE/DELETE
|
|
2693
|
-
{class: '
|
|
2724
|
+
{class: 'booking', owner: '$account'}` и `apiref.service:READ {class: 'Service'}`,
|
|
2694
2725
|
5 правил `allow weight 60` от группы Client.
|
|
2695
2726
|
|
|
2696
2727
|
#### `db.acl.check(account, endpoint): Promise<AclDecision>`
|
|
@@ -2710,10 +2741,10 @@ NULL — все); объекты — `API`-ресурсы, чья маска п
|
|
|
2710
2741
|
**Примеры**
|
|
2711
2742
|
|
|
2712
2743
|
```ts
|
|
2713
|
-
await db.acl.check(acc, 'v2.booking.create') // [
|
|
2744
|
+
await db.acl.check(acc, 'v2.booking.create') // [5.0 ms]
|
|
2714
2745
|
// → { allow: true, rule: { account: 'apiref.client:ACCOUNT', resource: 'apiref.api.booking:API',
|
|
2715
2746
|
// permission: 'allow', weight: 60, enabled: true } }
|
|
2716
|
-
await db.acl.check(acc, 'v2.admin.stats') // [1.
|
|
2747
|
+
await db.acl.check(acc, 'v2.admin.stats') // [1.8 ms] — покрыло только дно-правило сида:
|
|
2717
2748
|
// → { allow: false, rule: { account: 'any:ACCOUNT', resource: 'any:API', permission: 'deny', weight: 0, … },
|
|
2718
2749
|
// code: 403, message: 'Access denied - default for any ACCOUNT to any API' }
|
|
2719
2750
|
```
|
|
@@ -2738,7 +2769,7 @@ app.use(async (req, res, next) => {
|
|
|
2738
2769
|
|
|
2739
2770
|
**Назначение и алгоритм.** Решение по данным: объекты — ресурсы категории `op`, чья
|
|
2740
2771
|
`pattern.class`-маска совпала с именем класса **или любого предка** (lineage: право на
|
|
2741
|
-
`
|
|
2772
|
+
`booking` действует на `VipBooking`). Победа — как в `check`. У победившего allow остальные
|
|
2742
2773
|
ключи pattern (реальные колонки Entity, `"$account"` → id субъекта) возвращаются как
|
|
2743
2774
|
`filter` — готовый предикат строк. Справочный метод (SQL за категориями аккаунта на каждый
|
|
2744
2775
|
вызов ~1–2 ms); горячий путь цепочек использует резолвер, скомпилированный на connect
|
|
@@ -2747,35 +2778,37 @@ app.use(async (req, res, next) => {
|
|
|
2747
2778
|
**Примеры**
|
|
2748
2779
|
|
|
2749
2780
|
```ts
|
|
2750
|
-
await db.acl.checkData(acc, '
|
|
2781
|
+
await db.acl.checkData(acc, 'booking', 'READ') // [2.7 ms]
|
|
2751
2782
|
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', weight: 60, … },
|
|
2752
|
-
// filter: { owner: '
|
|
2753
|
-
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]
|
|
2754
2785
|
// → { allow: true, rule: { resource: 'apiref.service:READ', … } } ← безусловный (без filter)
|
|
2755
|
-
await db.acl.checkData(acc, 'Org', 'READ') // [
|
|
2786
|
+
await db.acl.checkData(acc, 'Org', 'READ') // [2.0 ms]
|
|
2756
2787
|
// → { allow: false, message: 'no matching rule (deny by default)' }
|
|
2757
|
-
await db.acl.checkData(acc, 'VipBooking', 'READ') // [
|
|
2758
|
-
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: '
|
|
2759
|
-
// класса нет в словаре — право дал предок
|
|
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)
|
|
2760
2791
|
```
|
|
2761
2792
|
|
|
2762
2793
|
**Кейс: enforceAcl — те же решения в SQL цепочек (реальный прогон)**
|
|
2763
2794
|
|
|
2764
2795
|
```ts
|
|
2765
|
-
const uc = await connect({ dsn, schema, account: acc.id, enforceAcl: true }) // [
|
|
2766
|
-
await uc
|
|
2767
|
-
await uc
|
|
2768
|
-
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, класс целиком
|
|
2769
2800
|
await uc.Организация().rows()
|
|
2770
2801
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
|
|
2771
|
-
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]
|
|
2772
2804
|
z.owner === acc.id // → true — owner пришпилен правилом
|
|
2773
|
-
await uc
|
|
2774
|
-
// Error: letopis: acl pins
|
|
2775
|
-
await uc
|
|
2776
|
-
|
|
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 }]
|
|
2777
2810
|
// watch: события только безусловных allow-классов —
|
|
2778
|
-
// uc.watch(cb) поймал ['Service'];
|
|
2811
|
+
// uc.watch(cb) поймал ['Service']; booking скрыт (предикат не проверить по payload)
|
|
2779
2812
|
```
|
|
2780
2813
|
|
|
2781
2814
|
#### `db.acl.reload(): void`
|
|
@@ -2786,7 +2819,7 @@ connect; подхватить новые правила = новый `connect()`
|
|
|
2786
2819
|
connect — новый класс в lineage-проверках увидит только новое подключение.
|
|
2787
2820
|
|
|
2788
2821
|
```ts
|
|
2789
|
-
db.acl.reload() // [
|
|
2822
|
+
db.acl.reload() // [187 µs]
|
|
2790
2823
|
```
|
|
2791
2824
|
|
|
2792
2825
|
**Кейс:** админка сохранила правило → `reload()` в том же процессе, чтобы `check` следующего
|
|
@@ -2806,9 +2839,9 @@ db.acl.reload() // [120 µs]
|
|
|
2806
2839
|
компилированный валидатор.
|
|
2807
2840
|
|
|
2808
2841
|
```ts
|
|
2809
|
-
db.registry.resolve('
|
|
2810
|
-
// → { id: '
|
|
2811
|
-
// 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 }
|
|
2812
2845
|
db.registry.resolve('Дракон')
|
|
2813
2846
|
// Error: letopis: unknown class "Дракон". Known: Entity·Сущность, Org·Организация, …
|
|
2814
2847
|
```
|
|
@@ -2822,10 +2855,10 @@ db.registry.resolve('Дракон')
|
|
|
2822
2855
|
`all` — все классы партиции.
|
|
2823
2856
|
|
|
2824
2857
|
```ts
|
|
2825
|
-
db.registry.has('
|
|
2858
|
+
db.registry.has('booking') // → true
|
|
2826
2859
|
db.registry.has('Дракон') // → false
|
|
2827
2860
|
db.registry.find('Дракон') // → undefined
|
|
2828
|
-
db.registry.all.length // →
|
|
2861
|
+
db.registry.all.length // → 20 (+1: LINK-класс price·«цена»)
|
|
2829
2862
|
```
|
|
2830
2863
|
|
|
2831
2864
|
**Кейс:** роутер `GET /:класс` — `has()` до цепочки, чтобы отвечать 404, а не 500.
|
|
@@ -2843,12 +2876,12 @@ db.registry.all.length // → 15
|
|
|
2843
2876
|
конец LINK) — обычный `Error` там же.
|
|
2844
2877
|
|
|
2845
2878
|
```ts
|
|
2846
|
-
try { await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [
|
|
2879
|
+
try { await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [9.9 ms]
|
|
2847
2880
|
catch (e) {
|
|
2848
2881
|
e instanceof ValidationError // → true
|
|
2849
2882
|
e.issues
|
|
2850
|
-
// → [{ type: '
|
|
2851
|
-
//
|
|
2883
|
+
// → [{ type: 'objectStrict', message: "The object '' contains forbidden keys: 'чепуха'.",
|
|
2884
|
+
// expected: 'id, name, description, duration', actual: 'чепуха' }]
|
|
2852
2885
|
}
|
|
2853
2886
|
```
|
|
2854
2887
|
|
|
@@ -2921,76 +2954,103 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
|
|
|
2921
2954
|
|
|
2922
2955
|
## 13. Что контролирует приложение
|
|
2923
2956
|
|
|
2924
|
-
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
-
|
|
2957
|
+
- Пересечения интервалов записей внутри окна-смены мастера (наложение броней; EXCLUDE на
|
|
2958
|
+
hypertable невозможен). Двойная бронь одного времени мертва самим id записи —
|
|
2959
|
+
`v5(Мастер, старт)` (§ 3.2); частичное наложение разных интервалов проверяет приложение.
|
|
2960
|
+
- Бизнес-проверки брони: у мастера есть навык на услугу (`Мастер→навык→Услуга`); интервал
|
|
2961
|
+
записи попадает в окно-смену того же мастера; длительность услуги укладывается в окно.
|
|
2962
|
+
- Итог по записи = цена предмета (`Услуга`/`Товар`/`Комплекс`), у комплекса — по составу:
|
|
2963
|
+
сами записи денег не хранят.
|
|
2964
|
+
- Генерация окон-смен из шаблонов/повторов; месячное `Расписание {name, year, month}`.
|
|
2928
2965
|
|
|
2929
2966
|
## 14. Производительность
|
|
2930
2967
|
|
|
2931
|
-
|
|
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
|
+
читают один и тот же полигон, мутируя лишь свои демо-сущности:
|
|
2932
2972
|
|
|
2933
|
-
| Операция |
|
|
2934
|
-
|
|
2935
|
-
|
|
|
2936
|
-
| `
|
|
2937
|
-
|
|
|
2938
|
-
| `count()`
|
|
2939
|
-
|
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
(
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
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 строк.
|
|
2948
2991
|
|
|
2949
2992
|
- Containment и обход графа — GIN; операторы — на уже суженном наборе.
|
|
2950
|
-
- Начинайте цепочку с самого селективного
|
|
2951
|
-
- `count()`
|
|
2952
|
-
- Каскадное удаление — серверное: один DELETE на всё
|
|
2953
|
-
- Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты)
|
|
2954
|
-
|
|
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).
|
|
2955
3002
|
|
|
2956
3003
|
---
|
|
2957
3004
|
|
|
2958
3005
|
## 15. E2E-пример: барбершоп
|
|
2959
3006
|
|
|
2960
|
-
Полный исполняемый сценарий — `test/integration.test.ts`.
|
|
3007
|
+
Полный исполняемый сценарий — `test/integration.test.ts`. Скелет (модель 0.17,
|
|
3008
|
+
позитивная доступность):
|
|
2961
3009
|
|
|
2962
3010
|
```ts
|
|
2963
|
-
// штат
|
|
3011
|
+
// организация, локация, штат (phone обязателен) — связи контекст-шагами; терминал .rows() исполняет план
|
|
2964
3012
|
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
2965
|
-
const [
|
|
2966
|
-
const [
|
|
2967
|
-
const [
|
|
2968
|
-
const [
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
await db
|
|
2972
|
-
|
|
2973
|
-
//
|
|
2974
|
-
|
|
2975
|
-
db
|
|
2976
|
-
|
|
2977
|
-
await db
|
|
2978
|
-
await db
|
|
2979
|
-
|
|
2980
|
-
//
|
|
2981
|
-
const [
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
await db
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
//
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
await
|
|
2992
|
-
|
|
2993
|
-
|
|
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
|
|
2994
3054
|
```
|
|
2995
3055
|
|
|
2996
3056
|
---
|
|
@@ -2998,7 +3058,7 @@ const удалено = await db.Запись(bkg.id).delete({ confirm: true }).r
|
|
|
2998
3058
|
## 16. Тесты
|
|
2999
3059
|
|
|
3000
3060
|
```bash
|
|
3001
|
-
npm test #
|
|
3061
|
+
cd lib && npm test # 167/167 тестов против живого docker (timescale + redis), по файлам:
|
|
3002
3062
|
# acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
|
|
3003
3063
|
# с $account, enforceAcl (предикаты в SQL, каскад, watch)
|
|
3004
3064
|
# api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
|