letopis 0.20.3 → 1.0.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/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -250
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -351
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -600
- package/sql/seed.auth.sql +0 -73
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,354 @@
|
|
|
2
2
|
|
|
3
3
|
Формат: [Keep a Changelog](https://keepachangelog.com/), версии — semver.
|
|
4
4
|
|
|
5
|
+
## [1.0.0] — 2026-10-09
|
|
6
|
+
|
|
7
|
+
letopis 1.0 — новая реализация хранилища по плану `reports/PLAN-LETOPIS-1.0.md` (редакция 8), основанному на
|
|
8
|
+
предложении «Свод» (`reports/PROPOSAL-SVOD.md`). Целостность, история, изоляция арендаторов и права проверяет
|
|
9
|
+
сама база — и для библиотеки, и для сырого SQL. Переход с 0.21 — [MIGRATION.md](MIGRATION.md): импорт данных
|
|
10
|
+
командой `letopis import` и таблица замен API. Подробная запись по этапам — ниже, в `[1.0.0-alpha.0]`.
|
|
11
|
+
|
|
12
|
+
### BREAKING — платформа и хранение
|
|
13
|
+
- PostgreSQL 18 и новее, Node 22 и новее. TimescaleDB, Redis и Docker не нужны; рантайм-зависимость одна —
|
|
14
|
+
`postgres`; fastest-validator заменён JSON Schema, модели на Zod 4 — необязательная peer-зависимость
|
|
15
|
+
(подпуть `letopis/model`).
|
|
16
|
+
- Три таблицы: `entity` (текущие версии), `log` (журнал) и приватная `secret` (креды и сессии) вместо `Schema`,
|
|
17
|
+
`Entity`, `Account`, `Credential`, `Resource`, `Rule`. Классы, аккаунты, ресурсы и правила — строки системных
|
|
18
|
+
классов. Колонки `partition` нет: отдельные пространства — отдельные схемы `v2.<имя>`. Схема 0.21 на месте не
|
|
19
|
+
обновляется — данные переносит `letopis import`.
|
|
20
|
+
- Журнал без партиций и политик TimescaleDB; хранение истории — политика «все версии — по дню — по неделе — по
|
|
21
|
+
месяцу — по году» (`history: { all, daily, weekly, monthly, yearly, tz }`), прореживает `maintain()`.
|
|
22
|
+
`compact()` снят: обрезка — `db.trimHistory()`, отказ от истории — `history: false`, который теперь отключает
|
|
23
|
+
и `versions`, `asOf`, `restore`, события подписки.
|
|
24
|
+
- `up()` без Docker и без сидов по умолчанию: демо-домен — только `seeds: ['booking']`; схема — `v2.<имя>`.
|
|
25
|
+
|
|
26
|
+
### BREAKING — идентификаторы и уникальность
|
|
27
|
+
- Только v5: id объекта класса с ключом — v5 от имени класса и значений ключа как есть в пространстве арендатора
|
|
28
|
+
(`idOf`); у класса без ключа id выдаёт база. v4 и v7, `generate` и `from` ушли; `uuidv7()` и `LETOPIS_NS` —
|
|
29
|
+
охранники `removed`. Переданный id должен совпасть с вычисленным (`id_mismatch`); класс без ключа id не
|
|
30
|
+
принимает (`id_from_db`).
|
|
31
|
+
- Уникальность — только ключ, правил `unique` нет; неявного ключа связи по её концам нет.
|
|
32
|
+
- Ключевые поля, id, арендатор и владелец обычной правкой не меняются (`immutable_key`): новый ключ — `rekey()`
|
|
33
|
+
или `db.rekeyClass()`, оба выдают новые id и переводят ссылки. В 0.21 правка ключа проходила, но id переставал
|
|
34
|
+
ему соответствовать.
|
|
35
|
+
- Смена класса — глагол `reclass()`: у класса без ключа id сохраняется, у класса с ключом вычисляется заново,
|
|
36
|
+
ссылки переводятся; правка класса обычной записью — `use_reclass`.
|
|
37
|
+
|
|
38
|
+
### BREAKING — запись и транзакции
|
|
39
|
+
- `create` — обычная вставка: занятый ключ — `exists` (`detail.id`), а не идемпотентный PUT. Замена целиком —
|
|
40
|
+
отдельный глагол `upsert`, только для класса с ключом (`no_key`) и только целым объектом.
|
|
41
|
+
- `update` по-прежнему не создаёт; затронуто меньше строк, чем нашёл шаг, — откат плана с `target_not_found`,
|
|
42
|
+
`conflict` или `acl_denied`.
|
|
43
|
+
- Ошибка любого запроса в `db.begin()` ломает транзакцию (в том числе `exists` и `conflict`): следующие вызовы и
|
|
44
|
+
`commit()` бросают исходную ошибку, `rollback()` проходит; обрыв связи во время `COMMIT` — `commit_unknown`.
|
|
45
|
+
В 0.21 повторный `commit` после ошибки молча проходил.
|
|
46
|
+
- Каскад удаления — в триггере после оператора; удаление и создание одного id одним оператором —
|
|
47
|
+
`recreated_in_statement`; удаление, смена класса и ключа — только в `read committed` (`isolation_level`).
|
|
48
|
+
- `db.as()` асинхронный и принимает арендатора: `await db.as(аккаунт, { tenant, owner })`.
|
|
49
|
+
- `batch.execute()` — охранник с заменой `batch.run()`.
|
|
50
|
+
|
|
51
|
+
### BREAKING — чтение
|
|
52
|
+
- `count()` и агрегаты считают сущности последнего шага, а не пути; пути — `count({ paths: true })`; `run()` и
|
|
53
|
+
`execute()` сняты — пути отдаёт `paths()`.
|
|
54
|
+
- `account()` снят: арендатор — свойство сессии (`connect({ token | apiKey })`, `db.as(…, { tenant })`,
|
|
55
|
+
`db.auth.switch()`).
|
|
56
|
+
- Строка ответа: `tenant` вместо `account`, `at` вместо `updated`; новые поля `rev`, `author`, `op`, `agent`,
|
|
57
|
+
`reason`, `moved`.
|
|
58
|
+
|
|
59
|
+
### BREAKING — доступ
|
|
60
|
+
- Изоляция арендаторов и права действуют всегда — политиками RLS, для библиотеки и сырого SQL; права по умолчанию
|
|
61
|
+
запрещают. Опции `connect()` `partition`, `enforceAccount`, `enforceAcl`, `container`, `image`, `dataDir`,
|
|
62
|
+
`redisPort`, `version` — охранники `removed`. Роль, обходящая RLS, подключается только с `allowBypassRls`
|
|
63
|
+
(иначе `bypass_rls`).
|
|
64
|
+
- Сессии — в PostgreSQL: `db.auth.sessions(store)` снят (`sessionFor`, `refresh`, `revoke`, `revokeAll`);
|
|
65
|
+
`enabled: false` гасит и живые сессии.
|
|
66
|
+
- Фасады `db.accounts`, `db.credentials`, `db.resources`, `db.rules` сняты — системные классы `Account`,
|
|
67
|
+
`Resource`, `rule`, `member` и `db.auth`; `accounts.purge` — `db.auth.purgeAccount()`. `db.reloadSchema()` и
|
|
68
|
+
`db.acl.reload()` сняты: реестр и права обновляются сами по сигналу базы (принудительно — `db.refresh()`).
|
|
69
|
+
- Системный сид — только общие группы и системные права; ресурсы продуктов (`chrono`, `payment`, `billing`,
|
|
70
|
+
`geo`, `shop`) в библиотеку не входят.
|
|
71
|
+
|
|
72
|
+
### BREAKING — события, классы, ошибки
|
|
73
|
+
- `watch(cb)` и `watch(класс, cb)` сняты: `db.watch({ from, classes })` — асинхронный итератор с курсором, без
|
|
74
|
+
потерь и повторов.
|
|
75
|
+
- Классы описываются моделями на Zod (`hub`, `link`, `one`, `many`) или строками `Class` с JSON Schema;
|
|
76
|
+
`db.schema.define()` и CLI `schema-sync`, `gen-types` заменены `letopis sync` и `letopis types`; `db.schema` —
|
|
77
|
+
имя схемы.
|
|
78
|
+
- Зарезервированы только имена методов цепочки и корня (`RESERVED_CLASS_NAMES`), а не 52 имени 0.21.
|
|
79
|
+
- Ошибки — `LetopisError`, текст `letopis: <код>: <сообщение>`, строчный код в `err.code`; ошибки базы —
|
|
80
|
+
SQLSTATE `LT001`–`LT020` (`error_catalog()`).
|
|
81
|
+
|
|
82
|
+
### Отличия от предложения «Свод» (§3 плана)
|
|
83
|
+
PostgreSQL 18 вместо 15; одна приватная таблица `secret` вместо `credential` и `session`; журнал без партиций с
|
|
84
|
+
политикой хранения; v5 — класс и значения ключа как есть, без канонизации и неявного ключа связи; только ключ
|
|
85
|
+
вместо правил `unique`; `create` и `upsert` — разные глаголы; `rekey` для ключевых полей; приращение `inc`;
|
|
86
|
+
`{ rev }` с кодом `conflict` и правилом одной цели; сломанная транзакция и `commit_unknown`; `forUpdate()`;
|
|
87
|
+
загрузчик через `COPY` от роли владельца; индексы под RLS и срезы `asOf` через функции владельца `find_ids()`;
|
|
88
|
+
план прав вместо таблицы-снимка; журнал защищён правами, а не триггерами; каскад после оператора; хэш —
|
|
89
|
+
генерируемая колонка; проверка данных компиляцией JSON Schema в jsonpath; `NOTIFY` с именем класса; кэш плана и
|
|
90
|
+
результатов; свободный объект `meta`; имперсонация через `letopis.account`; продление сессии отдельным вызовом;
|
|
91
|
+
выпуск — letopis 1.0.0, а не новый пакет.
|
|
92
|
+
|
|
93
|
+
### Added
|
|
94
|
+
- Ядро в базе (этап 1): триггеры проверок, ссылок, ключей, умолчаний и журнала; `merge`, `inc`, каскад
|
|
95
|
+
`cascade`/`unset`/`restrict`, `reclass()`, `verify()` с якорем; роли `letopis_owner` и `letopis_app`; установка
|
|
96
|
+
без суперпользователя.
|
|
97
|
+
- Модели и реестр (этап 2): `letopis/model`, нормализатор JSON Schema, v5 в TypeScript (`uuidv5`, `idOf`),
|
|
98
|
+
валидатор на TypeScript с теми же нарушениями, что в базе, `connect()` под сессией, `letopis sync` и `types`.
|
|
99
|
+
- Чтение (этап 3): шаги-роли, полиморфные и множественные концы, 18 операторов, keyset, агрегаты, `deep`,
|
|
100
|
+
`asOf`, `withDeleted`; функции владельца `find_ids()`, `find_hop()`, `find_deep()` — индексы под RLS.
|
|
101
|
+
- Запись (этап 4): `upsert`, `inc(n, { start })`, `update(patch, { rev })`, `forUpdate()`, слоты `add`/`remove`
|
|
102
|
+
множественных концов, батч со склейкой вставок, загрузчик `db.load()` и `letopis load`.
|
|
103
|
+
- Доступ (этап 5): правила `Resource` и `rule` в базе, членство `member`, имперсонация, креды и сессии в
|
|
104
|
+
`secret`, подписанный пропуск прав, `db.acl.check()` и `checkData()`.
|
|
105
|
+
- Время и эксплуатация (этап 6): `versions({ follow })`, `restore()`, `purge({ confirm })`, `trimHistory`,
|
|
106
|
+
`rekey`, `rekeyClass`, политика хранения, `maintain()`, `reset()`, подписка по курсору, кэш результатов, `up()`
|
|
107
|
+
без Docker и PGlite для разработки, `describe()` и `describeText()`.
|
|
108
|
+
- Импорт из 0.21 (этап 7): `letopis import` с проверками до первой записи и отчётом; бенч против 0.21.
|
|
109
|
+
- Руководство по переходу `MIGRATION.md` (входит в пакет) и шпаргалка агента 1.0.
|
|
110
|
+
|
|
111
|
+
### Changed
|
|
112
|
+
- Доводка контрольной точки Б (ревизия движка 5): индекс журнала `log_ends` ускорил срез `asOf` по переходу в
|
|
113
|
+
16 раз; чтение `letopis.changed` и `COMMIT` — одним конвейером.
|
|
114
|
+
- Второе ревью безопасности (этап 5): креды привилегированных аккаунтов, недавний вход, лимиты OTP и TOTP,
|
|
115
|
+
потолок веса правил арендатора.
|
|
116
|
+
|
|
117
|
+
### Added — после этапа 8 (решение владельца на точке В)
|
|
118
|
+
- `up({ tenant })` и `createTenant(sql, schema, { name, grantAll, user })`: первый арендатор (аккаунт в System, правило «аутентифицированным можно всё») и его пользователь (членство с ролью `owner`, пароль, сессия в арендаторе) без своего сида; повтор ничего не дублирует (id — v5 от имени и логина).
|
|
119
|
+
- `user.roles` в `up({ tenant })` и `createTenant`: роли членства пользователя, по умолчанию `['owner']`; непустой список имён (не масок), иначе `invalid_data`. Роли уже существующего членства повтор не меняет.
|
|
120
|
+
- `verifyPassword({ …, totp })` и `lookup({ …, totp })`: вход со вторым фактором. После верного пароля или найденной учётной записи база проверяет код той же функцией, что `verifyTotp` (`auth_verify_totp`: право `auth.totp`, окно — шаг в каждую сторону, повтор шага отклоняется, счётчик неудач и блокировка общие), и выдаёт сессию со способами `[вид, 'TOTP']` — по ней пользователь с TOTP меняет свои креды сам. Неверный или повторный код и код у аккаунта без TOTP — `null`; удачный код включает ещё не подтверждённый фактор. Без `totp` — как раньше.
|
|
121
|
+
|
|
122
|
+
### Documentation — после этапа 8 (решение владельца)
|
|
123
|
+
- `README.md` пакета переписан как руководство по задачам: оглавление, сквозная нумерация разделов, один пример (магазин) от установки до эксплуатации, рецепт кошелька, справочник API, ошибки с SQLSTATE, ограничения.
|
|
124
|
+
- Английские версии: `README.en.md`, `AGENT-CHEATSHEET.en.md` и корневой `README.en.md`.
|
|
125
|
+
- В пакет входят шпаргалка агента `AGENT-CHEATSHEET.md` (перенесена из корня репозитория в `lib/`) и `llms.txt` — указатель для языковых моделей.
|
|
126
|
+
- Тест `docs`: помеченные примеры README, шпаргалки и корневого README (обе версии) исполняются против PostgreSQL 18, а результаты, обещанные в комментариях примеров, сверяются.
|
|
127
|
+
- `check-docs` ищет разделы README по заголовку, проверяет якоря оглавления, отсутствие управляющих символов и то, что английские версии повторяют структуру русских; `check-package` требует в архиве шпаргалку и `llms.txt`.
|
|
128
|
+
|
|
129
|
+
### Fixed — после этапа 8 (код)
|
|
130
|
+
- Подмена актора в `db.batch()`: очередь была общей для всех фасадов подключения, и `run()` одного фасада исполнял от своего имени планы, поставленные другим (`db.as()`, `db.begin()`). Теперь очередь своя у каждого фасада, и план исполняется от имени поставившего.
|
|
131
|
+
- Курсор `after()` проверяется по типу поля `sort` до запроса: значение не того типа, курсор без `v` или `id` не uuid — `invalid_query`. Раньше курсор без `v` вешал соединение, а логический курсор по полю без описания давал ошибку сервера.
|
|
132
|
+
- Ошибка загрузчика называет точное число нарушений («и ещё N»; «не меньше N» с причинами, если проверены не все строки), а в `detail` к первым 100 нарушениям в `rows` добавлены `total`, `truncated`, `limit` и `complete`.
|
|
133
|
+
- Маска шаблона `Resource` не строкой (`categories`, `roles`, `endpoint`, `class`) совпадала со всеми, как в 0.21: группа `{ roles: ['staff'] }` с разрешающим правилом давала право любому аккаунту, маска `class` не строкой — на все классы. Теперь описание класса `Resource` требует строки, и маску не строкой отклоняет `invalid_data` — при записи библиотекой, сырым SQL и загрузчиком, в том числе при импорте. Строка, записанная в обход проверки, разрешений не даёт, а запрет по ней действует на всех: `acl_set_ok` и `acl_class_ok` на не строку отвечают null, `acl_rules` и `acl_plan_for` отбрасывают по ней разрешение. Новое описание попадает в свежую установку и после `reset({ all })`; в существующей схеме исправленное сопоставление действует после повторного `up()`.
|
|
134
|
+
- Ключи сервиса: по сессии своего ключа сервис не мог ни выпустить себе новый ключ, ни отозвать старый (только системный администратор), а отзыв любого креда гасил все сессии аккаунта — ротация без простоя не получалась. Теперь свои `APIKEY` и `KEYSECRET` сервис выпускает и отзывает сам по сессии своего ключа (`auth_service_self`); сессия по ключу помнит его id (`meta.cred`), и отзыв ключа гасит только его сессии и старые сессии того же вида без отметки (`auth_kill_cred`), `keepCurrent` — как у прочих кредов. Последний действующий ключ сервиса не отзывается никем — `acl_denied` «последний действующий ключ сервиса не отзывается: сначала выпустите новый»; отключить сервис — `enabled: false`.
|
|
135
|
+
- TOTP: пользователю с включённым TOTP свои креды было не сменить — база требует сессию с пройденным TOTP, а библиотека такую не выдавала. Теперь её выдают `verifyPassword` и `lookup` с `totp` (см. «Added — после этапа 8»).
|
|
136
|
+
- `maintain()` за пулом в режиме транзакций (PgBouncer) молча вставал: сеансовая advisory-блокировка оставалась на серверном соединении, которое пул отдаёт другим клиентам, — `skipped` у всех. Теперь каждая пачка в своей транзакции первым делом берёт транзакционную блокировку (`pg_try_advisory_xact_lock`), её снимает фиксация пачки; блокировка занята до первой пачки — `skipped`, между пачками — остановка с `more: true`.
|
|
137
|
+
- `after()` по полю данных терял строки с `null` или без поля: сравнение с `null` не проходило, а после строки с `null` следующая страница была пустой. Теперь `null` — после всех значений при возрастании и перед ними при убывании, как в `order by`; курсор `null` работает.
|
|
138
|
+
- `letopis import`: ошибка загрузчика в данных арендатора теряла `total`, `truncated`, `limit` и `complete` отчёта загрузчика — теперь они доходят вместе со строками 0.21 в `rows` и `notes`.
|
|
139
|
+
- `createTenant` и `up({ tenant })` после удаления арендатора, аккаунта пользователя или членства падали с `23505` (`log_id_rev`): `sys_create` пишет версию 1, а её занимает история. Теперь — `invalid_data` с объяснением и советом вернуть строку `restore()`, транзакция откатывается целиком; повтор удалённое не воскрешает.
|
|
140
|
+
|
|
141
|
+
### Fixed — документация
|
|
142
|
+
- В `README.md` стоял байт NUL: grep и поисковые инструменты считали файл двоичным и пропускали его.
|
|
143
|
+
- Примеры шпаргалки, которые в прежнем виде падали: строгий режим `{ rev }` с устаревшим номером версии, создание уже существующего членства (теперь — правка ролей), подписка без условия выхода и неопределённые переменные.
|
|
144
|
+
- Утверждения прежних README и шпаргалки, которые расходились с кодом (ревизия против кода и тестов): имперсонация не проверяет видимость пользователя по правилам доступа; `deep()` по умолчанию — 32 уровня; `refresh()` возвращает новое время истечения, а не токен; `verifyPassword` по умолчанию пускает только по подтверждённому креду; подписка отдаёт события в порядке номеров транзакций, а не фиксации; подключение к PGlite требует `allowBypassRls`; `update`, не нашедший целей, отвечает пустым списком, а не `target_not_found`; вес правила арендатора больше 999 урезается, а не отклоняется.
|
|
145
|
+
- Маска группы (`Resource` категории `ACCOUNT`) задаётся только строкой; маску не строкой теперь отклоняет база (см. «Fixed — после этапа 8 (код)»).
|
|
146
|
+
|
|
147
|
+
### Security — итоговое ревью безопасности (этап 8)
|
|
148
|
+
- Блокировка семейства классов (`family_lock`) — по арендатору корневого класса: ужесточение класса или `rekeyClass` в одном арендаторе больше не останавливает запись в одноимённый класс других арендаторов.
|
|
149
|
+
- `account_purge` проверяет право до поиска аккаунта: без права ответ всегда `acl_denied` — существование аккаунта не раскрывается.
|
|
150
|
+
- `restore` и `purge` блокируют запись журнала только своего арендатора: чужой id нельзя ни заблокировать, ни заметить по ожиданию.
|
|
151
|
+
- Маска `Resource` не строкой больше не открывает права всем: такую строку база не записывает (`invalid_data`), а записанная в обход проверки разрешений не даёт (см. «Fixed — после этапа 8 (код)»).
|
|
152
|
+
- Тест `security-final`.
|
|
153
|
+
|
|
154
|
+
## [1.0.0-alpha.0] — 2026-10-07
|
|
155
|
+
|
|
156
|
+
Начало переписывания letopis 1.0 по плану `reports/PLAN-LETOPIS-1.0.md` (редакция 7).
|
|
157
|
+
Версия не публиковалась; итоговая запись с разделом BREAKING — `[1.0.0]` выше.
|
|
158
|
+
|
|
159
|
+
### Changed — этап 0 (подготовка)
|
|
160
|
+
- Код 0.21 удалён из `lib/`: `src`, `sql`, `test`, `bench`, `docker`, `gen-types.mjs`,
|
|
161
|
+
`schema-sync.mjs`, а также `db/apply.mjs` и `db/policies.mjs`. Он доступен по тегу `v0.21.0`.
|
|
162
|
+
- Копии `deepMerge`, `decide`, `matchCategories`, `matchMask` и наследования из `buildDef`
|
|
163
|
+
лежат в `lib/test/oracle/` — эталоны дифференциальных тестов; в пакет не входят.
|
|
164
|
+
- Пакет: Node 22 и новее, единственная рантайм-зависимость `postgres`, Zod 4 — необязательная
|
|
165
|
+
peer-зависимость, подпуть `letopis/model`; `fastest-validator`, Redis и Docker уходят.
|
|
166
|
+
- CI проверяет каждый пуш в `dev` на `postgres:18`; проверка пакета `scripts/check-package.mjs`
|
|
167
|
+
общая для CI и `release.yml`; версии с дефисом публикуются под dist-tag `next`.
|
|
168
|
+
- `check-docs` и генератор контракта API переведены на новую раскладку.
|
|
169
|
+
|
|
170
|
+
### Added — этап 1 (ядро в базе)
|
|
171
|
+
- Три таблицы `entity`, `log`, `secret`; хэш версии — генерируемая колонка; права приложения только
|
|
172
|
+
на пользовательские колонки; RLS по арендатору; роли `letopis_owner` и `letopis_app`.
|
|
173
|
+
- Установщик `install()` (`lib/src/up.ts`): членство `with inherit false, set true`, работает от роли
|
|
174
|
+
с `CREATEROLE` без суперпользователя; сиды по опции `seeds`.
|
|
175
|
+
- Проверка данных: JSON Schema компилируется в jsonpath (колонка `validator` строки `Class`).
|
|
176
|
+
- Конвейер записи в триггерах: id v5 по ключу, умолчания, концы с `for key share`, воскрешение,
|
|
177
|
+
монотонное время, журнал; `merge`, `inc` со `start`; удаление триггером после оператора
|
|
178
|
+
(`cascade`, `unset`, `restrict` по итогу); `reclass()`; `verify()` с якорем.
|
|
179
|
+
- Классы — строки `Class`: метасхема, `resolved` с наследованием, пересчёт потомков, ужесточение
|
|
180
|
+
под исключительной блокировкой семейства, метаданные `meta`, политика хранения истории.
|
|
181
|
+
- Коды ошибок `LT001`–`LT017` (`error_catalog()`).
|
|
182
|
+
- Сигналы: сырой SQL шлёт `NOTIFY` из триггера; транзакции библиотеки (`letopis.notify = 'deferred'`)
|
|
183
|
+
копят классы в `letopis.changed` — решение 9 контрольной точки А.
|
|
184
|
+
|
|
185
|
+
### Added — этап 2 (модели, реестр, контекст, валидатор)
|
|
186
|
+
- `letopis/model`: `hub`, `link`, `one`, `many`, `Infer`, `InferLinks`, `ClassMeta`; нормализатор
|
|
187
|
+
JSON Schema (`$ref`/`$defs`, рекурсия — ошибка, ключи `.meta()` → `x-meta`, шаблоны в явные
|
|
188
|
+
классы символов, шаблон Zod у `format` сохраняется).
|
|
189
|
+
- v5 в TypeScript: `uuidv5`, `v5Name`, `v5Id`, `idOf()` — общий файл векторов с базой.
|
|
190
|
+
- Валидатор на TypeScript `compileValidator`, `applyDefaults` — те же нарушения, что в базе.
|
|
191
|
+
- `connect()`: отказ ролям в обход RLS без `allowBypassRls`; транзакция на операцию с токеном
|
|
192
|
+
сессии и повторами; нотификатор отложенного сигнала (интервал по умолчанию 100 мс);
|
|
193
|
+
реестр классов с обновлением по сигналу; `db.sql`, `db.transaction`, `db.idOf`.
|
|
194
|
+
- Ошибки: `LetopisError`, `ValidationError` с `issues` одного формата, `fromDbError`.
|
|
195
|
+
- Демо-домен booking на Zod (18 классов 0.21 без корней `Entity` и `link`), `seed.booking.sql`
|
|
196
|
+
генерируется из моделей (`npm run seed:booking`).
|
|
197
|
+
- CLI `letopis sync` (сухой прогон, применение, потерянные `refine`) и `letopis types`.
|
|
198
|
+
|
|
199
|
+
### Added — этап 3 (цепочки: чтение)
|
|
200
|
+
- Цепочки чтения `db.Класс(фильтр).Связь().Класс()`: шаг — имя класса, алиас или роль конца
|
|
201
|
+
(`db.Folder(f).Parent()`); переходы концами всех классов обоих семейств (шаг по базовому
|
|
202
|
+
классу, полиморфные и множественные концы); повтор связи — возврат к её узлу; `entity()`.
|
|
203
|
+
- Фильтры: id, список id, строка ответа, `or(…)`, объект любой глубины с 18 операторами 0.21 и
|
|
204
|
+
приведением по типу листа из JSON Schema, роли концов (`{ Customer: id }`, `{ Customer: null }`).
|
|
205
|
+
- Модификаторы `limit`, `offset`, `sort`, `after` и `cursorOf`, `asOf` (по журналу, в том числе с
|
|
206
|
+
`deep`), `withDeleted`, `deep(max)` с `$depth` (фильтр, теги и владелец — на каждом уровне),
|
|
207
|
+
`exact`, `alias`, `tags`, `owner`; терминалы `rows`, `first`, `ids`, `count`, `paths`,
|
|
208
|
+
`versions`, `sum`, `avg`, `min`, `max`, `countBy`; строки ответа несут `rev`.
|
|
209
|
+
- Функции владельца `find_ids()`, `find_hop()`, `find_deep()` (`lib/sql/70-read.sql`): поиск id по
|
|
210
|
+
индексам GIN без RLS с тем же условием видимости, что у политик (`row_visible()`); строки
|
|
211
|
+
читаются по первичному ключу под RLS. Три шага с фильтрами на 2,2 млн строк
|
|
212
|
+
(`bench/chain-read.mjs`): узкий запрос — 23,6 мс против 18,8 мс у владельца без RLS, широкий
|
|
213
|
+
(14 285 строк) — 3,09 с против 2,93 с; на точке А прототип давал 21,7 с (решение 5).
|
|
214
|
+
- Типы по моделям: `connect({ models })` → `TypedDb` (шаги по именам и алиасам, `RowOf`, `FilterOf`).
|
|
215
|
+
- `onQuery` и `slowMs` в `connect()`; охранники снесённого API (`run`, `account`, `reloadSchema`,
|
|
216
|
+
фасады таблиц, `watch(cb)`, `db.auth.sessions`, `db.acl.reload`, `compact`, `uuidv7`,
|
|
217
|
+
`LETOPIS_NS`, опции `partition`, `enforceAccount`, `enforceAcl`, `container`, `image`,
|
|
218
|
+
`dataDir`, `redisPort`, `version`) — ошибка `removed` с версией и заменой.
|
|
219
|
+
- Список методов цепочки `RESERVED_CLASS_NAMES` — единственный источник: установщик подставляет его
|
|
220
|
+
в `reserved_names()`, `check-docs` сверяет с Proxy и README §3.
|
|
221
|
+
|
|
222
|
+
### Changed — этап 3
|
|
223
|
+
- `count()` и агрегаты считают сущности последнего шага, а не пути; число путей — `count({ paths: true })`;
|
|
224
|
+
`run()` → `paths()`.
|
|
225
|
+
- Строки `Class` других арендаторов больше не видны (политика: только системные и свои).
|
|
226
|
+
- Надгробие строки, перенесённой `reclass()`, записывается видом `delete` (`moved` — новый id):
|
|
227
|
+
по нему срез `asOf` отличает удалённый id.
|
|
228
|
+
- Транзакции библиотеки выставляют `TimeZone = UTC`: время в строках ответа не зависит от сервера;
|
|
229
|
+
пул, который создаёт сам `connect()`, разбирает `timestamptz` строкой (микросекунды), как в 0.21.
|
|
230
|
+
- `db.schema` — имя схемы установки (в 0.21 — фасад таблицы `Schema`).
|
|
231
|
+
|
|
232
|
+
### Added — этап 4 (цепочки: запись и загрузчик)
|
|
233
|
+
- Глаголы записи — звенья плана одной транзакцией: `create`, `update(patch, { rev })`, `upsert` (`$upsert`),
|
|
234
|
+
`delete()` (превью `$action`) и `delete({ confirm })`, `anonymize`, `reclass`; продолжение от результата, fan-out,
|
|
235
|
+
self-update; слоты `.Роль.set/unset/add/remove` (функция базы `links_patch`).
|
|
236
|
+
- Приращение `inc(n, { start })` — маркер `Symbol.for('letopis.inc')`; строгий режим `{ rev }`; правило числа
|
|
237
|
+
затронутых строк (`target_not_found`, `conflict`, `acl_denied`); коды `exists`, `no_key`, `rev_ambiguous`.
|
|
238
|
+
- `db.begin()` — явная транзакция: сломанная транзакция, проверка ответа на `COMMIT` (`tx_rolled_back`), `lock()`,
|
|
239
|
+
`commit_unknown`; `forUpdate()` (`tx_required`, `lock_unsupported`); `db.batch()` со склейкой вставок и upsert;
|
|
240
|
+
`db.as(аккаунт, { owner })`.
|
|
241
|
+
- Загрузчик `db.load()` и `letopis load`: проверки в коде, `COPY` от владельца, запрос по целям вне загрузки,
|
|
242
|
+
`duplicates`, `existing` (воскрешение удалённых), `commitEvery`, режим истории, `verify`; коды `admin_required`,
|
|
243
|
+
`class_not_loadable`, в отчёте — `duplicate`, `exists`, `deleted`.
|
|
244
|
+
- Метасхема `Class`: `fields.anyOf` и `fields.oneOf` — условия по нескольким полям (потомок заменяет целиком).
|
|
245
|
+
- Превью удаления `delete_preview()` — то же правило `ref_action()`, что у триггера удаления.
|
|
246
|
+
- Тесты `chain-write`, `tx`, `load`, `balance` (обрыв связи — TCP-прокси `test/_proxy.ts`).
|
|
247
|
+
|
|
248
|
+
### Changed — этап 4
|
|
249
|
+
- Решение 2 точки А: строковые триггеры только разбирают концы (`links_shape`); цели проверяет и блокирует
|
|
250
|
+
`targets_bad()` в триггере после оператора — один раз на цель; журнал пишут триггеры после оператора
|
|
251
|
+
`entity_ai`/`entity_au` одной вставкой; надгробия каскада — одной вставкой на уровень; `class_row` — по индексу
|
|
252
|
+
`entity_class_name` без v5. Вставка пачкой: 776 → 328 мкс на строку (цель ≤ 400), каскад: 230 → 98 мкс на
|
|
253
|
+
строку; загрузчик не замедлился. Ревизия движка 2.
|
|
254
|
+
- `runTx` — на зарезервированном соединении со своими `begin`/`commit`: после гибели серверного процесса
|
|
255
|
+
postgres.js 3.4 ронял процесс необработанным `TypeError` (rollback и возврат в пул закрытого соединения).
|
|
256
|
+
- Поток `COPY` загрузчика перехватывает ошибку сервера, которую postgres.js 3.4 терял после конца потока.
|
|
257
|
+
|
|
258
|
+
### Added — этап 5 (доступ)
|
|
259
|
+
- Права ACL в базе: правила-строки `Resource` (группа `ACCOUNT` по категориям аккаунта или ролям членства, эндпоинт `API`, объект строк `READ`/`WRITE`/`DELETE` с маской класса и условием на колонки) и `rule` (группа → объект, `allow`/`deny`, вес) — семантика `decide` 0.21; план прав `acl_plan()` один раз на запрос, политики RLS на `entity` (select, insert, update, delete) и `log`, та же видимость в `find_*()`, превью удаления, проверке целей.
|
|
260
|
+
- Правила арендатора действуют на его строки, правила System — везде; роли членства — только в правилах своего арендатора; права `auth.*` и `letopis.*` — только по правилам System.
|
|
261
|
+
- Подписанный пропуск прав `letopis.acl` (`acl_token()`): библиотека кэширует план прав актора и передаёт его в каждой транзакции; счётчик изменений прав `acl_bump()` делает устаревший пропуск недействительным.
|
|
262
|
+
- Членство `member` (концы `User`, `Tenant`, ключ `[User, Tenant]`, роли), `auth.switch`, `letopis.tenant`, `db.as(аккаунт, { tenant, owner })`; выход из членства переводит владение строками арендатору.
|
|
263
|
+
- Имперсонация в `ctx()`: `letopis.account` — только при праве `auth.impersonate`; в журнале `author` — пользователь, `agent` — сервис.
|
|
264
|
+
- Креды и сессии в `secret` (`80-auth.sql`, `db.auth`): пароль (scrypt в Node, хэш — только сервису с правом), ключ API, ключ + секрет, OTP, TOTP (RFC 6238 на pgcrypto), внешние identity; недавний вход для своих кредов; гашение сессий со сигналом `session:<начало хэша>`; `sessionFor`, `refresh`, `revoke`, `revokeAll`, `tenants`; `totpCode`, `hashPassword`, `verifyHash`.
|
|
265
|
+
- `db.acl.check(endpoint)`, `db.acl.checkData(class, op)`.
|
|
266
|
+
- Системный сид: классы `member`, `Resource`, `rule`, общие группы, права сервиса и администратора; `sys_grant_all()` для обвязки и первичной настройки арендатора.
|
|
267
|
+
- Загрузчик: `load({ as })` — права пользователя, условия ACL, видимость целей, владелец по `ownerDefault`.
|
|
268
|
+
- Тесты: `acl`, `acl-parity` (1000 наборов против 0.21), `auth`, `impersonation`, `member`, `system-classes`, `access-rest`; бенч `acl-cost`.
|
|
269
|
+
|
|
270
|
+
### Changed — этап 5
|
|
271
|
+
- Права включены всегда и по умолчанию запрещают; тестовая обвязка выдаёт тестовым арендаторам «аутентифицированным можно всё».
|
|
272
|
+
- Аккаунты лежат только в арендаторе System; их категории правит системный администратор (член System с ролью `admin`).
|
|
273
|
+
- `db.as()` асинхронный: `await db.as(аккаунт, { tenant, owner })`.
|
|
274
|
+
- Явный владелец строки — существующий аккаунт, разрешение — по условию ACL (нарушение — `acl_denied`); несуществующий — `target_not_found`.
|
|
275
|
+
- Каскад удаления требует права `DELETE` на каждую удаляемую строку, снятие конца — `WRITE`; цель ссылки должна быть видна актору.
|
|
276
|
+
- `issue_session(аккаунт, ttl, способы)`; ревизия движка 3 (индекс `entity_owner`).
|
|
277
|
+
|
|
278
|
+
### Security — второе ревью безопасности (этап 5)
|
|
279
|
+
- Креды System, сервисов и системных администраторов меняет только сам аккаунт или системный администратор (`auth_guard`): сервис больше не привяжет себе вход под ними.
|
|
280
|
+
- Свой кред аккаунт не подтверждает; недавний вход — только интерактивный, при включённом TOTP — с пройденным TOTP.
|
|
281
|
+
- Вход по одноразовому коду и проверка TOTP — только сервису с правом (счётчики попыток транзакционные); лимит попыток OTP задаёт выпуск (1–10); окно TOTP — не шире шага, пять неудач — блокировка на 5 минут; продление сессии — не дольше выданного срока.
|
|
282
|
+
- Вес правила арендатора — не больше 999: административные запреты System (вес от 1000) он не перевесит.
|
|
283
|
+
- Строки прав не получаются сменой класса; служебная строка ключа подписи исключена из функций кредов; хэш выдаётся только для видов «логин/пароль».
|
|
284
|
+
|
|
285
|
+
### Changed — доводка контрольной точки Б
|
|
286
|
+
- Ревизия движка 5: индекс журнала `log_ends` (GIN по целям ссылок); срез `asOf` и withDeleted по переходу, `deep` на дату и условие на концы ищут в журнале от версий, ссылавшихся на источник (`log_refs_at`), а не перебором всех версий класса — на полигоне salon срез записей мастера 3,37 с → 0,22 с. Обновление установки — `up({ upgrade: true })` (строит индекс).
|
|
287
|
+
- Транзакция записи: чтение `letopis.changed` и `COMMIT` — одним конвейером (минус круг до базы на запись).
|
|
288
|
+
|
|
289
|
+
### Added — этап 7 (импорт из 0.21 и бенч)
|
|
290
|
+
- `letopis import --from <строка 0.21> --from-schema v1.x` (`src/import.ts`): Schema → строки `Class` в System (fastest-validator → JSON Schema, концы — роли по классу цели, союз потомков — полиморфный конец, корни `Entity` и `link` опущены, метаданные → `meta`), Entity → загрузчик в режиме истории (надгробия, воскрешение, теги, владелец), Account → строки `Account`, Credential → `secret` как есть, Resource и Rule → System; id — v5 от ключа или от старого id; `--tenant`, `--keyless`, `--rename`, `--update`, `--commit-every`; проверки до первой записи, отчёт со счётчиками и `verify()`.
|
|
291
|
+
- Загрузчик: `$tags` и `$owner` строки (владелец сверяется запросом в конце); `existing: 'skip'` в режиме истории.
|
|
292
|
+
- Фикстура настоящей 0.21 (`test/fixtures/v021.sql`, генератор `gen-v021.mjs`), тест `import`; `npm run test:import-salon` — полный импорт полигона salon; полигоны 1.0 загрузчиком (`bench/polygons.mjs`) и бенч против 0.21 (`bench/compare.bench.mjs`).
|
|
293
|
+
|
|
294
|
+
### Added — этап 6 (время, хранение, события, кэш, эксплуатация)
|
|
295
|
+
- История: `versions({ follow: true })` через смену id (`reclass`, `rekey`, колонка `moved`, индекс `log_moved`); глаголы `restore()` (последний снимок удалённого, перепроверка по текущему описанию, id прежний) и `purge({ confirm })` (только удалённые, запись `purge` под номером 1); `db.trimHistory(цель, момент)` — обрезка с записью `trim` (время версии 1, стык по хэшу).
|
|
296
|
+
- Смена ключа: глагол `rekey(patch)` и `db.rekeyClass(класс, ключ)` — новые id, перевод ссылок, рекурсия по связям с концом в ключе.
|
|
297
|
+
- Политика хранения истории (§2.11): `history: { all, daily, weekly, monthly, yearly, tz }` у класса и `up({ history })`; прореживание в `maintain()` пачками; законные пропуски в `verify()`.
|
|
298
|
+
- `db.verify({ data })` и `letopis verify`: законные разрывы (`purge`, `trim`, `reset`, прореживание), проверка данных, концов, арендаторов, владельцев и id всех строк; якорь.
|
|
299
|
+
- `db.maintain()` (сессии, коды, прореживание, очередь сигналов) под advisory-блокировкой, `connect({ maintain })`, `letopis maintain`.
|
|
300
|
+
- `db.reset({ level: 'sessions' | 'tenant' | 'all', confirm, tenant })` с флагом `allowReset`, правом `letopis.reset`, подтверждением; `all` — сид и новый ключ сервиса.
|
|
301
|
+
- `db.auth.purgeAccount()` — удаление аккаунта с предохранителями 0.21 и переходом владения.
|
|
302
|
+
- Подписка `db.watch({ from, classes })` по курсору (tx, seq) ниже горизонта, `cursor_expired`, `lag()`.
|
|
303
|
+
- Кэш результатов `.cache({ ttl })` и пометка класса `cache`; `db.cache.clear()`, `stats()`; `connect({ cache, listen })`.
|
|
304
|
+
- `up()` — оркестратор без Docker: ожидание и создание базы, PostgreSQL 18+ и UTF8, `fresh`, `upgrade` с миграциями `sql/migrate/`, свои сиды и ключ сервиса, `ANALYZE` без статистики; `up({ pglite })` — PGlite для разработки (решение 6 точки А; необязательные peer-зависимости `@electric-sql/pglite`, `@electric-sql/pglite-socket`); `db/apply.mjs`.
|
|
305
|
+
- `db.describe()`, `describeText()`, `letopis describe`; коды ошибок `not_deleted`, `reset_disabled`, `confirm_mismatch`; каталог ошибок в README §12.
|
|
306
|
+
- Тесты: `time`, `rekey`, `reset`, `verify`, `retention`, `watch`, `cache`, `maintain`, `up`, `telemetry`, `scenario-salon`; строки этапа 6 в `hash`, `load`, `balance`.
|
|
307
|
+
|
|
308
|
+
### Changed — этап 6
|
|
309
|
+
- Ревизия движка 4. `verify(p_data)` перенесён в `90-time.sql`; системный сид — функция `seed_system()`.
|
|
310
|
+
- Воскрешение, `restore`, `purge` и обрезка берут последнюю запись журнала id `for update`.
|
|
311
|
+
- Загрузчик в режиме истории пишет волну одной вставкой `insert … returning hash` (4000 волн — 6 с вместо 400 с).
|
|
312
|
+
- `acl_bump()` сохраняет остальные ключи служебной строки ключа (курсор прореживания).
|
|
313
|
+
- `compact()` — охранник с заменой `db.trimHistory`; `watch(cb)` — охранник с заменой `watch({ from })`.
|
|
314
|
+
|
|
315
|
+
### Removed
|
|
316
|
+
- Режим доверия загрузчика (`trust: true`, `--trust`) — решение владельца на контрольной точке А; охранник `removed`.
|
|
317
|
+
|
|
318
|
+
## [0.21.0] — 2026-08-23
|
|
319
|
+
|
|
320
|
+
### Added — no-history классы: `meta.history: false` (DDL_REVISION 2)
|
|
321
|
+
|
|
322
|
+
Класс, помеченный в `Schema.meta` флагом `history: false`, хранит **одну актуальную строку
|
|
323
|
+
на id**: новый AFTER INSERT триггер `entity_collapse` после каждой вставки физически удаляет
|
|
324
|
+
более старые версии этого id. Дефолт (`history` отсутствует или `true`) — прежнее поведение,
|
|
325
|
+
все версии копятся; фича строго opt-in, существующие схемы и сервисы не затронуты.
|
|
326
|
+
|
|
327
|
+
Зачем: у мутабельных статус-сущностей (очереди, outbox — статус живёт в `data`) история
|
|
328
|
+
версий делает jsonb-фильтры неселективными: GIN по `data` матчит каждую версию, что
|
|
329
|
+
когда-либо была в искомом статусе, и сканы растут O(вся история). Под `history:false`
|
|
330
|
+
фильтр матчит только актуальные строки — скан остаётся O(активных) при любом росте данных.
|
|
331
|
+
|
|
332
|
+
Механика:
|
|
333
|
+
- `entity_collapse` — AFTER INSERT (как `entity_notify`; collapse по алфавиту раньше —
|
|
334
|
+
один NOTIFY на выжившую строку). In-place UPDATE не годится: Entity — гипертаблица по
|
|
335
|
+
`updated` (перенос между чанками), а `entity_notify` слушает только INSERT.
|
|
336
|
+
- DELETE старых версий идёт под `letopis.purge='on'` (save+restore флага):
|
|
337
|
+
`entity_delete` пропущен — без tombstone и каскада. Advisory-lock тем же ключом,
|
|
338
|
+
что `entity_delete`/`purge`, сериализует конкурентные вставки одного id.
|
|
339
|
+
- `.delete()` под no-history оставляет один tombstone (его `updated` строго больше),
|
|
340
|
+
`purge()` работает как прежде.
|
|
341
|
+
- Новая серверная функция `compact(partition, class) → bigint` — разовая чистка УЖЕ
|
|
342
|
+
накопленной истории при переводе класса на `history:false` (оставить последнюю версию
|
|
343
|
+
каждого id, вернуть число удалённых). Идемпотентна. После вызова — `ANALYZE "Entity"`.
|
|
344
|
+
- `ClassDef.history` в реестре; `.versions()`/`.asOf()` по no-history классу предупреждают
|
|
345
|
+
в console.warn (один раз на класс+метод за процесс) — результат не отражает историю.
|
|
346
|
+
|
|
347
|
+
Накат на существующие схемы: `up({ upgrade: true })` или `node db/apply.mjs --upgrade`
|
|
348
|
+
(файл идемпотентен, данные целы); до наката `connect()` предупредит о ревизии (1 → 2).
|
|
349
|
+
|
|
350
|
+
- `test/no-history.test.ts` — 7 сцен: реестр, схлопывание, соседний history-класс,
|
|
351
|
+
NOTIFY, tombstone, `compact()`, warning; суммарно 205 тестов.
|
|
352
|
+
|
|
5
353
|
## [0.20.3] — 2026-07-31
|
|
6
354
|
|
|
7
355
|
### Fixed — релиз доезжает до npm
|
package/MIGRATION.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Переход с letopis 0.21 на 1.0
|
|
2
|
+
|
|
3
|
+
letopis 1.0 — новая реализация хранилища: PostgreSQL 18, три таблицы (`entity`, `log`, `secret`), проверки и
|
|
4
|
+
права в самой базе. Схема 0.21 не обновляется на месте: 1.0 ставится в новую схему, данные переносит команда
|
|
5
|
+
`letopis import`, а код приложения переводится по таблице замен ниже. Полный список несовместимостей — раздел
|
|
6
|
+
BREAKING записи `[1.0.0]` в `CHANGELOG.md`; руководство по 1.0 — `README.md`, краткая шпаргалка — `AGENT-CHEATSHEET.md`
|
|
7
|
+
(оба лежат в пакете рядом с этим файлом).
|
|
8
|
+
|
|
9
|
+
## 1. Порядок перехода
|
|
10
|
+
|
|
11
|
+
1. Поднимите PostgreSQL 18 (TimescaleDB, Redis и Docker больше не нужны) и Node 22+.
|
|
12
|
+
2. Установите схему 1.0 без демо-сида: `await up({ dsn: ADMIN, schema: 'salon' })` — схема `v2.salon`. Сохраните
|
|
13
|
+
`serviceKey` из результата: ключ сервиса выдаётся один раз. В 0.21 `up()` без `seeds` заливал демо-домен; в
|
|
14
|
+
1.0 сиды ставятся только явно (`seeds: ['booking']` или путь к своему `.sql`).
|
|
15
|
+
3. Создайте прикладную LOGIN-роль, которая состоит только в `letopis_app`
|
|
16
|
+
(`create role app login password '…'; grant letopis_app to app;`). Приложение подключается ею: роль, которая
|
|
17
|
+
обходит RLS, `connect()` отклоняет (`bypass_rls`), если не передать `allowBypassRls: true`.
|
|
18
|
+
4. Перенесите данные: `letopis import` (раздел 2). Повтор безопасен — он дописывает недостающее.
|
|
19
|
+
5. Переведите код по таблицам разделов 3–5 и проверьте установку: `letopis verify --data`.
|
|
20
|
+
|
|
21
|
+
## 2. Импорт данных: `letopis import`
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgres://…/new --schema v2.salon \
|
|
25
|
+
[--tenant <имя>] [--keyless <Класс>]… [--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Опция | Что делает |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `--from`, `--from-schema` | база и схема 0.21 (`v1.<имя>`): PostgreSQL 17 с TimescaleDB или дамп обычных таблиц |
|
|
31
|
+
| `--dsn`, `--schema` | база и схема 1.0 (`v2.<имя>`), поставленная заранее `up()`; оба подключения — администраторские |
|
|
32
|
+
| `--tenant <имя>` | арендатор, в которого переходят данные System 0.21 (в 1.0 у System своих данных нет) |
|
|
33
|
+
| `--keyless <Класс>` | класс, чьи данные нарушают ключ: его объекты получают id от старого id, а не от ключа |
|
|
34
|
+
| `--rename <Старое>=<Новое>` | новое имя класса, совпавшего с методом 1.0 (по умолчанию — имя с `_` на конце) |
|
|
35
|
+
| `--update` | при повторе — новые версии объектов, изменившихся в 0.21 после прошлого импорта |
|
|
36
|
+
| `--commit-every <N>` | размер порции загрузчика |
|
|
37
|
+
| `--partition <имя>` | партиция 0.21 (по умолчанию `entity`) |
|
|
38
|
+
|
|
39
|
+
Что переносится:
|
|
40
|
+
- **Классы** (`Schema`) — строки `Class` арендатора System. Правила fastest-validator переводятся в JSON Schema
|
|
41
|
+
(`optional` пропускает `null`, `$$strict` — `additionalProperties: false`, `record` — `propertyNames`); концы
|
|
42
|
+
становятся ролями с именем класса цели, союз всех потомков класса — полиморфным концом; корни `Entity` и `link`
|
|
43
|
+
опускаются; `description`, `appearance`, `order` — в `meta`.
|
|
44
|
+
- **id**: у класса с ключом (правило v5 из 0.21) — v5 от ключа в арендаторе, у класса без ключа — v5 от старого
|
|
45
|
+
id. uuid внутри `data`, совпадающие со старыми id, импорт не переписывает и перечисляет в отчёте.
|
|
46
|
+
- **Объекты** (`Entity`) — загрузчиком в режиме истории: все версии, надгробия, воскрешения, теги, владелец; агент
|
|
47
|
+
в журнале — `letopis import`. Каждый аккаунт 0.21 становится арендатором своих строк; владелец, не найденный
|
|
48
|
+
среди аккаунтов, заменяется арендатором. `onDelete` концов — `cascade`, как удаляла 0.21.
|
|
49
|
+
- **Аккаунты** — строки `Account` (в System); **креды** — в `secret` как есть (хэши scrypt, ключи);
|
|
50
|
+
одноразовые коды не переносятся.
|
|
51
|
+
- **Ресурсы и правила** ACL — в System, поэтому действуют во всех арендаторах; непереведённые правила — в отчёте.
|
|
52
|
+
Ресурс с маской не строкой импорт не пропустит (`invalid_data`, см. [раздел 4](#4-изменения-поведения)).
|
|
53
|
+
|
|
54
|
+
Проверки до первой записи: импорт останавливается, если два объекта 0.21 дают один id 1.0 (данные нарушают ключ —
|
|
55
|
+
укажите `--keyless <Класс>`), если ключ объекта менялся между версиями или ссылается на отсутствующий объект.
|
|
56
|
+
Висячая ссылка живой строки — `target_not_found` с объектом 0.21.
|
|
57
|
+
|
|
58
|
+
Отчёт — JSON в конце вывода (перед ним — строки хода `letopis.import: …`): объекты и версии по классам в 0.21 и в 1.0, аккаунты, креды, ресурсы, правила, классы без
|
|
59
|
+
ключа, результат `verify()`. Код выхода 1, если `verify()` нашёл расхождения. Шаги импорта (классы, аккаунты,
|
|
60
|
+
креды, права, данные каждого арендатора) — отдельные транзакции; источник читается в память целиком.
|
|
61
|
+
|
|
62
|
+
## 3. Таблица замен API
|
|
63
|
+
|
|
64
|
+
Снятые имена не исчезли молча: вызов бросает `LetopisError` с кодом `removed` и текстом
|
|
65
|
+
`letopis: removed: <имя> снят в 1.0.0 — <замена>`.
|
|
66
|
+
|
|
67
|
+
| 0.21 | 1.0 (текст замены в ошибке) |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `.run()` | `paths()` |
|
|
70
|
+
| `.execute()` | `paths()` |
|
|
71
|
+
| `.account(id)` | арендатор сессии и `auth.switch()` |
|
|
72
|
+
| `batch.execute()` | `batch.run()` |
|
|
73
|
+
| `uuidv7()` | `uuidv5()` и `idOf()`: id объекта вычисляется из ключа |
|
|
74
|
+
| `LETOPIS_NS` | пространство имён v5 — id арендатора: `idOf(класс, ключ, { tenant })` |
|
|
75
|
+
| `db.watch(cb)`, `db.watch(класс, cb)` | `watch({ from })` — подписка по курсору |
|
|
76
|
+
| `db.reloadSchema()` | реестр обновляется сам по сигналу; принудительно — `db.refresh()` |
|
|
77
|
+
| `db.accounts`, `db.credentials`, `db.resources`, `db.rules` | системные классы `Account`, `Resource`, `rule`, `member` и `db.auth` (`accounts.purge` — `db.auth.purgeAccount`) |
|
|
78
|
+
| `db.compact()` | `db.trimHistory(цель, момент)`, политика хранения `history` и `history: false` |
|
|
79
|
+
| `db.acl.reload()` | права обновляются сами: база применяет действующие правила |
|
|
80
|
+
| `db.auth.sessions(store)` | сессии хранятся в базе: `sessionFor`, `refresh`, `revoke`, `revokeAll` |
|
|
81
|
+
| `connect({ partition })` | отдельные схемы установки (`v2.<имя>`) |
|
|
82
|
+
| `connect({ enforceAccount })` | изоляция арендаторов включена всегда (RLS) |
|
|
83
|
+
| `connect({ enforceAcl })` | права включены всегда |
|
|
84
|
+
| `connect({ container })`, `({ image })`, `({ dataDir })` | Docker в `up()` не нужен |
|
|
85
|
+
| `connect({ redisPort })` | Redis не нужен: сессии в базе |
|
|
86
|
+
| `connect({ version })` | версия схемы — константа движка |
|
|
87
|
+
|
|
88
|
+
Замены без охранника — имя осталось, но значит другое, или API устроено иначе:
|
|
89
|
+
|
|
90
|
+
| 0.21 | 1.0 |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `db.schema.define(def)`, CLI `schema-sync` | модели `hub`/`link` из `letopis/model` и `letopis sync <модуль> [--apply]` (или `syncModels(db, модели, { apply })`); класс — строка `Class` с JSON Schema |
|
|
93
|
+
| `db.schema` — фасад таблицы `Schema` | `db.schema` — имя схемы установки |
|
|
94
|
+
| CLI `gen-types` | `letopis types`; типы цепочек — `connect({ models })` |
|
|
95
|
+
| `attributes` на fastest-validator, `id: { generate, from }` | поля на Zod / JSON Schema, ключ класса `key: [...]`; id только v5 |
|
|
96
|
+
| `db.as(account, { owner })` — синхронный | `await db.as(account, { tenant, owner })` — имперсонацию проверяет база (право `auth.impersonate`) |
|
|
97
|
+
| `connect({ dsn, schema })` + `db.as(accountId)` | `connect({ dsn, schema, token })` или `connect({ …, apiKey })` — сессия в базе |
|
|
98
|
+
| `row.account`, `row.updated` | `row.tenant`, `row.at` (в `sort` и `cursorOf` имя `updated` понимается как `at`); новые `rev`, `author`, `op`, `agent`, `reason`, `moved` |
|
|
99
|
+
| `.count()` — число путей | `.count()` — число сущностей; путей — `.count({ paths: true })` |
|
|
100
|
+
| `up({ schema: 'x', version: 1 })` → `v1.x` | `up({ schema: 'x' })` → `v2.x`; Docker-опций нет, есть `pglite: true` |
|
|
101
|
+
| `db.sql` — голый клиент | `` db.sql`…` `` — под сессией и RLS, одной транзакцией; `db.transaction(fn)` |
|
|
102
|
+
| коды ошибок 0.21 | `err.code` — строчный код каталога (`exists`, `conflict`, `invalid_data` …), ошибки базы — SQLSTATE `LT001`–`LT020` |
|
|
103
|
+
| `tr.lock(...keys)` | остался: advisory-блокировка до конца `db.begin()`; для денег — раздел 5 |
|
|
104
|
+
|
|
105
|
+
## 4. Изменения поведения
|
|
106
|
+
|
|
107
|
+
- **`create` — не PUT.** Занятый ключ — ошибка `exists` с `detail.id`; в 0.21 повторный `create` перезаписывал
|
|
108
|
+
объект. Замена целиком — `upsert(data)` (только класс с ключом, иначе `no_key`; в строке ответа `$upsert`:
|
|
109
|
+
`created`, `updated` или `unchanged`). Частичная правка — `update`.
|
|
110
|
+
- **`update` не создаёт** (как и в 0.21), но теперь строже: если глагол затронул меньше строк, чем нашёл шаг,
|
|
111
|
+
весь план откатывается с `target_not_found`, `conflict` или `acl_denied`.
|
|
112
|
+
- **`count()` считает сущности** последнего шага, агрегаты — тоже; число путей — `count({ paths: true })`.
|
|
113
|
+
- **Пути — `paths()`** вместо `run()`: `[{ Шаг: Row, … }]`, ключ — имя шага или `.alias(имя)`.
|
|
114
|
+
- **Арендатор сессии вместо `account()`.** Строки принадлежат арендатору сессии подключения; чужие не видны и
|
|
115
|
+
неотличимы от несуществующих. Работа в другом арендаторе — по членству (`member`): `db.auth.switch(tenant)` или
|
|
116
|
+
`await db.as(account, { tenant })`. System-fallback 0.21 нет.
|
|
117
|
+
- **Права включены всегда** и по умолчанию запрещают — для библиотеки и сырого SQL (политики RLS). После импорта
|
|
118
|
+
правила 0.21 лежат в System; новому арендатору нужны свои правила (`Resource` и `rule` цепочками).
|
|
119
|
+
- **Маска `Resource` — только строка** (`categories`, `roles`, `endpoint`, `class`; например, `roles: '{staff}'`). В 0.21
|
|
120
|
+
маска не строкой (например, массив `roles: ['staff']`) совпадала со всеми аккаунтами, и разрешающее правило с такой
|
|
121
|
+
группой давало право каждому. В 1.0 такую строку `Resource` база отклоняет с `invalid_data` — при записи
|
|
122
|
+
библиотекой, сырым SQL и загрузчиком, в том числе при импорте: перепишите маску строкой в источнике.
|
|
123
|
+
- **Ключ неизменен.** Правка ключевого поля — `immutable_key`; новый ключ — `rekey(patch)` или
|
|
124
|
+
`db.rekeyClass(класс, ключ)`, оба дают новые id и переводят ссылки. Смена класса — `reclass(класс, данные)`.
|
|
125
|
+
- **Явный id** у класса с ключом должен совпасть с вычисленным (`id_mismatch`), класс без ключа id не принимает
|
|
126
|
+
(`id_from_db`). Вычислить id заранее — `db.idOf(класс, ключ)`.
|
|
127
|
+
- **Ошибка ломает `db.begin()`**: следующие вызовы и `commit()` бросают исходную ошибку (`commit()` откатывает),
|
|
128
|
+
`rollback()` проходит. В 0.21 `commit()` после ошибки молча проходил. Обрыв связи во время `COMMIT` —
|
|
129
|
+
`commit_unknown`, без повтора.
|
|
130
|
+
- **Каскад удаления** — по правилам концов `onDelete` (`restrict` по умолчанию, `cascade`, `unset`); превью
|
|
131
|
+
`delete()` показывает `$action` каждой строки. Удаление, `reclass` и `rekey` — только в `read committed`.
|
|
132
|
+
- **`history: false`** отключает ещё и `versions`, `asOf`, `restore` и события подписки.
|
|
133
|
+
- **Подписка** `db.watch({ from, classes })` — асинхронный итератор с курсором `ev.cursor`: без потерь и повторов,
|
|
134
|
+
продолжение — `watch({ from: курсор })`; `onReconnect` не нужен.
|
|
135
|
+
|
|
136
|
+
## 5. Баланс и счётчики
|
|
137
|
+
|
|
138
|
+
В 0.21 баланс правили так: явная транзакция, `tr.lock(...)`, перечитать строку, записать новое значение. Advisory-
|
|
139
|
+
блокировка не останавливает тех, кто её не берёт (другой код, сырой SQL), а перечитывание и запись — два запроса.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
// 0.21
|
|
143
|
+
const tr = await db.begin()
|
|
144
|
+
await tr.lock('wallet', id)
|
|
145
|
+
const w = await tr.Wallet(id).first()
|
|
146
|
+
await tr.Wallet(id).update({ balance: w.data.balance - 300 }).rows()
|
|
147
|
+
await tr.commit()
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
В 1.0 — один из трёх способов.
|
|
151
|
+
|
|
152
|
+
**1. Приращение `inc`** — для пополнения, списания, счётчиков. База прибавляет величину внутри оператора: без
|
|
153
|
+
чтения, без блокировок в приложении, без потерянных обновлений. Нижнюю границу держит описание класса
|
|
154
|
+
(`minimum: 0` → уход в минус — `invalid_data`). Идемпотентность — ключом операции:
|
|
155
|
+
```ts
|
|
156
|
+
import { inc } from 'letopis'
|
|
157
|
+
// Movement: связь с ключом [Wallet, opId]; повтор той же операции — exists, баланс не меняется
|
|
158
|
+
await db.Wallet(id).Movement().create({ opId, kind: 'debit', amount: 300 }).Wallet().update({ balance: inc(-300) }).rows()
|
|
159
|
+
await db.Wallet(id).update({ visits: inc(1, { start: 0 }) }).first() // start — если поля ещё нет
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**2. Строгий режим `{ rev }`** — когда новое значение вычисляется из прочитанного (корректировка до целевого
|
|
163
|
+
значения). Если строку успели изменить, приходит `conflict` — перечитать и повторить:
|
|
164
|
+
```ts
|
|
165
|
+
for (;;) {
|
|
166
|
+
const w = await db.Wallet(id).first()
|
|
167
|
+
try {
|
|
168
|
+
await db.Wallet(id).update({ balance: target(w!.data) }, { rev: w!.rev }).rows()
|
|
169
|
+
break
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if ((e as { code?: string }).code !== 'conflict') throw e
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
`{ rev }` действует при одной цели шага (иначе `rev_ambiguous`) и не повторяется автоматически.
|
|
176
|
+
|
|
177
|
+
**3. Чтение с блокировкой `forUpdate()`** — когда нужно прочитать несколько строк и записать итог без повторов:
|
|
178
|
+
```ts
|
|
179
|
+
const tr = await db.begin()
|
|
180
|
+
try {
|
|
181
|
+
const w = await tr.Wallet(id).forUpdate().first() // for no key update, строки — в порядке id
|
|
182
|
+
await tr.Wallet(id).update({ balance: w!.data.balance - 300 }).rows()
|
|
183
|
+
await tr.commit()
|
|
184
|
+
} catch (e) {
|
|
185
|
+
await tr.rollback()
|
|
186
|
+
throw e
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
`forUpdate()` — только внутри `db.begin()` (`tx_required`) и только с `rows`, `first`, `ids` (`lock_unsupported`).
|
|
190
|
+
Транзакция — `read committed`; на `40P01` повторите весь блок `db.begin()`.
|