diff --git a/REACT.md b/REACT.md index 97a44aa..57d313d 100644 --- a/REACT.md +++ b/REACT.md @@ -20,7 +20,8 @@ released against. 4. [Mutations](#4-mutations) 5. [Forms with validation](#5-forms-with-validation) 6. [Testing with a mock layer](#6-testing-with-a-mock-layer) -7. [Caveats](#7-caveats) +7. [Developing against the mock backend](#7-developing-against-the-mock-backend) +8. [Caveats](#8-caveats) --- @@ -69,27 +70,25 @@ import { Client } from '@effect-firebase/client'; import { firestoreLayerAtom } from '../lib/atoms.js'; export function App({ children }) { - const layer = useMemo(() => { + // useState initializer: Firebase setup runs once per mount and the layer + // keeps a stable identity. + const [layer] = useState(() => { const firestore = initializeFirestore(initializeApp({...}), {...}); connectFirestoreEmulator(firestore, 'localhost', 8080); return Client.layer({ firestore }); - }, []); - - const initialValues = useMemo( - () => [[firestoreLayerAtom, layer] as const] as const, - [layer], - ); + }); return ( - + {children} ); } ``` -Wrap the layer in `useMemo` so Firebase initialization doesn't re-run on -every render. Note that `RegistryProvider` reads `initialValues` only when +Create the layer in a `useState` initializer so Firebase initialization runs +once per mount with a stable identity (a side-effecting `useMemo` is rejected +by the React Compiler lint). Note that `RegistryProvider` reads `initialValues` only when the registry is first created — changing the array (or the layer's identity) on a later render is silently ignored. To swap the layer at runtime, set the atom's value in the registry instead — `registry.set(firestoreLayerAtom, newLayer)` @@ -324,7 +323,57 @@ The components under test never change between production and test — only the layer at the registry boundary differs. Vitest needs `environment: 'jsdom'`; see the `test` block in [`example/app/vite.config.ts`](./example/app/vite.config.ts). -## 7. Caveats +## 7. Developing against the mock backend + +For building pages, `@effect-firebase/mock` goes further than per-method +overrides: `make()` returns a full in-memory backend seeded from +schema-encoded fixtures, with a controller for toggling every collection +between **data / empty / loading / error** at runtime. Because the layer atom +is the only seam, the swap is one `initialValues` entry: + +```tsx +// lib/mock.ts — shared by the app runtime and the devtools panel +export const mockBackend = make({ + fixtures: [ + fixture(PostModel, { collectionPath: 'posts', idField: 'id', docs: [...] }), + ], +}); + +// app.tsx — seed the registry with the mock instead of Client.layer + +``` + +`@effect-firebase/devtools` ships the controller as a TanStack Devtools +plugin, so the states can be flipped from a panel while the page is running: + +```tsx +import { TanStackDevtools } from '@tanstack/react-devtools'; +import { firestoreMockPlugin } from '@effect-firebase/devtools'; + + bumpEpoch((epoch) => epoch + 1), + }), + ]} +/>; +``` + +The example app wires this up behind an env flag — run `pnpm example:mock` +and open the devtools panel on the Firestore page. See +[`example/app/src/lib/atoms.ts`](./example/app/src/lib/atoms.ts) (the +`mockEpochAtom` / `Atom.family` pattern), +[`example/app/src/lib/mock.ts`](./example/app/src/lib/mock.ts) and +[`example/app/src/app/app.tsx`](./example/app/src/app/app.tsx). + +## 8. Caveats - **`@effect/atom-react` is lockstep with `effect` betas.** Each release of `@effect/atom-react@4.0.0-beta.N` peer-depends on `effect@^4.0.0-beta.N`. Bump diff --git a/example/app/package.json b/example/app/package.json index 7306e9d..5558797 100644 --- a/example/app/package.json +++ b/example/app/package.json @@ -10,11 +10,14 @@ "packageManager": "pnpm@10.25.0", "dependencies": { "@effect-firebase/client": "workspace:*", + "@effect-firebase/devtools": "workspace:*", + "@effect-firebase/mock": "workspace:*", "@effect/atom-react": "catalog:", "@effect/platform-browser": "catalog:", "@example/shared": "workspace:*", "@nx/vite": "23.1.1", "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-devtools": "^0.10.8", "@tanstack/react-form": "^1.33.3", "@tanstack/react-router": "^1.170.19", "@tanstack/react-router-devtools": "^1.167.1", @@ -30,7 +33,6 @@ "tailwind-merge": "^3.6.0" }, "devDependencies": { - "@effect-firebase/mock": "workspace:*", "vite": "8.2.0" } } diff --git a/example/app/src/app/app.tsx b/example/app/src/app/app.tsx index c7dd7c7..9444e1b 100644 --- a/example/app/src/app/app.tsx +++ b/example/app/src/app/app.tsx @@ -1,33 +1,86 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { initializeApp } from 'firebase/app'; import { getFunctions, connectFunctionsEmulator } from 'firebase/functions'; import { connectFirestoreEmulator, initializeFirestore, } from 'firebase/firestore'; +import { Layer } from 'effect'; import { Client } from '@effect-firebase/client'; -import { RegistryProvider } from '@effect/atom-react'; +import { RegistryProvider, useAtomSet } from '@effect/atom-react'; +import { TanStackDevtools } from '@tanstack/react-devtools'; +import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'; +import { + firestoreMockPlugin, + type TanStackDevtoolsReactPlugin, +} from '@effect-firebase/devtools'; import SideMenu from '../components/menu/side-menu.js'; import MenuItem from '../components/menu/menu-item.js'; -import { firestoreLayerAtom } from '../lib/atoms.js'; +import { firestoreLayerAtom, mockEpochAtom } from '../lib/atoms.js'; +import { mockBackend } from '../lib/mock.js'; interface AppProps { children: React.ReactNode; } +/** + * Start the app with `VITE_MOCK_BACKEND=1` (e.g. `pnpm example:mock`) to run + * Firestore against the in-memory mock backend instead of the emulator. + */ +const useMockBackend = import.meta.env['VITE_MOCK_BACKEND'] === '1'; + +/** + * One TanStack Devtools shell hosting the router panel and, in mock mode, + * the Firestore Mock panel. Every state toggle bumps `mockEpochAtom`, which + * remounts the data views: their atoms are disposed and the fresh + * subscriptions start from `Initial` against the toggled state. A refresh + * would not be enough — atoms keep their previous value while re-running, + * and a `loading` stream never emits, so stale data would stay on screen. + */ +function Devtools() { + const bumpEpoch = useAtomSet(mockEpochAtom); + const plugins = useMemo(() => { + const all: Array = [ + { + name: 'TanStack Router', + render: , + }, + ]; + if (useMockBackend) { + all.push( + firestoreMockPlugin(mockBackend.controller, { + defaultOpen: true, + onStateChange: () => { + bumpEpoch((epoch) => epoch + 1); + }, + }), + ); + } + return all; + }, [bumpEpoch]); + return ; +} + export function App({ children }: AppProps) { - const layer = useMemo(() => { + // useState initializer: Firebase setup runs once per mount, and the layer + // keeps a stable identity without a memo the compiler can't verify. + const [layer] = useState(() => { const app = initializeApp({ projectId: 'effect-firebase-example' }); const functions = getFunctions(app, 'europe-north1'); connectFunctionsEmulator(functions, 'localhost', 5001); + if (useMockBackend) { + // Fixture encoding errors are defects, not recoverable failures. + return Layer.orDie(mockBackend.layer); + } + const firestore = initializeFirestore(app, { ignoreUndefinedProperties: true, }); connectFirestoreEmulator(firestore, 'localhost', 8080); return Client.layer({ firestore }); - }, []); + }); // RegistryProvider reads initialValues only when the registry is first // created, so the array doesn't need a stable identity. @@ -45,6 +98,7 @@ export function App({ children }: AppProps) {
{children}
+
); } diff --git a/example/app/src/lib/atoms.ts b/example/app/src/lib/atoms.ts index ce54cbd..fbeb3ef 100644 --- a/example/app/src/lib/atoms.ts +++ b/example/app/src/lib/atoms.ts @@ -29,6 +29,16 @@ export const firestoreLayerAtom = Atom.keepAlive( ), ); +/** + * Bumped by the Firestore Mock devtools after every state toggle (mock mode + * only). Views key their data subtree on this value, so a toggle remounts + * the subtree: the old atoms are disposed and the fresh subscriptions start + * from `Initial` against the new state. A plain refresh is not enough — + * atom results keep their previous value while re-running, and a stream in + * the `loading` state never emits, so the stale data would stay on screen. + */ +export const mockEpochAtom = Atom.keepAlive(Atom.make(0)); + /** * Runtime atom — rebuilds whenever `firestoreLayerAtom` changes in the * registry (via `registry.set` / `useAtomSet`; `initialValues` is only read @@ -58,10 +68,17 @@ export const postByIdLiveAtom = Atom.family((id: typeof PostId.Type) => .pipe(Atom.setIdleTTL('30 seconds')), ); -// Live list of latest posts. A single canonical atom (no family) so every -// subscriber shares one Firestore subscription. -export const latestPostsAtom = clientRuntime.atom( - Stream.unwrap(Effect.map(PostRepository, (r) => r.latestPosts())), +// Live list of latest posts, keyed by the mock epoch. All subscribers pass +// the same epoch, so they share one Firestore subscription; a bumped epoch +// yields a *new* atom identity that re-subscribes from `Initial`. That is +// what makes the devtools' simulated `loading`/`error` states visible on an +// already-mounted page — an atom's retained value survives both refreshes +// and remounts, so only a fresh identity starts over. Outside mock mode the +// epoch is always `0` and this behaves like a single canonical atom. +export const latestPostsAtom = Atom.family((_epoch: number) => + clientRuntime.atom( + Stream.unwrap(Effect.map(PostRepository, (r) => r.latestPosts())), + ), ); // Mutations — writable atoms exposing AsyncResult state and a setter. diff --git a/example/app/src/lib/mock.ts b/example/app/src/lib/mock.ts new file mode 100644 index 0000000..f50d249 --- /dev/null +++ b/example/app/src/lib/mock.ts @@ -0,0 +1,74 @@ +import { DateTime, Option } from 'effect'; +import { fixture, make } from '@effect-firebase/mock'; +import { AuthorId, AuthorModel, PostId, PostModel } from '@example/shared'; + +const at = (iso: string) => DateTime.makeUnsafe(iso); + +const author = (id: string, name: string, created: string) => + new AuthorModel({ + id: AuthorId.make(id), + name, + createdAt: at(created), + updatedAt: at(created), + }); + +const post = ( + id: string, + title: string, + content: string, + created: string, + authorId = 'ada', +) => + new PostModel({ + id: PostId.make(id), + title, + content, + author: AuthorId.make(authorId), + createdAt: at(created), + updatedAt: at(created), + checked: false, + optional: Option.none(), + list: [], + }); + +/** + * A static mock backend for developing pages without the Firebase emulator. + * + * Enabled by starting the app with `VITE_MOCK_BACKEND=1` (see `app.tsx`). + * The handle is shared between the app runtime (which provides + * `mockBackend.layer` through `firestoreLayerAtom`) and the Firestore Mock + * devtools panel (which drives `mockBackend.controller`). + */ +export const mockBackend = make({ + fixtures: [ + fixture(AuthorModel, { + collectionPath: 'authors', + idField: 'id', + docs: [author('ada', 'Ada Lovelace', '2024-01-01T09:00:00Z')], + }), + fixture(PostModel, { + collectionPath: 'posts', + idField: 'id', + docs: [ + post( + 'welcome', + 'Welcome to mock mode', + 'This post is served from the in-memory mock backend — no emulator running. Open the TanStack Devtools panel to toggle this collection between data, empty, loading and error.', + '2024-05-03T10:00:00Z', + ), + post( + 'fixtures', + 'Fixtures are schema-encoded', + 'These documents were written through PostModel, so timestamps, references and options decode exactly like production data.', + '2024-05-02T15:30:00Z', + ), + post( + 'try-writing', + 'Writes are live', + 'Create, edit or delete posts — the mock store is reactive, so the stream behind this list re-emits just like onSnapshot.', + '2024-05-01T08:15:00Z', + ), + ], + }), + ], +}); diff --git a/example/app/src/routes/__root.tsx b/example/app/src/routes/__root.tsx index cc513e8..29a2949 100644 --- a/example/app/src/routes/__root.tsx +++ b/example/app/src/routes/__root.tsx @@ -1,16 +1,16 @@ import { Outlet, createRootRoute } from '@tanstack/react-router'; -import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; import App from '../app/app'; export const Route = createRootRoute({ component: RootComponent, }); +// Devtools (router + Firestore mock) are mounted by in a single +// TanStack Devtools shell. function RootComponent() { return ( - ); } diff --git a/example/app/src/routes/firestore.tsx b/example/app/src/routes/firestore.tsx index 0b4cf28..95f105d 100644 --- a/example/app/src/routes/firestore.tsx +++ b/example/app/src/routes/firestore.tsx @@ -20,6 +20,7 @@ import { addPostAtom, updatePostAtom, deletePostAtom, + mockEpochAtom, } from '../lib/atoms.js'; export const Route = createFileRoute('/firestore')({ @@ -180,7 +181,11 @@ function PostForm({ } export function PostList({ onEdit }: { onEdit: (post: Post) => void }) { - const result = useAtomValue(latestPostsAtom); + // The epoch is bumped by the Firestore Mock devtools on every state + // toggle; a new epoch keys a new atom identity, so the list re-subscribes + // from `Initial` against the toggled state (always 0 outside mock mode). + const mockEpoch = useAtomValue(mockEpochAtom); + const result = useAtomValue(latestPostsAtom(mockEpoch)); const remove = useAtomSet(deletePostAtom, { mode: 'promise' }); const [deleteError, setDeleteError] = useState(null); diff --git a/example/app/tsconfig.app.json b/example/app/tsconfig.app.json index e08a4df..63f9971 100644 --- a/example/app/tsconfig.app.json +++ b/example/app/tsconfig.app.json @@ -32,6 +32,9 @@ { "path": "../../packages/mock/tsconfig.lib.json" }, + { + "path": "../../packages/devtools/tsconfig.lib.json" + }, { "path": "../shared/tsconfig.lib.json" }, diff --git a/package.json b/package.json index 88f2bc9..8a58ef5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "start": "nx start", "example:emulator": "nx run @example/backend:emulator", "example:hosting": "nx run @example/app:dev", + "example:mock": "VITE_MOCK_BACKEND=1 nx run @example/app:dev", "release": "nx release --skip-publish" }, "private": true, diff --git a/packages/devtools/README.md b/packages/devtools/README.md new file mode 100644 index 0000000..b9c6d58 --- /dev/null +++ b/packages/devtools/README.md @@ -0,0 +1,94 @@ +# @effect-firebase/devtools + +Devtools for developing Effect Firebase apps against the [`@effect-firebase/mock`](../mock) backend: a panel that lets you toggle every collection between **data / empty / loading / error**, pick the simulated error code, dial in latency, and reset to your fixtures — live, while your app is running. + +Ships as a [TanStack Devtools](https://tanstack.com/devtools/latest) plugin and as a standalone React component. + +## Installation + +```bash +npm install --save-dev @effect-firebase/devtools @effect-firebase/mock +``` + +## Usage with TanStack Devtools + +Create the mock backend with `make()` (instead of `layer()`) so you get a handle both your app runtime and the devtools panel can share: + +```tsx +import { TanStackDevtools } from '@tanstack/react-devtools'; +import { make, fixture } from '@effect-firebase/mock'; +import { firestoreMockPlugin } from '@effect-firebase/devtools'; + +// Fixtures built with fixture()/rawFixture() from @effect-firebase/mock — +// see that package's README. +const posts = fixture(PostModel, { + collectionPath: 'posts', + idField: 'id', + docs: [new PostModel({/* ... */})], +}); + +const mock = make({ + fixtures: [posts], +}); + +// Provide mock.layer wherever your app builds its Effect runtime. +// With effect-atom, for example: +// const runtime = Atom.runtime(mock.layer); + +export function App() { + return ( + <> + {/* ... */} + + + ); +} +``` + +Only mount the devtools (and provide the mock layer) in development builds — for example behind `import.meta.env.DEV`. + +## Standalone panel + +The panel is a plain React component, so it can also live in a sidebar, a Storybook decorator, or anywhere else: + +```tsx +import { MockDevtoolsPanel } from '@effect-firebase/devtools'; + +; +``` + +## Options + +Both `firestoreMockPlugin(controller, options)` and `` accept: + +- `collections` — extra collection paths to always show, even before any document or state exists for them. +- `onStateChange(collectionPath, state)` — called after a toggle is applied. + +`firestoreMockPlugin` additionally accepts `id`, `name` and `defaultOpen` for the TanStack Devtools shell. + +### Making toggles visible on already-mounted pages + +Two states are only observable at **subscription time**: a simulated `error` fails live streams terminally (matching `onSnapshot` semantics), and `loading` makes streams silent. A consumer that already holds data keeps showing it — with effect-atom, a result retains its previous value across `registry.refresh` and even component remounts, so neither is enough to reveal the toggled state. + +Give the read a fresh **atom identity** instead: key it through `Atom.family` by an epoch that `onStateChange` bumps. A new epoch is a new atom, and a new atom starts from `Initial` against the toggled state — spinner for `loading`, failure for `error`, data on recovery: + +```tsx +const mockEpochAtom = Atom.make(0); + +const postsAtom = Atom.family((_epoch: number) => + runtime.atom(/* your stream */), +); + +firestoreMockPlugin(mock.controller, { + onStateChange: () => registry.update(mockEpochAtom, (epoch) => epoch + 1), +}); + +// In components: +const result = useAtomValue(postsAtom(useAtomValue(mockEpochAtom))); +``` + +Outside mock mode the epoch never changes, so the family behaves like a single shared atom. See `example/app` for the full wiring. + +## License + +MIT diff --git a/packages/devtools/eslint.config.mjs b/packages/devtools/eslint.config.mjs new file mode 100644 index 0000000..0712e8a --- /dev/null +++ b/packages/devtools/eslint.config.mjs @@ -0,0 +1,10 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/packages/devtools/package.json b/packages/devtools/package.json new file mode 100644 index 0000000..a7bceeb --- /dev/null +++ b/packages/devtools/package.json @@ -0,0 +1,46 @@ +{ + "name": "@effect-firebase/devtools", + "version": "1.0.0-beta.3", + "private": false, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/fwal/effect-firebase", + "directory": "packages/devtools" + }, + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "@effect-firebase/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "!**/*.tsbuildinfo" + ], + "nx": {}, + "dependencies": { + "tslib": "^2.3.0" + }, + "devDependencies": { + "@effect-firebase/mock": "workspace:*", + "effect": "catalog:", + "effect-firebase": "workspace:*", + "react": "19.2.8" + }, + "peerDependencies": { + "@effect-firebase/mock": "workspace:*", + "effect": "catalog:", + "react": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts new file mode 100644 index 0000000..fc88481 --- /dev/null +++ b/packages/devtools/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/panel.js'; +export * from './lib/plugin.js'; diff --git a/packages/devtools/src/lib/panel.spec.tsx b/packages/devtools/src/lib/panel.spec.tsx new file mode 100644 index 0000000..71429b7 --- /dev/null +++ b/packages/devtools/src/lib/panel.spec.tsx @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Effect } from 'effect'; +import { make, rawFixture } from '@effect-firebase/mock'; +import { MockDevtoolsPanel } from './panel.js'; +import { firestoreMockPlugin } from './plugin.js'; + +const makeHandle = () => + make({ + fixtures: [ + rawFixture('posts', { + '1': { title: 'Alpha' }, + '2': { title: 'Beta' }, + }), + rawFixture('authors', { + '1': { name: 'Ada' }, + }), + ], + }); + +/** Builds the handle's layer so fixtures are seeded into the store. */ +const seed = (handle: ReturnType) => + Effect.runPromise( + Effect.provide(Effect.void, handle.layer) as Effect.Effect, + ); + +describe('MockDevtoolsPanel', () => { + it('lists collections with document counts', async () => { + const handle = makeHandle(); + await seed(handle); + + render(); + + expect(await screen.findByText('posts')).toBeDefined(); + expect(await screen.findByText('authors')).toBeDefined(); + expect(await screen.findByText('2 docs')).toBeDefined(); + expect(await screen.findByText('1 docs')).toBeDefined(); + }); + + it('toggles a collection state through the controller', async () => { + const handle = makeHandle(); + await seed(handle); + + render(); + await screen.findByText('posts'); + + const postsRow = screen.getByText('posts').parentElement as HTMLElement; + fireEvent.click( + Array.from(postsRow.querySelectorAll('button')).find( + (button) => button.textContent === 'loading', + ) as HTMLElement, + ); + + await waitFor(async () => { + const states = await Effect.runPromise(handle.controller.states); + expect(states['posts']?._tag).toBe('Loading'); + }); + }); + + it('applies the selected error code', async () => { + const handle = makeHandle(); + await seed(handle); + + render(); + await screen.findByText('posts'); + + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'permission-denied' }, + }); + const postsRow = screen.getByText('posts').parentElement as HTMLElement; + fireEvent.click( + Array.from(postsRow.querySelectorAll('button')).find( + (button) => button.textContent === 'error', + ) as HTMLElement, + ); + + await waitFor(async () => { + const states = await Effect.runPromise(handle.controller.states); + const state = states['posts']; + expect(state?._tag).toBe('Error'); + if (state?._tag === 'Error') { + expect(state.error.code).toBe('permission-denied'); + } + }); + }); + + it('reflects external state changes live', async () => { + const handle = makeHandle(); + await seed(handle); + + render(); + await screen.findByText('posts'); + + await Effect.runPromise( + handle.controller.setDoc('comments/1', { body: 'Hi' }), + ); + + expect(await screen.findByText('comments')).toBeDefined(); + }); + + it('notifies onStateChange after a toggle', async () => { + const handle = makeHandle(); + await seed(handle); + const seen: Array<[string, string]> = []; + + render( + { + seen.push([collection, state._tag]); + }} + />, + ); + await screen.findByText('posts'); + + const postsRow = screen.getByText('posts').parentElement as HTMLElement; + fireEvent.click( + Array.from(postsRow.querySelectorAll('button')).find( + (button) => button.textContent === 'empty', + ) as HTMLElement, + ); + + // The callback fires only after the state change has been applied. + expect(seen).toEqual([]); + await waitFor(() => { + expect(seen).toEqual([['posts', 'Empty']]); + }); + const states = await Effect.runPromise(handle.controller.states); + expect(states['posts']?._tag).toBe('Empty'); + }); + + it('notifies onStateChange with the wildcard on reset and clear', async () => { + const handle = makeHandle(); + await seed(handle); + const seen: Array<[string, string]> = []; + + render( + { + seen.push([collection, state._tag]); + }} + />, + ); + await screen.findByText('posts'); + + fireEvent.click(screen.getByText('reset')); + await waitFor(() => { + expect(seen).toEqual([['*', 'Data']]); + }); + + fireEvent.click(screen.getByText('clear')); + await waitFor(() => { + expect(seen).toEqual([ + ['*', 'Data'], + ['*', 'Data'], + ]); + }); + }); + + it('reports the restored initial state on reset', async () => { + const handle = make({ + fixtures: [rawFixture('posts', { '1': { title: 'Alpha' } })], + states: { '*': 'empty' }, + }); + await seed(handle); + const seen: Array<[string, string]> = []; + + render( + { + seen.push([collection, state._tag]); + }} + />, + ); + await screen.findByText('posts'); + + // Reset restores the configured initial wildcard state, not `data`. + fireEvent.click(screen.getByText('reset')); + await waitFor(() => { + expect(seen).toEqual([['*', 'Empty']]); + }); + }); +}); + +describe('firestoreMockPlugin', () => { + it('produces a TanStack Devtools plugin descriptor', () => { + const handle = makeHandle(); + const plugin = firestoreMockPlugin(handle.controller, { + defaultOpen: true, + }); + expect(plugin.id).toBe('effect-firebase-mock'); + expect(plugin.name).toBe('Firestore Mock'); + expect(plugin.defaultOpen).toBe(true); + expect(plugin.render).toBeDefined(); + }); +}); diff --git a/packages/devtools/src/lib/panel.tsx b/packages/devtools/src/lib/panel.tsx new file mode 100644 index 0000000..54b225c --- /dev/null +++ b/packages/devtools/src/lib/panel.tsx @@ -0,0 +1,348 @@ +import { useEffect, useMemo, useState, type CSSProperties } from 'react'; +import { Duration, Effect, Fiber, Stream } from 'effect'; +import { + MockState, + type MockControllerShape, + type StoreSnapshot, +} from '@effect-firebase/mock'; + +export interface MockDevtoolsPanelProps { + /** + * The controller of the mock backend, from `make()` in + * `@effect-firebase/mock`. + */ + readonly controller: MockControllerShape; + /** + * Called after a state change has been applied. Use this to re-subscribe + * consumers that terminated on a simulated error — e.g. refresh the atoms + * or queries reading from the collection. Clearing the wildcard state and + * resetting the backend notify with the wildcard key (`'*'`) and the + * effective wildcard state after the operation (reset restores the + * backend's configured initial states, which may not be `data`). + */ + readonly onStateChange?: ( + collectionPath: string, + state: MockState.State, + ) => void; +} + +type StateName = 'data' | 'empty' | 'loading' | 'error'; + +const STATE_NAMES: ReadonlyArray = [ + 'data', + 'empty', + 'loading', + 'error', +]; + +const ERROR_CODES = [ + 'unavailable', + 'permission-denied', + 'unauthenticated', + 'not-found', + 'resource-exhausted', + 'deadline-exceeded', +] as const; + +const stateName = (state: MockState.State): StateName => + state._tag.toLowerCase() as StateName; + +/** The collection path a document path belongs to. */ +const collectionOf = (docPath: string): string => + docPath.split('/').slice(0, -1).join('/'); + +const palette: Record = { + data: '#22c55e', + empty: '#64748b', + loading: '#f59e0b', + error: '#ef4444', +}; + +const styles = { + panel: { + fontFamily: + "'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas, monospace", + fontSize: 12, + lineHeight: 1.5, + color: '#e5e7eb', + background: '#16181d', + padding: 12, + height: '100%', + boxSizing: 'border-box', + overflow: 'auto', + } satisfies CSSProperties, + toolbar: { + display: 'flex', + alignItems: 'center', + gap: 8, + flexWrap: 'wrap', + paddingBottom: 10, + borderBottom: '1px solid #2a2d35', + marginBottom: 10, + } satisfies CSSProperties, + label: { + color: '#9ca3af', + } satisfies CSSProperties, + input: { + background: '#1f2229', + color: '#e5e7eb', + border: '1px solid #2a2d35', + borderRadius: 4, + padding: '2px 6px', + fontSize: 12, + fontFamily: 'inherit', + width: 64, + } satisfies CSSProperties, + select: { + background: '#1f2229', + color: '#e5e7eb', + border: '1px solid #2a2d35', + borderRadius: 4, + padding: '2px 6px', + fontSize: 12, + fontFamily: 'inherit', + } satisfies CSSProperties, + row: { + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '4px 0', + } satisfies CSSProperties, + collection: { + flex: 1, + minWidth: 120, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + } satisfies CSSProperties, + count: { + color: '#9ca3af', + minWidth: 56, + textAlign: 'right', + } satisfies CSSProperties, + buttonGroup: { + display: 'flex', + gap: 4, + } satisfies CSSProperties, + emptyMessage: { + color: '#9ca3af', + padding: '8px 0', + } satisfies CSSProperties, +}; + +const stateButtonStyle = ( + name: StateName, + active: boolean, + inherited: boolean, +): CSSProperties => ({ + background: active ? palette[name] : 'transparent', + color: active ? '#0b0d10' : palette[name], + opacity: active && inherited ? 0.6 : 1, + border: `1px solid ${palette[name]}`, + borderRadius: 4, + padding: '1px 8px', + fontSize: 11, + fontFamily: 'inherit', + fontWeight: active ? 700 : 400, + cursor: 'pointer', +}); + +const actionButtonStyle: CSSProperties = { + background: 'transparent', + color: '#9ca3af', + border: '1px solid #2a2d35', + borderRadius: 4, + padding: '1px 8px', + fontSize: 11, + fontFamily: 'inherit', + cursor: 'pointer', +}; + +/** + * A devtools panel for the `@effect-firebase/mock` backend: toggle each + * collection between data / empty / loading / error, pick the simulated + * error code, control latency, and reset to the initial fixtures. + * + * Works standalone or embedded as a TanStack Devtools plugin via + * `firestoreMockPlugin`. + */ +export function MockDevtoolsPanel({ + controller, + onStateChange, +}: MockDevtoolsPanelProps) { + const [snapshot, setSnapshot] = useState(); + const [errorCode, setErrorCode] = + useState<(typeof ERROR_CODES)[number]>('unavailable'); + const [latencyMs, setLatencyMs] = useState(0); + + useEffect(() => { + const fiber = Effect.runFork( + Stream.runForEach(controller.changes, (current) => + Effect.sync(() => { + setSnapshot(current); + }), + ), + ); + void Effect.runPromise(controller.latency).then((latency) => { + setLatencyMs(Duration.toMillis(latency)); + }); + return () => { + Effect.runFork(Fiber.interrupt(fiber)); + }; + }, [controller]); + + const rows = useMemo(() => { + const known = new Set(); + for (const docPath of Object.keys(snapshot?.docs ?? {})) { + known.add(collectionOf(docPath)); + } + for (const key of Object.keys(snapshot?.states ?? {})) { + if (key !== MockState.All) { + known.add(key); + } + } + return [...known].sort(); + }, [snapshot]); + + const docCount = (collectionPath: string): number => { + const prefix = `${collectionPath}/`; + return Object.keys(snapshot?.docs ?? {}).filter( + (path) => + path.startsWith(prefix) && !path.slice(prefix.length).includes('/'), + ).length; + }; + + const toInput = (name: StateName): MockState.StateInput => + name === 'error' ? MockState.error(errorCode) : name; + + // Notify only after the controller effect has applied, so a refresh + // triggered by the callback re-subscribes against the new state. + const setState = (collectionPath: string, name: StateName): void => { + const state = MockState.fromInput(toInput(name)); + void Effect.runPromise(controller.setState(collectionPath, state)).then( + () => { + onStateChange?.(collectionPath, state); + }, + ); + }; + + // Clearing the wildcard and resetting notify with the wildcard key so + // consumers refresh broadly. The reported state is read back from the + // controller: reset restores the *initial* states, which may not be + // `data` when the backend was created with configured states. + const notifyEffectiveAfter = (effect: Effect.Effect): void => { + void Effect.runPromise( + Effect.flatMap(effect, () => controller.states), + ).then((states) => { + onStateChange?.(MockState.All, MockState.resolve(states, MockState.All)); + }); + }; + + const clearAll = (): void => { + notifyEffectiveAfter(controller.clearState(MockState.All)); + }; + + const reset = (): void => { + notifyEffectiveAfter(controller.reset); + }; + + const applyLatency = (value: number): void => { + // The input's min={0} doesn't stop typed negative or invalid values. + const latency = Number.isFinite(value) ? Math.max(0, value) : 0; + setLatencyMs(latency); + void Effect.runPromise(controller.setLatency(`${latency} millis`)); + }; + + const stateRow = (key: string, explicitOnly: boolean) => { + const states = snapshot?.states ?? {}; + const explicit = states[key]; + const effective = explicitOnly ? explicit : MockState.resolve(states, key); + const inherited = explicit === undefined; + return ( +
+ {STATE_NAMES.map((name) => { + const active = + effective !== undefined && stateName(effective) === name; + return ( + + ); + })} +
+ ); + }; + + return ( +
+
+ all collections + {stateRow(MockState.All, true)} + + + error code + + latency + applyLatency(Number(event.target.value) || 0)} + /> + ms + +
+ {rows.length === 0 ? ( +
+ No collections yet — seed fixtures or write a document. +
+ ) : ( + rows.map((collectionPath) => ( +
+ {collectionPath} + {docCount(collectionPath)} docs + {stateRow(collectionPath, false)} +
+ )) + )} +
+ ); +} diff --git a/packages/devtools/src/lib/plugin.tsx b/packages/devtools/src/lib/plugin.tsx new file mode 100644 index 0000000..6164417 --- /dev/null +++ b/packages/devtools/src/lib/plugin.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from 'react'; +import type { MockControllerShape } from '@effect-firebase/mock'; +import { MockDevtoolsPanel, type MockDevtoolsPanelProps } from './panel.js'; + +/** + * The plugin shape accepted by `` from + * `@tanstack/react-devtools`. Declared structurally so this package does not + * depend on TanStack Devtools itself. + */ +export interface TanStackDevtoolsReactPlugin { + readonly id?: string; + readonly name: ReactNode; + readonly render: ReactNode; + readonly defaultOpen?: boolean; +} + +export interface FirestoreMockPluginOptions extends Omit< + MockDevtoolsPanelProps, + 'controller' +> { + /** + * Open this panel by default when the devtools shell opens. + */ + readonly defaultOpen?: boolean; +} + +/** + * Create a TanStack Devtools plugin that renders the mock backend's control + * panel. + * + * @example + * ```tsx + * import { TanStackDevtools } from '@tanstack/react-devtools'; + * import { make } from '@effect-firebase/mock'; + * import { firestoreMockPlugin } from '@effect-firebase/devtools'; + * + * const mock = make({ fixtures: [posts] }); + * + * + * ``` + */ +export const firestoreMockPlugin = ( + controller: MockControllerShape, + options: FirestoreMockPluginOptions = {}, +): TanStackDevtoolsReactPlugin => ({ + id: 'effect-firebase-mock', + name: 'Firestore Mock', + defaultOpen: options.defaultOpen, + render: ( + + ), +}); diff --git a/packages/devtools/tsconfig.json b/packages/devtools/tsconfig.json new file mode 100644 index 0000000..62ebbd9 --- /dev/null +++ b/packages/devtools/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/devtools/tsconfig.lib.json b/packages/devtools/tsconfig.lib.json new file mode 100644 index 0000000..09fc88b --- /dev/null +++ b/packages/devtools/tsconfig.lib.json @@ -0,0 +1,37 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["es2022", "dom", "dom.iterable"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx" + ], + "references": [ + { + "path": "../effect-firebase/tsconfig.lib.json" + }, + { + "path": "../mock/tsconfig.lib.json" + } + ] +} diff --git a/packages/devtools/tsconfig.spec.json b/packages/devtools/tsconfig.spec.json new file mode 100644 index 0000000..398144c --- /dev/null +++ b/packages/devtools/tsconfig.spec.json @@ -0,0 +1,36 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "jsx": "react-jsx", + "lib": ["es2022", "dom", "dom.iterable"], + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ], + "forceConsistentCasingInFileNames": true + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/devtools/vite.config.ts b/packages/devtools/vite.config.ts new file mode 100644 index 0000000..a0b8a63 --- /dev/null +++ b/packages/devtools/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/devtools', + plugins: [], + test: { + name: '@effect-firebase/devtools', + watch: false, + globals: true, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); diff --git a/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts b/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts index 3f4b65d..c75ac24 100644 --- a/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts +++ b/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts @@ -8,16 +8,16 @@ export class Timestamp extends Schema.Class('Timestamp')({ nanoseconds: Schema.Number, }) { static fromDate(date: Date): Timestamp { - return new Timestamp({ - seconds: Math.floor(date.getTime() / 1000), - nanoseconds: (date.getTime() % 1000) * 1000000, - }); + return Timestamp.fromMillis(date.getTime()); } static fromMillis(millis: number): Timestamp { + const seconds = Math.floor(millis / 1000); return new Timestamp({ - seconds: Math.floor(millis / 1000), - nanoseconds: (millis % 1000) * 1000000, + seconds, + // Nanoseconds are always non-negative (matching Firestore), so the + // seconds/nanos split roundtrips for pre-1970 instants too. + nanoseconds: (millis - seconds * 1000) * 1000000, }); } diff --git a/packages/mock/README.md b/packages/mock/README.md index 4dc9ff0..8347a78 100644 --- a/packages/mock/README.md +++ b/packages/mock/README.md @@ -1,6 +1,14 @@ # @effect-firebase/mock -In-memory `FirestoreService` implementation for testing Effect Firebase applications. No Firebase connection required. +An in-memory, reactive `FirestoreService` implementation for testing and developing Effect Firebase applications. No Firebase connection required. + +Beyond a plain test double, the mock is a small simulated backend built for **developer experience**: + +- **Fixtures** — seed hard-coded models through your real schemas, so reads exercise the exact decoding path production data takes. +- **Reactive streams** — `streamDoc` / `streamQuery` are live: writes and runtime toggles push new emissions through already-subscribed streams, just like `onSnapshot`. +- **Simulated states** — flip any collection between `data`, `empty`, `loading` and `error` at runtime with the `MockController`, and watch your UI's spinner, empty and error paths render with no backend involved. +- **Latency simulation** — add artificial delay to every operation. +- **Write fidelity** — server timestamps materialize on write, `delete`/`arrayUnion`/`arrayRemove` sentinels are honored, and queries (where, orderBy, cursors, limits) are evaluated in-process. ## Installation @@ -10,7 +18,7 @@ npm install --save-dev @effect-firebase/mock ## Usage -Provide `mockFirestore` in place of the real Admin or Client layer: +Provide `layer` in place of the real Admin or Client layer: ```typescript import { Effect } from 'effect'; @@ -27,23 +35,123 @@ await Effect.runPromise( }); const post = yield* repo.getById(postId); expect(post.title).toBe('Test'); - }).pipe(Effect.provide(PostRepository), Effect.provide(mockFirestore)), + }).pipe(Effect.provide(PostRepository), Effect.provide(mockFirestore())), ); ``` -Each `Effect.provide(mockFirestore)` call gets a fresh in-memory store, so tests are isolated by default. +Each `Effect.provide(layer())` call gets a fresh in-memory store, so tests are isolated by default. -## Multiple repositories +## Fixtures + +Seed the backend with hard-coded models. Documents are encoded through the model's schema, so `getById`, `query` and streams decode them exactly like real data: ```typescript -const testLayer = Layer.mergeAll(mockFirestore, PostRepository, UserRepository); +import { fixture, layer } from '@effect-firebase/mock'; +import { DateTime } from 'effect'; + +const posts = fixture(PostModel, { + collectionPath: 'posts', + idField: 'id', + docs: [ + new PostModel({ + id: PostId.make('1'), + title: 'Hello world', + content: '...', + createdAt: DateTime.makeUnsafe('2024-01-01'), + // ... + }), + ], +}); -await Effect.runPromise( - Effect.gen(function* () { - const posts = yield* PostRepository; - const users = yield* UserRepository; - // ... - }).pipe(Effect.provide(testLayer)), +const mock = layer({ fixtures: [posts] }); +``` + +For documents without a model schema, use `rawFixture` with already-encoded data: + +```typescript +import { rawFixture } from '@effect-firebase/mock'; + +const settings = rawFixture('settings', { + general: { theme: 'dark' }, +}); +``` + +To fill a page with volume (long lists, pagination, layout stress), map over an array — `fixture` takes any `ReadonlyArray` of models: + +```typescript +const manyPosts = fixture(PostModel, { + collectionPath: 'posts', + idField: 'id', + docs: Array.from({ length: 50 }, (_, i) => makePost(i)), +}); +``` + +## Simulated states + +The layer also provides a `MockController` service for driving the backend at runtime — from tests, a dev panel, or a devtools plugin: + +```typescript +import { layer, MockController, MockState } from '@effect-firebase/mock'; + +Effect.gen(function* () { + const controller = yield* MockController; + + // Live streams re-emit immediately: + yield* controller.setState('posts', 'empty'); + yield* controller.setState('posts', 'loading'); // reads hang, streams go silent + yield* controller.setState('posts', 'error'); // reads/writes fail: code 'unavailable' + yield* controller.setState('posts', MockState.error('permission-denied')); + yield* controller.setState('posts', 'data'); // back to normal + + // Apply to every collection at once: + yield* controller.setState(MockState.All, 'loading'); + + // Other controls: + yield* controller.setLatency('300 millis'); + yield* controller.seed(morePosts); + yield* controller.reset; +}); +``` + +States can also be set up front: + +```typescript +const mock = layer({ + fixtures: [posts], + states: { comments: 'loading' }, + latency: '200 millis', +}); +``` + +## Driving the backend from outside Effect + +`make()` returns a handle instead of just a layer: the same options as `layer()`, plus direct access to the controller as a plain value. Every controller effect requires no services, so React components, Storybook decorators or test helpers can run them with `Effect.runPromise` directly. This is what the [`@effect-firebase/devtools`](../devtools) panel builds on: + +```typescript +import { Effect } from 'effect'; +import { Atom } from 'effect/unstable/reactivity'; +import { make } from '@effect-firebase/mock'; + +const mock = make({ fixtures: [posts] }); + +// Provide mock.layer to your app runtime (all provides share one store)... +const runtime = Atom.runtime(mock.layer); + +// ...and drive the same store from anywhere: +await Effect.runPromise(mock.controller.setState('posts', 'loading')); +``` + +Notes on semantics: + +- `empty` affects reads only; writes still land in the store. +- `loading` suspends reads _and_ writes, and live streams stop emitting. A stream subscribed while loading emits nothing until the state flips. +- `error` fails effects per call. A live stream fails **terminally** (matching `onSnapshot` semantics) — consumers must re-subscribe after the state recovers, e.g. by refreshing the atom/query that owns the stream. + +## Multiple repositories + +```typescript +const testLayer = Layer.mergeAll(PostRepository, UserRepository).pipe( + Layer.provideMerge(layer({ fixtures: [posts, users] })), ); ``` @@ -56,7 +164,7 @@ await Effect.runPromise( yield* repo.getById('nonexistent'); }).pipe( Effect.provide(PostRepository), - Effect.provide(mockFirestore), + Effect.provide(layer()), Effect.catchTag('NoSuchElementError', () => Effect.succeed('not found')), ), ); @@ -65,7 +173,8 @@ await Effect.runPromise( ## Limitations - In-memory only — no persistence between process restarts -- Queries are evaluated in-process — behaviour may differ from real Firestore for edge cases +- Queries are evaluated in-process — behaviour may differ from real Firestore for edge cases (composite index requirements are not enforced, `not-in`/`!=` null semantics are simplified) +- Simulated states are keyed per collection path (or the `'*'` wildcard), not per query - No security rules evaluation - `withTransaction` and `withBatch` run the effect directly — no retries, no rollback, and no staged writes - No multi-client synchronization diff --git a/packages/mock/src/index.ts b/packages/mock/src/index.ts index ad957c1..b84f0dd 100644 --- a/packages/mock/src/index.ts +++ b/packages/mock/src/index.ts @@ -1 +1,7 @@ export * from './lib/firestore/firestore-service.js'; +export * as MockState from './lib/firestore/state.js'; +export * from './lib/firestore/fixture.js'; +export * from './lib/firestore/controller.js'; +export * from './lib/firestore/layer.js'; +export type { StoreSnapshot } from './lib/firestore/store.js'; +export type { DocData } from './lib/firestore/value.js'; diff --git a/packages/mock/src/lib/firestore/controller.ts b/packages/mock/src/lib/firestore/controller.ts new file mode 100644 index 0000000..887bfd7 --- /dev/null +++ b/packages/mock/src/lib/firestore/controller.ts @@ -0,0 +1,88 @@ +import { Context, Duration, Effect, Schema, Stream } from 'effect'; +import type { Fixture } from './fixture.js'; +import type * as MockState from './state.js'; +import type { StoreSnapshot } from './store.js'; +import type { DocData } from './value.js'; + +export interface MockControllerShape { + /** + * Set the simulated state for a collection path. Live streams reading from + * the collection switch immediately. Use {@link MockState.All} (`'*'`) to + * apply to every collection without an explicit state. + * + * @example + * ```ts + * yield* controller.setState('posts', 'loading'); + * yield* controller.setState('posts', MockState.error('permission-denied')); + * ``` + */ + readonly setState: ( + collectionPath: string, + state: MockState.StateInput, + ) => Effect.Effect; + + /** + * Remove the simulated state for a collection path, falling back to the + * wildcard state or `data`. + */ + readonly clearState: (collectionPath: string) => Effect.Effect; + + /** + * The currently configured states, keyed by collection path. + */ + readonly states: Effect.Effect>>; + + /** + * All stored documents, keyed by full document path. + */ + readonly docs: Effect.Effect>>; + + /** + * A stream of the full backend state, emitting the current value on + * subscription and again after every change. Drives devtools UIs. + */ + readonly changes: Stream.Stream; + + /** + * Seed additional documents from a fixture. Existing documents at the same + * paths are replaced; live streams re-emit. Only fixtures whose models + * require no encoding services are supported (`Fixture`). + */ + readonly seed: (fixture: Fixture) => Effect.Effect; + + /** + * Insert or replace a single document (bypasses states and latency). + */ + readonly setDoc: (path: string, data: DocData) => Effect.Effect; + + /** + * Remove a single document (bypasses states and latency). + */ + readonly removeDoc: (path: string) => Effect.Effect; + + /** + * Set the simulated latency applied to every operation. + */ + readonly setLatency: (latency: Duration.Input) => Effect.Effect; + + /** + * The currently simulated latency. + */ + readonly latency: Effect.Effect; + + /** + * Restore the backend to its initial fixtures and states, and reset latency + * to the value the layer was created with. + */ + readonly reset: Effect.Effect; +} + +/** + * Runtime controls for the mock backend, provided by `layer` alongside the + * `FirestoreService` implementation. Toggle collection states, seed data and + * simulate latency — from tests, a devtools panel, or anywhere else. + */ +export class MockController extends Context.Service< + MockController, + MockControllerShape +>()('@effect-firebase/mock/MockController') {} diff --git a/packages/mock/src/lib/firestore/fixture.spec.ts b/packages/mock/src/lib/firestore/fixture.spec.ts new file mode 100644 index 0000000..dd30506 --- /dev/null +++ b/packages/mock/src/lib/firestore/fixture.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { DateTime, Effect, Option, Schema } from 'effect'; +import { Model } from 'effect/unstable/schema'; +import { Firestore, Query } from 'effect-firebase'; +import { fixture, type Fixture } from './fixture.js'; +import { layer } from './layer.js'; + +const PostId = Schema.String.pipe(Schema.brand('PostId')); + +class Post extends Model.Class('Post')({ + id: Model.GeneratedByDb(PostId), + title: Schema.String, + views: Schema.Number, + createdAt: Firestore.DateTimeInsert, + optional: Firestore.OptionalDeletable(Schema.String), +}) {} + +const build = (target: Fixture) => + Effect.runPromise(target.build as Effect.Effect>); + +const post = (id: string, views: number) => + new Post({ + id: PostId.make(id), + title: `Post ${id}`, + views, + createdAt: DateTime.makeUnsafe(1_000 + views), + optional: Option.none(), + }); + +const posts = (options: { readonly docs: ReadonlyArray }) => + fixture(Post, { + collectionPath: 'posts', + idField: 'id', + docs: options.docs, + }); + +describe('fixture', () => { + it('keys documents by their id field', async () => { + const docs = await build(posts({ docs: [post('a', 1), post('b', 2)] })); + expect(Object.keys(docs)).toEqual(['posts/a', 'posts/b']); + // The id field is stripped from the stored data — it lives in the path. + expect(docs['posts/a']).not.toHaveProperty('id'); + }); + + it('rejects document IDs containing a path separator', async () => { + await expect( + build(posts({ docs: [post('child/item', 0)] })), + ).rejects.toThrow(/must not contain '\/'/); + }); + + it('rejects invalid collection paths', async () => { + await expect( + build( + fixture(Post, { + collectionPath: 'posts/a', + idField: 'id', + docs: [post('x', 1)], + }), + ), + ).rejects.toThrow(/Invalid collection path/); + }); + + it('rejects duplicate document IDs', async () => { + await expect( + build(posts({ docs: [post('same', 1), post('same', 2)] })), + ).rejects.toThrow(/duplicate document ID 'same'/); + }); + + it('seeds a mock backend whose documents decode through a repository', () => + Effect.runPromise( + Effect.gen(function* () { + const repo = yield* Firestore.makeRepository(Post, { + collectionPath: 'posts', + idField: 'id', + spanPrefix: 'test.PostRepository', + }); + const found = yield* repo.query([ + new Query.OrderBy({ field: 'views', direction: 'asc' }), + ]); + expect(found.map((p) => p.id)).toEqual(['a', 'b', 'c']); + for (const p of found) { + expect(typeof p.title).toBe('string'); + expect(typeof p.views).toBe('number'); + expect(Option.isOption(p.optional)).toBe(true); + } + }).pipe( + Effect.provide( + layer({ + fixtures: [ + posts({ docs: [post('c', 3), post('a', 1), post('b', 2)] }), + ], + }), + ), + ) as Effect.Effect, + )); +}); diff --git a/packages/mock/src/lib/firestore/fixture.ts b/packages/mock/src/lib/firestore/fixture.ts new file mode 100644 index 0000000..7b99bce --- /dev/null +++ b/packages/mock/src/lib/firestore/fixture.ts @@ -0,0 +1,117 @@ +import { Effect, Schema } from 'effect'; +import { Model } from 'effect/unstable/schema'; +import { validateCollectionPath } from './store.js'; +import type { DocData } from './value.js'; + +/** + * A set of hard-coded documents to seed the mock backend with. + * Create one with {@link fixture} (schema-encoded models) or + * {@link rawFixture} (already-encoded document data). + */ +export interface Fixture { + readonly collectionPath: string; + /** + * Builds the documents, keyed by full document path. + */ + readonly build: Effect.Effect< + Readonly>, + Schema.SchemaError, + R + >; +} + +/** + * Create a fixture from hard-coded models. Documents are encoded through the + * model's schema, so reads exercise the exact same decoding path as real data. + * + * @example + * ```ts + * const posts = fixture(PostModel, { + * collectionPath: 'posts', + * idField: 'id', + * docs: [ + * new PostModel({ id: PostId.make('1'), title: 'Hello', ... }), + * ], + * }); + * ``` + */ +export const fixture = < + S extends Model.Any, + Id extends keyof S['Type'] & keyof S['fields'], +>( + model: S, + options: { + readonly collectionPath: string; + readonly idField: Id; + readonly docs: ReadonlyArray; + }, +): Fixture => ({ + collectionPath: options.collectionPath, + build: Effect.gen(function* () { + const invalidPath = validateCollectionPath(options.collectionPath); + if (invalidPath !== undefined) { + return yield* Effect.die(new Error(`fixture: ${invalidPath}`)); + } + const result: Record = {}; + for (const doc of options.docs) { + const encoded = (yield* Schema.encodeEffect(model as Schema.Top)( + doc, + )) as Record; + const { [options.idField as string]: id, ...data } = encoded; + if (typeof id !== 'string' || id.length === 0) { + return yield* Effect.die( + new Error( + `fixture(${options.collectionPath}): document is missing a string '${String( + options.idField, + )}' field`, + ), + ); + } + // Document IDs become a single path segment; a separator would + // silently move the document out of the intended collection. + if (id.includes('/')) { + return yield* Effect.die( + new Error( + `fixture(${options.collectionPath}): document ID '${id}' must not contain '/'`, + ), + ); + } + const path = `${options.collectionPath}/${id}`; + if (path in result) { + return yield* Effect.die( + new Error( + `fixture(${options.collectionPath}): duplicate document ID '${id}'`, + ), + ); + } + result[path] = data; + } + return result; + }) as Fixture['build'], +}); + +/** + * Create a fixture from already-encoded document data, keyed by document ID. + * Useful when there is no model schema, or for ad-hoc documents. + * + * @example + * ```ts + * const settings = rawFixture('settings', { + * general: { theme: 'dark' }, + * }); + * ``` + */ +export const rawFixture = ( + collectionPath: string, + docs: Readonly>, +): Fixture => ({ + collectionPath, + build: Effect.sync(() => + Object.fromEntries( + Object.entries(docs).map(([id, data]) => [ + `${collectionPath}/${id}`, + data, + ]), + ), + ), +}); diff --git a/packages/mock/src/lib/firestore/layer.spec.ts b/packages/mock/src/lib/firestore/layer.spec.ts new file mode 100644 index 0000000..a366116 --- /dev/null +++ b/packages/mock/src/lib/firestore/layer.spec.ts @@ -0,0 +1,576 @@ +import { describe, expect, it } from 'vitest'; +import { DateTime, Effect, Fiber, Option, Schema, Stream } from 'effect'; +import { Model } from 'effect/unstable/schema'; +import { + Firestore, + FirestoreError, + FirestoreSchema, + FirestoreService, + Query, + Snapshot, +} from 'effect-firebase'; +import { MockController } from './controller.js'; +import { fixture, rawFixture } from './fixture.js'; +import { layer, make } from './layer.js'; +import * as MockState from './state.js'; + +const PostId = Schema.String.pipe(Schema.brand('PostId')); + +class Post extends Model.Class('Post')({ + id: Model.GeneratedByDb(PostId), + title: Schema.String, + views: Schema.Number, + createdAt: Firestore.DateTimeInsert, +}) {} + +const postFixture = fixture(Post, { + collectionPath: 'posts', + idField: 'id', + docs: [ + new Post({ + id: PostId.make('1'), + title: 'Alpha', + views: 10, + createdAt: DateTime.makeUnsafe(1_000), + }), + new Post({ + id: PostId.make('2'), + title: 'Beta', + views: 30, + createdAt: DateTime.makeUnsafe(2_000), + }), + ], +}); + +const run = ( + effect: Effect.Effect, + options?: Parameters[0], +): Promise => + Effect.runPromise( + effect.pipe(Effect.provide(layer(options))) as Effect.Effect, + ); + +/** + * Poll until a collector array reaches the expected length, so stream + * assertions don't race emissions. + */ +const awaitLength = (collected: ReadonlyArray, length: number) => + Effect.gen(function* () { + for (let i = 0; i < 200 && collected.length < length; i++) { + yield* Effect.sleep('5 millis'); + } + if (collected.length < length) { + return yield* Effect.die( + new Error( + `Timed out waiting for ${length} emissions (got ${collected.length})`, + ), + ); + } + }); + +describe('layer', () => { + describe('CRUD', () => { + it('adds, reads, updates and deletes documents', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + + const { id, path } = yield* firestore.add('posts', { + title: 'Hello', + views: 1, + }); + expect(path).toBe(`posts/${id}`); + + const created = yield* firestore.get(path); + expect(Option.isSome(created)).toBe(true); + const [ref, data] = (created as Option.Some).value; + expect(ref.id).toBe(id); + expect(data['title']).toBe('Hello'); + + yield* firestore.update(path, { views: 2 }); + const updated = yield* firestore.get(path); + expect((updated as Option.Some).value[1]['views']).toBe(2); + + yield* firestore.delete(path); + const deleted = yield* firestore.get(path); + expect(Option.isNone(deleted)).toBe(true); + }), + )); + + it('materializes server timestamps on write', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const { path } = yield* firestore.add('posts', { + title: 'Hello', + createdAt: new FirestoreSchema.ServerTimestamp(), + }); + const created = yield* firestore.get(path); + const data = (created as Option.Some).value[1]; + expect(data['createdAt']).toBeInstanceOf(FirestoreSchema.Timestamp); + }), + )); + + it('fails update on a missing document with not-found', async () => { + const error = await run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + return yield* Effect.flip( + firestore.update('posts/missing', { title: 'X' }), + ); + }), + ); + expect(error).toBeInstanceOf(FirestoreError); + expect((error as FirestoreError).code).toBe('not-found'); + }); + + it('deletes recursively including subcollections', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + yield* firestore.set('posts/1', { title: 'A' }); + yield* firestore.set('posts/1/comments/1', { body: 'Hi' }); + yield* firestore.deleteRecursive('posts/1'); + expect(Option.isNone(yield* firestore.get('posts/1'))).toBe(true); + expect( + Option.isNone(yield* firestore.get('posts/1/comments/1')), + ).toBe(true); + }), + )); + + it('rejects invalid paths', async () => { + const error = await run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + return yield* Effect.flip(firestore.get('posts')); + }), + ); + expect((error as FirestoreError).code).toBe('invalid-argument'); + }); + }); + + describe('fixtures', () => { + it('seeds schema-encoded model fixtures', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const doc = yield* firestore.get('posts/1'); + const data = (doc as Option.Some).value[1]; + expect(data['title']).toBe('Alpha'); + expect(data['createdAt']).toBeInstanceOf(FirestoreSchema.Timestamp); + expect('id' in data).toBe(false); + }), + { fixtures: [postFixture] }, + )); + + it('seeds raw fixtures', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const doc = yield* firestore.get('settings/general'); + expect((doc as Option.Some).value[1]['theme']).toBe('dark'); + }), + { fixtures: [rawFixture('settings', { general: { theme: 'dark' } })] }, + )); + + it('queries seeded fixtures with constraints', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const results = yield* firestore.query('posts', [ + new Query.Where({ field: 'views', op: '>', value: 15 }), + ]); + expect(results.map(([ref]) => ref.id)).toEqual(['2']); + }), + { fixtures: [postFixture] }, + )); + }); + + describe('states', () => { + it('fails reads and writes while a collection is erroring', async () => { + const [readError, writeError] = await run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + yield* controller.setState('posts', 'error'); + const read = yield* Effect.flip(firestore.get('posts/1')); + const write = yield* Effect.flip( + firestore.add('posts', { title: 'X' }), + ); + return [read, write] as const; + }), + { fixtures: [postFixture] }, + ); + expect((readError as FirestoreError).code).toBe('unavailable'); + expect((writeError as FirestoreError).code).toBe('unavailable'); + }); + + it('supports custom error codes', async () => { + const error = await run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + yield* controller.setState( + 'posts', + MockState.error('permission-denied'), + ); + return yield* Effect.flip(firestore.get('posts/1')); + }), + ); + expect((error as FirestoreError).code).toBe('permission-denied'); + }); + + it('resolves reads to nothing while a collection is empty', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + yield* controller.setState('posts', 'empty'); + expect(Option.isNone(yield* firestore.get('posts/1'))).toBe(true); + expect(yield* firestore.query('posts', [])).toEqual([]); + }), + { fixtures: [postFixture] }, + )); + + it('never resolves while a collection is loading', async () => { + const result = await run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + yield* controller.setState('posts', 'loading'); + return yield* Effect.timeoutOption( + firestore.get('posts/1'), + '50 millis', + ); + }), + { fixtures: [postFixture] }, + ); + expect(Option.isNone(result)).toBe(true); + }); + + it('applies the wildcard state to every collection', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + expect(yield* firestore.query('posts', [])).toEqual([]); + expect(yield* firestore.query('authors', [])).toEqual([]); + }), + { fixtures: [postFixture], states: { [MockState.All]: 'empty' } }, + )); + }); + + describe('streams', () => { + it('re-emits query results on writes and state toggles', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + const emissions: Array> = []; + + const fiber = yield* Effect.forkChild( + Stream.runForEach(firestore.streamQuery('posts', []), (snapshots) => + Effect.sync(() => { + emissions.push(snapshots); + }), + ), + ); + + yield* awaitLength(emissions, 1); + expect(emissions[0].length).toBe(2); + + // A write flows through the live stream. + yield* firestore.add('posts', { title: 'Gamma', views: 5 }); + yield* awaitLength(emissions, 2); + expect(emissions[1].length).toBe(3); + + // Toggling to empty and back re-emits without re-subscribing. + yield* controller.setState('posts', 'empty'); + yield* awaitLength(emissions, 3); + expect(emissions[2]).toEqual([]); + + yield* controller.setState('posts', 'data'); + yield* awaitLength(emissions, 4); + expect(emissions[3].length).toBe(3); + + yield* Fiber.interrupt(fiber); + }), + { fixtures: [postFixture] }, + )); + + it('does not re-emit for unrelated collections', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const emissions: Array> = []; + + const fiber = yield* Effect.forkChild( + Stream.runForEach(firestore.streamQuery('posts', []), (snapshots) => + Effect.sync(() => { + emissions.push(snapshots); + }), + ), + ); + + yield* awaitLength(emissions, 1); + yield* firestore.set('authors/1', { name: 'Ada' }); + yield* Effect.sleep('30 millis'); + expect(emissions.length).toBe(1); + + yield* Fiber.interrupt(fiber); + }), + { fixtures: [postFixture] }, + )); + + it('fails live streams when a collection starts erroring', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + const emissions: Array> = []; + const failures: Array = []; + + const fiber = yield* Effect.forkChild( + Stream.runForEach(firestore.streamQuery('posts', []), (snapshots) => + Effect.sync(() => { + emissions.push(snapshots); + }), + ).pipe( + Effect.catch((error) => + Effect.sync(() => { + failures.push(error); + }), + ), + ), + ); + + yield* awaitLength(emissions, 1); + yield* controller.setState('posts', 'error'); + yield* awaitLength(failures, 1); + expect(failures[0].code).toBe('unavailable'); + + yield* Fiber.interrupt(fiber); + }), + { fixtures: [postFixture] }, + )); + + it('streams a single document', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const emissions: Array> = []; + + const fiber = yield* Effect.forkChild( + Stream.runForEach(firestore.streamDoc('posts/1'), (doc) => + Effect.sync(() => { + emissions.push(doc); + }), + ), + ); + + yield* awaitLength(emissions, 1); + expect(Option.isSome(emissions[0])).toBe(true); + + yield* firestore.update('posts/1', { views: 99 }); + yield* awaitLength(emissions, 2); + expect( + (emissions[1] as Option.Some).value[1]['views'], + ).toBe(99); + + yield* firestore.delete('posts/1'); + yield* awaitLength(emissions, 3); + expect(Option.isNone(emissions[2])).toBe(true); + + yield* Fiber.interrupt(fiber); + }), + { fixtures: [postFixture] }, + )); + }); + + describe('controller', () => { + it('seeds additional fixtures at runtime', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + yield* controller.seed( + rawFixture('posts', { extra: { title: 'Extra', views: 0 } }), + ); + const results = yield* firestore.query('posts', []); + expect(results.length).toBe(3); + }), + { fixtures: [postFixture] }, + )); + + it('resets to the initial fixtures and states', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + + yield* firestore.add('posts', { title: 'Temporary', views: 0 }); + yield* controller.setState('posts', 'empty'); + yield* controller.reset; + + const results = yield* firestore.query('posts', []); + expect(results.length).toBe(2); + expect(yield* controller.states).toEqual({}); + }), + { fixtures: [postFixture] }, + )); + + it('falls back to the wildcard state after clearState', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + yield* controller.setState('posts', 'data'); + expect((yield* firestore.query('posts', [])).length).toBe(2); + yield* controller.clearState('posts'); + expect(yield* firestore.query('posts', [])).toEqual([]); + }), + { fixtures: [postFixture], states: { [MockState.All]: 'empty' } }, + )); + + it('sets and removes documents directly', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + yield* controller.setDoc('posts/9', { title: 'Direct', views: 1 }); + expect(Option.isSome(yield* firestore.get('posts/9'))).toBe(true); + yield* controller.removeDoc('posts/9'); + expect(Option.isNone(yield* firestore.get('posts/9'))).toBe(true); + }), + { fixtures: [postFixture] }, + )); + + it('simulates latency', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + yield* controller.setLatency('40 millis'); + const start = Date.now(); + yield* firestore.get('posts/1'); + expect(Date.now() - start).toBeGreaterThanOrEqual(30); + }), + { fixtures: [postFixture] }, + )); + }); + + describe('make', () => { + it('exposes a controller that drives the provided layer from outside', async () => { + const mock = make({ fixtures: [postFixture] }); + + // The controller works before and outside any Effect.provide. + await Effect.runPromise(mock.controller.setState('posts', 'empty')); + + const emptied = await Effect.runPromise( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + return yield* firestore.query('posts', []); + }).pipe(Effect.provide(mock.layer)), + ); + expect(emptied).toEqual([]); + + await Effect.runPromise(mock.controller.setState('posts', 'data')); + const restored = await Effect.runPromise( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + return yield* firestore.query('posts', []); + }).pipe(Effect.provide(mock.layer)), + ); + expect(restored.length).toBe(2); + }); + + it('shares one store across provides and seeds fixtures once', async () => { + const mock = make({ fixtures: [postFixture] }); + + await Effect.runPromise( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + yield* firestore.set('posts/3', { title: 'Gamma', views: 0 }); + }).pipe(Effect.provide(mock.layer)), + ); + + const count = await Effect.runPromise( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + return (yield* firestore.query('posts', [])).length; + }).pipe(Effect.provide(mock.layer)), + ); + expect(count).toBe(3); + }); + }); + + describe('repository integration', () => { + it('drives a real repository end to end', () => + run( + Effect.gen(function* () { + const repo = yield* Firestore.makeRepository(Post, { + collectionPath: 'posts', + idField: 'id', + spanPrefix: 'test.PostRepository', + }); + + const existing = yield* repo.getById(PostId.make('1')); + expect(Option.isSome(existing)).toBe(true); + const post = (existing as Option.Some).value; + expect(post.title).toBe('Alpha'); + expect(DateTime.toEpochMillis(post.createdAt)).toBe(1_000); + + // Server timestamps materialize and decode back into DateTime. + const newId = yield* repo.add({ + title: 'Fresh', + views: 0, + createdAt: undefined, + }); + const fresh = yield* repo.getById(newId); + expect(Option.isSome(fresh)).toBe(true); + expect( + DateTime.toEpochMillis( + (fresh as Option.Some).value.createdAt, + ), + ).toBeGreaterThan(0); + + const popular = yield* repo.query([ + new Query.Where({ field: 'views', op: '>=', value: 10 }), + new Query.OrderBy({ field: 'views', direction: 'desc' }), + ]); + expect(popular.map((p) => p.title)).toEqual(['Beta', 'Alpha']); + }), + { fixtures: [postFixture] }, + )); + + it('streams decoded models through a repository', () => + run( + Effect.gen(function* () { + const repo = yield* Firestore.makeRepository(Post, { + collectionPath: 'posts', + idField: 'id', + spanPrefix: 'test.PostRepository', + }); + const controller = yield* MockController; + const emissions: Array> = []; + + const fiber = yield* Effect.forkChild( + Stream.runForEach(repo.queryStream([]), (posts) => + Effect.sync(() => { + emissions.push(posts); + }), + ), + ); + + yield* awaitLength(emissions, 1); + expect(emissions[0].map((p) => p.title)).toEqual(['Alpha', 'Beta']); + + yield* controller.setState('posts', 'empty'); + yield* awaitLength(emissions, 2); + expect(emissions[1]).toEqual([]); + + yield* Fiber.interrupt(fiber); + }), + { fixtures: [postFixture] }, + )); + }); +}); diff --git a/packages/mock/src/lib/firestore/layer.ts b/packages/mock/src/lib/firestore/layer.ts new file mode 100644 index 0000000..cecd26b --- /dev/null +++ b/packages/mock/src/lib/firestore/layer.ts @@ -0,0 +1,527 @@ +import { + Clock, + Context, + Duration, + Effect, + Layer, + Option, + Random, + Ref, + Schema, + Stream, + SubscriptionRef, +} from 'effect'; +import { + FirestoreError, + FirestoreSchema, + FirestoreService, + Snapshot, + type FirestoreServiceShape, +} from 'effect-firebase'; +import { MockController, type MockControllerShape } from './controller.js'; +import { applyConstraints } from './query-filter.js'; +import type { Fixture } from './fixture.js'; +import * as MockState from './state.js'; +import { + docsInCollection, + makeSnapshot, + parentPath, + validateCollectionPath, + validateDocPath, + type StoreSnapshot, +} from './store.js'; +import { + applyMerge, + applySet, + applyUpdate, + equals, + type DocData, +} from './value.js'; + +export interface LayerOptions { + /** + * Fixtures to seed the backend with. Only fixtures whose models require no + * encoding services are supported (`Fixture`). + */ + readonly fixtures?: ReadonlyArray; + /** + * Initial simulated states, keyed by collection path + * (or {@link MockState.All} for every collection). + */ + readonly states?: Readonly>; + /** + * Simulated latency applied to every operation and to the first emission + * of every stream. Defaults to none. + */ + readonly latency?: Duration.Input; +} + +const ID_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +const generateId: Effect.Effect = Effect.gen(function* () { + let id = ''; + for (let i = 0; i < 20; i++) { + // halfOpen keeps the index strictly below the alphabet length + // (nextIntBetween includes the upper bound by default). + const index = yield* Random.nextIntBetween(0, ID_ALPHABET.length, { + halfOpen: true, + }); + id += ID_ALPHABET[index]; + } + return id; +}); + +const invalidArgument = (message: string): FirestoreError => + new FirestoreError({ + code: 'invalid-argument', + name: 'FirebaseError', + message, + }); + +const notFound = (path: string): FirestoreError => + new FirestoreError({ + code: 'not-found', + name: 'FirebaseError', + message: `No document to update: ${path}`, + }); + +const now: Effect.Effect = Effect.map( + Clock.currentTimeMillis, + (millis) => FirestoreSchema.Timestamp.fromMillis(millis), +); + +const optionSnapshotEquals = ( + a: Option.Option, + b: Option.Option, +): boolean => + Option.isNone(a) || Option.isNone(b) + ? Option.isNone(a) === Option.isNone(b) + : snapshotEquals(a.value, b.value); + +const snapshotEquals = (a: Snapshot, b: Snapshot): boolean => + a[0].path === b[0].path && equals(a[1], b[1]); + +const snapshotsEqual = ( + a: ReadonlyArray, + b: ReadonlyArray, +): boolean => + a.length === b.length && + a.every((snapshot, index) => snapshotEquals(snapshot, b[index])); + +const makeFirestore = ( + ref: SubscriptionRef.SubscriptionRef, + latency: Ref.Ref, +): FirestoreServiceShape => { + const sleep = Effect.flatMap(Ref.get(latency), (duration) => + Duration.toMillis(duration) > 0 ? Effect.sleep(duration) : Effect.void, + ); + + const stateFor = (collectionPath: string) => + Effect.map(SubscriptionRef.get(ref), (snapshot) => + MockState.resolve(snapshot.states, collectionPath), + ); + + /** + * Gate an operation on the collection's simulated state: hang while + * loading, fail while erroring, and continue otherwise. + */ + const guard = (collectionPath: string) => + Effect.flatMap(stateFor(collectionPath), (state) => { + switch (state._tag) { + case 'Loading': + return Effect.never; + case 'Error': + return Effect.fail(state.error); + default: + return Effect.succeed(state); + } + }); + + const validate = (message: string | undefined) => + message === undefined ? Effect.void : Effect.fail(invalidArgument(message)); + + const readDoc = ( + path: string, + ): Effect.Effect, FirestoreError> => + Effect.gen(function* () { + yield* validate(validateDocPath(path)); + yield* sleep; + const state = yield* guard(parentPath(path)); + if (state._tag === 'Empty') { + return Option.none(); + } + const snapshot = yield* SubscriptionRef.get(ref); + const data = snapshot.docs[path]; + return data === undefined + ? Option.none() + : Option.some(makeSnapshot(path, data)); + }); + + const write = ( + collectionPath: string, + mutate: ( + docs: Readonly>, + timestamp: FirestoreSchema.Timestamp, + ) => Effect.Effect>, FirestoreError>, + ): Effect.Effect => + Effect.gen(function* () { + yield* sleep; + yield* guard(collectionPath); + const timestamp = yield* now; + // Read-modify-write inside updateEffect keeps concurrent writes consistent. + yield* SubscriptionRef.updateEffect(ref, (snapshot) => + Effect.map(mutate(snapshot.docs, timestamp), (docs) => ({ + ...snapshot, + docs, + })), + ); + }); + + return { + get: (path) => readDoc(path), + + add: (path, data) => + Effect.gen(function* () { + yield* validate(validateCollectionPath(path)); + // Like the real SDK, add generates a random 20-char ID without an + // occupancy check — collision odds are ~62^-20. + const id = yield* generateId; + const docPath = `${path}/${id}`; + yield* write(path, (docs, timestamp) => + Effect.succeed({ ...docs, [docPath]: applySet(data, timestamp) }), + ); + return { id, path: docPath }; + }), + + set: (path, data, options) => + Effect.gen(function* () { + yield* validate(validateDocPath(path)); + yield* write(parentPath(path), (docs, timestamp) => + Effect.succeed({ + ...docs, + [path]: options?.merge + ? applyMerge(docs[path], data, timestamp) + : applySet(data, timestamp), + }), + ); + }), + + update: (path, data) => + Effect.gen(function* () { + yield* validate(validateDocPath(path)); + yield* write(parentPath(path), (docs, timestamp) => { + const existing = docs[path]; + if (existing === undefined) { + return Effect.fail(notFound(path)); + } + return Effect.succeed({ + ...docs, + [path]: applyUpdate(existing, data, timestamp), + }); + }); + }), + + delete: (path) => + Effect.gen(function* () { + yield* validate(validateDocPath(path)); + yield* write(parentPath(path), (docs) => { + const rest = { ...docs }; + delete rest[path]; + return Effect.succeed(rest); + }); + }), + + deleteRecursive: (path) => + Effect.gen(function* () { + yield* validate(validateDocPath(path)); + const prefix = `${path}/`; + yield* write(parentPath(path), (docs) => + Effect.succeed( + Object.fromEntries( + Object.entries(docs).filter( + ([docPath]) => docPath !== path && !docPath.startsWith(prefix), + ), + ), + ), + ); + }), + + query: (collectionPath, constraints) => + Effect.gen(function* () { + yield* validate(validateCollectionPath(collectionPath)); + yield* sleep; + const state = yield* guard(collectionPath); + if (state._tag === 'Empty') { + return []; + } + const snapshot = yield* SubscriptionRef.get(ref); + return applyConstraints( + docsInCollection(snapshot.docs, collectionPath), + constraints, + ); + }), + + streamDoc: (path) => { + const invalid = validateDocPath(path); + if (invalid !== undefined) { + return Stream.fail(invalidArgument(invalid)); + } + const collectionPath = parentPath(path); + return Stream.unwrap( + Effect.as( + sleep, + SubscriptionRef.changes(ref).pipe( + Stream.switchMap( + ( + snapshot, + ): Stream.Stream, FirestoreError> => { + const state = MockState.resolve( + snapshot.states, + collectionPath, + ); + switch (state._tag) { + case 'Loading': + return Stream.never; + case 'Error': + return Stream.fail(state.error); + case 'Empty': + return Stream.succeed(Option.none()); + case 'Data': { + const data = snapshot.docs[path]; + return Stream.succeed( + data === undefined + ? Option.none() + : Option.some(makeSnapshot(path, data)), + ); + } + } + }, + ), + Stream.changesWith(optionSnapshotEquals), + ), + ), + ); + }, + + streamQuery: (collectionPath, constraints) => { + const invalid = validateCollectionPath(collectionPath); + if (invalid !== undefined) { + return Stream.fail(invalidArgument(invalid)); + } + return Stream.unwrap( + Effect.as( + sleep, + SubscriptionRef.changes(ref).pipe( + Stream.switchMap( + ( + snapshot, + ): Stream.Stream, FirestoreError> => { + const state = MockState.resolve( + snapshot.states, + collectionPath, + ); + switch (state._tag) { + case 'Loading': + return Stream.never; + case 'Error': + return Stream.fail(state.error); + case 'Empty': + return Stream.succeed([]); + case 'Data': + return Stream.succeed( + applyConstraints( + docsInCollection(snapshot.docs, collectionPath), + constraints, + ), + ); + } + }, + ), + Stream.changesWith(snapshotsEqual), + ), + ), + ); + }, + + // The mock has no concurrency or staging semantics, so transactions and + // batches simply run the effect: reads and writes hit the store directly, + // with no retries, rollback, or staged commits. + withTransaction: (self) => self, + + withBatch: (self) => self, + }; +}; + +const makeController = ( + ref: SubscriptionRef.SubscriptionRef, + latency: Ref.Ref, + initial: { ref: Ref.Ref; latency: Duration.Duration }, +): MockControllerShape => ({ + setState: (collectionPath, state) => + SubscriptionRef.update(ref, (snapshot) => ({ + ...snapshot, + states: { + ...snapshot.states, + [collectionPath]: MockState.fromInput(state), + }, + })), + + clearState: (collectionPath) => + SubscriptionRef.update(ref, (snapshot) => { + const states = { ...snapshot.states }; + delete states[collectionPath]; + return { ...snapshot, states }; + }), + + states: Effect.map(SubscriptionRef.get(ref), (snapshot) => snapshot.states), + + docs: Effect.map(SubscriptionRef.get(ref), (snapshot) => snapshot.docs), + + changes: SubscriptionRef.changes(ref), + + seed: (fixture) => + Effect.flatMap(fixture.build, (docs) => + SubscriptionRef.update(ref, (snapshot) => ({ + ...snapshot, + docs: { ...snapshot.docs, ...docs }, + })), + ), + + setDoc: (path, data) => + SubscriptionRef.update(ref, (snapshot) => ({ + ...snapshot, + docs: { ...snapshot.docs, [path]: data }, + })), + + removeDoc: (path) => + SubscriptionRef.update(ref, (snapshot) => { + const docs = { ...snapshot.docs }; + delete docs[path]; + return { ...snapshot, docs }; + }), + + setLatency: (input) => Ref.set(latency, Duration.fromInputUnsafe(input)), + + latency: Ref.get(latency), + + reset: Effect.gen(function* () { + yield* Ref.set(latency, initial.latency); + const snapshot = yield* Ref.get(initial.ref); + yield* SubscriptionRef.set(ref, snapshot); + }), +}); + +/** + * A handle to a mock backend: the layer to provide to your program, plus the + * controller as a plain value for use outside the Effect runtime — a devtools + * panel, a Storybook decorator, or an imperative test helper. + */ +export interface MockHandle { + /** + * Provides `FirestoreService` and {@link MockController}, backed by this + * handle's store. Providing it multiple times shares the same store. + */ + readonly layer: Layer.Layer< + FirestoreService | MockController, + Schema.SchemaError + >; + /** + * Direct access to the controller. All of its effects require no services, + * so they can be run with `Effect.runPromise`/`Effect.runFork` anywhere. + */ + readonly controller: MockControllerShape; +} + +/** + * Create a mock backend handle. Use this instead of {@link layer} when + * something outside the Effect runtime needs to drive the backend — most + * notably a devtools panel: + * + * @example + * ```ts + * const mock = make({ fixtures: [posts] }); + * + * // Provide mock.layer to your app's runtime... + * const runtime = Atom.runtime(mock.layer); + * + * // ...and hand mock.controller to the devtools panel. + * Effect.runPromise(mock.controller.setState('posts', 'loading')); + * ``` + */ +export const make = (options: LayerOptions = {}): MockHandle => { + const initialStates = Object.fromEntries( + Object.entries(options.states ?? {}).map(([key, input]) => [ + key, + MockState.fromInput(input), + ]), + ); + const initialLatency = Duration.fromInputUnsafe(options.latency ?? 0); + const emptySnapshot: StoreSnapshot = { docs: {}, states: initialStates }; + + const ref = Effect.runSync(SubscriptionRef.make(emptySnapshot)); + const latency = Effect.runSync(Ref.make(initialLatency)); + const initialRef = Effect.runSync(Ref.make(emptySnapshot)); + + const controller = makeController(ref, latency, { + ref: initialRef, + latency: initialLatency, + }); + + // Effect.cached deduplicates concurrent builds: every provider awaits the + // same seeding run, so none can observe a partially seeded store. + const seedOnce = Effect.runSync( + Effect.cached( + Effect.gen(function* () { + let docs: Record = {}; + for (const fixture of options.fixtures ?? []) { + docs = { ...docs, ...(yield* fixture.build) }; + } + const snapshot: StoreSnapshot = { docs, states: initialStates }; + yield* Ref.set(initialRef, snapshot); + // Keep anything written before the layer was built (e.g. via the + // controller); fixtures only fill in the seeded documents. + yield* SubscriptionRef.update(ref, (current) => ({ + ...current, + docs: { ...docs, ...current.docs }, + })); + }), + ), + ); + + return { + controller, + layer: Layer.effectContext( + Effect.map(seedOnce, () => + Context.make(FirestoreService, makeFirestore(ref, latency)).pipe( + Context.add(MockController, controller), + ), + ), + ), + }; +}; + +/** + * An in-memory, reactive `FirestoreService` backend. + * + * The returned layer provides both the `FirestoreService` implementation and + * a {@link MockController} for driving it at runtime. Every *build* of the + * layer gets a fresh, isolated store. Note that Effect memoizes layers, so + * providing the same layer value multiple times within one memoization scope + * shares a single store — call `layer()` again (or provide with + * `{ local: true }`) when you need separate stores, or use {@link make} when + * external code (like a devtools panel) needs a shared handle on the store. + * + * @example + * ```ts + * const mock = layer({ + * fixtures: [posts], + * states: { comments: 'loading' }, + * latency: '200 millis', + * }); + * ``` + */ +export const layer = ( + options: LayerOptions = {}, +): Layer.Layer => + Layer.suspend(() => make(options).layer); diff --git a/packages/mock/src/lib/firestore/query-filter.spec.ts b/packages/mock/src/lib/firestore/query-filter.spec.ts new file mode 100644 index 0000000..c20b392 --- /dev/null +++ b/packages/mock/src/lib/firestore/query-filter.spec.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; +import { Query, Snapshot } from 'effect-firebase'; +import { applyConstraints } from './query-filter.js'; + +const snap = (id: string, data: Record): Snapshot => [ + { id, path: `posts/${id}` }, + data, +]; + +const posts: ReadonlyArray = [ + snap('1', { title: 'Alpha', views: 10, tags: ['news'], status: 'draft' }), + snap('2', { + title: 'Beta', + views: 30, + tags: ['tech', 'news'], + status: 'published', + }), + snap('3', { title: 'Gamma', views: 20, tags: ['tech'], status: 'published' }), + snap('4', { title: 'Delta', views: 40, status: 'archived' }), +]; + +const ids = (results: ReadonlyArray) => + results.map(([ref]) => ref.id); + +describe('applyConstraints', () => { + it('returns everything ordered by document ID without constraints', () => { + expect(ids(applyConstraints(posts, []))).toEqual(['1', '2', '3', '4']); + }); + + it('filters with equality and inequality', () => { + expect( + ids( + applyConstraints(posts, [ + new Query.Where({ field: 'status', op: '==', value: 'published' }), + ]), + ), + ).toEqual(['2', '3']); + expect( + ids( + applyConstraints(posts, [ + new Query.Where({ field: 'status', op: '!=', value: 'published' }), + ]), + ), + ).toEqual(['1', '4']); + }); + + it('filters with range operators', () => { + expect( + ids( + applyConstraints(posts, [ + new Query.Where({ field: 'views', op: '>', value: 15 }), + new Query.Where({ field: 'views', op: '<=', value: 30 }), + ]), + ), + ).toEqual(['2', '3']); + }); + + it('range operators never match values of a different type', () => { + expect( + ids( + applyConstraints(posts, [ + new Query.Where({ field: 'title', op: '>', value: 5 }), + ]), + ), + ).toEqual([]); + }); + + it('filters with in and not-in', () => { + expect( + ids( + applyConstraints(posts, [ + new Query.Where({ + field: 'status', + op: 'in', + value: ['draft', 'archived'], + }), + ]), + ), + ).toEqual(['1', '4']); + expect( + ids( + applyConstraints(posts, [ + new Query.Where({ + field: 'status', + op: 'not-in', + value: ['draft', 'archived'], + }), + ]), + ), + ).toEqual(['2', '3']); + }); + + it('filters with array-contains and array-contains-any', () => { + expect( + ids( + applyConstraints(posts, [ + new Query.Where({ + field: 'tags', + op: 'array-contains', + value: 'tech', + }), + ]), + ), + ).toEqual(['2', '3']); + expect( + ids( + applyConstraints(posts, [ + new Query.Where({ + field: 'tags', + op: 'array-contains-any', + value: ['news', 'tech'], + }), + ]), + ), + ).toEqual(['1', '2', '3']); + }); + + it('supports or filters', () => { + expect( + ids( + applyConstraints(posts, [ + new Query.Or({ + constraints: [ + new Query.Where({ field: 'status', op: '==', value: 'draft' }), + new Query.Where({ field: 'views', op: '>=', value: 40 }), + ], + }), + ]), + ), + ).toEqual(['1', '4']); + }); + + it('supports and filters', () => { + expect( + ids( + applyConstraints(posts, [ + new Query.And({ + constraints: [ + new Query.Where({ + field: 'status', + op: '==', + value: 'published', + }), + new Query.Where({ field: 'views', op: '>', value: 25 }), + ], + }), + ]), + ), + ).toEqual(['2']); + }); + + it('orders ascending and descending', () => { + expect( + ids( + applyConstraints(posts, [ + new Query.OrderBy({ field: 'views', direction: 'asc' }), + ]), + ), + ).toEqual(['1', '3', '2', '4']); + expect( + ids( + applyConstraints(posts, [ + new Query.OrderBy({ field: 'views', direction: 'desc' }), + ]), + ), + ).toEqual(['4', '2', '3', '1']); + }); + + it('excludes documents missing an orderBy field, like Firestore', () => { + // Post '4' has no 'tags' field. + expect( + ids( + applyConstraints(posts, [ + new Query.OrderBy({ field: 'tags', direction: 'asc' }), + ]), + ), + ).toEqual(['1', '3', '2']); + }); + + it('applies limit and limitToLast', () => { + const ordered = [new Query.OrderBy({ field: 'views', direction: 'asc' })]; + expect( + ids(applyConstraints(posts, [...ordered, new Query.Limit({ count: 2 })])), + ).toEqual(['1', '3']); + expect( + ids( + applyConstraints(posts, [ + ...ordered, + new Query.LimitToLast({ count: 2 }), + ]), + ), + ).toEqual(['2', '4']); + }); + + it('prefers limitToLast when combined with limit', () => { + const ordered = [new Query.OrderBy({ field: 'views', direction: 'asc' })]; + expect( + ids( + applyConstraints(posts, [ + ...ordered, + new Query.Limit({ count: 3 }), + new Query.LimitToLast({ count: 2 }), + ]), + ), + ).toEqual(['2', '4']); + }); + + it('applies cursors relative to orderBy values', () => { + const ordered = [new Query.OrderBy({ field: 'views', direction: 'asc' })]; + expect( + ids( + applyConstraints(posts, [ + ...ordered, + new Query.StartAfter({ values: [20] }), + ]), + ), + ).toEqual(['2', '4']); + expect( + ids( + applyConstraints(posts, [ + ...ordered, + new Query.StartAt({ values: [20] }), + new Query.EndBefore({ values: [40] }), + ]), + ), + ).toEqual(['3', '2']); + }); +}); diff --git a/packages/mock/src/lib/firestore/query-filter.ts b/packages/mock/src/lib/firestore/query-filter.ts new file mode 100644 index 0000000..ad96637 --- /dev/null +++ b/packages/mock/src/lib/firestore/query-filter.ts @@ -0,0 +1,225 @@ +import { Query, Snapshot, type QueryConstraint } from 'effect-firebase'; +import { + compare, + equals, + fieldValue, + sameType, + type DocData, +} from './value.js'; + +type Filter = Query.Where | Query.And | Query.Or; + +const isFilter = (constraint: QueryConstraint): constraint is Filter => + constraint._tag === 'Where' || + constraint._tag === 'And' || + constraint._tag === 'Or'; + +const matchesWhere = (data: DocData, where: Query.Where): boolean => { + const value = fieldValue(data, where.field); + switch (where.op) { + case '==': + return value !== undefined && equals(value, where.value); + case '!=': + return value !== undefined && !equals(value, where.value); + case '<': + case '<=': + case '>': + case '>=': { + if (value === undefined || !sameType(value, where.value)) { + return false; + } + const diff = compare(value, where.value); + switch (where.op) { + case '<': + return diff < 0; + case '<=': + return diff <= 0; + case '>': + return diff > 0; + case '>=': + return diff >= 0; + } + break; + } + case 'in': + return ( + value !== undefined && + Array.isArray(where.value) && + where.value.some((candidate) => equals(value, candidate)) + ); + case 'not-in': + return ( + value !== undefined && + Array.isArray(where.value) && + !where.value.some((candidate) => equals(value, candidate)) + ); + case 'array-contains': + return ( + Array.isArray(value) && value.some((item) => equals(item, where.value)) + ); + case 'array-contains-any': + return ( + Array.isArray(value) && + Array.isArray(where.value) && + value.some((item) => + (where.value as ReadonlyArray).some((candidate) => + equals(item, candidate), + ), + ) + ); + } + return false; +}; + +const matchesFilter = (data: DocData, filter: Filter): boolean => { + switch (filter._tag) { + case 'Where': + return matchesWhere(data, filter); + case 'And': + return filter.constraints + .filter(isFilter) + .every((child) => matchesFilter(data, child)); + case 'Or': + return filter.constraints + .filter(isFilter) + .some((child) => matchesFilter(data, child)); + } +}; + +const orderValues = ( + snapshot: Snapshot, + orderBys: ReadonlyArray, +): ReadonlyArray => { + const [ref, data] = snapshot; + const values = orderBys.map((orderBy) => fieldValue(data, orderBy.field)); + // Firestore implicitly orders by document ID as the final tiebreaker. + return [...values, ref.id]; +}; + +const compareSnapshots = ( + orderBys: ReadonlyArray, +): ((a: Snapshot, b: Snapshot) => number) => { + const directions = [...orderBys.map((o) => o.direction), 'asc' as const]; + return (a, b) => { + const aValues = orderValues(a, orderBys); + const bValues = orderValues(b, orderBys); + for (let i = 0; i < aValues.length; i++) { + const diff = compare(aValues[i], bValues[i]); + if (diff !== 0) { + return directions[i] === 'desc' ? -diff : diff; + } + } + return 0; + }; +}; + +const compareCursor = ( + snapshot: Snapshot, + cursor: ReadonlyArray, + orderBys: ReadonlyArray, +): number => { + const values = orderValues(snapshot, orderBys); + for (let i = 0; i < Math.min(cursor.length, values.length); i++) { + const direction = orderBys[i]?.direction ?? 'asc'; + const diff = compare(values[i], cursor[i]); + if (diff !== 0) { + return direction === 'desc' ? -diff : diff; + } + } + return 0; +}; + +/** + * Evaluate query constraints against a collection of snapshots, following + * Firestore's filtering, ordering, cursor and limit semantics. + */ +export const applyConstraints = ( + snapshots: ReadonlyArray, + constraints: ReadonlyArray, +): ReadonlyArray => { + const filters: Array = []; + const orderBys: Array = []; + let limit: number | undefined; + let limitToLast: number | undefined; + let startAt: ReadonlyArray | undefined; + let startAfter: ReadonlyArray | undefined; + let endAt: ReadonlyArray | undefined; + let endBefore: ReadonlyArray | undefined; + + for (const constraint of constraints) { + switch (constraint._tag) { + case 'Where': + case 'And': + case 'Or': + filters.push(constraint); + break; + case 'OrderBy': + orderBys.push(constraint); + break; + case 'Limit': + limit = constraint.count; + break; + case 'LimitToLast': + limitToLast = constraint.count; + break; + case 'StartAt': + startAt = constraint.values; + break; + case 'StartAfter': + startAfter = constraint.values; + break; + case 'EndAt': + endAt = constraint.values; + break; + case 'EndBefore': + endBefore = constraint.values; + break; + } + } + + // Firestore excludes documents that lack a field named by an orderBy. + let results = snapshots.filter( + ([, data]) => + filters.every((filter) => matchesFilter(data, filter)) && + orderBys.every( + (orderBy) => fieldValue(data, orderBy.field) !== undefined, + ), + ); + + results = [...results].sort(compareSnapshots(orderBys)); + + if (startAt !== undefined) { + const cursor = startAt; + results = results.filter( + (snapshot) => compareCursor(snapshot, cursor, orderBys) >= 0, + ); + } + if (startAfter !== undefined) { + const cursor = startAfter; + results = results.filter( + (snapshot) => compareCursor(snapshot, cursor, orderBys) > 0, + ); + } + if (endAt !== undefined) { + const cursor = endAt; + results = results.filter( + (snapshot) => compareCursor(snapshot, cursor, orderBys) <= 0, + ); + } + if (endBefore !== undefined) { + const cursor = endBefore; + results = results.filter( + (snapshot) => compareCursor(snapshot, cursor, orderBys) < 0, + ); + } + + // Firestore rejects queries combining `limit()` and `limitToLast()`; + // the mock applies `limitToLast` and ignores `limit` in that case. + if (limitToLast !== undefined) { + results = results.slice(Math.max(0, results.length - limitToLast)); + } else if (limit !== undefined) { + results = results.slice(0, limit); + } + + return results; +}; diff --git a/packages/mock/src/lib/firestore/state.ts b/packages/mock/src/lib/firestore/state.ts new file mode 100644 index 0000000..7dcee08 --- /dev/null +++ b/packages/mock/src/lib/firestore/state.ts @@ -0,0 +1,86 @@ +import { FirestoreError } from 'effect-firebase'; + +/** + * The simulated state of a collection in the mock backend. + * + * - `Data` — reads resolve against the in-memory store (the default). + * - `Empty` — reads succeed but resolve to no documents. + * - `Loading` — reads and writes never resolve, streams never emit. + * - `Error` — reads and writes fail with the given {@link FirestoreError}. + */ +export type State = + | { readonly _tag: 'Data' } + | { readonly _tag: 'Empty' } + | { readonly _tag: 'Loading' } + | { readonly _tag: 'Error'; readonly error: FirestoreError }; + +/** + * Convenience input accepted anywhere a {@link State} is expected. + * The string shorthands map to their respective states, with `'error'` + * producing a `FirestoreError` with code `unavailable`. + */ +export type StateInput = 'data' | 'empty' | 'loading' | 'error' | State; + +/** + * Reads resolve against the in-memory store (the default state). + */ +export const data: State = { _tag: 'Data' }; + +/** + * Reads succeed but resolve to no documents. + */ +export const empty: State = { _tag: 'Empty' }; + +/** + * Reads and writes never resolve, streams never emit. + */ +export const loading: State = { _tag: 'Loading' }; + +/** + * Reads and writes fail. + * @param codeOrError - A Firestore error code (defaults to `unavailable`) or a full {@link FirestoreError}. + */ +export const error = (codeOrError?: string | FirestoreError): State => ({ + _tag: 'Error', + error: + typeof codeOrError === 'object' + ? codeOrError + : new FirestoreError({ + code: codeOrError ?? 'unavailable', + name: 'FirebaseError', + message: `Simulated error (${codeOrError ?? 'unavailable'})`, + }), +}); + +/** + * Normalize a {@link StateInput} shorthand into a {@link State}. + */ +export const fromInput = (input: StateInput): State => { + if (typeof input !== 'string') { + return input; + } + switch (input) { + case 'data': + return data; + case 'empty': + return empty; + case 'loading': + return loading; + case 'error': + return error(); + } +}; + +/** + * Wildcard key that applies to every collection without an explicit state. + */ +export const All = '*'; + +/** + * Resolve the effective state for a collection path. + * An exact entry wins over the {@link All} wildcard, which wins over {@link data}. + */ +export const resolve = ( + states: Readonly>, + collectionPath: string, +): State => states[collectionPath] ?? states[All] ?? data; diff --git a/packages/mock/src/lib/firestore/store.ts b/packages/mock/src/lib/firestore/store.ts new file mode 100644 index 0000000..efbeea5 --- /dev/null +++ b/packages/mock/src/lib/firestore/store.ts @@ -0,0 +1,77 @@ +import { Snapshot } from 'effect-firebase'; +import type * as MockState from './state.js'; +import { type DocData } from './value.js'; + +/** + * The full state of the mock backend at a point in time: every stored + * document (keyed by full document path) and every simulated collection state. + */ +export interface StoreSnapshot { + readonly docs: Readonly>; + readonly states: Readonly>; +} + +/** + * The collection path a document path belongs to (everything before the + * final segment). + */ +export const parentPath = (path: string): string => { + const segments = path.split('/'); + return segments.slice(0, -1).join('/'); +}; + +/** + * The document ID (final segment) of a document path. + */ +export const idOf = (path: string): string => { + const segments = path.split('/'); + return segments[segments.length - 1]; +}; + +/** + * Build a snapshot tuple for a stored document. + */ +export const makeSnapshot = (path: string, data: DocData): Snapshot => [ + { id: idOf(path), path }, + data, +]; + +/** + * All direct child documents of a collection, ordered by document ID. + */ +export const docsInCollection = ( + docs: Readonly>, + collectionPath: string, +): ReadonlyArray => { + const prefix = `${collectionPath}/`; + return Object.entries(docs) + .filter( + ([path]) => + path.startsWith(prefix) && !path.slice(prefix.length).includes('/'), + ) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([path, data]) => makeSnapshot(path, data)); +}; + +/** + * Validate a path, returning an error message when it is malformed. + * Documents sit at an even number of segments, collections at an odd number; + * `split` never yields fewer than one segment, so requiring every segment to + * be non-empty already rules out the empty path. + */ +const validatePath = (path: string, kind: 'document' | 'collection') => { + const segments = path.split('/'); + const parity = kind === 'document' ? 0 : 1; + return segments.length % 2 === parity && + segments.every((segment) => segment.length > 0) + ? undefined + : `Invalid ${kind} path '${path}': expected a non-empty path with an ${ + parity === 0 ? 'even' : 'odd' + } number of segments`; +}; + +export const validateDocPath = (path: string): string | undefined => + validatePath(path, 'document'); + +export const validateCollectionPath = (path: string): string | undefined => + validatePath(path, 'collection'); diff --git a/packages/mock/src/lib/firestore/value.spec.ts b/packages/mock/src/lib/firestore/value.spec.ts new file mode 100644 index 0000000..5ca3c56 --- /dev/null +++ b/packages/mock/src/lib/firestore/value.spec.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; +import { Firestore, FirestoreSchema } from 'effect-firebase'; +import { + applyMerge, + applySet, + applyUpdate, + compare, + equals, + fieldValue, +} from './value.js'; + +const now = FirestoreSchema.Timestamp.fromMillis(1_000_000); + +describe('compare', () => { + it('orders numbers naturally', () => { + expect(compare(1, 2)).toBeLessThan(0); + expect(compare(2, 1)).toBeGreaterThan(0); + expect(compare(1, 1)).toBe(0); + }); + + it('orders strings lexicographically', () => { + expect(compare('a', 'b')).toBeLessThan(0); + expect(compare('b', 'a')).toBeGreaterThan(0); + }); + + it('orders timestamps by instant', () => { + const earlier = FirestoreSchema.Timestamp.fromMillis(1_000); + const later = FirestoreSchema.Timestamp.fromMillis(2_000); + expect(compare(earlier, later)).toBeLessThan(0); + expect(compare(later, earlier)).toBeGreaterThan(0); + }); + + it('orders pre-1970 timestamps by instant and roundtrips them', () => { + const earlier = FirestoreSchema.Timestamp.fromMillis(-2_500); + const later = FirestoreSchema.Timestamp.fromMillis(-1_500); + expect(earlier.nanoseconds).toBeGreaterThanOrEqual(0); + expect(later.nanoseconds).toBeGreaterThanOrEqual(0); + expect(earlier.toMillis()).toBe(-2_500); + expect(later.toMillis()).toBe(-1_500); + expect(compare(earlier, later)).toBeLessThan(0); + }); + + it('never equates unrelated opaque values', () => { + expect(equals(undefined, { a: 1 })).toBe(false); + expect(equals(Firestore.delete(), Firestore.delete())).toBe(false); + expect(equals(undefined, undefined)).toBe(true); + }); + + it('orders distinct opaque values antisymmetrically and stably', () => { + const a = Firestore.delete(); + const b = Firestore.delete(); + expect(compare(a, b)).toBe(-compare(b, a)); + expect(compare(a, b)).not.toBe(0); + expect(compare(a, b)).toBe(compare(a, b)); + expect(compare(a, a)).toBe(0); + expect(compare(1n, 2n)).toBe(-compare(2n, 1n)); + + // Distinct symbols share a string form but are not equal. + const x = Symbol('x'); + const y = Symbol('x'); + expect(compare(x, y)).not.toBe(0); + expect(compare(x, y)).toBe(-compare(y, x)); + expect(compare(x, x)).toBe(0); + }); + + it('orders mixed types by Firestore type rank', () => { + // null < boolean < number < timestamp < string + expect(compare(null, true)).toBeLessThan(0); + expect(compare(true, 1)).toBeLessThan(0); + expect(compare(999, FirestoreSchema.Timestamp.fromMillis(0))).toBeLessThan( + 0, + ); + expect(compare(FirestoreSchema.Timestamp.fromMillis(0), 'a')).toBeLessThan( + 0, + ); + }); + + it('orders arrays elementwise, then by length', () => { + expect(compare([1, 2], [1, 3])).toBeLessThan(0); + expect(compare([1, 2], [1, 2, 0])).toBeLessThan(0); + expect(compare([1, 2], [1, 2])).toBe(0); + }); +}); + +describe('equals', () => { + it('compares nested structures', () => { + expect( + equals( + { a: [1, { b: 'x' }], t: FirestoreSchema.Timestamp.fromMillis(5) }, + { a: [1, { b: 'x' }], t: FirestoreSchema.Timestamp.fromMillis(5) }, + ), + ).toBe(true); + expect(equals({ a: 1 }, { a: 2 })).toBe(false); + }); +}); + +describe('fieldValue', () => { + it('resolves dot-separated paths', () => { + expect(fieldValue({ a: { b: { c: 1 } } }, 'a.b.c')).toBe(1); + expect(fieldValue({ a: 1 }, 'a.b')).toBeUndefined(); + expect(fieldValue({}, 'missing')).toBeUndefined(); + }); +}); + +describe('applySet', () => { + it('materializes server timestamps', () => { + const result = applySet( + { createdAt: new FirestoreSchema.ServerTimestamp(), title: 'Hi' }, + now, + ); + expect(result['createdAt']).toBe(now); + expect(result['title']).toBe('Hi'); + }); + + it('drops delete sentinels', () => { + const result = applySet({ gone: Firestore.delete(), kept: 1 }, now); + expect('gone' in result).toBe(false); + expect(result['kept']).toBe(1); + }); +}); + +describe('applyMerge', () => { + it('deep merges nested records', () => { + const result = applyMerge( + { nested: { a: 1, b: 2 }, top: 'x' }, + { nested: { b: 3 } }, + now, + ); + expect(result).toEqual({ nested: { a: 1, b: 3 }, top: 'x' }); + }); + + it('removes fields via delete sentinel', () => { + const result = applyMerge({ a: 1, b: 2 }, { b: Firestore.delete() }, now); + expect(result).toEqual({ a: 1 }); + }); +}); + +describe('applyUpdate', () => { + it('sets values at dot-separated paths', () => { + const result = applyUpdate( + { nested: { a: 1 }, top: 'x' }, + { 'nested.b': 2 }, + now, + ); + expect(result).toEqual({ nested: { a: 1, b: 2 }, top: 'x' }); + }); + + it('applies arrayUnion without duplicates', () => { + const result = applyUpdate( + { tags: ['a', 'b'] }, + { tags: Firestore.arrayUnion(['b', 'c']) }, + now, + ); + expect(result['tags']).toEqual(['a', 'b', 'c']); + }); + + it('applies arrayRemove', () => { + const result = applyUpdate( + { tags: ['a', 'b', 'c'] }, + { tags: Firestore.arrayRemove(['b']) }, + now, + ); + expect(result['tags']).toEqual(['a', 'c']); + }); + + it('materializes server timestamps in updates', () => { + const result = applyUpdate( + { title: 'Hi' }, + { updatedAt: new FirestoreSchema.ServerTimestamp() }, + now, + ); + expect(result['updatedAt']).toBe(now); + }); +}); diff --git a/packages/mock/src/lib/firestore/value.ts b/packages/mock/src/lib/firestore/value.ts new file mode 100644 index 0000000..1036b3d --- /dev/null +++ b/packages/mock/src/lib/firestore/value.ts @@ -0,0 +1,354 @@ +import { Firestore, FirestoreSchema } from 'effect-firebase'; + +/** + * Document data as stored by the mock backend: the encoded representation + * produced by the schema layer (`FirestoreSchema.Timestamp`, `GeoPoint`, + * `Reference` instances and plain JSON values). + */ +export type DocData = Record; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + !(value instanceof FirestoreSchema.Timestamp) && + !(value instanceof FirestoreSchema.ServerTimestamp) && + !(value instanceof FirestoreSchema.GeoPoint) && + !(value instanceof FirestoreSchema.Reference) && + !(value instanceof Firestore.Delete) && + !(value instanceof Firestore.ArrayUnion) && + !(value instanceof Firestore.ArrayRemove); + +/** + * Firestore value type ordering, used when comparing values of different types. + * @see https://firebase.google.com/docs/firestore/manage-data/data-types#value_type_ordering + */ +const rank = (value: unknown): number => { + if (value === undefined) return -1; + if (value === null) return 0; + if (typeof value === 'boolean') return 1; + if (typeof value === 'number') return 2; + if (value instanceof FirestoreSchema.Timestamp) return 3; + if (typeof value === 'string') return 4; + if (value instanceof FirestoreSchema.Reference) return 5; + if (value instanceof FirestoreSchema.GeoPoint) return 6; + if (Array.isArray(value)) return 7; + if (isRecord(value)) return 8; + return 9; +}; + +const compareNumbers = (a: number, b: number): number => + a < b ? -1 : a > b ? 1 : 0; + +/** + * Compare two stored values following Firestore's ordering semantics. + * Values of different types order by type rank. + */ +export const compare = (a: unknown, b: unknown): number => { + const rankDiff = compareNumbers(rank(a), rank(b)); + if (rankDiff !== 0) { + return rankDiff; + } + if (a === null) { + return 0; + } + if (typeof a === 'boolean' && typeof b === 'boolean') { + return compareNumbers(Number(a), Number(b)); + } + if (typeof a === 'number' && typeof b === 'number') { + return compareNumbers(a, b); + } + if ( + a instanceof FirestoreSchema.Timestamp && + b instanceof FirestoreSchema.Timestamp + ) { + return ( + compareNumbers(a.seconds, b.seconds) || + compareNumbers(a.nanoseconds, b.nanoseconds) + ); + } + if (typeof a === 'string' && typeof b === 'string') { + return a < b ? -1 : a > b ? 1 : 0; + } + if ( + a instanceof FirestoreSchema.Reference && + b instanceof FirestoreSchema.Reference + ) { + return compare(a.path, b.path); + } + if ( + a instanceof FirestoreSchema.GeoPoint && + b instanceof FirestoreSchema.GeoPoint + ) { + return ( + compareNumbers(a.latitude, b.latitude) || + compareNumbers(a.longitude, b.longitude) + ); + } + if (Array.isArray(a) && Array.isArray(b)) { + const length = Math.min(a.length, b.length); + for (let i = 0; i < length; i++) { + const diff = compare(a[i], b[i]); + if (diff !== 0) { + return diff; + } + } + return compareNumbers(a.length, b.length); + } + if (isRecord(a) && isRecord(b)) { + const aKeys = Object.keys(a).sort(); + const bKeys = Object.keys(b).sort(); + const length = Math.min(aKeys.length, bKeys.length); + for (let i = 0; i < length; i++) { + const keyDiff = compare(aKeys[i], bKeys[i]); + if (keyDiff !== 0) { + return keyDiff; + } + const valueDiff = compare(a[aKeys[i]], b[bKeys[i]]); + if (valueDiff !== 0) { + return valueDiff; + } + } + return compareNumbers(aKeys.length, bKeys.length); + } + // undefined vs undefined and opaque values (sentinels, bigints, ...): + // equal only on identity; distinct values get a stable, antisymmetric + // order so sorting stays deterministic across engines. + if (a === b) { + return 0; + } + return compareOpaque(a, b); +}; + +const isWeakKey = (value: unknown): value is WeakKey => + (typeof value === 'object' && value !== null) || typeof value === 'function'; + +const opaqueIds = new WeakMap(); +let nextOpaqueId = 0; + +const opaqueId = (value: WeakKey): number => { + let id = opaqueIds.get(value); + if (id === undefined) { + id = nextOpaqueId++; + opaqueIds.set(value, id); + } + return id; +}; + +const symbolIds = new Map(); +let nextSymbolId = 0; + +const symbolId = (value: symbol): number => { + let id = symbolIds.get(value); + if (id === undefined) { + id = nextSymbolId++; + symbolIds.set(value, id); + } + return id; +}; + +const compareOpaque = (a: unknown, b: unknown): number => { + const aIsWeak = isWeakKey(a); + const bIsWeak = isWeakKey(b); + if (aIsWeak && bIsWeak) { + // First-seen order: arbitrary but stable and antisymmetric. + return compareNumbers(opaqueId(a), opaqueId(b)); + } + if (aIsWeak !== bIsWeak) { + return aIsWeak ? 1 : -1; + } + if (typeof a === 'symbol' && typeof b === 'symbol') { + // Two Symbol('x') share a string form but are distinct values; order + // them by first-seen identity like other opaque objects. + return compareNumbers(symbolId(a), symbolId(b)); + } + // Distinct bigints: order by their string form. + const aString = String(a); + const bString = String(b); + return aString < bString ? -1 : aString > bString ? 1 : 0; +}; + +/** + * Structural equality for stored values. + */ +export const equals = (a: unknown, b: unknown): boolean => compare(a, b) === 0; + +/** + * Whether two values share the same Firestore type rank. Range comparisons + * (`<`, `<=`, `>`, `>=`) only ever match values of the same type. + */ +export const sameType = (a: unknown, b: unknown): boolean => + rank(a) === rank(b); + +/** + * Resolve a (possibly dot-separated) field path against document data. + * Returns `undefined` when any intermediate segment is missing. + */ +export const fieldValue = (data: DocData, fieldPath: string): unknown => { + let current: unknown = data; + for (const segment of fieldPath.split('.')) { + if (!isRecord(current)) { + return undefined; + } + current = current[segment]; + } + return current; +}; + +/** + * Recursively materialize sentinel values for storage: + * `ServerTimestamp` becomes `now`, array sentinels collapse to plain arrays. + */ +const materialize = ( + value: unknown, + now: FirestoreSchema.Timestamp, +): unknown => { + if (value instanceof FirestoreSchema.ServerTimestamp) { + return now; + } + if (value instanceof Firestore.ArrayUnion) { + return missingFrom( + [], + value.values.map((item) => materialize(item, now)), + ); + } + if (value instanceof Firestore.ArrayRemove) { + return []; + } + if (Array.isArray(value)) { + return value.map((item) => materialize(item, now)); + } + if (isRecord(value)) { + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (item instanceof Firestore.Delete) { + continue; + } + result[key] = materialize(item, now); + } + return result; + } + return value; +}; + +const applyField = ( + existing: unknown, + incoming: unknown, + now: FirestoreSchema.Timestamp, +): unknown => { + if (incoming instanceof Firestore.ArrayUnion) { + const base = Array.isArray(existing) ? existing : []; + const additions = missingFrom( + base, + incoming.values.map((item) => materialize(item, now)), + ); + return [...base, ...additions]; + } + if (incoming instanceof Firestore.ArrayRemove) { + const base = Array.isArray(existing) ? existing : []; + const removals = incoming.values.map((item) => materialize(item, now)); + return base.filter( + (item) => !removals.some((removal) => equals(removal, item)), + ); + } + return materialize(incoming, now); +}; + +const missingFrom = ( + base: ReadonlyArray, + additions: ReadonlyArray, +): Array => { + const result: Array = []; + for (const addition of additions) { + const present = + base.some((item) => equals(item, addition)) || + result.some((item) => equals(item, addition)); + if (!present) { + result.push(addition); + } + } + return result; +}; + +/** + * Apply a full document write (`add` / `set` without merge). + */ +export const applySet = ( + incoming: DocData, + now: FirestoreSchema.Timestamp, +): DocData => { + const result: DocData = {}; + for (const [key, value] of Object.entries(incoming)) { + if (value instanceof Firestore.Delete) { + continue; + } + result[key] = applyField(undefined, value, now); + } + return result; +}; + +const mergeRecords = ( + existing: Record, + incoming: Record, + now: FirestoreSchema.Timestamp, +): Record => { + const result: Record = { ...existing }; + for (const [key, value] of Object.entries(incoming)) { + if (value instanceof Firestore.Delete) { + delete result[key]; + continue; + } + const current = result[key]; + if (isRecord(current) && isRecord(value)) { + result[key] = mergeRecords(current, value, now); + continue; + } + result[key] = applyField(current, value, now); + } + return result; +}; + +/** + * Apply a merging write (`set` with `{ merge: true }`). + */ +export const applyMerge = ( + existing: DocData | undefined, + incoming: DocData, + now: FirestoreSchema.Timestamp, +): DocData => mergeRecords(existing ?? {}, incoming, now); + +const setAtPath = ( + data: Record, + segments: ReadonlyArray, + value: unknown, + now: FirestoreSchema.Timestamp, +): Record => { + const [head, ...rest] = segments; + const result = { ...data }; + if (rest.length === 0) { + if (value instanceof Firestore.Delete) { + delete result[head]; + } else { + result[head] = applyField(result[head], value, now); + } + return result; + } + const current = result[head]; + result[head] = setAtPath(isRecord(current) ? current : {}, rest, value, now); + return result; +}; + +/** + * Apply an `update` write. Keys may contain dot-separated field paths. + */ +export const applyUpdate = ( + existing: DocData, + incoming: DocData, + now: FirestoreSchema.Timestamp, +): DocData => { + let result: Record = { ...existing }; + for (const [key, value] of Object.entries(incoming)) { + result = setAtPath(result, key.split('.'), value, now); + } + return result; +}; diff --git a/packages/mock/vite.config.ts b/packages/mock/vite.config.ts index 8b2043e..c948572 100644 --- a/packages/mock/vite.config.ts +++ b/packages/mock/vite.config.ts @@ -4,20 +4,16 @@ export default defineConfig(() => ({ root: __dirname, cacheDir: '../../node_modules/.vite/packages/mock', plugins: [], - // Uncomment this if you are using workers. - // worker: { - // plugins: [ nxViteTsPaths() ], - // }, - // test: { - // name: '@effect-firebase/mock', - // watch: false, - // globals: true, - // environment: 'node', - // include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - // reporters: ['default'], - // coverage: { - // reportsDirectory: './test-output/vitest/coverage', - // provider: 'v8' as const, - // }, - // }, + test: { + name: '@effect-firebase/mock', + watch: false, + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, })); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62214bf..1797e1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,12 @@ importers: '@effect-firebase/client': specifier: workspace:* version: link:../../packages/client + '@effect-firebase/devtools': + specifier: workspace:* + version: link:../../packages/devtools + '@effect-firebase/mock': + specifier: workspace:* + version: link:../../packages/mock '@effect/atom-react': specifier: 'catalog:' version: 4.0.0-beta.103(effect@4.0.0-beta.103)(react@19.2.8)(scheduler@0.27.0) @@ -237,6 +243,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.1)(tsx@4.20.6)(yaml@2.9.0)) + '@tanstack/react-devtools': + specifier: ^0.10.8 + version: 0.10.9(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(csstype@3.2.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.14) '@tanstack/react-form': specifier: ^1.33.3 version: 1.33.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -277,9 +286,6 @@ importers: specifier: ^3.6.0 version: 3.6.0 devDependencies: - '@effect-firebase/mock': - specifier: workspace:* - version: link:../../packages/mock vite: specifier: 8.2.0 version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.1)(tsx@4.20.6)(yaml@2.9.0) @@ -356,6 +362,25 @@ importers: specifier: 'catalog:' version: 12.17.1 + packages/devtools: + dependencies: + tslib: + specifier: ^2.3.0 + version: 2.8.1 + devDependencies: + '@effect-firebase/mock': + specifier: workspace:* + version: link:../mock + effect: + specifier: 'catalog:' + version: 4.0.0-beta.103 + effect-firebase: + specifier: workspace:* + version: link:../effect-firebase + react: + specifier: 19.2.8 + version: 19.2.8 + packages/effect-firebase: dependencies: tslib: @@ -3075,6 +3100,36 @@ packages: '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@solid-primitives/event-listener@2.4.6': + resolution: {integrity: sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/keyboard@1.3.7': + resolution: {integrity: sha512-558RPNYnXx4nGh537DSqAn4xMrC8iFipl/5+xzgzWoTNFst4RnUN3BOLmtDjJ0UGGoQXVMALYR3bNOHM0xnt1Q==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/resize-observer@2.2.0': + resolution: {integrity: sha512-9Fuu/EWBeGj+atGHRJp70HKhdfalmpjwxY8a32NZixdLNmfCJ45AfhLQNr6uOzETbbiMx4iCKlTrJ8KZCHC2Ww==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/rootless@1.5.4': + resolution: {integrity: sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/static-store@0.1.4': + resolution: {integrity: sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.4.1': + resolution: {integrity: sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg==} + peerDependencies: + solid-js: ^1.6.12 + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3363,11 +3418,37 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/devtools-client@0.0.8': + resolution: {integrity: sha512-cG3iZkGWCwN330bLBKa8+9r4Of2AXNoz2zUqcsy/4XsD3105ghVBx78cGyvJj9fSclNomPxoqAnDGXXhg1WLvA==} + engines: {node: '>=18'} + + '@tanstack/devtools-event-bus@0.4.2': + resolution: {integrity: sha512-2LHzhwBFlKHCcklsQrGe8TeyjHd4XAF8nuCO6wHmva5fePUkJUULbu6CsCNAlGlCi0KkEsMXZSvRdR4HgMq4yA==} + engines: {node: '>=18'} + '@tanstack/devtools-event-client@0.4.4': resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} engines: {node: '>=18'} hasBin: true + '@tanstack/devtools-event-client@0.5.0': + resolution: {integrity: sha512-H+OH3zC6Vhu/K0NaVfQKknEKawc/+2PT+D3SB3Ox0V8SiMlTo0abbmH2rH0721R2aNYbjdMXA1oENOd8E2UVoA==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/devtools-ui@0.6.0': + resolution: {integrity: sha512-CVaM6rT6Nl5ijo83vJYFa2SjofvpuOl/uOvbYGhBrRgUhhelNHhx8zZX+hnZCHmIr0/lzM65hsocnZ72592Rvg==} + engines: {node: '>=18'} + peerDependencies: + solid-js: '>=1.9.7' + + '@tanstack/devtools@0.13.0': + resolution: {integrity: sha512-p/nOH9bS/OO/u3402zPjoGu+Mz6Fzi/iRqJuYghuuYRUY32kZt+C0/d+pP/bi6/2JTi1FdT6oEXI2lWlA5tXxw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + solid-js: '>=1.9.7' + '@tanstack/eslint-plugin-router@1.162.0': resolution: {integrity: sha512-0mv+5fOnWXeorh6zd3VyHVfpejIzc7+z68Ts0CQMDzMiwjM5rvea46fyl2m50zx5JFennsu7RO/QJAfnqkKJXw==} peerDependencies: @@ -3384,6 +3465,15 @@ packages: resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} + '@tanstack/react-devtools@0.10.9': + resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8' + '@types/react-dom': '>=16.8' + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-form@1.33.3': resolution: {integrity: sha512-lkzI/y15fHC8lKvzsLXFLLqGWroa+okvV2cKRCGAL+d0Kdf040fdMZbKh6uCXDMc08Ngpl8G3VZFnZ5KVUkUIw==} peerDependencies: @@ -4911,6 +5001,9 @@ packages: dayjs@1.11.18: resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -8068,12 +8161,22 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval-plugins@1.6.2: resolution: {integrity: sha512-TfxuUjlbBESzUOWdTkTKqvSmav0ABym+itetDXLK6mDz8SmrpdI30aF8RTXE8Bvq+tH/1yIDkvy3W0lfQb1ipQ==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + seroval@1.6.2: resolution: {integrity: sha512-mPT+SD2TrlB6wvte1KkYOYUkubaTbd6pZ/6Kk3C9nxzrHmCZyhxOO7XGAeL7f+yLKZglzGtM9odUVvg/EhO+vQ==} engines: {node: '>=10'} @@ -8166,6 +8269,9 @@ packages: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + solid-js@1.9.14: + resolution: {integrity: sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==} + sonic-boom@3.8.1: resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} @@ -12321,6 +12427,40 @@ snapshots: color: 5.0.3 text-hex: 1.0.0 + '@solid-primitives/event-listener@2.4.6(solid-js@1.9.14)': + dependencies: + '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) + solid-js: 1.9.14 + + '@solid-primitives/keyboard@1.3.7(solid-js@1.9.14)': + dependencies: + '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.14) + '@solid-primitives/rootless': 1.5.4(solid-js@1.9.14) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) + solid-js: 1.9.14 + + '@solid-primitives/resize-observer@2.2.0(solid-js@1.9.14)': + dependencies: + '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.14) + '@solid-primitives/rootless': 1.5.4(solid-js@1.9.14) + '@solid-primitives/static-store': 0.1.4(solid-js@1.9.14) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) + solid-js: 1.9.14 + + '@solid-primitives/rootless@1.5.4(solid-js@1.9.14)': + dependencies: + '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) + solid-js: 1.9.14 + + '@solid-primitives/static-store@0.1.4(solid-js@1.9.14)': + dependencies: + '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) + solid-js: 1.9.14 + + '@solid-primitives/utils@6.4.1(solid-js@1.9.14)': + dependencies: + solid-js: 1.9.14 + '@standard-schema/spec@1.1.0': {} '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)': @@ -12595,8 +12735,46 @@ snapshots: tailwindcss: 4.3.3 vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.1)(tsx@4.20.6)(yaml@2.9.0) + '@tanstack/devtools-client@0.0.8': + dependencies: + '@tanstack/devtools-event-client': 0.5.0 + + '@tanstack/devtools-event-bus@0.4.2': + dependencies: + ws: 8.21.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@tanstack/devtools-event-client@0.4.4': {} + '@tanstack/devtools-event-client@0.5.0': {} + + '@tanstack/devtools-ui@0.6.0(csstype@3.2.3)(solid-js@1.9.14)': + dependencies: + clsx: 2.1.1 + dayjs: 1.11.21 + goober: 2.1.19(csstype@3.2.3) + solid-js: 1.9.14 + transitivePeerDependencies: + - csstype + + '@tanstack/devtools@0.13.0(csstype@3.2.3)(solid-js@1.9.14)': + dependencies: + '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.14) + '@solid-primitives/keyboard': 1.3.7(solid-js@1.9.14) + '@solid-primitives/resize-observer': 2.2.0(solid-js@1.9.14) + '@tanstack/devtools-client': 0.0.8 + '@tanstack/devtools-event-bus': 0.4.2 + '@tanstack/devtools-ui': 0.6.0(csstype@3.2.3)(solid-js@1.9.14) + clsx: 2.1.1 + goober: 2.1.19(csstype@3.2.3) + solid-js: 1.9.14 + transitivePeerDependencies: + - bufferutil + - csstype + - utf-8-validate + '@tanstack/eslint-plugin-router@1.162.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) @@ -12615,6 +12793,19 @@ snapshots: '@tanstack/pacer-lite@0.1.1': {} + '@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(csstype@3.2.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.14)': + dependencies: + '@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - bufferutil + - csstype + - solid-js + - utf-8-validate + '@tanstack/react-form@1.33.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@tanstack/form-core': 1.33.3 @@ -14514,6 +14705,8 @@ snapshots: dayjs@1.11.18: {} + dayjs@1.11.21: {} + de-indent@1.0.2: {} debug@2.6.9: @@ -18408,10 +18601,16 @@ snapshots: transitivePeerDependencies: - supports-color + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + seroval-plugins@1.6.2(seroval@1.6.2): dependencies: seroval: 1.6.2 + seroval@1.5.6: {} + seroval@1.6.2: {} serve-static@1.16.3: @@ -18530,6 +18729,12 @@ snapshots: ip-address: 10.4.0 smart-buffer: 4.2.0 + solid-js@1.9.14: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + sonic-boom@3.8.1: dependencies: atomic-sleep: 1.0.0 diff --git a/tsconfig.json b/tsconfig.json index 03f1b17..d216779 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,9 @@ { "path": "./packages/mock" }, + { + "path": "./packages/devtools" + }, { "path": "./example/app" }