letopis 1.4.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.md +329 -281
- package/CHANGELOG.md +418 -0
- package/README.md +572 -1053
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -184
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +18 -41
- package/dist/chain.js +165 -203
- package/dist/cli.js +45 -90
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -70
- package/dist/index.js +189 -122
- package/dist/internal.d.ts +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +207 -181
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +168 -214
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +20 -29
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +7 -1
- package/dist/sql.js +101 -85
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +40 -17
- package/dist/tx.d.ts +11 -1
- package/dist/tx.js +32 -11
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +15 -90
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +180 -74
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +5 -16
- package/dist/watch.js +23 -77
- package/dist/write.d.ts +9 -16
- package/dist/write.js +102 -185
- package/llms.txt +22 -25
- package/package.json +2 -9
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +23 -27
- package/sql/90-time.sql +77 -944
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/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
|
@@ -42,8 +42,8 @@ export interface WatchCtx {
|
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
|
|
45
|
-
* любой); сессии не будят;
|
|
46
|
-
* будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
|
|
45
|
+
* любой); сессии не будят; переподключение слушателя, opaque без ключа и нагрузка не по режиму — будят. Незнакомый
|
|
46
|
+
* токен класса будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
|
|
47
47
|
*/
|
|
48
48
|
export declare function wakes(s: Signal, watched: ReadonlySet<string> | null, mine: string | null): boolean;
|
|
49
49
|
export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
@@ -58,12 +58,6 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
58
58
|
private dirty;
|
|
59
59
|
/** Пришёл сигнал: пустой ответ — ещё не горизонт, нужны короткие повторы. Таймер их не запускает (план 2.0, 7.4 №1). */
|
|
60
60
|
private burst;
|
|
61
|
-
/**
|
|
62
|
-
* from: 'start' — пока подписка не догнала журнал, свои чтения она шлёт без времени курсора: весь журнал, без
|
|
63
|
-
* cursor_expired на старых событиях (план 2.0, 7.4 №3). Курсоры событий и sub.cursor время хранят: продолжение с них
|
|
64
|
-
* после прореживания — cursor_expired, как обещано.
|
|
65
|
-
*/
|
|
66
|
-
private catchingUp;
|
|
67
61
|
private off;
|
|
68
62
|
private timer;
|
|
69
63
|
private failure;
|
|
@@ -74,16 +68,11 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
74
68
|
/** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
|
|
75
69
|
private queue;
|
|
76
70
|
constructor(ctx: WatchCtx, opts: WatchOptions);
|
|
77
|
-
/**
|
|
78
|
-
* Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
|
|
79
|
-
* в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
|
|
80
|
-
* курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
|
|
81
|
-
* cursor_expired.
|
|
82
|
-
*/
|
|
71
|
+
/** Курсор подписки: позиция после последнего выданного события (или начальная точка) — с неё продолжают после перезапуска. */
|
|
83
72
|
get cursor(): string | null;
|
|
84
73
|
/**
|
|
85
|
-
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в
|
|
86
|
-
*
|
|
74
|
+
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток.
|
|
75
|
+
* У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
|
|
87
76
|
* выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
|
|
88
77
|
*/
|
|
89
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);
|
|
@@ -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;
|
|
@@ -84,10 +72,8 @@ export class Subscription {
|
|
|
84
72
|
this.batch = opts.batch ?? 500;
|
|
85
73
|
this.pollMs = opts.pollMs ?? 1000;
|
|
86
74
|
const from = opts.from ?? 'now';
|
|
87
|
-
if (from === 'start')
|
|
88
|
-
this.pos = { tx: '0', seq: '0'
|
|
89
|
-
this.catchingUp = true;
|
|
90
|
-
}
|
|
75
|
+
if (from === 'start')
|
|
76
|
+
this.pos = { tx: '0', seq: '0' };
|
|
91
77
|
else if (from !== 'now')
|
|
92
78
|
this.pos = decode(from);
|
|
93
79
|
else {
|
|
@@ -110,20 +96,15 @@ export class Subscription {
|
|
|
110
96
|
}, this.pollMs);
|
|
111
97
|
this.timer.unref?.();
|
|
112
98
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
|
|
115
|
-
* в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
|
|
116
|
-
* курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
|
|
117
|
-
* cursor_expired.
|
|
118
|
-
*/
|
|
99
|
+
/** Курсор подписки: позиция после последнего выданного события (или начальная точка) — с неё продолжают после перезапуска. */
|
|
119
100
|
get cursor() {
|
|
120
101
|
if (this.buf.length)
|
|
121
102
|
return this.given;
|
|
122
103
|
return this.pos ? encode(this.pos) : null;
|
|
123
104
|
}
|
|
124
105
|
/**
|
|
125
|
-
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в
|
|
126
|
-
*
|
|
106
|
+
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток.
|
|
107
|
+
* У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
|
|
127
108
|
* выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
|
|
128
109
|
*/
|
|
129
110
|
async ready() {
|
|
@@ -132,7 +113,7 @@ export class Subscription {
|
|
|
132
113
|
}
|
|
133
114
|
async head() {
|
|
134
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 });
|
|
135
|
-
return { tx: r.tx, seq: r.seq,
|
|
116
|
+
return { tx: r.tx, seq: r.seq, snap: r.snap };
|
|
136
117
|
}
|
|
137
118
|
/** Следующая пачка событий ниже горизонта; пусто — горизонт ещё не дошёл или событий нет. */
|
|
138
119
|
async fetch() {
|
|
@@ -141,58 +122,18 @@ export class Subscription {
|
|
|
141
122
|
const p = this.pos;
|
|
142
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, `
|
|
143
124
|
+ `'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
|
|
145
|
-
//
|
|
146
|
-
|
|
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
|
-
}
|
|
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 });
|
|
181
128
|
// пока события пачки не выданы, курсор подписки — позиция до пачки
|
|
182
129
|
if (rows.length)
|
|
183
130
|
this.given = encode(p);
|
|
184
131
|
for (const r of rows) {
|
|
185
132
|
const row = toRow(r.row);
|
|
186
133
|
const snap = keepSnap(p.snap, r.tx);
|
|
187
|
-
this.pos = { tx: r.tx, seq: r.seq,
|
|
134
|
+
this.pos = { tx: r.tx, seq: r.seq, ...(snap ? { snap } : {}) };
|
|
188
135
|
this.buf.push({ cursor: encode(this.pos), row, id: row.id, class: row.class, op: row.op, rev: row.rev, at: row.at });
|
|
189
136
|
}
|
|
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
137
|
return rows.length;
|
|
197
138
|
}
|
|
198
139
|
/**
|
|
@@ -240,6 +181,11 @@ export class Subscription {
|
|
|
240
181
|
this.burst = false;
|
|
241
182
|
empty = 0;
|
|
242
183
|
}
|
|
184
|
+
// подписку закрыли, пока шли запрос или пауза (будильник тогда ещё не выставлен — close() будить было некого): ждать
|
|
185
|
+
// нельзя, будильника не будет — таймер снят, сигналы отписаны (план 2.0, 7.4 №8; находка обхода сервисов — поток
|
|
186
|
+
// SSE после выхода не закрывался)
|
|
187
|
+
if (this.closed)
|
|
188
|
+
return { value: undefined, done: true };
|
|
243
189
|
await new Promise((r) => {
|
|
244
190
|
this.wake = () => {
|
|
245
191
|
this.wake = null;
|
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;
|