[msc] make playersettings more efficient

This commit is contained in:
Devin Zuczek
2026-09-30 18:04:56 -04:00
parent 90c808c4a3
commit e00103c6b3
13 changed files with 178 additions and 37 deletions
+1
View File
@@ -15,6 +15,7 @@
"test": "run-vitest"
},
"dependencies": {
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
+9 -10
View File
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { mergePlayerSettings } from '@repo/domain'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
@@ -181,7 +182,9 @@ const app = new Hono<App>()
// Upsert player settings into KV, 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.
// (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.
.put(
'/playersettings',
describeRoute({
@@ -192,7 +195,8 @@ const app = new Hono<App>()
'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. Empty body on success.',
'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.',
].join(' '),
security: AUTHED,
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
@@ -208,15 +212,10 @@ const app = new Hono<App>()
const incoming = await parseSettings(c)
if (incoming.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 merged: Record<string, string> = { ...existing }
for (const { key, value } of incoming) merged[key] = value
const patch: Record<string, string> = {}
for (const { key, value } of incoming) patch[key] = value
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(merged))
await mergePlayerSettings(c.env.RECFLARE_PLAYER_SETTINGS, id, patch)
return c.body(null, 200)
}
)
@@ -140,6 +140,30 @@ describe('playersettings endpoints', () => {
expect(stored).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 () => {
const padded = '{ "PlayerSessionCount": "5", "Recroom.OOBE": "77" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player: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)
// 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(
JSON.stringify({ PlayerSessionCount: '6', 'Recroom.OOBE': '77' })
)
})
it('PUT /playersettings 200s with no parseable settings', async () => {
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
expect(res.status).toBe(200)