letopis 1.4.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/AGENT-CHEATSHEET.md +329 -281
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +572 -1053
  4. package/dist/acl.d.ts +78 -1
  5. package/dist/acl.js +264 -13
  6. package/dist/admin.d.ts +10 -141
  7. package/dist/admin.js +24 -184
  8. package/dist/auth.d.ts +2 -2
  9. package/dist/auth.js +5 -15
  10. package/dist/cache.d.ts +8 -1
  11. package/dist/cache.js +30 -8
  12. package/dist/chain.d.ts +18 -41
  13. package/dist/chain.js +165 -203
  14. package/dist/cli.js +45 -90
  15. package/dist/demo/booking.d.ts +352 -36
  16. package/dist/describe.d.ts +47 -0
  17. package/dist/describe.js +195 -0
  18. package/dist/errors.d.ts +59 -13
  19. package/dist/errors.js +190 -29
  20. package/dist/index.d.ts +52 -70
  21. package/dist/index.js +189 -122
  22. package/dist/internal.d.ts +5 -0
  23. package/dist/jsonschema.js +18 -17
  24. package/dist/load.d.ts +20 -12
  25. package/dist/load.js +207 -181
  26. package/dist/mcp.d.ts +5 -7
  27. package/dist/mcp.js +168 -214
  28. package/dist/messages/access.d.ts +90 -0
  29. package/dist/messages/access.js +113 -0
  30. package/dist/messages/api.d.ts +171 -0
  31. package/dist/messages/api.js +215 -0
  32. package/dist/messages/boot.d.ts +201 -0
  33. package/dist/messages/boot.js +256 -0
  34. package/dist/messages/codes.d.ts +172 -0
  35. package/dist/messages/codes.js +173 -0
  36. package/dist/messages/index.d.ts +95 -0
  37. package/dist/messages/index.js +118 -0
  38. package/dist/messages/mcp.d.ts +306 -0
  39. package/dist/messages/mcp.js +387 -0
  40. package/dist/messages/model.d.ts +233 -0
  41. package/dist/messages/model.js +283 -0
  42. package/dist/messages/query.d.ts +131 -0
  43. package/dist/messages/query.js +158 -0
  44. package/dist/messages/sql-access.d.ts +70 -0
  45. package/dist/messages/sql-access.js +87 -0
  46. package/dist/messages/sql-core.d.ts +150 -0
  47. package/dist/messages/sql-core.js +181 -0
  48. package/dist/messages/sql-time.d.ts +71 -0
  49. package/dist/messages/sql-time.js +88 -0
  50. package/dist/messages/sql-write.d.ts +246 -0
  51. package/dist/messages/sql-write.js +270 -0
  52. package/dist/messages/time.d.ts +23 -0
  53. package/dist/messages/time.js +29 -0
  54. package/dist/messages/types.d.ts +27 -0
  55. package/dist/messages/types.js +5 -0
  56. package/dist/messages/write.d.ts +405 -0
  57. package/dist/messages/write.js +509 -0
  58. package/dist/model.d.ts +73 -60
  59. package/dist/model.js +20 -29
  60. package/dist/ops.d.ts +35 -0
  61. package/dist/ops.js +101 -0
  62. package/dist/pglite.d.ts +0 -9
  63. package/dist/pglite.js +3 -1
  64. package/dist/registry.d.ts +3 -19
  65. package/dist/registry.js +3 -16
  66. package/dist/signals.d.ts +1 -5
  67. package/dist/signals.js +1 -5
  68. package/dist/sql.d.ts +7 -1
  69. package/dist/sql.js +101 -85
  70. package/dist/sync.d.ts +9 -3
  71. package/dist/sync.js +40 -17
  72. package/dist/tx.d.ts +11 -1
  73. package/dist/tx.js +32 -11
  74. package/dist/typed.d.ts +240 -45
  75. package/dist/types.d.ts +15 -90
  76. package/dist/types.js +12 -14
  77. package/dist/up.d.ts +53 -32
  78. package/dist/up.js +180 -74
  79. package/dist/uuid.js +5 -5
  80. package/dist/validate.d.ts +13 -1
  81. package/dist/validate.js +61 -29
  82. package/dist/watch.d.ts +5 -16
  83. package/dist/watch.js +23 -77
  84. package/dist/write.d.ts +9 -16
  85. package/dist/write.js +102 -185
  86. package/llms.txt +22 -25
  87. package/package.json +2 -9
  88. package/sql/10-core.sql +7 -50
  89. package/sql/15-errors.sql +64 -10
  90. package/sql/20-context.sql +7 -6
  91. package/sql/30-validate.sql +35 -27
  92. package/sql/40-class.sql +20 -114
  93. package/sql/50-acl.sql +8 -13
  94. package/sql/60-write.sql +220 -676
  95. package/sql/70-read.sql +11 -13
  96. package/sql/80-auth.sql +23 -27
  97. package/sql/90-time.sql +77 -944
  98. package/sql/95-seed.system.sql +7 -14
  99. package/sql/99-revision.sql +2 -1
  100. package/sql/README.md +32 -24
  101. package/sql/migrate/README.md +14 -0
  102. package/AGENT-CHEATSHEET.en.md +0 -488
  103. package/MIGRATION.md +0 -190
  104. package/README.en.md +0 -2910
  105. package/dist/import.d.ts +0 -185
  106. package/dist/import.js +0 -820
  107. package/dist/project.d.ts +0 -359
  108. package/dist/project.js +0 -1258
  109. package/sql/75-history.sql +0 -436
  110. package/sql/88-derived.sql +0 -1236
  111. package/sql/migrate/0006-changed-paths.sql +0 -3
  112. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/sql.d.ts CHANGED
