letopis 1.3.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.3 — agent cheatsheet
1
+ # letopis 1.4 — agent cheatsheet
2
2
 
3
3
  One page instead of [README.en.md](README.en.md), for an AI agent that writes code on top of the package. It ships in
4
4
  the package next to the README; in Russian: [Русская версия](AGENT-CHEATSHEET.md). Migration from 0.21:
@@ -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
@@ -1,4 +1,4 @@
1
- # letopis 1.3 — шпаргалка для агента
1
+ # letopis 1.4 — шпаргалка для агента
2
2
 
3
3
  Одна страница вместо [README.md](README.md) — для ИИ-агента, который пишет код поверх пакета. Лежит в пакете рядом с
4
4
  README; английская версия — [AGENT-CHEATSHEET.en.md](AGENT-CHEATSHEET.en.md). Переход с 0.21 — [MIGRATION.md](MIGRATION.md);
@@ -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` и сессию в арендаторе. Повтор с тем же именем и логином ничего не дублирует и роли
package/CHANGELOG.md CHANGED
@@ -2,6 +2,94 @@
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
+
5
93
  ## [1.3.0] — 2026-10-28
6
94
 
7
95
  letopis 1.3 — доработки после выпуска 1.2.0 (`docs/checkpoint-1.3.md`). Вошли три направления:
package/README.en.md CHANGED
@@ -1,4 +1,4 @@
1
- # letopis 1.3
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,6 +369,13 @@ 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
@@ -567,13 +574,21 @@ const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet
567
574
  key; the `via` end is single and accepts the target class; the `sum` field is a number; aggregates do not form a
568
575
  cycle; the target and the source are not system or derived classes. A source class is described before the
569
576
  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.
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.
572
581
  - Rows get into an aggregate only with the knowledge of someone who reads them all: the field also counts rows that
573
582
  the author cannot see. Only someone who reads all rows of the source and its descendants without conditions can
574
583
  describe or change an aggregate. Only someone who reads all rows of a class and its descendants without conditions,
575
584
  both before the change and after it, can make the class a source (a new descendant of a source, a `parent` change).
576
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.
577
592
  - The option is inherited, and a descendant replaces it as a whole. Adding or changing an aggregate is a tightening:
578
593
  writes to the target and to the sources wait while the field is recalculated for all rows.
579
594
  - `db.rebuildAggregates(class)` recalculates the field from the current rows, for repair. A version is written only
@@ -1883,7 +1898,13 @@ sales.close() // stop() after the current bat
1883
1898
  - `lockTimeout` is how long a projector transaction waits for another transaction's row lock (5000 ms; 0 — no limit).
1884
1899
  Otherwise a transaction of any tenant holding a total row or an end target would hold the batch of all tenants.
1885
1900
  Longer than that, the batch rolls back, the cursor stays, and `run()` does not reject but skips the pass
1886
- (`skipped`) and repeats it.
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.
1887
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
1888
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,
1889
1910
  so the handle from the example above (`out.inc` for every change) caught up with 6 thousand changes per second with
@@ -2340,12 +2361,12 @@ give `invalid_data`.
2340
2361
 
2341
2362
  | Member | What it does |
2342
2363
  |---|---|
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 |
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 |
2344
2365
  | `run({ once })` | catch up with the journal (`once`) or follow it until `stop()` → `ProjectorReport` |
2345
2366
  | `rebuild({ chunked, tenant })` | rebuild from the current state: atomically, in batches or for one tenant → `ProjectorReport` |
2346
2367
  | `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` is `{ [tenant id]: ProjectorFailure }`, where `ProjectorFailure` is `{ cursor, code, message }` |
2347
2368
  | `stop()`, `close()` | stop `run()` after the current batch; the same plus unsubscribing from signals |
2348
- | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds }` |
2369
+ | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds, locked }` |
2349
2370
  | `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` and, when present, `agent`, `reason`, `moved`, `before`, `rebuild` |
2350
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 |
2351
2372
 
@@ -2493,7 +2514,7 @@ runs) showed no write or read regressions against 1.1: two outliers did not repe
2493
2514
 
2494
2515
  ## 18. Limitations
2495
2516
 
2496
- This section lists what letopis 1.3 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
2497
2518
  behavior.
2498
2519
 
2499
2520
  **Platform**
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # letopis 1.3
1
+ # letopis 1.4
2
2
 
3
3
  **letopis** — библиотека для Node.js, хранилище версионированных сущностей на PostgreSQL 18. Каждое изменение
4
4
  сохраняется версией в журнале. Поэтому у любого объекта есть история, состояние на любую дату и подписка на
@@ -362,6 +362,12 @@ const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token
362
362
  получит `upgrade: true`. `up({ dsn, schema, upgrade: true })` применяет миграции из `sql/migrate/` и повторно
363
363
  накатывает функции; данные сохраняются.
364
364
 
365
+ Ревизия меняется не с каждой версией: у 1.1–1.4 она одна, а функции разные. Поэтому `up()` пишет в комментарий схемы
366
+ ещё и штамп движка: версию пакета и хэш его файлов SQL. Если штамп не совпадает с файлами пакета, `connect()`
367
+ предупреждает. Когда схему накатила более старая версия (или версия до 1.4, у которой штампа нет), он советует
368
+ `up({ schema })`. Когда схему уже обновила более новая версия, он советует обновить пакет: `up()` старой версией вернул
369
+ бы прежние функции. Новая версия пакета без правок SQL штамп не меняет, и предупреждения нет.
370
+
365
371
  Первый `up()` после перехода на 1.2 строит индекс журнала `log_class_tenant_at (class, tenant, at)` вместо
366
372
  `log_class_at`: срез `asOf` и `withDeleted` по классу больше не читает журнал других арендаторов с тем же именем
367
373
  класса. Пока индекс строится, запись в журнал ждёт. На большой установке постройте его заранее, не останавливая
@@ -550,12 +556,18 @@ const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet
550
556
  - Описание проверяет база (`invalid_class`): поле есть в схеме, оно числовое и не входит в ключ; конец `via` —
551
557
  одиночный и принимает класс цели; поле `sum` — число; агрегаты не образуют цикл; цель и источник — не системные и
552
558
  не производные классы. Класс-источник описывают раньше агрегата: агрегат над классом, которого ещё нет, отклоняется.
553
- У класса арендатора источник ищут среди классов арендатора. `syncModels` сам описывает источники раньше целей.
559
+ У класса арендатора источник ищут среди классов арендатора. `syncModels` сам описывает источники раньше целей, а
560
+ модели по кругу (агрегат над собственным потомком, цикл агрегатов) отклоняет до записи (`invalid_class` с кругом).
554
561
  Класс `of` не удаляется, пока его называет агрегат класса того же арендатора: сначала агрегат убирают из описания цели.
555
562
  - Строки попадают в агрегат только с ведома того, кто читает их все: поле считает и строки, которые автору не видны.
556
563
  Описать или изменить агрегат может только тот, кто без условий читает все строки источника и его потомков. Сделать
557
564
  класс источником (новый потомок источника, смена `parent`) может только тот, кто без условий читает все
558
565
  строки этого класса и его потомков — и до правки, и после. Иначе `acl_denied`.
566
+ - Новый источник проверяется и по тому, кто описал агрегат: он тоже должен без условий читать все строки класса.
567
+ Описавший — автор версии строки `Class` цели, с которой описание агрегата стало нынешним. Если он их не читает, класс
568
+ источником не станет (`acl_denied`), пока агрегат не опишет заново тот, кто читает. У агрегата класса System
569
+ описавший проверяется по классам System, как и при описании. Класс арендатора под агрегатом System проверяется
570
+ только у того, кто его заводит.
559
571
  - Опция наследуется, потомок заменяет её целиком. Добавить или изменить агрегат — ужесточение: запись в цель и в
560
572
  источники ждёт, пока поле пересчитывается у всех строк.
561
573
  - `db.rebuildAggregates(класс)` пересчитывает поле из текущих строк — для ремонта. Версия пишется только там, где
@@ -1817,7 +1829,13 @@ sales.close() // stop() после текущ
1817
1829
  проектор раньше.
1818
1830
  - `lockTimeout` — сколько транзакция проектора ждёт чужую блокировку строки (5000 мс; 0 — без предела). Транзакция
1819
1831
  любого арендатора, которая держит строку итога или цель конца, иначе держала бы пачку всех арендаторов. Дольше —
1820
- пачка откатывается, курсор остаётся, `run()` не отклоняется, а пропускает проход (`skipped`) и повторяет его.
1832
+ пачка откатывается, курсор остаётся, `run()` не отклоняется, а пропускает проход (`skipped`) и повторяет его. Отчёт
1833
+ называет арендатора, на чьей строке встала пачка (`locked`). `rebuild({ tenant, chunked: true })` такую блокировку
1834
+ пережидает.
1835
+ - `lockRetries` — сколько проходов подряд запись одного арендатора может упираться в `lockTimeout`, прежде чем он
1836
+ выбудет (по умолчанию 0 — не выбывает). Выбывший получает код `55P03`, и пачка идёт для остальных; его изменения
1837
+ пропускаются до `rebuild({ tenant })`. Счёт ведёт процесс, удачная запись арендатора его сбрасывает. Учтите: долгая
1838
+ исключительная блокировка семейства класса System (ужесточение описания) упрётся в каждого арендатора по очереди.
1821
1839
  - Каждая правка, которую `out` отправляет в базу, — отдельная правка строки, и скорость проектора задаёт число
1822
1840
  правок, а не число изменений. Свёртка `inc` сводит правки строки к одной за пачку, поэтому handle из примера выше
1823
1841
  (`out.inc` на каждое изменение) на машине замеров догонял 6 тысяч изменений в секунду при `batch` 500 и 33 тысячи
@@ -2258,12 +2276,12 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2258
2276
 
2259
2277
  | Член | Что делает |
2260
2278
  |---|---|
2261
- | опции `ProjectorOptions` | `from`, `into` — классы с потомками; `version` — целое от 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` — пачками); `batch` — изменений в пачке (500, от 1 до 10 000); `pollMs` — запасной опрос (1000 мс); `lockTimeout` — ожидание чужой блокировки строки (5000 мс, 0 — без предела); `tenants` — id арендаторов-участников |
2279
+ | опции `ProjectorOptions` | `from`, `into` — классы с потомками; `version` — целое от 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` — пачками); `batch` — изменений в пачке (500, от 1 до 10 000); `pollMs` — запасной опрос (1000 мс); `lockTimeout` — ожидание чужой блокировки строки (5000 мс, 0 — без предела); `lockRetries` — проходов подряд до выбывания арендатора с чужой блокировкой (0 — не выбывает); `tenants` — id арендаторов-участников |
2262
2280
  | `run({ once })` | догнать журнал (`once`) или следовать за ним до `stop()` → `ProjectorReport` |
2263
2281
  | `rebuild({ chunked, tenant })` | перестроить с текущего состояния: атомарно, пачками или одного арендатора → `ProjectorReport` |
2264
2282
  | `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` — `{ [id арендатора]: ProjectorFailure }`, где `ProjectorFailure` — `{ cursor, code, message }` |
2265
2283
  | `stop()`, `close()` | остановить `run()` после текущей пачки; то же и отписаться от сигналов |
2266
- | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds }` |
2284
+ | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds, locked }` |
2267
2285
  | `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` и, если есть, `agent`, `reason`, `moved`, `before`, `rebuild` |
2268
2286
  | `ProjectorOut` | `upsert(класс, строка, { tenant })`, `inc(класс, строка, приращения, { tenant })`, `delete(класс, ключ или id, { tenant })`, `get(класс, ключ или id, { tenant })` → `Row \| null`; строка — `ProjectorRow` в формате загрузчика |
2269
2287
 
@@ -2408,7 +2426,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2408
2426
 
2409
2427
  ## 18. Ограничения
2410
2428
 
2411
- Здесь собрано то, что letopis 1.3 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
2429
+ Здесь собрано то, что letopis 1.4 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
2412
2430
 
2413
2431
  **Платформа**
2414
2432
 
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import { Subscription } from './watch.js';
24
24
  import { Projector } from './project.js';
25
25
  import { SIGNAL_MODES, Signals, bucket, throttle } from './signals.js';
26
26
  import { describe as describeModel, maintain as runMaintain, rebuildAggregates as runRebuildAggregates, rekeyClass as runRekeyClass, reset as runReset, scheduleMaintain, trimHistory as runTrim, verify as runVerify, } from './admin.js';
27
+ import { stampDrift } from './up.js';
27
28
  export { DDL_REVISION } from './types.js';
28
29
  export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
29
30
  export { LetopisError, ValidationError, fromDbError } from './errors.js';
@@ -267,6 +268,12 @@ export async function connect(opts) {
267
268
  throw new LetopisError('invalid_data', `connect: схемы ${opts.schema} нет — установите её: up({ schema })`);
268
269
  if (rev < DDL_REVISION)
269
270
  console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev}, библиотека ждёт ${DDL_REVISION} — обновите: up({ schema: '${opts.schema}', upgrade: true })`);
