letopis 1.4.0 → 1.4.2

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 (62) hide show
  1. package/AGENT-CHEATSHEET.en.md +15 -7
  2. package/AGENT-CHEATSHEET.md +19 -11
  3. package/CHANGELOG.md +299 -1
  4. package/README.en.md +180 -62
  5. package/README.md +165 -55
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +53 -18
  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/cache.js +3 -2
  13. package/dist/chain.d.ts +92 -15
  14. package/dist/chain.js +56 -7
  15. package/dist/cli.js +16 -2
  16. package/dist/errors.d.ts +2 -2
  17. package/dist/errors.js +5 -5
  18. package/dist/import.js +7 -0
  19. package/dist/index.d.ts +73 -45
  20. package/dist/index.js +69 -14
  21. package/dist/internal.d.ts +7 -0
  22. package/dist/internal.js +5 -0
  23. package/dist/load.d.ts +10 -3
  24. package/dist/load.js +39 -11
  25. package/dist/mcp.d.ts +20 -11
  26. package/dist/mcp.js +8 -6
  27. package/dist/model.d.ts +4 -1
  28. package/dist/model.js +7 -1
  29. package/dist/ops.d.ts +58 -18
  30. package/dist/ops.js +58 -18
  31. package/dist/project.d.ts +39 -11
  32. package/dist/project.js +75 -15
  33. package/dist/registry.d.ts +4 -1
  34. package/dist/registry.js +4 -1
  35. package/dist/signals.d.ts +4 -4
  36. package/dist/signals.js +4 -4
  37. package/dist/sql.d.ts +8 -4
  38. package/dist/sql.js +110 -25
  39. package/dist/sync.js +11 -4
  40. package/dist/tx.d.ts +15 -0
  41. package/dist/tx.js +22 -4
  42. package/dist/types.d.ts +21 -7
  43. package/dist/types.js +13 -6
  44. package/dist/up.d.ts +48 -25
  45. package/dist/up.js +94 -41
  46. package/dist/validate.d.ts +9 -2
  47. package/dist/validate.js +19 -3
  48. package/dist/watch.d.ts +29 -5
  49. package/dist/watch.js +62 -13
  50. package/dist/write.d.ts +3 -2
  51. package/dist/write.js +52 -19
  52. package/package.json +8 -4
  53. package/sql/10-core.sql +6 -1
  54. package/sql/60-write.sql +47 -18
  55. package/sql/70-read.sql +13 -7
  56. package/sql/75-history.sql +106 -28
  57. package/sql/80-auth.sql +41 -3
  58. package/sql/88-derived.sql +3 -1
  59. package/sql/90-time.sql +118 -30
  60. package/sql/95-seed.system.sql +6 -3
  61. package/sql/99-revision.sql +1 -1
  62. 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 штамп не меняет, и предупреждения нет.
@@ -374,10 +385,30 @@ const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token
374
385
  записи: `create index concurrently log_class_tenant_at on "v2.<схема>".log (class, tenant, at)` от роли владельца, —
375
386
  тогда `up()` только снимет прежний.
376
387
 
377
- Обновление схем 1.1.0 и 1.0.1 проверяет тест `upgrade`. Схему ставит прежний пакет, данные пишет прежний движок, затем
388
+ Обновление схем 1.4.1, 1.1.0 и 1.0.1 проверяет тест `upgrade`; каждый выпуск проверяет обновление с предыдущего
389
+ выпуска. Схему ставит прежний пакет, данные пишет прежний движок, затем
378
390
  вызывается `up()` текущей версии. После него версии и хэши прежнего движка на месте, `verify({ data: true })` чист, а
379
- каталог схемы совпадает с чистой установкой. Схема 1.1.0 обновляется простым `up()`: ревизия у неё та же. Схема 1.0.1
380
- обновляется через `up({ upgrade: true })`.
391
+ каталог схемы совпадает с чистой установкой. Схема 1.4.1 той же ревизии 7 обновляется обычным `up()`, без миграций;
392
+ 1.1.0 (ревизия 6) и 1.0.1 (ревизия 5) — через `up({ upgrade: true })`: миграцией `0007` и миграциями `0006` и `0007`.
393
+
394
+ ### Версии и совместимость
395
+
396
+ Версии letopis следуют semver. Что обещает каждый шаг версии:
397
+
398
+ - **Патч** (1.4.1 → 1.4.2) — только исправления. Ревизия движка та же: исправленные функции накатывает обычный
399
+ `up({ schema })`, миграций нет; до наката `connect()` предупреждает о расхождении штампа. Работающий код не получает
400
+ новых отказов, методы, опции, экспорты и коды ошибок не исчезают.
401
+ - **Минор** (1.4 → 1.5) — новые возможности: методы, опции, коды ошибок; изменения поведения, которые не ломают
402
+ работающий код; новая ревизия движка. Новую ревизию накатывает `up({ schema, upgrade: true })`. Удалённого и
403
+ переименованного нет.
404
+ - **Мажор** (1.x → 2.0) — удаление и переименование методов, опций, экспортов и кодов ошибок, смена сигнатур и типов,
405
+ новые отказы для всех. Что менять в коде, описывает `MIGRATION.md`.
406
+
407
+ Текст сообщения об ошибке в API не входит: в патче он может стать точнее. Полагайтесь на `err.code`.
408
+
409
+ Шаг версии перед каждым выпуском сверяет сценарий `check:semver`: он сравнивает контракт API (`docs/api-contract.json`)
410
+ и ревизию движка с прошлым выпуском и требует шаг не меньше нужного. Каждый выпуск проверяет обновление схемы с
411
+ предыдущего выпуска (тест `upgrade`).
381
412
 
382
413
  ### Разработка без PostgreSQL: PGlite
383
414
 
@@ -602,8 +633,9 @@ npx letopis types --dsn "$APP" --schema v2.shop --token "$TOKEN" --out types.ts
602
633
  ### Имена классов
603
634
 
