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.
- package/AGENT-CHEATSHEET.md +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- 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 +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -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 +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -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 +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -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 +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- 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 +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- 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 +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +18 -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/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 {
|
|
10
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
65
|
+
throw lerr('model.key_end_not_uuid', { cls: cls.name, end: k });
|
|
58
66
|
return v.toLowerCase();
|
|
59
67
|
}
|
|
60
68
|
return v;
|
package/dist/validate.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
97
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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, '
|
|
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
|
-
|
|
160
|
+
new RegExp(src); // только разбор, без исполнения на данных: проверку шаблона делает база (ревью, ReDoS)
|
|
143
161
|
}
|
|
144
162
|
catch {
|
|
145
163
|
// шаблон, неверный уже для JavaScript (незакрытая скобка и т. п.): translatePattern его пропускает, RegExp — нет.
|
|
146
164
|
// Текст V8 в сообщение не идёт: он зависит от версии движка
|
|
147
|
-
fail(path,
|
|
165
|
+
fail(path, 'model.validator_bad_pattern', { pattern: JSON.stringify(sch.pattern) });
|
|
148
166
|
}
|
|
149
|
-
out.push({ path, keyword: 'pattern', select: sel, test: (
|
|
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,
|
|
154
|
-
|
|
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,
|
|
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, '
|
|
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
|
-
|
|
169
|
-
|
|
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, '
|
|
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) ? '
|
|
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, '
|
|
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 ? '
|
|
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
|
-
|
|
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
|
|
239
|
+
const compiled = sch[kw].map((b) => {
|
|
214
240
|
const rules = [];
|
|
215
241
|
compileNode(b, (root) => [root], path, true, rules);
|
|
216
|
-
return
|
|
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
|
|
229
|
-
compileNode(schema, (root) => [root], [], false,
|
|
230
|
-
|
|
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
|
|
242
|
-
|
|
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
|
-
* любой); сессии не будят;
|
|
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
|
-
*
|
|
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 {
|
|
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;
|
|
@@ -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'
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
}
|
|
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,
|
|
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)
|