@pikku/migrator-sql 0.12.2 → 0.12.3
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/CHANGELOG.md +8 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +2 -2
- package/run-tests.sh +0 -0
- package/src/index.ts +34 -0
- package/src/migration-identifiers.test.ts +142 -0
- package/src/migration-identifiers.ts +321 -0
- package/src/postgres/index.ts +4 -0
- package/src/postgres/postgres-migrator.ts +81 -0
- package/src/schema-sql.test.ts +66 -0
- package/src/schema-sql.ts +155 -0
- package/src/sql-migrator.ts +219 -0
- package/src/sqlite/index.ts +8 -0
- package/src/sqlite/sqlite-migrator.ts +49 -0
- package/src/sqlite/sqlite-runtime-bun.ts +83 -0
- package/src/sqlite/sqlite-runtime-node.ts +118 -0
- package/src/sqlite/sqlite-runtime.ts +41 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SqliteRuntime,
|
|
3
|
+
SyncSqliteChanges,
|
|
4
|
+
SyncSqliteDatabase,
|
|
5
|
+
SyncSqliteStatement,
|
|
6
|
+
} from './sqlite-runtime.js'
|
|
7
|
+
|
|
8
|
+
interface NodeSqliteStatementShape {
|
|
9
|
+
reader?: boolean
|
|
10
|
+
all(...parameters: unknown[]): unknown[]
|
|
11
|
+
get(...parameters: unknown[]): unknown
|
|
12
|
+
iterate(...parameters: unknown[]): IterableIterator<unknown>
|
|
13
|
+
run(...parameters: unknown[]): {
|
|
14
|
+
changes: number | bigint
|
|
15
|
+
lastInsertRowid: number | bigint
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface NodeSqliteDatabaseShape {
|
|
20
|
+
exec(sql: string): void
|
|
21
|
+
prepare(sql: string): NodeSqliteStatementShape
|
|
22
|
+
close(): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface NodeSqliteModule {
|
|
26
|
+
DatabaseSync: new (filename?: string) => NodeSqliteDatabaseShape
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class NodeSqliteStatement implements SyncSqliteStatement {
|
|
30
|
+
readonly reader: boolean
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly stmt: NodeSqliteStatementShape,
|
|
34
|
+
sql: string
|
|
35
|
+
) {
|
|
36
|
+
// node:sqlite StatementSync does not have a .reader property
|
|
37
|
+
// (that's a better-sqlite3 API). Fall back to SQL inspection when absent.
|
|
38
|
+
if (stmt.reader !== undefined) {
|
|
39
|
+
this.reader = Boolean(stmt.reader)
|
|
40
|
+
} else {
|
|
41
|
+
const upper = sql.trimStart().toUpperCase()
|
|
42
|
+
this.reader =
|
|
43
|
+
upper.startsWith('SELECT') ||
|
|
44
|
+
upper.startsWith('WITH') ||
|
|
45
|
+
upper.startsWith('PRAGMA') ||
|
|
46
|
+
upper.startsWith('EXPLAIN') ||
|
|
47
|
+
upper.startsWith('VALUES') ||
|
|
48
|
+
/\bRETURNING\b/.test(upper)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
all(...parameters: unknown[]): unknown[] {
|
|
53
|
+
return this.stmt.all(...parameters) as unknown[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get(...parameters: unknown[]): unknown | null {
|
|
57
|
+
return (this.stmt.get(...parameters) as unknown) ?? null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
iterate(...parameters: unknown[]): IterableIterator<unknown> {
|
|
61
|
+
return this.stmt.iterate(...parameters) as IterableIterator<unknown>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
run(...parameters: unknown[]): SyncSqliteChanges {
|
|
65
|
+
const result = this.stmt.run(...parameters)
|
|
66
|
+
return {
|
|
67
|
+
changes: result.changes,
|
|
68
|
+
lastInsertRowid: result.lastInsertRowid,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class NodeSqliteDatabase implements SyncSqliteDatabase {
|
|
74
|
+
constructor(private readonly db: NodeSqliteDatabaseShape) {}
|
|
75
|
+
|
|
76
|
+
exec(sql: string): void {
|
|
77
|
+
this.db.exec(sql)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
prepare(sql: string): SyncSqliteStatement {
|
|
81
|
+
return new NodeSqliteStatement(this.db.prepare(sql), sql)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
close(): void {
|
|
85
|
+
this.db.close()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function importNodeSqlite(): Promise<NodeSqliteModule> {
|
|
90
|
+
const dynamicImport = new Function(
|
|
91
|
+
'return import("node:sqlite")'
|
|
92
|
+
) as () => Promise<NodeSqliteModule>
|
|
93
|
+
try {
|
|
94
|
+
return await dynamicImport()
|
|
95
|
+
} catch (error: any) {
|
|
96
|
+
// node ships node:sqlite unflagged only from 24. The raw failure is
|
|
97
|
+
// ERR_UNKNOWN_BUILTIN_MODULE, which reads like a broken install rather than
|
|
98
|
+
// a runtime that is simply too old — so say which it is and how to get past it.
|
|
99
|
+
if (error?.code === 'ERR_UNKNOWN_BUILTIN_MODULE') {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`This needs node:sqlite, which Node ${process.versions.node} does not provide ` +
|
|
102
|
+
`(it is unflagged from Node 24). Either upgrade Node, or run the CLI on bun, ` +
|
|
103
|
+
`which has it: \`bunx --bun pikku <command>\`.`,
|
|
104
|
+
{ cause: error }
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
throw error
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function createNodeSqliteRuntime(): Promise<SqliteRuntime> {
|
|
112
|
+
const { DatabaseSync } = await importNodeSqlite()
|
|
113
|
+
return {
|
|
114
|
+
open(filename) {
|
|
115
|
+
return new NodeSqliteDatabase(new DatabaseSync(filename))
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface SyncSqliteChanges {
|
|
2
|
+
changes: number | bigint
|
|
3
|
+
lastInsertRowid: number | bigint
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SyncSqliteStatement {
|
|
7
|
+
reader: boolean
|
|
8
|
+
all(...parameters: unknown[]): unknown[]
|
|
9
|
+
get(...parameters: unknown[]): unknown | null
|
|
10
|
+
iterate(...parameters: unknown[]): IterableIterator<unknown>
|
|
11
|
+
run(...parameters: unknown[]): SyncSqliteChanges
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SyncSqliteDatabase {
|
|
15
|
+
exec(sql: string): void
|
|
16
|
+
prepare(sql: string): SyncSqliteStatement
|
|
17
|
+
close(): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SqliteRuntime {
|
|
21
|
+
open(filename: string): SyncSqliteDatabase
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let runtimePromise: Promise<SqliteRuntime> | undefined
|
|
25
|
+
|
|
26
|
+
export async function loadSqliteRuntime(): Promise<SqliteRuntime> {
|
|
27
|
+
runtimePromise ??= (async () => {
|
|
28
|
+
const isBunRuntime =
|
|
29
|
+
typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'
|
|
30
|
+
|
|
31
|
+
if (isBunRuntime) {
|
|
32
|
+
const { bunSqliteRuntime } = await import('./sqlite-runtime-bun.js')
|
|
33
|
+
return bunSqliteRuntime
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { createNodeSqliteRuntime } = await import('./sqlite-runtime-node.js')
|
|
37
|
+
return createNodeSqliteRuntime()
|
|
38
|
+
})()
|
|
39
|
+
|
|
40
|
+
return runtimePromise
|
|
41
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": ".",
|
|
5
|
+
"types": ["node", "bun"],
|
|
6
|
+
"module": "Node18",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"target": "esnext",
|
|
9
|
+
"declaration": true
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*.ts"],
|
|
12
|
+
"exclude": ["**/*.test.ts", "node_modules"]
|
|
13
|
+
}
|