Skip to content

Commit 11b24ec

Browse files
committed
fix(query-builder): honor configured SQLite pragmas
1 parent be2e53d commit 11b24ec

4 files changed

Lines changed: 98 additions & 12 deletions

File tree

docs/packages/query-builder.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,21 @@ const count = await db.selectFrom('users').count()
4747
```
4848

4949
Configure dialects, timestamps, pagination, relation limits, transaction retries, SQL features, and soft deletes in `config/query-builder.ts`. SQLite is the default dialect. The database proxy initializes the query builder lazily on first use.
50+
51+
## SQLite write throughput
52+
53+
Stacks defaults to a WAL checkpoint threshold of one page. Applications with frequent writes can choose a larger threshold through `sqlite.pragmas` in `config/query-builder.ts`:
54+
55+
```ts
56+
// Add this block to the existing query-builder configuration.
57+
sqlite: {
58+
pragmas: [
59+
'PRAGMA wal_autocheckpoint = 1000',
60+
'PRAGMA synchronous = FULL',
61+
],
62+
},
63+
```
64+
65+
Application pragmas run after the framework defaults on both the query-builder connection and the model writer. This example keeps foreign-key enforcement and the busy timeout, reduces checkpoint frequency, and requests full commit synchronization. The one-page default remains in effect when no checkpoint override is configured. See SQLite's [checkpoint threshold](https://www.sqlite.org/pragma.html#pragma_wal_autocheckpoint) and [synchronization modes](https://www.sqlite.org/pragma.html#pragma_synchronous) for the performance and durability tradeoffs.
66+
67+
Committed rows may remain in the WAL sidecar longer with a larger threshold. Use `buddy db:backup`, which creates a consistent SQLite snapshot, instead of copying only the main database file. Measure the chosen settings on the deployment's storage, and label tuned benchmark results separately from stock defaults.

storage/framework/core/query-builder/src/index.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,16 @@ export const SQLITE_BOOTSTRAP_PRAGMAS = [
122122
// sidecar indefinitely — invisible to anything that reads only the main
123123
// file: remote SQLite browsing (e.g. TablePlus over SSH downloads just the
124124
// db file), file-level backups, and snapshot scripts. Low-write apps pay
125-
// effectively nothing; a high-write deployment can raise this back up.
125+
// effectively nothing; a high-write deployment can raise this through
126+
// `sqlite.pragmas` in its query-builder configuration.
126127
'PRAGMA wal_autocheckpoint = 1',
127128
] as const
128129

130+
/** Application pragmas override the framework defaults in declaration order. */
131+
function sqliteBootstrapPragmas(): readonly string[] {
132+
return [...SQLITE_BOOTSTRAP_PRAGMAS, ...(bunQbConfig.sqlite?.pragmas ?? [])]
133+
}
134+
129135
type UnsafeReturn<TResult = unknown> = Promise<TResult> & { execute: () => Promise<TResult> }
130136
type SqlitePragmaExecutor = {
131137
unsafe: (query: string, params?: readonly unknown[]) => UnsafeReturn
@@ -142,7 +148,7 @@ type SqlitePragmaExecutor = {
142148
* preserve it themselves.
143149
*/
144150
export function applySqlitePragmas(instance: SqlitePragmaExecutor): void {
145-
for (const pragma of SQLITE_BOOTSTRAP_PRAGMAS) {
151+
for (const pragma of sqliteBootstrapPragmas()) {
146152
try {
147153
// bun:sqlite executes synchronously inside `.execute()` (the returned
148154
// promise is created already settled), so every pragma is in effect
@@ -159,11 +165,11 @@ export function applySqlitePragmas(instance: SqlitePragmaExecutor): void {
159165

160166
/**
161167
* Raw `bun:sqlite` `Database` handles that have already been bootstrapped.
162-
* The model executor caches its Database per configure/config-signature, so
163-
* a WeakSet keeps re-assertion calls (every wrapped `createQueryBuilder`)
164-
* from re-running the pragmas on an already-bootstrapped connection.
168+
* The model executor can keep its Database when only pragmas change. Remember
169+
* the applied list so repeated bootstrap calls stay cheap, while a changed
170+
* configuration is applied to the existing connection too.
165171
*/
166-
const bootstrappedRawDbs = new WeakSet<object>()
172+
const bootstrappedRawDbs = new WeakMap<object, string>()
167173

168174
/**
169175
* Bootstrap the MODEL-EXECUTOR connection — the raw `bun:sqlite` `Database`
@@ -176,24 +182,28 @@ const bootstrappedRawDbs = new WeakSet<object>()
176182
* `getDatabase()` returns the executor's live handle for the sqlite dialect
177183
* (creating the executor if needed) and throws for mysql/postgres — where
178184
* there is nothing to bootstrap, hence the silent catch. Safe to call
179-
* repeatedly: the WeakSet makes it a no-op after the first hit per
180-
* connection, and a config change that swaps the executor's Database
181-
* produces a fresh (unseen) handle that gets bootstrapped on the next call.
185+
* repeatedly: the WeakMap makes it a no-op while the connection and pragma
186+
* list remain unchanged. A new handle or changed list is bootstrapped on
187+
* the next call.
182188
*/
183189
export function bootstrapModelExecutorPragmas(): void {
184190
try {
185191
const raw = (bunQueryBuilder as { getDatabase?: () => { run: (sql: string) => unknown } }).getDatabase?.()
186-
if (!raw || typeof raw.run !== 'function' || bootstrappedRawDbs.has(raw))
192+
if (!raw || typeof raw.run !== 'function')
193+
return
194+
const pragmas = sqliteBootstrapPragmas()
195+
const signature = JSON.stringify(pragmas)
196+
if (bootstrappedRawDbs.get(raw) === signature)
187197
return
188-
for (const pragma of SQLITE_BOOTSTRAP_PRAGMAS) {
198+
for (const pragma of pragmas) {
189199
try {
190200
raw.run(pragma)
191201
}
192202
catch {
193203
// Fail open per-pragma — same rationale as `applySqlitePragmas`.
194204
}
195205
}
196-
bootstrappedRawDbs.add(raw)
206+
bootstrappedRawDbs.set(raw, signature)
197207
}
198208
catch {
199209
// Non-sqlite dialect (`getDatabase()` throws) — nothing to bootstrap.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { configureOrm, createQueryBuilder, getDatabase, releaseOrm, resetConnection, setConfig } from '../../src'
2+
3+
setConfig({ dialect: 'sqlite', database: { database: ':memory:' } })
4+
5+
async function observe() {
6+
const builder = createQueryBuilder()
7+
const raw = getDatabase()
8+
return {
9+
builder: await builder.unsafe('PRAGMA wal_autocheckpoint').execute(),
10+
model: raw.query('PRAGMA wal_autocheckpoint').get(),
11+
builderForeignKeys: await builder.unsafe('PRAGMA foreign_keys').execute(),
12+
modelForeignKeys: raw.query('PRAGMA foreign_keys').get(),
13+
}
14+
}
15+
16+
try {
17+
configureOrm({ database: ':memory:' })
18+
const defaults = await observe()
19+
setConfig({ sqlite: { pragmas: ['PRAGMA wal_autocheckpoint = 1000'] } })
20+
const configured = await observe()
21+
configureOrm({ database: ':memory:' })
22+
const reconnected = await observe()
23+
setConfig({ sqlite: { pragmas: ['PRAGMA wal_autocheckpoint = 2000'] } })
24+
const changed = await observe()
25+
setConfig({ sqlite: { pragmas: [] } })
26+
const restored = await observe()
27+
console.log(JSON.stringify({ defaults, configured, reconnected, changed, restored }))
28+
}
29+
finally {
30+
releaseOrm()
31+
resetConnection()
32+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { expect, test } from 'bun:test'
2+
import { join } from 'node:path'
3+
4+
test('configured SQLite checkpoint thresholds reach both writers and survive reconnects', async () => {
5+
const child = Bun.spawn([process.execPath, join(import.meta.dir, 'fixtures/sqlite-pragma-overrides.ts')], {
6+
cwd: join(import.meta.dir, '..'),
7+
env: { ...process.env, APP_ENV: 'test', DB_CONNECTION: 'sqlite', DB_DATABASE_PATH: ':memory:' },
8+
stdout: 'pipe',
9+
stderr: 'pipe',
10+
})
11+
const [code, stdout, stderr] = await Promise.all([
12+
child.exited,
13+
new Response(child.stdout).text(),
14+
new Response(child.stderr).text(),
15+
])
16+
expect(code, stderr).toBe(0)
17+
const observations = JSON.parse(stdout.trim().split('\n').at(-1)!)
18+
for (const [name, threshold] of Object.entries({ defaults: 1, configured: 1000, reconnected: 1000, changed: 2000, restored: 1 })) {
19+
expect(observations[name], name).toEqual({
20+
builder: [{ wal_autocheckpoint: threshold }],
21+
model: { wal_autocheckpoint: threshold },
22+
builderForeignKeys: [{ foreign_keys: 1 }],
23+
modelForeignKeys: { foreign_keys: 1 },
24+
})
25+
}
26+
})

0 commit comments

Comments
 (0)