agnes-cli 0.0.4 → 0.0.6

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 CHANGED
@@ -15,7 +15,8 @@ bun add agnes-cli agnes-library
15
15
 
16
16
  ## Configure
17
17
 
18
- Create `agnes.config.ts` in your project root (see `agnes.config.example.ts`):
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
- out: "./schema.ts", // where `pull` writes
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,29 @@ 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
+
95
+ **Defaults & auto-increment.** `pull` renders defaults in the column's type
96
+ (`bool("x").default(true)`, `int("n").default(1)` — not strings). Serial /
97
+ identity / `AUTO_INCREMENT` columns become `.autoincrement()`, and SQL
98
+ expressions that can't be a literal (e.g. `CURRENT_TIMESTAMP`) are omitted.
99
+
71
100
  ### `migrate` — versioned SQL files
72
101
 
73
102
  1. Diffs schema vs DB and, if there's drift, writes a timestamped file to
@@ -95,7 +124,7 @@ import { schema } from "../../schema";
95
124
  export const db = await AgnesClient.create(
96
125
  {
97
126
  driver: "sqlite",
98
- url: "sqlite:./demo.db",
127
+ url: process.env["DATABASE_URL"]!,
99
128
  cache: { enabled: true, walPath: ".agnes/cache.wal" },
100
129
  },
101
130
  schema,
@@ -105,6 +134,9 @@ export const db = await AgnesClient.create(
105
134
  - `output` (config) or `--output` picks the path. `.ts` → TypeScript, `.js` → JavaScript.
106
135
  - The `import { schema }` path is made relative to the output file automatically.
107
136
  - The `cache` block is emitted only if you set `cache` in the config.
137
+ - **Secrets stay out of the file:** set `urlEnv` (e.g. `"DATABASE_URL"`) and the
138
+ client reads `process.env[urlEnv]` at runtime instead of inlining the URL.
139
+ Without `urlEnv`, `generate` warns and inlines the literal `url`.
108
140
 
109
141
  ```bash
110
142
  bun agnes generate # uses config.output
@@ -116,7 +148,7 @@ bun agnes generate --output src/db.js # override; JS output
116
148
  | Flag | Applies to | Meaning |
117
149
  |------|-----------|---------|
118
150
  | `-c, --config <path>` | all | Config file (default `agnes.config.ts`) |
119
- | `-o, --out <path>` | pull | Output schema file |
151
+ | `-o, --out <path>` | init, pull | Config path (init) / output schema file or dir (pull) |
120
152
  | `--output <path>` | generate | Output client module (.ts/.js) |
121
153
  | `--dir <path>` | migrate | Migrations directory |
122
154
  | `-n, --name <name>` | migrate | Name for the generated migration |
@@ -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.4",
3
+ "version": "0.0.6",
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": "0.0.4"
26
+ "agnes-library": "0.0.6"
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":
@@ -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: ${JSON.stringify(config.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
+ }
@@ -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) {
@@ -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 current = await introspect(db, config.driver);
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
- const source = printSchema(current);
25
+ console.log(c.green(`\n✓ Introspected ${tableCount} table(s).`));
21
26
 
22
- const outPath = resolve(process.cwd(), args.out ?? config.out ?? "schema.ts");
23
- const exists = await Bun.file(outPath).exists();
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
- console.log(c.green(`\n✓ Introspected ${tableCount} table(s).`));
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
- const ok = await confirm("Overwrite it?");
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
+ }
@@ -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
- return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
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/diff.ts CHANGED
@@ -22,7 +22,12 @@ function byName<T extends { name: string }>(items: T[]): Map<string, T> {
22
22
  }
23
23
 
24
24
  function columnChanged(a: ColumnIR, b: ColumnIR): boolean {
25
- return a.type !== b.type || a.nullable !== b.nullable || a.primary !== b.primary;
25
+ return (
26
+ a.type !== b.type ||
27
+ a.nullable !== b.nullable ||
28
+ a.primary !== b.primary ||
29
+ !!a.autoincrement !== !!b.autoincrement
30
+ );
26
31
  }
27
32
 
28
33
  /**
package/src/generate.ts CHANGED
@@ -1,9 +1,26 @@
1
1
  import { defaultLiteral, ident, physicalType, type Dialect } from "./dialect";
2
- import type { ColumnIR, ForeignKeyIR, TableIR } from "./ir";
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 {
6
- let s = `${ident(dialect, col.name)} ${physicalType(dialect, col.type)}`;
6
+ const name = ident(dialect, col.name);
7
+ const type = physicalType(dialect, col.type);
8
+
9
+ if (col.autoincrement) {
10
+ // SQLite only auto-increments an INTEGER PRIMARY KEY.
11
+ if (dialect === "sqlite") return `${name} INTEGER PRIMARY KEY AUTOINCREMENT`;
12
+ if (dialect === "postgres") {
13
+ let s = `${name} ${type} GENERATED BY DEFAULT AS IDENTITY`;
14
+ if (col.primary) s += " PRIMARY KEY";
15
+ return s;
16
+ }
17
+ // mysql
18
+ let s = `${name} ${type} NOT NULL AUTO_INCREMENT`;
19
+ if (col.primary) s += " PRIMARY KEY";
20
+ return s;
21
+ }
22
+
23
+ let s = `${name} ${type}`;
7
24
  if (col.primary) s += " PRIMARY KEY";
8
25
  if (!col.nullable && !col.primary) s += " NOT NULL";
9
26
  if (col.default !== undefined) s += ` DEFAULT ${defaultLiteral(dialect, col.default)}`;
@@ -24,7 +41,7 @@ function createTable(dialect: Dialect, t: TableIR): string {
24
41
  if (dialect === "sqlite") {
25
42
  for (const fk of t.foreignKeys) lines.push(` ${fkClause(dialect, fk)}`);
26
43
  }
27
- return `CREATE TABLE ${ident(dialect, t.name)} (\n${lines.join(",\n")}\n);`;
44
+ return `CREATE TABLE ${ident(dialect, qualifiedName(t.name, t.schema))} (\n${lines.join(",\n")}\n);`;
28
45
  }
29
46
 
30
47
  function createIndex(dialect: Dialect, table: string, name: string, cols: string[], unique: boolean): string {
@@ -43,13 +60,14 @@ export function renderOperation(dialect: Dialect, op: Operation): string[] {
43
60
 
44
61
  switch (op.kind) {
45
62
  case "createTable": {
63
+ const qn = qualifiedName(op.table.name, op.table.schema);
46
64
  const out = [createTable(dialect, op.table)];
47
65
  for (const idx of op.table.indexes)
48
- out.push(createIndex(dialect, op.table.name, idx.name, idx.columns, idx.unique));
66
+ out.push(createIndex(dialect, qn, idx.name, idx.columns, idx.unique));
49
67
  // Non-SQLite: add FKs after create (SQLite already inlined them).
50
68
  if (dialect !== "sqlite")
51
69
  for (const fk of op.table.foreignKeys)
52
- out.push(`ALTER TABLE ${t(op.table.name)} ADD ${fkClause(dialect, fk)};`);
70
+ out.push(`ALTER TABLE ${t(qn)} ADD ${fkClause(dialect, fk)};`);
53
71
  return out;
54
72
  }
55
73
 
package/src/introspect.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { logicalType, type Dialect } from "./dialect";
2
- import type { ColumnIR, DatabaseIR, ForeignKeyIR, IndexIR, TableIR } from "./ir";
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(db: QueryClient, dialect: Dialect): Promise<DatabaseIR> {
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,93 @@ 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
39
 
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],
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],
50
45
  );
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
46
 
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
- }));
47
+ for (const tr of tables) {
48
+ const name = str(tr.table_name);
49
+ if (isInternal(name)) continue;
67
50
 
68
- const idxRows = await db.query<Row>(
69
- `SELECT i.relname AS index_name, a.attname AS column_name,
70
- ix.indisunique AS is_unique, ix.indisprimary AS is_primary
71
- FROM pg_class t
72
- JOIN pg_index ix ON t.oid = ix.indrelid
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) })),
82
- );
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)));
83
66
 
