letopis 1.4.0 → 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.
Files changed (62) hide show
  1. package/AGENT-CHEATSHEET.en.md +15 -7
  2. package/AGENT-CHEATSHEET.md +19 -11
  3. package/CHANGELOG.md +299 -1
  4. package/README.en.md +180 -62
  5. package/README.md +165 -55
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +53 -18
  9. package/dist/auth.d.ts +61 -17
  10. package/dist/auth.js +16 -2
  11. package/dist/cache.d.ts +5 -1
  12. package/dist/cache.js +3 -2
  13. package/dist/chain.d.ts +92 -15
  14. package/dist/chain.js +56 -7
  15. package/dist/cli.js +16 -2
  16. package/dist/errors.d.ts +2 -2
  17. package/dist/errors.js +5 -5
  18. package/dist/import.js +7 -0
  19. package/dist/index.d.ts +73 -45
  20. package/dist/index.js +69 -14
  21. package/dist/internal.d.ts +7 -0
  22. package/dist/internal.js +5 -0
  23. package/dist/load.d.ts +10 -3
  24. package/dist/load.js +39 -11
  25. package/dist/mcp.d.ts +20 -11
  26. package/dist/mcp.js +8 -6
  27. package/dist/model.d.ts +4 -1
  28. package/dist/model.js +7 -1
  29. package/dist/ops.d.ts +58 -18
  30. package/dist/ops.js +58 -18
  31. package/dist/project.d.ts +39 -11
  32. package/dist/project.js +75 -15
  33. package/dist/registry.d.ts +4 -1
  34. package/dist/registry.js +4 -1
  35. package/dist/signals.d.ts +4 -4
  36. package/dist/signals.js +4 -4
  37. package/dist/sql.d.ts +8 -4
  38. package/dist/sql.js +110 -25
  39. package/dist/sync.js +11 -4
  40. package/dist/tx.d.ts +15 -0
  41. package/dist/tx.js +22 -4
  42. package/dist/types.d.ts +21 -7
  43. package/dist/types.js +13 -6
  44. package/dist/up.d.ts +48 -25
  45. package/dist/up.js +94 -41
  46. package/dist/validate.d.ts +9 -2
  47. package/dist/validate.js +19 -3
  48. package/dist/watch.d.ts +29 -5
  49. package/dist/watch.js +62 -13
  50. package/dist/write.d.ts +3 -2
  51. package/dist/write.js +52 -19
  52. package/package.json +8 -4
  53. package/sql/10-core.sql +6 -1
  54. package/sql/60-write.sql +47 -18
  55. package/sql/70-read.sql +13 -7
  56. package/sql/75-history.sql +106 -28
  57. package/sql/80-auth.sql +41 -3
  58. package/sql/88-derived.sql +3 -1
  59. package/sql/90-time.sql +118 -30
  60. package/sql/95-seed.system.sql +6 -3
  61. package/sql/99-revision.sql +1 -1
  62. package/sql/migrate/0007-secret-account-fk.sql +12 -0
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';
@@ -67,6 +68,10 @@ function change(row, cursor, rebuild = false) {
67
68
  * значениями (22…) и пределами размера (54…). Сбой окружения (блокировки, отмена запроса, связь) — не причина
68
69
  * выводить арендатора: пачка откатывается целиком, повтор начнётся с того же курсора.
69
70
  */
71
+ /** «1 прохода», «2 проходов», «21 прохода» — число проходов в родительном падеже («в течение …»). */
72
+ function passesGen(n) {
73
+ return `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'прохода' : 'проходов'}`;
74
+ }
70
75
  /** Ожидание чужой блокировки строки дольше lock_timeout (55P03): сбой окружения, проход повторяется. */
71
76
  function lockWait(e) {
72
77
  return e?.code === '55P03';
@@ -161,17 +166,32 @@ class Out {
161
166
  if (key !== null)
162
167
  this.folds.set(key, d);
163
168
  }
169
+ /**
170
+ * Строка для delete и get. У класса с ключом её называют полями ключа: id такой строки — v5 от ключа, и по одному id
171
+ * (строкой или { $id }) база искала бы строку с пустым ключом — get отвечал бы null, а delete выводил арендатора с
172
+ * id_mismatch. Это ошибка кода handle, а не данных: invalid_data сразу, пачка откатывается целиком.
173
+ */
174
+ target(cls, tenant, keyOrId) {
175
+ const row = this.rowOf(keyOrId);
176
+ const key = this.p.keyOf(tenant, cls);
177
+ if (key.length && Object.keys(row).every((n) => n === '$id')) {
178
+ throw new LetopisError('invalid_data', `проектор ${this.p.name}: у класса ${cls} есть ключ (${key.join(', ')}) — строку называют полями ключа, например { ${key.map((k) => `${k}: …`).join(', ')} }, а не id`);
179
+ }
180
+ return row;
181
+ }
164
182
  delete(cls, keyOrId, opts) {
165
183
  const tenant = this.check(cls, opts?.tenant);
184
+ const row = this.target(cls, tenant, keyOrId);
166
185
  this.folds.clear();
167
- this.ops.push({ op: 'delete', class: cls, tenant, row: this.rowOf(keyOrId) });
186
+ this.ops.push({ op: 'delete', class: cls, tenant, row });
168
187
  }
169
188
  async get(cls, keyOrId, opts) {
170
189
  const tenant = this.check(cls, opts?.tenant);
190
+ const row = this.target(cls, tenant, keyOrId);
171
191
  await this.flush();
172
192
  const S = this.p.env.S;
173
193
  const [r] = await this.db(() => this.q.unsafe(`select ${rowJson('e')} as row from ${S}.entity e
174
- where e.id = ${S}.derived_id($1, $2::uuid, $3::text::jsonb) and e.class = $1 and e.tenant = $2::uuid`, [cls, tenant, JSON.stringify(this.rowOf(keyOrId))]));
194
+ where e.id = ${S}.derived_id($1, $2::uuid, $3::text::jsonb) and e.class = $1 and e.tenant = $2::uuid`, [cls, tenant, JSON.stringify(row)]));
175
195
  return r ? toRow(r.row) : null;
176
196
  }
177
197
  async flush() {
@@ -202,6 +222,10 @@ export class Projector {
202
222
  /** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
203
223
  blind = new Map();
204
224
  stale = true;
225
+ /** Когда состав читался в последний раз: без LISTEN сигнала о новых классах нет (план 2.0, 7.4 №6). */
226
+ resolvedAt = 0;
227
+ /** Участники, которых чекпоинт ещё не знает: им начальная перестройка пачками (tenantWork). */
228
+ newcomers = [];
205
229
  /** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
206
230
  tenantsPending = true;
207
231
  running = false;
@@ -215,6 +239,13 @@ export class Projector {
215
239
  /** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
216
240
  lockHits = new Map();
217
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
+ }
218
249
  constructor(env, name, def) {
219
250
  this.env = env;
220
251
  this.def = def;
@@ -267,6 +298,8 @@ export class Projector {
267
298
  throw new LetopisError('invalid_query', `проектор ${this.name} уже запущен в этом процессе`);
268
299
  this.running = true;
269
300
  this.stopped = false;
301
+ if (!this.env.onSignal)
302
+ this.stale = true;
270
303
  const rep = emptyReport();
271
304
  try {
272
305
  for (;;) {
@@ -371,7 +404,7 @@ export class Projector {
371
404
  }
372
405
  async status() {
373
406
  return this.tx(async (q) => {
374
- if (this.stale)
407
+ if (this.due())
375
408
  await this.resolve(q);
376
409
  const [r] = await q.unsafe(`select s.meta from ${this.env.S}.secret s
377
410
  where s.kind = 'PROJECTOR' and s.ident = $1 and s.account = $2::uuid and s.deleted_at is null`, [this.name, SYSTEM_ID]);
@@ -412,6 +445,7 @@ export class Projector {
412
445
  /** Транзакция под ролью владельца на отдельном соединении пула. */
413
446
  async tx(fn) {
414
447
  const q = await this.env.sql.reserve();
448
+ let failure = null;
415
449
  try {
416
450
  await q.unsafe('begin isolation level read committed');
417
451
  await q.unsafe(`set local role ${ident(this.env.ownerRole)}`);
@@ -425,11 +459,13 @@ export class Projector {
425
459
  return out;
426
460
  }
427
461
  catch (e) {
428
- await q.unsafe('rollback').catch(() => { });
462
+ // обрыв: rollback и возврат мёртвого соединения в пул роняли процесс (план 2.0, 7.4 №7)
463
+ failure = e;
464
+ await rollbackAfter(q, e);
429
465
  throw fromDbError(e);
430
466
  }
431
467
  finally {
432
- q.release();
468
+ release(q, failure);
433
469
  }
434
470
  }
435
471
  async lock(q) {
@@ -525,6 +561,7 @@ export class Projector {
525
561
  this.scopes = scopes;
526
562
  this.tenants = tenants;
527
563
  this.stale = false;
564
+ this.resolvedAt = Date.now();
528
565
  }
529
566
  /** Одна пачка журнала. */
530
567
  async step(rep) {
@@ -533,7 +570,7 @@ export class Projector {
533
570
  rep.skipped++;
534
571
  return 'busy';
535
572
  }
536
- if (this.stale)
573
+ if (this.due())
537
574
  await this.resolve(q);
538
575
  const meta = await this.state(q);
539
576
  if (!meta.cursor || meta.state === 'rebuilding')
@@ -541,6 +578,12 @@ export class Projector {
541
578
  if (meta.version !== this.def.version)
542
579
  throw this.versionError(meta);
543
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;
544
587
  const cur = dec(meta.cursor);
545
588
  const rows = await q.unsafe(`select l.tx::text as tx, l.seq::text as seq, ${rowJson('l')} as row
546
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]);
@@ -573,6 +616,8 @@ export class Projector {
573
616
  const snap = keepSnap(cur.snap, r.tx);
574
617
  pos = { tx: r.tx, seq: r.seq, ...(snap ? { snap } : {}) };
575
618
  const row = toRow(r.row);
619
+ if (this.newcomers.includes(row.tenant))
620
+ continue;
576
621
  const leaves = this.leaves(row, failed);
577
622
  if (!leaves && !this.ours(row, failed))
578
623
  continue;
@@ -602,7 +647,8 @@ export class Projector {
602
647
  await this.apply(q, changes, rep, failed);
603
648
  }
604
649
  const caught = rows.length < this.batch;
605
- 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);
606
652
  if (!rows.length) {
607
653
  // пусто: отметить время прохода, но не чаще раза в минуту — иначе каждый опрос писал бы чекпоинт
608
654
  if (meta.at && Date.now() - Date.parse(meta.at) < 60_000)
@@ -751,7 +797,7 @@ export class Projector {
751
797
  failed[tenant] = {
752
798
  cursor: list[0].cursor || start,
753
799
  code: '55P03',
754
- message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — ${hits} проходов подряд дольше lockTimeout`,
800
+ message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — в течение ${passesGen(hits)} подряд дольше lockTimeout`,
755
801
  };
756
802
  continue;
757
803
  }
@@ -808,10 +854,17 @@ export class Projector {
808
854
  const sc = this.scopes.get(row.tenant);
809
855
  return sc !== undefined && !failed?.[row.tenant] && row.op === 'reclass' && !row.moved && !sc.from.has(row.class) && row.class !== '#reset';
810
856
  }
811
- /** Класс результата в арендаторе — из его семейства into. */
857
+ /**
858
+ * Входит ли класс `cls` в классы `into` проектора у арендатора `tenant` — сами эти классы или их потомки. Служебный
859
+ * метод: им `out` проверяет, что `handle` пишет только в эти классы.
860
+ */
812
861
  intoOf(tenant, cls) {
813
862
  return this.scopes.get(tenant)?.into.has(cls) ?? false;
814
863
  }
864
+ /** Поля ключа класса into арендатора (пусто — класс без ключа). */
865
+ keyOf(tenant, cls) {
866
+ return this.scopes.get(tenant)?.keys.get(cls) ?? [];
867
+ }
815
868
  /**
816
869
  * Ключ свёртки inc: класс и строка из одних полей ключа класса или одного $id; иначе null. Строку с другими полями
817
870
  * (данные, $tags, $owner) inc не складывает: её поля правят строку, и порядок таких правок важен.
@@ -876,7 +929,8 @@ export class Projector {
876
929
  // выбывший посреди перестройки — без производных строк: прежние пачки построили их лишь частично
877
930
  if (Object.keys(failed).length)
878
931
  await this.clear(q, Object.keys(failed));
879
- 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));
880
934
  rep.rebuilds++;
881
935
  });
882
936
  }
@@ -918,7 +972,7 @@ export class Projector {
918
972
  const r = await this.tx(async (q) => {
919
973
  if (!(await this.lock(q)))
920
974
  return busy();
921
- if (this.stale)
975
+ if (this.due())
922
976
  await this.resolve(q);
923
977
  const meta = await this.state(q);
924
978
  const rb = meta.rebuild;
@@ -936,7 +990,7 @@ export class Projector {
936
990
  const real = rows.filter((r) => r.row);
937
991
  if (!real.length) {
938
992
  await this.advance(q, meta.cursor, withFailed({ cursor: rb.start, at: await this.now(q), version: this.def.version, state: 'running',
939
- 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));
940
994
  rep.rebuilds++;
941
995
  return 'done';
942
996
  }
@@ -1064,7 +1118,7 @@ export class Projector {
1064
1118
  const resync = { ...(meta.resync ?? {}) };
1065
1119
  delete resync[tenant];
1066
1120
  const next = { ...meta, retenant: { ...(meta.retenant ?? {}), [tenant]: { last: null, chunks: [] } },
1067
- since: { ...(meta.since ?? {}), [tenant]: now }, resync };
1121
+ since: { ...(meta.since ?? {}), [tenant]: now }, resync, tenants: [...new Set([...(meta.tenants ?? this.tenants), tenant])] };
1068
1122
  if (!Object.keys(resync).length)
1069
1123
  delete next.resync;
1070
1124
  await this.advance(q, meta.cursor, withFailed(next, failed));
@@ -1094,7 +1148,7 @@ export class Projector {
1094
1148
  rep.skipped++;
1095
1149
  return 'busy';
1096
1150
  }
1097
- if (this.stale)
1151
+ if (this.due())
1098
1152
  await this.resolve(q);
1099
1153
  const meta = await this.state(q);
1100
1154
  const rt = meta.retenant?.[tenant];
@@ -1151,6 +1205,12 @@ export class Projector {
1151
1205
  if (!meta.cursor || meta.state === 'rebuilding' || meta.version !== this.def.version)
1152
1206
  return false;
1153
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;
1154
1214
  if (start) {
1155
1215
  for (const [t, f] of Object.entries(meta.failed ?? {})) {
1156
1216
  if (f.code !== 'cursor_expired' || this.blind.has(t) || !this.scopes.has(t))
@@ -1159,7 +1219,7 @@ export class Projector {
1159
1219
  more = true;
1160
1220
  }
1161
1221
  }
1162
- 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;
1163
1223
  for (const [t, v] of Object.entries(fresh.retenant ?? {})) {
1164
1224
  if (v.done)
1165
1225
  continue;
@@ -68,7 +68,10 @@ export declare class Registry {
68
68
  all(): ClassInfo[];
69
69
  /** Класс и все его потомки (семейство). */
70
70
  family(name: string): ClassInfo[];
71
- /** Тип листа по пути полей класса: по JSON Schema (type и format). */
71
+ /**
72
+ * Тип листа — поля в конце пути `path` — у класса `cls`, по его JSON Schema (`type` и `format`). По этому типу
73
+ * библиотека сравнивает значения поля в фильтрах, сортировке и итогах.
74
+ */
72
75
  leafType(cls: string, path: readonly string[]): LeafType;
73
76
  }
74
77
  export {};
package/dist/registry.js CHANGED
@@ -70,7 +70,10 @@ export class Registry {
70
70
  const root = this.resolve(name).name;
71
71
  return this.all().filter((c) => c.ancestors.includes(root));
72
72
  }
73
- /** Тип листа по пути полей класса: по JSON Schema (type и format). */
73
+ /**
74
+ * Тип листа — поля в конце пути `path` — у класса `cls`, по его JSON Schema (`type` и `format`). По этому типу
75
+ * библиотека сравнивает значения поля в фильтрах, сортировке и итогах.
76
+ */
74
77
  leafType(cls, path) {
75
78
  let node = this.resolve(cls).schema;
76
79
  for (const p of path) {
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;
@@ -62,8 +62,12 @@ export interface Hop {
62
62
  * хаб → связь — назад; связь → хаб и хаб → хаб — вперёд; нет нужного — другое направление.
63
63
  */
64
64
  export declare function resolveHop(reg: Registry, prev: Step, next: Step): Hop;
65
- /** Курсор keyset-пагинации из последней строки страницы. */
66
- export declare function cursorOf(row: Row, field: string): Cursor;
65
+ /**
66
+ * Курсор постраничного вывода для `after()`: значение поля сортировки, id и номер версии последней строки страницы.
67
+ * Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`. Для пути из `paths()` — то же по его
68
+ * последнему узлу (узлу последнего шага) и id всех узлов: курсор отделяет пути с общим последним узлом.
69
+ */
70
+ export declare function cursorOf(row: Row | Path, field: string): Cursor;
67
71
  export interface BuildOptions {
68
72
  /**
69
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,22 +422,60 @@ function cursorParams(c, sort) {
422
422
  }
423
423
  return { v: value, id };
424
424
  }
425
- /** Курсор keyset-пагинации из последней строки страницы. */
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');
448
+ /**
449
+ * Курсор постраничного вывода для `after()`: значение поля сортировки, id и номер версии последней строки страницы.
450
+ * Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`. Для пути из `paths()` — то же по его
451
+ * последнему узлу (узлу последнего шага) и id всех узлов: курсор отделяет пути с общим последним узлом.
452
+ */
426
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
+ }
427
459
  const f = field === 'updated' ? 'at' : field;
460
+ const rev = typeof row.rev === 'number' ? { rev: row.rev } : {};
428
461
  if (f === 'at' || f === 'rev' || f === 'id')
429
- return { v: row[f], id: row.id };
462
+ return { v: row[f], id: row.id, ...rev };
430
463
  if (f.startsWith('data.')) {
431
464
  let v = row.data;
432
465
  for (const k of f.slice(5).split('.'))
433
466
  v = v?.[k];
434
- return { v: (v ?? null), id: row.id };
467
+ return { v: (v ?? null), id: row.id, ...rev };
435
468
  }
436
469
  return fail(`cursorOf: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${field}'`);
437
470
  }
438
471
  // ---------------------------------------------------------------------------
439
472
  // Построение запроса
440
473
  // ---------------------------------------------------------------------------
474
+ /**
475
+ * Метка схемы во фрагментах, собранных до имени схемы: символ NUL не бывает в тексте PostgreSQL, поэтому замена не
476
+ * заденет литералы — имя поля вида a@S@b прежде портилось (план 2.0, 7.1 №5).
477
+ */
478
+ const SCHEMA_MARK = '\u0000S\u0000';
441
479
  const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason', 'moved'];
442
480
  /** Колонки журнала, выданные приложению: все, кроме changed (ревью №1 точки А плана 1.1, 10-core.sql). */
443
481
  const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason',
@@ -523,7 +561,8 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
523
561
  return `(select v.id, v.rev from ${S}.entity v where v.class = any (${clsPh})${dataCond('v')}${endsCond('v.ends')}`
524
562
  + `${ids ? ` and v.id = any (${ids})` : ''})`;
525
563
  }
526
- 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 `
527
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)`)})`;
528
567
  };
529
568
  const entityPart = (src, depth = 'null::int', from = `${S}.entity x`) => `select ${colsOf('x')}, ${depth} as depth from ${from} where ${where('x', src)}`;
@@ -609,10 +648,13 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
609
648
  + `where (v.op = 'delete') = ${isDead} and v.op not in ('purge', 'trim', 'reset')${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)}${rolesCond('v', 's.src')})`;
610
649
  };
611
650
  ctes.push(`p${i}a as (select q.src, q.id, q.rev from ${hopCall(atPh, false)} q)`);
612
- parts.push(cur ? entityByIds(`select q.id from p${i}a q`) : logPart(`select q.id, q.rev from p${i}a q`));
651
+ // пары переходов — по одной на (источник, цель): объект, который ссылается на несколько объектов прошлого шага,
652
+ // встречается в них несколько раз; строка узла — одна на id, иначе rows(), count() и агрегаты его удваивали
653
+ // (entityByIds повторы отбрасывает сам, срез asOf() и надгробия withDeleted() — через distinct здесь)
654
+ parts.push(cur ? entityByIds(`select q.id from p${i}a q`) : logPart(`select distinct q.id, q.rev from p${i}a q`));
613
655
  if (dead) {
614
656
  ctes.push(`p${i}d as (select q.src, q.id, q.rev from ${hopCall(atPh, true)} q)`);
615
- parts.push(logPart(`select q.id, q.rev from p${i}d q`));
657
+ parts.push(logPart(`select distinct q.id, q.rev from p${i}d q`));
616
658
  ctes.push(`p${i} as (select src, id from p${i}a union all select src, id from p${i}d)`);
617
659
  }
618
660
  else {
@@ -646,32 +688,62 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
646
688
  const leaf = (path) => leafOf(reg, rowsNode(lastNode), path);
647
689
  const sort = mods.order ? sortSpec(mods.order, leaf) : undefined;
648
690
  const dir = mods.desc ? ' desc' : '';
649
- const orderBy = (a) => (sort ? ` order by ${sort.expr(a)}${dir}, ${a}.id${dir}` : '');
650
- 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 = () => []) => {
651
700
  if (!mods.after)
652
701
  return undefined;
653
702
  if (!sort)
654
703
  fail('after(cursor) требует sort(поле)');
655
704
  const c = cursorParams(mods.after, sort);
656
- if (!sort.nullable)
657
- 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 ? '<' : '>';
658
707
  // Поле данных бывает null (или его нет): order by ставит такие строки в конец при возрастании и в
659
708
  // начало при убывании, а сравнение строк с null даёт null — одно сравнение их теряло, курсор null
660
709
  // давал пустую страницу. Здесь null — значение после всех прочих (asc) или перед ними (desc); текст
661
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(', ')})`;
662
716
  const e = `(${sort.expr(a)})`;
