letopis 0.5.0 → 0.16.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.
package/dist/sql.js CHANGED
@@ -1,22 +1,63 @@
1
1
  import { OP } from './types.js';
2
2
  import { isOp } from './ops.js';
3
+ import { aclDenied } from './acl.js';
4
+ /** Транзиентные ошибки PG — гонка, снимаемая повтором: deadlock / serialization failure. */
5
+ export function isTransient(e) {
6
+ const code = e.code;
7
+ return code === '40P01' || code === '40001';
8
+ }
9
+ /** Внутри db.begin() повтор невозможен (вся транзакция aborted) — дописываем подсказку. */
10
+ export function hintTxRetry(e) {
11
+ const err = e;
12
+ if (err.letopisHinted || typeof err.message !== 'string')
13
+ return;
14
+ err.letopisHinted = true;
15
+ err.message += ' — letopis: transaction is aborted, retry the whole db.begin() block';
16
+ }
17
+ export const RETRIES = 3;
18
+ export const retryDelay = (attempt) => new Promise((r) => setTimeout(r, 40 * attempt + Math.random() * 40));
19
+ const READ_MODES = new Set([
20
+ 'paths', 'rows', 'ids', 'count', 'versions', 'agg',
21
+ ]);
3
22
  /**
4
23
  * Единая точка исполнения запросов цепочек: sql.unsafe + событие onQuery/slowMs.
24
+ * Чтения в автокоммите ретраят transient-ошибки (40P01/40001); внутри db.begin()
25
+ * повтор запрещён семантикой PG — ошибка уходит наружу с подсказкой.
5
26
  * Служебные запросы (loadRegistry, tables, listen) через неё не идут.
6
27
  */
