letopis 0.18.0 → 0.19.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 CHANGED
@@ -2,6 +2,59 @@
2
2
 
3
3
  Формат: [Keep a Changelog](https://keepachangelog.com/), версии — semver.
4
4
 
5
+ ## [0.19.0] — 2026-07-14
6
+
7
+ ### Added — физический purge (hard-erase) + живой подхват правок схемы
8
+
9
+ - **`.purge({ confirm })`** — необратимый физический снос уже логически удалённой (tombstone)
10
+ сущности + всего поддерева по `links` (все версии). Двухфазно: живую не трогает (сначала
11
+ `.delete()`); без `confirm` — dry-превью замыкания. Внутри — серверная `purge()`:
12
+ `SET LOCAL letopis.purge='on'` отключает append-only-триггер через `WHEN`-условие → плоский
13
+ `DELETE` (детерминированно, без `TM_SelfModified`).
14
+ - **`.withDeleted()`** — включить удалённые (tombstone) в выдачу последнего шага; снимает ТОЛЬКО
15
+ фильтр `deleted`, изоляция арендатора (`enforceAccount`) и ACL (`enforceAcl`) действуют.
16
+ - **`db.accounts.purge(id)`** — полный офбординг тенанта: физ. снос всех Entity (`account|owner`)
17
+ + сам Account (Credential — FK-каскад). Гарды: только Owner/System, не последний Owner, не свой аккаунт.
18
+ - **`db.reloadSchema()` / `db.schema.define(def)`** — правка определений классов простым SQL в
19
+ таблице `Schema` подхватывается без реконнекта (пересборка registry + ACL-резолвера).
20
+
21
+ ### Changed — DDL (требует наката `ddl.sql` на существующие схемы)
22
+
23
+ - `entity_delete` получил `WHEN (current_setting('letopis.purge', true) IS DISTINCT FROM 'on')` —
24
+ обычный путь (tombstone + каскад) неизменен; под флагом триггер пропускается для физ-сноса.
25
+ - Новые серверные функции: `purge_closure(partition,class,ids[])`,
26
+ `purge(partition,class,id[,dry]) RETURNS SETOF Entity`, `purge_account(id)`.
27
+
28
+ ### Notes
29
+
30
+ - Голый повторный `DELETE` надгробия БЕЗ флага остаётся no-op (safety-инвариант жив).
31
+ - Тесты: новый `test/wave5.test.ts` (13 кейсов P1/P2) — весь набор 181/181 зелёный.
32
+
33
+ ## [0.18.1] — 2026-07-13
34
+
35
+ ### Fixed — упаковка: CLI-скрипты в npm-пакете
36
+
37
+ - **`package.json` `files` += `"scripts"`**: `scripts/schema-sync.mjs` (миграция классов)
38
+ и `scripts/gen-types.mjs` (TS-типы из Schema) теперь входят в npm-тарбол — раньше
39
+ документировались в README, но в пакет не попадали (жили только в git-репо). Проверено
40
+ `npm pack --dry-run`. Оба скрипта самодостаточны (зависимости `postgres` +
41
+ `fastest-validator`, `tsx` не нужен). Корневые `db/apply.mjs` / `db/policies.mjs`
42
+ остаются repo-only (лежат выше пакета `lib/`) — в README помечены как таковые, накат
43
+ в установке делает `up()`.
44
+
45
+ ### Added — README: обзор фич + инструкция «своя схема с нуля»
46
+
47
+ - **Блок «Возможности»** в начале README — сгруппированный обзор всех возможностей
48
+ библиотеки (хранилище, схема, чтение, запись, история, auth/ACL, эксплуатация) со
49
+ ссылками на разделы.
50
+ - **§1.1 «Своя схема с нуля + всё в контейнере одной командой»**: пошаговая инструкция —
51
+ сид домена (System-аккаунт + классы) → `up({ seeds })` (контейнер + база + схема +
52
+ connect) → первые данные; гейты (`has no classes`, `Entity.account NOT NULL`),
53
+ изолированное окружение, prod / managed-PG путь.
54
+ - **§3.4 «Примеры схем из таблицы Schema»**: DDL таблицы `Schema`, формат строки-класса,
55
+ 4 разобранных примера (вложенный object, наследуемый v5-id, союз + optional, record /
56
+ мультивалюта) и таблица всех 20 классов демо-домена booking с типовым вызовом.
57
+
5
58
  ## [0.18.0] — 2026-07-13
6
59
 
7
60
  ### Added — генератор id берёт default поля из Schema
package/README.md CHANGED
@@ -16,6 +16,50 @@ const пути = await db.Мастер({ name: 'Вася' }).навык().Усл
16
16
  await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
17
17
  ```
18
18
 
19
+ ## Возможности
20
+
21
+ **Хранилище — темпоральное, append-only**
22
+ - Каждое изменение — новая версия-строка; история — first-class (§2, §10).
23
+ - Единая `Entity`-hypertable на TimescaleDB, партиции по времени `updated` (§2.1).
24
+ - Весь CRUD на уровне БД триггерами: `UPDATE` = новая версия, `DELETE` = tombstone + рекурсивный каскад (§2.2).
25
+ - FK-целостность с RESTRICT на append-only (§2.3); срез актуального через `DISTINCT ON` (§2.4).
26
+
27
+ **Схема и классы**
28
+ - Реестр классов из таблицы `Schema`: `HUB` / `LINK`, наследование `attributes` по иерархии (§3, §3.3).
29
+ - Строгая валидация данных — fastest-validator DSL, вложенные объекты любой глубины (§3, §6).
30
+ - Связи v2: союзы ролей (`{A|B|C}`), optional-концы, жадный матчинг (§3.1).
31
+ - Правило id в схеме: uuid v4 / v7 (time-ordered) / v5 (детерминированный → идемпотентный create) (§3.2).
32
+
33
+ **Чтение — dot-цепочки (graph-query)**
34
+ - Пути по графу HUB↔LINK, pivot-возврат, узел-переменная `entity()` (§4).
35
+ - Терминалы `run` / `rows` / `first` / `ids` / `count` — ленивый план, SQL уходит на терминале (§4).
36
+ - 18 операторов фильтров (`ne` `gt` `between` `inList` `like` `has` `exists` `not` `or`…), вложенные пути, касты по схеме (§5).
37
+ - Модификаторы: `limit` / `offset` / `sort`, `asOf`, `after` (keyset), `deep`, `tags` / `account` / `owner`, `alias` (§5).
38
+
39
+ **Запись**
40
+ - `create` / `update` / `delete` / `anonymize` — звенья плана; весь план одной транзакцией с откатом (§6).
41
+ - Слоты связей `.Класс.set()` / `.unset()`, вложенная цепочка как значение слота (§6.1).
42
+ - Deep-merge при `update`; идемпотентный `create` по вычисленному v5-id (§6).
43
+ - Транзакции `db.begin()` + advisory-lock (рецепт двойной брони, §7); батчи с multi-VALUES-склейкой (§8).
44
+
45
+ **История и время**
46
+ - `asOf()` («как было на T») + `versions()` (§10.1); keyset-пагинация `after()` / `cursorOf()` (§10.2).
47
+ - Агрегации в БД `sum` / `avg` / `min` / `max` / `countBy` (§10.3); деревья `deep()` recursive-CTE (§10.4).
48
+ - Realtime `watch()` через `pg_notify` + `onReconnect` (§10.5).
49
+ - Анонимизация (GDPR) `anonymize()` (§10.7); политики сжатия / retention Timescale (§10.8).
50
+
51
+ **Auth · ACL · сессии — встроенные**
52
+ - Пароли (scrypt), api-ключи, key-secret, external identity (oauth/sso/telegram), TOTP, одноразовые коды (§9.1).
53
+ - Сессии во внешнем Redis (клиент инжектируется, §9.1).
54
+ - ACL: `Resource` / `Rule`, `enforceAcl` — предикаты строк вливаются в SQL до сортировки/лимита; наследование прав по классам (§9.2).
55
+ - Изоляция арендатора `enforceAccount` (§10.6).
56
+
57
+ **Эксплуатация**
58
+ - `up()` — dev-bootstrap одной функцией: Docker (TimescaleDB pg17 + Redis) + схема + сиды + connect (§1, §1.1).
59
+ - Миграции классов `schema-sync` с отчётом совместимости (§10.9); TS-типы из схемы `gen-types` (§10.11).
60
+ - Наблюдаемость `onQuery` / `slowMs` (§10.10); ретраи deadlock/serialization, самопереподключение LISTEN (§10.12).
61
+ - Лёгкие зависимости: только `postgres` + `fastest-validator` (Redis-клиент внешний, `ioredis` опционален).
62
+
19
63
  Содержание:
20
64
  [1. Быстрый старт](#1-быстрый-старт) ·
21
65
  [2. Хранилище](#2-хранилище) ·
@@ -61,7 +105,7 @@ await db.close()
61
105
  Данные PG живут в named volume `letopis-pgdata` (кроссплатформенно, переживает пересоздание
62
106
  контейнера); `dataDir: '/path'` — bind mount папки хоста. Полный контракт — [§11.1a](#111a-upopts).
63
107
 
64
- Вручную (то же самое по шагам):
108
+ Вручную (то же самое по шагам) — **из клона репозитория** (`db/apply.mjs` — корневой скрипт репо, в npm-пакет не входит; в установленном пакете накат делает `up()` выше, а Docker-образ он собирает из вложенного `docker/`):
65
109
 
66
110
  ```bash
67
111
  docker build -t letopis-db lib/docker
@@ -76,6 +120,88 @@ node db/apply.mjs --dsn=postgres://postgres:test@localhost:15432/clockz --schema
76
120
  const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz', schema: 'v1.booking' })
77
121
  ```
78
122
 
123
+ ### 1.1 Своя схема с нуля + всё в контейнере одной командой
124
+
125
+ Три шага: **(1)** сид своей схемы → **(2)** `up()` поднимает контейнер и накатывает → **(3)** пишешь данные. Таблицы (`Schema`, `Account`, `Entity`, …) создаёт `ddl.sql` — своему сиду нужны только INSERT-ы; маркер `<SCHEMA-NAME>` подставит `up()`.
126
+
127
+ **1. Сид `myapp.sql`** — классы в таблицу `Schema` + System-аккаунт:
128
+
129
+ ```sql
130
+ -- myapp.sql — своя схема letopis
131
+
132
+ -- System-аккаунт: Entity.account/owner — NOT NULL, дефолт либа берёт ОТСЮДА
133
+ -- (connect ищет Account с категорией 'System'; без него первый create бросит ошибку)
134
+ INSERT INTO "<SCHEMA-NAME>"."Account" (id, categories, data)
135
+ VALUES ('00000000-0000-4000-8000-000000000001', '{System}', '{"name":"System"}')
136
+ ON CONFLICT (id) DO NOTHING;
137
+
138
+ -- Классы домена (нужен ≥1, иначе connect: "has no classes")
139
+ INSERT INTO "<SCHEMA-NAME>"."Schema"
140
+ (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors)
141
+ VALUES
142
+ -- корень: дефолт id v7 наследуется; abstract — напрямую не создаётся
143
+ ('entity','Entity','Сущность','HUB', NULL,
144
+ '{"id":{"type":"uuid","generate":7}}','{"abstract":true}','[]',100,'{Entity}'),
145
+ -- HUB: заметка
146
+ ('entity','Note','Заметка','HUB','Entity',
147
+ '{"title":"string","body":"string|optional","done":{"type":"boolean","default":false}}',
148
+ '{}','[]',200,'{Note,Entity}'),
149
+ -- HUB: тег
150
+ ('entity','Tag','Тег','HUB','Entity',
151
+ '{"name":"string"}','{}','[]',300,'{Tag,Entity}'),
152
+ -- LINK: заметка ↔ тег
153
+ ('entity','tagged','помечена','LINK', NULL,
154
+ '{"id":{"type":"uuid","generate":7}}','{}',
155
+ '[{"class":"Note","cardinality":1},{"class":"Tag","cardinality":1}]',400,'{tagged}')
156
+ ON CONFLICT (partition, id) DO UPDATE SET
157
+ alias=EXCLUDED.alias, ancestor=EXCLUDED.ancestor, attributes=EXCLUDED.attributes,
158
+ meta=EXCLUDED.meta, links=EXCLUDED.links, "order"=EXCLUDED."order";
159
+ ```
160
+
161
+ **2. `up()` — контейнер + база + схема + connect одной командой:**
162
+
163
+ ```ts
164
+ import { up } from 'letopis'
165
+
166
+ const db = await up({ schema: 'myapp', version: 1, seeds: ['./myapp.sql'] })
167
+ // [letopis.up] building image letopis-db … (первый раз тянет TimescaleDB-базу — минуты)
168
+ // [letopis.up] container letopis-timescale created (data: volume letopis-pgdata)
169
+ // [letopis.up] postgres ready in 5.0 s / redis ready on 16379
170
+ // [letopis.up] schema "v1.myapp" applied (2 files) ← ddl.sql + myapp.sql
171
+ // [letopis.up] connected (schema "v1.myapp")
172
+ ```
173
+
174
+ Изолированное окружение (если на хосте уже крутится дефолтный контейнер или нужен отдельный):
175
+
176
+ ```ts
177
+ const db = await up({
178
+ schema: 'myapp', version: 1, seeds: ['./myapp.sql'],
179
+ container: 'myapp-db', // иначе дефолтный 'letopis-timescale'
180
+ image: 'myapp-timescale', // соберётся из пакетного docker/ (TimescaleDB pg17 + Redis)
181
+ dsn: 'postgres://postgres:secret@localhost:25432/myapp',
182
+ dataDir: 'myapp-pgdata', // named volume (или путь хоста = bind mount)
183
+ redisPort: 26379,
184
+ })
185
+ ```
186
+
187
+ **3. Данные — работают сразу** (System-аккаунт из сида закрывает `account`/`owner`):
188
+
189
+ ```ts
190
+ const [n] = await db.Заметка().create({ title: 'Купить хлеб' }).rows()
191
+ const [t] = await db.Тег().create({ name: 'дом' }).rows()
192
+ await db.Заметка(n).помечена().create().Тег.set(t).rows() // связь Note ↔ Tag (§6.1)
193
+ const активные = await db.Заметка({ done: false }).rows()
194
+ await db.close()
195
+ ```
196
+
197
+ **Гейты и рецепты**
198
+ - **≥1 класс** в `Schema` обязателен — иначе `up()` падает на connect: `has no classes` (§3).
199
+ - **System-аккаунт обязателен** (или передать `up({ …, account: '<uuid существующей Account>' })`) — иначе первый `create` бросит `Entity.account is NOT NULL …`.
200
+ - **Готовый auth из коробки** (пароли/ACL): добавь пакетный сид вторым — `seeds: ['./myapp.sql', 'node_modules/letopis/sql/seed.auth.sql']` (System + 16 Resource + 12 Rule); тогда свою `Account`-строку не пиши.
201
+ - **Идемпотентно**: контейнер жив → reuse; схема есть → накат пропущен. **Заново с нуля**: `fresh: true` (дроп схемы — данные теряются) либо снести контейнер: `docker rm -f myapp-db && docker volume rm myapp-pgdata`.
202
+ - **Prod / managed PG**: `dsn` на живой postgres → docker пропускается целиком (только база + схема + connect); либо накатить `sql/ddl.sql` + свой сид своим мигратором и `connect({ dsn, schema: 'v1.myapp' })`. Пакетный `docker/`-образ (Redis без пароля/персиста) — dev-удобство, не для прода.
203
+ - Локальный путь требует установленного и запущенного **Docker**; managed PG — без него.
204
+
79
205
  ---
80
206
 
81
207
  ## 2. Хранилище
@@ -232,6 +358,194 @@ v7-классы (Org/Staff/Customer/Location/Schedule/Folder) наследуют
232
358
  наследуется так же — дефолт объявляется один раз на корне иерархии; `links` НЕ наследуются:
233
359
  концы объявляет каждый класс сам. Валидатор и типы полей компилируются из слитых attributes.
234
360
 
361
+ ### 3.4 Примеры схем из таблицы `Schema`
362
+
363
+ Демо-домен booking — **20 классов** в `lib/sql/seed.booking.sql` (**источник правды**:
364
+ правится руками, идемпотентен — по строке-INSERT на класс). Файл входит в npm-пакет;
365
+ `up({ schema: 'booking' })` накатывает его дефолтом. Свой домен — тем же форматом:
366
+
367
+ ```ts
368
+ await up({ schema: 'salon', version: 1, seeds: ['./salon.sql'] }) // свой сид вместо демо
369
+ // seed.auth.sql грузится отдельно и в Schema НЕ пишет — только Account/Resource/Rule (§9)
370
+ ```
371
+
372
+ Таблица `Schema` (`lib/sql/ddl.sql`) — по строке на класс:
373
+
374
+ ```sql
375
+ CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
376
+ partition text NOT NULL DEFAULT 'entity',
377
+ id text NOT NULL, -- англ. id класса: 'Staff', 'skill'
378
+ alias text NOT NULL, -- рус. алиас: 'Мастер', 'навык'
379
+ category text NOT NULL CHECK (category IN ('HUB', 'LINK')),
380
+ ancestor text, -- прямой родитель (наследование)
381
+ attributes jsonb NOT NULL DEFAULT '{}', -- fastest-validator DSL (валидирует либа)
382
+ links jsonb NOT NULL DEFAULT '[]', -- массив объектов-концов (§3.1)
383
+ meta jsonb NOT NULL DEFAULT '{}', -- {abstract?, description?, appearance?}
384
+ "order" int NOT NULL DEFAULT 0,
385
+ ancestors text[] NOT NULL DEFAULT '{}', -- [self, parent, …, root] — считает триггер schema_lineage
386
+ descendants text[] NOT NULL DEFAULT '{}', -- все потомки (транзитивно) — считает триггер schema_lineage
387
+ PRIMARY KEY (partition, id)
388
+ );
389
+ ```
390
+
391
+ Формат строки (один на все 20; `<SCHEMA-NAME>` подставляет `up()` реальным именем PG-схемы):
392
+
393
+ ```sql
394
+ INSERT INTO "<SCHEMA-NAME>"."Schema"
395
+ (partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors)
396
+ VALUES ('entity', 'Service', 'Услуга', 'HUB', 'Element',
397
+ '{"duration":"number|min:0|optional"}', -- attributes: DSL fastest-validator (§3)
398
+ '{"abstract":false,"description":"…"}', -- meta
399
+ '[{"class":"Org","cardinality":1}]', -- links: концы (§3.1); НЕ наследуются
400
+ 800, '{Service,Element,Entity}') -- order + ancestors
401
+ ON CONFLICT (partition, id) DO UPDATE SET …; -- повторный накат = правка класса
402
+ ```
403
+
404
+ - `id` + `alias` — англ. и рус. имя, **равноправны** в API: `db.Service(…)` ≡ `db.Услуга(…)`.
405
+ - `descendants` не пишем — считает триггер `schema_lineage`; `ancestors` он тоже пересчитает.
406
+ - `attributes.id` — правило рождения id (§3.2), наследуется по `ancestor` (§3.3).
407
+
408
+ Ниже — 4 характерные строки verbatim из сида и **как ими пользоваться**.
409
+
410
+ #### HUB · вложенный object — `Org` (Организация)
411
+
412
+ ```jsonc
413
+ // attributes:
414
+ {
415
+ "name": "string",
416
+ "timezone": { "type": "string", "default": "Europe/Moscow" },
417
+ "address": "string|optional",
418
+ "phone": "string|optional",
419
+ "active": { "type": "boolean", "default": true },
420
+ "settings": { "type": "object", "optional": true, "strict": true, "props": {
421
+ "booking": { "type": "object", "optional": true, "strict": true, "props": {
422
+ "deposit": { "type": "object", "optional": true, "strict": true, "props": {
423
+ "amount": "number|integer|min:0|default:0",
424
+ "currency": { "type": "enum", "values": ["RUB","USD","EUR","AED"], "default": "RUB" }
425
+ }},
426
+ "autoconfirm": "boolean|default:false"
427
+ }}
428
+ }}
429
+ }
430
+ // links: [] ← корневой HUB, арендатор сам себе владелец (концов нет)
431
+ ```
432
+
433
+ ```ts
434
+ // create: defaults подставит схема (timezone, active); вложенный object любой глубины
435
+ const [org] = await db.Организация().create({
436
+ name: 'BarberPro',
437
+ settings: { booking: { deposit: { amount: 500, currency: 'RUB' }, autoconfirm: true } },
438
+ }).rows()
439
+
440
+ // правка одного листа — deep-merge, соседи целы (§6): autoconfirm и currency сохранятся
441
+ await db.Организация(org).update({ settings: { booking: { deposit: { amount: 1000 } } } }).rows()
442
+
443
+ // фильтр по вложенному пути любой глубины (§5):
444
+ await db.Организация({ settings: { booking: { autoconfirm: true } } }).rows()
445
+ ```
446
+
447
+ Поле вне `attributes` → `ValidationError` (валидация строгая, `$$strict`).
448
+
449
+ #### HUB · наследуемый v5-id — `Element → Service` (Услуга)
450
+
451
+ `Element` объявляет правило id **один раз**; `Service`/`Product`/`Complex` наследуют (§3.3):
452
+
453
+ ```jsonc
454
+ // Element (abstract): attributes
455
+ { "id": { "type": "uuid", "generate": 5, "from": ["Org", "name"] }, // id = uuidv5(Org, name)
456
+ "name": "string", "description": "string|optional" }
457
+ // links: [{ "class": "Org", "cardinality": 1 }]
458
+
459
+ // Service (ancestor: Element): attributes — добавляет только своё поле
460
+ { "duration": "number|min:0|optional" } // name и правило id унаследованы от Element
461
+ // links: [{ "class": "Org", "cardinality": 1 }] ← links НЕ наследуются, объявлены заново
462
+ ```
463
+
464
+ ```ts
465
+ // create: id НЕ передаём — схема считает uuidv5(Org, name); повтор той же пары = та же услуга
466
+ const [svc] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60 }).rows()
467
+ svc.id === uuidv5(`v1.booking:entity:Service:${org.id}:Стрижка`) // → true (§3.2)
468
+ // имя уникально в организации; classId в формуле различает Услугу и Товар с тем же именем
469
+ ```
470
+
471
+ #### LINK · наследование + союз ролей + optional — `slot → booking` (запись)
472
+
473
+ `booking` наследует `slot` (интервал + правило id), добавляет предмет записи и клиента:
474
+
475
+ ```jsonc
476
+ // slot (окно): attributes
477
+ { "id": { "type": "uuid", "generate": 5, "from": ["Staff", "start_datetime"] },
478
+ "start_datetime": { "type": "date", "convert": true },
479
+ "end_datetime": { "type": "date", "convert": true } }
480
+ // links: [{class:"Staff"}, {class:"Location"}, {class:"Schedule"}] (все cardinality:1)
481
+
482
+ // booking (ancestor: slot): attributes — только своё; start/end и правило id унаследованы
483
+ { "notes": "string|optional" }
484
+ // links:
485
+ [ { "class": "Staff", "cardinality": 1 },
486
+ { "class": "Location", "cardinality": 1 },
487
+ { "class": "Schedule", "cardinality": 1 },
488
+ { "classes": ["Service","Product","Complex"], "cardinality": 1 }, // союз: РОВНО один из
489
+ { "class": "Customer", "optional": true, "cardinality": 1 } ] // конец может отсутствовать
490
+ ```
491
+
492
+ ```ts
493
+ // владелец (Мастер) — из пути; прочие концы — слотами (§6.1); id считает схема из (Staff, start)
494
+ await db.Мастер(m).запись().create({ start_datetime: t, end_datetime: e })
495
+ .Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
496
+ // союз занимает ОДИН ключ: услуга ИЛИ товар ИЛИ комплекс; лишняя связь → ошибка
497
+ // Клиент optional → ручная бронь без него, имя в notes:
498
+ await db.Мастер(m).запись().create({ start_datetime: t, end_datetime: e, notes: 'по телефону: Аня' })
499
+ .Локация.set(л).Расписание.set(р).Товар.set(тов).rows() // Клиент опущен — ok
500
+ ```
501
+
502
+ #### LINK · record / мультивалюта — `price` (цена)
503
+
504
+ ```jsonc
505
+ // attributes:
506
+ { "id": { "type": "uuid", "generate": 5, "from": ["Service|Product|Complex", "note"] },
507
+ "note": { "type": "string", "default": "базовая" }, // необязателен → дефолт входит в id (§3.2)
508
+ "amounts": { "type": "record",
509
+ "key": { "type": "enum", "values": ["RUB","USD","EUR","AED"] },
510
+ "value": "number|integer|min:0" } }
511
+ // links: [{ "classes": ["Service","Product","Complex"], "cardinality": 1 }] ← владелец — элемент
512
+ ```
513
+
514
+ ```ts
515
+ // у элемента несколько вариантов цены; note различает (id = uuidv5(элемент, note))
516
+ await db.Услуга(svc).цена().create({ amounts: { RUB: 1500, AED: 60 } }).rows() // note → 'базовая'
517
+ await db.Услуга(svc).цена().create({ note: 'с дизайном', amounts: { RUB: 2500 } }).rows()
518
+ await db.Услуга(svc).цена().sum('data.amounts.RUB') // агрегат по record-листу (§10.3): 4000
519
+ ```
520
+
521
+ #### Все 20 классов и типовой вызов
522
+
523
+ | Класс (id · alias) | cat | Что это | Типовой вызов |
524
+ |---|---|---|---|
525
+ | `Entity` · Сущность | HUB | abstract-корень, дефолт id v7 | — не создаётся |
526
+ | `Org` · Организация | HUB | арендатор, владелец каталога | `db.Организация().create({ name })` |
527
+ | `Person` · Контрагент | HUB | abstract: name + phone | — |
528
+ | `Staff` · Мастер | HUB | бронируемый исполнитель | `db.Организация(o).Мастер().create({ name, phone })` |
529
+ | `Customer` · Клиент | HUB | клиент | `db.Организация(o).Клиент().create({ name, phone })` |
530
+ | `Location` · Локация | HUB | точка обслуживания | `db.Организация(o).Локация().create({ address })` |
531
+ | `Element` · Элемент | HUB | abstract каталог, id v5(Org, name) | — |
532
+ | `Service` · Услуга | HUB | услуга (+ duration) | `db.Организация(o).Услуга().create({ name, duration })` |
533
+ | `Product` · Товар | HUB | товар (+ sku, unit) | `db.Организация(o).Товар().create({ name, sku })` |
534
+ | `Complex` · Комплекс | HUB | комплекс (+ cost, duration) | `db.Организация(o).Комплекс().create({ name, cost, duration })` |
535
+ | `Schedule` · Расписание | HUB | месячное расписание | `db.Организация(o).Расписание().create({ name, year, month })` |
536
+ | `Folder` · Папка | HUB | категория (вложенная) | `db.Организация(o).Папка().create({ name })` · дети: `db.Папка(f).Папка().create({ name })` |
537
+ | `link` · связь | LINK | abstract-корень связей, id v7 | — |
538
+ | `address` · адрес | LINK | Мастер работает в Локации | `db.Мастер(m).адрес().create({ default: true }).Локация.set(l)` |
539
+ | `slot` · окно | LINK | доступность Мастер × Локация × Расписание | `db.Мастер(m).окно().create({ start_datetime, end_datetime }).Локация.set(l).Расписание.set(s)` |
540
+ | `booking` · запись | LINK | бронь (наследник окна) | `db.Мастер(m).запись().create({…}).Локация.set(l).Расписание.set(s).Услуга.set(u).Клиент.set(c)` |
541
+ | `content` · содержимое | LINK | элемент в папке | `db.Папка(f).содержимое().create({ order: 1 }).Услуга.set(u)` |
542
+ | `compo` · состав | LINK | элемент в комплексе | `db.Комплекс(k).состав().create({ quantity: 2 }).Услуга.set(u)` |
543
+ | `skill` · навык | LINK | Мастер умеет Услугу | `db.Мастер(m).навык().create({ level: 'expert' }).Услуга.set(u)` |
544
+ | `price` · цена | LINK | вариант цены элемента | `db.Услуга(u).цена().create({ amounts: { RUB: 1500 } })` |
545
+
546
+ Полные `attributes` / `links` / `meta` каждого класса — в `lib/sql/seed.booking.sql`
547
+ (по одному INSERT-у на класс, в порядке `order`).
548
+
235
549
  ---
236
550
 
237
551
  ## 4. Чтение: цепочки
@@ -467,6 +781,29 @@ await db.Мастер(m).окно().delete({ confirm: true }).rows() // кон
467
781
  (+advisory-lock). История неприкосновенна; повторный delete → `[]`; `create()` с тем же
468
782
  id — воскрешение.
469
783
 
784
+ ### `.purge({ confirm })` — ФИЗИЧЕСКИЙ hard-erase (необратимо)
785
+
786
+ ```ts
787
+ await db.Клиент(cid).delete({ confirm: true }).rows() // 1) мягко: tombstone (обратимо)
788
+ await db.Клиент(cid).purge().rows() // превью замыкания (что сотрётся), БД цела
789
+ await db.Клиент(cid).purge({ confirm: true }).rows() // 2) физически: tombstone + всё поддерево (все версии)
790
+ ```
791
+
792
+ **Двухфазно:** `.purge()` работает ТОЛЬКО по уже логически удалённому (tombstone) — живую сущность
793
+ не трогает (сначала `.delete()`). Историю, в отличие от `.delete()`, НЕ сохраняет. Внутри — серверная
794
+ `purge(partition,class,id)`: `SET LOCAL letopis.purge='on'` отключает append-only-триггер (через `WHEN`)
795
+ → плоский физический `DELETE` замыкания (детерминированно, без вложенного DML). Голый SQL:
796
+ `SELECT * FROM "v1.notify".purge('entity','Клиент','…')` (или `…,true)` — dry-превью).
797
+
798
+ ### `.withDeleted()` — включить удалённые (tombstone) в выдачу
799
+
800
+ ```ts
801
+ await db.Клиент(cid).rows() // только живые
802
+ await db.Клиент(cid).withDeleted().rows() // + tombstone (последний шаг)
803
+ ```
804
+
805
+ Снимает **только** фильтр `deleted IS NULL`; изоляция арендатора (`enforceAccount`) и ACL (`enforceAcl`) действуют.
806
+
470
807
  ### Откат плана
471
808
 
472
809
  ```ts
