main sqlite.go
Eric Bower  ·  2026-02-25
  1package patchbin
  2
  3import (
  4	"fmt"
  5	"log/slog"
  6
  7	"github.com/jmoiron/sqlx"
  8	_ "modernc.org/sqlite"
  9)
 10
 11var sqliteSchema = `
 12CREATE TABLE IF NOT EXISTS app_users (
 13  id INTEGER PRIMARY KEY AUTOINCREMENT,
 14  pubkey TEXT NOT NULL UNIQUE,
 15  name TEXT NOT NULL UNIQUE,
 16  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 17  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
 18);
 19
 20CREATE TABLE IF NOT EXISTS acl (
 21  id INTEGER PRIMARY KEY AUTOINCREMENT,
 22  pubkey string,
 23  ip_address string,
 24  permission string NOT NULL,
 25  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
 26);
 27
 28CREATE TABLE IF NOT EXISTS patch_requests (
 29  id INTEGER PRIMARY KEY AUTOINCREMENT,
 30  user_id INTEGER NOT NULL,
 31  repo_name TEXT NOT NULL DEFAULT '',
 32  name TEXT NOT NULL,
 33  text TEXT NOT NULL,
 34  status TEXT NOT NULL,
 35  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 36  updated_at DATETIME NOT NULL,
 37  last_activity DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 38  CONSTRAINT pr_user_id_fk
 39    FOREIGN KEY(user_id) REFERENCES app_users(id)
 40    ON DELETE CASCADE
 41    ON UPDATE CASCADE
 42);
 43
 44CREATE TABLE IF NOT EXISTS patchsets (
 45  id INTEGER PRIMARY KEY AUTOINCREMENT,
 46  user_id INTEGER NOT NULL,
 47  patch_request_id INTEGER NOT NULL,
 48  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 49  CONSTRAINT patchset_user_id_fk
 50    FOREIGN KEY(user_id) REFERENCES app_users(id)
 51    ON DELETE CASCADE
 52    ON UPDATE CASCADE,
 53  CONSTRAINT patchset_patch_request_id_fk
 54    FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
 55    ON DELETE CASCADE
 56    ON UPDATE CASCADE
 57);
 58
 59CREATE TABLE IF NOT EXISTS patches (
 60	id INTEGER PRIMARY KEY AUTOINCREMENT,
 61	user_id INTEGER NOT NULL,
 62	patchset_id INTEGER NOT NULL,
 63	author_name TEXT NOT NULL,
 64	author_email TEXT NOT NULL,
 65	author_date DATETIME NOT NULL,
 66	title TEXT NOT NULL,
 67	body TEXT NOT NULL,
 68	body_appendix TEXT NOT NULL,
 69	commit_sha TEXT NOT NULL,
 70	content_sha TEXT NOT NULL,
 71	raw_text TEXT NOT NULL,
 72	base_commit_sha TEXT,
 73	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 74	CONSTRAINT patches_user_id_fk
 75		FOREIGN KEY(user_id) REFERENCES app_users(id)
 76		ON DELETE CASCADE
 77		ON UPDATE CASCADE,
 78	CONSTRAINT patches_patchset_id_fk
 79		FOREIGN KEY(patchset_id) REFERENCES patchsets(id)
 80		ON DELETE CASCADE
 81		ON UPDATE CASCADE
 82);
 83
 84CREATE TABLE IF NOT EXISTS event_logs (
 85	id INTEGER PRIMARY KEY AUTOINCREMENT,
 86	user_id INTEGER NOT NULL,
 87	patch_request_id INTEGER,
 88	patchset_id INTEGER,
 89	event TEXT NOT NULL,
 90	data TEXT,
 91	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 92	CONSTRAINT event_logs_pr_id_fk
 93		FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
 94		ON DELETE CASCADE
 95		ON UPDATE CASCADE,
 96	CONSTRAINT event_logs_patchset_id_fk
 97		FOREIGN KEY(patchset_id) REFERENCES patchsets(id)
 98		ON DELETE CASCADE
 99		ON UPDATE CASCADE,
100	CONSTRAINT event_logs_user_id_fk
101		FOREIGN KEY(user_id) REFERENCES app_users(id)
102		ON DELETE CASCADE
103		ON UPDATE CASCADE
104);
105
106CREATE INDEX IF NOT EXISTS idx_patch_requests_last_activity ON patch_requests(last_activity);
107`
108
109var sqliteMigrations = []string{
110	"", // migration #0 is reserved for schema initialization
111	"ALTER TABLE patches ADD COLUMN base_commit_sha TEXT",
112	// added this by accident
113	"",
114	// create repos table
115	`CREATE TABLE IF NOT EXISTS repos (
116		id INTEGER PRIMARY KEY AUTOINCREMENT,
117		user_id INTEGER NOT NULL,
118		name TEXT NOT NULL,
119		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
120		updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
121		UNIQUE (user_id, name),
122		CONSTRAINT repo_user_id_fk
123			FOREIGN KEY(user_id) REFERENCES app_users(id)
124			ON DELETE CASCADE
125			ON UPDATE CASCADE
126	);`,
127	// migrate existing repo info from patch_requests
128	`INSERT INTO repos (user_id, name) SELECT user_id, repo_id from patch_requests group by repo_id;`,
129	// convert patch_requests.repo_id to integer with FK constraint
130	`CREATE TABLE IF NOT EXISTS tmp_patch_requests (
131		id INTEGER PRIMARY KEY AUTOINCREMENT,
132		user_id INTEGER NOT NULL,
133		repo_id INTEGER NOT NULL,
134		name TEXT NOT NULL,
135		text TEXT NOT NULL,
136		status TEXT NOT NULL,
137		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
138		updated_at DATETIME NOT NULL,
139		CONSTRAINT pr_user_id_fk
140			FOREIGN KEY(user_id) REFERENCES app_users(id)
141			ON DELETE CASCADE
142			ON UPDATE CASCADE,
143		CONSTRAINT pr_repo_id_fk
144			FOREIGN KEY(repo_id) REFERENCES repos(id)
145			ON DELETE CASCADE
146			ON UPDATE CASCADE
147	);
148	INSERT INTO tmp_patch_requests (user_id, repo_id, name, text, status, created_at, updated_at)
149		SELECT pr.user_id, repos.id, pr.name, pr.text, pr.status, pr.created_at, pr.updated_at
150		FROM patch_requests AS pr
151		INNER JOIN repos ON repos.name = pr.repo_id;
152	DROP TABLE patch_requests;
153	ALTER TABLE tmp_patch_requests RENAME TO patch_requests;`,
154	// convert event_logs.repo_id to integer with FK constraint
155	`CREATE TABLE IF NOT EXISTS tmp_event_logs (
156		id INTEGER PRIMARY KEY AUTOINCREMENT,
157		user_id INTEGER NOT NULL,
158		repo_id INTEGER,
159		patch_request_id INTEGER,
160		patchset_id INTEGER,
161		event TEXT NOT NULL,
162		data TEXT,
163		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
164		CONSTRAINT event_logs_pr_id_fk
165			FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
166			ON DELETE CASCADE
167			ON UPDATE CASCADE,
168		CONSTRAINT event_logs_patchset_id_fk
169			FOREIGN KEY(patchset_id) REFERENCES patchsets(id)
170			ON DELETE CASCADE
171			ON UPDATE CASCADE,
172		CONSTRAINT event_logs_user_id_fk
173			FOREIGN KEY(user_id) REFERENCES app_users(id)
174			ON DELETE CASCADE
175			ON UPDATE CASCADE
176		CONSTRAINT event_logs_repo_id_fk
177			FOREIGN KEY(repo_id) REFERENCES repos(id)
178			ON DELETE CASCADE
179			ON UPDATE CASCADE
180	);
181	INSERT INTO tmp_event_logs (user_id, repo_id, patch_request_id, patchset_id, event, data, created_at)
182		SELECT ev.user_id, repos.id, ev.patch_request_id, ev.patchset_id, ev.event, ev.data, ev.created_at
183		FROM event_logs AS ev
184		LEFT JOIN repos ON repos.name = ev.repo_id;
185	DROP TABLE event_logs;
186	ALTER TABLE tmp_event_logs RENAME TO event_logs;`,
187	// Phase 1: Add repo_name column to patch_requests
188	`ALTER TABLE patch_requests ADD COLUMN repo_name TEXT`,
189	// Phase 1: Populate repo_name from existing repos table
190	`UPDATE patch_requests SET repo_name = (SELECT name FROM repos WHERE id = patch_requests.repo_id)`,
191	// Phase 1: Remove patch_requests whose repo no longer exists. These are
192	// orphans from repo deletion, since ON DELETE CASCADE never fired
193	// because PRAGMA foreign_keys was never enabled.
194	`DELETE FROM event_logs WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE repo_name IS NULL);
195	DELETE FROM patches WHERE patchset_id IN (SELECT id FROM patchsets WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE repo_name IS NULL));
196	DELETE FROM patchsets WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE repo_name IS NULL);
197	DELETE FROM patch_requests WHERE repo_name IS NULL;`,
198	// Phase 1: Add last_activity column to patch_requests
199	`ALTER TABLE patch_requests ADD COLUMN last_activity DATETIME`,
200	// Phase 1: Set initial last_activity values from event_logs
201	`UPDATE patch_requests SET last_activity = (SELECT MAX(created_at) FROM event_logs WHERE patch_request_id = patch_requests.id) WHERE last_activity IS NULL`,
202	// Phase 1: Set last_activity to created_at for PRs with no events
203	`UPDATE patch_requests SET last_activity = created_at WHERE last_activity IS NULL`,
204	// Phase 1: Create index on last_activity for fast filtering
205	`CREATE INDEX IF NOT EXISTS idx_patch_requests_last_activity ON patch_requests(last_activity)`,
206	// Phase 2: Drop repos table (no longer needed, repo_name is stored directly)
207	`DROP TABLE IF EXISTS repos`,
208	// Phase 2: Rebuild patch_requests without repo_id (SQLite can't drop a
209	// column that's part of a foreign key constraint via ALTER TABLE).
210	`CREATE TABLE tmp_patch_requests_v2 (
211		id INTEGER PRIMARY KEY AUTOINCREMENT,
212		user_id INTEGER NOT NULL,
213		repo_name TEXT NOT NULL DEFAULT '',
214		name TEXT NOT NULL,
215		text TEXT NOT NULL,
216		status TEXT NOT NULL,
217		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
218		updated_at DATETIME NOT NULL,
219		last_activity DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
220		CONSTRAINT pr_user_id_fk
221			FOREIGN KEY(user_id) REFERENCES app_users(id)
222			ON DELETE CASCADE
223			ON UPDATE CASCADE
224	);
225	INSERT INTO tmp_patch_requests_v2 (id, user_id, repo_name, name, text, status, created_at, updated_at, last_activity)
226		SELECT id, user_id, repo_name, name, text, status, created_at, updated_at, last_activity
227		FROM patch_requests;
228	DROP TABLE patch_requests;
229	ALTER TABLE tmp_patch_requests_v2 RENAME TO patch_requests;
230	CREATE INDEX IF NOT EXISTS idx_patch_requests_last_activity ON patch_requests(last_activity);`,
231	// Phase 2: Rebuild event_logs without repo_id, same reasoning as above.
232	`CREATE TABLE tmp_event_logs_v2 (
233		id INTEGER PRIMARY KEY AUTOINCREMENT,
234		user_id INTEGER NOT NULL,
235		patch_request_id INTEGER,
236		patchset_id INTEGER,
237		event TEXT NOT NULL,
238		data TEXT,
239		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
240		CONSTRAINT event_logs_pr_id_fk
241			FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
242			ON DELETE CASCADE
243			ON UPDATE CASCADE,
244		CONSTRAINT event_logs_patchset_id_fk
245			FOREIGN KEY(patchset_id) REFERENCES patchsets(id)
246			ON DELETE CASCADE
247			ON UPDATE CASCADE,
248		CONSTRAINT event_logs_user_id_fk
249			FOREIGN KEY(user_id) REFERENCES app_users(id)
250			ON DELETE CASCADE
251			ON UPDATE CASCADE
252	);
253	INSERT INTO tmp_event_logs_v2 (id, user_id, patch_request_id, patchset_id, event, data, created_at)
254		SELECT id, user_id, patch_request_id, patchset_id, event, data, created_at
255		FROM event_logs;
256	DROP TABLE event_logs;
257	ALTER TABLE tmp_event_logs_v2 RENAME TO event_logs;`,
258	// Phase 2: Collapse legacy statuses (closed, accepted, reviewed) into
259	// open, since the new model only has draft and open.
260	`UPDATE patch_requests SET status = 'open' WHERE status NOT IN ('draft', 'open')`,
261	// Delete patch requests with an empty title. These come from patchsets
262	// whose first patch had no subject line and are unusable in the UI.
263	`DELETE FROM event_logs WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE trim(name) = '');
264	DELETE FROM patches WHERE patchset_id IN (SELECT id FROM patchsets WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE trim(name) = ''));
265	DELETE FROM patchsets WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE trim(name) = '');
266	DELETE FROM patch_requests WHERE trim(name) = '';`,
267}
268
269// Open opens a database connection.
270func SqliteOpen(dsn string, logger *slog.Logger) (*sqlx.DB, error) {
271	logger.Info("opening db file", "dsn", dsn)
272	db, err := sqlx.Connect("sqlite", dsn)
273	if err != nil {
274		return nil, err
275	}
276
277	err = sqliteUpgrade(db)
278	if err != nil {
279		_ = db.Close()
280		return nil, err
281	}
282
283	return db, nil
284}
285
286func sqliteUpgrade(db *sqlx.DB) error {
287	var version int
288	if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
289		return fmt.Errorf("failed to query schema version: %v", err)
290	}
291
292	if version == len(sqliteMigrations) {
293		return nil
294	} else if version > len(sqliteMigrations) {
295		return fmt.Errorf("patchbin (version %d) older than schema (version %d)", len(sqliteMigrations), version)
296	}
297
298	tx, err := db.Beginx()
299	if err != nil {
300		return err
301	}
302	defer func() {
303		_ = tx.Rollback()
304	}()
305
306	if version == 0 {
307		if _, err := tx.Exec(sqliteSchema); err != nil {
308			return fmt.Errorf("failed to initialize schema: %v", err)
309		}
310	} else {
311		for i := version; i < len(sqliteMigrations); i++ {
312			if _, err := tx.Exec(sqliteMigrations[i]); err != nil {
313				return fmt.Errorf("failed to execute migration #%v: %v", i, err)
314			}
315		}
316	}
317
318	// For some reason prepared statements don't work here
319	_, err = tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", len(sqliteMigrations)))
320	if err != nil {
321		return fmt.Errorf("failed to bump schema version: %v", err)
322	}
323
324	return tx.Commit()
325}