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.
- package/AGENT-CHEATSHEET.md +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +18 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/llms.txt
CHANGED
|
@@ -1,33 +1,50 @@
|
|
|
1
1
|
# letopis
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
- [
|
|
33
|
-
-
|
|
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": "
|
|
4
|
-
"description": "Letopis (летопись):
|
|
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": "
|
|
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
|
-
"
|
|
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
|
-
-- журналом других арендаторов с тем же именем
|
|
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
|
|
111
|
-
-- каскадом внешнего ключа — сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (ревизия 7
|
|
112
|
-
--
|
|
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
|
-
|
|
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
|
-
--
|
|
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
|
|
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)'
|
|
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@;
|
package/sql/20-context.sql
CHANGED
|
@@ -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('
|
|
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('
|
|
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
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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 $$;
|