diff --git a/.changeset/quick-sqlite-paths.md b/.changeset/quick-sqlite-paths.md new file mode 100644 index 0000000..a7cf207 --- /dev/null +++ b/.changeset/quick-sqlite-paths.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Speed up SQLite-backed workspaces by indexing session posts, recent posts, comment lookups, and session assets. diff --git a/server/sqlStore.ts b/server/sqlStore.ts index 9cf8b88..2238131 100644 --- a/server/sqlStore.ts +++ b/server/sqlStore.ts @@ -88,6 +88,28 @@ export class SqlStore implements Store { this.migrateToSurfaces(); this.migrateToPosts(); this.migrateSurfaceIds(); + this.createIndexes(); + } + + // Add indexes after the column/table migrations above: older workspaces may + // still call comments' post columns snippetId/surfaceId when the base schema + // is first opened. IF NOT EXISTS makes this an in-place, idempotent migration + // for deployed Durable Objects as well as local SQLite databases. + private createIndexes() { + this.sql.exec(` + CREATE INDEX IF NOT EXISTS sideshow_posts_session_created_at_idx + ON posts (sessionId, createdAt); + CREATE INDEX IF NOT EXISTS sideshow_posts_updated_at_idx + ON posts (updatedAt DESC); + CREATE INDEX IF NOT EXISTS sideshow_comments_session_seq_idx + ON comments (sessionId, seq); + CREATE INDEX IF NOT EXISTS sideshow_comments_post_seq_idx + ON comments (postId, seq); + CREATE INDEX IF NOT EXISTS sideshow_comments_id_idx + ON comments (id); + CREATE INDEX IF NOT EXISTS sideshow_assets_session_idx + ON assets (sessionId); + `); } // Pre-0.5.0 workspaces stored a `snippets` table and `comments.snippetId`. Lift diff --git a/test/sqlStore.test.ts b/test/sqlStore.test.ts index 25ed552..3c5a6d5 100644 --- a/test/sqlStore.test.ts +++ b/test/sqlStore.test.ts @@ -10,6 +10,90 @@ import { runStoreContract } from "./storeContract.ts"; // real Node SQLite path rather than a bespoke shim. runStoreContract("SqlStore", () => new SqlStore(createSqliteStorage())); +const hotPathIndexes = { + sideshow_assets_session_idx: ["sessionId"], + sideshow_comments_id_idx: ["id"], + sideshow_comments_post_seq_idx: ["postId", "seq"], + sideshow_comments_session_seq_idx: ["sessionId", "seq"], + sideshow_posts_session_created_at_idx: ["sessionId", "createdAt"], + sideshow_posts_updated_at_idx: ["updatedAt"], +} as const; + +test("SqlStore adds hot-path indexes to existing workspaces idempotently", () => { + const storage = createSqliteStorage(); + new SqlStore(storage); + + // Model a database created by an older release, before these indexes existed. + for (const name of Object.keys(hotPathIndexes)) storage.exec(`DROP INDEX ${name}`); + + new SqlStore(storage); + new SqlStore(storage); + + const indexes = storage + .exec( + "SELECT name, tbl_name FROM sqlite_master WHERE type = 'index' AND name LIKE 'sideshow_%' ORDER BY name", + ) + .toArray(); + assert.deepEqual( + indexes.map((row) => row.name), + Object.keys(hotPathIndexes), + ); + for (const [name, columns] of Object.entries(hotPathIndexes)) { + const actual = storage + .exec(`SELECT name FROM pragma_index_info('${name}') ORDER BY seqno`) + .toArray() + .map((row) => row.name); + assert.deepEqual(actual, columns, `${name} column order`); + } +}); + +test("SqlStore hot queries use their covering or ordering indexes", () => { + const storage = createSqliteStorage(); + new SqlStore(storage); + + const assertUsesIndex = (query: string, index: string, ...bindings: (string | number)[]) => { + const plan = storage + .exec(`EXPLAIN QUERY PLAN ${query}`, ...bindings) + .toArray() + .map((row) => row.detail) + .join("\n"); + assert.match(plan, new RegExp(`\\b${index}\\b`), `${query}\n${plan}`); + }; + + assertUsesIndex( + "SELECT * FROM posts WHERE sessionId = ? ORDER BY createdAt ASC", + "sideshow_posts_session_created_at_idx", + "session", + ); + assertUsesIndex( + "SELECT sessionId, COUNT(*) AS count FROM posts GROUP BY sessionId", + "sideshow_posts_session_created_at_idx", + ); + assertUsesIndex( + "SELECT * FROM posts ORDER BY updatedAt DESC LIMIT ?", + "sideshow_posts_updated_at_idx", + 20, + ); + assertUsesIndex( + "SELECT * FROM comments WHERE sessionId = ? AND seq > ? ORDER BY seq ASC", + "sideshow_comments_session_seq_idx", + "session", + 10, + ); + assertUsesIndex( + "SELECT * FROM comments WHERE postId = ? AND seq > ? ORDER BY seq ASC", + "sideshow_comments_post_seq_idx", + "post", + 10, + ); + assertUsesIndex("SELECT * FROM comments WHERE id = ?", "sideshow_comments_id_idx", "comment"); + assertUsesIndex( + "SELECT * FROM assets WHERE sessionId = ?", + "sideshow_assets_session_idx", + "session", + ); +}); + test("SqlStore counts posts with one aggregate query and never selects body columns", async () => { const storage = createSqliteStorage(); const queries: string[] = [];