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.
- package/AGENT-CHEATSHEET.md +12 -5
- package/CHANGELOG.md +40 -0
- package/README.md +202 -46
- package/api-contract.json +4 -0
- package/dist/acl.d.ts +4 -2
- package/dist/admin.d.ts +5 -0
- package/dist/admin.js +10 -5
- package/dist/auth.d.ts +29 -8
- package/dist/auth.js +54 -9
- package/dist/chain.d.ts +19 -13
- package/dist/chain.js +17 -7
- package/dist/cli.js +46 -16
- package/dist/errors.d.ts +4 -5
- package/dist/errors.js +6 -7
- package/dist/index.d.ts +24 -13
- package/dist/index.js +75 -24
- package/dist/load.d.ts +1 -1
- package/dist/log.js +23 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +8 -5
- package/dist/messages/access.d.ts +10 -0
- package/dist/messages/access.js +13 -0
- package/dist/messages/api.d.ts +10 -0
- package/dist/messages/api.js +23 -10
- package/dist/messages/boot.d.ts +5 -0
- package/dist/messages/boot.js +7 -0
- package/dist/messages/codes.js +2 -2
- package/dist/messages/log.d.ts +8 -5
- package/dist/messages/log.js +8 -5
- package/dist/messages/mcp.js +7 -7
- package/dist/messages/model.d.ts +16 -0
- package/dist/messages/model.js +20 -2
- package/dist/messages/write.d.ts +5 -0
- package/dist/messages/write.js +22 -14
- package/dist/model.d.ts +6 -2
- package/dist/model.js +5 -1
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +6 -0
- package/dist/sync.d.ts +3 -4
- package/dist/sync.js +3 -4
- package/dist/typed.d.ts +6 -6
- package/dist/types.d.ts +14 -9
- package/dist/types.js +2 -2
- package/dist/up.d.ts +5 -2
- package/dist/up.js +14 -6
- package/dist/write.d.ts +13 -9
- package/dist/write.js +81 -12
- package/llms.txt +2 -1
- package/package.json +1 -1
- package/sql/60-write.sql +18 -6
package/AGENT-CHEATSHEET.md
CHANGED
|
@@ -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
|
|
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
|
-
любой глубине;
|
|
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`
|
|
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
|
-
удаление — превью и подтверждение человека.
|
|
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.
|
|
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
|
|
75
|
-
npm
|
|
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
|
-
`
|
|
79
|
-
и
|
|
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
|
-
У схемы есть ревизия движка (сейчас
|
|
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 (`
|
|
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` — у класса не бывает своих
|
|
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` — журнал хранит каждую версию).
|
|
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
|
-
|
|
|
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([…])`
|
|
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, и его можно отдать
|
|
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`) — в конце.
|
|
1121
|
-
удалиться у пользователя без права (`acl_denied`), поэтому интерфейсу стоит спросить
|
|
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
|
|
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
|
|
2206
|
-
|
|
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:
|
|
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`
|
|
2220
|
-
|
|
2221
|
-
mcp.fetch(new Request(url, init), { authInfo }) })`.
|
|
2222
|
-
|
|
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
|
-
цепочкам. `
|
|
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
|
-
ключами, секретами и кредами —
|
|
2333
|
-
`
|
|
2334
|
-
|
|
2335
|
-
|
|
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
|
-
**Основные события.** Полный каталог
|
|
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
|
-
Полный машинный список экспортов, методов и текстов ошибок — `
|
|
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
|
|
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 })`.
|