letopis 2.0.0 → 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 (75) hide show
  1. package/AGENT-CHEATSHEET.md +484 -414
  2. package/CHANGELOG.md +321 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +786 -183
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +3 -0
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +2 -1
  10. package/dist/admin.js +6 -4
  11. package/dist/auth.d.ts +18 -3
  12. package/dist/auth.js +75 -6
  13. package/dist/chain.d.ts +38 -17
  14. package/dist/chain.js +434 -63
  15. package/dist/cli.js +28 -6
  16. package/dist/errors.d.ts +19 -1
  17. package/dist/errors.js +46 -6
  18. package/dist/index.d.ts +10 -4
  19. package/dist/index.js +136 -46
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +9 -5
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +633 -0
  25. package/dist/mcp.js +26 -11
  26. package/dist/messages/api.d.ts +66 -0
  27. package/dist/messages/api.js +90 -1
  28. package/dist/messages/boot.js +4 -4
  29. package/dist/messages/codes.js +4 -4
  30. package/dist/messages/index.d.ts +5 -2
  31. package/dist/messages/index.js +5 -2
  32. package/dist/messages/log.d.ts +229 -0
  33. package/dist/messages/log.js +102 -0
  34. package/dist/messages/model.d.ts +24 -1
  35. package/dist/messages/model.js +34 -3
  36. package/dist/messages/query.d.ts +10 -0
  37. package/dist/messages/query.js +13 -0
  38. package/dist/messages/sql-core.d.ts +15 -1
  39. package/dist/messages/sql-core.js +20 -3
  40. package/dist/messages/sql-time.d.ts +15 -0
  41. package/dist/messages/sql-time.js +23 -0
  42. package/dist/messages/write.d.ts +47 -0
  43. package/dist/messages/write.js +63 -0
  44. package/dist/model.d.ts +4 -0
  45. package/dist/model.js +23 -5
  46. package/dist/sql.d.ts +2 -0
  47. package/dist/sql.js +77 -25
  48. package/dist/sync.d.ts +1 -1
  49. package/dist/sync.js +50 -0
  50. package/dist/tx.d.ts +10 -1
  51. package/dist/tx.js +44 -2
  52. package/dist/typed.d.ts +14 -10
  53. package/dist/types.d.ts +82 -4
  54. package/dist/types.js +6 -1
  55. package/dist/up.d.ts +3 -3
  56. package/dist/up.js +57 -17
  57. package/dist/uuid.d.ts +8 -0
  58. package/dist/uuid.js +9 -1
  59. package/dist/validate.js +24 -7
  60. package/dist/watch.d.ts +5 -0
  61. package/dist/watch.js +13 -0
  62. package/dist/write.d.ts +78 -1
  63. package/dist/write.js +357 -39
  64. package/llms.txt +37 -17
  65. package/package.json +12 -6
  66. package/sql/20-context.sql +11 -3
  67. package/sql/30-validate.sql +51 -10
  68. package/sql/40-class.sql +21 -1
  69. package/sql/80-auth.sql +88 -17
  70. package/sql/90-time.sql +34 -6
  71. package/sql/99-revision.sql +5 -1
  72. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  73. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  74. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  75. package/sql/migrate/README.md +5 -1
@@ -140,6 +140,53 @@ export declare const write: {
140
140
  code: "not_deleted";
141
141
  ru: string;
142
142
  };
