also looking at this
fix(app): an installed database gains the columns a new build needs
#43
4 files
+440
-37
| @@ -168,6 +168,10 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 168 | 168 | |
| 169 | 169 | const sender = new AppleScriptSender() |
| 170 | 170 | 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}`) | |
| 171 | 175 | const startedAt = Date.now() |
| 172 | 176 | let messages: MessagesDb | null = null |
| 173 | 177 | let messagesError: string | null = null |
| @@ -496,6 +500,11 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 496 | 500 | }, |
| 497 | 501 | engines: allEngines(config).map(engine => ({ id: engine.id, label: engine.label })), |
| 498 | 502 | 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, | |
| 499 | 508 | signIn: signIn.state, |
| 500 | 509 | } |
| 501 | 510 | } |
| @@ -1,52 +1,107 @@ | ||
| 1 | 1 | import type { ConversationRecord, RunRecord, Store } from './store' |
| 2 | 2 | import { Database } from 'bun:sqlite' |
| 3 | 3 | import { mkdirSync } from 'node:fs' |
| 4 | import { homedir } from 'node:os' | |
| 4 | 5 | import { dirname } from 'node:path' |
| 5 | 6 | |
| 6 | 7 | /** |
| 7 | 8 | * 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. | |
| 9 | 10 | * |
| 10 | 11 | * The Stacks app keeps its model-backed store (`model-store.ts`), whose tables |
| 11 | 12 | * the dashboard reads. This one exists because a downloaded Uplink.app has no |
| 12 | 13 | * Stacks project around it. Column names and units match the models - cost is |
| 13 | 14 | * 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. | |
| 14 | 28 | */ |
| 15 | 29 | |
| 16 | 30 | export interface RunRow extends RunRecord { |
| 17 | 31 | id: number |
| 18 | 32 | } |
| 19 | 33 | |
| 20 | const 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 | ||
| 49 | const 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 | */ | |
| 40 | const SCHEMA_VERSION = 1 | |
| 41 | ||
| 42 | export 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 | ||
| 63 | export 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 | ||
| 104 | export const RUN_COLUMNS: Record<keyof RunRecord, string> = { | |
| 50 | 105 | chatGuid: 'chat_guid', |
| 51 | 106 | messageGuid: 'message_guid', |
| 52 | 107 | prompt: 'prompt', |
| @@ -62,14 +117,98 @@ const RUN_COLUMNS: Record<keyof RunRecord, string> = { | ||
| 62 | 117 | finishedAt: 'finished_at', |
| 63 | 118 | } |
| 64 | 119 | |
| 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 | */ | |
| 135 | export 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 | */ | |
| 163 | function 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. */ | |
| 173 | function 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 | ||
| 178 | export function schemaVersion(db: Database): number { | |
| 179 | return (db.query('PRAGMA user_version').get() as { user_version: number }).user_version | |
| 180 | } | |
| 181 | ||
| 65 | 182 | export class SqliteStore implements Store { |
| 66 | 183 | private db: Database |
| 67 | 184 | |
| 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 | ||
| 68 | 197 | 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 | } | |
| 73 | 212 | } |
| 74 | 213 | |
| 75 | 214 | close(): void { |
| @@ -147,3 +286,31 @@ export class SqliteStore implements Store { | ||
| 147 | 286 | })) |
| 148 | 287 | } |
| 149 | 288 | } |
| 289 | ||
| 290 | function 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 | ||
| 309 | function message(error: unknown): string { | |
| 310 | return error instanceof Error ? error.message : String(error) | |
| 311 | } | |
| 312 | ||
| 313 | function tilde(path: string): string { | |
| 314 | const home = homedir() | |
| 315 | return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path | |
| 316 | } | |
| @@ -1,12 +1,13 @@ | ||
| 1 | 1 | import type { UplinkConfig } from '../../../app/Uplink/config' |
| 2 | 2 | import { afterEach, describe, expect, it } from 'bun:test' |
| 3 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 3 | import { Database } from 'bun:sqlite' | |
| 4 | import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 4 | 5 | import { tmpdir } from 'node:os' |
| 5 | 6 | import { join } from 'node:path' |
| 6 | 7 | import { loadConfig } from '../../../app/Uplink/config' |
| 7 | 8 | import { MessagesDb } from '../../../app/Uplink/messages-db' |
| 8 | 9 | import { cleanToken, readSettings, settingsEnv, tokenLooksValid, writeSettings } from '../../../app/Uplink/settings' |
| 9 | import { SqliteStore } from '../../../app/Uplink/sqlite-store' | |
| 10 | import { migrate, RUN_COLUMNS, schemaVersion, SqliteStore, TABLES } from '../../../app/Uplink/sqlite-store' | |
| 10 | 11 | import { Uplink } from '../../../app/Uplink/uplink' |
| 11 | 12 | import { FakeChatDb, FakeEngine, FakeSender, ME, settle } from './fixtures' |
| 12 | 13 | |
| @@ -64,6 +65,225 @@ describe('SqliteStore', () => { | ||
| 64 | 65 | }) |
| 65 | 66 | }) |
| 66 | 67 | |
| 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 | */ | |
| 73 | const 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 | ||
| 79 | function 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 | ||
| 86 | function 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 | */ | |
| 99 | function 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 | ||
| 103 | function versionOf(path: string): number { | |
| 104 | const db = new Database(path) | |
| 105 | const version = schemaVersion(db) | |
| 106 | db.close() | |
| 107 | return version | |
| 108 | } | |
| 109 | ||
| 110 | describe('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 | ||
| 67 | 287 | describe('settings', () => { |
| 68 | 288 | it('fills in defaults and survives a broken file', () => { |
| 69 | 289 | const path = join(tempDir(), 'settings.json') |