ReviewOS

also looking at this

stacks/uplink

fix(app): an installed database gains the columns a new build needs

#43
Merged glennmichael123 wants to merge fix/sqlite-store-migrations into main
4 files +440 -37
app/Uplink/desktop-agent.tsmodified+9-0
Changes to app/Uplink/desktop-agent.ts
@@ -168,6 +168,10 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
168168
169169 const sender = new AppleScriptSender()
170170 const store = new SqliteStore(DATABASE_PATH)
171 // A database this app cannot open no longer takes the app with it, so the
172 // one thing that must not happen is it going unmentioned.
173 if (store.schemaError)
174 console.error(`[uplink] ${store.schemaError}`)
171175 const startedAt = Date.now()
172176 let messages: MessagesDb | null = null
173177 let messagesError: string | null = null
@@ -496,6 +500,11 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
496500 },
497501 engines: allEngines(config).map(engine => ({ id: engine.id, label: engine.label })),
498502 lastError: uplink?.lastError ?? null,
503 // Not a check: a broken database is not a setup step anyone performs,
504 // and it must not hold the app in `setup` when it is still answering
505 // texts. The popover shows this in every state, which a check row cannot
506 // do. See `SqliteStore.schemaError`.
507 notice: store.schemaError,
499508 signIn: signIn.state,
500509 }
501510 }
app/Uplink/menubar.htmlmodified+7-0
Changes to app/Uplink/menubar.html
@@ -179,6 +179,10 @@
179179 </div>
180180 </header>
181181 <p class="error pad" id="last-error" hidden></p>
182 <!-- Shown in every state, unlike #last-error and unlike the setup steps.
183 A database Uplink could not open is not a step anyone performs and not
184 a reason to call the app unready, so it has nowhere else to appear. -->
185 <p class="error pad" id="notice" hidden></p>
182186
183187 <section id="setup" hidden>
184188 <ol class="steps">
@@ -469,6 +473,9 @@
469473 $('#last-error').hidden = setup || !status.lastError
470474 $('#last-error').textContent = status.lastError || ''
471475
476 $('#notice').hidden = !status.notice
477 $('#notice').textContent = status.notice || ''
478
472479 $('#setup').hidden = !setup
473480 $('#settings').hidden = setup
474481 renderEngine(status, byId.engine)
app/Uplink/sqlite-store.tsmodified+202-35
Changes to app/Uplink/sqlite-store.ts
@@ -1,52 +1,107 @@
11import type { ConversationRecord, RunRecord, Store } from './store'
22import { Database } from 'bun:sqlite'
33import { mkdirSync } from 'node:fs'
4import { homedir } from 'node:os'
45import { dirname } from 'node:path'
56
67/**
78 * The store the downloadable app uses: one SQLite file in
8 * ~/Library/Application Support/Uplink, no framework, no migrations to run.
9 * ~/Library/Application Support/Uplink, no framework and no migration runner.
910 *
1011 * The Stacks app keeps its model-backed store (`model-store.ts`), whose tables
1112 * the dashboard reads. This one exists because a downloaded Uplink.app has no
1213 * Stacks project around it. Column names and units match the models - cost is
1314 * integer cents - so the two stay describable in one sentence.
15 *
16 * `CREATE TABLE IF NOT EXISTS` does nothing on a Mac that already has the
17 * file, so a column added to a schema string would never reach an installed
18 * copy: the first text after the update would fail on the INSERT, for every
19 * existing user, on every message. That is why the tables are described as
20 * data below rather than as one block of SQL. The same description creates a
21 * new database and tells an old one which columns it is missing.
22 *
23 * `TABLES` is not the only place a `runs` column is named: `RUN_COLUMNS` maps
24 * it to its field and `recentRuns` reads it back. TypeScript ties
25 * `RUN_COLUMNS` to `RunRecord` but cannot tie either to `TABLES`, so a test
26 * does it instead. Adding a column means editing both, and the suite says so
27 * rather than a customer's Mac.
1428 */
1529
1630export interface RunRow extends RunRecord {
1731 id: number
1832}
1933
20const SCHEMA = `
21 CREATE TABLE IF NOT EXISTS state (key TEXT PRIMARY KEY, value TEXT NOT NULL);
22 CREATE TABLE IF NOT EXISTS conversations (
23 chat_guid TEXT PRIMARY KEY,
24 handle TEXT NOT NULL,
25 service TEXT NOT NULL DEFAULT 'iMessage',
26 session_id TEXT,
27 cwd TEXT,
28 last_active_at INTEGER,
29 more_text TEXT
30 );
31 CREATE TABLE IF NOT EXISTS runs (
32 id INTEGER PRIMARY KEY AUTOINCREMENT,
33 chat_guid TEXT NOT NULL,
34 message_guid TEXT NOT NULL,
35 prompt TEXT NOT NULL,
36 cwd TEXT,
37 status TEXT NOT NULL DEFAULT 'queued',
38 reply TEXT,
39 error TEXT,
40 session_id TEXT,
41 cost_cents INTEGER,
42 duration_ms INTEGER,
43 last_activity TEXT,
44 started_at INTEGER,
45 finished_at INTEGER
46 );
47`
48
49const RUN_COLUMNS: Record<keyof RunRecord, string> = {
34/**
35 * Stamped into `PRAGMA user_version`. Nothing branches on it today: the
36 * columns themselves are reconciled. It is recorded so a database can say how
37 * old it is, and so the day a change needs more than a new column - a backfill,
38 * a rename - has a version to key off.
39 */
40const SCHEMA_VERSION = 1
41
42export interface TableSpec {
43 name: string
44 /**
45 * Definitions in creation order. A column added here later is also added to
46 * databases that already exist, so it has to be one SQLite can ALTER in:
47 * nullable or carrying a constant default, and never PRIMARY KEY or UNIQUE.
48 *
49 * Only missing columns are added. Changing a definition here, a default or a
50 * type, reaches new databases and no existing one, which is the original bug
51 * in a quieter form. A change like that needs its own step, not an edit.
52 *
53 * Worth knowing before trusting a green test: SQLite only enforces the
54 * NOT NULL and constant-default halves once a table has rows. An empty one
55 * accepts both. So a new column can pass on a fresh database and fail on
56 * every Mac that has been running, which is this file's own bug wearing a
57 * different hat. `tests/unit/uplink/sqlite-store.test.ts` migrates a
58 * populated database for that reason.
59 */
60 columns: Record<string, string>
61}
62
63export const TABLES: TableSpec[] = [
64 {
65 name: 'state',
66 columns: {
67 key: 'TEXT PRIMARY KEY',
68 value: 'TEXT NOT NULL',
69 },
70 },
71 {
72 name: 'conversations',
73 columns: {
74 chat_guid: 'TEXT PRIMARY KEY',
75 handle: 'TEXT NOT NULL',
76 service: 'TEXT NOT NULL DEFAULT \'iMessage\'',
77 session_id: 'TEXT',
78 cwd: 'TEXT',
79 last_active_at: 'INTEGER',
80 more_text: 'TEXT',
81 },
82 },
83 {
84 name: 'runs',
85 columns: {
86 id: 'INTEGER PRIMARY KEY AUTOINCREMENT',
87 chat_guid: 'TEXT NOT NULL',
88 message_guid: 'TEXT NOT NULL',
89 prompt: 'TEXT NOT NULL',
90 cwd: 'TEXT',
91 status: 'TEXT NOT NULL DEFAULT \'queued\'',
92 reply: 'TEXT',
93 error: 'TEXT',
94 session_id: 'TEXT',
95 cost_cents: 'INTEGER',
96 duration_ms: 'INTEGER',
97 last_activity: 'TEXT',
98 started_at: 'INTEGER',
99 finished_at: 'INTEGER',
100 },
101 },
102]
103
104export const RUN_COLUMNS: Record<keyof RunRecord, string> = {
50105 chatGuid: 'chat_guid',
51106 messageGuid: 'message_guid',
52107 prompt: 'prompt',
@@ -62,14 +117,98 @@ const RUN_COLUMNS: Record<keyof RunRecord, string> = {
62117 finishedAt: 'finished_at',
63118}
64119
120/**
121 * Bring a database up to the shape `TABLES` describes: create the tables it
122 * does not have, and add to the ones it does any column it is missing.
123 *
124 * Forward only, and idempotent, so it runs on every start. The work is decided
125 * by reading first, and a database that is already current returns before the
126 * transaction opens. That matters because opening one takes a write lock, and
127 * during an update two Uplinks hold the same file for a moment: a start that
128 * wrote on every launch would lose that race, and the caller answers a lost
129 * race by abandoning the file. Almost every launch is the no-work case.
130 *
131 * When there is work it is one transaction, which in SQLite covers the
132 * `ALTER TABLE`s and the version stamp together: a database is either fully
133 * updated or untouched, never left halfway by a Mac that went to sleep.
134 */
135export function migrate(db: Database, tables: TableSpec[] = TABLES): void {
136 if (isCurrent(db, tables))
137 return
138
139 db.transaction(() => {
140 for (const table of tables) {
141 const present = columnsOf(db, table.name)
142 const columns = Object.entries(table.columns)
143
144 if (present.size === 0) {
145 db.run(`CREATE TABLE ${table.name} (${columns.map(([name, definition]) => `${name} ${definition}`).join(', ')})`)
146 continue
147 }
148 for (const [name, definition] of columns) {
149 if (!present.has(name))
150 db.run(`ALTER TABLE ${table.name} ADD COLUMN ${name} ${definition}`)
151 }
152 }
153 // Interpolated, not bound: PRAGMA takes no parameter.
154 db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`)
155 })()
156}
157
158/**
159 * Whether the database already has every table and column `tables` names, and
160 * carries this build's version. Reads only, so it is safe to ask while another
161 * process is writing.
162 */
163function isCurrent(db: Database, tables: TableSpec[]): boolean {
164 if (schemaVersion(db) !== SCHEMA_VERSION)
165 return false
166 return tables.every((table) => {
167 const present = columnsOf(db, table.name)
168 return present.size > 0 && Object.keys(table.columns).every(name => present.has(name))
169 })
170}
171
172/** Empty means the table does not exist, which is also how a new file answers. */
173function columnsOf(db: Database, table: string): Set<string> {
174 return new Set((db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>)
175 .map(column => column.name))
176}
177
178export function schemaVersion(db: Database): number {
179 return (db.query('PRAGMA user_version').get() as { user_version: number }).user_version
180}
181
65182export class SqliteStore implements Store {
66183 private db: Database
67184
185 /**
186 * Why this Uplink is running without its database, if it is.
187 *
188 * The store is built at the top of the desktop agent, under launchd, where
189 * an uncaught throw is a restart loop and the menubar never appears to say
190 * why. A corrupt file, a full disk or a migration SQLite refuses would all
191 * end there. Answering texts without remembering them is worth more than an
192 * app that is not running, so the file is abandoned for an in-memory
193 * database and the popover explains it.
194 */
195 readonly schemaError: string | null = null
196
68197 constructor(path: string) {
69 mkdirSync(dirname(path), { recursive: true })
70 this.db = new Database(path, { create: true })
71 this.db.exec('PRAGMA journal_mode = WAL;')
72 this.db.exec(SCHEMA)
198 try {
199 mkdirSync(dirname(path), { recursive: true })
200 this.db = open(path)
201 }
202 catch (error) {
203 // Written for whoever reads it in the popover, not for whoever wrote
204 // this: what is happening, and the one thing they can do about it.
205 this.schemaError = [
206 'Uplink is answering texts but not saving them.',
207 `Its history file could not be opened (${message(error)}).`,
208 `Quit and reopen Uplink to try again, or delete ${tilde(path)} to start a fresh history.`,
209 ].join(' ')
210 this.db = open(':memory:')
211 }
73212 }
74213
75214 close(): void {
@@ -147,3 +286,31 @@ export class SqliteStore implements Store {
147286 }))
148287 }
149288}
289
290function open(path: string): Database {
291 const db = new Database(path, { create: true })
292 try {
293 // A second Uplink on the same file is the normal case during an update.
294 // WAL is what lets it read while this one writes, and the timeout is what
295 // stops the one real upgrade from failing because the other one had the
296 // lock for a moment. Without it SQLite gives up at once and the caller
297 // reads that as a broken file.
298 db.exec('PRAGMA journal_mode = WAL;')
299 db.exec('PRAGMA busy_timeout = 5000;')
300 migrate(db)
301 return db
302 }
303 catch (error) {
304 db.close()
305 throw error
306 }
307}
308
309function message(error: unknown): string {
310 return error instanceof Error ? error.message : String(error)
311}
312
313function tilde(path: string): string {
314 const home = homedir()
315 return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
316}
tests/unit/uplink/sqlite-store.test.tsmodified+222-2
Changes to tests/unit/uplink/sqlite-store.test.ts
@@ -1,12 +1,13 @@
11import type { UplinkConfig } from '../../../app/Uplink/config'
22import { afterEach, describe, expect, it } from 'bun:test'
3import { mkdtempSync, rmSync } from 'node:fs'
3import { Database } from 'bun:sqlite'
4import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
45import { tmpdir } from 'node:os'
56import { join } from 'node:path'
67import { loadConfig } from '../../../app/Uplink/config'
78import { MessagesDb } from '../../../app/Uplink/messages-db'
89import { cleanToken, readSettings, settingsEnv, tokenLooksValid, writeSettings } from '../../../app/Uplink/settings'
9import { SqliteStore } from '../../../app/Uplink/sqlite-store'
10import { migrate, RUN_COLUMNS, schemaVersion, SqliteStore, TABLES } from '../../../app/Uplink/sqlite-store'
1011import { Uplink } from '../../../app/Uplink/uplink'
1112import { FakeChatDb, FakeEngine, FakeSender, ME, settle } from './fixtures'
1213
@@ -64,6 +65,225 @@ describe('SqliteStore', () => {
6465 })
6566})
6667
68/**
69 * The shape Uplink 0.1.3 wrote, which is what is on every Mac that already
70 * runs it. Frozen on purpose: it is the oldest database a new build has to
71 * open, so it is the thing an upgrade has to be proved against.
72 */
73const SHIPPED = {
74 state: 'key TEXT PRIMARY KEY, value TEXT NOT NULL',
75 conversations: 'chat_guid TEXT PRIMARY KEY, handle TEXT NOT NULL, service TEXT NOT NULL DEFAULT \'iMessage\', session_id TEXT, cwd TEXT, last_active_at INTEGER, more_text TEXT',
76 runs: 'id INTEGER PRIMARY KEY AUTOINCREMENT, chat_guid TEXT NOT NULL, message_guid TEXT NOT NULL, prompt TEXT NOT NULL, cwd TEXT, status TEXT NOT NULL DEFAULT \'queued\', reply TEXT, error TEXT, session_id TEXT, cost_cents INTEGER, duration_ms INTEGER, last_activity TEXT, started_at INTEGER, finished_at INTEGER',
77}
78
79function write(path: string, tables: Record<string, string>): void {
80 const db = new Database(path, { create: true })
81 for (const [name, columns] of Object.entries(tables))
82 db.run(`CREATE TABLE ${name} (${columns})`)
83 db.close()
84}
85
86function columnsOf(path: string, table: string): string[] {
87 const db = new Database(path)
88 const names = (db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map(column => column.name)
89 db.close()
90 return names
91}
92
93/**
94 * Compared as a set, not a list. `ALTER TABLE ADD COLUMN` appends, so a column
95 * written into the middle of `TABLES` is last on an upgraded database and
96 * mid-list on a new one. Both are correct, and an order-sensitive assertion
97 * would fail the upgrade that worked.
98 */
99function sameColumns(path: string, table: { name: string, columns: Record<string, string> }): void {
100 expect([...columnsOf(path, table.name)].sort()).toEqual(Object.keys(table.columns).sort())
101}
102
103function versionOf(path: string): number {
104 const db = new Database(path)
105 const version = schemaVersion(db)
106 db.close()
107 return version
108}
109
110describe('SqliteStore migrations', () => {
111 it('creates a database that is already current', () => {
112 const path = join(tempDir(), 'uplink.sqlite')
113 const store = new SqliteStore(path)
114 expect(store.schemaError).toBeNull()
115 store.close()
116
117 for (const table of TABLES)
118 expect(columnsOf(path, table.name)).toEqual(Object.keys(table.columns))
119
120 expect(versionOf(path)).toBeGreaterThan(0)
121 })
122
123 /**
124 * The bug this exists for. `CREATE TABLE IF NOT EXISTS` is a no-op against a
125 * database that is already there, so before this every column added to the
126 * schema was a column an installed copy would never get, and its first INSERT
127 * after the update would fail. Here `runs` is missing the four columns a
128 * newer build names and `conversations` is missing entirely.
129 */
130 it('adds what an installed database is missing, without losing its rows', async () => {
131 const path = join(tempDir(), 'uplink.sqlite')
132 write(path, {
133 state: SHIPPED.state,
134 runs: 'id INTEGER PRIMARY KEY AUTOINCREMENT, chat_guid TEXT NOT NULL, message_guid TEXT NOT NULL, prompt TEXT NOT NULL, cwd TEXT, status TEXT NOT NULL DEFAULT \'queued\', reply TEXT, error TEXT, session_id TEXT, cost_cents INTEGER',
135 })
136 const older = new Database(path)
137 older.run('INSERT INTO runs (chat_guid, message_guid, prompt, cwd, status, reply) VALUES (?, ?, ?, ?, ?, ?)', ['g', 'm', 'answered before the update', '/tmp', 'done', 'hi'])
138 older.close()
139
140 const store = new SqliteStore(path)
141 expect(store.schemaError).toBeNull()
142
143 // The history it already had is still there.
144 expect(store.recentRuns()[0]).toMatchObject({ prompt: 'answered before the update', status: 'done', reply: 'hi' })
145
146 // And the columns it did not have are usable, which is the INSERT that
147 // used to fail on every text.
148 const id = await store.createRun({ chatGuid: 'g', messageGuid: 'm2', prompt: 'after', cwd: '/tmp', status: 'queued' })
149 await store.updateRun(id, { status: 'done', durationMs: 7, lastActivity: 'Thinking', startedAt: 1, finishedAt: 8 })
150 expect(store.recentRuns()[0]).toMatchObject({ id, durationMs: 7, lastActivity: 'Thinking', startedAt: 1, finishedAt: 8 })
151
152 // A table that did not exist at all is created rather than altered.
153 await store.saveConversation({ chatGuid: 'g', handle: ME, service: 'iMessage', sessionId: 's', cwd: '/tmp', lastActiveAt: 5, moreText: null })
154 expect(await store.conversation('g')).toMatchObject({ sessionId: 's' })
155 store.close()
156 })
157
158 /**
159 * The guard that makes the mechanism worth having. SQLite will not ALTER in
160 * a PRIMARY KEY or UNIQUE column at all, and refuses a NOT NULL column with
161 * no default, or a non-constant default, **once the table has rows**. A
162 * column written any of those ways reaches every new install and no existing
163 * one, which is the failure this whole file is about, one level up.
164 *
165 * The rows are the point. On an empty table SQLite 3.51 accepts both
166 * `NOT NULL` with no default and `DEFAULT (datetime())`, so a version of
167 * this test that migrated an empty 0.1.3 database would pass while every
168 * real Mac, which has a history, failed.
169 *
170 * It passes trivially while the schema is unchanged and fails the day
171 * someone adds a column that could not travel.
172 */
173 it('can add every column to a shipped database that is already in use', () => {
174 // A table added after 0.1.3 would be created rather than altered, and this
175 // test would quietly stop covering it. Add it to SHIPPED, or state here
176 // why it does not need the guard.
177 expect(TABLES.map(table => table.name).sort()).toEqual(Object.keys(SHIPPED).sort())
178
179 const path = join(tempDir(), 'uplink.sqlite')
180 write(path, SHIPPED)
181 const used = new Database(path)
182 used.run('INSERT INTO state (key, value) VALUES (?, ?)', ['cursor', '1'])
183 used.run('INSERT INTO conversations (chat_guid, handle) VALUES (?, ?)', ['g', ME])
184 used.run('INSERT INTO runs (chat_guid, message_guid, prompt) VALUES (?, ?, ?)', ['g', 'm', 'p'])
185 used.close()
186
187 expect(versionOf(path)).toBe(0)
188
189 const store = new SqliteStore(path)
190 expect(store.schemaError).toBeNull()
191 store.close()
192
193 for (const table of TABLES)
194 sameColumns(path, table)
195 // Stamped on an upgraded database, not only on a new one. Without this a
196 // migration could leave the version at 0 and every other test stay green.
197 const fresh = join(tempDir(), 'fresh.sqlite')
198 new SqliteStore(fresh).close()
199 expect(versionOf(path)).toBe(versionOf(fresh))
200 })
201
202 /**
203 * The claim in this file's header is that a column is added in one place.
204 * TypeScript only half enforces it: `RUN_COLUMNS` is a `Record` over
205 * `RunRecord`, so those two cannot drift, but nothing ties either to
206 * `TABLES`. A column named in one and not the other is exactly #9 again, so
207 * it is pinned here instead of in the type system.
208 */
209 it('names the same runs columns in TABLES and in RUN_COLUMNS', () => {
210 const declared = Object.keys(TABLES.find(table => table.name === 'runs')!.columns)
211 // `id` is the key SQLite assigns; nothing writes it.
212 expect(Object.values(RUN_COLUMNS).sort()).toEqual(declared.filter(name => name !== 'id').sort())
213 })
214
215 /**
216 * During an update two Uplinks hold this file for a moment, and the loser of
217 * a write lock abandons it for an in-memory database and loses the history.
218 * A launch that changes nothing must therefore not ask for a write lock, and
219 * almost every launch changes nothing.
220 *
221 * Not covered here: the launch that does have work to do and meets a lock.
222 * That one waits on `PRAGMA busy_timeout`, and proving it needs a second
223 * process, because SQLite's wait blocks this one's event loop.
224 */
225 it('opens a current database while another process is writing to it', async () => {
226 const path = join(tempDir(), 'uplink.sqlite')
227 new SqliteStore(path).close()
228
229 const other = new Database(path)
230 other.exec('BEGIN IMMEDIATE')
231 other.run('INSERT INTO state (key, value) VALUES (?, ?)', ['cursor', '1'])
232
233 // Opening is the assertion: it neither threw nor fell back to memory.
234 const store = new SqliteStore(path)
235 expect(store.schemaError).toBeNull()
236
237 other.exec('ROLLBACK')
238 other.close()
239
240 // And it is the real file, not a replacement that forgets on quit.
241 expect(await store.createRun({ chatGuid: 'g', messageGuid: 'm', prompt: 'p', cwd: '/tmp', status: 'queued' })).toBeGreaterThan(0)
242 store.close()
243 expect(new SqliteStore(path).recentRuns()[0]).toMatchObject({ prompt: 'p' })
244 })
245
246 it('runs twice over the same database without changing it', () => {
247 const path = join(tempDir(), 'uplink.sqlite')
248 new SqliteStore(path).close()
249 const after = TABLES.map(table => columnsOf(path, table.name))
250 new SqliteStore(path).close()
251 expect(TABLES.map(table => columnsOf(path, table.name))).toEqual(after)
252 })
253
254 /**
255 * The store is built at the top of the desktop agent, under launchd, so a
256 * throw here is a restart loop with no menubar to explain it. A ruined file
257 * costs the history, not the app.
258 */
259 it('keeps answering texts when the file cannot be opened', async () => {
260 const path = join(tempDir(), 'uplink.sqlite')
261 writeFileSync(path, 'this is not a database')
262
263 const store = new SqliteStore(path)
264 expect(store.schemaError).toContain('not a database')
265 expect(store.schemaError).toContain('not saving them')
266
267 const id = await store.createRun({ chatGuid: 'g', messageGuid: 'm', prompt: 'still works', cwd: '/tmp', status: 'queued' })
268 expect(store.recentRuns()[0]).toMatchObject({ id, prompt: 'still works' })
269 store.close()
270 })
271
272 it('leaves a half-applied change out of the database entirely', () => {
273 const db = new Database(':memory:')
274 db.run('CREATE TABLE runs (id INTEGER PRIMARY KEY)')
275 // UNIQUE is one SQLite never allows in an ALTER, so this stands in for a
276 // migration that fails partway: the table before it was created first.
277 expect(() => migrate(db, [
278 { name: 'created_first', columns: { a: 'TEXT' } },
279 { name: 'runs', columns: { id: 'INTEGER PRIMARY KEY', nope: 'TEXT UNIQUE' } },
280 ])).toThrow()
281 expect(db.query('SELECT name FROM sqlite_master WHERE type = ?').all('table')).toEqual([{ name: 'runs' }])
282 expect(schemaVersion(db)).toBe(0)
283 db.close()
284 })
285})
286
67287describe('settings', () => {
68288 it('fills in defaults and survives a broken file', () => {
69289 const path = join(tempDir(), 'settings.json')