diff --git a/packages/api/src/index/model.ts b/packages/api/src/index/model.ts index 2af7f4b..20d970f 100644 --- a/packages/api/src/index/model.ts +++ b/packages/api/src/index/model.ts @@ -74,6 +74,8 @@ export interface TrendingOptions { type ProjectView = RunNiblingFeedDefs.ProjectView; +const KNOWN_CHUNK = 50; + // One statement per array entry: `Sql.exec` runs exactly one statement per call (that's what // `ctx.storage.sql.exec` does in the DO), so the schema can't be a single multi-statement string. const SCHEMA = [ @@ -317,17 +319,19 @@ export class IndexModel { } known(dids: readonly string[]): string[] { - if (dids.length === 0) return []; - const placeholders = dids.map(() => '?').join(', '); - const rows = this.sql.exec<{ did: string }>( - `SELECT DISTINCT did FROM ( - SELECT did FROM projects WHERE did IN (${placeholders}) + const found = new Set(); + // Each DID binds twice, and Durable Object SQLite caps a query at 100 bound parameters. + for (let i = 0; i < dids.length; i += KNOWN_CHUNK) { + const chunk = dids.slice(i, i + KNOWN_CHUNK); + const placeholders = chunk.map(() => '?').join(', '); + const rows = this.sql.exec<{ did: string }>( + `SELECT did FROM projects WHERE did IN (${placeholders}) UNION - SELECT did FROM profiles WHERE did IN (${placeholders}) - )`, - ...dids, ...dids, - ); - const found = new Set(rows.map((r) => r.did)); + SELECT did FROM profiles WHERE did IN (${placeholders})`, + ...chunk, ...chunk, + ); + for (const row of rows) found.add(row.did); + } return dids.filter((did) => found.has(did)); } diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index 74353d5..1476317 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -24,6 +24,9 @@ const XRPC_HEADERS = { 'cache-control': 'public, max-age=30', }; +/** `run.nibling.actor.getKnown`'s `dids` has `maxLength: 100` in the lexicon. */ +const MAX_KNOWN_DIDS = 100; + function xrpcJson(body: unknown): Response { return new Response(JSON.stringify(body), { headers: XRPC_HEADERS }); } @@ -104,6 +107,7 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise v.split(',')); if (dids.length === 0) return xrpcError('InvalidRequest', 'dids is required', 400); + if (dids.length > MAX_KNOWN_DIDS) return xrpcError('InvalidRequest', `dids accepts at most ${MAX_KNOWN_DIDS}`, 400); return xrpcJson({ dids: await stub.getKnown(dids) }); } diff --git a/packages/api/tests/model.test.ts b/packages/api/tests/model.test.ts index 7ae64a1..6369956 100644 --- a/packages/api/tests/model.test.ts +++ b/packages/api/tests/model.test.ts @@ -178,3 +178,13 @@ test('pruneRepo deletes only that repo\'s rows missing from the kept set', () => 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']); +}); diff --git a/packages/api/tests/router.test.ts b/packages/api/tests/router.test.ts index 89f2255..f2a42c5 100644 --- a/packages/api/tests/router.test.ts +++ b/packages/api/tests/router.test.ts @@ -136,3 +136,17 @@ test('/oembed with a malformed url is a 400, not a 500', async () => { assert.equal(res.status, 400); assert.deepStrictEqual(await res.json(), { error: 'InvalidRequest', message: 'url must point at a project page' }); }); + +test('getKnown with more than 100 DIDs is a 400', async () => { + const dids = Array.from({ length: 101 }, (_, i) => `did:plc:user${i}`).join(','); + const res = await handleRequest(new Request(`https://api.nibling.run/xrpc/run.nibling.actor.getKnown?dids=${dids}`), fakeEnv()); + assert.equal(res.status, 400); + assert.deepStrictEqual(await res.json(), { error: 'InvalidRequest', message: 'dids accepts at most 100' }); +}); + +test('getKnown with exactly 100 DIDs is answered', async () => { + const dids = ['did:plc:alice', ...Array.from({ length: 99 }, (_, i) => `did:plc:user${i}`)].join(','); + const res = await handleRequest(new Request(`https://api.nibling.run/xrpc/run.nibling.actor.getKnown?dids=${dids}`), fakeEnv()); + assert.equal(res.status, 200); + assert.deepStrictEqual(await res.json(), { dids: ['did:plc:alice'] }); +}); diff --git a/packages/api/tests/sqlite-sql.ts b/packages/api/tests/sqlite-sql.ts index 1029f6a..195b444 100644 --- a/packages/api/tests/sqlite-sql.ts +++ b/packages/api/tests/sqlite-sql.ts @@ -2,11 +2,18 @@ import { DatabaseSync } from 'node:sqlite'; import type { Sql } from '../src/index/model.ts'; +/** Durable Object SQLite allows at most 100 bound parameters per query + * (developers.cloudflare.com/durable-objects/platform/limits/); node:sqlite allows far more. */ +export const DO_MAX_BOUND_PARAMETERS = 100; + /** Implements the model's `Sql` interface over `node:sqlite`, the way the spec asks for in * tests. The DO implements the same interface over `ctx.storage.sql.exec(...).toArray()`. */ export function sqliteSql(db: DatabaseSync = new DatabaseSync(':memory:')): Sql { return { exec>(query: string, ...bindings: unknown[]): T[] { + if (bindings.length > DO_MAX_BOUND_PARAMETERS) { + throw new Error(`${bindings.length} bound parameters; Durable Object SQLite allows ${DO_MAX_BOUND_PARAMETERS}`); + } return db.prepare(query).all(...(bindings as never[])) as T[]; }, };