mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[api] fix a few 2023 compat issues
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(', ')
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user