letopis 1.2.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # letopis 1.2
1
+ # letopis 1.4
2
2
 
3
3
  **letopis** — библиотека для Node.js, хранилище версионированных сущностей на PostgreSQL 18. Каждое изменение
4
4
  сохраняется версией в журнале. Поэтому у любого объекта есть история, состояние на любую дату и подписка на
@@ -362,12 +362,23 @@ 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
  класса. Пока индекс строится, запись в журнал ждёт. На большой установке постройте его заранее, не останавливая
368
374
  записи: `create index concurrently log_class_tenant_at on "v2.<схема>".log (class, tenant, at)` от роли владельца, —
369
375
  тогда `up()` только снимет прежний.
370
376
 
377
+ Обновление схем 1.1.0 и 1.0.1 проверяет тест `upgrade`. Схему ставит прежний пакет, данные пишет прежний движок, затем
378
+ вызывается `up()` текущей версии. После него версии и хэши прежнего движка на месте, `verify({ data: true })` чист, а
379
+ каталог схемы совпадает с чистой установкой. Схема 1.1.0 обновляется простым `up()`: ревизия у неё та же. Схема 1.0.1
380
+ обновляется через `up({ upgrade: true })`.
381
+
371
382
  ### Разработка без PostgreSQL: PGlite
372
383
 
373
384
  `up({ pglite: true })` поднимает PGlite (PostgreSQL 18 на WebAssembly) с pgcrypto и сокетом на свободном порту.
