From 0eb59e6701c22d107b7e3dc2f1cf6fa25f7d6cbf Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 17 Aug 2026 21:38:26 -0400 Subject: [PATCH] fix(bench): stabilize tied recent-post ordering --- .changeset/calm-benchmarks-order.md | 5 +++ server/sqlStore.ts | 5 ++- server/storage.ts | 7 ++-- server/types.ts | 5 ++- test/sqlStore.test.ts | 2 +- test/storeContract.ts | 51 ++++++++++++++++++++++++++++- 6 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 .changeset/calm-benchmarks-order.md diff --git a/.changeset/calm-benchmarks-order.md b/.changeset/calm-benchmarks-order.md new file mode 100644 index 0000000..a9fa9f7 --- /dev/null +++ b/.changeset/calm-benchmarks-order.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Make recent-post ordering deterministic when multiple writes share the same millisecond timestamp, preventing different SQLite versions from selecting different posts at the result limit. diff --git a/server/sqlStore.ts b/server/sqlStore.ts index 20caf6b..b1f0ec9 100644 --- a/server/sqlStore.ts +++ b/server/sqlStore.ts @@ -411,8 +411,11 @@ export class SqlStore implements Store { } async listRecentPosts(limit: number) { + // ISO timestamps only have millisecond precision, so bulk writes frequently + // tie. Make LIMIT membership deterministic across SQLite versions and match + // JsonFileStore: among equal timestamps, the later insertion wins. const rows = this.sql - .exec("SELECT * FROM posts ORDER BY updatedAt DESC LIMIT ?", limit) + .exec("SELECT * FROM posts ORDER BY updatedAt DESC, rowid DESC LIMIT ?", limit) .toArray(); return rows.map((r) => this.rowToPost(r)); } diff --git a/server/storage.ts b/server/storage.ts index a136587..493686b 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -363,10 +363,13 @@ export class JsonFileStore implements Store { async listRecentPosts(limit: number) { await this.load(); + // Decorate with Map insertion order so millisecond timestamp ties have the + // same explicit newest-insertion-first order as SqlStore's rowid fallback. return [...this.surfaces.values()] - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .map((post, insertion) => ({ post, insertion })) + .sort((a, b) => b.post.updatedAt.localeCompare(a.post.updatedAt) || b.insertion - a.insertion) .slice(0, limit) - .map(clone); + .map(({ post }) => clone(post)); } async getPost(id: string) { diff --git a/server/types.ts b/server/types.ts index e87ba14..5ef3ceb 100644 --- a/server/types.ts +++ b/server/types.ts @@ -377,7 +377,10 @@ export interface Store { * omit it; the app falls back to listPosts() for source compatibility. */ countPostsBySession?(): Promise>; - /** The N most-recently-updated posts across all sessions (newest first). */ + /** + * The N most-recently-updated posts across all sessions (newest first). + * Equal millisecond timestamps are ordered by newest insertion first. + */ listRecentPosts(limit: number): Promise; getPost(id: string): Promise; createPost(input: CreatePostInput): Promise; diff --git a/test/sqlStore.test.ts b/test/sqlStore.test.ts index 3c5a6d5..7c9dac3 100644 --- a/test/sqlStore.test.ts +++ b/test/sqlStore.test.ts @@ -70,7 +70,7 @@ test("SqlStore hot queries use their covering or ordering indexes", () => { "sideshow_posts_session_created_at_idx", ); assertUsesIndex( - "SELECT * FROM posts ORDER BY updatedAt DESC LIMIT ?", + "SELECT * FROM posts ORDER BY updatedAt DESC, rowid DESC LIMIT ?", "sideshow_posts_updated_at_idx", 20, ); diff --git a/test/storeContract.ts b/test/storeContract.ts index 129b00f..a5e4eec 100644 --- a/test/storeContract.ts +++ b/test/storeContract.ts @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { HISTORY_LIMIT, htmlSurface, type Store, type Surface } from "../server/types.ts"; +import { + HISTORY_LIMIT, + htmlSurface, + type Post, + type Store, + type Surface, +} from "../server/types.ts"; const bytes = (...values: number[]) => new Uint8Array(values); const NUL = String.fromCharCode(0); @@ -376,6 +382,49 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< }, ); + contract( + "listRecentPosts deterministically limits posts with tied millisecond timestamps", + async (store) => { + const session = await store.createSession({ agent: "pi" }); + const OriginalDate = globalThis.Date; + const fixedMillis = OriginalDate.parse("2026-01-01T00:00:00.000Z"); + const FixedDate = class extends OriginalDate { + constructor() { + super(fixedMillis); + } + + static override now() { + return fixedMillis; + } + }; + const posts: Post[] = []; + + try { + globalThis.Date = FixedDate as DateConstructor; + for (let i = 0; i < 25; i++) { + const post = await store.createPost({ + sessionId: session.id, + title: `tied ${i}`, + surfaces: [htmlSurface(`

${"x".repeat(i)}

`)], + }); + assert.ok(post); + posts.push(post); + } + } finally { + globalThis.Date = OriginalDate; + } + + assert.equal(new Set(posts.map((post) => post.updatedAt)).size, 1); + assert.deepEqual( + (await store.listRecentPosts(20)).map((post) => post.id), + posts + .slice(-20) + .reverse() + .map((post) => post.id), + ); + }, + ); + contract("updates bump the version and archive the previous one", async (store) => { const session = await store.createSession({ agent: "pi" }); const surface = await store.createPost({