From a3655ca06ce443b22f06f1030284deee2c2cf345 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 6 Nov 2026 01:35:37 -0500 Subject: [PATCH] Keep getKnown under Durable Object SQLite's 100-parameter limit known() bound each DID twice in one statement, so 50 or more DIDs threw. It now queries in chunks of 50. The router rejects more than the lexicon's 100 DIDs with a 400, and the test SQL harness enforces the 100-parameter limit for every model test. --- packages/api/src/index/model.ts | 24 ++++++++++++++---------- packages/api/src/router.ts | 4 ++++ packages/api/tests/model.test.ts | 10 ++++++++++ packages/api/tests/router.test.ts | 14 ++++++++++++++ packages/api/tests/sqlite-sql.ts | 7 +++++++ 5 files changed, 49 insertions(+), 10 deletions(-) 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[]; }, }; -- knotmirror 0.0.1