letopis 3.0.0 → 3.0.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 (50) hide show
  1. package/AGENT-CHEATSHEET.md +12 -5
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +202 -46
  4. package/api-contract.json +4 -0
  5. package/dist/acl.d.ts +4 -2
  6. package/dist/admin.d.ts +5 -0
  7. package/dist/admin.js +10 -5
  8. package/dist/auth.d.ts +29 -8
  9. package/dist/auth.js +54 -9
  10. package/dist/chain.d.ts +19 -13
  11. package/dist/chain.js +17 -7
  12. package/dist/cli.js +46 -16
  13. package/dist/errors.d.ts +4 -5
  14. package/dist/errors.js +6 -7
  15. package/dist/index.d.ts +24 -13
  16. package/dist/index.js +75 -24
  17. package/dist/load.d.ts +1 -1
  18. package/dist/log.js +23 -2
  19. package/dist/mcp.d.ts +1 -1
  20. package/dist/mcp.js +8 -5
  21. package/dist/messages/access.d.ts +10 -0
  22. package/dist/messages/access.js +13 -0
  23. package/dist/messages/api.d.ts +10 -0
  24. package/dist/messages/api.js +23 -10
  25. package/dist/messages/boot.d.ts +5 -0
  26. package/dist/messages/boot.js +7 -0
  27. package/dist/messages/codes.js +2 -2
  28. package/dist/messages/log.d.ts +8 -5
  29. package/dist/messages/log.js +8 -5
  30. package/dist/messages/mcp.js +7 -7
  31. package/dist/messages/model.d.ts +16 -0
  32. package/dist/messages/model.js +20 -2
  33. package/dist/messages/write.d.ts +5 -0
  34. package/dist/messages/write.js +22 -14
  35. package/dist/model.d.ts +6 -2
  36. package/dist/model.js +5 -1
  37. package/dist/registry.d.ts +6 -0
  38. package/dist/registry.js +6 -0
  39. package/dist/sync.d.ts +3 -4
  40. package/dist/sync.js +3 -4
  41. package/dist/typed.d.ts +6 -6
  42. package/dist/types.d.ts +14 -9
  43. package/dist/types.js +2 -2
  44. package/dist/up.d.ts +5 -2
  45. package/dist/up.js +14 -6
  46. package/dist/write.d.ts +13 -9
  47. package/dist/write.js +81 -12
  48. package/llms.txt +2 -1
  49. package/package.json +1 -1
  50. package/sql/60-write.sql +18 -6
@@ -18,7 +18,7 @@
18
18
 
19
19
  ```sh
20
20
  npm init -y && npm pkg set type=module # код приложения — ESM, иначе import → SyntaxError
21
- npm i letopis zod postgres@3 # postgres@3 — та же мажорная версия, что у letopis (пул для createTenant)
21
+ npm i letopis@3 zod postgres@3 # @3 — линия 3 этой памятки; postgres@3 — та же мажорная версия, что у letopis
22
22
  npm i -D tsx
23
23
  ADMIN_DSN='postgres://postgres:…@localhost:5432/app' SERVICE_KEY=lts_… APP_PASSWORD=… USER_PASSWORD=… npx tsx app.ts
24
24
  ```
