Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["demo", "demo-advanced", "docs"]
"ignore": ["demo", "demo-advanced", "demo-dexie", "docs"]
}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ dist-ssr
coverage

resources


internal-docs
internal-docs/*
16 changes: 16 additions & 0 deletions apps/demo-dexie/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'off',
'@typescript-eslint/ban-types': 'off',
},
}
116 changes: 116 additions & 0 deletions apps/demo-dexie/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# EffState + Dexie Demo

This demo showcases integrating EffState with [Dexie](https://dexie.org/) for IndexedDB-based persistence.

## Why Dexie over localStorage?

| Feature | localStorage | Dexie (IndexedDB) |
|---------|-------------|-------------------|
| Storage limit | ~5MB | ~50% of disk |
| Cross-tab sync | Manual (BroadcastChannel) | Built-in via liveQuery |
| Async | No (blocks main thread) | Yes |
| Querying | None | Full query support |
| Schema | None | Typed tables |
| Transactions | None | ACID transactions |

## Architecture

This demo follows the Effect.Service pattern from the sync-engine-web reference:

```
src/lib/services/
├── dexie.ts # Dexie service (Effect.Service pattern)
└── state-persistence.ts # Persistence operations
```

### Key Patterns

**1. Dexie wrapped in Effect.Service**

```typescript
export class DexieService extends Effect.Service<DexieService>()(
"DexieService",
{
effect: Effect.sync(() => {
const db = new EffStateDexie();
return {
db,
query: <T>(execute: (db) => Promise<T>) =>
Effect.tryPromise({
try: () => execute(db),
catch: (cause) => new DexieQueryError({ cause }),
}),
};
}),
}
) {}
```

**2. Cross-tab sync via liveQuery**

```typescript
// Dexie's liveQuery detects IndexedDB changes across tabs
const persistedState = useLiveQuery(
() => db?.machineStates.get(MACHINE_ID),
[db]
);

useEffect(() => {
if (!persistedState || crossTabSync.isLeader()) return;
// Sync from other tab's changes
currentActor._syncSnapshot(snapshot, childSnapshots);
}, [persistedState]);
```

**3. Leader election for write coordination**

Only the leader writes to Dexie, preventing race conditions:

```typescript
const crossTabSync = createCrossTabSync({
storageKey: LEADER_KEY,
onSave: () => {
if (currentActor) saveStateToDexie(currentActor);
},
});

// On state change
actor.subscribe(() => crossTabSync.saveIfLeader());
```

## Running the Demo

```bash
# From repo root
pnpm install
pnpm --filter demo-dexie dev
```

## Verification

1. Open the app in a browser
2. Toggle the hamster to change state
3. Open DevTools > Application > IndexedDB > effstate
4. Verify `machineStates` table contains the state
5. Open a second tab - changes sync via liveQuery
6. Refresh - state persists from IndexedDB

## Comparison with localStorage Demo

The main differences from `apps/demo`:

| Aspect | demo (localStorage) | demo-dexie (IndexedDB) |
|--------|---------------------|------------------------|
| Persistence | `localStorage.getItem/setItem` | `db.machineStates.get/put` |
| Cross-tab sync | BroadcastChannel + storage event | Dexie liveQuery |
| Async | Sync (blocks) | Async (non-blocking) |
| Type safety | Manual JSON parsing | Schema-validated |

## Future Extensions

This pattern can be extended for:

- **Server sync**: Add sync-engine-web patterns for remote persistence
- **CRDT conflict resolution**: Add a CRDT for offline-first collaboration
- **Offline queue**: Buffer changes when offline, sync when online
- **Schema migrations**: Handle data model evolution
13 changes: 13 additions & 0 deletions apps/demo-dexie/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EffState + Dexie Demo</title>
</head>
<body style="background-color: #1f2937;">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
57 changes: 57 additions & 0 deletions apps/demo-dexie/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "demo-dexie",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@effect-atom/atom-react": "^0.3.4",
"@effect/opentelemetry": "^0.35.0",
"@effect/platform": "^0.93.8",
"@effstate/react": "workspace:*",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"dexie": "^4.0.11",
"dexie-react-hooks": "^1.1.7",
"effect": "^3.19.12",
"effstate": "workspace:*",
"lucide-react": "^0.456.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@effect/vitest": "^0.27.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^22.9.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@typescript-eslint/parser": "^7.15.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"@vitest/coverage-v8": "^4.0.16",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^27.3.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "^5.2.2",
"vite": "^5.3.4",
"vitest": "^4.0.16"
}
}
6 changes: 6 additions & 0 deletions apps/demo-dexie/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
7 changes: 7 additions & 0 deletions apps/demo-dexie/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HamsterWheel } from "@/components/hamster-wheel/hamster-wheel";

function App() {
return <HamsterWheel />;
}

export default App;
Loading