From e7d6aad45d4c9b4dc1e79a85245f12e0e404e677 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 22:44:55 +0000 Subject: [PATCH 01/13] feat(mock): reactive in-memory Firestore backend with fixtures and simulated states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the aspirational mock README with a real implementation: - layer(): in-memory FirestoreService backed by a SubscriptionRef, so streamDoc/streamQuery are live — writes and state toggles re-emit through already-subscribed streams like onSnapshot - fixture()/rawFixture(): seed hard-coded models encoded through the real schema pipeline so reads exercise actual decoding - MockController service (provided by the same layer): toggle collections between data/empty/loading/error at runtime, seed docs, simulate latency, reset — the control surface for a future devtools panel - In-process query evaluation (where/orderBy/limits/cursors/and/or) with Firestore type ordering - Write fidelity: ServerTimestamp materialized via Clock, Delete/ ArrayUnion/ArrayRemove sentinels honored, not-found on update, recursive delete The existing MockFirestoreService stub remains exported for backwards compatibility. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- packages/mock/README.md | 109 +++- packages/mock/src/index.ts | 6 + packages/mock/src/lib/firestore/controller.ts | 84 +++ packages/mock/src/lib/firestore/fixture.ts | 95 ++++ packages/mock/src/lib/firestore/layer.spec.ts | 514 ++++++++++++++++++ packages/mock/src/lib/firestore/layer.ts | 438 +++++++++++++++ .../src/lib/firestore/query-filter.spec.ts | 175 ++++++ .../mock/src/lib/firestore/query-filter.ts | 214 ++++++++ packages/mock/src/lib/firestore/state.ts | 86 +++ packages/mock/src/lib/firestore/store.ts | 80 +++ packages/mock/src/lib/firestore/value.spec.ts | 141 +++++ packages/mock/src/lib/firestore/value.ts | 302 ++++++++++ packages/mock/vite.config.ts | 28 +- 13 files changed, 2242 insertions(+), 30 deletions(-) create mode 100644 packages/mock/src/lib/firestore/controller.ts create mode 100644 packages/mock/src/lib/firestore/fixture.ts create mode 100644 packages/mock/src/lib/firestore/layer.spec.ts create mode 100644 packages/mock/src/lib/firestore/layer.ts create mode 100644 packages/mock/src/lib/firestore/query-filter.spec.ts create mode 100644 packages/mock/src/lib/firestore/query-filter.ts create mode 100644 packages/mock/src/lib/firestore/state.ts create mode 100644 packages/mock/src/lib/firestore/store.ts create mode 100644 packages/mock/src/lib/firestore/value.spec.ts create mode 100644 packages/mock/src/lib/firestore/value.ts diff --git a/packages/mock/README.md b/packages/mock/README.md index 1316e178..aae504fa 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,95 @@ 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' }, +}); +``` + +## 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', +}); +``` + +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 +136,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 +145,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 - No transaction support - No multi-client synchronization diff --git a/packages/mock/src/index.ts b/packages/mock/src/index.ts index ad957c1e..b84f0dd9 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 00000000..f1771064 --- /dev/null +++ b/packages/mock/src/lib/firestore/controller.ts @@ -0,0 +1,84 @@ +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. + */ + 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; + + /** + * 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.ts b/packages/mock/src/lib/firestore/fixture.ts new file mode 100644 index 00000000..e731f4ec --- /dev/null +++ b/packages/mock/src/lib/firestore/fixture.ts @@ -0,0 +1,95 @@ +import { Effect, Schema } from 'effect'; +import { Model } from 'effect/unstable/schema'; +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 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` + ) + ); + } + result[`${options.collectionPath}/${id}`] = 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 00000000..167ac107 --- /dev/null +++ b/packages/mock/src/lib/firestore/layer.spec.ts @@ -0,0 +1,514 @@ +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 } 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('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('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 00000000..8c080dc8 --- /dev/null +++ b/packages/mock/src/lib/firestore/layer.ts @@ -0,0 +1,438 @@ +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. + */ + 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++) { + const index = yield* Random.nextIntBetween(0, ID_ALPHABET.length); + 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)); + let id = yield* generateId; + const snapshot = yield* SubscriptionRef.get(ref); + while (snapshot.docs[`${path}/${id}`] !== undefined) { + 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) + ) + ) + ); + }, + }; +}; + +const makeController = ( + ref: SubscriptionRef.SubscriptionRef, + latency: Ref.Ref, + initial: { snapshot: StoreSnapshot; 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)), + + reset: Effect.flatMap(Ref.set(latency, initial.latency), () => + SubscriptionRef.set(ref, initial.snapshot) + ), +}); + +/** + * An in-memory, reactive `FirestoreService` backend. + * + * The returned layer provides both the `FirestoreService` implementation and + * a {@link MockController} for driving it at runtime. Every `Effect.provide` + * gets a fresh, isolated store. + * + * @example + * ```ts + * const mock = layer({ + * fixtures: [posts], + * states: { comments: 'loading' }, + * latency: '200 millis', + * }); + * ``` + */ +export const layer = ( + options: LayerOptions = {} +): Layer.Layer => + Layer.effectContext( + Effect.gen(function* () { + let docs: Record = {}; + for (const fixture of options.fixtures ?? []) { + docs = { ...docs, ...(yield* fixture.build) }; + } + const states = Object.fromEntries( + Object.entries(options.states ?? {}).map(([key, input]) => [ + key, + MockState.fromInput(input), + ]) + ); + const initialLatency = Duration.fromInputUnsafe(options.latency ?? 0); + const snapshot: StoreSnapshot = { docs, states }; + const ref = yield* SubscriptionRef.make(snapshot); + const latency = yield* Ref.make(initialLatency); + return Context.make(FirestoreService, makeFirestore(ref, latency)).pipe( + Context.add( + MockController, + makeController(ref, latency, { snapshot, latency: initialLatency }) + ) + ); + }) + ); 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 00000000..107b19ff --- /dev/null +++ b/packages/mock/src/lib/firestore/query-filter.spec.ts @@ -0,0 +1,175 @@ +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('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('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('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 00000000..78991480 --- /dev/null +++ b/packages/mock/src/lib/firestore/query-filter.ts @@ -0,0 +1,214 @@ +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; + } + } + + let results = snapshots.filter(([, data]) => + filters.every((filter) => matchesFilter(data, filter)) + ); + + 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 + ); + } + + if (limit !== undefined) { + results = results.slice(0, limit); + } + if (limitToLast !== undefined) { + results = results.slice(Math.max(0, results.length - limitToLast)); + } + + 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 00000000..8e6eb29a --- /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 00000000..1afc7b2e --- /dev/null +++ b/packages/mock/src/lib/firestore/store.ts @@ -0,0 +1,80 @@ +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)); +}; + +const isDocPath = (path: string): boolean => { + const segments = path.split('/'); + return ( + segments.length >= 2 && + segments.length % 2 === 0 && + segments.every((segment) => segment.length > 0) + ); +}; + +const isCollectionPath = (path: string): boolean => { + const segments = path.split('/'); + return ( + segments.length % 2 === 1 && + segments.every((segment) => segment.length > 0) + ); +}; + +export const validateDocPath = (path: string): string | undefined => + isDocPath(path) + ? undefined + : `Invalid document path '${path}': expected a non-empty path with an even number of segments`; + +export const validateCollectionPath = (path: string): string | undefined => + isCollectionPath(path) + ? undefined + : `Invalid collection path '${path}': expected a non-empty path with an odd number of segments`; 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 00000000..3ec44f52 --- /dev/null +++ b/packages/mock/src/lib/firestore/value.spec.ts @@ -0,0 +1,141 @@ +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 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 00000000..a48f1736 --- /dev/null +++ b/packages/mock/src/lib/firestore/value.ts @@ -0,0 +1,302 @@ +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 === 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; + return 8; +}; + +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); + } + return 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 dedupe(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 dedupe = (values: ReadonlyArray): Array => { + const result: Array = []; + for (const value of values) { + if (!result.some((existing) => equals(existing, value))) { + result.push(value); + } + } + return result; +}; + +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 8b2043e4..c9485725 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, + }, + }, })); From 30262678bf98b4f83059538838461cd4d3221f01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 22:54:09 +0000 Subject: [PATCH 02/13] feat(devtools): TanStack Devtools plugin for the mock Firestore backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @effect-firebase/devtools: new package with MockDevtoolsPanel, a React panel that lists collections with doc counts and toggles each between data/empty/loading/error, picks the simulated error code, controls latency, and resets to fixtures — subscribed live to the mock store - firestoreMockPlugin(controller): plugin factory for ; the plugin interface is declared structurally so TanStack Devtools is not a dependency - mock: add make() returning { layer, controller } so code outside the Effect runtime (devtools, Storybook, tests) can drive the same store the app's layer provides; layer() now builds on make() with fresh-per-provide semantics preserved - mock: add controller.latency getter for the panel's latency display Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- packages/devtools/README.md | 81 +++++ packages/devtools/eslint.config.mjs | 10 + packages/devtools/package.json | 46 +++ packages/devtools/src/index.ts | 2 + packages/devtools/src/lib/panel.spec.tsx | 138 +++++++ packages/devtools/src/lib/panel.tsx | 340 ++++++++++++++++++ packages/devtools/src/lib/plugin.tsx | 67 ++++ packages/devtools/tsconfig.json | 13 + packages/devtools/tsconfig.lib.json | 37 ++ packages/devtools/tsconfig.spec.json | 36 ++ packages/devtools/vite.config.ts | 19 + packages/mock/README.md | 16 + packages/mock/src/lib/firestore/controller.ts | 5 + packages/mock/src/lib/firestore/layer.spec.ts | 47 ++- packages/mock/src/lib/firestore/layer.ts | 127 +++++-- pnpm-lock.yaml | 19 + tsconfig.json | 3 + 17 files changed, 976 insertions(+), 30 deletions(-) create mode 100644 packages/devtools/README.md create mode 100644 packages/devtools/eslint.config.mjs create mode 100644 packages/devtools/package.json create mode 100644 packages/devtools/src/index.ts create mode 100644 packages/devtools/src/lib/panel.spec.tsx create mode 100644 packages/devtools/src/lib/panel.tsx create mode 100644 packages/devtools/src/lib/plugin.tsx create mode 100644 packages/devtools/tsconfig.json create mode 100644 packages/devtools/tsconfig.lib.json create mode 100644 packages/devtools/tsconfig.spec.json create mode 100644 packages/devtools/vite.config.ts diff --git a/packages/devtools/README.md b/packages/devtools/README.md new file mode 100644 index 00000000..8fcfd057 --- /dev/null +++ b/packages/devtools/README.md @@ -0,0 +1,81 @@ +# @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'; + +const mock = make({ + fixtures: [posts, authors], +}); + +// 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. + +### Recovering from simulated errors + +A simulated `error` fails live streams **terminally**, matching real `onSnapshot` semantics. Consumers must re-subscribe once the state recovers. Use `onStateChange` to hook your re-subscription mechanism — e.g. refreshing the atoms or queries that read from the collection: + +```tsx +firestoreMockPlugin(mock.controller, { + onStateChange: (collectionPath, state) => { + if (state._tag !== 'Error') { + registry.refresh(postsAtom); // effect-atom example + } + }, +}); +``` + +The same applies to `loading`: an already-resolved effect keeps its value; refresh it while the collection is loading to see your initial loading UI again. + +## License + +MIT diff --git a/packages/devtools/eslint.config.mjs b/packages/devtools/eslint.config.mjs new file mode 100644 index 00000000..0712e8a8 --- /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 00000000..c11e33fd --- /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.4" + }, + "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 00000000..fc88481d --- /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 00000000..544ee8d9 --- /dev/null +++ b/packages/devtools/src/lib/panel.spec.tsx @@ -0,0 +1,138 @@ +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 + ); + + expect(seen).toEqual([['posts', '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 00000000..877398f2 --- /dev/null +++ b/packages/devtools/src/lib/panel.tsx @@ -0,0 +1,340 @@ +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; + /** + * Extra collection paths to always show, even before any document or + * state exists for them. + */ + readonly collections?: ReadonlyArray; + /** + * Called after a state toggle 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. + */ + 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 => { + switch (state._tag) { + case 'Data': + return 'data'; + case 'Empty': + return 'empty'; + case 'Loading': + return 'loading'; + case 'Error': + return 'error'; + } +}; + +/** 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', +}; + +const runEffect = (effect: Effect.Effect): void => { + void Effect.runPromise(effect); +}; + +/** + * 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, + collections, + 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(collections ?? []); + 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, collections]); + + 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; + + const setState = (collectionPath: string, name: StateName): void => { + const state = MockState.fromInput(toInput(name)); + runEffect(controller.setState(collectionPath, state)); + onStateChange?.(collectionPath, state); + }; + + const applyLatency = (value: number): void => { + setLatencyMs(value); + runEffect(controller.setLatency(`${value} 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 00000000..3d80c96f --- /dev/null +++ b/packages/devtools/src/lib/plugin.tsx @@ -0,0 +1,67 @@ +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 { + /** + * Plugin ID shown to TanStack Devtools. Defaults to `effect-firebase-mock`. + */ + readonly id?: string; + /** + * Tab label in the devtools shell. Defaults to `Firestore Mock`. + */ + readonly name?: string; + /** + * 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: options.id ?? 'effect-firebase-mock', + name: options.name ?? 'Firestore Mock', + defaultOpen: options.defaultOpen, + render: ( + + ), +}); diff --git a/packages/devtools/tsconfig.json b/packages/devtools/tsconfig.json new file mode 100644 index 00000000..62ebbd94 --- /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 00000000..09fc88bd --- /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 00000000..398144cb --- /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 00000000..a0b8a63b --- /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/mock/README.md b/packages/mock/README.md index aae504fa..adc75fbb 100644 --- a/packages/mock/README.md +++ b/packages/mock/README.md @@ -113,6 +113,22 @@ const mock = layer({ }); ``` +## 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 { 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. diff --git a/packages/mock/src/lib/firestore/controller.ts b/packages/mock/src/lib/firestore/controller.ts index f1771064..9b8ef287 100644 --- a/packages/mock/src/lib/firestore/controller.ts +++ b/packages/mock/src/lib/firestore/controller.ts @@ -66,6 +66,11 @@ export interface MockControllerShape { 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. diff --git a/packages/mock/src/lib/firestore/layer.spec.ts b/packages/mock/src/lib/firestore/layer.spec.ts index 167ac107..a239ad95 100644 --- a/packages/mock/src/lib/firestore/layer.spec.ts +++ b/packages/mock/src/lib/firestore/layer.spec.ts @@ -18,7 +18,7 @@ import { } from 'effect-firebase'; import { MockController } from './controller.js'; import { fixture, rawFixture } from './fixture.js'; -import { layer } from './layer.js'; +import { layer, make } from './layer.js'; import * as MockState from './state.js'; const PostId = Schema.String.pipe(Schema.brand('PostId')); @@ -443,6 +443,51 @@ describe('layer', () => { )); }); + 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( diff --git a/packages/mock/src/lib/firestore/layer.ts b/packages/mock/src/lib/firestore/layer.ts index 8c080dc8..75a837dc 100644 --- a/packages/mock/src/lib/firestore/layer.ts +++ b/packages/mock/src/lib/firestore/layer.ts @@ -341,7 +341,7 @@ const makeFirestore = ( const makeController = ( ref: SubscriptionRef.SubscriptionRef, latency: Ref.Ref, - initial: { snapshot: StoreSnapshot; latency: Duration.Duration } + initial: { ref: Ref.Ref; latency: Duration.Duration } ): MockControllerShape => ({ setState: (collectionPath, state) => SubscriptionRef.update(ref, (snapshot) => ({ @@ -388,17 +388,109 @@ const makeController = ( setLatency: (input) => Ref.set(latency, Duration.fromInputUnsafe(input)), - reset: Effect.flatMap(Ref.set(latency, initial.latency), () => - SubscriptionRef.set(ref, initial.snapshot) - ), + 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 seeded = Effect.runSync(Ref.make(false)); + + const controller = makeController(ref, latency, { + ref: initialRef, + latency: initialLatency, + }); + + const seedOnce = Effect.gen(function* () { + if (yield* Ref.getAndSet(seeded, true)) { + return; + } + 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 `Effect.provide` - * gets a fresh, isolated store. + * gets a fresh, isolated store — use {@link make} instead when external code + * (like a devtools panel) needs a shared handle on the store. * * @example * ```ts @@ -412,27 +504,4 @@ const makeController = ( export const layer = ( options: LayerOptions = {} ): Layer.Layer => - Layer.effectContext( - Effect.gen(function* () { - let docs: Record = {}; - for (const fixture of options.fixtures ?? []) { - docs = { ...docs, ...(yield* fixture.build) }; - } - const states = Object.fromEntries( - Object.entries(options.states ?? {}).map(([key, input]) => [ - key, - MockState.fromInput(input), - ]) - ); - const initialLatency = Duration.fromInputUnsafe(options.latency ?? 0); - const snapshot: StoreSnapshot = { docs, states }; - const ref = yield* SubscriptionRef.make(snapshot); - const latency = yield* Ref.make(initialLatency); - return Context.make(FirestoreService, makeFirestore(ref, latency)).pipe( - Context.add( - MockController, - makeController(ref, latency, { snapshot, latency: initialLatency }) - ) - ); - }) - ); + Layer.suspend(() => make(options).layer); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35c199c0..cd6e7c17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -350,6 +350,25 @@ importers: specifier: 'catalog:' version: 12.16.0 + 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.99 + effect-firebase: + specifier: workspace:* + version: link:../effect-firebase + react: + specifier: 19.2.4 + version: 19.2.4 + packages/effect-firebase: dependencies: tslib: diff --git a/tsconfig.json b/tsconfig.json index 9ce785e3..892fa13b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,9 @@ { "path": "./packages/mock" }, + { + "path": "./packages/devtools" + }, { "path": "./example/app" } From fffb5ae235dded70d65294eddaf51d8fe22d9537 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 13:38:54 +0000 Subject: [PATCH 03/13] feat(example): mock backend mode with Firestore Mock devtools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on the atom-based example app from claude/vigilant-pascal-qgjsm4 (merged): the Firestore layer is swappable at the registry boundary via firestoreLayerAtom, so mock mode is one initialValues entry. - example/app/src/lib/mock.ts: mock backend handle with schema-encoded Post/Author fixtures - app.tsx: VITE_MOCK_BACKEND=1 seeds firestoreLayerAtom with the mock layer instead of the emulator client; a single TanStack Devtools shell now hosts the router panel plus the Firestore Mock panel, with onStateChange refreshing latestPostsAtom so streams that ended on a simulated error re-subscribe - mock: implement withTransaction/withBatch (pass-through — no concurrency or staging semantics to simulate) - devtools: pin explicit heights on panel elements so shell CSS resets that stretch divs cannot distort the layout - pnpm example:mock script; REACT.md section 7 documents the workflow Verified end-to-end in a real browser: fixtures render through atoms, empty/loading/error toggle live from the panel, data recovers after a terminal stream error via refresh, and form writes flow through the repository into the live stream (doc counts update in the panel). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- REACT.md | 50 +++++- example/app/package.json | 4 +- example/app/src/app/app.tsx | 56 ++++++- example/app/src/lib/mock.ts | 74 +++++++++ example/app/src/routes/__root.tsx | 4 +- example/app/tsconfig.app.json | 3 + package.json | 1 + packages/devtools/src/lib/panel.tsx | 6 + packages/mock/src/lib/firestore/layer.ts | 7 + pnpm-lock.yaml | 184 ++++++++++++++++++++++- 10 files changed, 379 insertions(+), 10 deletions(-) create mode 100644 example/app/src/lib/mock.ts diff --git a/REACT.md b/REACT.md index d2dd8206..f6402f75 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) --- @@ -318,7 +319,52 @@ 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'; + + refreshPosts(), + }), + ]} +/>; +``` + +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/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 2196d29e..43643109 100644 --- a/example/app/package.json +++ b/example/app/package.json @@ -9,10 +9,13 @@ }, "packageManager": "pnpm@10.25.0", "dependencies": { + "@effect-firebase/devtools": "workspace:*", + "@effect-firebase/mock": "workspace:*", "@effect/atom-react": "catalog:", "@effect/platform-browser": "catalog:", "@nx/react": "22.4.5", "@nx/vite": "22.5.4", + "@tanstack/react-devtools": "^0.10.8", "@tanstack/react-form": "^1.32.0", "@tanstack/react-router": "^1.139.3", "@tanstack/react-router-devtools": "^1.139.3", @@ -30,7 +33,6 @@ }, "devDependencies": { "@effect-firebase/client": "workspace:*", - "@effect-firebase/mock": "workspace:*", "@example/shared": "workspace:*", "vite": "7.1.8" }, diff --git a/example/app/src/app/app.tsx b/example/app/src/app/app.tsx index c7dd7c70..a7b97e82 100644 --- a/example/app/src/app/app.tsx +++ b/example/app/src/app/app.tsx @@ -5,22 +5,73 @@ import { connectFirestoreEmulator, initializeFirestore, } from 'firebase/firestore'; +import { Layer } from 'effect'; import { Client } from '@effect-firebase/client'; -import { RegistryProvider } from '@effect/atom-react'; +import { RegistryProvider, useAtomRefresh } 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, latestPostsAtom } 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. Mounted inside the RegistryProvider so state + * toggles can refresh the atoms whose streams ended on a simulated error + * (stream errors are terminal, matching onSnapshot semantics). + */ +function Devtools() { + const refreshPosts = useAtomRefresh(latestPostsAtom); + const plugins = useMemo(() => { + const all: Array = [ + { + name: 'TanStack Router', + render: , + }, + ]; + if (useMockBackend) { + all.push( + firestoreMockPlugin(mockBackend.controller, { + defaultOpen: true, + onStateChange: (collectionPath) => { + if (collectionPath === 'posts' || collectionPath === '*') { + refreshPosts(); + } + }, + }) + ); + } + return all; + }, [refreshPosts]); + return ; +} + export function App({ children }: AppProps) { const layer = useMemo(() => { 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, }); @@ -45,6 +96,7 @@ export function App({ children }: AppProps) {
{children}
+
); } diff --git a/example/app/src/lib/mock.ts b/example/app/src/lib/mock.ts new file mode 100644 index 00000000..887f4874 --- /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 cc513e89..29a2949c 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/tsconfig.app.json b/example/app/tsconfig.app.json index 30221cf8..2eb52406 100644 --- a/example/app/tsconfig.app.json +++ b/example/app/tsconfig.app.json @@ -31,6 +31,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 76ba5c0d..fbded891 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/src/lib/panel.tsx b/packages/devtools/src/lib/panel.tsx index 877398f2..82620579 100644 --- a/packages/devtools/src/lib/panel.tsx +++ b/packages/devtools/src/lib/panel.tsx @@ -88,6 +88,9 @@ const styles = { alignItems: 'center', gap: 8, flexWrap: 'wrap', + // Explicit height so host-page or devtools-shell CSS resets that stretch + // divs cannot distort the layout; same for row/buttonGroup below. + height: 'auto', paddingBottom: 10, borderBottom: '1px solid #2a2d35', marginBottom: 10, @@ -118,6 +121,7 @@ const styles = { display: 'flex', alignItems: 'center', gap: 8, + height: 'auto', padding: '4px 0', } satisfies CSSProperties, collection: { @@ -135,9 +139,11 @@ const styles = { buttonGroup: { display: 'flex', gap: 4, + height: 'auto', } satisfies CSSProperties, emptyMessage: { color: '#9ca3af', + height: 'auto', padding: '8px 0', } satisfies CSSProperties, }; diff --git a/packages/mock/src/lib/firestore/layer.ts b/packages/mock/src/lib/firestore/layer.ts index 75a837dc..42a3fdab 100644 --- a/packages/mock/src/lib/firestore/layer.ts +++ b/packages/mock/src/lib/firestore/layer.ts @@ -335,6 +335,13 @@ const makeFirestore = ( ) ); }, + + // 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, }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b30bf445..537991cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,12 @@ importers: example/app: dependencies: + '@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.99(effect@4.0.0-beta.99)(react@19.2.4)(scheduler@0.27.0) @@ -237,6 +243,9 @@ importers: '@nx/vite': specifier: 22.5.4 version: 22.5.4(@babel/traverse@7.28.0)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.19))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.19))(nx@22.5.4(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.19))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.19)))(typescript@5.9.3)(verdaccio@6.1.2(encoding@0.1.13)(typanion@3.14.0))(vite@7.1.8(@types/node@22.17.0)(jiti@2.4.2)(terser@5.43.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.0.9) + '@tanstack/react-devtools': + specifier: ^0.10.8 + version: 0.10.8(@types/react-dom@19.0.0)(@types/react@19.0.0)(csstype@3.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10) '@tanstack/react-form': specifier: ^1.32.0 version: 1.33.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -283,9 +292,6 @@ importers: '@effect-firebase/client': specifier: workspace:* version: link:../../packages/client - '@effect-firebase/mock': - specifier: workspace:* - version: link:../../packages/mock '@example/shared': specifier: workspace:* version: link:../shared @@ -2895,6 +2901,36 @@ packages: resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} engines: {node: '>=14.16'} + '@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==} @@ -3085,11 +3121,37 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} + '@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.12.5': + resolution: {integrity: sha512-JdxTSeVdjJheycgz4c7qbldNKDCEDWlWr1l9dZBhd9sOmRBT5Z70ka9Eb8mb+FUnalcOIB62IDSR/iSxAIUD8Q==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + solid-js: '>=1.9.7' + '@tanstack/eslint-plugin-router@1.139.0': resolution: {integrity: sha512-1lNmOtQF6VzDiGYulpYpdX/K8odTjmU5QJ3Mj/U05NDyzXj4aBBBo8y13PnnafOpsKm9sKar5xtMSjOnFCur3A==} peerDependencies: @@ -3106,6 +3168,15 @@ packages: resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} + '@tanstack/react-devtools@0.10.8': + resolution: {integrity: sha512-YJV6YttQf9lhhPbPBLULgy1eScEvJUMsCS26mjg9hfBKgAJQA5sF9zvzorDzW9Ob6o/asoXikO81JnRUVuFX0Q==} + 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.2': resolution: {integrity: sha512-nEfayOu+27q5cZ5E0G5dmnddqLcLjdFCatbL/LCs/iLD469a1o1yYJlr8RISV3GfnqsBpm0hf+8kM4okh5fPCw==} peerDependencies: @@ -3463,6 +3534,7 @@ packages: '@verdaccio/commons-api@10.2.0': resolution: {integrity: sha512-F/YZANu4DmpcEV0jronzI7v2fGVWkQ5Mwi+bVmV+ACJ+EzR0c9Jbhtbe5QyLUuzR97t8R5E/Xe53O0cc2LukdQ==} engines: {node: '>=8'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@verdaccio/config@8.0.0-next-8.15': resolution: {integrity: sha512-oEzQB+xeqaFAy54veMshqpt1hlZCYNkqoKuwkt7O8J43Fo/beiLluKUVneXckzi+pg1yvvGT7lNCbvuUQrxxQg==} @@ -4589,6 +4661,9 @@ packages: dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -5522,6 +5597,7 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.5.0: @@ -8669,6 +8745,7 @@ packages: whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -8780,6 +8857,18 @@ packages: utf-8-validate: optional: true + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xdg-basedir@4.0.0: resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} engines: {node: '>=8'} @@ -11997,6 +12086,40 @@ snapshots: '@sindresorhus/is@5.6.0': {} + '@solid-primitives/event-listener@2.4.6(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/keyboard@1.3.7(solid-js@1.9.10)': + dependencies: + '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.10) + '@solid-primitives/rootless': 1.5.4(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/resize-observer@2.2.0(solid-js@1.9.10)': + dependencies: + '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.10) + '@solid-primitives/rootless': 1.5.4(solid-js@1.9.10) + '@solid-primitives/static-store': 0.1.4(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/rootless@1.5.4(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/static-store@0.1.4(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/utils@6.4.1(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + '@standard-schema/spec@1.1.0': {} '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.0)': @@ -12193,8 +12316,46 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@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.1 + 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.1.3)(solid-js@1.9.10)': + dependencies: + clsx: 2.1.1 + dayjs: 1.11.21 + goober: 2.1.18(csstype@3.1.3) + solid-js: 1.9.10 + transitivePeerDependencies: + - csstype + + '@tanstack/devtools@0.12.5(csstype@3.1.3)(solid-js@1.9.10)': + dependencies: + '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.10) + '@solid-primitives/keyboard': 1.3.7(solid-js@1.9.10) + '@solid-primitives/resize-observer': 2.2.0(solid-js@1.9.10) + '@tanstack/devtools-client': 0.0.8 + '@tanstack/devtools-event-bus': 0.4.2 + '@tanstack/devtools-ui': 0.6.0(csstype@3.1.3)(solid-js@1.9.10) + clsx: 2.1.1 + goober: 2.1.18(csstype@3.1.3) + solid-js: 1.9.10 + transitivePeerDependencies: + - bufferutil + - csstype + - utf-8-validate + '@tanstack/eslint-plugin-router@1.139.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.9.3)': dependencies: '@typescript-eslint/utils': 8.45.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.9.3) @@ -12213,6 +12374,19 @@ snapshots: '@tanstack/pacer-lite@0.1.1': {} + '@tanstack/react-devtools@0.10.8(@types/react-dom@19.0.0)(@types/react@19.0.0)(csstype@3.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)': + dependencies: + '@tanstack/devtools': 0.12.5(csstype@3.1.3)(solid-js@1.9.10) + '@types/react': 19.0.0 + '@types/react-dom': 19.0.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - bufferutil + - csstype + - solid-js + - utf-8-validate + '@tanstack/react-form@1.33.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/form-core': 1.33.2 @@ -14164,6 +14338,8 @@ snapshots: dayjs@1.11.13: {} + dayjs@1.11.21: {} + de-indent@1.0.2: {} debug@2.6.9: @@ -19296,6 +19472,8 @@ snapshots: ws@8.18.0: {} + ws@8.21.1: {} + xdg-basedir@4.0.0: {} xml-name-validator@4.0.0: {} From 4471cee02f89927cf5d9a3880f56f56600c7a104 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 07:02:09 +0000 Subject: [PATCH 04/13] chore: align with updated dependencies and formatting - react 19.2.8 in devtools devDeps (two React copies broke jsdom tests) - useState initializer instead of side-effecting useMemo in app.tsx (rejected by eslint-plugin-react-hooks 7 / React Compiler) - apply prettier 3.9.6 formatting to new files Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- example/app/src/app/app.tsx | 10 +- example/app/src/lib/mock.ts | 8 +- packages/devtools/README.md | 4 +- packages/devtools/package.json | 2 +- packages/devtools/src/lib/panel.spec.tsx | 18 +-- packages/devtools/src/lib/panel.tsx | 14 +-- packages/devtools/src/lib/plugin.tsx | 13 +- packages/mock/README.md | 10 +- packages/mock/src/lib/firestore/controller.ts | 6 +- packages/mock/src/lib/firestore/fixture.ts | 18 +-- packages/mock/src/lib/firestore/layer.spec.ts | 119 ++++++++---------- packages/mock/src/lib/firestore/layer.ts | 85 +++++++------ .../src/lib/firestore/query-filter.spec.ts | 74 ++++++----- .../mock/src/lib/firestore/query-filter.ts | 33 ++--- packages/mock/src/lib/firestore/state.ts | 2 +- packages/mock/src/lib/firestore/store.ts | 8 +- packages/mock/src/lib/firestore/value.spec.ts | 24 ++-- packages/mock/src/lib/firestore/value.ts | 23 ++-- pnpm-lock.yaml | 10 +- 19 files changed, 242 insertions(+), 239 deletions(-) diff --git a/example/app/src/app/app.tsx b/example/app/src/app/app.tsx index a7b97e82..388f25b9 100644 --- a/example/app/src/app/app.tsx +++ b/example/app/src/app/app.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { initializeApp } from 'firebase/app'; import { getFunctions, connectFunctionsEmulator } from 'firebase/functions'; import { @@ -53,7 +53,7 @@ function Devtools() { refreshPosts(); } }, - }) + }), ); } return all; @@ -62,7 +62,9 @@ function Devtools() { } 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); @@ -78,7 +80,7 @@ export function App({ children }: AppProps) { 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. diff --git a/example/app/src/lib/mock.ts b/example/app/src/lib/mock.ts index 887f4874..f50d2497 100644 --- a/example/app/src/lib/mock.ts +++ b/example/app/src/lib/mock.ts @@ -17,7 +17,7 @@ const post = ( title: string, content: string, created: string, - authorId = 'ada' + authorId = 'ada', ) => new PostModel({ id: PostId.make(id), @@ -54,19 +54,19 @@ export const mockBackend = make({ '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' + '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' + '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' + '2024-05-01T08:15:00Z', ), ], }), diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 8fcfd057..98b1b630 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -31,9 +31,7 @@ export function App() { return ( <> {/* ... */} - + ); } diff --git a/packages/devtools/package.json b/packages/devtools/package.json index c11e33fd..a7bceeb9 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -33,7 +33,7 @@ "@effect-firebase/mock": "workspace:*", "effect": "catalog:", "effect-firebase": "workspace:*", - "react": "19.2.4" + "react": "19.2.8" }, "peerDependencies": { "@effect-firebase/mock": "workspace:*", diff --git a/packages/devtools/src/lib/panel.spec.tsx b/packages/devtools/src/lib/panel.spec.tsx index 544ee8d9..5cb7916c 100644 --- a/packages/devtools/src/lib/panel.spec.tsx +++ b/packages/devtools/src/lib/panel.spec.tsx @@ -21,7 +21,7 @@ const makeHandle = () => /** 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 + Effect.provide(Effect.void, handle.layer) as Effect.Effect, ); describe('MockDevtoolsPanel', () => { @@ -47,8 +47,8 @@ describe('MockDevtoolsPanel', () => { const postsRow = screen.getByText('posts').parentElement as HTMLElement; fireEvent.click( Array.from(postsRow.querySelectorAll('button')).find( - (button) => button.textContent === 'loading' - ) as HTMLElement + (button) => button.textContent === 'loading', + ) as HTMLElement, ); await waitFor(async () => { @@ -70,8 +70,8 @@ describe('MockDevtoolsPanel', () => { const postsRow = screen.getByText('posts').parentElement as HTMLElement; fireEvent.click( Array.from(postsRow.querySelectorAll('button')).find( - (button) => button.textContent === 'error' - ) as HTMLElement + (button) => button.textContent === 'error', + ) as HTMLElement, ); await waitFor(async () => { @@ -92,7 +92,7 @@ describe('MockDevtoolsPanel', () => { await screen.findByText('posts'); await Effect.runPromise( - handle.controller.setDoc('comments/1', { body: 'Hi' }) + handle.controller.setDoc('comments/1', { body: 'Hi' }), ); expect(await screen.findByText('comments')).toBeDefined(); @@ -109,15 +109,15 @@ describe('MockDevtoolsPanel', () => { onStateChange={(collection, state) => { 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 + (button) => button.textContent === 'empty', + ) as HTMLElement, ); expect(seen).toEqual([['posts', 'Empty']]); diff --git a/packages/devtools/src/lib/panel.tsx b/packages/devtools/src/lib/panel.tsx index 82620579..f62191bb 100644 --- a/packages/devtools/src/lib/panel.tsx +++ b/packages/devtools/src/lib/panel.tsx @@ -24,7 +24,7 @@ export interface MockDevtoolsPanelProps { */ readonly onStateChange?: ( collectionPath: string, - state: MockState.State + state: MockState.State, ) => void; } @@ -151,7 +151,7 @@ const styles = { const stateButtonStyle = ( name: StateName, active: boolean, - inherited: boolean + inherited: boolean, ): CSSProperties => ({ background: active ? palette[name] : 'transparent', color: active ? '#0b0d10' : palette[name], @@ -203,8 +203,8 @@ export function MockDevtoolsPanel({ Stream.runForEach(controller.changes, (current) => Effect.sync(() => { setSnapshot(current); - }) - ) + }), + ), ); void Effect.runPromise(controller.latency).then((latency) => { setLatencyMs(Duration.toMillis(latency)); @@ -231,7 +231,7 @@ export function MockDevtoolsPanel({ const prefix = `${collectionPath}/`; return Object.keys(snapshot?.docs ?? {}).filter( (path) => - path.startsWith(prefix) && !path.slice(prefix.length).includes('/') + path.startsWith(prefix) && !path.slice(prefix.length).includes('/'), ).length; }; @@ -252,9 +252,7 @@ export function MockDevtoolsPanel({ const stateRow = (key: string, explicitOnly: boolean) => { const states = snapshot?.states ?? {}; const explicit = states[key]; - const effective = explicitOnly - ? explicit - : MockState.resolve(states, key); + const effective = explicitOnly ? explicit : MockState.resolve(states, key); const inherited = explicit === undefined; return (
diff --git a/packages/devtools/src/lib/plugin.tsx b/packages/devtools/src/lib/plugin.tsx index 3d80c96f..07b22508 100644 --- a/packages/devtools/src/lib/plugin.tsx +++ b/packages/devtools/src/lib/plugin.tsx @@ -1,9 +1,6 @@ import type { ReactNode } from 'react'; import type { MockControllerShape } from '@effect-firebase/mock'; -import { - MockDevtoolsPanel, - type MockDevtoolsPanelProps, -} from './panel.js'; +import { MockDevtoolsPanel, type MockDevtoolsPanelProps } from './panel.js'; /** * The plugin shape accepted by `` from @@ -17,8 +14,10 @@ export interface TanStackDevtoolsReactPlugin { readonly defaultOpen?: boolean; } -export interface FirestoreMockPluginOptions - extends Omit { +export interface FirestoreMockPluginOptions extends Omit< + MockDevtoolsPanelProps, + 'controller' +> { /** * Plugin ID shown to TanStack Devtools. Defaults to `effect-firebase-mock`. */ @@ -52,7 +51,7 @@ export interface FirestoreMockPluginOptions */ export const firestoreMockPlugin = ( controller: MockControllerShape, - options: FirestoreMockPluginOptions = {} + options: FirestoreMockPluginOptions = {}, ): TanStackDevtoolsReactPlugin => ({ id: options.id ?? 'effect-firebase-mock', name: options.name ?? 'Firestore Mock', diff --git a/packages/mock/README.md b/packages/mock/README.md index 5d425980..bb2a14f6 100644 --- a/packages/mock/README.md +++ b/packages/mock/README.md @@ -35,7 +35,7 @@ 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())), ); ``` @@ -132,14 +132,14 @@ 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. +- `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] })) + Layer.provideMerge(layer({ fixtures: [posts, users] })), ); ``` @@ -153,8 +153,8 @@ await Effect.runPromise( }).pipe( Effect.provide(PostRepository), Effect.provide(layer()), - Effect.catchTag('NoSuchElementError', () => Effect.succeed('not found')) - ) + Effect.catchTag('NoSuchElementError', () => Effect.succeed('not found')), + ), ); ``` diff --git a/packages/mock/src/lib/firestore/controller.ts b/packages/mock/src/lib/firestore/controller.ts index 9b8ef287..f2225743 100644 --- a/packages/mock/src/lib/firestore/controller.ts +++ b/packages/mock/src/lib/firestore/controller.ts @@ -18,7 +18,7 @@ export interface MockControllerShape { */ readonly setState: ( collectionPath: string, - state: MockState.StateInput + state: MockState.StateInput, ) => Effect.Effect; /** @@ -62,9 +62,7 @@ export interface MockControllerShape { /** * Set the simulated latency applied to every operation. */ - readonly setLatency: ( - latency: Duration.Input - ) => Effect.Effect; + readonly setLatency: (latency: Duration.Input) => Effect.Effect; /** * The currently simulated latency. diff --git a/packages/mock/src/lib/firestore/fixture.ts b/packages/mock/src/lib/firestore/fixture.ts index e731f4ec..ee211292 100644 --- a/packages/mock/src/lib/firestore/fixture.ts +++ b/packages/mock/src/lib/firestore/fixture.ts @@ -36,30 +36,30 @@ export interface Fixture { */ export const fixture = < S extends Model.Any, - Id extends keyof S['Type'] & keyof S['fields'] + 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 result: Record = {}; for (const doc of options.docs) { const encoded = (yield* Schema.encodeEffect(model as Schema.Top)( - doc + 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` - ) + options.idField, + )}' field`, + ), ); } result[`${options.collectionPath}/${id}`] = data; @@ -81,7 +81,7 @@ export const fixture = < */ export const rawFixture = ( collectionPath: string, - docs: Readonly> + docs: Readonly>, ): Fixture => ({ collectionPath, build: Effect.sync(() => @@ -89,7 +89,7 @@ export const rawFixture = ( 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 index a239ad95..9eb1b5c4 100644 --- a/packages/mock/src/lib/firestore/layer.spec.ts +++ b/packages/mock/src/lib/firestore/layer.spec.ts @@ -1,12 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - DateTime, - Effect, - Fiber, - Option, - Schema, - Stream, -} from 'effect'; +import { DateTime, Effect, Fiber, Option, Schema, Stream } from 'effect'; import { Model } from 'effect/unstable/schema'; import { Firestore, @@ -51,10 +44,10 @@ const postFixture = fixture(Post, { const run = ( effect: Effect.Effect, - options?: Parameters[0] + options?: Parameters[0], ): Promise => Effect.runPromise( - effect.pipe(Effect.provide(layer(options))) as Effect.Effect + effect.pipe(Effect.provide(layer(options))) as Effect.Effect, ); /** @@ -69,8 +62,8 @@ const awaitLength = (collected: ReadonlyArray, length: number) => if (collected.length < length) { return yield* Effect.die( new Error( - `Timed out waiting for ${length} emissions (got ${collected.length})` - ) + `Timed out waiting for ${length} emissions (got ${collected.length})`, + ), ); } }); @@ -96,14 +89,12 @@ describe('layer', () => { yield* firestore.update(path, { views: 2 }); const updated = yield* firestore.get(path); - expect( - (updated as Option.Some).value[1]['views'] - ).toBe(2); + 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', () => @@ -117,7 +108,7 @@ describe('layer', () => { 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 () => { @@ -125,9 +116,9 @@ describe('layer', () => { Effect.gen(function* () { const firestore = yield* FirestoreService; return yield* Effect.flip( - firestore.update('posts/missing', { title: 'X' }) + firestore.update('posts/missing', { title: 'X' }), ); - }) + }), ); expect(error).toBeInstanceOf(FirestoreError); expect((error as FirestoreError).code).toBe('not-found'); @@ -142,9 +133,9 @@ describe('layer', () => { 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')) + Option.isNone(yield* firestore.get('posts/1/comments/1')), ).toBe(true); - }) + }), )); it('rejects invalid paths', async () => { @@ -152,7 +143,7 @@ describe('layer', () => { Effect.gen(function* () { const firestore = yield* FirestoreService; return yield* Effect.flip(firestore.get('posts')); - }) + }), ); expect((error as FirestoreError).code).toBe('invalid-argument'); }); @@ -169,7 +160,7 @@ describe('layer', () => { expect(data['createdAt']).toBeInstanceOf(FirestoreSchema.Timestamp); expect('id' in data).toBe(false); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); it('seeds raw fixtures', () => @@ -177,11 +168,9 @@ describe('layer', () => { Effect.gen(function* () { const firestore = yield* FirestoreService; const doc = yield* firestore.get('settings/general'); - expect( - (doc as Option.Some).value[1]['theme'] - ).toBe('dark'); + expect((doc as Option.Some).value[1]['theme']).toBe('dark'); }), - { fixtures: [rawFixture('settings', { general: { theme: 'dark' } })] } + { fixtures: [rawFixture('settings', { general: { theme: 'dark' } })] }, )); it('queries seeded fixtures with constraints', () => @@ -193,7 +182,7 @@ describe('layer', () => { ]); expect(results.map(([ref]) => ref.id)).toEqual(['2']); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); }); @@ -206,11 +195,11 @@ describe('layer', () => { yield* controller.setState('posts', 'error'); const read = yield* Effect.flip(firestore.get('posts/1')); const write = yield* Effect.flip( - firestore.add('posts', { title: 'X' }) + firestore.add('posts', { title: 'X' }), ); return [read, write] as const; }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, ); expect((readError as FirestoreError).code).toBe('unavailable'); expect((writeError as FirestoreError).code).toBe('unavailable'); @@ -223,10 +212,10 @@ describe('layer', () => { const controller = yield* MockController; yield* controller.setState( 'posts', - MockState.error('permission-denied') + MockState.error('permission-denied'), ); return yield* Effect.flip(firestore.get('posts/1')); - }) + }), ); expect((error as FirestoreError).code).toBe('permission-denied'); }); @@ -240,7 +229,7 @@ describe('layer', () => { expect(Option.isNone(yield* firestore.get('posts/1'))).toBe(true); expect(yield* firestore.query('posts', [])).toEqual([]); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); it('never resolves while a collection is loading', async () => { @@ -251,10 +240,10 @@ describe('layer', () => { yield* controller.setState('posts', 'loading'); return yield* Effect.timeoutOption( firestore.get('posts/1'), - '50 millis' + '50 millis', ); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, ); expect(Option.isNone(result)).toBe(true); }); @@ -266,7 +255,7 @@ describe('layer', () => { expect(yield* firestore.query('posts', [])).toEqual([]); expect(yield* firestore.query('authors', [])).toEqual([]); }), - { fixtures: [postFixture], states: { [MockState.All]: 'empty' } } + { fixtures: [postFixture], states: { [MockState.All]: 'empty' } }, )); }); @@ -282,8 +271,8 @@ describe('layer', () => { Stream.runForEach(firestore.streamQuery('posts', []), (snapshots) => Effect.sync(() => { emissions.push(snapshots); - }) - ) + }), + ), ); yield* awaitLength(emissions, 1); @@ -305,7 +294,7 @@ describe('layer', () => { yield* Fiber.interrupt(fiber); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); it('does not re-emit for unrelated collections', () => @@ -318,8 +307,8 @@ describe('layer', () => { Stream.runForEach(firestore.streamQuery('posts', []), (snapshots) => Effect.sync(() => { emissions.push(snapshots); - }) - ) + }), + ), ); yield* awaitLength(emissions, 1); @@ -329,7 +318,7 @@ describe('layer', () => { yield* Fiber.interrupt(fiber); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); it('fails live streams when a collection starts erroring', () => @@ -344,14 +333,14 @@ describe('layer', () => { Stream.runForEach(firestore.streamQuery('posts', []), (snapshots) => Effect.sync(() => { emissions.push(snapshots); - }) + }), ).pipe( Effect.catch((error) => Effect.sync(() => { failures.push(error); - }) - ) - ) + }), + ), + ), ); yield* awaitLength(emissions, 1); @@ -361,7 +350,7 @@ describe('layer', () => { yield* Fiber.interrupt(fiber); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); it('streams a single document', () => @@ -374,8 +363,8 @@ describe('layer', () => { Stream.runForEach(firestore.streamDoc('posts/1'), (doc) => Effect.sync(() => { emissions.push(doc); - }) - ) + }), + ), ); yield* awaitLength(emissions, 1); @@ -384,7 +373,7 @@ describe('layer', () => { yield* firestore.update('posts/1', { views: 99 }); yield* awaitLength(emissions, 2); expect( - (emissions[1] as Option.Some).value[1]['views'] + (emissions[1] as Option.Some).value[1]['views'], ).toBe(99); yield* firestore.delete('posts/1'); @@ -393,7 +382,7 @@ describe('layer', () => { yield* Fiber.interrupt(fiber); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); }); @@ -404,12 +393,12 @@ describe('layer', () => { const firestore = yield* FirestoreService; const controller = yield* MockController; yield* controller.seed( - rawFixture('posts', { extra: { title: 'Extra', views: 0 } }) + rawFixture('posts', { extra: { title: 'Extra', views: 0 } }), ); const results = yield* firestore.query('posts', []); expect(results.length).toBe(3); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); it('resets to the initial fixtures and states', () => @@ -426,7 +415,7 @@ describe('layer', () => { expect(results.length).toBe(2); expect(yield* controller.states).toEqual({}); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); it('simulates latency', () => @@ -439,7 +428,7 @@ describe('layer', () => { yield* firestore.get('posts/1'); expect(Date.now() - start).toBeGreaterThanOrEqual(30); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); }); @@ -454,7 +443,7 @@ describe('layer', () => { Effect.gen(function* () { const firestore = yield* FirestoreService; return yield* firestore.query('posts', []); - }).pipe(Effect.provide(mock.layer)) + }).pipe(Effect.provide(mock.layer)), ); expect(emptied).toEqual([]); @@ -463,7 +452,7 @@ describe('layer', () => { Effect.gen(function* () { const firestore = yield* FirestoreService; return yield* firestore.query('posts', []); - }).pipe(Effect.provide(mock.layer)) + }).pipe(Effect.provide(mock.layer)), ); expect(restored.length).toBe(2); }); @@ -475,14 +464,14 @@ describe('layer', () => { Effect.gen(function* () { const firestore = yield* FirestoreService; yield* firestore.set('posts/3', { title: 'Gamma', views: 0 }); - }).pipe(Effect.provide(mock.layer)) + }).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)) + }).pipe(Effect.provide(mock.layer)), ); expect(count).toBe(3); }); @@ -513,7 +502,9 @@ describe('layer', () => { const fresh = yield* repo.getById(newId); expect(Option.isSome(fresh)).toBe(true); expect( - DateTime.toEpochMillis((fresh as Option.Some).value.createdAt) + DateTime.toEpochMillis( + (fresh as Option.Some).value.createdAt, + ), ).toBeGreaterThan(0); const popular = yield* repo.query([ @@ -522,7 +513,7 @@ describe('layer', () => { ]); expect(popular.map((p) => p.title)).toEqual(['Beta', 'Alpha']); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); it('streams decoded models through a repository', () => @@ -540,8 +531,8 @@ describe('layer', () => { Stream.runForEach(repo.queryStream([]), (posts) => Effect.sync(() => { emissions.push(posts); - }) - ) + }), + ), ); yield* awaitLength(emissions, 1); @@ -553,7 +544,7 @@ describe('layer', () => { yield* Fiber.interrupt(fiber); }), - { fixtures: [postFixture] } + { fixtures: [postFixture] }, )); }); }); diff --git a/packages/mock/src/lib/firestore/layer.ts b/packages/mock/src/lib/firestore/layer.ts index 42a3fdab..a0c79f08 100644 --- a/packages/mock/src/lib/firestore/layer.ts +++ b/packages/mock/src/lib/firestore/layer.ts @@ -83,12 +83,12 @@ const notFound = (path: string): FirestoreError => const now: Effect.Effect = Effect.map( Clock.currentTimeMillis, - (millis) => FirestoreSchema.Timestamp.fromMillis(millis) + (millis) => FirestoreSchema.Timestamp.fromMillis(millis), ); const optionSnapshotEquals = ( a: Option.Option, - b: Option.Option + b: Option.Option, ): boolean => Option.isNone(a) || Option.isNone(b) ? Option.isNone(a) === Option.isNone(b) @@ -99,22 +99,22 @@ const snapshotEquals = (a: Snapshot, b: Snapshot): boolean => const snapshotsEqual = ( a: ReadonlyArray, - b: ReadonlyArray + b: ReadonlyArray, ): boolean => a.length === b.length && a.every((snapshot, index) => snapshotEquals(snapshot, b[index])); const makeFirestore = ( ref: SubscriptionRef.SubscriptionRef, - latency: Ref.Ref + latency: Ref.Ref, ): FirestoreServiceShape => { const sleep = Effect.flatMap(Ref.get(latency), (duration) => - Duration.toMillis(duration) > 0 ? Effect.sleep(duration) : Effect.void + Duration.toMillis(duration) > 0 ? Effect.sleep(duration) : Effect.void, ); const stateFor = (collectionPath: string) => Effect.map(SubscriptionRef.get(ref), (snapshot) => - MockState.resolve(snapshot.states, collectionPath) + MockState.resolve(snapshot.states, collectionPath), ); /** @@ -137,7 +137,7 @@ const makeFirestore = ( message === undefined ? Effect.void : Effect.fail(invalidArgument(message)); const readDoc = ( - path: string + path: string, ): Effect.Effect, FirestoreError> => Effect.gen(function* () { yield* validate(validateDocPath(path)); @@ -157,8 +157,8 @@ const makeFirestore = ( collectionPath: string, mutate: ( docs: Readonly>, - timestamp: FirestoreSchema.Timestamp - ) => Effect.Effect>, FirestoreError> + timestamp: FirestoreSchema.Timestamp, + ) => Effect.Effect>, FirestoreError>, ): Effect.Effect => Effect.gen(function* () { yield* sleep; @@ -169,7 +169,7 @@ const makeFirestore = ( Effect.map(mutate(snapshot.docs, timestamp), (docs) => ({ ...snapshot, docs, - })) + })), ); }); @@ -186,7 +186,7 @@ const makeFirestore = ( } const docPath = `${path}/${id}`; yield* write(path, (docs, timestamp) => - Effect.succeed({ ...docs, [docPath]: applySet(data, timestamp) }) + Effect.succeed({ ...docs, [docPath]: applySet(data, timestamp) }), ); return { id, path: docPath }; }), @@ -200,7 +200,7 @@ const makeFirestore = ( [path]: options?.merge ? applyMerge(docs[path], data, timestamp) : applySet(data, timestamp), - }) + }), ); }), @@ -237,11 +237,10 @@ const makeFirestore = ( Effect.succeed( Object.fromEntries( Object.entries(docs).filter( - ([docPath]) => - docPath !== path && !docPath.startsWith(prefix) - ) - ) - ) + ([docPath]) => docPath !== path && !docPath.startsWith(prefix), + ), + ), + ), ); }), @@ -256,7 +255,7 @@ const makeFirestore = ( const snapshot = yield* SubscriptionRef.get(ref); return applyConstraints( docsInCollection(snapshot.docs, collectionPath), - constraints + constraints, ); }), @@ -272,9 +271,12 @@ const makeFirestore = ( SubscriptionRef.changes(ref).pipe( Stream.switchMap( ( - snapshot + snapshot, ): Stream.Stream, FirestoreError> => { - const state = MockState.resolve(snapshot.states, collectionPath); + const state = MockState.resolve( + snapshot.states, + collectionPath, + ); switch (state._tag) { case 'Loading': return Stream.never; @@ -287,15 +289,15 @@ const makeFirestore = ( return Stream.succeed( data === undefined ? Option.none() - : Option.some(makeSnapshot(path, data)) + : Option.some(makeSnapshot(path, data)), ); } } - } + }, ), - Stream.changesWith(optionSnapshotEquals) - ) - ) + Stream.changesWith(optionSnapshotEquals), + ), + ), ); }, @@ -310,9 +312,12 @@ const makeFirestore = ( SubscriptionRef.changes(ref).pipe( Stream.switchMap( ( - snapshot + snapshot, ): Stream.Stream, FirestoreError> => { - const state = MockState.resolve(snapshot.states, collectionPath); + const state = MockState.resolve( + snapshot.states, + collectionPath, + ); switch (state._tag) { case 'Loading': return Stream.never; @@ -324,15 +329,15 @@ const makeFirestore = ( return Stream.succeed( applyConstraints( docsInCollection(snapshot.docs, collectionPath), - constraints - ) + constraints, + ), ); } - } + }, ), - Stream.changesWith(snapshotsEqual) - ) - ) + Stream.changesWith(snapshotsEqual), + ), + ), ); }, @@ -348,7 +353,7 @@ const makeFirestore = ( const makeController = ( ref: SubscriptionRef.SubscriptionRef, latency: Ref.Ref, - initial: { ref: Ref.Ref; latency: Duration.Duration } + initial: { ref: Ref.Ref; latency: Duration.Duration }, ): MockControllerShape => ({ setState: (collectionPath, state) => SubscriptionRef.update(ref, (snapshot) => ({ @@ -377,7 +382,7 @@ const makeController = ( SubscriptionRef.update(ref, (snapshot) => ({ ...snapshot, docs: { ...snapshot.docs, ...docs }, - })) + })), ), setDoc: (path, data) => @@ -446,7 +451,7 @@ export const make = (options: LayerOptions = {}): MockHandle => { Object.entries(options.states ?? {}).map(([key, input]) => [ key, MockState.fromInput(input), - ]) + ]), ); const initialLatency = Duration.fromInputUnsafe(options.latency ?? 0); const emptySnapshot: StoreSnapshot = { docs: {}, states: initialStates }; @@ -484,9 +489,9 @@ export const make = (options: LayerOptions = {}): MockHandle => { layer: Layer.effectContext( Effect.map(seedOnce, () => Context.make(FirestoreService, makeFirestore(ref, latency)).pipe( - Context.add(MockController, controller) - ) - ) + Context.add(MockController, controller), + ), + ), ), }; }; @@ -509,6 +514,6 @@ export const make = (options: LayerOptions = {}): MockHandle => { * ``` */ export const layer = ( - options: LayerOptions = {} + 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 index 107b19ff..8cea9723 100644 --- a/packages/mock/src/lib/firestore/query-filter.spec.ts +++ b/packages/mock/src/lib/firestore/query-filter.spec.ts @@ -9,12 +9,18 @@ const snap = (id: string, data: Record): Snapshot => [ 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('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); +const ids = (results: ReadonlyArray) => + results.map(([ref]) => ref.id); describe('applyConstraints', () => { it('returns everything ordered by document ID without constraints', () => { @@ -26,15 +32,15 @@ describe('applyConstraints', () => { 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']); }); @@ -44,8 +50,8 @@ describe('applyConstraints', () => { applyConstraints(posts, [ new Query.Where({ field: 'views', op: '>', value: 15 }), new Query.Where({ field: 'views', op: '<=', value: 30 }), - ]) - ) + ]), + ), ).toEqual(['2', '3']); }); @@ -54,8 +60,8 @@ describe('applyConstraints', () => { ids( applyConstraints(posts, [ new Query.Where({ field: 'title', op: '>', value: 5 }), - ]) - ) + ]), + ), ).toEqual([]); }); @@ -68,8 +74,8 @@ describe('applyConstraints', () => { op: 'in', value: ['draft', 'archived'], }), - ]) - ) + ]), + ), ).toEqual(['1', '4']); expect( ids( @@ -79,8 +85,8 @@ describe('applyConstraints', () => { op: 'not-in', value: ['draft', 'archived'], }), - ]) - ) + ]), + ), ).toEqual(['2', '3']); }); @@ -88,9 +94,13 @@ describe('applyConstraints', () => { expect( ids( applyConstraints(posts, [ - new Query.Where({ field: 'tags', op: 'array-contains', value: 'tech' }), - ]) - ) + new Query.Where({ + field: 'tags', + op: 'array-contains', + value: 'tech', + }), + ]), + ), ).toEqual(['2', '3']); expect( ids( @@ -100,8 +110,8 @@ describe('applyConstraints', () => { op: 'array-contains-any', value: ['news', 'tech'], }), - ]) - ) + ]), + ), ).toEqual(['1', '2', '3']); }); @@ -115,8 +125,8 @@ describe('applyConstraints', () => { new Query.Where({ field: 'views', op: '>=', value: 40 }), ], }), - ]) - ) + ]), + ), ).toEqual(['1', '4']); }); @@ -125,30 +135,30 @@ describe('applyConstraints', () => { 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('applies limit and limitToLast', () => { const ordered = [new Query.OrderBy({ field: 'views', direction: 'asc' })]; expect( - ids(applyConstraints(posts, [...ordered, new Query.Limit({ count: 2 })])) + 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']); }); @@ -159,8 +169,8 @@ describe('applyConstraints', () => { applyConstraints(posts, [ ...ordered, new Query.StartAfter({ values: [20] }), - ]) - ) + ]), + ), ).toEqual(['2', '4']); expect( ids( @@ -168,8 +178,8 @@ describe('applyConstraints', () => { ...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 index 78991480..67f80dd0 100644 --- a/packages/mock/src/lib/firestore/query-filter.ts +++ b/packages/mock/src/lib/firestore/query-filter.ts @@ -1,5 +1,11 @@ import { Query, Snapshot, type QueryConstraint } from 'effect-firebase'; -import { compare, equals, fieldValue, sameType, type DocData } from './value.js'; +import { + compare, + equals, + fieldValue, + sameType, + type DocData, +} from './value.js'; type Filter = Query.Where | Query.And | Query.Or; @@ -49,8 +55,7 @@ const matchesWhere = (data: DocData, where: Query.Where): boolean => { ); case 'array-contains': return ( - Array.isArray(value) && - value.some((item) => equals(item, where.value)) + Array.isArray(value) && value.some((item) => equals(item, where.value)) ); case 'array-contains-any': return ( @@ -58,8 +63,8 @@ const matchesWhere = (data: DocData, where: Query.Where): boolean => { Array.isArray(where.value) && value.some((item) => (where.value as ReadonlyArray).some((candidate) => - equals(item, candidate) - ) + equals(item, candidate), + ), ) ); } @@ -83,7 +88,7 @@ const matchesFilter = (data: DocData, filter: Filter): boolean => { const orderValues = ( snapshot: Snapshot, - orderBys: ReadonlyArray + orderBys: ReadonlyArray, ): ReadonlyArray => { const [ref, data] = snapshot; const values = orderBys.map((orderBy) => fieldValue(data, orderBy.field)); @@ -92,7 +97,7 @@ const orderValues = ( }; const compareSnapshots = ( - orderBys: ReadonlyArray + orderBys: ReadonlyArray, ): ((a: Snapshot, b: Snapshot) => number) => { const directions = [...orderBys.map((o) => o.direction), 'asc' as const]; return (a, b) => { @@ -111,7 +116,7 @@ const compareSnapshots = ( const compareCursor = ( snapshot: Snapshot, cursor: ReadonlyArray, - orderBys: ReadonlyArray + orderBys: ReadonlyArray, ): number => { const values = orderValues(snapshot, orderBys); for (let i = 0; i < Math.min(cursor.length, values.length); i++) { @@ -130,7 +135,7 @@ const compareCursor = ( */ export const applyConstraints = ( snapshots: ReadonlyArray, - constraints: ReadonlyArray + constraints: ReadonlyArray, ): ReadonlyArray => { const filters: Array = []; const orderBys: Array = []; @@ -173,7 +178,7 @@ export const applyConstraints = ( } let results = snapshots.filter(([, data]) => - filters.every((filter) => matchesFilter(data, filter)) + filters.every((filter) => matchesFilter(data, filter)), ); results = [...results].sort(compareSnapshots(orderBys)); @@ -181,25 +186,25 @@ export const applyConstraints = ( if (startAt !== undefined) { const cursor = startAt; results = results.filter( - (snapshot) => compareCursor(snapshot, cursor, orderBys) >= 0 + (snapshot) => compareCursor(snapshot, cursor, orderBys) >= 0, ); } if (startAfter !== undefined) { const cursor = startAfter; results = results.filter( - (snapshot) => compareCursor(snapshot, cursor, orderBys) > 0 + (snapshot) => compareCursor(snapshot, cursor, orderBys) > 0, ); } if (endAt !== undefined) { const cursor = endAt; results = results.filter( - (snapshot) => compareCursor(snapshot, cursor, orderBys) <= 0 + (snapshot) => compareCursor(snapshot, cursor, orderBys) <= 0, ); } if (endBefore !== undefined) { const cursor = endBefore; results = results.filter( - (snapshot) => compareCursor(snapshot, cursor, orderBys) < 0 + (snapshot) => compareCursor(snapshot, cursor, orderBys) < 0, ); } diff --git a/packages/mock/src/lib/firestore/state.ts b/packages/mock/src/lib/firestore/state.ts index 8e6eb29a..7dcee080 100644 --- a/packages/mock/src/lib/firestore/state.ts +++ b/packages/mock/src/lib/firestore/state.ts @@ -82,5 +82,5 @@ export const All = '*'; */ export const resolve = ( states: Readonly>, - collectionPath: string + 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 index 1afc7b2e..7f60fc76 100644 --- a/packages/mock/src/lib/firestore/store.ts +++ b/packages/mock/src/lib/firestore/store.ts @@ -41,12 +41,13 @@ export const makeSnapshot = (path: string, data: DocData): Snapshot => [ */ export const docsInCollection = ( docs: Readonly>, - collectionPath: string + collectionPath: string, ): ReadonlyArray => { const prefix = `${collectionPath}/`; return Object.entries(docs) .filter( - ([path]) => path.startsWith(prefix) && !path.slice(prefix.length).includes('/') + ([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)); @@ -64,8 +65,7 @@ const isDocPath = (path: string): boolean => { const isCollectionPath = (path: string): boolean => { const segments = path.split('/'); return ( - segments.length % 2 === 1 && - segments.every((segment) => segment.length > 0) + segments.length % 2 === 1 && segments.every((segment) => segment.length > 0) ); }; diff --git a/packages/mock/src/lib/firestore/value.spec.ts b/packages/mock/src/lib/firestore/value.spec.ts index 3ec44f52..bb1c51c2 100644 --- a/packages/mock/src/lib/firestore/value.spec.ts +++ b/packages/mock/src/lib/firestore/value.spec.ts @@ -35,11 +35,11 @@ describe('compare', () => { expect(compare(null, true)).toBeLessThan(0); expect(compare(true, 1)).toBeLessThan(0); expect(compare(999, FirestoreSchema.Timestamp.fromMillis(0))).toBeLessThan( - 0 + 0, + ); + expect(compare(FirestoreSchema.Timestamp.fromMillis(0), 'a')).toBeLessThan( + 0, ); - expect( - compare(FirestoreSchema.Timestamp.fromMillis(0), 'a') - ).toBeLessThan(0); }); it('orders arrays elementwise, then by length', () => { @@ -54,8 +54,8 @@ describe('equals', () => { expect( equals( { a: [1, { b: 'x' }], t: FirestoreSchema.Timestamp.fromMillis(5) }, - { 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); }); @@ -73,7 +73,7 @@ describe('applySet', () => { it('materializes server timestamps', () => { const result = applySet( { createdAt: new FirestoreSchema.ServerTimestamp(), title: 'Hi' }, - now + now, ); expect(result['createdAt']).toBe(now); expect(result['title']).toBe('Hi'); @@ -91,7 +91,7 @@ describe('applyMerge', () => { const result = applyMerge( { nested: { a: 1, b: 2 }, top: 'x' }, { nested: { b: 3 } }, - now + now, ); expect(result).toEqual({ nested: { a: 1, b: 3 }, top: 'x' }); }); @@ -107,7 +107,7 @@ describe('applyUpdate', () => { const result = applyUpdate( { nested: { a: 1 }, top: 'x' }, { 'nested.b': 2 }, - now + now, ); expect(result).toEqual({ nested: { a: 1, b: 2 }, top: 'x' }); }); @@ -116,7 +116,7 @@ describe('applyUpdate', () => { const result = applyUpdate( { tags: ['a', 'b'] }, { tags: Firestore.arrayUnion(['b', 'c']) }, - now + now, ); expect(result['tags']).toEqual(['a', 'b', 'c']); }); @@ -125,7 +125,7 @@ describe('applyUpdate', () => { const result = applyUpdate( { tags: ['a', 'b', 'c'] }, { tags: Firestore.arrayRemove(['b']) }, - now + now, ); expect(result['tags']).toEqual(['a', 'c']); }); @@ -134,7 +134,7 @@ describe('applyUpdate', () => { const result = applyUpdate( { title: 'Hi' }, { updatedAt: new FirestoreSchema.ServerTimestamp() }, - now + now, ); expect(result['updatedAt']).toBe(now); }); diff --git a/packages/mock/src/lib/firestore/value.ts b/packages/mock/src/lib/firestore/value.ts index a48f1736..e1d88dee 100644 --- a/packages/mock/src/lib/firestore/value.ts +++ b/packages/mock/src/lib/firestore/value.ts @@ -143,7 +143,10 @@ export const fieldValue = (data: DocData, fieldPath: string): unknown => { * Recursively materialize sentinel values for storage: * `ServerTimestamp` becomes `now`, array sentinels collapse to plain arrays. */ -const materialize = (value: unknown, now: FirestoreSchema.Timestamp): unknown => { +const materialize = ( + value: unknown, + now: FirestoreSchema.Timestamp, +): unknown => { if (value instanceof FirestoreSchema.ServerTimestamp) { return now; } @@ -182,13 +185,13 @@ const dedupe = (values: ReadonlyArray): Array => { const applyField = ( existing: unknown, incoming: unknown, - now: FirestoreSchema.Timestamp + 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)) + incoming.values.map((item) => materialize(item, now)), ); return [...base, ...additions]; } @@ -196,7 +199,7 @@ const applyField = ( 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)) + (item) => !removals.some((removal) => equals(removal, item)), ); } return materialize(incoming, now); @@ -204,7 +207,7 @@ const applyField = ( const missingFrom = ( base: ReadonlyArray, - additions: ReadonlyArray + additions: ReadonlyArray, ): Array => { const result: Array = []; for (const addition of additions) { @@ -223,7 +226,7 @@ const missingFrom = ( */ export const applySet = ( incoming: DocData, - now: FirestoreSchema.Timestamp + now: FirestoreSchema.Timestamp, ): DocData => { const result: DocData = {}; for (const [key, value] of Object.entries(incoming)) { @@ -238,7 +241,7 @@ export const applySet = ( const mergeRecords = ( existing: Record, incoming: Record, - now: FirestoreSchema.Timestamp + now: FirestoreSchema.Timestamp, ): Record => { const result: Record = { ...existing }; for (const [key, value] of Object.entries(incoming)) { @@ -262,14 +265,14 @@ const mergeRecords = ( export const applyMerge = ( existing: DocData | undefined, incoming: DocData, - now: FirestoreSchema.Timestamp + now: FirestoreSchema.Timestamp, ): DocData => mergeRecords(existing ?? {}, incoming, now); const setAtPath = ( data: Record, segments: ReadonlyArray, value: unknown, - now: FirestoreSchema.Timestamp + now: FirestoreSchema.Timestamp, ): Record => { const [head, ...rest] = segments; const result = { ...data }; @@ -292,7 +295,7 @@ const setAtPath = ( export const applyUpdate = ( existing: DocData, incoming: DocData, - now: FirestoreSchema.Timestamp + now: FirestoreSchema.Timestamp, ): DocData => { let result: Record = { ...existing }; for (const [key, value] of Object.entries(incoming)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 177d5765..1797e1d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,8 +378,8 @@ importers: specifier: workspace:* version: link:../effect-firebase react: - specifier: 19.2.4 - version: 19.2.4 + specifier: 19.2.8 + version: 19.2.8 packages/effect-firebase: dependencies: @@ -7896,10 +7896,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -18249,8 +18245,6 @@ snapshots: react-is@17.0.2: {} - react@19.2.4: {} - react@19.2.8: {} read-package-up@11.0.0: From f3535b79a192e3aa2f9660462ec9c6b1011ecfb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 09:40:15 +0000 Subject: [PATCH 05/13] =?UTF-8?q?feat(mock):=20generatedFixture=20?= =?UTF-8?q?=E2=80=94=20schema-derived=20documents=20via=20Schema.toArbitra?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate N schema-valid documents from a model using effect's bundled fast-check (effect/testing/FastCheck — no new dependency). Deterministic per seed so dev pages don't churn across reloads; document IDs are sequential rather than sampled to keep paths valid and collision-free. Complements hand-written fixture() docs: generated data satisfies the schema but reads as noise, so use it for volume (lists, pagination, layout stress) and hand-written docs for demo content. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- packages/mock/README.md | 13 +++ .../mock/src/lib/firestore/fixture.spec.ts | 94 +++++++++++++++++++ packages/mock/src/lib/firestore/fixture.ts | 66 +++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 packages/mock/src/lib/firestore/fixture.spec.ts diff --git a/packages/mock/README.md b/packages/mock/README.md index bb2a14f6..7eb4c7ca 100644 --- a/packages/mock/README.md +++ b/packages/mock/README.md @@ -76,6 +76,19 @@ const settings = rawFixture('settings', { }); ``` +To fill a page with volume (long lists, pagination, layout stress), `generatedFixture` derives documents from the model's schema via `Schema.toArbitrary` and fast-check — bundled with effect, no extra dependency. Generation is deterministic per seed, so data doesn't churn across reloads. Generated values satisfy the schema but read as noise; use hand-written `fixture` docs for demo-quality content — both compose in the same layer: + +```typescript +import { generatedFixture } from '@effect-firebase/mock'; + +const manyPosts = generatedFixture(PostModel, { + collectionPath: 'posts', + idField: 'id', + count: 50, + seed: 1, // optional, the default +}); +``` + ## Simulated states The layer also provides a `MockController` service for driving the backend at runtime — from tests, a dev panel, or a devtools plugin: 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 00000000..62964b58 --- /dev/null +++ b/packages/mock/src/lib/firestore/fixture.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { Effect, Option, Schema } from 'effect'; +import { Model } from 'effect/unstable/schema'; +import { Firestore, Query } from 'effect-firebase'; +import { generatedFixture } 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 = (fixture: ReturnType) => + Effect.runPromise(fixture.build as Effect.Effect>); + +describe('generatedFixture', () => { + it('generates the requested number of schema-valid documents', async () => { + const docs = await build( + generatedFixture(Post, { + collectionPath: 'posts', + idField: 'id', + count: 25, + }), + ); + const paths = Object.keys(docs); + expect(paths.length).toBe(25); + expect(paths[0]).toBe('posts/generated-0001'); + expect(paths.every((path) => /^posts\/generated-\d{4}$/.test(path))).toBe( + true, + ); + }); + + it('is deterministic for the same seed and diverges for another', async () => { + const options = { + collectionPath: 'posts', + idField: 'id', + count: 5, + } as const; + const a = await build(generatedFixture(Post, options)); + const b = await build(generatedFixture(Post, options)); + const c = await build(generatedFixture(Post, { ...options, seed: 2 })); + expect(a).toEqual(b); + expect(a).not.toEqual(c); + }); + + it('supports custom document IDs', async () => { + const docs = await build( + generatedFixture(Post, { + collectionPath: 'posts', + idField: 'id', + count: 2, + id: (index) => `custom-${index}`, + }), + ); + expect(Object.keys(docs)).toEqual(['posts/custom-0', 'posts/custom-1']); + }); + + 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 posts = yield* repo.query([ + new Query.OrderBy({ field: 'views', direction: 'asc' }), + ]); + expect(posts.length).toBe(10); + for (const post of posts) { + expect(typeof post.title).toBe('string'); + expect(typeof post.views).toBe('number'); + expect(Option.isOption(post.optional)).toBe(true); + } + }).pipe( + Effect.provide( + layer({ + fixtures: [ + generatedFixture(Post, { + collectionPath: 'posts', + idField: 'id', + count: 10, + }), + ], + }), + ), + ) as Effect.Effect, + )); +}); diff --git a/packages/mock/src/lib/firestore/fixture.ts b/packages/mock/src/lib/firestore/fixture.ts index ee211292..11addc47 100644 --- a/packages/mock/src/lib/firestore/fixture.ts +++ b/packages/mock/src/lib/firestore/fixture.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from 'effect'; +import * as FastCheck from 'effect/testing/FastCheck'; import { Model } from 'effect/unstable/schema'; import type { DocData } from './value.js'; @@ -68,6 +69,71 @@ export const fixture = < }) as Fixture['build'], }); +/** + * Create a fixture of documents generated from the model's schema via + * `Schema.toArbitrary` and fast-check (bundled with effect — no extra + * dependency). Useful for filling a page with volume — long lists, + * pagination, layout stress — without writing documents by hand. + * + * Generation is deterministic: the same model, `count` and `seed` produce + * the same documents on every run, so dev pages don't churn across reloads. + * Document IDs are sequential (`generated-0001`, ...) rather than sampled, + * keeping paths valid and collision-free; override with `id` if needed. + * + * Generated values satisfy the schema but read as noise (random strings, + * extreme dates). For demo-quality content, write docs with {@link fixture} + * — both are fixtures, so they compose in the same layer. + * + * @example + * ```ts + * const posts = generatedFixture(PostModel, { + * collectionPath: 'posts', + * idField: 'id', + * count: 50, + * }); + * ``` + */ +export const generatedFixture = < + S extends Model.Any, + Id extends keyof S['Type'] & keyof S['fields'], +>( + model: S, + options: { + readonly collectionPath: string; + readonly idField: Id; + /** Number of documents to generate. */ + readonly count: number; + /** Seed for deterministic generation. Defaults to `1`. */ + readonly seed?: number; + /** Custom document ID per index. Defaults to `generated-0001`, ... */ + readonly id?: (index: number) => string; + }, +): Fixture => ({ + collectionPath: options.collectionPath, + build: Effect.gen(function* () { + const arbitrary = Schema.toArbitrary(model as Schema.Top); + const samples = FastCheck.sample(arbitrary, { + numRuns: options.count, + seed: options.seed ?? 1, + }); + const digits = String(Math.max(options.count, 1)).length; + const result: Record = {}; + for (const [index, doc] of samples.entries()) { + const encoded = (yield* Schema.encodeEffect(model as Schema.Top)( + doc, + )) as Record; + // Sampled IDs can be empty or contain path separators; sequential IDs + // keep paths valid and sort stably by document ID. + const id = + options.id?.(index) ?? + `generated-${String(index + 1).padStart(Math.max(digits, 4), '0')}`; + const { [options.idField as string]: _ignored, ...data } = encoded; + result[`${options.collectionPath}/${id}`] = 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. From 1e6a1f11a096e502c350e3ce23d7a400ca1a9500 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 09:50:57 +0000 Subject: [PATCH 06/13] feat(schema): constrain generated timestamps to Firestore's valid range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema.toArbitrary on models previously produced DateTimes far outside what a Firestore Timestamp can store (years beyond 9999). The timestamp codecs now decode to a DateTimeUtc annotated with a toArbitrary hook that stays within 0001-01-01..9999-12-31, so generatedFixture and property tests produce storable dates. Also fixes Timestamp.fromMillis/fromDate for pre-1970 instants: the seconds/nanos split used a signed remainder, double-counting the fractional second for negative epoch millis. Nanoseconds are now always non-negative, matching Firestore, so the roundtrip holds. (Found by the new range test — the generated arbitraries caught it.) Mock README documents per-field tuning: built-in checks guide generation; toArbitrary annotations replace it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- .../src/lib/firestore/model/datetime.ts | 2 +- .../src/lib/firestore/schema/timestamp.ts | 33 +++++++++++---- packages/mock/README.md | 18 ++++++++ .../mock/src/lib/firestore/fixture.spec.ts | 42 +++++++++++++++++++ 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/packages/effect-firebase/src/lib/firestore/model/datetime.ts b/packages/effect-firebase/src/lib/firestore/model/datetime.ts index 3d5d0107..75b6fe5e 100644 --- a/packages/effect-firebase/src/lib/firestore/model/datetime.ts +++ b/packages/effect-firebase/src/lib/firestore/model/datetime.ts @@ -31,7 +31,7 @@ const ServerDateTimeSchema = Schema.Union([ FirestoreSchema.TimestampInstance, FirestoreSchema.ServerTimestampInstance, ]).pipe( - Schema.decodeTo(Schema.UndefinedOr(Schema.DateTimeUtc), { + Schema.decodeTo(Schema.UndefinedOr(FirestoreSchema.DateTimeUtcArbitrary), { decode: SchemaGetter.transformOrFail( (input: FirestoreSchema.Timestamp | FirestoreSchema.ServerTimestamp) => { if (input instanceof FirestoreSchema.Timestamp) { diff --git a/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts b/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts index 3f4b65df..e1297fae 100644 --- a/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts +++ b/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts @@ -1,5 +1,22 @@ import { DateTime, Effect, Schema, SchemaGetter, SchemaIssue } from 'effect'; +// Firestore Timestamps are only valid between 0001-01-01T00:00:00Z and +// 9999-12-31T23:59:59.999Z; derived arbitraries must stay in that range. +const FIRESTORE_MIN_MILLIS = -62135596800000; +const FIRESTORE_MAX_MILLIS = 253402300799999; + +/** + * `Schema.DateTimeUtc` restricted, for arbitrary generation, to the range a + * Firestore Timestamp can represent. Used as the decoded side of the + * timestamp codecs so `Schema.toArbitrary` on models produces storable dates. + */ +export const DateTimeUtcArbitrary = Schema.DateTimeUtc.annotate({ + toArbitrary: () => (fc) => + fc + .integer({ min: FIRESTORE_MIN_MILLIS, max: FIRESTORE_MAX_MILLIS }) + .map((millis) => DateTime.makeUnsafe(millis)), +}); + /** * Class representing a Timestamp in Firestore. */ @@ -8,16 +25,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, }); } @@ -54,7 +71,7 @@ export const TimestampInstance = Schema.instanceOf(Timestamp, { * Schema representing a timestamp as a DateTime.Utc. */ export const TimestampDateTimeUtc = TimestampInstance.pipe( - Schema.decodeTo(Schema.DateTimeUtc, { + Schema.decodeTo(DateTimeUtcArbitrary, { decode: SchemaGetter.transform((ts: Timestamp) => DateTime.makeUnsafe(ts.toMillis()), ), @@ -83,7 +100,7 @@ export const AnyTimestampDateTimeUtc = Schema.Union([ TimestampInstance, ServerTimestampInstance, ]).pipe( - Schema.decodeTo(Schema.DateTimeUtc, { + Schema.decodeTo(DateTimeUtcArbitrary, { decode: SchemaGetter.transformOrFail( (input: Timestamp | ServerTimestamp) => { if (input instanceof Timestamp) { diff --git a/packages/mock/README.md b/packages/mock/README.md index 7eb4c7ca..ef70cb31 100644 --- a/packages/mock/README.md +++ b/packages/mock/README.md @@ -89,6 +89,24 @@ const manyPosts = generatedFixture(PostModel, { }); ``` +Generation is tunable per field on the schema itself. Built-in checks guide it automatically (`Schema.isBetween` keeps numbers in range, `Schema.isMinLength` bounds strings), and a `toArbitrary` annotation replaces the generator entirely: + +```typescript +class PostModel extends Model.Class('PostModel')({ + // ... + title: Schema.String.annotate({ + toArbitrary: () => (fc) => + fc.constantFrom('Getting started', 'Release notes', 'Roadmap'), + }), + views: Schema.Number.check( + Schema.isInt(), + Schema.isBetween({ minimum: 0, maximum: 5000 }), + ), +}) {} +``` + +The Firestore date/time fields (`Firestore.DateTimeInsert`, ...) are pre-annotated to generate instants within the range a Firestore `Timestamp` can actually store (years 1–9999). + ## Simulated states The layer also provides a `MockController` service for driving the backend at runtime — from tests, a dev panel, or a devtools plugin: diff --git a/packages/mock/src/lib/firestore/fixture.spec.ts b/packages/mock/src/lib/firestore/fixture.spec.ts index 62964b58..5c91e67f 100644 --- a/packages/mock/src/lib/firestore/fixture.spec.ts +++ b/packages/mock/src/lib/firestore/fixture.spec.ts @@ -48,6 +48,48 @@ describe('generatedFixture', () => { expect(a).not.toEqual(c); }); + it('generates dates within the range a Firestore Timestamp can store', async () => { + const docs = await build( + generatedFixture(Post, { + collectionPath: 'posts', + idField: 'id', + count: 50, + }), + ); + const min = Date.parse('0001-01-01T00:00:00Z'); + const max = Date.parse('9999-12-31T23:59:59.999Z'); + for (const data of Object.values(docs)) { + const createdAt = (data as Record)[ + 'createdAt' + ]; + const millis = createdAt.toMillis(); + expect(millis).toBeGreaterThanOrEqual(min); + expect(millis).toBeLessThanOrEqual(max); + } + }); + + it('honors toArbitrary annotations on model fields', async () => { + const titles = ['Getting started', 'Release notes', 'Roadmap']; + + class Curated extends Model.Class('Curated')({ + id: Model.GeneratedByDb(PostId), + title: Schema.String.annotate({ + toArbitrary: () => (fc) => fc.constantFrom(...titles), + }), + }) {} + + const docs = await build( + generatedFixture(Curated, { + collectionPath: 'posts', + idField: 'id', + count: 10, + }), + ); + for (const data of Object.values(docs)) { + expect(titles).toContain((data as Record)['title']); + } + }); + it('supports custom document IDs', async () => { const docs = await build( generatedFixture(Post, { From 7e670773dcdceebca3d6cebc8ec809a6a0207d5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 12:07:28 +0000 Subject: [PATCH 07/13] fix: address review findings on recovery callbacks and orderBy semantics - devtools panel: clear and reset now invoke onStateChange (with the wildcard key and data state) so consumers terminally failed on a simulated error get refreshed; all notifications now fire only after the controller effect has applied, so refreshes re-subscribe against the new state instead of racing it - mock query evaluation: documents missing a field named by an orderBy constraint are excluded from results, matching Firestore Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- packages/devtools/src/lib/panel.spec.tsx | 37 ++++++++++++++- packages/devtools/src/lib/panel.tsx | 45 ++++++++++++++++--- .../src/lib/firestore/query-filter.spec.ts | 11 +++++ .../mock/src/lib/firestore/query-filter.ts | 9 +++- 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/packages/devtools/src/lib/panel.spec.tsx b/packages/devtools/src/lib/panel.spec.tsx index 5cb7916c..6dcc477a 100644 --- a/packages/devtools/src/lib/panel.spec.tsx +++ b/packages/devtools/src/lib/panel.spec.tsx @@ -120,7 +120,42 @@ describe('MockDevtoolsPanel', () => { ) as HTMLElement, ); - expect(seen).toEqual([['posts', 'Empty']]); + // 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'], + ]); + }); }); }); diff --git a/packages/devtools/src/lib/panel.tsx b/packages/devtools/src/lib/panel.tsx index f62191bb..c9a2d208 100644 --- a/packages/devtools/src/lib/panel.tsx +++ b/packages/devtools/src/lib/panel.tsx @@ -18,9 +18,11 @@ export interface MockDevtoolsPanelProps { */ readonly collections?: ReadonlyArray; /** - * Called after a state toggle has been applied. Use this to re-subscribe + * 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. + * or queries reading from the collection. Clearing the wildcard state and + * resetting the backend notify with the wildcard key (`'*'`) and the + * `data` state. */ readonly onStateChange?: ( collectionPath: string, @@ -238,10 +240,41 @@ export function MockDevtoolsPanel({ 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 notifyAfter = ( + effect: Effect.Effect, + collectionPath: string, + state: MockState.State, + ): void => { + void Effect.runPromise(effect).then(() => { + onStateChange?.(collectionPath, state); + }); + }; + const setState = (collectionPath: string, name: StateName): void => { const state = MockState.fromInput(toInput(name)); - runEffect(controller.setState(collectionPath, state)); - onStateChange?.(collectionPath, state); + notifyAfter( + controller.setState(collectionPath, state), + collectionPath, + state, + ); + }; + + // Clearing the wildcard and resetting both recover erroring collections, + // so they notify with the wildcard key for consumers to refresh broadly. + const clearAll = (): void => { + notifyAfter( + controller.clearState(MockState.All), + MockState.All, + MockState.data, + ); + }; + + const reset = (): void => { + notifyAfter(controller.reset, MockState.All, MockState.data); }; const applyLatency = (value: number): void => { @@ -288,7 +321,7 @@ export function MockDevtoolsPanel({ type="button" style={actionButtonStyle} title="Remove the wildcard state" - onClick={() => runEffect(controller.clearState(MockState.All))} + onClick={clearAll} > clear @@ -321,7 +354,7 @@ export function MockDevtoolsPanel({ type="button" style={actionButtonStyle} title="Restore initial fixtures and clear all states" - onClick={() => runEffect(controller.reset)} + onClick={reset} > reset diff --git a/packages/mock/src/lib/firestore/query-filter.spec.ts b/packages/mock/src/lib/firestore/query-filter.spec.ts index 8cea9723..1b622963 100644 --- a/packages/mock/src/lib/firestore/query-filter.spec.ts +++ b/packages/mock/src/lib/firestore/query-filter.spec.ts @@ -147,6 +147,17 @@ describe('applyConstraints', () => { ).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( diff --git a/packages/mock/src/lib/firestore/query-filter.ts b/packages/mock/src/lib/firestore/query-filter.ts index 67f80dd0..1f1c7d81 100644 --- a/packages/mock/src/lib/firestore/query-filter.ts +++ b/packages/mock/src/lib/firestore/query-filter.ts @@ -177,8 +177,13 @@ export const applyConstraints = ( } } - let results = snapshots.filter(([, data]) => - filters.every((filter) => matchesFilter(data, filter)), + // 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)); From 26829ef645e004c83c2eb75362d39c33108ae2fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 12:52:20 +0000 Subject: [PATCH 08/13] fix: address CodeRabbit review findings Bugs: - generateId: Random.nextIntBetween includes the upper bound by default in effect v4, so ids could contain the string 'undefined'; pass halfOpen so indexes stay within the alphabet - add: collision-check ids against the docs the write actually sees instead of a pre-read snapshot, so a concurrent add can't be replaced - make: seed fixtures through Effect.cached so concurrent providers await the same seeding run instead of racing past a boolean ref - compare: undefined gets its own rank and opaque values (sentinels) are equal only on identity, so arrayUnion dedup can't absorb unrelated members - query evaluation: limitToLast takes precedence over limit (Firestore rejects the combination; sequential slicing produced impossible sets) - panel: clamp latency input to non-negative finite values - fixtures: fail on duplicate document ids, validate count, document the zero-based id callback Docs: layer() memoization caveat, encoding-services limitation on fixture entry points, runnable devtools README example, REACT.md runtime setup aligned with the useState-initializer pattern. Tests: And filters, limit+limitToLast precedence, pre-1970 timestamp ordering/roundtrip, opaque-value equality, clearState wildcard fallback, setDoc/removeDoc, generated-id decoding, count validation, duplicate-id rejection. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- REACT.md | 18 +++-- packages/devtools/README.md | 10 ++- packages/devtools/src/lib/panel.tsx | 6 +- packages/mock/src/lib/firestore/controller.ts | 3 +- .../mock/src/lib/firestore/fixture.spec.ts | 37 ++++++++++ packages/mock/src/lib/firestore/fixture.ts | 37 ++++++++-- packages/mock/src/lib/firestore/layer.spec.ts | 26 +++++++ packages/mock/src/lib/firestore/layer.ts | 72 +++++++++++-------- .../src/lib/firestore/query-filter.spec.ts | 32 +++++++++ .../mock/src/lib/firestore/query-filter.ts | 7 +- packages/mock/src/lib/firestore/value.spec.ts | 16 +++++ packages/mock/src/lib/firestore/value.ts | 8 ++- 12 files changed, 220 insertions(+), 52 deletions(-) diff --git a/REACT.md b/REACT.md index 518fa2c0..1c4b220b 100644 --- a/REACT.md +++ b/REACT.md @@ -70,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)` diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 98b1b630..97af2ece 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -19,8 +19,16 @@ 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, authors], + fixtures: [posts], }); // Provide mock.layer wherever your app builds its Effect runtime. diff --git a/packages/devtools/src/lib/panel.tsx b/packages/devtools/src/lib/panel.tsx index c9a2d208..41a4abc5 100644 --- a/packages/devtools/src/lib/panel.tsx +++ b/packages/devtools/src/lib/panel.tsx @@ -278,8 +278,10 @@ export function MockDevtoolsPanel({ }; const applyLatency = (value: number): void => { - setLatencyMs(value); - runEffect(controller.setLatency(`${value} millis`)); + // 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); + runEffect(controller.setLatency(`${latency} millis`)); }; const stateRow = (key: string, explicitOnly: boolean) => { diff --git a/packages/mock/src/lib/firestore/controller.ts b/packages/mock/src/lib/firestore/controller.ts index f2225743..887bfd73 100644 --- a/packages/mock/src/lib/firestore/controller.ts +++ b/packages/mock/src/lib/firestore/controller.ts @@ -45,7 +45,8 @@ export interface MockControllerShape { /** * Seed additional documents from a fixture. Existing documents at the same - * paths are replaced; live streams re-emit. + * paths are replaced; live streams re-emit. Only fixtures whose models + * require no encoding services are supported (`Fixture`). */ readonly seed: (fixture: Fixture) => Effect.Effect; diff --git a/packages/mock/src/lib/firestore/fixture.spec.ts b/packages/mock/src/lib/firestore/fixture.spec.ts index 5c91e67f..ee8ddcb4 100644 --- a/packages/mock/src/lib/firestore/fixture.spec.ts +++ b/packages/mock/src/lib/firestore/fixture.spec.ts @@ -102,6 +102,40 @@ describe('generatedFixture', () => { expect(Object.keys(docs)).toEqual(['posts/custom-0', 'posts/custom-1']); }); + it('produces an empty fixture for count 0 and rejects negative counts', async () => { + const empty = await build( + generatedFixture(Post, { + collectionPath: 'posts', + idField: 'id', + count: 0, + }), + ); + expect(empty).toEqual({}); + + await expect( + build( + generatedFixture(Post, { + collectionPath: 'posts', + idField: 'id', + count: -1, + }), + ), + ).rejects.toThrow(/non-negative integer/); + }); + + it('rejects duplicate document IDs', async () => { + await expect( + build( + generatedFixture(Post, { + collectionPath: 'posts', + idField: 'id', + count: 2, + id: () => 'same', + }), + ), + ).rejects.toThrow(/duplicate document ID 'same'/); + }); + it('seeds a mock backend whose documents decode through a repository', () => Effect.runPromise( Effect.gen(function* () { @@ -115,6 +149,9 @@ describe('generatedFixture', () => { ]); expect(posts.length).toBe(10); for (const post of posts) { + // IDs are recovered from the document path after generatedFixture + // strips the stored idField. + expect(post.id).toMatch(/^generated-\d{4}$/); expect(typeof post.title).toBe('string'); expect(typeof post.views).toBe('number'); expect(Option.isOption(post.optional)).toBe(true); diff --git a/packages/mock/src/lib/firestore/fixture.ts b/packages/mock/src/lib/firestore/fixture.ts index 11addc47..e49b4460 100644 --- a/packages/mock/src/lib/firestore/fixture.ts +++ b/packages/mock/src/lib/firestore/fixture.ts @@ -63,7 +63,15 @@ export const fixture = < ), ); } - result[`${options.collectionPath}/${id}`] = data; + 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'], @@ -101,16 +109,29 @@ export const generatedFixture = < options: { readonly collectionPath: string; readonly idField: Id; - /** Number of documents to generate. */ + /** Number of documents to generate. Must be a non-negative integer. */ readonly count: number; /** Seed for deterministic generation. Defaults to `1`. */ readonly seed?: number; - /** Custom document ID per index. Defaults to `generated-0001`, ... */ + /** + * Custom document ID for the zero-based document index. + * The default ID uses the one-based position: `generated-0001`, ... + */ readonly id?: (index: number) => string; }, ): Fixture => ({ collectionPath: options.collectionPath, build: Effect.gen(function* () { + if (!Number.isInteger(options.count) || options.count < 0) { + return yield* Effect.die( + new Error( + `generatedFixture(${options.collectionPath}): count must be a non-negative integer, got ${options.count}`, + ), + ); + } + if (options.count === 0) { + return {}; + } const arbitrary = Schema.toArbitrary(model as Schema.Top); const samples = FastCheck.sample(arbitrary, { numRuns: options.count, @@ -128,7 +149,15 @@ export const generatedFixture = < options.id?.(index) ?? `generated-${String(index + 1).padStart(Math.max(digits, 4), '0')}`; const { [options.idField as string]: _ignored, ...data } = encoded; - result[`${options.collectionPath}/${id}`] = data; + const path = `${options.collectionPath}/${id}`; + if (path in result) { + return yield* Effect.die( + new Error( + `generatedFixture(${options.collectionPath}): duplicate document ID '${id}'`, + ), + ); + } + result[path] = data; } return result; }) as Fixture['build'], diff --git a/packages/mock/src/lib/firestore/layer.spec.ts b/packages/mock/src/lib/firestore/layer.spec.ts index 9eb1b5c4..a366116c 100644 --- a/packages/mock/src/lib/firestore/layer.spec.ts +++ b/packages/mock/src/lib/firestore/layer.spec.ts @@ -418,6 +418,32 @@ describe('layer', () => { { 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* () { diff --git a/packages/mock/src/lib/firestore/layer.ts b/packages/mock/src/lib/firestore/layer.ts index a0c79f08..9abe5e05 100644 --- a/packages/mock/src/lib/firestore/layer.ts +++ b/packages/mock/src/lib/firestore/layer.ts @@ -40,7 +40,8 @@ import { export interface LayerOptions { /** - * Fixtures to seed the backend with. + * Fixtures to seed the backend with. Only fixtures whose models require no + * encoding services are supported (`Fixture`). */ readonly fixtures?: ReadonlyArray; /** @@ -61,7 +62,11 @@ const ID_ALPHABET = const generateId: Effect.Effect = Effect.gen(function* () { let id = ''; for (let i = 0; i < 20; i++) { - const index = yield* Random.nextIntBetween(0, ID_ALPHABET.length); + // 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; @@ -180,13 +185,17 @@ const makeFirestore = ( Effect.gen(function* () { yield* validate(validateCollectionPath(path)); let id = yield* generateId; - const snapshot = yield* SubscriptionRef.get(ref); - while (snapshot.docs[`${path}/${id}`] !== undefined) { - id = yield* generateId; - } - const docPath = `${path}/${id}`; + let docPath = `${path}/${id}`; + // Collision-check against the docs the write actually sees, so a + // concurrently created document at the same path is never replaced. yield* write(path, (docs, timestamp) => - Effect.succeed({ ...docs, [docPath]: applySet(data, timestamp) }), + Effect.gen(function* () { + while (docs[docPath] !== undefined) { + id = yield* generateId; + docPath = `${path}/${id}`; + } + return { ...docs, [docPath]: applySet(data, timestamp) }; + }), ); return { id, path: docPath }; }), @@ -459,30 +468,32 @@ export const make = (options: LayerOptions = {}): MockHandle => { const ref = Effect.runSync(SubscriptionRef.make(emptySnapshot)); const latency = Effect.runSync(Ref.make(initialLatency)); const initialRef = Effect.runSync(Ref.make(emptySnapshot)); - const seeded = Effect.runSync(Ref.make(false)); const controller = makeController(ref, latency, { ref: initialRef, latency: initialLatency, }); - const seedOnce = Effect.gen(function* () { - if (yield* Ref.getAndSet(seeded, true)) { - return; - } - 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 }, - })); - }); + // 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, @@ -500,9 +511,12 @@ export const make = (options: LayerOptions = {}): MockHandle => { * An in-memory, reactive `FirestoreService` backend. * * The returned layer provides both the `FirestoreService` implementation and - * a {@link MockController} for driving it at runtime. Every `Effect.provide` - * gets a fresh, isolated store — use {@link make} instead when external code - * (like a devtools panel) needs a shared handle on the store. + * 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 diff --git a/packages/mock/src/lib/firestore/query-filter.spec.ts b/packages/mock/src/lib/firestore/query-filter.spec.ts index 1b622963..c20b3926 100644 --- a/packages/mock/src/lib/firestore/query-filter.spec.ts +++ b/packages/mock/src/lib/firestore/query-filter.spec.ts @@ -130,6 +130,25 @@ describe('applyConstraints', () => { ).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( @@ -173,6 +192,19 @@ describe('applyConstraints', () => { ).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( diff --git a/packages/mock/src/lib/firestore/query-filter.ts b/packages/mock/src/lib/firestore/query-filter.ts index 1f1c7d81..ad96637c 100644 --- a/packages/mock/src/lib/firestore/query-filter.ts +++ b/packages/mock/src/lib/firestore/query-filter.ts @@ -213,11 +213,12 @@ export const applyConstraints = ( ); } - if (limit !== undefined) { - results = results.slice(0, limit); - } + // 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/value.spec.ts b/packages/mock/src/lib/firestore/value.spec.ts index bb1c51c2..996fd144 100644 --- a/packages/mock/src/lib/firestore/value.spec.ts +++ b/packages/mock/src/lib/firestore/value.spec.ts @@ -30,6 +30,22 @@ describe('compare', () => { 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 mixed types by Firestore type rank', () => { // null < boolean < number < timestamp < string expect(compare(null, true)).toBeLessThan(0); diff --git a/packages/mock/src/lib/firestore/value.ts b/packages/mock/src/lib/firestore/value.ts index e1d88dee..cd5411cb 100644 --- a/packages/mock/src/lib/firestore/value.ts +++ b/packages/mock/src/lib/firestore/value.ts @@ -24,6 +24,7 @@ const isRecord = (value: unknown): value is Record => * @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; @@ -32,7 +33,8 @@ const rank = (value: unknown): number => { if (value instanceof FirestoreSchema.Reference) return 5; if (value instanceof FirestoreSchema.GeoPoint) return 6; if (Array.isArray(value)) return 7; - return 8; + if (isRecord(value)) return 8; + return 9; }; const compareNumbers = (a: number, b: number): number => @@ -109,7 +111,9 @@ export const compare = (a: unknown, b: unknown): number => { } return compareNumbers(aKeys.length, bKeys.length); } - return 0; + // undefined vs undefined and opaque values (sentinels, bigints, ...): + // equal only on identity, so unrelated values never compare as equal. + return a === b ? 0 : 1; }; /** From d225372260719a9ddb212d331bc730cfe9f6f0c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 14:17:33 +0000 Subject: [PATCH 09/13] fix(example): make the simulated loading state visible on mounted pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggling 'loading' in the devtools panel appeared to do nothing: the stream goes silent (correct — a stream cannot retract its last emission), and effect-atom results retain their previous value across registry.refresh AND component remounts (the new subscriber attaches in the same commit as the old one detaches, so the atom node never disposes). The stale posts stayed on screen. Fix: give reads a fresh atom identity per toggle. latestPostsAtom is now an Atom.family keyed by mockEpochAtom, which the devtools onStateChange bumps — a new epoch is a new atom that re-subscribes from Initial against the toggled state: spinner for loading, failure for error, data on recovery. Outside mock mode the epoch is always 0, so the family behaves like the previous single shared atom. Docs updated (devtools README, REACT.md §7): the previous guidance to refresh atoms only worked when the fresh subscription emits; the epoch identity pattern covers loading too. Verified in the browser: loading now shows the spinner, and empty/error/data still toggle live. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- REACT.md | 11 ++++++++--- example/app/src/app/app.tsx | 22 +++++++++++----------- example/app/src/lib/atoms.ts | 25 +++++++++++++++++++++---- example/app/src/routes/firestore.tsx | 7 ++++++- packages/devtools/README.md | 23 +++++++++++++++-------- 5 files changed, 61 insertions(+), 27 deletions(-) diff --git a/REACT.md b/REACT.md index 1c4b220b..57d313d4 100644 --- a/REACT.md +++ b/REACT.md @@ -355,9 +355,12 @@ import { firestoreMockPlugin } from '@effect-firebase/devtools'; refreshPosts(), + // `loading` streams never emit and `error` streams fail terminally + // (onSnapshot semantics), while atom results retain their previous + // value across refreshes and remounts. Bumping an epoch that keys the + // read atoms (Atom.family) gives them a fresh identity, so they + // re-subscribe from Initial against the toggled state. + onStateChange: () => bumpEpoch((epoch) => epoch + 1), }), ]} />; @@ -365,6 +368,8 @@ import { firestoreMockPlugin } from '@effect-firebase/devtools'; 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). diff --git a/example/app/src/app/app.tsx b/example/app/src/app/app.tsx index 388f25b9..9444e1b0 100644 --- a/example/app/src/app/app.tsx +++ b/example/app/src/app/app.tsx @@ -7,7 +7,7 @@ import { } from 'firebase/firestore'; import { Layer } from 'effect'; import { Client } from '@effect-firebase/client'; -import { RegistryProvider, useAtomRefresh } 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 { @@ -16,7 +16,7 @@ import { } from '@effect-firebase/devtools'; import SideMenu from '../components/menu/side-menu.js'; import MenuItem from '../components/menu/menu-item.js'; -import { firestoreLayerAtom, latestPostsAtom } from '../lib/atoms.js'; +import { firestoreLayerAtom, mockEpochAtom } from '../lib/atoms.js'; import { mockBackend } from '../lib/mock.js'; interface AppProps { @@ -31,12 +31,14 @@ 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. Mounted inside the RegistryProvider so state - * toggles can refresh the atoms whose streams ended on a simulated error - * (stream errors are terminal, matching onSnapshot semantics). + * 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 refreshPosts = useAtomRefresh(latestPostsAtom); + const bumpEpoch = useAtomSet(mockEpochAtom); const plugins = useMemo(() => { const all: Array = [ { @@ -48,16 +50,14 @@ function Devtools() { all.push( firestoreMockPlugin(mockBackend.controller, { defaultOpen: true, - onStateChange: (collectionPath) => { - if (collectionPath === 'posts' || collectionPath === '*') { - refreshPosts(); - } + onStateChange: () => { + bumpEpoch((epoch) => epoch + 1); }, }), ); } return all; - }, [refreshPosts]); + }, [bumpEpoch]); return ; } diff --git a/example/app/src/lib/atoms.ts b/example/app/src/lib/atoms.ts index ce54cbd8..fbeb3ef9 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/routes/firestore.tsx b/example/app/src/routes/firestore.tsx index 0b4cf284..95f105de 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/packages/devtools/README.md b/packages/devtools/README.md index 97af2ece..b9c6d58a 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -66,21 +66,28 @@ Both `firestoreMockPlugin(controller, options)` and `` acce `firestoreMockPlugin` additionally accepts `id`, `name` and `defaultOpen` for the TanStack Devtools shell. -### Recovering from simulated errors +### Making toggles visible on already-mounted pages -A simulated `error` fails live streams **terminally**, matching real `onSnapshot` semantics. Consumers must re-subscribe once the state recovers. Use `onStateChange` to hook your re-subscription mechanism — e.g. refreshing the atoms or queries that read from the collection: +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: (collectionPath, state) => { - if (state._tag !== 'Error') { - registry.refresh(postsAtom); // effect-atom example - } - }, + onStateChange: () => registry.update(mockEpochAtom, (epoch) => epoch + 1), }); + +// In components: +const result = useAtomValue(postsAtom(useAtomValue(mockEpochAtom))); ``` -The same applies to `loading`: an already-resolved effect keeps its value; refresh it while the collection is loading to see your initial loading UI again. +Outside mock mode the epoch never changes, so the family behaves like a single shared atom. See `example/app` for the full wiring. ## License From 3606c7de009a0314bdcb7a6b35eeec8dfa71188a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 14:24:08 +0000 Subject: [PATCH 10/13] fix(mock): antisymmetric opaque ordering and fixture ID validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - value.ts: distinct opaque values (sentinels, bigints) previously compared as 1 in both argument orders, breaking the comparator contract and making sorts engine-dependent. Objects now order by a stable first-seen identity (WeakMap), primitives by string form; 0 remains reserved for equal values. - fixture.ts: document IDs are validated as single path segments in both fixture() and generatedFixture() — an ID containing '/' would silently place the document outside the intended collection. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- .../mock/src/lib/firestore/fixture.spec.ts | 39 ++++++++++++++++++- packages/mock/src/lib/firestore/fixture.ts | 19 +++++++++ packages/mock/src/lib/firestore/value.spec.ts | 10 +++++ packages/mock/src/lib/firestore/value.ts | 39 ++++++++++++++++++- 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/packages/mock/src/lib/firestore/fixture.spec.ts b/packages/mock/src/lib/firestore/fixture.spec.ts index ee8ddcb4..f5e12a67 100644 --- a/packages/mock/src/lib/firestore/fixture.spec.ts +++ b/packages/mock/src/lib/firestore/fixture.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { Effect, Option, Schema } from 'effect'; +import { DateTime, Effect, Option, Schema } from 'effect'; import { Model } from 'effect/unstable/schema'; import { Firestore, Query } from 'effect-firebase'; -import { generatedFixture } from './fixture.js'; +import { fixture, generatedFixture } from './fixture.js'; import { layer } from './layer.js'; const PostId = Schema.String.pipe(Schema.brand('PostId')); @@ -18,6 +18,28 @@ class Post extends Model.Class('Post')({ const build = (fixture: ReturnType) => Effect.runPromise(fixture.build as Effect.Effect>); +describe('fixture', () => { + it('rejects document IDs containing a path separator', async () => { + await expect( + build( + fixture(Post, { + collectionPath: 'posts', + idField: 'id', + docs: [ + new Post({ + id: PostId.make('child/item'), + title: 'Nested', + views: 0, + createdAt: DateTime.makeUnsafe(1_000), + optional: Option.none(), + }), + ], + }), + ), + ).rejects.toThrow(/must not contain '\/'/); + }); +}); + describe('generatedFixture', () => { it('generates the requested number of schema-valid documents', async () => { const docs = await build( @@ -90,6 +112,19 @@ describe('generatedFixture', () => { } }); + it('rejects custom document IDs containing a path separator', async () => { + await expect( + build( + generatedFixture(Post, { + collectionPath: 'posts', + idField: 'id', + count: 1, + id: () => 'child/item', + }), + ), + ).rejects.toThrow(/must not contain '\/'/); + }); + it('supports custom document IDs', async () => { const docs = await build( generatedFixture(Post, { diff --git a/packages/mock/src/lib/firestore/fixture.ts b/packages/mock/src/lib/firestore/fixture.ts index e49b4460..6b55ccfa 100644 --- a/packages/mock/src/lib/firestore/fixture.ts +++ b/packages/mock/src/lib/firestore/fixture.ts @@ -20,6 +20,23 @@ export interface Fixture { >; } +/** + * Document IDs become a single path segment; a separator would silently move + * the document out of the intended collection. + */ +const validateId = ( + builder: string, + collectionPath: string, + id: string, +): Effect.Effect => + id.includes('/') + ? Effect.die( + new Error( + `${builder}(${collectionPath}): document ID '${id}' must not contain '/'`, + ), + ) + : Effect.void; + /** * 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. @@ -63,6 +80,7 @@ export const fixture = < ), ); } + yield* validateId('fixture', options.collectionPath, id); const path = `${options.collectionPath}/${id}`; if (path in result) { return yield* Effect.die( @@ -148,6 +166,7 @@ export const generatedFixture = < const id = options.id?.(index) ?? `generated-${String(index + 1).padStart(Math.max(digits, 4), '0')}`; + yield* validateId('generatedFixture', options.collectionPath, id); const { [options.idField as string]: _ignored, ...data } = encoded; const path = `${options.collectionPath}/${id}`; if (path in result) { diff --git a/packages/mock/src/lib/firestore/value.spec.ts b/packages/mock/src/lib/firestore/value.spec.ts index 996fd144..7a39dd5f 100644 --- a/packages/mock/src/lib/firestore/value.spec.ts +++ b/packages/mock/src/lib/firestore/value.spec.ts @@ -46,6 +46,16 @@ describe('compare', () => { 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)); + }); + it('orders mixed types by Firestore type rank', () => { // null < boolean < number < timestamp < string expect(compare(null, true)).toBeLessThan(0); diff --git a/packages/mock/src/lib/firestore/value.ts b/packages/mock/src/lib/firestore/value.ts index cd5411cb..f0037a33 100644 --- a/packages/mock/src/lib/firestore/value.ts +++ b/packages/mock/src/lib/firestore/value.ts @@ -112,8 +112,43 @@ export const compare = (a: unknown, b: unknown): number => { return compareNumbers(aKeys.length, bKeys.length); } // undefined vs undefined and opaque values (sentinels, bigints, ...): - // equal only on identity, so unrelated values never compare as equal. - return a === b ? 0 : 1; + // 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 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; + } + // Distinct primitives (bigints, symbols): order by their string form. + const aString = String(a); + const bString = String(b); + return aString < bString ? -1 : aString > bString ? 1 : 0; }; /** From e3fa8174d58f307c8e2c5ba054ac8a576636def5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 14:28:01 +0000 Subject: [PATCH 11/13] fix(devtools): report the effective wildcard state after clear and reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reset restores the backend's configured initial states, which may not be 'data' — the callback previously reported wildcard Data unconditionally. clear and reset now read the states back from the controller after the operation and report the resolved wildcard state. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- packages/devtools/src/lib/panel.spec.tsx | 25 ++++++++++++++++++++++++ packages/devtools/src/lib/panel.tsx | 25 +++++++++++++++--------- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/devtools/src/lib/panel.spec.tsx b/packages/devtools/src/lib/panel.spec.tsx index 6dcc477a..71429b7f 100644 --- a/packages/devtools/src/lib/panel.spec.tsx +++ b/packages/devtools/src/lib/panel.spec.tsx @@ -157,6 +157,31 @@ describe('MockDevtoolsPanel', () => { ]); }); }); + + 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', () => { diff --git a/packages/devtools/src/lib/panel.tsx b/packages/devtools/src/lib/panel.tsx index 41a4abc5..8021ee35 100644 --- a/packages/devtools/src/lib/panel.tsx +++ b/packages/devtools/src/lib/panel.tsx @@ -22,7 +22,8 @@ export interface MockDevtoolsPanelProps { * 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 - * `data` state. + * effective wildcard state after the operation (reset restores the + * backend's configured initial states, which may not be `data`). */ readonly onStateChange?: ( collectionPath: string, @@ -263,18 +264,24 @@ export function MockDevtoolsPanel({ ); }; - // Clearing the wildcard and resetting both recover erroring collections, - // so they notify with the wildcard key for consumers to refresh broadly. + // 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 => { - notifyAfter( - controller.clearState(MockState.All), - MockState.All, - MockState.data, - ); + notifyEffectiveAfter(controller.clearState(MockState.All)); }; const reset = (): void => { - notifyAfter(controller.reset, MockState.All, MockState.data); + notifyEffectiveAfter(controller.reset); }; const applyLatency = (value: number): void => { From 351117e9478e41312968e6c4ab45a62b23520bd0 Mon Sep 17 00:00:00 2001 From: Frederik Wallner Date: Tue, 11 Aug 2026 08:40:59 +0200 Subject: [PATCH 12/13] chore: clean up implementation --- packages/devtools/src/lib/panel.tsx | 60 ++---- packages/devtools/src/lib/plugin.tsx | 13 +- .../src/lib/firestore/model/datetime.ts | 2 +- .../src/lib/firestore/schema/timestamp.ts | 21 +- packages/mock/README.md | 27 +-- .../mock/src/lib/firestore/fixture.spec.ts | 186 +++--------------- packages/mock/src/lib/firestore/fixture.ts | 107 +--------- packages/mock/src/lib/firestore/layer.ts | 14 +- packages/mock/src/lib/firestore/store.ts | 33 ++-- packages/mock/src/lib/firestore/value.ts | 15 +- 10 files changed, 77 insertions(+), 401 deletions(-) diff --git a/packages/devtools/src/lib/panel.tsx b/packages/devtools/src/lib/panel.tsx index 8021ee35..54b225c5 100644 --- a/packages/devtools/src/lib/panel.tsx +++ b/packages/devtools/src/lib/panel.tsx @@ -12,11 +12,6 @@ export interface MockDevtoolsPanelProps { * `@effect-firebase/mock`. */ readonly controller: MockControllerShape; - /** - * Extra collection paths to always show, even before any document or - * state exists for them. - */ - readonly collections?: ReadonlyArray; /** * 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 @@ -49,18 +44,8 @@ const ERROR_CODES = [ 'deadline-exceeded', ] as const; -const stateName = (state: MockState.State): StateName => { - switch (state._tag) { - case 'Data': - return 'data'; - case 'Empty': - return 'empty'; - case 'Loading': - return 'loading'; - case 'Error': - return 'error'; - } -}; +const stateName = (state: MockState.State): StateName => + state._tag.toLowerCase() as StateName; /** The collection path a document path belongs to. */ const collectionOf = (docPath: string): string => @@ -91,9 +76,6 @@ const styles = { alignItems: 'center', gap: 8, flexWrap: 'wrap', - // Explicit height so host-page or devtools-shell CSS resets that stretch - // divs cannot distort the layout; same for row/buttonGroup below. - height: 'auto', paddingBottom: 10, borderBottom: '1px solid #2a2d35', marginBottom: 10, @@ -124,7 +106,6 @@ const styles = { display: 'flex', alignItems: 'center', gap: 8, - height: 'auto', padding: '4px 0', } satisfies CSSProperties, collection: { @@ -142,11 +123,9 @@ const styles = { buttonGroup: { display: 'flex', gap: 4, - height: 'auto', } satisfies CSSProperties, emptyMessage: { color: '#9ca3af', - height: 'auto', padding: '8px 0', } satisfies CSSProperties, }; @@ -179,10 +158,6 @@ const actionButtonStyle: CSSProperties = { cursor: 'pointer', }; -const runEffect = (effect: Effect.Effect): void => { - void Effect.runPromise(effect); -}; - /** * A devtools panel for the `@effect-firebase/mock` backend: toggle each * collection between data / empty / loading / error, pick the simulated @@ -193,7 +168,6 @@ const runEffect = (effect: Effect.Effect): void => { */ export function MockDevtoolsPanel({ controller, - collections, onStateChange, }: MockDevtoolsPanelProps) { const [snapshot, setSnapshot] = useState(); @@ -218,7 +192,7 @@ export function MockDevtoolsPanel({ }, [controller]); const rows = useMemo(() => { - const known = new Set(collections ?? []); + const known = new Set(); for (const docPath of Object.keys(snapshot?.docs ?? {})) { known.add(collectionOf(docPath)); } @@ -228,7 +202,7 @@ export function MockDevtoolsPanel({ } } return [...known].sort(); - }, [snapshot, collections]); + }, [snapshot]); const docCount = (collectionPath: string): number => { const prefix = `${collectionPath}/`; @@ -241,26 +215,14 @@ export function MockDevtoolsPanel({ 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 notifyAfter = ( - effect: Effect.Effect, - collectionPath: string, - state: MockState.State, - ): void => { - void Effect.runPromise(effect).then(() => { - onStateChange?.(collectionPath, state); - }); - }; - + // 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)); - notifyAfter( - controller.setState(collectionPath, state), - collectionPath, - state, + void Effect.runPromise(controller.setState(collectionPath, state)).then( + () => { + onStateChange?.(collectionPath, state); + }, ); }; @@ -288,7 +250,7 @@ export function MockDevtoolsPanel({ // 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); - runEffect(controller.setLatency(`${latency} millis`)); + void Effect.runPromise(controller.setLatency(`${latency} millis`)); }; const stateRow = (key: string, explicitOnly: boolean) => { diff --git a/packages/devtools/src/lib/plugin.tsx b/packages/devtools/src/lib/plugin.tsx index 07b22508..6164417d 100644 --- a/packages/devtools/src/lib/plugin.tsx +++ b/packages/devtools/src/lib/plugin.tsx @@ -18,14 +18,6 @@ export interface FirestoreMockPluginOptions extends Omit< MockDevtoolsPanelProps, 'controller' > { - /** - * Plugin ID shown to TanStack Devtools. Defaults to `effect-firebase-mock`. - */ - readonly id?: string; - /** - * Tab label in the devtools shell. Defaults to `Firestore Mock`. - */ - readonly name?: string; /** * Open this panel by default when the devtools shell opens. */ @@ -53,13 +45,12 @@ export const firestoreMockPlugin = ( controller: MockControllerShape, options: FirestoreMockPluginOptions = {}, ): TanStackDevtoolsReactPlugin => ({ - id: options.id ?? 'effect-firebase-mock', - name: options.name ?? 'Firestore Mock', + id: 'effect-firebase-mock', + name: 'Firestore Mock', defaultOpen: options.defaultOpen, render: ( ), diff --git a/packages/effect-firebase/src/lib/firestore/model/datetime.ts b/packages/effect-firebase/src/lib/firestore/model/datetime.ts index 75b6fe5e..3d5d0107 100644 --- a/packages/effect-firebase/src/lib/firestore/model/datetime.ts +++ b/packages/effect-firebase/src/lib/firestore/model/datetime.ts @@ -31,7 +31,7 @@ const ServerDateTimeSchema = Schema.Union([ FirestoreSchema.TimestampInstance, FirestoreSchema.ServerTimestampInstance, ]).pipe( - Schema.decodeTo(Schema.UndefinedOr(FirestoreSchema.DateTimeUtcArbitrary), { + Schema.decodeTo(Schema.UndefinedOr(Schema.DateTimeUtc), { decode: SchemaGetter.transformOrFail( (input: FirestoreSchema.Timestamp | FirestoreSchema.ServerTimestamp) => { if (input instanceof FirestoreSchema.Timestamp) { diff --git a/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts b/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts index e1297fae..c75ac247 100644 --- a/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts +++ b/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts @@ -1,22 +1,5 @@ import { DateTime, Effect, Schema, SchemaGetter, SchemaIssue } from 'effect'; -// Firestore Timestamps are only valid between 0001-01-01T00:00:00Z and -// 9999-12-31T23:59:59.999Z; derived arbitraries must stay in that range. -const FIRESTORE_MIN_MILLIS = -62135596800000; -const FIRESTORE_MAX_MILLIS = 253402300799999; - -/** - * `Schema.DateTimeUtc` restricted, for arbitrary generation, to the range a - * Firestore Timestamp can represent. Used as the decoded side of the - * timestamp codecs so `Schema.toArbitrary` on models produces storable dates. - */ -export const DateTimeUtcArbitrary = Schema.DateTimeUtc.annotate({ - toArbitrary: () => (fc) => - fc - .integer({ min: FIRESTORE_MIN_MILLIS, max: FIRESTORE_MAX_MILLIS }) - .map((millis) => DateTime.makeUnsafe(millis)), -}); - /** * Class representing a Timestamp in Firestore. */ @@ -71,7 +54,7 @@ export const TimestampInstance = Schema.instanceOf(Timestamp, { * Schema representing a timestamp as a DateTime.Utc. */ export const TimestampDateTimeUtc = TimestampInstance.pipe( - Schema.decodeTo(DateTimeUtcArbitrary, { + Schema.decodeTo(Schema.DateTimeUtc, { decode: SchemaGetter.transform((ts: Timestamp) => DateTime.makeUnsafe(ts.toMillis()), ), @@ -100,7 +83,7 @@ export const AnyTimestampDateTimeUtc = Schema.Union([ TimestampInstance, ServerTimestampInstance, ]).pipe( - Schema.decodeTo(DateTimeUtcArbitrary, { + Schema.decodeTo(Schema.DateTimeUtc, { decode: SchemaGetter.transformOrFail( (input: Timestamp | ServerTimestamp) => { if (input instanceof Timestamp) { diff --git a/packages/mock/README.md b/packages/mock/README.md index ef70cb31..0e7cbb44 100644 --- a/packages/mock/README.md +++ b/packages/mock/README.md @@ -76,37 +76,16 @@ const settings = rawFixture('settings', { }); ``` -To fill a page with volume (long lists, pagination, layout stress), `generatedFixture` derives documents from the model's schema via `Schema.toArbitrary` and fast-check — bundled with effect, no extra dependency. Generation is deterministic per seed, so data doesn't churn across reloads. Generated values satisfy the schema but read as noise; use hand-written `fixture` docs for demo-quality content — both compose in the same layer: +To fill a page with volume (long lists, pagination, layout stress), map over an array — `fixture` takes any `ReadonlyArray` of models: ```typescript -import { generatedFixture } from '@effect-firebase/mock'; - -const manyPosts = generatedFixture(PostModel, { +const manyPosts = fixture(PostModel, { collectionPath: 'posts', idField: 'id', - count: 50, - seed: 1, // optional, the default + docs: Array.from({ length: 50 }, (_, i) => makePost(i)), }); ``` -Generation is tunable per field on the schema itself. Built-in checks guide it automatically (`Schema.isBetween` keeps numbers in range, `Schema.isMinLength` bounds strings), and a `toArbitrary` annotation replaces the generator entirely: - -```typescript -class PostModel extends Model.Class('PostModel')({ - // ... - title: Schema.String.annotate({ - toArbitrary: () => (fc) => - fc.constantFrom('Getting started', 'Release notes', 'Roadmap'), - }), - views: Schema.Number.check( - Schema.isInt(), - Schema.isBetween({ minimum: 0, maximum: 5000 }), - ), -}) {} -``` - -The Firestore date/time fields (`Firestore.DateTimeInsert`, ...) are pre-annotated to generate instants within the range a Firestore `Timestamp` can actually store (years 1–9999). - ## Simulated states The layer also provides a `MockController` service for driving the backend at runtime — from tests, a dev panel, or a devtools plugin: diff --git a/packages/mock/src/lib/firestore/fixture.spec.ts b/packages/mock/src/lib/firestore/fixture.spec.ts index f5e12a67..172b4756 100644 --- a/packages/mock/src/lib/firestore/fixture.spec.ts +++ b/packages/mock/src/lib/firestore/fixture.spec.ts @@ -2,7 +2,7 @@ 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, generatedFixture } from './fixture.js'; +import { fixture, type Fixture } from './fixture.js'; import { layer } from './layer.js'; const PostId = Schema.String.pipe(Schema.brand('PostId')); @@ -15,159 +15,42 @@ class Post extends Model.Class('Post')({ optional: Firestore.OptionalDeletable(Schema.String), }) {} -const build = (fixture: ReturnType) => - Effect.runPromise(fixture.build as Effect.Effect>); +const build = (target: Fixture) => + Effect.runPromise(target.build as Effect.Effect>); -describe('fixture', () => { - it('rejects document IDs containing a path separator', async () => { - await expect( - build( - fixture(Post, { - collectionPath: 'posts', - idField: 'id', - docs: [ - new Post({ - id: PostId.make('child/item'), - title: 'Nested', - views: 0, - createdAt: DateTime.makeUnsafe(1_000), - optional: Option.none(), - }), - ], - }), - ), - ).rejects.toThrow(/must not contain '\/'/); - }); -}); - -describe('generatedFixture', () => { - it('generates the requested number of schema-valid documents', async () => { - const docs = await build( - generatedFixture(Post, { - collectionPath: 'posts', - idField: 'id', - count: 25, - }), - ); - const paths = Object.keys(docs); - expect(paths.length).toBe(25); - expect(paths[0]).toBe('posts/generated-0001'); - expect(paths.every((path) => /^posts\/generated-\d{4}$/.test(path))).toBe( - true, - ); - }); - - it('is deterministic for the same seed and diverges for another', async () => { - const options = { - collectionPath: 'posts', - idField: 'id', - count: 5, - } as const; - const a = await build(generatedFixture(Post, options)); - const b = await build(generatedFixture(Post, options)); - const c = await build(generatedFixture(Post, { ...options, seed: 2 })); - expect(a).toEqual(b); - expect(a).not.toEqual(c); +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(), }); - it('generates dates within the range a Firestore Timestamp can store', async () => { - const docs = await build( - generatedFixture(Post, { - collectionPath: 'posts', - idField: 'id', - count: 50, - }), - ); - const min = Date.parse('0001-01-01T00:00:00Z'); - const max = Date.parse('9999-12-31T23:59:59.999Z'); - for (const data of Object.values(docs)) { - const createdAt = (data as Record)[ - 'createdAt' - ]; - const millis = createdAt.toMillis(); - expect(millis).toBeGreaterThanOrEqual(min); - expect(millis).toBeLessThanOrEqual(max); - } +const posts = (options: { readonly docs: ReadonlyArray }) => + fixture(Post, { + collectionPath: 'posts', + idField: 'id', + docs: options.docs, }); - it('honors toArbitrary annotations on model fields', async () => { - const titles = ['Getting started', 'Release notes', 'Roadmap']; - - class Curated extends Model.Class('Curated')({ - id: Model.GeneratedByDb(PostId), - title: Schema.String.annotate({ - toArbitrary: () => (fc) => fc.constantFrom(...titles), - }), - }) {} - - const docs = await build( - generatedFixture(Curated, { - collectionPath: 'posts', - idField: 'id', - count: 10, - }), - ); - for (const data of Object.values(docs)) { - expect(titles).toContain((data as Record)['title']); - } +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 custom document IDs containing a path separator', async () => { + it('rejects document IDs containing a path separator', async () => { await expect( - build( - generatedFixture(Post, { - collectionPath: 'posts', - idField: 'id', - count: 1, - id: () => 'child/item', - }), - ), + build(posts({ docs: [post('child/item', 0)] })), ).rejects.toThrow(/must not contain '\/'/); }); - it('supports custom document IDs', async () => { - const docs = await build( - generatedFixture(Post, { - collectionPath: 'posts', - idField: 'id', - count: 2, - id: (index) => `custom-${index}`, - }), - ); - expect(Object.keys(docs)).toEqual(['posts/custom-0', 'posts/custom-1']); - }); - - it('produces an empty fixture for count 0 and rejects negative counts', async () => { - const empty = await build( - generatedFixture(Post, { - collectionPath: 'posts', - idField: 'id', - count: 0, - }), - ); - expect(empty).toEqual({}); - - await expect( - build( - generatedFixture(Post, { - collectionPath: 'posts', - idField: 'id', - count: -1, - }), - ), - ).rejects.toThrow(/non-negative integer/); - }); - it('rejects duplicate document IDs', async () => { await expect( - build( - generatedFixture(Post, { - collectionPath: 'posts', - idField: 'id', - count: 2, - id: () => 'same', - }), - ), + build(posts({ docs: [post('same', 1), post('same', 2)] })), ).rejects.toThrow(/duplicate document ID 'same'/); }); @@ -179,27 +62,20 @@ describe('generatedFixture', () => { idField: 'id', spanPrefix: 'test.PostRepository', }); - const posts = yield* repo.query([ + const found = yield* repo.query([ new Query.OrderBy({ field: 'views', direction: 'asc' }), ]); - expect(posts.length).toBe(10); - for (const post of posts) { - // IDs are recovered from the document path after generatedFixture - // strips the stored idField. - expect(post.id).toMatch(/^generated-\d{4}$/); - expect(typeof post.title).toBe('string'); - expect(typeof post.views).toBe('number'); - expect(Option.isOption(post.optional)).toBe(true); + 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: [ - generatedFixture(Post, { - collectionPath: 'posts', - idField: 'id', - count: 10, - }), + posts({ docs: [post('c', 3), post('a', 1), post('b', 2)] }), ], }), ), diff --git a/packages/mock/src/lib/firestore/fixture.ts b/packages/mock/src/lib/firestore/fixture.ts index 6b55ccfa..32c72201 100644 --- a/packages/mock/src/lib/firestore/fixture.ts +++ b/packages/mock/src/lib/firestore/fixture.ts @@ -1,5 +1,4 @@ import { Effect, Schema } from 'effect'; -import * as FastCheck from 'effect/testing/FastCheck'; import { Model } from 'effect/unstable/schema'; import type { DocData } from './value.js'; @@ -20,23 +19,6 @@ export interface Fixture { >; } -/** - * Document IDs become a single path segment; a separator would silently move - * the document out of the intended collection. - */ -const validateId = ( - builder: string, - collectionPath: string, - id: string, -): Effect.Effect => - id.includes('/') - ? Effect.die( - new Error( - `${builder}(${collectionPath}): document ID '${id}' must not contain '/'`, - ), - ) - : Effect.void; - /** * 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. @@ -80,99 +62,20 @@ export const fixture = < ), ); } - yield* validateId('fixture', options.collectionPath, id); - const path = `${options.collectionPath}/${id}`; - if (path in result) { + // 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}): duplicate document ID '${id}'`, + `fixture(${options.collectionPath}): document ID '${id}' must not contain '/'`, ), ); } - result[path] = data; - } - return result; - }) as Fixture['build'], -}); - -/** - * Create a fixture of documents generated from the model's schema via - * `Schema.toArbitrary` and fast-check (bundled with effect — no extra - * dependency). Useful for filling a page with volume — long lists, - * pagination, layout stress — without writing documents by hand. - * - * Generation is deterministic: the same model, `count` and `seed` produce - * the same documents on every run, so dev pages don't churn across reloads. - * Document IDs are sequential (`generated-0001`, ...) rather than sampled, - * keeping paths valid and collision-free; override with `id` if needed. - * - * Generated values satisfy the schema but read as noise (random strings, - * extreme dates). For demo-quality content, write docs with {@link fixture} - * — both are fixtures, so they compose in the same layer. - * - * @example - * ```ts - * const posts = generatedFixture(PostModel, { - * collectionPath: 'posts', - * idField: 'id', - * count: 50, - * }); - * ``` - */ -export const generatedFixture = < - S extends Model.Any, - Id extends keyof S['Type'] & keyof S['fields'], ->( - model: S, - options: { - readonly collectionPath: string; - readonly idField: Id; - /** Number of documents to generate. Must be a non-negative integer. */ - readonly count: number; - /** Seed for deterministic generation. Defaults to `1`. */ - readonly seed?: number; - /** - * Custom document ID for the zero-based document index. - * The default ID uses the one-based position: `generated-0001`, ... - */ - readonly id?: (index: number) => string; - }, -): Fixture => ({ - collectionPath: options.collectionPath, - build: Effect.gen(function* () { - if (!Number.isInteger(options.count) || options.count < 0) { - return yield* Effect.die( - new Error( - `generatedFixture(${options.collectionPath}): count must be a non-negative integer, got ${options.count}`, - ), - ); - } - if (options.count === 0) { - return {}; - } - const arbitrary = Schema.toArbitrary(model as Schema.Top); - const samples = FastCheck.sample(arbitrary, { - numRuns: options.count, - seed: options.seed ?? 1, - }); - const digits = String(Math.max(options.count, 1)).length; - const result: Record = {}; - for (const [index, doc] of samples.entries()) { - const encoded = (yield* Schema.encodeEffect(model as Schema.Top)( - doc, - )) as Record; - // Sampled IDs can be empty or contain path separators; sequential IDs - // keep paths valid and sort stably by document ID. - const id = - options.id?.(index) ?? - `generated-${String(index + 1).padStart(Math.max(digits, 4), '0')}`; - yield* validateId('generatedFixture', options.collectionPath, id); - const { [options.idField as string]: _ignored, ...data } = encoded; const path = `${options.collectionPath}/${id}`; if (path in result) { return yield* Effect.die( new Error( - `generatedFixture(${options.collectionPath}): duplicate document ID '${id}'`, + `fixture(${options.collectionPath}): duplicate document ID '${id}'`, ), ); } diff --git a/packages/mock/src/lib/firestore/layer.ts b/packages/mock/src/lib/firestore/layer.ts index 9abe5e05..e48ec311 100644 --- a/packages/mock/src/lib/firestore/layer.ts +++ b/packages/mock/src/lib/firestore/layer.ts @@ -184,18 +184,10 @@ const makeFirestore = ( add: (path, data) => Effect.gen(function* () { yield* validate(validateCollectionPath(path)); - let id = yield* generateId; - let docPath = `${path}/${id}`; - // Collision-check against the docs the write actually sees, so a - // concurrently created document at the same path is never replaced. + const id = yield* generateId; + const docPath = `${path}/${id}`; yield* write(path, (docs, timestamp) => - Effect.gen(function* () { - while (docs[docPath] !== undefined) { - id = yield* generateId; - docPath = `${path}/${id}`; - } - return { ...docs, [docPath]: applySet(data, timestamp) }; - }), + Effect.succeed({ ...docs, [docPath]: applySet(data, timestamp) }), ); return { id, path: docPath }; }), diff --git a/packages/mock/src/lib/firestore/store.ts b/packages/mock/src/lib/firestore/store.ts index 7f60fc76..efbeea52 100644 --- a/packages/mock/src/lib/firestore/store.ts +++ b/packages/mock/src/lib/firestore/store.ts @@ -53,28 +53,25 @@ export const docsInCollection = ( .map(([path, data]) => makeSnapshot(path, data)); }; -const isDocPath = (path: string): boolean => { +/** + * 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('/'); - return ( - segments.length >= 2 && - segments.length % 2 === 0 && + const parity = kind === 'document' ? 0 : 1; + return segments.length % 2 === parity && segments.every((segment) => segment.length > 0) - ); -}; - -const isCollectionPath = (path: string): boolean => { - const segments = path.split('/'); - return ( - segments.length % 2 === 1 && 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 => - isDocPath(path) - ? undefined - : `Invalid document path '${path}': expected a non-empty path with an even number of segments`; + validatePath(path, 'document'); export const validateCollectionPath = (path: string): string | undefined => - isCollectionPath(path) - ? undefined - : `Invalid collection path '${path}': expected a non-empty path with an odd number of segments`; + validatePath(path, 'collection'); diff --git a/packages/mock/src/lib/firestore/value.ts b/packages/mock/src/lib/firestore/value.ts index f0037a33..046a1a5e 100644 --- a/packages/mock/src/lib/firestore/value.ts +++ b/packages/mock/src/lib/firestore/value.ts @@ -190,7 +190,10 @@ const materialize = ( return now; } if (value instanceof Firestore.ArrayUnion) { - return dedupe(value.values.map((item) => materialize(item, now))); + return missingFrom( + [], + value.values.map((item) => materialize(item, now)), + ); } if (value instanceof Firestore.ArrayRemove) { return []; @@ -211,16 +214,6 @@ const materialize = ( return value; }; -const dedupe = (values: ReadonlyArray): Array => { - const result: Array = []; - for (const value of values) { - if (!result.some((existing) => equals(existing, value))) { - result.push(value); - } - } - return result; -}; - const applyField = ( existing: unknown, incoming: unknown, From ee22abcd34b291f0881e948a37cbb6602e3a8f74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 06:47:58 +0000 Subject: [PATCH 13/13] fix(mock): address review findings on the cleaned-up implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fixture(): validate collectionPath before building document paths — an even-segment path would seed documents unreachable from the declared collection - value.ts: distinct symbols share a string form; order them by first-seen identity so they never compare as equal - README: import Effect and Atom in the make() example - layer.ts add(): document that generating an ID without an occupancy check matches the real SDK (collision odds ~62^-20) — deliberately kept simple Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFRv4LXEZkuAb5EzX5mhV6 --- packages/mock/README.md | 2 ++ .../mock/src/lib/firestore/fixture.spec.ts | 12 ++++++++++++ packages/mock/src/lib/firestore/fixture.ts | 5 +++++ packages/mock/src/lib/firestore/layer.ts | 2 ++ packages/mock/src/lib/firestore/value.spec.ts | 7 +++++++ packages/mock/src/lib/firestore/value.ts | 19 ++++++++++++++++++- 6 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/mock/README.md b/packages/mock/README.md index 0e7cbb44..8347a786 100644 --- a/packages/mock/README.md +++ b/packages/mock/README.md @@ -128,6 +128,8 @@ const mock = layer({ `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] }); diff --git a/packages/mock/src/lib/firestore/fixture.spec.ts b/packages/mock/src/lib/firestore/fixture.spec.ts index 172b4756..dd30506a 100644 --- a/packages/mock/src/lib/firestore/fixture.spec.ts +++ b/packages/mock/src/lib/firestore/fixture.spec.ts @@ -48,6 +48,18 @@ describe('fixture', () => { ).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)] })), diff --git a/packages/mock/src/lib/firestore/fixture.ts b/packages/mock/src/lib/firestore/fixture.ts index 32c72201..7b99bcea 100644 --- a/packages/mock/src/lib/firestore/fixture.ts +++ b/packages/mock/src/lib/firestore/fixture.ts @@ -1,5 +1,6 @@ import { Effect, Schema } from 'effect'; import { Model } from 'effect/unstable/schema'; +import { validateCollectionPath } from './store.js'; import type { DocData } from './value.js'; /** @@ -47,6 +48,10 @@ export const fixture = < ): 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)( diff --git a/packages/mock/src/lib/firestore/layer.ts b/packages/mock/src/lib/firestore/layer.ts index e48ec311..cecd26b6 100644 --- a/packages/mock/src/lib/firestore/layer.ts +++ b/packages/mock/src/lib/firestore/layer.ts @@ -184,6 +184,8 @@ const makeFirestore = ( 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) => diff --git a/packages/mock/src/lib/firestore/value.spec.ts b/packages/mock/src/lib/firestore/value.spec.ts index 7a39dd5f..5ca3c563 100644 --- a/packages/mock/src/lib/firestore/value.spec.ts +++ b/packages/mock/src/lib/firestore/value.spec.ts @@ -54,6 +54,13 @@ describe('compare', () => { 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', () => { diff --git a/packages/mock/src/lib/firestore/value.ts b/packages/mock/src/lib/firestore/value.ts index 046a1a5e..1036b3da 100644 --- a/packages/mock/src/lib/firestore/value.ts +++ b/packages/mock/src/lib/firestore/value.ts @@ -135,6 +135,18 @@ const opaqueId = (value: WeakKey): number => { 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); @@ -145,7 +157,12 @@ const compareOpaque = (a: unknown, b: unknown): number => { if (aIsWeak !== bIsWeak) { return aIsWeak ? 1 : -1; } - // Distinct primitives (bigints, symbols): order by their string form. + 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;