@typesql/pg 0.1.5 → 0.2.0

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.
@@ -3,15 +3,15 @@ import { dirname, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { parseArgs } from "node:util";
5
5
  import type { PgQueryable } from "../client.js";
6
- import { generateSource, type GenerateOptions } from "./emit.js";
7
- import { introspect, type IntrospectOptions } from "./introspect.js";
6
+ import { type GenerateOptions, generateSource } from "./emit.js";
7
+ import { type IntrospectOptions, introspect } from "./introspect.js";
8
8
 
9
9
  /** What a `--config` module exports as its default: CLI settings plus every generator option. */
10
10
  export interface IntrospectConfig extends GenerateOptions, IntrospectOptions {
11
- /** Connection string. Default: `--url`, then `$DATABASE_URL`. */
12
- readonly url?: string;
13
- /** Output file, relative to the working directory. Default: stdout. */
14
- readonly out?: string;
11
+ /** Connection string. Default: `--url`, then `$DATABASE_URL`. */
12
+ readonly url?: string;
13
+ /** Output file, relative to the working directory. Default: stdout. */
14
+ readonly out?: string;
15
15
  }
16
16
 
17
17
  const USAGE = `Usage: typesql introspect [options]
@@ -32,107 +32,131 @@ Options:
32
32
  `;
33
33
 
34
34
  interface Io {
35
- readonly stdout: (s: string) => void;
36
- readonly stderr: (s: string) => void;
37
- readonly env: Readonly<Record<string, string | undefined>>;
38
- readonly cwd: string;
39
- /** Opens a client for a connection string; default: a `pg.Client`. */
40
- readonly connect?: (url: string) => Promise<PgQueryable & { end(): Promise<void> }>;
35
+ readonly stdout: (s: string) => void;
36
+ readonly stderr: (s: string) => void;
37
+ readonly env: Readonly<Record<string, string | undefined>>;
38
+ readonly cwd: string;
39
+ /** Opens a client for a connection string; default: a `pg.Client`. */
40
+ readonly connect?: (
41
+ url: string,
42
+ ) => Promise<PgQueryable & { end(): Promise<void> }>;
41
43
  }
42
44
 
43
45
  const defaultIo = (): Io => ({
44
- stdout: (s) => process.stdout.write(s),
45
- stderr: (s) => process.stderr.write(s),
46
- env: process.env,
47
- cwd: process.cwd(),
46
+ stdout: (s) => process.stdout.write(s),
47
+ stderr: (s) => process.stderr.write(s),
48
+ env: process.env,
49
+ cwd: process.cwd(),
48
50
  });
49
51
 
50
- async function pgConnect(url: string): Promise<PgQueryable & { end(): Promise<void> }> {
51
- let Client: typeof import("pg").Client;
52
- try {
53
- ({ Client } = (await import("pg")).default);
54
- } catch {
55
- throw new Error('typesql introspect needs the "pg" package: npm install pg');
56
- }
57
- const client = new Client({ connectionString: url });
58
- await client.connect();
59
- return client;
52
+ async function pgConnect(
53
+ url: string,
54
+ ): Promise<PgQueryable & { end(): Promise<void> }> {
55
+ let Client: typeof import("pg").Client;
56
+ try {
57
+ ({ Client } = (await import("pg")).default);
58
+ } catch {
59
+ throw new Error(
60
+ 'typesql introspect needs the "pg" package: npm install pg',
61
+ );
62
+ }
63
+ const client = new Client({ connectionString: url });
64
+ await client.connect();
65
+ return client;
60
66
  }
61
67
 
68
+ const parse = (argv: readonly string[]) =>
69
+ parseArgs({
70
+ args: [...argv],
71
+ allowPositionals: true,
72
+ options: {
73
+ url: { type: "string" },
74
+ out: { type: "string" },
75
+ schema: { type: "string", multiple: true },
76
+ exclude: { type: "string", multiple: true },
77
+ "type-name": { type: "string" },
78
+ "import-from": { type: "string" },
79
+ config: { type: "string" },
80
+ "no-comments": { type: "boolean" },
81
+ help: { type: "boolean", short: "h" },
82
+ },
83
+ });
84
+
62
85
  /** Runs the `typesql` CLI with `argv` (without `node` and the script). Returns the exit code. */
63
- export async function main(argv: readonly string[], io: Io = defaultIo()): Promise<number> {
64
- let parsed;
65
- try {
66
- parsed = parseArgs({
67
- args: [...argv],
68
- allowPositionals: true,
69
- options: {
70
- url: { type: "string" },
71
- out: { type: "string" },
72
- schema: { type: "string", multiple: true },
73
- exclude: { type: "string", multiple: true },
74
- "type-name": { type: "string" },
75
- "import-from": { type: "string" },
76
- config: { type: "string" },
77
- "no-comments": { type: "boolean" },
78
- help: { type: "boolean", short: "h" },
79
- },
80
- });
81
- } catch (err) {
82
- io.stderr(`${(err as Error).message}\n\n${USAGE}`);
83
- return 2;
84
- }
85
- const { values, positionals } = parsed;
86
- if (values.help || positionals.length === 0) {
87
- (values.help ? io.stdout : io.stderr)(USAGE);
88
- return values.help ? 0 : 2;
89
- }
90
- if (positionals[0] !== "introspect" || positionals.length > 1) {
91
- io.stderr(`unknown command: ${positionals.join(" ")}\n\n${USAGE}`);
92
- return 2;
93
- }
86
+ export async function main(
87
+ argv: readonly string[],
88
+ io: Io = defaultIo(),
89
+ ): Promise<number> {
90
+ let parsed: ReturnType<typeof parse>;
91
+ try {
92
+ parsed = parse(argv);
93
+ } catch (err) {
94
+ io.stderr(`${(err as Error).message}\n\n${USAGE}`);
95
+ return 2;
96
+ }
97
+ const { values, positionals } = parsed;
98
+ if (values.help || positionals.length === 0) {
99
+ (values.help ? io.stdout : io.stderr)(USAGE);
100
+ return values.help ? 0 : 2;
101
+ }
102
+ if (positionals[0] !== "introspect" || positionals.length > 1) {
103
+ io.stderr(`unknown command: ${positionals.join(" ")}\n\n${USAGE}`);
104
+ return 2;
105
+ }
94
106
 
95
- try {
96
- let config: IntrospectConfig = {};
97
- if (values.config) {
98
- const mod = (await import(pathToFileURL(resolve(io.cwd, values.config)).href)) as { default?: IntrospectConfig };
99
- config = mod.default ?? {};
100
- }
101
- const url = values.url ?? config.url ?? io.env["DATABASE_URL"];
102
- if (!url) {
103
- io.stderr("typesql introspect: pass --url or set DATABASE_URL\n");
104
- return 2;
105
- }
106
- const list = (v: string[] | undefined) => v?.flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
107
- const schemas = list(values.schema) ?? config.schemas;
108
- const exclude = list(values.exclude) ?? config.exclude;
109
- const options: IntrospectConfig = {
110
- ...config,
111
- ...(schemas !== undefined && { schemas }),
112
- ...(exclude !== undefined && { exclude }),
113
- ...(values["type-name"] !== undefined && { typeName: values["type-name"] }),
114
- ...(values["import-from"] !== undefined && { importFrom: values["import-from"] }),
115
- ...(values["no-comments"] && { comments: false }),
116
- };
117
- const client = await (io.connect ?? pgConnect)(url);
118
- let source: string;
119
- try {
120
- source = generateSource(await introspect(client, options), options);
121
- } finally {
122
- await client.end();
123
- }
124
- const out = values.out ?? config.out;
125
- if (out === undefined) {
126
- io.stdout(source);
127
- } else {
128
- const file = resolve(io.cwd, out);
129
- await mkdir(dirname(file), { recursive: true });
130
- await writeFile(file, source);
131
- io.stderr(`typesql introspect: wrote ${out}\n`);
132
- }
133
- return 0;
134
- } catch (err) {
135
- io.stderr(`typesql introspect: ${(err as Error).message}\n`);
136
- return 1;
137
- }
107
+ try {
108
+ let config: IntrospectConfig = {};
109
+ if (values.config) {
110
+ const mod = (await import(
111
+ pathToFileURL(resolve(io.cwd, values.config)).href
112
+ )) as { default?: IntrospectConfig };
113
+ config = mod.default ?? {};
114
+ }
115
+ const url = values.url ?? config.url ?? io.env["DATABASE_URL"];
116
+ if (!url) {
117
+ io.stderr("typesql introspect: pass --url or set DATABASE_URL\n");
118
+ return 2;
119
+ }
120
+ const list = (v: string[] | undefined) =>
121
+ v?.flatMap((s) =>
122
+ s
123
+ .split(",")
124
+ .map((x) => x.trim())
125
+ .filter(Boolean),
126
+ );
127
+ const schemas = list(values.schema) ?? config.schemas;
128
+ const exclude = list(values.exclude) ?? config.exclude;
129
+ const options: IntrospectConfig = {
130
+ ...config,
131
+ ...(schemas !== undefined && { schemas }),
132
+ ...(exclude !== undefined && { exclude }),
133
+ ...(values["type-name"] !== undefined && {
134
+ typeName: values["type-name"],
135
+ }),
136
+ ...(values["import-from"] !== undefined && {
137
+ importFrom: values["import-from"],
138
+ }),
139
+ ...(values["no-comments"] && { comments: false }),
140
+ };
141
+ const client = await (io.connect ?? pgConnect)(url);
142
+ let source: string;
143
+ try {
144
+ source = generateSource(await introspect(client, options), options);
145
+ } finally {
146
+ await client.end();
147
+ }
148
+ const out = values.out ?? config.out;
149
+ if (out === undefined) {
150
+ io.stdout(source);
151
+ } else {
152
+ const file = resolve(io.cwd, out);
153
+ await mkdir(dirname(file), { recursive: true });
154
+ await writeFile(file, source);
155
+ io.stderr(`typesql introspect: wrote ${out}\n`);
156
+ }
157
+ return 0;
158
+ } catch (err) {
159
+ io.stderr(`typesql introspect: ${(err as Error).message}\n`);
160
+ return 1;
161
+ }
138
162
  }
@@ -1,53 +1,79 @@
1
- import type { IntrospectedColumn, IntrospectedDatabase, IntrospectedRelation } from "./introspect.js";
1
+ import type {
2
+ IntrospectedColumn,
3
+ IntrospectedDatabase,
4
+ IntrospectedRelation,
5
+ } from "./introspect.js";
2
6
 
3
7
  export interface GenerateOptions {
4
- /** Name of the exported schema interface. Default `"DB"`. */
5
- readonly typeName?: string;
6
- /** Module the column helpers are imported from. Default `"@typesql/core"`. */
7
- readonly importFrom?: string;
8
- /** Extra lines after the helper import, e.g. `import type { HistoryItem } from "./json.js";`. */
9
- readonly imports?: readonly string[];
10
- /**
11
- * TypeScript types of `json` / `jsonb` columns: a map keyed `"table.column"` (public) or
12
- * `"schema.table.column"`, or a function returning the TS type source for a column (or
13
- * `undefined`). Such a column is declared as `Json<T>`: still jsonb to the SQL, read as `T`,
14
- * written with `sql.json(value)`. Other columns are an error (use `column` for those).
15
- */
16
- readonly columnTypes?:
17
- | Readonly<Record<string, string>>
18
- | ((column: IntrospectedColumn, relation: IntrospectedRelation) => string | undefined);
19
- /**
20
- * Full control over one column: return the TypeScript source of its descriptor
21
- * (`Col<"jsonb">`, a helper of your own, ...) or `undefined` to keep the default.
22
- */
23
- readonly column?: (column: IntrospectedColumn, relation: IntrospectedRelation) => string | undefined;
24
- /** Emit table and column comments and notes as JSDoc. Default `true`. */
25
- readonly comments?: boolean;
8
+ /** Name of the exported schema interface. Default `"DB"`. */
9
+ readonly typeName?: string;
10
+ /** Module the column helpers are imported from. Default `"@typesql/core"`. */
11
+ readonly importFrom?: string;
12
+ /** Extra lines after the helper import, e.g. `import type { HistoryItem } from "./json.js";`. */
13
+ readonly imports?: readonly string[];
14
+ /**
15
+ * TypeScript types of `json` / `jsonb` columns: a map keyed `"table.column"` (public) or
16
+ * `"schema.table.column"`, or a function returning the TS type source for a column (or
17
+ * `undefined`). Such a column is declared as `Json<T>`: still jsonb to the SQL, read as `T`,
18
+ * written with `sql.json(value)`. Other columns are an error (use `column` for those).
19
+ */
20
+ readonly columnTypes?:
21
+ | Readonly<Record<string, string>>
22
+ | ((
23
+ column: IntrospectedColumn,
24
+ relation: IntrospectedRelation,
25
+ ) => string | undefined);
26
+ /**
27
+ * Full control over one column: return the TypeScript source of its descriptor
28
+ * (`Col<"jsonb">`, a helper of your own, ...) or `undefined` to keep the default.
29
+ */
30
+ readonly column?: (
31
+ column: IntrospectedColumn,
32
+ relation: IntrospectedRelation,
33
+ ) => string | undefined;
34
+ /** Emit table and column comments and notes as JSDoc. Default `true`. */
35
+ readonly comments?: boolean;
26
36
  }
27
37
 
28
38
  const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
29
- const key = (name: string): string => (IDENT.test(name) ? name : JSON.stringify(name));
39
+ const key = (name: string): string =>
40
+ IDENT.test(name) ? name : JSON.stringify(name);
30
41
  const str = (s: string): string => JSON.stringify(s);
31
42
 
32
43
  function doc(lines: readonly string[], indent: string): string {
33
- const text = lines.flatMap((l) => l.split("\n")).map((l) => l.replace(/\*\//g, "*\\/").trimEnd());
34
- if (text.length === 0) return "";
35
- if (text.length === 1) return `${indent}/** ${text[0]} */\n`;
36
- return `${indent}/**\n${text.map((l) => `${indent} *${l ? ` ${l}` : ""}`).join("\n")}\n${indent} */\n`;
44
+ const text = lines
45
+ .flatMap((l) => l.split("\n"))
46
+ .map((l) => l.replace(/\*\//g, "*\\/").trimEnd());
47
+ if (text.length === 0) return "";
48
+ if (text.length === 1) return `${indent}/** ${text[0]} */\n`;
49
+ return `${indent}/**\n${text.map((l) => `${indent} *${l ? ` ${l}` : ""}`).join("\n")}\n${indent} */\n`;
37
50
  }
38
51
 
39
52
  type Helper = "Col" | "Nullable" | "Generated" | "NullableGenerated" | "Json";
40
53
  const helperFor = (nullable: boolean, hasDefault: boolean): Helper =>
41
- nullable ? (hasDefault ? "NullableGenerated" : "Nullable") : hasDefault ? "Generated" : "Col";
54
+ nullable
55
+ ? hasDefault
56
+ ? "NullableGenerated"
57
+ : "Nullable"
58
+ : hasDefault
59
+ ? "Generated"
60
+ : "Col";
42
61
 
43
62
  function columnNotes(c: IntrospectedColumn): string[] {
44
- const notes: string[] = [];
45
- if (c.comment) notes.push(c.comment);
46
- if (c.domain) notes.push(`Domain \`${c.domain}\`.`);
47
- else if (c.declaredType === "citext") notes.push("`citext`: typed as `text`.");
48
- if (c.generated !== null) notes.push(`\`GENERATED ALWAYS AS (${c.generated}) STORED\`: cannot be written.`);
49
- if (c.identity === "always") notes.push("`GENERATED ALWAYS AS IDENTITY`: written only with `OVERRIDING SYSTEM VALUE`.");
50
- return notes;
63
+ const notes: string[] = [];
64
+ if (c.comment) notes.push(c.comment);
65
+ if (c.domain) notes.push(`Domain \`${c.domain}\`.`);
66
+ else if (c.declaredType === "citext")
67
+ notes.push("`citext`: typed as `text`.");
68
+ if (c.generated !== null)
69
+ notes.push(
70
+ `\`GENERATED ALWAYS AS (${c.generated}) STORED\`: cannot be written.`,
71
+ );
72
+ if (c.identity === "always")
73
+ notes.push(
74
+ "`GENERATED ALWAYS AS IDENTITY`: written only with `OVERRIDING SYSTEM VALUE`.",
75
+ );
76
+ return notes;
51
77
  }
52
78
 
53
79
  /**
@@ -55,114 +81,173 @@ function columnNotes(c: IntrospectedColumn): string[] {
55
81
  * relations at the top level, other schemas nested (`DB["auth"]["sessions"]`), views and
56
82
  * materialized views as `View<…>`, enums under `$types`.
57
83
  */
58
- export function generateSource(db: IntrospectedDatabase, options: GenerateOptions = {}): string {
59
- const typeName = options.typeName ?? "DB";
60
- const comments = options.comments ?? true;
61
- const columnTypes = options.columnTypes ?? {};
62
- const used = new Set<string>();
63
- const usedKeys = new Set<string>();
84
+ export function generateSource(
85
+ db: IntrospectedDatabase,
86
+ options: GenerateOptions = {},
87
+ ): string {
88
+ const typeName = options.typeName ?? "DB";
89
+ const comments = options.comments ?? true;
90
+ const columnTypes = options.columnTypes ?? {};
91
+ const used = new Set<string>();
92
+ const usedKeys = new Set<string>();
64
93
 
65
- /** The TS type `columnTypes` gives a column, if any. */
66
- const customType = (rel: IntrospectedRelation, col: IntrospectedColumn): string | undefined => {
67
- const qualified = `${rel.schema}.${rel.name}.${col.name}`;
68
- const short = `${rel.name}.${col.name}`;
69
- let ts: string | undefined;
70
- if (typeof columnTypes === "function") ts = columnTypes(col, rel);
71
- else {
72
- const k = qualified in columnTypes ? qualified : rel.schema === "public" && short in columnTypes ? short : undefined;
73
- if (k !== undefined) {
74
- usedKeys.add(k);
75
- ts = columnTypes[k];
76
- }
77
- }
78
- if (ts !== undefined && col.type !== "json" && col.type !== "jsonb") {
79
- throw new Error(`columnTypes: ${JSON.stringify(rel.schema === "public" ? short : qualified)} has type ${col.type}; columnTypes only types json / jsonb columns (use the column hook for others)`);
80
- }
81
- return ts;
82
- };
94
+ /** The TS type `columnTypes` gives a column, if any. */
95
+ const customType = (
96
+ rel: IntrospectedRelation,
97
+ col: IntrospectedColumn,
98
+ ): string | undefined => {
99
+ const qualified = `${rel.schema}.${rel.name}.${col.name}`;
100
+ const short = `${rel.name}.${col.name}`;
101
+ let ts: string | undefined;
102
+ if (typeof columnTypes === "function") ts = columnTypes(col, rel);
103
+ else {
104
+ const k =
105
+ qualified in columnTypes
106
+ ? qualified
107
+ : rel.schema === "public" && short in columnTypes
108
+ ? short
109
+ : undefined;
110
+ if (k !== undefined) {
111
+ usedKeys.add(k);
112
+ ts = columnTypes[k];
113
+ }
114
+ }
115
+ if (ts !== undefined && col.type !== "json" && col.type !== "jsonb") {
116
+ throw new Error(
117
+ `columnTypes: ${JSON.stringify(rel.schema === "public" ? short : qualified)} has type ${col.type}; columnTypes only types json / jsonb columns (use the column hook for others)`,
118
+ );
119
+ }
120
+ return ts;
121
+ };
83
122
 
84
- /** `Json<T>`, `Json<T, { nullable: true }>`, `Json<T, {}, "json">`, … */
85
- const jsonDescriptor = (col: IntrospectedColumn, ts: string): string => {
86
- const flags = [...(col.nullable ? ["nullable: true"] : []), ...(col.hasDefault ? ["hasDefault: true"] : [])];
87
- const flagSource = flags.length > 0 ? `{ ${flags.join("; ")} }` : "{}";
88
- used.add("Json");
89
- if (col.type === "json") return `Json<${ts}, ${flagSource}, "json">`;
90
- return flags.length > 0 ? `Json<${ts}, ${flagSource}>` : `Json<${ts}>`;
91
- };
123
+ /** `Json<T>`, `Json<T, { nullable: true }>`, `Json<T, {}, "json">`, … */
124
+ const jsonDescriptor = (col: IntrospectedColumn, ts: string): string => {
125
+ const flags = [
126
+ ...(col.nullable ? ["nullable: true"] : []),
127
+ ...(col.hasDefault ? ["hasDefault: true"] : []),
128
+ ];
129
+ const flagSource = flags.length > 0 ? `{ ${flags.join("; ")} }` : "{}";
130
+ used.add("Json");
131
+ if (col.type === "json") return `Json<${ts}, ${flagSource}, "json">`;
132
+ return flags.length > 0 ? `Json<${ts}, ${flagSource}>` : `Json<${ts}>`;
133
+ };
92
134
 
93
- const relation = (rel: IntrospectedRelation, indent: string): string => {
94
- const isView = rel.kind === "view" || rel.kind === "materialized view";
95
- const cols = rel.columns.map((c) => {
96
- let descriptor = options.column?.(c, rel);
97
- if (descriptor === undefined) {
98
- const custom = customType(rel, c);
99
- if (custom !== undefined) descriptor = jsonDescriptor(c, custom);
100
- else {
101
- const helper = helperFor(c.nullable, c.hasDefault);
102
- used.add(helper);
103
- descriptor = `${helper}<${str(c.type)}>`;
104
- }
105
- }
106
- return `${comments ? doc(columnNotes(c), `${indent} `) : ""}${indent} ${key(c.name)}: ${descriptor};`;
107
- });
108
- const body = `{\n${cols.join("\n")}\n${indent}}`;
109
- if (!isView) return body;
110
- used.add("View");
111
- return `View<${body}>`;
112
- };
135
+ const relation = (rel: IntrospectedRelation, indent: string): string => {
136
+ const isView = rel.kind === "view" || rel.kind === "materialized view";
137
+ const cols = rel.columns.map((c) => {
138
+ let descriptor = options.column?.(c, rel);
139
+ if (descriptor === undefined) {
140
+ const custom = customType(rel, c);
141
+ if (custom !== undefined) descriptor = jsonDescriptor(c, custom);
142
+ else {
143
+ const helper = helperFor(c.nullable, c.hasDefault);
144
+ used.add(helper);
145
+ descriptor = `${helper}<${str(c.type)}>`;
146
+ }
147
+ }
148
+ return `${comments ? doc(columnNotes(c), `${indent} `) : ""}${indent} ${key(c.name)}: ${descriptor};`;
149
+ });
150
+ const body = `{\n${cols.join("\n")}\n${indent}}`;
151
+ if (!isView) return body;
152
+ used.add("View");
153
+ return `View<${body}>`;
154
+ };
113
155
 
114
- const relDoc = (rel: IntrospectedRelation, indent: string): string => {
115
- if (!comments) return "";
116
- const kind = rel.kind === "table" ? [] : [`${rel.kind[0]!.toUpperCase()}${rel.kind.slice(1)}.`];
117
- return doc([...(rel.comment ? [rel.comment] : []), ...kind], indent);
118
- };
156
+ const relDoc = (rel: IntrospectedRelation, indent: string): string => {
157
+ if (!comments) return "";
158
+ const kind =
159
+ rel.kind === "table"
160
+ ? []
161
+ : [`${rel.kind[0]!.toUpperCase()}${rel.kind.slice(1)}.`];
162
+ return doc([...(rel.comment ? [rel.comment] : []), ...kind], indent);
163
+ };
119
164
 
120
- const topLevel = new Set(db.relations.filter((r) => r.schema === "public").map((r) => r.name));
121
- const lines: string[] = [];
122
- const namespaces = new Map<string, IntrospectedRelation[]>();
123
- for (const rel of db.relations) {
124
- if (rel.schema === "public") lines.push(`${relDoc(rel, " ")} ${key(rel.name)}: ${relation(rel, " ")};`);
125
- else namespaces.set(rel.schema, [...(namespaces.get(rel.schema) ?? []), rel]);
126
- }
127
- for (const [schema, rels] of namespaces) {
128
- if (topLevel.has(schema)) {
129
- throw new Error(`schema "${schema}" has the same name as the table "public.${schema}": TypeSQL keeps public relations and schemas in one object`);
130
- }
131
- const body = rels.map((r) => `${relDoc(r, " ")} ${key(r.name)}: ${relation(r, " ")};`);
132
- lines.push(` ${key(schema)}: {\n${body.join("\n")}\n };`);
133
- }
165
+ const topLevel = new Set(
166
+ db.relations.filter((r) => r.schema === "public").map((r) => r.name),
167
+ );
168
+ const lines: string[] = [];
169
+ const namespaces = new Map<string, IntrospectedRelation[]>();
170
+ for (const rel of db.relations) {
171
+ if (rel.schema === "public")
172
+ lines.push(
173
+ `${relDoc(rel, " ")} ${key(rel.name)}: ${relation(rel, " ")};`,
174
+ );
175
+ else
176
+ namespaces.set(rel.schema, [...(namespaces.get(rel.schema) ?? []), rel]);
177
+ }
178
+ for (const [schema, rels] of namespaces) {
179
+ if (topLevel.has(schema)) {
180
+ throw new Error(
181
+ `schema "${schema}" has the same name as the table "public.${schema}": TypeSQL keeps public relations and schemas in one object`,
182
+ );
183
+ }
184
+ const body = rels.map(
185
+ (r) => `${relDoc(r, " ")} ${key(r.name)}: ${relation(r, " ")};`,
186
+ );
187
+ lines.push(` ${key(schema)}: {\n${body.join("\n")}\n };`);
188
+ }
134
189
 
135
- if (db.enums.length > 0) {
136
- // TypeSQL's enum registry is flat (`Col<"mood">`, schema qualifiers are dropped), so enums
137
- // of the same name in different schemas share an entry.
138
- const byName = new Map<string, { values: Set<string>; schemas: string[] }>();
139
- for (const e of db.enums) {
140
- const entry = byName.get(e.name) ?? { values: new Set<string>(), schemas: [] };
141
- for (const v of e.values) entry.values.add(v);
142
- entry.schemas.push(e.schema);
143
- byName.set(e.name, entry);
144
- }
145
- const entries = [...byName].map(([name, { values, schemas }]) => {
146
- const notes = comments && schemas.length > 1 ? doc([`Enums of the same name in ${schemas.map((s) => `\`${s}\``).join(", ")}.`], " ") : "";
147
- return `${notes} ${key(name)}: ${[...values].map(str).join(" | ") || "never"};`;
148
- });
149
- lines.push(` $types: {\n${entries.join("\n")}\n };`);
150
- }
190
+ if (db.enums.length > 0) {
191
+ // TypeSQL's enum registry is flat (`Col<"mood">`, schema qualifiers are dropped), so enums
192
+ // of the same name in different schemas share an entry.
193
+ const byName = new Map<
194
+ string,
195
+ { values: Set<string>; schemas: string[] }
196
+ >();
197
+ for (const e of db.enums) {
198
+ const entry = byName.get(e.name) ?? {
199
+ values: new Set<string>(),
200
+ schemas: [],
201
+ };
202
+ for (const v of e.values) entry.values.add(v);
203
+ entry.schemas.push(e.schema);
204
+ byName.set(e.name, entry);
205
+ }
206
+ const entries = [...byName].map(([name, { values, schemas }]) => {
207
+ const notes =
208
+ comments && schemas.length > 1
209
+ ? doc(
210
+ [
211
+ `Enums of the same name in ${schemas.map((s) => `\`${s}\``).join(", ")}.`,
212
+ ],
213
+ " ",
214
+ )
215
+ : "";
216
+ return `${notes} ${key(name)}: ${[...values].map(str).join(" | ") || "never"};`;
217
+ });
218
+ lines.push(` $types: {\n${entries.join("\n")}\n };`);
219
+ }
151
220
 
152
- const helpers = ["Col", "Generated", "Json", "Nullable", "NullableGenerated", "View"].filter((h) => used.has(h));
153
- const out = [
154
- `// Generated by \`typesql introspect\` (@typesql/pg) from PostgreSQL ${db.version.split(/[.\s]/)[0]}. Do not edit.`,
155
- ...(helpers.length > 0 ? [`import type { ${helpers.join(", ")} } from ${str(options.importFrom ?? "@typesql/core")};`] : []),
156
- ...(options.imports ?? []),
157
- "",
158
- `export interface ${key(typeName)} {`,
159
- ...lines,
160
- "}",
161
- "",
162
- ];
163
- const unknownKeys = typeof columnTypes === "function" ? [] : Object.keys(columnTypes).filter((k) => !usedKeys.has(k));
164
- if (unknownKeys.length > 0 && options.column === undefined) {
165
- throw new Error(`columnTypes: no column ${unknownKeys.map((k) => JSON.stringify(k)).join(", ")} (keys are "table.column" for public tables, else "schema.table.column")`);
166
- }
167
- return out.join("\n");
221
+ const helpers = [
222
+ "Col",
223
+ "Generated",
224
+ "Json",
225
+ "Nullable",
226
+ "NullableGenerated",
227
+ "View",
228
+ ].filter((h) => used.has(h));
229
+ const out = [
230
+ `// Generated by \`typesql introspect\` (@typesql/pg) from PostgreSQL ${db.version.split(/[.\s]/)[0]}. Do not edit.`,
231
+ ...(helpers.length > 0
232
+ ? [
233
+ `import type { ${helpers.join(", ")} } from ${str(options.importFrom ?? "@typesql/core")};`,
234
+ ]
235
+ : []),
236
+ ...(options.imports ?? []),
237
+ "",
238
+ `export interface ${key(typeName)} {`,
239
+ ...lines,
240
+ "}",
241
+ "",
242
+ ];
243
+ const unknownKeys =
244
+ typeof columnTypes === "function"
245
+ ? []
246
+ : Object.keys(columnTypes).filter((k) => !usedKeys.has(k));
247
+ if (unknownKeys.length > 0 && options.column === undefined) {
248
+ throw new Error(
249
+ `columnTypes: no column ${unknownKeys.map((k) => JSON.stringify(k)).join(", ")} (keys are "table.column" for public tables, else "schema.table.column")`,
250
+ );
251
+ }
252
+ return out.join("\n");
168
253
  }