@mark1russell7/docker-sqlite 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Connection utilities for SQLite using sql.js
3
+ */
4
+
5
+ import initSqlJs, { type Database } from "sql.js";
6
+ import { randomUUID } from "node:crypto";
7
+ import { readFile, mkdir, open, rename, unlink } from "node:fs/promises";
8
+ import { dirname, resolve } from "node:path";
9
+ import type { ConnectionConfig, ConnectionCallback } from "./types.js";
10
+
11
+ let sqlPromise: Promise<Awaited<ReturnType<typeof initSqlJs>>> | null = null;
12
+
13
+ /**
14
+ * Initialize sql.js (cached)
15
+ */
16
+ async function getSql(): Promise<Awaited<ReturnType<typeof initSqlJs>>> {
17
+ if (!sqlPromise) {
18
+ sqlPromise = initSqlJs();
19
+ }
20
+ return sqlPromise;
21
+ }
22
+
23
+ /** A database and the bytes of its file when it was loaded (null: there was no file). */
24
+ interface LoadedDatabase {
25
+ db: Database;
26
+ original: Uint8Array | null;
27
+ }
28
+
29
+ /**
30
+ * Load a database from a file path or create a new one
31
+ */
32
+ async function loadDatabase(dbPath: string): Promise<LoadedDatabase> {
33
+ const SQL = await getSql();
34
+
35
+ if (dbPath === ":memory:") {
36
+ return { db: new SQL.Database(), original: null };
37
+ }
38
+
39
+ try {
40
+ const buffer = await readFile(dbPath);
41
+ // sql.js keeps the array that it gets and writes into it: give it a copy, so `original`
42
+ // keeps the bytes of the file
43
+ return { db: new SQL.Database(new Uint8Array(buffer)), original: buffer };
44
+ } catch (error) {
45
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
46
+ // File doesn't exist, create new database
47
+ return { db: new SQL.Database(), original: null };
48
+ }
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Save a database to a file path, when its content changed.
55
+ *
56
+ * A database that did not change is not written. Before, each call wrote the file, also a
57
+ * read: a read failed on a read-only file (EPERM), a read wrote an old copy over the rows of
58
+ * another process, and a query of a missing file made an empty file. The export of a
59
+ * database that did not change has the bytes of its file, and an empty new database exports
60
+ * no bytes.
61
+ *
62
+ * Writes a temporary file, flushes it to the disk and renames it over the database, so a crash
63
+ * or a power loss during the write cannot leave a truncated database (the rename replaces the
64
+ * file in one step).
65
+ */
66
+ async function saveDatabase(db: Database, dbPath: string, original: Uint8Array | null): Promise<Uint8Array | null> {
67
+ if (dbPath === ":memory:") {
68
+ return null;
69
+ }
70
+
71
+ const data = db.export();
72
+ const unchanged = original
73
+ ? Buffer.compare(Buffer.from(data), Buffer.from(original)) === 0
74
+ : data.length === 0;
75
+ if (unchanged) {
76
+ return original;
77
+ }
78
+ const buffer = Buffer.from(data);
79
+
80
+ // Ensure directory exists
81
+ await mkdir(dirname(dbPath), { recursive: true });
82
+
83
+ // A random part, so two saves at once never share a temporary file. Before, the name had only
84
+ // the process id and the time in milliseconds.
85
+ const tempPath = `${dbPath}.${process.pid}.${randomUUID()}.tmp`;
86
+ try {
87
+ await writeDurably(tempPath, buffer);
88
+ } catch (error) {
89
+ await unlink(tempPath).catch(() => {});
90
+ throw error;
91
+ }
92
+ try {
93
+ await rename(tempPath, dbPath);
94
+ } catch (error) {
95
+ await unlink(tempPath).catch(() => {});
96
+ // The file is held open on Windows: fall back to a direct write. Before, each error of the
97
+ // rename started the direct write, which is not atomic.
98
+ if (!FILE_IN_USE.has((error as NodeJS.ErrnoException).code ?? "")) {
99
+ throw error;
100
+ }
101
+ await writeDurably(dbPath, buffer);
102
+ }
103
+ await syncDirectory(dirname(dbPath));
104
+ return data;
105
+ }
106
+
107
+ /** The error codes of a rename over a file that another process holds open (Windows). */
108
+ const FILE_IN_USE = new Set(["EPERM", "EACCES", "EBUSY"]);
109
+
110
+ /**
111
+ * Write a file and flush it to the disk before the rename. Without the flush, a power loss soon
112
+ * after the rename can leave an empty or partial database on some file systems.
113
+ */
114
+ async function writeDurably(path: string, data: Uint8Array): Promise<void> {
115
+ const file = await open(path, "w");
116
+ try {
117
+ await file.writeFile(data);
118
+ await file.sync();
119
+ } finally {
120
+ await file.close();
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Flush a directory, so that a rename in it stays after a power loss. Windows cannot open a
126
+ * directory for this, and some file systems refuse it: the flush is only an extra safety.
127
+ */
128
+ async function syncDirectory(path: string): Promise<void> {
129
+ if (process.platform === "win32") {
130
+ return;
131
+ }
132
+ try {
133
+ const directory = await open(path, "r");
134
+ try {
135
+ await directory.sync();
136
+ } finally {
137
+ await directory.close();
138
+ }
139
+ } catch {
140
+ // The rename is done
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The calls that wait for each database file. Each call reads the whole file, changes it in
146
+ * memory and writes it back, so two calls at once lost the changes of one of them
147
+ * (50 concurrent inserts kept 1 row). Calls for the same file now run one at a time in this
148
+ * process. (Other processes that write the same file are not coordinated.)
149
+ */
150
+ const fileQueues = new Map<string, Promise<unknown>>();
151
+
152
+ /**
153
+ * The queue key of a file. Windows file names do not depend on case, so the key is in lower
154
+ * case there. Before, two spellings of one file had two queues, and their calls ran at once.
155
+ */
156
+ export function fileQueueKey(dbPath: string, platform: NodeJS.Platform = process.platform): string {
157
+ const path = resolve(dbPath);
158
+ return platform === "win32" ? path.toLowerCase() : path;
159
+ }
160
+
161
+ async function inFileQueue<T>(dbPath: string, task: () => Promise<T>): Promise<T> {
162
+ if (dbPath === ":memory:") {
163
+ return task();
164
+ }
165
+ const key = fileQueueKey(dbPath);
166
+ const previous = fileQueues.get(key) ?? Promise.resolve();
167
+ const run = previous.then(task, task);
168
+ const tail = run.then(
169
+ () => undefined,
170
+ () => undefined
171
+ );
172
+ fileQueues.set(key, tail);
173
+ try {
174
+ return await run;
175
+ } finally {
176
+ if (fileQueues.get(key) === tail) {
177
+ fileQueues.delete(key);
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Execute a callback with a SQLite connection, ensuring proper cleanup and persistence.
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * const result = await withConnection(
188
+ * { dbPath: './data/app.db' },
189
+ * async (db) => {
190
+ * const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
191
+ * stmt.bind([1]);
192
+ * const rows = [];
193
+ * while (stmt.step()) {
194
+ * rows.push(stmt.getAsObject());
195
+ * }
196
+ * stmt.free();
197
+ * return rows;
198
+ * }
199
+ * );
200
+ * ```
201
+ */
202
+ export async function withConnection<T>(
203
+ config: ConnectionConfig,
204
+ fn: ConnectionCallback<T>
205
+ ): Promise<T> {
206
+ return inFileQueue(config.dbPath, async () => {
207
+ const { db, original } = await loadDatabase(config.dbPath);
208
+
209
+ try {
210
+ const result = await fn(db);
211
+ // Save changes back to file (only when there are changes)
212
+ await saveDatabase(db, config.dbPath, original);
213
+ return result;
214
+ } finally {
215
+ db.close();
216
+ }
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Create a SQLite database that can be manually managed.
222
+ * Caller is responsible for calling db.close() and saving if needed.
223
+ *
224
+ * @example
225
+ * ```typescript
226
+ * const { db, save } = await createConnection({ dbPath: './data/app.db' });
227
+ * try {
228
+ * db.run('INSERT INTO users (name) VALUES (?)', ['Alice']);
229
+ * await save();
230
+ * } finally {
231
+ * db.close();
232
+ * }
233
+ * ```
234
+ */
235
+ export async function createConnection(
236
+ config: ConnectionConfig
237
+ ): Promise<{ db: Database; save: () => Promise<void> }> {
238
+ const { db, original } = await loadDatabase(config.dbPath);
239
+ // The bytes of the file after the last save, to compare the next save with
240
+ let saved = original;
241
+ const save = async (): Promise<void> => {
242
+ saved = await saveDatabase(db, config.dbPath, saved);
243
+ };
244
+ return { db, save };
245
+ }
package/src/index.ts ADDED
@@ -0,0 +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";
package/src/migrate.ts ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Migration utilities for SQLite
3
+ */
4
+
5
+ import type { Database } from "sql.js";
6
+ import type { Migration, MigrationStatus } from "./types.js";
7
+ import { query, execute, execMultiple } from "./query.js";
8
+
9
+ /**
10
+ * Schema for tracking migrations
11
+ */
12
+ const MIGRATIONS_TABLE_SQL = `
13
+ CREATE TABLE IF NOT EXISTS _migrations (
14
+ version INTEGER PRIMARY KEY,
15
+ description TEXT NOT NULL,
16
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
17
+ )
18
+ `;
19
+
20
+ /**
21
+ * Ensure the migrations table exists
22
+ */
23
+ function ensureMigrationsTable(db: Database): void {
24
+ execMultiple(db, MIGRATIONS_TABLE_SQL);
25
+ }
26
+
27
+ /** The name of the savepoint that holds one migration and its record. */
28
+ const SAVEPOINT = "docker_sqlite_migration";
29
+
30
+ /**
31
+ * The first words of the statements that control transactions, cannot run in a transaction
32
+ * (VACUUM, ATTACH) or can do nothing there (PRAGMA foreign_keys).
33
+ */
34
+ const OUTSIDE_A_TRANSACTION = new Set([
35
+ "BEGIN",
36
+ "COMMIT",
37
+ "END",
38
+ "ROLLBACK",
39
+ "SAVEPOINT",
40
+ "RELEASE",
41
+ "VACUUM",
42
+ "ATTACH",
43
+ "DETACH",
44
+ "PRAGMA",
45
+ ]);
46
+
47
+ /**
48
+ * The tokens of SQL text: the words in upper case, ";" for each semicolon, and "?" for each
49
+ * other token. Comments are skipped. A string or a quoted name is one "?" token, so a word in it
50
+ * is not a keyword.
51
+ */
52
+ function* tokens(sql: string): Generator<string> {
53
+ const word = /[\p{L}_][\p{L}\p{N}_$]*/uy;
54
+ let i = 0;
55
+ while (i < sql.length) {
56
+ const c = sql[i]!;
57
+ if (/\s/.test(c)) {
58
+ i++;
59
+ } else if (c === "-" && sql[i + 1] === "-") {
60
+ const end = sql.indexOf("\n", i);
61
+ i = end < 0 ? sql.length : end + 1;
62
+ } else if (c === "/" && sql[i + 1] === "*") {
63
+ const end = sql.indexOf("*/", i + 2);
64
+ i = end < 0 ? sql.length : end + 2;
65
+ } else if (c === "'" || c === '"' || c === "`" || c === "[") {
66
+ // A quote in the text is two quotes. A name in brackets cannot hold "]".
67
+ const close = c === "[" ? "]" : c;
68
+ let end = sql.indexOf(close, i + 1);
69
+ while (end >= 0 && close !== "]" && sql[end + 1] === close) {
70
+ end = sql.indexOf(close, end + 2);
71
+ }
72
+ i = end < 0 ? sql.length : end + 1;
73
+ yield "?";
74
+ } else if (c === ";") {
75
+ i++;
76
+ yield ";";
77
+ } else {
78
+ word.lastIndex = i;
79
+ const match = word.exec(sql);
80
+ if (match) {
81
+ i += match[0].length;
82
+ yield match[0].toUpperCase();
83
+ } else {
84
+ i++;
85
+ yield "?";
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The first word of each statement of `sql`, in upper case. The statements in the body of a
93
+ * trigger are part of the trigger: like sqlite3_complete(), the trigger ends at "; END ;".
94
+ */
95
+ function statementKeywords(sql: string): string[] {
96
+ const keywords: string[] = [];
97
+ let words: string[] = [];
98
+ let trigger = false;
99
+ let afterSemicolon = false;
100
+ let afterEnd = false;
101
+ for (const token of tokens(sql)) {
102
+ if (token === ";") {
103
+ if (!trigger || afterEnd) {
104
+ words = [];
105
+ trigger = false;
106
+ afterEnd = false;
107
+ } else {
108
+ afterSemicolon = true;
109
+ }
110
+ continue;
111
+ }
112
+ if (words.length === 0) {
113
+ keywords.push(token);
114
+ }
115
+ if (words.length < 3) {
116
+ words.push(token);
117
+ const [first, second, third] = words;
118
+ trigger ||=
119
+ first === "CREATE" &&
120
+ (second === "TRIGGER" || ((second === "TEMP" || second === "TEMPORARY") && third === "TRIGGER"));
121
+ }
122
+ afterEnd = trigger && afterSemicolon && token === "END";
123
+ afterSemicolon = false;
124
+ }
125
+ return keywords;
126
+ }
127
+
128
+ /**
129
+ * Run `work` in a savepoint: its changes stay only when it succeeds. Before, a migration that
130
+ * failed after its first statement kept the changes of those statements, and was not recorded:
131
+ * the next run ran it again and failed ("table already exists").
132
+ *
133
+ * A savepoint also works in a transaction of the caller, and keeps that transaction open. SQL
134
+ * that controls transactions itself (BEGIN, COMMIT, VACUUM, PRAGMA and the others in
135
+ * OUTSIDE_A_TRANSACTION) runs without a savepoint, as before: in a transaction it fails or does
136
+ * nothing.
137
+ */
138
+ function atomically(db: Database, sql: string, work: () => void): void {
139
+ if (statementKeywords(sql).some((keyword) => OUTSIDE_A_TRANSACTION.has(keyword))) {
140
+ work();
141
+ return;
142
+ }
143
+ db.exec(`SAVEPOINT ${SAVEPOINT}`);
144
+ try {
145
+ work();
146
+ db.exec(`RELEASE ${SAVEPOINT}`);
147
+ } catch (error) {
148
+ try {
149
+ db.exec(`ROLLBACK TO ${SAVEPOINT}`);
150
+ db.exec(`RELEASE ${SAVEPOINT}`);
151
+ } catch {
152
+ // Some errors end the transaction (ON CONFLICT ROLLBACK): then the savepoint is gone
153
+ }
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Get the current migration status
160
+ */
161
+ export function getMigrationStatus(
162
+ db: Database,
163
+ migrations: Migration[]
164
+ ): MigrationStatus {
165
+ ensureMigrationsTable(db);
166
+
167
+ const applied = query<{ version: number }>(
168
+ db,
169
+ "SELECT version FROM _migrations ORDER BY version"
170
+ );
171
+
172
+ const appliedVersions = new Set(applied.rows.map((r) => r.version));
173
+ const appliedMigrations = [...appliedVersions].sort((a, b) => a - b);
174
+ const currentVersion =
175
+ appliedMigrations.length > 0
176
+ ? appliedMigrations[appliedMigrations.length - 1]!
177
+ : 0;
178
+
179
+ const pendingMigrations = migrations
180
+ .filter((m) => !appliedVersions.has(m.version))
181
+ .sort((a, b) => a.version - b.version);
182
+
183
+ return {
184
+ currentVersion,
185
+ appliedMigrations,
186
+ pendingMigrations,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Run all pending migrations
192
+ *
193
+ * @example
194
+ * ```typescript
195
+ * const migrations: Migration[] = [
196
+ * {
197
+ * version: 1,
198
+ * description: 'Create users table',
199
+ * up: 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)',
200
+ * down: 'DROP TABLE users'
201
+ * },
202
+ * {
203
+ * version: 2,
204
+ * description: 'Add email column',
205
+ * up: 'ALTER TABLE users ADD COLUMN email TEXT',
206
+ * }
207
+ * ];
208
+ *
209
+ * const applied = runMigrations(db, migrations);
210
+ * console.log(`Applied ${applied.length} migrations`);
211
+ * ```
212
+ */
213
+ export function runMigrations(db: Database, migrations: Migration[]): Migration[] {
214
+ const status = getMigrationStatus(db, migrations);
215
+ const applied: Migration[] = [];
216
+
217
+ for (const migration of status.pendingMigrations) {
218
+ try {
219
+ // Run the migration and record it as applied, in one savepoint
220
+ atomically(db, migration.up, () => {
221
+ execMultiple(db, migration.up);
222
+ execute(
223
+ db,
224
+ "INSERT INTO _migrations (version, description) VALUES (?, ?)",
225
+ [migration.version, migration.description]
226
+ );
227
+ });
228
+
229
+ applied.push(migration);
230
+ } catch (error) {
231
+ throw new Error(
232
+ `Failed to apply migration ${migration.version} (${migration.description}): ${error instanceof Error ? error.message : String(error)}`
233
+ );
234
+ }
235
+ }
236
+
237
+ return applied;
238
+ }
239
+
240
+ /**
241
+ * Rollback the last applied migration
242
+ */
243
+ export function rollbackMigration(
244
+ db: Database,
245
+ migrations: Migration[]
246
+ ): Migration | null {
247
+ const status = getMigrationStatus(db, migrations);
248
+
249
+ if (status.currentVersion === 0) {
250
+ return null;
251
+ }
252
+
253
+ const migration = migrations.find((m) => m.version === status.currentVersion);
254
+
255
+ if (!migration) {
256
+ throw new Error(`Migration ${status.currentVersion} not found in migrations list`);
257
+ }
258
+
259
+ if (!migration.down) {
260
+ throw new Error(
261
+ `Migration ${migration.version} (${migration.description}) has no down migration`
262
+ );
263
+ }
264
+
265
+ const down = migration.down;
266
+ try {
267
+ atomically(db, down, () => {
268
+ execMultiple(db, down);
269
+ execute(db, "DELETE FROM _migrations WHERE version = ?", [migration.version]);
270
+ });
271
+ return migration;
272
+ } catch (error) {
273
+ throw new Error(
274
+ `Failed to rollback migration ${migration.version}: ${error instanceof Error ? error.message : String(error)}`
275
+ );
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Create a migration from SQL strings
281
+ */
282
+ export function createMigration(
283
+ version: number,
284
+ description: string,
285
+ up: string,
286
+ down?: string
287
+ ): Migration {
288
+ const migration: Migration = { version, description, up };
289
+ if (down !== undefined) {
290
+ migration.down = down;
291
+ }
292
+ return migration;
293
+ }