@onreza/sqlx-js 0.4.0 → 0.6.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.
Files changed (38) hide show
  1. package/README.md +139 -48
  2. package/ROADMAP.md +1 -3
  3. package/dist/bin/sqlx-js-diagnostics.d.ts +2 -0
  4. package/dist/bin/sqlx-js-diagnostics.js +96 -0
  5. package/dist/bin/sqlx-js.js +337 -89
  6. package/dist/src/artifacts.d.ts +9 -0
  7. package/dist/src/artifacts.js +26 -0
  8. package/dist/src/cache.d.ts +17 -0
  9. package/dist/src/cache.js +83 -1
  10. package/dist/src/codegen.js +14 -2
  11. package/dist/src/commands/doctor.d.ts +16 -0
  12. package/dist/src/commands/doctor.js +196 -0
  13. package/dist/src/commands/init.js +93 -13
  14. package/dist/src/commands/migrate.d.ts +2 -101
  15. package/dist/src/commands/migrate.js +11 -566
  16. package/dist/src/commands/pgschema.d.ts +6 -0
  17. package/dist/src/commands/pgschema.js +30 -0
  18. package/dist/src/commands/prepare.d.ts +41 -6
  19. package/dist/src/commands/prepare.js +440 -63
  20. package/dist/src/commands/schema.js +1 -1
  21. package/dist/src/commands/watch.d.ts +1 -0
  22. package/dist/src/commands/watch.js +23 -9
  23. package/dist/src/config.d.ts +22 -0
  24. package/dist/src/config.js +149 -11
  25. package/dist/src/index.d.ts +4 -2
  26. package/dist/src/index.js +2 -1
  27. package/dist/src/migration-core.d.ts +157 -0
  28. package/dist/src/migration-core.js +578 -0
  29. package/dist/src/postgres-runtime.d.ts +3 -0
  30. package/dist/src/postgres-runtime.js +66 -29
  31. package/dist/src/runtime.d.ts +36 -3
  32. package/dist/src/runtime.js +92 -23
  33. package/dist/src/scan/scanner.d.ts +10 -3
  34. package/dist/src/scan/scanner.js +83 -32
  35. package/dist/src/type-inspection.d.ts +1 -0
  36. package/dist/src/type-inspection.js +20 -0
  37. package/dist/src/typed.d.ts +10 -0
  38. package/package.json +11 -6
@@ -1,28 +1,45 @@
1
1
  import postgres from "postgres";
2
- import { createSqlRuntime, encodeParam, encodePgArrayLiteral, parsePgArrayLiteral } from "./runtime.js";
2
+ import { resolve } from "node:path";
3
+ import { createSqlRuntime, encodePgArrayLiteral, isPrimitiveArrayElement, parameterKind, parsePgArrayLiteral, } from "./runtime.js";
3
4
  import { arrayElementOid, builtinArrayOids } from "./pg/oids.js";
4
5
  const HOOKS = Symbol.for("sqlx-js.hooks");