@@ -830,12 +1167,16 @@ await db.Клиент(id).anonymize(['name', 'phone']).rows()
830
1167
  // новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целы
831
1168
  ```
832
1169
 
833
- Физического стирания НЕТ — история священна: старые версии хранят PII до retention-политики
834
- (§ 10.8). Полное «право на забвение» = `anonymize()` сейчас + настроенный retention потом.
835
- Не-string поле в списке — ошибка (типы сверяются по Schema).
1170
+ Физического стирания `anonymize()` НЕ делает — история священна: старые версии хранят PII до
1171
+ retention-политики (§ 10.8). Три уровня «права на забвение»: `anonymize()` (затереть PII, запись
1172
+ живёт) → retention (снос по времени) → **`.purge({ confirm })`** — немедленный физический hard-erase
1173
+ удалённого (tombstone) + всего поддерева, а `db.accounts.purge(id)` — целого тенанта (см. § 11).
1174
+ Не-string поле в списке `anonymize` — ошибка (типы сверяются по Schema).
836
1175
 
837
1176
  ### 10.8 Политики хранения: `db/policies.mjs`
838
1177
 
1178
+ > `db/policies.mjs` живёт в репозитории (корневой `db/`), **в npm-пакет НЕ входит** — запускать из клона. Логика — чистый Timescale SQL (`add_compression_policy` / `add_retention_policy`), при желании вызывается напрямую тем же DSN.
1179
+
839
1180
  ```bash
