letopis 1.4.2 → 3.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 (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/sql.js CHANGED
@@ -17,15 +17,44 @@
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
+ import { PG_UUID_RE, UUID_RE } from './uuid.js';
23
24
  let nodeSeq = 0;
24
25
  /** Уникальная метка узла пути. */
25
26
  export const nextNodeKey = () => ++nodeSeq;
26
- const fail = (msg) => {
27
- throw new LetopisError('invalid_query', msg);
27
+ /** Отказ построителя по ключу каталога — там, где нужно выражение: `x ?? refuse(ключ)`; в операторах — throw lerr(…). */
28
+ const refuse = (key, params) => {
29
+ throw lerr(key, params);
28
30
  };
31
+ /**
32
+ * Поля data строк системного класса Class — свойства метасхемы описания класса (class_metaschema() в 40-class.sql): строки
33
+ * Class проверяет она, а в описании самого класса Class только name. Ключи — свойства описания (ClassDescription) и
34
+ * служебные system и resolved: новое свойство описания без строки здесь — ошибка типов.
35
+ */
36
+ const CLASS_ROW_FIELDS = Object.keys({
37
+ name: true, kind: true, parent: true, abstract: true, alias: true, system: true, fields: true, ends: true, key: true,
38
+ history: true, ownerDefault: true, cache: true, meta: true, resolved: true,
39
+ });
40
+ /** Поля верхнего уровня data строк класса: он закрыт (class_resolve, 40-class.sql) — в нём только поля описания. */
41
+ const dataFields = (c) => c.name === 'Class' ? CLASS_ROW_FIELDS : Object.keys((c.schema.properties ?? {}));
42
+ /**
43
+ * Поле фильтра, sort() или агрегата не описано ни в одном классе шага — такое условие ничего не найдёт: invalid_query с
44
+ * похожими именами (план 2.0, этап 3, п. 3; в 1.5 было предупреждение, 7.9 №6). Проверяется только верхний уровень:
45
+ * путь внутрь поля-объекта не сверяется. classes — класс шага и все классы, которые он матчит (потомки, если не exact(),
46
+ * у роли — её цели); ends — роли концов тоже годятся (фильтр).
47
+ */
48
+ function checkField(classes, field, where, ends) {
49
+ if (!classes.length)
50
+ return;
51
+ if (classes.some((c) => dataFields(c).includes(field) || (ends && c.ends[field] !== undefined)))
52
+ return;
53
+ const names = classes.flatMap((c) => [...dataFields(c), ...(ends ? Object.keys(c.ends) : [])]);
54
+ throw lerr('query.unknown_field', { where, field, cls: classes[0].name, similar: similar(field, names) });
55
+ }
56
+ /** Поле sort('data.<путь>') или агрегата: первый ключ пути. */
57
+ const topField = (field) => field.slice(5).split('.')[0];
29
58
  // ---------------------------------------------------------------------------
30
59
  // Классы шага и правила переходов
31
60
  // ---------------------------------------------------------------------------
@@ -47,6 +76,24 @@ export function stepClasses(reg, step) {
47
76
  return base;
48
77
  return uniq(base.flatMap((b) => (reg.has(b) ? reg.family(b).map((c) => c.name) : [b])));
49
78
  }
79
+ /** Классы шага для типов листьев и сверки полей: класс шага первым, затем прочие классы, которые шаг матчит. */
80
+ function typingOf(reg, step, names = stepClasses(reg, step)) {
81
+ const infos = names.map((n) => reg.find(n)).filter((c) => !!c);
82
+ return [step.cls, ...infos.filter((c) => c !== step.cls)];
83
+ }
84
+ /**
85
+ * Отказы построителя, которым не нужны база и путь, — фильтр шага (в том числе неизвестное поле) и поле sort('data.…') или
86
+ * агрегата по классам шага: план записи проверяет их до первого оператора (write.ts:precheckPlan, §2.5), поэтому
87
+ * опечатка в фильтре цели не ломает db.begin(). Построитель при чтении проверяет то же самое ещё раз.
88
+ */
89
+ export function precheckStep(reg, step, order, agg) {
90
+ const typing = typingOf(reg, step);
91
+ compileFilter(reg, typing, step.filter, new Params());
92
+ if (order?.startsWith('data.'))
93
+ checkField(typing, topField(order), `sort('${order}')`, false);
94
+ if (agg?.field.startsWith('data.'))
95
+ checkField(typing, topField(agg.field), `${agg.fn}('${agg.field}')`, false);
96
+ }
50
97
  /** Принимает ли список классов конца класс cls (с учётом его предков). */
51
98
  export const accepts = (reg, targets, cls) => (reg.find(cls)?.ancestors ?? [cls]).some((a) => targets.includes(a));
52
99
  /** Роли классов from, цели которых принимают хотя бы один класс to. */
@@ -85,7 +132,7 @@ export function resolveHop(reg, prev, next) {
85
132
  if ((dir === 'forward' ? fwd : rev).length)
86
133
  return { dir, roles: null };
87
134
  }
88
- throw new LetopisError('no_path', `нет перехода ${prev.name} → ${next.name}: ни один класс не ссылается на другой`);
135
+ throw lerr('query.no_path', { from: prev.name, to: next.name });
89
136
  }
90
137
  // ---------------------------------------------------------------------------
91
138
  // Фильтры и операторы
@@ -116,12 +163,33 @@ function leafOf(reg, classes, path) {
116
163
  }
117
164
  return 'jsonb';
118
165
  }
