letopis 0.5.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/tables.js ADDED
@@ -0,0 +1,148 @@
1
+ const T = (ctx, name) => `"${ctx.pgSchema.replace(/"/g, '""')}"."${name}"`;
2
+ /** WHERE из простых equality-условий (+спец-обработчики). */
3
+ function where(conds, params) {
4
+ if (!conds.length)
5
+ return '';
6
+ const parts = conds.map((c) => {
7
+ params.push(c.value);
8
+ return c.text(params.length);
9
+ });
10
+ return ' WHERE ' + parts.join(' AND ');
11
+ }
12
+ const eq = (col) => (n) => `"${col}" = $${n}`;
13
+ // ---------------------------------------------------------------------------
14
+ export function makeTables(ctx) {
15
+ const run = (text, params) => ctx.sql.unsafe(text, params);
16
+ const accounts = {
17
+ async find(f = {}) {
18
+ const conds = [];
19
+ if (f.id !== undefined)
20
+ conds.push({ text: eq('id'), value: f.id });
21
+ if (f.enabled !== undefined)
22
+ conds.push({ text: eq('enabled'), value: f.enabled });
23
+ if (f.category !== undefined)
24
+ conds.push({ text: (n) => `$${n} = ANY(categories)`, value: f.category });
25
+ const params = [];
26
+ return run(`SELECT * FROM ${T(ctx, 'Account')}${where(conds, params)} ORDER BY created`, params);
27
+ },
28
+ async get(id) {
29
+ return (await run(`SELECT * FROM ${T(ctx, 'Account')} WHERE id = $1`, [id]))[0] ?? null;
30
+ },
31
+ async set(a) {
32
+ if (a.id) {
33
+ const sets = ['updated = now()'];
34
+ const params = [];
35
+ for (const col of ['categories', 'data', 'meta', 'avatar', 'enabled']) {
36
+ if (a[col] !== undefined) {
37
+ params.push(a[col]);
38
+ sets.push(`"${col}" = $${params.length}`);
39
+ }
40
+ }
41
+ params.push(a.id);
42
+ const rows = await run(`UPDATE ${T(ctx, 'Account')} SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`, params);
43
+ if (!rows[0])
44
+ throw new Error(`letopis: account "${a.id}" not found`);
45
+ return rows[0];
46
+ }
47
+ const cols = [];
48
+ const params = [];
49
+ for (const col of ['categories', 'data', 'meta', 'avatar', 'enabled']) {
50
+ if (a[col] !== undefined) {
51
+ params.push(a[col]);
52
+ cols.push(`"${col}"`);
53
+ }
54
+ }
55
+ const ph = params.map((_, i) => `$${i + 1}`).join(', ');
56
+ const rows = await run(cols.length
57
+ ? `INSERT INTO ${T(ctx, 'Account')} (${cols.join(', ')}) VALUES (${ph}) RETURNING *`
58
+ : `INSERT INTO ${T(ctx, 'Account')} DEFAULT VALUES RETURNING *`, params);
59
+ return rows[0];
60
+ },
61
+ async delete(id) {
62
+ const rows = await run(`DELETE FROM ${T(ctx, 'Account')} WHERE id = $1 RETURNING id`, [id]);
63
+ return rows.length > 0;
64
+ },
65
+ };
66
+ const credentials = {
67
+ async find(f = {}) {
68
+ const conds = [];
69
+ if (f.id !== undefined)
70
+ conds.push({ text: eq('id'), value: f.id });
71
+ if (f.account !== undefined)
72
+ conds.push({ text: eq('account'), value: f.account });
73
+ if (f.category !== undefined)
74
+ conds.push({ text: eq('category'), value: f.category });
75
+ if (f.identifier !== undefined)
76
+ conds.push({ text: eq('identifier'), value: f.identifier });
77
+ if (f.confirmed !== undefined)
78
+ conds.push({ text: eq('confirmed'), value: f.confirmed });
79
+ const params = [];
80
+ let sql = `SELECT * FROM ${T(ctx, 'Credential')}${where(conds, params)}`;
81
+ if (!f.withDeleted)
82
+ sql += conds.length ? ' AND deleted IS NULL' : ' WHERE deleted IS NULL';
83
+ return run(sql + ' ORDER BY created', params);
84
+ },
85
+ async set(c) {
86
+ const rows = await run(`INSERT INTO ${T(ctx, 'Credential')} (account, category, identifier, meta, confirmed)
87
+ VALUES ($1, $2, $3, $4, $5)
88
+ ON CONFLICT (account, category, identifier) DO UPDATE
89
+ SET meta = EXCLUDED.meta, confirmed = EXCLUDED.confirmed, updated = now(), deleted = NULL
90
+ RETURNING *`, [c.account, c.category, c.identifier, c.meta ?? {}, c.confirmed ?? true]);
91
+ return rows[0];
92
+ },
93
+ async delete(id) {
94
+ const rows = await run(`UPDATE ${T(ctx, 'Credential')} SET deleted = now(), updated = now() WHERE id = $1 AND deleted IS NULL RETURNING id`, [id]);
95
+ return rows.length > 0;
96
+ },
97
+ };
98
+ const resources = {
99
+ async find(f = {}) {
100
+ const conds = [];
101
+ if (f.category !== undefined)
102
+ conds.push({ text: eq('category'), value: f.category });
103
+ const params = [];
104
+ return run(`SELECT * FROM ${T(ctx, 'Resource')}${where(conds, params)} ORDER BY alias`, params);
105
+ },
106
+ async get(alias) {
107
+ return (await run(`SELECT * FROM ${T(ctx, 'Resource')} WHERE alias = $1`, [alias]))[0] ?? null;
108
+ },
109
+ async set(r) {
110
+ const rows = await run(`INSERT INTO ${T(ctx, 'Resource')} (alias, category, pattern, meta) VALUES ($1, $2, $3, $4)
111
+ ON CONFLICT (alias) DO UPDATE SET category = EXCLUDED.category, pattern = EXCLUDED.pattern, meta = EXCLUDED.meta
112
+ RETURNING *`, [r.alias, r.category, r.pattern ?? null, r.meta ?? null]);
113
+ return rows[0];
114
+ },
115
+ async delete(alias) {
116
+ const rows = await run(`DELETE FROM ${T(ctx, 'Resource')} WHERE alias = $1 RETURNING alias`, [alias]);
117
+ return rows.length > 0;
118
+ },
119
+ };
120
+ const rules = {
121
+ async find(f = {}) {
122
+ const conds = [];
123
+ if (f.account !== undefined)
124
+ conds.push({ text: eq('account'), value: f.account });
125
+ if (f.resource !== undefined)
126
+ conds.push({ text: eq('resource'), value: f.resource });
127
+ if (f.permission !== undefined)
128
+ conds.push({ text: eq('permission'), value: f.permission });
129
+ if (f.enabled !== undefined)
130
+ conds.push({ text: eq('enabled'), value: f.enabled });
131
+ const params = [];
132
+ return run(`SELECT * FROM ${T(ctx, 'Rule')}${where(conds, params)} ORDER BY weight DESC NULLS LAST`, params);
133
+ },
134
+ async set(r) {
135
+ const rows = await run(`INSERT INTO ${T(ctx, 'Rule')} (account, resource, permission, weight, meta, enabled)
136
+ VALUES ($1, $2, $3, $4, $5, $6)
137
+ ON CONFLICT (account, resource) DO UPDATE
138
+ SET permission = EXCLUDED.permission, weight = EXCLUDED.weight, meta = EXCLUDED.meta, enabled = EXCLUDED.enabled
139
+ RETURNING *`, [r.account, r.resource, r.permission, r.weight ?? null, r.meta ?? null, r.enabled ?? true]);
140
+ return rows[0];
141
+ },
142
+ async delete(account, resource) {
143
+ const rows = await run(`DELETE FROM ${T(ctx, 'Rule')} WHERE account = $1 AND resource = $2 RETURNING account`, [account, resource]);
144
+ return rows.length > 0;
145
+ },
146
+ };
147
+ return { accounts, credentials, resources, rules };
148
+ }
package/dist/tx.d.ts ADDED
@@ -0,0 +1,13 @@
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 type { Ctx } from './sql.js';
7
+ export interface TxCtx extends Ctx {
8
+ commit(): Promise<void>;
9
+ rollback(): Promise<void>;
10
+ }
11
+ export declare function beginTx(ctx: Ctx): Promise<TxCtx>;
12
+ /** Advisory-lock на составной ключ; живёт до конца транзакции. */
13
+ export declare function lock(ctx: Ctx, ...keys: (string | number)[]): Promise<void>;
package/dist/tx.js ADDED
@@ -0,0 +1,31 @@
1
+ export async function beginTx(ctx) {
2
+ const reserved = await ctx.sql.reserve();
3
+ const r = reserved;
4
+ await r.unsafe('BEGIN');
5
+ let done = false;
6
+ const finish = async (cmd) => {
7
+ if (done)
8
+ return;
9
+ done = true;
10
+ try {
11
+ await r.unsafe(cmd);
12
+ }
13
+ finally {
14
+ r.release();
15
+ }
16
+ };
17
+ return {
18
+ ...ctx,
19
+ sql: reserved,
20
+ inTx: true,
21
+ commit: () => finish('COMMIT'),
22
+ rollback: () => finish('ROLLBACK'),
23
+ };
24
+ }
25
+ /** Advisory-lock на составной ключ; живёт до конца транзакции. */
26
+ export async function lock(ctx, ...keys) {
27
+ if (!ctx.inTx) {
28
+ throw new Error('letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock)');
29
+ }
30
+ await ctx.sql.unsafe('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [keys.join('|')]);
31
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * letopis: типы.
3
+ */
4
+ /** Строка Entity (актуальная версия), как отдаёт либа. account/owner — NOT NULL. */
5
+ export interface Row {
6
+ id: string;
7
+ class: string;
8
+ data: Record<string, unknown>;
9
+ links: Record<string, string>;
10
+ tags: string[];
11
+ account: string;
12
+ owner: string;
13
+ updated: string;
14
+ /** true у строк, возвращённых .delete() (цели + каскад) и tombstone-версий в .versions(). */
15
+ $deleted?: true;
16
+ /** Глубина узла при .deep()-обходе (1 = прямой ребёнок). */
17
+ $depth?: number;
18
+ }
19
+ /** Результат execute(): вариант пути — узел на каждый шаг цепочки. */
20
+ export type Path = Record<string, Row>;
21
+ /** Класс из таблицы Schema. */
22
+ export interface ClassDef {
23
+ id: string;
24
+ alias: string;
25
+ category: 'HUB' | 'LINK';
26
+ ancestor: string | null;
27
+ ancestors: string[];
28
+ /** Все потомки (транзитивно) — считает триггер schema_lineage. */
29
+ descendants: string[];
30
+ attributes: Record<string, unknown>;
31
+ links: string[];
32
+ meta: Record<string, unknown>;
33
+ abstract: boolean;
34
+ order: number;
35
+ /** Скомпилированный fastest-validator: true | ошибки. */
36
+ check: (data: Record<string, unknown>) => true | {
37
+ field: string;
38
+ message?: string;
39
+ }[];
40
+ /** Тип каждого поля attributes — для SQL-кастов в фильтрах. */
41
+ fieldTypes: Map<string, FieldType>;
42
+ }
43
+ export type FieldType = {
44
+ kind: 'number';
45
+ } | {
46
+ kind: 'date';
47
+ } | {
48
+ kind: 'boolean';
49
+ } | {
50
+ kind: 'string';
51
+ } | {
52
+ kind: 'array';
53
+ } | {
54
+ kind: 'record';
55
+ value: FieldType;
56
+ } | {
57
+ kind: 'any';
58
+ };
59
+ /** Метка операторов фильтра (Symbol — не конфликтует с данными). */
60
+ export declare const OP: unique symbol;
61
+ export interface Op {
62
+ [OP]: string;
63
+ args: unknown[];
64
+ }
65
+ export type Scalar = string | number | boolean | null;
66
+ /** Значение фильтра по полю: скаляр (eq), оператор или вложенный record-объект. */
67
+ export type FieldFilter = Scalar | Scalar[] | Op | {
68
+ [key: string]: Scalar | Op;
69
+ };
70
+ /**
71
+ * Фильтр шага:
72
+ * - строка / массив строк — id;
73
+ * - объект: { поле data: значение (eq) | оператор | вложенный record-путь } + ключ id.
74
+ * Колонки tags/account/owner фильтруются модификаторами цепочки .tags()/.account()/.owner().
75
+ */
76
+ export type Filter = string | string[] | Op | {
77
+ [field: string]: FieldFilter | undefined;
78
+ };
79
+ /** Курсор keyset-пагинации: значение поля сортировки + id последней строки страницы. */
80
+ export interface Cursor {
81
+ v: string | number;
82
+ id: string;
83
+ }
84
+ /** Модификаторы выборки цепочки: .limit() / .offset() / .sort() / .asOf() / .after(). */
85
+ export interface ChainMods {
86
+ limit?: number;
87
+ offset?: number;
88
+ /** 'updated' | 'data.<поле>' (каст по типу поля последнего шага). */
89
+ order?: string;
90
+ desc?: boolean;
91
+ /** Чтение «как было на момент T»: ISO-строка или Date. */
92
+ asOf?: string;
93
+ /** Keyset-пагинация от курсора (требует sort). */
94
+ after?: Cursor;
95
+ /** Внутреннее: агрегация терминалов .sum/.avg/.min/.max/.countBy. */
96
+ aggFn?: 'sum' | 'avg' | 'min' | 'max' | 'countBy';
97
+ aggField?: string;
98
+ }
99
+ export interface Account {
100
+ id: string;
101
+ categories: string[];
102
+ data: Record<string, unknown>;
103
+ meta: Record<string, unknown>;
104
+ avatar: string;
105
+ enabled: boolean;
106
+ created: string;
107
+ updated: string;
108
+ }
109
+ export interface Credential {
110
+ id: string;
111
+ account: string;
112
+ category: string;
113
+ identifier: string;
114
+ meta: Record<string, unknown>;
115
+ confirmed: boolean;
116
+ created: string;
117
+ updated: string;
118
+ deleted: string | null;
119
+ }
120
+ export interface Resource {
121
+ alias: string;
122
+ category: string;
123
+ pattern: Record<string, unknown> | null;
124
+ meta: Record<string, unknown> | null;
125
+ }
126
+ export interface Rule {
127
+ account: string;
128
+ resource: string;
129
+ permission: string;
130
+ weight: number | null;
131
+ meta: Record<string, unknown> | null;
132
+ enabled: boolean;
133
+ }
134
+ /** Событие хука onQuery: один SQL-запрос цепочки (чтение или запись). */
135
+ export interface QueryEvent {
136
+ /** Режим чтения или write-операция. */
137
+ mode: 'paths' | 'rows' | 'ids' | 'count' | 'versions' | 'agg' | 'insert' | 'delete';
138
+ /** Классы шагов цепочки (id из Schema). */
139
+ classes: string[];
140
+ /** Длительность, мс. */
141
+ ms: number;
142
+ /** Число строк результата. */
143
+ rows: number;
144
+ /** true при ms > slowMs (если slowMs задан). */
145
+ slow: boolean;
146
+ }
147
+ export interface ConnectOpts {
148
+ /** postgres://user:pass@host:port/db */
149
+ dsn: string;
150
+ /** PG-схема с таблицами Entity/Schema (напр. 'booking'). */
151
+ schema: string;
152
+ /** Партиция данных. Default 'entity'. */
153
+ partition?: string;
154
+ /** Дефолтный account для записей. */
155
+ account?: string;
156
+ /** Дефолтный owner для записей. Default = account. */
157
+ owner?: string;
158
+ /** Размер пула соединений. Default 10. */
159
+ max?: number;
160
+ /** Жёсткая изоляция арендатора: чтения фильтруются по account, записи пришпилены к нему. */
161
+ enforceAccount?: boolean;
162
+ /** Хук на каждый запрос цепочки (метрики, лог). */
163
+ onQuery?: (e: QueryEvent) => void;
164
+ /** Порог «медленного» запроса, мс: событие получает slow: true; без onQuery — console.warn. */
165
+ slowMs?: number;
166
+ }
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * letopis: типы.
3
+ */
4
+ /** Метка операторов фильтра (Symbol — не конфликтует с данными). */
5
+ export const OP = Symbol('letopis.op');
@@ -0,0 +1,60 @@
1
+ import type { Row, ChainMods } from './types.js';
2
+ import { type Ctx, type Step } from './sql.js';
3
+ export declare class ValidationError extends Error {
4
+ issues: {
5
+ field: string;
6
+ message?: string;
7
+ }[];
8
+ constructor(cls: string, issues: {
9
+ field: string;
10
+ message?: string;
11
+ }[]);
12
+ }
13
+ interface RawRow {
14
+ partition: string;
15
+ id: string;
16
+ class: string;
17
+ data: Record<string, unknown>;
18
+ links: Record<string, string> | null;
19
+ tags: string[] | null;
20
+ account: string;
21
+ owner: string;
22
+ updated: string | Date;
23
+ deleted: string | Date | null;
24
+ }
25
+ export declare function toRow(r: RawRow): Row;
26
+ /** Прочитать актуальные строки по цепочке. */
27
+ export declare function readRows(ctx: Ctx, steps: Step[], mods?: ChainMods): Promise<Row[]>;
28
+ /**
29
+ * Deep-merge патча в базу: меняются ТОЛЬКО указанные листья.
30
+ * Вложенные plain-объекты сливаются рекурсивно; массивы/скаляры/null — заменяются.
31
+ */
32
+ export declare function deepMerge(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
33
+ /**
34
+ * .set(data):
35
+ * - пустой фильтр последнего шага и нет data.id → INSERT (links = контекст);
36
+ * - id (фильтр-строка или data.id) → UPSERT: есть → новая версия (deep-merge), нет → создать;
37
+ * - фильтр-объект → новая версия каждого найденного (в границах контекста); пусто → [].
38
+ */
39
+ export declare function setOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown>, extraLinks?: Record<string, string>): Promise<Row[]>;
40
+ /**
41
+ * .anonymize(fields): GDPR-затирание — новая версия с '[erased]' в указанных string-полях
42
+ * + тег 'anonymized'. Только string-поля (по Schema); история сохраняется (см. README).
43
+ */
44
+ export declare function anonymizeOp(ctx: Ctx, steps: Step[], mods: ChainMods, fields: string[]): Promise<Row[]>;
45
+ /**
46
+ * .delete(): цели = фильтр последнего шага + контекст-связи; серверное удаление.
47
+ * Триггер entity_delete: tombstone + рекурсивный каскад (+advisory-lock).
48
+ * Возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
49
+ */
50
+ export declare function delOp(ctx: Ctx, steps: Step[], mods: ChainMods): Promise<Row[]>;
51
+ export interface BatchOp {
52
+ kind: 'set' | 'delete';
53
+ steps: Step[];
54
+ mods: ChainMods;
55
+ data?: Record<string, unknown>;
56
+ /** Связи, довешенные после set() (мутируется билдером до execute()). */
57
+ extraLinks?: Record<string, string>;
58
+ }
59
+ export declare function executeBatch(ctx: Ctx, queue: BatchOp[]): Promise<Row[][]>;
60
+ export {};