840
1181
  node db/policies.mjs --dsn=… --schema=v1.booking --compress-after=30d # сжатие (история цела)
841
1182
  node db/policies.mjs --dsn=… --schema=v1.booking --retain=2y # + retention (drop навсегда!)
@@ -852,6 +1193,8 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
852
1193
 
853
1194
  ### 10.9 Миграции классов: `scripts/schema-sync.mjs`
854
1195
 
1196
+ > Входит в npm-пакет (`files: ["scripts"]`); в установке путь — `node_modules/letopis/scripts/schema-sync.mjs`. Зависимости — `postgres` и `fastest-validator` (обе — deps пакета), `tsx` не нужен.
1197
+
855
1198
  ```bash
856
1199
  node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
857
1200
  # schema-sync: … ↔ booking.Schema (partition entity)
@@ -884,6 +1227,8 @@ const db = await connect({
884
1227
 
885
1228
  ### 10.11 TS-типы из Schema: `scripts/gen-types.mjs`
886
1229
 
1230
+ > Входит в npm-пакет; чистый Node-ESM (только `postgres`), `tsx` не нужен — в установке `node node_modules/letopis/scripts/gen-types.mjs …`.
1231
+
887
1232
  ```bash
888
1233
  npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d.ts
889
1234
  ```
@@ -2222,7 +2567,41 @@ await db.accounts.delete(SYS) // [6.0 ms]
2222
2567
  ```
