letopis 1.2.0 → 1.3.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 +9 -7
- package/AGENT-CHEATSHEET.md +9 -7
- package/CHANGELOG.md +125 -0
- package/README.en.md +48 -20
- package/README.md +40 -17
- package/dist/index.js +20 -7
- package/dist/project.d.ts +11 -2
- package/dist/project.js +63 -18
- package/dist/sync.js +11 -2
- package/dist/watch.js +28 -3
- package/llms.txt +1 -1
- package/package.json +2 -1
- package/sql/10-core.sql +3 -1
- package/sql/60-write.sql +130 -24
- package/sql/88-derived.sql +2 -2
- package/sql/90-time.sql +82 -18
- package/sql/README.md +1 -1
package/AGENT-CHEATSHEET.en.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# letopis 1.
|
|
1
|
+
# letopis 1.3 — 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:
|
|
@@ -263,7 +263,7 @@ const canDelete = await olga.acl.checkData('Wallet', 'DELETE') // { allow: fa
|
|
|
263
263
|
|
|
264
264
|
<!-- run -->
|
|
265
265
|
```ts
|
|
266
|
-
const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (default) | 'start' | a cursor; pollMs
|
|
266
|
+
const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (default) | 'start' | a cursor; pollMs; leaving classes — op 'delete'
|
|
267
267
|
const lagMs = await sub.lag() // lag, ms
|
|
268
268
|
await db.Wallet(w).update({ balance: inc(1) }).rows()
|
|
269
269
|
let cursor = ''
|
|
@@ -325,8 +325,8 @@ npx letopis import --from "$ADMIN" --from-schema v1.fixture --dsn "$ADMIN" --sch
|
|
|
325
325
|
|
|
326
326
|
A projector builds rows of derived classes from the journal: summaries, cards, anything that may be computed with a
|
|
327
327
|
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)
|
|
329
|
-
admin connection holding a tenant session (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) or an
|
|
328
|
+
derived class itself (own or inherited) and its ancestors are created, edited and deleted only by an administrator:
|
|
329
|
+
`syncModels` on an admin connection holding a tenant session (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) or an
|
|
330
330
|
account with the System permission `letopis.projector`; for an application session it is `acl_denied`. Everyone whom
|
|
331
331
|
the rules give `READ` on a derived class sees its totals, even without permissions on the source rows.
|
|
332
332
|
```ts
|
|
@@ -348,7 +348,7 @@ have no such field, so subtract `before` only after checking it for `null`. A cl
|
|
|
348
348
|
without a key) and takes the row out of the `from` family comes as a deletion (`op` `delete`, `row` is the last
|
|
349
349
|
version in the family with `$deleted`). `handle` gets the changes of one tenant. An error of its data (the
|
|
350
350
|
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
|
|
351
|
+
(`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
352
|
`status().rebuildingTenants`), and the others go on; with `onExpired: 'rebuild'` a tenant dropped by the tier rebuilds
|
|
353
353
|
itself in batches, in any process;
|
|
354
354
|
`tenants: [id, …]` is an explicit list of participants.
|
|
@@ -374,7 +374,9 @@ A trigger recalculates the field after every statement (the library, raw SQL, a
|
|
|
374
374
|
`rekey`, the loader); going below `minimum` is `invalid_data`, every change is a version of the target. Live rows of
|
|
375
375
|
the same tenant are counted (a System row is not). Repair: `adm.rebuildAggregates('Wallet')` on an admin connection;
|
|
376
376
|
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`.
|
|
377
|
+
conditions can describe or change an aggregate, otherwise `acl_denied`. The source is described before the aggregate:
|
|
378
|
+
an aggregate over a class that does not exist yet is refused (`invalid_class`), and the `of` class is not deleted while an
|
|
379
|
+
aggregate names it; `syncModels` puts sources before targets itself. A hot counter (more than ~150 writes per
|
|
378
380
|
second per object) is a projector that folds a batch, not an aggregate.
|
|
379
381
|
|
|
380
382
|
### 14. Agents and MCP
|
|
@@ -422,7 +424,7 @@ await adm.close(); await db.close(); await svc.close()
|
|
|
422
424
|
if the row was deleted between the lookup and the edit.
|
|
423
425
|
3. **Verbs return a chain.** Without a terminal (`.rows()`, `.first()` …) nothing runs. `delete()` without
|
|
424
426
|
`{ 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`).
|
|
427
|
+
of the class has read the deletion (`projector_pending`); `trimHistory`, once the projector has read the versions.
|
|
426
428
|
4. **Money and counters**: `update({ balance: inc(n) })` — the database adds inside the statement, without a read and
|
|
427
429
|
without lost updates; the `minimum` check in the schema catches a drop below zero. For a total computed from what
|
|
428
430
|
you read, use `{ rev }` (re-read and retry on `conflict`) or `forUpdate()` in `db.begin()`. Idempotency comes from
|
package/AGENT-CHEATSHEET.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# letopis 1.
|
|
1
|
+
# letopis 1.3 — шпаргалка для агента
|
|
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);
|
|
@@ -254,7 +254,7 @@ const canDelete = await olga.acl.checkData('Wallet', 'DELETE') // { allow: fa
|
|
|
254
254
|
|
|
255
255
|
<!-- run -->
|
|
256
256
|
```ts
|
|
257
|
-
const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (по умолчанию) | 'start' | курсор; pollMs
|
|
257
|
+
const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (по умолчанию) | 'start' | курсор; pollMs; уход из classes — op 'delete'
|
|
258
258
|
const lagMs = await sub.lag() // отставание, мс
|
|
259
259
|
await db.Wallet(w).update({ balance: inc(1) }).rows()
|
|
260
260
|
let cursor = ''
|
|
@@ -316,8 +316,8 @@ npx letopis import --from "$ADMIN" --from-schema v1.fixture --dsn "$ADMIN" --sch
|
|
|
316
316
|
|
|
317
317
|
Проектор строит строки производных классов из журнала: сводки, карточки, всё, что можно считать с отставанием.
|
|
318
318
|
Производный класс помечен `derivedBy: 'имя-проектора'`, и его строки пишет только этот проектор. Сам производный
|
|
319
|
-
класс (свой или унаследованный) заводит, меняет и удаляет только администратор: `syncModels`
|
|
320
|
-
подключением с сессией арендатора (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или аккаунт с
|
|
319
|
+
класс (свой или унаследованный) и его предков заводит, меняет и удаляет только администратор: `syncModels`
|
|
320
|
+
администраторским подключением с сессией арендатора (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или аккаунт с
|
|
321
321
|
правом System `letopis.projector`; у сессии приложения — `acl_denied`. Итоги производного класса видит каждый, кому
|
|
322
322
|
правила дают `READ` на него, даже без прав на строки источников.
|
|
323
323
|
```ts
|
|
@@ -339,7 +339,7 @@ p.run().catch(report) // run() без once — с catch: после ош
|
|
|
339
339
|
`row` — последняя версия в семействе с `$deleted`). `handle` получает изменения
|
|
340
340
|
одного арендатора. Ошибку его данных (база отклонила его результаты или `out` — его строку или приращение)
|
|
341
341
|
откатывает только он: арендатор выбывает (`status().failed`: `{ id: { cursor, code, message } }`) до
|
|
342
|
-
`rebuild({ tenant: id })` (`{ tenant, chunked: true }` — пачками, пока хвост идёт для
|
|
342
|
+
`rebuild({ tenant: id })` (`{ tenant, chunked: true }` — пачками, пока хвост идёт для всех, прерванную продолжает любой `run()`; `status().rebuildingTenants`),
|
|
343
343
|
остальные идут дальше; с `onExpired: 'rebuild'` выбывший по ярусу перестраивается пачками сам, в любом процессе; `tenants: [id, …]` — явный список участников. `status().state`: `'new'` —
|
|
344
344
|
проекции ещё нет, `'running'` — проекция построена и ведётся (а не «процесс сейчас работает»), `'rebuilding'` — идёт
|
|
345
345
|
или прервана перестройка пачками.
|
|
@@ -362,7 +362,9 @@ await db.Wallet(w).Payment().create({ opId: 'op-9', amount: 500 }).rows() //
|
|
|
362
362
|
загрузчик); уход ниже `minimum` — `invalid_data`, каждое изменение — версия цели. Считаются живые строки того же
|
|
363
363
|
арендатора (строка System — нет). Ремонт — `adm.rebuildAggregates('Wallet')` на администраторском подключении,
|
|
364
364
|
расхождения — `verify({ data: true })`. Описать или изменить агрегат может только тот, кто без условий читает все строки
|
|
365
|
-
источника и его потомков, иначе `acl_denied`.
|
|
365
|
+
источника и его потомков, иначе `acl_denied`. Источник описывают раньше агрегата: над классом, которого ещё нет,
|
|
366
|
+
агрегат не описывается (`invalid_class`), а класс `of` не удаляется, пока агрегат его называет; `syncModels` сам ставит
|
|
367
|
+
источники раньше целей. Горячий счётчик (больше ~150 записей в секунду на объект) —
|
|
366
368
|
проектор со свёрткой пачкой, а не агрегат.
|
|
367
369
|
|
|
368
370
|
### 14. Агенты и MCP
|
|
@@ -407,7 +409,7 @@ await adm.close(); await db.close(); await svc.close()
|
|
|
407
409
|
и правкой.
|
|
408
410
|
3. **Глаголы возвращают цепочку.** Без терминала (`.rows()`, `.first()` …) ничего не исполнится. `delete()` без
|
|
409
411
|
`{ confirm: true }` — только превью; `purge` — только для удалённых (`not_deleted`) и после того, как проектор
|
|
410
|
-
класса прочёл удаление (`projector_pending`)
|
|
412
|
+
класса прочёл удаление (`projector_pending`); `trimHistory` — после того, как проектор прочёл версии.
|
|
411
413
|
4. **Деньги и счётчики** — `update({ balance: inc(n) })`: база прибавляет внутри оператора, без чтения и потерянных
|
|
412
414
|
обновлений; проверка `minimum` в схеме ловит уход в минус. Итог, вычисленный из прочитанного, — `{ rev }`
|
|
413
415
|
(перечитать и повторить при `conflict`) или `forUpdate()` в `db.begin()`. Идемпотентность — ключом операции
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,131 @@
|
|
|
2
2
|
|
|
3
3
|
Формат: [Keep a Changelog](https://keepachangelog.com/), версии — semver.
|
|
4
4
|
|
|
5
|
+
## [1.3.0] — 2026-10-28
|
|
6
|
+
|
|
7
|
+
letopis 1.3 — доработки после выпуска 1.2.0 (`docs/checkpoint-1.3.md`). Вошли три направления:
|
|
8
|
+
- хвост ревью безопасности №3, все 12 кандидатов, плюс находки автоматического ревью и ревью безопасности доработок;
|
|
9
|
+
- автоматический тест обновления схемы с 1.1.0 и 1.0.1;
|
|
10
|
+
- замер генерируемых колонок первой заливки.
|
|
11
|
+
|
|
12
|
+
Версия `1.3.0` (во время работы — `1.3.0-alpha.0`, не публиковалась). Схема 1.2.0 обновляется повторным `up()`:
|
|
13
|
+
ревизия движка прежняя (6), функции накатываются заново. Новое в API:
|
|
14
|
+
- опция проектора `lockTimeout`;
|
|
15
|
+
- события ухода строки из классов подписки (`op: 'delete'` в прежнем классе).
|
|
16
|
+
|
|
17
|
+
Поведение, которое стоит проверить при обновлении:
|
|
18
|
+
- агрегат над классом, которого ещё нет, отклоняется, поэтому источник описывают раньше цели — `syncModels` делает
|
|
19
|
+
это сам;
|
|
20
|
+
- класс-источник не удаляется, пока его называет агрегат;
|
|
21
|
+
- класс с производными потомками правит только администратор.
|
|
22
|
+
|
|
23
|
+
### Added — тест обновления схемы
|
|
24
|
+
- `lib/test/upgrade.test.ts`. Схему ставит прежний пакет: 1.1.0 (новая dev-зависимость `letopis-11` =
|
|
25
|
+
`npm:letopis@1.1.0`) или 1.0.1 (`letopis-1`). Данные в неё пишет прежний движок: правки, перенос по концу,
|
|
26
|
+
удаление, каскад, `purge`, обрезка истории и креденшал пользователя вида `LOCK`. Затем вызывается `up()` текущей
|
|
27
|
+
версии: для 1.1.0 простой, для 1.0.1 с `upgrade: true` и миграцией `0006`. Тест проверяет:
|
|
28
|
+
- версии и хэши прежнего движка и `verify({ data: true })`;
|
|
29
|
+
- совпадение каталога схемы с чистой установкой: функции с правами и текстом, колонки, индексы, ограничения,
|
|
30
|
+
триггеры и политики;
|
|
31
|
+
- строки-замки семейств и снятие строк `LOCK` чужих аккаунтов;
|
|
32
|
+
- работу приложения, подписки и проектора по старым данным;
|
|
33
|
+
- что повторный `up()` не меняет ни каталог, ни данные.
|
|
34
|
+
|
|
35
|
+
Раньше обновление 1.1.0 → 1.2 было проверено только разовым прогоном.
|
|
36
|
+
|
|
37
|
+
### Security — хвост ревью безопасности №3
|
|
38
|
+
- Тексты отказов не называют невидимый класс (кандидат 6 из 10). Класс ссылающейся строки называется, только если
|
|
39
|
+
актор её видит, иначе текст общий. Это касается трёх отказов:
|
|
40
|
+
- каскад удаления без права DELETE (`acl_denied`);
|
|
41
|
+
- снятие конца без права WRITE (`acl_denied`);
|
|
42
|
+
- перевод ссылок при `reclass` и `rekey` на строку производного класса (`derived_class`).
|
|
43
|
+
|
|
44
|
+
Класс выбирает новая функция владельца `visible_class(jsonb)`. Атака №47.
|
|
45
|
+
- Класс System и одноимённые классы арендаторов (кандидат 6 из 10). Раньше `class_has_history` и проверка «есть строки
|
|
46
|
+
или потомки» при удалении класса System считали журнал, строки и потомков любого арендатора по имени. Поэтому
|
|
47
|
+
арендатор с собственным классом `Invoice` и историей его строк не давал администратору создать или удалить System
|
|
48
|
+
`Invoice`. Теперь строки, потомки и история арендатора со своим одноимённым классом не в счёт: так их резолвит
|
|
49
|
+
`class_row`. Строки класса System у арендатора без своего класса по-прежнему мешают. Атака №48.
|
|
50
|
+
- Надгробие класса (кандидат 5 из 10). Класс, удалённый с историей строк, создаётся заново только с прежней
|
|
51
|
+
политикой хранения `history` вместе с прежними `parent`, `alias` и `derivedBy`. Иначе `maintain()` прореживал бы его
|
|
52
|
+
старые версии по другой политике.
|
|
53
|
+
|
|
54
|
+
Алиасы предков в надгробии не записаны, поэтому дыра закрыта со стороны предка. Пока удалённые потомки класса
|
|
55
|
+
хранят историю строк, класс не меняет алиас (`invalid_class`). Удалённый, он создаётся заново только с описанием из
|
|
56
|
+
своего надгробия, даже без собственной истории. Удалённых потомков находит новая функция владельца
|
|
57
|
+
`class_orphan_history(text, uuid)`. Атака №49.
|
|
58
|
+
- Производный класс через непроизводного предка (кандидат 5 из 10). Правка предка пересчитывает описание потомка
|
|
59
|
+
(`class_evolve`): поля, ключ и `history`. Поэтому класс, у которого есть производные потомки, теперь тоже правит
|
|
60
|
+
только администратор, с правом `letopis.projector`. Потомки учитываются своего арендатора, у класса System — всех.
|
|
61
|
+
Иначе `acl_denied`. Атака №50.
|
|
62
|
+
- `family_conflicts` (кандидат 4 из 10). Конфликт смены `parent` (`tightening_conflict`) раньше называл роль невидимой
|
|
63
|
+
ссылающейся строки. Теперь о ней в тексте нет ни роли, ни id. Видимые ссылающиеся строки идут первыми, затем по id:
|
|
64
|
+
раньше бралась первая попавшаяся. Атака №51.
|
|
65
|
+
- Агрегат над ещё не созданным классом источника (`/security-review`, кандидат 5 из 10). Раньше `class_admit`
|
|
66
|
+
проверял право READ описывающего агрегат только по уже существующим классам `of`. Пока класса не было, проверять
|
|
67
|
+
было нечего. Когда источник потом заводил пользователь с полным READ, проверялись только его права, и описавший
|
|
68
|
+
агрегат читал в истории цели вклад каждой строки источника.
|
|
69
|
+
|
|
70
|
+
Теперь новый или изменённый агрегат, у которого нет ни класса `of`, ни его потомков, отклоняется
|
|
71
|
+
(`invalid_class`, «класс-источник описывают раньше агрегата»). У класса арендатора они ищутся среди классов
|
|
72
|
+
арендатора, у класса System — среди классов System. `syncModels` (`letopis sync`) теперь описывает класс-источник
|
|
73
|
+
раньше цели, в каком бы порядке ни пришли модели. Атака №52, тест `sync`.
|
|
74
|
+
|
|
75
|
+
Класс `of` проверяется только при описании агрегата. Поэтому класс-источник теперь не удаляется, пока его называет
|
|
76
|
+
агрегат класса того же арендатора (`invalid_class`): иначе пустой класс удалили бы, а класс с этим именем завёл бы
|
|
77
|
+
заново другой пользователь, например под другим `parent`. Это нашло автоматическое ревью коммита. Агрегаты
|
|
78
|
+
классов System описывает системный администратор, он и так читает всё. Атака №53.
|
|
79
|
+
- Прерванная `rebuild({ tenant, chunked: true })` (кандидат 6 из 10). Раньше её продолжал только `run()` с
|
|
80
|
+
`onExpired: 'rebuild'`. Без этой опции хвост бессрочно пропускал изменения арендатора с id выше последней пачки.
|
|
81
|
+
|
|
82
|
+
Теперь начатую перестройку арендатора продвигает `run()` любого процесса. Запуск перестройки выбывших по ярусу
|
|
83
|
+
по-прежнему только с `onExpired`. Если блокировку проектора держит другой процесс (`busy`), `run()` перестройку
|
|
84
|
+
не трогает и чекпоинт не читает: иначе второй процесс ждал бы пачку первого. Тест `projector`.
|
|
85
|
+
- Нет `lock_timeout` у транзакций проектора (кандидат 6 из 10). Пачка всех арендаторов ждала блокировку строки,
|
|
86
|
+
которую держит транзакция любого арендатора. Новая опция `ProjectorOptions.lockTimeout`: сколько ждать, по
|
|
87
|
+
умолчанию 5000 мс, 0 — без предела. Её ставит `set local lock_timeout` в каждой транзакции проектора.
|
|
88
|
+
|
|
89
|
+
На 55P03 пачка откатывается, курсор остаётся, арендатор не выбывает. `run()` не отклоняется, а считает проход
|
|
90
|
+
пропущенным (`skipped`) и повторяет его; `rebuild({ tenant, chunked: true })` повторяет пачку. Держащая
|
|
91
|
+
транзакция и так держит горизонт подписок, поэтому ждать имеет смысл только запись, зафиксированную раньше неё.
|
|
92
|
+
Тест `projector`.
|
|
93
|
+
- `trimHistory` версий, которые проектор ещё не прочёл (кандидат 5 из 10). Первая оставшаяся версия пришла бы
|
|
94
|
+
проектору с `before` = `null`, и свёртка прибавила бы её, не вычитая прежнюю. Теперь `trim_history` отказывает
|
|
95
|
+
`projector_pending`, пока проектор источника не прочёл первую оставшуюся версию. Класс проверяется у обеих версий
|
|
96
|
+
стыка, потому что строка могла уйти из семейства источников сменой класса.
|
|
97
|
+
|
|
98
|
+
Проверку `purge` и обрезки делает общая функция владельца `projector_behind(uuid, text[], xid8, bigint)`.
|
|
99
|
+
`rebuild({ tenant, chunked: true })` засчитывает завершённую перестройку, даже если последнюю пачку записал `run()`
|
|
100
|
+
другого процесса. Тест `projector`.
|
|
101
|
+
- `as()` арендатора корня (кандидат 5 из 10). Фасад `db.as()` того же арендатора, что и корень, получал сам реестр
|
|
102
|
+
корня. После `auth.switch()` корня он видел классы нового арендатора. Теперь у фасада свой реестр из кэша
|
|
103
|
+
арендатора. Тест `impersonation`.
|
|
104
|
+
- Пустой реестр `as()` (кандидат 4 из 10). Если на первой загрузке контекст сессии уже ушёл из арендатора (сменился
|
|
105
|
+
или кончился), пустой реестр кэшировался и не перечитывался. Теперь он не кэшируется: `as()` читает контекст
|
|
106
|
+
заново, не больше трёх раз, затем отказывает `no_session`. Тест `impersonation`.
|
|
107
|
+
- Подписка и смена класса без смены id. Раньше `db.watch()` по классам не видел ухода строки из класса: версия
|
|
108
|
+
пишется уже с новым классом. Теперь такой уход приходит событием удаления в прежнем классе (`op: 'delete'`, `row` —
|
|
109
|
+
прежняя версия с `$deleted`, автор и время — версии смены класса). Событие приходит, даже если новый класс
|
|
110
|
+
подписчику не виден, но только если прежнюю версию он видел.
|
|
111
|
+
|
|
112
|
+
Уход находит новая функция владельца `watch_leaving`, выданная приложению. Окно у неё то же, что у `watch_read`, и
|
|
113
|
+
ограничено полной пачкой, поэтому догонка с `'start'` не читает журнал заново на каждой пачке. Подписка сливает
|
|
114
|
+
оба потока по `(tx, seq)`. Тесты `watch` (уход в видимый и в невидимый класс, уход в чужом арендаторе, вход в
|
|
115
|
+
класс) и `roles`.
|
|
116
|
+
|
|
117
|
+
Ревью безопасности доработок нашло ошибку в подписке, исправлена до выпуска. `watch_read` и `watch_leaving` идут
|
|
118
|
+
одним оператором с общим снимком: двумя запросами с разными горизонтами курсор мог перескочить событие,
|
|
119
|
+
зафиксированное между ними. Если новую версию подписчик не видит, надгробие приходит при любых классах подписки и
|
|
120
|
+
без автора: иначе перебором классов он узнал бы новый класс.
|
|
121
|
+
- Обрезка истории проверяет курсор проектора по самой поздней по `(tx, seq)` версии у стыка: у более поздней версии
|
|
122
|
+
бывает меньший xid.
|
|
123
|
+
|
|
124
|
+
### Changed
|
|
125
|
+
- Порядок описания агрегата: класс-источник — раньше цели (раньше источник можно было описать позже, и он
|
|
126
|
+
сверялся при появлении). Классы с концом на ещё не созданный класс по-прежнему описываются, поэтому пара «цель —
|
|
127
|
+
источник с концом на цель» описывается в порядке «источник, цель». Тесты `aggregate` и бенч `derived` описывают
|
|
128
|
+
классы в этом порядке.
|
|
129
|
+
|
|
5
130
|
## [1.2.0] — 2026-10-28
|
|
6
131
|
|
|
7
132
|
letopis 1.2 по плану `reports/PLAN-LETOPIS-1.1.md` (редакция 5, этапы 4–6 и контрольная точка Б) и плану доработок
|
package/README.en.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# letopis 1.
|
|
1
|
+
# letopis 1.3
|
|
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
|
|
@@ -375,6 +375,11 @@ class name. While the index is being built, writes to the journal wait. On a lar
|
|
|
375
375
|
without stopping writes: `create index concurrently log_class_tenant_at on "v2.<schema>".log (class, tenant, at)` as
|
|
376
376
|
the owner role — then `up()` only drops the old one.
|
|
377
377
|
|
|
378
|
+
The `upgrade` test checks upgrading 1.1.0 and 1.0.1 schemas. The old package installs the schema and the old engine
|
|
379
|
+
writes the data, then the current version's `up()` runs. After it, the versions and hashes written by the old engine
|
|
380
|
+
are in place, `verify({ data: true })` is clean, and the schema catalog matches a clean installation. A 1.1.0 schema is
|
|
381
|
+
upgraded with a plain `up()`, since its revision is the same. A 1.0.1 schema is upgraded with `up({ upgrade: true })`.
|
|
382
|
+
|
|
378
383
|
### Development without PostgreSQL: PGlite
|
|
379
384
|
|
|
380
385
|
`up({ pglite: true })` starts PGlite (PostgreSQL 18 on WebAssembly) with pgcrypto and a socket on a free port. It needs
|
|
@@ -560,13 +565,15 @@ const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet
|
|
|
560
565
|
the target is needed: the permission to write the source is enough.
|
|
561
566
|
- The database checks the description (`invalid_class`): the field is in the schema, it is numeric and not part of the
|
|
562
567
|
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
|
|
564
|
-
|
|
568
|
+
cycle; the target and the source are not system or derived classes. A source class is described before the
|
|
569
|
+
aggregate: an aggregate over a class that does not exist yet is refused. For a tenant class, the source is looked up
|
|
570
|
+
among the tenant's classes. `syncModels` describes sources before targets itself. The `of` class is not deleted while
|
|
571
|
+
an aggregate of a class of the same tenant names it: first the aggregate is removed from the target's description.
|
|
565
572
|
- Rows get into an aggregate only with the knowledge of someone who reads them all: the field also counts rows that
|
|
566
573
|
the author cannot see. Only someone who reads all rows of the source and its descendants without conditions can
|
|
567
574
|
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
|
|
569
|
-
|
|
575
|
+
both before the change and after it, can make the class a source (a new descendant of a source, a `parent` change).
|
|
576
|
+
Otherwise `acl_denied`.
|
|
570
577
|
- The option is inherited, and a descendant replaces it as a whole. Adding or changing an aggregate is a tightening:
|
|
571
578
|
writes to the target and to the sources wait while the field is recalculated for all rows.
|
|
572
579
|
- `db.rebuildAggregates(class)` recalculates the field from the current rows, for repair. A version is written only
|
|
@@ -981,7 +988,8 @@ version 1, a `purge` record stays with the number of erased versions. The histor
|
|
|
981
988
|
While a projector whose `from` sources include the class of the object ([“Projectors”](#projectors)) has not read
|
|
982
989
|
the deletion, `purge` refuses (`projector_pending`): the projector would never see an erased tombstone, and the
|
|
983
990
|
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.
|
|
991
|
+
such a class. Likewise, `db.trimHistory` refuses while the projector has not read the first remaining version: it
|
|
992
|
+
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
993
|
|
|
986
994
|
## 8. Transactions, batches and money
|
|
987
995
|
|
|
@@ -1462,16 +1470,23 @@ alias in the tenant and System, and the name of a new class does not match anoth
|
|
|
1462
1470
|
written for one class would act on the rows of another too. A violation gives `invalid_class`.
|
|
1463
1471
|
|
|
1464
1472
|
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.
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1473
|
+
no rows, no descendants and no history of rows: `purge()` erases the history of deleted rows first. For a System class,
|
|
1474
|
+
the rows, descendants and history of a tenant that has its own class with the same name do not count: they belong to
|
|
1475
|
+
that tenant's class. A class deleted with history before is created again only with the same `parent`, `alias`,
|
|
1476
|
+
`derivedBy` and `history` — otherwise `invalid_class`: the old versions of its rows would fall under other rules or a
|
|
1477
|
+
different thinning. The rules also find old versions by the aliases of ancestors. So while deleted descendants of a
|
|
1478
|
+
class keep row history, the class does not change its alias (`invalid_class`), and once deleted it is created again
|
|
1479
|
+
only with the same description. If the journal holds rows of a class with this name but not its tombstone (for
|
|
1480
|
+
example, rows of a System class deleted in 1.1), a class with this name is not created until `purge()` erases the
|
|
1481
|
+
history.
|
|
1470
1482
|
|
|
1471
1483
|
A derived class (`derivedBy`) is created, edited and deleted only by an administrator: an admin connection or an
|
|
1472
1484
|
account with the System permission `letopis.projector` (the system administrator has it through the `letopis.*`
|
|
1473
1485
|
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.
|
|
1486
|
+
so a user who created it on their own would read totals over rows they may not read. A class that has derived
|
|
1487
|
+
descendants is also edited only by an administrator: editing an ancestor changes the description of the descendant
|
|
1488
|
+
(fields, key, `history`). The database itself sets the `aggregatedBy` mark (the class became an aggregate source) on a
|
|
1489
|
+
derived class too: it does not change its fields or key.
|
|
1475
1490
|
|
|
1476
1491
|
### Granting a permission
|
|
1477
1492
|
|
|
@@ -1708,7 +1723,10 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
|
|
|
1708
1723
|
moment: a write after it comes to the stream. The example above reads from `'start'`.
|
|
1709
1724
|
- Calls of `sub.next()` go in turn: parallel calls get different events. A call abandoned by a timeout
|
|
1710
1725
|
(`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).
|
|
1726
|
+
- `classes`: only these classes with their descendants; `pollMs`: fallback polling (1000 ms). A row that left these
|
|
1727
|
+
classes through a class change without an id change arrives as a deletion event in the previous class (`op:
|
|
1728
|
+
'delete'`, `row` is the previous version with `$deleted`), even if the subscriber cannot see the new class. The
|
|
1729
|
+
event comes if the subscriber could see the previous version.
|
|
1712
1730
|
- Events come without losses or repeats, under the reader's permissions, in the order of transaction numbers, and
|
|
1713
1731
|
within a transaction in the order of writing. This is not the commit order: compare versions of one object by `rev`.
|
|
1714
1732
|
- A long write transaction in any database of the cluster delays the stream: `await sub.lag()` shows the lag in ms.
|
|
@@ -1832,7 +1850,8 @@ sales.close() // stop() after the current bat
|
|
|
1832
1850
|
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
1851
|
those whose transaction is visible in its snapshot. The tenant's derived rows are deleted at once and are incomplete
|
|
1834
1852
|
until the end of the rebuild (`status().rebuildingTenants`); the rebuild waits out a lock held by `run()` of another
|
|
1835
|
-
process.
|
|
1853
|
+
process. An interrupted rebuild (the process stopped) is continued by `run()` of any process, with or without
|
|
1854
|
+
`onExpired`.
|
|
1836
1855
|
- `status().state` describes the projection in the database, not a process: `'new'` means there is no projection yet,
|
|
1837
1856
|
and the first `run()` or `rebuild()` builds it; `'running'` means the projection is built and kept up with the
|
|
1838
1857
|
journal (it does not mean that some process is executing `run()` right now); `'rebuilding'` means a batch rebuild
|
|
@@ -1861,6 +1880,10 @@ sales.close() // stop() after the current bat
|
|
|
1861
1880
|
tenant with `rebuild({ tenant })`.
|
|
1862
1881
|
- `batch` is how many changes go into a batch (500), `pollMs` is fallback polling (1000 ms): source signals wake the
|
|
1863
1882
|
projector earlier.
|
|
1883
|
+
- `lockTimeout` is how long a projector transaction waits for another transaction's row lock (5000 ms; 0 — no limit).
|
|
1884
|
+
Otherwise a transaction of any tenant holding a total row or an end target would hold the batch of all tenants.
|
|
1885
|
+
Longer than that, the batch rolls back, the cursor stays, and `run()` does not reject but skips the pass
|
|
1886
|
+
(`skipped`) and repeats it.
|
|
1864
1887
|
- 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
1888
|
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
1889
|
so the handle from the example above (`out.inc` for every change) caught up with 6 thousand changes per second with
|
|
@@ -2317,7 +2340,7 @@ give `invalid_data`.
|
|
|
2317
2340
|
|
|
2318
2341
|
| Member | What it does |
|
|
2319
2342
|
|---|---|
|
|
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 |
|
|
2343
|
+
| 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); `tenants`: ids of the participant tenants |
|
|
2321
2344
|
| `run({ once })` | catch up with the journal (`once`) or follow it until `stop()` → `ProjectorReport` |
|
|
2322
2345
|
| `rebuild({ chunked, tenant })` | rebuild from the current state: atomically, in batches or for one tenant → `ProjectorReport` |
|
|
2323
2346
|
| `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` is `{ [tenant id]: ProjectorFailure }`, where `ProjectorFailure` is `{ cursor, code, message }` |
|
|
@@ -2384,7 +2407,7 @@ Database codes. Raw SQL gets them too: SQLSTATE class `LT`, the details are JSON
|
|
|
2384
2407
|
| `confirm_mismatch` | LT020 | the reset confirmation does not match the schema name |
|
|
2385
2408
|
| `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
2409
|
| `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 |
|
|
2410
|
+
| `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
2411
|
|
|
2389
2412
|
Library codes:
|
|
2390
2413
|
|
|
@@ -2470,7 +2493,7 @@ runs) showed no write or read regressions against 1.1: two outliers did not repe
|
|
|
2470
2493
|
|
|
2471
2494
|
## 18. Limitations
|
|
2472
2495
|
|
|
2473
|
-
This section lists what letopis 1.
|
|
2496
|
+
This section lists what letopis 1.3 deliberately does not do or does with caveats, and where you need to watch the
|
|
2474
2497
|
behavior.
|
|
2475
2498
|
|
|
2476
2499
|
**Platform**
|
|
@@ -2556,8 +2579,10 @@ behavior.
|
|
|
2556
2579
|
attempts.
|
|
2557
2580
|
- When users link a taken identifier themselves, the answer is `acl_denied`, so a user with a recent sign-in learns
|
|
2558
2581
|
this way that the identifier is taken. Permission changes in all tenants are serialized through one counter row.
|
|
2559
|
-
-
|
|
2560
|
-
|
|
2582
|
+
- Refusal texts name the class, role and id of a referencing row only if the actor can see it: a cascade delete,
|
|
2583
|
+
unsetting an end, rewriting references on a class or key change, and a `parent` change conflict
|
|
2584
|
+
(`tightening_conflict`). The number of invisible rows that block a deletion or a
|
|
2585
|
+
class change (`reclass_denied`), and the existence of an account by id (as a reference target), are not hidden.
|
|
2561
2586
|
- Tightening a class description and `rekeyClass` check all rows of the family, so they need the `READ` permission
|
|
2562
2587
|
without conditions on all rows of the class and its descendants: otherwise a trial tightening (`maximum`, `enum`,
|
|
2563
2588
|
`pattern`) would answer with the number of invisible rows that do not match it. A system administrator who tightens
|
|
@@ -2640,6 +2665,8 @@ behavior.
|
|
|
2640
2665
|
- `purge()` waits for projectors: while a projector whose sources include the class of the object, and which has a
|
|
2641
2666
|
derived class in the object's tenant, has not read the deletion, `purge` answers `projector_pending`. A projector
|
|
2642
2667
|
that is no longer run holds `purge` of its sources while the tenant has its derived classes: run it or delete them.
|
|
2668
|
+
`trimHistory` waits the same way: while the projector has not read the first remaining version, trimming answers
|
|
2669
|
+
`projector_pending`.
|
|
2643
2670
|
|
|
2644
2671
|
**Aggregates in the database**
|
|
2645
2672
|
|
|
@@ -2707,7 +2734,7 @@ This section is for those who develop letopis itself. Run: `cd lib && npm test`.
|
|
|
2707
2734
|
# hash — the version hash chain, journal tampering and gaps, verify() (stage 1)
|
|
2708
2735
|
# harness — the test harness: starting the database, a schema per file, two connection strings (stage 0)
|
|
2709
2736
|
# 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)
|
|
2737
|
+
# 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
2738
|
# install — the installer and roles: a repeated installation, two installations in one database, three tables (stage 1)
|
|
2712
2739
|
# member — memberships: working in a tenant by roles, auth.switch, leaving with ownership transfer, system rows, tenant roles without System permissions (stage 5)
|
|
2713
2740
|
# model — Zod models, the JSON Schema normalizer, pattern translation, agreement with the database metaschema (stage 2)
|
|
@@ -2731,6 +2758,7 @@ This section is for those who develop letopis itself. Run: `cd lib && npm test`.
|
|
|
2731
2758
|
# 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
2759
|
# maintain — maintenance: expired sessions and codes, permissions, one process under a lock, schedule (stage 6)
|
|
2733
2760
|
# 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)
|
|
2761
|
+
# 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
2762
|
# 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
2763
|
# telemetry — onQuery and slowMs, describe() and class metadata with inheritance, describeText (stage 6)
|
|
2736
2764
|
# 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)
|