7
28
  export async function runQuery(ctx, text, params, mode, classes) {
8
- const t0 = performance.now();
9
- const res = (await ctx.sql.unsafe(text, params));
10
- if (ctx.onQuery || ctx.slowMs !== undefined) {
11
- const ms = performance.now() - t0;
12
- const slow = ctx.slowMs !== undefined && ms > ctx.slowMs;
13
- if (ctx.onQuery)
14
- ctx.onQuery({ mode, classes, ms, rows: res.length, slow });
15
- else if (slow)
16
- console.warn(`letopis: slow query ${ms.toFixed(0)}ms — ${mode} ${classes.join('→')}`);
29
+ for (let attempt = 1;; attempt++) {
30
+ const t0 = performance.now();
31
+ try {
32
+ const res = (await ctx.sql.unsafe(text, params));
33
+ if (ctx.onQuery || ctx.slowMs !== undefined) {
34
+ const ms = performance.now() - t0;
35
+ const slow = ctx.slowMs !== undefined && ms > ctx.slowMs;
36
+ if (ctx.onQuery)
37
+ ctx.onQuery({ mode, classes, ms, rows: res.length, slow });
38
+ else if (slow)
39
+ console.warn(`letopis: slow query ${ms.toFixed(0)}ms — ${mode} ${classes.join('→')}`);
40
+ }
41
+ return res;
42
+ }
43
+ catch (e) {
44
+ if (isTransient(e)) {
45
+ if (ctx.inTx) {
46
+ if (ctx.userTx)
47
+ hintTxRetry(e);
48
+ }
49
+ else if (READ_MODES.has(mode) && attempt < RETRIES) {
50
+ await retryDelay(attempt);
51
+ continue;
52
+ }
53
+ }
54
+ throw e;
55
+ }
17
56
  }
18
- return res;
19
57
  }
58
+ let nodeSeq = 0;
59
+ /** Уникальная метка узла пути (для pivot-возвратов; переживает нарезку плана). */
60
+ export const nextNodeKey = () => ++nodeSeq;
20
61
  /** Накопитель позиционных параметров. */
21
62
  class Params {
22
63
  list = [];
@@ -28,6 +69,35 @@ class Params {
28
69
  const escId = (s) => `"${s.replace(/"/g, '""')}"`;
29
70
  const escLit = (s) => s.replace(/'/g, "''");
30
71
  export const entityTable = (pgSchema) => `${escId(pgSchema)}.${escId('Entity')}`;
72
+ /**
73
+ * ACL-шаблон строки Entity → WHERE-фраги: колонки равенством, jsonb/tags — containment.
74
+ * Вливается и в кандидаты (GIN), и в перепроверку — ДО сортировки/лимита (пагинация цела).
75
+ */
76
+ function rowTemplateFrags(tpl, p) {
77
+ const frags = [];
78
+ for (const [k, v] of Object.entries(tpl)) {
79
+ if (k === 'data' || k === 'links') {
80
+ const ph = p.push(v);
81
+ frags.push((a) => `${a}.${k} @> ${ph}::jsonb`);
82
+ }
83
+ else if (k === 'tags') {
84
+ const ph = p.push(Array.isArray(v) ? v : [v]);
85
+ frags.push((a) => `${a}.tags @> ${ph}`);
86
+ }
87
+ else if (k === 'owner' || k === 'account') {
88
+ const ph = p.push(v);
89
+ frags.push((a) => `${a}.${k} = ${ph}::uuid`);
90
+ }
91
+ else if (k === 'id' || k === 'partition') {
92
+ const ph = p.push(v);
93
+ frags.push((a) => `${a}.${k} = ${ph}`);
94
+ }
95
+ else {
96
+ throw new Error(`letopis: acl pattern key "${k}" is not an Entity column`);
97
+ }
98
+ }
99
+ return frags;
100
+ }
31
101
  function castOf(ft) {
32
102
  switch (ft?.kind) {
33
103
  case 'number':
@@ -42,11 +112,23 @@ function castOf(ft) {
42
112
  }
43
113
  /** Каст параметра по JS-типу (для jsonb_build_array). */
44
114
  const jsCast = (v) => typeof v === 'number' ? '::numeric' : typeof v === 'boolean' ? '::boolean' : '::text';
45
- /** Доступ к полю data: 1 уровень — data->>'f'; 2 (record) — data->'f'->>'k'. */
115
+ /** jsonb-выражение до пути (без извлечения текста): data->'a'->'b'. */
116
+ function jsonbAt(path) {
117
+ return (a) => `${a}.data${path.map((k) => `->'${escLit(k)}'`).join('')}`;
118
+ }
119
+ /** Текст листа по data-пути ЛЮБОЙ глубины: data->'a'->'b'->>'c'. */
46
120
  function accessor(path) {
47
- if (path.length === 1)
48
- return (a) => `${a}.data->>'${escLit(path[0])}'`;
49
- return (a) => `${a}.data->'${escLit(path[0])}'->>'${escLit(path[1])}'`;
121
+ const head = path.slice(0, -1);
122
+ const last = path[path.length - 1];
123
+ return (a) => `${jsonbAt(head)(a)}->>'${escLit(last)}'`;
124
+ }
125
+ /** Тип листа data-пути по Schema: спуск через record (значение) и object (props). */
126
+ export function leafType(cls, path) {
127
+ let ft = cls.fieldTypes.get(path[0]);
128
+ for (let i = 1; i < path.length && ft; i++) {
129
+ ft = ft.kind === 'record' ? ft.value : ft.kind === 'object' ? ft.props.get(path[i]) : undefined;
130
+ }
131
+ return ft;
50
132
  }
51
133
  function compileOp(path, ft, o, p) {
52
134
  const lhs = accessor(path);
@@ -79,23 +161,26 @@ function compileOp(path, ft, o, p) {
79
161
  return (a) => `${lhs(a)} ILIKE ${p.push(args[0])}`;
80
162
  case 'has':
81
163
  // jsonb_build_array с кастом по JS-типу — JS-массив postgres.js слал бы как PG-array
82
- return (a) => `${a}.data->'${escLit(path[0])}' @> jsonb_build_array(${p.push(args[0])}${jsCast(args[0])})`;
164
+ return (a) => `${jsonbAt(path)(a)} @> jsonb_build_array(${p.push(args[0])}${jsCast(args[0])})`;
83
165
  case 'hasAll': {
84
166
  const vs = args[0];
85
167
  if (!vs.length)
86
168
  return () => 'TRUE';
87
- return (a) => `${a}.data->'${escLit(path[0])}' @> jsonb_build_array(${vs.map((x) => p.push(x) + jsCast(x)).join(', ')})`;
169
+ return (a) => `${jsonbAt(path)(a)} @> jsonb_build_array(${vs.map((x) => p.push(x) + jsCast(x)).join(', ')})`;
88
170
  }
89
171
  case 'hasAny': {
90
172
  const vs = args[0];
91
173
  if (!vs.length)
92
174
  return () => 'FALSE';
93
- return (a) => `${a}.data->'${escLit(path[0])}' ?| ARRAY[${vs.map((x) => p.push(String(x))).join(', ')}]::text[]`;
175
+ return (a) => `${jsonbAt(path)(a)} ?| ARRAY[${vs.map((x) => p.push(String(x))).join(', ')}]::text[]`;
94
176
  }
95
- case 'exists':
177
+ case 'exists': {
178
+ const holder = jsonbAt(path.slice(0, -1));
179
+ const last = escLit(path[path.length - 1]);
96
180
  return args[0]
97
- ? (a) => `${a}.data ? '${escLit(path[0])}'`
98
- : (a) => `NOT (${a}.data ? '${escLit(path[0])}')`;
181
+ ? (a) => `${holder(a)} ? '${last}'`
182
+ : (a) => `NOT (${holder(a)} ? '${last}')`;
183
+ }
99
184
  case 'isNull':
100
185
  return (a) => `${lhs(a)} IS NULL`;
101
186
  case 'not': {
@@ -157,6 +242,39 @@ function compileFilter(cls, filter, p) {
157
242
  return out;
158
243
  }
159
244
  const eqData = {};
245
+ const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
246
+ /**
247
+ * Рекурсивный разбор объекта фильтра на ЛЮБУЮ глубину:
248
+ * операторы уходят в finalFrags по своему пути (каст по типу листа из Schema),
249
+ * скаляры/массивы собираются в containment-поддерево (GIN).
250
+ * Возврат: поддерево для eqData; undefined — внутри были только операторы.
251
+ */
252
+ const walk = (path, obj) => {
253
+ const eq = {};
254
+ let hadOps = false;
255
+ for (const [k, v] of Object.entries(obj)) {
256
+ if (v === undefined)
257
+ continue;
258
+ const sub = [...path, k];
259
+ if (isOp(v)) {
260
+ out.finalFrags.push(compileOp(sub, leafType(cls, sub), v, p));
261
+ hadOps = true;
262
+ }
263
+ else if (isPlain(v)) {
264
+ const nested = walk(sub, v);
265
+ if (nested !== undefined)
266
+ eq[k] = nested;
267
+ else
268
+ hadOps = true;
269
+ }
270
+ else {
271
+ eq[k] = v;
272
+ }
273
+ }
274
+ if (Object.keys(eq).length)
275
+ return eq;
276
+ return hadOps ? undefined : {}; // пустой объект без операторов → containment {}
277
+ };
160
278
  for (const [key, value] of Object.entries(filter)) {
161
279
  if (value === undefined)
162
280
  continue;
@@ -169,29 +287,14 @@ function compileFilter(cls, filter, p) {
169
287
  throw new Error('letopis: filter.id accepts string | string[]');
170
288
  continue;
171
289
  }
172
- const ft = cls.fieldTypes.get(key);
173
290
  if (isOp(value)) {
174
- out.finalFrags.push(compileOp([key], ft, value, p));
291
+ out.finalFrags.push(compileOp([key], cls.fieldTypes.get(key), value, p));
175
292
  continue;
176
293
  }
177
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
178
- // объект: операторы по вложенным путям (record) и/или containment-скаляры
179
- const nestedEq = {};
180
- let hasNested = false;
181
- for (const [sub, sv] of Object.entries(value)) {
182
- if (isOp(sv)) {
183
- const vt = ft?.kind === 'record' ? ft.value : undefined;
184
- out.finalFrags.push(compileOp([key, sub], vt, sv, p));
185
- hasNested = true;
186
- }
187
- else {
188
- nestedEq[sub] = sv;
189
- }
190
- }
191
- if (Object.keys(nestedEq).length)
192
- eqData[key] = nestedEq;
193
- else if (!hasNested)
194
- eqData[key] = value;
294
+ if (isPlain(value)) {
295
+ const nested = walk([key], value);
296
+ if (nested !== undefined)
297
+ eqData[key] = nested;
195
298
  continue;
196
299
  }
197
300
  // скаляр / массив — точное совпадение через containment (GIN)
@@ -205,6 +308,10 @@ function compileFilter(cls, filter, p) {
205
308
  }
206
309
  return out;
207
310
  }
311
+ /** Класс target входит в какой-нибудь конец def (союзы учитываются). */
312
+ export function linksTo(def, target) {
313
+ return def.links.some((end) => end.classes.includes(target));
314
+ }
208
315
  /** Правило обхода между шагами (по реестру Schema). */
209
316
  export function resolveHop(prev, next) {
210
317
  if (prev.category === 'HUB' && next.category === 'LINK')
@@ -215,52 +322,83 @@ export function resolveHop(prev, next) {
215
322
  // self-переход (Папка→Папка) = reverse: дети; родитель и так лежит в row.links
216
323
  if (prev.id === next.id)
217
324
  return 'reverse';
218
- if (prev.links.includes(next.id))
325
+ if (linksTo(prev, next.id))
219
326
  return 'forward';
220
- if (next.links.includes(prev.id))
327
+ if (linksTo(next, prev.id))
221
328
  return 'reverse';
222
329
  throw new Error(`letopis: no path ${prev.id} → ${next.id}: neither embeds the other (Schema.links)`);
223
330
  }
224
331
  throw new Error(`letopis: LINK → LINK traversal is not supported (${prev.id} → ${next.id})`);
225
332
  }
226
- /** Тип листа data-пути (для каста агрегаций): 1 уровень или record-значение. */
227
- function cls0Field(cls, path) {
228
- const ft = cls.fieldTypes.get(path[0]);
229
- return path.length === 1 ? ft : ft?.kind === 'record' ? ft.value : undefined;
230
- }
231
333
  function sortField(alias, cls, order) {
232
334
  if (order === 'updated')
233
335
  return `${alias}.updated`;
234
336
  if (order.startsWith('data.')) {
235
- const field = order.slice(5);
236
- return `(${alias}.data->>'${escLit(field)}')${castOf(cls.fieldTypes.get(field))}`;
337
+ // data-путь любой глубины ('data.settings.booking.deposit.amount') + каст по типу листа
338
+ const path = order.slice(5).split('.');
339
+ return `(${accessor(path)(alias)})${castOf(leafType(cls, path))}`;
237
340
  }
238
- throw new Error(`letopis: order must be 'updated' or 'data.<field>'`);
341
+ throw new Error(`letopis: order must be 'updated' or 'data.<path>'`);
239
342
  }
240
343
  function orderExpr(alias, cls, mods) {
241
344
  if (!mods.order)
242
345
  return '';
243
346
  return ` ORDER BY ${sortField(alias, cls, mods.order)}${mods.desc ? ' DESC' : ''}`;
244
347
  }
245
- /** Keyset-пагинация: (поле сортировки, id) строго после курсора. Требует .sort(). */
246
- function afterCond(alias, cls, mods, p) {
348
+ /** Keyset-пагинация: (поле сортировки, id) строго после курсора. Требует .sort(). Выражение без WHERE. */
349
+ function afterExpr(alias, cls, mods, p) {
247
350
  if (!mods.after)
248
- return '';
351
+ return undefined;
249
352
  if (!mods.order)
250
353
  throw new Error('letopis: .after(cursor) requires .sort(field)');
251
354
  const expr = sortField(alias, cls, mods.order);
252
- const cast = mods.order === 'updated' ? '::timestamptz' : castOf(cls.fieldTypes.get(mods.order.slice(5)));
355
+ const cast = mods.order === 'updated' ? '::timestamptz' : castOf(leafType(cls, mods.order.slice(5).split('.')));
253
356
  const cmp = mods.desc ? '<' : '>';
254
- return ` WHERE (${expr}, ${alias}.id) ${cmp} (${p.push(mods.after.v)}${cast}, ${p.push(mods.after.id)})`;
357
+ return `(${expr}, ${alias}.id) ${cmp} (${p.push(mods.after.v)}${cast}, ${p.push(mods.after.id)})`;
255
358
  }
359
+ const whereOf = (conds) => {
360
+ const list = conds.filter((c) => !!c);
361
+ return list.length ? ` WHERE ${list.join(' AND ')}` : '';
362
+ };
256
363
  /** Построить читающий запрос по цепочке. */
257
364
  export function buildRead(ctx, steps, mods, mode) {
258
365
  const p = new Params();
259
366
  const ent = entityTable(ctx.pgSchema);
260
367
  const pPart = p.push(ctx.partition);
368
+ // pivot: карта эффективных блоков. real[i] — индекс блока-узла шага i
369
+ // (pivot-шаг блока не строит, ссылается на узел по pivotKey).
370
+ const real = [];
371
+ const byNodeKey = new Map();
372
+ const tailConds = []; // дофильтры pivot-шагов (alias подставлен) — в хвостовой WHERE
373
+ for (let i = 0; i < steps.length; i++) {
374
+ const s = steps[i];
375
+ if (s.pivotKey !== undefined) {
376
+ const at = byNodeKey.get(s.pivotKey);
377
+ if (at === undefined)
378
+ throw new Error(`letopis: pivot "${s.name}" — node is not in this path`);
379
+ real[i] = at;
380
+ }
381
+ else {
382
+ real[i] = i;
383
+ if (s.nodeKey !== undefined)
384
+ byNodeKey.set(s.nodeKey, i);
385
+ }
386
+ }
261
387
  const blocks = [];
262
388
  for (let i = 0; i < steps.length; i++) {
263
389
  const step = steps[i];
390
+ // pivot-шаг: узел уже есть — только дофильтры (AND на алиас узла)
391
+ if (step.pivotKey !== undefined) {
392
+ const a = `h${real[i]}`;
393
+ const f = compileFilter(step.cls, step.filter, p);
394
+ for (const c of f.idConds)
395
+ tailConds.push(c(a));
396
+ for (const fr of f.finalFrags)
397
+ tailConds.push(fr(a));
398
+ if (step.tagsFilter !== undefined)
399
+ tailConds.push(compileTags(step.tagsFilter, p)(a));
400
+ continue;
401
+ }
264
402
  const pCls = p.push(step.cls.id);
265
403
  const f = compileFilter(step.cls, step.filter, p);
266
404
  // модификаторы шага: .tags() / .account() / .owner()
@@ -292,6 +430,25 @@ export function buildRead(ctx, steps, mods, mode) {
292
430
  f.candFrags.push(frag);
293
431
  f.finalFrags.push(frag);
294
432
  }
433
+ // enforceAcl: READ-право на КАЖДЫЙ шаг; предикат победившего правила — в WHERE заранее
434
+ if (ctx.aclDecide) {
435
+ const d = ctx.aclDecide(step.cls, 'READ');
436
+ if (!d.allow)
437
+ throw aclDenied('READ', step.cls.id, d);
438
+ if (d.filter) {
439
+ for (const fr of rowTemplateFrags(d.filter, p)) {
440
+ f.candFrags.push(fr);
441
+ f.finalFrags.push(fr);
442
+ }
443
+ }
444
+ }
445
+ // предикат WRITE/DELETE-правила (поиск целей записи): только свои строки
446
+ if (step.aclFilter) {
447
+ for (const fr of rowTemplateFrags(step.aclFilter, p)) {
448
+ f.candFrags.push(fr);
449
+ f.finalFrags.push(fr);
450
+ }
451
+ }
295
452
  const innerWhere = [`e.partition = ${pPart}`, `e.class = ${pCls}`];
296
453
  if (mods.asOf !== undefined)
297
454
  innerWhere.push(`e.updated <= ${p.push(mods.asOf)}::timestamptz`); // «как было на T»
@@ -305,16 +462,17 @@ export function buildRead(ctx, steps, mods, mode) {
305
462
  const isDeep = step.deepMax !== undefined;
306
463
  if (i > 0 && !isDeep) { // deep строит containment сам (recursive CTE)
307
464
  const prev = steps[i - 1];
465
+ const prevAlias = `h${real[i - 1]}`; // pivot: ветвление от узла-возврата
308
466
  const hop = resolveHop(prev.cls, step.cls);
309
467
  if (hop === 'forward') {
310
468
  // id неизменен — условие сразу во внутренний WHERE, перепроверка не нужна
311
- innerWhere.push(`e.id = h${i - 1}.links->>${p.push(step.cls.id)}`);
469
+ innerWhere.push(`e.id = ${prevAlias}.links->>${p.push(step.cls.id)}`);
312
470
  }
313
471
  else {
314
472
  // reverse containment: кандидаты по GIN + перепроверка на latest
315
473
  const pKey = p.push(prev.cls.id);
316
- candWhere.push(`c.links @> jsonb_build_object(${pKey}::text, h${i - 1}.id)`);
317
- outerWhere.push(`t.links @> jsonb_build_object(${pKey}::text, h${i - 1}.id)`);
474
+ candWhere.push(`c.links @> jsonb_build_object(${pKey}::text, ${prevAlias}.id)`);
475
+ outerWhere.push(`t.links @> jsonb_build_object(${pKey}::text, ${prevAlias}.id)`);
318
476
  }
319
477
  }
320
478
  if (candWhere.length) {
@@ -341,7 +499,7 @@ export function buildRead(ctx, steps, mods, mode) {
341
499
  ` WHERE t.deleted IS NULL AND t.links @> jsonb_build_object(${p.push(step.cls.id)}::text, ${ref}))`;
342
500
  block =
343
501
  `(WITH RECURSIVE d AS (` +
344
- `SELECT k.*, 1 AS depth FROM ${kids(`h${i - 1}.id`)} k` +
502
+ `SELECT k.*, 1 AS depth FROM ${kids(`h${real[i - 1]}.id`)} k` +
345
503
  ` UNION ALL ` +
346
504
  `SELECT k.*, d.depth + 1 FROM d JOIN LATERAL ${kids('d.id')} k ON true WHERE d.depth < ${pMax}::int` +
347
505
  `) SELECT * FROM d) h${i}`;
@@ -349,45 +507,49 @@ export function buildRead(ctx, steps, mods, mode) {
349
507
  blocks.push(i === 0 ? `FROM ${block}` : `JOIN LATERAL ${block} ON true`);
350
508
  }
351
509
  const fromClause = blocks.join('\n');
352
- const last = steps.length - 1;
353
- const lastCls = steps[last].cls;
354
- // ключи шагов в путях: alias → имя вызова; дубликаты получают _2, _3…
355
- const keys = [];
510
+ const last = real[steps.length - 1]; // pivot в конце → терминал на его узле
511
+ const lastCls = steps[steps.length - 1].cls;
512
+ // ключи шагов в путях: alias → имя вызова; pivot узла не добавляет; дубликаты — _2, _3…
513
+ const keyed = [];
356
514
  const seen = new Map();
357
- for (const s of steps) {
515
+ for (let i = 0; i < steps.length; i++) {
516
+ const s = steps[i];
517
+ if (s.pivotKey !== undefined)
518
+ continue;
358
519
  const base = s.aliasKey ?? s.name;
359
520
  const n = (seen.get(base) ?? 0) + 1;
360
521
  seen.set(base, n);
361
- keys.push(n === 1 ? base : `${base}_${n}`);
522
+ keyed.push({ key: n === 1 ? base : `${base}_${n}`, block: i });
362
523
  }
524
+ const keys = keyed.map((k) => k.key);
363
525
  const limitOffset = (mods.limit !== undefined ? ` LIMIT ${p.push(mods.limit)}` : '') +
364
526
  (mods.offset !== undefined ? ` OFFSET ${p.push(mods.offset)}` : '');
365
527
  let text;
366
528
  switch (mode) {
367
529
  case 'paths': {
368
- const obj = keys.map((k, i) => `${p.push(k)}::text, to_jsonb(h${i})`).join(', ');
530
+ const obj = keyed.map(({ key, block }) => `${p.push(key)}::text, to_jsonb(h${block})`).join(', ');
369
531
  text =
370
532
  `SELECT jsonb_build_object(${obj}) AS path\n${fromClause}` +
371
- afterCond(`h${last}`, lastCls, mods, p) +
533
+ whereOf([...tailConds, afterExpr(`h${last}`, lastCls, mods, p)]) +
372
534
  orderExpr(`h${last}`, lastCls, mods) +
373
535
  limitOffset;
374
536
  break;
375
537
  }
376
538
  case 'rows':
377
539
  case 'ids': {
378
- const inner = `SELECT DISTINCT ON (h${last}.id) h${last}.* \n${fromClause}\n` +
540
+ const inner = `SELECT DISTINCT ON (h${last}.id) h${last}.* \n${fromClause}${whereOf(tailConds)}\n` +
379
541
  `ORDER BY h${last}.id, h${last}.updated DESC`;
380
542
  const sel = mode === 'rows' ? 'to_jsonb(z) AS row' : 'z.id';
381
543
  text =
382
544
  `SELECT ${sel} FROM (${inner}) z` +
383
- afterCond('z', lastCls, mods, p) +
545
+ whereOf([afterExpr('z', lastCls, mods, p)]) +
384
546
  orderExpr('z', lastCls, mods) +
385
547
  limitOffset;
386
548
  break;
387
549
  }
388
550
  case 'versions': {
389
551
  // ВСЕ версии (включая tombstone) сущностей последнего шага, по возрастанию updated
390
- const inner = `SELECT DISTINCT h${last}.id \n${fromClause}`;
552
+ const inner = `SELECT DISTINCT h${last}.id \n${fromClause}${whereOf(tailConds)}`;
391
553
  text =
392
554
  `SELECT to_jsonb(v) AS row FROM (${inner}) z ` +
393
555
  `JOIN ${ent} v ON v.partition = ${pPart} AND v.class = ${p.push(lastCls.id)} AND v.id = z.id ` +
@@ -396,7 +558,7 @@ export function buildRead(ctx, steps, mods, mode) {
396
558
  break;
397
559
  }
398
560
  case 'count':
399
- text = `SELECT count(*)::int AS n\n${fromClause}`;
561
+ text = `SELECT count(*)::int AS n\n${fromClause}${whereOf(tailConds)}`;
400
562
  break;
401
563
  case 'agg': {
402
564
  const { aggFn, aggField } = mods;
@@ -404,17 +566,14 @@ export function buildRead(ctx, steps, mods, mode) {
404
566
  throw new Error("letopis: aggregation needs field 'data.<путь>'");
405
567
  }
406
568
  const path = aggField.slice(5).split('.');
407
- const acc = path.length === 1
408
- ? `h${last}.data->>'${escLit(path[0])}'`
409
- : `h${last}.data->'${escLit(path[0])}'->>'${escLit(path[1])}'`;
569
+ const acc = accessor(path)(`h${last}`); // data-путь любой глубины
410
570
  if (aggFn === 'countBy') {
411
- text = `SELECT ${acc} AS k, count(*)::int AS n\n${fromClause}\nGROUP BY 1 ORDER BY 2 DESC`;
571
+ text = `SELECT ${acc} AS k, count(*)::int AS n\n${fromClause}${whereOf(tailConds)}\nGROUP BY 1 ORDER BY 2 DESC`;
412
572
  }
413
573
  else {
414
- // sum/avg — всегда numeric; min/max — по типу поля
415
- const ft = cls0Field(lastCls, path);
416
- const cast = aggFn === 'sum' || aggFn === 'avg' ? '::numeric' : castOf(ft);
417
- text = `SELECT ${aggFn}((${acc})${cast}) AS v\n${fromClause}`;
574
+ // sum/avg — всегда numeric; min/max — по типу листа
575
+ const cast = aggFn === 'sum' || aggFn === 'avg' ? '::numeric' : castOf(leafType(lastCls, path));
576
+ text = `SELECT ${aggFn}((${acc})${cast}) AS v\n${fromClause}${whereOf(tailConds)}`;
418
577
  }
419
578
  break;
420
579
  }
package/dist/tx.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * db.commit(tr) / db.rollback(tr) / tr.commit() / tr.rollback().
4
4
  * tr.lock(...) — pg_advisory_xact_lock (сериализация гонок, напр. двойная бронь).
5
5
  */
6
- import type { Ctx } from './sql.js';
6
+ import { type Ctx } from './sql.js';
7
7
  export interface TxCtx extends Ctx {
8
8
  commit(): Promise<void>;
9
9
  rollback(): Promise<void>;
package/dist/tx.js CHANGED
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Транзакции: db.begin() → tr (тот же API на выделенном соединении),
3
+ * db.commit(tr) / db.rollback(tr) / tr.commit() / tr.rollback().
4
+ * tr.lock(...) — pg_advisory_xact_lock (сериализация гонок, напр. двойная бронь).
5
+ */
6
+ import { hintTxRetry, isTransient } from './sql.js';
1
7
  export async function beginTx(ctx) {
2
8
  const reserved = await ctx.sql.reserve();
3
9
  const r = reserved;
@@ -18,6 +24,7 @@ export async function beginTx(ctx) {
18
24
  ...ctx,
19
25
  sql: reserved,
20
26
  inTx: true,
27
+ userTx: true,
21
28
  commit: () => finish('COMMIT'),
22
29
  rollback: () => finish('ROLLBACK'),
23
30
  };
@@ -27,5 +34,13 @@ export async function lock(ctx, ...keys) {
27
34
  if (!ctx.inTx) {
28
35
  throw new Error('letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock)');
29
36
  }
30
- await ctx.sql.unsafe('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [keys.join('|')]);
37
+ try {
38
+ await ctx.sql.unsafe('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [keys.join('|')]);
39
+ }
40
+ catch (e) {
41
+ // встречный порядок lock() двух транзакций → deadlock 40P01 (PG убивает одну)
42
+ if (isTransient(e))
43
+ hintTxRetry(e);
44
+ throw e;
45
+ }
31
46
  }