2223
2568
 
2224
2569
  **Кейс:** чистка мусорной регистрации — удалять можно только то, что не оставило следов;
2225
- след есть → `enabled: false` вместо удаления.
2570
+ след есть → `enabled: false` вместо удаления, либо `db.accounts.purge(id)` — полный офбординг ниже.
2571
+
2572
+ #### `db.accounts.purge(id): Promise<boolean>`
2573
+
2574
+ Полный физический **офбординг тенанта** (необратимо): все `Entity` с `account = id` ИЛИ `owner = id`
2575
+ + сам `Account` (`Credential` — FK-каскад). Освобождает `entity_account_fk`/`entity_owner_fk` (RESTRICT),
2576
+ которые блокируют обычный `delete`. Внутри — серверная `purge_account()` (флаг отключает append-only-триггер).
2577
+ Предохранители (до сноса): вызывать может лишь **Owner/System**; нельзя снести **последний enabled Owner**
2578
+ (лок-аут тенанта) и **свой** аккаунт сессии.
2579
+
2580
+ ```ts
2581
+ const dbSys = await connect({ dsn, schema: 'v1.notify', account: SYS })
2582
+ await dbSys.accounts.purge(tenantId) // → true: Entity + Account + Credential снесены, место освобождено
2583
+ ```
2584
+
2585
+ #### `db.schema.define(def)` / `db.reloadSchema()` — живой подхват правок схемы
2586
+
2587
+ Определения классов живут в таблице `Schema`. Меняешь их **простым SQL** (или `db.schema.define(...)`)
2588
+ → `db.reloadSchema()` пересобирает реестр (и ACL-резолвер) **без реконнекта**.
2589
+
2590
+ ```ts
2591
+ // правка простым SQL + перечитывание (напр. новое значение enum)
2592
+ await db.sql.unsafe(`UPDATE "v1.notify"."Schema"
2593
+ SET attributes = jsonb_set(attributes, '{status,values}', '["queued","sent","paused"]')
2594
+ WHERE id = 'Delivery'`)
2595
+ await db.reloadSchema() // запись со status:'paused' теперь проходит валидацию
2596
+
2597
+ // или спец-метод (сам перечитывает)
2598
+ await db.schema.define({ id: 'Coupon', alias: 'Купон', category: 'HUB',
2599
+ attributes: { id: { type: 'uuid', generate: 7 }, code: { type: 'string' } } })
2600
+ ```
2601
+
2602
+ `up()` при существующей схеме seed **не** перезаливает → правка в `Schema` переживает рестарт (клоббер только
2603
+ при явном re-run старого seed через `db/apply.mjs`/`fresh`). Бамп версии (`vN.*`) — **новый пустой** namespace
2604
+ с переливом данных, НЕ инструмент для аддитивной правки определения (новое значение enum/поле/класс).
2226
2605
 
