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/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
- 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
  }
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
- /** Доступ к полю data: 1 уровень — data->>'f'; 2 (record) — data->'f'->>'k'. */
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
- if (path.length === 1)
48
- return (a) => `${a}.data->>'${escLit(path[0])}'`;
49
- return (a) => `${a}.data->'${escLit(path[0])}'->>'${escLit(path[1])}'`;
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) => `${a}.data->'${escLit(path[0])}' @> jsonb_build_array(${p.push(args[0])}${jsCast(args[0])})`;
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) => `${a}.data->'${escLit(path[0])}' @> jsonb_build_array(${vs.map((x) => p.push(x) + jsCast(x)).join(', ')})`;
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) => `${a}.data->'${escLit(path[0])}' ?| ARRAY[${vs.map((x) => p.push(String(x))).join(', ')}]::text[]`;
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}.data ? '${escLit(path[0])}'`
98
- : (a) => `NOT (${a}.data ? '${escLit(path[0])}')`;
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], ft, value, p));
288
+ out.finalFrags.push(compileOp([key], cls.fieldTypes.get(key), value, p));
175
289
  continue;
176
290
  }
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;
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
- const field = order.slice(5);
236
- return `(${alias}.data->>'${escLit(field)}')${castOf(cls.fieldTypes.get(field))}`;
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.<field>'`);
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.fieldTypes.get(mods.order.slice(5)));
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.length === 1
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 ft = cls0Field(lastCls, path);
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 { 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
  }
package/dist/types.d.ts CHANGED
@@ -16,7 +16,7 @@ export interface Row {
16
16
  /** Глубина узла при .deep()-обходе (1 = прямой ребёнок). */
17
17
  $depth?: number;
18
18
  }
19
- /** Результат execute(): вариант пути — узел на каждый шаг цепочки. */
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>;