letopis 2.0.0 → 3.0.1

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 (83) hide show
  1. package/AGENT-CHEATSHEET.md +491 -414
  2. package/CHANGELOG.md +361 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +969 -210
  6. package/api-contract.json +385 -0
  7. package/dist/acl.d.ts +7 -2
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +7 -1
  10. package/dist/admin.js +16 -9
  11. package/dist/auth.d.ts +47 -11
  12. package/dist/auth.js +129 -15
  13. package/dist/chain.d.ts +52 -25
  14. package/dist/chain.js +445 -64
  15. package/dist/cli.js +72 -20
  16. package/dist/errors.d.ts +23 -6
  17. package/dist/errors.js +52 -13
  18. package/dist/index.d.ts +32 -15
  19. package/dist/index.js +209 -68
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +10 -6
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +654 -0
  25. package/dist/mcp.d.ts +1 -1
  26. package/dist/mcp.js +34 -16
  27. package/dist/messages/access.d.ts +10 -0
  28. package/dist/messages/access.js +13 -0
  29. package/dist/messages/api.d.ts +76 -0
  30. package/dist/messages/api.js +109 -7
  31. package/dist/messages/boot.d.ts +5 -0
  32. package/dist/messages/boot.js +11 -4
  33. package/dist/messages/codes.js +4 -4
  34. package/dist/messages/index.d.ts +5 -2
  35. package/dist/messages/index.js +5 -2
  36. package/dist/messages/log.d.ts +232 -0
  37. package/dist/messages/log.js +105 -0
  38. package/dist/messages/mcp.js +7 -7
  39. package/dist/messages/model.d.ts +40 -1
  40. package/dist/messages/model.js +52 -3
  41. package/dist/messages/query.d.ts +10 -0
  42. package/dist/messages/query.js +13 -0
  43. package/dist/messages/sql-core.d.ts +15 -1
  44. package/dist/messages/sql-core.js +20 -3
  45. package/dist/messages/sql-time.d.ts +15 -0
  46. package/dist/messages/sql-time.js +23 -0
  47. package/dist/messages/write.d.ts +52 -0
  48. package/dist/messages/write.js +77 -6
  49. package/dist/model.d.ts +10 -2
  50. package/dist/model.js +28 -6
  51. package/dist/registry.d.ts +6 -0
  52. package/dist/registry.js +6 -0
  53. package/dist/sql.d.ts +2 -0
  54. package/dist/sql.js +77 -25
  55. package/dist/sync.d.ts +4 -5
  56. package/dist/sync.js +53 -4
  57. package/dist/tx.d.ts +10 -1
  58. package/dist/tx.js +44 -2
  59. package/dist/typed.d.ts +18 -14
  60. package/dist/types.d.ts +91 -8
  61. package/dist/types.js +8 -3
  62. package/dist/up.d.ts +8 -5
  63. package/dist/up.js +71 -23
  64. package/dist/uuid.d.ts +8 -0
  65. package/dist/uuid.js +9 -1
  66. package/dist/validate.js +24 -7
  67. package/dist/watch.d.ts +5 -0
  68. package/dist/watch.js +13 -0
  69. package/dist/write.d.ts +84 -3
  70. package/dist/write.js +427 -40
  71. package/llms.txt +38 -17
  72. package/package.json +12 -6
  73. package/sql/20-context.sql +11 -3
  74. package/sql/30-validate.sql +51 -10
  75. package/sql/40-class.sql +21 -1
  76. package/sql/60-write.sql +18 -6
  77. package/sql/80-auth.sql +88 -17
  78. package/sql/90-time.sql +34 -6
  79. package/sql/99-revision.sql +5 -1
  80. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  81. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  82. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  83. package/sql/migrate/README.md +5 -1
package/dist/sql.js CHANGED
@@ -20,6 +20,7 @@
20
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;
@@ -162,12 +163,33 @@ function leafOf(reg, classes, path) {
162
163
  }
163
164
  return 'jsonb';
164
165
  }
