letopis 0.18.0 → 0.19.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/index.js CHANGED
@@ -60,8 +60,10 @@ export async function connect(opts) {
60
60
  slowMs: opts.slowMs,
61
61
  };
62
62
  // START_BLOCK_ENFORCE_ACL
63
- // enforceAcl: правила и категории субъекта фиксируются на connect (перечитка — новый connect)
64
- if (opts.enforceAcl) {
63
+ // enforceAcl: правила и категории субъекта компилятся в резолвер (перечитываются reloadSchema/reload)
64
+ const compileAcl = async () => {
65
+ if (!opts.enforceAcl)
66
+ return;
65
67
  if (!opts.account)
66
68
  throw new Error('letopis: enforceAcl requires connect({ account })');
67
69
  const tables = makeTables(ctx);
@@ -72,8 +74,14 @@ export async function connect(opts) {
72
74
  ]);
73
75
  if (!account)
74
76
  throw new Error(`letopis: enforceAcl — account "${opts.account}" not found`);
75
- ctx.aclDecide = compileEnforcer({ resources, rules }, account.id, account.categories, registry);
76
- }
77
+ ctx.aclDecide = compileEnforcer({ resources, rules }, account.id, account.categories, ctx.registry);
78
+ };
79
+ await compileAcl();
80
+ // db.reloadSchema(): перечитать определения классов из таблицы Schema (registry + enforcer) без реконнекта
81
+ ctx.reload = async () => {
82
+ ctx.registry = await loadRegistry(sql, opts.schema, partition);
83
+ await compileAcl();
84
+ };
77
85
  // END_BLOCK_ENFORCE_ACL
78
86
  return makeDb(ctx);
79
87
  }
package/dist/sql.d.ts CHANGED
@@ -41,6 +41,8 @@ export interface Ctx {
41
41
  onQuery?: (e: QueryEvent) => void;
42
42
  /** Порог «медленного» запроса, мс (без onQuery — console.warn). */
43
43
  slowMs?: number;
44
+ /** Перечитать определения классов из таблицы Schema: пересобрать registry (+ enforcer при enforceAcl). Ставит connect(). */
45
+ reload?: () => Promise<void>;
44
46
  }
45
47
  /** Транзиентные ошибки PG — гонка, снимаемая повтором: deadlock / serialization failure. */
46
48
  export declare function isTransient(e: unknown): boolean;
@@ -131,4 +133,10 @@ export declare function deleteSql(pgSchema: string, n: number): string;
131
133
  * Рекурсивный CTE; каждый узел — актуальная живая версия. $1 partition, $2 class, $3+ — ids.
132
134
  */
133
135
  export declare function closureSql(pgSchema: string, n: number): string;
136
+ /**
137
+ * Вызов серверной purge(): $4 dry → превью (актуальные версии замыкания, БД цела), иначе двухфазный
138
+ * физический снос корня+поддерева. RETURNS SETOF Entity (по строке на сущность). Вся логика
139
+ * (замыкание, двухфазность, отключение триггера через SET LOCAL) — в БД. $1 partition, $2 class, $3 id, $4 dry.
140
+ */
141
+ export declare function purgeCallSql(pgSchema: string): string;
134
142
  export {};
