letopis 1.4.1 → 1.4.2
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.en.md +5 -2
- package/AGENT-CHEATSHEET.md +5 -2
- package/CHANGELOG.md +140 -1
- package/README.en.md +66 -15
- package/README.md +62 -12
- package/dist/admin.js +11 -2
- package/dist/cache.js +3 -2
- package/dist/chain.d.ts +2 -1
- package/dist/chain.js +9 -3
- package/dist/cli.js +10 -2
- package/dist/errors.js +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.js +57 -9
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +5 -0
- package/dist/load.js +20 -7
- package/dist/mcp.js +4 -2
- package/dist/model.js +7 -1
- package/dist/project.d.ts +9 -0
- package/dist/project.js +45 -11
- package/dist/signals.d.ts +4 -4
- package/dist/signals.js +4 -4
- package/dist/sql.d.ts +6 -5
- package/dist/sql.js +100 -23
- package/dist/sync.js +11 -4
- package/dist/tx.d.ts +15 -0
- package/dist/tx.js +12 -3
- package/dist/types.d.ts +8 -1
- package/dist/up.js +5 -0
- package/dist/watch.d.ts +12 -0
- package/dist/watch.js +24 -7
- package/dist/write.js +23 -9
- package/package.json +8 -4
- package/sql/60-write.sql +9 -0
- package/sql/70-read.sql +13 -7
- package/sql/75-history.sql +106 -28
- package/sql/80-auth.sql +2 -1
- package/sql/88-derived.sql +2 -1
- package/sql/90-time.sql +106 -28
package/dist/project.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LetopisError, fromDbError } from './errors.js';
|
|
2
|
+
import { release, rollbackAfter } from './tx.js';
|
|
2
3
|
import { toRow } from './sql.js';
|
|
3
4
|
import { wakes } from './watch.js';
|
|
4
5
|
import { schemaRevision, SYSTEM_ID } from './up.js';
|
|
@@ -221,6 +222,10 @@ export class Projector {
|
|
|
221
222
|
/** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
|
|
222
223
|
blind = new Map();
|
|
223
224
|
stale = true;
|
|
225
|
+
/** Когда состав читался в последний раз: без LISTEN сигнала о новых классах нет (план 2.0, 7.4 №6). */
|
|
226
|
+
resolvedAt = 0;
|
|
227
|
+
/** Участники, которых чекпоинт ещё не знает: им начальная перестройка пачками (tenantWork). */
|
|
228
|
+
newcomers = [];
|
|
224
229
|
/** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
|
|
225
230
|
tenantsPending = true;
|
|
226
231
|
running = false;
|
|
@@ -234,6 +239,13 @@ export class Projector {
|
|
|
234
239
|
/** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
|
|
235
240
|
lockHits = new Map();
|
|
236
241
|
only;
|
|
242
|
+
/**
|
|
243
|
+
* Перечитать состав: по сигналу классов, а без LISTEN (сигналов нет) — не реже раза в 5 с, как реестр подключения в
|
|
244
|
+
* режиме none, и в начале run() (план 2.0, 7.4 №6): иначе арендатор, вступивший после запуска, не участвовал бы.
|
|
245
|
+
*/
|
|
246
|
+
due() {
|
|
247
|
+
return this.stale || (!this.env.onSignal && Date.now() - this.resolvedAt >= 5000);
|
|
248
|
+
}
|
|
237
249
|
constructor(env, name, def) {
|
|
238
250
|
this.env = env;
|
|
239
251
|
this.def = def;
|
|
@@ -286,6 +298,8 @@ export class Projector {
|
|
|
286
298
|
throw new LetopisError('invalid_query', `проектор ${this.name} уже запущен в этом процессе`);
|
|
287
299
|
this.running = true;
|
|
288
300
|
this.stopped = false;
|
|
301
|
+
if (!this.env.onSignal)
|
|
302
|
+
this.stale = true;
|
|
289
303
|
const rep = emptyReport();
|
|
290
304
|
try {
|
|
291
305
|
for (;;) {
|
|
@@ -390,7 +404,7 @@ export class Projector {
|
|
|
390
404
|
}
|
|
391
405
|
async status() {
|
|
392
406
|
return this.tx(async (q) => {
|
|
393
|
-
if (this.
|
|
407
|
+
if (this.due())
|
|
394
408
|
await this.resolve(q);
|
|
395
409
|
const [r] = await q.unsafe(`select s.meta from ${this.env.S}.secret s
|
|
396
410
|
where s.kind = 'PROJECTOR' and s.ident = $1 and s.account = $2::uuid and s.deleted_at is null`, [this.name, SYSTEM_ID]);
|
|
@@ -431,6 +445,7 @@ export class Projector {
|
|
|
431
445
|
/** Транзакция под ролью владельца на отдельном соединении пула. */
|
|
432
446
|
async tx(fn) {
|
|
433
447
|
const q = await this.env.sql.reserve();
|
|
448
|
+
let failure = null;
|
|
434
449
|
try {
|
|
435
450
|
await q.unsafe('begin isolation level read committed');
|
|
436
451
|
await q.unsafe(`set local role ${ident(this.env.ownerRole)}`);
|
|
@@ -444,11 +459,13 @@ export class Projector {
|
|
|
444
459
|
return out;
|
|
445
460
|
}
|
|
446
461
|
catch (e) {
|
|
447
|
-
|
|
462
|
+
// обрыв: rollback и возврат мёртвого соединения в пул роняли процесс (план 2.0, 7.4 №7)
|
|
463
|
+
failure = e;
|
|
464
|
+
await rollbackAfter(q, e);
|
|
448
465
|
throw fromDbError(e);
|
|
449
466
|
}
|
|
450
467
|
finally {
|
|
451
|
-
|
|
468
|
+
release(q, failure);
|
|
452
469
|
}
|
|
453
470
|
}
|
|
454
471
|
async lock(q) {
|
|
@@ -544,6 +561,7 @@ export class Projector {
|
|
|
544
561
|
this.scopes = scopes;
|
|
545
562
|
this.tenants = tenants;
|
|
546
563
|
this.stale = false;
|
|
564
|
+
this.resolvedAt = Date.now();
|
|
547
565
|
}
|
|
548
566
|
/** Одна пачка журнала. */
|
|
549
567
|
async step(rep) {
|
|
@@ -552,7 +570,7 @@ export class Projector {
|
|
|
552
570
|
rep.skipped++;
|
|
553
571
|
return 'busy';
|
|
554
572
|
}
|
|
555
|
-
if (this.
|
|
573
|
+
if (this.due())
|
|
556
574
|
await this.resolve(q);
|
|
557
575
|
const meta = await this.state(q);
|
|
558
576
|
if (!meta.cursor || meta.state === 'rebuilding')
|
|
@@ -560,6 +578,12 @@ export class Projector {
|
|
|
560
578
|
if (meta.version !== this.def.version)
|
|
561
579
|
throw this.versionError(meta);
|
|
562
580
|
this.tenantsPending = Object.values(meta.retenant ?? {}).some((v) => !v.done);
|
|
581
|
+
// участники, которых чекпоинт не знает (вступили после проекции): начальную перестройку пачками начнёт tenantWork,
|
|
582
|
+
// их изменения до неё учтёт она же (план 2.0, 7.4 №5)
|
|
583
|
+
const known = new Set(meta.tenants ?? this.tenants);
|
|
584
|
+
this.newcomers = this.tenants.filter((x) => !known.has(x) && !meta.failed?.[x] && !meta.retenant?.[x]);
|
|
585
|
+
if (this.newcomers.length)
|
|
586
|
+
this.tenantsPending = true;
|
|
563
587
|
const cur = dec(meta.cursor);
|
|
564
588
|
const rows = await q.unsafe(`select l.tx::text as tx, l.seq::text as seq, ${rowJson('l')} as row
|
|
565
589
|
from ${this.env.S}.watch_read($1::text::xid8, $2::bigint, null, $3::text[], $4::int, $5::text::pg_snapshot, true) l`, [cur.tx, cur.seq, [...this.from, '#reset'], this.batch, cur.snap ?? null]);
|
|
@@ -592,6 +616,8 @@ export class Projector {
|
|
|
592
616
|
const snap = keepSnap(cur.snap, r.tx);
|
|
593
617
|
pos = { tx: r.tx, seq: r.seq, ...(snap ? { snap } : {}) };
|
|
594
618
|
const row = toRow(r.row);
|
|
619
|
+
if (this.newcomers.includes(row.tenant))
|
|
620
|
+
continue;
|
|
595
621
|
const leaves = this.leaves(row, failed);
|
|
596
622
|
if (!leaves && !this.ours(row, failed))
|
|
597
623
|
continue;
|
|
@@ -621,7 +647,8 @@ export class Projector {
|
|
|
621
647
|
await this.apply(q, changes, rep, failed);
|
|
622
648
|
}
|
|
623
649
|
const caught = rows.length < this.batch;
|
|
624
|
-
|
|
650
|
+
// состав — известные нынешние участники: выбывший из участников, вернувшись, снова новичок
|
|
651
|
+
const next = withFailed({ ...meta, cursor: enc(pos), tenants: this.tenants.filter((x) => known.has(x)) }, failed);
|
|
625
652
|
if (!rows.length) {
|
|
626
653
|
// пусто: отметить время прохода, но не чаще раза в минуту — иначе каждый опрос писал бы чекпоинт
|
|
627
654
|
if (meta.at && Date.now() - Date.parse(meta.at) < 60_000)
|
|
@@ -902,7 +929,8 @@ export class Projector {
|
|
|
902
929
|
// выбывший посреди перестройки — без производных строк: прежние пачки построили их лишь частично
|
|
903
930
|
if (Object.keys(failed).length)
|
|
904
931
|
await this.clear(q, Object.keys(failed));
|
|
905
|
-
await this.advance(q, meta.cursor, withFailed({ cursor: enc(head), at: await this.now(q), version: this.def.version, state: 'running'
|
|
932
|
+
await this.advance(q, meta.cursor, withFailed({ cursor: enc(head), at: await this.now(q), version: this.def.version, state: 'running',
|
|
933
|
+
tenants: [...this.tenants] }, failed));
|
|
906
934
|
rep.rebuilds++;
|
|
907
935
|
});
|
|
908
936
|
}
|
|
@@ -944,7 +972,7 @@ export class Projector {
|
|
|
944
972
|
const r = await this.tx(async (q) => {
|
|
945
973
|
if (!(await this.lock(q)))
|
|
946
974
|
return busy();
|
|
947
|
-
if (this.
|
|
975
|
+
if (this.due())
|
|
948
976
|
await this.resolve(q);
|
|
949
977
|
const meta = await this.state(q);
|
|
950
978
|
const rb = meta.rebuild;
|
|
@@ -962,7 +990,7 @@ export class Projector {
|
|
|
962
990
|
const real = rows.filter((r) => r.row);
|
|
963
991
|
if (!real.length) {
|
|
964
992
|
await this.advance(q, meta.cursor, withFailed({ cursor: rb.start, at: await this.now(q), version: this.def.version, state: 'running',
|
|
965
|
-
chunks: [...rb.chunks, { upto: LAST_ID, snap: rows[0].snap }] }, failed));
|
|
993
|
+
chunks: [...rb.chunks, { upto: LAST_ID, snap: rows[0].snap }], tenants: [...this.tenants] }, failed));
|
|
966
994
|
rep.rebuilds++;
|
|
967
995
|
return 'done';
|
|
968
996
|
}
|
|
@@ -1090,7 +1118,7 @@ export class Projector {
|
|
|
1090
1118
|
const resync = { ...(meta.resync ?? {}) };
|
|
1091
1119
|
delete resync[tenant];
|
|
1092
1120
|
const next = { ...meta, retenant: { ...(meta.retenant ?? {}), [tenant]: { last: null, chunks: [] } },
|
|
1093
|
-
since: { ...(meta.since ?? {}), [tenant]: now }, resync };
|
|
1121
|
+
since: { ...(meta.since ?? {}), [tenant]: now }, resync, tenants: [...new Set([...(meta.tenants ?? this.tenants), tenant])] };
|
|
1094
1122
|
if (!Object.keys(resync).length)
|
|
1095
1123
|
delete next.resync;
|
|
1096
1124
|
await this.advance(q, meta.cursor, withFailed(next, failed));
|
|
@@ -1120,7 +1148,7 @@ export class Projector {
|
|
|
1120
1148
|
rep.skipped++;
|
|
1121
1149
|
return 'busy';
|
|
1122
1150
|
}
|
|
1123
|
-
if (this.
|
|
1151
|
+
if (this.due())
|
|
1124
1152
|
await this.resolve(q);
|
|
1125
1153
|
const meta = await this.state(q);
|
|
1126
1154
|
const rt = meta.retenant?.[tenant];
|
|
@@ -1177,6 +1205,12 @@ export class Projector {
|
|
|
1177
1205
|
if (!meta.cursor || meta.state === 'rebuilding' || meta.version !== this.def.version)
|
|
1178
1206
|
return false;
|
|
1179
1207
|
let more = false;
|
|
1208
|
+
// новые участники — начальная перестройка пачками и без onExpired (план 2.0, 7.4 №5)
|
|
1209
|
+
const newcomers = this.newcomers;
|
|
1210
|
+
this.newcomers = [];
|
|
1211
|
+
for (const t of newcomers)
|
|
1212
|
+
if ((await this.startTenant(rep, t, true)) === 'ok')
|
|
1213
|
+
more = true;
|
|
1180
1214
|
if (start) {
|
|
1181
1215
|
for (const [t, f] of Object.entries(meta.failed ?? {})) {
|
|
1182
1216
|
if (f.code !== 'cursor_expired' || this.blind.has(t) || !this.scopes.has(t))
|
|
@@ -1185,7 +1219,7 @@ export class Projector {
|
|
|
1185
1219
|
more = true;
|
|
1186
1220
|
}
|
|
1187
1221
|
}
|
|
1188
|
-
const fresh = start ? await this.tx(async (q) => this.state(q)) : meta;
|
|
1222
|
+
const fresh = start || newcomers.length ? await this.tx(async (q) => this.state(q)) : meta;
|
|
1189
1223
|
for (const [t, v] of Object.entries(fresh.retenant ?? {})) {
|
|
1190
1224
|
if (v.done)
|
|
1191
1225
|
continue;
|
package/dist/signals.d.ts
CHANGED
|
@@ -57,10 +57,6 @@ export declare class Signals {
|
|
|
57
57
|
/** Разобрать нагрузку сигнала или элемент letopis.changed своей транзакции. */
|
|
58
58
|
parse(p: string): Signal;
|
|
59
59
|
}
|
|
60
|
-
/**
|
|
61
|
-
* Не чаще раза в ms: первый вызов — сразу, вызовы внутри окна — один раз в его конце. Так заливка
|
|
62
|
-
* канала сигналами (в том числе поддельными) не превращается в поток перечитываний реестра.
|
|
63
|
-
*/
|
|
64
60
|
/**
|
|
65
61
|
* Ведро жетонов: count вызовов подряд — сразу, дальше не чаще раза в ms (жетон возвращается раз в ms); вызовы
|
|
66
62
|
* в ожидании сливаются в один. Короткая серия проходит без задержки, заливка — с заданной частотой.
|
|
@@ -68,6 +64,10 @@ export declare class Signals {
|
|
|
68
64
|
export declare function bucket(fn: () => void, count: number, ms: number): (() => void) & {
|
|
69
65
|
stop(): void;
|
|
70
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* Не чаще раза в ms: первый вызов — сразу, вызовы внутри окна — один раз в его конце. Так заливка
|
|
69
|
+
* канала сигналами (в том числе поддельными) не превращается в поток перечитываний реестра.
|
|
70
|
+
*/
|
|
71
71
|
export declare function throttle(fn: () => void, ms: number): (() => void) & {
|
|
72
72
|
stop(): void;
|
|
73
73
|
};
|
package/dist/signals.js
CHANGED
|
@@ -104,10 +104,6 @@ export class Signals {
|
|
|
104
104
|
return cls ? { kind: 'class', cls, tenant } : { kind: 'unknown', tenant };
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
-
/**
|
|
108
|
-
* Не чаще раза в ms: первый вызов — сразу, вызовы внутри окна — один раз в его конце. Так заливка
|
|
109
|
-
* канала сигналами (в том числе поддельными) не превращается в поток перечитываний реестра.
|
|
110
|
-
*/
|
|
111
107
|
/**
|
|
112
108
|
* Ведро жетонов: count вызовов подряд — сразу, дальше не чаще раза в ms (жетон возвращается раз в ms); вызовы
|
|
113
109
|
* в ожидании сливаются в один. Короткая серия проходит без задержки, заливка — с заданной частотой.
|
|
@@ -140,6 +136,10 @@ export function bucket(fn, count, ms) {
|
|
|
140
136
|
},
|
|
141
137
|
});
|
|
142
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Не чаще раза в ms: первый вызов — сразу, вызовы внутри окна — один раз в его конце. Так заливка
|
|
141
|
+
* канала сигналами (в том числе поддельными) не превращается в поток перечитываний реестра.
|
|
142
|
+
*/
|
|
143
143
|
export function throttle(fn, ms) {
|
|
144
144
|
let last = -Infinity;
|
|
145
145
|
let timer = null;
|
package/dist/sql.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClassInfo, LeafType, Registry } from './registry.js';
|
|
2
|
-
import { type ChainMods, type Cursor, type Filter, type Row } from './types.js';
|
|
2
|
+
import { type ChainMods, type Cursor, type Filter, type Path, type Row } from './types.js';
|
|
3
3
|
export interface Step {
|
|
4
4
|
/** Имя, как вызвано в цепочке (класс, алиас или роль). */
|
|
5
5
|
name: string;
|
|
@@ -37,7 +37,7 @@ export interface AggSpec {
|
|
|
37
37
|
export interface BuiltQuery {
|
|
38
38
|
text: string;
|
|
39
39
|
params: unknown[];
|
|
40
|
-
/** Ключи шагов в путях (алиасы, повторы — _2, _3)
|
|
40
|
+
/** Ключи шагов в путях (алиасы, повторы — _2, _3) в порядке шагов; ключ узла последнего шага — последний. */
|
|
41
41
|
keys: string[];
|
|
42
42
|
/** Тип листа агрегата (min/max числового поля приводятся к числу). */
|
|
43
43
|
aggLeaf?: LeafType;
|
|
@@ -63,10 +63,11 @@ export interface Hop {
|
|
|
63
63
|
*/
|
|
64
64
|
export declare function resolveHop(reg: Registry, prev: Step, next: Step): Hop;
|
|
65
65
|
/**
|
|
66
|
-
* Курсор постраничного вывода для `after()`:
|
|
67
|
-
* Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`.
|
|
66
|
+
* Курсор постраничного вывода для `after()`: значение поля сортировки, id и номер версии последней строки страницы.
|
|
67
|
+
* Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`. Для пути из `paths()` — то же по его
|
|
68
|
+
* последнему узлу (узлу последнего шага) и id всех узлов: курсор отделяет пути с общим последним узлом.
|
|
68
69
|
*/
|
|
69
|
-
export declare function cursorOf(row: Row, field: string): Cursor;
|
|
70
|
+
export declare function cursorOf(row: Row | Path, field: string): Cursor;
|
|
70
71
|
export interface BuildOptions {
|
|
71
72
|
/**
|
|
72
73
|
* Режим сверки (тест find-ids-parity): вместо функций владельца find_* — те же запросы прямо
|
package/dist/sql.js
CHANGED
|
@@ -295,7 +295,7 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
295
295
|
const id = idOfValue(value) ?? fail(`конец ${key}: ожидался id, строка ответа или null`);
|
|
296
296
|
out.ends.push(id);
|
|
297
297
|
const ph = p.push(id);
|
|
298
|
-
out.frags.push((a) =>
|
|
298
|
+
out.frags.push((a) => `${SCHEMA_MARK}.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
|
|
299
299
|
continue;
|
|
300
300
|
}
|
|
301
301
|
if (isOp(value)) {
|
|
@@ -422,25 +422,60 @@ function cursorParams(c, sort) {
|
|
|
422
422
|
}
|
|
423
423
|
return { v: value, id };
|
|
424
424
|
}
|
|
425
|
+
/** rev курсора: в versions() отделяет версии одного объекта с равным полем сортировки (план 2.0, 7.1 №4). */
|
|
426
|
+
function cursorRev(c) {
|
|
427
|
+
const rev = c.rev;
|
|
428
|
+
if (rev === undefined)
|
|
429
|
+
return undefined;
|
|
430
|
+
if (!Number.isInteger(rev) || rev < 1)
|
|
431
|
+
return fail(`after(cursor): rev курсора — номер версии (целое ≥ 1), а не ${shown(rev)}`);
|
|
432
|
+
return rev;
|
|
433
|
+
}
|
|
434
|
+
/** id узлов пути из курсора paths() по ключам узлов (план 2.0, 7.1 №3); курсор без path — undefined. */
|
|
435
|
+
function cursorPath(c, keys) {
|
|
436
|
+
const path = c.path;
|
|
437
|
+
if (path === undefined)
|
|
438
|
+
return undefined;
|
|
439
|
+
return keys.map((k) => {
|
|
440
|
+
const id = typeof path === 'object' && path !== null ? path[k] : undefined;
|
|
441
|
+
if (typeof id !== 'string' || !UUID_RE.test(id))
|
|
442
|
+
return fail(`after(cursor): в курсоре пути нет id узла ${k} — нужен cursorOf(путь, поле) из paths() этой же цепочки`);
|
|
443
|
+
return id;
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const isPath = (x) => typeof x.id !== 'string'
|
|
447
|
+
&& Object.values(x).length > 0 && Object.values(x).every((n) => typeof n === 'object' && n !== null && typeof n.id === 'string');
|
|
425
448
|
/**
|
|
426
|
-
* Курсор постраничного вывода для `after()`:
|
|
427
|
-
* Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`.
|
|
449
|
+
* Курсор постраничного вывода для `after()`: значение поля сортировки, id и номер версии последней строки страницы.
|
|
450
|
+
* Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`. Для пути из `paths()` — то же по его
|
|
451
|
+
* последнему узлу (узлу последнего шага) и id всех узлов: курсор отделяет пути с общим последним узлом.
|
|
428
452
|
*/
|
|
429
453
|
export function cursorOf(row, field) {
|
|
454
|
+
if (isPath(row)) {
|
|
455
|
+
const keys = Object.keys(row);
|
|
456
|
+
const { v, id } = cursorOf(row[keys[keys.length - 1]], field);
|
|
457
|
+
return { v, id, path: Object.fromEntries(keys.map((k) => [k, row[k].id])) };
|
|
458
|
+
}
|
|
430
459
|
const f = field === 'updated' ? 'at' : field;
|
|
460
|
+
const rev = typeof row.rev === 'number' ? { rev: row.rev } : {};
|
|
431
461
|
if (f === 'at' || f === 'rev' || f === 'id')
|
|
432
|
-
return { v: row[f], id: row.id };
|
|
462
|
+
return { v: row[f], id: row.id, ...rev };
|
|
433
463
|
if (f.startsWith('data.')) {
|
|
434
464
|
let v = row.data;
|
|
435
465
|
for (const k of f.slice(5).split('.'))
|
|
436
466
|
v = v?.[k];
|
|
437
|
-
return { v: (v ?? null), id: row.id };
|
|
467
|
+
return { v: (v ?? null), id: row.id, ...rev };
|
|
438
468
|
}
|
|
439
469
|
return fail(`cursorOf: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${field}'`);
|
|
440
470
|
}
|
|
441
471
|
// ---------------------------------------------------------------------------
|
|
442
472
|
// Построение запроса
|
|
443
473
|
// ---------------------------------------------------------------------------
|
|
474
|
+
/**
|
|
475
|
+
* Метка схемы во фрагментах, собранных до имени схемы: символ NUL не бывает в тексте PostgreSQL, поэтому замена не
|
|
476
|
+
* заденет литералы — имя поля вида a@S@b прежде портилось (план 2.0, 7.1 №5).
|
|
477
|
+
*/
|
|
478
|
+
const SCHEMA_MARK = '\u0000S\u0000';
|
|
444
479
|
const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason', 'moved'];
|
|
445
480
|
/** Колонки журнала, выданные приложению: все, кроме changed (ревью №1 точки А плана 1.1, 10-core.sql). */
|
|
446
481
|
const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason',
|
|
@@ -526,7 +561,8 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
526
561
|
return `(select v.id, v.rev from ${S}.entity v where v.class = any (${clsPh})${dataCond('v')}${endsCond('v.ends')}`
|
|
527
562
|
+ `${ids ? ` and v.id = any (${ids})` : ''})`;
|
|
528
563
|
}
|
|
529
|
-
|
|
564
|
+
// по классу: id-кандидаты классов шага, последняя версия — любого класса (как find_ids, план 2.0, 7.1 №1)
|
|
565
|
+
return `(select v.id, v.rev from ${versionsAt(at, ids ? `l.id = any (${ids})` : `l.id in (select c.id from ${S}.log c where c.class = any (${clsPh}))`)} v `
|
|
530
566
|
+ `where v.class = any (${clsPh}) and (v.op = 'delete') = ${isDead} and v.op not in ('purge', 'trim', 'reset')${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)})`;
|
|
531
567
|
};
|
|
532
568
|
const entityPart = (src, depth = 'null::int', from = `${S}.entity x`) => `select ${colsOf('x')}, ${depth} as depth from ${from} where ${where('x', src)}`;
|
|
@@ -652,32 +688,62 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
652
688
|
const leaf = (path) => leafOf(reg, rowsNode(lastNode), path);
|
|
653
689
|
const sort = mods.order ? sortSpec(mods.order, leaf) : undefined;
|
|
654
690
|
const dir = mods.desc ? ' desc' : '';
|
|
655
|
-
|
|
656
|
-
const
|
|
691
|
+
/** Порядок: поле sort, id строки и разделители равных (id прочих узлов пути). */
|
|
692
|
+
const orderBy = (a, more = []) => (sort ? ` order by ${[sort.expr(a), `${a}.id`, ...more].map((x) => `${x}${dir}`).join(', ')}` : '');
|
|
693
|
+
/**
|
|
694
|
+
* «Строго после курсора» в порядке order by: поле sort, id строки и разделители равных из курсора — rev версии
|
|
695
|
+
* (versions()) или id прочих узлов пути (paths()). Без них страница начиналась бы после всех строк с тем же полем
|
|
696
|
+
* и id: терялись бы другие версии объекта и другие пути к тому же узлу. Курсор без rev или path сравнивается по
|
|
697
|
+
* началу порядка, как прежде.
|
|
698
|
+
*/
|
|
699
|
+
const after = (a, ties = () => []) => {
|
|
657
700
|
if (!mods.after)
|
|
658
701
|
return undefined;
|
|
659
702
|
if (!sort)
|
|
660
703
|
fail('after(cursor) требует sort(поле)');
|
|
661
704
|
const c = cursorParams(mods.after, sort);
|
|
662
|
-
|
|
663
|
-
|
|
705
|
+
const t = ties(mods.after);
|
|
706
|
+
const lt = mods.desc ? '<' : '>';
|
|
664
707
|
// Поле данных бывает null (или его нет): order by ставит такие строки в конец при возрастании и в
|
|
665
708
|
// начало при убывании, а сравнение строк с null даёт null — одно сравнение их теряло, курсор null
|
|
666
709
|
// давал пустую страницу. Здесь null — значение после всех прочих (asc) или перед ними (desc); текст
|
|
667
710
|
// один на оба вида курсора (параметр — с явным приведением: он стоит и в is null).
|
|
711
|
+
const v = `${p.push(c.v)}${sort.nullable ? sort.pcast || '::text' : sort.pcast}`;
|
|
712
|
+
const cols = [`${a}.id`, ...t.map((x) => x.col)];
|
|
713
|
+
const vals = [`${p.push(c.id)}::uuid`, ...t.map((x) => `${p.push(x.val)}${x.cast}`)];
|
|
714
|
+
if (!sort.nullable)
|
|
715
|
+
return `(${sort.expr(a)}, ${cols.join(', ')}) ${lt} (${v}, ${vals.join(', ')})`;
|
|
668
716
|
const e = `(${sort.expr(a)})`;
|
|
669
|
-
const
|
|
670
|
-
const id = `${p.push(c.id)}::uuid`;
|
|
717
|
+
const rest = cols.length === 1 ? `${cols[0]} ${lt} ${vals[0]}` : `(${cols.join(', ')}) ${lt} (${vals.join(', ')})`;
|
|
671
718
|
return mods.desc
|
|
672
|
-
? `((${e}, ${
|
|
673
|
-
: `((${e}, ${
|
|
719
|
+
? `((${e}, ${cols.join(', ')}) < (${v}, ${vals.join(', ')}) or (${v} is null and (${e} is not null or ${rest})))`
|
|
720
|
+
: `((${e}, ${cols.join(', ')}) > (${v}, ${vals.join(', ')}) or (${e} is null and (${v} is not null or ${rest})))`;
|
|
674
721
|
};
|
|
722
|
+
// ключи пути — в порядке шагов, узел последнего шага (его строки отдают rows()) — последним: по нему cursorOf(путь)
|
|
723
|
+
// берёт поле и id курсора, по прочим — разделители путей к одному узлу (план 2.0, 7.1 №3)
|
|
724
|
+
const others = keyed.filter((k) => k.node !== lastNode);
|
|
725
|
+
const pathKeys = [...others, ...keyed.filter((k) => k.node === lastNode)].map((k) => k.key);
|
|
675
726
|
const limitOffset = () => (mods.limit !== undefined ? ` limit ${p.push(mods.limit)}::bigint` : '') + (mods.offset !== undefined ? ` offset ${p.push(mods.offset)}::bigint` : '');
|
|
676
|
-
// versions({ follow: true }):
|
|
727
|
+
// versions({ follow: true }): отрезки истории объектов шага по переносам id (reclass, rekey) в обе стороны — версии
|
|
728
|
+
// id с номерами (lo, hi]. Надгробие переноса кончает отрезок, первая версия на новом id начинает: объект, заведённый
|
|
729
|
+
// на прежнем id после переноса, в историю не попадает (план 2.0, 7.1 №2). Тот же обход — history_segments в
|
|
730
|
+
// lib/sql/75-history.sql (diff и blame); здесь он в самом запросе, чтобы работал и на схеме до up() этой версии.
|
|
677
731
|
if (mode === 'versions' && mods.follow) {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
732
|
+
// границы отрезка с версией r: строки переноса id — через парную строку по индексу log_moved (moved друг на друга),
|
|
733
|
+
// без прохода по версиям id
|
|
734
|
+
const bounds = (i, r) => `cross join lateral (select greatest(coalesce(max(x.rev) filter (where x.op = 'delete' and x.rev < ${r}), 0), `
|
|
735
|
+
+ `coalesce(max(x.rev - 1) filter (where x.op <> 'delete' and x.rev <= ${r}), 0)) as lo, `
|
|
736
|
+
+ `least(min(x.rev) filter (where x.op = 'delete' and x.rev >= ${r}), min(x.rev - 1) filter (where x.op <> 'delete' and x.rev > ${r})) as hi `
|
|
737
|
+
+ `from ${S}.log m join ${S}.log x on x.moved = m.id and x.id = ${i} where m.moved = ${i}) b`;
|
|
738
|
+
ctes.push(`f(id, lo, hi, n) as (select z.id, b.lo, b.hi, 0 from ${ents} z ${bounds('z.id', 'z.rev')} `
|
|
739
|
+
+ `union select g.id, b.lo, b.hi, g.n from f cross join lateral (`
|
|
740
|
+
+ `select a.moved as id, (select t.rev from ${S}.log t where t.id = a.moved and t.op = 'delete' and t.moved = f.id `
|
|
741
|
+
+ `order by abs(t.seq - a.seq) limit 1) as r, f.n - 1 as n from ${S}.log a `
|
|
742
|
+
+ `where f.n <= 0 and a.id = f.id and a.rev = f.lo + 1 and a.moved is not null and a.op <> 'delete' `
|
|
743
|
+
+ `union all select t.moved, (select a.rev from ${S}.log a where a.id = t.moved and a.op <> 'delete' and a.moved = f.id `
|
|
744
|
+
+ `order by abs(a.seq - t.seq) limit 1), f.n + 1 from ${S}.log t `
|
|
745
|
+
+ `where f.n >= 0 and t.id = f.id and t.rev = f.hi and t.op = 'delete' and t.moved is not null) g ${bounds('g.id', 'g.r')} `
|
|
746
|
+
+ `where g.r is not null and abs(g.n) <= 1000)`);
|
|
681
747
|
}
|
|
682
748
|
const withClause = `with ${mode === 'versions' && mods.follow ? 'recursive ' : ''}${ctes.join(',\n')}\n`;
|
|
683
749
|
let text;
|
|
@@ -686,7 +752,12 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
686
752
|
case 'paths': {
|
|
687
753
|
const obj = keyed.map(({ key, node }) => `${lit(key)}::text, to_jsonb(h${node})`).join(', ');
|
|
688
754
|
const h = `h${lastNode}`;
|
|
689
|
-
|
|
755
|
+
const ties = (c) => {
|
|
756
|
+
const ids = cursorPath(c, others.map((k) => k.key));
|
|
757
|
+
return ids ? others.map((k, j) => ({ col: `h${k.node}.id`, val: ids[j], cast: '::uuid' })) : [];
|
|
758
|
+
};
|
|
759
|
+
text = `${withClause}select jsonb_build_object(${obj}) as path ${from}${whereOf([...tail, after(h, ties)])}`
|
|
760
|
+
+ `${orderBy(h, others.map((k) => `h${k.node}.id`))}${limitOffset()}`;
|
|
690
761
|
break;
|
|
691
762
|
}
|
|
692
763
|
case 'rows':
|
|
@@ -703,11 +774,17 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
703
774
|
// с sort — по полю и направлению (последние версии первыми — sort('at', 'desc'), с limit — в самом запросе);
|
|
704
775
|
// без sort: с follow — по времени через все связанные id, без follow — по id и номеру версии
|
|
705
776
|
const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` : mods.follow ? 'v.at, v.id, v.rev' : 'v.id, v.rev';
|
|
777
|
+
// курсор — поле sort, id и номер версии: версии одного объекта с равным полем не теряются (план 2.0, 7.1 №4)
|
|
778
|
+
const cond = whereOf([after('v', (c) => {
|
|
779
|
+
const rev = cursorRev(c);
|
|
780
|
+
return rev === undefined ? [] : [{ col: 'v.rev', val: rev, cast: '::int' }];
|
|
781
|
+
})]);
|
|
706
782
|
text = mods.follow
|
|
707
|
-
? `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from
|
|
708
|
-
+ `where l.id
|
|
783
|
+
? `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from (select distinct f.id, f.lo, f.hi from f) s `
|
|
784
|
+
+ `cross join lateral (select ${colsOf('l')} from ${S}.log l where l.id = s.id and l.rev > s.lo and l.rev <= coalesce(s.hi, 2147483647)) l) v`
|
|
785
|
+
+ `${cond} order by ${order}${limitOffset()}`
|
|
709
786
|
: `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
|
|
710
|
-
+ `where l.id in (select z.id from ${ents} z)) v order by ${order}${limitOffset()}`;
|
|
787
|
+
+ `where l.id in (select z.id from ${ents} z)) v${cond} order by ${order}${limitOffset()}`;
|
|
711
788
|
break;
|
|
712
789
|
}
|
|
713
790
|
case 'diff':
|
|
@@ -741,7 +818,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
741
818
|
break;
|
|
742
819
|
}
|
|
743
820
|
}
|
|
744
|
-
return { text: text.replaceAll(
|
|
821
|
+
return { text: text.replaceAll(SCHEMA_MARK, S), params: p.list, keys: pathKeys, aggLeaf };
|
|
745
822
|
}
|
|
746
823
|
/** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
|
|
747
824
|
export function toRow(raw) {
|
package/dist/sync.js
CHANGED
|
@@ -77,8 +77,12 @@ export function schemaToTs(s, indent = '') {
|
|
|
77
77
|
return JSON.stringify(s.const);
|
|
78
78
|
if (Array.isArray(s.enum))
|
|
79
79
|
return s.enum.map((v) => JSON.stringify(v)).join(' | ');
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
const branches = (s.anyOf ?? s.oneOf);
|
|
81
|
+
// ветки из одних required у объекта с полями — условие, а не форма: тип — сам объект (план 2.0, 7.5 №1)
|
|
82
|
+
const onlyRequired = Array.isArray(branches) && s.properties !== undefined
|
|
83
|
+
&& branches.every((b) => Object.keys(b).every((k) => k === 'required'));
|
|
84
|
+
if (Array.isArray(branches) && !onlyRequired)
|
|
85
|
+
return branches.map((b) => schemaToTs(b, indent)).join(' | ');
|
|
82
86
|
const types = Array.isArray(s.type) ? s.type : [s.type];
|
|
83
87
|
return types.map((t) => {
|
|
84
88
|
switch (t) {
|
|
@@ -110,12 +114,15 @@ export function generateTypes(registry, opts = {}) {
|
|
|
110
114
|
for (const c of registry.all().sort((a, b) => a.name.localeCompare(b.name))) {
|
|
111
115
|
if (!opts.includeSystem && c.tenant === '2eba6d0a-1edd-4bb6-a85a-a703dab49035')
|
|
112
116
|
continue;
|
|
113
|
-
|
|
117
|
+
// идентификатор TypeScript — буквы Unicode, цифры, _ и $: кириллические имена классов допустимы (план 2.0, 7.5 №1)
|
|
118
|
+
if (!/^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(c.name))
|
|
114
119
|
throw new LetopisError('invalid_class', `types: имя класса ${c.name} не годится для идентификатора TypeScript`);
|
|
115
120
|
// additionalProperties: false верхнего уровня в тип не переносится — лишние ключи и так ошибка типов
|
|
116
121
|
const { additionalProperties: _closed, ...open } = c.schema;
|
|
117
122
|
void _closed;
|
|
118
|
-
|
|
123
|
+
// объектный литерал — интерфейс; класс без полей, union и открытая схема — псевдоним типа (иначе неверный TypeScript)
|
|
124
|
+
const data = schemaToTs(open);
|
|
125
|
+
out.push(/^\{[\s\S]*\}$/.test(data) && !/\}\s*&/.test(data) ? `export interface ${c.name}Data ${data}` : `export type ${c.name}Data = ${data};`);
|
|
119
126
|
const ends = Object.entries(c.ends).map(([role, e]) => ` ${JSON.stringify(role)}${e.required || (e.min ?? 0) > 0 ? '' : '?'}: ${e.many ? 'string[]' : 'string'};`);
|
|
120
127
|
out.push(`export interface ${c.name}Links {${ends.length ? `\n${ends.join('\n')}\n` : ''}}`, '');
|
|
121
128
|
}
|
package/dist/tx.d.ts
CHANGED
|
@@ -31,6 +31,21 @@ export declare function isTransient(e: unknown): boolean;
|
|
|
31
31
|
export declare function isLogConflict(e: unknown): boolean;
|
|
32
32
|
/** Обрыв связи: сокет закрыт или серверный процесс завершён (FATAL) — исход отправленного неизвестен. */
|
|
33
33
|
export declare function isConnectionError(e: unknown): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Откатить транзакцию на зарезервированном соединении после ошибки: после обрыва в него не пишут (postgres.js 3.4
|
|
36
|
+
* уронил бы процесс необработанным TypeError), иначе — rollback, ошибка которого не важна (план 2.0, 7.4 №7).
|
|
37
|
+
*/
|
|
38
|
+
export declare function rollbackAfter(conn: {
|
|
39
|
+
unsafe(text: string): Promise<unknown>;
|
|
40
|
+
}, err: unknown): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Вернуть зарезервированное соединение в пул. После обрыва сокета пул postgres.js уже перевёл
|
|
43
|
+
* соединение в закрытые (onclose); release() вернул бы его в открытые, и следующая операция
|
|
44
|
+
* писала бы в сокет null — необработанный TypeError роняет процесс.
|
|
45
|
+
*/
|
|
46
|
+
export declare function release(conn: {
|
|
47
|
+
release(): void;
|
|
48
|
+
}, err: unknown): void;
|
|
34
49
|
export declare const RETRIES = 3;
|
|
35
50
|
export declare const retryDelay: (attempt: number) => Promise<void>;
|
|
36
51
|
/** Нотификатор процесса: копит нагрузки сигналов и отправляет их пачкой раз в интервал. */
|
package/dist/tx.js
CHANGED
|
@@ -17,12 +17,21 @@ export function isConnectionError(e) {
|
|
|
17
17
|
const code = String(err?.code ?? '');
|
|
18
18
|
return code.startsWith('CONNECTION_') || ['ECONNRESET', 'EPIPE', 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNABORTED'].includes(code);
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Откатить транзакцию на зарезервированном соединении после ошибки: после обрыва в него не пишут (postgres.js 3.4
|
|
22
|
+
* уронил бы процесс необработанным TypeError), иначе — rollback, ошибка которого не важна (план 2.0, 7.4 №7).
|
|
23
|
+
*/
|
|
24
|
+
export async function rollbackAfter(conn, err) {
|
|
25
|
+
if (isConnectionError(err))
|
|
26
|
+
return;
|
|
27
|
+
await conn.unsafe('rollback').catch(() => { });
|
|
28
|
+
}
|
|
20
29
|
/**
|
|
21
30
|
* Вернуть зарезервированное соединение в пул. После обрыва сокета пул postgres.js уже перевёл
|
|
22
31
|
* соединение в закрытые (onclose); release() вернул бы его в открытые, и следующая операция
|
|
23
32
|
* писала бы в сокет null — необработанный TypeError роняет процесс.
|
|
24
33
|
*/
|
|
25
|
-
function release(conn, err) {
|
|
34
|
+
export function release(conn, err) {
|
|
26
35
|
const e = err;
|
|
27
36
|
if (e && !e.severity && isConnectionError(e))
|
|
28
37
|
return;
|
|
@@ -233,8 +242,8 @@ export class ExplicitTx {
|
|
|
233
242
|
ctx.passes.set(ctx, (await passSql(conn, ctx))[0]?.p);
|
|
234
243
|
}
|
|
235
244
|
catch (e) {
|
|
236
|
-
await conn
|
|
237
|
-
|
|
245
|
+
await rollbackAfter(conn, e);
|
|
246
|
+
release(conn, e);
|
|
238
247
|
throw fromDbError(e);
|
|
239
248
|
}
|
|
240
249
|
return new ExplicitTx(ctx, conn);
|
package/dist/types.d.ts
CHANGED
|
@@ -85,10 +85,17 @@ export type Filter = string | string[] | Op | {
|
|
|
85
85
|
} | {
|
|
86
86
|
[field: string]: FieldFilter | undefined;
|
|
87
87
|
};
|
|
88
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Курсор постраничного вывода для `after()`: значение поля сортировки и id последней строки страницы. Равные по полю
|
|
90
|
+
* строки разводят `rev` (версии в `versions()`) и `path` (id всех узлов пути в `paths()`); `cursorOf` заполняет их сам.
|
|
91
|
+
*/
|
|
89
92
|
export interface Cursor {
|
|
90
93
|
v: string | number | boolean | null;
|
|
91
94
|
id: string;
|
|
95
|
+
/** Номер версии строки: в `versions()` отделяет версии одного объекта с равным полем сортировки. */
|
|
96
|
+
rev?: number;
|
|
97
|
+
/** Узлы пути из `paths()`: ключ узла → id; отделяет пути с общим последним узлом. */
|
|
98
|
+
path?: Record<string, string>;
|
|
92
99
|
}
|
|
93
100
|
/** Модификаторы выборки цепочки. */
|
|
94
101
|
export interface ChainMods {
|
package/dist/up.js
CHANGED
|
@@ -147,6 +147,11 @@ export async function install(sql, opts) {
|
|
|
147
147
|
// точку сохранения, а не всю транзакцию.
|
|
148
148
|
await tx.savepoint((sp) => sp.unsafe(`grant ${quoteIdent(owner)} to current_user with inherit false, set true`))
|
|
149
149
|
.catch((e) => {
|
|
150
|
+
// роль владельца создал кто-то другой, а у установщика нет на неё ADMIN — понятная ошибка вместо сырой 42501
|
|
151
|
+
// (план 2.0, 7.5 №3)
|
|
152
|
+
if (e.code === '42501') {
|
|
153
|
+
throw Object.assign(new Error(`letopis.up: роль ${owner} уже есть, а у установщика нет на неё ADMIN OPTION — установку ведёт роль, создавшая ${owner}, или роль с этим правом (grant ${owner} to <установщик> with admin option)`), { cause: e });
|
|
154
|
+
}
|
|
150
155
|
if (e.code !== '0LP01' && e.code !== '23505')
|
|
151
156
|
throw e; // уже выдано циклом — нет; 23505 — выдал up() другой базы
|
|
152
157
|
});
|
package/dist/watch.d.ts
CHANGED
|
@@ -31,10 +31,14 @@ 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 —
|
|
@@ -52,6 +56,14 @@ 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;
|
|
61
|
+
/**
|
|
62
|
+
* from: 'start' — пока подписка не догнала журнал, свои чтения она шлёт без времени курсора: весь журнал, без
|
|
63
|
+
* cursor_expired на старых событиях (план 2.0, 7.4 №3). Курсоры событий и sub.cursor время хранят: продолжение с них
|
|
64
|
+
* после прореживания — cursor_expired, как обещано.
|
|
65
|
+
*/
|
|
66
|
+
private catchingUp;
|
|
55
67
|
private off;
|
|
56
68
|
private timer;
|
|
57
69
|
private failure;
|