From 706a04852e6f95a359a291cdcc7dd96ce0ae971f Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Sun, 11 Jan 2026 10:07:50 -0500 Subject: [PATCH] feat: dexie demo --- .changeset/config.json | 2 +- .gitignore | 4 + apps/demo-dexie/.eslintrc.cjs | 16 + apps/demo-dexie/README.md | 116 ++++ apps/demo-dexie/index.html | 13 + apps/demo-dexie/package.json | 57 ++ apps/demo-dexie/postcss.config.js | 6 + apps/demo-dexie/src/App.tsx | 7 + .../components/garage-door/garage-door.tsx | 239 ++++++++ .../hamster-wheel/hamster-wheel.tsx | 260 +++++++++ apps/demo-dexie/src/components/ui/button.tsx | 56 ++ .../src/data-access/garage-door-operations.ts | 329 +++++++++++ .../data-access/hamster-wheel-operations.ts | 530 ++++++++++++++++++ apps/demo-dexie/src/index.css | 65 +++ apps/demo-dexie/src/lib/app-runtime.ts | 27 + apps/demo-dexie/src/lib/cross-tab-leader.ts | 113 ++++ apps/demo-dexie/src/lib/services/dexie.ts | 96 ++++ .../src/lib/services/state-persistence.ts | 118 ++++ .../src/lib/services/weather-service.ts | 108 ++++ apps/demo-dexie/src/lib/utils.ts | 6 + apps/demo-dexie/src/main.tsx | 10 + apps/demo-dexie/tailwind.config.js | 57 ++ apps/demo-dexie/tsconfig.json | 28 + apps/demo-dexie/vite.config.ts | 12 + pnpm-lock.yaml | 136 +++++ 25 files changed, 2410 insertions(+), 1 deletion(-) create mode 100644 apps/demo-dexie/.eslintrc.cjs create mode 100644 apps/demo-dexie/README.md create mode 100644 apps/demo-dexie/index.html create mode 100644 apps/demo-dexie/package.json create mode 100644 apps/demo-dexie/postcss.config.js create mode 100644 apps/demo-dexie/src/App.tsx create mode 100644 apps/demo-dexie/src/components/garage-door/garage-door.tsx create mode 100644 apps/demo-dexie/src/components/hamster-wheel/hamster-wheel.tsx create mode 100644 apps/demo-dexie/src/components/ui/button.tsx create mode 100644 apps/demo-dexie/src/data-access/garage-door-operations.ts create mode 100644 apps/demo-dexie/src/data-access/hamster-wheel-operations.ts create mode 100644 apps/demo-dexie/src/index.css create mode 100644 apps/demo-dexie/src/lib/app-runtime.ts create mode 100644 apps/demo-dexie/src/lib/cross-tab-leader.ts create mode 100644 apps/demo-dexie/src/lib/services/dexie.ts create mode 100644 apps/demo-dexie/src/lib/services/state-persistence.ts create mode 100644 apps/demo-dexie/src/lib/services/weather-service.ts create mode 100644 apps/demo-dexie/src/lib/utils.ts create mode 100644 apps/demo-dexie/src/main.tsx create mode 100644 apps/demo-dexie/tailwind.config.js create mode 100644 apps/demo-dexie/tsconfig.json create mode 100644 apps/demo-dexie/vite.config.ts diff --git a/.changeset/config.json b/.changeset/config.json index 71e16db..2d03387 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,5 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["demo", "demo-advanced", "docs"] + "ignore": ["demo", "demo-advanced", "demo-dexie", "docs"] } diff --git a/.gitignore b/.gitignore index c6ce618..9a1fa28 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ dist-ssr coverage resources + + +internal-docs +internal-docs/* diff --git a/apps/demo-dexie/.eslintrc.cjs b/apps/demo-dexie/.eslintrc.cjs new file mode 100644 index 0000000..de7d613 --- /dev/null +++ b/apps/demo-dexie/.eslintrc.cjs @@ -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', + }, +} diff --git a/apps/demo-dexie/README.md b/apps/demo-dexie/README.md new file mode 100644 index 0000000..0a612f2 --- /dev/null +++ b/apps/demo-dexie/README.md @@ -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", + { + effect: Effect.sync(() => { + const db = new EffStateDexie(); + return { + db, + query: (execute: (db) => Promise) => + 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 Loro for offline-first collaboration +- **Offline queue**: Buffer changes when offline, sync when online +- **Schema migrations**: Handle data model evolution diff --git a/apps/demo-dexie/index.html b/apps/demo-dexie/index.html new file mode 100644 index 0000000..2b85abe --- /dev/null +++ b/apps/demo-dexie/index.html @@ -0,0 +1,13 @@ + + + + + + + EffState + Dexie Demo + + +
+ + + diff --git a/apps/demo-dexie/package.json b/apps/demo-dexie/package.json new file mode 100644 index 0000000..f69900f --- /dev/null +++ b/apps/demo-dexie/package.json @@ -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" + } +} diff --git a/apps/demo-dexie/postcss.config.js b/apps/demo-dexie/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/apps/demo-dexie/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/demo-dexie/src/App.tsx b/apps/demo-dexie/src/App.tsx new file mode 100644 index 0000000..e680efa --- /dev/null +++ b/apps/demo-dexie/src/App.tsx @@ -0,0 +1,7 @@ +import { HamsterWheel } from "@/components/hamster-wheel/hamster-wheel"; + +function App() { + return ; +} + +export default App; diff --git a/apps/demo-dexie/src/components/garage-door/garage-door.tsx b/apps/demo-dexie/src/components/garage-door/garage-door.tsx new file mode 100644 index 0000000..a1ef882 --- /dev/null +++ b/apps/demo-dexie/src/components/garage-door/garage-door.tsx @@ -0,0 +1,239 @@ +import { Button } from "@/components/ui/button"; +import { + type GarageDoorState, + type WeatherStatus, + AnimationComplete, + BangHammer, + Click, + getButtonLabel, + getStateLabel, + getWeatherStatus, +} from "@/data-access/garage-door-operations"; +import { useGarageDoorLeft } from "@/data-access/hamster-wheel-operations"; +import { cn } from "@/lib/utils"; + +type GarageDoorHook = typeof useGarageDoorLeft; + +const isPaused = (state: GarageDoorState): boolean => + state === "paused-while-opening" || state === "paused-while-closing"; + +const isAnimatingState = (state: GarageDoorState): boolean => + state === "opening" || state === "closing"; + +const WeatherDisplay = ({ weather }: { weather: WeatherStatus }) => { + switch (weather._tag) { + case "loading": + return ( +
+ Loading weather... +
+ ); + case "loaded": + return ( +
+
{weather.weather.icon}
+
+ {weather.weather.temperature}°F +
+
+ {weather.weather.description} +
+
+ ); + case "error": + return ( +
+
⚠️
+
+ {weather.error} +
+
+ ); + default: + return ( +
Garage Interior
+ ); + } +}; + +interface GarageDoorProps { + useHook?: GarageDoorHook; + title?: string; + mobileTitle?: string; +} + +export const GarageDoor = ({ useHook = useGarageDoorLeft, title = "Garage Door", mobileTitle }: GarageDoorProps) => { + const { send, isLoading, context, state } = useHook(); + + // Handle animation completion + const isOpening = state === "opening"; + const isClosing = state === "closing"; + + if (context.position >= 100 && isOpening) { + send(new AnimationComplete()); + } else if (context.position <= 0 && isClosing) { + send(new AnimationComplete()); + } + + if (isLoading) { + return ( +
+
Initializing...
+
+ ); + } + + // Derive status from context + const hasElectricity = context.isPowered; + const isAnimating = isOpening || isClosing; + const isPausedDueToNoPower = !hasElectricity && isAnimating; + const status = { + state, + position: context.position, + weather: getWeatherStatus(context), + }; + + const handleButtonClick = () => send(new Click()); + + // Door panel height based on position (0 = fully covering, 100 = fully retracted) + const doorHeight = 100 - status.position; + + return ( +
+
+

+ {mobileTitle ? ( + <> + {mobileTitle} + {title} + + ) : ( + title + )} +

+ {!hasElectricity && ( + 🔌 + )} +
+ + {/* Garage Frame */} +
+ {/* Inside of garage (visible when door opens) */} +
+ +
+ + {/* Door Panels */} +
+ {/* Door panel lines */} + {[0, 1, 2, 3].map((i) => ( +
+ ))} + + {/* Door handle */} +
+
+ + {/* Progress indicator */} +
+
+
+
+
+
+ + {/* Floor/Driveway */} +
+ + {/* Status Display */} +
+
{getStateLabel(status.state)}
+
+ Position: {status.position.toFixed(0)}% +
+ {isPausedDueToNoPower && ( +
+ Paused - No Power +
+ )} +
+ + {/* Control Button */} + + + {/* Bang Hammer Button - Wake the hamster when there's no power! */} + {!hasElectricity && ( + + )} + + {/* State Machine Debug Info */} +
+
State: {status.state}
+
Position: {status.position.toFixed(2)}%
+
Power: {hasElectricity ? "On" : "Off"}
+ {isPausedDueToNoPower &&
Animation Paused (no power)
} +
+ Click behavior: + {status.state === "closed" && " Start opening"} + {status.state === "opening" && " Pause (will close on resume)"} + {status.state === "paused-while-opening" && " Close door"} + {status.state === "open" && " Start closing"} + {status.state === "closing" && " Pause (will open on resume)"} + {status.state === "paused-while-closing" && " Open door"} +
+
+
+ ); +}; diff --git a/apps/demo-dexie/src/components/hamster-wheel/hamster-wheel.tsx b/apps/demo-dexie/src/components/hamster-wheel/hamster-wheel.tsx new file mode 100644 index 0000000..5125b72 --- /dev/null +++ b/apps/demo-dexie/src/components/hamster-wheel/hamster-wheel.tsx @@ -0,0 +1,260 @@ +import { Button } from "@/components/ui/button"; +import { + getButtonLabel, + getStateLabel, + useHamsterWheel, + useGarageDoorLeft, + useGarageDoorRight, +} from "@/data-access/hamster-wheel-operations"; +import { GarageDoor } from "@/components/garage-door/garage-door"; +import { cn } from "@/lib/utils"; + +const ElectricityBolt = ({ active, delay }: { active: boolean; delay: number }) => ( +
+ ⚡ +
+); + +const LightBulb = ({ on }: { on: boolean }) => ( +
+ 💡 +
+); + +const HamsterWheelContent = ({ + status, + handleToggle, +}: { + status: ReturnType["status"]; + handleToggle: () => void; +}) => { + const isRunning = status.state === "running"; + const isStopping = status.state === "stopping"; + const hasElectricity = status.electricityLevel > 0; + + return ( +
+

+ Hamster Power Generator +

+ + {/* Light bulbs */} +
+ + + +
+ + {/* Electricity flow */} +
+ {hasElectricity && ( + <> + + + + + )} +
+ + {/* Hamster wheel container - explicit size for proper centering */} +
+ + {/* Spinning wheel (visual ring + spokes + edge decoration) */} +
+ {/* Wheel spokes */} + {[0, 45, 90, 135].map((angle) => ( +
+ ))} + + {/* Decorative circle on wheel edge (spins with wheel) */} +
+
+ + {/* Center hub - stationary, outside the spinning div */} +
+ + {/* Hamster - stationary, on top */} +
+ {status.state === "idle" ? "😴" : isRunning ? "🐹" : "🐹"} +
+ + + {/* Running indicator */} + {isRunning && ( +
+ {"*running noises*".split("").map((char, i) => ( + + {char === " " ? "\u00A0" : char} + + ))} +
+ )} +
+ + {/* Generator */} +
+ {hasElectricity ? "🔋" : "🪫"} +
+ + {/* Status Display */} +
+
{getStateLabel(status.state)}
+
+ Electricity: {status.electricityLevel}% +
+ {isStopping && ( +
+ Power shutting down in 2 seconds... +
+ )} +
+ + {/* Control Button */} + + + {/* State Machine Debug Info */} +
+
State: {status.state}
+
Wheel Rotation: {status.wheelRotation.toFixed(0)}°
+
Electricity: {status.electricityLevel}%
+
Background: {status.isDark ? "Dark" : "Light"}
+
+ State transitions: + {status.state === "idle" && " → TOGGLE → running"} + {status.state === "running" && " → TOGGLE → stopping"} + {status.state === "stopping" && " → 2s delay → idle (OR TOGGLE → running)"} +
+
+
+ ); +}; + +export const HamsterWheel = () => { + const { status, handleToggle, isLoading } = useHamsterWheel(); + + if (isLoading) { + return ( +
+
Initializing...
+
+ ); + } + + const hasElectricity = status.electricityLevel > 0; + + return ( +
+ {/* Responsive layout: stacked on mobile, side by side on desktop */} +
+
+ +
+ +
+ +
+
+ + {/* Cross-tab sync note */} +
+

+ Using Dexie (IndexedDB) for persistence. Cross-tab sync via liveQuery! +

+
+
+ ); +}; diff --git a/apps/demo-dexie/src/components/ui/button.tsx b/apps/demo-dexie/src/components/ui/button.tsx new file mode 100644 index 0000000..36496a2 --- /dev/null +++ b/apps/demo-dexie/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/apps/demo-dexie/src/data-access/garage-door-operations.ts b/apps/demo-dexie/src/data-access/garage-door-operations.ts new file mode 100644 index 0000000..44755e0 --- /dev/null +++ b/apps/demo-dexie/src/data-access/garage-door-operations.ts @@ -0,0 +1,329 @@ +import { + assign, + createMachine, + effect, + interpret, + invoke, + sendParent, + type MachineSnapshot, + type MachineActor, +} from "effstate"; +import { + WeatherService, + type Weather, +} from "@/lib/services/weather-service"; +import { Data, Duration, Effect, Schedule, Schema, Scope, Stream } from "effect"; + +// ============================================================================ +// Types +// ============================================================================ + +export type GarageDoorState = + | "closed" + | "opening" + | "paused-while-opening" + | "open" + | "closing" + | "paused-while-closing"; + +// Weather status for the UI +export type WeatherStatus = + | { readonly _tag: "idle" } + | { readonly _tag: "loading" } + | { readonly _tag: "loaded"; readonly weather: Weather } + | { readonly _tag: "error"; readonly error: string }; + +const WeatherDataSchema = Schema.Struct({ + status: Schema.Literal("idle", "loading", "loaded", "error"), + temp: Schema.optional(Schema.Number), + desc: Schema.optional(Schema.String), + icon: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}); + +export const GarageDoorContextSchema = Schema.Struct({ + position: Schema.Number, + lastUpdated: Schema.DateFromString, + weather: WeatherDataSchema, + isPowered: Schema.Boolean, +}); + +class Click extends Data.TaggedClass("CLICK")<{}> {} +class Tick extends Data.TaggedClass("TICK")<{ readonly delta: number }> {} +class AnimationComplete extends Data.TaggedClass("ANIMATION_COMPLETE")<{}> {} +export class PowerOn extends Data.TaggedClass("POWER_ON")<{}> {} +export class PowerOff extends Data.TaggedClass("POWER_OFF")<{}> {} +export class BangHammer extends Data.TaggedClass("BANG_HAMMER")<{}> {} + +export type GarageDoorEvent = Click | Tick | AnimationComplete | PowerOn | PowerOff | BangHammer; + +// Event sent to parent when hammer is banged +export class WakeHamster extends Data.TaggedClass("WAKE_HAMSTER")<{}> {} + +// Type alias for snapshot +type GarageDoorContext = typeof GarageDoorContextSchema.Type; +type GarageDoorSnapshot = MachineSnapshot; + +// Initial snapshot defined at module level (doesn't depend on services) +// Exported for use with createUseChildMachineHook +export const initialSnapshot: GarageDoorSnapshot = { + value: "closed", + context: { + position: 0, + lastUpdated: new Date(), + weather: { status: "idle" }, + isPowered: false, + }, + event: null, +}; + +// ============================================================================ +// Animation Activity +// ============================================================================ + +const CYCLE_MS = 10_000; +const TICK_MS = 16; +const DELTA = 100 / (CYCLE_MS / TICK_MS); + +const animation = (dir: 1 | -1) => ({ + id: `animation-${dir}`, + src: ({ send }: { send: (e: GarageDoorEvent) => void }) => + Stream.fromSchedule(Schedule.spaced(Duration.millis(TICK_MS))).pipe( + Stream.runForEach(() => Effect.sync(() => send(new Tick({ delta: dir * DELTA })))), + ), +}); + +// ============================================================================ +// Garage Door Machine Service +// ============================================================================ + +// Default location (San Francisco) +const DEFAULT_LAT = 37.7749; +const DEFAULT_LON = -122.4194; + +/** + * GarageDoor machine as an Effect.Service. + * + * The machine is defined inside the service, capturing WeatherService at creation time. + * This provides: + * - Clean types (no explicit R parameter needed) + * - No type casts + * - Automatic R channel composition via `dependencies` + * + * Parent machines should yield this service to access `.definition` for `spawnChild`. + */ +export class GarageDoorMachineService extends Effect.Service()( + "GarageDoorMachineService", + { + effect: Effect.gen(function* () { + // Capture WeatherService at service creation time + const weatherService = yield* WeatherService; + + // Machine definition with closure over weatherService + const machine = createMachine< + GarageDoorState, + GarageDoorEvent, + typeof GarageDoorContextSchema + >({ + id: "garageDoor", + initial: "closed", + context: GarageDoorContextSchema, + initialContext: { + position: 0, + lastUpdated: new Date(), + weather: { status: "idle" }, + isPowered: false, + }, + states: { + closed: { + entry: [assign(() => ({ position: 0, lastUpdated: new Date(), weather: { status: "idle" } }))], + on: { + CLICK: { target: "opening", guard: ({ context }) => context.isPowered }, + POWER_ON: { actions: [assign(() => ({ isPowered: true }))] }, + POWER_OFF: { actions: [assign(() => ({ isPowered: false }))] }, + BANG_HAMMER: { actions: [sendParent(new WakeHamster())] }, + }, + }, + + opening: { + entry: [effect(() => Effect.log("Entering: opening"))], + activities: [animation(1)], + on: { + CLICK: { target: "paused-while-opening", guard: ({ context }) => context.isPowered }, + TICK: { + guard: ({ context }) => context.isPowered, + actions: [ + assign(({ context, event }) => ({ + position: Math.min(100, context.position + event.delta), + lastUpdated: new Date(), + })), + ], + }, + ANIMATION_COMPLETE: { target: "open", guard: ({ context }) => context.isPowered }, + POWER_ON: { actions: [assign(() => ({ isPowered: true }))] }, + POWER_OFF: { actions: [assign(() => ({ isPowered: false }))] }, + BANG_HAMMER: { actions: [sendParent(new WakeHamster())] }, + }, + }, + + "paused-while-opening": { + entry: [effect(() => Effect.log("Entering: paused-while-opening"))], + on: { + CLICK: { target: "closing", guard: ({ context }) => context.isPowered }, + POWER_ON: { actions: [assign(() => ({ isPowered: true }))] }, + POWER_OFF: { actions: [assign(() => ({ isPowered: false }))] }, + BANG_HAMMER: { actions: [sendParent(new WakeHamster())] }, + }, + }, + + open: { + entry: [ + assign(() => ({ position: 100, lastUpdated: new Date(), weather: { status: "loading" } })), + effect(() => Effect.log("Entering: open - fetching weather")), + ], + invoke: invoke({ + id: "fetchWeather", + src: () => weatherService.getWeather(DEFAULT_LAT, DEFAULT_LON), + assignResult: { + success: ({ output }) => ({ + weather: { + status: "loaded", + temp: output.temperature, + desc: output.description, + icon: output.icon, + }, + }), + catchTags: { + WeatherNetworkError: ({ error }) => ({ + weather: { status: "error", error: `Network: ${error.message}` }, + }), + WeatherParseError: ({ error }) => ({ + weather: { status: "error", error: `Parse: ${error.message}` }, + }), + }, + defect: ({ defect }) => ({ + weather: { status: "error", error: `Unexpected: ${String(defect)}` }, + }), + }, + }), + on: { + CLICK: { target: "closing", guard: ({ context }) => context.isPowered }, + POWER_ON: { actions: [assign(() => ({ isPowered: true }))] }, + POWER_OFF: { actions: [assign(() => ({ isPowered: false }))] }, + BANG_HAMMER: { actions: [sendParent(new WakeHamster())] }, + }, + }, + + closing: { + entry: [ + effect(() => Effect.log("Entering: closing")), + assign(() => ({ weather: { status: "idle" } })), + ], + activities: [animation(-1)], + on: { + CLICK: { target: "paused-while-closing", guard: ({ context }) => context.isPowered }, + TICK: { + guard: ({ context }) => context.isPowered, + actions: [ + assign(({ context, event }) => ({ + position: Math.max(0, context.position + event.delta), + lastUpdated: new Date(), + })), + ], + }, + ANIMATION_COMPLETE: { target: "closed", guard: ({ context }) => context.isPowered }, + POWER_ON: { actions: [assign(() => ({ isPowered: true }))] }, + POWER_OFF: { actions: [assign(() => ({ isPowered: false }))] }, + BANG_HAMMER: { actions: [sendParent(new WakeHamster())] }, + }, + }, + + "paused-while-closing": { + entry: [effect(() => Effect.log("Entering: paused-while-closing"))], + on: { + CLICK: { target: "opening", guard: ({ context }) => context.isPowered }, + POWER_ON: { actions: [assign(() => ({ isPowered: true }))] }, + POWER_OFF: { actions: [assign(() => ({ isPowered: false }))] }, + BANG_HAMMER: { actions: [sendParent(new WakeHamster())] }, + }, + }, + }, + }); + + return { + /** The machine definition - use with spawnChild in parent machines */ + definition: machine, + /** Create a new actor instance */ + createActor: (): Effect.Effect< + MachineActor, + never, + Scope.Scope + > => interpret(machine), + }; + }), + // WeatherService is automatically provided via dependencies + dependencies: [WeatherService.Default], + } +) {} + +// ============================================================================ +// Exported Events (for component use) +// ============================================================================ + +export { Click, AnimationComplete }; + +// ============================================================================ +// Status Helpers +// ============================================================================ + +export interface GarageDoorStatus { + readonly state: GarageDoorState; + readonly position: number; + readonly weather: WeatherStatus; +} + +export const getWeatherStatus = (context: { weather: { status: "idle" | "loading" | "loaded" | "error"; temp?: number | undefined; desc?: string | undefined; icon?: string | undefined; error?: string | undefined } }): WeatherStatus => { + switch (context.weather.status) { + case "loading": + return { _tag: "loading" }; + case "loaded": + return { + _tag: "loaded", + weather: { + temperature: context.weather.temp!, + description: context.weather.desc!, + icon: context.weather.icon!, + }, + }; + case "error": + return { _tag: "error", error: context.weather.error! }; + default: + return { _tag: "idle" }; + } +}; + +// ============================================================================ +// UI Helpers +// ============================================================================ + +const stateLabels: Record = { + closed: "Closed", + opening: "Opening...", + "paused-while-opening": "Paused (was opening)", + open: "Open", + closing: "Closing...", + "paused-while-closing": "Paused (was closing)", +}; + +export const getStateLabel = (state: GarageDoorState) => stateLabels[state]; + +const buttonLabels: Record = { + closed: "Open Door", + opening: "Pause", + "paused-while-opening": "Close Door", + open: "Close Door", + closing: "Pause", + "paused-while-closing": "Open Door", +}; + +export const getButtonLabel = (state: GarageDoorState) => buttonLabels[state]; diff --git a/apps/demo-dexie/src/data-access/hamster-wheel-operations.ts b/apps/demo-dexie/src/data-access/hamster-wheel-operations.ts new file mode 100644 index 0000000..dddd529 --- /dev/null +++ b/apps/demo-dexie/src/data-access/hamster-wheel-operations.ts @@ -0,0 +1,530 @@ +import { Atom } from "@effect-atom/atom-react"; +import { useLiveQuery } from "dexie-react-hooks"; +import { appRuntime } from "@/lib/app-runtime"; +import { + assign, + createMachine, + effect, + interpret, + sendTo, + spawnChild, + type MachineSnapshot, + type MachineActor, +} from "effstate"; +import { + createUseChildMachineHook, + createUseMachineHook, +} from "@effstate/react"; +import { Data, Duration, Effect, Schedule, Schema, Scope, Stream, SubscriptionRef } from "effect"; +import { useCallback, useEffect, useState } from "react"; +import { + GarageDoorMachineService, + GarageDoorContextSchema, + PowerOn, + PowerOff, + WakeHamster, + type GarageDoorState, + type GarageDoorEvent, + initialSnapshot as garageDoorInitialSnapshot, +} from "./garage-door-operations"; +import { createCrossTabSync } from "@/lib/cross-tab-leader"; +import { DexieService, type EffStateDexie } from "@/lib/services/dexie"; +import { StatePersistence, type PersistedState } from "@/lib/services/state-persistence"; + +// ============================================================================ +// Types +// ============================================================================ + +export type HamsterWheelState = "idle" | "running" | "stopping"; + +const HamsterWheelContextSchema = Schema.Struct({ + wheelRotation: Schema.Number, + electricityLevel: Schema.Number, +}); + +class Toggle extends Data.TaggedClass("TOGGLE")<{}> {} +class Tick extends Data.TaggedClass("TICK")<{ readonly delta: number }> {} + +type HamsterWheelEvent = Toggle | Tick | WakeHamster; + +type HamsterWheelContext = typeof HamsterWheelContextSchema.Type; +type HamsterWheelSnapshot = MachineSnapshot; + +const initialSnapshot: HamsterWheelSnapshot = { + value: "idle", + context: { + wheelRotation: 0, + electricityLevel: 0, + }, + event: null, +}; + +// ============================================================================ +// Animation Activity +// ============================================================================ + +const TICK_MS = 16; +const ROTATION_SPEED = 5; // degrees per tick + +const wheelAnimation = { + id: "wheel-animation", + src: ({ send }: { send: (e: HamsterWheelEvent) => void }) => + Stream.fromSchedule(Schedule.spaced(Duration.millis(TICK_MS))).pipe( + Stream.runForEach(() => Effect.sync(() => send(new Tick({ delta: ROTATION_SPEED })))), + ), +}; + +// ============================================================================ +// Hamster Wheel Machine Service +// ============================================================================ + +const GARAGE_DOOR_LEFT_ID = "garageDoorLeft"; +const GARAGE_DOOR_RIGHT_ID = "garageDoorRight"; + +/** + * HamsterWheel machine as an Effect.Service. + * + * Same pattern as the localStorage demo, but persistence is handled + * by Dexie (IndexedDB) instead of localStorage. + */ +export class HamsterWheelMachineService extends Effect.Service()( + "HamsterWheelMachineService", + { + effect: Effect.gen(function* () { + // Yield child service to get its machine definition + const garageDoorService = yield* GarageDoorMachineService; + + // Machine definition with closure over child service + const machine = createMachine({ + id: "hamsterWheel", + initial: "idle", + context: HamsterWheelContextSchema, + initialContext: { + wheelRotation: 0, + electricityLevel: 0, + }, + states: { + idle: { + entry: [ + effect(() => Effect.log("Hamster is resting - lights out")), + assign(() => ({ electricityLevel: 0 })), + // Spawn both garage doors + spawnChild(garageDoorService.definition, { id: GARAGE_DOOR_LEFT_ID }), + spawnChild(garageDoorService.definition, { id: GARAGE_DOOR_RIGHT_ID }), + // Power off both garage doors + sendTo(GARAGE_DOOR_LEFT_ID, new PowerOff()), + sendTo(GARAGE_DOOR_RIGHT_ID, new PowerOff()), + ], + on: { + TOGGLE: { target: "running" }, + // Child garage doors can wake the hamster by banging the hammer! + WAKE_HAMSTER: { target: "running" }, + }, + }, + + running: { + entry: [ + effect(() => Effect.log("Hamster is running! Generating electricity")), + assign(() => ({ electricityLevel: 100 })), + // Power on both garage doors + sendTo(GARAGE_DOOR_LEFT_ID, new PowerOn()), + sendTo(GARAGE_DOOR_RIGHT_ID, new PowerOn()), + ], + activities: [wheelAnimation], + on: { + TOGGLE: { target: "stopping" }, + TICK: { + actions: [ + assign(({ context, event }) => ({ + wheelRotation: (context.wheelRotation + event.delta) % 360, + })), + ], + }, + }, + }, + + stopping: { + entry: [ + effect(() => Effect.log("Hamster stopped - electricity draining in 2 seconds...")), + // Power stays on during stopping - only turns off when entering idle + ], + after: { + delay: Duration.seconds(2), + transition: { target: "idle" }, + }, + on: { + TOGGLE: { target: "running" }, + }, + }, + }, + }); + + return { + /** The machine definition */ + definition: machine, + /** Create a new actor instance */ + createActor: (): Effect.Effect< + MachineActor, + never, + Scope.Scope + > => interpret(machine), + }; + }), + // Depend on GarageDoorMachineService - this chains the dependency on WeatherService + dependencies: [GarageDoorMachineService.Default], + } +) {} + +// ============================================================================ +// Dexie-Based Persistence +// ============================================================================ + +const MACHINE_ID = "hamsterWheel"; +const LEADER_KEY = "hamsterWheel:dexie"; + +// Schema for garage door snapshot (encoded form for JSON) +const GarageDoorSnapshotSchema = Schema.Struct({ + value: Schema.Literal("closed", "opening", "paused-while-opening", "open", "closing", "paused-while-closing"), + context: GarageDoorContextSchema, +}); + +/** + * Save the actor state to IndexedDB via Dexie. + * This replaces localStorage.setItem. + */ +const saveStateToDexie = ( + actor: MachineActor +): void => { + const parentSnapshot = actor.getSnapshot(); + const leftChild = actor.children.get(GARAGE_DOOR_LEFT_ID); + const rightChild = actor.children.get(GARAGE_DOOR_RIGHT_ID); + + // Build children snapshots + const leftSnapshot = leftChild ? { + value: leftChild.getSnapshot().value as GarageDoorState, + context: leftChild.getSnapshot().context as typeof GarageDoorContextSchema.Type, + } : undefined; + + const rightSnapshot = rightChild ? { + value: rightChild.getSnapshot().value as GarageDoorState, + context: rightChild.getSnapshot().context as typeof GarageDoorContextSchema.Type, + } : undefined; + + const state: PersistedState = { + parent: { + value: parentSnapshot.value, + context: parentSnapshot.context, + }, + children: { + garageDoorLeft: leftSnapshot, + garageDoorRight: rightSnapshot, + }, + }; + + // Run the persistence effect using Effect.runFork + Effect.runFork( + Effect.gen(function* () { + const persistence = yield* StatePersistence; + yield* persistence.save(MACHINE_ID, state); + console.log("[Dexie] Saved state:", state.parent.value); + }).pipe( + Effect.provide(StatePersistence.Default), + Effect.provide(DexieService.Default), + ) + ); +}; + +/** + * Load the actor state from IndexedDB via Dexie. + * This replaces localStorage.getItem. + */ +const loadStateFromDexie = (): Effect.Effect< + { snapshot: HamsterWheelSnapshot; childSnapshots: Map> } | null, + never, + StatePersistence +> => + Effect.gen(function* () { + const persistence = yield* StatePersistence; + const state = yield* persistence.load(MACHINE_ID); + if (!state) return null; + + const snapshot: HamsterWheelSnapshot = { + value: state.parent.value, + context: state.parent.context, + event: null, + }; + + const childSnapshots = new Map>(); + + if (state.children[GARAGE_DOOR_LEFT_ID]) { + childSnapshots.set(GARAGE_DOOR_LEFT_ID, { + value: state.children[GARAGE_DOOR_LEFT_ID].value, + context: state.children[GARAGE_DOOR_LEFT_ID].context, + event: null, + }); + } + + if (state.children[GARAGE_DOOR_RIGHT_ID]) { + childSnapshots.set(GARAGE_DOOR_RIGHT_ID, { + value: state.children[GARAGE_DOOR_RIGHT_ID].value, + context: state.children[GARAGE_DOOR_RIGHT_ID].context, + event: null, + }); + } + + console.log("[Dexie] Loaded state:", snapshot.value); + return { snapshot, childSnapshots }; + }); + +// ============================================================================ +// Cross-Tab Sync with Dexie +// ============================================================================ + +let currentActor: MachineActor | null = null; + +// Cross-tab sync - leader writes to Dexie, followers react via liveQuery +const crossTabSync = createCrossTabSync({ + storageKey: LEADER_KEY, + onSave: () => { + if (currentActor) saveStateToDexie(currentActor); + }, +}); + +// ============================================================================ +// Atom Integration with Dexie Persistence +// ============================================================================ + +const actorAtom = appRuntime + .atom( + Effect.gen(function* () { + const hamsterWheelService = yield* HamsterWheelMachineService; + + // Try to load persisted state from Dexie + const persisted = yield* loadStateFromDexie(); + + // Create actor with optional restored state + const actor = yield* persisted + ? interpret(hamsterWheelService.definition, { + snapshot: persisted.snapshot, + childSnapshots: persisted.childSnapshots, + }) + : interpret(hamsterWheelService.definition); + + // Store reference for cross-tab sync + currentActor = actor; + + // Save on state changes (only if leader) + actor.subscribe(() => crossTabSync.saveIfLeader()); + + // Also save when child actors change state + actor.children.forEach((child) => { + child.subscribe(() => crossTabSync.saveIfLeader()); + }); + + return actor; + }).pipe( + Effect.provide(HamsterWheelMachineService.Default), + Effect.provide(StatePersistence.Default), + ) + ) + .pipe(Atom.keepAlive); + +const snapshotAtom = appRuntime + .subscriptionRef((get) => + Effect.gen(function* () { + const actor = yield* get.result(actorAtom); + const ref = yield* SubscriptionRef.make(actor.getSnapshot()); + actor.subscribe((snapshot) => { + Effect.runSync(SubscriptionRef.set(ref, snapshot)); + }); + return ref; + }) + ) + .pipe(Atom.keepAlive); + +const useHamsterWheelMachine = createUseMachineHook( + actorAtom, + snapshotAtom, + initialSnapshot, +); + +// ============================================================================ +// Cross-Tab Sync via Dexie liveQuery +// ============================================================================ + +/** + * Hook to sync state from Dexie when another tab makes changes. + * Uses Dexie's liveQuery for reactive IndexedDB updates. + */ +const useDexieCrossTabSync = () => { + // Get the Dexie database from the runtime + const [db, setDb] = useState(null); + + useEffect(() => { + // Initialize the database reference + const init = Effect.gen(function* () { + const dexie = yield* DexieService; + return dexie.db; + }).pipe(Effect.provide(DexieService.Default)); + + Effect.runPromise(init).then(setDb); + }, []); + + // Use liveQuery to watch for changes from other tabs + const persistedState = useLiveQuery( + () => db?.machineStates.get(MACHINE_ID), + [db], + undefined + ); + + // Sync when state changes and we're not the leader + useEffect(() => { + if (!persistedState || !currentActor || crossTabSync.isLeader()) return; + + // Reconstruct the state from the persisted row + try { + const raw = { + parent: { + value: persistedState.parentValue, + context: persistedState.parentContext, + }, + children: persistedState.childSnapshots, + }; + + // Decode the schema + const PersistedStateSchema = Schema.Struct({ + parent: Schema.Struct({ + value: Schema.Literal("idle", "running", "stopping"), + context: HamsterWheelContextSchema, + }), + children: Schema.Struct({ + garageDoorLeft: Schema.optional(GarageDoorSnapshotSchema), + garageDoorRight: Schema.optional(GarageDoorSnapshotSchema), + }), + }); + + const decoded = Schema.decodeUnknownSync(PersistedStateSchema)(raw); + + const snapshot: HamsterWheelSnapshot = { + value: decoded.parent.value, + context: decoded.parent.context, + event: null, + }; + + const childSnapshots = new Map>(); + + if (decoded.children.garageDoorLeft) { + childSnapshots.set(GARAGE_DOOR_LEFT_ID, { + value: decoded.children.garageDoorLeft.value, + context: decoded.children.garageDoorLeft.context, + event: null, + }); + } + + if (decoded.children.garageDoorRight) { + childSnapshots.set(GARAGE_DOOR_RIGHT_ID, { + value: decoded.children.garageDoorRight.value, + context: decoded.children.garageDoorRight.context, + event: null, + }); + } + + console.log("[Dexie liveQuery] Syncing from other tab:", snapshot.value); + currentActor._syncSnapshot(snapshot, childSnapshots); + } catch (e) { + console.warn("[Dexie liveQuery] Failed to decode persisted state:", e); + } + }, [persistedState]); +}; + +// ============================================================================ +// Child Machine Hooks (Garage Doors) +// ============================================================================ + +// Type for garage door context (inferred from schema) +type GarageDoorContext = typeof garageDoorInitialSnapshot.context; + +export const useGarageDoorLeft = createUseChildMachineHook< + HamsterWheelState, + HamsterWheelContext, + HamsterWheelEvent, + GarageDoorState, + GarageDoorContext, + GarageDoorEvent +>( + appRuntime, + actorAtom, + GARAGE_DOOR_LEFT_ID, + garageDoorInitialSnapshot, +); + +export const useGarageDoorRight = createUseChildMachineHook< + HamsterWheelState, + HamsterWheelContext, + HamsterWheelEvent, + GarageDoorState, + GarageDoorContext, + GarageDoorEvent +>( + appRuntime, + actorAtom, + GARAGE_DOOR_RIGHT_ID, + garageDoorInitialSnapshot, +); + +// ============================================================================ +// React Hook +// ============================================================================ + +export interface HamsterWheelStatus { + readonly state: HamsterWheelState; + readonly wheelRotation: number; + readonly electricityLevel: number; + readonly isDark: boolean; +} + +export const useHamsterWheel = (): { + status: HamsterWheelStatus; + handleToggle: () => void; + isLoading: boolean; +} => { + const { snapshot, send, isLoading, context } = useHamsterWheelMachine(); + + // Enable cross-tab sync via Dexie liveQuery + useDexieCrossTabSync(); + + const handleToggle = useCallback(() => { + send(new Toggle()); + }, [send]); + + return { + status: { + state: snapshot.value, + wheelRotation: context.wheelRotation, + electricityLevel: context.electricityLevel, + isDark: snapshot.value === "idle", + }, + handleToggle, + isLoading, + }; +}; + +// ============================================================================ +// UI Helpers +// ============================================================================ + +const stateLabels: Record = { + idle: "Resting", + running: "Running!", + stopping: "Slowing down...", +}; + +export const getStateLabel = (state: HamsterWheelState) => stateLabels[state]; + +const buttonLabels: Record = { + idle: "Wake Up Hamster", + running: "Stop Hamster", + stopping: "Start Running Again", +}; + +export const getButtonLabel = (state: HamsterWheelState) => buttonLabels[state]; diff --git a/apps/demo-dexie/src/index.css b/apps/demo-dexie/src/index.css new file mode 100644 index 0000000..6d499fe --- /dev/null +++ b/apps/demo-dexie/src/index.css @@ -0,0 +1,65 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; +@layer base { + :root { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + --primary: 60 9.1% 97.8%; + --primary-foreground: 24 9.8% 10%; + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 60 9.1% 97.8%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + --ring: 24 5.7% 82.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } + .dark { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + --primary: 60 9.1% 97.8%; + --primary-foreground: 24 9.8% 10%; + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 60 9.1% 97.8%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + --ring: 24 5.7% 82.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/demo-dexie/src/lib/app-runtime.ts b/apps/demo-dexie/src/lib/app-runtime.ts new file mode 100644 index 0000000..e7d1152 --- /dev/null +++ b/apps/demo-dexie/src/lib/app-runtime.ts @@ -0,0 +1,27 @@ +import { Atom } from "@effect-atom/atom-react"; +import { Layer, Logger } from "effect"; +import { WeatherService } from "@/lib/services/weather-service"; +import { DexieService } from "@/lib/services/dexie"; +import { StatePersistence } from "@/lib/services/state-persistence"; +import { MachineRegistry } from "effstate"; + +// Base services layer +const ServicesLayer = Layer.mergeAll( + Logger.pretty, + WeatherService.Default, + DexieService.Default, + StatePersistence.Default, +); + +// Machine registry layer (for service-based spawning) +const MachineLayer = MachineRegistry.Default; + +// Combined app layer +// Note: Machine services (GarageDoorMachineService, HamsterWheelMachineService) +// are provided inline where needed to avoid circular imports +const AppLayer = Layer.mergeAll( + ServicesLayer, + MachineLayer, +); + +export const appRuntime = Atom.runtime(AppLayer); diff --git a/apps/demo-dexie/src/lib/cross-tab-leader.ts b/apps/demo-dexie/src/lib/cross-tab-leader.ts new file mode 100644 index 0000000..dcba3a2 --- /dev/null +++ b/apps/demo-dexie/src/lib/cross-tab-leader.ts @@ -0,0 +1,113 @@ +/** + * Cross-Tab Leader Election + * + * Provides a simple leader election mechanism for cross-tab/window synchronization. + * The newest window becomes the leader, and leadership can be reclaimed on focus. + * + * With Dexie, we still use leader election to coordinate writes, but we don't need + * BroadcastChannel for sync since Dexie's liveQuery handles cross-tab reactivity. + * + * @example + * ```ts + * const sync = createCrossTabSync({ + * storageKey: "myApp:state", + * onSave: () => saveState(actor), + * }); + * + * // Call when state changes + * actor.subscribe(() => sync.saveIfLeader()); + * ``` + */ + +export interface CrossTabSyncConfig { + /** Storage key for the leader coordination */ + storageKey: string; + /** Called to save state (only when leader) */ + onSave: () => void; + /** Throttle interval in ms (default: 500) */ + throttleMs?: number; +} + +export interface CrossTabSync { + /** Save state if this window is the leader (throttled) */ + saveIfLeader: () => void; + /** Check if this window is currently the leader */ + isLeader: () => boolean; + /** Manually claim leadership */ + claimLeadership: () => void; + /** Clean up event listeners */ + destroy: () => void; +} + +export function createCrossTabSync(config: CrossTabSyncConfig): CrossTabSync { + const { storageKey, onSave, throttleMs = 500 } = config; + + const leaderKey = `${storageKey}:leader`; + const windowId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + + let saveTimeout: ReturnType | null = null; + let pendingSave = false; + + const claimLeadership = () => { + localStorage.setItem(leaderKey, windowId); + }; + + const isLeader = () => { + return localStorage.getItem(leaderKey) === windowId; + }; + + const saveIfLeader = () => { + if (!isLeader()) return; + + if (saveTimeout) { + pendingSave = true; + return; + } + + onSave(); + + saveTimeout = setTimeout(() => { + saveTimeout = null; + if (pendingSave && isLeader()) { + pendingSave = false; + onSave(); + } + }, throttleMs); + }; + + // Event handlers + const handleFocus = () => { + claimLeadership(); + }; + + const handleBeforeUnload = () => { + if (isLeader()) { + localStorage.removeItem(leaderKey); + } + }; + + // Initialize + if (typeof window !== "undefined") { + claimLeadership(); + + window.addEventListener("focus", handleFocus); + window.addEventListener("beforeunload", handleBeforeUnload); + } + + const destroy = () => { + if (typeof window !== "undefined") { + window.removeEventListener("focus", handleFocus); + window.removeEventListener("beforeunload", handleBeforeUnload); + if (isLeader()) { + localStorage.removeItem(leaderKey); + } + } + }; + + return { + saveIfLeader, + isLeader, + claimLeadership, + destroy, + }; +} diff --git a/apps/demo-dexie/src/lib/services/dexie.ts b/apps/demo-dexie/src/lib/services/dexie.ts new file mode 100644 index 0000000..bd2fcec --- /dev/null +++ b/apps/demo-dexie/src/lib/services/dexie.ts @@ -0,0 +1,96 @@ +import Dexie, { type EntityTable } from "dexie"; +import { Data, Effect } from "effect"; + +// ============================================================================ +// Errors +// ============================================================================ + +export class DexieQueryError extends Data.TaggedError("DexieQueryError")<{ + readonly cause: unknown; +}> {} + +// ============================================================================ +// Table Types +// ============================================================================ + +/** + * Machine state row stored in IndexedDB. + * + * We store the state as JSON-compatible values since IndexedDB handles + * serialization automatically. The context is stored as a plain object + * that matches the Schema-encoded form. + */ +export interface MachineStateRow { + /** Unique identifier for the machine instance (e.g., "hamsterWheel") */ + id: string; + /** The current state value (e.g., "idle", "running") */ + parentValue: string; + /** The serialized parent context */ + parentContext: unknown; + /** Serialized child snapshots */ + childSnapshots: unknown; + /** When the state was last updated */ + updatedAt: Date; +} + +// ============================================================================ +// Dexie Database +// ============================================================================ + +/** + * EffState Dexie database for storing machine states. + * + * Uses Dexie 4.x EntityTable pattern for type-safe tables. + */ +class EffStateDexie extends Dexie { + machineStates!: EntityTable; + + constructor() { + super("effstate"); + + this.version(1).stores({ + // id is the primary key, updatedAt is indexed for queries + machineStates: "id, updatedAt", + }); + } +} + +// ============================================================================ +// Dexie Service +// ============================================================================ + +/** + * Dexie service for IndexedDB access. + * + * Following the sync-engine-web pattern of wrapping Dexie in an Effect.Service. + * This provides: + * - Type-safe database access + * - Effect-based error handling + * - Composable with other Effect services + */ +export class DexieService extends Effect.Service()( + "DexieService", + { + effect: Effect.sync(() => { + const db = new EffStateDexie(); + + return { + /** The raw Dexie database instance */ + db, + + /** + * Execute a query against the database. + * Wraps Dexie's Promise-based API in Effect for proper error handling. + */ + query: (execute: (db: EffStateDexie) => Promise): Effect.Effect => + Effect.tryPromise({ + try: () => execute(db), + catch: (cause) => new DexieQueryError({ cause }), + }), + }; + }), + } +) {} + +// Re-export the database type for use in other modules +export type { EffStateDexie }; diff --git a/apps/demo-dexie/src/lib/services/state-persistence.ts b/apps/demo-dexie/src/lib/services/state-persistence.ts new file mode 100644 index 0000000..5193097 --- /dev/null +++ b/apps/demo-dexie/src/lib/services/state-persistence.ts @@ -0,0 +1,118 @@ +import { Effect, Schema } from "effect"; +import { DexieService, type MachineStateRow } from "./dexie"; +import { GarageDoorContextSchema } from "@/data-access/garage-door-operations"; + +// ============================================================================ +// Schemas +// ============================================================================ + +const HamsterWheelContextSchema = Schema.Struct({ + wheelRotation: Schema.Number, + electricityLevel: Schema.Number, +}); + +const GarageDoorSnapshotSchema = Schema.Struct({ + value: Schema.Literal("closed", "opening", "paused-while-opening", "open", "closing", "paused-while-closing"), + context: GarageDoorContextSchema, +}); + +const PersistedStateSchema = Schema.Struct({ + parent: Schema.Struct({ + value: Schema.Literal("idle", "running", "stopping"), + context: HamsterWheelContextSchema, + }), + children: Schema.Struct({ + garageDoorLeft: Schema.optional(GarageDoorSnapshotSchema), + garageDoorRight: Schema.optional(GarageDoorSnapshotSchema), + }), +}); + +export type PersistedState = typeof PersistedStateSchema.Type; + +// ============================================================================ +// State Persistence Service +// ============================================================================ + +/** + * State Persistence service for saving/loading machine state to IndexedDB. + * + * This service provides: + * - Type-safe state persistence using Effect.Schema + * - Automatic serialization/deserialization + * - Integration with Dexie for IndexedDB storage + */ +export class StatePersistence extends Effect.Service()( + "StatePersistence", + { + effect: Effect.gen(function* () { + const { db, query } = yield* DexieService; + + return { + /** + * Save machine state to IndexedDB. + * Uses Dexie's put() which creates or updates. + */ + save: (id: string, state: PersistedState): Effect.Effect => + Effect.gen(function* () { + // Encode the state using Schema for proper Date serialization + const encoded = Schema.encodeSync(PersistedStateSchema)(state); + + const row: MachineStateRow = { + id, + parentValue: encoded.parent.value, + parentContext: encoded.parent.context, + childSnapshots: encoded.children, + updatedAt: new Date(), + }; + + yield* query((db) => db.machineStates.put(row)); + }).pipe( + // Log errors but don't fail - persistence is best-effort + Effect.catchAll((error) => + Effect.sync(() => { + console.warn("[StatePersistence] Failed to save:", error); + }) + ) + ), + + /** + * Load machine state from IndexedDB. + * Returns null if no state is found or if decoding fails. + */ + load: (id: string): Effect.Effect => + Effect.gen(function* () { + const row = yield* query((db) => db.machineStates.get(id)); + if (!row) return null; + + // Reconstruct the persisted state from the row + const raw = { + parent: { + value: row.parentValue, + context: row.parentContext, + }, + children: row.childSnapshots, + }; + + // Decode using Schema + const decoded = Schema.decodeUnknownSync(PersistedStateSchema)(raw); + return decoded; + }).pipe( + // Return null on any error + Effect.catchAll((error) => + Effect.sync(() => { + console.warn("[StatePersistence] Failed to load:", error); + return null; + }) + ) + ), + + /** + * Get the raw Dexie database for liveQuery usage. + * Components can use this with useLiveQuery for reactive updates. + */ + getDb: () => db, + }; + }), + dependencies: [DexieService.Default], + } +) {} diff --git a/apps/demo-dexie/src/lib/services/weather-service.ts b/apps/demo-dexie/src/lib/services/weather-service.ts new file mode 100644 index 0000000..d102b0c --- /dev/null +++ b/apps/demo-dexie/src/lib/services/weather-service.ts @@ -0,0 +1,108 @@ +import { Data, Effect, Schema } from "effect"; + +// ============================================================================ +// Types +// ============================================================================ + +const WeatherResponseSchema = Schema.Struct({ + current: Schema.Struct({ + temperature_2m: Schema.Number, + weather_code: Schema.Number, + }), +}); + +export interface Weather { + readonly temperature: number; + readonly description: string; + readonly icon: string; +} + +// ============================================================================ +// Errors +// ============================================================================ + +export class WeatherNetworkError extends Data.TaggedError("WeatherNetworkError")<{ + readonly message: string; +}> {} + +export class WeatherParseError extends Data.TaggedError("WeatherParseError")<{ + readonly message: string; +}> {} + +export type WeatherError = WeatherNetworkError | WeatherParseError; + +// ============================================================================ +// Weather Code to Description +// ============================================================================ + +const weatherCodes: Record = { + 0: { description: "Clear sky", icon: "☀️" }, + 1: { description: "Mainly clear", icon: "🌤️" }, + 2: { description: "Partly cloudy", icon: "⛅" }, + 3: { description: "Overcast", icon: "☁️" }, + 45: { description: "Foggy", icon: "🌫️" }, + 48: { description: "Icy fog", icon: "🌫️" }, + 51: { description: "Light drizzle", icon: "🌧️" }, + 53: { description: "Drizzle", icon: "🌧️" }, + 55: { description: "Heavy drizzle", icon: "🌧️" }, + 61: { description: "Light rain", icon: "🌧️" }, + 63: { description: "Rain", icon: "🌧️" }, + 65: { description: "Heavy rain", icon: "🌧️" }, + 71: { description: "Light snow", icon: "🌨️" }, + 73: { description: "Snow", icon: "🌨️" }, + 75: { description: "Heavy snow", icon: "🌨️" }, + 80: { description: "Rain showers", icon: "🌦️" }, + 81: { description: "Heavy showers", icon: "🌦️" }, + 82: { description: "Violent showers", icon: "⛈️" }, + 95: { description: "Thunderstorm", icon: "⛈️" }, + 96: { description: "Thunderstorm with hail", icon: "⛈️" }, + 99: { description: "Severe thunderstorm", icon: "⛈️" }, +}; + +const getWeatherInfo = (code: number) => + weatherCodes[code] ?? { description: "Unknown", icon: "❓" }; + +// ============================================================================ +// Service +// ============================================================================ + +export class WeatherService extends Effect.Service()("WeatherService", { + effect: Effect.gen(function* () { + yield* Effect.log("Created WeatherService"); + + const getWeather = (lat: number, lon: number): Effect.Effect => + Effect.gen(function* () { + const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code&temperature_unit=fahrenheit`; + + const response = yield* Effect.tryPromise({ + try: () => fetch(url), + catch: (error) => new WeatherNetworkError({ message: String(error) }), + }); + + if (!response.ok) { + return yield* Effect.fail( + new WeatherNetworkError({ message: `HTTP ${response.status}` }) + ); + } + + const json = yield* Effect.tryPromise({ + try: () => response.json(), + catch: (error) => new WeatherParseError({ message: String(error) }), + }); + + const parsed = yield* Schema.decodeUnknown(WeatherResponseSchema)(json).pipe( + Effect.mapError((error) => new WeatherParseError({ message: String(error) })) + ); + + const weatherInfo = getWeatherInfo(parsed.current.weather_code); + + return { + temperature: Math.round(parsed.current.temperature_2m), + description: weatherInfo.description, + icon: weatherInfo.icon, + }; + }); + + return { getWeather }; + }), +}) {} diff --git a/apps/demo-dexie/src/lib/utils.ts b/apps/demo-dexie/src/lib/utils.ts new file mode 100644 index 0000000..37295e1 --- /dev/null +++ b/apps/demo-dexie/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: Parameters) { + return twMerge(clsx(inputs)); +} diff --git a/apps/demo-dexie/src/main.tsx b/apps/demo-dexie/src/main.tsx new file mode 100644 index 0000000..9b67590 --- /dev/null +++ b/apps/demo-dexie/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/apps/demo-dexie/tailwind.config.js b/apps/demo-dexie/tailwind.config.js new file mode 100644 index 0000000..2487604 --- /dev/null +++ b/apps/demo-dexie/tailwind.config.js @@ -0,0 +1,57 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], + theme: { + extend: { + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + chart: { + 1: "hsl(var(--chart-1))", + 2: "hsl(var(--chart-2))", + 3: "hsl(var(--chart-3))", + 4: "hsl(var(--chart-4))", + 5: "hsl(var(--chart-5))", + }, + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; diff --git a/apps/demo-dexie/tsconfig.json b/apps/demo-dexie/tsconfig.json new file mode 100644 index 0000000..4145e33 --- /dev/null +++ b/apps/demo-dexie/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "exactOptionalPropertyTypes": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/apps/demo-dexie/vite.config.ts b/apps/demo-dexie/vite.config.ts new file mode 100644 index 0000000..112c417 --- /dev/null +++ b/apps/demo-dexie/vite.config.ts @@ -0,0 +1,12 @@ +import path from "path"; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eceb22f..1e7c2b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,124 @@ importers: specifier: ^5.3.4 version: 5.4.21(@types/node@22.19.3) + apps/demo-dexie: + dependencies: + '@effect-atom/atom-react': + specifier: ^0.3.4 + version: 0.3.4(@effect/experimental@0.56.0(@effect/platform@0.93.8(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.93.8(effect@3.19.14))(@effect/rpc@0.71.2(@effect/platform@0.93.8(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)(react@18.3.1)(scheduler@0.23.2) + '@effect/opentelemetry': + specifier: ^0.35.0 + version: 0.35.8(@opentelemetry/api@1.9.0)(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-web@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.28.0)(effect@3.19.14) + '@effect/platform': + specifier: ^0.93.8 + version: 0.93.8(effect@3.19.14) + '@effstate/react': + specifier: workspace:* + version: link:../../packages/react + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.52.1 + version: 0.52.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-web': + specifier: ^1.25.1 + version: 1.30.1(@opentelemetry/api@1.9.0) + '@radix-ui/react-slot': + specifier: ^1.1.0 + version: 1.2.4(@types/react@18.3.27)(react@18.3.1) + class-variance-authority: + specifier: ^0.7.0 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dexie: + specifier: ^4.0.11 + version: 4.2.1 + dexie-react-hooks: + specifier: ^1.1.7 + version: 1.1.7(@types/react@18.3.27)(dexie@4.2.1)(react@18.3.1) + effect: + specifier: ^3.19.12 + version: 3.19.14 + effstate: + specifier: workspace:* + version: link:../../packages/core + lucide-react: + specifier: ^0.456.0 + version: 0.456.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + tailwind-merge: + specifier: ^2.5.4 + version: 2.6.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) + devDependencies: + '@effect/vitest': + specifier: ^0.27.0 + version: 0.27.0(effect@3.19.14)(vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@22.19.3)(jiti@1.21.7)(jsdom@27.4.0)(tsx@4.21.0)(yaml@2.8.2)) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.1 + version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node': + specifier: ^22.9.0 + version: 22.19.3 + '@types/react': + specifier: ^18.3.3 + version: 18.3.27 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.27) + '@typescript-eslint/eslint-plugin': + specifier: ^7.15.0 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^7.15.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@vitejs/plugin-react-swc': + specifier: ^3.5.0 + version: 3.11.0(@swc/helpers@0.5.18)(vite@5.4.21(@types/node@22.19.3)) + '@vitest/coverage-v8': + specifier: ^4.0.16 + version: 4.0.16(vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@22.19.3)(jiti@1.21.7)(jsdom@27.4.0)(tsx@4.21.0)(yaml@2.8.2)) + autoprefixer: + specifier: ^10.4.20 + version: 10.4.23(postcss@8.5.6) + eslint: + specifier: ^8.57.0 + version: 8.57.1 + eslint-plugin-react-hooks: + specifier: ^4.6.2 + version: 4.6.2(eslint@8.57.1) + eslint-plugin-react-refresh: + specifier: ^0.4.7 + version: 0.4.26(eslint@8.57.1) + jsdom: + specifier: ^27.3.0 + version: 27.4.0 + postcss: + specifier: ^8.4.49 + version: 8.5.6 + tailwindcss: + specifier: ^3.4.14 + version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) + typescript: + specifier: ^5.2.2 + version: 5.9.3 + vite: + specifier: ^5.3.4 + version: 5.4.21(@types/node@22.19.3) + vitest: + specifier: ^4.0.16 + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@22.19.3)(jiti@1.21.7)(jsdom@27.4.0)(tsx@4.21.0)(yaml@2.8.2) + apps/docs: dependencies: '@astrojs/react': @@ -2497,6 +2615,16 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dexie-react-hooks@1.1.7: + resolution: {integrity: sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==} + peerDependencies: + '@types/react': '>=16' + dexie: ^3.2 || ^4.0.1-alpha + react: '>=16' + + dexie@4.2.1: + resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} + dfa@1.2.0: resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} @@ -6758,6 +6886,14 @@ snapshots: dependencies: dequal: 2.0.3 + dexie-react-hooks@1.1.7(@types/react@18.3.27)(dexie@4.2.1)(react@18.3.1): + dependencies: + '@types/react': 18.3.27 + dexie: 4.2.1 + react: 18.3.1 + + dexie@4.2.1: {} + dfa@1.2.0: {} didyoumean@1.2.2: {}