letopis 1.4.1 → 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 (113) hide show
  1. package/AGENT-CHEATSHEET.md +329 -278
  2. package/CHANGELOG.md +558 -1
  3. package/README.md +606 -1037
  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 -175
  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 +33 -10
  12. package/dist/chain.d.ts +19 -41
  13. package/dist/chain.js +169 -201
  14. package/dist/cli.js +51 -88
  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 -71
  21. package/dist/index.js +245 -130
  22. package/dist/internal.d.ts +12 -0
  23. package/dist/internal.js +5 -0
  24. package/dist/jsonschema.js +18 -17
  25. package/dist/load.d.ts +20 -12
  26. package/dist/load.js +225 -186
  27. package/dist/mcp.d.ts +5 -7
  28. package/dist/mcp.js +171 -215
  29. package/dist/messages/access.d.ts +90 -0
  30. package/dist/messages/access.js +113 -0
  31. package/dist/messages/api.d.ts +171 -0
  32. package/dist/messages/api.js +215 -0
  33. package/dist/messages/boot.d.ts +201 -0
  34. package/dist/messages/boot.js +256 -0
  35. package/dist/messages/codes.d.ts +172 -0
  36. package/dist/messages/codes.js +173 -0
  37. package/dist/messages/index.d.ts +95 -0
  38. package/dist/messages/index.js +118 -0
  39. package/dist/messages/mcp.d.ts +306 -0
  40. package/dist/messages/mcp.js +387 -0
  41. package/dist/messages/model.d.ts +233 -0
  42. package/dist/messages/model.js +283 -0
  43. package/dist/messages/query.d.ts +131 -0
  44. package/dist/messages/query.js +158 -0
  45. package/dist/messages/sql-access.d.ts +70 -0
  46. package/dist/messages/sql-access.js +87 -0
  47. package/dist/messages/sql-core.d.ts +150 -0
  48. package/dist/messages/sql-core.js +181 -0
  49. package/dist/messages/sql-time.d.ts +71 -0
  50. package/dist/messages/sql-time.js +88 -0
  51. package/dist/messages/sql-write.d.ts +246 -0
  52. package/dist/messages/sql-write.js +270 -0
  53. package/dist/messages/time.d.ts +23 -0
  54. package/dist/messages/time.js +29 -0
  55. package/dist/messages/types.d.ts +27 -0
  56. package/dist/messages/types.js +5 -0
  57. package/dist/messages/write.d.ts +405 -0
  58. package/dist/messages/write.js +509 -0
  59. package/dist/model.d.ts +73 -60
  60. package/dist/model.js +26 -29
  61. package/dist/ops.d.ts +35 -0
  62. package/dist/ops.js +101 -0
  63. package/dist/pglite.d.ts +0 -9
  64. package/dist/pglite.js +3 -1
  65. package/dist/registry.d.ts +3 -19
  66. package/dist/registry.js +3 -16
  67. package/dist/signals.d.ts +5 -9
  68. package/dist/signals.js +5 -9
  69. package/dist/sql.d.ts +13 -6
  70. package/dist/sql.js +176 -83
  71. package/dist/sync.d.ts +9 -3
  72. package/dist/sync.js +51 -21
  73. package/dist/tx.d.ts +26 -1
  74. package/dist/tx.js +44 -14
  75. package/dist/typed.d.ts +240 -45
  76. package/dist/types.d.ts +23 -91
  77. package/dist/types.js +12 -14
  78. package/dist/up.d.ts +53 -32
  79. package/dist/up.js +184 -73
  80. package/dist/uuid.js +5 -5
  81. package/dist/validate.d.ts +13 -1
  82. package/dist/validate.js +61 -29
  83. package/dist/watch.d.ts +11 -10
  84. package/dist/watch.js +33 -70
  85. package/dist/write.d.ts +9 -16
  86. package/dist/write.js +118 -187
  87. package/llms.txt +22 -25
  88. package/package.json +8 -11
  89. package/sql/10-core.sql +7 -50
  90. package/sql/15-errors.sql +64 -10
  91. package/sql/20-context.sql +7 -6
  92. package/sql/30-validate.sql +35 -27
  93. package/sql/40-class.sql +20 -114
  94. package/sql/50-acl.sql +8 -13
  95. package/sql/60-write.sql +220 -667
  96. package/sql/70-read.sql +20 -16
  97. package/sql/80-auth.sql +24 -27
  98. package/sql/90-time.sql +70 -859
  99. package/sql/95-seed.system.sql +7 -14
  100. package/sql/99-revision.sql +2 -1
  101. package/sql/README.md +32 -24
  102. package/sql/migrate/README.md +14 -0
  103. package/AGENT-CHEATSHEET.en.md +0 -485
  104. package/MIGRATION.md +0 -190
  105. package/README.en.md +0 -2859
  106. package/dist/import.d.ts +0 -185
  107. package/dist/import.js +0 -820
  108. package/dist/project.d.ts +0 -350
  109. package/dist/project.js +0 -1224
  110. package/sql/75-history.sql +0 -358
  111. package/sql/88-derived.sql +0 -1235
  112. package/sql/migrate/0006-changed-paths.sql +0 -3
  113. package/sql/migrate/0007-secret-account-fk.sql +0 -12
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
- out.frags.push((a) => `@S@.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
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,46 +463,80 @@ 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
  }
478
+ /** rev курсора: в versions() отделяет версии одного объекта с равным полем сортировки (план 2.0, 7.1 №4). */
479
+ function cursorRev(c) {
480
+ const rev = c.rev;
481
+ if (rev === undefined)
482
+ return undefined;
483
+ if (!Number.isInteger(rev) || rev < 1)
484
+ throw lerr('query.cursor_rev', { rev: shown(rev) });
485
+ return rev;
486
+ }
487
+ /** id узлов пути из курсора paths() по ключам узлов (план 2.0, 7.1 №3); курсор без path — undefined. */
488
+ function cursorPath(c, keys) {
489
+ const path = c.path;
490
+ if (path === undefined)
491
+ return undefined;
492
+ return keys.map((k) => {
493
+ const id = typeof path === 'object' && path !== null ? path[k] : undefined;
494
+ if (typeof id !== 'string' || !UUID_RE.test(id))
495
+ throw lerr('query.cursor_path', { key: k });
496
+ return id;
497
+ });
498
+ }
499
+ const isPath = (x) => typeof x.id !== 'string'
500
+ && Object.values(x).length > 0 && Object.values(x).every((n) => typeof n === 'object' && n !== null && typeof n.id === 'string');
425
501
  /**
426
- * Курсор постраничного вывода для `after()`: `{ v, id }` — значение поля сортировки и id последней строки страницы.
427
- * Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`.
502
+ * Курсор постраничного вывода для `after()`: значение поля сортировки, id и номер версии последней строки страницы.
503
+ * Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`. Для пути из `paths()` — то же по его
504
+ * последнему узлу (узлу последнего шага) и id всех узлов: курсор отделяет пути с общим последним узлом.
428
505
  */
429
506
  export function cursorOf(row, field) {
507
+ if (isPath(row)) {
508
+ const keys = Object.keys(row);
509
+ const { v, id } = cursorOf(row[keys[keys.length - 1]], field);
510
+ return { v, id, path: Object.fromEntries(keys.map((k) => [k, row[k].id])) };
511
+ }
430
512
  const f = field === 'updated' ? 'at' : field;
513
+ const rev = typeof row.rev === 'number' ? { rev: row.rev } : {};
431
514
  if (f === 'at' || f === 'rev' || f === 'id')
432
- return { v: row[f], id: row.id };
515
+ return { v: row[f], id: row.id, ...rev };
433
516
  if (f.startsWith('data.')) {
434
517
  let v = row.data;
435
518
  for (const k of f.slice(5).split('.'))
436
519
  v = v?.[k];
437
- return { v: (v ?? null), id: row.id };
520
+ return { v: (v ?? null), id: row.id, ...rev };
438
521
  }
439
- return fail(`cursorOf: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${field}'`);
522
+ throw lerr('query.cursor_of_field', { field });
440
523
  }
