letopis 1.4.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/llms.txt CHANGED
@@ -1,33 +1,50 @@
1
1
  # letopis
2
2
 
3
- > Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
4
-
5
- Key facts for writing code with letopis 1.4:
6
-
7
- - ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`; `@modelcontextprotocol/server`, only by `letopis/mcp`.
8
- - Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
9
- - Classes are hubs (objects) and links (relations with ends), described with `hub()`/`link()` from `letopis/model` and written to the database with `syncModels()` or `letopis sync`. A class with a key gets a deterministic UUID v5 id (`db.idOf`); uniqueness is the key.
10
- - Verbs return a chain; a terminal (`rows`, `first`, `ids`, `count`, …) executes the whole plan in one transaction. `create` only creates (a taken key is `exists`), `update` merges and never creates, `upsert` replaces the whole object (keyed classes only), `delete()` is a preview and `delete({ confirm: true })` deletes.
11
- - Money and counters: `update({ balance: inc(n) })` adds inside the statement; `update(patch, { rev })` fails with `conflict` if the row changed; `forUpdate()` locks rows inside `db.begin()`.
12
- - History: `versions()`, `diff({ from, to })` and `blame()` (who, when and why last changed each field); verbs, `db.begin()` and `db.batch()` take `{ reason }`.
13
- - Bulk loading from an admin connection: `db.load(rows)`; into an empty schema `db.load(rows, { initial: true })` — one transaction, indexes and triggers rebuilt at the end.
14
- - Projectors from an admin connection: `db.projector(name, { from, into, version, handle })` builds rows of derived classes (`derivedBy: name`) from the journal; each batch is one transaction and every change is applied exactly once; `run()`, `rebuild()`, `status()`. Only the projector writes derived rows: the application, raw SQL and the loader get `derived_class`. Only an administrator creates, changes and deletes derived classes (an admin connection with a tenant session, or `letopis sync` with the admin `--dsn`); for an application session it is `acl_denied`. `purge()` of a deleted source object waits until the projector has read the deletion (`projector_pending`).
15
- - Aggregates in the database: the class option `aggregate: { balance: { sum: 'amount', of: 'Movement', via: 'Wallet' } }` (or `count: true`, `where` as a JSON Schema) keeps the field equal to the total of live source rows of the same tenant; a trigger after each statement recalculates it for the library, raw SQL, cascades and the loader; only the database writes the field (`aggregate_field`); repair with `db.rebuildAggregates(class)` on an admin connection, check with `db.verify({ data: true })`.
16
- - Subscriptions: `db.watch({ from, classes })` — `await sub.ready()` before a write that must reach the stream (`from: 'now'` fixes its starting point with the first query); parallel `next()` calls get different events.
17
- - NOTIFY signals are visible to any database role: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` chooses what they reveal (default `class`).
18
- - Access rules are always on and deny by default; `up({ tenant })` or `createTenant()` creates a tenant with the rule "authenticated users may do everything" and its first user.
19
- - MCP: `createMcpServer(db)` from `letopis/mcp` and `letopis mcp` (stdio, a user session token only) give an AI agent the tools `letopis_model`, `letopis_query` (a JSON chain, no SQL), `letopis_history` and, with `write: true`, `letopis_write`; the agent acts with the rights of one user, and a deletion needs a human confirmation through elicitation; by default `letopis_write` does not write the permission classes (`allowAclWrite`), and a session in the System tenant is refused (`allowSystemAdmin`). `structuredContent` of a tool result is `{ notice, data }` (an error — `{ notice, error }`): `notice` warns that `data` holds untrusted stored data, not instructions.
20
- - Errors carry a lowercase `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, `removed` (0.21 API with its replacement), …
21
-
22
- ## Docs
23
-
24
- - [Agent cheatsheet](AGENT-CHEATSHEET.en.md): one page for an AI agent writing code with letopis — working cycle and pitfalls
25
- - [Guide](README.en.md): task-based guide, API reference, errors, limitations
26
- - [Шпаргалка для агента](AGENT-CHEATSHEET.md): the cheatsheet in Russian (original)
27
- - [Руководство](README.md): the guide in Russian (original)
28
-
29
- ## Optional
30
-
31
- - [Migration from 0.21](MIGRATION.md): data import with `letopis import` and the API replacement table (Russian)
32
- - [Changelog](CHANGELOG.md): decisions by version, BREAKING changes of 1.0 (Russian)
33
- - Model of a concrete installation for an agent: `letopis describe --format text --out llms.txt` or `describeText(db.describe())` — classes, keys, fields and ends of that schema
3
+ > ИИ-агенту: код на letopis пишите по [AGENT-CHEATSHEET.md](AGENT-CHEATSHEET.md) — памятка лежит в пакете рядом с этим файлом (`node_modules/letopis/AGENT-CHEATSHEET.md`); прочитайте её целиком: «Правила для любой задачи», рабочий цикл и «Грабли».
4
+ >
5
+ > Хранилище версионированных объектов для Node.js на PostgreSQL 18, версия 3.0. Каждое изменение — версия в журнале (история, чтение на момент, подписка по курсору). Целостность, изоляцию арендаторов и права доступа проверяет сама база (триггеры и политики RLS), поэтому они действуют и для сырого SQL. Данные читают и пишут цепочками вида `db.Team(g).Item().rows()`.
6
+
7
+ Безопасность по умолчанию (одна строка — одно правило):
8
+
9
+ - Удаление — только по превью: `delete()` → показать → `delete({ confirm: true, expect: превью })`; `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`).
10
+ - Восстановление — только по надгробиям: `restore({ expect: ответ delete() })`; `restore()` без `expect`, и по явному id тоже, — `invalid_query` (`write.restore_without_expect`).
11
+ - Массовое и без сверки — только явно и только в осознанных скриптах: `{ all: true }` у `update`, `delete({ confirm: true, all: true })`, `restore({ all: true })`.
12
+ - Арендатор — только из серверной сессии (`svc.auth.session(token)`), никогда из параметра клиента.
13
+ - Права по умолчанию запрещают; подключение в обход RLS — `bypass_rls`, пока нет `allowBypassRls: true`.
14
+ - `createTenant(admin, …)` — `admin` только пул `postgres()`, иначе `invalid_query` (`api.create_tenant_admin`).
15
+ - Журнал событий маскирует значения полей с паролями, токенами, ключами, секретами и кредами (и полей из `redact`), учётные данные в строке подключения и пароль в тексте SQL; значения параметров SQL, тексты ошибок PostgreSQL и поле `data` строк в подробностях ошибок не пишет. Значения фильтров чтения в `chain` пишутся как есть — персональные поля добавьте в `redact`.
16
+
17
+ Главное для кода на letopis:
18
+
19
+ - Установка: `npm pkg set type=module && npm i letopis zod postgres@3 && npm i -D tsx` (код приложения — ESM; `postgres@3` — та же мажорная версия, что у letopis, иначе `createTenant` не проходит проверку типов). Node 22+, PostgreSQL 18+ с pgcrypto, база UTF8. Zod 4 нужен только `letopis/model`, `@modelcontextprotocol/server` — только `letopis/mcp`.
20
+ - Схему ставит `up({ dsn, schema, login, tenant })` от администратора (суперпользователь или роль с `CREATEROLE`, `CREATE` в базе и `CREATEDB`, если базы нет); роль приложения заводит `login`, её строка подключения — `r.login.dsn`. Ключ сервиса выдаёт только первый `up()` — храните его сами или передавайте свой `up({ serviceKey })`; `connect({ apiKey: null | '' })` — сразу `invalid_data` (`api.connect_empty_credential`), а `undefined` — подключение без сессии, поэтому ключ проверяйте до `connect()`; `up({ upgrade: true })` обновляет схему отставшей ревизии, `up({ fresh: true })` пересоздаёт её с потерей данных.
21
+ - Вход пользователя: одно подключение сервиса на процесс (`connect({ apiKey })`), `svc.auth.verifyPassword()` → `{ account, token }`, затем `svc.as(account, { tenant })` на запрос. Без `{ tenant }` сессия — в **личном** арендаторе пользователя (классов там нет, `invalid_class`); проверка — `db.tenant`. По погашенной сессии чтение отдаёт пустые ответы, запись — `no_session`, поэтому `svc.auth.session(token)` — в начале каждого запроса.
22
+ - Классы — хабы (объекты) и связи (отношения с концами): `hub()` и `link()` из `letopis/model`, в базу — `syncModels()` или `letopis sync` (в арендатора сессии). Имя роли конца одно на арендатора; роли `Group`, `Object`, `User`, `Tenant` заняты системными классами `rule` и `member` — конец с таким именем на другой класс — `invalid_class` (`model.end_role_system`).
23
+ - Уникальность и инварианты держит ключ, а не проверка в коде: у класса с ключом id — UUID v5 (`db.idOf`), второй такой `create()` — `exists`, и под гонкой. «Одна открытая запись на объект» — связь с ключом по концу (`key: ['Obj']`); `restore()` бизнес-правил не проверяет, поэтому и при возврате инвариант держит только ключ.
24
+ - Глаголы возвращают цепочку; терминал (`rows`, `first`, `ids`, `count`, …) исполняет весь план одной транзакцией. `create` только создаёт, `update` сливает патч и никогда не создаёт (`undefined` в патче — поле не меняется), `upsert` заменяет данные целиком (поле с `undefined` пропадает; только у классов с ключом).
25
+ - Удаление ровно показанного атомарно: превью сверяется под блокировкой каскада, расхождение — `conflict` (`write.preview_changed`) с новым превью в `err.detail.preview`. Целей нет: непустой `expect` — `conflict` с `err.detail.preview: []` («уже удалено или не найдено»), `expect: []` — пустой ответ.
26
+ - Возврат — по ответу `delete()` (или надгробиям `withDeleted()` по id): `restore({ expect })` — всё или отказ по смыслу (`write.restore_already`, `write.restore_id_taken` — `not_deleted`; `write.restore_stale` — `conflict`); `restore({ expect, partial: true })` — что можно, пропуски — строки `$action: 'skip'` с `$why`. Цель конца — первой, каскад — следующим шагом, оба — в одной `db.begin()` с `commit()`/`rollback()`.
27
+ - Гонка за прочитанное — `forUpdate()` в `db.begin()` (только с `rows`, `first`, `ids`), `update(patch, { rev })` (`conflict`) или ключ; счётчик — `update({ n: inc(k) })`. Фильтр `update()` по данным, концам, `tags()`, `owner()` перепроверяется под блокировкой строки: выведенную параллельной транзакцией из-под фильтра строку он не правит (из двух одновременных правок со `status: 'new'` проходит одна); фильтр только по id — пройдут обе. Форма транзакции — `const tr = await db.begin(); try { …; await tr.commit() } catch (e) { await tr.rollback(); throw e }`; `db.begin(fn)` — `invalid_query`.
28
+ - Строгий режим: `connect({ models })` или `connect<Models>()` дают строгие типы; неизвестное поле, класс или опция — отказ с похожими именами; `update()` без фильтра — `invalid_query` (все строки — `{ all: true }`); id не uuid — `invalid_query` до запроса; фильтр `undefined` — шаг без фильтра и предупреждение.
29
+ - Концы связи — не поля данных: из пути или слотом `.Роль.set(id)`; имя конца в данных — `invalid_data` (`write.link_in_data`).
30
+ - Модификаторы выборки (`sort`, `limit`, `offset`, `after`, `forUpdate`) — последнему шагу; `withDeleted()` — своему шагу. `versions()` — терминал; по пути — только живые, с удалёнными — `withDeleted()` на шаге или явный id.
31
+ - Ответы и ошибки: строка — `{ id, class, rev, links, data, … }`, превью — с `$action`, надгробие — `$deleted: true`; ошибка — `LetopisError` с `err.code`, `err.key`, `err.detail`, `err.hint`, у данных — `ValidationError` с `err.issues: [{ path, keyword, message }]`. Штатные отказы (`exists`, `conflict`, `not_deleted`, `target_not_found`, `tenant_denied`, `acl_denied`, `no_session`, …) — ответ человеку своими фразами по `err.key`/`err.code`; ошибки кода — 500.
32
+ - Логирование: `connect({ log, logLevel, redact })` и `up({ … })` — функция или объект-логгер (`console`, pino), порог `debug|info|warn|error|off` (по умолчанию `info`); без кода — `LETOPIS_LOG=info` (JSON-строки в stderr), CLI — `--log[=уровень]`. События `delete` и `restore` — что удалено и что не вернулось; внутри `db.begin()` событие значит «выполнено в транзакции», фиксацию доказывает `tx.commit` с тем же `tx`. Штатные отказы — событие `error` уровня `warn`, ошибки кода — уровня `error`.
33
+ - Обслуживание — `maintain()` (администраторское подключение, системный администратор или право `letopis.maintain`, иначе `acl_denied`); загрузка — `db.load(rows)` с администраторского подключения; подписка — `db.watch({ from, classes })` и `await sub.ready()` перед записью.
34
+ - MCP: `createMcpServer(await svc.as(пользователь, { tenant }))` из `letopis/mcp` и `letopis mcp` дают ИИ-агенту `letopis_model`, `letopis_query`, `letopis_history` и, с `write: true`, `letopis_write`; удаление подтверждает человек, удаляется ровно показанное превью.
35
+ - Лицензия — PolyForm Noncommercial 1.0.0 (SPDX `PolyForm-Noncommercial-1.0.0`): исходный код доступен, некоммерческое использование бесплатно, коммерческое — только по отдельной лицензии владельца (запрос — issue на https://github.com/alepri51/letopis/issues).
36
+
37
+ ## Документы
38
+
39
+ - [Памятка для агента](AGENT-CHEATSHEET.md): общие правила для любой задачи на letopis, рабочий цикл одной программой и грабли
40
+ - [Руководство](README.md): руководство по задачам, справочник API, ограничения
41
+ - [Безопасность по умолчанию](README.md#безопасность-по-умолчанию): что отклоняется без сверки и как это сделать осознанно
42
+ - [Логирование](README.md#логирование): опции, уровни, `LETOPIS_LOG`, формат события, маскировка, список событий
43
+ - [Ошибки](README.md#15-ошибки): все коды и ключи — что значит, что делать и что сказать человеку
44
+ - Машинный каталог API и ошибок — `api-contract.json` в пакете (`node_modules/letopis/api-contract.json`)
45
+
46
+ ## Дополнительно
47
+
48
+ - [Изменения по версиям](CHANGELOG.md): что изменилось при переходе на 3.0 и чем заменить снятое
49
+ - [Лицензия](LICENSE) и [коммерческое использование](LICENSE-COMMERCIAL.md): что можно без разрешения и как получить коммерческую лицензию
50
+ - Модель конкретной установки для агента — инструмент MCP `letopis_model`: классы, ключи, поля, концы и рабочие цепочки этой схемы
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "1.4.2",
4
- "description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
3
+ "version": "3.0.0",
4
+ "description": "Letopis (летопись): хранилище версионированных сущностей на PostgreSQL 18 — целостность, история, арендаторы и права проверяет сама база, поверх — цепочки через точку. ИИ-агенту — AGENT-CHEATSHEET.md и llms.txt в пакете; исходный код доступен (PolyForm Noncommercial 1.0.0)",
5
5
  "keywords": [
6
6
  "postgres",
7
7
  "postgresql",
@@ -14,9 +14,12 @@
14
14
  "jsonb",
15
15
  "multi-tenant",
16
16
  "acl",
17
- "uuidv5"
17
+ "uuidv5",
18
+ "llms-txt",
19
+ "ai-agents",
20
+ "agent-cheatsheet"
18
21
  ],
19
- "license": "MIT",
22
+ "license": "PolyForm-Noncommercial-1.0.0",
20
23
  "repository": {
21
24
  "type": "git",
22
25
  "url": "git+https://github.com/alepri51/letopis.git",
@@ -51,23 +54,23 @@
51
54
  "dist",
52
55
  "sql",
53
56
  "README.md",
54
- "README.en.md",
55
57
  "AGENT-CHEATSHEET.md",
56
- "AGENT-CHEATSHEET.en.md",
57
58
  "llms.txt",
59
+ "api-contract.json",
58
60
  "CHANGELOG.md",
59
- "MIGRATION.md",
60
- "LICENSE"
61
+ "LICENSE",
62
+ "LICENSE-COMMERCIAL.md"
61
63
  ],
62
64
  "scripts": {
63
65
  "build": "tsc -p tsconfig.build.json",
64
- "typecheck": "tsc --noEmit",
66
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
67
+ "typecheck:all": "tsc --noEmit",
65
68
  "test": "tsx --test --test-concurrency=1 --test-force-exit test/*.test.ts",
66
69
  "check:docs": "node scripts/check-docs.mjs",
67
70
  "check:package": "node scripts/check-package.mjs",
68
71
  "seed:booking": "tsx scripts/gen-seed-booking.mjs",
69
- "test:import-salon": "tsx bench/import-salon.mjs",
70
72
  "api:contract": "node scripts/gen-api-contract.mjs",
73
+ "ops:tables": "tsx scripts/gen-ops-tables.mjs",
71
74
  "prepublishOnly": "npm run typecheck && npm run check:docs && npm run build",
72
75
  "check:semver": "node scripts/check-semver.mjs",
73
76
  "prerelease": "node scripts/prerelease.mjs",
@@ -102,10 +105,6 @@
102
105
  "@modelcontextprotocol/client": "^2.4.0",
103
106
  "@modelcontextprotocol/server": "^2.4.0",
104
107
  "@types/node": "^22.20.5",
105
- "letopis-0": "npm:letopis@0.21.0",
106
- "letopis-1": "npm:letopis@1.0.1",
107
- "letopis-11": "npm:letopis@1.1.0",
108
- "letopis-141": "npm:letopis@1.4.1",
109
108
  "tsx": "^4.23.15",
110
109
  "typescript": "~5.9.3",
111
110
  "zod": "4.6.5"
package/sql/10-core.sql CHANGED
@@ -17,25 +17,6 @@ return (
17
17
  from jsonb_path_query(coalesce(links, '{}'::jsonb), 'lax $.*[*]') as v
18
18
  );
19
19
 
20
- -- Снимок версии для хэша (§2.2). Не зависит от настроек сеанса: время — числом микросекунд,
21
- -- остальное — через текстовый вид jsonb, который от настроек не зависит.
22
- create or replace function @SCHEMA@.snapshot(
23
- id uuid, rev int, class text, tenant uuid, owner uuid, links jsonb, data jsonb, tags text[],
24
- at timestamptz, author uuid, agent text, op text, reason text, moved uuid
25
- ) returns bytea
26
- language sql immutable parallel safe
27
- return convert_to(
28
- jsonb_build_array(
29
- id, rev, class, tenant, owner, links, data, to_jsonb(tags),
30
- (extract(epoch from at) * 1000000)::bigint, author, agent, op, reason, moved
31
- )::text,
32
- 'UTF8'
33
- );
34
-
35
- create or replace function @SCHEMA@.version_hash(prev_hash bytea, snap bytea) returns bytea
36
- language sql immutable parallel safe
37
- return sha256(coalesce(prev_hash, ''::bytea) || snap);
38
-
39
20
  create table if not exists @SCHEMA@.entity (
40
21
  id uuid primary key,
41
22
  rev int not null,
@@ -53,10 +34,6 @@ create table if not exists @SCHEMA@.entity (
53
34
  agent text,
54
35
  op text not null,
55
36
  reason text,
56
- moved uuid,
57
- prev_hash bytea,
58
- hash bytea generated always as (@SCHEMA@.version_hash(prev_hash,
59
- @SCHEMA@.snapshot(id, rev, class, tenant, owner, links, data, tags, at, author, agent, op, reason, moved))) stored,
60
37
  validator jsonpath
61
38
  );
62
39
  create index if not exists entity_class_tenant on @SCHEMA@.entity (class, tenant);
@@ -84,32 +61,19 @@ create table if not exists @SCHEMA@.log (
84
61
  agent text,
85
62
  op text not null,
86
63
  reason text,
87
- moved uuid,
88
- prev_hash bytea,
89
- hash bytea generated always as (@SCHEMA@.version_hash(prev_hash,
90
- @SCHEMA@.snapshot(id, rev, class, tenant, owner, links, data, tags, at, author, agent, op, reason, moved))) stored,
91
- -- пути изменения против предыдущей версии (план 1.1, ревизия 6): производные, в снимок и хэш не входят;
92
- -- null — не посчитано (создание, загрузчик, импорт), blame досчитывает сравнением
93
- changed jsonb,
94
64
  constraint log_id_rev unique (id, rev)
95
65
  );
96
66
  -- срез журнала по классу (asOf, withDeleted) — по арендатору (доработки после точки Б): цена запроса не растёт с
97
- -- журналом других арендаторов с тем же именем класса. Прежний log_class_at (class, at) снимается при накате; на
98
- -- большой установке новый индекс можно построить заранее — create index concurrently log_class_tenant_at …
67
+ -- журналом других арендаторов с тем же именем класса
99
68
  create index if not exists log_class_tenant_at on @SCHEMA@.log (class, tenant, at);
100
- drop index if exists @SCHEMA@.log_class_at;
101
69
  create index if not exists log_tx_seq on @SCHEMA@.log (tx, seq);
102
- -- versions({ follow: true }): переход по связи старого и нового id при reclass и rekey (этап 6)
103
- create index if not exists log_moved on @SCHEMA@.log (moved) where moved is not null;
104
70
  -- срез asOf и withDeleted по переходу и deep (ревизия 5): версии, ссылавшиеся на id, — как entity_ends у
105
71
  -- текущего состояния; без него поиск в журнале перебирал все версии класса (бенч этапа 7)
106
72
  create index if not exists log_ends on @SCHEMA@.log using gin (@SCHEMA@.link_ids(links));
107
- -- журнал часто прореживается (§2.11): чаще чистим
108
- alter table @SCHEMA@.log set (autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.02);
109
73
 
110
- -- account — строка entity (аккаунт; у служебных строк KEY, LOCK, PROJECTOR — System): удалённый аккаунт уносит свои строки
111
- -- каскадом внешнего ключа — сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (ревизия 7; в схему
112
- -- прежней ревизии ключ добавляет миграция 0007). Строка Account у System не удаляется (trg_entity_bd_account, 60-write.sql)
74
+ -- account — строка entity (аккаунт; у служебных строк KEY и LOCK — System): удалённый аккаунт уносит свои строки
75
+ -- каскадом внешнего ключа — сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (ревизия 7). Строка
76
+ -- Account у System не удаляется (trg_entity_bd_account, 60-write.sql)
113
77
  create table if not exists @SCHEMA@.secret (
114
78
  id uuid primary key default gen_random_uuid(),
115
79
  account uuid not null constraint secret_account_fkey references @SCHEMA@.entity (id) on delete cascade,
@@ -131,24 +95,17 @@ alter table @SCHEMA@.entity owner to @OWNER@;
131
95
  alter table @SCHEMA@.log owner to @OWNER@;
132
96
  alter table @SCHEMA@.secret owner to @OWNER@;
133
97
  alter function @SCHEMA@.link_ids(jsonb) owner to @OWNER@;
134
- alter function @SCHEMA@.snapshot(uuid, int, text, uuid, uuid, jsonb, jsonb, text[], timestamptz, uuid, text, text, text, uuid) owner to @OWNER@;
135
- alter function @SCHEMA@.version_hash(bytea, bytea) owner to @OWNER@;
136
98
 
137
99
  -- Права приложения (§2.7, этап 1, п. 1): только пользовательские колонки.
138
100
  revoke all on @SCHEMA@.entity, @SCHEMA@.log, @SCHEMA@.secret from public, @APP@;
139
101
  grant select, delete on @SCHEMA@.entity to @APP@;
140
102
  grant insert (id, tenant, class, links, data, tags, owner) on @SCHEMA@.entity to @APP@;
141
103
  grant update (class, links, data, tags) on @SCHEMA@.entity to @APP@;
142
- -- журнал — без колонки changed (ревью №1 точки А плана 1.1): её пути описывают разницу с предыдущей версией, а та
143
- -- читателю может быть не видна (условие ACL скрыло её и пропустило следующую); blame получает пути через
144
- -- version_paths, которая проверяет видимость обеих версий (75-history.sql)
145
- grant select (seq, tx, id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, hash)
104
+ grant select (seq, tx, id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason)
146
105
  on @SCHEMA@.log to @APP@;
147
106
  alter table @SCHEMA@.entity enable row level security;
148
107
  alter table @SCHEMA@.log enable row level security;
149
108
  alter table @SCHEMA@.secret enable row level security;
150
109
  revoke all on all functions in schema @SCHEMA@ from public;
151
- -- генерируемые колонки ends и hash вычисляются с правами пишущей роли: функции чистые, без побочных эффектов
152
- grant execute on function @SCHEMA@.link_ids(jsonb),
153
- @SCHEMA@.snapshot(uuid, int, text, uuid, uuid, jsonb, jsonb, text[], timestamptz, uuid, text, text, text, uuid),
154
- @SCHEMA@.version_hash(bytea, bytea) to @APP@;
110
+ -- генерируемая колонка ends вычисляется с правами пишущей роли: функция чистая, без побочных эффектов
111
+ grant execute on function @SCHEMA@.link_ids(jsonb) to @APP@;
package/sql/15-errors.sql CHANGED
@@ -16,19 +16,11 @@ as $$
16
16
  ('LT008', 'id_from_db', 'letopis: id_from_db: id класса без ключа выдаёт база'),
17
17
  ('LT009', 'immutable_key', 'letopis: immutable_key: ключевое поле менять нельзя'),
18
18
  ('LT010', 'invalid_class', 'letopis: invalid_class: описание класса отклонено'),
19
- ('LT011', 'use_reclass', 'letopis: use_reclass: класс с ключом меняется функцией reclass()'),
20
- ('LT012', 'reclass_denied', 'letopis: reclass_denied: смена класса отклонена'),
21
19
  ('LT013', 'tightening_conflict', 'letopis: tightening_conflict: ужесточению описания мешают строки'),
22
20
  ('LT014', 'isolation_level', 'letopis: isolation_level: операция разрешена только в read committed'),
23
21
  ('LT015', 'recreated_in_statement', 'letopis: recreated_in_statement: id удалён и создан заново одним оператором'),
24
- ('LT016', 'cursor_expired', 'letopis: cursor_expired: курсор подписки старше яруса all'),
25
22
  ('LT017', 'delete_restricted', 'letopis: delete_restricted: удалению мешают ссылающиеся строки'),
26
- ('LT018', 'not_deleted', 'letopis: not_deleted: объект не удалён — restore и purge только для удалённых'),
27
- ('LT019', 'reset_disabled', 'letopis: reset_disabled: reset выключен в этой установке'),
28
- ('LT020', 'confirm_mismatch', 'letopis: confirm_mismatch: подтверждение не совпадает с именем схемы'),
29
- ('LT021', 'derived_class', 'letopis: derived_class: строки производного класса пишет только его проектор'),
30
- ('LT022', 'aggregate_field', 'letopis: aggregate_field: агрегатное поле считает база'),
31
- ('LT023', 'projector_pending', 'letopis: projector_pending: проектор источника ещё не прочёл удаление')
23
+ ('LT018', 'not_deleted', 'letopis: not_deleted: объект не удалён — restore только для удалённых')
32
24
  $$;
33
25
 
34
26
  create or replace function @SCHEMA@.lt_raise(p_code text, p_name text, p_msg text, p_detail jsonb default null)
@@ -52,12 +44,74 @@ begin
52
44
  perform @SCHEMA@.lt_raise(coalesce(c, 'LT000'), p_name, p_msg, p_detail);
53
45
  end $$;
54
46
 
47
+ -- Каталог сообщений базы (план 2.0, этап 3, п. 3): ключ → имя ошибки и русский шаблон, {имя} — параметр. Строки
48
+ -- подставляет установщик (@MESSAGES@) из разделов sql-* каталога библиотеки (lib/src/messages). Обновляет обычный up(),
49
+ -- как прочие функции.
50
+ create or replace function @SCHEMA@.message_catalog() returns table (key text, name text, ru text)
51
+ language sql immutable parallel safe
52
+ as $catalog$
53
+ select m.key, m.value->>'name', m.value->>'ru' from jsonb_each(@MESSAGES@::jsonb) m
54
+ $catalog$;
55
+
56
+ -- Русский текст по ключу каталога: шаблон, где {имя} — значение p_params текстом (null — пусто, как у format('%s'));
57
+ -- ключа нет — сам ключ. Выдана приложению: функции, которые оно исполняет (lt_fail ему не выдан), бросают raise с ней.
58
+ create or replace function @SCHEMA@.lt_text(p_key text, p_params jsonb default null)
59
+ returns text
60
+ language plpgsql stable parallel safe
61
+ as $$
62
+ declare
63
+ t text;
64
+ p record;
65
+ begin
66
+ select m.ru into t from @SCHEMA@.message_catalog() m where m.key = p_key;
67
+ t := coalesce(t, p_key);
68
+ for p in select * from jsonb_each_text(coalesce(p_params, '{}'::jsonb)) loop
69
+ t := replace(t, '{' || p.key || '}', coalesce(p.value, ''));
70
+ end loop;
71
+ return t;
72
+ end $$;
73
+
74
+ -- Ошибка по ключу каталога сообщений: имя (и SQLSTATE) — из каталога, текст — русский шаблон с параметрами (его видит
75
+ -- сырой SQL), в DETAIL — поля p_detail и ещё key, params: библиотека переносит их в поля ошибки (key, params).
76
+ -- Без параметров — один аргумент: литерал без типа вторым аргументом выбрал бы lt_fail(имя, текст).
77
+ -- p_detail — прежний DETAIL места: не передан — {} (так всегда делала lt_raise); JSON null (p_detail => 'null') — DETAIL
78
+ -- не было (место бросало raise без detail): в DETAIL признак nodetail, и у ошибки библиотеки detail нет, как раньше.
79
+ create or replace function @SCHEMA@.lt_fail(p_key text, p_params jsonb default '{}'::jsonb, p_detail jsonb default null)
80
+ returns void
81
+ language plpgsql
82
+ as $$
83
+ declare
84
+ n text;
85
+ begin
86
+ select m.name into n from @SCHEMA@.message_catalog() m where m.key = p_key;
87
+ perform @SCHEMA@.lt_fail(coalesce(n, p_key), @SCHEMA@.lt_text(p_key, p_params),
88
+ case when jsonb_typeof(p_detail) = 'null' then '{"nodetail": true}'::jsonb else coalesce(p_detail, '{}'::jsonb) end
89
+ || jsonb_build_object('key', p_key, 'params', coalesce(p_params, '{}'::jsonb)));
90
+ end $$;
91
+
92
+ -- То же со своим SQLSTATE не из каталога (например, 40001 — повторяемый конфликт).
93
+ create or replace function @SCHEMA@.lt_raise(p_code text, p_key text, p_params jsonb, p_detail jsonb default null)
94
+ returns void
95
+ language plpgsql
96
+ as $$
97
+ declare
98
+ n text;
99
+ begin
100
+ select m.name into n from @SCHEMA@.message_catalog() m where m.key = p_key;
101
+ perform @SCHEMA@.lt_raise(p_code, coalesce(n, p_key), @SCHEMA@.lt_text(p_key, p_params),
102
+ case when jsonb_typeof(p_detail) = 'null' then '{"nodetail": true}'::jsonb else coalesce(p_detail, '{}'::jsonb) end
103
+ || jsonb_build_object('key', p_key, 'params', coalesce(p_params, '{}'::jsonb)));
104
+ end $$;
105
+
55
106
  do $$
56
107
  declare f text;
57
108
  begin
58
- foreach f in array array['error_catalog()', 'lt_raise(text, text, text, jsonb)', 'lt_fail(text, text, jsonb)'] loop
109
+ foreach f in array array['error_catalog()', 'lt_raise(text, text, text, jsonb)', 'lt_fail(text, text, jsonb)',
110
+ 'message_catalog()', 'lt_text(text, jsonb)', 'lt_fail(text, jsonb, jsonb)', 'lt_raise(text, text, jsonb, jsonb)'] loop
59
111
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
60
112
  execute format('revoke all on function @SCHEMA@.%s from public', f);
61
113
  end loop;
62
114
  end $$;
63
115
  grant execute on function @SCHEMA@.error_catalog() to @APP@;
116
+ grant execute on function @SCHEMA@.message_catalog() to @APP@;
117
+ grant execute on function @SCHEMA@.lt_text(text, jsonb) to @APP@;
@@ -48,7 +48,7 @@ begin
48
48
  if imp is not null and imp is distinct from s.account::text then
49
49
  -- имперсонация (этап 5, п. 3): право сервиса — по правилам System, в его собственном арендаторе
50
50
  if not pg_input_is_valid(imp, 'uuid') then
51
- perform @SCHEMA@.lt_fail('acl_denied', 'letopis.account — не id аккаунта');
51
+ perform @SCHEMA@.lt_fail('sql-core.account_not_id');
52
52
  end if;
53
53
  scats := array(select jsonb_array_elements_text(coalesce(a.data->'categories', '[]'::jsonb)));
54
54
  stenant := tenant;
@@ -56,15 +56,20 @@ begin
56
56
  select jsonb_array_elements_text(coalesce(x.data->'roles', '[]'::jsonb)) from @SCHEMA@.entity x
57
57
  where x.id = @SCHEMA@.v5_id(stenant, 'member', array[s.account::text, stenant::text]) and x.class = 'member') end;
58
58
  if not coalesce((@SCHEMA@.acl_api_for(stenant, scats, sroles, 'auth.impersonate', true)->>'allow')::boolean, false) then
59
- perform @SCHEMA@.lt_fail('acl_denied', 'нет права auth.impersonate');
59
+ perform @SCHEMA@.lt_fail('sql-core.no_impersonate_right');
60
60
  end if;
61
61
  select e.id, e.data into a from @SCHEMA@.entity e where e.id = imp::uuid and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid;
62
+ -- аккаунта нет (не заводили или удалён) — «не найден», а не «нельзя» (3.0): сервис с правом имперсонации отличит
63
+ -- опечатку в id от запрета
64
+ if a.id is null then
65
+ perform @SCHEMA@.lt_fail('sql-core.impersonation_no_account', jsonb_build_object('id', imp));
66
+ end if;
62
67
  -- от имени System, сервисов, системных администраторов и отключённых аккаунтов действовать нельзя
63
- if a.id is null or not coalesce((a.data->>'enabled')::boolean, true) or a.id = '@SYSTEM@'::uuid
68
+ if not coalesce((a.data->>'enabled')::boolean, true) or a.id = '@SYSTEM@'::uuid
64
69
  or coalesce(a.data->'categories' ?| array['System', 'Service'], false)
65
70
  or exists (select 1 from @SCHEMA@.entity x
66
71
  where x.id = @SCHEMA@.v5_id('@SYSTEM@'::uuid, 'member', array[a.id::text, '@SYSTEM@']) and x.class = 'member') then
67
- perform @SCHEMA@.lt_fail('acl_denied', 'действовать от имени этого аккаунта нельзя');
72
+ perform @SCHEMA@.lt_fail('sql-core.impersonation_denied');
68
73
  end if;
69
74
  service := s.account;
70
75
  agent := s.account::text;
@@ -73,7 +78,7 @@ begin
73
78
  end if;
74
79
  if want is not null then
75
80
  if not pg_input_is_valid(want, 'uuid') then
76
- perform @SCHEMA@.lt_fail('tenant_denied', 'letopis.tenant — не id арендатора');
81
+ perform @SCHEMA@.lt_fail('sql-core.tenant_not_id');
77
82
  end if;
78
83
  tenant := want::uuid;
79
84
  end if;
@@ -86,7 +91,7 @@ begin
86
91
  select e.id into t from @SCHEMA@.entity e
87
92
  where e.id = tenant and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid and coalesce((e.data->>'enabled')::boolean, true);
88
93
  if m is null or t.id is null then
89
- perform @SCHEMA@.lt_fail('tenant_denied', 'нет членства в арендаторе');
94
+ perform @SCHEMA@.lt_fail('sql-core.no_membership');
90
95
  end if;
91
96
  roles := array(select jsonb_array_elements_text(coalesce(m.data->'roles', '[]'::jsonb)));
92
97
  end if;
@@ -119,7 +124,7 @@ begin
119
124
  end $$;
120
125
 
121
126
  -- Вход по API-ключу (APIKEY): префикс ищется в ident, ключ сверяется по sha256. Сессия помнит ключ
122
- -- (meta.cred): отзыв ключа гасит только его сессии.
127
+ -- (meta.cred): отзыв ключа гасит только его сессии. Вход и отзыв синхронизированы по строке ключа (for share).
123
128
  create or replace function @SCHEMA@.login_apikey(p_key text) returns text
124
129
  language plpgsql volatile security definer
125
130
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -135,9 +140,13 @@ begin
135
140
  and s.material = sha256(convert_to(p_key, 'UTF8'))
136
141
  and (s.expires_at is null or s.expires_at > clock_timestamp())
137
142
  and coalesce((s.meta->>'enabled')::boolean, true)
138
- and coalesce((a.data->>'enabled')::boolean, true);
143
+ and coalesce((a.data->>'enabled')::boolean, true)
144
+ -- строка ключа — под блокировкой до конца транзакции входа: отзыв (auth_revoke_credential) ждёт её
145
+ -- фиксации и гасит открытую здесь сессию, а вход после отзыва ключа не находит
146
+ for share of s;
139
147
  if k.account is null then
140
- raise exception using errcode = 'LT001', message = 'letopis: no_session: неверный ключ';
148
+ -- DETAIL не было и до каталога (raise без detail): p_detail => 'null' (15-errors.sql)
149
+ perform @SCHEMA@.lt_fail('sql-core.bad_api_key', p_detail => 'null');
141
150
  end if;
142
151
  return @SCHEMA@.new_session(k.account, interval '7 days', jsonb_build_object('methods', jsonb_build_array('APIKEY'), 'cred', k.id));
143
152
  end $$;