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/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# agnes-cli
|
|
2
|
+
|
|
3
|
+
Schema toolkit for **agnes-rs**. Reads your TypeScript `schema.ts` DSL and keeps
|
|
4
|
+
your database in sync — `push`, `pull`, and `migrate`. Runs on [Bun](https://bun.sh)
|
|
5
|
+
and talks to the database through the same Rust bridge (`agnes-library`), so it
|
|
6
|
+
supports **PostgreSQL, MySQL and SQLite**.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Inside this workspace it's already linked. From a consumer project:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
bun add agnes-cli agnes-library
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Configure
|
|
17
|
+
|
|
18
|
+
Create `agnes.config.ts` in your project root (see `agnes.config.example.ts`):
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { defineConfig } from "agnes-cli";
|
|
22
|
+
import { schema } from "./schema"; // your `export const schema = { ... }`
|
|
23
|
+
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
driver: "postgres",
|
|
26
|
+
url: process.env.DATABASE_URL!,
|
|
27
|
+
schema,
|
|
28
|
+
out: "./schema.ts", // where `pull` writes
|
|
29
|
+
migrationsDir: "./migrations",
|
|
30
|
+
|
|
31
|
+
// `generate` output — extension picks the language (.ts or .js)
|
|
32
|
+
output: "src/services/db.ts",
|
|
33
|
+
schemaPath: "./schema.ts", // module the generated client imports `schema` from
|
|
34
|
+
cache: { enabled: true, walPath: ".agnes/cache.wal" },
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bun agnes push # make the DB match schema.ts (create/alter/DROP)
|
|
42
|
+
bun agnes pull # regenerate schema.ts from the live DB
|
|
43
|
+
bun agnes migrate # write a versioned .sql from drift, then apply pending
|
|
44
|
+
bun agnes generate # emit a pre-wired AgnesClient module (db.ts / db.js)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
(From the repo root: `bun run agnes push`.)
|
|
48
|
+
|
|
49
|
+
### `push` — schema.ts ➜ database
|
|
50
|
+
|
|
51
|
+
Diffs your schema against the live database and applies the difference. This is a
|
|
52
|
+
**full sync**: tables and columns that exist in the DB but not in your schema are
|
|
53
|
+
**dropped**. Destructive operations (`DROP TABLE` / `DROP COLUMN`) require an
|
|
54
|
+
interactive confirmation — pass `-y` / `--yes` to skip it in CI.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bun agnes push --dry-run # print the plan + SQL, change nothing
|
|
58
|
+
bun agnes push --yes # apply without prompting
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### `pull` — database ➜ schema.ts
|
|
62
|
+
|
|
63
|
+
Introspects the live database and regenerates `schema.ts` to mirror it exactly
|
|
64
|
+
(tables/columns/indexes/foreign keys no longer present are removed from the file).
|
|
65
|
+
Prompts before overwriting an existing file unless `--yes`.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
bun agnes pull --out src/schema.ts
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `migrate` — versioned SQL files
|
|
72
|
+
|
|
73
|
+
1. Diffs schema vs DB and, if there's drift, writes a timestamped file to
|
|
74
|
+
`migrationsDir` (e.g. `20260701123000_auto.sql`).
|
|
75
|
+
2. Applies every pending file (those not yet recorded in the `_agnes_migrations`
|
|
76
|
+
tracking table) in order, recording each as it succeeds.
|
|
77
|
+
|
|
78
|
+
Destructive migrations prompt for confirmation unless `--yes`.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bun agnes migrate -n add_users # name the generated migration
|
|
82
|
+
bun agnes migrate --dry-run # show what would be generated/applied
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### `generate` — pre-wired client module
|
|
86
|
+
|
|
87
|
+
Writes a ready-to-import `AgnesClient` module built from your config — driver,
|
|
88
|
+
url and cache all baked in. Point `output` anywhere (nested dirs are created):
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// src/services/db.ts ← generated
|
|
92
|
+
import { AgnesClient } from "agnes-library";
|
|
93
|
+
import { schema } from "../../schema";
|
|
94
|
+
|
|
95
|
+
export const db = await AgnesClient.create(
|
|
96
|
+
{
|
|
97
|
+
driver: "sqlite",
|
|
98
|
+
url: "sqlite:./demo.db",
|
|
99
|
+
cache: { enabled: true, walPath: ".agnes/cache.wal" },
|
|
100
|
+
},
|
|
101
|
+
schema,
|
|
102
|
+
);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `output` (config) or `--output` picks the path. `.ts` → TypeScript, `.js` → JavaScript.
|
|
106
|
+
- The `import { schema }` path is made relative to the output file automatically.
|
|
107
|
+
- The `cache` block is emitted only if you set `cache` in the config.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
bun agnes generate # uses config.output
|
|
111
|
+
bun agnes generate --output src/db.js # override; JS output
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Options
|
|
115
|
+
|
|
116
|
+
| Flag | Applies to | Meaning |
|
|
117
|
+
|------|-----------|---------|
|
|
118
|
+
| `-c, --config <path>` | all | Config file (default `agnes.config.ts`) |
|
|
119
|
+
| `-o, --out <path>` | pull | Output schema file |
|
|
120
|
+
| `--output <path>` | generate | Output client module (.ts/.js) |
|
|
121
|
+
| `--dir <path>` | migrate | Migrations directory |
|
|
122
|
+
| `-n, --name <name>` | migrate | Name for the generated migration |
|
|
123
|
+
| `-y, --yes` | push, pull, migrate | Skip destructive confirmations |
|
|
124
|
+
| `--dry-run` | push, migrate | Show the plan/SQL without executing |
|
|
125
|
+
|
|
126
|
+
## Type mapping
|
|
127
|
+
|
|
128
|
+
| DSL | PostgreSQL | MySQL | SQLite |
|
|
129
|
+
|-----|-----------|-------|--------|
|
|
130
|
+
| `int` | integer | int | integer |
|
|
131
|
+
| `bigint` | bigint | bigint | integer |
|
|
132
|
+
| `text` | text | text | text |
|
|
133
|
+
| `bool` | boolean | tinyint(1) | integer |
|
|
134
|
+
| `float` | double precision | double | real |
|
|
135
|
+
| `bytes` | bytea | blob | blob |
|
|
136
|
+
| `json` | jsonb | json | text |
|
|
137
|
+
|
|
138
|
+
> **SQLite note:** SQLite can't `ALTER` column types or add/drop foreign keys after
|
|
139
|
+
> table creation. Those operations are emitted as `-- SKIPPED` comments; recreate
|
|
140
|
+
> the table manually if you need them.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { defineConfig } from "agnes-cli";
|
|
2
|
+
import {
|
|
3
|
+
table,
|
|
4
|
+
int,
|
|
5
|
+
text,
|
|
6
|
+
bool,
|
|
7
|
+
many,
|
|
8
|
+
one,
|
|
9
|
+
OnAction,
|
|
10
|
+
} from "agnes-library";
|
|
11
|
+
|
|
12
|
+
// Your schema. Can live here or be imported from ./schema.ts.
|
|
13
|
+
export const schema = {
|
|
14
|
+
user: table(
|
|
15
|
+
{
|
|
16
|
+
id: int("id").primary(),
|
|
17
|
+
name: text("name").index("name_idx"),
|
|
18
|
+
email: text("email").uniqueIndex("email_idx"),
|
|
19
|
+
age: int("age"),
|
|
20
|
+
active: bool("active").default(true),
|
|
21
|
+
posts: many("post", "userId"),
|
|
22
|
+
},
|
|
23
|
+
"users",
|
|
24
|
+
),
|
|
25
|
+
post: table(
|
|
26
|
+
{
|
|
27
|
+
id: int("id").primary(),
|
|
28
|
+
userId: int("user_id"),
|
|
29
|
+
content: text("content"),
|
|
30
|
+
user: one("user", "userId", "id", OnAction.None, OnAction.Cascade),
|
|
31
|
+
},
|
|
32
|
+
"posts",
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default defineConfig({
|
|
37
|
+
driver: "postgres",
|
|
38
|
+
url: process.env.DATABASE_URL ?? "postgres://user:pass@localhost/db",
|
|
39
|
+
schema,
|
|
40
|
+
out: "./schema.ts",
|
|
41
|
+
migrationsDir: "./migrations",
|
|
42
|
+
|
|
43
|
+
// `agnes generate` writes the ready-to-import client here.
|
|
44
|
+
// Extension picks the language: db.ts → TypeScript, db.js → JavaScript.
|
|
45
|
+
output: "src/services/db.ts",
|
|
46
|
+
// Module the generated client imports `schema` from (default: `out`).
|
|
47
|
+
schemaPath: "./agnes.config.ts",
|
|
48
|
+
// Cache baked into the generated client.
|
|
49
|
+
cache: { enabled: true, walPath: ".agnes/cache.wal" },
|
|
50
|
+
});
|
package/bin/agnes.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agnes-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Schema migration CLI for agnes-rs — push, pull and migrate from schema.ts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agnes": "./bin/agnes.ts"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.ts",
|
|
10
|
+
"module": "src/index.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"bin",
|
|
17
|
+
"agnes.config.example.ts",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"agnes": "bun bin/agnes.ts",
|
|
22
|
+
"typecheck": "bunx tsc --noEmit",
|
|
23
|
+
"test": "bun test"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"agnes-library": "0.0.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/bun": "latest"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"typescript": "^5"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/apply.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Dialect } from "./dialect";
|
|
2
|
+
import type { CliDb } from "./db";
|
|
3
|
+
import { isDestructive, type Operation } from "./diff";
|
|
4
|
+
import { describeOperation, renderPlan } from "./generate";
|
|
5
|
+
import { c, confirm } from "./prompt";
|
|
6
|
+
|
|
7
|
+
export interface ApplyOpts {
|
|
8
|
+
yes?: boolean;
|
|
9
|
+
dryRun?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Print the diff plan, confirm destructive operations, then execute the SQL.
|
|
14
|
+
* Returns the SQL statements that were (or would be) run.
|
|
15
|
+
*/
|
|
16
|
+
export async function applyPlan(
|
|
17
|
+
db: CliDb,
|
|
18
|
+
dialect: Dialect,
|
|
19
|
+
ops: Operation[],
|
|
20
|
+
opts: ApplyOpts = {},
|
|
21
|
+
): Promise<string[]> {
|
|
22
|
+
if (ops.length === 0) {
|
|
23
|
+
console.log(c.green("✓ Database is up to date. Nothing to apply."));
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log(c.bold("\nPlan:"));
|
|
28
|
+
for (const op of ops) {
|
|
29
|
+
const line = describeOperation(op);
|
|
30
|
+
console.log(" " + (isDestructive(op) ? c.red(line) : c.green(line)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const statements = renderPlan(dialect, ops);
|
|
34
|
+
|
|
35
|
+
if (opts.dryRun) {
|
|
36
|
+
console.log(c.bold("\nSQL (dry run):"));
|
|
37
|
+
for (const s of statements) console.log(c.dim(s));
|
|
38
|
+
return statements;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const destructive = ops.filter(isDestructive);
|
|
42
|
+
if (destructive.length > 0 && !opts.yes) {
|
|
43
|
+
console.log(
|
|
44
|
+
c.yellow(`\n⚠ ${destructive.length} destructive operation(s) will delete data or objects.`),
|
|
45
|
+
);
|
|
46
|
+
const ok = await confirm("Apply these changes?");
|
|
47
|
+
if (!ok) {
|
|
48
|
+
console.log(c.dim("Aborted. No changes made."));
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(c.bold("\nApplying..."));
|
|
54
|
+
for (const sql of statements) {
|
|
55
|
+
if (sql.startsWith("--")) {
|
|
56
|
+
console.log(c.yellow(" " + sql));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
await db.mutate(sql);
|
|
60
|
+
console.log(c.dim(" ✓ " + sql.split("\n")[0]));
|
|
61
|
+
}
|
|
62
|
+
console.log(c.green(`\n✓ Applied ${statements.filter((s) => !s.startsWith("--")).length} statement(s).`));
|
|
63
|
+
return statements;
|
|
64
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { loadConfig } from "./config";
|
|
2
|
+
import { push } from "./commands/push";
|
|
3
|
+
import { pull } from "./commands/pull";
|
|
4
|
+
import { migrate } from "./commands/migrate";
|
|
5
|
+
import { generate } from "./commands/generate";
|
|
6
|
+
import { c } from "./prompt";
|
|
7
|
+
|
|
8
|
+
interface Flags {
|
|
9
|
+
_: string[];
|
|
10
|
+
config?: string;
|
|
11
|
+
out?: string;
|
|
12
|
+
output?: string;
|
|
13
|
+
dir?: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
yes: boolean;
|
|
16
|
+
dryRun: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv: string[]): Flags {
|
|
20
|
+
const flags: Flags = { _: [], yes: false, dryRun: false };
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const a = argv[i]!;
|
|
23
|
+
switch (a) {
|
|
24
|
+
case "-y":
|
|
25
|
+
case "--yes":
|
|
26
|
+
flags.yes = true;
|
|
27
|
+
break;
|
|
28
|
+
case "--dry-run":
|
|
29
|
+
flags.dryRun = true;
|
|
30
|
+
break;
|
|
31
|
+
case "-c":
|
|
32
|
+
case "--config":
|
|
33
|
+
flags.config = argv[++i];
|
|
34
|
+
break;
|
|
35
|
+
case "-o":
|
|
36
|
+
case "--out":
|
|
37
|
+
flags.out = argv[++i];
|
|
38
|
+
break;
|
|
39
|
+
case "--output":
|
|
40
|
+
flags.output = argv[++i];
|
|
41
|
+
break;
|
|
42
|
+
case "--dir":
|
|
43
|
+
flags.dir = argv[++i];
|
|
44
|
+
break;
|
|
45
|
+
case "-n":
|
|
46
|
+
case "--name":
|
|
47
|
+
flags.name = argv[++i];
|
|
48
|
+
break;
|
|
49
|
+
default:
|
|
50
|
+
flags._.push(a);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return flags;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const HELP = `${c.bold("agnes")} — schema toolkit for agnes-rs
|
|
57
|
+
|
|
58
|
+
${c.bold("Usage:")}
|
|
59
|
+
agnes <command> [options]
|
|
60
|
+
|
|
61
|
+
${c.bold("Commands:")}
|
|
62
|
+
push Sync the database to match schema.ts (create/alter/drop)
|
|
63
|
+
pull Introspect the database and (re)generate schema.ts
|
|
64
|
+
migrate Generate a versioned SQL migration from drift, then apply pending
|
|
65
|
+
generate Emit a pre-wired AgnesClient module (db.ts/db.js) from the config
|
|
66
|
+
|
|
67
|
+
${c.bold("Options:")}
|
|
68
|
+
-c, --config <path> Config file (default: agnes.config.ts)
|
|
69
|
+
-o, --out <path> [pull] Output schema file (default: config.out or schema.ts)
|
|
70
|
+
--output <path> [generate] Output client module (default: config.output)
|
|
71
|
+
--dir <path> [migrate] Migrations directory (default: migrations)
|
|
72
|
+
-n, --name <name> [migrate] Name for the generated migration
|
|
73
|
+
-y, --yes Skip confirmation for destructive operations
|
|
74
|
+
--dry-run Show the plan/SQL without executing
|
|
75
|
+
-h, --help Show this help
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
export async function run(argv: string[]): Promise<void> {
|
|
79
|
+
const flags = parseArgs(argv);
|
|
80
|
+
const command = flags._[0];
|
|
81
|
+
|
|
82
|
+
if (!command || flags._.includes("help") || argv.includes("-h") || argv.includes("--help")) {
|
|
83
|
+
console.log(HELP);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const config = await loadConfig(flags.config);
|
|
89
|
+
switch (command) {
|
|
90
|
+
case "push":
|
|
91
|
+
await push(config, { yes: flags.yes, dryRun: flags.dryRun });
|
|
92
|
+
break;
|
|
93
|
+
case "pull":
|
|
94
|
+
await pull(config, { out: flags.out, yes: flags.yes });
|
|
95
|
+
break;
|
|
96
|
+
case "migrate":
|
|
97
|
+
await migrate(config, {
|
|
98
|
+
yes: flags.yes,
|
|
99
|
+
dryRun: flags.dryRun,
|
|
100
|
+
dir: flags.dir,
|
|
101
|
+
name: flags.name,
|
|
102
|
+
});
|
|
103
|
+
break;
|
|
104
|
+
case "generate":
|
|
105
|
+
await generate(config, { output: flags.output, yes: flags.yes });
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
console.error(c.red(`Unknown command: ${command}`));
|
|
109
|
+
console.log(HELP);
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(c.red(`\n✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { dirname, relative, resolve } from "node:path";
|
|
2
|
+
import type { AgnesConfig } from "../config";
|
|
3
|
+
import { c } from "../prompt";
|
|
4
|
+
|
|
5
|
+
export interface GenerateArgs {
|
|
6
|
+
output?: string;
|
|
7
|
+
yes?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Turn a filesystem path into a relative ESM import specifier (no extension). */
|
|
11
|
+
function toImportSpecifier(fromFile: string, toModule: string): string {
|
|
12
|
+
let rel = relative(dirname(fromFile), toModule).replace(/\\/g, "/");
|
|
13
|
+
rel = rel.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, "");
|
|
14
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
15
|
+
return rel;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderConfigObject(config: AgnesConfig): string {
|
|
19
|
+
const lines: string[] = [
|
|
20
|
+
` driver: ${JSON.stringify(config.driver)},`,
|
|
21
|
+
` url: ${JSON.stringify(config.url)},`,
|
|
22
|
+
];
|
|
23
|
+
if (config.maxConnections !== undefined)
|
|
24
|
+
lines.push(` maxConnections: ${config.maxConnections},`);
|
|
25
|
+
if (config.cache) {
|
|
26
|
+
const parts = [`enabled: ${config.cache.enabled}`];
|
|
27
|
+
if (config.cache.walPath !== undefined) parts.push(`walPath: ${JSON.stringify(config.cache.walPath)}`);
|
|
28
|
+
if (config.cache.compactionThreshold !== undefined)
|
|
29
|
+
parts.push(`compactionThreshold: ${config.cache.compactionThreshold}`);
|
|
30
|
+
lines.push(` cache: { ${parts.join(", ")} },`);
|
|
31
|
+
}
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderClient(config: AgnesConfig, schemaImport: string): string {
|
|
36
|
+
return (
|
|
37
|
+
`// Generated by \`agnes generate\`. Do not edit — re-run to regenerate.\n` +
|
|
38
|
+
`import { AgnesClient } from "agnes-library";\n` +
|
|
39
|
+
`import { schema } from ${JSON.stringify(schemaImport)};\n\n` +
|
|
40
|
+
`export const db = await AgnesClient.create(\n` +
|
|
41
|
+
` {\n${renderConfigObject(config)}\n },\n` +
|
|
42
|
+
` schema,\n` +
|
|
43
|
+
`);\n`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Emit a ready-to-import AgnesClient module wired to the config. */
|
|
48
|
+
export async function generate(config: AgnesConfig, args: GenerateArgs): Promise<void> {
|
|
49
|
+
const outRel = args.output ?? config.output;
|
|
50
|
+
if (!outRel) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`No output path. Set \`output\` in agnes.config.ts (e.g. "src/services/db.ts") or pass --output <path>.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const outPath = resolve(process.cwd(), outRel);
|
|
57
|
+
const schemaFsPath = resolve(process.cwd(), config.schemaPath ?? config.out ?? "schema.ts");
|
|
58
|
+
const schemaImport = toImportSpecifier(outPath, schemaFsPath);
|
|
59
|
+
|
|
60
|
+
const source = renderClient(config, schemaImport);
|
|
61
|
+
|
|
62
|
+
if (await Bun.file(outPath).exists() && !args.yes) {
|
|
63
|
+
console.log(c.yellow(`⚠ ${outPath} already exists and will be overwritten.`));
|
|
64
|
+
const { confirm } = await import("../prompt");
|
|
65
|
+
if (!(await confirm("Overwrite it?"))) {
|
|
66
|
+
console.log(c.dim("Aborted. File left unchanged."));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await Bun.write(outPath, source);
|
|
72
|
+
console.log(c.green(`✓ Generated client → ${outPath}`));
|
|
73
|
+
console.log(c.dim(` imports schema from ${schemaImport}`));
|
|
74
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import type { AgnesConfig } from "../config";
|
|
4
|
+
import type { Dialect } from "../dialect";
|
|
5
|
+
import { openDb, type CliDb } from "../db";
|
|
6
|
+
import { diffSchemas } from "../diff";
|
|
7
|
+
import { renderPlan } from "../generate";
|
|
8
|
+
import { schemaToIR } from "../ir";
|
|
9
|
+
import { normalizeIR } from "../normalize";
|
|
10
|
+
import { introspect } from "../introspect";
|
|
11
|
+
import { c, confirm } from "../prompt";
|
|
12
|
+
|
|
13
|
+
export interface MigrateArgs {
|
|
14
|
+
yes?: boolean;
|
|
15
|
+
dryRun?: boolean;
|
|
16
|
+
dir?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DESTRUCTIVE = /\bDROP\s+(TABLE|COLUMN|CONSTRAINT|FOREIGN\s+KEY)\b/i;
|
|
21
|
+
|
|
22
|
+
function trackingTableDdl(dialect: Dialect): string {
|
|
23
|
+
const nameType = dialect === "mysql" ? "VARCHAR(255)" : "TEXT";
|
|
24
|
+
return (
|
|
25
|
+
`CREATE TABLE IF NOT EXISTS _agnes_migrations (` +
|
|
26
|
+
`name ${nameType} PRIMARY KEY, ` +
|
|
27
|
+
`applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function timestamp(): string {
|
|
32
|
+
const d = new Date();
|
|
33
|
+
const p = (n: number, w = 2) => String(n).padStart(w, "0");
|
|
34
|
+
return (
|
|
35
|
+
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
|
|
36
|
+
`${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function splitStatements(sql: string): string[] {
|
|
41
|
+
return sql
|
|
42
|
+
.split(";")
|
|
43
|
+
// Strip full-line SQL comments so a leading `-- header` doesn't swallow the
|
|
44
|
+
// statement that follows it on the next line.
|
|
45
|
+
.map((chunk) =>
|
|
46
|
+
chunk
|
|
47
|
+
.split("\n")
|
|
48
|
+
.filter((line) => !line.trim().startsWith("--"))
|
|
49
|
+
.join("\n")
|
|
50
|
+
.trim(),
|
|
51
|
+
)
|
|
52
|
+
.filter((s) => s.length > 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Generate a new migration from schema drift, then apply all pending files. */
|
|
56
|
+
export async function migrate(config: AgnesConfig, args: MigrateArgs): Promise<void> {
|
|
57
|
+
console.log(c.cyan(`agnes migrate → ${config.driver}`));
|
|
58
|
+
const db = await openDb(config);
|
|
59
|
+
const dir = resolve(process.cwd(), args.dir ?? config.migrationsDir ?? "migrations");
|
|
60
|
+
|
|
61
|
+
await db.mutate(trackingTableDdl(config.driver));
|
|
62
|
+
|
|
63
|
+
// 1. Detect drift and write a new migration file.
|
|
64
|
+
const desired = normalizeIR(schemaToIR(config.schema), config.driver);
|
|
65
|
+
const current = normalizeIR(await introspect(db, config.driver), config.driver);
|
|
66
|
+
const ops = diffSchemas(desired, current);
|
|
67
|
+
|
|
68
|
+
if (ops.length > 0) {
|
|
69
|
+
const statements = renderPlan(config.driver, ops);
|
|
70
|
+
const fileName = `${timestamp()}_${args.name ?? "auto"}.sql`;
|
|
71
|
+
const filePath = resolve(dir, fileName);
|
|
72
|
+
const body = statements.map((s) => (s.startsWith("--") ? s : `${s}`)).join("\n\n") + "\n";
|
|
73
|
+
|
|
74
|
+
if (args.dryRun) {
|
|
75
|
+
console.log(c.bold(`\nWould create ${fileName}:`));
|
|
76
|
+
console.log(c.dim(body));
|
|
77
|
+
} else {
|
|
78
|
+
await Bun.write(filePath, `-- Migration ${fileName}\n-- Generated by \`agnes migrate\`\n\n${body}`);
|
|
79
|
+
console.log(c.green(`✓ Created migration ${fileName} (${statements.length} statement(s))`));
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
console.log(c.dim("No schema drift — no new migration generated."));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 2. Apply pending migration files.
|
|
86
|
+
await applyPending(db, config.driver, dir, args);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function applyPending(
|
|
90
|
+
db: CliDb,
|
|
91
|
+
dialect: Dialect,
|
|
92
|
+
dir: string,
|
|
93
|
+
args: MigrateArgs,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
let files: string[];
|
|
96
|
+
try {
|
|
97
|
+
files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
|
|
98
|
+
} catch {
|
|
99
|
+
files = [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const appliedRows = await db.query<{ name: string }>(`SELECT name FROM _agnes_migrations`);
|
|
103
|
+
const applied = new Set(appliedRows.map((r) => String(r.name)));
|
|
104
|
+
const pending = files.filter((f) => !applied.has(f));
|
|
105
|
+
|
|
106
|
+
if (pending.length === 0) {
|
|
107
|
+
console.log(c.green("\n✓ No pending migrations."));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log(c.bold(`\nPending migrations (${pending.length}):`));
|
|
112
|
+
for (const f of pending) console.log(" " + c.green(f));
|
|
113
|
+
|
|
114
|
+
if (args.dryRun) {
|
|
115
|
+
console.log(c.dim("\nDry run — not applied."));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Confirm if any pending file contains destructive statements.
|
|
120
|
+
const bodies = new Map<string, string>();
|
|
121
|
+
let anyDestructive = false;
|
|
122
|
+
for (const f of pending) {
|
|
123
|
+
const text = await Bun.file(resolve(dir, f)).text();
|
|
124
|
+
bodies.set(f, text);
|
|
125
|
+
if (DESTRUCTIVE.test(text)) anyDestructive = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (anyDestructive && !args.yes) {
|
|
129
|
+
console.log(c.yellow("\n⚠ Some pending migrations contain destructive statements (DROP)."));
|
|
130
|
+
const ok = await confirm("Apply all pending migrations?");
|
|
131
|
+
if (!ok) {
|
|
132
|
+
console.log(c.dim("Aborted. No migrations applied."));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const f of pending) {
|
|
138
|
+
console.log(c.bold(`\nApplying ${f}...`));
|
|
139
|
+
for (const stmt of splitStatements(bodies.get(f)!)) {
|
|
140
|
+
await db.mutate(stmt);
|
|
141
|
+
console.log(c.dim(" ✓ " + stmt.split("\n")[0]));
|
|
142
|
+
}
|
|
143
|
+
await db.mutate(`INSERT INTO _agnes_migrations (name) VALUES (${dialect === "postgres" ? "$1" : "?"})`, [f]);
|
|
144
|
+
}
|
|
145
|
+
console.log(c.green(`\n✓ Applied ${pending.length} migration(s).`));
|
|
146
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import type { AgnesConfig } from "../config";
|
|
3
|
+
import { openDb } from "../db";
|
|
4
|
+
import { introspect } from "../introspect";
|
|
5
|
+
import { printSchema } from "../print";
|
|
6
|
+
import { c, confirm } from "../prompt";
|
|
7
|
+
|
|
8
|
+
export interface PullArgs {
|
|
9
|
+
out?: string;
|
|
10
|
+
yes?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Introspect the database and (re)generate schema.ts to mirror it. */
|
|
14
|
+
export async function pull(config: AgnesConfig, args: PullArgs): Promise<void> {
|
|
15
|
+
console.log(c.cyan(`agnes pull ← ${config.driver}`));
|
|
16
|
+
const db = await openDb(config);
|
|
17
|
+
|
|
18
|
+
const current = await introspect(db, config.driver);
|
|
19
|
+
const tableCount = Object.keys(current).length;
|
|
20
|
+
const source = printSchema(current);
|
|
21
|
+
|
|
22
|
+
const outPath = resolve(process.cwd(), args.out ?? config.out ?? "schema.ts");
|
|
23
|
+
const exists = await Bun.file(outPath).exists();
|
|
24
|
+
|
|
25
|
+
console.log(c.green(`\n✓ Introspected ${tableCount} table(s).`));
|
|
26
|
+
|
|
27
|
+
if (exists && !args.yes) {
|
|
28
|
+
console.log(c.yellow(`\n⚠ ${outPath} already exists and will be overwritten.`));
|
|
29
|
+
const ok = await confirm("Overwrite it?");
|
|
30
|
+
if (!ok) {
|
|
31
|
+
console.log(c.dim("Aborted. File left unchanged."));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await Bun.write(outPath, source);
|
|
37
|
+
console.log(c.green(`✓ Wrote ${outPath}`));
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { applyPlan } from "../apply";
|
|
2
|
+
import type { AgnesConfig } from "../config";
|
|
3
|
+
import { openDb } from "../db";
|
|
4
|
+
import { diffSchemas } from "../diff";
|
|
5
|
+
import { schemaToIR } from "../ir";
|
|
6
|
+
import { normalizeIR } from "../normalize";
|
|
7
|
+
import { introspect } from "../introspect";
|
|
8
|
+
import { c } from "../prompt";
|
|
9
|
+
|
|
10
|
+
export interface PushArgs {
|
|
11
|
+
yes?: boolean;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Sync the database to match schema.ts (create/alter/drop). */
|
|
16
|
+
export async function push(config: AgnesConfig, args: PushArgs): Promise<void> {
|
|
17
|
+
console.log(c.cyan(`agnes push → ${config.driver}`));
|
|
18
|
+
const db = await openDb(config);
|
|
19
|
+
|
|
20
|
+
const desired = normalizeIR(schemaToIR(config.schema), config.driver);
|
|
21
|
+
const current = normalizeIR(await introspect(db, config.driver), config.driver);
|
|
22
|
+
const ops = diffSchemas(desired, current);
|
|
23
|
+
|
|
24
|
+
await applyPlan(db, config.driver, ops, args);
|
|
25
|
+
}
|