letopis 1.4.1 → 2.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 (113) hide show
  1. package/AGENT-CHEATSHEET.md +329 -278
  2. package/CHANGELOG.md +558 -1
  3. package/README.md +606 -1037
  4. package/dist/acl.d.ts +78 -1
  5. package/dist/acl.js +264 -13
  6. package/dist/admin.d.ts +10 -141
  7. package/dist/admin.js +24 -175
  8. package/dist/auth.d.ts +2 -2
  9. package/dist/auth.js +5 -15
  10. package/dist/cache.d.ts +8 -1
  11. package/dist/cache.js +33 -10
  12. package/dist/chain.d.ts +19 -41
  13. package/dist/chain.js +169 -201
  14. package/dist/cli.js +51 -88
  15. package/dist/demo/booking.d.ts +352 -36
  16. package/dist/describe.d.ts +47 -0
  17. package/dist/describe.js +195 -0
  18. package/dist/errors.d.ts +59 -13
  19. package/dist/errors.js +190 -29
  20. package/dist/index.d.ts +52 -71
  21. package/dist/index.js +245 -130
  22. package/dist/internal.d.ts +12 -0
  23. package/dist/internal.js +5 -0
  24. package/dist/jsonschema.js +18 -17
  25. package/dist/load.d.ts +20 -12
  26. package/dist/load.js +225 -186
  27. package/dist/mcp.d.ts +5 -7
  28. package/dist/mcp.js +171 -215
  29. package/dist/messages/access.d.ts +90 -0
  30. package/dist/messages/access.js +113 -0
  31. package/dist/messages/api.d.ts +171 -0
  32. package/dist/messages/api.js +215 -0
  33. package/dist/messages/boot.d.ts +201 -0
  34. package/dist/messages/boot.js +256 -0
  35. package/dist/messages/codes.d.ts +172 -0
  36. package/dist/messages/codes.js +173 -0
  37. package/dist/messages/index.d.ts +95 -0
  38. package/dist/messages/index.js +118 -0
  39. package/dist/messages/mcp.d.ts +306 -0
  40. package/dist/messages/mcp.js +387 -0
  41. package/dist/messages/model.d.ts +233 -0
  42. package/dist/messages/model.js +283 -0
  43. package/dist/messages/query.d.ts +131 -0
  44. package/dist/messages/query.js +158 -0
  45. package/dist/messages/sql-access.d.ts +70 -0
  46. package/dist/messages/sql-access.js +87 -0
  47. package/dist/messages/sql-core.d.ts +150 -0
  48. package/dist/messages/sql-core.js +181 -0
  49. package/dist/messages/sql-time.d.ts +71 -0
  50. package/dist/messages/sql-time.js +88 -0
  51. package/dist/messages/sql-write.d.ts +246 -0
  52. package/dist/messages/sql-write.js +270 -0
  53. package/dist/messages/time.d.ts +23 -0
  54. package/dist/messages/time.js +29 -0
  55. package/dist/messages/types.d.ts +27 -0
  56. package/dist/messages/types.js +5 -0
  57. package/dist/messages/write.d.ts +405 -0
  58. package/dist/messages/write.js +509 -0
  59. package/dist/model.d.ts +73 -60
  60. package/dist/model.js +26 -29
  61. package/dist/ops.d.ts +35 -0
  62. package/dist/ops.js +101 -0
  63. package/dist/pglite.d.ts +0 -9
  64. package/dist/pglite.js +3 -1
  65. package/dist/registry.d.ts +3 -19
  66. package/dist/registry.js +3 -16
  67. package/dist/signals.d.ts +5 -9
  68. package/dist/signals.js +5 -9
  69. package/dist/sql.d.ts +13 -6
  70. package/dist/sql.js +176 -83
  71. package/dist/sync.d.ts +9 -3
  72. package/dist/sync.js +51 -21
  73. package/dist/tx.d.ts +26 -1
  74. package/dist/tx.js +44 -14
  75. package/dist/typed.d.ts +240 -45
  76. package/dist/types.d.ts +23 -91
  77. package/dist/types.js +12 -14
  78. package/dist/up.d.ts +53 -32
  79. package/dist/up.js +184 -73
  80. package/dist/uuid.js +5 -5
  81. package/dist/validate.d.ts +13 -1
  82. package/dist/validate.js +61 -29
  83. package/dist/watch.d.ts +11 -10
  84. package/dist/watch.js +33 -70
  85. package/dist/write.d.ts +9 -16
  86. package/dist/write.js +118 -187
  87. package/llms.txt +22 -25
  88. package/package.json +8 -11
  89. package/sql/10-core.sql +7 -50
  90. package/sql/15-errors.sql +64 -10
  91. package/sql/20-context.sql +7 -6
  92. package/sql/30-validate.sql +35 -27
  93. package/sql/40-class.sql +20 -114
  94. package/sql/50-acl.sql +8 -13
  95. package/sql/60-write.sql +220 -667
  96. package/sql/70-read.sql +20 -16
  97. package/sql/80-auth.sql +24 -27
  98. package/sql/90-time.sql +70 -859
  99. package/sql/95-seed.system.sql +7 -14
  100. package/sql/99-revision.sql +2 -1
  101. package/sql/README.md +32 -24
  102. package/sql/migrate/README.md +14 -0
  103. package/AGENT-CHEATSHEET.en.md +0 -485
  104. package/MIGRATION.md +0 -190
  105. package/README.en.md +0 -2859
  106. package/dist/import.d.ts +0 -185
  107. package/dist/import.js +0 -820
  108. package/dist/project.d.ts +0 -350
  109. package/dist/project.js +0 -1224
  110. package/sql/75-history.sql +0 -358
  111. package/sql/88-derived.sql +0 -1235
  112. package/sql/migrate/0006-changed-paths.sql +0 -3
  113. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/llms.txt CHANGED
