letopis 1.3.0 → 1.4.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.
Files changed (56) hide show
  1. package/AGENT-CHEATSHEET.en.md +14 -6
  2. package/AGENT-CHEATSHEET.md +18 -10
  3. package/CHANGELOG.md +247 -0
  4. package/README.en.md +146 -58
  5. package/README.md +129 -51
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +42 -16
  9. package/dist/auth.d.ts +61 -17
  10. package/dist/auth.js +16 -2
  11. package/dist/cache.d.ts +5 -1
  12. package/dist/chain.d.ts +91 -15
  13. package/dist/chain.js +47 -4
  14. package/dist/cli.js +6 -0
  15. package/dist/errors.d.ts +2 -2
  16. package/dist/errors.js +5 -5
  17. package/dist/import.js +7 -0
  18. package/dist/index.d.ts +74 -45
  19. package/dist/index.js +18 -4
  20. package/dist/load.d.ts +10 -3
  21. package/dist/load.js +19 -4
  22. package/dist/mcp.d.ts +20 -11
  23. package/dist/mcp.js +4 -4
  24. package/dist/model.d.ts +4 -1
  25. package/dist/ops.d.ts +58 -18
  26. package/dist/ops.js +58 -18
  27. package/dist/project.d.ts +44 -12
  28. package/dist/project.js +90 -8
  29. package/dist/registry.d.ts +4 -1
  30. package/dist/registry.js +4 -1
  31. package/dist/sql.d.ts +4 -1
  32. package/dist/sql.js +12 -4
  33. package/dist/sync.js +11 -5
  34. package/dist/tx.js +10 -1
  35. package/dist/types.d.ts +14 -7
  36. package/dist/types.js +13 -6
  37. package/dist/up.d.ts +65 -25
  38. package/dist/up.js +152 -40
  39. package/dist/validate.d.ts +9 -2
  40. package/dist/validate.js +19 -3
  41. package/dist/watch.d.ts +17 -5
  42. package/dist/watch.js +40 -8
  43. package/dist/write.d.ts +3 -2
  44. package/dist/write.js +29 -10
  45. package/llms.txt +1 -1
  46. package/package.json +1 -1
  47. package/sql/10-core.sql +6 -1
  48. package/sql/50-acl.sql +5 -1
  49. package/sql/60-write.sql +77 -22
  50. package/sql/80-auth.sql +39 -2
  51. package/sql/88-derived.sql +82 -1
  52. package/sql/90-time.sql +75 -38
  53. package/sql/95-seed.system.sql +6 -3
  54. package/sql/99-revision.sql +3 -2
  55. package/sql/README.md +2 -0
  56. package/sql/migrate/0007-secret-account-fk.sql +12 -0
@@ -1,4 +1,4 @@
1
- # letopis 1.3 — agent cheatsheet
1
+ # letopis 1.4 — agent cheatsheet
2
2
 
3
3
  One page instead of [README.en.md](README.en.md), for an AI agent that writes code on top of the package. It ships in
4
4
  the package next to the README; in Russian: [Русская версия](AGENT-CHEATSHEET.md). Migration from 0.21:
@@ -32,7 +32,7 @@ const r = await up({ dsn: ADMIN, schema: 'myapp', // schema 'v2
32
32
  tenant: { name: 'Salon', user: { name: 'Olga', login: 'olga@salon.ru', password: 'Lilac-2026!' } } })
33
33
  // r: { schema, created, upgraded, revision, serviceKey, migrations, analyzed, tenant, … }
34
34
  // r.serviceKey ('lts_…') is the service key; it is issued at the first installation (again only if the installation has
35
- // no valid API keys left). Save it.
35
+ // no API keys left except revoked ones). Save it.
36
36
  // r.tenant: { tenant, user, token, created } — the tenant "Salon" and its first user Olga
37
37
  ```
38
38
  - `up()` waits for the database, creates it, checks for PostgreSQL 18+ and UTF8, and installs the roles `letopis_owner`
@@ -49,6 +49,9 @@ const r = await up({ dsn: ADMIN, schema: 'myapp', // schema 'v2
49
49
  schema: `up()` without the option keeps it; after a change, restart the processes. The signal key (precise cache
50
50
  clearing) goes to services — the `letopis.signals` permission. The `up()` result also has `signals` and `warnings`
51
51
  (PostgreSQL below 18.2 — upgrade).
52
+ - After a package update, call `up()`: only it re-applies the schema functions. `connect()` warns if the engine stamp
53
+ in the schema comment (package version and a hash of its SQL) does not match the package: an older schema — `up()`,
54
+ a newer one — update the package.
52
55
  - `tenant` — the first tenant and user without a seed of your own: the tenant gets the rule "authenticated users may
53
56
  do everything" (`grantAll`, on by default, weight 10); the user gets a membership with the roles `user.roles`
54
57
  (`['owner']` by default), a password for signing in with the login `login`, and a session in the tenant. Repeating
@@ -79,10 +82,10 @@ db.tenant // the session tenant: by default the account's own tenant (h
79
82
  allowBypassRls, models })`. `schema` is the **full** name with `v2.`.
80
83
  - `db.auth`: `setPassword`, `verifyPassword`, `issueApiKey`, `verifyApiKey`, `issueKeySecret`, `verifyKeySecret`,
81
84
  `enrollTotp`, `verifyTotp`, `totpEnabled`, `issueOtp`, `verifyOtp`, `link`, `lookup`, `credentials`, `revokeCredential`,
