[api] fix a few 2023 compat issues

This commit is contained in:
Devin Zuczek
2026-09-15 11:22:33 -04:00
parent 3299f05940
commit 71bb29b8c7
14 changed files with 360 additions and 130 deletions
+15 -7
View File
@@ -11,6 +11,8 @@
* it from `@repo/domain` (each uses the subset it needs).
*/
import { bindPlaceholders, chunkForBinds } from './d1-binds'
/**
* Schema DDL — the head schema, i.e. what the table looks like after every migration
* (0001_accounts + 0002_avatar + 0009_account_has_plus, sans seed INSERTs; 0004 added a
@@ -349,15 +351,21 @@ export async function countAccountsBySignupIp(db: D1Database, ip: string): Promi
return row?.n ?? 0
}
/** Look up multiple accounts by AccountId (order not guaranteed). */
/**
* Look up multiple accounts by AccountId (order not guaranteed). A list longer than
* {@link MAX_BOUND_PARAMS} is split across statements and the rows concatenated.
*/
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
const { results } = await db
.prepare(`SELECT data FROM account WHERE account_id IN (${placeholders})`)
.bind(...ids)
.all<AccountRow>()
return parseAll(results)
const pages = await Promise.all(
chunkForBinds(ids).map((chunk) =>
db
.prepare(`SELECT data FROM account WHERE account_id IN (${bindPlaceholders(chunk)})`)
.bind(...chunk)
.all<AccountRow>()
)
)
return parseAll(pages.flatMap((page) => page.results))
}
/**
+27
View File
@@ -0,0 +1,27 @@
/**
* D1 caps a prepared statement at 100 bound parameters — binding more fails outright with
* "variable number must be between ?1 and ?100". Every `IN (…)` list built from a caller's
* array has to respect this, which is easy to miss: the id list behind a bulk lookup (a
* friends list, a room's subrooms) stays under a hundred right up until it meets a real
* account, and then the whole endpoint 500s rather than degrading.
*/
export const MAX_BOUND_PARAMS = 100
/**
* Split values into chunks that fit {@link MAX_BOUND_PARAMS}, one statement each. `reserve`
* is how many binds the statement spends on something other than the list — a `now` cutoff,
* a player id — which come out of the same budget.
*/
export function chunkForBinds<T>(values: T[], reserve = 0): T[][] {
const perChunk = MAX_BOUND_PARAMS - reserve
const chunks: T[][] = []
for (let i = 0; i < values.length; i += perChunk) {
chunks.push(values.slice(i, i + perChunk))
}
return chunks
}
/** The `?1,?2,…` placeholder list for one chunk, offset past any binds reserved ahead of it. */
export function bindPlaceholders(chunk: unknown[], offset = 0): string {
return chunk.map((_, i) => `?${i + 1 + offset}`).join(', ')
}
+1
View File
@@ -8,6 +8,7 @@ export {
} from './enums'
export * from './accounts-db'
export * from './clubs-db'
export * from './d1-binds'
export * from './images-db'
export * from './password'
export * from './rooms-db'
+16 -9
View File
@@ -21,6 +21,8 @@
* {@link deleteExpiredPresence} purges them.
*/
import { bindPlaceholders, chunkForBinds } from './d1-binds'
/** Presence is kept this long (s) after the last matchmake/heartbeat refresh. */
export const PRESENCE_TTL_SECONDS = 900
@@ -136,7 +138,7 @@ export async function getPresence<TRoomInstance>(
}
/**
* Read many players' live presence in one query, keyed by account id (absent or
* Read many players' live presence in as few queries as D1 allows, keyed by account id (absent or
* expired players are simply missing from the map). Replaces the N point reads the
* batch `/player?id=…` lookup did against KV.
*/
@@ -147,15 +149,20 @@ export async function getPresences<TRoomInstance>(
): Promise<Map<number, StoredPresence<TRoomInstance>>> {
const out = new Map<number, StoredPresence<TRoomInstance>>()
if (accountIds.length === 0) return out
const placeholders = accountIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(
`SELECT data FROM presence
WHERE account_id IN (${placeholders}) AND expires_at > ?${accountIds.length + 1}`
// The expiry cutoff spends one of the statement's binds, so each chunk of ids runs one
// short of the cap. A friends list is routinely longer than that; unchunked, it 500s.
const pages = await Promise.all(
chunkForBinds(accountIds, 1).map((chunk) =>
db
.prepare(
`SELECT data FROM presence
WHERE account_id IN (${bindPlaceholders(chunk)}) AND expires_at > ?${chunk.length + 1}`
)
.bind(...chunk, now)
.all<{ data: string }>()
)
.bind(...accountIds, now)
.all<{ data: string }>()
for (const r of results) {
)
for (const r of pages.flatMap((page) => page.results)) {
const p = JSON.parse(r.data) as StoredPresence<TRoomInstance>
out.set(p.accountId, p)
}
+15 -6
View File
@@ -18,6 +18,8 @@
* the writer.
*/
import { bindPlaceholders, chunkForBinds } from './d1-binds'
/** Schema DDL (mirror of apps/econ/migrations/0012_progression.sql). */
export const PROGRESSION_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS progression (
@@ -211,12 +213,19 @@ export async function getProgressions(
accountIds: number[]
): Promise<Progression[]> {
if (accountIds.length === 0) return []
const placeholders = accountIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT account_id, level, xp FROM progression WHERE account_id IN (${placeholders})`)
.bind(...accountIds)
.all<{ account_id: number; level: number; xp: number }>()
const stored = new Map(results.map((r) => [r.account_id, r]))
// Chunked: the friends list behind `/api/players/v2/progression/bulk` runs past the
// bind cap, and an unchunked statement fails the whole lookup rather than part of it.
const pages = await Promise.all(
chunkForBinds(accountIds).map((chunk) =>
db
.prepare(
`SELECT account_id, level, xp FROM progression WHERE account_id IN (${bindPlaceholders(chunk)})`
)
.bind(...chunk)
.all<{ account_id: number; level: number; xp: number }>()
)
)
const stored = new Map(pages.flatMap((page) => page.results).map((r) => [r.account_id, r]))
return accountIds.map((id) => {
const row = stored.get(id)
return row === undefined
+1 -21
View File
@@ -17,6 +17,7 @@
* database read-only and uses `getRoomById`. Each imports the subset it needs.
*/
import { bindPlaceholders, chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
import { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db'
@@ -1428,27 +1429,6 @@ const parseRow = (row: RoomRow): Room => {
const parseOne = (row: RoomRow | null): Room | null => (row ? parseRow(row) : null)
const parseAll = (rows: RoomRow[]): Room[] => rows.map(parseRow)
/**
* D1 caps a prepared statement at 100 bound parameters — binding more fails outright with
* "variable number must be between ?1 and ?100". Every `IN (…)` list built from a caller's
* array has to respect this, which is easy to miss: a seeded dev database has fewer than a
* hundred rooms, so an unchunked query works right up until it meets a real one.
*/
const MAX_BOUND_PARAMS = 100
/**
* Split values into chunks that fit {@link MAX_BOUND_PARAMS}, for the reads whose rows are
* too heavy to fetch wholesale (subroom and save blobs) and so have to page through an
* `IN (…)` rather than scan.
*/
function chunkForBinds<T>(values: T[]): T[][] {
const chunks: T[][] = []
for (let i = 0; i < values.length; i += MAX_BOUND_PARAMS) {
chunks.push(values.slice(i, i + MAX_BOUND_PARAMS))
}
return chunks
}
/**
* Run one `… IN (…)` query per chunk and concatenate the rows. `sql` is handed the
* placeholder list for its chunk (`?1,?2,…`), which always restarts at `?1` because each