143
+ 'write.restore_id_taken': {
144
+ code: "not_deleted";
145
+ ru: string;
146
+ hint: string;
147
+ };
148
+ 'write.restore_already': {
149
+ code: "not_deleted";
150
+ ru: string;
151
+ hint: string;
152
+ };
153
+ 'write.restore_stale': {
154
+ code: "conflict";
155
+ ru: string;
156
+ hint: string;
157
+ };
158
+ 'write.partial_without_expect': {
159
+ code: "invalid_query";
160
+ ru: string;
161
+ };
162
+ 'write.bad_expect': {
163
+ code: "invalid_query";
164
+ ru: string;
165
+ };
166
+ 'write.expect_without_confirm': {
167
+ code: "invalid_query";
168
+ ru: string;
169
+ };
170
+ 'write.preview_changed': {
171
+ code: "conflict";
172
+ ru: string;
173
+ hint: string;
174
+ };
175
+ 'write.link_in_data': {
176
+ code: "invalid_data";
177
+ ru: string;
178
+ hint: string;
179
+ };
180
+ 'write.confirm_without_expect': {
181
+ code: "invalid_query";
182
+ ru: string;
183
+ hint: string;
184
+ };
185
+ 'write.restore_without_expect': {
186
+ code: "invalid_query";
187
+ ru: string;
188
+ hint: string;
189
+ };
143
190
  'write.commit_unknown': {
144
191
  code: "commit_unknown";
145
192
  ru: string;
@@ -177,6 +177,69 @@ export const write = {
177
177
  code: 'not_deleted',
178
178
  ru: 'объект не удалён: {verb}() — только для удалённых',
179
179
  },
180
+ // restore() живого объекта, который уже удаляли: за последним надгробием — create (id занял новый объект с тем же
181
+ // ключом); ids — такие id; detail.ids, с expect — ещё detail.stale (все цели, которые не восстановить, с why)
182
+ 'write.restore_id_taken': {
183
+ code: 'not_deleted',
184
+ ru: '{verb}(): id {ids} занят новым объектом с тем же ключом — create() по ключу удалённого берёт его id, восстанавливать нечего; ничего не восстановлено',
185
+ hint: 'объект с этим ключом уже живёт: прочитайте его (db.Класс(id).first()); перед create() по ключу удалённого проверьте db.Класс({ ключ }).withDeleted(); восстановить остальное — restore({ expect, partial: true })',
186
+ },
187
+ // restore() живого объекта, который уже восстановили: за последним надгробием — restore (второй раунд 2.1, A2: другой
188
+ // запрос успел первым); ids — такие id; detail.ids, с expect — ещё detail.stale
189
+ 'write.restore_already': {
190
+ code: 'not_deleted',
191
+ ru: '{verb}(): {ids} уже восстановлены — объекты живые, восстанавливать нечего; ничего не восстановлено',
192
+ hint: 'их вернул другой запрос: прочитайте их (db.Класс(id).first()); восстановить остальное — restore({ expect, partial: true })',
193
+ },
194
+ // restore({ expect }): последняя версия id не та, что в expect, и это не «уже восстановлено» и не «id занят» — удалён
195
+ // снова, не удалялся, гонка; ничего не восстановлено. ids — такие id; detail.ids — они же, detail.stale — все цели,
196
+ // которые не восстановить: { id, rev, expected, after, deleted, live, why }
197
+ 'write.restore_stale': {
198
+ code: 'conflict',
199
+ ru: 'restore({ expect }): {ids} уже не те, что удаляли — после этого удаления их меняли (что было — err.detail.stale: after, deleted, live); ничего не восстановлено',
200
+ hint: 'надгробия — из ответа delete() или withDeleted(), их { id, rev } — в expect; расхождение значит, что вещь уже у другого — сообщить пользователю, а не восстанавливать; восстановить то, что можно, — restore({ expect, partial: true })',
201
+ },
202
+ // restore({ partial: true }) без expect: пропускать не по чему
203
+ 'write.partial_without_expect': {
204
+ code: 'invalid_query',
205
+ ru: 'restore({ partial: true }) — только с { expect }: пропуск считается по показанным надгробиям',
206
+ },
207
+ // restore({ expect }), delete({ expect }): expect — не список строк { id, rev }; verb — глагол
208
+ 'write.bad_expect': {
209
+ code: 'invalid_query',
210
+ ru: '{verb}({ expect }): expect — список строк { id, rev } (строки ответа)',
211
+ },
212
+ // delete({ expect }) без confirm: превью сравнивать не с чем
213
+ 'write.expect_without_confirm': {
214
+ code: 'invalid_query',
215
+ ru: 'delete({ expect }) — только с { confirm: true }: без него delete() и есть превью',
216
+ },
217
+ // delete({ confirm: true, expect }): превью в транзакции удаления не совпало с показанным; detail.preview — новое превью
218
+ 'write.preview_changed': {
219
+ code: 'conflict',
220
+ ru: 'delete({ confirm: true, expect }): превью изменилось с тех пор, как его показали — ничего не удалено',
221
+ hint: 'показать пользователю новое превью (err.detail.preview) и удалить с ним: delete({ confirm: true, expect: err.detail.preview })',
222
+ },
223
+ // write.ts, checkFullData и checkPatchEnds (3.0, вместо api.validation_failed_end): лишний ключ данных — конец класса
224
+ // (имя роли или класса на конце); keys — такие ключи, role — роль первого из них. Нарушения ValidationError дописывает
225
+ // к тексту сам
226
+ 'write.link_in_data': {
227
+ code: 'invalid_data',
228
+ ru: '{verb}() у {cls}: {keys} — связь, а не поле данных; конец задают путь цепочки или слот после глагола: .{role}.set(id)',
229
+ hint: 'конец (связь) — не поле данных: путь цепочки (db.Владелец(id).Класс().create(…)) или слот после глагола: db.Класс.create({ … }).Роль.set(id); у конца-списка — .Роль.add(id) / .Роль.remove(id), снять — .Роль.unset()',
230
+ },
231
+ // 3.0: delete({ confirm: true }) без expect и без all — удаление не сверено с показанным превью
232
+ 'write.confirm_without_expect': {
233
+ code: 'invalid_query',
234
+ ru: 'delete({ confirm: true }) без { expect }: удаление не сверено с превью, которое видел пользователь — ничего не удалено',
235
+ hint: 'передайте expect из превью: const p = await db.Класс(id).delete().rows(); показать p пользователю; await db.Класс(id).delete({ confirm: true, expect: p }).rows(). Осознанно удалить текущий состав без сверки — delete({ confirm: true, all: true })',
236
+ },
237
+ // 3.0: restore() без expect и без all — и для явных id: последнее надгробие могло смениться с тех пор, как его видели
238
+ 'write.restore_without_expect': {
239
+ code: 'invalid_query',
240
+ ru: 'restore() без { expect }: восстановление не сверено с надгробиями, которые видел пользователь — ничего не восстановлено',
241
+ hint: 'передайте expect — надгробия из ответа delete() или из db.Класс(id).withDeleted().rows(): restore({ expect }); восстановить то, что можно, — restore({ expect, partial: true }); осознанно вернуть все удалённые цели шага без сверки — restore({ all: true })',
242
+ },
180
243
  // tx.ts — tx.ts:commitUnknown: связь оборвалась во время commit
181
244
  'write.commit_unknown': {
182
245
  code: 'commit_unknown',
package/dist/model.d.ts CHANGED
@@ -210,7 +210,11 @@ type ModelOf<N extends string, D, S extends z.ZodRawShape> = Model<N, DataOf<Ext
210
210
  */
211
211
  export declare function hub<N extends string, S extends z.ZodRawShape, const O extends ClassOpts>(name: N, shape: S, opts: O): ModelOf<N, O, S>;
212
212
  export declare function hub<N extends string, const D extends ClassDef<z.ZodRawShape, EndsInput, AnyModel | undefined>>(name: N, def?: D): ModelOf<N, D, ShapeOf<D['fields']>>;
213
+ /** Короткая форма без опций: hub('Имя', { поле: z… }) — поля класса (полная форма { fields: z.object(…) } — выше). */
214
+ export declare function hub<N extends string, S extends z.ZodRawShape>(name: N, shape: S): ModelOf<N, {}, S>;
213
215
  /** Связь — отношение между объектами со своими данными (бронь, навык). */
214
216
  export declare function link<N extends string, S extends z.ZodRawShape, const O extends ClassOpts>(name: N, shape: S, opts: O): ModelOf<N, O, S>;
215
217
  export declare function link<N extends string, const D extends ClassDef<z.ZodRawShape, EndsInput, AnyModel | undefined>>(name: N, def?: D): ModelOf<N, D, ShapeOf<D['fields']>>;
218
+ /** Короткая форма без опций: link('Имя', { поле: z… }) — поля класса (полная форма { fields: z.object(…) } — выше). */
219
+ export declare function link<N extends string, S extends z.ZodRawShape>(name: N, shape: S): ModelOf<N, {}, S>;
216
220
  export { normalizeSchema, translatePattern, type JsonSchema } from './jsonschema.js';
package/dist/model.js CHANGED
@@ -73,14 +73,17 @@ const DEF_KEYS = new Set(['alias', 'extends', 'abstract', 'ends', 'fields', 'key
73
73
  function isZod(v) {
74
74
  return typeof v === 'object' && v !== null && '_zod' in v;
75
75
  }
76
- /** Пути правил refine/superRefine в схеме Zod — их нет в JSON Schema. */
76
+ /**
77
+ * Пути правил Zod, которых нет в JSON Schema: refine/superRefine (`refine`) и преобразования — `overwrite` (`.trim()`,
78
+ * `.toLowerCase()`, `.toUpperCase()`, `.normalize()` …) и `transform` (`.transform()`, `z.preprocess()`, `.pipe()`).
79
+ * Преобразования база и валидатор letopis не исполняют: их syncModels отклоняет (план 2.1, A11).
80
+ */
77
81
  function collectLost(schema, path, out) {
78
82
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
83
  const def = schema._zod.def;
80
- for (const c of def.checks ?? []) {
81
- if (c?._zod?.def?.check === 'custom')
82
- out.push({ path, kind: 'refine' });
83
- }
84
+ collectChecks(schema, path, out);
85
+ if (def.type === 'transform')
86
+ out.push({ path, kind: 'transform' });
84
87
  if (def.type === 'object')
85
88
  for (const [k, v] of Object.entries(def.shape))
86
89
  collectLost(v, `${path}.${k}`, out);
@@ -95,6 +98,17 @@ function collectLost(schema, path, out) {
95
98
  if (def.innerType)
96
99
  collectLost(def.innerType, path, out);
97
100
  }
101
+ /** Проверки самой схемы (без вложенных): refine/superRefine/check — `refine`, `overwrite` — преобразование. */
102
+ function collectChecks(schema, path, out) {
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
+ for (const c of schema._zod.def.checks ?? []) {
105
+ const check = c?._zod?.def?.check;
106
+ if (check === 'custom')
107
+ out.push({ path, kind: 'refine' });
108
+ else if (check === 'overwrite')
109
+ out.push({ path, kind: 'overwrite' });
110
+ }
111
+ }
98
112
  function makeModel(kind, name, defArg, optsArg) {
99
113
  if (!NAME.test(name))
100
114
  throw lerr('model.class_name_invalid', { name });
@@ -222,6 +236,10 @@ function makeModel(kind, name, defArg, optsArg) {
222
236
  },
223
237
  lostRules() {
224
238
  const out = [];
239
+ // проверки z.object(…).refine/superRefine/check — на уровне объекта (между полями): в описание класса идёт
240
+ // только shape, поэтому они тоже потеряны — путь — имя класса
241
+ if (fields instanceof z.ZodObject)
242
+ collectChecks(fields, name, out);
225
243
  for (const [k, v] of Object.entries(ownShape))
226
244
  collectLost(v, `${name}.${k}`, out);
227
245
  return out;
package/dist/sql.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface Step {
18
18
  tagsFilter?: unknown;
19
19
  ownerFilter?: string;
20
20
  deepMax?: number;
21
+ /** withDeleted(): узел шага — живые и надгробия удалённых (модификатор шага, план 2.1, A9). */
22
+ withDeleted?: true;
21
23
  /** Звено плана записи (этап 4): глагол на этом шаге; построитель чтения его не видит. */
22
24
  op?: import('./write.js').PlanOp;
23
25
  /** Модификаторы цепочки на момент глагола: limit и sort сужают цели записи. */
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?: {
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)
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 {};