@pikku/migrator-sql 0.12.2 → 0.12.4

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,92 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ splitStatements,
6
+ tableCreationSql,
7
+ tablesInSourceOrder,
8
+ } from './schema-sql.js'
9
+
10
+ test('a semicolon inside a string literal is not a statement boundary', () => {
11
+ const sql = `CREATE TABLE a (id TEXT, note TEXT DEFAULT 'one; two');
12
+ CREATE TABLE b (id TEXT);`
13
+
14
+ assert.deepEqual(splitStatements(sql), [
15
+ `CREATE TABLE a (id TEXT, note TEXT DEFAULT 'one; two');`,
16
+ 'CREATE TABLE b (id TEXT);',
17
+ ])
18
+ })
19
+
20
+ test('a doubled quote inside a literal does not end it', () => {
21
+ const sql = `CREATE TABLE a (note TEXT DEFAULT 'it''s; fine');`
22
+ assert.deepEqual(splitStatements(sql), [sql])
23
+ })
24
+
25
+ test('semicolons in comments and dollar-quoted bodies are ignored', () => {
26
+ const sql = `-- a comment; with a semicolon
27
+ CREATE TABLE a (id TEXT);
28
+ /* another; one */
29
+ CREATE FUNCTION f() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql;`
30
+
31
+ assert.deepEqual(splitStatements(sql), [
32
+ `-- a comment; with a semicolon
33
+ CREATE TABLE a (id TEXT);`,
34
+ `/* another; one */
35
+ CREATE FUNCTION f() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql;`,
36
+ ])
37
+ })
38
+
39
+ test('a trailing statement with no semicolon still counts', () => {
40
+ assert.deepEqual(splitStatements('CREATE TABLE a (id TEXT)'), [
41
+ 'CREATE TABLE a (id TEXT)',
42
+ ])
43
+ })
44
+
45
+ test("a table's own SQL comes back with its indexes and constraints", () => {
46
+ const sql = `CREATE TABLE "user" ("id" text primary key);
47
+ CREATE TABLE "two_factor" ("id" text primary key, "user_id" text not null references "user" ("id"));
48
+ CREATE UNIQUE INDEX "two_factor_user_id_idx" ON "two_factor" ("user_id");
49
+ CREATE INDEX "user_id_idx" ON "user" ("id");
50
+ ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_secret_check" CHECK (length("id") > 0);`
51
+
52
+ assert.deepEqual(tableCreationSql(sql, 'two_factor'), [
53
+ `CREATE TABLE "two_factor" ("id" text primary key, "user_id" text not null references "user" ("id"));`,
54
+ `CREATE UNIQUE INDEX "two_factor_user_id_idx" ON "two_factor" ("user_id");`,
55
+ `ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_secret_check" CHECK (length("id") > 0);`,
56
+ ])
57
+ })
58
+
59
+ test('quoting, casing and a schema qualifier all name the same table', () => {
60
+ const sql = 'create table if not exists public."Two_Factor" (id text);'
61
+ assert.deepEqual(tableCreationSql(sql, '"two_factor"'), [sql])
62
+ assert.deepEqual(tableCreationSql(sql, 'app.two_factor'), [sql])
63
+ })
64
+
65
+ test('a table the SQL never creates yields nothing to copy', () => {
66
+ const sql = `CREATE TABLE "user" ("id" text primary key);
67
+ CREATE INDEX "orphan_idx" ON "orders" ("id");`
68
+
69
+ assert.deepEqual(tableCreationSql(sql, 'orders'), [])
70
+ })
71
+
72
+ test('a referencing table is created after the one it references, not alphabetically', () => {
73
+ const sql = `create table "channels" ("channel_id" text primary key);
74
+ create table "channel_subscriptions" ("channel_id" text not null references "channels" ("channel_id"));`
75
+
76
+ assert.deepEqual(
77
+ tablesInSourceOrder(sql, ['channel_subscriptions', 'channels']),
78
+ ['channels', 'channel_subscriptions']
79
+ )
80
+ })
81
+
82
+ test('a table the source does not create is left at the end', () => {
83
+ const sql = 'create table "a" ("id" text);'
84
+ assert.deepEqual(tablesInSourceOrder(sql, ['unknown', 'a']), ['a', 'unknown'])
85
+ })
86
+
87
+ test('source order survives a schema qualifier on either side', () => {
88
+ const sql = `create table app."b" ("id" text);
89
+ create table "a" ("id" text);`
90
+
91
+ assert.deepEqual(tablesInSourceOrder(sql, ['a', 'app.b']), ['app.b', 'a'])
92
+ })
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Reading a schema source's own SQL back out, one table at a time.
3
+ *
4
+ * A source hands `db generate` both what must exist (`tables`) and what creates
5
+ * it (`sql`). The column map is a comparison surface — it answers "is this
6
+ * table there, and does it have these columns" — and it is deliberately lossy:
7
+ * primary keys, foreign keys, uniqueness, check constraints and indexes are all
8
+ * absent from it. So the moment the generator has to *create* something, the
9
+ * only honest source is the SQL, and this is what pulls the relevant part of it
10
+ * out.
11
+ */
12
+
13
+ /**
14
+ * Split a SQL script into its top-level statements, semicolons included.
15
+ *
16
+ * A naive `split(';')` is wrong in a way that only shows up later: a semicolon
17
+ * inside a string literal, a quoted identifier or a comment is not a statement
18
+ * boundary, and cutting there yields two fragments that are each valid-looking
19
+ * and neither of which does what the original did.
20
+ */
21
+ export function splitStatements(sql: string): string[] {
22
+ const statements: string[] = []
23
+ let start = 0
24
+ let i = 0
25
+
26
+ const closeQuote = (quote: string) => {
27
+ i++
28
+ while (i < sql.length) {
29
+ if (sql[i] === '\\' && quote === "'") {
30
+ i += 2
31
+ continue
32
+ }
33
+ if (sql[i] === quote) {
34
+ // A doubled quote is an escaped one, not the end of the literal.
35
+ if (sql[i + 1] === quote) {
36
+ i += 2
37
+ continue
38
+ }
39
+ i++
40
+ return
41
+ }
42
+ i++
43
+ }
44
+ }
45
+
46
+ while (i < sql.length) {
47
+ const ch = sql[i]!
48
+
49
+ if (ch === "'" || ch === '"' || ch === '`') {
50
+ closeQuote(ch)
51
+ continue
52
+ }
53
+
54
+ if (ch === '-' && sql[i + 1] === '-') {
55
+ const end = sql.indexOf('\n', i)
56
+ i = end === -1 ? sql.length : end + 1
57
+ continue
58
+ }
59
+
60
+ if (ch === '/' && sql[i + 1] === '*') {
61
+ const end = sql.indexOf('*/', i + 2)
62
+ i = end === -1 ? sql.length : end + 2
63
+ continue
64
+ }
65
+
66
+ // Postgres dollar quoting: everything between `$tag$` and its twin is a
67
+ // literal, and a function body written that way is full of semicolons.
68
+ if (ch === '$') {
69
+ const tag = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i))
70
+ if (tag) {
71
+ const end = sql.indexOf(tag[0], i + tag[0].length)
72
+ i = end === -1 ? sql.length : end + tag[0].length
73
+ continue
74
+ }
75
+ }
76
+
77
+ if (ch === ';') {
78
+ const statement = sql.slice(start, i + 1).trim()
79
+ if (statement.length > 1) statements.push(statement)
80
+ start = i + 1
81
+ }
82
+
83
+ i++
84
+ }
85
+
86
+ // A script whose last statement has no trailing semicolon still ran it.
87
+ const tail = sql.slice(start).trim()
88
+ if (tail.length > 0) statements.push(tail)
89
+
90
+ return statements
91
+ }
92
+
93
+ /**
94
+ * Reduce a written table name to the form two sources can be compared on.
95
+ *
96
+ * The same table is `two_factor` to one writer, `"two_factor"` to Kysely and
97
+ * `public.two_factor` to Postgres introspection. Dropping the schema qualifier,
98
+ * the quoting and the case is the only shape all three agree on.
99
+ */
100
+ export function bareTableName(name: string): string {
101
+ const last = name.split('.').pop() ?? name
102
+ return last.replace(/^["'`[]|["'`\]]$/g, '').toLowerCase()
103
+ }
104
+
105
+ const CREATE_TABLE =
106
+ /^CREATE\s+(?:TEMP(?:ORARY)?\s+|UNLOGGED\s+)*TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s(]+)/i
107
+
108
+ const CREATE_INDEX =
109
+ /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?\S+\s+ON\s+(?:ONLY\s+)?([^\s(]+)/i
110
+
111
+ const ALTER_TABLE = /^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?([^\s(]+)/i
112
+
113
+ /**
114
+ * Every statement in `sql` that builds `table`, in the order the source wrote
115
+ * them.
116
+ *
117
+ * The `CREATE TABLE` and its indexes, plus any `ALTER TABLE` the source uses to
118
+ * hang a constraint on it afterwards — which together are what the column list
119
+ * cannot express. Source order is preserved because it is load-bearing: an
120
+ * index cannot precede its table, and a table cannot precede one it references.
121
+ *
122
+ * An empty result means the source's SQL does not visibly create the table.
123
+ * That is a real answer rather than a failure — a source may create a table
124
+ * from something other than a literal `CREATE TABLE` — and the caller is
125
+ * expected to fall back rather than emit nothing.
126
+ */
127
+ export function tableCreationSql(sql: string, table: string): string[] {
128
+ const wanted = bareTableName(table)
129
+ const statements: string[] = []
130
+ let creates = false
131
+
132
+ for (const statement of splitStatements(sql)) {
133
+ const create = CREATE_TABLE.exec(statement)
134
+ if (create && bareTableName(create[1]!) === wanted) {
135
+ creates = true
136
+ statements.push(statement)
137
+ continue
138
+ }
139
+
140
+ const index = CREATE_INDEX.exec(statement)
141
+ if (index && bareTableName(index[1]!) === wanted) {
142
+ statements.push(statement)
143
+ continue
144
+ }
145
+
146
+ const alter = ALTER_TABLE.exec(statement)
147
+ if (alter && bareTableName(alter[1]!) === wanted) {
148
+ statements.push(statement)
149
+ }
150
+ }
151
+
152
+ // Indexes and alters without the table they belong to would fail on their own,
153
+ // and their presence says the table came from somewhere this cannot read.
154
+ return creates ? statements : []
155
+ }
156
+
157
+ /**
158
+ * `tables`, in the order `sql` creates them.
159
+ *
160
+ * A diff hands its missing tables back in whatever order it walked them, which
161
+ * is usually alphabetical and is never the order they can be created in: a
162
+ * table that references another has to come after it, and `channel_subscriptions`
163
+ * sorts before `channels`. The source's own SQL already has a workable order —
164
+ * it was applied in it — so that is the order used, with anything the source
165
+ * does not visibly create left at the end for the caller to render its own way.
166
+ */
167
+ export function tablesInSourceOrder(sql: string, tables: string[]): string[] {
168
+ const position = new Map<string, number>()
169
+ let index = 0
170
+ for (const statement of splitStatements(sql)) {
171
+ const create = CREATE_TABLE.exec(statement)
172
+ if (create) {
173
+ const name = bareTableName(create[1]!)
174
+ if (!position.has(name)) position.set(name, index++)
175
+ }
176
+ }
177
+ const rank = (table: string) => position.get(bareTableName(table)) ?? Number.MAX_SAFE_INTEGER
178
+ return [...tables].sort((a, b) => rank(a) - rank(b))
179
+ }
@@ -0,0 +1,219 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { assertSnakeCaseIdentifiers } from './migration-identifiers.js'
6
+
7
+ /**
8
+ * The migrator's own bookkeeping table, which belongs to no dialect and to no
9
+ * project's schema.
10
+ *
11
+ * Every introspector hides it for that reason. Leaving it visible on one
12
+ * dialect and not the other is not cosmetic: a schema source exported from a
13
+ * database that has been migrated would publish `sql_migrations` as one of its
14
+ * own tables, and the consumer — which also has it — would then read the source
15
+ * as partially covered and emit column deltas instead of the source's own SQL,
16
+ * silently dropping its primary keys, indexes and constraints.
17
+ */
18
+ export const MIGRATION_TRACKING_TABLE = 'sql_migrations'
19
+
20
+ export class MigrationDriftError extends Error {
21
+ constructor(
22
+ public readonly file: string,
23
+ public readonly recordedHash: string,
24
+ public readonly currentHash: string | null,
25
+ public readonly appliedAt: string,
26
+ migrationsDir: string
27
+ ) {
28
+ const onDisk =
29
+ currentHash === null
30
+ ? 'file missing on disk'
31
+ : `sha256:${currentHash.slice(0, 8)}…`
32
+ super(
33
+ `[PKU-DB-DRIFT] ${migrationsDir}/${file}\n\n` +
34
+ `Migration content has changed since it was applied.\n` +
35
+ ` recorded: sha256:${recordedHash.slice(0, 8)}… applied ${appliedAt}\n` +
36
+ ` on disk: ${onDisk}\n\n` +
37
+ `If this edit was intentional, write a new forward migration to revert the change.\n` +
38
+ `Production migrations are immutable.`
39
+ )
40
+ this.name = 'MigrationDriftError'
41
+ }
42
+ }
43
+
44
+ export interface MigrateResult {
45
+ applied: string[]
46
+ skipped: string[]
47
+ }
48
+
49
+ export interface AppliedMigration {
50
+ name: string
51
+ hash: string
52
+ applied_at: string
53
+ }
54
+
55
+ /**
56
+ * Provider-agnostic migration executor. Implement this for each DB dialect.
57
+ * Each method maps to a single DB operation; all file I/O and hashing lives
58
+ * in the shared `migrate()` function above.
59
+ */
60
+ export interface MigrationExecutor {
61
+ ensureTrackingTable(): Promise<void>
62
+ getApplied(): Promise<AppliedMigration[]>
63
+ runMigration(sql: string, name: string, hash: string): Promise<void>
64
+ /**
65
+ * Record a migration as applied without running its SQL.
66
+ *
67
+ * For a database that already contains what the migration describes, because
68
+ * something created those tables before anyone wrote them down. Only ever
69
+ * called once the caller has confirmed that is actually true — recording a
70
+ * migration whose tables are absent leaves a database permanently behind with
71
+ * no pending migration to reveal it.
72
+ */
73
+ recordMigration(name: string, hash: string): Promise<void>
74
+ }
75
+
76
+ function sha256(bytes: Buffer): string {
77
+ return createHash('sha256').update(bytes).digest('hex')
78
+ }
79
+
80
+ /**
81
+ * Apply pending migrations from `migrationsDir/*.sql` using the supplied
82
+ * executor. Hashes raw file bytes on apply; subsequent runs re-hash and bail
83
+ * with `MigrationDriftError` if any applied file has changed on disk.
84
+ */
85
+ /**
86
+ * The migrations on disk, or none.
87
+ *
88
+ * A project that has never generated a migration has no directory to read, and
89
+ * that is the ordinary first-run state rather than a failure — it is precisely
90
+ * the project `db generate` exists to serve.
91
+ */
92
+ const migrationFiles = (migrationsDir: string): string[] =>
93
+ existsSync(migrationsDir)
94
+ ? readdirSync(migrationsDir)
95
+ .filter((f) => f.endsWith('.sql'))
96
+ .sort()
97
+ : []
98
+
99
+ /**
100
+ * Re-hash every applied migration and bail if one has changed on disk.
101
+ *
102
+ * Applies to baselining as much as to migrating: recording a file as applied
103
+ * only means anything if the file is still the one that was applied.
104
+ */
105
+ function assertNoDrift(
106
+ applied: AppliedMigration[],
107
+ migrationsDir: string
108
+ ): void {
109
+ for (const row of applied) {
110
+ let currentHash: string | null = null
111
+ try {
112
+ currentHash = sha256(readFileSync(join(migrationsDir, row.name)))
113
+ } catch {
114
+ currentHash = null
115
+ }
116
+ if (currentHash !== row.hash) {
117
+ throw new MigrationDriftError(
118
+ row.name,
119
+ row.hash,
120
+ currentHash,
121
+ row.applied_at,
122
+ migrationsDir
123
+ )
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Every migration on disk, read once.
130
+ *
131
+ * The identifier check reads all of them and not just the pending ones, so that
132
+ * whether a migration is rejected depends on the files alone. A camelCase
133
+ * column that had already been applied somewhere would otherwise pass on that
134
+ * machine and fail on a fresh checkout, which is the opposite of deterministic.
135
+ */
136
+ const readMigrations = (
137
+ migrationsDir: string
138
+ ): Array<{ name: string; sql: string }> =>
139
+ migrationFiles(migrationsDir).map((name) => ({
140
+ name,
141
+ sql: readFileSync(join(migrationsDir, name), 'utf8'),
142
+ }))
143
+
144
+ export async function migrate(
145
+ executor: MigrationExecutor,
146
+ migrationsDir: string
147
+ ): Promise<MigrateResult> {
148
+ assertSnakeCaseIdentifiers(readMigrations(migrationsDir))
149
+ await executor.ensureTrackingTable()
150
+ const applied = await executor.getApplied()
151
+ assertNoDrift(applied, migrationsDir)
152
+ const appliedNames = new Set(applied.map((r) => r.name))
153
+
154
+ const result: MigrateResult = { applied: [], skipped: [] }
155
+
156
+ for (const name of migrationFiles(migrationsDir)) {
157
+ if (appliedNames.has(name)) {
158
+ result.skipped.push(name)
159
+ continue
160
+ }
161
+ const raw = readFileSync(join(migrationsDir, name))
162
+ const hash = sha256(raw)
163
+ await executor.runMigration(raw.toString('utf8'), name, hash)
164
+ result.applied.push(name)
165
+ }
166
+
167
+ return result
168
+ }
169
+
170
+ /**
171
+ * Record every pending migration as applied, without running any of it.
172
+ *
173
+ * The escape hatch for a database that already has the tables a migration
174
+ * creates — the shape you get when a runtime bootstrapped its own schema at
175
+ * boot and the migration writing it down was authored afterwards. Running that
176
+ * migration would fail on every existing deployment; skipping it forever would
177
+ * leave the history lying. Recording it says what is true.
178
+ *
179
+ * Deliberately unconditional here. Whether the database really does match is a
180
+ * question about schemas, not migration files, so the caller answers it first
181
+ * and this only runs once it has.
182
+ */
183
+ /**
184
+ * The migrations on disk that the database has not recorded.
185
+ *
186
+ * Deliberately not derived by the caller: "pending" has to mean the same set
187
+ * `migrate` is about to apply, and that is filename order over `*.sql` minus
188
+ * what is recorded — not whatever a directory listing happens to return.
189
+ */
190
+ export function pendingMigrations(
191
+ migrationsDir: string,
192
+ applied: AppliedMigration[]
193
+ ): string[] {
194
+ const appliedNames = new Set(applied.map((row) => row.name))
195
+ return migrationFiles(migrationsDir).filter(
196
+ (name) => !appliedNames.has(name)
197
+ )
198
+ }
199
+
200
+ export async function baselineMigrations(
201
+ executor: MigrationExecutor,
202
+ migrationsDir: string
203
+ ): Promise<string[]> {
204
+ await executor.ensureTrackingTable()
205
+ const applied = await executor.getApplied()
206
+ assertNoDrift(applied, migrationsDir)
207
+
208
+ const appliedNames = new Set(applied.map((r) => r.name))
209
+ const recorded: string[] = []
210
+ for (const name of migrationFiles(migrationsDir)) {
211
+ if (appliedNames.has(name)) continue
212
+ await executor.recordMigration(
213
+ name,
214
+ sha256(readFileSync(join(migrationsDir, name)))
215
+ )
216
+ recorded.push(name)
217
+ }
218
+ return recorded
219
+ }
@@ -0,0 +1,8 @@
1
+ export { SqliteMigrationExecutor, dropTrackingTable } from './sqlite-migrator.js'
2
+ export { loadSqliteRuntime } from './sqlite-runtime.js'
3
+ export type {
4
+ SqliteRuntime,
5
+ SyncSqliteChanges,
6
+ SyncSqliteDatabase,
7
+ SyncSqliteStatement,
8
+ } from './sqlite-runtime.js'
@@ -0,0 +1,49 @@
1
+ import type { MigrationExecutor, AppliedMigration } from '../sql-migrator.js'
2
+ import { MIGRATION_TRACKING_TABLE as TRACKING_TABLE } from '../sql-migrator.js'
3
+ import type { SyncSqliteDatabase } from './sqlite-runtime.js'
4
+
5
+ export class SqliteMigrationExecutor implements MigrationExecutor {
6
+ constructor(private readonly db: SyncSqliteDatabase) {}
7
+
8
+ async ensureTrackingTable(): Promise<void> {
9
+ this.db.exec(
10
+ `CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} (
11
+ name TEXT PRIMARY KEY,
12
+ hash TEXT NOT NULL,
13
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
14
+ )`
15
+ )
16
+ }
17
+
18
+ async getApplied(): Promise<AppliedMigration[]> {
19
+ return this.db
20
+ .prepare(
21
+ `SELECT name, hash, applied_at FROM ${TRACKING_TABLE} ORDER BY name`
22
+ )
23
+ .all() as unknown as AppliedMigration[]
24
+ }
25
+
26
+ async recordMigration(name: string, hash: string): Promise<void> {
27
+ this.db
28
+ .prepare(`INSERT INTO ${TRACKING_TABLE} (name, hash) VALUES (?, ?)`)
29
+ .run(name, hash)
30
+ }
31
+
32
+ async runMigration(sql: string, name: string, hash: string): Promise<void> {
33
+ this.db.exec('BEGIN')
34
+ try {
35
+ this.db.exec(sql)
36
+ this.db
37
+ .prepare(`INSERT INTO ${TRACKING_TABLE} (name, hash) VALUES (?, ?)`)
38
+ .run(name, hash)
39
+ this.db.exec('COMMIT')
40
+ } catch (err) {
41
+ this.db.exec('ROLLBACK')
42
+ throw err
43
+ }
44
+ }
45
+ }
46
+
47
+ export function dropTrackingTable(db: SyncSqliteDatabase): void {
48
+ db.exec(`DROP TABLE IF EXISTS ${TRACKING_TABLE}`)
49
+ }
@@ -0,0 +1,83 @@
1
+ import { Database, type SQLQueryBindings } from 'bun:sqlite'
2
+ import type {
3
+ SqliteRuntime,
4
+ SyncSqliteChanges,
5
+ SyncSqliteDatabase,
6
+ SyncSqliteStatement,
7
+ } from './sqlite-runtime.js'
8
+
9
+ class BunSqliteStatement implements SyncSqliteStatement {
10
+ readonly reader: boolean
11
+
12
+ constructor(
13
+ private readonly stmt: ReturnType<Database['prepare']>,
14
+ reader: boolean
15
+ ) {
16
+ this.reader = reader
17
+ }
18
+
19
+ all(...parameters: unknown[]): unknown[] {
20
+ return this.stmt.all(...(parameters as SQLQueryBindings[])) as unknown[]
21
+ }
22
+
23
+ get(...parameters: unknown[]): unknown | null {
24
+ return (
25
+ (this.stmt.get(...(parameters as SQLQueryBindings[])) as unknown) ?? null
26
+ )
27
+ }
28
+
29
+ iterate(...parameters: unknown[]): IterableIterator<unknown> {
30
+ return this.stmt.iterate(
31
+ ...(parameters as SQLQueryBindings[])
32
+ ) as IterableIterator<unknown>
33
+ }
34
+
35
+ run(...parameters: unknown[]): SyncSqliteChanges {
36
+ const result = this.stmt.run(...(parameters as SQLQueryBindings[]))
37
+ return {
38
+ changes: result.changes,
39
+ lastInsertRowid: result.lastInsertRowid,
40
+ }
41
+ }
42
+ }
43
+
44
+ class BunSqliteDatabase implements SyncSqliteDatabase {
45
+ constructor(private readonly db: Database) {}
46
+
47
+ exec(sql: string): void {
48
+ // bun:sqlite throws "no valid SQL statement" on comment-only/empty input
49
+ // (e.g. a placeholder dev-seed.sql); node:sqlite silently no-ops. Match node's
50
+ // tolerance by skipping when nothing executable remains after stripping
51
+ // comments. The original `sql` is still exec'd verbatim when non-empty.
52
+ const executable = sql
53
+ .replace(/--[^\n]*/g, '')
54
+ .replace(/\/\*[\s\S]*?\*\//g, '')
55
+ .trim()
56
+ if (executable.length === 0) return
57
+ this.db.exec(sql)
58
+ }
59
+
60
+ prepare(sql: string): SyncSqliteStatement {
61
+ return new BunSqliteStatement(this.db.prepare(sql), isReaderSql(sql))
62
+ }
63
+
64
+ close(): void {
65
+ this.db.close()
66
+ }
67
+ }
68
+
69
+ function isReaderSql(sql: string): boolean {
70
+ const normalized = sql.trimStart().toUpperCase()
71
+ return (
72
+ normalized.startsWith('SELECT') ||
73
+ normalized.startsWith('WITH') ||
74
+ normalized.startsWith('PRAGMA') ||
75
+ normalized.startsWith('EXPLAIN')
76
+ )
77
+ }
78
+
79
+ export const bunSqliteRuntime: SqliteRuntime = {
80
+ open(filename) {
81
+ return new BunSqliteDatabase(new Database(filename))
82
+ },
83
+ }