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/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({ ...row.attributes, $$strict: true });
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(row.attributes)) {
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: row.attributes,
113
- links: row.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;
@@ -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';