agnes-cli 0.0.4 → 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 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,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: "sqlite:./demo.db",
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 | Output schema file |
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 |
@@ -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.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": "0.0.4"
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":
@@ -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/generate.ts CHANGED
@@ -1,5 +1,5 @@
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 {
@@ -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, op.table.name, idx.name, idx.columns, idx.unique));
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(op.table.name)} ADD ${fkClause(dialect, fk)};`);
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 { 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,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
- 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) })),
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 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
- }));
106
-
107
- ir[name] = { name, columns, indexes, foreignKeys };
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 physical table name. */
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
- ir[entry.tableName] = { name: entry.tableName, columns, indexes, foreignKeys };
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 { 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,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 ` ${t.name}: table({\n${lines.join("\n")}\n }, ${JSON.stringify(t.name)}),`;
68
+ return ` ${idKey(physical)}: table({\n${lines.join("\n")}\n }, ${JSON.stringify(physical)}),`;
60
69
  }
61
70
 
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))
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
- `import { table, int, bigint, text, bool, float, bytes, json, one, OnAction } from "agnes-library";\n\n` +
72
- `export const schema = {\n${tables}\n};\n`
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
+ }