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/CHANGELOG.md +269 -0
- package/README.md +2472 -240
- package/dist/acl.d.ts +48 -0
- package/dist/acl.js +208 -0
- package/dist/auth.d.ts +116 -0
- package/dist/auth.js +263 -0
- package/dist/chain.d.ts +81 -28
- package/dist/chain.js +312 -72
- package/dist/index.d.ts +10 -3
- package/dist/index.js +26 -3
- package/dist/schema.js +107 -4
- package/dist/sessions.d.ts +32 -0
- package/dist/sessions.js +48 -0
- package/dist/sql.d.ts +43 -1
- package/dist/sql.js +238 -79
- package/dist/tx.d.ts +1 -1
- package/dist/tx.js +16 -1
- package/dist/types.d.ts +81 -2
- package/dist/up.d.ts +48 -0
- package/dist/up.js +209 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +32 -0
- package/dist/write.d.ts +29 -16
- package/dist/write.js +427 -98
- package/docker/Dockerfile +17 -0
- package/docker/start.sh +4 -0
- package/package.json +16 -2
- package/sql/ddl.sql +404 -0
- package/sql/seed.auth.sql +46 -0
- package/sql/seed.booking.sql +69 -0
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
return (a) => `${
|
|
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) => `${
|
|
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) => `${
|
|
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) => `${
|
|
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}
|
|
98
|
-
: (a) => `NOT (${a}
|
|
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],
|
|
291
|
+
out.finalFrags.push(compileOp([key], cls.fieldTypes.get(key), value, p));
|
|
175
292
|
continue;
|
|
176
293
|
}
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
325
|
+
if (linksTo(prev, next.id))
|
|
219
326
|
return 'forward';
|
|
220
|
-
if (next
|
|
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
|
-
|
|
236
|
-
|
|
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.<
|
|
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
|
|
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
|
|
355
|
+
const cast = mods.order === 'updated' ? '::timestamptz' : castOf(leafType(cls, mods.order.slice(5).split('.')));
|
|
253
356
|
const cmp = mods.desc ? '<' : '>';
|
|
254
|
-
return `
|
|
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 =
|
|
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,
|
|
317
|
-
outerWhere.push(`t.links @> jsonb_build_object(${pKey}::text,
|
|
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[
|
|
354
|
-
// ключи шагов в путях: alias → имя вызова; дубликаты
|
|
355
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|