119
- function compileOp(path, leaf, o, p) {
166
+ /**
167
+ * Тип поля у классов шага не один (потомок переопределил тип: Base.price — число, Child.price — строка; у другой цели
168
+ * полиморфного конца поля нет или оно другого типа): приведение к типу листа должно быть безопасным — значение, которое
169
+ * к нему не приводится, сравнивается и сортируется как null, а не роняет весь запрос ошибкой приведения.
170
+ */
171
+ function leafMixed(reg, classes, path, leaf) {
172
+ return !!CAST[leaf] && classes.some((c) => reg.leafType(c.name, path) !== leaf);
173
+ }
174
+ /** Имя типа для pg_input_is_valid по типу листа. */
175
+ const PG_TYPE = {
176
+ text: 'text', jsonb: 'jsonb', numeric: 'numeric', boolean: 'boolean', timestamptz: 'timestamptz', date: 'date', uuid: 'uuid',
177
+ };
178
+ /**
179
+ * Значение поля, приведённое к типу листа (скобки обязательны: '::' сильнее '->>'). safe — тип поля в семействе
180
+ * разный (leafMixed): то, что не приводится, — null.
181
+ */
182
+ function castLeaf(expr, leaf, safe) {
183
+ const cast = CAST[leaf];
184
+ if (!cast)
185
+ return expr;
186
+ return safe ? `(case when pg_input_is_valid(${expr}, ${lit(PG_TYPE[leaf])}) then (${expr})${cast} end)` : `(${expr})${cast}`;
187
+ }
188
+ function compileOp(path, leaf, o, p, safe = false) {
120
189
  const lhs = accessor(path);
121
190
  const cast = CAST[leaf];
122
191
  const pc = PCAST[leaf];
123
- // скобки обязательны: '::' сильнее '->>'
124
- const L = cast ? (a) => `(${lhs(a)})${cast}` : lhs;
192
+ const L = cast ? (a) => castLeaf(lhs(a), leaf, safe) : lhs;
125
193
  const args = o.args;
126
194
  switch (o[OP]) {
127
195
  case 'ne':
@@ -182,13 +250,13 @@ function compileOp(path, leaf, o, p) {
182
250
  return (a) => `${lhs(a)} is null`;
183
251
  case 'not': {
184
252
  if (isOp(args[0])) {
185
- const inner = compileOp(path, leaf, args[0], p);
253
+ const inner = compileOp(path, leaf, args[0], p, safe);
186
254
  return (a) => `not coalesce(${inner(a)}, false)`;
187
255
  }
188
- return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p);
256
+ return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p, safe);
189
257
  }
190
258
  default:
191
- return fail(`неизвестный оператор ${String(o[OP])}`);
259
+ throw lerr('query.unknown_op', { op: String(o[OP]) });
192
260
  }
193
261
  }
194
262
  /** Колонка tags: строка | список (все) | has / hasAny / hasAll. */
