letopis 1.2.0 → 1.4.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.
@@ -1,4 +1,4 @@
1
- # letopis 1.2 — 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:
@@ -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
@@ -263,7 +266,7 @@ const canDelete = await olga.acl.checkData('Wallet', 'DELETE') // { allow: fa
263
266
 
264
267
  <!-- run -->
265
268
  ```ts
266
- const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (default) | 'start' | a cursor; pollMs
269
+ const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (default) | 'start' | a cursor; pollMs; leaving classes — op 'delete'
267
270
  const lagMs = await sub.lag() // lag, ms
268
271
  await db.Wallet(w).update({ balance: inc(1) }).rows()
269
272
  let cursor = ''
@@ -325,8 +328,8 @@ npx letopis import --from "$ADMIN" --from-schema v1.fixture --dsn "$ADMIN" --sch
325
328
 
326
329
  A projector builds rows of derived classes from the journal: summaries, cards, anything that may be computed with a
327
330
  lag. A derived class is marked with `derivedBy: 'projector-name'`, and only this projector writes its rows. The
328
- derived class itself (own or inherited) is created, edited and deleted only by an administrator: `syncModels` on an
329
- admin connection holding a tenant session (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) or an
331
+ derived class itself (own or inherited) and its ancestors are created, edited and deleted only by an administrator:
332
+ `syncModels` on an admin connection holding a tenant session (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) or an
330
333
  account with the System permission `letopis.projector`; for an application session it is `acl_denied`. Everyone whom
331
334
  the rules give `READ` on a derived class sees its totals, even without permissions on the source rows.
332
335
  ```ts
@@ -348,7 +351,7 @@ have no such field, so subtract `before` only after checking it for `null`. A cl
348
351
  without a key) and takes the row out of the `from` family comes as a deletion (`op` `delete`, `row` is the last
349
352
  version in the family with `$deleted`). `handle` gets the changes of one tenant. An error of its data (the
350
353
  database rejected its results, or `out` rejected its row or delta) rolls back only this tenant: it drops out
