agnes-cli 0.0.3 → 0.0.5
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 +31 -4
- package/agnes.config.example.ts +13 -0
- package/package.json +2 -2
- package/src/cli.ts +8 -0
- package/src/commands/generate.ts +17 -1
- package/src/commands/init.ts +67 -0
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pull.ts +44 -10
- package/src/commands/push.ts +1 -1
- package/src/config.ts +19 -1
- package/src/dialect.ts +3 -1
- package/src/generate.ts +5 -4
- package/src/introspect.ts +84 -74
- package/src/ir.ts +20 -2
- package/src/print.ts +63 -9
package/README.md
CHANGED
|
@@ -15,7 +15,8 @@ bun add agnes-cli agnes-library
|
|
|
15
15
|
|
|
16
16
|
## Configure
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Run `bun agnes init` to scaffold `agnes.config.ts`, or create it yourself
|
|
19
|
+
(see `agnes.config.example.ts`):
|
|
19
20
|
|
|
20
21
|
```ts
|
|
21
22
|
import { defineConfig } from "agnes-cli";
|
|
@@ -25,11 +26,15 @@ export default defineConfig({
|
|
|
25
26
|
driver: "postgres",
|
|
26
27
|
url: process.env.DATABASE_URL!,
|
|
27
28
|
schema,
|
|
28
|
-
|
|
29
|
+
|
|
30
|
+
schemas: ["public"], // PostgreSQL schemas to introspect (default: public)
|
|
31
|
+
pullMode: "singlefile", // "singlefile" | "multifile"
|
|
32
|
+
out: "./schema.ts", // where `pull` writes (a directory in multifile mode)
|
|
29
33
|
migrationsDir: "./migrations",
|
|
30
34
|
|
|
31
35
|
// `generate` output — extension picks the language (.ts or .js)
|
|
32
36
|
output: "src/services/db.ts",
|
|
37
|
+
urlEnv: "DATABASE_URL", // `generate` reads the URL from here (keeps it out of the file)
|
|
33
38
|
schemaPath: "./schema.ts", // module the generated client imports `schema` from
|
|
34
39
|
cache: { enabled: true, walPath: ".agnes/cache.wal" },
|
|
35
40
|
});
|
|
@@ -38,6 +43,7 @@ export default defineConfig({
|
|
|
38
43
|
## Commands
|
|
39
44
|
|
|
40
45
|
```bash
|
|
46
|
+
bun agnes init # scaffold agnes.config.ts
|
|
41
47
|
bun agnes push # make the DB match schema.ts (create/alter/DROP)
|
|
42
48
|
bun agnes pull # regenerate schema.ts from the live DB
|
|
43
49
|
bun agnes migrate # write a versioned .sql from drift, then apply pending
|
|
@@ -68,6 +74,24 @@ Prompts before overwriting an existing file unless `--yes`.
|
|
|
68
74
|
bun agnes pull --out src/schema.ts
|
|
69
75
|
```
|
|
70
76
|
|
|
77
|
+
**Multi-schema (PostgreSQL).** List the schemas you want in `schemas` — tables
|
|
78
|
+
outside `public` get qualified physical names (`table(def, "auth.users")`):
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
schemas: ["public", "auth", "billing"],
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
With `pullMode: "multifile"`, `pull` writes one file per DB schema plus an
|
|
85
|
+
`index.ts` that merges them into a single `schema` export — `out` is then a
|
|
86
|
+
directory:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
schema/
|
|
90
|
+
public.ts
|
|
91
|
+
auth.ts
|
|
92
|
+
index.ts ← import { schema } from "./schema"
|
|
93
|
+
```
|
|
94
|
+
|
|
71
95
|
### `migrate` — versioned SQL files
|
|
72
96
|
|
|
73
97
|
1. Diffs schema vs DB and, if there's drift, writes a timestamped file to
|
|
@@ -95,7 +119,7 @@ import { schema } from "../../schema";
|
|
|
95
119
|
export const db = await AgnesClient.create(
|
|
96
120
|
{
|
|
97
121
|
driver: "sqlite",
|
|
98
|
-
url:
|
|
122
|
+
url: process.env["DATABASE_URL"]!,
|
|
99
123
|
cache: { enabled: true, walPath: ".agnes/cache.wal" },
|
|
100
124
|
},
|
|
101
125
|
schema,
|
|
@@ -105,6 +129,9 @@ export const db = await AgnesClient.create(
|
|
|
105
129
|
- `output` (config) or `--output` picks the path. `.ts` → TypeScript, `.js` → JavaScript.
|
|
106
130
|
- The `import { schema }` path is made relative to the output file automatically.
|
|
107
131
|
- The `cache` block is emitted only if you set `cache` in the config.
|
|
132
|
+
- **Secrets stay out of the file:** set `urlEnv` (e.g. `"DATABASE_URL"`) and the
|
|
133
|
+
client reads `process.env[urlEnv]` at runtime instead of inlining the URL.
|
|
134
|
+
Without `urlEnv`, `generate` warns and inlines the literal `url`.
|
|
108
135
|
|
|
109
136
|
```bash
|
|
110
137
|
bun agnes generate # uses config.output
|
|
@@ -116,7 +143,7 @@ bun agnes generate --output src/db.js # override; JS output
|
|
|
116
143
|
| Flag | Applies to | Meaning |
|
|
117
144
|
|------|-----------|---------|
|
|
118
145
|
| `-c, --config <path>` | all | Config file (default `agnes.config.ts`) |
|
|
119
|
-
| `-o, --out <path>` | pull |
|
|
146
|
+
| `-o, --out <path>` | init, pull | Config path (init) / output schema file or dir (pull) |
|
|
120
147
|
| `--output <path>` | generate | Output client module (.ts/.js) |
|
|
121
148
|
| `--dir <path>` | migrate | Migrations directory |
|
|
122
149
|
| `-n, --name <name>` | migrate | Name for the generated migration |
|
package/agnes.config.example.ts
CHANGED
|
@@ -37,12 +37,25 @@ export default defineConfig({
|
|
|
37
37
|
driver: "postgres",
|
|
38
38
|
url: process.env.DATABASE_URL ?? "postgres://user:pass@localhost/db",
|
|
39
39
|
schema,
|
|
40
|
+
|
|
41
|
+
// PostgreSQL schemas to introspect (default: ["public"]). Tables outside the
|
|
42
|
+
// default schema get qualified physical names, e.g. table(def, "auth.users").
|
|
43
|
+
schemas: ["public"],
|
|
44
|
+
|
|
45
|
+
// How `agnes pull` writes the schema:
|
|
46
|
+
// "singlefile" (default) → one file at `out` (below).
|
|
47
|
+
// "multifile" → one file per DB schema + an index re-exporting
|
|
48
|
+
// them merged; `out` is then treated as a directory.
|
|
49
|
+
pullMode: "singlefile",
|
|
40
50
|
out: "./schema.ts",
|
|
41
51
|
migrationsDir: "./migrations",
|
|
42
52
|
|
|
43
53
|
// `agnes generate` writes the ready-to-import client here.
|
|
44
54
|
// Extension picks the language: db.ts → TypeScript, db.js → JavaScript.
|
|
45
55
|
output: "src/services/db.ts",
|
|
56
|
+
// Env var holding the URL. When set, `agnes generate` emits
|
|
57
|
+
// process.env[urlEnv] instead of inlining the URL — credentials stay in .env.
|
|
58
|
+
urlEnv: "DATABASE_URL",
|
|
46
59
|
// Module the generated client imports `schema` from (default: `out`).
|
|
47
60
|
schemaPath: "./agnes.config.ts",
|
|
48
61
|
// Cache baked into the generated client.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agnes-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Schema migration CLI for agnes-rs — push, pull and migrate from schema.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test": "bun test"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"agnes-library": "
|
|
26
|
+
"agnes-library": "0.0.5"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/bun": "latest"
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from "./config";
|
|
2
|
+
import { init } from "./commands/init";
|
|
2
3
|
import { push } from "./commands/push";
|
|
3
4
|
import { pull } from "./commands/pull";
|
|
4
5
|
import { migrate } from "./commands/migrate";
|
|
@@ -59,6 +60,7 @@ ${c.bold("Usage:")}
|
|
|
59
60
|
agnes <command> [options]
|
|
60
61
|
|
|
61
62
|
${c.bold("Commands:")}
|
|
63
|
+
init Scaffold an agnes.config.ts in the current directory
|
|
62
64
|
push Sync the database to match schema.ts (create/alter/drop)
|
|
63
65
|
pull Introspect the database and (re)generate schema.ts
|
|
64
66
|
migrate Generate a versioned SQL migration from drift, then apply pending
|
|
@@ -85,6 +87,12 @@ export async function run(argv: string[]): Promise<void> {
|
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
try {
|
|
90
|
+
// `init` runs without an existing config.
|
|
91
|
+
if (command === "init") {
|
|
92
|
+
await init({ out: flags.out, yes: flags.yes });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
88
96
|
const config = await loadConfig(flags.config);
|
|
89
97
|
switch (command) {
|
|
90
98
|
case "push":
|
package/src/commands/generate.ts
CHANGED
|
@@ -15,10 +15,17 @@ function toImportSpecifier(fromFile: string, toModule: string): string {
|
|
|
15
15
|
return rel;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function renderUrl(config: AgnesConfig): string {
|
|
19
|
+
// Never inline credentials: when urlEnv is set, read the URL from the
|
|
20
|
+
// environment at runtime so the generated file stays secret-free.
|
|
21
|
+
if (config.urlEnv) return `process.env[${JSON.stringify(config.urlEnv)}]!`;
|
|
22
|
+
return JSON.stringify(config.url);
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
function renderConfigObject(config: AgnesConfig): string {
|
|
19
26
|
const lines: string[] = [
|
|
20
27
|
` driver: ${JSON.stringify(config.driver)},`,
|
|
21
|
-
` url: ${
|
|
28
|
+
` url: ${renderUrl(config)},`,
|
|
22
29
|
];
|
|
23
30
|
if (config.maxConnections !== undefined)
|
|
24
31
|
lines.push(` maxConnections: ${config.maxConnections},`);
|
|
@@ -57,6 +64,15 @@ export async function generate(config: AgnesConfig, args: GenerateArgs): Promise
|
|
|
57
64
|
const schemaFsPath = resolve(process.cwd(), config.schemaPath ?? config.out ?? "schema.ts");
|
|
58
65
|
const schemaImport = toImportSpecifier(outPath, schemaFsPath);
|
|
59
66
|
|
|
67
|
+
if (!config.urlEnv) {
|
|
68
|
+
console.log(
|
|
69
|
+
c.yellow(
|
|
70
|
+
`⚠ config.urlEnv is not set — the connection URL will be INLINED into ${outRel}.`,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
console.log(c.dim(` Set urlEnv (e.g. "DATABASE_URL") to read it from the environment instead.`));
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
const source = renderClient(config, schemaImport);
|
|
61
77
|
|
|
62
78
|
if (await Bun.file(outPath).exists() && !args.yes) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { c, confirm } from "../prompt";
|
|
3
|
+
|
|
4
|
+
export interface InitArgs {
|
|
5
|
+
/** Target config path (default: agnes.config.ts). */
|
|
6
|
+
out?: string;
|
|
7
|
+
yes?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const TEMPLATE = `import { defineConfig } from "agnes-cli";
|
|
11
|
+
import { schema } from "./schema";
|
|
12
|
+
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
// Target database dialect: "postgres" | "mysql" | "sqlite".
|
|
15
|
+
driver: "postgres",
|
|
16
|
+
|
|
17
|
+
// Connection URL. Keep credentials in your .env — see urlEnv below.
|
|
18
|
+
url: process.env.DATABASE_URL!,
|
|
19
|
+
|
|
20
|
+
// The schema object exported from your schema.ts.
|
|
21
|
+
schema,
|
|
22
|
+
|
|
23
|
+
// ── pull / push ──────────────────────────────────────────────────────────
|
|
24
|
+
// PostgreSQL schemas to introspect. "public" is the default; add more to
|
|
25
|
+
// pull/push tables that live outside it (they get qualified names, "auth.users").
|
|
26
|
+
schemas: ["public"],
|
|
27
|
+
|
|
28
|
+
// How \`agnes pull\` lays out the generated schema:
|
|
29
|
+
// "singlefile" — every table in one file (config.out, default ./schema.ts)
|
|
30
|
+
// "multifile" — one file per DB schema + an index re-exporting them merged
|
|
31
|
+
// (config.out is then a directory, default ./schema)
|
|
32
|
+
pullMode: "singlefile",
|
|
33
|
+
out: "./schema.ts",
|
|
34
|
+
|
|
35
|
+
// Directory for versioned migration files.
|
|
36
|
+
migrationsDir: "./migrations",
|
|
37
|
+
|
|
38
|
+
// ── generate ──────────────────────────────────────────────────────────────
|
|
39
|
+
// Env var holding the URL. When set, \`agnes generate\` emits
|
|
40
|
+
// process.env[urlEnv] in the client so no credentials land in the output file.
|
|
41
|
+
urlEnv: "DATABASE_URL",
|
|
42
|
+
|
|
43
|
+
// Where \`agnes generate\` writes the pre-wired AgnesClient module (.ts or .js).
|
|
44
|
+
output: "./src/db.ts",
|
|
45
|
+
|
|
46
|
+
// Cache baked into the generated client.
|
|
47
|
+
cache: { enabled: false },
|
|
48
|
+
});
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
/** Scaffold an agnes.config.ts in the current directory. */
|
|
52
|
+
export async function init(args: InitArgs): Promise<void> {
|
|
53
|
+
const outRel = args.out ?? "agnes.config.ts";
|
|
54
|
+
const outPath = resolve(process.cwd(), outRel);
|
|
55
|
+
|
|
56
|
+
if ((await Bun.file(outPath).exists()) && !args.yes) {
|
|
57
|
+
console.log(c.yellow(`⚠ ${outPath} already exists and will be overwritten.`));
|
|
58
|
+
if (!(await confirm("Overwrite it?"))) {
|
|
59
|
+
console.log(c.dim("Aborted. File left unchanged."));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await Bun.write(outPath, TEMPLATE);
|
|
65
|
+
console.log(c.green(`✓ Created ${outPath}`));
|
|
66
|
+
console.log(c.dim(` Edit driver/url, then run \`agnes pull\` to generate schema.ts.`));
|
|
67
|
+
}
|
package/src/commands/migrate.ts
CHANGED
|
@@ -62,7 +62,7 @@ export async function migrate(config: AgnesConfig, args: MigrateArgs): Promise<v
|
|
|
62
62
|
|
|
63
63
|
// 1. Detect drift and write a new migration file.
|
|
64
64
|
const desired = normalizeIR(schemaToIR(config.schema), config.driver);
|
|
65
|
-
const current = normalizeIR(await introspect(db, config.driver), config.driver);
|
|
65
|
+
const current = normalizeIR(await introspect(db, config.driver, config.schemas), config.driver);
|
|
66
66
|
const ops = diffSchemas(desired, current);
|
|
67
67
|
|
|
68
68
|
if (ops.length > 0) {
|
package/src/commands/pull.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
2
|
import type { AgnesConfig } from "../config";
|
|
3
3
|
import { openDb } from "../db";
|
|
4
4
|
import { introspect } from "../introspect";
|
|
5
|
-
import { printSchema } from "../print";
|
|
5
|
+
import { printSchema, printSchemaFiles } from "../print";
|
|
6
6
|
import { c, confirm } from "../prompt";
|
|
7
7
|
|
|
8
8
|
export interface PullArgs {
|
|
@@ -15,19 +15,30 @@ export async function pull(config: AgnesConfig, args: PullArgs): Promise<void> {
|
|
|
15
15
|
console.log(c.cyan(`agnes pull ← ${config.driver}`));
|
|
16
16
|
const db = await openDb(config);
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const schemas = config.schemas?.length ? config.schemas : ["public"];
|
|
19
|
+
if (config.driver === "postgres" && schemas.length > 1) {
|
|
20
|
+
console.log(c.dim(` schemas: ${schemas.join(", ")}`));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const current = await introspect(db, config.driver, schemas);
|
|
19
24
|
const tableCount = Object.keys(current).length;
|
|
20
|
-
|
|
25
|
+
console.log(c.green(`\n✓ Introspected ${tableCount} table(s).`));
|
|
21
26
|
|
|
22
|
-
const
|
|
23
|
-
|
|
27
|
+
const multifile = config.pullMode === "multifile";
|
|
28
|
+
if (multifile) {
|
|
29
|
+
await writeMultifile(config, args, current);
|
|
30
|
+
} else {
|
|
31
|
+
await writeSinglefile(config, args, current);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
24
34
|
|
|
25
|
-
|
|
35
|
+
async function writeSinglefile(config: AgnesConfig, args: PullArgs, ir: import("../ir").DatabaseIR) {
|
|
36
|
+
const outPath = resolve(process.cwd(), args.out ?? config.out ?? "schema.ts");
|
|
37
|
+
const source = printSchema(ir);
|
|
26
38
|
|
|
27
|
-
if (exists && !args.yes) {
|
|
39
|
+
if ((await Bun.file(outPath).exists()) && !args.yes) {
|
|
28
40
|
console.log(c.yellow(`\n⚠ ${outPath} already exists and will be overwritten.`));
|
|
29
|
-
|
|
30
|
-
if (!ok) {
|
|
41
|
+
if (!(await confirm("Overwrite it?"))) {
|
|
31
42
|
console.log(c.dim("Aborted. File left unchanged."));
|
|
32
43
|
return;
|
|
33
44
|
}
|
|
@@ -36,3 +47,26 @@ export async function pull(config: AgnesConfig, args: PullArgs): Promise<void> {
|
|
|
36
47
|
await Bun.write(outPath, source);
|
|
37
48
|
console.log(c.green(`✓ Wrote ${outPath}`));
|
|
38
49
|
}
|
|
50
|
+
|
|
51
|
+
async function writeMultifile(config: AgnesConfig, args: PullArgs, ir: import("../ir").DatabaseIR) {
|
|
52
|
+
// In multifile mode `out` names a directory (default ./schema).
|
|
53
|
+
const dir = resolve(process.cwd(), args.out ?? config.out ?? "schema");
|
|
54
|
+
const { files, index } = printSchemaFiles(ir);
|
|
55
|
+
const targets = [
|
|
56
|
+
...files.map((f) => ({ path: join(dir, `${f.name}.ts`), source: f.source })),
|
|
57
|
+
{ path: join(dir, "index.ts"), source: index },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const existing = (await Promise.all(targets.map((t) => Bun.file(t.path).exists()))).some(Boolean);
|
|
61
|
+
if (existing && !args.yes) {
|
|
62
|
+
console.log(c.yellow(`\n⚠ Files under ${dir} already exist and will be overwritten.`));
|
|
63
|
+
if (!(await confirm("Overwrite them?"))) {
|
|
64
|
+
console.log(c.dim("Aborted. Files left unchanged."));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const t of targets) await Bun.write(t.path, t.source);
|
|
70
|
+
console.log(c.green(`✓ Wrote ${files.length} schema file(s) + index → ${dir}`));
|
|
71
|
+
console.log(c.dim(` point config.schemaPath / config.out at ${join(dir, "index.ts")}`));
|
|
72
|
+
}
|
package/src/commands/push.ts
CHANGED
|
@@ -18,7 +18,7 @@ export async function push(config: AgnesConfig, args: PushArgs): Promise<void> {
|
|
|
18
18
|
const db = await openDb(config);
|
|
19
19
|
|
|
20
20
|
const desired = normalizeIR(schemaToIR(config.schema), config.driver);
|
|
21
|
-
const current = normalizeIR(await introspect(db, config.driver), config.driver);
|
|
21
|
+
const current = normalizeIR(await introspect(db, config.driver, config.schemas), config.driver);
|
|
22
22
|
const ops = diffSchemas(desired, current);
|
|
23
23
|
|
|
24
24
|
await applyPlan(db, config.driver, ops, args);
|
package/src/config.ts
CHANGED
|
@@ -15,8 +15,20 @@ export interface AgnesConfig {
|
|
|
15
15
|
url: string;
|
|
16
16
|
/** The schema object exported from your schema.ts (`export const schema = {...}`). */
|
|
17
17
|
schema: DslSchema;
|
|
18
|
-
/** Where `pull` writes the generated schema (default: ./schema.ts).
|
|
18
|
+
/** Where `pull` writes the generated schema (default: ./schema.ts).
|
|
19
|
+
* In `multifile` mode this is treated as a directory (default: ./schema). */
|
|
19
20
|
out?: string;
|
|
21
|
+
/**
|
|
22
|
+
* PostgreSQL schemas to introspect on `pull`/`push` (default: `["public"]`).
|
|
23
|
+
* Tables outside the default schema get qualified physical names ("auth.users").
|
|
24
|
+
*/
|
|
25
|
+
schemas?: string[];
|
|
26
|
+
/**
|
|
27
|
+
* How `pull` lays out the generated schema:
|
|
28
|
+
* - "singlefile" (default): every table in one file.
|
|
29
|
+
* - "multifile": one file per DB schema + an index re-exporting them merged.
|
|
30
|
+
*/
|
|
31
|
+
pullMode?: "singlefile" | "multifile";
|
|
20
32
|
/** Directory for versioned migration files (default: ./migrations). */
|
|
21
33
|
migrationsDir?: string;
|
|
22
34
|
maxConnections?: number;
|
|
@@ -28,6 +40,12 @@ export interface AgnesConfig {
|
|
|
28
40
|
* e.g. "src/services/db.ts" or "db.js".
|
|
29
41
|
*/
|
|
30
42
|
output?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Env var holding the connection URL. When set, `agnes generate` emits
|
|
45
|
+
* `process.env[urlEnv]` in the client instead of inlining the literal URL —
|
|
46
|
+
* so credentials stay in your .env and never land in the generated file.
|
|
47
|
+
*/
|
|
48
|
+
urlEnv?: string;
|
|
31
49
|
/**
|
|
32
50
|
* Module that exports `schema`, imported by the generated client.
|
|
33
51
|
* Default: `out` (the pull target) or "./schema". A filesystem path
|
package/src/dialect.ts
CHANGED
|
@@ -51,7 +51,9 @@ export function logicalType(raw: string): ColumnType {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export function ident(dialect: Dialect, name: string): string {
|
|
54
|
-
|
|
54
|
+
const q = (p: string) => (dialect === "mysql" ? `\`${p}\`` : `"${p}"`);
|
|
55
|
+
// Qualified table refs ("schema.table") quote each part separately.
|
|
56
|
+
return name.includes(".") ? name.split(".").map(q).join(".") : q(name);
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/** Render a default value as a SQL literal. */
|
package/src/generate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defaultLiteral, ident, physicalType, type Dialect } from "./dialect";
|
|
2
|
-
import type
|
|
2
|
+
import { qualifiedName, type ColumnIR, type ForeignKeyIR, type TableIR } from "./ir";
|
|
3
3
|
import type { Operation } from "./diff";
|
|
4
4
|
|
|
5
5
|
function columnDef(dialect: Dialect, col: ColumnIR): string {
|
|
@@ -24,7 +24,7 @@ function createTable(dialect: Dialect, t: TableIR): string {
|
|
|
24
24
|
if (dialect === "sqlite") {
|
|
25
25
|
for (const fk of t.foreignKeys) lines.push(` ${fkClause(dialect, fk)}`);
|
|
26
26
|
}
|
|
27
|
-
return `CREATE TABLE ${ident(dialect, t.name)} (\n${lines.join(",\n")}\n);`;
|
|
27
|
+
return `CREATE TABLE ${ident(dialect, qualifiedName(t.name, t.schema))} (\n${lines.join(",\n")}\n);`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function createIndex(dialect: Dialect, table: string, name: string, cols: string[], unique: boolean): string {
|
|
@@ -43,13 +43,14 @@ export function renderOperation(dialect: Dialect, op: Operation): string[] {
|
|
|
43
43
|
|
|
44
44
|
switch (op.kind) {
|
|
45
45
|
case "createTable": {
|
|
46
|
+
const qn = qualifiedName(op.table.name, op.table.schema);
|
|
46
47
|
const out = [createTable(dialect, op.table)];
|
|
47
48
|
for (const idx of op.table.indexes)
|
|
48
|
-
out.push(createIndex(dialect,
|
|
49
|
+
out.push(createIndex(dialect, qn, idx.name, idx.columns, idx.unique));
|
|
49
50
|
// Non-SQLite: add FKs after create (SQLite already inlined them).
|
|
50
51
|
if (dialect !== "sqlite")
|
|
51
52
|
for (const fk of op.table.foreignKeys)
|
|
52
|
-
out.push(`ALTER TABLE ${t(
|
|
53
|
+
out.push(`ALTER TABLE ${t(qn)} ADD ${fkClause(dialect, fk)};`);
|
|
53
54
|
return out;
|
|
54
55
|
}
|
|
55
56
|
|
package/src/introspect.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { logicalType, type Dialect } from "./dialect";
|
|
2
|
-
import type
|
|
2
|
+
import { qualifiedName, type ColumnIR, type DatabaseIR, type ForeignKeyIR, type IndexIR, type TableIR } from "./ir";
|
|
3
3
|
|
|
4
4
|
// Minimal DB surface the CLI needs — satisfied by AgnesClient.
|
|
5
5
|
export interface QueryClient {
|
|
@@ -17,10 +17,14 @@ function isInternal(name: string): boolean {
|
|
|
17
17
|
return name.startsWith("_agnes") || name.startsWith("sqlite_");
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export async function introspect(
|
|
20
|
+
export async function introspect(
|
|
21
|
+
db: QueryClient,
|
|
22
|
+
dialect: Dialect,
|
|
23
|
+
schemas?: string[],
|
|
24
|
+
): Promise<DatabaseIR> {
|
|
21
25
|
switch (dialect) {
|
|
22
26
|
case "postgres":
|
|
23
|
-
return introspectPostgres(db);
|
|
27
|
+
return introspectPostgres(db, schemas && schemas.length ? schemas : ["public"]);
|
|
24
28
|
case "mysql":
|
|
25
29
|
return introspectMysql(db);
|
|
26
30
|
case "sqlite":
|
|
@@ -30,81 +34,87 @@ export async function introspect(db: QueryClient, dialect: Dialect): Promise<Dat
|
|
|
30
34
|
|
|
31
35
|
// ─── PostgreSQL ─────────────────────────────────────────────────────────────
|
|
32
36
|
|
|
33
|
-
async function introspectPostgres(db: QueryClient): Promise<DatabaseIR> {
|
|
37
|
+
async function introspectPostgres(db: QueryClient, schemas: string[]): Promise<DatabaseIR> {
|
|
34
38
|
const ir: DatabaseIR = {};
|
|
35
|
-
const tables = await db.query<Row>(
|
|
36
|
-
`SELECT table_name FROM information_schema.tables
|
|
37
|
-
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`,
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
for (const tr of tables) {
|
|
41
|
-
const name = str(tr.table_name);
|
|
42
|
-
if (isInternal(name)) continue;
|
|
43
|
-
|
|
44
|
-
const cols = await db.query<Row>(
|
|
45
|
-
`SELECT column_name, data_type, is_nullable, column_default
|
|
46
|
-
FROM information_schema.columns
|
|
47
|
-
WHERE table_schema = 'public' AND table_name = $1
|
|
48
|
-
ORDER BY ordinal_position`,
|
|
49
|
-
[name],
|
|
50
|
-
);
|
|
51
|
-
const pks = await db.query<Row>(
|
|
52
|
-
`SELECT kcu.column_name FROM information_schema.table_constraints tc
|
|
53
|
-
JOIN information_schema.key_column_usage kcu
|
|
54
|
-
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
55
|
-
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1 AND tc.table_schema = 'public'`,
|
|
56
|
-
[name],
|
|
57
|
-
);
|
|
58
|
-
const pkSet = new Set(pks.map((r) => str(r.column_name)));
|
|
59
|
-
|
|
60
|
-
const columns: ColumnIR[] = cols.map((c) => ({
|
|
61
|
-
name: str(c.column_name),
|
|
62
|
-
type: logicalType(str(c.data_type)),
|
|
63
|
-
nullable: str(c.is_nullable) === "YES",
|
|
64
|
-
primary: pkSet.has(str(c.column_name)),
|
|
65
|
-
default: c.column_default == null ? undefined : str(c.column_default),
|
|
66
|
-
}));
|
|
67
39
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
74
|
-
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
|
75
|
-
WHERE t.relname = $1 AND t.relkind = 'r'`,
|
|
76
|
-
[name],
|
|
77
|
-
);
|
|
78
|
-
const indexes = groupIndexes(
|
|
79
|
-
idxRows
|
|
80
|
-
.filter((r) => !truthy(r.is_primary))
|
|
81
|
-
.map((r) => ({ index: str(r.index_name), column: str(r.column_name), unique: truthy(r.is_unique) })),
|
|
40
|
+
for (const schema of schemas) {
|
|
41
|
+
const tables = await db.query<Row>(
|
|
42
|
+
`SELECT table_name FROM information_schema.tables
|
|
43
|
+
WHERE table_schema = $1 AND table_type = 'BASE TABLE'`,
|
|
44
|
+
[schema],
|
|
82
45
|
);
|
|
83
46
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
47
|
+
for (const tr of tables) {
|
|
48
|
+
const name = str(tr.table_name);
|
|
49
|
+
if (isInternal(name)) continue;
|
|
50
|
+
|
|
51
|
+
const cols = await db.query<Row>(
|
|
52
|
+
`SELECT column_name, data_type, is_nullable, column_default
|
|
53
|
+
FROM information_schema.columns
|
|
54
|
+
WHERE table_schema = $1 AND table_name = $2
|
|
55
|
+
ORDER BY ordinal_position`,
|
|
56
|
+
[schema, name],
|
|
57
|
+
);
|
|
58
|
+
const pks = await db.query<Row>(
|
|
59
|
+
`SELECT kcu.column_name FROM information_schema.table_constraints tc
|
|
60
|
+
JOIN information_schema.key_column_usage kcu
|
|
61
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
62
|
+
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $2 AND tc.table_schema = $1`,
|
|
63
|
+
[schema, name],
|
|
64
|
+
);
|
|
65
|
+
const pkSet = new Set(pks.map((r) => str(r.column_name)));
|
|
66
|
+
|
|
67
|
+
const columns: ColumnIR[] = cols.map((c) => ({
|
|
68
|
+
name: str(c.column_name),
|
|
69
|
+
type: logicalType(str(c.data_type)),
|
|
70
|
+
nullable: str(c.is_nullable) === "YES",
|
|
71
|
+
primary: pkSet.has(str(c.column_name)),
|
|
72
|
+
default: c.column_default == null ? undefined : str(c.column_default),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const idxRows = await db.query<Row>(
|
|
76
|
+
`SELECT i.relname AS index_name, a.attname AS column_name,
|
|
77
|
+
ix.indisunique AS is_unique, ix.indisprimary AS is_primary
|
|
78
|
+
FROM pg_class t
|
|
79
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
80
|
+
JOIN pg_index ix ON t.oid = ix.indrelid
|
|
81
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
82
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
|
83
|
+
WHERE t.relname = $2 AND n.nspname = $1 AND t.relkind = 'r'`,
|
|
84
|
+
[schema, name],
|
|
85
|
+
);
|
|
86
|
+
const indexes = groupIndexes(
|
|
87
|
+
idxRows
|
|
88
|
+
.filter((r) => !truthy(r.is_primary))
|
|
89
|
+
.map((r) => ({ index: str(r.index_name), column: str(r.column_name), unique: truthy(r.is_unique) })),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const fkRows = await db.query<Row>(
|
|
93
|
+
`SELECT tc.constraint_name, kcu.column_name,
|
|
94
|
+
ccu.table_schema AS foreign_schema, ccu.table_name AS foreign_table,
|
|
95
|
+
ccu.column_name AS foreign_column, rc.update_rule, rc.delete_rule
|
|
96
|
+
FROM information_schema.table_constraints tc
|
|
97
|
+
JOIN information_schema.key_column_usage kcu
|
|
98
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
99
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
100
|
+
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
|
101
|
+
JOIN information_schema.referential_constraints rc
|
|
102
|
+
ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.table_schema
|
|
103
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = $2 AND tc.table_schema = $1`,
|
|
104
|
+
[schema, name],
|
|
105
|
+
);
|
|
106
|
+
const foreignKeys: ForeignKeyIR[] = fkRows.map((r) => ({
|
|
107
|
+
name: str(r.constraint_name),
|
|
108
|
+
column: str(r.column_name),
|
|
109
|
+
refTable: qualifiedName(str(r.foreign_table), str(r.foreign_schema)),
|
|
110
|
+
refColumn: str(r.foreign_column),
|
|
111
|
+
onUpdate: str(r.update_rule).toUpperCase(),
|
|
112
|
+
onDelete: str(r.delete_rule).toUpperCase(),
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
const s = schema === "public" ? undefined : schema;
|
|
116
|
+
ir[qualifiedName(name, s)] = { name, schema: s, columns, indexes, foreignKeys };
|
|
117
|
+
}
|
|
108
118
|
}
|
|
109
119
|
return ir;
|
|
110
120
|
}
|
package/src/ir.ts
CHANGED
|
@@ -30,15 +30,23 @@ export interface ForeignKeyIR {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export interface TableIR {
|
|
33
|
+
/** Physical table name (unqualified). */
|
|
33
34
|
name: string;
|
|
35
|
+
/** Owning DB schema. `undefined`/"public" = default schema. */
|
|
36
|
+
schema?: string;
|
|
34
37
|
columns: ColumnIR[];
|
|
35
38
|
indexes: IndexIR[];
|
|
36
39
|
foreignKeys: ForeignKeyIR[];
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
/** Keyed by
|
|
42
|
+
/** Keyed by qualified name (see {@link qualifiedName}). */
|
|
40
43
|
export type DatabaseIR = Record<string, TableIR>;
|
|
41
44
|
|
|
45
|
+
/** IR key / physical reference: `table` in the default schema, else `schema.table`. */
|
|
46
|
+
export function qualifiedName(name: string, schema?: string): string {
|
|
47
|
+
return !schema || schema === "public" ? name : `${schema}.${name}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
// ─── Structural view of the schema DSL ──────────────────────────────────────
|
|
43
51
|
// We treat the schema object duck-typed (via `_kind`) so the CLI never has to
|
|
44
52
|
// share a compiled build with agnes-library — only the shape matters.
|
|
@@ -140,7 +148,17 @@ export function schemaToIR(schema: DslSchema): DatabaseIR {
|
|
|
140
148
|
// `many` relations have no physical footprint — the FK lives on the target side.
|
|
141
149
|
}
|
|
142
150
|
|
|
143
|
-
|
|
151
|
+
// A dotted physical name ("auth.users") carries an explicit schema.
|
|
152
|
+
const dot = entry.tableName.indexOf(".");
|
|
153
|
+
const tblSchema = dot === -1 ? undefined : entry.tableName.slice(0, dot);
|
|
154
|
+
const bare = dot === -1 ? entry.tableName : entry.tableName.slice(dot + 1);
|
|
155
|
+
ir[qualifiedName(bare, tblSchema)] = {
|
|
156
|
+
name: bare,
|
|
157
|
+
schema: tblSchema,
|
|
158
|
+
columns,
|
|
159
|
+
indexes,
|
|
160
|
+
foreignKeys,
|
|
161
|
+
};
|
|
144
162
|
}
|
|
145
163
|
|
|
146
164
|
return ir;
|
package/src/print.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { qualifiedName, type ColumnType, type DatabaseIR, type TableIR } from "./ir";
|
|
2
2
|
|
|
3
3
|
const HELPER: Record<ColumnType, string> = {
|
|
4
4
|
int: "int",
|
|
@@ -18,6 +18,9 @@ const ON_ACTION: Record<string, string> = {
|
|
|
18
18
|
"SET DEFAULT": "OnAction.SetDefault",
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
const HEADER =
|
|
22
|
+
`import { table, int, bigint, text, bool, float, bytes, json, one, OnAction } from "agnes-library";\n`;
|
|
23
|
+
|
|
21
24
|
function onAction(rule: string): string {
|
|
22
25
|
return ON_ACTION[rule.toUpperCase()] ?? "OnAction.None";
|
|
23
26
|
}
|
|
@@ -28,7 +31,13 @@ function literal(v: unknown): string {
|
|
|
28
31
|
return JSON.stringify(String(v));
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
/** A qualified name → a valid JS identifier (for the `schema` object keys / relation keys). */
|
|
35
|
+
function idKey(name: string): string {
|
|
36
|
+
return name.replace(/[^A-Za-z0-9_]/g, "_");
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
function tableSource(t: TableIR): string {
|
|
40
|
+
const physical = qualifiedName(t.name, t.schema);
|
|
32
41
|
const fkByColumn = new Map(t.foreignKeys.map((fk) => [fk.column, fk]));
|
|
33
42
|
const lines: string[] = [];
|
|
34
43
|
|
|
@@ -49,26 +58,71 @@ function tableSource(t: TableIR): string {
|
|
|
49
58
|
|
|
50
59
|
for (const fk of fkByColumn.values()) {
|
|
51
60
|
// Relation TS key: derive from referenced table (best effort).
|
|
52
|
-
const relKey = fk.refTable;
|
|
61
|
+
const relKey = idKey(fk.refTable);
|
|
53
62
|
lines.push(
|
|
54
63
|
` ${relKey}: one(${JSON.stringify(fk.refTable)}, ${JSON.stringify(fk.column)}, ` +
|
|
55
64
|
`${JSON.stringify(fk.refColumn)}, ${onAction(fk.onUpdate)}, ${onAction(fk.onDelete)}),`,
|
|
56
65
|
);
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
return ` ${
|
|
68
|
+
return ` ${idKey(physical)}: table({\n${lines.join("\n")}\n }, ${JSON.stringify(physical)}),`;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
71
|
+
function schemaBlock(tables: TableIR[]): string {
|
|
72
|
+
const body = tables
|
|
73
|
+
.slice()
|
|
74
|
+
.sort((a, b) => qualifiedName(a.name, a.schema).localeCompare(qualifiedName(b.name, b.schema)))
|
|
66
75
|
.map(tableSource)
|
|
67
76
|
.join("\n");
|
|
77
|
+
return `export const schema = {\n${body}\n};\n`;
|
|
78
|
+
}
|
|
68
79
|
|
|
80
|
+
/** Render a full DatabaseIR into a single schema.ts source string. */
|
|
81
|
+
export function printSchema(ir: DatabaseIR): string {
|
|
69
82
|
return (
|
|
70
83
|
`// Generated by \`agnes pull\`. Edit and re-run \`agnes push\` to apply changes.\n` +
|
|
71
|
-
|
|
72
|
-
`
|
|
84
|
+
HEADER +
|
|
85
|
+
`\n` +
|
|
86
|
+
schemaBlock(Object.values(ir))
|
|
73
87
|
);
|
|
74
88
|
}
|
|
89
|
+
|
|
90
|
+
export interface SchemaFile {
|
|
91
|
+
/** Bare file name without extension, e.g. "public", "auth". */
|
|
92
|
+
name: string;
|
|
93
|
+
source: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Render a DatabaseIR into one file per DB schema plus an `index` that
|
|
98
|
+
* re-exports every file's tables merged into a single `schema` object.
|
|
99
|
+
*/
|
|
100
|
+
export function printSchemaFiles(ir: DatabaseIR): { files: SchemaFile[]; index: string } {
|
|
101
|
+
const bySchema = new Map<string, TableIR[]>();
|
|
102
|
+
for (const t of Object.values(ir)) {
|
|
103
|
+
const key = t.schema ?? "public";
|
|
104
|
+
(bySchema.get(key) ?? bySchema.set(key, []).get(key)!).push(t);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const names = [...bySchema.keys()].sort();
|
|
108
|
+
const files: SchemaFile[] = names.map((name) => ({
|
|
109
|
+
name,
|
|
110
|
+
source:
|
|
111
|
+
`// Generated by \`agnes pull\`. Schema "${name}".\n` +
|
|
112
|
+
HEADER +
|
|
113
|
+
`\n` +
|
|
114
|
+
schemaBlock(bySchema.get(name)!),
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
// Alias with a "Schema" suffix so reserved words (e.g. "public") stay valid bindings.
|
|
118
|
+
const alias = (n: string) => `${idKey(n)}Schema`;
|
|
119
|
+
const imports = names
|
|
120
|
+
.map((n) => `import { schema as ${alias(n)} } from ${JSON.stringify(`./${n}`)};`)
|
|
121
|
+
.join("\n");
|
|
122
|
+
const spread = names.map((n) => ` ...${alias(n)},`).join("\n");
|
|
123
|
+
const index =
|
|
124
|
+
`// Generated by \`agnes pull\`. Merges every per-schema file into one \`schema\`.\n` +
|
|
125
|
+
`${imports}\n\nexport const schema = {\n${spread}\n};\n`;
|
|
126
|
+
|
|
127
|
+
return { files, index };
|
|
128
|
+
}
|