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.
- package/AGENT-CHEATSHEET.en.md +4 -1
- package/AGENT-CHEATSHEET.md +4 -1
- package/CHANGELOG.md +88 -0
- package/README.en.md +28 -7
- package/README.md +24 -6
- package/dist/index.js +7 -0
- package/dist/project.d.ts +14 -1
- package/dist/project.js +61 -5
- package/dist/sync.js +11 -5
- package/dist/up.d.ts +17 -0
- package/dist/up.js +64 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/sql/50-acl.sql +5 -1
- package/sql/60-write.sql +39 -4
- package/sql/88-derived.sql +81 -1
- package/sql/90-time.sql +60 -33
- package/sql/99-revision.sql +3 -2
- package/sql/README.md +2 -0
package/AGENT-CHEATSHEET.en.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# letopis 1.
|
|
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
|
package/AGENT-CHEATSHEET.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# letopis 1.
|
|
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.
|
|
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
|
|
571
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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) => {
|
|
342
|
-
|
|
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
|
-
|
|
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
|
|
18
|
+
const path = [];
|
|
18
19
|
const visit = (m) => {
|
|
19
|
-
if (ordered.includes(m)
|
|
20
|
+
if (ordered.includes(m))
|
|
20
21
|
return;
|
|
21
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
"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
|
-
|
|
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
|
|
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));
|
package/sql/88-derived.sql
CHANGED
|
@@ -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)', '
|
|
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 =
|
|
89
|
-
or pg_has_role(
|
|
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
|
-
-- Проектор источника ещё не прочёл
|
|
260
|
-
--
|
|
261
|
-
--
|
|
262
|
-
--
|
|
263
|
-
--
|
|
264
|
-
|
|
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
|
|
276
|
-
|
|
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
|
|
279
|
-
|
|
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@.
|
|
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, а её уже не будет — свёртка прибавила бы новую, не вычитая прежнюю (хвост ревью безопасности
|
|
390
|
-
--
|
|
391
|
-
select x.rev
|
|
392
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
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)', '
|
|
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
|
package/sql/99-revision.sql
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
-- letopis 1.0 — штамп ревизии движка (план, §2.2, этап 1, п. 11): последний оператор наката.
|
|
2
|
-
-- Номер совпадает с DDL_REVISION в lib/src/types.ts (проверяет check-docs).
|
|
3
|
-
|
|
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
|
|