@mark1russell7/docker-sqlite 0.1.0 → 0.2.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/src/connect.ts CHANGED
@@ -1,194 +1,317 @@
1
- /**
2
- * Connection utilities for SQLite using sql.js
3
- */
4
-
5
- import initSqlJs, { type Database } from "sql.js";
6
- import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
7
- import { dirname, resolve } from "node:path";
8
- import type { ConnectionConfig, ConnectionCallback } from "./types.js";
9
-
10
- let sqlPromise: Promise<Awaited<ReturnType<typeof initSqlJs>>> | null = null;
11
-
12
- /**
13
- * Initialize sql.js (cached)
14
- */
15
- async function getSql(): Promise<Awaited<ReturnType<typeof initSqlJs>>> {
16
- if (!sqlPromise) {
17
- sqlPromise = initSqlJs();
18
- }
19
- return sqlPromise;
20
- }
21
-
22
- /** A database and the bytes of its file when it was loaded (null: there was no file). */
23
- interface LoadedDatabase {
24
- db: Database;
25
- original: Uint8Array | null;
26
- }
27
-
28
- /**
29
- * Load a database from a file path or create a new one
30
- */
31
- async function loadDatabase(dbPath: string): Promise<LoadedDatabase> {
32
- const SQL = await getSql();
33
-
34
- if (dbPath === ":memory:") {
35
- return { db: new SQL.Database(), original: null };
36
- }
37
-
38
- try {
39
- const buffer = await readFile(dbPath);
40
- // sql.js keeps the array that it gets and writes into it: give it a copy, so `original`
41
- // keeps the bytes of the file
42
- return { db: new SQL.Database(new Uint8Array(buffer)), original: buffer };
43
- } catch (error) {
44
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
45
- // File doesn't exist, create new database
46
- return { db: new SQL.Database(), original: null };
47
- }
48
- throw error;
49
- }
50
- }
51
-
52
- /**
53
- * Save a database to a file path, when its content changed.
54
- *
55
- * A database that did not change is not written. Before, each call wrote the file, also a
56
- * read: a read failed on a read-only file (EPERM), a read wrote an old copy over the rows of
57
- * another process, and a query of a missing file made an empty file. The export of a
58
- * database that did not change has the bytes of its file, and an empty new database exports
59
- * no bytes.
60
- *
61
- * Writes a temporary file and renames it over the database, so a crash during the write
62
- * cannot leave a truncated database (the rename replaces the file in one step).
63
- */
64
- async function saveDatabase(db: Database, dbPath: string, original: Uint8Array | null): Promise<Uint8Array | null> {
65
- if (dbPath === ":memory:") {
66
- return null;
67
- }
68
-
69
- const data = db.export();
70
- const unchanged = original
71
- ? Buffer.compare(Buffer.from(data), Buffer.from(original)) === 0
72
- : data.length === 0;
73
- if (unchanged) {
74
- return original;
75
- }
76
- const buffer = Buffer.from(data);
77
-
78
- // Ensure directory exists
79
- await mkdir(dirname(dbPath), { recursive: true });
80
-
81
- const tempPath = `${dbPath}.${process.pid}.${Date.now()}.tmp`;
82
- await writeFile(tempPath, buffer);
83
- try {
84
- await rename(tempPath, dbPath);
85
- } catch {
86
- // For example, the file is held open on Windows: fall back to a direct write
87
- await unlink(tempPath).catch(() => {});
88
- await writeFile(dbPath, buffer);
89
- }
90
- return data;
91
- }
92
-
93
- /**
94
- * The calls that wait for each database file. Each call reads the whole file, changes it in
95
- * memory and writes it back, so two calls at once lost the changes of one of them
96
- * (50 concurrent inserts kept 1 row). Calls for the same file now run one at a time in this
97
- * process. (Other processes that write the same file are not coordinated.)
98
- */
99
- const fileQueues = new Map<string, Promise<unknown>>();
100
-
101
- /**
102
- * The queue key of a file. Windows file names do not depend on case, so the key is in lower
103
- * case there. Before, two spellings of one file had two queues, and their calls ran at once.
104
- */
105
- export function fileQueueKey(dbPath: string, platform: NodeJS.Platform = process.platform): string {
106
- const path = resolve(dbPath);
107
- return platform === "win32" ? path.toLowerCase() : path;
108
- }
109
-
110
- async function inFileQueue<T>(dbPath: string, task: () => Promise<T>): Promise<T> {
111
- if (dbPath === ":memory:") {
112
- return task();
113
- }
114
- const key = fileQueueKey(dbPath);
115
- const previous = fileQueues.get(key) ?? Promise.resolve();
116
- const run = previous.then(task, task);
117
- const tail = run.then(
118
- () => undefined,
119
- () => undefined
120
- );
121
- fileQueues.set(key, tail);
122
- try {
123
- return await run;
124
- } finally {
125
- if (fileQueues.get(key) === tail) {
126
- fileQueues.delete(key);
127
- }
128
- }
129
- }
130
-
131
- /**
132
- * Execute a callback with a SQLite connection, ensuring proper cleanup and persistence.
133
- *
134
- * @example
135
- * ```typescript
136
- * const result = await withConnection(
137
- * { dbPath: './data/app.db' },
138
- * async (db) => {
139
- * const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
140
- * stmt.bind([1]);
141
- * const rows = [];
142
- * while (stmt.step()) {
143
- * rows.push(stmt.getAsObject());
144
- * }
145
- * stmt.free();
146
- * return rows;
147
- * }
148
- * );
149
- * ```
150
- */
151
- export async function withConnection<T>(
152
- config: ConnectionConfig,
153
- fn: ConnectionCallback<T>
154
- ): Promise<T> {
155
- return inFileQueue(config.dbPath, async () => {
156
- const { db, original } = await loadDatabase(config.dbPath);
157
-
158
- try {
159
- const result = await fn(db);
160
- // Save changes back to file (only when there are changes)
161
- await saveDatabase(db, config.dbPath, original);
162
- return result;
163
- } finally {
164
- db.close();
165
- }
166
- });
167
- }
168
-
169
- /**
170
- * Create a SQLite database that can be manually managed.
171
- * Caller is responsible for calling db.close() and saving if needed.
172
- *
173
- * @example
174
- * ```typescript
175
- * const { db, save } = await createConnection({ dbPath: './data/app.db' });
176
- * try {
177
- * db.run('INSERT INTO users (name) VALUES (?)', ['Alice']);
178
- * await save();
179
- * } finally {
180
- * db.close();
181
- * }
182
- * ```
183
- */
184
- export async function createConnection(
185
- config: ConnectionConfig
186
- ): Promise<{ db: Database; save: () => Promise<void> }> {
187
- const { db, original } = await loadDatabase(config.dbPath);
188
- // The bytes of the file after the last save, to compare the next save with
189
- let saved = original;
190
- const save = async (): Promise<void> => {
191
- saved = await saveDatabase(db, config.dbPath, saved);
192
- };
193
- return { db, save };
194
- }
1
+ /**
2
+ * Connection utilities for SQLite using sql.js
3
+ */
4
+
5
+ import initSqlJs, { type Database } from "sql.js";
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ import { randomUUID } from "node:crypto";
8
+ import { readFile, mkdir, open, rename, unlink } from "node:fs/promises";
9
+ import { dirname, resolve } from "node:path";
10
+ import type { ConnectionConfig, ConnectionCallback } from "./types.js";
11
+
12
+ let sqlPromise: Promise<Awaited<ReturnType<typeof initSqlJs>>> | null = null;
13
+
14
+ /**
15
+ * Initialize sql.js (cached)
16
+ */
17
+ async function getSql(): Promise<Awaited<ReturnType<typeof initSqlJs>>> {
18
+ if (!sqlPromise) {
19
+ sqlPromise = initSqlJs();
20
+ }
21
+ return sqlPromise;
22
+ }
23
+
24
+ /** A database and the bytes of its file when it was loaded (null: there was no file). */
25
+ interface LoadedDatabase {
26
+ db: Database;
27
+ original: Uint8Array | null;
28
+ }
29
+
30
+ /**
31
+ * Load a database from a file path or create a new one
32
+ */
33
+ async function loadDatabase(dbPath: string): Promise<LoadedDatabase> {
34
+ const SQL = await getSql();
35
+
36
+ if (dbPath === ":memory:") {
37
+ return { db: new SQL.Database(), original: null };
38
+ }
39
+
40
+ try {
41
+ const buffer = await readFile(dbPath);
42
+ // sql.js keeps the array that it gets and writes into it: give it a copy, so `original`
43
+ // keeps the bytes of the file
44
+ return { db: new SQL.Database(new Uint8Array(buffer)), original: buffer };
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
47
+ // File doesn't exist, create new database
48
+ return { db: new SQL.Database(), original: null };
49
+ }
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Save a database to a file path, when its content changed.
56
+ *
57
+ * A database that did not change is not written. Before, each call wrote the file, also a
58
+ * read: a read failed on a read-only file (EPERM), a read wrote an old copy over the rows of
59
+ * another process, and a query of a missing file made an empty file. The export of a
60
+ * database that did not change has the bytes of its file, and an empty new database exports
61
+ * no bytes.
62
+ *
63
+ * Writes a temporary file, flushes it to the disk and renames it over the database, so a crash
64
+ * or a power loss during the write cannot leave a truncated database (the rename replaces the
65
+ * file in one step).
66
+ */
67
+ /**
68
+ * The error of the `save` of `createConnection` when the file changed after the connection
69
+ * loaded it or saved it last. Another connection or process wrote the file. The save does not
70
+ * write: it would replace those changes with the old copy of this connection.
71
+ */
72
+ export class DatabaseFileChangedError extends Error {
73
+ /** A stable code for checks: `error.code === "DATABASE_FILE_CHANGED"`. */
74
+ readonly code = "DATABASE_FILE_CHANGED";
75
+
76
+ constructor(readonly dbPath: string) {
77
+ super(
78
+ `The database file ${dbPath} changed after this connection loaded it: another connection or process wrote it. ` +
79
+ "The save did not write, because it would replace those changes. Open a new connection and make the change again."
80
+ );
81
+ this.name = "DatabaseFileChangedError";
82
+ }
83
+ }
84
+
85
+ /** The bytes of a file, or null when there is no file. */
86
+ async function readIfExists(path: string): Promise<Uint8Array | null> {
87
+ try {
88
+ return await readFile(path);
89
+ } catch (error) {
90
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
91
+ return null;
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ function sameBytes(a: Uint8Array | null, b: Uint8Array | null): boolean {
98
+ return a === null || b === null ? a === b : Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
99
+ }
100
+
101
+ async function saveDatabase(
102
+ db: Database,
103
+ dbPath: string,
104
+ original: Uint8Array | null,
105
+ options: { refuseChangedFile?: boolean } = {}
106
+ ): Promise<Uint8Array | null> {
107
+ if (dbPath === ":memory:") {
108
+ return null;
109
+ }
110
+
111
+ const data = db.export();
112
+ const unchanged = original
113
+ ? Buffer.compare(Buffer.from(data), Buffer.from(original)) === 0
114
+ : data.length === 0;
115
+ if (unchanged) {
116
+ return original;
117
+ }
118
+ // The file must still have the bytes that this database came from. Else another connection
119
+ // or process wrote it, and this save would replace its changes.
120
+ if (options.refuseChangedFile && !sameBytes(await readIfExists(dbPath), original)) {
121
+ throw new DatabaseFileChangedError(dbPath);
122
+ }
123
+ const buffer = Buffer.from(data);
124
+
125
+ // Ensure directory exists
126
+ await mkdir(dirname(dbPath), { recursive: true });
127
+
128
+ // A random part, so two saves at once never share a temporary file. Before, the name had only
129
+ // the process id and the time in milliseconds.
130
+ const tempPath = `${dbPath}.${process.pid}.${randomUUID()}.tmp`;
131
+ try {
132
+ await writeDurably(tempPath, buffer);
133
+ } catch (error) {
134
+ await unlink(tempPath).catch(() => {});
135
+ throw error;
136
+ }
137
+ try {
138
+ await rename(tempPath, dbPath);
139
+ } catch (error) {
140
+ await unlink(tempPath).catch(() => {});
141
+ // The file is held open on Windows: fall back to a direct write. Before, each error of the
142
+ // rename started the direct write, which is not atomic.
143
+ if (!FILE_IN_USE.has((error as NodeJS.ErrnoException).code ?? "")) {
144
+ throw error;
145
+ }
146
+ await writeDurably(dbPath, buffer);
147
+ }
148
+ await syncDirectory(dirname(dbPath));
149
+ return data;
150
+ }
151
+
152
+ /** The error codes of a rename over a file that another process holds open (Windows). */
153
+ const FILE_IN_USE = new Set(["EPERM", "EACCES", "EBUSY"]);
154
+
155
+ /**
156
+ * Write a file and flush it to the disk before the rename. Without the flush, a power loss soon
157
+ * after the rename can leave an empty or partial database on some file systems.
158
+ */
159
+ async function writeDurably(path: string, data: Uint8Array): Promise<void> {
160
+ const file = await open(path, "w");
161
+ try {
162
+ await file.writeFile(data);
163
+ await file.sync();
164
+ } finally {
165
+ await file.close();
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Flush a directory, so that a rename in it stays after a power loss. Windows cannot open a
171
+ * directory for this, and some file systems refuse it: the flush is only an extra safety.
172
+ */
173
+ async function syncDirectory(path: string): Promise<void> {
174
+ if (process.platform === "win32") {
175
+ return;
176
+ }
177
+ try {
178
+ const directory = await open(path, "r");
179
+ try {
180
+ await directory.sync();
181
+ } finally {
182
+ await directory.close();
183
+ }
184
+ } catch {
185
+ // The rename is done
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The calls that wait for each database file. Each call reads the whole file, changes it in
191
+ * memory and writes it back, so two calls at once lost the changes of one of them
192
+ * (50 concurrent inserts kept 1 row). Calls for the same file now run one at a time in this
193
+ * process. (Other processes that write the same file are not coordinated.)
194
+ */
195
+ const fileQueues = new Map<string, Promise<unknown>>();
196
+
197
+ /**
198
+ * The queue keys that the current call holds. A `save` of `createConnection` in a callback of
199
+ * `withConnection` for the same file runs at once: in the queue, it would wait for the
200
+ * callback, and the callback waits for it.
201
+ */
202
+ const heldQueues = new AsyncLocalStorage<ReadonlySet<string>>();
203
+
204
+ /**
205
+ * The queue key of a file. Windows file names do not depend on case, so the key is in lower
206
+ * case there. Before, two spellings of one file had two queues, and their calls ran at once.
207
+ */
208
+ export function fileQueueKey(dbPath: string, platform: NodeJS.Platform = process.platform): string {
209
+ const path = resolve(dbPath);
210
+ return platform === "win32" ? path.toLowerCase() : path;
211
+ }
212
+
213
+ async function inFileQueue<T>(
214
+ dbPath: string,
215
+ task: () => Promise<T>,
216
+ options: { reentrant?: boolean } = {}
217
+ ): Promise<T> {
218
+ if (dbPath === ":memory:") {
219
+ return task();
220
+ }
221
+ const key = fileQueueKey(dbPath);
222
+ const held = heldQueues.getStore();
223
+ if (options.reentrant && held?.has(key)) {
224
+ return task();
225
+ }
226
+ const holding = new Set(held).add(key);
227
+ const start = (): Promise<T> => heldQueues.run(holding, task);
228
+ const previous = fileQueues.get(key) ?? Promise.resolve();
229
+ const run = previous.then(start, start);
230
+ const tail = run.then(
231
+ () => undefined,
232
+ () => undefined
233
+ );
234
+ fileQueues.set(key, tail);
235
+ try {
236
+ return await run;
237
+ } finally {
238
+ if (fileQueues.get(key) === tail) {
239
+ fileQueues.delete(key);
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Execute a callback with a SQLite connection, ensuring proper cleanup and persistence.
246
+ *
247
+ * @example
248
+ * ```typescript
249
+ * const result = await withConnection(
250
+ * { dbPath: './data/app.db' },
251
+ * async (db) => {
252
+ * const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
253
+ * stmt.bind([1]);
254
+ * const rows = [];
255
+ * while (stmt.step()) {
256
+ * rows.push(stmt.getAsObject());
257
+ * }
258
+ * stmt.free();
259
+ * return rows;
260
+ * }
261
+ * );
262
+ * ```
263
+ */
264
+ export async function withConnection<T>(
265
+ config: ConnectionConfig,
266
+ fn: ConnectionCallback<T>
267
+ ): Promise<T> {
268
+ return inFileQueue(config.dbPath, async () => {
269
+ const { db, original } = await loadDatabase(config.dbPath);
270
+
271
+ try {
272
+ const result = await fn(db);
273
+ // Save changes back to file (only when there are changes)
274
+ await saveDatabase(db, config.dbPath, original);
275
+ return result;
276
+ } finally {
277
+ db.close();
278
+ }
279
+ });
280
+ }
281
+
282
+ /**
283
+ * Create a SQLite database that can be manually managed.
284
+ * Caller is responsible for calling db.close() and saving if needed.
285
+ *
286
+ * `save` runs in the queue of the file, one call at a time with `withConnection` and the other
287
+ * saves of this process. It throws a `DatabaseFileChangedError` and does not write when the
288
+ * file changed after this connection loaded it or saved it last. Before, it wrote its old copy
289
+ * over the changes of the other connection or process. After the error, open a new connection.
290
+ *
291
+ * @example
292
+ * ```typescript
293
+ * const { db, save } = await createConnection({ dbPath: './data/app.db' });
294
+ * try {
295
+ * db.run('INSERT INTO users (name) VALUES (?)', ['Alice']);
296
+ * await save();
297
+ * } finally {
298
+ * db.close();
299
+ * }
300
+ * ```
301
+ */
302
+ export async function createConnection(
303
+ config: ConnectionConfig
304
+ ): Promise<{ db: Database; save: () => Promise<void> }> {
305
+ const { db, original } = await loadDatabase(config.dbPath);
306
+ // The bytes of the file after the last save, to compare the next save with
307
+ let saved = original;
308
+ const save = (): Promise<void> =>
309
+ inFileQueue(
310
+ config.dbPath,
311
+ async () => {
312
+ saved = await saveDatabase(db, config.dbPath, saved, { refuseChangedFile: true });
313
+ },
314
+ { reentrant: true }
315
+ );
316
+ return { db, save };
317
+ }
package/src/index.ts CHANGED
@@ -1,61 +1,61 @@
1
- /**
2
- * docker-sqlite - Reusable SQLite Docker setup with TypeScript utilities
3
- *
4
- * @example
5
- * ```typescript
6
- * import { withConnection, query, execute, runMigrations } from 'docker-sqlite';
7
- *
8
- * // Define migrations
9
- * const migrations = [
10
- * {
11
- * version: 1,
12
- * description: 'Create users table',
13
- * up: 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)',
14
- * down: 'DROP TABLE users'
15
- * }
16
- * ];
17
- *
18
- * // Run with auto-save
19
- * await withConnection({ dbPath: './data/app.db' }, (db) => {
20
- * runMigrations(db, migrations);
21
- *
22
- * execute(db, 'INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']);
23
- *
24
- * const result = query(db, 'SELECT * FROM users');
25
- * console.log(result.rows);
26
- * });
27
- * ```
28
- */
29
-
30
- // Types
31
- export type {
32
- ConnectionConfig,
33
- ConnectionCallback,
34
- QueryResult,
35
- ExecuteResult,
36
- Migration,
37
- MigrationStatus,
38
- } from "./types.js";
39
-
40
- // Connection utilities
41
- export { withConnection, createConnection } from "./connect.js";
42
-
43
- // Query utilities
44
- export {
45
- query,
46
- execute,
47
- execMultiple,
48
- lastInsertRowId,
49
- tableExists,
50
- } from "./query.js";
51
-
52
- // Migration utilities
53
- export {
54
- getMigrationStatus,
55
- runMigrations,
56
- rollbackMigration,
57
- createMigration,
58
- } from "./migrate.js";
59
-
60
- // Re-export sql.js Database type for convenience
61
- export type { Database } from "sql.js";
1
+ /**
2
+ * docker-sqlite - Reusable SQLite Docker setup with TypeScript utilities
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { withConnection, query, execute, runMigrations } from 'docker-sqlite';
7
+ *
8
+ * // Define migrations
9
+ * const migrations = [
10
+ * {
11
+ * version: 1,
12
+ * description: 'Create users table',
13
+ * up: 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)',
14
+ * down: 'DROP TABLE users'
15
+ * }
16
+ * ];
17
+ *
18
+ * // Run with auto-save
19
+ * await withConnection({ dbPath: './data/app.db' }, (db) => {
20
+ * runMigrations(db, migrations);
21
+ *
22
+ * execute(db, 'INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']);
23
+ *
24
+ * const result = query(db, 'SELECT * FROM users');
25
+ * console.log(result.rows);
26
+ * });
27
+ * ```
28
+ */
29
+
30
+ // Types
31
+ export type {
32
+ ConnectionConfig,
33
+ ConnectionCallback,
34
+ QueryResult,
35
+ ExecuteResult,
36
+ Migration,
37
+ MigrationStatus,
38
+ } from "./types.js";
39
+
40
+ // Connection utilities
41
+ export { withConnection, createConnection, DatabaseFileChangedError } from "./connect.js";
42
+
43
+ // Query utilities
44
+ export {
45
+ query,
46
+ execute,
47
+ execMultiple,
48
+ lastInsertRowId,
49
+ tableExists,
50
+ } from "./query.js";
51
+
52
+ // Migration utilities
53
+ export {
54
+ getMigrationStatus,
55
+ runMigrations,
56
+ rollbackMigration,
57
+ createMigration,
58
+ } from "./migrate.js";
59
+
60
+ // Re-export sql.js Database type for convenience
61
+ export type { Database } from "sql.js";