true-pg 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - v*
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+
12
+ permissions:
13
+ contents: read
14
+ id-token: write
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: oven-sh/setup-bun@v2
19
+ - run : bun install
20
+ - run : bun run prepare
21
+ - name: Publish to npm
22
+ run : |
23
+ bun run version.ts
24
+ bun publish --ignore-scripts --access=public
25
+ env :
26
+ NPM_CONFIG_TOKEN: ${{ secrets.NPM_CONFIG_TOKEN }}
27
+ NPM_CONFIG_PROVENANCE: true
28
+ - name: Publish to JSR
29
+ run: bunx jsr publish --allow-dirty
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # true-pg
2
+
3
+ A truthful and complete<sup>†</sup> TypeScript code generator for PostgreSQL database schemas.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install true-pg
9
+ # or
10
+ yarn add true-pg
11
+ # or
12
+ bun add true-pg
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### Command Line Interface
18
+
19
+ ```bash
20
+ true-pg [options]
21
+ ```
22
+
23
+ Options:
24
+
25
+ - `-h, --help` - Show help information
26
+ - `-c, --config [path]` - Path to config file (JSON)
27
+ - `-u, --uri [uri]` - Database URI (Postgres only!)
28
+ - `-o, --out [path]` - Path to output directory (defaults to "models")
29
+ - `-a, --adapter [adapter]` - Adapter to use (e.g. `kysely`, `zod`). Can be specified multiple times.
30
+ - `-A, --all-adapters` - Enable all built-in adapters
31
+
32
+ You can configure true-pg either through command-line arguments or a config file.
33
+
34
+ ### Configuration file
35
+
36
+ The tool looks for configuration in the following locations (in order):
37
+
38
+ 1. `.truepgrc.json`
39
+ 2. `.config/.truepgrc.json`
40
+
41
+ Example config file:
42
+
43
+ ```json
44
+ {
45
+ "uri": "postgres://user:password@localhost:5432/database",
46
+ "out": "src/models",
47
+ "adapters": ["kysely", "zod"],
48
+ "defaultSchema": "public"
49
+ }
50
+ ```
51
+
52
+ ## Configuration Options
53
+
54
+ | Option | Description | Default |
55
+ | --------------- | -------------------------------------------------------- | ---------- |
56
+ | `uri` | PostgreSQL connection URI | Required |
57
+ | `out` | Output directory for generated files | `"models"` |
58
+ | `adapters` | Adapters to use (e.g. `kysely`, `zod`) | `"kysely"` |
59
+ | `defaultSchema` | Default schema to use (Kysely schema will be unprefixed) | `"public"` |
60
+
61
+ ## Customising Code Generation
62
+
63
+ > 🔔 HERE BE DRAGONS!
64
+ >
65
+ > Keep in mind that programmatic usage of `true-pg` is not yet stable. Functions and methods may change until we're comfortable with the API.
66
+ >
67
+ > However, if you're interested, we welcome your feedback and contributions!
68
+
69
+ You can create a custom generator to control how code is generated:
70
+
71
+ ```typescript
72
+ import { createGenerator, generate } from "true-pg";
73
+
74
+ const generator = createGenerator(opts => ({
75
+ formatSchema: name => `${name}Schema`,
76
+ formatSchemaType: type => `${type}Type`,
77
+ formatType: type => `${type}Interface`,
78
+ table: (imports, table) => {
79
+ // Custom table type generation
80
+ },
81
+ enum: (imports, en) => {
82
+ // Custom enum type generation
83
+ },
84
+ composite: (imports, composite) => {
85
+ // Custom composite type generation
86
+ },
87
+ function: (imports, func) => {
88
+ // Custom function type generation
89
+ },
90
+ schemaKindIndex: (schema, kind) => {
91
+ // Custom schema kind index generation
92
+ },
93
+ schemaIndex: schema => {
94
+ // Custom schema index generation
95
+ },
96
+ fullIndex: schemas => {
97
+ // Custom full index generation
98
+ },
99
+ }));
100
+
101
+ await generate(
102
+ {
103
+ uri: "postgres://user:password@localhost:5432/database",
104
+ out: "src/models",
105
+ },
106
+ [generator],
107
+ );
108
+ ```
109
+
110
+ Filenames will be created using the `format*` methods of the FIRST generator passed to `generate` or via the `--adapter` CLI option.
111
+
112
+ ## Schema Generator Interface
113
+
114
+ The `SchemaGenerator` interface provides methods to customize code generation:
115
+
116
+ | Method | Description |
117
+ | ------------------------------- | ----------------------------------------------------------------- |
118
+ | `formatSchema(name)` | Formats schema names (public -> PublicSchema) |
119
+ | `formatSchemaType(type)` | Formats schema type names (user_sessions -> UserSessions) |
120
+ | `formatType(type)` | Formats type names (pg_catalog.int4 -> number) |
121
+ | `table(types, table)` | Generates code for tables |
122
+ | `enum(types, en)` | Generates code for enums |
123
+ | `composite(types, composite)` | Generates code for composite types |
124
+ | `function(types, func)` | Generates code for functions |
125
+ | `schemaKindIndex(schema, kind)` | Generates index for a schema kind (models/public/tables/index.ts) |
126
+ | `schemaIndex(schema)` | Generates index for a schema (models/public/index.ts) |
127
+ | `fullIndex(schemas)` | Generates full index (models/index.ts) |
128
+
129
+ ## License
130
+
131
+ [MIT](LICENSE)
132
+
133
+ <sup>†</sup> We only support tables, enums, composite types, and functions at the moment, but we're working on adding support for views, materialised views, domains, and more.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "true-pg",
3
+ "version": "0.0.1",
4
+ "module": "src/index.ts",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "bin": {
8
+ "true-pg": "src/bin.ts"
9
+ },
10
+ "scripts": {
11
+ "check": "tsc --noEmit",
12
+ "build": "tsc"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "latest"
16
+ },
17
+ "peerDependencies": {
18
+ "kysely": "^0.27",
19
+ "typescript": "^5",
20
+ "zod": "^3"
21
+ },
22
+ "dependencies": {
23
+ "mri": "^1.2.0",
24
+ "pg-extract": "^0.0.3"
25
+ }
26
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,81 @@
1
+ import mri from "mri";
2
+ import { existsSync } from "fs";
3
+ import { generate, adapters } from "./index.ts";
4
+
5
+ const args = process.argv.slice(2);
6
+ const opts = mri<{
7
+ "help"?: boolean;
8
+ "config"?: string;
9
+ "uri"?: string;
10
+ "out"?: string;
11
+ "adapter"?: string | string[];
12
+ "all-adapters"?: boolean;
13
+ }>(args, {
14
+ boolean: ["help", "all-adapters"],
15
+ string: ["config", "uri", "out", "adapter"],
16
+ alias: {
17
+ h: "help",
18
+ c: "config",
19
+ u: "uri",
20
+ o: "out",
21
+ a: "adapter",
22
+ A: "all-adapters",
23
+ },
24
+ });
25
+
26
+ const help = opts.help || (!opts.config && !opts.uri);
27
+
28
+ if (help) {
29
+ // if help is triggered unintentionally, it's a user error
30
+ const type = opts.help ? "log" : "error";
31
+ const log = console[type];
32
+
33
+ log();
34
+ log("Usage: true-pg [options]");
35
+ log();
36
+ log("Options:");
37
+ log(" -h, --help Show help");
38
+ log(" -u, --uri [uri] Database URI (Postgres only!)");
39
+ log(" -o, --out [path] Path to output directory");
40
+ log(" -a, --adapter [adapter] Output adapter to use (default: 'kysely')");
41
+ log(" -A, --all-adapters Output all adapters");
42
+ log(" -c, --config [path] Path to config file (JSON)");
43
+ log(" Defaults to '.truepgrc.json' or '.config/.truepgrc.json'");
44
+ log("Example:");
45
+ log(" true-pg -u postgres://user:pass@localhost:5432/my-database -o models -a kysely -a zod");
46
+ log();
47
+ if (opts.help) process.exit(0);
48
+ else process.exit(1);
49
+ }
50
+
51
+ let configfile = opts.config;
52
+ if (!configfile) {
53
+ const candidates = [".truepgrc.json", ".config/.truepgrc.json"];
54
+ for (const candidate of candidates) {
55
+ if (await existsSync(candidate)) {
56
+ configfile = candidate;
57
+ break;
58
+ }
59
+ }
60
+ }
61
+
62
+ const config = configfile ? await Bun.file(configfile).json() : {};
63
+
64
+ if (opts["all-adapters"]) {
65
+ opts.adapter = Object.keys(adapters);
66
+ console.log("Enabling all built-in adapters:", opts.adapter);
67
+ }
68
+
69
+ if (!(opts.adapter || config.adapters)) console.warn('No adapters specified, using default: ["kysely"]');
70
+
71
+ opts.out ??= "models";
72
+ // allow single adapter or comma-separated list of adapters
73
+ if (typeof opts.adapter === "string") opts.adapter = opts.adapter.split(",");
74
+ opts.adapter ??= ["kysely"];
75
+
76
+ // CLI args take precedence over config file
77
+ config.uri = opts.uri ?? config.uri;
78
+ config.out = opts.out ?? config.out;
79
+ config.adapters = opts.adapter ?? config.adapters;
80
+
81
+ await generate(config);
@@ -0,0 +1,38 @@
1
+ export declare namespace True {
2
+ export type Column<Selectable, Insertable = Selectable, Updateable = Selectable> = {
3
+ "@true-pg/insert": Insertable;
4
+ "@true-pg/update": Updateable;
5
+ "@true-pg/select": Selectable;
6
+ };
7
+
8
+ export type Generated<T> = Column<T, T | undefined, T | undefined>;
9
+ export type HasDefault<T> = Column<T, T | undefined, T | undefined>;
10
+ export type AlwaysGenerated<T> = Column<T, never, never>;
11
+
12
+ type DrainOuterGeneric<T> = [T] extends [unknown] ? T : never;
13
+ type Simplify<T> = DrainOuterGeneric<{ [K in keyof T]: T[K] } & {}>;
14
+
15
+ export type Selectable<T extends Record<string, any>> = Simplify<{
16
+ [K in keyof T]: T[K] extends Record<string, any>
17
+ ? Selectable<T[K]>
18
+ : T[K] extends Column<infer S, infer I, infer U>
19
+ ? S
20
+ : T[K];
21
+ }>;
22
+
23
+ export type Insertable<T extends Record<string, any>> = Simplify<{
24
+ [K in keyof T]: T[K] extends Record<string, any>
25
+ ? Insertable<T[K]>
26
+ : T[K] extends Column<infer S, infer I, infer U>
27
+ ? I
28
+ : T[K];
29
+ }>;
30
+
31
+ export type Updateable<T extends Record<string, any>> = Simplify<{
32
+ [K in keyof T]?: T[K] extends Record<string, any>
33
+ ? Updateable<T[K]>
34
+ : T[K] extends Column<infer S, infer I, infer U>
35
+ ? U
36
+ : T[K];
37
+ }>;
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { Extractor, type FunctionDetails, type Schema } from "pg-extract";
2
+ import { rm, mkdir, writeFile } from "fs/promises";
3
+ import { Nodes, allowed_kind_names, type FolderStructure, type TruePGOpts, type createGenerator } from "./types.ts";
4
+ import { existsSync } from "fs";
5
+ import { join } from "./util.ts";
6
+
7
+ import { Kysely } from "./kysely/index.ts";
8
+ import { Zod } from "./zod/index.ts";
9
+
10
+ export const adapters: Record<string, createGenerator> = {
11
+ kysely: Kysely,
12
+ zod: Zod,
13
+ };
14
+
15
+ export * from "./consumer.ts";
16
+
17
+ const filter_function = (func: FunctionDetails, warnings: string[]) => {
18
+ const typesToFilter = [
19
+ "pg_catalog.trigger",
20
+ "pg_catalog.event_trigger",
21
+ "pg_catalog.internal",
22
+ "pg_catalog.language_handler",
23
+ "pg_catalog.fdw_handler",
24
+ "pg_catalog.index_am_handler",
25
+ "pg_catalog.tsm_handler",
26
+ ];
27
+
28
+ const warn = (type: string) =>
29
+ warnings.push(`Skipping function ${func.name}: cannot represent ${type} (safe to ignore)`);
30
+
31
+ if (func.returnType.kind === "table") {
32
+ for (const col of func.returnType.columns) {
33
+ if (typesToFilter.includes(col.type.canonical_name)) {
34
+ warn(col.type.canonical_name);
35
+ return false;
36
+ }
37
+ }
38
+ } else {
39
+ if (typesToFilter.includes(func.returnType.type.canonical_name)) {
40
+ warn(func.returnType.type.canonical_name);
41
+ return false;
42
+ }
43
+ }
44
+
45
+ for (const param of func.parameters) {
46
+ if (typesToFilter.includes(param.type.canonical_name)) {
47
+ warn(param.type.canonical_name);
48
+ return false;
49
+ }
50
+ }
51
+
52
+ return func;
53
+ };
54
+
55
+ const write = (filename: string, file: string) => writeFile(filename, file + "\n");
56
+
57
+ const multifile = async (generators: createGenerator[], schemas: Record<string, Schema>, opts: TruePGOpts) => {
58
+ const { out } = opts;
59
+
60
+ const warnings: string[] = [];
61
+ const gens = generators.map(g => g({ ...opts, warnings }));
62
+ const def_gen = gens[0]!;
63
+
64
+ const files: FolderStructure = {
65
+ name: out,
66
+ type: "root",
67
+ children: Object.fromEntries(
68
+ Object.values(schemas).map(schema => [
69
+ schema.name,
70
+ {
71
+ name: schema.name,
72
+ type: "schema",
73
+ children: Object.fromEntries(
74
+ allowed_kind_names.map(kind => [
75
+ kind,
76
+ {
77
+ kind: kind,
78
+ type: "kind",
79
+ children: Object.fromEntries(
80
+ schema[kind].map(item => [
81
+ item.name,
82
+ {
83
+ name: def_gen.formatSchemaType(item),
84
+ type: "type",
85
+ },
86
+ ]),
87
+ ),
88
+ },
89
+ ]),
90
+ ),
91
+ },
92
+ ]),
93
+ ),
94
+ };
95
+
96
+ const start = performance.now();
97
+
98
+ for (const schema of Object.values(schemas)) {
99
+ console.log("Selected schema '%s':\n", schema.name);
100
+
101
+ const schemaDir = `${out}/${schema.name}`;
102
+
103
+ // skip functions that cannot be represented in JavaScript
104
+ schema.functions = schema.functions.filter(f => filter_function(f, warnings));
105
+
106
+ for (const kind of allowed_kind_names) {
107
+ if (schema[kind].length < 1) continue;
108
+
109
+ await mkdir(`${schemaDir}/${kind}`, { recursive: true });
110
+ console.log(" Creating %s:\n", kind);
111
+
112
+ for (const [i, item] of schema[kind].entries()) {
113
+ const index = "[" + (i + 1 + "]").padEnd(3, " ");
114
+ const filename = `${schemaDir}/${kind}/${def_gen.formatSchemaType(item)}.ts`;
115
+
116
+ const exists = await existsSync(filename);
117
+
118
+ if (exists) {
119
+ warnings.push(
120
+ `Skipping ${item.kind} "${item.name}": formatted name clashes. Wanted to create ${filename}`,
121
+ );
122
+ continue;
123
+ }
124
+
125
+ const start = performance.now();
126
+
127
+ let file = "";
128
+
129
+ const imports = new Nodes.ImportList([]);
130
+
131
+ if (item.kind === "table") file += join(gens.map(gen => gen.table(imports, item)));
132
+ if (item.kind === "composite") file += join(gens.map(gen => gen.composite(imports, item)));
133
+ if (item.kind === "enum") file += join(gens.map(gen => gen.enum(imports, item)));
134
+ if (item.kind === "function") file += join(gens.map(gen => gen.function(imports, item)));
135
+
136
+ const parts: string[] = [];
137
+ parts.push(imports.stringify(filename, files));
138
+ parts.push(file);
139
+ file = join(parts);
140
+
141
+ await write(filename, file);
142
+
143
+ const end = performance.now();
144
+ console.log(" %s %s \x1b[32m(%sms)\x1B[0m", index, filename, (end - start).toFixed(2));
145
+ }
146
+
147
+ const kindIndex = join(gens.map(gen => gen.schemaKindIndex(schema, kind, def_gen)));
148
+ const kindIndexFilename = `${schemaDir}/${kind}/index.ts`;
149
+ await write(kindIndexFilename, kindIndex);
150
+ console.log(' ✅ Created "%s" %s index: %s\n', schema.name, kind, kindIndexFilename);
151
+ }
152
+
153
+ const index = join(gens.map(gen => gen.schemaIndex(schema, def_gen)));
154
+ const indexFilename = `${out}/${schema.name}/index.ts`;
155
+ await write(indexFilename, index);
156
+ console.log(" Created schema index: %s\n", indexFilename);
157
+ }
158
+
159
+ const fullIndex = join(gens.map(gen => gen.fullIndex(Object.values(schemas))));
160
+ const fullIndexFilename = `${out}/index.ts`;
161
+ await write(fullIndexFilename, fullIndex);
162
+ console.log("Created full index: %s", fullIndexFilename);
163
+ const end = performance.now();
164
+ console.log("Completed in \x1b[32m%sms\x1b[0m", (end - start).toFixed(2));
165
+
166
+ if (warnings.length > 0) {
167
+ console.log("\nWarnings generated:");
168
+ console.log(warnings.map(warning => "* " + warning).join("\n"));
169
+ }
170
+ };
171
+
172
+ export async function generate(opts: TruePGOpts, generators?: createGenerator[]) {
173
+ const out = opts.out || "./models";
174
+ const extractor = new Extractor(opts.uri);
175
+ const schemas = await extractor.extractSchemas();
176
+ generators ??= opts.adapters.map(adapter => {
177
+ const selected = adapters[adapter];
178
+ if (!selected) throw new Error(`Requested adapter ${adapter} not found`);
179
+ return selected;
180
+ });
181
+ console.log("Clearing directory and generating schemas at '%s'", out);
182
+ await rm(out, { recursive: true, force: true });
183
+ await mkdir(out, { recursive: true });
184
+ await multifile(generators, schemas, { ...opts, out });
185
+ }
@@ -0,0 +1,38 @@
1
+ // extended from https://github.com/kristiandupont/kanel/blob/e9332f03ff5e38f5b844dd7a4563580c0d9d1444/packages/kanel/src/defaultTypeMap.ts
2
+
3
+ export const builtins: Record<string, string> = {
4
+ "pg_catalog.int2": "number",
5
+ "pg_catalog.int4": "number",
6
+
7
+ // JS numbers are always floating point, so there is only 53 bits of precision
8
+ // for the integer part. Thus, storing a 64-bit integer in a JS number will
9
+ // result in potential data loss. We therefore use strings for 64-bit integers
10
+ // the same way that the pg driver does.
11
+ "pg_catalog.int8": "string",
12
+
13
+ "pg_catalog.float4": "number",
14
+ "pg_catalog.float8": "number",
15
+ "pg_catalog.numeric": "string",
16
+ "pg_catalog.bool": "boolean",
17
+ "pg_catalog.json": "unknown",
18
+ "pg_catalog.jsonb": "unknown",
19
+ "pg_catalog.char": "string",
20
+ "pg_catalog.bpchar": "string",
21
+ "pg_catalog.varchar": "string",
22
+ "pg_catalog.text": "string",
23
+ "pg_catalog.uuid": "string",
24
+ "pg_catalog.inet": "string",
25
+ "pg_catalog.date": "Date",
26
+ "pg_catalog.time": "Date",
27
+ "pg_catalog.timetz": "Date",
28
+ "pg_catalog.timestamp": "Date",
29
+ "pg_catalog.timestamptz": "Date",
30
+ "pg_catalog.int4range": "string",
31
+ "pg_catalog.int8range": "string",
32
+ "pg_catalog.numrange": "string",
33
+ "pg_catalog.tsrange": "string",
34
+ "pg_catalog.tstzrange": "string",
35
+ "pg_catalog.daterange": "string",
36
+ "pg_catalog.record": "Record<string, unknown>",
37
+ "pg_catalog.void": "void",
38
+ };