Agent skill
dexie-cache-sync
Dexie IndexedDB caching layer with Convex sync for local-first architecture. Covers sync hooks, optimistic updates (React state only), cascade deletes, orphan detection, SSR safety. Triggers on "cache", "dexie", "useCacheSync", "optimistic", "offline", "IndexedDB".
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/dexie-cache-sync
SKILL.md
Dexie Cache Sync
Local-first caching with Convex→Dexie sync, optimistic updates, offline queue. 10 tables: conversations, messages, notes, tasks, projects, attachments, toolCalls, sources, pendingMutations, userPreferences.
Flow: Convex subscription → useQuery → useEffect → cache.bulkPut → Dexie → useLiveQuery → Component
Cache Schema
10 tables in apps/web/src/lib/cache/db.ts:
class BlahChatCache extends Dexie {
conversations!: Table<Doc<"conversations">>;
messages!: Table<Doc<"messages">>;
notes!: Table<Doc<"notes">>;
tasks!: Table<Doc<"tasks">>;
projects!: Table<Doc<"projects">>;
attachments!: Table<Doc<"attachments">>;
toolCalls!: Table<Doc<"toolCalls">>;
sources!: Table<Doc<"sources">>;
pendingMutations!: Table<PendingMutation>;
userPreferences!: Table<CachedPreferences>;
constructor() {
super("blahchat-cache");
this.version(4).stores({
conversations: "_id, userId, parentMessageId, updatedAt, projectId",
messages: "_id, conversationId, createdAt",
attachments: "_id, messageId",
toolCalls: "_id, messageId",
sources: "_id, messageId",
// ...
});
}
}
SSR Guard:
// From apps/web/src/lib/cache/db.ts:98-118
let _cache: BlahChatCache | null = null;
function getCache(): BlahChatCache {
if (typeof window === "undefined") {
throw new Error(
"Attempted to access IndexedDB cache during SSR. Ensure cache is only used in client components."
);
}
if (!_cache) _cache = new BlahChatCache();
return _cache;
}
export const cache = typeof window !== "undefined"
? getCache()
: (null as unknown as BlahChatCache);
Only use cache in "use client" components.
Sync Hook Pattern
Pattern from apps/web/src/hooks/useCacheSync.ts:
- Subscribe to Convex with
useQuery - On data change, sync to Dexie (bulkPut + orphan detection)
- Read from Dexie with
useLiveQueryfor instant UI
Message Sync (with orphan detection):
// From apps/web/src/hooks/useCacheSync.ts:16-141
export function useMessageCacheSync({
conversationId,
initialNumItems = 50,
}: MessageCacheSyncOptions) {
// Convex subscription (real-time)
const convexMessages = usePaginatedQuery(
api.messages.listPaginated,
conversationId ? { conversationId } : "skip",
{ initialNumItems }
);
// Sync to Dexie when Convex updates
useEffect(() => {
if (!conversationId || convexMessages.results === undefined) return;
const syncCache = async () => {
// Orphan detection: find Dexie records not in Convex
const convexIds = new Set(convexMessages.results.map((m) => m._id));
const dexieRecords = await cache.messages
.where("conversationId")
.equals(conversationId)
.toArray();
const orphanIds = dexieRecords
.filter((d) => !convexIds.has(d._id))
.map((d) => d._id);
if (orphanIds.length > 0) await cache.messages.bulkDelete(orphanIds);
if (convexMessages.results.length > 0)
await cache.messages.bulkPut(convexMessages.results);
};
syncCache().catch(console.error);
}, [convexMessages.results, conversationId]);
// Read from Dexie (instant)
const cachedMessages = useLiveQuery(
() =>
conversationId
? cache.messages
.where("conversationId")
.equals(conversationId)
.sortBy("createdAt")
: [],
[conversationId],
undefined // Return undefined while loading, not []
);
// Validation: ensure cached data matches current conversation
const validatedMessages =
conversationId === undefined
? cachedMessages
: cachedMessages === undefined
? undefined
: cachedMessages.length === 0
? cachedMessages // Empty conversation - valid!
: cachedMessages.every((m) => m.conversationId === conversationId)
? cachedMessages
: undefined; // Wrong conversation
return {
results: validatedMessages,
loadMore: convexMessages.loadMore,
status: convexMessages.status,
};
}
Conversation Sync (with projectId filtering):
// From apps/web/src/hooks/useCacheSync.ts:211-251
export function useConversationCacheSync(
options: ConversationCacheSyncOptions = {}
) {
const { projectId } = options;
const conversations = useQuery(
api.conversations.list,
{ projectId: projectId || undefined }
);
useEffect(() => {
if (conversations === undefined) return;
const syncCache = async () => {
const convexIds = new Set(conversations.map((c) => c._id));
const dexieRecords = await getConversationsByProject(projectId);
const orphanIds = dexieRecords
.filter((d) => !convexIds.has(d._id))
.map((d) => d._id);
if (orphanIds.length > 0) await cache.conversations.bulkDelete(orphanIds);
if (conversations.length > 0) await cache.conversations.bulkPut(conversations);
};
syncCache().catch(console.error);
}, [conversations, projectId]);
const cachedConversations = useLiveQuery(
() => getConversationsByProject(projectId),
[projectId],
[] as Doc<"conversations">[]
);
return {
conversations: cachedConversations,
isLoading: conversations === undefined,
};
}
Metadata Sync (attachments, toolCalls, sources):
// From apps/web/src/hooks/useCacheSync.ts:143-176
export function useMetadataCacheSync(messageIds: Id<"messages">[]) {
const metadata = useQuery(
api.messages.batchGetMetadata,
messageIds.length > 0 ? { messageIds } : "skip"
);
useEffect(() => {
if (!metadata) return;
const syncOps: Promise<unknown>[] = [];
if (metadata.attachments?.length) {
syncOps.push(cache.attachments.bulkPut(metadata.attachments));
}
if (metadata.toolCalls?.length) {
syncOps.push(cache.toolCalls.bulkPut(metadata.toolCalls));
}
if (metadata.sources?.length) {
syncOps.push(cache.sources.bulkPut(metadata.sources));
}
if (syncOps.length > 0) {
Promise.all(syncOps).catch(console.error);
}
}, [metadata]);
}
Optimistic Updates (React State Only)
CRITICAL: Optimistic messages NEVER touch Dexie. React state only, deduped by time window when server confirms.
From apps/web/src/hooks/useOptimisticMessages.ts:
// Time windows for deduplication
const MATCH_FUTURE_WINDOW_MS = 10_000; // Server can arrive 10s after optimistic
const MATCH_PAST_WINDOW_MS = 1_000; // Handle small clock skew
export function useOptimisticMessages({
serverMessages,
}: UseOptimisticMessagesOptions) {
const [optimisticMessages, setOptimisticMessages] = useState<
OptimisticMessage[]
>([]);
// Add optimistic message (instant, before API call)
const addOptimisticMessages = useCallback(
(newMessages: OptimisticMessage[]) => {
setOptimisticMessages((prev) => [...prev, ...newMessages]);
},
[]
);
// Merge server + optimistic, dedupe by time window
const messages = useMemo<MessageWithOptimistic[] | undefined>(() => {
if (serverMessages === undefined) return undefined;
return mergeWithOptimisticMessages(
serverMessages as MessageWithOptimistic[],
optimisticMessages
);
}, [serverMessages, optimisticMessages]);
return { messages, addOptimisticMessages };
}
function mergeWithOptimisticMessages(
serverMessages: MessageWithOptimistic[],
optimisticMessages: OptimisticMessage[]
): MessageWithOptimistic[] {
if (optimisticMessages.length === 0) return serverMessages;
const serverByRole = {
user: serverMessages.filter((m) => m.role === "user"),
assistant: [], // Assistant messages never optimistic
};
const remainingOptimistic: OptimisticMessage[] = [];
for (const opt of optimisticMessages) {
const candidates = serverByRole[opt.role] || [];
const matchIndex = candidates.findIndex((serverMsg) => {
const timeDiff = serverMsg.createdAt - opt.createdAt;
return (
timeDiff >= -MATCH_PAST_WINDOW_MS &&
timeDiff <= MATCH_FUTURE_WINDOW_MS
);
});
if (matchIndex === -1) {
remainingOptimistic.push(opt);
} else {
candidates.splice(matchIndex, 1); // Consume matched message
}
}
return [...serverMessages, ...remainingOptimistic].sort(
(a, b) => a.createdAt - b.createdAt
);
}
Clear on conversation switch:
// From apps/web/src/hooks/useOptimisticMessages.ts:92-108
const conversationIdRef = useRef<string | undefined>(undefined);
const currentConversationId = serverMessages?.[0]?.conversationId;
useEffect(() => {
if (
conversationIdRef.current &&
conversationIdRef.current !== currentConversationId
) {
setOptimisticMessages([]);
}
conversationIdRef.current = currentConversationId;
}, [currentConversationId]);
Cascade Delete Pattern
CRITICAL: Always delete attachments/toolCalls/sources when deleting messages.
From apps/web/src/lib/cache/cleanup.ts:
// Delete messages and cascade to related data
await cache.transaction(
"rw",
[cache.messages, cache.attachments, cache.toolCalls, cache.sources],
async () => {
await cache.messages.bulkDelete(oldMessageIds);
await cache.attachments
.where("messageId")
.anyOf(oldMessageIds)
.delete();
await cache.toolCalls.where("messageId").anyOf(oldMessageIds).delete();
await cache.sources.where("messageId").anyOf(oldMessageIds).delete();
}
);
Use in mutations/actions:
// Delete single message with cascade
await Promise.all([
cache.messages.delete(messageId),
cache.attachments.where("messageId").equals(messageId).delete(),
cache.toolCalls.where("messageId").equals(messageId).delete(),
cache.sources.where("messageId").equals(messageId).delete(),
]);
Orphan Detection
Sync hooks reconcile Convex (source of truth) vs Dexie (cache).
Pattern:
const syncCache = async () => {
// 1. Get Convex IDs (source of truth)
const convexIds = new Set(convexData.map((item) => item._id));
// 2. Get Dexie records (cache)
const dexieRecords = await cache.table.toArray();
// 3. Find orphans (in Dexie but not in Convex)
const orphanIds = dexieRecords
.filter((d) => !convexIds.has(d._id))
.map((d) => d._id);
// 4. Delete orphans
if (orphanIds.length > 0) await cache.table.bulkDelete(orphanIds);
// 5. Update/insert current data
if (convexData.length > 0) await cache.table.bulkPut(convexData);
};
Messages/conversations have orphan detection. Notes/tasks/projects rely on cascade or time-based cleanup.
Cleanup Strategy
From apps/web/src/lib/cache/cleanup.ts:
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
export async function cleanupOldData(): Promise<void> {
const thirtyDaysAgo = Date.now() - THIRTY_DAYS_MS;
// Messages: 30 days (with cascade delete)
const oldMessageIds = await cache.messages
.where("createdAt")
.below(thirtyDaysAgo)
.primaryKeys();
await cache.transaction(
"rw",
[cache.messages, cache.attachments, cache.toolCalls, cache.sources],
async () => {
await cache.messages.bulkDelete(oldMessageIds);
await cache.attachments.where("messageId").anyOf(oldMessageIds).delete();
await cache.toolCalls.where("messageId").anyOf(oldMessageIds).delete();
await cache.sources.where("messageId").anyOf(oldMessageIds).delete();
}
);
// Notes: 30 days since last update
await cache.notes.where("updatedAt").below(thirtyDaysAgo).delete();
// Tasks: 30 days for pending, 90 days for completed
const ninetyDaysAgo = Date.now() - NINETY_DAYS_MS;
const oldTaskIds = await cache.tasks
.filter(
(task) =>
(task._creationTime < thirtyDaysAgo && task.status !== "completed") ||
(task._creationTime < ninetyDaysAgo && task.status === "completed")
)
.primaryKeys();
if (oldTaskIds.length > 0) {
await cache.tasks.bulkDelete(oldTaskIds);
}
}
Run on app start via CacheProvider. Non-blocking.
Offline Queue
From apps/web/src/lib/offline/messageQueue.ts:
export class MessageQueue {
private readonly MAX_RETRIES = 3;
async enqueue(
message: Omit<QueuedMessage, "id" | "timestamp" | "retries">
): Promise<void> {
const queuedMessage: QueuedMessage = {
...message,
id: crypto.randomUUID(),
timestamp: Date.now(),
retries: 0,
};
await cache.pendingMutations.add({
_id: queuedMessage.id,
type: "sendMessage",
payload: queuedMessage,
createdAt: Date.now(),
retries: 0,
});
this.dispatchQueueUpdate();
}
async processQueue(
sendFn: (msg: QueuedMessage) => Promise<void>
): Promise<void> {
const queue = await this.getQueue();
for (const msg of queue) {
try {
await sendFn(msg);
await this.remove(msg.id);
} catch (_error) {
if (msg.retries >= this.MAX_RETRIES) {
await this.remove(msg.id);
console.error(
`[MessageQueue] Permanently failed after ${this.MAX_RETRIES} retries`
);
} else {
await this.incrementRetry(msg.id);
// Exponential backoff: 2s → 4s → 8s
const backoffMs = 2000 * 2 ** msg.retries;
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
}
}
}
export const messageQueue = new MessageQueue();
Auto-retry on reconnect with exponential backoff.
Anti-Patterns
DON'T:
- Put optimistic messages in Dexie - React state only
- Delete messages without cascade (orphans attachments/toolCalls/sources)
- Use cache in server components - SSR will throw
- Skip orphan detection in sync hooks - causes stale data
- Return empty array during loading - return
undefinedto distinguish from "no data" - Use raw Convex queries for cached tables - use sync hooks instead
DO:
- Use
useLiveQuerywith dependency array for reactive reads - Validate cached data matches current context (conversation ID)
- Clear optimistic messages on conversation switch
- Use transactions for multi-table operations
- Handle
undefinedvs[]correctly (loading vs empty)
Key Files
apps/web/src/lib/cache/db.ts- Schema, SSR guardapps/web/src/hooks/useCacheSync.ts- Sync hooks (messages, conversations, metadata, notes, tasks, projects, preferences)apps/web/src/hooks/useOptimisticMessages.ts- React state optimistic updates, time-window deduplicationapps/web/src/lib/cache/cleanup.ts- 30/90 day cleanupapps/web/src/lib/offline/messageQueue.ts- Offline queue with exponential backoffapps/web/src/components/providers/cache-provider.tsx- Cleanup on app start
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?