@@ -29,7 +29,7 @@ export interface Step {
29
29
  }
30
30
  /** Уникальная метка узла пути. */
31
31
  export declare const nextNodeKey: () => number;
32
- export type ReadMode = 'paths' | 'rows' | 'ids' | 'count' | 'countPaths' | 'versions' | 'agg' | 'diff' | 'blame';
32
+ export type ReadMode = 'paths' | 'rows' | 'ids' | 'count' | 'countPaths' | 'versions' | 'agg';
33
33
  export interface AggSpec {
34
34
  fn: 'sum' | 'avg' | 'min' | 'max' | 'countBy';
35
35
  field: string;
@@ -48,6 +48,12 @@ export declare function roleOwners(reg: Registry, role: string): ClassInfo[];
48
48
  export declare function roleTargets(reg: Registry, role: string): string[];
49
49
  /** Конкретные классы, которые матчит шаг: класс (роль — её цели) и потомки, если не exact(). */
50
50
  export declare function stepClasses(reg: Registry, step: Step): string[];
51
+ /**
52
+ * Отказы построителя, которым не нужны база и путь, — фильтр шага (в том числе неизвестное поле) и поле sort('data.…') или
53
+ * агрегата по классам шага: план записи проверяет их до первого оператора (write.ts:precheckPlan, §2.5), поэтому
54
+ * опечатка в фильтре цели не ломает db.begin(). Построитель при чтении проверяет то же самое ещё раз.
55
+ */
56
+ export declare function precheckStep(reg: Registry, step: Step, order?: string, agg?: AggSpec): void;
51
57
  /** Принимает ли список классов конца класс cls (с учётом его предков). */
52
58
  export declare const accepts: (reg: Registry, targets: string[], cls: string) => boolean;
53
59
  export interface Hop {
package/dist/sql.js CHANGED
@@ -17,15 +17,43 @@
17
17
  * Значения всегда уходят параметрами, поэтому текст запроса зависит только от формы цепочки: его
18
18
  * кэширует план (chain.ts), а postgres.js готовит оператор один раз на соединение.
19
19
  */
20
- import { LetopisError } from './errors.js';
20
+ import { lerr, lmsg, similar } from './errors.js';
21
21
  import { isOp } from './ops.js';
22
22
  import { OP } from './types.js';
23
23
  let nodeSeq = 0;
24
24
  /** Уникальная метка узла пути. */
25
25
  export const nextNodeKey = () => ++nodeSeq;
26
- const fail = (msg) => {
27
- throw new LetopisError('invalid_query', msg);
26
+ /** Отказ построителя по ключу каталога — там, где нужно выражение: `x ?? refuse(ключ)`; в операторах — throw lerr(…). */
27
+ const refuse = (key, params) => {
28
+ throw lerr(key, params);
28
29
  };
30
+ /**
31
+ * Поля data строк системного класса Class — свойства метасхемы описания класса (class_metaschema() в 40-class.sql): строки
32
+ * Class проверяет она, а в описании самого класса Class только name. Ключи — свойства описания (ClassDescription) и
33
+ * служебные system и resolved: новое свойство описания без строки здесь — ошибка типов.
34
+ */
35
+ const CLASS_ROW_FIELDS = Object.keys({
36
+ name: true, kind: true, parent: true, abstract: true, alias: true, system: true, fields: true, ends: true, key: true,
37
+ history: true, ownerDefault: true, cache: true, meta: true, resolved: true,
38
+ });
39
+ /** Поля верхнего уровня data строк класса: он закрыт (class_resolve, 40-class.sql) — в нём только поля описания. */
40
+ const dataFields = (c) => c.name === 'Class' ? CLASS_ROW_FIELDS : Object.keys((c.schema.properties ?? {}));
41
+ /**
42
+ * Поле фильтра, sort() или агрегата не описано ни в одном классе шага — такое условие ничего не найдёт: invalid_query с
43
+ * похожими именами (план 2.0, этап 3, п. 3; в 1.5 было предупреждение, 7.9 №6). Проверяется только верхний уровень:
44
+ * путь внутрь поля-объекта не сверяется. classes — класс шага и все классы, которые он матчит (потомки, если не exact(),
45
+ * у роли — её цели); ends — роли концов тоже годятся (фильтр).
46
+ */
47
+ function checkField(classes, field, where, ends) {
48
+ if (!classes.length)
49
+ return;
50
+ if (classes.some((c) => dataFields(c).includes(field) || (ends && c.ends[field] !== undefined)))
51
+ return;
52
+ const names = classes.flatMap((c) => [...dataFields(c), ...(ends ? Object.keys(c.ends) : [])]);
53
+ throw lerr('query.unknown_field', { where, field, cls: classes[0].name, similar: similar(field, names) });
54
+ }
55
+ /** Поле sort('data.<путь>') или агрегата: первый ключ пути. */
56
+ const topField = (field) => field.slice(5).split('.')[0];
29
57
  // ---------------------------------------------------------------------------
30
58
  // Классы шага и правила переходов
31
59
  // ---------------------------------------------------------------------------
@@ -47,6 +75,24 @@ export function stepClasses(reg, step) {
47
75
  return base;
48
76
  return uniq(base.flatMap((b) => (reg.has(b) ? reg.family(b).map((c) => c.name) : [b])));
49
77
  }
78
+ /** Классы шага для типов листьев и сверки полей: класс шага первым, затем прочие классы, которые шаг матчит. */
79
+ function typingOf(reg, step, names = stepClasses(reg, step)) {
80
+ const infos = names.map((n) => reg.find(n)).filter((c) => !!c);
81
+ return [step.cls, ...infos.filter((c) => c !== step.cls)];
82
+ }
83
+ /**
84
+ * Отказы построителя, которым не нужны база и путь, — фильтр шага (в том числе неизвестное поле) и поле sort('data.…') или
85
+ * агрегата по классам шага: план записи проверяет их до первого оператора (write.ts:precheckPlan, §2.5), поэтому
86
+ * опечатка в фильтре цели не ломает db.begin(). Построитель при чтении проверяет то же самое ещё раз.
87
+ */
88
+ export function precheckStep(reg, step, order, agg) {
89
+ const typing = typingOf(reg, step);
90
+ compileFilter(reg, typing, step.filter, new Params());
91
+ if (order?.startsWith('data.'))
92
+ checkField(typing, topField(order), `sort('${order}')`, false);
93
+ if (agg?.field.startsWith('data.'))
94
+ checkField(typing, topField(agg.field), `${agg.fn}('${agg.field}')`, false);
95
+ }
50
96
  /** Принимает ли список классов конца класс cls (с учётом его предков). */
51
97
  export const accepts = (reg, targets, cls) => (reg.find(cls)?.ancestors ?? [cls]).some((a) => targets.includes(a));
52
98
  /** Роли классов from, цели которых принимают хотя бы один класс to. */
@@ -85,7 +131,7 @@ export function resolveHop(reg, prev, next) {
85
131
  if ((dir === 'forward' ? fwd : rev).length)
86
132
  return { dir, roles: null };
87
133
  }
88
- throw new LetopisError('no_path', `нет перехода ${prev.name} → ${next.name}: ни один класс не ссылается на другой`);
134
+ throw lerr('query.no_path', { from: prev.name, to: next.name });
89
135
  }
90
136
  // ---------------------------------------------------------------------------
91
137
  // Фильтры и операторы
@@ -188,7 +234,7 @@ function compileOp(path, leaf, o, p) {
188
234
  return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p);
189
235
  }
190
236
  default:
191
- return fail(`неизвестный оператор ${String(o[OP])}`);
237
+ throw lerr('query.unknown_op', { op: String(o[OP]) });
192
238
  }
