@onreza/sqlx-js 0.4.0 → 0.5.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.
@@ -1,3 +1,4 @@
1
+ import type { ScanConfig } from "../config.js";
1
2
  export type QueryCallSite = {
2
3
  file: string;
3
4
  line: number;
@@ -7,6 +8,12 @@ export type QueryCallSite = {
7
8
  kind: "inline" | "file";
8
9
  sqlFilePath?: string;
9
10
  };
10
- export declare function findSourceFiles(root: string): string[];
11
+ export declare class ScanError extends Error {
12
+ readonly file: string;
13
+ readonly line: number;
14
+ readonly column: number;
15
+ constructor(file: string, line: number, column: number, message: string);
16
+ }
17
+ export declare function findSourceFiles(root: string, scan?: ScanConfig): string[];
11
18
  export declare function scanFile(absPath: string, root: string): QueryCallSite[];
12
- export declare function scanProject(root: string): QueryCallSite[];
19
+ export declare function scanProject(root: string, scan?: ScanConfig): QueryCallSite[];
@@ -1,25 +1,67 @@
1
1
  import ts from "typescript";
2
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
- import { dirname, join, relative, resolve } from "node:path";
4
- const EXCLUDE_DIRS = new Set(["node_modules", ".git", ".sqlx-js", "dist", "build", ".next"]);
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ export class ScanError extends Error {
5
+ file;
6
+ line;
7
+ column;
8
+ constructor(file, line, column, message) {
9
+ super(`sqlx-js: ${file}:${line}:${column} — ${message}`);
10
+ this.file = file;
11
+ this.line = line;
12
+ this.column = column;
13
+ this.name = "ScanError";
14
+ }
15
+ }
16
+ const DEFAULT_EXCLUDES = [
17
+ "**/node_modules/**",
18
+ "**/.git/**",
19
+ "**/.sqlx-js/**",
20
+ "**/dist/**",
21
+ "**/build/**",
22
+ "**/.next/**",
23
+ ];
5
24
  const SQLX_MODULES = new Set(["@onreza/sqlx-js"]);
6
25
  const EXT = /\.(ts|tsx|mts|cts)$/;
7
- function walk(dir, out) {
8
- for (const name of readdirSync(dir)) {
9
- if (EXCLUDE_DIRS.has(name))
10
- continue;
11
- const full = join(dir, name);
12
- const st = statSync(full);
13
- if (st.isDirectory())
14
- walk(full, out);
15
- else if (EXT.test(name))
16
- out.push(full);
26
+ const TS_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
27
+ function formatConfigError(error) {
28
+ return ts.flattenDiagnosticMessageText(error.messageText, "\n");
29
+ }
30
+ function collectTsconfigFiles(configPath, out, visited) {
31
+ const resolved = resolve(configPath);
32
+ if (visited.has(resolved))
33
+ return;
34
+ visited.add(resolved);
35
+ const read = ts.readConfigFile(resolved, ts.sys.readFile);
36
+ if (read.error)
37
+ throw new Error(`sqlx-js scan: ${resolved}: ${formatConfigError(read.error)}`);
38
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, resolve(resolved, ".."), undefined, resolved);
39
+ if (parsed.errors.length > 0) {
40
+ throw new Error(`sqlx-js scan: ${resolved}: ${parsed.errors.map(formatConfigError).join("; ")}`);
41
+ }
42
+ for (const file of parsed.fileNames) {
43
+ if (EXT.test(file))
44
+ out.add(resolve(file));
45
+ }
46
+ for (const reference of parsed.projectReferences ?? []) {
47
+ collectTsconfigFiles(ts.resolveProjectReferencePath(reference), out, visited);
17
48
  }
18
49
  }
19
- export function findSourceFiles(root) {
20
- const out = [];
21
- walk(root, out);
22
- return out;
50
+ export function findSourceFiles(root, scan = {}) {
51
+ const excludes = [...DEFAULT_EXCLUDES, ...(scan.exclude ?? [])];
52
+ if (scan.include !== undefined) {
53
+ if (scan.include.length === 0)
54
+ return [];
55
+ return ts.sys.readDirectory(root, TS_EXTENSIONS, excludes, scan.include).map((file) => resolve(file)).sort();
56
+ }
57
+ const configPath = join(root, "tsconfig.json");
58
+ if (!ts.sys.fileExists(configPath)) {
59
+ return ts.sys.readDirectory(root, TS_EXTENSIONS, excludes, ["**/*"]).map((file) => resolve(file)).sort();
60
+ }
61
+ const configured = new Set();
62
+ collectTsconfigFiles(configPath, configured, new Set());
63
+ const allowed = new Set(ts.sys.readDirectory(root, TS_EXTENSIONS, excludes, ["**/*"]).map((file) => resolve(file)));
64
+ return [...configured].filter((file) => allowed.has(file)).sort();
23
65
  }
24
66
  function classifyCallee(callee, scope) {
25
67
  if (ts.isIdentifier(callee)) {
@@ -45,7 +87,7 @@ function classifyCallee(callee, scope) {
45
87
  return { kind: "transaction" };
46
88
  if (methodName === "file")
47
89
  return { kind: "file" };
48
- if (methodName === "one" || methodName === "optional")
90
+ if (methodName === "one" || methodName === "optional" || methodName === "execute")
49
91
  return { kind: "inline" };
50
92
  return null;
51
93
  }
@@ -57,7 +99,7 @@ function classifyCallee(callee, scope) {
57
99
  if (ts.isIdentifier(mid.expression) && scope.namespaces.has(mid.expression.text)) {
58
100
  if (mid.name.text !== "sql")
59
101
  return null;
60
- if (methodName === "one" || methodName === "optional")
102
+ if (methodName === "one" || methodName === "optional" || methodName === "execute")
61
103
  return { kind: "inline" };
62
104
  if (methodName === "file")
63
105
  return { kind: "file" };
@@ -72,7 +114,7 @@ function classifyCallee(callee, scope) {
72
114
  return null;
73
115
  if (mid.name.text !== "file")
74
116
  return null;
75
- if (methodName === "one" || methodName === "optional")
117
+ if (methodName === "one" || methodName === "optional" || methodName === "execute")
76
118
  return { kind: "file" };
77
119
  return null;
78
120
  }
@@ -83,7 +125,7 @@ function classifyCallee(callee, scope) {
83
125
  scope.namespaces.has(mid.expression.expression.text) &&
84
126
  mid.expression.name.text === "sql" &&
85
127
  mid.name.text === "file" &&
86
- (methodName === "one" || methodName === "optional")) {
128
+ (methodName === "one" || methodName === "optional" || methodName === "execute")) {
87
129
  return { kind: "file" };
88
130
  }
89
131
  }
@@ -130,7 +172,7 @@ export function scanFile(absPath, root) {
130
172
  const recordInline = (first, args) => {
131
173
  if (!ts.isStringLiteralLike(first)) {
132
174
  const pos = here(first);
133
- throw new Error(`sqlx-js: ${fileRel}:${pos.line}:${pos.column} sql() requires a string literal as first argument`);
175
+ throw new ScanError(fileRel, pos.line, pos.column, "sql() requires a string literal as first argument");
134
176
  }
135
177
  const pos = here(first);
136
178
  out.push({
@@ -146,13 +188,22 @@ export function scanFile(absPath, root) {
146
188
  const recordFile = (first, args, callee) => {
147
189
  if (!ts.isStringLiteralLike(first)) {
148
190
  const pos = first ? here(first) : here(callee);
149
- throw new Error(`sqlx-js: ${fileRel}:${pos.line}:${pos.column} sql.file() requires a string literal path`);
191
+ throw new ScanError(fileRel, pos.line, pos.column, "sql.file() requires a string literal path");
150
192
  }
151
193
  const sqlPath = first.text;
152
- const abs = resolve(dirname(absPath), sqlPath);
194
+ if (isAbsolute(sqlPath)) {
195
+ const pos = here(first);
196
+ throw new ScanError(fileRel, pos.line, pos.column, `sql.file path must be relative to --root: ${sqlPath}`);
197
+ }
198
+ const abs = resolve(root, sqlPath);
199
+ const rel = relative(root, abs);
200
+ if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)) {
201
+ const pos = here(first);
202
+ throw new ScanError(fileRel, pos.line, pos.column, `sql.file path escapes --root: ${sqlPath}`);
203
+ }
153
204
  if (!existsSync(abs)) {
154
205
  const pos = here(first);
155
- throw new Error(`sqlx-js: ${fileRel}:${pos.line}:${pos.column} sql.file path not found: ${sqlPath}`);
206
+ throw new ScanError(fileRel, pos.line, pos.column, `sql.file path not found: ${sqlPath}`);
156
207
  }
157
208
  const query = readFileSync(abs, "utf8");
158
209
  const pos = here(first);
@@ -163,7 +214,7 @@ export function scanFile(absPath, root) {
163
214
  query,
164
215
  paramCount: args.length - 1,
165
216
  kind: "file",
166
- sqlFilePath: relative(root, abs),
217
+ sqlFilePath: sqlPath,
167
218
  });
168
219
  return true;
169
220
  };
@@ -272,8 +323,8 @@ export function scanFile(absPath, root) {
272
323
  visit(source, { sqlAliases: importedAliases, namespaces: importedNamespaces });
273
324
  return out;
274
325
  }
275
- export function scanProject(root) {
276
- const files = findSourceFiles(root);
326
+ export function scanProject(root, scan = {}) {
327
+ const files = findSourceFiles(root, scan);
277
328
  const out = [];
278
329
  for (const f of files) {
279
330
  for (const site of scanFile(f, root))
@@ -4,17 +4,27 @@ type ParamsOf<T> = T extends {
4
4
  type RowOf<T> = T extends {
5
5
  row: infer R;
6
6
  } ? R : never;
7
+ type ExecuteResult = import("./runtime.js").ExecuteResult;
8
+ type JsonInputValue = import("./runtime.js").JsonInputValue;
9
+ type JsonParameter<T> = import("./runtime.js").JsonParameter<T>;
10
+ type PgArrayParameter<T> = import("./runtime.js").PgArrayParameter<T>;
11
+ type JsonFn = <T extends JsonInputValue>(value: T) => JsonParameter<T>;
12
+ type ArrayFn = <T>(value: readonly (T | null)[]) => PgArrayParameter<T>;
7
13
  export type TypedFile<TFileQueries> = {
8
14
  <P extends keyof TFileQueries>(path: P, ...params: ParamsOf<TFileQueries[P]>): Promise<RowOf<TFileQueries[P]>[]>;
9
15
  one: <P extends keyof TFileQueries>(path: P, ...params: ParamsOf<TFileQueries[P]>) => Promise<RowOf<TFileQueries[P]>>;
10
16
  optional: <P extends keyof TFileQueries>(path: P, ...params: ParamsOf<TFileQueries[P]>) => Promise<RowOf<TFileQueries[P]> | null>;
17
+ execute: <P extends keyof TFileQueries>(path: P, ...params: ParamsOf<TFileQueries[P]>) => Promise<ExecuteResult>;
11
18
  };
12
19
  export type TypedSql<TQueries, TFileQueries> = {
13
20
  <Q extends keyof TQueries>(query: Q, ...params: ParamsOf<TQueries[Q]>): Promise<RowOf<TQueries[Q]>[]>;
14
21
  one: <Q extends keyof TQueries>(query: Q, ...params: ParamsOf<TQueries[Q]>) => Promise<RowOf<TQueries[Q]>>;
15
22
  optional: <Q extends keyof TQueries>(query: Q, ...params: ParamsOf<TQueries[Q]>) => Promise<RowOf<TQueries[Q]> | null>;
23
+ execute: <Q extends keyof TQueries>(query: Q, ...params: ParamsOf<TQueries[Q]>) => Promise<ExecuteResult>;
16
24
  file: TypedFile<TFileQueries>;
17
25
  id: (...parts: string[]) => string;
26
+ json: JsonFn;
27
+ array: ArrayFn;
18
28
  };
19
29
  export type Typed<TQueries, TFileQueries, TTransactionOptions> = TypedSql<TQueries, TFileQueries> & {
20
30
  transaction: {
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@onreza/sqlx-js",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Compile-time-checked raw SQL for TypeScript + PostgreSQL. Inspired by sqlx.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "engines": {
8
- "node": ">=18"
8
+ "node": ">=24",
9
+ "bun": ">=1.3"
9
10
  },
10
11
  "sideEffects": false,
11
12
  "main": "./dist/src/index.js",
@@ -49,13 +50,13 @@
49
50
  "migrations"
50
51
  ],
51
52
  "scripts": {
52
- "prepare": "(test -f dist/bin/sqlx-js.js || bun run build) && ([ ! -d .git ] || lefthook install >/dev/null 2>&1 || true)",
53
- "test": "bun test tests",
53
+ "prepare": "node scripts/prepare-package.mjs",
54
+ "test": "bun test tests --timeout 120000",
54
55
  "test:typecheck": "tsc --noEmit",
55
56
  "test:example": "bunx tsc -p example --noEmit",
56
57
  "sqlx:prepare": "bun bin/sqlx-js.ts prepare",
57
58
  "sqlx:migrate": "bun bin/sqlx-js.ts migrate",
58
- "build": "rm -rf dist && tsc -p tsconfig.build.json && bun scripts/fix-dist-imports.ts",
59
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json && bun scripts/fix-dist-imports.ts",
59
60
  "prepack": "bun run build"
60
61
  },
61
62
  "peerDependencies": {