@mark1russell7/docker-sqlite 0.1.0 → 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/migrate.ts CHANGED
@@ -1,159 +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
- /**
28
- * Get the current migration status
29
- */
30
- export function getMigrationStatus(
31
- db: Database,
32
- migrations: Migration[]
33
- ): MigrationStatus {
34
- ensureMigrationsTable(db);
35
-
36
- const applied = query<{ version: number }>(
37
- db,
38
- "SELECT version FROM _migrations ORDER BY version"
39
- );
40
-
41
- const appliedVersions = new Set(applied.rows.map((r) => r.version));
42
- const appliedMigrations = [...appliedVersions].sort((a, b) => a - b);
43
- const currentVersion =
44
- appliedMigrations.length > 0
45
- ? appliedMigrations[appliedMigrations.length - 1]!
46
- : 0;
47
-
48
- const pendingMigrations = migrations
49
- .filter((m) => !appliedVersions.has(m.version))
50
- .sort((a, b) => a.version - b.version);
51
-
52
- return {
53
- currentVersion,
54
- appliedMigrations,
55
- pendingMigrations,
56
- };
57
- }
58
-
59
- /**
60
- * Run all pending migrations
61
- *
62
- * @example
63
- * ```typescript
64
- * const migrations: Migration[] = [
65
- * {
66
- * version: 1,
67
- * description: 'Create users table',
68
- * up: 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)',
69
- * down: 'DROP TABLE users'
70
- * },
71
- * {
72
- * version: 2,
73
- * description: 'Add email column',
74
- * up: 'ALTER TABLE users ADD COLUMN email TEXT',
75
- * }
76
- * ];
77
- *
78
- * const applied = runMigrations(db, migrations);
79
- * console.log(`Applied ${applied.length} migrations`);
80
- * ```
81
- */
82
- export function runMigrations(db: Database, migrations: Migration[]): Migration[] {
83
- const status = getMigrationStatus(db, migrations);
84
- const applied: Migration[] = [];
85
-
86
- for (const migration of status.pendingMigrations) {
87
- try {
88
- // Run the migration
89
- execMultiple(db, migration.up);
90
-
91
- // Record it as applied
92
- execute(
93
- db,
94
- "INSERT INTO _migrations (version, description) VALUES (?, ?)",
95
- [migration.version, migration.description]
96
- );
97
-
98
- applied.push(migration);
99
- } catch (error) {
100
- throw new Error(
101
- `Failed to apply migration ${migration.version} (${migration.description}): ${error instanceof Error ? error.message : String(error)}`
102
- );
103
- }
104
- }
105
-
106
- return applied;
107
- }
108
-
109
- /**
110
- * Rollback the last applied migration
111
- */
112
- export function rollbackMigration(
113
- db: Database,
114
- migrations: Migration[]
115
- ): Migration | null {
116
- const status = getMigrationStatus(db, migrations);
117
-
118
- if (status.currentVersion === 0) {
119
- return null;
120
- }
121
-
122
- const migration = migrations.find((m) => m.version === status.currentVersion);
123
-
124
- if (!migration) {
125
- throw new Error(`Migration ${status.currentVersion} not found in migrations list`);
126
- }
127
-
128
- if (!migration.down) {
129
- throw new Error(
130
- `Migration ${migration.version} (${migration.description}) has no down migration`
131
- );
132
- }
133
-
134
- try {
135
- execMultiple(db, migration.down);
136
- execute(db, "DELETE FROM _migrations WHERE version = ?", [migration.version]);
137
- return migration;
138
- } catch (error) {
139
- throw new Error(
140
- `Failed to rollback migration ${migration.version}: ${error instanceof Error ? error.message : String(error)}`
141
- );
142
- }
143
- }
144
-
145
- /**
146
- * Create a migration from SQL strings
147
- */
148
- export function createMigration(
149
- version: number,
150
- description: string,
151
- up: string,
152
- down?: string
153
- ): Migration {
154
- const migration: Migration = { version, description, up };
155
- if (down !== undefined) {
156
- migration.down = down;
157
- }
158
- return migration;
159
- }
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
+ }