193
239
  }
194
240
  /** Колонка tags: строка | список (все) | has / hasAny / hasAll. */
@@ -215,7 +261,7 @@ function compileTags(v, p) {
215
261
  }
216
262
  }
217
263
  }
218
- return fail('tags() принимает строку, список или has/hasAny/hasAll');
264
+ throw lerr('query.tags_arg');
219
265
  }
220
266
  const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
221
267
  /** Объект-строка ответа как значение: берётся id. */
@@ -227,7 +273,7 @@ function compileFilter(reg, classes, filter, p) {
227
273
  // or(…): дизъюнкция под-фильтров — только перепроверка
228
274
  if (isOp(filter)) {
229
275
  if (filter[OP] !== 'or')
230
- fail(`оператор ${String(filter[OP])} на уровне фильтра шага — допустим только or(…)`);
276
+ throw lerr('query.step_op', { op: String(filter[OP]) });
231
277
  const parts = filter.args[0].map((f) => {
232
278
  const sub = compileFilter(reg, classes, f, p);
233
279
  const frags = [...asFrags(sub, p)];
@@ -241,7 +287,7 @@ function compileFilter(reg, classes, filter, p) {
241
287
  return out;
242
288
  }
243
289
  if (Array.isArray(filter)) {
244
- out.ids = filter.map((x) => idOfValue(x) ?? fail('список id: ожидались строки'));
290
+ out.ids = filter.map((x) => idOfValue(x) ?? refuse('query.id_list'));
245
291
  return out;
246
292
  }
247
293
  const props = (classes[0]?.schema.properties ?? {});
@@ -281,9 +327,9 @@ function compileFilter(reg, classes, filter, p) {
281
327
  if (typeof value === 'string')
282
328
  out.ids = [value];
283
329
  else if (Array.isArray(value))
284
- out.ids = value.map((x) => idOfValue(x) ?? fail('filter.id: ожидались строки'));
330
+ out.ids = value.map((x) => idOfValue(x) ?? refuse('query.filter_id_list'));
285
331
  else
286
- fail('filter.id принимает строку или список строк');
332
+ throw lerr('query.filter_id');
287
333
  continue;
288
334
  }
289
335
  if (isRole(key)) {
@@ -292,12 +338,14 @@ function compileFilter(reg, classes, filter, p) {
292
338
  out.frags.push((a) => `coalesce(${a}.links->${role}, 'null'::jsonb) in ('null'::jsonb, '[]'::jsonb)`);
293
339
  continue;
294
340
  }
295
- const id = idOfValue(value) ?? fail(`конец ${key}: ожидался id, строка ответа или null`);
341
+ const id = idOfValue(value) ?? refuse('query.end_value', { role: key });
296
342
  out.ends.push(id);
297
343
  const ph = p.push(id);
298
344
  out.frags.push((a) => `${SCHEMA_MARK}.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
299
345
  continue;
300
346
  }
347
+ // поле данных: не описано ни в одном классе шага — invalid_query с похожими (план 2.0, этап 3, п. 3)
348
+ checkField(classes, key, `${classes[0]?.name ?? ''}({ ${key} })`, true);
301
349
  if (isOp(value)) {
302
350
  out.frags.push(compileOp([key], leafOf(reg, classes, [key]), value, p));
303
351
  continue;
@@ -318,8 +366,12 @@ function compileFilter(reg, classes, filter, p) {
318
366
  function asFrags(cf, p) {
319
367
  const frags = [...cf.frags];
320
368
  if (cf.ids) {
321
- const ph = p.push(cf.ids);
322
- frags.push((a) => `${a}.id = any (${ph}::uuid[])`);
369
+ // один id — равенство, а не = any (массив): общий план подготовленного запроса для = any PostgreSQL считает на ~10
370
+ // элементов, и при неудачной выборке ANALYZE чтение по ключу навсегда оставалось на планах под каждый вызов —
371
+ // 0,5–0,8 мс планирования на чтение (замер против 1.0.1); общий план равенства стоит как план вызова и выбирается всегда
372
+ const one = cf.ids.length === 1;
373
+ const ph = p.push(one ? cf.ids[0] : cf.ids);
374
+ frags.push((a) => (one ? `${a}.id = ${ph}::uuid` : `${a}.id = any (${ph}::uuid[])`));
323
375
  }
324
376
  if (cf.eq) {
325
377
  const ph = p.push(cf.eq);
@@ -341,7 +393,7 @@ function sortSpec(order, leaf) {
341
393
  const t = leaf(path);
342
394
  return { expr: (a) => (CAST[t] ? `(${accessor(path)(a)})${CAST[t]}` : accessor(path)(a)), pcast: PCAST[t], field: order, type: t, nullable: true };
343
395
  }
344
- return fail(`sort: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${order}'`);
396
+ throw lerr('query.sort_field', { field: order });
345
397
  }
346
398
  /** Формы значений курсора — как у форматов uuid, date и date-time валидатора (validate.ts). */
347
399
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -398,9 +450,11 @@ function shown(v) {
398
450
  }
399
451
  return s.length > 60 ? `${s.slice(0, 59)}…` : s;
400
452
  }
453
+ /** Какое значение курсора ждёт поле sort() по своему типу — запись каталога (параметр expect ошибки курсора). */
401
454
  const CURSOR_EXPECT = {
402
- numeric: 'число', int: 'целое число', boolean: 'true/false', text: 'строка', uuid: 'uuid строкой',
403
- timestamptz: 'время строкой вида 2026-10-07T10:00:00Z', date: 'дата строкой вида 2026-10-07', jsonb: 'строка, число, true/false',
455
+ numeric: 'query.cursor_expect_numeric', int: 'query.cursor_expect_int', boolean: 'query.cursor_expect_boolean',
456
+ text: 'query.cursor_expect_text', uuid: 'query.cursor_expect_uuid', timestamptz: 'query.cursor_expect_timestamptz',
457
+ date: 'query.cursor_expect_date', jsonb: 'query.cursor_expect_jsonb',
404
458
  };
405
459
  /**
406
460
  * Параметры keyset из курсора: значение проверяется по типу поля сортировки до запроса. Курсор
@@ -409,16 +463,15 @@ const CURSOR_EXPECT = {
409
463
  * Курсор того же типа с другого поля проверка не отличит.
410
464
  */
411
465
  function cursorParams(c, sort) {
412
- const how = `cursorOf(строка, '${sort.field}')`;
413
466
  if (typeof c !== 'object' || c === null || c.v === undefined)
414
- return fail(`after(cursor): курсор — объект { v, id } из ${how}`);
467
+ throw lerr('query.cursor_shape', { field: sort.field });
415
468
  const { v, id } = c;
416
469
  if (typeof id !== 'string' || !UUID_RE.test(id))
417
- return fail(`after(cursor): id курсора — uuid строки, а не ${shown(id)}`);
470
+ throw lerr('query.cursor_id', { id: shown(id) });
418
471
  const value = v === null && sort.nullable ? null : cursorValue(v, sort.type);
419
472
  if (value === undefined) {
420
- return fail(`after(cursor): значение курсора ${shown(v)} не подходит к sort('${sort.field}') — ожидалось: ${CURSOR_EXPECT[sort.type]}`
421
- + `${sort.nullable ? ' или null' : ''}; курсор снят с другого поля? Нужен ${how}`);
473
+ // у поля данных подходит и курсор null — своя запись, а не склейка « или null»
474
+ throw lerr(sort.nullable ? 'query.cursor_value_nullable' : 'query.cursor_value', { value: shown(v), field: sort.field, expect: lmsg(CURSOR_EXPECT[sort.type]) });
422
475
  }
423
476
  return { v: value, id };
424
477
  }
@@ -428,7 +481,7 @@ function cursorRev(c) {
428
481
  if (rev === undefined)
429
482
  return undefined;
430
483
  if (!Number.isInteger(rev) || rev < 1)
431
- return fail(`after(cursor): rev курсора — номер версии (целое ≥ 1), а не ${shown(rev)}`);
484
+ throw lerr('query.cursor_rev', { rev: shown(rev) });
432
485
  return rev;
433
486
  }
434
487
  /** id узлов пути из курсора paths() по ключам узлов (план 2.0, 7.1 №3); курсор без path — undefined. */
@@ -439,7 +492,7 @@ function cursorPath(c, keys) {
439
492
  return keys.map((k) => {
440
493
  const id = typeof path === 'object' && path !== null ? path[k] : undefined;
441
494
  if (typeof id !== 'string' || !UUID_RE.test(id))
442
- return fail(`after(cursor): в курсоре пути нет id узла ${k} — нужен cursorOf(путь, поле) из paths() этой же цепочки`);
495
+ throw lerr('query.cursor_path', { key: k });
443
496
  return id;
444
497
  });
445
498
  }
@@ -466,7 +519,7 @@ export function cursorOf(row, field) {
466
519
  v = v?.[k];
467
520
  return { v: (v ?? null), id: row.id, ...rev };
468
521
  }
469
- return fail(`cursorOf: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${field}'`);
522
+ throw lerr('query.cursor_of_field', { field });
470
523
  }
471
524
  // ---------------------------------------------------------------------------
472
525
  // Построение запроса
@@ -476,14 +529,14 @@ export function cursorOf(row, field) {
476
529
  * заденет литералы — имя поля вида a@S@b прежде портилось (план 2.0, 7.1 №5).
477
530
  */
478
531
  const SCHEMA_MARK = '\u0000S\u0000';
479
- const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason', 'moved'];
480
- /** Колонки журнала, выданные приложению: все, кроме changed (ревью №1 точки А плана 1.1, 10-core.sql). */
481
- const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason',
482
- 'moved', 'prev_hash', 'hash'].map((c) => 'l.' + c).join(', ');
532
+ const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason'];
533
+ /** Колонки журнала (выданные приложению — 10-core.sql). */
534
+ const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason']
535
+ .map((c) => 'l.' + c).join(', ');
483
536
  const colsOf = (a) => COLS.map((c) => `${a}.${c}`).join(', ');
484
537
  export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
485
538
  if (!steps.length)
486
- fail('пустая цепочка');
539
+ throw lerr('query.empty_chain');
487
540
  const S = `"${schema.replace(/"/g, '""')}"`;
488
541
  const p = new Params();
489
542
  const cur = mods.asOf === undefined;
@@ -495,7 +548,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
495
548
  if (s.pivotKey !== undefined) {
496
549
  const at = byNodeKey.get(s.pivotKey);
497
550
  if (at === undefined)
498
- fail(`возврат к узлу ${s.name}: узла нет в этом пути`);
551
+ throw lerr('query.pivot_missing', { name: s.name });
499
552
  real[i] = at;
500
553
  }
501
554
  else {
@@ -513,8 +566,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
513
566
  for (let i = 0; i < steps.length; i++) {
514
567
  const step = steps[i];
515
568
  const names = stepClasses(reg, step);
516
- const infos = names.map((n) => reg.find(n)).filter((c) => !!c);
517
- const typing = [step.cls, ...infos.filter((c) => c !== step.cls)];
569
+ const typing = typingOf(reg, step, names);
518
570
  // возврат к узлу: только дофильтры
519
571
  if (step.pivotKey !== undefined) {
520
572
  const a = `h${real[i]}`;
@@ -563,7 +615,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
563
615
  }
564
616
  // по классу: id-кандидаты классов шага, последняя версия — любого класса (как find_ids, план 2.0, 7.1 №1)
565
617
  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 `
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)`)})`;
618
+ + `where v.class = any (${clsPh}) and (v.op = 'delete') = ${isDead}${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)})`;
567
619
  };
568
620
  const entityPart = (src, depth = 'null::int', from = `${S}.entity x`) => `select ${colsOf('x')}, ${depth} as depth from ${from} where ${where('x', src)}`;
569
621
  /**
@@ -580,7 +632,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
580
632
  const parts = [];
581
633
  if (i === 0) {
582
634
  if (step.deepMax !== undefined)
583
- fail('deep() — на шаге перехода к тому же классу, а не на первом шаге');
635
+ throw lerr('query.deep_first_step');
584
636
  if (cur) {
585
637
  // условие id уже в conds — первичный ключ; вложение — поиск по индексу GIN
586
638
  parts.push(!cf.ids && (cf.eq || cf.ends.length) ? entityByIds(`select f.id from ${findIds(null, 'null', false)} f`) : entityPart(undefined));
@@ -601,15 +653,15 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
601
653
  const rolesCond = (v, src) => (hop.roles ? ` and ${S}.role_has(${v}.links, ${rolesPh}, ${src})` : '');
602
654
  if (step.deepMax !== undefined) {
603
655
  if (hop.dir !== 'reverse' || !stepClasses(reg, steps[a]).some((c) => names.includes(c))) {
604
- fail(`deep() — только на переходе к тому же классу (дети любой глубины): ${steps[a].name} → ${step.name}`);
656
+ throw lerr('query.deep_hop', { from: steps[a].name, to: step.name });
605
657
  }
606
658
  if (dead)
607
- fail('deep() и withDeleted() не сочетаются');
659
+ throw lerr('query.deep_with_deleted');
608
660
  const maxPh = `${p.push(step.deepMax)}::int`;
609
661
  let deepSrc = `${S}.find_deep(${clsPh}, ${rolesPh}, ${srcArr}, ${maxPh}, ${atPh})`;
610
662
  if (opts.direct) {
611
663
  // тот же обход под RLS: невидимый узел обрывает поддерево политикой
612
- const node = cur ? `${S}.entity` : `(select w.id, w.rev, w.links from ${versionsAt(atPh, `l.class = any (${clsPh})`)} w where w.op not in ('delete', 'purge', 'trim', 'reset'))`;
664
+ const node = cur ? `${S}.entity` : `(select w.id, w.rev, w.links from ${versionsAt(atPh, `l.class = any (${clsPh})`)} w where w.op <> 'delete')`;
613
665
  const hasSrc = (v, src) => (cur ? `${v}.ends @> array[${src}]` : `${S}.link_ids(${v}.links) @> array[${src}]`);
614
666
  const cls = (v) => (cur ? ` and ${v}.class = any (${clsPh})` : '');
615
667
  deepSrc = `(with recursive d(src, id, rev, depth, seen) as (`
@@ -645,7 +697,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
645
697
  }
646
698
  return `(select s.src, v.id, v.rev from ${versionsAt(at, `l.class = any (${clsPh})`)} v `
647
699
  + `cross join lateral unnest(${S}.link_ids(v.links)) t(x) join ${srcs} on s.src = t.x `
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')})`;
700
+ + `where (v.op = 'delete') = ${isDead}${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)}${rolesCond('v', 's.src')})`;
649
701
  };
650
702
  ctes.push(`p${i}a as (select q.src, q.id, q.rev from ${hopCall(atPh, false)} q)`);
651
703
  // пары переходов — по одной на (источник, цель): объект, который ссылается на несколько объектов прошлого шага,
@@ -686,6 +738,8 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
686
738
  const single = tail.length === 0 && !steps.some((st) => st.pivotKey !== undefined);
687
739
  const ents = single ? `n${lastNode}` : `(select z0.* from n${lastNode} z0 where z0.id in (select h${lastNode}.id ${from}${whereOf(tail)}))`;
688
740
  const leaf = (path) => leafOf(reg, rowsNode(lastNode), path);
741
+ if (mods.order?.startsWith('data.'))
742
+ checkField(rowsNode(lastNode), topField(mods.order), `sort('${mods.order}')`, false);
689
743
  const sort = mods.order ? sortSpec(mods.order, leaf) : undefined;
690
744
  const dir = mods.desc ? ' desc' : '';
691
745
  /** Порядок: поле sort, id строки и разделители равных (id прочих узлов пути). */
@@ -700,7 +754,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
700
754
  if (!mods.after)
701
755
  return undefined;
702
756
  if (!sort)
703
- fail('after(cursor) требует sort(поле)');
757
+ throw lerr('query.after_without_sort');
704
758
  const c = cursorParams(mods.after, sort);
705
759
  const t = ties(mods.after);
706
760
  const lt = mods.desc ? '<' : '>';
@@ -724,28 +778,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
724
778
  const others = keyed.filter((k) => k.node !== lastNode);
725
779
  const pathKeys = [...others, ...keyed.filter((k) => k.node === lastNode)].map((k) => k.key);
726
780
  const limitOffset = () => (mods.limit !== undefined ? ` limit ${p.push(mods.limit)}::bigint` : '') + (mods.offset !== undefined ? ` offset ${p.push(mods.offset)}::bigint` : '');
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() этой версии.
731
- if (mode === 'versions' && mods.follow) {
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)`);
747
- }
748
- const withClause = `with ${mode === 'versions' && mods.follow ? 'recursive ' : ''}${ctes.join(',\n')}\n`;
781
+ const withClause = `with ${ctes.join(',\n')}\n`;
749
782
  let text;
750
783
  let aggLeaf;
751
784
  switch (mode) {
@@ -772,37 +805,22 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
772
805
  break;
773
806
  case 'versions': {
774
807
  // с sort — по полю и направлению (последние версии первыми — sort('at', 'desc'), с limit — в самом запросе);
775
- // без sort: с follow — по времени через все связанные id, без follow — по id и номеру версии
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';
808
+ // без sort — по id и номеру версии
809
+ const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` : 'v.id, v.rev';
777
810
  // курсор — поле sort, id и номер версии: версии одного объекта с равным полем не теряются (план 2.0, 7.1 №4)
778
811
  const cond = whereOf([after('v', (c) => {
779
812
  const rev = cursorRev(c);
780
813
  return rev === undefined ? [] : [{ col: 'v.rev', val: rev, cast: '::int' }];
781
814
  })]);
782
- text = mods.follow
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()}`
786
- : `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
787
- + `where l.id in (select z.id from ${ents} z)) v${cond} order by ${order}${limitOffset()}`;
788
- break;
789
- }
790
- case 'diff':
791
- case 'blame': {
792
- // план 1.1, §2.1: функции базы считают по журналу под RLS — по одному документу на объект шага
793
- const h = mods.hist ?? {};
794
- const num = (v) => (typeof v === 'number' ? `${p.push(v)}::int` : 'null');
795
- const ts = (v) => (typeof v === 'string' ? `${p.push(v)}::text::timestamptz` : 'null');
796
- const call = mode === 'diff'
797
- ? `${S}.diff_versions(z.id, ${num(h.from)}, ${num(h.to)}, ${ts(h.from)}, ${ts(h.to)}, ${h.follow ? 'true' : 'false'})`
798
- : `${S}.blame(z.id, ${ts(h.at)}, ${num(h.limit)})`;
799
- text = `${withClause}select ${call} as row from (select distinct z.id from ${ents} z) z order by z.id`;
815
+ text = `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
816
+ + `where l.id in (select z.id from ${ents} z)) v${cond} order by ${order}${limitOffset()}`;
800
817
  break;
801
818
  }
802
819
  case 'agg': {
803
820
  if (!agg || !agg.field.startsWith('data.'))
804
- fail("агрегат по полю 'data.<путь>'");
821
+ throw lerr('query.agg_field');
805
822
  const path = agg.field.slice(5).split('.');
823
+ checkField(rowsNode(lastNode), path[0], `${agg.fn}('${agg.field}')`, false);
806
824
  const acc = accessor(path)('z');
807
825
  aggLeaf = leaf(path);
808
826
  if (agg.fn === 'countBy') {
@@ -822,14 +840,12 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
822
840
  }
823
841
  /** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
824
842
  export function toRow(raw) {
825
- const { depth, agent, reason, moved, changed: _changed, ...rest } = raw;
843
+ const { depth, agent, reason, ...rest } = raw;
826
844
  const row = rest;
827
845
  if (agent != null)
828
846
  row.agent = agent;
829
847
  if (reason != null)
830
848
  row.reason = reason;
831
- if (moved != null)
832
- row.moved = moved;
833
849
  if (row.op === 'delete')
834
850
  row.$deleted = true;
835
851
  if (depth != null)
package/dist/sync.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * мешающими строками отклоняет сама база (`tightening_conflict` с перечнем) уже в сухом прогоне.
7
7
  * generateTypes: объявления TypeScript для классов из реестра — в том числе созданных данными.
8
8
  */
9
- import type { Db } from './index.js';
9
+ import type { DbCore } from './index.js';
10
10
  import type { AnyModel, LostRule } from './model.js';
11
11
  import type { JsonSchema } from './jsonschema.js';
12
12
  import type { Registry } from './registry.js';
@@ -20,12 +20,18 @@ export interface SyncResult {
20
20
  /** Правила refine, которых нет в базе. */
21
21
  lost: LostRule[];
22
22
  }
23
- export declare function syncModels(db: Db, models: readonly AnyModel[], opts?: {
23
+ export declare function syncModels(db: DbCore, models: readonly AnyModel[], opts?: {
24
24
  apply?: boolean;
25
25
  }): Promise<SyncResult>;
26
26
  /** Тип TypeScript по JSON Schema подмножества. */
27
27
  export declare function schemaToTs(s: JsonSchema | undefined, indent?: string): string;
28
- /** Объявления `<Класс>Data` и `<Класс>Links` для классов реестра (кроме системных). */
28
+ /**
29
+ * Объявления `<Класс>Data` и `<Класс>Links` для классов реестра и `<Класс>Model` — имя, алиас, концы с классами целей
30
+ * и предки (ModelLike, typed.ts), — а в конце список `Models`: строгие типы цепочек без моделей Zod, `connect<Models>()`
31
+ * (план 2.0, этап 4, п. 1). Классы арендатора System (системные и классы сидов пакета) — только с includeSystem
32
+ * (`letopis types --system`); без него системные классы даёт встроенный набор SystemModels. Пометка в первой строке —
33
+ * запись каталога model.types_header.
34
+ */
29
35
  export declare function generateTypes(registry: Registry, opts?: {
30
36
  includeSystem?: boolean;
31
37
  }): string;
package/dist/sync.js CHANGED
@@ -1,4 +1,6 @@
1
- import { LetopisError } from './errors.js';
1
+ import { lerr, lmsg } from './errors.js';
2
+ import { internalsOf } from './internal.js';
3
+ import { RESERVED_CLASS_NAMES } from './types.js';
2
4
  const ROLLBACK = Symbol('sync-dry-run');
3
5
  /** Канонический JSON: ключи по алфавиту — сравнение описаний без учёта порядка ключей. */
4
6
  function canon(v) {
@@ -9,11 +11,17 @@ function canon(v) {
9
11
  return JSON.stringify(v);
10
12
  }
11
13
  export async function syncModels(db, models, opts = {}) {
14
+ // хаб с именем метода цепочки или корня db (модель собрана мимо hub()) — отказ до записи (план 2.0, этап 4, п. 2;
15
+ // 7.10 №3); связь с таким именем отклоняет база
16
+ for (const m of models) {
17
+ if (m.kind === 'hub' && RESERVED_CLASS_NAMES.includes(m.name))
18
+ throw lerr('model.sync_hub_name_reserved', { name: m.name });
19
+ }
12
20
  const res = { applied: Boolean(opts.apply), created: [], updated: [], unchanged: [], skipped: [], lost: models.flatMap((m) => m.lostRules()) };
13
21
  const S = `"${db.schema.replace(/"/g, '""')}"`;
