letopis 1.4.0 → 1.4.1

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.
Files changed (52) hide show
  1. package/AGENT-CHEATSHEET.en.md +10 -5
  2. package/AGENT-CHEATSHEET.md +14 -9
  3. package/CHANGELOG.md +159 -0
  4. package/README.en.md +119 -52
  5. package/README.md +107 -47
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +42 -16
  9. package/dist/auth.d.ts +61 -17
  10. package/dist/auth.js +16 -2
  11. package/dist/cache.d.ts +5 -1
  12. package/dist/chain.d.ts +91 -15
  13. package/dist/chain.js +47 -4
  14. package/dist/cli.js +6 -0
  15. package/dist/errors.d.ts +2 -2
  16. package/dist/errors.js +5 -5
  17. package/dist/import.js +7 -0
  18. package/dist/index.d.ts +74 -45
  19. package/dist/index.js +12 -5
  20. package/dist/load.d.ts +10 -3
  21. package/dist/load.js +19 -4
  22. package/dist/mcp.d.ts +20 -11
  23. package/dist/mcp.js +4 -4
  24. package/dist/model.d.ts +4 -1
  25. package/dist/ops.d.ts +58 -18
  26. package/dist/ops.js +58 -18
  27. package/dist/project.d.ts +30 -11
  28. package/dist/project.js +30 -4
  29. package/dist/registry.d.ts +4 -1
  30. package/dist/registry.js +4 -1
  31. package/dist/sql.d.ts +4 -1
  32. package/dist/sql.js +12 -4
  33. package/dist/tx.js +10 -1
  34. package/dist/types.d.ts +14 -7
  35. package/dist/types.js +13 -6
  36. package/dist/up.d.ts +48 -25
  37. package/dist/up.js +89 -41
  38. package/dist/validate.d.ts +9 -2
  39. package/dist/validate.js +19 -3
  40. package/dist/watch.d.ts +17 -5
  41. package/dist/watch.js +40 -8
  42. package/dist/write.d.ts +3 -2
  43. package/dist/write.js +29 -10
  44. package/package.json +1 -1
  45. package/sql/10-core.sql +6 -1
  46. package/sql/60-write.sql +38 -18
  47. package/sql/80-auth.sql +39 -2
  48. package/sql/88-derived.sql +1 -0
  49. package/sql/90-time.sql +15 -5
  50. package/sql/95-seed.system.sql +6 -3
  51. package/sql/99-revision.sql +1 -1
  52. package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/README.md CHANGED
@@ -129,7 +129,7 @@ await svc.close()
129
129
  уменьшил цену внутри оператора `UPDATE`, без предварительного чтения. `versions()` прочитал историю из журнала.
130
130
 
131
131
  Повторный вызов `up()` безопасен: он повторно накатывает ту же ревизию и данные не трогает, а ключ сервиса снова не
132
- выдаёт, пока в установке есть действующий ключ API. Дальше раздел 2 объясняет устройство, а разделы 3–13 разбирают задачи по порядку.
132
+ выдаёт, пока в установке есть хоть один ключ API, кроме отозванных. Дальше раздел 2 объясняет устройство, а разделы 3–13 разбирают задачи по порядку.
133
133
 
134
134
  ## 2. Как устроен letopis
135
135
 
@@ -239,11 +239,16 @@ LOGIN-роль состоит только в ней. Всё, что выпол
239
239
  | `owner`, `app` | имена ролей владельца и приложения (по умолчанию `letopis_owner`, `letopis_app`) |
240
240
 
241
241
  Результат: `schema` (полное имя), `serviceKey` (при первой установке и при накате, когда в установке не осталось ни
242
- одного действующего ключа API; иначе `null`), `created`,
242
+ одного ключа API, кроме отозванных: истёкший или выключенный ключ тоже в счёт; иначе `null`), `created`,
243
243
  `upgraded`, `revision`, `migrations`, `analyzed`, `owner`, `app`, `signals` (режим сигналов установки), `warnings`
244
244
  (предупреждения, без `quiet` они печатаются), а также `tenant` и `pglite`, если их просили. На PostgreSQL ниже 18.2
245
245
  `up()` предупреждает: такой сервер мог усечь CLOG, который ещё нужен непрочитанным сигналам, — обновите его.
246
246
 
247
+ Несколько экземпляров сервиса могут звать `up()` одной схемы одновременно: накат идёт под блокировкой схемы. Первый
248
+ `up()` ставит или обновляет её, остальные ждут и делают повторный накат по готовой (`created: false`,
249
+ `upgraded: false`); первого арендатора `tenant` заводит один из них, остальные находят его по имени. `up()` разных
250
+ схем одной базы друг друга не ждут.
251
+
247
252
  Скрыть из сигналов имена классов — режим `opaque` (или `tenant`, если имена не секрет, а нужна точность кэша по
248
253
  арендатору); после смены режима перезапустите процессы приложения — режим читается при подключении:
249
254
 
@@ -285,7 +290,9 @@ const ANNA = r.tenant!.user! // его первый пользовател
285
290
  Повтор с теми же именем и логином ничего не дублирует и пароль не меняет, только выдаёт новую сессию. Логин входит в
286
291
  id как есть: `Anna@shop.ru` и `anna@shop.ru` дали бы два аккаунта, и у второго не было бы пароля, — пишите логин в
287
292
  одном регистре. Поле `created` в ответе — создан ли арендатор этим вызовом: для существующего арендатора с новым
