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.
@@ -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
+ }