84
- const fkRows = await db.query<Row>(
85
- `SELECT tc.constraint_name, kcu.column_name,
86
- ccu.table_name AS foreign_table, ccu.column_name AS foreign_column,
87
- rc.update_rule, rc.delete_rule
88
- FROM information_schema.table_constraints tc
89
- JOIN information_schema.key_column_usage kcu
90
- ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
91
- JOIN information_schema.constraint_column_usage ccu
92
- ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
93
- JOIN information_schema.referential_constraints rc
94
- ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.table_schema
95
- WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = $1 AND tc.table_schema = 'public'`,
96
- [name],
97
- );
98
- const foreignKeys: ForeignKeyIR[] = fkRows.map((r) => ({
99
- name: str(r.constraint_name),
100
- column: str(r.column_name),
101
- refTable: str(r.foreign_table),
102
- refColumn: str(r.foreign_column),
103
- onUpdate: str(r.update_rule).toUpperCase(),
104
- onDelete: str(r.delete_rule).toUpperCase(),
105
- }));
67
+ const columns: ColumnIR[] = cols.map((c) => {
68
+ const rawDefault = c.column_default == null ? undefined : str(c.column_default);
69
+ // serial/identity columns default to nextval(...) — surface them as autoincrement.
70
+ const autoincrement = rawDefault != null && /nextval\(/i.test(rawDefault);
71
+ return {
72
+ name: str(c.column_name),
73
+ type: logicalType(str(c.data_type)),
74
+ nullable: str(c.is_nullable) === "YES",
75
+ primary: pkSet.has(str(c.column_name)),
76
+ default: autoincrement ? undefined : rawDefault,
77
+ autoincrement,
78
+ };
79
+ });
106
80
 
107
- ir[name] = { name, columns, indexes, foreignKeys };
81
+ const idxRows = await db.query<Row>(
82
+ `SELECT i.relname AS index_name, a.attname AS column_name,
83
+ ix.indisunique AS is_unique, ix.indisprimary AS is_primary
84
+ FROM pg_class t
85
+ JOIN pg_namespace n ON n.oid = t.relnamespace
86
+ JOIN pg_index ix ON t.oid = ix.indrelid
87
+ JOIN pg_class i ON i.oid = ix.indexrelid
88
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
89
+ WHERE t.relname = $2 AND n.nspname = $1 AND t.relkind = 'r'`,
90
+ [schema, name],
91
+ );
92
+ const indexes = groupIndexes(
93
+ idxRows
94
+ .filter((r) => !truthy(r.is_primary))
95
+ .map((r) => ({ index: str(r.index_name), column: str(r.column_name), unique: truthy(r.is_unique) })),
96
+ );
97
+
98
+ const fkRows = await db.query<Row>(
99
+ `SELECT tc.constraint_name, kcu.column_name,
100
+ ccu.table_schema AS foreign_schema, ccu.table_name AS foreign_table,
101
+ ccu.column_name AS foreign_column, rc.update_rule, rc.delete_rule
102
+ FROM information_schema.table_constraints tc
103
+ JOIN information_schema.key_column_usage kcu
104
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
105
+ JOIN information_schema.constraint_column_usage ccu
106
+ ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
107
+ JOIN information_schema.referential_constraints rc
108
+ ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.table_schema
109
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = $2 AND tc.table_schema = $1`,
110
+ [schema, name],
111
+ );
112
+ const foreignKeys: ForeignKeyIR[] = fkRows.map((r) => ({
113
+ name: str(r.constraint_name),
114
+ column: str(r.column_name),
115
+ refTable: qualifiedName(str(r.foreign_table), str(r.foreign_schema)),
116
+ refColumn: str(r.foreign_column),
117
+ onUpdate: str(r.update_rule).toUpperCase(),
118
+ onDelete: str(r.delete_rule).toUpperCase(),
119
+ }));
120
+
121
+ const s = schema === "public" ? undefined : schema;
122
+ ir[qualifiedName(name, s)] = { name, schema: s, columns, indexes, foreignKeys };
123
+ }
108
124
  }
