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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # letopis 1.2
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
- класс источником (новый класс, смена `parent`, источник после цели) может только тот, кто без условий читает все
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` одной транзакцией в таком классе поэтому не проходят. Удалить аккаунт — `db.auth.purgeAccount`
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
- раньше, создаётся заново только с прежними `parent`, `alias` и `derivedBy` — иначе `invalid_class`: старые версии его
1428
- строк попали бы под чужие правила. Если журнал хранит строки класса с этим именем, но не его надгробие (например,
1429
- строки класса System, удалённого в 1.1), класс с этим именем не создаётся, пока историю не сотрёт `purge()`.
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` удалённого объекта, чьё удаление ещё не прочёл проектор его класса: сначала `run()` проектора |
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.2 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
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
- - Число невидимых строк, мешающих удалению или смене класса (`reclass_denied`), и существование аккаунта по id (как
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
- const [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
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
- const tenant = r.tenant;
149
- const registry = await sh.registryFor(tenant, scoped);
150
- return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
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(((await loadClasses(txc, tenant)) ?? []));
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
- * Автоматическая перестройка выбывших по cursor_expired (onExpired: 'rebuild') — проход run(): начинает перестройку
303
- * пачками у каждого такого арендатора и продвигает начатые на пачку. true — работа ещё осталась.
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 (!(e instanceof LetopisError && e.code === 'cursor_expired' && this.def.onExpired === 'rebuild'))
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
- if (this.def.onExpired === 'rebuild' && r !== 'rebuild' && (await this.tenantWork(rep)) && r !== 'busy')
272
- r = 'more';
273
- // пачку перестройки держит другой процесс того же проектора — подождать и продолжить
274
- if (r === 'rebuild')
275
- r = (await this.rebuildNow(rep, 'auto')) === 'busy' ? 'busy' : 'more';
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
- const r = await this.tenantChunk(rep, tenant);
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
- * Автоматическая перестройка выбывших по cursor_expired (onExpired: 'rebuild') — проход run(): начинает перестройку
1063
- * пачками у каждого такого арендатора и продвигает начатые на пачку. true — работа ещё осталась.
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
- for (const [t, f] of Object.entries(meta.failed ?? {})) {
1071
- if (f.code !== 'cursor_expired' || this.blind.has(t) || !this.scopes.has(t))
1072
- continue;
1073
- if ((await this.startTenant(rep, t, true)) === 'ok')
1074
- more = true;
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 rows = await this.ctx.exec.run((q) => q.unsafe(`select 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, `
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
- + `from ${this.ctx.S}.watch_read($1::text::xid8, $2::bigint, $3::text::timestamptz, $4::text[], $5::int, $6::text::pg_snapshot) l`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
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.2:
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.2.0",
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 (