288
- пользователем оно `false`. Роли задаёт `user.roles` — непустой список имён (не масок), иначе `invalid_data`. Роли
293
+ пользователем оно `false`. Роли задаёт `user.roles` — непустой список имён (не масок), иначе `invalid_data`. Вид
294
+ пароля `user.kind` (по умолчанию `PASSWORD`) подчиняется правилу вида креда, как у `auth.setPassword`
295
+ ([«Справочник API»](#dbauth)), иначе — тоже `invalid_data`. Роли
289
296
  уже существующего членства повтор не меняет: их меняет правка членства ([«Выдать право»](#выдать-право)).
290
297
  Удалённое повтор не воскрешает: если арендатор, аккаунт пользователя или членство удалены (строки нет, а история в
291
298
  журнале есть), `createTenant` отвечает `invalid_data` и не записывает ничего. Текст ошибки называет удалённое и
@@ -356,14 +363,18 @@ const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token
356
363
 
357
364
  ### Обновление пакета и схемы
358
365
 
359
- У схемы есть ревизия движка (сейчас 6). Если новая версия пакета принесла новую ревизию, `connect()` только
366
+ У схемы есть ревизия движка (сейчас 7). Если новая версия пакета принесла новую ревизию, `connect()` только
360
367
  предупредит и назовёт команду. Отказывает `up()`: на отставшую схему без `upgrade: true` и на схему новее пакета.
368
+ К схеме новее пакета (её уже обновил более новый пакет, а старые экземпляры приложения ещё работают) `connect()`
369
+ подключается с предупреждением «движок схемы … ревизии N новее библиотеки (она знает M) — обновите пакет letopis».
361
370
  Поэтому скрипт запуска, который вызывает `up()` при каждом старте, после обновления пакета остановится, пока не
362
371
  получит `upgrade: true`. `up({ dsn, schema, upgrade: true })` применяет миграции из `sql/migrate/` и повторно
363
- накатывает функции; данные сохраняются.
372
+ накатывает функции; данные сохраняются. Ревизия 7 (1.4.1) добавляет внешний ключ `secret.account`: миграция `0007`
373
+ сначала стирает строки `secret`, чьих аккаунтов уже нет (креды удалённых раньше аккаунтов хранились с пометкой
374
+ `deleted_at`), и ставит ключ.
364
375
 
365
- Ревизия меняется не с каждой версией: у 1.1–1.4 она одна, а функции разные. Поэтому `up()` пишет в комментарий схемы
366
- ещё и штамп движка: версию пакета и хэш его файлов SQL. Если штамп не совпадает с файлами пакета, `connect()`
376
+ Ревизия меняется не с каждой версией: у 1.1–1.4.0 она одна (6), а функции разные. Поэтому `up()` пишет в комментарий
377
+ схемы ещё и штамп движка: версию пакета и хэш его файлов SQL. Если штамп не совпадает с файлами пакета, `connect()`
367
378
  предупреждает. Когда схему накатила более старая версия (или версия до 1.4, у которой штампа нет), он советует
368
379
  `up({ schema })`. Когда схему уже обновила более новая версия, он советует обновить пакет: `up()` старой версией вернул
369
380
  бы прежние функции. Новая версия пакета без правок SQL штамп не меняет, и предупреждения нет.
@@ -376,8 +387,8 @@ const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token
376
387
 
377
388
  Обновление схем 1.1.0 и 1.0.1 проверяет тест `upgrade`. Схему ставит прежний пакет, данные пишет прежний движок, затем
378
389
  вызывается `up()` текущей версии. После него версии и хэши прежнего движка на месте, `verify({ data: true })` чист, а
379
- каталог схемы совпадает с чистой установкой. Схема 1.1.0 обновляется простым `up()`: ревизия у неё та же. Схема 1.0.1
380
- обновляется через `up({ upgrade: true })`.
390
+ каталог схемы совпадает с чистой установкой. Обе схемы обновляются через `up({ upgrade: true })`: 1.1.0 (ревизия 6) —
391
+ миграцией `0007`, 1.0.1 (ревизия 5) — миграциями `0006` и `0007`.
381
392
 
382
393
  ### Разработка без PostgreSQL: PGlite
383
394
 
@@ -602,8 +613,9 @@ npx letopis types --dsn "$APP" --schema v2.shop --token "$TOKEN" --out types.ts
602
613
  ### Имена классов
603
614
 
604
615
  Шаг цепочки называется именем класса, его алиасом или именем роли. Имена методов цепочки и корня (`count`, `sort`,
605
- `set`, `sql`, `auth` и другие) Proxy разбирает раньше классов. Поэтому класс с таким именем недостижим как шаг, а
606
- связь с таким именем база не примет. Полный список — в разделе
616
+ `set`, `sql`, `auth` и другие) там, где такой метод есть, библиотека разбирает раньше классов: класс `count` доступен
617
+ первым шагом `db.count(…)`, но не после другого шага. Связь с таким именем база не примет. Не давайте классам эти
618
+ имена; полный список — в разделе
607
619
  [«Зарезервированные имена»](#зарезервированные-имена).
608
620
 
609
621
  ## 5. Запись
@@ -657,7 +669,8 @@ await db.Shop(shop).Product({ price: gte(2000) }).update({ title: 'Чайник
657
669
  `db.Product().update(…)` правит все товары арендатора. Шаг ничего не нашёл — ответ пустой и ошибки нет, поэтому
658
670
  проверяйте длину ответа. Если `update` затронул меньше строк, чем нашёл шаг, весь план откатывается: строку успели
659
671
  удалить — `target_not_found`, строка видна, но править её нельзя — `acl_denied`. Модификатор `.tags(…)` перед
660
- `update` и отбирает строки, и заменяет их теги целиком.
672
+ `update` отбирает строки; строка или список ещё и заменяют их теги целиком, а оператор (`has`, `hasAny`, `hasAll`)
673
+ только отбирает — теги не меняются.
661
674
 
662
675
  ### Создать или заменить: `upsert`
663
676
 
@@ -789,8 +802,14 @@ await db.Order(order).line().create({ qty: 1, price: 900 }).Product.set(sencha).
789
802
  - Весь план — одна транзакция уровня `read committed`. При взаимной блокировке (`40P01`), ошибке сериализации
790
803
  (`40001`) и конфликте журнала библиотека повторяет план целиком, до трёх раз.
791
804
  - Ошибка любого звена откатывает весь план. `exists` и `conflict` не повторяются: это ответы для приложения.
792
- - Модификаторы до глагола (`limit`, `sort`) сужают его цели.
805
+ - Модификаторы до глагола (`limit`, `offset`, `sort`) сужают его цели.
806
+ - Модификатор после глагола относится к тому, что идёт за ним: к шагу класса, к `versions()`, `diff()`, `blame()` и
807
+ агрегатам или к целям следующего глагола. Если за ним сразу терминал `rows`, `first`, `ids`, `count` или `paths`,
808
+ сужать уже нечего — глагол прошёл по всем целям шага, — поэтому `db.Product().update(…).limit(1).rows()` даёт
809
+ `invalid_query` и ничего не пишет. В батче так же отказывает `run()`.
793
810
  - Глагол сразу после глагола пишет в те же сущности.
811
+ - Строки ответа глагола идут в порядке его целей: по `sort()` до глагола, без него — по id; `first()` после глагола —
812
+ первая по этому порядку.
794
813
 
795
814
  ## 6. Чтение
796
815
 
@@ -834,7 +853,7 @@ const roots = await db.Category({ Parent: null }).rows() // корневы
834
853
  | `ne(v)`, `gt(v)`, `gte(v)`, `lt(v)`, `lte(v)` | ≠ (с учётом `null`), >, ≥, <, ≤ |
835
854
  | `between(a, b)`, `inList([…])` | a ≤ x ≤ b; значение из списка |
836
855
  | `like(s)`, `ilike(s)`, `starts(s)`, `ends(s)` | шаблон с `%` и `_`; без учёта регистра; начинается с; заканчивается на |
837
- | `has(v)`, `hasAny([…])`, `hasAll([…])` | поле-массив содержит значение; хотя бы одно; все |
856
+ | `has(v)`, `hasAny([…])`, `hasAll([…])` | поле-массив содержит значение; хотя бы одно; все (`hasAll([])` подходит любой строке, `hasAny([])` — никакой) |
838
857
  | `exists(true \| false)`, `isNull()` | поле есть или нет; поле `null` или отсутствует |
839
858
  | `not(v)` | отрицание оператора; скаляр — «не равно» |
840
859
  | `or(f1, f2, …)` | любой из фильтров шага |
@@ -904,7 +923,9 @@ const byStatus = await db.Order().countBy('data.status') // { new: 1 }
904
923
  ```
905
924
 
906
925
  Агрегаты считает база, по сущностям последнего шага. `limit(n)` и `offset(n)` перед `countBy()` относятся к группам:
907
- `db.Order().limit(3).countBy('data.status')` — три самых частых значения.
926
+ `db.Order().limit(3).countBy('data.status')` — три самых частых значения. У логического поля `min()` — `false`, если
927
+ есть хоть одно `false` (все ли значения `true`), а `max()` — `true`, если есть хоть одно `true`; `sum()` и `avg()`
928
+ логическое поле не считают — PostgreSQL отвечает ошибкой.
908
929
 
909
930
  ### Пути
910
931
 
@@ -936,10 +957,11 @@ try {
936
957
 
937
958
  `delete()` без `{ confirm: true }` — только превью: всё замыкание каскада. В каждой строке превью `$action`:
938
959
  `delete`, `unset` или `restrict`, и `$depth` — уровень каскада (0 — сама цель; у строк `unset` и `restrict` его
939
- нет). С подтверждением удаляются все цели
940
- шага одним оператором, а каскад применяет правила концов. Превью не учитывает права: строка с `$action: 'delete'`
941
- может не удалиться у пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить
942
- `acl.checkData(класс, 'DELETE')`. Правила концов:
960
+ нет). С подтверждением удаляются все цели шага одним оператором, а каскад применяет правила концов. Итог —
961
+ надгробия по уровням каскада, внутри уровня по id (в таком порядке они и в журнале); цели классов без журнала
962
+ (`history: false`) — в конце. Превью не учитывает права: строка с `$action: 'delete'` может не удалиться у
963
+ пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить `acl.checkData(класс, 'DELETE')`.
964
+ Правила концов:
943
965
 
944
966
  - `restrict` — удаление запрещено, пока на цель ссылаются (`delete_restricted` со списком видимых строк и числом
945
967
  остальных);
@@ -1389,8 +1411,10 @@ const viaTelegram = await svc.auth.lookup({ kind: 'TELEGRAM', identifier: '10050
1389
1411
  <!-- run: guide -->
1390
1412
  ```ts
1391
1413
  const shortToken = await svc.auth.sessionFor(BORIS, { ttl: 600 }) // сессия на 10 минут простоя — для другого процесса
1414
+ const whose = await svc.auth.session(shortToken) // { account, tenant, member, expiresAt, loginAt, methods }
1392
1415
  const boris = await connect({ dsn: APP, schema: r.schema, token: shortToken })
1393
1416
  await boris.auth.revoke() // выход: сессия погашена
1417
+ const ended = await svc.auth.session(shortToken) // null
1394
1418
  await boris.close()
1395
1419
  ```
1396
1420
 
@@ -1403,6 +1427,15 @@ await boris.close()
1403
1427
  старые сессии того же вида входа без отметки ключа); текущая сессия остаётся, если не передать
1404
1428
  `keepCurrent: false`.
1405
1429
 
1430
+ Сервис, у которого есть токен пользователя (например, в cookie), узнаёт о сессии через `svc.auth.session(token)`:
1431
+ подключение по этому токену не открывается, срок сессии не продлевается. Ответ — чья сессия (`account`), в каком
1432
+ арендаторе она работает (`tenant`: выбранный `auth.switch()` или собственный арендатор аккаунта), цело ли там членство
1433
+ (`member`; в собственном арендаторе — всегда `true`), когда она истечёт (`expiresAt`), когда и как был вход
1434
+ (`loginAt`, `methods`: `PASSWORD`, `APIKEY`, `SERVICE`, `TOTP` …). Неверный, погашенный или истёкший токен и
1435
+ отключённый аккаунт — `null`, причина не различается. Так веб-сервис проверяет сессию в начале каждого запроса:
1436
+ чтение по погашенной сессии ошибки не даёт, ответы просто пустые. Вызывает метод сервис или системный администратор
1437
+ (право `auth.session`); пользователю отвечают `acl_denied`.
1438
+
1406
1439
  ### Работа в другом арендаторе
1407
1440
 
1408
1441
  Сервис действует от имени пользователя через `svc.as(account, { tenant, owner })`. База проверяет право сервиса
@@ -1510,7 +1543,14 @@ const canReport = await asOleg.acl.check('shop.reports.daily') // { allow:
1510
1543
 
1511
1544
  `db.auth.purgeAccount(аккаунт)` удаляет аккаунт и данные его арендатора. Вызывать может системный администратор или
1512
1545
  владелец его арендатора; себя и последнего владельца арендатора удалить нельзя. Владение строками в чужих
1513
- арендаторах переходит этим арендаторам, сессии гаснут, креды удаляются.
1546
+ арендаторах переходит этим арендаторам, сессии гаснут, креды удаляются. Владелец может стереть и арендатора, в
1547
+ котором сейчас работает: его сессия вернётся в личный арендатор.
1548
+
1549
+ Системный администратор может удалить и саму строку аккаунта — `db.Account(id).delete({ confirm: true })` в
1550
+ арендаторе System; данные арендатора аккаунта при этом остаются. Любой путь сразу стирает строки аккаунта в `secret`:
1551
+ сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (внешний ключ `secret.account` с каскадом,
1552
+ ревизия 7). Сессии гаснут до этого с сигналом, и процессы сбрасывают кэш ответов этих сессий. Аккаунт System
1553
+ не удаляется (`acl_denied`): ему принадлежат служебные строки установки в `secret`.
1514
1554
 
1515
1555
  ## 11. История и срезы на дату
1516
1556
 
@@ -1671,7 +1711,7 @@ const sub = db.watch({ from: 'start', classes: ['Product'] }) // весь жу
1671
1711
  await db.Product(kettle).update({ price: 2100 }).rows()
1672
1712
  let cursor = ''
1673
1713
  for await (const ev of sub) { // ev: { cursor, row, id, class, op, rev, at }
1674
- cursor = ev.cursor // сохраните курсор: watch({ from: cursor }) продолжит после него
1714
+ cursor = sub.cursor! // курсор подписки: watch({ from: cursor }) продолжит после этого события
1675
1715
  if (ev.id === kettle.id && ev.row.data.price === 2100) break // break (или sub.close()) — остановить
1676
1716
  }
1677
1717
  ```
@@ -1689,10 +1729,13 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
1689
1729
  порядке записи. Это не порядок фиксации: версии одного объекта сверяйте по `rev`.
1690
1730
  - Долгая пишущая транзакция в любой базе кластера задерживает поток: `await sub.lag()` показывает отставание в мс.
1691
1731
  - Курсор старше яруса `all` политики хранения — `cursor_expired`: перечитайте текущее состояние и подпишитесь
1692
- с `'now'`.
1732
+ с `'now'`. Время в курсоре подписки `sub.cursor` — время последнего события, а когда подписка догнала журнал, —
1733
+ время опроса, поэтому подписка, к которой сутки не было событий, не устаревает. Курсор события `ev.cursor` хранит
1734
+ время самого события.
1693
1735
  - Без `LISTEN` (`connect({ listen: false })`) подписка опрашивает журнал.
1694
- - Подписка без потерь — это сохранённый курсор: храните `ev.cursor` последнего обработанного события и продолжайте
1695
- с него после перезапуска.
1736
+ - Подписка без потерь — это сохранённый курсор: обработав событие, храните `sub.cursor` (позиция после последнего
1737
+ выданного события) и продолжайте с него после перезапуска. С `ev.cursor` поток тоже продолжится, но после затишья
1738
+ дольше яруса `all` такой курсор старше яруса, и продолжение с него — `cursor_expired`.
1696
1739
 
1697
1740
  ### Проекторы
1698
1741
 
@@ -1774,7 +1817,9 @@ sales.close() // stop() после текущ
1774
1817
  `out.inc(класс, строка, приращения, { tenant })` создаёт строку или дополняет её и прибавляет числа, не читая
1775
1818
  строку (поля, которых нет, считаются от 0). `out.delete(класс, ключ или id, { tenant })` удаляет строку, а если её
1776
1819
  нет, ничего не делает. `await out.get(класс, ключ или id, { tenant })` читает текущую строку вместе с записями
1777
- пачки. `out` пишет только в классы `into` и только в арендатора изменений, переданных в `handle`; иначе —
1820
+ пачки. Строку класса с ключом `delete` и `get` находят по полям ключа (`{ day: '2026-10-29' }`), а по id (строкой
1821
+ или `{ $id }`) — только строку класса без ключа; один id у класса с ключом — `invalid_data`, и пачка откатывается.
1822
+ `out` пишет только в классы `into` и только в арендатора изменений, переданных в `handle`; иначе —
1778
1823
  `derived_class` или `tenant_denied`, и пачка откатывается.
1779
1824
  - Приращения строки, заданной одними полями ключа (или одним `$id`), `out` складывает до отправки в базу, пока между
1780
1825
  ними нет `upsert`, `delete` или `get`: на строку — одна правка за пачку, а правило класса проверяется над итогом её
@@ -1822,9 +1867,9 @@ sales.close() // stop() после текущ
1822
1867
  выбирает он: пользователь арендатора сам к проектору не подключится. Опция
1823
1868
  `tenants: [id, …]` — явный список участников: проектор работает только с этими арендаторами (их классы `into` тоже
1824
1869
  должны быть помечены), а пометку у остальных не учитывает. Источники — семейства `from` у этих арендаторов; класс
1825
- не может быть одновременно источником и результатом. Изменений класса с `history: false` проектор не видит:
1826
- журнала у него нет. Поэтому арендатор, у которого такой класс среди источников, выбывает с кодом `invalid_class`, и
1827
- его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
1870
+ из `into` источником не бывает, даже если попадает в `from`: свои результаты проектор не читает. Изменений класса
1871
+ с `history: false` проектор не видит: журнала у него нет. Поэтому арендатор, у которого такой класс среди
1872
+ источников, выбывает с кодом `invalid_class`, и его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
1828
1873
  - `batch` — сколько изменений в пачке (500), `pollMs` — запасной опрос (1000 мс): сигналы источников будят
1829
1874
  проектор раньше.
1830
1875
  - `lockTimeout` — сколько транзакция проектора ждёт чужую блокировку строки (5000 мс; 0 — без предела). Транзакция
@@ -1914,7 +1959,9 @@ const stats = db.cache.stats() // { size, hits,
1914
1959
  - изменение прав и классов, запись через `db.sql`, погашенная сессия и переподключение слушателя сбрасывают весь
1915
1960
  кэш или ответы сессии;
1916
1961
  - срок по умолчанию — 60 секунд; внутри `db.begin()` кэш не работает;
1917
- - без `LISTEN` и в режиме `none` кэш выключен, а `connect({ cache: { ttlOnly: true } })` включает его только по сроку.
1962
+ - без `LISTEN` и в режиме `none` кэш выключен, а `connect({ cache: { ttlOnly: true } })` включает его: ответ живёт до
1963
+ конца срока, если его не сбросит запись этого подключения;
1964
+ - `db.load()`, проектор и `db.rebuildAggregates()` сбрасывают кэш, даже этого подключения, только сигналом базы.
1918
1965
 
1919
1966
  Реестр классов процесс перечитывает по сигналу об изменении классов сразу (одно перечитывание за раз), а реестр и
1920
1967
  ключ сигналов по `*`, по незнакомому токену и по нагрузке не своего режима (а без ключа в режиме `opaque` — по любому
@@ -1934,10 +1981,16 @@ const stats = db.cache.stats() // { size, hits,
1934
1981
  const report = await adm.maintain() // { sessions, codes, thinned, batches, more, queue, skipped }
1935
1982
  ```
1936
1983
 
1937
- `maintain()` удаляет истёкшие сессии и одноразовые коды, прореживает журнал по политике хранения и проверяет
1938
- очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Вызывать может системный администратор,
1939
- роль с правом `letopis.maintain` или администраторское подключение. Запускает его внешний планировщик
1940
- (`letopis maintain`) или сама библиотека: `connect({ maintain: '1h' })`. Из нескольких процессов работает один:
1984
+ `maintain()` удаляет из таблицы `secret` сессии и одноразовые коды всех видов (`OTP` и свои `OTP_<ИМЯ>` из
1985
+ `issueOtp({ kind })`) — истёкшие, а также погашенные сессии и коды, которые уже использованы, заменены новым или
1986
+ исчерпали попытки, — прореживает журнал по политике хранения
1987
+ и проверяет очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Строки удалённого аккаунта
1988
+ `maintain()` ждать не нужно: их стирает само удаление ([«Удалить аккаунт»](#удалить-аккаунт)). Вызывать может системный
1989
+ администратор, роль с правом `letopis.maintain` или администраторское подключение. Запускает его внешний
1990
+ планировщик (`letopis maintain`) или сама библиотека: `connect({ maintain: '1h' })`. Интервал — число миллисекунд или
1991
+ строка (`'500ms'`, `'30s'`, `'15m'`, `'1h'`, `'1d'`) от 1 мс до 2³¹−1 мс (около 24 суток): другое значение, в том
1992
+ числе ноль, отрицательное или `NaN`, `connect()` отклоняет с `invalid_data`, не открывая соединений. `close()` ждёт
1993
+ идущий по расписанию запуск: он доделывает текущую пачку и следующую не начинает. Из нескольких процессов работает один:
1941
1994
  каждая пачка — своя транзакция, и первым делом она берёт блокировку обслуживания — строку таблицы `secret`, которую
1942
1995
  ни прикладная, ни посторонняя роль базы не займёт; блокировка снимается с фиксацией пачки. Если блокировку держит
1943
1996
  другой процесс, вызов до первой пачки отвечает `skipped: true`, а между пачками останавливается (продолжит тот
@@ -2139,6 +2192,9 @@ letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пол
2139
2192
  (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
2140
2193
  ```
2141
2194
 
2195
+ `letopis types` и `letopis describe` строят результат по классам сессии, поэтому без действующей сессии (токен истёк,
2196
+ погашен, неверен или не задан, ключ API неверен) выходят с кодом 1 и ошибкой `no_session`, а файл `--out` не трогают.
2197
+
2142
2198
  ### Наблюдение
2143
2199
 
2144
2200
  - `connect({ onQuery, slowMs })`: хук получает на каждый запрос цепочки режим, классы, длительность, число строк и
@@ -2256,17 +2312,19 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2256
2312
  | `link`, `lookup` | `{ account, kind, identifier, meta?, confirmed? }` → id; `{ kind, identifier, requireConfirmed?, ttl?, totp? }` → вход или `null` |
2257
2313
  | `credentials`, `revokeCredential`, `setFlags` | `(account)` → креды без секретов; `(id, { keepCurrent? })`; `(id, { confirmed?, enabled? })` |
2258
2314
  | `sessionFor`, `refresh`, `revoke`, `revokeAll` | `(account, { ttl? })` → токен; `({ ttl? })` → новое время истечения (токен прежний); `()`; `(account?)` → число сессий |
2315
+ | `session` | `(token)` → `{ account, tenant, member, expiresAt, loginAt, methods }` или `null` |
2259
2316
  | `tenants`, `switch` | `()` → `[{ tenant, roles }]`; `(tenant)` |
2260
2317
  | `purgeAccount` | `(account)` → `{ account, rows, versions, memberships, owned }` |
2261
2318
 
2262
- Проверку паролей, кодов и TOTP, выпуск сессий и подтверждение кредов вызывает сервис (права `auth.*`); обычному
2263
- пользователю отвечают `acl_denied`. Креды System, сервисов и системных администраторов меняет только сам аккаунт
2264
- после недавнего интерактивного входа или системный администратор. Исключение — ключи сервиса: свои ключи API и
2265
- пары «ключ + секрет» сервис выпускает и отзывает сам по сессии своего ключа, прочие свои креды — нет; последний
2266
- действующий ключ сервиса не отзывается никем (`acl_denied`). С `totp` вход `verifyPassword` и `lookup` проверяет
2267
- ещё и код TOTP (право `auth.totp`) и выдаёт сессию со способом `TOTP`.
2319
+ Проверку паролей, кодов и TOTP, выпуск сессий, сведения о сессии по токену и подтверждение кредов вызывает сервис
2320
+ (права `auth.*`); обычному пользователю отвечают `acl_denied`. Креды System, сервисов и системных администраторов
2321
+ меняет только сам аккаунт после недавнего интерактивного входа или системный администратор. Исключение — ключи
2322
+ сервиса: свои ключи API и пары «ключ + секрет» сервис выпускает и отзывает сам по сессии своего ключа, прочие свои
2323
+ креды — нет; последний действующий ключ сервиса не отзывается никем (`acl_denied`). С `totp` вход `verifyPassword` и
2324
+ `lookup` проверяет ещё и код TOTP (право `auth.totp`) и выдаёт сессию со способом `TOTP`.
2268
2325
  Вид креда `kind` — `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`, `TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`,
2269
- `OTP`, `TOTP` и `KEY` заняты.
2326
+ `OTP`, `TOTP`, `KEY`, `PROJECTOR` и `LOCK` заняты. То же правило — у вида пароля `user.kind` в `createTenant` и
2327
+ `up({ tenant })`.
2270
2328
 
2271
2329
  ### Проектор
2272
2330
 
@@ -2283,7 +2341,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2283
2341
  | `stop()`, `close()` | остановить `run()` после текущей пачки; то же и отписаться от сигналов |
2284
2342
  | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds, locked }` |
2285
2343
  | `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` и, если есть, `agent`, `reason`, `moved`, `before`, `rebuild` |
2286
- | `ProjectorOut` | `upsert(класс, строка, { tenant })`, `inc(класс, строка, приращения, { tenant })`, `delete(класс, ключ или id, { tenant })`, `get(класс, ключ или id, { tenant })` → `Row \| null`; строка — `ProjectorRow` в формате загрузчика |
2344
+ | `ProjectorOut` | `upsert(класс, строка, { tenant })`, `inc(класс, строка, приращения, { tenant })`, `delete(класс, ключ или id, { tenant })`, `get(класс, ключ или id, { tenant })` → `Row \| null`; строка — `ProjectorRow` в формате загрузчика; у класса с ключом `delete` и `get` — по ключу, id — только у класса без ключа |
2287
2345
 
2288
2346
  ### Строка ответа
2289
2347
 
@@ -2300,8 +2358,10 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2300
2358
 
2301
2359
  ### Зарезервированные имена
2302
2360
 
2303
- Имена ниже Proxy разбирает раньше классов, поэтому класс с таким именем недостижим как шаг, а связь с таким
2304
- именем база не примет (`reserved_names()`; список подставляет установщик из `RESERVED_CLASS_NAMES`).
2361
+ Имя метода закрывает класс только там, где этот метод есть: после шага — методы цепочки, у корня `db` — его методы,
2362
+ у батча — его. Поэтому хаб с именем метода только цепочки (например, `count`) доступен первым шагом `db.count(…)`, а
2363
+ после другого шага недостижим; хаб с именем метода корня первым шагом не вызвать. Связь с любым из этих имён база не
2364
+ примет (`reserved_names()`; список подставляет установщик из `RESERVED_CLASS_NAMES`). Не давайте классам эти имена.
2305
2365
 
2306
2366
  - методы цепочки: `then`, `entity`, `paths`, `rows`, `first`, `ids`, `count`, `limit`, `offset`, `sort`, `asOf`,
2307
2367
  `withDeleted`, `deep`, `exact`, `sum`, `avg`, `min`, `max`, `countBy`, `after`, `versions`, `create`, `update`,
@@ -2358,7 +2418,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2358
2418
  | `tx_rolled_back` | PostgreSQL ответил `ROLLBACK` на `COMMIT`: ошибку перехватили внутри `tr.transaction(fn)` |
2359
2419
  | `no_key` | `upsert`, `rekey` или `idOf` у класса без ключа |
2360
2420
  | `no_path` | между классами соседних шагов нет перехода |
2361
- | `invalid_query` | цепочку или API используют неверно (модификатор шага `deep`, `exact`, `alias`, `tags` или `owner` после глагола, слот не после глагола, курсор `after()` со значением не того типа, что поле `sort`, без `v` или с `id` не uuid и т. п.) |
2421
+ | `invalid_query` | цепочку или API используют неверно (модификатор шага `deep`, `exact`, `alias`, `tags` или `owner` после глагола, модификатор выборки между глаголом и терминалом `rows`, `first`, `ids`, `count` или `paths`, слот не после глагола, курсор `after()` со значением не того типа, что поле `sort`, без `v` или с `id` не uuid и т. п.) |
2362
2422
  | `admin_required` | загрузчику, проектору и `rebuildAggregates` нужно администраторское подключение |
2363
2423
  | `rebuild_required` | проекция построена другой версией проектора (`version`): нужна перестройка `rebuild()`; `rebuild({ tenant })`, пока проекции нет или идёт перестройка пачками |
2364
2424
  | `projector_busy` | перестройку не начать: проектор сейчас работает в другом процессе |
@@ -2489,8 +2549,8 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2489
2549
  - Горячие цели ссылок (организация, на которую ссылается почти всё) копят блокировки `for key share` и MultiXact,
2490
2550
  как при обычных внешних ключах; это нужно мониторить. Операции одного кошелька идут друг за другом под
2491
2551
  блокировкой его строки ([раздел 8](#8-транзакции-батчи-и-деньги), «Рецепт: кошелёк»).
2492
- - Связь не может называться зарезервированным именем, а хаб с таким именем недостижим как шаг
2493
- ([«Зарезервированные имена»](#зарезервированные-имена)).
2552
+ - Связь не может называться зарезервированным именем, а хаб с таким именем доступен не везде: например, `count` —
2553
+ только первым шагом ([«Зарезервированные имена»](#зарезервированные-имена)).
2494
2554
 
2495
2555
  **Доступ**
2496
2556
 
@@ -2677,7 +2737,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2677
2737
  # cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков сигналов (этап 6; план 1.1, точка А)
2678
2738
  # maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6)
2679
2739
  # 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)
2740
+ # upgrade — обновление схемы с 1.1.0 (upgrade: true, миграция 0007) и с 1.0.1 (upgrade: true, миграции 0006 и 0007) пакетами letopis-11 и letopis-1: версии и хэши прежнего движка, verify(), каталог против чистой установки, строки-замки, строки secret без аккаунта стёрты, приложение, подписка и проектор по старым данным, повторный up() (доработки после 1.2.0, этап 1; ревизия 7)
2681
2741
  # scenario-salon — сквозной сценарий салона: 21 акт 0.21 (salon) с матрицей покрытия API 1.0 (этап 6)
2682
2742
  # telemetry — onQuery и slowMs, describe() и метаданные классов с наследованием, describeText (этап 6)
2683
2743
  # history — diff и blame по журналу, причина изменения у глаголов, db.begin() и батча; версия, скрытая условием ACL, колонка changed, пропуск версии, row_changes на 20 тыс. целей (план 1.1, этап 1 и точка А)
package/dist/acl.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { Executor } from './tx.js';
2
2
  export type AclOp = 'READ' | 'WRITE' | 'DELETE';
3
- /** Решение по эндпоинту API. */
3
+ /** Решение по ресурсу приложения (эндпоинту): можно ли к нему обратиться. */
4
4
  export interface AclDecision {
5
5
  allow: boolean;
6
- /** Победившее правило: id, группа и объект (alias ресурсов), вес, разрешение. */
6
+ /** Победившее правило: id, группа и объект (`alias` ресурсов), вес, разрешение. */
7
7
  rule?: {
8
8
  id: string;
9
9
  group: string;
@@ -11,7 +11,7 @@ export interface AclDecision {
11
11
  weight: number;
12
12
  permission: 'allow' | 'deny';
13
13
  };
14
- /** Код и сообщение запрещающего правила (поля code, message строки rule). */
14
+ /** Код и сообщение запрещающего правила (поля `code`, `message` строки `rule`). */
15
15
  code?: number;
16
16
  message?: string;
17
17
  }
@@ -21,9 +21,15 @@ export interface AclDataDecision {
21
21
  filter?: Record<string, unknown>;
22
22
  }
23
23
  export interface AclApi {
24
- /** Может ли актор вызвать эндпоинт (ресурсы категории API; правила System и арендатора). */
24
+ /**
25
+ * Можно ли тому, от чьего имени идёт запрос, обратиться к ресурсу приложения (эндпоинту). Решают правила доступа
26
+ * System и текущего арендатора.
27
+ */
25
28
  check(endpoint: string): Promise<AclDecision>;
26
- /** Решение операции над классом для актора: allow и условие на строки (filter). */
29
+ /**
30
+ * Можно ли тому, от чьего имени идёт запрос, выполнять операцию `op` с объектами класса — со всеми или только с теми,
31
+ * что подходят под условие `filter` в ответе.
32
+ */
27
33
  checkData(className: string, op: AclOp): Promise<AclDataDecision>;
28
34
  }
29
35
  export declare function makeAcl(S: string, exec: Executor): AclApi;
package/dist/admin.d.ts CHANGED
@@ -17,22 +17,28 @@ export interface VerifyIssue {
17
17
  export interface VerifyResult {
18
18
  ok: boolean;
19
19
  issues: VerifyIssue[];
20
- /** Хэш всех голов цепочек — храните вне базы: подмену последних версий выдаст его расхождение. */
20
+ /** Один хэш текущих версий всех объектов — храните его вне базы: подмену самых свежих версий выдаст его расхождение. */
21
21
  anchor: string;
22
22
  }
23
23
  export interface MaintainOptions {
24
- /** Момент «сейчас» для прореживания — только в установке с allowReset (тесты). */
24
+ /** Момент «сейчас» для прореживания — только в установке с `allowReset` (тесты). */
25
25
  now?: string | Date;
26
- /** Пачек прореживания за вызов (по умолчанию 100); каждая — своя транзакция. */
26
+ /** Сколько пачек прореживания журнала самое большее сделать за один вызов; каждая пачка — своя транзакция. */
27
27
  batches?: number;
28
- /** Версий в пачке (по умолчанию 10 000). */
28
+ /** Сколько версий самое большее удалить в одной пачке прореживания. */
29
29
  limit?: number;
30
30
  }
31
+ /** Внутренняя опция maintain(): остановка запуска по расписанию — текущая пачка доделывается, следующая не начинается. */
32
+ export declare const MAINTAIN_STOP: unique symbol;
31
33
  export interface MaintainReport {
32
34
  /** Другой процесс уже обслуживает установку (держит строку-замок обслуживания): этот вызов ничего не делал. */
33
35
  skipped: boolean;
34
- /** Удалено истёкших и погашенных сессий и одноразовых кодов. */
36
+ /** Удалено сессий: истёкших и погашенных (выход, отзыв, смена пароля, отключение или удаление аккаунта). */
35
37
  sessions: number;
38
+ /**
39
+ * Удалено одноразовых кодов (вид `OTP` и свои виды `OTP_<ИМЯ>` из `issueOtp({ kind })`): истёкших, использованных,
40
+ * заменённых новым и исчерпавших попытки.
41
+ */
36
42
  codes: number;
37
43
  /** Прорежено версий журнала. */
38
44
  thinned: number;
@@ -46,7 +52,7 @@ export interface ResetOptions {
46
52
  level: 'sessions' | 'tenant' | 'all';
47
53
  /** Подтверждение — имя схемы установки. */
48
54
  confirm: string;
49
- /** level: 'tenant' — арендатор (по умолчанию — арендатор сессии). */
55
+ /** Какого арендатора сбросить при `level: 'tenant'` — его id; на других уровнях не учитывается. */
50
56
  tenant?: string;
51
57
  }
52
58
  export interface ResetResult {
@@ -55,10 +61,10 @@ export interface ResetResult {
55
61
  tenant?: string;
56
62
  rows?: number;
57
63
  versions?: number;
58
- /** level: 'all' — новый ключ сервиса: прежние сессии и ключи больше не действуют. */
64
+ /** `level: 'all'` — новый ключ сервиса: прежние сессии и ключи больше не действуют. */
59
65
  serviceKey?: string;
60
66
  }
61
- /** Машинное описание модели (db.describe()). */
67
+ /** Машинное описание модели (`db.describe()`). */
62
68
  export interface ModelDescription {
63
69
  schema: string;
64
70
  tenant: string | null;
@@ -117,13 +123,23 @@ export declare function verify(a: AdminCtx, opts?: {
117
123
  * (skipped у всех). Блокировку держит другой процесс: до первой пачки — skipped; между пачками —
118
124
  * вызов останавливается (продолжит тот процесс), отчёт — о сделанном, more: true.
119
125
  */
120
- export declare function maintain(a: AdminCtx, opts?: MaintainOptions): Promise<MaintainReport>;
126
+ export declare function maintain(a: AdminCtx, opts?: MaintainOptions & {
127
+ [MAINTAIN_STOP]?: () => boolean;
128
+ }): Promise<MaintainReport>;
121
129
  /** Предупреждение о заполненной очереди сигналов (§2.12): больше половины — зависший слушатель. */
122
130
  export declare function queueWarning(queue: number): string | null;
123
- /** Интервал расписания: число мс или '30s', '15m', '1h', '1d'. */
124
- export declare function parseInterval(v: string | number): number;
125
- /** Запуск maintain() по расписанию (connect({ maintain: '1h' })): ошибки — предупреждением. */
126
- export declare function scheduleMaintain(run: () => Promise<unknown>, every: string | number): () => void;
131
+ /**
132
+ * Интервал расписания: число мс или строка '500ms', '30s', '15m', '1h', '1d' — конечный, от 1 мс до 2³¹−1 мс (около 24
133
+ * суток); иначе invalid_data. Ноль, отрицательное, NaN и слишком длинный интервал таймер Node.js превращает в 1 мс —
134
+ * обслуживание шло бы без перерыва.
135
+ */
136
+ export declare function parseInterval(v: unknown): number;
137
+ /**
138
+ * Запуск maintain() по расписанию (connect({ maintain: '1h' }); ms — уже проверенный интервал): ошибки — предупреждением,
139
+ * следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер, просит идущий запуск не
140
+ * начинать новых пачек и ждёт его конца — close() закрывает пул после неё, а не посреди пачки.
141
+ */
142
+ export declare function scheduleMaintain(run: (stopped: () => boolean) => Promise<unknown>, ms: number): () => Promise<void>;
127
143
  /**
128
144
  * Сброс (этап 6, п. 6). Предохранители — в базе: флаг allowReset установки, право letopis.reset,
129
145
  * подтверждение именем схемы. tenant — пачками (длинная операция не держит горизонт подписок).
@@ -153,6 +169,6 @@ export declare function rebuildAggregates(env: {
153
169
  }, cls: string): Promise<number>;
154
170
  /** Машинное описание модели арендатора вместе с метаданными классов (этап 6, п. 12). */
155
171
  export declare function describe(a: AdminCtx): ModelDescription;
156
- /** Описание модели текстом для языковых моделей (llms.txt). */
172
+ /** Описание модели текстом для языковых моделей. */
157
173
  export declare function describeText(d: ModelDescription): string;
158
174
  export {};