Experimental Bluesky client for agents
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268import { afterEach, describe, expect, test } from 'bun:test';import { mkdtempSync, rmSync } from 'node:fs';import { tmpdir } from 'node:os';import { join } from 'node:path';import type { PostNode, Target, ThreadSnapshot } from '../src/model.ts';import { ReadingStore, StoreConflictError, UnknownReferenceError } from '../src/store.ts';
const temporaryDirectories: string[] = [];const source = { service: 'https://offline.invalid', mode: 'public' as const };
function temporaryStorePath(): string { const directory = mkdtempSync(join(tmpdir(), 'perch-state-store-')); temporaryDirectories.push(directory); return join(directory, 'reader.sqlite');}
function threadSnapshot(marker: string, suppliedNodes?: PostNode[]): ThreadSnapshot { const focusUri = `at://did:plc:test/app.bsky.feed.post/${marker}`; const nodes = suppliedNodes ?? [{ uri: focusUri, cid: `cid-${marker}`, availability: 'available' as const, author: { did: 'did:plc:author', handle: 'reader.test', displayName: 'Reader', }, text: `snapshot ${marker}`, createdAt: '2026-09-05T12:00:00.000Z', indexedAt: '2026-09-05T12:00:01.000Z', replies: [], repliesFetched: true, facets: [], media: [], quotes: [], cards: [], labels: [], unsupported: [], counts: {}, }]; return { kind: 'thread', requested: focusUri, focusUri: nodes[0]?.uri ?? focusUri, fetchedAt: '2026-09-05T12:01:00.000Z', source: { ...source }, nodes, ancestors: [], replyOrder: 'source', limits: { depth: 3, parentHeight: 40, maxNodes: 60 }, locallyTruncated: false, warnings: [], };}
afterEach(() => { for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });});
describe('ReadingStore persistence and navigation', () => { test('reopens exact immutable views, returns exact back snapshots, and never reuses view prefixes', () => { const path = temporaryStorePath(); const store = new ReadingStore(path); const mutableInput = threadSnapshot('first'); const first = store.commit('main', mutableInput, store.position('main').revision); const firstSnapshot = first.snapshot; if (firstSnapshot.kind !== 'thread') throw new Error('Expected the committed thread fixture to remain a thread.'); const firstPostReference = Object.entries(first.refs).find(([, target]) => target.value === firstSnapshot.focusUri); expect(firstPostReference).toBeDefined();
mutableInput.nodes[0].text = 'changed after commit'; mutableInput.source.service = 'https://changed.invalid'; expect(firstSnapshot.nodes[0].text).toBe('snapshot first'); expect(firstSnapshot.source.service).toBe('https://offline.invalid'); expect(Object.isFrozen(first)).toBe(true); expect(Object.isFrozen(first.refs)).toBe(true); expect(Object.isFrozen(firstSnapshot.nodes[0])).toBe(true); expect(() => { firstSnapshot.nodes[0].text = 'attempted mutation'; }).toThrow(TypeError);
const second = store.commit('main', threadSnapshot('second'), store.position('main').revision); expect(second.id).not.toBe(first.id); store.close();
const reopened = new ReadingStore(path); expect(reopened.getView(first.id)).toEqual(first); expect(reopened.current('main')).toEqual(second); const beforeBack = reopened.position('main'); const returned = reopened.back('main', beforeBack.revision); expect(returned).toEqual(first); expect(reopened.position('main').session.history).toEqual([]);
const third = reopened.commit('main', threadSnapshot('third'), reopened.position('main').revision); expect([first.id, second.id, third.id]).toEqual(['v1', 'v2', 'v3']); expect(reopened.resolveRef(firstPostReference![0])).toEqual({ view: first, ref: firstPostReference![0], target: firstPostReference![1], }); const secondReference = Object.keys(second.refs)[0]; expect(reopened.resolveRef(secondReference).view).toEqual(second); reopened.close(); });
test('forks copied history while future movement remains independent', () => { const store = new ReadingStore(temporaryStorePath()); const first = store.commit('main', threadSnapshot('one'), store.position('main').revision); const second = store.commit('main', threadSnapshot('two'), store.position('main').revision); const third = store.commit('main', threadSnapshot('three'), store.position('main').revision);
const forked = store.fork('main', 'research'); expect(forked.session).toEqual({ name: 'research', viewId: third.id, history: [first.id, second.id] }); expect(store.position('main').session.history).toEqual([first.id, second.id]);
expect(store.back('research', forked.revision)).toEqual(second); expect(store.position('research').session).toEqual({ name: 'research', viewId: second.id, history: [first.id] }); expect(store.position('main').session).toEqual({ name: 'main', viewId: third.id, history: [first.id, second.id] });
const fourth = store.commit('main', threadSnapshot('four'), store.position('main').revision); const fifth = store.commit('research', threadSnapshot('five'), store.position('research').revision); expect(store.position('main').session).toEqual({ name: 'main', viewId: fourth.id, history: [first.id, second.id, third.id], }); expect(store.position('research').session).toEqual({ name: 'research', viewId: fifth.id, history: [first.id, second.id], }); store.close(); });
test('references exact target identities and edge-only parent, root, and reply posts', () => { const focusUri = 'at://did:plc:focus/app.bsky.feed.post/focus'; const hydratedReplyUri = 'at://did:plc:reply/app.bsky.feed.post/hydrated'; const parentUri = 'at://did:plc:parent/app.bsky.feed.post/omitted'; const rootUri = 'at://did:plc:root/app.bsky.feed.post/omitted'; const omittedReplyUri = 'at://did:plc:reply/app.bsky.feed.post/omitted'; const repeatedQuoteUri = 'at://did:plc:quoted/app.bsky.feed.post/same'; const linkTarget: Target = { kind: 'link', value: 'https://example.invalid/exact?x=1#fragment', label: 'Exact link', ownerUri: focusUri, index: 7, }; const focus: PostNode = { uri: focusUri, availability: 'available', author: { did: 'did:plc:focus', handle: 'focus.test', displayName: 'Focus' }, text: 'focus', parentUri, rootUri, replies: [hydratedReplyUri, omittedReplyUri], repliesFetched: true, facets: [{ byteStart: 0, byteEnd: 5, text: 'focus', targets: [linkTarget], unsupported: [] }], media: [{ kind: 'image', url: 'https://cdn.invalid/image.png', alt: 'exact alt' }], quotes: [repeatedQuoteUri], cards: [{ uri: 'https://card.invalid/story', title: 'Story', description: 'Description' }], labels: [], unsupported: [], counts: {}, }; const hydratedReply: PostNode = { uri: hydratedReplyUri, availability: 'available', author: { did: 'did:plc:reply', handle: 'reply.test', displayName: 'Reply' }, text: 'reply', replies: [], repliesFetched: true, facets: [], media: [], quotes: [repeatedQuoteUri], cards: [], labels: [], unsupported: [], counts: {}, }; const snapshot = threadSnapshot('references', [focus, hydratedReply]); snapshot.locallyTruncated = true; const store = new ReadingStore(temporaryStorePath()); const view = store.commit('main', snapshot, store.position('main').revision);
expect(view.snapshot).toEqual(snapshot); const postTargets = Object.values(view.refs).filter((target) => target.kind === 'post'); for (const uri of [focusUri, hydratedReplyUri, parentUri, rootUri, omittedReplyUri]) { expect(postTargets.filter((target) => target.value === uri)).toHaveLength(1); }
const expectedTargets: Target[] = [ linkTarget, { kind: 'post', value: repeatedQuoteUri, label: 'Quoted post', ownerUri: focusUri, index: 0 }, { kind: 'post', value: repeatedQuoteUri, label: 'Quoted post', ownerUri: hydratedReplyUri, index: 0 }, { kind: 'link', value: 'https://card.invalid/story', label: 'Story', ownerUri: focusUri, index: 0 }, { kind: 'media', value: 'https://cdn.invalid/image.png', label: 'Image media', ownerUri: focusUri, index: 0 }, ]; for (const expectedTarget of expectedTargets) { const entry = Object.entries(view.refs).find(([, target]) => JSON.stringify(target) === JSON.stringify(expectedTarget)); expect(entry).toBeDefined(); expect(store.resolveRef(entry![0]).target).toEqual(expectedTarget); } expect(Object.values(view.refs).filter((target) => target.value === repeatedQuoteUri)).toHaveLength(2); store.close(); });
test('rejects overlapping revisions transactionally without consuming a view id', () => { const path = temporaryStorePath(); const firstHandle = new ReadingStore(path); const secondHandle = new ReadingStore(path); const firstBaseline = firstHandle.position('shared'); const staleBaseline = secondHandle.position('shared');
const winner = firstHandle.commit('shared', threadSnapshot('winner'), firstBaseline.revision); expect(winner.id).toBe('v1'); expect(() => secondHandle.commit('shared', threadSnapshot('stale'), staleBaseline.revision)) .toThrow(StoreConflictError); expect(secondHandle.position('shared')).toEqual({ session: { name: 'shared', viewId: winner.id, history: [] }, revision: 1, view: winner, });
const alpha = firstHandle.commit('alpha', threadSnapshot('alpha'), firstHandle.position('alpha').revision); const beta = secondHandle.commit('beta', threadSnapshot('beta'), secondHandle.position('beta').revision); expect([alpha.id, beta.id]).toEqual(['v2', 'v3']); expect(firstHandle.getView('v4')).toBeNull(); expect(firstHandle.current('beta')).toEqual(beta); expect(secondHandle.current('alpha')).toEqual(alpha); expect(firstHandle.position('shared').session.history).toEqual([]); firstHandle.close(); secondHandle.close();
const reopened = new ReadingStore(path); expect(reopened.sessions()).toEqual([ { name: 'alpha', viewId: alpha.id, history: [] }, { name: 'beta', viewId: beta.id, history: [] }, { name: 'shared', viewId: winner.id, history: [] }, ]); reopened.close(); });
test('invalid references, names, and forks leave every history unchanged', () => { const store = new ReadingStore(temporaryStorePath()); const first = store.commit('main', threadSnapshot('first'), store.position('main').revision); const second = store.commit('main', threadSnapshot('second'), store.position('main').revision); store.fork('main', 'copy'); const baseline = store.sessions();
for (const reference of ['garbage', 'v0-p1', 'v999-p1', `${first.id}-missing99`]) { expect(() => store.resolveRef(reference)).toThrow(UnknownReferenceError); } for (const invalidName of ['', ' padded', 'padded ', 'line\nbreak']) { expect(() => store.position(invalidName)).toThrow('Session names must be 1-128 characters'); expect(() => store.fork('main', invalidName)).toThrow('Session names must be 1-128 characters'); } expect(() => store.fork('missing', 'destination')).toThrow('Unknown session: missing'); expect(() => store.fork('main', 'main')).toThrow('different session name'); expect(() => store.fork('main', 'copy')).toThrow('already exists');
expect(store.sessions()).toEqual(baseline); expect(store.current('main')).toEqual(second); expect(store.position('main').session.history).toEqual([first.id]); store.close(); });});