663
- const v = `${p.push(c.v)}${sort.pcast || '::text'}`;
664
- const id = `${p.push(c.id)}::uuid`;
717
+ const rest = cols.length === 1 ? `${cols[0]} ${lt} ${vals[0]}` : `(${cols.join(', ')}) ${lt} (${vals.join(', ')})`;
665
718
  return mods.desc
666
- ? `((${e}, ${a}.id) < (${v}, ${id}) or (${v} is null and (${e} is not null or ${a}.id < ${id})))`
667
- : `((${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})))`;
668
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);
669
726
  const limitOffset = () => (mods.limit !== undefined ? ` limit ${p.push(mods.limit)}::bigint` : '') + (mods.offset !== undefined ? ` offset ${p.push(mods.offset)}::bigint` : '');
670
- // 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() этой версии.
671
731
  if (mode === 'versions' && mods.follow) {
672
- ctes.push(`f(id) as (select z.id from ${ents} z union select x.id from f cross join lateral (`
673
- + `select l.moved as id from ${S}.log l where l.id = f.id and l.moved is not null `
674
- + `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)`);
675
747
  }
676
748
  const withClause = `with ${mode === 'versions' && mods.follow ? 'recursive ' : ''}${ctes.join(',\n')}\n`;
677
749
  let text;
