@onreza/sqlx-js 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,9 @@
1
- import { watch as fsWatch } from "node:fs";
2
- import { basename } from "node:path";
3
- import { openSession, prepareOnce } from "./prepare.js";
1
+ import { existsSync, watch as fsWatch } from "node:fs";
2
+ import { basename, relative, resolve } from "node:path";
3
+ import { openSession, prepareOnce, PrepareFatalError, } from "./prepare.js";
4
4
  import { configHash, loadConfig } from "../config.js";
5
+ import { fingerprint } from "../cache.js";
6
+ import { findSourceFiles, scanFile, scanProject } from "../scan/scanner.js";
5
7
  const EXT_RE = /\.(ts|tsx|mts|cts|sql)$/;
6
8
  const SKIP_DIRS = ["node_modules", ".git", ".sqlx-js", "dist", "build", ".next"];
7
9
  const DEBOUNCE_MS = 150;
@@ -23,7 +25,25 @@ export function shouldWatchFile(filename) {
23
25
  return true;
24
26
  return EXT_RE.test(file);
25
27
  }
26
- const DEFAULT_DEPS = { openSession, prepareOnce };
28
+ export function formatWatchEvent(name, data = {}, timestamp = new Date().toISOString()) {
29
+ return JSON.stringify({ formatVersion: 1, event: name, timestamp, ...data });
30
+ }
31
+ export function watchErrorData(error) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ if (!(error instanceof PrepareFatalError))
34
+ return { message };
35
+ return {
36
+ diagnostic: {
37
+ severity: "error",
38
+ phase: error.phase,
39
+ message,
40
+ ...(error.file === undefined ? {} : { file: error.file }),
41
+ ...(error.line === undefined ? {} : { line: error.line }),
42
+ ...(error.column === undefined ? {} : { column: error.column }),
43
+ },
44
+ };
45
+ }
46
+ const DEFAULT_DEPS = { openSession, prepareOnce, loadConfig, scanProject, scanFile, findSourceFiles };
27
47
  async function closeSession(session) {
28
48
  if (!session)
29
49
  return;
@@ -32,35 +52,164 @@ async function closeSession(session) {
32
52
  }
33
53
  catch { }
34
54
  }
35
- export async function prepareWatchedOnce(opts, state, log, err, deps = DEFAULT_DEPS) {
55
+ export async function prepareWatchedOnce(opts, state, log, err, deps = {}, changedFiles = []) {
56
+ const active = { ...DEFAULT_DEPS, ...deps };
36
57
  const hookResult = await opts.beforePrepare?.();
37
- const currentConfig = await loadConfig(opts.root);
38
- if (hookResult?.resetSession === true ||
39
- (state.session !== null && configHash(state.session.userCfg) !== configHash(currentConfig))) {
58
+ const currentConfig = await active.loadConfig(opts.root);
59
+ const configChanged = state.session !== null && configHash(state.session.userCfg) !== configHash(currentConfig);
60
+ const resetSession = hookResult?.resetSession === true || configChanged;
61
+ if (resetSession) {
40
62
  await closeSession(state.session);
41
63
  state.session = null;
42
64
  }
43
65
  if (!state.session)
44
- state.session = await deps.openSession(opts);
45
- return await deps.prepareOnce(opts, state.session, log, err, 1);
66
+ state.session = await active.openSession(opts);
67
+ const full = resetSession || !state.sitesByFile || changedFiles.some(isProjectGraphFile);
68
+ let nextSites;
69
+ let changedFps = new Set();
70
+ if (full) {
71
+ const sites = active.scanProject(opts.root, currentConfig.scan);
72
+ nextSites = groupSites(sites);
73
+ changedFps = new Set(sites.map((site) => fingerprint(site.query)));
74
+ }
75
+ else {
76
+ const previousSites = state.sitesByFile;
77
+ const dirtyFiles = state.dirtyFiles ?? new Set();
78
+ const requested = new Set([...changedFiles, ...dirtyFiles].map((file) => projectPath(opts.root, file)));
79
+ const affectedSources = new Set();
80
+ for (const changed of requested) {
81
+ if (/\.(ts|tsx|mts|cts)$/.test(changed))
82
+ affectedSources.add(changed);
83
+ if (changed.endsWith(".sql")) {
84
+ for (const [source, sites] of previousSites) {
85
+ if (sites.some((site) => site.sqlFilePath && projectPath(opts.root, site.sqlFilePath) === changed)) {
86
+ affectedSources.add(source);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ nextSites = new Map(previousSites);
92
+ const allowed = new Set(active.findSourceFiles(opts.root, currentConfig.scan).map((file) => projectPath(opts.root, file)));
93
+ for (const source of nextSites.keys()) {
94
+ if (!allowed.has(source))
95
+ affectedSources.add(source);
96
+ }
97
+ try {
98
+ for (const source of affectedSources) {
99
+ const previous = nextSites.get(source) ?? [];
100
+ for (const site of previous)
101
+ changedFps.add(fingerprint(site.query));
102
+ const absolute = resolve(opts.root, source);
103
+ if (!existsSync(absolute) || !allowed.has(source)) {
104
+ nextSites.delete(source);
105
+ continue;
106
+ }
107
+ const scanned = active.scanFile(absolute, opts.root, currentConfig.scan?.modules);
108
+ if (scanned.length > 0)
109
+ nextSites.set(source, scanned);
110
+ else
111
+ nextSites.delete(source);
112
+ for (const site of scanned)
113
+ changedFps.add(fingerprint(site.query));
114
+ }
115
+ for (const source of affectedSources)
116
+ dirtyFiles.delete(source);
117
+ }
118
+ catch (error) {
119
+ for (const source of affectedSources)
120
+ dirtyFiles.add(source);
121
+ state.dirtyFiles = dirtyFiles;
122
+ throw error;
123
+ }
124
+ state.dirtyFiles = dirtyFiles;
125
+ }
126
+ const sites = [...nextSites.values()].flat();
127
+ const dirtyFps = state.dirtyFps ?? new Set();
128
+ const reuseCacheFps = new Set(sites
129
+ .map((site) => fingerprint(site.query))
130
+ .filter((fp) => !changedFps.has(fp) && !dirtyFps.has(fp)));
131
+ const result = await active.prepareOnce(opts, state.session, log, err, 1, {
132
+ sites,
133
+ reuseCacheFps,
134
+ reuseFunctions: !full,
135
+ });
136
+ state.sitesByFile = nextSites;
137
+ if (result.failures === 0) {
138
+ state.dirtyFps = new Set();
139
+ }
140
+ else {
141
+ state.dirtyFps = new Set([...dirtyFps, ...changedFps]);
142
+ }
143
+ return result;
144
+ }
145
+ function normalizePath(path) {
146
+ return path.replace(/\\/g, "/").replace(/^\.\//, "");
147
+ }
148
+ function projectPath(root, path) {
149
+ return normalizePath(relative(resolve(root), resolve(root, path)));
150
+ }
151
+ function isProjectGraphFile(path) {
152
+ const base = basename(path);
153
+ return CONFIG_FILES.has(base) || /^tsconfig(?:\.[^.]+)*\.json$/.test(base);
154
+ }
155
+ function groupSites(sites) {
156
+ const grouped = new Map();
157
+ for (const site of sites) {
158
+ const file = normalizePath(site.file);
159
+ const current = grouped.get(file) ?? [];
160
+ current.push(site);
161
+ grouped.set(file, current);
162
+ }
163
+ return grouped;
46
164
  }
47
165
  export async function runWatch(opts) {
48
166
  const stamp = () => new Date().toTimeString().slice(0, 8);
49
- const log = (m) => console.log(`[${stamp()}] ${m}`);
50
- const err = (m) => console.error(`[${stamp()}] ${m}`);
167
+ const event = (name, data = {}) => {
168
+ console.log(formatWatchEvent(name, data));
169
+ };
170
+ const log = opts.jsonl ? () => { } : (m) => console.log(`[${stamp()}] ${m}`);
171
+ const err = opts.jsonl ? () => { } : (m) => console.error(`[${stamp()}] ${m}`);
51
172
  const state = { session: null };
52
- log("watch: initial prepare");
173
+ const report = (result, durationMs) => {
174
+ if (!opts.jsonl)
175
+ return;
176
+ for (const diagnostic of result.diagnostics)
177
+ event("diagnostic", { diagnostic });
178
+ event("prepared", {
179
+ ok: result.failures === 0,
180
+ sites: result.sites,
181
+ entries: result.entries,
182
+ failures: result.failures,
183
+ pruned: result.pruned,
184
+ functions: result.functions,
185
+ ...(durationMs === undefined ? {} : { durationMs }),
186
+ });
187
+ };
188
+ if (opts.jsonl)
189
+ event("start", { root: opts.root });
190
+ else
191
+ log("watch: initial prepare");
53
192
  try {
54
193
  const r = await prepareWatchedOnce(opts, state, log, err);
55
- log(`watch: ready — ${r.entries} queries, ${r.failures} failures`);
194
+ if (opts.jsonl)
195
+ report(r);
196
+ else
197
+ log(`watch: ready — ${r.entries} queries, ${r.failures} failures`);
56
198
  }
57
199
  catch (e) {
58
- err(`watch: initial prepare failed — ${e.message}`);
200
+ if (opts.jsonl)
201
+ event("error", watchErrorData(e));
202
+ else
203
+ err(`watch: initial prepare failed — ${e.message}`);
59
204
  }
60
- log(`watch: monitoring ${opts.root}`);
205
+ if (opts.jsonl)
206
+ event("watching", { root: opts.root });
207
+ else
208
+ log(`watch: monitoring ${opts.root}`);
61
209
  let pending = false;
62
210
  let running = null;
63
211
  let timer = null;
212
+ const changedFiles = new Set();
64
213
  const trigger = () => {
65
214
  if (timer)
66
215
  clearTimeout(timer);
@@ -71,13 +220,22 @@ export async function runWatch(opts) {
71
220
  return;
72
221
  }
73
222
  const start = Date.now();
223
+ const batch = [...changedFiles];
224
+ changedFiles.clear();
74
225
  running = (async () => {
75
226
  try {
76
- const r = await prepareWatchedOnce(opts, state, log, err);
77
- log(`watch: re-prepared in ${Date.now() - start}ms (${r.entries} queries, ${r.failures} failures)`);
227
+ const r = await prepareWatchedOnce(opts, state, log, err, {}, batch);
228
+ const durationMs = Date.now() - start;
229
+ if (opts.jsonl)
230
+ report(r, durationMs);
231
+ else
232
+ log(`watch: re-prepared in ${durationMs}ms (${r.entries} queries, ${r.failures} failures)`);
78
233
  }
79
234
  catch (e) {
80
- err(`watch: prepare error — ${e.message}`);
235
+ if (opts.jsonl)
236
+ event("error", watchErrorData(e));
237
+ else
238
+ err(`watch: prepare error — ${e.message}`);
81
239
  }
82
240
  })();
83
241
  await running;
@@ -93,11 +251,16 @@ export async function runWatch(opts) {
93
251
  return;
94
252
  if (!shouldWatchFile(filename.toString()))
95
253
  return;
254
+ changedFiles.add(normalizePath(filename.toString()));
96
255
  trigger();
97
256
  });
98
257
  const shutdown = async () => {
99
- console.log();
100
- log("watch: stopping");
258
+ if (opts.jsonl)
259
+ event("stopping");
260
+ else {
261
+ console.log();
262
+ log("watch: stopping");
263
+ }
101
264
  watcher.close();
102
265
  await closeSession(state.session);
103
266
  process.exit(0);
@@ -1,6 +1,7 @@
1
1
  export type ScanConfig = {
2
2
  include?: string[];
3
3
  exclude?: string[];
4
+ modules?: string[];
4
5
  };
5
6
  export type SqlxJsConfig = {
6
7
  jsonbTypes?: Record<string, string>;
@@ -52,6 +52,12 @@ function validateStringArray(value, name, path) {
52
52
  throw new Error(`sqlx-js: ${path} ${name} must be an array of strings`);
53
53
  }
54
54
  }
55
+ function validateModuleArray(value, path) {
56
+ validateStringArray(value, "scan.modules", path);
57
+ if (value.length === 0 || value.some((item) => item.trim() === "")) {
58
+ throw new Error(`sqlx-js: ${path} scan.modules must contain at least one non-empty module name`);
59
+ }
60
+ }
55
61
  function validateConfig(value, path) {
56
62
  if (!value || typeof value !== "object" || Array.isArray(value)) {
57
63
  throw new Error(`sqlx-js: ${path} must default-export a config object`);
@@ -70,6 +76,8 @@ function validateConfig(value, path) {
70
76
  validateStringArray(scan.include, "scan.include", path);
71
77
  if (scan.exclude !== undefined)
72
78
  validateStringArray(scan.exclude, "scan.exclude", path);
79
+ if (scan.modules !== undefined)
80
+ validateModuleArray(scan.modules, path);
73
81
  }
74
82
  if (config.schema !== undefined) {
75
83
  if (!config.schema || typeof config.schema !== "object" || Array.isArray(config.schema)) {
@@ -14,5 +14,7 @@ export type FunctionEntry = {
14
14
  returns: string;
15
15
  returnsSet: boolean;
16
16
  };
17
+ export declare function functionCachePath(cacheDir: string): string;
18
+ export declare function functionCacheExists(cacheDir: string): boolean;
17
19
  export declare function readFunctionCache(cacheDir: string): FunctionEntry[];
18
20
  export declare function writeFunctionCache(cacheDir: string, functions: FunctionEntry[]): void;
@@ -1,9 +1,12 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
- function cachePath(cacheDir) {
4
+ export function functionCachePath(cacheDir) {
5
5
  return join(cacheDir, "functions", "functions.json");
6
6
  }
7
+ export function functionCacheExists(cacheDir) {
8
+ return existsSync(functionCachePath(cacheDir));
9
+ }
7
10
  function parseFunctionCache(raw) {
8
11
  if (!raw || typeof raw !== "object")
9
12
  return [];
@@ -13,14 +16,14 @@ function parseFunctionCache(raw) {
13
16
  return obj.functions;
14
17
  }
15
18
  export function readFunctionCache(cacheDir) {
16
- const path = cachePath(cacheDir);
19
+ const path = functionCachePath(cacheDir);
17
20
  if (!existsSync(path))
18
21
  return [];
19
22
  const text = readFileSync(path, "utf8");
20
23
  return parseFunctionCache(JSON.parse(text));
21
24
  }
22
25
  export function writeFunctionCache(cacheDir, functions) {
23
- const path = cachePath(cacheDir);
26
+ const path = functionCachePath(cacheDir);
24
27
  mkdirSync(dirname(path), { recursive: true });
25
28
  const payload = { version: 1, functions };
26
29
  const tmp = `${path}.tmp-${randomBytes(4).toString("hex")}`;
@@ -23,17 +23,33 @@ export type { ScanConfig, SqlxJsConfig } from "./config.js";
23
23
  export type { SslMode, ConnConfig } from "./pg/wire.js";
24
24
  export { PgError, ConnectionLostError } from "./pg/wire.js";
25
25
  export { NoRowsError, TooManyRowsError } from "./runtime.js";
26
- export type { TransactionOptions, MigrateOptions, OnQueryEvent, OnQueryHook } from "./runtime.js";
26
+ export type { TransactionOptions, MigrateOptions, OnQueryEvent, OnQueryHook, OnQueryHookError } from "./runtime.js";
27
27
  export type { ExecuteResult, JsonParameter, PgArrayParameter } from "./runtime.js";
28
28
  export type { PostgresClient, PostgresOptions, CreateClientOptions } from "./postgres-runtime.js";
29
29
  export type TypedFile = TypedFileFor<KnownFileQueries>;
30
30
  export type TypedSql = TypedSqlFor<KnownQueries, KnownFileQueries>;
31
31
  export type Typed = TypedFor<KnownQueries, KnownFileQueries, import("./runtime.js").TransactionOptions>;
32
+ export type QueryRegistry = {
33
+ queries: object;
34
+ fileQueries: object;
35
+ };
36
+ export interface DefaultQueryRegistry {
37
+ queries: KnownQueries;
38
+ fileQueries: KnownFileQueries;
39
+ functions: KnownFunctions;
40
+ }
41
+ export type SqlClient<Registry extends QueryRegistry = DefaultQueryRegistry> = {
42
+ sql: TypedFor<Registry["queries"], Registry["fileQueries"], import("./runtime.js").TransactionOptions>;
43
+ unsafe: Unsafe;
44
+ client: import("./postgres-runtime.js").PostgresClient;
45
+ close: () => Promise<void>;
46
+ };
32
47
  export declare const sql: Typed;
33
48
  export type Unsafe = (query: string, ...params: unknown[]) => Promise<Record<string, unknown>[]>;
34
49
  export declare const unsafe: Unsafe;
35
50
  export declare const getClient: typeof rt.getClient;
36
51
  export declare const setClient: typeof rt.setClient;
37
52
  export declare const createClient: typeof rt.createClient;
53
+ export declare function createSqlClient<Registry extends QueryRegistry = DefaultQueryRegistry>(url?: string, options?: import("./postgres-runtime.js").CreateClientOptions): SqlClient<Registry>;
38
54
  export declare const close: typeof rt.close;
39
55
  export { migrate, clearSqlFileCache, encodePgArrayLiteral, id, json, array } from "./runtime.js";
package/dist/src/index.js CHANGED
@@ -7,5 +7,8 @@ export const unsafe = rt.unsafe;
7
7
  export const getClient = rt.getClient;
8
8
  export const setClient = rt.setClient;
9
9
  export const createClient = rt.createClient;
10
+ export function createSqlClient(url, options) {
11
+ return rt.createSqlClient(url, options);
12
+ }
10
13
  export const close = rt.close;
11
14
  export { migrate, clearSqlFileCache, encodePgArrayLiteral, id, json, array } from "./runtime.js";
@@ -0,0 +1,157 @@
1
+ import { PgClient } from "./pg/wire.js";
2
+ export type MigrationFile = {
3
+ version: number;
4
+ name: string;
5
+ upPath: string;
6
+ downPath: string | null;
7
+ upSql: string;
8
+ upHash: string;
9
+ squash: SquashMetadata | null;
10
+ };
11
+ export type SquashReplacement = {
12
+ version: number;
13
+ name: string;
14
+ upHash: string;
15
+ };
16
+ export type SquashMetadata = {
17
+ format: 1;
18
+ replaces: SquashReplacement[];
19
+ };
20
+ export type MigrationStore = {
21
+ table: string;
22
+ };
23
+ export declare const SQUASH_PREFIX = "-- sqlx-js-squash:";
24
+ export declare const DEFAULT_MIGRATE_LOCK_KEY = 18750938867203960n;
25
+ export declare function readMigrations(dir: string): MigrationFile[];
26
+ export declare function parseSquashMetadata(sql: string): SquashMetadata | null;
27
+ export declare function ensureTable(client: PgClient): Promise<MigrationStore>;
28
+ export declare function listApplied(client: PgClient, store?: MigrationStore): Promise<Map<number, {
29
+ name: string;
30
+ hash: string;
31
+ }>>;
32
+ export type ApplyOutcome = {
33
+ kind: "applied";
34
+ version: number;
35
+ name: string;
36
+ } | {
37
+ kind: "adopted";
38
+ version: number;
39
+ name: string;
40
+ replaced: number;
41
+ } | {
42
+ kind: "tampered";
43
+ version: number;
44
+ name: string;
45
+ applied: string;
46
+ current: string;
47
+ } | {
48
+ kind: "failed";
49
+ version: number;
50
+ name: string;
51
+ error: string;
52
+ };
53
+ export type PlanOutcome = {
54
+ kind: "pending";
55
+ version: number;
56
+ name: string;
57
+ } | {
58
+ kind: "adoptable";
59
+ version: number;
60
+ name: string;
61
+ replaced: number;
62
+ } | {
63
+ kind: "tampered";
64
+ version: number;
65
+ name: string;
66
+ applied: string;
67
+ current: string;
68
+ } | {
69
+ kind: "failed";
70
+ version: number;
71
+ name: string;
72
+ error: string;
73
+ };
74
+ export type MigrationPlanItem = {
75
+ kind: "apply";
76
+ version: number;
77
+ name: string;
78
+ } | {
79
+ kind: "adopt";
80
+ version: number;
81
+ name: string;
82
+ replaced: number;
83
+ };
84
+ export type MigrationPlanDiagnostic = Extract<PlanOutcome, {
85
+ kind: "tampered" | "failed";
86
+ }>;
87
+ export type MigrationPlanSnapshot = {
88
+ ok: boolean;
89
+ pending: number;
90
+ adoptable: number;
91
+ tampered: number;
92
+ failed: number;
93
+ steps: MigrationPlanItem[];
94
+ diagnostics: MigrationPlanDiagnostic[];
95
+ };
96
+ export type MigrationInfoStatus = "applied" | "pending" | "adoptable" | "superseded" | "tampered" | "failed";
97
+ export type MigrationInfoItem = {
98
+ version: number;
99
+ name: string;
100
+ status: MigrationInfoStatus;
101
+ detail?: string;
102
+ };
103
+ export type MigrationInfoSnapshot = {
104
+ historyTable: string | null;
105
+ summary: Record<MigrationInfoStatus, number>;
106
+ items: MigrationInfoItem[];
107
+ };
108
+ type InternalMigrationPlanItem = {
109
+ kind: "apply";
110
+ migration: MigrationFile;
111
+ } | {
112
+ kind: "adopt";
113
+ migration: MigrationFile;
114
+ replaced: number;
115
+ };
116
+ export type MigrationValidationOutcome = {
117
+ kind: "tampered";
118
+ version: number;
119
+ name: string;
120
+ applied: string;
121
+ current: string;
122
+ } | {
123
+ kind: "failed";
124
+ version: number;
125
+ name: string;
126
+ error: string;
127
+ };
128
+ export declare function effectiveSquashReplacements(all: MigrationFile[]): SquashReplacement[];
129
+ export declare function buildMigrationPlan(all: MigrationFile[], applied: Map<number, {
130
+ name: string;
131
+ hash: string;
132
+ }>, onEvent?: (event: MigrationValidationOutcome) => void): {
133
+ kind: "ok";
134
+ steps: InternalMigrationPlanItem[];
135
+ } | {
136
+ kind: "failed";
137
+ } | {
138
+ kind: "tampered";
139
+ };
140
+ export declare function resetMigrationSession(client: PgClient): Promise<void>;
141
+ export declare function planPending(client: PgClient, migrationsDir: string, onEvent?: (event: PlanOutcome) => void): Promise<{
142
+ pending: number;
143
+ adoptable: number;
144
+ tampered: number;
145
+ failed: number;
146
+ steps: MigrationPlanItem[];
147
+ }>;
148
+ export declare function inspectMigrationPlan(client: PgClient, migrationsDir: string): Promise<MigrationPlanSnapshot>;
149
+ export declare function inspectMigrations(client: PgClient, migrationsDir: string): Promise<MigrationInfoSnapshot>;
150
+ export declare function applyPending(client: PgClient, migrationsDir: string, onEvent?: (event: ApplyOutcome) => void): Promise<{
151
+ applied: number;
152
+ tampered: number;
153
+ failed: number;
154
+ }>;
155
+ export declare function acquireMigrateLock(client: PgClient, lockKey?: number | bigint, timeoutMs?: number): Promise<void>;
156
+ export declare function releaseMigrateLock(client: PgClient, lockKey?: number | bigint): Promise<void>;
157
+ export {};