@@ -544,11 +555,19 @@ const Movement = link('Movement', { amount: z.number().int() }, { ends: { Wallet
544
555
  достаточно права на запись в источник.
545
556
  - Описание проверяет база (`invalid_class`): поле есть в схеме, оно числовое и не входит в ключ; конец `via` —
546
557
  одиночный и принимает класс цели; поле `sum` — число; агрегаты не образуют цикл; цель и источник — не системные и
547
- не производные классы. Источник, описанный позже цели, сверяется при появлении.
558
+ не производные классы. Класс-источник описывают раньше агрегата: агрегат над классом, которого ещё нет, отклоняется.
559
+ У класса арендатора источник ищут среди классов арендатора. `syncModels` сам описывает источники раньше целей, а
560
+ модели по кругу (агрегат над собственным потомком, цикл агрегатов) отклоняет до записи (`invalid_class` с кругом).
561
+ Класс `of` не удаляется, пока его называет агрегат класса того же арендатора: сначала агрегат убирают из описания цели.
548
562
  - Строки попадают в агрегат только с ведома того, кто читает их все: поле считает и строки, которые автору не видны.
549
563
  Описать или изменить агрегат может только тот, кто без условий читает все строки источника и его потомков. Сделать
550
- класс источником (новый класс, смена `parent`, источник после цели) может только тот, кто без условий читает все
564
+ класс источником (новый потомок источника, смена `parent`) может только тот, кто без условий читает все
551
565
  строки этого класса и его потомков — и до правки, и после. Иначе `acl_denied`.
566
+ - Новый источник проверяется и по тому, кто описал агрегат: он тоже должен без условий читать все строки класса.
567
+ Описавший — автор версии строки `Class` цели, с которой описание агрегата стало нынешним. Если он их не читает, класс
568
+ источником не станет (`acl_denied`), пока агрегат не опишет заново тот, кто читает. У агрегата класса System
569
+ описавший проверяется по классам System, как и при описании. Класс арендатора под агрегатом System проверяется
570
+ только у того, кто его заводит.
552
571
  - Опция наследуется, потомок заменяет её целиком. Добавить или изменить агрегат — ужесточение: запись в цель и в
553
572
  источники ждёт, пока поле пересчитывается у всех строк.
554
573
  - `db.rebuildAggregates(класс)` пересчитывает поле из текущих строк — для ремонта. Версия пишется только там, где
@@ -955,7 +974,8 @@ await db.Customer(temp).purge({ confirm: true }).rows() // история
955
974
  версии 1 остаётся запись `purge` с числом стёртых версий. История системных классов не стирается. Пока проектор,
956
975
  чьи источники `from` включают класс объекта ([«Проекторы»](#проекторы)), не прочёл удаление, `purge` отказывает
957
976
  (`projector_pending`): стёртое надгробие проектор не увидел бы, и вклад объекта остался бы в итогах. Удаление и
958
- `purge` одной транзакцией в таком классе поэтому не проходят. Удалить аккаунт — `db.auth.purgeAccount`
977
+ `purge` одной транзакцией в таком классе поэтому не проходят. Так же `db.trimHistory` отказывает, пока проектор не
978
+ прочёл первую оставшуюся версию: прошлую версию для `before` он бы уже не нашёл. Удалить аккаунт — `db.auth.purgeAccount`
959
979
  ([раздел 10](#10-пользователи-вход-и-права)).
960
980
 
961
981
  ## 8. Транзакции, батчи и деньги
@@ -1423,15 +1443,21 @@ await anna.auth.switch(SHOP) // арендатор сессии
1423
1443
  для одного класса, действовало бы и на строки другого. Нарушение — `invalid_class`.
1424
1444
 
1425
1445
  По тем же именам правила находят и старые версии строк в журнале. Поэтому класс удаляется, только когда у него нет
1426
- строк, потомков и истории строк: историю удалённых строк сначала стирает `purge()`. Класс, удалённый с историей
1427
- раньше, создаётся заново только с прежними `parent`, `alias` и `derivedBy` — иначе `invalid_class`: старые версии его
1428
- строк попали бы под чужие правила. Если журнал хранит строки класса с этим именем, но не его надгробие (например,
1429
- строки класса System, удалённого в 1.1), класс с этим именем не создаётся, пока историю не сотрёт `purge()`.
1446
+ строк, потомков и истории строк: историю удалённых строк сначала стирает `purge()`. Для класса System не в счёт
1447
+ строки, потомки и история арендатора, у которого есть свой класс с тем же именем: они относятся к его классу. Класс,
1448
+ удалённый с историей раньше, создаётся заново только с прежними `parent`, `alias`, `derivedBy` и `history` — иначе
1449
+ `invalid_class`: старые версии его строк попали бы под чужие правила или другое прореживание. Правила находят старые
1450
+ версии и по алиасам предков. Поэтому, пока удалённые потомки класса хранят историю строк, класс не меняет алиас
1451
+ (`invalid_class`), а удалённым создаётся заново только с прежним описанием. Если журнал хранит строки класса с этим
1452
+ именем, но не его надгробие (например, строки класса System, удалённого в 1.1), класс с этим именем не создаётся, пока
1453
+ историю не сотрёт `purge()`.
1430
1454
 
1431
1455
  Производный класс (`derivedBy`) заводит, меняет и удаляет только администратор: администраторское подключение или
1432
1456
  аккаунт с правом System `letopis.projector` (у системного администратора оно есть по маске `letopis.*`). Проектор
1433
1457
  пишет в такой класс итоги по всем строкам источников арендатора, без прав чтения, поэтому пользователь, заведя его
1434
- сам, читал бы итоги по строкам, которые ему читать запрещено.
1458
+ сам, читал бы итоги по строкам, которые ему читать запрещено. Класс, у которого есть производные потомки, тоже
1459
+ правит только администратор: правка предка меняет описание потомка (поля, ключ, `history`). Пометку `aggregatedBy`
1460
+ (класс стал источником агрегата) база ставит и производному классу сама: полей и ключа она не меняет.
1435
1461
 
1436
1462
  ### Выдать право
1437
1463
 
@@ -1656,7 +1682,9 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
1656
1682
  запись после него в поток придёт. Пример выше читает с `'start'`.
1657
1683
  - Вызовы `sub.next()` идут по очереди: параллельные получают разные события. Вызов, брошенный по таймауту
1658
1684
  (`Promise.race`), всё равно получит своё событие — держите его обещание и ждите его снова.
1659
- - `classes` — только эти классы с потомками; `pollMs` — запасной опрос (1000 мс).
1685
+ - `classes` — только эти классы с потомками; `pollMs` — запасной опрос (1000 мс). Строка, ушедшая из этих классов
1686
+ сменой класса без смены id, приходит событием удаления в прежнем классе (`op: 'delete'`, `row` — прежняя версия с
1687
+ `$deleted`), даже если новый класс подписчику не виден. Событие приходит, если прежнюю версию подписчик видел.
1660
1688
  - События идут без потерь и повторов, под правами читателя, в порядке номеров транзакций, а внутри транзакции — в
1661
1689
  порядке записи. Это не порядок фиксации: версии одного объекта сверяйте по `rev`.
1662
1690
  - Долгая пишущая транзакция в любой базе кластера задерживает поток: `await sub.lag()` показывает отставание в мс.
@@ -1772,7 +1800,8 @@ sales.close() // stop() после текущ
1772
1800
  пачками, каждая своей транзакцией со своим снимком, пока хвост журнала идёт для всех: изменения арендатора выше
1773
1801
  последней пачки хвост пропускает (их прочтёт пачка), в пройденной — если транзакция видна в её снимке. Производные
1774
1802
  строки арендатора удаляются сразу и до конца перестройки неполны (`status().rebuildingTenants`); блокировку,
1775
- занятую `run()` другого процесса, перестройка пережидает.
1803
+ занятую `run()` другого процесса, перестройка пережидает. Прерванную перестройку (процесс остановился) продолжает
1804
+ `run()` любого процесса — с `onExpired` и без него.
1776
1805
  - `status().state` описывает проекцию в базе, а не процесс: `'new'` — проекции ещё нет, её построит первый `run()`
1777
1806
  или `rebuild()`; `'running'` — проекция построена и ведётся по журналу (это не значит, что какой-то процесс сейчас
1778
1807
  выполняет `run()`); `'rebuilding'` — идёт или прервана перестройка пачками.
@@ -1798,6 +1827,15 @@ sales.close() // stop() после текущ
1798
1827
  его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
1799
1828
  - `batch` — сколько изменений в пачке (500), `pollMs` — запасной опрос (1000 мс): сигналы источников будят
1800
1829
  проектор раньше.
1830
+ - `lockTimeout` — сколько транзакция проектора ждёт чужую блокировку строки (5000 мс; 0 — без предела). Транзакция
1831
+ любого арендатора, которая держит строку итога или цель конца, иначе держала бы пачку всех арендаторов. Дольше —
1832
+ пачка откатывается, курсор остаётся, `run()` не отклоняется, а пропускает проход (`skipped`) и повторяет его. Отчёт
1833
+ называет арендатора, на чьей строке встала пачка (`locked`). `rebuild({ tenant, chunked: true })` такую блокировку
1834
+ пережидает.
1835
+ - `lockRetries` — сколько проходов подряд запись одного арендатора может упираться в `lockTimeout`, прежде чем он
1836
+ выбудет (по умолчанию 0 — не выбывает). Выбывший получает код `55P03`, и пачка идёт для остальных; его изменения
1837
+ пропускаются до `rebuild({ tenant })`. Счёт ведёт процесс, удачная запись арендатора его сбрасывает. Учтите: долгая
1838
+ исключительная блокировка семейства класса System (ужесточение описания) упрётся в каждого арендатора по очереди.
1801
1839
  - Каждая правка, которую `out` отправляет в базу, — отдельная правка строки, и скорость проектора задаёт число
1802
1840
  правок, а не число изменений. Свёртка `inc` сводит правки строки к одной за пачку, поэтому handle из примера выше
1803
1841
  (`out.inc` на каждое изменение) на машине замеров догонял 6 тысяч изменений в секунду при `batch` 500 и 33 тысячи
@@ -2238,12 +2276,12 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2238
2276
 
2239
2277
  | Член | Что делает |
2240
2278
  |---|---|
2241
- | опции `ProjectorOptions` | `from`, `into` — классы с потомками; `version` — целое от 1; `handle(changes, out)`; `withBefore`; `onExpired: 'rebuild' \| 'error'` (`'rebuild'` — пачками); `batch` — изменений в пачке (500, от 1 до 10 000); `pollMs` — запасной опрос (1000 мс); `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 арендаторов-участников |
2242
2280
  | `run({ once })` | догнать журнал (`once`) или следовать за ним до `stop()` → `ProjectorReport` |
2243
2281
  | `rebuild({ chunked, tenant })` | перестроить с текущего состояния: атомарно, пачками или одного арендатора → `ProjectorReport` |
2244
2282
  | `status()` | → `ProjectorStatus`: `{ name, state, version, cursor, at, lag, failed, rebuildingTenants }`; `failed` — `{ [id арендатора]: ProjectorFailure }`, где `ProjectorFailure` — `{ cursor, code, message }` |
2245
2283
  | `stop()`, `close()` | остановить `run()` после текущей пачки; то же и отписаться от сигналов |
2246
- | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds }` |
2284
+ | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds, locked }` |
2247
2285
  | `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` и, если есть, `agent`, `reason`, `moved`, `before`, `rebuild` |
2248
2286
  | `ProjectorOut` | `upsert(класс, строка, { tenant })`, `inc(класс, строка, приращения, { tenant })`, `delete(класс, ключ или id, { tenant })`, `get(класс, ключ или id, { tenant })` → `Row \| null`; строка — `ProjectorRow` в формате загрузчика |
2249
2287
 
@@ -2304,7 +2342,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2304
2342
  | `confirm_mismatch` | LT020 | подтверждение сброса не совпадает с именем схемы |
2305
2343
  | `derived_class` | LT021 | строку производного класса (`derivedBy`) пишет, правит или удаляет не его проектор; конец ведёт на такую строку или на производный класс; проектор пишет в класс вне своих `into` или не нашёл ни одного арендатора, у которого все классы `into` помечены его `derivedBy` |
2306
2344
  | `aggregate_field` | LT022 | приложение передало или изменило агрегатное поле (`aggregate`): его считает база |
2307
- | `projector_pending` | LT023 | `purge` удалённого объекта, чьё удаление ещё не прочёл проектор его класса: сначала `run()` проектора |
2345
+ | `projector_pending` | LT023 | `purge` удалённого объекта, чьё удаление ещё не прочёл проектор его класса, или `trimHistory`, чью первую оставшуюся версию он ещё не прочёл: сначала `run()` проектора |
2308
2346
 
2309
2347
  Коды библиотеки:
2310
2348
 
@@ -2388,7 +2426,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2388
2426
 
2389
2427
  ## 18. Ограничения
2390
2428
 
2391
- Здесь собрано то, что letopis 1.2 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
2429
+ Здесь собрано то, что letopis 1.4 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
2392
2430
 
2393
2431
  **Платформа**
2394
2432
 
@@ -2468,8 +2506,9 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2468
2506
  - Счётчики попыток OTP и TOTP откатываются вместе с транзакцией вызывающего: частоту попыток ограничивает сервис.
2469
2507
  - Самостоятельная привязка занятого идентификатора отвечает `acl_denied` — пользователь с недавним входом так
2470
2508
  узнаёт, что идентификатор занят. Изменения прав во всех арендаторах упорядочены одной строкой счётчика.
2471
- - Число невидимых строк, мешающих удалению или смене класса (`reclass_denied`), и существование аккаунта по id (как
2472
- цели ссылки) не скрываются.
2509
+ - Тексты отказов называют класс, роль и id ссылающейся строки, только если актор её видит: каскад удаления, снятие
2510
+ конца, перевод ссылок при смене класса и ключа, конфликт смены `parent` (`tightening_conflict`). Число невидимых строк, мешающих удалению или смене класса
2511
+ (`reclass_denied`), и существование аккаунта по id (как цели ссылки) не скрываются.
2473
2512
  - Ужесточение описания класса и `rekeyClass` проверяют все строки семейства, поэтому требуют права `READ` без условий
2474
2513
  на все строки класса и его потомков: иначе пробное ужесточение (`maximum`, `enum`, `pattern`) отвечало бы числом
2475
2514
  невидимых строк, которые ему не соответствуют. Системный администратор, ужесточая класс System, получает число
@@ -2547,6 +2586,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2547
2586
  - `purge()` ждёт проекторов: пока проектор, чьи источники включают класс объекта, а у арендатора объекта есть его
2548
2587
  производный класс, не прочёл удаление, `purge` отвечает `projector_pending`. Проектор, который больше не запускают,
2549
2588
  держит `purge` своих источников, пока у арендатора есть его производные классы: запустите его или удалите их.
2589
+ Так же ждёт `trimHistory`: пока проектор не прочёл первую оставшуюся версию, обрезка отвечает `projector_pending`.
2550
2590
 
2551
2591
  **Агрегаты в базе**
2552
2592
 
@@ -2613,7 +2653,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2613
2653
  # hash — цепочка хэшей версий, подмена и разрыв журнала, verify() (этап 1)
2614
2654
  # harness — тестовая обвязка: подъём базы, схема на файл, две строки подключения (этап 0)
2615
2655
  # jsonschema — компиляция JSON Schema в jsonpath и проверка данных в базе (этап 1)
2616
- # impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б)
2656
+ # impersonation — имперсонация: letopis.account только для сервиса с правом, запреты целей, author и agent в журнале, sessionFor; реестр db.as() — один на арендатора, не пустеет, перечитывается по сигналу своего арендатора (этап 5; доработки после точки Б); реестр фасада не общий с корнем, пустой реестр не кэшируется (хвост ревью безопасности №3)
2617
2657
  # install — установщик и роли: повторная установка, две установки в базе, три таблицы (этап 1)
2618
2658
  # member — членство: работа в арендаторе по ролям, auth.switch, выход с переходом владения, системные строки, роли арендатора без прав System (этап 5)
2619
2659
  # model — модели на Zod, нормализатор JSON Schema, перевод шаблонов, совпадение с метасхемой базы (этап 2)
@@ -2637,6 +2677,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2637
2677
  # cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков сигналов (этап 6; план 1.1, точка А)
2638
2678
  # maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6)
2639
2679
  # up — полный установщик: свежая установка, повторный накат, свои сиды, автосоздание базы, UTF8, версия 17, upgrade с миграциями, PGlite, первый арендатор и пользователь (up({ tenant }), createTenant) (этапы 6, 8)
2680
+ # upgrade — обновление схемы с 1.1.0 (повторный накат) и с 1.0.1 (upgrade: true, миграция 0006) пакетами letopis-11 и letopis-1: версии и хэши прежнего движка, verify(), каталог против чистой установки, строки-замки, приложение, подписка и проектор по старым данным, повторный up() (доработки после 1.2.0, этап 1)
2640
2681
  # scenario-salon — сквозной сценарий салона: 21 акт 0.21 (salon) с матрицей покрытия API 1.0 (этап 6)
2641
2682
  # telemetry — onQuery и slowMs, describe() и метаданные классов с наследованием, describeText (этап 6)
2642
2683
  # history — diff и blame по журналу, причина изменения у глаголов, db.begin() и батча; версия, скрытая условием ACL, колонка changed, пропуск версии, row_changes на 20 тыс. целей (план 1.1, этап 1 и точка А)
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';
@@ -142,12 +143,22 @@ function makeDb(sh, scope, exec, owner, tx) {
142
143
  }
143
144
  // имперсонация и арендатор проверяются базой сразу: ошибка — здесь, а не на первой цепочке
144
145
  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 });
146
+ let [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
146
147
  if (!r?.account)
147
148
  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);
149
+ // контекст сессии между запросами ушёл из арендатора (сменился или кончился): реестр не загружен и не
150
+ // кэширован (хвост ревью безопасности №3) — контекст читается заново, не больше трёх раз
151
+ for (let attempt = 1;; attempt++) {
152
+ const tenant = r.tenant;
153
+ const registry = await sh.registryFor(tenant, scoped);
154
+ if (registry)
155
+ return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
156
+ if (attempt === 3)
157
+ throw new LetopisError('no_session', 'db.as(): контекст сессии меняется — реестр арендатора не загружен');
158
+ [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
159
+ if (!r?.account)
160
+ throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
161
+ }
151
162
  },
152
163
  begin: async (o) => {
153
164
  if (tx)
@@ -257,6 +268,12 @@ export async function connect(opts) {
257
268
  throw new LetopisError('invalid_data', `connect: схемы ${opts.schema} нет — установите её: up({ schema })`);
258
269
  if (rev < DDL_REVISION)
259
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
+ }
260
277
  // режим сигналов установки (план 1.1, §2.2); схема, накатанная до него (нет signal_mode()), — class
261
278
  const [sm] = await sql.unsafe(`select ${S}.signal_mode() as m`).catch((e) => {
262
279
  if (e.code === '42883')
@@ -315,9 +332,9 @@ export async function connect(opts) {
315
332
  }
316
333
  tokens();
317
334
  };
335
+ // фасад db.as() не делит реестр с корнем, даже в том же арендаторе: корень после auth.switch() перечитывает свой
336
+ // реестр классами нового арендатора (хвост ревью безопасности №3)
318
337
  const registryFor = async (tenant, txc) => {
319
- if (tenant === root.tenant)
320
- return root.registry;
321
338
  const known = others.get(tenant);
322
339
  if (known) {
323
340
  known.txc = txc;
@@ -327,8 +344,11 @@ export async function connect(opts) {
327
344
  let pending = loading.get(tenant);
328
345
  if (!pending) {
329
346
  pending = (async () => {
347
+ const rows = await loadClasses(txc, tenant);
348
+ if (!rows)
349
+ return null;
330
350
  const registry = new Registry();
331
- registry.load(((await loadClasses(txc, tenant)) ?? []));
351
+ registry.load(rows);
332
352
  others.set(tenant, { registry, txc });
333
353
  tokens();
334
354
  return registry;
package/dist/project.d.ts CHANGED
@@ -98,6 +98,17 @@ export interface ProjectorOptions {
98
98
  batch?: number;
99
99
  /** Запасной опрос журнала, мс (по умолчанию 1000; сигналы источников будят раньше). */
100
100
  pollMs?: number;
101
+ /**
102
+ * Сколько транзакция проектора ждёт чужую блокировку строки, мс (по умолчанию 5000; 0 — без предела). Дольше —
103
+ * пачка откатывается, курсор остаётся, run() не отклоняется, а пропускает проход и повторяет его.
104
+ */
105
+ lockTimeout?: number;
106
+ /**
107
+ * Сколько проходов подряд запись одного арендатора может упираться в lockTimeout, прежде чем он выбудет (код 55P03,
108
+ * дальше — rebuild({ tenant })), а пачка пойдёт для остальных; по умолчанию 0 — не выбывает, проход повторяется для всех.
109
+ * Счёт — в процессе, удачная запись арендатора его сбрасывает.
110
+ */
111
+ lockRetries?: number;
101
112
  /**
102
113
  * Явный список арендаторов проектора: участники — только они (и только те, у кого каждый класс into помечен
103
114
  * derivedBy). Без списка участник — любой арендатор с такими классами.
@@ -113,10 +124,12 @@ export interface ProjectorReport {
113
124
  updated: number;
114
125
  deleted: number;
115
126
  unchanged: number;
116
- /** Пачек, пропущенных из-за блокировки: проектор работает в другом процессе. */
127
+ /** Пачек, пропущенных из-за блокировки: проектор работает в другом процессе или строку держит чужая транзакция. */
117
128
  skipped: number;
118
129
  /** Перестроек. */
119
130
  rebuilds: number;
131
+ /** Арендаторы, чья запись упиралась в чужую блокировку строки дольше lockTimeout (по порядку, без повторов). */
132
+ locked: string[];
120
133
  }
121
134
  /** Выбывший арендатор: база отклонила запись его результатов. */
122
135
  export interface ProjectorFailure {
@@ -168,12 +181,18 @@ export declare class Projector {
168
181
  /** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
169
182
  private blind;
170
183
  private stale;
184
+ /** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
185
+ private tenantsPending;
171
186
  private running;
172
187
  private stopped;
173
188
  private wake;
174
189
  private off;
175
190
  private readonly batch;
176
191
  private readonly pollMs;
192
+ private readonly lockMs;
193
+ private readonly lockRetries;
194
+ /** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
195
+ private readonly lockHits;
177
196
  private readonly only;
178
197
  constructor(env: ProjectorEnv, name: string, def: ProjectorOptions);
179
198
  /**
@@ -299,10 +318,13 @@ export declare class Projector {
299
318
  */
300
319
  private tenantChunk;
301
320
  /**
302
- * Автоматическая перестройка выбывших по cursor_expired (onExpired: 'rebuild') — проход run(): начинает перестройку
303
- * пачками у каждого такого арендатора и продвигает начатые на пачку. true — работа ещё осталась.
321
+ * Перестройка арендаторов пачками — проход run(): с start (onExpired: 'rebuild') начинает её у каждого выбывшего по
322
+ * cursor_expired, затем продвигает на пачку каждую начатую — автоматическую и прерванную rebuild({ tenant, chunked:
323
+ * true }). true — работа ещё осталась.
304
324
  */
305
325
  private tenantWork;
326
+ /** Повторять fn, пока строку держит чужая транзакция дольше lockTimeout (55P03): пауза 20 мс, проход — в skipped. */
327
+ private untilFree;
306
328
  /** Ждать сигнал источников, остановку или запасной опрос. */
307
329
  private sleep;
308
330
  }