@onreza/sqlx-js 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -36,8 +36,10 @@ const rows = await sql(
36
36
  - **Linear migrations** with hash tampering detection.
37
37
  - **Migration squash baselines** via `migrate squash`: generate a schema-only baseline from a shadow database, then hash-adopt it on already-migrated databases.
38
38
  - **Runtime `migrate()`** with PostgreSQL advisory lock, safe for multi-replica startup.
39
+ - **Optional pgschema workflow** via `init --schema-provider pgschema` and `sqlx-js db install|check|plan|apply` for PostgreSQL schema-as-code projects.
39
40
  - **Offline cache** committed to your repo. CI verifies via `prepare --check` without a database.
40
41
  - **Schema snapshot + LLM manifest** via `schema dump` / `schema check`: tables, columns, constraints, indexes, types, and function/procedure metadata are introspected from PostgreSQL.
42
+ - **Generated function catalog** via `KnownFunctions`: `prepare` records user-schema PostgreSQL functions/procedures from `pg_proc` with approximate parameter and return TypeScript types.
41
43
  - **Shadow database validation** via `migrate dev` / `migrate verify`: auto-create a disposable shadow DB, apply migrations, validate SQL, and drop it afterwards.
42
44
  - **Safe identifier quoting** via `sql.id(...)`, backed by the committed schema snapshot whitelist.
43
45
  - **Single runtime adapter**: Postgres.js backs the runtime on Node/Bun-compatible environments — no Bun.SQL-specific adapter to choose.
@@ -64,6 +66,16 @@ npx @onreza/sqlx-js init
64
66
 
65
67
  Creates `sqlx-js.config.ts`, a `migrations/` directory, and `.env.example` if they don't already exist (it never overwrites existing files), then prints the next steps. Skip it if you prefer to wire things up manually.
66
68
 
69
+ For declarative PostgreSQL schema management, scaffold the pgschema workflow instead:
70
+
71
+ ```bash
72
+ npx @onreza/sqlx-js init --schema-provider pgschema
73
+ ```
74
+
75
+ This creates `schema.sql` and configures `schema.provider = "pgschema"` in `sqlx-js.config.ts`. The npm package does not bundle pgschema, but `sqlx-js db install` downloads the pinned pgschema binary into `node_modules/.cache/sqlx-js/pgschema/`; then `sqlx-js db check` verifies it.
76
+
77
+ The managed pgschema workflow supports Linux and macOS. On Windows, run sqlx-js under WSL/Linux/macOS or use the built-in `sqlx-js migrate` workflow.
78
+
67
79
  ### 1. Configure the database URL
68
80
 
69
81
  ```bash
@@ -334,7 +346,8 @@ In addition to `import { sql } from "@onreza/sqlx-js"`, the scanner recognises `
334
346
  ## CLI
335
347
 
336
348
  ```
337
- sqlx-js init [--root <dir>]
349
+ sqlx-js init [--root <dir>] [--schema-provider builtin|pgschema]
350
+ sqlx-js db install | check | plan | apply [--root <dir>] [-- <pgschema args>]
338
351
  sqlx-js prepare [--check | --watch] [--root <dir>] [--dts <path>] [--no-prune] [--shadow-url <url>]
339
352
  sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | verify [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | run [--dry-run] [--json] [--lock-timeout <ms>] | info [--json] | check [--json] | revert [--dry-run] [--json] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | add <name> | squash <name> [--shadow-admin-url <url> | --shadow-url <url>] [--replace] [--pg-dump <path>] [--lock-timeout <ms>] | archive list | archive restore <name> [--force]
340
353
  sqlx-js schema dump | check [--schema <path>] [--manifest <path>] [--no-manifest] [--shadow-url <url>]
@@ -362,6 +375,7 @@ Regular `prepare` describes queries across a small connection pool (default 8, o
362
375
  | `--schema <path>` | Schema snapshot path (default: `<root>/.sqlx-js/schema/schema.json`). |
363
376
  | `--manifest <path>` | LLM schema manifest path (default: `<root>/.sqlx-js/schema/schema.md`). |
364
377
  | `--no-manifest` | Skip writing the LLM schema manifest during `schema dump`. |
378
+ | `--schema-provider <name>` | For `init`: `builtin` (default) or `pgschema`. |
365
379
 
366
380
  Flags that take a value accept both `--flag value` and `--flag=value` forms.
367
381
 
@@ -369,6 +383,20 @@ Flags that take a value accept both `--flag value` and `--flag=value` forms.
369
383
 
370
384
  ### Development and deployment flows
371
385
 
386
+ For complex PostgreSQL schemas with functions, triggers, RLS, grants, partitions, and other schema-level objects, prefer pgschema for DDL ownership and use sqlx-js for application-query typing:
387
+
388
+ ```bash
389
+ sqlx-js init --schema-provider pgschema
390
+ sqlx-js db install
391
+ sqlx-js db check
392
+ # edit schema.sql
393
+ sqlx-js db plan -- --output-json plan.json
394
+ sqlx-js db apply -- --auto-approve
395
+ sqlx-js prepare
396
+ ```
397
+
398
+ `sqlx-js db install` installs the pinned pgschema version used by this sqlx-js release. `sqlx-js db check`, `plan`, and `apply` use `schema.command` when configured; otherwise they prefer the managed binary under `node_modules/.cache/sqlx-js/pgschema/` and fall back to `pgschema` on `PATH`. `plan` and file-backed `apply` translate `DATABASE_URL` into `--host`, `--port`, `--db`, `--user`, `--file`, and `--schema` arguments, pass the password through `PGPASSWORD`, pass TLS settings through `PGSSLMODE` / `PGSSLROOTCERT` / `PGSSLCERT` / `PGSSLKEY`, and forward any arguments after `--` directly to pgschema. `sqlx-js db apply -- --plan plan.json` applies a reviewed pgschema plan without requiring the local `schema.sql` file. The schema provider is configured in `sqlx-js.config.ts`; by default the schema file is `schema.sql` and the schema is `public`. The pinned pgschema 1.12.0 CLI accepts a single `--schema` value, so sqlx-js rejects pgschema configs with more than one schema instead of silently applying only one.
399
+
372
400
  Use `migrate dev` while developing migrations and SQL:
373
401
 
374
402
  ```bash
@@ -379,6 +407,8 @@ sqlx-js migrate dev
379
407
 
380
408
  `migrate dev` creates a disposable shadow database, applies all migrations from scratch, validates that the latest migration's `.down.sql` restores the previous schema (squash baselines may omit `.down.sql`), prepares project SQL against the shadow schema, writes `.sqlx-js/` plus `sqlx-js-env.d.ts`, and drops the shadow database. This means you can keep editing a local WIP migration before it is merged. You do not need to drop your application database or create a new migration for every local edit.
381
409
 
410
+ The built-in `migrate` workflow is kept for simple projects and embedded application startup. PostgreSQL-heavy schema lifecycle features belong in pgschema rather than in sqlx-js.
411
+
382
412
  Use `migrate verify` in PR/CI before merge:
383
413
 
384
414
  ```bash
@@ -449,6 +479,11 @@ Phases reported separately: `describe failed`, `analyze failed`, `paramMap faile
449
479
  import type { SqlxJsConfig } from "@onreza/sqlx-js";
450
480
 
451
481
  const config: SqlxJsConfig = {
482
+ schema: {
483
+ provider: "pgschema",
484
+ file: "schema.sql",
485
+ schemas: ["public"],
486
+ },
452
487
  jsonbTypes: {
453
488
  "users.settings": "SqlxJsJson.UserSettings",
454
489
  "posts.meta": "SqlxJsJson.PostMeta",
@@ -459,6 +494,8 @@ const config: SqlxJsConfig = {
459
494
  export default config;
460
495
  ```
461
496
 
497
+ The `schema` block is optional. Use `provider: "pgschema"` when sqlx-js should delegate schema planning/apply commands to pgschema. `command` can override the managed binary lookup and point at another executable. With the pinned pgschema 1.12.0 CLI, `schemas` must contain exactly one schema name.
498
+
462
499
  Declare the referenced types anywhere in your project (`.d.ts` file is conventional):
463
500
 
464
501
  ```ts
@@ -535,7 +572,10 @@ Commit the generated `sqlx-js-env.d.ts` and the `.sqlx-js/` cache directory to y
535
572
 
536
573
  ```yaml
537
574
  - run: bun install
538
- - run: sqlx-js migrate verify # builds schema from migrations in a disposable shadow DB
575
+ - run: sqlx-js migrate verify # built-in migration workflow
576
+ # or, when schema.provider is "pgschema":
577
+ - run: sqlx-js db install
578
+ - run: sqlx-js db plan -- --output-json plan.json
539
579
  - run: sqlx-js prepare --check # fails if any query is missing from the committed cache
540
580
  - run: sqlx-js schema check # fails if the committed schema snapshot is stale
541
581
  - run: tsc --noEmit # fails if types are stale
@@ -543,7 +583,9 @@ Commit the generated `sqlx-js-env.d.ts` and the `.sqlx-js/` cache directory to y
543
583
  - run: bun run build # emits publishable JS + declarations under dist/
544
584
  ```
545
585
 
546
- The `migrate verify` step needs `DATABASE_URL` credentials that can either create a temporary database or use `--shadow-admin-url` / `--shadow-url`. It does not write `.sqlx-js/` or `sqlx-js-env.d.ts`. The `prepare --check` step then runs without a database; your committed offline cache is the source of truth. `schema check` intentionally uses a live database because it verifies the committed schema contract against PostgreSQL.
586
+ The `migrate verify` step needs `DATABASE_URL` credentials that can either create a temporary database or use `--shadow-admin-url` / `--shadow-url`. It does not write `.sqlx-js/` or `sqlx-js-env.d.ts`. For pgschema projects, `sqlx-js db plan` checks the desired `schema.sql` against the target database and leaves application query typing to `prepare`. The `prepare --check` step then runs without a database; your committed offline cache is the source of truth. `schema check` intentionally uses a live database because it verifies the committed schema contract against PostgreSQL.
587
+
588
+ The managed pgschema binary is installed under `node_modules/.cache/sqlx-js/pgschema/`, not `.sqlx-js/`, so it is not part of the committed offline cache.
547
589
 
548
590
  ## Contributing
549
591
 
package/ROADMAP.md CHANGED
@@ -6,7 +6,8 @@ Items already shipped live in the [README](./README.md) feature list; this file
6
6
 
7
7
  | Feature | ROI | Notes |
8
8
  |---------|-----|-------|
9
- | Migration lifecycle improvements | 8 | Squash baseline MVP, `migrate run --dry-run`, read-only `migrate info`, archive/restore helpers, JSON operator output, filesystem-only `migrate check`, and shadow-based `migrate revert --dry-run` exist; continue with safer migration lifecycle guardrails. This is the foundation for reliable external migration import. |
9
+ | pgschema integration hardening | 8 | Basic `init --schema-provider pgschema` plus managed `sqlx-js db install/check/plan/apply` exists. Continue with better docs, CI examples, schema snapshot handoff, and migration guidance for projects that outgrow built-in migrations. |
10
+ | Built-in migration lifecycle maintenance | 5 | Keep `migrate run/dev/verify/revert/squash/archive` stable for simple projects and application startup, but avoid expanding it into a full PostgreSQL schema-as-code system. |
10
11
  | Prisma migration assistant | 7 | Import Prisma Migrate SQL history and Prisma TypedSQL/raw SQL into `sqlx-js`; classify Prisma Client CRUD/nested-write sites as assisted/manual instead of promising a fully automatic ORM rewrite. |
11
12
  | Self-join precision (unqualified ColumnRef) | 4 | `SELECT name FROM users u1 JOIN users u2 ON ...` with unqualified `name` can't be attributed to a specific alias. PG would reject ambiguous unqualified refs anyway, but explicit aliasing currently has no narrowing benefit in self-joins. |
12
13
  | `INSERT INTO t VALUES (...)` without column list | 3 | Map params by `pg_attribute attnum` ordering. Rare in practice — most teams use explicit column lists. |
@@ -7,6 +7,8 @@ import { runWatch } from "../src/commands/watch.js";
7
7
  import { migrateArchiveList, migrateArchiveRestore, migrateCheck, migrateDev, migrateRun, migrateInfo, migrateRevert, migrateAdd, migrateSquash, migrateVerify, } from "../src/commands/migrate.js";
8
8
  import { applyShadowMigrations, runSchemaCheck, runSchemaDump } from "../src/commands/schema.js";
9
9
  import { runInit } from "../src/commands/init.js";
10
+ import { runPgschemaCommand, runPgschemaInstall } from "../src/commands/pgschema.js";
11
+ import { loadConfig } from "../src/config.js";
10
12
  function packageVersion() {
11
13
  const here = dirname(fileURLToPath(import.meta.url));
12
14
  for (const path of [join(here, "../package.json"), join(here, "../../package.json")]) {
@@ -23,7 +25,8 @@ function help() {
23
25
  console.error(`sqlx-js — compile-time-checked SQL for TypeScript + Postgres (v${VERSION})
24
26
 
25
27
  usage:
26
- sqlx-js init [--root <dir>]
28
+ sqlx-js init [--root <dir>] [--schema-provider builtin|pgschema]
29
+ sqlx-js db install | check | plan | apply [--root <dir>] [-- <pgschema args>]
27
30
  sqlx-js prepare [--check | --watch] [--root <dir>] [--dts <path>] [--no-prune] [--shadow-url <url>]
28
31
  sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | verify [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | run [--dry-run] [--json] [--lock-timeout <ms>] | info [--json] | check [--json] | revert [--dry-run] [--json] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | add <name> | squash <name> [--shadow-admin-url <url> | --shadow-url <url>] [--replace] [--pg-dump <path>] [--lock-timeout <ms>] | archive list | archive restore <name> [--force]
29
32
  sqlx-js schema dump | check [--schema <path>] [--manifest <path>] [--no-manifest] [--shadow-url <url>]
@@ -52,23 +55,26 @@ flags:
52
55
  --schema <path> schema snapshot path (default: <root>/.sqlx-js/schema/schema.json)
53
56
  --manifest <path> LLM schema manifest path (default: <root>/.sqlx-js/schema/schema.md)
54
57
  --no-manifest skip writing the LLM schema manifest during schema dump
58
+ --schema-provider <name> init schema workflow: builtin (default) or pgschema
55
59
  `);
56
60
  process.exit(2);
57
61
  }
62
+ const passthroughIndex = process.argv.indexOf("--");
63
+ const cliArgv = passthroughIndex >= 0 ? process.argv.slice(0, passthroughIndex) : process.argv;
64
+ const passthroughArgs = passthroughIndex >= 0 ? process.argv.slice(passthroughIndex + 1) : [];
58
65
  function arg(name, def) {
59
- const argv = process.argv;
60
66
  const eq = `${name}=`;
61
- for (let i = 0; i < argv.length; i++) {
62
- const a = argv[i];
67
+ for (let i = 0; i < cliArgv.length; i++) {
68
+ const a = cliArgv[i];
63
69
  if (a === name)
64
- return argv[i + 1] ?? def;
70
+ return cliArgv[i + 1] ?? def;
65
71
  if (a.startsWith(eq))
66
72
  return a.slice(eq.length);
67
73
  }
68
74
  return def;
69
75
  }
70
76
  function flag(name) {
71
- for (const a of process.argv) {
77
+ for (const a of cliArgv) {
72
78
  if (a === name)
73
79
  return true;
74
80
  }
@@ -96,7 +102,41 @@ const schemaPath = schemaArg ? resolve(schemaArg) : join(root, ".sqlx-js/schema/
96
102
  const manifestArg = arg("--manifest");
97
103
  const manifestPath = manifestArg ? resolve(manifestArg) : join(root, ".sqlx-js/schema/schema.md");
98
104
  if (cmd === "init") {
99
- runInit({ root });
105
+ const provider = arg("--schema-provider", "builtin");
106
+ if (provider !== "builtin" && provider !== "pgschema") {
107
+ console.error("--schema-provider must be builtin or pgschema");
108
+ process.exit(2);
109
+ }
110
+ runInit({ root, schemaProvider: provider });
111
+ }
112
+ else if (cmd === "db") {
113
+ const sub = process.argv[3];
114
+ if (sub === "install") {
115
+ try {
116
+ await runPgschemaInstall({ root });
117
+ }
118
+ catch (e) {
119
+ console.error(e.message);
120
+ process.exit(2);
121
+ }
122
+ }
123
+ else {
124
+ if (sub !== "check" && sub !== "plan" && sub !== "apply")
125
+ help();
126
+ try {
127
+ runPgschemaCommand({
128
+ root,
129
+ databaseUrl,
130
+ config: await loadConfig(root),
131
+ subcommand: sub,
132
+ passthrough: passthroughArgs,
133
+ });
134
+ }
135
+ catch (e) {
136
+ console.error(e.message);
137
+ process.exit(2);
138
+ }
139
+ }
100
140
  }
101
141
  else if (cmd === "prepare") {
102
142
  if (flag("--check") && shadowUrlArg) {
@@ -1,2 +1,3 @@
1
1
  import { type CacheEntry } from "./cache.js";
2
- export declare function emitDts(outPath: string, entries: CacheEntry[]): void;
2
+ import type { FunctionEntry } from "./function-cache.js";
3
+ export declare function emitDts(outPath: string, entries: CacheEntry[], functions?: FunctionEntry[]): void;
@@ -16,18 +16,18 @@ function entrySignature(e) {
16
16
  });
17
17
  return { params, row: `{ ${cols.join("; ")} }` };
18
18
  }
19
- export function emitDts(outPath, entries) {
19
+ export function emitDts(outPath, entries, functions = []) {
20
20
  mkdirSync(dirname(outPath), { recursive: true });
21
21
  const lines = [];
22
22
  lines.push("// AUTO-GENERATED by sqlx-js. Do not edit.");
23
23
  lines.push("// Run `sqlx-js prepare` to regenerate.");
24
24
  lines.push("");
25
- emitModule(lines, "@onreza/sqlx-js", entries);
25
+ emitModule(lines, "@onreza/sqlx-js", entries, functions);
26
26
  lines.push("");
27
27
  lines.push("export {};");
28
28
  writeFileSync(outPath, lines.join("\n") + "\n");
29
29
  }
30
- function emitModule(lines, moduleName, entries) {
30
+ function emitModule(lines, moduleName, entries, functions) {
31
31
  lines.push(`declare module ${JSON.stringify(moduleName)} {`);
32
32
  lines.push(" interface KnownQueries {");
33
33
  const inlineSeen = new Set();
@@ -70,5 +70,22 @@ function emitModule(lines, moduleName, entries) {
70
70
  lines.push(` ${JSON.stringify(path)}: { params: ${params}; row: ${row} };`);
71
71
  }
72
72
  lines.push(" }");
73
+ lines.push("");
74
+ lines.push(" interface KnownFunctions {");
75
+ const functionSeen = new Set();
76
+ for (const fn of functions.slice().sort((a, b) => a.signature.localeCompare(b.signature))) {
77
+ if (functionSeen.has(fn.signature))
78
+ continue;
79
+ functionSeen.add(fn.signature);
80
+ const params = inputParams(fn);
81
+ lines.push(` ${JSON.stringify(fn.signature)}: { kind: ${JSON.stringify(fn.kind)}; params: ${params}; returns: ${fn.returns}; returnsSet: ${fn.returnsSet} };`);
82
+ }
83
+ lines.push(" }");
73
84
  lines.push("}");
74
85
  }
86
+ function inputParams(fn) {
87
+ const params = fn.params
88
+ .filter((p) => p.mode === "in" || p.mode === "inout" || p.mode === "variadic")
89
+ .map((p) => p.tsType);
90
+ return params.length === 0 ? "[]" : `[${params.join(", ")}]`;
91
+ }
@@ -1,5 +1,6 @@
1
1
  export type InitOptions = {
2
2
  root: string;
3
+ schemaProvider?: "builtin" | "pgschema";
3
4
  log?: (msg: string) => void;
4
5
  };
5
6
  export declare function runInit(opts: InitOptions): void;
@@ -11,6 +11,20 @@ const config: SqlxJsConfig = {
11
11
  customTypes: {},
12
12
  };
13
13
 
14
+ export default config;
15
+ `;
16
+ const PGSCHEMA_CONFIG_TEMPLATE = `import type { SqlxJsConfig } from "@onreza/sqlx-js";
17
+
18
+ const config: SqlxJsConfig = {
19
+ schema: {
20
+ provider: "pgschema",
21
+ file: "schema.sql",
22
+ schemas: ["public"],
23
+ },
24
+ jsonbTypes: {},
25
+ customTypes: {},
26
+ };
27
+
14
28
  export default config;
15
29
  `;
16
30
  const ENV_TEMPLATE = `# Connection string used by sqlx-js prepare/migrate and the runtime.
@@ -18,6 +32,11 @@ DATABASE_URL=postgres://user:password@localhost:5432/your_db
18
32
  # Managed Postgres with TLS:
19
33
  # DATABASE_URL=postgres://user:password@db.example.com:5432/your_db?sslmode=verify-full
20
34
  `;
35
+ const PGSCHEMA_TEMPLATE = `-- Desired PostgreSQL schema managed by pgschema.
36
+ -- Edit this file, then run:
37
+ -- sqlx-js db plan -- --output-json plan.json
38
+ -- sqlx-js db apply -- --auto-approve
39
+ `;
21
40
  export function runInit(opts) {
22
41
  const log = opts.log ?? console.log;
23
42
  const created = [];
@@ -41,8 +60,12 @@ export function runInit(opts) {
41
60
  mkdirSync(full, { recursive: true });
42
61
  created.push(`${rel}/`);
43
62
  };
44
- ensureFile("sqlx-js.config.ts", CONFIG_TEMPLATE);
45
- ensureDir("migrations");
63
+ const schemaProvider = opts.schemaProvider ?? "builtin";
64
+ ensureFile("sqlx-js.config.ts", schemaProvider === "pgschema" ? PGSCHEMA_CONFIG_TEMPLATE : CONFIG_TEMPLATE);
65
+ if (schemaProvider === "pgschema")
66
+ ensureFile("schema.sql", PGSCHEMA_TEMPLATE);
67
+ else
68
+ ensureDir("migrations");
46
69
  ensureFile(".env.example", ENV_TEMPLATE);
47
70
  for (const f of created)
48
71
  log(`created ${f}`);
@@ -51,7 +74,15 @@ export function runInit(opts) {
51
74
  log("");
52
75
  log("Next steps:");
53
76
  log(" 1. Set DATABASE_URL (see .env.example).");
54
- log(" 2. Add a migration: sqlx-js migrate add init");
55
- log(" 3. Make sure tsconfig.json \"include\" covers sqlx-js-env.d.ts.");
56
- log(" 4. Develop locally: sqlx-js migrate dev");
77
+ if (schemaProvider === "pgschema") {
78
+ log(" 2. Install managed pgschema: sqlx-js db install");
79
+ log(" 3. Verify it: sqlx-js db check");
80
+ log(" 4. Edit schema.sql, then review: sqlx-js db plan");
81
+ log(" 5. After applying schema changes, run: sqlx-js prepare");
82
+ }
83
+ else {
84
+ log(" 2. Add a migration: sqlx-js migrate add init");
85
+ log(" 3. Make sure tsconfig.json \"include\" covers sqlx-js-env.d.ts.");
86
+ log(" 4. Develop locally: sqlx-js migrate dev");
87
+ }
57
88
  }
@@ -0,0 +1,25 @@
1
+ import type { SqlxJsConfig } from "../config.js";
2
+ export type PgschemaSubcommand = "check" | "plan" | "apply";
3
+ export declare const PGSCHEMA_VERSION = "1.12.0";
4
+ export type PgschemaAsset = {
5
+ key: string;
6
+ name: string;
7
+ sha256: string;
8
+ };
9
+ export type PgschemaCommandOptions = {
10
+ root: string;
11
+ databaseUrl: string;
12
+ config: SqlxJsConfig;
13
+ subcommand: PgschemaSubcommand;
14
+ passthrough?: string[];
15
+ };
16
+ export type PgschemaInstallOptions = {
17
+ root: string;
18
+ asset?: PgschemaAsset;
19
+ baseUrl?: string;
20
+ log?: (msg: string) => void;
21
+ };
22
+ export declare function resolvePgschemaAsset(platform?: NodeJS.Platform, arch?: NodeJS.Architecture): PgschemaAsset;
23
+ export declare function managedPgschemaPath(root: string, asset?: PgschemaAsset): string;
24
+ export declare function runPgschemaInstall(opts: PgschemaInstallOptions): Promise<void>;
25
+ export declare function runPgschemaCommand(opts: PgschemaCommandOptions): void;
@@ -0,0 +1,178 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { parseDatabaseUrl } from "../pg/wire.js";
6
+ export const PGSCHEMA_VERSION = "1.12.0";
7
+ const PGSCHEMA_BASE_URL = `https://github.com/pgplex/pgschema/releases/download/v${PGSCHEMA_VERSION}`;
8
+ const WINDOWS_UNSUPPORTED = "sqlx-js db: pgschema is not supported on Windows. Run sqlx-js under WSL/Linux/macOS or use the built-in sqlx-js migrate workflow.";
9
+ const PGSCHEMA_ASSETS = {
10
+ "darwin:x64": {
11
+ key: "darwin-amd64",
12
+ name: `pgschema-${PGSCHEMA_VERSION}-darwin-amd64`,
13
+ sha256: "c64b2ac24c4246344908910e892c4123be282bbb449f0b535079ff41d0f47c8f",
14
+ },
15
+ "darwin:arm64": {
16
+ key: "darwin-arm64",
17
+ name: `pgschema-${PGSCHEMA_VERSION}-darwin-arm64`,
18
+ sha256: "f01ea488f21700752d5747bc013c406daa583a68b631739f33af430d5d3ec449",
19
+ },
20
+ "linux:x64": {
21
+ key: "linux-amd64",
22
+ name: `pgschema-${PGSCHEMA_VERSION}-linux-amd64`,
23
+ sha256: "12610adf748b0dafe4e488ee7e9e68e6ffbef1f4e0f038dda36cf0138eede598",
24
+ },
25
+ "linux:arm64": {
26
+ key: "linux-arm64",
27
+ name: `pgschema-${PGSCHEMA_VERSION}-linux-arm64`,
28
+ sha256: "58ec57023954a0239cf9d607c4e5432da6dd0b279399d1c318204120619a221d",
29
+ },
30
+ };
31
+ export function resolvePgschemaAsset(platform = process.platform, arch = process.arch) {
32
+ if (platform === "win32")
33
+ throw new Error(WINDOWS_UNSUPPORTED);
34
+ const asset = PGSCHEMA_ASSETS[`${platform}:${arch}`];
35
+ if (!asset)
36
+ throw new Error(`sqlx-js db install: unsupported platform ${platform}/${arch}`);
37
+ return asset;
38
+ }
39
+ function pgschemaConfig(config) {
40
+ if (config.schema?.provider !== "pgschema") {
41
+ throw new Error("sqlx-js db: set schema.provider = \"pgschema\" in sqlx-js.config.ts");
42
+ }
43
+ return config.schema;
44
+ }
45
+ export function managedPgschemaPath(root, asset = resolvePgschemaAsset()) {
46
+ return join(root, "node_modules/.cache/sqlx-js/pgschema", `v${PGSCHEMA_VERSION}`, asset.key, "pgschema");
47
+ }
48
+ function maybeManagedPgschemaPath(root) {
49
+ try {
50
+ const asset = resolvePgschemaAsset();
51
+ const managed = managedPgschemaPath(root, asset);
52
+ if (!existsSync(managed))
53
+ return undefined;
54
+ if (sha256(readFileSync(managed)) !== asset.sha256) {
55
+ throw new Error(`sqlx-js db: managed pgschema checksum mismatch at ${managed}. Run sqlx-js db install.`);
56
+ }
57
+ chmodSync(managed, 0o755);
58
+ return managed;
59
+ }
60
+ catch (e) {
61
+ if (e.message.includes("checksum mismatch"))
62
+ throw e;
63
+ return undefined;
64
+ }
65
+ }
66
+ function commandName(root, config) {
67
+ if (process.platform === "win32")
68
+ throw new Error(WINDOWS_UNSUPPORTED);
69
+ if (config.command)
70
+ return config.command;
71
+ const managed = maybeManagedPgschemaPath(root);
72
+ if (managed && existsSync(managed))
73
+ return managed;
74
+ return "pgschema";
75
+ }
76
+ function schemaFile(root, config) {
77
+ return resolve(root, config.file ?? "schema.sql");
78
+ }
79
+ function appliesPlan(subcommand, passthrough) {
80
+ return subcommand === "apply" && (passthrough ?? []).some((arg) => arg === "--plan" || arg.startsWith("--plan="));
81
+ }
82
+ function installHint(command) {
83
+ return `sqlx-js db: ${command} was not found. Run sqlx-js db install or set schema.command in sqlx-js.config.ts.`;
84
+ }
85
+ function run(command, args, env) {
86
+ const child = spawnSync(command, args, { encoding: "utf8", env, stdio: "inherit" });
87
+ if (child.error) {
88
+ const code = child.error.code;
89
+ if (code === "ENOENT")
90
+ throw new Error(installHint(command));
91
+ throw child.error;
92
+ }
93
+ if (child.signal)
94
+ throw new Error(`sqlx-js db: ${command} terminated by signal ${child.signal}`);
95
+ if (child.status && child.status !== 0)
96
+ process.exit(child.status);
97
+ }
98
+ function pgschemaEnv(db) {
99
+ return {
100
+ ...process.env,
101
+ ...(db.password ? { PGPASSWORD: db.password } : {}),
102
+ ...(db.sslmode ? { PGSSLMODE: db.sslmode } : {}),
103
+ ...(db.sslRootCert ? { PGSSLROOTCERT: db.sslRootCert } : {}),
104
+ ...(db.sslCert ? { PGSSLCERT: db.sslCert } : {}),
105
+ ...(db.sslKey ? { PGSSLKEY: db.sslKey } : {}),
106
+ };
107
+ }
108
+ function sha256(data) {
109
+ return createHash("sha256").update(data).digest("hex");
110
+ }
111
+ export async function runPgschemaInstall(opts) {
112
+ const asset = opts.asset ?? resolvePgschemaAsset();
113
+ const baseUrl = opts.baseUrl ?? PGSCHEMA_BASE_URL;
114
+ const log = opts.log ?? console.log;
115
+ const target = managedPgschemaPath(opts.root, asset);
116
+ if (existsSync(target) && sha256(readFileSync(target)) === asset.sha256) {
117
+ chmodSync(target, 0o755);
118
+ log(`pgschema v${PGSCHEMA_VERSION} already installed at ${target}`);
119
+ return;
120
+ }
121
+ const url = `${baseUrl.replace(/\/$/, "")}/${asset.name}`;
122
+ const response = await fetch(url);
123
+ if (!response.ok) {
124
+ throw new Error(`sqlx-js db install: failed to download pgschema v${PGSCHEMA_VERSION}: HTTP ${response.status}`);
125
+ }
126
+ const bytes = new Uint8Array(await response.arrayBuffer());
127
+ const actual = sha256(bytes);
128
+ if (actual !== asset.sha256) {
129
+ throw new Error(`sqlx-js db install: checksum mismatch for ${asset.name}`);
130
+ }
131
+ mkdirSync(dirname(target), { recursive: true });
132
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
133
+ try {
134
+ writeFileSync(tmp, bytes, { mode: 0o755 });
135
+ chmodSync(tmp, 0o755);
136
+ renameSync(tmp, target);
137
+ }
138
+ catch (e) {
139
+ rmSync(tmp, { force: true });
140
+ throw e;
141
+ }
142
+ writeFileSync(`${target}.json`, JSON.stringify({ version: PGSCHEMA_VERSION, asset: asset.name, sha256: asset.sha256 }, null, 2) + "\n");
143
+ log(`installed pgschema v${PGSCHEMA_VERSION} to ${target}`);
144
+ }
145
+ export function runPgschemaCommand(opts) {
146
+ const config = pgschemaConfig(opts.config);
147
+ const command = commandName(opts.root, config);
148
+ if (opts.subcommand === "check") {
149
+ run(command, ["--help"], process.env);
150
+ return;
151
+ }
152
+ if (!opts.databaseUrl)
153
+ throw new Error("DATABASE_URL is required for sqlx-js db commands");
154
+ const file = appliesPlan(opts.subcommand, opts.passthrough) ? undefined : schemaFile(opts.root, config);
155
+ if (file && !existsSync(file))
156
+ throw new Error(`sqlx-js db: schema file not found: ${file}`);
157
+ const db = parseDatabaseUrl(opts.databaseUrl);
158
+ const schemas = pgschemaSchemas(config);
159
+ const args = [
160
+ opts.subcommand,
161
+ "--host", db.host,
162
+ "--port", String(db.port),
163
+ "--db", db.database,
164
+ "--user", db.user,
165
+ ];
166
+ if (file)
167
+ args.push("--file", file);
168
+ args.push("--schema", schemas[0]);
169
+ args.push(...opts.passthrough ?? []);
170
+ run(command, args, pgschemaEnv(db));
171
+ }
172
+ function pgschemaSchemas(config) {
173
+ const schemas = config.schemas?.length ? config.schemas : ["public"];
174
+ if (schemas.length > 1) {
175
+ throw new Error("sqlx-js db: pgschema 1.12.0 supports exactly one --schema value; split plan/apply per schema or use a single schema in sqlx-js.config.ts");
176
+ }
177
+ return schemas;
178
+ }
@@ -32,6 +32,7 @@ export declare function prepareOnce(opts: PrepareOptions, session: PrepareSessio
32
32
  entries: number;
33
33
  failures: number;
34
34
  pruned: number;
35
+ functions: number;
35
36
  }>;
36
37
  export declare function runPrepare(opts: PrepareOptions): Promise<void>;
37
38
  export {};
@@ -6,6 +6,8 @@ import { scanProject } from "../scan/scanner.js";
6
6
  import { Cache, fingerprint, effectiveNullable } from "../cache.js";
7
7
  import { emitDts } from "../codegen.js";
8
8
  import { loadConfig, lookupJsonbType } from "../config.js";
9
+ import { readFunctionCache, writeFunctionCache } from "../function-cache.js";
10
+ import { introspectFunctions } from "../pg/functions.js";
9
11
  import { buildParamMap } from "../pg/param-map.js";
10
12
  import { mergeExtensionTypes } from "../pg/extensions.js";
11
13
  const JSON_OIDS = new Set([114, 3802]);
@@ -344,8 +346,13 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
344
346
  if (pruned > 0)
345
347
  log(`pruned ${pruned} orphaned cache entry/entries`);
346
348
  }
347
- emitDts(opts.dtsPath, entries);
348
- return { entries: entries.length, failures, pruned };
349
+ let functions = [];
350
+ if (failures === 0) {
351
+ functions = await introspectFunctions(client, schema);
352
+ writeFunctionCache(opts.cacheDir, functions);
353
+ }
354
+ emitDts(opts.dtsPath, entries, functions);
355
+ return { entries: entries.length, failures, pruned, functions: functions.length };
349
356
  }
350
357
  export async function runPrepare(opts) {
351
358
  if (opts.check) {
@@ -377,8 +384,9 @@ export async function runPrepare(opts) {
377
384
  const entry = cache.read(u.fp);
378
385
  return entry ? { ...entry, ...siteUsage(u.sites) } : null;
379
386
  }).filter((e) => e !== null);
380
- emitDts(opts.dtsPath, entries);
381
- console.log(`ok — ${entries.length} unique queries, types regenerated`);
387
+ const functions = readFunctionCache(opts.cacheDir);
388
+ emitDts(opts.dtsPath, entries, functions);
389
+ console.log(`ok — ${entries.length} unique queries, ${functions.length} function(s), types regenerated`);
382
390
  return;
383
391
  }
384
392
  const session = await openSession(opts);
@@ -389,7 +397,7 @@ export async function runPrepare(opts) {
389
397
  await session.client.end();
390
398
  process.exit(1);
391
399
  }
392
- console.log(`\nprepared ${r.entries} unique query/queries → ${opts.dtsPath}`);
400
+ console.log(`\nprepared ${r.entries} unique query/queries, ${r.functions} function(s) → ${opts.dtsPath}`);
393
401
  }
394
402
  finally {
395
403
  await session.client.end();
@@ -1,6 +1,12 @@
1
1
  export type SqlxJsConfig = {
2
2
  jsonbTypes?: Record<string, string>;
3
3
  customTypes?: Record<string, string>;
4
+ schema?: {
5
+ provider?: "builtin" | "pgschema";
6
+ file?: string;
7
+ schemas?: string[];
8
+ command?: string;
9
+ };
4
10
  };
5
11
  export declare function loadConfig(root: string): Promise<SqlxJsConfig>;
6
12
  export declare function lookupJsonbType(cfg: SqlxJsConfig, schema: string, table: string, column: string): string | undefined;
@@ -0,0 +1,18 @@
1
+ export type FunctionKind = "function" | "procedure" | "aggregate" | "window";
2
+ export type FunctionParamMode = "in" | "out" | "inout" | "variadic" | "table";
3
+ export type FunctionParamEntry = {
4
+ mode: FunctionParamMode;
5
+ tsType: string;
6
+ name?: string;
7
+ };
8
+ export type FunctionEntry = {
9
+ schema: string;
10
+ name: string;
11
+ signature: string;
12
+ kind: FunctionKind;
13
+ params: FunctionParamEntry[];
14
+ returns: string;
15
+ returnsSet: boolean;
16
+ };
17
+ export declare function readFunctionCache(cacheDir: string): FunctionEntry[];
18
+ export declare function writeFunctionCache(cacheDir: string, functions: FunctionEntry[]): void;
@@ -0,0 +1,38 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ function cachePath(cacheDir) {
5
+ return join(cacheDir, "functions", "functions.json");
6
+ }
7
+ function parseFunctionCache(raw) {
8
+ if (!raw || typeof raw !== "object")
9
+ return [];
10
+ const obj = raw;
11
+ if (obj.version !== 1 || !Array.isArray(obj.functions))
12
+ return [];
13
+ return obj.functions;
14
+ }
15
+ export function readFunctionCache(cacheDir) {
16
+ const path = cachePath(cacheDir);
17
+ if (!existsSync(path))
18
+ return [];
19
+ const text = readFileSync(path, "utf8");
20
+ return parseFunctionCache(JSON.parse(text));
21
+ }
22
+ export function writeFunctionCache(cacheDir, functions) {
23
+ const path = cachePath(cacheDir);
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ const payload = { version: 1, functions };
26
+ const tmp = `${path}.tmp-${randomBytes(4).toString("hex")}`;
27
+ writeFileSync(tmp, JSON.stringify(payload, null, 2));
28
+ try {
29
+ renameSync(tmp, path);
30
+ }
31
+ catch (err) {
32
+ try {
33
+ unlinkSync(tmp);
34
+ }
35
+ catch { }
36
+ throw err;
37
+ }
38
+ }
@@ -4,6 +4,8 @@ export interface KnownQueries {
4
4
  }
5
5
  export interface KnownFileQueries {
6
6
  }
7
+ export interface KnownFunctions {
8
+ }
7
9
  export type JsonPrimitive = string | number | boolean | null;
8
10
  export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
9
11
  export type JsonObject = {
@@ -0,0 +1,4 @@
1
+ import type { FunctionEntry } from "../function-cache.js";
2
+ import { type PgClient } from "./wire.js";
3
+ import { SchemaCache } from "./schema.js";
4
+ export declare function introspectFunctions(client: PgClient, schema: SchemaCache): Promise<FunctionEntry[]>;
@@ -0,0 +1,150 @@
1
+ import { decodeText } from "./wire.js";
2
+ const JSON_OIDS = new Set([114, 3802]);
3
+ const JSON_ARRAY_OIDS = new Set([199, 3807]);
4
+ const JSON_INPUT = 'import("@onreza/sqlx-js").JsonInput';
5
+ export async function introspectFunctions(client, schema) {
6
+ const rows = await loadFunctionRows(client);
7
+ const typeOids = new Set();
8
+ for (const row of rows) {
9
+ typeOids.add(row.returnOid);
10
+ for (const oid of row.inputArgOids)
11
+ typeOids.add(oid);
12
+ for (const oid of row.allArgOids ?? [])
13
+ typeOids.add(oid);
14
+ }
15
+ await schema.loadCustomTypes([...typeOids]);
16
+ return rows.map((row) => toEntry(row, schema)).sort((a, b) => a.signature.localeCompare(b.signature));
17
+ }
18
+ async function loadFunctionRows(client) {
19
+ const result = await client.simpleQueryAll(`
20
+ SELECT
21
+ n.nspname,
22
+ p.proname,
23
+ p.prokind::text,
24
+ pg_get_function_identity_arguments(p.oid),
25
+ to_json(
26
+ CASE
27
+ WHEN p.proargtypes::text = '' THEN ARRAY[]::oid[]
28
+ ELSE string_to_array(p.proargtypes::text, ' ')::oid[]
29
+ END
30
+ )::text,
31
+ to_json(p.proallargtypes)::text,
32
+ to_json(p.proargmodes)::text,
33
+ to_json(p.proargnames)::text,
34
+ p.prorettype::int8,
35
+ p.proretset
36
+ FROM pg_proc p
37
+ JOIN pg_namespace n ON n.oid = p.pronamespace
38
+ WHERE ${userSchemaFilter("n")}
39
+ ORDER BY n.nspname, p.proname, pg_get_function_identity_arguments(p.oid)
40
+ `);
41
+ return result.rows.map((row) => ({
42
+ schema: decodeText(row[0]),
43
+ name: decodeText(row[1]),
44
+ kind: functionKind(decodeText(row[2])),
45
+ identityArguments: decodeText(row[3]) ?? "",
46
+ inputArgOids: parseNumberJsonArray(decodeText(row[4])),
47
+ allArgOids: parseNullableNumberJsonArray(decodeText(row[5] ?? null)),
48
+ argModes: parseNullableStringJsonArray(decodeText(row[6] ?? null)),
49
+ argNames: parseNullableStringJsonArray(decodeText(row[7] ?? null)),
50
+ returnOid: Number(decodeText(row[8])),
51
+ returnsSet: decodeText(row[9]) === "t",
52
+ }));
53
+ }
54
+ function toEntry(row, schema) {
55
+ const allArgOids = row.allArgOids ?? row.inputArgOids;
56
+ const modes = row.argModes ?? allArgOids.map(() => "i");
57
+ const params = allArgOids.map((oid, i) => {
58
+ const mode = paramMode(modes[i]);
59
+ const resultTsType = outputTsType(oid, schema);
60
+ return {
61
+ mode,
62
+ tsType: mode === "out" || mode === "table" ? resultTsType : inputTsType(oid, schema),
63
+ ...(mode === "inout" ? { resultTsType } : {}),
64
+ ...(row.argNames?.[i] ? { name: row.argNames[i] } : {}),
65
+ };
66
+ });
67
+ const output = params.filter((p) => p.mode === "out" || p.mode === "inout" || p.mode === "table");
68
+ return {
69
+ schema: row.schema,
70
+ name: row.name,
71
+ signature: `${row.schema}.${row.name}(${row.identityArguments})`,
72
+ kind: row.kind,
73
+ params: params.map(persistedParam),
74
+ returns: returnTsType(row, output, schema),
75
+ returnsSet: row.returnsSet,
76
+ };
77
+ }
78
+ function persistedParam(param) {
79
+ return {
80
+ mode: param.mode,
81
+ tsType: param.tsType,
82
+ ...(param.name ? { name: param.name } : {}),
83
+ };
84
+ }
85
+ function returnTsType(row, output, schema) {
86
+ if (output.length > 0)
87
+ return outputObject(output);
88
+ if (row.kind === "procedure")
89
+ return "void";
90
+ return nullableReturn(schema.tsType(row.returnOid));
91
+ }
92
+ function outputObject(output) {
93
+ const fields = output.map((p, i) => {
94
+ const name = p.name && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(p.name) ? p.name : JSON.stringify(p.name ?? `column${i + 1}`);
95
+ return `${name}: ${nullableReturn(p.resultTsType ?? p.tsType)}`;
96
+ });
97
+ return `{ ${fields.join("; ")} }`;
98
+ }
99
+ function inputTsType(oid, schema) {
100
+ if (JSON_OIDS.has(oid))
101
+ return JSON_INPUT;
102
+ if (JSON_ARRAY_OIDS.has(oid))
103
+ return `(${JSON_INPUT})[]`;
104
+ return schema.tsType(oid);
105
+ }
106
+ function outputTsType(oid, schema) {
107
+ return schema.tsType(oid);
108
+ }
109
+ function nullableReturn(tsType) {
110
+ if (tsType === "void")
111
+ return tsType;
112
+ return `${tsType} | null`;
113
+ }
114
+ function parseNumberJsonArray(raw) {
115
+ if (!raw)
116
+ return [];
117
+ const parsed = JSON.parse(raw);
118
+ return Array.isArray(parsed) ? parsed.map(Number).filter((n) => Number.isFinite(n)) : [];
119
+ }
120
+ function parseNullableNumberJsonArray(raw) {
121
+ if (raw === null)
122
+ return null;
123
+ return parseNumberJsonArray(raw);
124
+ }
125
+ function parseNullableStringJsonArray(raw) {
126
+ if (raw === null)
127
+ return null;
128
+ const parsed = JSON.parse(raw);
129
+ return Array.isArray(parsed) ? parsed.map((v) => (typeof v === "string" ? v : "")) : [];
130
+ }
131
+ function paramMode(raw) {
132
+ switch (raw) {
133
+ case "o": return "out";
134
+ case "b": return "inout";
135
+ case "v": return "variadic";
136
+ case "t": return "table";
137
+ default: return "in";
138
+ }
139
+ }
140
+ function functionKind(raw) {
141
+ switch (raw) {
142
+ case "p": return "procedure";
143
+ case "a": return "aggregate";
144
+ case "w": return "window";
145
+ default: return "function";
146
+ }
147
+ }
148
+ function userSchemaFilter(alias) {
149
+ return `${alias}.nspname <> 'information_schema' AND ${alias}.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'`;
150
+ }
@@ -42,6 +42,8 @@ const SCALAR = {
42
42
  2950: { ts: "string" },
43
43
  2205: { ts: "string" },
44
44
  2206: { ts: "string" },
45
+ 2249: { ts: "Record<string, unknown>" },
46
+ 2278: { ts: "void" },
45
47
  3220: { ts: "string" },
46
48
  3614: { ts: "string" },
47
49
  3615: { ts: "string" },
@@ -74,4 +74,5 @@ export declare class SchemaCache {
74
74
  loadCustomTypes(typeOids: number[]): Promise<void>;
75
75
  private resolveBaseTs;
76
76
  customType(oid: number): CustomTypeInfo | undefined;
77
+ tsType(oid: number): string;
77
78
  }
@@ -262,6 +262,9 @@ export class SchemaCache {
262
262
  customType(oid) {
263
263
  return this.customTypes.get(oid);
264
264
  }
265
+ tsType(oid) {
266
+ return this.resolveBaseTs(oid) ?? "unknown";
267
+ }
265
268
  }
266
269
  function key(oid, attno) {
267
270
  return `${oid}/${attno}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onreza/sqlx-js",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Compile-time-checked raw SQL for TypeScript + PostgreSQL. Inspired by sqlx.",
5
5
  "license": "MIT",
6
6
  "type": "module",