letopis 0.13.0 → 0.16.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 +98 -0
- package/README.md +362 -199
- package/dist/chain.d.ts +44 -16
- package/dist/chain.js +197 -26
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/schema.js +96 -4
- package/dist/sql.d.ts +23 -3
- package/dist/sql.js +71 -24
- package/dist/types.d.ts +40 -4
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +32 -0
- package/dist/write.d.ts +12 -5
- package/dist/write.js +213 -95
- package/package.json +1 -1
- package/sql/ddl.sql +53 -11
- package/sql/seed.booking.sql +21 -16
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ const пути = await db.Сотрудник({ name: 'Вася' }).навык().
|
|
|
13
13
|
// [ { Сотрудник: Row, навык: Row, Услуга: Row }, … ]
|
|
14
14
|
|
|
15
15
|
// запись: операции — звенья, исполняет терминал
|
|
16
|
-
await db.Организация(org).Сотрудник().
|
|
16
|
+
await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Содержание:
|
|
@@ -52,8 +52,8 @@ const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v
|
|
|
52
52
|
// [letopis.up] schema "v1.booking" applied (3 files)
|
|
53
53
|
// [letopis.up] connected (schema "v1.booking")
|
|
54
54
|
|
|
55
|
-
const [org] = await db.Организация().
|
|
56
|
-
const [вася] = await db.Организация(org).Сотрудник().
|
|
55
|
+
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
56
|
+
const [вася] = await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
|
|
57
57
|
await db.close()
|
|
58
58
|
```
|
|
59
59
|
|
|
@@ -108,7 +108,7 @@ const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz
|
|
|
108
108
|
|
|
109
109
|
| SQL | Триггер | Поведение |
|
|
110
110
|
|---|---|---|
|
|
111
|
-
| `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id;
|
|
111
|
+
| `INSERT` | `entity_check` | класс существует (плюс FK) и не abstract; ключи `links` — существующие классы, значения — строки-id; концы LINK по Schema.links v2 (жадный матчинг, союзы, optional, лишние связи — ошибка; legacy-строки — по-старому). Tombstone-вставки не проверяются |
|
|
112
112
|
| `UPDATE` | `entity_update` | физического апдейта нет: вставляется **новая версия** (`updated = GREATEST(clock, prev+1µs)`); не-latest строки игнорируются — история неизменна |
|
|
113
113
|
| `DELETE` | `entity_delete` | вставляется **tombstone** + **рекурсивный каскад**: DELETE живых зависимых (`links ⊃ {класс: id}`) повторяет триггер по дереву; advisory-lock; история/tombstone неприкосновенны (повторный DELETE — no-op) |
|
|
114
114
|
|
|
@@ -148,13 +148,79 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
148
148
|
| `ancestor` | прямой родитель |
|
|
149
149
|
| `ancestors` | `[self, parent, …, root]` — **считает триггер `schema_lineage`** |
|
|
150
150
|
| `descendants` | все потомки транзитивно — **тот же триггер** |
|
|
151
|
-
| `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; вложенные `{type:'object', props:{…}}` любой глубины; строгая
|
|
152
|
-
| `links` |
|
|
151
|
+
| `attributes` | [fastest-validator](https://github.com/icebob/fastest-validator) DSL; вложенные `{type:'object', props:{…}}` любой глубины; строгая валидация; собираются по иерархии (§ 3.3); правило `id` — генерация (§ 3.2) |
|
|
152
|
+
| `links` | концы связей класса, формат v2 — массив объектов (см. ниже); legacy-строка `'Org'` = `{class:'Org'}`, `'Entity'` = старый полиморф |
|
|
153
153
|
| `meta` | `{ abstract?, description? }` |
|
|
154
154
|
|
|
155
|
+
### 3.1 Концы связей — Schema.links v2
|
|
156
|
+
|
|
157
|
+
Каждый конец — объект (`Schema.links jsonb` — массив объектов):
|
|
158
|
+
|
|
159
|
+
```jsonc
|
|
160
|
+
"links": [
|
|
161
|
+
{ "class": "Booking", "cardinality": 1 }, // один класс
|
|
162
|
+
{ "classes": ["Service", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
|
|
163
|
+
{ "class": "Staff", "optional": true, "cardinality": 1 } // конец может отсутствовать
|
|
164
|
+
]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `Entity` в концах не используется — классы называются явно («максимально точная идентификация связи»)
|
|
168
|
+
- **Матчинг жадный, по порядку объявления**: каждый ключ `Entity.links` строки занимает первый подходящий конец. Союз ролей: предмет позиции — `{Услуга|Комплекс}` (заказ услуги ИЛИ заказ комплекса — ровно один из)
|
|
169
|
+
- Обязательный конец без ключа → `requires end "Service|Complex"`; связь вне объявленных концов → `stray link(s)` — **ошибки и в либе, и в БД-триггере** (голый SQL ловится так же)
|
|
170
|
+
- `cardinality` — зарезервировано (0 — безлимит, N — точное число), пока не проверяется: связь класса в строке одна (`{Класс: id}`), множественность выражается строками-связками
|
|
171
|
+
- Демо-домен: `позиция = [Запись, {Услуга|Комплекс}, Сотрудник?]`, `состав = [Комплекс, Услуга]` (состав комплекса — отдельный LINK), `навык = [Сотрудник, {Услуга|Комплекс}]`, `занятость = [Сотрудник, Окно, Запись?]`, `смена = [Расписание, Сотрудник]`, `Запись = [Клиент?]`
|
|
172
|
+
- Источник правды — `schema.booking.v2.json`; сид генерится: `node lib/scripts/gen-seed.mjs`
|
|
173
|
+
|
|
155
174
|
`schema_lineage` (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
|
|
156
175
|
пересчитывает `ancestors`/`descendants` при любом изменении Schema.
|
|
157
|
-
Из либы: `db.registry.resolve('связь').descendants` → `['busy','item','shift','skill']`.
|
|
176
|
+
Из либы: `db.registry.resolve('связь').descendants` → `['busy','compo','item','shift','skill']`.
|
|
177
|
+
|
|
178
|
+
### 3.2 id считает схема: `attributes.id`
|
|
179
|
+
|
|
180
|
+
Правило рождения id объявляется в `attributes.id` класса (наследуется — § 3.3):
|
|
181
|
+
|
|
182
|
+
| Правило | Версия | Смысл |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `"uuid"` / `{"type":"uuid"}` | **v4** | random — дефолт (как раньше) |
|
|
185
|
+
| `{"type":"uuid","generate":7}` | **v7** | unix-время в старших битах — вставки ложатся в хвост btree-индекса |
|
|
186
|
+
| `{"type":"uuid","generate":5,"from":[…]}` | **v5** | детерминированный: id вычисляется из данных |
|
|
187
|
+
|
|
188
|
+
Формула v5 открыта: `uuidv5("pgSchema:partition:класс:значения from")`, namespace letopis —
|
|
189
|
+
`c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90`. `from` — имена ОБЯЗАТЕЛЬНЫХ концов `Schema.links`
|
|
190
|
+
(класс или полное имя союза `'Service|Complex'`; значение — id конца) и/или скалярных полей
|
|
191
|
+
`data`. Экспорт: `import { uuidv5, uuidv7, LETOPIS_NS } from 'letopis'`.
|
|
192
|
+
|
|
193
|
+
Два свойства v5, ради которых всё:
|
|
194
|
+
|
|
195
|
+
- **create идемпотентен**: та же комбинация → та же сущность (повтор — новая версия, не
|
|
196
|
+
дубль) — даже в гонке двух процессов оба вычислят один id; двойная бронь мертва на
|
|
197
|
+
уровне схемы;
|
|
198
|
+
- **id известен ДО создания**: «занято ли окно» — точечный `first()` по вычисленному id,
|
|
199
|
+
без создания чего-либо.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// занятость: id считается сам — uuidv5(окно, мастер)
|
|
203
|
+
const [б] = await db.Сотрудник(м).занятость().create({ kind: 'booking' }).Окно.set(о).rows()
|
|
204
|
+
б.id === uuidv5(`v1.booking:entity:busy:${о.id}:${м.id}`) // → true
|
|
205
|
+
// «занято?» — ДО создания чего-либо:
|
|
206
|
+
await db.занятость(uuidv5(`v1.booking:entity:busy:${о.id}:${м.id}`)).first() // Row | null
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Явный id у v5-класса запрещён (`computes id` — его всегда считает схема); в батче
|
|
210
|
+
v5-классы не склеиваются в multi-VALUES (id нужны концы) — исполняются поштучно (§ 8).
|
|
211
|
+
|
|
212
|
+
Демо-схема booking: корни `Entity`/`link` объявляют дефолт `{generate: 7}` один раз;
|
|
213
|
+
v7-классы (Org/Staff/Folder/Customer/Schedule/Booking/item) наследуют его без собственного
|
|
214
|
+
правила; v5 переопределяют: `busy ← v5(Slot, Staff)`, `skill ← v5(Staff, Service|Complex)`,
|
|
215
|
+
`shift ← v5(Schedule, Staff)`, `compo ← v5(Complex, Service)`, `Slot ← v5(Schedule,
|
|
216
|
+
data.start)`, `Service`/`Complex` ← `v5(Org, data.name)` — имя уникально в организации.
|
|
217
|
+
|
|
218
|
+
### 3.3 Наследование attributes
|
|
219
|
+
|
|
220
|
+
При загрузке реестра attributes класса собираются по цепочке `ancestor`: **потомок ПОВЕРХ
|
|
221
|
+
предка**, переопределение поля — замена правила ЦЕЛИКОМ (не слияние). Правило `id` (§ 3.2)
|
|
222
|
+
наследуется так же — дефолт объявляется один раз на корне иерархии; `links` НЕ наследуются:
|
|
223
|
+
концы объявляет каждый класс сам. Валидатор и типы полей компилируются из слитых attributes.
|
|
158
224
|
|
|
159
225
|
---
|
|
160
226
|
|
|
@@ -169,6 +235,26 @@ GIN-кандидатам, затем перепроверка условий н
|
|
|
169
235
|
| HUB → HUB (разные) | forward, если цель ∈ `Schema.links` текущего; иначе reverse; иначе «no path» |
|
|
170
236
|
| HUB → HUB (тот же класс) | reverse = **дети** (`db.Папка(id).Папка()`); родитель — `row.links.Folder` |
|
|
171
237
|
| LINK → LINK | ошибка |
|
|
238
|
+
| повтор LINK-класса | **pivot**: возврат к тому же узлу (ветвление к другому концу + дофильтр AND) |
|
|
239
|
+
|
|
240
|
+
Путь подчиняется переходам **всегда** — и в чтении, и в записи; недопустимый переход —
|
|
241
|
+
ошибка **синхронно при построении цепочки** (не в терминале). К концу связки, до которого
|
|
242
|
+
прямого HUB→HUB пути нет, идут через саму связку и **pivot** (повтор её имени):
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// «позиции записи з, где услуга у и мастер вася» — путь через позицию, pivot-возврат:
|
|
246
|
+
db.Запись(з).позиция().Услуга(у).позиция().Сотрудник(вася).позиция().rows()
|
|
247
|
+
// «умеет ли Ирина стрижку» — навык, дофильтр предметом, count путей:
|
|
248
|
+
db.Сотрудник(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Узел-переменная: `entity()`
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
const p = db.позиция() // ленивый узел-паттерн
|
|
255
|
+
await db.Запись(з).entity(p).Услуга(у).entity(p).Сотрудник().run() // та же p = тот же узел (явный pivot)
|
|
256
|
+
await db.entity(row).Услуга().first() // старт пути с готового Row
|
|
257
|
+
```
|
|
172
258
|
|
|
173
259
|
### Терминалы
|
|
174
260
|
|
|
@@ -244,88 +330,112 @@ db.Организация().owner(acc) // фильтр по owne
|
|
|
244
330
|
db.Сотрудник({…}).alias('Мастер') // ключ шага в путях
|
|
245
331
|
```
|
|
246
332
|
|
|
247
|
-
| Модификатор | Область | В чтении | В
|
|
333
|
+
| Модификатор | Область | В чтении | В записи |
|
|
248
334
|
|---|---|---|---|
|
|
249
|
-
| `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей
|
|
335
|
+
| `limit(n)` / `offset(n)` / `sort(field, dir?)` | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей `update()` |
|
|
250
336
|
| `asOf(t)` | вся цепочка | «как было на T» (§ 10.1) | — |
|
|
251
337
|
| `after(cursor)` | вся цепочка | keyset-пагинация, требует `sort` (§ 10.2) | — |
|
|
252
338
|
| `deep(max?)` | текущий шаг (self-hop) | рекурсивные дети, `$depth` (§ 10.4) | — |
|
|
253
|
-
| `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при
|
|
254
|
-
| `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при
|
|
255
|
-
| `
|
|
339
|
+
| `tags(v)` | текущий шаг | фильтр по колонке | **значение** тегов при `create()` (string \| string[]) |
|
|
340
|
+
| `account(v)` / `owner(v)` | текущий шаг | фильтр по колонке | **значение** при `create()` |
|
|
341
|
+
| `.Класс.set(x)` / `.Класс.unset()` | слот связи — после глагола записи | — | записать / снять конец связи БЕЗ участия в фильтре целей (§ 6.1) |
|
|
256
342
|
| `alias(name)` | текущий шаг | ключ в путях | — |
|
|
257
343
|
|
|
258
344
|
---
|
|
259
345
|
|
|
260
346
|
## 6. Запись: операции — звенья плана
|
|
261
347
|
|
|
262
|
-
`.
|
|
263
|
-
применяется к шагу, к которому приклеена точкой, и возвращает цепочку
|
|
264
|
-
Сама по себе ничего не пишет — **исполняет терминал**
|
|
265
|
-
весь план **одной транзакцией**: отказ любого сегмента
|
|
348
|
+
`.create(data?)` / `.update(data?)` / `.delete(opts?)` / `.anonymize(fields)` — **звенья
|
|
349
|
+
цепочки**: операция применяется к шагу, к которому приклеена точкой, и возвращает цепочку
|
|
350
|
+
для продолжения. Сама по себе ничего не пишет — **исполняет терминал**
|
|
351
|
+
(`rows()/first()/count()/run()/…`), весь план **одной транзакцией**: отказ любого сегмента
|
|
352
|
+
(валидация, ACL) откатывает всё.
|
|
353
|
+
|
|
354
|
+
Глаголы не перепутать: `create` **вставляет** (или версионирует по известному id),
|
|
355
|
+
`update` **правит найденное путём** и никогда не создаёт. Старый `set()` разбит на них
|
|
356
|
+
в 0.16.0 (бросает подсказку) — мина «`Класс()` → INSERT, `Класс({})` → UPDATE всех» мертва,
|
|
357
|
+
намерение всегда явное.
|
|
266
358
|
|
|
267
359
|
```ts
|
|
268
360
|
// одиночная запись: операция + терминал
|
|
269
|
-
const [вася] = await db.Организация(org).Сотрудник().
|
|
361
|
+
const [вася] = await db.Организация(org).Сотрудник().create({ name: 'Вася', roles: ['master'] }).rows()
|
|
270
362
|
|
|
271
363
|
// несколько операций в одной цепочке: продолжение — ОТ РЕЗУЛЬТАТА предыдущей
|
|
272
|
-
await db.Клиент({ vip: true }).
|
|
273
|
-
.Запись().
|
|
364
|
+
await db.Клиент({ vip: true }).update({ bonus: 500 }) // новая версия всех vip
|
|
365
|
+
.Запись().create({ status: 'gift' }) // INSERT записи КАЖДОМУ (fan-out)
|
|
274
366
|
.rows() // → подарочные записи
|
|
275
367
|
|
|
276
368
|
// операция сразу после операции — к тем же сущностям (две версии подряд)
|
|
277
|
-
await db.Запись(id).
|
|
369
|
+
await db.Запись(id).update({ status: 'confirmed' }).update({ paid: true }).rows()
|
|
278
370
|
```
|
|
279
371
|
|
|
280
372
|
### Анатомия
|
|
281
373
|
|
|
282
374
|
```
|
|
283
|
-
db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР )
|
|
284
|
-
└───── контекст ─────┘ └─────┘
|
|
285
|
-
каждый шаг = связь
|
|
375
|
+
db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хвост()… .rows()
|
|
376
|
+
└───── контекст ─────┘ └─────┘ └────┘ └─ продолжение ─┘ └─ терминал: исполняет план
|
|
377
|
+
каждый шаг = связь кого трогаем create|update|delete от записанных
|
|
378
|
+
(только update)
|
|
286
379
|
```
|
|
287
380
|
|
|
288
381
|
- **Контекст-шаги** (шаги до операции): каждый резолвится в **ровно одну** сущность —
|
|
289
382
|
id-фильтром (без запроса) или уникальным фильтром (0 или >1 → ошибка). Дают: `links`
|
|
290
|
-
при
|
|
291
|
-
-
|
|
383
|
+
при `create` и containment-фильтр целей при `update`/`delete`.
|
|
384
|
+
- **Режим выбирает глагол** (фильтр-объект допустим только перед `update`):
|
|
292
385
|
|
|
293
|
-
|
|
|
386
|
+
| Глагол | Шаг операции | Действие |
|
|
294
387
|
|---|---|---|
|
|
295
|
-
| `Класс()` — без
|
|
296
|
-
| `Класс()`
|
|
297
|
-
| `Класс(
|
|
298
|
-
|
|
|
299
|
-
| `Класс({})`
|
|
388
|
+
| `create(data?)` | `Класс()` — без фильтра | **INSERT**: id по правилу схемы (§ 3.2), links = контекст + слоты |
|
|
389
|
+
| | `Класс(id)` / `data.id` / вычисленный v5-id **уже существует** | **новая версия** (идемпотентный create, REST-PUT семантика); не существует → INSERT с этим id |
|
|
390
|
+
| | `Класс({фильтр})` | ошибка ПОСТРОЕНИЯ `create() takes no filter`; create на pivot-шаге — тоже ошибка |
|
|
391
|
+
| `update(data?)` | `Класс()` ≡ `Класс({})` | новая версия **всех** в границах контекста |
|
|
392
|
+
| | `Класс(id)` / `Класс({поля})` / pivot | новая версия **каждого** найденного путём; не найдено → `[]` — update НИКОГДА не создаёт |
|
|
300
393
|
|
|
301
|
-
- **Продолжение после операции** — от её результата: класс-шаг = переход (
|
|
394
|
+
- **Продолжение после операции** — от её результата: класс-шаг = переход (create-цель
|
|
302
395
|
следующего сегмента получает связь на записанное; чтение — обычный hop). При
|
|
303
396
|
множественном результате следующий сегмент исполняется **для каждой строки** (fan-out).
|
|
304
397
|
После `delete` продолжение идёт от строк класса цели.
|
|
305
|
-
- **DATA** — поля сущности/связи; `id`
|
|
398
|
+
- **DATA** — поля сущности/связи; явный `id` — здесь (`{ id, … }`) или шагом `Класс(id)`;
|
|
399
|
+
у v5-класса явный id запрещён — его считает схема (§ 3.2).
|
|
306
400
|
Валидация **строгая, всегда**: поле вне `Schema.attributes` → `ValidationError`;
|
|
307
401
|
default-ы схемы подставляются; id в `data` не хранится (он — колонка).
|
|
308
|
-
- **Deep-merge при
|
|
309
|
-
`
|
|
310
|
-
Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
|
|
311
|
-
- **Концы LINK**:
|
|
402
|
+
- **Deep-merge при `update`** (и у create-версии по известному id): меняются только
|
|
403
|
+
указанные листья — `update({ price: { RUB: 1100 } })` сохранит `USD/EUR` и остальные
|
|
404
|
+
поля. Массивы/скаляры заменяются целиком. `links` домерживаются по ключам.
|
|
405
|
+
- **Концы LINK**: по `Schema.links` v2 (§3.1) — обязательные требуются, союз занимает один ключ, лишние связи — ошибка; значения — id, Row или вложенная цепочка (§6.1).
|
|
312
406
|
- **account/owner NOT NULL**: `.account()/.owner()` → `connect()` → System-аккаунт.
|
|
313
407
|
- Версии монотонны (`GREATEST(clock, prev+1µs)`), коллизия 23505 ретраится.
|
|
314
408
|
- Цепочка переиспользуема: повторный терминал = повторное исполнение плана.
|
|
315
409
|
|
|
316
|
-
### Связи:
|
|
410
|
+
### 6.1 Связи: путь и слоты `.Класс.set()` / `.Класс.unset()`
|
|
411
|
+
|
|
412
|
+
Один закон: **владелец создаваемой связки берётся из валидного пути; прочие концы —
|
|
413
|
+
слотами** `.Класс.set(значение)` после глагола записи. Слот — свойство-класс БЕЗ вызова
|
|
414
|
+
(`.Сотрудник.set(x)`, не `.Сотрудник(x)`); валиден только для конца из `Schema.links` и
|
|
415
|
+
требует операцию записи ПЕРЕД собой: `…create(…).Класс.set(x)` / `…update(…).Класс.unset()`;
|
|
416
|
+
слот без глагола — ошибка `link slot needs a write`.
|
|
317
417
|
|
|
318
418
|
```ts
|
|
319
|
-
//
|
|
320
|
-
await db
|
|
419
|
+
// СОЗДАНИЕ: владелец Запись — из пути, предмет и исполнитель — слотами
|
|
420
|
+
await db.Запись(b).позиция().create({ qty: 1, price: { RUB: 1500 } })
|
|
421
|
+
.Услуга.set(u).Сотрудник.set(s).rows()
|
|
321
422
|
|
|
322
|
-
//
|
|
323
|
-
await db.занятость(
|
|
324
|
-
.set({ kind: 'booking' }).rows()
|
|
423
|
+
// занятость: владелец Сотрудник из пути, окно и запись — слотами; id считает схема (§ 3.2)
|
|
424
|
+
await db.Сотрудник(s).занятость().create({ kind: 'booking' }).Окно.set(w).Запись.set(b).rows()
|
|
325
425
|
|
|
326
|
-
//
|
|
327
|
-
await tr.Запись(b).позиция(
|
|
328
|
-
|
|
426
|
+
// ПЕРЕВЕС связи (update): цель ищется путём/pivot, слот пишет новое значение БЕЗ фильтра
|
|
427
|
+
await tr.Запись(b).позиция().Сотрудник(старый).позиция().update().Сотрудник.set(новый).rows()
|
|
428
|
+
|
|
429
|
+
// СОЮЗ-конец [Услуга|Комплекс]: слот замещает конец целиком (соседний класс снимается)
|
|
430
|
+
await db.позиция(p).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
|
|
431
|
+
|
|
432
|
+
// СНЯТИЕ optional-конца
|
|
433
|
+
await db.занятость(bz).update({ kind: 'hold' }).Запись.unset().rows()
|
|
434
|
+
|
|
435
|
+
// вложенная цепочка как значение слота — та же транзакция, ровно одна сущность:
|
|
436
|
+
await db.Запись(b).позиция().create({ qty: 1 })
|
|
437
|
+
.Услуга.set(u)
|
|
438
|
+
.Сотрудник.set(db.Сотрудник().create({ name: 'Новичок' }).Org.set(org)).rows() // создать И привязать
|
|
329
439
|
```
|
|
330
440
|
|
|
331
441
|
### `.delete({ confirm })` — превью и серверное удаление
|
|
@@ -343,14 +453,14 @@ await db.Запись(b).занятость().delete({ confirm: true }).rows() /
|
|
|
343
453
|
|
|
344
454
|
Цели = фильтр шага + контекст-связи. Замыкание (цели + все живые зависимые) собирается
|
|
345
455
|
одним CTE; `confirm: true` шлёт один SQL `DELETE` — триггер БД тумбстоунит дерево
|
|
346
|
-
(+advisory-lock). История неприкосновенна; повторный delete → `[]`; `
|
|
456
|
+
(+advisory-lock). История неприкосновенна; повторный delete → `[]`; `create()` с тем же
|
|
347
457
|
id — воскрешение.
|
|
348
458
|
|
|
349
459
|
### Откат плана
|
|
350
460
|
|
|
351
461
|
```ts
|
|
352
|
-
await db.Клиент(id).
|
|
353
|
-
.Запись().
|
|
462
|
+
await db.Клиент(id).update({ name: 'Новое' }) // валидный сегмент…
|
|
463
|
+
.Запись().create({ чепуха: 1 }) // …невалидный: ValidationError
|
|
354
464
|
.rows()
|
|
355
465
|
// ← ОТКАТ ВСЕГО: имя клиента не изменилось, версий не прибавилось
|
|
356
466
|
```
|
|
@@ -360,25 +470,28 @@ await db.Клиент(id).set({ name: 'Новое' }) // валидный
|
|
|
360
470
|
## 7. Транзакции
|
|
361
471
|
|
|
362
472
|
```ts
|
|
473
|
+
const busyId = uuidv5(`v1.booking:entity:busy:${slotId}:${staffId}`) // id известен ДО создания (§ 3.2)
|
|
474
|
+
|
|
363
475
|
const tr = await db.begin() // тот же API на выделенном соединении
|
|
364
476
|
await tr.lock('busy', staffId, slotId) // advisory-xact-lock до конца транзакции
|
|
365
477
|
const занято = await tr.занятость(busyId).first()
|
|
366
|
-
if (!занято) await tr.Сотрудник(s)
|
|
478
|
+
if (!занято) await tr.Сотрудник(s).занятость().create({ kind: 'booking' }).Окно.set(w).rows()
|
|
367
479
|
await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()
|
|
368
480
|
```
|
|
369
481
|
|
|
370
482
|
Повторный commit/rollback — no-op. `lock()` вне транзакции — ошибка. Держите транзакции
|
|
371
|
-
короткими. **Рецепт двойной брони**:
|
|
372
|
-
|
|
373
|
-
(
|
|
483
|
+
короткими. **Рецепт двойной брони**: id занятости детерминирован схемой (v5 — § 3.2), так
|
|
484
|
+
что дубль невозможен в принципе (второй `create` стал бы версией той же занятости);
|
|
485
|
+
`lock(мастер, окно)` + перечитка `first()` под локом нужны, чтобы сопернику честно
|
|
486
|
+
ОТКАЗАТЬ, а не молча версионировать чужую бронь (ровно одна успешна — покрыто тестом-гонкой).
|
|
374
487
|
|
|
375
488
|
---
|
|
376
489
|
|
|
377
490
|
## 8. Батчи
|
|
378
491
|
|
|
379
492
|
```ts
|
|
380
|
-
db.batch('окна').Расписание(sch).Окно().
|
|
381
|
-
db.batch('окна').Расписание(sch).Окно().
|
|
493
|
+
db.batch('окна').Расписание(sch).Окно().create({ start, end }) // план встал в очередь
|
|
494
|
+
db.batch('окна').Расписание(sch).Окно().create({ … })
|
|
382
495
|
db.batch('окна').size() // 2
|
|
383
496
|
const res = await db.batch('окна').run() // одна транзакция; Row[][] по порядку
|
|
384
497
|
db.batch('окна').discard() // отменить
|
|
@@ -387,8 +500,9 @@ db.batch('окна').discard() // отменить
|
|
|
387
500
|
- `run()` атомарен: любая ошибка откатывает всё.
|
|
388
501
|
- Операции в батч-цепочке кладут ПЛАН в очередь (многосегментные планы — одним элементом);
|
|
389
502
|
терминал на такой цепочке — ошибка `plan is queued in the batch — call batch.run()`.
|
|
390
|
-
- Подряд идущие чистые
|
|
391
|
-
склеиваются в один multi-VALUES
|
|
503
|
+
- Подряд идущие чистые `create()` одного класса (план из одного шага, без `data.id` и
|
|
504
|
+
слотов) склеиваются в один multi-VALUES; **v5-классы — поштучно** (id считается из
|
|
505
|
+
концов/полей — § 3.2), семантика та же.
|
|
392
506
|
- Read-вызовы на батч-фасаде исполняются сразу, мимо очереди.
|
|
393
507
|
|
|
394
508
|
---
|
|
@@ -533,7 +647,7 @@ db.acl.reload() // сброс кэша Resou
|
|
|
533
647
|
```
|
|
534
648
|
|
|
535
649
|
**`connect({ account, enforceAcl: true })`** — те же решения в цепочках: каждый шаг —
|
|
536
|
-
`READ`, `
|
|
650
|
+
`READ`, `create()`/`update()`/anonymize/батчи — `WRITE`, `delete()` — `DELETE` **по всем классам каскада**
|
|
537
651
|
(deny в замыкании откатывает транзакцию); `watch()` отдаёт события только безусловных
|
|
538
652
|
allow-классов (payload нечем проверить предикат). Правила фиксируются на connect.
|
|
539
653
|
|
|
@@ -542,10 +656,10 @@ const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
|
|
|
542
656
|
await u.Запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
|
|
543
657
|
await u.Запись().count() // честный count по суженному множеству
|
|
544
658
|
await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
|
|
545
|
-
const [z] = await u.Запись().
|
|
546
|
-
await u.Запись().owner(other).
|
|
547
|
-
await u.Запись(чужаяId).
|
|
548
|
-
//
|
|
659
|
+
const [z] = await u.Запись().create().rows() // owner пришпилен правилом → user.id
|
|
660
|
+
await u.Запись().owner(other).create().rows() // Error: acl pins Booking writes to owner …
|
|
661
|
+
await u.Запись(чужаяId).update({ status: '…' }).rows() // → [] — цель вне предиката не находится
|
|
662
|
+
// create по известному чужому id тоже НЕ перехватывает: → [] вместо новой версии
|
|
549
663
|
```
|
|
550
664
|
|
|
551
665
|
Оверхед (article-полигон 1.1M, p50 из 20; `bench/acl.bench.mjs`):
|
|
@@ -687,7 +801,7 @@ const stop = await db.watch('Запись', onEvent, {
|
|
|
687
801
|
```ts
|
|
688
802
|
const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
|
|
689
803
|
await db.Запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
|
|
690
|
-
await db.Клиент().
|
|
804
|
+
await db.Клиент().create({ name: 'X' }) // запись пришпилена к account
|
|
691
805
|
db.Запись().account(чужой).rows() // ошибка: reads are pinned to account …
|
|
692
806
|
```
|
|
693
807
|
|
|
@@ -808,7 +922,7 @@ deadlock detected — letopis: transaction is aborted, retry the whole db.begin(
|
|
|
808
922
|
|
|
809
923
|
Разделы: [11.1 Модуль](#111-модуль-connect-и-экспорты) · [11.1a up](#111a-upopts) ·
|
|
810
924
|
[11.2 EntityDb](#112-entitydb--корень) · [11.3 Chain: чтение](#113-chain--чтение) ·
|
|
811
|
-
[11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-
|
|
925
|
+
[11.4 Операторы](#114-операторы-фильтров) · [11.5 Запись](#115-запись-create--update--delete--anonymize) ·
|
|
812
926
|
[11.6 Транзакции](#116-транзакции-entitytx) · [11.7 Батчи](#117-batch) ·
|
|
813
927
|
[11.8 Таблицы](#118-таблицы-accounts--credentials--resources--rules) ·
|
|
814
928
|
[11.9 db.auth](#119-dbauth--вход-и-сессии) · [11.10 db.acl](#1110-dbacl) ·
|
|
@@ -932,7 +1046,7 @@ await up({ dsn, schema: 'v1.booking', version: 1 })
|
|
|
932
1046
|
```ts
|
|
933
1047
|
const db = await up({ schema: 'booking', version: 1, dsn }) // [7.4 s] образ+контейнер+схема
|
|
934
1048
|
await db.Клиент().count() // → 0 — свежая схема
|
|
935
|
-
const [аня] = await db.Клиент().
|
|
1049
|
+
const [аня] = await db.Клиент().create({ name: 'Аня', contact: '+7 900' }).rows()
|
|
936
1050
|
await db.close()
|
|
937
1051
|
|
|
938
1052
|
// … docker stop letopis-timescale (ребут, уборка, что угодно) …
|
|
@@ -947,6 +1061,7 @@ await db2.close()
|
|
|
947
1061
|
```ts
|
|
948
1062
|
import {
|
|
949
1063
|
connect, up, cursorOf, totpCode, // функции
|
|
1064
|
+
uuidv5, uuidv7, LETOPIS_NS, // генерация id (§ 3.2): формула v5 открыта
|
|
950
1065
|
ne, gt, gte, lt, lte, between, inList, like, ilike, // операторы (18)
|
|
951
1066
|
starts, ends, has, hasAny, hasAll, exists, isNull, not, or,
|
|
952
1067
|
ValidationError, Registry, // классы
|
|
@@ -976,7 +1091,7 @@ import type {
|
|
|
976
1091
|
|
|
977
1092
|
**Назначение и алгоритм.** Старт цепочки чтения/записи (§ 4–6). Ничего не выполняет —
|
|
978
1093
|
только копит шаги; SQL строится и уходит в БД одним запросом на терминале
|
|
979
|
-
(`rows/
|
|
1094
|
+
(`rows/run/count/…` — § 11.3). Формы `filter` нормализуются сразу: объект с `.id`
|
|
980
1095
|
сворачивается в строку-id.
|
|
981
1096
|
|
|
982
1097
|
**Примеры**
|
|
@@ -1015,8 +1130,8 @@ deadlock/serialization ошибка приходит сразу с подска
|
|
|
1015
1130
|
|
|
1016
1131
|
```ts
|
|
1017
1132
|
const tr = await db.begin() // [0.8 ms]
|
|
1018
|
-
await tr.Организация('…0901').Услуга().
|
|
1019
|
-
// [7.0 ms] → [Row] — видно ТОЛЬКО внутри tr
|
|
1133
|
+
await tr.Организация('…0901').Услуга().create({ name: 'Укладка', duration: 15, price: { RUB: 700 } }).rows()
|
|
1134
|
+
// [7.0 ms] → [Row] — id вычислен схемой: uuidv5(Org, "Укладка") (§ 3.2); видно ТОЛЬКО внутри tr
|
|
1020
1135
|
await tr.commit() // [2.7 ms] — теперь видно всем
|
|
1021
1136
|
```
|
|
1022
1137
|
|
|
@@ -1024,10 +1139,10 @@ await tr.commit() // [2.7 ms] — т
|
|
|
1024
1139
|
|
|
1025
1140
|
```ts
|
|
1026
1141
|
const tr = await db.begin()
|
|
1027
|
-
await tr.Услуга('
|
|
1028
|
-
await tr.Услуга('
|
|
1142
|
+
await tr.Услуга({ name: 'Укладка' }).update({ price: { RUB: 9900 } }).rows()
|
|
1143
|
+
await tr.Услуга({ name: 'Укладка' }).first() // внутри tx → price.RUB = 9900
|
|
1029
1144
|
await tr.rollback() // [0.8 ms]
|
|
1030
|
-
await db.Услуга('
|
|
1145
|
+
await db.Услуга({ name: 'Укладка' }).first() // снаружи → price.RUB = 700 — изменение исчезло
|
|
1031
1146
|
```
|
|
1032
1147
|
|
|
1033
1148
|
#### `db.commit(tr): Promise<void>` / `db.rollback(tr): Promise<void>`
|
|
@@ -1069,8 +1184,8 @@ Tombstone-версия приходит с `deleted: true`.
|
|
|
1069
1184
|
|
|
1070
1185
|
```ts
|
|
1071
1186
|
const stop = await db.watch('Клиент', (e) => пойманные.push(e)) // [34.8 ms]
|
|
1072
|
-
await db.Организация('…0901').Клиент().
|
|
1073
|
-
await db.Клиент('…0932').
|
|
1187
|
+
await db.Организация('…0901').Клиент().create({ id: '…0932', name: 'Пётр §11' }).rows()
|
|
1188
|
+
await db.Клиент('…0932').update({ name: 'Пётр Второй' }).rows()
|
|
1074
1189
|
await db.Клиент('…0932').delete({ confirm: true }).rows()
|
|
1075
1190
|
// пойманные (3 события: insert → update → tombstone):
|
|
1076
1191
|
// { partition: 'entity', class: 'Customer', id: '…0932', updated: '…34.775349+00:00', deleted: false }
|
|
@@ -1087,7 +1202,7 @@ const stop = await db.watch('Клиент', (e) => события.push(e), {
|
|
|
1087
1202
|
})
|
|
1088
1203
|
// авария: pg_terminate_backend по LISTEN-соединению …
|
|
1089
1204
|
// onReconnect сработал через 0.1 s после обрыва (реальный прогон)
|
|
1090
|
-
await db.Клиент('…0932').
|
|
1205
|
+
await db.Клиент('…0932').create({ name: 'Пётр после обрыва' }).rows() // create по id — воскрешение
|
|
1091
1206
|
// событие после reconnect: { class: 'Customer', id: '…0932', updated: '…35.40884+00:00', deleted: false }
|
|
1092
1207
|
await stop()
|
|
1093
1208
|
```
|
|
@@ -1146,8 +1261,8 @@ await db.sql.unsafe('SELECT count(*)::int AS n FROM "v1.article"."Entity"')
|
|
|
1146
1261
|
|
|
1147
1262
|
**Назначение и алгоритм.** Продолжение пути по связям: прямой переход (`e.id =
|
|
1148
1263
|
prev.links->>'Класс'`) кладётся точным равенством, обратный — containment
|
|
1149
|
-
`links @> {prevClass: prevId}` в кандидаты + перепроверку. В
|
|
1150
|
-
|
|
1264
|
+
`links @> {prevClass: prevId}` в кандидаты + перепроверку. В цепочке записи шаги до
|
|
1265
|
+
операции — **контекст** (§ 11.5).
|
|
1151
1266
|
|
|
1152
1267
|
**Примеры**
|
|
1153
1268
|
|
|
@@ -1240,7 +1355,7 @@ await db.Сотрудник().ids() // [3.8 ms] → 302 id
|
|
|
1240
1355
|
Параметров нет.
|
|
1241
1356
|
|
|
1242
1357
|
**Назначение и алгоритм.** `SELECT count(*)` поверх соединения шагов — считает **пути**
|
|
1243
|
-
(как `
|
|
1358
|
+
(как `run()`), не уникальные сущности: для цепочки из одного класса это одно и то же,
|
|
1244
1359
|
для многошаговой — нет (см. кейс `.rows()`).
|
|
1245
1360
|
|
|
1246
1361
|
```ts
|
|
@@ -1479,7 +1594,7 @@ await db.Запись().countBy('data.status') // [575.8 ms] — 250k сущн
|
|
|
1479
1594
|
|
|
1480
1595
|
| Параметр | Тип | Описание |
|
|
1481
1596
|
|---|---|---|
|
|
1482
|
-
| `name` | `string` | ключ ТЕКУЩЕГО шага в объектах-путях `
|
|
1597
|
+
| `name` | `string` | ключ ТЕКУЩЕГО шага в объектах-путях `run()` |
|
|
1483
1598
|
|
|
1484
1599
|
**Назначение.** Переименование ключа шага в выводе (данные и SQL не меняются) — удобно,
|
|
1485
1600
|
когда один класс встречается в пути дважды.
|
|
@@ -1495,7 +1610,7 @@ await db.Организация(org).alias('салон').Сотрудник({ na
|
|
|
1495
1610
|
|
|
1496
1611
|
| Параметр | Тип | Описание |
|
|
1497
1612
|
|---|---|---|
|
|
1498
|
-
| `v` | `string \| string[] \| has/hasAny/hasAll` | чтение: фильтр колонки `tags` (строка ≡ содержит; массив ≡ содержит все); в
|
|
1613
|
+
| `v` | `string \| string[] \| has/hasAny/hasAll` | чтение: фильтр колонки `tags` (строка ≡ содержит; массив ≡ содержит все); в цепочке записи — **значение** тегов `create()`-INSERT |
|
|
1499
1614
|
|
|
1500
1615
|
**Назначение и алгоритм.** Фильтр по массивной колонке `tags` (`@>` — GIN-индекс,
|
|
1501
1616
|
кандидаты + перепроверка). В записи — модификатор значения.
|
|
@@ -1512,7 +1627,7 @@ await db.Клиент().tags(hasAny(['vip', 'telegram'])).count() // [3.5 ms]
|
|
|
1512
1627
|
|
|
1513
1628
|
| Параметр | Тип | Описание |
|
|
1514
1629
|
|---|---|---|
|
|
1515
|
-
| `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`;
|
|
1630
|
+
| `v` | `uuid \| { id }` | чтение: фильтр колонки `account`/`owner`; при `create()` — значение колонки |
|
|
1516
1631
|
|
|
1517
1632
|
**Назначение и алгоритм.** Прямое равенство по uuid-колонке (btree). Под `enforceAccount`
|
|
1518
1633
|
чужой `.account()` — ошибка; под `enforceAcl` конфликт с пришпиленной правилом колонкой —
|
|
@@ -1670,119 +1785,145 @@ await db.Услуга(or({ name: 'Стрижка' }, { duration: lt(45) })).coun
|
|
|
1670
1785
|
|
|
1671
1786
|
**Кейс:** «стрижка или что-нибудь быстрое» — один запрос: 1 (Стрижка) + 101 (быстрые) = 102.
|
|
1672
1787
|
|
|
1673
|
-
### 11.5 Запись:
|
|
1788
|
+
### 11.5 Запись: create / update / delete / anonymize
|
|
1674
1789
|
|
|
1675
1790
|
Операции — **звенья плана** (§ 6): каждая применяется к шагу, к которому приклеена точкой,
|
|
1676
1791
|
и возвращает цепочку. Исполняет **терминал** — весь план одной транзакцией (внутренней,
|
|
1677
1792
|
с ретраем transient; внутри `db.begin()` — транзакцией пользователя); отказ любого сегмента
|
|
1678
|
-
откатывает всё. Продолжение цепочки — от результата операции.
|
|
1793
|
+
откатывает всё. Продолжение цепочки — от результата операции. Старый `set()` разбит на
|
|
1794
|
+
`create()`/`update()` в 0.16.0 — бросает подсказку.
|
|
1679
1795
|
|
|
1680
|
-
#### `.
|
|
1796
|
+
#### `.create(data?): Chain`
|
|
1681
1797
|
|
|
1682
1798
|
| Параметр | Тип | Описание |
|
|
1683
1799
|
|---|---|---|
|
|
1684
|
-
| `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация: лишний ключ — ошибка) + опциональный `id` —
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
(
|
|
1697
|
-
`owner`/`account` и фильтрует цели); (2) контекст-шаги резолвятся в связи — каждый обязан
|
|
1698
|
-
дать **ровно одну** сущность; (3) поиск целей: INSERT-форма не ищет, id/фильтр — ищут в
|
|
1699
|
-
границах контекста; (4) на каждую цель: **deep-merge** листьев `data`, слияние links
|
|
1700
|
-
(контекст + `.link()`), строгая валидация, **один INSERT новой версии** с
|
|
1701
|
-
`updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) UPSERT по id при пустом поиске —
|
|
1702
|
-
INSERT (воскрешает удалённую; под ACL существующая-но-недоступная **не перехватывается**);
|
|
1703
|
-
(6) конфликт `23505`/transient ретраится транзакцией плана. Сегмент после операции
|
|
1704
|
-
исполняется **для каждой строки её результата** (fan-out); `set` сразу после `set` — новая
|
|
1705
|
-
версия тех же сущностей (self).
|
|
1800
|
+
| `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация: лишний ключ — ошибка) + опциональный `id` (у v5-класса запрещён — id считает схема, § 3.2) |
|
|
1801
|
+
|
|
1802
|
+
**Назначение и алгоритм.** «Чтобы сущность существовала». На терминале: (1) под
|
|
1803
|
+
`enforceAcl` — WRITE-решение по классу (deny — откат; предикат правила пришпиливает
|
|
1804
|
+
`owner`/`account`); (2) владелец создаваемой связки — из валидного пути (каждый
|
|
1805
|
+
контекст-шаг обязан дать **ровно одну** сущность), прочие концы — слотами; (3) id: явный
|
|
1806
|
+
(шаг `Класс(id)` или `data.id`) либо по правилу схемы — v4/v7 генерируются, v5 вычисляется
|
|
1807
|
+
из концов/полей (§ 3.2); (4) id известен и **уже существует** (в границах пути) → **новая
|
|
1808
|
+
версия** с deep-merge — идемпотентный create, REST-PUT семантика (удалённую — воскрешает;
|
|
1809
|
+
под ACL существующая-но-недоступная **не перехватывается** → `[]`); не существует → **один
|
|
1810
|
+
INSERT** с `updated = GREATEST(clock_timestamp(), prev + 1 µs)`; (5) конфликт
|
|
1811
|
+
`23505`/transient ретраится транзакцией плана. Фильтр-объект перед create — ошибка
|
|
1812
|
+
ПОСТРОЕНИЯ (`create() takes no filter`); create на pivot-шаге — ошибка.
|
|
1706
1813
|
|
|
1707
1814
|
**Примеры**
|
|
1708
1815
|
|
|
1709
1816
|
```ts
|
|
1710
|
-
await db.Организация().
|
|
1817
|
+
await db.Организация().create({ name: 'Пилигрим' }).rows() // [9.0 ms] INSERT + defaults из Schema
|
|
1711
1818
|
// → [{ id: '39aba8a5-…', class: 'Org', data: { name: 'Пилигрим', active: true, timezone: 'Europe/Moscow' }, … }]
|
|
1712
1819
|
|
|
1713
|
-
await db.Организация().
|
|
1820
|
+
await db.Организация().create({ id: '…0901', name: 'Демо-салон §11' }).rows()
|
|
1821
|
+
// [7.0 ms] явный id — можно: Org наследует v7 (§ 3.2); повторный create того же id → новая версия
|
|
1714
1822
|
|
|
1715
|
-
await db.Организация('…0901').Сотрудник().
|
|
1823
|
+
await db.Организация('…0901').Сотрудник().create({ id: '…0911', name: 'Мия', roles: ['master'] }).rows()
|
|
1716
1824
|
// [14.5 ms] контекст → links: { Org: '…0901' }
|
|
1717
1825
|
|
|
1718
|
-
await db
|
|
1826
|
+
await db.Организация('…0901').Услуга().create({ name: 'Укладка экспресс', duration: 15, price: { RUB: 700 } }).rows()
|
|
1827
|
+
// [7.2 ms] Услуга — v5-класс: id вычислен схемой из (Org, name) — § 3.2;
|
|
1828
|
+
// повторный create той же пары (салон, имя) → новая ВЕРСИЯ той же услуги, не дубль
|
|
1829
|
+
|
|
1830
|
+
db.Услуга({ name: 'Укладка экспресс' }).create({ duration: 20 }) // [0.1 ms] — синхронно, до БД:
|
|
1831
|
+
// Error: letopis: create() takes no filter — Услуга(id).create(…) fixes the id, searching is update()
|
|
1832
|
+
|
|
1833
|
+
await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() // [1.4 ms] — ошибка НА ТЕРМИНАЛЕ:
|
|
1834
|
+
// ValidationError: letopis: validation failed for "Service":
|
|
1835
|
+
// duration — The 'duration' field is required.; … forbidden keys: 'чепуха'
|
|
1836
|
+
|
|
1837
|
+
db.Услуга('…0921').Клиент() // [0.1 ms] недопустимый переход — синхронно при построении
|
|
1838
|
+
// Error: letopis: no path Service → Customer
|
|
1839
|
+
```
|
|
1840
|
+
|
|
1841
|
+
#### `.update(data?): Chain`
|
|
1842
|
+
|
|
1843
|
+
| Параметр | Тип | Описание |
|
|
1844
|
+
|---|---|---|
|
|
1845
|
+
| `data` | `Record<string, unknown>?` | поля по `Schema.attributes` (строгая валидация); без аргумента — версия без изменения полей (например, ради слотов) |
|
|
1846
|
+
|
|
1847
|
+
**Назначение и алгоритм.** Новая версия **каждого** найденного путём. Цели ищутся как при
|
|
1848
|
+
чтении — id, фильтр, pivot; `Класс()` ≡ `Класс({})` — «все в границах контекста». На каждую
|
|
1849
|
+
цель: **deep-merge** листьев `data` (`update({ price: { RUB: 1100 } })` сохранит `USD` и
|
|
1850
|
+
остальные поля; массивы/скаляры — целиком), слияние links (слоты `.Класс.set()`/`.unset()`),
|
|
1851
|
+
строгая валидация, один INSERT новой версии. Не найдено → `[]` — update **НИКОГДА не
|
|
1852
|
+
создаёт**. Сегмент после операции исполняется **для каждой строки её результата** (fan-out);
|
|
1853
|
+
`update` сразу после `update` — новая версия тех же сущностей (self).
|
|
1854
|
+
|
|
1855
|
+
**Примеры**
|
|
1856
|
+
|
|
1857
|
+
```ts
|
|
1858
|
+
await db.Услуга('…0921').update({ price: { RUB: 1400, USD: 15 } }).rows() // [7.0 ms] новая версия по id
|
|
1719
1859
|
// было: { name: 'Массаж головы', price: { RUB: 1200 }, duration: 30, description: 'релакс', … }
|
|
1720
1860
|
// стало: { name: 'Массаж головы', price: { RUB: 1400, USD: 15 }, duration: 30, description: 'релакс', … }
|
|
1721
1861
|
// deep-merge тронул только листья price; duration/description целы
|
|
1722
1862
|
|
|
1723
|
-
await db.Клиент('…0931').Запись({ status: 'created' }).
|
|
1863
|
+
await db.Клиент('…0931').Запись({ status: 'created' }).update({ status: 'confirmed' }).rows() // [12.1 ms]
|
|
1724
1864
|
// → [{ id: '…0962', status: 'confirmed' }, { id: '…0963', status: 'confirmed' }] — только записи Златы
|
|
1725
1865
|
|
|
1726
|
-
await db.Клиент('…0931').Запись(
|
|
1866
|
+
await db.Клиент('…0931').Запись().update({ status: 'completed' }).rows() // [10.7 ms] ВСЕ в контексте
|
|
1727
1867
|
// → [{ id: '…0962', status: 'completed' }, { id: '…0963', status: 'completed' }]
|
|
1728
1868
|
|
|
1729
|
-
await db
|
|
1730
|
-
// ValidationError: letopis: validation failed for "Service":
|
|
1731
|
-
// duration — The 'duration' field is required.; … forbidden keys: 'чепуха'
|
|
1732
|
-
|
|
1733
|
-
db.Услуга('…0921').set({ duration: 30 }).link('Сотрудник', м) // [0.1 ms]
|
|
1734
|
-
// Error: letopis: step modifier after set() — add a class step first
|
|
1869
|
+
await db.Запись({ status: 'нет-такого' }).update({ status: 'x' }).rows() // → [] — НИЧЕГО не создано
|
|
1735
1870
|
```
|
|
1736
1871
|
|
|
1737
1872
|
**Кейс: план из нескольких операций — реальный прогон**
|
|
1738
1873
|
|
|
1739
1874
|
```ts
|
|
1740
1875
|
// обновить клиента → вставить ему запись (продолжение от записанного, одна транзакция)
|
|
1741
|
-
await db.Клиент('…0931').
|
|
1742
|
-
.Запись().
|
|
1876
|
+
await db.Клиент('…0931').update({ language: 'en' })
|
|
1877
|
+
.Запись().create({ id: '…0964', status: 'created', total: { RUB: 990 } })
|
|
1743
1878
|
.rows() // [15.5 ms]
|
|
1744
1879
|
// → [{ id: '…0964', class: 'Booking', links: { Customer: '…0931' }, status: 'created' }]
|
|
1745
1880
|
|
|
1746
|
-
// self-
|
|
1747
|
-
await db.Запись('…0964').
|
|
1881
|
+
// self-update: две версии подряд
|
|
1882
|
+
await db.Запись('…0964').update({ status: 'confirmed' }).update({ status: 'completed' }).rows() // [13.6 ms]
|
|
1748
1883
|
// → ['completed']; versions: ['created', 'confirmed', 'completed']
|
|
1749
1884
|
|
|
1750
1885
|
// хвост-чтение после операции — в той же транзакции
|
|
1751
|
-
await db.Клиент('…0931').
|
|
1886
|
+
await db.Клиент('…0931').update({ language: 'ru' }).Запись().count() // [201.6 ms] → 7
|
|
1752
1887
|
|
|
1753
1888
|
// ОТКАТ: невалидный второй сегмент откатывает и первый
|
|
1754
|
-
await db.Клиент('…0931').
|
|
1889
|
+
await db.Клиент('…0931').update({ name: 'Не запишется' }).Запись().create({ чепуха: 1 }).rows()
|
|
1755
1890
|
// Error: letopis: validation failed for "Booking" … [6.1 ms]; имя клиента не изменилось
|
|
1756
1891
|
|
|
1757
1892
|
// fan-out: обновить клиента → снести ВСЕ его записи
|
|
1758
|
-
await db.Клиент('…0931').
|
|
1893
|
+
await db.Клиент('…0931').update({ active: true }).Запись().delete({ confirm: true }).rows()
|
|
1759
1894
|
// [229.4 ms] → 7 записей, все с $deleted: true
|
|
1760
1895
|
```
|
|
1761
1896
|
|
|
1762
|
-
####
|
|
1897
|
+
#### Слоты связей: `.Класс.set(target): Chain` / `.Класс.unset(): Chain`
|
|
1763
1898
|
|
|
1764
|
-
|
|
|
1765
|
-
|
|
1766
|
-
| `
|
|
1767
|
-
| `
|
|
1899
|
+
| Форма | Описание |
|
|
1900
|
+
|---|---|
|
|
1901
|
+
| `.Класс.set(target)` | значение конца связи новой версии; `target` = id \| Row \| вложенная цепочка |
|
|
1902
|
+
| `.Класс.unset()` | снять optional-конец (0.16.0: переименован из `.Класс.delete()` — старое имя бросает подсказку) |
|
|
1768
1903
|
|
|
1769
|
-
**Назначение и алгоритм.**
|
|
1770
|
-
|
|
1771
|
-
|
|
1904
|
+
**Назначение и алгоритм.** Слот — свойство-класс БЕЗ вызова, идёт ПОСЛЕ глагола записи
|
|
1905
|
+
(`create`/`update`); слот без операции — ошибка `link slot needs a write`. Пишет конец в
|
|
1906
|
+
links **БЕЗ участия в фильтре целей** (в отличие от шага пути, который фильтрует). Валиден
|
|
1907
|
+
только для конца из `Schema.links` владельца; союз-конец `[A|B]` замещается целиком
|
|
1908
|
+
(соседний класс снимается); дубль одного слота — ошибка. `target`-цепочка
|
|
1909
|
+
исполняется в той же транзакции и обязана дать ровно одну сущность класса конца.
|
|
1772
1910
|
|
|
1773
1911
|
**Примеры**
|
|
1774
1912
|
|
|
1775
1913
|
```ts
|
|
1776
|
-
await db.навык().
|
|
1914
|
+
await db.Сотрудник(м9).навык().create().Услуга.set(у9).rows() // [4.1 ms] ОДИН INSERT
|
|
1777
1915
|
// → { id: '7b374ae4-…', class: 'skill', links: { Staff: '…0911', Service: '…0921' } }
|
|
1916
|
+
// навык — v5: id вычислен из (Staff, Service|Complex) — второй раз тот же навык не завести
|
|
1917
|
+
|
|
1918
|
+
// вложенная цепочка — создать И привязать в одной транзакции:
|
|
1919
|
+
await db.Запись(b).позиция().create({ qty: 1 }).Услуга.set(db.Услуга(у).update({ active: true })).rows()
|
|
1778
1920
|
```
|
|
1779
1921
|
|
|
1780
1922
|
**Кейс: перевесить исполнителя на всех позициях записи**
|
|
1781
1923
|
|
|
1782
1924
|
```ts
|
|
1783
|
-
await tr.Запись(b).позиция(
|
|
1784
|
-
//
|
|
1785
|
-
// а .link только записывает значение
|
|
1925
|
+
await tr.Запись(b).позиция().update().Сотрудник.set(новый).rows()
|
|
1926
|
+
// цель ищется путём (все позиции записи); слот пишет нового исполнителя БЕЗ фильтра по старому
|
|
1786
1927
|
```
|
|
1787
1928
|
|
|
1788
1929
|
#### `.delete(opts?): Chain`
|
|
@@ -1797,14 +1938,14 @@ await tr.Запись(b).позиция({}).link('Сотрудник', новы
|
|
|
1797
1938
|
под `enforceAcl` DELETE-решение проверяется на класс целей **и каждый класс замыкания**
|
|
1798
1939
|
(deny откатывает план); `DELETE` по целям будит серверный триггер: advisory-lock →
|
|
1799
1940
|
tombstone-версия → рекурсивное удаление зависимых. Возврат — замыкание с `$deleted: true`.
|
|
1800
|
-
История остаётся; `
|
|
1941
|
+
История остаётся; `create()` с тем же id — воскрешение. Продолжение цепочки — от строк
|
|
1801
1942
|
класса цели.
|
|
1802
1943
|
|
|
1803
1944
|
**Примеры**
|
|
1804
1945
|
|
|
1805
1946
|
```ts
|
|
1806
1947
|
await db.Запись('…0961').delete().rows() // [192.7 ms] ПРЕВЬЮ — кандидаты живы:
|
|
1807
|
-
// → [{ id: '…0961', class: 'Booking' }, {
|
|
1948
|
+
// → [{ id: '…0961', class: 'Booking' }, { class: 'busy', … }, { class: 'item', … }] — бронь + занятость + позиция
|
|
1808
1949
|
// запись жива: true
|
|
1809
1950
|
|
|
1810
1951
|
await db.Запись('…0961').delete({ confirm: true }).rows() // [209.2 ms] — сервер нашёл зависимых:
|
|
@@ -1819,8 +1960,8 @@ const последствия = await db.Запись(bid).delete().rows()
|
|
|
1819
1960
|
if (операторПодтвердил) {
|
|
1820
1961
|
await db.Запись(bid).delete({ confirm: true }).rows() // [209.2 ms] бронь + занятость + позиция
|
|
1821
1962
|
}
|
|
1822
|
-
// клиент передумал: воскрешение тем же id (зависимые пересоздать явно)
|
|
1823
|
-
await db.Клиент('…0931').Запись('…0961').
|
|
1963
|
+
// клиент передумал: воскрешение тем же id — create (зависимые пересоздать явно)
|
|
1964
|
+
await db.Клиент('…0931').Запись('…0961').create({ status: 'created', total: { RUB: 500 } }).rows() // [11.3 ms]
|
|
1824
1965
|
```
|
|
1825
1966
|
|
|
1826
1967
|
#### `.anonymize(fields): Chain`
|
|
@@ -1867,10 +2008,10 @@ await db.Клиент().tags('anonymized').count() // в
|
|
|
1867
2008
|
|
|
1868
2009
|
```ts
|
|
1869
2010
|
const tr = await db.begin() // [0.8 ms]
|
|
1870
|
-
await tr.Услуга('
|
|
1871
|
-
await tr.Услуга('
|
|
2011
|
+
await tr.Услуга({ name: 'Укладка' }).update({ price: { RUB: 9900 } }).rows()
|
|
2012
|
+
await tr.Услуга({ name: 'Укладка' }).first() // внутри → 9900
|
|
1872
2013
|
await tr.rollback() // [0.8 ms]
|
|
1873
|
-
await db.Услуга('
|
|
2014
|
+
await db.Услуга({ name: 'Укладка' }).first() // снаружи → 700, изменения нет
|
|
1874
2015
|
```
|
|
1875
2016
|
|
|
1876
2017
|
**Кейс: commit** — § 11.2 `db.begin()`; ниже — главный сценарий `lock`.
|
|
@@ -1899,20 +2040,24 @@ await db.lock('x')
|
|
|
1899
2040
|
**Кейс: гонка двойной брони — реальный прогон двух транзакций**
|
|
1900
2041
|
|
|
1901
2042
|
```ts
|
|
1902
|
-
// два администратора жмут «забронировать» на одно окно
|
|
2043
|
+
// два администратора жмут «забронировать» на одно окно одновременно;
|
|
2044
|
+
// id занятости детерминирован (v5, § 3.2) — известен ДО создания:
|
|
2045
|
+
const busyId = uuidv5(`v1.article:entity:busy:${окно.id}:${мастер.id}`)
|
|
1903
2046
|
const trA = await db.begin(), trB = await db.begin()
|
|
1904
|
-
await trA.lock('busy',
|
|
2047
|
+
await trA.lock('busy', мастер.id, окно.id) // [1.4 ms] A первый
|
|
1905
2048
|
const гонкаB = (async () => {
|
|
1906
|
-
await trB.lock('busy',
|
|
2049
|
+
await trB.lock('busy', мастер.id, окно.id) // B ВИСИТ до конца trA
|
|
1907
2050
|
const занято = await trB.занятость(busyId).first() // перечитка под локом
|
|
1908
2051
|
if (занято) { await trB.rollback(); return 'ОТКАЗ: окно уже занято' }
|
|
1909
|
-
await trB.Сотрудник(
|
|
2052
|
+
await trB.Сотрудник(мастер).занятость().create({ kind: 'booking' }).Окно.set(окно).rows()
|
|
1910
2053
|
await trB.commit(); return 'бронь моя'
|
|
1911
2054
|
})()
|
|
1912
2055
|
await trA.занятость(busyId).first() // → null — свободно
|
|
1913
|
-
await trA.Сотрудник(
|
|
2056
|
+
await trA.Сотрудник(мастер).занятость().create({ kind: 'booking' }).Окно.set(окно).rows()
|
|
1914
2057
|
await trA.commit()
|
|
1915
2058
|
await гонкаB // → 'ОТКАЗ: окно уже занято' — B увидел бронь A, дубля нет
|
|
2059
|
+
// дубль невозможен и без лока (оба create вычислят ОДИН id — второй стал бы версией);
|
|
2060
|
+
// лок нужен, чтобы B получил честный отказ, а не молча версионировал чужую бронь
|
|
1916
2061
|
|
|
1917
2062
|
// а если два лока взять в разном порядке — деадлок, жертва получает:
|
|
1918
2063
|
// Error: deadlock detected — letopis: transaction is aborted, retry the whole db.begin() block
|
|
@@ -1940,26 +2085,28 @@ const b = db.batch('слоты-августа') // [16 µs]
|
|
|
1940
2085
|
|
|
1941
2086
|
**Назначение и алгоритм.** Вся очередь — **одна транзакция** (бывший `execute()`);
|
|
1942
2087
|
результаты по порядку планов (`Row[]` на план; у многосегментного — результат последнего
|
|
1943
|
-
сегмента). Подряд идущие
|
|
1944
|
-
и
|
|
1945
|
-
пришпиливание — на каждый элемент склейки. Transient-ошибка
|
|
1946
|
-
Очередь очищается.
|
|
2088
|
+
сегмента). Подряд идущие чистые `create()` одного класса (план из одного шага, без `data.id`
|
|
2089
|
+
и слотов; id класса не v5 — § 3.2) склеиваются в **один multi-VALUES INSERT**; под
|
|
2090
|
+
`enforceAcl` WRITE-проверка и пришпиливание — на каждый элемент склейки. Transient-ошибка
|
|
2091
|
+
ретраит всю транзакцию целиком. Очередь очищается.
|
|
1947
2092
|
|
|
1948
2093
|
**Примеры**
|
|
1949
2094
|
|
|
1950
2095
|
```ts
|
|
1951
2096
|
const b = db.batch('слоты-августа')
|
|
1952
|
-
b.Окно().
|
|
1953
|
-
b.Окно().
|
|
1954
|
-
b.Окно().
|
|
2097
|
+
b.Расписание(sch).Окно().create({ start: '2026-08-01T11:00:00Z', end: '…12:00Z' })
|
|
2098
|
+
b.Расписание(sch).Окно().create({ start: '2026-08-01T12:00:00Z', end: '…13:00Z' })
|
|
2099
|
+
b.Расписание(sch).Окно().create({ start: '2026-08-01T13:00:00Z', end: '…14:00Z' })
|
|
1955
2100
|
b.size() // [29 µs] → 3
|
|
1956
|
-
await b.run() // [32.1 ms] — 3 INSERT
|
|
1957
|
-
// → [[{
|
|
2101
|
+
await b.run() // [32.1 ms] — одна транзакция; Окно — v5-класс → 3 честных INSERT, не склейка
|
|
2102
|
+
// → [[{ start: '2026-08-01T11:00:00.000Z', … }], [{ …12:00 }], [{ …13:00 }]]
|
|
2103
|
+
// id каждого окна вычислен схемой: uuidv5(Schedule, start) — § 3.2
|
|
1958
2104
|
b.size() // → 0
|
|
1959
2105
|
```
|
|
1960
2106
|
|
|
1961
|
-
**Кейс: генерация расписания на день** — 3 окна одной транзакцией
|
|
1962
|
-
|
|
2107
|
+
**Кейс: генерация расписания на день** — 3 окна одной транзакцией (выше); упавшая
|
|
2108
|
+
валидация любого окна откатывает все; v7-классы без `data.id` и слотов склеились бы в
|
|
2109
|
+
один multi-VALUES INSERT. Терминал на плане в батче:
|
|
1963
2110
|
`план.rows()` → `Error: letopis: plan is queued in the batch — call batch.run()`.
|
|
1964
2111
|
|
|
1965
2112
|
#### `batch.discard(): void` / `batch.size(): number`
|
|
@@ -1968,14 +2115,14 @@ b.size() // → 0
|
|
|
1968
2115
|
|
|
1969
2116
|
```ts
|
|
1970
2117
|
const b2 = db.batch('отмена')
|
|
1971
|
-
b2.Окно().
|
|
2118
|
+
b2.Расписание(sch).Окно().create({ start: '2026-08-02T11:00:00Z', end: '…12:00Z' })
|
|
1972
2119
|
b2.discard() // [53 µs]
|
|
1973
2120
|
b2.size() // → 0
|
|
1974
|
-
await db.Окно('
|
|
2121
|
+
await db.Расписание(sch).Окно({ start: '2026-08-02T11:00:00Z' }).first() // → null — не исполнилось
|
|
1975
2122
|
```
|
|
1976
2123
|
|
|
1977
2124
|
**Кейс: черновик импорта** — копим операции по мере парсинга файла; ошибка парсера →
|
|
1978
|
-
`discard()`, полный успех → `
|
|
2125
|
+
`discard()`, полный успех → `run()`.
|
|
1979
2126
|
|
|
1980
2127
|
### 11.8 Таблицы: accounts / credentials / resources / rules
|
|
1981
2128
|
|
|
@@ -2621,11 +2768,11 @@ await uc.Запись().count() // [20.3 ms] → 3 — честный count п
|
|
|
2621
2768
|
await uc.Услуга().count() // [15.9 ms] → 404 — безусловный allow, класс целиком
|
|
2622
2769
|
await uc.Организация().rows()
|
|
2623
2770
|
// Error: letopis: acl denies READ on Org — no matching rule (deny by default) [0.3 ms]
|
|
2624
|
-
const [z] = await uc.Запись().
|
|
2771
|
+
const [z] = await uc.Запись().create({ status: 'created', total: { RUB: 300 } }).rows() // [11.7 ms]
|
|
2625
2772
|
z.owner === acc.id // → true — owner пришпилен правилом
|
|
2626
|
-
await uc.Запись().owner(SYS).
|
|
2773
|
+
await uc.Запись().owner(SYS).create({ … }).rows()
|
|
2627
2774
|
// Error: letopis: acl pins Booking writes to owner … — на терминале [1.8 ms]
|
|
2628
|
-
await uc.Запись(чужаяId).
|
|
2775
|
+
await uc.Запись(чужаяId).update({ status: 'hacked' }).rows() // [10.9 ms] → [] — чужая жива и НЕ перехвачена
|
|
2629
2776
|
await uc.Запись('…0965').delete({ confirm: true }).rows() // [27.7 ms] → [{ id: '…0965', $deleted: true }]
|
|
2630
2777
|
// watch: события только безусловных allow-классов —
|
|
2631
2778
|
// uc.watch(cb) поймал ['Service']; Booking скрыт (предикат не проверить по payload)
|
|
@@ -2690,13 +2837,13 @@ db.registry.all.length // → 15
|
|
|
2690
2837
|
| `message` | `string` | `letopis: validation failed for "<Класс>": …` |
|
|
2691
2838
|
| `issues` | `{ field, type, message, … }[]` | отчёт fastest-validator по каждому полю |
|
|
2692
2839
|
|
|
2693
|
-
**Назначение.** Бросается из ТЕРМИНАЛА плана (`.
|
|
2694
|
-
не проходит строгую схему класса (недостающее обязательное, лишний ключ,
|
|
2695
|
-
весь план откатывается. Другие ошибки записи (abstract-класс, недостающий
|
|
2696
|
-
обычный `Error` там же.
|
|
2840
|
+
**Назначение.** Бросается из ТЕРМИНАЛА плана (`.create()`/`.update()`/`.anonymize()`/батчи),
|
|
2841
|
+
когда `data` не проходит строгую схему класса (недостающее обязательное, лишний ключ,
|
|
2842
|
+
неверный тип) — весь план откатывается. Другие ошибки записи (abstract-класс, недостающий
|
|
2843
|
+
конец LINK) — обычный `Error` там же.
|
|
2697
2844
|
|
|
2698
2845
|
```ts
|
|
2699
|
-
try { await db.Услуга().
|
|
2846
|
+
try { await db.Услуга().create({ name: 'X', чепуха: 1 }).rows() } // [1.4 ms]
|
|
2700
2847
|
catch (e) {
|
|
2701
2848
|
e instanceof ValidationError // → true
|
|
2702
2849
|
e.issues
|
|
@@ -2744,13 +2891,24 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
|
|
|
2744
2891
|
| `unknown class "X". Known: …` | класс вне Schema (со списком) |
|
|
2745
2892
|
| `ValidationError` (`.issues`) | строгая валидация: мусор или лишние поля |
|
|
2746
2893
|
| `class "X" is abstract` | запись в abstract |
|
|
2747
|
-
| `link "X" requires end "Y"` / `
|
|
2748
|
-
| `context step "X" must resolve to exactly one entity` |
|
|
2749
|
-
| `
|
|
2750
|
-
| `
|
|
2894
|
+
| `link "X" requires end "Y|Z"` / `has stray link(s)` | не хватает обязательного конца LINK / связь вне объявленных концов (v2); legacy: `polymorphic end(s)` |
|
|
2895
|
+
| `context step "X" must resolve to exactly one entity` | шаг-владелец пути дал 0 или >1 |
|
|
2896
|
+
| `create() takes no filter — searching is update()` | фильтр-объект перед `create()` (синхронно, при построении) |
|
|
2897
|
+
| `create() on a pivot step` | pivot возвращает к существующему узлу — это `update()` |
|
|
2898
|
+
| `class "X" computes id (uuid v5 from …)` | явный id у v5-класса (§ 3.2) — id всегда считает схема |
|
|
2899
|
+
| `id (uuid v5) of "X" needs end "A\|B"` / `needs scalar data field "f"` | v5-классу не хватает конца (путь/слот) или скалярного поля из `from` |
|
|
2900
|
+
| `step modifier after create()/update()` | модификатор шага (alias/tags/…) сразу после операции |
|
|
2901
|
+
| `"X" is not a link end of "Y"` | слот `.X.set()` — не конец Y по Schema.links |
|
|
2902
|
+
| `slot "X" needs an owner step` | слот на корне (`db.X.set()`) без шага-владельца |
|
|
2903
|
+
| `link slot "X" needs a write` | слот без глагола записи — добавить `.create(…)`/`.update(…)` перед ним |
|
|
2904
|
+
| `link end "X" of "Y" is required — cannot unset` | `.unset()` обязательного конца |
|
|
2905
|
+
| `duplicate link slot "X"` | один конец задан слотом дважды в одной записи |
|
|
2906
|
+
| `set() split into create()/update() (0.16.0)` | старый глагол записи — create() вставляет, update() версионирует найденное |
|
|
2907
|
+
| `slot .delete() renamed to .unset() (0.16.0)` | старое имя слот-снятия |
|
|
2908
|
+
| `.link() removed (0.15.0)` | снесённый `.link()` — теперь слот `.Класс.set()` |
|
|
2751
2909
|
| `execute() renamed to run() (0.11.0)` | старое имя терминала путей (и `batch.execute()`) |
|
|
2752
2910
|
| `plan is queued in the batch — call batch.run()` | терминал на батч-цепочке с операциями |
|
|
2753
|
-
| `no path X → Y` / `LINK → LINK …` | недопустимый переход
|
|
2911
|
+
| `no path X → Y` / `LINK → LINK …` | недопустимый переход (синхронно при построении цепочки) |
|
|
2754
2912
|
| `Entity.account is NOT NULL…` | нет account и System-аккаунта |
|
|
2755
2913
|
| `violates foreign key constraint "entity_*_fk"` | несуществующий класс/аккаунт; удаление класса с данными |
|
|
2756
2914
|
| `lock() works only inside db.begin()` | лок вне транзакции |
|
|
@@ -2778,7 +2936,7 @@ AclDecision = { allow, rule?, filter?, code?, message? } // filter —
|
|
|
2778
2936
|
| `rows()` весь класс, sort+limit 100 | 63 ms | 74 ms |
|
|
2779
2937
|
| цепочка 3 хопа (пути) | 15 ms | 23 ms |
|
|
2780
2938
|
| `count()` путей | 15 ms | 20 ms |
|
|
2781
|
-
|
|
|
2939
|
+
| запись новой версии (`create`/`update`) | 8 ms | 12 ms |
|
|
2782
2940
|
|
|
2783
2941
|
TOAST-порог (data > 2KB): 0 строк. Слабое место — выборка «весь класс с сортировкой»
|
|
2784
2942
|
(DISTINCT ON всех сущностей класса); лечится селективным фильтром или курсором.
|
|
@@ -2792,7 +2950,7 @@ API Reference (§ 11) — `npx tsx bench/api-reference-demo.mjs` (живой art
|
|
|
2792
2950
|
- Начинайте цепочку с самого селективного шага.
|
|
2793
2951
|
- `count()` — пути; количество сущностей дешевле `ids().length`.
|
|
2794
2952
|
- Каскадное удаление — серверное: один DELETE на всё дерево.
|
|
2795
|
-
- Один сегмент плана пишет одним INSERT на цель независимо от числа связей (
|
|
2953
|
+
- Один сегмент плана пишет одним INSERT на цель независимо от числа связей (путь + слоты).
|
|
2796
2954
|
- EXPLAIN-паттерн — тест `EXPLAIN` (индексы обязаны быть в плане; ноль seq scan).
|
|
2797
2955
|
|
|
2798
2956
|
---
|
|
@@ -2803,30 +2961,31 @@ API Reference (§ 11) — `npx tsx bench/api-reference-demo.mjs` (живой art
|
|
|
2803
2961
|
|
|
2804
2962
|
```ts
|
|
2805
2963
|
// штат и каталог — связи контекст-шагами; терминал .rows() исполняет план
|
|
2806
|
-
const [org] = await db.Организация().
|
|
2807
|
-
const [ivan] = await db.Организация(org).Сотрудник().
|
|
2808
|
-
const [oleg] = await db.Организация(org).Сотрудник().
|
|
2809
|
-
const [стрижка] = await db.Организация(org).Услуга().
|
|
2810
|
-
const [комплекс] = await db.Организация(org).Комплекс().
|
|
2811
|
-
|
|
2812
|
-
await db
|
|
2964
|
+
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
|
|
2965
|
+
const [ivan] = await db.Организация(org).Сотрудник().create({ name: 'Иван', roles: ['owner','master'] }).rows()
|
|
2966
|
+
const [oleg] = await db.Организация(org).Сотрудник().create({ name: 'Олег', roles: ['master'] }).rows()
|
|
2967
|
+
const [стрижка] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60, price: { RUB: 1500 } }).rows()
|
|
2968
|
+
const [комплекс] = await db.Организация(org).Комплекс().create({ name: 'Стрижка+борода', duration: 90, price: { RUB: 2500 } }).rows()
|
|
2969
|
+
// id услуги/комплекса вычислила схема: uuidv5(Org, name) — дубль имени в салоне невозможен (§ 3.2)
|
|
2970
|
+
await db.Комплекс(комплекс).состав().create({ qty: 1 }).Услуга.set(стрижка).rows() // состав комплекса
|
|
2971
|
+
await db.Сотрудник(ivan).навык().create().Комплекс.set(комплекс).rows() // умение
|
|
2813
2972
|
|
|
2814
2973
|
// календарь: период → окна батчем → ростер → исключение
|
|
2815
|
-
const [sch] = await db.Организация(org).Расписание().
|
|
2816
|
-
db.batch('о').Расписание(sch).Окно().
|
|
2974
|
+
const [sch] = await db.Организация(org).Расписание().create({ start: '2026-07-10T10:00:00+03:00', end: '…13:00' }).rows()
|
|
2975
|
+
db.batch('о').Расписание(sch).Окно().create({ start: '…10:00', end: '…11:00' }) // ×3 — планы в очередь
|
|
2817
2976
|
const [[w1],[w2],[w3]] = await db.batch('о').run()
|
|
2818
|
-
await db.Расписание(sch)
|
|
2819
|
-
await db.Сотрудник(oleg)
|
|
2977
|
+
await db.Расписание(sch).смена().create().Сотрудник.set(ivan).rows()
|
|
2978
|
+
await db.Сотрудник(oleg).занятость().create({ kind: 'off' }).Окно.set(w3).rows() // id = uuidv5(w3, oleg)
|
|
2820
2979
|
|
|
2821
|
-
// бронь мульти-слот (90м > 60м → два окна):
|
|
2822
|
-
const [bkg] = await db.Клиент(пётр).Запись().
|
|
2823
|
-
await db.Запись(bkg)
|
|
2824
|
-
await db.занятость().
|
|
2825
|
-
|
|
2826
|
-
|
|
2980
|
+
// бронь мульти-слот (90м > 60м → два окна): владелец из пути, концы слотами
|
|
2981
|
+
const [bkg] = await db.Клиент(пётр).Запись().create({ status: 'created', total: { RUB: 2500 } }).rows()
|
|
2982
|
+
await db.Запись(bkg).позиция().create({ qty: 1, price: { RUB: 2500 } }).Комплекс.set(комплекс).Сотрудник.set(ivan).rows()
|
|
2983
|
+
await db.Сотрудник(ivan).занятость().create({ kind: 'booking' }).Окно.set(w2).Запись.set(bkg).rows()
|
|
2984
|
+
await db.Сотрудник(ivan).занятость().create({ kind: 'booking' }).Окно.set(w3).Запись.set(bkg).rows()
|
|
2985
|
+
// id занятостей детерминированы — uuidv5(окно, мастер): двойная бронь мертва на уровне схемы
|
|
2827
2986
|
|
|
2828
2987
|
// жизненный цикл, чтения, отмена
|
|
2829
|
-
await db.Запись(bkg.id).
|
|
2988
|
+
await db.Запись(bkg.id).update({ status: 'confirmed' }).rows()
|
|
2830
2989
|
await db.Запись(bkg.id).занятость().Окно().sort('data.start').rows() // окна брони
|
|
2831
2990
|
await db.Окно(w2.id).занятость({ kind: 'booking' }).Сотрудник().rows() // кто занят
|
|
2832
2991
|
await db.Запись(bkg.id).delete().rows() // ПРЕВЬЮ: что удалится
|
|
@@ -2839,24 +2998,28 @@ const удалено = await db.Запись(bkg.id).delete({ confirm: true }).r
|
|
|
2839
2998
|
## 16. Тесты
|
|
2840
2999
|
|
|
2841
3000
|
```bash
|
|
2842
|
-
npm test #
|
|
3001
|
+
npm test # 165/165 тестов против живого docker (timescale + redis), по файлам:
|
|
2843
3002
|
# acl — Resource/Rule: маски/weight/deny-by-default, шаблоны строк
|
|
2844
3003
|
# с $account, enforceAcl (предикаты в SQL, каскад, watch)
|
|
2845
3004
|
# api-full — сквозной чек-лист ВСЕХ публичных методов API (15 групп)
|
|
2846
3005
|
# auth — db.auth: пароль/api-key/key-secret/TOTP/OTP/link+lookup,
|
|
2847
3006
|
# глобальная идентичность, сессии на живом Redis (expire/revoke)
|
|
2848
|
-
# plan — план-модель: несколько операций, fan-out, self-
|
|
2849
|
-
# превью/confirm delete, ОТКАТ плана,
|
|
3007
|
+
# plan — план-модель: несколько операций, fan-out, self-update,
|
|
3008
|
+
# превью/confirm delete, ОТКАТ плана, слот-перевес, батч-гард
|
|
2850
3009
|
# resilience — ретраи 40P01/40001, реальный deadlock двух транзакций,
|
|
2851
3010
|
# watch переживает обрыв LISTEN (pg_terminate_backend)
|
|
2852
3011
|
# core — триггеры (check/версии/каскад/lineage), обходы, операторы,
|
|
2853
|
-
# модификаторы,
|
|
3012
|
+
# модификаторы, create/update-формы, delete, гонка, батчи, EXPLAIN
|
|
2854
3013
|
# depth — наследование 3+ уровней, data-пути любой глубины
|
|
3014
|
+
# idgen — генерация id по Schema (§ 3.2): v4/v7/v5 из концов и полей,
|
|
3015
|
+
# гонка без локов, наследование attributes и правила id, гарды
|
|
2855
3016
|
# integration — E2E-барбершоп (8 сцен)
|
|
2856
3017
|
# real-life — 16 сцен «дня салона»: 4 руки, гонки ×3, переносы, no-show
|
|
2857
3018
|
# tables — auth/ACL-таблицы
|
|
2858
3019
|
# up — up(): docker-argv, probe-ветка, идемпотентность, fresh,
|
|
2859
3020
|
# свои сиды/seeds:false, автосоздание базы, валидация
|
|
3021
|
+
# salon — ТЕСТ-ПЛАН: имитация салона, ВСЕ 145 публичных API
|
|
3022
|
+
# (21 акт + матрица покрытия); слоты/pivot/entity
|
|
2860
3023
|
# wave2 — asOf/versions, keyset-курсор, gen-types, enforceAccount, anonymize
|
|
2861
3024
|
# wave3 — or/not, агрегации, deep, watch
|
|
2862
3025
|
# wave4 — compression-политики (чтение сжатого чанка), schema-sync, onQuery
|