@@ -215,19 +283,31 @@ function compileTags(v, p) {
215
283
  }
216
284
  }
217
285
  }
218
- return fail('tags() принимает строку, список или has/hasAny/hasAll');
286
+ throw lerr('query.tags_arg');
219
287
  }
220
288
  const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
221
289
  /** Объект-строка ответа как значение: берётся id. */
222
290
  const idOfValue = (v) => typeof v === 'string' ? v : isPlain(v) && typeof v.id === 'string' ? v.id : undefined;
291
+ /**
292
+ * id фильтра шага, `{ id }` или конца — uuid, который примет приведение базы (PG_UUID_RE): иначе invalid_query до запроса,
293
+ * а не сырая ошибка PostgreSQL 22P02 (у null — TypeError; план 2.1, A7).
294
+ */
295
+ function idCheck(v, where) {
296
+ if (typeof v === 'string' && PG_UUID_RE.test(v))
297
+ return v;
298
+ throw lerr('query.bad_uuid', { where, value: shown(v) });
299
+ }
223
300
  function compileFilter(reg, classes, filter, p) {
224
301
  const out = { ends: [], frags: [] };
225
302
  if (filter === undefined)
226
303
  return out;
304
+ const at = `${classes[0]?.name ?? ''}(…)`;
305
+ if (filter === null)
306
+ idCheck(filter, at);
227
307
  // or(…): дизъюнкция под-фильтров — только перепроверка
228
308
  if (isOp(filter)) {
229
309
  if (filter[OP] !== 'or')
230
- fail(`оператор ${String(filter[OP])} на уровне фильтра шага — допустим только or(…)`);
310
+ throw lerr('query.step_op', { op: String(filter[OP]) });
231
311
  const parts = filter.args[0].map((f) => {
232
312
  const sub = compileFilter(reg, classes, f, p);
233
313
  const frags = [...asFrags(sub, p)];
@@ -237,11 +317,11 @@ function compileFilter(reg, classes, filter, p) {
237
317
  return out;
238
318
  }
239
319
  if (typeof filter === 'string') {
240
- out.ids = [filter];
320
+ out.ids = [idCheck(filter, at)];
241
321
  return out;
242
322
  }
243
323
  if (Array.isArray(filter)) {
244
- out.ids = filter.map((x) => idOfValue(x) ?? fail('список id: ожидались строки'));
324
+ out.ids = filter.map((x) => idCheck(idOfValue(x) ?? refuse('query.id_list'), at));
245
325
  return out;
246
326
  }
247
327
  const props = (classes[0]?.schema.properties ?? {});
@@ -257,7 +337,8 @@ function compileFilter(reg, classes, filter, p) {
257
337
  continue;
258
338
  const at = [...path, k];
259
339
  if (isOp(v)) {
260
- out.frags.push(compileOp(at, leafOf(reg, classes, at), v, p));
340
+ const t = leafOf(reg, classes, at);
341
+ out.frags.push(compileOp(at, t, v, p, leafMixed(reg, classes, at, t)));
261
342
  hadOps = true;
262
343
  }
263
344
  else if (isPlain(v)) {
@@ -278,12 +359,13 @@ function compileFilter(reg, classes, filter, p) {
278
359
  if (value === undefined)
279
360
  continue;
280
361
  if (key === 'id') {
362
+ const w = `${classes[0]?.name ?? ''}({ id })`;
281
363
  if (typeof value === 'string')
282
- out.ids = [value];
364
+ out.ids = [idCheck(value, w)];
283
365
  else if (Array.isArray(value))
284
- out.ids = value.map((x) => idOfValue(x) ?? fail('filter.id: ожидались строки'));
366
+ out.ids = value.map((x) => idCheck(idOfValue(x) ?? refuse('query.filter_id_list'), w));
285
367
  else
286
- fail('filter.id принимает строку или список строк');
368
+ throw lerr('query.filter_id');
287
369
  continue;
288
370
  }
289
371
  if (isRole(key)) {
@@ -292,14 +374,17 @@ function compileFilter(reg, classes, filter, p) {
292
374
  out.frags.push((a) => `coalesce(${a}.links->${role}, 'null'::jsonb) in ('null'::jsonb, '[]'::jsonb)`);
293
375
  continue;
294
376
  }
295
- const id = idOfValue(value) ?? fail(`конец ${key}: ожидался id, строка ответа или null`);
377
+ const id = idCheck(idOfValue(value) ?? refuse('query.end_value', { role: key }), `${classes[0]?.name ?? ''}({ ${key} })`);
296
378
  out.ends.push(id);
297
379
  const ph = p.push(id);
298
380
  out.frags.push((a) => `${SCHEMA_MARK}.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
299
381
  continue;
300
382
  }
383
+ // поле данных: не описано ни в одном классе шага — invalid_query с похожими (план 2.0, этап 3, п. 3)
384
+ checkField(classes, key, `${classes[0]?.name ?? ''}({ ${key} })`, true);
301
385
  if (isOp(value)) {
302
- out.frags.push(compileOp([key], leafOf(reg, classes, [key]), value, p));
386
+ const t = leafOf(reg, classes, [key]);
387
+ out.frags.push(compileOp([key], t, value, p, leafMixed(reg, classes, [key], t)));
303
388
  continue;
304
389
  }
305
390
  if (isPlain(value)) {
@@ -318,8 +403,12 @@ function compileFilter(reg, classes, filter, p) {
318
403
  function asFrags(cf, p) {
319
404
  const frags = [...cf.frags];
320
405
  if (cf.ids) {
321
- const ph = p.push(cf.ids);
322
- frags.push((a) => `${a}.id = any (${ph}::uuid[])`);
406
+ // один id — равенство, а не = any (массив): общий план подготовленного запроса для = any PostgreSQL считает на ~10
407
+ // элементов, и при неудачной выборке ANALYZE чтение по ключу навсегда оставалось на планах под каждый вызов —
408
+ // 0,5–0,8 мс планирования на чтение (замер против 1.0.1); общий план равенства стоит как план вызова и выбирается всегда
409
+ const one = cf.ids.length === 1;
410
+ const ph = p.push(one ? cf.ids[0] : cf.ids);
411
+ frags.push((a) => (one ? `${a}.id = ${ph}::uuid` : `${a}.id = any (${ph}::uuid[])`));
323
412
  }
324
413
  if (cf.eq) {
325
414
  const ph = p.push(cf.eq);
@@ -327,7 +416,7 @@ function asFrags(cf, p) {
327
416
  }
328
417
  return frags;
329
418
  }
330
- function sortSpec(order, leaf) {
419
+ function sortSpec(order, leaf, mixed) {
331
420
  const f = order === 'updated' ? 'at' : order;
332
421
  const col = { field: order, nullable: false };
333
422
  if (f === 'at')
@@ -339,12 +428,16 @@ function sortSpec(order, leaf) {
339
428
  if (f.startsWith('data.')) {
340
429
  const path = f.slice(5).split('.');
341
430
  const t = leaf(path);
342
- return { expr: (a) => (CAST[t] ? `(${accessor(path)(a)})${CAST[t]}` : accessor(path)(a)), pcast: PCAST[t], field: order, type: t, nullable: true };
431
+ const safe = mixed(path, t);
432
+ return { expr: (a) => castLeaf(accessor(path)(a), t, safe), pcast: PCAST[t], field: order, type: t, nullable: true, safe };
343
433
  }
344
- return fail(`sort: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${order}'`);
434
+ // имя поля данных без префикса (sort('code')) — подсказка «data.code?»: принять его молча нельзя — колонки at, rev, id
435
+ // и поле данных с тем же именем разошлись бы, а курсор cursorOf(строка, 'code') — с ними
436
+ if (/^[\p{L}_$][\p{L}\p{N}_$]*(\.[\p{L}\p{N}_$]+)*$/u.test(order))
437
+ throw lerr('query.sort_field_data', { field: order });
438
+ throw lerr('query.sort_field', { field: order });
345
439
  }
346
- /** Формы значений курсора — как у форматов uuid, date и date-time валидатора (validate.ts). */
347
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
440
+ /** Формы значений курсора — как у форматов uuid (UUID_RE, uuid.ts), date и date-time валидатора (validate.ts). */
348
441
  const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
349
442
  const TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?(?:Z|[+-](\d{2}):(\d{2}))$/;
350
443
  /** Дата (и время), которую примет приведение базы ::date / ::timestamptz: форма и смысл полей. */
@@ -398,9 +491,11 @@ function shown(v) {
398
491
  }
399
492
  return s.length > 60 ? `${s.slice(0, 59)}…` : s;
400
493
  }
494
+ /** Какое значение курсора ждёт поле sort() по своему типу — запись каталога (параметр expect ошибки курсора). */
401
495
  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',
496
+ numeric: 'query.cursor_expect_numeric', int: 'query.cursor_expect_int', boolean: 'query.cursor_expect_boolean',
497
+ text: 'query.cursor_expect_text', uuid: 'query.cursor_expect_uuid', timestamptz: 'query.cursor_expect_timestamptz',
498
+ date: 'query.cursor_expect_date', jsonb: 'query.cursor_expect_jsonb',
404
499
  };
405
500
  /**
406
501
  * Параметры keyset из курсора: значение проверяется по типу поля сортировки до запроса. Курсор
@@ -409,16 +504,18 @@ const CURSOR_EXPECT = {
409
504
  * Курсор того же типа с другого поля проверка не отличит.
410
505
  */
411
506
  function cursorParams(c, sort) {
412
- const how = `cursorOf(строка, '${sort.field}')`;
413
507
  if (typeof c !== 'object' || c === null || c.v === undefined)
414
- return fail(`after(cursor): курсор — объект { v, id } из ${how}`);
508
+ throw lerr('query.cursor_shape', { field: sort.field });
415
509
  const { v, id } = c;
416
510
  if (typeof id !== 'string' || !UUID_RE.test(id))
417
- return fail(`after(cursor): id курсора — uuid строки, а не ${shown(id)}`);
418
- const value = v === null && sort.nullable ? null : cursorValue(v, sort.type);
511
+ throw lerr('query.cursor_id', { id: shown(id) });
512
+ let value = v === null && sort.nullable ? null : cursorValue(v, sort.type);
513
+ // строка потомка с другим типом поля (курсор её значения) стоит среди null — как в castLeaf
514
+ if (value === undefined && sort.safe && v !== undefined && typeof v !== 'object')
515
+ value = null;
419
516
  if (value === undefined) {
420
- return fail(`after(cursor): значение курсора ${shown(v)} не подходит к sort('${sort.field}') — ожидалось: ${CURSOR_EXPECT[sort.type]}`
421
- + `${sort.nullable ? ' или null' : ''}; курсор снят с другого поля? Нужен ${how}`);
517
+ // у поля данных подходит и курсор null — своя запись, а не склейка « или null»
518
+ throw lerr(sort.nullable ? 'query.cursor_value_nullable' : 'query.cursor_value', { value: shown(v), field: sort.field, expect: lmsg(CURSOR_EXPECT[sort.type]) });
422
519
  }
423
520
  return { v: value, id };
424
521
  }
@@ -428,7 +525,7 @@ function cursorRev(c) {
428
525
  if (rev === undefined)
429
526
  return undefined;
430
527
  if (!Number.isInteger(rev) || rev < 1)
431
- return fail(`after(cursor): rev курсора — номер версии (целое ≥ 1), а не ${shown(rev)}`);
528
+ throw lerr('query.cursor_rev', { rev: shown(rev) });
432
529
  return rev;
433
530
  }
434
531
  /** id узлов пути из курсора paths() по ключам узлов (план 2.0, 7.1 №3); курсор без path — undefined. */
@@ -439,7 +536,7 @@ function cursorPath(c, keys) {
439
536
  return keys.map((k) => {
440
537
  const id = typeof path === 'object' && path !== null ? path[k] : undefined;
441
538
  if (typeof id !== 'string' || !UUID_RE.test(id))
442
- return fail(`after(cursor): в курсоре пути нет id узла ${k} — нужен cursorOf(путь, поле) из paths() этой же цепочки`);
539
+ throw lerr('query.cursor_path', { key: k });
443
540
  return id;
444
541
  });
445
542
  }
@@ -466,7 +563,7 @@ export function cursorOf(row, field) {
466
563
  v = v?.[k];
467
564
  return { v: (v ?? null), id: row.id, ...rev };
468
565
  }
469
- return fail(`cursorOf: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${field}'`);
566
+ throw lerr('query.cursor_of_field', { field });
470
567
  }
471
568
  // ---------------------------------------------------------------------------
472
569
  // Построение запроса
@@ -476,14 +573,14 @@ export function cursorOf(row, field) {
476
573
  * заденет литералы — имя поля вида a@S@b прежде портилось (план 2.0, 7.1 №5).
477
574
  */
478
575
  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(', ');
576
+ const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason'];
577
+ /** Колонки журнала (выданные приложению — 10-core.sql). */
578
+ const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason']
579
+ .map((c) => 'l.' + c).join(', ');
483
580
  const colsOf = (a) => COLS.map((c) => `${a}.${c}`).join(', ');
484
581
  export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
485
582
  if (!steps.length)
486
- fail('пустая цепочка');
583
+ throw lerr('query.empty_chain');
487
584
  const S = `"${schema.replace(/"/g, '""')}"`;
488
585
  const p = new Params();
489
586
  const cur = mods.asOf === undefined;
@@ -495,7 +592,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
495
592
  if (s.pivotKey !== undefined) {
496
593
  const at = byNodeKey.get(s.pivotKey);
497
594
  if (at === undefined)
498
- fail(`возврат к узлу ${s.name}: узла нет в этом пути`);
595
+ throw lerr('query.pivot_missing', { name: s.name });
499
596
  real[i] = at;
500
597
  }
501
598
  else {
@@ -513,8 +610,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
513
610
  for (let i = 0; i < steps.length; i++) {
514
611
  const step = steps[i];
515
612
  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)];
613
+ const typing = typingOf(reg, step, names);
518
614
  // возврат к узлу: только дофильтры
519
615
  if (step.pivotKey !== undefined) {
520
616
  const a = `h${real[i]}`;
@@ -547,7 +643,9 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
547
643
  const dataP = lazy(() => (cf.eq ? `${p.push(cf.eq)}::jsonb` : 'null'));
548
644
  const endsP = lazy(() => (cf.ends.length ? `${p.push(cf.ends)}::uuid[]` : 'null'));
549
645
  const idsP = lazy(() => (cf.ids ? `${p.push(cf.ids)}::uuid[]` : 'null'));
550
- const dead = mods.withDeleted === true && i === lastNode;
646
+ // надгробия узла: withDeleted() этого шага (план 2.1, A9) или versions() по явным id последнего шага — история
647
+ // удалённого объекта (план 2.1, A10)
648
+ const dead = step.withDeleted === true || (mode === 'versions' && i === lastNode && cf.ids !== undefined && step.deepMax === undefined);
551
649
  const dataCond = (v) => (cf.eq ? ` and ${v}.data @> ${dataP()}` : '');
552
650
  const endsCond = (col) => (cf.ends.length ? ` and ${col} @> ${endsP()}` : '');
553
651
  /** Версии журнала: последняя версия каждого id не позже момента. */
@@ -563,7 +661,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
563
661
  }
564
662
  // по классу: id-кандидаты классов шага, последняя версия — любого класса (как find_ids, план 2.0, 7.1 №1)
565
663
  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)`)})`;
664
+ + `where v.class = any (${clsPh}) and (v.op = 'delete') = ${isDead}${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)})`;
567
665
  };
568
666
  const entityPart = (src, depth = 'null::int', from = `${S}.entity x`) => `select ${colsOf('x')}, ${depth} as depth from ${from} where ${where('x', src)}`;
569
667
  /**
@@ -580,7 +678,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
580
678
  const parts = [];
581
679
  if (i === 0) {
582
680
  if (step.deepMax !== undefined)
583
- fail('deep() — на шаге перехода к тому же классу, а не на первом шаге');
681
+ throw lerr('query.deep_first_step');
584
682
  if (cur) {
585
683
  // условие id уже в conds — первичный ключ; вложение — поиск по индексу GIN
586
684
  parts.push(!cf.ids && (cf.eq || cf.ends.length) ? entityByIds(`select f.id from ${findIds(null, 'null', false)} f`) : entityPart(undefined));
@@ -601,15 +699,15 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
601
699
  const rolesCond = (v, src) => (hop.roles ? ` and ${S}.role_has(${v}.links, ${rolesPh}, ${src})` : '');
602
700
  if (step.deepMax !== undefined) {
603
701
  if (hop.dir !== 'reverse' || !stepClasses(reg, steps[a]).some((c) => names.includes(c))) {
604
- fail(`deep() — только на переходе к тому же классу (дети любой глубины): ${steps[a].name} → ${step.name}`);
702
+ throw lerr('query.deep_hop', { from: steps[a].name, to: step.name });
605
703
  }
606
704
  if (dead)
607
- fail('deep() и withDeleted() не сочетаются');
705
+ throw lerr('query.deep_with_deleted');
608
706
  const maxPh = `${p.push(step.deepMax)}::int`;
609
707
  let deepSrc = `${S}.find_deep(${clsPh}, ${rolesPh}, ${srcArr}, ${maxPh}, ${atPh})`;
610
708
  if (opts.direct) {
611
709
  // тот же обход под 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'))`;
710
+ 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
711
  const hasSrc = (v, src) => (cur ? `${v}.ends @> array[${src}]` : `${S}.link_ids(${v}.links) @> array[${src}]`);
614
712
  const cls = (v) => (cur ? ` and ${v}.class = any (${clsPh})` : '');
615
713
  deepSrc = `(with recursive d(src, id, rev, depth, seen) as (`
@@ -645,7 +743,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
645
743
  }
646
744
  return `(select s.src, v.id, v.rev from ${versionsAt(at, `l.class = any (${clsPh})`)} v `
647
745
  + `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')})`;
746
+ + `where (v.op = 'delete') = ${isDead}${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)}${rolesCond('v', 's.src')})`;
649
747
  };
650
748
  ctes.push(`p${i}a as (select q.src, q.id, q.rev from ${hopCall(atPh, false)} q)`);
651
749
  // пары переходов — по одной на (источник, цель): объект, который ссылается на несколько объектов прошлого шага,
@@ -665,15 +763,19 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
665
763
  joins.push(`join p${i} on p${i}.src = h${a}.id join n${i} h${i} on h${i}.id = p${i}.id`);
666
764
  }
667
765
  // ключи шагов в путях
766
+ // повтор имени — суффикс _2, _3…, свободный среди всех уже выданных ключей: алиас x_2 и второй шаг x не
767
+ // совпадают (x, x_2, x → x, x_2, x_3), иначе узел терялся бы в jsonb_build_object
668
768
  const keyed = [];
669
- const seen = new Map();
769
+ const used = new Set();
670
770
  steps.forEach((s, i) => {
671
771
  if (s.pivotKey !== undefined)
672
772
  return;
673
773
  const base = s.aliasKey ?? s.name;
674
- const n = (seen.get(base) ?? 0) + 1;
675
- seen.set(base, n);
676
- keyed.push({ key: n === 1 ? base : `${base}_${n}`, node: i });
774
+ let key = base;
775
+ for (let n = 2; used.has(key); n++)
776
+ key = `${base}_${n}`;
777
+ used.add(key);
778
+ keyed.push({ key, node: i });
677
779
  });
678
780
  const from = `from n0 h0${joins.length ? ' ' + joins.join(' ') : ''}`;
679
781
  const whereOf = (conds) => {
@@ -686,7 +788,10 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
686
788
  const single = tail.length === 0 && !steps.some((st) => st.pivotKey !== undefined);
687
789
  const ents = single ? `n${lastNode}` : `(select z0.* from n${lastNode} z0 where z0.id in (select h${lastNode}.id ${from}${whereOf(tail)}))`;
688
790
  const leaf = (path) => leafOf(reg, rowsNode(lastNode), path);
689
- const sort = mods.order ? sortSpec(mods.order, leaf) : undefined;
791
+ const mixed = (path, t) => leafMixed(reg, rowsNode(lastNode), path, t);
792
+ if (mods.order?.startsWith('data.'))
793
+ checkField(rowsNode(lastNode), topField(mods.order), `sort('${mods.order}')`, false);
794
+ const sort = mods.order ? sortSpec(mods.order, leaf, mixed) : undefined;
690
795
  const dir = mods.desc ? ' desc' : '';
691
796
  /** Порядок: поле sort, id строки и разделители равных (id прочих узлов пути). */
692
797
  const orderBy = (a, more = []) => (sort ? ` order by ${[sort.expr(a), `${a}.id`, ...more].map((x) => `${x}${dir}`).join(', ')}` : '');
@@ -700,7 +805,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
700
805
  if (!mods.after)
701
806
  return undefined;
702
807
  if (!sort)
703
- fail('after(cursor) требует sort(поле)');
808
+ throw lerr('query.after_without_sort');
704
809
  const c = cursorParams(mods.after, sort);
705
810
  const t = ties(mods.after);
706
811
  const lt = mods.desc ? '<' : '>';
@@ -724,28 +829,7 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
724
829
  const others = keyed.filter((k) => k.node !== lastNode);
725
830
  const pathKeys = [...others, ...keyed.filter((k) => k.node === lastNode)].map((k) => k.key);
726
831
  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`;
832
+ const withClause = `with ${ctes.join(',\n')}\n`;
749
833
  let text;
750
834
  let aggLeaf;
751
835
  switch (mode) {
@@ -772,37 +856,22 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
772
856
  break;
773
857
  case 'versions': {
774
858
  // с 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';
859
+ // без sort — по id и номеру версии
860
+ const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` : 'v.id, v.rev';
777
861
  // курсор — поле sort, id и номер версии: версии одного объекта с равным полем не теряются (план 2.0, 7.1 №4)
778
862
  const cond = whereOf([after('v', (c) => {
779
863
  const rev = cursorRev(c);
780
864
  return rev === undefined ? [] : [{ col: 'v.rev', val: rev, cast: '::int' }];
781
865
  })]);
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`;
866
+ text = `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
867
+ + `where l.id in (select z.id from ${ents} z)) v${cond} order by ${order}${limitOffset()}`;
800
868
  break;
801
869
  }
802
870
  case 'agg': {
803
871
  if (!agg || !agg.field.startsWith('data.'))
804
- fail("агрегат по полю 'data.<путь>'");
872
+ throw lerr('query.agg_field');
805
873
  const path = agg.field.slice(5).split('.');
874
+ checkField(rowsNode(lastNode), path[0], `${agg.fn}('${agg.field}')`, false);
806
875
  const acc = accessor(path)('z');
807
876
  aggLeaf = leaf(path);
808
877
  if (agg.fn === 'countBy') {
@@ -810,10 +879,11 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
810
879
  text = `${withClause}select ${acc} as k, count(*)::int as n from ${ents} z group by 1 order by 2 desc, 1${limitOffset()}`;
811
880
  }
812
881
  else {
813
- const cast = agg.fn === 'sum' || agg.fn === 'avg' ? '::numeric' : CAST[aggLeaf];
882
+ const t = agg.fn === 'sum' || agg.fn === 'avg' ? 'numeric' : aggLeaf;
814
883
  // у boolean в PostgreSQL нет min и max; false меньше true: наименьшее — «все ли true», наибольшее — «есть ли true»
815
884
  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`;
885
+ // тип поля в семействе разный — значения, которые не приводятся к типу листа, агрегат пропускает (как null)
886
+ text = `${withClause}select ${fn}(${castLeaf(acc, t, aggLeaf === t && mixed(path, t))}) as v from ${ents} z`;
817
887
  }
818
888
  break;
819
889
  }
@@ -822,14 +892,12 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
822
892
  }
823
893
  /** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
824
894
  export function toRow(raw) {
825
- const { depth, agent, reason, moved, changed: _changed, ...rest } = raw;
895
+ const { depth, agent, reason, ...rest } = raw;
826
896
  const row = rest;
827
897
  if (agent != null)
828
898
  row.agent = agent;
829
899
  if (reason != null)
830
900
  row.reason = reason;
831
- if (moved != null)
832
- row.moved = moved;
833
901
  if (row.op === 'delete')
834
902
  row.$deleted = true;
835
903
  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';
@@ -17,15 +17,21 @@ export interface SyncResult {
17
17
  unchanged: string[];
18
18
  /** Классы системного арендатора: их меняет только установка. */
19
19
  skipped: string[];
20
- /** Правила refine, которых нет в базе. */
20
+ /** Правила refine, которых нет в базе (преобразования Zod syncModels отклоняет — model.transform_unsupported). */
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;