165
- 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) {
166
189
  const lhs = accessor(path);
167
190
  const cast = CAST[leaf];
168
191
  const pc = PCAST[leaf];
169
- // скобки обязательны: '::' сильнее '->>'
170
- const L = cast ? (a) => `(${lhs(a)})${cast}` : lhs;
192
+ const L = cast ? (a) => castLeaf(lhs(a), leaf, safe) : lhs;
171
193
  const args = o.args;
172
194
  switch (o[OP]) {
173
195
  case 'ne':
@@ -228,10 +250,10 @@ function compileOp(path, leaf, o, p) {
228
250
  return (a) => `${lhs(a)} is null`;
229
251
  case 'not': {
230
252
  if (isOp(args[0])) {
231
- const inner = compileOp(path, leaf, args[0], p);
253
+ const inner = compileOp(path, leaf, args[0], p, safe);
232
254
  return (a) => `not coalesce(${inner(a)}, false)`;
233
255
  }
234
- return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p);
256
+ return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p, safe);
235
257
  }
236
258
  default:
237
259
  throw lerr('query.unknown_op', { op: String(o[OP]) });
@@ -266,10 +288,22 @@ function compileTags(v, p) {
266
288
  const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
267
289
  /** Объект-строка ответа как значение: берётся id. */
268
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
+ }
269
300
  function compileFilter(reg, classes, filter, p) {
270
301
  const out = { ends: [], frags: [] };
271
302
  if (filter === undefined)
272
303
  return out;
304
+ const at = `${classes[0]?.name ?? ''}(…)`;
305
+ if (filter === null)
306
+ idCheck(filter, at);
273
307
  // or(…): дизъюнкция под-фильтров — только перепроверка
274
308
  if (isOp(filter)) {
275
309
  if (filter[OP] !== 'or')
@@ -283,11 +317,11 @@ function compileFilter(reg, classes, filter, p) {
283
317
  return out;
284
318
  }
285
319
  if (typeof filter === 'string') {
286
- out.ids = [filter];
320
+ out.ids = [idCheck(filter, at)];
287
321
  return out;
288
322
  }
289
323
  if (Array.isArray(filter)) {
290
- out.ids = filter.map((x) => idOfValue(x) ?? refuse('query.id_list'));
324
+ out.ids = filter.map((x) => idCheck(idOfValue(x) ?? refuse('query.id_list'), at));
291
325
  return out;
292
326
  }
293
327
  const props = (classes[0]?.schema.properties ?? {});
@@ -303,7 +337,8 @@ function compileFilter(reg, classes, filter, p) {
303
337
  continue;
304
338
  const at = [...path, k];
305
339
  if (isOp(v)) {
306
- 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)));
307
342
  hadOps = true;
308
343
  }
309
344
  else if (isPlain(v)) {
@@ -324,10 +359,11 @@ function compileFilter(reg, classes, filter, p) {
324
359
  if (value === undefined)
325
360
  continue;
326
361
  if (key === 'id') {
362
+ const w = `${classes[0]?.name ?? ''}({ id })`;
327
363
  if (typeof value === 'string')
328
- out.ids = [value];
364
+ out.ids = [idCheck(value, w)];
329
365
  else if (Array.isArray(value))
330
- out.ids = value.map((x) => idOfValue(x) ?? refuse('query.filter_id_list'));
366
+ out.ids = value.map((x) => idCheck(idOfValue(x) ?? refuse('query.filter_id_list'), w));
331
367
  else
332
368
  throw lerr('query.filter_id');
333
369
  continue;
@@ -338,7 +374,7 @@ function compileFilter(reg, classes, filter, p) {
338
374
  out.frags.push((a) => `coalesce(${a}.links->${role}, 'null'::jsonb) in ('null'::jsonb, '[]'::jsonb)`);
339
375
  continue;
340
376
  }
341
- const id = idOfValue(value) ?? refuse('query.end_value', { role: key });
377
+ const id = idCheck(idOfValue(value) ?? refuse('query.end_value', { role: key }), `${classes[0]?.name ?? ''}({ ${key} })`);
342
378
  out.ends.push(id);
343
379
  const ph = p.push(id);
344
380
  out.frags.push((a) => `${SCHEMA_MARK}.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
@@ -347,7 +383,8 @@ function compileFilter(reg, classes, filter, p) {
347
383
  // поле данных: не описано ни в одном классе шага — invalid_query с похожими (план 2.0, этап 3, п. 3)
348
384
  checkField(classes, key, `${classes[0]?.name ?? ''}({ ${key} })`, true);
349
385
  if (isOp(value)) {
350
- 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)));
351
388
  continue;
352
389
  }
353
390
  if (isPlain(value)) {
@@ -379,7 +416,7 @@ function asFrags(cf, p) {
379
416
  }
380
417
  return frags;
381
418
  }
382
- function sortSpec(order, leaf) {
419
+ function sortSpec(order, leaf, mixed) {
383
420
  const f = order === 'updated' ? 'at' : order;
384
421
  const col = { field: order, nullable: false };
385
422
  if (f === 'at')
@@ -391,12 +428,16 @@ function sortSpec(order, leaf) {
391
428
  if (f.startsWith('data.')) {
392
429
  const path = f.slice(5).split('.');
393
430
  const t = leaf(path);
394
- 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 };
395
433
  }
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 });
396
438
  throw lerr('query.sort_field', { field: order });
397
439
  }
398
- /** Формы значений курсора — как у форматов uuid, date и date-time валидатора (validate.ts). */
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;
440
+ /** Формы значений курсора — как у форматов uuid (UUID_RE, uuid.ts), date и date-time валидатора (validate.ts). */
400
441
  const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
401
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}))$/;
402
443
  /** Дата (и время), которую примет приведение базы ::date / ::timestamptz: форма и смысл полей. */
@@ -468,7 +509,10 @@ function cursorParams(c, sort) {
468
509
  const { v, id } = c;
469
510
  if (typeof id !== 'string' || !UUID_RE.test(id))
470
511
  throw lerr('query.cursor_id', { id: shown(id) });
471
- const value = v === null && sort.nullable ? null : cursorValue(v, sort.type);
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;
472
516
  if (value === undefined) {
473
517
  // у поля данных подходит и курсор null — своя запись, а не склейка « или null»
474
518
  throw lerr(sort.nullable ? 'query.cursor_value_nullable' : 'query.cursor_value', { value: shown(v), field: sort.field, expect: lmsg(CURSOR_EXPECT[sort.type]) });
@@ -599,7 +643,9 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
599
643
  const dataP = lazy(() => (cf.eq ? `${p.push(cf.eq)}::jsonb` : 'null'));
600
644
  const endsP = lazy(() => (cf.ends.length ? `${p.push(cf.ends)}::uuid[]` : 'null'));
601
645
  const idsP = lazy(() => (cf.ids ? `${p.push(cf.ids)}::uuid[]` : 'null'));
602
- 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);
603
649
  const dataCond = (v) => (cf.eq ? ` and ${v}.data @> ${dataP()}` : '');
604
650
  const endsCond = (col) => (cf.ends.length ? ` and ${col} @> ${endsP()}` : '');
605
651
  /** Версии журнала: последняя версия каждого id не позже момента. */
@@ -717,15 +763,19 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
717
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`);
718
764
  }
719
765
  // ключи шагов в путях
766
+ // повтор имени — суффикс _2, _3…, свободный среди всех уже выданных ключей: алиас x_2 и второй шаг x не
767
+ // совпадают (x, x_2, x → x, x_2, x_3), иначе узел терялся бы в jsonb_build_object
720
768
  const keyed = [];
721
- const seen = new Map();
769
+ const used = new Set();
722
770
  steps.forEach((s, i) => {
723
771
  if (s.pivotKey !== undefined)
724
772
  return;
725
773
  const base = s.aliasKey ?? s.name;
726
- const n = (seen.get(base) ?? 0) + 1;
727
- seen.set(base, n);
728
- 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 });
729
779
  });
730
780
  const from = `from n0 h0${joins.length ? ' ' + joins.join(' ') : ''}`;
731
781
  const whereOf = (conds) => {
@@ -738,9 +788,10 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
738
788
  const single = tail.length === 0 && !steps.some((st) => st.pivotKey !== undefined);
739
789
  const ents = single ? `n${lastNode}` : `(select z0.* from n${lastNode} z0 where z0.id in (select h${lastNode}.id ${from}${whereOf(tail)}))`;
740
790
  const leaf = (path) => leafOf(reg, rowsNode(lastNode), path);
791
+ const mixed = (path, t) => leafMixed(reg, rowsNode(lastNode), path, t);
741
792
  if (mods.order?.startsWith('data.'))
742
793
  checkField(rowsNode(lastNode), topField(mods.order), `sort('${mods.order}')`, false);
743
- const sort = mods.order ? sortSpec(mods.order, leaf) : undefined;
794
+ const sort = mods.order ? sortSpec(mods.order, leaf, mixed) : undefined;
744
795
  const dir = mods.desc ? ' desc' : '';
745
796
  /** Порядок: поле sort, id строки и разделители равных (id прочих узлов пути). */
746
797
  const orderBy = (a, more = []) => (sort ? ` order by ${[sort.expr(a), `${a}.id`, ...more].map((x) => `${x}${dir}`).join(', ')}` : '');
@@ -828,10 +879,11 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
828
879
  text = `${withClause}select ${acc} as k, count(*)::int as n from ${ents} z group by 1 order by 2 desc, 1${limitOffset()}`;
829
880
  }
830
881
  else {
831
- const cast = agg.fn === 'sum' || agg.fn === 'avg' ? '::numeric' : CAST[aggLeaf];
882
+ const t = agg.fn === 'sum' || agg.fn === 'avg' ? 'numeric' : aggLeaf;
832
883
  // у boolean в PostgreSQL нет min и max; false меньше true: наименьшее — «все ли true», наибольшее — «есть ли true»
833
884
  const fn = aggLeaf === 'boolean' && agg.fn === 'min' ? 'bool_and' : aggLeaf === 'boolean' && agg.fn === 'max' ? 'bool_or' : agg.fn;
834
- 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`;
835
887
  }
836
888
  break;
837
889
  }
package/dist/sync.d.ts CHANGED
@@ -17,7 +17,7 @@ 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
23
  export declare function syncModels(db: DbCore, models: readonly AnyModel[], opts?: {
@@ -27,10 +27,9 @@ export declare function syncModels(db: DbCore, models: readonly AnyModel[], opts
27
27
  export declare function schemaToTs(s: JsonSchema | undefined, indent?: string): string;
28
28
  /**
29
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.
30
+ * и предки, — а в конце список `Models`: строгие типы цепочек без моделей Zod, `connect<Models>()`. Классы арендатора
31
+ * System (системные и классы сидов пакета) — только с includeSystem (`letopis types --system`); без него системные
32
+ * классы даёт встроенный набор SystemModels. Первая строка — пометка «Сгенерировано letopis types — не править руками.»
34
33
  */
35
34
  export declare function generateTypes(registry: Registry, opts?: {
36
35
  includeSystem?: boolean;
package/dist/sync.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { lerr, lmsg } from './errors.js';
2
2
  import { internalsOf } from './internal.js';
3
+ import { NOLOG, traced } from './log.js';
3
4
  import { RESERVED_CLASS_NAMES } from './types.js';
4
5
  const ROLLBACK = Symbol('sync-dry-run');
5
6
  /** Канонический JSON: ключи по алфавиту — сравнение описаний без учёта порядка ключей. */
@@ -11,6 +12,14 @@ function canon(v) {
11
12
  return JSON.stringify(v);
12
13
  }
13
14
  export async function syncModels(db, models, opts = {}) {
15
+ // событие sync (логирование 3.0): логгер — фасада, которым пишут классы
16
+ return traced(internalsOf(db)?.log ?? NOLOG, 'sync', { op: 'syncModels' }, () => syncCore(db, models, opts), (r) => ({
17
+ rows: r.created.length + r.updated.length,
18
+ detail: { applied: r.applied, models: models.length, created: r.created, updated: r.updated, unchanged: r.unchanged.length,
19
+ skipped: r.skipped, lost: r.lost.length },
20
+ }));
21
+ }
22
+ async function syncCore(db, models, opts) {
14
23
  // хаб с именем метода цепочки или корня db (модель собрана мимо hub()) — отказ до записи (план 2.0, этап 4, п. 2;
15
24
  // 7.10 №3); связь с таким именем отклоняет база
16
25
  for (const m of models) {
@@ -18,6 +27,13 @@ export async function syncModels(db, models, opts = {}) {
18
27
  throw lerr('model.sync_hub_name_reserved', { name: m.name });
19
28
  }
20
29
  const res = { applied: Boolean(opts.apply), created: [], updated: [], unchanged: [], skipped: [], lost: models.flatMap((m) => m.lostRules()) };
30
+ // преобразования Zod (.trim(), .toLowerCase(), .transform(), z.preprocess …) база и валидатор letopis не исполняют —
31
+ // данные записались бы как есть: отказ до записи (план 2.1, A11); refine остаётся в отчёте lost
32
+ const transforms = res.lost.filter((l) => l.kind !== 'refine');
33
+ if (transforms.length) {
34
+ throw lerr('model.transform_unsupported', { rules: transforms.map((l) => `${l.path} (${l.kind})`).join(', ') }, { detail: { rules: transforms } });
35
+ }
36
+ checkEndRoles(db.registry, models);
21
37
  const S = `"${db.schema.replace(/"/g, '""')}"`;
