fix(mana/web/who): set createdAt + use simple gameId index for messages

Two related bugs that caused user messages to disappear into the
ether: optimistic insert succeeds but neither the user message nor
the NPC reply ever shows up in PlayView, and no errors hit the
console because nothing actually throws.

Bug 1 — createdAt was never set
-------------------------------
The Dexie creating-hook in apps/mana/apps/web/src/lib/data/database.ts
auto-stamps userId and __fieldTimestamps but does NOT auto-stamp
createdAt. Module stores have to set it themselves. Chat gets away
with it because its query uses a simple conversationId index and
the type converter falls back to "now" — but I had the who store
omit createdAt entirely.

Bug 2 — composite index hides rows with undefined createdAt
-----------------------------------------------------------
queries.ts used .where('[gameId+createdAt]').between(...) against
the [gameId+createdAt] composite. Dexie does NOT index rows where
any compound key component is undefined, so even though the insert
succeeded and the row was physically in the table, the range query
returned an empty list. The liveQuery effect re-fired but found
nothing → no UI update. Same issue inside sendMessage's history-
fetch step.

Fix:
  1. Set createdAt explicitly on insert in whoGamesStore (both
     user message and NPC reply, +1ms on the reply so it sorts
     strictly after even when both inserts land in the same ms)
  2. Switch queries to .where('gameId').equals(id) and sort in JS
     — same pattern as chat's useConversationMessages, robust
     against missing createdAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 16:41:36 +02:00
parent 92f8221bfd
commit 77ad48972e
2 changed files with 24 additions and 12 deletions

View file

@ -61,14 +61,21 @@ export function gameByIdLive(gameId: string) {
export function messagesForGameLive(gameId: string) {
return liveQuery(async () => {
// Pull by the simple gameId index, sort in JS by createdAt asc.
// Same pattern as the chat module — using the [gameId+createdAt]
// composite would skip rows where createdAt is undefined (Dexie
// doesn't index undefined components in compound keys), and the
// creating hook in database.ts doesn't auto-stamp createdAt.
const locals = await db
.table<LocalWhoMessage>('whoMessages')
.where('[gameId+createdAt]')
.between([gameId, ''], [gameId, '\uffff'])
.where('gameId')
.equals(gameId)
.toArray();
const visible = locals.filter((m) => !m.deletedAt);
const decrypted = await decryptRecords('whoMessages', visible);
return decrypted.map(toWhoMessage);
return decrypted
.map(toWhoMessage)
.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
});
}

View file

@ -108,23 +108,25 @@ export const whoGamesStore = {
const trimmed = text.trim();
if (!trimmed) return;
// 1. Optimistic insert of the user message.
// 1. Optimistic insert of the user message. createdAt is set
// explicitly because the database creating-hook does NOT
// auto-stamp it; without it, time-based sorts and any
// composite index that includes createdAt skip the row.
const userMsg: LocalWhoMessage = {
id: crypto.randomUUID(),
gameId,
sender: 'user',
content: trimmed,
createdAt: new Date().toISOString(),
};
await encryptRecord('whoMessages', userMsg);
await whoMessageTable.add(userMsg);
// 2. Pull recent message history to send to the server. Decrypt
// on the way out — the wire format is plaintext to/from
// apps/api, encryption happens at-rest in Dexie.
const allMessages = await whoMessageTable
.where('[gameId+createdAt]')
.between([gameId, ''], [gameId, '\uffff'])
.toArray();
// 2. Pull recent message history to send to the server. Use the
// simple gameId index instead of [gameId+createdAt] composite
// — same reason as the queries.ts: rows with undefined
// createdAt aren't visible through the composite.
const allMessages = await whoMessageTable.where('gameId').equals(gameId).toArray();
const { decryptRecords } = await import('$lib/data/crypto');
const decrypted = await decryptRecords('whoMessages', allMessages);
// Drop the just-inserted user message from the history payload —
@ -141,12 +143,15 @@ export const whoGamesStore = {
history,
});
// 4. Insert the NPC reply.
// 4. Insert the NPC reply. createdAt explicit + bumped by 1ms
// so the npc message sorts strictly after the user message
// even when both inserts happen in the same millisecond.
const npcMsg: LocalWhoMessage = {
id: crypto.randomUUID(),
gameId,
sender: 'npc',
content: response.reply,
createdAt: new Date(Date.now() + 1).toISOString(),
};
await encryptRecord('whoMessages', npcMsg);
await whoMessageTable.add(npcMsg);