@onreza/sqlx-js 0.7.0 → 0.8.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
@@ -53,11 +53,13 @@ const rows = await sql(
53
53
 
54
54
  ```bash
55
55
  npm install @onreza/sqlx-js
56
+ npm install --save-dev typescript
56
57
  # or
57
58
  bun add @onreza/sqlx-js
59
+ bun add --dev typescript
58
60
  ```
59
61
 
60
- Node.js 24 or newer is required. Bun users need Bun 1.3 or newer.
62
+ Node.js 24 or newer is required. Bun users need Bun 1.3 or newer. TypeScript is an optional peer so production-only installs do not pull the compiler and its platform package into the application image; source scanning commands (`prepare`, `doctor`, `ci`, and migration development/verification) require it in development dependencies.
61
63
 
62
64
  The package installs `sqlx-js` and `sqlx-js-diagnostics` binaries. The CLI examples below use `npx @onreza/sqlx-js`; `bunx @onreza/sqlx-js ...` works the same if your project uses Bun.
63
65
 
@@ -112,6 +114,20 @@ CREATE TABLE users (
112
114
  );
113
115
  ```
114
116
 
117
+ For queries with several values, named parameters keep the SQL and arguments aligned:
118
+
119
+ ```ts
120
+ const rows = await sql(
121
+ `SELECT id, name
122
+ FROM users
123
+ WHERE email = $email OR recovery_email = $email
124
+ LIMIT $limit`,
125
+ { email: "user@example.com", limit: 10 },
126
+ );
127
+ ```
128
+
129
+ Named parameters use ASCII identifier names (`$user_id`), are numbered by first appearance before PostgreSQL sees the query, and repeated names reuse the same positional parameter. The generated object contract rejects missing, extra, and incorrectly typed properties. Named `$name` and positional `$1` parameters cannot be mixed in one query. Quoted strings, comments, dollar-quoted bodies, and `$` inside PostgreSQL identifiers are left unchanged. Positional parameters remain supported and are still the shortest form for simple queries.
130
+
115
131
  During local development, validate the migration and regenerate query artifacts against a disposable shadow database:
116
132
 
117
133
  ```bash
@@ -411,10 +427,11 @@ In addition to `import { sql } from "@onreza/sqlx-js"`, the scanner recognises `
411
427
  ```
412
428
  sqlx-js init [--root <dir>] [--schema-provider builtin|pgschema]
413
429
  sqlx-js doctor [--root <dir>] [--dts <path>] [--json]
430
+ sqlx-js ci [--root <dir>] [--dts <path>] [--schema <path>] [--json] [--shadow-url <url>] [--shadow-admin-url <url>]
414
431
  sqlx-js db install | check [--root <dir>]
415
432
  sqlx-js db plan | apply [--root <dir>] [-- <pgschema args>]
416
433
  sqlx-js prepare [--check | --offline | --verify | --watch] [--json | --jsonl] [--strict-inference] [--root <dir>] [--dts <path>] [--no-prune] [--shadow-url <url>]
417
- sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | 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]
434
+ sqlx-js migrate dev [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | 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]
418
435
  sqlx-js schema dump [--schema <path>] [--manifest <path>] [--no-manifest] [--shadow-url <url>]
419
436
  sqlx-js schema check [--schema <path>] [--shadow-url <url>]
420
437
  sqlx-js --version | --help
@@ -680,6 +697,14 @@ The runtime strips the `!`/`?` suffix from column keys so the row shape stays cl
680
697
 
681
698
  ## CI workflow
682
699
 
700
+ The shortest production gate is provider-aware:
701
+
702
+ ```bash
703
+ sqlx-js ci
704
+ ```
705
+
706
+ For the built-in migration provider it runs shadow migration verification with strict inference, followed by the read-only offline artifact check. For pgschema it checks the configured provider, fails when the desired schema produces an unapplied plan, performs live `prepare --verify --strict-inference`, and then verifies committed artifacts offline. If a committed schema snapshot exists, both flows also run `schema check`. `--json` returns a versioned per-step report suitable for CI systems.
707
+
683
708
  Commit the generated `sqlx-js-env.d.ts` and the `.sqlx-js/` cache directory to your repo. In CI:
684
709
 