441
524
  // ---------------------------------------------------------------------------
442
525
  // Построение запроса
443
526
  // ---------------------------------------------------------------------------
444
- const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason', 'moved'];
445
- /** Колонки журнала, выданные приложению: все, кроме changed (ревью №1 точки А плана 1.1, 10-core.sql). */
446
- const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason',
447
- 'moved', 'prev_hash', 'hash'].map((c) => 'l.' + c).join(', ');
527
+ /**
528
+ * Метка схемы во фрагментах, собранных до имени схемы: символ NUL не бывает в тексте PostgreSQL, поэтому замена не
529
+ * заденет литералы — имя поля вида a@S@b прежде портилось (план 2.0, 7.1 №5).
530
+ */
531
+ const SCHEMA_MARK = '\u0000S\u0000';
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(', ');
448
536
  const colsOf = (a) => COLS.map((c) => `${a}.${c}`).join(', ');
449
537
  export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
450
538
  if (!steps.length)
451
- fail('пустая цепочка');
539
+ throw lerr('query.empty_chain');
452
540
  const S = `"${schema.replace(/"/g, '""')}"`;
453
541
  const p = new Params();
454
542
  const cur = mods.asOf === undefined;
@@ -460,7 +548,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
460
548
  if (s.pivotKey !== undefined) {
461
549
  const at = byNodeKey.get(s.pivotKey);
462
550
  if (at === undefined)
463
- fail(`возврат к узлу ${s.name}: узла нет в этом пути`);
551
+ throw lerr('query.pivot_missing', { name: s.name });
464
552
  real[i] = at;
465
553
  }
