neosqlite 1.0.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/.prettierrc +12 -0
- package/database.db +0 -0
- package/index.ts +14 -0
- package/lib/package.json +31 -0
- package/lib/src/client/index.d.ts +2 -0
- package/lib/src/client/index.js +62 -0
- package/lib/src/client/util.d.ts +108 -0
- package/lib/src/client/util.js +223 -0
- package/lib/src/index.d.ts +1 -0
- package/lib/src/index.js +17 -0
- package/lib/src/jobs/index.d.ts +48 -0
- package/lib/src/jobs/index.js +201 -0
- package/lib/src/migrations/commands/down.d.ts +2 -0
- package/lib/src/migrations/commands/down.js +74 -0
- package/lib/src/migrations/commands/new.d.ts +6 -0
- package/lib/src/migrations/commands/new.js +26 -0
- package/lib/src/migrations/commands/up.d.ts +2 -0
- package/lib/src/migrations/commands/up.js +80 -0
- package/lib/src/migrations/index.d.ts +6 -0
- package/lib/src/migrations/index.js +35 -0
- package/lib/src/migrations/util.d.ts +9 -0
- package/lib/src/migrations/util.js +43 -0
- package/lib/src/types.d.ts +75 -0
- package/lib/src/types.js +16 -0
- package/package.json +31 -0
- package/src/client/index.ts +70 -0
- package/src/client/util.ts +231 -0
- package/src/index.ts +1 -0
- package/src/jobs/index.ts +286 -0
- package/src/migrations/commands/down.ts +50 -0
- package/src/migrations/commands/new.ts +33 -0
- package/src/migrations/commands/up.ts +56 -0
- package/src/migrations/index.ts +45 -0
- package/src/migrations/util.ts +46 -0
- package/src/types.ts +98 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
|
|
3
|
+
import type { ExecuteQueryParams, NeosqliteClient, NeosqliteConfig } from "../types";
|
|
4
|
+
|
|
5
|
+
import { parseQuery, runQuery } from "./util";
|
|
6
|
+
|
|
7
|
+
export function createClient(config: NeosqliteConfig): NeosqliteClient {
|
|
8
|
+
const { file } = config;
|
|
9
|
+
const db = new Database(file);
|
|
10
|
+
|
|
11
|
+
const transaction = (fn: Function) => {
|
|
12
|
+
db.transaction(() => {
|
|
13
|
+
fn();
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read data from the database, returns all matching rows
|
|
19
|
+
* @returns An array of objects
|
|
20
|
+
*/
|
|
21
|
+
const read = (params: ExecuteQueryParams) => {
|
|
22
|
+
const query = parseQuery(params);
|
|
23
|
+
const statement = db.prepare(query.sql);
|
|
24
|
+
|
|
25
|
+
return runQuery(config, params, () => statement.all(query.args));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read data from the database, returns the first matching row
|
|
30
|
+
* @returns An object
|
|
31
|
+
*/
|
|
32
|
+
const readOne = (params: ExecuteQueryParams) => {
|
|
33
|
+
const query = parseQuery(params);
|
|
34
|
+
const statement = db.prepare(query.sql);
|
|
35
|
+
|
|
36
|
+
return runQuery(config, params, () => statement.get(query.args));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write data to the database, returns the number of
|
|
41
|
+
* rows affected
|
|
42
|
+
*/
|
|
43
|
+
const write = (params: ExecuteQueryParams) => {
|
|
44
|
+
const query = parseQuery(params);
|
|
45
|
+
const statement = db.prepare(query.sql);
|
|
46
|
+
|
|
47
|
+
return runQuery(config, params, () => statement.run(query.args));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write data to the database, returns all matching rows
|
|
52
|
+
* via RETURNING
|
|
53
|
+
*/
|
|
54
|
+
const writeReturning = (params: ExecuteQueryParams) => {
|
|
55
|
+
const query = parseQuery(params);
|
|
56
|
+
const statement = db.prepare(query.sql);
|
|
57
|
+
|
|
58
|
+
return runQuery(config, params, () => statement.all(query.args));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
read,
|
|
63
|
+
readOne,
|
|
64
|
+
write,
|
|
65
|
+
transaction,
|
|
66
|
+
writeReturning,
|
|
67
|
+
pragma: db.pragma,
|
|
68
|
+
backup: db.backup,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { ExecuteQueryParams, NeosqliteConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
const NEWLINE_CHAR = "\t";
|
|
4
|
+
|
|
5
|
+
export const parseQuery = (params: ExecuteQueryParams) => {
|
|
6
|
+
if (typeof params === "string") {
|
|
7
|
+
return { sql: params, args: {} };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return params;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const runQuery = (config: NeosqliteConfig, params: ExecuteQueryParams, fn: Function) => {
|
|
14
|
+
const startTime = process.hrtime();
|
|
15
|
+
const result = fn();
|
|
16
|
+
const endTime = process.hrtime(startTime);
|
|
17
|
+
const endTimeMs = endTime[0] * 1_000 + endTime[1] / 1_000_000;
|
|
18
|
+
|
|
19
|
+
if (config.onQueryComplete || config.onQueryLog) {
|
|
20
|
+
const queryString = getFullQueryString(params);
|
|
21
|
+
|
|
22
|
+
config.onQueryComplete?.({ query: queryString, time: endTimeMs });
|
|
23
|
+
|
|
24
|
+
if (typeof params === "object" && params.logQuery) {
|
|
25
|
+
config.onQueryLog?.(queryString);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Join a list of strings into a single string for
|
|
34
|
+
* SQL queries
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* queryString(
|
|
38
|
+
* "SELECT * FROM users",
|
|
39
|
+
* "WHERE id = :id"
|
|
40
|
+
* )
|
|
41
|
+
* // Returns
|
|
42
|
+
* "SELECT * FROM users WHERE id = :id"
|
|
43
|
+
*/
|
|
44
|
+
export const queryString = (...args: (string | boolean | undefined)[]) => {
|
|
45
|
+
const filteredArgs = args.filter(Boolean);
|
|
46
|
+
return filteredArgs.join(NEWLINE_CHAR);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Take a list and return an object with its sql placeholder names, and
|
|
51
|
+
* its prepared arguments
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* parameterize("id", [1, 2, 3])
|
|
55
|
+
* // Returns
|
|
56
|
+
* [
|
|
57
|
+
* // Placeholders
|
|
58
|
+
* ':id0, :id1, :id2',
|
|
59
|
+
* // Args
|
|
60
|
+
* { id0: 1, id1: 2, id2: 3 }
|
|
61
|
+
* ]
|
|
62
|
+
*/
|
|
63
|
+
export const parameterizePrimitiveArray = (key: string, value: Array<string | number>) => {
|
|
64
|
+
const args = Object.fromEntries(value.map((v, i) => [`${key}${i}`, v]));
|
|
65
|
+
const placeholders = value.map((_, i) => `:${key}${i}`).join(", ");
|
|
66
|
+
|
|
67
|
+
return [placeholders, args] as const;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Take an array of objects and extract specific fields as needed to be used
|
|
72
|
+
* as placeholders, and prepared arguments
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* parameterizeComplexArray([{ id: 1, name: "foo" }, { id: 2, name: "bar" }], ["id", "name"])
|
|
76
|
+
* // Returns
|
|
77
|
+
* [
|
|
78
|
+
* // Placeholders
|
|
79
|
+
* '(:id0, :name0), (:id1, :name1)',
|
|
80
|
+
* // Args
|
|
81
|
+
* { id0: 1, name0: "foo", id1: 2, name1: "bar" }
|
|
82
|
+
* ]
|
|
83
|
+
*/
|
|
84
|
+
export const parameterizeComplexArray = <T>(value: T[], fields: (keyof T)[]) => {
|
|
85
|
+
const placeholders = value.map((_, i) => `(${fields.map((field) => `:${String(field)}${i}`).join(",")})`).join(", ");
|
|
86
|
+
const args = value.reduce((acc, item, i) => {
|
|
87
|
+
return { ...acc, ...Object.fromEntries(fields.map((field) => [`${String(field)}${i}`, item[field] ?? null])) };
|
|
88
|
+
}, {});
|
|
89
|
+
|
|
90
|
+
return [placeholders, args] as const;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sanitizes a string to be used in a SQL query
|
|
95
|
+
* @remarks
|
|
96
|
+
* You should still prefer using parameterized queries over string concatenation, but this
|
|
97
|
+
* is sometimes necessary
|
|
98
|
+
*/
|
|
99
|
+
export const sanitize = (value: string | number | boolean | null | undefined) => {
|
|
100
|
+
if (value === null || value === undefined) return "NULL";
|
|
101
|
+
const sanitizedString = String(value)
|
|
102
|
+
.replace(/\\/g, "\\\\") // backslashes
|
|
103
|
+
.replace(/\u0008/g, "\\b") // backspace
|
|
104
|
+
.replace(/\t/g, "\\t") // tab
|
|
105
|
+
.replace(/\n/g, "\\n") // newline
|
|
106
|
+
.replace(/\f/g, "\\f") // form feed
|
|
107
|
+
.replace(/\r/g, "\\r") // carriage return
|
|
108
|
+
.replace(/'/g, "''"); // single quotes
|
|
109
|
+
return `'${sanitizedString}'`;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse a column as JSON, where it is either parsed as an object, or as
|
|
114
|
+
* null
|
|
115
|
+
*
|
|
116
|
+
* @returns A parsed object, or null
|
|
117
|
+
*/
|
|
118
|
+
export const Jsonify = <T extends Record<string, any> | Array<any> = Record<string, any>>(value: string) => {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(value) as T;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Logs the full query string that will be executed.
|
|
128
|
+
*
|
|
129
|
+
* @remarks
|
|
130
|
+
* ⚠️ DO NOT EXECUTE THIS STRING. By default, this is for logging only, to see
|
|
131
|
+
* what the query would look like with its parameters in place. This does NOT
|
|
132
|
+
* sanitize the parameters. In fact, it unsanitizes them.
|
|
133
|
+
*/
|
|
134
|
+
export const getFullQueryString = (params: ExecuteQueryParams) => {
|
|
135
|
+
if (typeof params === "string") {
|
|
136
|
+
return params;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let queryString = params.sql;
|
|
140
|
+
|
|
141
|
+
const args = Object.entries(params.args ?? {}).sort(([aKey], [bKey]) => {
|
|
142
|
+
return bKey.length - aKey.length;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
for (const [key, value] of args) {
|
|
146
|
+
const regex = new RegExp(`:${key}\\b`, "g");
|
|
147
|
+
queryString = queryString.replace(regex, `'${value}'`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let fullString = "";
|
|
151
|
+
const lines = queryString.split(NEWLINE_CHAR);
|
|
152
|
+
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
if (!line.trim()) continue;
|
|
155
|
+
fullString += `${line}\n`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return fullString;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Sanitize a string to remove characters that would break glob patterns
|
|
163
|
+
* from the string
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* sanitizeLike('foo%bar')
|
|
167
|
+
* // Returns
|
|
168
|
+
* 'foo\\%bar'
|
|
169
|
+
*/
|
|
170
|
+
export const sanitizeLike = (str: string) => {
|
|
171
|
+
return str
|
|
172
|
+
.replace(/\\/g, "\\\\") // escape backslashes
|
|
173
|
+
.replace(/%/g, "\\%") // escape %
|
|
174
|
+
.replace(/_/g, "\\_"); // escape _
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Fully sanitize a string down to only alphanumeric characters. Useful for JSON key paths
|
|
179
|
+
* or other string values that cannot be parameterized, but need to be sanitized
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* sanitizeSqlPath('foo';--bar')
|
|
183
|
+
* // Returns
|
|
184
|
+
* 'foobar'
|
|
185
|
+
*/
|
|
186
|
+
export const sanitizeSqlPath = (str: string) => {
|
|
187
|
+
return str.replace(/[^a-zA-Z0-9_]/g, "");
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Converts a date to the standard sqlite date format `YYYY-MM-DD HH:MM:SS`
|
|
192
|
+
* Also converts timezone to UTC
|
|
193
|
+
*/
|
|
194
|
+
export const toSqliteDateString = (date: Date | string) => {
|
|
195
|
+
if (typeof date === "string") {
|
|
196
|
+
date = new Date(date);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const yyyy = date.getUTCFullYear();
|
|
200
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
201
|
+
const dd = String(date.getUTCDate()).padStart(2, "0");
|
|
202
|
+
const hh = String(date.getUTCHours()).padStart(2, "0");
|
|
203
|
+
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
|
204
|
+
const ss = String(date.getUTCSeconds()).padStart(2, "0");
|
|
205
|
+
|
|
206
|
+
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Converts a database row to a String or null value
|
|
211
|
+
*/
|
|
212
|
+
export const StringOrNull = (value: unknown) => {
|
|
213
|
+
if (value === null) return null;
|
|
214
|
+
return String(value);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Converts a column value to a Number or null value
|
|
219
|
+
*/
|
|
220
|
+
export const NumberOrNull = (value: unknown) => {
|
|
221
|
+
if (value === null) return null;
|
|
222
|
+
return Number(value);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Converts a column value to a Boolean or null value
|
|
227
|
+
*/
|
|
228
|
+
export const BooleanOrNull = (value: unknown) => {
|
|
229
|
+
if (value === null) return null;
|
|
230
|
+
return Boolean(value);
|
|
231
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./types";
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import parser from "cron-parser";
|
|
2
|
+
import { parseDate } from "chrono-node";
|
|
3
|
+
|
|
4
|
+
import { createClient } from "../client";
|
|
5
|
+
import { Jsonify, queryString, toSqliteDateString } from "../client/util";
|
|
6
|
+
import { CreateJobsOptions, JobOptions, JobStatus, Row, NeosqliteClient } from "../types";
|
|
7
|
+
|
|
8
|
+
interface JobRegistryValue {
|
|
9
|
+
name: string;
|
|
10
|
+
options: JobOptions;
|
|
11
|
+
fn: (data: any) => void;
|
|
12
|
+
onFailure?: (data: any) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class NeosqliteJobs {
|
|
16
|
+
private db: NeosqliteClient;
|
|
17
|
+
private options: CreateJobsOptions;
|
|
18
|
+
private jobsRegistry: Record<string, JobRegistryValue> = {};
|
|
19
|
+
|
|
20
|
+
// Jobs worker information
|
|
21
|
+
private activeJobs = 0;
|
|
22
|
+
private isRunning = false;
|
|
23
|
+
|
|
24
|
+
constructor(options: CreateJobsOptions) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
this.db = createClient(options);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private get jobsTable() {
|
|
30
|
+
return this.options.jobsTable ?? "jobs";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private get maxJobs() {
|
|
34
|
+
return this.options.maxJobs ?? 10;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private get processEvery() {
|
|
38
|
+
return this.options.processEvery ?? 5000;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private get maxRetries() {
|
|
42
|
+
return this.options.maxRetries ?? 3;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Setup tables and start the job worker for neosqlite jobs
|
|
47
|
+
*/
|
|
48
|
+
public async start() {
|
|
49
|
+
await this.setupTables();
|
|
50
|
+
await this.setupJobWorker();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stop the job worker from listening for jobs
|
|
55
|
+
*/
|
|
56
|
+
public async stop() {
|
|
57
|
+
this.isRunning = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Register a job to be run
|
|
62
|
+
*/
|
|
63
|
+
public register<T extends Record<string, any> = Record<string, any>>(
|
|
64
|
+
name: string,
|
|
65
|
+
options: JobOptions,
|
|
66
|
+
fn: (data: T) => void,
|
|
67
|
+
onFailure?: (data: T) => void,
|
|
68
|
+
) {
|
|
69
|
+
this.jobsRegistry[name] = { name, options, fn, onFailure };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Queue a job to run instantly based on its
|
|
74
|
+
* priority
|
|
75
|
+
*/
|
|
76
|
+
public async queue<T extends Record<string, any> = Record<string, any>>(name: string, data?: T) {
|
|
77
|
+
const job = this.jobsRegistry[name];
|
|
78
|
+
if (!job) return console.error(`Job ${name} does not exist`);
|
|
79
|
+
|
|
80
|
+
this.db.write({
|
|
81
|
+
sql: queryString(
|
|
82
|
+
"INSERT INTO " + this.jobsTable,
|
|
83
|
+
"(name, data, runAt, priority)",
|
|
84
|
+
"VALUES (:name, :data, CURRENT_TIMESTAMP, :priority)",
|
|
85
|
+
),
|
|
86
|
+
args: {
|
|
87
|
+
name: job.name,
|
|
88
|
+
priority: job.options.priority ?? 0,
|
|
89
|
+
data: data ? JSON.stringify(data) : null,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Schedule a job to run in the future. This can use a date string, or a human-readable string
|
|
96
|
+
* such as 'in 10 minutes' or 'next week'
|
|
97
|
+
*/
|
|
98
|
+
public async schedule<T extends Record<string, any> = Record<string, any>>(date: string, name: string, data?: T) {
|
|
99
|
+
const job = this.jobsRegistry[name];
|
|
100
|
+
if (!job) return console.error(`Job ${name} does not exist`);
|
|
101
|
+
|
|
102
|
+
const parsedDate = parseDate(date);
|
|
103
|
+
if (!parsedDate) return;
|
|
104
|
+
|
|
105
|
+
this.db.write({
|
|
106
|
+
sql: queryString(
|
|
107
|
+
"INSERT INTO " + this.jobsTable,
|
|
108
|
+
"(name, data, runAt, priority)",
|
|
109
|
+
"VALUES (:name, :data, :runAt, :priority)",
|
|
110
|
+
),
|
|
111
|
+
args: {
|
|
112
|
+
name: job.name,
|
|
113
|
+
priority: job.options.priority ?? 0,
|
|
114
|
+
data: data ? JSON.stringify(data) : null,
|
|
115
|
+
runAt: toSqliteDateString(parsedDate),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Schedule a job to run every time the cron string is met
|
|
122
|
+
*/
|
|
123
|
+
public async every<T extends Record<string, any> = Record<string, any>>(cronString: string, name: string, data?: T) {
|
|
124
|
+
const job = this.jobsRegistry[name];
|
|
125
|
+
if (!job) return console.error(`Job ${name} does not exist`);
|
|
126
|
+
|
|
127
|
+
const interval = parser.parse(cronString);
|
|
128
|
+
const runAt = interval.next().toDate();
|
|
129
|
+
|
|
130
|
+
this.db.write({
|
|
131
|
+
sql: queryString(
|
|
132
|
+
"INSERT OR IGNORE INTO " + this.jobsTable,
|
|
133
|
+
"(name, data, runAt, priority, cron)",
|
|
134
|
+
"VALUES (:name, :data, :runAt, :priority, :cron)",
|
|
135
|
+
),
|
|
136
|
+
args: {
|
|
137
|
+
name: job.name,
|
|
138
|
+
priority: job.options.priority ?? 0,
|
|
139
|
+
data: data ? JSON.stringify(data) : null,
|
|
140
|
+
runAt: toSqliteDateString(runAt),
|
|
141
|
+
cron: cronString,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Creates the jobs table and sets up indexes on it
|
|
148
|
+
*/
|
|
149
|
+
private async setupTables() {
|
|
150
|
+
const jobStatuses = Object.values(JobStatus)
|
|
151
|
+
.map((s) => `'${s}'`)
|
|
152
|
+
.join(", ");
|
|
153
|
+
|
|
154
|
+
this.db.write(
|
|
155
|
+
queryString(
|
|
156
|
+
"CREATE TABLE IF NOT EXISTS " + this.jobsTable + " (",
|
|
157
|
+
" id INTEGER PRIMARY KEY AUTOINCREMENT,",
|
|
158
|
+
" name TEXT NOT NULL,",
|
|
159
|
+
" data TEXT,",
|
|
160
|
+
" runAt DATETIME NOT NULL,",
|
|
161
|
+
" priority INTEGER DEFAULT 0,",
|
|
162
|
+
" cron TEXT,",
|
|
163
|
+
" attempts INTEGER DEFAULT 0,",
|
|
164
|
+
" status TEXT CHECK (status IN ( " + jobStatuses + ")) DEFAULT '" + JobStatus.Pending + "',",
|
|
165
|
+
" updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,",
|
|
166
|
+
" createdAt DATETIME DEFAULT CURRENT_TIMESTAMP",
|
|
167
|
+
")",
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Prevent multiple cron jobs from getting scheduled for the same dates
|
|
172
|
+
this.db.write(
|
|
173
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_${this.jobsTable}_name_cron_runAt ON ${this.jobsTable} (name, cron, runAt)`,
|
|
174
|
+
);
|
|
175
|
+
this.db.write(
|
|
176
|
+
`CREATE INDEX IF NOT EXISTS idx_${this.jobsTable}_status_runAt_priority ON ${this.jobsTable} (status, runAt, priority DESC)`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Sets up the jobs worker to run automatically
|
|
182
|
+
*/
|
|
183
|
+
private async setupJobWorker() {
|
|
184
|
+
if (this.isRunning) return;
|
|
185
|
+
this.isRunning = true;
|
|
186
|
+
|
|
187
|
+
const loop = async () => {
|
|
188
|
+
if (!this.isRunning) return;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
if (this.activeJobs < this.maxJobs) {
|
|
192
|
+
const limit = this.maxJobs - this.activeJobs;
|
|
193
|
+
|
|
194
|
+
const rows = this.db.writeReturning({
|
|
195
|
+
sql: queryString(
|
|
196
|
+
"UPDATE jobs",
|
|
197
|
+
"SET status = :runningStatus,",
|
|
198
|
+
" attempts = attempts + 1,",
|
|
199
|
+
" updatedAt = CURRENT_TIMESTAMP",
|
|
200
|
+
"WHERE id IN (",
|
|
201
|
+
" SELECT id",
|
|
202
|
+
" FROM jobs",
|
|
203
|
+
" WHERE status = :pendingStatus",
|
|
204
|
+
" AND runAt <= CURRENT_TIMESTAMP",
|
|
205
|
+
" ORDER BY priority DESC, runAt ASC",
|
|
206
|
+
" LIMIT :limit",
|
|
207
|
+
")",
|
|
208
|
+
"RETURNING *",
|
|
209
|
+
),
|
|
210
|
+
args: {
|
|
211
|
+
limit,
|
|
212
|
+
pendingStatus: JobStatus.Pending,
|
|
213
|
+
runningStatus: JobStatus.Running,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
for (const row of rows) {
|
|
218
|
+
this.activeJobs++;
|
|
219
|
+
this.processJob(row).finally(() => {
|
|
220
|
+
this.activeJobs--;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error(err);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
setTimeout(loop, this.processEvery);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
loop();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async processJob(row: Row) {
|
|
235
|
+
const id = Number(row["id"]);
|
|
236
|
+
const name = String(row["name"]);
|
|
237
|
+
const attempts = Number(row["attempts"]);
|
|
238
|
+
|
|
239
|
+
const cron = row["cron"] ? String(row["cron"]) : null;
|
|
240
|
+
const data = row["data"] ? Jsonify(String(row["data"])) : null;
|
|
241
|
+
|
|
242
|
+
const job = this.jobsRegistry[name];
|
|
243
|
+
if (!job) return;
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await Promise.resolve(job.fn(data));
|
|
247
|
+
|
|
248
|
+
this.db.write({
|
|
249
|
+
sql: queryString("UPDATE " + this.jobsTable, "SET status = :status, updatedAt = CURRENT_TIMESTAMP", "WHERE id = :id"),
|
|
250
|
+
args: { id, status: JobStatus.Completed },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// If this is a cronjob, schedule the next run
|
|
254
|
+
if (cron) {
|
|
255
|
+
const interval = parser.parse(cron);
|
|
256
|
+
const nextRun = interval.next().toDate();
|
|
257
|
+
|
|
258
|
+
this.db.write({
|
|
259
|
+
sql: queryString(
|
|
260
|
+
"INSERT OR IGNORE INTO " + this.jobsTable,
|
|
261
|
+
"(name, data, runAt, priority, cron)",
|
|
262
|
+
"VALUES (:name, :data, :runAt, :priority, :cron)",
|
|
263
|
+
),
|
|
264
|
+
args: {
|
|
265
|
+
cron,
|
|
266
|
+
name: job.name,
|
|
267
|
+
priority: job.options.priority ?? 0,
|
|
268
|
+
data: data ? JSON.stringify(data) : null,
|
|
269
|
+
runAt: toSqliteDateString(nextRun),
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
const updatedStatus = attempts >= this.maxRetries ? JobStatus.Failed : JobStatus.Pending;
|
|
275
|
+
|
|
276
|
+
this.db.write({
|
|
277
|
+
sql: queryString("UPDATE " + this.jobsTable, "SET status = :status, updatedAt = CURRENT_TIMESTAMP", "WHERE id = :id"),
|
|
278
|
+
args: { id, status: updatedStatus },
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (updatedStatus === JobStatus.Failed && job.onFailure) {
|
|
282
|
+
await Promise.resolve(job.onFailure(err));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
|
|
3
|
+
import type { MigrationFile } from "..";
|
|
4
|
+
import type { CliCommandOptions } from "../../types";
|
|
5
|
+
|
|
6
|
+
import { createClient } from "../../client";
|
|
7
|
+
import { createMigrationTableIfNotExists, logError, logSuccess, validateMigrationDirectory } from "../util";
|
|
8
|
+
|
|
9
|
+
export default async function Down({ migrationTable, migrationPath, ...config }: CliCommandOptions) {
|
|
10
|
+
const db = createClient(config);
|
|
11
|
+
|
|
12
|
+
await validateMigrationDirectory(migrationPath);
|
|
13
|
+
createMigrationTableIfNotExists(db, migrationTable);
|
|
14
|
+
|
|
15
|
+
// Query the last migration that was ran
|
|
16
|
+
const result = db.readOne("SELECT id, filepath FROM " + migrationTable + " ORDER BY timestamp DESC LIMIT 1");
|
|
17
|
+
|
|
18
|
+
if (!result) {
|
|
19
|
+
logError("No migrations have been ran");
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const id = String(result["id"]);
|
|
24
|
+
const filepath = String(result["filepath"]);
|
|
25
|
+
|
|
26
|
+
const files = await fs.readdir(migrationPath);
|
|
27
|
+
const matchingFile = files.filter((file) => {
|
|
28
|
+
const fileName = file.split(".")[0];
|
|
29
|
+
return fileName === filepath;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!matchingFile.length) {
|
|
33
|
+
logError(`Unable to find migration ${filepath}`);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const migration: MigrationFile = (await import(`${migrationPath}/${matchingFile[0]}`)).default;
|
|
38
|
+
|
|
39
|
+
// Run the migration
|
|
40
|
+
db.transaction(() => migration.down(db));
|
|
41
|
+
|
|
42
|
+
// Store the migration data
|
|
43
|
+
db.write({
|
|
44
|
+
sql: "DELETE FROM " + migrationTable + " WHERE id = :id",
|
|
45
|
+
args: { id },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
logSuccess(`Reverted migration ${filepath}`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
2
|
+
|
|
3
|
+
import { CliCommandOptions } from "../../types";
|
|
4
|
+
import { logError, logSuccess } from "../util";
|
|
5
|
+
|
|
6
|
+
interface NewCommandOptions extends CliCommandOptions {
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default async function New({ name, migrationPath }: NewCommandOptions) {
|
|
11
|
+
const epochTime = Date.now();
|
|
12
|
+
const fileName = `${epochTime}_${name}.ts`;
|
|
13
|
+
|
|
14
|
+
const fileContents = `
|
|
15
|
+
import type { NeosqliteClient } from 'neosqlite';
|
|
16
|
+
|
|
17
|
+
export default class Migration_${epochTime}_${name} {
|
|
18
|
+
public static async up(db: NeosqliteClient) {
|
|
19
|
+
// Add your migration logic here
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public static async down(db: NeosqliteClient) {
|
|
23
|
+
// Add your rollback logic here
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
await mkdir(migrationPath, { recursive: true }).catch((e) => logError(e.message));
|
|
29
|
+
await writeFile(`${migrationPath}/${fileName}`, fileContents).catch((e) => logError(e.message));
|
|
30
|
+
|
|
31
|
+
logSuccess(`Created migration ${fileName}`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|