letopis 0.5.0 → 0.13.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 +171 -0
- package/README.md +2280 -211
- 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 +51 -26
- package/dist/chain.js +143 -74
- package/dist/index.d.ts +9 -3
- package/dist/index.js +24 -3
- package/dist/schema.js +11 -0
- package/dist/sessions.d.ts +32 -0
- package/dist/sessions.js +48 -0
- package/dist/sql.d.ts +23 -1
- package/dist/sql.js +167 -55
- package/dist/tx.d.ts +1 -1
- package/dist/tx.js +16 -1
- package/dist/types.d.ts +44 -1
- package/dist/up.d.ts +48 -0
- package/dist/up.js +209 -0
- package/dist/write.d.ts +18 -12
- package/dist/write.js +247 -36
- package/docker/Dockerfile +17 -0
- package/docker/start.sh +4 -0
- package/package.json +16 -2
- package/sql/ddl.sql +362 -0
- package/sql/seed.auth.sql +46 -0
- package/sql/seed.booking.sql +64 -0
package/dist/sql.d.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Цепочка шагов соединяется JOIN LATERAL — комбинации путей сохраняются.
|
|
19
19
|
*/
|
|
20
20
|
import type { Sql } from 'postgres';
|
|
21
|
-
import type { ClassDef, ChainMods, Filter, QueryEvent } from './types.js';
|
|
21
|
+
import type { AclOp, AclDecision, ClassDef, ChainMods, FieldType, Filter, QueryEvent } from './types.js';
|
|
22
22
|
import type { Registry } from './schema.js';
|
|
23
23
|
export interface Ctx {
|
|
24
24
|
sql: Sql;
|
|
@@ -31,15 +31,27 @@ export interface Ctx {
|
|
|
31
31
|
systemAccount?: string;
|
|
32
32
|
/** Жёсткая изоляция арендатора: все чтения фильтруются, записи пришпилены к account. */
|
|
33
33
|
enforceAccount?: boolean;
|
|
34
|
+
/** enforceAcl: скомпилированный на connect резолвер Rule/Resource (READ/WRITE/DELETE). */
|
|
35
|
+
aclDecide?: (cls: ClassDef, op: AclOp) => AclDecision;
|
|
34
36
|
/** true внутри db.begin()-транзакции (write не оборачивает в begin повторно). */
|
|
35
37
|
inTx?: boolean;
|
|
38
|
+
/** true только у db.begin()-транзакций (не у внутренних): управляет подсказкой при 40P01/40001. */
|
|
39
|
+
userTx?: boolean;
|
|
36
40
|
/** Наблюдаемость: хук на каждый запрос цепочки. */
|
|
37
41
|
onQuery?: (e: QueryEvent) => void;
|
|
38
42
|
/** Порог «медленного» запроса, мс (без onQuery — console.warn). */
|
|
39
43
|
slowMs?: number;
|
|
40
44
|
}
|
|
45
|
+
/** Транзиентные ошибки PG — гонка, снимаемая повтором: deadlock / serialization failure. */
|
|
46
|
+
export declare function isTransient(e: unknown): boolean;
|
|
47
|
+
/** Внутри db.begin() повтор невозможен (вся транзакция aborted) — дописываем подсказку. */
|
|
48
|
+
export declare function hintTxRetry(e: unknown): void;
|
|
49
|
+
export declare const RETRIES = 3;
|
|
50
|
+
export declare const retryDelay: (attempt: number) => Promise<void>;
|
|
41
51
|
/**
|
|
42
52
|
* Единая точка исполнения запросов цепочек: sql.unsafe + событие onQuery/slowMs.
|
|
53
|
+
* Чтения в автокоммите ретраят transient-ошибки (40P01/40001); внутри db.begin()
|
|
54
|
+
* повтор запрещён семантикой PG — ошибка уходит наружу с подсказкой.
|
|
43
55
|
* Служебные запросы (loadRegistry, tables, listen) через неё не идут.
|
|
44
56
|
*/
|
|
45
57
|
export declare function runQuery<T>(ctx: Ctx, text: string, params: unknown[], mode: QueryEvent['mode'], classes: string[]): Promise<T[]>;
|
|
@@ -58,10 +70,20 @@ export interface Step {
|
|
|
58
70
|
ownerFilter?: string;
|
|
59
71
|
/** Внутреннее (write-цепочки): containment по links — контекст-связи. */
|
|
60
72
|
linksFilter?: Record<string, string>;
|
|
73
|
+
/** .link(Класс, target) — значение связи при записи БЕЗ участия в фильтре целей. */
|
|
74
|
+
extraLinks?: Record<string, string>;
|
|
75
|
+
/** Внутреннее (enforceAcl): предикат WRITE/DELETE-правила — цели ищутся только среди своих. */
|
|
76
|
+
aclFilter?: Record<string, unknown>;
|
|
61
77
|
/** .deep(max): рекурсивный self-обход (дети любой глубины), только reverse того же класса. */
|
|
62
78
|
deepMax?: number;
|
|
79
|
+
/** Операция записи на шаге (.set/.delete/.anonymize) — исполняется терминалом плана. */
|
|
80
|
+
op?: import('./types.js').PlanOp;
|
|
81
|
+
/** Внутреннее: шаг «те же сущности» (операция сразу после операции) — цель = строки старта. */
|
|
82
|
+
self?: boolean;
|
|
63
83
|
}
|
|
64
84
|
export declare const entityTable: (pgSchema: string) => string;
|
|
85
|
+
/** Тип листа data-пути по Schema: спуск через record (значение) и object (props). */
|
|
86
|
+
export declare function leafType(cls: ClassDef, path: string[]): FieldType | undefined;
|
|
65
87
|
type HopMode = 'forward' | 'reverse';
|
|
66
88
|
/** Правило обхода между шагами (по реестру Schema). */
|
|
67
89
|
export declare function resolveHop(prev: ClassDef, next: ClassDef): HopMode;
|
package/dist/sql.js
CHANGED
|
@@ -1,21 +1,59 @@
|
|
|
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
|
}
|
|
20
58
|
/** Накопитель позиционных параметров. */
|
|
21
59
|
class Params {
|
|
@@ -28,6 +66,35 @@ class Params {
|
|
|
28
66
|
const escId = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
29
67
|
const escLit = (s) => s.replace(/'/g, "''");
|
|
30
68
|
export const entityTable = (pgSchema) => `${escId(pgSchema)}.${escId('Entity')}`;
|
|
69
|
+
/**
|
|
70
|
+
* ACL-шаблон строки Entity → WHERE-фраги: колонки равенством, jsonb/tags — containment.
|
|
71
|
+
* Вливается и в кандидаты (GIN), и в перепроверку — ДО сортировки/лимита (пагинация цела).
|
|
72
|
+
*/
|
|
73
|
+
function rowTemplateFrags(tpl, p) {
|
|
74
|
+
const frags = [];
|
|
75
|
+
for (const [k, v] of Object.entries(tpl)) {
|
|
76
|
+
if (k === 'data' || k === 'links') {
|
|
77
|
+
const ph = p.push(v);
|
|
78
|
+
frags.push((a) => `${a}.${k} @> ${ph}::jsonb`);
|
|
79
|
+
}
|
|
80
|
+
else if (k === 'tags') {
|
|
81
|
+
const ph = p.push(Array.isArray(v) ? v : [v]);
|
|
82
|
+
frags.push((a) => `${a}.tags @> ${ph}`);
|
|
83
|
+
}
|
|
84
|
+
else if (k === 'owner' || k === 'account') {
|
|
85
|
+
const ph = p.push(v);
|
|
86
|
+
frags.push((a) => `${a}.${k} = ${ph}::uuid`);
|
|
87
|
+
}
|
|
88
|
+
else if (k === 'id' || k === 'partition') {
|
|
89
|
+
const ph = p.push(v);
|
|
90
|
+
frags.push((a) => `${a}.${k} = ${ph}`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
throw new Error(`letopis: acl pattern key "${k}" is not an Entity column`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return frags;
|
|
97
|
+
}
|
|
31
98
|
function castOf(ft) {
|
|
32
99
|
switch (ft?.kind) {
|
|
33
100
|
case 'number':
|
|
@@ -42,11 +109,23 @@ function castOf(ft) {
|
|
|
42
109
|
}
|
|
43
110
|
/** Каст параметра по JS-типу (для jsonb_build_array). */
|
|
44
111
|
const jsCast = (v) => typeof v === 'number' ? '::numeric' : typeof v === 'boolean' ? '::boolean' : '::text';
|
|
45
|
-
/**
|
|
112
|
+
/** jsonb-выражение до пути (без извлечения текста): data->'a'->'b'. */
|
|
113
|
+
function jsonbAt(path) {
|
|
114
|
+
return (a) => `${a}.data${path.map((k) => `->'${escLit(k)}'`).join('')}`;
|
|
115
|
+
}
|
|
116
|
+
/** Текст листа по data-пути ЛЮБОЙ глубины: data->'a'->'b'->>'c'. */
|
|
46
117
|
function accessor(path) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return (a) => `${
|
|
118
|
+
const head = path.slice(0, -1);
|
|
119
|
+
const last = path[path.length - 1];
|
|
120
|
+
return (a) => `${jsonbAt(head)(a)}->>'${escLit(last)}'`;
|
|
121
|
+
}
|
|
122
|
+
/** Тип листа data-пути по Schema: спуск через record (значение) и object (props). */
|
|
123
|
+
export function leafType(cls, path) {
|
|
124
|
+
let ft = cls.fieldTypes.get(path[0]);
|
|
125
|
+
for (let i = 1; i < path.length && ft; i++) {
|
|
126
|
+
ft = ft.kind === 'record' ? ft.value : ft.kind === 'object' ? ft.props.get(path[i]) : undefined;
|
|
127
|
+
}
|
|
128
|
+
return ft;
|
|
50
129
|
}
|
|
51
130
|
function compileOp(path, ft, o, p) {
|
|
52
131
|
const lhs = accessor(path);
|
|
@@ -79,23 +158,26 @@ function compileOp(path, ft, o, p) {
|
|
|
79
158
|
return (a) => `${lhs(a)} ILIKE ${p.push(args[0])}`;
|
|
80
159
|
case 'has':
|
|
81
160
|
// jsonb_build_array с кастом по JS-типу — JS-массив postgres.js слал бы как PG-array
|
|
82
|
-
return (a) => `${
|
|
161
|
+
return (a) => `${jsonbAt(path)(a)} @> jsonb_build_array(${p.push(args[0])}${jsCast(args[0])})`;
|
|
83
162
|
case 'hasAll': {
|
|
84
163
|
const vs = args[0];
|
|
85
164
|
if (!vs.length)
|
|
86
165
|
return () => 'TRUE';
|
|
87
|
-
return (a) => `${
|
|
166
|
+
return (a) => `${jsonbAt(path)(a)} @> jsonb_build_array(${vs.map((x) => p.push(x) + jsCast(x)).join(', ')})`;
|
|
88
167
|
}
|
|
89
168
|
case 'hasAny': {
|
|
90
169
|
const vs = args[0];
|
|
91
170
|
if (!vs.length)
|
|
92
171
|
return () => 'FALSE';
|
|
93
|
-
return (a) => `${
|
|
172
|
+
return (a) => `${jsonbAt(path)(a)} ?| ARRAY[${vs.map((x) => p.push(String(x))).join(', ')}]::text[]`;
|
|
94
173
|
}
|
|
95
|
-
case 'exists':
|
|
174
|
+
case 'exists': {
|
|
175
|
+
const holder = jsonbAt(path.slice(0, -1));
|
|
176
|
+
const last = escLit(path[path.length - 1]);
|
|
96
177
|
return args[0]
|
|
97
|
-
? (a) => `${a}
|
|
98
|
-
: (a) => `NOT (${a}
|
|
178
|
+
? (a) => `${holder(a)} ? '${last}'`
|
|
179
|
+
: (a) => `NOT (${holder(a)} ? '${last}')`;
|
|
180
|
+
}
|
|
99
181
|
case 'isNull':
|
|
100
182
|
return (a) => `${lhs(a)} IS NULL`;
|
|
101
183
|
case 'not': {
|
|
@@ -157,6 +239,39 @@ function compileFilter(cls, filter, p) {
|
|
|
157
239
|
return out;
|
|
158
240
|
}
|
|
159
241
|
const eqData = {};
|
|
242
|
+
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
|
|
243
|
+
/**
|
|
244
|
+
* Рекурсивный разбор объекта фильтра на ЛЮБУЮ глубину:
|
|
245
|
+
* операторы уходят в finalFrags по своему пути (каст по типу листа из Schema),
|
|
246
|
+
* скаляры/массивы собираются в containment-поддерево (GIN).
|
|
247
|
+
* Возврат: поддерево для eqData; undefined — внутри были только операторы.
|
|
248
|
+
*/
|
|
249
|
+
const walk = (path, obj) => {
|
|
250
|
+
const eq = {};
|
|
251
|
+
let hadOps = false;
|
|
252
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
253
|
+
if (v === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
const sub = [...path, k];
|
|
256
|
+
if (isOp(v)) {
|
|
257
|
+
out.finalFrags.push(compileOp(sub, leafType(cls, sub), v, p));
|
|
258
|
+
hadOps = true;
|
|
259
|
+
}
|
|
260
|
+
else if (isPlain(v)) {
|
|
261
|
+
const nested = walk(sub, v);
|
|
262
|
+
if (nested !== undefined)
|
|
263
|
+
eq[k] = nested;
|
|
264
|
+
else
|
|
265
|
+
hadOps = true;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
eq[k] = v;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (Object.keys(eq).length)
|
|
272
|
+
return eq;
|
|
273
|
+
return hadOps ? undefined : {}; // пустой объект без операторов → containment {}
|
|
274
|
+
};
|
|
160
275
|
for (const [key, value] of Object.entries(filter)) {
|
|
161
276
|
if (value === undefined)
|
|
162
277
|
continue;
|
|
@@ -169,29 +284,14 @@ function compileFilter(cls, filter, p) {
|
|
|
169
284
|
throw new Error('letopis: filter.id accepts string | string[]');
|
|
170
285
|
continue;
|
|
171
286
|
}
|
|
172
|
-
const ft = cls.fieldTypes.get(key);
|
|
173
287
|
if (isOp(value)) {
|
|
174
|
-
out.finalFrags.push(compileOp([key],
|
|
288
|
+
out.finalFrags.push(compileOp([key], cls.fieldTypes.get(key), value, p));
|
|
175
289
|
continue;
|
|
176
290
|
}
|
|
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;
|
|
291
|
+
if (isPlain(value)) {
|
|
292
|
+
const nested = walk([key], value);
|
|
293
|
+
if (nested !== undefined)
|
|
294
|
+
eqData[key] = nested;
|
|
195
295
|
continue;
|
|
196
296
|
}
|
|
197
297
|
// скаляр / массив — точное совпадение через containment (GIN)
|
|
@@ -223,19 +323,15 @@ export function resolveHop(prev, next) {
|
|
|
223
323
|
}
|
|
224
324
|
throw new Error(`letopis: LINK → LINK traversal is not supported (${prev.id} → ${next.id})`);
|
|
225
325
|
}
|
|
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
326
|
function sortField(alias, cls, order) {
|
|
232
327
|
if (order === 'updated')
|
|
233
328
|
return `${alias}.updated`;
|
|
234
329
|
if (order.startsWith('data.')) {
|
|
235
|
-
|
|
236
|
-
|
|
330
|
+
// data-путь любой глубины ('data.settings.booking.deposit.amount') + каст по типу листа
|
|
331
|
+
const path = order.slice(5).split('.');
|
|
332
|
+
return `(${accessor(path)(alias)})${castOf(leafType(cls, path))}`;
|
|
237
333
|
}
|
|
238
|
-
throw new Error(`letopis: order must be 'updated' or 'data.<
|
|
334
|
+
throw new Error(`letopis: order must be 'updated' or 'data.<path>'`);
|
|
239
335
|
}
|
|
240
336
|
function orderExpr(alias, cls, mods) {
|
|
241
337
|
if (!mods.order)
|
|
@@ -249,7 +345,7 @@ function afterCond(alias, cls, mods, p) {
|
|
|
249
345
|
if (!mods.order)
|
|
250
346
|
throw new Error('letopis: .after(cursor) requires .sort(field)');
|
|
251
347
|
const expr = sortField(alias, cls, mods.order);
|
|
252
|
-
const cast = mods.order === 'updated' ? '::timestamptz' : castOf(cls
|
|
348
|
+
const cast = mods.order === 'updated' ? '::timestamptz' : castOf(leafType(cls, mods.order.slice(5).split('.')));
|
|
253
349
|
const cmp = mods.desc ? '<' : '>';
|
|
254
350
|
return ` WHERE (${expr}, ${alias}.id) ${cmp} (${p.push(mods.after.v)}${cast}, ${p.push(mods.after.id)})`;
|
|
255
351
|
}
|
|
@@ -292,6 +388,25 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
292
388
|
f.candFrags.push(frag);
|
|
293
389
|
f.finalFrags.push(frag);
|
|
294
390
|
}
|
|
391
|
+
// enforceAcl: READ-право на КАЖДЫЙ шаг; предикат победившего правила — в WHERE заранее
|
|
392
|
+
if (ctx.aclDecide) {
|
|
393
|
+
const d = ctx.aclDecide(step.cls, 'READ');
|
|
394
|
+
if (!d.allow)
|
|
395
|
+
throw aclDenied('READ', step.cls.id, d);
|
|
396
|
+
if (d.filter) {
|
|
397
|
+
for (const fr of rowTemplateFrags(d.filter, p)) {
|
|
398
|
+
f.candFrags.push(fr);
|
|
399
|
+
f.finalFrags.push(fr);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// предикат WRITE/DELETE-правила (поиск целей записи): только свои строки
|
|
404
|
+
if (step.aclFilter) {
|
|
405
|
+
for (const fr of rowTemplateFrags(step.aclFilter, p)) {
|
|
406
|
+
f.candFrags.push(fr);
|
|
407
|
+
f.finalFrags.push(fr);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
295
410
|
const innerWhere = [`e.partition = ${pPart}`, `e.class = ${pCls}`];
|
|
296
411
|
if (mods.asOf !== undefined)
|
|
297
412
|
innerWhere.push(`e.updated <= ${p.push(mods.asOf)}::timestamptz`); // «как было на T»
|
|
@@ -404,16 +519,13 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
404
519
|
throw new Error("letopis: aggregation needs field 'data.<путь>'");
|
|
405
520
|
}
|
|
406
521
|
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])}'`;
|
|
522
|
+
const acc = accessor(path)(`h${last}`); // data-путь любой глубины
|
|
410
523
|
if (aggFn === 'countBy') {
|
|
411
524
|
text = `SELECT ${acc} AS k, count(*)::int AS n\n${fromClause}\nGROUP BY 1 ORDER BY 2 DESC`;
|
|
412
525
|
}
|
|
413
526
|
else {
|
|
414
|
-
// sum/avg — всегда numeric; min/max — по типу
|
|
415
|
-
const
|
|
416
|
-
const cast = aggFn === 'sum' || aggFn === 'avg' ? '::numeric' : castOf(ft);
|
|
527
|
+
// sum/avg — всегда numeric; min/max — по типу листа
|
|
528
|
+
const cast = aggFn === 'sum' || aggFn === 'avg' ? '::numeric' : castOf(leafType(lastCls, path));
|
|
417
529
|
text = `SELECT ${aggFn}((${acc})${cast}) AS v\n${fromClause}`;
|
|
418
530
|
}
|
|
419
531
|
break;
|
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
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface Row {
|
|
|
16
16
|
/** Глубина узла при .deep()-обходе (1 = прямой ребёнок). */
|
|
17
17
|
$depth?: number;
|
|
18
18
|
}
|
|
19
|
-
/** Результат
|
|
19
|
+
/** Результат run(): вариант пути — узел на каждый шаг цепочки. */
|
|
20
20
|
export type Path = Record<string, Row>;
|
|
21
21
|
/** Класс из таблицы Schema. */
|
|
22
22
|
export interface ClassDef {
|
|
@@ -53,6 +53,9 @@ export type FieldType = {
|
|
|
53
53
|
} | {
|
|
54
54
|
kind: 'record';
|
|
55
55
|
value: FieldType;
|
|
56
|
+
} | {
|
|
57
|
+
kind: 'object';
|
|
58
|
+
props: Map<string, FieldType>;
|
|
56
59
|
} | {
|
|
57
60
|
kind: 'any';
|
|
58
61
|
};
|
|
@@ -81,6 +84,21 @@ export interface Cursor {
|
|
|
81
84
|
v: string | number;
|
|
82
85
|
id: string;
|
|
83
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Операция записи, привязанная к шагу цепочки: .set() / .delete() / .anonymize().
|
|
89
|
+
* Терминал исполняет план (все операции + финальное чтение) одной транзакцией.
|
|
90
|
+
*/
|
|
91
|
+
export interface PlanOp {
|
|
92
|
+
kind: 'set' | 'delete' | 'anonymize';
|
|
93
|
+
/** set: данные новой версии (deep-merge листьев). */
|
|
94
|
+
data?: Record<string, unknown>;
|
|
95
|
+
/** anonymize: string-поля под '[erased]'. */
|
|
96
|
+
fields?: string[];
|
|
97
|
+
/** delete: true — удалить; без confirm — превью (вернуть кандидатов, БД не трогать). */
|
|
98
|
+
confirm?: boolean;
|
|
99
|
+
/** Снапшот модификаторов на момент вызова операции (limit/sort для поиска целей). */
|
|
100
|
+
mods: ChainMods;
|
|
101
|
+
}
|
|
84
102
|
/** Модификаторы выборки цепочки: .limit() / .offset() / .sort() / .asOf() / .after(). */
|
|
85
103
|
export interface ChainMods {
|
|
86
104
|
limit?: number;
|
|
@@ -131,6 +149,23 @@ export interface Rule {
|
|
|
131
149
|
meta: Record<string, unknown> | null;
|
|
132
150
|
enabled: boolean;
|
|
133
151
|
}
|
|
152
|
+
/** Операция над данными = категория Resource: 'READ' | 'WRITE' | 'DELETE'. */
|
|
153
|
+
export type AclOp = 'READ' | 'WRITE' | 'DELETE';
|
|
154
|
+
/** Решение ACL: победившее правило (max weight; при равенстве deny; нет правил — deny). */
|
|
155
|
+
export interface AclDecision {
|
|
156
|
+
allow: boolean;
|
|
157
|
+
/** Победившее правило (нет — deny «no matching rule»). */
|
|
158
|
+
rule?: Rule;
|
|
159
|
+
/**
|
|
160
|
+
* Остаточный шаблон строки Entity из pattern победившего allow-ресурса
|
|
161
|
+
* (ключи-колонки без class, "$account" уже подставлен) — вливается в SQL
|
|
162
|
+
* до сортировки/лимита. Отсутствует = класс целиком.
|
|
163
|
+
*/
|
|
164
|
+
filter?: Record<string, unknown>;
|
|
165
|
+
/** Из meta deny-правила: код и сообщение для ответа клиенту. */
|
|
166
|
+
code?: number;
|
|
167
|
+
message?: string;
|
|
168
|
+
}
|
|
134
169
|
/** Событие хука onQuery: один SQL-запрос цепочки (чтение или запись). */
|
|
135
170
|
export interface QueryEvent {
|
|
136
171
|
/** Режим чтения или write-операция. */
|
|
@@ -159,6 +194,14 @@ export interface ConnectOpts {
|
|
|
159
194
|
max?: number;
|
|
160
195
|
/** Жёсткая изоляция арендатора: чтения фильтруются по account, записи пришпилены к нему. */
|
|
161
196
|
enforceAccount?: boolean;
|
|
197
|
+
/**
|
|
198
|
+
* ACL по Resource/Rule: категории READ/WRITE/DELETE, pattern — шаблон строки Entity
|
|
199
|
+
* (колонки + "$account"). Каждый шаг цепочки проверяется на READ, записи — WRITE,
|
|
200
|
+
* delete — DELETE (включая классы каскада); предикат победившего правила вливается
|
|
201
|
+
* в SQL до сортировки/лимита. Требует account. Deny-by-default.
|
|
202
|
+
* Правила читаются один раз при connect (перечитка — новый connect).
|
|
203
|
+
*/
|
|
204
|
+
enforceAcl?: boolean;
|
|
162
205
|
/** Хук на каждый запрос цепочки (метрики, лог). */
|
|
163
206
|
onQuery?: (e: QueryEvent) => void;
|
|
164
207
|
/** Порог «медленного» запроса, мс: событие получает slow: true; без onQuery — console.warn. */
|
package/dist/up.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EntityDb } from './chain.js';
|
|
2
|
+
import type { ConnectOpts } from './types.js';
|
|
3
|
+
export interface UpOpts extends Omit<ConnectOpts, 'dsn' | 'schema'> {
|
|
4
|
+
/** postgres://user:pass@host:port/db. Default postgres://postgres:test@localhost:15432/letopis. */
|
|
5
|
+
dsn?: string;
|
|
6
|
+
/** Базовое имя схемы БЕЗ версии и точек (напр. 'booking'). */
|
|
7
|
+
schema: string;
|
|
8
|
+
/** Версия движка: итоговая PG-схема "v<N>.<schema>". Бамп руками при breaking-изменении DDL. */
|
|
9
|
+
version: number;
|
|
10
|
+
/** Имя dev-контейнера. Default 'letopis-timescale'. */
|
|
11
|
+
container?: string;
|
|
12
|
+
/** Имя docker-образа; при отсутствии соберётся из пакованного Dockerfile. Default 'letopis-db'. */
|
|
13
|
+
image?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Где держать данные PG: путь на хосте (bind mount, на Windows/NTFS — на свой риск)
|
|
16
|
+
* либо имя docker-volume. Default — named volume 'letopis-pgdata' (кроссплатформенно;
|
|
17
|
+
* переживает пересоздание контейнера).
|
|
18
|
+
*/
|
|
19
|
+
dataDir?: string;
|
|
20
|
+
/** Хост-порт Redis контейнера. Default 16379. */
|
|
21
|
+
redisPort?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Сиды после ddl: массив путей — свои файлы (маркер "<SCHEMA-NAME>" в них поддержан);
|
|
24
|
+
* default — демо booking + auth. Свой сид должен дать хотя бы один класс в Schema
|
|
25
|
+
* (иначе connect в конце честно упадёт «has no classes»); false — голая структура
|
|
26
|
+
* без сидов, годится только чтобы поставить движок — connect не переживёт.
|
|
27
|
+
*/
|
|
28
|
+
seeds?: string[] | false;
|
|
29
|
+
/** Дропнуть схему и накатить заново. ДАННЫЕ СХЕМЫ ТЕРЯЮТСЯ. */
|
|
30
|
+
fresh?: boolean;
|
|
31
|
+
/** Без console.log-прогресса. */
|
|
32
|
+
quiet?: boolean;
|
|
33
|
+
/** Максимум ожидания готовности, мс. Default 120 000 (первый запуск: pull образа + initdb). */
|
|
34
|
+
waitTimeoutMs?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface RunCfg {
|
|
37
|
+
container: string;
|
|
38
|
+
image: string;
|
|
39
|
+
pgPort: string;
|
|
40
|
+
redisPort: number;
|
|
41
|
+
user: string;
|
|
42
|
+
password: string;
|
|
43
|
+
database: string;
|
|
44
|
+
dataDir?: string;
|
|
45
|
+
}
|
|
46
|
+
/** argv для docker run (чистая функция — покрыта юнитом). dataDir: путь → bind, имя → volume. */
|
|
47
|
+
export declare function dockerRunArgs(cfg: RunCfg): string[];
|
|
48
|
+
export declare function up(opts: UpOpts): Promise<EntityDb>;
|