2227
2606
  #### `db.credentials.find(f?): Promise<Credential[]>`
2228
2607
 
package/dist/chain.d.ts CHANGED
@@ -33,6 +33,11 @@ export interface ChainCore {
33
33
  asOf(t: string | Date): Chain;
34
34
  /** Keyset-пагинация: строго после курсора (см. cursorOf). Требует .sort(). */
35
35
  after(cursor: import('./types.js').Cursor): Chain;
36
+ /**
37
+ * Включить в выдачу последнего шага удалённые (tombstone). Снимает ТОЛЬКО фильтр deleted —
38
+ * изоляция арендатора (enforceAccount) и ACL (enforceAcl) остаются в силе.
39
+ */
40
+ withDeleted(): Chain;
36
41
  /** ВСЕ версии сущностей последнего шага (включая tombstone → $deleted), по возрастанию updated. */
37
42
  versions(): Promise<Row[]>;
38
43
  /** Рекурсивный self-обход: дети любой глубины (тот же класс), $depth в Row. */
@@ -89,6 +94,15 @@ export interface ChainCore {
89
94
  delete(opts?: {
90
95
  confirm?: boolean;
91
96
  }): Chain;
97
+ /**
98
+ * ФИЗИЧЕСКИЙ hard-erase (необратимо): сносит УЖЕ логически удалённые (tombstone) цели + всё
99
+ * поддерево по links (все версии). Живую сущность не трогает — сначала .delete(). { confirm: true }
100
+ * — стирает; без confirm — превью замыкания (что сотрётся), БД не тронута. Историю НЕ сохраняет
101
+ * (в отличие от .delete()). Реализуется серверной purge() (SET LOCAL letopis.purge отключает триггер).
102
+ */
103
+ purge(opts?: {
104
+ confirm?: boolean;
105
+ }): Chain;
92
106
  }
93
107
  /**
94
108
  * Свойство-класс на цепочке:
@@ -126,6 +140,11 @@ export interface DbCore {
126
140
  watch(cb: (e: WatchEvent) => void, opts?: WatchOpts): Promise<() => void>;
127
141
  watch(cls: string, cb: (e: WatchEvent) => void, opts?: WatchOpts): Promise<() => void>;
128
142
  close(): Promise<void>;
143
+ /**
144
+ * Перечитать определения классов из таблицы Schema (после правки её простым SQL / db.schema.define):
145
+ * пересобирает registry и, при enforceAcl, ACL-резолвер — без реконнекта. Подхват «сразу».
146
+ */
147
+ reloadSchema(): Promise<void>;
129
148
  registry: Registry;
130
149
  /** Голый postgres-клиент (тесты, EXPLAIN). */
131
150
  sql: Ctx['sql'];
@@ -134,6 +153,8 @@ export interface DbCore {
134
153
  credentials: Tables['credentials'];
135
154
  resources: Tables['resources'];
136
155
  rules: Tables['rules'];
156
+ /** Определения классов: db.schema.define(def) — upsert в таблицу Schema + reloadSchema(). */
157
+ schema: Tables['schema'];
137
158
  /** Вход по кредам (пароль/api-key/key-secret/внешние identity) + сессии в Redis. */
138
159
  auth: AuthApi;
139
160
  /** ACL по Resource/Rule: check(эндпоинт) / checkData(класс, READ|WRITE|DELETE) / reload. */
package/dist/chain.js CHANGED
@@ -354,6 +354,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
354
354
  return (field, dir) => withMods({ order: field, desc: dir === true || dir === 'desc' });
355
355
  case 'asOf':
356
356
  return (t) => withMods({ asOf: t instanceof Date ? t.toISOString() : t });
357
+ case 'withDeleted':
358
+ return () => withMods({ withDeleted: true });
357
359
  case 'deep':
358
360
  return (max = 32) => withLast({ deepMax: max });
359
361
  // END_BLOCK_READ_MODIFIERS
@@ -434,6 +436,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
434
436
  return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, mods });
