letopis 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.en.md +9 -7
- package/AGENT-CHEATSHEET.md +9 -7
- package/CHANGELOG.md +125 -0
- package/README.en.md +48 -20
- package/README.md +40 -17
- package/dist/index.js +20 -7
- package/dist/project.d.ts +11 -2
- package/dist/project.js +63 -18
- package/dist/sync.js +11 -2
- package/dist/watch.js +28 -3
- package/llms.txt +1 -1
- package/package.json +2 -1
- package/sql/10-core.sql +3 -1
- package/sql/60-write.sql +130 -24
- package/sql/88-derived.sql +2 -2
- package/sql/90-time.sql +82 -18
- package/sql/README.md +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# letopis 1.
|
|
1
|
+
# letopis 1.3
|
|
2
2
|
|
|
3
3
|
**letopis** — библиотека для Node.js, хранилище версионированных сущностей на PostgreSQL 18. Каждое изменение
|
|
4
4
|
сохраняется версией в журнале. Поэтому у любого объекта есть история, состояние на любую дату и подписка на
|
|
@@ -368,6 +368,11 @@ const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token
|
|
|
368
368
|
записи: `create index concurrently log_class_tenant_at on "v2.<схема>".log (class, tenant, at)` от роли владельца, —
|
|
369
369
|
тогда `up()` только снимет прежний.
|
|
370
370
|
|
|
371
|
+
Обновление схем 1.1.0 и 1.0.1 проверяет тест `upgrade`. Схему ставит прежний пакет, данные пишет прежний движок, затем
|
|
372
|
+
вызывается `up()` текущей версии. После него версии и хэши прежнего движка на месте, `verify({ data: true })` чист, а
|
|
373
|
+
каталог схемы совпадает с чистой установкой. Схема 1.1.0 обновляется простым `up()`: ревизия у неё та же. Схема 1.0.1
|
|
374
|
+
обновляется через `up({ upgrade: true })`.
|
|
375
|
+
|
|
371
376
|
### Разработка без PostgreSQL: PGlite
|
|
372
377
|
|
|
373
378
|
`up({ pglite: true })` поднимает PGlite (PostgreSQL 18 на WebAssembly) с pgcrypto и сокетом на свободном порту.
|
|
@@ -544,10 +549,12 @@ const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet
|
|
|
544
549
|
достаточно права на запись в источник.
|
|
545
550
|
- Описание проверяет база (`invalid_class`): поле есть в схеме, оно числовое и не входит в ключ; конец `via` —
|
|
546
551
|
одиночный и принимает класс цели; поле `sum` — число; агрегаты не образуют цикл; цель и источник — не системные и
|
|
547
|
-
не производные классы.
|
|
552
|
+
не производные классы. Класс-источник описывают раньше агрегата: агрегат над классом, которого ещё нет, отклоняется.
|
|
553
|
+
У класса арендатора источник ищут среди классов арендатора. `syncModels` сам описывает источники раньше целей.
|
|
554
|
+
Класс `of` не удаляется, пока его называет агрегат класса того же арендатора: сначала агрегат убирают из описания цели.
|
|
548
555
|
- Строки попадают в агрегат только с ведома того, кто читает их все: поле считает и строки, которые автору не видны.
|
|
549
556
|
Описать или изменить агрегат может только тот, кто без условий читает все строки источника и его потомков. Сделать
|
|
550
|
-
класс источником (новый
|
|
557
|
+
класс источником (новый потомок источника, смена `parent`) может только тот, кто без условий читает все
|
|
551
558
|
строки этого класса и его потомков — и до правки, и после. Иначе `acl_denied`.
|
|
552
559
|
- Опция наследуется, потомок заменяет её целиком. Добавить или изменить агрегат — ужесточение: запись в цель и в
|
|
553
560
|
источники ждёт, пока поле пересчитывается у всех строк.
|
|
@@ -955,7 +962,8 @@ await db.Customer(temp).purge({ confirm: true }).rows() // история
|
|
|
955
962
|
версии 1 остаётся запись `purge` с числом стёртых версий. История системных классов не стирается. Пока проектор,
|
|
956
963
|
чьи источники `from` включают класс объекта ([«Проекторы»](#проекторы)), не прочёл удаление, `purge` отказывает
|
|
957
964
|
(`projector_pending`): стёртое надгробие проектор не увидел бы, и вклад объекта остался бы в итогах. Удаление и
|
|
958
|
-
`purge` одной транзакцией в таком классе поэтому не проходят.
|
|
965
|
+
`purge` одной транзакцией в таком классе поэтому не проходят. Так же `db.trimHistory` отказывает, пока проектор не
|
|
966
|
+
прочёл первую оставшуюся версию: прошлую версию для `before` он бы уже не нашёл. Удалить аккаунт — `db.auth.purgeAccount`
|
|
959
967
|
([раздел 10](#10-пользователи-вход-и-права)).
|
|
960
968
|
|
|
961
969
|
## 8. Транзакции, батчи и деньги
|
|
@@ -1423,15 +1431,21 @@ await anna.auth.switch(SHOP) // арендатор сессии
|
|
|
1423
1431
|
для одного класса, действовало бы и на строки другого. Нарушение — `invalid_class`.
|
|
1424
1432
|
|
|
1425
1433
|
По тем же именам правила находят и старые версии строк в журнале. Поэтому класс удаляется, только когда у него нет
|
|
1426
|
-
строк, потомков и истории строк: историю удалённых строк сначала стирает `purge()`.
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1434
|
+
строк, потомков и истории строк: историю удалённых строк сначала стирает `purge()`. Для класса System не в счёт
|
|
1435
|
+
строки, потомки и история арендатора, у которого есть свой класс с тем же именем: они относятся к его классу. Класс,
|
|
1436
|
+
удалённый с историей раньше, создаётся заново только с прежними `parent`, `alias`, `derivedBy` и `history` — иначе
|
|
1437
|
+
`invalid_class`: старые версии его строк попали бы под чужие правила или другое прореживание. Правила находят старые
|
|
1438
|
+
версии и по алиасам предков. Поэтому, пока удалённые потомки класса хранят историю строк, класс не меняет алиас
|
|
1439
|
+
(`invalid_class`), а удалённым создаётся заново только с прежним описанием. Если журнал хранит строки класса с этим
|
|
1440
|
+
именем, но не его надгробие (например, строки класса System, удалённого в 1.1), класс с этим именем не создаётся, пока
|
|
1441
|
+
историю не сотрёт `purge()`.
|
|
1430
1442
|
|
|
1431
1443
|
Производный класс (`derivedBy`) заводит, меняет и удаляет только администратор: администраторское подключение или
|
|
1432
1444
|
аккаунт с правом System `letopis.projector` (у системного администратора оно есть по маске `letopis.*`). Проектор
|
|
1433
1445
|
пишет в такой класс итоги по всем строкам источников арендатора, без прав чтения, поэтому пользователь, заведя его
|
|
1434
|
-
сам, читал бы итоги по строкам, которые ему читать запрещено.
|
|
1446
|
+
сам, читал бы итоги по строкам, которые ему читать запрещено. Класс, у которого есть производные потомки, тоже
|
|
1447
|
+
правит только администратор: правка предка меняет описание потомка (поля, ключ, `history`). Пометку `aggregatedBy`
|
|
1448
|
+
(класс стал источником агрегата) база ставит и производному классу сама: полей и ключа она не меняет.
|
|
1435
1449
|
|
|
1436
1450
|
### Выдать право
|
|
1437
1451
|
|
|
@@ -1656,7 +1670,9 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
|
|
|
1656
1670
|
запись после него в поток придёт. Пример выше читает с `'start'`.
|
|
1657
1671
|
- Вызовы `sub.next()` идут по очереди: параллельные получают разные события. Вызов, брошенный по таймауту
|
|
1658
1672
|
(`Promise.race`), всё равно получит своё событие — держите его обещание и ждите его снова.
|
|
1659
|
-
- `classes` — только эти классы с потомками; `pollMs` — запасной опрос (1000 мс).
|
|
1673
|
+
- `classes` — только эти классы с потомками; `pollMs` — запасной опрос (1000 мс). Строка, ушедшая из этих классов
|
|
1674
|
+
сменой класса без смены id, приходит событием удаления в прежнем классе (`op: 'delete'`, `row` — прежняя версия с
|
|
1675
|
+
`$deleted`), даже если новый класс подписчику не виден. Событие приходит, если прежнюю версию подписчик видел.
|
|
1660
1676
|
- События идут без потерь и повторов, под правами читателя, в порядке номеров транзакций, а внутри транзакции — в
|
|
1661
1677
|
порядке записи. Это не порядок фиксации: версии одного объекта сверяйте по `rev`.
|
|
1662
1678
|
- Долгая пишущая транзакция в любой базе кластера задерживает поток: `await sub.lag()` показывает отставание в мс.
|
|
@@ -1772,7 +1788,8 @@ sales.close() // stop() после текущ
|
|
|
1772
1788
|
пачками, каждая своей транзакцией со своим снимком, пока хвост журнала идёт для всех: изменения арендатора выше
|
|
1773
1789
|
последней пачки хвост пропускает (их прочтёт пачка), в пройденной — если транзакция видна в её снимке. Производные
|
|
1774
1790
|
строки арендатора удаляются сразу и до конца перестройки неполны (`status().rebuildingTenants`); блокировку,
|
|
1775
|
-
занятую `run()` другого процесса, перестройка пережидает.
|
|
1791
|
+
занятую `run()` другого процесса, перестройка пережидает. Прерванную перестройку (процесс остановился) продолжает
|
|
1792
|
+
`run()` любого процесса — с `onExpired` и без него.
|
|
1776
1793
|
- `status().state` описывает проекцию в базе, а не процесс: `'new'` — проекции ещё нет, её построит первый `run()`
|
|
1777
1794
|
или `rebuild()`; `'running'` — проекция построена и ведётся по журналу (это не значит, что какой-то процесс сейчас
|
|
1778
1795
|
выполняет `run()`); `'rebuilding'` — идёт или прервана перестройка пачками.
|
|
@@ -1798,6 +1815,9 @@ sales.close() // stop() после текущ
|
|
|
1798
1815
|
его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
|
|
1799
1816
|
- `batch` — сколько изменений в пачке (500), `pollMs` — запасной опрос (1000 мс): сигналы источников будят
|
|
1800
1817
|
проектор раньше.
|
|
1818
|
+
- `lockTimeout` — сколько транзакция проектора ждёт чужую блокировку строки (5000 мс; 0 — без предела). Транзакция
|
|
1819
|
+
любого арендатора, которая держит строку итога или цель конца, иначе держала бы пачку всех арендаторов. Дольше —
|
|
1820
|
+
пачка откатывается, курсор остаётся, `run()` не отклоняется, а пропускает проход (`skipped`) и повторяет его.
|
|
1801
1821
|
- Каждая правка, которую `out` отправляет в базу, — отдельная правка строки, и скорость проектора задаёт число
|
|
1802
1822
|
правок, а не число изменений. Свёртка `inc` сводит правки строки к одной за пачку, поэтому handle из примера выше
|
|
1803
1823
|
(`out.inc` на каждое изменение) на машине замеров догонял 6 тысяч изменений в секунду при `batch` 500 и 33 тысячи
|
|
@@ -2238,7 +2258,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2238
2258
|
|
|
2239
2259
|
| Член | Что делает |
|
|
2240
2260
|
|---|---|
|
|
2241
|
-
| опции `ProjectorOptions` | `from`, `into` — классы с потомками; `version` — целое от 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` — пачками); `batch` — изменений в пачке (500, от 1 до 10 000); `pollMs` — запасной опрос (1000 мс); `tenants` — id арендаторов-участников |
|
|
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 арендаторов-участников |
|
|
2242
2262
|
| `run({ once })` | догнать журнал (`once`) или следовать за ним до `stop()` → `ProjectorReport` |
|
|
2243
2263
|
| `rebuild({ chunked, tenant })` | перестроить с текущего состояния: атомарно, пачками или одного арендатора → `ProjectorReport` |
|
|
2244
2264
|
| `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` — `{ [id арендатора]: ProjectorFailure }`, где `ProjectorFailure` — `{ cursor, code, message }` |
|
|
@@ -2304,7 +2324,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
|
|
|
2304
2324
|
| `confirm_mismatch` | LT020 | подтверждение сброса не совпадает с именем схемы |
|
|
2305
2325
|
| `derived_class` | LT021 | строку производного класса (`derivedBy`) пишет, правит или удаляет не его проектор; конец ведёт на такую строку или на производный класс; проектор пишет в класс вне своих `into` или не нашёл ни одного арендатора, у которого все классы `into` помечены его `derivedBy` |
|
|
2306
2326
|
| `aggregate_field` | LT022 | приложение передало или изменило агрегатное поле (`aggregate`): его считает база |
|
|
2307
|
-
| `projector_pending` | LT023 | `purge` удалённого объекта, чьё удаление ещё не прочёл проектор его
|
|
2327
|
+
| `projector_pending` | LT023 | `purge` удалённого объекта, чьё удаление ещё не прочёл проектор его класса, или `trimHistory`, чью первую оставшуюся версию он ещё не прочёл: сначала `run()` проектора |
|
|
2308
2328
|
|
|
2309
2329
|
Коды библиотеки:
|
|
2310
2330
|
|
|
@@ -2388,7 +2408,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2388
2408
|
|
|
2389
2409
|
## 18. Ограничения
|
|
2390
2410
|
|
|
2391
|
-
Здесь собрано то, что letopis 1.
|
|
2411
|
+
Здесь собрано то, что letopis 1.3 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
|
|
2392
2412
|
|
|
2393
2413
|
**Платформа**
|
|
2394
2414
|
|
|
@@ -2468,8 +2488,9 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2468
2488
|
- Счётчики попыток OTP и TOTP откатываются вместе с транзакцией вызывающего: частоту попыток ограничивает сервис.
|
|
2469
2489
|
- Самостоятельная привязка занятого идентификатора отвечает `acl_denied` — пользователь с недавним входом так
|
|
2470
2490
|
узнаёт, что идентификатор занят. Изменения прав во всех арендаторах упорядочены одной строкой счётчика.
|
|
2471
|
-
-
|
|
2472
|
-
|
|
2491
|
+
- Тексты отказов называют класс, роль и id ссылающейся строки, только если актор её видит: каскад удаления, снятие
|
|
2492
|
+
конца, перевод ссылок при смене класса и ключа, конфликт смены `parent` (`tightening_conflict`). Число невидимых строк, мешающих удалению или смене класса
|
|
2493
|
+
(`reclass_denied`), и существование аккаунта по id (как цели ссылки) не скрываются.
|
|
2473
2494
|
- Ужесточение описания класса и `rekeyClass` проверяют все строки семейства, поэтому требуют права `READ` без условий
|
|
2474
2495
|
на все строки класса и его потомков: иначе пробное ужесточение (`maximum`, `enum`, `pattern`) отвечало бы числом
|
|
2475
2496
|
невидимых строк, которые ему не соответствуют. Системный администратор, ужесточая класс System, получает число
|
|
@@ -2547,6 +2568,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2547
2568
|
- `purge()` ждёт проекторов: пока проектор, чьи источники включают класс объекта, а у арендатора объекта есть его
|
|
2548
2569
|
производный класс, не прочёл удаление, `purge` отвечает `projector_pending`. Проектор, который больше не запускают,
|
|
2549
2570
|
держит `purge` своих источников, пока у арендатора есть его производные классы: запустите его или удалите их.
|
|
2571
|
+
Так же ждёт `trimHistory`: пока проектор не прочёл первую оставшуюся версию, обрезка отвечает `projector_pending`.
|
|
2550
2572
|
|
|
2551
2573
|
**Агрегаты в базе**
|
|
2552
2574
|
|
|
@@ -2613,7 +2635,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2613
2635
|
# hash — цепочка хэшей версий, подмена и разрыв журнала, verify() (этап 1)
|
|
2614
2636
|
# harness — тестовая обвязка: подъём базы, схема на файл, две строки подключения (этап 0)
|
|
2615
2637
|
# jsonschema — компиляция JSON Schema в jsonpath и проверка данных в базе (этап 1)
|
|
2616
|
-
# impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б)
|
|
2638
|
+
# impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б); реестр фасада не общий с корнем, пустой реестр не кэшируется (хвост ревью безопасности №3)
|
|
2617
2639
|
# install — установщик и роли: повторная установка, две установки в базе, три таблицы (этап 1)
|
|
2618
2640
|
# member — членство: работа в арендаторе по ролям, auth.switch, выход с переходом владения, системные строки, роли арендатора без прав System (этап 5)
|
|
2619
2641
|
# model — модели на Zod, нормализатор JSON Schema, перевод шаблонов, совпадение с метасхемой базы (этап 2)
|
|
@@ -2637,6 +2659,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
|
|
|
2637
2659
|
# cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков сигналов (этап 6; план 1.1, точка А)
|
|
2638
2660
|
# maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6)
|
|
2639
2661
|
# up — полный установщик: свежая установка, повторный накат, свои сиды, автосоздание базы, UTF8, версия 17, upgrade с миграциями, PGlite, первый арендатор и пользователь (up({ tenant }), createTenant) (этапы 6, 8)
|
|
2662
|
+
# upgrade — обновление схемы с 1.1.0 (повторный накат) и с 1.0.1 (upgrade: true, миграция 0006) пакетами letopis-11 и letopis-1: версии и хэши прежнего движка, verify(), каталог против чистой установки, строки-замки, приложение, подписка и проектор по старым данным, повторный up() (доработки после 1.2.0, этап 1)
|
|
2640
2663
|
# scenario-salon — сквозной сценарий салона: 21 акт 0.21 (salon) с матрицей покрытия API 1.0 (этап 6)
|
|
2641
2664
|
# telemetry — onQuery и slowMs, describe() и метаданные классов с наследованием, describeText (этап 6)
|
|
2642
2665
|
# history — diff и blame по журналу, причина изменения у глаголов, db.begin() и батча; версия, скрытая условием ACL, колонка changed, пропуск версии, row_changes на 20 тыс. целей (план 1.1, этап 1 и точка А)
|
package/dist/index.js
CHANGED
|
@@ -142,12 +142,22 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
142
142
|
}
|
|
143
143
|
// имперсонация и арендатор проверяются базой сразу: ошибка — здесь, а не на первой цепочке
|
|
144
144
|
const scoped = { ...txc, account: id ?? txc.account, tenant: o?.tenant ?? (id === undefined ? txc.tenant : undefined) };
|
|
145
|
-
|
|
145
|
+
let [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
146
146
|
if (!r?.account)
|
|
147
147
|
throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
// контекст сессии между запросами ушёл из арендатора (сменился или кончился): реестр не загружен и не
|
|
149
|
+
// кэширован (хвост ревью безопасности №3) — контекст читается заново, не больше трёх раз
|
|
150
|
+
for (let attempt = 1;; attempt++) {
|
|
151
|
+
const tenant = r.tenant;
|
|
152
|
+
const registry = await sh.registryFor(tenant, scoped);
|
|
153
|
+
if (registry)
|
|
154
|
+
return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
|
|
155
|
+
if (attempt === 3)
|
|
156
|
+
throw new LetopisError('no_session', 'db.as(): контекст сессии меняется — реестр арендатора не загружен');
|
|
157
|
+
[r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
158
|
+
if (!r?.account)
|
|
159
|
+
throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
|
|
160
|
+
}
|
|
151
161
|
},
|
|
152
162
|
begin: async (o) => {
|
|
153
163
|
if (tx)
|
|
@@ -315,9 +325,9 @@ export async function connect(opts) {
|
|
|
315
325
|
}
|
|
316
326
|
tokens();
|
|
317
327
|
};
|
|
328
|
+
// фасад db.as() не делит реестр с корнем, даже в том же арендаторе: корень после auth.switch() перечитывает свой
|
|
329
|
+
// реестр классами нового арендатора (хвост ревью безопасности №3)
|
|
318
330
|
const registryFor = async (tenant, txc) => {
|
|
319
|
-
if (tenant === root.tenant)
|
|
320
|
-
return root.registry;
|
|
321
331
|
const known = others.get(tenant);
|
|
322
332
|
if (known) {
|
|
323
333
|
known.txc = txc;
|
|
@@ -327,8 +337,11 @@ export async function connect(opts) {
|
|
|
327
337
|
let pending = loading.get(tenant);
|
|
328
338
|
if (!pending) {
|
|
329
339
|
pending = (async () => {
|
|
340
|
+
const rows = await loadClasses(txc, tenant);
|
|
341
|
+
if (!rows)
|
|
342
|
+
return null;
|
|
330
343
|
const registry = new Registry();
|
|
331
|
-
registry.load(
|
|
344
|
+
registry.load(rows);
|
|
332
345
|
others.set(tenant, { registry, txc });
|
|
333
346
|
tokens();
|
|
334
347
|
return registry;
|
package/dist/project.d.ts
CHANGED
|
@@ -98,6 +98,11 @@ export interface ProjectorOptions {
|
|
|
98
98
|
batch?: number;
|
|
99
99
|
/** Запасной опрос журнала, мс (по умолчанию 1000; сигналы источников будят раньше). */
|
|
100
100
|
pollMs?: number;
|
|
101
|
+
/**
|
|
102
|
+
* Сколько транзакция проектора ждёт чужую блокировку строки, мс (по умолчанию 5000; 0 — без предела). Дольше —
|
|
103
|
+
* пачка откатывается, курсор остаётся, run() не отклоняется, а пропускает проход и повторяет его.
|
|
104
|
+
*/
|
|
105
|
+
lockTimeout?: number;
|
|
101
106
|
/**
|
|
102
107
|
* Явный список арендаторов проектора: участники — только они (и только те, у кого каждый класс into помечен
|
|
103
108
|
* derivedBy). Без списка участник — любой арендатор с такими классами.
|
|
@@ -168,12 +173,15 @@ export declare class Projector {
|
|
|
168
173
|
/** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
|
|
169
174
|
private blind;
|
|
170
175
|
private stale;
|
|
176
|
+
/** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
|
|
177
|
+
private tenantsPending;
|
|
171
178
|
private running;
|
|
172
179
|
private stopped;
|
|
173
180
|
private wake;
|
|
174
181
|
private off;
|
|
175
182
|
private readonly batch;
|
|
176
183
|
private readonly pollMs;
|
|
184
|
+
private readonly lockMs;
|
|
177
185
|
private readonly only;
|
|
178
186
|
constructor(env: ProjectorEnv, name: string, def: ProjectorOptions);
|
|
179
187
|
/**
|
|
@@ -299,8 +307,9 @@ export declare class Projector {
|
|
|
299
307
|
*/
|
|
300
308
|
private tenantChunk;
|
|
301
309
|
/**
|
|
302
|
-
*
|
|
303
|
-
*
|
|
310
|
+
* Перестройка арендаторов пачками — проход run(): с start (onExpired: 'rebuild') начинает её у каждого выбывшего по
|
|
311
|
+
* cursor_expired, затем продвигает на пачку каждую начатую — автоматическую и прерванную rebuild({ tenant, chunked:
|
|
312
|
+
* true }). true — работа ещё осталась.
|
|
304
313
|
*/
|
|
305
314
|
private tenantWork;
|
|
306
315
|
/** Ждать сигнал источников, остановку или запасной опрос. */
|
package/dist/project.js
CHANGED
|
@@ -67,6 +67,10 @@ function change(row, cursor, rebuild = false) {
|
|
|
67
67
|
* значениями (22…) и пределами размера (54…). Сбой окружения (блокировки, отмена запроса, связь) — не причина
|
|
68
68
|
* выводить арендатора: пачка откатывается целиком, повтор начнётся с того же курсора.
|
|
69
69
|
*/
|
|
70
|
+
/** Ожидание чужой блокировки строки дольше lock_timeout (55P03): сбой окружения, проход повторяется. */
|
|
71
|
+
function lockWait(e) {
|
|
72
|
+
return e?.code === '55P03';
|
|
73
|
+
}
|
|
70
74
|
function tenantFault(e) {
|
|
71
75
|
if (e instanceof LetopisError)
|
|
72
76
|
return true;
|
|
@@ -198,12 +202,15 @@ export class Projector {
|
|
|
198
202
|
/** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
|
|
199
203
|
blind = new Map();
|
|
200
204
|
stale = true;
|
|
205
|
+
/** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
|
|
206
|
+
tenantsPending = true;
|
|
201
207
|
running = false;
|
|
202
208
|
stopped = false;
|
|
203
209
|
wake = null;
|
|
204
210
|
off = null;
|
|
205
211
|
batch;
|
|
206
212
|
pollMs;
|
|
213
|
+
lockMs;
|
|
207
214
|
only;
|
|
208
215
|
constructor(env, name, def) {
|
|
209
216
|
this.env = env;
|
|
@@ -228,6 +235,9 @@ export class Projector {
|
|
|
228
235
|
this.only = def.tenants ? new Set(def.tenants.map((x) => x.toLowerCase())) : null;
|
|
229
236
|
this.batch = def.batch ?? 500;
|
|
230
237
|
this.pollMs = def.pollMs ?? 1000;
|
|
238
|
+
this.lockMs = def.lockTimeout ?? 5000;
|
|
239
|
+
if (!Number.isInteger(this.lockMs) || this.lockMs < 0)
|
|
240
|
+
throw new LetopisError('invalid_data', `проектор ${name}: lockTimeout — целое число мс, 0 и больше`);
|
|
231
241
|
if (!Number.isInteger(this.batch) || this.batch < 1 || this.batch > 10_000)
|
|
232
242
|
throw new LetopisError('invalid_data', `проектор ${name}: batch — от 1 до 10 000`);
|
|
233
243
|
if (env.onSignal) {
|
|
@@ -261,18 +271,37 @@ export class Projector {
|
|
|
261
271
|
r = await this.step(rep);
|
|
262
272
|
}
|
|
263
273
|
catch (e) {
|
|
264
|
-
if (
|
|
274
|
+
if (lockWait(e)) {
|
|
275
|
+
rep.skipped++;
|
|
276
|
+
r = 'busy';
|
|
277
|
+
}
|
|
278
|
+
else if (e instanceof LetopisError && e.code === 'cursor_expired' && this.def.onExpired === 'rebuild') {
|
|
279
|
+
r = 'rebuild';
|
|
280
|
+
}
|
|
281
|
+
else
|
|
265
282
|
throw e;
|
|
266
|
-
r = 'rebuild';
|
|
267
283
|
}
|
|
268
284
|
// курсор истёк у части арендаторов: с onExpired: 'rebuild' они перестраиваются сами, пачками (доработки после
|
|
269
285
|
// точки Б). Состояние — в чекпоинте, поэтому перестройку продолжает run() любого процесса, а занятая
|
|
270
|
-
// блокировка — только повтор на следующем проходе; ошибка данных выводит арендатора с её кодом, без
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
286
|
+
// блокировка — только повтор на следующем проходе; ошибка данных выводит арендатора с её кодом, без повторов.
|
|
287
|
+
// Начатую перестройку арендатора пачками — и прерванную rebuild({ tenant, chunked: true }) — продвигает run()
|
|
288
|
+
// и без onExpired (ревью безопасности №3): иначе хвост бессрочно пропускал бы изменения арендатора с id выше
|
|
289
|
+
// последней пачки. Начинает перестройку выбывших по ярусу только onExpired: 'rebuild'. Блокировку проектора держит
|
|
290
|
+
// другой процесс (busy) — перестройку продвинет он: чтение чекпоинта ждало бы его пачку
|
|
291
|
+
const auto = this.def.onExpired === 'rebuild';
|
|
292
|
+
try {
|
|
293
|
+
if (r !== 'rebuild' && r !== 'busy' && (auto || this.tenantsPending) && (await this.tenantWork(rep, auto)))
|
|
294
|
+
r = 'more';
|
|
295
|
+
// пачку перестройки держит другой процесс того же проектора — подождать и продолжить
|
|
296
|
+
if (r === 'rebuild')
|
|
297
|
+
r = (await this.rebuildNow(rep, 'auto')) === 'busy' ? 'busy' : 'more';
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
if (!lockWait(e))
|
|
301
|
+
throw e;
|
|
302
|
+
rep.skipped++;
|
|
303
|
+
r = 'busy';
|
|
304
|
+
}
|
|
276
305
|
if (r === 'more')
|
|
277
306
|
continue;
|
|
278
307
|
if (opts.once)
|
|
@@ -306,8 +335,11 @@ export class Projector {
|
|
|
306
335
|
return rep;
|
|
307
336
|
}
|
|
308
337
|
await this.startTenant(rep, tenant, false);
|
|
338
|
+
const started = rep.rebuilds;
|
|
309
339
|
for (;;) {
|
|
310
|
-
|
|
340
|
+
// занятая строка (lockTimeout) — как занятая блокировка проектора: подождать и повторить пачку
|
|
341
|
+
const r = await this.tenantChunk(rep, tenant).catch((e) => { if (lockWait(e))
|
|
342
|
+
return 'busy'; throw e; });
|
|
311
343
|
if (r === 'busy')
|
|
312
344
|
await new Promise((res) => setTimeout(res, 20));
|
|
313
345
|
if (r !== 'done')
|
|
@@ -316,6 +348,10 @@ export class Projector {
|
|
|
316
348
|
const f = (await this.tx(async (q) => this.state(q))).failed?.[tenant];
|
|
317
349
|
if (f)
|
|
318
350
|
throw new LetopisError(f.code, f.message);
|
|
351
|
+
// последнюю пачку мог записать run() другого процесса (он продвигает начатые перестройки): перестройка этого
|
|
352
|
+
// вызова всё равно завершена
|
|
353
|
+
if (rep.rebuilds === started)
|
|
354
|
+
rep.rebuilds++;
|
|
319
355
|
return rep;
|
|
320
356
|
}
|
|
321
357
|
}
|
|
@@ -369,6 +405,10 @@ export class Projector {
|
|
|
369
405
|
await q.unsafe('begin isolation level read committed');
|
|
370
406
|
await q.unsafe(`set local role ${ident(this.env.ownerRole)}`);
|
|
371
407
|
await q.unsafe(`set local timezone = 'UTC'`);
|
|
408
|
+
// чужая блокировка строки (транзакция любого арендатора держит итог или цель) не держит пачку всех арендаторов
|
|
409
|
+
// дольше lockTimeout (хвост ревью безопасности №3): 55P03 — проход пропускается, курсор остаётся
|
|
410
|
+
if (this.lockMs > 0)
|
|
411
|
+
await q.unsafe(`set local lock_timeout = ${this.lockMs}`);
|
|
372
412
|
const out = await fn(q);
|
|
373
413
|
await q.unsafe('commit');
|
|
374
414
|
return out;
|
|
@@ -489,6 +529,7 @@ export class Projector {
|
|
|
489
529
|
return 'rebuild';
|
|
490
530
|
if (meta.version !== this.def.version)
|
|
491
531
|
throw this.versionError(meta);
|
|
532
|
+
this.tenantsPending = Object.values(meta.retenant ?? {}).some((v) => !v.done);
|
|
492
533
|
const cur = dec(meta.cursor);
|
|
493
534
|
const rows = await q.unsafe(`select l.tx::text as tx, l.seq::text as seq, ${rowJson('l')} as row
|
|
494
535
|
from ${this.env.S}.watch_read($1::text::xid8, $2::bigint, null, $3::text[], $4::int, $5::text::pg_snapshot, true) l`, [cur.tx, cur.seq, [...this.from, '#reset'], this.batch, cur.snap ?? null]);
|
|
@@ -1059,27 +1100,31 @@ export class Projector {
|
|
|
1059
1100
|
});
|
|
1060
1101
|
}
|
|
1061
1102
|
/**
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1103
|
+
* Перестройка арендаторов пачками — проход run(): с start (onExpired: 'rebuild') начинает её у каждого выбывшего по
|
|
1104
|
+
* cursor_expired, затем продвигает на пачку каждую начатую — автоматическую и прерванную rebuild({ tenant, chunked:
|
|
1105
|
+
* true }). true — работа ещё осталась.
|
|
1064
1106
|
*/
|
|
1065
|
-
async tenantWork(rep) {
|
|
1107
|
+
async tenantWork(rep, start) {
|
|
1066
1108
|
const meta = await this.tx(async (q) => this.state(q));
|
|
1067
1109
|
if (!meta.cursor || meta.state === 'rebuilding' || meta.version !== this.def.version)
|
|
1068
1110
|
return false;
|
|
1069
1111
|
let more = false;
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1112
|
+
if (start) {
|
|
1113
|
+
for (const [t, f] of Object.entries(meta.failed ?? {})) {
|
|
1114
|
+
if (f.code !== 'cursor_expired' || this.blind.has(t) || !this.scopes.has(t))
|
|
1115
|
+
continue;
|
|
1116
|
+
if ((await this.startTenant(rep, t, true)) === 'ok')
|
|
1117
|
+
more = true;
|
|
1118
|
+
}
|
|
1075
1119
|
}
|
|
1076
|
-
const fresh = await this.tx(async (q) => this.state(q));
|
|
1120
|
+
const fresh = start ? await this.tx(async (q) => this.state(q)) : meta;
|
|
1077
1121
|
for (const [t, v] of Object.entries(fresh.retenant ?? {})) {
|
|
1078
1122
|
if (v.done)
|
|
1079
1123
|
continue;
|
|
1080
1124
|
if ((await this.tenantChunk(rep, t)) === 'more')
|
|
1081
1125
|
more = true;
|
|
1082
1126
|
}
|
|
1127
|
+
this.tenantsPending = more;
|
|
1083
1128
|
return more;
|
|
1084
1129
|
}
|
|
1085
1130
|
/** Ждать сигнал источников, остановку или запасной опрос. */
|
package/dist/sync.js
CHANGED
|
@@ -11,13 +11,22 @@ function canon(v) {
|
|
|
11
11
|
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
|
const ordered = [];
|
|
17
|
+
const visiting = new Set();
|
|
16
18
|
const visit = (m) => {
|
|
17
|
-
if (ordered.includes(m))
|
|
19
|
+
if (ordered.includes(m) || visiting.has(m))
|
|
18
20
|
return;
|
|
21
|
+
visiting.add(m);
|
|
19
22
|
if (m.parent && models.includes(m.parent))
|
|
20
23
|
visit(m.parent);
|
|
24
|
+
for (const a of Object.values(m.describe().aggregate ?? {})) {
|
|
25
|
+
const src = models.find((x) => x.name === a.of);
|
|
26
|
+
if (src)
|
|
27
|
+
visit(src);
|
|
28
|
+
}
|
|
29
|
+
visiting.delete(m);
|
|
21
30
|
ordered.push(m);
|
|
22
31
|
};
|
|
23
32
|
models.forEach(visit);
|
package/dist/watch.js
CHANGED
|
@@ -116,10 +116,35 @@ export class Subscription {
|
|
|
116
116
|
if (!this.pos)
|
|
117
117
|
this.pos = await (this.start ?? this.head());
|
|
118
118
|
const p = this.pos;
|
|
119
|
-
const
|
|
119
|
+
const cols = `l.tx::text as tx, l.seq::text as seq, jsonb_build_object('id', l.id, 'rev', l.rev, 'class', l.class, 'tenant', l.tenant, `
|
|
120
120
|
+ `'owner', l.owner, 'links', l.links, 'data', l.data, 'tags', l.tags, 'at', l.at, 'author', l.author, 'agent', l.agent, `
|
|
121
|
-
+ `'op', l.op, 'reason', l.reason, 'moved', l.moved) as row
|
|
122
|
-
|
|
121
|
+
+ `'op', l.op, 'reason', l.reason, 'moved', l.moved) as row`;
|
|
122
|
+
// уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3) — надгробием прежней
|
|
123
|
+
// версии в том же окне: полная пачка watch_read ограничивает окно своей последней строкой. Оба потока — одним
|
|
124
|
+
// оператором: общий снимок и горизонт, иначе курсор перескочил бы событие, зафиксированное между запросами
|
|
125
|
+
// (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом
|
|
126
|
+
const S = this.ctx.S;
|
|
127
|
+
const both = await this.ctx.exec.run((q) => q.unsafe(`with r as (select l.* from ${S}.watch_read($1::text::xid8, $2::bigint, $3::text::timestamptz, $4::text[], $5::int, $6::text::pg_snapshot) l),
|
|
128
|
+
u as (select r.tx, r.seq from r where (select count(*) from r) = $5 order by r.tx desc, r.seq desc limit 1)
|
|
129
|
+
select 0 as src, ${cols} from r l
|
|
130
|
+
union all
|
|
131
|
+
select 1 as src, ${cols} from ${S}.watch_leaving($1::text::xid8, $2::bigint, $4::text[], $5::int, $6::text::pg_snapshot,
|
|
132
|
+
(select tx from u), (select seq from u)) l`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
|
|
133
|
+
const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
|
|
134
|
+
const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
|
|
135
|
+
const read = both.filter((r) => r.src === 0).sort(order);
|
|
136
|
+
const away = both.filter((r) => r.src === 1).sort(order);
|
|
137
|
+
let rows = read;
|
|
138
|
+
{
|
|
139
|
+
const upto = read.length === this.batch ? read[read.length - 1] : null;
|
|
140
|
+
if (away.length) {
|
|
141
|
+
let bound = upto;
|
|
142
|
+
const lastAway = away[away.length - 1];
|
|
143
|
+
if (away.length === this.batch && (!bound || before(lastAway, bound)))
|
|
144
|
+
bound = lastAway;
|
|
145
|
+
rows = [...read, ...away].sort(order).filter((r) => !bound || !before(bound, r));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
123
148
|
for (const r of rows) {
|
|
124
149
|
const row = toRow(r.row);
|
|
125
150
|
const snap = keepSnap(p.snap, r.tx);
|
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.3:
|
|
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.3.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",
|
|
@@ -101,6 +101,7 @@
|
|
|
101
101
|
"@types/node": "^22.20.5",
|
|
102
102
|
"letopis-0": "npm:letopis@0.21.0",
|
|
103
103
|
"letopis-1": "npm:letopis@1.0.1",
|
|
104
|
+
"letopis-11": "npm:letopis@1.1.0",
|
|
104
105
|
"tsx": "^4.23.15",
|
|
105
106
|
"typescript": "~5.9.3",
|
|
106
107
|
"zod": "4.6.5"
|
package/sql/10-core.sql
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
revoke all on schema @SCHEMA@ from public;
|
|
7
7
|
grant usage on schema @SCHEMA@ to @APP@;
|
|
8
8
|
|
|
9
|
-
-- Все цели концов плоским списком: {роль: id} или {роль: [id, …]}.
|
|
9
|
+
-- Все цели концов плоским списком: {роль: id} или {роль: [id, …]}. Тело — SQL-стандарта (return): имена связываются
|
|
10
|
+
-- при создании и от search_path сеанса не зависят, а колонку ends вычисляет роль приложения. Вариант на plpgsql быстрее
|
|
11
|
+
-- на 17 % (бенч gencols), но его имена разрешались бы при исполнении (ревью безопасности доработок после 1.2.0)
|
|
10
12
|
create or replace function @SCHEMA@.link_ids(links jsonb) returns uuid[]
|
|
11
13
|
language sql immutable parallel safe
|
|
12
14
|
return (
|