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/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.stale)
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
- await q.unsafe('rollback').catch(() => { });
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
- q.release();
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.stale)
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
- const next = withFailed({ ...meta, cursor: enc(pos) }, failed);
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' }, failed));
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.stale)
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.stale)
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()`: `{ v, id }` — значение поля сортировки и id последней строки страницы.
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) => `@S@.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
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()`: `{ v, id }` — значение поля сортировки и id последней строки страницы.
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
- return `(select v.id, v.rev from ${versionsAt(at, ids ? `l.id = any (${ids})` : `l.class = any (${clsPh})`)} v `
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
- const orderBy = (a) => (sort ? ` order by ${sort.expr(a)}${dir}, ${a}.id${dir}` : '');
656
- const after = (a) => {
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
- if (!sort.nullable)
663
- return `(${sort.expr(a)}, ${a}.id) ${mods.desc ? '<' : '>'} (${p.push(c.v)}${sort.pcast}, ${p.push(c.id)}::uuid)`;
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 v = `${p.push(c.v)}${sort.pcast || '::text'}`;
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}, ${a}.id) < (${v}, ${id}) or (${v} is null and (${e} is not null or ${a}.id < ${id})))`
673
- : `((${e}, ${a}.id) > (${v}, ${id}) or (${e} is null and (${v} is not null or ${a}.id > ${id})))`;
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 }): id, связанные колонкой moved (reclass, rekey), в обе стороны
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
- ctes.push(`f(id) as (select z.id from ${ents} z union select x.id from f cross join lateral (`
679
- + `select l.moved as id from ${S}.log l where l.id = f.id and l.moved is not null `
680
- + `union select l.id from ${S}.log l where l.moved = f.id) x)`);
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
- text = `${withClause}select jsonb_build_object(${obj}) as path ${from}${whereOf([...tail, after(h)])}${orderBy(h)}${limitOffset()}`;
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 ${S}.log l `
708
- + `where l.id in (select f.id from f)) v order by ${order}${limitOffset()}`
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('@S@', S), params: p.list, keys: keyed.map((k) => k.key), aggLeaf };
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
- if (Array.isArray(s.anyOf) || Array.isArray(s.oneOf))
81
- return (s.anyOf ?? s.oneOf).map((b) => schemaToTs(b, indent)).join(' | ');
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
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(c.name))
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
- out.push(`export interface ${c.name}Data ${schemaToTs(open)}`);
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.unsafe('rollback').catch(() => { });
237
- conn.release();
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
- /** Курсор постраничного вывода для `after()`: значение поля сортировки и id последней строки страницы. */
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;