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/schema.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Загрузка таблицы Schema → реестр классов:
|
|
3
3
|
* - резолв имени по id и alias;
|
|
4
4
|
* - цепочка ancestors (walk по ancestor, если в БД не заполнена);
|
|
5
|
+
* - attributes НАСЛЕДУЮТСЯ по цепочке (потомок поверх предка; поле = замена правила
|
|
6
|
+
* целиком) — включая правило id (генерация v4/v5/v7); links НЕ наследуются;
|
|
5
7
|
* - скомпилированный fastest-validator per class;
|
|
6
8
|
* - карта типов полей (для SQL-кастов фильтров).
|
|
7
9
|
*/
|
|
@@ -62,6 +64,17 @@ export function fieldTypeOf(attr) {
|
|
|
62
64
|
const t = attr.type;
|
|
63
65
|
if (t === 'record')
|
|
64
66
|
return { kind: 'record', value: fieldTypeOf(attr.value ?? 'any') };
|
|
67
|
+
if (t === 'object') {
|
|
68
|
+
// вложенная структура: {type:'object', props:{…}} — типы листьев любой глубины
|
|
69
|
+
const props = attr.props ?? attr.properties;
|
|
70
|
+
if (props && typeof props === 'object') {
|
|
71
|
+
const m = new Map();
|
|
72
|
+
for (const [k, v] of Object.entries(props))
|
|
73
|
+
m.set(k, fieldTypeOf(v));
|
|
74
|
+
return { kind: 'object', props: m };
|
|
75
|
+
}
|
|
76
|
+
return { kind: 'any' };
|
|
77
|
+
}
|
|
65
78
|
if (t === 'array')
|
|
66
79
|
return { kind: 'array' };
|
|
67
80
|
if (t === 'number')
|
|
@@ -77,6 +90,58 @@ export function fieldTypeOf(attr) {
|
|
|
77
90
|
}
|
|
78
91
|
return { kind: 'any' };
|
|
79
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Элемент Schema.links: объект-конец v2 (jsonb) либо legacy-строка 'Org'
|
|
95
|
+
* (включая JSON-текст в text[] у старых схем). Возвращает [конец, isV2].
|
|
96
|
+
*/
|
|
97
|
+
function parseEnd(cls, raw) {
|
|
98
|
+
let o;
|
|
99
|
+
if (typeof raw === 'string') {
|
|
100
|
+
if (!raw.trimStart().startsWith('{'))
|
|
101
|
+
return [{ classes: [raw] }, false];
|
|
102
|
+
try {
|
|
103
|
+
o = JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new Error(`letopis: class "${cls}" has malformed link end (bad JSON): ${raw}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
o = raw;
|
|
111
|
+
}
|
|
112
|
+
const classes = o.classes ?? (o.class ? [o.class] : []);
|
|
113
|
+
if (!classes.length || classes.some((c) => typeof c !== 'string' || !c)) {
|
|
114
|
+
throw new Error(`letopis: class "${cls}" has link end without classes: ${JSON.stringify(raw)}`);
|
|
115
|
+
}
|
|
116
|
+
return [{ classes, optional: o.optional === true, cardinality: o.cardinality }, true];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* attributes.id: правило валидации + (объектом) спецификация генерации.
|
|
120
|
+
* generate/from — ключи letopis, вырезаются из правила перед компиляцией валидатора.
|
|
121
|
+
*/
|
|
122
|
+
function parseIdGen(cls, attributes) {
|
|
123
|
+
const raw = attributes.id;
|
|
124
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
125
|
+
return { idGen: { version: 4 }, attrs: attributes };
|
|
126
|
+
}
|
|
127
|
+
const { generate, from, ...rule } = raw;
|
|
128
|
+
const attrs = { ...attributes, id: rule };
|
|
129
|
+
if (generate === undefined)
|
|
130
|
+
return { idGen: { version: 4 }, attrs };
|
|
131
|
+
if (generate !== 4 && generate !== 5 && generate !== 7) {
|
|
132
|
+
throw new Error(`letopis: class "${cls}" — attributes.id.generate must be 4 | 5 | 7, got ${JSON.stringify(generate)}`);
|
|
133
|
+
}
|
|
134
|
+
if (generate === 5) {
|
|
135
|
+
if (!Array.isArray(from) || !from.length || from.some((f) => typeof f !== 'string' || !f)) {
|
|
136
|
+
throw new Error(`letopis: class "${cls}" — id generate:5 needs "from": non-empty string[] (link ends / data fields)`);
|
|
137
|
+
}
|
|
138
|
+
return { idGen: { version: 5, from: from }, attrs };
|
|
139
|
+
}
|
|
140
|
+
if (from !== undefined) {
|
|
141
|
+
throw new Error(`letopis: class "${cls}" — attributes.id.from is for generate:5 only`);
|
|
142
|
+
}
|
|
143
|
+
return { idGen: { version: generate }, attrs };
|
|
144
|
+
}
|
|
80
145
|
function buildDef(row, byId) {
|
|
81
146
|
// цепочка наследования: из БД или walk по ancestor
|
|
82
147
|
let ancestors = row.ancestors ?? [];
|
|
@@ -90,18 +155,54 @@ function buildDef(row, byId) {
|
|
|
90
155
|
cur = byId.get(cur)?.ancestor ?? null;
|
|
91
156
|
}
|
|
92
157
|
}
|
|
158
|
+
// attributes НАСЛЕДУЮТСЯ по цепочке ancestor: потомок ПОВЕРХ предка, переопределение
|
|
159
|
+
// поля — замена правила ЦЕЛИКОМ (не слияние объекта-правила). links не наследуются.
|
|
160
|
+
const merged = {};
|
|
161
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
162
|
+
Object.assign(merged, byId.get(ancestors[i])?.attributes ?? {});
|
|
163
|
+
}
|
|
164
|
+
// attributes.id: спецификация генерации (generate/from) отделяется от правила валидации
|
|
165
|
+
const { idGen, attrs } = parseIdGen(row.id, merged);
|
|
93
166
|
// fastest-validator: валидируем {id, ...data}; СТРОГО — лишние поля запрещены
|
|
94
|
-
const compiled = v.compile({ ...
|
|
167
|
+
const compiled = v.compile({ ...attrs, $$strict: true });
|
|
95
168
|
const check = (data) => {
|
|
96
169
|
const res = compiled(data);
|
|
97
170
|
return res === true ? true : res;
|
|
98
171
|
};
|
|
99
172
|
const fieldTypes = new Map();
|
|
100
|
-
for (const [field, attr] of Object.entries(
|
|
173
|
+
for (const [field, attr] of Object.entries(attrs)) {
|
|
101
174
|
if (field.startsWith('$$'))
|
|
102
175
|
continue;
|
|
103
176
|
fieldTypes.set(field, fieldTypeOf(attr));
|
|
104
177
|
}
|
|
178
|
+
let strictEnds = false;
|
|
179
|
+
// jsonb-массив; text[] у legacy-схем даёт string[]; jsonb-строка "[…]" (двойная
|
|
180
|
+
// сериализация внешних писателей) — распарсить
|
|
181
|
+
let rawLinks = row.links ?? [];
|
|
182
|
+
if (typeof rawLinks === 'string')
|
|
183
|
+
rawLinks = JSON.parse(rawLinks);
|
|
184
|
+
const ends = rawLinks.map((raw) => {
|
|
185
|
+
const [end, v2] = parseEnd(row.id, raw);
|
|
186
|
+
if (v2)
|
|
187
|
+
strictEnds = true;
|
|
188
|
+
return end;
|
|
189
|
+
});
|
|
190
|
+
// id v5: каждый источник from — обязательный конец links (класс или полное имя союза
|
|
191
|
+
// 'Service|Complex') ЛИБО поле data
|
|
192
|
+
if (idGen.version === 5) {
|
|
193
|
+
for (const f of idGen.from) {
|
|
194
|
+
const end = ends.find((e) => e.classes.includes(f) || e.classes.join('|') === f);
|
|
195
|
+
if (end) {
|
|
196
|
+
if (end.optional) {
|
|
197
|
+
throw new Error(`letopis: class "${row.id}" — id (uuid v5) can't depend on optional end "${f}"`);
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (f !== 'id' && f in attrs)
|
|
202
|
+
continue;
|
|
203
|
+
throw new Error(`letopis: class "${row.id}" — id "from" source "${f}" is neither a link end nor a data field`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
105
206
|
return {
|
|
106
207
|
id: row.id,
|
|
107
208
|
alias: row.alias,
|
|
@@ -109,8 +210,10 @@ function buildDef(row, byId) {
|
|
|
109
210
|
ancestor: row.ancestor,
|
|
110
211
|
ancestors,
|
|
111
212
|
descendants: row.descendants ?? [], // считает триггер schema_lineage
|
|
112
|
-
attributes:
|
|
113
|
-
links:
|
|
213
|
+
attributes: merged,
|
|
214
|
+
links: ends,
|
|
215
|
+
strictEnds,
|
|
216
|
+
idGen,
|
|
114
217
|
meta: row.meta ?? {},
|
|
115
218
|
abstract: row.meta?.abstract === true || row.meta?.abstract === 'true',
|
|
116
219
|
order: row.order,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Минимальный срез Redis-клиента (ioredis подходит как есть). */
|
|
2
|
+
export interface SessionStore {
|
|
3
|
+
set(key: string, value: string, mode: 'EX', ttlSec: number): Promise<unknown>;
|
|
4
|
+
get(key: string): Promise<string | null>;
|
|
5
|
+
del(...keys: string[]): Promise<unknown>;
|
|
6
|
+
sadd(key: string, ...members: string[]): Promise<unknown>;
|
|
7
|
+
srem(key: string, ...members: string[]): Promise<unknown>;
|
|
8
|
+
smembers(key: string): Promise<string[]>;
|
|
9
|
+
expire(key: string, ttlSec: number): Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
export interface Session {
|
|
12
|
+
account: string;
|
|
13
|
+
meta: Record<string, unknown>;
|
|
14
|
+
created: string;
|
|
15
|
+
}
|
|
16
|
+
export interface Sessions {
|
|
17
|
+
/** Новая сессия → токен (отдаётся один раз; в Redis — только его sha256). Default ttl 7 суток. */
|
|
18
|
+
start(account: string | {
|
|
19
|
+
id: string;
|
|
20
|
+
}, opts?: {
|
|
21
|
+
ttlSec?: number;
|
|
22
|
+
meta?: Record<string, unknown>;
|
|
23
|
+
}): Promise<string>;
|
|
24
|
+
/** null — нет/просрочена/отозвана. */
|
|
25
|
+
check(token: string): Promise<Session | null>;
|
|
26
|
+
revoke(token: string): Promise<boolean>;
|
|
27
|
+
/** Гасит все сессии аккаунта, возвращает сколько было в индексе. */
|
|
28
|
+
revokeAll(account: string | {
|
|
29
|
+
id: string;
|
|
30
|
+
}): Promise<number>;
|
|
31
|
+
}
|
|
32
|
+
export declare function makeSessions(store: SessionStore): Sessions;
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Сессии во внешнем KV (Redis): db.auth.sessions(store).
|
|
3
|
+
* Клиент НЕ входит в зависимости — инжектируется пользователем (интерфейс ioredis-совместим).
|
|
4
|
+
* В store лежит только sha256-хэш токена: дамп Redis не раскрывает действующие токены.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
7
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
8
|
+
const accId = (a) => (typeof a === 'object' ? a.id : a);
|
|
9
|
+
const DEFAULT_TTL = 7 * 24 * 3600;
|
|
10
|
+
export function makeSessions(store) {
|
|
11
|
+
const kTok = (h) => `sess:${h}`;
|
|
12
|
+
// индекс аккаунта без TTL: протухшие хэши безвредны, полная чистка — revokeAll
|
|
13
|
+
const kAcc = (id) => `sess:acc:${id}`;
|
|
14
|
+
return {
|
|
15
|
+
async start(account, opts = {}) {
|
|
16
|
+
const id = accId(account);
|
|
17
|
+
const token = randomBytes(32).toString('hex');
|
|
18
|
+
const h = sha256(token);
|
|
19
|
+
const ttl = opts.ttlSec ?? DEFAULT_TTL;
|
|
20
|
+
const payload = { account: id, meta: opts.meta ?? {}, created: new Date().toISOString() };
|
|
21
|
+
await store.set(kTok(h), JSON.stringify(payload), 'EX', ttl);
|
|
22
|
+
await store.sadd(kAcc(id), h);
|
|
23
|
+
return token;
|
|
24
|
+
},
|
|
25
|
+
async check(token) {
|
|
26
|
+
const raw = await store.get(kTok(sha256(token)));
|
|
27
|
+
return raw ? JSON.parse(raw) : null;
|
|
28
|
+
},
|
|
29
|
+
async revoke(token) {
|
|
30
|
+
const h = sha256(token);
|
|
31
|
+
const raw = await store.get(kTok(h));
|
|
32
|
+
if (!raw)
|
|
33
|
+
return false;
|
|
34
|
+
const { account } = JSON.parse(raw);
|
|
35
|
+
await store.del(kTok(h));
|
|
36
|
+
await store.srem(kAcc(account), h);
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
async revokeAll(account) {
|
|
40
|
+
const id = accId(account);
|
|
41
|
+
const hs = await store.smembers(kAcc(id));
|
|
42
|
+
if (hs.length)
|
|
43
|
+
await store.del(...hs.map(kTok));
|
|
44
|
+
await store.del(kAcc(id));
|
|
45
|
+
return hs.length;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
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,23 +31,49 @@ 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
|
+
/** Значение слота связи: id | вложенный план | null (снять конец). */
|
|
59
|
+
export type SlotValue = string | {
|
|
60
|
+
plan: Step[];
|
|
61
|
+
mods?: ChainMods;
|
|
62
|
+
} | null;
|
|
63
|
+
/** Уникальная метка узла пути (для pivot-возвратов; переживает нарезку плана). */
|
|
64
|
+
export declare const nextNodeKey: () => number;
|
|
46
65
|
export interface Step {
|
|
47
66
|
/** Имя, как вызвано в цепочке (алиас или id). */
|
|
48
67
|
name: string;
|
|
49
68
|
cls: ClassDef;
|
|
50
69
|
filter?: Filter;
|
|
70
|
+
/** Метка узла (есть у каждого реального шага). */
|
|
71
|
+
nodeKey?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Pivot: шаг-ВОЗВРАТ к уже введённому узлу (повтор LINK-класса / entity(та же переменная)).
|
|
74
|
+
* Не создаёт узел; его фильтры дофильтровывают узел (AND); следующий шаг ветвится от узла.
|
|
75
|
+
*/
|
|
76
|
+
pivotKey?: number;
|
|
51
77
|
/** .alias(name) — ключ шага в путях. */
|
|
52
78
|
aliasKey?: string;
|
|
53
79
|
/** .tags(…) — фильтр по колонке tags: строка | string[] (все) | has/hasAny/hasAll. */
|
|
@@ -58,11 +84,27 @@ export interface Step {
|
|
|
58
84
|
ownerFilter?: string;
|
|
59
85
|
/** Внутреннее (write-цепочки): containment по links — контекст-связи. */
|
|
60
86
|
linksFilter?: Record<string, string>;
|
|
87
|
+
/**
|
|
88
|
+
* Слоты связей (`.Класс.set(target)` / `.Класс.unset()`): значения концов записываемой
|
|
89
|
+
* версии БЕЗ участия в фильтре целей. string — id; {plan} — вложенная цепочка (та же
|
|
90
|
+
* транзакция, ровно одна сущность класса конца); null — снять optional-конец.
|
|
91
|
+
*/
|
|
92
|
+
extraLinks?: Record<string, SlotValue>;
|
|
93
|
+
/** Внутреннее (enforceAcl): предикат WRITE/DELETE-правила — цели ищутся только среди своих. */
|
|
94
|
+
aclFilter?: Record<string, unknown>;
|
|
61
95
|
/** .deep(max): рекурсивный self-обход (дети любой глубины), только reverse того же класса. */
|
|
62
96
|
deepMax?: number;
|
|
97
|
+
/** Операция записи на шаге (.create/.update/.delete/.anonymize) — исполняется терминалом плана. */
|
|
98
|
+
op?: import('./types.js').PlanOp;
|
|
99
|
+
/** Внутреннее: шаг «те же сущности» (операция сразу после операции) — цель = строки старта. */
|
|
100
|
+
self?: boolean;
|
|
63
101
|
}
|
|
64
102
|
export declare const entityTable: (pgSchema: string) => string;
|
|
103
|
+
/** Тип листа data-пути по Schema: спуск через record (значение) и object (props). */
|
|
104
|
+
export declare function leafType(cls: ClassDef, path: string[]): FieldType | undefined;
|
|
65
105
|
type HopMode = 'forward' | 'reverse';
|
|
106
|
+
/** Класс target входит в какой-нибудь конец def (союзы учитываются). */
|
|
107
|
+
export declare function linksTo(def: ClassDef, target: string): boolean;
|
|
66
108
|
/** Правило обхода между шагами (по реестру Schema). */
|
|
67
109
|
export declare function resolveHop(prev: ClassDef, next: ClassDef): HopMode;
|
|
68
110
|
export type ReadMode = 'paths' | 'rows' | 'ids' | 'count' | 'versions' | 'agg';
|