letopis 1.4.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/uuid.js CHANGED
@@ -6,15 +6,23 @@
6
6
  * разделитель — запятая с пробелом. Совпадение закрепляет общий файл векторов lib/test/vectors/v5.json.
7
7
  */
8
8
  import { hash } from 'node:crypto';
9
- import { LetopisError } from './errors.js';
10
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ import { lerr } from './errors.js';
10
+ /** uuid в каноническом виде 8-4-4-4-12 (регистр любой) — общий для построителя, загрузчика и MCP. */
11
+ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const UUID = UUID_RE;
13
+ /**
14
+ * Любой вид uuid, который принимает приведение PostgreSQL `::uuid`: 32 шестнадцатеричные цифры, дефис — после любой
15
+ * группы из четырёх (или без дефисов), весь в фигурных скобках или без них. Фильтр с таким id работал и до проверки,
16
+ * поэтому проверка до запроса (sql.ts, compileFilter) отказывает только тому, что отклонила бы база.
17
+ */
18
+ export const PG_UUID_RE = /^(?:[0-9a-f]{4}(?:-?[0-9a-f]{4}){7}|\{[0-9a-f]{4}(?:-?[0-9a-f]{4}){7}\})$/i;
11
19
  // байты пространств имён (арендаторов): в загрузке их единицы, а проверка и разбор шли на каждый id
12
20
  const NS = new Map();
