local-first · diff

git:20260103.9f62587 to git:20260309.80c3cb2

1 added, 188 removed. Audit A to B.

---
description: Local-first write patterns and controller naming conventions
globs: src/core/**/*
alwaysApply: false
---
# Local-First Rules
- Rules for implementing local-first patterns. Based on ADR-0001, ADR-0011.
-
- ## Write Flow
-
- All write operations must follow this pattern:
-
- ```
- 1. Write to IndexedDB (Dexie) first
- 2. Update UI immediately
- 3. Sync to homeserver in background
- 4. Reconcile conflicts asynchronously
- ```
-
- ## Controller Method Naming
-
- Controller method names encode IO behavior and delivery guarantees:
-
- ### Read Operations
-
- | Prefix | Source | Network | Description |
- |--------|--------|---------|-------------|
- | `fetch*` | Nexus API | Yes | Network only, no cache |
- | `get*` | IndexedDB | No | Local cache only |
- | `getMany*` | IndexedDB | No | Bulk reads, returns `Map<Pubky, T>` |
- | `getOrFetch*` | IndexedDB → Nexus | Maybe | Local first, fallback to network |
- | `getManyOrFetch*` | IndexedDB → Nexus | Maybe | Bulk local first, fetch missing |
-
- ### Write Operations
-
- | Prefix | Pattern | Description |
- |--------|---------|-------------|
- | `commit[Create\|Update\|Delete]*` | Local-first | Write to IndexedDB, sync to homeserver |
-
- ## Examples
-
- ### Read Methods
- ```typescript
- // ✅ Good naming
- PostController.fetchPost(id); // Always network
- PostController.getPost(id); // Always local
- PostController.getOrFetchPost(id); // Local first, fallback
- UserController.getManyProfiles(pubkys); // Bulk local
- UserController.getManyOrFetchProfiles(pubkys); // Bulk with fallback
-
- // ❌ Bad naming
- PostController.loadPost(id); // Unclear source
- PostController.retrievePost(id); // Unclear source
- ```
-
- ### Write Methods
- ```typescript
- // ✅ Good naming
- PostController.commitCreatePost(post); // Create new post
- PostController.commitUpdatePost(id, data); // Update existing
- PostController.commitDeletePost(id); // Delete post
- BookmarkController.commitCreateBookmark(postId);
-
- // ❌ Bad naming
- PostController.createPost(post); // Missing "commit" prefix
- PostController.savePost(post); // Unclear operation type
- PostController.removePost(id); // Should be commitDelete
- ```
-
- ## Implementation Pattern
-
- ### Write Operation Flow
- ```typescript
- class PostController {
- static async commitCreatePost(input: CreatePostInput) {
- // 1. Validate/normalize via pipes
- const post = PostPipe.normalizeCreate(input);
-
- // 2. Update UI state immediately
- usePostStore.getState().addOptimistic(post);
-
- // 3. Persist locally first (through application)
- await PostApplication.create(post);
-
- // 4. Background sync happens in application layer
- }
- }
-
- class PostApplication {
- static async create(post: Post) {
- // Write to IndexedDB first
- await LocalPostService.upsert(post);
-
- // Sync to homeserver (can fail, will retry)
- try {
- await HomeserverService.put(post);
- } catch (error) {
- // Queue for retry, don't block
- await SyncQueue.add(post);
- }
- }
- }
- ```
-
- ## useLiveQuery Rules (ADR-0011)
-
- When using Dexie's `useLiveQuery`:
-
- ### ✅ DO
- ```typescript
- // Pure, read-only, local-only
- const posts = useLiveQuery(
- () => LocalPostService.getByStream(streamId),
- [streamId]
- );
- ```
-
- ### ❌ DON'T
- ```typescript
- // Never call TanStack Query or network code inside useLiveQuery
- const posts = useLiveQuery(async () => {
- const local = await LocalPostService.get(id);
- if (!local) {
- await queryClient.fetchQuery(...); // ❌ Breaks Dexie PSD
- }
- return local;
- }, [id]);
- ```
-
- ### Pattern: Fetch in useEffect, Read in useLiveQuery
- ```typescript
- function PostView({ id }) {
- // Fetch via controller (TanStack Query compatible)
- useEffect(() => {
- PostController.getOrFetchPost(id);
- }, [id]);
-
- // React to local changes
- const post = useLiveQuery(
- () => LocalPostService.get(id),
- [id]
- );
-
- return <PostCard post={post} />;
- }
- ```
-
- ## Persistence Order
-
- When writing related entities, persist dependencies first:
-
- ```typescript
- // ✅ Correct order
- await LocalUserService.upsert(author); // 1. Author first
- await LocalPostService.upsert(post); // 2. Then post
- await LocalTagService.upsert(tags); // 3. Then tags
-
- // ❌ Wrong order (foreign key issues)
- await LocalPostService.upsert(post); // Post references author
- await LocalUserService.upsert(author); // Author not yet in DB!
- ```
-
- ## Optimistic Updates
-
- For immediate UI feedback:
-
- ```typescript
- // 1. Optimistic update in store
- usePostStore.getState().addPost(optimisticPost);
-
- // 2. Persist
- try {
- await PostApplication.create(post);
- // Success: optimistic = real
- } catch (error) {
- // Rollback on failure
- usePostStore.getState().removePost(optimisticPost.id);
- throw error;
- }
- ```
-
- ## Quick Checklist
-
- When adding controller methods:
-
- - [ ] Does the name follow `fetch*/get*/getOrFetch*/commit*` pattern?
- - [ ] Do write operations write to IndexedDB first?
- - [ ] Is UI updated immediately (optimistic)?
- - [ ] Does background sync handle failures gracefully?
- - [ ] Is `useLiveQuery` used only for local reads?
-
- ---
-
- **Reference**: `.cursor/adr/0001-local-first-writes.md`, `.cursor/adr/0011-dexie-psd-and-tanstack-query.md`
+ @docs/local-first.md