letopis 1.4.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.md +329 -278
- package/CHANGELOG.md +558 -1
- package/README.md +606 -1037
- 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 -175
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +33 -10
- package/dist/chain.d.ts +19 -41
- package/dist/chain.js +169 -201
- package/dist/cli.js +51 -88
- 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 -71
- package/dist/index.js +245 -130
- package/dist/internal.d.ts +12 -0
- package/dist/internal.js +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +225 -186
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +171 -215
- 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 +26 -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 +5 -9
- package/dist/signals.js +5 -9
- package/dist/sql.d.ts +13 -6
- package/dist/sql.js +176 -83
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +51 -21
- package/dist/tx.d.ts +26 -1
- package/dist/tx.js +44 -14
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +23 -91
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +184 -73
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +11 -10
- package/dist/watch.js +33 -70
- package/dist/write.d.ts +9 -16
- package/dist/write.js +118 -187
- package/llms.txt +22 -25
- package/package.json +8 -11
- 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 -667
- package/sql/70-read.sql +20 -16
- package/sql/80-auth.sql +24 -27
- package/sql/90-time.sql +70 -859
- 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 -485
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2859
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -350
- package/dist/project.js +0 -1224
- package/sql/75-history.sql +0 -358
- package/sql/88-derived.sql +0 -1235
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
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 {
|
|
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,23 +104,24 @@ 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
|
-
|
|
97
|
-
|
|
107
|
+
/** Описание не компилируется (`invalid_class`): ключ каталога и параметры; путь узла — параметр node. */
|
|
108
|
+
function fail(path, key, params) {
|
|
109
|
+
throw lerr(key, { ...params, node: JSON.stringify(path) });
|
|
98
110
|
}
|
|
99
111
|
/** Компиляция узла схемы в правила — порядок и состав как в jp_compile. */
|
|
100
112
|
function compileNode(s, sel, path, inAny, out) {
|
|
101
113
|
if (s === undefined || s === true || (isObj(s) && Object.keys(s).length === 0))
|
|
102
114
|
return;
|
|
103
115
|
if (!isObj(s))
|
|
104
|
-
fail(path,
|
|
116
|
+
fail(path, 'model.validator_node_not_object');
|
|
105
117
|
const sch = s;
|
|
106
118
|
for (const k of Object.keys(sch)) {
|
|
107
119
|
if (!SUPPORTED.has(k) && !k.startsWith('x-'))
|
|
108
|
-
fail(path,
|
|
120
|
+
fail(path, 'model.validator_keyword_unsupported', { keyword: k });
|
|
109
121
|
}
|
|
110
122
|
if (sch.type !== undefined) {
|
|
111
123
|
const types = Array.isArray(sch.type) ? sch.type : [sch.type];
|
|
112
|
-
const tests = types.map((t) => TYPE_TEST[t] ?? fail(path,
|
|
124
|
+
const tests = types.map((t) => TYPE_TEST[t] ?? fail(path, 'model.validator_unknown_type'));
|
|
113
125
|
out.push({ path, keyword: 'type', select: sel, test: (v) => tests.some((f) => f(v)) });
|
|
114
126
|
}
|
|
115
127
|
if (sch.enum !== undefined || sch.const !== undefined) {
|
|
@@ -117,8 +129,8 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
117
129
|
const values = (sch.enum ?? [sch.const]);
|
|
118
130
|
if (values.some((v) => typeof v === 'object' && v !== null)) {
|
|
119
131
|
if (inAny)
|
|
120
|
-
fail(path, '
|
|
121
|
-
out.push({ path, keyword, select: sel, test: () => true, extra: (v) => (values.some((x) => jsonEqual(x, v)) ? null : '
|
|
132
|
+
fail(path, 'model.validator_enum_objects_in_any_of');
|
|
133
|
+
out.push({ path, keyword, select: sel, test: () => true, extra: (v) => (values.some((x) => jsonEqual(x, v)) ? null : lmsg('model.issue_enum')) });
|
|
122
134
|
}
|
|
123
135
|
else {
|
|
124
136
|
out.push({ path, keyword, select: sel, test: (v) => values.some((x) => typeOf(x) === typeOf(v) && jsonEqual(x, v)) });
|
|
@@ -137,36 +149,36 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
137
149
|
out.push({ path, keyword: 'multipleOf', select: sel, test: num((v, b) => isMultiple(v, b), sch.multipleOf) });
|
|
138
150
|
if (sch.pattern !== undefined) {
|
|
139
151
|
const src = translatePattern(sch.pattern); // то, чего база не понимает, — своя ошибка invalid_class
|
|
140
|
-
let re;
|
|
141
152
|
try {
|
|
142
|
-
|
|
153
|
+
new RegExp(src); // только разбор, без исполнения на данных: проверку шаблона делает база (ревью, ReDoS)
|
|
143
154
|
}
|
|
144
155
|
catch {
|
|
145
156
|
// шаблон, неверный уже для JavaScript (незакрытая скобка и т. п.): translatePattern его пропускает, RegExp — нет.
|
|
146
157
|
// Текст V8 в сообщение не идёт: он зависит от версии движка
|
|
147
|
-
fail(path,
|
|
158
|
+
fail(path, 'model.validator_bad_pattern', { pattern: JSON.stringify(sch.pattern) });
|
|
148
159
|
}
|
|
149
|
-
out.push({ path, keyword: 'pattern', select: sel, test: (
|
|
160
|
+
out.push({ path, keyword: 'pattern', select: sel, test: () => true, db: true });
|
|
150
161
|
}
|
|
151
162
|
if (sch.format !== undefined) {
|
|
152
163
|
const f = sch.format;
|
|
153
|
-
const re = FORMAT_RE[f] ?? fail(path,
|
|
154
|
-
|
|
164
|
+
const re = FORMAT_RE[f] ?? fail(path, 'model.validator_format_unsupported', { format: f });
|
|
165
|
+
const max = f === 'email' ? EMAIL_MAX : Infinity;
|
|
166
|
+
out.push({ path, keyword: 'format', select: sel, test: (v) => typeof v !== 'string' || (v.length <= max && re.test(v)) });
|
|
155
167
|
if (f === 'date' || f === 'date-time') {
|
|
156
168
|
if (inAny)
|
|
157
|
-
fail(path,
|
|
169
|
+
fail(path, 'model.validator_format_in_any_of', { format: f });
|
|
158
170
|
out.push({ path, keyword: 'format', select: sel, test: () => true,
|
|
159
|
-
extra: (v) => (typeof v !== 'string' || !re.test(v) || validDate(v, f === 'date-time') ? null : '
|
|
171
|
+
extra: (v) => (typeof v !== 'string' || !re.test(v) || validDate(v, f === 'date-time') ? null : lmsg('model.issue_date')) });
|
|
160
172
|
}
|
|
161
173
|
}
|
|
162
174
|
if (sch.minLength !== undefined || sch.maxLength !== undefined) {
|
|
163
175
|
if (inAny)
|
|
164
|
-
fail(path, '
|
|
176
|
+
fail(path, 'model.validator_string_length_in_any_of');
|
|
165
177
|
const min = sch.minLength ?? 0;
|
|
166
178
|
const max = sch.maxLength ?? Infinity;
|
|
167
179
|
out.push({ path, keyword: sch.minLength !== undefined ? 'minLength' : 'maxLength', select: sel, test: () => true,
|
|
168
180
|
extra: (v) => { if (typeof v !== 'string')
|
|
169
|
-
return null; const n = [...v].length; return n < min || n > max ? '
|
|
181
|
+
return null; const n = [...v].length; return n < min || n > max ? lmsg('model.issue_string_length') : null; } });
|
|
170
182
|
}
|
|
171
183
|
if (sch.minItems !== undefined)
|
|
172
184
|
out.push({ path, keyword: 'minItems', select: sel, test: (v) => !Array.isArray(v) || v.length >= sch.minItems });
|
|
@@ -174,9 +186,9 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
174
186
|
out.push({ path, keyword: 'maxItems', select: sel, test: (v) => !Array.isArray(v) || v.length <= sch.maxItems });
|
|
175
187
|
if (sch.uniqueItems === true) {
|
|
176
188
|
if (inAny)
|
|
177
|
-
fail(path, '
|
|
189
|
+
fail(path, 'model.validator_unique_items_in_any_of');
|
|
178
190
|
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) ? '
|
|
191
|
+
extra: (v) => (Array.isArray(v) && v.some((x, i) => v.findIndex((y) => jsonEqual(x, y)) !== i) ? lmsg('model.issue_unique_items') : null) });
|
|
180
192
|
}
|
|
181
193
|
if (sch.items !== undefined)
|
|
182
194
|
compileNode(sch.items, items(sel), [...path, '*'], inAny, out);
|
|
@@ -186,12 +198,12 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
186
198
|
}
|
|
187
199
|
if (sch.minProperties !== undefined || sch.maxProperties !== undefined) {
|
|
188
200
|
if (inAny)
|
|
189
|
-
fail(path, '
|
|
201
|
+
fail(path, 'model.validator_min_properties_in_any_of');
|
|
190
202
|
const min = sch.minProperties ?? 0;
|
|
191
203
|
const max = sch.maxProperties ?? Infinity;
|
|
192
204
|
out.push({ path, keyword: sch.minProperties !== undefined ? 'minProperties' : 'maxProperties', select: sel, test: () => true,
|
|
193
205
|
extra: (v) => { if (!isObj(v))
|
|
194
|
-
return null; const n = Object.keys(v).length; return n < min || n > max ? '
|
|
206
|
+
return null; const n = Object.keys(v).length; return n < min || n > max ? lmsg('model.issue_property_count') : null; } });
|
|
195
207
|
}
|
|
196
208
|
const known = isObj(sch.properties) ? Object.keys(sch.properties) : [];
|
|
197
209
|
if (isObj(sch.properties)) {
|
|
@@ -210,24 +222,32 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
210
222
|
for (const kw of ['anyOf', 'oneOf']) {
|
|
211
223
|
if (!Array.isArray(sch[kw]))
|
|
212
224
|
continue;
|
|
213
|
-
const
|
|
225
|
+
const compiled = sch[kw].map((b) => {
|
|
214
226
|
const rules = [];
|
|
215
227
|
compileNode(b, (root) => [root], path, true, rules);
|
|
216
|
-
return
|
|
228
|
+
return rules;
|
|
217
229
|
});
|
|
230
|
+
// ветку с правилом базы здесь не решить: anyOf/oneOf целиком проверяет база (у oneOf иначе — ложное нарушение)
|
|
231
|
+
if (compiled.some((rules) => rules.some((r) => r.db))) {
|
|
232
|
+
out.push({ path, keyword: kw, select: sel, test: () => true, db: true });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const branches = compiled.map((rules) => (node) => rules.every((r) => r.select(node).every((n) => r.test(n))));
|
|
218
236
|
out.push({ path, keyword: kw, select: sel, test: kw === 'anyOf'
|
|
219
237
|
? (v) => branches.some((b) => b(v))
|
|
220
238
|
: (v) => branches.filter((b) => b(v)).length === 1 });
|
|
221
239
|
}
|
|
222
240
|
}
|
|
223
241
|
/**
|
|
224
|
-
* Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при
|
|
242
|
+
* Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи, кроме
|
|
243
|
+
* `pattern` (и `anyOf`/`oneOf` с ним в ветке): шаблоны задаёт арендатор, их проверяет только база. Схема
|
|
225
244
|
* класса — `db.registry.resolve(имя).schema`.
|
|
226
245
|
*/
|
|
227
246
|
export function compileValidator(schema) {
|
|
228
|
-
const
|
|
229
|
-
compileNode(schema, (root) => [root], [], false,
|
|
230
|
-
|
|
247
|
+
const all = [];
|
|
248
|
+
compileNode(schema, (root) => [root], [], false, all);
|
|
249
|
+
const rules = all.filter((r) => !r.db);
|
|
250
|
+
const v = (data) => {
|
|
231
251
|
const issues = [];
|
|
232
252
|
for (const r of rules) {
|
|
233
253
|
const nodes = r.select(data);
|
|
@@ -239,11 +259,23 @@ export function compileValidator(schema) {
|
|
|
239
259
|
}
|
|
240
260
|
}
|
|
241
261
|
else if (!nodes.every((n) => r.test(n))) {
|
|
242
|
-
issues.push({ path: r.path, keyword: r.keyword, message:
|
|
262
|
+
issues.push({ path: r.path, keyword: r.keyword, message: lmsg('model.issue_rule', { keyword: r.keyword }) });
|
|
243
263
|
}
|
|
244
264
|
}
|
|
245
265
|
return issues;
|
|
246
266
|
};
|
|
267
|
+
if (rules.length < all.length)
|
|
268
|
+
LEAVES_TO_DB.add(v);
|
|
269
|
+
return v;
|
|
270
|
+
}
|
|
271
|
+
/** Валидаторы с правилами, которые проверяет только база. */
|
|
272
|
+
const LEAVES_TO_DB = new WeakSet();
|
|
273
|
+
/**
|
|
274
|
+
* Оставляет ли валидатор правила базе (`pattern` и `anyOf`/`oneOf` с ним): строки таких классов загрузчик, который
|
|
275
|
+
* пишет мимо триггеров, сверяет запросом к базе. Не входит в API пакета.
|
|
276
|
+
*/
|
|
277
|
+
export function leavesToDb(v) {
|
|
278
|
+
return LEAVES_TO_DB.has(v);
|
|
247
279
|
}
|
|
248
280
|
/**
|
|
249
281
|
* Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
|
package/dist/watch.d.ts
CHANGED
|
@@ -31,15 +31,19 @@ export interface WatchCtx {
|
|
|
31
31
|
exec: Executor;
|
|
32
32
|
/** Классы подписки с потомками (null — все). */
|
|
33
33
|
classes: string[] | null;
|
|
34
|
+
/** Классы с потомками заново — по текущему реестру: потомки, заведённые после watch(), тоже в подписке (план 2.0, 7.4 №2). */
|
|
35
|
+
classesOf?: () => string[] | null;
|
|
34
36
|
/** Подписка на сигналы базы (разобранные); возвращает отписку. */
|
|
35
37
|
onSignal?: (fn: (s: Signal) => void) => () => void;
|
|
36
38
|
/** Токен арендатора подписки (null — процесс без ключа или режим без токенов). */
|
|
37
39
|
tenant?: () => string | null;
|
|
40
|
+
/** Подписка закрылась: подключение перестаёт её учитывать (план 2.0, 7.4 №8). */
|
|
41
|
+
onClose?: (s: Subscription) => void;
|
|
38
42
|
}
|
|
39
43
|
/**
|
|
40
44
|
* Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
|
|
41
|
-
* любой); сессии не будят;
|
|
42
|
-
* будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
|
|
45
|
+
* любой); сессии не будят; переподключение слушателя, opaque без ключа и нагрузка не по режиму — будят. Незнакомый
|
|
46
|
+
* токен класса будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
|
|
43
47
|
*/
|
|
44
48
|
export declare function wakes(s: Signal, watched: ReadonlySet<string> | null, mine: string | null): boolean;
|
|
45
49
|
export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
@@ -52,6 +56,8 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
52
56
|
private closed;
|
|
53
57
|
private wake;
|
|
54
58
|
private dirty;
|
|
59
|
+
/** Пришёл сигнал: пустой ответ — ещё не горизонт, нужны короткие повторы. Таймер их не запускает (план 2.0, 7.4 №1). */
|
|
60
|
+
private burst;
|
|
55
61
|
private off;
|
|
56
62
|
private timer;
|
|
57
63
|
private failure;
|
|
@@ -62,16 +68,11 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
62
68
|
/** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
|
|
63
69
|
private queue;
|
|
64
70
|
constructor(ctx: WatchCtx, opts: WatchOptions);
|
|
65
|
-
/**
|
|
66
|
-
* Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
|
|
67
|
-
* в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
|
|
68
|
-
* курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
|
|
69
|
-
* cursor_expired.
|
|
70
|
-
*/
|
|
71
|
+
/** Курсор подписки: позиция после последнего выданного события (или начальная точка) — с неё продолжают после перезапуска. */
|
|
71
72
|
get cursor(): string | null;
|
|
72
73
|
/**
|
|
73
|
-
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в
|
|
74
|
-
*
|
|
74
|
+
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток.
|
|
75
|
+
* У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
|
|
75
76
|
* выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
|
|
76
77
|
*/
|
|
77
78
|
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 {
|
|
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.
|
|
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+)
|
|
21
|
+
const m = /^(\d+):(\d+)$/.exec(head);
|
|
27
22
|
if (!m || (snap !== undefined && !/^\d+:\d+:[\d,]*$/.test(snap))) {
|
|
28
|
-
throw
|
|
23
|
+
throw lerr('time.watch_cursor', { cursor: c });
|
|
29
24
|
}
|
|
30
|
-
return { tx: m[1], seq: m[2],
|
|
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
|
-
* любой); сессии не будят;
|
|
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);
|
|
@@ -61,6 +55,8 @@ export class Subscription {
|
|
|
61
55
|
closed = false;
|
|
62
56
|
wake = null;
|
|
63
57
|
dirty = true;
|
|
58
|
+
/** Пришёл сигнал: пустой ответ — ещё не горизонт, нужны короткие повторы. Таймер их не запускает (план 2.0, 7.4 №1). */
|
|
59
|
+
burst = false;
|
|
64
60
|
off = null;
|
|
65
61
|
timer = null;
|
|
66
62
|
failure = null;
|
|
@@ -77,7 +73,7 @@ export class Subscription {
|
|
|
77
73
|
this.pollMs = opts.pollMs ?? 1000;
|
|
78
74
|
const from = opts.from ?? 'now';
|
|
79
75
|
if (from === 'start')
|
|
80
|
-
this.pos = { tx: '0', seq: '0'
|
|
76
|
+
this.pos = { tx: '0', seq: '0' };
|
|
81
77
|
else if (from !== 'now')
|
|
82
78
|
this.pos = decode(from);
|
|
83
79
|
else {
|
|
@@ -85,11 +81,12 @@ export class Subscription {
|
|
|
85
81
|
this.start.catch(() => { });
|
|
86
82
|
}
|
|
87
83
|
if (ctx.onSignal) {
|
|
88
|
-
const watched = ctx.classes ? new Set(ctx.classes) : null;
|
|
89
84
|
this.off = ctx.onSignal((s) => {
|
|
90
|
-
|
|
85
|
+
const cls = ctx.classesOf?.() ?? ctx.classes;
|
|
86
|
+
if (!wakes(s, cls ? new Set(cls) : null, ctx.tenant?.() ?? null))
|
|
91
87
|
return;
|
|
92
88
|
this.dirty = true;
|
|
89
|
+
this.burst = true;
|
|
93
90
|
this.wake?.();
|
|
94
91
|
});
|
|
95
92
|
}
|
|
@@ -99,20 +96,15 @@ export class Subscription {
|
|
|
99
96
|
}, this.pollMs);
|
|
100
97
|
this.timer.unref?.();
|
|
101
98
|
}
|
|
102
|
-
/**
|
|
103
|
-
* Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
|
|
104
|
-
* в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
|
|
105
|
-
* курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
|
|
106
|
-
* cursor_expired.
|
|
107
|
-
*/
|
|
99
|
+
/** Курсор подписки: позиция после последнего выданного события (или начальная точка) — с неё продолжают после перезапуска. */
|
|
108
100
|
get cursor() {
|
|
109
101
|
if (this.buf.length)
|
|
110
102
|
return this.given;
|
|
111
103
|
return this.pos ? encode(this.pos) : null;
|
|
112
104
|
}
|
|
113
105
|
/**
|
|
114
|
-
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в
|
|
115
|
-
*
|
|
106
|
+
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток.
|
|
107
|
+
* У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
|
|
116
108
|
* выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
|
|
117
109
|
*/
|
|
118
110
|
async ready() {
|
|
@@ -121,7 +113,7 @@ export class Subscription {
|
|
|
121
113
|
}
|
|
122
114
|
async head() {
|
|
123
115
|
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 });
|
|
124
|
-
return { tx: r.tx, seq: r.seq,
|
|
116
|
+
return { tx: r.tx, seq: r.seq, snap: r.snap };
|
|
125
117
|
}
|
|
126
118
|
/** Следующая пачка событий ниже горизонта; пусто — горизонт ещё не дошёл или событий нет. */
|
|
127
119
|
async fetch() {
|
|
@@ -130,56 +122,18 @@ export class Subscription {
|
|
|
130
122
|
const p = this.pos;
|
|
131
123
|
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, `
|
|
132
124
|
+ `'owner', l.owner, 'links', l.links, 'data', l.data, 'tags', l.tags, 'at', l.at, 'author', l.author, 'agent', l.agent, `
|
|
133
|
-
+ `'op', l.op, 'reason', l.reason
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
// оператором: общий снимок и горизонт, иначе курсор перескочил бы событие, зафиксированное между запросами
|
|
137
|
-
// (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом.
|
|
138
|
-
// Третья строка (src 2) — время курсора, когда пачка неполная (подписка догнала журнал): «сейчас», но не позже самой
|
|
139
|
-
// старой записи подписки, которая уже зафиксирована, а горизонт ещё не прошла (её держит более старая транзакция;
|
|
140
|
-
// снимок «now» — без записей, которые подписка не отдаёт). Тот же снимок, что у watch_read
|
|
141
|
-
const S = this.ctx.S;
|
|
142
|
-
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),
|
|
143
|
-
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)
|
|
144
|
-
select 0 as src, ${cols} from r l
|
|
145
|
-
union all
|
|
146
|
-
select 1 as src, ${cols} from ${S}.watch_leaving($1::text::xid8, $2::bigint, $4::text[], $5::int, $6::text::pg_snapshot,
|
|
147
|
-
(select tx from u), (select seq from u)) l
|
|
148
|
-
union all
|
|
149
|
-
select 2 as src, null, null, jsonb_build_object('at', least(statement_timestamp(), min(l.at))) from ${S}.log l
|
|
150
|
-
where (select count(*) from r) < $5 and (l.tx, l.seq) > ($1::text::xid8, $2::bigint)
|
|
151
|
-
and l.tx >= pg_snapshot_xmin(pg_current_snapshot()) and ($4::text[] is null or l.class = any ($4::text[]))
|
|
152
|
-
and ($6::text::pg_snapshot is null or not pg_visible_in_snapshot(l.tx, $6::text::pg_snapshot))
|
|
153
|
-
having (select count(*) from r) < $5`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
|
|
154
|
-
const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
|
|
155
|
-
const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
|
|
156
|
-
const read = both.filter((r) => r.src === 0).sort(order);
|
|
157
|
-
const away = both.filter((r) => r.src === 1).sort(order);
|
|
158
|
-
const caught = both.find((r) => r.src === 2);
|
|
159
|
-
let rows = read;
|
|
160
|
-
{
|
|
161
|
-
const upto = read.length === this.batch ? read[read.length - 1] : null;
|
|
162
|
-
if (away.length) {
|
|
163
|
-
let bound = upto;
|
|
164
|
-
const lastAway = away[away.length - 1];
|
|
165
|
-
if (away.length === this.batch && (!bound || before(lastAway, bound)))
|
|
166
|
-
bound = lastAway;
|
|
167
|
-
rows = [...read, ...away].sort(order).filter((r) => !bound || !before(bound, r));
|
|
168
|
-
}
|
|
169
|
-
}
|
|
125
|
+
+ `'op', l.op, 'reason', l.reason) as row`;
|
|
126
|
+
// строки — по (tx, seq), как их отдаёт watch_read
|
|
127
|
+
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 });
|
|
170
128
|
// пока события пачки не выданы, курсор подписки — позиция до пачки
|
|
171
129
|
if (rows.length)
|
|
172
130
|
this.given = encode(p);
|
|
173
131
|
for (const r of rows) {
|
|
174
132
|
const row = toRow(r.row);
|
|
175
133
|
const snap = keepSnap(p.snap, r.tx);
|
|
176
|
-
this.pos = { tx: r.tx, seq: r.seq,
|
|
134
|
+
this.pos = { tx: r.tx, seq: r.seq, ...(snap ? { snap } : {}) };
|
|
177
135
|
this.buf.push({ cursor: encode(this.pos), row, id: row.id, class: row.class, op: row.op, rev: row.rev, at: row.at });
|
|
178
136
|
}
|
|
179
|
-
// догнала журнал — время курсора подписки (sub.cursor) обновляется и на пустых опросах: прореживать после него нечего,
|
|
180
|
-
// и подписка, к которой долго не было событий, не получает ложный cursor_expired. Курсоры событий хранят время события
|
|
181
|
-
if (caught && away.length < this.batch)
|
|
182
|
-
this.pos = { ...(this.pos ?? p), at: String(caught.row.at) };
|
|
183
137
|
return rows.length;
|
|
184
138
|
}
|
|
185
139
|
/**
|
|
@@ -216,14 +170,22 @@ export class Subscription {
|
|
|
216
170
|
this.close();
|
|
217
171
|
throw e;
|
|
218
172
|
}
|
|
219
|
-
// сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной
|
|
220
|
-
|
|
173
|
+
// сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной опрос. Опрос по таймеру —
|
|
174
|
+
// один запрос: серия повторов на каждый тик давала около 4 запросов в секунду на молчащую подписку (план 2.0,
|
|
175
|
+
// 7.4 №1)
|
|
176
|
+
if (this.burst && ++empty < 40) {
|
|
221
177
|
await sleep(Math.min(25 * empty, 250));
|
|
222
178
|
continue;
|
|
223
179
|
}
|
|
224
180
|
this.dirty = false;
|
|
181
|
+
this.burst = false;
|
|
225
182
|
empty = 0;
|
|
226
183
|
}
|
|
184
|
+
// подписку закрыли, пока шли запрос или пауза (будильник тогда ещё не выставлен — close() будить было некого): ждать
|
|
185
|
+
// нельзя, будильника не будет — таймер снят, сигналы отписаны (план 2.0, 7.4 №8; находка обхода сервисов — поток
|
|
186
|
+
// SSE после выхода не закрывался)
|
|
187
|
+
if (this.closed)
|
|
188
|
+
return { value: undefined, done: true };
|
|
227
189
|
await new Promise((r) => {
|
|
228
190
|
this.wake = () => {
|
|
229
191
|
this.wake = null;
|
|
@@ -248,6 +210,7 @@ export class Subscription {
|
|
|
248
210
|
return;
|
|
249
211
|
this.closed = true;
|
|
250
212
|
this.off?.();
|
|
213
|
+
this.ctx.onClose?.(this);
|
|
251
214
|
if (this.timer)
|
|
252
215
|
clearInterval(this.timer);
|
|
253
216
|
this.wake?.();
|
package/dist/write.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ClassInfo, Registry } from './registry.js';
|
|
2
2
|
import { type AggSpec, type BuiltQuery, type ReadMode, type Step } from './sql.js';
|
|
3
3
|
import type { Tx } from './tx.js';
|
|
4
4
|
import type { ChainMods, Row } from './types.js';
|
|
@@ -26,27 +26,16 @@ export type PlanOp = ({
|
|
|
26
26
|
kind: 'update';
|
|
27
27
|
data: Record<string, unknown>;
|
|
28
28
|
rev?: number;
|
|
29
|
+
all?: true;
|
|
29
30
|
} | {
|
|
30
31
|
kind: 'upsert';
|
|
31
32
|
data: Record<string, unknown>;
|
|
32
33
|
} | {
|
|
33
34
|
kind: 'delete';
|
|
34
35
|
confirm: boolean;
|
|
35
|
-
|
|
36
|
-
kind: 'anonymize';
|
|
37
|
-
fields: string[];
|
|
38
|
-
} | {
|
|
39
|
-
kind: 'reclass';
|
|
40
|
-
cls: string;
|
|
41
|
-
data?: Record<string, unknown>;
|
|
36
|
+
all?: true;
|
|
42
37
|
} | {
|
|
43
38
|
kind: 'restore';
|
|
44
|
-
} | {
|
|
45
|
-
kind: 'purge';
|
|
46
|
-
confirm: boolean;
|
|
47
|
-
} | {
|
|
48
|
-
kind: 'rekey';
|
|
49
|
-
data: Record<string, unknown>;
|
|
50
39
|
}) & {
|
|
51
40
|
reason?: string;
|
|
52
41
|
};
|
|
@@ -77,8 +66,12 @@ export declare function splitPatch(patch: Record<string, unknown>): {
|
|
|
77
66
|
rest: Record<string, unknown>;
|
|
78
67
|
incs: IncSpec[];
|
|
79
68
|
};
|
|
80
|
-
/**
|
|
81
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Всё, что проверяется без базы, — до отправки первого оператора (§2.5). Фильтры шагов и поля sort() и агрегатов
|
|
71
|
+
* (неизвестное поле — invalid_query, план 2.0, этап 3, п. 3) — precheckStep построителя; order и agg — модификатор и
|
|
72
|
+
* агрегат чтения за последним глаголом (терминал цепочки).
|
|
73
|
+
*/
|
|
74
|
+
export declare function precheckPlan(reg: Registry, steps: Step[], order?: string, agg?: AggSpec): void;
|
|
82
75
|
export interface WriteCtx {
|
|
83
76
|
reg: Registry;
|
|
84
77
|
schema: string;
|