13
21
  function nsBytes(ns) {
14
22
  let b = NS.get(ns);
15
23
  if (!b) {
16
24
  if (!UUID.test(ns))
17
- throw new LetopisError('invalid_data', `пространство имён v5 — не uuid: ${ns}`);
25
+ throw lerr('model.v5_namespace_not_uuid', { ns });
18
26
  b = Buffer.from(ns.replace(/-/g, ''), 'hex');
19
27
  if (NS.size >= 10_000)
20
28
  NS.clear();
@@ -44,17 +52,17 @@ export function v5Id(tenant, cls, values) {
44
52
  */
45
53
  export function idOf(cls, key, opts) {
46
54
  if (!cls.key.length) {
47
- throw new LetopisError('no_key', `у класса ${cls.name} нет ключа: id выдаёт база при создании`);
55
+ throw lerr('model.class_no_key', { cls: cls.name });
48
56
  }
49
57
  const ends = new Set(cls.ends ?? []);
50
58
  const values = cls.key.map((k) => {
51
59
  const v = key[k];
52
60
  if (typeof v !== 'string') {
53
- throw new LetopisError('invalid_data', `idOf(${cls.name}): часть ключа ${k} должна быть строкой`, { key: cls.key });
61
+ throw lerr('model.key_part_not_string', { cls: cls.name, part: k }, { detail: { key: cls.key } });
54
62
  }
55
63
  if (ends.has(k)) {
56
64
  if (!UUID.test(v))
57
- throw new LetopisError('invalid_data', `idOf(${cls.name}): конец ${k} — не uuid`);
65
+ throw lerr('model.key_end_not_uuid', { cls: cls.name, end: k });
58
66
  return v.toLowerCase();
59
67
  }
60
68
  return v;
@@ -4,6 +4,12 @@
4
4
  * слова нарушений. Им пользуются загрузчик (этап 4) и ранние ошибки библиотеки; совпадение с базой
5
5
  * закрепляет дифференциальный тест validator-parity.
6
6
  *
7
+ * Исключение — `pattern` (ревью): шаблон задаёт арендатор, и движок V8 с перебором на шаблоне вроде
8
+ * `^(a+)+$` повесил бы процесс приложения. Такие правила (`db`) проверяет только база (`like_regex`,
9
+ * движок без катастрофического перебора); здесь шаблон лишь разбирается — то, что база не примет, и
10
+ * шаблон, неверный для JavaScript, — `invalid_class`. `anyOf`/`oneOf`, в ветке которых есть `pattern`,
11
+ * без него не решить (у `oneOf` вышло бы ложное нарушение) — их тоже проверяет только база.
12
+ *
7
13
  * Как и в базе, правило — «нет узла по пути, нарушающего условие»: обычное правило даёт одно
8
14
  * нарушение, сколько бы узлов его ни нарушили; дополнительные проверки (длина строки, uniqueItems,
9
15
  * смысл даты, перечисления объектов, число ключей) — по нарушению на каждый узел.
@@ -18,10 +24,16 @@ export interface Validator {
18
24
  (data: unknown): Issue[];
19
25
  }
20
26
  /**
21
- * Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи. Схема
27
+ * Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи, кроме
28
+ * `pattern` (и `anyOf`/`oneOf` с ним в ветке): шаблоны задаёт арендатор, их проверяет только база. Схема
22
29
  * класса — `db.registry.resolve(имя).schema`.
23
30
  */
24
31
  export declare function compileValidator(schema: JsonSchema): Validator;
32
+ /**
33
+ * Оставляет ли валидатор правила базе (`pattern` и `anyOf`/`oneOf` с ним): строки таких классов загрузчик, который
34
+ * пишет мимо триггеров, сверяет запросом к базе. Не входит в API пакета.
35
+ */
36
+ export declare function leavesToDb(v: Validator): boolean;
25
37
  /**
26
38
  * Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
27
39
  * записи: только в отсутствующие поля, а во вложенный объект — только если он в данных уже есть. Исходные данные не
package/dist/validate.js CHANGED
@@ -4,11 +4,17 @@
4
4
  * слова нарушений. Им пользуются загрузчик (этап 4) и ранние ошибки библиотеки; совпадение с базой
5
5
  * закрепляет дифференциальный тест validator-parity.
6
6
  *
7
+ * Исключение — `pattern` (ревью): шаблон задаёт арендатор, и движок V8 с перебором на шаблоне вроде
8
+ * `^(a+)+$` повесил бы процесс приложения. Такие правила (`db`) проверяет только база (`like_regex`,
9
+ * движок без катастрофического перебора); здесь шаблон лишь разбирается — то, что база не примет, и
10
+ * шаблон, неверный для JavaScript, — `invalid_class`. `anyOf`/`oneOf`, в ветке которых есть `pattern`,
11
+ * без него не решить (у `oneOf` вышло бы ложное нарушение) — их тоже проверяет только база.
12
+ *
7
13
  * Как и в базе, правило — «нет узла по пути, нарушающего условие»: обычное правило даёт одно
8
14
  * нарушение, сколько бы узлов его ни нарушили; дополнительные проверки (длина строки, uniqueItems,
9
15
  * смысл даты, перечисления объектов, число ключей) — по нарушению на каждый узел.
10
16
  */
11
- import { LetopisError } from './errors.js';
17
+ import { lerr, lmsg } from './errors.js';
12
18
  import { SUPPORTED, translatePattern } from './jsonschema.js';
13
19
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
14
20
  const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'object' ? 'object' : typeof v === 'number' ? 'number' : typeof v === 'string' ? 'string' : typeof v === 'boolean' ? 'boolean' : 'unknown');
@@ -56,6 +62,11 @@ const FORMAT_RE = {
56
62
  'date-time': /^[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})$/,
57
63
  email: /^[^@\s]+@[^@\s]+\.[^@\s]+$/,
58
64
  };
65
+ /**
66
+ * Предел длины адреса для format: 'email' — 254 символа (RFC 5321). Длиннее — нарушение без шаблона: на подобранной
67
+ * строке (много точек в домене и лишний символ в конце) шаблон email перебирает квадратично (ревью).
68
+ */
69
+ const EMAIL_MAX = 254;
59
70
  /** Смысл даты и времени, как pg_input_is_valid(…, 'date' | 'timestamptz') для уже проверенной формы. */
60
71
  function validDate(s, withTime) {
61
72
  const y = Number(s.slice(0, 4));
@@ -93,35 +104,43 @@ const prop = (sel, k) => (root) => sel(root).flatMap((n) => (isObj(n) && Object.
93
104
  const items = (sel) => (root) => sel(root).flatMap((n) => (Array.isArray(n) ? n : []));
94
105
  const others = (sel, known) => (root) => sel(root).flatMap((n) => (isObj(n) ? Object.keys(n).filter((k) => !known.includes(k)).map((k) => n[k]) : []));
95
106
  const keys = (sel) => (root) => sel(root).flatMap((n) => (isObj(n) ? Object.keys(n) : []));
96
- function fail(path, msg) {
97
- throw new LetopisError('invalid_class', `${msg} (узел ${JSON.stringify(path)})`);
107
+ /** Допустимые значения перечисления для сообщения нарушения — как у базы (validate_data): JSON через запятую, до 20. */
108
+ function enumValues(values) {
109
+ const shown = values.slice(0, 20).map((v) => JSON.stringify(v)).join(', ');
110
+ return values.length > 20 ? `${shown}, …` : shown;
111
+ }
112
+ /** Описание не компилируется (`invalid_class`): ключ каталога и параметры; путь узла — параметр node. */
113
+ function fail(path, key, params) {
114
+ throw lerr(key, { ...params, node: JSON.stringify(path) });
98
115
  }
99
116
  /** Компиляция узла схемы в правила — порядок и состав как в jp_compile. */
100
117
  function compileNode(s, sel, path, inAny, out) {
101
118
  if (s === undefined || s === true || (isObj(s) && Object.keys(s).length === 0))
102
119
  return;
103
120
  if (!isObj(s))
104
- fail(path, `описание узла ${JSON.stringify(path)} — не объект`);
121
+ fail(path, 'model.validator_node_not_object');
105
122
  const sch = s;
106
123
  for (const k of Object.keys(sch)) {
107
124
  if (!SUPPORTED.has(k) && !k.startsWith('x-'))
108
- fail(path, `ключевое слово ${k} не поддерживается`);
125
+ fail(path, 'model.validator_keyword_unsupported', { keyword: k });
109
126
  }
110
127
  if (sch.type !== undefined) {
111
128
  const types = Array.isArray(sch.type) ? sch.type : [sch.type];
112
- const tests = types.map((t) => TYPE_TEST[t] ?? fail(path, `неизвестный тип в узле ${JSON.stringify(path)}`));
129
+ const tests = types.map((t) => TYPE_TEST[t] ?? fail(path, 'model.validator_unknown_type'));
113
130
  out.push({ path, keyword: 'type', select: sel, test: (v) => tests.some((f) => f(v)) });
114
131
  }
115
132
  if (sch.enum !== undefined || sch.const !== undefined) {
116
133
  const keyword = sch.enum !== undefined ? 'enum' : 'const';
117
134
  const values = (sch.enum ?? [sch.const]);
135
+ // сообщение называет допустимые значения (план 2.1, A8)
136
+ const msg = lmsg('model.issue_enum', { values: enumValues(values) });
118
137
  if (values.some((v) => typeof v === 'object' && v !== null)) {
119
138
  if (inAny)
120
- fail(path, 'перечисление объектов внутри anyOf не поддерживается');
121
- out.push({ path, keyword, select: sel, test: () => true, extra: (v) => (values.some((x) => jsonEqual(x, v)) ? null : 'значение не из перечисления') });
139
+ fail(path, 'model.validator_enum_objects_in_any_of');
140
+ out.push({ path, keyword, select: sel, test: () => true, extra: (v) => (values.some((x) => jsonEqual(x, v)) ? null : msg) });
122
141
  }
123
142
  else {
124
- out.push({ path, keyword, select: sel, test: (v) => values.some((x) => typeOf(x) === typeOf(v) && jsonEqual(x, v)) });
143
+ out.push({ path, keyword, select: sel, test: (v) => values.some((x) => typeOf(x) === typeOf(v) && jsonEqual(x, v)), explain: () => msg });
125
144
  }
126
145
  }
127
146
  const num = (f, b) => (v) => typeof v !== 'number' || f(v, b);
@@ -137,36 +156,41 @@ function compileNode(s, sel, path, inAny, out) {
137
156
  out.push({ path, keyword: 'multipleOf', select: sel, test: num((v, b) => isMultiple(v, b), sch.multipleOf) });
138
157
  if (sch.pattern !== undefined) {
139
158
  const src = translatePattern(sch.pattern); // то, чего база не понимает, — своя ошибка invalid_class
140
- let re;
141
159
  try {
142
- re = new RegExp(src);
160
+ new RegExp(src); // только разбор, без исполнения на данных: проверку шаблона делает база (ревью, ReDoS)
143
161
  }
144
162
  catch {
145
163
  // шаблон, неверный уже для JavaScript (незакрытая скобка и т. п.): translatePattern его пропускает, RegExp — нет.
146
164
  // Текст V8 в сообщение не идёт: он зависит от версии движка
147
- fail(path, `pattern ${JSON.stringify(sch.pattern)} — неверное регулярное выражение`);
165
+ fail(path, 'model.validator_bad_pattern', { pattern: JSON.stringify(sch.pattern) });
148
166
  }
149
- out.push({ path, keyword: 'pattern', select: sel, test: (v) => typeof v !== 'string' || re.test(v) });
167
+ out.push({ path, keyword: 'pattern', select: sel, test: () => true, db: true });
150
168
  }
151
169
  if (sch.format !== undefined) {
152
170
  const f = sch.format;
153
- const re = FORMAT_RE[f] ?? fail(path, `format ${f} не поддерживается`);
154
- out.push({ path, keyword: 'format', select: sel, test: (v) => typeof v !== 'string' || re.test(v) });
171
+ const re = FORMAT_RE[f] ?? fail(path, 'model.validator_format_unsupported', { format: f });
172
+ const max = f === 'email' ? EMAIL_MAX : Infinity;
173
+ out.push({ path, keyword: 'format', select: sel, test: (v) => typeof v !== 'string' || (v.length <= max && re.test(v)) });
155
174
  if (f === 'date' || f === 'date-time') {
156
175
  if (inAny)
157
- fail(path, `format ${f} внутри anyOf не поддерживается`);
176
+ fail(path, 'model.validator_format_in_any_of', { format: f });
158
177
  out.push({ path, keyword: 'format', select: sel, test: () => true,
159
- extra: (v) => (typeof v !== 'string' || !re.test(v) || validDate(v, f === 'date-time') ? null : 'такой даты не бывает') });
178
+ extra: (v) => (typeof v !== 'string' || !re.test(v) || validDate(v, f === 'date-time') ? null : lmsg('model.issue_date')) });
160
179
  }
161
180
  }
162
181
  if (sch.minLength !== undefined || sch.maxLength !== undefined) {
163
182
  if (inAny)
164
- fail(path, 'длина строки внутри anyOf не поддерживается');
183
+ fail(path, 'model.validator_string_length_in_any_of');
165
184
  const min = sch.minLength ?? 0;
166
185
  const max = sch.maxLength ?? Infinity;
167
186
  out.push({ path, keyword: sch.minLength !== undefined ? 'minLength' : 'maxLength', select: sel, test: () => true,
168
- extra: (v) => { if (typeof v !== 'string')
169
- return null; const n = [...v].length; return n < min || n > max ? 'длина строки вне границ' : null; } });
187
+ // текст называет границу (второй раунд 2.1, A6), как у базы (30-validate.sql, validate_data)
188
+ extra: (v) => {
189
+ if (typeof v !== 'string')
190
+ return null;
191
+ const n = [...v].length;
192
+ return n < min ? lmsg('model.issue_string_too_short', { limit: min }) : n > max ? lmsg('model.issue_string_too_long', { limit: max }) : null;
193
+ } });
170
194
  }
171
195
  if (sch.minItems !== undefined)
172
196
  out.push({ path, keyword: 'minItems', select: sel, test: (v) => !Array.isArray(v) || v.length >= sch.minItems });
@@ -174,9 +198,9 @@ function compileNode(s, sel, path, inAny, out) {
174
198
  out.push({ path, keyword: 'maxItems', select: sel, test: (v) => !Array.isArray(v) || v.length <= sch.maxItems });
175
199
  if (sch.uniqueItems === true) {
176
200
  if (inAny)
177
- fail(path, 'uniqueItems внутри anyOf не поддерживается');
201
+ fail(path, 'model.validator_unique_items_in_any_of');
178
202
  out.push({ path, keyword: 'uniqueItems', select: sel, test: () => true,
179
- extra: (v) => (Array.isArray(v) && v.some((x, i) => v.findIndex((y) => jsonEqual(x, y)) !== i) ? 'элементы повторяются' : null) });
203
+ extra: (v) => (Array.isArray(v) && v.some((x, i) => v.findIndex((y) => jsonEqual(x, y)) !== i) ? lmsg('model.issue_unique_items') : null) });
180
204
  }
181
205
  if (sch.items !== undefined)
182
206
  compileNode(sch.items, items(sel), [...path, '*'], inAny, out);
@@ -186,12 +210,12 @@ function compileNode(s, sel, path, inAny, out) {
186
210
  }
187
211
  if (sch.minProperties !== undefined || sch.maxProperties !== undefined) {
188
212
  if (inAny)
189
- fail(path, 'minProperties внутри anyOf не поддерживается');
213
+ fail(path, 'model.validator_min_properties_in_any_of');
190
214
  const min = sch.minProperties ?? 0;
191
215
  const max = sch.maxProperties ?? Infinity;
192
216
  out.push({ path, keyword: sch.minProperties !== undefined ? 'minProperties' : 'maxProperties', select: sel, test: () => true,
193
217
  extra: (v) => { if (!isObj(v))
194
- return null; const n = Object.keys(v).length; return n < min || n > max ? 'число ключей вне границ' : null; } });
218
+ return null; const n = Object.keys(v).length; return n < min || n > max ? lmsg('model.issue_property_count') : null; } });
195
219
  }
196
220
  const known = isObj(sch.properties) ? Object.keys(sch.properties) : [];
197
221
  if (isObj(sch.properties)) {
@@ -199,7 +223,9 @@ function compileNode(s, sel, path, inAny, out) {
199
223
  compileNode(v, prop(sel, k), [...path, k], inAny, out);
200
224
  }
201
225
  if (sch.additionalProperties === false) {
202
- out.push({ path, keyword: 'additionalProperties', select: sel, test: (v) => !isObj(v) || Object.keys(v).every((k) => known.includes(k)) });
226
+ // сообщение называет лишние ключи (план 2.1, A8)
227
+ out.push({ path, keyword: 'additionalProperties', select: sel, test: (v) => !isObj(v) || Object.keys(v).every((k) => known.includes(k)),
228
+ explain: (v) => lmsg('model.issue_additional', { keys: Object.keys(v).filter((k) => !known.includes(k)).slice(0, 20).join(', ') }) });
203
229
  }
204
230
  else if (isObj(sch.additionalProperties)) {
205
231
  compileNode(sch.additionalProperties, others(sel, known), [...path, '*'], inAny, out);
@@ -210,24 +236,32 @@ function compileNode(s, sel, path, inAny, out) {
210
236
  for (const kw of ['anyOf', 'oneOf']) {
211
237
  if (!Array.isArray(sch[kw]))
212
238
  continue;
213
- const branches = sch[kw].map((b) => {
239
+ const compiled = sch[kw].map((b) => {
214
240
  const rules = [];
215
241
  compileNode(b, (root) => [root], path, true, rules);
216
- return (node) => rules.every((r) => r.select(node).every((n) => r.test(n)));
242
+ return rules;
217
243
  });
244
+ // ветку с правилом базы здесь не решить: anyOf/oneOf целиком проверяет база (у oneOf иначе — ложное нарушение)
245
+ if (compiled.some((rules) => rules.some((r) => r.db))) {
246
+ out.push({ path, keyword: kw, select: sel, test: () => true, db: true });
247
+ continue;
248
+ }
249
+ const branches = compiled.map((rules) => (node) => rules.every((r) => r.select(node).every((n) => r.test(n))));
218
250
  out.push({ path, keyword: kw, select: sel, test: kw === 'anyOf'
219
251
  ? (v) => branches.some((b) => b(v))
220
252
  : (v) => branches.filter((b) => b(v)).length === 1 });
221
253
  }
222
254
  }
223
255
  /**
224
- * Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи. Схема
256
+ * Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи, кроме
257
+ * `pattern` (и `anyOf`/`oneOf` с ним в ветке): шаблоны задаёт арендатор, их проверяет только база. Схема
225
258
  * класса — `db.registry.resolve(имя).schema`.
226
259
  */
227
260
  export function compileValidator(schema) {
228
- const rules = [];
229
- compileNode(schema, (root) => [root], [], false, rules);
230
- return (data) => {
261
+ const all = [];
262
+ compileNode(schema, (root) => [root], [], false, all);
263
+ const rules = all.filter((r) => !r.db);
264
+ const v = (data) => {
231
265
  const issues = [];
232
266
  for (const r of rules) {
233
267
  const nodes = r.select(data);
@@ -238,12 +272,27 @@ export function compileValidator(schema) {
238
272
  issues.push({ path: r.path, keyword: r.keyword, message: msg });
239
273
  }
240
274
  }
241
- else if (!nodes.every((n) => r.test(n))) {
242
- issues.push({ path: r.path, keyword: r.keyword, message: `нарушено правило ${r.keyword}` });
275
+ else {
276
+ const at = nodes.findIndex((n) => !r.test(n));
277
+ if (at >= 0) {
278
+ issues.push({ path: r.path, keyword: r.keyword, message: r.explain ? r.explain(nodes[at]) : lmsg('model.issue_rule', { keyword: r.keyword }) });
279
+ }
243
280
  }
244
281
  }
245
282
  return issues;
246
283
  };
284
+ if (rules.length < all.length)
285
+ LEAVES_TO_DB.add(v);
286
+ return v;
287
+ }
288
+ /** Валидаторы с правилами, которые проверяет только база. */
289
+ const LEAVES_TO_DB = new WeakSet();
290
+ /**
291
+ * Оставляет ли валидатор правила базе (`pattern` и `anyOf`/`oneOf` с ним): строки таких классов загрузчик, который
292
+ * пишет мимо триггеров, сверяет запросом к базе. Не входит в API пакета.
293
+ */
294
+ export function leavesToDb(v) {
295
+ return LEAVES_TO_DB.has(v);
247
296
  }
248
297
  /**
249
298
  * Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
package/dist/watch.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Executor } from './tx.js';
2
2
  import type { Row } from './types.js';
3
3
  import type { Signal } from './signals.js';
4
+ import type { Log } from './log.js';
4
5
  export interface WatchOptions {
5
6
  /** Откуда: курсор (sub.cursor или курсор события), 'now' (по умолчанию — изменения после подписки) или 'start' (весь журнал). */
6
7
  from?: string;
@@ -39,11 +40,13 @@ export interface WatchCtx {
39
40
  tenant?: () => string | null;
40
41
  /** Подписка закрылась: подключение перестаёт её учитывать (план 2.0, 7.4 №8). */
41
42
  onClose?: (s: Subscription) => void;
43
+ /** Логгер фасада (lib/src/log.ts): watch.start, watch.fetch, watch.stop, error. */
44
+ log?: Log;
42
45
  }
43
46
  /**
44
47
  * Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
45
- * любой); сессии не будят; *, opaque без ключа и нагрузка не по режиму — будят. Незнакомый токен класса
46
- * будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
48
+ * любой); сессии не будят; переподключение слушателя, opaque без ключа и нагрузка не по режиму — будят. Незнакомый
49
+ * токен класса будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
47
50
  */
48
51
  export declare function wakes(s: Signal, watched: ReadonlySet<string> | null, mine: string | null): boolean;
49
52
  export declare class Subscription implements AsyncIterable<WatchEvent> {
@@ -58,12 +61,6 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
58
61
  private dirty;
59
62
  /** Пришёл сигнал: пустой ответ — ещё не горизонт, нужны короткие повторы. Таймер их не запускает (план 2.0, 7.4 №1). */
60
63
  private burst;
61
- /**
62
- * from: 'start' — пока подписка не догнала журнал, свои чтения она шлёт без времени курсора: весь журнал, без
63
- * cursor_expired на старых событиях (план 2.0, 7.4 №3). Курсоры событий и sub.cursor время хранят: продолжение с них
64
- * после прореживания — cursor_expired, как обещано.
65
- */
66
- private catchingUp;
67
64
  private off;
68
65
  private timer;
69
66
  private failure;
@@ -73,17 +70,14 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
73
70
  private start;
74
71
  /** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
75
72
  private queue;
73
+ /** Выдано событий — для watch.stop. */
74
+ private delivered;
76
75
  constructor(ctx: WatchCtx, opts: WatchOptions);
77
- /**
78
- * Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
79
- * в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
80
- * курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
81
- * cursor_expired.
82
- */
76
+ /** Курсор подписки: позиция после последнего выданного события (или начальная точка) — с неё продолжают после перезапуска. */
83
77
  get cursor(): string | null;
84
78
  /**
85
- * Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток
86
- * (доработки после точки Б). У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
79
+ * Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток.
80
+ * У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
87
81
  * выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
88
82
  */
89
83
  ready(): Promise<void>;
package/dist/watch.js CHANGED
@@ -11,36 +11,30 @@
11
11
  * журнала: каждый видит только своё. Сигнал базы только будит подписку: свои классы и свой арендатор
12
12
  * (чужие — не будят; процесс без ключа сигналов арендатора не различает, в режиме opaque — и класса);
13
13
  * без LISTEN и на случай потери сигнала — запасной опрос.
14
- *
15
- * Курсор подписчика старше яруса all политики хранения его классов — cursor_expired: события могли
16
- * проредиться, нужно перечитать текущее состояние и подписаться заново. Время курсора подписки — время последнего
17
- * события, а догнавшей журнал подписки — время опроса (не позже самой старой ещё не отданной записи): подписка без
18
- * событий не стареет. Курсор события хранит время события.
19
14
  */
20
- import { LetopisError } from './errors.js';
15
+ import { lerr } from './errors.js';
21
16
  import { toRow } from './sql.js';
22
- /** Курсор события: «tx:seq[:время][~снимок]». */
23
- const encode = (p) => `${p.tx}:${p.seq}${p.at ? `:${p.at}` : ''}${p.snap ? `~${p.snap}` : ''}`;
17
+ /** Курсор события: «tx:seq[~снимок]». */
18
+ const encode = (p) => `${p.tx}:${p.seq}${p.snap ? `~${p.snap}` : ''}`;
24
19
  function decode(c) {
25
20
  const [head, snap] = c.split('~');
26
- const m = /^(\d+):(\d+)(?::(.+))?$/.exec(head);
21
+ const m = /^(\d+):(\d+)$/.exec(head);
27
22
  if (!m || (snap !== undefined && !/^\d+:\d+:[\d,]*$/.test(snap))) {
28
- throw new LetopisError('invalid_query', `watch: курсор ${c} — ожидается курсор события`);
23
+ throw lerr('time.watch_cursor', { cursor: c });
29
24
  }
30
- return { tx: m[1], seq: m[2], at: m[3] ?? null, ...(snap ? { snap } : {}) };
25
+ return { tx: m[1], seq: m[2], ...(snap ? { snap } : {}) };
31
26
  }
32
27
  /** Снимок ещё нужен: курсор не дошёл до его xmax. */
33
28
  const keepSnap = (snap, tx) => snap && BigInt(tx) < BigInt(snap.split(':')[1]) ? snap : undefined;
34
29
  /**
35
30
  * Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
36
- * любой); сессии не будят; *, opaque без ключа и нагрузка не по режиму — будят. Незнакомый токен класса
37
- * будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
31
+ * любой); сессии не будят; переподключение слушателя, opaque без ключа и нагрузка не по режиму — будят. Незнакомый
32
+ * токен класса будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
38
33
  */
39
34
  export function wakes(s, watched, mine) {
40
35
  const ours = (t) => t === null || mine === null || t === mine;
41
36
  switch (s.kind) {
42
37
  case 'session':
43
- case 'sessions':
44
38
  return false;
45
39
  case 'class':
46
40
  return (!watched || watched.has(s.cls)) && ours(s.tenant);
@@ -63,12 +57,6 @@ export class Subscription {
63
57
  dirty = true;
64
58
  /** Пришёл сигнал: пустой ответ — ещё не горизонт, нужны короткие повторы. Таймер их не запускает (план 2.0, 7.4 №1). */
65
59
  burst = false;
66
- /**
67
- * from: 'start' — пока подписка не догнала журнал, свои чтения она шлёт без времени курсора: весь журнал, без
68
- * cursor_expired на старых событиях (план 2.0, 7.4 №3). Курсоры событий и sub.cursor время хранят: продолжение с них
69
- * после прореживания — cursor_expired, как обещано.
70
- */
71
- catchingUp = false;
72
60
  off = null;
73
61
  timer = null;
74
62
  failure = null;
@@ -78,16 +66,16 @@ export class Subscription {
78
66
  start = null;
79
67
  /** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
80
68
  queue = Promise.resolve();
69
+ /** Выдано событий — для watch.stop. */
70
+ delivered = 0;
81
71
  constructor(ctx, opts) {
82
72
  this.ctx = ctx;
83
73
  this.opts = opts;
84
74
  this.batch = opts.batch ?? 500;
85
75
  this.pollMs = opts.pollMs ?? 1000;
86
76
  const from = opts.from ?? 'now';
87
- if (from === 'start') {
88
- this.pos = { tx: '0', seq: '0', at: null };
89
- this.catchingUp = true;
90
- }
77
+ if (from === 'start')
78
+ this.pos = { tx: '0', seq: '0' };
91
79
  else if (from !== 'now')
92
80
  this.pos = decode(from);
93
81
  else {
@@ -109,21 +97,20 @@ export class Subscription {
109
97
  this.wake?.();
110
98
  }, this.pollMs);
111
99
  this.timer.unref?.();
100
+ if (ctx.log?.wants('watch.start')) {
101
+ ctx.log.emit('watch.start', { op: 'watch', detail: { from: opts.from ?? 'now', classes: ctx.classes, batch: this.batch,
102
+ pollMs: this.pollMs, signals: Boolean(ctx.onSignal) } });
103
+ }
112
104
  }
113
- /**
114
- * Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
115
- * в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
116
- * курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
117
- * cursor_expired.
118
- */
105
+ /** Курсор подписки: позиция после последнего выданного события (или начальная точка) — с неё продолжают после перезапуска. */
119
106
  get cursor() {
120
107
  if (this.buf.length)
121
108
  return this.given;
122
109
  return this.pos ? encode(this.pos) : null;
123
110
  }
124
111
  /**
125
- * Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток
126
- * (доработки после точки Б). У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
112
+ * Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток.
113
+ * У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
127
114
  * выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
128
115
  */
129
116
  async ready() {
@@ -132,7 +119,7 @@ export class Subscription {
132
119
  }
133
120
  async head() {
134
121
  const [r] = await this.ctx.exec.run((q) => q.unsafe(`select tx::text as tx, seq::text as seq, snap::text as snap from ${this.ctx.S}.watch_head()`), { read: true });
135
- return { tx: r.tx, seq: r.seq, at: null, snap: r.snap };
122
+ return { tx: r.tx, seq: r.seq, snap: r.snap };
136
123
  }
137
124
  /** Следующая пачка событий ниже горизонта; пусто — горизонт ещё не дошёл или событий нет. */
138
125
  async fetch() {
@@ -141,58 +128,20 @@ export class Subscription {
141
128
  const p = this.pos;
142
129
  const cols = `l.tx::text as tx, l.seq::text as seq, jsonb_build_object('id', l.id, 'rev', l.rev, 'class', l.class, 'tenant', l.tenant, `
143
130
  + `'owner', l.owner, 'links', l.links, 'data', l.data, 'tags', l.tags, 'at', l.at, 'author', l.author, 'agent', l.agent, `
144
- + `'op', l.op, 'reason', l.reason, 'moved', l.moved) as row`;
145
- // уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3) — надгробием прежней
146
- // версии в том же окне: полная пачка watch_read ограничивает окно своей последней строкой. Оба потока — одним
147
- // оператором: общий снимок и горизонт, иначе курсор перескочил бы событие, зафиксированное между запросами
148
- // (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом.
149
- // Третья строка (src 2) — время курсора, когда пачка неполная (подписка догнала журнал): «сейчас», но не позже самой
150
- // старой записи подписки, которая уже зафиксирована, а горизонт ещё не прошла (её держит более старая транзакция;
151
- // снимок «now» — без записей, которые подписка не отдаёт). Тот же снимок, что у watch_read
152
- const S = this.ctx.S;
153
- const both = await this.ctx.exec.run((q) => q.unsafe(`with r as (select l.* from ${S}.watch_read($1::text::xid8, $2::bigint, $3::text::timestamptz, $4::text[], $5::int, $6::text::pg_snapshot) l),
154
- u as (select r.tx, r.seq from r where (select count(*) from r) = $5 order by r.tx desc, r.seq desc limit 1)
155
- select 0 as src, ${cols} from r l
156
- union all
157
- select 1 as src, ${cols} from ${S}.watch_leaving($1::text::xid8, $2::bigint, $4::text[], $5::int, $6::text::pg_snapshot,
158
- (select tx from u), (select seq from u)) l
159
- union all
160
- select 2 as src, null, null, jsonb_build_object('at', least(statement_timestamp(), min(l.at))) from ${S}.log l
161
- where (select count(*) from r) < $5 and (l.tx, l.seq) > ($1::text::xid8, $2::bigint)
162
- and l.tx >= pg_snapshot_xmin(pg_current_snapshot()) and ($4::text[] is null or l.class = any ($4::text[]))
163
- and ($6::text::pg_snapshot is null or not pg_visible_in_snapshot(l.tx, $6::text::pg_snapshot))
164
- having (select count(*) from r) < $5`, [p.tx, p.seq, this.catchingUp ? null : p.at, this.ctx.classesOf?.() ?? this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
165
- const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
166
- const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
167
- const read = both.filter((r) => r.src === 0).sort(order);
168
- const away = both.filter((r) => r.src === 1).sort(order);
169
- const caught = both.find((r) => r.src === 2);
170
- let rows = read;
171
- {
172
- const upto = read.length === this.batch ? read[read.length - 1] : null;
173
- if (away.length) {
174
- let bound = upto;
175
- const lastAway = away[away.length - 1];
176
- if (away.length === this.batch && (!bound || before(lastAway, bound)))
177
- bound = lastAway;
178
- rows = [...read, ...away].sort(order).filter((r) => !bound || !before(bound, r));
179
- }
180
- }
131
+ + `'op', l.op, 'reason', l.reason) as row`;
132
+ // строки — по (tx, seq), как их отдаёт watch_read
133
+ const rows = await this.ctx.exec.run((q) => q.unsafe(`select ${cols} from ${this.ctx.S}.watch_read($1::text::xid8, $2::bigint, $3::text[], $4::int, $5::text::pg_snapshot) l`, [p.tx, p.seq, this.ctx.classesOf?.() ?? this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
181
134
  // пока события пачки не выданы, курсор подписки — позиция до пачки
182
135
  if (rows.length)
183
136
  this.given = encode(p);
137
+ if (rows.length && this.ctx.log?.wants('watch.fetch'))
138
+ this.ctx.log.emit('watch.fetch', { op: 'watch', rows: rows.length });
184
139
  for (const r of rows) {
185
140
  const row = toRow(r.row);
186
141
  const snap = keepSnap(p.snap, r.tx);
187
- this.pos = { tx: r.tx, seq: r.seq, at: row.at, ...(snap ? { snap } : {}) };
142
+ this.pos = { tx: r.tx, seq: r.seq, ...(snap ? { snap } : {}) };
188
143
  this.buf.push({ cursor: encode(this.pos), row, id: row.id, class: row.class, op: row.op, rev: row.rev, at: row.at });
189
144
  }
190
- // догнала журнал — время курсора подписки (sub.cursor) обновляется и на пустых опросах: прореживать после него нечего,
191
- // и подписка, к которой долго не было событий, не получает ложный cursor_expired. Курсоры событий хранят время события
192
- if (caught && away.length < this.batch) {
193
- this.pos = { ...(this.pos ?? p), at: String(caught.row.at) };
194
- this.catchingUp = false;
195
- }
196
145
  return rows.length;
197
146
  }
198
147
  /**
@@ -212,6 +161,7 @@ export class Subscription {
212
161
  if (this.buf.length) {
213
162
  const ev = this.buf.shift();
214
163
  this.given = ev.cursor;
164
+ this.delivered++;
215
165
  return { value: ev, done: false };
216
166
  }
217
167
  if (this.closed)
@@ -226,6 +176,7 @@ export class Subscription {
226
176
  }
227
177
  catch (e) {
228
178
  this.failure = e;
179
+ this.ctx.log?.error(e, { op: 'watch' });
229
180
  this.close();
230
181
  throw e;
231
182
  }
@@ -240,6 +191,11 @@ export class Subscription {
240
191
  this.burst = false;
241
192
  empty = 0;
242
193
  }
194
+ // подписку закрыли, пока шли запрос или пауза (будильник тогда ещё не выставлен — close() будить было некого): ждать
195
+ // нельзя, будильника не будет — таймер снят, сигналы отписаны (план 2.0, 7.4 №8; находка обхода сервисов — поток
196
+ // SSE после выхода не закрывался)
197
+ if (this.closed)
198
+ return { value: undefined, done: true };
243
199
  await new Promise((r) => {
244
200
  this.wake = () => {
245
201
  this.wake = null;
@@ -263,6 +219,9 @@ export class Subscription {
263
219
  if (this.closed)
264
220
  return;
265
221
  this.closed = true;
222
+ if (this.ctx.log?.wants('watch.stop')) {
223
+ this.ctx.log.emit('watch.stop', { op: 'watch', rows: this.delivered, detail: { cursor: this.cursor, ...(this.failure ? { failed: true } : {}) } });
224
+ }
266
225
  this.off?.();
267
226
  this.ctx.onClose?.(this);
268
227
  if (this.timer)