@@ -680,7 +752,12 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
680
752
  case 'paths': {
681
753
  const obj = keyed.map(({ key, node }) => `${lit(key)}::text, to_jsonb(h${node})`).join(', ');
682
754
  const h = `h${lastNode}`;
683
- 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()}`;
684
761
  break;
685
762
  }
686
763
  case 'rows':
@@ -697,11 +774,17 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
697
774
  // с sort — по полю и направлению (последние версии первыми — sort('at', 'desc'), с limit — в самом запросе);
698
775
  // без sort: с follow — по времени через все связанные id, без follow — по id и номеру версии
699
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
+ })]);
700
782
  text = mods.follow
701
- ? `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
702
- + `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()}`
703
786
  : `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
704
- + `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()}`;
705
788
  break;
706
789
  }
707
790
  case 'diff':
@@ -728,12 +811,14 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
728
811
  }
729
812
  else {
730
813
  const cast = agg.fn === 'sum' || agg.fn === 'avg' ? '::numeric' : CAST[aggLeaf];
731
- text = `${withClause}select ${agg.fn}((${acc})${cast}) as v from ${ents} z`;
814
+ // у boolean в PostgreSQL нет min и max; false меньше true: наименьшее — «все ли true», наибольшее — «есть ли true»
815
+ const fn = aggLeaf === 'boolean' && agg.fn === 'min' ? 'bool_and' : aggLeaf === 'boolean' && agg.fn === 'max' ? 'bool_or' : agg.fn;
816
+ text = `${withClause}select ${fn}((${acc})${cast}) as v from ${ents} z`;
732
817
  }
733
818
  break;
734
819
  }
735
820
  }
736
- 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 };
737
822
  }
738
823
  /** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
739
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
  }