@@ -215,6 +215,10 @@ const esc = (s: string) => s.replace(/[&<>"']/g, (c) =>
215
215
  // `<li data-id="${esc(row.id)}">${esc(String(row.data.code))}</li>`; обработчик — на список, не onclick из данных
216
216
  ```
217
217
  - Ключ сервиса → файл с правами только владельца или хранилище секретов, не в репозиторий и не клиенту.
218
+ - Строгие типы у фасада → `svc.as(…) as unknown as TypedDb<typeof models>`, не свой `connect({ token })` на вход
219
+ (README, «Строгий режим»).
220
+ - Системный администратор (член System с ролью `admin`) через сервис не входит и `svc.as()` от его имени →
221
+ `acl_denied`: отдельный аккаунт, вход ключом API и `auth.switch(System)` (README, «Аккаунты, арендаторы и членство»).
218
222
 
219
223
  ### 6. Ошибки → текст для человека
220
224
 
@@ -279,7 +283,7 @@ function toHuman(e: unknown): string[] {
279
283
  SQLSTATE и имена ограничения, таблицы, колонки); значения полей `data` строк в `detail` ошибки (`***`); значение
280
284
  аргумента в тексте ошибки letopis (`***`).
281
285
  - Маскируются (`***`): поля с именами паролей, токенов, ключей, секретов, кредов, `cookie` и поля из `redact` — на
282
- любой глубине; учётные данные в строке подключения (`postgres://***@host/db`); пароль в тексте SQL.
286
+ любой глубине; строка подключения — без пользователя и пароля (`postgres://localhost:5432/app`); пароль в тексте SQL.
283
287
  - Пишутся как есть: значения **фильтров** в `chain` (`Item({"code":"i1"})`; данные записи — только именами полей),
284
288
  тексты исключений вашего кода. Фильтр по персональным или секретным полям с другим именем → имя поля в `redact`.
285
289
  Объект ошибки, который получает приложение (`err.message`, `err.cause`), не маскируется.
@@ -529,7 +533,7 @@ const history = await db.Item(w).versions() // все в
529
533
  - `versions()` по пути или по классу без id → только **живые** объекты; с удалёнными → `withDeleted()` на этом шаге
530
534
  (`db.Obj(o).Hold().withDeleted().versions()`) или явный id (`db.Hold(id).versions()`).
531
535
  - Причина версии — опция `reason` глагола, `db.begin({ reason })`, `db.batch(имя, { reason })`. Журнал хранит каждую
532
- версию; класс с `history: false` не даёт `versions`, `asOf`, `restore`, событий подписки.
536
+ версию; класс с `history: false` — без журнала: `versions()` → `[]`, нет `asOf`, `restore`, событий подписки.
533
537
 
534
538
  ### 11. Агенты и MCP
535
539
 
@@ -541,7 +545,8 @@ const server = await createMcpServer(await svc.as(userId, { tenant: TENANT }), {
541
545
  - Только сессия пользователя в арендаторе приложения: без `{ tenant }` — личный арендатор, классов там нет; корень
542
546
  сервиса без `as()` → `user_session_required`; подключение в обход RLS → `bypass_rls`.
543
547
  - `letopis_model`, `letopis_query`, `letopis_history`; с `write: true` — `letopis_write`: `update` только с `rev`,
544
- удаление — превью и подтверждение человека. Форматы ответов — README, «Агенты и MCP».
548
+ удаление — превью и подтверждение человека. Ответ — `structuredContent.data` (`rows` у `letopis_query`); клиент в
549
+ процессе — `Client` и `InMemoryTransport` из `@modelcontextprotocol/client`; аргументы — README, «Агенты и MCP».
545
550
 
546
551
  ### 12. Эксплуатация и завершение
547
552
 
@@ -583,5 +588,7 @@ await adm.close(); await svc.close() // svc закрывает п
583
588
  11. Класс с историей строк не удаляется (`invalid_class`): класс, который придётся удалять, заводят с `history: false`.
584
589
  12. Предупреждения `letopis: …` в консоли (фильтр `undefined`, `sort()` не в конце, `withDeleted()` не у своего шага) —
585
590
  почти всегда ошибка в коде.
586
- 13. SQLSTATE `22P02` (значение не того типа, и в `db.sql`) → `invalid_query` (`api.db_bad_value`), исходная —
591
+ 13. Персональные данные, которые придётся стереть, → класс с `history: false`: журнал хранит каждую версию, а
592
+ `purgeAccount` чужие арендаторы не трогает (README, «Удалить аккаунт»).
593
+ 14. SQLSTATE `22P02` (значение не того типа, и в `db.sql`) → `invalid_query` (`api.db_bad_value`), исходная —
587
594
  `err.cause`.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@
4
4
  «Версии и совместимость» в README; шаг версии сверяет `npm run check:semver`. С 1.4.2 у правки ядра (библиотеки и SQL)
5
5
  в конце пункта указан источник находки: «Источник: тест», «пример», «сервис», «ревью», «soak» или «внедрение».
6
6
 
7
+ ## [3.0.1] — 2026-11-04
8
+
9
+ Патч по полному ревью сайта и пакета: API и ревизия движка (11) прежние. Новое тело функции `delete_preview` схема
10
+ 3.0.0 получает обычным `up()` той же ревизии (файлы ядра накатываются заново; до этого `connect()` предупреждает о
11
+ расхождении штампа).
12
+
13
+ ### Исправления
14
+ - `db.close()`: после закрытия любой вызов подключения — цепочки (и созданные до закрытия), `begin`, `transaction`,
15
+ `sql`, `auth.*`, `acl.*`, `as()`, `batch`, `maintain`, `load`, `watch` — отказ `invalid_query`, ключ `api.closed`.
16
+ Раньше вызовы молча выполнялись и заново открывали пул, процесс не завершался. Повторный `close()` — без ошибки.
17
+ Источник: ревью.
18
+ - Предпросмотр удаления (`delete()` без `confirm`) сообщает о строках каскада, которые пользователь не видит по
19
+ правам: в конце ответа — по строке-сводке на действие (`$action: 'hidden'`, `$hidden` — `delete` | `unset` |
20
+ `restrict`, `$count` — число; класс, id и данные скрытых строк не раскрываются). `delete({ confirm: true, expect })`
21
+ сверяет и сводки (`preview_changed`, если число скрытых строк изменилось) и при строках `restrict` — видимых или
22
+ скрытых — отказывает `delete_restricted` до удаления, `detail: { rows, others }`. Раньше предпросмотр выглядел
23
+ чистым, а подтверждение падало `delete_restricted`. Источник: ревью.
24
+ - `issueOtp({ maxAttempts })`: не целое или вне 1–10 — `invalid_data` (`access.bad_max_attempts`); раньше значение
25
+ принималось. Источник: ревью.
26
+ - `ttl` (`sessionFor`, `issueOtp`, `issueApiKey`, `verifyPassword`, `lookup`, `refresh`, `createTenant({ user })`):
27
+ число секунд, интервал PostgreSQL (`'15 minutes'`) или короткая форма, как у `connect({ maintain })` (`'15m'`, `'2h'`,
28
+ `'30s'`, `'1d'`); иное, ноль и отрицательные — `invalid_data` (`access.bad_ttl`) с подсказкой вместо сырой ошибки
29
+ PostgreSQL 22007. Источник: ревью.
30
+ - `up({ login })`, когда установщик не может выдать роль приложения (PostgreSQL 16+, нет ADMIN OPTION), — `invalid_data`
31
+ (`boot.login_grant_denied`) с подсказкой вместо сырого «permission denied to grant role». Источник: ревью.
32
+ - `invalid_class`: подсказка «сессия в личном арендаторе» — только для личного арендатора; в арендаторе без классов —
33
+ «вызовите syncModels в нём» (`api.unknown_step_empty_tenant`). Источник: ревью.
34
+ - Журнал: маскируются поля, имя которых состоит из слов-секретов (`password`, `pass`, `pwd`, `token`, `secret`,
35
+ `apiKey`, `authorization`, `cookie`, `credentials`, `otp`, …, и составные `newPassword`, `accessToken`); `bypass`,
36
+ `passport`, `isPassive` и подобные больше не скрываются. Источник: ревью.
37
+ - CLI: ошибки разбора аргументов (неизвестный флаг, флаг без значения, лишний аргумент) — по-русски, с подсказкой
38
+ `letopis --help`. Источник: ревью.
39
+ - Тексты ошибок: «предпросмотр» вместо «превью», «шаг роли» и «конец» вместо «слот», «объект» вместо «сущность»;
40
+ подсказки `api.begin_arg`, `api.db_bad_value`, `model.transform_unsupported` — законченные фразы. Источник: ревью.
41
+
42
+ ### Документация
43
+ - JSDoc (справочник сайта): тип `LOG_EVENTS` (объект «имя → { level, ru }»), поля `LoginOptions`, единицы `ttl`,
44
+ `db.sql` с таблицами со схемой, поведение `db.close()`, методы `Registry`, параметры типа `End`; убраны внутренние
45
+ подробности (пути файлов, ключи каталога, «фасад»). Источник: ревью.
46
+
7
47
  ## [3.0.0] — 2026-11-03
8
48
 
9
49
  Мажор: безопасность по умолчанию и журнал событий. Удаление и восстановление без сверки с тем, что видел пользователь,
package/README.md CHANGED
@@ -71,12 +71,16 @@ RLS. Правила одинаково действуют и для библио
71
71
 
72
72
  ```sh
73
73
  npm pkg set type=module # код приложения — ESM: "type": "module" или файлы .mjs
74
- npm install letopis zod postgres@3 # postgres@3 — для createTenant и своих SQL-запросов
75
- npm install -D tsx # запуск примеров на TypeScript: npx tsx app.ts
74
+ npm i letopis@3 zod postgres@3 # postgres@3 — для createTenant и своих SQL-запросов
75
+ npm i -D tsx # запуск примеров на TypeScript: npx tsx app.ts
76
76
  ```
77
77
 
78
- `postgres` — зависимость letopis версии 3. Ставьте ту же мажорную версию: `npm i postgres` без версии поставит 4.x,
79
- и код будет работать, но `createTenant(admin, …)` не пройдёт проверку типов — типы двух копий `postgres` несовместимы.
78
+ `letopis@3` — мажорная линия 3, о которой это руководство: внутри неё обновления не ломают работающий код
79
+ ([«Версии и совместимость»](#версии-и-совместимость)), а `npm i letopis` без версии поставит последний выпуск, и после
80
+ выхода 4.0 им окажется он. `postgres` — зависимость letopis версии 3. Ставьте ту же мажорную версию: `npm i postgres`
81
+ без версии поставит 4.x, и код будет работать, но `createTenant(admin, …)` не пройдёт проверку типов — типы двух копий
82
+ `postgres` несовместимы. Для MCP добавьте `@modelcontextprotocol/server` (и `@modelcontextprotocol/client` — проверять
83
+ сервер своим клиентом), для PGlite — `@electric-sql/pglite @electric-sql/pglite-socket`.
80
84
 
81
85
  Пакет — модуль ES, и примеры написаны на `import`: без `"type": "module"` в `package.json` приложения (или без
82
86
  расширения `.mjs`) Node ответит `SyntaxError: Cannot use import statement outside a module`.
@@ -300,10 +304,10 @@ await up({ dsn: ADMIN, schema: 'shop', signals: 'opaque' }) // дальше up
300
304
  import { up, connect } from 'letopis'
301
305
 
302
306
  const ADMIN = 'postgres://postgres:secret@localhost:5432/shop'
303
- const APP = 'postgres://shop_app:app-secret@localhost:5432/shop'
304
307
 
305
- const r = await up({ dsn: ADMIN, schema: 'shop',
308
+ const r = await up({ dsn: ADMIN, schema: 'shop', login: { user: 'shop_app', password: 'app-secret' },
306
309
  tenant: { name: 'Ромашка', user: { name: 'Анна', login: 'anna@shop.ru', password: 'Ромашка-2026!' } } })
310
+ const APP = r.login!.dsn! // роль приложения shop_app: postgres://shop_app:app-secret@localhost:5432/shop
307
311
  const SHOP = r.tenant!.tenant // арендатор — магазин «Ромашка»
308
312
  const ANNA = r.tenant!.user! // его первый пользователь
309
313
  ```
@@ -402,6 +406,9 @@ const anna = await connect({ dsn: APP, schema: r.schema, token: r.tenant!.token!
402
406
  У подключения есть `db.schema`, `db.token`, `db.tenant` (арендатор сессии) и `db.close()`. Подключения,
403
407
  полученные через `as()`, делят пул с исходным: закрывать нужно исходное, один раз — оно закрывает пул, подписки и все
404
408
  фасады. `close()` у подключения из `as()` ничего не делает: пул общий, и фасад после такого вызова работает дальше.
409
+ После `close()` исходного подключения любой вызов — цепочка, `begin()`, `transaction()`, `sql`, `auth.*` у него и у
410
+ его фасадов — `invalid_query` (`api.closed`, «подключение закрыто (db.close()) — откройте новое connect()»): пул заново
411
+ не открывается, и процесс может завершиться. Повторный `close()` ошибки не даёт.
405
412
 
406
413
  ### Администраторское подключение
407
414
 
@@ -436,7 +443,7 @@ await solo.close() // закрывает оба пул
436
443
 
437
444
  ### Обновление пакета и схемы
438
445
 
439
- У схемы есть ревизия движка (сейчас 10). Если новая версия пакета принесёт новую ревизию, `connect()` только
446
+ У схемы есть ревизия движка (сейчас 11, `r.revision` и константа `DDL_REVISION`). Если новая версия пакета принесёт новую ревизию, `connect()` только
440
447
  предупредит и назовёт команду. Отказывает `up()`: на отставшую схему без `upgrade: true` и на схему новее пакета.
441
448
  К схеме новее пакета (её уже обновил более новый пакет, а старые экземпляры приложения ещё работают) `connect()`
442
449
  подключается с предупреждением
@@ -467,7 +474,7 @@ await solo.close() // закрывает оба пул
467
474
 
468
475
  Текст сообщения об ошибке в API не входит: в патче он может стать точнее. Полагайтесь на `err.code`.
469
476
 
470
- Шаг версии перед каждым выпуском сверяет сценарий `check:semver`: он сравнивает контракт API (`lib/api-contract.json`)
477
+ Шаг версии перед каждым выпуском сверяет сценарий `check:semver`: он сравнивает контракт API (`api-contract.json` в корне пакета)
471
478
  и ревизию движка с прошлым выпуском и требует шаг не меньше нужного. Каждый выпуск проверяет
472
479
  обновление схемы с предыдущего выпуска. Что изменилось при переходе на 3.0 и чем заменить снятое — запись
473
480
  версии в [CHANGELOG.md](CHANGELOG.md).
@@ -596,14 +603,18 @@ id из email подбирается перебором и остаётся в
596
603
  ### Наследование и алиасы
597
604
 
598
605
  `extends` — потомок получает поля, концы и ключ предка. Своё поле с тем же именем заменяет поле предка целиком.
599
- `abstract: true` — у класса не бывает своих строк. Шаг цепочки по классу отдаёт и строки потомков: `db.Customer()`
606
+ `abstract: true` — у класса не бывает своих строк: `create()` абстрактного класса — `invalid_class`
607
+ (`sql-write.class_abstract`, «класс … абстрактный»), строки создают у потомков. Шаг цепочки по классу отдаёт и строки потомков: `db.Customer()`
600
608
  найдёт и `Vip`, а `.exact()` оставит только сам класс. `alias` — второе имя класса для шага цепочки, например
601
609
  по-русски.
602
610
 
603
611
  ### Настройки класса
604
612
 
605
613
  - `history: false` — журнал для класса не ведётся: нет версий, среза на дату, восстановления и событий подписки (по
606
- умолчанию `true` — журнал хранит каждую версию). Сменить `history` у существующего класса может только тот, у кого
614
+ умолчанию `true` — журнал хранит каждую версию). `versions()` такого класса — пустой список без ошибки, с предупреждением
615
+ `api.no_history` в консоль; правка
616
+ перезаписывает данные, а удаление стирает строку без следа в журнале — так хранят данные, которые потом стирают
617
+ ([«Удалить аккаунт»](#удалить-аккаунт)). Сменить `history` у существующего класса может только тот, у кого
607
618
  есть право `READ` и `DELETE` без условий на все строки класса и его потомков: `false` выключает журнал — аудит этих
608
619
  строк.
609
620
  - `ownerDefault: 'actor'` — владелец новых строк — сам пользователь, а не арендатор (по умолчанию `'tenant'`).
@@ -683,6 +694,23 @@ const n = await loose(typed)[name]().count()
683
694
  Чего строгие типы не знают: возврат к узлу связи от класса, с которым она не связана концом, и роль чужого класса
684
695
  шагом; `db.as()`, `db.begin()`, `db.batch()` и `entity()` отдают цепочки без строгих типов.
685
696
 
697
+ Веб-сервису строгие типы нужны у фасада `svc.as()`, а не у своего подключения по токену: свой пул на каждый вход
698
+ исчерпал бы соединения ([«Веб-сервис»](#веб-сервис-одно-подключение-и-безопасность)). Фасад и подключение по токену —
699
+ одни и те же цепочки во время выполнения, различаются только типы, поэтому фасад приводят к `TypedDb` одним
700
+ помощником:
701
+
702
+ ```ts
703
+ import type { Db, TypedDb } from 'letopis'
704
+
705
+ const typedAs = (d: Db) => d as unknown as TypedDb<typeof models> // только тип: проверки во время выполнения те же
706
+ const shopDb = typedAs(await svc.as(ANNA, { tenant: SHOP }))
707
+ await shopDb.Shop({ name: 'Ромашка' }).Product().rows() // строгие типы, как у connect({ models })
708
+ ```
709
+
710
+ Фильтр, собранный во время выполнения (из параметров запроса), — тип `FieldFilter` из `letopis`:
711
+ `const f: Record<string, FieldFilter> = { status: req.query.status }` и `loose(shopDb).Order(f)`; `Record<string,
712
+ unknown>` проверку типов не проходит.
713
+
686
714
  **Отказы во время выполнения** — с моделями и без них:
687
715
 
688
716
  | Что | Ответ |
@@ -700,6 +728,9 @@ const n = await loose(typed)[name]().count()
700
728
  | `createTenant(admin, …)`, где `admin` — не пул `postgres()`: строка DSN, подключение `connect()`, `undefined` | `invalid_query` (`api.create_tenant_admin`) до запросов ([«Первый арендатор и пользователь»](#первый-арендатор-и-пользователь)) |
701
729
  | метод цепочки у результата `versions()`: `versions().rows()`, `.sort()`, `.limit()` | `invalid_query` (`api.versions_terminal`): `versions()` — терминал, модификаторы — до него |
702
730
  | `forUpdate()` с тем, что блокировать нельзя: `count()`, `paths()`, `versions()`, агрегат, `asOf()`, `withDeleted()`, `deep()`, глагол | `lock_unsupported` (`api.for_update_unsupported`): текст называет помеху |
731
+ | поле фильтра (и внутри `or()`), `sort('data.…')` или агрегата, которого нет в описании ни одного класса шага (с потомками, если шаг не `exact()`; у роли — её целей); в фильтре годится и роль конца | `invalid_query` с похожими именами до запроса, у плана записи — до записи. Сверяется только первый ключ пути: путь внутрь поля-объекта не проверяется. У строк `Class` поля — свойства описания класса (`kind`, `alias`, `parent` и другие) |
732
+ | `after()` перед `count()`, `sum()`, `avg()`, `min()`, `max()` или `countBy()` | `invalid_query`: курсор листает строки, а счёт идёт по всем объектам шага |
733
+ | класс с зарезервированным именем | `invalid_class`: хаб — от `hub()`, `syncModels` и базы, связь — от базы ([«Имена классов»](#имена-классов)) |
703
734
 
704
735
  **Предупреждения** — не отказы: цепочка исполняется, а в консоль один раз на место (`console.warn`, текст начинается с
705
736
  `letopis:`) выводится строка:
@@ -709,9 +740,7 @@ const n = await loose(typed)[name]().count()
709
740
  | шаг с аргументом `undefined`: `db.Product(undefined)` | `api.step_undefined_filter` | фильтра нет — шаг находит все строки, как `db.Product()`; чаще всего это пустая переменная с id |
710
741
  | шаг после `sort()`: `db.Shop(shop).sort('data.name').Product()` | `api.sort_not_last` | `sort()` относится к последнему шагу цепочки — ставьте его в конец |
711
742
  | `withDeleted()` не у последнего шага, а у последнего его нет: `db.Order(o).withDeleted().line()` | `api.with_deleted_step` | `withDeleted()` — модификатор своего шага: здесь удалённые заказы и живые строки; удалённые строки — `.line().withDeleted()` ([«Удалённые»](#удалённые)) |
712
- | поле фильтра (и внутри `or()`), `sort('data.…')` или агрегата, которого нет в описании ни одного класса шага (с потомками, если шаг не `exact()`; у роли — её целей); в фильтре годится и роль конца | `invalid_query` с похожими именами до запроса, у плана записи — до записи. Сверяется только первый ключ пути: путь внутрь поля-объекта не проверяется. У строк `Class` поля — свойства описания класса (`kind`, `alias`, `parent` и другие) |
713
- | `after()` перед `count()`, `sum()`, `avg()`, `min()`, `max()` или `countBy()` | `invalid_query`: курсор листает строки, а счёт идёт по всем объектам шага |
714
- | класс с зарезервированным именем | `invalid_class`: хаб — от `hub()`, `syncModels` и базы, связь — от базы ([«Имена классов»](#имена-классов)) |
743
+ | `versions()` у класса с `history: false` | `api.no_history` | журнала у класса нет — ответ пустой ([«Настройки класса»](#настройки-класса)) |
715
744
 
716
745
  ### Имена классов
717
746
 
@@ -752,8 +781,9 @@ try {
752
781
  - `create` только создаёт. Занятый ключ — ошибка `exists` с `detail.id`: id библиотека вычисляет сама. Поэтому у
753
782
  класса с ключом повтор после сбоя сети сразу показывает, что первая попытка прошла. Класс без ключа так не
754
783
  защищён: повтор создаст второй объект.
755
- - Теги новой строки задаёт `.tags([…])` перед глаголом, владельца — `.owner(id)`; явного владельца должны разрешать
756
- права.
784
+ - Теги новой строки задаёт `.tags([…])` после шага класса и перед глаголом —
785
+ `db.Product().tags(['новинка']).create({ … }).rows()`, владельца — так же `.owner(id)`; явного владельца должны
786
+ разрешать права. У корня таких методов нет: `db.tags([…])` ищет класс `tags` — `invalid_class`.
757
787
  - Удалённый объект можно создать снова тем же ключом: новый объект **занимает id удалённого** (id — v5 от ключа), и
758
788
  история id продолжится. Восстановить прежний после этого нельзя: `restore({ expect })` по его надгробию — `not_deleted`
759
789
  (`write.restore_id_taken`). Если удалённое ещё могут вернуть, перед `create()` проверьте
@@ -1038,7 +1068,10 @@ const page2 = await db.Product().sort('data.price', 'desc')
1038
1068
  `sort(поле, 'asc' | 'desc')` — по `'at'`, `'rev'`, `'id'` или `'data.<путь>'`. `after(cursorOf(строка, поле))` —
1039
1069
  постраничный вывод «после такой-то строки»; он требует `sort()`. Такой вывод устойчив к вставкам и удалениям между
1040
1070
  страницами, но строка, у которой изменилось поле сортировки, может выпасть или повториться. Курсор — объект
1041
- `{ v, id, rev }` (значение поля, id и номер версии строки): он переживает JSON, и его можно отдать клиенту. Поле в `cursorOf` должно
1071
+ `{ v, id, rev }` (значение поля, id и номер версии строки): он переживает JSON, и его можно отдать клиенту — например,
1072
+ строкой в ссылке «дальше»: `Buffer.from(JSON.stringify(cursorOf(last, 'data.price'))).toString('base64url')`, обратно —
1073
+ `JSON.parse(Buffer.from(s, 'base64url').toString())` в `after()`; курсор от клиента — недоверенные данные, неверный
1074
+ даёт `invalid_query` (ниже). Поле в `cursorOf` должно
1042
1075
  совпадать с полем `sort`. Значение курсора проверяется по типу поля `sort` до запроса, неверное — `invalid_query`:
1043
1076
  числовому полю нужно конечное число, `rev` — целое, логическому — `true` или `false`, текстовому — строка, uuid и
1044
1077
  `id` — uuid, времени и `at` — строка ISO с поясом, дате — `ГГГГ-ММ-ДД`, полю без описания — строка, число или
@@ -1063,7 +1096,10 @@ const page2 = await db.Product().sort('data.price', 'desc')
1063
1096
  const subtree = await db.Category(tea).Category().deep().rows() // подкатегории любой глубины; $depth — уровень
1064
1097
  ```
1065
1098
 
1066
- `deep(max)` повторяет переход к тому же классу до глубины `max` (по умолчанию 32 уровня). Фильтр, теги и владелец
1099
+ `deep(max)` повторяет переход к тому же классу до глубины `max` (по умолчанию 32 уровня). Нужен конец класса на
1100
+ самого себя (`Parent: one('Category')` в модели выше); обход идёт от узла к детям — к строкам, чей конец ведёт на
1101
+ узел. `deep()` на первом шаге — `invalid_query` (`query.deep_first_step`), на переходе к другому классу —
1102
+ `invalid_query` (`query.deep_hop`); переход без конца на тот же класс — `no_path`. Фильтр, теги и владелец
1067
1103
  шага отбирают узлы любого уровня, но обход не обрывают. Обрывает его только узел, невидимый по правам: он скрывает и
1068
1104
  своё поддерево.
1069
1105
 
@@ -1117,8 +1153,20 @@ try {
1117
1153
  каждой строке превью `$action`: `delete`, `unset` или `restrict`, и `$depth` — уровень каскада (0 — сама цель; у
1118
1154
  строк `unset` и `restrict` его нет). С подтверждением удаляются все цели шага одним оператором, а каскад применяет
1119
1155
  правила концов. Итог — надгробия по уровням каскада, внутри уровня по id (в таком порядке они и в журнале); цели
1120
- классов без журнала (`history: false`) — в конце. Превью не учитывает права: строка с `$action: 'delete'` может не
1121
- удалиться у пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить `acl.checkData(класс, 'DELETE')`.
1156
+ классов без журнала (`history: false`) — в конце. Права на удаление превью не проверяет: строка с
1157
+ `$action: 'delete'` может не удалиться у пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить
1158
+ `acl.checkData(класс, 'DELETE')`.
1159
+
1160
+ **Строки, скрытые правами.** Строки замыкания, которых пользователь не видит (например, оплаты заказа у мастера без
1161
+ права `READ` на них), в превью не раскрываются: в конце превью идёт по одной строке-сводке на действие —
1162
+ `{ id: 'hidden:restrict', $action: 'hidden', $hidden: 'restrict', $count: 2, … }` (порядок `delete`, `unset`,
1163
+ `restrict`, только при числе больше нуля). `$hidden` — что будет со скрытыми строками, `$count` — сколько их; класс,
1164
+ id и данные скрытых строк не показываются. Отличить сводку — `row.$action === 'hidden'`. Сводки входят в `expect` как
1165
+ есть и сверяются по `$hidden` и `$count`: изменилось число скрытых строк — `conflict` (`write.preview_changed`).
1166
+ Сводки `delete` и `unset` удалению не мешают, а превью со строками `restrict` — видимыми или скрытыми — подтверждение
1167
+ отклоняет заранее, до удаления: `delete_restricted` («удалению мешают ссылающиеся строки: видимых N, скрытых правами
1168
+ M»), `err.detail` — `{ rows: [{ id, class }], others: M }`. Пользователю — «ещё M скрытых записей мешают удалению —
1169
+ обратитесь к владельцу».
1122
1170
 
1123
1171
  - `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`) до записи, ничего не
1124
1172
  удалено: удаление не сверено с тем, что видел пользователь. Подсказка ошибки — та же форма в два шага.
@@ -1303,7 +1351,8 @@ const ours = (await db.Order(order).withDeleted().line().withDeleted().rows())
1303
1351
  `delete()` — в [памятке](AGENT-CHEATSHEET.md), «Правила для любой задачи».
1304
1352
 
1305
1353
  Историю удалённого объекта журнал хранит: стереть версии одного объекта нельзя. Удаление аккаунта
1306
- (`db.auth.purgeAccount`, [раздел 10](#10-пользователи-вход-и-права)) стирает данные и журнал его арендатора.
1354
+ (`db.auth.purgeAccount`) стирает данные и журнал только его собственного арендатора; что делать с персональными
1355
+ данными в чужих арендаторах — [«Удалить аккаунт»](#удалить-аккаунт).
1307
1356
 
1308
1357
  ## 8. Транзакции и батчи
1309
1358
 
@@ -1385,7 +1434,8 @@ try {
1385
1434
 
1386
1435
  - `forUpdate()` — только внутри `db.begin()` (иначе `tx_required`) и только с `rows`, `first`, `ids` (иначе
1387
1436
  `lock_unsupported`). Строки блокируются в порядке id; строка, которая перестала подходить под условия шага, в ответ
1388
- не попадает.
1437
+ не попадает. Блокировка — это право записи: строки, которые пользователь видит, но править не может, `forUpdate()`
1438
+ не блокирует — `acl_denied` (`api.lock_denied`).
1389
1439
  - Под `{ rev }` и под `forUpdate()` объект и ключи блокируются в разном порядке: смешение двух путей в параллельных
1390
1440
  запросах одного объекта даёт `40P01`. Внутри блока с `forUpdate()` не ждите внешних вызовов: строку ждут все
1391
1441
  писатели (помогут `lock_timeout` и `idle_in_transaction_session_timeout`).
@@ -1514,12 +1564,43 @@ npx letopis load shops.ndjson products.ndjson --dsn "$ADMIN" --schema v2.shop --
1514
1564
 
1515
1565
  Аккаунт — строка класса `Account` в System: имя, категории (`User`, `Service` и свои), признак `enabled`. Каждый
1516
1566
  аккаунт — сам себе арендатор. Работать в чужом арендаторе позволяет членство `member` — связь пользователя
1517
- (`User`) с арендатором (`Tenant`) с ролями в данных. Аккаунты и их категории правит системный администратор —
1518
- член System с ролью `admin`. Отдельного метода, чтобы назначить его, нет: членство в System с ролью `admin` кладёт
1519
- свой сид установки или загрузчик на администраторском подключении (строка `member` с `tenant: <id System>`).
1520
- Новых пользователей арендатора удобно заводить `createTenant`
1567
+ (`User`) с арендатором (`Tenant`) с ролями в данных. Новых пользователей арендатора удобно заводить `createTenant`
1521
1568
  ([раздел 3](#3-установка-и-подключение)).
1522
1569
 
1570
+ **Системный администратор** — член System с ролью `admin`: правит аккаунты и их категории, удаляет аккаунты
1571
+ (`purgeAccount`), выдаёт креды System и сервисов. Его права действуют, только пока сессия работает в арендаторе
1572
+ System. Отдельного метода, чтобы назначить его, нет; путь — четыре шага: отдельный аккаунт, членство в System
1573
+ загрузчиком на администраторском подключении, первый вход по токену `createTenant`, постоянный вход — своим ключом API.
1574
+
1575
+ ```ts
1576
+ const SYSTEM = '2eba6d0a-1edd-4bb6-a85a-a703dab49035' // id арендатора System — один во всех установках
1577
+ // 1. отдельный аккаунт: свой арендатор без правил («Администрирование»), логин и пароль
1578
+ const rootPool = postgres(ADMIN)
1579
+ const root = await createTenant(rootPool, r.schema, { name: 'Администрирование', grantAll: false,
1580
+ user: { name: 'Root', login: 'root@shop.ru', password: 'Root-2026!' } })
1581
+ await rootPool.end()
1582
+ // 2. членство в System с ролью admin — загрузчиком на администраторском подключении (adm выше)
1583
+ await adm.load({ member: [{ User: root.user, Tenant: SYSTEM, roles: ['admin'] }] }, { tenant: SYSTEM })
1584
+ // 3. первый вход — токен из createTenant; права администратора — после перехода сессии в System
1585
+ const sa = await connect({ dsn: APP, schema: r.schema, token: root.token! })
1586
+ await sa.auth.switch(SYSTEM)
1587
+ // 4. постоянный вход — свой ключ API (отдаётся один раз: храните, как ключ сервиса)
1588
+ const { key: ROOT_KEY } = await sa.auth.issueApiKey({ account: root.user!, name: 'root' })
1589
+ await sa.close()
1590
+ // дальше: const sa = await connect({ dsn: APP, schema, apiKey: ROOT_KEY }); await sa.auth.switch(SYSTEM)
1591
+ ```
1592
+
1593
+ - Системный администратор — не обычный пользователь: сервис его не пускает. `verifyPassword`, `lookup` и
1594
+ `sessionFor` для него — `acl_denied` (`sql-access.no_sessions_for_account`, «сессии для этого аккаунта не
1595
+ выпускаются»), `svc.as()` от его имени — `acl_denied` (`sql-core.impersonation_denied`): иначе сервис получил бы его
1596
+ права. Поэтому назначайте администратором отдельный аккаунт: пользователь, которого сделали администратором,
1597
+ перестаёт входить через сервис.
1598
+ - Вход по ключу API (`connect({ apiKey })`) работает и у него; сессия по ключу начинается в личном
1599
+ арендаторе, поэтому после входа — `auth.switch(SYSTEM)`. Ключ потерян — новый токен даёт повтор `createTenant` с тем
1600
+ же `name` и логином (пароль он не меняет), затем шаги 3–4.
1601
+ - Снять роль — удалить строку `member` в System по превью от имени другого администратора или правкой её `roles`.
1602
+ - Сервер MCP с сессией в System — только с опцией `allowSystemAdmin: true` ([«Агенты и MCP»](#агенты-и-mcp)).
1603
+
1523
1604
  ### Сервис и ключи API
1524
1605
 
1525
1606
  Сервис — аккаунт категории `Service` с ключом API. Ключ установки выдаёт `up()`. Сервис проверяет пароли и коды,
@@ -1581,6 +1662,7 @@ const { code } = await svc.auth.issueOtp({ account: BORIS, identifier: 'boris@lu
1581
1662
  const byCode = await svc.auth.verifyOtp({ identifier: 'boris@lutik.ru', code }) // { account, token } | null
1582
1663
  const { secret, uri } = await svc.auth.enrollTotp({ account: BORIS, issuer: 'Лютик' }) // uri — для QR-кода
1583
1664
  const totpOk = await svc.auth.verifyTotp({ account: BORIS, code: totpCode(secret) }) // первая проверка включает фактор
1665
+ // тот же код второй раз (в том же 30-секундном шаге) отклоняется: вход с totp сразу после verifyTotp — null до следующего шага
1584
1666
  ```
1585
1667
 
1586
1668
  Код одноразовый: успешная проверка и перебор (по умолчанию 5 попыток) его сжигают. Счётчики попыток откатываются
@@ -1893,10 +1975,39 @@ await adm.close()
1893
1975
 
1894
1976
  ### Удалить аккаунт
1895
1977
 
1896
- `db.auth.purgeAccount(аккаунт)` удаляет аккаунт и данные его арендатора. Вызывать может системный администратор или
1897
- владелец его арендатора; себя и последнего владельца арендатора удалить нельзя. Владение строками в чужих
1898
- арендаторах переходит этим арендаторам, сессии гаснут, креды удаляются. Владелец может стереть и арендатора, в
1899
- котором сейчас работает: его сессия вернётся в личный арендатор.
1978
+ `db.auth.purgeAccount(аккаунт)` удаляет аккаунт и его собственного арендатора. Вызывать может системный администратор
1979
+ ([«Аккаунты, арендаторы и членство»](#аккаунты-арендаторы-и-членство)) или владелец (роль `owner`) этого арендатора;
1980
+ себя и последнего владельца арендатора удалить нельзя. Сервису по ключу — `acl_denied`
1981
+ (`sql-time.account_purge_denied`). Ответ — `{ account, rows, versions, memberships, owned }`. Владелец может стереть
1982
+ и арендатора, в котором сейчас работает: его сессия вернётся в личный арендатор.
1983
+
1984
+ Что стирается и что остаётся:
1985
+
1986
+ | Что | После `purgeAccount` |
1987
+ |---|---|
1988
+ | строки собственного арендатора аккаунта и их журнал | стёрты физически (`rows`, `versions`) |
1989
+ | креды, сессии, секреты TOTP, внешние учётные записи (`secret`) | стёрты; сессии гаснут с сигналом |
1990
+ | членства в чужих арендаторах | удалены, надгробие — в журнале (`memberships`) |
1991
+ | строка `Account` | удалена; надгробие с последним снимком (имя, категории) — в журнале System |
1992
+ | строки чужих арендаторов, которыми он владел | остаются, владелец — их арендатор (`owned`) |
1993
+ | строки чужих арендаторов, которые он создал или правил, и **все их версии** | остаются как есть; `author` версий — id удалённого аккаунта |
1994
+ | строки, которые ссылаются на аккаунт концом | мешают: `delete_restricted` (`sql-time.account_referenced`), ничего не удалено |
1995
+
1996
+ Персональные данные клиента в данных арендатора (телефон и почта в строке `Customer` магазина) `purgeAccount` не
1997
+ трогает, а журнал хранит каждую их версию: стереть версии одного объекта нельзя. Поэтому стираемые персональные
1998
+ данные держите в отдельном классе с `history: false` — у него нет версий, правка перезаписывает, а удаление стирает
1999
+ без следа, — и ссылайтесь на него из основного объекта. Стереть клиента — удалить его строку этого класса по превью:
2000
+
2001
+ ```ts
2002
+ const Contact = hub('Contact', { phone: z.string(), email: z.string() }, { history: false }) // без журнала
2003
+ const Client = hub('Client', { name: z.string() }, { ends: { Contact: one(Contact).optional().onDelete('unset') } })
2004
+ // … стереть персональные данные клиента c:
2005
+ const shown = await db.Client(c).Contact().delete().rows()
2006
+ await db.Client(c).Contact().delete({ confirm: true, expect: shown, reason: 'стирание по запросу' }).rows()
2007
+ ```
2008
+
2009
+ Данные, уже записанные в класс с журналом, остаются в его версиях; обезличить можно только текущую версию правкой.
2010
+ Удалить весь арендатор с журналом — `purgeAccount` арендатора.
1900
2011
 
1901
2012
  Системный администратор может удалить и саму строку аккаунта — по превью, `db.Account(id).delete({ confirm: true,
1902
2013
  expect: превью })`, в арендаторе System; данные арендатора аккаунта при этом остаются. Любой путь сразу стирает строки аккаунта в `secret`:
@@ -2202,10 +2313,10 @@ import { createMcpServer } from 'letopis/mcp'
2202
2313
  import { createMcpHandler } from '@modelcontextprotocol/server'
2203
2314
 
2204
2315
  const ADMIN = 'postgres://postgres:secret@localhost:5432/shop'
2205
- const APP = 'postgres://shop_app:app-secret@localhost:5432/shop'
2206
- const r = await up({ dsn: ADMIN, schema: 'shop', quiet: true,
2316
+ const r = await up({ dsn: ADMIN, schema: 'shop', quiet: true, login: { user: 'shop_app', password: 'app-secret' },
2317
+ serviceKey: process.env.SERVICE_KEY, // повторный up() ключ не выдаёт — для повторяемого запуска передайте свой
2207
2318
  tenant: { name: 'Ромашка', user: { name: 'Анна', login: 'anna@shop.ru', password: 'Ромашка-2026!' } } })
2208
- const svc = await connect({ dsn: APP, schema: r.schema, apiKey: r.serviceKey! })
2319
+ const svc = await connect({ dsn: r.login!.dsn!, schema: r.schema, apiKey: (r.serviceKey ?? process.env.SERVICE_KEY)! })
2209
2320
 
2210
2321
  // authInfo — итог проверки токена запроса приложением; в extra оно кладёт id аккаунта letopis
2211
2322
  const mcp = createMcpHandler(async ({ authInfo }) =>
@@ -2216,11 +2327,34 @@ const mcp = createMcpHandler(async ({ authInfo }) =>
2216
2327
  Фабрику обработчик вызывает на каждый запрос HTTP, поэтому `createMcpServer` проверяет подключение каждый раз (один
2217
2328
  запрос к базе).
2218
2329
 
2219
- Проверить обработчик можно без сети: клиент SDK из пакета `@modelcontextprotocol/client` ходит в него через `fetch`
2220
- приложения — `new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { fetch: (url, init) =>
2221
- mcp.fetch(new Request(url, init), { authInfo }) })`. Так пример выше проверяет тест `docs` (сценарий `mcp` в
2222
- `lib/test/docs.test.ts` репозитория): без `write` клиент видит три инструмента, а `letopis_model` отвечает от имени
2223
- Анны в магазине.
2330
+ Проверить обработчик можно без сети: клиент SDK из пакета `@modelcontextprotocol/client` (его ставят отдельно:
2331
+ `npm i -D @modelcontextprotocol/client`) ходит в него через `fetch` приложения — `new StreamableHTTPClientTransport(new
2332
+ URL('http://localhost/mcp'), { fetch: (url, init) => mcp.fetch(new Request(url, init), { authInfo }) })`.
2333
+
2334
+ **Клиент в том же процессе** — без HTTP, парой связанных транспортов `InMemoryTransport` (есть в обоих пакетах SDK):
2335
+
2336
+ ```ts
2337
+ import { Client, InMemoryTransport } from '@modelcontextprotocol/client'
2338
+
2339
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair()
2340
+ const server = await createMcpServer(await svc.as(r.tenant!.user!, { tenant: r.tenant!.tenant }), { write: true })
2341
+ await server.connect(serverSide)
2342
+ const client = new Client({ name: 'check', version: '1' }) // подтверждение удаления — { capabilities: { elicitation: {} } }
2343
+ await client.connect(clientSide)
2344
+ const res = await client.callTool({ name: 'letopis_query', arguments: { class: 'Shop', limit: 10 } })
2345
+ const rows = (res.structuredContent as { data: { rows: unknown[] } }).data.rows // { notice, data: { rows, count, more } }
2346
+ await client.callTool({ name: 'letopis_write', arguments: { op: 'create', class: 'Shop', data: { name: 'Лютик' } } })
2347
+ await client.close()
2348
+ ```
2349
+
2350
+ Аргументы инструментов (полные схемы с описаниями отдаёт `client.listTools()`):
2351
+
2352
+ | Инструмент | Аргументы |
2353
+ |---|---|
2354
+ | `letopis_model` | `class?` |
2355
+ | `letopis_query` | `class`; `id`, `ids` или `filter`; `steps: [{ step, id?, ids?, filter? }]`, `asOf`, `withDeleted`, `sort: { field, dir }`, `limit`, `cursor`, `terminal`, `field` (поле агрегата) |
2356
+ | `letopis_history` | `class`, `id`, `kind?` (`versions`) |
2357
+ | `letopis_write` | `op` (`create`, `update`, `upsert`, `delete`), `class`; `id` или `ids` — цели `update` и `delete`; `data` — поля; `links: { Роль: id \| [id] \| null }`; `rev` — обязателен у `update`; `confirm`, `operation` — подтверждение удаления. Причину версий задаёт сервер (`mcp: letopis_write`), своей нет |
2224
2358
 
2225
2359
  ### Сырой SQL
2226
2360
 
@@ -2230,7 +2364,21 @@ const [{ n }] = await db.sql`select count(*)::int as n from "v2.shop".entity whe
2230
2364
  ```
2231
2365
 
2232
2366
  `` db.sql`…` `` выполняет запрос одной транзакцией под сессией подключения и RLS: видны те же строки, что и
2233
- цепочкам. `db.transaction(fn)` даёт соединение postgres.js на всю транзакцию. При взаимной блокировке и ошибке
2367
+ цепочкам. Таблицы пишут с именем схемы (`"v2.shop".entity`): путь поиска роли схему letopis не содержит, и
2368
+ `from entity` даст `42P01`.
2369
+
2370
+ Имя схемы — **один** идентификатор с точкой внутри, поэтому его берут в двойные кавычки целиком. Идентификатор
2371
+ postgres.js `sql(r.schema)` делит имя по точке (`"v2"."shop"`) — и запрос падает с `0A000` (cross-database
2372
+ references). Имя из переменной вставляют так:
2373
+
2374
+ ```ts
2375
+ const S = `"${r.schema}"` // "v2.shop" — имя схемы целиком в кавычках
2376
+ const [{ n: products }] = await db.transaction((t) =>
2377
+ t`select count(*)::int as n from ${t.unsafe(S)}.entity where class = ${'Product'}`)
2378
+ ```
2379
+
2380
+ `db.sql` — только шаблонный вызов; соединение postgres.js со всеми его помощниками (`t.unsafe`, `t(…)`) даёт
2381
+ `db.transaction(fn)`. `db.transaction(fn)` даёт соединение postgres.js на всю транзакцию. При взаимной блокировке и ошибке
2234
2382
  сериализации запрос и `fn` повторяются до трёх раз, поэтому `fn` должна выдерживать повтор. Писать сырым SQL тоже
2235
2383
  можно — те же триггеры проверят данные, а функции базы `merge` и `inc` доступны приложению.
2236
2384
 
@@ -2329,10 +2477,13 @@ npx letopis maintain --dsn "$ADMIN" --schema v2.shop --api-key "$KEY" --log=debu
2329
2477
  | `cause` | у события `error` — исходная ошибка: `{ name, message, code }` |
2330
2478
 
2331
2479
  **Маскировка.** Без настройки заменяются на `'***'` на любой глубине `detail`: значения полей с паролями, токенами,
2332
- ключами, секретами и кредами — в том числе `password`, `pass`, `passwd`, `pwd`, `passphrase`, `secret`, `token`,
2333
- `serviceKey`, `apiKey`, `api_key`, `credential`, `authorization`, `cookie`, `hash`, `material`, `totp`, `otp`;
2334
- учётные данные в строке подключения (`postgres://***@host/db` — скрыт и пользователь); пароль в тексте SQL
2335
- (`password '…'`). `redact` добавляет свои имена полей (персональные данные и т. п.).
2480
+ ключами, секретами и кредами — по точному имени: `password`, `passwd`, `passphrase`, `pwd`, `secret`, `token`,
2481
+ `apiKey`, `serviceKey`, `authorization`, `cookie`, `credential`, `credentials`, `hash`, `material`, `totp`, `otp`, — и
2482
+ составных имён с ними (`dbPassword`, `refreshToken`, `clientSecret`); слова, где такие буквы лишь внутри (`bypass`),
2483
+ не маскируются. Строка подключения в поле `dsn` пишется без пользователя, пароля и параметров —
2484
+ `postgres://localhost:5432/shop`; в тексте событий учётные данные вида `postgres://user:pass@` заменяются на
2485
+ `postgres://***@`. Пароль в тексте SQL (`password '…'`) — `'***'`. `redact` добавляет свои имена полей (персональные
2486
+ данные и т. п.).
2336
2487
 
2337
2488
  Не пишутся никогда: значения параметров SQL (у событий `sql` и `raw` — только их число); текст, `DETAIL`, `WHERE`,
2338
2489
  `HINT` и внутренний запрос ошибки PostgreSQL — в событии только SQLSTATE и имена ограничения, таблицы, колонки, схемы,
@@ -2350,7 +2501,8 @@ npx letopis maintain --dsn "$ADMIN" --schema v2.shop --api-key "$KEY" --log=debu
2350
2501
  rollback` оставит событие `delete`, а строки — на месте. Фиксацию такой записи доказывает только `tx.commit` с тем же
2351
2502
  `tx`; `tx.rollback` с ним — запись отменена; `commit_unknown` — исход неизвестен (повтор с тем же ключом).
2352
2503
 
2353
- **Основные события.** Полный каталог с уровнями и описаниями — `lib/src/messages/log.ts` (`LOG_EVENTS`).
2504
+ **Основные события.** Полный каталог — `import { LOG_EVENTS } from 'letopis'`: объект «имя события →
2505
+ `{ level, ru }`» (уровень и описание), тип события — `LogEvent`.
2354
2506
 
2355
2507
  | Событие | Уровень | Когда | Главное в событии |
2356
2508
  |---|---|---|---|
@@ -2395,13 +2547,14 @@ await svc.close() // и подключения, полученные чер
2395
2547
 
2396
2548
  ## 14. Справочник API
2397
2549
 
2398
- Полный машинный список экспортов, методов и текстов ошибок — `lib/api-contract.json` в репозитории.
2550
+ Полный машинный список экспортов, методов и текстов ошибок — `api-contract.json` в корне пакета
2551
+ (`node_modules/letopis/api-contract.json`).
2399
2552
 
2400
2553
  ### Экспорты пакета
2401
2554
 
2402
2555
  | Точка входа | Что экспортирует |
2403
2556
  |---|---|
2404
- | `letopis` | `connect`, `loose`, `up`, `createTenant`; операторы `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `inList`, `like`, `ilike`, `starts`, `ends`, `has`, `hasAny`, `hasAll`, `exists`, `isNull`, `not`, `or`, `isOp`; `inc`, `isInc`, `cursorOf`; `idOf`, `uuidv5`, `v5Name`, `v5Id`; `LetopisError`, `ValidationError`, `fromDbError`; `syncModels`, `generateTypes`, `schemaToTs`; `compileValidator`, `applyDefaults`, `Registry`; `totpCode`, `hashPassword`, `verifyHash`; константы `DDL_REVISION`, `RESERVED_CLASS_NAMES`, `CHAIN_METHODS`, `DB_METHODS`; типы `Db`, `DbTx`, `TypedDb`, `SystemModels`, `Chain`, `Row`, `Expected` (строка `expect`: `{ id, rev, $action? }`), `DeleteOptions`, `RestoreOptions`, `Filter`, `Cursor`, `ConnectOptions`, `UpOptions`, `LoadOptions`, `LoadReport`, `WatchOptions`, `WatchEvent`, `AuthApi`, `AclApi`, `MaintainReport` и другие |
2557
+ | `letopis` | `connect`, `loose`, `up`, `createTenant`; операторы `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `inList`, `like`, `ilike`, `starts`, `ends`, `has`, `hasAny`, `hasAll`, `exists`, `isNull`, `not`, `or`, `isOp`; `inc`, `isInc`, `cursorOf`; `idOf`, `uuidv5`, `v5Name`, `v5Id`; `LetopisError`, `ValidationError`, `fromDbError`; `syncModels`, `generateTypes`, `schemaToTs`; `compileValidator`, `applyDefaults`, `Registry`; `totpCode`, `hashPassword`, `verifyHash`; константы `DDL_REVISION`, `RESERVED_CLASS_NAMES`, `CHAIN_METHODS`, `DB_METHODS`, `LOG_EVENTS` (каталог событий журнала); типы `Db`, `DbTx`, `TypedDb`, `SystemModels`, `Chain`, `Row`, `Expected` (строка `expect`: `{ id, rev, $action? }`), `DeleteOptions`, `RestoreOptions`, `Filter`, `FieldFilter` (значение поля в фильтре: `Record<string, FieldFilter>` — фильтр, собранный во время выполнения), `Cursor`, `LogEvent`, `ConnectOptions`, `UpOptions`, `LoadOptions`, `LoadReport`, `WatchOptions`, `WatchEvent`, `AuthApi`, `AclApi`, `MaintainReport` и другие |
2405
2558
  | `letopis/model` | `hub`, `link`, `one`, `many`, `End`, типы `Infer`, `InferLinks`, `Model`, `ClassDef`, `ClassMeta`; `normalizeSchema`, `translatePattern`, тип `JsonSchema` |
2406
2559
  | `letopis/mcp` | `createMcpServer(db, опции)` — сервер MCP SDK с инструментами letopis; тип `McpOptions`: `write` — инструмент `letopis_write` (по умолчанию его нет), `maxRows` — строк в ответе (100, от 1 до 10 000), `maxBytes` — байт в ответе (50 000, от 1 000 до 50 000 000); `allowAclWrite` — `letopis_write` пишет и классы прав (по умолчанию — `acl_denied`); `allowSystemAdmin` — сессия в арендаторе System (по умолчанию — `user_session_required`); значение вне пределов — `invalid_data`. Нужен пакет `@modelcontextprotocol/server` ([раздел 13](#агенты-и-mcp)) |
2407
2560
  | CLI `letopis` | `sync`, `types`, `load`, `maintain`, `mcp` ([раздел 13](#13-эксплуатация)); у всех — `--log[=уровень]` ([«Логирование»](#логирование)); у `mcp` — `--dsn`, `--schema`, `--token` (или `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`) и `--write` |
@@ -2422,7 +2575,7 @@ await svc.close() // и подключения, полученные чер
2422
2575
  | `idOf(класс, ключ)` | id объекта класса с ключом в арендаторе сессии |
2423
2576
  | `entity(строка \| цепочка)` | начать путь с готового узла |
2424
2577
  | `maintain()` | обслуживание ([раздел 13](#13-эксплуатация)) |
2425
- | `refresh()`, `flushSignals()`, `close()` | перечитать классы; отправить сигналы сейчас; закрыть пул и подписки (у фасада `as()` — ничего не делает, в `db.begin()` — откат) |
2578
+ | `refresh()`, `flushSignals()`, `close()` | перечитать классы; отправить сигналы сейчас; закрыть пул и подписки (у фасада `as()` — ничего не делает, в `db.begin()` — откат); после `close()` любой вызов — `invalid_query` (`api.closed`) |
2426
2579
 
2427
2580
  ### Цепочка
2428
2581
 
@@ -2457,6 +2610,9 @@ await svc.close() // и подключения, полученные чер
2457
2610
  сервиса: свои ключи API и пары «ключ + секрет» сервис выпускает и отзывает сам по сессии своего ключа, прочие свои
2458
2611
  креды — нет; последний действующий ключ сервиса не отзывается никем (`acl_denied`). С `totp` вход `verifyPassword` и
2459
2612
  `lookup` проверяет ещё и код TOTP (право `auth.totp`) и выдаёт сессию со способом `TOTP`.
2613
+ Срок `ttl` (`sessionFor`, `verifyPassword`, `issueApiKey`, `issueOtp` и другие) — число **секунд** больше нуля, короткая
2614
+ строка как у `connect({ maintain })` (`'15m'`, `'2h'`, `'30s'`, `'1d'`) или интервал PostgreSQL (`'15 minutes'`); иное —
2615
+ `invalid_data` (`access.bad_ttl`). Без `ttl` — 7 суток. `maxAttempts` у `issueOtp` — целое от 1 до 10, иначе `invalid_data`.
2460
2616
  Вид креда `kind` — `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`, `TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`,
2461
2617
  `OTP`, `TOTP`, `KEY` и `LOCK` заняты. То же правило — у вида пароля `user.kind` в `createTenant` и
2462
2618
  `up({ tenant })`.
package/api-contract.json CHANGED
@@ -341,6 +341,10 @@
341
341
  "module": "60-write",
342
342
  "message": "letopis: invalid_data: "
343
343
  },
344
+ {
345
+ "module": "cli",
346
+ "message": "letopis: <…>"
347
+ },
344
348
  {
345
349
  "module": "errors",
346
350
  "message": "letopis: <…>"