604
635
  Шаг цепочки называется именем класса, его алиасом или именем роли. Имена методов цепочки и корня (`count`, `sort`,
605
- `set`, `sql`, `auth` и другие) Proxy разбирает раньше классов. Поэтому класс с таким именем недостижим как шаг, а
606
- связь с таким именем база не примет. Полный список — в разделе
636
+ `set`, `sql`, `auth` и другие) там, где такой метод есть, библиотека разбирает раньше классов: класс `count` доступен
637
+ первым шагом `db.count(…)`, но не после другого шага. Связь с таким именем база не примет. Не давайте классам эти
638
+ имена; полный список — в разделе
607
639
  [«Зарезервированные имена»](#зарезервированные-имена).
608
640
 
609
641
  ## 5. Запись
@@ -657,7 +689,8 @@ await db.Shop(shop).Product({ price: gte(2000) }).update({ title: 'Чайник
657
689
  `db.Product().update(…)` правит все товары арендатора. Шаг ничего не нашёл — ответ пустой и ошибки нет, поэтому
658
690
  проверяйте длину ответа. Если `update` затронул меньше строк, чем нашёл шаг, весь план откатывается: строку успели
659
691
  удалить — `target_not_found`, строка видна, но править её нельзя — `acl_denied`. Модификатор `.tags(…)` перед
660
- `update` и отбирает строки, и заменяет их теги целиком.
692
+ `update` отбирает строки; строка или список ещё и заменяют их теги целиком, а оператор (`has`, `hasAny`, `hasAll`)
693
+ только отбирает — теги не меняются.
661
694
 
662
695
  ### Создать или заменить: `upsert`
663
696
 
@@ -789,8 +822,14 @@ await db.Order(order).line().create({ qty: 1, price: 900 }).Product.set(sencha).
789
822
  - Весь план — одна транзакция уровня `read committed`. При взаимной блокировке (`40P01`), ошибке сериализации
790
823
  (`40001`) и конфликте журнала библиотека повторяет план целиком, до трёх раз.
791
824
  - Ошибка любого звена откатывает весь план. `exists` и `conflict` не повторяются: это ответы для приложения.
792
- - Модификаторы до глагола (`limit`, `sort`) сужают его цели.
825
+ - Модификаторы до глагола (`limit`, `offset`, `sort`) сужают его цели.
826
+ - Модификатор после глагола относится к тому, что идёт за ним: к шагу класса, к `versions()`, `diff()`, `blame()` и
827
+ агрегатам или к целям следующего глагола. Если за ним сразу терминал `rows`, `first`, `ids`, `count` или `paths`,
828
+ сужать уже нечего — глагол прошёл по всем целям шага, — поэтому `db.Product().update(…).limit(1).rows()` даёт
829
+ `invalid_query` и ничего не пишет. В батче так же отказывает `run()`.
793
830
  - Глагол сразу после глагола пишет в те же сущности.
831
+ - Строки ответа глагола идут в порядке его целей: по `sort()` до глагола, без него — по id; `first()` после глагола —
832
+ первая по этому порядку.
794
833
 
795
834
  ## 6. Чтение
796
835
 
@@ -834,7 +873,7 @@ const roots = await db.Category({ Parent: null }).rows() // корневы
834
873
  | `ne(v)`, `gt(v)`, `gte(v)`, `lt(v)`, `lte(v)` | ≠ (с учётом `null`), >, ≥, <, ≤ |
835
874
  | `between(a, b)`, `inList([…])` | a ≤ x ≤ b; значение из списка |
836
875
  | `like(s)`, `ilike(s)`, `starts(s)`, `ends(s)` | шаблон с `%` и `_`; без учёта регистра; начинается с; заканчивается на |
837
- | `has(v)`, `hasAny([…])`, `hasAll([…])` | поле-массив содержит значение; хотя бы одно; все |
876
+ | `has(v)`, `hasAny([…])`, `hasAll([…])` | поле-массив содержит значение; хотя бы одно; все (`hasAll([])` подходит любой строке, `hasAny([])` — никакой) |
838
877
  | `exists(true \| false)`, `isNull()` | поле есть или нет; поле `null` или отсутствует |
839
878
  | `not(v)` | отрицание оператора; скаляр — «не равно» |
840
879
  | `or(f1, f2, …)` | любой из фильтров шага |
@@ -871,13 +910,18 @@ const page2 = await db.Product().sort('data.price', 'desc')
871
910
  `sort(поле, 'asc' | 'desc')` — по `'at'`, `'rev'`, `'id'` или `'data.<путь>'`. `after(cursorOf(строка, поле))` —
872
911
  постраничный вывод «после такой-то строки»; он требует `sort()`. Такой вывод устойчив к вставкам и удалениям между
873
912
  страницами, но строка, у которой изменилось поле сортировки, может выпасть или повториться. Курсор — объект
874
- `{ v, id }` (значение поля и id строки): он переживает JSON, и его можно отдать клиенту. Поле в `cursorOf` должно
913
+ `{ v, id, rev }` (значение поля, id и номер версии строки): он переживает JSON, и его можно отдать клиенту. Поле в `cursorOf` должно
875
914
  совпадать с полем `sort`. Значение курсора проверяется по типу поля `sort` до запроса, неверное — `invalid_query`:
876
915
  числовому полю нужно конечное число, `rev` — целое, логическому — `true` или `false`, текстовому — строка, uuid и
877
916
  `id` — uuid, времени и `at` — строка ISO с поясом, дате — `ГГГГ-ММ-ДД`, полю без описания — строка, число или
878
917
  `true`/`false`. `null` подходит только полям данных, `id` курсора — всегда uuid. Курсор того же типа, снятый
879
918
  с другого поля, проверка не отличит. `limit` и `offset` тоже есть.
880
919
 
920
+ `after()` листает и `versions()`: курсор версии несёт её `rev`, поэтому версии одного объекта с равным полем
921
+ сортировки не теряются. В `paths()` курсор даёт `cursorOf(путь, поле)`: поле и id — последнего узла пути, а в `path`
922
+ — id всех узлов, поэтому не теряются пути к одному узлу. Курсор `cursorOf(путь.Узел, поле)` с одного узла пропустит
923
+ остальные пути к этому узлу. `count()`, агрегаты, `diff()` и `blame()` курсор не учитывают.
924
+
881
925
  Поле данных бывает `null` или отсутствует. Такие строки `sort` ставит, как `order by` в PostgreSQL: при
882
926
  возрастании — в конце, при убывании — в начале, а `after()` считает `null` значением после всех прочих (по
883
927
  возрастанию) или перед ними (по убыванию). Поэтому страницы проходят такие строки без пропусков и повторов, а
@@ -904,7 +948,9 @@ const byStatus = await db.Order().countBy('data.status') // { new: 1 }
904
948
  ```
905
949
 
906
950
  Агрегаты считает база, по сущностям последнего шага. `limit(n)` и `offset(n)` перед `countBy()` относятся к группам:
907
- `db.Order().limit(3).countBy('data.status')` — три самых частых значения.
951
+ `db.Order().limit(3).countBy('data.status')` — три самых частых значения. У логического поля `min()` — `false`, если
952
+ есть хоть одно `false` (все ли значения `true`), а `max()` — `true`, если есть хоть одно `true`; `sum()` и `avg()`
953
+ логическое поле не считают — PostgreSQL отвечает ошибкой.
908
954
 
909
955
  ### Пути
910
956
 
@@ -913,7 +959,8 @@ const byStatus = await db.Order().countBy('data.status') // { new: 1 }
913
959
  const orderPaths = await db.Order(order).line().Product().paths() // [{ Order: Row, line: Row, Product: Row }, …]
914
960
  ```
915
961
 
916
- `paths()` отдаёт узлы каждого варианта пути; ключ узла — имя шага или имя из `.alias(имя)`. Повтор связи в
962
+ `paths()` отдаёт узлы каждого варианта пути; ключ узла — имя шага или имя из `.alias(имя)`. Ключи идут в порядке
963
+ шагов; если цепочка кончается возвратом к прежнему узлу, этот узел — последний. Повтор связи в
917
964
  цепочке возвращает к её узлу: так из одной связи можно пройти в несколько сторон. `db.entity(строка | цепочка)`
918
965
  вставляет готовый узел в путь.
919
966
 
@@ -936,10 +983,11 @@ try {
936
983
 
937
984
  `delete()` без `{ confirm: true }` — только превью: всё замыкание каскада. В каждой строке превью `$action`:
938
985
  `delete`, `unset` или `restrict`, и `$depth` — уровень каскада (0 — сама цель; у строк `unset` и `restrict` его
939
- нет). С подтверждением удаляются все цели
940
- шага одним оператором, а каскад применяет правила концов. Превью не учитывает права: строка с `$action: 'delete'`
941
- может не удалиться у пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить
942
- `acl.checkData(класс, 'DELETE')`. Правила концов:
986
+ нет). С подтверждением удаляются все цели шага одним оператором, а каскад применяет правила концов. Итог —
987
+ надгробия по уровням каскада, внутри уровня по id (в таком порядке они и в журнале); цели классов без журнала
988
+ (`history: false`) — в конце. Превью не учитывает права: строка с `$action: 'delete'` может не удалиться у
989
+ пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить `acl.checkData(класс, 'DELETE')`.
990
+ Правила концов:
943
991
 
944
992
  - `restrict` — удаление запрещено, пока на цель ссылаются (`delete_restricted` со списком видимых строк и числом
945
993
  остальных);
@@ -1221,7 +1269,7 @@ const changed = await adm.load({ Shop: [{ name: 'Василёк', city: 'Сам
1221
1269
  | Опция | Значения |
1222
1270
  |---|---|
1223
1271
  | `duplicates` — повтор id внутри загрузки | `'error'` (по умолчанию) — отменить; `'first'` или `'last'` — оставить первую или последнюю строку |
1224
- | `existing` — объект уже есть в базе | `'error'` (по умолчанию) — отменить; `'skip'` — пропустить, удалённые не воскрешать; `'update'` — новая версия, если содержимое отличается (неизменённые считаются в `skipped`), удалённые — создать заново |
1272
+ | `existing` — объект уже есть в базе | `'error'` (по умолчанию) — отменить; `'skip'` — пропустить, удалённые не воскрешать; `'update'` — новая версия, если содержимое отличается (неизменённые считаются в `skipped`), удалённые — создать заново; объект другого класса с тем же id (класс без ключа после `reclass`) — `exists` |
1225
1273
 
1226
1274
  ### Порции, история и проверка
1227
1275
 
@@ -1389,8 +1437,10 @@ const viaTelegram = await svc.auth.lookup({ kind: 'TELEGRAM', identifier: '10050
1389
1437
  <!-- run: guide -->
1390
1438
  ```ts
1391
1439
  const shortToken = await svc.auth.sessionFor(BORIS, { ttl: 600 }) // сессия на 10 минут простоя — для другого процесса
1440
+ const whose = await svc.auth.session(shortToken) // { account, tenant, member, expiresAt, loginAt, methods }
1392
1441
  const boris = await connect({ dsn: APP, schema: r.schema, token: shortToken })
1393
1442
  await boris.auth.revoke() // выход: сессия погашена
1443
+ const ended = await svc.auth.session(shortToken) // null
1394
1444
  await boris.close()
1395
1445
  ```
1396
1446
 
@@ -1403,13 +1453,23 @@ await boris.close()
1403
1453
  старые сессии того же вида входа без отметки ключа); текущая сессия остаётся, если не передать
1404
1454
  `keepCurrent: false`.
1405
1455
 
1456
+ Сервис, у которого есть токен пользователя (например, в cookie), узнаёт о сессии через `svc.auth.session(token)`:
1457
+ подключение по этому токену не открывается, срок сессии не продлевается. Ответ — чья сессия (`account`), в каком
1458
+ арендаторе она работает (`tenant`: выбранный `auth.switch()` или собственный арендатор аккаунта), цело ли там членство
1459
+ (`member`; в собственном арендаторе — всегда `true`), когда она истечёт (`expiresAt`), когда и как был вход
1460
+ (`loginAt`, `methods`: `PASSWORD`, `APIKEY`, `SERVICE`, `TOTP` …). Неверный, погашенный или истёкший токен и
1461
+ отключённый аккаунт — `null`, причина не различается. Так веб-сервис проверяет сессию в начале каждого запроса:
1462
+ чтение по погашенной сессии ошибки не даёт, ответы просто пустые. Вызывает метод сервис или системный администратор
1463
+ (право `auth.session`); пользователю отвечают `acl_denied`.
1464
+
1406
1465
  ### Работа в другом арендаторе
1407
1466
 
1408
1467
  Сервис действует от имени пользователя через `svc.as(account, { tenant, owner })`. База проверяет право сервиса
1409
1468
  `auth.impersonate` (по правилам System), что пользователь существует и включён и что арендатор ему доступен —
1410
1469
  собственный или по членству. От имени System, сервисов, системных администраторов и отключённых аккаунтов
1411
1470
  действовать нельзя. Видимость пользователя по правилам доступа не проверяется. В журнале `author` — пользователь,
1412
- `agent` — сервис. Подключение
1471
+ `agent` — сервис. Реестр классов `db.as()` — один на арендатора, подключение держит их не больше 1000: давно не
1472
+ нужный вытесняется, а фасад, чей реестр вытеснен, продолжает работать — реестр перечитывается. Подключение
1413
1473
  пользователя по своему токену переключается между арендаторами так:
1414
1474
 
1415
1475
  <!-- run: guide -->
@@ -1510,7 +1570,14 @@ const canReport = await asOleg.acl.check('shop.reports.daily') // { allow:
1510
1570
 
1511
1571
  `db.auth.purgeAccount(аккаунт)` удаляет аккаунт и данные его арендатора. Вызывать может системный администратор или
1512
1572
  владелец его арендатора; себя и последнего владельца арендатора удалить нельзя. Владение строками в чужих
1513
- арендаторах переходит этим арендаторам, сессии гаснут, креды удаляются.
1573
+ арендаторах переходит этим арендаторам, сессии гаснут, креды удаляются. Владелец может стереть и арендатора, в
1574
+ котором сейчас работает: его сессия вернётся в личный арендатор.
1575
+
1576
+ Системный администратор может удалить и саму строку аккаунта — `db.Account(id).delete({ confirm: true })` в
1577
+ арендаторе System; данные арендатора аккаунта при этом остаются. Любой путь сразу стирает строки аккаунта в `secret`:
1578
+ сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (внешний ключ `secret.account` с каскадом,
1579
+ ревизия 7). Сессии гаснут до этого с сигналом, и процессы сбрасывают кэш ответов этих сессий. Аккаунт System
1580
+ не удаляется (`acl_denied`): ему принадлежат служебные строки установки в `secret`.
1514
1581
 
1515
1582
  ## 11. История и срезы на дату
1516
1583
 
@@ -1531,6 +1598,11 @@ const followed = await db.Product(mug2).versions({ follow: true }) // и чер
1531
1598
  `limit()` ограничивает их в самом запросе: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` — пять
1532
1599
  последних версий, новые первыми.
1533
1600
 
1601
+ `follow: true` отдаёт историю объекта на всех его id: до переноса — версии прежнего id, после — нового. Объект,
1602
+ который потом завели на прежнем id (тот же ключ после `rekey`), — другой, и его версий в этой истории нет; его
1603
+ собственная история с `follow` начинается с его создания. Так же идут `diff({ follow: true })` и `blame()`. Без
1604
+ `follow` `versions()` отдаёт все версии id, чьи бы они ни были.
1605
+
1534
1606
  ### Срез на дату
1535
1607
 
1536
1608
  <!-- run: guide -->
@@ -1627,7 +1699,8 @@ const diffed: DiffResult = {
1627
1699
  скаляр или массив в данных, пустой объект, роль одиночного конца, каждый id множественного конца
1628
1700
  (`['links', роль, id]`), каждый тег (`['tags', тег]`), `owner` и `class`. `rev`, `at`, `author`, `agent`, `op` и
1629
1701
  `reason` — от версии, которая последней изменила лист. Пустые `agent` и `reason` опускаются; `id` есть, только если
1630
- это версия прежнего id (история прошла через `reclass` или `rekey`).
1702
+ это версия прежнего id (история прошла через `reclass` или `rekey`). Версии другого объекта на том же id — того,
1703
+ что жил там до переноса или заведён после, — `blame()` не смотрит.
1631
1704
  - `exact: false` — между текущей версией и найденной есть пропуски (история прорежена, обрезана или часть версий не
1632
1705
  видна): лист мог менять и выпавшая версия. `complete: true` — история просмотрена целиком и без пропусков; по
1633
1706
  умолчанию просматривается не больше 1000 последних версий.
@@ -1671,7 +1744,7 @@ const sub = db.watch({ from: 'start', classes: ['Product'] }) // весь жу
1671
1744
  await db.Product(kettle).update({ price: 2100 }).rows()
1672
1745
  let cursor = ''
1673
1746
  for await (const ev of sub) { // ev: { cursor, row, id, class, op, rev, at }
1674
- cursor = ev.cursor // сохраните курсор: watch({ from: cursor }) продолжит после него
1747
+ cursor = sub.cursor! // курсор подписки: watch({ from: cursor }) продолжит после этого события
1675
1748
  if (ev.id === kettle.id && ev.row.data.price === 2100) break // break (или sub.close()) — остановить
1676
1749
  }
1677
1750
  ```
@@ -1689,10 +1762,13 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
1689
1762
  порядке записи. Это не порядок фиксации: версии одного объекта сверяйте по `rev`.
1690
1763
  - Долгая пишущая транзакция в любой базе кластера задерживает поток: `await sub.lag()` показывает отставание в мс.
1691
1764
  - Курсор старше яруса `all` политики хранения — `cursor_expired`: перечитайте текущее состояние и подпишитесь
1692
- с `'now'`.
1765
+ с `'now'`. Время в курсоре подписки `sub.cursor` — время последнего события, а когда подписка догнала журнал, —
1766
+ время опроса, поэтому подписка, к которой сутки не было событий, не устаревает. Курсор события `ev.cursor` хранит
1767
+ время самого события.
1693
1768
  - Без `LISTEN` (`connect({ listen: false })`) подписка опрашивает журнал.
1694
- - Подписка без потерь — это сохранённый курсор: храните `ev.cursor` последнего обработанного события и продолжайте
1695
- с него после перезапуска.
1769
+ - Подписка без потерь — это сохранённый курсор: обработав событие, храните `sub.cursor` (позиция после последнего
1770
+ выданного события) и продолжайте с него после перезапуска. С `ev.cursor` поток тоже продолжится, но после затишья
1771
+ дольше яруса `all` такой курсор старше яруса, и продолжение с него — `cursor_expired`.
1696
1772
 
1697
1773
  ### Проекторы
1698
1774
 
@@ -1774,7 +1850,9 @@ sales.close() // stop() после текущ
1774
1850
  `out.inc(класс, строка, приращения, { tenant })` создаёт строку или дополняет её и прибавляет числа, не читая
1775
1851
  строку (поля, которых нет, считаются от 0). `out.delete(класс, ключ или id, { tenant })` удаляет строку, а если её
1776
1852
  нет, ничего не делает. `await out.get(класс, ключ или id, { tenant })` читает текущую строку вместе с записями
1777
- пачки. `out` пишет только в классы `into` и только в арендатора изменений, переданных в `handle`; иначе —
1853
+ пачки. Строку класса с ключом `delete` и `get` находят по полям ключа (`{ day: '2026-10-29' }`), а по id (строкой
1854
+ или `{ $id }`) — только строку класса без ключа; один id у класса с ключом — `invalid_data`, и пачка откатывается.
1855
+ `out` пишет только в классы `into` и только в арендатора изменений, переданных в `handle`; иначе —
1778
1856
  `derived_class` или `tenant_denied`, и пачка откатывается.
1779
1857
  - Приращения строки, заданной одними полями ключа (или одним `$id`), `out` складывает до отправки в базу, пока между
1780
1858
  ними нет `upsert`, `delete` или `get`: на строку — одна правка за пачку, а правило класса проверяется над итогом её
@@ -1822,9 +1900,13 @@ sales.close() // stop() после текущ
1822
1900
  выбирает он: пользователь арендатора сам к проектору не подключится. Опция
1823
1901
  `tenants: [id, …]` — явный список участников: проектор работает только с этими арендаторами (их классы `into` тоже
1824
1902
  должны быть помечены), а пометку у остальных не учитывает. Источники — семейства `from` у этих арендаторов; класс
1825
- не может быть одновременно источником и результатом. Изменений класса с `history: false` проектор не видит:
1826
- журнала у него нет. Поэтому арендатор, у которого такой класс среди источников, выбывает с кодом `invalid_class`, и
1827
- его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
1903
+ из `into` источником не бывает, даже если попадает в `from`: свои результаты проектор не читает. Изменений класса
1904
+ с `history: false` проектор не видит: журнала у него нет. Поэтому арендатор, у которого такой класс среди
1905
+ источников, выбывает с кодом `invalid_class`, и его производных строк проектор не строит. Включите журнал класса и перестройте арендатора `rebuild({ tenant })`.
1906
+ - Арендатор, который стал участником после первой проекции (администратор пометил его классы позже), получает
1907
+ начальную перестройку пачками: в проекцию попадают и строки, заведённые до вступления. Состав участников проектор
1908
+ перечитывает по сигналу классов, а на подключении без LISTEN (`listen: false`) — в начале `run()` и не реже раза
1909
+ в 5 секунд.
1828
1910
  - `batch` — сколько изменений в пачке (500), `pollMs` — запасной опрос (1000 мс): сигналы источников будят
1829
1911
  проектор раньше.
1830
1912
  - `lockTimeout` — сколько транзакция проектора ждёт чужую блокировку строки (5000 мс; 0 — без предела). Транзакция
@@ -1914,7 +1996,9 @@ const stats = db.cache.stats() // { size, hits,
1914
1996
  - изменение прав и классов, запись через `db.sql`, погашенная сессия и переподключение слушателя сбрасывают весь
1915
1997
  кэш или ответы сессии;
1916
1998
  - срок по умолчанию — 60 секунд; внутри `db.begin()` кэш не работает;
1917
- - без `LISTEN` и в режиме `none` кэш выключен, а `connect({ cache: { ttlOnly: true } })` включает его только по сроку.
1999
+ - без `LISTEN` и в режиме `none` кэш выключен, а `connect({ cache: { ttlOnly: true } })` включает его: ответ живёт до
2000
+ конца срока, если его не сбросит запись этого подключения;
2001
+ - `db.load()`, проектор и `db.rebuildAggregates()` сбрасывают кэш, даже этого подключения, только сигналом базы.
1918
2002
 
1919
2003
  Реестр классов процесс перечитывает по сигналу об изменении классов сразу (одно перечитывание за раз), а реестр и
1920
2004
  ключ сигналов по `*`, по незнакомому токену и по нагрузке не своего режима (а без ключа в режиме `opaque` — по любому
@@ -1934,10 +2018,17 @@ const stats = db.cache.stats() // { size, hits,
1934
2018
  const report = await adm.maintain() // { sessions, codes, thinned, batches, more, queue, skipped }
1935
2019
  ```
1936
2020
 
1937
- `maintain()` удаляет истёкшие сессии и одноразовые коды, прореживает журнал по политике хранения и проверяет
1938
- очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Вызывать может системный администратор,
1939
- роль с правом `letopis.maintain` или администраторское подключение. Запускает его внешний планировщик
1940
- (`letopis maintain`) или сама библиотека: `connect({ maintain: '1h' })`. Из нескольких процессов работает один:
2021
+ `maintain()` удаляет из таблицы `secret` сессии и одноразовые коды всех видов (`OTP` и свои `OTP_<ИМЯ>` из
2022
+ `issueOtp({ kind })`) — истёкшие, а также погашенные сессии и коды, которые уже использованы, заменены новым или
2023
+ исчерпали попытки, — и строки-замки удалённых классов (по две на класс; строку, которую держит транзакция, — следующим
2024
+ вызовом), прореживает журнал по политике хранения
2025
+ и проверяет очередь сигналов PostgreSQL: заполнение больше половины — предупреждение. Строки удалённого аккаунта
2026
+ `maintain()` ждать не нужно: их стирает само удаление ([«Удалить аккаунт»](#удалить-аккаунт)). Вызывать может системный
2027
+ администратор, роль с правом `letopis.maintain` или администраторское подключение. Запускает его внешний
2028
+ планировщик (`letopis maintain`) или сама библиотека: `connect({ maintain: '1h' })`. Интервал — число миллисекунд или
2029
+ строка (`'500ms'`, `'30s'`, `'15m'`, `'1h'`, `'1d'`) от 1 мс до 2³¹−1 мс (около 24 суток): другое значение, в том
2030
+ числе ноль, отрицательное или `NaN`, `connect()` отклоняет с `invalid_data`, не открывая соединений. `close()` ждёт
2031
+ идущий по расписанию запуск: он доделывает текущую пачку и следующую не начинает. Из нескольких процессов работает один:
1941
2032
  каждая пачка — своя транзакция, и первым делом она берёт блокировку обслуживания — строку таблицы `secret`, которую
1942
2033
  ни прикладная, ни посторонняя роль базы не займёт; блокировка снимается с фиксацией пачки. Если блокировку держит
1943
2034
  другой процесс, вызов до первой пачки отвечает `skipped: true`, а между пачками останавливается (продолжит тот
@@ -2139,6 +2230,9 @@ letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пол
2139
2230
  (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
2140
2231
  ```
2141
2232
 
2233
+ `letopis types` и `letopis describe` строят результат по классам сессии, поэтому без действующей сессии (токен истёк,
2234
+ погашен, неверен или не задан, ключ API неверен) выходят с кодом 1 и ошибкой `no_session`, а файл `--out` не трогают.
2235
+
2142
2236
  ### Наблюдение
2143
2237
 
2144
2238
  - `connect({ onQuery, slowMs })`: хук получает на каждый запрос цепочки режим, классы, длительность, число строк и
@@ -2256,17 +2350,19 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2256
2350
  | `link`, `lookup` | `{ account, kind, identifier, meta?, confirmed? }` → id; `{ kind, identifier, requireConfirmed?, ttl?, totp? }` → вход или `null` |
2257
2351
  | `credentials`, `revokeCredential`, `setFlags` | `(account)` → креды без секретов; `(id, { keepCurrent? })`; `(id, { confirmed?, enabled? })` |
2258
2352
  | `sessionFor`, `refresh`, `revoke`, `revokeAll` | `(account, { ttl? })` → токен; `({ ttl? })` → новое время истечения (токен прежний); `()`; `(account?)` → число сессий |
2353
+ | `session` | `(token)` → `{ account, tenant, member, expiresAt, loginAt, methods }` или `null` |
2259
2354
  | `tenants`, `switch` | `()` → `[{ tenant, roles }]`; `(tenant)` |
2260
2355
  | `purgeAccount` | `(account)` → `{ account, rows, versions, memberships, owned }` |
2261
2356
 
2262
- Проверку паролей, кодов и TOTP, выпуск сессий и подтверждение кредов вызывает сервис (права `auth.*`); обычному
2263
- пользователю отвечают `acl_denied`. Креды System, сервисов и системных администраторов меняет только сам аккаунт
2264
- после недавнего интерактивного входа или системный администратор. Исключение — ключи сервиса: свои ключи API и
2265
- пары «ключ + секрет» сервис выпускает и отзывает сам по сессии своего ключа, прочие свои креды — нет; последний
2266
- действующий ключ сервиса не отзывается никем (`acl_denied`). С `totp` вход `verifyPassword` и `lookup` проверяет
2267
- ещё и код TOTP (право `auth.totp`) и выдаёт сессию со способом `TOTP`.
2357
+ Проверку паролей, кодов и TOTP, выпуск сессий, сведения о сессии по токену и подтверждение кредов вызывает сервис
2358
+ (права `auth.*`); обычному пользователю отвечают `acl_denied`. Креды System, сервисов и системных администраторов
2359
+ меняет только сам аккаунт после недавнего интерактивного входа или системный администратор. Исключение — ключи
2360
+ сервиса: свои ключи API и пары «ключ + секрет» сервис выпускает и отзывает сам по сессии своего ключа, прочие свои
2361
+ креды — нет; последний действующий ключ сервиса не отзывается никем (`acl_denied`). С `totp` вход `verifyPassword` и
2362
+ `lookup` проверяет ещё и код TOTP (право `auth.totp`) и выдаёт сессию со способом `TOTP`.
2268
2363
  Вид креда `kind` — `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`, `TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`,
2269
- `OTP`, `TOTP` и `KEY` заняты.
2364
+ `OTP`, `TOTP`, `KEY`, `PROJECTOR` и `LOCK` заняты. То же правило — у вида пароля `user.kind` в `createTenant` и
2365
+ `up({ tenant })`.
2270
2366
 
2271
2367
  ### Проектор
2272
2368
 
@@ -2283,7 +2379,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2283
2379
  | `stop()`, `close()` | остановить `run()` после текущей пачки; то же и отписаться от сигналов |
2284
2380
  | `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds, locked }` |
2285
2381
  | `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` в формате загрузчика |
2382
+ | `ProjectorOut` | `upsert(класс, строка, { tenant })`, `inc(класс, строка, приращения, { tenant })`, `delete(класс, ключ или id, { tenant })`, `get(класс, ключ или id, { tenant })` → `Row \| null`; строка — `ProjectorRow` в формате загрузчика; у класса с ключом `delete` и `get` — по ключу, id — только у класса без ключа |
2287
2383
 
2288
2384
  ### Строка ответа
2289
2385
 
@@ -2300,8 +2396,10 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2300
2396
 
2301
2397
  ### Зарезервированные имена
2302
2398
 
2303
- Имена ниже Proxy разбирает раньше классов, поэтому класс с таким именем недостижим как шаг, а связь с таким
2304
- именем база не примет (`reserved_names()`; список подставляет установщик из `RESERVED_CLASS_NAMES`).
2399
+ Имя метода закрывает класс только там, где этот метод есть: после шага — методы цепочки, у корня `db` — его методы,
2400
+ у батча — его. Поэтому хаб с именем метода только цепочки (например, `count`) доступен первым шагом `db.count(…)`, а
2401
+ после другого шага недостижим; хаб с именем метода корня первым шагом не вызвать. Связь с любым из этих имён база не
2402
+ примет (`reserved_names()`; список подставляет установщик из `RESERVED_CLASS_NAMES`). Не давайте классам эти имена.
2305
2403
 
2306
2404
  - методы цепочки: `then`, `entity`, `paths`, `rows`, `first`, `ids`, `count`, `limit`, `offset`, `sort`, `asOf`,
2307
2405
  `withDeleted`, `deep`, `exact`, `sum`, `avg`, `min`, `max`, `countBy`, `after`, `versions`, `create`, `update`,
@@ -2358,7 +2456,7 @@ npx letopis import --from postgres://…/old --from-schema v1.salon --dsn postgr
2358
2456
  | `tx_rolled_back` | PostgreSQL ответил `ROLLBACK` на `COMMIT`: ошибку перехватили внутри `tr.transaction(fn)` |
2359
2457
  | `no_key` | `upsert`, `rekey` или `idOf` у класса без ключа |
2360
2458
  | `no_path` | между классами соседних шагов нет перехода |
2361
- | `invalid_query` | цепочку или API используют неверно (модификатор шага `deep`, `exact`, `alias`, `tags` или `owner` после глагола, слот не после глагола, курсор `after()` со значением не того типа, что поле `sort`, без `v` или с `id` не uuid и т. п.) |
2459
+ | `invalid_query` | цепочку или API используют неверно (модификатор шага `deep`, `exact`, `alias`, `tags` или `owner` после глагола, модификатор выборки между глаголом и терминалом `rows`, `first`, `ids`, `count` или `paths`, слот не после глагола, курсор `after()` со значением не того типа, что поле `sort`, без `v` или с `id` не uuid и т. п.) |
2362
2460
  | `admin_required` | загрузчику, проектору и `rebuildAggregates` нужно администраторское подключение |
2363
2461
  | `rebuild_required` | проекция построена другой версией проектора (`version`): нужна перестройка `rebuild()`; `rebuild({ tenant })`, пока проекции нет или идёт перестройка пачками |
2364
2462
  | `projector_busy` | перестройку не начать: проектор сейчас работает в другом процессе |
@@ -2431,7 +2529,7 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2431
2529
  **Платформа**
2432
2530
 
2433
2531
  - Нужен PostgreSQL 18 или новее; обходных путей для 15–17 нет. AWS RDS, Google Cloud SQL, Azure Flexible Server
2434
- и Neon поддерживают 18, а Supabase на октябрь 2026 года — только 15 и 17, поэтому там 1.0 не заработает.
2532
+ и Neon поддерживают 18, а Supabase на октябрь 2026 года — только 15 и 17, поэтому там letopis не заработает.
2435
2533
  - База — в кодировке UTF8 (`casefold()` и коллация `pg_unicode_fast`). Если новая основная версия PostgreSQL
2436
2534
  обновит таблицы Unicode, уникальный индекс идентификаторов кредов перестраивается командой `reindex`.
2437
2535
  - PGlite — одно соединение суперпользователя: без параллельных писателей и `LISTEN`, RLS не действует
@@ -2489,8 +2587,8 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2489
2587
  - Горячие цели ссылок (организация, на которую ссылается почти всё) копят блокировки `for key share` и MultiXact,
2490
2588
  как при обычных внешних ключах; это нужно мониторить. Операции одного кошелька идут друг за другом под
2491
2589
  блокировкой его строки ([раздел 8](#8-транзакции-батчи-и-деньги), «Рецепт: кошелёк»).
2492
- - Связь не может называться зарезервированным именем, а хаб с таким именем недостижим как шаг
2493
- ([«Зарезервированные имена»](#зарезервированные-имена)).
2590
+ - Связь не может называться зарезервированным именем, а хаб с таким именем доступен не везде: например, `count` —
2591
+ только первым шагом ([«Зарезервированные имена»](#зарезервированные-имена)).
2494
2592
 
2495
2593
  **Доступ**
2496
2594
 
@@ -2629,6 +2727,12 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2629
2727
  Раздел — для тех, кто развивает сам letopis. Запуск: `cd lib && npm test`. Обвязка сама поднимает контейнер `letopis-pg18` (`postgres:18`,
2630
2728
  порт 15433), если база не отвечает; каждый тестовый файл работает в своей схеме `v2.t_<имя>`.
2631
2729
 
2730
+ В CI тестов нет: перед пушем и выпуском всё, что проверяет поведение, идёт одной командой `cd lib && npm run prerelease` —
2731
+ по очереди typecheck, сверка документов, все тесты, примеры сайта со снимками, песочница, сценарии сервисов, быстрый
2732
+ долгий прогон (15 минут смешанной нагрузки) и проверка пакета с живым прогоном; первый красный шаг останавливает
2733
+ прогон. Та же нагрузка на сутки — `cd lib && npm run soak` (длительность — `SOAK_HOURS`, по умолчанию 24 часа). Тест
2734
+ исправления называется пунктом тест-плана («план 2.0, 7.3 №1») и без исправления падает.
2735
+
2632
2736
  ```
2633
2737
  # connect — подключение под сессией, отказ ролям в обход RLS, реестр, отложенный сигнал, перевод ошибок (этап 2)
2634
2738
  # context — сессии, вход сервиса по ключу, политики RLS этапа 1 (этап 1)
@@ -2675,9 +2779,9 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2675
2779
  # retention — политика хранения: ярусы, конечный срок, особые версии, удалённые, asOf, cursor_expired, часовой пояс, догоняющий запуск, наследование и умолчание — против эталона на TypeScript (этап 6)
2676
2780
  # watch — подписка по курсору: порядок фиксации, долгая транзакция, возобновление, обрыв слушателя, два арендатора (этап 6)
2677
2781
  # cache — кэш результатов: попадание, свои и чужие записи, ключ с пользователем, права, погашенные сессии, обрыв слушателя, ttl, пометка класса, без LISTEN, гонка, db.sql, предел счётчиков сигналов (этап 6; план 1.1, точка А)
2678
- # maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6)
2782
+ # maintain — обслуживание: истёкшие сессии и коды, права, один процесс под блокировкой, расписание (этап 6), строки-замки удалённых классов
2679
2783
  # 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)
2784
+ # upgrade — обновление схемы с 1.4.1 (обычный up() без миграций, план 2.0, 7.8 №2), с 1.1.0 (upgrade: true, миграция 0007) и с 1.0.1 (upgrade: true, миграции 0006 и 0007) пакетами letopis-141, letopis-11 и letopis-1: версии и хэши прежнего движка, verify(), каталог против чистой установки, строки-замки, строки secret без аккаунта стёрты, приложение, подписка и проектор по старым данным, повторный up() (доработки после 1.2.0, этап 1; ревизия 7)
2681
2785
  # scenario-salon — сквозной сценарий салона: 21 акт 0.21 (salon) с матрицей покрытия API 1.0 (этап 6)
2682
2786
  # telemetry — onQuery и slowMs, describe() и метаданные классов с наследованием, describeText (этап 6)
2683
2787
  # history — diff и blame по журналу, причина изменения у глаголов, db.begin() и батча; версия, скрытая условием ACL, колонка changed, пропуск версии, row_changes на 20 тыс. целей (план 1.1, этап 1 и точка А)
@@ -2687,4 +2791,10 @@ p50 — от −0,28 до +0,21 мс. Бенч против 1.0.1 (`bench/vs-101
2687
2791
  # mcp — инструменты MCP: модель и meta только в результатах, права арендатора, JSON-цепочка и подсказки похожих имён, предел строк и байт с курсором, обёртка недоверенных данных, запись только с write, update только с rev, удаление с подтверждением человека (elicitation в ревизиях 2025 и 2026-07-28), причина в blame, учётные данные, схемы инструментов, letopis без SDK; letopis mcp по stdio, обработчик HTTP; окно подтверждения без алиаса и meta, countBy и versions с пределом в самом запросе, предел шагов и условий $or; классы прав — только с allowAclWrite, сессия в арендаторе System — только с allowSystemAdmin; structuredContent с пояснением, подпись requestState пользователя сервера со сроком (план 1.1, этап 5; ревью безопасности №2; точка Б и доработки после неё)
2688
2792
  # import — импорт из 0.21 по фикстуре настоящей 0.21: нарушение ключа, --keyless, счётчики по классам, verify, самоссылка, повтор, id без ключа, висячая ссылка, $$strict и поля на $, update, креды, CLI (этап 7)
2689
2793
  # security-final — итоговое ревью безопасности (этап 8, §7.10 №8.4): блокировка семейства по арендатору корня, account_purge без права и restore/purge чужого id не дают ни утечки, ни блокировки; функции этапа 6, log_refs_at, срезы find_* по журналу и watch_read не выходят за свой арендатор; NOTIFY несёт только имена или токены режима; ключ кэша различает токен, актора и арендатора (этап 8)
2794
+ # semver — правила версий: check:semver требует шаг версии не меньше нужного — мажор за исчезнувший метод или код ошибки, минор за новый метод и новую ревизию движка, патча хватает на тексты (план 2.0, 7.8 №1)
2795
+ # prerelease — проверка перед выпуском: шаги идут строго по очереди, первый красный останавливает прогон, следующие не запускаются (план 2.0, 7.8 №3)
2796
+ # write-contract — обещания README о записи (план 2.0, 7.2): повтор ключа в батче — exists и откат, порядок строк ответа reclass, restore и rekey, цепочка с глаголом в значении слота, загрузчик с existing: update не правит строку другого класса, ответ из кэша не портится правкой прошлого ответа
2797
+ # watch-contract — обещания README о подписке и подключении (план 2.0, 7.4): молчащая подписка опрашивает журнал раз в pollMs, close() подключения закрывает живые подписки, reclass без ключа сбрасывает кэш прежнего класса, новый участник проектора получает начальную перестройку, проектор без LISTEN перечитывает состав, реестров db.as() не больше предела
2798
+ # model-contract — обещания README о моделях, типах, CLI и ошибках (план 2.0, 7.5): letopis types — верный TypeScript для кириллицы, класса без полей и anyOf, полная форма hub() с z.object, понятные ошибки вместо сырых, коды выхода CLI
2799
+ # read-contract — обещания README о чтении и истории (план 2.0, 7.1): срез asOf по классу и по концам после смены класса без смены id, versions, diff и blame с follow после rekey, курсор after() в versions() и paths(), имя поля с @S@
2690
2800
  ```
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;