letopis 0.20.3 → 1.0.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.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +348 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3466
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +369 -551
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -250
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -351
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -600
  89. package/sql/seed.auth.sql +0 -73
package/dist/schema.js DELETED
@@ -1,351 +0,0 @@
1
- /**
2
- * Загрузка таблицы Schema → реестр классов:
3
- * - резолв имени по id и alias;
4
- * - цепочка ancestors (walk по ancestor, если в БД не заполнена);
5
- * - attributes НАСЛЕДУЮТСЯ по цепочке (потомок поверх предка; поле = замена правила
6
- * целиком) — включая правило id (генерация v4/v5/v7); links НЕ наследуются;
7
- * - скомпилированный fastest-validator per class;
8
- * - карта типов полей (для SQL-кастов фильтров).
9
- */
10
- //
11
- // FILE: lib/src/schema.ts
12
- // VERSION: 1.1.0
13
- // START_MODULE_CONTRACT
14
- // PURPOSE: Строит реестр классов из таблицы Schema — резолв наследования/link-ends, типы полей, компиляция валидаторов.
15
- // SCOPE: Registry (add/find/resolve/has), loadRegistry, fieldTypeOf; локальные parseEnd/parseIdGen/buildDef.
16
- // DEPENDS: M-TYPES
17
- // LINKS: M-SCHEMA, V-M-SCHEMA
18
- // ROLE: RUNTIME
19
- // MAP_MODE: EXPORTS
20
- // END_MODULE_CONTRACT
21
- //
22
- // START_MODULE_MAP
23
- // Registry - реестр классов (add/find/resolve/has, all)
24
- // fieldTypeOf - attribute-спека fastest-validator → FieldType (для SQL-кастов)
25
- // loadRegistry - загрузка Schema-строк партиции → Registry со скомпилированными check
26
- // END_MODULE_MAP
27
- //
28
- // START_CHANGE_SUMMARY
29
- // LAST_CHANGE: [v1.1.0 - loadRegistry: 42P01/3F000 перехватываются и заменяются адресной ошибкой
30
- // (передано базовое имя вместо "vN.имя") со списком letopis-схем — паритет с валидацией up()]
31
- // END_CHANGE_SUMMARY
32
- import { createRequire } from 'node:module';
33
- import { reservedNamesOf } from './types.js';
34
- const Validator = createRequire(import.meta.url)('fastest-validator');
35
- const v = new Validator({ useNewCustomCheckerFunction: true });
36
- export class Registry {
37
- byName = new Map();
38
- all = [];
39
- add(def) {
40
- this.all.push(def);
41
- this.byName.set(def.id, def);
42
- this.byName.set(def.alias, def);
43
- }
44
- /** Класс по id или alias; undefined если нет. */
45
- find(name) {
46
- return this.byName.get(name);
47
- }
48
- // START_CONTRACT: Registry.resolve
49
- // PURPOSE: Вернуть класс по id или alias, иначе бросить ошибку со списком известных.
50
- // INPUTS: { name: string - id или alias класса }
51
- // OUTPUTS: { ClassDef - найденный класс }
52
- // SIDE_EFFECTS: none
53
- // LINKS: M-SCHEMA, V-M-SCHEMA
54
- // END_CONTRACT: Registry.resolve
55
- /** Класс по id или alias; иначе понятная ошибка со списком. */
56
- resolve(name) {
57
- const def = this.byName.get(name);
58
- if (!def) {
59
- const names = this.all.map((d) => `${d.id}·${d.alias}`).join(', ');
60
- throw new Error(`letopis: unknown class "${name}". Known: ${names}`);
61
- }
62
- return def;
63
- }
64
- has(name) {
65
- return this.byName.has(name);
66
- }
67
- }
68
- // START_CONTRACT: fieldTypeOf
69
- // PURPOSE: Свести attribute-спеку fastest-validator (строка/массив/объект) к FieldType.
70
- // INPUTS: { attr: unknown - правило поля из attributes }
71
- // OUTPUTS: { FieldType - вид поля (number/date/boolean/string/array/record/object/any) }
72
- // SIDE_EFFECTS: none (рекурсивно по вложенным props/value)
73
- // LINKS: M-SCHEMA, V-M-SCHEMA, type-FieldType
74
- // END_CONTRACT: fieldTypeOf
75
- /** Тип поля из fastest-validator DSL — для каста в SQL. */
76
- export function fieldTypeOf(attr) {
77
- if (typeof attr === 'string') {
78
- const base = attr.split('|')[0].trim();
79
- switch (base) {
80
- case 'number':
81
- return { kind: 'number' };
82
- case 'date':
83
- return { kind: 'date' };
84
- case 'boolean':
85
- return { kind: 'boolean' };
86
- case 'array':
87
- return { kind: 'array' };
88
- case 'string':
89
- case 'uuid':
90
- case 'email':
91
- case 'url':
92
- case 'enum':
93
- return { kind: 'string' };
94
- default:
95
- return { kind: 'any' };
96
- }
97
- }
98
- if (Array.isArray(attr))
99
- return attr.length ? fieldTypeOf(attr[0]) : { kind: 'any' };
100
- if (typeof attr === 'object' && attr !== null) {
101
- const t = attr.type;
102
- if (t === 'record')
103
- return { kind: 'record', value: fieldTypeOf(attr.value ?? 'any') };
104
- if (t === 'object') {
105
- // вложенная структура: {type:'object', props:{…}} — типы листьев любой глубины
106
- const props = attr.props ?? attr.properties;
107
- if (props && typeof props === 'object') {
108
- const m = new Map();
109
- for (const [k, v] of Object.entries(props))
110
- m.set(k, fieldTypeOf(v));
111
- return { kind: 'object', props: m };
112
- }
113
- return { kind: 'any' };
114
- }
115
- if (t === 'array')
116
- return { kind: 'array' };
117
- if (t === 'number')
118
- return { kind: 'number' };
119
- if (t === 'date')
120
- return { kind: 'date' };
121
- if (t === 'boolean')
122
- return { kind: 'boolean' };
123
- if (t === 'enum' || t === 'string' || t === 'uuid' || t === 'email' || t === 'url')
124
- return { kind: 'string' };
125
- if (typeof t === 'string')
126
- return fieldTypeOf(t);
127
- }
128
- return { kind: 'any' };
129
- }
130
- /**
131
- * Элемент Schema.links: объект-конец v2 (jsonb) либо legacy-строка 'Org'
132
- * (включая JSON-текст в text[] у старых схем). Возвращает [конец, isV2].
133
- */
134
- // START_CONTRACT: parseEnd
135
- // PURPOSE: Разобрать один элемент Schema.links в LinkEnd (объект-конец v2 или legacy-строка).
136
- // INPUTS: { cls: string - id класса; raw: string|object - сырой конец из jsonb/text[] }
137
- // OUTPUTS: { [LinkEnd, boolean] - конец и признак v2-формы (для strictEnds) }
138
- // SIDE_EFFECTS: none
139
- // ERRORS: malformed link end (bad JSON); link end without classes
140
- // LINKS: M-SCHEMA, V-M-SCHEMA
141
- // END_CONTRACT: parseEnd
142
- function parseEnd(cls, raw) {
143
- let o;
144
- if (typeof raw === 'string') {
145
- if (!raw.trimStart().startsWith('{'))
146
- return [{ classes: [raw] }, false];
147
- try {
148
- o = JSON.parse(raw);
149
- }
150
- catch {
151
- throw new Error(`letopis: class "${cls}" has malformed link end (bad JSON): ${raw}`);
152
- }
153
- }
154
- else {
155
- o = raw;
156
- }
157
- const classes = o.classes ?? (o.class ? [o.class] : []);
158
- if (!classes.length || classes.some((c) => typeof c !== 'string' || !c)) {
159
- throw new Error(`letopis: class "${cls}" has link end without classes: ${JSON.stringify(raw)}`);
160
- }
161
- return [{ classes, optional: o.optional === true, cardinality: o.cardinality }, true];
162
- }
163
- /**
164
- * attributes.id: правило валидации + (объектом) спецификация генерации.
165
- * generate/from — ключи letopis, вырезаются из правила перед компиляцией валидатора.
166
- */
167
- // START_CONTRACT: parseIdGen
168
- // PURPOSE: Отделить спецификацию генерации id (generate/from) от правила валидации attributes.id.
169
- // INPUTS: { cls: string - id класса; attributes: object - слитые attributes класса }
170
- // OUTPUTS: { { idGen: IdGen; attrs: object } - версия генерации id и очищенное правило }
171
- // SIDE_EFFECTS: none
172
- // ERRORS: generate must be 4|5|7; generate:5 needs "from"; from is for generate:5 only
173
- // LINKS: M-SCHEMA, V-M-SCHEMA
174
- // END_CONTRACT: parseIdGen
175
- function parseIdGen(cls, attributes) {
176
- const raw = attributes.id;
177
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
178
- return { idGen: { version: 4 }, attrs: attributes };
179
- }
180
- const { generate, from, ...rule } = raw;
181
- const attrs = { ...attributes, id: rule };
182
- if (generate === undefined)
183
- return { idGen: { version: 4 }, attrs };
184
- if (generate !== 4 && generate !== 5 && generate !== 7) {
185
- throw new Error(`letopis: class "${cls}" — attributes.id.generate must be 4 | 5 | 7, got ${JSON.stringify(generate)}`);
186
- }
187
- if (generate === 5) {
188
- if (!Array.isArray(from) || !from.length || from.some((f) => typeof f !== 'string' || !f)) {
189
- throw new Error(`letopis: class "${cls}" — id generate:5 needs "from": non-empty string[] (link ends / data fields)`);
190
- }
191
- return { idGen: { version: 5, from: from }, attrs };
192
- }
193
- if (from !== undefined) {
194
- throw new Error(`letopis: class "${cls}" — attributes.id.from is for generate:5 only`);
195
- }
196
- return { idGen: { version: generate }, attrs };
197
- }
198
- // START_CONTRACT: buildDef
199
- // PURPOSE: Собрать ClassDef из строки Schema — цепочка ancestors, наследование attributes, id-gen, компиляция check, типы полей, link-ends.
200
- // INPUTS: { row: SchemaRow - строка класса; byId: Map<string,SchemaRow> - все классы партиции }
201
- // OUTPUTS: { ClassDef - полностью разрешённое определение класса }
202
- // SIDE_EFFECTS: none (компилирует fastest-validator)
203
- // ERRORS: id v5 can't depend on optional end; id "from" source is neither end nor field
204
- // LINKS: M-SCHEMA, V-M-SCHEMA, type-ClassDef
205
- // END_CONTRACT: buildDef
206
- function buildDef(row, byId) {
207
- // START_BLOCK_INHERIT_ANCESTORS
208
- // цепочка наследования: из БД или walk по ancestor
209
- let ancestors = row.ancestors ?? [];
210
- if (!ancestors.length) {
211
- ancestors = [row.id];
212
- let cur = row.ancestor;
213
- const seen = new Set([row.id]);
214
- while (cur && !seen.has(cur)) {
215
- ancestors.push(cur);
216
- seen.add(cur);
217
- cur = byId.get(cur)?.ancestor ?? null;
218
- }
219
- }
220
- // attributes НАСЛЕДУЮТСЯ по цепочке ancestor: потомок ПОВЕРХ предка, переопределение
221
- // поля — замена правила ЦЕЛИКОМ (не слияние объекта-правила). links не наследуются.
222
- const merged = {};
223
- for (let i = ancestors.length - 1; i >= 0; i--) {
224
- Object.assign(merged, byId.get(ancestors[i])?.attributes ?? {});
225
- }
226
- // END_BLOCK_INHERIT_ANCESTORS
227
- // attributes.id: спецификация генерации (generate/from) отделяется от правила валидации
228
- const { idGen, attrs } = parseIdGen(row.id, merged);
229
- // fastest-validator: валидируем {id, ...data}; СТРОГО — лишние поля запрещены
230
- const compiled = v.compile({ ...attrs, $$strict: true });
231
- const check = (data) => {
232
- const res = compiled(data);
233
- return res === true ? true : res;
234
- };
235
- const fieldTypes = new Map();
236
- for (const [field, attr] of Object.entries(attrs)) {
237
- if (field.startsWith('$$'))
238
- continue;
239
- fieldTypes.set(field, fieldTypeOf(attr));
240
- }
241
- let strictEnds = false;
242
- // jsonb-массив; text[] у legacy-схем даёт string[]; jsonb-строка "[…]" (двойная
243
- // сериализация внешних писателей) — распарсить
244
- let rawLinks = row.links ?? [];
245
- if (typeof rawLinks === 'string')
246
- rawLinks = JSON.parse(rawLinks);
247
- const ends = rawLinks.map((raw) => {
248
- const [end, v2] = parseEnd(row.id, raw);
249
- if (v2)
250
- strictEnds = true;
251
- return end;
252
- });
253
- // START_BLOCK_ID_V5_VALIDATE
254
- // id v5: каждый источник from — обязательный конец links (класс или полное имя союза
255
- // 'Service|Complex') ЛИБО поле data
256
- if (idGen.version === 5) {
257
- for (const f of idGen.from) {
258
- const end = ends.find((e) => e.classes.includes(f) || e.classes.join('|') === f);
259
- if (end) {
260
- if (end.optional) {
261
- throw new Error(`letopis: class "${row.id}" — id (uuid v5) can't depend on optional end "${f}"`);
262
- }
263
- continue;
264
- }
265
- if (f !== 'id' && f in attrs)
266
- continue;
267
- throw new Error(`letopis: class "${row.id}" — id "from" source "${f}" is neither a link end nor a data field`);
268
- }
269
- }
270
- // END_BLOCK_ID_V5_VALIDATE
271
- return {
272
- id: row.id,
273
- alias: row.alias,
274
- category: row.category,
275
- ancestor: row.ancestor,
276
- ancestors,
277
- descendants: row.descendants ?? [], // считает триггер schema_lineage
278
- attributes: merged,
279
- links: ends,
280
- strictEnds,
281
- idGen,
282
- meta: row.meta ?? {},
283
- abstract: row.meta?.abstract === true || row.meta?.abstract === 'true',
284
- order: row.order,
285
- check,
286
- fieldTypes,
287
- };
288
- }
289
- // START_CONTRACT: loadRegistry
290
- // PURPOSE: Прочитать классы партиции из таблицы Schema и построить Registry со скомпилированными check.
291
- // INPUTS: { sql: postgres.Sql; pgSchema: string; partition: string }
292
- // OUTPUTS: { Promise<Registry> - реестр всех классов партиции }
293
- // SIDE_EFFECTS: SELECT из "<pgSchema>"."Schema"; на 42P01/3F000 — доп. SELECT списка схем для подсказки
294
- // ERRORS: schema has no classes for partition; schema has no "Schema" table (передано базовое имя вместо "vN.имя")
295
- // LINKS: M-SCHEMA, V-M-SCHEMA, M-DDL
296
- // END_CONTRACT: loadRegistry
297
- /** Уже предупреждённые «схема:класс» — чтобы не повторять на каждый connect/reloadSchema. */
298
- const warnedReserved = new Set();
299
- export async function loadRegistry(sql, pgSchema, partition) {
300
- // START_BLOCK_LOAD_QUERY
301
- const ident = `"${pgSchema.replace(/"/g, '""')}"`;
302
- let rows;
303
- try {
304
- rows = (await sql.unsafe(`SELECT id, alias, category, ancestor, attributes, links, meta, "order", ancestors, descendants
305
- FROM ${ident}."Schema" WHERE partition = $1 ORDER BY category, "order"`, [partition]));
306
- }
307
- catch (e) {
308
- // 42P01 undefined_table / 3F000 invalid_schema_name — типовая ошибка: передали БАЗОВОЕ имя
309
- // ('booking') вместо полного с версией ('v1.booking'). Подсказываем вместо сырой ошибки PG.
310
- const code = e.code;
311
- if (code !== '42P01' && code !== '3F000')
312
- throw e;
313
- // перечисляем ТОЛЬКО схемы letopis (те, где есть таблица Schema), а не все namespace БД
314
- const found = (await sql.unsafe(`SELECT table_schema AS s FROM information_schema.tables
315
- WHERE table_name = 'Schema' ORDER BY 1`));
316
- throw new Error(`letopis: schema "${pgSchema}" has no "Schema" table — connect({ schema }) takes the FULL ` +
317
- `PG-schema name WITH the engine version ("v1.booking"); the library adds no prefix. ` +
318
- `letopis schemas here: ${found.map((r) => r.s).join(', ') || '(none)'}. ` +
319
- `Create one with up({ schema, version }) or db/apply.mjs --schema=… --version=…`);
320
- }
321
- if (!rows.length) {
322
- throw new Error(`letopis: schema "${pgSchema}" has no classes for partition "${partition}"`);
323
- }
324
- // END_BLOCK_LOAD_QUERY
325
- const byId = new Map(rows.map((r) => [r.id, r]));
326
- const registry = new Registry();
327
- for (const row of rows)
328
- registry.add(buildDef(row, byId));
329
- // START_BLOCK_RESERVED_WARN
330
- // Имя, перехватываемое Proxy до резолва класса, делает шаг недостижимым ПОД ЭТИМ именем
331
- // (второе имя класса, если оно свободно, работает). Предупреждаем, а НЕ бросаем: демо-сид
332
- // содержит класс id "link", и throw уронил бы up() на штатной схеме.
333
- // Один раз на (схема, класс) за процесс: loadRegistry зовётся на каждый connect и
334
- // reloadSchema — иначе штатный прогон утонул бы в повторах.
335
- for (const def of registry.all) {
336
- const clash = reservedNamesOf(def);
337
- if (!clash.length)
338
- continue;
339
- const seen = `${pgSchema}:${def.id}`;
340
- if (warnedReserved.has(seen))
341
- continue;
342
- warnedReserved.add(seen);
343
- const free = [def.id, def.alias].filter((n) => !clash.includes(n));
344
- console.warn(`letopis: class name ${clash.map((n) => `"${n}"`).join(' / ')} is reserved by the chain API — ` +
345
- (free.length
346
- ? `use ${free.map((n) => `"${n}"`).join(' / ')} for chain steps`
347
- : 'this class is unreachable as a chain step; rename it'));
348
- }
349
- // END_BLOCK_RESERVED_WARN
350
- return registry;
351
- }
@@ -1,32 +0,0 @@
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 DELETED
@@ -1,114 +0,0 @@
1
- /**
2
- * Сессии во внешнем KV (Redis): db.auth.sessions(store).
3
- * Клиент НЕ входит в зависимости — инжектируется пользователем (интерфейс ioredis-совместим).
4
- * В store лежит только sha256-хэш токена: дамп Redis не раскрывает действующие токены.
5
- */
6
- //
7
- // FILE: lib/src/sessions.ts
8
- // VERSION: 1.0.0
9
- // START_MODULE_CONTRACT
10
- // PURPOSE: Жизненный цикл сессий поверх инжектируемого Redis-совместимого хранилища (start/check/revoke/revokeAll).
11
- // SCOPE: интерфейсы SessionStore/Session/Sessions + фабрика makeSessions с операциями старта, проверки и отзыва сессий; токены хранятся как sha256 под ключами sess:* / sess:acc:* с TTL.
12
- // DEPENDS: none
13
- // LINKS: M-SESSIONS, V-M-SESSIONS
14
- // ROLE: RUNTIME
15
- // MAP_MODE: EXPORTS
16
- // END_MODULE_CONTRACT
17
- //
18
- // START_MODULE_MAP
19
- // SessionStore - минимальный срез Redis-клиента (set/get/del/sadd/srem/smembers/expire).
20
- // Session - полезная нагрузка сессии: account, meta, created.
21
- // Sessions - контракт сервиса сессий: start/check/revoke/revokeAll.
22
- // sha256 - (локальный) hex-хэш строки по sha256.
23
- // accId - (локальный) нормализует account: string | { id } → id.
24
- // DEFAULT_TTL - (локальный) TTL по умолчанию: 7 суток в секундах.
25
- // makeSessions - фабрика: связывает store и возвращает объект Sessions.
26
- // END_MODULE_MAP
27
- //
28
- // START_CHANGE_SUMMARY
29
- // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
30
- // END_CHANGE_SUMMARY
31
- import { createHash, randomBytes } from 'node:crypto';
32
- const sha256 = (s) => createHash('sha256').update(s).digest('hex');
33
- const accId = (a) => (typeof a === 'object' ? a.id : a);
34
- const DEFAULT_TTL = 7 * 24 * 3600;
35
- // START_CONTRACT: makeSessions
36
- // PURPOSE: Фабрика сервиса сессий: связывает инжектируемый store и возвращает объект Sessions.
37
- // INPUTS: { store: SessionStore - Redis-совместимое хранилище (инжектируется пользователем) }
38
- // OUTPUTS: { Sessions - объект с методами start/check/revoke/revokeAll }
39
- // SIDE_EFFECTS: none при создании; возвращённые методы читают/пишут store (ключи sess:* / sess:acc:*)
40
- // LINKS: M-SESSIONS, V-M-SESSIONS
41
- // END_CONTRACT: makeSessions
42
- export function makeSessions(store) {
43
- const kTok = (h) => `sess:${h}`;
44
- // индекс аккаунта без TTL: протухшие хэши безвредны, полная чистка — revokeAll
45
- const kAcc = (id) => `sess:acc:${id}`;
46
- return {
47
- // START_CONTRACT: start
48
- // PURPOSE: Создать новую сессию и вернуть одноразовый токен (в store — только его sha256).
49
- // INPUTS: { account: string | { id: string } - аккаунт, opts?: { ttlSec?: number; meta?: Record<string, unknown> } - TTL (default 7 суток) и метаданные }
50
- // OUTPUTS: { Promise<string> - секретный токен (32 байта hex, отдаётся один раз) }
51
- // SIDE_EFFECTS: store.set(sess:<h>, payload, EX ttl) + store.sadd(sess:acc:<id>, h)
52
- // LINKS: M-SESSIONS, V-M-SESSIONS
53
- // END_CONTRACT: start
54
- async start(account, opts = {}) {
55
- // START_BLOCK_START_MINT_AND_PERSIST
56
- const id = accId(account);
57
- const token = randomBytes(32).toString('hex');
58
- const h = sha256(token);
59
- const ttl = opts.ttlSec ?? DEFAULT_TTL;
60
- const payload = { account: id, meta: opts.meta ?? {}, created: new Date().toISOString() };
61
- await store.set(kTok(h), JSON.stringify(payload), 'EX', ttl);
62
- await store.sadd(kAcc(id), h);
63
- // END_BLOCK_START_MINT_AND_PERSIST
64
- return token;
65
- },
66
- // START_CONTRACT: check
67
- // PURPOSE: Проверить токен и вернуть данные сессии либо null.
68
- // INPUTS: { token: string - секретный токен }
69
- // OUTPUTS: { Promise<Session | null> - null = нет / просрочена / отозвана }
70
- // SIDE_EFFECTS: store.get(sess:<sha256(token)>) (только чтение)
71
- // LINKS: M-SESSIONS, V-M-SESSIONS
72
- // END_CONTRACT: check
73
- async check(token) {
74
- const raw = await store.get(kTok(sha256(token)));
75
- return raw ? JSON.parse(raw) : null;
76
- },
77
- // START_CONTRACT: revoke
78
- // PURPOSE: Отозвать одну сессию по токену.
79
- // INPUTS: { token: string - секретный токен }
80
- // OUTPUTS: { Promise<boolean> - true если сессия была и удалена, false если её нет }
81
- // SIDE_EFFECTS: store.del(sess:<h>) + store.srem(sess:acc:<account>, h)
82
- // LINKS: M-SESSIONS, V-M-SESSIONS
83
- // END_CONTRACT: revoke
84
- async revoke(token) {
85
- // START_BLOCK_REVOKE_LOOKUP_AND_DELETE
86
- const h = sha256(token);
87
- const raw = await store.get(kTok(h));
88
- if (!raw)
89
- return false;
90
- const { account } = JSON.parse(raw);
91
- await store.del(kTok(h));
92
- await store.srem(kAcc(account), h);
93
- return true;
94
- // END_BLOCK_REVOKE_LOOKUP_AND_DELETE
95
- },
96
- // START_CONTRACT: revokeAll
97
- // PURPOSE: Погасить все сессии аккаунта и очистить его индекс.
98
- // INPUTS: { account: string | { id: string } - аккаунт }
99
- // OUTPUTS: { Promise<number> - сколько хэшей было в индексе аккаунта }
100
- // SIDE_EFFECTS: store.del(все sess:<h>) + store.del(sess:acc:<id>)
101
- // LINKS: M-SESSIONS, V-M-SESSIONS
102
- // END_CONTRACT: revokeAll
103
- async revokeAll(account) {
104
- // START_BLOCK_REVOKE_ALL_PURGE
105
- const id = accId(account);
106
- const hs = await store.smembers(kAcc(id));
107
- if (hs.length)
108
- await store.del(...hs.map(kTok));
109
- await store.del(kAcc(id));
110
- return hs.length;
111
- // END_BLOCK_REVOKE_ALL_PURGE
112
- },
113
- };
114
- }
package/dist/tables.d.ts DELETED
@@ -1,105 +0,0 @@
1
- /**
2
- * Фасады служебных таблиц auth/ACL: Account, Credential, Resource, Rule.
3
- * Обычные таблицы (UPDATE/DELETE разрешены) — цепочки/версии тут не применяются.
4
- * Доступны как db.accounts / db.credentials / db.resources / db.rules (и в транзакции).
5
- */
6
- import type { Ctx } from './sql.js';
7
- import type { Account, Credential, Resource, Rule } from './types.js';
8
- export interface AccountsApi {
9
- /** Фильтры: id, enabled, category (строка — вхождение в categories). */
10
- find(f?: {
11
- id?: string;
12
- enabled?: boolean;
13
- category?: string;
14
- }): Promise<Account[]>;
15
- get(id: string): Promise<Account | null>;
16
- /** insert (без id) | update по id. jsonb-поля мержатся на уровне значения целиком. */
17
- set(a: Partial<Account>): Promise<Account>;
18
- /** Физический DELETE; Credential снесётся FK-каскадом. */
19
- delete(id: string): Promise<boolean>;
20
- /**
21
- * Полный физический офбординг тенанта (необратимо): все Entity (account|owner=id) + сам Account
22
- * (Credential — FK-каскад), через серверную purge_account(). Предохранители: только Owner/System-вызов;
23
- * нельзя снести последний enabled Owner (лок-аут) и свой аккаунт. → true если Account снесён.
24
- */
25
- purge(id: string): Promise<boolean>;
26
- }
27
- export interface CredentialsApi {
28
- /** deleted IS NULL по умолчанию; withDeleted: true — включая удалённые. */
29
- find(f?: {
30
- id?: string;
31
- account?: string;
32
- category?: string;
33
- identifier?: string;
34
- confirmed?: boolean;
35
- withDeleted?: boolean;
36
- }): Promise<Credential[]>;
37
- /** upsert по UNIQUE (account, category, identifier); повторный set воскрешает (deleted → NULL). */
38
- set(c: {
39
- account: string;
40
- category: string;
41
- identifier: string;
42
- meta?: Record<string, unknown>;
43
- confirmed?: boolean;
44
- }): Promise<Credential>;
45
- /** Мягкое удаление: deleted = now(). */
46
- delete(id: string): Promise<boolean>;
47
- }
48
- export interface ResourcesApi {
49
- find(f?: {
50
- category?: string;
51
- }): Promise<Resource[]>;
52
- get(alias: string): Promise<Resource | null>;
53
- /** upsert по alias. */
54
- set(r: {
55
- alias: string;
56
- category: string;
57
- pattern?: Record<string, unknown> | null;
58
- meta?: Record<string, unknown> | null;
59
- }): Promise<Resource>;
60
- /** Физический DELETE; Rule на этот ресурс снесутся FK-каскадом. */
61
- delete(alias: string): Promise<boolean>;
62
- }
63
- export interface RulesApi {
64
- find(f?: {
65
- account?: string;
66
- resource?: string;
67
- permission?: string;
68
- enabled?: boolean;
69
- }): Promise<Rule[]>;
70
- /** upsert по PK (account, resource). Оба конца — Resource.alias (FK). */
71
- set(r: {
72
- account: string;
73
- resource: string;
74
- permission: string;
75
- weight?: number | null;
76
- meta?: Record<string, unknown> | null;
77
- enabled?: boolean;
78
- }): Promise<Rule>;
79
- delete(account: string, resource: string): Promise<boolean>;
80
- }
81
- export interface SchemaApi {
82
- /**
83
- * Upsert определения класса в таблицу Schema (ON CONFLICT (partition,id) DO UPDATE) + reloadSchema().
84
- * Правка «простым SQL», подхватывается сразу без реконнекта. attributes — DSL fastest-validator
85
- * (валидирует либа при чтении реестра); ancestors/descendants считает триггер schema_lineage.
86
- */
87
- define(def: {
88
- id: string;
89
- alias: string;
90
- category: 'HUB' | 'LINK';
91
- ancestor?: string | null;
92
- attributes?: Record<string, unknown>;
93
- meta?: Record<string, unknown>;
94
- links?: unknown[];
95
- order?: number;
96
- }): Promise<void>;
97
- }
98
- export interface Tables {
99
- accounts: AccountsApi;
100
- credentials: CredentialsApi;
101
- resources: ResourcesApi;
102
- rules: RulesApi;
103
- schema: SchemaApi;
104
- }
105
- export declare function makeTables(ctx: Ctx): Tables;