letopis 1.0.0 → 1.1.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.0
1
+ # letopis 1.1
2
2
 
3
3
  **letopis** — библиотека для Node.js, хранилище версионированных сущностей на PostgreSQL 18. Каждое изменение
4
4
  сохраняется версией в журнале. Поэтому у любого объекта есть история, состояние на любую дату и подписка на
@@ -188,10 +188,22 @@ LOGIN-роль состоит только в ней. Всё, что выпол
188
188
 
189
189
  ### Сигналы
190
190
 
191
- Запись сообщает об изменённых классах через `NOTIFY`. Сырой SQL шлёт сигнал из триггера, и PostgreSQL доставляет
191
+ Запись сообщает об изменениях через `NOTIFY` в канал схемы. Сырой SQL шлёт сигнал из триггера, и PostgreSQL доставляет
192
192
  его при фиксации. Транзакции библиотеки шлют сигналы отложенно: нотификатор процесса отправляет их раз в 100 мс
193
- (`notifyIntervalMs`). В сигнале только имя класса или начало хэша токена погашенной сессии — ни id, ни данных.
194
- По сигналам перечитывается реестр классов, сбрасывается кэш результатов и будится подписка.
193
+ (`notifyIntervalMs`), а неотправленные из-за ошибки — повторяет. Что несёт сигнал записи, задаёт режим установки
194
+ `up({ signals })`:
195
+
196
+ | Режим | Сигнал записи | Что видит любая роль, которая может подключиться к базе |
197
+ |---|---|---|
198
+ | `class` (по умолчанию) | имя класса: `Booking` | имена классов и время записи |
199
+ | `tenant` | имя класса и токен арендатора: `Booking@3f9c…` | ещё и псевдонимы арендаторов |
200
+ | `opaque` | токен класса и токен арендатора | только время и частоту записи по псевдонимам |
201
+ | `none` | сигналов нет | ничего |
202
+
203
+ Погашенная сессия шлёт `session:<начало хэша токена>`, сброс — `*`; ни id, ни данных в сигналах нет. Токен — ключевой
204
+ HMAC: сопоставить псевдоним с именем может только процесс с ключом сигналов, а ключ выдаётся сервисам (право
205
+ `letopis.signals`). По сигналам перечитывается реестр классов, сбрасывается кэш результатов и будится подписка;
206
+ что меняет режим для кэша и подписки — в [разделе 12](#12-подписка-на-изменения-и-кэш).
195
207
 
196
208
  ## 3. Установка и подключение
197
209
 
@@ -210,6 +222,7 @@ LOGIN-роль состоит только в ней. Всё, что выпол
210
222
  | `history` | политика хранения истории для классов без своей ([раздел 11](#11-история-и-срезы-на-дату)) |
211
223
  | `serviceKey` | свой ключ сервиса вместо случайного: `lts_` и 32–128 шестнадцатеричных знаков |
212
224
  | `allowReset` | разрешить `db.reset()` ([раздел 13](#13-эксплуатация)); по умолчанию выключено |
225
+ | `signals` | режим сигналов: `'class'` (по умолчанию), `'tenant'`, `'opaque'` или `'none'` ([«Сигналы»](#сигналы)); режим хранится в схеме — `up()` без опции его не меняет |
213
226
  | `fresh` | удалить схему и поставить заново — **данные схемы теряются** |
214
227
  | `upgrade` | обновить схему старой ревизии: миграции и повторный накат |
215
228
  | `pglite` | база для разработки без PostgreSQL (см. ниже) |
@@ -218,7 +231,16 @@ LOGIN-роль состоит только в ней. Всё, что выпол
218
231
 
219
232
  Результат: `schema` (полное имя), `serviceKey` (при первой установке и при накате, когда в установке не осталось ни
220
233
  одного действующего ключа API; иначе `null`), `created`,
221
- `upgraded`, `revision`, `migrations`, `analyzed`, `owner`, `app`, а также `tenant` и `pglite`, если их просили.
234
+ `upgraded`, `revision`, `migrations`, `analyzed`, `owner`, `app`, `signals` (режим сигналов установки), `warnings`
235
+ (предупреждения, без `quiet` они печатаются), а также `tenant` и `pglite`, если их просили. На PostgreSQL ниже 18.2
236
+ `up()` предупреждает: такой сервер мог усечь CLOG, который ещё нужен непрочитанным сигналам, — обновите его.
237
+
238
+ Скрыть из сигналов имена классов — режим `opaque` (или `tenant`, если имена не секрет, а нужна точность кэша по
239
+ арендатору); после смены режима перезапустите процессы приложения — режим читается при подключении:
240
+
241
+ ```ts
242
+ await up({ dsn: ADMIN, schema: 'shop', signals: 'opaque' }) // дальше up({ dsn, schema }) режим не меняет
243
+ ```
222
244
 
223
245
  <!-- run: guide -->
224
246
  ```ts
@@ -467,8 +489,8 @@ id из email подбирается перебором и остаётся в
467
489
  `syncModels(db, модели)` без `apply` — сухой прогон, с `{ apply: true }` — применение. Результат:
468
490
  `{ applied, created, updated, unchanged, skipped, lost }`; в `skipped` — классы System, их меняет только установка.
469
491
  `syncModels` трогает только переданные классы и ничего не удаляет. Безопасные изменения — новое необязательное поле или
470
- конец, метаданные, алиас — применяются сразу. Ужесточение проверяет все строки семейства, и если им мешают строки,
471
- приходит `tightening_conflict` со списком. Классы пишутся в арендатора сессии, и другие арендаторы их не видят.
492
+ конец, метаданные, алиас — применяются сразу. Всё остальное, в том числе любая правка условий `anyOf` и `oneOf`, —
493
+ ужесточение: оно проверяет все строки семейства, и если им мешают строки, приходит `tightening_conflict` со списком. Классы пишутся в арендатора сессии, и другие арендаторы их не видят.
472
494
  Классы, общие для всех, лежат в System: их ставит сид установки или импорт.
473
495
 
474
496
  То же из командной строки:
@@ -1056,6 +1078,22 @@ await adm.load({ Category: [{ name: 'Посуда' }] }, { as: ANNA, tenant: SHO
1056
1078
  `ownerDefault`. Без `tenant` строки идут в собственного арендатора пользователя. Так процесс загрузки принимает
1057
1079
  файлы, присланные пользователями.
1058
1080
 
1081
+ ### Первая заливка
1082
+
1083
+ ```ts
1084
+ const r = await adm.load(rows, { initial: true }) // пустая схема: одна транзакция, индексы и триггеры — в конце
1085
+ ```
1086
+
1087
+ `db.load(rows, { initial: true })` (`letopis load --initial`) заливает данные в пустую схему быстрее обычного
1088
+ загрузчика — на миллионе объектов примерно в 2,2 раза: всё идёт одной транзакцией, на время загрузки таблицы закрыты
1089
+ для чтения и записи, индексы, ограничения и триггеры сняты и строятся заново в конце, строки пишутся `COPY … FREEZE`.
1090
+ Пустая — значит, кроме строк системных классов (описания классов, аккаунты, членства, ресурсы, правила) и записей
1091
+ `reset`, в схеме ничего нет; иначе — `not_empty` до любых изменений. Режим несовместим с `commitEvery`, `existing`
1092
+ (кроме `'error'`) и `{ as }`; ошибка любой строки откатывает всё, вместе с индексами и триггерами.
1093
+ `letopis import --initial` проводит так весь импорт: одной транзакцией, без `--update` и `--commit-every`. WAL пишется
1094
+ и здесь (без него — только при `wal_level = minimal`), но индексы строятся один раз, и его почти втрое меньше; после
1095
+ `FREEZE` первое чтение и первая очистка таблицу не переписывают.
1096
+
1059
1097
  ### Командная строка
1060
1098
 
1061
1099
  ```sh
@@ -1294,8 +1332,9 @@ const followed = await db.Product(mug2).versions({ follow: true }) // и чер
1294
1332
  ```
1295
1333
 
1296
1334
  У каждой версии — автор, агент, время, номер, вид операции (`create`, `update`, `delete`, `reclass`, `rekey`,
1297
- `restore`, `purge`, `trim`, `reset`) и причина (`reason`) — если её задала настройка `letopis.reason` транзакции;
1298
- отдельного API для причины нет. Версия удаления — с `$deleted`.
1335
+ `restore`, `purge`, `trim`, `reset`) и причина (`reason`). Причину задаёт опция `reason` глагола (`update(patch, { reason })`, `create(data, { reason })`,
1336
+ `delete({ confirm, reason })` и других), для всех операций блока — `db.begin({ reason })` и
1337
+ `db.batch(имя, { reason })`; сырой SQL — настройка `letopis.reason` транзакции. Версия удаления — с `$deleted`.
1299
1338
 
1300
1339
  ### Срез на дату
1301
1340
 
@@ -1339,6 +1378,31 @@ history: { all: '1 day', daily: '1 week', weekly: '1 month', monthly: '1 year',
1339
1378
  - В прореженной части срез показывает последнюю сохранённую версию до момента, то есть его точность равна
1340
1379
  периоду яруса. Подписчик, отставший дольше яруса `all`, получает `cursor_expired`.
1341
1380
 
1381
+ ### Что изменилось и кто: `diff` и `blame`
1382
+
1383
+ ```ts
1384
+ const [d] = await db.Order(id).diff() // предыдущая версия → последняя
1385
+ await db.Order(id).diff({ from: 3, to: 7 }) // по номерам версий
1386
+ await db.Order(id).diff({ from: '2026-10-01' }) // состояние на момент → последнее
1387
+ await db.Order(id).diff({ follow: true }) // через смену id (reclass, rekey)
1388
+ const [b] = await db.Order(id).blame() // каждое поле последней версии
1389
+ await db.Order(id).blame({ at: '2026-10-01', limit: 200 }) // версия на момент; просмотреть 200 версий
1390
+ ```
1391
+
1392
+ - `diff()` — по объекту шага: `{ id, class, from, to, changes }`, где изменение — `{ path, kind, before, after }`,
1393
+ `kind` — `added`, `removed` или `changed`, а путь начинается с раздела строки: `data`, `links`, `tags`, `owner`,
1394
+ `class`. Объекты данных сравниваются по ключам, массивы — целиком; множественный конец и теги — по элементам.
1395
+ Если на момент `from` объекта не было, все пути — `added`; если на момент `to` он удалён — `to.deleted: true`.
1396
+ - `blame()` — `{ id, class, rev, complete, fields }`: у каждого листа — версия, которая последней его изменила,
1397
+ `author`, `agent`, `op`, `reason` и `exact`. `exact: false` — между текущей версией и этой есть пропуски (история
1398
+ прорежена, обрезана или часть версий не видна): лист мог менять и выпавшая версия. `complete: true` — история
1399
+ просмотрена целиком и без пропусков; по умолчанию просматривается не больше 1000 последних версий.
1400
+ - Считает база с правами вызывающего: сырой SQL получает то же — `select "v2.shop".blame('<id>')`,
1401
+ `select "v2.shop".diff_versions('<id>')`.
1402
+ - Колонка журнала `changed` (пути изменения версии, по ним `blame` работает быстро) приложению не выдана: пути
1403
+ описывают разницу с предыдущей версией, а её читатель может не видеть. Поэтому `select * from log` от приложения
1404
+ не работает — колонки перечисляются явно; `blame` берёт пути функцией базы, только когда видны обе версии.
1405
+
1342
1406
  ### Обрезка истории
1343
1407
 
1344
1408
  <!-- run: guide -->
@@ -1403,11 +1467,19 @@ const stats = db.cache.stats() // { size, hits,
1403
1467
 
1404
1468
  - в ключе ответа — запрос, параметры, сессия, актор и арендатор, поэтому один пользователь не получит ответ другого;
1405
1469
  - свои записи сбрасывают ответы изменённых классов сразу после фиксации (с классами каскада), чужие — по сигналу
1406
- базы;
1470
+ базы. В режимах `tenant` и `opaque` процесс с ключом сигналов сбрасывает класс только в арендаторе записи (строки
1471
+ System — во всех); процесс без ключа — во всех арендаторах, а в режиме `opaque` любой сигнал сбрасывает у него
1472
+ весь кэш;
1407
1473
  - изменение прав и классов, запись через `db.sql`, погашенная сессия и переподключение слушателя сбрасывают весь
1408
1474
  кэш или ответы сессии;
1409
1475
  - срок по умолчанию — 60 секунд; внутри `db.begin()` кэш не работает;
1410
- - без `LISTEN` кэш выключен, а `connect({ cache: { ttlOnly: true } })` включает его только по сроку.
1476
+ - без `LISTEN` и в режиме `none` кэш выключен, а `connect({ cache: { ttlOnly: true } })` включает его только по сроку.
1477
+
1478
+ Реестр классов процесс перечитывает по сигналу об изменении классов сразу (одно перечитывание за раз), а реестр и
1479
+ ключ сигналов по `*`, по незнакомому токену и по нагрузке не своего режима (а без ключа в режиме `opaque` — по любому
1480
+ сигналу) — не чаще раза в 5 секунд: поток сигналов, в том числе поддельных, не превращается в поток запросов. В режиме `none` реестр раз в 5 секунд сверяет отпечаток классов, а подписка
1481
+ опрашивает журнал. Подписка просыпается только от своих классов и своего арендатора, а без ключа — от любого
1482
+ сигнала; события она в любом случае читает из журнала, поэтому ничего не теряет.
1411
1483
 
1412
1484
  Между процессами кэш устаревает на интервал отправки сигналов (`notifyIntervalMs`, 100 мс) плюс доставку;
1413
1485
  `db.flushSignals()` отправляет накопленные сигналы сразу. `db.cache.clear()` очищает кэш.
@@ -1452,8 +1524,9 @@ const model = db.describe() // { schema, tenant, classes: [...], metho
1452
1524
  const modelText = describeText(model) // то же текстом — для llms.txt приложения или подсказки ИИ-агенту
1453
1525
  ```
1454
1526
 
1455
- `db.describe()` отдаёт описание классов, доступных сессии: поля, концы, ключи, политики и метаданные. Из
1456
- командной строки — `letopis describe --format text --out llms.txt`.
1527
+ `db.describe()` отдаёт описание классов, доступных сессии: поля, концы, ключи, политики и метаданные, а также
1528
+ условия по нескольким полям `anyOf` и `oneOf` с учётом наследования, если они заданы. Из командной строки —
1529
+ `letopis describe --format text --out llms.txt`.
1457
1530
 
1458
1531
  ### Сырой SQL
1459
1532
 
@@ -1473,12 +1546,12 @@ const [{ n }] = await db.sql`select count(*)::int as n from "v2.shop".entity whe
1473
1546
  letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply]
1474
1547
  letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>]
1475
1548
  letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
1476
- [--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify]
1549
+ [--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify] [--initial]
1477
1550
  letopis verify --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--data]
1478
1551
  letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--batches <N>]
1479
1552
  letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
1480
1553
  letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
1481
- [--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>]
1554
+ [--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
1482
1555
  ```
1483
1556
 
1484
1557
  ### Наблюдение
@@ -1628,7 +1701,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1628
1701
  - методы цепочки: `then`, `entity`, `paths`, `rows`, `first`, `ids`, `count`, `limit`, `offset`, `sort`, `asOf`,
1629
1702
  `withDeleted`, `deep`, `exact`, `sum`, `avg`, `min`, `max`, `countBy`, `after`, `versions`, `create`, `update`,
1630
1703
  `upsert`, `set`, `unset`, `add`, `remove`, `delete`, `anonymize`, `reclass`, `restore`, `purge`, `alias`, `tags`,
1631
- `owner`, `forUpdate`, `cache`, `rekey`; охранники снесённого API — `run`, `execute`, `account`;
1704
+ `owner`, `forUpdate`, `cache`, `rekey`, `diff`, `blame`; охранники снесённого API — `run`, `execute`, `account`;
1632
1705
  - корень `db`: `as`, `begin`, `commit`, `rollback`, `lock`, `batch`, `watch`, `close`, `registry`, `sql`, `load`,
1633
1706
  `verify`, `maintain`, `reset`, `describe`, `auth`, `acl`, `idOf`, `refresh`, `transaction`, `flushSignals`,
1634
1707
  `schema`, `token`, `tenant`, `trimHistory`, `rekeyClass`, `cache`; охранники — `reloadSchema`, `accounts`,
@@ -1679,6 +1752,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1679
1752
  | `no_path` | между классами соседних шагов нет перехода |
1680
1753
  | `invalid_query` | цепочку или API используют неверно (модификатор шага `deep`, `exact`, `alias`, `tags` или `owner` после глагола, слот не после глагола, курсор `after()` со значением не того типа, что поле `sort`, без `v` или с `id` не uuid и т. п.) |
1681
1754
  | `admin_required` | загрузчику нужно администраторское подключение |
1755
+ | `not_empty` | первая заливка (`initial`) — только в пустую схему |
1682
1756
  | `class_not_loadable` | строки `Class` загрузчик не принимает |
1683
1757
  | `bypass_rls` | `connect()` с ролью, которая обходит RLS, без `allowBypassRls` |
1684
1758
  | `removed` | снесённое API 0.21; в тексте — замена |
@@ -1715,9 +1789,15 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1715
1789
  запись: 119 → 128–147 строк/с в разных прогонах). Построчный `create` остаётся медленнее 0.21 на 12–18 % (замеры
1716
1790
  колеблются): основное время — триггеры проверок в базе и фиксация с записью на диск.
1717
1791
 
1792
+ Первая заливка (`cd lib && npx tsx bench/initial-load.mjs`, 2026-10-11, медиана трёх кругов): 1,07 млн объектов
1793
+ полигона salon без истории обычный загрузчик пишет за 169 с (6 351 строка/с, WAL 4,0 ГБ), первая заливка — за 77 с
1794
+ (13 902 строки/с, WAL 1,4 ГБ). Главную разницу дают индексы GIN: поддерживать их во время загрузки стоит 36 с, построить
1795
+ в конце — около 5 с. Подготовка строк в самом загрузчике (проверки, концы, id) идёт одновременно с `COPY` предыдущей
1796
+ пачки и теперь задаёт скорость: около 60 с из 77.
1797
+
1718
1798
  ## 18. Ограничения
1719
1799
 
1720
- Здесь собрано то, что letopis 1.0 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
1800
+ Здесь собрано то, что letopis 1.1 сознательно не делает или делает с оговорками, и где за поведением нужно следить.
1721
1801
 
1722
1802
  **Платформа**
1723
1803
 
@@ -1732,6 +1812,9 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1732
1812
  с `prepare: false` в `connect({ sql })`.
1733
1813
  - Символ NUL (`\u0000`) в строках данных невозможен: PostgreSQL не хранит его в `jsonb`; такие данные
1734
1814
  отклоняются ещё при разборе JSON (официальный набор JSON-Schema-Test-Suite, группы «nul characters», пропущены).
1815
+ - На PostgreSQL до 18.6 параллельная сборка индекса GIN могла записать в статистику таблицы (`pg_class.reltuples`)
1816
+ неверное число строк, после чего автоочистка переставала её обрабатывать. Первая заливка поэтому всегда
1817
+ заканчивается `ANALYZE`, но сервер лучше обновить до 18.6 или новее.
1735
1818
 
1736
1819
  **Запись и целостность**
1737
1820
 
@@ -1742,6 +1825,10 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1742
1825
  библиотеки всегда открываются на этом уровне.
1743
1826
  - `COPY` в таблицы под RLS PostgreSQL не выполняет; массовая загрузка — только загрузчиком на администраторском
1744
1827
  подключении.
1828
+ - Первая заливка закрывает таблицы схемы на всё время загрузки — и для записи, и для чтения: она для пустой схемы
1829
+ (засевка, перенос, импорт), а не для работающей системы. Выигрыш у неё — в базе (индексы и ограничения строятся
1830
+ один раз); подготовку строк в загрузчике она не ускоряет, а только ведёт одновременно с `COPY`, поэтому на миллионе
1831
+ объектов она быстрее обычной загрузки примерно в 2,2 раза.
1745
1832
  - Загрузчику база доверяет: данные, концы и id загруженных строк проверяет код библиотеки (валидатор на TypeScript),
1746
1833
  а не триггеры; база сверяет только цели вне загрузки, арендаторов и владельцев одним запросом в конце.
1747
1834
  Защита — загрузчик только у администратора, дифференциальные тесты валидатора против базы, `load({ verify: true })`
@@ -1805,16 +1892,25 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1805
1892
  старше яруса `all` у класса с политикой `verify()` не отличит от прореживания.
1806
1893
  - Журнал без партиций: прореживание удаляет версии пачками, файл таблицы сам не уменьшается (место занимают новые
1807
1894
  версии после autovacuum); вернуть место системе — `VACUUM FULL` или `pg_repack` в окно обслуживания.
1808
- - Сигналы раскрывают активность: слушать канал PostgreSQL разрешает любой роли, и по именам классов в сигналах
1809
- видно, когда и в какие классы пишут (ни id, ни арендатора, ни данных в сигнале нет). Поддельный сигнал лишь
1810
- сбросит кэш лишний раз.
1895
+ - Сигналы раскрывают активность: слушать канал PostgreSQL разрешает любой роли. В режиме `class` видно, когда и в
1896
+ какие классы пишут; `tenant` добавляет псевдонимы арендаторов; `opaque` прячет, что записано, но не когда: время и
1897
+ частоту записи по псевдониму видно, а детерминированный токен — основа частотных атак. Время записи видно и без
1898
+ сигналов: `pg_stat_all_tables` и `pg_locks` открыты для PUBLIC. Режим `none` убирает сигналы, но тогда отзыв
1899
+ сессии действует в кэше других процессов только по сроку, а новый класс появляется в их реестре в течение
1900
+ 5 секунд. Настоящая защита от посторонних ролей — отдельная база. Поддельный сигнал лишь сбросит кэш лишний раз:
1901
+ по `*` и незнакомым токенам реестр перечитывается не чаще раза в 5 секунд, а поддельный сигнал изменения классов
1902
+ перечитывает его, как настоящий, но не больше одного перечитывания одновременно.
1903
+ - Режим сигналов меняется `up({ signals })`, и после этого нужно перезапустить процессы: режим и ключ сигналов
1904
+ процесс читает при подключении (нагрузку другого режима он считает чужой и сбрасывает весь кэш).
1905
+ - PostgreSQL ниже 18.2 мог усечь CLOG, который ещё нужен непрочитанным сигналам: `up()` предупреждает об этом.
1811
1906
  - Очередь `NOTIFY` (8 ГБ) общая для кластера: зависший слушатель её переполняет, и тогда транзакции с `NOTIFY`
1812
1907
  падают при фиксации. Библиотека читает сигналы сразу, `maintain()` предупреждает о заполнении больше чем
1813
1908
  наполовину.
1814
1909
  - Горизонт подписки общий для кластера: долгая пишущая транзакция задерживает события всех подписчиков
1815
1910
  (`sub.lag()`); длинные операции лучше вести пачками.
1816
1911
  - Кэш результатов между процессами устаревает на интервал отправки сигналов (100 мс) плюс доставку и на время
1817
- переподключения слушателя; срок `ttl` — предохранитель.
1912
+ переподключения слушателя: обрыв соединения слушателя postgres.js не сообщает, поэтому до переподключения кэш
1913
+ работает, а после — сбрасывается целиком. Срок `ttl` — предохранитель.
1818
1914
  - Курсор подписки «с этого момента» несёт снимок момента подписки, пока не пройдёт его границу: такой
1819
1915
  курсор длиннее обычного.
1820
1916
 
@@ -1845,7 +1941,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1845
1941
  # chain-write — запись цепочками: create, update, upsert, delete с превью, anonymize, reclass, слоты, inc, { rev }, fan-out, откат плана (этап 4)
1846
1942
  # demo — демо-домен booking на Zod: сид из моделей, запись данных, id по ключу, согласие Zod и базы (этап 2)
1847
1943
  # docs — примеры документации исполняются: помеченные блоки README и шпаргалки (русские и английские) — сценариями против PostgreSQL 18 (этап 8)
1848
- # engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными исходами (этапы 1, 3)
1944
+ # engine-attacks — атаки сырым SQL от прикладной роли: строки §7.3.1 этапов 1 и 3 с конкретными исходами; ключ сигналов без права, заливка поддельными сигналами, в том числе Class (этапы 1, 3; план 1.1, этап 2 и точка А)
1849
1945
  # engine-class — классы как сущности: метасхема, наследование, пересчёт потомков, ужесточение, метаданные (этап 1)
1850
1946
  # engine-write — конвейер записи сырым SQL: id v5, умолчания, merge, inc, удаление, воскрешение, журнал, сигналы (этап 1)
1851
1947
  # engine-races — гонки на двух соединениях в обоих порядках без пауз по времени (этап 1)
@@ -1859,7 +1955,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1859
1955
  # member — членство: работа в арендаторе по ролям, auth.switch, выход с переходом владения, системные строки, роли арендатора без прав System (этап 5)
1860
1956
  # model — модели на Zod, нормализатор JSON Schema, перевод шаблонов, совпадение с метасхемой базы (этап 2)
1861
1957
  # load — загрузчик: несколько классов, циклы, $key и $ext, повторы, existing, порции, режим истории, запрос по целям вне загрузки, CLI (этап 4)
1862
- # roles — роли и права: установка без суперпользователя, функции владельца, триггеры по каталогу (этап 1)
1958
+ # roles — роли и права: установка без суперпользователя, функции владельца, триггеры по каталогу, журнал без колонки changed (этап 1; план 1.1, точка А)
1863
1959
  # write-races — гонки записи: 20 параллельных правок разных полей без потерь и строгий rev, параллельные add/remove множественного конца, загрузчик против ужесточения класса и против параллельной правки (existing: update) в обоих порядках (этап 4)
1864
1960
  # validator-parity — валидатор на TypeScript против проверки в базе: официальный набор, 10 000 случайных документов, умолчания (этап 2)
1865
1961
  # scenario-barbershop — сквозной сценарий барбершопа: 9 сцен 0.21 (integration) на цепочках 1.0 — каталог, окна батчем, брони под lock, мульти-слот и мульти-мастер, версии, отмена и бронь заново на тот же id, закрытие месяца при restrict (этап 4)
@@ -1875,11 +1971,14 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
1875
1971
  # verify — законные разрывы и подмены, verify({ data: true }): данные, концы, арендаторы, id по ключу; права и CLI (этап 6)
1876
1972
  # retention — политика хранения: ярусы, конечный срок, особые версии, удалённые, asOf, cursor_expired, часовой пояс, догоняющий запуск, наследование и умолчание — против эталона на TypeScript (этап 6)
1877
1973
  # watch — подписка по курсору: порядок фиксации, долгая транзакция, возобновление, обрыв слушателя, два арендатора (этап 6)
1878
- # cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql (этап 6)
1974
+ # cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков сигналов (этап 6; план 1.1, точка А)
1879
1975
  # maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6)
1880
1976
  # up — полный установщик: свежая установка, повторный накат, свои сиды, автосоздание базы, UTF8, версия 17, upgrade с миграциями, PGlite, первый арендатор и пользователь (up({ tenant }), createTenant) (этапы 6, 8)
1881
1977
  # scenario-salon — сквозной сценарий салона: 21 акт 0.21 (salon) с матрицей покрытия API 1.0 (этап 6)
1882
1978
  # telemetry — onQuery и slowMs, describe() и метаданные классов с наследованием, describeText (этап 6)
1979
+ # history — diff и blame по журналу, причина изменения у глаголов, db.begin() и батча; версия, скрытая условием ACL, колонка changed, пропуск версии, row_changes на 20 тыс. целей (план 1.1, этап 1 и точка А)
1980
+ # signals — режимы сигналов: нагрузка у посторонней роли, точность кэша до арендатора, System, классы прав, процесс без ключа, загрузчик, подписка, ключ, none, склейка, версия сервера, смена класса, повтор отправки, режим после up() и reset({ all }) (план 1.1, этап 2)
1981
+ # initial-load — первая заливка: равенство с обычным загрузчиком, структура и режимы триггеров, системные строки, непустая схема, вставка во время запуска, откат, MULTI_INSERT в WAL, commitEvery, import --initial (план 1.1, этап 3)
1883
1982
  # import — импорт из 0.21 по фикстуре настоящей 0.21: нарушение ключа, --keyless, счётчики по классам, verify, самоссылка, повтор, id без ключа, висячая ссылка, $$strict и поля на $, update, креды, CLI (этап 7)
1884
- # security-final — итоговое ревью безопасности (этап 8, §7.10 №8.4): блокировка семейства по арендатору корня, account_purge без права и restore/purge чужого id не дают ни утечки, ни блокировки; функции этапа 6, log_refs_at, срезы find_* по журналу и watch_read не выходят за свой арендатор; NOTIFY несёт только имена; ключ кэша различает токен, актора и арендатора (этап 8)
1983
+ # security-final — итоговое ревью безопасности (этап 8, §7.10 №8.4): блокировка семейства по арендатору корня, account_purge без права и restore/purge чужого id не дают ни утечки, ни блокировки; функции этапа 6, log_refs_at, срезы find_* по журналу и watch_read не выходят за свой арендатор; NOTIFY несёт только имена или токены режима; ключ кэша различает токен, актора и арендатора (этап 8)
1885
1984
  ```
package/dist/admin.d.ts CHANGED
@@ -72,6 +72,10 @@ export interface ModelDescription {
72
72
  system: boolean;
73
73
  fields: Record<string, unknown>;
74
74
  required: string[];
75
+ /** Условия по нескольким полям (`fields.anyOf` описания с учётом наследования): хотя бы одно должно выполняться. */
76
+ anyOf?: unknown[];
77
+ /** Условия по нескольким полям (`fields.oneOf`): ровно одно должно выполняться. */
78
+ oneOf?: unknown[];
75
79
  ends: Record<string, unknown>;
76
80
  key: string[];
77
81
  history: unknown;
package/dist/admin.js CHANGED
@@ -134,6 +134,8 @@ export function describe(a) {
134
134
  system: SYSTEM_CLASSES.has(c.name),
135
135
  fields: (c.schema.properties ?? {}),
136
136
  required: (c.schema.required ?? []),
137
+ ...(Array.isArray(c.schema.anyOf) ? { anyOf: c.schema.anyOf } : {}),
138
+ ...(Array.isArray(c.schema.oneOf) ? { oneOf: c.schema.oneOf } : {}),
137
139
  ends: c.ends,
138
140
  key: c.key,
139
141
  history: c.history,
@@ -158,6 +160,10 @@ export function describeText(d) {
158
160
  });
159
161
  if (fields.length)
160
162
  out.push(`- поля: ${fields.join(', ')}`);
163
+ if (c.anyOf)
164
+ out.push(`- условия anyOf (хотя бы одно): ${JSON.stringify(c.anyOf)}`);
165
+ if (c.oneOf)
166
+ out.push(`- условия oneOf (ровно одно): ${JSON.stringify(c.oneOf)}`);
161
167
  const ends = Object.entries(c.ends).map(([k, v]) => {
162
168
  const e = v;
163
169
  return `${k}${e.required ? '*' : ''} → ${Array.isArray(e.class) ? e.class.join('|') : e.class}${e.many ? '[]' : ''}`;
package/dist/cache.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Signals, type Signal } from './signals.js';
1
2
  /** Классы, строки которых меняют права и схему: их сигнал сбрасывает весь кэш. */
2
3
  export declare const ACL_CLASSES: ReadonlySet<string>;
3
4
  export interface CacheOptions {
@@ -25,14 +26,22 @@ export interface CacheStats {
25
26
  /** Снимок счётчиков до запроса: ответ кладётся, только если они не изменились. */
26
27
  export interface CacheToken {
27
28
  epoch: number;
28
- classes: [string, number][];
29
+ /** Класс, его счётчик и счётчик пары «класс, арендатор» (без арендатора — сигналов класса в любом). */
30
+ classes: [string, number, number][];
29
31
  session: number;
32
+ /** Токен арендатора запроса; null — ответ сбрасывается сигналом класса из любого арендатора. */
33
+ tenant: string | null;
30
34
  }
31
35
  /** Начало sha256 токена — так база называет сессию в сигнале session:<16 hex>. */
32
36
  export declare const sessionTag: (token: string | null) => string;
33
37
  export declare class ResultCache {
38
+ private sig;
39
+ private allTenants;
34
40
  private map;
35
41
  private classEpoch;
42
+ /** Сигналы класса в арендаторе: по паре и по классу в любом арендаторе (для ответов без арендатора). */
43
+ private pairEpoch;
44
+ private anyEpoch;
36
45
  private sessionEpoch;
37
46
  private epoch;
38
47
  private listening;
@@ -40,7 +49,11 @@ export declare class ResultCache {
40
49
  readonly ttlMs: number;
41
50
  readonly ttlOnly: boolean;
42
51
  private max;
43
- constructor(opts?: CacheOptions);
52
+ /**
53
+ * sig — режим сигналов и ключ подключения (разбор нагрузки, токены арендаторов); allTenants —
54
+ * подключение видит строки всех арендаторов (обходит RLS): ответ сбрасывается сигналом из любого.
55
+ */
56
+ constructor(opts?: CacheOptions, sig?: Signals, allTenants?: boolean);
44
57
  /** Кэш работает: слушатель на месте или режим только по сроку. */
45
58
  get enabled(): boolean;
46
59
  /** Ключ ответа: запрос, параметры и тот, кто спрашивает. */
@@ -49,24 +62,35 @@ export declare class ResultCache {
49
62
  account?: string;
50
63
  tenant?: string | null;
51
64
  }): string;
52
- /** Снимок счётчиков классов и сессии — до запроса. */
53
- token(classes: readonly string[], session: string): CacheToken;
65
+ /** Снимок счётчиков классов, пар с арендатором запроса (id) и сессии — до запроса. */
66
+ token(classes: readonly string[], session: string, tenantId?: string | null): CacheToken;
67
+ private pairCount;
54
68
  get(key: string): unknown[] | undefined;
55
69
  /** Положить ответ, если с момента снимка не было сигналов о его классах и сессии. */
56
70
  set(key: string, value: unknown[], classes: readonly string[], session: string, tok: CacheToken, ttlMs: number, sessionExpires: number | null): boolean;
57
- /** Сбросить ответы, читавшие эти классы. */
71
+ /** Сбросить ответы, читавшие эти классы (во всех арендаторах). */
58
72
  invalidate(classes: Iterable<string>): void;
73
+ /** Сбросить ответы класса в арендаторе (токен) и ответы без арендатора; null — во всех. */
74
+ private invalidateIn;
59
75
  /** Сбросить ответы сессии (тег — начало sha256 токена). */
60
76
  invalidateSession(tag: string): void;
77
+ /**
78
+ * Счётчик сигналов по ключу (класс, пара «класс, арендатор», сессия). Ключи приходят в нагрузке сигнала, а её
79
+ * может прислать любая роль базы: при переполнении карта очищается, и снимки в полёте устаревают целиком
80
+ * (epoch), — иначе заливка разными именами копила бы счётчики без предела (ревью №1 точки А плана 1.1).
81
+ */
82
+ private bump;
61
83
  /** Весь кэш. */
62
84
  clear(): void;
85
+ /** Сброс по разобранному сигналу. */
86
+ apply(s: Signal): void;
63
87
  /**
64
- * Имена из сигнала или из letopis.changed своей транзакции: классы, `*`, `session:<тег>`,
65
- * `session:*`. raw — запись через db.sql: весь кэш.
88
+ * Нагрузки из letopis.changed своей транзакции (те же, что уйдут сигналом). raw — запись через db.sql:
89
+ * весь кэш.
66
90
  */
67
91
  changed(names: Iterable<string>, raw?: boolean): void;
68
- /** Сигнал от базы. */
69
- signal(name: string): void;
92
+ /** Сигнал от базы: нагрузка или уже разобранный. */
93
+ signal(s: string | Signal): void;
70
94
  /** Слушатель (пере)подключился: сигналы за время обрыва потеряны — весь кэш. */
71
95
  listen(): void;
72
96
  stats(): CacheStats;