local-first · git:20260103.9f62587 · 2026-01-03 · sha256 b17eab5f16254238
local-first git:20260103.9f62587A
Immutable. This exact content is served forever at /api/v1/blob/b17eab5f16254238.
---
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`