package/dist/sql.js CHANGED
@@ -517,8 +517,10 @@ export function buildRead(ctx, steps, mods, mode) {
517
517
  const innerWhere = [`e.partition = ${pPart}`, `e.class = ${pCls}`];
518
518
  if (mods.asOf !== undefined)
519
519
  innerWhere.push(`e.updated <= ${p.push(mods.asOf)}::timestamptz`); // «как было на T»
520
- // versions: последний шаг включает и удалённые сущности (история существует после delete)
521
- const outerWhere = [mode === 'versions' && i === steps.length - 1 ? 'TRUE' : 't.deleted IS NULL'];
520
+ // versions/.withDeleted(): последний шаг включает и удалённые (история/tombstone видны после delete).
521
+ // Снимается ТОЛЬКО фильтр deleted — enforceAccount/enforceAcl/контекст-фраги ниже действуют (изоляция цела).
522
+ const keepDeleted = i === steps.length - 1 && (mode === 'versions' || mods.withDeleted === true);
523
+ const outerWhere = [keepDeleted ? 'TRUE' : 't.deleted IS NULL'];
522
524
  const candWhere = f.candFrags.map((fr) => fr('c'));
523
525
  for (const c of f.idConds)
524
526
  innerWhere.push(c('e'));
@@ -726,3 +728,18 @@ export function closureSql(pgSchema, n) {
726
728
  )
727
729
  SELECT DISTINCT ON (class, id) * FROM node ORDER BY class, id, updated DESC`);
728
730
  }
731
+ /**
732
+ * Вызов серверной purge(): $4 dry → превью (актуальные версии замыкания, БД цела), иначе двухфазный
733
+ * физический снос корня+поддерева. RETURNS SETOF Entity (по строке на сущность). Вся логика
734
+ * (замыкание, двухфазность, отключение триггера через SET LOCAL) — в БД. $1 partition, $2 class, $3 id, $4 dry.
735
+ */
736
+ // START_CONTRACT: purgeCallSql
737
+ // PURPOSE: SQL-обёртка вызова серверной purge() (снос/превью) — вся логика в БД, JS лишь зовёт.
738
+ // INPUTS: { pgSchema: string }
739
+ // OUTPUTS: { string - SELECT * FROM "<schema>".purge($1,$2,$3,$4) }
740
+ // SIDE_EFFECTS: none
741
+ // LINKS: M-SQL, V-M-SQL, M-DDL, M-WRITE
742
+ // END_CONTRACT: purgeCallSql
743
+ export function purgeCallSql(pgSchema) {
744
+ return `SELECT * FROM ${escId(pgSchema)}.purge($1, $2, $3, $4)`;
745
+ }
package/dist/tables.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface AccountsApi {
17
17
  set(a: Partial<Account>): Promise<Account>;
18
18
  /** Физический DELETE; Credential снесётся FK-каскадом. */
19
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>;
20
26
  }
21
27
  export interface CredentialsApi {
22
28
  /** deleted IS NULL по умолчанию; withDeleted: true — включая удалённые. */
@@ -72,10 +78,28 @@ export interface RulesApi {
72
78
  }): Promise<Rule>;
73
79
  delete(account: string, resource: string): Promise<boolean>;
74
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
+ }
75
98
  export interface Tables {
76
99
  accounts: AccountsApi;
77
100
  credentials: CredentialsApi;
78
101
  resources: ResourcesApi;
79
102
  rules: RulesApi;
103
+ schema: SchemaApi;
80
104
  }
81
105
  export declare function makeTables(ctx: Ctx): Tables;
package/dist/tables.js CHANGED
@@ -85,6 +85,44 @@ export function makeTables(ctx) {
85
85
  const rows = await run(`DELETE FROM ${T(ctx, 'Account')} WHERE id = $1 RETURNING id`, [id]);
86
86
  return rows.length > 0;
87
87
  },
88
+ // START_CONTRACT: accounts.purge
89
+ // PURPOSE: Физический офбординг тенанта через purge_account(); гарды Owner/System, последний Owner, не-себя — до сноса, в одной tx.
90
+ // INPUTS: { id: string - целевой аккаунт }
91
+ // OUTPUTS: { Promise<boolean> - true если Account физически снесён }
92
+ // SIDE_EFFECTS: физ. DELETE всех Entity аккаунта + Account (Credential FK-каскад) через SQL purge_account()
93
+ // ERRORS: нет caller/ACL; вызов не Owner/System; снос последнего Owner; снос своего аккаунта
94
+ // LINKS: M-TABLES, V-M-TABLES, M-DDL
95
+ // END_CONTRACT: accounts.purge
96
+ async purge(id) {
97
+ if (!ctx.account) {
98
+ throw new Error('letopis: accounts.purge requires an authenticated caller — connect({ account })');
99
+ }
100
+ if (id === ctx.account) {
101
+ throw new Error('letopis: accounts.purge cannot purge the calling account itself');
102
+ }
103
+ const schema = `"${ctx.pgSchema.replace(/"/g, '""')}"`;
104
+ return ctx.sql.begin(async (tsql) => {
105
+ const q = (t, p) => tsql.unsafe(t, p);
106
+ // вызывающий обязан быть Owner/System
107
+ const caller = await q(`SELECT categories FROM ${T(ctx, 'Account')} WHERE id = $1`, [ctx.account]);
108
+ const cats = caller[0]?.categories ?? [];
109
+ if (!cats.includes('Owner') && !cats.includes('System')) {
110
+ throw new Error('letopis: accounts.purge is restricted to Owner/System accounts');
111
+ }
112
+ const tgt = await q(`SELECT categories FROM ${T(ctx, 'Account')} WHERE id = $1`, [id]);
113
+ if (!tgt.length)
114
+ return false;
115
+ // нельзя снести последний enabled Owner → тенант без владельца (лок-аут)
116
+ if ((tgt[0].categories ?? []).includes('Owner')) {
117
+ const cnt = await q(`SELECT count(*)::text AS n FROM ${T(ctx, 'Account')} WHERE 'Owner' = ANY(categories) AND enabled`, []);
118
+ if (Number(cnt[0]?.n ?? 0) <= 1) {
119
+ throw new Error('letopis: refusing to purge the last enabled Owner (tenant lock-out)');
120
+ }
121
+ }
122
+ const res = await q(`SELECT ${schema}.purge_account($1::uuid) AS id`, [id]);
123
+ return res[0]?.id != null;
124
+ });
125
+ },
88
126
  };