82
- `setFlags`, `sessionFor(account, { ttl })`, `refresh`, `revoke`, `revokeAll`, `switch(tenant)`, `tenants()`,
83
- `purgeAccount`. Password, OTP and TOTP checks, issuing sessions and confirming credentials are called by the
84
- **service** (the `auth.*` permissions); a regular user gets `acl_denied`. Signatures:
85
- `issueApiKey({ account, name?, ttl? })` → `{ key, id }`; `verifyApiKey(key)` → `{ token, account } | null`;
85
+ `setFlags`, `sessionFor(account, { ttl })`, `session(token)`, `refresh`, `revoke`, `revokeAll`, `switch(tenant)`,
86
+ `tenants()`, `purgeAccount`. Password, OTP and TOTP checks, issuing sessions, session details by token and
87
+ confirming credentials are called by the **service** (the `auth.*` permissions); a regular user gets `acl_denied`.
88
+ Signatures: `issueApiKey({ account, name?, ttl? })` → `{ key, id }`; `verifyApiKey(key)` → `{ token, account } | null`;
86
89
  `sessionFor(account, { ttl })` → a token string; then `connect({ dsn, schema, token })`, and to work in another
87
90
  tenant through a membership, `db.auth.switch(tenant)` (without it, the session is in the account's own tenant). The
88
91
  token for the CLI (`--token`) is the same `token` from `verifyPassword`/`verifyApiKey`/`sessionFor`. An admin
@@ -96,6 +99,11 @@ db.tenant // the session tenant: by default the account's own tenant (h
96
99
  `issueKeySecret` with `account` set to the service id; `revokeCredential(id)`). Revoking a key revokes only the
97
100
  sessions of that key, so rotation has no downtime: new key → processes onto it → revoke the old one. The last active
98
101
  key of a service cannot be revoked (`acl_denied`); to disable a service, set `enabled: false` on its account.
102
+ - Session by token: `svc.auth.session(token)` → `{ account, tenant, member, expiresAt, loginAt, methods } | null`.
103
+ The service (the `auth.session` permission) learns whose session it is, which tenant it works in and whether the
104
+ membership there is intact, without connecting with the token and without extending the session. A wrong, revoked
105
+ or expired token and a disabled account give `null`. This is how a web service checks the session from a cookie at
106
+ the start of every request: reading under a revoked session gives no error, the answers are simply empty.
99
107
  - Working in another tenant through a membership: `await user.auth.tenants()` → `[{ tenant, roles }]`, then
100
108
  `await user.auth.switch(tenant)` (only from the connection root). Impersonation by a service:
101
109
  `const u = await svc.as(account, { tenant, owner })` — the database checks it; in the journal, `author` is the user
@@ -1,4 +1,4 @@
1
- # letopis 1.3 — шпаргалка для агента
1
+ # letopis 1.4 — шпаргалка для агента
2
2
 
3
3
  Одна страница вместо [README.md](README.md) — для ИИ-агента, который пишет код поверх пакета. Лежит в пакете рядом с
4
4
  README; английская версия — [AGENT-CHEATSHEET.en.md](AGENT-CHEATSHEET.en.md). Переход с 0.21 — [MIGRATION.md](MIGRATION.md);
@@ -30,7 +30,7 @@ const r = await up({ dsn: ADMIN, schema: 'myapp', // схема
30
30
  tenant: { name: 'Салон', user: { name: 'Ольга', login: 'olga@salon.ru', password: 'Сирень-2026!' } } })
31
31
  // r: { schema, created, upgraded, revision, serviceKey, migrations, analyzed, tenant, … }
32
32
  // r.serviceKey ('lts_…') — ключ сервиса, выдаётся при первой установке (снова — только если в установке не осталось
33
- // действующих ключей API). Сохраните его.
33
+ // ключей API, кроме отозванных). Сохраните его.
34
34
  // r.tenant: { tenant, user, token, created } — арендатор «Салон» и его первый пользователь Ольга
35
35
  ```
36
36
  - `up()` ждёт базу, создаёт её, проверяет PostgreSQL 18+ и UTF8, ставит роли `letopis_owner` и `letopis_app`.
@@ -45,6 +45,9 @@ const r = await up({ dsn: ADMIN, schema: 'myapp', // схема
45
45
  `session:<16 hex>` — погашенная сессия, `session:*` и `*` — сбросы (`reset`). Режим хранится в схеме: `up()` без
46
46
  опции его не меняет; после смены — перезапуск процессов. Ключ сигналов (точный сброс кэша) получают сервисы — право
47
47
  `letopis.signals`. Результат `up()`: ещё `signals` и `warnings` (PostgreSQL ниже 18.2 — обновить).
48
+ - После обновления пакета вызывайте `up()`: функции схемы накатывает только он. `connect()` предупреждает, если штамп
49
+ движка в комментарии схемы (версия пакета и хэш его SQL) не совпадает с пакетом: схема старее — `up()`, новее —
50
+ обновить пакет.
48
51
  - `tenant` — первый арендатор и пользователь без своего сида: арендатор получает правило «аутентифицированным можно
49
52
  всё» (`grantAll`, по умолчанию да, вес 10), пользователь — членство с ролями `user.roles` (по умолчанию `['owner']`),
50
53
  пароль для входа по логину `login` и сессию в арендаторе. Повтор с тем же именем и логином ничего не дублирует и роли
@@ -74,14 +77,14 @@ db.tenant // арендатор сессии: по умолчанию
74
77
  allowBypassRls, models })`. `schema` — **полное** имя с `v2.`.
