import assert from 'node:assert/strict'; import { test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; import { IndexModel, type IndexEvent } from '../src/index/model.ts'; import { sqliteSql } from './sqlite-sql.ts'; const NOW = Date.parse('2026-11-06T00:00:00.000Z'); const project1Uri = 'at://did:plc:alice/dev.nibling.project/3la1'; const project2Uri = 'at://did:plc:dave/dev.nibling.project/3la2'; const like1Uri = 'at://did:plc:bob/dev.nibling.feed.like/3lb1'; const like2Uri = 'at://did:plc:carol/dev.nibling.feed.like/3lb2'; const profileUri = 'at://did:plc:erin/dev.nibling.actor.profile/self'; function fixtureEvents(): IndexEvent[] { return [ { action: 'put', collection: 'dev.nibling.project', uri: project1Uri, cid: 'bafy-project1', did: 'did:plc:alice', record: { name: 'Alice Project', createdAt: '2026-10-20T00:00:00.000Z' }, indexedAt: '2026-10-20T00:00:05.000Z', }, { action: 'put', collection: 'dev.nibling.feed.like', uri: like1Uri, cid: 'bafy-like1', did: 'did:plc:bob', record: { subject: { uri: project1Uri, cid: 'bafy-project1' }, createdAt: '2026-11-06T00:00:00.000Z' }, indexedAt: '2026-11-06T00:00:01.000Z', }, { action: 'put', collection: 'dev.nibling.feed.like', uri: like2Uri, cid: 'bafy-like2', did: 'did:plc:carol', record: { subject: { uri: project1Uri, cid: 'bafy-project1' }, createdAt: '2026-11-02T12:00:00.000Z' }, indexedAt: '2026-11-02T12:00:01.000Z', }, { action: 'put', collection: 'dev.nibling.project', uri: project2Uri, cid: 'bafy-project2', did: 'did:plc:dave', record: { name: 'Dave Fork', createdAt: '2026-11-06T00:00:00.000Z', forkOf: { uri: project1Uri, cid: 'bafy-project1' }, }, indexedAt: '2026-11-06T00:00:05.000Z', }, { action: 'put', collection: 'dev.nibling.actor.profile', uri: profileUri, cid: 'bafy-profile', did: 'did:plc:erin', record: { createdAt: '2026-11-01T00:00:00.000Z' }, }, ]; } test('apply + recent: newest first, exact counts', () => { const model = new IndexModel(sqliteSql()); for (const event of fixtureEvents()) model.apply(event); const { projects } = model.recent(); assert.deepStrictEqual( projects.map((p) => ({ uri: p.uri, name: p.name, likeCount: p.likeCount, forkCount: p.forkCount })), [ { uri: project2Uri, name: 'Dave Fork', likeCount: 0, forkCount: 0 }, { uri: project1Uri, name: 'Alice Project', likeCount: 2, forkCount: 1 }, ], ); const project2View = projects[0]!; assert.equal(project2View.forkOf, project1Uri); }); test('recent: pagination cursor returns the remaining page exactly once', () => { const model = new IndexModel(sqliteSql()); for (const event of fixtureEvents()) model.apply(event); const first = model.recent({ limit: 1 }); assert.equal(first.projects.length, 1); assert.equal(first.projects[0]!.uri, project2Uri); assert.ok(first.cursor); const second = model.recent({ limit: 1, cursor: first.cursor }); assert.deepStrictEqual(second.projects.map((p) => p.uri), [project1Uri]); assert.equal(second.cursor, undefined); }); test('trending: ranks by decayed likes + forks within the 7-day window', () => { const model = new IndexModel(sqliteSql()); for (const event of fixtureEvents()) model.apply(event); const { projects } = model.trending({ now: NOW }); assert.deepStrictEqual(projects.map((p) => p.uri), [project1Uri, project2Uri]); }); test('apply is idempotent: replaying the same events changes nothing', () => { const model = new IndexModel(sqliteSql()); const events = fixtureEvents(); for (const event of events) model.apply(event); const before = model.recent(); for (const event of events) model.apply(event); const after = model.recent(); assert.deepStrictEqual(after, before); }); test('delete removes the project from recent', () => { const model = new IndexModel(sqliteSql()); for (const event of fixtureEvents()) model.apply(event); model.apply({ action: 'delete', collection: 'dev.nibling.project', uri: project2Uri }); const { projects } = model.recent(); assert.deepStrictEqual(projects.map((p) => p.uri), [project1Uri]); assert.equal(projects[0]!.forkCount, 0); }); test('a like on a not-yet-indexed project still counts once the project appears', () => { const model = new IndexModel(sqliteSql()); model.apply(fixtureEvents()[1]!); model.apply(fixtureEvents()[0]!); const { projects } = model.recent(); assert.equal(projects[0]!.likeCount, 1); }); test('a deleted like decrements the count', () => { const model = new IndexModel(sqliteSql()); for (const event of fixtureEvents()) model.apply(event); model.apply({ action: 'delete', collection: 'dev.nibling.feed.like', uri: like1Uri }); const { projects } = model.recent(); const project1 = projects.find((p) => p.uri === project1Uri)!; assert.equal(project1.likeCount, 1); }); test('known returns exactly the DIDs with a project or profile record', () => { const model = new IndexModel(sqliteSql()); for (const event of fixtureEvents()) model.apply(event); const known = model.known(['did:plc:alice', 'did:plc:bob', 'did:plc:dave', 'did:plc:erin', 'did:plc:frank']); assert.deepStrictEqual(known, ['did:plc:alice', 'did:plc:dave', 'did:plc:erin']); }); test('cursor round-trips as time_us', () => { const model = new IndexModel(sqliteSql()); assert.equal(model.cursor(), undefined); model.setCursor(1762387200000000); assert.equal(model.cursor(), 1762387200000000); model.setCursor(1762400000123456); assert.equal(model.cursor(), 1762400000123456); }); test('pruneRepo deletes only that repo\'s rows missing from the kept set', () => { const model = new IndexModel(sqliteSql()); for (const event of fixtureEvents()) model.apply(event); model.pruneRepo('dev.nibling.feed.like', 'did:plc:bob', new Set()); model.pruneRepo('dev.nibling.project', 'did:plc:alice', new Set([project1Uri])); model.pruneRepo('dev.nibling.actor.profile', 'did:plc:erin', new Set()); const project1 = model.recent().projects.find((p) => p.uri === project1Uri)!; assert.equal(project1.likeCount, 1); assert.deepStrictEqual(model.known(['did:plc:alice', 'did:plc:erin']), ['did:plc:alice']); }); test('known answers for 100 DIDs within the Durable Object bound-parameter limit', () => { const model = new IndexModel(sqliteSql()); for (const event of fixtureEvents()) model.apply(event); const dids = Array.from({ length: 98 }, (_, i) => `did:plc:stranger${i}`); dids.splice(10, 0, 'did:plc:alice'); dids.push('did:plc:erin'); assert.equal(dids.length, 100); assert.deepStrictEqual(model.known(dids), ['did:plc:alice', 'did:plc:erin']); }); function project( rkey: string, times: { createdAt?: string; updatedAt?: string; indexedAt?: string } = {}, ): IndexEvent { const record = { name: rkey, createdAt: times.createdAt ?? '2026-10-01T00:00:00.000Z' }; return { action: 'put', collection: 'dev.nibling.project', uri: `at://did:plc:owner/dev.nibling.project/${rkey}`, cid: `bafy-${rkey}`, did: 'did:plc:owner', record: times.updatedAt ? { ...record, updatedAt: times.updatedAt } : record, indexedAt: times.indexedAt ?? '2026-10-01T00:00:00.000Z', }; } function like(liker: string, rkey: string, subject: string, createdAt: string, indexedAt: string): IndexEvent { return { action: 'put', collection: 'dev.nibling.feed.like', uri: `at://${liker}/dev.nibling.feed.like/${rkey}`, cid: `bafy-${rkey}`, did: liker, record: { subject: { uri: `at://did:plc:owner/dev.nibling.project/${subject}`, cid: `bafy-${subject}` }, createdAt }, indexedAt, }; } test('trending: a fresh like outranks a 6-day-old one, and a future createdAt counts from when it was seen', () => { const model = new IndexModel(sqliteSql()); for (const rkey of ['fresh', 'old', 'future']) model.apply(project(rkey)); model.apply(like('did:plc:a', 'l1', 'fresh', '2026-11-05T23:00:00.000Z', '2026-11-05T23:00:01.000Z')); model.apply(like('did:plc:b', 'l2', 'old', '2026-10-31T00:00:00.000Z', '2026-10-31T00:00:01.000Z')); model.apply(like('did:plc:c', 'l3', 'future', '2030-01-01T00:00:00.000Z', '2026-11-03T00:00:00.000Z')); const { projects } = model.trending({ now: NOW }); assert.deepStrictEqual(projects.map((p) => p.name), ['fresh', 'future', 'old']); }); test('recent: a far-future updatedAt sorts by when the index saw it, not where the author put it', () => { const model = new IndexModel(sqliteSql()); model.apply(project('pinned', { createdAt: '2026-11-01T00:00:00.000Z', updatedAt: '9999-12-31T00:00:00.000Z', indexedAt: '2026-11-02T00:00:00.000Z' })); model.apply(project('honest', { createdAt: '2026-11-03T00:00:00.000Z', indexedAt: '2026-11-03T00:00:01.000Z' })); const { projects } = model.recent(); assert.deepStrictEqual(projects.map((p) => [p.name, p.updatedAt]), [ ['honest', '2026-11-03T00:00:00.000Z'], ['pinned', '2026-11-02T00:00:00.000Z'], ]); }); test('likeCount and forkCount count distinct accounts, not records', () => { const model = new IndexModel(sqliteSql()); model.apply(project('target')); for (const rkey of ['l1', 'l2', 'l3']) { model.apply(like('did:plc:spam', rkey, 'target', '2026-11-05T00:00:00.000Z', '2026-11-05T00:00:01.000Z')); } model.apply(like('did:plc:fan', 'l4', 'target', '2026-11-05T00:00:00.000Z', '2026-11-05T00:00:01.000Z')); for (const rkey of ['f1', 'f2']) { model.apply({ action: 'put', collection: 'dev.nibling.project', uri: `at://did:plc:spam/dev.nibling.project/${rkey}`, cid: `bafy-${rkey}`, did: 'did:plc:spam', record: { name: rkey, createdAt: '2026-11-05T00:00:00.000Z', forkOf: { uri: 'at://did:plc:owner/dev.nibling.project/target', cid: 'bafy-target' } }, indexedAt: '2026-11-05T00:00:01.000Z', }); } const target = model.recent().projects.find((p) => p.name === 'target')!; assert.deepStrictEqual({ likeCount: target.likeCount, forkCount: target.forkCount }, { likeCount: 2, forkCount: 1 }); }); test('a project put with a slug appears in recent with that slug', () => { const model = new IndexModel(sqliteSql()); model.apply({ action: 'put', collection: 'dev.nibling.project', uri: 'at://did:plc:owner/dev.nibling.project/3la1', cid: 'bafy-ball', did: 'did:plc:owner', record: { name: 'Bouncing Ball', slug: 'bouncing-ball', createdAt: '2026-11-01T00:00:00.000Z' }, indexedAt: '2026-11-01T00:00:00.000Z', }); const { projects } = model.recent(); assert.equal(projects[0]!.slug, 'bouncing-ball'); }); test('a model built over a pre-slug projects table migrates the column and then stores a slug', () => { const db = new DatabaseSync(':memory:'); db.exec(`CREATE TABLE IF NOT EXISTS projects ( uri TEXT PRIMARY KEY, cid TEXT NOT NULL, did TEXT NOT NULL, name TEXT NOT NULL, description TEXT, forkOf TEXT, createdAt TEXT NOT NULL, updatedAt TEXT, sortAt TEXT NOT NULL, indexedAt TEXT NOT NULL, createdMs INTEGER NOT NULL )`); const model = new IndexModel(sqliteSql(db)); // Constructing again over the now-migrated table must stay a no-op. new IndexModel(sqliteSql(db)); model.apply({ action: 'put', collection: 'dev.nibling.project', uri: 'at://did:plc:owner/dev.nibling.project/3la1', cid: 'bafy-ball', did: 'did:plc:owner', record: { name: 'Bouncing Ball', slug: 'bouncing-ball', createdAt: '2026-11-01T00:00:00.000Z' }, indexedAt: '2026-11-01T00:00:00.000Z', }); const { projects } = model.recent(); assert.equal(projects[0]!.slug, 'bouncing-ball'); }); test('trending: one account liking many times counts once', () => { const model = new IndexModel(sqliteSql()); model.apply(project('spammed')); model.apply(project('liked')); for (const rkey of ['l1', 'l2', 'l3', 'l4', 'l5']) { model.apply(like('did:plc:spam', rkey, 'spammed', '2026-11-05T00:00:00.000Z', '2026-11-05T00:00:01.000Z')); } model.apply(like('did:plc:a', 'l6', 'liked', '2026-11-05T00:00:00.000Z', '2026-11-05T00:00:01.000Z')); model.apply(like('did:plc:b', 'l7', 'liked', '2026-11-05T00:00:00.000Z', '2026-11-05T00:00:01.000Z')); assert.deepStrictEqual(model.trending({ now: NOW }).projects.map((p) => p.name), ['liked', 'spammed']); });