agnes-cli 0.0.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/README.md +140 -0
- package/agnes.config.example.ts +50 -0
- package/bin/agnes.ts +4 -0
- package/package.json +34 -0
- package/src/apply.ts +64 -0
- package/src/cli.ts +116 -0
- package/src/commands/generate.ts +74 -0
- package/src/commands/migrate.ts +146 -0
- package/src/commands/pull.ts +38 -0
- package/src/commands/push.ts +25 -0
- package/src/config.ts +73 -0
- package/src/db.ts +26 -0
- package/src/dialect.ts +67 -0
- package/src/diff.ts +131 -0
- package/src/generate.ts +137 -0
- package/src/index.ts +11 -0
- package/src/introspect.ts +242 -0
- package/src/ir.ts +147 -0
- package/src/normalize.ts +24 -0
- package/src/print.ts +74 -0
- package/src/prompt.ts +25 -0
package/src/ir.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Dialect-agnostic intermediate representation of a database schema.
|
|
2
|
+
// Both the TS schema DSL and live-DB introspection are normalized into
|
|
3
|
+
// DatabaseIR so push/pull/migrate can diff them structurally.
|
|
4
|
+
|
|
5
|
+
export type ColumnType = "int" | "bigint" | "text" | "bool" | "float" | "bytes" | "json";
|
|
6
|
+
|
|
7
|
+
export interface ColumnIR {
|
|
8
|
+
name: string;
|
|
9
|
+
type: ColumnType;
|
|
10
|
+
nullable: boolean;
|
|
11
|
+
primary: boolean;
|
|
12
|
+
/** default value as a literal; undefined = no default. */
|
|
13
|
+
default?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IndexIR {
|
|
17
|
+
name: string;
|
|
18
|
+
columns: string[];
|
|
19
|
+
unique: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ForeignKeyIR {
|
|
23
|
+
/** Deterministic name so diff is stable. */
|
|
24
|
+
name: string;
|
|
25
|
+
column: string;
|
|
26
|
+
refTable: string;
|
|
27
|
+
refColumn: string;
|
|
28
|
+
onUpdate: string;
|
|
29
|
+
onDelete: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TableIR {
|
|
33
|
+
name: string;
|
|
34
|
+
columns: ColumnIR[];
|
|
35
|
+
indexes: IndexIR[];
|
|
36
|
+
foreignKeys: ForeignKeyIR[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Keyed by physical table name. */
|
|
40
|
+
export type DatabaseIR = Record<string, TableIR>;
|
|
41
|
+
|
|
42
|
+
// ─── Structural view of the schema DSL ──────────────────────────────────────
|
|
43
|
+
// We treat the schema object duck-typed (via `_kind`) so the CLI never has to
|
|
44
|
+
// share a compiled build with agnes-library — only the shape matters.
|
|
45
|
+
|
|
46
|
+
interface DslColumn {
|
|
47
|
+
_kind: "column";
|
|
48
|
+
name: string;
|
|
49
|
+
type: ColumnType;
|
|
50
|
+
flags: {
|
|
51
|
+
primary?: boolean;
|
|
52
|
+
nullable?: boolean;
|
|
53
|
+
default?: unknown;
|
|
54
|
+
index?: { name: string; unique: boolean };
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface DslOneRelation {
|
|
59
|
+
_kind: "one";
|
|
60
|
+
target: string;
|
|
61
|
+
localKey: string;
|
|
62
|
+
targetKey: string;
|
|
63
|
+
onUpdate: string;
|
|
64
|
+
onDelete: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface DslManyRelation {
|
|
68
|
+
_kind: "many";
|
|
69
|
+
target: string;
|
|
70
|
+
foreignKey: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type DslField = DslColumn | DslOneRelation | DslManyRelation;
|
|
74
|
+
|
|
75
|
+
interface DslTableEntry {
|
|
76
|
+
def: Record<string, DslField>;
|
|
77
|
+
tableName: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type DslSchema = Record<string, DslTableEntry>;
|
|
81
|
+
|
|
82
|
+
function fkName(table: string, column: string): string {
|
|
83
|
+
return `fk_${table}_${column}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Resolve the physical column name of a DSL key inside a table def. */
|
|
87
|
+
function colName(def: Record<string, DslField>, key: string): string | undefined {
|
|
88
|
+
const f = def[key];
|
|
89
|
+
return f && f._kind === "column" ? f.name : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Convert the user's schema DSL into the canonical DatabaseIR. */
|
|
93
|
+
export function schemaToIR(schema: DslSchema): DatabaseIR {
|
|
94
|
+
// Map DSL table key → physical name for relation resolution.
|
|
95
|
+
const physicalName = new Map<string, string>();
|
|
96
|
+
for (const key in schema) physicalName.set(key, schema[key]!.tableName);
|
|
97
|
+
|
|
98
|
+
const ir: DatabaseIR = {};
|
|
99
|
+
|
|
100
|
+
for (const key in schema) {
|
|
101
|
+
const entry = schema[key]!;
|
|
102
|
+
const def = entry.def;
|
|
103
|
+
const columns: ColumnIR[] = [];
|
|
104
|
+
const indexes: IndexIR[] = [];
|
|
105
|
+
const foreignKeys: ForeignKeyIR[] = [];
|
|
106
|
+
|
|
107
|
+
for (const fieldKey in def) {
|
|
108
|
+
const field = def[fieldKey]!;
|
|
109
|
+
if (field._kind === "column") {
|
|
110
|
+
columns.push({
|
|
111
|
+
name: field.name,
|
|
112
|
+
type: field.type,
|
|
113
|
+
nullable: field.flags.nullable ?? false,
|
|
114
|
+
primary: field.flags.primary ?? false,
|
|
115
|
+
default: field.flags.default,
|
|
116
|
+
});
|
|
117
|
+
if (field.flags.index) {
|
|
118
|
+
indexes.push({
|
|
119
|
+
name: field.flags.index.name,
|
|
120
|
+
columns: [field.name],
|
|
121
|
+
unique: field.flags.index.unique,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
} else if (field._kind === "one") {
|
|
125
|
+
const localCol = colName(def, field.localKey);
|
|
126
|
+
const targetDef = schema[field.target]?.def;
|
|
127
|
+
const refCol = targetDef ? colName(targetDef, field.targetKey) : undefined;
|
|
128
|
+
const refTable = physicalName.get(field.target);
|
|
129
|
+
if (localCol && refCol && refTable) {
|
|
130
|
+
foreignKeys.push({
|
|
131
|
+
name: fkName(entry.tableName, localCol),
|
|
132
|
+
column: localCol,
|
|
133
|
+
refTable,
|
|
134
|
+
refColumn: refCol,
|
|
135
|
+
onUpdate: field.onUpdate,
|
|
136
|
+
onDelete: field.onDelete,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// `many` relations have no physical footprint — the FK lives on the target side.
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ir[entry.tableName] = { name: entry.tableName, columns, indexes, foreignKeys };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return ir;
|
|
147
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { logicalType, physicalType, type Dialect } from "./dialect";
|
|
2
|
+
import type { DatabaseIR } from "./ir";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Project an IR into the lossy type space a given dialect actually stores, so a
|
|
6
|
+
* schema round-trips cleanly against introspection. Without this, e.g. SQLite
|
|
7
|
+
* (which stores `bool`/`bigint` as `integer`) would report a spurious diff on
|
|
8
|
+
* every `push`. Also enforces PK ⇒ NOT NULL on both sides.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeIR(ir: DatabaseIR, dialect: Dialect): DatabaseIR {
|
|
11
|
+
const out: DatabaseIR = {};
|
|
12
|
+
for (const name in ir) {
|
|
13
|
+
const t = ir[name]!;
|
|
14
|
+
out[name] = {
|
|
15
|
+
...t,
|
|
16
|
+
columns: t.columns.map((c) => ({
|
|
17
|
+
...c,
|
|
18
|
+
type: logicalType(physicalType(dialect, c.type)),
|
|
19
|
+
nullable: c.primary ? false : c.nullable,
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
package/src/print.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ColumnType, DatabaseIR, TableIR } from "./ir";
|
|
2
|
+
|
|
3
|
+
const HELPER: Record<ColumnType, string> = {
|
|
4
|
+
int: "int",
|
|
5
|
+
bigint: "bigint",
|
|
6
|
+
text: "text",
|
|
7
|
+
bool: "bool",
|
|
8
|
+
float: "float",
|
|
9
|
+
bytes: "bytes",
|
|
10
|
+
json: "json",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ON_ACTION: Record<string, string> = {
|
|
14
|
+
"NO ACTION": "OnAction.None",
|
|
15
|
+
RESTRICT: "OnAction.Restrict",
|
|
16
|
+
CASCADE: "OnAction.Cascade",
|
|
17
|
+
"SET NULL": "OnAction.SetNull",
|
|
18
|
+
"SET DEFAULT": "OnAction.SetDefault",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function onAction(rule: string): string {
|
|
22
|
+
return ON_ACTION[rule.toUpperCase()] ?? "OnAction.None";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function literal(v: unknown): string {
|
|
26
|
+
if (typeof v === "boolean") return String(v);
|
|
27
|
+
if (typeof v === "number" || typeof v === "bigint") return String(v);
|
|
28
|
+
return JSON.stringify(String(v));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tableSource(t: TableIR): string {
|
|
32
|
+
const fkByColumn = new Map(t.foreignKeys.map((fk) => [fk.column, fk]));
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
|
|
35
|
+
for (const col of t.columns) {
|
|
36
|
+
let expr = `${HELPER[col.type]}(${JSON.stringify(col.name)})`;
|
|
37
|
+
if (col.primary) expr += ".primary()";
|
|
38
|
+
else if (col.nullable) expr += ".nullable()";
|
|
39
|
+
if (col.default !== undefined) expr += `.default(${literal(col.default)})`;
|
|
40
|
+
for (const idx of t.indexes) {
|
|
41
|
+
if (idx.columns.length === 1 && idx.columns[0] === col.name) {
|
|
42
|
+
expr += idx.unique
|
|
43
|
+
? `.uniqueIndex(${JSON.stringify(idx.name)})`
|
|
44
|
+
: `.index(${JSON.stringify(idx.name)})`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
lines.push(` ${col.name}: ${expr},`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const fk of fkByColumn.values()) {
|
|
51
|
+
// Relation TS key: derive from referenced table (best effort).
|
|
52
|
+
const relKey = fk.refTable;
|
|
53
|
+
lines.push(
|
|
54
|
+
` ${relKey}: one(${JSON.stringify(fk.refTable)}, ${JSON.stringify(fk.column)}, ` +
|
|
55
|
+
`${JSON.stringify(fk.refColumn)}, ${onAction(fk.onUpdate)}, ${onAction(fk.onDelete)}),`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return ` ${t.name}: table({\n${lines.join("\n")}\n }, ${JSON.stringify(t.name)}),`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Render a full DatabaseIR into schema.ts source code. */
|
|
63
|
+
export function printSchema(ir: DatabaseIR): string {
|
|
64
|
+
const tables = Object.values(ir)
|
|
65
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
66
|
+
.map(tableSource)
|
|
67
|
+
.join("\n");
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
`// Generated by \`agnes pull\`. Edit and re-run \`agnes push\` to apply changes.\n` +
|
|
71
|
+
`import { table, int, bigint, text, bool, float, bytes, json, one, OnAction } from "agnes-library";\n\n` +
|
|
72
|
+
`export const schema = {\n${tables}\n};\n`
|
|
73
|
+
);
|
|
74
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Tiny ANSI + interactive-confirm helpers. No external deps.
|
|
2
|
+
|
|
3
|
+
const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
|
|
4
|
+
|
|
5
|
+
function paint(code: string, s: string): string {
|
|
6
|
+
return useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const c = {
|
|
10
|
+
green: (s: string) => paint("32", s),
|
|
11
|
+
red: (s: string) => paint("31", s),
|
|
12
|
+
yellow: (s: string) => paint("33", s),
|
|
13
|
+
cyan: (s: string) => paint("36", s),
|
|
14
|
+
dim: (s: string) => paint("2", s),
|
|
15
|
+
bold: (s: string) => paint("1", s),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Ask a yes/no question. Returns true only on explicit y/yes. */
|
|
19
|
+
export async function confirm(question: string): Promise<boolean> {
|
|
20
|
+
process.stdout.write(`${question} ${c.dim("[y/N]")} `);
|
|
21
|
+
for await (const line of console) {
|
|
22
|
+
return /^y(es)?$/i.test(line.trim());
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|