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/schema.js ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Загрузка таблицы Schema → реестр классов:
3
+ * - резолв имени по id и alias;
4
+ * - цепочка ancestors (walk по ancestor, если в БД не заполнена);
5
+ * - скомпилированный fastest-validator per class;
6
+ * - карта типов полей (для SQL-кастов фильтров).
7
+ */
8
+ import { createRequire } from 'node:module';
9
+ const Validator = createRequire(import.meta.url)('fastest-validator');
10
+ const v = new Validator({ useNewCustomCheckerFunction: true });
11
+ export class Registry {
12
+ byName = new Map();
13
+ all = [];
14
+ add(def) {
15
+ this.all.push(def);
16
+ this.byName.set(def.id, def);
17
+ this.byName.set(def.alias, def);
18
+ }
19
+ /** Класс по id или alias; undefined если нет. */
20
+ find(name) {
21
+ return this.byName.get(name);
22
+ }
23
+ /** Класс по id или alias; иначе понятная ошибка со списком. */
24
+ resolve(name) {
25
+ const def = this.byName.get(name);
26
+ if (!def) {
27
+ const names = this.all.map((d) => `${d.id}·${d.alias}`).join(', ');
28
+ throw new Error(`letopis: unknown class "${name}". Known: ${names}`);
29
+ }
30
+ return def;
31
+ }
32
+ has(name) {
33
+ return this.byName.has(name);
34
+ }
35
+ }
36
+ /** Тип поля из fastest-validator DSL — для каста в SQL. */
37
+ export function fieldTypeOf(attr) {
38
+ if (typeof attr === 'string') {
39
+ const base = attr.split('|')[0].trim();
40
+ switch (base) {
41
+ case 'number':
42
+ return { kind: 'number' };
43
+ case 'date':
44
+ return { kind: 'date' };
45
+ case 'boolean':
46
+ return { kind: 'boolean' };
47
+ case 'array':
48
+ return { kind: 'array' };
49
+ case 'string':
50
+ case 'uuid':
51
+ case 'email':
52
+ case 'url':
53
+ case 'enum':
54
+ return { kind: 'string' };
55
+ default:
56
+ return { kind: 'any' };
57
+ }
58
+ }
59
+ if (Array.isArray(attr))
60
+ return attr.length ? fieldTypeOf(attr[0]) : { kind: 'any' };
61
+ if (typeof attr === 'object' && attr !== null) {
62
+ const t = attr.type;
63
+ if (t === 'record')
64
+ return { kind: 'record', value: fieldTypeOf(attr.value ?? 'any') };
65
+ if (t === 'array')
66
+ return { kind: 'array' };
67
+ if (t === 'number')
68
+ return { kind: 'number' };
69
+ if (t === 'date')
70
+ return { kind: 'date' };
71
+ if (t === 'boolean')
72
+ return { kind: 'boolean' };
73
+ if (t === 'enum' || t === 'string' || t === 'uuid' || t === 'email' || t === 'url')
74
+ return { kind: 'string' };
75
+ if (typeof t === 'string')
76
+ return fieldTypeOf(t);
77
+ }
78
+ return { kind: 'any' };
79
+ }
80
+ function buildDef(row, byId) {
81
+ // цепочка наследования: из БД или walk по ancestor
82
+ let ancestors = row.ancestors ?? [];
83
+ if (!ancestors.length) {
84
+ ancestors = [row.id];
85
+ let cur = row.ancestor;
86
+ const seen = new Set([row.id]);
87
+ while (cur && !seen.has(cur)) {
88
+ ancestors.push(cur);
89
+ seen.add(cur);
90
+ cur = byId.get(cur)?.ancestor ?? null;
91
+ }
92
+ }
93
+ // fastest-validator: валидируем {id, ...data}; СТРОГО — лишние поля запрещены
94
+ const compiled = v.compile({ ...row.attributes, $$strict: true });
95
+ const check = (data) => {
96
+ const res = compiled(data);
97
+ return res === true ? true : res;
98
+ };
99
+ const fieldTypes = new Map();
100
+ for (const [field, attr] of Object.entries(row.attributes)) {
101
+ if (field.startsWith('$$'))
102
+ continue;
103
+ fieldTypes.set(field, fieldTypeOf(attr));
104
+ }
105
+ return {
106
+ id: row.id,
107
+ alias: row.alias,
108
+ category: row.category,
109
+ ancestor: row.ancestor,
110
+ ancestors,
111
+ descendants: row.descendants ?? [], // считает триггер schema_lineage
112
+ attributes: row.attributes,
113
+ links: row.links ?? [],
114
+ meta: row.meta ?? {},
115
+ abstract: row.meta?.abstract === true || row.meta?.abstract === 'true',
116
+ order: row.order,
117
+ check,
118
+ fieldTypes,
119
+ };
120
+ }
121
+ export async function loadRegistry(sql, pgSchema, partition) {
122
+ const ident = `"${pgSchema.replace(/"/g, '""')}"`;
123
+ const rows = (await sql.unsafe(`SELECT id, alias, category, ancestor, attributes, links, meta, "order", ancestors, descendants
124
+ FROM ${ident}."Schema" WHERE partition = $1 ORDER BY category, "order"`, [partition]));
125
+ if (!rows.length) {
126
+ throw new Error(`letopis: schema "${pgSchema}" has no classes for partition "${partition}"`);
127
+ }
128
+ const byId = new Map(rows.map((r) => [r.id, r]));
129
+ const registry = new Registry();
130
+ for (const row of rows)
131
+ registry.add(buildDef(row, byId));
132
+ return registry;
133
+ }
package/dist/sql.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * SQL-билдер. Ключевая идея (замена «медленных» вью движка):
3
+ * «последняя версия» считается ПОСЛЕ сужения по class + GIN-кандидатам, а не по всей таблице.
4
+ *
5
+ * Шаблон шага:
6
+ * SELECT * FROM (
7
+ * SELECT DISTINCT ON (e.id) e.* FROM Entity e
8
+ * WHERE e.partition=$ AND e.class=$
9
+ * [AND e.id = $ | e.id IN (…)] -- id-фильтр
10
+ * [AND e.id = hPrev.links->>$cls] -- forward-джойн (id неизменен)
11
+ * [AND e.id IN (SELECT c.id FROM Entity c -- GIN-кандидаты: containment-фильтры
12
+ * WHERE c.partition=$ AND c.class=$ AND c.data @> $ [AND c.links @> …])]
13
+ * ORDER BY e.id, e.updated DESC -- latest-версия кандидатов
14
+ * ) t
15
+ * WHERE t.deleted IS NULL [AND все фильтры на t] -- перепроверка на АКТУАЛЬНОЙ версии
16
+ *
17
+ * Кандидатное сужение — оптимизация (GIN); истина — перепроверка условий на latest.
18
+ * Цепочка шагов соединяется JOIN LATERAL — комбинации путей сохраняются.
19
+ */
20
+ import type { Sql } from 'postgres';
21
+ import type { ClassDef, ChainMods, Filter, QueryEvent } from './types.js';
22
+ import type { Registry } from './schema.js';
23
+ export interface Ctx {
24
+ sql: Sql;
25
+ registry: Registry;
26
+ pgSchema: string;
27
+ partition: string;
28
+ account?: string;
29
+ owner?: string;
30
+ /** System-аккаунт — fallback для NOT NULL Entity.account (загружается при connect). */
31
+ systemAccount?: string;
32
+ /** Жёсткая изоляция арендатора: все чтения фильтруются, записи пришпилены к account. */
33
+ enforceAccount?: boolean;
34
+ /** true внутри db.begin()-транзакции (write не оборачивает в begin повторно). */
35
+ inTx?: boolean;
36
+ /** Наблюдаемость: хук на каждый запрос цепочки. */
37
+ onQuery?: (e: QueryEvent) => void;
38
+ /** Порог «медленного» запроса, мс (без onQuery — console.warn). */
39
+ slowMs?: number;
40
+ }
41
+ /**
42
+ * Единая точка исполнения запросов цепочек: sql.unsafe + событие onQuery/slowMs.
43
+ * Служебные запросы (loadRegistry, tables, listen) через неё не идут.
44
+ */
45
+ export declare function runQuery<T>(ctx: Ctx, text: string, params: unknown[], mode: QueryEvent['mode'], classes: string[]): Promise<T[]>;
46
+ export interface Step {
47
+ /** Имя, как вызвано в цепочке (алиас или id). */
48
+ name: string;
49
+ cls: ClassDef;
50
+ filter?: Filter;
51
+ /** .alias(name) — ключ шага в путях. */
52
+ aliasKey?: string;
53
+ /** .tags(…) — фильтр по колонке tags: строка | string[] (все) | has/hasAny/hasAll. */
54
+ tagsFilter?: unknown;
55
+ /** .account(uuid|Row) — фильтр по колонке account. */
56
+ accountFilter?: string;
57
+ /** .owner(uuid|Row) — фильтр по колонке owner. */
58
+ ownerFilter?: string;
59
+ /** Внутреннее (write-цепочки): containment по links — контекст-связи. */
60
+ linksFilter?: Record<string, string>;
61
+ /** .deep(max): рекурсивный self-обход (дети любой глубины), только reverse того же класса. */
62
+ deepMax?: number;
63
+ }
64
+ export declare const entityTable: (pgSchema: string) => string;
65
+ type HopMode = 'forward' | 'reverse';
66
+ /** Правило обхода между шагами (по реестру Schema). */
67
+ export declare function resolveHop(prev: ClassDef, next: ClassDef): HopMode;
68
+ export type ReadMode = 'paths' | 'rows' | 'ids' | 'count' | 'versions' | 'agg';
69
+ export interface BuiltQuery {
70
+ text: string;
71
+ params: unknown[];
72
+ /** Ключи шагов в путях (после alias/dedupe). */
73
+ keys: string[];
74
+ }
75
+ /** Построить читающий запрос по цепочке. */
76
+ export declare function buildRead(ctx: Ctx, steps: Step[], mods: ChainMods, mode: ReadMode): BuiltQuery;
77
+ /** INSERT новой версии/tombstone. $9 = updated прошлой версии (или null), $10 = deleted. */
78
+ export declare function insertSql(pgSchema: string): string;
79
+ /** Мульти-INSERT новых строк (батч): n строк × 8 параметров, updated/deleted по умолчанию. */
80
+ export declare function multiInsertSql(pgSchema: string, n: number): string;
81
+ /**
82
+ * Серверное удаление: DELETE перехватывает триггер entity_delete —
83
+ * tombstone актуальной живой версии + рекурсивный каскад по links, всё в БД.
84
+ * $1 partition, $2 class, дальше — id-шники.
85
+ */
86
+ export declare function deleteSql(pgSchema: string, n: number): string;
87
+ /**
88
+ * Замыкание удаления: цели + все живые зависимые рекурсивно (то, что каскад затомбстоунит).
89
+ * Рекурсивный CTE; каждый узел — актуальная живая версия. $1 partition, $2 class, $3+ — ids.
90
+ */
91
+ export declare function closureSql(pgSchema: string, n: number): string;
92
+ export {};