letopis 0.20.3 → 1.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 (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +348 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3466
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +369 -551
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -250
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -351
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -600
  89. package/sql/seed.auth.sql +0 -73
@@ -0,0 +1,423 @@
1
+ -- letopis 1.0 — идентификаторы v5 и проверка данных по JSON Schema (план, §2.3, §2.6, этап 1, п. 4–5).
2
+
3
+ -- ========================= v5 =========================
4
+ -- id = uuid_v5(арендатор, '["<класс>", "<значение 1>", …]'). Имя собирается одинаково в базе
5
+ -- (jsonb-массив в текстовом виде) и в TypeScript (JSON.stringify элементов через «, »);
6
+ -- совпадение закрепляет общий файл векторов lib/test/vectors/v5.json.
7
+
8
+ create or replace function @SCHEMA@.uuid_v5(ns uuid, name text) returns uuid
9
+ language sql immutable strict parallel safe
10
+ return (
11
+ select encode(
12
+ set_byte(set_byte(b, 6, (get_byte(b, 6) & 15) | 80), 8, (get_byte(b, 8) & 63) | 128),
13
+ 'hex')::uuid
14
+ from (select substring(@PGCRYPTO@.digest(uuid_send(ns) || convert_to(name, 'UTF8'), 'sha1') from 1 for 16) as b) s
15
+ );
16
+
17
+ create or replace function @SCHEMA@.v5_name(class text, vals text[]) returns text
18
+ language sql immutable strict parallel safe
19
+ return (
20
+ select jsonb_agg(x order by i)::text
21
+ from unnest(array[class] || vals) with ordinality as u(x, i)
22
+ );
23
+
24
+ create or replace function @SCHEMA@.v5_id(tenant uuid, class text, vals text[]) returns uuid
25
+ language sql immutable strict parallel safe
26
+ return @SCHEMA@.uuid_v5(tenant, @SCHEMA@.v5_name(class, vals));
27
+
28
+ -- ========================= JSON Schema → jsonpath =========================
29
+ -- Компилятор переводит JSON Schema класса в список правил. Каждое правило — предикат jsonpath
30
+ -- вида lax !exists(<путь> ? (!(<условие>))) над документом: «нет узла, нарушающего условие».
31
+ -- Условие всегда начинается с проверки типа узла, поэтому развёртывание массивов в режиме lax
32
+ -- не даёт строке ["x"] пройти проверку «равно "x"». Значения правил идут переменными ($v1, $v2…),
33
+ -- шаблоны и имена полей экранируются как строковые литералы jsonpath (§2.6).
34
+ -- То, чего jsonpath не выражает (длина строки, uniqueItems, смысл даты), идёт «дополнительными
35
+ -- проверками» — отдельными записями с полем extra; их выполняет validate_data().
36
+
37
+ create or replace function @SCHEMA@.jp_str(s text) returns text
38
+ language sql immutable strict parallel safe
39
+ return to_jsonb(s)::text;
40
+
41
+ -- Перевод шаблона JavaScript в регулярное выражение PostgreSQL (этап 1, п. 5): разбор по символам,
42
+ -- как translatePattern в lib/src/jsonschema.ts. \d \w \s и их отрицания — явными наборами (внутри
43
+ -- [ ] — без скобок), экранированная обратная косая черта остаётся собой; \b, \p{…}, именованные
44
+ -- группы и обратные ссылки отклоняются.
45
+ create or replace function @SCHEMA@.jp_pattern(p text) returns text
46
+ language plpgsql immutable strict
47
+ as $$
48
+ declare
49
+ res text := '';
50
+ i int := 1;
51
+ c text;
52
+ n text;
53
+ in_class boolean := false;
54
+ sp constant text := ' \t\n\r\f\v';
55
+ why text;
56
+ begin
57
+ while i <= length(p) loop
58
+ c := substr(p, i, 1);
59
+ if c = '\' then
60
+ n := substr(p, i + 1, 1);
61
+ i := i + 2;
62
+ if n = '' then
63
+ why := 'обратная косая черта в конце шаблона';
64
+ elsif n = 'd' then
65
+ res := res || case when in_class then '0-9' else '[0-9]' end;
66
+ elsif n = 'w' then
67
+ res := res || case when in_class then 'A-Za-z0-9_' else '[A-Za-z0-9_]' end;
68
+ elsif n = 's' then
69
+ res := res || case when in_class then sp else '[' || sp || ']' end;
70
+ elsif n in ('D', 'W', 'S') then
71
+ if in_class then
72
+ why := '\' || n || ' внутри набора [ ] не переводится';
73
+ else
74
+ res := res || case n when 'D' then '[^0-9]' when 'W' then '[^A-Za-z0-9_]' else '[^' || sp || ']' end;
75
+ end if;
76
+ elsif n in ('b', 'B') then
77
+ why := '\' || n || ' в PostgreSQL значит другое';
78
+ elsif n in ('p', 'P') then
79
+ why := '\p{…} не поддерживается';
80
+ elsif n = 'k' or n ~ '[1-9]' then
81
+ why := 'обратные ссылки не поддерживаются';
82
+ else
83
+ res := res || c || n;
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
+ continue;
89
+ end if;
90
+ 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 (именованные группы не поддерживаются)';
92
+ end if;
93
+ if c = '[' and not in_class then
94
+ in_class := true;
95
+ elsif c = ']' and in_class then
96
+ in_class := false;
97
+ end if;
98
+ res := res || c;
99
+ i := i + 1;
100
+ end loop;
101
+ return res;
102
+ end $$;
103
+
104
+ -- Навигация в строгом режиме без ошибок на отсутствующих ключах и чужих типах:
105
+ -- в объект — через keyvalue() с фильтром по имени, в массив — только если узел массив.
106
+ create or replace function @SCHEMA@.jp_prop(p text, k text) returns text
107
+ language sql immutable strict parallel safe
108
+ return p || ' ? (@.type() == "object").keyvalue() ? (@.key == ' || @SCHEMA@.jp_str(k) || ').value';
109
+
110
+ create or replace function @SCHEMA@.jp_items(p text) returns text
111
+ language sql immutable strict parallel safe
112
+ return p || ' ? (@.type() == "array")[*]';
113
+
114
+ create or replace function @SCHEMA@.jp_type_cond(t text) returns text
115
+ language sql immutable strict parallel safe
116
+ return case t
117
+ when 'integer' then '(@.type() == "number" && @ == @.floor())'
118
+ when 'number' then '@.type() == "number"'
119
+ when 'string' then '@.type() == "string"'
120
+ when 'boolean' then '@.type() == "boolean"'
121
+ when 'object' then '@.type() == "object"'
122
+ when 'array' then '@.type() == "array"'
123
+ when 'null' then '@.type() == "null"'
124
+ end;
125
+
126
+ -- Рекурсивная компиляция узла. p_path — jsonpath-путь до узла от базы (например $."a"[*]);
127
+ -- p_disp — путь для сообщений; p_in_any — внутри anyOf/oneOf (там нельзя дополнительных проверок).
128
+ -- Возвращает массив правил {path, keyword, cond, path_expr, vars, extra}.
129
+ create or replace function @SCHEMA@.jp_compile(p_schema jsonb, p_path text, p_disp jsonb, p_in_any boolean, inout p_n int, out rules jsonb)
130
+ language plpgsql immutable
131
+ as $$
132
+ declare
133
+ k text;
134
+ v jsonb;
135
+ t text;
136
+ conds text[];
137
+ sub record;
138
+ r jsonb := '[]'::jsonb;
139
+ vname text;
140
+ vars jsonb;
141
+ allowed text[] := array['type','enum','const','minLength','maxLength','pattern','format','minimum','maximum',
142
+ 'exclusiveMinimum','exclusiveMaximum','multipleOf','items','minItems','maxItems','uniqueItems','properties',
143
+ 'required','additionalProperties','propertyNames','anyOf','oneOf','default','minProperties','maxProperties',
144
+ 'title','description','examples','deprecated'];
145
+ tcond text;
146
+ br jsonb;
147
+ bv jsonb;
148
+ alt text[];
149
+ i int;
150
+ j int;
151
+ begin
152
+ if p_schema is null or p_schema = 'true'::jsonb or p_schema = '{}'::jsonb then
153
+ rules := r; return;
154
+ end if;
155
+ if jsonb_typeof(p_schema) <> 'object' then
156
+ raise exception using errcode = 'LT010', message = format('letopis: invalid_class: описание узла %s — не объект', p_disp);
157
+ end if;
158
+ for k in select jsonb_object_keys(p_schema) loop
159
+ 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);
161
+ end if;
162
+ end loop;
163
+
164
+ -- тип: строка или список строк
165
+ if p_schema ? 'type' then
166
+ if jsonb_typeof(p_schema->'type') = 'array' then
167
+ select string_agg(@SCHEMA@.jp_type_cond(x), ' || ') into tcond from jsonb_array_elements_text(p_schema->'type') x;
168
+ tcond := '(' || tcond || ')';
169
+ else
170
+ tcond := @SCHEMA@.jp_type_cond(p_schema->>'type');
171
+ end if;
172
+ if tcond is null then
173
+ raise exception using errcode = 'LT010', message = format('letopis: invalid_class: неизвестный тип в узле %s', p_disp);
174
+ end if;
175
+ r := r || jsonb_build_object('path', p_disp, 'keyword', 'type', 'path_expr', p_path, 'cond', tcond, 'vars', '{}'::jsonb);
176
+ end if;
177
+
178
+ -- перечисление и константа: только скаляры внутри jsonpath; объекты и списки — сравнение jsonb
179
+ if p_schema ? 'enum' or p_schema ? 'const' then
180
+ vars := '{}'; conds := '{}';
181
+ for v in select x from jsonb_array_elements(coalesce(p_schema->'enum', jsonb_build_array(p_schema->'const'))) x loop
182
+ if jsonb_typeof(v) in ('object', 'array') then
183
+ if p_in_any then
184
+ raise exception using errcode = 'LT010', message = format('letopis: invalid_class: перечисление объектов внутри anyOf не поддерживается (узел %s)', p_disp);
185
+ end if;
186
+ r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'enum' then 'enum' else 'const' end,
187
+ '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'))));
188
+ conds := null; exit;
189
+ end if;
190
+ p_n := p_n + 1; vname := 'v' || p_n;
191
+ vars := vars || jsonb_build_object(vname, v);
192
+ conds := conds || format('(@.type() == %s && @ == $%s)', @SCHEMA@.jp_str(case jsonb_typeof(v) when 'number' then 'number' when 'string' then 'string' when 'boolean' then 'boolean' else 'null' end), vname);
193
+ end loop;
194
+ -- пустое перечисление не пропускает ничего: условие «узла нет» ложно для любого узла
195
+ if conds is not null then
196
+ r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'enum' then 'enum' else 'const' end,
197
+ 'path_expr', p_path, 'cond', case when cardinality(conds) = 0 then '!exists(@)' else '(' || array_to_string(conds, ' || ') || ')' end, 'vars', vars);
198
+ end if;
199
+ end if;
200
+
201
+ -- числовые границы
202
+ foreach k in array array['minimum','maximum','exclusiveMinimum','exclusiveMaximum','multipleOf'] loop
203
+ if p_schema ? k then
204
+ p_n := p_n + 1; vname := 'v' || p_n;
205
+ r := r || jsonb_build_object('path', p_disp, 'keyword', k, 'path_expr', p_path, 'vars', jsonb_build_object(vname, p_schema->k),
206
+ 'cond', '(@.type() != "number" || ' || case k
207
+ when 'minimum' then '@ >= $' || vname
208
+ when 'maximum' then '@ <= $' || vname
209
+ when 'exclusiveMinimum' then '@ > $' || vname
210
+ when 'exclusiveMaximum' then '@ < $' || vname
211
+ else '(@ / $' || vname || ') == (@ / $' || vname || ').floor()' end || ')');
212
+ end if;
213
+ end loop;
214
+
215
+ -- строки
216
+ if p_schema ? 'pattern' then
217
+ r := r || jsonb_build_object('path', p_disp, 'keyword', 'pattern', 'path_expr', p_path, 'vars', '{}'::jsonb,
218
+ 'cond', '(@.type() != "string" || @ like_regex ' || @SCHEMA@.jp_str(@SCHEMA@.jp_pattern(p_schema->>'pattern')) || ')');
219
+ end if;
220
+ if p_schema ? 'format' then
221
+ t := p_schema->>'format';
222
+ r := r || jsonb_build_object('path', p_disp, 'keyword', 'format', 'path_expr', p_path, 'vars', '{}'::jsonb,
223
+ 'cond', '(@.type() != "string" || @ like_regex ' || @SCHEMA@.jp_str(case t
224
+ when 'uuid' then '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
225
+ when 'date' then '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
226
+ when 'date-time' then '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?(Z|[+-][0-9]{2}:[0-9]{2})$'
227
+ when 'email' then '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$'
228
+ else null end) || ')');
229
+ 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);
231
+ end if;
232
+ if t in ('date', 'date-time') then
233
+ if p_in_any then
234
+ raise exception using errcode = 'LT010', message = format('letopis: invalid_class: format %s внутри anyOf не поддерживается (узел %s)', t, p_disp);
235
+ end if;
236
+ r := r || jsonb_build_object('path', p_disp, 'keyword', 'format', 'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
237
+ 'extra', jsonb_build_object('kind', 'date', 'type', case t when 'date' then 'date' else 'timestamptz' end,
238
+ 're', case t when 'date' then '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
239
+ else '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?(Z|[+-][0-9]{2}:[0-9]{2})$' end));
240
+ end if;
241
+ end if;
242
+ if p_schema ? 'minLength' or p_schema ? 'maxLength' then
243
+ if p_in_any then
244
+ raise exception using errcode = 'LT010', message = format('letopis: invalid_class: длина строки внутри anyOf не поддерживается (узел %s)', p_disp);
245
+ end if;
246
+ r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'minLength' then 'minLength' else 'maxLength' end,
247
+ 'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
248
+ 'extra', jsonb_build_object('kind', 'length', 'min', p_schema->'minLength', 'max', p_schema->'maxLength'));
249
+ end if;
250
+
251
+ -- массивы
252
+ foreach k in array array['minItems','maxItems'] loop
253
+ if p_schema ? k then
254
+ p_n := p_n + 1; vname := 'v' || p_n;
255
+ r := r || jsonb_build_object('path', p_disp, 'keyword', k, 'path_expr', p_path, 'vars', jsonb_build_object(vname, p_schema->k),
256
+ 'cond', '(@.type() != "array" || @.size() ' || case k when 'minItems' then '>=' else '<=' end || ' $' || vname || ')');
257
+ end if;
258
+ end loop;
259
+ if coalesce((p_schema->>'uniqueItems')::boolean, false) then
260
+ if p_in_any then
261
+ raise exception using errcode = 'LT010', message = format('letopis: invalid_class: uniqueItems внутри anyOf не поддерживается (узел %s)', p_disp);
262
+ end if;
263
+ r := r || jsonb_build_object('path', p_disp, 'keyword', 'uniqueItems', 'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
264
+ 'extra', jsonb_build_object('kind', 'unique'));
265
+ end if;
266
+ if p_schema ? 'items' then
267
+ select c.p_n, c.rules into p_n, v from @SCHEMA@.jp_compile(p_schema->'items', @SCHEMA@.jp_items(p_path), p_disp || '"*"'::jsonb, p_in_any, p_n) c;
268
+ r := r || v;
269
+ end if;
270
+
271
+ -- объекты
272
+ if p_schema ? 'required' then
273
+ for k in select jsonb_array_elements_text(p_schema->'required') loop
274
+ r := r || jsonb_build_object('path', p_disp || to_jsonb(k), 'keyword', 'required', 'path_expr', p_path, 'vars', '{}'::jsonb,
275
+ 'cond', '(@.type() != "object" || exists(@.keyvalue() ? (@.key == ' || @SCHEMA@.jp_str(k) || ')))');
276
+ end loop;
277
+ end if;
278
+ if p_schema ? 'minProperties' or p_schema ? 'maxProperties' then
279
+ if p_in_any then
280
+ raise exception using errcode = 'LT010', message = format('letopis: invalid_class: minProperties внутри anyOf не поддерживается (узел %s)', p_disp);
281
+ end if;
282
+ r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'minProperties' then 'minProperties' else 'maxProperties' end,
283
+ 'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
284
+ 'extra', jsonb_build_object('kind', 'props', 'min', p_schema->'minProperties', 'max', p_schema->'maxProperties'));
285
+ end if;
286
+ if p_schema ? 'properties' then
287
+ for sub in select key, value from jsonb_each(p_schema->'properties') loop
288
+ select c.p_n, c.rules into p_n, v
289
+ from @SCHEMA@.jp_compile(sub.value, @SCHEMA@.jp_prop(p_path, sub.key), p_disp || to_jsonb(sub.key), p_in_any, p_n) c;
290
+ r := r || v;
291
+ end loop;
292
+ end if;
293
+ if p_schema ? 'additionalProperties' then
294
+ v := p_schema->'additionalProperties';
295
+ if v = 'false'::jsonb then
296
+ select string_agg('@.key == ' || @SCHEMA@.jp_str(x), ' || ') into t from jsonb_object_keys(coalesce(p_schema->'properties', '{}')) x;
297
+ r := r || jsonb_build_object('path', p_disp, 'keyword', 'additionalProperties', 'path_expr', p_path, 'vars', '{}'::jsonb,
298
+ 'cond', case when t is null then '(@.type() != "object" || !exists(@.keyvalue()))'
299
+ else '(@.type() != "object" || !exists(@.keyvalue() ? (!(' || t || '))))' end);
300
+ elsif jsonb_typeof(v) = 'object' then
301
+ -- значения прочих ключей проверяются схемой additionalProperties
302
+ select string_agg('@.key != ' || @SCHEMA@.jp_str(x), ' && ') into t from jsonb_object_keys(coalesce(p_schema->'properties', '{}')) x;
303
+ select c.p_n, c.rules into p_n, v
304
+ from @SCHEMA@.jp_compile(v, p_path || ' ? (@.type() == "object").keyvalue() ? (' || coalesce(t, 'exists(@)') || ').value', p_disp || '"*"'::jsonb, p_in_any, p_n) c;
305
+ r := r || v;
306
+ end if;
307
+ end if;
308
+ if p_schema ? 'propertyNames' then
309
+ select c.p_n, c.rules into p_n, v
310
+ from @SCHEMA@.jp_compile(p_schema->'propertyNames' || '{"type": "string"}', p_path || ' ? (@.type() == "object").keyvalue().key', p_disp || '"<ключ>"'::jsonb, p_in_any, p_n) c;
311
+ r := r || v;
312
+ end if;
313
+
314
+ -- anyOf и oneOf: каждая ветка — конъюнкция её правил относительно узла @
315
+ foreach k in array array['anyOf', 'oneOf'] loop
316
+ if p_schema ? k then
317
+ alt := '{}';
318
+ vars := '{}';
319
+ for bv in select x from jsonb_array_elements(p_schema->k) x loop
320
+ select c.p_n, c.rules into p_n, br from @SCHEMA@.jp_compile(bv, '@', p_disp, true, p_n) c;
321
+ -- ветка без правил принимает любой узел; в фильтре jsonpath нужно условие, а не значение true
322
+ select coalesce(string_agg(format('!exists(%s ? (!(%s)))', e->>'path_expr', e->>'cond'), ' && '), 'exists(@)')
323
+ into t from jsonb_array_elements(br) e;
324
+ alt := alt || ('(' || t || ')');
325
+ vars := vars || coalesce((select jsonb_object_agg(kv.key, kv.value) from jsonb_array_elements(br) e2, jsonb_each(e2->'vars') kv), '{}');
326
+ end loop;
327
+ t := array_to_string(alt, ' || ');
328
+ if k = 'oneOf' then
329
+ for i in 1 .. cardinality(alt) loop
330
+ for j in i + 1 .. cardinality(alt) loop
331
+ t := t || ') && !(' || alt[i] || ' && ' || alt[j];
332
+ end loop;
333
+ end loop;
334
+ end if;
335
+ r := r || jsonb_build_object('path', p_disp, 'keyword', k, 'path_expr', p_path, 'vars', vars, 'cond', '((' || t || '))');
336
+ end if;
337
+ end loop;
338
+ rules := r;
339
+ end $$;
340
+
341
+ -- Компиляция описания класса: правила и общее выражение validator (одно на класс, §2.6).
342
+ create or replace function @SCHEMA@.compile_validator(p_schema jsonb, out rules jsonb, out validator jsonpath, out vars jsonb)
343
+ language plpgsql immutable
344
+ as $$
345
+ declare
346
+ n int := 0;
347
+ expr text;
348
+ begin
349
+ select c.rules into rules from @SCHEMA@.jp_compile(p_schema, '$', '[]'::jsonb, false, n) c;
350
+ select coalesce(string_agg(format('!exists(%s ? (!(%s)))', e->>'path_expr', e->>'cond'), ' && ') filter (where e->>'cond' <> 'true'), 'true'),
351
+ coalesce((select jsonb_object_agg(kv.key, kv.value) from jsonb_array_elements(rules) e2, jsonb_each(e2->'vars') kv), '{}')
352
+ into expr, vars
353
+ from jsonb_array_elements(rules) e;
354
+ validator := ('strict ' || expr)::jsonpath;
355
+ end $$;
356
+
357
+ -- Проверка данных: общий вызов jsonb_path_match; при отказе — список нарушений по правилам.
358
+ -- Результат сравнивается через is true: ошибка внутри выражения даёт NULL, а не «да» (этап 1, п. 5).
359
+ create or replace function @SCHEMA@.validate_data(p_rules jsonb, p_validator jsonpath, p_vars jsonb, p_data jsonb) returns jsonb
360
+ language plpgsql stable
361
+ as $$
362
+ declare
363
+ e jsonb;
364
+ bad jsonb := '[]'::jsonb;
365
+ ok boolean;
366
+ x jsonb;
367
+ begin
368
+ ok := jsonb_path_match(p_data, p_validator, p_vars, true) is true;
369
+ if ok and not exists (select 1 from jsonb_array_elements(p_rules) r where r ? 'extra') then
370
+ return bad;
371
+ end if;
372
+ for e in select value from jsonb_array_elements(p_rules) loop
373
+ if e ? 'extra' then
374
+ for x in select * from jsonb_path_query(p_data, ('strict ' || (e->>'path_expr'))::jsonpath, '{}'::jsonb, true) loop
375
+ if (e->'extra'->>'kind') = 'length' and jsonb_typeof(x) = 'string' then
376
+ if char_length(x #>> '{}') < coalesce((e->'extra'->>'min')::int, 0)
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', 'длина строки вне границ');
379
+ end if;
380
+ elsif (e->'extra'->>'kind') = 'unique' and jsonb_typeof(x) = 'array' then
381
+ 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
+ end if;
384
+ elsif (e->'extra'->>'kind') = 'date' and jsonb_typeof(x) = 'string' then
385
+ -- смысл проверяется только у строки правильной формы: форму проверяет правило format
386
+ 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', 'такой даты не бывает');
388
+ end if;
389
+ elsif (e->'extra'->>'kind') = 'props' and jsonb_typeof(x) = 'object' then
390
+ if (select count(*) from jsonb_object_keys(x)) < coalesce((e->'extra'->>'min')::int, 0)
391
+ 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', 'число ключей вне границ');
393
+ end if;
394
+ elsif (e->'extra'->>'kind') = 'enum' then
395
+ 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', 'значение не из перечисления');
397
+ end if;
398
+ end if;
399
+ end loop;
400
+ elsif not ok then
401
+ 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'));
403
+ end if;
404
+ end if;
405
+ end loop;
406
+ if not ok and bad = '[]'::jsonb then
407
+ bad := jsonb_build_array(jsonb_build_object('path', '[]'::jsonb, 'keyword', 'schema', 'message', 'данные не проходят описание'));
408
+ end if;
409
+ return bad;
410
+ end $$;
411
+
412
+ do $$
413
+ declare f text;
414
+ begin
415
+ foreach f in array array[
416
+ 'uuid_v5(uuid, text)', 'v5_name(text, text[])', 'v5_id(uuid, text, text[])', 'jp_str(text)', 'jp_pattern(text)',
417
+ 'jp_type_cond(text)', 'jp_prop(text, text)', 'jp_items(text)', 'jp_compile(jsonb, text, jsonb, boolean, int)', 'compile_validator(jsonb)',
418
+ 'validate_data(jsonb, jsonpath, jsonb, jsonb)'] loop
419
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
420
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
421
+ end loop;
422
+ end $$;
423
+ grant execute on function @SCHEMA@.uuid_v5(uuid, text), @SCHEMA@.v5_name(text, text[]), @SCHEMA@.v5_id(uuid, text, text[]) to @APP@;