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/CHANGELOG.md +53 -0
- package/README.md +384 -5
- package/dist/chain.d.ts +21 -0
- package/dist/chain.js +10 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +12 -4
- package/dist/sql.d.ts +8 -0
- package/dist/sql.js +19 -2
- package/dist/tables.d.ts +24 -0
- package/dist/tables.js +56 -1
- package/dist/types.d.ts +4 -2
- package/dist/write.d.ts +9 -0
- package/dist/write.js +39 -2
- package/package.json +2 -1
- package/scripts/gen-types.mjs +154 -0
- package/scripts/schema-sync.mjs +185 -0
- package/sql/ddl.sql +114 -2
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: правила и категории субъекта
|
|
64
|
-
|
|
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: последний шаг включает и удалённые
|
|
521
|
-
|
|
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
|
-
|
|
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 —
|
|
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'
|
|
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.
|
|
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`);
|