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/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
- return ` ORDER BY ${sortField(alias, cls, mods.order)}${mods.desc ? ' DESC' : ''}`;
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