@onreza/sqlx-js 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cache.js CHANGED
@@ -1,6 +1,13 @@
1
1
  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
+ import { isBuiltinOid } from "./pg/oids.js";
5
+ export const CACHE_FORMAT_VERSION = 2;
6
+ export const GENERATOR_REVISION = 3;
7
+ export const CACHE_MANIFEST_FILE = "cache-manifest.json";
8
+ export function portableCacheOid(oid) {
9
+ return isBuiltinOid(oid) ? oid : 0;
10
+ }
4
11
  export function fingerprint(query) {
5
12
  const norm = normalizeForFingerprint(query);
6
13
  return createHash("sha256").update(norm).digest("hex").slice(0, 16);
@@ -202,11 +209,35 @@ export class Cache {
202
209
  throw err;
203
210
  }
204
211
  }
212
+ replaceAll(entries, prune = true) {
213
+ this.ensure();
214
+ const staged = [];
215
+ try {
216
+ for (const { fp, entry } of entries) {
217
+ const final = join(this.dir, `${fp}.json`);
218
+ const tmp = `${final}.tmp-${randomBytes(4).toString("hex")}`;
219
+ writeFileSync(tmp, JSON.stringify(entry, null, 2));
220
+ staged.push({ fp, tmp, final });
221
+ }
222
+ for (const item of staged)
223
+ renameSync(item.tmp, item.final);
224
+ }
225
+ catch (err) {
226
+ for (const item of staged) {
227
+ try {
228
+ unlinkSync(item.tmp);
229
+ }
230
+ catch { }
231
+ }
232
+ throw err;
233
+ }
234
+ return prune ? this.prune(staged.map((item) => item.fp)) : [];
235
+ }
205
236
  list() {
206
237
  if (!existsSync(this.dir))
207
238
  return [];
208
239
  return readdirSync(this.dir)
209
- .filter((f) => f.endsWith(".json") && !f.includes(".tmp-"))
240
+ .filter((f) => f !== CACHE_MANIFEST_FILE && f.endsWith(".json") && !f.includes(".tmp-"))
210
241
  .map((f) => {
211
242
  const fp = f.replace(/\.json$/, "");
212
243
  return { fp, entry: assertEntryShape(fp, parseEntryJson(join(this.dir, f))) };
@@ -229,3 +260,54 @@ export class Cache {
229
260
  return removed;
230
261
  }
231
262
  }
263
+ export function cacheManifestPath(cacheDir) {
264
+ return join(cacheDir, CACHE_MANIFEST_FILE);
265
+ }
266
+ export function writeCacheManifest(cacheDir, configHash) {
267
+ if (!existsSync(cacheDir))
268
+ mkdirSync(cacheDir, { recursive: true });
269
+ const path = cacheManifestPath(cacheDir);
270
+ const tmp = `${path}.tmp-${randomBytes(4).toString("hex")}`;
271
+ const manifest = {
272
+ cacheFormat: CACHE_FORMAT_VERSION,
273
+ generatorRevision: GENERATOR_REVISION,
274
+ configHash,
275
+ };
276
+ writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n");
277
+ try {
278
+ renameSync(tmp, path);
279
+ }
280
+ catch (err) {
281
+ try {
282
+ unlinkSync(tmp);
283
+ }
284
+ catch { }
285
+ throw err;
286
+ }
287
+ }
288
+ export function readCacheManifest(cacheDir) {
289
+ const path = cacheManifestPath(cacheDir);
290
+ if (!existsSync(path))
291
+ return null;
292
+ const raw = parseEntryJson(path);
293
+ if (!raw || typeof raw !== "object") {
294
+ throw new Error(`sqlx-js: cache manifest is malformed: ${path}`);
295
+ }
296
+ const value = raw;
297
+ if (value.cacheFormat !== CACHE_FORMAT_VERSION ||
298
+ value.generatorRevision !== GENERATOR_REVISION ||
299
+ typeof value.configHash !== "string") {
300
+ throw new Error(`sqlx-js: cache manifest is stale: ${path}. Run \`sqlx-js prepare\`.`);
301
+ }
302
+ return value;
303
+ }
304
+ export function assertCacheManifest(cacheDir, configHash) {
305
+ const manifest = readCacheManifest(cacheDir);
306
+ if (!manifest) {
307
+ throw new Error(`sqlx-js: cache manifest is missing. Run \`sqlx-js prepare\` to regenerate the cache.`);
308
+ }
309
+ if (manifest.configHash !== configHash) {
310
+ throw new Error("sqlx-js: cache was generated with a different jsonbTypes/customTypes config. Run `sqlx-js prepare`.");
311
+ }
312
+ return manifest;
313
+ }
@@ -1,4 +1,5 @@
1
- import { writeFileSync, mkdirSync } from "node:fs";
1
+ import { writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { dirname } from "node:path";
3
4
  import { effectiveNullable } from "./cache.js";
4
5
  function entrySignature(e) {
@@ -25,7 +26,18 @@ export function emitDts(outPath, entries, functions = []) {
25
26
  emitModule(lines, "@onreza/sqlx-js", entries, functions);
26
27
  lines.push("");
27
28
  lines.push("export {};");
28
- writeFileSync(outPath, lines.join("\n") + "\n");
29
+ const tmp = `${outPath}.tmp-${randomBytes(4).toString("hex")}`;
30
+ writeFileSync(tmp, lines.join("\n") + "\n");
31
+ try {
32
+ renameSync(tmp, outPath);
33
+ }
34
+ catch (err) {
35
+ try {
36
+ unlinkSync(tmp);
37
+ }
38
+ catch { }
39
+ throw err;
40
+ }
29
41
  }
30
42
  function emitModule(lines, moduleName, entries, functions) {
31
43
  lines.push(`declare module ${JSON.stringify(moduleName)} {`);
@@ -0,0 +1,16 @@
1
+ export type DoctorCheck = {
2
+ name: string;
3
+ status: "ok" | "warning" | "error";
4
+ message: string;
5
+ details?: Record<string, unknown>;
6
+ };
7
+ export type DoctorOptions = {
8
+ root: string;
9
+ databaseUrl: string;
10
+ cacheDir: string;
11
+ dtsPath: string;
12
+ json?: boolean;
13
+ envError?: string;
14
+ };
15
+ export declare function inspectDoctor(opts: DoctorOptions): Promise<DoctorCheck[]>;
16
+ export declare function runDoctor(opts: DoctorOptions): Promise<void>;
@@ -0,0 +1,196 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import ts from "typescript";
4
+ import { assertCacheManifest } from "../cache.js";
5
+ import { assertSupportedRuntime, envFilePath, loadConfigInfo, nativeTypeScriptEnabled, prepareConfigHash, runtimeVersion, } from "../config.js";
6
+ import { decodeText, parseDatabaseUrl, PgClient } from "../pg/wire.js";
7
+ import { probePgschema } from "./pgschema.js";
8
+ function decodeBoolean(value) {
9
+ const text = decodeText(value ?? null);
10
+ return text === "t" || text === "true" || text === "1";
11
+ }
12
+ function tsconfigIncludes(root, file) {
13
+ const path = join(root, "tsconfig.json");
14
+ if (!existsSync(path))
15
+ return { ok: false, message: `tsconfig.json not found at ${path}` };
16
+ const target = resolve(file);
17
+ const visited = new Set();
18
+ const visit = (configPath) => {
19
+ const resolvedConfig = resolve(configPath);
20
+ if (visited.has(resolvedConfig))
21
+ return { included: false };
22
+ visited.add(resolvedConfig);
23
+ const read = ts.readConfigFile(resolvedConfig, ts.sys.readFile);
24
+ if (read.error) {
25
+ return { included: false, error: ts.flattenDiagnosticMessageText(read.error.messageText, "\n") };
26
+ }
27
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, resolve(resolvedConfig, ".."), undefined, resolvedConfig);
28
+ if (parsed.errors.length > 0) {
29
+ return {
30
+ included: false,
31
+ error: parsed.errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("; "),
32
+ };
33
+ }
34
+ if (parsed.fileNames.some((name) => resolve(name) === target))
35
+ return { included: true };
36
+ for (const reference of parsed.projectReferences ?? []) {
37
+ const result = visit(ts.resolveProjectReferencePath(reference));
38
+ if (result.included || result.error)
39
+ return result;
40
+ }
41
+ return { included: false };
42
+ };
43
+ const result = visit(path);
44
+ if (result.error)
45
+ return { ok: false, message: result.error };
46
+ const included = result.included;
47
+ return included
48
+ ? { ok: true, message: `${file} is included by tsconfig.json` }
49
+ : { ok: false, message: `${file} is not included by tsconfig.json` };
50
+ }
51
+ export async function inspectDoctor(opts) {
52
+ const checks = [];
53
+ const runtime = runtimeVersion();
54
+ try {
55
+ assertSupportedRuntime();
56
+ checks.push({
57
+ name: "runtime",
58
+ status: "ok",
59
+ message: `${runtime.runtime} ${runtime.version} satisfies the supported baseline`,
60
+ });
61
+ }
62
+ catch (e) {
63
+ checks.push({ name: "runtime", status: "error", message: e.message });
64
+ }
65
+ let config = {};
66
+ let configLoaded = true;
67
+ try {
68
+ const info = await loadConfigInfo(opts.root);
69
+ config = info.config;
70
+ const nativeTs = nativeTypeScriptEnabled();
71
+ if (info.path && /\.m?ts$/.test(info.path) && nativeTs === false) {
72
+ checks.push({ name: "config", status: "error", message: "native TypeScript execution is disabled" });
73
+ }
74
+ else {
75
+ checks.push({
76
+ name: "config",
77
+ status: "ok",
78
+ message: info.path ? `loaded ${info.path}` : "no config file; defaults are active",
79
+ });
80
+ }
81
+ }
82
+ catch (e) {
83
+ configLoaded = false;
84
+ checks.push({ name: "config", status: "error", message: e.message });
85
+ }
86
+ const envPath = envFilePath(opts.root);
87
+ checks.push(opts.envError
88
+ ? { name: "env", status: "error", message: opts.envError }
89
+ : existsSync(envPath) && !opts.databaseUrl
90
+ ? { name: "env", status: "error", message: `${envPath} exists but DATABASE_URL is missing` }
91
+ : existsSync(envPath)
92
+ ? { name: "env", status: "ok", message: `loaded environment file ${envPath}` }
93
+ : opts.databaseUrl
94
+ ? { name: "env", status: "ok", message: "DATABASE_URL is provided by the process environment" }
95
+ : { name: "env", status: "error", message: `DATABASE_URL is missing and ${envPath} does not exist` });
96
+ if (!configLoaded) {
97
+ checks.push({ name: "cache", status: "warning", message: "cache config-hash check skipped because config failed to load" });
98
+ }
99
+ else {
100
+ try {
101
+ assertCacheManifest(opts.cacheDir, prepareConfigHash(config));
102
+ checks.push({ name: "cache", status: "ok", message: "cache manifest and config hash are current" });
103
+ }
104
+ catch (e) {
105
+ checks.push({ name: "cache", status: "error", message: e.message });
106
+ }
107
+ }
108
+ const tsconfig = tsconfigIncludes(opts.root, opts.dtsPath);
109
+ checks.push({
110
+ name: "tsconfig",
111
+ status: tsconfig.ok ? "ok" : "error",
112
+ message: tsconfig.message,
113
+ });
114
+ if (!opts.databaseUrl) {
115
+ checks.push({ name: "database", status: "error", message: "DATABASE_URL is required for the database check" });
116
+ }
117
+ else {
118
+ const client = new PgClient(parseDatabaseUrl(opts.databaseUrl));
119
+ try {
120
+ await client.connect();
121
+ await client.describe("SELECT 1");
122
+ const result = await client.simpleQueryAll(`
123
+ SELECT current_setting('server_version'), current_user, current_database(),
124
+ (SELECT (rolcreatedb OR rolsuper)::text FROM pg_roles WHERE rolname = current_user),
125
+ COALESCE(has_schema_privilege(current_user, current_schema(), 'USAGE'), false)::text
126
+ `);
127
+ const row = result.rows[0];
128
+ const canCreateDatabase = decodeBoolean(row[3]);
129
+ const hasSchemaUsage = decodeBoolean(row[4]);
130
+ const shadowDatabaseUrl = process.env.SHADOW_DATABASE_URL;
131
+ const shadowAdminDatabaseUrl = process.env.SHADOW_ADMIN_DATABASE_URL;
132
+ const hasShadowFallback = Boolean(shadowDatabaseUrl || shadowAdminDatabaseUrl);
133
+ const needsShadowCreate = config.schema?.provider !== "pgschema";
134
+ checks.push({
135
+ name: "database",
136
+ status: "ok",
137
+ message: `connected to ${decodeText(row[2])} as ${decodeText(row[1])}`,
138
+ details: {
139
+ serverVersion: decodeText(row[0]),
140
+ describe: true,
141
+ schemaUsage: hasSchemaUsage,
142
+ },
143
+ });
144
+ checks.push({
145
+ name: "permissions",
146
+ status: hasSchemaUsage && (!needsShadowCreate || canCreateDatabase || hasShadowFallback) ? "ok" : "warning",
147
+ message: !hasSchemaUsage
148
+ ? "current user lacks USAGE on the current schema"
149
+ : !needsShadowCreate
150
+ ? "current user can use the current schema; pgschema does not require shadow-database creation"
151
+ : canCreateDatabase
152
+ ? "current user can use the current schema and create shadow databases"
153
+ : hasShadowFallback
154
+ ? "current user cannot create databases; a shadow database fallback is configured"
155
+ : "current user cannot create shadow databases; configure SHADOW_ADMIN_DATABASE_URL or SHADOW_DATABASE_URL",
156
+ details: {
157
+ schemaUsage: hasSchemaUsage,
158
+ createDatabase: canCreateDatabase,
159
+ shadowDatabaseConfigured: Boolean(shadowDatabaseUrl),
160
+ shadowAdminConfigured: Boolean(shadowAdminDatabaseUrl),
161
+ },
162
+ });
163
+ }
164
+ catch (e) {
165
+ checks.push({ name: "database", status: "error", message: e.message });
166
+ }
167
+ finally {
168
+ await client.end().catch(() => { });
169
+ }
170
+ }
171
+ if (!configLoaded) {
172
+ checks.push({ name: "pgschema", status: "warning", message: "schema-provider check skipped because config failed to load" });
173
+ }
174
+ else if (config.schema?.provider === "pgschema") {
175
+ const probe = probePgschema(opts.root, config);
176
+ checks.push({ name: "pgschema", status: probe.ok ? "ok" : "error", message: probe.message });
177
+ }
178
+ else {
179
+ checks.push({ name: "pgschema", status: "ok", message: "built-in migration provider is active" });
180
+ }
181
+ return checks;
182
+ }
183
+ export async function runDoctor(opts) {
184
+ const checks = await inspectDoctor(opts);
185
+ const ok = checks.every((check) => check.status !== "error");
186
+ if (opts.json) {
187
+ console.log(JSON.stringify({ formatVersion: 1, ok, checks }, null, 2));
188
+ }
189
+ else {
190
+ for (const check of checks) {
191
+ console.log(`${check.status.padEnd(7)} ${check.name}: ${check.message}`);
192
+ }
193
+ }
194
+ if (!ok)
195
+ process.exitCode = 1;
196
+ }
@@ -1,21 +1,19 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- const CONFIG_TEMPLATE = `import type { SqlxJsConfig } from "@onreza/sqlx-js";
3
+ const CONFIG_TEMPLATE = `import { defineConfig } from "@onreza/sqlx-js";
4
4
 
5
- const config: SqlxJsConfig = {
5
+ export default defineConfig({
6
6
  // Map jsonb columns/params to TypeScript types declared in a .d.ts, e.g.
7
7
  // "users.settings": "SqlxJsJson.UserSettings",
8
8
  jsonbTypes: {},
9
9
  // Map PostgreSQL type names to TypeScript types, e.g.
10
10
  // geometry: "GeoJSON.Geometry",
11
11
  customTypes: {},
12
- };
13
-
14
- export default config;
12
+ });
15
13
  `;
16
- const PGSCHEMA_CONFIG_TEMPLATE = `import type { SqlxJsConfig } from "@onreza/sqlx-js";
14
+ const PGSCHEMA_CONFIG_TEMPLATE = `import { defineConfig } from "@onreza/sqlx-js";
17
15
 
18
- const config: SqlxJsConfig = {
16
+ export default defineConfig({
19
17
  schema: {
20
18
  provider: "pgschema",
21
19
  file: "schema.sql",
@@ -23,9 +21,7 @@ const config: SqlxJsConfig = {
23
21
  },
24
22
  jsonbTypes: {},
25
23
  customTypes: {},
26
- };
27
-
28
- export default config;
24
+ });
29
25
  `;
30
26
  const ENV_TEMPLATE = `# Connection string used by sqlx-js prepare/migrate and the runtime.
31
27
  DATABASE_URL=postgres://user:password@localhost:5432/your_db
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { basename, join } from "node:path";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import { PgClient, parseDatabaseUrl, decodeText } from "../pg/wire.js";
7
- import { openSession, prepareOnce } from "./prepare.js";
7
+ import { openSession, prepareOnce, verifyPrepareArtifacts } from "./prepare.js";
8
8
  import { introspectConnected, schemaSnapshotEqual, } from "../schema-snapshot.js";
9
9
  const SQUASH_PREFIX = "-- sqlx-js-squash:";
10
10
  export const DEFAULT_MIGRATE_LOCK_KEY = 18750938867203960n;
@@ -512,31 +512,16 @@ async function prepareWorkflowArtifacts(opts, databaseUrl) {
512
512
  }
513
513
  }
514
514
  async function prepareInTemporaryArtifacts(opts, databaseUrl) {
515
- const tmp = mkdtempSync(join(tmpdir(), "sqlx-js-verify-"));
516
- const cacheDir = join(tmp, "cache");
517
- const dtsPath = join(tmp, "sqlx-js-env.d.ts");
518
- const prepareOpts = {
515
+ const verification = await verifyPrepareArtifacts({
519
516
  root: opts.root,
520
517
  databaseUrl,
521
- cacheDir,
522
- dtsPath,
518
+ cacheDir: opts.cacheDir,
519
+ dtsPath: opts.dtsPath,
523
520
  check: false,
521
+ verify: true,
524
522
  prune: true,
525
- };
526
- const session = await openSession(prepareOpts);
527
- try {
528
- const r = await prepareOnce(prepareOpts, session);
529
- if (r.failures > 0) {
530
- console.error(`\n${r.failures} query/queries failed to prepare`);
531
- return false;
532
- }
533
- console.log(`shadow: prepared ${r.entries} unique query/queries`);
534
- return true;
535
- }
536
- finally {
537
- await session.client.end();
538
- rmSync(tmp, { recursive: true, force: true });
539
- }
523
+ });
524
+ return verification.ok;
540
525
  }
541
526
  function effectiveSquashReplacements(all) {
542
527
  const covered = squashCoveredVersions(all);
@@ -19,7 +19,13 @@ export type PgschemaInstallOptions = {
19
19
  baseUrl?: string;
20
20
  log?: (msg: string) => void;
21
21
  };
22
+ export type PgschemaProbe = {
23
+ ok: boolean;
24
+ command?: string;
25
+ message: string;
26
+ };
22
27
  export declare function resolvePgschemaAsset(platform?: NodeJS.Platform, arch?: NodeJS.Architecture): PgschemaAsset;
23
28
  export declare function managedPgschemaPath(root: string, asset?: PgschemaAsset): string;
29
+ export declare function probePgschema(root: string, config: SqlxJsConfig): PgschemaProbe;
24
30
  export declare function runPgschemaInstall(opts: PgschemaInstallOptions): Promise<void>;
25
31
  export declare function runPgschemaCommand(opts: PgschemaCommandOptions): void;
@@ -73,6 +73,36 @@ function commandName(root, config) {
73
73
  return managed;
74
74
  return "pgschema";
75
75
  }
76
+ export function probePgschema(root, config) {
77
+ try {
78
+ const schema = pgschemaConfig(config);
79
+ const command = commandName(root, schema);
80
+ const child = spawnSync(command, ["--help"], {
81
+ encoding: "utf8",
82
+ env: process.env,
83
+ timeout: 5_000,
84
+ });
85
+ if (child.error) {
86
+ const code = child.error.code;
87
+ return {
88
+ ok: false,
89
+ command,
90
+ message: code === "ENOENT"
91
+ ? installHint(command)
92
+ : code === "ETIMEDOUT"
93
+ ? `${command} --help timed out after 5000ms`
94
+ : child.error.message,
95
+ };
96
+ }
97
+ if (child.status !== 0) {
98
+ return { ok: false, command, message: `${command} --help exited with ${child.status}` };
99
+ }
100
+ return { ok: true, command, message: `pgschema is available: ${command}` };
101
+ }
102
+ catch (e) {
103
+ return { ok: false, message: e.message };
104
+ }
105
+ }
76
106
  function schemaFile(root, config) {
77
107
  return resolve(root, config.file ?? "schema.sql");
78
108
  }
@@ -7,8 +7,42 @@ export type PrepareOptions = {
7
7
  cacheDir: string;
8
8
  dtsPath: string;
9
9
  check: boolean;
10
+ verify?: boolean;
11
+ json?: boolean;
10
12
  prune?: boolean;
11
13
  };
14
+ export type PrepareDiagnosticPhase = "config" | "connect" | "shadow" | "scan" | "describe" | "introspect" | "analyze" | "param-map" | "cache" | "verify";
15
+ export declare class PrepareFatalError extends Error {
16
+ readonly phase: PrepareDiagnosticPhase;
17
+ readonly file?: string;
18
+ readonly line?: number;
19
+ readonly column?: number;
20
+ constructor(phase: PrepareDiagnosticPhase, message: string, location?: {
21
+ file?: string;
22
+ line?: number;
23
+ column?: number;
24
+ }, options?: ErrorOptions);
25
+ }
26
+ export type PrepareDiagnostic = {
27
+ severity: "error" | "warning";
28
+ phase: PrepareDiagnosticPhase;
29
+ message: string;
30
+ file?: string;
31
+ line?: number;
32
+ column?: number;
33
+ query?: string;
34
+ code?: string;
35
+ position?: number;
36
+ hint?: string;
37
+ };
38
+ export type PrepareResult = {
39
+ sites: number;
40
+ entries: number;
41
+ failures: number;
42
+ pruned: number;
43
+ functions: number;
44
+ diagnostics: PrepareDiagnostic[];
45
+ };
12
46
  export type PrepareSession = {
13
47
  client: PgClient;
14
48
  schema: SchemaCache;
@@ -28,11 +62,11 @@ export declare function describeAll(cfg: ConnConfig, sessionClient: PgClient, qu
28
62
  fp: string;
29
63
  query: string;
30
64
  }[], concurrency: number): Promise<Map<string, DescribeOutcome>>;
31
- export declare function prepareOnce(opts: PrepareOptions, session: PrepareSession, log?: (msg: string) => void, err?: (msg: string) => void, concurrency?: number): Promise<{
32
- entries: number;
33
- failures: number;
34
- pruned: number;
35
- functions: number;
36
- }>;
65
+ export declare function prepareOnce(opts: PrepareOptions, session: PrepareSession, log?: (msg: string) => void, err?: (msg: string) => void, concurrency?: number): Promise<PrepareResult>;
37
66
  export declare function runPrepare(opts: PrepareOptions): Promise<void>;
67
+ export declare function verifyPrepareArtifacts(opts: PrepareOptions, log?: (msg: string) => void, err?: (msg: string) => void): Promise<{
68
+ ok: boolean;
69
+ result: PrepareResult;
70
+ changed: string[];
71
+ }>;
38
72
  export {};