mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 16:08:58 -07:00
kv-to-d1
This commit is contained in:
@@ -6,8 +6,12 @@ export type Env = SharedHonoEnv & {
|
||||
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
|
||||
// signed by `auth` verify here.
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
/** Per-player settings store. Key `player:<id>` → JSON map of `{ key: value }`. */
|
||||
RECFLARE_PLAYER_SETTINGS: KVNamespace
|
||||
/**
|
||||
* Shared `recflare` D1. This worker owns the `player_settings` table — one row per
|
||||
* player holding their whole settings map as a JSON `{ key: value }` object (see
|
||||
* `@repo/domain`'s player-settings-db).
|
||||
*/
|
||||
DB: D1Database
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Default player settings seeded on a player's first read, ported verbatim from
|
||||
* the reference's `PlayerSettingsController.GetPlayerSettings`. Ordered; written to KV the
|
||||
* the reference's `PlayerSettingsController.GetPlayerSettings`. Ordered; written to the player’s row the
|
||||
* first time a player has no stored settings.
|
||||
*/
|
||||
export const DEFAULT_SETTINGS: Array<{ Key: string; Value: string }> = [
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { mergePlayerSettings } from '@repo/domain'
|
||||
import {
|
||||
mergePlayerSettings,
|
||||
putPlayerSettingsIfChanged,
|
||||
readPlayerSettings,
|
||||
writePlayerSettings,
|
||||
} from '@repo/domain'
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
@@ -25,9 +30,11 @@ import type { App } from './context'
|
||||
|
||||
/**
|
||||
* Player Settings Worker. Serves the small key/value settings bag the game client reads
|
||||
* on load and writes back as the player toggles options. Backed by a per-player KV map
|
||||
* (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
|
||||
* their first read.
|
||||
* on load and writes back as the player toggles options. Backed by one row per player in
|
||||
* the `player_settings` table on the shared D1 — the whole map as a JSON object, read and
|
||||
* written through `@repo/domain`'s player-settings-db, which `api`, `match` and `chat` also
|
||||
* merge their own keys into. A player with nothing stored is seeded with the reference
|
||||
* defaults on their first read.
|
||||
*
|
||||
* Every `/playersettings` route is auth-gated on the Bearer JWT issued by the `auth` worker.
|
||||
*/
|
||||
@@ -147,7 +154,7 @@ const app = new Hono<App>()
|
||||
)
|
||||
|
||||
// The authenticated player's settings as `{ PlayerId, Key, Value }`. Reads
|
||||
// the per-player KV map; seeds (and persists) the defaults on first read.
|
||||
// the player's row; seeds (and persists) the defaults on first read.
|
||||
.get(
|
||||
'/playersettings',
|
||||
describeRoute({
|
||||
@@ -155,7 +162,7 @@ const app = new Hono<App>()
|
||||
summary: 'The player’s settings',
|
||||
description: [
|
||||
'The authenticated player’s settings as `{ PlayerId, Key, Value }` entries, read from',
|
||||
'their KV map. A player with nothing stored is seeded with the reference defaults',
|
||||
'their stored map. A player with nothing stored is seeded with the reference defaults',
|
||||
'(Recroom.OOBE, TUTORIAL_COMPLETE_MASK, FIRST_TIME_IN_FLAGS), which are persisted on',
|
||||
'that first read.',
|
||||
].join(' '),
|
||||
@@ -169,34 +176,33 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const kvKey = `player:${id}`
|
||||
let stored = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(kvKey, 'json')
|
||||
let stored = await readPlayerSettings(c.env.DB, id)
|
||||
if (!stored || Object.keys(stored).length === 0) {
|
||||
stored = Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value]))
|
||||
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(stored))
|
||||
await writePlayerSettings(c.env.DB, id, stored)
|
||||
}
|
||||
|
||||
return c.json(Object.entries(stored).map(([Key, Value]) => ({ PlayerId: id, Key, Value })))
|
||||
}
|
||||
)
|
||||
|
||||
// Upsert player settings into KV, keyed by the authenticated player id.
|
||||
// Upsert player settings into the player's row, keyed by the authenticated player id.
|
||||
// A full replace would overwrite the player's entire set; we merge so individual key PUTs
|
||||
// (e.g. `key=PlayerSessionCount&value=1`) don't wipe the rest. The client re-posts values
|
||||
// it already has at every login and menu change, so a PUT that changes nothing is a
|
||||
// read only — KV writes are what cost money here.
|
||||
// read only — the row is written only when a value changed.
|
||||
.put(
|
||||
'/playersettings',
|
||||
describeRoute({
|
||||
tags: ['Player Settings'],
|
||||
summary: 'Write the player’s settings',
|
||||
description: [
|
||||
'Upserts the posted setting(s) into the caller’s KV map. The write MERGES: a single',
|
||||
'Upserts the posted setting(s) into the caller’s settings map. The write MERGES: a single',
|
||||
'key PUT (`key=PlayerSessionCount&value=1`, which is what the client sends) leaves the',
|
||||
'player’s other settings alone. A JSON body is also accepted, as one object or an',
|
||||
'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are',
|
||||
'dropped. An unparseable or empty body is a no-op 200, not a 400, and so is a PUT whose',
|
||||
'values are already stored — nothing is written to KV. Empty body on success.',
|
||||
'values are already stored — nothing is written. Empty body on success.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
|
||||
@@ -215,7 +221,7 @@ const app = new Hono<App>()
|
||||
const patch: Record<string, string> = {}
|
||||
for (const { key, value } of incoming) patch[key] = value
|
||||
|
||||
await mergePlayerSettings(c.env.RECFLARE_PLAYER_SETTINGS, id, patch)
|
||||
await mergePlayerSettings(c.env.DB, id, patch)
|
||||
return c.body(null, 200)
|
||||
}
|
||||
)
|
||||
@@ -229,7 +235,7 @@ const app = new Hono<App>()
|
||||
tags: ['Player Settings'],
|
||||
summary: 'Delete a player setting',
|
||||
description: [
|
||||
'Removes the named setting(s) from the caller’s KV map. The client sends a bare',
|
||||
'Removes the named setting(s) from the caller’s settings map. The client sends a bare',
|
||||
'form-urlencoded `key=PlayerShoppingBagId` (no `value`); a JSON body — a string, a',
|
||||
'`{ key }` object, or an array of either — and a `?key=` query param are also read.',
|
||||
'Deleting a key that isn’t stored, or sending nothing to delete, is a no-op 200, not a',
|
||||
@@ -252,22 +258,13 @@ const app = new Hono<App>()
|
||||
const keys = await parseDeleteKeys(c)
|
||||
if (keys.length === 0) return c.body(null, 200)
|
||||
|
||||
const kvKey = `player:${id}`
|
||||
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
kvKey,
|
||||
'json'
|
||||
)
|
||||
const existing = await readPlayerSettings(c.env.DB, id)
|
||||
if (!existing) return c.body(null, 200)
|
||||
|
||||
const remaining = { ...existing }
|
||||
let removed = false
|
||||
for (const key of keys) {
|
||||
if (key in remaining) {
|
||||
delete remaining[key]
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
if (removed) await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(remaining))
|
||||
for (const key of keys) delete remaining[key]
|
||||
// Nothing stored was named: no write.
|
||||
await putPlayerSettingsIfChanged(c.env.DB, id, existing, remaining)
|
||||
|
||||
return c.body(null, 200)
|
||||
}
|
||||
@@ -287,8 +284,8 @@ app.get(
|
||||
description: [
|
||||
'The player key/value settings bag for recflare, a private-server reimplementation of',
|
||||
'the Rec Room backend. The client reads these on load and writes them back as the',
|
||||
'player toggles options; they are stored in a per-player KV map, seeded with the',
|
||||
'reference defaults on a player’s first read.',
|
||||
'player toggles options; they are stored as one JSON row per player on the shared',
|
||||
'D1, seeded with the reference defaults on a player’s first read.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://playersettings.recflare.net', description: 'Production' }],
|
||||
|
||||
@@ -3,6 +3,8 @@ import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import '../../playersettings.app'
|
||||
|
||||
import { PLAYER_SETTINGS_SCHEMA_DDL, readPlayerSettings } from '@repo/domain'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
@@ -14,8 +16,33 @@ const ORIGIN = 'https://example.com'
|
||||
beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
// The player_settings table this worker owns (mirror of migrations/0001).
|
||||
for (const stmt of PLAYER_SETTINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
/** The player's stored map, as the worker reads it. */
|
||||
const stored = (playerId: number) => readPlayerSettings(env.DB, playerId)
|
||||
|
||||
/**
|
||||
* The row's `data` exactly as stored. The "nothing was written" tests seed it with whitespace
|
||||
* the worker's own JSON.stringify never produces: if that survives a request, no write happened.
|
||||
*/
|
||||
async function rawData(playerId: number): Promise<string | null> {
|
||||
const row = await env.DB.prepare('SELECT data FROM player_settings WHERE account_id = ?1')
|
||||
.bind(playerId)
|
||||
.first<{ data: string }>()
|
||||
return row?.data ?? null
|
||||
}
|
||||
|
||||
async function seedRaw(playerId: number, data: string): Promise<void> {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO player_settings (account_id, data) VALUES (?1, ?2)
|
||||
ON CONFLICT (account_id) DO UPDATE SET data = excluded.data`
|
||||
)
|
||||
.bind(playerId, data)
|
||||
.run()
|
||||
}
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store.
|
||||
const TEST_SECRET = 'test-signing-key'
|
||||
|
||||
@@ -85,12 +112,9 @@ describe('playersettings endpoints', () => {
|
||||
expect(settings.find((s) => s.Key === 'Recroom.OOBE')?.Value).toBe('77')
|
||||
expect(settings.find((s) => s.Key === 'TUTORIAL_COMPLETE_MASK')?.Value).toBe('11')
|
||||
|
||||
// Defaults were persisted to KV.
|
||||
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
'player:100',
|
||||
'json'
|
||||
)
|
||||
expect(stored?.['Recroom.OOBE']).toBe('77')
|
||||
// Defaults were persisted.
|
||||
const map = await stored(100)
|
||||
expect(map?.['Recroom.OOBE']).toBe('77')
|
||||
})
|
||||
|
||||
it('GET /playersettings reflects a value written by PUT', async () => {
|
||||
@@ -109,18 +133,15 @@ describe('playersettings endpoints', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('PUT /playersettings persists the form key/value into KV', async () => {
|
||||
it('PUT /playersettings persists the form key/value', async () => {
|
||||
const res = await SELF.fetch(
|
||||
`${ORIGIN}/playersettings`,
|
||||
putForm({ key: 'PlayerSessionCount', value: '1' }, await bearer('7'))
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
'player:7',
|
||||
'json'
|
||||
)
|
||||
expect(stored).toEqual({ PlayerSessionCount: '1' })
|
||||
const map = await stored(7)
|
||||
expect(map).toEqual({ PlayerSessionCount: '1' })
|
||||
})
|
||||
|
||||
it('PUT /playersettings merges instead of replacing', async () => {
|
||||
@@ -133,33 +154,30 @@ describe('playersettings endpoints', () => {
|
||||
putForm({ key: 'B', value: '2' }, await bearer('8'))
|
||||
)
|
||||
|
||||
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
'player:8',
|
||||
'json'
|
||||
)
|
||||
expect(stored).toEqual({ A: '1', B: '2' })
|
||||
const map = await stored(8)
|
||||
expect(map).toEqual({ A: '1', B: '2' })
|
||||
})
|
||||
|
||||
// KV writes are the cost here, and the client re-posts settings it already has at every
|
||||
// login. The raw value is seeded with whitespace the worker's own JSON.stringify would
|
||||
// never produce: if it survives the PUT, nothing was written.
|
||||
it('PUT /playersettings does not write KV when the value is already stored', async () => {
|
||||
// The client re-posts settings it already has at every login, and a write costs more
|
||||
// than a read. The raw value is seeded with whitespace the worker's own JSON.stringify
|
||||
// would never produce: if it survives the PUT, nothing was written.
|
||||
it('PUT /playersettings does not write when the value is already stored', async () => {
|
||||
const padded = '{ "PlayerSessionCount": "5", "Recroom.OOBE": "77" }'
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put('player:10', padded)
|
||||
await seedRaw(10, padded)
|
||||
|
||||
const same = await SELF.fetch(
|
||||
`${ORIGIN}/playersettings`,
|
||||
putForm({ key: 'PlayerSessionCount', value: '5' }, await bearer('10'))
|
||||
)
|
||||
expect(same.status).toBe(200)
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:10', 'text')).toBe(padded)
|
||||
expect(await rawData(10)).toBe(padded)
|
||||
|
||||
// A real change is still written — and compacted, which is how we know.
|
||||
await SELF.fetch(
|
||||
`${ORIGIN}/playersettings`,
|
||||
putForm({ key: 'PlayerSessionCount', value: '6' }, await bearer('10'))
|
||||
)
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:10', 'text')).toBe(
|
||||
expect(await rawData(10)).toBe(
|
||||
JSON.stringify({ PlayerSessionCount: '6', 'Recroom.OOBE': '77' })
|
||||
)
|
||||
})
|
||||
@@ -193,11 +211,8 @@ describe('playersettings endpoints', () => {
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
'player:20',
|
||||
'json'
|
||||
)
|
||||
expect(stored).toEqual({ PlayerSessionCount: '3' })
|
||||
const map = await stored(20)
|
||||
expect(map).toEqual({ PlayerSessionCount: '3' })
|
||||
})
|
||||
|
||||
it('DELETE /playersettings reads a body with no content-type', async () => {
|
||||
@@ -213,11 +228,8 @@ describe('playersettings endpoints', () => {
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
'player:21',
|
||||
'json'
|
||||
)
|
||||
expect(stored).toEqual({})
|
||||
const map = await stored(21)
|
||||
expect(map).toEqual({})
|
||||
})
|
||||
|
||||
it('DELETE /playersettings 200s for an unknown key and an empty body', async () => {
|
||||
@@ -236,11 +248,8 @@ describe('playersettings endpoints', () => {
|
||||
expect(empty.status).toBe(200)
|
||||
|
||||
// Neither call touched the stored map.
|
||||
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
'player:22',
|
||||
'json'
|
||||
)
|
||||
expect(stored).toEqual({ A: '1' })
|
||||
const map = await stored(22)
|
||||
expect(map).toEqual({ A: '1' })
|
||||
})
|
||||
|
||||
it('GET /openapi.json documents every route', async () => {
|
||||
|
||||
Reference in New Issue
Block a user