466
554
  else {
@@ -478,8 +566,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
478
566
  for (let i = 0; i < steps.length; i++) {
479
567
  const step = steps[i];
480
568
  const names = stepClasses(reg, step);
481
- const infos = names.map((n) => reg.find(n)).filter((c) => !!c);
482
- const typing = [step.cls, ...infos.filter((c) => c !== step.cls)];
569
+ const typing = typingOf(reg, step, names);
483
570
  // возврат к узлу: только дофильтры
484
571
  if (step.pivotKey !== undefined) {
485
572
  const a = `h${real[i]}`;
@@ -526,8 +613,9 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
526
613
  return `(select v.id, v.rev from ${S}.entity v where v.class = any (${clsPh})${dataCond('v')}${endsCond('v.ends')}`
527
614
  + `${ids ? ` and v.id = any (${ids})` : ''})`;
528
615
  }
529
- return `(select v.id, v.rev from ${versionsAt(at, ids ? `l.id = any (${ids})` : `l.class = any (${clsPh})`)} v `
530
- + `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)`)})`;
616
+ // по классу: id-кандидаты классов шага, последняя версия — любого класса (как find_ids, план 2.0, 7.1 №1)
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 `
618
+ + `where v.class = any (${clsPh}) and (v.op = 'delete') = ${isDead}${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)})`;
531
619
  };
532
620
  const entityPart = (src, depth = 'null::int', from = `${S}.entity x`) => `select ${colsOf('x')}, ${depth} as depth from ${from} where ${where('x', src)}`;