685
710
  ```yaml
package/ROADMAP.md CHANGED
@@ -7,11 +7,12 @@ Items already shipped live in the [README](./README.md) feature list; this file
7
7
  | Feature | ROI | Notes |
8
8
  |---------|-----|-------|
9
9
  | pgschema integration hardening | 6 | Managed install/check/plan/apply now has a real pinned-binary PostgreSQL E2E in CI. Continue with schema snapshot handoff and migration guidance for projects that outgrow built-in migrations. |
10
+ | Separate runtime package | Deferred | The audited root import already excludes compile-time modules. Making TypeScript an optional peer reduced a clean production install from about 33 MB to 2.4 MB; a second public package and release boundary is not justified for the remaining analyzer dependency unless production consumers demonstrate measurable pressure. |
10
11
  | 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. |
11
12
  | 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. |
12
13
  | 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. |
13
14
  | Tagged-template literal API (`` sql`SELECT ${x}` ``) | 8 | Restoring sqlx's inline-SQL aesthetic requires either a TS compiler plugin (`ts-patch`) or a Bun preload-time AST rewriter. TS itself hardcodes the first tag argument as `TemplateStringsArray` and refuses to narrow to literal tuples. Significant effort, large UX win. |
14
- | LSP server | 5 | Realtime hover and schema autocomplete. Versioned batch JSON, incremental `prepare --watch --jsonl`, and `sqlx-js-diagnostics` already provide the transport for editor problems; a full server still needs separate VS Code / Neovim clients. |
15
+ | Editor integration / LSP | Deferred | Keep the versioned batch JSON, incremental `prepare --watch --jsonl`, and `sqlx-js-diagnostics` transport stable, but do not build or maintain a VS Code extension or full LSP until real consumer demand justifies the separate editor clients and release lifecycle. |
15
16
  | Schema-aware `jsonb` runtime validation | 5 | Optional opt-in: pass a Zod / Valibot / ArkType schema, validate rows on read. Currently we are compile-time-only by design. |
16
17
  | MySQL backend | 5 | Some runtime clients support it, but MySQL has no `Describe Statement` equivalent. Would need a real SQL parser pass + `INFORMATION_SCHEMA` introspection. |
17
18
  | SQLite backend | 4 | SQLite's column types are dynamic. Would require running `EXPLAIN` and a heuristic mapper, or schema-driven inference per-statement. |
@@ -21,6 +22,6 @@ Items already shipped live in the [README](./README.md) feature list; this file
21
22
 
22
23
  ## Long-term
23
24
 
24
- - Full LSP with schema-driven autocomplete.
25
+ - Editor clients or a full LSP only after repeated consumer demand demonstrates that the separate maintenance and release lifecycle will pay for itself.
25
26
  - Hooks for ORM-like helpers that build on top of the typed `sql()` primitive (joins, paginated queries, etc.) without becoming an ORM.
26
27
  - Optional binary protocol support in the underlying wire client for measurable perf gain on large result sets.
@@ -22,10 +22,11 @@ const HELP = {
22
22
  usage:
23
23
  sqlx-js init [--root <dir>] [--schema-provider builtin|pgschema]
24
24
  sqlx-js doctor [--root <dir>] [--dts <path>] [--json]
25
+ sqlx-js ci [--root <dir>] [--dts <path>] [--schema <path>] [--json] [--shadow-url <url>] [--shadow-admin-url <url>]
25
26
  sqlx-js db install | check [--root <dir>]
26
27
  sqlx-js db plan | apply [--root <dir>] [-- <pgschema args>]
27
28
  sqlx-js prepare [--check | --offline | --verify | --watch] [--json | --jsonl] [--strict-inference] [--root <dir>] [--dts <path>] [--no-prune] [--shadow-url <url>]
28
- sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | 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
+ sqlx-js migrate dev [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | 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
30
  sqlx-js schema dump [--schema <path>] [--manifest <path>] [--no-manifest] [--shadow-url <url>]
30
31
  sqlx-js schema check [--schema <path>] [--shadow-url <url>]
31
32
  sqlx-js --version
@@ -62,9 +63,10 @@ flags:
62
63
  `,
63
64
  init: `usage: sqlx-js init [--root <dir>] [--schema-provider builtin|pgschema]`,
64
65
  doctor: `usage: sqlx-js doctor [--root <dir>] [--dts <path>] [--json]`,
66
+ ci: `usage: sqlx-js ci [--root <dir>] [--dts <path>] [--schema <path>] [--json] [--shadow-url <url>] [--shadow-admin-url <url>] [--migrations <dir>]`,
65
67
  db: `usage: sqlx-js db install | check [--root <dir>] | plan | apply [--root <dir>] [-- <pgschema args>]`,
66
68
  prepare: `usage: sqlx-js prepare [--check | --offline | --verify | --watch] [--json | --jsonl] [--strict-inference] [--root <dir>] [--dts <path>] [--no-prune] [--shadow-url <url>]`,
67
- migrate: `usage: sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | 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]`,
69
+ migrate: `usage: sqlx-js migrate dev [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | 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]`,
68
70
  schema: `usage: sqlx-js schema dump [--schema <path>] [--manifest <path>] [--no-manifest] [--shadow-url <url>] | check [--schema <path>] [--shadow-url <url>]`,
69
71
  };
