letopis 0.5.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 +77 -0
- package/README.md +799 -0
- package/dist/chain.d.ts +126 -0
- package/dist/chain.js +278 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +43 -0
- package/dist/ops.d.ts +42 -0
- package/dist/ops.js +43 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.js +133 -0
- package/dist/sql.d.ts +92 -0
- package/dist/sql.js +480 -0
- package/dist/tables.d.ts +81 -0
- package/dist/tables.js +148 -0
- package/dist/tx.d.ts +13 -0
- package/dist/tx.js +31 -0
- package/dist/types.d.ts +166 -0
- package/dist/types.js +5 -0
- package/dist/write.d.ts +60 -0
- package/dist/write.js +302 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
# Letopis — руководство
|
|
2
|
+
|
|
3
|
+
Dot-цепочки над append-only Entity-хранилищем (TimescaleDB). Всё — точками: чтение,
|
|
4
|
+
связи, запись, настройки выборки.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { connect } from 'letopis'
|
|
8
|
+
|
|
9
|
+
const db = await connect({ dsn: 'postgres://…', schema: 'booking' })
|
|
10
|
+
|
|
11
|
+
// чтение: пути по графу
|
|
12
|
+
const пути = await db.Сотрудник({ name: 'Вася' }).навык().Услуга().execute()
|
|
13
|
+
// [ { Сотрудник: Row, навык: Row, Услуга: Row }, … ]
|
|
14
|
+
|
|
15
|
+
// запись: связи — тоже точками
|
|
16
|
+
await db.Организация(org).Сотрудник().set({ name: 'Вася', roles: ['master'] })
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Содержание:
|
|
20
|
+
[1. Быстрый старт](#1-быстрый-старт) ·
|
|
21
|
+
[2. Хранилище](#2-хранилище) ·
|
|
22
|
+
[3. Schema](#3-schema-классы-hub--link) ·
|
|
23
|
+
[4. Чтение](#4-чтение-цепочки) ·
|
|
24
|
+
[5. Фильтры и модификаторы](#5-фильтры-и-модификаторы) ·
|
|
25
|
+
[6. Запись](#6-запись-set--delete) ·
|
|
26
|
+
[7. Транзакции](#7-транзакции) ·
|
|
27
|
+
[8. Батчи](#8-батчи) ·
|
|
28
|
+
[9. Auth-таблицы](#9-служебные-таблицы-authacl) ·
|
|
29
|
+
[10. Срез и история](#10-срез-актуальных-данных-и-история) ·
|
|
30
|
+
[11. API Reference](#11-api-reference) ·
|
|
31
|
+
[12. Ошибки](#12-ошибки) ·
|
|
32
|
+
[13. Зона приложения](#13-что-контролирует-приложение) ·
|
|
33
|
+
[14. Производительность](#14-производительность) ·
|
|
34
|
+
[15. E2E-пример](#15-e2e-пример-барбершоп) ·
|
|
35
|
+
[16. Тесты](#16-тесты)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 1. Быстрый старт
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
docker run -d --name clockz-timescale -e POSTGRES_PASSWORD=test -e POSTGRES_DB=clockz \
|
|
43
|
+
-p 15432:5432 timescale/timescaledb:latest-pg17
|
|
44
|
+
node db/apply.mjs --dsn=postgres://postgres:test@localhost:15432/clockz --schema=booking
|
|
45
|
+
# = ddl.sql + seed.booking.sql + seed.auth.sql в указанную PG-схему (любую; --no-seed — только ddl)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz', schema: 'booking' })
|
|
50
|
+
const [org] = await db.Организация().set({ name: 'BarberPro' })
|
|
51
|
+
const [вася] = await db.Организация(org).Сотрудник().set({ name: 'Вася', roles: ['master'] })
|
|
52
|
+
await db.close()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 2. Хранилище
|
|
58
|
+
|
|
59
|
+
`db/ddl.sql`: `Entity`, `Schema`, `Account`, `Credential`, `Resource`, `Rule` + триггеры.
|
|
60
|
+
**Весь CRUD работает на уровне БД** — голый SQL равносилен либе; либа добавляет строгую
|
|
61
|
+
валидацию данных, цепочки и удобства.
|
|
62
|
+
|
|
63
|
+
### 2.1 Entity — единая append-only hypertable
|
|
64
|
+
|
|
65
|
+
| Колонка | Тип | Смысл |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `partition` | text | пространство данных (`'entity'`) |
|
|
68
|
+
| `id` | text | id сущности (uuid или детерминированная строка) |
|
|
69
|
+
| `class` | text | класс из `Schema` (FK) |
|
|
70
|
+
| `data` | jsonb | атрибуты (строгая валидация по `Schema.attributes`) |
|
|
71
|
+
| `updated` | timestamptz | момент версии (компонента PK) |
|
|
72
|
+
| `account` | uuid **NOT NULL** | арендатор (FK → Account, RESTRICT) |
|
|
73
|
+
| `owner` | uuid **NOT NULL** | владелец (FK → Account, RESTRICT) |
|
|
74
|
+
| `tags` | text[] | метки |
|
|
75
|
+
| `links` | jsonb | связи `{"Класс": "id", …}` — произвольное число на строку |
|
|
76
|
+
| `deleted` | timestamptz | tombstone-метка |
|
|
77
|
+
|
|
78
|
+
- **PK `(partition, class, id, updated)`**: сущность = все её версии; строка = версия.
|
|
79
|
+
- Актуальная версия = max(`updated`); живая, если `deleted IS NULL`.
|
|
80
|
+
- Партиционирование: ТОЛЬКО время `updated` (чанки 30 дней). Hash-размерности убраны: на 1.1M строк точечный доступ равен PK-btree, простые фильтры −10–20%, но цепочки ×2…×140 медленнее и 93 чанка/год против 14 (`bench/dimensions.bench.mjs`).
|
|
81
|
+
- Индексы: latest-btree, GIN `links`/`data` (jsonb_path_ops), GIN `tags`, btree `account`/`owner`.
|
|
82
|
+
|
|
83
|
+
### 2.2 Триггеры Entity — CRUD в БД
|
|
84
|
+
|
|
85
|
+
| SQL | Триггер | Поведение |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id; у LINK есть обязательные концы (`'Entity'`-концы полиморфны — закрывает либа). Tombstone-вставки не проверяются |
|
|
88
|
+
| `UPDATE` | `entity_update` | физического апдейта нет: вставляется **новая версия** (`updated = GREATEST(clock, prev+1µs)`); не-latest строки игнорируются — история неизменна |
|
|
89
|
+
| `DELETE` | `entity_delete` | вставляется **tombstone** + **рекурсивный каскад**: DELETE живых зависимых (`links ⊃ {класс: id}`) повторяет триггер по дереву; advisory-lock; история/tombstone неприкосновенны (повторный DELETE — no-op) |
|
|
90
|
+
|
|
91
|
+
```sql
|
|
92
|
+
-- голый SQL работает как либа:
|
|
93
|
+
UPDATE "booking"."Entity" SET data = data || '{"duration":45}'
|
|
94
|
+
WHERE partition='entity' AND class='Service' AND id='…'; -- → новая версия
|
|
95
|
+
DELETE FROM "booking"."Entity"
|
|
96
|
+
WHERE partition='entity' AND class='Booking' AND id='…'; -- → tombstone + каскад
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Управление объёмом истории — только политики Timescale: `db/policies.mjs` (§ 10.8).
|
|
100
|
+
|
|
101
|
+
### 2.3 FK-целостность
|
|
102
|
+
|
|
103
|
+
| FK | Правило |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `Entity(partition, class) → Schema(partition, id)` | RESTRICT: класс с данными не удалить |
|
|
106
|
+
| `Entity.account / owner → Account(id)` | RESTRICT: аккаунт с историей не удалить — `enabled=false`; CASCADE/SET NULL невозможны на append-only |
|
|
107
|
+
| `Credential.account → Account(id)` | CASCADE |
|
|
108
|
+
| `Rule.account / resource → Resource(alias)` | CASCADE (оба конца ACL-правила) |
|
|
109
|
+
|
|
110
|
+
### 2.4 Чтение «актуального» (замена вью legacy)
|
|
111
|
+
|
|
112
|
+
Каждый шаг: `DISTINCT ON (id) … ORDER BY id, updated DESC` **после** сужения по class +
|
|
113
|
+
GIN-кандидатам, затем перепроверка условий на latest. Фильтр истинен относительно
|
|
114
|
+
*актуальной* версии: старое `name='Вася'` при новом `name='Петя'` не матчится.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 3. Schema: классы (HUB / LINK)
|
|
119
|
+
|
|
120
|
+
| Поле | Смысл |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `id` / `alias` | англ. id (`Staff`) и русский алиас (`Сотрудник`) — равноправны в API |
|
|
123
|
+
| `category` | `HUB` (сущность) \| `LINK` (связь с атрибутами) |
|
|
124
|
+
| `ancestor` | прямой родитель |
|
|
125
|
+
| `ancestors` | `[self, parent, …, root]` — **считает триггер `schema_lineage`** |
|
|
126
|
+
| `descendants` | все потомки транзитивно — **тот же триггер** |
|
|
127
|
+
| `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; строгая валидация |
|
|
128
|
+
| `links` | HUB: встраиваемые цели (forward-обход); LINK: обязательные концы, `'Entity'` = полиморфный |
|
|
129
|
+
| `meta` | `{ abstract?, description? }` |
|
|
130
|
+
|
|
131
|
+
`schema_lineage` (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
|
|
132
|
+
пересчитывает `ancestors`/`descendants` при любом изменении Schema.
|
|
133
|
+
Из либы: `db.registry.resolve('связь').descendants` → `['busy','item','shift','skill']`.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 4. Чтение: цепочки
|
|
138
|
+
|
|
139
|
+
### Правила обхода
|
|
140
|
+
|
|
141
|
+
| Переход | Механика |
|
|
142
|
+
|---|---|
|
|
143
|
+
| HUB → LINK | reverse: строки LINK с `links ⊃ {Hub: id}` |
|
|
144
|
+
| LINK → HUB | forward: `links->>'Hub'` — для **любого** HUB-ключа строки |
|
|
145
|
+
| HUB → HUB (разные) | forward, если цель ∈ `Schema.links` текущего; иначе reverse; иначе «no path» |
|
|
146
|
+
| HUB → HUB (тот же класс) | reverse = **дети** (`db.Папка(id).Папка()`); родитель — `row.links.Folder` |
|
|
147
|
+
| LINK → LINK | ошибка |
|
|
148
|
+
|
|
149
|
+
### Терминалы
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
const пути = await db.Сотрудник({ name:'Вася' }).alias('Мастер').навык().Услуга().execute()
|
|
153
|
+
// [{ Мастер: Row, навык: Row, Услуга: Row }, …]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Вызов | Возврат |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `execute()` | `Path[]` — все узлы каждого варианта пути; ключ = имя шага / `.alias()`; повтор → `имя_2` |
|
|
159
|
+
| `rows()` | `Row[]` — уникальные сущности последнего шага |
|
|
160
|
+
| `first()` | `Row \| null` |
|
|
161
|
+
| `ids()` | `string[]` |
|
|
162
|
+
| `count()` | число **путей** |
|
|
163
|
+
|
|
164
|
+
Терминалы без аргументов — выборка настраивается модификаторами (§ 5).
|
|
165
|
+
`Row = { id, class, data, links, tags, account, owner, updated, $deleted? }`.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 5. Фильтры и модификаторы
|
|
170
|
+
|
|
171
|
+
### Фильтр — аргумент шага
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
db.Услуга('uuid') // по id
|
|
175
|
+
db.Услуга(rowИлиAccount) // объект с id — возьмётся .id
|
|
176
|
+
db.Услуга(['id1','id2']) // по списку ([] → пусто)
|
|
177
|
+
db.Услуга({ name: 'Стрижка', active: true }) // eq полей data → GIN-containment
|
|
178
|
+
db.Услуга({ id: 'uuid', duration: gte(30) }) // ключ id — тоже id-фильтр
|
|
179
|
+
db.Услуга({ price: { RUB: lte(2000) } }) // record: вложенный путь + каст
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Операторы (18: + `not`, `or`)
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends,
|
|
186
|
+
has, hasAny, hasAll, exists, isNull, not, or } from 'letopis'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
| Оператор | Типы поля | SQL |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| скаляр (eq) | любые | `data @> '{"f": v}'` (GIN) |
|
|
192
|
+
| `ne(v)` | любые | `IS DISTINCT FROM` |
|
|
193
|
+
| `gt/gte/lt/lte(v)`, `between(a,b)` | number, date, string | `(data->>'f')::cast ⋛ $` |
|
|
194
|
+
| `inList([…])` | любые | `IN (…)`; `[]` → FALSE |
|
|
195
|
+
| `like/ilike/starts/ends(s)` | string | `[I]LIKE` |
|
|
196
|
+
| `has(v)/hasAll([…])/hasAny([…])` | массивы data (`roles`) | `@>` / `?\|` |
|
|
197
|
+
| `exists(true/false)` | любые | ключ есть/нет |
|
|
198
|
+
| `isNull()` | любые | null или отсутствует |
|
|
199
|
+
| `not(op \| скаляр)` | по внутреннему | `NOT (…)`; `not(скаляр)` = `ne` |
|
|
200
|
+
| `or(f1, f2, …)` | **фильтр целиком** | `db.Запись(or({status:'created'}, {status:'confirmed'}))` — дизъюнкция под-фильтров |
|
|
201
|
+
|
|
202
|
+
Касты по `Schema.attributes`: number→`::numeric`, date→`::timestamptz`, boolean→`::boolean`.
|
|
203
|
+
Поле вне схемы фильтруется как text (записать его нельзя — строгая валидация).
|
|
204
|
+
Даты хранятся ISO UTC (`'…+03:00'` → `'…Z'`), сравнения корректны.
|
|
205
|
+
`{ roles: ['a','b'] }` = containment «содержит оба», не «равно массиву».
|
|
206
|
+
|
|
207
|
+
### Модификаторы цепочки
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
db.Окно().sort('data.start').limit(10).offset(20).rows() // выборка
|
|
211
|
+
db.Запись().sort('updated', 'desc').limit(50).rows()
|
|
212
|
+
|
|
213
|
+
db.Клиент().tags('vip') // фильтр: tags ⊇ ['vip']
|
|
214
|
+
db.Клиент().tags(['vip','telegram']) // все перечисленные
|
|
215
|
+
db.Клиент().tags(hasAny(['vip','b2b'])) // хотя бы один
|
|
216
|
+
db.Запись().account(accId) // фильтр по колонке account (uuid | Row)
|
|
217
|
+
db.Организация().owner(acc) // фильтр по owner
|
|
218
|
+
db.Сотрудник({…}).alias('Мастер') // ключ шага в путях
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
| Модификатор | Область | В чтении | В `set()` |
|
|
222
|
+
|---|---|---|---|
|
|
223
|
+
| `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей UPDATE |
|
|
224
|
+
| `asOf(t)` | вся цепочка | «как было на T» (§ 10.1) | — |
|
|
225
|
+
| `after(cursor)` | вся цепочка | keyset-пагинация, требует `sort` (§ 10.2) | — |
|
|
226
|
+
| `deep(max?)` | текущий шаг (self-hop) | рекурсивные дети, `$depth` (§ 10.4) | — |
|
|
227
|
+
| `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при INSERT (string \| string[]) |
|
|
228
|
+
| `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при INSERT |
|
|
229
|
+
| `alias(name)` | текущий шаг | ключ в путях | — |
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## 6. Запись: `.set()` / `.delete()`
|
|
234
|
+
|
|
235
|
+
### Связи — только точками. Три равнозначные формы
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
// 1) контекст ДО set(): шаги-связи, затем класс-цель
|
|
239
|
+
await db.Сотрудник(s).Окно(w).Запись(b).занятость().set({ id: busyId, kind: 'booking' })
|
|
240
|
+
|
|
241
|
+
// 2) связи ПОСЛЕ set(): продолжение цепочки
|
|
242
|
+
await db.Сотрудник(s).занятость().set({ id: busyId, kind: 'booking' }).Окно(w).Запись(b)
|
|
243
|
+
|
|
244
|
+
// 3) z-форма: копим связи на переменной, исполняем await-ом
|
|
245
|
+
const z = db.занятость().set({ id: busyId, kind: 'booking' })
|
|
246
|
+
z.Сотрудник(s)
|
|
247
|
+
z.Окно(w)
|
|
248
|
+
z.Запись(b)
|
|
249
|
+
await z
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`set()` возвращает **ленивый билдер** (thenable): класс-вызовы довешивают связи,
|
|
253
|
+
первый `await` исполняет ОДИН INSERT со всеми связями; промис кешируется
|
|
254
|
+
(повторный `await` не создаёт версий; довесить связь после исполнения — ошибка).
|
|
255
|
+
|
|
256
|
+
### Анатомия
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).set( DATA ).Связь(id)… → await → Row[]
|
|
260
|
+
└───── контекст ─────┘ └─────┘ └──┬─┘ └── ещё связи ──┘
|
|
261
|
+
каждый шаг = связь кого трогаем что писать (id — здесь же)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- **Контекст-шаги** (всё, кроме последнего класса): каждый резолвится в **ровно одну**
|
|
265
|
+
сущность — id-фильтром (без запроса, значение как есть) или уникальным фильтром
|
|
266
|
+
(0 или >1 → ошибка). Дают: `links` при INSERT и containment-фильтр целей при UPDATE/DELETE.
|
|
267
|
+
- **ФИЛЬТР последнего шага** — режим:
|
|
268
|
+
|
|
269
|
+
| Фильтр последнего шага | data.id | Действие |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `Класс()` — без аргумента | нет | **INSERT** (id = uuid), links = контекст |
|
|
272
|
+
| `Класс()` — без аргумента | есть | **UPSERT** по `data.id` |
|
|
273
|
+
| `Класс('id')` — строка | — | **UPSERT** по этому id |
|
|
274
|
+
| `Класс({...поля})` — объект | — | **UPDATE**: новая версия каждого найденного (в границах контекста); пусто → `[]` |
|
|
275
|
+
| `Класс({})` — **пустой объект** | — | **UPDATE всех** в границах контекста: `tr.Запись(b).позиция({}).set({}).Сотрудник(новый)` — сменить исполнителя на всех позициях записи |
|
|
276
|
+
|
|
277
|
+
Довешенные после `set()` связи — только **значения** (в links записи); фильтром целей
|
|
278
|
+
служат контекст-шаги ДО `set()`.
|
|
279
|
+
|
|
280
|
+
- **DATA** — поля сущности/связи; `id` передаётся здесь (`{ id: busyId, … }`).
|
|
281
|
+
Валидация **строгая, всегда**: поле вне `Schema.attributes` → `ValidationError`;
|
|
282
|
+
default-ы схемы подставляются; id в `data` не хранится (он — колонка).
|
|
283
|
+
- **Deep-merge при UPDATE**: меняются только указанные листья —
|
|
284
|
+
`set({ price: { RUB: 1100 } })` сохранит `USD/EUR` и остальные поля.
|
|
285
|
+
Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
|
|
286
|
+
- **Концы LINK**: объявленные в `Schema.links` обязательны; значения — id или Row.
|
|
287
|
+
- **account/owner NOT NULL**: `.account()/.owner()` → `connect()` → System-аккаунт.
|
|
288
|
+
- Версии монотонны (`GREATEST(clock, prev+1µs)`), коллизия 23505 ретраится.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
// INSERT со связями из контекста
|
|
292
|
+
const [вася] = await db.Организация(org).Сотрудник().set({ name: 'Вася', roles: ['master'] })
|
|
293
|
+
|
|
294
|
+
// UPDATE по фильтру в границах контекста: записи Пети со статусом created → confirmed
|
|
295
|
+
await db.Клиент(петя).Запись({ status: 'created' }).set({ status: 'confirmed' })
|
|
296
|
+
|
|
297
|
+
// INSERT со значениями колонок из модификаторов
|
|
298
|
+
await db.Организация(org).Клиент().tags(['vip']).account(accId).set({ name: 'Пётр' })
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### `.delete()` — серверное удаление
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
const удалено = await db.Запись(bid).delete()
|
|
305
|
+
// ВСЁ удалённое (цель + каскад), каждый Row с $deleted: true:
|
|
306
|
+
// [{class:'Booking', $deleted:true}, {class:'busy'…}, {class:'item'…}]
|
|
307
|
+
|
|
308
|
+
await db.Запись(b).занятость().delete() // контекст: только занятости этой записи
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Цели = фильтр последнего шага + контекст-связи. Замыкание (цели + все живые зависимые)
|
|
312
|
+
собирается одним CTE, затем один SQL `DELETE` — триггер БД тумбстоунит дерево
|
|
313
|
+
(+advisory-lock). История неприкосновенна; повторный delete → `[]`; `set()` после —
|
|
314
|
+
воскрешение.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## 7. Транзакции
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
const tr = await db.begin() // тот же API на выделенном соединении
|
|
322
|
+
await tr.lock('busy', staffId, slotId) // advisory-xact-lock до конца транзакции
|
|
323
|
+
const занято = await tr.занятость(busyId).first()
|
|
324
|
+
if (!занято) await tr.Сотрудник(s).Окно(w).занятость(busyId).set({ kind: 'booking' })
|
|
325
|
+
await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Повторный commit/rollback — no-op. `lock()` вне транзакции — ошибка. Держите транзакции
|
|
329
|
+
короткими. **Рецепт двойной брони**: `lock(мастер, окно)` + детерминированный id занятости +
|
|
330
|
+
перечитать `first()` под локом → параллельная транзакция видит бронь и отказывает
|
|
331
|
+
(ровно одна успешна — покрыто тестом-гонкой).
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## 8. Батчи
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
db.batch('окна').Расписание(sch).Окно().set({ start, end }) // копится (без await)
|
|
339
|
+
db.batch('окна').Расписание(sch).Окно().set({ … })
|
|
340
|
+
db.batch('окна').size() // 2
|
|
341
|
+
const res = await db.batch('окна').execute() // одна транзакция; Row[][] по порядку
|
|
342
|
+
db.batch('окна').discard() // отменить
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
- `execute()` атомарен: любая ошибка откатывает всё.
|
|
346
|
+
- `set()` в батче тоже возвращает билдер — связи довешиваются до `execute()`
|
|
347
|
+
(`const z = db.batch('b').занятость().set({…}); z.Окно(w)`); `await` билдера → № в очереди.
|
|
348
|
+
- Подряд идущие чистые INSERT одного класса (один шаг, пустой фильтр, без data.id и связей)
|
|
349
|
+
склеиваются в один multi-VALUES.
|
|
350
|
+
- Read-вызовы на батч-фасаде исполняются сразу, мимо очереди.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## 9. Служебные таблицы (auth/ACL)
|
|
355
|
+
|
|
356
|
+
Обычные таблицы (UPDATE/DELETE стандартные), на `db` и `tr`. Имена
|
|
357
|
+
`accounts / credentials / resources / rules` зарезервированы.
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
const acc = await db.accounts.set({ categories: ['User'], data: { name: 'Вася' } })
|
|
361
|
+
await db.accounts.set({ id: acc.id, enabled: false })
|
|
362
|
+
await db.accounts.find({ category: 'User', enabled: true })
|
|
363
|
+
await db.accounts.delete(acc.id) // физический; RESTRICT при истории Entity
|
|
364
|
+
|
|
365
|
+
await db.credentials.set({ account: acc.id, category: 'APIKEY', identifier: 'k1' })
|
|
366
|
+
// upsert по UNIQUE (account, category, identifier); повторный set воскрешает
|
|
367
|
+
await db.credentials.find({ account: acc.id }) // deleted IS NULL по умолчанию
|
|
368
|
+
await db.credentials.delete(credId) // мягкое: deleted = now()
|
|
369
|
+
|
|
370
|
+
await db.resources.set({ alias: 'shop:API', category: 'API', pattern: { endpoint: 'booking.*' } })
|
|
371
|
+
await db.rules.set({ account: 'authenticated:ACCOUNT', resource: 'shop:API', permission: 'allow', weight: 90 })
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Связка с Entity: `account`/`owner` NOT NULL, default — System-аккаунт; фильтры и значения —
|
|
375
|
+
модификаторы `.account()/.owner()`; профиль владельца — `db.accounts.get(row.owner)`.
|
|
376
|
+
Сид `db/seed.auth.sql`: System + 16 Resource + 12 Rule.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## 10. Срез актуальных данных и история
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
await db.Услуга().rows() // срез класса (актуальное, живое)
|
|
384
|
+
await db.Запись().sort('updated','desc').limit(50).rows()
|
|
385
|
+
await db.Клиент(cid).Запись().позиция().execute() // связный подграф
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
```sql
|
|
389
|
+
-- вся партиция одним SQL
|
|
390
|
+
SELECT * FROM (SELECT DISTINCT ON (class, id) * FROM "booking"."Entity"
|
|
391
|
+
WHERE partition='entity' ORDER BY class, id, updated DESC) t WHERE t.deleted IS NULL;
|
|
392
|
+
|
|
393
|
+
-- история сущности (все версии, включая tombstone)
|
|
394
|
+
SELECT updated, deleted, data, links FROM "booking"."Entity"
|
|
395
|
+
WHERE partition='entity' AND class='Booking' AND id=$1 ORDER BY updated;
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### 10.1 Время-путешествия: `.asOf()` / `.versions()`
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
// «какая цена была на момент брони» — версии позже T невидимы, tombstone до T = «удалён»
|
|
402
|
+
const тогда = await db.Услуга(id).asOf('2026-07-01T12:00:00Z').first()
|
|
403
|
+
const срезДня = await db.Запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
|
|
404
|
+
|
|
405
|
+
// вся история сущности без сырого SQL (tombstone-версии приходят с $deleted: true)
|
|
406
|
+
const история = await db.Запись(id).versions()
|
|
407
|
+
// [{data:{status:'created'}}, {data:{status:'confirmed'}}, {…, $deleted:true}]
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
`asOf` применяется к каждому шагу цепочки — подграф целиком «как был». `versions()`
|
|
411
|
+
работает и после удаления сущности (у последнего шага tombstone не отфильтровывается).
|
|
412
|
+
|
|
413
|
+
### 10.2 Пагинация курсором: `.after()` + `cursorOf()`
|
|
414
|
+
|
|
415
|
+
Offset на больших списках заставляет БД пролистывать пропущенное; keyset — нет:
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
const стр1 = await db.Запись().sort('updated', 'desc').limit(50).rows()
|
|
419
|
+
const стр2 = await db.Запись().sort('updated', 'desc')
|
|
420
|
+
.after(cursorOf(стр1.at(-1)!)) // строго после последней строки
|
|
421
|
+
.limit(50).rows()
|
|
422
|
+
|
|
423
|
+
// по data-полю — курсор с тем же field, что в sort
|
|
424
|
+
const дальше = await db.Окно().sort('data.start')
|
|
425
|
+
.after(cursorOf(окно, 'data.start')).limit(20).rows()
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
`.after()` требует `.sort()` (иначе ошибка); сравнение `(поле, id)` — стабильно при дублях.
|
|
429
|
+
|
|
430
|
+
### 10.3 Агрегации: считает БД
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
await db.Запись({ status: 'completed' }).sum('data.total.RUB') // выручка: number | null
|
|
434
|
+
await db.Услуга().avg('data.duration') // среднее
|
|
435
|
+
await db.Запись().countBy('data.status') // { confirmed: 12, cancelled: 3 } ({} на пустом)
|
|
436
|
+
await db.Окно().min('data.start') // min/max — каст по типу поля из Schema
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Один проход в БД вместо перекачки строк в JS. Путь — `'data.<поле>'` или record-лист
|
|
440
|
+
`'data.total.RUB'`. `sum`/`avg` кастуются в numeric; пустое множество → `null`.
|
|
441
|
+
|
|
442
|
+
### 10.4 Деревья: `.deep()`
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
const всё = await db.Папка(root).Папка().deep().rows() // ВСЕ вложенные папки (default ≤ 32)
|
|
446
|
+
// Row.$depth: 1 = прямой ребёнок, 2 = внук…
|
|
447
|
+
const дваУровня = await db.Папка(root).Папка().deep(2).rows()
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Один recursive-CTE-запрос вместо запроса на уровень. Только self-hop (тот же класс,
|
|
451
|
+
reverse = дети); на другом переходе — ошибка. Работает с `asOf`.
|
|
452
|
+
|
|
453
|
+
### 10.5 Realtime: `db.watch()`
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
const stop = await db.watch('Запись', (e) => {
|
|
457
|
+
// e = { partition, class, id, updated, deleted } — факт версии (insert/update/tombstone)
|
|
458
|
+
обновитьКалендарь(e.id)
|
|
459
|
+
})
|
|
460
|
+
const stopAll = await db.watch((e) => log(e)) // все классы
|
|
461
|
+
await stop() // отписка
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Триггер `entity_notify` шлёт лёгкий `pg_notify` (канал = имя PG-схемы) на каждую
|
|
465
|
+
вставленную версию; данные подписчик дочитывает обычной цепочкой. Основа живых
|
|
466
|
+
интерфейсов без поллинга.
|
|
467
|
+
|
|
468
|
+
### 10.6 Изоляция арендатора: `enforceAccount`
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
|
|
472
|
+
await db.Запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
|
|
473
|
+
await db.Клиент().set({ name: 'X' }) // запись пришпилена к account
|
|
474
|
+
db.Запись().account(чужой).rows() // ошибка: reads are pinned to account …
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Изоляцию гарантирует либа, а не дисциплина: забытый `.account()` в одном запросе
|
|
478
|
+
больше не утечка данных соседнего салона.
|
|
479
|
+
|
|
480
|
+
### 10.7 Анонимизация (GDPR): `.anonymize()`
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
await db.Клиент(id).anonymize(['name', 'contact'])
|
|
484
|
+
// новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Физического стирания НЕТ — история священна: старые версии хранят PII до retention-политики
|
|
488
|
+
(§ 10.8). Полное «право на забвение» = `anonymize()` сейчас + настроенный retention потом.
|
|
489
|
+
Не-string поле в списке — ошибка (типы сверяются по Schema).
|
|
490
|
+
|
|
491
|
+
### 10.8 Политики хранения: `db/policies.mjs`
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
node db/policies.mjs --dsn=… --schema=booking --compress-after=30d # сжатие (история цела)
|
|
495
|
+
node db/policies.mjs --dsn=… --schema=booking --retain=2y # + retention (drop навсегда!)
|
|
496
|
+
node db/policies.mjs --dsn=… --schema=booking # текущее состояние
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
- **Сжатие** — чанки старше N: `segmentby (partition, class, id)` кладёт версии сущности рядом
|
|
500
|
+
(~10–20× экономии); чтение прозрачно (`rows`/`versions`/`asOf` — покрыто тестом); новые
|
|
501
|
+
версии идут в свежие несжатые чанки — идеально ложится на append-only.
|
|
502
|
+
- **Retention** — осознанный рубильник: чанки старше N УДАЛЯЮТСЯ насовсем (здесь наступает
|
|
503
|
+
«забвение» из § 10.7). По умолчанию ВЫКЛ.
|
|
504
|
+
- Интервалы: `30d`, `2y`, `12h`, `6mon` или сырой PG (`'90 days'`). Повторный запуск
|
|
505
|
+
переустанавливает политику.
|
|
506
|
+
|
|
507
|
+
### 10.9 Миграции классов: `scripts/schema-sync.mjs`
|
|
508
|
+
|
|
509
|
+
```bash
|
|
510
|
+
node scripts/schema-sync.mjs --file=../schema.booking.v2.json --dsn=… --schema=booking
|
|
511
|
+
# schema-sync: … ↔ booking.Schema (partition entity)
|
|
512
|
+
# + Coupon (HUB · Купон) — новый класс
|
|
513
|
+
# ~ Service — изменены: attributes
|
|
514
|
+
# ! Service: 3/50 живых строк НЕ пройдут новую валидацию:
|
|
515
|
+
# id=… → brand — The 'brand' field is required.
|
|
516
|
+
# ИТОГ: ломающие изменения (3 строк) … exit 1
|
|
517
|
+
node scripts/schema-sync.mjs --file=… --dsn=… --schema=booking --apply # применить
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Diff файла и таблицы Schema (новые/изменённые/отсутствующие классы) + отчёт совместимости:
|
|
521
|
+
живые latest-строки изменённых классов прогоняются НОВОЙ строгой валидацией до применения.
|
|
522
|
+
Без `--apply` — только отчёт (exit 1 при breaking — удобно в CI). Отсутствующие в файле
|
|
523
|
+
классы не удаляются. `ancestors`/`descendants` пересчитает триггер `schema_lineage`.
|
|
524
|
+
|
|
525
|
+
### 10.10 Наблюдаемость: `onQuery` / `slowMs`
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
const db = await connect({
|
|
529
|
+
dsn, schema,
|
|
530
|
+
onQuery: (e) => metrics.observe(e), // {mode:'rows', classes:['Staff','skill'], ms:4.2, rows:7, slow:false}
|
|
531
|
+
slowMs: 200, // ms > 200 → slow: true
|
|
532
|
+
})
|
|
533
|
+
// slowMs БЕЗ onQuery: console.warn('letopis: slow query 312ms — rows Staff→skill')
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Событие на каждый SQL цепочек: чтения (`paths/rows/ids/count/versions/agg`) и записи
|
|
537
|
+
(`insert/delete`). Служебные запросы (реестр, auth-таблицы, listen) не шумят.
|
|
538
|
+
|
|
539
|
+
### 10.11 TS-типы из Schema: `scripts/gen-types.mjs`
|
|
540
|
+
|
|
541
|
+
```bash
|
|
542
|
+
npx tsx scripts/gen-types.mjs --dsn=… --schema=booking --out=entity-types.d.ts
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
```ts
|
|
546
|
+
import type { TypedDb } from './entity-types'
|
|
547
|
+
const t = db as unknown as TypedDb
|
|
548
|
+
const [svc] = await t.Услуга({ active: true }).rows() // svc.data.duration: number
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Интерфейсы data-полей всех классов (enum → union-литералы, record → `Partial<Record<…>>`)
|
|
552
|
+
+ фасад `TypedDb`. Ядро остаётся динамическим — типы это надстройка.
|
|
553
|
+
|
|
554
|
+
---
|
|
555
|
+
|
|
556
|
+
## 11. API Reference
|
|
557
|
+
|
|
558
|
+
### `connect(opts): Promise<EntityDb>`
|
|
559
|
+
|
|
560
|
+
| Параметр | Тип | Default | Описание |
|
|
561
|
+
|---|---|---|---|
|
|
562
|
+
| `dsn` | string | — | `postgres://user:pass@host:port/db` |
|
|
563
|
+
| `schema` | string | — | PG-схема с таблицами |
|
|
564
|
+
| `partition` | string | `'entity'` | партиция данных |
|
|
565
|
+
| `account` | uuid | System-аккаунт | default-арендатор записей |
|
|
566
|
+
| `owner` | uuid | `account` | default-владелец |
|
|
567
|
+
| `max` | number | `10` | пул соединений |
|
|
568
|
+
| `enforceAccount` | boolean | `false` | жёсткая изоляция арендатора: чтения фильтруются по `account`, записи пришпилены (подмена → ошибка `pinned`) |
|
|
569
|
+
| `onQuery` | `(e: QueryEvent) => void` | — | хук на каждый запрос цепочки (§ 10.10) |
|
|
570
|
+
| `slowMs` | number | — | порог: `ms > slowMs` → `e.slow = true`; без `onQuery` — `console.warn` |
|
|
571
|
+
|
|
572
|
+
### Экспорты модуля
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
import {
|
|
576
|
+
connect, cursorOf, // функции
|
|
577
|
+
ne, gt, gte, lt, lte, between, inList, like, ilike, // операторы (18)
|
|
578
|
+
starts, ends, has, hasAny, hasAll, exists, isNull, not, or,
|
|
579
|
+
ValidationError, Registry, // классы
|
|
580
|
+
} from 'letopis'
|
|
581
|
+
import type {
|
|
582
|
+
Row, Path, Filter, Cursor, ChainMods, ConnectOpts, QueryEvent,
|
|
583
|
+
EntityDb, EntityTx, Chain, Batch, Tables,
|
|
584
|
+
Account, Credential, Resource, Rule,
|
|
585
|
+
} from 'letopis'
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
### `cursorOf(row, field = 'updated'): Cursor`
|
|
589
|
+
|
|
590
|
+
Курсор keyset-пагинации из последней строки страницы; `field` — тот же, что в `.sort()`
|
|
591
|
+
(`'updated'` или `'data.<поле>'`). Возврат `{ v, id }` — аргумент `.after()`.
|
|
592
|
+
|
|
593
|
+
### `EntityDb` / `EntityTx`
|
|
594
|
+
|
|
595
|
+
| Член | Сигнатура | Описание |
|
|
596
|
+
|---|---|---|
|
|
597
|
+
| `db.<Класс>` | `(filter?: Filter) => Chain` | старт цепочки (id или алиас) |
|
|
598
|
+
| `begin` | `() => Promise<EntityTx>` | транзакция (`EntityTx` = тот же API + commit/rollback/lock) |
|
|
599
|
+
| `commit` / `rollback` | `(tr) => Promise<void>` | или `tr.commit()`/`tr.rollback()`; повторно — no-op |
|
|
600
|
+
| `lock` | `(...keys: (string\|number)[]) => Promise<void>` | advisory-xact-lock; только на `tr` |
|
|
601
|
+
| `batch` | `(name: string) => Batch` | именованный батч |
|
|
602
|
+
| `watch` | `(classOrCb, cb?) => Promise<() => void>` | realtime: `WatchEvent {partition, class, id, updated, deleted}` на каждую версию (триггер `entity_notify` → LISTEN); возврат — stop |
|
|
603
|
+
| `accounts/credentials/resources/rules` | § 9 | фасады таблиц |
|
|
604
|
+
| `close` | `() => Promise<void>` | закрыть пул |
|
|
605
|
+
| `registry` | `Registry` | `.resolve(name)` → ClassDef (ancestors, descendants, attributes…) |
|
|
606
|
+
| `sql` | postgres.js | голый клиент |
|
|
607
|
+
|
|
608
|
+
### `Chain`
|
|
609
|
+
|
|
610
|
+
| Член | Сигнатура | Описание |
|
|
611
|
+
|---|---|---|
|
|
612
|
+
| `.<Класс>` | `(filter?: Filter) => Chain` | следующий шаг (§ 4) / контекст-связь (§ 6) |
|
|
613
|
+
| `.limit` / `.offset` | `(n: number) => Chain` | LIMIT / OFFSET |
|
|
614
|
+
| `.sort` | `(field: 'updated' \| 'data.<поле>', dir?: 'asc'\|'desc'\|boolean) => Chain` | сортировка по полю последнего шага (каст по Schema) |
|
|
615
|
+
| `.alias` | `(name: string) => Chain` | ключ текущего шага в путях |
|
|
616
|
+
| `.tags` | `(v: string \| string[] \| has/hasAny/hasAll) => Chain` | фильтр tags; значение при INSERT |
|
|
617
|
+
| `.account` / `.owner` | `(v: uuid \| Row) => Chain` | фильтр колонки; значение при INSERT |
|
|
618
|
+
| `.execute` | `() => Promise<Path[]>` | пути |
|
|
619
|
+
| `.rows` / `.first` / `.ids` / `.count` | `() => Promise<…>` | `Row[]` / `Row\|null` / `string[]` / число путей |
|
|
620
|
+
| `.set` | `(data?: object) => SetChain` | § 6; ленивый билдер; в батче await → № очереди |
|
|
621
|
+
| `.delete` | `() => Promise<Row[]>` | всё удалённое (цели+каскад) с `$deleted: true` |
|
|
622
|
+
| `.asOf` | `(t: string \| Date) => Chain` | чтение «как было на T» — версии позже T невидимы (все терминалы) |
|
|
623
|
+
| `.after` | `(c: Cursor) => Chain` | keyset-пагинация после курсора; требует `.sort()`; курсор — `cursorOf(row, field?)` |
|
|
624
|
+
| `.versions` | `() => Promise<Row[]>` | ВСЯ история сущностей последнего шага (tombstone → `$deleted`), по возрастанию |
|
|
625
|
+
| `.anonymize` | `(fields: string[]) => Promise<Row[]>` | GDPR: `'[erased]'` в string-полях + тег `anonymized`; история остаётся |
|
|
626
|
+
| `.deep` | `(max = 32) => Chain` | рекурсивные дети self-hop; `$depth` в Row |
|
|
627
|
+
| `.sum/.avg` | `('data.<путь>') => Promise<number \| null>` | агрегация в БД (record-путь `data.total.RUB` поддержан) |
|
|
628
|
+
| `.min/.max` | `(field) => Promise<unknown>` | каст по типу поля из Schema |
|
|
629
|
+
| `.countBy` | `(field) => Promise<Record<string, number>>` | GROUP BY значению поля |
|
|
630
|
+
|
|
631
|
+
### `SetChain` — результат `.set()`
|
|
632
|
+
|
|
633
|
+
| Член | Сигнатура | Описание |
|
|
634
|
+
|---|---|---|
|
|
635
|
+
| `.<Класс>` | `(target: id \| Row) => SetChain` | довесить связь (до первого await; мутирует билдер) |
|
|
636
|
+
| `await …` | `PromiseLike<Row[]>` | исполнить ОДИН INSERT/UPDATE; промис кешируется |
|
|
637
|
+
|
|
638
|
+
**Формы Filter** (аргумент шага):
|
|
639
|
+
|
|
640
|
+
| Форма | Пример | Смысл |
|
|
641
|
+
|---|---|---|
|
|
642
|
+
| — | `db.Услуга()` | чтение: весь класс; **set: INSERT**; контекст: ошибка |
|
|
643
|
+
| `{}` пустой объект | `db.позиция({})` | чтение: весь класс; **set: UPDATE всех** (в границах контекста) |
|
|
644
|
+
| `string` | `db.Услуга('uuid')` | по id; set: upsert; контекст: значение связи (без запроса) |
|
|
645
|
+
| `Row`/объект с `.id` | `db.Организация(org)` | то же, что id |
|
|
646
|
+
| `string[]` | `db.Услуга(['a','b'])` | по списку id |
|
|
647
|
+
| объект | `{ name: 'X', duration: gte(30), price: { RUB: lte(2000) }, id: 'u1' }` | поля data (eq/операторы/record-пути) + ключ `id` |
|
|
648
|
+
|
|
649
|
+
**`data`** (аргумент set): поля по `Schema.attributes` (строго) + `id?` — явный id строки.
|
|
650
|
+
|
|
651
|
+
### `Batch`
|
|
652
|
+
|
|
653
|
+
| Член | Описание |
|
|
654
|
+
|---|---|
|
|
655
|
+
| `.<Класс>(filter?)…` | те же цепочки; `set` → билдер в очередь; `delete` → № очереди |
|
|
656
|
+
| `.execute()` | `Promise<Row[][]>` — одна транзакция, по порядку |
|
|
657
|
+
| `.discard()` / `.size()` | очистить / размер |
|
|
658
|
+
|
|
659
|
+
### Таблицы auth/ACL
|
|
660
|
+
|
|
661
|
+
| Метод | Сигнатура | Заметки |
|
|
662
|
+
|---|---|---|
|
|
663
|
+
| `accounts.find` | `({ id?, enabled?, category? }?) → Account[]` | category — вхождение в categories[] |
|
|
664
|
+
| `accounts.get` | `(id) → Account \| null` | |
|
|
665
|
+
| `accounts.set` | `({ id?, categories?, data?, meta?, avatar?, enabled? }) → Account` | без id insert, с id update |
|
|
666
|
+
| `accounts.delete` | `(id) → boolean` | физический; RESTRICT при истории |
|
|
667
|
+
| `credentials.find` | `({ id?, account?, category?, identifier?, confirmed?, withDeleted? }?) → Credential[]` | живые по умолчанию |
|
|
668
|
+
| `credentials.set` | `({ account, category, identifier, meta?, confirmed? }) → Credential` | upsert; воскрешает |
|
|
669
|
+
| `credentials.delete` | `(id) → boolean` | мягкое |
|
|
670
|
+
| `resources.find/get/set/delete` | по `alias` | set — upsert; delete каскадит Rule |
|
|
671
|
+
| `rules.find` | `({ account?, resource?, permission?, enabled? }?) → Rule[]` | weight DESC |
|
|
672
|
+
| `rules.set` | `({ account, resource, permission, weight?, meta?, enabled? }) → Rule` | upsert по PK |
|
|
673
|
+
| `rules.delete` | `(account, resource) → boolean` | |
|
|
674
|
+
|
|
675
|
+
### Типы
|
|
676
|
+
|
|
677
|
+
```ts
|
|
678
|
+
Row = { id, class, data, links, tags, account, owner, updated,
|
|
679
|
+
$deleted?: true, // у .delete()-результатов и tombstone в .versions()
|
|
680
|
+
$depth?: number } // у .deep()-строк: 1 = прямой ребёнок
|
|
681
|
+
Path = Record<string, Row> // вариант пути: ключ шага → узел
|
|
682
|
+
Cursor = { v: string | number, id: string } // .after() / cursorOf()
|
|
683
|
+
QueryEvent = { mode: 'paths'|'rows'|'ids'|'count'|'versions'|'agg'|'insert'|'delete',
|
|
684
|
+
classes: string[], ms: number, rows: number, slow: boolean }
|
|
685
|
+
ChainMods = { limit?, offset?, order?, desc?, asOf?, after?, aggFn?, aggField? }
|
|
686
|
+
Account = { id, categories: string[], data, meta, avatar, enabled, created, updated }
|
|
687
|
+
Credential = { id, account, category, identifier, meta, confirmed, created, updated, deleted }
|
|
688
|
+
Resource = { alias, category, pattern, meta }
|
|
689
|
+
Rule = { account, resource, permission, weight, meta, enabled }
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
## 12. Ошибки
|
|
695
|
+
|
|
696
|
+
| Ошибка | Когда |
|
|
697
|
+
|---|---|
|
|
698
|
+
| `unknown class "X". Known: …` | класс вне Schema (со списком) |
|
|
699
|
+
| `ValidationError` (`.issues`) | строгая валидация: мусор или лишние поля |
|
|
700
|
+
| `class "X" is abstract` | запись в abstract |
|
|
701
|
+
| `link "X" requires end "Y"` / `polymorphic end(s)` | не хватает концов LINK |
|
|
702
|
+
| `context step "X" must resolve to exactly one entity` | контекст-шаг дал 0 или >1 |
|
|
703
|
+
| `context step "X" needs an id or a unique filter` | контекст-шаг без фильтра |
|
|
704
|
+
| `set() already executed — link before the first await` | довес связи после исполнения билдера |
|
|
705
|
+
| `no path X → Y` / `LINK → LINK …` | недопустимый переход чтения |
|
|
706
|
+
| `Entity.account is NOT NULL…` | нет account и System-аккаунта |
|
|
707
|
+
| `violates foreign key constraint "entity_*_fk"` | несуществующий класс/аккаунт; удаление класса с данными |
|
|
708
|
+
| `lock() works only inside db.begin()` | лок вне транзакции |
|
|
709
|
+
| `commit() needs a transaction` | commit на корневом db |
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
## 13. Что контролирует приложение
|
|
714
|
+
|
|
715
|
+
- Непересечение окон в расписании (EXCLUDE на hypertable невозможен).
|
|
716
|
+
- Бизнес-проверки брони: навык, смена, число подряд окон под `duration` (§ 7).
|
|
717
|
+
- `total` записи = Σ позиций.
|
|
718
|
+
- Генерация окон из шаблонов/повторов.
|
|
719
|
+
|
|
720
|
+
## 14. Производительность
|
|
721
|
+
|
|
722
|
+
Замеры `npm run bench` (105 200 строк: 5 000 сущностей × 20 версий + связи; docker, локально):
|
|
723
|
+
|
|
724
|
+
| Операция | p50 | p95 |
|
|
725
|
+
|---|---|---|
|
|
726
|
+
| `rows()` класс + containment-фильтр | 5 ms | 52 ms |
|
|
727
|
+
| `rows()` весь класс, sort+limit 100 | 63 ms | 74 ms |
|
|
728
|
+
| цепочка 3 хопа (пути) | 15 ms | 23 ms |
|
|
729
|
+
| `count()` путей | 15 ms | 20 ms |
|
|
730
|
+
| `set()` новой версии | 8 ms | 12 ms |
|
|
731
|
+
|
|
732
|
+
TOAST-порог (data > 2KB): 0 строк. Слабое место — выборка «весь класс с сортировкой»
|
|
733
|
+
(DISTINCT ON всех сущностей класса); лечится селективным фильтром или курсором.
|
|
734
|
+
Масштаб побольше: `node bench/history.bench.mjs --entities=10000 --versions=100` (1M строк).
|
|
735
|
+
|
|
736
|
+
- Containment и обход графа — GIN; операторы — на уже суженном наборе.
|
|
737
|
+
- Начинайте цепочку с самого селективного шага.
|
|
738
|
+
- `count()` — пути; количество сущностей дешевле `ids().length`.
|
|
739
|
+
- Каскадное удаление — серверное: один DELETE на всё дерево.
|
|
740
|
+
- Билдер `set()` пишет одним INSERT независимо от числа довешенных связей.
|
|
741
|
+
- EXPLAIN-паттерн — тест `EXPLAIN` (индексы обязаны быть в плане; ноль seq scan).
|
|
742
|
+
|
|
743
|
+
---
|
|
744
|
+
|
|
745
|
+
## 15. E2E-пример: барбершоп
|
|
746
|
+
|
|
747
|
+
Полный исполняемый сценарий — `test/integration.test.ts`. Скелет:
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
// штат и каталог — связи контекст-шагами
|
|
751
|
+
const [org] = await db.Организация().set({ name: 'BarberPro' })
|
|
752
|
+
const [ivan] = await db.Организация(org).Сотрудник().set({ name: 'Иван', roles: ['owner','master'] })
|
|
753
|
+
const [oleg] = await db.Организация(org).Сотрудник().set({ name: 'Олег', roles: ['master'] })
|
|
754
|
+
const [стрижка] = await db.Организация(org).Услуга().set({ name: 'Стрижка', duration: 60, price: { RUB: 1500 } })
|
|
755
|
+
const [комплекс] = await db.Организация(org).Комплекс().set({ name: 'Стрижка+борода', duration: 90, price: { RUB: 2500 } })
|
|
756
|
+
await db.Комплекс(комплекс).Услуга(стрижка).позиция().set({ qty: 1 }) // состав
|
|
757
|
+
await db.Сотрудник(ivan).Комплекс(комплекс).навык().set({}) // умение
|
|
758
|
+
|
|
759
|
+
// календарь: период → окна батчем → ростер → исключение
|
|
760
|
+
const [sch] = await db.Организация(org).Расписание().set({ start: '2026-07-10T10:00:00+03:00', end: '…13:00' })
|
|
761
|
+
db.batch('о').Расписание(sch).Окно().set({ start: '…10:00', end: '…11:00' }) // ×3
|
|
762
|
+
const [[w1],[w2],[w3]] = await db.batch('о').execute()
|
|
763
|
+
await db.Расписание(sch).Сотрудник(ivan).смена().set({})
|
|
764
|
+
await db.Сотрудник(oleg).Окно(w3).занятость().set({ id: `off-${oleg.id}-${w3.id}`, kind: 'off' })
|
|
765
|
+
|
|
766
|
+
// бронь мульти-слот (90м > 60м → два окна) — z-форма набора связей
|
|
767
|
+
const [bkg] = await db.Клиент(пётр).Запись().set({ status: 'created', total: { RUB: 2500 } })
|
|
768
|
+
await db.Запись(bkg).Комплекс(комплекс).Сотрудник(ivan).позиция().set({ qty: 1, price: { RUB: 2500 } })
|
|
769
|
+
const бронь1 = db.занятость().set({ id: `busy-${ivan.id}-${w2.id}`, kind: 'booking' })
|
|
770
|
+
бронь1.Сотрудник(ivan); бронь1.Окно(w2); бронь1.Запись(bkg); await бронь1
|
|
771
|
+
await db.Сотрудник(ivan).Окно(w3).Запись(bkg).занятость(`busy-${ivan.id}-${w3.id}`).set({ kind: 'booking' })
|
|
772
|
+
|
|
773
|
+
// жизненный цикл, чтения, отмена
|
|
774
|
+
await db.Запись(bkg.id).set({ status: 'confirmed' })
|
|
775
|
+
await db.Запись(bkg.id).занятость().Окно().sort('data.start').rows() // окна брони
|
|
776
|
+
await db.Окно(w2.id).занятость({ kind: 'booking' }).Сотрудник().rows() // кто занят
|
|
777
|
+
const удалено = await db.Запись(bkg.id).delete() // всё с $deleted (Booking+busy×2+item)
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
---
|
|
781
|
+
|
|
782
|
+
## 16. Тесты
|
|
783
|
+
|
|
784
|
+
```bash
|
|
785
|
+
npm test # 86 тестов против живого docker-timescale, по файлам:
|
|
786
|
+
# api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
|
|
787
|
+
# core — триггеры (check/версии/каскад/lineage), обходы, операторы,
|
|
788
|
+
# модификаторы, set-формы, delete, гонка, батчи, EXPLAIN
|
|
789
|
+
# integration — E2E-барбершоп (8 сцен)
|
|
790
|
+
# real-life — 16 сцен «дня салона»: 4 руки, гонки ×3, переносы, no-show
|
|
791
|
+
# tables — auth/ACL-таблицы
|
|
792
|
+
# wave2 — asOf/versions, keyset-курсор, gen-types, enforceAccount, anonymize
|
|
793
|
+
# wave3 — or/not, агрегации, deep, watch
|
|
794
|
+
# wave4 — compression-политики (чтение сжатого чанка), schema-sync, onQuery
|
|
795
|
+
npm run bench # производительность на 105k строк (§ 14)
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
`ENTITY_DSN` переопределяет DSN (default `postgres://postgres:test@localhost:15432/clockz`).
|
|
799
|
+
Тесты полностью автономны: `wave4` сам создаёт себе PG-схему через `db/apply.mjs`.
|