435
437
  case 'anonymize':
436
438
  return (fields) => withOp({ kind: 'anonymize', fields, mods });
439
+ case 'purge':
440
+ return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true, mods });
437
441
  // END_BLOCK_WRITE_TERMINALS
438
442
  // START_BLOCK_COLUMN_MODS
439
443
  case 'alias':
@@ -512,7 +516,7 @@ export function makeDb(ctx, root) {
512
516
  if (typeof prop === 'symbol' || prop === 'then')
513
517
  return undefined;
514
518
  // START_BLOCK_TABLES_AUTH_ACL
515
- if (prop === 'accounts' || prop === 'credentials' || prop === 'resources' || prop === 'rules') {
519
+ if (prop === 'accounts' || prop === 'credentials' || prop === 'resources' || prop === 'rules' || prop === 'schema') {
516
520
  tables ??= makeTables(ctx);
517
521
  return tables[prop];
518
522
  }
@@ -607,6 +611,11 @@ export function makeDb(ctx, root) {
607
611
  // START_BLOCK_CLOSE_META
608
612
  case 'close':
609
613
  return () => ctx.sql.end();
614
+ case 'reloadSchema':
615
+ return async () => {
616
+ await ctx.reload?.();
617
+ acl = undefined; // db.acl-фасад перестроится на свежем registry при следующем доступе
618
+ };
610
619
  case 'registry':
611
620
  return ctx.registry;
612
621
  case 'sql':
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
15
15
  export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
16
16
  export type { Row, Path, Filter, ChainMods, Cursor, ConnectOpts, QueryEvent, Account, Credential, Resource, Rule, AclOp, AclDecision, } from './types.js';
17
17
  export type { EntityDb, EntityTx, Chain, Batch, WatchEvent, WatchOpts } from './chain.js';
18
- export type { Tables, AccountsApi, CredentialsApi, ResourcesApi, RulesApi } from './tables.js';
18
+ export type { Tables, AccountsApi, CredentialsApi, ResourcesApi, RulesApi, SchemaApi } from './tables.js';
19
19
  export { totpCode } from './auth.js';
20
20
  export type { AuthApi, AuthResult } from './auth.js';
21
21
  export type { AclApi } from './acl.js';