@onreza/sqlx-js 0.6.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/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 = 3;
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) => {
@@ -23,7 +25,9 @@ export function emitDts(outPath, entries, functions = []) {
23
25
  lines.push("// AUTO-GENERATED by sqlx-js. Do not edit.");
24
26
  lines.push("// Run `sqlx-js prepare` to regenerate.");
25
27
  lines.push("");
26
- emitModule(lines, "@onreza/sqlx-js", entries, functions);
28
+ emitRegistry(lines, entries, functions);
29
+ lines.push("");
30
+ emitModuleAugmentation(lines, "@onreza/sqlx-js");
27
31
  lines.push("");
28
32
  lines.push("export {};");
29
33
  const tmp = `${outPath}.tmp-${randomBytes(4).toString("hex")}`;
@@ -39,9 +43,8 @@ export function emitDts(outPath, entries, functions = []) {
39
43
  throw err;
40
44
  }
41
45
  }
42
- function emitModule(lines, moduleName, entries, functions) {
43
- lines.push(`declare module ${JSON.stringify(moduleName)} {`);
44
- lines.push(" interface KnownQueries {");
46
+ function emitRegistry(lines, entries, functions) {
47
+ lines.push("export interface SqlxJsGeneratedQueries {");
45
48
  const inlineSeen = new Set();
46
49
  const inlinePairs = [];
47
50
  for (const e of entries) {
@@ -58,12 +61,12 @@ function emitModule(lines, moduleName, entries, functions) {
58
61
  inlineSeen.add(query);
59
62
  const { params, row } = entrySignature(entry);
60
63
  if (entry.degraded)
61
- lines.push(` /** Nullability inference degraded: ${entry.degraded.reason}. All result columns conservatively typed as nullable. */`);
62
- lines.push(` ${JSON.stringify(query)}: { params: ${params}; row: ${row} };`);
64
+ lines.push(` /** Nullability inference degraded: ${entry.degraded.reason}. All result columns conservatively typed as nullable. */`);
65
+ lines.push(` ${JSON.stringify(query)}: { params: ${params}; row: ${row} };`);
63
66
  }
64
- lines.push(" }");
67
+ lines.push("}");
65
68
  lines.push("");
66
- lines.push(" interface KnownFileQueries {");
69
+ lines.push("export interface SqlxJsGeneratedFileQueries {");
67
70
  const filePairs = [];
68
71
  for (const e of entries) {
69
72
  if (!e.filePaths || e.filePaths.length === 0)
@@ -78,21 +81,33 @@ function emitModule(lines, moduleName, entries, functions) {
78
81
  filePathsSeen.add(path);
79
82
  const { params, row } = entrySignature(entry);
80
83
  if (entry.degraded)
81
- lines.push(` /** Nullability inference degraded: ${entry.degraded.reason}. */`);
82
- lines.push(` ${JSON.stringify(path)}: { params: ${params}; row: ${row} };`);
84
+ lines.push(` /** Nullability inference degraded: ${entry.degraded.reason}. */`);
85
+ lines.push(` ${JSON.stringify(path)}: { params: ${params}; row: ${row} };`);
83
86
  }
84
- lines.push(" }");
87
+ lines.push("}");
85
88
  lines.push("");
86
- lines.push(" interface KnownFunctions {");
89
+ lines.push("export interface SqlxJsGeneratedFunctions {");
87
90
  const functionSeen = new Set();
88
91
  for (const fn of functions.slice().sort((a, b) => a.signature.localeCompare(b.signature))) {
89
92
  if (functionSeen.has(fn.signature))
90
93
  continue;
91
94
  functionSeen.add(fn.signature);
92
95
  const params = inputParams(fn);
93
- lines.push(` ${JSON.stringify(fn.signature)}: { kind: ${JSON.stringify(fn.kind)}; params: ${params}; returns: ${fn.returns}; returnsSet: ${fn.returnsSet} };`);
96
+ lines.push(` ${JSON.stringify(fn.signature)}: { kind: ${JSON.stringify(fn.kind)}; params: ${params}; returns: ${fn.returns}; returnsSet: ${fn.returnsSet} };`);
94
97
  }
95
- lines.push(" }");
98
+ lines.push("}");
99
+ lines.push("");
100
+ lines.push("export interface SqlxJsGeneratedRegistry {");
101
+ lines.push(" queries: SqlxJsGeneratedQueries;");
102
+ lines.push(" fileQueries: SqlxJsGeneratedFileQueries;");
103
+ lines.push(" functions: SqlxJsGeneratedFunctions;");
104
+ lines.push("}");
105
+ }
106
+ function emitModuleAugmentation(lines, moduleName) {
107
+ lines.push(`declare module ${JSON.stringify(moduleName)} {`);
108
+ lines.push(" interface KnownQueries extends SqlxJsGeneratedQueries {}");
109
+ lines.push(" interface KnownFileQueries extends SqlxJsGeneratedFileQueries {}");
110
+ lines.push(" interface KnownFunctions extends SqlxJsGeneratedFunctions {}");
96
111
  lines.push("}");
97
112
  }
98
113
  function inputParams(fn) {
@@ -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
+ }
@@ -36,10 +36,19 @@ const PGSCHEMA_TEMPLATE = `-- Desired PostgreSQL schema managed by pgschema.
36
36
  const DTS_TEMPLATE = `// AUTO-GENERATED by sqlx-js. Do not edit.
37
37
  // Run \`sqlx-js prepare\` to regenerate.
38
38
 
39
+ export interface SqlxJsGeneratedQueries {}
40
+ export interface SqlxJsGeneratedFileQueries {}
41
+ export interface SqlxJsGeneratedFunctions {}
42
+ export interface SqlxJsGeneratedRegistry {
43
+ queries: SqlxJsGeneratedQueries;
44
+ fileQueries: SqlxJsGeneratedFileQueries;
45
+ functions: SqlxJsGeneratedFunctions;
46
+ }
47
+
39
48
  declare module "@onreza/sqlx-js" {
40
- interface KnownQueries {}
41
- interface KnownFileQueries {}
42
- interface KnownFunctions {}
49
+ interface KnownQueries extends SqlxJsGeneratedQueries {}
50
+ interface KnownFileQueries extends SqlxJsGeneratedFileQueries {}
51
+ interface KnownFunctions extends SqlxJsGeneratedFunctions {}
43
52
  }
44
53
 
45
54
  export {};
@@ -132,7 +141,9 @@ export function runInit(opts) {
132
141
  const defaults = {
133
142
  "sqlx:prepare": "sqlx-js prepare",
134
143
  "sqlx:check": "sqlx-js prepare --check",
144
+ "sqlx:offline": "sqlx-js prepare --offline",
135
145
  "sqlx:verify": "sqlx-js prepare --verify --strict-inference",
146
+ "sqlx:ci": "sqlx-js ci",
136
147
  };
137
148
  let changed = false;
138
149
  for (const [name, command] of Object.entries(defaults)) {
@@ -1,5 +1,6 @@
1
1
  import { PgClient, type ConnConfig, type FieldDescription } from "../pg/wire.js";
2
2
  import { SchemaCache } from "../pg/schema.js";
3
+ import { type QueryCallSite } from "../scan/scanner.js";
3
4
  import { type SqlxJsConfig } from "../config.js";
4
5
  export type PrepareOptions = {
5
6
  root: string;
@@ -7,6 +8,7 @@ export type PrepareOptions = {
7
8
  cacheDir: string;
8
9
  dtsPath: string;
9
10
  check: boolean;
11
+ offline?: boolean;
10
12
  verify?: boolean;
11
13
  json?: boolean;
12
14
  prune?: boolean;
@@ -49,6 +51,11 @@ export type PrepareSession = {
49
51
  schema: SchemaCache;
50
52
  userCfg: SqlxJsConfig;
51
53
  };
54
+ export type PrepareIncrementalInput = {
55
+ sites?: QueryCallSite[];
56
+ reuseCacheFps?: ReadonlySet<string>;
57
+ reuseFunctions?: boolean;
58
+ };
52
59
  export declare function openSession(opts: PrepareOptions): Promise<PrepareSession>;
53
60
  type DescribeOutcome = {
54
61
  ok: true;
@@ -63,7 +70,7 @@ export declare function describeAll(cfg: ConnConfig, sessionClient: PgClient, qu
63
70
  fp: string;
64
71
  query: string;
65
72
  }[], concurrency: number): Promise<Map<string, DescribeOutcome>>;
66
- export declare function prepareOnce(opts: PrepareOptions, session: PrepareSession, log?: (msg: string) => void, err?: (msg: string) => void, concurrency?: number): Promise<PrepareResult>;
73
+ export declare function prepareOnce(opts: PrepareOptions, session: PrepareSession, log?: (msg: string) => void, err?: (msg: string) => void, concurrency?: number, input?: PrepareIncrementalInput): Promise<PrepareResult>;
67
74
  export declare function runPrepare(opts: PrepareOptions): Promise<void>;
68
75
  export declare function verifyPrepareArtifacts(opts: PrepareOptions, log?: (msg: string) => void, err?: (msg: string) => void): Promise<{
69
76
  ok: boolean;