109
125
  return ir;
110
126
  }
package/src/ir.ts CHANGED
@@ -11,6 +11,8 @@ export interface ColumnIR {
11
11
  primary: boolean;
12
12
  /** default value as a literal; undefined = no default. */
13
13
  default?: unknown;
14
+ /** DB assigns the value (serial/identity/AUTO_INCREMENT). Mutually exclusive with `default`. */
15
+ autoincrement?: boolean;
14
16
  }
15
17
 
16
18
  export interface IndexIR {
@@ -30,15 +32,23 @@ export interface ForeignKeyIR {
30
32
  }
31
33
 
32
34
  export interface TableIR {
35
+ /** Physical table name (unqualified). */
33
36
  name: string;
37
+ /** Owning DB schema. `undefined`/"public" = default schema. */
38
+ schema?: string;
34
39
  columns: ColumnIR[];
35
40
  indexes: IndexIR[];
36
41
  foreignKeys: ForeignKeyIR[];
37
42
  }
38
43
 
39
- /** Keyed by physical table name. */
44
+ /** Keyed by qualified name (see {@link qualifiedName}). */
40
45
  export type DatabaseIR = Record<string, TableIR>;
41
46
 
47
+ /** IR key / physical reference: `table` in the default schema, else `schema.table`. */
48
+ export function qualifiedName(name: string, schema?: string): string {
49
+ return !schema || schema === "public" ? name : `${schema}.${name}`;
50
+ }
51
+
42
52
  // ─── Structural view of the schema DSL ──────────────────────────────────────
43
53
  // We treat the schema object duck-typed (via `_kind`) so the CLI never has to
44
54
  // share a compiled build with agnes-library — only the shape matters.
@@ -51,6 +61,7 @@ interface DslColumn {
51
61
  primary?: boolean;
52
62
  nullable?: boolean;
53
63
  default?: unknown;
64
+ autoincrement?: boolean;
54
65
  index?: { name: string; unique: boolean };
55
66
  };
56
67
  }