75
78
  - `db.auth`: `setPassword`, `verifyPassword`, `issueApiKey`, `verifyApiKey`, `issueKeySecret`, `verifyKeySecret`,
76
79
  `enrollTotp`, `verifyTotp`, `totpEnabled`, `issueOtp`, `verifyOtp`, `link`, `lookup`, `credentials`, `revokeCredential`,
77
- `setFlags`, `sessionFor(account, { ttl })`, `refresh`, `revoke`, `revokeAll`, `switch(tenant)`, `tenants()`,
78
- `purgeAccount`. Проверку паролей, OTP и TOTP, выпуск сессий и подтверждение кредов вызывает **сервис** (права
79
- `auth.*`); обычному пользователю — `acl_denied`. Сигнатуры: `issueApiKey({ account, name?, ttl? })` →
80
- `{ key, id }`; `verifyApiKey(key)` → `{ token, account } | null`; `sessionFor(account, { ttl })` → строка-токен —
81
- дальше `connect({ dsn, schema, token })`, а для работы в чужом арендаторе по членству — `db.auth.switch(арендатор)`
82
- (без него сессия — в собственном арендаторе аккаунта). Токен для CLI (`--token`) — тот же `token` из
83
- `verifyPassword`/`verifyApiKey`/`sessionFor`. Администраторское подключение (`allowBypassRls`) печатает
84
- предупреждение «RLS не действует» — так и должно быть.
80
+ `setFlags`, `sessionFor(account, { ttl })`, `session(token)`, `refresh`, `revoke`, `revokeAll`, `switch(tenant)`,
81
+ `tenants()`, `purgeAccount`. Проверку паролей, OTP и TOTP, выпуск сессий, сведения о сессии по токену и
82
+ подтверждение кредов вызывает **сервис** (права `auth.*`); обычному пользователю — `acl_denied`. Сигнатуры:
83
+ `issueApiKey({ account, name?, ttl? })` → `{ key, id }`; `verifyApiKey(key)` → `{ token, account } | null`;
84
+ `sessionFor(account, { ttl })` → строка-токен — дальше `connect({ dsn, schema, token })`, а для работы в чужом
85
+ арендаторе по членству — `db.auth.switch(арендатор)` (без него сессия — в собственном арендаторе аккаунта). Токен для
86
+ CLI (`--token`) — тот же `token` из `verifyPassword`/`verifyApiKey`/`sessionFor`. Администраторское подключение
87
+ (`allowBypassRls`) печатает предупреждение «RLS не действует» — так и должно быть.
85
88
  - Второй фактор: `verifyPassword({ identifier, password, totp })` и `lookup({ kind, identifier, totp })` после первой
86
89
  проверки сверяют код TOTP (право `auth.totp`, счётчик неудач и блокировка — общие с `verifyTotp`) → сессия со
87
90
  способами `[вид, 'TOTP']`, по ней пользователь с TOTP меняет свои креды сам. Неверный или повторный код и код у
@@ -90,6 +93,11 @@ db.tenant // арендатор сессии: по умолчанию
90
93
  `issueKeySecret` с `account` — id сервиса; `revokeCredential(id)`). Отзыв ключа гасит только сессии этого ключа —
91
94
  ротация без простоя: новый ключ → процессы на него → отзыв старого. Последний действующий ключ сервиса не
92
95
  отзывается (`acl_denied`); отключить сервис — `enabled: false` у его аккаунта.
96
+ - Сессия по токену: `svc.auth.session(token)` → `{ account, tenant, member, expiresAt, loginAt, methods } | null`.
97
+ Сервис (право `auth.session`) узнаёт, чья сессия, в каком арендаторе она работает и цело ли там членство, не
98
+ подключаясь по токену и не продлевая сессию. Неверный, погашенный или истёкший токен и отключённый аккаунт —
99
+ `null`. Так веб-сервис проверяет сессию из cookie в начале каждого запроса: чтение по погашенной сессии ошибки не
100
+ даёт, ответы просто пустые.
93
101
  - Работа в чужом арендаторе по членству: `await user.auth.tenants()` → `[{ tenant, roles }]`, затем
94
102
  `await user.auth.switch(tenant)` (только от корня подключения). Имперсонация сервисом:
95
103
  `const u = await svc.as(account, { tenant, owner })` — проверяет база, в журнале `author` — пользователь,
package/CHANGELOG.md CHANGED
@@ -2,6 +2,253 @@
2
2
 