@@ -1,33 +1,30 @@
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()`.
3
+ > Хранилище версионированных объектов для Node.js на PostgreSQL 18. Каждое изменение — версия в журнале (история, чтение на момент, подписка по курсору). Целостность, изоляцию арендаторов и права доступа проверяет сама база (триггеры и политики RLS), поэтому они действуют и для сырого SQL. Данные читают и пишут цепочками вида `db.Shop(shop).Product().rows()`.
4
4
 
5
- Key facts for writing code with letopis 1.4:
5
+ Главное для кода на letopis:
6
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), …
7
+ - Пакет ESM, Node 22+, PostgreSQL 18+ с pgcrypto, кодировка базы UTF8. Зависимость времени выполнения — `postgres`. Zod 4 — необязательная peer-зависимость, нужна только `letopis/model`; `@modelcontextprotocol/server` — только `letopis/mcp`.
8
+ - Схему ставит `up({ dsn, schema })` от администраторской роли; имя схемы — `v2.<имя>`; `up({ upgrade: true })` обновляет схему отставшей ревизии (миграции и повторный накат), `up({ schema, fresh: true })` пересоздаёт схему — данные теряются. Приложение подключается ролью LOGIN — членом `letopis_app`: `connect({ dsn, schema: 'v2.<имя>', token | apiKey })`. Роль в обход RLS отклоняется (`bypass_rls`), если не передать `allowBypassRls: true`.
9
+ - Классы — хабы (объекты) и связи (отношения с концами). Их описывают `hub()` и `link()` из `letopis/model` и пишут в базу `syncModels()` или `letopis sync`. У класса с ключом id детерминированный, UUID v5 (`db.idOf`); уникальность — по ключу.
10
+ - Глаголы возвращают цепочку; терминал (`rows`, `first`, `ids`, `count`, …) исполняет весь план одной транзакцией. `create` только создаёт (занятый ключ — `exists`), `update` сливает и никогда не создаёт, `upsert` заменяет объект целиком (только у классов с ключом), `delete()` — превью, `delete({ confirm: true })` удаляет.
11
+ - Строгий режим. `connect({ models })` или `connect<Models>()` с файлом `letopis types` дают строгие типы: шаг — только класс или алиас моделей и системных классов, переходы — по концам, слоты — роли класса, поля фильтров, `or()` и агрегатов — поля класса; имя, известное только во время выполнения, — `loose(db)[имя]()`. Во время выполнения: `update()` и `delete({ confirm: true })` без фильтра, id, `tags()`, `owner()` и `limit()` ни у одного шага — `invalid_query` (все строки — `{ all: true }`); неизвестное поле фильтра, `sort()` или агрегата — `invalid_query` с похожими именами (сверяется первый ключ пути), неизвестный класс или роль — `invalid_class` с похожими; неизвестная опция `connect()`, `up()`, `load()`, `watch()`, `createMcpServer()` — `invalid_data` с похожими именами; `after()` перед `count()` и агрегатами — `invalid_query`; хаб с именем метода цепочки, корня `db` или батча — `invalid_class`.
12
+ - Деньги и счётчики: `update({ balance: inc(n) })` прибавляет внутри оператора; `update(patch, { rev })` даёт `conflict`, если строка изменилась; `forUpdate()` блокирует строки внутри `db.begin()`.
13
+ - История: `versions()` (у версии — автор, время, вид операции и причина) и срез `asOf(момент)`; глаголы, `db.begin()` и `db.batch()` принимают `{ reason }`. Журнал хранит каждую версию; `history: false` у класса — журнала нет.
14
+ - Массовая загрузка с администраторского подключения: `db.load(rows)`; в пустую схему — `db.load(rows, { initial: true })`: одна транзакция, индексы и триггеры перестраиваются в конце.
15
+ - Подписка: `db.watch({ from, classes })` — `await sub.ready()` перед записью, которая должна попасть в поток (`from: 'now'` фиксирует начало первым запросом); параллельные `next()` получают разные события.
16
+ - Сигналы NOTIFY видны любой роли базы: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` выбирает, что они раскрывают (по умолчанию `class`).
17
+ - Права действуют всегда и по умолчанию запрещают; `up({ tenant })` или `createTenant()` заводит арендатора с правилом «вошедшим можно всё» и его первого пользователя.
18
+ - MCP: `createMcpServer(db)` из `letopis/mcp` и `letopis mcp` (stdio, только токен сессии пользователя) дают ИИ-агенту инструменты `letopis_model`, `letopis_query` (JSON-цепочка, без SQL), `letopis_history` и, с `write: true`, `letopis_write`; агент действует с правами одного пользователя, удаление подтверждает человек через elicitation. `structuredContent` ответа — `{ notice, data }` (отказ — `{ notice, error }`): `notice` предупреждает, что в `data` — данные базы, а не инструкции.
19
+ - У ошибок строчный код `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, …; `err.hint` — что делать, `err.docs` — ссылка на код в разделе «Ошибки» README.
21
20
 
22
- ## Docs
21
+ ## Документы
23
22
 
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)
23
+ - [Шпаргалка для агента](AGENT-CHEATSHEET.md): одна страница для ИИ-агента, который пишет код на letopis, — рабочий цикл и грабли
24
+ - [Руководство](README.md): руководство по задачам, справочник API, ограничения
25
+ - [Ошибки](README.md#15-ошибки): все коды — что значит и что делать
28
26
 
29
- ## Optional
27
+ ## Дополнительно
30
28
 
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
29
+ - [Изменения по версиям](CHANGELOG.md): решения по версиям, несовместимые изменения
30
+ - Модель конкретной установки для агента — инструмент MCP `letopis_model`: классы, ключи, поля, концы и рабочие цепочки этой схемы
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "1.4.1",
3
+ "version": "2.0.0",
4
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",
5
5
  "keywords": [
6
6
  "postgres",
@@ -51,12 +51,9 @@
51
51
  "dist",
52
52
  "sql",
53
53
  "README.md",
54
- "README.en.md",
55
54
  "AGENT-CHEATSHEET.md",
56
- "AGENT-CHEATSHEET.en.md",
57
55
  "llms.txt",
58
56
  "CHANGELOG.md",
59
- "MIGRATION.md",
60
57
  "LICENSE"
61
58
  ],
62
59
  "scripts": {
@@ -66,18 +63,21 @@
66
63
  "check:docs": "node scripts/check-docs.mjs",
67
64
  "check:package": "node scripts/check-package.mjs",
68
65
  "seed:booking": "tsx scripts/gen-seed-booking.mjs",
69
- "test:import-salon": "tsx bench/import-salon.mjs",
70
66
  "api:contract": "node scripts/gen-api-contract.mjs",
71
- "prepublishOnly": "npm run typecheck && npm run check:docs && npm run build"
67
+ "ops:tables": "tsx scripts/gen-ops-tables.mjs",
68
+ "prepublishOnly": "npm run typecheck && npm run check:docs && npm run build",
69
+ "check:semver": "node scripts/check-semver.mjs",
70
+ "prerelease": "node scripts/prerelease.mjs",
71
+ "soak": "tsx bench/soak.mjs"
72
72
  },
73
73
  "dependencies": {
74
74
  "postgres": "^3.4.9"
75
75
  },
76
76
  "peerDependencies": {
77
- "zod": "^4",
78
77
  "@electric-sql/pglite": "^0.5.8",
79
78
  "@electric-sql/pglite-socket": "^0.2.11",
80
- "@modelcontextprotocol/server": "^2.4.0"
79
+ "@modelcontextprotocol/server": "^2.4.0",
80
+ "zod": "^4"
81
81
  },
82
82
  "peerDependenciesMeta": {
83
83
  "zod": {
@@ -99,9 +99,6 @@
99
99
  "@modelcontextprotocol/client": "^2.4.0",
100
100
  "@modelcontextprotocol/server": "^2.4.0",
101
101
  "@types/node": "^22.20.5",
102
- "letopis-0": "npm:letopis@0.21.0",
103
- "letopis-1": "npm:letopis@1.0.1",
104
- "letopis-11": "npm:letopis@1.1.0",
105
102
  "tsx": "^4.23.15",
106
103
  "typescript": "~5.9.3",
107
104
  "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,7 +56,7 @@ 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
62
  -- от имени System, сервисов, системных администраторов и отключённых аккаунтов действовать нельзя
@@ -64,7 +64,7 @@ begin
64
64
  or coalesce(a.data->'categories' ?| array['System', 'Service'], false)
65
65
  or exists (select 1 from @SCHEMA@.entity x
66
66
  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', 'действовать от имени этого аккаунта нельзя');
67
+ perform @SCHEMA@.lt_fail('sql-core.impersonation_denied');
68
68
  end if;
69
69
  service := s.account;
70
70
  agent := s.account::text;
@@ -73,7 +73,7 @@ begin
73
73
  end if;
74
74
  if want is not null then
75
75
  if not pg_input_is_valid(want, 'uuid') then
76
- perform @SCHEMA@.lt_fail('tenant_denied', 'letopis.tenant — не id арендатора');
76
+ perform @SCHEMA@.lt_fail('sql-core.tenant_not_id');
77
77
  end if;
78
78
  tenant := want::uuid;
79
79
  end if;
@@ -86,7 +86,7 @@ begin
86
86
  select e.id into t from @SCHEMA@.entity e
87
87
  where e.id = tenant and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid and coalesce((e.data->>'enabled')::boolean, true);
88
88
  if m is null or t.id is null then
89
- perform @SCHEMA@.lt_fail('tenant_denied', 'нет членства в арендаторе');
89
+ perform @SCHEMA@.lt_fail('sql-core.no_membership');
90
90
  end if;
91
91
  roles := array(select jsonb_array_elements_text(coalesce(m.data->'roles', '[]'::jsonb)));
92
92
  end if;
@@ -137,7 +137,8 @@ begin
137
137
  and coalesce((s.meta->>'enabled')::boolean, true)
138
138
  and coalesce((a.data->>'enabled')::boolean, true);
139
139
  if k.account is null then
140
- raise exception using errcode = 'LT001', message = 'letopis: no_session: неверный ключ';
140
+ -- DETAIL не было и до каталога (raise без detail): p_detail => 'null' (15-errors.sql)
141
+ perform @SCHEMA@.lt_fail('sql-core.bad_api_key', p_detail => 'null');
141
142
  end if;
142
143
  return @SCHEMA@.new_session(k.account, interval '7 days', jsonb_build_object('methods', jsonb_build_array('APIKEY'), 'cred', k.id));
143
144
  end $$;
@@ -41,7 +41,8 @@ return to_jsonb(s)::text;
41
41
  -- Перевод шаблона JavaScript в регулярное выражение PostgreSQL (этап 1, п. 5): разбор по символам,
42
42
  -- как translatePattern в lib/src/jsonschema.ts. \d \w \s и их отрицания — явными наборами (внутри
43
43
  -- [ ] — без скобок), экранированная обратная косая черта остаётся собой; \b, \p{…}, именованные
44
- -- группы и обратные ссылки отклоняются.
44
+ -- группы и обратные ссылки отклоняются. Отказы здесь и в jp_compile — lt_fail(…, p_detail => 'null'): DETAIL у них
45
+ -- не было и до каталога (raise без detail), у ошибки библиотеки detail нет (15-errors.sql).
45
46
  create or replace function @SCHEMA@.jp_pattern(p text) returns text
46
47
  language plpgsql immutable strict
47
48
  as $$
@@ -52,7 +53,6 @@ declare
52
53
  n text;
53
54
  in_class boolean := false;
54
55
  sp constant text := ' \t\n\r\f\v';
55
- why text;
56
56
  begin
57
57
  while i <= length(p) loop
58
58
  c := substr(p, i, 1);
@@ -60,7 +60,7 @@ begin
60
60
  n := substr(p, i + 1, 1);
61
61
  i := i + 2;
62
62
  if n = '' then
63
- why := 'обратная косая черта в конце шаблона';
63
+ perform @SCHEMA@.lt_fail('sql-core.pattern_trailing_backslash', jsonb_build_object('pattern', p), p_detail => 'null');
64
64
  elsif n = 'd' then
65
65
  res := res || case when in_class then '0-9' else '[0-9]' end;
66
66
  elsif n = 'w' then
@@ -69,26 +69,23 @@ begin
69
69
  res := res || case when in_class then sp else '[' || sp || ']' end;
70
70
  elsif n in ('D', 'W', 'S') then
71
71
  if in_class then
72
- why := '\' || n || ' внутри набора [ ] не переводится';
72
+ perform @SCHEMA@.lt_fail('sql-core.pattern_escape_in_set', jsonb_build_object('pattern', p, 'escape', '\' || n), p_detail => 'null');
73
73
  else
74
74
  res := res || case n when 'D' then '[^0-9]' when 'W' then '[^A-Za-z0-9_]' else '[^' || sp || ']' end;
75
75
  end if;
76
76
  elsif n in ('b', 'B') then
77
- why := '\' || n || ' в PostgreSQL значит другое';
77
+ perform @SCHEMA@.lt_fail('sql-core.pattern_word_boundary', jsonb_build_object('pattern', p, 'escape', '\' || n), p_detail => 'null');
78
78
  elsif n in ('p', 'P') then
79
- why := '\p{…} не поддерживается';
79
+ perform @SCHEMA@.lt_fail('sql-core.pattern_unicode_property', jsonb_build_object('pattern', p), p_detail => 'null');
80
80
  elsif n = 'k' or n ~ '[1-9]' then
81
- why := 'обратные ссылки не поддерживаются';
81
+ perform @SCHEMA@.lt_fail('sql-core.pattern_backreference', jsonb_build_object('pattern', p), p_detail => 'null');
82
82
  else
83
83
  res := res || c || n;
84
84
  end if;
85
- if why is not null then
86
- raise exception using errcode = 'LT010', message = 'letopis: invalid_class: шаблон ' || p || ' несовместим с регулярными выражениями PostgreSQL (' || why || ')';
87
- end if;
88
85
  continue;
89
86
  end if;
90
87
  if not in_class and substr(p, i, 3) = '(?<' and substr(p, i + 3, 1) not in ('=', '!') then
91
- raise exception using errcode = 'LT010', message = 'letopis: invalid_class: шаблон ' || p || ' несовместим с регулярными выражениями PostgreSQL (именованные группы не поддерживаются)';
88
+ perform @SCHEMA@.lt_fail('sql-core.pattern_named_group', jsonb_build_object('pattern', p), p_detail => 'null');
92
89
  end if;
93
90
  if c = '[' and not in_class then
94
91
  in_class := true;
@@ -153,11 +150,11 @@ begin
153
150
  rules := r; return;
154
151
  end if;
155
152
  if jsonb_typeof(p_schema) <> 'object' then
156
- raise exception using errcode = 'LT010', message = format('letopis: invalid_class: описание узла %s — не объект', p_disp);
153
+ perform @SCHEMA@.lt_fail('sql-core.schema_node_not_object', jsonb_build_object('node', p_disp::text), p_detail => 'null');
157
154
  end if;
158
155
  for k in select jsonb_object_keys(p_schema) loop
159
156
  if not (k = any (allowed) or k like 'x-%') then
160
- raise exception using errcode = 'LT010', message = format('letopis: invalid_class: ключевое слово %s не поддерживается (узел %s)', k, p_disp);
157
+ perform @SCHEMA@.lt_fail('sql-core.keyword_unsupported', jsonb_build_object('keyword', k, 'node', p_disp::text), p_detail => 'null');
161
158
  end if;
162
159
  end loop;
163
160
 
@@ -170,7 +167,7 @@ begin
170
167
  tcond := @SCHEMA@.jp_type_cond(p_schema->>'type');
171
168
  end if;
172
169
  if tcond is null then
173
- raise exception using errcode = 'LT010', message = format('letopis: invalid_class: неизвестный тип в узле %s', p_disp);
170
+ perform @SCHEMA@.lt_fail('sql-core.unknown_type', jsonb_build_object('node', p_disp::text), p_detail => 'null');
174
171
  end if;
175
172
  r := r || jsonb_build_object('path', p_disp, 'keyword', 'type', 'path_expr', p_path, 'cond', tcond, 'vars', '{}'::jsonb);
176
173
  end if;
@@ -181,7 +178,7 @@ begin
181
178
  for v in select x from jsonb_array_elements(coalesce(p_schema->'enum', jsonb_build_array(p_schema->'const'))) x loop
182
179
  if jsonb_typeof(v) in ('object', 'array') then
183
180
  if p_in_any then
184
- raise exception using errcode = 'LT010', message = format('letopis: invalid_class: перечисление объектов внутри anyOf не поддерживается (узел %s)', p_disp);
181
+ perform @SCHEMA@.lt_fail('sql-core.enum_objects_in_any_of', jsonb_build_object('node', p_disp::text), p_detail => 'null');
185
182
  end if;
186
183
  r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'enum' then 'enum' else 'const' end,
187
184
  'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb, 'extra', jsonb_build_object('kind', 'enum', 'values', coalesce(p_schema->'enum', jsonb_build_array(p_schema->'const'))));
@@ -227,11 +224,11 @@ begin
227
224
  when 'email' then '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$'
228
225
  else null end) || ')');
229
226
  if t not in ('uuid', 'date', 'date-time', 'email') then
230
- raise exception using errcode = 'LT010', message = format('letopis: invalid_class: format %s не поддерживается (узел %s)', t, p_disp);
227
+ perform @SCHEMA@.lt_fail('sql-core.format_unsupported', jsonb_build_object('format', t, 'node', p_disp::text), p_detail => 'null');
231
228
  end if;
232
229
  if t in ('date', 'date-time') then
233
230
  if p_in_any then
234
- raise exception using errcode = 'LT010', message = format('letopis: invalid_class: format %s внутри anyOf не поддерживается (узел %s)', t, p_disp);
231
+ perform @SCHEMA@.lt_fail('sql-core.format_in_any_of', jsonb_build_object('format', t, 'node', p_disp::text), p_detail => 'null');
235
232
  end if;
236
233
  r := r || jsonb_build_object('path', p_disp, 'keyword', 'format', 'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
237
234
  'extra', jsonb_build_object('kind', 'date', 'type', case t when 'date' then 'date' else 'timestamptz' end,
@@ -241,7 +238,7 @@ begin
241
238
  end if;
242
239
  if p_schema ? 'minLength' or p_schema ? 'maxLength' then
243
240
  if p_in_any then
244
- raise exception using errcode = 'LT010', message = format('letopis: invalid_class: длина строки внутри anyOf не поддерживается (узел %s)', p_disp);
241
+ perform @SCHEMA@.lt_fail('sql-core.string_length_in_any_of', jsonb_build_object('node', p_disp::text), p_detail => 'null');
245
242
  end if;
246
243
  r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'minLength' then 'minLength' else 'maxLength' end,
247
244
  'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
@@ -258,7 +255,7 @@ begin
258
255
  end loop;
259
256
  if coalesce((p_schema->>'uniqueItems')::boolean, false) then
260
257
  if p_in_any then
261
- raise exception using errcode = 'LT010', message = format('letopis: invalid_class: uniqueItems внутри anyOf не поддерживается (узел %s)', p_disp);
258
+ perform @SCHEMA@.lt_fail('sql-core.unique_items_in_any_of', jsonb_build_object('node', p_disp::text), p_detail => 'null');
262
259
  end if;
263
260
  r := r || jsonb_build_object('path', p_disp, 'keyword', 'uniqueItems', 'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
264
261
  'extra', jsonb_build_object('kind', 'unique'));
@@ -277,7 +274,7 @@ begin
277
274
  end if;
278
275
  if p_schema ? 'minProperties' or p_schema ? 'maxProperties' then
279
276
  if p_in_any then
280
- raise exception using errcode = 'LT010', message = format('letopis: invalid_class: minProperties внутри anyOf не поддерживается (узел %s)', p_disp);
277
+ perform @SCHEMA@.lt_fail('sql-core.min_properties_in_any_of', jsonb_build_object('node', p_disp::text), p_detail => 'null');
281
278
  end if;
282
279
  r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'minProperties' then 'minProperties' else 'maxProperties' end,
283
280
  'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
@@ -356,6 +353,8 @@ end $$;
356
353
 
357
354
  -- Проверка данных: общий вызов jsonb_path_match; при отказе — список нарушений по правилам.
358
355
  -- Результат сравнивается через is true: ошибка внутри выражения даёт NULL, а не «да» (этап 1, п. 5).
356
+ -- Нарушение — { path, keyword, message, key, params }: message — русский текст по ключу каталога, рядом его key и
357
+ -- params; библиотека убирает key и params (fromDbError, загрузчик).
359
358
  create or replace function @SCHEMA@.validate_data(p_rules jsonb, p_validator jsonpath, p_vars jsonb, p_data jsonb) returns jsonb
360
359
  language plpgsql stable
361
360
  as $$
@@ -364,6 +363,7 @@ declare
364
363
  bad jsonb := '[]'::jsonb;
365
364
  ok boolean;
366
365
  x jsonb;
366
+ p jsonb;
367
367
  begin
368
368
  ok := jsonb_path_match(p_data, p_validator, p_vars, true) is true;
369
369
  if ok and not exists (select 1 from jsonb_array_elements(p_rules) r where r ? 'extra') then
@@ -375,36 +375,44 @@ begin
375
375
  if (e->'extra'->>'kind') = 'length' and jsonb_typeof(x) = 'string' then
376
376
  if char_length(x #>> '{}') < coalesce((e->'extra'->>'min')::int, 0)
377
377
  or char_length(x #>> '{}') > coalesce((e->'extra'->>'max')::int, 2147483647) then
378
- bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', 'длина строки вне границ');
378
+ bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_string_length'),
379
+ 'key', 'sql-core.issue_string_length', 'params', '{}'::jsonb);
379
380
  end if;
380
381
  elsif (e->'extra'->>'kind') = 'unique' and jsonb_typeof(x) = 'array' then
381
382
  if (select count(*) from jsonb_array_elements(x)) <> (select count(distinct y) from jsonb_array_elements(x) y) then
382
- bad := bad || jsonb_build_object('path', e->'path', 'keyword', 'uniqueItems', 'message', 'элементы повторяются');
383
+ bad := bad || jsonb_build_object('path', e->'path', 'keyword', 'uniqueItems', 'message', @SCHEMA@.lt_text('sql-core.issue_unique_items'),
384
+ 'key', 'sql-core.issue_unique_items', 'params', '{}'::jsonb);
383
385
  end if;
384
386
  elsif (e->'extra'->>'kind') = 'date' and jsonb_typeof(x) = 'string' then
385
387
  -- смысл проверяется только у строки правильной формы: форму проверяет правило format
386
388
  if (x #>> '{}') ~ (e->'extra'->>'re') and not pg_input_is_valid(x #>> '{}', e->'extra'->>'type') then
387
- bad := bad || jsonb_build_object('path', e->'path', 'keyword', 'format', 'message', 'такой даты не бывает');
389
+ bad := bad || jsonb_build_object('path', e->'path', 'keyword', 'format', 'message', @SCHEMA@.lt_text('sql-core.issue_date'),
390
+ 'key', 'sql-core.issue_date', 'params', '{}'::jsonb);
388
391
  end if;
389
392
  elsif (e->'extra'->>'kind') = 'props' and jsonb_typeof(x) = 'object' then
390
393
  if (select count(*) from jsonb_object_keys(x)) < coalesce((e->'extra'->>'min')::int, 0)
391
394
  or (select count(*) from jsonb_object_keys(x)) > coalesce((e->'extra'->>'max')::int, 2147483647) then
392
- bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', 'число ключей вне границ');
395
+ bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_property_count'),
396
+ 'key', 'sql-core.issue_property_count', 'params', '{}'::jsonb);
393
397
  end if;
394
398
  elsif (e->'extra'->>'kind') = 'enum' then
395
399
  if not exists (select 1 from jsonb_array_elements(e->'extra'->'values') y where y = x) then
396
- bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', 'значение не из перечисления');
400
+ bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_enum'),
401
+ 'key', 'sql-core.issue_enum', 'params', '{}'::jsonb);
397
402
  end if;
398
403
  end if;
399
404
  end loop;
400
405
  elsif not ok then
401
406
  if jsonb_path_match(p_data, ('strict !exists(' || (e->>'path_expr') || ' ? (!(' || (e->>'cond') || ')))')::jsonpath, e->'vars', true) is not true then
402
- bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', 'нарушено правило ' || (e->>'keyword'));
407
+ p := jsonb_build_object('keyword', e->>'keyword');
408
+ bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_rule', p),
409
+ 'key', 'sql-core.issue_rule', 'params', p);
403
410
  end if;
404
411
  end if;
405
412
  end loop;
406
413
  if not ok and bad = '[]'::jsonb then
407
- bad := jsonb_build_array(jsonb_build_object('path', '[]'::jsonb, 'keyword', 'schema', 'message', 'данные не проходят описание'));
414
+ bad := jsonb_build_array(jsonb_build_object('path', '[]'::jsonb, 'keyword', 'schema', 'message', @SCHEMA@.lt_text('sql-core.issue_schema'),
415
+ 'key', 'sql-core.issue_schema', 'params', '{}'::jsonb));
408
416
  end if;
409
417
  return bad;
410
418
  end $$;