351
- (`status().failed`: `{ id: { cursor, code, message } }`) until `rebuild({ tenant: id })` (`{ tenant, chunked: true }` — in batches while the tail goes on for all;
354
+ (`status().failed`: `{ id: { cursor, code, message } }`) until `rebuild({ tenant: id })` (`{ tenant, chunked: true }` — in batches while the tail goes on for all, an interrupted one is continued by any `run()`;
352
355
  `status().rebuildingTenants`), and the others go on; with `onExpired: 'rebuild'` a tenant dropped by the tier rebuilds
353
356
  itself in batches, in any process;
354
357
  `tenants: [id, …]` is an explicit list of participants.
@@ -374,7 +377,9 @@ A trigger recalculates the field after every statement (the library, raw SQL, a
374
377
  `rekey`, the loader); going below `minimum` is `invalid_data`, every change is a version of the target. Live rows of
375
378
  the same tenant are counted (a System row is not). Repair: `adm.rebuildAggregates('Wallet')` on an admin connection;
376
379
  discrepancies: `verify({ data: true })`. Only someone who reads all rows of the source and its descendants without
377
- conditions can describe or change an aggregate, otherwise `acl_denied`. A hot counter (more than ~150 writes per
380
+ conditions can describe or change an aggregate, otherwise `acl_denied`. The source is described before the aggregate:
381
+ an aggregate over a class that does not exist yet is refused (`invalid_class`), and the `of` class is not deleted while an
382
+ aggregate names it; `syncModels` puts sources before targets itself. A hot counter (more than ~150 writes per
378
383
  second per object) is a projector that folds a batch, not an aggregate.
379
384
 
380
385
  ### 14. Agents and MCP
@@ -422,7 +427,7 @@ await adm.close(); await db.close(); await svc.close()
422
427
  if the row was deleted between the lookup and the edit.
423
428
  3. **Verbs return a chain.** Without a terminal (`.rows()`, `.first()` …) nothing runs. `delete()` without
424
429
  `{ confirm: true }` is only a preview; `purge` works only on deleted entities (`not_deleted`) and once a projector
425
- of the class has read the deletion (`projector_pending`).
430
+ of the class has read the deletion (`projector_pending`); `trimHistory`, once the projector has read the versions.
426
431
  4. **Money and counters**: `update({ balance: inc(n) })` — the database adds inside the statement, without a read and
427
432
  without lost updates; the `minimum` check in the schema catches a drop below zero. For a total computed from what
428
433
  you read, use `{ rev }` (re-read and retry on `conflict`) or `forUpdate()` in `db.begin()`. Idempotency comes from
@@ -1,4 +1,4 @@
1
- # letopis 1.2 — шпаргалка для агента
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);
@@ -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` и сессию в арендаторе. Повтор с тем же именем и логином ничего не дублирует и роли
@@ -254,7 +257,7 @@ const canDelete = await olga.acl.checkData('Wallet', 'DELETE') // { allow: fa
254
257
 
255
258
  <!-- run -->
256
259
  ```ts
257
- const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (по умолчанию) | 'start' | курсор; pollMs
260
+ const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (по умолчанию) | 'start' | курсор; pollMs; уход из classes — op 'delete'
258
261
  const lagMs = await sub.lag() // отставание, мс
259
262
  await db.Wallet(w).update({ balance: inc(1) }).rows()
260
263
  let cursor = ''
@@ -316,8 +319,8 @@ npx letopis import --from "$ADMIN" --from-schema v1.fixture --dsn "$ADMIN" --sch
316
319
 
317
320
  Проектор строит строки производных классов из журнала: сводки, карточки, всё, что можно считать с отставанием.
318
321
  Производный класс помечен `derivedBy: 'имя-проектора'`, и его строки пишет только этот проектор. Сам производный
319
- класс (свой или унаследованный) заводит, меняет и удаляет только администратор: `syncModels` администраторским
320
- подключением с сессией арендатора (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или аккаунт с
322
+ класс (свой или унаследованный) и его предков заводит, меняет и удаляет только администратор: `syncModels`
323
+ администраторским подключением с сессией арендатора (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или аккаунт с
321
324
  правом System `letopis.projector`; у сессии приложения — `acl_denied`. Итоги производного класса видит каждый, кому
322
325
  правила дают `READ` на него, даже без прав на строки источников.
323
326
  ```ts
@@ -339,7 +342,7 @@ p.run().catch(report) // run() без once — с catch: после ош
339
342
  `row` — последняя версия в семействе с `$deleted`). `handle` получает изменения
340
343
  одного арендатора. Ошибку его данных (база отклонила его результаты или `out` — его строку или приращение)
341
344
  откатывает только он: арендатор выбывает (`status().failed`: `{ id: { cursor, code, message } }`) до
342
- `rebuild({ tenant: id })` (`{ tenant, chunked: true }` — пачками, пока хвост идёт для всех; `status().rebuildingTenants`),
345
+ `rebuild({ tenant: id })` (`{ tenant, chunked: true }` — пачками, пока хвост идёт для всех, прерванную продолжает любой `run()`; `status().rebuildingTenants`),
343
346
  остальные идут дальше; с `onExpired: 'rebuild'` выбывший по ярусу перестраивается пачками сам, в любом процессе; `tenants: [id, …]` — явный список участников. `status().state`: `'new'` —
344
347
  проекции ещё нет, `'running'` — проекция построена и ведётся (а не «процесс сейчас работает»), `'rebuilding'` — идёт
345
348
  или прервана перестройка пачками.
@@ -362,7 +365,9 @@ await db.Wallet(w).Payment().create({ opId: 'op-9', amount: 500 }).rows() //
362
365
  загрузчик); уход ниже `minimum` — `invalid_data`, каждое изменение — версия цели. Считаются живые строки того же
363
366
  арендатора (строка System — нет). Ремонт — `adm.rebuildAggregates('Wallet')` на администраторском подключении,
364
367
  расхождения — `verify({ data: true })`. Описать или изменить агрегат может только тот, кто без условий читает все строки
365
- источника и его потомков, иначе `acl_denied`. Горячий счётчик (больше ~150 записей в секунду на объект) —
368
+ источника и его потомков, иначе `acl_denied`. Источник описывают раньше агрегата: над классом, которого ещё нет,
369
+ агрегат не описывается (`invalid_class`), а класс `of` не удаляется, пока агрегат его называет; `syncModels` сам ставит
370
+ источники раньше целей. Горячий счётчик (больше ~150 записей в секунду на объект) —
366
371
  проектор со свёрткой пачкой, а не агрегат.
367
372
 
368
373
  ### 14. Агенты и MCP
@@ -407,7 +412,7 @@ await adm.close(); await db.close(); await svc.close()
407
412
  и правкой.
408
413
  3. **Глаголы возвращают цепочку.** Без терминала (`.rows()`, `.first()` …) ничего не исполнится. `delete()` без
409
414
  `{ confirm: true }` — только превью; `purge` — только для удалённых (`not_deleted`) и после того, как проектор
410
- класса прочёл удаление (`projector_pending`).
415
+ класса прочёл удаление (`projector_pending`); `trimHistory` — после того, как проектор прочёл версии.
411
416
  4. **Деньги и счётчики** — `update({ balance: inc(n) })`: база прибавляет внутри оператора, без чтения и потерянных
412
417
  обновлений; проверка `minimum` в схеме ловит уход в минус. Итог, вычисленный из прочитанного, — `{ rev }`
413
418
  (перечитать и повторить при `conflict`) или `forUpdate()` в `db.begin()`. Идемпотентность — ключом операции
package/CHANGELOG.md CHANGED
@@ -2,6 +2,219 @@
2
2
 
3
3
  Формат: [Keep a Changelog](https://keepachangelog.com/), версии — semver.
4
4
 
5
+ ## [1.4.0] — 2026-10-29
6
+
7
+ letopis 1.4 — доработки после выпуска 1.3.0. Вошли:
8
+ - хвост ревью безопасности доработок после 1.2.0 — все девять замечаний, плюс находка автоматического ревью:
9
+ администратора база определяла по роли входа, а не по действующей роли сеанса;
10
+ - штамп версии движка в схеме: `connect()` замечает схему, которую после обновления пакета не накатили;
11
+ - опция проектора `lockRetries`.
12
+
13
+ Версия `1.4.0` (во время работы — `1.4.0-alpha.0`, не публиковалась). Схема 1.3.0 обновляется повторным `up()`:
14
+ ревизия движка прежняя (6), функции накатываются заново. До наката `connect()` новой версии предупреждает о схеме без
15
+ штампа и советует `up({ schema })`. Новое в API:
16
+ - опция проектора `lockRetries` и поле отчёта `ProjectorReport.locked`;
17
+ - штамп движка в комментарии схемы и предупреждение `connect()` о схеме другой версии.
18
+
19
+ Поведение, которое стоит проверить при обновлении:
20
+ - на подключении, где роль приложения поставлена поверх привилегированной роли входа (`SET ROLE`, параметр запуска
21
+ `role`), пользователи больше не получают права `letopis.*`;
22
+ - новый источник агрегата должен без условий читать и тот, кто описал агрегат, иначе `acl_denied`; агрегат можно
23
+ описать заново от того, кто читает;
24
+ - предки и алиас класса System больше не дают прав на строки одноимённого класса арендатора;
25
+ - запись, дождавшаяся удаления своего класса, получает `invalid_class` «класс … удалён».
26
+
27
+ ### Added — проектор: изоляция арендаторов при чужой блокировке
28
+ - Опция проектора `lockRetries` (решение владельца, кандидат 6 из 10 «`lockTimeout` не изолирует арендаторов»). Задаёт,
29
+ сколько проходов подряд запись одного арендатора может упираться в `lockTimeout`. Дальше арендатор выбывает с кодом
30
+ `55P03`, и пачка идёт для остальных; его изменения пропускаются до `rebuild({ tenant })`. По умолчанию 0: арендатор
31
+ не выбывает, и проход повторяется для всех, как раньше. Счёт ведёт процесс, удачная запись арендатора его
32
+ сбрасывает.
33
+ - Отчёт `run()` называет арендаторов, на чьей строке вставала пачка: новое поле `ProjectorReport.locked`.
34
+ - `rebuild({ tenant, chunked: true })` пережидает чужую блокировку строки арендатора (кандидат 5 из 10). Раньше
35
+ `startTenant` отдавал вызывающему сырую ошибку 55P03, хотя состояние не портилось. Так же ведёт себя чтение
36
+ чекпоинта в конце перестройки, а пропущенные из-за блокировки проходы идут в `skipped`.
37
+
38
+ ### Added — штамп движка в схеме
39
+ - `up()` пишет в комментарий схемы, кроме ревизии, штамп движка: версию пакета и 16 знаков sha256 исходного текста
40
+ файлов `sql/NN-*.sql` (`'letopis ddl_revision=6 engine=1.4.0 sql=…'`). Ревизия у 1.1–1.4 одна, а функции разные, и
41
+ `connect()` раньше не замечал схему, которую после обновления пакета не накатили (так было при 1.1 → 1.2).
42
+ - `connect()` при той же ревизии сверяет штамп с файлами своего пакета и при расхождении предупреждает
43
+ (`console.warn`). Схема без штампа (до 1.4) или со штампом более старой версии — совет `up({ schema })`. Штамп более
44
+ новой версии — совет обновить пакет: `up()` старой версией вернул бы прежние функции. Новая версия пакета без правок
45
+ SQL штамп не меняет. Если файлов SQL рядом со сборкой нет, сверка молча пропускается.
46
+
47
+ ### Security — хвост ревью безопасности доработок после 1.2.0
48
+ - `purge` и `trimHistory` проверяют, прочёл ли проектор источника каждую версию строки, а не одну (кандидат 5 из 10).
49
+ Раньше `purge` смотрел только надгробие, а обрезка — самую позднюю по `(tx, seq)` версию у стыка. Но у более поздней
50
+ версии бывает меньший xid, а снимок перестройки учитывает транзакции, зафиксированные до него. Поэтому непрочитанной
51
+ могла остаться версия, которую проверка пропускала, и `purge` или обрезка стирали её. Проверку делает функция
52
+ владельца `projector_unread(uuid, uuid, int)`, прежняя `projector_behind` удаляется при накате.
53
+ - Удаление класса больше не расходится с параллельной записью (кандидат 5 из 10, «гонка смены алиаса и удаления
54
+ потомка»). Удаление проверяет, что у класса нет строк, потомков и истории, но раньше проверка не видела строку, которую
55
+ писатель ещё не зафиксировал: строка оставалась без класса, а вместе с ней появлялась история удалённого класса, от
56
+ которой зависит запрет смены алиаса предка. Теперь так:
57
+ - удаление класса берёт исключительную блокировку его семейства, поэтому ждёт писателей и видит их строки;
58
+ - создание класса и смена `parent` берут разделяемую блокировку семейства родителя, поэтому родитель не удаляется мимо
59
+ нового потомка;
60
+ - писатель, дождавшийся блокировки, проверяет, что класс ещё есть (`invalid_class`, «класс … удалён»). Так делают
61
+ вставка, правка, `reclass`, `restore`, `rekey` и запись проектора.
62
+ - План прав берёт один класс на имя — тот, что находит `class_row`: свой класс арендатора, иначе класс System (замечание
63
+ `/security-review` ниже порога). Раньше в план попадали оба одноимённых класса, и побеждало разрешение любого из них.
64
+ Поэтому предки и алиас класса System, заведённого после класса арендатора, давали права на строки класса арендатора.
65
+ - Правка класса System без права `letopis.projector` больше не упирается в производных потомков у арендаторов со своим
66
+ одноимённым классом (кандидат 5 из 10, лишняя строгость): их потомки наследуют класс арендатора, а не класс System.
67
+ - Подписка подключения в обход RLS получает события ухода строки из классов подписки (кандидат 6 из 10). Раньше без
68
+ сессии `watch_leaving` их не отдавал (`acl_actor` — null), хотя `watch_read` такому подключению отдаёт журнал всех
69
+ арендаторов. Теперь роль сеанса (суперпользователь, `BYPASSRLS`, член роли-владельца) видит все события ухода, с
70
+ автором.
71
+ - Администратором и подключением в обход RLS база считает действующую роль сеанса, а не роль входа (находка
72
+ автоматического ревью коммита). Внутри функции владельца `session_user` — роль входа, даже если поверх неё поставлена
73
+ роль приложения (`SET ROLE` или параметр запуска `role`). Так бывает у пула с одной привилегированной ролью входа: там
74
+ `connect()` видит роль приложения, а `admin_role()` с 1.0 видела суперпользователя. Поэтому любой пользователь
75
+ арендатора на таком подключении получал права `letopis.*` (`verify`, `reset`, `letopis.projector` и другие), а
76
+ подписка — события ухода чужих арендаторов. Новая функция владельца `session_role()` берёт роль `SET ROLE`, иначе роль
77
+ входа; через неё работают `admin_role()` и `watch_leaving`.
78
+ - Новый источник агрегата проверяется и по тому, кто описал агрегат (кандидат 6 из 10, решение владельца). Раньше право
79
+ `READ` проверялось только у того, кто заводит потомка источника (или переносит класс под него сменой `parent`). Тот,
80
+ кто описал агрегат без права на строки будущего потомка, читал бы в истории цели вклад каждой из них. Теперь
81
+ описавший тоже должен без условий читать все строки нового источника, иначе `acl_denied`; агрегат можно описать
82
+ заново от того, кто их читает. Описавший — автор версии строки `Class` цели, с которой описание агрегата (своё или
83
+ унаследованное) стало нынешним; журнал строк `Class` не стирается. Объём проверки — как при описании: у агрегата
84
+ класса арендатора — классы арендатора, у агрегата класса System — классы System; класс арендатора под агрегатом System
85
+ проверяет только заводящий.
86
+
87
+ ### Fixed — хвост ревью безопасности доработок после 1.2.0
88
+ - `syncModels` отклоняет модели по кругу до записи: `invalid_class` называет круг (кандидат 4 из 10). Пример — агрегат
89
+ над собственным потомком: потомок ждёт родителя, а цель ждёт источник агрегата. Раньше обход молча ставил потомка
90
+ раньше предка, и база отвечала «предок … не найден». В два шага такой агрегат отклоняет уже база: потомок наследует
91
+ агрегат над самим собой, и выходит «цикл агрегатов».
92
+
93
+ ## [1.3.0] — 2026-10-28
94
+
95
+ letopis 1.3 — доработки после выпуска 1.2.0 (`docs/checkpoint-1.3.md`). Вошли три направления:
96
+ - хвост ревью безопасности №3, все 12 кандидатов, плюс находки автоматического ревью и ревью безопасности доработок;
97
+ - автоматический тест обновления схемы с 1.1.0 и 1.0.1;
98
+ - замер генерируемых колонок первой заливки.
99
+
100
+ Версия `1.3.0` (во время работы — `1.3.0-alpha.0`, не публиковалась). Схема 1.2.0 обновляется повторным `up()`:
101
+ ревизия движка прежняя (6), функции накатываются заново. Новое в API:
102
+ - опция проектора `lockTimeout`;
103
+ - события ухода строки из классов подписки (`op: 'delete'` в прежнем классе).
104
+
105
+ Поведение, которое стоит проверить при обновлении:
106
+ - агрегат над классом, которого ещё нет, отклоняется, поэтому источник описывают раньше цели — `syncModels` делает
107
+ это сам;
108
+ - класс-источник не удаляется, пока его называет агрегат;
109
+ - класс с производными потомками правит только администратор.
110
+
111
+ ### Added — тест обновления схемы
112
+ - `lib/test/upgrade.test.ts`. Схему ставит прежний пакет: 1.1.0 (новая dev-зависимость `letopis-11` =
113
+ `npm:letopis@1.1.0`) или 1.0.1 (`letopis-1`). Данные в неё пишет прежний движок: правки, перенос по концу,
114
+ удаление, каскад, `purge`, обрезка истории и креденшал пользователя вида `LOCK`. Затем вызывается `up()` текущей
115
+ версии: для 1.1.0 простой, для 1.0.1 с `upgrade: true` и миграцией `0006`. Тест проверяет:
116
+ - версии и хэши прежнего движка и `verify({ data: true })`;
117
+ - совпадение каталога схемы с чистой установкой: функции с правами и текстом, колонки, индексы, ограничения,
118
+ триггеры и политики;
119
+ - строки-замки семейств и снятие строк `LOCK` чужих аккаунтов;
120
+ - работу приложения, подписки и проектора по старым данным;
121
+ - что повторный `up()` не меняет ни каталог, ни данные.
122
+
123
+ Раньше обновление 1.1.0 → 1.2 было проверено только разовым прогоном.
124
+
125
+ ### Security — хвост ревью безопасности №3
126
+ - Тексты отказов не называют невидимый класс (кандидат 6 из 10). Класс ссылающейся строки называется, только если
127
+ актор её видит, иначе текст общий. Это касается трёх отказов:
128
+ - каскад удаления без права DELETE (`acl_denied`);
129
+ - снятие конца без права WRITE (`acl_denied`);
130
+ - перевод ссылок при `reclass` и `rekey` на строку производного класса (`derived_class`).
131
+
132
+ Класс выбирает новая функция владельца `visible_class(jsonb)`. Атака №47.
133
+ - Класс System и одноимённые классы арендаторов (кандидат 6 из 10). Раньше `class_has_history` и проверка «есть строки
134
+ или потомки» при удалении класса System считали журнал, строки и потомков любого арендатора по имени. Поэтому
135
+ арендатор с собственным классом `Invoice` и историей его строк не давал администратору создать или удалить System
136
+ `Invoice`. Теперь строки, потомки и история арендатора со своим одноимённым классом не в счёт: так их резолвит
137
+ `class_row`. Строки класса System у арендатора без своего класса по-прежнему мешают. Атака №48.
138
+ - Надгробие класса (кандидат 5 из 10). Класс, удалённый с историей строк, создаётся заново только с прежней
139
+ политикой хранения `history` вместе с прежними `parent`, `alias` и `derivedBy`. Иначе `maintain()` прореживал бы его
140
+ старые версии по другой политике.
141
+
142
+ Алиасы предков в надгробии не записаны, поэтому дыра закрыта со стороны предка. Пока удалённые потомки класса
143
+ хранят историю строк, класс не меняет алиас (`invalid_class`). Удалённый, он создаётся заново только с описанием из
144
+ своего надгробия, даже без собственной истории. Удалённых потомков находит новая функция владельца
145
+ `class_orphan_history(text, uuid)`. Атака №49.
146
+ - Производный класс через непроизводного предка (кандидат 5 из 10). Правка предка пересчитывает описание потомка
147
+ (`class_evolve`): поля, ключ и `history`. Поэтому класс, у которого есть производные потомки, теперь тоже правит
148
+ только администратор, с правом `letopis.projector`. Потомки учитываются своего арендатора, у класса System — всех.
149
+ Иначе `acl_denied`. Атака №50.
150
+ - `family_conflicts` (кандидат 4 из 10). Конфликт смены `parent` (`tightening_conflict`) раньше называл роль невидимой
151
+ ссылающейся строки. Теперь о ней в тексте нет ни роли, ни id. Видимые ссылающиеся строки идут первыми, затем по id:
152
+ раньше бралась первая попавшаяся. Атака №51.
153
+ - Агрегат над ещё не созданным классом источника (`/security-review`, кандидат 5 из 10). Раньше `class_admit`
154
+ проверял право READ описывающего агрегат только по уже существующим классам `of`. Пока класса не было, проверять
155
+ было нечего. Когда источник потом заводил пользователь с полным READ, проверялись только его права, и описавший
156
+ агрегат читал в истории цели вклад каждой строки источника.
157
+
158
+ Теперь новый или изменённый агрегат, у которого нет ни класса `of`, ни его потомков, отклоняется
159
+ (`invalid_class`, «класс-источник описывают раньше агрегата»). У класса арендатора они ищутся среди классов
160
+ арендатора, у класса System — среди классов System. `syncModels` (`letopis sync`) теперь описывает класс-источник
161
+ раньше цели, в каком бы порядке ни пришли модели. Атака №52, тест `sync`.
162
+
163
+ Класс `of` проверяется только при описании агрегата. Поэтому класс-источник теперь не удаляется, пока его называет
164
+ агрегат класса того же арендатора (`invalid_class`): иначе пустой класс удалили бы, а класс с этим именем завёл бы
165
+ заново другой пользователь, например под другим `parent`. Это нашло автоматическое ревью коммита. Агрегаты
166
+ классов System описывает системный администратор, он и так читает всё. Атака №53.
167
+ - Прерванная `rebuild({ tenant, chunked: true })` (кандидат 6 из 10). Раньше её продолжал только `run()` с
168
+ `onExpired: 'rebuild'`. Без этой опции хвост бессрочно пропускал изменения арендатора с id выше последней пачки.
169
+
170
+ Теперь начатую перестройку арендатора продвигает `run()` любого процесса. Запуск перестройки выбывших по ярусу
171
+ по-прежнему только с `onExpired`. Если блокировку проектора держит другой процесс (`busy`), `run()` перестройку
172
+ не трогает и чекпоинт не читает: иначе второй процесс ждал бы пачку первого. Тест `projector`.
173
+ - Нет `lock_timeout` у транзакций проектора (кандидат 6 из 10). Пачка всех арендаторов ждала блокировку строки,
174
+ которую держит транзакция любого арендатора. Новая опция `ProjectorOptions.lockTimeout`: сколько ждать, по
175
+ умолчанию 5000 мс, 0 — без предела. Её ставит `set local lock_timeout` в каждой транзакции проектора.
176
+
177
+ На 55P03 пачка откатывается, курсор остаётся, арендатор не выбывает. `run()` не отклоняется, а считает проход
178
+ пропущенным (`skipped`) и повторяет его; `rebuild({ tenant, chunked: true })` повторяет пачку. Держащая
179
+ транзакция и так держит горизонт подписок, поэтому ждать имеет смысл только запись, зафиксированную раньше неё.
180
+ Тест `projector`.
181
+ - `trimHistory` версий, которые проектор ещё не прочёл (кандидат 5 из 10). Первая оставшаяся версия пришла бы
182
+ проектору с `before` = `null`, и свёртка прибавила бы её, не вычитая прежнюю. Теперь `trim_history` отказывает
183
+ `projector_pending`, пока проектор источника не прочёл первую оставшуюся версию. Класс проверяется у обеих версий
184
+ стыка, потому что строка могла уйти из семейства источников сменой класса.
185
+
186
+ Проверку `purge` и обрезки делает общая функция владельца `projector_behind(uuid, text[], xid8, bigint)`.
187
+ `rebuild({ tenant, chunked: true })` засчитывает завершённую перестройку, даже если последнюю пачку записал `run()`
188
+ другого процесса. Тест `projector`.
189
+ - `as()` арендатора корня (кандидат 5 из 10). Фасад `db.as()` того же арендатора, что и корень, получал сам реестр
190
+ корня. После `auth.switch()` корня он видел классы нового арендатора. Теперь у фасада свой реестр из кэша
191
+ арендатора. Тест `impersonation`.
192
+ - Пустой реестр `as()` (кандидат 4 из 10). Если на первой загрузке контекст сессии уже ушёл из арендатора (сменился
193
+ или кончился), пустой реестр кэшировался и не перечитывался. Теперь он не кэшируется: `as()` читает контекст
194
+ заново, не больше трёх раз, затем отказывает `no_session`. Тест `impersonation`.
195
+ - Подписка и смена класса без смены id. Раньше `db.watch()` по классам не видел ухода строки из класса: версия
196
+ пишется уже с новым классом. Теперь такой уход приходит событием удаления в прежнем классе (`op: 'delete'`, `row` —
197
+ прежняя версия с `$deleted`, автор и время — версии смены класса). Событие приходит, даже если новый класс
198
+ подписчику не виден, но только если прежнюю версию он видел.
199
+
200
+ Уход находит новая функция владельца `watch_leaving`, выданная приложению. Окно у неё то же, что у `watch_read`, и
201
+ ограничено полной пачкой, поэтому догонка с `'start'` не читает журнал заново на каждой пачке. Подписка сливает
202
+ оба потока по `(tx, seq)`. Тесты `watch` (уход в видимый и в невидимый класс, уход в чужом арендаторе, вход в
203
+ класс) и `roles`.
204
+
205
+ Ревью безопасности доработок нашло ошибку в подписке, исправлена до выпуска. `watch_read` и `watch_leaving` идут
206
+ одним оператором с общим снимком: двумя запросами с разными горизонтами курсор мог перескочить событие,
207
+ зафиксированное между ними. Если новую версию подписчик не видит, надгробие приходит при любых классах подписки и
208
+ без автора: иначе перебором классов он узнал бы новый класс.
209
+ - Обрезка истории проверяет курсор проектора по самой поздней по `(tx, seq)` версии у стыка: у более поздней версии
210
+ бывает меньший xid.
211
+
212
+ ### Changed
213
+ - Порядок описания агрегата: класс-источник — раньше цели (раньше источник можно было описать позже, и он
214
+ сверялся при появлении). Классы с концом на ещё не созданный класс по-прежнему описываются, поэтому пара «цель —
215
+ источник с концом на цель» описывается в порядке «источник, цель». Тесты `aggregate` и бенч `derived` описывают
216
+ классы в этом порядке.
217
+
5
218
  ## [1.2.0] — 2026-10-28
6
219
 
7
220
  letopis 1.2 по плану `reports/PLAN-LETOPIS-1.1.md` (редакция 5, этапы 4–6 и контрольная точка Б) и плану доработок
package/README.en.md CHANGED
@@ -1,4 +1,4 @@
1
- # letopis 1.2
1
+ # letopis 1.4
2
2
 
3
3
  **letopis** is a Node.js library: a store of versioned entities on PostgreSQL 18. Every change is saved as a version
4
4
  in a journal. So every object has a history, a state as of any date and a change subscription. The database itself
@@ -369,12 +369,24 @@ package. So a startup script that calls `up()` on every start will stop after a
369
369
  `upgrade: true`. `up({ dsn, schema, upgrade: true })` applies the migrations from `sql/migrate/` and re-applies the
370
370
  functions; the data is kept.
371
371
 
372
+ The revision does not change with every version: 1.1–1.4 share one, while their functions differ. So `up()` also writes
373
+ an engine stamp into the schema comment: the package version and a hash of its SQL files. If the stamp does not match
374
+ the package files, `connect()` warns. When the schema was applied by an older version (or by a version before 1.4,
375
+ which has no stamp), it suggests `up({ schema })`. When a newer version has already upgraded the schema, it suggests
376
+ updating the package: `up()` from the older version would bring the old functions back. A new package version without
377
+ SQL changes keeps the stamp, and there is no warning.
378
+
372
379
  The first `up()` after moving to 1.2 builds the journal index `log_class_tenant_at (class, tenant, at)` instead of
373
380
  `log_class_at`: an `asOf` and `withDeleted` slice by class no longer reads the journal of other tenants with the same
374
381
  class name. While the index is being built, writes to the journal wait. On a large installation, build it in advance
375
382
  without stopping writes: `create index concurrently log_class_tenant_at on "v2.<schema>".log (class, tenant, at)` as
376
383
  the owner role — then `up()` only drops the old one.
377
384
 
385
+ The `upgrade` test checks upgrading 1.1.0 and 1.0.1 schemas. The old package installs the schema and the old engine
386
+ writes the data, then the current version's `up()` runs. After it, the versions and hashes written by the old engine
387
+ are in place, `verify({ data: true })` is clean, and the schema catalog matches a clean installation. A 1.1.0 schema is
388
+ upgraded with a plain `up()`, since its revision is the same. A 1.0.1 schema is upgraded with `up({ upgrade: true })`.
389
+
378
390
  ### Development without PostgreSQL: PGlite
379
391
 
380
392
  `up({ pglite: true })` starts PGlite (PostgreSQL 18 on WebAssembly) with pgcrypto and a socket on a free port. It needs
@@ -560,13 +572,23 @@ const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet
560
572
  the target is needed: the permission to write the source is enough.
561
573
  - The database checks the description (`invalid_class`): the field is in the schema, it is numeric and not part of the
562
574
  key; the `via` end is single and accepts the target class; the `sum` field is a number; aggregates do not form a
563
- cycle; the target and the source are not system or derived classes. A source described after the target is checked
564
- when it appears.
575
+ cycle; the target and the source are not system or derived classes. A source class is described before the
576
+ aggregate: an aggregate over a class that does not exist yet is refused. For a tenant class, the source is looked up
577
+ among the tenant's classes. `syncModels` describes sources before targets itself, and models in a circle (an
578
+ aggregate over the class's own descendant, a cycle of aggregates) it refuses before writing (`invalid_class` naming
579
+ the circle). The `of` class is not deleted while an aggregate of a class of the same tenant names it: first the
580
+ aggregate is removed from the target's description.
565
581
  - Rows get into an aggregate only with the knowledge of someone who reads them all: the field also counts rows that
566
582
  the author cannot see. Only someone who reads all rows of the source and its descendants without conditions can
567
583
  describe or change an aggregate. Only someone who reads all rows of a class and its descendants without conditions,
568
- both before the change and after it, can make the class a source (a new class, a `parent` change, a source described
569
- after the target). Otherwise `acl_denied`.
584
+ both before the change and after it, can make the class a source (a new descendant of a source, a `parent` change).
585
+ Otherwise `acl_denied`.
586
+ - A new source is also checked against whoever described the aggregate: they too must read all rows of the class
587
+ without conditions. The describer is the author of the version of the target's `Class` row from which the aggregate
588
+ description became the current one. If they cannot read those rows, the class does not become a source
589
+ (`acl_denied`) until someone who can read them describes the aggregate again. For an aggregate of a System class, the
590
+ describer is checked against System classes, as at description time. A tenant class under a System aggregate is
591
+ checked only for whoever creates it.
570
592
  - The option is inherited, and a descendant replaces it as a whole. Adding or changing an aggregate is a tightening:
571
593
  writes to the target and to the sources wait while the field is recalculated for all rows.
572
594
  - `db.rebuildAggregates(class)` recalculates the field from the current rows, for repair. A version is written only
@@ -981,7 +1003,8 @@ version 1, a `purge` record stays with the number of erased versions. The histor
981
1003
  While a projector whose `from` sources include the class of the object ([“Projectors”](#projectors)) has not read
982
1004
  the deletion, `purge` refuses (`projector_pending`): the projector would never see an erased tombstone, and the
983
1005
  object's contribution would stay in the totals. So a deletion and a `purge` in one transaction do not go through in
984
- such a class. To delete an account, use `db.auth.purgeAccount` ([section 10](#10-users-sign-in-and-permissions)).
1006
+ such a class. Likewise, `db.trimHistory` refuses while the projector has not read the first remaining version: it
1007
+ would no longer find the previous version for `before`. To delete an account, use `db.auth.purgeAccount` ([section 10](#10-users-sign-in-and-permissions)).
985
1008
 
986
1009
  ## 8. Transactions, batches and money
987
1010
 
@@ -1462,16 +1485,23 @@ alias in the tenant and System, and the name of a new class does not match anoth
1462
1485
  written for one class would act on the rows of another too. A violation gives `invalid_class`.
1463
1486
 
1464
1487
  By the same names, the rules also find the old versions of rows in the journal. So a class is deleted only when it has
1465
- no rows, no descendants and no history of rows: `purge()` erases the history of deleted rows first. A class deleted
1466
- with history before is created again only with the same `parent`, `alias` and `derivedBy` — otherwise `invalid_class`:
1467
- the old versions of its rows would fall under other rules. If the journal holds rows of a class with this name but not
1468
- its tombstone (for example, rows of a System class deleted in 1.1), a class with this name is not created until
1469
- `purge()` erases the history.
1488
+ no rows, no descendants and no history of rows: `purge()` erases the history of deleted rows first. For a System class,
1489
+ the rows, descendants and history of a tenant that has its own class with the same name do not count: they belong to
1490
+ that tenant's class. A class deleted with history before is created again only with the same `parent`, `alias`,
1491
+ `derivedBy` and `history` — otherwise `invalid_class`: the old versions of its rows would fall under other rules or a
1492
+ different thinning. The rules also find old versions by the aliases of ancestors. So while deleted descendants of a
1493
+ class keep row history, the class does not change its alias (`invalid_class`), and once deleted it is created again
1494
+ only with the same description. If the journal holds rows of a class with this name but not its tombstone (for
1495
+ example, rows of a System class deleted in 1.1), a class with this name is not created until `purge()` erases the
1496
+ history.
1470
1497
 
1471
1498
  A derived class (`derivedBy`) is created, edited and deleted only by an administrator: an admin connection or an
1472
1499
  account with the System permission `letopis.projector` (the system administrator has it through the `letopis.*`
1473
1500
  mask). The projector writes totals over all source rows of the tenant into such a class, without read permissions,
1474
- so a user who created it on their own would read totals over rows they may not read.
1501
+ so a user who created it on their own would read totals over rows they may not read. A class that has derived
1502
+ descendants is also edited only by an administrator: editing an ancestor changes the description of the descendant
1503
+ (fields, key, `history`). The database itself sets the `aggregatedBy` mark (the class became an aggregate source) on a
1504
+ derived class too: it does not change its fields or key.
1475
1505
 
1476
1506
  ### Granting a permission
1477
1507
 
@@ -1708,7 +1738,10 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
1708
1738
  moment: a write after it comes to the stream. The example above reads from `'start'`.
1709
1739
  - Calls of `sub.next()` go in turn: parallel calls get different events. A call abandoned by a timeout
1710
1740
  (`Promise.race`) still gets its event — keep its promise and wait for it again.
1711
- - `classes`: only these classes with their descendants; `pollMs`: fallback polling (1000 ms).
1741
+ - `classes`: only these classes with their descendants; `pollMs`: fallback polling (1000 ms). A row that left these
1742
+ classes through a class change without an id change arrives as a deletion event in the previous class (`op:
1743
+ 'delete'`, `row` is the previous version with `$deleted`), even if the subscriber cannot see the new class. The
1744
+ event comes if the subscriber could see the previous version.
1712
1745
  - Events come without losses or repeats, under the reader's permissions, in the order of transaction numbers, and
1713
1746
  within a transaction in the order of writing. This is not the commit order: compare versions of one object by `rev`.
1714
1747
  - A long write transaction in any database of the cluster delays the stream: `await sub.lag()` shows the lag in ms.
@@ -1832,7 +1865,8 @@ sales.close() // stop() after the current bat
1832
1865
  on for all: the tail skips the tenant's changes above the last batch (a batch will read them) and, in a passed batch,
1833
1866
  those whose transaction is visible in its snapshot. The tenant's derived rows are deleted at once and are incomplete
1834
1867
  until the end of the rebuild (`status().rebuildingTenants`); the rebuild waits out a lock held by `run()` of another
1835
- process.
1868
+ process. An interrupted rebuild (the process stopped) is continued by `run()` of any process, with or without
1869
+ `onExpired`.
1836
1870
  - `status().state` describes the projection in the database, not a process: `'new'` means there is no projection yet,
1837
1871
  and the first `run()` or `rebuild()` builds it; `'running'` means the projection is built and kept up with the
1838
1872
  journal (it does not mean that some process is executing `run()` right now); `'rebuilding'` means a batch rebuild
@@ -1861,6 +1895,16 @@ sales.close() // stop() after the current bat
1861
1895
  tenant with `rebuild({ tenant })`.
1862
1896
  - `batch` is how many changes go into a batch (500), `pollMs` is fallback polling (1000 ms): source signals wake the
1863
1897
  projector earlier.
1898
+ - `lockTimeout` is how long a projector transaction waits for another transaction's row lock (5000 ms; 0 — no limit).
1899
+ Otherwise a transaction of any tenant holding a total row or an end target would hold the batch of all tenants.
1900
+ Longer than that, the batch rolls back, the cursor stays, and `run()` does not reject but skips the pass
1901
+ (`skipped`) and repeats it. The report names the tenant whose row stopped the batch (`locked`).
1902
+ `rebuild({ tenant, chunked: true })` waits such a lock out.
1903
+ - `lockRetries` is how many passes in a row the writes of one tenant may run into `lockTimeout` before it drops out
1904
+ (0 by default — it never drops out). The dropped tenant gets the code `55P03`, and the batch goes on for the others;
1905
+ its changes are skipped until `rebuild({ tenant })`. The process keeps the count, and a successful write of the
1906
+ tenant resets it. Note: a long exclusive lock on the family of a System class (a tightening of its description) will
1907
+ hit every tenant in turn.
1864
1908
  - Every edit that `out` sends to the database is a separate edit of a row, and the speed of a projector is set by the
1865
1909
  number of edits, not by the number of changes. The folding of `inc` brings the edits of a row down to one per batch,
1866
1910
  so the handle from the example above (`out.inc` for every change) caught up with 6 thousand changes per second with
@@ -2317,12 +2361,12 @@ give `invalid_data`.
2317
2361
 
2318
2362
  | Member | What it does |
2319
2363
  |---|---|
2320
- | options `ProjectorOptions` | `from`, `into`: classes with descendants; `version`: an integer from 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` rebuilds in batches); `batch`: changes in a batch (500, from 1 to 10,000); `pollMs`: fallback polling (1000 ms); `tenants`: ids of the participant tenants |
2364
+ | options `ProjectorOptions` | `from`, `into`: classes with descendants; `version`: an integer from 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` rebuilds in batches); `batch`: changes in a batch (500, from 1 to 10,000); `pollMs`: fallback polling (1000 ms); `lockTimeout`: waiting for another transaction's row lock (5000 ms, 0 — no limit); `lockRetries`: passes in a row before a tenant with another transaction's lock drops out (0 — never); `tenants`: ids of the participant tenants |
2321
2365
  | `run({ once })` | catch up with the journal (`once`) or follow it until `stop()` → `ProjectorReport` |
2322
2366
  | `rebuild({ chunked, tenant })` | rebuild from the current state: atomically, in batches or for one tenant → `ProjectorReport` |
2323
2367
  | `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` is `{ [tenant id]: ProjectorFailure }`, where `ProjectorFailure` is `{ cursor, code, message }` |
2324
2368
  | `stop()`, `close()` | stop `run()` after the current batch; the same plus unsubscribing from signals |
2325
- | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds }` |
2369
+ | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds, locked }` |
2326
2370
  | `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` and, when present, `agent`, `reason`, `moved`, `before`, `rebuild` |
2327
2371
  | `ProjectorOut` | `upsert(class, row, { tenant })`, `inc(class, row, deltas, { tenant })`, `delete(class, key or id, { tenant })`, `get(class, key or id, { tenant })` → `Row \| null`; a row is a `ProjectorRow` in the loader format |
2328
2372
 
@@ -2384,7 +2428,7 @@ Database codes. Raw SQL gets them too: SQLSTATE class `LT`, the details are JSON
2384
2428
  | `confirm_mismatch` | LT020 | the reset confirmation does not match the schema name |
2385
2429
  | `derived_class` | LT021 | a row of a derived class (`derivedBy`) is written, edited or deleted by someone other than its projector; an end leads to such a row or to a derived class; a projector writes to a class outside its `into` or has found no tenant in which every `into` class is marked with its `derivedBy` |
2386
2430
  | `aggregate_field` | LT022 | the application passed or changed an aggregate field (`aggregate`): the database calculates it |
2387
- | `projector_pending` | LT023 | `purge` of a deleted object whose deletion a projector of its class has not read yet: run the projector first |
2431
+ | `projector_pending` | LT023 | `purge` of a deleted object whose deletion a projector of its class has not read yet, or `trimHistory` whose first remaining version it has not read yet: run the projector first |
2388
2432
 
2389
2433
  Library codes:
2390
2434
 
@@ -2470,7 +2514,7 @@ runs) showed no write or read regressions against 1.1: two outliers did not repe
2470
2514
 
2471
2515
  ## 18. Limitations
2472
2516
 
2473
- This section lists what letopis 1.2 deliberately does not do or does with caveats, and where you need to watch the
2517
+ This section lists what letopis 1.4 deliberately does not do or does with caveats, and where you need to watch the
2474
2518
  behavior.
2475
2519
 
2476
2520
  **Platform**
@@ -2556,8 +2600,10 @@ behavior.
2556
2600
  attempts.
2557
2601
  - When users link a taken identifier themselves, the answer is `acl_denied`, so a user with a recent sign-in learns
2558
2602
  this way that the identifier is taken. Permission changes in all tenants are serialized through one counter row.
2559
- - The number of invisible rows that block a deletion or a class change (`reclass_denied`), and the existence of an
2560
- account by id (as a reference target), are not hidden.
2603
+ - Refusal texts name the class, role and id of a referencing row only if the actor can see it: a cascade delete,
2604
+ unsetting an end, rewriting references on a class or key change, and a `parent` change conflict
2605
+ (`tightening_conflict`). The number of invisible rows that block a deletion or a
2606
+ class change (`reclass_denied`), and the existence of an account by id (as a reference target), are not hidden.
2561
2607
  - Tightening a class description and `rekeyClass` check all rows of the family, so they need the `READ` permission
2562
2608
  without conditions on all rows of the class and its descendants: otherwise a trial tightening (`maximum`, `enum`,
2563
2609
  `pattern`) would answer with the number of invisible rows that do not match it. A system administrator who tightens
@@ -2640,6 +2686,8 @@ behavior.
2640
2686
  - `purge()` waits for projectors: while a projector whose sources include the class of the object, and which has a
2641
2687
  derived class in the object's tenant, has not read the deletion, `purge` answers `projector_pending`. A projector
2642
2688
  that is no longer run holds `purge` of its sources while the tenant has its derived classes: run it or delete them.
2689
+ `trimHistory` waits the same way: while the projector has not read the first remaining version, trimming answers
2690
+ `projector_pending`.
2643
2691
 
2644
2692
  **Aggregates in the database**
2645
2693
 
@@ -2707,7 +2755,7 @@ This section is for those who develop letopis itself. Run: `cd lib && npm test`.
2707
2755
  # hash — the version hash chain, journal tampering and gaps, verify() (stage 1)
2708
2756
  # harness — the test harness: starting the database, a schema per file, two connection strings (stage 0)
2709
2757
  # jsonschema — compiling JSON Schema to jsonpath and checking data in the database (stage 1)
2710
- # impersonation — impersonation: letopis.account only for a service with the permission, target restrictions, author and agent in the journal, sessionFor; the db.as() registry is one per tenant, does not empty, is re-read on a signal of its tenant (stage 5; the work after checkpoint B)
2758
+ # impersonation — impersonation: letopis.account only for a service with the permission, target restrictions, author and agent in the journal, sessionFor; the db.as() registry is one per tenant, does not empty, is re-read on a signal of its tenant (stage 5; the work after checkpoint B); the facade registry is not shared with the root, an empty registry is not cached (security review No. 3 leftovers)
2711
2759
  # install — the installer and roles: a repeated installation, two installations in one database, three tables (stage 1)
2712
2760
  # member — memberships: working in a tenant by roles, auth.switch, leaving with ownership transfer, system rows, tenant roles without System permissions (stage 5)
2713
2761
  # model — Zod models, the JSON Schema normalizer, pattern translation, agreement with the database metaschema (stage 2)
@@ -2731,6 +2779,7 @@ This section is for those who develop letopis itself. Run: `cd lib && npm test`.
2731
2779
  # cache — result cache: hits, own and others' writes, the key with the user, permissions, revoked sessions, listener drop, ttl, class marking, without LISTEN, a race, db.sql, the limit of signal counters (stage 6; plan 1.1, checkpoint A)
2732
2780
  # maintain — maintenance: expired sessions and codes, permissions, one process under a lock, schedule (stage 6)
2733
2781
  # up — the full installer: fresh installation, re-apply, custom seeds, automatic database creation, UTF8, version 17, upgrade with migrations, PGlite, the first tenant and user (up({ tenant }), createTenant) (stages 6, 8)
2782
+ # upgrade — schema upgrade from 1.1.0 (re-apply) and from 1.0.1 (upgrade: true, migration 0006) with the letopis-11 and letopis-1 packages: versions and hashes written by the old engine, verify(), the catalog against a clean installation, lock rows, the application, a subscription and a projector over old data, a repeated up() (work after 1.2.0, stage 1)
2734
2783
  # scenario-salon — end-to-end salon scenario: 21 acts from 0.21 (salon) with a coverage matrix of the 1.0 API (stage 6)
2735
2784
  # telemetry — onQuery and slowMs, describe() and class metadata with inheritance, describeText (stage 6)
2736
2785
  # history — diff and blame over the log, the reason of a change for verbs, db.begin() and batches; a version hidden by an ACL condition, the changed column, a missing version, row_changes over 20 thousand targets (plan 1.1, stage 1 and checkpoint A)