From 77ad48972ea6f6c986951f771667264dd3d97abe Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 16:41:36 +0200 Subject: [PATCH] fix(mana/web/who): set createdAt + use simple gameId index for messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../apps/web/src/lib/modules/who/queries.ts | 13 ++++++++--- .../lib/modules/who/stores/games.svelte.ts | 23 +++++++++++-------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/mana/apps/web/src/lib/modules/who/queries.ts b/apps/mana/apps/web/src/lib/modules/who/queries.ts index b0e490612..aa049b7c3 100644 --- a/apps/mana/apps/web/src/lib/modules/who/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/who/queries.ts @@ -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('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 ?? '')); }); } diff --git a/apps/mana/apps/web/src/lib/modules/who/stores/games.svelte.ts b/apps/mana/apps/web/src/lib/modules/who/stores/games.svelte.ts index 6d8698b67..2a397c75a 100644 --- a/apps/mana/apps/web/src/lib/modules/who/stores/games.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/who/stores/games.svelte.ts @@ -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);