letopis 0.20.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +228 -170
- package/dist/index.js +68 -13
- package/dist/types.d.ts +20 -0
- package/dist/types.js +25 -0
- package/dist/up.d.ts +8 -0
- package/dist/up.js +22 -7
- package/package.json +1 -1
- package/scripts/check-docs.mjs +39 -2
- package/scripts/gen-api-contract.mjs +7 -2
- package/sql/ddl.sql +15 -1
package/README.md
CHANGED
|
@@ -987,41 +987,41 @@ const acc = await db.accounts.set({ categories: ['User'], data: { name: 'Вас
|
|
|
987
987
|
|
|
988
988
|
// ПАРОЛЬ (почта/пароль и логин/пароль — одна механика, различает category)
|
|
989
989
|
await db.auth.setPassword({ account: acc, identifier: 'vasya@salon.io', password: 'корень-огня-77' })
|
|
990
|
-
// → Credential; в БД НЕ пароль, а слоёный хэш: [
|
|
990
|
+
// → Credential; в БД НЕ пароль, а слоёный хэш: [105 ms]
|
|
991
991
|
// meta.password = "scrypt$32768$8$1$EmyOQlletdEahwgoXendhw==$vGQB9LrwcDSIB0+…"
|
|
992
992
|
await db.auth.verifyPassword({ identifier: 'vasya@salon.io', password: 'корень-огня-77' })
|
|
993
993
|
// → { account: {id: '7277…', data: {name: 'Вася'}, enabled: true, …},
|
|
994
994
|
// credential: {category: 'PASSWORD', identifier: 'vasya@salon.io', …} } [71.8 ms]
|
|
995
|
-
await db.auth.verifyPassword({ identifier: 'vasya@salon.io', password: 'хм' }) // → null [
|
|
995
|
+
await db.auth.verifyPassword({ identifier: 'vasya@salon.io', password: 'хм' }) // → null [109 ms]
|
|
996
996
|
// цена задана scrypt-ом (~70 ms) и выровнена: «нет такого identifier» не быстрее «пароль неверен»
|
|
997
997
|
|
|
998
998
|
// API-КЛЮЧ: показывается ОДИН раз, в БД — только sha256
|
|
999
999
|
const { key } = await db.auth.issueApiKey({ account: acc, name: 'CI' })
|
|
1000
|
-
// key = "lts_a61118c4af31868640529131927eb578211730bcd092856c" [
|
|
1000
|
+
// key = "lts_a61118c4af31868640529131927eb578211730bcd092856c" [9.0 ms]
|
|
1001
1001
|
// в БД: identifier = "ae9002e2cd4c…" (sha256), meta = {name: 'CI', prefix: 'lts_a61118c4'}
|
|
1002
|
-
await db.auth.verifyApiKey(key) // → { account, credential } [
|
|
1002
|
+
await db.auth.verifyApiKey(key) // → { account, credential } [6.9 ms]
|
|
1003
1003
|
|
|
1004
1004
|
// КЛЮЧ-СЕКРЕТ: key — открытый id пары, secret — только sha256
|
|
1005
1005
|
const { key: k, secret } = await db.auth.issueKeySecret({ account: acc, name: 'integration' })
|
|
1006
1006
|
// k = "96d327b741421f74", secret = "38f69922c9748aa8…" (48 hex) [4.7 ms]
|
|
1007
|
-
await db.auth.verifyKeySecret(k, secret) // → { account, credential } [
|
|
1007
|
+
await db.auth.verifyKeySecret(k, secret) // → { account, credential } [4.4 ms]
|
|
1008
1008
|
|
|
1009
1009
|
// ВНЕШНЯЯ IDENTITY (oauth/sso/telegram): токен проверяет приложение, тут — связка
|
|
1010
1010
|
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915', meta: { username: 'roboteza' } })
|
|
1011
1011
|
await db.auth.lookup({ category: 'TELEGRAM', identifier: '1635246915' })
|
|
1012
|
-
// → { account, credential } [
|
|
1012
|
+
// → { account, credential } [5.3 ms]
|
|
1013
1013
|
|
|
1014
1014
|
// TOTP (authenticator, RFC 6238): секрет в QR, активен после первой проверки
|
|
1015
1015
|
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: 'anna@salon.io' })
|
|
1016
1016
|
// secret = "RWVB3WWQ62LEED55AMU6L425C6KIT5EJ" (base32, 20 байт) [5.6 ms]
|
|
1017
1017
|
// uri = "otpauth://totp/anna%40salon.io?secret=…&issuer=clockz&algorithm=SHA1&digits=6&period=30"
|
|
1018
|
-
await db.auth.verifyTotp({ account: acc, code: '139999' }) // → true [
|
|
1018
|
+
await db.auth.verifyTotp({ account: acc, code: '139999' }) // → true [7.2 ms]
|
|
1019
1019
|
await db.auth.verifyTotp({ account: acc, code: '139999' }) // → false — replay того же шага отбит
|
|
1020
1020
|
await db.auth.totpEnabled(acc) // → true (после первой проверки)
|
|
1021
1021
|
|
|
1022
1022
|
// ОДНОРАЗОВЫЙ КОД (email/SMS/reset — доставка на приложении)
|
|
1023
1023
|
const { code } = await db.auth.issueOtp({ account: acc, identifier: 'anna@salon.io', ttlSec: 600 })
|
|
1024
|
-
// code = "273746"; в БД sha256 + expires + attempts [
|
|
1024
|
+
// code = "273746"; в БД sha256 + expires + attempts [4.8 ms]
|
|
1025
1025
|
await db.auth.verifyOtp({ identifier: 'anna@salon.io', code })
|
|
1026
1026
|
// → { account, credential }; код СОЖЖЁН (одноразовость) [6.0 ms]; повторно → null
|
|
1027
1027
|
// 5 неверных попыток тоже сжигают; повторный issue перезаписывает код (валиден последний)
|
|
@@ -1090,9 +1090,10 @@ db.acl.reload() // сброс кэша Resou
|
|
|
1090
1090
|
**`connect({ enforceAcl: true })` + `db.as(субъект)`** — те же решения в цепочках: каждый шаг —
|
|
1091
1091
|
`READ`, `create()`/`update()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
|
|
1092
1092
|
(deny в замыкании откатывает транзакцию); `watch()` отдаёт события только безусловных
|
|
1093
|
-
allow-классов (payload нечем проверить предикат). Субъекта даёт `db.as()`:
|
|
1094
|
-
|
|
1095
|
-
|
|
1093
|
+
allow-классов (payload нечем проверить предикат). Субъекта даёт `db.as()`: у каждого scope
|
|
1094
|
+
**свой** memo-резолвер (ключ memo субъекта не содержит, поэтому один энфорсер = один субъект),
|
|
1095
|
+
а сам словарь `Resource`/`Rule` — общий снимок на подключение; правки словаря подхватывает
|
|
1096
|
+
`db.reloadSchema()` (§ 11.10).
|
|
1096
1097
|
|
|
1097
1098
|
```ts
|
|
1098
1099
|
const u = await (await connect({ dsn, schema, enforceAcl: true })).as(user.id)
|
|
@@ -1123,9 +1124,10 @@ await u.запись(чужаяId).update({ notes: '…' }).rows() // → []
|
|
|
1123
1124
|
не отсекает); в реальности он СУЖАЕТ выборку, и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
|
|
1124
1125
|
`db.acl.checkData` — справочный (1.7 ms: SQL за категориями аккаунта на каждый вызов);
|
|
1125
1126
|
горячий путь цепочек использует резолвер, скомпилированный при `db.as()` (микросекунды, memo).
|
|
1126
|
-
Цена самого `db.as()` под `enforceAcl` —
|
|
1127
|
-
|
|
1128
|
-
|
|
1127
|
+
Цена самого `db.as()` под `enforceAcl` — **один** SELECT за свой аккаунт: словарь
|
|
1128
|
+
`Resource`/`Rule` снимается один раз на подключение и переиспользуется всеми scope (сбрасывает
|
|
1129
|
+
`db.reloadSchema()`), поэтому scope-per-request не платит за словарь на каждый HTTP-запрос.
|
|
1130
|
+
Без `enforceAcl` `db.as()` бесплатен вовсе: клон контекста, без SQL и без нового соединения.
|
|
1129
1131
|
|
|
1130
1132
|
`enforceAccount` (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
|
|
1131
1133
|
|
|
@@ -1274,21 +1276,23 @@ await db.запись().rows() // ошибка: enforceAccount i
|
|
|
1274
1276
|
|
|
1275
1277
|
**Цена изоляции — отрицательная в реальной многоарендаторной базе.** Предикат `account`
|
|
1276
1278
|
попадает не только в перепроверку, но и в подзапрос кандидатов, то есть **сужает выборку по
|
|
1277
|
-
индексу до основного
|
|
1278
|
-
`Customer`, `count()
|
|
1279
|
+
индексу до основного скана**: изоляция не добавляет фильтр к полному проходу по классу, а
|
|
1280
|
+
заменяет его. Замерено на полигоне из 10 арендаторов (1 млн версий класса `Customer`, `count()`,
|
|
1281
|
+
p50 из 5; воспроизводится `node bench/tenants-seed.mjs && npx tsx bench/isolation.bench.mjs`):
|
|
1279
1282
|
|
|
1280
|
-
| Доля арендатора в классе |
|
|
1281
|
-
|
|
1282
|
-
| 50 % |
|
|
1283
|
-
| 10 % |
|
|
1284
|
-
| 1 % |
|
|
1285
|
-
| 0.3 % |
|
|
1286
|
-
|
|
1287
|
-
Чем мельче арендатор, тем
|
|
1288
|
-
|
|
1289
|
-
|
|
1283
|
+
| Доля арендатора в классе | `db.as(арендатор)` | явный `.account()` | против всего класса (835 ms) |
|
|
1284
|
+
|---|---|---|---|
|
|
1285
|
+
| 50 % (250 000 сущн.) | 954 ms | 945 ms | равно |
|
|
1286
|
+
| 10 % (50 000) | 316 ms | 314 ms | быстрее ×2.6 |
|
|
1287
|
+
| 1 % (5 000) | 96 ms | 95 ms | быстрее ×8.7 |
|
|
1288
|
+
| 0.3 % (1 500) | 32 ms | 27 ms | **быстрее ×26.5** |
|
|
1289
|
+
|
|
1290
|
+
Чем мельче арендатор, тем дешевле чтение. Колонки `db.as()` и `.account()` совпадают: SQL у них
|
|
1291
|
+
один — изоляция не берёт ничего сверх явного фильтра, она лишь ставит его на каждый шаг сама.
|
|
1292
|
+
Расплата только в вырожденном случае, когда один арендатор владеет почти всем классом: на
|
|
1293
|
+
`v1.salondemo` System владеет 100 %, и там `count()` под изоляцией — 2.6 с против 1.3 с.
|
|
1290
1294
|
Вывод практический: чем больше у вас арендаторов, тем выгоднее держать изоляцию включённой.
|
|
1291
|
-
|
|
1295
|
+
Все замеры — на проанализированной базе; без `ANALYZE` эта же сцена давала 54 с (§ 14.1).
|
|
1292
1296
|
|
|
1293
1297
|
Административный доступ (миграции, дев-скрипты, отчёты по всем арендаторам) — явным
|
|
1294
1298
|
`connect({ …, enforceAccount: false })`: там `db` читает и пишет без scope, а колонку
|
|
@@ -1325,6 +1329,49 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
|
|
|
1325
1329
|
- Интервалы: `30d`, `2y`, `12h`, `6mon` или сырой PG (`'90 days'`). Повторный запуск
|
|
1326
1330
|
переустанавливает политику.
|
|
1327
1331
|
|
|
1332
|
+
### 10.8a Обновление движка: ревизия DDL и `upgrade`
|
|
1333
|
+
|
|
1334
|
+
Классы живут в таблице `Schema` и правятся на ходу (§ 10.9), а **движок** — это `ddl.sql`:
|
|
1335
|
+
таблицы, индексы, триггеры и серверные функции. Он тоже растёт: в 0.19.0, например, появились
|
|
1336
|
+
`purge`/`purge_closure`/`purge_account`. Схема, накатанная более старой версией либы, этих
|
|
1337
|
+
функций **не получает** — приложение обновляет пакет и падает сырым
|
|
1338
|
+
|
|
1339
|
+
```
|
|
1340
|
+
PostgresError: function "v1.booking".purge(unknown, unknown, unknown, boolean) does not exist
|
|
1341
|
+
```
|
|
1342
|
+
|
|
1343
|
+
Поэтому последняя строка `ddl.sql` штампует в схему метку ревизии
|
|
1344
|
+
(`COMMENT ON SCHEMA … IS 'letopis ddl_revision=N'`), а `connect()` сверяет её с константой
|
|
1345
|
+
`DDL_REVISION` либы и **один раз на схему за процесс** предупреждает:
|
|
1346
|
+
|
|
1347
|
+
```
|
|
1348
|
+
letopis: движок схемы "v1.booking" отстал — ревизия не помечена (схема накатана либой до
|
|
1349
|
+
появления метки). Схема продолжит работать, но новых функций/триггеров в ней нет
|
|
1350
|
+
(например purge/purge_account из 0.19.0) … up({ …, upgrade: true }) либо
|
|
1351
|
+
node db/apply.mjs --dsn=… --schema=<имя> --version=<N> --upgrade
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
Предупреждение, а не отказ: правки аддитивны — то, что работало, продолжает работать.
|
|
1355
|
+
|
|
1356
|
+
**Апгрейд** перекатывает **только** `ddl.sql`, сиды не трогает:
|
|
1357
|
+
|
|
1358
|
+
```ts
|
|
1359
|
+
await up({ dsn, schema: 'booking', version: 1, upgrade: true }) // данные целы
|
|
1360
|
+
```
|
|
1361
|
+
```bash
|
|
1362
|
+
node db/apply.mjs --dsn=… --schema=booking --version=1 --upgrade
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
Это безопасно, потому что `ddl.sql` идемпотентен: функции — `CREATE OR REPLACE`, триггеры —
|
|
1366
|
+
`DROP IF EXISTS` + `CREATE`, таблицы и индексы — `IF NOT EXISTS`. Данные append-only не
|
|
1367
|
+
перезаписываются.
|
|
1368
|
+
|
|
1369
|
+
**Ревизия ≠ версия схемы.** Ревизия — про аддитивные правки внутри одной версии движка, они
|
|
1370
|
+
доезжают апгрейдом. Несовместимая правка структуры таблиц — это смена `version` в имени схемы
|
|
1371
|
+
(`v1.booking` → `v2.booking`), то есть новая схема и перенос данных силами приложения.
|
|
1372
|
+
Совпадение маркера в `ddl.sql`, штампа `COMMENT` и константы `DDL_REVISION` проверяет
|
|
1373
|
+
`npm run check:docs`.
|
|
1374
|
+
|
|
1328
1375
|
### 10.9 Миграции классов: `scripts/schema-sync.mjs`
|
|
1329
1376
|
|
|
1330
1377
|
> Входит в npm-пакет (`files: ["scripts"]`); в установке путь — `node_modules/letopis/scripts/schema-sync.mjs`. Зависимости — `postgres` и `fastest-validator` (обе — deps пакета), `tsx` не нужен.
|
|
@@ -1414,6 +1461,13 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
|
|
|
1414
1461
|
id сокращены: `…0911` = `00000000-0000-4000-8000-000000000911`; повторяющиеся
|
|
1415
1462
|
`account`/`owner`/`partition` в ответах опущены.
|
|
1416
1463
|
|
|
1464
|
+
> **Как читать тайминги.** Значения перенесены из прогона демо **точным сопоставлением по
|
|
1465
|
+
> коду вызова**, полигон при этом проанализирован (`ANALYZE`, § 14.1 — без него те же запросы
|
|
1466
|
+
> медленнее на порядок). Часть примеров иллюстративна и в демо в такой форме не исполняется
|
|
1467
|
+
> (плейсхолдеры вроде `connect({ dsn, schema })`, переименованные переменные, разбитые на
|
|
1468
|
+
> строки цепочки) — у них цифра осталась от более раннего прогона и может быть пессимистичной.
|
|
1469
|
+
> Сводные, всегда свежие цифры — таблицы § 9.2 (ACL), § 10.6 (изоляция) и § 14 (перф).
|
|
1470
|
+
|
|
1417
1471
|
Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
|
|
1418
1472
|
[11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
|
|
1419
1473
|
[11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-create--update--delete--anonymize) ·
|
|
@@ -1468,7 +1522,7 @@ const db = await connect({ dsn, schema, enforceAccount: false })
|
|
|
1468
1522
|
const iso = await (await connect({ dsn, schema })).as(acc.id) // [55.3 ms]
|
|
1469
1523
|
const uc = await (await connect({ dsn, schema, enforceAcl: true })).as(acc.id) // [58.1 ms]
|
|
1470
1524
|
await db.Организация().count() // → 30 — админский видит всех
|
|
1471
|
-
await iso.Организация().count() // [
|
|
1525
|
+
await iso.Организация().count() // [9.0 ms] → 1 — только свой арендатор
|
|
1472
1526
|
await uc.Организация().rows()
|
|
1473
1527
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default)
|
|
1474
1528
|
await iso.close(); await uc.close()
|
|
@@ -1624,8 +1678,9 @@ import type {
|
|
|
1624
1678
|
**Назначение и алгоритм.** Возвращает хендл **того же пула**, работающий от имени `account`:
|
|
1625
1679
|
арендатор — свойство вызова, не подключения (в `connect()` опции `account` нет). Клонирует
|
|
1626
1680
|
контекст (как `db.begin()`), нового соединения не открывает; под `enforceAcl` дополнительно
|
|
1627
|
-
читает
|
|
1628
|
-
свой, решения между арендаторами не переиспользуются.
|
|
1681
|
+
читает **свой аккаунт** (один SELECT) и компилирует резолвер **под этого субъекта** — у каждого
|
|
1682
|
+
scope свой, решения между арендаторами не переиспользуются. Словарь `Resource`/`Rule` при этом
|
|
1683
|
+
общий: снимается один раз на подключение, сбрасывается `db.reloadSchema()`. Батчи с корня не наследуются: очередь
|
|
1629
1684
|
планов, общая для разных арендаторов, была бы утечкой записи. `close()` закрывает пул, поэтому
|
|
1630
1685
|
зовётся один раз — и с корня, и с любого scope (это одно и то же соединение).
|
|
1631
1686
|
|
|
@@ -1660,10 +1715,10 @@ await db.close() // один раз на пул
|
|
|
1660
1715
|
**Примеры**
|
|
1661
1716
|
|
|
1662
1717
|
```ts
|
|
1663
|
-
await db.Услуга('…0000').first() // [
|
|
1718
|
+
await db.Услуга('…0000').first() // [4.8 ms] по id → Row {name: 'Стрижка 0', …}
|
|
1664
1719
|
await db.Услуга(['…0000', '…0006']).rows() // [2.6 ms] по списку → ['Стрижка 0', 'Бритьё 6']
|
|
1665
|
-
await db.Организация(org).first() // [
|
|
1666
|
-
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [
|
|
1720
|
+
await db.Организация(org).first() // [3.9 ms] Row-объект ≡ его id → 'Салон «Стрижка» №0'
|
|
1721
|
+
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [56 ms] → 630 (фильтр по цене)
|
|
1667
1722
|
await db.НетТакогоКласса().rows()
|
|
1668
1723
|
// Error: letopis: unknown class "НетТакогоКласса". Known: Entity·Сущность, Org·Организация, …
|
|
1669
1724
|
```
|
|
@@ -1671,7 +1726,7 @@ await db.НетТакогоКласса().rows()
|
|
|
1671
1726
|
**Кейс: одна сущность тремя формами фильтра**
|
|
1672
1727
|
|
|
1673
1728
|
```ts
|
|
1674
|
-
const поId = await db.Услуга('…0000').first() // [
|
|
1729
|
+
const поId = await db.Услуга('…0000').first() // [4.8 ms] → data.name = 'Стрижка 0'
|
|
1675
1730
|
const поПолю = await db.Услуга({ name: 'Стрижка 0' }).first() // тот же Row
|
|
1676
1731
|
const поОбъекту = await db.Услуга(поId).first() // Row как фильтр ≡ его id
|
|
1677
1732
|
// все три → id '…0000', data.name = 'Стрижка 0' (цена — отдельным LINK «цена», § 11.9)
|
|
@@ -1692,10 +1747,10 @@ deadlock/serialization ошибка приходит сразу с подска
|
|
|
1692
1747
|
**Примеры**
|
|
1693
1748
|
|
|
1694
1749
|
```ts
|
|
1695
|
-
const tr = await db.begin() // [0
|
|
1750
|
+
const tr = await db.begin() // [1.0 ms]
|
|
1696
1751
|
await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15 }).rows()
|
|
1697
|
-
// [
|
|
1698
|
-
await tr.commit() // [3.
|
|
1752
|
+
// [10 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
|
|
1753
|
+
await tr.commit() // [3.2 ms] — теперь видно всем
|
|
1699
1754
|
```
|
|
1700
1755
|
|
|
1701
1756
|
**Кейс: атомарный перенос с откатом при провале** — § 11.6 (`tr.lock`), плюс rollback:
|
|
@@ -1721,7 +1776,7 @@ await db.цена(цУкл).first() // снаружи → amounts.RUB = 7
|
|
|
1721
1776
|
**Примеры**
|
|
1722
1777
|
|
|
1723
1778
|
```ts
|
|
1724
|
-
await db.commit(tr3) // [4
|
|
1779
|
+
await db.commit(tr3) // [3.4 ms] — то же, что tr3.commit()
|
|
1725
1780
|
await db.commit()
|
|
1726
1781
|
// Error: letopis: commit() needs a transaction: db.commit(tr) or tr.commit() [0.1 ms]
|
|
1727
1782
|
```
|
|
@@ -1777,7 +1832,7 @@ await stop()
|
|
|
1777
1832
|
падают ошибкой postgres.js.
|
|
1778
1833
|
|
|
1779
1834
|
```ts
|
|
1780
|
-
await db.close() // [
|
|
1835
|
+
await db.close() // [2.0 ms]
|
|
1781
1836
|
```
|
|
1782
1837
|
|
|
1783
1838
|
**Кейс** — завершение процесса: `close()` в `finally`/`SIGTERM`-хендлере после `stop()`
|
|
@@ -1799,7 +1854,7 @@ db.registry.resolve('запись') // [99 µs] → ClassDef {id: 'booking', a
|
|
|
1799
1854
|
|
|
1800
1855
|
```ts
|
|
1801
1856
|
await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.salondemo"."Entity"')
|
|
1802
|
-
// [
|
|
1857
|
+
// [51 ms] → [{ n: 980216 }]
|
|
1803
1858
|
```
|
|
1804
1859
|
|
|
1805
1860
|
**Кейс** — снятие плана тяжёлого запроса: `db.sql.unsafe('EXPLAIN (ANALYZE) …')` для
|
|
@@ -1831,8 +1886,8 @@ prev.links->>'Класс'`) кладётся точным равенством,
|
|
|
1831
1886
|
**Примеры**
|
|
1832
1887
|
|
|
1833
1888
|
```ts
|
|
1834
|
-
await db.Организация('…0000').Мастер().count() // [
|
|
1835
|
-
await db.навык().Услуга().count() // [
|
|
1889
|
+
await db.Организация('…0000').Мастер().count() // [12 ms] → 12 (обратный hop)
|
|
1890
|
+
await db.навык().Услуга().count() // [93 ms] → 1260 (LINK → HUB, прямой)
|
|
1836
1891
|
```
|
|
1837
1892
|
|
|
1838
1893
|
**Кейс: маршрут «мастер → его навыки → услуги»** — см. `.run()` ниже (тот же прогон).
|
|
@@ -1849,7 +1904,7 @@ await db.навык().Услуга().count() // [65.2 ms] → 126
|
|
|
1849
1904
|
**Примеры**
|
|
1850
1905
|
|
|
1851
1906
|
```ts
|
|
1852
|
-
await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [
|
|
1907
|
+
await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [23 ms]
|
|
1853
1908
|
// → 2 пути (у Ольги 0.0 два навыка на разные услуги); первый:
|
|
1854
1909
|
// [{
|
|
1855
1910
|
// Мастер: { id: '00000003-…-000', class: 'Staff', data: { name: 'Ольга 0.0', phone: '+7 921 0000000', specialization: 'парикмахер' }, links: { Org: '00000001-…-000' }, … },
|
|
@@ -1861,7 +1916,7 @@ await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().ru
|
|
|
1861
1916
|
**Кейс: отчёт «кто что умеет» одним запросом**
|
|
1862
1917
|
|
|
1863
1918
|
```ts
|
|
1864
|
-
const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [
|
|
1919
|
+
const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().Услуга().run() // [23 ms]
|
|
1865
1920
|
пути.map((p) => `${p.Мастер.data.name} → ${p.Услуга.data.name}`)
|
|
1866
1921
|
// → ['Ольга 0.0 → Стрижка 0', 'Ольга 0.0 → Массаж 7']
|
|
1867
1922
|
```
|
|
@@ -1876,7 +1931,7 @@ const пути = await db.Мастер({ name: 'Ольга 0.0' }).навык().
|
|
|
1876
1931
|
**Примеры**
|
|
1877
1932
|
|
|
1878
1933
|
```ts
|
|
1879
|
-
const услуги = await db.Услуга().rows() // [
|
|
1934
|
+
const услуги = await db.Услуга().rows() // [21 ms] → 600 Row
|
|
1880
1935
|
// [0] = { id: '00000004-0000-4000-8000-000000000000', class: 'Service',
|
|
1881
1936
|
// data: { name: 'Стрижка 0', duration: 30, description: 'популярное' },
|
|
1882
1937
|
// links: { Org: '00000001-0000-4000-8000-000000000000' }, tags: [],
|
|
@@ -1886,7 +1941,7 @@ const услуги = await db.Услуга().rows() // [11.1 ms] → 600 Row
|
|
|
1886
1941
|
**Кейс: пути vs уникальные сущности**
|
|
1887
1942
|
|
|
1888
1943
|
```ts
|
|
1889
|
-
await db.навык().Услуга().count() // [
|
|
1944
|
+
await db.навык().Услуга().count() // [93 ms] → 1260 путей (навык → услуга)
|
|
1890
1945
|
(await db.навык().Услуга().rows()).length // [83.6 ms] → 600 уникальных услуг
|
|
1891
1946
|
```
|
|
1892
1947
|
|
|
@@ -1895,10 +1950,10 @@ await db.навык().Услуга().count() // [65.2 ms] → 1260 пу
|
|
|
1895
1950
|
Параметров нет. То же, что `rows()` с `LIMIT 1`: первая строка или `null`.
|
|
1896
1951
|
|
|
1897
1952
|
```ts
|
|
1898
|
-
await db.Мастер({ name: 'Олег 0.7' }).first() // [
|
|
1953
|
+
await db.Мастер({ name: 'Олег 0.7' }).first() // [8.6 ms]
|
|
1899
1954
|
// → { id: '…0007', class: 'Staff', data: { name: 'Олег 0.7', phone: '+7 921 0000007', specialization: 'колорист' },
|
|
1900
1955
|
// links: { Org: '…0000' }, tags: [], updated: '2025-08-01T00:00:00+00:00' }
|
|
1901
|
-
await db.Мастер({ name: 'Гэндальф' }).first() // [
|
|
1956
|
+
await db.Мастер({ name: 'Гэндальф' }).first() // [7.2 ms] → null
|
|
1902
1957
|
```
|
|
1903
1958
|
|
|
1904
1959
|
**Кейс: проверка «занято ли окно» перед бронью** — § 11.6 (перечитка под локом).
|
|
@@ -1909,7 +1964,7 @@ await db.Мастер({ name: 'Гэндальф' }).first() // [5.7 ms] → nul
|
|
|
1909
1964
|
(дешевле по трафику).
|
|
1910
1965
|
|
|
1911
1966
|
```ts
|
|
1912
|
-
await db.Мастер().ids() // [
|
|
1967
|
+
await db.Мастер().ids() // [6.7 ms] → 360 id
|
|
1913
1968
|
// ['00000003-0000-4000-8000-000000000000', '…0001', '00000003-0000-4000-8000-000000000002', …]
|
|
1914
1969
|
```
|
|
1915
1970
|
|
|
@@ -1925,17 +1980,17 @@ await db.Мастер().ids() // [4.3 ms] → 360 id
|
|
|
1925
1980
|
для многошаговой — нет (см. кейс `.rows()`).
|
|
1926
1981
|
|
|
1927
1982
|
```ts
|
|
1928
|
-
await db.Организация('…0000').Мастер().count() // [
|
|
1929
|
-
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [
|
|
1983
|
+
await db.Организация('…0000').Мастер().count() // [12 ms] → 12
|
|
1984
|
+
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [56 ms] → 630
|
|
1930
1985
|
await db.Локация({ coordinates: { lat: gte(55.5) } }).count()
|
|
1931
|
-
// [
|
|
1986
|
+
// [5.7 ms] → 26 — оператор на листе record-пути (глубина 2), каст numeric по Schema
|
|
1932
1987
|
```
|
|
1933
1988
|
|
|
1934
1989
|
**Кейс: витрина каталога** — счётчики к фильтрам без выборки строк:
|
|
1935
1990
|
|
|
1936
1991
|
```ts
|
|
1937
|
-
await db.Услуга({ duration: lte(45) }).count() // [
|
|
1938
|
-
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [
|
|
1992
|
+
await db.Услуга({ duration: lte(45) }).count() // [4.6 ms] → 300 «быстрые»
|
|
1993
|
+
await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() // [56 ms] → 630 «премиум»
|
|
1939
1994
|
```
|
|
1940
1995
|
|
|
1941
1996
|
#### `.limit(n): Chain` / `.offset(n): Chain`
|
|
@@ -1951,9 +2006,9 @@ await db.цена({ amounts: { RUB: gte(1300) } }).Услуга().count() //
|
|
|
1951
2006
|
**Примеры**
|
|
1952
2007
|
|
|
1953
2008
|
```ts
|
|
1954
|
-
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [
|
|
2009
|
+
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [17 ms]
|
|
1955
2010
|
// → [{ note: 'базовая', RUB: 8100 }, { note: 'базовая', RUB: 8000 }, { note: 'базовая', RUB: 7900 }]
|
|
1956
|
-
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows() // [19
|
|
2011
|
+
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows() // [19 ms]
|
|
1957
2012
|
// → [{ RUB: 7800 }, { RUB: 7700 }, { RUB: 7600 }] — вторая страница
|
|
1958
2013
|
```
|
|
1959
2014
|
|
|
@@ -1974,10 +2029,10 @@ await db.цена().sort('data.amounts.RUB', 'desc').limit(3).offset(3).rows()
|
|
|
1974
2029
|
**Примеры**
|
|
1975
2030
|
|
|
1976
2031
|
```ts
|
|
1977
|
-
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [
|
|
1978
|
-
await db.Услуга().sort('data.duration').limit(2).rows() // [
|
|
2032
|
+
await db.цена().sort('data.amounts.RUB', 'desc').limit(3).rows() // [17 ms] → 8100, 8000, 7900
|
|
2033
|
+
await db.Услуга().sort('data.duration').limit(2).rows() // [11 ms] asc по умолчанию
|
|
1979
2034
|
// → [{ name: 'Стрижка 0', duration: 30 }, { name: 'Педикюр 4', duration: 30 }]
|
|
1980
|
-
await db.запись().sort('updated', 'desc').limit(2).rows() // [
|
|
2035
|
+
await db.запись().sort('updated', 'desc').limit(2).rows() // [5726 ms]
|
|
1981
2036
|
// ЧЕСТНО: DISTINCT ON всех ~440k сущностей класса без фильтра — см. § 14
|
|
1982
2037
|
```
|
|
1983
2038
|
|
|
@@ -1998,7 +2053,7 @@ await db.запись().sort('updated', 'desc').limit(2).rows() // [6232.3
|
|
|
1998
2053
|
**Примеры**
|
|
1999
2054
|
|
|
2000
2055
|
```ts
|
|
2001
|
-
const истор = await db.цена('…0009').versions() // [
|
|
2056
|
+
const истор = await db.цена('…0009').versions() // [9.1 ms] (для t1 ниже)
|
|
2002
2057
|
await db.цена('…0009').asOf(истор[0].updated).first() // [3.0 ms]
|
|
2003
2058
|
// → data.amounts.RUB = 2550 — цена ТОГДА
|
|
2004
2059
|
await db.цена('…0009').first()
|
|
@@ -2020,7 +2075,7 @@ await db.цена('…0009').first()
|
|
|
2020
2075
|
**Примеры**
|
|
2021
2076
|
|
|
2022
2077
|
```ts
|
|
2023
|
-
await db.цена('…0009').versions() // [
|
|
2078
|
+
await db.цена('…0009').versions() // [9.1 ms]
|
|
2024
2079
|
// → [{ RUB: 2550, updated: '2025-08-23T…' }, { RUB: 2650, updated: '2025-11-21T…' }]
|
|
2025
2080
|
await db.Клиент('…0931').versions() // жизнь с удалением и воскрешением:
|
|
2026
2081
|
// → [{ name: 'Злата', updated: '…54.159646' },
|
|
@@ -2117,10 +2172,10 @@ const брони = await db.Мастер(и).запись().rows() // ч
|
|
|
2117
2172
|
**Примеры**
|
|
2118
2173
|
|
|
2119
2174
|
```ts
|
|
2120
|
-
await db.Папка('…0000').Папка().deep().rows() // [
|
|
2175
|
+
await db.Папка('…0000').Папка().deep().rows() // [22 ms]
|
|
2121
2176
|
// → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 },
|
|
2122
2177
|
// { name: 'Борода и усы', $depth: 2 }, { name: 'Уход', $depth: 3 }]
|
|
2123
|
-
await db.Папка('…0000').Папка().deep(1).rows() // [
|
|
2178
|
+
await db.Папка('…0000').Папка().deep(1).rows() // [15 ms] только прямые дети
|
|
2124
2179
|
// → [{ name: 'Мужской зал', $depth: 1 }, { name: 'Женский зал', $depth: 1 }]
|
|
2125
2180
|
```
|
|
2126
2181
|
|
|
@@ -2148,10 +2203,10 @@ const дерево = await db.Папка(корень).Папка().deep().rows(
|
|
|
2148
2203
|
**Примеры**
|
|
2149
2204
|
|
|
2150
2205
|
```ts
|
|
2151
|
-
await db.цена().sum('data.amounts.RUB') // [
|
|
2152
|
-
await db.Услуга().avg('data.duration') // [
|
|
2153
|
-
await db.Локация({}).sum('data.coordinates.lat') // [
|
|
2154
|
-
await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [
|
|
2206
|
+
await db.цена().sum('data.amounts.RUB') // [9.0 ms] → 2277000
|
|
2207
|
+
await db.Услуга().avg('data.duration') // [7.4 ms] → 52.5
|
|
2208
|
+
await db.Локация({}).sum('data.coordinates.lat') // [4.0 ms] лист record-пути (глубина 2) → 3327.925547539955
|
|
2209
|
+
await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [6.6 ms] → null (пусто)
|
|
2155
2210
|
```
|
|
2156
2211
|
|
|
2157
2212
|
**Кейс: итог по каталогу без выгрузки строк** — `sum('data.amounts.RUB')` по 405 ценам за 4 ms;
|
|
@@ -2163,8 +2218,8 @@ await db.Услуга({ name: 'НетТакой' }).sum('data.duration') // [5
|
|
|
2163
2218
|
**числом**, string — строкой.
|
|
2164
2219
|
|
|
2165
2220
|
```ts
|
|
2166
|
-
await db.цена().min('data.amounts.RUB') // [
|
|
2167
|
-
await db.цена().max('data.amounts.RUB') // [
|
|
2221
|
+
await db.цена().min('data.amounts.RUB') // [7.1 ms] → 300 (число, не '300')
|
|
2222
|
+
await db.цена().max('data.amounts.RUB') // [6.2 ms] → 8100
|
|
2168
2223
|
```
|
|
2169
2224
|
|
|
2170
2225
|
**Кейс: границы ценового слайдера** — `min` + `max` двумя запросами по 3–4 ms.
|
|
@@ -2179,7 +2234,7 @@ await db.цена().max('data.amounts.RUB') // [14.8 ms] → 8100
|
|
|
2179
2234
|
объекта = значения поля, значения = счётчики (по путям).
|
|
2180
2235
|
|
|
2181
2236
|
```ts
|
|
2182
|
-
await db.запись().countBy('data.notes') // [
|
|
2237
|
+
await db.запись().countBy('data.notes') // [1853 ms] — ~440k сущностей
|
|
2183
2238
|
// → { 'подтверждена': 400000, 'по телефону: Вера': 3334, 'по телефону: Ольга': 3334, …,
|
|
2184
2239
|
// 'по телефону: Марина': 3333 } — 12 имён «по телефону» по ~3333 (ручные брони ~9%)
|
|
2185
2240
|
```
|
|
@@ -2197,7 +2252,7 @@ await db.запись().countBy('data.notes') // [3266.3 ms] — ~440k сущ
|
|
|
2197
2252
|
|
|
2198
2253
|
```ts
|
|
2199
2254
|
await db.Организация(org).alias('салон').Мастер({ name: 'Ольга 0.0' }).alias('мастер').run()
|
|
2200
|
-
// [
|
|
2255
|
+
// [13 ms] → ключи пути: ['салон', 'мастер']
|
|
2201
2256
|
```
|
|
2202
2257
|
|
|
2203
2258
|
**Кейс: self-join читаемо** — `db.Папка(a).alias('родитель').Папка().alias('дочка').run()`.
|
|
@@ -2212,8 +2267,8 @@ await db.Организация(org).alias('салон').Мастер({ name: '
|
|
|
2212
2267
|
кандидаты + перепроверка). В записи — модификатор значения.
|
|
2213
2268
|
|
|
2214
2269
|
```ts
|
|
2215
|
-
await db.Клиент().tags('vip').count() // [
|
|
2216
|
-
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [
|
|
2270
|
+
await db.Клиент().tags('vip').count() // [52 ms] → 400 (vip-клиенты)
|
|
2271
|
+
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [78 ms] → 800
|
|
2217
2272
|
```
|
|
2218
2273
|
|
|
2219
2274
|
**Кейс: пометить и найти** — § 5 (вставка с `.tags(['vip','telegram'])`, поиск `tags('vip')`);
|
|
@@ -2233,7 +2288,7 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [48.4 ms]
|
|
|
2233
2288
|
|
|
2234
2289
|
```ts
|
|
2235
2290
|
await db.Организация().account(SYS).count() // [5.1 ms] → 30
|
|
2236
|
-
await db.Организация().owner(SYS).count() // [
|
|
2291
|
+
await db.Организация().owner(SYS).count() // [5.5 ms] → 30
|
|
2237
2292
|
```
|
|
2238
2293
|
|
|
2239
2294
|
**Кейс: чей это салон** — профиль владельца строки: `db.accounts.get(row.owner)`; выборка
|
|
@@ -2252,7 +2307,7 @@ await db.Организация().owner(SYS).count() // [3.9 ms] → 30
|
|
|
2252
2307
|
`v: string | number | boolean | null` — «не равно» (`IS DISTINCT FROM` — null-безопасно).
|
|
2253
2308
|
|
|
2254
2309
|
```ts
|
|
2255
|
-
await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.
|
|
2310
|
+
await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.3 ms] → 570
|
|
2256
2311
|
```
|
|
2257
2312
|
|
|
2258
2313
|
**Кейс:** всё, кроме выбранного, — «другие услуги» под карточкой текущей.
|
|
@@ -2262,7 +2317,7 @@ await db.Услуга({ name: ne('Стрижка 0') }).count() // [4.9 ms]
|
|
|
2262
2317
|
`v: number | string` — строго больше / больше-или-равно (числа и сравнимые строки-даты).
|
|
2263
2318
|
|
|
2264
2319
|
```ts
|
|
2265
|
-
await db.Услуга({ duration: gt(60) }).count() // [
|
|
2320
|
+
await db.Услуга({ duration: gt(60) }).count() // [4.4 ms] → 150
|
|
2266
2321
|
await db.Услуга({ duration: gte(60) }).count() // [4.3 ms] → 300
|
|
2267
2322
|
```
|
|
2268
2323
|
|
|
@@ -2274,8 +2329,8 @@ await db.Услуга({ duration: gte(60) }).count() // [4.3 ms] → 300
|
|
|
2274
2329
|
`v: number | string` — строго меньше / меньше-или-равно.
|
|
2275
2330
|
|
|
2276
2331
|
```ts
|
|
2277
|
-
await db.Услуга({ duration: lt(45) }).count() // [5
|
|
2278
|
-
await db.Услуга({ duration: lte(45) }).count() // [
|
|
2332
|
+
await db.Услуга({ duration: lt(45) }).count() // [6.5 ms] → 150
|
|
2333
|
+
await db.Услуга({ duration: lte(45) }).count() // [4.6 ms] → 300
|
|
2279
2334
|
```
|
|
2280
2335
|
|
|
2281
2336
|
**Кейс:** «экспресс до 45 минут включительно» = `lte(45)` → 300 услуг.
|
|
@@ -2285,7 +2340,7 @@ await db.Услуга({ duration: lte(45) }).count() // [3.3 ms] → 300
|
|
|
2285
2340
|
`a, b: number | string` — диапазон включительно (`a ≤ x ≤ b`).
|
|
2286
2341
|
|
|
2287
2342
|
```ts
|
|
2288
|
-
await db.Услуга({ duration: between(40, 65) }).count() // [
|
|
2343
|
+
await db.Услуга({ duration: between(40, 65) }).count() // [4.7 ms] → 300
|
|
2289
2344
|
```
|
|
2290
2345
|
|
|
2291
2346
|
**Кейс:** слайдер длительности «40–65 минут» одной функцией вместо пары gte+lte.
|
|
@@ -2295,7 +2350,7 @@ await db.Услуга({ duration: between(40, 65) }).count() // [8.1 ms] → 3
|
|
|
2295
2350
|
`vs: (string | number)[]` — значение из списка (`IN`).
|
|
2296
2351
|
|
|
2297
2352
|
```ts
|
|
2298
|
-
await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [
|
|
2353
|
+
await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).count() // [5.3 ms] → 60
|
|
2299
2354
|
```
|
|
2300
2355
|
|
|
2301
2356
|
**Кейс:** сравнение выбранных чекбоксами услуг: имена из UI → один запрос.
|
|
@@ -2305,8 +2360,8 @@ await db.Услуга({ name: inList(['Стрижка 0', 'Массаж 7']) }).
|
|
|
2305
2360
|
`s: string` — SQL-шаблон (`%` — любое, `_` — один символ); `ilike` — без учёта регистра.
|
|
2306
2361
|
|
|
2307
2362
|
```ts
|
|
2308
|
-
await db.Услуга({ name: like('Стри%') }).count() // [
|
|
2309
|
-
await db.Услуга({ name: ilike('%массаж%') }).count() // [4.
|
|
2363
|
+
await db.Услуга({ name: like('Стри%') }).count() // [6.6 ms] → 60
|
|
2364
|
+
await db.Услуга({ name: ilike('%массаж%') }).count() // [4.8 ms] → 60
|
|
2310
2365
|
```
|
|
2311
2366
|
|
|
2312
2367
|
**Кейс:** живой поиск в админке — `ilike('%' + ввод + '%')` прощает регистр
|
|
@@ -2317,8 +2372,8 @@ await db.Услуга({ name: ilike('%массаж%') }).count() // [4.3 m
|
|
|
2317
2372
|
`s: string` — начинается с / заканчивается на (сахар над `like(s+'%')` / `like('%'+s)`).
|
|
2318
2373
|
|
|
2319
2374
|
```ts
|
|
2320
|
-
await db.Услуга({ name: starts('Массаж') }).count() // [
|
|
2321
|
-
await db.Услуга({ name: ends('7') }).count() // [
|
|
2375
|
+
await db.Услуга({ name: starts('Массаж') }).count() // [5.7 ms] → 60
|
|
2376
|
+
await db.Услуга({ name: ends('7') }).count() // [5.0 ms] → 60
|
|
2322
2377
|
```
|
|
2323
2378
|
|
|
2324
2379
|
**Кейс:** префиксная навигация по названию: `starts('Массаж')` → все «Массаж N» (60 в каталоге).
|
|
@@ -2329,9 +2384,9 @@ await db.Услуга({ name: ends('7') }).count() // [2.9 ms] → 60
|
|
|
2329
2384
|
идёт и в GIN-кандидаты). В демо-схеме массивов в `data` нет — операторы показаны на колонке `tags`.
|
|
2330
2385
|
|
|
2331
2386
|
```ts
|
|
2332
|
-
await db.Клиент().tags(has('vip')).count() // [
|
|
2333
|
-
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [
|
|
2334
|
-
await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [
|
|
2387
|
+
await db.Клиент().tags(has('vip')).count() // [85 ms] → 400
|
|
2388
|
+
await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [78 ms] → 800
|
|
2389
|
+
await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [19 ms] → 100
|
|
2335
2390
|
```
|
|
2336
2391
|
|
|
2337
2392
|
**Кейс:** сегменты по меткам: «vip ИЛИ из телеграма» = `tags(hasAny(['vip','telegram']))` → 800;
|
|
@@ -2342,8 +2397,8 @@ await db.Клиент().tags(hasAll(['vip', 'telegram'])).count() // [11.0 ms
|
|
|
2342
2397
|
`yes: boolean?` — поле присутствует (`true`, default) / отсутствует (`false`) в `data`.
|
|
2343
2398
|
|
|
2344
2399
|
```ts
|
|
2345
|
-
await db.Услуга({ description: exists() }).count() // [
|
|
2346
|
-
await db.Услуга({ description: exists(false) }).count() // [
|
|
2400
|
+
await db.Услуга({ description: exists() }).count() // [7.9 ms] → 390
|
|
2401
|
+
await db.Услуга({ description: exists(false) }).count() // [6.2 ms] → 210
|
|
2347
2402
|
```
|
|
2348
2403
|
|
|
2349
2404
|
**Кейс:** контроль заполненности каталога — «услуги без ключа `description`» = `exists(false)` → 210
|
|
@@ -2354,7 +2409,7 @@ await db.Услуга({ description: exists(false) }).count() // [3.2 ms] →
|
|
|
2354
2409
|
Без параметров — поле `NULL` **или** отсутствует.
|
|
2355
2410
|
|
|
2356
2411
|
```ts
|
|
2357
|
-
await db.Услуга({ description: isNull() }).count() // [
|
|
2412
|
+
await db.Услуга({ description: isNull() }).count() // [6.1 ms] → 390
|
|
2358
2413
|
```
|
|
2359
2414
|
|
|
2360
2415
|
**Кейс:** отличие от `exists(false)` — на полигоне видно числом: `isNull()` → 390 (ловит и явный
|
|
@@ -2366,8 +2421,8 @@ await db.Услуга({ description: isNull() }).count() // [3.1 ms] → 390
|
|
|
2366
2421
|
`v: скаляр | Op` — отрицание; скаляр ≡ «не равно» (как `ne`).
|
|
2367
2422
|
|
|
2368
2423
|
```ts
|
|
2369
|
-
await db.Услуга({ name: not(starts('Стрижка')) }).count() // [
|
|
2370
|
-
await db.Услуга({ name: not('Стрижка 0') }).count() // [3
|
|
2424
|
+
await db.Услуга({ name: not(starts('Стрижка')) }).count() // [5.2 ms] → 540
|
|
2425
|
+
await db.Услуга({ name: not('Стрижка 0') }).count() // [4.3 ms] → 570
|
|
2371
2426
|
```
|
|
2372
2427
|
|
|
2373
2428
|
**Кейс:** инверсия готового условия без переписывания: «всё, что НЕ стрижки» =
|
|
@@ -2379,7 +2434,7 @@ await db.Услуга({ name: not('Стрижка 0') }).count() // [3.
|
|
|
2379
2434
|
обычное И).
|
|
2380
2435
|
|
|
2381
2436
|
```ts
|
|
2382
|
-
await db.Услуга(or({ name: 'Стрижка 0' }, { duration: lt(45) })).count() // [
|
|
2437
|
+
await db.Услуга(or({ name: 'Стрижка 0' }, { duration: lt(45) })).count() // [5.7 ms] → 150
|
|
2383
2438
|
```
|
|
2384
2439
|
|
|
2385
2440
|
**Кейс:** «Стрижка 0 или что-нибудь быстрое» — один запрос: Стрижка 0 (duration 30) уже среди
|
|
@@ -2414,11 +2469,11 @@ INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конф
|
|
|
2414
2469
|
**Примеры**
|
|
2415
2470
|
|
|
2416
2471
|
```ts
|
|
2417
|
-
await db.Организация().create({ name: 'Пилигрим' }).rows() // [
|
|
2472
|
+
await db.Организация().create({ name: 'Пилигрим' }).rows() // [45 ms] INSERT + defaults из Schema
|
|
2418
2473
|
// → [{ id: '019f5a53-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, links: {}, … }]
|
|
2419
2474
|
|
|
2420
2475
|
await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
|
|
2421
|
-
// [
|
|
2476
|
+
// [14 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
|
|
2422
2477
|
|
|
2423
2478
|
await db.Организация('…0901').Мастер().create({ id: '…0911', name: 'Мия', phone: '+7 909 000-09-11', specialization: 'массажист' }).rows()
|
|
2424
2479
|
// [15.2 ms] контекст → links: { Org: '…0901' }
|
|
@@ -2430,7 +2485,7 @@ await db.Организация('…0901').Услуга().create({ name: 'Мас
|
|
|
2430
2485
|
db.Услуга({ name: 'Массаж головы' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
|
|
2431
2486
|
// Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
|
|
2432
2487
|
|
|
2433
|
-
await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [
|
|
2488
|
+
await db.Организация('…0901').Услуга().create({ name: 'X', чепуха: 1 }).rows() // [11 ms] — ошибка НА ТЕРМИНАЛЕ:
|
|
2434
2489
|
// ValidationError: letopis: validation failed for "Service":
|
|
2435
2490
|
// The object '' contains forbidden keys: 'чепуха'.
|
|
2436
2491
|
|
|
@@ -2455,12 +2510,12 @@ db.Локация('…0921').Клиент() // [0.1 ms] недопустимы
|
|
|
2455
2510
|
**Примеры**
|
|
2456
2511
|
|
|
2457
2512
|
```ts
|
|
2458
|
-
await db.Клиент('…0931').запись({ start_datetime: between(t, t) }).update({ notes: 'подтверждена' }).rows() // [
|
|
2513
|
+
await db.Клиент('…0931').запись({ start_datetime: between(t, t) }).update({ notes: 'подтверждена' }).rows() // [22 ms]
|
|
2459
2514
|
// → [{ id: '4907d8cd-…', notes: 'подтверждена' }]
|
|
2460
2515
|
// момент фильтруется between(t, t): скаляр-eq по date-полю = строковый containment, потому диапазон
|
|
2461
2516
|
// (сотни мс: поиск целей идёт по всему классу записей ~440k без btree по data->>'start_datetime' — § 14)
|
|
2462
2517
|
|
|
2463
|
-
await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [
|
|
2518
|
+
await db.Клиент('…0931').запись({}).update({ notes: 'день закрыт' }).rows() // [25 ms] — ВСЕ записи в контексте
|
|
2464
2519
|
// → [{ id: '4907d8cd-…', notes: 'день закрыт' }, { id: 'e7f16a08-…', notes: 'день закрыт' }]
|
|
2465
2520
|
|
|
2466
2521
|
await db.запись({ notes: 'нет-такого' }).update({ notes: 'x' }).rows() // → [] — update НИКОГДА не создаёт
|
|
@@ -2481,7 +2536,7 @@ await db.запись('…d091').update({ notes: 'подтверждена' }).u
|
|
|
2481
2536
|
// → ['выполнена']; versions: [null, 'подтверждена', 'выполнена']
|
|
2482
2537
|
|
|
2483
2538
|
// хвост-чтение после операции — в той же транзакции
|
|
2484
|
-
await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [
|
|
2539
|
+
await db.Клиент('…0931').update({ preferred_contact: 'phone' }).запись().count() // [23 ms] → 1
|
|
2485
2540
|
|
|
2486
2541
|
// ОТКАТ: валидный update + невалидный create — весь план назад
|
|
2487
2542
|
await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().create({ чепуха: 1 }).rows()
|
|
@@ -2489,7 +2544,7 @@ await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запис
|
|
|
2489
2544
|
|
|
2490
2545
|
// fan-out: обновить клиента → снести ВСЕ его записи
|
|
2491
2546
|
await db.Клиент('…0931').update({ notes: 'аудит 2026' }).запись().delete({ confirm: true }).rows()
|
|
2492
|
-
// [
|
|
2547
|
+
// [40 ms] → снесено 1 запись, с $deleted: true
|
|
2493
2548
|
```
|
|
2494
2549
|
|
|
2495
2550
|
#### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
|
|
@@ -2550,13 +2605,13 @@ tombstone-версия → рекурсивное удаление зависи
|
|
|
2550
2605
|
**Примеры**
|
|
2551
2606
|
|
|
2552
2607
|
```ts
|
|
2553
|
-
await db.Клиент('…0931').delete().rows() // [
|
|
2608
|
+
await db.Клиент('…0931').delete().rows() // [23 ms] ПРЕВЬЮ — кандидаты живы:
|
|
2554
2609
|
// → [{ id: '…0931', class: 'Customer' }, { class: 'booking' }, { class: 'booking' }] — клиент + его записи (каскад)
|
|
2555
2610
|
// после превью клиент жив: true
|
|
2556
2611
|
|
|
2557
|
-
await db.Клиент('…0931').delete({ confirm: true }).rows() // [
|
|
2612
|
+
await db.Клиент('…0931').delete({ confirm: true }).rows() // [49 ms] — сервер нашёл зависимых:
|
|
2558
2613
|
// → те же три, каждый с $deleted: true
|
|
2559
|
-
await db.Клиент('…0931').delete({ confirm: true }).rows() // [
|
|
2614
|
+
await db.Клиент('…0931').delete({ confirm: true }).rows() // [49 ms] повторно → []
|
|
2560
2615
|
```
|
|
2561
2616
|
|
|
2562
2617
|
**Кейс: отмена и воскрешение**
|
|
@@ -2584,7 +2639,7 @@ await db.Организация('…0901').Клиент().create({ id: cid, name
|
|
|
2584
2639
|
**Примеры**
|
|
2585
2640
|
|
|
2586
2641
|
```ts
|
|
2587
|
-
await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [
|
|
2642
|
+
await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [13 ms]
|
|
2588
2643
|
// → [{ data: { name: '[erased]', phone: '[erased]', preferred_contact: 'phone' },
|
|
2589
2644
|
// tags: ['anonymized'] }]
|
|
2590
2645
|
```
|
|
@@ -2592,7 +2647,7 @@ await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [12.7
|
|
|
2592
2647
|
**Кейс: запрос на забвение**
|
|
2593
2648
|
|
|
2594
2649
|
```ts
|
|
2595
|
-
await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [
|
|
2650
|
+
await db.Клиент('…0931').anonymize(['name', 'phone']).rows() // [13 ms]
|
|
2596
2651
|
;(await db.Клиент('…0931').versions()).map((r) => r.data.name) // → ['Злата', 'Злата', 'Злата', '[erased]']
|
|
2597
2652
|
await db.Клиент().tags('anonymized').count() // все стёртые — под контролем
|
|
2598
2653
|
```
|
|
@@ -2613,10 +2668,10 @@ await db.Клиент().tags('anonymized').count() // вс
|
|
|
2613
2668
|
**Примеры**
|
|
2614
2669
|
|
|
2615
2670
|
```ts
|
|
2616
|
-
const tr = await db.begin() // [1.
|
|
2671
|
+
const tr = await db.begin() // [1.0 ms]
|
|
2617
2672
|
await tr.цена(цУкл).update({ amounts: { RUB: 9900 } }).rows() // цУкл — базовая цена «Укладки»
|
|
2618
2673
|
await tr.цена(цУкл).first() // внутри → amounts.RUB = 9900
|
|
2619
|
-
await tr.rollback() // [
|
|
2674
|
+
await tr.rollback() // [1.1 ms]
|
|
2620
2675
|
await db.цена(цУкл).first() // снаружи → amounts.RUB = 700, изменения нет
|
|
2621
2676
|
```
|
|
2622
2677
|
|
|
@@ -2638,7 +2693,7 @@ await db.цена(цУкл).first() // снаружи → amo
|
|
|
2638
2693
|
**Примеры**
|
|
2639
2694
|
|
|
2640
2695
|
```ts
|
|
2641
|
-
await trA.lock('booking', staffId, start) // [
|
|
2696
|
+
await trA.lock('booking', staffId, start) // [1.7 ms]
|
|
2642
2697
|
await db.lock('x')
|
|
2643
2698
|
// Error: letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock) [0.2 ms]
|
|
2644
2699
|
```
|
|
@@ -2650,7 +2705,7 @@ await db.lock('x')
|
|
|
2650
2705
|
// id записи детерминирован (v5, § 3.2) — известен ДО создания:
|
|
2651
2706
|
const bId = uuidv5(`v1.salondemo:entity:booking:${мастер.id}:${start}`)
|
|
2652
2707
|
const trA = await db.begin(), trB = await db.begin()
|
|
2653
|
-
await trA.lock('booking', мастер.id, start) // [
|
|
2708
|
+
await trA.lock('booking', мастер.id, start) // [1.7 ms] A первый
|
|
2654
2709
|
const гонкаB = (async () => {
|
|
2655
2710
|
await trB.lock('booking', мастер.id, start) // B ВИСИТ до конца trA
|
|
2656
2711
|
const занято = await trB.запись(bId).first() // перечитка под локом
|
|
@@ -2706,7 +2761,7 @@ b.Мастер(m).окно().create({ start_datetime: '2026-08-05T07:00:00Z', en
|
|
|
2706
2761
|
b.Мастер(m).окно().create({ start_datetime: '2026-08-05T09:00:00Z', end_datetime: '…11:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
2707
2762
|
b.Мастер(m).окно().create({ start_datetime: '2026-08-05T11:00:00Z', end_datetime: '…13:00Z' }).Локация.set(loc).Расписание.set(sch)
|
|
2708
2763
|
b.size() // [35 µs] → 3
|
|
2709
|
-
await b.run() // [
|
|
2764
|
+
await b.run() // [63 ms] — одна транзакция; окно — v5-класс → 3 честных INSERT, не склейка
|
|
2710
2765
|
// → [[{ id: '06f67c88-…', start_datetime: '2026-08-05T07:00:00.000Z' }], [{ …09:00 }], [{ …11:00 }]]
|
|
2711
2766
|
// id каждого окна вычислен схемой: uuidv5(Staff, start_datetime) — § 3.2
|
|
2712
2767
|
b.size() // → 0
|
|
@@ -2749,7 +2804,7 @@ await db.Мастер(m).окно({ start_datetime: between('2026-08-06T11:00:00
|
|
|
2749
2804
|
`category` → `= ANY(categories)`.
|
|
2750
2805
|
|
|
2751
2806
|
```ts
|
|
2752
|
-
await db.accounts.find({ category: 'Client', enabled: true }) // [
|
|
2807
|
+
await db.accounts.find({ category: 'Client', enabled: true }) // [4.1 ms] → 1 аккаунт
|
|
2753
2808
|
```
|
|
2754
2809
|
|
|
2755
2810
|
**Кейс:** список арендаторов для биллинга: `find({ enabled: true })`, отключённые не в счёте.
|
|
@@ -2759,7 +2814,7 @@ await db.accounts.find({ category: 'Client', enabled: true }) // [2.4 ms] →
|
|
|
2759
2814
|
`id: string` — точечный SELECT по PK.
|
|
2760
2815
|
|
|
2761
2816
|
```ts
|
|
2762
|
-
await db.accounts.get(acc.id) // [
|
|
2817
|
+
await db.accounts.get(acc.id) // [3.2 ms] → Account | null
|
|
2763
2818
|
```
|
|
2764
2819
|
|
|
2765
2820
|
**Кейс:** профиль владельца строки Entity: `db.accounts.get(row.owner)`.
|
|
@@ -2779,7 +2834,7 @@ await db.accounts.get(acc.id) // [1.9 ms] → Account | null
|
|
|
2779
2834
|
|
|
2780
2835
|
```ts
|
|
2781
2836
|
const acc = await db.accounts.set({ categories: ['Client'], data: { название: 'ИП Ромашка' } })
|
|
2782
|
-
// [
|
|
2837
|
+
// [9.7 ms] → { id: '06d3bbfe-…', categories: ['Client'], data: { название: 'ИП Ромашка' },
|
|
2783
2838
|
// meta: {}, avatar: 'https://i.pravatar.cc/128?img=33', enabled: true, created: …, updated: … }
|
|
2784
2839
|
await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) // [5.7 ms] update
|
|
2785
2840
|
```
|
|
@@ -2794,7 +2849,7 @@ await db.accounts.set({ id: acc.id, avatar: 'https://cdn.example/i.png' }) //
|
|
|
2794
2849
|
|
|
2795
2850
|
```ts
|
|
2796
2851
|
await db.accounts.delete(времId) // [16.4 ms] → true (пустой аккаунт)
|
|
2797
|
-
await db.accounts.delete(SYS) // [
|
|
2852
|
+
await db.accounts.delete(SYS) // [5.5 ms]
|
|
2798
2853
|
// Error: update or delete on table "Account" violates foreign key constraint "entity_account_fk"
|
|
2799
2854
|
```
|
|
2800
2855
|
|
|
@@ -2848,7 +2903,7 @@ await db.schema.define({ id: 'Coupon', alias: 'Купон', category: 'HUB',
|
|
|
2848
2903
|
| `f.withDeleted` | `boolean?` | включить мягко-удалённые (default — только живые `deleted IS NULL`) |
|
|
2849
2904
|
|
|
2850
2905
|
```ts
|
|
2851
|
-
await db.credentials.find({ account: acc.id }) // [
|
|
2906
|
+
await db.credentials.find({ account: acc.id }) // [2.9 ms] → 1 живой
|
|
2852
2907
|
await db.credentials.find({ account: acc.id, withDeleted: true }) // → 1 (после delete: 0 и 1)
|
|
2853
2908
|
```
|
|
2854
2909
|
|
|
@@ -2884,7 +2939,7 @@ await db.credentials.set({ account: acc.id, category: 'phone', identifier: '+7 9
|
|
|
2884
2939
|
освобождается для других аккаунтов (§ 11.9).
|
|
2885
2940
|
|
|
2886
2941
|
```ts
|
|
2887
|
-
await db.credentials.delete(кред.id) // [4.
|
|
2942
|
+
await db.credentials.delete(кред.id) // [4.0 ms] → true; find() больше не видит
|
|
2888
2943
|
```
|
|
2889
2944
|
|
|
2890
2945
|
**Кейс:** отзыв api-ключа: `delete(credential.id)` → `verifyApiKey` мгновенно null
|
|
@@ -2905,10 +2960,10 @@ await db.credentials.delete(кред.id) // [4.6 ms] → true; find() боль
|
|
|
2905
2960
|
|
|
2906
2961
|
```ts
|
|
2907
2962
|
await db.resources.set({ alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' } })
|
|
2908
|
-
// [
|
|
2909
|
-
await db.resources.get('apiref.demo:API') // [2.
|
|
2910
|
-
await db.resources.find({ category: 'API' }) // [
|
|
2911
|
-
await db.resources.delete('apiref.demo:API') // [
|
|
2963
|
+
// [5.1 ms] → { alias: 'apiref.demo:API', category: 'API', pattern: { endpoint: 'demo.*' }, meta: null }
|
|
2964
|
+
await db.resources.get('apiref.demo:API') // [2.5 ms] → тот же Resource
|
|
2965
|
+
await db.resources.find({ category: 'API' }) // [2.5 ms] → 14 ресурсов
|
|
2966
|
+
await db.resources.delete('apiref.demo:API') // [4.9 ms] → true
|
|
2912
2967
|
```
|
|
2913
2968
|
|
|
2914
2969
|
**Кейс:** полный словарь для нового тарифа — § 11.10 (6 ресурсов + 5 правил одним блоком).
|
|
@@ -2927,9 +2982,9 @@ await db.resources.delete('apiref.demo:API') // [5.2 ms] → true
|
|
|
2927
2982
|
|
|
2928
2983
|
```ts
|
|
2929
2984
|
await db.rules.set({ account: 'apiref.demo:API', resource: 'apiref.demo:API', permission: 'allow', weight: 90 })
|
|
2930
|
-
// [5.
|
|
2931
|
-
await db.rules.find({ resource: 'apiref.demo:API' }) // [2.
|
|
2932
|
-
await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [
|
|
2985
|
+
// [5.9 ms] → { account: …, resource: …, permission: 'allow', weight: 90, meta: null, enabled: true }
|
|
2986
|
+
await db.rules.find({ resource: 'apiref.demo:API' }) // [2.8 ms] → 1
|
|
2987
|
+
await db.rules.delete('apiref.demo:API', 'apiref.demo:API') // [3.7 ms] → true
|
|
2933
2988
|
```
|
|
2934
2989
|
|
|
2935
2990
|
**Кейс:** временный бан группы: `set({ account: группа, resource: цель, permission: 'deny',
|
|
@@ -2961,7 +3016,7 @@ uuid-строку или объект с `.id`.
|
|
|
2961
3016
|
|
|
2962
3017
|
```ts
|
|
2963
3018
|
await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
2964
|
-
// [
|
|
3019
|
+
// [105 ms] → Credential; в БД вместо пароля:
|
|
2965
3020
|
// meta.password = "scrypt$32768$8$1$+yEMcUTJIO7xEu/oDUAMXA==$b6…"
|
|
2966
3021
|
```
|
|
2967
3022
|
|
|
@@ -2984,11 +3039,11 @@ await db.auth.setPassword({ account: acc, identifier: 'romashka@salon.io', passw
|
|
|
2984
3039
|
**Примеры**
|
|
2985
3040
|
|
|
2986
3041
|
```ts
|
|
2987
|
-
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [
|
|
3042
|
+
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' }) // [109 ms]
|
|
2988
3043
|
// → { account: { id: '06d3bbfe-…', categories: ['Client'], enabled: true, … },
|
|
2989
3044
|
// credential: { category: 'PASSWORD', identifier: 'romashka@salon.io', … } }
|
|
2990
|
-
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [
|
|
2991
|
-
await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [
|
|
3045
|
+
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'зима' }) // [109 ms] → null
|
|
3046
|
+
await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' }) // [109 ms] → null
|
|
2992
3047
|
// незнакомый identifier — то же время (dummy-verify)
|
|
2993
3048
|
```
|
|
2994
3049
|
|
|
@@ -2998,11 +3053,11 @@ await db.auth.verifyPassword({ identifier: 'ghost@nowhere.io', password: 'x' })
|
|
|
2998
3053
|
await db.auth.setPassword({ account: acc, identifier: 'noconfirm@salon.io', password: 'пароль-77',
|
|
2999
3054
|
category: 'EMAIL', confirmed: false })
|
|
3000
3055
|
await db.auth.verifyPassword({ identifier: 'noconfirm@salon.io', password: 'пароль-77', category: 'EMAIL' })
|
|
3001
|
-
// [
|
|
3002
|
-
await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [
|
|
3056
|
+
// [109 ms] → null — кред не подтверждён
|
|
3057
|
+
await db.auth.verifyPassword({ …то же…, requireConfirmed: false }) // [109 ms] → { account, credential }
|
|
3003
3058
|
await db.accounts.set({ id: acc.id, enabled: false })
|
|
3004
3059
|
await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
3005
|
-
// [
|
|
3060
|
+
// [109 ms] → null — аккаунт выключен, пароль уже не важен
|
|
3006
3061
|
```
|
|
3007
3062
|
|
|
3008
3063
|
#### `db.auth.issueApiKey(a): Promise<{ key, credential }>`
|
|
@@ -3017,7 +3072,7 @@ await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'ле
|
|
|
3017
3072
|
Сам ключ возвращается **один раз**; утечка БД ключи не раскрывает.
|
|
3018
3073
|
|
|
3019
3074
|
```ts
|
|
3020
|
-
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [
|
|
3075
|
+
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [9.0 ms]
|
|
3021
3076
|
// key = 'lts_ca41bf2a3614c3f228be2551a2ba998db5285f1a2bc3b859' ← показать и забыть
|
|
3022
3077
|
// в БД: identifier = '624b00355aba026f…' (sha256), meta = { name: 'касса-1', prefix: 'lts_ca41bf2a' }
|
|
3023
3078
|
```
|
|
@@ -3035,16 +3090,16 @@ const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'к
|
|
|
3035
3090
|
Быстрый (без scrypt): ключ высокоэнтропийный, подбор бессмыслен.
|
|
3036
3091
|
|
|
3037
3092
|
```ts
|
|
3038
|
-
await db.auth.verifyApiKey(key) // [
|
|
3093
|
+
await db.auth.verifyApiKey(key) // [6.9 ms] → { account: 06d3bbfe…, credential }
|
|
3039
3094
|
```
|
|
3040
3095
|
|
|
3041
3096
|
**Кейс: полный жизненный цикл ключа**
|
|
3042
3097
|
|
|
3043
3098
|
```ts
|
|
3044
|
-
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [
|
|
3045
|
-
await db.auth.verifyApiKey(key) // [
|
|
3099
|
+
const { key, credential } = await db.auth.issueApiKey({ account: acc, name: 'касса-1' }) // [9.0 ms]
|
|
3100
|
+
await db.auth.verifyApiKey(key) // [6.9 ms] → { account, credential } — касса работает
|
|
3046
3101
|
await db.credentials.delete(credential.id) // отзыв (мягкий)
|
|
3047
|
-
await db.auth.verifyApiKey(key) // [
|
|
3102
|
+
await db.auth.verifyApiKey(key) // [6.9 ms] → null — мгновенно недействителен
|
|
3048
3103
|
```
|
|
3049
3104
|
|
|
3050
3105
|
#### `db.auth.issueKeySecret(a): Promise<{ key, secret, credential }>`
|
|
@@ -3056,7 +3111,7 @@ await db.auth.verifyApiKey(key) // [2.1 ms] → null — мгновен
|
|
|
3056
3111
|
возвращается один раз.
|
|
3057
3112
|
|
|
3058
3113
|
```ts
|
|
3059
|
-
const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [4.
|
|
3114
|
+
const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'интеграция-1С' }) // [4.5 ms]
|
|
3060
3115
|
// key = 'f8ab4007e4570809'; secret = 'c25b8b906f69…' (48 hex, показан один раз)
|
|
3061
3116
|
```
|
|
3062
3117
|
|
|
@@ -3071,8 +3126,8 @@ const { key, secret } = await db.auth.issueKeySecret({ account: acc, name: 'ин
|
|
|
3071
3126
|
**Алгоритм.** Кред по identifier = key, `timingSafeEqual(sha256(secret), meta.secret)`, ворота.
|
|
3072
3127
|
|
|
3073
3128
|
```ts
|
|
3074
|
-
await db.auth.verifyKeySecret(key, secret) // [4.
|
|
3075
|
-
await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [
|
|
3129
|
+
await db.auth.verifyKeySecret(key, secret) // [4.4 ms] → { account, credential }
|
|
3130
|
+
await db.auth.verifyKeySecret(key, 'f'.repeat(48)) // [4.4 ms] → null
|
|
3076
3131
|
```
|
|
3077
3132
|
|
|
3078
3133
|
**Кейс:** серверная интеграция (1С, платёжка): key хранится в конфиге открыто и светится
|
|
@@ -3114,8 +3169,8 @@ const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz
|
|
|
3114
3169
|
|
|
3115
3170
|
```ts
|
|
3116
3171
|
const код = totpCode(secret) // [659 µs] → '564517' (как в приложении)
|
|
3117
|
-
await db.auth.verifyTotp({ account: acc, code: код }) // [
|
|
3118
|
-
await db.auth.verifyTotp({ account: acc, code: код }) // [2
|
|
3172
|
+
await db.auth.verifyTotp({ account: acc, code: код }) // [7.2 ms] → true — фактор активирован
|
|
3173
|
+
await db.auth.verifyTotp({ account: acc, code: код }) // [7.2 ms] → false — replay отбит
|
|
3119
3174
|
const прошлый = totpCode(secret, Date.now() - 30_000) // код прошлого шага (окно ±1)
|
|
3120
3175
|
await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false — шаг ≤ lastStep
|
|
3121
3176
|
```
|
|
@@ -3125,8 +3180,8 @@ await db.auth.verifyTotp({ account: acc, code: прошлый }) // → false
|
|
|
3125
3180
|
```ts
|
|
3126
3181
|
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz' }) // [4.8 ms]
|
|
3127
3182
|
await db.auth.totpEnabled(acc) // → false — QR показан, ждём подтверждения
|
|
3128
|
-
await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [
|
|
3129
|
-
await db.auth.totpEnabled(acc) // [2.
|
|
3183
|
+
await db.auth.verifyTotp({ account: acc, code: изПриложения }) // [7.2 ms] → true
|
|
3184
|
+
await db.auth.totpEnabled(acc) // [2.0 ms] → true — теперь требуем код при входе
|
|
3130
3185
|
```
|
|
3131
3186
|
|
|
3132
3187
|
#### `db.auth.totpEnabled(account): Promise<boolean>`
|
|
@@ -3135,7 +3190,7 @@ await db.auth.totpEnabled(acc) // [2.2 ms] → true — те
|
|
|
3135
3190
|
проверкой (`confirmed`). Приложение по нему решает, спрашивать ли второй фактор.
|
|
3136
3191
|
|
|
3137
3192
|
```ts
|
|
3138
|
-
await db.auth.totpEnabled(acc) // [2.
|
|
3193
|
+
await db.auth.totpEnabled(acc) // [2.0 ms] → true
|
|
3139
3194
|
```
|
|
3140
3195
|
|
|
3141
3196
|
#### `totpCode(secretBase32, atMs = Date.now()): string` — экспорт модуля
|
|
@@ -3172,7 +3227,7 @@ totpCode('G3RLJFOF4J4W7U2EC4GBBNNNYUIEGNWS') // [659 µs] → '564517'
|
|
|
3172
3227
|
|
|
3173
3228
|
```ts
|
|
3174
3229
|
const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@salon.io', ttlSec: 600 })
|
|
3175
|
-
// [
|
|
3230
|
+
// [4.8 ms] code = '255243'; в БД: meta = { code: '7566c91d8a5e…' (sha256), expires: '2026-07-13T07:24:44.435Z', attempts: 0 }
|
|
3176
3231
|
```
|
|
3177
3232
|
|
|
3178
3233
|
#### `db.auth.verifyOtp(a): Promise<AuthResult | null>`
|
|
@@ -3191,17 +3246,17 @@ const { code } = await db.auth.issueOtp({ account: acc, identifier: 'romashka@sa
|
|
|
3191
3246
|
**Примеры**
|
|
3192
3247
|
|
|
3193
3248
|
```ts
|
|
3194
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [
|
|
3195
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11
|
|
3196
|
-
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [
|
|
3249
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code: '000000' }) // [11 ms] → null (+1 попытка)
|
|
3250
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11 ms] → { account, credential }
|
|
3251
|
+
await db.auth.verifyOtp({ identifier: 'romashka@salon.io', code }) // [11 ms] → null — сожжён
|
|
3197
3252
|
```
|
|
3198
3253
|
|
|
3199
3254
|
**Кейс: сброс пароля**
|
|
3200
3255
|
|
|
3201
3256
|
```ts
|
|
3202
|
-
const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [
|
|
3257
|
+
const { code } = await db.auth.issueOtp({ account: acc, identifier: почта, ttlSec: 600 }) // [4.8 ms]
|
|
3203
3258
|
отправитьПисьмо(почта, code) // доставка — на приложении
|
|
3204
|
-
const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11
|
|
3259
|
+
const кто = await db.auth.verifyOtp({ identifier: почта, code: изФормы }) // [11 ms]
|
|
3205
3260
|
if (кто) await db.auth.setPassword({ account: кто.account, identifier: почта, password: новый })
|
|
3206
3261
|
// протухший код (ttl 1 s в прогоне): verifyOtp → null [9.3 ms]
|
|
3207
3262
|
```
|
|
@@ -3224,7 +3279,7 @@ if (кто) await db.auth.setPassword({ account: кто.account, identifier: п
|
|
|
3224
3279
|
|
|
3225
3280
|
```ts
|
|
3226
3281
|
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' } })
|
|
3227
|
-
// [
|
|
3282
|
+
// [12 ms] → { category: 'TELEGRAM', identifier: '777000111', meta: { username: 'romashka' }, confirmed: true }
|
|
3228
3283
|
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915' }) // id занят ДРУГИМ аккаунтом:
|
|
3229
3284
|
// Error: duplicate key value violates unique constraint "credential_identity_udx" [3.9 ms]
|
|
3230
3285
|
```
|
|
@@ -3245,14 +3300,14 @@ await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915
|
|
|
3245
3300
|
|
|
3246
3301
|
```ts
|
|
3247
3302
|
await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' })
|
|
3248
|
-
// [5.
|
|
3303
|
+
// [5.3 ms] → { account: 06d3bbfe…, credential }
|
|
3249
3304
|
```
|
|
3250
3305
|
|
|
3251
3306
|
**Кейс: вход через telegram-бота**
|
|
3252
3307
|
|
|
3253
3308
|
```ts
|
|
3254
3309
|
// платформа подтвердила пользователя 777000111 (initData бота проверило приложение)
|
|
3255
|
-
const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [5.
|
|
3310
|
+
const кто = await db.auth.lookup({ category: 'TELEGRAM', identifier: '777000111' }) // [5.3 ms]
|
|
3256
3311
|
if (!кто) { /* первая встреча: создать аккаунт + db.auth.link(…) */ }
|
|
3257
3312
|
const token = await sess.start(кто.account) // дальше обычная сессия
|
|
3258
3313
|
```
|
|
@@ -3284,7 +3339,7 @@ const sess = db.auth.sessions(new Redis('redis://localhost:16379')) // [148 µ
|
|
|
3284
3339
|
`sess:acc:<accountId>` для `revokeAll`. Дамп Redis действующих токенов не раскрывает.
|
|
3285
3340
|
|
|
3286
3341
|
```ts
|
|
3287
|
-
const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [
|
|
3342
|
+
const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }) // [18 ms]
|
|
3288
3343
|
// token = 'fe0a7e83db6fcd1aa2c5002988da2b353602837db3bc0a813eddac863b9a1944'
|
|
3289
3344
|
// в Redis: 'sess:8198bf2cd8d2ef2376d…' и 'sess:acc:06d3bbfe-d9a2-4…'
|
|
3290
3345
|
```
|
|
@@ -3295,7 +3350,7 @@ const token = await sess.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } }
|
|
|
3295
3350
|
(нет / истекла / отозвана). Суб-миллисекундный — на каждый HTTP-запрос.
|
|
3296
3351
|
|
|
3297
3352
|
```ts
|
|
3298
|
-
await sess.check(token) // [
|
|
3353
|
+
await sess.check(token) // [1.7 ms]
|
|
3299
3354
|
// → { account: '06d3bbfe-…', meta: { device: 'iphone' }, created: '2026-07-13T07:14:45.810Z' }
|
|
3300
3355
|
await sess.check(протухший) // [0.8 ms] → null (ttl 1 s истёк — Redis сам удалил)
|
|
3301
3356
|
```
|
|
@@ -3305,8 +3360,8 @@ await sess.check(протухший) // [0.8 ms] → null (ttl 1 s истёк
|
|
|
3305
3360
|
`token: string` — `DEL` ключа + `SREM` из индекса; `false`, если сессии уже нет.
|
|
3306
3361
|
|
|
3307
3362
|
```ts
|
|
3308
|
-
await sess.revoke(token) // [
|
|
3309
|
-
await sess.revoke(token) // [
|
|
3363
|
+
await sess.revoke(token) // [3.9 ms] → true
|
|
3364
|
+
await sess.revoke(token) // [3.9 ms] → false — повторно
|
|
3310
3365
|
```
|
|
3311
3366
|
|
|
3312
3367
|
#### `sessions.revokeAll(account): Promise<number>`
|
|
@@ -3315,19 +3370,19 @@ await sess.revoke(token) // [0.7 ms] → false — повторно
|
|
|
3315
3370
|
сколько погашено.
|
|
3316
3371
|
|
|
3317
3372
|
```ts
|
|
3318
|
-
await sess.revokeAll(acc) // [
|
|
3373
|
+
await sess.revokeAll(acc) // [2.5 ms] → 2 — обе сессии (ipad + macbook) погасли
|
|
3319
3374
|
```
|
|
3320
3375
|
|
|
3321
3376
|
**Кейс: полный вход — пароль → сессия → запрос → выход (реальный прогон)**
|
|
3322
3377
|
|
|
3323
3378
|
```ts
|
|
3324
3379
|
const визит = await db.auth.verifyPassword({ identifier: 'romashka@salon.io', password: 'лето-2026!' })
|
|
3325
|
-
// [
|
|
3380
|
+
// [109 ms] → { account, credential }
|
|
3326
3381
|
const token = await sess.start(визит.account, { ttlSec: 86400, meta: { ip: '10.0.0.7' } }) // [1.2 ms]
|
|
3327
3382
|
// … каждый запрос в middleware:
|
|
3328
|
-
const кто = await sess.check(token) // [
|
|
3383
|
+
const кто = await sess.check(token) // [1.7 ms] → { account: '06d3bbfe…', meta: { ip: '10.0.0.7' }, … }
|
|
3329
3384
|
// logout:
|
|
3330
|
-
await sess.revoke(token) // [
|
|
3385
|
+
await sess.revoke(token) // [3.9 ms] → true
|
|
3331
3386
|
// «выйти со всех устройств» после смены пароля: await sess.revokeAll(визит.account)
|
|
3332
3387
|
```
|
|
3333
3388
|
|
|
@@ -3356,10 +3411,10 @@ NULL — все); объекты — `API`-ресурсы, чья маска п
|
|
|
3356
3411
|
**Примеры**
|
|
3357
3412
|
|
|
3358
3413
|
```ts
|
|
3359
|
-
await db.acl.check(acc, 'v2.booking.create') // [
|
|
3414
|
+
await db.acl.check(acc, 'v2.booking.create') // [6.1 ms]
|
|
3360
3415
|
// → { allow: true, rule: { account: 'apiref.client:ACCOUNT', resource: 'apiref.api.booking:API',
|
|
3361
3416
|
// permission: 'allow', weight: 60, enabled: true } }
|
|
3362
|
-
await db.acl.check(acc, 'v2.admin.stats') // [
|
|
3417
|
+
await db.acl.check(acc, 'v2.admin.stats') // [2.5 ms] — покрыло только дно-правило сида:
|
|
3363
3418
|
// → { allow: false, rule: { account: 'any:ACCOUNT', resource: 'any:API', permission: 'deny', weight: 0, … },
|
|
3364
3419
|
// code: 403, message: 'Access denied - default for any ACCOUNT to any API' }
|
|
3365
3420
|
```
|
|
@@ -3393,12 +3448,12 @@ app.use(async (req, res, next) => {
|
|
|
3393
3448
|
**Примеры**
|
|
3394
3449
|
|
|
3395
3450
|
```ts
|
|
3396
|
-
await db.acl.checkData(acc, 'booking', 'READ') // [2.
|
|
3451
|
+
await db.acl.checkData(acc, 'booking', 'READ') // [2.3 ms]
|
|
3397
3452
|
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', weight: 60, … },
|
|
3398
3453
|
// filter: { owner: '06d3bbfe-d9a2-4c1d-be44-04c40cb01108' } } ← $account подставлен
|
|
3399
|
-
await db.acl.checkData(acc, 'Service', 'READ') // [1
|
|
3454
|
+
await db.acl.checkData(acc, 'Service', 'READ') // [2.1 ms]
|
|
3400
3455
|
// → { allow: true, rule: { resource: 'apiref.service:READ', … } } ← безусловный (без filter)
|
|
3401
|
-
await db.acl.checkData(acc, 'Org', 'READ') // [
|
|
3456
|
+
await db.acl.checkData(acc, 'Org', 'READ') // [1.9 ms]
|
|
3402
3457
|
// → { allow: false, message: 'no matching rule (deny by default)' }
|
|
3403
3458
|
await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий connect]
|
|
3404
3459
|
// → { allow: true, rule: { resource: 'apiref.booking.own:READ', … }, filter: { owner: '06d3bbfe-…' } }
|
|
@@ -3411,9 +3466,9 @@ await db.acl.checkData(acc, 'VipBooking', 'READ') // [23.4 ms — свежий
|
|
|
3411
3466
|
// enforceAccount: false — показаны ACL-предикаты, а не изоляция арендатора (§ 10.6)
|
|
3412
3467
|
const uc = await (await connect({ dsn, schema, enforceAcl: true, enforceAccount: false })).as(acc.id)
|
|
3413
3468
|
// [58.1 ms] снимок правил под ЭТОГО субъекта (обновить — reloadSchema)
|
|
3414
|
-
await uc.запись().rows() // [
|
|
3415
|
-
await uc.запись().count() // [
|
|
3416
|
-
await uc.Услуга().count() // [
|
|
3469
|
+
await uc.запись().rows() // [26 ms] → 3 Row — предикат owner=$account в WHERE ДО сортировки/лимита
|
|
3470
|
+
await uc.запись().count() // [26 ms] → 3 — честный count по суженному множеству
|
|
3471
|
+
await uc.Услуга().count() // [30 ms] → 605 — безусловный allow, класс целиком
|
|
3417
3472
|
await uc.Организация().rows()
|
|
3418
3473
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
|
|
3419
3474
|
const [z] = await uc.запись().create({ start_datetime: t, end_datetime: e })
|
|
@@ -3438,7 +3493,7 @@ await uc.запись(свойId).delete({ confirm: true }).rows() // [27.8 ms
|
|
|
3438
3493
|
| Вызов | Что пересобирает | На что НЕ влияет |
|
|
3439
3494
|
|---|---|---|
|
|
3440
3495
|
| `db.acl.reload()` | кэш Resource/Rule фасада `db.acl` (`check`/`checkData`) | энфорсер `enforceAcl`-цепочек, реестр классов |
|
|
3441
|
-
| `await db.reloadSchema()` | реестр классов из `Schema
|
|
3496
|
+
| `await db.reloadSchema()` | реестр классов из `Schema` **и** снимок `Resource`/`Rule` подключения (следующий `db.as()` получит свежий словарь); со scope-хендла — **и** его энфорсер `enforceAcl` (§ 11.2) | кэш фасада `db.acl` (сбрасывать отдельно); уже созданные энфорсеры ДРУГИХ scope |
|
|
3442
3497
|
|
|
3443
3498
|
Реконнект нужен только для смены `dsn`/`schema`/`partition` и самих флагов `enforce*`
|
|
3444
3499
|
(сменить арендатора реконнект НЕ требует — это новый `db.as()`).
|
|
@@ -3663,7 +3718,10 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
|
|
|
3663
3718
|
- Микро-бенч `npm run bench` (105k строк) + EXPLAIN-тесты (индексы обязаны быть в плане; ноль
|
|
3664
3719
|
seq scan); партиционирование — `bench/dimensions.bench.mjs`, масштаб —
|
|
3665
3720
|
`node bench/history.bench.mjs --entities=10000 --versions=100`, оверхед ACL —
|
|
3666
|
-
`npx tsx bench/acl.bench.mjs` (таблица в § 9.2)
|
|
3721
|
+
`npx tsx bench/acl.bench.mjs` (таблица в § 9.2), цена изоляции арендатора —
|
|
3722
|
+
`node bench/tenants-seed.mjs && npx tsx bench/isolation.bench.mjs` (таблица в § 10.6;
|
|
3723
|
+
свой полигон `v1.tenants` — 10 арендаторов с перекосом 50 %…0.3 %, единый `v1.salondemo`
|
|
3724
|
+
этот вопрос не измеряет, там System владеет всем классом).
|
|
3667
3725
|
|
|
3668
3726
|
### 14.1 `ANALYZE` обязателен после массовой заливки
|
|
3669
3727
|
|