70
72
  function printHelp(scope, error = false) {
@@ -84,7 +86,7 @@ const passthroughIndex = rawArgv.indexOf("--");
84
86
  const cliArgv = passthroughIndex >= 0 ? rawArgv.slice(0, passthroughIndex) : rawArgv;
85
87
  const passthroughArgs = passthroughIndex >= 0 ? rawArgv.slice(passthroughIndex + 1) : [];
86
88
  const cmd = cliArgv[0];
87
- const scopes = new Set(["init", "doctor", "db", "prepare", "migrate", "schema"]);
89
+ const scopes = new Set(["init", "doctor", "ci", "db", "prepare", "migrate", "schema"]);
88
90
  if (cmd === "--version" || cmd === "-v") {
89
91
  console.log(VERSION);
90
92
  process.exit(0);
@@ -107,6 +109,16 @@ function optionsFor(command, subcommand) {
107
109
  return { ...ROOT_OPTIONS, "schema-provider": { type: "string" } };
108
110
  if (command === "doctor")
109
111
  return { ...ROOT_OPTIONS, dts: { type: "string" }, json: { type: "boolean" } };
112
+ if (command === "ci")
113
+ return {
114
+ ...ROOT_OPTIONS,
115
+ json: { type: "boolean" },
116
+ dts: { type: "string" },
117
+ schema: { type: "string" },
118
+ migrations: { type: "string" },
119
+ "shadow-url": { type: "string" },
120
+ "shadow-admin-url": { type: "string" },
121
+ };
110
122
  if (command === "db")
111
123
  return ROOT_OPTIONS;
112
124
  if (command === "prepare") {
@@ -145,6 +157,7 @@ function optionsFor(command, subcommand) {
145
157
  if (subcommand === "dev") {
146
158
  return {
147
159
  ...common,
160
+ dts: { type: "string" },
148
161
  "shadow-admin-url": { type: "string" },
149
162
  "shadow-url": { type: "string" },
150
163
  "lock-timeout": { type: "string" },
@@ -155,6 +168,7 @@ function optionsFor(command, subcommand) {
155
168
  if (subcommand === "verify") {
156
169
  return {
157
170
  ...common,
171
+ dts: { type: "string" },
158
172
  "shadow-admin-url": { type: "string" },
159
173
  "shadow-url": { type: "string" },
160
174
  "lock-timeout": { type: "string" },
@@ -214,7 +228,7 @@ function requirePositionals(min, max, label) {
214
228
  }
215
229
  }
216
230
  function validateInvocation() {
217
- if (cmd === "init" || cmd === "doctor" || cmd === "prepare") {
231
+ if (cmd === "init" || cmd === "doctor" || cmd === "ci" || cmd === "prepare") {
218
232
  requirePositionals(0, 0, cmd);
219
233
  return;
220
234
  }
@@ -264,17 +278,55 @@ function validateInvocation() {
264
278
  }
265
279
  validateInvocation();
266
280
  const root = resolve(arg("--root", process.cwd()));
281
+ function failCiPreflight(message) {
282
+ if (cmd === "ci" && flag("--json")) {
283
+ console.log(JSON.stringify({
284
+ formatVersion: 1,
285
+ ok: false,
286
+ results: [{ name: "preflight", ok: false, durationMs: 0, exitCode: 2, stderr: message }],
287
+ }, null, 2));
288
+ }
289
+ else {
290
+ console.error(message);
291
+ }
292
+ process.exit(2);
293
+ }
267
294
  if (cmd !== "doctor") {
268
295
  try {
269
296
  assertSupportedRuntime();
270
297
  }
271
298
  catch (e) {
272
- console.error(e.message);
299
+ failCiPreflight(e.message);
300
+ }
301
+ }
302
+ const needsTypeScript = cmd === "doctor" ||
303
+ cmd === "ci" ||
304
+ cmd === "prepare" ||
305
+ (cmd === "migrate" && (positionals[0] === "dev" || positionals[0] === "verify"));
306
+ if (needsTypeScript) {
307
+ try {
308
+ import.meta.resolve("typescript");
309
+ }
310
+ catch {
311
+ const message = "sqlx-js: TypeScript is required for source scanning. Install it with `npm install --save-dev typescript` or `bun add --dev typescript`.";
312
+ if (cmd === "doctor" && flag("--json")) {
313
+ console.log(JSON.stringify({
314
+ formatVersion: 1,
315
+ ok: false,
316
+ checks: [{ name: "typescript", status: "error", message }],
317
+ }, null, 2));
318
+ }
319
+ else {
320
+ if (cmd === "ci")
321
+ failCiPreflight(message);
322
+ console.error(message);
323
+ }
273
324
  process.exit(2);
274
325
  }
275
326
  }
276
327
  let envError;
277
328
  const needsEnvironment = cmd === "doctor" ||
329
+ cmd === "ci" ||
278
330
  cmd === "schema" ||
279
331
  (cmd === "db" && (positionals[0] === "plan" || positionals[0] === "apply")) ||
280
332
  (cmd === "prepare" && !flag("--check") && !flag("--offline")) ||
@@ -286,8 +338,7 @@ if (needsEnvironment) {
286
338
  catch (e) {
287
339
  envError = e.message;
288
340
  if (cmd !== "doctor") {
289
- console.error(envError);
290
- process.exit(2);
341
+ failCiPreflight(envError);
291
342
  }
292
343
  }
293
344
  }
@@ -316,6 +367,28 @@ else if (cmd === "doctor") {
316
367
  const { runDoctor } = await import("../src/commands/doctor.js");
317
368
  await runDoctor({ root, databaseUrl, cacheDir, dtsPath, json: flag("--json"), envError });
318
369
  }
370
+ else if (cmd === "ci") {
371
+ const { runCi } = await import("../src/commands/ci.js");
372
+ let config;
373
+ try {
374
+ config = await loadConfig(root);
375
+ }
376
+ catch (error) {
377
+ failCiPreflight(error.message);
378
+ }
379
+ runCi({
380
+ executable: process.execPath,
381
+ cliPath: fileURLToPath(import.meta.url),
382
+ root,
383
+ config,
384
+ schemaPath,
385
+ json: flag("--json"),
386
+ shadowUrl,
387
+ shadowAdminUrl,
388
+ migrationsDir: arg("--migrations"),
389
+ dtsPath: dtsArg ? dtsPath : undefined,
390
+ });
391
+ }
319
392
  else if (cmd === "db") {
320
393
  const { runPgschemaCommand, runPgschemaInstall } = await import("../src/commands/pgschema.js");
321
394
  const sub = positionals[0];
@@ -1,5 +1,5 @@
1
- export declare const CACHE_FORMAT_VERSION = 2;
2
- export declare const GENERATOR_REVISION = 4;
1
+ export declare const CACHE_FORMAT_VERSION = 3;
2
+ export declare const GENERATOR_REVISION = 5;
3
3
  export declare const CACHE_MANIFEST_FILE = "cache-manifest.json";
4
4
  export type CacheManifest = {
5
5
  cacheFormat: typeof CACHE_FORMAT_VERSION;
@@ -19,6 +19,7 @@ export type CacheEntry = {
19
19
  paramOids: number[];
20
20
  paramTsTypes: string[];
21
21
  paramNullable?: boolean[];
22
+ paramNames?: string[];
22
23
  columns: CacheColumn[];
23
24
  hasResultSet: boolean;
24
25
  hasInline?: boolean;
package/dist/src/cache.js CHANGED
@@ -2,8 +2,10 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { isBuiltinOid } from "./pg/oids.js";
5
- export const CACHE_FORMAT_VERSION = 2;
6
- export const GENERATOR_REVISION = 4;
5
+ import { isEscapeStringPrefix, isIdentifierContinuation, readBlockComment, readDollarQuoted, readLineComment, readQuotedIdentifier, readSingleQuoted, } from "./sql-lex.js";
6
+ import { rewriteNamedParameters } from "./sql-params.js";
7
+ export const CACHE_FORMAT_VERSION = 3;
8
+ export const GENERATOR_REVISION = 5;
7
9
  export const CACHE_MANIFEST_FILE = "cache-manifest.json";
8
10
  export function portableCacheOid(oid) {
9
11
  return isBuiltinOid(oid) ? oid : 0;
@@ -56,7 +58,7 @@ function normalizeForFingerprint(query) {
56
58
  continue;
57
59
  }
58
60
  if (ch === "$") {
59
- const next = readDollarQuoted(query, i);
61
+ const next = i === 0 || !isIdentifierContinuation(query[i - 1]) ? readDollarQuoted(query, i) : null;
60
62
  if (next !== null) {
61
63
  emit(query.slice(i, next));
62
64
  i = next;
@@ -68,77 +70,6 @@ function normalizeForFingerprint(query) {
68
70
  }
69
71
  return out;
70
72
  }
71
- function readSingleQuoted(query, start, escapeBackslash) {
72
- let i = start + 1;
73
- while (i < query.length) {
74
- const ch = query[i];
75
- if (escapeBackslash && ch === "\\") {
76
- i += 2;
77
- continue;
78
- }
79
- if (ch === "'") {
80
- if (query[i + 1] === "'") {
81
- i += 2;
82
- continue;
83
- }
84
- return i + 1;
85
- }
86
- i++;
87
- }
88
- return query.length;
89
- }
90
- function readQuotedIdentifier(query, start) {
91
- let i = start + 1;
92
- while (i < query.length) {
93
- if (query[i] === "\"") {
94
- if (query[i + 1] === "\"") {
95
- i += 2;
96
- continue;
97
- }
98
- return i + 1;
99
- }
100
- i++;
101
- }
102
- return query.length;
103
- }
104
- function readDollarQuoted(query, start) {
105
- let tagEnd = start + 1;
106
- while (tagEnd < query.length && /[A-Za-z0-9_]/.test(query[tagEnd]))
107
- tagEnd++;
108
- if (query[tagEnd] !== "$")
109
- return null;
110
- const tag = query.slice(start, tagEnd + 1);
111
- const end = query.indexOf(tag, tagEnd + 1);
112
- return end === -1 ? query.length : end + tag.length;
113
- }
114
- function readLineComment(query, start) {
115
- const end = query.indexOf("\n", start + 2);
116
- return end === -1 ? query.length : end + 1;
117
- }
118
- function readBlockComment(query, start) {
119
- let depth = 1;
120
- let i = start + 2;
121
- while (i < query.length && depth > 0) {
122
- if (query[i] === "/" && query[i + 1] === "*") {
123
- depth++;
124
- i += 2;
125
- continue;
126
- }
127
- if (query[i] === "*" && query[i + 1] === "/") {
128
- depth--;
129
- i += 2;
130
- continue;
131
- }
132
- i++;
133
- }
134
- return i;
135
- }
136
- function isEscapeStringPrefix(query, quoteIndex) {
137
- if (quoteIndex === 0 || query[quoteIndex - 1]?.toLowerCase() !== "e")
138
- return false;
139
- const beforePrefix = query[quoteIndex - 2];
140
- return beforePrefix === undefined || !/[A-Za-z0-9_$]/.test(beforePrefix);
141
- }
142
73
  export function effectiveNullable(c) {
143
74
  if (c.override === "non-null")
144
75
  return false;
@@ -166,6 +97,27 @@ function assertEntryShape(fp, raw) {
166
97
  throw new Error(`sqlx-js: cache entry ${fp}.json is malformed`);
167
98
  }
168
99
  const cols = raw.columns;
100
+ const entry = raw;
101
+ let expectedNames;
102
+ try {
103
+ if (typeof entry.query !== "string")
104
+ throw new Error("query must be a string");
105
+ expectedNames = rewriteNamedParameters(entry.query).names;
106
+ }
107
+ catch {
108
+ throw new Error(`sqlx-js: cache entry ${fp}.json has malformed named parameter metadata. Run \`sqlx-js prepare\`.`);
109
+ }
110
+ if (entry.paramNames !== undefined || expectedNames.length > 0) {
111
+ if (!Array.isArray(entry.paramNames) ||
112
+ !entry.paramNames.every((name) => typeof name === "string") ||
113
+ !Array.isArray(entry.paramTsTypes) ||
114
+ entry.paramNames.length !== entry.paramTsTypes.length ||
115
+ new Set(entry.paramNames).size !== entry.paramNames.length ||
116
+ entry.paramNames.length !== expectedNames.length ||
117
+ entry.paramNames.some((name, index) => name !== expectedNames[index])) {
118
+ throw new Error(`sqlx-js: cache entry ${fp}.json has malformed named parameter metadata. Run \`sqlx-js prepare\`.`);
119
+ }
120
+ }
169
121
  if (cols.length > 0) {
170
122
  const c = cols[0];
171
123
  if ("forceNonNull" in c || "forceNullable" in c) {
@@ -7,7 +7,9 @@ function entrySignature(e) {
7
7
  const nullable = e.paramNullable?.[i] === true;
8
8
  return nullable ? `${t} | null` : t;
9
9
  });
10
- const params = paramTypes.length === 0 ? "[]" : `[${paramTypes.join(", ")}]`;
10
+ const params = e.paramNames && e.paramNames.length > 0
11
+ ? `{ ${e.paramNames.map((name, index) => `${JSON.stringify(name)}: ${paramTypes[index]}`).join("; ")} }`
12
+ : paramTypes.length === 0 ? "[]" : `[${paramTypes.join(", ")}]`;
11
13
  if (!e.hasResultSet)
12
14
  return { params, row: "never" };
13
15
  const cols = e.columns.map((c) => {
@@ -0,0 +1,28 @@
1
+ import type { SqlxJsConfig } from "../config.js";
2
+ export type CiStep = {
3
+ name: string;
4
+ args: string[];
5
+ check?: "pgschema-plan";
6
+ };
7
+ export type CiStepResult = {
8
+ name: string;
9
+ ok: boolean;
10
+ durationMs: number;
11
+ exitCode: number;
12
+ stderr?: string;
13
+ };
14
+ export type CiOptions = {
15
+ executable: string;
16
+ cliPath: string;
17
+ root: string;
18
+ config: SqlxJsConfig;
19
+ schemaPath: string;
20
+ json?: boolean;
21
+ shadowUrl?: string;
22
+ shadowAdminUrl?: string;
23
+ migrationsDir?: string;
24
+ dtsPath?: string;
25
+ };
26
+ export declare function buildCiSteps(opts: Pick<CiOptions, "config" | "root" | "schemaPath" | "shadowUrl" | "shadowAdminUrl" | "migrationsDir" | "dtsPath">): CiStep[];
27
+ export declare function assertPgschemaPlanClean(path: string): void;
28
+ export declare function runCi(opts: CiOptions): void;
@@ -0,0 +1,103 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ const PLAN_PATH = "{sqlx-js-ci-plan}";
6
+ export function buildCiSteps(opts) {
7
+ const root = ["--root", opts.root];
8
+ const shadow = opts.shadowUrl ? ["--shadow-url", opts.shadowUrl] : [];
9
+ const shadowAdmin = opts.shadowAdminUrl ? ["--shadow-admin-url", opts.shadowAdminUrl] : [];
10
+ const migrations = opts.migrationsDir ? ["--migrations", opts.migrationsDir] : [];
11
+ const dts = opts.dtsPath ? ["--dts", opts.dtsPath] : [];
12
+ const steps = opts.config.schema?.provider === "pgschema"
13
+ ? [
14
+ { name: "pgschema", args: ["db", "check", ...root] },
15
+ {
16
+ name: "pgschema-plan",
17
+ args: ["db", "plan", ...root, "--", "--output-json", PLAN_PATH, "--no-color"],
18
+ check: "pgschema-plan",
19
+ },
20
+ { name: "prepare-live", args: ["prepare", "--verify", "--strict-inference", ...shadow, ...dts, ...root] },
21
+ ]
22
+ : [{
23
+ name: "migrations",
24
+ args: ["migrate", "verify", "--strict-inference", ...shadow, ...shadowAdmin, ...migrations, ...dts, ...root],
25
+ }];
26
+ steps.push({ name: "prepare-offline", args: ["prepare", "--check", "--strict-inference", ...dts, ...root] });
27
+ if (existsSync(opts.schemaPath)) {
28
+ steps.push({ name: "schema", args: ["schema", "check", ...shadow, "--schema", opts.schemaPath, ...migrations, ...root] });
29
+ }
30
+ return steps;
31
+ }
32
+ export function assertPgschemaPlanClean(path) {
33
+ const plan = JSON.parse(readFileSync(path, "utf8"));
34
+ if (plan.groups === null)
35
+ return;
36
+ if (!Array.isArray(plan.groups))
37
+ throw new Error("plan JSON does not contain groups as an array or null");
38
+ if (plan.groups.length > 0)
39
+ throw new Error(`pgschema plan contains ${plan.groups.length} unapplied change group(s)`);
40
+ }
41
+ export function runCi(opts) {
42
+ const results = [];
43
+ const temp = mkdtempSync(join(tmpdir(), "sqlx-js-ci-"));
44
+ try {
45
+ for (const step of buildCiSteps(opts)) {
46
+ if (!opts.json)
47
+ console.log(`ci: ${step.name}`);
48
+ const started = performance.now();
49
+ const planPath = join(temp, "pgschema-plan.json");
50
+ const args = step.args.map((arg) => arg === PLAN_PATH ? planPath : arg);
51
+ const result = spawnSync(opts.executable, [opts.cliPath, ...args], {
52
+ encoding: "utf8",
53
+ env: process.env,
54
+ stdio: opts.json ? ["ignore", "ignore", "pipe"] : "inherit",
55
+ maxBuffer: 4 * 1024 * 1024,
56
+ });
57
+ let exitCode = result.status ?? 1;
58
+ let checkError;
59
+ if (exitCode === 0 && step.check === "pgschema-plan") {
60
+ try {
61
+ assertPgschemaPlanClean(planPath);
62
+ }
63
+ catch (error) {
64
+ checkError = error.message.startsWith("pgschema plan contains")
65
+ ? error.message
66
+ : `cannot verify pgschema plan: ${error.message}`;
67
+ exitCode = 1;
68
+ }
69
+ }
70
+ if (result.error) {
71
+ checkError = result.error.message;
72
+ exitCode = 1;
73
+ }
74
+ else if (result.signal) {
75
+ checkError = `terminated by signal ${result.signal}`;
76
+ exitCode = 1;
77
+ }
78
+ if (checkError && !opts.json)
79
+ console.error(`ci: ${step.name}: ${checkError}`);
80
+ results.push({
81
+ name: step.name,
82
+ ok: exitCode === 0,
83
+ durationMs: Math.round(performance.now() - started),
84
+ exitCode,
85
+ ...(opts.json && exitCode !== 0 && (checkError || result.stderr)
86
+ ? { stderr: [result.stderr?.trim(), checkError].filter(Boolean).join("\n") }
87
+ : {}),
88
+ });
89
+ if (exitCode !== 0)
90
+ break;
91
+ }
92
+ }
93
+ finally {
94
+ rmSync(temp, { recursive: true, force: true });
95
+ }
96
+ const ok = results.length > 0 && results.every((result) => result.ok);
97
+ if (opts.json)
98
+ console.log(JSON.stringify({ formatVersion: 1, ok, results }, null, 2));
99
+ else if (ok)
100
+ console.log(`ci: ok — ${results.length} check(s) passed`);
101
+ if (!ok)
102
+ process.exitCode = 1;
103
+ }
@@ -143,6 +143,7 @@ export function runInit(opts) {
143
143
  "sqlx:check": "sqlx-js prepare --check",
144
144
  "sqlx:offline": "sqlx-js prepare --offline",
145
145
  "sqlx:verify": "sqlx-js prepare --verify --strict-inference",
146
+ "sqlx:ci": "sqlx-js ci",
146
147
  };
147
148
  let changed = false;
148
149
  for (const [name, command] of Object.entries(defaults)) {
@@ -15,6 +15,7 @@ import { buildParamMap } from "../pg/param-map.js";
15
15
  import { mergeExtensionTypes } from "../pg/extensions.js";
16
16
  import { compareArtifacts } from "../artifacts.js";
17
17
  import { containsUnknownType } from "../type-inspection.js";
18
+ import { originalPosition, rewriteNamedParameters } from "../sql-params.js";
18
19
  const JSON_OIDS = new Set([114, 3802]);
19
20
  const JSON_ARRAY_OIDS = new Set([199, 3807]);
20
21
  const JSON_INPUT_VALUE = 'import("@onreza/sqlx-js").JsonInputValue';
@@ -207,8 +208,9 @@ function inferenceIssues(entry) {
207
208
  if (entry.degraded)
208
209
  issues.push(`nullability inference degraded: ${entry.degraded.reason}`);
209
210
  entry.paramTsTypes.forEach((type, index) => {
211
+ const parameter = entry.paramNames?.[index] ? `$${entry.paramNames[index]}` : `$${index + 1}`;
210
212
  if (containsUnknownType(type))
211
- issues.push(`parameter $${index + 1} resolved to ${type}`);
213
+ issues.push(`parameter ${parameter} resolved to ${type}`);
212
214
  });
213
215
  for (const column of entry.columns) {
214
216
  if (containsUnknownType(column.tsType)) {
@@ -225,7 +227,7 @@ function inferenceDiagnostics(entry, site, strict) {
225
227
  file: site.file,
226
228
  line: site.line,
227
229
  column: site.column,
228
- query: entry.query,
230
+ query: site.query,
229
231
  }));
230
232
  }
231
233
  export async function openSession(opts) {
@@ -326,12 +328,13 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
326
328
  let failures = 0;
327
329
  const unique = new Map();
328
330
  for (const s of sites) {
331
+ const rewritten = rewriteNamedParameters(s.query);
329
332
  const fp = fingerprint(s.query);
330
333
  const existing = unique.get(fp);
331
334
  if (existing)
332
335
  existing.sites.push(s);
333
336
  else
334
- unique.set(fp, { fp, query: s.query, sites: [s] });
337
+ unique.set(fp, { fp, query: rewritten.query, paramNames: rewritten.names, sites: [s] });
335
338
  }
336
339
  const raw = [];
337
340
  const reusedEntries = [];
@@ -372,18 +375,19 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
372
375
  file: site.file,
373
376
  line: site.line,
374
377
  column: site.column,
375
- query,
378
+ query: site.query,
376
379
  });
377
380
  err(` ✗ ${formatSite(site)} — ${message}`);
378
- err(` query: ${snippet(query)}`);
381
+ err(` query: ${snippet(site.query)}`);
379
382
  continue;
380
383
  }
381
- raw.push({ fp, query, sites: ss, paramOids: outcome.paramOids, fields: outcome.fields });
384
+ raw.push({ fp, query, sites: ss, paramOids: outcome.paramOids, fields: outcome.fields, paramNames: toPrepare.get(fp).paramNames });
382
385
  continue;
383
386
  }
384
387
  failures++;
385
388
  const e = outcome.error;
386
389
  if (e instanceof PgError) {
390
+ const position = e.position ? originalPosition(rewriteNamedParameters(site.query), e.position) : undefined;
387
391
  diagnostics.push({
388
392
  severity: "error",
389
393
  phase: "describe",
@@ -391,21 +395,21 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
391
395
  file: site.file,
392
396
  line: site.line,
393
397
  column: site.column,
394
- query,
398
+ query: site.query,
395
399
  ...(e.code ? { code: e.code } : {}),
396
- ...(e.position ? { position: e.position } : {}),
400
+ ...(position ? { position } : {}),
397
401
  ...(e.hint ? { hint: e.hint } : {}),
398
402
  });
399
403
  const extras = [];
400
- if (e.position)
401
- extras.push(`pos ${e.position}`);
404
+ if (position)
405
+ extras.push(`pos ${position}`);
402
406
  if (e.code)
403
407
  extras.push(`code ${e.code}`);
404
408
  const tail = extras.length > 0 ? ` (${extras.join(", ")})` : "";
405
409
  err(` ✗ ${formatSite(site)} — describe failed: ${e.message}${tail}`);
406
410
  if (e.hint)
407
411
  err(` hint: ${e.hint}`);
408
- err(` query: ${snippet(query)}`);
412
+ err(` query: ${snippet(site.query)}`);
409
413
  }
410
414
  else {
411
415
  diagnostics.push({
@@ -415,10 +419,10 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
415
419
  file: site.file,
416
420
  line: site.line,
417
421
  column: site.column,
418
- query,
422
+ query: site.query,
419
423
  });
420
424
  err(` ✗ ${formatSite(site)} — describe failed: ${e.message}`);
421
- err(` query: ${snippet(query)}`);
425
+ err(` query: ${snippet(site.query)}`);
422
426
  }
423
427
  }
424
428
  const allAttrRefs = [];
@@ -456,10 +460,10 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
456
460
  file: site.file,
457
461
  line: site.line,
458
462
  column: site.column,
459
- query: r.query,
463
+ query: site.query,
460
464
  });
461
465
  err(` ✗ ${formatSite(site)} — analyze failed: ${e.message}`);
462
- err(` query: ${snippet(r.query)}`);
466
+ err(` query: ${snippet(site.query)}`);
463
467
  continue;
464
468
  }
465
469
  try {
@@ -475,10 +479,10 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
475
479
  file: site.file,
476
480
  line: site.line,
477
481
  column: site.column,
478
- query: r.query,
482
+ query: site.query,
479
483
  });
480
484
  err(` ✗ ${formatSite(site)} — paramMap failed: ${e.message}`);
481
- err(` query: ${snippet(r.query)}`);
485
+ err(` query: ${snippet(site.query)}`);
482
486
  }
483
487
  }
484
488
  const dmlTablesToLoad = new Map();
@@ -534,11 +538,12 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
534
538
  dmlBound: new Set(),
535
539
  };
536
540
  const entry = {
537
- query: r.query,
541
+ query: r.sites[0].query,
538
542
  ...siteUsage(r.sites),
539
543
  paramOids: r.paramOids.map(portableCacheOid),
540
544
  paramTsTypes: r.paramOids.map((o, idx) => resolveParamTs(idx + 1, o, pm.targets, schema, userCfg)),
541
545
  paramNullable: r.paramOids.map((_o, idx) => resolveParamNullable(idx + 1, pm, schema)),
546
+ ...(r.paramNames.length > 0 ? { paramNames: r.paramNames } : {}),
542
547
  columns: r.fields.map((f, i) => {
543
548
  const parsed = parseColumnOverride(f.name);
544
549
  const treatAsOverride = parsed.override !== undefined && isAliasOrExpression(f, schema);
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { isAbsolute, relative, resolve } from "node:path";
3
3
  import { PgClient, parseDatabaseUrl, PgError } from "./pg/wire.js";
4
4
  import { applyPending, acquireMigrateLock, releaseMigrateLock, DEFAULT_MIGRATE_LOCK_KEY } from "./migration-core.js";
5
+ import { bindNamedParameters, rewriteNamedParameters } from "./sql-params.js";
5
6
  const PARAMETER_KIND = Symbol("sqlx-js.parameter");
6
7
  export function json(value) {
7
8
  return { [PARAMETER_KIND]: "json", value };
@@ -242,6 +243,11 @@ export const _internal = {
242
243
  toPgError,
243
244
  };
244
245
  async function runRawQuery(client, query, params) {
246
+ const observedQuery = query;
247
+ const observedParams = params;
248
+ const bound = bindNamedParameters(rewriteNamedParameters(query), params);
249
+ query = bound.query;
250
+ params = bound.params;
245
251
  const encoded = params.length === 0
246
252
  ? params
247
253
  : params.map((p) => client.transformParam ? client.transformParam(p) : encodeParam(p));
@@ -258,8 +264,8 @@ async function runRawQuery(client, query, params) {
258
264
  try {
259
265
  const result = await client.query(query, encoded);
260
266
  notifyQuery(client, {
261
- query,
262
- params,
267
+ query: observedQuery,
268
+ params: observedParams,
263
269
  durationMs: performance.now() - start,
264
270
  rowCount: result.count ?? result.length,
265
271
  });
@@ -267,7 +273,7 @@ async function runRawQuery(client, query, params) {
267
273
  }
268
274
  catch (e) {
269
275
  const error = toPgError(e) ?? e;
270
- notifyQuery(client, { query, params, durationMs: performance.now() - start, error });
276
+ notifyQuery(client, { query: observedQuery, params: observedParams, durationMs: performance.now() - start, error });
271
277
  throw error;
272
278
  }
273
279
  }
@@ -1,6 +1,7 @@
1
1
  import ts from "typescript";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { extname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { rewriteNamedParameters } from "../sql-params.js";
4
5
  export class ScanError extends Error {
5
6
  file;
6
7
  line;
@@ -187,6 +188,16 @@ export function scanFile(absPath, root, modules = DEFAULT_SQLX_MODULES) {
187
188
  throw new ScanError(fileRel, pos.line, pos.column, "sql() requires a string literal as first argument");
188
189
  }
189
190
  const pos = here(first);
191
+ let named;
192
+ try {
193
+ named = rewriteNamedParameters(first.text).names;
194
+ }
195
+ catch (error) {
196
+ throw new ScanError(fileRel, pos.line, pos.column, error.message.replace(/^sqlx-js: /, ""));
197
+ }
198
+ if (named.length > 0 && args.length !== 2) {
199
+ throw new ScanError(fileRel, pos.line, pos.column, "a query with named parameters requires exactly one parameter object");
200
+ }
190
201
  out.push({
191
202
  file: fileRel,
192
203
  line: pos.line,
@@ -219,6 +230,16 @@ export function scanFile(absPath, root, modules = DEFAULT_SQLX_MODULES) {
219
230
  }
220
231
  const query = readFileSync(abs, "utf8");
221
232
  const pos = here(first);
233
+ let named;
234
+ try {
235
+ named = rewriteNamedParameters(query).names;
236
+ }
237
+ catch (error) {
238
+ throw new ScanError(fileRel, pos.line, pos.column, error.message.replace(/^sqlx-js: /, ""));
239
+ }
240
+ if (named.length > 0 && args.length !== 2) {
241
+ throw new ScanError(fileRel, pos.line, pos.column, "a SQL file with named parameters requires exactly one parameter object");
242
+ }
222
243
  out.push({
223
244
  file: fileRel,
224
245
  line: pos.line,
@@ -0,0 +1,7 @@
1
+ export declare function isIdentifierContinuation(value: string): boolean;
2
+ export declare function isEscapeStringPrefix(query: string, quote: number): boolean;
3
+ export declare function readSingleQuoted(query: string, start: number, escapeBackslash: boolean): number;
4
+ export declare function readQuotedIdentifier(query: string, start: number): number;
5
+ export declare function readDollarQuoted(query: string, start: number): number | null;
6
+ export declare function readLineComment(query: string, start: number): number;
7
+ export declare function readBlockComment(query: string, start: number): number;
@@ -0,0 +1,76 @@
1
+ export function isIdentifierContinuation(value) {
2
+ return /[A-Za-z0-9_$\u0080-\uFFFF]/.test(value);
3
+ }
4
+ export function isEscapeStringPrefix(query, quote) {
5
+ const prefix = query[quote - 1];
6
+ if (prefix !== "e" && prefix !== "E")
7
+ return false;
8
+ const before = query[quote - 2];
9
+ return before === undefined || !isIdentifierContinuation(before);
10
+ }
11
+ export function readSingleQuoted(query, start, escapeBackslash) {
12
+ let i = start + 1;
13
+ while (i < query.length) {
14
+ if (escapeBackslash && query[i] === "\\") {
15
+ i += 2;
16
+ continue;
17
+ }
18
+ if (query[i] === "'") {
19
+ if (query[i + 1] === "'") {
20
+ i += 2;
21
+ continue;
22
+ }
23
+ return i + 1;
24
+ }
25
+ i++;
26
+ }
27
+ return query.length;
28
+ }
29
+ export function readQuotedIdentifier(query, start) {
30
+ let i = start + 1;
31
+ while (i < query.length) {
32
+ if (query[i] === '"') {
33
+ if (query[i + 1] === '"') {
34
+ i += 2;
35
+ continue;
36
+ }
37
+ return i + 1;
38
+ }
39
+ i++;
40
+ }
41
+ return query.length;
42
+ }
43
+ export function readDollarQuoted(query, start) {
44
+ let end = start + 1;
45
+ if (query[end] !== "$" && !/[A-Za-z_\u0080-\uFFFF]/.test(query[end]))
46
+ return null;
47
+ while (end < query.length && /[A-Za-z0-9_\u0080-\uFFFF]/.test(query[end]))
48
+ end++;
49
+ if (query[end] !== "$")
50
+ return null;
51
+ const tag = query.slice(start, end + 1);
52
+ const close = query.indexOf(tag, end + 1);
53
+ return close === -1 ? query.length : close + tag.length;
54
+ }
55
+ export function readLineComment(query, start) {
56
+ const end = query.indexOf("\n", start + 2);
57
+ return end === -1 ? query.length : end + 1;
58
+ }
59
+ export function readBlockComment(query, start) {
60
+ let depth = 1;
61
+ let i = start + 2;
62
+ while (i < query.length && depth > 0) {
63
+ if (query[i] === "/" && query[i + 1] === "*") {
64
+ depth++;
65
+ i += 2;
66
+ continue;
67
+ }
68
+ if (query[i] === "*" && query[i + 1] === "/") {
69
+ depth--;
70
+ i += 2;
71
+ continue;
72
+ }
73
+ i++;
74
+ }
75
+ return i;
76
+ }
@@ -0,0 +1,11 @@
1
+ export type RewrittenSql = {
2
+ query: string;
3
+ names: string[];
4
+ positionMap: number[];
5
+ };
6
+ export declare function rewriteNamedParameters(query: string): RewrittenSql;
7
+ export declare function bindNamedParameters(rewritten: RewrittenSql, args: readonly unknown[]): {
8
+ query: string;
9
+ params: unknown[];
10
+ };
11
+ export declare function originalPosition(rewritten: RewrittenSql, oneBasedPosition: number): number;
@@ -0,0 +1,101 @@
1
+ import { isEscapeStringPrefix, isIdentifierContinuation, readBlockComment, readDollarQuoted, readLineComment, readQuotedIdentifier, readSingleQuoted, } from "./sql-lex.js";
2
+ export function rewriteNamedParameters(query) {
3
+ let out = "";
4
+ let i = 0;
5
+ let positional = false;
6
+ const names = [];
7
+ const indexes = new Map();
8
+ const positionMap = [];
9
+ const append = (text, sourceStart) => {
10
+ out += text;
11
+ for (let offset = 0; offset < text.length; offset++)
12
+ positionMap.push(sourceStart + offset);
13
+ };
14
+ while (i < query.length) {
15
+ const start = i;
16
+ const ch = query[i];
17
+ if (ch === "-" && query[i + 1] === "-") {
18
+ i = readLineComment(query, i);
19
+ append(query.slice(start, i), start);
20
+ continue;
21
+ }
22
+ if (ch === "/" && query[i + 1] === "*") {
23
+ i = readBlockComment(query, i);
24
+ append(query.slice(start, i), start);
25
+ continue;
26
+ }
27
+ if (ch === "'") {
28
+ i = readSingleQuoted(query, i, isEscapeStringPrefix(query, i));
29
+ append(query.slice(start, i), start);
30
+ continue;
31
+ }
32
+ if (ch === '"') {
33
+ i = readQuotedIdentifier(query, i);
34
+ append(query.slice(start, i), start);
35
+ continue;
36
+ }
37
+ if (ch === "$") {
38
+ if (i > 0 && isIdentifierContinuation(query[i - 1])) {
39
+ append(ch, i);
40
+ i++;
41
+ continue;
42
+ }
43
+ const quotedEnd = readDollarQuoted(query, i);
44
+ if (quotedEnd !== null) {
45
+ i = quotedEnd;
46
+ append(query.slice(start, i), start);
47
+ continue;
48
+ }
49
+ const positionalMatch = /^\$[1-9][0-9]*/.exec(query.slice(i));
50
+ if (positionalMatch) {
51
+ positional = true;
52
+ i += positionalMatch[0].length;
53
+ append(positionalMatch[0], start);
54
+ continue;
55
+ }
56
+ const namedMatch = /^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(query.slice(i));
57
+ if (namedMatch) {
58
+ const name = namedMatch[1];
59
+ let index = indexes.get(name);
60
+ if (index === undefined) {
61
+ names.push(name);
62
+ index = names.length;
63
+ indexes.set(name, index);
64
+ }
65
+ const replacement = `$${index}`;
66
+ append(replacement, start);
67
+ i += namedMatch[0].length;
68
+ continue;
69
+ }
70
+ }
71
+ append(ch, i);
72
+ i++;
73
+ }
74
+ if (positional && names.length > 0) {
75
+ throw new Error("sqlx-js: named and positional parameters cannot be mixed in one query");
76
+ }
77
+ positionMap.push(query.length);
78
+ return { query: out, names, positionMap };
79
+ }
80
+ export function bindNamedParameters(rewritten, args) {
81
+ if (rewritten.names.length === 0)
82
+ return { query: rewritten.query, params: [...args] };
83
+ if (args.length !== 1 || args[0] === null || typeof args[0] !== "object" || Array.isArray(args[0])) {
84
+ throw new Error("sqlx-js: a query with named parameters requires exactly one parameter object");
85
+ }
86
+ const values = args[0];
87
+ const missing = rewritten.names.filter((name) => !Object.hasOwn(values, name));
88
+ const expected = new Set(rewritten.names);
89
+ const extra = Object.keys(values).filter((name) => !expected.has(name));
90
+ if (missing.length > 0)
91
+ throw new Error(`sqlx-js: missing named parameter(s): ${missing.join(", ")}`);
92
+ if (extra.length > 0)
93
+ throw new Error(`sqlx-js: unknown named parameter(s): ${extra.join(", ")}`);
94
+ return { query: rewritten.query, params: rewritten.names.map((name) => values[name]) };
95
+ }
96
+ export function originalPosition(rewritten, oneBasedPosition) {
97
+ const position = Number(oneBasedPosition);
98
+ if (!Number.isInteger(position) || position < 1)
99
+ return oneBasedPosition;
100
+ return (rewritten.positionMap[position - 1] ?? position - 1) + 1;
101
+ }
@@ -1,6 +1,6 @@
1
1
  type ParamsOf<T> = T extends {
2
- params: infer P extends readonly unknown[];
3
- } ? P : never[];
2
+ params: infer P;
3
+ } ? P extends readonly unknown[] ? P : [P] : never[];
4
4
  type RowOf<T> = T extends {
5
5
  row: infer R;
6
6
  } ? R : never;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onreza/sqlx-js",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Compile-time-checked raw SQL for TypeScript + PostgreSQL. Inspired by sqlx.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -66,6 +66,11 @@
66
66
  "peerDependencies": {
67
67
  "typescript": ">=5.4"
68
68
  },
69
+ "peerDependenciesMeta": {
70
+ "typescript": {
71
+ "optional": true
72
+ }
73
+ },
69
74
  "dependencies": {
70
75
  "libpg-query": "^17.7.3",
71
76
  "postgres": "^3.4.9"