letopis 0.16.0 → 0.18.1
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 +132 -5
- package/README.md +924 -544
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.js +112 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +36 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +73 -0
- package/dist/sessions.js +66 -0
- package/dist/sql.js +90 -1
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.js +36 -0
- package/dist/write.js +148 -2
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +2 -1
- package/scripts/gen-types.mjs +154 -0
- package/scripts/schema-sync.mjs +185 -0
- package/sql/ddl.sql +71 -1
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +60 -18
package/dist/sessions.js
CHANGED
|
@@ -3,16 +3,56 @@
|
|
|
3
3
|
* Клиент НЕ входит в зависимости — инжектируется пользователем (интерфейс ioredis-совместим).
|
|
4
4
|
* В store лежит только sha256-хэш токена: дамп Redis не раскрывает действующие токены.
|
|
5
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
|
|
6
31
|
import { createHash, randomBytes } from 'node:crypto';
|
|
7
32
|
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
8
33
|
const accId = (a) => (typeof a === 'object' ? a.id : a);
|
|
9
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
|
|
10
42
|
export function makeSessions(store) {
|
|
11
43
|
const kTok = (h) => `sess:${h}`;
|
|
12
44
|
// индекс аккаунта без TTL: протухшие хэши безвредны, полная чистка — revokeAll
|
|
13
45
|
const kAcc = (id) => `sess:acc:${id}`;
|
|
14
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
|
|
15
54
|
async start(account, opts = {}) {
|
|
55
|
+
// START_BLOCK_START_MINT_AND_PERSIST
|
|
16
56
|
const id = accId(account);
|
|
17
57
|
const token = randomBytes(32).toString('hex');
|
|
18
58
|
const h = sha256(token);
|
|
@@ -20,13 +60,29 @@ export function makeSessions(store) {
|
|
|
20
60
|
const payload = { account: id, meta: opts.meta ?? {}, created: new Date().toISOString() };
|
|
21
61
|
await store.set(kTok(h), JSON.stringify(payload), 'EX', ttl);
|
|
22
62
|
await store.sadd(kAcc(id), h);
|
|
63
|
+
// END_BLOCK_START_MINT_AND_PERSIST
|
|
23
64
|
return token;
|
|
24
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
|
|
25
73
|
async check(token) {
|
|
26
74
|
const raw = await store.get(kTok(sha256(token)));
|
|
27
75
|
return raw ? JSON.parse(raw) : null;
|
|
28
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
|
|
29
84
|
async revoke(token) {
|
|
85
|
+
// START_BLOCK_REVOKE_LOOKUP_AND_DELETE
|
|
30
86
|
const h = sha256(token);
|
|
31
87
|
const raw = await store.get(kTok(h));
|
|
32
88
|
if (!raw)
|
|
@@ -35,14 +91,24 @@ export function makeSessions(store) {
|
|
|
35
91
|
await store.del(kTok(h));
|
|
36
92
|
await store.srem(kAcc(account), h);
|
|
37
93
|
return true;
|
|
94
|
+
// END_BLOCK_REVOKE_LOOKUP_AND_DELETE
|
|
38
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
|
|
39
103
|
async revokeAll(account) {
|
|
104
|
+
// START_BLOCK_REVOKE_ALL_PURGE
|
|
40
105
|
const id = accId(account);
|
|
41
106
|
const hs = await store.smembers(kAcc(id));
|
|
42
107
|
if (hs.length)
|
|
43
108
|
await store.del(...hs.map(kTok));
|
|
44
109
|
await store.del(kAcc(id));
|
|
45
110
|
return hs.length;
|
|
111
|
+
// END_BLOCK_REVOKE_ALL_PURGE
|
|
46
112
|
},
|
|
47
113
|
};
|
|
48
114
|
}
|
package/dist/sql.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { OP } from './types.js';
|
|
2
2
|
import { isOp } from './ops.js';
|
|
3
3
|
import { aclDenied } from './acl.js';
|
|
4
|
+
// START_CONTRACT: isTransient
|
|
5
|
+
// PURPOSE: Признак транзиентной ошибки PG (deadlock 40P01 / serialization failure 40001) — снимается повтором.
|
|
6
|
+
// INPUTS: { e: unknown - пойманная ошибка }
|
|
7
|
+
// OUTPUTS: { boolean }
|
|
8
|
+
// SIDE_EFFECTS: none
|
|
9
|
+
// LINKS: M-SQL, V-M-SQL
|
|
10
|
+
// END_CONTRACT: isTransient
|
|
4
11
|
/** Транзиентные ошибки PG — гонка, снимаемая повтором: deadlock / serialization failure. */
|
|
5
12
|
export function isTransient(e) {
|
|
6
13
|
const code = e.code;
|
|
@@ -25,7 +32,15 @@ const READ_MODES = new Set([
|
|
|
25
32
|
* повтор запрещён семантикой PG — ошибка уходит наружу с подсказкой.
|
|
26
33
|
* Служебные запросы (loadRegistry, tables, listen) через неё не идут.
|
|
27
34
|
*/
|
|
35
|
+
// START_CONTRACT: runQuery
|
|
36
|
+
// PURPOSE: Единая точка исполнения запросов цепочек: sql.unsafe + событие onQuery/slow-warn + ретрай transient в автокоммите.
|
|
37
|
+
// INPUTS: { ctx: Ctx; text: string; params: unknown[]; mode: QueryEvent['mode']; classes: string[] }
|
|
38
|
+
// OUTPUTS: { Promise<T[]> - строки результата }
|
|
39
|
+
// SIDE_EFFECTS: выполняет SQL; вызывает onQuery-хук; console.warn при slowMs; ретрай до RETRIES вне транзакции
|
|
40
|
+
// LINKS: M-SQL, V-M-SQL
|
|
41
|
+
// END_CONTRACT: runQuery
|
|
28
42
|
export async function runQuery(ctx, text, params, mode, classes) {
|
|
43
|
+
// START_BLOCK_RUN_QUERY_RETRY
|
|
29
44
|
for (let attempt = 1;; attempt++) {
|
|
30
45
|
const t0 = performance.now();
|
|
31
46
|
try {
|
|
@@ -54,6 +69,7 @@ export async function runQuery(ctx, text, params, mode, classes) {
|
|
|
54
69
|
throw e;
|
|
55
70
|
}
|
|
56
71
|
}
|
|
72
|
+
// END_BLOCK_RUN_QUERY_RETRY
|
|
57
73
|
}
|
|
58
74
|
let nodeSeq = 0;
|
|
59
75
|
/** Уникальная метка узла пути (для pivot-возвратов; переживает нарезку плана). */
|
|
@@ -123,6 +139,13 @@ function accessor(path) {
|
|
|
123
139
|
return (a) => `${jsonbAt(head)(a)}->>'${escLit(last)}'`;
|
|
124
140
|
}
|
|
125
141
|
/** Тип листа data-пути по Schema: спуск через record (значение) и object (props). */
|
|
142
|
+
// START_CONTRACT: leafType
|
|
143
|
+
// PURPOSE: Тип листа data-пути по Schema (спуск через record/object) — для SQL-каста.
|
|
144
|
+
// INPUTS: { cls: ClassDef; path: string[] - сегменты data-пути }
|
|
145
|
+
// OUTPUTS: { FieldType | undefined }
|
|
146
|
+
// SIDE_EFFECTS: none
|
|
147
|
+
// LINKS: M-SQL, V-M-SQL, type-FieldType
|
|
148
|
+
// END_CONTRACT: leafType
|
|
126
149
|
export function leafType(cls, path) {
|
|
127
150
|
let ft = cls.fieldTypes.get(path[0]);
|
|
128
151
|
for (let i = 1; i < path.length && ft; i++) {
|
|
@@ -130,7 +153,16 @@ export function leafType(cls, path) {
|
|
|
130
153
|
}
|
|
131
154
|
return ft;
|
|
132
155
|
}
|
|
156
|
+
// START_CONTRACT: compileOp
|
|
157
|
+
// PURPOSE: Скомпилировать оператор фильтра (ne/gt/between/in/like/has*/exists/isNull/not) в SQL-фраг с кастом по типу листа.
|
|
158
|
+
// INPUTS: { path: string[]; ft?: FieldType; o: Op; p: Params }
|
|
159
|
+
// OUTPUTS: { Frag - (alias) => SQL-предикат }
|
|
160
|
+
// SIDE_EFFECTS: пушит значения в Params
|
|
161
|
+
// ERRORS: unknown operator "<name>"
|
|
162
|
+
// LINKS: M-SQL, V-M-SQL, M-OPS
|
|
163
|
+
// END_CONTRACT: compileOp
|
|
133
164
|
function compileOp(path, ft, o, p) {
|
|
165
|
+
// START_BLOCK_COMPILE_OP
|
|
134
166
|
const lhs = accessor(path);
|
|
135
167
|
const cast = castOf(ft);
|
|
136
168
|
// скобки обязательны: '::' сильнее '->>' (data->>'f'::ts кастил бы литерал 'f')
|
|
@@ -192,6 +224,7 @@ function compileOp(path, ft, o, p) {
|
|
|
192
224
|
default:
|
|
193
225
|
throw new Error(`letopis: unknown operator "${name}"`);
|
|
194
226
|
}
|
|
227
|
+
// END_BLOCK_COMPILE_OP
|
|
195
228
|
}
|
|
196
229
|
/** Операторы на колонку tags (text[]): строка | string[] (все) | has/hasAny/hasAll. */
|
|
197
230
|
function compileTags(v, p) {
|
|
@@ -215,6 +248,14 @@ function compileTags(v, p) {
|
|
|
215
248
|
}
|
|
216
249
|
throw new Error('letopis: $tags accepts a string or has/hasAny/hasAll');
|
|
217
250
|
}
|
|
251
|
+
// START_CONTRACT: compileFilter
|
|
252
|
+
// PURPOSE: Разобрать фильтр (id | string[] | or(...) | объект любой глубины) в idConds / candFrags(GIN) / finalFrags(перепроверка на latest).
|
|
253
|
+
// INPUTS: { cls: ClassDef; filter?: Filter; p: Params }
|
|
254
|
+
// OUTPUTS: { CompiledFilter }
|
|
255
|
+
// SIDE_EFFECTS: пушит значения в Params
|
|
256
|
+
// ERRORS: filter.id accepts string | string[]
|
|
257
|
+
// LINKS: M-SQL, V-M-SQL
|
|
258
|
+
// END_CONTRACT: compileFilter
|
|
218
259
|
function compileFilter(cls, filter, p) {
|
|
219
260
|
const out = { idConds: [], candFrags: [], finalFrags: [] };
|
|
220
261
|
if (filter === undefined)
|
|
@@ -241,6 +282,7 @@ function compileFilter(cls, filter, p) {
|
|
|
241
282
|
out.idConds.push((a) => `${a}.id IN (${filter.map((x) => p.push(x)).join(', ')})`);
|
|
242
283
|
return out;
|
|
243
284
|
}
|
|
285
|
+
// START_BLOCK_COMPILE_FILTER_WALK
|
|
244
286
|
const eqData = {};
|
|
245
287
|
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
|
|
246
288
|
/**
|
|
@@ -306,12 +348,21 @@ function compileFilter(cls, filter, p) {
|
|
|
306
348
|
out.candFrags.push(frag);
|
|
307
349
|
out.finalFrags.push(frag);
|
|
308
350
|
}
|
|
351
|
+
// END_BLOCK_COMPILE_FILTER_WALK
|
|
309
352
|
return out;
|
|
310
353
|
}
|
|
311
354
|
/** Класс target входит в какой-нибудь конец def (союзы учитываются). */
|
|
312
355
|
export function linksTo(def, target) {
|
|
313
356
|
return def.links.some((end) => end.classes.includes(target));
|
|
314
357
|
}
|
|
358
|
+
// START_CONTRACT: resolveHop
|
|
359
|
+
// PURPOSE: Определить направление обхода между классами (forward/reverse) по Schema.links.
|
|
360
|
+
// INPUTS: { prev: ClassDef; next: ClassDef }
|
|
361
|
+
// OUTPUTS: { HopMode - 'forward' | 'reverse' }
|
|
362
|
+
// SIDE_EFFECTS: none
|
|
363
|
+
// ERRORS: no path A→B (neither embeds the other); LINK → LINK traversal is not supported
|
|
364
|
+
// LINKS: M-SQL, V-M-SQL
|
|
365
|
+
// END_CONTRACT: resolveHop
|
|
315
366
|
/** Правило обхода между шагами (по реестру Schema). */
|
|
316
367
|
export function resolveHop(prev, next) {
|
|
317
368
|
if (prev.category === 'HUB' && next.category === 'LINK')
|
|
@@ -343,7 +394,10 @@ function sortField(alias, cls, order) {
|
|
|
343
394
|
function orderExpr(alias, cls, mods) {
|
|
344
395
|
if (!mods.order)
|
|
345
396
|
return '';
|
|
346
|
-
|
|
397
|
+
const dir = mods.desc ? ' DESC' : '';
|
|
398
|
+
// id — стабильный tiebreaker ТЕМ ЖЕ направлением: ORDER BY совпадает с кортежом afterExpr
|
|
399
|
+
// (sortExpr, id), поэтому keyset не даёт пересечения страниц при неуникальном sort-поле.
|
|
400
|
+
return ` ORDER BY ${sortField(alias, cls, mods.order)}${dir}, ${alias}.id${dir}`;
|
|
347
401
|
}
|
|
348
402
|
/** Keyset-пагинация: (поле сортировки, id) строго после курсора. Требует .sort(). Выражение без WHERE. */
|
|
349
403
|
function afterExpr(alias, cls, mods, p) {
|
|
@@ -360,6 +414,14 @@ const whereOf = (conds) => {
|
|
|
360
414
|
const list = conds.filter((c) => !!c);
|
|
361
415
|
return list.length ? ` WHERE ${list.join(' AND ')}` : '';
|
|
362
416
|
};
|
|
417
|
+
// START_CONTRACT: buildRead
|
|
418
|
+
// PURPOSE: Скомпилировать цепочку шагов+модов в один параметризованный read-запрос (латеральный обход путей, latest-версии, asOf/versions/agg, keyset).
|
|
419
|
+
// INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; mode: ReadMode }
|
|
420
|
+
// OUTPUTS: { BuiltQuery - { text, params, keys } }
|
|
421
|
+
// SIDE_EFFECTS: none (строит SQL; ACL-проверка READ может бросить)
|
|
422
|
+
// ERRORS: pivot node not in path; enforceAccount pin; acl denies READ; deep on non-self hop; agg needs data.<path>
|
|
423
|
+
// LINKS: M-SQL, V-M-SQL, M-ACL, M-DDL
|
|
424
|
+
// END_CONTRACT: buildRead
|
|
363
425
|
/** Построить читающий запрос по цепочке. */
|
|
364
426
|
export function buildRead(ctx, steps, mods, mode) {
|
|
365
427
|
const p = new Params();
|
|
@@ -367,6 +429,7 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
367
429
|
const pPart = p.push(ctx.partition);
|
|
368
430
|
// pivot: карта эффективных блоков. real[i] — индекс блока-узла шага i
|
|
369
431
|
// (pivot-шаг блока не строит, ссылается на узел по pivotKey).
|
|
432
|
+
// START_BLOCK_BUILD_PIVOT_MAP
|
|
370
433
|
const real = [];
|
|
371
434
|
const byNodeKey = new Map();
|
|
372
435
|
const tailConds = []; // дофильтры pivot-шагов (alias подставлен) — в хвостовой WHERE
|
|
@@ -384,6 +447,8 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
384
447
|
byNodeKey.set(s.nodeKey, i);
|
|
385
448
|
}
|
|
386
449
|
}
|
|
450
|
+
// END_BLOCK_BUILD_PIVOT_MAP
|
|
451
|
+
// START_BLOCK_BUILD_STEP_LOOP
|
|
387
452
|
const blocks = [];
|
|
388
453
|
for (let i = 0; i < steps.length; i++) {
|
|
389
454
|
const step = steps[i];
|
|
@@ -506,6 +571,7 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
506
571
|
}
|
|
507
572
|
blocks.push(i === 0 ? `FROM ${block}` : `JOIN LATERAL ${block} ON true`);
|
|
508
573
|
}
|
|
574
|
+
// END_BLOCK_BUILD_STEP_LOOP
|
|
509
575
|
const fromClause = blocks.join('\n');
|
|
510
576
|
const last = real[steps.length - 1]; // pivot в конце → терминал на его узле
|
|
511
577
|
const lastCls = steps[steps.length - 1].cls;
|
|
@@ -524,6 +590,7 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
524
590
|
const keys = keyed.map((k) => k.key);
|
|
525
591
|
const limitOffset = (mods.limit !== undefined ? ` LIMIT ${p.push(mods.limit)}` : '') +
|
|
526
592
|
(mods.offset !== undefined ? ` OFFSET ${p.push(mods.offset)}` : '');
|
|
593
|
+
// START_BLOCK_BUILD_MODE_SQL
|
|
527
594
|
let text;
|
|
528
595
|
switch (mode) {
|
|
529
596
|
case 'paths': {
|
|
@@ -578,8 +645,16 @@ export function buildRead(ctx, steps, mods, mode) {
|
|
|
578
645
|
break;
|
|
579
646
|
}
|
|
580
647
|
}
|
|
648
|
+
// END_BLOCK_BUILD_MODE_SQL
|
|
581
649
|
return { text, params: p.list, keys };
|
|
582
650
|
}
|
|
651
|
+
// START_CONTRACT: insertSql
|
|
652
|
+
// PURPOSE: SQL одиночного INSERT новой версии/tombstone ($9 = updated прошлой версии, $10 = deleted).
|
|
653
|
+
// INPUTS: { pgSchema: string }
|
|
654
|
+
// OUTPUTS: { string - INSERT ... RETURNING * }
|
|
655
|
+
// SIDE_EFFECTS: none
|
|
656
|
+
// LINKS: M-SQL, V-M-SQL, M-DDL
|
|
657
|
+
// END_CONTRACT: insertSql
|
|
583
658
|
/** INSERT новой версии/tombstone. $9 = updated прошлой версии (или null), $10 = deleted. */
|
|
584
659
|
export function insertSql(pgSchema) {
|
|
585
660
|
return (`INSERT INTO ${entityTable(pgSchema)} ` +
|
|
@@ -605,6 +680,13 @@ export function multiInsertSql(pgSchema, n) {
|
|
|
605
680
|
* tombstone актуальной живой версии + рекурсивный каскад по links, всё в БД.
|
|
606
681
|
* $1 partition, $2 class, дальше — id-шники.
|
|
607
682
|
*/
|
|
683
|
+
// START_CONTRACT: deleteSql
|
|
684
|
+
// PURPOSE: SQL серверного удаления n сущностей — триггер entity_delete делает tombstone + рекурсивный каскад.
|
|
685
|
+
// INPUTS: { pgSchema: string; n: number }
|
|
686
|
+
// OUTPUTS: { string - DELETE ... WHERE id IN (...) }
|
|
687
|
+
// SIDE_EFFECTS: none
|
|
688
|
+
// LINKS: M-SQL, V-M-SQL, M-DDL
|
|
689
|
+
// END_CONTRACT: deleteSql
|
|
608
690
|
export function deleteSql(pgSchema, n) {
|
|
609
691
|
const ids = Array.from({ length: n }, (_, i) => `$${i + 3}`).join(', ');
|
|
610
692
|
return `DELETE FROM ${entityTable(pgSchema)} WHERE partition = $1 AND class = $2 AND id IN (${ids})`;
|
|
@@ -613,6 +695,13 @@ export function deleteSql(pgSchema, n) {
|
|
|
613
695
|
* Замыкание удаления: цели + все живые зависимые рекурсивно (то, что каскад затомбстоунит).
|
|
614
696
|
* Рекурсивный CTE; каждый узел — актуальная живая версия. $1 partition, $2 class, $3+ — ids.
|
|
615
697
|
*/
|
|
698
|
+
// START_CONTRACT: closureSql
|
|
699
|
+
// PURPOSE: Рекурсивный CTE: цели + все живые зависимые (то, что затомбстоунит каскад entity_delete).
|
|
700
|
+
// INPUTS: { pgSchema: string; n: number }
|
|
701
|
+
// OUTPUTS: { string - WITH RECURSIVE ... }
|
|
702
|
+
// SIDE_EFFECTS: none
|
|
703
|
+
// LINKS: M-SQL, V-M-SQL, M-DDL
|
|
704
|
+
// END_CONTRACT: closureSql
|
|
616
705
|
export function closureSql(pgSchema, n) {
|
|
617
706
|
const ent = entityTable(pgSchema);
|
|
618
707
|
const ids = Array.from({ length: n }, (_, i) => `$${i + 3}`).join(', ');
|
package/dist/tables.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
const T = (ctx, name) => `"${ctx.pgSchema.replace(/"/g, '""')}"."${name}"`;
|
|
2
|
+
// START_CONTRACT: where
|
|
3
|
+
// PURPOSE: Собрать SQL WHERE из простых equality-условий, добавляя значения в params.
|
|
4
|
+
// INPUTS: { conds: Cond[] - условия с генераторами текста; params: unknown[] - аккумулятор значений }
|
|
5
|
+
// OUTPUTS: { string - " WHERE ..." или пустая строка }
|
|
6
|
+
// SIDE_EFFECTS: пушит значения в params (мутация массива)
|
|
7
|
+
// LINKS: M-TABLES, V-M-TABLES
|
|
8
|
+
// END_CONTRACT: where
|
|
2
9
|
/** WHERE из простых equality-условий (+спец-обработчики). */
|
|
3
10
|
function where(conds, params) {
|
|
4
11
|
if (!conds.length)
|
|
@@ -11,8 +18,16 @@ function where(conds, params) {
|
|
|
11
18
|
}
|
|
12
19
|
const eq = (col) => (n) => `"${col}" = $${n}`;
|
|
13
20
|
// ---------------------------------------------------------------------------
|
|
21
|
+
// START_CONTRACT: makeTables
|
|
22
|
+
// PURPOSE: Построить фасад Tables (accounts/credentials/resources/rules) поверх Ctx.
|
|
23
|
+
// INPUTS: { ctx: Ctx - runtime-контекст с sql и pgSchema }
|
|
24
|
+
// OUTPUTS: { Tables - четыре API с find/get/set/delete }
|
|
25
|
+
// SIDE_EFFECTS: каждый метод выполняет SELECT/INSERT/UPDATE/DELETE через ctx.sql
|
|
26
|
+
// LINKS: M-TABLES, V-M-TABLES, M-DDL
|
|
27
|
+
// END_CONTRACT: makeTables
|
|
14
28
|
export function makeTables(ctx) {
|
|
15
29
|
const run = (text, params) => ctx.sql.unsafe(text, params);
|
|
30
|
+
// START_BLOCK_ACCOUNTS_API
|
|
16
31
|
const accounts = {
|
|
17
32
|
async find(f = {}) {
|
|
18
33
|
const conds = [];
|
|
@@ -28,6 +43,14 @@ export function makeTables(ctx) {
|
|
|
28
43
|
async get(id) {
|
|
29
44
|
return (await run(`SELECT * FROM ${T(ctx, 'Account')} WHERE id = $1`, [id]))[0] ?? null;
|
|
30
45
|
},
|
|
46
|
+
// START_CONTRACT: accounts.set
|
|
47
|
+
// PURPOSE: INSERT (без id) либо UPDATE по id аккаунта; вернуть строку Account.
|
|
48
|
+
// INPUTS: { a: Partial<Account> - поля categories/data/meta/avatar/enabled (+id для update) }
|
|
49
|
+
// OUTPUTS: { Promise<Account> - созданная/обновлённая строка }
|
|
50
|
+
// SIDE_EFFECTS: INSERT или UPDATE в таблице Account
|
|
51
|
+
// ERRORS: account "<id>" not found (UPDATE не нашёл строку)
|
|
52
|
+
// LINKS: M-TABLES, V-M-TABLES
|
|
53
|
+
// END_CONTRACT: accounts.set
|
|
31
54
|
async set(a) {
|
|
32
55
|
if (a.id) {
|
|
33
56
|
const sets = ['updated = now()'];
|
|
@@ -63,6 +86,8 @@ export function makeTables(ctx) {
|
|
|
63
86
|
return rows.length > 0;
|
|
64
87
|
},
|
|
65
88
|
};
|
|
89
|
+
// END_BLOCK_ACCOUNTS_API
|
|
90
|
+
// START_BLOCK_CREDENTIALS_API
|
|
66
91
|
const credentials = {
|
|
67
92
|
async find(f = {}) {
|
|
68
93
|
const conds = [];
|
|
@@ -82,6 +107,13 @@ export function makeTables(ctx) {
|
|
|
82
107
|
sql += conds.length ? ' AND deleted IS NULL' : ' WHERE deleted IS NULL';
|
|
83
108
|
return run(sql + ' ORDER BY created', params);
|
|
84
109
|
},
|
|
110
|
+
// START_CONTRACT: credentials.set
|
|
111
|
+
// PURPOSE: upsert креденшела по UNIQUE (account, category, identifier); повторный set воскрешает (deleted → NULL).
|
|
112
|
+
// INPUTS: { c: { account, category, identifier, meta?, confirmed? } }
|
|
113
|
+
// OUTPUTS: { Promise<Credential> - upsert-нутая строка }
|
|
114
|
+
// SIDE_EFFECTS: INSERT ... ON CONFLICT DO UPDATE в таблице Credential
|
|
115
|
+
// LINKS: M-TABLES, V-M-TABLES
|
|
116
|
+
// END_CONTRACT: credentials.set
|
|
85
117
|
async set(c) {
|
|
86
118
|
const rows = await run(`INSERT INTO ${T(ctx, 'Credential')} (account, category, identifier, meta, confirmed)
|
|
87
119
|
VALUES ($1, $2, $3, $4, $5)
|
|
@@ -95,6 +127,8 @@ export function makeTables(ctx) {
|
|
|
95
127
|
return rows.length > 0;
|
|
96
128
|
},
|
|
97
129
|
};
|
|
130
|
+
// END_BLOCK_CREDENTIALS_API
|
|
131
|
+
// START_BLOCK_RESOURCES_API
|
|
98
132
|
const resources = {
|
|
99
133
|
async find(f = {}) {
|
|
100
134
|
const conds = [];
|
|
@@ -117,6 +151,8 @@ export function makeTables(ctx) {
|
|
|
117
151
|
return rows.length > 0;
|
|
118
152
|
},
|
|
119
153
|
};
|
|
154
|
+
// END_BLOCK_RESOURCES_API
|
|
155
|
+
// START_BLOCK_RULES_API
|
|
120
156
|
const rules = {
|
|
121
157
|
async find(f = {}) {
|
|
122
158
|
const conds = [];
|
|
@@ -144,5 +180,6 @@ export function makeTables(ctx) {
|
|
|
144
180
|
return rows.length > 0;
|
|
145
181
|
},
|
|
146
182
|
};
|
|
183
|
+
// END_BLOCK_RULES_API
|
|
147
184
|
return { accounts, credentials, resources, rules };
|
|
148
185
|
}
|
package/dist/tx.js
CHANGED
|
@@ -3,7 +3,34 @@
|
|
|
3
3
|
* db.commit(tr) / db.rollback(tr) / tr.commit() / tr.rollback().
|
|
4
4
|
* tr.lock(...) — pg_advisory_xact_lock (сериализация гонок, напр. двойная бронь).
|
|
5
5
|
*/
|
|
6
|
+
// FILE: lib/src/tx.ts
|
|
7
|
+
// VERSION: 1.0.0
|
|
8
|
+
// START_MODULE_CONTRACT
|
|
9
|
+
// PURPOSE: Ручные транзакции и advisory-локи поверх зарезервированного pg-соединения.
|
|
10
|
+
// SCOPE: beginTx, lock
|
|
11
|
+
// DEPENDS: M-SQL
|
|
12
|
+
// LINKS: M-TX, V-M-TX
|
|
13
|
+
// ROLE: RUNTIME
|
|
14
|
+
// MAP_MODE: EXPORTS
|
|
15
|
+
// END_MODULE_CONTRACT
|
|
16
|
+
//
|
|
17
|
+
// START_MODULE_MAP
|
|
18
|
+
// TxCtx - Ctx транзакции с методами commit()/rollback()
|
|
19
|
+
// beginTx - резервирует соединение, шлёт BEGIN, возвращает TxCtx
|
|
20
|
+
// lock - pg_advisory_xact_lock на составной ключ (только внутри транзакции)
|
|
21
|
+
// END_MODULE_MAP
|
|
22
|
+
//
|
|
23
|
+
// START_CHANGE_SUMMARY
|
|
24
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
25
|
+
// END_CHANGE_SUMMARY
|
|
6
26
|
import { hintTxRetry, isTransient } from './sql.js';
|
|
27
|
+
// START_CONTRACT: beginTx
|
|
28
|
+
// PURPOSE: Начать ручную транзакцию на зарезервированном соединении и вернуть TxCtx.
|
|
29
|
+
// INPUTS: { ctx: Ctx - базовый контекст с пулом sql }
|
|
30
|
+
// OUTPUTS: { Promise<TxCtx> - контекст с sql на резерве + commit()/rollback() }
|
|
31
|
+
// SIDE_EFFECTS: reserve() соединения из пула и BEGIN; commit/rollback шлют COMMIT/ROLLBACK и release() (идемпотентно через флаг done)
|
|
32
|
+
// LINKS: M-TX, V-M-TX, M-SQL
|
|
33
|
+
// END_CONTRACT: beginTx
|
|
7
34
|
export async function beginTx(ctx) {
|
|
8
35
|
const reserved = await ctx.sql.reserve();
|
|
9
36
|
const r = reserved;
|
|
@@ -30,10 +57,18 @@ export async function beginTx(ctx) {
|
|
|
30
57
|
};
|
|
31
58
|
}
|
|
32
59
|
/** Advisory-lock на составной ключ; живёт до конца транзакции. */
|
|
60
|
+
// START_CONTRACT: lock
|
|
61
|
+
// PURPOSE: Взять pg_advisory_xact_lock на составной ключ (сериализация гонок; живёт до конца транзакции).
|
|
62
|
+
// INPUTS: { ctx: Ctx - контекст (должен быть inTx); keys: (string|number)[] - части составного ключа }
|
|
63
|
+
// OUTPUTS: { Promise<void> }
|
|
64
|
+
// SIDE_EFFECTS: SELECT pg_advisory_xact_lock(hashtextextended(...)); вне транзакции бросает 'letopis: lock() works only inside db.begin() transaction'; на transient-ошибке (напр. deadlock 40P01) вызывает hintTxRetry и пробрасывает
|
|
65
|
+
// LINKS: M-TX, V-M-TX, M-SQL
|
|
66
|
+
// END_CONTRACT: lock
|
|
33
67
|
export async function lock(ctx, ...keys) {
|
|
34
68
|
if (!ctx.inTx) {
|
|
35
69
|
throw new Error('letopis: lock() works only inside db.begin() transaction (pg_advisory_xact_lock)');
|
|
36
70
|
}
|
|
71
|
+
// START_BLOCK_ADVISORY_LOCK
|
|
37
72
|
try {
|
|
38
73
|
await ctx.sql.unsafe('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [keys.join('|')]);
|
|
39
74
|
}
|
|
@@ -43,4 +78,5 @@ export async function lock(ctx, ...keys) {
|
|
|
43
78
|
hintTxRetry(e);
|
|
44
79
|
throw e;
|
|
45
80
|
}
|
|
81
|
+
// END_BLOCK_ADVISORY_LOCK
|
|
46
82
|
}
|
package/dist/types.js
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* letopis: типы.
|
|
3
3
|
*/
|
|
4
|
+
//
|
|
5
|
+
// FILE: lib/src/types.ts
|
|
6
|
+
// VERSION: 1.0.0
|
|
7
|
+
// START_MODULE_CONTRACT
|
|
8
|
+
// PURPOSE: Общий словарь типов всей библиотеки letopis.
|
|
9
|
+
// SCOPE: сущности, пути, определения классов, типы полей, фильтры и операторы, курсоры/план записи/модификаторы цепочки, записи auth/ACL, событие запроса и опции connect + символ OP.
|
|
10
|
+
// DEPENDS: none
|
|
11
|
+
// LINKS: M-TYPES, V-M-TYPES
|
|
12
|
+
// ROLE: TYPES
|
|
13
|
+
// MAP_MODE: EXPORTS
|
|
14
|
+
// END_MODULE_CONTRACT
|
|
15
|
+
//
|
|
16
|
+
// START_MODULE_MAP
|
|
17
|
+
// Row - строка Entity (актуальная версия): id/class/data/links/tags/account/owner/updated + служебные $deleted/$depth.
|
|
18
|
+
// Path - вариант пути run(): по одному узлу Row на каждый шаг цепочки.
|
|
19
|
+
// LinkEnd - конец связи класса (Schema.links v2): допустимые классы, optional, cardinality.
|
|
20
|
+
// IdGen - способ генерации id класса (версия 4/5/7) + источники from для v5.
|
|
21
|
+
// ClassDef - класс из таблицы Schema: категория, наследование, атрибуты, концы связей, idGen, валидатор, fieldTypes.
|
|
22
|
+
// FieldType - тип поля attributes для SQL-кастов (number/date/boolean/string/array/record/object/any).
|
|
23
|
+
// OP - символьная метка операторов фильтра (Symbol — не конфликтует с данными).
|
|
24
|
+
// Op - узел оператора фильтра: { [OP]: имя, args }.
|
|
25
|
+
// Scalar - скалярное значение фильтра: string | number | boolean | null.
|
|
26
|
+
// FieldFilter - значение фильтра по полю: скаляр (eq) / массив / оператор Op / вложенный record.
|
|
27
|
+
// Filter - фильтр шага: id-строка/массив, Op (or) или объект полей data.
|
|
28
|
+
// Cursor - курсор keyset-пагинации: значение поля сортировки + id последней строки.
|
|
29
|
+
// PlanOp - операция записи шага (create/update/delete/anonymize) + снапшот модификаторов.
|
|
30
|
+
// ChainMods - модификаторы выборки цепочки (limit/offset/order/desc/asOf/after + внутренняя агрегация).
|
|
31
|
+
// Account - запись аккаунта служебной таблицы auth.
|
|
32
|
+
// Credential - учётные данные аккаунта (identifier/category/confirmed/…).
|
|
33
|
+
// Resource - ресурс ACL: alias/category/pattern/meta.
|
|
34
|
+
// Rule - правило ACL: account/resource/permission/weight/enabled.
|
|
35
|
+
// AclOp - операция над данными = категория Resource: 'READ' | 'WRITE' | 'DELETE'.
|
|
36
|
+
// AclDecision - решение ACL: allow + победившее правило + остаточный filter + code/message.
|
|
37
|
+
// QueryEvent - событие хука onQuery: режим, классы, ms, rows, slow.
|
|
38
|
+
// ConnectOpts - опции connect(): dsn/schema/partition/account/owner/max/enforceAccount/enforceAcl/onQuery/slowMs.
|
|
39
|
+
// END_MODULE_MAP
|
|
40
|
+
//
|
|
41
|
+
// START_CHANGE_SUMMARY
|
|
42
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
43
|
+
// END_CHANGE_SUMMARY
|
|
44
|
+
//
|
|
45
|
+
// START_BLOCK_ENTITY_TYPES
|
|
46
|
+
// END_BLOCK_ENTITY_TYPES
|
|
47
|
+
//
|
|
48
|
+
// START_BLOCK_FILTER_TYPES
|
|
4
49
|
/** Метка операторов фильтра (Symbol — не конфликтует с данными). */
|
|
5
50
|
export const OP = Symbol('letopis.op');
|
|
51
|
+
// END_BLOCK_CONNECT_TYPES
|