89
127
  // END_BLOCK_ACCOUNTS_API
90
128
  // START_BLOCK_CREDENTIALS_API
@@ -181,5 +219,22 @@ export function makeTables(ctx) {
181
219
  },
182
220
  };
183
221
  // END_BLOCK_RULES_API
184
- return { accounts, credentials, resources, rules };
222
+ // START_BLOCK_SCHEMA_API
223
+ const schema = {
224
+ async define(def) {
225
+ // jsonb-значения через sql.json() — postgres.js кладёт как jsonb (не PG-массив/строку)
226
+ const j = (v) => ctx.sql.json(v);
227
+ await run(`INSERT INTO ${T(ctx, 'Schema')} (partition, id, alias, category, ancestor, attributes, meta, links, "order")
228
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
229
+ ON CONFLICT (partition, id) DO UPDATE SET
230
+ alias = EXCLUDED.alias, category = EXCLUDED.category, ancestor = EXCLUDED.ancestor,
231
+ attributes = EXCLUDED.attributes, meta = EXCLUDED.meta, links = EXCLUDED.links, "order" = EXCLUDED."order"`, [
232
+ ctx.partition, def.id, def.alias, def.category, def.ancestor ?? null,
233
+ j(def.attributes ?? {}), j(def.meta ?? {}), j(def.links ?? []), def.order ?? 0,
234
+ ]);
235
+ await ctx.reload?.(); // подхватить новое определение сразу (registry + enforcer)
236
+ },
237
+ };
238
+ // END_BLOCK_SCHEMA_API
239
+ return { accounts, credentials, resources, rules, schema };
185
240
  }