533
621
  /**
@@ -544,7 +632,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
544
632
  const parts = [];
545
633
  if (i === 0) {
546
634
  if (step.deepMax !== undefined)
547
- fail('deep() — на шаге перехода к тому же классу, а не на первом шаге');
635
+ throw lerr('query.deep_first_step');
548
636
  if (cur) {
549
637
  // условие id уже в conds — первичный ключ; вложение — поиск по индексу GIN
550
638
  parts.push(!cf.ids && (cf.eq || cf.ends.length) ? entityByIds(`select f.id from ${findIds(null, 'null', false)} f`) : entityPart(undefined));
@@ -565,15 +653,15 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
565
653
  const rolesCond = (v, src) => (hop.roles ? ` and ${S}.role_has(${v}.links, ${rolesPh}, ${src})` : '');
566
654
  if (step.deepMax !== undefined) {
567
655
  if (hop.dir !== 'reverse' || !stepClasses(reg, steps[a]).some((c) => names.includes(c))) {
568
- fail(`deep() — только на переходе к тому же классу (дети любой глубины): ${steps[a].name} → ${step.name}`);
656
+ throw lerr('query.deep_hop', { from: steps[a].name, to: step.name });
569
657
  }
570
658
  if (dead)
571
- fail('deep() и withDeleted() не сочетаются');
659
+ throw lerr('query.deep_with_deleted');
572
660
  const maxPh = `${p.push(step.deepMax)}::int`;
573
661
  let deepSrc = `${S}.find_deep(${clsPh}, ${rolesPh}, ${srcArr}, ${maxPh}, ${atPh})`;
574
662
  if (opts.direct) {
575
663
  // тот же обход под RLS: невидимый узел обрывает поддерево политикой
576
- 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')`;
577
665
  const hasSrc = (v, src) => (cur ? `${v}.ends @> array[${src}]` : `${S}.link_ids(${v}.links) @> array[${src}]`);
578
666
  const cls = (v) => (cur ? ` and ${v}.class = any (${clsPh})` : '');
579
667
  deepSrc = `(with recursive d(src, id, rev, depth, seen) as (`
@@ -609,7 +697,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
609
697
  }
610
698
  return `(select s.src, v.id, v.rev from ${versionsAt(at, `l.class = any (${clsPh})`)} v `
611
699
  + `cross join lateral unnest(${S}.link_ids(v.links)) t(x) join ${srcs} on s.src = t.x `
612
- + `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')})`;
613
701
  };
614
702
  ctes.push(`p${i}a as (select q.src, q.id, q.rev from ${hopCall(atPh, false)} q)`);
615
703
  // пары переходов — по одной на (источник, цель): объект, который ссылается на несколько объектов прошлого шага,
@@ -650,43 +738,59 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
650
738
  const single = tail.length === 0 && !steps.some((st) => st.pivotKey !== undefined);
651
739
  const ents = single ? `n${lastNode}` : `(select z0.* from n${lastNode} z0 where z0.id in (select h${lastNode}.id ${from}${whereOf(tail)}))`;
652
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);
653
743
  const sort = mods.order ? sortSpec(mods.order, leaf) : undefined;
654
744
  const dir = mods.desc ? ' desc' : '';
655
- const orderBy = (a) => (sort ? ` order by ${sort.expr(a)}${dir}, ${a}.id${dir}` : '');
656
- const after = (a) => {
745
+ /** Порядок: поле sort, id строки и разделители равных (id прочих узлов пути). */
746
+ const orderBy = (a, more = []) => (sort ? ` order by ${[sort.expr(a), `${a}.id`, ...more].map((x) => `${x}${dir}`).join(', ')}` : '');
747
+ /**
748
+ * «Строго после курсора» в порядке order by: поле sort, id строки и разделители равных из курсора — rev версии
749
+ * (versions()) или id прочих узлов пути (paths()). Без них страница начиналась бы после всех строк с тем же полем
750
+ * и id: терялись бы другие версии объекта и другие пути к тому же узлу. Курсор без rev или path сравнивается по
751
+ * началу порядка, как прежде.
752
+ */
753
+ const after = (a, ties = () => []) => {
657
754
  if (!mods.after)
658
755
  return undefined;
659
756
  if (!sort)
660
- fail('after(cursor) требует sort(поле)');
757
+ throw lerr('query.after_without_sort');
661
758
  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)`;
759
+ const t = ties(mods.after);
760
+ const lt = mods.desc ? '<' : '>';
664
761
  // Поле данных бывает null (или его нет): order by ставит такие строки в конец при возрастании и в
665
762
  // начало при убывании, а сравнение строк с null даёт null — одно сравнение их теряло, курсор null
666
763
  // давал пустую страницу. Здесь null — значение после всех прочих (asc) или перед ними (desc); текст
667
764
  // один на оба вида курсора (параметр — с явным приведением: он стоит и в is null).
765
+ const v = `${p.push(c.v)}${sort.nullable ? sort.pcast || '::text' : sort.pcast}`;
766
+ const cols = [`${a}.id`, ...t.map((x) => x.col)];
767
+ const vals = [`${p.push(c.id)}::uuid`, ...t.map((x) => `${p.push(x.val)}${x.cast}`)];
768
+ if (!sort.nullable)
769
+ return `(${sort.expr(a)}, ${cols.join(', ')}) ${lt} (${v}, ${vals.join(', ')})`;
668
770
  const e = `(${sort.expr(a)})`;
669
- const v = `${p.push(c.v)}${sort.pcast || '::text'}`;
670
- const id = `${p.push(c.id)}::uuid`;
771
+ const rest = cols.length === 1 ? `${cols[0]} ${lt} ${vals[0]}` : `(${cols.join(', ')}) ${lt} (${vals.join(', ')})`;
671
772
  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})))`;
773
+ ? `((${e}, ${cols.join(', ')}) < (${v}, ${vals.join(', ')}) or (${v} is null and (${e} is not null or ${rest})))`
774
+ : `((${e}, ${cols.join(', ')}) > (${v}, ${vals.join(', ')}) or (${e} is null and (${v} is not null or ${rest})))`;
674
775
  };
776
+ // ключи пути — в порядке шагов, узел последнего шага (его строки отдают rows()) — последним: по нему cursorOf(путь)
777
+ // берёт поле и id курсора, по прочим — разделители путей к одному узлу (план 2.0, 7.1 №3)
778
+ const others = keyed.filter((k) => k.node !== lastNode);
779
+ const pathKeys = [...others, ...keyed.filter((k) => k.node === lastNode)].map((k) => k.key);
675
780
  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), в обе стороны
