letopis 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +321 -1
- package/package.json +2 -1
- package/scripts/gen-types.mjs +154 -0
- package/scripts/schema-sync.mjs +185 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
Формат: [Keep a Changelog](https://keepachangelog.com/), версии — semver.
|
|
4
4
|
|
|
5
|
+
## [0.18.1] — 2026-07-13
|
|
6
|
+
|
|
7
|
+
### Fixed — упаковка: CLI-скрипты в npm-пакете
|
|
8
|
+
|
|
9
|
+
- **`package.json` `files` += `"scripts"`**: `scripts/schema-sync.mjs` (миграция классов)
|
|
10
|
+
и `scripts/gen-types.mjs` (TS-типы из Schema) теперь входят в npm-тарбол — раньше
|
|
11
|
+
документировались в README, но в пакет не попадали (жили только в git-репо). Проверено
|
|
12
|
+
`npm pack --dry-run`. Оба скрипта самодостаточны (зависимости `postgres` +
|
|
13
|
+
`fastest-validator`, `tsx` не нужен). Корневые `db/apply.mjs` / `db/policies.mjs`
|
|
14
|
+
остаются repo-only (лежат выше пакета `lib/`) — в README помечены как таковые, накат
|
|
15
|
+
в установке делает `up()`.
|
|
16
|
+
|
|
17
|
+
### Added — README: обзор фич + инструкция «своя схема с нуля»
|
|
18
|
+
|
|
19
|
+
- **Блок «Возможности»** в начале README — сгруппированный обзор всех возможностей
|
|
20
|
+
библиотеки (хранилище, схема, чтение, запись, история, auth/ACL, эксплуатация) со
|
|
21
|
+
ссылками на разделы.
|
|
22
|
+
- **§1.1 «Своя схема с нуля + всё в контейнере одной командой»**: пошаговая инструкция —
|
|
23
|
+
сид домена (System-аккаунт + классы) → `up({ seeds })` (контейнер + база + схема +
|
|
24
|
+
connect) → первые данные; гейты (`has no classes`, `Entity.account NOT NULL`),
|
|
25
|
+
изолированное окружение, prod / managed-PG путь.
|
|
26
|
+
- **§3.4 «Примеры схем из таблицы Schema»**: DDL таблицы `Schema`, формат строки-класса,
|
|
27
|
+
4 разобранных примера (вложенный object, наследуемый v5-id, союз + optional, record /
|
|
28
|
+
мультивалюта) и таблица всех 20 классов демо-домена booking с типовым вызовом.
|
|
29
|
+
|
|
5
30
|
## [0.18.0] — 2026-07-13
|
|
6
31
|
|
|
7
32
|
### 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. Чтение: цепочки
|
|
@@ -836,6 +1150,8 @@ await db.Клиент(id).anonymize(['name', 'phone']).rows()
|
|
|
836
1150
|
|
|
837
1151
|
### 10.8 Политики хранения: `db/policies.mjs`
|
|
838
1152
|
|
|
1153
|
+
> `db/policies.mjs` живёт в репозитории (корневой `db/`), **в npm-пакет НЕ входит** — запускать из клона. Логика — чистый Timescale SQL (`add_compression_policy` / `add_retention_policy`), при желании вызывается напрямую тем же DSN.
|
|
1154
|
+
|
|
839
1155
|
```bash
|
|
840
1156
|
node db/policies.mjs --dsn=… --schema=v1.booking --compress-after=30d # сжатие (история цела)
|
|
841
1157
|
node db/policies.mjs --dsn=… --schema=v1.booking --retain=2y # + retention (drop навсегда!)
|
|
@@ -852,6 +1168,8 @@ node db/policies.mjs --dsn=… --schema=v1.booking # тек
|
|
|
852
1168
|
|
|
853
1169
|
### 10.9 Миграции классов: `scripts/schema-sync.mjs`
|
|
854
1170
|
|
|
1171
|
+
> Входит в npm-пакет (`files: ["scripts"]`); в установке путь — `node_modules/letopis/scripts/schema-sync.mjs`. Зависимости — `postgres` и `fastest-validator` (обе — deps пакета), `tsx` не нужен.
|
|
1172
|
+
|
|
855
1173
|
```bash
|
|
856
1174
|
node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
|
|
857
1175
|
# schema-sync: … ↔ booking.Schema (partition entity)
|
|
@@ -884,6 +1202,8 @@ const db = await connect({
|
|
|
884
1202
|
|
|
885
1203
|
### 10.11 TS-типы из Schema: `scripts/gen-types.mjs`
|
|
886
1204
|
|
|
1205
|
+
> Входит в npm-пакет; чистый Node-ESM (только `postgres`), `tsx` не нужен — в установке `node node_modules/letopis/scripts/gen-types.mjs …`.
|
|
1206
|
+
|
|
887
1207
|
```bash
|
|
888
1208
|
npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d.ts
|
|
889
1209
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Letopis (летопись): append-only versioned entity store on TimescaleDB with dot-notation chains — every change is a new row, history is first-class (asOf, versions, watch, cascade tombstones)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"timescaledb",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"dist",
|
|
38
|
+
"scripts",
|
|
38
39
|
"sql",
|
|
39
40
|
"docker",
|
|
40
41
|
"README.md",
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Генерация TS-типов из таблицы Schema:
|
|
4
|
+
* npx tsx scripts/gen-types.mjs --dsn=postgres://… --schema=v1.booking [--out=entity-types.d.ts]
|
|
5
|
+
*
|
|
6
|
+
* Результат: интерфейсы data-полей каждого класса + фасад TypedDb.
|
|
7
|
+
* import type { TypedDb } from './entity-types';
|
|
8
|
+
* const t = db as unknown as TypedDb; // t.Сотрудник(...).rows(): Promise<TypedRow<StaffData>[]>
|
|
9
|
+
*/
|
|
10
|
+
//
|
|
11
|
+
// FILE: lib/scripts/gen-types.mjs
|
|
12
|
+
// VERSION: 1.0.0
|
|
13
|
+
// START_MODULE_CONTRACT
|
|
14
|
+
// PURPOSE: CLI-кодген — читает таблицу Schema и печатает типизированный .d.ts-фасад (Data-интерфейсы классов + TypedRow/TypedChain/TypedDb).
|
|
15
|
+
// SCOPE: парсинг аргументов, чтение Schema, конвертация fastest-validator DSL → TS (ts), эмиссия .d.ts.
|
|
16
|
+
// DEPENDS: none
|
|
17
|
+
// LINKS: M-GEN-TYPES, V-M-GEN-TYPES
|
|
18
|
+
// ROLE: SCRIPT
|
|
19
|
+
// MAP_MODE: LOCALS
|
|
20
|
+
// END_MODULE_CONTRACT
|
|
21
|
+
//
|
|
22
|
+
// START_MODULE_MAP
|
|
23
|
+
// ts - fastest-validator DSL → TS-тип ({ t, opt })
|
|
24
|
+
// ident - безопасный идентификатор либо строковый ключ
|
|
25
|
+
// sql/rows - чтение классов из таблицы Schema
|
|
26
|
+
// lines/facade - аккумуляторы генерируемого .d.ts
|
|
27
|
+
// END_MODULE_MAP
|
|
28
|
+
//
|
|
29
|
+
// START_CHANGE_SUMMARY
|
|
30
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
31
|
+
// END_CHANGE_SUMMARY
|
|
32
|
+
import { writeFile } from 'node:fs/promises';
|
|
33
|
+
import postgres from 'postgres';
|
|
34
|
+
|
|
35
|
+
// START_BLOCK_PARSE_ARGS
|
|
36
|
+
const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')));
|
|
37
|
+
const dsn = args.dsn ?? process.env.ENTITY_DSN;
|
|
38
|
+
const schema = args.schema ?? 'booking';
|
|
39
|
+
const out = args.out ?? 'entity-types.d.ts';
|
|
40
|
+
if (!dsn) { console.error('usage: tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking [--out=…]'); process.exit(1); }
|
|
41
|
+
|
|
42
|
+
// END_BLOCK_PARSE_ARGS
|
|
43
|
+
// START_CONTRACT: ts
|
|
44
|
+
// PURPOSE: Свести attribute-спеку fastest-validator (строка/массив/объект) к TS-типу и признаку optional.
|
|
45
|
+
// INPUTS: { attr: unknown - правило поля }
|
|
46
|
+
// OUTPUTS: { { t: string; opt: boolean } }
|
|
47
|
+
// SIDE_EFFECTS: none (рекурсивно по вложенным props/items)
|
|
48
|
+
// LINKS: M-GEN-TYPES, V-M-GEN-TYPES
|
|
49
|
+
// END_CONTRACT: ts
|
|
50
|
+
// fastest-validator DSL → TS-тип
|
|
51
|
+
function ts(attr) {
|
|
52
|
+
if (typeof attr === 'string') {
|
|
53
|
+
const parts = attr.split('|').map((s) => s.trim());
|
|
54
|
+
const opt = parts.includes('optional');
|
|
55
|
+
const base = { number: 'number', boolean: 'boolean', date: 'string', string: 'string', uuid: 'string', email: 'string', url: 'string', any: 'unknown' }[parts[0]] ?? 'unknown';
|
|
56
|
+
return { t: base, opt };
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(attr)) { const first = ts(attr[0] ?? 'any'); return { t: first.t, opt: true }; }
|
|
59
|
+
if (typeof attr === 'object' && attr !== null) {
|
|
60
|
+
const opt = attr.optional === true;
|
|
61
|
+
switch (attr.type) {
|
|
62
|
+
case 'enum': return { t: (attr.values ?? []).map((v) => JSON.stringify(v)).join(' | ') || 'string', opt };
|
|
63
|
+
case 'record': {
|
|
64
|
+
const key = attr.key?.type === 'enum' ? (attr.key.values ?? []).map((v) => JSON.stringify(v)).join(' | ') : 'string';
|
|
65
|
+
return { t: `Partial<Record<${key || 'string'}, number>>`, opt };
|
|
66
|
+
}
|
|
67
|
+
case 'array': {
|
|
68
|
+
const item = ts(attr.items ?? 'any');
|
|
69
|
+
return { t: `(${item.t})[]`, opt };
|
|
70
|
+
}
|
|
71
|
+
case 'number': return { t: 'number', opt };
|
|
72
|
+
case 'boolean': return { t: 'boolean', opt };
|
|
73
|
+
case 'date': return { t: 'string', opt };
|
|
74
|
+
case 'string': case 'uuid': case 'email': case 'url': return { t: 'string', opt };
|
|
75
|
+
case 'object': {
|
|
76
|
+
const props = attr.props ?? attr.properties;
|
|
77
|
+
if (props && typeof props === 'object') {
|
|
78
|
+
const fields = Object.entries(props)
|
|
79
|
+
.map(([k, v]) => { const f = ts(v); return `${ident(k)}${f.opt ? '?' : ''}: ${f.t}`; })
|
|
80
|
+
.join('; ');
|
|
81
|
+
return { t: `{ ${fields} }`, opt };
|
|
82
|
+
}
|
|
83
|
+
return { t: 'Record<string, unknown>', opt };
|
|
84
|
+
}
|
|
85
|
+
default: return { t: 'unknown', opt };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { t: 'unknown', opt: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ident = (s) => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : JSON.stringify(s));
|
|
92
|
+
|
|
93
|
+
// START_BLOCK_READ_SCHEMA
|
|
94
|
+
const sql = postgres(dsn, { max: 1 });
|
|
95
|
+
const rows = await sql.unsafe(
|
|
96
|
+
`SELECT id, alias, category, attributes, meta FROM "${schema.replaceAll('"', '""')}"."Schema" WHERE partition = 'entity' ORDER BY category, "order"`,
|
|
97
|
+
);
|
|
98
|
+
await sql.end();
|
|
99
|
+
|
|
100
|
+
// END_BLOCK_READ_SCHEMA
|
|
101
|
+
// START_BLOCK_EMIT_TYPES
|
|
102
|
+
const lines = [
|
|
103
|
+
'/* Сгенерировано scripts/gen-types.mjs — не редактировать вручную. */',
|
|
104
|
+
`import type { Row, Filter, Path, Cursor } from 'letopis';`,
|
|
105
|
+
'',
|
|
106
|
+
'export interface TypedRow<D> extends Omit<Row, \'data\'> { data: D }',
|
|
107
|
+
'',
|
|
108
|
+
];
|
|
109
|
+
const facade = [];
|
|
110
|
+
for (const r of rows) {
|
|
111
|
+
if (r.meta?.abstract === true || r.meta?.abstract === 'true') continue;
|
|
112
|
+
const name = `${r.id}Data`;
|
|
113
|
+
lines.push(`/** ${r.category} ${r.id} · ${r.alias} */`, `export interface ${name} {`);
|
|
114
|
+
for (const [f, attr] of Object.entries(r.attributes)) {
|
|
115
|
+
if (f === 'id') continue;
|
|
116
|
+
const { t, opt } = ts(attr);
|
|
117
|
+
lines.push(` ${ident(f)}${opt ? '?' : ''}: ${t};`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('}', '');
|
|
120
|
+
for (const key of [r.id, r.alias]) {
|
|
121
|
+
facade.push(` ${ident(key)}: (filter?: Filter) => TypedChain<${name}>;`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
lines.push(
|
|
125
|
+
'export interface TypedChain<D> {',
|
|
126
|
+
' execute(): Promise<Path[]>;',
|
|
127
|
+
' rows(): Promise<TypedRow<D>[]>;',
|
|
128
|
+
' first(): Promise<TypedRow<D> | null>;',
|
|
129
|
+
' ids(): Promise<string[]>;',
|
|
130
|
+
' count(): Promise<number>;',
|
|
131
|
+
' versions(): Promise<TypedRow<D>[]>;',
|
|
132
|
+
' set(data?: Partial<D> & { id?: string }): PromiseLike<TypedRow<D>[]> & Record<string, (t: string | { id: string }) => unknown>;',
|
|
133
|
+
' delete(): Promise<TypedRow<D>[]>;',
|
|
134
|
+
' anonymize(fields: (keyof D & string)[]): Promise<TypedRow<D>[]>;',
|
|
135
|
+
' limit(n: number): TypedChain<D>;',
|
|
136
|
+
' offset(n: number): TypedChain<D>;',
|
|
137
|
+
' sort(field: string, dir?: \'asc\' | \'desc\' | boolean): TypedChain<D>;',
|
|
138
|
+
' asOf(t: string | Date): TypedChain<D>;',
|
|
139
|
+
' after(c: Cursor): TypedChain<D>;',
|
|
140
|
+
' alias(name: string): TypedChain<D>;',
|
|
141
|
+
' tags(v: string | string[] | object): TypedChain<D>;',
|
|
142
|
+
' account(v: string | { id: string }): TypedChain<D>;',
|
|
143
|
+
' owner(v: string | { id: string }): TypedChain<D>;',
|
|
144
|
+
' [className: string]: unknown;',
|
|
145
|
+
'}',
|
|
146
|
+
'',
|
|
147
|
+
'export interface TypedDb {',
|
|
148
|
+
...facade,
|
|
149
|
+
'}',
|
|
150
|
+
'',
|
|
151
|
+
);
|
|
152
|
+
// END_BLOCK_EMIT_TYPES
|
|
153
|
+
await writeFile(out, lines.join('\n'), 'utf8');
|
|
154
|
+
console.log(`written ${out}: ${rows.length} classes`);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Миграция классов: diff JSON-файла схемы и таблицы Schema + отчёт совместимости.
|
|
4
|
+
* Ловит ломающие изменения ДО применения: новый валидатор прогоняется по живым
|
|
5
|
+
* latest-строкам каждого изменённого класса (строгая валидация, как в рантайме).
|
|
6
|
+
*
|
|
7
|
+
* node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
|
|
8
|
+
* node scripts/schema-sync.mjs --file=… --dsn=… --schema=v1.booking --apply
|
|
9
|
+
*
|
|
10
|
+
* Без --apply — только отчёт (exit 1, если есть несовместимые строки).
|
|
11
|
+
* Классы, отсутствующие в файле, НЕ удаляются (append-only дух; удаление — вручную).
|
|
12
|
+
* ancestors/descendants пересчитывает триггер schema_lineage.
|
|
13
|
+
*/
|
|
14
|
+
//
|
|
15
|
+
// FILE: lib/scripts/schema-sync.mjs
|
|
16
|
+
// VERSION: 1.0.0
|
|
17
|
+
// START_MODULE_CONTRACT
|
|
18
|
+
// PURPOSE: CLI-миграция схемы — diff JSON-файла и таблицы Schema, ревалидация выборки живых строк, upsert классов при --apply.
|
|
19
|
+
// SCOPE: парсинг аргументов, канон-сериализация (stable/linkEnd), diff added/changed/removed, проверка совместимости, apply.
|
|
20
|
+
// DEPENDS: none
|
|
21
|
+
// LINKS: M-SCHEMA-SYNC, V-M-SCHEMA-SYNC
|
|
22
|
+
// ROLE: SCRIPT
|
|
23
|
+
// MAP_MODE: LOCALS
|
|
24
|
+
// END_MODULE_CONTRACT
|
|
25
|
+
//
|
|
26
|
+
// START_MODULE_MAP
|
|
27
|
+
// stable - стабильная сериализация для сравнения
|
|
28
|
+
// linkEnd/linksCanon - канон элементов Schema.links
|
|
29
|
+
// FIELDS - поля класса для diff/upsert
|
|
30
|
+
// added/changed/removed - результат diff файла и БД
|
|
31
|
+
// breakingRows - живые строки, ломающиеся о новую валидацию
|
|
32
|
+
// END_MODULE_MAP
|
|
33
|
+
//
|
|
34
|
+
// START_CHANGE_SUMMARY
|
|
35
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
36
|
+
// END_CHANGE_SUMMARY
|
|
37
|
+
import { readFile } from 'node:fs/promises';
|
|
38
|
+
import { createRequire } from 'node:module';
|
|
39
|
+
import postgres from 'postgres';
|
|
40
|
+
|
|
41
|
+
const Validator = createRequire(import.meta.url)('fastest-validator');
|
|
42
|
+
const v = new Validator({ useNewCustomCheckerFunction: true });
|
|
43
|
+
|
|
44
|
+
// START_BLOCK_PARSE_ARGS
|
|
45
|
+
const args = Object.fromEntries(
|
|
46
|
+
process.argv.slice(2).map((a) => {
|
|
47
|
+
const m = a.match(/^--([^=]+)(?:=(.*))?$/);
|
|
48
|
+
return m ? [m[1], m[2] ?? true] : [a, true];
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
const { file, schema } = args;
|
|
52
|
+
const dsn = args.dsn ?? process.env.ENTITY_DSN;
|
|
53
|
+
const partition = args.partition ?? 'entity';
|
|
54
|
+
const sample = Number(args.sample ?? 200);
|
|
55
|
+
if (!file || !dsn || !schema || typeof schema !== 'string') {
|
|
56
|
+
console.error('usage: node scripts/schema-sync.mjs --file=schema.json --dsn=… --schema=<pg_schema> [--partition=entity] [--sample=200] [--apply]');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// END_BLOCK_PARSE_ARGS
|
|
61
|
+
const ident = `"${schema.replaceAll('"', '""')}"`;
|
|
62
|
+
/** Поля класса, участвующие в diff и upsert (lineage-колонки считает триггер). */
|
|
63
|
+
const FIELDS = ['alias', 'category', 'ancestor', 'attributes', 'links', 'meta', 'order'];
|
|
64
|
+
|
|
65
|
+
// START_CONTRACT: stable
|
|
66
|
+
// PURPOSE: Стабильная сериализация значения (сортировка ключей на всех уровнях) для сравнения diff.
|
|
67
|
+
// INPUTS: { x: unknown }
|
|
68
|
+
// OUTPUTS: { string - канонический вид }
|
|
69
|
+
// SIDE_EFFECTS: none
|
|
70
|
+
// LINKS: M-SCHEMA-SYNC, V-M-SCHEMA-SYNC
|
|
71
|
+
// END_CONTRACT: stable
|
|
72
|
+
/** Стабильная сериализация для сравнения (сортировка ключей на всех уровнях). */
|
|
73
|
+
function stable(x) {
|
|
74
|
+
if (Array.isArray(x)) return `[${x.map(stable).join(',')}]`;
|
|
75
|
+
if (typeof x === 'object' && x !== null)
|
|
76
|
+
return `{${Object.keys(x).sort().map((k) => `${JSON.stringify(k)}:${stable(x[k])}`).join(',')}}`;
|
|
77
|
+
return JSON.stringify(x ?? null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// START_CONTRACT: linkEnd
|
|
81
|
+
// PURPOSE: Привести элемент links к канону (конец v2: в БД JSON-текст в text[], в файле — объект).
|
|
82
|
+
// INPUTS: { e: string|object }
|
|
83
|
+
// OUTPUTS: { string }
|
|
84
|
+
// SIDE_EFFECTS: none
|
|
85
|
+
// LINKS: M-SCHEMA-SYNC, V-M-SCHEMA-SYNC
|
|
86
|
+
// END_CONTRACT: linkEnd
|
|
87
|
+
/** Элемент links к канону: в БД конец v2 лежит JSON-текстом в text[], в файле — объектом. */
|
|
88
|
+
function linkEnd(e) {
|
|
89
|
+
if (typeof e === 'string' && e.trimStart().startsWith('{')) return stable(JSON.parse(e));
|
|
90
|
+
return typeof e === 'string' ? JSON.stringify(e) : stable(e);
|
|
91
|
+
}
|
|
92
|
+
const linksCanon = (arr) => `[${(arr ?? []).map(linkEnd).join(',')}]`;
|
|
93
|
+
|
|
94
|
+
const fileClasses = JSON.parse(await readFile(file, 'utf8'));
|
|
95
|
+
const sql = postgres(dsn, { max: 1 });
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// START_BLOCK_DIFF
|
|
99
|
+
const dbRows = await sql.unsafe(
|
|
100
|
+
`SELECT id, alias, category, ancestor, attributes, links, meta, "order" FROM ${ident}."Schema" WHERE partition = $1`,
|
|
101
|
+
[partition],
|
|
102
|
+
);
|
|
103
|
+
const inDb = new Map(dbRows.map((r) => [r.id, r]));
|
|
104
|
+
const inFile = new Map(fileClasses.map((c) => [c.id, c]));
|
|
105
|
+
|
|
106
|
+
const added = fileClasses.filter((c) => !inDb.has(c.id));
|
|
107
|
+
const removed = dbRows.filter((r) => !inFile.has(r.id));
|
|
108
|
+
const changed = [];
|
|
109
|
+
for (const c of fileClasses) {
|
|
110
|
+
const db = inDb.get(c.id);
|
|
111
|
+
if (!db) continue;
|
|
112
|
+
const diff = FIELDS.filter((f) =>
|
|
113
|
+
f === 'links'
|
|
114
|
+
? linksCanon(c.links) !== linksCanon(db.links)
|
|
115
|
+
: stable(c[f] ?? (f === 'order' ? 0 : f === 'ancestor' ? null : {})) !== stable(db[f]));
|
|
116
|
+
if (diff.length) changed.push({ cls: c, fields: diff });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// END_BLOCK_DIFF
|
|
120
|
+
console.log(`schema-sync: ${file} ↔ ${schema}.Schema (partition ${partition})`);
|
|
121
|
+
for (const c of added) console.log(` + ${c.id} (${c.category} · ${c.alias}) — новый класс`);
|
|
122
|
+
for (const { cls, fields } of changed) console.log(` ~ ${cls.id} — изменены: ${fields.join(', ')}`);
|
|
123
|
+
for (const r of removed) console.log(` - ${r.id} — в файле отсутствует (НЕ удаляется)`);
|
|
124
|
+
if (!added.length && !changed.length && !removed.length) console.log(' без изменений');
|
|
125
|
+
|
|
126
|
+
// совместимость: живые latest-строки изменённых классов против НОВОЙ строгой валидации
|
|
127
|
+
// START_BLOCK_COMPAT_CHECK
|
|
128
|
+
let breakingRows = 0;
|
|
129
|
+
for (const { cls, fields } of changed) {
|
|
130
|
+
if (!fields.includes('attributes')) continue;
|
|
131
|
+
const check = v.compile({ ...cls.attributes, $$strict: true });
|
|
132
|
+
const rows = await sql.unsafe(
|
|
133
|
+
`SELECT t.id, t.data FROM (
|
|
134
|
+
SELECT DISTINCT ON (e.id) e.id, e.data, e.deleted FROM ${ident}."Entity" e
|
|
135
|
+
WHERE e.partition = $1 AND e.class = $2
|
|
136
|
+
ORDER BY e.id, e.updated DESC
|
|
137
|
+
) t WHERE t.deleted IS NULL LIMIT $3`,
|
|
138
|
+
[partition, cls.id, sample],
|
|
139
|
+
);
|
|
140
|
+
const bad = [];
|
|
141
|
+
for (const r of rows) {
|
|
142
|
+
const res = check({ ...r.data, id: r.id });
|
|
143
|
+
if (res !== true) bad.push({ id: r.id, issues: res });
|
|
144
|
+
}
|
|
145
|
+
if (bad.length) {
|
|
146
|
+
breakingRows += bad.length;
|
|
147
|
+
console.log(` ! ${cls.id}: ${bad.length}/${rows.length} живых строк НЕ пройдут новую валидацию:`);
|
|
148
|
+
for (const b of bad.slice(0, 3)) {
|
|
149
|
+
console.log(` id=${b.id} → ${b.issues.map((i) => `${i.field} — ${i.message ?? 'invalid'}`).join('; ')}`);
|
|
150
|
+
}
|
|
151
|
+
if (bad.length > 3) console.log(` … и ещё ${bad.length - 3}`);
|
|
152
|
+
} else if (rows.length) {
|
|
153
|
+
console.log(` ✓ ${cls.id}: ${rows.length} живых строк проходят новую валидацию`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// END_BLOCK_COMPAT_CHECK
|
|
158
|
+
// START_BLOCK_APPLY
|
|
159
|
+
if (!args.apply) {
|
|
160
|
+
if (breakingRows) {
|
|
161
|
+
console.log(`ИТОГ: ломающие изменения (${breakingRows} строк). Применение (--apply) сломает запись этих сущностей.`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
} else {
|
|
164
|
+
console.log(`ИТОГ: ${added.length} новых, ${changed.length} изменённых. Применить: --apply`);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
if (breakingRows) console.warn(`ВНИМАНИЕ: применяю НЕсовместимую схему (${breakingRows} строк перестанут проходить валидацию при записи).`);
|
|
168
|
+
for (const c of [...added, ...changed.map((x) => x.cls)]) {
|
|
169
|
+
await sql.unsafe(
|
|
170
|
+
`INSERT INTO ${ident}."Schema" (partition, id, alias, category, ancestor, attributes, links, meta, "order")
|
|
171
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
172
|
+
ON CONFLICT (partition, id) DO UPDATE SET
|
|
173
|
+
alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor,
|
|
174
|
+
attributes = EXCLUDED.attributes, links = EXCLUDED.links, meta = EXCLUDED.meta, "order" = EXCLUDED."order"`,
|
|
175
|
+
// links — jsonb-массив (объекты-концы v2 / legacy-строки); postgres.js сериализует сам
|
|
176
|
+
[partition, c.id, c.alias, c.category, c.ancestor ?? null, c.attributes ?? {},
|
|
177
|
+
c.links ?? [], c.meta ?? {}, c.order ?? 0],
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
console.log(`применено: ${added.length} новых, ${changed.length} изменённых классов`);
|
|
181
|
+
}
|
|
182
|
+
// END_BLOCK_APPLY
|
|
183
|
+
} finally {
|
|
184
|
+
await sql.end();
|
|
185
|
+
}
|