22
38
  // предок раньше потомка: потомка класса, которого ещё нет, база отклоняет. Круг родителей (модель, собранная мимо
23
39
  // hub()) так не упорядочить — отказ до записи с самим кругом (хвост ревью доработок после 1.2.0: раньше обход молча
@@ -75,6 +91,40 @@ export async function syncModels(db, models, opts = {}) {
75
91
  await db.refresh();
76
92
  return res;
77
93
  }
94
+ const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
95
+ /** Набор классов конца — как class_set() базы: список без повторов по порядку. */
96
+ const classSet = (c) => [...new Set(typeof c === 'string' ? [c] : c)].sort().join(', ');
97
+ /**
98
+ * Имя роли конца — одно на арендатора: в любом другом классе (своём или системном) роль с тем же именем обязана вести
99
+ * на тот же набор классов (база: sql-write.end_other_class в 60-write.sql, проверка ends при записи Class). Системные
100
+ * классы занимают роли User и Tenant (member → Account), Group и Object (rule → Resource). Проверка — до записи, по
101
+ * реестру и самим моделям, с понятным текстом; база проверяет всё равно.
102
+ */
103
+ function checkEndRoles(reg, models) {
104
+ const names = new Set(models.map((m) => m.name));
105
+ const seen = new Map();
106
+ for (const c of reg.all()) {
107
+ if (names.has(c.name))
108
+ continue;
109
+ for (const [role, e] of Object.entries(c.ends)) {
110
+ if (!seen.has(role) || c.tenant === SYSTEM_ID)
111
+ seen.set(role, { cls: c.name, set: classSet(e.class), system: c.tenant === SYSTEM_ID });
112
+ }
113
+ }
114
+ for (const m of models) {
115
+ for (const [role, e] of Object.entries(m.describe().ends ?? {})) {
116
+ const mine = classSet(e.class);
117
+ const other = seen.get(role);
118
+ if (other && other.set !== mine) {
119
+ if (other.system)
120
+ throw lerr('model.end_role_system', { end: role, model: m.name, cls: other.cls, target: other.set });
121
+ throw lerr('model.end_role_taken', { end: role, model: m.name, mine, cls: other.cls, target: other.set });
122
+ }
123
+ if (!other)
124
+ seen.set(role, { cls: m.name, set: mine, system: false });
125
+ }
126
+ }
127
+ }
78
128
  /** Тип TypeScript по JSON Schema подмножества. */