14
- // предок раньше потомка; класс-источник агрегата раньше цели: агрегат над классом, которого ещё нет, база отклоняет.
15
- // Круг (агрегат над собственным потомком, цикл агрегатов) так не упорядочить — отказ до записи с самим кругом (хвост
16
- // ревью доработок после 1.2.0: раньше обход молча ставил потомка раньше предка)
22
+ // предок раньше потомка: потомка класса, которого ещё нет, база отклоняет. Круг родителей (модель, собранная мимо
23
+ // hub()) так не упорядочить — отказ до записи с самим кругом (хвост ревью доработок после 1.2.0: раньше обход молча
24
+ // ставил потомка раньше предка)
17
25
  const ordered = [];
18
26
  const path = [];
19
27
  const visit = (m) => {
@@ -21,23 +29,21 @@ export async function syncModels(db, models, opts = {}) {
21
29
  return;
22
30
  const at = path.indexOf(m);
23
31
  if (at >= 0) {
24
- throw new LetopisError('invalid_class', `syncModels: модели зависят друг от друга по кругу: ${[...path.slice(at), m].map((x) => x.name).join(' → ')} — `
25
- + 'родителя и источник агрегата база ждёт раньше класса');
32
+ throw lerr('model.models_cycle', { cycle: [...path.slice(at), m].map((x) => x.name).join(' → ') });
26
33
  }
27
34
  path.push(m);
28
35
  if (m.parent && models.includes(m.parent))
29
36
  visit(m.parent);
30
- for (const a of Object.values(m.describe().aggregate ?? {})) {
31
- const src = models.find((x) => x.name === a.of);
32
- if (src)
33
- visit(src);
34
- }
35
37
  path.pop();
36
38
  ordered.push(m);
37
39
  };
38
40
  models.forEach(visit);
41
+ // у корня connect({ admin }) классы пишет администраторский пул с той же сессией (план 2.0, 7.9 №3): правка класса
42
+ // со строками требует прав, которых у роли приложения может не быть
43
+ const adminTx = internalsOf(db)?.adminTx;
44
+ const run = (fn) => (adminTx ? adminTx(fn) : db.transaction(fn));
39
45
  try {
40
- await db.transaction(async (tx) => {
46
+ await run(async (tx) => {
41
47
  for (const m of ordered) {
42
48
  const desc = m.describe();
43
49
  const [cur] = await tx.unsafe(`select tenant = ${S}.ctx_tenant() as own, data - 'resolved' as data from ${S}.entity
@@ -108,23 +114,40 @@ export function schemaToTs(s, indent = '') {
108
114
  }
109
115
  }).join(' | ');
110
116
  }
111
- /** Объявления `<Класс>Data` и `<Класс>Links` для классов реестра (кроме системных). */
117
+ /**
118
+ * Объявления `<Класс>Data` и `<Класс>Links` для классов реестра и `<Класс>Model` — имя, алиас, концы с классами целей
119
+ * и предки (ModelLike, typed.ts), — а в конце список `Models`: строгие типы цепочек без моделей Zod, `connect<Models>()`
120
+ * (план 2.0, этап 4, п. 1). Классы арендатора System (системные и классы сидов пакета) — только с includeSystem
121
+ * (`letopis types --system`); без него системные классы даёт встроенный набор SystemModels. Пометка в первой строке —
122
+ * запись каталога model.types_header.
123
+ */
112
124
  export function generateTypes(registry, opts = {}) {
113
- const out = ['// Сгенерировано letopis types — не править руками.', ''];
125
+ const out = [`// ${lmsg('model.types_header')}`, ''];
126
+ const models = [];
127
+ const lit = (v) => JSON.stringify(v);
114
128
  for (const c of registry.all().sort((a, b) => a.name.localeCompare(b.name))) {
115
129
  if (!opts.includeSystem && c.tenant === '2eba6d0a-1edd-4bb6-a85a-a703dab49035')
116
130
  continue;
117
131
  // идентификатор TypeScript — буквы Unicode, цифры, _ и $: кириллические имена классов допустимы (план 2.0, 7.5 №1)
118
132
  if (!/^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(c.name))
119
- throw new LetopisError('invalid_class', `types: имя класса ${c.name} не годится для идентификатора TypeScript`);
133
+ throw lerr('model.types_class_name', { name: c.name });
120
134
  // additionalProperties: false верхнего уровня в тип не переносится — лишние ключи и так ошибка типов
121
135
  const { additionalProperties: _closed, ...open } = c.schema;
122
136
  void _closed;
123
137
  // объектный литерал — интерфейс; класс без полей, union и открытая схема — псевдоним типа (иначе неверный TypeScript)
124
138
  const data = schemaToTs(open);
125
139
  out.push(/^\{[\s\S]*\}$/.test(data) && !/\}\s*&/.test(data) ? `export interface ${c.name}Data ${data}` : `export type ${c.name}Data = ${data};`);
126
- const ends = Object.entries(c.ends).map(([role, e]) => ` ${JSON.stringify(role)}${e.required || (e.min ?? 0) > 0 ? '' : '?'}: ${e.many ? 'string[]' : 'string'};`);
127
- out.push(`export interface ${c.name}Links {${ends.length ? `\n${ends.join('\n')}\n` : ''}}`, '');
140
+ const required = (e) => e.required === true || (e.min ?? 0) > 0;
141
+ const ends = Object.entries(c.ends).map(([role, e]) => ` ${lit(role)}${required(e) ? '' : '?'}: ${e.many ? 'string[]' : 'string'};`);
142
+ out.push(`export interface ${c.name}Links {${ends.length ? `\n${ends.join('\n')}\n` : ''}}`);
143
+ // концы с классами целей и предки — переходы, слоты и семейства строгих типов (typed.ts: ModelLike)
144
+ const specs = Object.entries(c.ends).map(([role, e]) => {
145
+ const targets = (Array.isArray(e.class) ? e.class : [e.class]).map(lit).join(' | ');
146
+ return ` ${lit(role)}: { targets: ${targets}; many: ${e.many === true}; required: ${required(e)} };`;
147
+ });
148
+ out.push(`export interface ${c.name}Model {`, ` readonly name: ${lit(c.name)};`, ...(c.alias ? [` readonly alias: ${lit(c.alias)};`] : []), ' readonly __types?: {', ` data: ${c.name}Data;`, ` links: ${c.name}Links;`, ` ends: {${specs.length ? `\n${specs.join('\n')}\n ` : ''}};`, ` ancestors: ${(c.ancestors.length ? c.ancestors : [c.name]).map(lit).join(' | ')};`, ' };', '}', '');
149
+ models.push(`${c.name}Model`);
128
150
  }
151
+ out.push(`export type Models = readonly [${models.join(', ')}];`, '');
129
152
  return out.join('\n');
130
153
  }
package/dist/tx.d.ts CHANGED
@@ -93,6 +93,16 @@ export interface TxContext {
93
93
  passes?: AclPasses;
94
94
  /** После успешной операции (продление сессии раз в минуту). */
95
95
  after?: () => void;
96
+ /**
97
+ * Сессия кончилась (no_session): выдать новую и вписать её токен в failed — у подключения по ключу APIKEY ключ
98
+ * обменивается заново. true — операцию можно повторить (один раз). Нет — no_session как есть.
99
+ */
100
+ renew?: (failed: TxContext) => Promise<boolean>;
101
+ /**
102
+ * Перед операцией (runTx, ExplicitTx.open): после простоя подключение проверяет, жива ли сессия, — иначе чтение под
103
+ * истёкшей сессией шло бы как анонимное и молча отдавало пустой ответ. Обновляет токен ctx, если сессию выдали заново.
104
+ */
105
+ before?: (ctx: TxContext) => Promise<void>;
96
106
  /**
97
107
  * После фиксации записи: нагрузки из letopis.changed (сигналы записи, session:<тег>) — кэш
98
108
  * результатов сбрасывает свои ответы сразу (§2.12); raw — запись через db.sql.
@@ -136,7 +146,7 @@ export declare class ExplicitTx implements Executor {
136
146
  /** В транзакции был сырой SQL (db.sql): после фиксации — весь кэш. */
137
147
  private raw;
138
148
  private constructor();
139
- static open(ctx: TxContext): Promise<ExplicitTx>;
149
+ static open(ctx: TxContext, renewed?: boolean): Promise<ExplicitTx>;
140
150
  /** Транзакция ещё открыта (не зафиксирована и не откачена). */
141
151
  get active(): boolean;
142
152
  run<T>(fn: (tx: Tx) => Promise<T>, opts?: {