271
+ else if (rev === DDL_REVISION) {
272
+ // ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
273
+ const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
274
+ if (drift)
275
+ console.warn(drift);
276
+ }
270
277
  // режим сигналов установки (план 1.1, §2.2); схема, накатанная до него (нет signal_mode()), — class
271
278
  const [sm] = await sql.unsafe(`select ${S}.signal_mode() as m`).catch((e) => {
272
279
  if (e.code === '42883')
package/dist/project.d.ts CHANGED
@@ -103,6 +103,12 @@ export interface ProjectorOptions {
103
103
  * пачка откатывается, курсор остаётся, run() не отклоняется, а пропускает проход и повторяет его.
104
104
  */
105
105
  lockTimeout?: number;
106
+ /**
107
+ * Сколько проходов подряд запись одного арендатора может упираться в lockTimeout, прежде чем он выбудет (код 55P03,
108
+ * дальше — rebuild({ tenant })), а пачка пойдёт для остальных; по умолчанию 0 — не выбывает, проход повторяется для всех.
109
+ * Счёт — в процессе, удачная запись арендатора его сбрасывает.
110
+ */
111
+ lockRetries?: number;
106
112
  /**
107
113
  * Явный список арендаторов проектора: участники — только они (и только те, у кого каждый класс into помечен
108
114
  * derivedBy). Без списка участник — любой арендатор с такими классами.
@@ -118,10 +124,12 @@ export interface ProjectorReport {
118
124
  updated: number;
119
125
  deleted: number;
120
126
  unchanged: number;
121
- /** Пачек, пропущенных из-за блокировки: проектор работает в другом процессе. */
127
+ /** Пачек, пропущенных из-за блокировки: проектор работает в другом процессе или строку держит чужая транзакция. */
122
128
  skipped: number;
123
129
  /** Перестроек. */
124
130
  rebuilds: number;
131
+ /** Арендаторы, чья запись упиралась в чужую блокировку строки дольше lockTimeout (по порядку, без повторов). */
132
+ locked: string[];
125
133
  }
126
134
  /** Выбывший арендатор: база отклонила запись его результатов. */
127
135
  export interface ProjectorFailure {
@@ -182,6 +190,9 @@ export declare class Projector {
182
190
  private readonly batch;
183
191
  private readonly pollMs;
184
192
  private readonly lockMs;
193
+ private readonly lockRetries;
194
+ /** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
195
+ private readonly lockHits;
185
196
  private readonly only;
186
197
  constructor(env: ProjectorEnv, name: string, def: ProjectorOptions);
187
198
  /**
@@ -312,6 +323,8 @@ export declare class Projector {
312
323
  * true }). true — работа ещё осталась.
313
324
  */
314
325
  private tenantWork;
326
+ /** Повторять fn, пока строку держит чужая транзакция дольше lockTimeout (55P03): пауза 20 мс, проход — в skipped. */
327
+ private untilFree;
315
328
  /** Ждать сигнал источников, остановку или запасной опрос. */
316
329
  private sleep;
317
330
  }
package/dist/project.js CHANGED
@@ -50,7 +50,7 @@ function withFailed(meta, failed) {
50
50
  delete next.failed;
51
51
  return next;
52
52
  }
53
- const emptyReport = () => ({ batches: 0, changes: 0, created: 0, updated: 0, deleted: 0, unchanged: 0, skipped: 0, rebuilds: 0 });
53
+ const emptyReport = () => ({ batches: 0, changes: 0, created: 0, updated: 0, deleted: 0, unchanged: 0, skipped: 0, rebuilds: 0, locked: [] });
54
54
  function change(row, cursor, rebuild = false) {
55
55
  return {
56
56
  cursor, id: row.id, class: row.class, tenant: row.tenant, op: rebuild ? 'create' : row.op, rev: row.rev, at: row.at,
@@ -211,6 +211,9 @@ export class Projector {
211
211
  batch;
212
212
  pollMs;
213
213
  lockMs;
214
+ lockRetries;
215
+ /** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
216
+ lockHits = new Map();
214
217
  only;
215
218
  constructor(env, name, def) {
216
219
  this.env = env;
@@ -238,6 +241,9 @@ export class Projector {
238
241
  this.lockMs = def.lockTimeout ?? 5000;
239
242
  if (!Number.isInteger(this.lockMs) || this.lockMs < 0)
240
243
  throw new LetopisError('invalid_data', `проектор ${name}: lockTimeout — целое число мс, 0 и больше`);
244
+ this.lockRetries = def.lockRetries ?? 0;
245
+ if (!Number.isInteger(this.lockRetries) || this.lockRetries < 0)
246
+ throw new LetopisError('invalid_data', `проектор ${name}: lockRetries — целое число, 0 и больше`);
241
247
  if (!Number.isInteger(this.batch) || this.batch < 1 || this.batch > 10_000)
242
248
  throw new LetopisError('invalid_data', `проектор ${name}: batch — от 1 до 10 000`);
243
249
  if (env.onSignal) {
@@ -338,14 +344,19 @@ export class Projector {
338
344
  const started = rep.rebuilds;
339
345
  for (;;) {
340
346
  // занятая строка (lockTimeout) — как занятая блокировка проектора: подождать и повторить пачку
341
- const r = await this.tenantChunk(rep, tenant).catch((e) => { if (lockWait(e))
342
- return 'busy'; throw e; });
347
+ const r = await this.tenantChunk(rep, tenant).catch((e) => {
348
+ if (!lockWait(e))
349
+ throw e;
350
+ rep.skipped++;
351
+ return 'busy';
352
+ });
343
353
  if (r === 'busy')
344
354
  await new Promise((res) => setTimeout(res, 20));
345
355
  if (r !== 'done')
346
356
  continue;
347
- // ошибка данных арендатора — как у перестройки одной транзакцией: отказ, арендатор выбывший
348
- const f = (await this.tx(async (q) => this.state(q))).failed?.[tenant];
357
+ // ошибка данных арендатора — как у перестройки одной транзакцией: отказ, арендатор выбывший; чекпоинт держит
358
+ // пачка run() другого процесса дольше lockTimeout — подождать
359
+ const f = (await this.untilFree(rep, () => this.tx(async (q) => this.state(q)))).failed?.[tenant];
349
360
  if (f)
350
361
  throw new LetopisError(f.code, f.message);
351
362
  // последнюю пачку мог записать run() другого процесса (он продвигает начатые перестройки): перестройка этого
@@ -723,6 +734,27 @@ export class Projector {
723
734
  catch (e) {
724
735
  // причина — первая ошибка базы группы (следом за ней база отвечает лишь «транзакция прервана»)
725
736
  const cause = out.aborted ?? e;
737
+ // строку арендатора держит чужая транзакция дольше lockTimeout (хвост ревью доработок после 1.2.0): отчёт
738
+ // называет арендатора; с lockRetries после стольких проходов подряд он выбывает, и пачка идёт для остальных,
739
+ // иначе откатывается вся пачка и проход повторяется
740
+ if (failed && lockWait(cause)) {
741
+ if (!rep.locked.includes(tenant))
742
+ rep.locked.push(tenant);
743
+ const hits = (this.lockHits.get(tenant) ?? 0) + 1;
744
+ if (this.lockRetries === 0 || hits < this.lockRetries) {
745
+ this.lockHits.set(tenant, hits);
746
+ throw e;
747
+ }
748
+ this.lockHits.delete(tenant);
749
+ await q.unsafe('rollback to savepoint letopis_projector');
750
+ await q.unsafe('release savepoint letopis_projector');
751
+ failed[tenant] = {
752
+ cursor: list[0].cursor || start,
753
+ code: '55P03',
754
+ message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — ${hits} проходов подряд дольше lockTimeout`,
755
+ };
756
+ continue;
757
+ }
726
758
  if (!failed || !out.owns(e) || !tenantFault(cause))
727
759
  throw e;
728
760
  await q.unsafe('rollback to savepoint letopis_projector');
@@ -737,6 +769,7 @@ export class Projector {
737
769
  }
738
770
  if (failed)
739
771
  await q.unsafe('release savepoint letopis_projector');
772
+ this.lockHits.delete(tenant);
740
773
  rep.created += out.stats.created;
741
774
  rep.updated += out.stats.updated;
742
775
  rep.deleted += out.stats.deleted;
@@ -1002,6 +1035,9 @@ export class Projector {
1002
1035
  */
1003
1036
  async startTenant(rep, tenant, auto) {
1004
1037
  for (;;) {
1038
+ // строку арендатора (его производные строки удаляются) держит чужая транзакция дольше lockTimeout — как занятая
1039
+ // блокировка проектора: run() повторит на следующем проходе, rebuild() подождёт (хвост ревью доработок после 1.2.0:
1040
+ // rebuild({ tenant, chunked: true }) отдавал сырую 55P03)
1005
1041
  const r = await this.tx(async (q) => {
1006
1042
  if (!(await this.lock(q)))
1007
1043
  return 'busy';
@@ -1033,6 +1069,12 @@ export class Projector {
1033
1069
  delete next.resync;
1034
1070
  await this.advance(q, meta.cursor, withFailed(next, failed));
1035
1071
  return 'ok';
1072
+ }).catch((e) => {
1073
+ if (!lockWait(e))
1074
+ throw e;
1075
+ if (!auto)
1076
+ rep.skipped++;
1077
+ return 'busy';
1036
1078
  });
1037
1079
  if (r !== 'busy' || auto) {
1038
1080
  if (r === 'busy')
@@ -1127,6 +1169,20 @@ export class Projector {
1127
1169
  this.tenantsPending = more;
1128
1170
  return more;
1129
1171
  }
1172
+ /** Повторять fn, пока строку держит чужая транзакция дольше lockTimeout (55P03): пауза 20 мс, проход — в skipped. */
1173
+ async untilFree(rep, fn) {
1174
+ for (;;) {
1175
+ try {
1176
+ return await fn();
1177
+ }
1178
+ catch (e) {
1179
+ if (!lockWait(e))
1180
+ throw e;
1181
+ rep.skipped++;
1182
+ }
1183
+ await new Promise((res) => setTimeout(res, 20));
1184
+ }
1185
+ }
1130
1186
  /** Ждать сигнал источников, остановку или запасной опрос. */
1131
1187
  sleep() {
1132
1188
  return new Promise((resolve) => {
package/dist/sync.js CHANGED
@@ -12,13 +12,19 @@ export async function syncModels(db, models, opts = {}) {
12
12
  const res = { applied: Boolean(opts.apply), created: [], updated: [], unchanged: [], skipped: [], lost: models.flatMap((m) => m.lostRules()) };
13
13
  const S = `"${db.schema.replace(/"/g, '""')}"`;
14
14
  // предок раньше потомка; класс-источник агрегата раньше цели: агрегат над классом, которого ещё нет, база отклоняет.
15
- // Цикл агрегатов база отклоняет сама — обход его только не зацикливает
15
+ // Круг (агрегат над собственным потомком, цикл агрегатов) так не упорядочить — отказ до записи с самим кругом (хвост
16
+ // ревью доработок после 1.2.0: раньше обход молча ставил потомка раньше предка)
16
17
  const ordered = [];
17
- const visiting = new Set();
18
+ const path = [];
18
19
  const visit = (m) => {
19
- if (ordered.includes(m) || visiting.has(m))
20
+ if (ordered.includes(m))
20
21
  return;
21
- visiting.add(m);
22
+ const at = path.indexOf(m);
23
+ if (at >= 0) {
24
+ throw new LetopisError('invalid_class', `syncModels: модели зависят друг от друга по кругу: ${[...path.slice(at), m].map((x) => x.name).join(' → ')} — `
25
+ + 'родителя и источник агрегата база ждёт раньше класса');
26
+ }
27
+ path.push(m);
22
28
  if (m.parent && models.includes(m.parent))
23
29
  visit(m.parent);
24
30
  for (const a of Object.values(m.describe().aggregate ?? {})) {
@@ -26,7 +32,7 @@ export async function syncModels(db, models, opts = {}) {
26
32
  if (src)
27
33
  visit(src);
28
34
  }
29
- visiting.delete(m);
35
+ path.pop();
30
36
  ordered.push(m);
31
37
  };
32
38
  models.forEach(visit);
package/dist/up.d.ts CHANGED
@@ -57,6 +57,23 @@ export interface InstallResult {
57
57
  export declare const quoteIdent: (name: string) => string;
58
58
  /** Файлы ядра по номеру: `10-core.sql` … `99-revision.sql`; сиды демо-домена сюда не входят. */
59
59
  export declare function engineFiles(upTo?: number): Promise<string[]>;
60
+ /** Штамп движка: версия пакета и хэш файлов ядра (`99-revision.sql` пишет его в комментарий схемы). */
61
+ export interface EngineStamp {
62
+ version: string;
63
+ sql: string;
64
+ }
65
+ /**
66
+ * Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF): одна и та же
67
+ * сборка даёт один штамп при любых опциях установки, а версия пакета без правок SQL штамп не меняет.
68
+ */
69
+ export declare function engineStamp(): Promise<EngineStamp>;
70
+ /** Сравнение версий semver (метаданные сборки не учитываются): меньше нуля, ноль, больше нуля. */
71
+ export declare function compareVersions(a: string, b: string): number;
72
+ /**
73
+ * Штамп схемы (комментарий схемы) против файлов ядра пакета — текст предупреждения или null. Ревизия та же, а
74
+ * функции поставила другая версия движка: после обновления пакета не накатили up() (так было при 1.1 → 1.2).
75
+ */
76
+ export declare function stampDrift(schema: string, comment: string): Promise<string | null>;
60
77
  export declare function substitute(text: string, vars: Record<string, string>): string;
61
78
  export declare function install(sql: postgres.Sql, opts: InstallOptions): Promise<InstallResult>;
62
79
  /** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
package/dist/up.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * выполняет ANALYZE только для таблиц без статистики.
12
12
  */
13
13
  import { readFile, readdir } from 'node:fs/promises';
14
+ import { createRequire } from 'node:module';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import { join, resolve } from 'node:path';
16
17
  import { createHash, randomBytes } from 'node:crypto';
@@ -34,6 +35,65 @@ export async function engineFiles(upTo = 99) {
34
35
  const files = (await readdir(SQL_DIR)).filter((f) => /^\d{2}-[a-z0-9.-]+\.sql$/.test(f));
35
36
  return files.filter((f) => Number(f.slice(0, 2)) <= upTo).sort();
36
37
  }
38
+ let stamp;
39
+ /**
40
+ * Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF): одна и та же
41
+ * сборка даёт один штамп при любых опциях установки, а версия пакета без правок SQL штамп не меняет.
42
+ */
43
+ export async function engineStamp() {
44
+ if (stamp)
45
+ return stamp;
46
+ const h = createHash('sha256');
47
+ for (const f of await engineFiles())
48
+ h.update(`${f}\n${(await readFile(join(SQL_DIR, f), 'utf8')).replace(/\r\n/g, '\n')}\n`);
49
+ const version = createRequire(import.meta.url)('../package.json').version;
50
+ return (stamp = { version, sql: h.digest('hex').slice(0, 16) });
51
+ }
52
+ /** Сравнение версий semver (метаданные сборки не учитываются): меньше нуля, ноль, больше нуля. */
53
+ export function compareVersions(a, b) {
54
+ const parse = (v) => {
55
+ const [core, pre] = v.split('+')[0].split(/-(.*)/s);
56
+ return { core: core.split('.').map(Number), pre: pre ? pre.split('.') : [] };
57
+ };
58
+ const x = parse(a);
59
+ const y = parse(b);
60
+ for (let i = 0; i < 3; i++)
61
+ if ((x.core[i] ?? 0) !== (y.core[i] ?? 0))
62
+ return (x.core[i] ?? 0) - (y.core[i] ?? 0);
63
+ if (!x.pre.length || !y.pre.length)
64
+ return y.pre.length - x.pre.length; // выпуск старше своих предварительных версий
65
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
66
+ const p = x.pre[i];
67
+ const q = y.pre[i];
68
+ if (p === undefined || q === undefined)
69
+ return p === undefined ? -1 : 1;
70
+ if (p === q)
71
+ continue;
72
+ const pn = /^\d+$/.test(p);
73
+ const qn = /^\d+$/.test(q);
74
+ if (pn && qn)
75
+ return Number(p) - Number(q);
76
+ if (pn !== qn)
77
+ return pn ? -1 : 1;
78
+ return p < q ? -1 : 1;
79
+ }
80
+ return 0;
81
+ }
82
+ /**
83
+ * Штамп схемы (комментарий схемы) против файлов ядра пакета — текст предупреждения или null. Ревизия та же, а
84
+ * функции поставила другая версия движка: после обновления пакета не накатили up() (так было при 1.1 → 1.2).
85
+ */
86
+ export async function stampDrift(schema, comment) {
87
+ const code = await engineStamp().catch(() => null); // файлов ядра рядом со сборкой нет — сверять не с чем
88
+ if (!code || /\bsql=([0-9a-f]+)/.exec(comment)?.[1] === code.sql)
89
+ return null;
90
+ const was = /\bengine=(\S+)/.exec(comment)?.[1];
91
+ if (was && compareVersions(was, code.version) > 0) {
92
+ return `letopis: схему ${schema} обновила letopis ${was}, библиотека старее (${code.version}) — обновите пакет: up() этой версией вернул бы прежние функции`;
93
+ }
94
+ const by = !was ? 'версия до 1.4.0' : was === code.version ? `другая сборка letopis ${was}` : `letopis ${was}`;
95
+ return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}: накатите up({ schema: '${schema}' })`;
96
+ }
37
97
  export function substitute(text, vars) {
38
98
  return text.replace(/@([A-Z_]+)@/g, (m, k) => {
39
99
  if (!(k in vars))
@@ -88,6 +148,10 @@ export async function install(sql, opts) {
88
148
  // режим сигналов — после set role: сохранённый режим лежит в строке ключа secret владельца
89
149
  SIGNALS: 'class',
90
150
  };
151
+ // штамп движка — в комментарий схемы (99-revision.sql): по нему connect() узнаёт схему, не накатанную этой версией
152
+ const st = await engineStamp();
153
+ vars.ENGINE_VERSION = st.version;
154
+ vars.ENGINE_SQL = st.sql;
91
155
  // Схема создаётся до set role: у владельца может не быть права CREATE в базе.
92
156
  await sql.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
93
157
  const files = await engineFiles(opts.upTo);
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
4
4
 
5
- Key facts for writing code with letopis 1.3:
5
+ Key facts for writing code with letopis 1.4:
6
6
 
7
7
  - ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`; `@modelcontextprotocol/server`, only by `letopis/mcp`.
8
8
  - Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
5
5
  "keywords": [
6
6
  "postgres",
package/sql/50-acl.sql CHANGED
@@ -228,9 +228,13 @@ begin
228
228
  and jsonb_typeof(x.pattern->'class') <> 'string', false) as bad) m
229
229
  where x.deny or not m.bad
230
230
  ), cl as (
231
- select c.data->>'name' as name, c.data->>'alias' as alias, c.data->'resolved'->'ancestors' as anc
231
+ -- один класс на имя — тот, что строки арендатора находят по имени (class_row): свой, иначе System. Одноимённый
232
+ -- класс System, заведённый после класса арендатора, в план не входит: его предки и алиас давали бы права на строки
233
+ -- класса арендатора (хвост ревью доработок после 1.2.0)
234
+ select distinct on (c.data->>'name') c.data->>'name' as name, c.data->>'alias' as alias, c.data->'resolved'->'ancestors' as anc
232
235
  from @SCHEMA@.entity c
233
236
  where c.class = 'Class' and c.tenant in (p_tenant, '@SYSTEM@'::uuid)
237
+ order by c.data->>'name', c.tenant = '@SYSTEM@'::uuid
234
238
  ), k as (
235
239
  select cl.name,
236
240
  array(select jsonb_array_elements_text(cl.anc))
package/sql/60-write.sql CHANGED
@@ -760,6 +760,14 @@ begin
760
760
  perform @SCHEMA@.lt_fail('invalid_class', format('имя %s занято системным классом', p_data->>'name'));
761
761
  end if;
762
762
  end if;
763
+ -- родитель — под разделяемой блокировкой своего семейства (хвост ревью доработок после 1.2.0): удаление класса берёт
764
+ -- исключительную, поэтому родитель не удалится мимо нового потомка, а удалённый, пока ждали, class_prepare не найдёт
765
+ if p_data->>'parent' is not null then
766
+ tc := @SCHEMA@.class_row(p_data->>'parent', p_tenant);
767
+ if tc.id is not null then
768
+ perform @SCHEMA@.family_lock(tc, false);
769
+ end if;
770
+ end if;
763
771
  select c.data, c.validator into data, validator from @SCHEMA@.class_prepare(p_data, p_tenant) c;
764
772
  if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
765
773
  perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
@@ -798,12 +806,15 @@ begin
798
806
  data->>'name'));
799
807
  end if;
800
808
  -- предок производного класса (ревью безопасности №3): правка предка пересчитывает описание потомка (class_evolve) —
801
- -- поля, ключ, history, — поэтому класс с производными потомками (своего арендатора, у класса System — всех) меняет
802
- -- тоже только администратор
809
+ -- поля, ключ, history, — поэтому класс с производными потомками (своего арендатора, у класса System — всех, кроме
810
+ -- арендаторов со своим одноимённым классом: их потомки наследуют свой класс) меняет тоже только администратор
803
811
  if prevd is not null and not @SCHEMA@.letopis_allowed('letopis.projector') then
804
812
  select x.data->>'name' into dkid from @SCHEMA@.entity x
805
813
  where x.class = 'Class' and x.data->>'name' <> data->>'name'
806
- and (x.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
814
+ and (x.tenant = p_tenant
815
+ or p_tenant = '@SYSTEM@'::uuid
816
+ and x.tenant <> all (array(select y.tenant from @SCHEMA@.entity y
817
+ where y.class = 'Class' and y.data->>'name' = data->>'name' and y.tenant <> '@SYSTEM@'::uuid)))
807
818
  and x.data->'resolved'->'ancestors' ? (data->>'name') and x.data->'resolved' ? 'derivedBy'
808
819
  order by x.data->>'name' limit 1;
809
820
  if dkid is not null then
@@ -962,9 +973,12 @@ begin
962
973
  if cls.id is null then
963
974
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
964
975
  end if;
965
- -- разделяемая блокировка корня семейства, затем описание перечитывается под ней
976
+ -- разделяемая блокировка корня семейства, затем описание перечитывается под ней (класс могли удалить, пока ждали)
966
977
  perform @SCHEMA@.family_lock(cls, false);
967
978
  cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
979
+ if cls.id is null then
980
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', NEW.class));
981
+ end if;
968
982
  res := cls.data->'resolved';
969
983
  if coalesce((res->>'abstract')::boolean, false) then
970
984
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
@@ -1091,6 +1105,9 @@ begin
1091
1105
  end if;
1092
1106
  perform @SCHEMA@.family_lock(cls, false);
1093
1107
  cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
1108
+ if cls.id is null then
1109
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', NEW.class));
1110
+ end if;
1094
1111
  res := cls.data->'resolved';
1095
1112
  if coalesce((res->>'abstract')::boolean, false) then
1096
1113
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
@@ -1212,6 +1229,10 @@ begin
1212
1229
  -- прав после acl_bump видит новый класс
1213
1230
  perform @SCHEMA@.agg_source_admit(n.data->>'name', @SCHEMA@.agg_new_mark(null, n.data->'resolved'))
1214
1231
  from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
1232
+ -- и описавшему агрегат (хвост ревью доработок после 1.2.0): заведя потомка источника, читающий всё пользователь
1233
+ -- иначе открыл бы его строки тому, кто описал агрегат без права на них
1234
+ perform @SCHEMA@.agg_author_admit(n.data->>'name', n.tenant, null, n.data->'resolved')
1235
+ from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
1215
1236
  -- новая цель агрегатов (план 1.1, §2.4): метки её источников (aggregatedBy) — заново
1216
1237
  perform @SCHEMA@.agg_mark(n.tenant, @SCHEMA@.agg_of(n.data->'resolved'))
1217
1238
  from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregate';
@@ -1269,6 +1290,7 @@ begin
1269
1290
  -- класс становится источником агрегата (смена parent; ревью коммита этапа 4б): право READ без условий на его строки
1270
1291
  -- и после правки — по новым предкам (до правки проверил class_admit)
1271
1292
  perform @SCHEMA@.agg_source_admit(n_row.data->>'name', @SCHEMA@.agg_new_mark(o_row.data->'resolved', n_row.data->'resolved'));
1293
+ perform @SCHEMA@.agg_author_admit(n_row.data->>'name', n_row.tenant, o_row.data->'resolved', n_row.data->'resolved');
1272
1294
  perform @SCHEMA@.class_evolve(o_row, n_row);
1273
1295
  end loop;
1274
1296
  -- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
@@ -1316,6 +1338,8 @@ begin
1316
1338
  if d.tenant = n.tenant then
1317
1339
  perform @SCHEMA@.agg_source_admit(d.data->>'name', @SCHEMA@.agg_new_mark(d.data->'resolved', prep.data->'resolved'));
1318
1340
  end if;
1341
+ -- и описавшему агрегат — в объёме описания (agg_author_admit сам пропускает класс арендатора под агрегатом System)
1342
+ perform @SCHEMA@.agg_author_admit(d.data->>'name', d.tenant, d.data->'resolved', prep.data->'resolved');
1319
1343
  classes := classes || (d.data->>'name');
1320
1344
  if d.data->'resolved'->'aggregate' is distinct from prep.data->'resolved'->'aggregate' then
1321
1345
  agg_names := agg_names || @SCHEMA@.agg_of(d.data->'resolved') || @SCHEMA@.agg_of(prep.data->'resolved');
@@ -1438,6 +1462,7 @@ declare
1438
1462
  lvl_mems jsonb;
1439
1463
  drows jsonb;
1440
1464
  own uuid[];
1465
+ cl @SCHEMA@.entity;
1441
1466
  begin
1442
1467
  select * into c from @SCHEMA@.ctx();
1443
1468
  -- права в замыкании (этап 5, п. 6): DELETE на каждую удаляемую каскадом строку, WRITE — на строку,
@@ -1453,6 +1478,13 @@ begin
1453
1478
  if denied is not null then
1454
1479
  perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки удаляет проектор', denied));
1455
1480
  end if;
1481
+ -- удаление класса (хвост ревью безопасности доработок после 1.2.0): проверки ниже — нет строк, потомков и истории —
1482
+ -- не видят строку, которую писатель ещё не зафиксировал, и она осталась бы без класса, а с ней история удалённого
1483
+ -- класса, от которой зависит смена алиаса предка. Писатели строк и создание потомка держат разделяемую блокировку
1484
+ -- семейства: исключительная ждёт их, и следующие операторы проверок видят зафиксированное
1485
+ for cl in select g.* from gone g where g.class = 'Class' order by @SCHEMA@.family_name(g.tenant, g.data), g.id loop
1486
+ perform @SCHEMA@.family_lock(cl, true);
1487
+ end loop;
1456
1488
  for r in select g.id, g.tenant, g.data->>'name' as name, g.data->'resolved' ? 'derivedBy' as derived from gone g where g.class = 'Class' loop
1457
1489
  -- производный класс удаляет только администратор (class_admit)
1458
1490
  if r.derived and not @SCHEMA@.letopis_allowed('letopis.projector') then
@@ -1808,6 +1840,9 @@ begin
1808
1840
  end if;
1809
1841
  perform @SCHEMA@.family_lock(cls, false);
1810
1842
  cls := @SCHEMA@.class_row(p_class, o.tenant);
1843
+ if cls.id is null then
1844
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', p_class));
1845
+ end if;
1811
1846
  res := cls.data->'resolved';
1812
1847
  if coalesce((res->>'abstract')::boolean, false) then
1813
1848
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
@@ -143,6 +143,9 @@ begin
143
143
  -- как у писателей приложения: разделяемая блокировка семейства, описание — свежее под ней
144
144
  perform @SCHEMA@.family_lock(cls, false);
145
145
  cls := @SCHEMA@.class_row(cname, tid);
146
+ if cls.id is null then
147
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', cname) || w);
148
+ end if;
146
149
  res := cls.data->'resolved';
147
150
  if res->>'derivedBy' is distinct from p_name then
148
151
  perform @SCHEMA@.lt_fail('derived_class', format('класс %s не производный проектора %s', cname, p_name) || w);
@@ -581,6 +584,82 @@ begin
581
584
  end if;
582
585
  end $$;
583
586
 
587
+ -- Кто описал агрегат p_field цели p_target (строка Class): автор версии строки Class, с которой развёрнутое описание
588
+ -- агрегата (своё или унаследованное) стало нынешним; надгробие обрывает описание — пересоздавший класс описал агрегат
589
+ -- заново. Журнал строк Class не прореживается и не стирается (purge и обрезка их не берут). Сид и пути владельца без
590
+ -- сессии пишут автора System.
591
+ create or replace function @SCHEMA@.agg_author(p_target @SCHEMA@.entity, p_field text) returns uuid
592
+ language sql stable
593
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
594
+ return (select l.author from @SCHEMA@.log l
595
+ where l.id = p_target.id and l.op not in ('purge', 'trim', 'reset', 'delete')
596
+ and l.data->'resolved'->'aggregate'->p_field is not distinct from p_target.data->'resolved'->'aggregate'->p_field
597
+ and (select case when p.op = 'delete' then null else p.data->'resolved'->'aggregate'->p_field end
598
+ from @SCHEMA@.log p
599
+ where p.id = l.id and p.rev < l.rev and p.op not in ('purge', 'trim', 'reset')
600
+ order by p.rev desc limit 1) is distinct from p_target.data->'resolved'->'aggregate'->p_field
601
+ order by l.rev desc limit 1);
602
+
603
+ -- Аккаунт p_account читает без условий все строки класса p_class арендатора p_tenant: план READ — как у acl_subject
604
+ -- (аккаунт включён и лежит в System, арендатор — сам аккаунт или по членству), но без отказа: нет аккаунта или
605
+ -- членства — права нет.
606
+ create or replace function @SCHEMA@.agg_author_reads(p_account uuid, p_tenant uuid, p_class text) returns boolean
607
+ language plpgsql stable
608
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
609
+ as $$
610
+ declare
611
+ a jsonb;
612
+ m jsonb;
613
+ begin
614
+ select e.data into a from @SCHEMA@.entity e
615
+ where e.id = p_account and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid and coalesce((e.data->>'enabled')::boolean, true);
616
+ if a is null then
617
+ return false;
618
+ end if;
619
+ if p_tenant <> p_account then
620
+ select x.data into m from @SCHEMA@.entity x
621
+ where x.id = @SCHEMA@.v5_id(p_tenant, 'member', array[p_account::text, p_tenant::text]) and x.class = 'member' and x.tenant = p_tenant;
622
+ if m is null then
623
+ return false;
624
+ end if;
625
+ end if;
626
+ return coalesce(@SCHEMA@.acl_plan_for(p_account, p_tenant,
627
+ array(select jsonb_array_elements_text(coalesce(a->'categories', '[]'::jsonb))),
628
+ case when p_tenant = p_account then array['owner']
629
+ else array(select jsonb_array_elements_text(coalesce(m->'roles', '[]'::jsonb))) end,
630
+ 'READ') -> p_class = '{}'::jsonb, false);
631
+ end $$;
632
+
633
+ -- Класс p_class арендатора p_tenant становится источником агрегатов — новые метки aggregatedBy описания p_new против
634
+ -- p_old (хвост ревью безопасности доработок после 1.2.0, решение владельца): его строки войдут в агрегат, и описавший
635
+ -- агрегат читал бы в истории цели вклад каждой из них, — поэтому право READ без условий на все строки класса нужно и
636
+ -- описавшему, иначе acl_denied. Объём — тот же, что при описании (class_admit, agg_classes): у агрегата класса
637
+ -- арендатора — классы арендатора, у агрегата класса System — классы System. Класс арендатора под агрегатом System
638
+ -- описавший не проверял и при описании: его, как и прежде, проверяет только тот, кто заводит (agg_source_admit).
639
+ -- Описавший System (сид, путь владельца без сессии) не проверяется.
640
+ create or replace function @SCHEMA@.agg_author_admit(p_class text, p_tenant uuid, p_old jsonb, p_new jsonb) returns void
641
+ language plpgsql stable
642
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
643
+ as $$
644
+ declare
645
+ mk jsonb;
646
+ tc @SCHEMA@.entity;
647
+ who uuid;
648
+ begin
649
+ for mk in select x from jsonb_array_elements(coalesce(p_new->'aggregatedBy', '[]'::jsonb)) x
650
+ where not coalesce(p_old->'aggregatedBy', '[]'::jsonb) @> jsonb_build_array(x)
651
+ order by x->>'class', x->>'field' loop
652
+ tc := @SCHEMA@.class_row(mk->>'class', p_tenant);
653
+ continue when tc.id is null or (tc.tenant = '@SYSTEM@'::uuid and p_tenant <> '@SYSTEM@'::uuid);
654
+ who := @SCHEMA@.agg_author(tc, mk->>'field');
655
+ continue when who is null or who = '@SYSTEM@'::uuid;
656
+ if not @SCHEMA@.agg_author_reads(who, tc.tenant, p_class) then
657
+ perform @SCHEMA@.lt_fail('acl_denied', format('класс %s становится источником агрегата %s.%s, а описавший агрегат не читает все его строки (право READ без условий): агрегат описывает заново тот, кто их читает',
658
+ p_class, mk->>'class', mk->>'field'));
659
+ end if;
660
+ end loop;
661
+ end $$;
662
+
584
663
  -- Метки источников заново: классы арендатора p_tenant (System — всех), в предках которых есть одно из имён p_names;
585
664
  -- изменившееся описание — новой версией строки Class (журнал, сигнал).
586
665
  create or replace function @SCHEMA@.agg_mark(p_tenant uuid, p_names text[]) returns void
@@ -1142,7 +1221,8 @@ begin
1142
1221
  foreach f in array array['agg_fields(jsonb)', 'agg_of(jsonb)', 'agg_zero(jsonb, jsonb)', 'agg_num(jsonb)', 'agg_where_ok(jsonb, jsonb)',
1143
1222
  'agg_guard(jsonb, jsonb, jsonb, boolean)', 'agg_keep(text, uuid, jsonb, jsonb)', 'agg_check_source(jsonb, jsonb, text, jsonb)',
1144
1223
  'agg_cycle(uuid, jsonb)', 'agg_prepare(jsonb, jsonb, uuid)', 'agg_source(jsonb, uuid)', 'agg_marked(jsonb, jsonb)',
1145
- 'agg_new_mark(jsonb, jsonb)', 'agg_source_admit(text, jsonb)', 'agg_mark(uuid, text[])', 'agg_lock_sources(jsonb, jsonb, uuid)',
1224
+ 'agg_new_mark(jsonb, jsonb)', 'agg_source_admit(text, jsonb)', 'agg_author(@SCHEMA@.entity, text)',
1225
+ 'agg_author_reads(uuid, uuid, text)', 'agg_author_admit(text, uuid, jsonb, jsonb)', 'agg_mark(uuid, text[])', 'agg_lock_sources(jsonb, jsonb, uuid)',
1146
1226
  'agg_classes(text, uuid, boolean)', 'agg_compute(jsonb, uuid, uuid)',
1147
1227
  'agg_check(text, uuid, jsonb)', 'agg_write(@SCHEMA@.entity, jsonb, boolean)', 'agg_token(text)', 'agg_held()',
1148
1228
  'agg_hold_begin(text)', 'agg_hold_end(boolean)', 'agg_merge(jsonb, jsonb)', 'agg_flush(jsonb)', 'agg_kind(text, uuid)',
package/sql/90-time.sql CHANGED
@@ -80,13 +80,22 @@ begin
80
80
  return false;
81
81
  end $$;
82
82
 
83
+ -- Действующая роль сеанса, под которой вызывающий работает вне функций владельца: роль SET ROLE (и параметра запуска
84
+ -- role), иначе роль входа. Внутри функции владельца current_user — владелец, а session_user — роль входа, даже если
85
+ -- поверх неё поставлена роль приложения: привилегированный вход с ролью приложения выглядел бы администратором
86
+ -- (автоматическое ревью коммита 1734d21). Подменить её нельзя: SET ROLE проверяет членство.
87
+ create or replace function @SCHEMA@.session_role() returns name
88
+ language sql stable
89
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
90
+ return coalesce(nullif(current_setting('role'), 'none'), session_user)::name;
91
+
83
92
  -- Роль подключения — администраторская (суперпользователь или может стать владельцем): verify,
84
93
  -- reset и maintain доступны ей и без сессии (CLI, обслуживание по расписанию).
85
94
  create or replace function @SCHEMA@.admin_role() returns boolean
86
95
  language sql stable
87
96
  set search_path = pg_catalog, @SCHEMA@, pg_temp
88
- return exists (select 1 from pg_roles where rolname = session_user and rolsuper)
89
- or pg_has_role(session_user, '@OWNER_NAME@', 'SET');
97
+ return exists (select 1 from pg_roles where rolname = @SCHEMA@.session_role() and rolsuper)
98
+ or pg_has_role(@SCHEMA@.session_role(), '@OWNER_NAME@', 'SET');
90
99
 
91
100
  -- Право letopis.* актора сессии по правилам System или администраторская роль подключения.
92
101
  create or replace function @SCHEMA@.letopis_allowed(p_endpoint text) returns boolean
@@ -209,6 +218,9 @@ begin
209
218
  end if;
210
219
  perform @SCHEMA@.family_lock(cls, false);
211
220
  cls := @SCHEMA@.class_row(l.class, l.tenant);
221
+ if cls.id is null then
222
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', l.class));
223
+ end if;
212
224
  res := cls.data->'resolved';
213
225
  if coalesce((res->>'abstract')::boolean, false) then
214
226
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', l.class));
@@ -256,12 +268,16 @@ end $$;
256
268
  -- все версии удаляются, а на место версии 1 встаёт запись purge с числом стёртых версий и хэшем
257
269
  -- последней. Право DELETE на класс по последнему снимку. История системных классов — аудит прав и
258
270
  -- схемы: её не стирают.
259
- -- Проектор источника ещё не прочёл версию (p_tx, p_seq) строки арендатора p_tenant класса из p_classes (ревью
260
- -- безопасности №3, №5 — purge; хвост ревью №3 — обрезка истории). Проектор касается арендатора, у которого есть его
261
- -- производный класс (derivedBy — имя проектора): брошенный проектор с удалёнными производными классами не держит.
262
- -- Курсор «tx:seq~снимок»: транзакции, видимые в снимке, учла перестройка. Чекпоинт без from (до 1.2.0) касается всех
263
- -- классов.
264
- create or replace function @SCHEMA@.projector_behind(p_tenant uuid, p_classes text[], p_tx xid8, p_seq bigint) returns boolean
271
+ -- Проектор источника ещё не прочёл одну из версий p_id арендатора p_tenant с номером до p_rev включительно (null — все
272
+ -- версии): курсор не прошёл её позицию (tx, seq), и снимок перестройки её не учёл (ревью безопасности №3, №5 — purge;
273
+ -- хвост ревью №3 — обрезка истории). Проверяется каждая версия (хвост ревью доработок после 1.2.0): у более поздней
274
+ -- версии бывает меньший xid, а снимок перестройки учитывает транзакции, зафиксированные до него, — непрочитанной может
275
+ -- оказаться и версия между самой поздней по (tx, seq) и последней. Проектор касается арендатора, у которого есть его
276
+ -- производный класс (derivedBy — имя проектора): брошенный проектор с удалёнными производными классами не держит, — и
277
+ -- строки, у которой хоть одна из этих версий — в классах его источника: уход из семейства источников сменой класса он
278
+ -- тоже читает. Курсор «tx:seq~снимок»; чекпоинт без from (до 1.2.0) касается всех классов. Записи purge, trim и reset —
279
+ -- не версии.
280
+ create or replace function @SCHEMA@.projector_unread(p_tenant uuid, p_id uuid, p_rev int) returns boolean
265
281
  language sql stable
266
282
  set search_path = pg_catalog, @SCHEMA@, pg_temp
267
283
  return exists (select 1 from @SCHEMA@.secret s
@@ -272,11 +288,18 @@ return exists (select 1 from @SCHEMA@.secret s
272
288
  and exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.tenant = p_tenant
273
289
  and x.data->'resolved'->>'derivedBy' = s.ident)
274
290
  and (not s.meta ? 'from'
275
- or exists (select 1 from unnest(p_classes) c
276
- where coalesce((@SCHEMA@.class_row(c, p_tenant)).data->'resolved'->'ancestors', jsonb_build_array(c))
291
+ or exists (select 1 from (select distinct v.class from @SCHEMA@.log v
292
+ where v.id = p_id and (p_rev is null or v.rev <= p_rev)
293
+ and v.op not in ('purge', 'trim', 'reset')) c
294
+ where coalesce((@SCHEMA@.class_row(c.class, p_tenant)).data->'resolved'->'ancestors', jsonb_build_array(c.class))
277
295
  ?| array(select jsonb_array_elements_text(s.meta->'from'))))
278
- and (p_tx, p_seq) > (split_part(k.head, ':', 1)::xid8, split_part(k.head, ':', 2)::bigint)
279
- and not coalesce(pg_visible_in_snapshot(p_tx, k.snap::pg_snapshot), false));
296
+ and exists (select 1 from @SCHEMA@.log v
297
+ where v.id = p_id and (p_rev is null or v.rev <= p_rev) and v.op not in ('purge', 'trim', 'reset')
298
+ and (v.tx, v.seq) > (split_part(k.head, ':', 1)::xid8, split_part(k.head, ':', 2)::bigint)
299
+ and not coalesce(pg_visible_in_snapshot(v.tx, k.snap::pg_snapshot), false)));
300
+
301
+ -- до 1.4 проверялась одна позиция: надгробие у purge, самая поздняя по (tx, seq) версия у стыка обрезки
302
+ drop function if exists @SCHEMA@.projector_behind(uuid, text[], xid8, bigint);
280
303
 
281
304
  create or replace function @SCHEMA@.purge(p_id uuid, p_confirm boolean default false) returns jsonb
282
305
  language plpgsql volatile security definer
@@ -301,9 +324,9 @@ begin
301
324
  if not @SCHEMA@.acl_row_ok(@SCHEMA@.acl_plan('DELETE'), l.class, l.id, l.owner, l.tenant, l.tags, l.data, l.links) then
302
325
  perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s', l.class));
303
326
  end if;
304
- -- проектор источника, чей курсор ещё не прошёл надгробие (в том числе надгробие этой транзакции), об удалении не
305
- -- узнал бы — вклад строки остался бы в итогах навсегда (ревью безопасности №3, №5)
306
- if @SCHEMA@.projector_behind(l.tenant, array[l.class], l.tx, l.seq) then
327
+ -- проектор источника, который ещё не прочёл какую-то версию строки (в том числе надгробие этой транзакции), об
328
+ -- удалении не узнал бы — вклад строки остался бы в итогах навсегда (ревью безопасности №3, №5)
329
+ if @SCHEMA@.projector_unread(l.tenant, p_id, null) then
307
330
  perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл удаление: purge — после его run()');
308
331
  end if;
309
332
  select count(*)::int into n from @SCHEMA@.log x where x.id = p_id;
@@ -340,7 +363,6 @@ declare
340
363
  r @SCHEMA@.log;
341
364
  first @SCHEMA@.log;
342
365
  nxt record;
343
- late record;
344
366
  j int;
345
367
  h bytea;
346
368
  k int;
@@ -385,15 +407,11 @@ begin
385
407
  select x.* into first from @SCHEMA@.log x where x.id = r.id order by x.rev limit 1;
386
408
  -- до момента — только сама запись trim: стирать нечего
387
409
  continue when j = 1 and first.op = 'trim';
388
- -- проектор источника, чей курсор ещё не прошёл первую оставшуюся версию: читая её, он возьмёт прошлую версию для
389
- -- before, а её уже не будет — свёртка прибавила бы новую, не вычитая прежнюю (хвост ревью безопасности №3). Класс —
390
- -- обеих версий у стыка: строка могла уйти из семейства источников сменой класса
391
- select x.rev, x.class into nxt from @SCHEMA@.log x where x.id = r.id and x.rev > j order by x.rev limit 1;
392
- -- позиция — самая поздняя по (tx, seq) среди стираемых версий и первой оставшейся: у более поздней версии бывает
393
- -- меньший xid (автоматическое ревью)
394
- select x.tx, x.seq into late from @SCHEMA@.log x where x.id = r.id and x.rev <= nxt.rev order by x.tx desc, x.seq desc limit 1;
395
- if @SCHEMA@.projector_behind(r.tenant, array[(select x.class from @SCHEMA@.log x where x.id = r.id and x.rev = j), nxt.class],
396
- late.tx, late.seq) then
410
+ -- проектор источника, который ещё не прочёл стираемые версии или первую оставшуюся: читая её, он возьмёт прошлую
411
+ -- версию для before, а её уже не будет — свёртка прибавила бы новую, не вычитая прежнюю (хвост ревью безопасности
412
+ -- №3); каждая версия до первой оставшейся — у более поздней версии бывает меньший xid
413
+ select x.rev into nxt from @SCHEMA@.log x where x.id = r.id and x.rev > j order by x.rev limit 1;
414
+ if @SCHEMA@.projector_unread(r.tenant, r.id, nxt.rev) then
397
415
  perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл версии: trimHistory — после его run()');
398
416
  end if;
399
417
  select x.hash into h from @SCHEMA@.log x where x.id = r.id and x.rev = j;
@@ -456,6 +474,9 @@ begin
456
474
  cls := @SCHEMA@.class_row(o.class, o.tenant);
457
475
  perform @SCHEMA@.family_lock(cls, false);
458
476
  cls := @SCHEMA@.class_row(o.class, o.tenant);
477
+ if cls.id is null then
478
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', o.class));
479
+ end if;
459
480
  res := cls.data->'resolved';
460
481
  if res ? 'derivedBy' then
461
482
  perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', o.class, res->>'derivedBy'));
@@ -1061,7 +1082,10 @@ end $$;
1061
1082
  -- окна p_upto — последняя строка полной пачки watch_read), а строки — синтетические надгробия прежней версии: класс,
1062
1083
  -- концы и данные прежние; номер, время, автор, tx и seq — версии смены класса; вид delete; агента и причины нет. Только
1063
1084
  -- если нового класса нет в p_classes, а прежнюю версию актор видит (row_visible с его планом READ): о строке, которой он
1064
- -- не читал, подписчик не узнает. Функция владельца: версию с новым классом актору видеть не нужно.
1085
+ -- не читал, подписчик не узнает. Функция владельца: версию с новым классом актору видеть не нужно. Подключение в обход
1086
+ -- RLS (суперпользователь, BYPASSRLS, член роли-владельца с наследованием — connect() пускает его только с
1087
+ -- allowBypassRls) видит в watch_read журнал всех арендаторов, поэтому и события ухода получает все, с автором (хвост
1088
+ -- ревью доработок после 1.2.0). Роль — действующая роль сеанса (session_role): та, к которой watch_read применяет RLS.
1065
1089
  create or replace function @SCHEMA@.watch_leaving(p_tx xid8, p_seq bigint, p_classes text[], p_limit int,
1066
1090
  p_snap pg_snapshot default null, p_upto_tx xid8 default null, p_upto_seq bigint default null)
1067
1091
  returns setof @SCHEMA@.log
@@ -1071,12 +1095,15 @@ as $$
1071
1095
  declare
1072
1096
  horizon xid8 := pg_snapshot_xmin(pg_current_snapshot());
1073
1097
  a jsonb;
1098
+ bypass boolean;
1074
1099
  begin
1075
1100
  if p_classes is null then
1076
1101
  return;
1077
1102
  end if;
1078
- a := @SCHEMA@.acl_actor('READ');
1079
- if a is null then
1103
+ bypass := exists (select 1 from pg_roles r where r.rolname = @SCHEMA@.session_role() and (r.rolsuper or r.rolbypassrls))
1104
+ or pg_has_role(@SCHEMA@.session_role(), '@OWNER_NAME@', 'USAGE');
1105
+ a := case when not bypass then @SCHEMA@.acl_actor('READ') end;
1106
+ if a is null and not bypass then
1080
1107
  return;
1081
1108
  end if;
1082
1109
  -- новую версию актор не видит — надгробие при любом p_classes и без автора: перебором классов подписки он узнал бы её
@@ -1086,15 +1113,15 @@ begin
1086
1113
  from @SCHEMA@.log l
1087
1114
  cross join lateral (select x.* from @SCHEMA@.log x where x.id = l.id and x.rev < l.rev and x.op not in ('purge', 'trim', 'reset')
1088
1115
  order by x.rev desc limit 1) p
1089
- cross join lateral (select @SCHEMA@.row_visible(l.tenant, l.class, l.id, l.owner, l.tags, l.data, l.links,
1116
+ cross join lateral (select bypass or @SCHEMA@.row_visible(l.tenant, l.class, l.id, l.owner, l.tags, l.data, l.links,
1090
1117
  (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan') as seen) v
1091
1118
  where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon
1092
1119
  and (p_upto_tx is null or (l.tx, l.seq) <= (p_upto_tx, p_upto_seq))
1093
1120
  and l.op = 'reclass' and l.moved is null and (not (l.class = any (p_classes)) or not v.seen)
1094
1121
  and p.class = any (p_classes) and p.op <> 'delete'
1095
1122
  and (p_snap is null or not pg_visible_in_snapshot(l.tx, p_snap))
1096
- and @SCHEMA@.row_visible(p.tenant, p.class, p.id, p.owner, p.tags, p.data, p.links,
1097
- (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan')
1123
+ and (bypass or @SCHEMA@.row_visible(p.tenant, p.class, p.id, p.owner, p.tags, p.data, p.links,
1124
+ (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan'))
1098
1125
  order by l.tx, l.seq limit p_limit;
1099
1126
  end $$;
1100
1127
 
@@ -1116,8 +1143,8 @@ declare f text;
1116
1143
  begin
1117
1144
  foreach f in array array[
1118
1145
  'history_default()', 'history_policy(jsonb)', 'history_iv(text)', 'history_horizon(jsonb)',
1119
- 'history_keep(jsonb, timestamp, timestamp, timestamp)', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
1120
- 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'projector_behind(uuid, text[], xid8, bigint)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
1146
+ 'history_keep(jsonb, timestamp, timestamp, timestamp)', 'session_role()', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
1147
+ 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'projector_unread(uuid, uuid, int)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
1121
1148
  'rekey(uuid, jsonb, jsonb)', 'rekey_class(text, jsonb)', 'account_purge(uuid)', 'reset_mark(uuid, text, uuid)',
1122
1149
  'reset(text, text, uuid, int)', 'thin_batch(timestamptz, int)', 'maintain(timestamptz, int)', 'verify(boolean)',
1123
1150
  'watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean)', 'watch_leaving(xid8, bigint, text[], int, pg_snapshot, xid8, bigint)', 'watch_head()'] loop
@@ -1,3 +1,4 @@
1
1
  -- letopis 1.0 — штамп ревизии движка (план, §2.2, этап 1, п. 11): последний оператор наката.
2
- -- Номер совпадает с DDL_REVISION в lib/src/types.ts (проверяет check-docs).
3
- comment on schema @SCHEMA@ is 'letopis ddl_revision=6';
2
+ -- Номер совпадает с DDL_REVISION в lib/src/types.ts (проверяет check-docs). С 1.4 в штампе ещё версия пакета и хэш
3
+ -- файлов ядра (engine, sql): connect() предупреждает, если функции схемы ставила другая версия движка той же ревизии.
4
+ comment on schema @SCHEMA@ is 'letopis ddl_revision=6 engine=@ENGINE_VERSION@ sql=@ENGINE_SQL@';
package/sql/README.md CHANGED
@@ -17,6 +17,8 @@
17
17
  | `@HISTORY_DEFAULT@` | политика хранения по умолчанию `up({ history })` литералом JSON или `'null'` — константа установки |
18
18
  | `@RESERVED_NAMES@` | методы цепочки — `RESERVED_CLASS_NAMES` из `lib/src/types.ts`, литералами через запятую |
19
19
  | `@SIGNALS@` | режим сигналов `class`, `tenant`, `opaque` или `none` (план 1.1, §2.2): опция `up({ signals })`, иначе сохранённый в `meta.signals` строки ключа `secret`, иначе `class` — константа установки |
20
+ | `@ENGINE_VERSION@` | версия пакета (`lib/package.json`) — штамп движка в комментарии схемы (`99-revision.sql`) |
21
+ | `@ENGINE_SQL@` | 16 знаков sha256 исходного текста файлов `NN-*.sql` до подстановок (переводы строк — LF): `connect()` сверяет его с файлами своего пакета |
20
22
 
21
23
  ## Колонки снимка версии
22
24