79
129
  export function schemaToTs(s, indent = '') {
80
130
  if (!s || Object.keys(s).length === 0)
@@ -116,10 +166,9 @@ export function schemaToTs(s, indent = '') {
116
166
  }
117
167
  /**
118
168
  * Объявления `<Класс>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.
169
+ * и предки, — а в конце список `Models`: строгие типы цепочек без моделей Zod, `connect<Models>()`. Классы арендатора
170
+ * System (системные и классы сидов пакета) — только с includeSystem (`letopis types --system`); без него системные
171
+ * классы даёт встроенный набор SystemModels. Первая строка — пометка «Сгенерировано letopis types — не править руками.»
123
172
  */
124
173
  export function generateTypes(registry, opts = {}) {
125
174
  const out = [`// ${lmsg('model.types_header')}`, ''];
package/dist/tx.d.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  * записи. Неотправленное (ошибка отправки) остаётся в очереди до следующего интервала.
24
24
  */
25
25
  import type postgres from 'postgres';
26
+ import { type Log } from './log.js';
26
27
  type Sql = postgres.Sql<Record<string, unknown>>;
27
28
  export type Tx = postgres.TransactionSql<Record<string, unknown>>;
28
29
  /** Транзиентные ошибки PG, которые снимает повтор: взаимная блокировка, сериализация. */
@@ -54,11 +55,12 @@ export declare class Notifier {
54
55
  private channel;
55
56
  private intervalMs;
56
57
  private onError;
58
+ private log;
57
59
  private queue;
58
60
  private timer;
59
61
  private stopped;
60
62
  private flushing;
61
- constructor(sql: Sql, channel: string, intervalMs: number, onError?: (e: unknown) => void);
63
+ constructor(sql: Sql, channel: string, intervalMs: number, onError?: (e: unknown) => void, log?: Log);
62
64
  /** Добавить нагрузки из letopis.changed (строка через запятую). */
63
65
  add(changed: string | null | undefined): void;
64
66
  private schedule;
@@ -108,6 +110,8 @@ export interface TxContext {
108
110
  * результатов сбрасывает свои ответы сразу (§2.12); raw — запись через db.sql.
109
111
  */
110
112
  onChanged?: (names: string[], raw: boolean) => void;
113
+ /** Логгер (lib/src/log.ts): повторы (tx.retry), транзакции db.begin() (tx.begin, tx.commit, tx.rollback). */
114
+ log?: Log;
111
115
  }
112
116
  /**
113
117
  * Операция одной транзакцией с контекстом сессии и повтором при транзиентных ошибках и конфликте
@@ -145,7 +149,10 @@ export declare class ExplicitTx implements Executor {
145
149
  private failure;
146
150
  /** В транзакции был сырой SQL (db.sql): после фиксации — весь кэш. */
147
151
  private raw;
152
+ /** Время открытия — для ms у tx.commit и tx.rollback. */
153
+ private readonly t0;
148
154
  private constructor();
155
+ private get log();
149
156
  static open(ctx: TxContext, renewed?: boolean): Promise<ExplicitTx>;
150
157
  /** Транзакция ещё открыта (не зафиксирована и не откачена). */
151
158
  get active(): boolean;
@@ -156,6 +163,8 @@ export declare class ExplicitTx implements Executor {
156
163
  /** Advisory-блокировка до конца транзакции (отдельное от блокировок семейств пространство ключей). */
157
164
  lock(...keys: (string | number)[]): Promise<void>;
158
165
  commit(): Promise<void>;
166
+ /** Событие отката (tx.rollback) и ошибка, которой он вызван. */
167
+ private rolledBack;
159
168
  rollback(): Promise<void>;
160
169
  }
161
170
  export {};
package/dist/tx.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { LetopisError, fromDbError, lerr, lmsg } from './errors.js';
2
+ import { NOLOG } from './log.js';
2
3
  /** Транзиентные ошибки PG, которые снимает повтор: взаимная блокировка, сериализация. */
3
4
  export function isTransient(e) {
4
5
  const code = e.code;
@@ -56,15 +57,17 @@ export class Notifier {
56
57
  channel;
57
58
  intervalMs;
58
59
  onError;
60
+ log;
59
61
  queue = new Set();
60
62
  timer = null;
61
63
  stopped = false;
62
64
  flushing = Promise.resolve();
63
- constructor(sql, channel, intervalMs, onError = () => { }) {
65
+ constructor(sql, channel, intervalMs, onError = () => { }, log = NOLOG) {
64
66
  this.sql = sql;
65
67
  this.channel = channel;
66
68
  this.intervalMs = intervalMs;
67
69
  this.onError = onError;
70
+ this.log = log;
68
71
  }
69
72
  /** Добавить нагрузки из letopis.changed (строка через запятую). */
70
73
  add(changed) {
@@ -89,7 +92,10 @@ export class Notifier {
89
92
  return this.flushing;
90
93
  const payloads = [...this.queue];
91
94
  this.queue.clear();
92
- this.flushing = this.flushing.then(() => this.sql `select pg_notify(${this.channel}, c) from unnest(${payloads}::text[]) as c`.then(() => undefined, (e) => {
95
+ this.flushing = this.flushing.then(() => this.sql `select pg_notify(${this.channel}, c) from unnest(${payloads}::text[]) as c`.then(() => {
96
+ if (this.log.wants('signal.send'))
97
+ this.log.emit('signal.send', { rows: payloads.length });
98
+ }, (e) => {
93
99
  // не отправлено (обрыв связи, отказ базы) — обратно в очередь: уйдёт следующим интервалом
94
100
  // (в 1.0.1 такие сигналы терялись, и кэш других процессов устаревал до срока)
95
101
  for (const c of payloads)
@@ -204,6 +210,11 @@ export async function runTx(ctx, fn, opts = {}) {
204
210
  if (bodyDone && !opts.read && dead)
205
211
  throw commitUnknown(e);
206
212
  if (attempt <= retries && (isTransient(e) || isLogConflict(e))) {
213
+ const log = ctx.log ?? NOLOG;
214
+ if (log.wants('tx.retry')) {
215
+ log.emit('tx.retry', { detail: { attempt, of: retries, sqlstate: e.code,
216
+ ...(isLogConflict(e) ? { conflict: 'log_id_rev' } : {}) } });
217
+ }
207
218
  await retryDelay(attempt);
208
219
  continue;
209
220
  }
@@ -243,10 +254,15 @@ export class ExplicitTx {
243
254
  failure = null;
244
255
  /** В транзакции был сырой SQL (db.sql): после фиксации — весь кэш. */
245
256
  raw = false;
257
+ /** Время открытия — для ms у tx.commit и tx.rollback. */
258
+ t0 = performance.now();
246
259
  constructor(ctx, conn) {
247
260
  this.ctx = ctx;
248
261
  this.conn = conn;
249
262
  }
263
+ get log() {
264
+ return this.ctx.log ?? NOLOG;
265
+ }
250
266
  static async open(ctx, renewed = false) {
251
267
  if (!renewed && ctx.before)
252
268
  await ctx.before(ctx);
@@ -267,6 +283,9 @@ export class ExplicitTx {
267
283
  return ExplicitTx.open(ctx, true);
268
284
  throw le;
269
285
  }
286
+ const log = ctx.log ?? NOLOG;
287
+ if (log.wants('tx.begin'))
288
+ log.emit('tx.begin', { op: 'begin', detail: { isolation: 'read committed', ...(ctx.reason ? { reason: ctx.reason } : {}) } });
270
289
  return new ExplicitTx(ctx, conn);
271
290
  }
272
291
  /** Транзакция ещё открыта (не зафиксирована и не откачена). */
@@ -307,6 +326,7 @@ export class ExplicitTx {
307
326
  await this.conn.unsafe('rollback').catch(() => { });
308
327
  release(this.conn, this.broken);
309
328
  this.failure = this.broken;
329
+ this.rolledBack('error', this.broken);
310
330
  throw this.broken;
311
331
  }
312
332
  let changed = null;
@@ -331,18 +351,40 @@ export class ExplicitTx {
331
351
  catch (e) {
332
352
  this.failure ??= isConnectionError(e) ? commitUnknown(e) : fromDbError(e);
333
353
  release(this.conn, e);
354
+ this.rolledBack('error', this.failure);
334
355
  throw this.failure;
335
356
  }
336
357
  this.conn.release();
358
+ // транзакция с записью (write, delete, restore, db.sql, права, креды) — tx.commit уровня info: это и есть
359
+ // доказательство фиксации её событий записи (у них то же поле tx); без записи — debug, как прежде
360
+ const writes = this.log.writes ?? 0;
361
+ const level = writes > 0 ? 'info' : 'debug';
362
+ if (this.log.level(level)) {
363
+ this.log.emit('tx.commit', { op: 'commit', level, ms: performance.now() - this.t0,
364
+ detail: { writes, ...(changed ? { signals: changed.split(',').filter(Boolean).length } : {}) } });
365
+ }
337
366
  this.ctx.notifier?.add(changed);
338
367
  if (changed)
339
368
  this.ctx.onChanged?.(changedList(changed), this.raw);
340
369
  this.ctx.after?.();
341
370
  }
371
+ /** Событие отката (tx.rollback) и ошибка, которой он вызван. */
372
+ rolledBack(reason, err) {
373
+ const log = this.log;
374
+ if (!log.enabled)
375
+ return;
376
+ if (err !== undefined)
377
+ log.error(err, { op: 'commit', ms: performance.now() - this.t0 });
378
+ if (log.wants('tx.rollback')) {
379
+ log.emit('tx.rollback', { op: reason === 'error' ? 'commit' : 'rollback', ms: performance.now() - this.t0,
380
+ detail: { reason, writes: log.writes ?? 0, ...(this.broken ? { broken: true } : {}) } });
381
+ }
382
+ }
342
383
  async rollback() {
343
384
  if (this.done)
344
385
  return;
345
386
  this.done = true;
387
+ this.rolledBack('explicit');
346
388
  try {
347
389
  // соединение, оборванное ошибкой, не трогаем: postgres.js уронил бы процесс записью в закрытый сокет
348
390
  if (!isConnectionError(this.broken))
package/dist/typed.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * - переходы цепочки выводятся из концов, как в resolveHop (lib/src/sql.ts): от класса C — роли его семейства (C и
7
7
  * потомков) и классы, связанные с семейством C концом в любую сторону (роль, имя которой совпало с именем класса,
8
8
  * во время выполнения — шаг класса, так же и здесь);
9
- * - слоты — только роли класса C: `set(id | строка | цепочка)`, `add` и `remove` — у множественного конца, `unset` —
9
+ * - шаги ролей после глагола — только роли класса C: `set(id | строка | цепочка)`, `add` и `remove` — у множественного конца, `unset` —
10
10
  * у необязательного;
11
11
  * - поля фильтров, `or()` и агрегатов `sum`, `avg`, `min`, `max`, `countBy` — поля класса.
12
12
  * Чего типы не знают: возврат к узлу связи из пути (pivot) от класса, с которым связь не связана концом, и роль чужого
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import type { ChainCore, ChainRef } from './chain.js';
18
18
  import type { Cursor, Op, OP, Path, Row, Scalar } from './types.js';
19
- import type { IncMarker } from './write.js';
19
+ import type { DeleteOptions, IncMarker, RestoreOptions } from './write.js';
20
20
  /**
21
21
  * То, что цепочкам нужно от модели: имя, алиас и типы для вывода — данные строки, концы строки, концы класса (роль →
22
22
  * `{ targets: имена классов целей; many; required }`) и имена класса и его предков. Модели letopis/model и объявления
@@ -214,11 +214,11 @@ export type StepsOf<M extends readonly AnyModelLike[]> = {
214
214
  } & {
215
215
  [K in AllOf<M> as AliasOf<K>]: StepFnOf<K, M>;
216
216
  };
217
- /** Значение слота одиночного конца: id, строка ответа или цепочка. */
217
+ /** Значение шага роли одиночного конца (`.Роль.set(…)`): id, строка ответа или цепочка. */
218
218
  type SlotOne = string | {
219
219
  readonly id: string;
220
220
  } | ChainRef;
221
- /** Значение слота множественного конца: ещё и список id или строк. */
221
+ /** Значение шага роли множественного конца (`.Роль.add(…)`, `.remove(…)`): ещё и список id или строк. */
222
222
  type SlotMany = SlotOne | readonly (string | {
223
223
  readonly id: string;
224
224
  })[];
@@ -248,7 +248,7 @@ type HopsOf<C, M extends readonly AnyModelLike[], All = AllOf<M>> = {
248
248
  type Retyped = 'paths' | 'rows' | 'first' | 'versions' | 'limit' | 'offset' | 'sort' | 'asOf' | 'after' | 'withDeleted' | 'deep' | 'exact' | 'alias' | 'tags' | 'owner' | 'create' | 'update' | 'upsert' | 'delete' | 'forUpdate' | 'restore' | 'cache' | 'sum' | 'avg' | 'min' | 'max' | 'countBy';
249
249
  /**
250
250
  * Цепочка с текущим классом C (у шага-роли с несколькими целями — их союз): терминалы отдают RowOf<C>, шаги — переходы
251
- * из концов, слоты — роли C, агрегаты — поля C.
251
+ * из концов, шаги ролей после глагола (`.Роль.set(…)`) — роли C, агрегаты — поля C.
252
252
  */
253
253
  export type TypedChain<C, M extends readonly AnyModelLike[]> = Omit<ChainCore, Retyped> & {
254
254
  paths(): Promise<Path[]>;
@@ -290,16 +290,20 @@ export type TypedChain<C, M extends readonly AnyModelLike[]> = Omit<ChainCore, R
290
290
  upsert(data: Partial<DataOf<C>>, opts?: {
291
291
  reason?: string;
292
292
  }): TypedChain<C, M>;
293
- /** С `{ confirm: true }` без фильтра, id, `tags()`, `owner()` и `limit()` у шагов — `invalid_query`; всех — `{ all: true }`. */
294
- delete(opts?: {
295
- confirm?: boolean;
296
- all?: boolean;
297
- reason?: string;
298
- }): TypedChain<C, M>;
293
+ /**
294
+ * Без опций — предпросмотр. `{ confirm: true, expect: предпросмотр }` — удалить ровно показанное (цели сужены фильтром, id, `tags()`,
295
+ * `owner()` или `limit()`); `{ confirm: true }` без `expect` — `invalid_query`;
296
+ * осознанно удалить текущий состав без сверки — `{ confirm: true, all: true }`.
297
+ */
298
+ delete(opts?: DeleteOptions): TypedChain<C, M>;
299
299
  forUpdate(): TypedChain<C, M>;
300
- restore(opts?: {
301
- reason?: string;
302
- }): TypedChain<C, M>;
300
+ /**
301
+ * `{ expect }` — надгробия `{ id, rev }`: восстановить ровно то, что ушло удалением; расхождение — отказ по смыслу
302
+ * (`write.restore_already`, `write.restore_id_taken`, `write.restore_stale`). `{ expect, partial: true }` — что можно, а
303
+ * остальное — строки `$action: 'skip'` с причиной `$why`. Без `expect` — `invalid_query` (`write.restore_without_expect`);
304
+ * осознанно вернуть все удалённые цели шага без сверки — `{ all: true }`.
305
+ */
306
+ restore(opts: RestoreOptions): TypedChain<C, M>;
303
307
  cache(opts?: {
304
308
  ttl?: number;
305
309
  }): TypedChain<C, M>;