6
+ function resolvedFileRoot(value) {
7
+ return resolve(value ?? process.env.SQLX_JS_FILE_ROOT ?? process.cwd());
8
+ }
5
9
  class PostgresRuntimeClient {
6
10
  client;
7
11
  onQuery;
8
- constructor(client, onQuery) {
12
+ prepare;
13
+ fileRoot;
14
+ constructor(client, onQuery, prepare = true, fileRoot = resolvedFileRoot()) {
9
15
  this.client = client;
10
16
  this.onQuery = onQuery;
17
+ this.prepare = prepare;
18
+ this.fileRoot = fileRoot;
11
19
  }
12
20
  async query(query, params) {
13
- return await this.client.unsafe(query, params, { prepare: true });
21
+ return await this.client.unsafe(query, params, { prepare: this.prepare });
14
22
  }
15
23
  transformParam(param) {
16
- const elementOid = postgresNativeArrayElementOid(param);
17
- if (elementOid !== undefined)
18
- return this.client.array(param, elementOid);
19
- return encodeParam(param);
24
+ const kind = parameterKind(param);
25
+ if (kind === "json")
26
+ return this.client.json(param.value);
27
+ if (kind === "array") {
28
+ const value = [...param.value];
29
+ if (value.every(isPrimitiveArrayElement))
30
+ return encodePgArrayLiteral(value);
31
+ if (value.every((item) => item === null || parameterKind(item) === "json")) {
32
+ return this.client.array(value, 3807);
33
+ }
34
+ return this.client.array(value);
35
+ }
36
+ return param;
20
37
  }
21
38
  async transaction(fn) {
22
39
  if (!("begin" in this.client))
23
40
  throw new Error("sqlx-js.transaction: nested transactions are not supported");
24
41
  return await this.client.begin(async (tx) => {
25
- return await fn(new PostgresRuntimeClient(tx, this.onQuery));
42
+ return await fn(new PostgresRuntimeClient(tx, this.onQuery, this.prepare, this.fileRoot));
26
43
  });
27
44
  }
28
45
  async close() {
@@ -74,6 +91,7 @@ const STRING_ARRAY_ELEMENT_OIDS = new Set([
74
91
  4451,
75
92
  4536,
76
93
  ]);
94
+ const JSON_ELEMENT_OIDS = new Set([114, 3802]);
77
95
  function parseSimpleArrayElement(oid) {
78
96
  switch (oid) {
79
97
  case 16:
@@ -90,28 +108,21 @@ function parseSimpleArrayElement(oid) {
90
108
  return STRING_ARRAY_ELEMENT_OIDS.has(oid) ? (value) => value : undefined;
91
109
  }
92
110
  }
93
- function postgresNativeArrayElementOid(value) {
94
- if (!Array.isArray(value) || value.length === 0)
95
- return undefined;
96
- let oid;
97
- for (const item of value) {
98
- if (item === null)
99
- continue;
100
- const itemOid = item instanceof Uint8Array ? 17 : undefined;
101
- if (itemOid === undefined)
102
- return undefined;
103
- if (oid !== undefined && oid !== itemOid)
104
- return undefined;
105
- oid = itemOid;
106
- }
107
- return oid;
108
- }
109
111
  function postgresTypes() {
110
112
  const types = { bigint: postgres.BigInt };
111
113
  for (const oid of builtinArrayOids()) {
112
114
  const elementOid = arrayElementOid(oid);
113
115
  if (elementOid === undefined)
114
116
  continue;
117
+ if (JSON_ELEMENT_OIDS.has(elementOid)) {
118
+ types[`array_${oid}`] = {
119
+ to: oid,
120
+ from: [oid],
121
+ serialize: (value) => Array.isArray(value) ? encodePgArrayLiteral(value) : String(value),
122
+ parse: (value) => parsePgArrayLiteral(value, JSON.parse),
123
+ };
124
+ continue;
125
+ }
115
126
  const parseElement = parseSimpleArrayElement(elementOid);
116
127
  if (!parseElement)
117
128
  continue;
@@ -124,10 +135,30 @@ function postgresTypes() {
124
135
  }
125
136
  return types;
126
137
  }
138
+ function typeOids(value) {
139
+ if (value === null || value === undefined)
140
+ return [];
141
+ return Array.isArray(value) ? value : [value];
142
+ }
143
+ function installJsonArrayCodecs(client) {
144
+ const options = client.options;
145
+ options.parsers ??= {};
146
+ options.serializers ??= {};
147
+ const configured = Object.values(options.types ?? {});
148
+ for (const oid of [199, 3807]) {
149
+ const hasParser = configured.some((type) => typeOids(type.from).includes(oid));
150
+ const hasSerializer = configured.some((type) => type.to === oid);
151
+ if (!hasParser)
152
+ options.parsers[oid] = (value) => parsePgArrayLiteral(value, JSON.parse);
153
+ if (!hasSerializer) {
154
+ options.serializers[oid] = (value) => Array.isArray(value) ? encodePgArrayLiteral(value) : String(value);
155
+ }
156
+ }
157
+ }
127
158
  export function createClient(url = process.env.DATABASE_URL, options = {}) {
128
159
  if (!url)
129
160
  throw new Error("sqlx-js: DATABASE_URL is not set");
130
- const { onQuery, statementTimeoutMs, ...pgOptions } = options;
161
+ const { onQuery, statementTimeoutMs, fileRoot, ...pgOptions } = options;
131
162
  const connection = statementTimeoutMs !== undefined
132
163
  ? { ...(pgOptions.connection ?? {}), statement_timeout: statementTimeoutMs }
133
164
  : pgOptions.connection;
@@ -136,12 +167,17 @@ export function createClient(url = process.env.DATABASE_URL, options = {}) {
136
167
  ...(connection ? { connection } : {}),
137
168
  types: { ...postgresTypes(), ...(pgOptions.types ?? {}) },
138
169
  });
139
- if (onQuery)
140
- client[HOOKS] = { onQuery };
170
+ client[HOOKS] = {
171
+ onQuery,
172
+ prepare: pgOptions.prepare ?? true,
173
+ fileRoot: resolvedFileRoot(fileRoot),
174
+ };
141
175
  return client;
142
176
  }
143
177
  function createDefaultClient() {
144
- return new PostgresRuntimeClient(createClient());
178
+ const client = createClient();
179
+ const attached = client[HOOKS];
180
+ return new PostgresRuntimeClient(client, attached.onQuery, attached.prepare, attached.fileRoot);
145
181
  }
146
182
  function getRuntimeClient() {
147
183
  defaultClient ??= createDefaultClient();
@@ -151,8 +187,9 @@ export function getClient() {
151
187
  return getRuntimeClient().client;
152
188
  }
153
189
  export function setClient(client, options) {
190
+ installJsonArrayCodecs(client);
154
191
  const attached = client[HOOKS];
155
- defaultClient = new PostgresRuntimeClient(client, options?.onQuery ?? attached?.onQuery);
192
+ defaultClient = new PostgresRuntimeClient(client, options?.onQuery ?? attached?.onQuery, options?.prepare ?? attached?.prepare ?? client.options?.prepare ?? true, resolvedFileRoot(options?.fileRoot ?? attached?.fileRoot));
156
193
  }
157
194
  export async function close() {
158
195
  if (defaultClient) {
@@ -7,19 +7,47 @@ export type OnQueryEvent = {
7
7
  error?: unknown;
8
8
  };
9
9
  export type OnQueryHook = (event: OnQueryEvent) => void;
10
+ export type RuntimeQueryResult = unknown[] & {
11
+ count?: number | null;
12
+ command?: string | null;
13
+ };
10
14
  export type RuntimeClient = {
11
- query: (query: string, params: unknown[]) => Promise<unknown[]>;
15
+ query: (query: string, params: unknown[]) => Promise<RuntimeQueryResult>;
12
16
  transformParam?: (param: unknown) => unknown;
13
17
  transaction: <R>(fn: (client: RuntimeClient) => Promise<R>) => Promise<R>;
14
18
  close: () => Promise<void>;
15
19
  onQuery?: OnQueryHook;
20
+ fileRoot?: string;
16
21
  };
17
22
  type AnyFn = (...args: unknown[]) => Promise<unknown[]>;
18
23
  type AnyOneFn = (...args: unknown[]) => Promise<unknown>;
19
24
  type AnyOptionalFn = (...args: unknown[]) => Promise<unknown | null>;
25
+ type AnyExecuteFn = (...args: unknown[]) => Promise<ExecuteResult>;
20
26
  type IdentifierFn = (...parts: string[]) => string;
27
+ declare const PARAMETER_KIND: unique symbol;
28
+ export type JsonParameter<T = JsonInputValue> = {
29
+ readonly [PARAMETER_KIND]: "json";
30
+ readonly value: T;
31
+ };
32
+ export type PgArrayParameter<T = unknown> = {
33
+ readonly [PARAMETER_KIND]: "array";
34
+ readonly value: readonly (T | null)[];
35
+ };
36
+ export type JsonPrimitive = string | number | boolean | null;
37
+ export type JsonInputValue = JsonPrimitive | JsonInputObject | JsonInputArray;
38
+ export type JsonInputObject = {
39
+ readonly [key: string]: JsonInputValue | undefined;
40
+ };
41
+ export type JsonInputArray = readonly JsonInputValue[];
42
+ export type ExecuteResult = {
43
+ rowCount: number;
44
+ command: string;
45
+ };
46
+ export declare function json<T extends JsonInputValue>(value: T): JsonParameter<T>;
47
+ export declare function array<T>(value: readonly (T | null)[]): PgArrayParameter<T>;
48
+ export declare function parameterKind(value: unknown): "json" | "array" | undefined;
21
49
  declare function renameRows(rows: unknown[]): unknown[];
22
- declare function isPrimitiveArrayElement(v: unknown): boolean;
50
+ export declare function isPrimitiveArrayElement(v: unknown): boolean;
23
51
  export declare function encodePgArrayLiteral(arr: unknown[]): string;
24
52
  type PgArrayValue<T> = T | null | PgArrayValue<T>[];
25
53
  export declare function parsePgArrayLiteral<T = string>(input: string, parseElement?: (value: string) => T): PgArrayValue<T>[];
@@ -40,21 +68,26 @@ export declare const _internal: {
40
68
  loadSqlFile: typeof loadSqlFile;
41
69
  buildSetTransaction: typeof buildSetTransaction;
42
70
  clearIdentifierCache: typeof clearIdentifierCache;
71
+ parameterKind: typeof parameterKind;
43
72
  toPgError: typeof toPgError;
44
73
  };
45
- declare function loadSqlFile(path: string): string;
74
+ declare function loadSqlFile(path: string, fileRoot?: string): string;
46
75
  export declare function clearSqlFileCache(): void;
47
76
  declare function clearIdentifierCache(): void;
48
77
  export declare function id(...parts: string[]): string;
49
78
  type FileCallable = AnyFn & {
50
79
  one: AnyOneFn;
51
80
  optional: AnyOptionalFn;
81
+ execute: AnyExecuteFn;
52
82
  };
53
83
  type SqlCallable = AnyFn & {
54
84
  file: FileCallable;
55
85
  one: AnyOneFn;
56
86
  optional: AnyOptionalFn;
87
+ execute: AnyExecuteFn;
57
88
  id: IdentifierFn;
89
+ json: typeof json;
90
+ array: typeof array;
58
91
  };
59
92
  export type TransactionOptions = {
60
93
  isolation?: "read uncommitted" | "read committed" | "repeatable read" | "serializable";
@@ -1,7 +1,19 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
3
  import { PgClient, parseDatabaseUrl, PgError } from "./pg/wire.js";
4
- import { applyPending, acquireMigrateLock, releaseMigrateLock, DEFAULT_MIGRATE_LOCK_KEY } from "./commands/migrate.js";
4
+ import { applyPending, acquireMigrateLock, releaseMigrateLock, DEFAULT_MIGRATE_LOCK_KEY } from "./migration-core.js";
5
+ const PARAMETER_KIND = Symbol("sqlx-js.parameter");
6
+ export function json(value) {
7
+ return { [PARAMETER_KIND]: "json", value };
8
+ }
9
+ export function array(value) {
10
+ return { [PARAMETER_KIND]: "array", value };
11
+ }
12
+ export function parameterKind(value) {
13
+ if (!value || typeof value !== "object")
14
+ return undefined;
15
+ return value[PARAMETER_KIND];
16
+ }
5
17
  const SUFFIX = /[!?]$/;
6
18
  function renameRows(rows) {
7
19
  if (rows.length === 0)
@@ -28,9 +40,11 @@ function renameRows(rows) {
28
40
  }
29
41
  return out;
30
42
  }
31
- function isPrimitiveArrayElement(v) {
43
+ export function isPrimitiveArrayElement(v) {
32
44
  if (v === null || v === undefined)
33
45
  return true;
46
+ if (v instanceof Date || v instanceof Uint8Array)
47
+ return true;
34
48
  const t = typeof v;
35
49
  return t === "string" || t === "number" || t === "bigint" || t === "boolean";
36
50
  }
@@ -44,6 +58,18 @@ export function encodePgArrayLiteral(arr) {
44
58
  parts.push("NULL");
45
59
  continue;
46
60
  }
61
+ if (parameterKind(v) === "json") {
62
+ parts.push(quoteArrayElement(JSON.stringify(v.value)));
63
+ continue;
64
+ }
65
+ if (v instanceof Date) {
66
+ parts.push(quoteArrayElement(v.toISOString()));
67
+ continue;
68
+ }
69
+ if (v instanceof Uint8Array) {
70
+ parts.push(quoteArrayElement(`\\x${Buffer.from(v).toString("hex")}`));
71
+ continue;
72
+ }
47
73
  if (typeof v === "bigint") {
48
74
  parts.push(v.toString());
49
75
  continue;
@@ -126,13 +152,12 @@ export function parsePgArrayLiteral(input, parseElement = (value) => value) {
126
152
  return parsed;
127
153
  }
128
154
  export function encodeParam(p) {
129
- if (!Array.isArray(p))
130
- return p;
131
- if (p.length === 0)
132
- return p;
133
- if (!p.every(isPrimitiveArrayElement))
134
- return p;
135
- return encodePgArrayLiteral(p);
155
+ const kind = parameterKind(p);
156
+ if (kind === "json")
157
+ return JSON.stringify(p.value);
158
+ if (kind === "array")
159
+ return encodePgArrayLiteral([...p.value]);
160
+ return p;
136
161
  }
137
162
  export class NoRowsError extends Error {
138
163
  constructor(message = "expected exactly 1 row, got 0") {
@@ -213,16 +238,17 @@ export const _internal = {
213
238
  loadSqlFile,
214
239
  buildSetTransaction,
215
240
  clearIdentifierCache,
241
+ parameterKind,
216
242
  toPgError,
217
243
  };
218
- async function runQuery(client, query, params) {
244
+ async function runRawQuery(client, query, params) {
219
245
  const encoded = params.length === 0
220
246
  ? params
221
247
  : params.map((p) => client.transformParam ? client.transformParam(p) : encodeParam(p));
222
248
  const onQuery = client.onQuery;
223
249
  if (!onQuery) {
224
250
  try {
225
- return renameRows(await client.query(query, encoded));
251
+ return await client.query(query, encoded);
226
252
  }
227
253
  catch (e) {
228
254
  throw toPgError(e) ?? e;
@@ -230,9 +256,14 @@ async function runQuery(client, query, params) {
230
256
  }
231
257
  const start = performance.now();
232
258
  try {
233
- const rows = await client.query(query, encoded);
234
- onQuery({ query, params, durationMs: performance.now() - start, rowCount: rows.length });
235
- return renameRows(rows);
259
+ const result = await client.query(query, encoded);
260
+ onQuery({
261
+ query,
262
+ params,
263
+ durationMs: performance.now() - start,
264
+ rowCount: result.count ?? result.length,
265
+ });
266
+ return result;
236
267
  }
237
268
  catch (e) {
238
269
  const error = toPgError(e) ?? e;
@@ -240,6 +271,16 @@ async function runQuery(client, query, params) {
240
271
  throw error;
241
272
  }
242
273
  }
274
+ async function runQuery(client, query, params) {
275
+ return renameRows(await runRawQuery(client, query, params));
276
+ }
277
+ async function runExecute(client, query, params) {
278
+ const result = await runRawQuery(client, query, params);
279
+ return {
280
+ rowCount: result.count ?? result.length,
281
+ command: result.command ?? "",
282
+ };
283
+ }
243
284
  async function runOne(client, query, params) {
244
285
  const rows = await runQuery(client, query, params);
245
286
  if (rows.length === 1)
@@ -257,8 +298,16 @@ async function runOptional(client, query, params) {
257
298
  throw new TooManyRowsError(rows.length, "0 or 1");
258
299
  }
259
300
  const sqlFileCache = new Map();
260
- function loadSqlFile(path) {
261
- const full = resolve(process.cwd(), path);
301
+ function loadSqlFile(path, fileRoot = process.env.SQLX_JS_FILE_ROOT ?? process.cwd()) {
302
+ const root = resolve(fileRoot);
303
+ if (isAbsolute(path)) {
304
+ throw new Error(`sqlx-js.sql.file: path must be relative to fileRoot: ${path}`);
305
+ }
306
+ const full = resolve(root, path);
307
+ const rel = relative(root, full);
308
+ if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)) {
309
+ throw new Error(`sqlx-js.sql.file: path escapes fileRoot: ${path}`);
310
+ }
262
311
  try {
263
312
  const st = statSync(full);
264
313
  const cached = sqlFileCache.get(full);
@@ -393,13 +442,16 @@ function makeBoundCallable(client) {
393
442
  return runQuery(client, query, params);
394
443
  });
395
444
  const file = (async (path, ...params) => {
396
- return runQuery(client, loadSqlFile(path), params);
445
+ return runQuery(client, loadSqlFile(path, client.fileRoot), params);
397
446
  });
398
447
  file.one = (async (path, ...params) => {
399
- return runOne(client, loadSqlFile(path), params);
448
+ return runOne(client, loadSqlFile(path, client.fileRoot), params);
400
449
  });
401
450
  file.optional = (async (path, ...params) => {
402
- return runOptional(client, loadSqlFile(path), params);
451
+ return runOptional(client, loadSqlFile(path, client.fileRoot), params);
452
+ });
453
+ file.execute = (async (path, ...params) => {
454
+ return runExecute(client, loadSqlFile(path, client.fileRoot), params);
403
455
  });
404
456
  fn.file = file;
405
457
  fn.one = (async (query, ...params) => {
@@ -408,7 +460,12 @@ function makeBoundCallable(client) {
408
460
  fn.optional = (async (query, ...params) => {
409
461
  return runOptional(client, query, params);
410
462
  });
463
+ fn.execute = (async (query, ...params) => {
464
+ return runExecute(client, query, params);
465
+ });
411
466
  fn.id = id;
467
+ fn.json = json;
468
+ fn.array = array;
412
469
  return fn;
413
470
  }
414
471
  function buildSetTransaction(opts) {
@@ -428,13 +485,20 @@ export function createSqlRuntime(getClient) {
428
485
  return runQuery(getClient(), query, params);
429
486
  });
430
487
  const rootFile = (async (path, ...params) => {
431
- return runQuery(getClient(), loadSqlFile(path), params);
488
+ const client = getClient();
489
+ return runQuery(client, loadSqlFile(path, client.fileRoot), params);
432
490
  });
433
491
  rootFile.one = (async (path, ...params) => {
434
- return runOne(getClient(), loadSqlFile(path), params);
492
+ const client = getClient();
493
+ return runOne(client, loadSqlFile(path, client.fileRoot), params);
435
494
  });
436
495
  rootFile.optional = (async (path, ...params) => {
437
- return runOptional(getClient(), loadSqlFile(path), params);
496
+ const client = getClient();
497
+ return runOptional(client, loadSqlFile(path, client.fileRoot), params);
498
+ });
499
+ rootFile.execute = (async (path, ...params) => {
500
+ const client = getClient();
501
+ return runExecute(client, loadSqlFile(path, client.fileRoot), params);
438
502
  });
439
503
  root.file = rootFile;
440
504
  root.one = (async (query, ...params) => {
@@ -443,7 +507,12 @@ export function createSqlRuntime(getClient) {
443
507
  root.optional = (async (query, ...params) => {
444
508
  return runOptional(getClient(), query, params);
445
509
  });
510
+ root.execute = (async (query, ...params) => {
511
+ return runExecute(getClient(), query, params);
512
+ });
446
513
  root.id = id;
514
+ root.json = json;
515
+ root.array = array;
447
516
  root.transaction = (async (fnOrOpts, maybeFn) => {
448
517
  const c = getClient();
449
518
  let opts = {};
@@ -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 function scanFile(absPath: string, root: string): QueryCallSite[];
12
- export declare function scanProject(root: string): QueryCallSite[];
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[];
18
+ export declare function scanFile(absPath: string, root: string, modules?: readonly 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"]);
5
- const SQLX_MODULES = new Set(["@onreza/sqlx-js"]);
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
+ ];
24
+ const DEFAULT_SQLX_MODULES = ["@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,13 +125,13 @@ 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
  }
90
132
  return null;
91
133
  }
92
- export function scanFile(absPath, root) {
134
+ export function scanFile(absPath, root, modules = DEFAULT_SQLX_MODULES) {
93
135
  const text = readFileSync(absPath, "utf8");
94
136
  const source = ts.createSourceFile(absPath, text, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TSX);
95
137
  const importedAliases = new Set();
@@ -100,7 +142,7 @@ export function scanFile(absPath, root) {
100
142
  const mod = stmt.moduleSpecifier;
101
143
  if (!ts.isStringLiteral(mod))
102
144
  continue;
103
- if (!SQLX_MODULES.has(mod.text))
145
+ if (!modules.includes(mod.text))
104
146
  continue;
105
147
  const ic = stmt.importClause;
106
148
  if (!ic)
@@ -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,11 +323,11 @@ 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
- for (const site of scanFile(f, root))
330
+ for (const site of scanFile(f, root, scan.modules ?? DEFAULT_SQLX_MODULES))
280
331
  out.push(site);
281
332
  }
282
333
  return out;
@@ -0,0 +1 @@
1
+ export declare function containsUnknownType(type: string): boolean;