package/dist/types.d.ts CHANGED
@@ -125,12 +125,12 @@ export interface Cursor {
125
125
  * Терминал исполняет план (все операции + финальное чтение) одной транзакцией.
126
126
  */
127
127
  export interface PlanOp {
128
- kind: 'create' | 'update' | 'delete' | 'anonymize';
128
+ kind: 'create' | 'update' | 'delete' | 'anonymize' | 'purge';
129
129
  /** create/update: данные новой версии (deep-merge листьев). */
130
130
  data?: Record<string, unknown>;
131
131
  /** anonymize: string-поля под '[erased]'. */
132
132
  fields?: string[];
133
- /** delete: true — удалить; без confirm — превью (вернуть кандидатов, БД не трогать). */
133
+ /** delete/purge: true — выполнить; без confirm — превью (вернуть кандидатов/замыкание, БД не трогать). */
134
134
  confirm?: boolean;
135
135
  /** Снапшот модификаторов на момент вызова операции (limit/sort для поиска целей). */
136
136
  mods: ChainMods;
@@ -149,6 +149,8 @@ export interface ChainMods {
149
149
  /** Внутреннее: агрегация терминалов .sum/.avg/.min/.max/.countBy. */
150
150
  aggFn?: 'sum' | 'avg' | 'min' | 'max' | 'countBy';
151
151
  aggField?: string;
152
+ /** .withDeleted(): последний шаг включает удалённые (tombstone). Снимает ТОЛЬКО фильтр deleted; enforceAccount/enforceAcl действуют. */
153
+ withDeleted?: boolean;
152
154
  }
153
155
  export interface Account {
154
156
  id: string;
package/dist/write.d.ts CHANGED
@@ -56,6 +56,15 @@ export declare function anonymizeOp(ctx: Ctx, steps: Step[], mods: ChainMods, fi
56
56
  * Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
57
57
  */
58
58
  export declare function delOp(ctx: Ctx, steps: Step[], mods: ChainMods, confirm: boolean): Promise<Row[]>;
59
+ /**
60
+ * .purge({confirm}): ФИЗИЧЕСКИЙ hard-erase — сносит логически удалённые цели + всё поддерево (все версии).
61
+ * Вся логика — в серверной purge() (двухфазность: живую цель не трогает; замыкание по links; SET LOCAL
62
+ * отключает entity_delete → плоский снос без TM_SelfModified). JS лишь резолвит цели и зовёт функцию.
63
+ * confirm: false → dry-превью (что сотрётся, БД цела); true → снос, возвращает снесённое ($deleted).
64
+ * gate: DELETE-право на класс цели (aclWrite) — поддерево уже было DELETE-авторизовано при мягком удалении.
65
+ * В отличие от .delete() (tombstone, обратимо) — необратимо, историю НЕ сохраняет.
66
+ */
67
+ export declare function purgeOp(ctx: Ctx, steps: Step[], mods: ChainMods, confirm: boolean): Promise<Row[]>;
59
68
  export type PlanMode = 'rows' | 'ids' | 'count' | 'paths' | 'versions' | 'agg';
60
69
  /**
61
70
  * Исполнить план целиком: все операции + финальное чтение — одна транзакция
package/dist/write.js CHANGED
@@ -40,7 +40,7 @@
40
40
  // END_CHANGE_SUMMARY
41
41
  import { randomUUID } from 'node:crypto';
42
42
  import { uuidv5, uuidv7 } from './uuid.js';
43
- import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, isTransient, retryDelay, RETRIES, } from './sql.js';
43
+ import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, purgeCallSql, isTransient, retryDelay, RETRIES, } from './sql.js';
44
44
  import { aclDenied } from './acl.js';
45
45
  // START_CONTRACT: ValidationError
46
46
  // PURPOSE: Ошибка валидации класса, несущая список проблемных полей (issues).
@@ -581,6 +581,40 @@ export async function delOp(ctx, steps, mods, confirm) {
581
581
  });
582
582
  // END_BLOCK_DELETE_CASCADE
583
583
  }
584
+ /**
585
+ * .purge({confirm}): ФИЗИЧЕСКИЙ hard-erase — сносит логически удалённые цели + всё поддерево (все версии).
586
+ * Вся логика — в серверной purge() (двухфазность: живую цель не трогает; замыкание по links; SET LOCAL
587
+ * отключает entity_delete → плоский снос без TM_SelfModified). JS лишь резолвит цели и зовёт функцию.
588
+ * confirm: false → dry-превью (что сотрётся, БД цела); true → снос, возвращает снесённое ($deleted).
589
+ * gate: DELETE-право на класс цели (aclWrite) — поддерево уже было DELETE-авторизовано при мягком удалении.
590
+ * В отличие от .delete() (tombstone, обратимо) — необратимо, историю НЕ сохраняет.
591
+ */
592
+ // START_CONTRACT: purgeOp
593
+ // PURPOSE: .purge({confirm}) — резолв целей (вкл. tombstone) + вызов серверной purge() (снос при confirm, иначе dry-превью).
594
+ // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; confirm: boolean }
595
+ // OUTPUTS: { Promise<Row[]> - снесённое с $deleted, либо dry-превью; [] если цели не tombstone/не найдены }
596
+ // SIDE_EFFECTS: физический DELETE в Entity через purge() (SET LOCAL letopis.purge отключает триггер)
597
+ // ERRORS: acl denies DELETE (класс цели)
598
+ // LINKS: M-WRITE, V-M-WRITE, M-ACL, M-DDL, M-SQL
599
+ // END_CONTRACT: purgeOp
600
+ export async function purgeOp(ctx, steps, mods, confirm) {
601
+ const target = steps[steps.length - 1];
602
+ aclWrite(ctx, target, 'DELETE');
603
+ // цели ищем ВКЛЮЧАЯ tombstone — .purge() работает по уже логически удалённым (двухфазность в purge())
604
+ const targets = await readRows(ctx, steps, { ...mods, withDeleted: true });
605
+ if (!targets.length)
606
+ return [];
607
+ return inTransaction(ctx, async (c) => {
608
+ const out = [];
609
+ // вся логика (двухфазность, замыкание, отключение триггера) — в серверной purge(); dry = !confirm
610
+ for (const t of targets) {
611
+ const rows = await runQuery(c, purgeCallSql(c.pgSchema), [c.partition, target.cls.id, t.id, !confirm], 'delete', [target.cls.id]);
612
+ for (const r of rows)
613
+ out.push(confirm ? { ...toRow(r), $deleted: true } : toRow(r));
614
+ }
615
+ return out;
616
+ });
617
+ }
584
618
  /** Разрезать план: сегменты (…шаги + op-шаг) и читающий хвост после последней операции. */
585
619
  function splitPlan(steps) {
586
620
  const segments = [];
@@ -604,6 +638,8 @@ function opCall(c, steps, op) {
604
638
  return anonymizeOp(c, steps, op.mods, op.fields ?? []);
605
639
  case 'delete':
606
640
  return delOp(c, steps, op.mods, op.confirm === true);
641
+ case 'purge':
642
+ return purgeOp(c, steps, op.mods, op.confirm === true);
607
643
  }
608
644
  }
609
645
  /** Виртуальный старт-шаг «от этих строк»: контекст/чтение следующего сегмента. */
@@ -651,7 +687,8 @@ export async function runPlan(ctx, steps, mods, mode) {
651
687
  last.push(...await opCall(c, rowSteps, seg.op));
652
688
  }
653
689
  }
654
- start = seg.op.kind === 'delete' ? last.filter((r) => r.class === targetCls.id) : last;
690
+ start = (seg.op.kind === 'delete' || seg.op.kind === 'purge')
691
+ ? last.filter((r) => r.class === targetCls.id) : last;
655
692
  }
656
693
  // END_BLOCK_RUNPLAN_SEGMENTS
657
694
  const opStep = segments[segments.length - 1].steps.at(-1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Letopis (летопись): append-only versioned entity store on TimescaleDB with dot-notation chains — every change is a new row, history is first-class (asOf, versions, watch, cascade tombstones)",
5
5
  "keywords": [
6
6
  "timescaledb",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "files": [
37
37
  "dist",
38
+ "scripts",
38
39
  "sql",
39
40
  "docker",
40
41
  "README.md",
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Генерация TS-типов из таблицы Schema:
4
+ * npx tsx scripts/gen-types.mjs --dsn=postgres://… --schema=v1.booking [--out=entity-types.d.ts]
5
+ *
6
+ * Результат: интерфейсы data-полей каждого класса + фасад TypedDb.
7
+ * import type { TypedDb } from './entity-types';
8
+ * const t = db as unknown as TypedDb; // t.Сотрудник(...).rows(): Promise<TypedRow<StaffData>[]>
9
+ */
10
+ //
11
+ // FILE: lib/scripts/gen-types.mjs
12
+ // VERSION: 1.0.0
13
+ // START_MODULE_CONTRACT
14
+ // PURPOSE: CLI-кодген — читает таблицу Schema и печатает типизированный .d.ts-фасад (Data-интерфейсы классов + TypedRow/TypedChain/TypedDb).
15
+ // SCOPE: парсинг аргументов, чтение Schema, конвертация fastest-validator DSL → TS (ts), эмиссия .d.ts.
16
+ // DEPENDS: none
17
+ // LINKS: M-GEN-TYPES, V-M-GEN-TYPES
18
+ // ROLE: SCRIPT
19
+ // MAP_MODE: LOCALS
20
+ // END_MODULE_CONTRACT
21
+ //
22
+ // START_MODULE_MAP
23
+ // ts - fastest-validator DSL → TS-тип ({ t, opt })
24
+ // ident - безопасный идентификатор либо строковый ключ
25
+ // sql/rows - чтение классов из таблицы Schema
26
+ // lines/facade - аккумуляторы генерируемого .d.ts
27
+ // END_MODULE_MAP
28
+ //
29
+ // START_CHANGE_SUMMARY
30
+ // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
31
+ // END_CHANGE_SUMMARY
32
+ import { writeFile } from 'node:fs/promises';
33
+ import postgres from 'postgres';
34
+
35
+ // START_BLOCK_PARSE_ARGS
36
+ const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')));
37
+ const dsn = args.dsn ?? process.env.ENTITY_DSN;
38
+ const schema = args.schema ?? 'booking';
39
+ const out = args.out ?? 'entity-types.d.ts';
40
+ if (!dsn) { console.error('usage: tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking [--out=…]'); process.exit(1); }
41
+
42
+ // END_BLOCK_PARSE_ARGS
43
+ // START_CONTRACT: ts
44
+ // PURPOSE: Свести attribute-спеку fastest-validator (строка/массив/объект) к TS-типу и признаку optional.
45
+ // INPUTS: { attr: unknown - правило поля }
46
+ // OUTPUTS: { { t: string; opt: boolean } }
47
+ // SIDE_EFFECTS: none (рекурсивно по вложенным props/items)
48
+ // LINKS: M-GEN-TYPES, V-M-GEN-TYPES
49
+ // END_CONTRACT: ts
50
+ // fastest-validator DSL → TS-тип
51
+ function ts(attr) {
52
+ if (typeof attr === 'string') {
53
+ const parts = attr.split('|').map((s) => s.trim());
54
+ const opt = parts.includes('optional');
55
+ const base = { number: 'number', boolean: 'boolean', date: 'string', string: 'string', uuid: 'string', email: 'string', url: 'string', any: 'unknown' }[parts[0]] ?? 'unknown';
56
+ return { t: base, opt };
57
+ }
58
+ if (Array.isArray(attr)) { const first = ts(attr[0] ?? 'any'); return { t: first.t, opt: true }; }
59
+ if (typeof attr === 'object' && attr !== null) {
60
+ const opt = attr.optional === true;
61
+ switch (attr.type) {
62
+ case 'enum': return { t: (attr.values ?? []).map((v) => JSON.stringify(v)).join(' | ') || 'string', opt };
63
+ case 'record': {
64
+ const key = attr.key?.type === 'enum' ? (attr.key.values ?? []).map((v) => JSON.stringify(v)).join(' | ') : 'string';
65
+ return { t: `Partial<Record<${key || 'string'}, number>>`, opt };
66
+ }
67
+ case 'array': {
68
+ const item = ts(attr.items ?? 'any');
69
+ return { t: `(${item.t})[]`, opt };
70
+ }
71
+ case 'number': return { t: 'number', opt };
72
+ case 'boolean': return { t: 'boolean', opt };
73
+ case 'date': return { t: 'string', opt };
74
+ case 'string': case 'uuid': case 'email': case 'url': return { t: 'string', opt };
75
+ case 'object': {
76
+ const props = attr.props ?? attr.properties;
77
+ if (props && typeof props === 'object') {
78
+ const fields = Object.entries(props)
79
+ .map(([k, v]) => { const f = ts(v); return `${ident(k)}${f.opt ? '?' : ''}: ${f.t}`; })
80
+ .join('; ');
81
+ return { t: `{ ${fields} }`, opt };
82
+ }
83
+ return { t: 'Record<string, unknown>', opt };
84
+ }
85
+ default: return { t: 'unknown', opt };
86
+ }
87
+ }
88
+ return { t: 'unknown', opt: true };
89
+ }
90
+
91
+ const ident = (s) => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : JSON.stringify(s));
92
+
93
+ // START_BLOCK_READ_SCHEMA
94
+ const sql = postgres(dsn, { max: 1 });
95
+ const rows = await sql.unsafe(
96
+ `SELECT id, alias, category, attributes, meta FROM "${schema.replaceAll('"', '""')}"."Schema" WHERE partition = 'entity' ORDER BY category, "order"`,
97
+ );
98
+ await sql.end();
99
+
100
+ // END_BLOCK_READ_SCHEMA
101
+ // START_BLOCK_EMIT_TYPES
102
+ const lines = [
103
+ '/* Сгенерировано scripts/gen-types.mjs — не редактировать вручную. */',
104
+ `import type { Row, Filter, Path, Cursor } from 'letopis';`,
105
+ '',
106
+ 'export interface TypedRow<D> extends Omit<Row, \'data\'> { data: D }',
107
+ '',
108
+ ];
109
+ const facade = [];
110
+ for (const r of rows) {
111
+ if (r.meta?.abstract === true || r.meta?.abstract === 'true') continue;
112
+ const name = `${r.id}Data`;
113
+ lines.push(`/** ${r.category} ${r.id} · ${r.alias} */`, `export interface ${name} {`);
114
+ for (const [f, attr] of Object.entries(r.attributes)) {
115
+ if (f === 'id') continue;
116
+ const { t, opt } = ts(attr);
117
+ lines.push(` ${ident(f)}${opt ? '?' : ''}: ${t};`);
118
+ }
119
+ lines.push('}', '');
120
+ for (const key of [r.id, r.alias]) {
121
+ facade.push(` ${ident(key)}: (filter?: Filter) => TypedChain<${name}>;`);
122
+ }
123
+ }
124
+ lines.push(
125
+ 'export interface TypedChain<D> {',
126
+ ' execute(): Promise<Path[]>;',
127
+ ' rows(): Promise<TypedRow<D>[]>;',
128
+ ' first(): Promise<TypedRow<D> | null>;',
129
+ ' ids(): Promise<string[]>;',
130
+ ' count(): Promise<number>;',
131
+ ' versions(): Promise<TypedRow<D>[]>;',
132
+ ' set(data?: Partial<D> & { id?: string }): PromiseLike<TypedRow<D>[]> & Record<string, (t: string | { id: string }) => unknown>;',
133
+ ' delete(): Promise<TypedRow<D>[]>;',
134
+ ' anonymize(fields: (keyof D & string)[]): Promise<TypedRow<D>[]>;',
135
+ ' limit(n: number): TypedChain<D>;',
136
+ ' offset(n: number): TypedChain<D>;',
137
+ ' sort(field: string, dir?: \'asc\' | \'desc\' | boolean): TypedChain<D>;',
138
+ ' asOf(t: string | Date): TypedChain<D>;',
139
+ ' after(c: Cursor): TypedChain<D>;',
140
+ ' alias(name: string): TypedChain<D>;',
141
+ ' tags(v: string | string[] | object): TypedChain<D>;',
142
+ ' account(v: string | { id: string }): TypedChain<D>;',
143
+ ' owner(v: string | { id: string }): TypedChain<D>;',
144
+ ' [className: string]: unknown;',
145
+ '}',
146
+ '',
147
+ 'export interface TypedDb {',
148
+ ...facade,
149
+ '}',
150
+ '',
151
+ );
152
+ // END_BLOCK_EMIT_TYPES
153
+ await writeFile(out, lines.join('\n'), 'utf8');
154
+ console.log(`written ${out}: ${rows.length} classes`);