3
3
  Формат: [Keep a Changelog](https://keepachangelog.com/), версии — semver.
4
4
 
5
+ ## [1.4.1] — 2026-11-01
6
+
7
+ Исправления, найденные прогоном примеров документации для людей (`guide/`).
8
+
9
+ ### Добавлено
10
+ - `db.auth.session(token)` — сведения о сессии по её токену для сервиса: чья сессия (`account`), в каком арендаторе
11
+ она работает (`tenant`), цело ли там членство (`member`; в собственном арендаторе — всегда `true`), когда она
12
+ истечёт (`expiresAt`), когда и как был вход (`loginAt`, `methods`). Подключение по этому токену не открывается,
13
+ срок сессии не продлевается. Неверный, погашенный или истёкший токен и отключённый аккаунт — `null`, причина не
14
+ различается. Вызывает сервис или системный администратор (право `auth.session`, как у `sessionFor`); пользователю
15
+ отвечают `acl_denied`, вызову без сессии — `no_session`. В базе — функция владельца `auth_session_info(text)`, в
16
+ пакете — новый тип `SessionInfo`; в схеме метод появляется после повторного `up()`. Пробел нашли сервисы-примеры
17
+ сайта (салон, helpdesk). Helpdesk теперь проверяет сессию в начале каждого запроса через `svc.auth.session(token)`,
18
+ а не вызовом `auth.tenants()` по подключению пользователя.
19
+
20
+ ### Исправлено
21
+ - `db.auth.purgeAccount(арендатор)` из сессии, которая работает в этом же арендаторе, откатывался с
22
+ `tenant_denied` («нет членства в арендаторе»): функция стирала членство вызывающего вместе с данными, а
23
+ надгробия и передача владения дальше перечитывали контекст. Теперь после проверки прав остаток операции идёт в
24
+ личном арендаторе вызывающего, его сессия больше не указывает на стёртого арендатора, а корень подключения
25
+ перечитывает арендатора и реестр. Подтверждает пример `guide/examples/auth/02-sessions-tenants.ts` (шаг 8):
26
+ без исправления он падает.
27
+ - `commit()` явной транзакции (`db.begin()`), в которой ошибку поймали и проглотили, бросал сырую ошибку
28
+ PostgreSQL `25P02` вместо обещанного README `tx_rolled_back`: чтение `letopis.changed` в одном конвейере с
29
+ COMMIT падало раньше, чем COMMIT отвечал ROLLBACK. Теперь на `25P02` библиотека дожидается ответа COMMIT и
30
+ бросает `tx_rolled_back`. Подтверждает пример `guide/examples/errors/01-api-errors.ts` (шаг 7): без исправления
31
+ он падает с `25P02`.
32
+ - Порядок надгробий одного уровня каскада в журнале (`seq`, время `at`) и строк итога
33
+ `delete({ confirm: true })` зависел от плана запроса и менялся от прогона к прогону: триггер удаления писал
34
+ надгробия вставкой без `order by`, а превью при этом шло по id. Теперь надгробия каждого уровня пишутся в порядке
35
+ id, итог — по уровням каскада, внутри уровня по id (README, раздел 7); цели классов без журнала — в конце, тоже по
36
+ id. Подтверждают примеры `guide/examples/delete/01-delete-cascade.ts` и `02-restore-purge.ts`: без исправления их
37
+ снимки то и дело расходятся порядком строк заказа.
38
+
39
+ - Порядок записей журнала у остальных массовых операций тоже определён, а не зависит от плана запроса: пакетная
40
+ вставка — в порядке входа, правка многих строк, перестройка проектора, загрузчик с `existing: 'update'` — по id;
41
+ смена владельца при выходе из членства и `purgeAccount` идут по арендаторам по порядку; глаголы по списку целей без
42
+ `sort` (`restore`, `purge`, `reclass`, `rekey`) обходят цели по id.
43
+ - Подписка, к которой больше суток (яруса `all` политики хранения её классов) не было событий, получала ложный
44
+ `cursor_expired`, хотя ничего не пропустила: время курсора оставалось временем последнего события, а база проверяет
45
+ его на каждом опросе. Теперь подписка, догнав журнал, ставит в свой курсор время опроса — «сейчас», но не позже самой
46
+ старой записи её классов, которая уже зафиксирована, а горизонт ещё не прошла; курсор события `ev.cursor` по-прежнему
47
+ хранит время события. `sub.cursor` больше не обгоняет события, прочитанные в запас: он стоит после последнего
48
+ события, которое отдал `next()`, поэтому продолжать после перезапуска нужно с него (README, раздел 12 «Подписка»).
49
+ Подтверждает пример `guide/examples/watch/02-quiet-cursor.ts`: без исправления шаг 3 падает с `cursor_expired`.
50
+ - `connect({ maintain })` не проверял интервал: ноль, отрицательное число, `NaN`, `Infinity` или интервал длиннее
51
+ 2³¹−1 мс (`'25d'`, `'30d'`) таймер Node.js превращал в 1 мс, и обслуживание шло без перерыва, а `null` и `true`
52
+ давали `TypeError`. Теперь интервал — число или строка, конечный, от 1 мс до 2³¹−1 мс (около 24 суток), иначе
53
+ `invalid_data`; проверка идёт до открытия пула и таймеров. `close()` ждёт идущий по расписанию запуск: он доделывает
54
+ текущую пачку и следующую не начинает, а пул закрывается после него. Подтверждает пример
55
+ `guide/examples/install/03-connect-options.ts` (шаг 6): без исправления подключения с такими интервалами открывались.
56
+ - `createTenant` и `up({ tenant })` принимали любой `user.kind`: вид в другом регистре (`password`), пустой или
57
+ служебный (`SESSION`, `TOTP` …). По такому паролю нельзя было войти, а служебным видом пароль выдавал себя за другой
58
+ кред: с видом `TOTP` аккаунт выглядел так, будто у него включён второй фактор (`totpEnabled: true`), с видом
59
+ `SESSION` пароль пропадал из `auth.credentials()`. Теперь вид проверяется по правилу функций кредов (`^[A-Z][A-Z0-9_]{1,31}$`, не `SESSION`, `APIKEY`,
60
+ `KEYSECRET`, `OTP`, `TOTP`, `KEY`, `PROJECTOR`, `LOCK`), иначе `invalid_data` до записи. Подтверждает пример
61
+ `guide/examples/install/01-up-and-connect.ts` (шаг 4): без исправления арендатор создавался.
62
+ - Параллельные `up()` разных схем одной базы падали с `tuple concurrently updated` (`XX000`): общую часть
63
+ установки — роли `letopis_owner` и `letopis_app`, членство установщика, pgcrypto и право владельца на схему pgcrypto —
64
+ каждый `up()` менял одновременно с другими, а `grant usage on schema` переписывает строку схемы в каталоге, даже если
65
+ право уже выдано. На чистой базе так же столкнулись бы `create role` и `create extension`. Теперь общая часть — одна
66
+ транзакция под advisory-блокировкой: следующий `up()` ждёт, пока предыдущий её зафиксирует. Блокировка действует в
67
+ пределах базы, а роли общие для кластера, поэтому роль или членство, которые одновременно создал `up()` в другой
68
+ базе, считаются уже созданными. Нашёл стенд сайта (`services/compose.yaml`): helpdesk падал при одновременном
69
+ запуске четырёх сервисов; без исправления 4 из 8 параллельных `up()` падали в каждом круге.
70
+ - Одновременные `up()` одной и той же схемы (несколько экземпляров сервиса при запуске) падали: второй
71
+ `create schema` — на уникальном индексе каталога (`23505`), второй `create or replace function` или `comment` —
72
+ `tuple concurrently updated` (`XX000`), а `up({ tenant })` дважды вставлял того же арендатора. Ревизию, от которой
73
+ считаются миграции, `up()` читал до наката, поэтому второй `upgrade` повторил бы миграции, уже накатанные первым.
74
+ Теперь накат схемы — `fresh`, чтение ревизии, `create schema`, миграции, ядро, сиды и ключ сервиса — одна транзакция
75
+ под advisory-блокировкой схемы (ключ — её имя), и `createTenant` берёт ту же блокировку. Следующий `up()` ждёт
76
+ предыдущий и делает повторный накат по готовой схеме: `created` и `upgraded` он считает по ревизии, которую увидел
77
+ под блокировкой. `up()` разных схем друг друга не ждут. Подтверждает пример
78
+ `guide/examples/install/02-up-options.ts` (шаг 6): без исправления он падал 5 раз из 5.
79
+ - `out.delete` и `out.get` проектора у производного класса с ключом, получив один id (строкой или `{ $id }`), искали
80
+ строку с пустым ключом: `get` отвечал `null`, а `delete` выводил арендатора с `id_mismatch`. Теперь это сразу
81
+ `invalid_data` с перечнем полей ключа — ошибка кода `handle`, пачка откатывается целиком; строку класса с ключом
82
+ называют полями ключа, id — только у класса без ключа (README, раздел 12 «Проекторы»). Подтверждает пример
83
+ `guide/examples/derived/03-projector-options.ts` (шаги 3 и 4): без исправления `get` по id отвечал `null`, а
84
+ `run()` проходил без ошибки.
85
+ - Строки `secret` удалённого аккаунта хранились вечно: удаление строки `Account` гасило сессии и ставило способам
86
+ входа `deleted_at`, а `maintain()` физически убирает только сессии и коды вида `OTP` — хэши паролей и ключей,
87
+ секреты TOTP и внешние учётные записи удалённого аккаунта оставались в базе. Теперь их уносит внешний ключ
88
+ `secret.account → entity(id) on delete cascade` в том же операторе удаления. Сессии гаснут с сигналом
89
+ `session:<хэш>` раньше каскада — в новом триггере `entity_bd_account` (до удаления, для всех ролей, в том числе
90
+ владельца: аккаунты удаляют и каскад `trg_entity_ad`, и `purgeAccount`), поэтому процессы по-прежнему сбрасывают кэш
91
+ ответов этих сессий; пометка `deleted_at` в триггере удаления снята. Первая заливка снимает внешний ключ до `TRUNCATE`
92
+ и возвращает его после первичного ключа `entity`; `letopis import` пропускает креды аккаунтов, которых нет в
93
+ `Account` версии 0.21 (считает их в `skipped`). Подтверждает пример `guide/examples/auth/03-delete-account.ts`: без
94
+ исправления шаг 3 находит в `secret` три строки удалённого аккаунта, а шаг 4 не получает `acl_denied`.
95
+ - Описание `maintain()` в README расходилось с кодом и JSDoc: он удаляет не только истёкшие сессии и коды, но и
96
+ погашенные сессии и использованные, заменённые или исчерпавшие попытки коды вида `OTP`. В README перечислены и
97
+ занятые виды кредов `PROJECTOR` и `LOCK`.
98
+ - `.tags(has(…))`, `.tags(hasAny(…))` и `.tags(hasAll(…))` перед `update()` падали сырой ошибкой Node.js
99
+ `ERR_INVALID_ARG_TYPE`: оператор отбора библиотека пыталась записать в колонку тегов. Теперь оператор перед
100
+ `update()` только отбирает строки, а их теги не меняются; строка или список, как и раньше, и отбирают строки, и
101
+ заменяют их теги целиком. Подтверждает пример `guide/examples/write/05-batch-tags-reason.ts` (шаг 6): без
102
+ исправления он падает.
103
+
104
+ - Срез `asOf()` и надгробия `withDeleted()` на обратном переходе отдавали объект столько раз, на сколько объектов
105
+ прошлого шага он ссылается: товар в двух категориях на шаге `db.Category().Product().asOf(t)` приходил в `rows()`
106
+ дважды, `count()` и итоги его удваивали. Без среза повторы отбрасывались, а пары переходов среза журнала шли в узел
107
+ как есть. Теперь узел — одна строка на объект, `paths()` по-прежнему отдаёт путь на каждую ссылку. Подтверждает
108
+ пример `guide/examples/history/01-versions-asof-diff-blame.ts` (шаг 6): без исправления он падает.
109
+ - Строки ответа `update()` и `anonymize()` шли в порядке обхода таблицы, а не целей, поэтому `first()` после глагола
110
+ отдавал случайную из исправленных строк. Теперь ответ — в порядке целей: по `sort()` до глагола, без него — по id.
111
+ Подтверждает пример `guide/examples/write/01-create-update-upsert.ts` (шаг 7): без исправления он падает.
112
+ - `maintain()` удалял только одноразовые коды вида `OTP`, а использованные и истёкшие коды своих видов `OTP_<ИМЯ>`
113
+ (`auth.issueOtp({ kind })`) оставались в `secret` навсегда. Теперь он удаляет коды всех видов, которые выпускает
114
+ `issueOtp`. Подтверждает пример `guide/examples/auth/01-login-factors.ts` (шаг 6): без исправления он падает.
115
+ - `min()` и `max()` по логическому полю падали сырой ошибкой PostgreSQL (у `boolean` нет `min` и `max`). Теперь
116
+ `false` меньше `true`: `min` — `false`, если есть хоть одно `false`, `max` — `true`, если есть хоть одно `true`.
117
+ Подтверждает пример `guide/examples/read/03-sort-pages-aggregates.ts` (шаг 7).
118
+ - `verifyHash()` и вход `auth.verifyPassword()` по хэшу scrypt, которому нужно больше 64 МБ памяти или с
119
+ недопустимыми N, r, p, бросали сырую ошибку Node.js; теперь — `invalid_data` с объяснением. `compileValidator()` с
120
+ шаблоном `pattern`, неверным для JavaScript, бросал `SyntaxError`; теперь — `invalid_class` с путём поля.
121
+ Подтверждает пример `guide/examples/models/06-exports.ts` (шаги 3 и 5).
122
+
123
+ ### Изменено
124
+ - `letopis types` и `letopis describe` без действующей сессии (токен истёк, погашен, неверен или не задан) выдавали
125
+ пустой результат с кодом 0, а с `--out` затирали им файл. Теперь они выходят с кодом 1 и ошибкой `no_session`, файл
126
+ не трогают. Подтверждает пример `guide/examples/ops/04-cli.ts` (шаг 5).
127
+ - `connect()` на схеме, ревизия которой новее пакета (её обновил более новый пакет), молчал; теперь он подключается и
128
+ предупреждает «обновите пакет letopis», как предупреждает об отставшей схеме. Подтверждает пример
129
+ `guide/examples/install/05-engine-stamp.ts` (шаг 4).
130
+ - Описания методов (JSDoc), которые справочник сайта показывал непонятными, переписаны простыми словами: цепочка и
131
+ операторы фильтра, `db.as`, `db.verify`, `db.reset`, `db.trimHistory`, способы входа, подписка, проектор, загрузчик,
132
+ кэш, `up()` и `createTenant`, MCP, валидатор, константы. Исправлено неверное описание `InstallResult.serviceKey`:
133
+ новый ключ сервиса выпускается на любом накате, где не осталось ни одного неотозванного ключа API.
134
+ - README: ключ сервиса `up()` выдаёт, когда не осталось ни одного ключа API, кроме отозванных (истёкший или
135
+ выключенный ключ тоже в счёт), — было «действующего»; класс из `into` проектора источником не бывает, даже если
136
+ попадает в `from`, — было «не может быть одновременно источником и результатом», будто это запрет.
137
+ - Модификатор выборки (`limit`, `offset`, `sort`, `after`, `asOf`, `withDeleted`, `cache`) сразу после глагола, за
138
+ которым идёт терминал `rows`, `first`, `ids`, `count` или `paths`, — теперь `invalid_query` до записи. Раньше
139
+ `db.Product().update(…).limit(1).rows()` правил все товары, а в ответе отдавал одну строку; `offset`, `sort` и
140
+ остальные молча пропускались, а у `ids` и `count` — и `limit`. Цели глагола сужают модификаторы до него. В батче
141
+ так же отказывает `run()`. После глагола модификатор по-прежнему действует на чтение за ним (шаг класса,
142
+ `versions()`, `diff()`, `blame()`, агрегаты) и на цели следующего глагола. Подтверждают примеры
143
+ `guide/examples/write/01-create-update-upsert.ts` (шаг 8) и `guide/examples/write/05-batch-tags-reason.ts`
144
+ (шаг 9): без изменения они падают.
145
+ - Ревизия движка 7: внешний ключ `secret.account` (миграция `0007` сначала стирает строки `secret`, чьих аккаунтов уже
146
+ нет в `entity`, затем ставит ключ) и индекс `secret_account`. Схемы ревизии 6 (1.1–1.4.0) обновляются через
147
+ `up({ upgrade: true })`: без него `up()` отказывает, а `connect()` предупреждает об отставшей ревизии.
148
+ - Строку `Account` аккаунта System удалить нельзя — `acl_denied` (как уже отказывал `purgeAccount`): ему принадлежат
149
+ служебные строки установки в `secret` (ключ прав и сигналов, строки-замки, чекпоинты проекторов), которые каскад
150
+ внешнего ключа стёр бы. `seed_system()` создаёт аккаунт System раньше строки ключа.
151
+ - Убран код ошибки `not_implemented` (охранник `notYet`): в 1.4 его не бросал ни один метод.
152
+ - Тексты для людей:
153
+ - ошибка снятой опции `connect()` согласована по роду: «опция connect({ … }) снята в 1.0.0»;
154
+ - предупреждение о схеме, которую не обновили после смены пакета, — «обновите схему — вызовите up(…)» вместо
155
+ «накатите up(…)»;
156
+ - ошибка `rev_ambiguous` — «проверить версию { rev } можно только у одной цели» вместо «строгий режим только
157
+ для одной»;
158
+ - причина выбывания арендатора проектора по `lockRetries` (`status().failed`, код `55P03`) — «держит чужая
159
+ транзакция — в течение 3 проходов подряд дольше lockTimeout» вместо «— 3 проходов подряд…»: число и слово не
160
+ согласовывались («1 проходов», «3 проходов»).
161
+ - Тест `retention` «7.8.1 №7» больше не зависит от даты: 29–31 числа «сейчас» эталона уходило в прошлое, и проверка
162
+ разрыва свежей версии падала при исправной библиотеке.
163
+
164
+ ## [1.4.0] — 2026-10-29
165
+
166
+ letopis 1.4 — доработки после выпуска 1.3.0. Вошли:
167
+ - хвост ревью безопасности доработок после 1.2.0 — все девять замечаний, плюс находка автоматического ревью:
168
+ администратора база определяла по роли входа, а не по действующей роли сеанса;
169
+ - штамп версии движка в схеме: `connect()` замечает схему, которую после обновления пакета не накатили;
170
+ - опция проектора `lockRetries`.
171
+
172
+ Версия `1.4.0` (во время работы — `1.4.0-alpha.0`, не публиковалась). Схема 1.3.0 обновляется повторным `up()`:
173
+ ревизия движка прежняя (6), функции накатываются заново. До наката `connect()` новой версии предупреждает о схеме без
174
+ штампа и советует `up({ schema })`. Новое в API:
175
+ - опция проектора `lockRetries` и поле отчёта `ProjectorReport.locked`;
176
+ - штамп движка в комментарии схемы и предупреждение `connect()` о схеме другой версии.
177
+
178
+ Поведение, которое стоит проверить при обновлении:
179
+ - на подключении, где роль приложения поставлена поверх привилегированной роли входа (`SET ROLE`, параметр запуска
180
+ `role`), пользователи больше не получают права `letopis.*`;
181
+ - новый источник агрегата должен без условий читать и тот, кто описал агрегат, иначе `acl_denied`; агрегат можно
182
+ описать заново от того, кто читает;
183
+ - предки и алиас класса System больше не дают прав на строки одноимённого класса арендатора;
184
+ - запись, дождавшаяся удаления своего класса, получает `invalid_class` «класс … удалён».
185
+
186
+ ### Added — проектор: изоляция арендаторов при чужой блокировке
187
+ - Опция проектора `lockRetries` (решение владельца, кандидат 6 из 10 «`lockTimeout` не изолирует арендаторов»). Задаёт,
188
+ сколько проходов подряд запись одного арендатора может упираться в `lockTimeout`. Дальше арендатор выбывает с кодом
189
+ `55P03`, и пачка идёт для остальных; его изменения пропускаются до `rebuild({ tenant })`. По умолчанию 0: арендатор
190
+ не выбывает, и проход повторяется для всех, как раньше. Счёт ведёт процесс, удачная запись арендатора его
191
+ сбрасывает.
192
+ - Отчёт `run()` называет арендаторов, на чьей строке вставала пачка: новое поле `ProjectorReport.locked`.
193
+ - `rebuild({ tenant, chunked: true })` пережидает чужую блокировку строки арендатора (кандидат 5 из 10). Раньше
194
+ `startTenant` отдавал вызывающему сырую ошибку 55P03, хотя состояние не портилось. Так же ведёт себя чтение
195
+ чекпоинта в конце перестройки, а пропущенные из-за блокировки проходы идут в `skipped`.
196
+
197
+ ### Added — штамп движка в схеме
198
+ - `up()` пишет в комментарий схемы, кроме ревизии, штамп движка: версию пакета и 16 знаков sha256 исходного текста
199
+ файлов `sql/NN-*.sql` (`'letopis ddl_revision=6 engine=1.4.0 sql=…'`). Ревизия у 1.1–1.4 одна, а функции разные, и
200
+ `connect()` раньше не замечал схему, которую после обновления пакета не накатили (так было при 1.1 → 1.2).
201
+ - `connect()` при той же ревизии сверяет штамп с файлами своего пакета и при расхождении предупреждает
202
+ (`console.warn`). Схема без штампа (до 1.4) или со штампом более старой версии — совет `up({ schema })`. Штамп более
203
+ новой версии — совет обновить пакет: `up()` старой версией вернул бы прежние функции. Новая версия пакета без правок
204
+ SQL штамп не меняет. Если файлов SQL рядом со сборкой нет, сверка молча пропускается.
205
+
206
+ ### Security — хвост ревью безопасности доработок после 1.2.0
207
+ - `purge` и `trimHistory` проверяют, прочёл ли проектор источника каждую версию строки, а не одну (кандидат 5 из 10).
208
+ Раньше `purge` смотрел только надгробие, а обрезка — самую позднюю по `(tx, seq)` версию у стыка. Но у более поздней
209
+ версии бывает меньший xid, а снимок перестройки учитывает транзакции, зафиксированные до него. Поэтому непрочитанной
210
+ могла остаться версия, которую проверка пропускала, и `purge` или обрезка стирали её. Проверку делает функция
211
+ владельца `projector_unread(uuid, uuid, int)`, прежняя `projector_behind` удаляется при накате.
212
+ - Удаление класса больше не расходится с параллельной записью (кандидат 5 из 10, «гонка смены алиаса и удаления
213
+ потомка»). Удаление проверяет, что у класса нет строк, потомков и истории, но раньше проверка не видела строку, которую
214
+ писатель ещё не зафиксировал: строка оставалась без класса, а вместе с ней появлялась история удалённого класса, от
215
+ которой зависит запрет смены алиаса предка. Теперь так:
216
+ - удаление класса берёт исключительную блокировку его семейства, поэтому ждёт писателей и видит их строки;
217
+ - создание класса и смена `parent` берут разделяемую блокировку семейства родителя, поэтому родитель не удаляется мимо
218
+ нового потомка;
219
+ - писатель, дождавшийся блокировки, проверяет, что класс ещё есть (`invalid_class`, «класс … удалён»). Так делают
220
+ вставка, правка, `reclass`, `restore`, `rekey` и запись проектора.
221
+ - План прав берёт один класс на имя — тот, что находит `class_row`: свой класс арендатора, иначе класс System (замечание
222
+ `/security-review` ниже порога). Раньше в план попадали оба одноимённых класса, и побеждало разрешение любого из них.
223
+ Поэтому предки и алиас класса System, заведённого после класса арендатора, давали права на строки класса арендатора.
224
+ - Правка класса System без права `letopis.projector` больше не упирается в производных потомков у арендаторов со своим
225
+ одноимённым классом (кандидат 5 из 10, лишняя строгость): их потомки наследуют класс арендатора, а не класс System.
226
+ - Подписка подключения в обход RLS получает события ухода строки из классов подписки (кандидат 6 из 10). Раньше без
227
+ сессии `watch_leaving` их не отдавал (`acl_actor` — null), хотя `watch_read` такому подключению отдаёт журнал всех
228
+ арендаторов. Теперь роль сеанса (суперпользователь, `BYPASSRLS`, член роли-владельца) видит все события ухода, с
229
+ автором.
230
+ - Администратором и подключением в обход RLS база считает действующую роль сеанса, а не роль входа (находка
231
+ автоматического ревью коммита). Внутри функции владельца `session_user` — роль входа, даже если поверх неё поставлена
232
+ роль приложения (`SET ROLE` или параметр запуска `role`). Так бывает у пула с одной привилегированной ролью входа: там
233
+ `connect()` видит роль приложения, а `admin_role()` с 1.0 видела суперпользователя. Поэтому любой пользователь
234
+ арендатора на таком подключении получал права `letopis.*` (`verify`, `reset`, `letopis.projector` и другие), а
235
+ подписка — события ухода чужих арендаторов. Новая функция владельца `session_role()` берёт роль `SET ROLE`, иначе роль
236
+ входа; через неё работают `admin_role()` и `watch_leaving`.
237
+ - Новый источник агрегата проверяется и по тому, кто описал агрегат (кандидат 6 из 10, решение владельца). Раньше право
238
+ `READ` проверялось только у того, кто заводит потомка источника (или переносит класс под него сменой `parent`). Тот,
239
+ кто описал агрегат без права на строки будущего потомка, читал бы в истории цели вклад каждой из них. Теперь
240
+ описавший тоже должен без условий читать все строки нового источника, иначе `acl_denied`; агрегат можно описать
241
+ заново от того, кто их читает. Описавший — автор версии строки `Class` цели, с которой описание агрегата (своё или
242
+ унаследованное) стало нынешним; журнал строк `Class` не стирается. Объём проверки — как при описании: у агрегата
243
+ класса арендатора — классы арендатора, у агрегата класса System — классы System; класс арендатора под агрегатом System
244
+ проверяет только заводящий.
245
+
246
+ ### Fixed — хвост ревью безопасности доработок после 1.2.0
247
+ - `syncModels` отклоняет модели по кругу до записи: `invalid_class` называет круг (кандидат 4 из 10). Пример — агрегат
248
+ над собственным потомком: потомок ждёт родителя, а цель ждёт источник агрегата. Раньше обход молча ставил потомка
249
+ раньше предка, и база отвечала «предок … не найден». В два шага такой агрегат отклоняет уже база: потомок наследует
250
+ агрегат над самим собой, и выходит «цикл агрегатов».
251
+
5
252
  ## [1.3.0] — 2026-10-28
6
253
 
7
254
  letopis 1.3 — доработки после выпуска 1.2.0 (`docs/checkpoint-1.3.md`). Вошли три направления: