Skip to content

Commit ed47cb4

Browse files
feat: custom script path for manual resolution (#111)
* feat: implement manual job resolution with configurable jobs file path and enhance stack trace parsing * feat: enhance manual job resolution with custom jobs file path and improve error handling * feat: add dashboard configuration options and enhance manual job resolution documentation * feat: rename resolveScriptPath to resolveScriptPathForJob and update references * feat: update manual-loader mock to include resolveScriptPath and improve error message clarity * feat: update resolveScriptPath tests for cross-platform compatibility and improve error handling * fix: improve error handling in afterAll cleanup for MANUAL_SCRIPT_TAG file * refactor: move MANUAL_SCRIPT_TAG setup and teardown to manualJobResolution tests * feat: change to better-sqlite3 * docs: update documentation to specify better-sqlite3 in SqliteBackend
1 parent 4959114 commit ed47cb4

18 files changed

Lines changed: 935 additions & 500 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
"semantic-release": "^24.2.7",
8181
"tailwindcss": "^4.1.13",
8282
"tsx": "^4.20.5",
83-
"turbo": "^2.5.6",
83+
"turbo": "^2.5.8",
8484
"typescript": "^5.9.2",
8585
"typescript-eslint": "^8.42.0",
8686
"vitepress": "^1.6.4",

packages/backends/sqlite/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@
4545
"dependencies": {
4646
"@sidequest/backend": "workspace:*",
4747
"@sidequest/core": "workspace:*",
48-
"knex": "^3.1.0",
49-
"sqlite3": "^5.1.7"
48+
"better-sqlite3": "^12.4.1",
49+
"knex": "^3.1.0"
5050
},
5151
"devDependencies": {
5252
"@sidequest/backend-test": "workspace:*"

packages/backends/sqlite/src/sqlite-backend.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { hostname } from "os";
55
import path from "path";
66

77
const defaultKnexConfig = {
8-
client: "sqlite3",
8+
client: "better-sqlite3",
99
useNullAsDefault: true,
1010
migrations: {
1111
directory: path.join(import.meta.dirname, "..", "migrations"),
@@ -18,7 +18,7 @@ const defaultKnexConfig = {
1818
* Represents a backend implementation for SQLite databases using Knex.
1919
*
2020
* This class extends the `SQLBackend` and configures a Knex instance
21-
* for SQLite3, specifying the database file path, migration directory,
21+
* for better-sqlite3, specifying the database file path, migration directory,
2222
* migration table name, and file extension for migration files.
2323
*
2424
* @example

packages/core/src/job/job.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CompletedResult, RetryResult, SnoozeResult } from "@sidequest/core";
22
import path from "path";
33
import { pathToFileURL } from "url";
4-
import { Job, resolveScriptPath } from "./job";
4+
import { Job, resolveScriptPathForJob } from "./job";
55

66
export class DummyJob extends Job {
77
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -116,31 +116,31 @@ describe("job.ts", () => {
116116
});
117117
});
118118

119-
describe("resolveScriptPath", () => {
119+
describe("resolveScriptPathForJob", () => {
120120
it("should return the input if it is already a file URL", () => {
121121
const fileUrl = "file:///some/path/to/file.js";
122-
expect(resolveScriptPath(fileUrl)).toBe(fileUrl);
122+
expect(resolveScriptPathForJob(fileUrl)).toBe(fileUrl);
123123
});
124124

125125
it("should convert an absolute path to a file URL", () => {
126126
const absPath = path.resolve("/tmp/test.js");
127127
const expected = pathToFileURL(absPath).href;
128-
expect(resolveScriptPath(absPath)).toBe(expected);
128+
expect(resolveScriptPathForJob(absPath)).toBe(expected);
129129
});
130130

131131
it("should resolve a relative path to a file URL based on import.meta.dirname", () => {
132132
const relativePath = "test/fixtures/job-script.js";
133133
const baseDir = import.meta?.dirname ? import.meta.dirname : __dirname;
134134
const absPath = path.resolve(baseDir, relativePath);
135135
const expected = pathToFileURL(absPath).href;
136-
expect(resolveScriptPath(relativePath)).toBe(expected);
136+
expect(resolveScriptPathForJob(relativePath)).toBe(expected);
137137
});
138138

139139
it("should handle relative paths with ./ and ../", () => {
140140
const relativePath = "../test/fixtures/job-script.js";
141141
const baseDir = import.meta?.dirname ? import.meta.dirname : __dirname;
142142
const absPath = path.resolve(baseDir, relativePath);
143143
const expected = pathToFileURL(absPath).href;
144-
expect(resolveScriptPath(relativePath)).toBe(expected);
144+
expect(resolveScriptPathForJob(relativePath)).toBe(expected);
145145
});
146146
});

packages/core/src/job/job.ts

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { pathToFileURL } from "url";
44
import { logger } from "../logger";
55
import { BackoffStrategy, ErrorData, JobData, JobState } from "../schema";
66
import { toErrorData } from "../tools";
7+
import { parseStackTrace } from "../tools/stack-parser";
78
import { CompletedResult, FailedResult, isJobResult, JobResult, RetryResult, SnoozeResult } from "../transitions";
89
import { UniquenessConfig } from "../uniquiness";
910

@@ -250,30 +251,20 @@ export abstract class Job implements JobData {
250251
*/
251252
async function buildPath(className: string) {
252253
const err = new Error();
253-
let stackLines = err.stack?.split("\n") ?? [];
254-
stackLines = stackLines.slice(1);
255-
logger("Job").debug(`Resolving script file path. Stack lines: ${stackLines.join("\n")}`);
256-
const filePaths = stackLines
257-
.map((line) => {
258-
const match = /(file:\/\/)?(((\/?)(\w:))?([/\\].+)):\d+:\d+/.exec(line);
259-
if (match) {
260-
return `${match[5] ?? ""}${match[6].replaceAll("\\", "/")}`;
261-
}
262-
return null;
263-
})
264-
.filter(Boolean);
254+
logger("Job").debug(`Resolving script file path. Stack lines: ${err.stack}`);
255+
const filePaths = parseStackTrace(err);
265256

266257
for (const filePath of filePaths) {
267-
const hasExported = await hasClassExported(filePath!, className);
258+
const hasExported = await hasClassExported(filePath, className);
268259
if (hasExported) {
269-
const relativePath = path.relative(import.meta.dirname, filePath!);
260+
const relativePath = path.relative(import.meta.dirname, filePath);
270261
logger("Job").debug(`${filePath} exports class ${className}, relative path: ${relativePath}`);
271262
return relativePath.replaceAll("\\", "/");
272263
}
273264
}
274265

275266
if (filePaths.length > 0) {
276-
const relativePath = path.relative(import.meta.dirname, filePaths[0]!);
267+
const relativePath = path.relative(import.meta.dirname, filePaths[0]);
277268
logger("Job").debug(`No class ${className} found in stack, returning first file path: ${relativePath}`);
278269
return relativePath.replaceAll("\\", "/");
279270
}
@@ -294,11 +285,11 @@ async function buildPath(className: string) {
294285
*
295286
* @example
296287
* ```typescript
297-
* const scriptUrl = resolveScriptPath("../../../examples/hello-job.js");
288+
* const scriptUrl = resolveScriptPathForJob("../../../examples/hello-job.js");
298289
* const module = await import(scriptUrl);
299290
* ```
300291
*/
301-
export function resolveScriptPath(relativePath: string): string {
292+
export function resolveScriptPathForJob(relativePath: string): string {
302293
// If it's already a file URL, return as-is
303294
if (relativePath.startsWith("file://")) {
304295
return relativePath;

packages/core/src/tools/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./parse-error-data";
22
export * from "./serialize-error";
3+
export * from "./stack-parser";
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { parseStackTrace } from "./stack-parser";
2+
3+
describe("parseStackTrace", () => {
4+
it("should parse stack trace with Windows file paths", () => {
5+
const error = new Error("Test error");
6+
error.stack = `Error: Test error
7+
at function1 (C:\\Users\\test\\file.js:10:5)
8+
at function2 (C:\\Projects\\app\\index.ts:25:12)`;
9+
10+
const result = parseStackTrace(error);
11+
expect(result).toEqual(["C:/Users/test/file.js", "C:/Projects/app/index.ts"]);
12+
});
13+
14+
it("should parse stack trace with Unix file paths", () => {
15+
const error = new Error("Test error");
16+
error.stack = `Error: Test error
17+
at function1 (/home/user/file.js:10:5)
18+
at function2 (/opt/app/index.ts:25:12)`;
19+
20+
const result = parseStackTrace(error);
21+
expect(result).toEqual(["/home/user/file.js", "/opt/app/index.ts"]);
22+
});
23+
24+
it("should parse stack trace with file:// protocol", () => {
25+
const error = new Error("Test error");
26+
error.stack = `Error: Test error
27+
at function1 (file:///C:/Users/test/file.js:10:5)
28+
at function2 (file:///home/user/app.ts:15:8)`;
29+
30+
const result = parseStackTrace(error);
31+
expect(result).toEqual(["C:/Users/test/file.js", "/home/user/app.ts"]);
32+
});
33+
34+
it("should handle mixed path formats", () => {
35+
const error = new Error("Test error");
36+
error.stack = `Error: Test error
37+
at function1 (C:\\Windows\\file.js:10:5)
38+
at function2 (/usr/local/file.ts:20:3)
39+
at function3 (file:///D:/project/main.js:5:1)`;
40+
41+
const result = parseStackTrace(error);
42+
expect(result).toEqual(["C:/Windows/file.js", "/usr/local/file.ts", "D:/project/main.js"]);
43+
});
44+
45+
it("should return empty array when stack is undefined", () => {
46+
const error = new Error("Test error");
47+
error.stack = undefined;
48+
49+
const result = parseStackTrace(error);
50+
expect(result).toEqual([]);
51+
});
52+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Parses an error stack trace to extract file paths.
3+
*
4+
* @param err - The Error object containing the stack trace to parse
5+
* @returns An array of normalized file paths extracted from the stack trace, with backslashes converted to forward slashes and null entries filtered out
6+
*
7+
* @example
8+
* ```typescript
9+
* const error = new Error('Something went wrong');
10+
* const filePaths = parseStackTrace(error);
11+
* console.log(filePaths); // ['C:/path/to/file.js', '/another/path/file.ts']
12+
* ```
13+
*/
14+
export function parseStackTrace(err: Error): string[] {
15+
const stackLines = err.stack?.split("\n") ?? [];
16+
return stackLines
17+
.map((line) => {
18+
const match = /(file:\/\/)?(((\/?)(\w:))?([/\\].+)):\d+:\d+/.exec(line);
19+
if (match) {
20+
return `${match[5] ?? ""}${match[6].replaceAll("\\", "/")}`;
21+
}
22+
return undefined;
23+
})
24+
.filter(Boolean) as string[];
25+
}

packages/docs/engine/configuration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ await Sidequest.start({
176176
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------- |
177177
| `backend.driver` | Backend driver package name (SQLite, Postgres, MySQL, MongoDB) | `@sidequest/sqlite-backend` |
178178
| `backend.config` | Backend-specific connection string or [Knex configuration object](https://knexjs.org/guide/#configuration-options) | `./sidequest.sqlite` |
179+
| `dashboard.enabled` | Whether to enable the dashboard web interface | `true` |
180+
| `dashboard.port` | Port for the dashboard web interface | `8678` |
181+
| `dashboard.auth` | Basic auth configuration with `user` and `password`. If omitted, no auth is required. | `undefined` |
179182
| `queues` | Array of queue configurations with name, concurrency, priority, and state | `[]` |
180183
| `maxConcurrentJobs` | Maximum number of jobs processed simultaneously across all queues | `10` |
181184
| `minThreads` | Minimum number of worker threads to use | Number of CPU cores |
@@ -192,6 +195,8 @@ await Sidequest.start({
192195
| `gracefulShutdown` | Whether to enable graceful shutdown handling | `true` |
193196
| `jobDefaults` | Default values for new jobs. Used while enqueueing | `undefined` |
194197
| `queueDefaults` | Default values for auto-created queues | `undefined` |
198+
| `manualJobResolution` | Whether to manually resolve job classes. See [Manual Job Resolution](/jobs/manual-resolution.md) | `false` |
199+
| `jobsFilePath` | Optional path to the file where job classes are exported. Ignored if `manualJobResolution` is `false`. | `undefined` |
195200

196201
::: danger
197202
If `auth` is not configured and `dashboard: true` is enabled in production, the dashboard will be publicly accessible. This is a security risk and **not recommended**.

packages/docs/jobs/manual-resolution.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ When you see `sidequest.jobs.js` as the job script, it indicates that the job cl
2828
When manual job resolution is enabled:
2929

3030
1. **Job Enqueuing**: Jobs are enqueued with `script: "sidequest.jobs.js"` instead of specific file paths
31-
2. **Job Execution**: The runner looks for a `sidequest.jobs.js` file in the current directory or any parent directory
31+
2. **Job Execution**: The runner looks for a `sidequest.jobs.js` file in the current directory or any parent directory. If the `jobsFilePath` configuration is set, it uses that path instead.
3232
3. **Class Resolution**: Job classes are imported from the central registry file instead of individual script files
3333

3434
## Setting Up Manual Job Resolution
@@ -45,12 +45,13 @@ await Sidequest.start({
4545
backend: { driver: "@sidequest/sqlite-backend" },
4646
queues: [{ name: "default" }],
4747
manualJobResolution: true, // Enable manual job resolution
48+
jobsFilePath: "./sidequest.jobs.js", // Optional: specify custom path. If not set, defaults to searching for sidequest.jobs.js in parent dirs
4849
});
4950
```
5051

5152
### Step 2: Create the Job Registry
5253

53-
Create a `sidequest.jobs.js` file in your project root (or any parent directory):
54+
Create a `sidequest.jobs.js` file in your project root (or any parent directory), or where you specified in `jobsFilePath`:
5455

5556
```javascript
5657
// sidequest.jobs.js
@@ -75,6 +76,8 @@ await Sidequest.build(ProcessImageJob).with(imageProcessor).enqueue("/path/to/im
7576

7677
## File Discovery
7778

79+
### Finding `sidequest.jobs.js` when `jobsFilePath` is not set
80+
7881
Sidequest searches for the `sidequest.jobs.js` file using the following strategy:
7982

8083
1. **Current Working Directory**: Starts from `process.cwd()`
@@ -126,6 +129,24 @@ For example:
126129
In this case, since `sidequest.jobs.js` is at the `My Projects/` level, both worker projects can find it when they start up.
127130
If this file exports all job classes used by both projects, everything will work seamlessly.
128131

132+
### Finding `sidequest.jobs.js` when `jobsFilePath` is set
133+
134+
If you set the `jobsFilePath` configuration option, Sidequest will use that exact path to locate the `sidequest.jobs.js` file.
135+
This is useful if you want to place the file in a non-standard location or have multiple job registries for different environments.
136+
137+
If you provide an absolute path or file URL, Sidequest will use that directly.
138+
However, if you provide a relative path, it will be resolved relative to the file that called `Sidequest.start` or `Sidequest.configure`.
139+
140+
For example, if you provide `jobsFilePath: "./config/sidequest.jobs.js"` in your main application file which is located at `/app/src/server.js`, Sidequest will look for the jobs file at `/app/src/config/sidequest.jobs.js`.
141+
142+
```text
143+
/app/
144+
└── src/
145+
├── server.js
146+
└── config/
147+
└── sidequest.jobs.js
148+
```
149+
129150
## Best Practices
130151

131152
### 1. Consistent Export Strategy

0 commit comments

Comments
 (0)