@@ -107,12 +118,15 @@ export function schemaToIR(schema: DslSchema): DatabaseIR {
107
118
  for (const fieldKey in def) {
108
119
  const field = def[fieldKey]!;
109
120
  if (field._kind === "column") {
121
+ const autoincrement = field.flags.autoincrement ?? false;
110
122
  columns.push({
111
123
  name: field.name,
112
124
  type: field.type,
113
125
  nullable: field.flags.nullable ?? false,
114
126
  primary: field.flags.primary ?? false,
115
- default: field.flags.default,
127
+ // Auto-increment supplies the value; never emit an explicit default too.
128
+ default: autoincrement ? undefined : field.flags.default,
129
+ autoincrement,
116
130
  });
117
131
  if (field.flags.index) {
118
132
  indexes.push({
@@ -140,7 +154,17 @@ export function schemaToIR(schema: DslSchema): DatabaseIR {
140
154
  // `many` relations have no physical footprint — the FK lives on the target side.
141
155
  }
142
156
 
143
- ir[entry.tableName] = { name: entry.tableName, columns, indexes, foreignKeys };
157
+ // A dotted physical name ("auth.users") carries an explicit schema.
158
+ const dot = entry.tableName.indexOf(".");
159
+ const tblSchema = dot === -1 ? undefined : entry.tableName.slice(0, dot);
160
+ const bare = dot === -1 ? entry.tableName : entry.tableName.slice(dot + 1);
161
+ ir[qualifiedName(bare, tblSchema)] = {
162
+ name: bare,
163
+ schema: tblSchema,
164
+ columns,
165
+ indexes,
166
+ foreignKeys,
167
+ };
144
168
  }
145
169
 
146
170
  return ir;
package/src/print.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ColumnType, DatabaseIR, TableIR } from "./ir";
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,17 +18,58 @@ 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
  }
24
27
 
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));
28
+ /**
29
+ * Render a raw DB default into the `.default(...)` argument for a given column
30
+ * type, or `null` to omit it. Introspection hands us raw strings ("true", "1",
31
+ * "'x'::text", "CURRENT_TIMESTAMP"); we coerce them to the type the DSL expects
32
+ * and drop SQL expressions we can't represent as a literal.
33
+ */
34
+ function renderDefault(type: ColumnType, raw: unknown): string | null {
35
+ if (raw === undefined || raw === null) return null;
36
+ const s = String(raw).trim();
37
+ // Strip a trailing type cast: 'x'::text, 5::integer, 'a'::character varying.
38
+ const cast = s.replace(/::[\w\s"]+$/, "").trim();
39
+ const quoted = cast.match(/^'([\s\S]*)'$/);
40
+ const inner = quoted ? quoted[1]!.replace(/''/g, "'") : undefined;
41
+ const scalar = inner ?? cast;
42
+
43
+ switch (type) {
44
+ case "bool": {
45
+ const t = scalar.toLowerCase();
46
+ if (t === "true" || t === "t" || t === "1") return "true";
47
+ if (t === "false" || t === "f" || t === "0") return "false";
48
+ return null;
49
+ }
50
+ case "int":
51
+ case "float": {
52
+ const n = Number(scalar);
53
+ return scalar !== "" && Number.isFinite(n) ? String(n) : null;
54
+ }
55
+ case "bigint":
56
+ return /^-?\d+$/.test(scalar) ? `${scalar}n` : null;
57
+ case "text":
58
+ case "json":
59
+ // Only string literals; skip expressions (CURRENT_TIMESTAMP, now(), …).
60
+ return inner !== undefined ? JSON.stringify(inner) : null;
61
+ default:
62
+ return null; // bytes
63
+ }
64
+ }
65
+
66
+ /** A qualified name → a valid JS identifier (for the `schema` object keys / relation keys). */
67
+ function idKey(name: string): string {
68
+ return name.replace(/[^A-Za-z0-9_]/g, "_");
29
69
  }
30
70
 
31
71
  function tableSource(t: TableIR): string {
72
+ const physical = qualifiedName(t.name, t.schema);
32
73
  const fkByColumn = new Map(t.foreignKeys.map((fk) => [fk.column, fk]));
33
74
  const lines: string[] = [];
34
75
 
@@ -36,7 +77,12 @@ function tableSource(t: TableIR): string {
36
77
  let expr = `${HELPER[col.type]}(${JSON.stringify(col.name)})`;
37
78
  if (col.primary) expr += ".primary()";
38
79
  else if (col.nullable) expr += ".nullable()";
39
- if (col.default !== undefined) expr += `.default(${literal(col.default)})`;
80
+ if (col.autoincrement) {
81
+ expr += ".autoincrement()";
82
+ } else {
83
+ const d = renderDefault(col.type, col.default);
84
+ if (d !== null) expr += `.default(${d})`;
85
+ }
40
86
  for (const idx of t.indexes) {
41
87
  if (idx.columns.length === 1 && idx.columns[0] === col.name) {
42
88
  expr += idx.unique
@@ -49,26 +95,71 @@ function tableSource(t: TableIR): string {
49
95
 
50
96
  for (const fk of fkByColumn.values()) {
51
97
  // Relation TS key: derive from referenced table (best effort).
52
- const relKey = fk.refTable;
98
+ const relKey = idKey(fk.refTable);
53
99
  lines.push(
54
100
  ` ${relKey}: one(${JSON.stringify(fk.refTable)}, ${JSON.stringify(fk.column)}, ` +
55
101
  `${JSON.stringify(fk.refColumn)}, ${onAction(fk.onUpdate)}, ${onAction(fk.onDelete)}),`,
56
102
  );
57
103
  }
58
104
 
59
- return ` ${t.name}: table({\n${lines.join("\n")}\n }, ${JSON.stringify(t.name)}),`;
105
+ return ` ${idKey(physical)}: table({\n${lines.join("\n")}\n }, ${JSON.stringify(physical)}),`;
60
106
  }
61
107
 
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))
108
+ function schemaBlock(tables: TableIR[]): string {
109
+ const body = tables
110
+ .slice()
111
+ .sort((a, b) => qualifiedName(a.name, a.schema).localeCompare(qualifiedName(b.name, b.schema)))
66
112
  .map(tableSource)
67
113
  .join("\n");
114
+ return `export const schema = {\n${body}\n};\n`;
115
+ }
68
116
 
117
+ /** Render a full DatabaseIR into a single schema.ts source string. */
118
+ export function printSchema(ir: DatabaseIR): string {
69
119
  return (
70
120
  `// 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`
121
+ HEADER +
122
+ `\n` +
123
+ schemaBlock(Object.values(ir))
73
124
  );
74
125
  }
126
+
127
+ export interface SchemaFile {
128
+ /** Bare file name without extension, e.g. "public", "auth". */
129
+ name: string;
130
+ source: string;
131
+ }
132
+
133
+ /**
134
+ * Render a DatabaseIR into one file per DB schema plus an `index` that
135
+ * re-exports every file's tables merged into a single `schema` object.
136
+ */
137
+ export function printSchemaFiles(ir: DatabaseIR): { files: SchemaFile[]; index: string } {
138
+ const bySchema = new Map<string, TableIR[]>();
139
+ for (const t of Object.values(ir)) {
140
+ const key = t.schema ?? "public";
141
+ (bySchema.get(key) ?? bySchema.set(key, []).get(key)!).push(t);
142
+ }
143
+
144
+ const names = [...bySchema.keys()].sort();
145
+ const files: SchemaFile[] = names.map((name) => ({
146
+ name,
147
+ source:
148
+ `// Generated by \`agnes pull\`. Schema "${name}".\n` +
149
+ HEADER +
150
+ `\n` +
151
+ schemaBlock(bySchema.get(name)!),
152
+ }));
153
+
154
+ // Alias with a "Schema" suffix so reserved words (e.g. "public") stay valid bindings.
155
+ const alias = (n: string) => `${idKey(n)}Schema`;
156
+ const imports = names
157
+ .map((n) => `import { schema as ${alias(n)} } from ${JSON.stringify(`./${n}`)};`)
158
+ .join("\n");
159
+ const spread = names.map((n) => ` ...${alias(n)},`).join("\n");
160
+ const index =
161
+ `// Generated by \`agnes pull\`. Merges every per-schema file into one \`schema\`.\n` +
162
+ `${imports}\n\nexport const schema = {\n${spread}\n};\n`;
163
+
164
+ return { files, index };
165
+ }