677
- 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)`);
681
- }
682
- const withClause = `with ${mode === 'versions' && mods.follow ? 'recursive ' : ''}${ctes.join(',\n')}\n`;
781
+ const withClause = `with ${ctes.join(',\n')}\n`;
683
782
  let text;
684
783
  let aggLeaf;
685
784
  switch (mode) {
686
785
  case 'paths': {
687
786
  const obj = keyed.map(({ key, node }) => `${lit(key)}::text, to_jsonb(h${node})`).join(', ');
688
787
  const h = `h${lastNode}`;
689
- text = `${withClause}select jsonb_build_object(${obj}) as path ${from}${whereOf([...tail, after(h)])}${orderBy(h)}${limitOffset()}`;
788
+ const ties = (c) => {
789
+ const ids = cursorPath(c, others.map((k) => k.key));
790
+ return ids ? others.map((k, j) => ({ col: `h${k.node}.id`, val: ids[j], cast: '::uuid' })) : [];
791
+ };
792
+ text = `${withClause}select jsonb_build_object(${obj}) as path ${from}${whereOf([...tail, after(h, ties)])}`
793
+ + `${orderBy(h, others.map((k) => `h${k.node}.id`))}${limitOffset()}`;
690
794
  break;
691
795
  }
692
796
  case 'rows':
@@ -701,31 +805,22 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
701
805
  break;
702
806
  case 'versions': {
703
807
  // с sort — по полю и направлению (последние версии первыми — sort('at', 'desc'), с limit — в самом запросе);
704
- // без sort: с follow — по времени через все связанные id, без follow — по id и номеру версии
705
- 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';
706
- 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()}`
709
- : `${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()}`;
711
- break;
712
- }
713
- case 'diff':
714
- case 'blame': {
715
- // план 1.1, §2.1: функции базы считают по журналу под RLS — по одному документу на объект шага
716
- const h = mods.hist ?? {};
717
- const num = (v) => (typeof v === 'number' ? `${p.push(v)}::int` : 'null');
718
- const ts = (v) => (typeof v === 'string' ? `${p.push(v)}::text::timestamptz` : 'null');
719
- const call = mode === 'diff'
720
- ? `${S}.diff_versions(z.id, ${num(h.from)}, ${num(h.to)}, ${ts(h.from)}, ${ts(h.to)}, ${h.follow ? 'true' : 'false'})`
721
- : `${S}.blame(z.id, ${ts(h.at)}, ${num(h.limit)})`;
722
- text = `${withClause}select ${call} as row from (select distinct z.id from ${ents} z) z order by z.id`;
808
+ // без sort — по id и номеру версии
809
+ const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` : 'v.id, v.rev';
810
+ // курсор — поле sort, id и номер версии: версии одного объекта с равным полем не теряются (план 2.0, 7.1 №4)
811
+ const cond = whereOf([after('v', (c) => {
812
+ const rev = cursorRev(c);
813
+ return rev === undefined ? [] : [{ col: 'v.rev', val: rev, cast: '::int' }];
814
+ })]);
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()}`;
723
817
  break;
724
818
  }
725
819
  case 'agg': {
726
820
  if (!agg || !agg.field.startsWith('data.'))
727
- fail("агрегат по полю 'data.<путь>'");
821
+ throw lerr('query.agg_field');
728
822
  const path = agg.field.slice(5).split('.');
823
+ checkField(rowsNode(lastNode), path[0], `${agg.fn}('${agg.field}')`, false);
729
824
  const acc = accessor(path)('z');
730
825
  aggLeaf = leaf(path);
731
826
  if (agg.fn === 'countBy') {
@@ -741,18 +836,16 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
741
836
  break;
742
837
  }
743
838
  }
744
- return { text: text.replaceAll('@S@', S), params: p.list, keys: keyed.map((k) => k.key), aggLeaf };
839
+ return { text: text.replaceAll(SCHEMA_MARK, S), params: p.list, keys: pathKeys, aggLeaf };
745
840
  }
746
841
  /** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
747
842
  export function toRow(raw) {
748
- const { depth, agent, reason, moved, changed: _changed, ...rest } = raw;
843
+ const { depth, agent, reason, ...rest } = raw;
749
844
  const row = rest;
750
845
  if (agent != null)
751
846
  row.agent = agent;
752
847
  if (reason != null)
753
848
  row.reason = reason;
754
- if (moved != null)
755
- row.moved = moved;
756
849
  if (row.op === 'delete')
757
850
  row.$deleted = true;
758
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;