letopis 1.4.2 → 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.
- package/AGENT-CHEATSHEET.md +329 -281
- package/CHANGELOG.md +418 -0
- package/README.md +572 -1053
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -184
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +18 -41
- package/dist/chain.js +165 -203
- package/dist/cli.js +45 -90
- 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 +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -70
- package/dist/index.js +189 -122
- package/dist/internal.d.ts +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +207 -181
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +168 -214
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -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 +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -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 +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -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 +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +20 -29
- 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 +7 -1
- package/dist/sql.js +101 -85
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +40 -17
- package/dist/tx.d.ts +11 -1
- package/dist/tx.js +32 -11
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +15 -90
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +180 -74
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +5 -16
- package/dist/watch.js +23 -77
- package/dist/write.d.ts +9 -16
- package/dist/write.js +102 -185
- package/llms.txt +22 -25
- package/package.json +2 -9
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- 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 +23 -27
- package/sql/90-time.sql +77 -944
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "
|
|
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,8 +63,8 @@
|
|
|
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",
|
|
67
|
+
"ops:tables": "tsx scripts/gen-ops-tables.mjs",
|
|
71
68
|
"prepublishOnly": "npm run typecheck && npm run check:docs && npm run build",
|
|
72
69
|
"check:semver": "node scripts/check-semver.mjs",
|
|
73
70
|
"prerelease": "node scripts/prerelease.mjs",
|
|
@@ -102,10 +99,6 @@
|
|
|
102
99
|
"@modelcontextprotocol/client": "^2.4.0",
|
|
103
100
|
"@modelcontextprotocol/server": "^2.4.0",
|
|
104
101
|
"@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
102
|
"tsx": "^4.23.15",
|
|
110
103
|
"typescript": "~5.9.3",
|
|
111
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
|
-
-- журналом других арендаторов с тем же именем
|
|
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,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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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 $$;
|
package/sql/30-validate.sql
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 $$;
|